90 lines
8.6 KiB
TypeScript
90 lines
8.6 KiB
TypeScript
/** @jest-environment node */
|
|
import { isSuperAdmin } from '@/lib/department-permission'
|
|
import { processDetailJob } from '@/lib/detail-generator/jobs'
|
|
import { analyzeDetailFacts, composeDetailOutline, DetailProviderError, generateDetailImage } from '@/lib/detail-generator/provider'
|
|
import { checkpointDetailJob, findDetailAsset, findDetailProject, finishDetailJob, saveDetailJobAsset, saveDetailJobDocument, type StoredDetailJob } from '@/lib/detail-generator/repository'
|
|
import type { DetailDocument, DetailScreen } from '@/lib/detail-generator/types'
|
|
import { prisma } from '@/lib/prisma'
|
|
|
|
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: { user: { findUnique: jest.fn() } } }))
|
|
jest.mock('@/lib/detail-generator/repository', () => ({ assertDetailDocumentAssets: jest.fn(), checkpointDetailJob: jest.fn(), claimDetailJob: jest.fn(), findDetailAsset: jest.fn(), findDetailProject: jest.fn(), finishDetailJob: jest.fn(), renewDetailJobLease: jest.fn(), saveDetailJobAsset: jest.fn(), saveDetailJobDocument: jest.fn() }))
|
|
jest.mock('@/lib/detail-generator/storage', () => ({ deleteDetailFile: jest.fn(), readDetailFile: jest.fn().mockResolvedValue(Buffer.from('image')), saveDetailFile: jest.fn().mockResolvedValue('private/image.png') }))
|
|
jest.mock('@/lib/detail-generator/render', () => ({ renderDetailExport: jest.fn() }))
|
|
jest.mock('@/lib/detail-generator/provider', () => ({ ...jest.requireActual('@/lib/detail-generator/provider'), analyzeDetailFacts: jest.fn(), composeDetailOutline: jest.fn(), generateDetailImage: jest.fn() }))
|
|
|
|
const makeDocument = (): DetailDocument => ({ version: 1, name: '产品', product: { identifierType: 'sku', identifier: 'SKU1', variant: '白色', name: '产品', facts: '真实尺寸', assetIds: ['asset-1'], referenceAssetIds: [], documentAssetIds: ['doc-1'] }, brand: { name: '品牌', tone: '真实', primaryColor: '#111111', backgroundColor: '#FFFFFF', textColor: '#111111', fontFamily: 'Noto Sans SC', status: 'draft' }, models: { analysis: 'gemini-2.5-flash', copy: 'gemini-2.5-flash', image: 'nano-banana-2' }, analysis: null, approvedAt: null, screens: [] })
|
|
const bindings = Object.fromEntries(['analysis', 'copy', 'image'].map(stage => [stage, { providerId: '11111111-1111-4111-8111-111111111111', modelKey: '22222222-2222-4222-8222-222222222222', modelId: stage, providerName: '测试上游', baseUrl: 'https://model.example.test/v1', revision: 1 }]))
|
|
const makeJob = (kind: StoredDetailJob['kind'] = 'analyze'): StoredDetailJob => ({ id: 'job-1', projectId: 'project-1', ownerId: 'owner-1', kind, status: 'running', payload: { modelBindings: bindings }, checkpoint: {}, fingerprint: 'fingerprint', idempotencyKey: 'request-id', projectRevision: 1, leaseToken: 'lease-1', leaseExpiresAt: new Date(Date.now() + 10000), progress: 0, error: null, requestId: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() })
|
|
let document: DetailDocument
|
|
const originalEnabled = process.env.DETAIL_GENERATOR_ENABLED
|
|
afterAll(() => { if (originalEnabled === undefined) delete process.env.DETAIL_GENERATOR_ENABLED; else process.env.DETAIL_GENERATOR_ENABLED = originalEnabled })
|
|
beforeEach(() => {
|
|
jest.clearAllMocks(); process.env.DETAIL_GENERATOR_ENABLED = 'true'
|
|
document = makeDocument()
|
|
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
|
|
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ status: 'active', isActive: true, archivedAt: null })
|
|
;(findDetailProject as jest.Mock).mockImplementation(async () => ({ id: 'project-1', ownerId: 'owner-1', document, revision: 1 }))
|
|
;(findDetailAsset as jest.Mock).mockImplementation(async id => ({ id, ownerId: 'owner-1', projectId: 'project-1', kind: id === 'doc-1' ? 'document' : 'product', mimeType: 'image/png', storageKey: 'private/source.png', extractedText: '文档中的原始产品事实' }))
|
|
})
|
|
|
|
test('revocation immediately before dispatch clears marker and makes zero paid calls', async () => {
|
|
;(isSuperAdmin as jest.Mock).mockResolvedValueOnce(true).mockResolvedValueOnce(true).mockResolvedValueOnce(false)
|
|
await processDetailJob(makeJob())
|
|
expect(analyzeDetailFacts).not.toHaveBeenCalled()
|
|
expect(composeDetailOutline).not.toHaveBeenCalled()
|
|
expect(checkpointDetailJob).toHaveBeenLastCalledWith('job-1', 'lease-1', expect.objectContaining({ dispatchPending: false }), 10)
|
|
expect(finishDetailJob).toHaveBeenCalledWith('job-1', 'lease-1', 'failed', expect.objectContaining({ dispatchPending: false }), expect.stringContaining('超级管理员'), null)
|
|
})
|
|
|
|
test('suspension immediately before model dispatch stops a queued super-admin job with zero paid calls', async () => {
|
|
;(prisma.user.findUnique as jest.Mock).mockResolvedValueOnce({ status: 'active', isActive: true, archivedAt: null }).mockResolvedValueOnce({ status: 'active', isActive: true, archivedAt: null }).mockResolvedValueOnce({ status: 'suspended', isActive: true, archivedAt: null })
|
|
await processDetailJob(makeJob())
|
|
expect(analyzeDetailFacts).not.toHaveBeenCalled()
|
|
expect(composeDetailOutline).not.toHaveBeenCalled()
|
|
expect(finishDetailJob).toHaveBeenCalledWith('job-1', 'lease-1', 'failed', expect.objectContaining({ dispatchPending: false }), expect.stringContaining('账号当前不可用'), null)
|
|
})
|
|
|
|
test('uploaded product text reaches analysis and copy; saved facts checkpoint resumes without repeated analysis', async () => {
|
|
;(analyzeDetailFacts as jest.Mock).mockResolvedValue('已核对事实')
|
|
;(composeDetailOutline as jest.Mock).mockResolvedValue({ sellingPoints: ['真实卖点'], warnings: [], screens: [] })
|
|
await processDetailJob(makeJob())
|
|
expect(analyzeDetailFacts).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.stringContaining('文档中的原始产品事实'), 'job-1-product-analysis', 'owner-1', bindings.analysis)
|
|
expect(composeDetailOutline).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('文档中的原始产品事实'), 'job-1-outline', 'owner-1', bindings.copy)
|
|
expect(saveDetailJobDocument).toHaveBeenCalledWith('job-1', 'lease-1', expect.objectContaining({ approvedAt: null }), expect.objectContaining({ dispatchPending: false }), 100)
|
|
jest.clearAllMocks()
|
|
const resumed = makeJob(); resumed.checkpoint = { facts: '已核对事实', dispatchPending: false }
|
|
await processDetailJob(resumed)
|
|
expect(analyzeDetailFacts).not.toHaveBeenCalled()
|
|
expect(composeDetailOutline).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('unknown provider outcome is terminal without retry or advancing to copy', async () => {
|
|
;(analyzeDetailFacts as jest.Mock).mockRejectedValue(new DetailProviderError('请求结果未知', true, 'safe-request-id'))
|
|
await processDetailJob(makeJob())
|
|
expect(analyzeDetailFacts).toHaveBeenCalledTimes(1)
|
|
expect(composeDetailOutline).not.toHaveBeenCalled()
|
|
expect(finishDetailJob).toHaveBeenCalledWith('job-1', 'lease-1', 'unknown', expect.objectContaining({ dispatchPending: true }), '请求结果未知', 'safe-request-id')
|
|
})
|
|
|
|
test('crash after durable completion finalizes without a second model or export call', async () => {
|
|
const resumed = makeJob(); resumed.checkpoint = { stage: 'complete', facts: '已保存事实', dispatchPending: false }
|
|
await processDetailJob(resumed)
|
|
expect(analyzeDetailFacts).not.toHaveBeenCalled()
|
|
expect(composeDetailOutline).not.toHaveBeenCalled()
|
|
expect(finishDetailJob).toHaveBeenCalledWith('job-1', 'lease-1', 'succeeded', expect.objectContaining({ stage: 'complete' }))
|
|
})
|
|
|
|
test('remaining-screen generation skips completed imagery and stops immediately on unknown outcome', async () => {
|
|
const screen: DetailScreen = { id: 'first', title: '一', body: '', imagePrompt: '场景', width: 750, height: 1200, backgroundColor: '#FFFFFF', texts: [], image: { assetId: 'existing', x: 0, y: 380, width: 750, height: 750 } }
|
|
document.approvedAt = new Date().toISOString(); document.analysis = { sellingPoints: [], warnings: [], screens: [] }; document.screens = [screen, { ...screen, id: 'second', image: undefined }, { ...screen, id: 'third', image: undefined }]
|
|
;(generateDetailImage as jest.Mock).mockRejectedValue(new DetailProviderError('生成结果未知', true))
|
|
await processDetailJob(makeJob('generate'))
|
|
expect(generateDetailImage).toHaveBeenCalledTimes(1)
|
|
expect(generateDetailImage).toHaveBeenCalledWith(expect.anything(), '场景', expect.anything(), 'job-1-screen-second', 'owner-1', bindings.image)
|
|
expect(saveDetailJobAsset).not.toHaveBeenCalled()
|
|
expect(finishDetailJob).toHaveBeenCalledWith('job-1', 'lease-1', 'unknown', expect.objectContaining({ stage: 'screen-second' }), '生成结果未知', null)
|
|
})
|