172 lines
13 KiB
TypeScript
172 lines
13 KiB
TypeScript
/** @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'
|
||
|
||
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() } } }))
|
||
|
||
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()
|
||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ 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')
|
||
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')).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')).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'); 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('unverified models cannot dispatch and explicit validation list stays stage-specific', async () => {
|
||
const document = fixture(); document.models.image = 'unvalidated-image'
|
||
await expect(analyzeDetailFacts(document, [], '', 'request-unverified')).rejects.toThrow('已验证')
|
||
expect(global.fetch).not.toHaveBeenCalled()
|
||
process.env.DETAIL_GENERATOR_VALIDATED_MODELS_JSON = JSON.stringify({ analysis: ['verified-analyzer'], image: ['verified-imager'] })
|
||
expect(getDetailModels()).toEqual({ analysis: ['gemini-2.5-flash', 'verified-analyzer'], copy: ['gemini-2.5-flash'], image: ['nano-banana-2', 'verified-imager'] })
|
||
})
|
||
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')
|
||
expect(result.screens).toHaveLength(2)
|
||
expect(result.screens[0].id).not.toBe(result.screens[1].id)
|
||
expect(result.warnings).toContain('材质待确认')
|
||
})
|
||
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))
|
||
})
|
||
})
|