gongxiangp2/lib/__tests__/detail-generator-service.test.ts

178 lines
14 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 { importDetailMaterial, editDetailProject, newDetailProject, getDetailProject, createDetailAction, createDetailScreen, __detailUploadTesting } from '@/lib/detail-generator/service'
import { readDetailMaterialImage } from '@/lib/detail-generator/materials'
import { attachDetailAsset, createDetailProject, findDetailProject, updateDetailProject, enqueueDetailJob, listDetailAssets, listDetailJobs } from '@/lib/detail-generator/repository'
import type { DetailDocument } from '@/lib/detail-generator/types'
import { deflateRawSync } from 'zlib'
import AdmZip from 'adm-zip'
import { captureAiModelBindings, resolveAiModel } from '@/lib/ai-model-service'
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/detail-generator/brand-profiles', () => ({ listDetailBrandProfiles: jest.fn() }))
jest.mock('@/lib/detail-generator/materials', () => ({ readDetailMaterialImage: jest.fn() }))
jest.mock('@/lib/detail-generator/repository', () => ({ assertDetailDocumentAssets: jest.fn(), attachDetailAsset: jest.fn(), createDetailProject: jest.fn(), detailPublicAsset: jest.fn(), detailPublicJob: jest.fn(), enqueueDetailJob: jest.fn(), findDetailProject: jest.fn(), listDetailAssets: jest.fn(), listDetailJobs: jest.fn(), listDetailProjects: jest.fn(), updateDetailProject: jest.fn() }))
jest.mock('@/lib/detail-generator/storage', () => ({ saveDetailFile: jest.fn(), deleteDetailFile: jest.fn() }))
jest.mock('@/lib/ai-model-service', () => ({ captureAiModelBindings: jest.fn().mockResolvedValue({}), getAiModelRuntimeState: jest.fn(), resolveAiModel: jest.fn().mockResolvedValue({}) }))
const fixture = (): DetailDocument => ({ version: 1, name: '真实产品', product: { identifierType: 'sku', identifier: 'SKU-001', variant: '', name: '产品', facts: '用户提供事实', assetIds: [], referenceAssetIds: [], documentAssetIds: [] }, 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: [] })
let document: DetailDocument
beforeEach(() => {
jest.clearAllMocks(); document = fixture(); delete process.env.DETAIL_GENERATOR_VALIDATED_MODELS_JSON
;(findDetailProject as jest.Mock).mockImplementation(async () => ({ id: 'project-1', ownerId: 'owner-1', revision: 3, document }))
;(readDetailMaterialImage as jest.Mock).mockResolvedValue({ buffer: Buffer.from('image'), name: '来源图', productModel: 'SPU-002', variant: 'SKU-002', barcodes: ['BAR-002'] })
})
test.each([['sku', 'SKU-001'], ['spu', 'SPU-001'], ['barcode', 'BAR-001']] as const)('rejects a %s source mismatch before writing a project asset', async (identifierType, identifier) => {
document.product.identifierType = identifierType; document.product.identifier = identifier
await expect(importDetailMaterial('owner-1', 'project-1', { materialId: 'material-2', source: 'asset', sourceId: 'asset-2', revision: 3 })).rejects.toMatchObject({ status: 409, code: 'product_identity_conflict' })
expect(attachDetailAsset).not.toHaveBeenCalled()
})
test('client-provided approval cannot make a draft generate-ready', async () => {
const edited = fixture(); edited.approvedAt = '2026-10-12T00:00:00.000Z'
await editDetailProject('owner-1', 'project-1', { revision: 3, document: edited })
expect(updateDetailProject).toHaveBeenCalledWith('project-1', 'owner-1', 3, expect.objectContaining({ approvedAt: null }))
await expect(newDetailProject('owner-1', edited)).rejects.toThrow('空白草案')
expect(createDetailProject).not.toHaveBeenCalled()
})
test('empty model selections can be saved as a product draft without a model connection', async () => {
const draft = fixture(); draft.models = { analysis: '', copy: '', image: '' }
await newDetailProject('owner-1', draft)
expect(createDetailProject).toHaveBeenCalledWith('owner-1', expect.objectContaining({ models: draft.models }))
expect(captureAiModelBindings).not.toHaveBeenCalled()
expect(resolveAiModel).not.toHaveBeenCalled()
})
test('unchanged unavailable models do not prevent ordinary editing and client bindings cannot replace server snapshots', async () => {
const currentBinding = { providerId: '11111111-1111-4111-8111-111111111111', modelKey: '22222222-2222-4222-8222-222222222222', modelId: 'original', providerName: '原上游', baseUrl: 'https://original.example.com/v1', revision: 1 }
document.modelBindings = { analysis: currentBinding, copy: currentBinding, image: currentBinding }
const edited = structuredClone(document); edited.name = '仅编辑项目名称'
const forgedBinding = { ...currentBinding, baseUrl: 'https://other.example.com/v1', revision: 200 }
edited.modelBindings = { analysis: forgedBinding, copy: forgedBinding, image: forgedBinding }
await editDetailProject('owner-1', 'project-1', { revision: 3, document: edited })
expect(updateDetailProject).toHaveBeenCalledWith('project-1', 'owner-1', 3, expect.objectContaining({ modelBindings: document.modelBindings, name: edited.name }))
expect(resolveAiModel).not.toHaveBeenCalled()
})
test('changing product facts invalidates the old product imagery and approval', async () => {
const edited = fixture(); edited.product.facts = '已修正产品事实'
await editDetailProject('owner-1', 'project-1', { revision: 3, document: edited })
expect(updateDetailProject).toHaveBeenCalledWith('project-1', 'owner-1', 3, expect.objectContaining({ approvedAt: null, analysis: null, screens: [] }))
})
function initialAnalysis() {
return { sellingPoints: ['用户给出的真实卖点'], warnings: ['认证未知'], screens: [{ id: '11111111-1111-4111-8111-111111111111', title: '原始标题', body: '原始正文', imagePrompt: '真实产品展示' }] }
}
function databaseKeyOrder(value: unknown): unknown {
if (Array.isArray(value)) return value.map(databaseKeyOrder)
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).reverse().map(([key, entry]) => [key, databaseKeyOrder(entry)]))
return value
}
test('JSONB source key reordering does not erase an edited unapproved outline', async () => {
const stored = fixture(); stored.analysis = initialAnalysis()
document = databaseKeyOrder(stored) as DetailDocument
const edited = JSON.parse(JSON.stringify(document)) as DetailDocument
edited.analysis!.screens[0].title = '数据库重排键后的人工标题'
await editDetailProject('owner-1', 'project-1', { revision: 3, document: edited })
expect(updateDetailProject).toHaveBeenCalledWith('project-1', 'owner-1', 3, expect.objectContaining({ analysis: edited.analysis, approvedAt: null }))
})
test('JSONB analysis key reordering preserves an unchanged approved outline and its screens', async () => {
const stored = fixture(); stored.analysis = initialAnalysis(); stored.approvedAt = '2026-10-12T00:00:00.000Z'
stored.screens = [createDetailScreen(stored.analysis.screens[0], stored)]
document = databaseKeyOrder(stored) as DetailDocument
const edited = JSON.parse(JSON.stringify(document)) as DetailDocument
edited.name = '仅修改项目名称'
await editDetailProject('owner-1', 'project-1', { revision: 3, document: edited })
expect(updateDetailProject).toHaveBeenCalledWith('project-1', 'owner-1', 3, expect.objectContaining({ analysis: stored.analysis, approvedAt: stored.approvedAt, screens: stored.screens }))
})
test('human-edited unapproved outline can be saved and restored without approving it', async () => {
document.analysis = initialAnalysis()
const edited = JSON.parse(JSON.stringify(document)) as DetailDocument
edited.analysis!.screens[0].title = '人工修订标题'
edited.analysis!.sellingPoints[0] = '人工核对后的真实卖点'
;(updateDetailProject as jest.Mock).mockImplementation(async (id, ownerId, revision, saved) => { document = saved; return { id, ownerId, revision: revision + 1, document } })
;(listDetailAssets as jest.Mock).mockResolvedValue([]); (listDetailJobs as jest.Mock).mockResolvedValue([])
const saved = await editDetailProject('owner-1', 'project-1', { revision: 3, document: edited })
const restored = await getDetailProject('owner-1', 'project-1')
expect(saved.document.analysis!.screens[0].title).toBe('人工修订标题')
expect(restored.project.document.analysis).toEqual(edited.analysis)
expect(restored.project.document.approvedAt).toBeNull()
})
test('returning to product information resets approval and allows saving facts before re-analysis', async () => {
document.analysis = initialAnalysis(); document.approvedAt = '2026-10-12T00:00:00.000Z'
document.screens = [createDetailScreen(document.analysis.screens[0], document)]
const assetId = '22222222-2222-4222-8222-222222222222'
document.product.assetIds = [assetId]
const edited = JSON.parse(JSON.stringify(document)) as DetailDocument
edited.analysis = null; edited.product.facts = '修正后的真实产品参数'
;(updateDetailProject as jest.Mock).mockImplementation(async (id, ownerId, revision, saved) => { document = saved; return { id, ownerId, revision: revision + 1, document } })
await editDetailProject('owner-1', 'project-1', { revision: 3, document: edited })
expect(document.analysis).toBeNull(); expect(document.approvedAt).toBeNull(); expect(document.screens).toEqual([])
const previousKey = process.env.DETAIL_GENERATOR_API_KEY
process.env.DETAIL_GENERATOR_API_KEY = 'unit-test-only-key'
try { await createDetailAction('owner-1', 'project-1', 'analyze', { idempotencyKey: 'reanalyze-request-12345' }) } finally { if (previousKey === undefined) delete process.env.DETAIL_GENERATOR_API_KEY; else process.env.DETAIL_GENERATOR_API_KEY = previousKey }
expect(enqueueDetailJob).toHaveBeenCalledWith(expect.objectContaining({ kind: 'analyze', ownerId: 'owner-1' }))
})
test('resetting analysis without source changes clears old approved screens', async () => {
document.analysis = initialAnalysis(); document.approvedAt = '2026-10-12T00:00:00.000Z'; document.screens = [createDetailScreen(document.analysis.screens[0], document)]
const edited = { ...document, analysis: null }
await editDetailProject('owner-1', 'project-1', { revision: 3, document: edited })
expect(updateDetailProject).toHaveBeenCalledWith('project-1', 'owner-1', 3, expect.objectContaining({ analysis: null, approvedAt: null, screens: [] }))
})
test('blank projects cannot forge analysis and approved outlines cannot be silently changed', async () => {
const forged = fixture(); forged.analysis = initialAnalysis()
await expect(editDetailProject('owner-1', 'project-1', { revision: 3, document: forged })).rejects.toThrow('先分析产品资料')
document.analysis = initialAnalysis(); document.approvedAt = '2026-10-12T00:00:00.000Z'
const edited = JSON.parse(JSON.stringify(document)) as DetailDocument; edited.analysis!.screens[0].title = '不同标题'; edited.approvedAt = null
await expect(editDetailProject('owner-1', 'project-1', { revision: 3, document: edited })).rejects.toThrow('已确认的大纲')
})
test('changing approved source data invalidates analysis before checking client outline differences', async () => {
document.analysis = initialAnalysis(); document.approvedAt = '2026-10-12T00:00:00.000Z'; document.screens = [createDetailScreen(document.analysis.screens[0], document)]
const edited = JSON.parse(JSON.stringify(document)) as DetailDocument
edited.product.facts = '新的产品资料'; edited.analysis!.screens[0].title = '客户端旧稿的修改'
await editDetailProject('owner-1', 'project-1', { revision: 3, document: edited })
expect(updateDetailProject).toHaveBeenCalledWith('project-1', 'owner-1', 3, expect.objectContaining({ analysis: null, approvedAt: null, screens: [] }))
})
test('invalid binary upload and non-UTF8 product documents fail before storage', async () => {
await expect(__detailUploadTesting.normalizeUpload(Buffer.from('<svg onload="alert(1)"></svg>'), 'fake.png', 'product')).rejects.toThrow('有效的 JPG')
await expect(__detailUploadTesting.normalizeUpload(Buffer.from([0xff, 0xfe, 0x00]), 'binary.txt', 'document')).rejects.toThrow('UTF-8')
expect(() => __detailUploadTesting.xmlText('<!DOCTYPE x [<!ENTITY secret SYSTEM "file:///etc/passwd">]><w:t>&secret;</w:t>')).toThrow('外部实体')
})
test('forged DOCX uncompressed size cannot inflate an eight-megabyte zip bomb', () => {
const compressed = deflateRawSync(Buffer.alloc(8 * 1024 * 1024, 65))
const getData = jest.fn(() => { throw new Error('Unbounded decompression must never run') })
const getCompressedData = jest.fn(() => compressed)
const entry = { isDirectory: false, header: { size: 1, compressedSize: compressed.length, method: 8, flags: 0, crc: 0 }, getData, getCompressedData }
try {
__detailUploadTesting.boundedDocxEntry(entry as any)
throw new Error('Expected bounded decompression failure')
} catch (error) { expect(error).toMatchObject({ code: 'ERR_BUFFER_TOO_LARGE' }) }
expect(getCompressedData).toHaveBeenCalledTimes(1)
expect(getData).not.toHaveBeenCalled()
getCompressedData.mockClear()
expect(() => __detailUploadTesting.boundedDocxEntry({ ...entry, header: { ...entry.header, size: 0 } } as any)).toThrow()
expect(getCompressedData).not.toHaveBeenCalled()
expect(getData).not.toHaveBeenCalled()
})
test('bounded DOCX extraction accepts a valid document with verified byte count and CRC', async () => {
const archive = new AdmZip()
archive.addFile('word/document.xml', Buffer.from('<w:document><w:p><w:r><w:t>产品真实尺寸:500mm</w:t></w:r></w:p></w:document>'))
const result = await __detailUploadTesting.normalizeUpload(archive.toBuffer(), 'product.docx', 'document')
expect(result.extractedText).toBe('产品真实尺寸:500mm')
})