Persist image submissions with fixed idempotency keys, leased execution, provider identity, reference snapshots and staged results. Integrate DIY, assisted assembly and non-integrated white-background sessions without holding generation in a browser request. Recover ambiguous submissions through owner-scoped reads and stop a new eight-view batch when restoring an earlier request. Add the NativeImageGenerationJob migration and a dedicated worker to both release entry points. Protect job inputs/results from storage GC. Existing production configuration and business data are not changed by this commit. Validation: 256 focused Jest tests, isolated PostgreSQL migration/lease/transaction checks (8), deployment guard tests (9), type-check, production build and desktop/ mobile browser component scenarios. Live native 2K reference-image request worked; reusing its idempotency key returned the same task. Build uses local placeholders for otherwise absent Drawing System configuration. Three unrelated material tests also fail with the original mainline test file; they remain outside this change. Architecture/visual/root-cause review: passed with warnings. Independent review completed for the integration and shared input/storage paths; worker additionally reviewed by the primary agent. Production merge/deployment requires authorization.
101 lines
8.3 KiB
TypeScript
101 lines
8.3 KiB
TypeScript
/** @jest-environment node */
|
|
import { diyAiRenderService } from '../diy-ai-render.service'
|
|
import { diyAiRenderRepository } from '@/server/repositories/diy-ai-render.repository'
|
|
import { diySchemeRepository } from '@/server/repositories/diy-scheme.repository'
|
|
import { diyCatalogService } from '../diy-catalog.service'
|
|
import { intakeNodePermissionSettingsService } from '../intake-node-permission-settings.service'
|
|
import { getExternalImageApiConfig } from '../external-image-generate.service'
|
|
import { uploadDiyAiReference, discardDiyAiUploads } from '../diy-ai-render.storage'
|
|
import { enqueueNativeImageGeneration } from '../native-image-generation.service'
|
|
import { nativeRenderRepository } from '@/server/repositories/native-render.repository'
|
|
import { catalogFixture } from '@/lib/diy/__tests__/catalog.fixture'
|
|
import type { AuthUser } from '@/server/core/types'
|
|
import type { DiyAiRenderInput } from '@/lib/diy/ai-render'
|
|
|
|
jest.mock('@/server/repositories/diy-ai-render.repository', () => ({ diyAiRenderRepository: { findOwned: jest.fn(), listOwned: jest.fn(), create: jest.fn(), update: jest.fn() } }))
|
|
jest.mock('@/server/repositories/diy-scheme.repository', () => ({ diySchemeRepository: { findOwned: jest.fn() } }))
|
|
jest.mock('../diy-catalog.service', () => ({ diyCatalogService: { getWorkbenchCatalog: jest.fn() } }))
|
|
jest.mock('../intake-node-permission-settings.service', () => ({ intakeNodePermissionSettingsService: { getSettings: jest.fn() } }))
|
|
jest.mock('../external-image-generate.service', () => ({ getExternalImageApiConfig: jest.fn() }))
|
|
jest.mock('../native-image-generation.service', () => ({ enqueueNativeImageGeneration: jest.fn() }))
|
|
jest.mock('@/server/repositories/native-render.repository', () => ({ nativeRenderRepository: { createWithJob: jest.fn() } }))
|
|
jest.mock('../diy-ai-render.storage', () => ({ uploadDiyAiReference: jest.fn(), storeDiyAiResults: jest.fn(), discardDiyAiUploads: jest.fn() }))
|
|
jest.mock('../prompt-optimizer.service', () => ({ promptOptimizerService: { optimize: jest.fn() } }))
|
|
const repo = jest.mocked(diyAiRenderRepository)
|
|
const user: AuthUser = { id: 'designer', name: 'Designer', role: 'SUPER_ADMIN', email: 'diy@example.local', status: 'ACTIVE' }
|
|
const input: DiyAiRenderInput = { requestId: 'ea7ade88-9db3-412c-86ec-94e4ba222510', name: 'My arrangement', catalogHash: catalogFixture.sourceHash,
|
|
snapshot: { version: 1, spu: 'SJ0380', items: [{ instanceId: 'cabinet-one', code: 'SJ0380K01', colorName: '榆木黑色', offsetY: 0, offsetZ: 1.67, quarterTurn: 0, supportId: 'floor' }],
|
|
doors: {}, camera: { yaw: .4, pitch: .2, zoom: 1, target: [0,0,0] } }, cameraView: { position: [3,4,12], target: [0,2,0] },
|
|
mode: 'white-background', prompt: 'Keep exact primary view; white seamless background.', userPrompt: '', imageCount: 1, imageSize: '2K', aspectRatio: '1:1',
|
|
referenceImageDataUrl: 'data:image/png;base64,AQID', structureReferenceImageDataUrls: ['data:image/png;base64,BAUG'] }
|
|
let row: Record<string,unknown>
|
|
beforeEach(() => {
|
|
jest.clearAllMocks(); row = { id: input.requestId, resultImages: [], referenceImageUrl: null, errorMessage: null, createdAt: new Date('2026-10-15T00:00:00Z') }
|
|
jest.mocked(intakeNodePermissionSettingsService.getSettings).mockResolvedValue({ overrides: {} } as never)
|
|
jest.mocked(diyCatalogService.getWorkbenchCatalog).mockResolvedValue(catalogFixture)
|
|
repo.findOwned.mockResolvedValue(null); repo.listOwned.mockResolvedValue([])
|
|
repo.create.mockImplementation(async data => { Object.assign(row,data); return row as never })
|
|
repo.update.mockImplementation(async (_id,data) => { Object.assign(row,data); return row as never })
|
|
jest.mocked(getExternalImageApiConfig).mockResolvedValue({enabled:true,baseUrl:'https://provider.invalid',apiKey:'test',model:'test-model'})
|
|
jest.mocked(enqueueNativeImageGeneration).mockImplementation(async (_options, commit) => { await commit?.(jest.fn()) })
|
|
jest.mocked(nativeRenderRepository.createWithJob).mockImplementation(async (data, persist) => { Object.assign(row, data); repo.findOwned.mockResolvedValue(row as never); await persist({} as never) })
|
|
jest.mocked(uploadDiyAiReference).mockResolvedValue({url:'/api/storage/object?key=reference',storageKey:'reference'})
|
|
})
|
|
test('supplier access is denied before catalog, persistence and provider calls', async () => {
|
|
await expect(diyAiRenderService.create(input,{...user,businessRoles:['SUPPLIER']})).rejects.toMatchObject({statusCode:403})
|
|
expect(repo.create).not.toHaveBeenCalled(); expect(enqueueNativeImageGeneration).not.toHaveBeenCalled()
|
|
})
|
|
test('stale catalog or invalid snapshot cannot incur an AI request', async () => {
|
|
await expect(diyAiRenderService.create({...input,catalogHash:'old'},user)).rejects.toMatchObject({statusCode:422})
|
|
await expect(diyAiRenderService.create({...input,snapshot:{...input.snapshot,items:[]}},user)).rejects.toMatchObject({statusCode:422})
|
|
expect(repo.create).not.toHaveBeenCalled(); expect(enqueueNativeImageGeneration).not.toHaveBeenCalled()
|
|
})
|
|
test('scheme ownership is checked independently of snapshot validity', async () => {
|
|
jest.mocked(diySchemeRepository.findOwned).mockResolvedValue(null)
|
|
await expect(diyAiRenderService.create({...input,schemeCode:'other-owner'},user)).rejects.toMatchObject({statusCode:404})
|
|
expect(repo.create).not.toHaveBeenCalled()
|
|
})
|
|
test('primary image, same-view structure, exact camera and original colors reach durable DIY records', async () => {
|
|
const result = await diyAiRenderService.create(input,user)
|
|
expect(result.status).toBe('PENDING'); expect(result.snapshot).toEqual(input.snapshot); expect(result.cameraView).toEqual(input.cameraView)
|
|
const request = jest.mocked(enqueueNativeImageGeneration).mock.calls[0][0].input
|
|
expect(request.referenceImage?.buffer).toEqual(Buffer.from([1,2,3])); expect(request.additionalReferenceImages?.[0].buffer).toEqual(Buffer.from([4,5,6]))
|
|
expect(request.prompt).toContain('exactly 1 cabinets and 0 installed door panels')
|
|
expect(jest.mocked(nativeRenderRepository.createWithJob).mock.calls[0][0]).toMatchObject({createdBy:'designer',storageClass:'PERMANENT',params:{source:'diy'},workspaceSnapshot:{snapshot:input.snapshot}})
|
|
expect(jest.mocked(nativeRenderRepository.createWithJob).mock.calls[0][0]).not.toHaveProperty('intakeSkuItemId')
|
|
expect(discardDiyAiUploads).not.toHaveBeenCalled()
|
|
})
|
|
test('retrying the same owned request returns its record without generating again', async () => {
|
|
const first = await diyAiRenderService.create(input,user)
|
|
repo.findOwned.mockResolvedValue(row as never)
|
|
const retry = await diyAiRenderService.create(input,user)
|
|
expect(first.id).toBe(retry.id); expect(enqueueNativeImageGeneration).toHaveBeenCalledTimes(1)
|
|
expect(repo.findOwned).toHaveBeenLastCalledWith(input.requestId,'designer')
|
|
})
|
|
test('scene generation requires user text and discards white structure references', async () => {
|
|
await expect(diyAiRenderService.create({...input,mode:'scene'},user)).rejects.toMatchObject({statusCode:422})
|
|
await diyAiRenderService.create({...input,mode:'scene',userPrompt:'Modern living room'},user)
|
|
expect(jest.mocked(enqueueNativeImageGeneration).mock.calls[0][0].input.additionalReferenceImages).toEqual([])
|
|
expect(jest.mocked(nativeRenderRepository.createWithJob).mock.calls[0][0].outputMode).toBe('SCENE')
|
|
})
|
|
test('a failed enqueue does not leave a pending business row and discards its unattached reference', async () => {
|
|
jest.mocked(enqueueNativeImageGeneration).mockRejectedValueOnce(new Error('queue write failed'))
|
|
await expect(diyAiRenderService.create(input,user)).rejects.toThrow('queue write failed')
|
|
expect(nativeRenderRepository.createWithJob).not.toHaveBeenCalled()
|
|
expect(discardDiyAiUploads).toHaveBeenCalledWith(['reference'])
|
|
})
|
|
|
|
test('list requests use the authenticated owner and optional arrangement filter', async () => {
|
|
const key = 'a'.repeat(64)
|
|
await diyAiRenderService.list(user,key)
|
|
expect(repo.listOwned).toHaveBeenCalledWith('designer',key,undefined)
|
|
await expect(diyAiRenderService.list(user,'bad')).rejects.toMatchObject({statusCode:422})
|
|
})
|
|
|
|
|
|
test('same DIY request id rejects a changed prompt and preserves the original paid task', async () => {
|
|
await diyAiRenderService.create(input, user)
|
|
await expect(diyAiRenderService.create({ ...input, prompt: 'A different prompt' }, user)).rejects.toThrow('同一出图请求编号')
|
|
expect(enqueueNativeImageGeneration).toHaveBeenCalledTimes(1)
|
|
})
|