teqo-backup/tests/unit/archiveVisionApi.unit.spec.ts
Francisco Solla 671af1e21f
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions
feat(C232): pré-catalogar o acervo de fotos com IA local e curadoria que vence
Collection archivePhoto ganha o grupo catalog (legenda/descrição/cena/texto
visível/temas/pessoas/município/origem/catalogedAt), curatedFields (o que a
assessoria edita nunca é sobrescrito), takenOn e searchText com índice GIN trgm
(migration aditiva add_archive_photo_catalog); hook beforeValidate deriva o
índice e marca curadoria só em edição humana; pipeline transacional baixa os
bytes privados, prepara JPEG <=1024px com sharp e mescla o VLM local com o
gazetteer/catálogo curado (pessoas só do TEXTO, nunca de rosto); CLI
pnpm archive:catalog (plan/--apply/--verify, recibo honesto, idempotente por
catalogedAt) com engine OpenAI-compatible na rede privada atrás de guarda de
host (ARCHIVE_VISION_ALLOW_REMOTE é o escape); client HTTP com retry/timeout e
parser zod fail-closed; resolver de município extraído para owner compartilhado
com o catálogo de peças delegando; unit/int novos e runbook staging-primeiro.
2026-09-26 12:30:34 -03:00

149 lines
4.8 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it, vi } from 'vitest'
import {
ArchiveVisionError,
analyzeArchivePhotoVision,
} from '../../scripts/lib/archiveVisionApi.mjs'
// C232 — the vision client contract: request shape, auth header, transient
// retry, terminal 4xx, timeout/abort and the named refusals. `fetch` is always
// a fake — the real engine never enters the suite.
const jsonResponse = (body: unknown, status = 200) =>
({
ok: status >= 200 && status < 300,
status,
json: async () => body,
text: async () => JSON.stringify(body),
}) as Response
const fakeFetch = (impl: (url: string, init?: RequestInit) => Promise<Response>) =>
vi.fn(impl) as unknown as typeof fetch
const call = (fetchImpl: typeof fetch, overrides: Record<string, unknown> = {}) =>
analyzeArchivePhotoVision({
baseUrl: 'http://100.94.122.26:11434/v1',
model: 'qwen2.5vl:7b',
imageDataUrl: 'data:image/jpeg;base64,AAAA',
prompt: 'descreva a foto',
systemPrompt: 'responda em JSON',
fetchImpl,
retryAttempts: 1,
sleep: async () => undefined,
...overrides,
})
describe('analyzeArchivePhotoVision (C232)', () => {
it('refuses to call without the engine config or a prepared image', async () => {
await expect(
call(
fakeFetch(async () => jsonResponse({})),
{ baseUrl: ' ' },
),
).rejects.toThrow(/ARCHIVE_VISION_BASE_URL/)
await expect(
call(
fakeFetch(async () => jsonResponse({})),
{ model: '' },
),
).rejects.toThrow(/ARCHIVE_VISION_MODEL/)
await expect(
call(
fakeFetch(async () => jsonResponse({})),
{ imageDataUrl: 'http://x/foto.jpg' },
),
).rejects.toThrow(/data: URL/)
})
it('posts the OpenAI-compatible shape with the image and the key header', async () => {
const fetchImpl = fakeFetch(async () =>
jsonResponse({ choices: [{ message: { content: '{"caption":"ok"}' } }] }),
)
const content = await call(fetchImpl, { apiKey: 'segredo' })
expect(content).toBe('{"caption":"ok"}')
const [url, init] = (fetchImpl as unknown as { mock: { calls: [string, RequestInit][] } }).mock
.calls[0]
expect(url).toBe('http://100.94.122.26:11434/v1/chat/completions')
expect(init.method).toBe('POST')
const headers = init.headers as Record<string, string>
expect(headers.Authorization).toBe('Bearer segredo')
const body = JSON.parse(String(init.body))
expect(body.model).toBe('qwen2.5vl:7b')
expect(body.stream).toBe(false)
expect(body.messages[0]).toMatchObject({ role: 'system', content: 'responda em JSON' })
expect(body.messages[1].content[0]).toMatchObject({ type: 'text', text: 'descreva a foto' })
expect(body.messages[1].content[1]).toMatchObject({
type: 'image_url',
image_url: { url: 'data:image/jpeg;base64,AAAA' },
})
})
it('retries a transient 5xx with backoff and succeeds', async () => {
const sleeps: number[] = []
let attempts = 0
const fetchImpl = fakeFetch(async () => {
attempts += 1
if (attempts === 1) return jsonResponse({ error: 'sobrecarregado' }, 503)
return jsonResponse({ choices: [{ message: { content: 'ok' } }] })
})
const content = await call(fetchImpl, {
retryAttempts: 3,
retryBaseDelayMs: 100,
sleep: async (ms: number) => {
sleeps.push(ms)
},
})
expect(content).toBe('ok')
expect(attempts).toBe(2)
expect(sleeps).toEqual([100])
})
it('does not retry a terminal 4xx refusal and keeps the provider detail', async () => {
const fetchImpl = fakeFetch(async () => jsonResponse({ error: 'modelo desconhecido' }, 404))
await expect(call(fetchImpl, { retryAttempts: 3 })).rejects.toMatchObject({
name: 'ArchiveVisionError',
status: 404,
message: expect.stringContaining('modelo desconhecido'),
})
expect((fetchImpl as unknown as { mock: { calls: unknown[] } }).mock.calls).toHaveLength(1)
})
it('retries a network failure as transient', async () => {
let attempts = 0
const fetchImpl = fakeFetch(async () => {
attempts += 1
if (attempts === 1) throw new TypeError('fetch failed')
return jsonResponse({ choices: [{ message: { content: 'ok' } }] })
})
await expect(call(fetchImpl, { retryAttempts: 2 })).resolves.toBe('ok')
expect(attempts).toBe(2)
})
it('names an off-contract answer instead of guessing', async () => {
await expect(call(fakeFetch(async () => jsonResponse({ choices: [] })))).rejects.toBeInstanceOf(
ArchiveVisionError,
)
await expect(
call(
fakeFetch(
async () =>
({
ok: true,
status: 200,
json: async () => {
throw new Error('html')
},
}) as unknown as Response,
),
),
).rejects.toThrow(/não é JSON/)
})
})