413 lines
33 KiB
TypeScript
413 lines
33 KiB
TypeScript
/** @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)
|
||
})
|