teqo-backup/tests/unit/faceIndexPlan.unit.spec.ts
Francisco Solla 9e9864f420 feat(C234): busca por selfie no álbum público — índice A/C consentido, selfie on-device
- /fotos/encontre + POST /api/fotos/selfie: só o vetor 128-d, sem imagem, sem score e
  sem nomes de terceiros; fail-closed pela flag photoAlbum.selfieSearchEnabled (default
  false) e pelos Consents por chave estável (busca-selfie-fotos/busca-selfie-indice, não
  seedados — a superfície nasce fechada até o aval jurídico/DPIA).
- Índice A/C do gate (PR #1370): só quem aderiu é indexado; descriptors do acervo são
  transitórios. Collection faceSubject (admin-only, sem vínculo com Contact), vínculos
  matchedPhotos e marker faces.checkedKey no archivePhoto (modelo + enrolledAt).
- Engine @vladmandic/face-api nos dois lados: browser lazy com modelos same-origin
  (copy-face-vision-assets no prebuild/predev/Docker) e CLI node-wasm sem tfjs-node.
  CLIs ops-only faces:enroll / faces:index --apply|--verify com guardas C231/C232.
- Opt-out self-service por reconhecimento (leave-index) + canal de remoção do C233;
  rate limit próprio (namespace selfie:<hash>) e bounded body extraído do beacon.
- UI portada classe-a-classe do design estendido (paridade certificada, tier primário);
  e2e frontendFotosSelfie (manifest+curado+projeto, engine stubada) e canário do engine
  real; runbook §C234, contrato em AGENTS-public.md e changelog.
- Migration aditiva add_face_subject (SQL revisado); hard-stops aprovados na sessão.
2026-09-27 20:04:41 -03:00

176 lines
5.5 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
FACE_INDEX_DEFAULT_OUT_DIR,
faceIndexReportStamp,
formatFaceIndexInventory,
formatFaceIndexReport,
parseFaceIndexCliArgs,
summarizeFaceIndexInventory,
summarizeFaceIndexResults,
} from '../../scripts/lib/faceIndexPlan.mjs'
// C234 — the pure parser/summaries of `pnpm faces:index`: the honest staleness
// accounting and the operator-facing lines.
describe('parseFaceIndexCliArgs', () => {
it('defaults to the dry-run and parses the modes', () => {
expect(parseFaceIndexCliArgs([])).toEqual({
apply: false,
verify: false,
limit: null,
refresh: false,
out: FACE_INDEX_DEFAULT_OUT_DIR,
help: false,
})
const apply = parseFaceIndexCliArgs(['--apply', '--limit', '5', '--refresh', '--out', 'x'])
expect(apply.apply).toBe(true)
expect(apply.limit).toBe(5)
expect(apply.refresh).toBe(true)
expect(apply.out).toBe('x')
expect(parseFaceIndexCliArgs(['--verify']).verify).toBe(true)
expect(parseFaceIndexCliArgs(['--help']).help).toBe(true)
})
it('fails closed on mutual exclusion, bad limit and escaping out', () => {
expect(() => parseFaceIndexCliArgs(['--apply', '--verify'])).toThrow(/mutuamente exclusivos/)
expect(() => parseFaceIndexCliArgs(['--limit', '0'])).toThrow(/--limit/)
expect(() => parseFaceIndexCliArgs(['--limit', 'x'])).toThrow(/--limit/)
expect(() => parseFaceIndexCliArgs(['--out', '../etc'])).toThrow(/--out/)
expect(() => parseFaceIndexCliArgs(['--refresh=1'])).toThrow(/argumento desconhecido/)
expect(() => parseFaceIndexCliArgs(['--limit'])).toThrow(/faltou valor/)
})
})
describe('summarizeFaceIndexResults', () => {
it('counts indexed/linked/failed and aggregates links per subject', () => {
const summary = summarizeFaceIndexResults([
{ photoId: 1, status: 'indexed', matchedSubjects: [3, 4] },
{ photoId: 2, status: 'indexed', matchedSubjects: [3] },
{ photoId: 3, status: 'indexed', matchedSubjects: [] },
{ photoId: 4, status: 'failed', stage: 'download', error: 'sem arquivo' },
])
expect(summary.indexed).toBe(3)
expect(summary.failed).toBe(1)
expect(summary.linkedPhotos).toBe(2)
expect(summary.linksBySubject).toEqual([
{ subjectId: 3, photos: 2 },
{ subjectId: 4, photos: 1 },
])
expect(summary.failures).toEqual([{ photoId: 4, stage: 'download', error: 'sem arquivo' }])
})
})
describe('summarizeFaceIndexInventory', () => {
const queue = { indexKey: 'k', eligibleSubjects: 2, totalApproved: 10, stale: 4 }
const subjects = [
{
id: 1,
status: 'active',
model: 'face-api@1.7.15/faceRecognitionNet',
consentHash: 'hash-atual',
vector: [0],
matchedPhotoIds: [1, 2],
},
{
id: 2,
status: 'active',
model: 'face-api@1.7.15/faceRecognitionNet',
consentHash: 'hash-antigo',
vector: [0],
matchedPhotoIds: [],
},
{
id: 3,
status: 'removed',
model: 'face-api@1.7.15/faceRecognitionNet',
consentHash: 'hash-atual',
vector: null,
matchedPhotoIds: [],
},
]
it('derives the honest gap, the consent staleness and the links breakdown', () => {
const inventory = summarizeFaceIndexInventory({
queue,
subjects,
currentConsentHash: 'hash-atual',
})
expect(inventory).toEqual({
indexKey: 'k',
totalApproved: 10,
indexed: 6,
stale: 4,
eligibleSubjects: 2,
activeSubjects: 2,
removedSubjects: 1,
staleConsentSubjects: 1,
linkedPhotos: 2,
linksBySubject: [{ subjectId: 1, photos: 2 }],
})
})
it('treats a missing current consent as stale for everyone', () => {
const inventory = summarizeFaceIndexInventory({
queue,
subjects,
currentConsentHash: null,
})
expect(inventory.staleConsentSubjects).toBe(2)
})
})
describe('formatting', () => {
it('stamps the receipt name and prints the inventory lines', () => {
expect(faceIndexReportStamp('2026-09-27T16:42:01.123Z')).toBe('2026-09-27T16-42-01-123Z')
const lines = formatFaceIndexInventory(
{
indexKey: 'k',
totalApproved: 10,
indexed: 6,
stale: 4,
eligibleSubjects: 2,
activeSubjects: 2,
removedSubjects: 1,
staleConsentSubjects: 1,
linkedPhotos: 2,
linksBySubject: [{ subjectId: 1, photos: 2 }],
},
'face-api@1.7.15/faceRecognitionNet',
)
const text = lines.join('\n')
expect(text).toContain('fotos aprovadas: 10')
expect(text).toContain('desatualizadas (a processar): 4')
expect(text).toContain('consentimento vencido')
expect(text).toContain('sujeito #1: 2 foto(s)')
})
it('prints the apply summary with failures', () => {
const report = {
mode: 'apply',
target: 'host/db',
model: 'face-api@1.7.15/faceRecognitionNet',
queue: { items: 3, totalApproved: 10, eligibleSubjects: 1 },
summary: {
indexed: 2,
failed: 1,
linkedPhotos: 1,
linksBySubject: [{ subjectId: 3, photos: 1 }],
failures: [{ photoId: 4, stage: 'analyze', error: 'engine fora' }],
},
}
const text = formatFaceIndexReport(report).join('\n')
expect(text).toContain('modo: apply')
expect(text).toContain('processadas: 2 | vinculadas a alguém: 1 | falharam: 1')
expect(text).toContain('sujeito #3: 1 foto(s)')
expect(text).toContain('✗ foto 4 [analyze]: engine fora')
})
})