mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
O escopo A/C do gate (PR #1370) é superado por decisão do dono (2026-10-01, aval jurídico declarado): o índice vira anônimo (archivePhotoFace, foto + descritor 128-d, sem nome/vínculo), o enrollment por pessoa morre (faceSubject/faces:enroll removidos) e qualquer visitante encontra as próprias fotos aprovadas. - faces:index persiste os descritores por foto (transação substitui as linhas + marker = modelo); desaprovar/remover/deletar foto apaga os descritores (hooks + FK cascade) - POST /api/fotos/selfie casa o vetor da consulta contra o índice em memória (TTL 60 s) e devolve só a interseção com o read aprovado do C233, sem score/nome de terceiro; leave-index apaga os descritores que casarem - Consentimentos seguem fail-closed: busca-selfie-fotos (consulta) e busca-selfie-indice (aviso público do índice), textos versionados em scripts/lib/faceConsentTexts.mjs e provisionados por pnpm seed:face-consents - pnpm archive:publish aprova o acervo em lote (draft -> approved; removed nunca) com guardas e recibo - C243: seção condicional SelfieSearchHomeSection (port do design hi-fi, cenas 390/1280) entre a Central e a newsletter; sem engine/captura na home e sem CTA morto quando fechada - docs: planos C242/C243, AGENTS-public, runbook §C242, changelog Provas: gate:fast 457 arquivos/5.055 testes; int full 109/1.075; e2e frontendFotosSelfie 9/9; knip verde.
247 lines
7.9 KiB
TypeScript
247 lines
7.9 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { rm } from 'node:fs/promises'
|
|
import type { Payload } from 'payload'
|
|
import { getPayload } from 'payload'
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
|
|
vi.mock('next/cache', () => ({
|
|
revalidatePath: () => undefined,
|
|
revalidateTag: vi.fn(),
|
|
unstable_cache: (fn: unknown) => fn,
|
|
}))
|
|
|
|
import { ARCHIVE_PHOTO_SLUG } from '@/lib/archivePhoto'
|
|
import { FACE_SEARCH_MODEL } from '@/lib/faceSearch'
|
|
import config from '@/payload.config'
|
|
import {
|
|
indexArchivePhotoFaces,
|
|
listArchivePhotoFaceIndexQueue,
|
|
purgeFaceDescriptorsForPhoto,
|
|
writeArchivePhotoFaceDescriptors,
|
|
} from '@/utilities/faceIndex/faceDescriptorIndex'
|
|
|
|
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
|
import {
|
|
createFaceTestPhoto,
|
|
FACE_SPECS_LEASE_KEY,
|
|
faceTestVector,
|
|
} from '../helpers/faceIntFixtures'
|
|
import {
|
|
acquireTestDatabaseLease,
|
|
PHOTO_ALBUM_LEASE_KEY,
|
|
withExclusiveTestDatabaseLease,
|
|
} from '../helpers/testDatabaseLease'
|
|
|
|
// C242 — the anonymous index boundary over the real Payload `teqo_test`: the
|
|
// batch's transactional descriptor replacement, the marker that makes the run
|
|
// resumable and the purges that keep draft/removed photos out of the search.
|
|
|
|
let payload: Payload
|
|
const createdFlickrIds = new Set<string>()
|
|
const tempDirs: string[] = []
|
|
|
|
installCampaignFixtures({
|
|
getPayload: () => payload,
|
|
setPayload: (nextPayload) => {
|
|
payload = nextPayload
|
|
},
|
|
})
|
|
|
|
const CHANNEL = 'https://jorge.solla.example.org/remocao'
|
|
|
|
const createPhoto = (approved: boolean) =>
|
|
createFaceTestPhoto({
|
|
payload,
|
|
approved,
|
|
prefix: 'c240',
|
|
trackedFlickrIds: createdFlickrIds,
|
|
tempDirs,
|
|
})
|
|
|
|
const faceRowsOf = async (photoId: number): Promise<number> => {
|
|
const result = await payload.count({
|
|
collection: 'archivePhotoFace',
|
|
where: { photo: { equals: photoId } },
|
|
overrideAccess: true,
|
|
})
|
|
return result.totalDocs
|
|
}
|
|
|
|
const markerOf = async (
|
|
id: number,
|
|
): Promise<{ checkedAt?: string | null; checkedKey?: string | null }> => {
|
|
const doc = await payload.findByID({
|
|
collection: ARCHIVE_PHOTO_SLUG,
|
|
id,
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
return doc.faces ?? {}
|
|
}
|
|
|
|
const queueItem = async (
|
|
id: number,
|
|
refresh = false,
|
|
): Promise<
|
|
Awaited<ReturnType<typeof listArchivePhotoFaceIndexQueue>>['items'][number] | undefined
|
|
> => {
|
|
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh })
|
|
return queue.items.find((item) => item.id === id)
|
|
}
|
|
|
|
describe('anonymous face descriptor index (C242)', () => {
|
|
let specsLease: Awaited<ReturnType<typeof acquireTestDatabaseLease>>
|
|
|
|
beforeAll(async () => {
|
|
payload = await getPayload({ config: await config })
|
|
specsLease = await acquireTestDatabaseLease(payload, FACE_SPECS_LEASE_KEY)
|
|
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
|
|
payload.updateGlobal({
|
|
slug: 'photoAlbum',
|
|
data: { published: true, removalChannelUrl: CHANNEL, selfieSearchEnabled: false },
|
|
overrideAccess: true,
|
|
}),
|
|
)
|
|
})
|
|
|
|
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 })))
|
|
await specsLease.release()
|
|
})
|
|
|
|
it('lists only approved photos as stale and stamps the marker after indexing', async () => {
|
|
const approved = await createPhoto(true)
|
|
await createPhoto(false)
|
|
|
|
const before = await queueItem(approved.id)
|
|
expect(before).toBeDefined()
|
|
expect(before?.checkedKey).toBeNull()
|
|
|
|
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
|
|
const result = await indexArchivePhotoFaces({
|
|
payload,
|
|
item: queue.items.find((item) => item.id === approved.id)!,
|
|
indexKey: queue.indexKey,
|
|
analyze: async () => [faceTestVector(0), faceTestVector(0.1)],
|
|
})
|
|
expect(result).toEqual({ status: 'indexed', descriptorCount: 2 })
|
|
expect(await faceRowsOf(approved.id)).toBe(2)
|
|
|
|
const marker = await markerOf(approved.id)
|
|
expect(marker.checkedKey).toBe(FACE_SEARCH_MODEL)
|
|
expect(typeof marker.checkedAt).toBe('string')
|
|
|
|
// The marker makes the next run a no-op for this photo.
|
|
expect(await queueItem(approved.id)).toBeUndefined()
|
|
})
|
|
|
|
it('replaces the rows of a photo on re-index instead of merging revisions', async () => {
|
|
const photo = await createPhoto(true)
|
|
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
|
|
const item = queue.items.find((entry) => entry.id === photo.id)!
|
|
|
|
await indexArchivePhotoFaces({
|
|
payload,
|
|
item,
|
|
indexKey: queue.indexKey,
|
|
analyze: async () => [faceTestVector(0), faceTestVector(0.2), faceTestVector(0.3)],
|
|
})
|
|
expect(await faceRowsOf(photo.id)).toBe(3)
|
|
|
|
await indexArchivePhotoFaces({
|
|
payload,
|
|
item,
|
|
indexKey: queue.indexKey,
|
|
analyze: async () => [faceTestVector(0.4)],
|
|
})
|
|
expect(await faceRowsOf(photo.id)).toBe(1)
|
|
})
|
|
|
|
it('drops malformed descriptors (fail-closed) and still stamps the marker', async () => {
|
|
const photo = await createPhoto(true)
|
|
const written = await writeArchivePhotoFaceDescriptors({
|
|
payload,
|
|
photoId: photo.id,
|
|
descriptors: [
|
|
[1, 2, 3],
|
|
[Number.NaN, ...faceTestVector(0).slice(1)],
|
|
],
|
|
indexKey: FACE_SEARCH_MODEL,
|
|
})
|
|
expect(written).toBe(0)
|
|
expect(await faceRowsOf(photo.id)).toBe(0)
|
|
expect((await markerOf(photo.id)).checkedKey).toBe(FACE_SEARCH_MODEL)
|
|
expect(await queueItem(photo.id)).toBeUndefined()
|
|
})
|
|
|
|
it('purges the descriptor rows when the photo stops being approved', async () => {
|
|
const photo = await createPhoto(true)
|
|
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
|
|
await indexArchivePhotoFaces({
|
|
payload,
|
|
item: queue.items.find((item) => item.id === photo.id)!,
|
|
indexKey: queue.indexKey,
|
|
analyze: async () => [faceTestVector(0)],
|
|
})
|
|
expect(await faceRowsOf(photo.id)).toBe(1)
|
|
|
|
await payload.update({
|
|
collection: ARCHIVE_PHOTO_SLUG,
|
|
id: photo.id,
|
|
data: { publicationStatus: 'removed' },
|
|
overrideAccess: true,
|
|
})
|
|
expect(await faceRowsOf(photo.id)).toBe(0)
|
|
expect(await queueItem(photo.id, true)).toBeUndefined()
|
|
})
|
|
|
|
it('purges the descriptor rows on deletion and on the explicit maintenance call', async () => {
|
|
const photo = await createPhoto(true)
|
|
await writeArchivePhotoFaceDescriptors({
|
|
payload,
|
|
photoId: photo.id,
|
|
descriptors: [faceTestVector(0.5)],
|
|
indexKey: FACE_SEARCH_MODEL,
|
|
})
|
|
expect(await faceRowsOf(photo.id)).toBe(1)
|
|
|
|
await purgeFaceDescriptorsForPhoto({ payload, photoId: photo.id })
|
|
expect(await faceRowsOf(photo.id)).toBe(0)
|
|
|
|
await writeArchivePhotoFaceDescriptors({
|
|
payload,
|
|
photoId: photo.id,
|
|
descriptors: [faceTestVector(0.5)],
|
|
indexKey: FACE_SEARCH_MODEL,
|
|
})
|
|
await payload.delete({ collection: ARCHIVE_PHOTO_SLUG, id: photo.id, overrideAccess: true })
|
|
const orphans = await payload.count({
|
|
collection: 'archivePhotoFace',
|
|
where: { photo: { equals: photo.id } },
|
|
overrideAccess: true,
|
|
})
|
|
expect(orphans.totalDocs).toBe(0)
|
|
})
|
|
|
|
it('reports an indexed photo with zero faces honestly', async () => {
|
|
const photo = await createPhoto(true)
|
|
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
|
|
const result = await indexArchivePhotoFaces({
|
|
payload,
|
|
item: queue.items.find((item) => item.id === photo.id)!,
|
|
indexKey: queue.indexKey,
|
|
analyze: async () => [],
|
|
})
|
|
expect(result).toEqual({ status: 'indexed', descriptorCount: 0 })
|
|
expect(await faceRowsOf(photo.id)).toBe(0)
|
|
expect((await markerOf(photo.id)).checkedKey).toBe(FACE_SEARCH_MODEL)
|
|
})
|
|
})
|