329 lines
15 KiB
TypeScript
329 lines
15 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import {
|
|
createApplicationDatabase,
|
|
type Database,
|
|
type DatabaseTransaction,
|
|
} from '@atlas/database';
|
|
import {
|
|
createImageSuiteSchema,
|
|
imageSuiteInputSchema,
|
|
type ImageSuite,
|
|
} from '@atlas/contracts';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { IdempotencyService } from '../src/common/idempotency.service.js';
|
|
import { ImageSuiteService } from '../src/media/image-suite.service.js';
|
|
import { MediaAiEditService } from '../src/media/media-ai-edit.service.js';
|
|
import { ProductImageTaskService } from '../src/media/product-image-task.service.js';
|
|
|
|
/** 所有实体和 Outbox 都只存在于最终回滚的事务,验证不调用实际收费生图。 */
|
|
describe.skipIf(!process.env.DATABASE_URL)(
|
|
'套图数据库隔离、幂等与状态汇总',
|
|
() => {
|
|
it('创建/部分失败/取消/重试保留成功图片,FK 与 RLS 拒绝越权引用', async () => {
|
|
const db = createApplicationDatabase(process.env.DATABASE_URL!);
|
|
const rollback = Symbol('rollback');
|
|
try {
|
|
await db.transaction(async (tx) => {
|
|
const workspaceId = randomUUID(),
|
|
otherWorkspace = randomUUID(),
|
|
userId = randomUUID();
|
|
await tx.query(
|
|
"INSERT INTO atlas.users (id, email, password_hash) VALUES ($1, $2, 'test-only')",
|
|
[userId, `${userId}@example.test`],
|
|
);
|
|
for (const id of [workspaceId, otherWorkspace])
|
|
await tx.query(
|
|
"INSERT INTO atlas.workspaces (id, name, slug) VALUES ($1::uuid, '套图测试', $1::text)",
|
|
[id],
|
|
);
|
|
const inWorkspace = async (id: string) =>
|
|
tx.query(
|
|
"SELECT set_config('app.workspace_id', $1, true)",
|
|
[id],
|
|
);
|
|
await inWorkspace(workspaceId);
|
|
const productId = randomUUID(),
|
|
variantId = randomUUID(),
|
|
itemId = randomUUID(),
|
|
assetId = randomUUID(),
|
|
outputId = randomUUID();
|
|
await tx.query(
|
|
"INSERT INTO atlas.collection_items (id, workspace_id, source_kind, source_item_key) VALUES ($1::uuid, $2, 'manual', $1::text)",
|
|
[itemId, workspaceId],
|
|
);
|
|
await tx.query(
|
|
'INSERT INTO atlas.products (id, workspace_id, collection_item_id) VALUES ($1, $2, $3)',
|
|
[productId, workspaceId, itemId],
|
|
);
|
|
await tx.query(
|
|
'INSERT INTO atlas.product_variants (id, workspace_id, product_id) VALUES ($1, $2, $3)',
|
|
[variantId, workspaceId, productId],
|
|
);
|
|
const asset = async (id: string, ws: string) =>
|
|
tx.query(
|
|
`INSERT INTO atlas.media_assets
|
|
(id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size, checksum_sha256, width_px, height_px)
|
|
VALUES ($1::uuid, $2, 'local_minio', 'test', $1::text, 'image/jpeg', 100, $1::text, 100, 100)`,
|
|
[id, ws],
|
|
);
|
|
await asset(assetId, workspaceId);
|
|
await asset(outputId, workspaceId);
|
|
await inWorkspace(otherWorkspace);
|
|
const foreignAsset = randomUUID();
|
|
await asset(foreignAsset, otherWorkspace);
|
|
await inWorkspace(workspaceId);
|
|
let queryQueue: Promise<void> = Promise.resolve();
|
|
const scoped = {
|
|
workspaceTransaction: <T>(
|
|
ws: string,
|
|
work: (
|
|
transaction: DatabaseTransaction,
|
|
) => Promise<T>,
|
|
) => {
|
|
const result = queryQueue.then(async () => {
|
|
await inWorkspace(ws);
|
|
return work(tx);
|
|
});
|
|
queryQueue = result.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
return result;
|
|
},
|
|
} as Database;
|
|
const idempotency = new IdempotencyService(scoped, {
|
|
IDEMPOTENCY_KEY_SECRET: 'suite-test-secret',
|
|
} as never);
|
|
const edits = new MediaAiEditService(scoped, idempotency, {
|
|
accessUrl: async () => ({
|
|
url: 'https://example.test/preview',
|
|
expires_at: '2099-01-01T00:00:00Z',
|
|
}),
|
|
} as never);
|
|
const suites = new ImageSuiteService(
|
|
scoped,
|
|
idempotency,
|
|
edits,
|
|
);
|
|
const input = imageSuiteInputSchema.parse({
|
|
variant_id: variantId,
|
|
reference_asset_ids: [assetId],
|
|
product_name: '台灯',
|
|
});
|
|
const shots = ['white', 'scene', 'selling'].map(
|
|
(category, i) => ({
|
|
id: randomUUID(),
|
|
category,
|
|
type: ['white_background', 'lifestyle', 'detail'][
|
|
i
|
|
],
|
|
theme: `方案${i}`,
|
|
description: '依据真实底图展示商品',
|
|
}),
|
|
);
|
|
const request = createImageSuiteSchema.parse({
|
|
...input,
|
|
shots,
|
|
});
|
|
const key = randomUUID();
|
|
const created = await suites.create(
|
|
workspaceId,
|
|
userId,
|
|
productId,
|
|
key,
|
|
request,
|
|
'generate',
|
|
);
|
|
const id = (created.body as { data: ImageSuite }).data.id;
|
|
expect(
|
|
(
|
|
await suites.create(
|
|
workspaceId,
|
|
userId,
|
|
productId,
|
|
key,
|
|
request,
|
|
'generate',
|
|
)
|
|
).body,
|
|
).toEqual(created.body);
|
|
await expect(
|
|
suites.create(
|
|
workspaceId,
|
|
userId,
|
|
productId,
|
|
key,
|
|
{ ...request, product_name: '不同请求' },
|
|
'generate',
|
|
),
|
|
).rejects.toMatchObject({
|
|
status: 409,
|
|
response: { error: { code: 'IDEMPOTENCY_KEY_REUSED' } },
|
|
});
|
|
const children = (
|
|
await tx.query<{ id: string }>(
|
|
'SELECT id FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position',
|
|
[id],
|
|
)
|
|
).rows;
|
|
expect(children).toHaveLength(3);
|
|
expect(
|
|
(
|
|
await tx.query(
|
|
'SELECT id FROM atlas.outbox_events WHERE aggregate_id = ANY($1::uuid[])',
|
|
[children.map((child) => child.id)],
|
|
)
|
|
).rows,
|
|
).toHaveLength(3);
|
|
await tx.query(
|
|
"UPDATE atlas.media_ai_edits SET status = 'succeeded', output_asset_id = $2 WHERE id = $1",
|
|
[children[0].id, outputId],
|
|
);
|
|
await tx.query(
|
|
"UPDATE atlas.media_ai_edits SET status = 'failed', error_code = 'TEST_FAILURE' WHERE id = $1",
|
|
[children[1].id],
|
|
);
|
|
expect(
|
|
(await suites.read(workspaceId, productId, id)).status,
|
|
).toBe('running');
|
|
await suites.action(
|
|
workspaceId,
|
|
userId,
|
|
productId,
|
|
id,
|
|
randomUUID(),
|
|
'cancel',
|
|
);
|
|
expect(
|
|
(await suites.read(workspaceId, productId, id)).status,
|
|
).toBe('cancelled');
|
|
await suites.action(
|
|
workspaceId,
|
|
userId,
|
|
productId,
|
|
id,
|
|
randomUUID(),
|
|
'retry',
|
|
);
|
|
const retried = await suites.read(
|
|
workspaceId,
|
|
productId,
|
|
id,
|
|
);
|
|
expect(retried.items.map((item) => item.status)).toEqual([
|
|
'succeeded',
|
|
'queued',
|
|
'queued',
|
|
]);
|
|
expect(retried.items[0].output_asset?.id).toBe(outputId);
|
|
const attempts = (
|
|
await tx.query<{ generation_attempt: number }>(
|
|
'SELECT generation_attempt FROM atlas.media_ai_edits WHERE suite_id = $1 ORDER BY suite_position',
|
|
[id],
|
|
)
|
|
).rows;
|
|
expect(
|
|
attempts.map((item) => item.generation_attempt),
|
|
).toEqual([1, 2, 2]);
|
|
await tx.query(
|
|
"UPDATE atlas.media_ai_edits SET status = 'failed', error_code = 'TEST_FAILURE' WHERE id = ANY($1::uuid[])",
|
|
[children.slice(1).map((child) => child.id)],
|
|
);
|
|
expect(
|
|
(await suites.read(workspaceId, productId, id)).status,
|
|
).toBe('partial');
|
|
const tasks = await new ProductImageTaskService(
|
|
scoped,
|
|
).list(workspaceId, productId, { limit: 20 });
|
|
expect(tasks.data).toMatchObject([
|
|
{
|
|
id,
|
|
kind: 'ai_suite',
|
|
status: 'partial',
|
|
total: 3,
|
|
completed: 1,
|
|
output_asset_ids: [outputId],
|
|
},
|
|
]);
|
|
expect(tasks.data).toHaveLength(1);
|
|
await expect(
|
|
suites.read(otherWorkspace, productId, id),
|
|
).rejects.toMatchObject({ status: 404 });
|
|
await expect(
|
|
suites.create(
|
|
workspaceId,
|
|
userId,
|
|
productId,
|
|
randomUUID(),
|
|
{ ...input, reference_asset_ids: [foreignAsset] },
|
|
'plan',
|
|
),
|
|
).rejects.toMatchObject({ status: 404 });
|
|
const forbidden = async (
|
|
sql: string,
|
|
params: unknown[],
|
|
code: string,
|
|
) => {
|
|
await tx.query('SAVEPOINT forbidden_write');
|
|
try {
|
|
await tx.query(sql, params);
|
|
throw new Error('本应拒绝的写入成功');
|
|
} catch (error) {
|
|
expect((error as { code: string }).code).toBe(code);
|
|
} finally {
|
|
await tx.query(
|
|
'ROLLBACK TO SAVEPOINT forbidden_write',
|
|
);
|
|
}
|
|
};
|
|
await forbidden(
|
|
'UPDATE atlas.product_image_suites SET input = input || \'{"color":"篡改"}\'::jsonb WHERE id = $1',
|
|
[id],
|
|
'23514',
|
|
);
|
|
await forbidden(
|
|
'UPDATE atlas.media_ai_edits SET suite_position = NULL WHERE id = $1',
|
|
[children[0].id],
|
|
'23514',
|
|
);
|
|
await forbidden(
|
|
'UPDATE atlas.media_ai_edits SET source_asset_id = $2 WHERE id = $1',
|
|
[children[0].id, foreignAsset],
|
|
'23514',
|
|
);
|
|
const foreignReferenceParent = await suites.create(
|
|
workspaceId,
|
|
userId,
|
|
productId,
|
|
randomUUID(),
|
|
input,
|
|
'plan',
|
|
);
|
|
const foreignParentId = (
|
|
foreignReferenceParent.body as { data: ImageSuite }
|
|
).data.id;
|
|
await forbidden(
|
|
'UPDATE atlas.product_image_suite_references SET asset_id = $2 WHERE suite_id = $1',
|
|
[foreignParentId, foreignAsset],
|
|
'23514',
|
|
);
|
|
await inWorkspace(otherWorkspace);
|
|
expect(
|
|
(
|
|
await tx.query(
|
|
'SELECT id FROM atlas.product_image_suites WHERE id = $1',
|
|
[id],
|
|
)
|
|
).rows,
|
|
).toHaveLength(0);
|
|
throw rollback;
|
|
});
|
|
} catch (error) {
|
|
if (error !== rollback) throw error;
|
|
} finally {
|
|
await db.close();
|
|
}
|
|
});
|
|
},
|
|
);
|