mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
fix(S28): lê os headers só no caminho de tema e estabiliza o e2e do streaming
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions
This commit is contained in:
parent
8d81883509
commit
68065032f5
5 changed files with 46 additions and 16 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ContentPieceCatalogSearchParams>
|
||||
}) {
|
||||
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 (
|
||||
|
|
|
|||
|
|
@ -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<ThemeSearchHeaders> => (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<ContentPieceCatalogSearchData> => {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue