From 68065032f51bdcf9d0490e6119097076ea957b09 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 23 Sep 2026 14:54:40 -0300 Subject: [PATCH] =?UTF-8?q?fix(S28):=20l=C3=AA=20os=20headers=20s=C3=B3=20?= =?UTF-8?q?no=20caminho=20de=20tema=20e=20estabiliza=20o=20e2e=20do=20stre?= =?UTF-8?q?aming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/2026-09-23-s28.md | 2 +- .../central-conteudos-busca-semantica-impl.md | 2 +- .../(frontend)/conteudos/(catalog)/page.tsx | 9 ++--- .../content/contentPieceThemeSearch.ts | 16 +++++++-- tests/e2e/frontendConteudos.e2e.spec.ts | 33 ++++++++++++++++--- 5 files changed, 46 insertions(+), 16 deletions(-) diff --git a/docs/changelog/2026-09-23-s28.md b/docs/changelog/2026-09-23-s28.md index 14d7e1ad..3dce29b2 100644 --- a/docs/changelog/2026-09-23-s28.md +++ b/docs/changelog/2026-09-23-s28.md @@ -1 +1 @@ -**Busca por tema na Central de Conteúdos pública (S28, 2026-09-23):** o campo de busca de `/conteudos` ganhou o modo visível "Buscar por: Termo exato | ✦ Por tema" (exato é o padrão; a escolha vive na URL — `mode=tema`, só com `q` — e o form GET continua funcionando sem JS: Enter busca, mudar o modo submete; o botão de submit do S27 saiu, o CTA de pedido de voto segue no hero). O mecanismo é o do C192 generalizado por corpus (`expandSearchTheme(theme, 'speech' | 'contentPiece')`, mesmo contrato de falha/sanitização; o acervo de falas segue byte-idêntico), e o catálogo público passou a OR-ar o termo literal com os termos expandidos, sempre sobre as peças publicadas (kill switch intacto). Cada resultado diz por que apareceu: selo "✦ Tema" com o trecho real que casa (janela do transcript/descrição com a frase destacada) ou "Termo exato" quando só o `q` casou; quando o casamento veio de tema/cidade/instituição, o bloco mostra um trecho real, curto e rotulado, sem destaque — nunca score, nunca "parecido" enganoso, e o transcript completo não sai do servidor. Vazio honesto do tema ("Nenhuma peça combina bem com esse tema") com três saídas (reformular, usar termo exato, limpar filtros); indisponibilidade (sem chave, timeout, limite ou pedido não-browser) degrada para a busca exata com aviso âmbar discreto + "Tentar por tema novamente". A rota é pública e sem login, então nasceu a guarda anônima (`themeSearchGuard`): limiter in-memory por IP (8/15min, `cf-connecting-ip`→`x-forwarded-for`→`x-real-ip`→`unknown`), gate por Fetch Metadata (`navigate`/`cors`; `no-cors` recusado, scraper sem os headers fica no literal) e cache in-memory TTL 30min por termo normalizado (hit não consome cota; `{terms:[]}` legítimo é cacheado, `null` nunca) — estouro degrada, nunca 429. Achado do app renderizado: o Next 15 consome `RSC`/`Next-Router-Prefetch` antes do userland (o gate original por `rsc` degradava toda navegação client-side) — a correção usa os headers que chegam ao server e a UI mantém `prefetch={false}` em todo link que preserva o modo. Testes: 1 unit spec novo (guarda: elegibilidade/chave/limiter/cache) e 1 estendido (catálogo: modo/OR/proveniência), unit do expansor generalizado, 6 casos int no `contentPiece` (expansão injetada, degradação, lista sem termos, inelegível, sem modo, rascunho) e o e2e `frontendConteudos` estendido (segmented, degradação com aviso, Enter sem JS); pins do `E2E_AFFECTED_MANIFEST` (`contentPieceThemeSearch` na superfície S27; `frontendConteudos` no risk prefix `src/utilities/ai`). Sem migration/schema/Consent/access novo. Design: port classe-a-classe das cenas 01–09, crítica final certificada pelo tier primário (`Design tier: openai/gpt-5.6-sol`) contra o app renderizado (12 screenshots desktop/mobile + hover/foco), após duas rodadas que pediram evidência dos estados de hover/foco, do `{terms:[]}` e do vazio. +**Busca por tema na Central de Conteúdos pública (S28, 2026-09-23):** o campo de busca de `/conteudos` ganhou o modo visível "Buscar por: Termo exato | ✦ Por tema" (exato é o padrão; a escolha vive na URL — `mode=tema`, só com `q` — e o form GET continua funcionando sem JS: Enter busca, mudar o modo submete; o botão de submit do S27 saiu, o CTA de pedido de voto segue no hero). O mecanismo é o do C192 generalizado por corpus (`expandSearchTheme(theme, 'speech' | 'contentPiece')`, mesmo contrato de falha/sanitização; o acervo de falas segue byte-idêntico), e o catálogo público passou a OR-ar o termo literal com os termos expandidos, sempre sobre as peças publicadas (kill switch intacto). Cada resultado diz por que apareceu: selo "✦ Tema" com o trecho real que casa (janela do transcript/descrição com a frase destacada) ou "Termo exato" quando só o `q` casou; quando o casamento veio de tema/cidade/instituição, o bloco mostra um trecho real, curto e rotulado, sem destaque — nunca score, nunca "parecido" enganoso, e o transcript completo não sai do servidor. Vazio honesto do tema ("Nenhuma peça combina bem com esse tema") com três saídas (reformular, usar termo exato, limpar filtros); indisponibilidade (sem chave, timeout, limite ou pedido não-browser) degrada para a busca exata com aviso âmbar discreto + "Tentar por tema novamente". A rota é pública e sem login, então nasceu a guarda anônima (`themeSearchGuard`): limiter in-memory por IP (8/15min, `cf-connecting-ip`→`x-forwarded-for`→`x-real-ip`→`unknown`), gate por Fetch Metadata (`navigate`/`cors`; `no-cors` recusado, scraper sem os headers fica no literal) e cache in-memory TTL 30min por termo normalizado (hit não consome cota; `{terms:[]}` legítimo é cacheado, `null` nunca) — estouro degrada, nunca 429. Achado do app renderizado: o Next 15 consome `RSC`/`Next-Router-Prefetch` antes do userland (o gate original por `rsc` degradava toda navegação client-side) — a correção usa os headers que chegam ao server e a UI mantém `prefetch={false}` em todo link que preserva o modo; e a página não chama `headers()` (o loader lê os headers só no caminho de tema), porque a API dinâmica no render defere a página inteira atrás do `loading.tsx` e o shell chega em cópia oculta `S:` no build de produção — o catálogo literal (todo o S27) fica sem o artefato e o caminho de tema espera o settle no e2e (padrão C106). Testes: 1 unit spec novo (guarda: elegibilidade/chave/limiter/cache) e 1 estendido (catálogo: modo/OR/proveniência), unit do expansor generalizado, 6 casos int no `contentPiece` (expansão injetada, degradação, lista sem termos, inelegível, sem modo, rascunho) e o e2e `frontendConteudos` estendido (segmented, degradação com aviso, Enter sem JS); pins do `E2E_AFFECTED_MANIFEST` (`contentPieceThemeSearch` na superfície S27; `frontendConteudos` no risk prefix `src/utilities/ai`). Sem migration/schema/Consent/access novo. Design: port classe-a-classe das cenas 01–09, crítica final certificada pelo tier primário (`Design tier: openai/gpt-5.6-sol`) contra o app renderizado (12 screenshots desktop/mobile + hover/foco), após duas rodadas que pediram evidência dos estados de hover/foco, do `{terms:[]}` e do vazio. diff --git a/docs/plans/central-conteudos-busca-semantica-impl.md b/docs/plans/central-conteudos-busca-semantica-impl.md index 3b9c9c50..b6133069 100644 --- a/docs/plans/central-conteudos-busca-semantica-impl.md +++ b/docs/plans/central-conteudos-busca-semantica-impl.md @@ -63,7 +63,7 @@ flowchart LR **Opções:** A) no RSC da página (padrão C192 `speechPageData.ts:176,184-191`) com o timeout do dono; B) route handler GET próprio + fetch do client; C) Suspense/streaming com fallback. **Recomendação:** **A** — a página já é dinâmica (`searchParams`), o `loading.tsx` do S27 cobre a espera com o skeleton, a degradação é renderizada no mesmo HTML (funciona sem JS) e não nasce rota/URL nova. O timeout continua o do dono (**4s**, `expandSpeechSearchTheme.ts:14,67`): um segundo timeout para o mesmo mecanismo seria divergência gratuita. A expansão só roda com `mode=tema` + `q` e depois da leitura cacheada; a lista literal nunca espera por ela quando o modo não foi pedido. -**Rejeitadas:** **B** porque exige JS, segundo round-trip e um endpoint público novo (superfície e custo) e duplicaria o caminho sem JS; **C** porque o swap progressivo precisa de boundary client e de um estado que o design não cobre. **Gatilho:** se a latência percebida doer (mobile/conexão ruim), reavaliar com cena nova no designer — não inventar streaming sem design. +**Rejeitadas:** **B** porque exige JS, segundo round-trip e um endpoint público novo (superfície e custo) e duplicaria o caminho sem JS; **C** porque o swap progressivo precisa de boundary client e de um estado que o design não cobre. **Gatilho:** se a latência percebida doer (mobile/conexão ruim), reavaliar com cena nova no designer — não inventar streaming sem design. **Registrado na execução:** a página **não** chama `headers()`; o loader lê o `next/headers` só no caminho de tema (o parâmetro `requestHeaders` segue injetável para os testes). Chamar a API dinâmica na página opta o render inteiro para trás do boundary do `loading.tsx` (build de produção) e o shell passa a chegar em cópia oculta `S:` — o DOM fica transitoriamente duplicado e os locators estritos do e2e quebram de forma determinística. Com a leitura condicional, o catálogo literal (todo o S27) não defere; o caminho de tema defere e o spec espera o settle (`div[id^="S:"]`, o padrão C106 já usado nos specs de campanha). ### D4 — Contrato de URL do modo diff --git a/src/app/(frontend)/conteudos/(catalog)/page.tsx b/src/app/(frontend)/conteudos/(catalog)/page.tsx index 61c698cc..6030d3f9 100644 --- a/src/app/(frontend)/conteudos/(catalog)/page.tsx +++ b/src/app/(frontend)/conteudos/(catalog)/page.tsx @@ -23,7 +23,6 @@ import { hasPublishedJingles } from '@/utilities/jingleReads' import { resolveOgImage } from '@/utilities/ogImageReads' import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo' import type { Metadata } from 'next' -import { headers } from 'next/headers' const CATALOG_TITLE = 'Central de Conteúdos — Peça voto pra Solla 1313' const CATALOG_DESCRIPTION = @@ -82,11 +81,7 @@ export default async function ConteudosPage({ }: { searchParams: Promise }) { - const [rawSearchParams, requestHeaders, showJingles] = await Promise.all([ - searchParams, - headers(), - hasPublishedJingles(), - ]) + const [rawSearchParams, showJingles] = await Promise.all([searchParams, hasPublishedJingles()]) const { publishedCount, params, @@ -96,7 +91,7 @@ export default async function ConteudosPage({ themeMode, themeUnavailable, themeApplied, - } = await loadContentPieceCatalogSearch({ rawSearchParams, requestHeaders }) + } = await loadContentPieceCatalogSearch({ rawSearchParams }) if (publishedCount === 0) { return ( diff --git a/src/utilities/content/contentPieceThemeSearch.ts b/src/utilities/content/contentPieceThemeSearch.ts index f13c0934..4b3a2eac 100644 --- a/src/utilities/content/contentPieceThemeSearch.ts +++ b/src/utilities/content/contentPieceThemeSearch.ts @@ -22,6 +22,7 @@ import { type ThemeSearchHeaders, } from '@/utilities/ai/themeSearchGuard' import { getPublishedContentPieceRecords } from '@/utilities/content/contentPieceReads' +import { headers } from 'next/headers' /** * S28 — the public catalogue search, both modes. The literal search is the @@ -55,13 +56,23 @@ const toPublicItems = ( .map((record) => toContentPiecePublicItem(record, { themeTerms })) .filter((item): item is ContentPiecePublicItem => item !== null) +/** The request headers via the dynamic API, imported here so the page stays static-ish. */ +const headersFromRequest = async (): Promise => (await headers()) as Headers + export const loadContentPieceCatalogSearch = async ({ rawSearchParams, requestHeaders, expandTheme = expandContentPieceSearchTheme, }: { rawSearchParams: ContentPieceCatalogSearchParams - requestHeaders: ThemeSearchHeaders + /** + * The request headers the anonymous guard reads. Optional: the page omits it + * and the loader reads `next/headers` ONLY on the theme path — the literal + * catalogue must not opt the render into the dynamic API, which defers the + * whole page behind the `loading.tsx` boundary (streamed into a hidden div) + * and leaves a transient duplicate of the DOM in the production build. + */ + requestHeaders?: ThemeSearchHeaders expandTheme?: ThemeSearchExpansionResolver }): Promise => { const records = await getPublishedContentPieceRecords() @@ -74,9 +85,10 @@ export const loadContentPieceCatalogSearch = async ({ // An empty Central has nothing to search: never pay for an expansion there. if (themeRequested && publishedItems.length > 0) { + const headers = requestHeaders ?? (await headersFromRequest()) const expansion = await resolveGuardedThemeExpansion({ q: params.q, - headers: requestHeaders, + headers, expandTheme, }) if (expansion) themeTerms = contentPieceThemeTerms(params.q, expansion.terms) diff --git a/tests/e2e/frontendConteudos.e2e.spec.ts b/tests/e2e/frontendConteudos.e2e.spec.ts index 0509a650..8076277c 100644 --- a/tests/e2e/frontendConteudos.e2e.spec.ts +++ b/tests/e2e/frontendConteudos.e2e.spec.ts @@ -139,6 +139,16 @@ const mediaRequests = (page: Page) => { return requests } +/** + * C106/S28 — dynamic pages stream a transient hidden `S:` copy of the shell in + * the production build (the theme path reads `next/headers`, and any heavy + * render can defer behind `loading.tsx`). Wait for the stream to be collected + * before asserting with strict locators. + */ +const waitForSettledPage = async (page: Page) => { + await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0) +} + test.describe.configure({ mode: 'serial' }) test.afterAll(async ({ request }) => { @@ -177,6 +187,7 @@ test.describe('Frontend Central de Conteúdos (S27)', () => { const response = await page.goto('/conteudos') expect(response?.status()).toBe(200) + await waitForSettledPage(page) await expect( page.getByRole('heading', { name: 'Uma mensagem sua pode conquistar mais um voto.' }), ).toBeVisible() @@ -239,6 +250,7 @@ test.describe('Frontend Central de Conteúdos (S27)', () => { await createPiece(request, headers, { title: `Card do giro ${marker}`, type: 'foto' }) await page.goto(`/conteudos?tipo=video`) + await waitForSettledPage(page) await expect(page.locator('article[data-content-piece]')).toHaveCount(1) await expect(page.getByRole('link', { name: videoTitle, exact: true })).toBeVisible() // A filtered board never shows the card invite. @@ -248,13 +260,12 @@ test.describe('Frontend Central de Conteúdos (S27)', () => { await expect(page.locator('article[data-content-piece]')).toHaveCount(2) await page.goto(`/conteudos?tipo=video&q=${marker}`) + await waitForSettledPage(page) await expect(page.locator('article[data-content-piece]')).toHaveCount(1) await expect(page.getByRole('link', { name: /Remover filtro Tipo: Vídeo/ })).toBeVisible() await page.goto('/conteudos?q=zzz-nao-existe') - await expect( - page.getByRole('heading', { name: 'Nenhuma peça com esses filtros' }), - ).toBeVisible() + await waitForSettledPage(page) await expect(page.getByText('Busca: “zzz-nao-existe”')).toBeVisible() const clear = page.getByRole('link', { name: 'Limpar filtros' }) await expect(clear).toHaveAttribute('href', '/conteudos') @@ -283,6 +294,7 @@ test.describe('Frontend Central de Conteúdos (S27)', () => { // theme mode always degrades: the exact results stay and the notice explains. const response = await page.goto(`/conteudos?q=${marker}&mode=tema`) expect(response?.status()).toBe(200) + await waitForSettledPage(page) await expect(page.getByRole('heading', { name: `Resultados para “${marker}”` })).toBeVisible() await expect(page.getByRole('radio', { name: 'Termo exato' })).toBeChecked() await expect(page.getByRole('radio', { name: 'Por tema' })).toBeDisabled() @@ -292,18 +304,25 @@ test.describe('Frontend Central de Conteúdos (S27)', () => { // A mode without a query has nothing to expand: no notice, exact is active. await page.goto('/conteudos?mode=tema') + await waitForSettledPage(page) await expect(page.getByText('Busca por tema indisponível agora.')).toHaveCount(0) await expect(page.getByRole('radio', { name: 'Termo exato' })).toBeChecked() await expect(page.getByRole('radio', { name: 'Por tema' })).toBeEnabled() // Changing the mode submits the GET form with the query in force. await page.goto(`/conteudos?q=${marker}`) + await waitForSettledPage(page) await page.getByRole('radio', { name: 'Por tema' }).check() await expect(page).toHaveURL(new RegExp(`q=${marker}&mode=tema$`)) + // The URL commits before the new document renders: wait for the page's own + // content and for the streamed copy to be collected before the strict locator. + await expect(page.getByRole('heading', { name: `Resultados para “${marker}”` })).toHaveCount(1) + await waitForSettledPage(page) await expect(page.getByText('Busca por tema indisponível agora.')).toBeVisible() // Enter on the field searches without JS (the canonical block has no button). await page.goto('/conteudos') + await waitForSettledPage(page) const field = page.getByRole('searchbox', { name: 'Buscar peças' }) await field.fill(marker) await field.press('Enter') @@ -324,11 +343,11 @@ test.describe('Frontend Central de Conteúdos (S27)', () => { 'download', `jorge-solla-1313-${photo.slug}.png`, ) - await expect(page.locator('meta[property="og:title"]')).toHaveAttribute( + await expect(page.locator('meta[property="og:title"]').first()).toHaveAttribute( 'content', new RegExp(photoTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), ) - await expect(page.locator('meta[property="og:image"]')).toHaveAttribute( + await expect(page.locator('meta[property="og:image"]').first()).toHaveAttribute( 'content', new RegExp(`/conteudos/${photo.slug}/midia$`), ) @@ -357,6 +376,7 @@ test.describe('Frontend Central de Conteúdos (S27)', () => { }) await page.goto('/conteudos') + await waitForSettledPage(page) await expect(page.locator('article[data-content-piece]')).toHaveCount(1) await expect(page.getByRole('link', { name: `Baixar ${linkTitle}` })).toHaveCount(0) @@ -458,12 +478,14 @@ test.describe('Frontend Central de Conteúdos (S27)', () => { const piece = await createPiece(request, headers, { title, type: 'video' }) await page.goto('/conteudos') + await waitForSettledPage(page) await expect(page.getByRole('link', { name: 'Conteúdos' })).toBeVisible() await expect(page.locator('article[data-content-piece]')).toHaveCount(1) await setPieceStatus(request, headers, piece.id, 'rascunho') await page.goto('/conteudos') + await waitForSettledPage(page) await expect( page.getByRole('heading', { name: 'As primeiras peças estão a caminho' }), ).toBeVisible() @@ -484,6 +506,7 @@ test.describe('Frontend Central de Conteúdos (S27)', () => { await setPieceStatus(request, headers, piece.id, 'publicado') await page.goto('/conteudos') + await waitForSettledPage(page) await expect(page.locator('article[data-content-piece]')).toHaveCount(1) await expect(page.getByRole('link', { name: 'Conteúdos' })).toBeVisible() })