178 lines
14 KiB
TypeScript
178 lines
14 KiB
TypeScript
/** @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')
|
||
})
|