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

162 lines
12 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 { 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'
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, duplicate = false, unresolved = 0, active = 0
beforeEach(() => {
jest.clearAllMocks(); duplicate = false; unresolved = 0; active = 0
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.idempotencyKey = args[4]; row.evidence = JSON.parse(args[6]); return [row] }
if (sql.includes('idempotency_key=$3')) 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(); return { text: JSON.stringify(row.evidence?.challenge || { topLeft: 'red', bottomRight: 'blue' }), requestId: 'request-1', summary: {} } })
})
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('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("status IN ('running','unknown')")
})
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('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.find(([sql]) => sql.includes('active_executions'))?.[2]).toBe(row.id)
})
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('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 () => {
const image = await sharp({ create: { width: 128, height: 128, channels: 3, background: '#ffffff' } }).png().toBuffer()
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => { await request.beforeRequest(); await request.onTaskSubmitted('task-ended'); return { image, requestId: 'known-result', summary: {} } })
row.stage = 'image'
await verifyAiModel('admin', providerId, modelKey, { ...input(), stage: 'image' })
expect(mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))?.[2]).toBe('failed')
expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
})