teqo-backup/tests/unit/archivePhotoCatalog.unit.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

504 lines
17 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
ARCHIVE_PHOTO_CAPTION_MAX_LENGTH,
ARCHIVE_PHOTO_PUBLICATION_STATUSES,
archivePhotoCatalogText,
archivePhotoIsPublic,
archivePhotoPublicationStatusLabels,
archivePhotoSearchText,
archivePhotoTakenOn,
archivePhotoThemesFrom,
buildArchivePhotoCatalogWrite,
buildArchivePhotoMetadataCatalogWrite,
changedArchivePhotoCuratedFields,
isArchivePhotoPublicationStatus,
isArchivePhotoRemovalChannelUrl,
matchPublicFigureMentions,
parseArchiveVisionOutput,
resolveArchivePhotoScene,
} from '@/lib/archivePhotoCatalog'
import { publicFigureCatalog } from '@/lib/publicFigureCatalog'
// C232 — the pure rules of the archive pre-cataloguing: closed vocabularies
// (scene/themes/curated fields), the fail-closed vision parser, the
// deterministic text matches and the merge that never overwrites curation.
// No provider, network or Payload here.
const suggestion = (
overrides: Partial<Parameters<typeof buildArchivePhotoCatalogWrite>[0]['suggestion']> = {},
) => ({
caption: null as string | null,
description: null as string | null,
scene: null as ReturnType<typeof resolveArchivePhotoScene>,
visibleText: null as string | null,
hasPeople: null as boolean | null,
themes: [] as ReturnType<typeof archivePhotoThemesFrom>,
...overrides,
})
const writeInput = (
overrides: Partial<Parameters<typeof buildArchivePhotoCatalogWrite>[0]> = {},
): Parameters<typeof buildArchivePhotoCatalogWrite>[0] => ({
suggestion: suggestion(),
municipalityId: null,
mentionedPeople: [],
gazetteerThemes: [],
curatedFields: [],
currentCatalog: null,
currentAlt: null,
catalogedAt: '2026-09-26T12:00:00.000Z',
metadataCheckedAt: '2026-09-26T12:00:00.000Z',
...overrides,
})
const metadataWriteInput = (
overrides: Partial<Parameters<typeof buildArchivePhotoMetadataCatalogWrite>[0]> = {},
): Parameters<typeof buildArchivePhotoMetadataCatalogWrite>[0] => ({
municipalityId: null,
gazetteerThemes: [],
curatedFields: [],
currentCatalog: null,
metadataCheckedAt: '2026-10-01T12:00:00.000Z',
...overrides,
})
describe('resolveArchivePhotoScene (C232)', () => {
it('resolves value and pt-BR label, accent/case insensitive', () => {
expect(resolveArchivePhotoScene('plenaria')).toBe('plenaria')
expect(resolveArchivePhotoScene('Plenária')).toBe('plenaria')
expect(resolveArchivePhotoScene('SEGURANCA')).toBeNull()
expect(resolveArchivePhotoScene('Audiência')).toBe('audiencia')
expect(resolveArchivePhotoScene('outro')).toBe('outro')
})
it('is fail-closed for unknown or non-string tokens', () => {
expect(resolveArchivePhotoScene('comício')).toBeNull()
expect(resolveArchivePhotoScene(null)).toBeNull()
expect(resolveArchivePhotoScene(7)).toBeNull()
expect(resolveArchivePhotoScene('')).toBeNull()
})
})
describe('archivePhotoThemesFrom (C232)', () => {
it('resolves the existing taxonomy and drops the unknown', () => {
expect(archivePhotoThemesFrom(['saude', 'Saúde', 'educacao', 'inventado', 7])).toEqual([
'saude',
'educacao',
])
expect(archivePhotoThemesFrom([])).toEqual([])
})
})
describe('matchPublicFigureMentions (C232)', () => {
const name = publicFigureCatalog[0]!.name
it('matches the curated catalog by whole word, accent/case insensitive', () => {
expect(matchPublicFigureMentions(`Plenária com ${name} no palco`)).toEqual([name])
expect(matchPublicFigureMentions(name.toLowerCase())).toEqual([name])
})
it('never invents a name and ignores unrelated text', () => {
expect(matchPublicFigureMentions('Plenária em Vitória da Conquista')).toEqual([])
expect(matchPublicFigureMentions('')).toEqual([])
})
})
describe('parseArchiveVisionOutput (C232)', () => {
it('parses a fenced JSON answer and normalizes the closed fields', () => {
const answer = [
'```json',
JSON.stringify({
caption: ' Plenária lotada no interior ',
description: 'Solla discursa para uma plateia cheia.',
scene: 'Plenária',
visibleText: 'SAÚDE É DIREITO',
hasPeople: true,
themes: ['Saúde', 'inventado'],
}),
'```',
].join('\n')
expect(parseArchiveVisionOutput(answer)).toEqual({
caption: 'Plenária lotada no interior',
description: 'Solla discursa para uma plateia cheia.',
scene: 'plenaria',
visibleText: 'SAÚDE É DIREITO',
hasPeople: true,
themes: ['saude'],
})
})
it('is null for a non-object answer (named failure upstream)', () => {
expect(parseArchiveVisionOutput('resposta em prosa')).toBeNull()
expect(parseArchiveVisionOutput('["a"]')).toBeNull()
expect(parseArchiveVisionOutput('{quebrado')).toBeNull()
expect(parseArchiveVisionOutput(null)).toBeNull()
})
it('degrades field by field instead of guessing', () => {
expect(
parseArchiveVisionOutput({
caption: 42,
scene: 'comício',
themes: ['saude', 5],
hasPeople: 'sim',
}),
).toEqual({
caption: null,
description: null,
scene: null,
visibleText: null,
hasPeople: null,
themes: ['saude'],
})
})
it('clips the caption to the field contract', () => {
const parsed = parseArchiveVisionOutput({ caption: 'a'.repeat(500) })
expect(parsed?.caption).toHaveLength(ARCHIVE_PHOTO_CAPTION_MAX_LENGTH)
})
})
describe('archivePhotoTakenOn (C232)', () => {
it('keeps the wall-clock day at noon UTC', () => {
expect(archivePhotoTakenOn('2025-03-14 10:20:30')).toBe('2025-03-14T12:00:00.000Z')
expect(archivePhotoTakenOn('2025-03-14')).toBe('2025-03-14T12:00:00.000Z')
})
it('is null for a missing or impossible date', () => {
expect(archivePhotoTakenOn(null)).toBeNull()
expect(archivePhotoTakenOn('14/03/2025')).toBeNull()
expect(archivePhotoTakenOn('2025-02-31 10:00:00')).toBeNull()
})
})
describe('changedArchivePhotoCuratedFields (C232)', () => {
it('marks only what actually changed for a request with data', () => {
expect(
changedArchivePhotoCuratedFields({
data: { alt: 'novo', catalog: { caption: 'x', scene: 'plenaria' } },
originalDoc: { alt: 'velho', catalog: { caption: 'x', scene: null } },
}),
).toEqual(['alt', 'scene'])
})
it('treats clearing and array edits as curation', () => {
expect(
changedArchivePhotoCuratedFields({
data: { catalog: { themes: [], people: ['Fulano'] } },
originalDoc: { catalog: { themes: ['saude'], people: ['Fulano'] } },
}),
).toEqual(['themes'])
})
it('normalizes the relationship shape and ignores absent fields', () => {
expect(
changedArchivePhotoCuratedFields({
data: { catalog: { municipality: { id: 5 } } },
originalDoc: { catalog: { municipality: 5 } },
}),
).toEqual([])
expect(
changedArchivePhotoCuratedFields({ data: {}, originalDoc: { catalog: { caption: 'x' } } }),
).toEqual([])
expect(changedArchivePhotoCuratedFields({ data: null, originalDoc: {} })).toEqual([])
})
})
describe('buildArchivePhotoCatalogWrite (C232)', () => {
it('writes the model fields and derives the alt from the caption', () => {
const { data, source } = buildArchivePhotoCatalogWrite(
writeInput({
suggestion: suggestion({
caption: 'Comício em Feira de Santana',
description: 'Solla cumprimenta apoiadores.',
scene: 'evento',
visibleText: 'JUNTOS PELA BAHIA',
hasPeople: true,
themes: ['politica-instituicoes'],
}),
}),
)
expect(source).toBe('ai')
expect(data.alt).toBe('Comício em Feira de Santana')
expect(data.catalog).toEqual({
caption: 'Comício em Feira de Santana',
description: 'Solla cumprimenta apoiadores.',
scene: 'evento',
visibleText: 'JUNTOS PELA BAHIA',
hasPeople: true,
themes: ['politica-instituicoes'],
source: 'ai',
catalogedAt: '2026-09-26T12:00:00.000Z',
metadataCheckedAt: '2026-09-26T12:00:00.000Z',
})
})
it('states metadata when only the text-derived fields landed', () => {
const { data, source } = buildArchivePhotoCatalogWrite(
writeInput({
municipalityId: 7,
mentionedPeople: ['Fulano de Tal'],
gazetteerThemes: ['saude'],
}),
)
expect(source).toBe('metadata')
expect(data.alt).toBeUndefined()
expect(data.catalog).toEqual({
themes: ['saude'],
people: ['Fulano de Tal'],
municipality: 7,
source: 'metadata',
catalogedAt: '2026-09-26T12:00:00.000Z',
metadataCheckedAt: '2026-09-26T12:00:00.000Z',
})
})
it('states none when there was nothing to propose', () => {
const { data, source } = buildArchivePhotoCatalogWrite(writeInput())
expect(source).toBe('none')
expect(data.catalog).toEqual({
source: 'none',
catalogedAt: '2026-09-26T12:00:00.000Z',
metadataCheckedAt: '2026-09-26T12:00:00.000Z',
})
})
it('never overwrites a curated field, and the source reflects only what landed', () => {
const { data, source } = buildArchivePhotoCatalogWrite(
writeInput({
suggestion: suggestion({ caption: 'IA', themes: ['saude'] }),
municipalityId: 9,
curatedFields: ['caption', 'themes', 'municipality'],
currentCatalog: { caption: 'Humano', themes: ['esporte'], municipality: 3 },
}),
)
expect(source).toBe('none')
expect(data.catalog).toEqual({
caption: 'Humano',
themes: ['esporte'],
municipality: 3,
source: 'none',
catalogedAt: '2026-09-26T12:00:00.000Z',
metadataCheckedAt: '2026-09-26T12:00:00.000Z',
})
expect(data.alt).toBeUndefined()
})
it('keeps writing when the curation freezes only one field', () => {
const { data, source } = buildArchivePhotoCatalogWrite(
writeInput({
suggestion: suggestion({ caption: 'IA', description: 'Descrição da IA' }),
curatedFields: ['caption'],
currentCatalog: { caption: 'Humano' },
currentAlt: 'Alt humano',
}),
)
expect(source).toBe('ai')
expect(data.catalog.caption).toBe('Humano')
expect(data.catalog.description).toBe('Descrição da IA')
// The curated caption freezes the alt refinement too (never mirrors human text).
expect(data.alt).toBeUndefined()
})
it('keeps a curated alt and never mirrors the curated caption into it', () => {
const { data } = buildArchivePhotoCatalogWrite(
writeInput({
suggestion: suggestion({ caption: 'IA' }),
curatedFields: ['alt', 'caption'],
currentCatalog: { caption: 'Humano' },
currentAlt: 'Alt humano',
}),
)
expect(data.alt).toBe('Alt humano')
expect(data.catalog.caption).toBe('Humano')
})
it('writes a negative hasPeople as a real proposal, never a blank string', () => {
const negative = buildArchivePhotoCatalogWrite(
writeInput({ suggestion: suggestion({ hasPeople: false }) }),
)
expect(negative.source).toBe('ai')
expect(negative.data.catalog.hasPeople).toBe(false)
const blank = buildArchivePhotoCatalogWrite(
writeInput({ suggestion: suggestion({ caption: ' ' }) }),
)
expect(blank.source).toBe('none')
expect(blank.data.catalog.caption).toBeUndefined()
})
it('unions the model themes with the gazetteer without duplicating', () => {
const { data, source } = buildArchivePhotoCatalogWrite(
writeInput({
suggestion: suggestion({ themes: ['saude'] }),
gazetteerThemes: ['saude', 'educacao'],
}),
)
expect(source).toBe('ai')
expect(data.catalog.themes).toEqual(['saude', 'educacao'])
})
})
describe('archivePhotoCatalogText (C245)', () => {
it('joins the base text fields and drops blanks', () => {
expect(
archivePhotoCatalogText({
alt: 'Alt',
title: 'Título',
description: ' ',
albumTitles: ['Álbum', null, ''],
tagNames: ['saude'],
}),
).toBe('Alt Título Álbum saude')
})
it('appends the extras — the AI layer adds its own caption/visible text', () => {
expect(archivePhotoCatalogText({ title: 'Título' }, ['Legenda', null, 'Faixa'])).toBe(
'Título Legenda Faixa',
)
expect(archivePhotoCatalogText({})).toBe('')
})
})
describe('buildArchivePhotoMetadataCatalogWrite (C245)', () => {
it('writes municipality and themes from the gazetteer, never catalogedAt or people', () => {
const { data, source } = buildArchivePhotoMetadataCatalogWrite(
metadataWriteInput({ municipalityId: 7, gazetteerThemes: ['saude', 'saude', 'educacao'] }),
)
expect(source).toBe('metadata')
expect(data.catalog).toEqual({
municipality: 7,
themes: ['saude', 'educacao'],
source: 'metadata',
metadataCheckedAt: '2026-10-01T12:00:00.000Z',
})
expect(data.catalog).not.toHaveProperty('catalogedAt')
expect(data.catalog).not.toHaveProperty('people')
expect(data.catalog).not.toHaveProperty('caption')
})
it('states none when the text sustains nothing, still stamping the marker', () => {
const { data, source } = buildArchivePhotoMetadataCatalogWrite(metadataWriteInput())
expect(source).toBe('none')
expect(data.catalog).toEqual({
source: 'none',
metadataCheckedAt: '2026-10-01T12:00:00.000Z',
})
})
it('never overwrites a curated field and reports what actually landed', () => {
const curated = buildArchivePhotoMetadataCatalogWrite(
metadataWriteInput({
municipalityId: 9,
gazetteerThemes: ['saude'],
curatedFields: ['municipality', 'themes'],
currentCatalog: { municipality: 3, themes: ['esporte'] },
}),
)
expect(curated.source).toBe('none')
expect(curated.data.catalog).toEqual({
municipality: 3,
themes: ['esporte'],
source: 'none',
metadataCheckedAt: '2026-10-01T12:00:00.000Z',
})
const partial = buildArchivePhotoMetadataCatalogWrite(
metadataWriteInput({
municipalityId: 9,
gazetteerThemes: ['saude'],
curatedFields: ['municipality'],
currentCatalog: { municipality: 3 },
}),
)
expect(partial.source).toBe('metadata')
expect(partial.data.catalog.municipality).toBe(3)
expect(partial.data.catalog.themes).toEqual(['saude'])
})
it('omits a curated municipality that was never stored (nothing to keep)', () => {
const { data } = buildArchivePhotoMetadataCatalogWrite(
metadataWriteInput({
municipalityId: 9,
curatedFields: ['municipality'],
currentCatalog: { municipality: null },
}),
)
expect(data.catalog.municipality).toBeUndefined()
expect(data.catalog.source).toBe('none')
})
})
describe('archivePhotoSearchText (C232)', () => {
it('normalizes every facet into one haystack', () => {
const text = archivePhotoSearchText({
alt: 'Comício',
title: 'Plenária',
description: 'Evento',
catalogDescription: 'Descrição IA',
caption: 'Legenda',
scene: 'plenaria',
visibleText: 'SAÚDE',
themes: ['saude'],
people: ['Fulano de Tal'],
municipalityName: 'Feira de Santana',
albumTitles: ['Vitória da Conquista'],
tagNames: ['Solla'],
})
expect(text).toContain('comicio')
expect(text).toContain('plenaria')
expect(text).toContain('saude')
expect(text).toContain('fulano de tal')
expect(text).toContain('feira de santana')
expect(text).toContain('vitoria da conquista')
expect(text).toContain('solla')
})
it('is empty for an empty input', () => {
expect(archivePhotoSearchText({})).toBe('')
})
})
// C233 — the publication gate appended to the same contract: the closed status
// vocabulary (fail-closed predicate) and the removal-channel validator that
// guards the album's approval path. No Payload here.
describe('archive photo publication (C233)', () => {
it('only approves the exact approved state', () => {
expect(ARCHIVE_PHOTO_PUBLICATION_STATUSES).toEqual(['draft', 'approved', 'removed'])
expect(archivePhotoPublicationStatusLabels).toEqual({
draft: 'Rascunho',
approved: 'Aprovada',
removed: 'Removida',
})
expect(isArchivePhotoPublicationStatus('approved')).toBe(true)
expect(isArchivePhotoPublicationStatus('publicado')).toBe(false)
expect(archivePhotoIsPublic({ publicationStatus: 'approved' })).toBe(true)
expect(archivePhotoIsPublic({ publicationStatus: 'draft' })).toBe(false)
expect(archivePhotoIsPublic({ publicationStatus: 'removed' })).toBe(false)
expect(archivePhotoIsPublic({})).toBe(false)
})
it('accepts only absolute http(s) or mailto removal channels', () => {
expect(isArchivePhotoRemovalChannelUrl('https://jorgesolla1313.com.br/contato')).toBe(true)
expect(isArchivePhotoRemovalChannelUrl('http://localhost:3000/remocao')).toBe(true)
expect(isArchivePhotoRemovalChannelUrl('mailto:acervo@example.org')).toBe(true)
expect(isArchivePhotoRemovalChannelUrl(' mailto:acervo@example.org ')).toBe(true)
expect(isArchivePhotoRemovalChannelUrl('javascript:alert(1)')).toBe(false)
expect(isArchivePhotoRemovalChannelUrl('/contato')).toBe(false)
expect(isArchivePhotoRemovalChannelUrl('')).toBe(false)
expect(isArchivePhotoRemovalChannelUrl(null)).toBe(false)
})
})