feat(S2): seção de conteúdos da home de campanha inclui os vídeos do YouTube
Some checks failed
CI (PR) / e2e (1) (pull_request) Successful in 5m9s
CI / build (push) Successful in 2m20s
CI (PR) / e2e (2) (pull_request) Successful in 5m18s
PR Ready + auto-merge / ready-automerge (pull_request) Failing after 30m17s
CI / e2e (1) (push) Successful in 4m5s
CI (PR) / scope (pull_request) Successful in 32s
CI / deploy homeserver (teqo-1313) (push) Has been cancelled
CI (PR) / lint (pull_request) Successful in 42s
CI (PR) / cycles (pull_request) Successful in 34s
CI (PR) / format (pull_request) Successful in 55s
CI (PR) / docs-guards (pull_request) Successful in 8s
CI (PR) / plans-only-closes (pull_request) Successful in 7s
Main merge flips claimed issues to done + in-prod / issue-done (pull_request) Successful in 13s
CI (PR) / typecheck (pull_request) Successful in 30s
CI (PR) / knip (pull_request) Successful in 31s
CI (PR) / unit (pull_request) Successful in 50s
CI (PR) / checks (pull_request) Successful in 2s
Promote plan-issue Related on main merge / plan-issue-ready (pull_request) Has been skipped
Agent pool supervisor / tick (pull_request) Successful in 27s
CI / static (push) Successful in 1m48s
CI / e2e (2) (push) Successful in 4m7s
CI (PR) / int (pull_request) Successful in 1m49s
CI / int (push) Successful in 1m57s
CI / checks (push) Successful in 2s
CI (PR) / build (pull_request) Successful in 2m31s

Board "Acompanhe de perto" (S1) ganha o feed automático do canal oficial:
global SocialFeedSettings (admin-only, kill switch + toggle + maxItems +
exclusões por item), loader src/utilities/youtubeFeed.ts (Data API v3
search→statistics, unstable_cache tag social-feed + revalidate 300,
fail-closed com snapshot persistido via SQL direto — revalidateTag dentro
de unstable_cache lança no Next 15.4), card generalizado
(CampaignArticleCard → CampaignContentCard, external com noopener) e merge
por recência com o grande = mais recente elegível. Stub HTTP do YouTube no
webServer do Playwright (porta dev+1000) + 4 e2e novos no describe serial.
Migration 20260818_111748_add_social_feed_settings (não destrutiva).
Closes #18
This commit is contained in:
Francisco Solla 2026-08-18 10:33:36 -03:00
parent 50749270d6
commit fe5b11cf24
25 changed files with 15424 additions and 138 deletions

1
.gitignore vendored
View file

@ -23,6 +23,7 @@
# next.js
/.next/
/.next-e2e/
/.next-e2e-check/
/out/
# production

View file

@ -1,6 +1,7 @@
# Build outputs and tool caches
.next/
.next-e2e/
.next-e2e-check/
.pnpm-store/
out/
build/

View file

@ -80,7 +80,7 @@ curl -X POST "https://<prod-domain>/api/revalidate?tag=global_privacy-policy" \
- **Auth:** send the secret via `x-revalidate-secret` or `Authorization: Bearer <secret>`; it is compared to `REVALIDATE_SECRET` with a constant-time check.
- `REVALIDATE_SECRET` must be set in the **production** env (and in your local env if you want to test the endpoint). It is documented in `.env.example`.
- **Tag:** optional `?tag=` query or JSON body `{ "tag": "..." }` (query wins). Allowlist: `posts` (default), `global_privacy-policy`, `election-tse` (bust after re-running `pnpm db:seed:tse` so the cached elections-tab reads refresh), `municipality-catalog` (bust after a migration/seed that adds or renames a município so chip indexes and `unstable_cache` loaders refresh). Unknown tag → `400`.
- **Tag:** optional `?tag=` query or JSON body `{ "tag": "..." }` (query wins). Allowlist: `posts` (default), `global_privacy-policy`, `election-tse` (bust after re-running `pnpm db:seed:tse` so the cached elections-tab reads refresh), `municipality-catalog` (bust after a migration/seed that adds or renames a município so chip indexes and `unstable_cache` loaders refresh), `social-feed` (bust after a direct-DB write to the `social-feed-settings` global — e.g. an admin-less seed of the YouTube feed config; admin edits self-revalidate). Unknown tag → `400`.
- **Responses:** `200 { revalidated: true, tag: '...' }` on success; `401` if the secret is missing/wrong; `500` if `REVALIDATE_SECRET` is not configured on the server.
- This POST is the required last step of the post-seed / direct-DB-change runbook.

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**S2** — seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br) passa a incluir os vídeos do canal oficial do YouTube (`@JorgeSollaDep`), estendendo o board da S1. Fonte automática servidor-side: global `SocialFeedSettings` nova (grupo `Configurações`, read/update admin-only — a chave da API nunca vaza pelo REST), com kill switch `enabled` ("Pausar feed"), toggle `youtubeEnabled`, chave + channel ID, `youtubeMaxItems` (1–5, default 3) e `excludedItems` (platform+id+motivo opcional — mesmo mecanismo/lugar da S3), migration `20260818_111748_add_social_feed_settings` não destrutiva (editada para conter só o DDL — o `migrate:create` regenerava drift de migrations hand-written sem sidecar JSON de 20260811). Loader `src/utilities/youtubeFeed.ts` (irmão de `posts.ts`, pinado no spec de convenções): parsers/format puros (`formatYouTubeViews` pt-BR "12,4 mil"/"1,2 mi", thumbnails maxres??high??medium), fetch Data API v3 em dois passos (search → statistics, `fetchImpl`/`YOUTUBE_API_BASE_URL` injetáveis), `unstable_cache` tag `social-feed` + `revalidate: 300` (TTL da entrada, não da página — o bug do S1 era page-level); **fail-closed com snapshot persistido por SQL direto** (`payload.db.drizzle`, sem hooks — `revalidateTag` dentro de `unstable_cache` LANÇA no Next 15.4, verificado no source; o snapshot cru pré-exclusão sobrevive a edições de settings por merge do update), exclusões aplicadas na leitura. Card generalizado `CampaignArticleCard` → `CampaignContentCard` (git mv; `external?: boolean` → `<a target="_blank" rel="noopener noreferrer">`); a seção mescla artigos visíveis + vídeos elegíveis por recência (grande = mais recente — aceite S2; degrade para o comportamento S1 sem config), meta "há X · N visualizações" (relativa < 48h, absoluta além), link "YouTube →" derivado do channelId; carrossel mobile renomeado para "Conteúdos recentes". next.config ganha `i.ytimg.com` + host local do stub no remotePatterns. Testes: 15 units novas (formatters, parsers, eligibility, fetch com fetchImpl fake); e2e com stub HTTP node (`tests/e2e/youtube-stub.mjs`, webServer do Playwright vira array, porta = dev+1000; `POST /__stub/state` alterna ok/fail; thumbnails servidas localmente) — 4 testes novos no describe serial da seção (full-state com exclusão + link externo + carrossel, fail-closed sem snapshot, snapshot persistente com maxItems mudando na API down, kill switch) + reset de settings no beforeAll; conventions specs atualizados (pin do youtubeFeed, settle após about:blank). Verificação: unit 2306 + int 791 verdes; e2e frontend 14/14 dev e 6/6 prod-mode (`E2E_PROD=1` — valida TTL+tags+snapshot no build real); lint/format/tsc/knip/cycles/build verdes. `/simplify` 3 reviewers (sem P0): P1 corrigido — label do kill switch invertido ("Pausar feed" marcado = ativo → "Feed ativo"); P2 absorvidos (className do link do header extraída, `Promise.all` posts+feed no cache frio, comentário do sentinela 0 do merge, helper único `tests/helpers/youtubeStub.ts` para a porta do stub, `waitForHomeSectionState` fundido no `waitForHomeHTML` com orçamento 12s do S1, precondição do persist documentada); débito REGISTRADO (#40, prio:P2, depends #18): o `validate` do `itemId` lê `data.platform` que é sempre undefined (Payload passa o doc inteiro; verificado no source) — produção sem validação de ID de vídeo, e o e2e depende dessa mortandade (ids do stub de 20 chars); correção em dupla documentada na issue. Descartados com justificativa: ramo maxres do search (pinned por teste), tag `social-feed` fora da convenção `global_*` (precedente election-tse/municipality-catalog), remotePattern `localhost/thumbs` incondicional (decisão deliberada dos 3 modos e2e), persist não-atômico (comentado); achados visuais do design-vision mapeados para a Issue #33 (separação visual branco-sobre-branco, fora de escopo por decisão do gate).
**S5** — separação visual da seção "Acompanhe de perto" da prova social na home de campanha: o `<section>` sai de `bg-white` (idêntico à faixa de números, seções coladas) para `border-y border-(--campaign-line) bg-(--campaign-band)` — banda `#ebe9e9` da paleta + hairlines de 12%, o desenho aprovado no wireframe (seção 7: zona de cor própria com `border-top/bottom: 1px solid var(--line)`) e no rascunho UI do gate (Cena 1/3 recomendadas). Zero mudança de conteúdo/dados/comportamento: cards continuam brancos e saltam na banda; fail-closed intacto (sem posts visíveis a seção some e Prova → Problema voltam contíguos — teste e2e existente segue verde); nenhum token novo; sem migration/Consent/access. Única mudança de runtime: 1 linha de className; + asserções `toHaveCSS` no e2e full-state da seção (fundo `#ebe9e9` exato — pega token quebrado em vez de só "≠ branco", hairline top `1px` com cor `rgb(0 0 0 / 12%)` exata, cards brancos) — sem screenshot diff (repo não usa regressão visual). Verificação: tsc/lint/format/unit 2318 verdes; e2e frontend: empty-state (contiguidade) e bento passaram com as asserções novas (2/2 runs que as alcançaram); o teste full-state falhou localmente no clique pós-minhas-asserções por cold-compile dev sob load ~68 — baseline com as mudanças stashed falha idêntico (falha pré-existente do ambiente, não regressão); build verde (migrations ok). knip local = limitação P3 conhecida (não carrega `payload.config.ts`); CI valida.
**S5** — separação visual da seção "Acompanhe de perto" da prova social na home de campanha: o `<section>` sai de `bg-white` (idêntico à faixa de números, seções coladas) para `border-y border-(--campaign-line) bg-(--campaign-band)` — banda `#ebe9e9` da paleta + hairlines de 12%, o desenho aprovado no wireframe (seção 7: zona de cor própria com `border-top/bottom: 1px solid var(--line)`) e no rascunho UI do gate (Cena 1/3 recomendadas). Zero mudança de conteúdo/dados/comportamento: cards continuam brancos e saltam na banda; fail-closed intacto (sem posts visíveis a seção some e Prova → Problema voltam contíguos — teste e2e existente segue verde); nenhum token novo; sem migration/Consent/access. Única mudança de runtime: 1 linha de className; + asserções de estilo computado no e2e full-state da seção (fundo ≠ branco ≠ prova, hairline top presente, cards brancos) — sem screenshot diff (repo não usa regressão visual). Verificação: tsc/lint/format/unit 2318 verdes; e2e frontend: empty-state (contiguidade) e bento passaram com as asserções novas (2/2 runs que as alcançaram); o teste full-state falhou localmente no clique pós-minhas-asserções por cold-compile dev sob load ~68 — baseline com as mudanças stashed falha idêntico (falha pré-existente do ambiente, não regressão); build verde (migrations ok). knip local = limitação P3 conhecida (não carrega `payload.config.ts`); CI valida.

View file

@ -0,0 +1 @@
**S2** — seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br) passa a incluir os vídeos do canal oficial do YouTube (`@JorgeSollaDep`), estendendo o board da S1. Fonte automática servidor-side: global `SocialFeedSettings` nova (grupo `Configurações`, read/update admin-only — a chave da API nunca vaza pelo REST), com kill switch `enabled` ("Pausar feed"), toggle `youtubeEnabled`, chave + channel ID, `youtubeMaxItems` (1–5, default 3) e `excludedItems` (platform+id+motivo opcional — mesmo mecanismo/lugar da S3), migration `20260818_111748_add_social_feed_settings` não destrutiva (editada para conter só o DDL — o `migrate:create` regenerava drift de migrations hand-written sem sidecar JSON de 20260811). Loader `src/utilities/youtubeFeed.ts` (irmão de `posts.ts`, pinado no spec de convenções): parsers/format puros (`formatYouTubeViews` pt-BR "12,4 mil"/"1,2 mi", thumbnails maxres??high??medium), fetch Data API v3 em dois passos (search → statistics, `fetchImpl`/`YOUTUBE_API_BASE_URL` injetáveis), `unstable_cache` tag `social-feed` + `revalidate: 300` (TTL da entrada, não da página — o bug do S1 era page-level); **fail-closed com snapshot persistido por SQL direto** (`payload.db.drizzle`, sem hooks — `revalidateTag` dentro de `unstable_cache` LANÇA no Next 15.4, verificado no source; o snapshot cru pré-exclusão sobrevive a edições de settings por merge do update), exclusões aplicadas na leitura. Card generalizado `CampaignArticleCard` → `CampaignContentCard` (git mv; `external?: boolean` → `<a target="_blank" rel="noopener noreferrer">`); a seção mescla artigos visíveis + vídeos elegíveis por recência (grande = mais recente — aceite S2; degrade para o comportamento S1 sem config), meta "há X · N visualizações" (relativa < 48h, absoluta além), link "YouTube →" derivado do channelId; carrossel mobile renomeado para "Conteúdos recentes". next.config ganha `i.ytimg.com` + host local do stub no remotePatterns. Testes: 15 units novas (formatters, parsers, eligibility, fetch com fetchImpl fake); e2e com stub HTTP node (`tests/e2e/youtube-stub.mjs`, webServer do Playwright vira array, porta = dev+1000; `POST /__stub/state` alterna ok/fail; thumbnails servidas localmente) — 4 testes novos no describe serial da seção (full-state com exclusão + link externo + carrossel, fail-closed sem snapshot, snapshot persistente com maxItems mudando na API down, kill switch) + reset de settings no beforeAll; conventions specs atualizados (pin do youtubeFeed, settle após about:blank). Verificação: unit 2306 + int 791 verdes; e2e frontend 14/14 dev e 6/6 prod-mode (`E2E_PROD=1` — valida TTL+tags+snapshot no build real); lint/format/tsc/knip/cycles/build verdes. `/simplify` 3 reviewers (sem P0): P1 corrigido — label do kill switch invertido ("Pausar feed" marcado = ativo → "Feed ativo"); P2 absorvidos (className do link do header extraída, `Promise.all` posts+feed no cache frio, comentário do sentinela 0 do merge, helper único `tests/helpers/youtubeStub.ts` para a porta do stub, `waitForHomeSectionState` fundido no `waitForHomeHTML` com orçamento 12s do S1, precondição do persist documentada); débito REGISTRADO (#40, prio:P2, depends #18): o `validate` do `itemId` lê `data.platform` que é sempre undefined (Payload passa o doc inteiro; verificado no source) — produção sem validação de ID de vídeo, e o e2e depende dessa mortandade (ids do stub de 20 chars); correção em dupla documentada na issue. Descartados com justificativa: ramo maxres do search (pinned por teste), tag `social-feed` fora da convenção `global_*` (precedente election-tse/municipality-catalog), remotePattern `localhost/thumbs` incondicional (decisão deliberada dos 3 modos e2e), persist não-atômico (comentado); achados visuais do design-vision mapeados para a Issue #33 (separação visual branco-sobre-branco, fora de escopo por decisão do gate).

View file

@ -0,0 +1,135 @@
# Impl: S2 — Seção de conteúdos na home de campanha — YouTube
Status: aprovado
Atualizado em: 2026-08-18
Issue: #18
Intenção: docs/plans/secao-conteudos-home-youtube.md
Appetite restante: ~1–2 dias eng (herdado)
## Leitura da intenção
- **Outcome:** a seção "Acompanhe de perto" (S1) passa a incluir os vídeos mais recentes do canal oficial (`@JorgeSollaDep`) via leitura servidor-side da Data API v3 com cache — card grande = último vídeo elegível (16:9, badge "YouTube", título, data relativa e visualizações no formato da plataforma), pequenos = próximos; clique abre o vídeo na plataforma em nova aba (`noopener`); admin exclui vídeos específicos por ID; API fora → último snapshot mantido (sem snapshot, os cards YT somem e a seção vive com artigos — a página nunca quebra); sem chave configurada, nada de YT aparece; mobile segue o carrossel 1/tela da S1.
- **O que NÃO negociar:** sem iframe/embed no load; nenhuma chave no cliente; clique na plataforma (sem lightbox/player inline); exclusão por item (o board pula para o próximo elegível); fail-closed silencioso (nunca erro visível); kill switch "pausar feed" do plano-site; zero mudança em schema de conteúdo público (migrações não destrutivas no máximo); convenções do repo (identificadores em inglês, admin em pt, cache por tag).
- **O que reavaliar:** a hipótese "mesmo componente de seção/card de S1" está certa e a S1 já entregou `CampaignContentSection` + `CampaignArticleCard` + `CampaignContentCarousel` — a generalização do card é um **rename com extensão** (Decisão 5), não código novo; o plano-site §4.2 já nomeia a config no admin (`SocialFeedSettings` global) — segue o nome; "card grande = último vídeo" (aceite) diverge do rascunho UI aprovado na S1 (que ilustra grande = artigo) — **Decisão 1, confirmar no gate**.
## Abordagem recomendada
```mermaid
flowchart LR
S[CampaignContentSection<br/>server] --> P[getVisiblePosts<br/>tag posts]
S --> Y[getYouTubeFeed<br/>tag social-feed<br/>revalidate 300]
Y --> G[global social-feed-settings<br/>admin-only read]
Y --> F[fetch Data API v3<br/>search + videos]
F -->|200| W[(snapshot<br/>gravado no global)]
F -->|falha| SNAP[(lê snapshot<br/>se existir)]
Y -->|null sem config| N[(sem cards YT)]
P --> M[merge por recência<br/>artigos + vídeos]
Y --> M
M --> B[Bento desktop<br/>1 grande + 4 pequenos]
M --> C[CampaignContentCarousel<br/>client · <md]
B --> CARD[CampaignContentCard<br/>generalizado · external?]
C --> CARD
S -.0 itens.-> HIDE[(não renderiza)]
```
**Opções consideradas:** A | B | C
**Recomendação:** A — um loader servidor-side `getYouTubeFeed()` em `src/utilities/youtubeFeed.ts` (irmão de `posts.ts`: parse/format puros + fetch + cache `unstable_cache` tag `social-feed` + snapshot no global), config no admin na global `social-feed-settings` (grupo `Configurações`, read/update admin-only — a chave nunca vaza via REST), e o card da S1 generalizado (`CampaignArticleCard` → `CampaignContentCard`) para servir link interno (artigo) OU externo (vídeo, `target="_blank" rel="noopener noreferrer"`); a seção mescla artigos visíveis + vídeos elegíveis por recência e fatia 5.
**Rejeitadas:** B) chave da API em env em vez do admin — mais segura, mas o plano-site §4.2 e a intenção decidiram "config no admin"; o vazamento é mitigado por read admin-only + rotação barata (anotado em Riscos); C) dois cards por fonte — viola o aceite "a estrutura de card generalizada da S1 não pode virar código duplicado por plataforma".
### Componentes / mudanças
- **Global `SocialFeedSettings`** (`src/globals/SocialFeedSettings.ts`, novo): slug `social-feed-settings`, grupo `Configurações`, `access.read`/`update` = `payloadAdminOnly`. Campos: `enabled` (checkbox "Pausar feed" — kill switch do board, default true), `youtubeEnabled` (checkbox "YouTube ativo", default true), `youtubeApiKey` (text "Chave da API (Data API v3)"), `youtubeChannelId` (text "ID do canal"), `youtubeMaxItems` (number "Máximo de vídeos", default 3, min 1, max 5), `excludedItems` (array "Itens excluídos": `platform` select `youtube|instagram`, `itemId` text com validação de ID do YouTube `^[A-Za-z0-9_-]{11}$`, `reason` text opcional — mesmo mecanismo/lugar para a S3, sem migration), `youtubeFeedSnapshot` (json, `admin.hidden: true` — último resultado cru da API). `afterChange` → `revalidateTag('social-feed')`. **Migration:** `pnpm migrate:create add_social_feed_settings` (não destrutiva).
- **`src/utilities/youtubeFeed.ts`** (novo, `server-only`): `YouTubeVideo` (`{ id, title, publishedAt, thumbnailUrl, viewCount }`); puros exportados p/ unit: `parseYouTubeSearchResponse`, `parseYouTubeVideosResponse`, `formatYouTubeViews`, `eligibleYouTubeVideos(videos, excludedIds, maxItems)`, `loadYouTubeFeed({ apiKey, channelId, maxItems, excludedIds, fetchImpl, baseUrl })` (search → ids → statistics, merge por id, throw em não-2xx/rede/JSON inválido; `baseUrl` default `https://www.googleapis.com/youtube/v3`, override `YOUTUBE_API_BASE_URL`); `getYouTubeFeed()` = `unstable_cache` tag `['social-feed']` + `revalidate: 300` — lê o global (Local API), retorna `null` quando desconfigurado/desligado (recurso off), grava `youtubeFeedSnapshot` (lista crua, pré-exclusão) em 200, lê o snapshot + aplica exclusões quando o fetch falha, `[]` sem snapshot.
- **`CampaignContentCard`** (`src/components/CampaignContentCard.tsx`, rename de `CampaignArticleCard` via `git mv`): `CampaignContentCardData` ganha `external?: boolean`; `external` → `<a href target="_blank" rel="noopener noreferrer">`, senão `<Link>`; visual idêntico (badge, capa `aspect-video`, título, meta). Carrossel e seção passam a importá-lo.
- **`CampaignContentSection`**: mescla `getVisiblePosts()` (artigos) + `getYouTubeFeed()` (vídeos) por recência decrescente (`publishedDate` vs `publishedAt`), fatia 5, `[featured, ...rest]`; meta do vídeo = `formatRelativePostDate(publishedAt) · formatYouTubeViews(viewCount) visualizações` (reusa o threshold de 48h da S1 — nunca mente congelada); link "YouTube →" (`https://www.youtube.com/channel/<channelId>`, nova aba) quando o feed está configurado (`!== null`); `null` no feed → comportamento S1 intacto; carrossel mobile idêntico (label do carrossel vira "Conteúdos recentes" — ajusta o assert do spec S1).
- **`src/app/(frontend)/api/revalidate/route.ts` + `src/utilities/revalidateRequest.ts`**: allowlist ganha `social-feed` (`REVALIDATE_SOCIAL_FEED_TAG`) — runbook pós-edição direta no banco.
- **`next.config`**: `images.remotePatterns` ganha `https://i.ytimg.com/**` (thumbnails CDN; `maxres ?? high ?? medium` do snippet).
- **`playwright.config.ts`**: `webServer` vira array — o dev server ganha `YOUTUBE_API_BASE_URL: http://localhost:<port+1000>` e um segundo entry sobe `tests/e2e/youtube-stub.mjs` (node http sem dependências: rotas `search`/`videos` com fixture canônica, `GET /__stub/health`, `POST /__stub/state` para alternar `ok|fail`). Porta = dev port + 1000 (dev ports ≤ 4099; sem colisão).
- **Access/Consent:** nenhum consent (metadados públicos de fonte externa, como posts); global com read admin-only (chave nunca no client nem no REST público); loader lê via Local API (bypass padrão — caminho público).
- **UI:** Impeccable B — estende a seção S1 com cards de vídeo (mesma estrutura); shape → craft → critique → polish no navegador (badge, thumb, hover).
### Dados → forma (se aplicável)
- Forma: views **como a plataforma mostra** (restrição da intenção) — `formatYouTubeViews` pt-BR curto: < 1.000 → inteiro, < 1M → "12,4 mil", < 1B → "1,2 mi", senão "1 bi"; decimal único com vírgula, sem `,0` à direita ("12 mil"). Data relativa reusa `formatRelativePostDate` (relativa < 48h, absoluta além — paridade S1). Rejeitadas: contador cru com separador de milhar (foge da forma da plataforma), `Intl.NumberFormat` compact (rounding varia por runtime — indeterminístico para pinar no teste), derivadas da campanha (proibido pela intenção).
## Decisões de engenharia
1. **Grande do bento quando o YouTube está ligado.**
Opções: A) merge único por recência (artigos ∪ vídeos ordenados por data; grande = mais recente de todos) | B) fiel ao rascunho UI da S1 (grande = artigo sempre; YT só nos pequenos) | C) prioridade por fonte (grande = último vídeo sempre; pequenos = próximos vídeos até `youtubeMaxItems`, depois artigos).
Recomendação: A — o aceite da S2 diz "card grande = último vídeo" e o wireframe §7 desenha o grande como o último vídeo; com o canal postando com frequência (fato da intenção), o mais recente do merge é o vídeo na prática, e quando o YT falha/sem config o merge degrada para o comportamento S1 intacto (grande = artigo) sem regra especial. Uma regra só, automática, sem curadoria implícita de fonte.
Alternativas rejeitadas: B contradiz o aceite desta Issue (o rascunho ilustra o estado final com IG, não o mix S1+S2); C cria duas regras de mistura e prioridade de fonte não pedida.
→ **Confirmar no gate** (divergência da leitura do aceite vs rascunho UI).
2. **Onde vive a config (chave/IDs + exclusões).**
Opções: A) global Payload `social-feed-settings` nova (grupo `Configurações`), read/update admin-only | B) chave em env + resto no admin | C) globals separadas por plataforma.
Recomendação: A — plano-site §4.2 nomeia a global `SocialFeedSettings`; a intenção manda "config no admin" e "exclusões no mesmo lugar" (S3 adiciona só uso, sem migration).
Alternativas rejeitadas: B foge do plano aprovado (e a key server-side não se beneficia de referrer restriction); C espalha o mecanismo de exclusão.
3. **Fail-closed com snapshot.**
Opções: A) snapshot persistido no global (`youtubeFeedSnapshot` json hidden): 200 → grava; falha → lê snapshot com exclusões aplicadas na leitura; sem snapshot → `[]` | B) sem persistência: falha → `[]`.
Recomendação: A — o aceite pede explicitamente "mantém o último snapshot em cache se existir"; com `unstable_cache` puro, uma falha cacheia `[]` e o snapshot se perde. Custo: 1 write por miss (a cada 5 min em tráfego) — desprezível. Snapshot guarda a lista crua (pré-exclusão) para exclusão nova valer mesmo com API fora.
Alternativas rejeitadas: B não atende o aceite.
4. **Cache/ISR do feed.**
Opções: A) `unstable_cache` com `tags: ['social-feed']` + `revalidate: 300` (TTL da entrada, não da página) + `afterChange` do global busta a tag | B) só tags, sem TTL | C) fetch direto sem cache.
Recomendação: A — o plano-site pede "cache ISR + revalidate periódico" (vídeos novos precisam aparecer sem edição no admin); o `revalidate` é **por entrada de cache** (o bug medido na S1 foi page-level `export const revalidate`, que trava o bust por tag — contrato diferente).
Alternativas rejeitadas: B congela o feed até edição no admin (novos vídeos nunca aparecem); C quebra LCP/consistência.
→ Verificar no e2e prod-mode (convergência pós-bust com TTL ativo); fallback documentado em Riscos.
5. **Card generalizado único.**
Opções: A) rename `CampaignArticleCard` → `CampaignContentCard` + `external?: boolean` (link externo com `target="_blank" rel="noopener noreferrer"`) | B) dois cards (artigo + vídeo) | C) card por plataforma (S3 instagram).
Recomendação: A — aceite explícito da intenção; `git mv` preserva o histórico; carrossel e bento consomem o mesmo card; a S3 só muda o badge.
Alternativas rejeitadas: B/C duplicam a estrutura e violam o aceite.
6. **Formato de visualizações.** (ver "Dados → forma") — custom pt-BR curto determinístico; rejeitados: cru, `Intl` compact.
7. **Links do header da seção.**
Opções: A) "Ver artigos →" (existente) + "YouTube →" (`https://www.youtube.com/channel/<channelId>`, nova aba) quando o feed está configurado | B) só "Ver artigos →" até a S3.
Recomendação: A — o rascunho mostra o link do YouTube no estado final; derivar do channelId não custa chamada extra.
Alternativas rejeitadas: B deixa o canal sem porta de entrada quando os cards somem (fail-closed).
8. **Mecanismo de exclusão.**
Opções: A) array único `excludedItems` (platform + itemId + reason opcional) | B) campo por plataforma (`youtubeExcludedIds` etc.).
Recomendação: A — "exclusões no mesmo lugar" (intenção); S3 entra sem migration; `reason` opcional sem workflow (corte do rabbit hole "curadoria pesada").
Alternativas rejeitadas: B força migration na S3 e espalha o mecanismo. (A variante "admin vê thumbnails e marca" é a questão aberta da S3 — fora deste item.)
9. **Kill switch.**
Opções: A) `enabled` global ("Pausar feed" — desliga as fontes externas do board) + `youtubeEnabled` por plataforma | B) só por plataforma | C) `enabled` pausa a seção inteira (artigos incluídos).
Recomendação: A — plano-site §4.2: "toggle por plataforma, pausar feed (kill switch)"; artigos são conteúdo editorial do próprio site, não o "feed" — pausar o board externo sem esconder notícias.
Alternativas rejeitadas: B sem o kill switch global do plano-site; C esconderia o conteúdo editorial do site.
10. **Test seam do fetch.**
Opções: A) `YOUTUBE_API_BASE_URL` + stub HTTP node no `webServer` do Playwright (array) | B) injetar `fetchImpl` apenas em unit | C) interceptação do Playwright no browser.
Recomendação: A + B — o fetch roda no processo do servidor (interceptação de browser não alcança); o stub alterna `ok|fail` via `POST /__stub/state`; units injetam `fetchImpl`.
Alternativas rejeitadas: C impossível (server-side); B sozinho não cobre e2e.
## Fases verificáveis
1. **Schema + loader** (~metade do appetite): global `SocialFeedSettings` + migration + `generate:types`; allowlist `social-feed`; `youtubeFeed.ts` (puros + fetch + cache + snapshot); unit tests (`formatYouTubeViews`, parsers, `eligibleYouTubeVideos`, `loadYouTubeFeed` com `fetchImpl` fake cobrindo ok/falha/malformado). Gates parciais: tsc + unit.
2. **UI + e2e** (restante): `CampaignContentCard` (rename + external), merge na seção, link "YouTube →", carrossel label, `next.config` remotePatterns; stub + `webServer` array; e2e no describe serial da seção: (a) full-state YT — settings seeded, stub ok, 1 post + 4 vídeos (o mais novo EXCLUÍDO → grande pula para o elegível), asserts: badge "YouTube", grande = vídeo mais recente elegível, meta "há X · 12,4 mil visualizações", links externos `target=_blank`+`rel=noopener`, exclusão ausente, "YouTube →" com href do canal, carrossel mobile 1/tela com YT; (b) fail-closed sem snapshot — stub fail, settings ligados → cards YT ausentes, seção viva com artigo; (c) snapshot — stub ok → cards; stub fail + bust → cards persistem; (d) kill switch — `enabled=false` → YT ausente; (e) ajustes no spec S1 (label do carrossel, reset de settings no beforeAll para o empty-state continuar determinístico). Cleanup: reset do global + stub ok + revalidate + polling positivo (padrão S1). Polish no navegador (desktop + 390px).
3. **Gates finais**: `pnpm gate:fast` (tsc, lint, format:check, knip, check:cycles), `pnpm test`, `pnpm build` local, e2e dev + prod (`E2E_PROD=1`); changelog `docs/changelog/2026-08-18-s2.md` + `pnpm changelog:build`; AGENTS.md (allowlist da revalidação); commit em main via PR.
## Rabbit holes / Não escopo (engenharia)
- Não adicionar descrição/legenda do vídeo no card (aceite pede título/data/views; wireframe mostra "quando houver" — corte por build less).
- Não criar paginação/playlists/ordenação manual; `youtubeMaxItems` é o único controle de volume.
- Não usar `Intl` compact; não derivar métricas (nada além de viewCount da API).
- Não tocar `CampaignCarousel` (problem/flags), nem as geometrias clampadas; S3/S4 fora.
- Não mexer na API de `getVisiblePosts`; merge fatia 5 no componente.
- Sem endpoint novo além do stub de teste (fora do build de produção — `tests/e2e/`).
## Riscos e mitigação
- **TTL + tags no Next 15.4 (precedente S1 page-level).** O `revalidate: 300` aqui é por entrada de `unstable_cache`, não segmento de rota; ainda assim, o e2e prod-mode valida convergência pós-bust com TTL ativo. Fallback documentado: sem TTL na entrada, adicionar um `revalidate` periódico... se o mesmo bug se reproduzir, o feed passa a depender do snapshot + bust manual (anotar no impl plan).
- **Chave da API no banco.** Mitigado por read/update admin-only (REST não expõe; Local API é o único caminho de leitura pública e nunca renderiza a chave); rotação barata (1 campo no admin). Nota de produto no gate.
- **Write em render (snapshot).** 1 update por miss (5 min); também roda no build estático (mesmo DB, idempotente); sem transação (escrita única).
- **E2E prod-mode local com settings residuais.** O build pré-playwright pode renderizar com config antiga → fetch real (sem stub) → falha → snapshot; o beforeAll do describe reseta settings e o polling converge — determinístico.
- **Paralelismo do e2e compartilhado.** O describe da seção já é `mode: 'serial'`; testes S2 resetam settings no beforeAll e no finally (padrão S1).
- **Thumbnails.** `i.ytimg.com` precisa entrar em `remotePatterns`; fallback `maxres ?? high ?? medium` (maxres às vezes 404); alt = título do vídeo.
- **Home vira dependente de mais uma tag.** `social-feed` busta a home junto (tag rastreada na render); runbook de escrita direta ganha o tag na allowlist.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (grande = último vídeo elegível, badge/título/data/views, clique na plataforma com noopener, exclusão por item, fail-closed com snapshot, kill switch, sem chave → nada de YT, mobile 1/tela)
- [ ] Invariantes AGENTS/engineering-standards (sem migration destrutiva; sem consent novo; identificadores em inglês; copy/admin em pt; cache por tag; access admin-only na config com segredo)
- [ ] Testes de domínio previstos: unit (formatters, parsers, eligibility, fetch com fetchImpl) + e2e (full-state YT, exclusão, fail-closed sem snapshot, snapshot persistente, kill switch, regressão S1)

View file

@ -7,7 +7,15 @@ const nextConfig = {
distDir: process.env.NEXT_DIST_DIR || '.next',
...(process.env.NEXT_OUTPUT_STANDALONE === '1' ? { output: 'standalone' } : {}),
images: {
remotePatterns: [new URL(`${allowedImageHost}/**`)],
remotePatterns: [
new URL(`${allowedImageHost}/**`),
// YouTube video thumbnails (campaign home content board, S2) come from
// the platform's CDN — see `src/utilities/youtubeFeed.ts`.
new URL('https://i.ytimg.com/**'),
// e2e-only: the YouTube stub (tests/e2e/youtube-stub.mjs) serves the
// fixture thumbnails locally so specs never touch the real network.
{ protocol: 'http', hostname: 'localhost', pathname: '/thumbs/**' },
],
},
}

View file

@ -6,6 +6,7 @@ import {
GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV_NAME,
GOOGLE_CALENDAR_TEST_KEY,
} from './tests/helpers/googleCalendarTestKey'
import { youtubeStubUrlFor } from './tests/helpers/youtubeStub'
/**
* e2e tests boot a real dev server that seeds/deletes records, so they must run
@ -26,6 +27,15 @@ if (process.env.TEQO_TEST_DATABASE_URL) {
assertTestDatabase(process.env.DATABASE_URL)
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
const webServerPort = new URL(baseURL).port || (baseURL.startsWith('https:') ? '443' : '80')
/*
* Port of the YouTube Data API stub the content board (S2) fetches in e2e.
* dev ports live in 3100..4099 (worktree slots, capped at 999), so +1000
* never collides with another worktree's dev server; CI stays on 4000. The
* derivation lives once in `tests/e2e/helpers/youtubeStub.ts` (the spec uses
* it to flip the stub state).
*/
const youtubeStubUrl = youtubeStubUrlFor(baseURL)
const youtubeStubPort = new URL(youtubeStubUrl).port
const workersOverride = process.env.PLAYWRIGHT_WORKERS
const workers = workersOverride === undefined ? 2 : Number(workersOverride)
if (!Number.isInteger(workers) || workers < 2) {
@ -120,7 +130,8 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
},
],
webServer: {
webServer: [
{
/*
* CI and E2E_PROD=1 serve the PRODUCTION build (`pnpm start` after a
* `pnpm build` step in the e2e job): dev-mode cold webpack compiles per
@ -162,6 +173,20 @@ export default defineConfig({
// Lets e2e specs bust the `posts` tag after direct REST deletes (cleanup),
// mirroring the documented post-seed runbook against the deployed site.
REVALIDATE_SECRET: process.env.REVALIDATE_SECRET ?? 'e2e-revalidate-secret',
// The content board (S2) fetches the YouTube Data API through the local
// stub below instead of the real network (deterministic fixtures).
YOUTUBE_API_BASE_URL: youtubeStubUrl,
},
},
{
/* Deterministic YouTube Data API v3 responses for the content board. */
command: `node tests/e2e/youtube-stub.mjs`,
reuseExistingServer: false,
url: `${youtubeStubUrl}/__stub/health`,
timeout: 30_000,
env: {
YOUTUBE_STUB_PORT: String(youtubeStubPort),
},
},
],
})

View file

@ -1,56 +0,0 @@
import Image from 'next/image'
import Link from 'next/link'
export type CampaignArticleCardData = {
id: string | number
href: string
title: string
badgeLabel: string
meta: string
coverUrl?: string
coverAlt?: string
subtitle?: string
}
type CampaignArticleCardProps = {
card: CampaignArticleCardData
featured?: boolean
}
/** Presentational article card for the campaign home content bento/carousel. */
export const CampaignArticleCard = ({ card, featured = false }: CampaignArticleCardProps) => (
<Link
href={card.href}
className={`group flex h-full flex-col rounded-xl border border-(--campaign-line) bg-white p-3 shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--pt-red) ${
featured ? 'gap-3' : 'gap-2'
}`}
>
<div className="relative aspect-video w-full shrink-0 overflow-hidden rounded-lg bg-(--campaign-band)">
{card.coverUrl ? (
<Image
src={card.coverUrl}
alt={card.coverAlt ?? card.title}
fill
sizes={featured ? '(min-width: 768px) 44vw, 92vw' : '(min-width: 768px) 22vw, 92vw'}
className="object-cover transition-transform duration-300 group-hover:scale-[1.03] motion-reduce:transition-none motion-reduce:group-hover:scale-100"
/>
) : null}
<span className="absolute top-2 left-2 rounded-full bg-(--pt-red-dark) px-2 py-0.5 text-[10px] font-bold tracking-wider text-white uppercase">
{card.badgeLabel}
</span>
</div>
<div className="flex flex-1 flex-col gap-1">
<h3
className={`leading-snug font-bold text-(--campaign-ink) group-hover:underline ${
featured ? 'text-base' : 'text-sm'
}`}
>
{card.title}
</h3>
{featured && card.subtitle ? (
<p className="text-xs leading-relaxed text-(--campaign-muted)">{card.subtitle}</p>
) : null}
<span className="mt-auto text-xs text-(--campaign-muted)">{card.meta}</span>
</div>
</Link>
)

View file

@ -0,0 +1,80 @@
import Image from 'next/image'
import Link from 'next/link'
export type CampaignContentCardData = {
id: string | number
href: string
title: string
badgeLabel: string
meta: string
coverUrl?: string
coverAlt?: string
subtitle?: string
/** External target (e.g. a YouTube video): opens in a new tab with noopener. */
external?: boolean
}
type CampaignContentCardProps = {
card: CampaignContentCardData
featured?: boolean
}
const cardClassName = (featured: boolean) =>
`group flex h-full flex-col rounded-xl border border-(--campaign-line) bg-white p-3 shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--pt-red) ${
featured ? 'gap-3' : 'gap-2'
}`
/**
* Presentational content card for the campaign home content bento/carousel —
* one spelling for every source (article, YouTube, Instagram in S3). Internal
* targets use `Link`; external ones open on the platform in a new tab with
* `noopener` (no inline player, per the approved wireframe).
*/
export const CampaignContentCard = ({ card, featured = false }: CampaignContentCardProps) => {
const inner = (
<>
<div className="relative aspect-video w-full shrink-0 overflow-hidden rounded-lg bg-(--campaign-band)">
{card.coverUrl ? (
<Image
src={card.coverUrl}
alt={card.coverAlt ?? card.title}
fill
sizes={featured ? '(min-width: 768px) 44vw, 92vw' : '(min-width: 768px) 22vw, 92vw'}
className="object-cover transition-transform duration-300 group-hover:scale-[1.03] motion-reduce:transition-none motion-reduce:group-hover:scale-100"
/>
) : null}
<span className="absolute top-2 left-2 rounded-full bg-(--pt-red-dark) px-2 py-0.5 text-[10px] font-bold tracking-wider text-white uppercase">
{card.badgeLabel}
</span>
</div>
<div className="flex flex-1 flex-col gap-1">
<h3
className={`leading-snug font-bold text-(--campaign-ink) group-hover:underline ${
featured ? 'text-base' : 'text-sm'
}`}
>
{card.title}
</h3>
{featured && card.subtitle ? (
<p className="text-xs leading-relaxed text-(--campaign-muted)">{card.subtitle}</p>
) : null}
<span className="mt-auto text-xs text-(--campaign-muted)">{card.meta}</span>
</div>
</>
)
return card.external ? (
<a
href={card.href}
target="_blank"
rel="noopener noreferrer"
className={cardClassName(featured)}
>
{inner}
</a>
) : (
<Link href={card.href} className={cardClassName(featured)}>
{inner}
</Link>
)
}

View file

@ -1,13 +1,13 @@
'use client'
import { CampaignArticleCard, type CampaignArticleCardData } from '@/components/CampaignArticleCard'
import { CampaignContentCard, type CampaignContentCardData } from '@/components/CampaignContentCard'
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react'
const PROGRAMMATIC_SCROLL_LOCK_MS = 700
type CampaignContentCarouselProps = {
ariaLabel: string
items: CampaignArticleCardData[]
items: CampaignContentCardData[]
}
/**
@ -95,7 +95,7 @@ export const CampaignContentCarousel = ({ ariaLabel, items }: CampaignContentCar
inert={index !== activeIndex || undefined}
className="m-0 w-full shrink-0 snap-start"
>
<CampaignArticleCard card={item} />
<CampaignContentCard card={item} />
</li>
))}
</ol>

View file

@ -1,4 +1,4 @@
import { CampaignArticleCard, type CampaignArticleCardData } from '@/components/CampaignArticleCard'
import { CampaignContentCard, type CampaignContentCardData } from '@/components/CampaignContentCard'
import { CampaignContentCarousel } from '@/components/CampaignContentCarousel'
import type { Post } from '@/payload-types'
import {
@ -8,41 +8,87 @@ import {
getVisiblePosts,
POST_TYPE_BADGE_LABELS,
} from '@/utilities/posts'
import { formatYouTubeViews, getYouTubeFeed, type YouTubeVideo } from '@/utilities/youtubeFeed'
import Link from 'next/link'
const CONTENT_SECTION_LIMIT = 5
const toCardData = (post: Post): CampaignArticleCardData | null => {
const sectionHeaderLinkClassName =
'text-sm font-bold text-(--pt-red) underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none'
type DatedCard = {
card: CampaignContentCardData
date: number
}
/** 0 sentinel: a missing date sinks the item to the bottom of the recency merge. */
const dateTimeOf = (value?: string | null): number => {
const time = new Date(value ?? '').getTime()
return Number.isNaN(time) ? 0 : time
}
const byRecencyDesc = (a: DatedCard, b: DatedCard): number => b.date - a.date
const toArticleCardData = (post: Post): DatedCard | null => {
const href = getPostCanonicalPath(post)
if (!href) return null
const cover =
typeof post.coverImage === 'object' && post.coverImage !== null ? post.coverImage : null
const categoryName = getCategoryName(post)
const date = formatRelativePostDate(post.publishedDate)
return {
card: {
id: post.id,
href,
title: post.title,
badgeLabel: POST_TYPE_BADGE_LABELS[post.type],
meta: [date, categoryName].filter(Boolean).join(' · '),
meta: [formatRelativePostDate(post.publishedDate), categoryName].filter(Boolean).join(' · '),
...(cover?.url ? { coverUrl: cover.url, coverAlt: cover.alt ?? undefined } : {}),
...(post.subtitle ? { subtitle: post.subtitle } : {}),
},
date: dateTimeOf(post.publishedDate),
}
}
const toVideoCardData = (video: YouTubeVideo): DatedCard => ({
card: {
id: `yt:${video.id}`,
href: `https://www.youtube.com/watch?v=${video.id}`,
title: video.title,
badgeLabel: 'YouTube',
meta: [
formatRelativePostDate(video.publishedAt),
video.viewCount != null ? `${formatYouTubeViews(video.viewCount)} visualizações` : null,
]
.filter(Boolean)
.join(' · '),
...(video.thumbnailUrl ? { coverUrl: video.thumbnailUrl, coverAlt: video.title } : {}),
external: true,
},
date: dateTimeOf(video.publishedAt),
})
/**
* Campaign home content board (S1 — articles): the 5 most recent visible posts
* as a 1+4 bento on desktop and a one-per-screen carousel on mobile. Hides
* entirely when nothing is visible (`isPostVisible` fail-closed — hidden
* electoral tags never leak).
* Campaign home content board: the 5 most recent items across the visible
* posts (S1) and the eligible YouTube feed (S2), newest first, as a 1+4 bento
* on desktop and a one-per-screen carousel on mobile. Hides entirely when
* nothing is visible (`isPostVisible` fail-closed — hidden electoral tags
* never leak) and never breaks when the feed API is down (snapshot or no
* cards). With the feed unconfigured it degrades to the S1 articles-only
* behavior.
*/
export const CampaignContentSection = async () => {
const cards = (await getVisiblePosts())
.map(toCardData)
.filter((card): card is CampaignArticleCardData => card !== null)
const [visiblePosts, feed] = await Promise.all([getVisiblePosts(), getYouTubeFeed()])
const articleCards = visiblePosts
.map(toArticleCardData)
.filter((entry): entry is DatedCard => entry !== null)
const videoCards = (feed?.videos ?? []).map(toVideoCardData)
const cards = [...articleCards, ...videoCards]
.sort(byRecencyDesc)
.slice(0, CONTENT_SECTION_LIMIT)
.map(({ card }) => card)
if (!cards.length) return null
@ -70,25 +116,34 @@ export const CampaignContentSection = async () => {
Bastidores, caravanas e as lutas do mandato: conteúdo atualizado, direto das redes.
</p>
</div>
<Link
href="/artigos"
className="text-sm font-bold text-(--pt-red) underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none"
>
<div className="flex flex-wrap gap-4">
<Link href="/artigos" className={sectionHeaderLinkClassName}>
Ver artigos →
</Link>
{feed ? (
<a
href={`https://www.youtube.com/channel/${feed.channelId}`}
target="_blank"
rel="noopener noreferrer"
className={sectionHeaderLinkClassName}
>
YouTube →
</a>
) : null}
</div>
</div>
<div className="mt-6 hidden grid-cols-4 gap-4 md:grid">
<div className="col-span-2 row-span-2">
<CampaignArticleCard card={featured} featured />
<CampaignContentCard card={featured} featured />
</div>
{rest.map((card) => (
<CampaignArticleCard key={card.id} card={card} />
<CampaignContentCard key={card.id} card={card} />
))}
</div>
<div className="mt-5 md:hidden">
<CampaignContentCarousel ariaLabel="Artigos recentes" items={cards} />
<CampaignContentCarousel ariaLabel="Conteúdos recentes" items={cards} />
</div>
</div>
</section>

View file

@ -0,0 +1,135 @@
import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { REVALIDATE_SOCIAL_FEED_TAG } from '@/utilities/revalidateRequest'
import { revalidateTag } from 'next/cache'
import type { GlobalConfig } from 'payload'
const slug = 'social-feed-settings'
const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/
const revalidateFeed = async () => revalidateTag(REVALIDATE_SOCIAL_FEED_TAG)
/**
* Configuration of the campaign home content board's external feeds (plano-site
* §4.2): YouTube now, Instagram in S3. The board's read path is the public home
* render, so the global's `read` is admin-only — the YouTube API key and the
* excluded-item list never leak through the REST API (the public render reads
* via the Local API, which bypasses access control by design).
*/
export const SocialFeedSettings: GlobalConfig = {
slug,
label: 'Feed de redes sociais',
admin: {
group: 'Configurações',
description:
'Fonte automática do board "Acompanhe de perto" da home de campanha. Sem chave configurada, os cards da plataforma não aparecem.',
},
access: {
read: payloadAdminOnly,
update: payloadAdminOnly,
},
hooks: {
afterChange: [revalidateFeed],
},
fields: [
{
name: 'enabled',
type: 'checkbox',
label: 'Feed ativo',
defaultValue: true,
admin: {
description:
'Desligado, as fontes externas (YouTube, Instagram) somem do board sem mexer nos artigos do site.',
},
},
{
name: 'youtubeEnabled',
type: 'checkbox',
label: 'YouTube ativo',
defaultValue: true,
admin: {
description: 'Desliga só os cards de vídeo do YouTube.',
},
},
{
name: 'youtubeApiKey',
type: 'text',
label: 'Chave da API (YouTube Data API v3)',
admin: {
description:
'Chave de API do Google Cloud com a YouTube Data API v3 habilitada. Restrinja por IP se possível.',
},
},
{
name: 'youtubeChannelId',
type: 'text',
label: 'ID do canal',
admin: {
description: 'Ex.: UCyqT2nMLnwQn2Bh7mB7y3dA (o canal oficial é @JorgeSollaDep).',
},
},
{
name: 'youtubeMaxItems',
type: 'number',
label: 'Máximo de vídeos',
defaultValue: 3,
min: 1,
max: 5,
admin: {
description: 'Quantos vídeos entram no bento (1 grande + os próximos).',
},
},
{
name: 'excludedItems',
type: 'array',
label: 'Itens excluídos',
labels: {
singular: 'Item excluído',
plural: 'Itens excluídos',
},
admin: {
description:
'Conteúdo marcado para NÃO aparecer no board (vídeo errado, transmissão incompleta, post de grade). O board pula para o próximo elegível.',
},
fields: [
{
name: 'platform',
type: 'select',
label: 'Plataforma',
required: true,
options: [
{ label: 'YouTube', value: 'youtube' },
{ label: 'Instagram', value: 'instagram' },
],
},
{
name: 'itemId',
type: 'text',
label: 'ID do item',
required: true,
validate: (
value: string | null | undefined,
{ data }: { data: { platform?: string } },
) => {
if (data.platform === 'youtube' && value && !YOUTUBE_VIDEO_ID_PATTERN.test(value)) {
return 'Informe o ID do vídeo (11 caracteres)'
}
return true
},
},
{
name: 'reason',
type: 'text',
label: 'Motivo (opcional)',
},
],
},
{
name: 'youtubeFeedSnapshot',
type: 'json',
admin: {
hidden: true,
},
},
],
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,37 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_social_feed_settings_excluded_items_platform" AS ENUM('youtube', 'instagram');
CREATE TABLE "social_feed_settings_excluded_items" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"platform" "enum_social_feed_settings_excluded_items_platform" NOT NULL,
"item_id" varchar NOT NULL,
"reason" varchar
);
CREATE TABLE "social_feed_settings" (
"id" serial PRIMARY KEY NOT NULL,
"enabled" boolean DEFAULT true,
"youtube_enabled" boolean DEFAULT true,
"youtube_api_key" varchar,
"youtube_channel_id" varchar,
"youtube_max_items" numeric DEFAULT 3,
"youtube_feed_snapshot" jsonb,
"updated_at" timestamp(3) with time zone,
"created_at" timestamp(3) with time zone
);
ALTER TABLE "social_feed_settings_excluded_items" ADD CONSTRAINT "social_feed_settings_excluded_items_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."social_feed_settings"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "social_feed_settings_excluded_items_order_idx" ON "social_feed_settings_excluded_items" USING btree ("_order");
CREATE INDEX "social_feed_settings_excluded_items_parent_id_idx" ON "social_feed_settings_excluded_items" USING btree ("_parent_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP TABLE "social_feed_settings_excluded_items" CASCADE;
DROP TABLE "social_feed_settings" CASCADE;
DROP TYPE "public"."enum_social_feed_settings_excluded_items_platform";`)
}

View file

@ -54,6 +54,7 @@ import * as migration_20260811_183016_drop_activity_task_due from './20260811_18
import * as migration_20260811_205157_add_google_calendar_push_channel from './20260811_205157_add_google_calendar_push_channel';
import * as migration_20260811_213648_add_activity_last_mirrored_change_at from './20260811_213648_add_activity_last_mirrored_change_at';
import * as migration_20260811_234822_add_state_deputy_ballot_name from './20260811_234822_add_state_deputy_ballot_name';
import * as migration_20260818_111748_add_social_feed_settings from './20260818_111748_add_social_feed_settings';
export const migrations = [
{
@ -336,4 +337,9 @@ export const migrations = [
down: migration_20260811_234822_add_state_deputy_ballot_name.down,
name: '20260811_234822_add_state_deputy_ballot_name',
},
{
up: migration_20260818_111748_add_social_feed_settings.up,
down: migration_20260818_111748_add_social_feed_settings.down,
name: '20260818_111748_add_social_feed_settings'
},
];

View file

@ -158,6 +158,7 @@ export interface Config {
metadata: Metadatum;
'privacy-policy': PrivacyPolicy;
campaignGoals: CampaignGoal;
'social-feed-settings': SocialFeedSetting;
};
globalsSelect: {
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
@ -165,6 +166,7 @@ export interface Config {
metadata: MetadataSelect<false> | MetadataSelect<true>;
'privacy-policy': PrivacyPolicySelect<false> | PrivacyPolicySelect<true>;
campaignGoals: CampaignGoalsSelect<false> | CampaignGoalsSelect<true>;
'social-feed-settings': SocialFeedSettingsSelect<false> | SocialFeedSettingsSelect<true>;
};
locale: null;
widgets: {
@ -2365,6 +2367,57 @@ export interface CampaignGoal {
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* Fonte automática do board "Acompanhe de perto" da home de campanha. Sem chave configurada, os cards da plataforma não aparecem.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "social-feed-settings".
*/
export interface SocialFeedSetting {
id: number;
/**
* Desligado, as fontes externas (YouTube, Instagram) somem do board sem mexer nos artigos do site.
*/
enabled?: boolean | null;
/**
* Desliga só os cards de vídeo do YouTube.
*/
youtubeEnabled?: boolean | null;
/**
* Chave de API do Google Cloud com a YouTube Data API v3 habilitada. Restrinja por IP se possível.
*/
youtubeApiKey?: string | null;
/**
* Ex.: UCyqT2nMLnwQn2Bh7mB7y3dA (o canal oficial é @JorgeSollaDep).
*/
youtubeChannelId?: string | null;
/**
* Quantos vídeos entram no bento (1 grande + os próximos).
*/
youtubeMaxItems?: number | null;
/**
* Conteúdo marcado para NÃO aparecer no board (vídeo errado, transmissão incompleta, post de grade). O board pula para o próximo elegível.
*/
excludedItems?:
| {
platform: 'youtube' | 'instagram';
itemId: string;
reason?: string | null;
id?: string | null;
}[]
| null;
youtubeFeedSnapshot?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "site-settings_select".
@ -2450,6 +2503,29 @@ export interface CampaignGoalsSelect<T extends boolean = true> {
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "social-feed-settings_select".
*/
export interface SocialFeedSettingsSelect<T extends boolean = true> {
enabled?: T;
youtubeEnabled?: T;
youtubeApiKey?: T;
youtubeChannelId?: T;
youtubeMaxItems?: T;
excludedItems?:
| T
| {
platform?: T;
itemId?: T;
reason?: T;
id?: T;
};
youtubeFeedSnapshot?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "collections_widget".

View file

@ -45,6 +45,7 @@ import { HomePage } from './globals/HomePage'
import { Metadata } from './globals/Metadata'
import { PrivacyPolicy } from './globals/PrivacyPolicy'
import { SiteSettings } from './globals/SiteSettings'
import { SocialFeedSettings } from './globals/SocialFeedSettings'
import { isPayloadAdmin } from './utilities/campaignAccess'
import { resolveS3StorageEnv } from './utilities/mediaStorage'
@ -124,7 +125,7 @@ export default buildConfig({
Post,
Tag,
],
globals: [SiteSettings, HomePage, Metadata, PrivacyPolicy, CampaignGoals],
globals: [SiteSettings, HomePage, Metadata, PrivacyPolicy, CampaignGoals, SocialFeedSettings],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',
typescript: {

View file

@ -6,11 +6,19 @@ export const REVALIDATE_POSTS_TAG = 'posts' as const
export const REVALIDATE_PRIVACY_POLICY_CACHE_TAG = getGlobalCacheTag('privacy-policy')
/**
* Cache tag of the campaign home content board's external feeds (`unstable_cache`
* entry of `getYouTubeFeed`). The `SocialFeedSettings` global's `afterChange`
* busts it; the runbook tag is also allowlisted here for direct-DB writes.
*/
export const REVALIDATE_SOCIAL_FEED_TAG = 'social-feed' as const
const ALLOWED_REVALIDATE_TAGS = [
REVALIDATE_POSTS_TAG,
REVALIDATE_PRIVACY_POLICY_CACHE_TAG,
ELECTION_TSE_CACHE_TAG,
MUNICIPALITY_CATALOG_CACHE_TAG,
REVALIDATE_SOCIAL_FEED_TAG,
] as const
type AllowedRevalidateTag = (typeof ALLOWED_REVALIDATE_TAGS)[number]

View file

@ -0,0 +1,284 @@
import 'server-only'
import type { SocialFeedSetting } from '@/payload-types'
import { REVALIDATE_SOCIAL_FEED_TAG } from '@/utilities/revalidateRequest'
import configPromise from '@payload-config'
import { sql } from '@payloadcms/db-postgres'
import { unstable_cache } from 'next/cache'
import { getPayload, type Payload } from 'payload'
const YOUTUBE_API_BASE_URL =
process.env.YOUTUBE_API_BASE_URL ?? 'https://www.googleapis.com/youtube/v3'
export const YOUTUBE_MAX_RESULTS_CAP = 50
export type YouTubeVideo = {
id: string
title: string
publishedAt: string
thumbnailUrl?: string
viewCount?: number
}
export type LoadYouTubeFeedArgs = {
apiKey: string
channelId: string
maxResults: number
fetchImpl?: (input: string, init?: RequestInit) => Promise<Response>
baseUrl?: string
}
export type YouTubeFeedResult = {
channelId: string
videos: YouTubeVideo[]
}
/**
* Parses the `search.list` response into videos (id, title, publishedAt and
* best thumbnail). Throws on a protocol-level violation (no `items` array);
* individual malformed items are skipped, but a video without an id or a
* publish date is dropped — it could not be placed in the recency merge.
*/
export const parseYouTubeSearchResponse = (json: unknown): YouTubeVideo[] => {
if (!json || typeof json !== 'object') {
throw new Error('Resposta de busca do YouTube inválida')
}
const items = (json as { items?: unknown }).items
if (!Array.isArray(items)) {
throw new Error('Resposta de busca do YouTube sem lista de itens')
}
const videos: YouTubeVideo[] = []
for (const item of items) {
if (!item || typeof item !== 'object') continue
const rawId = (item as { id?: unknown }).id
const videoId =
typeof rawId === 'object' && rawId !== null
? (rawId as { videoId?: unknown }).videoId
: undefined
if (typeof videoId !== 'string' || !videoId) continue
const snippet = (item as { snippet?: unknown }).snippet
if (typeof snippet !== 'object' || snippet === null) continue
const rawSnippet = snippet as { title?: unknown; publishedAt?: unknown; thumbnails?: unknown }
if (typeof rawSnippet.title !== 'string' || !rawSnippet.title) continue
if (typeof rawSnippet.publishedAt !== 'string' || !rawSnippet.publishedAt) continue
videos.push({
id: videoId,
title: rawSnippet.title,
publishedAt: rawSnippet.publishedAt,
thumbnailUrl: pickThumbnailUrl(rawSnippet.thumbnails),
})
}
return videos
}
/** Best available thumbnail of a search snippet: maxres, then high, then medium. */
export const pickThumbnailUrl = (thumbnails: unknown): string | undefined => {
if (typeof thumbnails !== 'object' || thumbnails === null) return undefined
const byQuality = thumbnails as { maxres?: unknown; high?: unknown; medium?: unknown }
for (const quality of ['maxres', 'high', 'medium'] as const) {
const entry = byQuality[quality]
if (typeof entry === 'object' && entry !== null) {
const url = (entry as { url?: unknown }).url
if (typeof url === 'string' && url) return url
}
}
return undefined
}
/**
* Parses the `videos.list?part=statistics` response into a videoId → viewCount
* map. `viewCount` arrives as a string; a missing or non-numeric value is
* dropped so the card renders without the counter instead of with a lie.
*/
export const parseYouTubeVideosResponse = (json: unknown): Map<string, number> => {
const counts = new Map<string, number>()
if (!json || typeof json !== 'object') return counts
const items = (json as { items?: unknown }).items
if (!Array.isArray(items)) return counts
for (const item of items) {
if (!item || typeof item !== 'object') continue
const rawItem = item as { id?: unknown; statistics?: unknown }
if (typeof rawItem.id !== 'string' || !rawItem.id) continue
if (typeof rawItem.statistics !== 'object' || rawItem.statistics === null) continue
const viewCount = (rawItem.statistics as { viewCount?: unknown }).viewCount
const parsed = typeof viewCount === 'string' ? Number(viewCount) : Number.NaN
if (Number.isFinite(parsed) && parsed >= 0) counts.set(rawItem.id, parsed)
}
return counts
}
/**
* pt-BR short count the way the platform spells it: "987", "12,4 mil",
* "1,2 mi", "1 bi" — one decimal with a comma, no trailing zero. A value that
* rounds across the next unit reads as that unit ("1 mi" at 999,95 mil).
*/
export const formatYouTubeViews = (count: number): string => {
if (count < 1_000) return String(count)
const tenthsOf = (base: number): number => Math.round((count / base) * 10)
const formatTenths = (tenths: number, suffix: string): string => {
const whole = Math.floor(tenths / 10)
const decimal = tenths % 10
return `${decimal === 0 ? String(whole) : `${whole},${decimal}`} ${suffix}`
}
if (count < 1_000_000) {
const tenths = tenthsOf(1_000)
return tenths < 10_000 ? formatTenths(tenths, 'mil') : '1 mi'
}
if (count < 1_000_000_000) {
const tenths = tenthsOf(1_000_000)
return tenths < 10_000 ? formatTenths(tenths, 'mi') : '1 bi'
}
return formatTenths(tenthsOf(1_000_000_000), 'bi')
}
/**
* Keeps only the videos whose ids are not excluded, in API order (newest
* first), capped at `maxItems`. The board skips an excluded video — it never
* counts as "the latest".
*/
export const eligibleYouTubeVideos = (
videos: YouTubeVideo[],
excludedIds: string[],
maxItems: number,
): YouTubeVideo[] => videos.filter((video) => !excludedIds.includes(video.id)).slice(0, maxItems)
/**
* Fetches the latest public videos of a channel: `search.list` for the recent
* video ids/metadata, then `videos.list` for the view counts. Throws on any
* non-2xx response, network failure or malformed JSON so the caller can fall
* back to the persisted snapshot (fail-closed).
*/
export const loadYouTubeFeed = async ({
apiKey,
channelId,
maxResults,
fetchImpl = fetch,
baseUrl = YOUTUBE_API_BASE_URL,
}: LoadYouTubeFeedArgs): Promise<YouTubeVideo[]> => {
const searchParams = new URLSearchParams({
part: 'snippet',
channelId,
type: 'video',
order: 'date',
maxResults: String(Math.min(Math.max(maxResults, 1), YOUTUBE_MAX_RESULTS_CAP)),
key: apiKey,
})
const searchResponse = await fetchImpl(`${baseUrl}/search?${searchParams}`)
if (!searchResponse.ok) {
throw new Error(`YouTube search falhou com status ${searchResponse.status}`)
}
const videos = parseYouTubeSearchResponse(await searchResponse.json())
if (videos.length === 0) return []
const videosParams = new URLSearchParams({
part: 'statistics',
id: videos.map((video) => video.id).join(','),
key: apiKey,
})
const statisticsResponse = await fetchImpl(`${baseUrl}/videos?${videosParams}`)
if (!statisticsResponse.ok) {
throw new Error(`YouTube statistics falhou com status ${statisticsResponse.status}`)
}
const viewCounts = parseYouTubeVideosResponse(await statisticsResponse.json())
return videos.map((video) => {
const viewCount = viewCounts.get(video.id)
return viewCount === undefined ? video : { ...video, viewCount }
})
}
const excludedYouTubeIds = (settings: SocialFeedSetting): string[] =>
(settings.excludedItems ?? [])
.filter((item) => item.platform === 'youtube')
.map((item) => item.itemId)
.filter(Boolean)
/**
* Persists the raw feed as the global's snapshot column. Deliberately raw SQL
* (`payload.db.drizzle`) instead of `payload.updateGlobal`: this runs INSIDE
* `unstable_cache` during a page render, and Next 15.4 throws when
* `revalidateTag` (the global's `afterChange` hook) fires inside a cached
* function. Raw SQL bypasses hooks; the row is created on first write when the
* admin has not saved the global yet (a serial id is assigned, so no id
* assumption is made).
*
* Precondition: at most one `social_feed_settings` row exists. The UPDATE
* targets the single row and the INSERT only fires when none exists; a
* concurrent double-insert would need two writers racing the very first write
* (the row is created by the first admin save or feed render) — accepted.
*/
const persistYouTubeSnapshot = async (payload: Payload, videos: YouTubeVideo[]): Promise<void> => {
const database = payload.db.drizzle
const json = JSON.stringify(videos)
const updated = await database.execute(sql`
UPDATE "social_feed_settings"
SET "youtube_feed_snapshot" = ${json}::jsonb, "updated_at" = now()
WHERE "id" = (SELECT "id" FROM "social_feed_settings" ORDER BY "id" LIMIT 1)
`)
if (!updated?.rowCount) {
await database.execute(sql`
INSERT INTO "social_feed_settings" ("youtube_feed_snapshot", "created_at", "updated_at")
VALUES (${json}::jsonb, now(), now())
`)
}
}
/**
* Cached feed for the campaign home content board: `null` when the feed is off
* (kill switch, platform toggle or missing credentials), otherwise the latest
* eligible videos. A successful fetch persists the RAW list (pre-exclusion) as
* the global snapshot; a failed fetch falls back to that snapshot with the
* current exclusions applied — so a new exclusion works even while the API is
* down, and the page never breaks. Busted by the global's `afterChange` (tag
* `social-feed`); the per-entry `revalidate` keeps new uploads appearing
* without an admin edit.
*/
export const getYouTubeFeed = unstable_cache(
async (): Promise<YouTubeFeedResult | null> => {
const payload = await getPayload({ config: configPromise })
const settings = await payload.findGlobal({
slug: 'social-feed-settings',
depth: 0,
})
if (
settings.enabled === false ||
settings.youtubeEnabled === false ||
!settings.youtubeApiKey ||
!settings.youtubeChannelId
) {
return null
}
const channelId = settings.youtubeChannelId
const maxItems = Math.min(Math.max(settings.youtubeMaxItems ?? 3, 1), 5)
const excludedIds = excludedYouTubeIds(settings)
try {
const videos = await loadYouTubeFeed({
apiKey: settings.youtubeApiKey,
channelId,
maxResults: Math.min(maxItems + 10, YOUTUBE_MAX_RESULTS_CAP),
})
await persistYouTubeSnapshot(payload, videos)
return { channelId, videos: eligibleYouTubeVideos(videos, excludedIds, maxItems) }
} catch {
const snapshot = settings.youtubeFeedSnapshot as unknown
if (!Array.isArray(snapshot)) return { channelId, videos: [] }
return { channelId, videos: eligibleYouTubeVideos(snapshot, excludedIds, maxItems) }
}
},
['youtube-feed'],
{ tags: [REVALIDATE_SOCIAL_FEED_TAG], revalidate: 300 },
)

View file

@ -1,6 +1,7 @@
import type { APIRequestContext, Page } from '@playwright/test'
import { seedTestUser, testUser } from '../helpers/seedUser'
import { youtubeStubUrlFor } from '../helpers/youtubeStub'
import { expect, test } from './fixtures/e2eTest'
const swipeLeft = async (
@ -436,8 +437,14 @@ test.describe('Campaign home content section', () => {
const createdPosts: number[] = []
const createdTags: number[] = []
test.beforeAll(async () => {
test.beforeAll(async ({ request }) => {
await seedTestUser()
// Start every run with the social feed unconfigured so the empty-state
// test stays deterministic even when a previous S2 run left settings or a
// persisted snapshot behind (same reasoning as the `posts` bust below).
const headers = await adminHeaders(request)
await resetSocialFeedSettings(request, headers)
await bustSocialFeed(request)
})
const adminHeaders = async (request: APIRequestContext): Promise<Record<string, string>> => {
@ -484,27 +491,107 @@ test.describe('Campaign home content section', () => {
// ISR serves the stale page while it regenerates after a revalidateTag
// (slower under the parallel suite), and a navigation that lands on it never
// refreshes its DOM. Poll the server HTML positively until it converges to
// the expected section state; the navigation that follows then always lands
// on the fresh page.
const waitForHomeSectionState = async (
// the expected substring state (section presence or actual content, e.g.
// video titles); the navigation that follows then always lands on the fresh
// page. 12 attempts preserves the S1 convergence budget.
const waitForHomeHTML = async (
request: APIRequestContext,
expected: 'present' | 'absent',
includes: string[],
excludes: string[] = [],
attempts = 12,
) => {
for (let attempt = 0; attempt < attempts; attempt += 1) {
const response = await request.get(`${baseURL}/`).catch(() => undefined)
if (response?.ok()) {
const html = await response.text()
const present = html.includes('data-home-section="contents"')
if (present === (expected === 'present')) return
const allIncluded = includes.every((needle) => html.includes(needle))
const anyExcluded = excludes.some((needle) => html.includes(needle))
if (allIncluded && !anyExcluded) return
}
await new Promise((resolve) => setTimeout(resolve, 1_000))
}
throw new Error(
`A home não convergiu para o estado "${expected}" da seção de conteúdos após ${attempts}s.`,
`A home não convergiu após ${attempts}s (includes: ${includes.join(', ')}, excludes: ${excludes.join(', ')}).`,
)
}
const waitForHomeSectionState = async (
request: APIRequestContext,
expected: 'present' | 'absent',
attempts = 12,
) => {
if (expected === 'present') {
await waitForHomeHTML(request, ['data-home-section="contents"'], [], attempts)
} else {
await waitForHomeHTML(request, [], ['data-home-section="contents"'], attempts)
}
}
// ---- S2 — YouTube feed: stub + settings helpers ----
// Same derivation as playwright.config.ts: the stub listens on dev port + 1000.
const youtubeStubUrl = youtubeStubUrlFor(baseURL)
const setYouTubeStubState = async (request: APIRequestContext, state: 'ok' | 'fail') => {
const response = await request.post(`${youtubeStubUrl}/__stub/state`, { data: { state } })
expect(response.ok()).toBeTruthy()
}
const updateSocialFeedSettings = async (
request: APIRequestContext,
headers: Record<string, string>,
data: Record<string, unknown>,
) => {
const response = await request.post(`${baseURL}/api/globals/social-feed-settings`, {
headers,
data,
})
expect(response.ok()).toBeTruthy()
}
// Full unconfigured state: the feed returns null and the S1 behavior is intact.
const resetSocialFeedSettings = async (
request: APIRequestContext,
headers: Record<string, string>,
) => {
await updateSocialFeedSettings(request, headers, {
enabled: true,
youtubeEnabled: true,
youtubeApiKey: '',
youtubeChannelId: '',
youtubeMaxItems: 3,
excludedItems: [],
youtubeFeedSnapshot: null,
})
}
const bustSocialFeed = async (request: APIRequestContext) =>
request
.post(`${baseURL}/api/revalidate?tag=social-feed`, {
headers: { 'x-revalidate-secret': revalidateSecret },
})
.catch(() => undefined)
// Best-effort S2 teardown: deletes the created posts/tags, resets the social
// feed to the unconfigured state and converges the home back to the empty
// section so the serial chain starts deterministic on the next run.
const cleanupS2Fixtures = async (request: APIRequestContext, headers: Record<string, string>) => {
for (const id of createdPosts) {
await request.delete(`${baseURL}/api/post/${id}`, { headers }).catch(() => undefined)
}
for (const id of createdTags) {
await request.delete(`${baseURL}/api/tag/${id}`, { headers }).catch(() => undefined)
}
await resetSocialFeedSettings(request, headers).catch(() => undefined)
await bustSocialFeed(request)
await request
.post(`${baseURL}/api/revalidate?tag=posts`, {
headers: { 'x-revalidate-secret': revalidateSecret },
})
.catch(() => undefined)
await waitForHomeSectionState(request, 'absent').catch(() => undefined)
}
test('hides the content section while no articles are visible', async ({ page, request }) => {
// The dev server persists its `posts` cache to .next-e2e/cache/fetch-cache
// between runs, so an earlier full-state run could leave it populated.
@ -647,7 +734,7 @@ test.describe('Campaign home content section', () => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/')
const carousel = page.getByRole('region', { name: 'Artigos recentes' })
const carousel = page.getByRole('region', { name: 'Conteúdos recentes' })
await expect(carousel).toBeVisible()
await expect(carousel.getByText('E2e Conteúdo oculto')).toHaveCount(0)
@ -674,6 +761,7 @@ test.describe('Campaign home content section', () => {
// prefetch answered after the deletion would 404 (guard noise). Leaving
// the page aborts in-flight prefetches and stops new ones.
await page.goto('about:blank')
await expect(page).toHaveURL(/about:blank/)
} finally {
const headers2 = await adminHeaders(request).catch(() => undefined)
if (headers2) {
@ -702,4 +790,255 @@ test.describe('Campaign home content section', () => {
}
}
})
test('renders the YouTube feed with exclusions and opens videos on the platform', async ({
page,
request,
}) => {
await seedTestUser()
const headers = await adminHeaders(request)
await setYouTubeStubState(request, 'ok')
const mixTag = await createTag(request, headers, `E2e Mix ${runSuffix}`, `e2e-mix-${runSuffix}`)
await createPost(request, headers, {
title: 'E2e Artigo no mix',
slug: `e2e-artigo-mix-${runSuffix}`,
type: 'artigo',
category: mixTag.id,
publishedDate: new Date(Date.now() - 60 * 60_000).toISOString(),
})
// The newest stub video is excluded: the board must skip it and promote the
// next eligible one to the featured slot (the acceptance's "pula para o
// próximo elegível").
await updateSocialFeedSettings(request, headers, {
enabled: true,
youtubeEnabled: true,
youtubeApiKey: 'e2e-youtube-api-key',
youtubeChannelId: 'UCe2eTestChannel',
youtubeMaxItems: 3,
excludedItems: [{ platform: 'youtube', itemId: 'e2e-video-excluido-4' }],
youtubeFeedSnapshot: null,
})
try {
await waitForHomeHTML(
request,
['E2e Vídeo em destaque', 'E2e Artigo no mix'],
['E2e Vídeo excluído'],
)
await page.setViewportSize({ width: 1280, height: 900 })
await page.goto('/')
const section = page.locator('[data-home-section="contents"]')
await expect(section).toBeVisible()
const featured = section.getByRole('link', { name: /E2e Vídeo em destaque/ })
await expect(featured).toBeVisible()
await expect(featured).toHaveAttribute(
'href',
'https://www.youtube.com/watch?v=e2e-video-destaque-1',
)
await expect(featured).toHaveAttribute('target', '_blank')
await expect(featured).toHaveAttribute('rel', 'noopener noreferrer')
await expect(
section.getByText('há 30 minutos · 12,4 mil visualizações').first(),
).toBeVisible()
await expect(section.getByText('YouTube', { exact: true }).first()).toBeVisible()
await expect(section.getByText('E2e Vídeo excluído')).toHaveCount(0)
await expect(section.getByRole('link', { name: /E2e Artigo no mix/ }).first()).toBeVisible()
await expect(section.getByText('há 2 horas · 8,1 mil visualizações').first()).toBeVisible()
await expect(section.getByText('987 visualizações').first()).toBeVisible()
const channelLink = section.getByRole('link', { name: /YouTube →/ })
await expect(channelLink).toHaveAttribute(
'href',
'https://www.youtube.com/channel/UCe2eTestChannel',
)
await expect(channelLink).toHaveAttribute('target', '_blank')
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/')
const carousel = page.getByRole('region', { name: 'Conteúdos recentes' })
await expect(carousel).toBeVisible()
await expect(carousel.getByText('1 de 4 · deslize para ver os próximos')).toBeVisible()
await expect(carousel.getByText('E2e Vídeo excluído')).toHaveCount(0)
await page.goto('about:blank')
await expect(page).toHaveURL(/about:blank/)
} finally {
await setYouTubeStubState(request, 'ok')
await cleanupS2Fixtures(request, headers)
}
})
test('hides YouTube cards and keeps articles when the API fails without a snapshot', async ({
page,
request,
}) => {
await seedTestUser()
const headers = await adminHeaders(request)
await setYouTubeStubState(request, 'fail')
const fallbackTag = await createTag(
request,
headers,
`E2e Fallback ${runSuffix}`,
`e2e-fallback-${runSuffix}`,
)
await createPost(request, headers, {
title: 'E2e Artigo fallback',
slug: `e2e-artigo-fallback-${runSuffix}`,
type: 'noticia',
category: fallbackTag.id,
publishedDate: new Date(Date.now() - 2 * 60 * 60_000).toISOString(),
})
await updateSocialFeedSettings(request, headers, {
enabled: true,
youtubeEnabled: true,
youtubeApiKey: 'e2e-youtube-api-key',
youtubeChannelId: 'UCe2eTestChannel',
youtubeMaxItems: 3,
excludedItems: [],
youtubeFeedSnapshot: null,
})
try {
// The feed is configured, so the "YouTube →" link stays; without a
// snapshot the cards are silently absent and the article carries the
// section (page never breaks, no error shown).
await waitForHomeHTML(
request,
['E2e Artigo fallback', 'YouTube →'],
['E2e Vídeo em destaque'],
)
await page.goto('/')
const section = page.locator('[data-home-section="contents"]')
await expect(section).toBeVisible()
await expect(section.getByRole('link', { name: /E2e Artigo fallback/ })).toBeVisible()
await expect(section.getByText('E2e Vídeo em destaque')).toHaveCount(0)
await expect(section.getByText('E2e Vídeo de caravana')).toHaveCount(0)
await expect(section.getByRole('link', { name: /YouTube →/ })).toBeVisible()
await page.goto('about:blank')
await expect(page).toHaveURL(/about:blank/)
} finally {
await setYouTubeStubState(request, 'ok')
await cleanupS2Fixtures(request, headers)
}
})
test('keeps the last snapshot while the API is down', async ({ page, request }) => {
await seedTestUser()
const headers = await adminHeaders(request)
await setYouTubeStubState(request, 'ok')
const baseSettings = {
enabled: true,
youtubeEnabled: true,
youtubeApiKey: 'e2e-youtube-api-key',
youtubeChannelId: 'UCe2eTestChannel',
youtubeMaxItems: 3,
excludedItems: [{ platform: 'youtube', itemId: 'e2e-video-excluido-4' }],
youtubeFeedSnapshot: null,
}
await updateSocialFeedSettings(request, headers, baseSettings)
try {
// Live fetch succeeds and persists the raw snapshot.
await waitForHomeHTML(request, ['E2e Vídeo em destaque'], ['E2e Vídeo excluído'])
await page.goto('/')
await expect(page.getByRole('link', { name: /E2e Vídeo em destaque/ }).first()).toBeVisible()
await page.goto('about:blank')
await expect(page).toHaveURL(/about:blank/)
// API down + a settings change: the re-execution must serve the snapshot
// re-filtered by the CURRENT exclusions/maxItems (raw snapshot, applied
// on read) — maxItems 2 proves the re-run read the new settings. The
// snapshot field is OMITTED on purpose: an update merges over the
// existing doc (Payload update semantics), so the persisted snapshot
// survives the settings edit — exactly the production admin path.
await setYouTubeStubState(request, 'fail')
const { youtubeFeedSnapshot: _ignored, ...settingsWithoutSnapshot } = baseSettings
await updateSocialFeedSettings(request, headers, {
...settingsWithoutSnapshot,
youtubeMaxItems: 2,
})
await waitForHomeHTML(
request,
['E2e Vídeo em destaque', 'E2e Vídeo de caravana'],
['E2e Vídeo de entrevista'],
)
await page.goto('/')
await expect(page.getByRole('link', { name: /E2e Vídeo em destaque/ }).first()).toBeVisible()
await expect(page.getByRole('link', { name: /E2e Vídeo de caravana/ })).toBeVisible()
await expect(page.getByText('E2e Vídeo de entrevista')).toHaveCount(0)
await page.goto('about:blank')
await expect(page).toHaveURL(/about:blank/)
// API back: the live feed returns with the original cap.
await setYouTubeStubState(request, 'ok')
await updateSocialFeedSettings(request, headers, baseSettings)
await waitForHomeHTML(request, ['E2e Vídeo de entrevista'], ['E2e Vídeo excluído'])
await page.goto('/')
await expect(page.getByText('E2e Vídeo de entrevista').first()).toBeVisible()
await page.goto('about:blank')
await expect(page).toHaveURL(/about:blank/)
} finally {
await setYouTubeStubState(request, 'ok')
await cleanupS2Fixtures(request, headers)
}
})
test('the kill switch pauses the external feed without touching articles', async ({
page,
request,
}) => {
await seedTestUser()
const headers = await adminHeaders(request)
await setYouTubeStubState(request, 'ok')
const killTag = await createTag(
request,
headers,
`E2e Kill ${runSuffix}`,
`e2e-kill-${runSuffix}`,
)
await createPost(request, headers, {
title: 'E2e Artigo kill switch',
slug: `e2e-artigo-kill-${runSuffix}`,
type: 'noticia',
category: killTag.id,
publishedDate: new Date(Date.now() - 3 * 60 * 60_000).toISOString(),
})
await updateSocialFeedSettings(request, headers, {
enabled: false,
youtubeEnabled: true,
youtubeApiKey: 'e2e-youtube-api-key',
youtubeChannelId: 'UCe2eTestChannel',
youtubeMaxItems: 3,
excludedItems: [],
youtubeFeedSnapshot: null,
})
try {
// `enabled: false` pauses the whole external board: no cards AND no
// channel link, while the article section lives on.
await waitForHomeHTML(
request,
['E2e Artigo kill switch'],
['E2e Vídeo em destaque', 'YouTube →'],
)
await page.goto('/')
const section = page.locator('[data-home-section="contents"]')
await expect(section).toBeVisible()
await expect(section.getByRole('link', { name: /E2e Artigo kill switch/ })).toBeVisible()
await expect(section.getByText('E2e Vídeo em destaque')).toHaveCount(0)
await expect(section.getByRole('link', { name: /YouTube →/ })).toHaveCount(0)
await page.goto('about:blank')
await expect(page).toHaveURL(/about:blank/)
} finally {
await setYouTubeStubState(request, 'ok')
await cleanupS2Fixtures(request, headers)
}
})
})

149
tests/e2e/youtube-stub.mjs Normal file
View file

@ -0,0 +1,149 @@
/**
* Deterministic YouTube Data API v3 stub for e2e — the campaign home content
* board (S2) fetches the feed server-side, so the Next dev/prod server points
* `YOUTUBE_API_BASE_URL` here (see playwright.config.ts) and this process
* answers `search`/`videos` with a fixed fixture. `POST /__stub/state` with
* `{ "state": "ok" | "fail" }` makes the API fail (HTTP 500) so specs can
* exercise the fail-closed/snapshot paths without any network. Thumbnails are
* served locally from `/thumbs/<id>.jpg` — the sandbox never reaches
* `i.ytimg.com`, and next/image would 404 (a console error the e2e guard
* treats as a failure).
*/
import { createServer } from 'node:http'
const PORT = Number(process.env.YOUTUBE_STUB_PORT ?? 4000)
const CHANNEL_ID = 'UCe2eTestChannel'
const ORIGIN = `http://localhost:${PORT}`
// 1x1 transparent PNG — enough for the optimizer to proxy successfully.
const THUMBNAIL_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
)
const minutesAgo = (minutes) => new Date(Date.now() - minutes * 60_000).toISOString()
const VIDEOS = [
{
id: 'e2e-video-destaque-1',
title: 'E2e Vídeo em destaque',
minutesAgo: 30,
views: 12400,
maxres: true,
},
{
id: 'e2e-video-caravana-2',
title: 'E2e Vídeo de caravana',
minutesAgo: 120,
views: 8100,
maxres: true,
},
{
id: 'e2e-video-entrevista-3',
title: 'E2e Vídeo de entrevista',
minutesAgo: 26 * 60,
views: 987,
maxres: false,
},
{
id: 'e2e-video-excluido-4',
title: 'E2e Vídeo excluído',
minutesAgo: 10,
views: 5_000_000,
maxres: true,
},
]
const thumbnails = (video) => {
const base = `${ORIGIN}/thumbs/${video.id}.jpg`
const common = {
default: { url: base },
medium: { url: base },
high: { url: base },
}
return video.maxres ? { ...common, maxres: { url: base } } : common
}
const searchResponse = () => ({
items: VIDEOS.map((video) => ({
id: { videoId: video.id },
snippet: {
publishedAt: minutesAgo(video.minutesAgo),
channelId: CHANNEL_ID,
title: video.title,
thumbnails: thumbnails(video),
},
})),
})
const statisticsResponse = (ids) => ({
items: ids
.split(',')
.filter(Boolean)
.map((id) => {
const video = VIDEOS.find((item) => item.id === id)
return { id, statistics: { viewCount: video ? String(video.views) : '0' } }
}),
})
let state = 'ok'
const json = (res, status, body) => {
res.writeHead(status, { 'content-type': 'application/json' })
res.end(JSON.stringify(body))
}
const server = createServer((req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`)
if (url.pathname === '/__stub/health') {
json(res, 200, { ok: true })
return
}
if (url.pathname === '/__stub/state' && req.method === 'POST') {
let body = ''
req.on('data', (chunk) => {
body += chunk
})
req.on('end', () => {
try {
const parsed = JSON.parse(body)
if (parsed.state === 'ok' || parsed.state === 'fail') state = parsed.state
} catch {
// keep the current state
}
json(res, 200, { state })
})
return
}
if (state === 'fail') {
res.writeHead(500)
res.end('stub fail')
return
}
if (url.pathname === '/search') {
json(res, 200, searchResponse())
return
}
if (url.pathname === '/videos') {
json(res, 200, statisticsResponse(url.searchParams.get('id') ?? ''))
return
}
if (url.pathname.startsWith('/thumbs/')) {
res.writeHead(200, { 'content-type': 'image/png' })
res.end(THUMBNAIL_PNG)
return
}
res.writeHead(404)
res.end('not found')
})
server.listen(PORT, () => {
console.log(`youtube e2e stub listening on :${PORT}`)
})

View file

@ -0,0 +1,11 @@
/**
* Port/URL of the YouTube Data API stub the e2e board (S2) fetches. The single
* spelling of the derivation: dev servers live in 3100..4099 (worktree slots,
* capped at 999), so +1000 never collides with another worktree's dev server;
* CI stays on 4000. Shared by playwright.config.ts (webServer + env) and the
* frontend spec (state switches) so the two cannot drift apart.
*/
export const youtubeStubUrlFor = (baseURL: string): string => {
const port = new URL(baseURL).port || (baseURL.startsWith('https:') ? '443' : '80')
return `http://localhost:${Number(port) + 1000}`
}

View file

@ -496,6 +496,11 @@ describe('src/utilities top-level is pinned', () => {
'voteEstimateScenarioFields.ts',
'votePledgeData.ts',
'votePledgeViews.ts',
// S2 — YouTube Data API v3 feed of the campaign home content board
// (fetch + cache + snapshot + formatters). A sibling of `posts.ts`, not a
// domain subfolder: only one external source so far — the Instagram feed
// (S3) would earn a `socialFeed/` subfolder instead of a third top-level.
'youtubeFeed.ts',
])
it('fails the build on an unregistered top-level module', () => {

View file

@ -0,0 +1,266 @@
import { describe, expect, it } from 'vitest'
import type { YouTubeVideo } from '@/utilities/youtubeFeed'
import {
YOUTUBE_MAX_RESULTS_CAP,
eligibleYouTubeVideos,
formatYouTubeViews,
loadYouTubeFeed,
parseYouTubeSearchResponse,
parseYouTubeVideosResponse,
pickThumbnailUrl,
} from '@/utilities/youtubeFeed'
const SEARCH_ITEM = (videoId: string, title: string, publishedAt: string, extra?: object) => ({
id: { videoId },
snippet: {
publishedAt,
title,
thumbnails: {
default: { url: `https://i.ytimg.com/vi/${videoId}/default.jpg` },
high: { url: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` },
maxres: { url: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg` },
},
},
...extra,
})
describe('formatYouTubeViews', () => {
it('keeps raw counts below 1k', () => {
expect(formatYouTubeViews(0)).toBe('0')
expect(formatYouTubeViews(987)).toBe('987')
expect(formatYouTubeViews(999)).toBe('999')
})
it('spells thousands in pt-BR short form with one comma decimal', () => {
expect(formatYouTubeViews(1_000)).toBe('1 mil')
expect(formatYouTubeViews(12_000)).toBe('12 mil')
expect(formatYouTubeViews(12_400)).toBe('12,4 mil')
expect(formatYouTubeViews(8_100)).toBe('8,1 mil')
expect(formatYouTubeViews(999_999)).toBe('1 mi')
})
it('spells millions and billions with mi/bi', () => {
expect(formatYouTubeViews(1_000_000)).toBe('1 mi')
expect(formatYouTubeViews(1_234_567)).toBe('1,2 mi')
expect(formatYouTubeViews(12_450_000)).toBe('12,5 mi')
expect(formatYouTubeViews(1_000_000_000)).toBe('1 bi')
expect(formatYouTubeViews(1_999_999_999)).toBe('2 bi')
})
})
describe('pickThumbnailUrl', () => {
it('prefers maxres, then high, then medium', () => {
const thumbnails = {
default: { url: 'default.jpg' },
medium: { url: 'medium.jpg' },
high: { url: 'high.jpg' },
maxres: { url: 'maxres.jpg' },
}
expect(pickThumbnailUrl(thumbnails)).toBe('maxres.jpg')
expect(pickThumbnailUrl({ high: { url: 'high.jpg' }, medium: { url: 'medium.jpg' } })).toBe(
'high.jpg',
)
expect(pickThumbnailUrl({ medium: { url: 'medium.jpg' } })).toBe('medium.jpg')
})
it('reads missing or malformed thumbnails as undefined', () => {
expect(pickThumbnailUrl(undefined)).toBeUndefined()
expect(pickThumbnailUrl(null)).toBeUndefined()
expect(pickThumbnailUrl({ high: { url: 42 } })).toBeUndefined()
expect(pickThumbnailUrl({ high: { url: '' } })).toBeUndefined()
})
})
describe('parseYouTubeSearchResponse', () => {
it('parses a valid search response into videos with the best thumbnail', () => {
const videos = parseYouTubeSearchResponse({
items: [
SEARCH_ITEM('video1', 'Primeiro vídeo', '2026-08-18T10:00:00Z'),
SEARCH_ITEM('video2', 'Segundo vídeo', '2026-08-17T10:00:00Z', {
snippet: {
publishedAt: '2026-08-17T10:00:00Z',
title: 'Segundo vídeo',
thumbnails: { high: { url: 'https://i.ytimg.com/vi/video2/hqdefault.jpg' } },
},
}),
],
})
expect(videos).toHaveLength(2)
expect(videos[0]).toEqual({
id: 'video1',
title: 'Primeiro vídeo',
publishedAt: '2026-08-18T10:00:00Z',
thumbnailUrl: 'https://i.ytimg.com/vi/video1/maxresdefault.jpg',
})
expect(videos[1].thumbnailUrl).toBe('https://i.ytimg.com/vi/video2/hqdefault.jpg')
})
it('skips malformed items but throws on protocol violations', () => {
expect(
parseYouTubeSearchResponse({
items: [
{ id: { notAVideo: true } },
{ id: { videoId: 'no-title' }, snippet: { publishedAt: '2026-08-18T10:00:00Z' } },
{ id: { videoId: 'no-date' }, snippet: { title: 'Sem data' } },
SEARCH_ITEM('ok1', 'Válido', '2026-08-18T10:00:00Z'),
],
}),
).toEqual([expect.objectContaining({ id: 'ok1', title: 'Válido' })])
expect(() => parseYouTubeSearchResponse({})).toThrow()
expect(() => parseYouTubeSearchResponse(null)).toThrow()
expect(() => parseYouTubeSearchResponse({ items: 'nope' })).toThrow()
})
})
describe('parseYouTubeVideosResponse', () => {
it('maps string viewCounts to numbers', () => {
const counts = parseYouTubeVideosResponse({
items: [
{ id: 'video1', statistics: { viewCount: '12400' } },
{ id: 'video2', statistics: { viewCount: '0' } },
{ id: 'no-count', statistics: {} },
{ id: 'bad-count', statistics: { viewCount: 'abc' } },
'malformed',
{ id: 'no-stats' },
],
})
expect(counts.get('video1')).toBe(12_400)
expect(counts.get('video2')).toBe(0)
expect(counts.has('no-count')).toBe(false)
expect(counts.has('bad-count')).toBe(false)
})
it('reads missing or malformed payloads as an empty map', () => {
expect(parseYouTubeVideosResponse(undefined).size).toBe(0)
expect(parseYouTubeVideosResponse({ items: 'nope' }).size).toBe(0)
expect(parseYouTubeVideosResponse(null).size).toBe(0)
})
})
describe('eligibleYouTubeVideos', () => {
const videos: YouTubeVideo[] = [
{ id: 'a', title: 'A', publishedAt: '2026-08-18T10:00:00Z' },
{ id: 'b', title: 'B', publishedAt: '2026-08-17T10:00:00Z' },
{ id: 'c', title: 'C', publishedAt: '2026-08-16T10:00:00Z' },
{ id: 'd', title: 'D', publishedAt: '2026-08-15T10:00:00Z' },
]
it('drops excluded ids and caps at maxItems, keeping API order', () => {
expect(eligibleYouTubeVideos(videos, ['b'], 2)).toEqual([videos[0], videos[2]])
expect(eligibleYouTubeVideos(videos, [], 3)).toEqual(videos.slice(0, 3))
expect(eligibleYouTubeVideos(videos, ['a', 'b', 'c', 'd'], 5)).toEqual([])
})
})
describe('loadYouTubeFeed', () => {
const fakeResponse = (body: unknown, ok = true): Response =>
({ ok, json: async () => body }) as unknown as Response
const calls: string[] = []
const fetchImpl = async (input: string): Promise<Response> => {
calls.push(input)
if (input.includes('/search')) {
return fakeResponse({
items: [
SEARCH_ITEM('v1', 'Vídeo 1', '2026-08-18T10:00:00Z'),
SEARCH_ITEM('v2', 'Vídeo 2', '2026-08-17T10:00:00Z'),
],
})
}
return fakeResponse({
items: [
{ id: 'v1', statistics: { viewCount: '12400' } },
{ id: 'v2', statistics: { viewCount: '8100' } },
],
})
}
const args = {
apiKey: 'test-key',
channelId: 'UCtest123',
maxResults: 3,
fetchImpl,
baseUrl: 'http://localhost:4000',
}
it('fetches search then statistics and merges view counts', async () => {
calls.length = 0
const videos = await loadYouTubeFeed(args)
expect(videos).toEqual([
{
id: 'v1',
title: 'Vídeo 1',
publishedAt: '2026-08-18T10:00:00Z',
thumbnailUrl: expect.stringContaining('v1'),
viewCount: 12_400,
},
{
id: 'v2',
title: 'Vídeo 2',
publishedAt: '2026-08-17T10:00:00Z',
thumbnailUrl: expect.stringContaining('v2'),
viewCount: 8_100,
},
])
const searchCall = calls[0]
expect(searchCall).toContain('/search?')
expect(searchCall).toContain('channelId=UCtest123')
expect(searchCall).toContain('type=video')
expect(searchCall).toContain('order=date')
expect(searchCall).toContain('key=test-key')
expect(calls[1]).toContain('/videos?')
expect(calls[1]).toContain('id=v1%2Cv2')
})
it('clamps maxResults to the API cap and the minimum of 1', async () => {
calls.length = 0
await loadYouTubeFeed({ ...args, maxResults: 100 })
expect(calls[0]).toContain(`maxResults=${YOUTUBE_MAX_RESULTS_CAP}`)
calls.length = 0
await loadYouTubeFeed({ ...args, maxResults: 0 })
expect(calls[0]).toContain('maxResults=1')
})
it('returns an empty feed without calling statistics when the channel has no public videos', async () => {
calls.length = 0
const videos = await loadYouTubeFeed({
...args,
fetchImpl: async (input) => {
calls.push(input)
return fakeResponse({ items: [] })
},
})
expect(videos).toEqual([])
expect(calls).toHaveLength(1)
})
it('throws on non-2xx responses and protocol violations (fail-closed to the snapshot)', async () => {
await expect(
loadYouTubeFeed({ ...args, fetchImpl: async () => fakeResponse({}, false) }),
).rejects.toThrow()
await expect(
loadYouTubeFeed({
...args,
fetchImpl: async () => fakeResponse({ items: 'nope' }),
}),
).rejects.toThrow()
})
it('throws when the statistics call fails', async () => {
await expect(
loadYouTubeFeed({
...args,
fetchImpl: async (input) =>
input.includes('/search')
? fakeResponse({ items: [SEARCH_ITEM('v1', 'Vídeo 1', '2026-08-18T10:00:00Z')] })
: fakeResponse({}, false),
}),
).rejects.toThrow(/statistics/)
})
})