diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 7b0fedb2..006a7d0e 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,11 +4,14 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**Recently resolved (2026-08-09):** **C93** (Issue #437) — botão "Link de import" da agenda destravado **sem filtros** (sucessor direto do C16): com zero filtros ativos, staff gera o link do feed **completo dentro do escopo de leitura do criador** — coordinator/candidate = tudo; advisor = portfólio dos municípios que administra — com fail-closed/revogação de C16 intactos. O servidor já cobria o caso (vazio = escopo do criador em `buildFeedWhere`/`resolveFeedCreatorAccess`; descrição do campo `filterMunicipality`); a entrega removeu o gate `hasFilters → disabled` de `CalendarFeedButton` (prop morto `state` incluído), manteve `hasFilters` só no "Limpar filtros" e neutralizou a copy do diálogo ("este recorte" → "a agenda"). Testes: 3 int de conteúdo sem filtro (coordinator = A+B; advisor = só o portfólio, nada fora vaza; advisor sem município → feed vazio fail-closed) + unit component (botão habilitado com zero filtros e fluxo nomear→criar). Sem migration, access, Consent ou schema. Intenção [`c93-gerar-link-import-sem-filtros.md`](plans/c93-gerar-link-import-sem-filtros.md); impl [`c93-gerar-link-import-sem-filtros-impl.md`](plans/c93-gerar-link-import-sem-filtros-impl.md). + **Recently resolved (2026-08-09):** **B176** (Issue #458) — `/campanha/municipios` ganhou três dimensões de filtro na omnibox — **Dobradinha** (nome do contato), **Liderança** (nome do contato) e **Partido** (sigla) — com recortes "Sem dobradinha"/"Sem liderança"/"Sem partido" (precedente "Sem nível"). Params aditivos `stateDeputy`/`leadership`/`party` na URL congelada (sentinelas `sem_dobradinha`/`sem_lideranca`, reuso de `sem_partido`); `buildMunicipalityListWhere` ganhou um catálogo request-scoped fail-closed (`municipalityRelationSets.ts`, lazy: metade liderança só com filtro ativo) que vira as leituras reversa (`leadership.municipalities`) e cruzada (`stateDeputy.party`) em `where` — sem migration, paginação nativa mantida; facets estendidas (`stateDeputyIDs`/`leadershipOptions`/`parties`) e mapa compartilha o catálogo. **Coluna Dobradinhas liberada ao Assessor** (staff-wide): servidor já estava scoped (B37/B157 `reloadStaffActor` + `canUpdateMunicipality`), a mudança foi só de gate de UI (lista/mobile/picker) + catálogo full-staff para a célula (decisão do gate humano 2026-08-09). Extrato do par "sentinela + `exists:false`" em `campaignListUrl` (`NO_PARTY_FILTER_VALUE`, `splitAbsenceFilterValues`, `collapseListWhereOrBranches`) com 5 call sites. E2E: assessor vê/edita a coluna no portfólio; filtros pelas três dimensões; teste de coluna-restrita invertido. Intenção [`filtros-municipios-dobradinha-lideranca.md`](plans/filtros-municipios-dobradinha-lideranca.md); impl [`filtros-municipios-dobradinha-lideranca-impl.md`](plans/filtros-municipios-dobradinha-lideranca-impl.md). **Recently resolved (2026-08-09):** **B174 reverso** (sem Issue — pedido direto) — a Sollinha (`/campanha/api/ai-chat`) voltou do `@ai-sdk/deepinfra` para o **`@ai-sdk/deepseek`** (API oficial `api.deepseek.com`): o cache-hit do V4-Flash direto custa **$0.0028/M** contra **$0.018/M** na Deep Infra (~6,4× mais barato), e a Sollinha tem prefixo estável (system prompt + tools + histórico) — o hit ratio alto derruba o custo efetivo mesmo com miss ($0.14 vs $0.09) e output ($0.28 vs $0.18) mais caros. Mesmo modelo (`deepseek-v4-flash`), mesmas tools; `@ai-sdk/deepseek` **pinado em `3.0.19`** (não `^3.0.19`, que flutuaria a 3.0.26 e puxaria a ferroviária `provider@4.0.7` duplicada — o lock ficou na `4.0.4/5.0.18` do `ai@7.0.47`, verificado no lockfile). **STT fica na Deep Infra** (B173): `whisper-large-v3` a $0.00045/min de áudio é o mais barato verificado (Groq $0.0019, OpenAI $0.006); `.env.example` agora documenta **as duas chaves** — `DEEPSEEK_API_KEY` (LLM) e `DEEPINFRA_API_KEY` (STT). Aviso da DeepSeek: aumento de preço anunciado ("em breve"). Sem migration, access, UI ou teste novo; smoke de deploy = 2–3 perguntas forçando tool calling real antes de confiar. **Recently resolved (2026-08-09):** **OPS20** (Issue #464) — o e2e de cada worktree agora usa a porta do slot próprio (`3100+slot`), nunca o fallback `3000`: o `.env.test.local` (que o `playwright.config.ts` lê em `playwright.config.ts:14`) passou a carregar `PLAYWRIGHT_BASE_URL` — antes ele só ia para o `.env.local` (que o config **não** lê), então dois `pnpm test:e2e` em worktrees paralelos subiam dois dev servers na porta `3000`. Causa-raiz tratada além do sintoma: os dois blocos env inline do `provision` (que já tinham derivado uma vez) viraram um builder puro único `worktreeEnvFileContents` em `scripts/lib/worktree-env.mjs` que deriva `NEXT_PUBLIC_SITE_URL` + `PLAYWRIGHT_BASE_URL` do mesmo `env.devPort` para **ambos** `.env.local`/`.env.test.local` (paridade estrutural, drift impossível) + unit tests de paridade em `tests/unit/worktree.unit.spec.ts` (falham se o drift voltar). `playwright.config.ts` intocado (a variável certa já existia; só faltava chegar). Sem Docker (fallback) continua degradando ao compartilhado — fora de escopo. Sem migration. Intenção [`porta-e2e-isolada-por-worktree.md`](plans/porta-e2e-isolada-por-worktree.md); impl [`porta-e2e-isolada-por-worktree-impl.md`](plans/porta-e2e-isolada-por-worktree-impl.md). + **Recently resolved (2026-08-08):** **B174** (Issue #449) — a Sollinha (`/campanha/api/ai-chat`) trocou o provider de `@ai-sdk/deepseek` (API oficial `api.deepseek.com`) pelo **`@ai-sdk/deepinfra`** no caminho OpenAI-compatível da Deep Infra — **mesmo modelo** (`deepseek-ai/DeepSeek-V4-Flash`), **mesmas tools** e **mesma chave** que os agentes do time usam (um provedor, uma chave, uma fatura). Swap de 2 linhas no `route.ts`; dep `@ai-sdk/deepinfra@3.0.20` pinada na mesma ferroviária do `ai@7.0.47` (provider 4.0.4 / provider-utils 5.0.18, `pnpm why` confirma cópia única — nada de 4.0.7 duplicado), `@ai-sdk/deepseek` removido (dead dep, knip); `.env.example` `DEEPSEEK_API_KEY` → `DEEPINFRA_API_KEY` (B173 STT/Whisper reusa a mesma chave). Sem migration, access, UI ou teste novo — e2e já mocka o SSE (sem chave real no CI); rollback documentado = `git revert` (restaura provider/baseURL antiga); smoke de deploy = 2–3 perguntas forçando tool calling real antes de confiar o flip. Intenção [`sollinha-no-deepinfra.md`](plans/sollinha-no-deepinfra.md); impl [`sollinha-no-deepinfra-impl.md`](plans/sollinha-no-deepinfra-impl.md). **Recently resolved (2026-08-08):** **B170** (Issue #441, follow-up B169) — o **contrato do colapso de chips virou spec unitário** (`tests/unit/relationChipCellCollapse.unit.spec.tsx`): `RelationChipCell` renderiza 16 chips com `getBoundingClientRect` stubado (4/linha, 20px, gap 6 — a matemática real de `COLLAPSED_CHIP_ROWS`/`CHIP_GAP_PX`) e o spec pina que o slice fica em 12 (3 linhas), "Ver mais…" expande para 16 e "Ver menos" recolhe para 12; casos tudo-cabe (8) e exatamente-cheio (12) não mostram toggle. Antes, os specs das células usavam `measureOverflow: false` ou colapsavam para 1 chip — a mudança de timing do B169 (medição em `useLayoutEffect`) não tinha regressão do comportamento de colapso. **F2 medido e deferido em vez de eliminado**: `PerformanceObserver('layout-shift')` no worktree confirma o resíduo pré-hidratação exatamente como registrado no B169 — load SSR→hidratação com shifts únicos `0.139/0.043/0.257` (fontes: shell — sidebar/Drawer/main/col-resize — nenhuma célula de chips) e a linha da célula indo `97px/42chips → 57px/0chips` numa única transição; **ordenação client-side (RSC) → zero shifts novos**. Eliminar a paint pré-hidratação exigiria estimativa de largura por label (rejeitada pelo dono, que mede layout real de propósito) ou mudança de layout/regra de colapso (anti-goal) — o shift único do primeiro load é aceitável num painel interno staff. Sem migration, dados, access ou mudança visual. Intenção [`estabilizar-lista-dobradinhas-followup.md`](docs/plans/estabilizar-lista-dobradinhas-followup.md); impl [`estabilizar-lista-dobradinhas-followup-impl.md`](docs/plans/estabilizar-lista-dobradinhas-followup-impl.md). diff --git a/docs/plans/c93-gerar-link-import-sem-filtros-impl.md b/docs/plans/c93-gerar-link-import-sem-filtros-impl.md new file mode 100644 index 00000000..eeb98023 --- /dev/null +++ b/docs/plans/c93-gerar-link-import-sem-filtros-impl.md @@ -0,0 +1,86 @@ +# Impl: C93 — Gerar link de import sem filtros (agenda completa do escopo) + +Status: aprovado +Atualizado em: 2026-08-09 +Issue: #437 +Intenção: docs/plans/c93-gerar-link-import-sem-filtros.md +Appetite restante: ~0,5 dia eng (herdado; entrega menor que o appetite) + +## Leitura da intenção + +- **Outcome:** com **nenhum filtro ativo**, o botão "Link de import" permite gerar o link; o feed sem filtro cobre a agenda completa **dentro do escopo de leitura do criador** (coordinator/candidate = tudo; advisor = municípios administrados); revogar/listar e o endpoint de leitura seguem fail-closed (invariante de C16). +- **O que NÃO negociar:** fail-closed no read do feed (segredo não vaza / criador desativado → para de servir); escopo do criador preservado no feed "sem filtro" (não é backdoor); líder lockdown; sem PII/Consent novo; revogação intacta. +- **O que reavaliar:** a hipótese da intenção ("construir feed sem filtros = só o escopo do criador" em `calendarFeed.ts`/action). **Explorando o código, o servidor já cobre o caso sem filtro por inteiro** — o único bloqueio real é o gate de UI `hasFilters → disabled`. Isso muda o formato da entrega: unlock de UI + testes que provam o aceite de conteúdo (que hoje não existem), sem tocar schema/server. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[ActivityAgendaFilters] -- para de passar hasFilters/state --> B[CalendarFeedButton] + B -- sempre habilitado, copy neutra --> C[diálogo nomear→copiar→revogar] + C --> D[createCalendarFeedLink com filtros todos vazios] + D --> E[servidor: filterMunicipality vazio = escopo do criador — já pronto] + F[int test: feed conteúdo sem filtro] --> G[coordinator=tudo / advisor=portfolio] +``` + +**Opções consideradas:** + +- **A. Unlock de UI + testes de conteúdo (recomendada):** remover o gate `hasFilters` e o prop morto `state` de `CalendarFeedButton`; manter `hasFilters` apenas para o botão "Limpar filtros"; ajustar copy neutra no diálogo; adicionar int tests (feed sem filtro respeita o escopo do criador) e um unit component test (botão habilitado sem filtros). +- **B. Médio:** como A, mas sem remover o prop `state` de `CalendarFeedButton` (mantendo `_state` morto "para o C94"). Deixa código morto num arquivo que já estamos editando. +- **C. Servidor explícito ("all" marker):** adicionar uma flag/breakpoint no schema/action tipo `scope: 'all'` para "sem filtro". Internet do work: o vazio **já significa** "tudo no escopo" (descrição do campo `filterMunicipality` na collection + `buildFeedWhere`/`resolveFeedCreatorAccess`); inventar uma representação paralela é adicionar superfície sem ganho. + +**Recomendação: A** — porque mantém o outcome da intenção com a menor superfície: o servidor já está pronto (decisão de C16), o bloqueio é puramente o `disabled` do botão; remove código morto (`state: _state`, `hasFilters` feed-only) no caminho; e os testes novos fecham a lacuna de verificação do aceite de conteúdo — hoje nenhum teste prova que um feed **sem filtro** serve a agenda completa do escopo (os int atuais só cobrem create/revoke/access, e o unit só o `generateICalFeed`). +**Rejeitadas:** B porque deixa `state: _state` morto num arquivo que editamos (C94 reconstroi a localização do controle de qualquer forma — o prop não é necessário como ponte); C porque inventa representação paralela ao contrato existente de C16 ("vazio = escopo do criador") e infla o appetite sem demand de produto. + +### Componentes / mudanças + +> **Reconciliação com o C94 (merge em main durante a execução):** o C94 moveu o +> "Link de import" do card de filtros para o header do app (ícone) + FAB mobile, +> e o gate `hasFilters → disabled` passou a viver como `canGenerate` +> (`page.tsx` → `AgendaFeedChrome` → `CalendarFeedDialog`), com o C94 documentando +> explicitamente "C93 cobre gerar sem filtro". A execução final removeu o gate +> **no estado final do C94**: o `CalendarFeedButton` antigo foi apagado (deleção +> do C94 mantida; as mudanças C93 nele tornaram-se obsoletas) e o gate virou +> remoção estrutural do `canGenerate` — sem knob morto com um único estado. + +- **`agenda/page.tsx`**: removido `canGenerateFeed` e o prop `canGenerate` do `AgendaFeedChrome` — staff sempre pode gerar (zero filtros = escopo do criador no servidor). +- **`AgendaFeedChrome`** (`src/components/campaign/activity/AgendaFeedChrome.tsx`): removido o prop `canGenerate`; ícone do header sem `disabled`/tooltip condicional. +- **`CalendarFeedDialog`** (`src/components/campaign/activity/CalendarFeedDialog.tsx`): removido o prop `canGenerate` (guard do create, aviso "Aplique filtros…" e `disabled` do "Gerar link"); copy neutra ("este recorte" → "a agenda"). +- **`activityQuickActions.ts`**: copy do FAB "Link de import" neutra (recorte → agenda). +- **Migration:** sem migration (UI/copy apenas; schema inalterado). +- **Access / Consent:** sem mudança — servidor e access de C16/C96 intactos; sem PII/Consent novo. +- **UI:** Impeccable A — encaixe nos controles do C94 (ícone header + FAB + diálogo) sem rearranjo. + +### Testes + +- **Int** (`tests/int/calendarFeed.int.spec.ts`, adicionar 2 testes, padrão `validActivityInput`/`stub` de `campaignActivity.int.spec.ts`): + 1. _Coordinator cria feed sem filtros e o feed cobre todas as municipali­dades do escopo:_ atividades `confirmado` amanhã (janela 90/365 dias) nas municipali­dades A e B → feed só com `label` → `resolveFeedCreatorAccess` = `{ accessible: true, municipalityIds: null }` → `loadFeedActivities` retorna atividades de A **e** B. + 2. _Advisor cria feed sem filtros e o feed cobre só as municipali­dades que administra:_ advisor administra A; atividades em A e B → `resolveFeedCreatorAccess` = `{ accessible: true, municipalityIds: [A] }` → `loadFeedActivities` retorna só a de A; a de B **nunca** vaza. +- **Unit component** (`tests/unit/calendarFeedDialog.unit.spec.tsx`, novo, Testing Library): o diálogo do C94 abre o formulário de criação **sem gate de filtros** (sem aviso "Aplique filtros…") e cria o feed com apenas o label via `onCreateFeed` — aceite C93 no estado final do C94. + +## Fases verificáveis + +1. **UI unlock (estado final do C94)** — `page.tsx` + `AgendaFeedChrome` + `CalendarFeedDialog` + `activityQuickActions` (remoção do gate `canGenerate` + copy neutra). +2. **Testes** — int de conteúdo de escopo (coordinator/advisor, sem filtro) + unit component do diálogo sem gate. +3. **Gates** — `pnpm gate:fast` (lint/tsc/unit), `pnpm test` (unit+int), `pnpm build` local. + +## Rabbit holes / Não escopo (engenharia) + +- **C94 (botão vira ícone no header/FAB)** — **já mergeado em main durante a execução**; a entrega reconciliou com o estado final (remoção estrutural do `canGenerate`), sem rearranjo de layout. +- **Representação explícita "all" no schema/action** — corte: vazio já é o contrato de C16; sem superfície nova. +- **Prompt de confirmação "tudo vs recorte"** — produto decidiu: feed sem filtro é direto. +- **Refactor de `hasFilters` geral / copy do placeholder do label** — fora do escopo (sem regressão visual; placeholder segue válido). + +## Riscos e mitigação + +- **Regressão em consumers do `CalendarFeedButton`:** único call site é `ActivityAgendaFilters` (grep); TS pega qualquer outro. +- **Int test depender de janela deslizante (90/365 dias):** usar `startAt` amanhã (padrão `validActivityInput`) — sempre dentro da janela. +- **Contaminação entre testes (atividades/municípios):** fixtures alocam município único via sequência e limpam `activity`/`touchedMunicipalities`; atividades criadas via `payload.create` + `fixtures().own('activity', …)`. +- **`filterDeputyPresent: false` gravado quando sem filtro:** semanticamente vazio para o leitor (`if (feed.filterDeputyPresent)`), sem efeito no conteúdo — já coberto pelo int test de conteúdo. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (botão habilitado sem filtros; feed = escopo do criador; fail-closed/revogação intactos) +- [ ] Invariantes AGENTS/engineering-standards (identificadores EN, copy pt-BR, sem toque em schema/access de C16) +- [ ] Testes: int de conteúdo de escopo sem filtro (coordinator=tudo, advisor=portfolio) + unit do botão habilitado +- [ ] `pnpm gate:fast` + `pnpm test` + `pnpm build` verdes diff --git a/src/app/(campaign)/campanha/(app)/agenda/page.tsx b/src/app/(campaign)/campanha/(app)/agenda/page.tsx index 718f68f9..f9a0a9ed 100644 --- a/src/app/(campaign)/campanha/(app)/agenda/page.tsx +++ b/src/app/(campaign)/campanha/(app)/agenda/page.tsx @@ -53,8 +53,6 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) { const accessibleHref = buildActivityAgendaHref(state) if (accessibleHref !== resolvedUrl.href) redirect(accessibleHref) - const canGenerateFeed = Boolean(state.municipality || state.deputyPresent || state.tag) - return ( { 'use server' return createCalendarFeedLink({ diff --git a/src/components/campaign/activity/AgendaFeedChrome.tsx b/src/components/campaign/activity/AgendaFeedChrome.tsx index 9d1580b9..793c2bf7 100644 --- a/src/components/campaign/activity/AgendaFeedChrome.tsx +++ b/src/components/campaign/activity/AgendaFeedChrome.tsx @@ -14,23 +14,17 @@ import { Button } from '@/components/ui/button' type AgendaFeedChromeProps = { feeds: CalendarFeedSummary[] - /** Whether the current agenda recorte has filters (feed gate, C93 handles "sem filtros"). */ - canGenerate: boolean onCreateFeed: (label: string) => Promise onRevokeFeed: (feedId: number) => Promise<{ ok: boolean }> } /** - * Agenda page → app chrome bridge for the calendar-feed surface (C94): - * registers the desktop header icon (agenda-contextual), exposes the sheet to - * the mobile FAB quick action and owns the single shared dialog instance. + * Agenda page → app chrome bridge for the calendar-feed surface (C94, gate + * unlocked by C93): registers the desktop header icon (agenda-contextual), + * exposes the sheet to the mobile FAB quick action and owns the single shared + * dialog instance. */ -export const AgendaFeedChrome = ({ - feeds, - canGenerate, - onCreateFeed, - onRevokeFeed, -}: AgendaFeedChromeProps) => { +export const AgendaFeedChrome = ({ feeds, onCreateFeed, onRevokeFeed }: AgendaFeedChromeProps) => { const { setContext } = useCampaignQuickActionContext() const [open, setOpen] = useState(false) const openFeed = useCallback(() => setOpen(true), []) @@ -50,14 +44,13 @@ export const AgendaFeedChrome = ({ size="icon" className="size-11 hidden shrink-0 md:inline-flex" aria-label="Link de import" - title={canGenerate ? 'Link de import' : 'Aplique filtros para gerar um link de import'} - disabled={!canGenerate} + title="Link de import" onClick={openFeed} > ), - [canGenerate, openFeed], + [openFeed], ) return ( @@ -68,7 +61,6 @@ export const AgendaFeedChrome = ({ open={open} onOpenChange={setOpen} feeds={feeds} - canGenerate={canGenerate} onCreateFeed={onCreateFeed} onRevokeFeed={onRevokeFeed} /> diff --git a/src/components/campaign/activity/CalendarFeedDialog.tsx b/src/components/campaign/activity/CalendarFeedDialog.tsx index 860c7752..7175a6ac 100644 --- a/src/components/campaign/activity/CalendarFeedDialog.tsx +++ b/src/components/campaign/activity/CalendarFeedDialog.tsx @@ -38,8 +38,6 @@ type CalendarFeedDialogProps = { open: boolean onOpenChange: (open: boolean) => void feeds: CalendarFeedSummary[] - /** Whether the current agenda recorte can generate a feed (has filters). */ - canGenerate: boolean onCreateFeed: (label: string) => Promise onRevokeFeed: (feedId: number) => Promise<{ ok: boolean }> } @@ -47,13 +45,14 @@ type CalendarFeedDialogProps = { /** * Calendar feed dialog (nomear → copiar → revogar). Shared by the desktop * header icon and the mobile FAB quick action — rendered once per host, opened - * by either trigger. Bottom sheet on mobile, dialog on desktop (C94). + * by either trigger. Bottom sheet on mobile, dialog on desktop (C94). Always + * enabled for staff (C93): with zero filters the feed covers the creator's + * full read scope. */ export const CalendarFeedDialog = ({ open, onOpenChange, feeds, - canGenerate, onCreateFeed, onRevokeFeed, }: CalendarFeedDialogProps) => { @@ -65,7 +64,7 @@ export const CalendarFeedDialog = ({ const [copied, setCopied] = useState(false) const handleCreate = async () => { - if (!label.trim() || !canGenerate || isCreating) return + if (!label.trim() || isCreating) return setIsCreating(true) setError(null) const result = await onCreateFeed(label.trim()) @@ -154,12 +153,6 @@ export const CalendarFeedDialog = ({ ) : (
- {!canGenerate ? ( -

- Aplique filtros à agenda para gerar um link de import. -

- ) : null} -
@@ -231,8 +220,8 @@ export const CalendarFeedDialog = ({ Sincronizar com Google Calendar - Gere um link de import para sincronizar este recorte da agenda com o Google Calendar. - Alterações em Teqo serão refletidas automaticamente. + Gere um link de import para sincronizar a agenda com o Google Calendar. Alterações em + Teqo serão refletidas automaticamente.
{body}
@@ -250,8 +239,8 @@ export const CalendarFeedDialog = ({ Sincronizar com Google Calendar - Gere um link de import para sincronizar este recorte da agenda com o Google Calendar. - Alterações em Teqo serão refletidas automaticamente. + Gere um link de import para sincronizar a agenda com o Google Calendar. Alterações em + Teqo serão refletidas automaticamente. {body} diff --git a/src/lib/activityQuickActions.ts b/src/lib/activityQuickActions.ts index 636a589d..80cada99 100644 --- a/src/lib/activityQuickActions.ts +++ b/src/lib/activityQuickActions.ts @@ -37,7 +37,7 @@ const listQuickActions = (context: CampaignQuickActionContext): readonly Campaig id: 'import-calendar', label: 'Link de import', icon: Calendar, - description: 'Gerar link para sincronizar este recorte da agenda com o Google Calendar', + description: 'Gerar link para sincronizar a agenda com o Google Calendar', onAction: context.openCalendarFeed, }, ] diff --git a/tests/int/calendarFeed.int.spec.ts b/tests/int/calendarFeed.int.spec.ts index b2e0f0e7..10ca0399 100644 --- a/tests/int/calendarFeed.int.spec.ts +++ b/tests/int/calendarFeed.int.spec.ts @@ -357,4 +357,135 @@ describe('campaign calendar feed domain', () => { const activities = await loadFeedActivities(payload, feed, access.municipalityIds) expect(activities.map((doc) => doc.id)).toContain(activity.id) }) + + it('covers every municipality of the coordinator scope in a filterless feed (C93)', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipalityA = await fixtures.getMunicipality() + const municipalityB = await fixtures.getMunicipality() + + const activityInA = await createActivityRecord( + payload, + coordinator, + validActivityInput(municipalityA.id, fixtures.value('Atividade A')), + ) + fixtures.own('activity', activityInA.id) + const activityInB = await createActivityRecord( + payload, + coordinator, + validActivityInput(municipalityB.id, fixtures.value('Atividade B')), + ) + fixtures.own('activity', activityInB.id) + + const ok = requireOk( + await createCalendarFeedLinkRecord(payload, coordinator, { label: 'Agenda completa' }), + ) + fixtures.own('calendarFeed', ok.feedId) + + const feed = await payload.findByID({ + collection: 'calendarFeed', + id: ok.feedId, + depth: 0, + overrideAccess: true, + }) + expect(feed.filterMunicipality).toBeFalsy() + + const access = await resolveFeedCreatorAccess(payload, feed) + expect(access.accessible).toBe(true) + expect(access.municipalityIds).toBeNull() + + const activities = await loadFeedActivities(payload, feed, access.municipalityIds) + const ids = activities.map((activity) => activity.id) + expect(ids).toContain(activityInA.id) + expect(ids).toContain(activityInB.id) + }) + + it('scopes a filterless feed to every municipality the advisor administers (C93)', async () => { + const fixtures = campaignFixtures() + const advisor = await fixtures.createCampaignUser('advisor') + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipalityA = await fixtures.getMunicipality() + const municipalityB = await fixtures.getMunicipality() + const municipalityC = await fixtures.getMunicipality() + await fixtures.assignMunicipalityAdvisors(municipalityA, [advisor.id]) + await fixtures.assignMunicipalityAdvisors(municipalityB, [advisor.id]) + + const activityInScopeA = await createActivityRecord( + payload, + coordinator, + validActivityInput(municipalityA.id, fixtures.value('Atividade administrada A')), + ) + fixtures.own('activity', activityInScopeA.id) + const activityInScopeB = await createActivityRecord( + payload, + coordinator, + validActivityInput(municipalityB.id, fixtures.value('Atividade administrada B')), + ) + fixtures.own('activity', activityInScopeB.id) + const activityOutOfScope = await createActivityRecord( + payload, + coordinator, + validActivityInput(municipalityC.id, fixtures.value('Atividade fora do escopo')), + ) + fixtures.own('activity', activityOutOfScope.id) + + const ok = requireOk( + await createCalendarFeedLinkRecord(payload, advisor, { label: 'Meu portfólio' }), + ) + fixtures.own('calendarFeed', ok.feedId) + + const feed = await payload.findByID({ + collection: 'calendarFeed', + id: ok.feedId, + depth: 0, + overrideAccess: true, + }) + + const access = await resolveFeedCreatorAccess(payload, feed) + expect(access.accessible).toBe(true) + const advisorMunicipalities = access.municipalityIds as number[] + expect(advisorMunicipalities).toHaveLength(2) + expect(advisorMunicipalities).toEqual( + expect.arrayContaining([municipalityA.id, municipalityB.id]), + ) + + const activities = await loadFeedActivities(payload, feed, advisorMunicipalities) + const ids = activities.map((activity) => activity.id) + expect(ids).toContain(activityInScopeA.id) + expect(ids).toContain(activityInScopeB.id) + expect(ids).not.toContain(activityOutOfScope.id) + }) + + it('serves an empty filterless feed when the advisor administers no municipality (C93)', async () => { + const fixtures = campaignFixtures() + const advisor = await fixtures.createCampaignUser('advisor') + const coordinator = await fixtures.createCampaignUser('coordinator') + const unadministered = await fixtures.getMunicipality() + + const activity = await createActivityRecord( + payload, + coordinator, + validActivityInput(unadministered.id, fixtures.value('Nunca servida')), + ) + fixtures.own('activity', activity.id) + + const ok = requireOk( + await createCalendarFeedLinkRecord(payload, advisor, { label: 'Sem municípios' }), + ) + fixtures.own('calendarFeed', ok.feedId) + + const feed = await payload.findByID({ + collection: 'calendarFeed', + id: ok.feedId, + depth: 0, + overrideAccess: true, + }) + + const access = await resolveFeedCreatorAccess(payload, feed) + expect(access.accessible).toBe(true) + expect(access.municipalityIds).toEqual([]) + + const activities = await loadFeedActivities(payload, feed, access.municipalityIds) + expect(activities).toHaveLength(0) + }) }) diff --git a/tests/unit/calendarFeedDialog.unit.spec.tsx b/tests/unit/calendarFeedDialog.unit.spec.tsx new file mode 100644 index 00000000..1b69adaf --- /dev/null +++ b/tests/unit/calendarFeedDialog.unit.spec.tsx @@ -0,0 +1,88 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { CalendarFeedDialog } from '@/components/campaign/activity/CalendarFeedDialog' +import { TooltipProvider } from '@/components/ui/tooltip' + +type DialogProps = Parameters[0] + +const renderDialog = (overrides: Partial = {}) => { + const onCreateFeed = vi.fn() + const onRevokeFeed = vi.fn() + render( + + + , + ) + return { onCreateFeed, onRevokeFeed } +} + +const matchMediaMock = vi.fn() + +beforeAll(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + Element.prototype.scrollIntoView = () => {} +}) + +beforeEach(() => { + matchMediaMock.mockImplementation((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + vi.stubGlobal('matchMedia', matchMediaMock) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +afterAll(() => { + vi.unstubAllGlobals() + Reflect.deleteProperty(Element.prototype, 'scrollIntoView') +}) + +describe('CalendarFeedDialog (C93 — no filters needed)', () => { + it('opens the naming form without any filter gate', () => { + renderDialog() + + const dialog = screen.getByRole('dialog') + expect(within(dialog).getByText('Sincronizar com Google Calendar')).toBeTruthy() + expect(within(dialog).getByLabelText('Nome do feed')).toBeTruthy() + expect(within(dialog).queryByText(/Aplique filtros/)).toBeNull() + }) + + it('creates the filterless feed through onCreateFeed with the chosen label', async () => { + const FEED_URL = 'https://teqo.dev/campanha/agenda/ical/secret' + const { onCreateFeed } = renderDialog() + onCreateFeed.mockResolvedValue({ ok: true, feedUrl: FEED_URL }) + + const dialog = screen.getByRole('dialog') + fireEvent.change(within(dialog).getByLabelText('Nome do feed'), { + target: { value: 'Agenda completa' }, + }) + fireEvent.click(within(dialog).getByRole('button', { name: 'Gerar link' })) + + await waitFor(() => expect(onCreateFeed).toHaveBeenCalledWith('Agenda completa')) + await within(dialog).findByDisplayValue(FEED_URL) + }) +})