264 lines
20 KiB
TypeScript
264 lines
20 KiB
TypeScript
/** Disposable local PostgreSQL acceptance. Never connects to the production database. */
|
||
import assert from 'node:assert/strict'
|
||
import { randomUUID } from 'node:crypto'
|
||
import { mkdir, writeFile } from 'node:fs/promises'
|
||
import path from 'node:path'
|
||
import bcrypt from 'bcryptjs'
|
||
import sharp from 'sharp'
|
||
import { readPsd } from 'ag-psd'
|
||
import { prisma } from '../lib/prisma'
|
||
import { assertDetailGeneratorUser } from '../lib/detail-generator/access'
|
||
import { approveDetailAnalysis, createDetailAction, editDetailProject, editDetailScreen, getDetailProject, newDetailProject, uploadDetailAsset } from '../lib/detail-generator/service'
|
||
import { claimDetailJob, enqueueDetailJob, findDetailAsset, findDetailJob, findDetailProject, finishDetailJob } from '../lib/detail-generator/repository'
|
||
import { runDetailGeneratorWorkerOnce } from '../lib/detail-generator/jobs'
|
||
import { readDetailFile } from '../lib/detail-generator/storage'
|
||
import { saveDetailBrandProfile } from '../lib/detail-generator/brand-profiles'
|
||
import { DETAIL_FONT_FAMILY, DETAIL_MODEL_DEFAULTS, type DetailDocument } from '../lib/detail-generator/types'
|
||
import { importAiModelEnvironment } from '../lib/ai-model-service'
|
||
import { parseAiModelCapabilityMetadata } from '../lib/ai-model-capabilities'
|
||
import dns from 'node:dns/promises'
|
||
import https from 'node:https'
|
||
import { syncBuiltinESMExports } from 'node:module'
|
||
import { EventEmitter } from 'node:events'
|
||
import { Writable } from 'node:stream'
|
||
|
||
async function main() {
|
||
const db = new URL(process.env.DATABASE_URL || '')
|
||
assert.equal(db.hostname, '127.0.0.1', 'Only disposable local PostgreSQL is permitted')
|
||
assert.equal(db.port, '15437')
|
||
assert.equal(db.pathname, '/detail_test')
|
||
process.env.DETAIL_GENERATOR_ENABLED = 'true'
|
||
process.env.DETAIL_GENERATOR_API_KEY = 'synthetic-test-key-no-provider-spend'
|
||
process.env.DETAIL_GENERATOR_BASE_URL = 'https://detail-provider.invalid/v1'
|
||
process.env.DETAIL_GENERATOR_STORAGE_DIR = path.resolve('output/detail-generator/private')
|
||
const password = 'LocalDetailPreviewOnly-20261012'
|
||
const hashed = await bcrypt.hash(password, 4)
|
||
const owners = await Promise.all([0, 1, 2, 3].map(async index => prisma.user.upsert({
|
||
where: { email: `detail-test-${index}@example.invalid` },
|
||
update: { isSuperAdmin: index !== 3, status: 'active', isActive: true },
|
||
create: { email: `detail-test-${index}@example.invalid`, username: `详情页本地验收${index}`, password: hashed,
|
||
isSuperAdmin: index !== 3, role: 'ADMIN', status: 'active', isActive: true },
|
||
})))
|
||
for (const owner of owners) await prisma.$executeRaw`DELETE FROM detail_generator_projects WHERE owner_id=${owner.id}`
|
||
await prisma.$executeRaw`DELETE FROM detail_generator_brand_profiles WHERE name='本地验收品牌'`
|
||
await assertDetailGeneratorUser(owners[0].id)
|
||
await assert.rejects(assertDetailGeneratorUser(owners[3].id), /超级管理员/)
|
||
if (!(await prisma.$queryRawUnsafe<Array<{ id: string }>>('SELECT id FROM ai_model_providers WHERE legacy_binding=TRUE')).length) await importAiModelEnvironment(owners[0].id)
|
||
// The guarded synthetic provider has explicit test evidence; it must not inherit real-provider capabilities by model name.
|
||
for (const stage of ['copy', 'image'] as const) {
|
||
const stages = stage === 'copy' ? ['analysis', 'copy'] : ['image']
|
||
const metadata = parseAiModelCapabilityMetadata({ input_modalities: ['text', 'image'], output_modalities: [stage === 'image' ? 'image' : 'text'], endpoints: [stage === 'image' ? '/images/edits' : '/chat/completions'] })
|
||
await prisma.$executeRawUnsafe(`UPDATE ai_model_catalog SET capability_metadata=$1::jsonb,stages=$2::text[],enabled=TRUE,compatibility='confirmed' WHERE model_id=$3 AND provider_id IN (SELECT id FROM ai_model_providers WHERE base_url='https://detail-provider.invalid/v1')`, JSON.stringify(metadata), stages, DETAIL_MODEL_DEFAULTS[stage])
|
||
}
|
||
await prisma.$executeRawUnsafe(`UPDATE ai_model_configuration SET default_analysis=(SELECT c.id FROM ai_model_catalog c JOIN ai_model_providers p ON p.id=c.provider_id WHERE p.legacy_binding=TRUE AND c.model_id=$1),default_copy=(SELECT c.id FROM ai_model_catalog c JOIN ai_model_providers p ON p.id=c.provider_id WHERE p.legacy_binding=TRUE AND c.model_id=$1),default_image=(SELECT c.id FROM ai_model_catalog c JOIN ai_model_providers p ON p.id=c.provider_id WHERE p.legacy_binding=TRUE AND c.model_id=$2) WHERE id='detail-generator'`, DETAIL_MODEL_DEFAULTS.analysis, DETAIL_MODEL_DEFAULTS.image)
|
||
const document = (): DetailDocument => ({
|
||
version: 1, name: '本地验收 · 产品详情页',
|
||
product: { identifierType: 'sku', identifier: 'TEST-SKU-001', variant: '验收款式', name: '本地验收产品',
|
||
facts: '仅用于测试:木色外观、圆角设计。没有提供尺寸、材质、承重和认证。', assetIds: [], referenceAssetIds: [], documentAssetIds: [] },
|
||
brand: { name: '本地验收品牌', tone: '自然、简洁,真实产品摄影', primaryColor: '#415947', backgroundColor: '#ffffff', textColor: '#101828', fontFamily: DETAIL_FONT_FAMILY, status: 'draft' },
|
||
models: { ...DETAIL_MODEL_DEFAULTS }, analysis: null, approvedAt: null, screens: [],
|
||
})
|
||
const image = await sharp(Buffer.from('<svg width="750" height="750" xmlns="http://www.w3.org/2000/svg"><rect width="750" height="750" fill="#f4f0e8"/><rect x="210" y="190" width="330" height="330" rx="32" fill="#aa855a"/><path d="M240 520v100M510 520v100" stroke="#785a3b" stroke-width="30"/></svg>')).png().toBuffer()
|
||
async function inputProject(ownerId: string) {
|
||
let project = await newDetailProject(ownerId, document())
|
||
for (const kind of ['product', 'reference', 'document'] as const) {
|
||
const form = new FormData()
|
||
form.set('kind', kind); form.set('revision', String(project.revision))
|
||
const bytes = kind === 'document' ? Buffer.from('测试产品资料:木色外观、圆角设计。禁止编造尺寸、认证或材质。') : image
|
||
form.set('file', new Blob([new Uint8Array(bytes)], { type: kind === 'document' ? 'text/plain' : 'image/png' }), kind === 'document' ? '产品资料.txt' : `${kind}.png`)
|
||
const response = await uploadDetailAsset(ownerId, project.id, new Request('http://localhost/upload', { method: 'POST', body: form }))
|
||
project = response.project
|
||
}
|
||
return project
|
||
}
|
||
const project = await inputProject(owners[0].id)
|
||
await assert.rejects(findDetailProject(project.id, owners[1].id), /项目不存在/)
|
||
let providerCalls = 0
|
||
let imageCalls = 0
|
||
let returnUnknown = false
|
||
let revokeDuringAnalysis: string | null = null
|
||
async function fakeProvider(input: string, init: { headers: Record<string, string>; body: Buffer }) {
|
||
assert.ok(String(input).startsWith('https://detail-provider.invalid/'), 'No live provider calls')
|
||
assert.ok(new Headers(init?.headers).get('Idempotency-Key'))
|
||
providerCalls++
|
||
if (returnUnknown) return Response.json({ error: { code: 'request_outcome_unknown' } }, { status: 503, headers: { 'x-request-id': 'test-unknown-request' } })
|
||
if (String(input).endsWith('/images/edits')) {
|
||
imageCalls++
|
||
assert.ok(init.body.includes(Buffer.from('Content-Disposition: form-data;')), 'Actual streamed multipart reaches the fake HTTPS socket')
|
||
return Response.json({ data: [{ b64_json: image.toString('base64') }] })
|
||
}
|
||
const body = JSON.parse(String(init?.body))
|
||
const isAnalysis = Array.isArray(body.messages[0].content)
|
||
if (isAnalysis) {
|
||
assert.ok(JSON.stringify(body).includes('测试产品资料'), 'Documents included in analysis')
|
||
if (revokeDuringAnalysis) await prisma.user.update({ where: { id: revokeDuringAnalysis }, data: { isSuperAdmin: false } })
|
||
}
|
||
return Response.json({ choices: [{ message: { content: JSON.stringify(isAnalysis ? { facts: ['木色外观', '圆角设计'], warnings: ['未提供材质和尺寸'] } : {
|
||
sellingPoints: ['木色外观', '圆角设计'], warnings: ['未提供材质和尺寸'], screens: [
|
||
{ title: '木色,自然融入空间', body: '本地验收示例。展示真实外观与圆角细节。', imagePrompt: '简洁自然背景,保持产品款式' },
|
||
{ title: '圆角细节', body: '本地验收示例。呈现可见细节。', imagePrompt: '近距离展示实际圆角细节' },
|
||
],
|
||
}) } }] })
|
||
}
|
||
// Test harness replaces the Node HTTPS socket and DNS only after the disposable DB guard.
|
||
// Production transport remains unchanged, including its public-address and dispatch checks.
|
||
const realLookup = dns.lookup
|
||
const realRequest = https.request
|
||
dns.lookup = (async hostname => { assert.equal(hostname, 'detail-provider.invalid'); return [{ address: '93.184.216.34', family: 4 }] }) as typeof dns.lookup
|
||
https.request = ((target: URL, options: any, onResponse: (response: any) => void) => {
|
||
assert.equal(target.hostname, 'detail-provider.invalid', 'No live provider sockets')
|
||
const chunks: Buffer[] = []
|
||
return new Writable({
|
||
write(chunk, _encoding, callback) { chunks.push(Buffer.from(chunk)); callback() },
|
||
final(callback) {
|
||
fakeProvider(target.href, { headers: options.headers, body: Buffer.concat(chunks) }).then(async response => {
|
||
const incoming = new EventEmitter() as EventEmitter & { statusCode: number; headers: Record<string, string>; destroy: () => void }
|
||
incoming.statusCode = response.status; incoming.headers = Object.fromEntries(response.headers.entries()); incoming.destroy = () => undefined
|
||
onResponse(incoming)
|
||
incoming.emit('data', Buffer.from(await response.arrayBuffer())); incoming.emit('end'); callback()
|
||
}).catch(callback)
|
||
},
|
||
})
|
||
}) as typeof https.request
|
||
syncBuiltinESMExports()
|
||
try {
|
||
const key = randomUUID()
|
||
const job = await createDetailAction(owners[0].id, project.id, 'analyze', { idempotencyKey: key })
|
||
assert.equal((await createDetailAction(owners[0].id, project.id, 'analyze', { idempotencyKey: key })).id, job.id)
|
||
await assert.rejects(createDetailAction(owners[0].id, project.id, 'analyze', { idempotencyKey: randomUUID() }), /已有任务/)
|
||
await runDetailGeneratorWorkerOnce()
|
||
assert.equal((await findDetailJob(job.id, owners[0].id)).status, 'succeeded')
|
||
let current = (await getDetailProject(owners[0].id, project.id)).project
|
||
const humanDraft = structuredClone(current.document)
|
||
assert.ok(humanDraft.analysis)
|
||
humanDraft.analysis.screens[0].title = '人工核对:木色自然融入空间'
|
||
humanDraft.analysis.sellingPoints[0] = '人工核对后的木色外观'
|
||
const beforeDraftSave = providerCalls
|
||
current = await editDetailProject(owners[0].id, current.id, { document: humanDraft, revision: current.revision })
|
||
current = (await getDetailProject(owners[0].id, current.id)).project
|
||
assert.deepEqual(current.document.analysis, humanDraft.analysis)
|
||
assert.equal(current.document.approvedAt, null)
|
||
assert.equal(providerCalls, beforeDraftSave, 'Saving an unapproved human draft must not call a model')
|
||
current = await approveDetailAnalysis(owners[0].id, project.id, { analysis: current.document.analysis, revision: current.revision })
|
||
const generation = await createDetailAction(owners[0].id, project.id, 'generate', { idempotencyKey: randomUUID() })
|
||
await runDetailGeneratorWorkerOnce()
|
||
assert.equal((await findDetailJob(generation.id, owners[0].id)).status, 'succeeded')
|
||
assert.equal(imageCalls, 2)
|
||
current = (await getDetailProject(owners[0].id, project.id)).project
|
||
const previousCalls = providerCalls
|
||
const screen = current.document.screens[0]
|
||
const edited = { ...screen, body: '修改后的中文文案\n本地验收,不调用生图。', texts: screen.texts.map(layer => layer.id.endsWith('-body') ? { ...layer, text: '修改后的中文文案\n本地验收,不调用生图。' } : layer) }
|
||
current = await editDetailScreen(owners[0].id, current.id, edited.id, { screen: edited, revision: current.revision })
|
||
assert.equal(providerCalls, previousCalls)
|
||
const style = await saveDetailBrandProfile(current.document.brand, undefined)
|
||
await saveDetailBrandProfile({ ...style.profile, tone: '修改后的品牌草案' }, style.revision)
|
||
await assert.rejects(saveDetailBrandProfile(style.profile, style.revision), /已更新/)
|
||
assert.equal(current.document.brand.tone, '自然、简洁,真实产品摄影')
|
||
await mkdir('output/detail-generator', { recursive: true })
|
||
for (const format of ['png', 'jpg', 'long-png', 'psd'] as const) {
|
||
const exportJob = await createDetailAction(owners[0].id, current.id, 'export', { format, idempotencyKey: randomUUID() })
|
||
await runDetailGeneratorWorkerOnce()
|
||
const result = await findDetailJob(exportJob.id, owners[0].id)
|
||
assert.equal(result.status, 'succeeded', result.error || undefined)
|
||
const asset = await findDetailAsset(String(result.checkpoint.exportAssetId), owners[0].id)
|
||
await assert.rejects(findDetailAsset(asset.id, owners[1].id), /文件不存在/)
|
||
const bytes = await readDetailFile(asset.storageKey)
|
||
await writeFile(path.join('output/detail-generator', asset.name), bytes)
|
||
if (format === 'psd') {
|
||
const psd = readPsd(bytes, { skipLayerImageData: true, skipCompositeImageData: true, skipThumbnail: true })
|
||
assert.equal(psd.children?.length, 2)
|
||
assert.ok(JSON.stringify(psd).includes('修改后的中文文案'))
|
||
}
|
||
}
|
||
const untouchedImage = current.document.screens[1].image?.assetId
|
||
const regenerate = await createDetailAction(owners[0].id, current.id, 'regenerate', { idempotencyKey: randomUUID() }, screen.id)
|
||
await runDetailGeneratorWorkerOnce()
|
||
assert.equal((await findDetailJob(regenerate.id, owners[0].id)).status, 'succeeded')
|
||
current = (await getDetailProject(owners[0].id, current.id)).project
|
||
assert.equal(current.document.screens[1].image?.assetId, untouchedImage)
|
||
assert.equal(imageCalls, 3)
|
||
// Separate projects exercise draft reset without altering the generated browser preview.
|
||
const blankProject = await newDetailProject(owners[2].id, document())
|
||
const forged = { ...blankProject.document, analysis: structuredClone(current.document.analysis), approvedAt: new Date().toISOString() }
|
||
const beforeForged = providerCalls
|
||
await assert.rejects(editDetailProject(owners[2].id, blankProject.id, { document: forged, revision: blankProject.revision }), /请先分析产品资料/)
|
||
assert.equal(providerCalls, beforeForged)
|
||
assert.equal((await getDetailProject(owners[2].id, blankProject.id)).project.document.analysis, null)
|
||
|
||
let workflow = await inputProject(owners[2].id)
|
||
const workflowAnalysis = await createDetailAction(owners[2].id, workflow.id, 'analyze', { idempotencyKey: randomUUID() })
|
||
await runDetailGeneratorWorkerOnce()
|
||
assert.equal((await findDetailJob(workflowAnalysis.id, owners[2].id)).status, 'succeeded')
|
||
workflow = (await getDetailProject(owners[2].id, workflow.id)).project
|
||
workflow = await approveDetailAnalysis(owners[2].id, workflow.id, { analysis: workflow.document.analysis, revision: workflow.revision })
|
||
assert.ok(workflow.document.approvedAt)
|
||
assert.ok(workflow.document.screens.length)
|
||
const resetDraft = structuredClone(workflow.document)
|
||
resetDraft.analysis = null
|
||
resetDraft.product.facts = '人工修正后的测试产品资料:木色外观、圆角设计;其余参数未提供。'
|
||
workflow = await editDetailProject(owners[2].id, workflow.id, { document: resetDraft, revision: workflow.revision })
|
||
assert.equal(workflow.document.analysis, null)
|
||
assert.equal(workflow.document.approvedAt, null)
|
||
assert.deepEqual(workflow.document.screens, [])
|
||
const reanalysis = await createDetailAction(owners[2].id, workflow.id, 'analyze', { idempotencyKey: randomUUID() })
|
||
await runDetailGeneratorWorkerOnce()
|
||
assert.equal((await findDetailJob(reanalysis.id, owners[2].id)).status, 'succeeded')
|
||
workflow = (await getDetailProject(owners[2].id, workflow.id)).project
|
||
assert.ok(workflow.document.analysis)
|
||
assert.equal(workflow.document.approvedAt, null)
|
||
workflow = await approveDetailAnalysis(owners[2].id, workflow.id, { analysis: workflow.document.analysis, revision: workflow.revision })
|
||
const changedSource = structuredClone(workflow.document)
|
||
changedSource.brand.tone = '修改后的测试品牌风格草案'
|
||
const beforeSourceChange = providerCalls
|
||
workflow = await editDetailProject(owners[2].id, workflow.id, { document: changedSource, revision: workflow.revision })
|
||
assert.equal(workflow.document.analysis, null)
|
||
assert.equal(workflow.document.approvedAt, null)
|
||
assert.deepEqual(workflow.document.screens, [])
|
||
assert.equal(providerCalls, beforeSourceChange)
|
||
const outlinePreviewJob = await createDetailAction(owners[2].id, workflow.id, 'analyze', { idempotencyKey: randomUUID() })
|
||
await runDetailGeneratorWorkerOnce()
|
||
assert.equal((await findDetailJob(outlinePreviewJob.id, owners[2].id)).status, 'succeeded')
|
||
workflow = (await getDetailProject(owners[2].id, workflow.id)).project
|
||
assert.ok(workflow.document.analysis)
|
||
assert.equal(workflow.document.approvedAt, null)
|
||
assert.deepEqual(workflow.document.screens, [])
|
||
|
||
const unknownProject = await inputProject(owners[1].id)
|
||
returnUnknown = true
|
||
const unknownJob = await createDetailAction(owners[1].id, unknownProject.id, 'analyze', { idempotencyKey: randomUUID() })
|
||
const beforeUnknown = providerCalls
|
||
await runDetailGeneratorWorkerOnce()
|
||
assert.equal((await findDetailJob(unknownJob.id, owners[1].id)).status, 'unknown')
|
||
await runDetailGeneratorWorkerOnce()
|
||
assert.equal(providerCalls, beforeUnknown + 1)
|
||
await assert.rejects(createDetailAction(owners[1].id, unknownProject.id, 'analyze', { idempotencyKey: randomUUID() }), /结果未知/)
|
||
returnUnknown = false
|
||
const revokedProject = await inputProject(owners[2].id)
|
||
revokeDuringAnalysis = owners[2].id
|
||
const revokedJob = await createDetailAction(owners[2].id, revokedProject.id, 'analyze', { idempotencyKey: randomUUID() })
|
||
const beforeRevoked = providerCalls
|
||
await runDetailGeneratorWorkerOnce()
|
||
assert.equal((await findDetailJob(revokedJob.id, owners[2].id)).status, 'failed')
|
||
assert.equal(providerCalls, beforeRevoked + 1, 'No copy/image call after revocation')
|
||
await assert.rejects(assertDetailGeneratorUser(owners[2].id), /超级管理员/)
|
||
await prisma.user.update({ where: { id: owners[2].id }, data: { isSuperAdmin: true } })
|
||
revokeDuringAnalysis = null
|
||
// Concurrent claims prove the limit under real PostgreSQL locks.
|
||
const queueProjects = await Promise.all(owners.slice(0, 3).map(owner => newDetailProject(owner.id, document())))
|
||
const queue = await Promise.all(queueProjects.map((item, i) => enqueueDetailJob({ projectId: item.id, ownerId: owners[i].id, kind: 'export', revision: item.revision, idempotencyKey: randomUUID(), payload: { format: 'png' } })))
|
||
const claimed = (await Promise.all([claimDetailJob(), claimDetailJob(), claimDetailJob()])).filter(item => item !== null)
|
||
assert.equal(claimed.length, 2)
|
||
assert.equal(new Set(claimed.map(item => item.ownerId)).size, 2)
|
||
for (const item of claimed) await finishDetailJob(item.id, item.leaseToken!, 'failed', {}, 'Synthetic capacity test')
|
||
const last = await claimDetailJob()
|
||
assert.ok(last)
|
||
await prisma.$executeRaw`UPDATE detail_generator_jobs SET checkpoint='{"dispatchPending":true}'::jsonb,lease_expires_at=NOW()-INTERVAL '1 second' WHERE id=${last.id}`
|
||
assert.equal(await claimDetailJob(), null)
|
||
assert.equal((await findDetailJob(last.id, last.ownerId)).status, 'unknown')
|
||
assert.equal(queue.length, 3)
|
||
console.log(JSON.stringify({ passed: ['DB super-admin gate', 'creator isolation', 'uploads/documents', 'analyze+approve+generate', 'human outline draft save+restore', 'blank analysis forgery denied', 'reset facts+reanalyze', 'approved source invalidation', 'idempotency', 'text edit without image call', 'brand draft snapshot+conflict', '4 export formats', 'single-screen regeneration', 'unknown no replay', 'revocation before next call', 'concurrent max2', 'expired dispatch no replay'], providerCalls, realProviderCalls: 0, previewEmail: owners[0].email, projectId: current.id, outlineProjectId: workflow.id, outlineEmail: owners[2].email }))
|
||
await writeFile('output/detail-generator/preview.json', JSON.stringify({ email: owners[0].email, password, projectId: current.id, outlineProjectId: workflow.id, outlineEmail: owners[2].email }))
|
||
} finally { dns.lookup = realLookup; https.request = realRequest; syncBuiltinESMExports(); await prisma.$disconnect() }
|
||
}
|
||
main().catch(error => { console.error(error); process.exitCode = 1 })
|