Merge pull request #551 from fsolla/B185-sollinha-liderancas-pendentes-de-abordagem-assessores-r

B185 — revisão /simplify: escopo efetivo de assessor + DRY na tool de lideranças pendentes
This commit is contained in:
Francisco Solla 2026-08-10 03:19:22 -03:00 committed by GitHub
commit f38ae4d63f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 261 additions and 182 deletions

View file

@ -6,6 +6,27 @@ Issue: #524
Intenção: docs/plans/sollinha-liderancas-pendentes-abordagem.md
Appetite restante: ~1 dia eng (herdado); sem migration / Consent / collection / UI nova
## Registro de execução (2026-08-10)
- **Entrega:** `getPendingLeaderships` nova (`src/utilities/ai/tools/getPendingLeaderships.ts`) + registro no `index.ts` + seção no system prompt; 18 unit tests (`tests/unit/pendingLeadershipsTool.unit.spec.ts`); allowlist P3-D justificada. Mergeado via PR #547 (Closes #524); Issue fechada `done/in-prod`.
- **Divergência da hipótese da intenção:** nenhuma nos aceites. O corte de `negativo` (fora do critério declarado) foi apresentado no gate humano e aprovado. Implementado como previsto: critério default fixo com eixo do compromisso = presença de `votePledge` no escopo; recência/`declaredAt` deixada para a B186.
- **Pós-merge, revisão /simplify (3 reviewers paralelos) — fixes aplicados em PR de follow-up:**
- Escopo efetivo para assessor: município/território/cidade real **fora do portfólio** devolve lista vazia escopada (antes: erro "não reconhecido" — mentira de existência); resposta agora declara `escopoRestrito: true` para advisor (o plano prometia "a resposta declara o escopo efetivo" e o teste do shape vazio — ambos faltavam).
- Chips de município fora do acesso: removidos (antes `{ id, nome: null }` vazava o vínculo — mesma política da UI `MunicipalityPortfolioCell`).
- `uniqueIDs` local substituído pelo canônico `uniqueRelationshipIds` (regra "edit the owner, don't twin").
- Chaves do input zod em inglês (`scope`/`filter`/`mode` — convenção de identificadores; valores continuam pt como dados).
- Salvador: ramo da cidade antes do ramo de município (elimina o round-trip garantido de miss).
- Query de pledges restrita às lideranças `engajado` (a_abordar/em_disputa nunca consultam o eixo) + guard de skip.
- `dica` de estreitar escopo quando `truncado` + linha no prompt sobre `escopoRestrito`/`truncado`.
- Tipo nomeado `PendingLeadershipDoc` no lugar do cast inline.
- **`exists: false` em hasMany `advisors`:** verificação obrigatória do plano executada **ao vivo** durante a revisão (reviewer 3, contra a DB local do worktree com linhas reais): o id-set do SQL bate com o filtro JS. Não pinado em int test (tools são unit-stubbed); risco residual zero observado.
- **Débitos deferidos (triage da revisão, sem Issue):**
- Eixo do compromisso binário por liderança (não por liderança×município): liderança engajada com pledge em 1 de 3 municípios do escopo aparece/não aparece conforme o recorte da pergunta; o critério declarado torna isso transparente. **Gatilho:** se a família B185/B186/B189 precisar de granularidade por município, adicionar flags `semCompromisso` por município na resposta.
- `getLeaderships.SUPPORT_LABELS` (duplicata privada de `supportStatusLabels`): **gatilho:** próxima edição de `getLeaderships`.
- Mensagem "Escopo não reconhecido" para ZE individual ("Salvador ZE 3"): aceitável (intenção só promete nível cidade), texto permanece.
- `loadNamesByIds` ao lado dos helpers P3-E (justificado: os P3-E usam `overrideAccess: true`; aqui o acesso do ator deve valer). Polish.
- **Perda no main (fora desta entrega):** o merge do C104 (PR #537) removeu do `docs/CHANGELOG-AGENTS.md` as entradas B183 e C102 — restauração é decisão de outra Issue (registrada no comentário da #524).
## Leitura da intenção
- **Outcome:** uma tool nova do Sollinha responde "quais lideranças ainda precisam ser abordadas" por território de identidade, cidade (Salvador = 19 ZE) ou município — com o **critério de pendência declarado na resposta**, responsáveis (assessores) à vista, links de navegação via `buildCampaignLinks`, RBAC atual (assessor = portfólio; leader = deny fail-closed), filtro "sem assessor" (C3) e modo "municípios sem liderança" (C4).

View file

@ -42,6 +42,7 @@ export const AI_SYSTEM_PROMPT = `Você é o Sollinha, assistente virtual da camp
- Use a ferramenta "getPendingLeaderships" para "quais lideranças ainda precisam ser abordadas em X", "lideranças sem assessor responsável" ou "quais municípios não têm liderança cadastrada".
- A resposta da ferramenta traz o critério de pendência — declare-o sempre na sua resposta (ex.: "critério: status 'A abordar' ou 'Em disputa'; ou 'Engajado' sem compromisso de votos no escopo consultado").
- Escopos: território de identidade (aceita grafia sem acento, ex.: "Vale do Jiquiriça"), cidade ("Salvador" = as 19 zonas eleitorais juntas) ou município. Sem escopo, vale o escopo de acesso do usuário.
- Quando a resposta trouxer "escopoRestrito: true", deixe claro que os resultados estão limitados aos municípios do portfólio do usuário; quando trouxer "truncado: true", sugira estreitar o escopo para ver o restante.
- Ofereça links de navegação (buildCampaignLinks) para as lideranças citadas (por id) e municípios (por slug); assessores (por id) quando o perfil permitir.
## Contexto eleitoral

View file

@ -6,7 +6,7 @@ import type { AIToolContext } from '@/lib/ai/types'
import { bahiaIdentityTerritories } from '@/lib/bahiaTerritories'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import { resolveMunicipalityName } from '@/lib/municipalityNameAliases'
import { relationshipId } from '@/lib/relationship'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import { salvadorCity } from '@/lib/salvadorCity'
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
import { supportStatusLabels } from '@/utilities/leadership/leadershipLabels'
@ -15,6 +15,7 @@ const DENIED_MESSAGE = 'Leitura de dados de lideranças negada.'
const PENDING_CRITERION =
'Status "A abordar" ou "Em disputa"; ou "Engajado" sem compromisso de votos no escopo consultado.'
const EMPTY_MUNICIPALITIES_CRITERION = 'Municípios do escopo sem nenhuma liderança vinculada.'
const NARROW_SCOPE_HINT = 'Estreite o escopo (território, cidade ou município) para ver o restante.'
const PENDING_SUPPORT_STATUSES = ['a_abordar', 'em_disputa', 'engajado'] as const
type ScopeMunicipality = {
@ -32,6 +33,15 @@ type ResolvedScope = {
municipalities: ScopeMunicipality[]
}
type PendingLeadershipDoc = {
id: number
contact: number | string | null
municipalities: Array<number | string> | null
supportStatus: string
advisors: Array<number | string> | null
updatedAt: string | null
}
/**
* B185 — "quais lideranças ainda precisam ser abordadas" por território,
* cidade (Salvador = 19 ZE) ou município, com critério declarado e assessores
@ -49,7 +59,7 @@ export const getPendingLeaderships = (ctx: AIToolContext) =>
'"Quais lideranças estão sem assessor responsável?" or "Quais municípios não têm liderança cadastrada?". ' +
'The pending criterion is always declared in the response; combine the ids/slugs with buildCampaignLinks.',
inputSchema: z.object({
escopo: z
scope: z
.string()
.trim()
.min(1)
@ -57,11 +67,11 @@ export const getPendingLeaderships = (ctx: AIToolContext) =>
.describe(
'Território de identidade, cidade ou município (ex.: "Vale do Jiquiriça", "Salvador", "Feira de Santana"). Omita para usar o escopo de acesso do usuário.',
),
filtro: z
filter: z
.enum(['sem_assessor'])
.optional()
.describe('Restringe a lista de lideranças às que não têm assessor responsável.'),
modo: z
mode: z
.enum(['liderancas', 'municipios_sem_lideranca'])
.optional()
.default('liderancas')
@ -77,128 +87,88 @@ export const getPendingLeaderships = (ctx: AIToolContext) =>
.default(20)
.describe('Máximo de resultados (default 20, max 50).'),
}),
execute: async ({ escopo, filtro, modo, limit }) => {
execute: async ({ scope, filter, mode, limit }) => {
if (!isStaffCampaignRole(ctx.user.role)) return { error: DENIED_MESSAGE }
const scope = await resolveScope(ctx, escopo)
if ('error' in scope) return scope
const resolved = await resolveScope(ctx, scope)
if ('error' in resolved) return resolved
if (modo === 'municipios_sem_lideranca') {
if (filtro) {
if (mode === 'municipios_sem_lideranca') {
if (filter) {
return { error: 'O filtro "sem assessor" só se aplica à lista de lideranças.' }
}
return listMunicipalitiesWithoutLeadership(ctx, scope, limit)
return listMunicipalitiesWithoutLeadership(ctx, resolved, limit)
}
return listPendingLeaderships(ctx, scope, filtro === 'sem_assessor', limit)
return listPendingLeaderships(ctx, resolved, filter === 'sem_assessor', limit)
},
})
/**
* Resolve the requested scope into its municipality set through the actor's
* own access (an advisor asking outside the portfolio gets the empty scoped
* set — never an existence lie). The generic error stays reserved for names
* that match nothing at all.
*/
const resolveScope = async (
ctx: AIToolContext,
escopo: string | undefined,
scope: string | undefined,
): Promise<ResolvedScope | { error: string }> => {
if (!escopo) {
if (!scope) {
return { kind: 'todos', name: null, municipalityIDs: null, municipalities: [] }
}
const canonicalMunicipality = resolveMunicipalityName(escopo)
if (canonicalMunicipality) {
const result = await ctx.payload.find({
collection: 'municipality',
where: { name: { equals: canonicalMunicipality } },
depth: 0,
limit: 1,
pagination: false,
select: { id: true, name: true, slug: true, city: true, region: true },
overrideAccess: false,
user: ctx.user,
})
const doc = result.docs[0]
if (doc) {
return {
kind: 'municipio',
name: doc.name,
municipalityIDs: [doc.id],
municipalities: [
{
id: doc.id,
name: doc.name,
slug: doc.slug,
city: doc.city ?? null,
region: doc.region ?? null,
},
],
}
}
// Salvador first: the virtual city is a first-class scope (19 ZE juntas) and
// the catalog has no row literally named "Salvador" — the municipality query
// would be a guaranteed-miss round trip.
if (normalizeSearchPhrase(scope) === normalizeSearchPhrase(salvadorCity.city)) {
const resolved = await resolveMunicipalities(ctx, { city: { equals: salvadorCity.city } })
return { kind: 'cidade', name: salvadorCity.name, ...resolved }
}
if (normalizeSearchPhrase(escopo) === normalizeSearchPhrase(salvadorCity.city)) {
const result = await ctx.payload.find({
collection: 'municipality',
where: { city: { equals: salvadorCity.city } },
depth: 0,
limit: 0,
pagination: false,
select: { id: true, name: true, slug: true, city: true, region: true },
overrideAccess: false,
user: ctx.user,
})
const municipalities = result.docs.map((doc) => ({
id: doc.id,
name: doc.name,
slug: doc.slug,
city: doc.city ?? null,
region: doc.region ?? null,
}))
if (municipalities.length === 0) {
return { error: `Nenhuma zona eleitoral encontrada para "${escopo}".` }
}
return {
kind: 'cidade',
name: salvadorCity.name,
municipalityIDs: municipalities.map((m) => m.id),
municipalities,
}
const canonicalMunicipality = resolveMunicipalityName(scope)
if (canonicalMunicipality) {
const resolved = await resolveMunicipalities(ctx, { name: { equals: canonicalMunicipality } })
return { kind: 'municipio', name: canonicalMunicipality, ...resolved }
}
const territory = bahiaIdentityTerritories.find(
(name) => normalizeSearchPhrase(name) === normalizeSearchPhrase(escopo),
(name) => normalizeSearchPhrase(name) === normalizeSearchPhrase(scope),
)
if (territory) {
const result = await ctx.payload.find({
collection: 'municipality',
where: { region: { equals: territory } },
depth: 0,
limit: 0,
pagination: false,
select: { id: true, name: true, slug: true, city: true, region: true },
overrideAccess: false,
user: ctx.user,
})
const municipalities = result.docs.map((doc) => ({
id: doc.id,
name: doc.name,
slug: doc.slug,
city: doc.city ?? null,
region: doc.region ?? null,
}))
if (municipalities.length === 0) {
return { error: `Nenhum município encontrado no território "${territory}".` }
}
return {
kind: 'regiao',
name: territory,
municipalityIDs: municipalities.map((m) => m.id),
municipalities,
}
const resolved = await resolveMunicipalities(ctx, { region: { equals: territory } })
return { kind: 'regiao', name: territory, ...resolved }
}
return {
error: `Escopo não reconhecido: "${escopo}". Use um município, "Salvador" ou um território de identidade da Bahia.`,
error: `Escopo não reconhecido: "${scope}". Use um município, "Salvador" ou um território de identidade da Bahia.`,
}
}
const resolveMunicipalities = async (
ctx: AIToolContext,
where: Where,
): Promise<{ municipalityIDs: number[]; municipalities: ScopeMunicipality[] }> => {
const result = await ctx.payload.find({
collection: 'municipality',
where,
depth: 0,
limit: 0,
pagination: false,
select: { id: true, name: true, slug: true, city: true, region: true },
overrideAccess: false,
user: ctx.user,
})
const municipalities = result.docs.map((doc) => ({
id: doc.id,
name: doc.name,
slug: doc.slug,
city: doc.city ?? null,
region: doc.region ?? null,
}))
return { municipalityIDs: municipalities.map((m) => m.id), municipalities }
}
const listPendingLeaderships = async (
ctx: AIToolContext,
scope: ResolvedScope,
@ -231,23 +201,18 @@ const listPendingLeaderships = async (
user: ctx.user,
})
const docs = leaderships.docs as unknown as Array<{
id: number
contact: number | string | null
municipalities: Array<number | string> | null
supportStatus: string
advisors: Array<number | string> | null
updatedAt: string | null
}>
const docs = leaderships.docs as unknown as PendingLeadershipDoc[]
const leadershipIDs = docs.map((doc) => doc.id)
// Only engaged leaderships consult the pledge axis — a_abordar/em_disputa
// are pending regardless, so their pledges are never fetched.
const engagedIDs = docs.filter((doc) => doc.supportStatus === 'engajado').map((doc) => doc.id)
const pledgeAndClauses: Where[] = [{ leadership: { in: leadershipIDs } }]
if (scope.municipalityIDs) {
pledgeAndClauses.push({ municipality: { in: scope.municipalityIDs } })
}
const pledgedLeadershipIDs = new Set<number>()
if (leadershipIDs.length > 0) {
if (engagedIDs.length > 0) {
const pledgeAndClauses: Where[] = [{ leadership: { in: engagedIDs } }]
if (scope.municipalityIDs) {
pledgeAndClauses.push({ municipality: { in: scope.municipalityIDs } })
}
const pledges = await payload.find({
collection: 'votePledge',
where: { and: pledgeAndClauses },
@ -273,9 +238,9 @@ const listPendingLeaderships = async (
const total = pending.length
const top = pending.slice(0, limit)
const contactIDs = uniqueIDs(top.flatMap((doc) => [doc.contact]))
const municipalityIDs = uniqueIDs(top.flatMap((doc) => doc.municipalities ?? []))
const advisorIDs = uniqueIDs(top.flatMap((doc) => doc.advisors ?? []))
const contactIDs = uniqueRelationshipIds(top.flatMap((doc) => [doc.contact]))
const municipalityIDs = uniqueRelationshipIds(top.flatMap((doc) => doc.municipalities ?? []))
const advisorIDs = uniqueRelationshipIds(top.flatMap((doc) => doc.advisors ?? []))
const [contactNames, municipalityNames, advisorNames] = await Promise.all([
loadNamesByIds(ctx, 'contact', contactIDs),
@ -285,35 +250,42 @@ const listPendingLeaderships = async (
const liderancas = top.map((doc) => {
const contactID = relationshipId(doc.contact)
const municipalities = (doc.municipalities ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null)
const advisors = (doc.advisors ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null)
return {
id: doc.id,
nome: contactNames.get(contactID ?? -1) ?? 'Sem nome',
municipios: municipalities.map((id) => ({
// Unresolved names mean out-of-access links (advisor portfolio): the chip
// is dropped instead of leaking a bare id — same policy as the UI.
const municipios = uniqueRelationshipIds(doc.municipalities)
.map((id) => ({
id,
nome: municipalityNames.get(id)?.name ?? null,
slug: municipalityNames.get(id)?.slug ?? null,
})),
}))
.filter((m) => m.nome !== null)
const assessores = uniqueRelationshipIds(doc.advisors).map((id) => ({
id,
nome: advisorNames.get(id) ?? null,
}))
return {
id: doc.id,
nome: contactNames.get(contactID ?? -1) ?? 'Sem nome',
municipios,
status:
supportStatusLabels[doc.supportStatus as keyof typeof supportStatusLabels] ??
doc.supportStatus,
assessores: advisors.map((id) => ({ id, nome: advisorNames.get(id) ?? null })),
assessores,
ultimaAtualizacao: doc.updatedAt ?? null,
}
})
const truncado = total > top.length
return {
escopo: { tipo: scope.kind, nome: scope.name },
escopoRestrito: ctx.user.role === 'advisor',
criterio: PENDING_CRITERION,
filtroAplicado: { semAssessor: semAssessor },
total,
liderancas,
truncado: total > top.length,
truncado,
...(truncado ? { dica: NARROW_SCOPE_HINT } : {}),
}
}
@ -360,19 +332,19 @@ const listMunicipalitiesWithoutLeadership = async (
const coveredIDs = new Set<number>()
for (const doc of covered.docs) {
for (const id of (Array.isArray(doc.municipalities) ? doc.municipalities : []).map(
relationshipId,
)) {
if (id !== null) coveredIDs.add(id)
for (const id of uniqueRelationshipIds(doc.municipalities)) {
coveredIDs.add(id)
}
}
const missing = scopeDocs.filter((doc) => !coveredIDs.has(doc.id))
const total = missing.length
const shown = missing.slice(0, limit)
const truncado = total > shown.length
return {
escopo: { tipo: scope.kind, nome: scope.name },
escopoRestrito: ctx.user.role === 'advisor',
criterio: EMPTY_MUNICIPALITIES_CRITERION,
total,
municipios: shown.map((doc) => ({
@ -382,19 +354,11 @@ const listMunicipalitiesWithoutLeadership = async (
regiao: doc.region,
cidade: doc.city,
})),
truncado: total > shown.length,
truncado,
...(truncado ? { dica: NARROW_SCOPE_HINT } : {}),
}
}
const uniqueIDs = (values: Array<number | string | null>): number[] => {
const ids: number[] = []
for (const value of values) {
const id = relationshipId(value)
if (id !== null && !ids.includes(id)) ids.push(id)
}
return ids
}
const loadNamesByIds = async (
ctx: AIToolContext,
collection: 'contact' | 'campaignUser',

View file

@ -17,6 +17,7 @@ const ctxFor = (user: CampaignUser, payload: Payload): AIToolContext => ({ user,
const leader = stub<CampaignUser>({ collection: 'campaignUser', role: 'leader' })
const coordinator = stub<CampaignUser>({ collection: 'campaignUser', role: 'coordinator' })
const advisor = stub<CampaignUser>({ collection: 'campaignUser', role: 'advisor' })
const scriptedPayload = (find: FindMock): Payload =>
stub<Payload>({ find: find as Payload['find'] })
@ -48,6 +49,14 @@ const execute =
const PENDING_STATUS_WHERE = { supportStatus: { in: ['a_abordar', 'em_disputa', 'engajado'] } }
const amargosa = {
id: 10,
name: 'Amargosa',
slug: 'amargosa',
city: 'Amargosa',
region: 'Vale do Jiquiriçá',
}
describe('getPendingLeaderships gate (B185)', () => {
it('denies a leader with the chat-shaped error before any payload query', async () => {
const payload = untouchablePayload
@ -61,6 +70,7 @@ describe('getPendingLeaderships gate (B185)', () => {
expect(result.total).toBe(0)
expect(result.liderancas).toEqual([])
expect(result.truncado).toBe(false)
expect(result.escopoRestrito).toBe(false)
expect(result.criterio).toContain('Engajado')
expect(find).toHaveBeenCalledWith(
expect.objectContaining({ collection: 'leadership', overrideAccess: false }),
@ -69,9 +79,9 @@ describe('getPendingLeaderships gate (B185)', () => {
it('lets an advisor pass (RBAC atual via access control, não pelo gate)', async () => {
const find = vi.fn().mockResolvedValue(findResult([]))
const advisor = stub<CampaignUser>({ collection: 'campaignUser', role: 'advisor' })
const result = (await execute(scriptedPayload(find), advisor)({})) as Record<string, unknown>
expect(result.total).toBe(0)
expect(result.escopoRestrito).toBe(true)
})
})
@ -108,29 +118,26 @@ describe('getPendingLeaderships pending criterion (B185)', () => {
})
expect(first.ultimaAtualizacao).toBe('2026-08-01T00:00:00.000Z')
const leadershipCall = find.mock.calls[0]![0] as { where: unknown }
const leadershipCall = find.mock.calls[0]![0] as { where: unknown; sort: unknown }
expect(leadershipCall.where).toEqual({ and: [PENDING_STATUS_WHERE] })
expect(leadershipCall.sort).toBe('-updatedAt')
// Only engaged docs consult the pledge axis; the pledge where carries the
// same access options as every other read.
const pledgeCall = find.mock.calls[1]![0] as { where: unknown; overrideAccess: boolean }
expect(pledgeCall.where).toEqual({ and: [{ leadership: { in: [2, 4] } }] })
expect(pledgeCall.overrideAccess).toBe(false)
})
it('um pledge fora do escopo não satisfaz o eixo do compromisso', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(
findResult([
{
id: 10,
name: 'Amargosa',
slug: 'amargosa',
city: 'Amargosa',
region: 'Vale do Jiquiriçá',
},
]),
)
find.mockResolvedValueOnce(findResult([amargosa]))
find.mockResolvedValueOnce(findResult([leadershipDoc({ id: 7, supportStatus: 'engajado' })]))
find.mockResolvedValueOnce(findResult([{ leadership: 7 }]))
find.mockResolvedValue(findResult([]))
const result = (await execute(scriptedPayload(find))({
escopo: 'Vale do Jiquiriça',
scope: 'Vale do Jiquiriça',
})) as { liderancas: Array<{ id: number }> }
expect(result.liderancas).toEqual([])
@ -140,27 +147,40 @@ describe('getPendingLeaderships pending criterion (B185)', () => {
and: [{ leadership: { in: [7] } }, { municipality: { in: [10] } }],
})
})
it('não consulta pledges quando não há lideranças engajadas no escopo', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(findResult([leadershipDoc({ id: 1, supportStatus: 'a_abordar' })]))
find.mockResolvedValueOnce(findResult([{ id: 101, name: 'Lider A' }]))
find.mockResolvedValue(findResult([{ id: 1, name: 'Amargosa', slug: 'amargosa' }]))
const result = (await execute(scriptedPayload(find))({})) as {
liderancas: Array<{ id: number }>
}
expect(result.liderancas).toEqual([
{
id: 1,
municipios: [{ id: 1, nome: 'Amargosa', slug: 'amargosa' }],
assessores: [],
nome: 'Lider A',
status: 'A abordar',
ultimaAtualizacao: '2026-08-01T00:00:00.000Z',
},
])
expect(find).toHaveBeenCalledTimes(3)
})
})
describe('getPendingLeaderships scope resolution (B185)', () => {
it('resolve território com tolerância a acento ("Vale do Jiquiriça" → região canônica)', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(
findResult([
{
id: 10,
name: 'Amargosa',
slug: 'amargosa',
city: 'Amargosa',
region: 'Vale do Jiquiriçá',
},
]),
)
find.mockResolvedValueOnce(findResult([amargosa]))
find.mockResolvedValueOnce(findResult([leadershipDoc({ id: 7, municipalities: [10] })]))
find.mockResolvedValue(findResult([]))
const result = (await execute(scriptedPayload(find))({
escopo: 'Vale do Jiquiriça',
scope: 'Vale do Jiquiriça',
})) as { escopo: { tipo: string; nome: string }; liderancas: unknown[] }
expect(result.escopo).toEqual({ tipo: 'regiao', nome: 'Vale do Jiquiriçá' })
@ -173,9 +193,8 @@ describe('getPendingLeaderships scope resolution (B185)', () => {
expect(result.liderancas).toHaveLength(1)
})
it('resolve "Salvador" como cidade agregando as 19 zonas', async () => {
it('resolve "Salvador" como cidade agregando as 19 zonas, sem consulta de município', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(findResult([]))
find.mockResolvedValueOnce(
findResult([
{
@ -198,13 +217,13 @@ describe('getPendingLeaderships scope resolution (B185)', () => {
find.mockResolvedValue(findResult([]))
const result = (await execute(scriptedPayload(find))({
escopo: 'Salvador',
scope: 'Salvador',
})) as { escopo: { tipo: string; nome: string } }
expect(result.escopo).toEqual({ tipo: 'cidade', nome: 'Salvador (cidade)' })
const cityCall = find.mock.calls[1]![0] as { where: unknown }
const cityCall = find.mock.calls[0]![0] as { where: unknown }
expect(cityCall.where).toEqual({ city: { equals: 'Salvador' } })
const leadershipCall = find.mock.calls[2]![0] as { where: unknown }
const leadershipCall = find.mock.calls[1]![0] as { where: unknown }
expect(leadershipCall.where).toEqual({
and: [PENDING_STATUS_WHERE, { municipalities: { in: [11, 12] } }],
})
@ -227,7 +246,7 @@ describe('getPendingLeaderships scope resolution (B185)', () => {
find.mockResolvedValue(findResult([]))
const result = (await execute(scriptedPayload(find))({
escopo: 'Feira de Santana',
scope: 'Feira de Santana',
})) as { escopo: { tipo: string; nome: string } }
expect(result.escopo).toEqual({ tipo: 'municipio', nome: 'Feira de Santana' })
@ -236,10 +255,39 @@ describe('getPendingLeaderships scope resolution (B185)', () => {
})
})
it('município real fora do portfólio do assessor devolve lista vazia escopada, sem erro', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(findResult([]))
find.mockResolvedValueOnce(findResult([]))
find.mockResolvedValue(findResult([]))
const result = (await execute(
scriptedPayload(find),
advisor,
)({
scope: 'Vale do Jiquiriça',
})) as {
escopo: { tipo: string; nome: string }
escopoRestrito: boolean
total: number
liderancas: unknown[]
error?: string
}
expect(result.error).toBeUndefined()
expect(result.escopo).toEqual({ tipo: 'regiao', nome: 'Vale do Jiquiriçá' })
expect(result.escopoRestrito).toBe(true)
expect(result.total).toBe(0)
expect(result.liderancas).toEqual([])
expect((find.mock.calls[1]![0] as { where: unknown }).where).toEqual({
and: [PENDING_STATUS_WHERE, { municipalities: { in: [] } }],
})
})
it('escopo desconhecido devolve erro sem consultar lideranças', async () => {
const find = vi.fn()
const result = (await execute(scriptedPayload(find))({
escopo: 'Lugar Inexistente',
scope: 'Lugar Inexistente',
})) as { error: string }
expect(result.error).toContain('Escopo não reconhecido')
expect(find).not.toHaveBeenCalled()
@ -253,7 +301,7 @@ describe('getPendingLeaderships filtros e modos (B185)', () => {
find.mockResolvedValue(findResult([]))
const result = (await execute(scriptedPayload(find))({
filtro: 'sem_assessor',
filter: 'sem_assessor',
})) as { filtroAplicado: { semAssessor: boolean }; liderancas: unknown[] }
expect(result.filtroAplicado).toEqual({ semAssessor: true })
@ -289,8 +337,8 @@ describe('getPendingLeaderships filtros e modos (B185)', () => {
find.mockResolvedValue(findResult([]))
const result = (await execute(scriptedPayload(find))({
escopo: 'Vale do Jiquiriça',
modo: 'municipios_sem_lideranca',
scope: 'Vale do Jiquiriça',
mode: 'municipios_sem_lideranca',
})) as {
total: number
criterio: string
@ -312,17 +360,43 @@ describe('getPendingLeaderships filtros e modos (B185)', () => {
expect(coveredCall.where).toEqual({ municipalities: { in: [1, 2, 3] } })
})
it('modo municipios_sem_lideranca sem escopo consulta todos os municípios do ator', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(
findResult([
{
id: 1,
name: 'Amargosa',
slug: 'amargosa',
city: 'Amargosa',
region: 'Vale do Jiquiriçá',
},
]),
)
find.mockResolvedValueOnce(findResult([{ municipalities: [1] }]))
find.mockResolvedValue(findResult([]))
const result = (await execute(scriptedPayload(find))({
mode: 'municipios_sem_lideranca',
})) as { total: number }
expect(result.total).toBe(0)
const allCall = find.mock.calls[0]![0] as { collection: string; where: unknown }
expect(allCall.collection).toBe('municipality')
expect(allCall.where).toEqual({})
})
it('rejeita combinar o filtro sem_assessor com o modo de municípios', async () => {
const find = vi.fn()
const result = (await execute(scriptedPayload(find))({
modo: 'municipios_sem_lideranca',
filtro: 'sem_assessor',
mode: 'municipios_sem_lideranca',
filter: 'sem_assessor',
})) as { error: string }
expect(result.error).toContain('só se aplica à lista de lideranças')
expect(find).not.toHaveBeenCalled()
})
it('trunca no limite mantendo o total real', async () => {
it('trunca no limite mantendo o total real e devolve a dica de estreitar', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(
findResult([
@ -338,11 +412,13 @@ describe('getPendingLeaderships filtros e modos (B185)', () => {
const result = (await execute(scriptedPayload(find))({ limit: 2 })) as {
total: number
truncado: boolean
dica?: string
liderancas: unknown[]
}
expect(result.total).toBe(3)
expect(result.truncado).toBe(true)
expect(result.dica).toContain('Estreite o escopo')
expect(result.liderancas).toHaveLength(2)
})
@ -351,7 +427,6 @@ describe('getPendingLeaderships filtros e modos (B185)', () => {
find.mockResolvedValueOnce(
findResult([leadershipDoc({ id: 1, advisors: [501, 502], supportStatus: 'em_disputa' })]),
)
find.mockResolvedValueOnce(findResult([]))
find.mockResolvedValueOnce(findResult([{ id: 101, name: 'Lider A' }]))
find.mockResolvedValueOnce(findResult([{ id: 1, name: 'Amargosa', slug: 'amargosa' }]))
find.mockResolvedValueOnce(
@ -369,8 +444,26 @@ describe('getPendingLeaderships filtros e modos (B185)', () => {
{ id: 501, nome: 'João Assessor' },
{ id: 502, nome: 'Maria Assessora' },
])
const advisorCall = find.mock.calls[4]![0] as { collection: string; select: unknown }
const advisorCall = find.mock.calls[3]![0] as { collection: string; select: unknown }
expect(advisorCall.collection).toBe('campaignUser')
expect(advisorCall.select).toEqual({ name: true })
})
it('municípios fora do acesso são omitidos (nunca um id pelado no chip)', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(
findResult([leadershipDoc({ id: 1, municipalities: [1, 99], supportStatus: 'a_abordar' })]),
)
find.mockResolvedValueOnce(findResult([{ id: 101, name: 'Lider A' }]))
find.mockResolvedValueOnce(findResult([{ id: 1, name: 'Amargosa', slug: 'amargosa' }]))
find.mockResolvedValue(findResult([]))
const result = (await execute(scriptedPayload(find))({})) as {
liderancas: Array<{ municipios: unknown[] }>
}
expect(result.liderancas[0]!.municipios).toEqual([
{ id: 1, nome: 'Amargosa', slug: 'amargosa' },
])
})
})