import { randomUUID } from 'node:crypto'; import { describe, expect, it, vi } from 'vitest'; import { createImageSuiteSchema, imageSuiteInputSchema, imageSuitePlanInputSchema, } from '@atlas/contracts'; import { ImageSuiteService } from '../src/media/image-suite.service.js'; const input = () => imageSuiteInputSchema.parse({ variant_id: randomUUID(), reference_asset_ids: [randomUUID()], product_name: '台灯', }); const shot = () => ({ id: randomUUID(), category: 'white', type: 'white_background', theme: '白底主图', description: '展示商品完整外观', }); describe('套图共享契约与资源边界', () => { it('规划默认四张,接受 1–20 张预算,生成接口不接收规划预算字段', () => { const valid = input(); expect(imageSuitePlanInputSchema.parse(valid).image_count).toBe(4); expect( imageSuitePlanInputSchema.parse({ ...valid, image_count: 6 }) .image_count, ).toBe(6); for (const image_count of [0, 21, 1.2]) expect( imageSuitePlanInputSchema.safeParse({ ...valid, image_count }) .success, ).toBe(false); expect( createImageSuiteSchema.safeParse({ ...valid, image_count: 4, shots: [shot()], }).success, ).toBe(false); }); it('规划保存预算并只创建规划 Outbox,不创建生图子任务', async () => { const request = imageSuitePlanInputSchema.parse({ ...input(), image_count: 6, }); const suiteId = randomUUID(), productId = randomUUID(); const query = vi.fn(async (sql: string, values: unknown[] = []) => { if (sql.startsWith('SELECT id FROM atlas.product_variants')) return { rows: [{ id: request.variant_id }] }; if (sql.startsWith('SELECT id FROM atlas.media_assets')) return { rows: request.reference_asset_ids.map((id) => ({ id })), }; if (sql.startsWith('INSERT INTO atlas.product_image_suites')) return { rows: [ { id: suiteId, product_id: productId, variant_id: request.variant_id, purpose: 'plan', status: 'queued', input: JSON.parse(values[3] as string), shots: [], created_at: new Date(), completed_at: null, error_code: null, }, ], }; return { rows: [] }; }); const service = new ImageSuiteService( {} as never, { run: async ( _context: unknown, work: (tx: { query: typeof query }) => Promise, ) => work({ query }), } as never, {} as never, ); const result = await service.create( randomUUID(), randomUUID(), productId, randomUUID(), request, 'plan', ); expect(result).toMatchObject({ status: 202, body: { data: { purpose: 'plan', input: { image_count: 6 }, shots: [] }, }, }); expect( query.mock.calls.some(([sql]) => sql.includes('INSERT INTO atlas.media_ai_edits'), ), ).toBe(false); expect( query.mock.calls.find(([sql]) => sql.includes('INSERT INTO atlas.outbox_events'), )?.[1], ).toContain('image_suite.plan.requested'); }); it('拒绝重复底图、超过 8 张底图、超过分类数量和无实物尺寸的尺寸图', () => { const valid = input(); expect( imageSuiteInputSchema.safeParse({ ...valid, reference_asset_ids: Array.from({ length: 9 }, randomUUID), }).success, ).toBe(false); expect( imageSuiteInputSchema.safeParse({ ...valid, reference_asset_ids: [ ...valid.reference_asset_ids, ...valid.reference_asset_ids, ], }).success, ).toBe(false); expect( createImageSuiteSchema.safeParse({ ...valid, shots: Array.from({ length: 6 }, shot), }).success, ).toBe(false); expect( createImageSuiteSchema.safeParse({ ...valid, shots: [{ ...shot(), category: 'other', type: 'dimensions' }], }).success, ).toBe(false); }); it('逐张底图必须来自已选图库,并保留主体图,类型必须匹配分类', () => { const valid = input(); expect( createImageSuiteSchema.safeParse({ ...valid, shots: [{ ...shot(), reference_asset_ids: [randomUUID()] }], }).success, ).toBe(false); expect( createImageSuiteSchema.safeParse({ ...valid, shots: [{ ...shot(), category: 'scene' }], }).success, ).toBe(false); expect( createImageSuiteSchema.safeParse({ ...valid, shots: [shot()], ratio: '4:3', language: 'zh', }).success, ).toBe(true); }); it('目标 SKU 不属于当前商品时,在产生任何任务前拒绝请求', async () => { const query = vi.fn(async () => ({ rows: [] })); const run = vi.fn( async ( _input: unknown, work: (tx: { query: typeof query }) => Promise, ) => work({ query }), ); const service = new ImageSuiteService( {} as never, { run } as never, {} as never, ); await expect( service.create( randomUUID(), randomUUID(), randomUUID(), randomUUID(), input(), 'plan', ), ).rejects.toMatchObject({ status: 404 }); expect(query).toHaveBeenCalledOnce(); }); it('任一底图不可见时拒绝,不产生套图及 Outbox', async () => { const query = vi.fn(async (sql: string) => ({ rows: sql.includes('atlas.product_variants') ? [{ id: 'variant' }] : [], })); const run = vi.fn( async ( _input: unknown, work: (tx: { query: typeof query }) => Promise, ) => work({ query }), ); const service = new ImageSuiteService( {} as never, { run } as never, {} as never, ); await expect( service.create( randomUUID(), randomUUID(), randomUUID(), randomUUID(), input(), 'plan', ), ).rejects.toMatchObject({ status: 404 }); expect(query).toHaveBeenCalledTimes(2); }); it('默认逐张用图保留主体,每张最多 3 张,整套轮换覆盖已选 8 张底图', async () => { const request = createImageSuiteSchema.parse({ ...input(), reference_asset_ids: Array.from({ length: 8 }, randomUUID), shots: Array.from({ length: 8 }, (_, index) => ({ ...shot(), category: ['white', 'scene', 'selling', 'other'][index % 4], type: ['white_background', 'lifestyle', 'detail', 'custom'][ index % 4 ], })), }); const used: string[][] = []; const query = vi.fn(async (sql: string, values: unknown[] = []) => { if (sql.startsWith('SELECT id FROM atlas.product_variants')) return { rows: [{ id: request.variant_id }] }; if (sql.startsWith('SELECT id FROM atlas.media_assets')) return { rows: request.reference_asset_ids.map((id) => ({ id })), }; if (sql.includes('INSERT INTO atlas.product_image_suites')) return { rows: [ { id: randomUUID(), product_id: randomUUID(), variant_id: request.variant_id, purpose: 'generate', status: 'queued', input: request, shots: request.shots, created_at: new Date(), completed_at: null, error_code: null, }, ], }; if (sql.includes('INSERT INTO atlas.media_ai_edits')) used.push([values[5] as string, ...(values[6] as string[])]); return { rows: [] }; }); const service = new ImageSuiteService( {} as never, { run: async ( _request: unknown, work: (tx: { query: typeof query }) => Promise, ) => work({ query }), } as never, {} as never, ); await service.create( randomUUID(), randomUUID(), randomUUID(), randomUUID(), request, 'generate', ); expect(used).toHaveLength(8); expect( used.every( (refs) => refs.length <= 3 && refs[0] === request.reference_asset_ids[0], ), ).toBe(true); expect(new Set(used.flat())).toEqual( new Set(request.reference_asset_ids), ); }); });