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

156 lines
13 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, assertAiModelAdminForWrite } from '@/lib/ai-model-access'
import { checkAiModelSubmission, reconcileAiModelVerification } from '@/lib/ai-model-recovery'
import { aiModelAudit } from '@/lib/ai-model-repository'
import { assertAiModelRecoveryTextSafe, resolveAiModelVerification } from '@/lib/ai-model-service'
import { aiModelVerificationSummary } from '@/lib/ai-model-verification-state'
import { createVerificationChallenge, validateAiModelVerificationOutput, verificationReferenceImage } from '@/lib/ai-model-verification-output'
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: {} }))
jest.mock('@/lib/ai-model-access', () => ({ ...jest.requireActual('@/lib/ai-model-access'), assertAiModelAdmin: jest.fn(), assertAiModelAdminForWrite: jest.fn() }))
jest.mock('@/lib/ai-model-service', () => ({ getAiModelAdminState: jest.fn(async () => ({ providers: [] })), resolveAiModelVerification: jest.fn(), assertAiModelRecoveryTextSafe: jest.fn() }))
const mockDb = { $queryRawUnsafe: jest.fn(), $executeRawUnsafe: jest.fn() }
jest.mock('@/lib/ai-model-repository', () => ({ aiModelTransaction: jest.fn(async action => action(mockDb)), aiModelAudit: jest.fn() }))
const providerId = randomUUID(), modelKey = randomUUID(), request = { stage: 'analysis', idempotencyKey: randomUUID() }
const proof = { outcome: 'failed', reference: 'upstream-log-123', note: '核查上游日志,原请求已失败并停止执行。', confirmed: true }
let row: any, group: any[] | null, exists: boolean, closed: boolean, found: boolean
beforeEach(() => {
jest.clearAllMocks(); exists = true; closed = false; found = true; group = null
row = { id: randomUUID(), modelKey, stage: 'analysis', idempotencyKey: request.idempotencyKey, actorId: 'admin', status: 'unknown', taskId: null, evidence: {}, contextHash: 'a'.repeat(64), deadlineAt: new Date(Date.now() - 1), startedAt: new Date(), finishedAt: new Date(), requestId: null }
;(assertAiModelAdmin as jest.Mock).mockResolvedValue('admin')
;(assertAiModelAdminForWrite as jest.Mock).mockResolvedValue(undefined)
;(assertAiModelRecoveryTextSafe as jest.Mock).mockResolvedValue(undefined)
;(resolveAiModelVerification as jest.Mock).mockResolvedValue({ contextHash: row.contextHash })
mockDb.$queryRawUnsafe.mockImplementation(async sql => sql.includes("evidence->>'executionGroupId'=$3") ? group || [] : sql.includes('FROM ai_model_catalog WHERE id=') ? found ? [{ id: modelKey }] : [] : sql.includes('FROM ai_model_audit') ? closed ? [{ id: 'audit' }] : [] : exists ? [row] : [])
mockDb.$executeRawUnsafe.mockResolvedValue(1)
})
test('absent intake is closed by audit and never creates a verification or calls a model', async () => {
exists = false
await expect(checkAiModelSubmission('admin', providerId, modelKey, request)).resolves.toMatchObject({ submissionCheck: { received: false, idempotencyKey: request.idempotencyKey } })
expect(aiModelAudit).toHaveBeenCalledWith(mockDb, 'admin', 'model.verification-intake-cancelled', providerId, expect.objectContaining(request))
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
expect(resolveAiModelVerification).not.toHaveBeenCalled()
})
test('existing intake is read without cancellation or repeat model calls', async () => {
await expect(checkAiModelSubmission('admin', providerId, modelKey, request)).resolves.toMatchObject({ submissionCheck: { received: true } })
expect(aiModelAudit).not.toHaveBeenCalled()
expect(resolveAiModelVerification).not.toHaveBeenCalled()
})
function jointRows() {
const evidence = { purpose: 'analysis-copy', executionGroupId: randomUUID() }
group = [{ ...row, stage: 'analysis', evidence }, { ...row, id: randomUUID(), stage: 'copy', evidence }]
row = group[1]
}
test('one joint intake resolves both records and one recovery closes both without approving them', async () => {
jointRows()
await expect(checkAiModelSubmission('admin', providerId, modelKey, { ...request, stage: 'analysis-copy' })).resolves.toMatchObject({ submissionCheck: { received: true, stage: 'analysis-copy' } })
await reconcileAiModelVerification('admin', providerId, row.id, proof)
expect(mockDb.$executeRawUnsafe.mock.calls.map(([, id]) => id)).toEqual(group!.map(value => value.id))
expect(mockDb.$executeRawUnsafe.mock.calls.every(([sql]) => sql.includes("status='failed'"))).toBe(true)
expect(aiModelAudit).toHaveBeenCalledWith(mockDb, 'admin', 'model.verification-reconciled', providerId, expect.objectContaining({ stage: 'analysis-copy', verificationIds: group!.map(value => value.id) }))
})
test('incomplete or still-running joint records cannot be manually closed', async () => {
jointRows(); group![0].status = 'running'; group![0].deadlineAt = new Date(Date.now() + 600000)
await expect(reconcileAiModelVerification('admin', providerId, row.id, proof)).rejects.toMatchObject({ code: 'verification_not_unknown' })
group!.shift()
await expect(reconcileAiModelVerification('admin', providerId, row.id, proof)).rejects.toMatchObject({ code: 'verification_checkpoint_invalid' })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('a joint original task must be queried unless at least one of its contexts changed', async () => {
jointRows(); group!.forEach(value => { value.taskId = 'task-joint' })
await expect(reconcileAiModelVerification('admin', providerId, row.id, proof)).rejects.toMatchObject({ code: 'verification_query_required' })
;(resolveAiModelVerification as jest.Mock).mockImplementation(async (_provider, _model, stage) => ({ contextHash: stage === 'analysis' ? 'changed' : row.contextHash }))
await reconcileAiModelVerification('admin', providerId, row.id, proof)
expect(mockDb.$executeRawUnsafe).toHaveBeenCalledTimes(2)
})
test('closing the same absent intake twice does not duplicate audit', async () => {
exists = false; closed = true
await checkAiModelSubmission('admin', providerId, modelKey, request)
expect(aiModelAudit).not.toHaveBeenCalled()
})
test('wrong provider is denied; another super administrator can inspect the original global intake', async () => {
found = false
await expect(checkAiModelSubmission('admin', providerId, modelKey, request)).rejects.toMatchObject({ status: 404 })
found = true; row.actorId = 'another-admin'
await expect(checkAiModelSubmission('admin', providerId, modelKey, request)).resolves.toMatchObject({ submissionCheck: { received: true } })
expect(aiModelAudit).not.toHaveBeenCalled()
})
test('unknown without checkpoint can be closed with evidence, never passed or enabled', async () => {
await reconcileAiModelVerification('admin', providerId, row.id, proof)
expect(mockDb.$executeRawUnsafe).toHaveBeenCalledWith(expect.stringContaining("status='failed'"), row.id, expect.stringContaining('upstream-log-123'))
expect(aiModelAudit).toHaveBeenCalledWith(mockDb, 'admin', 'model.verification-reconciled', providerId, expect.any(Object))
expect(mockDb.$executeRawUnsafe.mock.calls.some(([sql]) => /status='passed'|UPDATE ai_model_catalog/.test(sql))).toBe(false)
})
test.each([{ confirmed: false }, { note: '确认' }, { reference: '' }, { outcome: 'unknown' }, { compatibility: 'confirmed' }])('insufficient evidence cannot close an unknown request: %j', async change => {
await expect(reconcileAiModelVerification('admin', providerId, row.id, { ...proof, ...change })).rejects.toMatchObject({ status: 400 })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('live running cannot be reconciled and queryable original task cannot be manually closed', async () => {
row.status = 'running'; row.deadlineAt = new Date(Date.now() + 600000)
await expect(reconcileAiModelVerification('admin', providerId, row.id, proof)).rejects.toMatchObject({ code: 'verification_not_unknown' })
row.status = 'unknown'; row.taskId = 'original-task'
await expect(reconcileAiModelVerification('admin', providerId, row.id, proof)).rejects.toMatchObject({ code: 'verification_query_required' })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('a retained image lookup may still be closed with evidence when its response cannot be retrieved; closure never approves it', async () => {
row.stage = 'image'; row.requestId = randomUUID()
expect(aiModelVerificationSummary([row], modelKey, 'image', row.contextHash)).toMatchObject({ canResume: false, canReconcile: true })
await reconcileAiModelVerification('admin', providerId, row.id, proof)
expect(mockDb.$executeRawUnsafe).toHaveBeenCalledWith(expect.stringContaining("status='failed'"), row.id, expect.stringContaining('upstream-log-123'))
expect(resolveAiModelVerification).not.toHaveBeenCalled()
})
test('changed connection allows recording upstream evidence but never regenerates', async () => {
row.taskId = 'original-task'
;(resolveAiModelVerification as jest.Mock).mockResolvedValue({ contextHash: 'changed' })
await reconcileAiModelVerification('admin', providerId, row.id, proof)
expect(mockDb.$executeRawUnsafe).toHaveBeenCalledTimes(1)
})
test('idempotent reconciliation cannot rewrite the original evidence', async () => {
row.evidence = { reconciliation: proof }
await reconcileAiModelVerification('admin', providerId, row.id, { ...proof, outcome: 'completed' })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
expect(aiModelAudit).not.toHaveBeenCalled()
})
test('revocation inside transaction and credential-bearing notes cause no write', async () => {
;(assertAiModelAdminForWrite as jest.Mock).mockRejectedValue(new AiModelError('已撤权', 403))
await expect(checkAiModelSubmission('admin', providerId, modelKey, request)).rejects.toMatchObject({ status: 403 })
;(assertAiModelRecoveryTextSafe as jest.Mock).mockRejectedValue(new AiModelError('包含密钥', 400))
await expect(reconcileAiModelVerification('admin', providerId, row.id, proof)).rejects.toMatchObject({ status: 400 })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('old passed rules require retest; task recovery preserves context and cannot be silently reapproved', () => {
row.status = 'passed'
expect(aiModelVerificationSummary([row], modelKey, 'analysis', row.contextHash).status).toBe('stale')
row.status = 'unknown'; row.taskId = 'old-task'
expect(aiModelVerificationSummary([row], modelKey, 'analysis', row.contextHash)).toMatchObject({ status: 'unknown', canResume: true, canReconcile: false })
expect(aiModelVerificationSummary([row], modelKey, 'analysis', 'changed')).toMatchObject({ status: 'unknown', canResume: false, canReconcile: true })
expect(aiModelVerificationSummary([row], modelKey, 'analysis', row.contextHash, false)).toMatchObject({ status: 'unknown', canResume: false, canReconcile: true })
})
test('unknown outcome retains its safe cause; an expired running record uses the interruption warning', () => {
row.message = '上游返回异常响应(502),请核查请求结果;勿重复提交'
expect(aiModelVerificationSummary([row], modelKey, 'analysis', row.contextHash)).toMatchObject({ status: 'unknown', message: row.message, canReconcile: true })
row.status = 'running'; row.message = '正在执行'
expect(aiModelVerificationSummary([row], modelKey, 'analysis', row.contextHash)).toMatchObject({ status: 'unknown', message: '请求结果尚未确认,已暂停重复测试;请核查上游请求记录' })
})
test('a reconciled failed upstream request exposes the checked cause without passing or enabling it', () => {
row.status = 'failed'; row.evidence = { reconciliation: proof }
expect(aiModelVerificationSummary([row], modelKey, 'analysis', row.contextHash)).toMatchObject({ status: 'failed', reconciled: true, canResume: false, canReconcile: false, message: `已核查上游执行失败:${proof.note}` })
expect(aiModelVerificationSummary([row], modelKey, 'analysis', 'changed')).toMatchObject({ status: 'stale', message: '连接、路由或测试规则已改变,请重新测试' })
})
test('randomized reference image validation rejects swapped layout and wrong image interpretation', async () => {
const challenge = createVerificationChallenge(), correct = await verificationReferenceImage(challenge)
expect(challenge.topLeft).not.toBe(challenge.bottomRight)
await expect(validateAiModelVerificationOutput('image', { image: correct }, challenge)).resolves.toMatchObject({ referenceLayoutMatched: true })
const swapped = { topLeft: challenge.bottomRight, bottomRight: challenge.topLeft }
await expect(validateAiModelVerificationOutput('image', { image: await verificationReferenceImage(swapped) }, challenge)).rejects.toMatchObject({ code: 'verification_output_invalid' })
await expect(validateAiModelVerificationOutput('analysis', { text: JSON.stringify(swapped) }, challenge)).rejects.toMatchObject({ code: 'verification_output_invalid' })
await expect(validateAiModelVerificationOutput('analysis', { text: JSON.stringify(challenge) }, challenge)).resolves.toMatchObject({ imageInputVerified: true })
})