/** @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 makeJob = (kind: StoredDetailJob['kind'] = 'analyze'): StoredDetailJob => ({ id: 'job-1', projectId: 'project-1', ownerId: 'owner-1', kind, status: 'running', payload: {}, 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') expect(composeDetailOutline).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('文档中的原始产品事实'), 'job-1-outline') 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') expect(saveDetailJobAsset).not.toHaveBeenCalled() expect(finishDetailJob).toHaveBeenCalledWith('job-1', 'lease-1', 'unknown', expect.objectContaining({ stage: 'screen-second' }), '生成结果未知', null) })