Files
atlas-seller-kit/apps/api/test/product-editor.e2e.test.ts
T

1914 lines
79 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,
};
}
async function seedStoreCategory(
externalCategoryId: string,
externalTypeId: string,
name: string,
path: string,
) {
return database.workspaceTransaction(workspaceId, async (tx) => {
const category = await tx.query<{ id: string }>(
`INSERT INTO atlas.platform_product_categories
(platform_id, external_category_id, external_type_id, name, path)
VALUES ($1, $2, $3, $4, $5)
RETURNING id`,
[platformId, externalCategoryId, externalTypeId, name, path],
);
return { id: category.rows[0].id };
});
}
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_listing_media 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.store_product_categories 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 tx.query(
'DELETE FROM atlas.media_assets 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.platform_catalog_sync_requests
WHERE requested_by_user_id = $1`,
[userId],
);
await database.query(
`DELETE FROM atlas.platform_catalog_sync_runs
WHERE requested_by_user_id = $1`,
[userId],
);
await database.query(
`DELETE FROM atlas.platform_catalog_resource_states
WHERE category_id = ANY($1::uuid[])`,
[seededCategoryIds],
);
await database.query(
`DELETE FROM atlas.platform_catalog_dictionary_values
WHERE platform_product_category_id = ANY($1::uuid[])`,
[seededCategoryIds],
);
await database.query(
`DELETE FROM atlas.platform_product_category_attributes
WHERE platform_product_category_id = ANY($1::uuid[])`,
[seededCategoryIds],
);
await database.query(
`DELETE FROM atlas.platform_product_categories WHERE id = ANY($1::uuid[])`,
[seededCategoryIds],
);
await database.query(
`DELETE FROM atlas.platform_catalog_sources
WHERE updated_by_user_id = $1 AND client_id = 'm35-test-client'`,
[userId],
);
await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]);
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 返回去重语义的动态属性,并校验必填、scope 与类目切换', async () => {
const dictionarySuffix = randomUUID();
const materialDictionaryId = `m34-material-${dictionarySuffix}`;
const colorDictionaryId = `m34-color-${dictionarySuffix}`;
seededDictionaryIds.push(materialDictionaryId, colorDictionaryId);
const categoryA = await seedStoreCategory(
`m34-a-${randomUUID()}`,
'type-a',
'M3.4 收纳箱',
'家居 › 收纳 › M3.4 收纳箱',
);
const categoryB = await seedStoreCategory(
`m34-b-${randomUUID()}`,
'type-b',
'M3.4 背包',
'箱包 › 背包 › M3.4 背包',
);
seededCategoryIds.push(categoryA.id, categoryB.id);
const seededAttributes = await database.workspaceTransaction(
workspaceId,
(tx) =>
tx.query<{ id: string; platform_attribute_id: string }>(
`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, $2, '85', '品牌', 'text', true, false, 1, NULL, false),
($1, $2, '9048', '型号名称', 'text', true, false, 1, NULL, false),
($1, $2, '8229', '类型', 'text', true, false, 1, NULL, false),
($1, $2, '23171', '主题标签', 'text', false, true, 10, NULL, false),
($1, $2, '11254', 'JSON 富内容', 'text', false, false, 1, NULL, false),
($1, $2, '4191', '简介', 'text', true, false, 1, NULL, false),
($1, $3, 'capacity', '容量', 'integer', false, false, 1, NULL, false)
RETURNING id, platform_attribute_id`,
[
platformId,
categoryA.id,
categoryB.id,
materialDictionaryId,
colorDictionaryId,
],
),
);
const materialAttributeId = seededAttributes.rows.find(
(item) => item.platform_attribute_id === 'material',
)!.id;
const colorAttributeId = seededAttributes.rows.find(
(item) => item.platform_attribute_id === 'color',
)!.id;
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
`INSERT INTO atlas.platform_catalog_dictionary_values
(platform_id, platform_product_category_id, attribute_id, dictionary_id,
external_value_id, value)
VALUES ($1, $2, $3, $4, 'cotton', '棉'),
($1, $2, $5, $6, 'black', '黑色')`,
[
platformId,
categoryA.id,
materialAttributeId,
materialDictionaryId,
colorAttributeId,
colorDictionaryId,
],
);
await tx.query(
`INSERT INTO atlas.platform_catalog_resource_states
(platform_id, scope, scope_key, category_id, attribute_id,
last_success_at, is_complete)
VALUES
($1, 'attributes', 'attributes:' || $2::uuid, $2::uuid, NULL, now(), true),
($1, 'attributes', 'attributes:' || $3::uuid, $3::uuid, NULL, now(), true),
($1, 'dictionary', 'dictionary:' || $4::uuid, $2::uuid, $4::uuid, now(), true),
($1, 'dictionary', 'dictionary:' || $5::uuid, $2::uuid, $5::uuid, now(), true)`,
[
platformId,
categoryA.id,
categoryB.id,
materialAttributeId,
colorAttributeId,
],
);
});
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.id,
),
);
const before = await request(
`/api/products/${first.productId}/editor`,
{
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
},
);
const attributeVariantId =
before.body.data.variants.find(
(variant: any) => variant.listing.status !== 'imported',
)?.id ?? before.body.data.variants[0].id;
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.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: attributeVariantId,
platform_attribute_id: 'color',
complex_id: '0',
values: ['black'],
language_code: null,
source: 'manual',
confirmation_status: 'confirmed',
},
{
scope: 'common',
variant_id: null,
platform_attribute_id: '85',
complex_id: '0',
values: ['重复品牌值'],
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.id}`,
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
);
assert.equal(schema.response.status, 200);
assert.equal(
schema.body.data.items.find(
(item: { platform_attribute_id: string }) =>
item.platform_attribute_id === 'color',
).is_aspect,
true,
);
assert.equal(
schema.body.data.items.find(
(item: { platform_attribute_id: string }) =>
item.platform_attribute_id === '85',
).editor_field,
'brand',
);
assert.equal(
schema.body.data.items.find(
(item: { platform_attribute_id: string }) =>
item.platform_attribute_id === '9048',
).editor_field,
'model',
);
assert.equal(
schema.body.data.items.find(
(item: { platform_attribute_id: string }) =>
item.platform_attribute_id === '8229',
).editor_field,
'category',
);
assert.equal(
schema.body.data.items.find(
(item: { platform_attribute_id: string }) =>
item.platform_attribute_id === '23171',
).editor_field,
'tags',
);
assert.equal(
schema.body.data.items.find(
(item: { platform_attribute_id: string }) =>
item.platform_attribute_id === '11254',
).editor_field,
'rich_content',
);
assert.equal(
schema.body.data.items.find(
(item: { platform_attribute_id: string }) =>
item.platform_attribute_id === '4191',
).editor_field,
'description',
);
const dictionary = await request(
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialAttributeId}?category_id=${categoryA.id}`,
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
);
assert.deepEqual(dictionary.body.data.items, [
{ value_id: 'cotton', value: '棉' },
]);
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`INSERT INTO atlas.platform_catalog_dictionary_values
(platform_id, platform_product_category_id, attribute_id,
dictionary_id, external_value_id, value)
SELECT $1, $2, $3, $4,
'page-' || value::text, '测试选项' || lpad(value::text, 3, '0')
FROM generate_series(1, 205) AS series(value)`,
[
platformId,
categoryA.id,
materialAttributeId,
materialDictionaryId,
],
),
);
const dictionaryPageOne = await request(
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialAttributeId}?category_id=${categoryA.id}`,
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
);
assert.equal(dictionaryPageOne.body.data.items.length, 200);
assert.ok(dictionaryPageOne.body.data.next_cursor);
const dictionaryPageTwo = await request(
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialAttributeId}?category_id=${categoryA.id}&cursor=${encodeURIComponent(dictionaryPageOne.body.data.next_cursor)}`,
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
);
assert.equal(dictionaryPageTwo.body.data.items.length, 6);
assert.equal(dictionaryPageTwo.body.data.next_cursor, null);
const invalidDictionaryCursor = await request(
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialAttributeId}?category_id=${categoryA.id}&cursor=invalid`,
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
);
assert.equal(invalidDictionaryCursor.response.status, 400);
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.id);
assert.equal(afterSave.body.data.profile.category.name, 'M3.4 收纳箱');
assert.equal(afterSave.body.data.attributes.length, 2);
const validAttributes = await request(
`/api/product-platform-profiles/${first.profileId}/validation`,
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
);
assert.equal(validAttributes.response.status, 200);
assert.ok(
!validAttributes.body.data.profile.issues.some(
(issue: { code: string }) =>
issue.code === 'OZON_ATTRIBUTE_REQUIRED',
),
);
assert.ok(
!validAttributes.body.data.variants
.find(
(variant: { variant_id: string }) =>
variant.variant_id === attributeVariantId,
)
.issues.some(
(issue: { code: string }) =>
issue.code === 'OZON_ATTRIBUTE_REQUIRED',
),
);
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`DELETE FROM atlas.product_attributes
WHERE profile_id = $1 AND product_variant_id = $2
AND platform_attribute_id = 'color'`,
[first.profileId, attributeVariantId],
),
);
const missingVariantAttribute = await request(
`/api/product-platform-profiles/${first.profileId}/validation`,
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
);
assert.ok(
missingVariantAttribute.body.data.variants
.find(
(variant: { variant_id: string }) =>
variant.variant_id === attributeVariantId,
)
.issues.some(
(issue: { code: string; platform_attribute_id?: string }) =>
issue.code === 'OZON_ATTRIBUTE_REQUIRED' &&
issue.platform_attribute_id === 'color',
),
);
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`INSERT INTO atlas.product_attributes
(workspace_id, product_id, profile_id, product_variant_id, scope,
platform_attribute_id, complex_id, values, source, confirmation_status)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, 'variant',
'color', '0', '["black"]'::jsonb, 'manual', 'confirmed')`,
[first.productId, first.profileId, attributeVariantId],
),
);
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`DELETE FROM atlas.product_attributes
WHERE profile_id = $1 AND scope = 'common'
AND platform_attribute_id = 'material'`,
[first.profileId],
),
);
const missingCommonAttribute = await request(
`/api/product-platform-profiles/${first.profileId}/validation`,
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
);
assert.ok(
missingCommonAttribute.body.data.profile.issues.some(
(issue: { code: string; platform_attribute_id?: string }) =>
issue.code === 'OZON_ATTRIBUTE_REQUIRED' &&
issue.platform_attribute_id === 'material',
),
);
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`INSERT INTO atlas.product_attributes
(workspace_id, product_id, profile_id, product_variant_id, scope,
platform_attribute_id, complex_id, values, source, confirmation_status)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, NULL, 'common',
'material', '0', '["cotton"]'::jsonb, 'manual', 'confirmed')`,
[first.productId, first.profileId],
),
);
const invalidScope = {
...draft,
ai_source_material: afterSave.body.data.source_material,
ai_source_overridden_fields:
afterSave.body.data.source_material_state.overridden_fields,
attributes: afterSave.body.data.attributes.map(
(attribute: any) => ({
scope:
attribute.platform_attribute_id === 'color'
? 'common'
: attribute.scope,
variant_id:
attribute.platform_attribute_id === 'color'
? null
: 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 invalidScopeSave = 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(invalidScope),
},
);
assert.equal(invalidScopeSave.response.status, 422);
assert.equal(
invalidScopeSave.body.error.code,
'ATTRIBUTE_SCOPE_INVALID',
);
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.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 按 Ozon 采集层级回填空类目,且绝不覆盖已保存选择', async () => {
const suffix = randomUUID();
const captured = await seedStoreCategory(
`m35-source-${suffix}`,
'source-type',
'M3.5 采集叶',
'M3.5 来源 › M3.5 采集叶',
);
const manual = await seedStoreCategory(
`m35-manual-${suffix}`,
'manual-type',
'M3.5 手动叶',
'M3.5 手动 › M3.5 手动叶',
);
seededCategoryIds.push(captured.id, manual.id);
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
`UPDATE atlas.collection_items
SET source_kind = 'platform', source_platform_id = $1,
normalized_payload = $2::jsonb
WHERE id = $3`,
[
platformId,
JSON.stringify({
common: {
categories: [
{ key: 'source-root', name: ' M3.5 来源 ' },
{ key: 'source-leaf', name: 'M3.5 采集叶' },
],
},
}),
first.collectionItemId,
],
);
await tx.query(
`UPDATE atlas.product_platform_profiles
SET platform_product_category_id = NULL,
store_product_category_id = NULL, platform_type_id = NULL,
category_source = NULL
WHERE id = $1`,
[first.profileId],
);
});
const resolved = await request(
`/api/products/${first.productId}/editor`,
{
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
},
);
assert.equal(resolved.response.status, 200);
assert.equal(resolved.body.data.profile.category.id, captured.id);
assert.equal(
resolved.body.data.profile.category.source,
'source_capture',
);
await database.workspaceTransaction(workspaceId, (tx) =>
tx.query(
`UPDATE atlas.product_platform_profiles
SET platform_product_category_id = $1, platform_type_id = 'manual-type',
category_source = 'manual'
WHERE id = $2`,
[manual.id, first.profileId],
),
);
const preserved = await request(
`/api/products/${first.productId}/editor`,
{
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
},
);
assert.equal(preserved.response.status, 200);
assert.equal(preserved.body.data.profile.category.id, manual.id);
assert.equal(preserved.body.data.profile.category.source, 'manual');
});
it('M3.5 为类目属性和关键词字典搜索创建无密钥 outbox 任务', async () => {
const dictionaryId = `m35-dictionary-${randomUUID()}`;
const category = await seedStoreCategory(
`m35-category-${randomUUID()}`,
'm35-type',
'M3.5 测试类目',
'测试 › M3.5 测试类目',
);
seededCategoryIds.push(category.id);
seededDictionaryIds.push(dictionaryId);
const dictionaryAttribute = await database.workspaceTransaction(
workspaceId,
(tx) =>
tx.query<{ id: string }>(
`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)
RETURNING id`,
[platformId, category.id, dictionaryId],
),
);
const dictionaryAttributeId = dictionaryAttribute.rows[0].id;
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
`INSERT INTO atlas.platform_catalog_sources
(platform_id, client_id, secret_ciphertext, secret_nonce,
secret_fingerprint, encryption_key_version, updated_by_user_id)
VALUES ($1, 'm35-test-client', $2, $3, $4, 'test', $5)
ON CONFLICT (platform_id) DO NOTHING`,
[
platformId,
Buffer.from('m35-test-ciphertext'),
Buffer.from('m35-test-nonce'),
randomUUID(),
userId,
],
);
});
const schema = await request(
`/api/product-platform-profiles/${first.profileId}/attribute-schema-syncs?category_id=${category.id}`,
{
method: 'POST',
headers: headers({
'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': randomUUID(),
}),
},
);
assert.equal(schema.response.status, 202);
assert.equal(schema.body.data.run.scope, 'attributes');
const dictionary = await request(
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${dictionaryAttributeId}/syncs?category_id=${category.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.run.scope, 'dictionary_search');
const initialDictionary = await request(
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${dictionaryAttributeId}/syncs?category_id=${category.id}`,
{
method: 'POST',
headers: headers({
'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': randomUUID(),
}),
},
);
assert.equal(initialDictionary.response.status, 202);
await database.workspaceTransaction(workspaceId, async (tx) => {
const events = await tx.query<{
scope: string;
scope_key: string;
}>(
`SELECT run.scope, run.scope_key
FROM atlas.platform_catalog_outbox_events event
JOIN atlas.platform_catalog_sync_runs run ON run.id = event.run_id
WHERE run.requested_by_user_id = $1 AND run.category_id = $2
ORDER BY run.created_at`,
[userId, category.id],
);
assert.equal(events.rows.length, 3);
assert.deepEqual(events.rows.map((event) => event.scope).sort(), [
'attributes',
'dictionary',
'dictionary_search',
]);
assert.ok(
events.rows.every(
(event) =>
!JSON.stringify(event).includes('m35-test-ciphertext'),
),
);
});
});
it('发布预检只刷新当前类型和实际使用的字典,并按过期状态提升优先级', async () => {
const category = await seedStoreCategory(
`publish-preflight-${randomUUID()}`,
'publish-preflight-type',
'发布预检类目',
'测试 › 发布预检类目',
);
seededCategoryIds.push(category.id);
const definitions = await database.workspaceTransaction(
workspaceId,
(tx) =>
tx.query<{ id: string; platform_attribute_id: string }>(
`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, 'publish-used', '已用字典', 'dictionary', false,
false, 1, $3, false),
($1, $2, 'publish-unused', '未用字典', 'dictionary', false,
false, 1, $4, false)
RETURNING id, platform_attribute_id`,
[
platformId,
category.id,
`publish-used-${randomUUID()}`,
`publish-unused-${randomUUID()}`,
],
),
);
const usedAttributeId = definitions.rows.find(
(item) => item.platform_attribute_id === 'publish-used',
)!.id;
const unusedAttributeId = definitions.rows.find(
(item) => item.platform_attribute_id === 'publish-unused',
)!.id;
const editor = await request(
`/api/products/${first.productId}/editor`,
{
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
},
);
const variantId = editor.body.data.variants.find(
(item: { id: string; listing: { status: string } }) =>
['not_listed', 'failed'].includes(item.listing.status),
).id as string;
await database.workspaceTransaction(workspaceId, async (tx) => {
await tx.query(
`UPDATE atlas.product_platform_profiles
SET platform_product_category_id = $1,
platform_type_id = 'publish-preflight-type',
category_source = 'manual'
WHERE id = $2`,
[category.id, first.profileId],
);
await tx.query(
`DELETE FROM atlas.product_attributes WHERE profile_id = $1`,
[first.profileId],
);
await tx.query(
`INSERT INTO atlas.product_attributes
(workspace_id, product_id, profile_id, product_variant_id,
scope, platform_attribute_id, complex_id, values, source,
confirmation_status)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2,
NULL, 'common', 'publish-used', '0', '["used-value"]'::jsonb,
'manual', 'confirmed')`,
[first.productId, first.profileId],
);
await tx.query(
`INSERT INTO atlas.platform_catalog_dictionary_values
(platform_id, platform_product_category_id, attribute_id,
dictionary_id, external_value_id, value)
SELECT $1, $2, id, dictionary_id, 'used-value', '已用候选值'
FROM atlas.platform_product_category_attributes WHERE id = $3`,
[platformId, category.id, usedAttributeId],
);
await tx.query(
`INSERT INTO atlas.platform_catalog_resource_states
(platform_id, scope, scope_key, category_id, attribute_id,
last_success_at, is_complete)
VALUES
($1, 'attributes', 'attributes:' || $2::uuid, $2, NULL,
now() - interval '15 days', true),
($1, 'dictionary', 'dictionary:' || $3::uuid, $2, $3,
now() - interval '15 days', true)`,
[platformId, category.id, usedAttributeId],
);
});
const stale = await request(
`/api/product-platform-profiles/${first.profileId}/publish-preflights`,
{
method: 'POST',
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
body: JSON.stringify({ variant_ids: [variantId] }),
},
);
assert.equal(stale.response.status, 200);
assert.equal(stale.body.data.catalog.schema.status, 'stale');
assert.deepEqual(
stale.body.data.catalog.dictionaries.map(
(item: { attribute_id: string; cache: { status: string } }) => [
item.attribute_id,
item.cache.status,
],
),
[[usedAttributeId, 'stale']],
);
assert.equal(stale.body.data.refresh_runs.length, 2);
assert.ok(
stale.body.data.refresh_runs.every(
(run: { priority: string; trigger_kind: string }) =>
run.priority === 'low' && run.trigger_kind === 'publish',
),
);
await database.query(
`UPDATE atlas.platform_catalog_dictionary_values
SET is_active = false WHERE attribute_id = $1`,
[usedAttributeId],
);
const invalidValue = await request(
`/api/product-platform-profiles/${first.profileId}/validation`,
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
);
assert.ok(
invalidValue.body.data.profile.issues.some(
(issue: { code: string }) =>
issue.code === 'ATTRIBUTE_DICTIONARY_VALUE_INVALID',
),
);
await database.query(
`UPDATE atlas.platform_catalog_dictionary_values
SET is_active = true WHERE attribute_id = $1`,
[usedAttributeId],
);
await database.query(
`UPDATE atlas.platform_catalog_resource_states
SET last_success_at = now() - interval '22 days'
WHERE platform_id = $1
AND scope_key = 'dictionary:' || $2::uuid`,
[platformId, usedAttributeId],
);
const expired = await request(
`/api/product-platform-profiles/${first.profileId}/publish-preflights`,
{
method: 'POST',
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
body: JSON.stringify({ variant_ids: [variantId] }),
},
);
assert.equal(expired.response.status, 202);
assert.equal(expired.body.data.status, 'refreshing');
assert.equal(expired.body.data.catalog.schema.status, 'stale');
assert.equal(
expired.body.data.catalog.dictionaries[0].cache.status,
'expired',
);
await database.query(
`UPDATE atlas.platform_catalog_resource_states
SET last_success_at = now() - interval '22 days'
WHERE platform_id = $1
AND scope_key = 'attributes:' || $2::uuid`,
[platformId, category.id],
);
const expiredSchema = await request(
`/api/product-platform-profiles/${first.profileId}/publish-preflights`,
{
method: 'POST',
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
body: JSON.stringify({ variant_ids: [variantId] }),
},
);
assert.equal(expiredSchema.response.status, 202);
assert.equal(expiredSchema.body.data.catalog.schema.status, 'expired');
assert.deepEqual(expiredSchema.body.data.catalog.dictionaries, []);
const runs = await database.query<{
scope_key: string;
priority: string;
}>(
`SELECT DISTINCT ON (scope_key) scope_key, priority
FROM atlas.platform_catalog_sync_runs
WHERE platform_id = $1
AND scope_key IN ('attributes:' || $2::uuid,
'dictionary:' || $3::uuid,
'dictionary:' || $4::uuid)
ORDER BY scope_key, created_at DESC`,
[platformId, category.id, usedAttributeId, unusedAttributeId],
);
assert.equal(runs.rows.length, 2);
assert.ok(runs.rows.every((run) => run.priority === 'high'));
assert.ok(
!runs.rows.some(
(run) => run.scope_key === `dictionary:${unusedAttributeId}`,
),
);
}, 20_000);
it('M3.6 仅允许当前 SKU 已转存候选作为有序正式素材', async () => {
const before = await request(
`/api/products/${first.productId}/editor`,
{
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
},
);
const variant = before.body.data.variants[0];
const asset = await database.workspaceTransaction(
workspaceId,
async (tx) => {
const created = 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 (current_setting('app.workspace_id')::uuid, 'test', 'test', $1,
'image/jpeg', 1024, $2, 1200, 900)
RETURNING id`,
[
`m36/${randomUUID()}.jpg`,
randomUUID().replaceAll('-', ''),
],
);
const snapshot = await tx.query<{
source_snapshot_revision: string;
}>(
`SELECT source_snapshot_revision::text FROM atlas.collection_items WHERE id = $1`,
[first.collectionItemId],
);
await tx.query(
`INSERT INTO atlas.collection_asset_transfers
(workspace_id, collection_item_id, product_id, source_variant_id, role,
source_url, status, asset_id, source_snapshot_revision, completed_at)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3,
'source_sku', 'https://example.test/m36.jpg', 'succeeded', $4, $5, now())`,
[
first.collectionItemId,
first.productId,
variant.id,
created.rows[0].id,
snapshot.rows[0].source_snapshot_revision,
],
);
return created.rows[0].id;
},
);
const payload = {
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:
before.body.data.profile.category.id,
category_source: before.body.data.profile.category.source,
},
attributes: before.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,
})),
variants: before.body.data.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,
physical_spec: item.physical_spec,
package_spec: item.package_spec,
offer_id: item.listing.offer_id,
media_asset_ids: item.id === variant.id ? [asset] : [],
})),
};
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.deepEqual(
after.body.data.variants[0].media.map((item: any) => item.asset_id),
[asset],
);
});
it('独立保存采集描述会持久化覆盖值并保持幂等', async () => {
const created = await createCollectionItem('独立保存描述测试商品');
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);
const before = await request(`/api/products/${created.productId}/editor`, {
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
});
const key = randomUUID();
const body = {
description: '独立持久化后的采集商品描述',
source_snapshot_revision:
before.body.data.source_material.source_snapshot_revision,
};
const saved = await request(
`/api/products/${created.productId}/source-description`,
{
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(body),
},
);
assert.equal(saved.response.status, 200);
assert.equal(
saved.body.data.source_material.description,
'独立持久化后的采集商品描述',
);
assert.ok(
saved.body.data.source_material_state.overridden_fields.includes(
'description',
),
);
const replay = await request(
`/api/products/${created.productId}/source-description`,
{
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(body),
},
);
assert.deepEqual(replay.body, saved.body);
const after = await request(`/api/products/${created.productId}/editor`, {
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
});
assert.equal(
after.body.data.source_material.description,
'独立持久化后的采集商品描述',
);
});
});