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
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:
parent
50749270d6
commit
fe5b11cf24
25 changed files with 15424 additions and 138 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -23,6 +23,7 @@
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
/.next-e2e/
|
/.next-e2e/
|
||||||
|
/.next-e2e-check/
|
||||||
/out/
|
/out/
|
||||||
|
|
||||||
# production
|
# production
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
# Build outputs and tool caches
|
# Build outputs and tool caches
|
||||||
.next/
|
.next/
|
||||||
.next-e2e/
|
.next-e2e/
|
||||||
|
.next-e2e-check/
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
out/
|
out/
|
||||||
build/
|
build/
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- **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`.
|
- `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.
|
- **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.
|
- This POST is the required last step of the post-seed / direct-DB-change runbook.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 `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.
|
**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.
|
||||||
|
|
|
||||||
1
docs/changelog/2026-08-18-s2.md
Normal file
1
docs/changelog/2026-08-18-s2.md
Normal 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).
|
||||||
135
docs/plans/secao-conteudos-home-youtube-impl.md
Normal file
135
docs/plans/secao-conteudos-home-youtube-impl.md
Normal 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)
|
||||||
|
|
@ -7,7 +7,15 @@ const nextConfig = {
|
||||||
distDir: process.env.NEXT_DIST_DIR || '.next',
|
distDir: process.env.NEXT_DIST_DIR || '.next',
|
||||||
...(process.env.NEXT_OUTPUT_STANDALONE === '1' ? { output: 'standalone' } : {}),
|
...(process.env.NEXT_OUTPUT_STANDALONE === '1' ? { output: 'standalone' } : {}),
|
||||||
images: {
|
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/**' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV_NAME,
|
GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV_NAME,
|
||||||
GOOGLE_CALENDAR_TEST_KEY,
|
GOOGLE_CALENDAR_TEST_KEY,
|
||||||
} from './tests/helpers/googleCalendarTestKey'
|
} 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
|
* 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)
|
assertTestDatabase(process.env.DATABASE_URL)
|
||||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
|
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
|
||||||
const webServerPort = new URL(baseURL).port || (baseURL.startsWith('https:') ? '443' : '80')
|
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 workersOverride = process.env.PLAYWRIGHT_WORKERS
|
||||||
const workers = workersOverride === undefined ? 2 : Number(workersOverride)
|
const workers = workersOverride === undefined ? 2 : Number(workersOverride)
|
||||||
if (!Number.isInteger(workers) || workers < 2) {
|
if (!Number.isInteger(workers) || workers < 2) {
|
||||||
|
|
@ -120,48 +130,63 @@ export default defineConfig({
|
||||||
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
|
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
|
|
||||||
* route plus dev-server memory restarts made the full suite blow the 30-min
|
|
||||||
* job timeout on CI (measured 2026-07-30). The municipalities-list React
|
|
||||||
* #130 was fixed by sharing one Drawer across the mobile cards
|
|
||||||
* (CampaignListSheetProvider, 2026-07-30). Locally `pnpm dev` stays the
|
|
||||||
* default: no build wait and hot reload while writing specs.
|
|
||||||
*/
|
|
||||||
command: isProdMode ? 'pnpm start' : 'pnpm dev',
|
|
||||||
/*
|
|
||||||
* Never reuse a dev server a developer may already have running against the
|
|
||||||
* production database — always start a fresh one bound to the test DB.
|
|
||||||
*/
|
|
||||||
reuseExistingServer: false,
|
|
||||||
url: baseURL,
|
|
||||||
// Local-only tolerance for parallel-worktree load (machine load ~60); CI
|
|
||||||
// boots the production build and keeps the usual 60s budget. Keyed on CI
|
|
||||||
// alone (not `isProdMode`) on purpose: a local `E2E_PROD=1` run keeps the
|
|
||||||
// 240s budget — prod-mode here is a local mirror, not a real CI runner.
|
|
||||||
timeout: process.env.CI ? undefined : 240_000,
|
|
||||||
/* Force the isolated test database into the server process. */
|
|
||||||
env: {
|
|
||||||
DATABASE_URL: process.env.DATABASE_URL as string,
|
|
||||||
PORT: webServerPort,
|
|
||||||
/*
|
/*
|
||||||
* C122 — the fake service-account key lets the agenda Google mirror
|
* CI and E2E_PROD=1 serve the PRODUCTION build (`pnpm start` after a
|
||||||
* derive real states (synced/disabled/paused) in the server process.
|
* `pnpm build` step in the e2e job): dev-mode cold webpack compiles per
|
||||||
* It parses as a credential but fails locally at JWT signing, so any
|
* route plus dev-server memory restarts made the full suite blow the 30-min
|
||||||
* sync pass the hooks/auto-retry run fails fast WITHOUT network — the
|
* job timeout on CI (measured 2026-07-30). The municipalities-list React
|
||||||
* e2e states stay deterministic (see `googleCalendarTestKey.ts`).
|
* #130 was fixed by sharing one Drawer across the mobile cards
|
||||||
|
* (CampaignListSheetProvider, 2026-07-30). Locally `pnpm dev` stays the
|
||||||
|
* default: no build wait and hot reload while writing specs.
|
||||||
*/
|
*/
|
||||||
[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV_NAME]: GOOGLE_CALENDAR_TEST_KEY,
|
command: isProdMode ? 'pnpm start' : 'pnpm dev',
|
||||||
// Keep e2e artifacts outside `.next`: a concurrent development server
|
/*
|
||||||
// owns that entire directory and may clear nested production bundles.
|
* Never reuse a dev server a developer may already have running against the
|
||||||
NEXT_DIST_DIR: process.env.NEXT_DIST_DIR ?? '.next-e2e',
|
* production database — always start a fresh one bound to the test DB.
|
||||||
PAYLOAD_SECRET: process.env.PAYLOAD_SECRET ?? 'test-only-secret-not-used-in-production',
|
*/
|
||||||
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL ?? baseURL,
|
reuseExistingServer: false,
|
||||||
// Lets e2e specs bust the `posts` tag after direct REST deletes (cleanup),
|
url: baseURL,
|
||||||
// mirroring the documented post-seed runbook against the deployed site.
|
// Local-only tolerance for parallel-worktree load (machine load ~60); CI
|
||||||
REVALIDATE_SECRET: process.env.REVALIDATE_SECRET ?? 'e2e-revalidate-secret',
|
// boots the production build and keeps the usual 60s budget. Keyed on CI
|
||||||
|
// alone (not `isProdMode`) on purpose: a local `E2E_PROD=1` run keeps the
|
||||||
|
// 240s budget — prod-mode here is a local mirror, not a real CI runner.
|
||||||
|
timeout: process.env.CI ? undefined : 240_000,
|
||||||
|
/* Force the isolated test database into the server process. */
|
||||||
|
env: {
|
||||||
|
DATABASE_URL: process.env.DATABASE_URL as string,
|
||||||
|
PORT: webServerPort,
|
||||||
|
/*
|
||||||
|
* C122 — the fake service-account key lets the agenda Google mirror
|
||||||
|
* derive real states (synced/disabled/paused) in the server process.
|
||||||
|
* It parses as a credential but fails locally at JWT signing, so any
|
||||||
|
* sync pass the hooks/auto-retry run fails fast WITHOUT network — the
|
||||||
|
* e2e states stay deterministic (see `googleCalendarTestKey.ts`).
|
||||||
|
*/
|
||||||
|
[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV_NAME]: GOOGLE_CALENDAR_TEST_KEY,
|
||||||
|
// Keep e2e artifacts outside `.next`: a concurrent development server
|
||||||
|
// owns that entire directory and may clear nested production bundles.
|
||||||
|
NEXT_DIST_DIR: process.env.NEXT_DIST_DIR ?? '.next-e2e',
|
||||||
|
PAYLOAD_SECRET: process.env.PAYLOAD_SECRET ?? 'test-only-secret-not-used-in-production',
|
||||||
|
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL ?? baseURL,
|
||||||
|
// 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),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
80
src/components/CampaignContentCard.tsx
Normal file
80
src/components/CampaignContentCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
'use client'
|
'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'
|
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react'
|
||||||
|
|
||||||
const PROGRAMMATIC_SCROLL_LOCK_MS = 700
|
const PROGRAMMATIC_SCROLL_LOCK_MS = 700
|
||||||
|
|
||||||
type CampaignContentCarouselProps = {
|
type CampaignContentCarouselProps = {
|
||||||
ariaLabel: string
|
ariaLabel: string
|
||||||
items: CampaignArticleCardData[]
|
items: CampaignContentCardData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -95,7 +95,7 @@ export const CampaignContentCarousel = ({ ariaLabel, items }: CampaignContentCar
|
||||||
inert={index !== activeIndex || undefined}
|
inert={index !== activeIndex || undefined}
|
||||||
className="m-0 w-full shrink-0 snap-start"
|
className="m-0 w-full shrink-0 snap-start"
|
||||||
>
|
>
|
||||||
<CampaignArticleCard card={item} />
|
<CampaignContentCard card={item} />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { CampaignArticleCard, type CampaignArticleCardData } from '@/components/CampaignArticleCard'
|
import { CampaignContentCard, type CampaignContentCardData } from '@/components/CampaignContentCard'
|
||||||
import { CampaignContentCarousel } from '@/components/CampaignContentCarousel'
|
import { CampaignContentCarousel } from '@/components/CampaignContentCarousel'
|
||||||
import type { Post } from '@/payload-types'
|
import type { Post } from '@/payload-types'
|
||||||
import {
|
import {
|
||||||
|
|
@ -8,41 +8,87 @@ import {
|
||||||
getVisiblePosts,
|
getVisiblePosts,
|
||||||
POST_TYPE_BADGE_LABELS,
|
POST_TYPE_BADGE_LABELS,
|
||||||
} from '@/utilities/posts'
|
} from '@/utilities/posts'
|
||||||
|
import { formatYouTubeViews, getYouTubeFeed, type YouTubeVideo } from '@/utilities/youtubeFeed'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
const CONTENT_SECTION_LIMIT = 5
|
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)
|
const href = getPostCanonicalPath(post)
|
||||||
if (!href) return null
|
if (!href) return null
|
||||||
|
|
||||||
const cover =
|
const cover =
|
||||||
typeof post.coverImage === 'object' && post.coverImage !== null ? post.coverImage : null
|
typeof post.coverImage === 'object' && post.coverImage !== null ? post.coverImage : null
|
||||||
const categoryName = getCategoryName(post)
|
const categoryName = getCategoryName(post)
|
||||||
const date = formatRelativePostDate(post.publishedDate)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: post.id,
|
card: {
|
||||||
href,
|
id: post.id,
|
||||||
title: post.title,
|
href,
|
||||||
badgeLabel: POST_TYPE_BADGE_LABELS[post.type],
|
title: post.title,
|
||||||
meta: [date, categoryName].filter(Boolean).join(' · '),
|
badgeLabel: POST_TYPE_BADGE_LABELS[post.type],
|
||||||
...(cover?.url ? { coverUrl: cover.url, coverAlt: cover.alt ?? undefined } : {}),
|
meta: [formatRelativePostDate(post.publishedDate), categoryName].filter(Boolean).join(' · '),
|
||||||
...(post.subtitle ? { subtitle: post.subtitle } : {}),
|
...(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
|
* Campaign home content board: the 5 most recent items across the visible
|
||||||
* as a 1+4 bento on desktop and a one-per-screen carousel on mobile. Hides
|
* posts (S1) and the eligible YouTube feed (S2), newest first, as a 1+4 bento
|
||||||
* entirely when nothing is visible (`isPostVisible` fail-closed — hidden
|
* on desktop and a one-per-screen carousel on mobile. Hides entirely when
|
||||||
* electoral tags never leak).
|
* 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 () => {
|
export const CampaignContentSection = async () => {
|
||||||
const cards = (await getVisiblePosts())
|
const [visiblePosts, feed] = await Promise.all([getVisiblePosts(), getYouTubeFeed()])
|
||||||
.map(toCardData)
|
const articleCards = visiblePosts
|
||||||
.filter((card): card is CampaignArticleCardData => card !== null)
|
.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)
|
.slice(0, CONTENT_SECTION_LIMIT)
|
||||||
|
.map(({ card }) => card)
|
||||||
|
|
||||||
if (!cards.length) return null
|
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.
|
Bastidores, caravanas e as lutas do mandato: conteúdo atualizado, direto das redes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<div className="flex flex-wrap gap-4">
|
||||||
href="/artigos"
|
<Link href="/artigos" className={sectionHeaderLinkClassName}>
|
||||||
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"
|
Ver artigos →
|
||||||
>
|
</Link>
|
||||||
Ver artigos →
|
{feed ? (
|
||||||
</Link>
|
<a
|
||||||
|
href={`https://www.youtube.com/channel/${feed.channelId}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={sectionHeaderLinkClassName}
|
||||||
|
>
|
||||||
|
YouTube →
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 hidden grid-cols-4 gap-4 md:grid">
|
<div className="mt-6 hidden grid-cols-4 gap-4 md:grid">
|
||||||
<div className="col-span-2 row-span-2">
|
<div className="col-span-2 row-span-2">
|
||||||
<CampaignArticleCard card={featured} featured />
|
<CampaignContentCard card={featured} featured />
|
||||||
</div>
|
</div>
|
||||||
{rest.map((card) => (
|
{rest.map((card) => (
|
||||||
<CampaignArticleCard key={card.id} card={card} />
|
<CampaignContentCard key={card.id} card={card} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 md:hidden">
|
<div className="mt-5 md:hidden">
|
||||||
<CampaignContentCarousel ariaLabel="Artigos recentes" items={cards} />
|
<CampaignContentCarousel ariaLabel="Conteúdos recentes" items={cards} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
135
src/globals/SocialFeedSettings.ts
Normal file
135
src/globals/SocialFeedSettings.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
13717
src/migrations/20260818_111748_add_social_feed_settings.json
Normal file
13717
src/migrations/20260818_111748_add_social_feed_settings.json
Normal file
File diff suppressed because it is too large
Load diff
37
src/migrations/20260818_111748_add_social_feed_settings.ts
Normal file
37
src/migrations/20260818_111748_add_social_feed_settings.ts
Normal 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";`)
|
||||||
|
}
|
||||||
|
|
@ -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_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_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_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 = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -336,4 +337,9 @@ export const migrations = [
|
||||||
down: migration_20260811_234822_add_state_deputy_ballot_name.down,
|
down: migration_20260811_234822_add_state_deputy_ballot_name.down,
|
||||||
name: '20260811_234822_add_state_deputy_ballot_name',
|
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'
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,7 @@ export interface Config {
|
||||||
metadata: Metadatum;
|
metadata: Metadatum;
|
||||||
'privacy-policy': PrivacyPolicy;
|
'privacy-policy': PrivacyPolicy;
|
||||||
campaignGoals: CampaignGoal;
|
campaignGoals: CampaignGoal;
|
||||||
|
'social-feed-settings': SocialFeedSetting;
|
||||||
};
|
};
|
||||||
globalsSelect: {
|
globalsSelect: {
|
||||||
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
|
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
|
||||||
|
|
@ -165,6 +166,7 @@ export interface Config {
|
||||||
metadata: MetadataSelect<false> | MetadataSelect<true>;
|
metadata: MetadataSelect<false> | MetadataSelect<true>;
|
||||||
'privacy-policy': PrivacyPolicySelect<false> | PrivacyPolicySelect<true>;
|
'privacy-policy': PrivacyPolicySelect<false> | PrivacyPolicySelect<true>;
|
||||||
campaignGoals: CampaignGoalsSelect<false> | CampaignGoalsSelect<true>;
|
campaignGoals: CampaignGoalsSelect<false> | CampaignGoalsSelect<true>;
|
||||||
|
'social-feed-settings': SocialFeedSettingsSelect<false> | SocialFeedSettingsSelect<true>;
|
||||||
};
|
};
|
||||||
locale: null;
|
locale: null;
|
||||||
widgets: {
|
widgets: {
|
||||||
|
|
@ -2365,6 +2367,57 @@ export interface CampaignGoal {
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
createdAt?: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "site-settings_select".
|
* via the `definition` "site-settings_select".
|
||||||
|
|
@ -2450,6 +2503,29 @@ export interface CampaignGoalsSelect<T extends boolean = true> {
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
globalType?: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "collections_widget".
|
* via the `definition` "collections_widget".
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import { HomePage } from './globals/HomePage'
|
||||||
import { Metadata } from './globals/Metadata'
|
import { Metadata } from './globals/Metadata'
|
||||||
import { PrivacyPolicy } from './globals/PrivacyPolicy'
|
import { PrivacyPolicy } from './globals/PrivacyPolicy'
|
||||||
import { SiteSettings } from './globals/SiteSettings'
|
import { SiteSettings } from './globals/SiteSettings'
|
||||||
|
import { SocialFeedSettings } from './globals/SocialFeedSettings'
|
||||||
import { isPayloadAdmin } from './utilities/campaignAccess'
|
import { isPayloadAdmin } from './utilities/campaignAccess'
|
||||||
import { resolveS3StorageEnv } from './utilities/mediaStorage'
|
import { resolveS3StorageEnv } from './utilities/mediaStorage'
|
||||||
|
|
||||||
|
|
@ -124,7 +125,7 @@ export default buildConfig({
|
||||||
Post,
|
Post,
|
||||||
Tag,
|
Tag,
|
||||||
],
|
],
|
||||||
globals: [SiteSettings, HomePage, Metadata, PrivacyPolicy, CampaignGoals],
|
globals: [SiteSettings, HomePage, Metadata, PrivacyPolicy, CampaignGoals, SocialFeedSettings],
|
||||||
editor: lexicalEditor(),
|
editor: lexicalEditor(),
|
||||||
secret: process.env.PAYLOAD_SECRET || '',
|
secret: process.env.PAYLOAD_SECRET || '',
|
||||||
typescript: {
|
typescript: {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,19 @@ export const REVALIDATE_POSTS_TAG = 'posts' as const
|
||||||
|
|
||||||
export const REVALIDATE_PRIVACY_POLICY_CACHE_TAG = getGlobalCacheTag('privacy-policy')
|
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 = [
|
const ALLOWED_REVALIDATE_TAGS = [
|
||||||
REVALIDATE_POSTS_TAG,
|
REVALIDATE_POSTS_TAG,
|
||||||
REVALIDATE_PRIVACY_POLICY_CACHE_TAG,
|
REVALIDATE_PRIVACY_POLICY_CACHE_TAG,
|
||||||
ELECTION_TSE_CACHE_TAG,
|
ELECTION_TSE_CACHE_TAG,
|
||||||
MUNICIPALITY_CATALOG_CACHE_TAG,
|
MUNICIPALITY_CATALOG_CACHE_TAG,
|
||||||
|
REVALIDATE_SOCIAL_FEED_TAG,
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type AllowedRevalidateTag = (typeof ALLOWED_REVALIDATE_TAGS)[number]
|
type AllowedRevalidateTag = (typeof ALLOWED_REVALIDATE_TAGS)[number]
|
||||||
|
|
|
||||||
284
src/utilities/youtubeFeed.ts
Normal file
284
src/utilities/youtubeFeed.ts
Normal 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 },
|
||||||
|
)
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { APIRequestContext, Page } from '@playwright/test'
|
import type { APIRequestContext, Page } from '@playwright/test'
|
||||||
|
|
||||||
import { seedTestUser, testUser } from '../helpers/seedUser'
|
import { seedTestUser, testUser } from '../helpers/seedUser'
|
||||||
|
import { youtubeStubUrlFor } from '../helpers/youtubeStub'
|
||||||
import { expect, test } from './fixtures/e2eTest'
|
import { expect, test } from './fixtures/e2eTest'
|
||||||
|
|
||||||
const swipeLeft = async (
|
const swipeLeft = async (
|
||||||
|
|
@ -436,8 +437,14 @@ test.describe('Campaign home content section', () => {
|
||||||
const createdPosts: number[] = []
|
const createdPosts: number[] = []
|
||||||
const createdTags: number[] = []
|
const createdTags: number[] = []
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async ({ request }) => {
|
||||||
await seedTestUser()
|
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>> => {
|
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
|
// ISR serves the stale page while it regenerates after a revalidateTag
|
||||||
// (slower under the parallel suite), and a navigation that lands on it never
|
// (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
|
// refreshes its DOM. Poll the server HTML positively until it converges to
|
||||||
// the expected section state; the navigation that follows then always lands
|
// the expected substring state (section presence or actual content, e.g.
|
||||||
// on the fresh page.
|
// video titles); the navigation that follows then always lands on the fresh
|
||||||
const waitForHomeSectionState = async (
|
// page. 12 attempts preserves the S1 convergence budget.
|
||||||
|
const waitForHomeHTML = async (
|
||||||
request: APIRequestContext,
|
request: APIRequestContext,
|
||||||
expected: 'present' | 'absent',
|
includes: string[],
|
||||||
|
excludes: string[] = [],
|
||||||
attempts = 12,
|
attempts = 12,
|
||||||
) => {
|
) => {
|
||||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||||
const response = await request.get(`${baseURL}/`).catch(() => undefined)
|
const response = await request.get(`${baseURL}/`).catch(() => undefined)
|
||||||
if (response?.ok()) {
|
if (response?.ok()) {
|
||||||
const html = await response.text()
|
const html = await response.text()
|
||||||
const present = html.includes('data-home-section="contents"')
|
const allIncluded = includes.every((needle) => html.includes(needle))
|
||||||
if (present === (expected === 'present')) return
|
const anyExcluded = excludes.some((needle) => html.includes(needle))
|
||||||
|
if (allIncluded && !anyExcluded) return
|
||||||
}
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1_000))
|
await new Promise((resolve) => setTimeout(resolve, 1_000))
|
||||||
}
|
}
|
||||||
throw new Error(
|
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 }) => {
|
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
|
// 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.
|
// 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.setViewportSize({ width: 390, height: 844 })
|
||||||
await page.goto('/')
|
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).toBeVisible()
|
||||||
await expect(carousel.getByText('E2e Conteúdo oculto')).toHaveCount(0)
|
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
|
// prefetch answered after the deletion would 404 (guard noise). Leaving
|
||||||
// the page aborts in-flight prefetches and stops new ones.
|
// the page aborts in-flight prefetches and stops new ones.
|
||||||
await page.goto('about:blank')
|
await page.goto('about:blank')
|
||||||
|
await expect(page).toHaveURL(/about:blank/)
|
||||||
} finally {
|
} finally {
|
||||||
const headers2 = await adminHeaders(request).catch(() => undefined)
|
const headers2 = await adminHeaders(request).catch(() => undefined)
|
||||||
if (headers2) {
|
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
149
tests/e2e/youtube-stub.mjs
Normal 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}`)
|
||||||
|
})
|
||||||
11
tests/helpers/youtubeStub.ts
Normal file
11
tests/helpers/youtubeStub.ts
Normal 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}`
|
||||||
|
}
|
||||||
|
|
@ -496,6 +496,11 @@ describe('src/utilities top-level is pinned', () => {
|
||||||
'voteEstimateScenarioFields.ts',
|
'voteEstimateScenarioFields.ts',
|
||||||
'votePledgeData.ts',
|
'votePledgeData.ts',
|
||||||
'votePledgeViews.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', () => {
|
it('fails the build on an unregistered top-level module', () => {
|
||||||
|
|
|
||||||
266
tests/unit/youtubeFeed.unit.spec.ts
Normal file
266
tests/unit/youtubeFeed.unit.spec.ts
Normal 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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue