gongxiangp2/lib/__tests__/ai-model-verification.test.ts

413 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** @jest-environment node */
import { randomUUID } from 'node:crypto'
import { AiModelError, assertAiModelAdmin } from '@/lib/ai-model-access'
import { verifyAiModel, refreshAiModelVerification, validateAiModelVerificationOutput } from '@/lib/ai-model-verification'
import { validateAiModelCombinedVerificationOutput, verificationReferenceImage } from '@/lib/ai-model-verification-output'
import { callAiModelProtocol, AiModelProtocolError } from '@/lib/ai-model-protocol'
import { resolveAiModelVerification } from '@/lib/ai-model-service'
import { prisma } from '@/lib/prisma'
import { detailTransactionLock } from '@/lib/detail-generator/repository'
import { aiModelAudit } from '@/lib/ai-model-repository'
import sharp from 'sharp'
import { multiReferenceGrid, multiReferenceImages } from '@/lib/ai-model-multireference'
jest.mock('@/lib/ai-model-access', () => { const actual = jest.requireActual('@/lib/ai-model-access'); return { ...actual, assertAiModelAdmin: jest.fn(), assertAiModelAdminForWrite: jest.fn() } })
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn() }))
jest.mock('@/lib/prisma', () => ({ prisma: { $executeRawUnsafe: jest.fn(), $queryRawUnsafe: jest.fn() } }))
jest.mock('@/lib/ai-model-service', () => ({ getAiModelAdminState: jest.fn(async () => ({ providers: [] })), resolveAiModelVerification: jest.fn() }))
jest.mock('@/lib/ai-model-protocol', () => ({ ...jest.requireActual('@/lib/ai-model-protocol'), callAiModelProtocol: jest.fn() }))
jest.mock('@/lib/detail-generator/repository', () => ({ detailTransactionLock: jest.fn() }))
const mockDb = { $queryRawUnsafe: jest.fn(), $executeRawUnsafe: jest.fn() }
jest.mock('@/lib/ai-model-repository', () => ({ aiModelTransaction: jest.fn(async callback => callback(mockDb)), aiModelAudit: jest.fn() }))
const providerId = '11111111-1111-4111-8111-111111111111', modelKey = '22222222-2222-4222-8222-222222222222'
const input = () => ({ stage: 'analysis' as const, revision: 1, idempotencyKey: randomUUID() })
let row: any, executions: any[], duplicate = false, unresolved = 0, active = 0
const validCopy = { sellingPoints: ['蓝色外观'], warnings: [], screens: [{ title: '蓝色产品', body: '型号TEST,蓝色外观', imagePrompt: '无文字的蓝色产品展示场景' }] }
beforeEach(() => {
jest.clearAllMocks(); duplicate = false; unresolved = 0; active = 0; executions = []
row = { id: randomUUID(), modelKey, stage: 'analysis', contextHash: 'a'.repeat(64), idempotencyKey: randomUUID(), status: 'running', taskId: null, deadlineAt: new Date(Date.now() - 1) }
;(assertAiModelAdmin as jest.Mock).mockResolvedValue('admin')
;(resolveAiModelVerification as jest.Mock).mockResolvedValue({ provider: { baseUrl: 'https://models.example.com/v1' }, model: { id: 'model' }, protocol: { id: 'openai-chat', path: '/v1/chat/completions', headers: {}, supportsImageInput: true }, apiKey: 'synthetic-test-secret', contextHash: row.contextHash })
mockDb.$queryRawUnsafe.mockImplementation(async (sql: string, ...args: any[]) => {
if (sql.startsWith('INSERT')) { row = { ...row, id: args[0], stage: args[2], contextHash: args[3], idempotencyKey: args[4], evidence: JSON.parse(args[6]) }; executions.push(row); return [row] }
if (sql.includes("evidence->>'executionGroupId'=$3")) return [...executions].sort((left, right) => left.stage.localeCompare(right.stage))
if (sql.includes('idempotency_key=$2')) return duplicate ? [row] : []
if (sql.includes('FOR UPDATE')) return [row]
if (sql.includes('active_executions')) return [{ count: active, own: 0 }]
if (sql.includes('COUNT(*)')) return [{ count: unresolved }]
return []
})
mockDb.$executeRawUnsafe.mockResolvedValue(1)
;(prisma.$executeRawUnsafe as jest.Mock).mockResolvedValue(1)
;(prisma.$queryRawUnsafe as jest.Mock).mockResolvedValue([{ id: row.id }])
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => { await request.beforeRequest(); const analysis = row.evidence?.challenge || { topLeft: 'red', bottomRight: 'blue' }; return { text: JSON.stringify(row.evidence?.purpose === 'analysis-copy' ? { analysis, copy: validCopy } : analysis), requestId: 'request-1', summary: {} } })
})
test.each([2, 4, 8])('native image acceptance verifies every one of %i persisted references', async count => {
const original = await (resolveAiModelVerification as jest.Mock)()
;(resolveAiModelVerification as jest.Mock).mockResolvedValue({ ...original, protocol: { id: 'openai-image-generate', path: '/v1/images/generations', supportsImageInput: true, headers: {}, referenceContract: 'probe-required', maxImages: 8 } })
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => {
await request.beforeRequest()
const challenge = row.evidence.multiReferenceChallenge, expected = await multiReferenceImages(challenge)
expect(request.images.map((value: any) => value.buffer)).toEqual(expected.map(value => value.buffer))
expect(request.prompt).not.toMatch(/red|blue|green|yellow|红色|蓝色|绿色|黄色/i)
const { columns, rows } = multiReferenceGrid(count)
const image = await sharp({ create: { width: columns * 512, height: rows * 512, channels: 3, background: '#fff' } }).composite(expected.map((value, i) => ({ input: value.buffer, left: i % columns * 512, top: Math.floor(i / columns) * 512 }))).png().toBuffer()
return { image, requestId: 'request-multi', summary: {} }
})
await verifyAiModel('admin', providerId, modelKey, { ...input(), stage: 'image', ...(count === 8 ? {} : { referenceImageCount: count }) })
expect(row.evidence.multiReferenceChallenge.references).toHaveLength(count)
const passed = mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes("status='passed'"))!
expect(JSON.parse(passed[3])).toMatchObject({ referenceImageCount: count, allReferenceImagesMatched: true, referenceMatches: Array(count).fill(true) })
expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
})
test('changing a persisted test quantity cannot reuse its UUID or dispatch another paid call', async () => {
duplicate = true; row.evidence = { purpose: 'image', referenceImageCount: 2 }
await expect(verifyAiModel('admin', providerId, modelKey, { ...input(), stage: 'image', referenceImageCount: 4 })).rejects.toMatchObject({ code: 'verification_idempotency_conflict' })
expect(callAiModelProtocol).not.toHaveBeenCalled()
})
test('a real image-understanding response persists passed evidence without enabling the model', async () => {
await verifyAiModel('admin', providerId, modelKey, input())
expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
expect((callAiModelProtocol as jest.Mock).mock.calls[0][0].images[0].buffer).toBeInstanceOf(Buffer)
expect(mockDb.$executeRawUnsafe.mock.calls.some(([sql]) => sql.includes("status='passed'"))).toBe(true)
expect(mockDb.$executeRawUnsafe.mock.calls.some(([sql]) => /UPDATE ai_model_catalog|ai_model_configuration/.test(sql))).toBe(false)
expect(detailTransactionLock).toHaveBeenCalledWith(mockDb, 'detail-generator-execution')
})
test('reference-image acceptance requests an unchanged flat redraw without leaking the random color answers', async () => {
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => {
await request.beforeRequest()
return { image: await verificationReferenceImage(row.evidence.challenge), requestId: 'flat-reference-check', summary: {} }
})
await verifyAiModel('admin', providerId, modelKey, { ...input(), stage: 'image' })
const request = (callAiModelProtocol as jest.Mock).mock.calls[0][0]
expect(request.prompt).toBe('将参考图重新绘制为干净的平面图,严格保留两个方块各自的原始颜色、形状和相对位置,纯白背景;不要添加文字、标志、阴影、纹理、渐变、光照或其它内容。')
expect(request.prompt).not.toMatch(/red|blue|green|yellow|红色|蓝色|绿色|黄色/i)
expect(request.prompt).not.toContain(row.evidence.challenge.topLeft)
expect(request.prompt).not.toContain(row.evidence.challenge.bottomRight)
expect(request.images).toHaveLength(1)
expect(request.images[0].buffer).toEqual(await verificationReferenceImage(row.evidence.challenge))
expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
expect(mockDb.$executeRawUnsafe.mock.calls.some(([sql]) => sql.includes("status='passed'"))).toBe(true)
})
test.each(['multipart', 'json-inline'])('image acceptance preserves the bounded %s request and original versus stored dimensions', async referenceTransport => {
const parameters = { n: 1, response_format: 'b64_json', size: '4096x4096', quality: 'high' }
const context = await (resolveAiModelVerification as jest.Mock)()
;(resolveAiModelVerification as jest.Mock).mockResolvedValue({ ...context, protocol: { id: 'openai-image-generate', path: '/v1/images/generations', headers: {}, supportsImageInput: true, inputField: 'image', referenceTransport, requestParameters: parameters } })
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => {
await request.beforeRequest()
const image = await sharp(await verificationReferenceImage(row.evidence.challenge)).resize(1440, 1440).png().toBuffer()
return { image, requestId: 'actual-4k-request', summary: { referenceTransport, requestParameters: parameters,
generatedWidth: 4096, generatedHeight: 4096, storedWidth: 1440, storedHeight: 1440,
referenceImageAccepted: false, referenceLayoutMatched: false, imageDecoded: false, width: 9999,
ruleVersion: -1, adapterVersion: -1, prompt: 'must not persist', secret: 'synthetic-test-secret' } }
})
await verifyAiModel('admin', providerId, modelKey, { ...input(), stage: 'image' })
const passed = mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes("status='passed'"))!
expect(passed[2]).toBe('actual-4k-request')
const evidence = JSON.parse(passed[3])
expect(evidence).toMatchObject({ referenceTransport, requestParameters: parameters,
generatedWidth: 4096, generatedHeight: 4096, storedWidth: 1440, storedHeight: 1440, width: 1440, height: 1440,
referenceImageAccepted: true, referenceLayoutMatched: true, imageDecoded: true })
expect(evidence.ruleVersion).toBeGreaterThan(0); expect(evidence.adapterVersion).toBeGreaterThan(0)
expect(evidence).not.toHaveProperty('prompt'); expect(evidence).not.toHaveProperty('secret')
expect(passed[3]).not.toContain('synthetic-test-secret')
expect((aiModelAudit as jest.Mock).mock.calls.find(([, , action]) => action === 'model.verification-passed')?.[4].evidence).toMatchObject({ generatedWidth: 4096, storedWidth: 1440 })
})
test('an unexpected reference transport cannot replace the locked native route evidence', async () => {
const context = await (resolveAiModelVerification as jest.Mock)()
const parameters = { n: 1, response_format: 'b64_json' }
;(resolveAiModelVerification as jest.Mock).mockResolvedValue({ ...context, protocol: { id: 'openai-image-generate', path: '/v1/images/generations', headers: {}, supportsImageInput: true, inputField: 'image', referenceTransport: 'json-inline', requestParameters: parameters } })
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => {
await request.beforeRequest()
return { image: await verificationReferenceImage(row.evidence.challenge), requestId: 'safe-result', summary: { referenceTransport: 'multipart', requestParameters: parameters } }
})
await verifyAiModel('admin', providerId, modelKey, { ...input(), stage: 'image' })
const evidence = JSON.parse(mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes("status='passed'"))![3])
expect(evidence.referenceLayoutMatched).toBe(true)
expect(evidence).not.toHaveProperty('referenceTransport')
expect(evidence).not.toHaveProperty('requestParameters')
})
test.each([
{ n: 1, response_format: 'b64_json', callback_url: 'https://example.com/private' },
{ n: 2, response_format: 'b64_json', size: '4096x4096' },
{ n: 1, response_format: 'b64_json', size: '8192x8192' },
{ n: 1, response_format: 'b64_json', size: '2048x2048' },
])('adapter evidence rejects unknown, unbounded or changed request parameters: %j', async requestParameters => {
const context = await (resolveAiModelVerification as jest.Mock)()
;(resolveAiModelVerification as jest.Mock).mockResolvedValue({ ...context, protocol: { id: 'openai-image-generate', path: '/v1/images/generations', headers: {}, supportsImageInput: true, inputField: 'image', referenceTransport: 'multipart', requestParameters: { n: 1, response_format: 'b64_json', size: '4096x4096' } } })
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => {
await request.beforeRequest()
return { image: await verificationReferenceImage(row.evidence.challenge), requestId: 'safe-result', summary: {
referenceTransport: 'multipart', requestParameters, generatedWidth: Infinity, generatedHeight: 1,
storedWidth: 512, storedHeight: -1, referenceLayoutMatched: false,
} }
})
await verifyAiModel('admin', providerId, modelKey, { ...input(), stage: 'image' })
const evidence = JSON.parse(mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes("status='passed'"))![3])
expect(evidence.referenceLayoutMatched).toBe(true)
expect(evidence.referenceTransport).toBe('multipart')
expect(evidence).not.toHaveProperty('requestParameters')
expect(evidence).not.toHaveProperty('generatedWidth'); expect(evidence).not.toHaveProperty('generatedHeight')
expect(evidence).not.toHaveProperty('storedWidth'); expect(evidence).not.toHaveProperty('storedHeight')
})
test('repeated idempotency keys and unresolved requests cannot submit another model call', async () => {
duplicate = true
await verifyAiModel('admin', providerId, modelKey, input())
expect(callAiModelProtocol).not.toHaveBeenCalled()
duplicate = false; unresolved = 1
await expect(verifyAiModel('admin', providerId, modelKey, input())).rejects.toMatchObject({ code: 'verification_unresolved' })
expect(callAiModelProtocol).not.toHaveBeenCalled()
})
test('verification respects active generation capacity', async () => {
active = 2
await expect(verifyAiModel('admin', providerId, modelKey, input())).rejects.toMatchObject({ code: 'verification_busy' })
expect(callAiModelProtocol).not.toHaveBeenCalled()
const capacity = mockDb.$queryRawUnsafe.mock.calls.find(([sql]) => sql.includes('active_executions'))!
expect(capacity[0]).toContain("status='queued' AND checkpoint->>'dispatchPending'='true'")
expect(capacity[0]).toContain("v.status='unknown'")
expect(capacity[0]).toContain("model.verification-execution-ended")
})
test('revocation before admission performs no call or insert', async () => {
;(assertAiModelAdmin as jest.Mock).mockRejectedValue(new AiModelError('forbidden', 403))
await expect(verifyAiModel('admin', providerId, modelKey, input())).rejects.toMatchObject({ status: 403 })
expect(callAiModelProtocol).not.toHaveBeenCalled()
expect(mockDb.$queryRawUnsafe).not.toHaveBeenCalled()
})
test.each([false, true])('protocol outcomeUnknown=%s is persisted accurately, never retried', async unknown => {
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => { await request.beforeRequest(); throw new AiModelProtocolError('synthetic-test-secret must not escape', unknown, 'req-2', unknown ? 'task-2' : null) })
await verifyAiModel('admin', providerId, modelKey, input())
expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
const terminal = mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))!
expect(terminal[2]).toBe(unknown ? 'unknown' : 'failed')
expect(JSON.stringify(terminal)).not.toContain('synthetic-test-secret')
})
test.each([false, true])('safe protocol diagnostics are preserved for outcomeUnknown=%s', async unknown => {
const message = unknown ? '上游返回异常响应(502),请核查请求结果;勿重复提交' : '上游拒绝模型请求(HTTP 403),测试未通过'
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => { await request.beforeRequest(); throw new AiModelProtocolError(message, unknown, 'safe-request-id') })
await verifyAiModel('admin', providerId, modelKey, input())
const terminal = mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))!
expect(terminal).toContain(message)
expect(terminal[2]).toBe(unknown ? 'unknown' : 'failed')
expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
})
test('a persisted task can only be queried through resumeTaskId and its original idempotency key', async () => {
row.status = 'unknown'; row.taskId = 'task-original'
await refreshAiModelVerification('admin', providerId, row.id)
expect(callAiModelProtocol).toHaveBeenCalledWith(expect.objectContaining({ resumeTaskId: 'task-original', idempotencyKey: row.idempotencyKey }))
expect(mockDb.$queryRawUnsafe.mock.calls.some(([sql]) => sql.startsWith('INSERT'))).toBe(false)
expect(mockDb.$queryRawUnsafe.mock.calls.some(([sql]) => sql.includes('active_executions'))).toBe(false)
})
test('unknown without a checkpoint cannot be resumed or bypassed with manual confirmation', async () => {
row.status = 'unknown'; row.taskId = null
await expect(refreshAiModelVerification('admin', providerId, row.id)).rejects.toMatchObject({ code: 'verification_not_resumable' })
await expect(verifyAiModel('admin', providerId, modelKey, { ...input(), compatibility: 'confirmed' })).rejects.toThrow('参数无效')
expect(callAiModelProtocol).not.toHaveBeenCalled()
})
test('a recovered customer request reuses the persisted multi-image challenge and never creates another verification', async () => {
const requestId = randomUUID(), original = await (resolveAiModelVerification as jest.Mock)()
row.status = 'unknown'; row.stage = 'image'; row.evidence = { challenge: { topLeft: 'red', bottomRight: 'blue' }, referenceImageCount: 2, multiReferenceChallenge: { version: 1, references: [{ topLeft: 'red', bottomRight: 'blue' }, { topLeft: 'yellow', bottomRight: 'green' }] } }
;(resolveAiModelVerification as jest.Mock).mockResolvedValue({ ...original, protocol: { id: 'openai-image-generate', path: '/v1/images/generations', headers: {}, supportsImageInput: true, maxImages: 8 } })
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => {
await request.beforeRequest()
const refs = await multiReferenceImages(row.evidence.multiReferenceChallenge)
return { image: await sharp({ create: { width: 1024, height: 512, channels: 3, background: '#fff' } }).composite(refs.map((value, i) => ({ input: value.buffer, left: i * 512, top: 0 }))).png().toBuffer(), requestId, summary: {} }
})
await refreshAiModelVerification('admin', providerId, row.id, { requestId })
expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
expect(callAiModelProtocol).toHaveBeenCalledWith(expect.objectContaining({ resumeRequestId: requestId, idempotencyKey: row.idempotencyKey }))
expect((callAiModelProtocol as jest.Mock).mock.calls[0][0]).not.toHaveProperty('resumeTaskId')
expect(mockDb.$queryRawUnsafe.mock.calls.some(([sql]) => sql.startsWith('INSERT'))).toBe(false)
const passed = mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes("status='passed'"))!
expect(JSON.parse(passed[3])).toMatchObject({ retainedResponse: true, referenceImageCount: 2, referenceMatches: [true, true] })
expect(aiModelAudit).toHaveBeenCalledWith(mockDb, 'admin', 'model.verification-query', providerId, expect.objectContaining({ requestId, retainedResponse: true }))
})
test('unavailable retained result keeps the original request id unknown and cannot be replaced', async () => {
const requestId = randomUUID(), original = await (resolveAiModelVerification as jest.Mock)()
row.status = 'unknown'; row.stage = 'image'; row.requestId = requestId; row.evidence = { challenge: { topLeft: 'red', bottomRight: 'blue' } }
;(resolveAiModelVerification as jest.Mock).mockResolvedValue({ ...original, protocol: { id: 'openai-image-generate', path: '/v1/images/generations', headers: {}, supportsImageInput: true } })
;(callAiModelProtocol as jest.Mock).mockRejectedValue(new AiModelProtocolError('暂未取回', true, requestId))
await refreshAiModelVerification('admin', providerId, row.id)
expect(mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))?.slice(2, 4)).toEqual(['unknown', requestId])
await expect(refreshAiModelVerification('admin', providerId, row.id, { requestId: randomUUID() })).rejects.toMatchObject({ code: 'verification_request_conflict' })
expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
})
test('changed context before dispatch records known failure without pretending it passed', async () => {
;(resolveAiModelVerification as jest.Mock).mockResolvedValueOnce({ ...(await (resolveAiModelVerification as jest.Mock)()), contextHash: row.contextHash })
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => { (resolveAiModelVerification as jest.Mock).mockResolvedValue({ contextHash: 'b'.repeat(64) }); await request.beforeRequest(); throw new Error('must not send') })
await verifyAiModel('admin', providerId, modelKey, input())
const terminal = mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))!
expect(terminal[2]).toBe('failed')
})
test('configuration changes immediately after admission persist known failure and make no model call', async () => {
const original = await (resolveAiModelVerification as jest.Mock)()
;(resolveAiModelVerification as jest.Mock).mockResolvedValueOnce(original).mockRejectedValue(new AiModelError('连接已改变', 409, 'verification_context_changed'))
await verifyAiModel('admin', providerId, modelKey, input())
expect(callAiModelProtocol).not.toHaveBeenCalled()
expect(mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))?.[2]).toBe('failed')
})
test('revocation after an upstream task was accepted preserves unknown and the query checkpoint', async () => {
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => {
await request.beforeRequest()
await request.onTaskSubmitted('task-accepted')
throw new AiModelError('权限已撤销', 403)
})
await verifyAiModel('admin', providerId, modelKey, input())
const terminal = mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))!
expect(terminal[2]).toBe('unknown')
expect(terminal[4]).toBe('task-accepted')
expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
})
test('a resumed task remains unknown when result lookup fails before the request guard', async () => {
row.status = 'unknown'; row.taskId = 'task-original'
;(callAiModelProtocol as jest.Mock).mockRejectedValue(new AiModelProtocolError('DNS temporarily unavailable', true, null, 'task-original'))
await refreshAiModelVerification('admin', providerId, row.id)
const terminal = mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))!
expect(terminal[2]).toBe('unknown')
expect(terminal[4]).toBe('task-original')
expect(callAiModelProtocol).toHaveBeenCalledWith(expect.objectContaining({ resumeTaskId: 'task-original' }))
})
test('output acceptance checks image interpretation and actual outline structure', async () => {
await expect(validateAiModelVerificationOutput('analysis', { text: '{"topLeft":"blue","bottomRight":"red"}' })).rejects.toMatchObject({ code: 'verification_output_invalid' })
await expect(validateAiModelVerificationOutput('copy', { text: '{"text":"ok"}' })).rejects.toMatchObject({ code: 'verification_output_invalid' })
await expect(validateAiModelVerificationOutput('copy', { text: '{"sellingPoints":["蓝色"],"warnings":[],"screens":[{"title":"蓝色产品","body":"型号TEST","imagePrompt":"蓝色产品,无文字"}]}' })).resolves.toMatchObject({ outlineSchemaVerified: true })
})
test('a closed absent submission cannot arrive late and create a paid call', async () => {
const original = mockDb.$queryRawUnsafe.getMockImplementation()!
mockDb.$queryRawUnsafe.mockImplementation(async (sql: string, ...args: any[]) => sql.includes('verification-intake-cancelled') ? [{ id: 'closed-intake' }] : original(sql, ...args))
await expect(verifyAiModel('admin', providerId, modelKey, input())).rejects.toMatchObject({ code: 'verification_submission_closed' })
expect(callAiModelProtocol).not.toHaveBeenCalled()
expect(mockDb.$queryRawUnsafe.mock.calls.some(([sql]) => sql.startsWith('INSERT'))).toBe(false)
})
test('a closed verification stops dispatch and a late response cannot append a false success audit', async () => {
;(prisma.$queryRawUnsafe as jest.Mock).mockResolvedValue([])
await verifyAiModel('admin', providerId, modelKey, input())
expect(mockDb.$executeRawUnsafe.mock.calls.some(([sql]) => sql.includes("status='passed'"))).toBe(false)
;(prisma.$queryRawUnsafe as jest.Mock).mockResolvedValue([{ id: row.id }])
mockDb.$executeRawUnsafe.mockImplementation(async sql => sql.includes("status='passed'") ? 0 : 1)
await verifyAiModel('admin', providerId, modelKey, input())
expect((aiModelAudit as jest.Mock).mock.calls.some(([, , action]) => action === 'model.verification-passed')).toBe(false)
})
test('a terminal decoded image that fails reference validation is failed, not an endless unknown task', async () => {
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => {
await request.beforeRequest(); await request.onTaskSubmitted('task-ended')
const challenge = row.evidence.challenge
const image = await verificationReferenceImage({ topLeft: challenge.bottomRight, bottomRight: challenge.topLeft })
return { image, requestId: 'known-result', summary: {} }
})
row.stage = 'image'
await verifyAiModel('admin', providerId, modelKey, { ...input(), stage: 'image' })
const failed = mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))!
expect(failed[2]).toBe('failed')
expect(failed[3]).toBe('known-result')
expect(failed[4]).toBe('task-ended')
expect(failed[5]).toContain('颜色与相对位置')
expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
})
test('one multimodal request creates independent passed records and consumes one execution identity', async () => {
const submission = { ...input(), stage: 'analysis-copy' }
await verifyAiModel('admin', providerId, modelKey, submission)
expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
expect(callAiModelProtocol).toHaveBeenCalledWith(expect.objectContaining({ idempotencyKey: submission.idempotencyKey, maxOutputTokens: 1800 }))
expect(executions.map(value => value.stage)).toEqual(['analysis', 'copy'])
expect(new Set(executions.map(value => value.evidence.executionGroupId)).size).toBe(1)
expect(executions.every(value => value.evidence.purpose === 'analysis-copy' && value.idempotencyKey === submission.idempotencyKey)).toBe(true)
expect(mockDb.$executeRawUnsafe.mock.calls.filter(([sql]) => sql.includes("status='passed'")).map(([, id]) => id)).toEqual(executions.map(value => value.id))
expect((aiModelAudit as jest.Mock).mock.calls.filter(([, , action]) => action === 'model.verification-start')).toHaveLength(1)
expect(mockDb.$queryRawUnsafe.mock.calls.find(([sql]) => sql.includes('active_executions'))?.[0]).toContain("SELECT DISTINCT COALESCE(v.evidence->>'executionGroupId',v.id)")
duplicate = true
await verifyAiModel('admin', providerId, modelKey, submission)
expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
})
test.each(['analysis', 'copy'] as const)('a failed %s section leaves the other purpose independently passed', async failedStage => {
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => {
await request.beforeRequest()
const content = { analysis: row.evidence.challenge, copy: validCopy, [failedStage]: failedStage === 'analysis' ? { topLeft: 'pink', bottomRight: 'pink' } : { screens: [] } }
return { text: JSON.stringify(content), requestId: 'partial-result', summary: {} }
})
await verifyAiModel('admin', providerId, modelKey, { ...input(), stage: 'analysis-copy' })
const failed = executions.find(value => value.stage === failedStage), passed = executions.find(value => value.stage !== failedStage)
expect(mockDb.$executeRawUnsafe.mock.calls.some(([sql, id]) => sql.includes("status='failed'") && id === failed.id)).toBe(true)
expect(mockDb.$executeRawUnsafe.mock.calls.some(([sql, id]) => sql.includes("status='passed'") && id === passed.id)).toBe(true)
expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
})
test('unsupported image input or a different copy route cannot dispatch a joint test', async () => {
const original = await (resolveAiModelVerification as jest.Mock)()
;(resolveAiModelVerification as jest.Mock).mockResolvedValue({ ...original, protocol: { ...original.protocol, supportsImageInput: false } })
await expect(verifyAiModel('admin', providerId, modelKey, { ...input(), stage: 'analysis-copy' })).rejects.toMatchObject({ code: 'verification_combined_unsupported' })
;(resolveAiModelVerification as jest.Mock).mockImplementation(async (_provider, _model, stage) => ({ ...original, protocol: { ...original.protocol, path: stage === 'copy' ? '/v1/messages' : original.protocol.path } }))
await expect(verifyAiModel('admin', providerId, modelKey, { ...input(), stage: 'analysis-copy' })).rejects.toMatchObject({ code: 'verification_combined_unsupported' })
expect(callAiModelProtocol).not.toHaveBeenCalled()
expect(executions).toHaveLength(0)
})
test('unknown joint outcomes pause both purposes and query only the same original task', async () => {
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => {
await request.beforeRequest(); await request.onTaskSubmitted('task-joint')
throw new AiModelProtocolError('interrupted', true, 'request-joint', 'task-joint')
})
const submission = { ...input(), stage: 'analysis-copy' }
await verifyAiModel('admin', providerId, modelKey, submission)
expect(mockDb.$executeRawUnsafe.mock.calls.filter(([sql, , status]) => sql.includes('status=$2') && status === 'unknown')).toHaveLength(2)
executions.forEach(value => { value.status = 'unknown'; value.taskId = 'task-joint' })
row = executions[1]
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => { await request.beforeRequest(); return { text: JSON.stringify({ analysis: row.evidence.challenge, copy: validCopy }), requestId: 'request-joint', summary: {} } })
await refreshAiModelVerification('admin', providerId, row.id)
expect(callAiModelProtocol).toHaveBeenLastCalledWith(expect.objectContaining({ resumeTaskId: 'task-joint', idempotencyKey: submission.idempotencyKey }))
expect(executions).toHaveLength(2)
expect(mockDb.$queryRawUnsafe.mock.calls.filter(([sql]) => sql.includes('active_executions'))).toHaveLength(1)
})
test('malformed joint JSON fails both checks and null sections cannot pass', async () => {
await expect(validateAiModelCombinedVerificationOutput({ text: 'not JSON' }, { topLeft: 'red', bottomRight: 'blue' })).resolves.toMatchObject({ analysis: { status: 'failed' }, copy: { status: 'failed' } })
await expect(validateAiModelCombinedVerificationOutput({ text: JSON.stringify({ analysis: null, copy: validCopy }) }, { topLeft: 'red', bottomRight: 'blue' })).resolves.toMatchObject({ analysis: { status: 'failed' }, copy: { status: 'passed' } })
})
test('a joint text recovery reads the original retained response once and records both purposes separately', async () => {
await verifyAiModel('admin', providerId, modelKey, { ...input(), stage: 'analysis-copy' })
const requestId = randomUUID()
executions.forEach(value => { value.status = 'unknown'; value.requestId = requestId; value.taskId = null })
row = executions[1]
;(callAiModelProtocol as jest.Mock).mockClear().mockImplementation(async request => {
await request.beforeRequest()
expect(request.resumeRequestId).toBe(requestId)
expect(request.resumeTaskId).toBeUndefined()
return { text: JSON.stringify({ analysis: row.evidence.challenge, copy: { screens: [] } }), requestId, summary: { retainedResponse: true } }
})
mockDb.$executeRawUnsafe.mockClear()
await refreshAiModelVerification('admin', providerId, row.id)
expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
const saved = mockDb.$executeRawUnsafe.mock.calls.filter(([sql]) => sql.includes('finished_at=NOW()') && sql.includes('evidence=evidence'))
expect(saved).toHaveLength(2)
expect(saved.map(call => call[0])).toEqual([expect.stringContaining("status='passed'"), expect.stringContaining("status='failed'")])
expect(saved.every(call => JSON.parse(call[3]).retainedResponse === true)).toBe(true)
expect(executions).toHaveLength(2)
})
test('a terminal truncated retained text response closes the checks as failed without another submission', async () => {
await verifyAiModel('admin', providerId, modelKey, { ...input(), stage: 'analysis-copy' })
const requestId = randomUUID()
executions.forEach(value => { value.status = 'unknown'; value.requestId = requestId; value.taskId = null })
row = executions[0]
;(callAiModelProtocol as jest.Mock).mockClear().mockImplementation(async request => { await request.beforeRequest(); throw new AiModelProtocolError('模型输出不完整,本次调用已结束', false, row.requestId) })
mockDb.$executeRawUnsafe.mockClear()
await refreshAiModelVerification('admin', providerId, row.id)
expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
const failed = mockDb.$executeRawUnsafe.mock.calls.filter(([sql]) => sql.includes('status=$2'))
expect(failed).toHaveLength(2)
expect(failed.every(call => call[2] === 'failed')).toBe(true)
})