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

191 lines
15 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 { getServerSession } from 'next-auth'
import { isSuperAdmin } from '@/lib/department-permission'
import { assertDetailGeneratorUser, requireDetailGeneratorUser } from '@/lib/detail-generator/access'
import { analyzeDetailFacts, composeDetailOutline, DetailProviderError, getDetailModels, readDetailResponseBytes } from '@/lib/detail-generator/provider'
import { parseDetailDocument, parseDetailScreen, parseDetailJson } from '@/lib/detail-generator/validation'
import type { DetailDocument } from '@/lib/detail-generator/types'
import { estimateDetailTextHeight, reflowDetailScreen } from '@/lib/detail-generator/layout'
import { prisma } from '@/lib/prisma'
import { detailPublicJob, type StoredDetailJob } from '@/lib/detail-generator/repository'
import { GET as getCapabilities } from '@/app/api/detail-generator/capabilities/route'
import sharp from 'sharp'
import { getAiModelRuntimeState } 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: { user: { findUnique: jest.fn() } } }))
jest.mock('@/lib/ai-model-service', () => ({
captureAiModelBindings: jest.fn(), getAiModelRuntimeState: jest.fn(),
resolveAiModel: jest.fn(async (stage, selection) => {
if (selection.startsWith('unvalidated')) throw new (require('@/lib/detail-generator/access').DetailGeneratorError)('所选模型未确认兼容')
return { apiKey: 'unit-test-key', binding: { providerId: '11111111-1111-4111-8111-111111111111', modelKey: '22222222-2222-4222-8222-222222222222', modelId: selection, providerName: '测试上游', baseUrl: 'https://model.example.test/v1', revision: 1 } }
}),
}))
jest.mock('@/lib/ai-model-http', () => ({ requestAiModel: jest.fn(async (baseUrl, apiKey, endpoint, body, idempotencyKey, beforeRequest) => {
await beforeRequest()
return global.fetch(baseUrl + endpoint, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Idempotency-Key': idempotencyKey }, body })
}) }))
const originalFetch = global.fetch
const originalEnv = { ...process.env }
const fixture = (): DetailDocument => ({
version: 1, name: '橡木座椅', product: { identifierType: 'sku', identifier: 'SKU-001', variant: '原木色', name: '座椅', facts: '尺寸 500×500×800mm', assetIds: [], referenceAssetIds: [], documentAssetIds: [] },
brand: { name: '测试品牌', tone: '简洁', primaryColor: '#2055AA', 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: [],
})
beforeEach(() => {
jest.clearAllMocks()
process.env.DETAIL_GENERATOR_ENABLED = 'true'
process.env.DETAIL_GENERATOR_API_KEY = 'unit-test-key'
process.env.DETAIL_GENERATOR_BASE_URL = 'https://model.example.test/v1'
delete process.env.DETAIL_GENERATOR_VALIDATED_MODELS_JSON
global.fetch = jest.fn()
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
;(getAiModelRuntimeState as jest.Mock).mockResolvedValue({ models: { analysis: ['analysis-catalog-key'], copy: ['copy-catalog-key'], image: ['image-catalog-key'] } })
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ isSuperAdmin: true, status: 'active', isActive: true, archivedAt: null })
})
afterAll(() => { global.fetch = originalFetch; process.env = originalEnv })
describe('detail generator access', () => {
test('revoked database flag rejects a stale super-admin session before any provider request', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'owner', isSuperAdmin: true, role: 'ADMIN' } })
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
await expect(requireDetailGeneratorUser()).rejects.toMatchObject({ status: 403, code: 'forbidden' })
expect(isSuperAdmin).toHaveBeenCalledWith('owner')
expect(global.fetch).not.toHaveBeenCalled()
})
test('disabled beta denies even super-admins and unauthenticated requests remain 401', async () => {
process.env.DETAIL_GENERATOR_ENABLED = 'false'
await expect(assertDetailGeneratorUser('owner')).rejects.toMatchObject({ status: 403, code: 'feature_disabled' })
await expect(assertDetailGeneratorUser(undefined)).rejects.toMatchObject({ status: 401 })
expect(isSuperAdmin).not.toHaveBeenCalled()
})
test.each([
{ status: 'suspended', isActive: true, archivedAt: null },
{ status: 'archived', isActive: true, archivedAt: null },
{ status: 'active', isActive: true, archivedAt: new Date('2026-10-12') },
{ status: 'pending', isActive: true, archivedAt: null },
])('fresh account state rejects stale active super-admin sessions: %o', async row => {
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'owner', isSuperAdmin: true, accountStatus: 'active', role: 'ADMIN' } })
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(row)
await expect(requireDetailGeneratorUser()).rejects.toMatchObject({ status: 403, code: 'account_inactive' })
expect(await (await getCapabilities()).json()).toEqual({ canUse: false })
expect(global.fetch).not.toHaveBeenCalled()
})
test('legacy activation flag applies only when account status is missing', async () => {
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ status: null, isActive: true, archivedAt: null })
await expect(assertDetailGeneratorUser('owner')).resolves.toBe('owner')
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ status: null, isActive: false, archivedAt: null })
await expect(assertDetailGeneratorUser('owner')).rejects.toMatchObject({ code: 'account_inactive' })
})
})
test('public export jobs disclose only a valid format and never the frozen project or prompts', () => {
const base: StoredDetailJob = { id: 'job', projectId: 'project', ownerId: 'owner', kind: 'export', status: 'succeeded', payload: { format: 'psd', document: fixture(), prompt: 'private-prompt' }, checkpoint: {}, error: null, requestId: null, progress: 100, createdAt: '', updatedAt: '', fingerprint: 'test', projectRevision: 1, idempotencyKey: 'test-key', leaseToken: null, leaseExpiresAt: null }
expect(detailPublicJob(base).payload).toEqual({ format: 'psd' })
expect(detailPublicJob({ ...base, kind: 'generate' }).payload).toEqual({})
expect(detailPublicJob({ ...base, payload: { format: '../private' } }).payload).toEqual({})
expect(JSON.stringify(detailPublicJob(base))).not.toContain('private-prompt')
expect(JSON.stringify(detailPublicJob(base))).not.toContain('assetIds')
})
test('oversized chunked JSON cancels immediately without a full-body allocation', async () => {
const cancelled = jest.fn()
const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(new Uint8Array(128 * 1024)); controller.enqueue(new Uint8Array(128 * 1024)); controller.enqueue(new Uint8Array(1)) }, cancel: cancelled })
const request = new Request('https://platform.test/api/detail-generator', { method: 'POST', body: stream, duplex: 'half' } as RequestInit)
const fullRead = jest.spyOn(request, 'text')
await expect(parseDetailJson(request)).rejects.toMatchObject({ status: 413 })
expect(cancelled).toHaveBeenCalledTimes(1)
expect(fullRead).not.toHaveBeenCalled()
})
describe('provider outcomes and verified model selection', () => {
test('transparent product references become white-background analysis JPEGs while preserving product color', async () => {
const subject = await sharp({ create: { width: 64, height: 64, channels: 4, background: { r: 120, g: 70, b: 40, alpha: 1 } } }).png().toBuffer()
const image = await sharp({ create: { width: 128, height: 128, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } }).composite([{ input: subject, left: 32, top: 32 }]).png().toBuffer()
;(global.fetch as jest.Mock).mockResolvedValue(Response.json({ choices: [{ message: { content: '{"facts":[],"warnings":[]}' } }] }))
await analyzeDetailFacts(fixture(), [{ buffer: image, mimeType: 'image/png' }], '', 'white-reference-request', 'owner')
const request = (global.fetch as jest.Mock).mock.calls[0][1]
const body = JSON.parse(request.body)
const url = body.messages[0].content.find((part: any) => part.type === 'image_url').image_url.url
expect(url).toMatch(/^data:image\/jpeg;base64,/)
const encoded = Buffer.from(url.split(',')[1], 'base64')
const { data, info } = await sharp(encoded).removeAlpha().raw().toBuffer({ resolveWithObject: true })
const pixel = (x: number, y: number) => Array.from(data.subarray((y * info.width + x) * info.channels, (y * info.width + x) * info.channels + 3))
expect(pixel(1, 1).every(channel => channel >= 250)).toBe(true)
pixel(64, 64).forEach((channel, index) => expect(Math.abs(channel - [120, 70, 40][index])).toBeLessThanOrEqual(8))
expect(global.fetch).toHaveBeenCalledTimes(1)
})
test('503 unknown is recorded once with safe request ID and never retried', async () => {
;(global.fetch as jest.Mock).mockResolvedValue(new Response(JSON.stringify({ error: { code: 'request_outcome_unknown', message: 'upstream-secret' } }), { status: 503, headers: { 'x-request-id': 'provider-request-1' } }))
await expect(analyzeDetailFacts(fixture(), [], '真实产品资料', 'same-request-12345', 'owner')).rejects.toMatchObject({ outcomeUnknown: true, requestId: 'provider-request-1' })
expect(global.fetch).toHaveBeenCalledTimes(1)
const [, request] = (global.fetch as jest.Mock).mock.calls[0]
expect(request.headers['Idempotency-Key']).toBe('same-request-12345')
expect(request.body).toContain('真实产品资料')
})
test('transport loss is unknown and 400 is a known failure without exposing upstream text', async () => {
;(global.fetch as jest.Mock).mockRejectedValueOnce(new Error('unit-test-key leaked in transport'))
await expect(analyzeDetailFacts(fixture(), [], '', 'transport-request-1', 'owner')).rejects.toMatchObject({ outcomeUnknown: true })
;(global.fetch as jest.Mock).mockResolvedValueOnce(new Response(JSON.stringify({ error: { message: 'unit-test-key' } }), { status: 400 }))
try { await analyzeDetailFacts(fixture(), [], '', 'rejected-request-1', 'owner'); throw new Error('expected error') } catch (error) {
expect(error).toBeInstanceOf(DetailProviderError)
expect(error).toMatchObject({ outcomeUnknown: false })
expect(String(error)).not.toContain('unit-test-key')
}
expect(global.fetch).toHaveBeenCalledTimes(2)
})
test('unconfirmed analysis models cannot dispatch and database catalog controls choices', async () => {
const document = fixture(); document.models.analysis = 'unvalidated-analysis'
await expect(analyzeDetailFacts(document, [], '', 'request-unverified', 'owner')).rejects.toThrow('未确认兼容')
expect(global.fetch).not.toHaveBeenCalled()
process.env.DETAIL_GENERATOR_VALIDATED_MODELS_JSON = JSON.stringify({ analysis: ['verified-analyzer'], image: ['verified-imager'] })
expect(await getDetailModels()).toEqual({ analysis: ['analysis-catalog-key'], copy: ['copy-catalog-key'], image: ['image-catalog-key'] })
})
test('outline validates schema and assigns independent screen identities', async () => {
;(global.fetch as jest.Mock).mockResolvedValue(new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({ sellingPoints: ['尺寸已提供'], warnings: ['材质待确认'], screens: [{ title: '产品展示', body: '500×500×800mm', imagePrompt: '准确展示产品' }, { title: '使用场景', body: '', imagePrompt: '同一产品居家场景' }] }) } }] })))
const result = await composeDetailOutline(fixture(), '原始事实', 'outline-request-1', 'owner')
expect(result.screens).toHaveLength(2)
expect(result.screens[0].id).not.toBe(result.screens[1].id)
expect(result.warnings).toContain('材质待确认')
})
test('literal key echoed in model content or request IDs is never saved or disclosed', async () => {
;(global.fetch as jest.Mock).mockResolvedValue(Response.json({ choices: [{ message: { content: 'unit-test-key' } }] }, { headers: { 'x-request-id': 'unit-test-key' } }))
await expect(analyzeDetailFacts(fixture(), [], '', 'key-echo-request', 'owner')).rejects.toMatchObject({ outcomeUnknown: true, requestId: null })
expect(global.fetch).toHaveBeenCalledTimes(1)
})
test('chunked provider responses obey actual byte limit', async () => {
const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(new Uint8Array(16)); controller.enqueue(new Uint8Array(16)); controller.close() } })
await expect(readDetailResponseBytes(new Response(stream), 20)).rejects.toThrow('响应过大')
})
})
describe('shared project document validation', () => {
test('supports empty drafts but rejects invisible fonts and screen bounds', () => {
expect(parseDetailDocument(fixture()).screens).toEqual([])
const badFont = fixture(); (badFont.brand as any).fontFamily = 'Missing Brand Font'
expect(() => parseDetailDocument(badFont)).toThrow()
expect(() => parseDetailScreen({ id: '11111111-1111-4111-8111-111111111111', title: '标题', body: '', imagePrompt: '产品', width: 750, height: 1200, backgroundColor: '#FFFFFF', texts: [{ id: 'copy', text: '文字', x: 700, y: 50, width: 100, fontSize: 24, lineHeight: 1.5, color: '#111111', fontFamily: 'Noto Sans SC', align: 'left' }] })).toThrow('图层超出画布')
})
test('long Chinese copy reflows before the image and excessive copy fails before generation', () => {
const id = '11111111-1111-4111-8111-111111111111'
const screen = { id, title: '真实产品标题'.repeat(12), body: '这段产品说明用于核对中文换行与画面位置。'.repeat(10), imagePrompt: '产品', width: 750, height: 1200, backgroundColor: '#FFFFFF',
image: { assetId: '22222222-2222-4222-8222-222222222222', x: 0, y: 380, width: 750, height: 750 },
texts: [{ id: `${id}-title`, text: '', x: 48, y: 58, width: 654, fontSize: 48, lineHeight: 1.25, color: '#111111', fontFamily: 'Noto Sans SC' as const, align: 'left' as const }, { id: `${id}-body`, text: '', x: 48, y: 182, width: 654, fontSize: 26, lineHeight: 1.5, color: '#111111', fontFamily: 'Noto Sans SC' as const, align: 'left' as const }] }
const arranged = reflowDetailScreen(screen)
const titleBottom = arranged.texts[0].y + estimateDetailTextHeight(screen.title, 654, 48, 1.25)
const bodyBottom = arranged.texts[1].y + estimateDetailTextHeight(screen.body, 654, 26, 1.5)
expect(arranged.texts[1].y).toBeGreaterThan(titleBottom)
expect(arranged.image!.y).toBeGreaterThan(bodyBottom)
expect(arranged.height).toBeLessThanOrEqual(2400)
expect(arranged.image!.assetId).toBe(screen.image.assetId)
expect(() => reflowDetailScreen({ ...screen, body: '超长说明'.repeat(400) })).toThrow('文案过长')
expect(estimateDetailTextHeight('第一行\n第二行', 654, 26, 1.5)).toBeGreaterThan(estimateDetailTextHeight('第一行', 654, 26, 1.5))
})
})