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

52 lines
1.9 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { FACE_SEARCH_DESCRIPTOR_LENGTH } from '@/lib/faceSearch'
import { faceSearchRequestSchema } from '@/lib/schemas/faceSearch'
// C234 — the wire contract of the selfie endpoint: strict object, exact
// descriptor length, finite numbers only and the two intents. Everything else
// is a malformed body (never a silently ignored field).
const vector = (): number[] => Array.from({ length: FACE_SEARCH_DESCRIPTOR_LENGTH }, () => 0.5)
describe('faceSearchRequestSchema', () => {
it('accepts both intents with a full descriptor', () => {
expect(faceSearchRequestSchema.safeParse({ vector: vector(), intent: 'search' }).success).toBe(
true,
)
expect(
faceSearchRequestSchema.safeParse({ vector: vector(), intent: 'leave-index' }).success,
).toBe(true)
})
it('rejects unknown intents and extra keys', () => {
expect(faceSearchRequestSchema.safeParse({ vector: vector(), intent: 'enroll' }).success).toBe(
false,
)
expect(
faceSearchRequestSchema.safeParse({ vector: vector(), intent: 'search', image: 'x' }).success,
).toBe(false)
})
it('rejects descriptors of the wrong length or with non-finite values', () => {
expect(
faceSearchRequestSchema.safeParse({ vector: vector().slice(1), intent: 'search' }).success,
).toBe(false)
expect(
faceSearchRequestSchema.safeParse({
vector: [...vector().slice(1), Number.NaN],
intent: 'search',
}).success,
).toBe(false)
expect(
faceSearchRequestSchema.safeParse({
vector: [...vector().slice(1), Number.POSITIVE_INFINITY],
intent: 'search',
}).success,
).toBe(false)
expect(faceSearchRequestSchema.safeParse({ vector: {}, intent: 'search' }).success).toBe(false)
expect(faceSearchRequestSchema.safeParse({ intent: 'search' }).success).toBe(false)
})
})