From 7cf820f51b3182c7b945ad738829d6f2f408a788 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 10 Aug 2026 03:07:08 -0300 Subject: [PATCH] =?UTF-8?q?fix(B185):=20revis=C3=A3o=20/simplify=20?= =?UTF-8?q?=E2=80=94=20escopo=20efetivo=20de=20assessor,=20chips=20sem=20v?= =?UTF-8?q?azamento,=20DRY=20e=20conven=C3=A7=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Município/território real fora do portfólio devolve lista vazia escopada (antes erro 'não reconhecido') + escopoRestrito: true declarado na resposta - Chips de município fora do acesso omitidos (nunca id pelado) - uniqueRelationshipIds canônico no lugar do uniqueIDs local - Chaves do input zod em inglês (scope/filter/mode); Salvador sem round-trip - Pledge query restrita a lideranças engajado + dica quando truncado + prompt --- ...nha-liderancas-pendentes-abordagem-impl.md | 21 ++ src/utilities/ai/systemPrompt.ts | 1 + .../ai/tools/getPendingLeaderships.ts | 246 ++++++++---------- .../unit/pendingLeadershipsTool.unit.spec.ts | 175 ++++++++++--- 4 files changed, 261 insertions(+), 182 deletions(-) diff --git a/docs/plans/sollinha-liderancas-pendentes-abordagem-impl.md b/docs/plans/sollinha-liderancas-pendentes-abordagem-impl.md index 10a19ec1..0db5d1d4 100644 --- a/docs/plans/sollinha-liderancas-pendentes-abordagem-impl.md +++ b/docs/plans/sollinha-liderancas-pendentes-abordagem-impl.md @@ -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). diff --git a/src/utilities/ai/systemPrompt.ts b/src/utilities/ai/systemPrompt.ts index e38c30a3..38fb842f 100644 --- a/src/utilities/ai/systemPrompt.ts +++ b/src/utilities/ai/systemPrompt.ts @@ -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 diff --git a/src/utilities/ai/tools/getPendingLeaderships.ts b/src/utilities/ai/tools/getPendingLeaderships.ts index 15f95071..75ffae6c 100644 --- a/src/utilities/ai/tools/getPendingLeaderships.ts +++ b/src/utilities/ai/tools/getPendingLeaderships.ts @@ -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 | null + supportStatus: string + advisors: Array | 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 => { - 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 | null - supportStatus: string - advisors: Array | 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() - 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() 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[] => { - 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', diff --git a/tests/unit/pendingLeadershipsTool.unit.spec.ts b/tests/unit/pendingLeadershipsTool.unit.spec.ts index d1c1973b..43af73cf 100644 --- a/tests/unit/pendingLeadershipsTool.unit.spec.ts +++ b/tests/unit/pendingLeadershipsTool.unit.spec.ts @@ -17,6 +17,7 @@ const ctxFor = (user: CampaignUser, payload: Payload): AIToolContext => ({ user, const leader = stub({ collection: 'campaignUser', role: 'leader' }) const coordinator = stub({ collection: 'campaignUser', role: 'coordinator' }) +const advisor = stub({ collection: 'campaignUser', role: 'advisor' }) const scriptedPayload = (find: FindMock): Payload => stub({ 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({ collection: 'campaignUser', role: 'advisor' }) const result = (await execute(scriptedPayload(find), advisor)({})) as Record 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' }, + ]) + }) })