292 lines
10 KiB
TypeScript
292 lines
10 KiB
TypeScript
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<unknown>,
|
|
) => 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<unknown>,
|
|
) => 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<unknown>,
|
|
) => 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<unknown>,
|
|
) => 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),
|
|
);
|
|
});
|
|
});
|