test(C244): e2e da faceta facial e manifesto de afetados

This commit is contained in:
Francisco Solla 2026-10-02 09:09:17 -03:00
parent ee3dda757b
commit 9a60807d4e
2 changed files with 151 additions and 3 deletions

View file

@ -243,6 +243,8 @@ export const E2E_AFFECTED_MANIFEST = [
// the C233 prefix above), the anonymous descriptor endpoint, the anonymous
// photo-face index and the bounded-body/rate-limit owners it shares. The
// migration makes every PR of this delivery high-risk (curated only).
// C244 — `faceIndex` now also feeds the album's curated-figure facet
// (`/fotos`), so a diff there wakes both specs.
prefixes: [
'src/app/(frontend)/api/fotos',
'src/lib/faceSearch.ts',
@ -252,7 +254,15 @@ export const E2E_AFFECTED_MANIFEST = [
'src/utilities/campaignConsent.ts',
'src/lib/campaignConsentKeys.ts',
],
specs: ['frontendFotosSelfie'],
specs: ['frontendFotos', 'frontendFotosSelfie'],
},
{
// C244 — the curated public figures of the album's `pessoa` facet: the
// figure catalog, the pure matcher and the collection that stores the
// curated references (the C233 entry above already maps
// `archivePhotoPublicCatalog`/`archivePhotoReads`).
prefixes: ['src/lib/faceFigureCatalog.ts', 'src/collections/FaceFigure.ts'],
specs: ['frontendFotos'],
},
{
// C234 — the bounded streaming body reader extracted from the beacon: both

View file

@ -3,6 +3,8 @@ import { randomUUID } from 'node:crypto'
import type { APIRequestContext, Page } from '@playwright/test'
import sharp from 'sharp'
import { FACE_INDEX_CONSENT_KEY } from '../../src/lib/campaignConsentKeys.js'
import { FACE_SEARCH_MODEL } from '../../src/lib/faceSearch.js'
import { getMunicipalityCatalogEntry } from '../../src/lib/municipalityCatalog.js'
import { adminHeaders } from '../helpers/adminApi'
import { seedTestUser } from '../helpers/seedUser'
@ -15,6 +17,13 @@ import { expect, test } from './fixtures/e2eTest'
* `approved→removed` edit pulls the photo down immediately and the
* `photoAlbum.published` kill switch closes the route without a deploy.
*
* C244 — the `pessoa` facet and "Quem aparece" come from the curated
* `faceFigure` catalog matched against the anonymous `archivePhotoFace` index:
* the spec seeds one figure + the descriptor of the Feira photo through the
* deployed REST API (the server process runs the real hooks), proves that a
* text-only `catalog.people` name no longer creates the facet, and that the
* public-notice Consent gates the whole layer.
*
* Photos and the global are seeded through the deployed REST API (admin
* session) so the server process runs the real cache hooks; a Local API call
* from the runner would throw on `revalidateTag` (same reason as the S21/S27
@ -26,6 +35,10 @@ import { expect, test } from './fixtures/e2eTest'
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
const REMOVAL_CHANNEL = 'https://example.org/acervo/remocao'
const FACE_FIGURE_SLUG = 'jorge-solla'
const FACE_FIGURE_NAME = 'Jorge Solla'
const FACE_VECTOR = Array.from({ length: 128 }, () => 0.25)
// Read-only catalog lookups (never pinned/written rows — see the allocator
// guard): the spec only mutates its own archive photos. A missing catalog entry
// is a broken fixture and fails loudly instead of deep-linking an empty slug.
@ -44,6 +57,8 @@ const feiraName = getMunicipalityCatalogEntry('feira-de-santana')?.name ?? ''
type Headers = Record<string, string>
const createdPhotoIds: number[] = []
const createdFigureIds: number[] = []
const createdConsentKeys = new Set<string>()
const uniqueMarker = () => randomUUID().slice(0, 8)
@ -130,6 +145,88 @@ const municipalityId = async (
return id
}
/** C244 — the public-notice Consent that gates the curated figure layer. */
const ensureFaceIndexConsent = async (
request: APIRequestContext,
headers: Headers,
): Promise<void> => {
const existing = await request.get(
`${BASE_URL}/api/consent?limit=1&depth=0&where[key][equals]=${encodeURIComponent(FACE_INDEX_CONSENT_KEY)}`,
{ headers },
)
expect(existing.ok(), await existing.text()).toBeTruthy()
const found = ((await existing.json()) as { docs: { id: number }[] }).docs[0]
if (found) return
const text = {
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [{ type: 'text', text: 'Aviso de teste C244 do índice facial.', version: 1 }],
direction: null,
format: '',
indent: 0,
version: 1,
},
],
direction: null,
format: '',
indent: 0,
version: 1,
},
}
const created = await request.post(`${BASE_URL}/api/consent`, {
headers,
data: { key: FACE_INDEX_CONSENT_KEY, text },
})
expect(created.ok(), await created.text()).toBeTruthy()
createdConsentKeys.add(FACE_INDEX_CONSENT_KEY)
}
/** One curated figure with a single reference descriptor (C244). */
const createFigure = async (request: APIRequestContext, headers: Headers): Promise<number> => {
const response = await request.post(`${BASE_URL}/api/faceFigure`, {
headers,
data: {
name: FACE_FIGURE_NAME,
slug: FACE_FIGURE_SLUG,
active: true,
references: [
{
model: FACE_SEARCH_MODEL,
vector: FACE_VECTOR,
source: 'retrato de teste (C244)',
addedAt: new Date().toISOString(),
},
],
},
})
expect(response.ok(), await response.text()).toBeTruthy()
const id = ((await response.json()) as { doc: { id: number } }).doc.id
createdFigureIds.push(id)
return id
}
/** One indexed face row, exactly like the batch writes it. */
const createFaceRow = async (
request: APIRequestContext,
headers: Headers,
data: { photo: number; vector: number[] },
): Promise<void> => {
const response = await request.post(`${BASE_URL}/api/archivePhotoFace`, {
headers,
data: {
photo: data.photo,
model: FACE_SEARCH_MODEL,
detectedAt: new Date().toISOString(),
vector: data.vector,
},
})
expect(response.ok(), await response.text()).toBeTruthy()
}
/** Dynamic pages stream a transient hidden `S:` copy of the shell; wait it out. */
const waitForSettledPage = async (page: Page) => {
await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0)
@ -187,11 +284,39 @@ test.describe('Frontend Álbum de Fotos (C233)', () => {
status: 'approved',
})
await setPhotoStatus(request, headers, removedPhoto, 'removed')
// C244 — the curated layer: the notice Consent, one figure and the indexed
// face of the Feira photo (the Camacarí photo keeps only the text name, the
// negative case of the facet).
await ensureFaceIndexConsent(request, headers)
await createFaceRow(request, headers, { photo: approvedFeira, vector: FACE_VECTOR })
// The figure write busts the album cache last, so the first visit already
// computes the match over the seeded face row.
await createFigure(request, headers)
})
test.afterAll(async ({ request }) => {
const cleanupHeaders = await adminHeaders(request, BASE_URL).catch(() => null)
if (cleanupHeaders) {
for (const id of createdFigureIds.splice(0)) {
await request
.delete(`${BASE_URL}/api/faceFigure/${id}`, { headers: cleanupHeaders })
.catch(() => undefined)
}
for (const key of createdConsentKeys) {
const lookup = await request
.get(
`${BASE_URL}/api/consent?limit=1&depth=0&where[key][equals]=${encodeURIComponent(key)}`,
{ headers: cleanupHeaders },
)
.catch(() => null)
const doc = lookup ? ((await lookup.json()) as { docs: { id: number }[] }).docs[0] : null
if (doc) {
await request
.delete(`${BASE_URL}/api/consent/${doc.id}`, { headers: cleanupHeaders })
.catch(() => undefined)
}
}
for (const id of createdPhotoIds.splice(0)) {
await request
.delete(`${BASE_URL}/api/archivePhoto/${id}`, { headers: cleanupHeaders })
@ -216,7 +341,9 @@ test.describe('Frontend Álbum de Fotos (C233)', () => {
await expect(camacariCard).toBeVisible()
await expect(feiraCard.getByText(`Plenária da saúde ${marker}`)).toBeVisible()
await expect(feiraCard.getByText(`12 set 2026 · ${feiraName} · Plenária`)).toBeVisible()
// C244 — "Quem aparece" comes from the recognized figure, not the text.
await expect(feiraCard.getByText('Jorge Solla')).toBeVisible()
await expect(camacariCard.getByText('Rui Costa')).toHaveCount(0)
await expect(page.locator(`a[data-photo-id="${draftPhoto}"]`)).toHaveCount(0)
await expect(page.locator(`a[data-photo-id="${removedPhoto}"]`)).toHaveCount(0)
@ -255,10 +382,21 @@ test.describe('Frontend Álbum de Fotos (C233)', () => {
await expect(page.locator(`a[data-photo-id="${approvedFeira}"]`)).toBeVisible()
await expect(page.locator(`a[data-photo-id="${approvedCamacari}"]`)).toHaveCount(0)
// C244 — the facet follows the curated facial layer: Jorge Solla was
// recognized in the Feira photo (seeded figure + indexed face)…
await page.goto(`/fotos?pessoa=${FACE_FIGURE_SLUG}`)
await waitForSettledPage(page)
await expect(page.getByRole('heading', { name: `Fotos com ${FACE_FIGURE_NAME}` })).toBeVisible()
await expect(page.locator(`a[data-photo-id="${approvedFeira}"]`)).toBeVisible()
await expect(page.locator(`a[data-photo-id="${approvedCamacari}"]`)).toHaveCount(0)
// …while the Camacarí photo names Rui Costa only in the ficha text: text
// no longer creates the facet, and the empty state explains the curation.
await page.goto('/fotos?pessoa=rui-costa')
await waitForSettledPage(page)
await expect(page.getByRole('heading', { name: 'Fotos com Rui Costa' })).toBeVisible()
await expect(page.locator(`a[data-photo-id="${approvedCamacari}"]`)).toBeVisible()
const textOnly = page.locator('[data-album-no-results]')
await expect(textOnly.getByText('Nada encontrado com esses filtros')).toBeVisible()
await expect(textOnly.getByText(/figuras reconhecidas com curadoria/)).toBeVisible()
await page.goto(`/fotos?q=${encodeURIComponent(marker)}`)
await waitForSettledPage(page)