Files
atlas-seller-kit/apps/api/test/product-editor.e2e.test.ts
T
2026-09-10 17:45:46 +08:00

1146 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'),
),
);
});
});
});