1146 lines
47 KiB
TypeScript
1146 lines
47 KiB
TypeScript
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 = '';
|
||
let platformId = '';
|
||
let first: { collectionItemId: string; productId: string; profileId: string };
|
||
const seededCategoryIds: string[] = [];
|
||
const seededDictionaryIds: string[] = [];
|
||
|
||
async function request(path: string, init: RequestInit = {}) {
|
||
const response = await fetch(`${baseUrl}${path}`, {
|
||
...init,
|
||
headers: { 'Content-Type': 'application/json', ...init.headers },
|
||
});
|
||
return {
|
||
response,
|
||
body:
|
||
response.status === 204
|
||
? undefined
|
||
: ((await response.json()) as any),
|
||
};
|
||
}
|
||
|
||
function headers(extra: Record<string, string> = {}) {
|
||
return { Authorization: `Bearer ${token}`, ...extra };
|
||
}
|
||
|
||
async function createCollectionItem(title: string) {
|
||
const created = await request('/api/collection-items', {
|
||
method: 'POST',
|
||
headers: headers({ 'Idempotency-Key': randomUUID() }),
|
||
body: JSON.stringify({ title }),
|
||
});
|
||
assert.equal(created.response.status, 201);
|
||
return {
|
||
collectionItemId: created.body.data.collection_item.id as string,
|
||
productId: created.body.data.product_id as string,
|
||
};
|
||
}
|
||
|
||
describe('商品编辑 M3.0–M3.3 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 registration = await request('/api/auth/register', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
email: `editor-${randomUUID()}@example.test`,
|
||
password: 'correct-horse-battery-staple-123',
|
||
password_confirmation: 'correct-horse-battery-staple-123',
|
||
}),
|
||
});
|
||
assert.equal(registration.response.status, 201);
|
||
userId = registration.body.data.user.id;
|
||
workspaceId = registration.body.data.workspace.id;
|
||
token = registration.body.data.access_token;
|
||
const platforms = await request('/api/platforms', {
|
||
headers: headers(),
|
||
});
|
||
platformId = platforms.body.data[0].id;
|
||
});
|
||
|
||
afterAll(async () => {
|
||
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.product_unmapped_attributes WHERE workspace_id = $1',
|
||
[workspaceId],
|
||
);
|
||
await tx.query(
|
||
'DELETE FROM atlas.product_attributes WHERE workspace_id = $1',
|
||
[workspaceId],
|
||
);
|
||
await tx.query(
|
||
'DELETE FROM atlas.product_variant_platform_listings WHERE workspace_id = $1',
|
||
[workspaceId],
|
||
);
|
||
await tx.query(
|
||
'DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1',
|
||
[workspaceId],
|
||
);
|
||
await tx.query(
|
||
'DELETE FROM atlas.product_variants WHERE workspace_id = $1',
|
||
[workspaceId],
|
||
);
|
||
await tx.query(
|
||
'DELETE FROM atlas.product_platform_profiles 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 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 (seededDictionaryIds.length) {
|
||
await database.query(
|
||
`DELETE FROM atlas.platform_attribute_dictionary_values
|
||
WHERE platform_id = $1 AND dictionary_id = ANY($2::text[])`,
|
||
[platformId, seededDictionaryIds],
|
||
);
|
||
}
|
||
if (seededCategoryIds.length) {
|
||
await database.query(
|
||
'DELETE FROM atlas.platform_product_categories WHERE id = ANY($1::uuid[])',
|
||
[seededCategoryIds],
|
||
);
|
||
}
|
||
await app.close();
|
||
});
|
||
|
||
it('仅在存在已验证默认店铺时创建平台编辑上下文,并返回稳定读模型', async () => {
|
||
const created = await createCollectionItem('编辑器测试商品 A');
|
||
const missingStore = await request(
|
||
`/api/collection-items/${created.collectionItemId}/editor-contexts`,
|
||
{
|
||
method: 'POST',
|
||
headers: headers({
|
||
'X-Atlas-Platform-Id': platformId,
|
||
'Idempotency-Key': randomUUID(),
|
||
}),
|
||
},
|
||
);
|
||
assert.equal(missingStore.response.status, 422);
|
||
assert.equal(missingStore.body.error.code, 'DEFAULT_STORE_REQUIRED');
|
||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||
await tx.query(
|
||
`INSERT INTO atlas.stores
|
||
(workspace_id, platform_id, name, platform_client_id, currency_code, is_default, status, credential_status)
|
||
VALUES (current_setting('app.workspace_id')::uuid, $1, 'M3 测试店铺', $2, 'RUB', true, 'active', 'valid')`,
|
||
[platformId, `editor-${randomUUID()}`],
|
||
);
|
||
});
|
||
const entered = await request(
|
||
`/api/collection-items/${created.collectionItemId}/editor-contexts`,
|
||
{
|
||
method: 'POST',
|
||
headers: headers({
|
||
'X-Atlas-Platform-Id': platformId,
|
||
'Idempotency-Key': randomUUID(),
|
||
}),
|
||
},
|
||
);
|
||
assert.equal(entered.response.status, 201);
|
||
first = {
|
||
collectionItemId: created.collectionItemId,
|
||
productId: created.productId,
|
||
profileId: entered.body.data.profile_id,
|
||
};
|
||
const enteredAgain = await request(
|
||
`/api/collection-items/${created.collectionItemId}/editor-contexts`,
|
||
{
|
||
method: 'POST',
|
||
headers: headers({
|
||
'X-Atlas-Platform-Id': platformId,
|
||
'Idempotency-Key': randomUUID(),
|
||
}),
|
||
},
|
||
);
|
||
assert.equal(enteredAgain.response.status, 200);
|
||
assert.equal(enteredAgain.body.data.profile_id, first.profileId);
|
||
const editor = await request(
|
||
`/api/products/${first.productId}/editor`,
|
||
{
|
||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||
},
|
||
);
|
||
assert.equal(editor.response.status, 200);
|
||
assert.equal(editor.body.data.product.id, first.productId);
|
||
assert.equal(editor.body.data.profile.id, first.profileId);
|
||
assert.equal(editor.body.data.variants.length, 1);
|
||
assert.equal(editor.body.data.variants[0].listing.status, 'not_listed');
|
||
});
|
||
|
||
it('数据库拒绝跨商品 SKU 与 profile 的关联,并保留未映射属性草稿', async () => {
|
||
const second = await createCollectionItem('编辑器测试商品 B');
|
||
const secondContext = await request(
|
||
`/api/collection-items/${second.collectionItemId}/editor-contexts`,
|
||
{
|
||
method: 'POST',
|
||
headers: headers({
|
||
'X-Atlas-Platform-Id': platformId,
|
||
'Idempotency-Key': randomUUID(),
|
||
}),
|
||
},
|
||
);
|
||
assert.equal(secondContext.response.status, 201);
|
||
const link = await database.workspaceTransaction(
|
||
workspaceId,
|
||
async (tx) => {
|
||
const secondVariant = await tx.query<{ id: string }>(
|
||
'SELECT id FROM atlas.product_variants WHERE product_id = $1 AND deleted_at IS NULL',
|
||
[second.productId],
|
||
);
|
||
const firstProfile = await tx.query<{
|
||
target_store_id: string;
|
||
}>(
|
||
'SELECT target_store_id FROM atlas.product_platform_profiles WHERE id = $1',
|
||
[first.profileId],
|
||
);
|
||
return {
|
||
variantId: secondVariant.rows[0].id,
|
||
storeId: firstProfile.rows[0].target_store_id,
|
||
};
|
||
},
|
||
);
|
||
await assert.rejects(
|
||
database.workspaceTransaction(workspaceId, async (tx) =>
|
||
tx.query(
|
||
`INSERT INTO atlas.product_variant_platform_listings
|
||
(workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id)
|
||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5)`,
|
||
[
|
||
second.productId,
|
||
first.profileId,
|
||
platformId,
|
||
link.variantId,
|
||
link.storeId,
|
||
],
|
||
),
|
||
),
|
||
{ code: '23503' },
|
||
);
|
||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||
await tx.query(
|
||
`INSERT INTO atlas.product_unmapped_attributes
|
||
(workspace_id, product_id, profile_id, platform_id, original_platform_attribute_id,
|
||
original_name, values, reason)
|
||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, 'legacy-material',
|
||
'旧类目材质', '["棉"]'::jsonb, 'category_changed')`,
|
||
[first.productId, first.profileId, platformId],
|
||
);
|
||
});
|
||
const editor = await request(
|
||
`/api/products/${first.productId}/editor`,
|
||
{
|
||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||
},
|
||
);
|
||
assert.equal(editor.response.status, 200);
|
||
assert.equal(editor.body.data.unmapped_attributes.length, 1);
|
||
assert.equal(
|
||
editor.body.data.unmapped_attributes[0].original_name,
|
||
'旧类目材质',
|
||
);
|
||
assert.equal(
|
||
editor.body.data.warnings[0].code,
|
||
'UNMAPPED_ATTRIBUTES_PENDING',
|
||
);
|
||
});
|
||
|
||
it('原子保存草稿会更新双版本、创建 SKU,并拒绝过期版本覆盖', async () => {
|
||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||
await tx.query(
|
||
`UPDATE atlas.collection_items
|
||
SET normalized_payload = $1::jsonb, raw_evidence = '{"kept":true}'::jsonb,
|
||
source_snapshot_revision = 3
|
||
WHERE id = $2`,
|
||
[
|
||
JSON.stringify({
|
||
common: {
|
||
title: { value: '采集原始标题' },
|
||
description: { value: '采集原始描述' },
|
||
sourcePrice: {
|
||
amount: '79.90',
|
||
currencyCode: 'CNY',
|
||
},
|
||
attributes: [
|
||
{
|
||
key: 'brand',
|
||
name: '品牌',
|
||
value: '来源品牌',
|
||
},
|
||
{
|
||
key: 'material',
|
||
name: '材质',
|
||
value: '棉',
|
||
},
|
||
],
|
||
categories: [{ key: 'bags', name: '箱包' }],
|
||
},
|
||
}),
|
||
first.collectionItemId,
|
||
],
|
||
);
|
||
});
|
||
const before = await request(
|
||
`/api/products/${first.productId}/editor`,
|
||
{
|
||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||
},
|
||
);
|
||
assert.equal(
|
||
before.body.data.collection_item_id,
|
||
first.collectionItemId,
|
||
);
|
||
assert.equal(before.body.data.source_material.title, '采集原始标题');
|
||
assert.equal(before.body.data.source_material.brand, '来源品牌');
|
||
assert.equal(
|
||
before.body.data.source_material.source_snapshot_revision,
|
||
3,
|
||
);
|
||
assert.deepEqual(
|
||
before.body.data.source_material_state.overridden_fields,
|
||
[],
|
||
);
|
||
const existing = before.body.data.variants[0];
|
||
const payload = {
|
||
ai_source_material: {
|
||
...before.body.data.source_material,
|
||
title: '用户整理后的采集标题',
|
||
description: '用户整理后的采集描述',
|
||
},
|
||
ai_source_overridden_fields: ['title', 'description'],
|
||
common: {
|
||
source_title: '已由编辑器确认的商品名',
|
||
brand: 'Atlas',
|
||
model: null,
|
||
barcode: null,
|
||
purchase_url: null,
|
||
supply_note: 'M3.1 集成测试',
|
||
default_physical_spec: {
|
||
length_mm: '100',
|
||
width_mm: '50',
|
||
height_mm: '20',
|
||
net_weight_g: '300',
|
||
status: 'confirmed',
|
||
},
|
||
default_package_spec: {
|
||
length_mm: '120',
|
||
width_mm: '60',
|
||
height_mm: '30',
|
||
shipping_weight_g: '400',
|
||
status: 'confirmed',
|
||
},
|
||
},
|
||
profile: {
|
||
target_store_id: before.body.data.profile.target_store.id,
|
||
fulfillment: 'rfbs',
|
||
title: 'Ozon 测试标题',
|
||
description: null,
|
||
tags: ['测试', '草稿'],
|
||
},
|
||
variants: [
|
||
{
|
||
id: existing.id,
|
||
client_key: null,
|
||
copy_from_variant_id: null,
|
||
display_name: '默认 SKU(已编辑)',
|
||
source_sku: existing.source_sku,
|
||
specification_values: [
|
||
{
|
||
axis_key: 'color',
|
||
value_key: 'black',
|
||
value_name: '黑色',
|
||
},
|
||
],
|
||
purchase_cost_cny: '88.50',
|
||
physical_spec: {
|
||
length_mm: null,
|
||
width_mm: null,
|
||
height_mm: null,
|
||
net_weight_g: null,
|
||
status: null,
|
||
},
|
||
package_spec: {
|
||
length_mm: null,
|
||
width_mm: null,
|
||
height_mm: null,
|
||
shipping_weight_g: null,
|
||
status: null,
|
||
},
|
||
offer_id: 'm3-variant-a',
|
||
},
|
||
{
|
||
id: null,
|
||
client_key: 'new-variant-b',
|
||
copy_from_variant_id: existing.id,
|
||
display_name: '新增 SKU',
|
||
source_sku: null,
|
||
specification_values: [],
|
||
purchase_cost_cny: '99.00',
|
||
physical_spec: {
|
||
length_mm: null,
|
||
width_mm: null,
|
||
height_mm: null,
|
||
net_weight_g: null,
|
||
status: null,
|
||
},
|
||
package_spec: {
|
||
length_mm: null,
|
||
width_mm: null,
|
||
height_mm: null,
|
||
shipping_weight_g: null,
|
||
status: null,
|
||
},
|
||
offer_id: 'm3-variant-b',
|
||
},
|
||
],
|
||
};
|
||
const key = randomUUID();
|
||
const saved = await request(
|
||
`/api/products/${first.productId}/editor-draft`,
|
||
{
|
||
method: 'PUT',
|
||
headers: headers({
|
||
'X-Atlas-Platform-Id': platformId,
|
||
'Idempotency-Key': key,
|
||
'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`,
|
||
}),
|
||
body: JSON.stringify(payload),
|
||
},
|
||
);
|
||
assert.equal(saved.response.status, 200);
|
||
assert.equal(saved.body.data.created_variants.length, 1);
|
||
assert.equal(
|
||
saved.body.data.product_revision,
|
||
before.body.data.product.revision + 1,
|
||
);
|
||
assert.equal(
|
||
saved.body.data.profile_revision,
|
||
before.body.data.profile.revision + 1,
|
||
);
|
||
const replay = await request(
|
||
`/api/products/${first.productId}/editor-draft`,
|
||
{
|
||
method: 'PUT',
|
||
headers: headers({
|
||
'X-Atlas-Platform-Id': platformId,
|
||
'Idempotency-Key': key,
|
||
'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`,
|
||
}),
|
||
body: JSON.stringify(payload),
|
||
},
|
||
);
|
||
assert.deepEqual(replay.body, saved.body);
|
||
const after = await request(`/api/products/${first.productId}/editor`, {
|
||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||
});
|
||
assert.equal(
|
||
after.body.data.product.common.source_title,
|
||
'已由编辑器确认的商品名',
|
||
);
|
||
assert.equal(after.body.data.profile.title, 'Ozon 测试标题');
|
||
assert.equal(
|
||
after.body.data.source_material.title,
|
||
'用户整理后的采集标题',
|
||
);
|
||
assert.equal(
|
||
after.body.data.ai_input.source_material.description,
|
||
'用户整理后的采集描述',
|
||
);
|
||
assert.deepEqual(
|
||
after.body.data.source_material_state.overridden_fields,
|
||
['title', 'description'],
|
||
);
|
||
assert.equal(
|
||
after.body.data.ai_input.default_physical_spec.net_weight_g,
|
||
'300.000',
|
||
);
|
||
assert.equal(
|
||
after.body.data.ai_input.default_package_spec.shipping_weight_g,
|
||
'400.000',
|
||
);
|
||
assert.equal(
|
||
after.body.data.ai_input.variants[0].effective_package_spec
|
||
.shipping_weight_g,
|
||
'400.000',
|
||
);
|
||
assert.equal(after.body.data.variants.length, 2);
|
||
assert.equal(
|
||
after.body.data.variants[0].listing.offer_id,
|
||
'm3-variant-a',
|
||
);
|
||
assert.equal(
|
||
after.body.data.product.default_package_spec.shipping_weight_g,
|
||
'400.000',
|
||
);
|
||
assert.equal(
|
||
after.body.data.variants[0].effective_package_spec
|
||
.shipping_weight_g,
|
||
'400.000',
|
||
);
|
||
assert.deepEqual(after.body.data.variants[1].specification_values, [
|
||
{ axis_key: 'color', value_key: 'black', value_name: '黑色' },
|
||
]);
|
||
const evidence = await database.workspaceTransaction(
|
||
workspaceId,
|
||
async (tx) =>
|
||
tx.query<{
|
||
normalized_payload: any;
|
||
raw_evidence: any;
|
||
}>(
|
||
'SELECT normalized_payload, raw_evidence FROM atlas.collection_items WHERE id = $1',
|
||
[first.collectionItemId],
|
||
),
|
||
);
|
||
assert.equal(
|
||
evidence.rows[0].normalized_payload.common.title.value,
|
||
'采集原始标题',
|
||
);
|
||
assert.deepEqual(evidence.rows[0].raw_evidence, { kept: true });
|
||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||
await tx.query(
|
||
`UPDATE atlas.collection_items
|
||
SET normalized_payload = jsonb_set(
|
||
jsonb_set(
|
||
normalized_payload,
|
||
'{common,title,value}',
|
||
'"复采后的来源标题"'::jsonb
|
||
),
|
||
'{common,sourcePrice,amount}',
|
||
'"89.90"'::jsonb
|
||
),
|
||
source_snapshot_revision = 4
|
||
WHERE id = $1`,
|
||
[first.collectionItemId],
|
||
);
|
||
});
|
||
const afterRefresh = await request(
|
||
`/api/products/${first.productId}/editor`,
|
||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||
);
|
||
assert.equal(
|
||
afterRefresh.body.data.source_material.title,
|
||
'用户整理后的采集标题',
|
||
);
|
||
assert.equal(
|
||
afterRefresh.body.data.source_material.source_snapshot_revision,
|
||
4,
|
||
);
|
||
assert.equal(
|
||
afterRefresh.body.data.source_material_state.latest.title,
|
||
'复采后的来源标题',
|
||
);
|
||
assert.equal(
|
||
afterRefresh.body.data.source_material.price.amount,
|
||
'89.90',
|
||
);
|
||
assert.equal(
|
||
afterRefresh.body.data.source_material_state
|
||
.base_source_snapshot_revision,
|
||
3,
|
||
);
|
||
assert.equal(
|
||
afterRefresh.body.data.source_material_state.has_source_updates,
|
||
true,
|
||
);
|
||
assert.ok(
|
||
afterRefresh.body.data.source_material_state.changed_fields.includes(
|
||
'title',
|
||
),
|
||
);
|
||
const staleSource = await request(
|
||
`/api/products/${first.productId}/editor-draft`,
|
||
{
|
||
method: 'PUT',
|
||
headers: headers({
|
||
'X-Atlas-Platform-Id': platformId,
|
||
'Idempotency-Key': randomUUID(),
|
||
'If-Match': `"${after.body.data.product.revision}:${after.body.data.profile.revision}"`,
|
||
}),
|
||
body: JSON.stringify(payload),
|
||
},
|
||
);
|
||
assert.equal(staleSource.response.status, 409);
|
||
assert.equal(staleSource.body.error.code, 'SOURCE_SNAPSHOT_CHANGED');
|
||
const stale = await request(
|
||
`/api/products/${first.productId}/editor-draft`,
|
||
{
|
||
method: 'PUT',
|
||
headers: headers({
|
||
'X-Atlas-Platform-Id': platformId,
|
||
'Idempotency-Key': randomUUID(),
|
||
'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`,
|
||
}),
|
||
body: JSON.stringify(payload),
|
||
},
|
||
);
|
||
assert.equal(stale.response.status, 409);
|
||
assert.equal(stale.body.error.code, 'VERSION_CONFLICT');
|
||
const removeSecond = {
|
||
...payload,
|
||
ai_source_material: afterRefresh.body.data.source_material,
|
||
ai_source_overridden_fields:
|
||
afterRefresh.body.data.source_material_state.overridden_fields,
|
||
variants: [payload.variants[0]],
|
||
};
|
||
const removed = await request(
|
||
`/api/products/${first.productId}/editor-draft`,
|
||
{
|
||
method: 'PUT',
|
||
headers: headers({
|
||
'X-Atlas-Platform-Id': platformId,
|
||
'Idempotency-Key': randomUUID(),
|
||
'If-Match': `"${after.body.data.product.revision}:${after.body.data.profile.revision}"`,
|
||
}),
|
||
body: JSON.stringify(removeSecond),
|
||
},
|
||
);
|
||
assert.equal(removed.response.status, 200);
|
||
const replacement = {
|
||
...removeSecond,
|
||
variants: [
|
||
removeSecond.variants[0],
|
||
{
|
||
id: null,
|
||
client_key: 'replacement-variant-b',
|
||
copy_from_variant_id: existing.id,
|
||
display_name: '替代 SKU',
|
||
source_sku: null,
|
||
specification_values: [],
|
||
purchase_cost_cny: '99.00',
|
||
physical_spec: {
|
||
length_mm: null,
|
||
width_mm: null,
|
||
height_mm: null,
|
||
net_weight_g: null,
|
||
status: null,
|
||
},
|
||
package_spec: {
|
||
length_mm: null,
|
||
width_mm: null,
|
||
height_mm: null,
|
||
shipping_weight_g: null,
|
||
status: null,
|
||
},
|
||
offer_id: 'm3-variant-b',
|
||
},
|
||
],
|
||
};
|
||
const reusedOffer = await request(
|
||
`/api/products/${first.productId}/editor-draft`,
|
||
{
|
||
method: 'PUT',
|
||
headers: headers({
|
||
'X-Atlas-Platform-Id': platformId,
|
||
'Idempotency-Key': randomUUID(),
|
||
'If-Match': `"${removed.body.data.product_revision}:${removed.body.data.profile_revision}"`,
|
||
}),
|
||
body: JSON.stringify(replacement),
|
||
},
|
||
);
|
||
assert.equal(reusedOffer.response.status, 200);
|
||
});
|
||
|
||
it('保存规格轴与覆盖规格,并在切换店铺时保留已导入 SKU 的原店铺', async () => {
|
||
const before = await request(
|
||
`/api/products/${first.productId}/editor`,
|
||
{
|
||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||
},
|
||
);
|
||
const primaryStoreId = before.body.data.profile.target_store
|
||
.id as string;
|
||
const existingStore = await database.workspaceTransaction(
|
||
workspaceId,
|
||
async (tx) =>
|
||
tx.query<{ id: string }>(
|
||
`INSERT INTO atlas.stores
|
||
(workspace_id, platform_id, name, platform_client_id, currency_code, status, credential_status)
|
||
VALUES (current_setting('app.workspace_id')::uuid, $1, 'M3.2 第二店铺', $2, 'RUB', 'active', 'valid')
|
||
RETURNING id`,
|
||
[platformId, `editor-m32-${randomUUID()}`],
|
||
),
|
||
);
|
||
const secondStoreId = existingStore.rows[0].id;
|
||
const importedVariant = before.body.data.variants[0];
|
||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||
await tx.query(
|
||
`UPDATE atlas.product_variant_platform_listings
|
||
SET status = 'imported', platform_product_id = 'm3-imported-1', first_imported_at = now()
|
||
WHERE id = $1`,
|
||
[importedVariant.listing.id],
|
||
);
|
||
});
|
||
const payload = {
|
||
ai_source_material: before.body.data.source_material_state.latest,
|
||
ai_source_overridden_fields: [],
|
||
common: {
|
||
...before.body.data.product.common,
|
||
default_physical_spec:
|
||
before.body.data.product.default_physical_spec,
|
||
default_package_spec:
|
||
before.body.data.product.default_package_spec,
|
||
},
|
||
profile: {
|
||
target_store_id: secondStoreId,
|
||
fulfillment: before.body.data.profile.fulfillment,
|
||
title: before.body.data.profile.title,
|
||
description: before.body.data.profile.description,
|
||
tags: before.body.data.profile.tags,
|
||
},
|
||
variants: before.body.data.variants.map(
|
||
(variant: any, index: number) => ({
|
||
id: variant.id,
|
||
client_key: null,
|
||
copy_from_variant_id: null,
|
||
display_name: variant.display_name,
|
||
source_sku: variant.source_sku,
|
||
specification_values:
|
||
index === 0
|
||
? [
|
||
{
|
||
axis_key: 'size',
|
||
value_key: 'm',
|
||
value_name: 'M',
|
||
},
|
||
]
|
||
: variant.specification_values,
|
||
purchase_cost_cny: variant.purchase_cost_cny,
|
||
physical_spec:
|
||
index === 0
|
||
? {
|
||
length_mm: '101',
|
||
width_mm: '51',
|
||
height_mm: '21',
|
||
net_weight_g: '301',
|
||
status: 'confirmed',
|
||
}
|
||
: variant.physical_spec,
|
||
package_spec: variant.package_spec,
|
||
offer_id: variant.listing.offer_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': `"${before.body.data.product.revision}:${before.body.data.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.response.status, 200);
|
||
assert.equal(after.body.data.source_material.title, '复采后的来源标题');
|
||
assert.deepEqual(
|
||
after.body.data.source_material_state.overridden_fields,
|
||
[],
|
||
);
|
||
assert.equal(
|
||
after.body.data.source_material_state.has_source_updates,
|
||
false,
|
||
);
|
||
assert.equal(after.body.data.profile.target_store.id, secondStoreId);
|
||
assert.equal(after.body.data.variants[0].listing.status, 'imported');
|
||
const stores = await database.workspaceTransaction(
|
||
workspaceId,
|
||
async (tx) =>
|
||
tx.query<{ product_variant_id: string; store_id: string }>(
|
||
`SELECT product_variant_id, store_id FROM atlas.product_variant_platform_listings
|
||
WHERE profile_id = $1 ORDER BY product_variant_id`,
|
||
[before.body.data.profile.id],
|
||
),
|
||
);
|
||
const importedListing = stores.rows.find(
|
||
(row) => row.product_variant_id === importedVariant.id,
|
||
);
|
||
assert.equal(importedListing?.store_id, primaryStoreId);
|
||
assert.ok(
|
||
stores.rows.some(
|
||
(row) =>
|
||
row.product_variant_id !== importedVariant.id &&
|
||
row.store_id === secondStoreId,
|
||
),
|
||
);
|
||
assert.deepEqual(after.body.data.variants[0].specification_values, [
|
||
{ axis_key: 'size', value_key: 'm', value_name: 'M' },
|
||
]);
|
||
assert.equal(
|
||
after.body.data.variants[0].physical_spec.length_mm,
|
||
'101.000',
|
||
);
|
||
assert.equal(
|
||
after.body.data.variants[0].effective_physical_spec.length_mm,
|
||
'101.000',
|
||
);
|
||
});
|
||
|
||
it('M3.4 按当前类目返回动态属性,并将类目切换后的旧属性保留为未映射草稿', async () => {
|
||
const dictionarySuffix = randomUUID();
|
||
const materialDictionaryId = `m34-material-${dictionarySuffix}`;
|
||
const colorDictionaryId = `m34-color-${dictionarySuffix}`;
|
||
seededDictionaryIds.push(materialDictionaryId, colorDictionaryId);
|
||
const categoryA = await database.query<{ id: string }>(
|
||
`INSERT INTO atlas.platform_product_categories
|
||
(platform_id, external_category_id, external_type_id, name, path)
|
||
VALUES ($1, $2, 'type-a', 'M3.4 收纳箱', '家居 › 收纳 › M3.4 收纳箱')
|
||
RETURNING id`,
|
||
[platformId, `m34-a-${randomUUID()}`],
|
||
);
|
||
const categoryB = await database.query<{ id: string }>(
|
||
`INSERT INTO atlas.platform_product_categories
|
||
(platform_id, external_category_id, external_type_id, name, path)
|
||
VALUES ($1, $2, 'type-b', 'M3.4 背包', '箱包 › 背包 › M3.4 背包')
|
||
RETURNING id`,
|
||
[platformId, `m34-b-${randomUUID()}`],
|
||
);
|
||
seededCategoryIds.push(categoryA.rows[0].id, categoryB.rows[0].id);
|
||
await database.query(
|
||
`INSERT INTO atlas.platform_product_category_attributes
|
||
(platform_id, platform_product_category_id, platform_attribute_id, name,
|
||
value_type, is_required, is_collection, max_value_count, dictionary_id, is_aspect)
|
||
VALUES
|
||
($1, $2, 'material', '材质', 'dictionary', true, false, 1, $4, false),
|
||
($1, $2, 'color', '颜色', 'dictionary', true, false, 1, $5, true),
|
||
($1, $3, 'capacity', '容量', 'integer', false, false, 1, NULL, false)`,
|
||
[
|
||
platformId,
|
||
categoryA.rows[0].id,
|
||
categoryB.rows[0].id,
|
||
materialDictionaryId,
|
||
colorDictionaryId,
|
||
],
|
||
);
|
||
await database.query(
|
||
`INSERT INTO atlas.platform_attribute_dictionary_values
|
||
(platform_id, dictionary_id, external_value_id, value)
|
||
VALUES ($1, $2, 'cotton', '棉'),
|
||
($1, $3, 'black', '黑色')`,
|
||
[platformId, materialDictionaryId, colorDictionaryId],
|
||
);
|
||
const categories = await request(
|
||
`/api/product-platform-profiles/${first.profileId}/category-options?q=M3.4`,
|
||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||
);
|
||
assert.equal(categories.response.status, 200);
|
||
assert.ok(
|
||
categories.body.data.some(
|
||
(item: { id: string }) => item.id === categoryA.rows[0].id,
|
||
),
|
||
);
|
||
const before = await request(
|
||
`/api/products/${first.productId}/editor`,
|
||
{
|
||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||
},
|
||
);
|
||
const variants = before.body.data.variants.map((variant: any) => ({
|
||
id: variant.id,
|
||
client_key: null,
|
||
copy_from_variant_id: null,
|
||
display_name: variant.display_name,
|
||
source_sku: variant.source_sku,
|
||
specification_values: variant.specification_values,
|
||
purchase_cost_cny: variant.purchase_cost_cny,
|
||
physical_spec: variant.physical_spec,
|
||
package_spec: variant.package_spec,
|
||
offer_id: variant.listing.offer_id,
|
||
}));
|
||
const draft = {
|
||
ai_source_material: before.body.data.source_material,
|
||
ai_source_overridden_fields:
|
||
before.body.data.source_material_state.overridden_fields,
|
||
common: {
|
||
...before.body.data.product.common,
|
||
default_physical_spec:
|
||
before.body.data.product.default_physical_spec,
|
||
default_package_spec:
|
||
before.body.data.product.default_package_spec,
|
||
},
|
||
profile: {
|
||
target_store_id: before.body.data.profile.target_store.id,
|
||
fulfillment: before.body.data.profile.fulfillment,
|
||
title: before.body.data.profile.title,
|
||
description: before.body.data.profile.description,
|
||
tags: before.body.data.profile.tags,
|
||
platform_product_category_id: categoryA.rows[0].id,
|
||
category_source: 'manual',
|
||
},
|
||
attributes: [
|
||
{
|
||
scope: 'common',
|
||
variant_id: null,
|
||
platform_attribute_id: 'material',
|
||
complex_id: '0',
|
||
values: ['cotton'],
|
||
language_code: null,
|
||
source: 'manual',
|
||
confirmation_status: 'confirmed',
|
||
},
|
||
{
|
||
scope: 'variant',
|
||
variant_id: variants[0].id,
|
||
platform_attribute_id: 'color',
|
||
complex_id: '0',
|
||
values: ['black'],
|
||
language_code: null,
|
||
source: 'manual',
|
||
confirmation_status: 'confirmed',
|
||
},
|
||
],
|
||
variants,
|
||
};
|
||
const saved = await request(
|
||
`/api/products/${first.productId}/editor-draft`,
|
||
{
|
||
method: 'PUT',
|
||
headers: headers({
|
||
'X-Atlas-Platform-Id': platformId,
|
||
'Idempotency-Key': randomUUID(),
|
||
'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`,
|
||
}),
|
||
body: JSON.stringify(draft),
|
||
},
|
||
);
|
||
assert.equal(saved.response.status, 200);
|
||
const schema = await request(
|
||
`/api/product-platform-profiles/${first.profileId}/attribute-schema?category_id=${categoryA.rows[0].id}`,
|
||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||
);
|
||
assert.equal(schema.response.status, 200);
|
||
assert.equal(
|
||
schema.body.data.find(
|
||
(item: { platform_attribute_id: string }) =>
|
||
item.platform_attribute_id === 'color',
|
||
).is_aspect,
|
||
true,
|
||
);
|
||
const dictionary = await request(
|
||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialDictionaryId}?category_id=${categoryA.rows[0].id}`,
|
||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||
);
|
||
assert.deepEqual(dictionary.body.data.items, [
|
||
{ value_id: 'cotton', value: '棉' },
|
||
]);
|
||
const afterSave = await request(
|
||
`/api/products/${first.productId}/editor`,
|
||
{
|
||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||
},
|
||
);
|
||
assert.equal(
|
||
afterSave.body.data.profile.category.id,
|
||
categoryA.rows[0].id,
|
||
);
|
||
assert.equal(afterSave.body.data.profile.category.name, 'M3.4 收纳箱');
|
||
assert.equal(afterSave.body.data.attributes.length, 2);
|
||
|
||
const categoryChanged = {
|
||
...draft,
|
||
ai_source_material: afterSave.body.data.source_material,
|
||
ai_source_overridden_fields:
|
||
afterSave.body.data.source_material_state.overridden_fields,
|
||
profile: {
|
||
...draft.profile,
|
||
platform_product_category_id: categoryB.rows[0].id,
|
||
},
|
||
attributes: afterSave.body.data.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,
|
||
}),
|
||
),
|
||
};
|
||
const changed = await request(
|
||
`/api/products/${first.productId}/editor-draft`,
|
||
{
|
||
method: 'PUT',
|
||
headers: headers({
|
||
'X-Atlas-Platform-Id': platformId,
|
||
'Idempotency-Key': randomUUID(),
|
||
'If-Match': `"${afterSave.body.data.product.revision}:${afterSave.body.data.profile.revision}"`,
|
||
}),
|
||
body: JSON.stringify(categoryChanged),
|
||
},
|
||
);
|
||
assert.equal(changed.response.status, 200);
|
||
const afterCategoryChange = await request(
|
||
`/api/products/${first.productId}/editor`,
|
||
{
|
||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||
},
|
||
);
|
||
assert.equal(afterCategoryChange.body.data.attributes.length, 0);
|
||
assert.ok(
|
||
afterCategoryChange.body.data.unmapped_attributes.some(
|
||
(attribute: { original_name: string }) =>
|
||
attribute.original_name === '材质',
|
||
),
|
||
);
|
||
assert.ok(
|
||
afterCategoryChange.body.data.unmapped_attributes.some(
|
||
(attribute: { original_name: string }) =>
|
||
attribute.original_name === '颜色',
|
||
),
|
||
);
|
||
});
|
||
|
||
it('M3.5 为类目树、类目属性和关键词字典搜索创建无密钥 outbox 任务', async () => {
|
||
const dictionaryId = `m35-dictionary-${randomUUID()}`;
|
||
const category = await database.query<{ id: string }>(
|
||
`INSERT INTO atlas.platform_product_categories
|
||
(platform_id, external_category_id, external_type_id, name, path)
|
||
VALUES ($1, $2, 'm35-type', 'M3.5 测试类目', '测试 › M3.5 测试类目')
|
||
RETURNING id`,
|
||
[platformId, `m35-category-${randomUUID()}`],
|
||
);
|
||
seededCategoryIds.push(category.rows[0].id);
|
||
seededDictionaryIds.push(dictionaryId);
|
||
await database.query(
|
||
`INSERT INTO atlas.platform_product_category_attributes
|
||
(platform_id, platform_product_category_id, platform_attribute_id, name,
|
||
value_type, is_required, is_collection, max_value_count, dictionary_id, is_aspect)
|
||
VALUES ($1, $2, 'm35-color', 'M3.5 颜色', 'dictionary', false, false, 1, $3, false)`,
|
||
[platformId, category.rows[0].id, dictionaryId],
|
||
);
|
||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||
const profile = await tx.query<{ target_store_id: string }>(
|
||
'SELECT target_store_id FROM atlas.product_platform_profiles WHERE id = $1',
|
||
[first.profileId],
|
||
);
|
||
await tx.query(
|
||
`INSERT INTO atlas.store_secrets
|
||
(workspace_id, store_id, kind, secret_ciphertext, secret_nonce,
|
||
encryption_key_version, fingerprint, status)
|
||
VALUES (current_setting('app.workspace_id')::uuid, $1, 'seller_api_key',
|
||
$2, $3, 'test', $4, 'valid')`,
|
||
[
|
||
profile.rows[0].target_store_id,
|
||
Buffer.from('m35-test-ciphertext'),
|
||
Buffer.from('m35-test-nonce'),
|
||
randomUUID(),
|
||
],
|
||
);
|
||
});
|
||
const tree = await request(
|
||
`/api/product-platform-profiles/${first.profileId}/category-syncs`,
|
||
{
|
||
method: 'POST',
|
||
headers: headers({
|
||
'X-Atlas-Platform-Id': platformId,
|
||
'Idempotency-Key': randomUUID(),
|
||
}),
|
||
},
|
||
);
|
||
assert.equal(tree.response.status, 202);
|
||
assert.equal(tree.body.data.operation.kind, 'platform_category_tree_sync');
|
||
const schema = await request(
|
||
`/api/product-platform-profiles/${first.profileId}/attribute-schema-syncs?category_id=${category.rows[0].id}`,
|
||
{
|
||
method: 'POST',
|
||
headers: headers({
|
||
'X-Atlas-Platform-Id': platformId,
|
||
'Idempotency-Key': randomUUID(),
|
||
}),
|
||
},
|
||
);
|
||
assert.equal(schema.response.status, 202);
|
||
assert.equal(schema.body.data.operation.kind, 'platform_category_attribute_sync');
|
||
const dictionary = await request(
|
||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${dictionaryId}/syncs?category_id=${category.rows[0].id}&q=%E9%BB%91%E8%89%B2`,
|
||
{
|
||
method: 'POST',
|
||
headers: headers({
|
||
'X-Atlas-Platform-Id': platformId,
|
||
'Idempotency-Key': randomUUID(),
|
||
}),
|
||
},
|
||
);
|
||
assert.equal(dictionary.response.status, 202);
|
||
assert.equal(dictionary.body.data.operation.kind, 'platform_attribute_dictionary_sync');
|
||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||
const events = await tx.query<{ event_type: string; event_payload: unknown }>(
|
||
`SELECT event_type, event_payload FROM atlas.outbox_events
|
||
WHERE aggregate_id = $1 AND event_type LIKE 'platform_%_sync.requested'
|
||
ORDER BY created_at`,
|
||
[first.profileId],
|
||
);
|
||
assert.equal(events.rows.length, 3);
|
||
assert.deepEqual(
|
||
events.rows.map((event) => event.event_type),
|
||
[
|
||
'platform_category_tree_sync.requested',
|
||
'platform_category_attribute_sync.requested',
|
||
'platform_attribute_dictionary_sync.requested',
|
||
],
|
||
);
|
||
assert.ok(
|
||
events.rows.every(
|
||
(event) =>
|
||
!JSON.stringify(event.event_payload).includes('m35-test-ciphertext'),
|
||
),
|
||
);
|
||
});
|
||
});
|
||
});
|