feat(ai-image): AI生图开发
This commit is contained in:
@@ -179,6 +179,69 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
assert.equal(first.body.data.collection_item.status, 'ready_for_edit');
|
||||
assert.equal(first.body.data.materialization.pending_selection, 1);
|
||||
assert.equal(first.body.data.materialization.queued, 0);
|
||||
const source1688 = {
|
||||
source: {
|
||||
platform: '1688', itemKey: '1688-price-image-test',
|
||||
canonicalUrl: 'https://detail.1688.com/offer/123456789.html',
|
||||
collectedAt: new Date().toISOString(), parserVersion: '1688-test',
|
||||
},
|
||||
common: { attributes: [], categories: [] },
|
||||
variantAxes: [],
|
||||
variants: [{
|
||||
sourceVariantKey: '6100454176961', sourceSku: '6100454176961',
|
||||
displayName: '六代目卡卡西25cm', selections: [],
|
||||
sourcePrice: { amount: '31.335', currencyCode: 'CNY' },
|
||||
confidence: 'verified', evidenceRefs: ['1688:sku-map'],
|
||||
}],
|
||||
assets: [{
|
||||
url: 'https://cbu01.alicdn.com/sku.jpg', role: 'source_sku',
|
||||
relation: 'axis_value', variantSourceKey: '6100454176961',
|
||||
sortOrder: 0, evidenceRef: '1688:sku-map',
|
||||
}],
|
||||
evidence: [{ ref: '1688:sku-map', kind: 'state', path: 'skuModel.skuInfoMap' }],
|
||||
};
|
||||
const captured1688 = await request('/api/extension/materials', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${webAccessToken}`,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
},
|
||||
body: JSON.stringify(source1688),
|
||||
});
|
||||
assert.equal(captured1688.response.status, 201);
|
||||
const associated1688 = await database.workspaceTransaction(workspaceId, async (tx) =>
|
||||
tx.query<{ purchase_cost_cny: string; source_variant_id: string; variant_id: string }>(
|
||||
`SELECT v.purchase_cost_cny::text, t.source_variant_id, v.id AS variant_id
|
||||
FROM atlas.product_variants v
|
||||
JOIN atlas.collection_asset_transfers t ON t.product_id = v.product_id
|
||||
WHERE v.product_id = $1 AND t.role = 'source_sku'`,
|
||||
[captured1688.body.data.product_id],
|
||||
),
|
||||
);
|
||||
assert.equal(associated1688.rows[0]?.purchase_cost_cny, '31.3400');
|
||||
assert.equal(associated1688.rows[0]?.source_variant_id, associated1688.rows[0]?.variant_id);
|
||||
const refreshed1688 = await request('/api/extension/materials', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${webAccessToken}`,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...source1688,
|
||||
variants: [{
|
||||
...source1688.variants[0],
|
||||
sourcePrice: { amount: '32.525', currencyCode: 'CNY' },
|
||||
}],
|
||||
}),
|
||||
});
|
||||
assert.equal(refreshed1688.response.status, 200);
|
||||
const updatedCost = await database.workspaceTransaction(workspaceId, async (tx) =>
|
||||
tx.query<{ purchase_cost_cny: string }>(
|
||||
`SELECT purchase_cost_cny::text FROM atlas.product_variants WHERE product_id = $1`,
|
||||
[captured1688.body.data.product_id],
|
||||
),
|
||||
);
|
||||
assert.equal(updatedCost.rows[0]?.purchase_cost_cny, '32.5300');
|
||||
const platforms = await request('/api/platforms', {
|
||||
headers: { Authorization: `Bearer ${webAccessToken}` },
|
||||
});
|
||||
@@ -328,7 +391,7 @@ describe('扩展授权与采集 HTTP 集成测试', () => {
|
||||
variant.source_variant_key === '123456789',
|
||||
);
|
||||
assert.equal(originalVariant.display_name, '人工确认 SKU');
|
||||
assert.equal(originalVariant.purchase_cost_cny, '88.8000');
|
||||
assert.equal(originalVariant.purchase_cost_cny, '88.80');
|
||||
assert.equal(originalVariant.listing.offer_id, 'manual-offer-001');
|
||||
const discoveredVariant = editorAfterRefresh.body.data.variants.find(
|
||||
(variant: { source_variant_key: string }) =>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createMediaAiEditRequestSchema } from '@atlas/contracts';
|
||||
import { MediaAiEditService } from '../src/media/media-ai-edit.service.js';
|
||||
|
||||
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
|
||||
const sourceId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
const referenceId = '50ea00b7-6675-4cc7-b7a2-d45f05c07100';
|
||||
const editId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
|
||||
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
|
||||
describe('单张 AI 编辑任务', () => {
|
||||
it('验证主图和辅图都属于当前工作空间后,原子写入任务、进度和 Outbox', async () => {
|
||||
const statements: string[] = [];
|
||||
const query = vi.fn(async (sql: string) => {
|
||||
statements.push(sql);
|
||||
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
|
||||
if (sql.includes('SELECT id FROM atlas.media_assets'))
|
||||
return { rows: [{ id: sourceId }, { id: referenceId }] };
|
||||
if (sql.includes('RETURNING id')) return { rows: [{ id: editId }] };
|
||||
return { rows: [] };
|
||||
});
|
||||
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
|
||||
const service = new MediaAiEditService(
|
||||
{} as ConstructorParameters<typeof MediaAiEditService>[0],
|
||||
{ run } as unknown as ConstructorParameters<typeof MediaAiEditService>[1],
|
||||
{} as ConstructorParameters<typeof MediaAiEditService>[2],
|
||||
);
|
||||
const input = createMediaAiEditRequestSchema.parse({ product_id: productId, reference_asset_ids: [referenceId], background: 'reference' });
|
||||
const result = await service.create(workspaceId, 'user-id', sourceId, 'key', input);
|
||||
expect(result).toMatchObject({ status: 202, body: { data: { id: editId, status: 'queued' } } });
|
||||
expect(run).toHaveBeenCalledOnce();
|
||||
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_ai_edits'))).toBe(true);
|
||||
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
|
||||
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
|
||||
});
|
||||
|
||||
it('任一辅助图不在当前工作空间时,不创建任务', async () => {
|
||||
const query = vi.fn(async (sql: string) => ({ rows: [{ id: sql.includes('atlas.products') ? productId : sourceId }] }));
|
||||
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
|
||||
const service = new MediaAiEditService(
|
||||
{} as ConstructorParameters<typeof MediaAiEditService>[0],
|
||||
{ run } as unknown as ConstructorParameters<typeof MediaAiEditService>[1],
|
||||
{} as ConstructorParameters<typeof MediaAiEditService>[2],
|
||||
);
|
||||
const input = createMediaAiEditRequestSchema.parse({ product_id: productId, reference_asset_ids: [referenceId] });
|
||||
await expect(service.create(workspaceId, 'user-id', sourceId, 'key', input))
|
||||
.rejects.toMatchObject({ status: 404 });
|
||||
expect(query).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('仅扩图不能带辅图或尺寸标注,参考背景必须带辅图', () => {
|
||||
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, mode: 'outpaint', reference_asset_ids: [referenceId] }).success).toBe(false);
|
||||
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, background: 'reference' }).success).toBe(false);
|
||||
expect(createMediaAiEditRequestSchema.safeParse({ product_id: productId, generate_size_chart: true }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('读取失败任务时返回记录的失败来源、步骤和诊断', async () => {
|
||||
const query = vi.fn(async (sql: string) => {
|
||||
expect(sql).toContain('o.workspace_id = e.workspace_id');
|
||||
return { rows: [{
|
||||
id: editId, status: 'failed', source_asset_id: sourceId,
|
||||
output_asset_id: null, error_code: 'AI_IMAGE_GENERATION_FAILED',
|
||||
failure_source: 'atlas', failure_stage: 'result_processing',
|
||||
failure_detail: 'Pcore 结果图片无效',
|
||||
}] };
|
||||
});
|
||||
const database = { workspaceTransaction: vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query })) };
|
||||
const service = new MediaAiEditService(
|
||||
database as unknown as ConstructorParameters<typeof MediaAiEditService>[0],
|
||||
{} as ConstructorParameters<typeof MediaAiEditService>[1],
|
||||
{} as ConstructorParameters<typeof MediaAiEditService>[2],
|
||||
);
|
||||
const result = await service.read(workspaceId, editId);
|
||||
expect(result).toMatchObject({
|
||||
failure_source: 'atlas', failure_stage: 'result_processing',
|
||||
failure_detail: 'Pcore 结果图片无效',
|
||||
});
|
||||
});
|
||||
|
||||
it('AI 生图记录按当前商品筛选并重新签发短时预览地址', async () => {
|
||||
const query = vi.fn(async (_sql: string, values: unknown[]) => {
|
||||
expect(values).toEqual([productId]);
|
||||
return { rows: [{
|
||||
id: editId, status: 'succeeded', source_asset_id: sourceId,
|
||||
output_asset_id: referenceId, error_code: null,
|
||||
content_type: 'image/jpeg', byte_size: '1234', width_px: 1152,
|
||||
height_px: 1536, asset_created_at: new Date('2026-09-26T00:00:00Z'),
|
||||
}] };
|
||||
});
|
||||
const database = { workspaceTransaction: vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query })) };
|
||||
const accessUrl = vi.fn(async () => ({ url: 'https://example.com/short-lived', expires_at: '2026-09-26T01:00:00Z' }));
|
||||
const service = new MediaAiEditService(
|
||||
database as unknown as ConstructorParameters<typeof MediaAiEditService>[0],
|
||||
{} as ConstructorParameters<typeof MediaAiEditService>[1],
|
||||
{ accessUrl } as unknown as ConstructorParameters<typeof MediaAiEditService>[2],
|
||||
);
|
||||
const items = await service.list(workspaceId, productId);
|
||||
expect(items).toMatchObject([{ id: editId, output_asset: { id: referenceId, preview_url: 'https://example.com/short-lived' } }]);
|
||||
expect(accessUrl).toHaveBeenCalledWith(workspaceId, referenceId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AspectRatioDerivativeService } from '../src/media/aspect-ratio-derivative.service.js';
|
||||
|
||||
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
|
||||
const sourceAssetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
const derivativeId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
|
||||
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
|
||||
describe('一键 3:4 派生任务', () => {
|
||||
it('在同一个租户事务写入派生记录、操作进度和 Outbox', async () => {
|
||||
const statements: string[] = [];
|
||||
const query = vi.fn(async (sql: string) => {
|
||||
statements.push(sql);
|
||||
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
|
||||
if (sql.includes('RETURNING id')) return { rows: [{ id: derivativeId }] };
|
||||
return { rows: [] };
|
||||
});
|
||||
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
|
||||
work({ query }));
|
||||
const service = new AspectRatioDerivativeService(
|
||||
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[0],
|
||||
{ run } as unknown as ConstructorParameters<typeof AspectRatioDerivativeService>[1],
|
||||
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[2],
|
||||
);
|
||||
const result = await service.create(
|
||||
workspaceId,
|
||||
'user-id',
|
||||
sourceAssetId,
|
||||
'idempotency-key',
|
||||
{ preset: '3:4', fit: 'contain' },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
status: 202,
|
||||
body: { data: { id: derivativeId, status: 'queued', source_asset_id: sourceAssetId } },
|
||||
});
|
||||
expect(run).toHaveBeenCalledOnce();
|
||||
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_aspect_ratio_derivatives'))).toBe(true);
|
||||
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
|
||||
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
|
||||
});
|
||||
|
||||
it('缺少或跨工作空间的源素材不会创建任务', 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 AspectRatioDerivativeService(
|
||||
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[0],
|
||||
{ run } as unknown as ConstructorParameters<typeof AspectRatioDerivativeService>[1],
|
||||
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[2],
|
||||
);
|
||||
await expect(service.create(
|
||||
workspaceId,
|
||||
'user-id',
|
||||
sourceAssetId,
|
||||
'idempotency-key',
|
||||
{ preset: '3:4', fit: 'contain' },
|
||||
)).rejects.toMatchObject({ status: 404 });
|
||||
expect(query).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('将当前工作空间的商品关联到新派生任务', async () => {
|
||||
const query = vi.fn(async (sql: string, values?: unknown[]) => {
|
||||
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
|
||||
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
|
||||
if (sql.includes('INSERT INTO atlas.media_aspect_ratio_derivatives')) {
|
||||
expect(values).toEqual([productId, sourceAssetId, 'user-id']);
|
||||
return { rows: [{ id: derivativeId }] };
|
||||
}
|
||||
return { rows: [] };
|
||||
});
|
||||
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
|
||||
const service = new AspectRatioDerivativeService(
|
||||
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[0],
|
||||
{ run } as unknown as ConstructorParameters<typeof AspectRatioDerivativeService>[1],
|
||||
{} as ConstructorParameters<typeof AspectRatioDerivativeService>[2],
|
||||
);
|
||||
await service.create(workspaceId, 'user-id', sourceAssetId, 'key',
|
||||
{ preset: '3:4', fit: 'contain', product_id: productId });
|
||||
expect(query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.media_aspect_ratio_derivatives'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import {
|
||||
createMediaAccessUrlRequestSchema,
|
||||
createMediaUploadIntentRequestSchema,
|
||||
importProductImageRequestSchema,
|
||||
} from '@atlas/contracts';
|
||||
@@ -55,6 +56,35 @@ function png(width: number, height: number) {
|
||||
}
|
||||
|
||||
describe('商品图片上传策略', () => {
|
||||
it('下载地址签名 attachment 响应头,预览地址保持内联显示', async () => {
|
||||
const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79';
|
||||
const query = vi.fn(async () => ({
|
||||
rows: [{ bucket: 'test', object_key: 'uploads/test.png', content_type: 'image/png' }],
|
||||
}));
|
||||
const workspaceTransaction = vi.fn(async (
|
||||
_workspaceId: string,
|
||||
callback: (tx: { query: typeof query }) => Promise<unknown>,
|
||||
) => callback({ query }));
|
||||
const service = new MediaService(
|
||||
{} as ConstructorParameters<typeof MediaService>[0],
|
||||
{ workspaceTransaction } as unknown as ConstructorParameters<typeof MediaService>[1],
|
||||
{
|
||||
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
|
||||
OBJECT_STORAGE_BUCKET: 'test',
|
||||
objectStorageForcePathStyle: true,
|
||||
objectStorageAccessKey: 'test',
|
||||
objectStorageSecretKey: 'test',
|
||||
} as ConstructorParameters<typeof MediaService>[2],
|
||||
);
|
||||
expect(createMediaAccessUrlRequestSchema.safeParse({ purpose: 'download' }).success).toBe(true);
|
||||
const preview = await service.accessUrl('workspace-id', assetId);
|
||||
const download = await service.accessUrl('workspace-id', assetId, 'download');
|
||||
expect(new URL(preview.url).searchParams.has('response-content-disposition')).toBe(false);
|
||||
expect(new URL(download.url).searchParams.get('response-content-disposition'))
|
||||
.toBe(`attachment; filename="atlas-image-${assetId}.png"`);
|
||||
expect(workspaceTransaction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('创建商品图片上传意图时持久化用途并签发独立对象路径', async () => {
|
||||
const query = vi.fn(async (_sql: string, _params?: unknown[]) => ({
|
||||
rows: [],
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import type { Database } from '@atlas/database';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { afterAll, beforeAll, describe, it } from 'vitest';
|
||||
import { DATABASE } from '../src/database/database.module.js';
|
||||
import { createApplication } from '../src/main.js';
|
||||
|
||||
let app: NestExpressApplication;
|
||||
let database: Database;
|
||||
let baseUrl = '';
|
||||
let workspaceId = '';
|
||||
let userId = '';
|
||||
let token = '';
|
||||
|
||||
describe('水印派生 API 集成测试', () => {
|
||||
beforeAll(async () => {
|
||||
Object.assign(process.env, {
|
||||
NODE_ENV: 'test', SWAGGER_ENABLED: 'false',
|
||||
AUTH_REGISTER_IP_LIMIT: '100', AUTH_REGISTER_EMAIL_LIMIT: '100',
|
||||
});
|
||||
const created = await createApplication();
|
||||
app = created.app;
|
||||
database = app.get<Database>(DATABASE);
|
||||
await app.listen(0, '127.0.0.1');
|
||||
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
|
||||
const response = await fetch(`${baseUrl}/api/auth/register`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: `watermark-derivative-${randomUUID()}@example.test`,
|
||||
password: 'correct-horse-battery-staple-123',
|
||||
password_confirmation: 'correct-horse-battery-staple-123',
|
||||
}),
|
||||
});
|
||||
assert.equal(response.status, 201);
|
||||
const body = await response.json() as { data: {
|
||||
user: { id: string }; workspace: { id: string }; access_token: string;
|
||||
} };
|
||||
userId = body.data.user.id;
|
||||
workspaceId = body.data.workspace.id;
|
||||
token = body.data.access_token;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (workspaceId) {
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await tx.query('DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1', [workspaceId]);
|
||||
await tx.query('DELETE FROM atlas.outbox_events WHERE workspace_id = $1', [workspaceId]);
|
||||
await tx.query('DELETE FROM atlas.operations WHERE workspace_id = $1', [workspaceId]);
|
||||
await tx.query('DELETE FROM atlas.media_aspect_ratio_derivatives WHERE workspace_id = $1', [workspaceId]);
|
||||
await tx.query('DELETE FROM atlas.media_watermark_derivatives WHERE workspace_id = $1', [workspaceId]);
|
||||
await tx.query('DELETE FROM atlas.watermark_template_versions WHERE workspace_id = $1', [workspaceId]);
|
||||
await tx.query('DELETE FROM atlas.watermark_templates WHERE workspace_id = $1', [workspaceId]);
|
||||
await tx.query('DELETE FROM atlas.media_assets WHERE workspace_id = $1', [workspaceId]);
|
||||
await tx.query('DELETE FROM atlas.products WHERE workspace_id = $1', [workspaceId]);
|
||||
await tx.query('DELETE FROM atlas.collection_items WHERE workspace_id = $1', [workspaceId]);
|
||||
});
|
||||
await database.query('DELETE FROM atlas.workspace_memberships WHERE user_id = $1', [userId]);
|
||||
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [workspaceId]);
|
||||
await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]);
|
||||
}
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('HTTP 创建任务锁定模板版本,结果查询保持工作空间边界', async () => {
|
||||
const { assetId, templateId, productId } = await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const item = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.collection_items (workspace_id, source_kind, source_item_key)
|
||||
VALUES ($1, 'manual', $2) RETURNING id`,
|
||||
[workspaceId, `watermark-history-${randomUUID()}`],
|
||||
);
|
||||
const product = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.products (workspace_id, collection_item_id)
|
||||
VALUES ($1, $2) RETURNING id`,
|
||||
[workspaceId, item.rows[0].id],
|
||||
);
|
||||
const asset = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.media_assets
|
||||
(workspace_id, storage_provider, bucket, object_key, content_type,
|
||||
byte_size, checksum_sha256, width_px, height_px)
|
||||
VALUES ($1, 'local_minio', 'atlas-media', $2, 'image/jpeg', 100,
|
||||
$3, 120, 100) RETURNING id`,
|
||||
[workspaceId, `test/watermark-${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').padEnd(64, '0')],
|
||||
);
|
||||
const template = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.watermark_templates (workspace_id, name, kind)
|
||||
VALUES ($1, $2, 'text') RETURNING id`,
|
||||
[workspaceId, `集成测试水印 ${randomUUID()}`],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.watermark_template_versions
|
||||
(workspace_id, template_id, version, kind, text_content, font_code,
|
||||
font_size_px, opacity, position, position_x, position_y, created_by_user_id)
|
||||
VALUES ($1, $2, 1, 'text', 'Atlas', 'NotoSans', 20, 0.5,
|
||||
'bottom_right', 1, 1, $3)`,
|
||||
[workspaceId, template.rows[0].id, userId],
|
||||
);
|
||||
return { assetId: asset.rows[0].id, templateId: template.rows[0].id, productId: product.rows[0].id };
|
||||
});
|
||||
const key = randomUUID();
|
||||
const create = () => fetch(`${baseUrl}/api/media-assets/${assetId}/watermark-derivatives`, {
|
||||
method: 'POST', headers: {
|
||||
Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'Idempotency-Key': key,
|
||||
},
|
||||
body: JSON.stringify({ watermark_template_id: templateId, product_id: productId }),
|
||||
});
|
||||
const response = await create();
|
||||
assert.equal(response.status, 202);
|
||||
const body = await response.json() as { data: {
|
||||
id: string; watermark_template_id: string; watermark_template_version: number;
|
||||
} };
|
||||
assert.equal(body.data.watermark_template_id, templateId);
|
||||
assert.equal(body.data.watermark_template_version, 1);
|
||||
const replay = await create();
|
||||
assert.equal(replay.status, 202);
|
||||
assert.equal((await replay.json() as { data: { id: string } }).data.id, body.data.id);
|
||||
const read = await fetch(`${baseUrl}/api/media-watermark-derivatives/${body.data.id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(read.status, 200);
|
||||
assert.equal((await read.json() as { data: { id: string } }).data.id, body.data.id);
|
||||
const stored = await database.workspaceTransaction(workspaceId, async (tx) =>
|
||||
tx.query<{ product_id: string }>(
|
||||
'SELECT product_id FROM atlas.media_watermark_derivatives WHERE id = $1',
|
||||
[body.data.id],
|
||||
),
|
||||
);
|
||||
assert.equal(stored.rows[0]?.product_id, productId);
|
||||
|
||||
const outputs = await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const ids: string[] = [];
|
||||
for (const kind of ['watermark', 'ratio']) {
|
||||
const asset = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.media_assets
|
||||
(workspace_id, storage_provider, bucket, object_key, content_type,
|
||||
byte_size, checksum_sha256, width_px, height_px)
|
||||
VALUES ($1, 'local_minio', 'atlas-media', $2, 'image/jpeg', 100,
|
||||
$3, 1200, 1600) RETURNING id`,
|
||||
[workspaceId, `test/${kind}-${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').padEnd(64, '0')],
|
||||
);
|
||||
ids.push(asset.rows[0].id);
|
||||
}
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.media_watermark_derivatives
|
||||
(workspace_id, product_id, source_asset_id, watermark_template_id,
|
||||
watermark_template_version, output_asset_id, status, completed_at)
|
||||
VALUES ($1, $2, $3, $4, 1, $5, 'succeeded', now())`,
|
||||
[workspaceId, productId, assetId, templateId, ids[0]],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.media_aspect_ratio_derivatives
|
||||
(workspace_id, product_id, source_asset_id, output_asset_id, status, completed_at)
|
||||
VALUES ($1, $2, $3, $4, 'succeeded', now())`,
|
||||
[workspaceId, productId, assetId, ids[1]],
|
||||
);
|
||||
return ids;
|
||||
});
|
||||
const history = await fetch(`${baseUrl}/api/products/${productId}/image-derivatives`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(history.status, 200);
|
||||
const records = (await history.json() as { data: Array<{
|
||||
kind: string; output_asset: { id: string; preview_url: string };
|
||||
}> }).data;
|
||||
assert.deepEqual(new Set(records.map((record) => record.kind)), new Set(['watermark', 'aspect_ratio_3_4']));
|
||||
assert.deepEqual(new Set(records.map((record) => record.output_asset.id)), new Set(outputs));
|
||||
assert.ok(records.every((record) => record.output_asset.preview_url));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { WatermarkDerivativeService } from '../src/media/watermark-derivative.service.js';
|
||||
|
||||
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
|
||||
const sourceAssetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
const templateId = '0878262e-6c38-4f3b-953a-2084ef297a33';
|
||||
const derivativeId = '68db226f-3fcb-4579-ab9e-4932b1d84067';
|
||||
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
|
||||
function serviceWithQuery(query: ReturnType<typeof vi.fn>) {
|
||||
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
|
||||
work({ query }));
|
||||
const service = new WatermarkDerivativeService(
|
||||
{} as ConstructorParameters<typeof WatermarkDerivativeService>[0],
|
||||
{ run } as unknown as ConstructorParameters<typeof WatermarkDerivativeService>[1],
|
||||
{} as ConstructorParameters<typeof WatermarkDerivativeService>[2],
|
||||
);
|
||||
return { service, run };
|
||||
}
|
||||
|
||||
describe('水印派生任务', () => {
|
||||
it('固定当前模板版本,并在同一个事务创建任务、操作和 Outbox', async () => {
|
||||
const statements: string[] = [];
|
||||
const query = vi.fn(async (sql: string) => {
|
||||
statements.push(sql);
|
||||
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
|
||||
if (sql.includes('FROM atlas.watermark_templates')) return { rows: [{ id: templateId, current_version: 4 }] };
|
||||
if (sql.includes('RETURNING id')) return { rows: [{ id: derivativeId }] };
|
||||
return { rows: [] };
|
||||
});
|
||||
const { service, run } = serviceWithQuery(query);
|
||||
const result = await service.create(workspaceId, 'user-id', sourceAssetId, 'idempotency-key',
|
||||
{ watermark_template_id: templateId });
|
||||
expect(result).toMatchObject({ status: 202, body: { data: {
|
||||
id: derivativeId, watermark_template_id: templateId, watermark_template_version: 4,
|
||||
} } });
|
||||
expect(run).toHaveBeenCalledOnce();
|
||||
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.media_watermark_derivatives'))).toBe(true);
|
||||
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.operations'))).toBe(true);
|
||||
expect(statements.some((sql) => sql.includes('INSERT INTO atlas.outbox_events'))).toBe(true);
|
||||
});
|
||||
|
||||
it('拒绝不可用模板且不创建派生任务', async () => {
|
||||
const query = vi.fn(async (sql: string) => sql.includes('SELECT id FROM atlas.media_assets')
|
||||
? { rows: [{ id: sourceAssetId }] } : { rows: [] });
|
||||
const { service } = serviceWithQuery(query);
|
||||
await expect(service.create(workspaceId, 'user-id', sourceAssetId, 'idempotency-key',
|
||||
{ watermark_template_id: templateId })).rejects.toMatchObject({ status: 404 });
|
||||
expect(query.mock.calls.every(([sql]) => !String(sql).includes('INSERT INTO'))).toBe(true);
|
||||
});
|
||||
|
||||
it('将当前工作空间的商品关联到水印结果', async () => {
|
||||
const query = vi.fn(async (sql: string, values?: unknown[]) => {
|
||||
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: sourceAssetId }] };
|
||||
if (sql.includes('SELECT id FROM atlas.products')) return { rows: [{ id: productId }] };
|
||||
if (sql.includes('FROM atlas.watermark_templates')) return { rows: [{ id: templateId, current_version: 4 }] };
|
||||
if (sql.includes('INSERT INTO atlas.media_watermark_derivatives')) {
|
||||
expect(values).toEqual([productId, sourceAssetId, templateId, 4, 'user-id']);
|
||||
return { rows: [{ id: derivativeId }] };
|
||||
}
|
||||
return { rows: [] };
|
||||
});
|
||||
const { service } = serviceWithQuery(query);
|
||||
await service.create(workspaceId, 'user-id', sourceAssetId, 'key',
|
||||
{ watermark_template_id: templateId, product_id: productId });
|
||||
expect(query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.media_watermark_derivatives'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import type { Database } from '@atlas/database';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { afterAll, beforeAll, describe, it } from 'vitest';
|
||||
import { DATABASE } from '../src/database/database.module.js';
|
||||
import { createApplication } from '../src/main.js';
|
||||
|
||||
let app: NestExpressApplication;
|
||||
let database: Database;
|
||||
let baseUrl: string;
|
||||
let token = '';
|
||||
let workspaceId = '';
|
||||
let userId = '';
|
||||
let platformId = '';
|
||||
let storeId = '';
|
||||
let listingId = '';
|
||||
let runId = '';
|
||||
|
||||
async function request(path: string, init: RequestInit = {}) {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: { 'Content-Type': 'application/json', ...init.headers },
|
||||
});
|
||||
return { response, body: (await response.json()) as Record<string, any> };
|
||||
}
|
||||
|
||||
function auth(extra: Record<string, string> = {}) {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe('在线商品 HTTP 契约', () => {
|
||||
beforeAll(async () => {
|
||||
Object.assign(process.env, {
|
||||
NODE_ENV: 'test',
|
||||
SWAGGER_ENABLED: 'false',
|
||||
AUTH_REGISTER_IP_LIMIT: '100',
|
||||
AUTH_REGISTER_EMAIL_LIMIT: '100',
|
||||
});
|
||||
const created = await createApplication();
|
||||
app = created.app;
|
||||
database = app.get<Database>(DATABASE);
|
||||
await app.listen(0, '127.0.0.1');
|
||||
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
|
||||
const registered = await request('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: `online-${randomUUID()}@example.test`,
|
||||
password: 'correct-horse-battery-staple-123',
|
||||
password_confirmation: 'correct-horse-battery-staple-123',
|
||||
}),
|
||||
});
|
||||
assert.equal(registered.response.status, 201);
|
||||
token = registered.body.data.access_token;
|
||||
workspaceId = registered.body.data.workspace.id;
|
||||
userId = registered.body.data.user.id;
|
||||
const platforms = await request('/api/platforms', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
platformId = platforms.body.data.find(
|
||||
(platform: { code: string }) => platform.code === 'OZON',
|
||||
).id;
|
||||
storeId = randomUUID();
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.stores (id, workspace_id, platform_id, name, platform_client_id, currency_code, is_default, status, credential_status)
|
||||
VALUES ($1, $2, $3, '测试门店', $4, 'RUB', true, 'active', 'valid')`,
|
||||
[storeId, workspaceId, platformId, `online-http-${storeId}`],
|
||||
);
|
||||
const run = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.platform_product_sync_runs
|
||||
(workspace_id, store_id, platform_id, trigger_source, status, completed_at)
|
||||
VALUES ($1, $2, $3, 'manual', 'succeeded', now()) RETURNING id`,
|
||||
[workspaceId, storeId, platformId],
|
||||
);
|
||||
runId = run.rows[0].id;
|
||||
const listing = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.platform_listings
|
||||
(workspace_id, store_id, platform_id, remote_product_id, offer_id, remote_sku, name,
|
||||
currency_code, selling_price, visible, moderation_status, stock_summary,
|
||||
image_summary, source, last_sync_run_id, last_synced_at, raw_payload)
|
||||
VALUES ($1, $2, $3, '123456', 'test-offer', '654321', '测试商品',
|
||||
'RUB', 99, true, 'approved', '{"total":0,"has_data":true}',
|
||||
'{"url":null,"width_px":null,"height_px":null}', 'external_sync', $4, now(), '{}') RETURNING id`,
|
||||
[workspaceId, storeId, platformId, runId],
|
||||
);
|
||||
listingId = listing.rows[0].id;
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.platform_listings
|
||||
(workspace_id, store_id, platform_id, remote_product_id, offer_id, name,
|
||||
visible, stock_summary, image_summary, source, last_sync_run_id, last_synced_at,
|
||||
missing_since, archived_at, raw_payload)
|
||||
VALUES ($1, $2, $3, '123457', 'archived-offer', '归档商品',
|
||||
NULL, '{"total":0,"has_data":false}', '{}', 'external_sync', $4,
|
||||
now(), now() - interval '8 days', now(), '{}')`,
|
||||
[workspaceId, storeId, platformId, runId],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (workspaceId && database) {
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.platform_listing_sync_snapshots WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.platform_listings WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.platform_product_sync_runs WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.outbox_events WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.store_secrets WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.stores WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
});
|
||||
await database.query(
|
||||
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
|
||||
[userId],
|
||||
);
|
||||
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [
|
||||
workspaceId,
|
||||
]);
|
||||
await database.query('DELETE FROM atlas.users WHERE id = $1', [
|
||||
userId,
|
||||
]);
|
||||
}
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
it('列表、详情与同步历史按平台店铺返回镜像,不返回原始响应', async () => {
|
||||
const listed = await request(
|
||||
`/api/platform-listings?store_id=${storeId}&q=test-offer`,
|
||||
{ headers: auth() },
|
||||
);
|
||||
assert.equal(listed.response.status, 200);
|
||||
assert.equal(listed.body.data.length, 1);
|
||||
assert.equal(listed.body.data[0].visible, true);
|
||||
assert.equal(listed.body.data[0].selling_price, '99.00');
|
||||
assert.deepEqual(listed.body.data[0].stock_summary, {
|
||||
total: 0,
|
||||
has_data: true,
|
||||
});
|
||||
assert.equal(listed.body.meta.store.id, storeId);
|
||||
assert.equal(listed.body.meta.last_sync_status, 'succeeded');
|
||||
assert.equal('raw_payload' in listed.body.data[0], false);
|
||||
const detail = await request(`/api/platform-listings/${listingId}`, {
|
||||
headers: auth(),
|
||||
});
|
||||
assert.equal(detail.response.status, 200);
|
||||
assert.equal(detail.body.data.offer_id, 'test-offer');
|
||||
const runs = await request(
|
||||
`/api/stores/${storeId}/platform-product-sync-runs`,
|
||||
{ headers: auth() },
|
||||
);
|
||||
assert.equal(runs.response.status, 200);
|
||||
assert.equal(runs.body.data[0].id, runId);
|
||||
const run = await request(`/api/platform-product-sync-runs/${runId}`, {
|
||||
headers: auth(),
|
||||
});
|
||||
assert.equal(run.response.status, 200);
|
||||
assert.equal(run.body.data.status, 'succeeded');
|
||||
});
|
||||
|
||||
it('筛选未知可见性与缺少平台头的请求按契约处理', async () => {
|
||||
const unknown = await request(
|
||||
`/api/platform-listings?store_id=${storeId}&visibility=unknown`,
|
||||
{ headers: auth() },
|
||||
);
|
||||
assert.equal(unknown.response.status, 200);
|
||||
assert.equal(unknown.body.data.length, 0);
|
||||
const archived = await request(
|
||||
`/api/platform-listings?store_id=${storeId}&visibility=unknown&include_archived=true`,
|
||||
{ headers: auth() },
|
||||
);
|
||||
assert.equal(archived.response.status, 200);
|
||||
assert.equal(archived.body.data.length, 1);
|
||||
assert.equal(archived.body.data[0].offer_id, 'archived-offer');
|
||||
const missingHeader = await request('/api/platform-listings', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(missingHeader.response.status, 400);
|
||||
assert.equal(
|
||||
missingHeader.body.error.code,
|
||||
'PLATFORM_CONTEXT_REQUIRED',
|
||||
);
|
||||
const noCredentials = await request(
|
||||
`/api/stores/${storeId}/platform-product-sync-runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: auth({ 'Idempotency-Key': randomUUID() }),
|
||||
body: JSON.stringify({ reason: 'manual_refresh' }),
|
||||
},
|
||||
);
|
||||
assert.equal(noCredentials.response.status, 422);
|
||||
assert.equal(
|
||||
noCredentials.body.error.code,
|
||||
'STORE_CREDENTIAL_UNAVAILABLE',
|
||||
);
|
||||
});
|
||||
|
||||
it('触发同步写入 Outbox,同一幂等键复用任务,并发请求返回冲突', async () => {
|
||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`INSERT INTO atlas.store_secrets
|
||||
(workspace_id, store_id, kind, secret_ciphertext, secret_nonce,
|
||||
encryption_key_version, fingerprint, status)
|
||||
VALUES ($1, $2, 'seller_api_key', $3, $4, 'v1', $5, 'valid')`,
|
||||
[workspaceId, storeId, Buffer.alloc(32), Buffer.alloc(12), randomUUID()],
|
||||
),
|
||||
);
|
||||
const key = randomUUID();
|
||||
const post = (idempotencyKey: string) =>
|
||||
request(`/api/stores/${storeId}/platform-product-sync-runs`, {
|
||||
method: 'POST',
|
||||
headers: auth({ 'Idempotency-Key': idempotencyKey }),
|
||||
body: JSON.stringify({ reason: 'manual_refresh' }),
|
||||
});
|
||||
const created = await post(key);
|
||||
assert.equal(created.response.status, 202);
|
||||
assert.equal(created.body.data.sync_run.status, 'queued');
|
||||
const repeated = await post(key);
|
||||
assert.equal(repeated.response.status, 202);
|
||||
assert.equal(repeated.body.data.sync_run.id, created.body.data.sync_run.id);
|
||||
const concurrent = await post(randomUUID());
|
||||
assert.equal(concurrent.response.status, 409);
|
||||
assert.equal(concurrent.body.error.code, 'SYNC_ALREADY_RUNNING');
|
||||
const events = await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query<{ count: string }>(
|
||||
`SELECT count(*)::text AS count FROM atlas.outbox_events
|
||||
WHERE aggregate_id = $1 AND event_type = 'platform_product_sync.requested'`,
|
||||
[created.body.data.sync_run.id],
|
||||
),
|
||||
);
|
||||
assert.equal(events.rows[0].count, '1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ProductEditorService } from '../src/product-editor/product-editor.service.js';
|
||||
|
||||
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
const variantId = '50ea00b7-6675-4cc7-b7a2-d45f05c07100';
|
||||
const assetId = '664d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
|
||||
function mediaTransaction(processed: boolean) {
|
||||
const query = vi.fn(async (sql: string, values?: unknown[]) => {
|
||||
if (sql.includes('FROM atlas.product_variant_platform_listings'))
|
||||
return { rows: [{ id: 'listing-id', product_variant_id: variantId }] };
|
||||
if (sql.includes('FROM atlas.media_assets'))
|
||||
return { rows: [{ id: assetId, content_type: 'image/jpeg', byte_size: '1200', width_px: 1200, height_px: 1600 }] };
|
||||
if (sql.includes('FROM atlas.media_aspect_ratio_derivatives')) {
|
||||
expect(values).toEqual([productId, [assetId]]);
|
||||
return { rows: processed ? [{ asset_id: assetId }] : [] };
|
||||
}
|
||||
return { rows: [] };
|
||||
});
|
||||
return { query };
|
||||
}
|
||||
|
||||
describe('SKU 图片处理结果的保存资格', () => {
|
||||
const input = {
|
||||
product: { id: productId, collection_item_id: 'collection-id', source_snapshot_revision: 1 },
|
||||
profile: { id: 'profile-id' },
|
||||
variants: [{ variantId, assetIds: [assetId] }],
|
||||
};
|
||||
const service = new ProductEditorService(
|
||||
{} as ConstructorParameters<typeof ProductEditorService>[0],
|
||||
{} as ConstructorParameters<typeof ProductEditorService>[1],
|
||||
) as unknown as {
|
||||
persistListingMedia: (tx: ReturnType<typeof mediaTransaction>, value: typeof input) => Promise<void>;
|
||||
};
|
||||
|
||||
it('当前商品的成功处理结果可加入 SKU 并保存', async () => {
|
||||
const tx = mediaTransaction(true);
|
||||
await service.persistListingMedia(tx, input);
|
||||
expect(tx.query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO atlas.product_variant_listing_media'))).toBe(true);
|
||||
});
|
||||
|
||||
it('其他商品或未成功的处理结果仍不能加入 SKU', async () => {
|
||||
const tx = mediaTransaction(false);
|
||||
await expect(service.persistListingMedia(tx, input)).rejects.toMatchObject({
|
||||
status: 422, response: { error: { code: 'LISTING_MEDIA_NOT_SELECTABLE' } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -132,6 +132,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
'DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.media_aspect_ratio_derivatives WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.product_image_imports WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
@@ -1985,6 +1989,79 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
assert.equal(privateImport.body.error.code, 'PRODUCT_IMAGE_IMPORT_INVALID');
|
||||
});
|
||||
|
||||
it('当前商品的 3:4 处理记录可重新加入 SKU 并保存', async () => {
|
||||
const before = await request(`/api/products/${first.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
const sourceAssetId = before.body.data.variants[0].media[0].asset_id as string;
|
||||
const outputAssetId = await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const output = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.media_assets
|
||||
(workspace_id, storage_provider, bucket, object_key, content_type,
|
||||
byte_size, checksum_sha256, width_px, height_px)
|
||||
VALUES ($1, 'test', 'test', $2, 'image/jpeg', 1024, $3, 1200, 1600)
|
||||
RETURNING id`,
|
||||
[workspaceId, `processed/${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').repeat(2)],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.media_aspect_ratio_derivatives
|
||||
(workspace_id, product_id, source_asset_id, output_asset_id, status, completed_at)
|
||||
VALUES ($1, $2, $3, $4, 'succeeded', now())`,
|
||||
[workspaceId, first.productId, sourceAssetId, output.rows[0].id],
|
||||
);
|
||||
return output.rows[0].id;
|
||||
});
|
||||
const snapshot = before.body.data;
|
||||
const payload = {
|
||||
ai_source_material: snapshot.source_material,
|
||||
ai_source_overridden_fields: snapshot.source_material_state.overridden_fields,
|
||||
common: { ...snapshot.product.common,
|
||||
default_physical_spec: snapshot.product.default_physical_spec,
|
||||
default_package_spec: snapshot.product.default_package_spec },
|
||||
profile: {
|
||||
target_store_id: snapshot.profile.target_store.id,
|
||||
fulfillment: snapshot.profile.fulfillment,
|
||||
title: snapshot.profile.title,
|
||||
description: snapshot.profile.description,
|
||||
tags: snapshot.profile.tags,
|
||||
platform_product_category_id: snapshot.profile.category.id,
|
||||
category_source: snapshot.profile.category.source,
|
||||
},
|
||||
attributes: snapshot.attributes.map((attribute: any) => ({
|
||||
scope: attribute.scope, variant_id: attribute.variant_id,
|
||||
platform_attribute_id: attribute.platform_attribute_id,
|
||||
complex_id: attribute.complex_id, values: attribute.values,
|
||||
language_code: attribute.language_code, source: attribute.source,
|
||||
confirmation_status: attribute.confirmation_status,
|
||||
})),
|
||||
variants: snapshot.variants.map((item: any) => ({
|
||||
id: item.id, client_key: null, copy_from_variant_id: null,
|
||||
display_name: item.display_name, source_sku: item.source_sku,
|
||||
specification_values: item.specification_values,
|
||||
purchase_cost_cny: item.purchase_cost_cny,
|
||||
tags: item.tags, description: item.description,
|
||||
physical_spec: item.physical_spec, package_spec: item.package_spec,
|
||||
offer_id: item.listing.offer_id,
|
||||
media_asset_ids: item.id === snapshot.variants[0].id
|
||||
? [outputAssetId] : item.media.map((media: any) => media.asset_id),
|
||||
})),
|
||||
};
|
||||
const saved = await request(`/api/products/${first.productId}/editor-draft`, {
|
||||
method: 'PUT',
|
||||
headers: headers({
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
'If-Match': `"${snapshot.product.revision}:${snapshot.profile.revision}"`,
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
assert.equal(saved.response.status, 200);
|
||||
const after = await request(`/api/products/${first.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
assert.equal(after.body.data.variants[0].media[0].asset_id, outputAssetId);
|
||||
});
|
||||
|
||||
it('独立保存采集描述会持久化覆盖值并保持幂等', async () => {
|
||||
const created = await createCollectionItem('独立保存描述测试商品');
|
||||
const entered = await request(
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ProductImageDerivativeHistoryService } from '../src/media/product-image-derivative-history.service.js';
|
||||
|
||||
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
|
||||
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
|
||||
describe('商品图片处理记录', () => {
|
||||
it('只读取当前商品成功的 3:4 与水印结果,并重新签发预览地址', async () => {
|
||||
const query = vi.fn(async (sql: string, values: unknown[]) => {
|
||||
expect(values).toEqual([productId]);
|
||||
if (sql.includes('FROM atlas.products')) return { rows: [{ id: productId }] };
|
||||
expect(sql).toContain("WHERE product_id = $1 AND status = 'succeeded'");
|
||||
expect(sql).toContain('LIMIT 100');
|
||||
return { rows: [
|
||||
{
|
||||
id: 'watermark-task', kind: 'watermark', source_asset_id: 'source-1',
|
||||
output_asset_id: 'output-1', created_at: new Date('2026-09-27T08:00:00Z'),
|
||||
content_type: 'image/jpeg', byte_size: '1200', width_px: 1200, height_px: 1600,
|
||||
asset_created_at: new Date('2026-09-27T08:01:00Z'),
|
||||
},
|
||||
{
|
||||
id: 'ratio-task', kind: 'aspect_ratio_3_4', source_asset_id: 'source-2',
|
||||
output_asset_id: 'output-2', created_at: new Date('2026-09-27T07:00:00Z'),
|
||||
content_type: 'image/jpeg', byte_size: '1100', width_px: 1200, height_px: 1600,
|
||||
asset_created_at: new Date('2026-09-27T07:01:00Z'),
|
||||
},
|
||||
] };
|
||||
});
|
||||
const workspaceTransaction = vi.fn(async (_id: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
|
||||
const accessUrl = vi.fn(async (_workspace: string, assetId: string) => ({
|
||||
url: `https://example.test/${assetId}`, expires_at: '2026-09-27T08:10:00Z',
|
||||
}));
|
||||
const service = new ProductImageDerivativeHistoryService(
|
||||
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageDerivativeHistoryService>[0],
|
||||
{ accessUrl } as unknown as ConstructorParameters<typeof ProductImageDerivativeHistoryService>[1],
|
||||
);
|
||||
const records = await service.list(workspaceId, productId);
|
||||
expect(workspaceTransaction).toHaveBeenCalledWith(workspaceId, expect.any(Function));
|
||||
expect(records).toMatchObject([
|
||||
{ kind: 'watermark', output_asset: { id: 'output-1', preview_url: 'https://example.test/output-1' } },
|
||||
{ kind: 'aspect_ratio_3_4', output_asset: { id: 'output-2', preview_url: 'https://example.test/output-2' } },
|
||||
]);
|
||||
expect(accessUrl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('商品不在当前工作空间时返回 404,不查询图片记录', async () => {
|
||||
const query = vi.fn(async () => ({ rows: [] }));
|
||||
const workspaceTransaction = vi.fn(async (_id: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
|
||||
const service = new ProductImageDerivativeHistoryService(
|
||||
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageDerivativeHistoryService>[0],
|
||||
{} as ConstructorParameters<typeof ProductImageDerivativeHistoryService>[1],
|
||||
);
|
||||
await expect(service.list(workspaceId, productId)).rejects.toMatchObject({ status: 404 });
|
||||
expect(query).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ProductImageTaskService } from '../src/media/product-image-task.service.js';
|
||||
|
||||
const workspaceId = '30cf54b7-06a3-4e5f-a5fc-d74165115094';
|
||||
const productId = '733d0d73-3d39-4a98-ac0a-5c0e521d8998';
|
||||
|
||||
describe('商品图片任务列表', () => {
|
||||
it('按工作空间和商品读取任务,结果只包含可公开的任务字段', async () => {
|
||||
const query = vi.fn(async (sql: string, values: unknown[]) => {
|
||||
expect(values[0]).toBe(productId);
|
||||
if (sql.includes('atlas.products')) return { rows: [{ id: productId }] };
|
||||
expect(values).toEqual([productId, 3]);
|
||||
return { rows: [{
|
||||
id: 'edit-id', status: 'running', provider_task_id: 'private-provider-id',
|
||||
output_asset_id: null, error_code: null,
|
||||
created_at: new Date('2026-09-26T03:00:00Z'), completed_at: null,
|
||||
}, {
|
||||
id: 'completed-id', status: 'succeeded', provider_task_id: 'private-provider-id',
|
||||
output_asset_id: 'asset-id', error_code: null,
|
||||
created_at: new Date('2026-09-26T02:00:00Z'), completed_at: new Date('2026-09-26T02:05:00Z'),
|
||||
}] };
|
||||
});
|
||||
const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
|
||||
const service = new ProductImageTaskService(
|
||||
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageTaskService>[0],
|
||||
);
|
||||
const result = await service.list(workspaceId, productId, { limit: 2 });
|
||||
expect(workspaceTransaction).toHaveBeenCalledWith(workspaceId, expect.any(Function));
|
||||
expect(result.meta).toEqual({ has_more: false, next_cursor: null });
|
||||
expect(result.data).toEqual([{
|
||||
id: 'edit-id', kind: 'single_ai_edit', status: 'running', phase: 'waiting_provider',
|
||||
total: 1, completed: 0, output_asset_ids: [], error_code: null, provider_error_raw: null,
|
||||
failure_source: null, failure_stage: null, failure_detail: null,
|
||||
created_at: '2026-09-26T03:00:00.000Z', completed_at: null,
|
||||
}, {
|
||||
id: 'completed-id', kind: 'single_ai_edit', status: 'succeeded', phase: null,
|
||||
total: 1, completed: 1, output_asset_ids: ['asset-id'], error_code: null, provider_error_raw: null,
|
||||
failure_source: null, failure_stage: null, failure_detail: null,
|
||||
created_at: '2026-09-26T02:00:00.000Z', completed_at: '2026-09-26T02:05:00.000Z',
|
||||
}]);
|
||||
expect(JSON.stringify(result)).not.toContain('private-provider-id');
|
||||
});
|
||||
|
||||
it('失败任务返回 Pcore 原始错误,不暴露供应商任务 ID', async () => {
|
||||
const raw = '{"message":"请求无法用于生成图像","type":"image_task_error","code":"failed"}';
|
||||
const query = vi.fn(async (sql: string) => {
|
||||
if (sql.includes('atlas.products')) return { rows: [{ id: productId }] };
|
||||
expect(sql).toContain('o.workspace_id = e.workspace_id');
|
||||
return { rows: [{
|
||||
id: 'failed-id', status: 'failed', provider_task_id: 'private-provider-id',
|
||||
output_asset_id: null, error_code: 'AI_IMAGE_PROVIDER_FAILED', provider_error_raw: raw,
|
||||
failure_source: 'pcore', failure_stage: 'provider_polling', failure_detail: 'Pcore 图片任务失败',
|
||||
created_at: new Date('2026-09-27T03:00:00Z'), completed_at: new Date('2026-09-27T03:01:00Z'),
|
||||
}] };
|
||||
});
|
||||
const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
|
||||
const service = new ProductImageTaskService(
|
||||
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageTaskService>[0],
|
||||
);
|
||||
const result = await service.list(workspaceId, productId, { limit: 20 });
|
||||
expect(result.data[0].provider_error_raw).toBe(raw);
|
||||
expect(result.data[0]).toMatchObject({
|
||||
failure_source: 'pcore', failure_stage: 'provider_polling', failure_detail: 'Pcore 图片任务失败',
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain('private-provider-id');
|
||||
});
|
||||
|
||||
it('商品不属于当前工作空间时返回 404,不查询任务', async () => {
|
||||
const query = vi.fn(async () => ({ rows: [] }));
|
||||
const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
|
||||
const service = new ProductImageTaskService(
|
||||
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageTaskService>[0],
|
||||
);
|
||||
await expect(service.list(workspaceId, productId, { limit: 20 })).rejects.toMatchObject({ status: 404 });
|
||||
expect(query).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('超过页大小时返回游标,并将游标作为下一页的稳定排序条件', async () => {
|
||||
const id = '68db226f-3fcb-4579-ab9e-4932b1d84067';
|
||||
const rows = [0, 1, 2].map((index) => ({
|
||||
id, status: 'succeeded', provider_task_id: null, output_asset_id: null,
|
||||
error_code: null, created_at: new Date(`2026-09-26T0${3 - index}:00:00Z`),
|
||||
completed_at: null,
|
||||
}));
|
||||
const query = vi.fn(async (sql: string, _values?: unknown[]) => ({ rows: sql.includes('atlas.products') ? [{ id: productId }] : rows }));
|
||||
const workspaceTransaction = vi.fn(async (_workspace: string, work: (tx: { query: typeof query }) => Promise<unknown>) => work({ query }));
|
||||
const service = new ProductImageTaskService(
|
||||
{ workspaceTransaction } as unknown as ConstructorParameters<typeof ProductImageTaskService>[0],
|
||||
);
|
||||
const first = await service.list(workspaceId, productId, { limit: 2 });
|
||||
expect(first.meta.has_more).toBe(true);
|
||||
expect(first.meta.next_cursor).toEqual(expect.any(String));
|
||||
await service.list(workspaceId, productId, { limit: 2, cursor: first.meta.next_cursor! });
|
||||
const nextQuery = query.mock.calls.at(-1)!;
|
||||
expect(nextQuery[0]).toContain('e.created_at, e.id)');
|
||||
expect(nextQuery[1]).toEqual([productId, 0, '2026-09-26T02:00:00.000Z', id, 3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { WatermarksService } from '../src/watermarks/watermarks.service.js';
|
||||
|
||||
const templateId = '46ea1f9c-28d0-4b32-92e1-3d4e74ad7757';
|
||||
|
||||
const templateRow = (isDefault: boolean) => ({
|
||||
id: templateId,
|
||||
name: '品牌水印',
|
||||
kind: 'text' as const,
|
||||
is_default: isDefault,
|
||||
current_version: 1,
|
||||
created_at: new Date('2026-09-25T00:00:00Z'),
|
||||
updated_at: new Date('2026-09-25T00:00:00Z'),
|
||||
revision: 1,
|
||||
text_content: 'Atlas',
|
||||
image_asset_id: null,
|
||||
font_code: 'NotoSans',
|
||||
font_size_px: 15,
|
||||
opacity: '0.2600',
|
||||
scale_ratio: null,
|
||||
image_size_px: null,
|
||||
border_radius_px: 0,
|
||||
position: 'center',
|
||||
position_x: '0.5000',
|
||||
position_y: '0.5000',
|
||||
});
|
||||
|
||||
describe('工作空间默认水印', () => {
|
||||
it('切换默认后返回新状态,重复设置不会再写入', async () => {
|
||||
let isDefault = false;
|
||||
const statements: string[] = [];
|
||||
const query = vi.fn(async (sql: string) => {
|
||||
statements.push(sql);
|
||||
if (sql.includes('SELECT id FROM atlas.workspaces')) return { rows: [{ id: 'workspace' }] };
|
||||
if (sql.includes('SELECT template.id')) return { rows: [templateRow(isDefault)] };
|
||||
if (sql.includes('SET is_default = false')) return { rows: [] };
|
||||
if (sql.includes('SET is_default = true')) {
|
||||
isDefault = true;
|
||||
return { rows: [] };
|
||||
}
|
||||
throw new Error(`未知 SQL: ${sql}`);
|
||||
});
|
||||
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
|
||||
work({ query }));
|
||||
const service = new WatermarksService(
|
||||
{ run } as unknown as ConstructorParameters<typeof WatermarksService>[0],
|
||||
{} as ConstructorParameters<typeof WatermarksService>[1],
|
||||
);
|
||||
|
||||
const first = await service.setDefault('workspace', 'user', templateId, 'key-1');
|
||||
expect(first).toMatchObject({ status: 200, body: { data: { id: templateId, is_default: true } } });
|
||||
expect(statements.filter((sql) => sql.includes('SET is_default'))).toHaveLength(2);
|
||||
expect(statements[0]).toContain('FOR UPDATE');
|
||||
|
||||
const second = await service.setDefault('workspace', 'user', templateId, 'key-2');
|
||||
expect(second).toMatchObject({ status: 200, body: { data: { is_default: true } } });
|
||||
expect(statements.filter((sql) => sql.includes('SET is_default'))).toHaveLength(2);
|
||||
expect(run).toHaveBeenCalledWith(expect.objectContaining({
|
||||
method: 'PUT',
|
||||
path: `/api/watermark-templates/${templateId}/default`,
|
||||
}), expect.any(Function));
|
||||
});
|
||||
|
||||
it('未设置默认时返回 null', async () => {
|
||||
const query = vi.fn(async () => ({ rows: [] }));
|
||||
const workspaceTransaction = vi.fn(async (_workspaceId: string, work: (tx: { query: typeof query }) => Promise<unknown>) =>
|
||||
work({ query }));
|
||||
const service = new WatermarksService(
|
||||
{} as ConstructorParameters<typeof WatermarksService>[0],
|
||||
{ workspaceTransaction } as unknown as ConstructorParameters<typeof WatermarksService>[1],
|
||||
);
|
||||
|
||||
expect(await service.getDefault('workspace')).toBeNull();
|
||||
expect(query.mock.calls).toHaveLength(1);
|
||||
expect(workspaceTransaction).toHaveBeenCalledWith('workspace', expect.any(Function));
|
||||
});
|
||||
|
||||
it('删除默认模板时同时清除默认标记', async () => {
|
||||
const query = vi.fn(async (sql: string) => {
|
||||
if (sql.includes('SELECT template.id')) return { rows: [templateRow(true)] };
|
||||
return { rows: [] };
|
||||
});
|
||||
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
|
||||
work({ query }));
|
||||
const service = new WatermarksService(
|
||||
{ run } as unknown as ConstructorParameters<typeof WatermarksService>[0],
|
||||
{} as ConstructorParameters<typeof WatermarksService>[1],
|
||||
);
|
||||
|
||||
expect(await service.remove('workspace', 'user', templateId, 'key')).toEqual({ status: 204, body: null });
|
||||
expect(query.mock.calls.some(([sql]) => sql.includes("status = 'archived'")
|
||||
&& sql.includes('is_default = false'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createWatermarkTemplateRequestSchema } from '@atlas/contracts';
|
||||
import { WatermarksService } from '../src/watermarks/watermarks.service.js';
|
||||
|
||||
const templateId = '46ea1f9c-28d0-4b32-92e1-3d4e74ad7757';
|
||||
const assetId = '54799515-028d-4320-80a2-f53fd13fb588';
|
||||
|
||||
const imageRow = (size: number, radius: number, version = 1) => ({
|
||||
id: templateId,
|
||||
name: '图片水印',
|
||||
kind: 'image' as const,
|
||||
current_version: version,
|
||||
created_at: new Date('2026-09-26T00:00:00Z'),
|
||||
updated_at: new Date('2026-09-26T00:00:00Z'),
|
||||
revision: version,
|
||||
text_content: null,
|
||||
image_asset_id: assetId,
|
||||
font_code: null,
|
||||
font_size_px: null,
|
||||
opacity: '0.2600',
|
||||
scale_ratio: '0.5556',
|
||||
image_size_px: size,
|
||||
border_radius_px: radius,
|
||||
position: 'bottom_right',
|
||||
position_x: '1.0000',
|
||||
position_y: '1.0000',
|
||||
});
|
||||
|
||||
const serviceWith = (query: ReturnType<typeof vi.fn>) => {
|
||||
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
|
||||
work({ query }));
|
||||
return new WatermarksService(
|
||||
{ run } as unknown as ConstructorParameters<typeof WatermarksService>[0],
|
||||
{} as ConstructorParameters<typeof WatermarksService>[1],
|
||||
);
|
||||
};
|
||||
|
||||
describe('图片水印像素尺寸和圆角', () => {
|
||||
it('创建契约接收像素尺寸并约束尺寸和圆角范围,同时兼容旧比例', () => {
|
||||
const base = {
|
||||
name: '图片水印', kind: 'image',
|
||||
config: { image_asset_id: assetId, opacity: '0.26', position_x: 1, position_y: 1 },
|
||||
};
|
||||
expect(createWatermarkTemplateRequestSchema.safeParse({
|
||||
...base, config: { ...base.config, image_size_px: 80, border_radius_px: 12 },
|
||||
}).success).toBe(true);
|
||||
expect(createWatermarkTemplateRequestSchema.safeParse({
|
||||
...base, config: { ...base.config, image_size_px: 10 },
|
||||
}).success).toBe(true);
|
||||
expect(createWatermarkTemplateRequestSchema.safeParse({
|
||||
...base, config: { ...base.config, scale_ratio: '0.25' },
|
||||
}).success).toBe(true);
|
||||
expect(createWatermarkTemplateRequestSchema.safeParse(base).success).toBe(false);
|
||||
expect(createWatermarkTemplateRequestSchema.safeParse({
|
||||
...base, config: { ...base.config, image_size_px: 9 },
|
||||
}).success).toBe(false);
|
||||
expect(createWatermarkTemplateRequestSchema.safeParse({
|
||||
...base, config: { ...base.config, image_size_px: 201 },
|
||||
}).success).toBe(false);
|
||||
expect(createWatermarkTemplateRequestSchema.safeParse({
|
||||
...base, config: { ...base.config, image_size_px: 80, border_radius_px: 101 },
|
||||
}).success).toBe(false);
|
||||
});
|
||||
|
||||
it('创建时保存绝对尺寸和圆角,并保留旧比例字段', async () => {
|
||||
let inserted: unknown[] = [];
|
||||
const query = vi.fn(async (sql: string, args?: unknown[]) => {
|
||||
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: assetId }] };
|
||||
if (sql.includes('INSERT INTO atlas.watermark_templates')) return { rows: [{ id: templateId }] };
|
||||
if (sql.includes('INSERT INTO atlas.watermark_template_versions')) {
|
||||
inserted = args ?? [];
|
||||
return { rows: [] };
|
||||
}
|
||||
if (sql.includes('SELECT template.id')) return { rows: [imageRow(Number(inserted[13]), Number(inserted[14]))] };
|
||||
throw new Error(`未知 SQL: ${sql}`);
|
||||
});
|
||||
const result = await serviceWith(query).create('workspace', 'user', 'key', {
|
||||
name: '图片水印', kind: 'image',
|
||||
config: { image_asset_id: assetId, image_size_px: 80, border_radius_px: 12,
|
||||
opacity: '0.26', position_x: 1, position_y: 1 },
|
||||
});
|
||||
expect(inserted[8]).toBe('0.5556');
|
||||
expect(inserted.slice(13, 15)).toEqual([80, 12]);
|
||||
expect(result).toMatchObject({ body: { data: { config: {
|
||||
image_size_px: 80, border_radius_px: 12,
|
||||
} } } });
|
||||
});
|
||||
|
||||
it('旧版比例输入换算为像素尺寸,圆角默认为零', async () => {
|
||||
let inserted: unknown[] = [];
|
||||
const query = vi.fn(async (sql: string, args?: unknown[]) => {
|
||||
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: assetId }] };
|
||||
if (sql.includes('INSERT INTO atlas.watermark_templates')) return { rows: [{ id: templateId }] };
|
||||
if (sql.includes('INSERT INTO atlas.watermark_template_versions')) {
|
||||
inserted = args ?? [];
|
||||
return { rows: [] };
|
||||
}
|
||||
if (sql.includes('SELECT template.id')) return { rows: [imageRow(36, 0)] };
|
||||
throw new Error(`未知 SQL: ${sql}`);
|
||||
});
|
||||
await serviceWith(query).create('workspace', 'user', 'key', {
|
||||
name: '图片水印', kind: 'image',
|
||||
config: { image_asset_id: assetId, scale_ratio: '0.25',
|
||||
opacity: '0.26', position_x: 1, position_y: 1 },
|
||||
});
|
||||
expect(inserted[8]).toBe('0.25');
|
||||
expect(inserted.slice(13, 15)).toEqual([36, 0]);
|
||||
});
|
||||
|
||||
it('10px 尺寸原值保存,兼容比例保持旧版允许的最小值', async () => {
|
||||
let inserted: unknown[] = [];
|
||||
const query = vi.fn(async (sql: string, args?: unknown[]) => {
|
||||
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: assetId }] };
|
||||
if (sql.includes('INSERT INTO atlas.watermark_templates')) return { rows: [{ id: templateId }] };
|
||||
if (sql.includes('INSERT INTO atlas.watermark_template_versions')) {
|
||||
inserted = args ?? [];
|
||||
return { rows: [] };
|
||||
}
|
||||
if (sql.includes('SELECT template.id')) return { rows: [imageRow(10, 0)] };
|
||||
throw new Error(`未知 SQL: ${sql}`);
|
||||
});
|
||||
await serviceWith(query).create('workspace', 'user', 'key', {
|
||||
name: '图片水印', kind: 'image',
|
||||
config: { image_asset_id: assetId, image_size_px: 10,
|
||||
opacity: '0.26', position_x: 1, position_y: 1 },
|
||||
});
|
||||
expect(inserted[8]).toBe('0.1');
|
||||
expect(inserted[13]).toBe(10);
|
||||
});
|
||||
|
||||
it('只修改圆角时保留图片尺寸并创建新版本', async () => {
|
||||
let inserted: unknown[] = [];
|
||||
const query = vi.fn(async (sql: string, args?: unknown[]) => {
|
||||
if (sql.includes('SELECT template.id') && sql.includes('FOR UPDATE')) return { rows: [imageRow(80, 0)] };
|
||||
if (sql.includes('SELECT id FROM atlas.media_assets')) return { rows: [{ id: assetId }] };
|
||||
if (sql.includes('UPDATE atlas.watermark_templates')) return { rows: [] };
|
||||
if (sql.includes('INSERT INTO atlas.watermark_template_versions')) {
|
||||
inserted = args ?? [];
|
||||
return { rows: [] };
|
||||
}
|
||||
if (sql.includes('SELECT template.id')) return { rows: [imageRow(80, 16, 2)] };
|
||||
throw new Error(`未知 SQL: ${sql}`);
|
||||
});
|
||||
const result = await serviceWith(query).update(
|
||||
'workspace', 'user', templateId, 'key', 1, { config: { border_radius_px: 16 } },
|
||||
);
|
||||
expect(inserted[13]).toBe(80);
|
||||
expect(inserted[14]).toBe(16);
|
||||
expect(result).toMatchObject({ body: { data: { current_version: 2, config: {
|
||||
image_size_px: 80, border_radius_px: 16,
|
||||
} } } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createWatermarkTemplateRequestSchema } from '@atlas/contracts';
|
||||
import { WatermarksService } from '../src/watermarks/watermarks.service.js';
|
||||
|
||||
const templateId = '46ea1f9c-28d0-4b32-92e1-3d4e74ad7757';
|
||||
|
||||
const templateRow = (x: number, y: number, position: string) => ({
|
||||
id: templateId,
|
||||
name: '测试水印',
|
||||
kind: 'text' as const,
|
||||
current_version: 1,
|
||||
created_at: new Date('2026-09-25T00:00:00Z'),
|
||||
updated_at: new Date('2026-09-25T00:00:00Z'),
|
||||
revision: 1,
|
||||
text_content: 'Atlas',
|
||||
image_asset_id: null,
|
||||
font_code: 'NotoSans',
|
||||
font_size_px: 15,
|
||||
opacity: '0.2600',
|
||||
scale_ratio: null,
|
||||
position,
|
||||
position_x: x.toFixed(4),
|
||||
position_y: y.toFixed(4),
|
||||
});
|
||||
|
||||
describe('水印任意位置坐标', () => {
|
||||
it('契约接收精确坐标、兼容旧位置,并拒绝创建时只给一个坐标', () => {
|
||||
const base = {
|
||||
name: '测试水印',
|
||||
kind: 'text',
|
||||
config: {
|
||||
text_content: 'Atlas',
|
||||
font_code: 'NotoSans',
|
||||
font_size_px: 15,
|
||||
opacity: '0.26',
|
||||
},
|
||||
};
|
||||
expect(createWatermarkTemplateRequestSchema.safeParse({
|
||||
...base, config: { ...base.config, position_x: 0.3737, position_y: 0.6161 },
|
||||
}).success).toBe(true);
|
||||
expect(createWatermarkTemplateRequestSchema.safeParse({
|
||||
...base, config: { ...base.config, position: 'bottom_right' },
|
||||
}).success).toBe(true);
|
||||
expect(createWatermarkTemplateRequestSchema.safeParse({
|
||||
...base, config: { ...base.config, position_x: 0.3737 },
|
||||
}).success).toBe(false);
|
||||
});
|
||||
|
||||
it('创建时保存精确坐标并在读取响应中返回,不吸附九宫格', async () => {
|
||||
let insertedVersion: unknown[] = [];
|
||||
const query = vi.fn(async (sql: string, args?: unknown[]) => {
|
||||
if (sql.includes('SELECT code FROM atlas.watermark_fonts')) return { rows: [{ code: 'NotoSans' }] };
|
||||
if (sql.includes('INSERT INTO atlas.watermark_templates')) return { rows: [{ id: templateId }] };
|
||||
if (sql.includes('INSERT INTO atlas.watermark_template_versions')) {
|
||||
insertedVersion = args ?? [];
|
||||
return { rows: [] };
|
||||
}
|
||||
if (sql.includes('SELECT template.id')) return { rows: [templateRow(
|
||||
Number(insertedVersion[9]), Number(insertedVersion[10]), String(insertedVersion[11]),
|
||||
)] };
|
||||
throw new Error(`未知 SQL: ${sql}`);
|
||||
});
|
||||
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
|
||||
work({ query }));
|
||||
const service = new WatermarksService(
|
||||
{ run } as unknown as ConstructorParameters<typeof WatermarksService>[0],
|
||||
{} as ConstructorParameters<typeof WatermarksService>[1],
|
||||
);
|
||||
const result = await service.create('workspace', 'user', 'key', {
|
||||
name: '测试水印', kind: 'text',
|
||||
config: {
|
||||
text_content: 'Atlas', font_code: 'NotoSans', font_size_px: 15,
|
||||
opacity: '0.26', position_x: 0.3737, position_y: 0.6161,
|
||||
},
|
||||
});
|
||||
expect(insertedVersion.slice(9, 12)).toEqual([0.3737, 0.6161, 'center']);
|
||||
expect(result).toMatchObject({
|
||||
body: { data: { config: { position_x: 0.3737, position_y: 0.6161 } } },
|
||||
});
|
||||
});
|
||||
|
||||
it('旧版九宫格输入换算为精确坐标', async () => {
|
||||
let insertedVersion: unknown[] = [];
|
||||
const query = vi.fn(async (sql: string, args?: unknown[]) => {
|
||||
if (sql.includes('SELECT code FROM atlas.watermark_fonts')) return { rows: [{ code: 'NotoSans' }] };
|
||||
if (sql.includes('INSERT INTO atlas.watermark_templates')) return { rows: [{ id: templateId }] };
|
||||
if (sql.includes('INSERT INTO atlas.watermark_template_versions')) {
|
||||
insertedVersion = args ?? [];
|
||||
return { rows: [] };
|
||||
}
|
||||
if (sql.includes('SELECT template.id')) return { rows: [templateRow(1, 0, 'top_right')] };
|
||||
throw new Error(`未知 SQL: ${sql}`);
|
||||
});
|
||||
const run = vi.fn(async (_input: unknown, work: (tx: { query: typeof query }) => Promise<unknown>) =>
|
||||
work({ query }));
|
||||
const service = new WatermarksService(
|
||||
{ run } as unknown as ConstructorParameters<typeof WatermarksService>[0],
|
||||
{} as ConstructorParameters<typeof WatermarksService>[1],
|
||||
);
|
||||
await service.create('workspace', 'user', 'key', {
|
||||
name: '测试水印', kind: 'text',
|
||||
config: {
|
||||
text_content: 'Atlas', font_code: 'NotoSans', font_size_px: 15,
|
||||
opacity: '0.26', position: 'top_right',
|
||||
},
|
||||
});
|
||||
expect(insertedVersion.slice(9, 12)).toEqual([1, 0, 'top_right']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user