162 lines
12 KiB
TypeScript
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)
|
|
})
|