teqo-backup/tests/int/archivePhotoCatalog.int.spec.ts
Francisco Solla 930db3eb02 feat(C245): catalogar o acervo em produção por camadas — metadados primeiro
A catalogação do C232 nunca rodou em produção: catalog.* zerado nas 6.492
fotos publicadas e o engine de visão fora do ar. O pnpm archive:catalog
ganha --metadata-only: deriva município (gazetteer) e temas do texto já
ingerido, sem engine e sem mídia, grava catalog.metadataCheckedAt
(migration aditiva) e não marca catalogedAt — a fila da IA segue aberta.
Nunca escreve catalog.people (C244 assume a faceta por facial).

A passada de IA passa a carimbar os dois markers; a fila de metadados exclui
linha já catalogada pela IA (não rebaixa o source); o --verify relata as duas
camadas com exit pela selecionada; o --apply de metadados dispensa S3_* e
engine. Runbook §C245: metadados → revalidate archivePhotos → IA.

Closes #1417
2026-10-02 10:17:49 -03:00

467 lines
16 KiB
TypeScript

// @vitest-environment node
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { ARCHIVE_PHOTO_SLUG, type ArchivePhotoImport } from '@/lib/archivePhoto'
import type { ArchivePhotoSuggestion } from '@/lib/archivePhotoCatalog'
import { publicFigureCatalog } from '@/lib/publicFigureCatalog'
import config from '@/payload.config'
import {
catalogArchivePhoto,
catalogArchivePhotoMetadata,
listArchivePhotoCatalogQueue,
listArchivePhotoMetadataCatalogQueue,
type ArchivePhotoAnalyzer,
type ArchivePhotoCatalogItem,
} from '@/utilities/flickr/archivePhotoCatalog'
import { ingestArchivePhoto } from '@/utilities/flickr/archivePhotoIngest'
import { ARCHIVE_PHOTO_JPEG_BYTES } from '../helpers/archivePhotoFixture'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
// C232 — the cataloguing boundary over the real Payload `teqo_test`: the
// transactional write, the curated-wins rule, the honest failure (nothing
// written, retried) and the derived index (searchText/takenOn). The vision
// engine is a stub — the real provider never enters the suite.
let payload: Payload
const createdFlickrIds = new Set<string>()
const tempDirs: string[] = []
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const figureName = publicFigureCatalog[0]!.name
const record = (
flickrId: string,
overrides: Partial<ArchivePhotoImport> = {},
): ArchivePhotoImport => {
createdFlickrIds.add(flickrId)
return {
flickrId,
sourceUrl: `https://www.flickr.com/photos/depjorgesolla/${flickrId}/`,
owner: '12345678@N00',
license: '0',
title: `Foto ${flickrId}`,
description: null,
tags: [],
takenAt: '2025-03-14 10:20:30',
postedAt: '2025-03-15T10:00:00.000Z',
albums: [],
geo: null,
exif: [],
originalUrl: `https://live.staticflickr.com/65535/${flickrId}_abcdef_o.jpg`,
originalKind: 'original',
...overrides,
}
}
const createPhoto = async (
flickrId: string,
overrides: Partial<ArchivePhotoImport> = {},
): Promise<{ id: number; item: ArchivePhotoCatalogItem }> => {
const dir = await mkdtemp(join(tmpdir(), 'archive-photo-'))
tempDirs.push(dir)
const filePath = join(dir, `flickr-${flickrId}.jpg`)
await writeFile(filePath, ARCHIVE_PHOTO_JPEG_BYTES)
const created = await ingestArchivePhoto(payload, record(flickrId, overrides), { filePath })
if (created.status !== 'created') throw new Error(`fixture not created: ${created.status}`)
const queue = await listArchivePhotoCatalogQueue({ payload })
const item = queue.find((entry) => entry.flickrId === flickrId)
if (!item) throw new Error(`fixture not in the catalog queue: ${flickrId}`)
return { id: created.id, item }
}
const suggestion = (overrides: Partial<ArchivePhotoSuggestion> = {}): ArchivePhotoSuggestion => ({
caption: null,
description: null,
scene: null,
visibleText: null,
hasPeople: null,
themes: [],
...overrides,
})
const analyzeWith =
(answer: ArchivePhotoSuggestion): ArchivePhotoAnalyzer =>
async () =>
answer
const findByFlickrId = async (flickrId: string) => {
const found = await payload.find({
collection: ARCHIVE_PHOTO_SLUG,
where: { flickrId: { equals: flickrId } },
depth: 0,
limit: 1,
overrideAccess: true,
})
return found.docs[0] ?? null
}
const municipalityId = async (slug: string): Promise<number> => {
const found = await payload.find({
collection: 'municipality',
where: { slug: { equals: slug } },
depth: 0,
limit: 1,
overrideAccess: true,
})
const id = found.docs[0]?.id
if (id == null) throw new Error(`município fixture ausente: ${slug}`)
return id
}
describe('catalogArchivePhoto (C232)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
afterAll(async () => {
for (const flickrId of createdFlickrIds) {
await payload.delete({
collection: ARCHIVE_PHOTO_SLUG,
where: { flickrId: { equals: flickrId } },
overrideAccess: true,
})
}
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
})
it('catalogues one photo and derives the searchable index', async () => {
const feira = await municipalityId('feira-de-santana')
const { item } = await createPhoto('53900000001', {
title: 'Plenária em Feira de Santana',
albums: [{ albumId: '1', title: 'Plenária em Feira de Santana' }],
tags: ['saude'],
})
const result = await catalogArchivePhoto({
payload,
item,
analyze: analyzeWith(
suggestion({
caption: 'Plenária lotada no interior',
description: 'Solla discursa para uma plateia cheia.',
scene: 'plenaria',
visibleText: 'SAÚDE É DIREITO',
hasPeople: true,
themes: ['saude'],
}),
),
})
expect(result).toEqual({ status: 'cataloged', source: 'ai' })
const doc = await findByFlickrId('53900000001')
expect(doc?.catalog).toMatchObject({
caption: 'Plenária lotada no interior',
description: 'Solla discursa para uma plateia cheia.',
scene: 'plenaria',
visibleText: 'SAÚDE É DIREITO',
hasPeople: true,
themes: ['saude'],
municipality: feira,
source: 'ai',
})
expect(doc?.catalog?.catalogedAt).toBeTruthy()
expect(doc?.takenOn).toBe('2025-03-14T12:00:00.000Z')
expect(doc?.searchText).toContain('plenaria')
expect(doc?.searchText).toContain('feira de santana')
expect(doc?.searchText).toContain('saude')
expect(doc?.searchText).toContain('plenaria lotada no interior')
})
it('matches the curated public-figure catalog against the text, never the model', async () => {
const { item } = await createPhoto('53900000002', {
title: `Encontro com ${figureName}`,
})
expect(item.title).toContain(figureName)
const result = await catalogArchivePhoto({
payload,
item,
analyze: analyzeWith(suggestion({ caption: 'Encontro político' })),
})
expect(result).toEqual({ status: 'cataloged', source: 'ai' })
const doc = await findByFlickrId('53900000002')
expect(doc?.catalog?.people).toEqual([figureName])
})
it('states metadata when the model has nothing to propose', async () => {
const feira = await municipalityId('feira-de-santana')
const { item } = await createPhoto('53900000003', {
title: 'Visita a Feira de Santana',
})
const result = await catalogArchivePhoto({
payload,
item,
analyze: analyzeWith(suggestion()),
})
expect(result).toEqual({ status: 'cataloged', source: 'metadata' })
const doc = await findByFlickrId('53900000003')
expect(doc?.catalog?.municipality).toBe(feira)
expect(doc?.catalog?.source).toBe('metadata')
expect(doc?.catalog?.catalogedAt).toBeTruthy()
})
it('states none, honestly, when there is nothing at all', async () => {
const { item } = await createPhoto('53900000004')
const result = await catalogArchivePhoto({
payload,
item,
analyze: analyzeWith(suggestion()),
})
expect(result).toEqual({ status: 'cataloged', source: 'none' })
const doc = await findByFlickrId('53900000004')
expect(doc?.catalog?.source).toBe('none')
expect(doc?.catalog?.catalogedAt).toBeTruthy()
})
it('never overwrites a curated field', async () => {
const feira = await municipalityId('feira-de-santana')
const { id, item } = await createPhoto('53900000005', {
title: 'Plenária em Feira de Santana',
})
const admin = await campaignFixtures().createAdminUser()
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id,
data: { catalog: { caption: 'Legenda humana', municipality: feira } },
user: admin,
overrideAccess: false,
})
const result = await catalogArchivePhoto({
payload,
item,
analyze: analyzeWith(suggestion({ caption: 'Legenda da IA' })),
})
expect(result).toEqual({ status: 'cataloged', source: 'none' })
const doc = await findByFlickrId('53900000005')
expect(doc?.catalog?.caption).toBe('Legenda humana')
expect(doc?.catalog?.municipality).toBe(feira)
expect(doc?.curatedFields).toEqual(expect.arrayContaining(['caption', 'municipality']))
})
it('reports a failed analysis, writes nothing and lets the next run retry', async () => {
const { item } = await createPhoto('53900000006', { title: 'Plenária em Feira de Santana' })
const failing: ArchivePhotoAnalyzer = async () => {
throw new Error('engine fora do ar')
}
const failed = await catalogArchivePhoto({ payload, item, analyze: failing })
expect(failed).toEqual({ status: 'failed', stage: 'analyze', error: 'engine fora do ar' })
expect((await findByFlickrId('53900000006'))?.catalog?.catalogedAt ?? null).toBeNull()
const retried = await catalogArchivePhoto({
payload,
item,
analyze: analyzeWith(suggestion({ caption: 'Segunda tentativa' })),
})
expect(retried).toEqual({ status: 'cataloged', source: 'ai' })
expect((await findByFlickrId('53900000006'))?.catalog?.caption).toBe('Segunda tentativa')
})
it('skips a photo catalogued after the queue was read', async () => {
const { item } = await createPhoto('53900000007')
await catalogArchivePhoto({
payload,
item,
analyze: analyzeWith(suggestion({ caption: 'Primeira passada' })),
})
const second = await catalogArchivePhoto({
payload,
item,
analyze: analyzeWith(suggestion({ caption: 'Segunda passada' })),
})
expect(second).toEqual({ status: 'skipped' })
expect((await findByFlickrId('53900000007'))?.catalog?.caption).toBe('Primeira passada')
expect(
(await listArchivePhotoCatalogQueue({ payload })).map((entry) => entry.flickrId),
).not.toContain('53900000007')
})
it('caps the queue with the canary limit and marks curation only on user edits', async () => {
const { id } = await createPhoto('53900000008')
await createPhoto('53900000009')
const queued = await listArchivePhotoCatalogQueue({ payload, limit: 1 })
expect(queued).toHaveLength(1)
const admin = await campaignFixtures().createAdminUser()
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id,
data: { catalog: { scene: 'evento' } },
user: admin,
overrideAccess: false,
})
expect((await findByFlickrId('53900000008'))?.curatedFields).toEqual(['scene'])
// A no-op admin save marks nothing new.
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id,
data: { catalog: { scene: 'evento' } },
user: admin,
overrideAccess: false,
})
expect((await findByFlickrId('53900000008'))?.curatedFields).toEqual(['scene'])
// The system write (the cataloguing itself) never marks.
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id,
data: { catalog: { caption: 'escrita do sistema' } },
context: { archivePhotoCatalog: true },
overrideAccess: true,
})
expect((await findByFlickrId('53900000008'))?.curatedFields).toEqual(['scene'])
})
})
describe('catalogArchivePhotoMetadata (C245)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
afterAll(async () => {
for (const flickrId of createdFlickrIds) {
await payload.delete({
collection: ARCHIVE_PHOTO_SLUG,
where: { flickrId: { equals: flickrId } },
overrideAccess: true,
})
}
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
})
it('derives municipality and themes without the model, keeps the AI queue open and never writes people', async () => {
const feira = await municipalityId('feira-de-santana')
const { item } = await createPhoto('53900002001', {
title: 'Plenária em Feira de Santana',
tags: ['saude'],
})
const result = await catalogArchivePhotoMetadata({ payload, item })
expect(result).toEqual({ status: 'cataloged', source: 'metadata' })
const doc = await findByFlickrId('53900002001')
expect(doc?.catalog?.municipality).toBe(feira)
expect(doc?.catalog?.themes).toEqual(['saude'])
expect(doc?.catalog?.metadataCheckedAt).toBeTruthy()
expect(doc?.catalog?.catalogedAt ?? null).toBeNull()
expect(doc?.catalog?.people ?? []).toEqual([])
expect(doc?.catalog?.caption ?? null).toBeNull()
expect(
(await listArchivePhotoMetadataCatalogQueue({ payload })).map((entry) => entry.flickrId),
).not.toContain('53900002001')
expect(
(await listArchivePhotoCatalogQueue({ payload })).map((entry) => entry.flickrId),
).toContain('53900002001')
})
it('keeps both layers consistent: the AI after the metadata preserves the fields and stamps both markers', async () => {
const feira = await municipalityId('feira-de-santana')
const { item } = await createPhoto('53900002002', { title: 'Plenária em Feira de Santana' })
await catalogArchivePhotoMetadata({ payload, item })
const result = await catalogArchivePhoto({
payload,
item,
analyze: analyzeWith(suggestion({ caption: 'Plenária lotada', scene: 'plenaria' })),
})
expect(result).toEqual({ status: 'cataloged', source: 'ai' })
const doc = await findByFlickrId('53900002002')
expect(doc?.catalog?.caption).toBe('Plenária lotada')
expect(doc?.catalog?.scene).toBe('plenaria')
expect(doc?.catalog?.municipality).toBe(feira)
expect(doc?.catalog?.catalogedAt).toBeTruthy()
expect(doc?.catalog?.metadataCheckedAt).toBeTruthy()
})
it('never reopens a row already catalogued by the AI (legacy without the metadata marker)', async () => {
const { id, item } = await createPhoto('53900002003', { title: 'Plenária em Feira de Santana' })
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id,
data: {
catalog: { caption: 'Legado', source: 'ai', catalogedAt: new Date().toISOString() },
},
context: { archivePhotoCatalog: true },
overrideAccess: true,
})
expect((await findByFlickrId('53900002003'))?.catalog?.metadataCheckedAt ?? null).toBeNull()
expect(
(await listArchivePhotoMetadataCatalogQueue({ payload })).map((entry) => entry.flickrId),
).not.toContain('53900002003')
expect(await catalogArchivePhotoMetadata({ payload, item })).toEqual({ status: 'skipped' })
expect((await findByFlickrId('53900002003'))?.catalog?.source).toBe('ai')
})
it('never overwrites a curated municipality or themes', async () => {
const feira = await municipalityId('feira-de-santana')
const { id, item } = await createPhoto('53900002004', {
title: 'Plenária em Feira de Santana',
tags: ['saude'],
})
const admin = await campaignFixtures().createAdminUser()
await payload.update({
collection: ARCHIVE_PHOTO_SLUG,
id,
data: { catalog: { municipality: feira, themes: ['esporte'] } },
user: admin,
overrideAccess: false,
})
const result = await catalogArchivePhotoMetadata({ payload, item })
expect(result).toEqual({ status: 'cataloged', source: 'none' })
const doc = await findByFlickrId('53900002004')
expect(doc?.catalog?.municipality).toBe(feira)
expect(doc?.catalog?.themes).toEqual(['esporte'])
expect(doc?.catalog?.metadataCheckedAt).toBeTruthy()
expect(doc?.curatedFields).toEqual(expect.arrayContaining(['municipality', 'themes']))
})
it('reports a failed municipality resolution, writes nothing and lets the next run retry', async () => {
const { item } = await createPhoto('53900002005', { title: 'Plenária em Feira de Santana' })
const findSpy = vi
.spyOn(payload, 'find')
.mockRejectedValueOnce(new Error('catálogo de municípios indisponível'))
const failed = await catalogArchivePhotoMetadata({ payload, item })
findSpy.mockRestore()
expect(failed).toEqual({
status: 'failed',
stage: 'resolve',
error: 'catálogo de municípios indisponível',
})
expect((await findByFlickrId('53900002005'))?.catalog?.metadataCheckedAt ?? null).toBeNull()
expect(
(await listArchivePhotoMetadataCatalogQueue({ payload })).map((entry) => entry.flickrId),
).toContain('53900002005')
})
})