2450 lines
105 KiB
TypeScript
2450 lines
105 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,
|
||
};
|
||
}
|
||
|
||
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.media_aspect_ratio_derivatives WHERE workspace_id = $1',
|
||
[workspaceId],
|
||
);
|
||
await tx.query(
|
||
'DELETE FROM atlas.product_image_imports WHERE workspace_id = $1',
|
||
[workspaceId],
|
||
);
|
||
await tx.query(
|
||
'DELETE FROM atlas.media_upload_intents 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.profile.fulfillment, 'rfbs');
|
||
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',
|
||
tags: [],
|
||
description: null,
|
||
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',
|
||
tags: [],
|
||
description: null,
|
||
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',
|
||
tags: [],
|
||
description: null,
|
||
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,
|
||
tags: variant.tags,
|
||
description: variant.description,
|
||
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,
|
||
tags: variant.tags,
|
||
description: variant.description,
|
||
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 root = await seedStoreCategory(
|
||
`m35-root-${suffix}`,
|
||
'0',
|
||
'M3.5 来源',
|
||
'M3.5 来源',
|
||
);
|
||
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(root.id, captured.id, manual.id);
|
||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||
await tx.query(
|
||
`UPDATE atlas.platform_product_categories
|
||
SET parent_id = $1, source_payload = '{"type_name":"M3.5 类型"}'::jsonb
|
||
WHERE id = $2`,
|
||
[root.id, captured.id],
|
||
);
|
||
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 来源 ' },
|
||
],
|
||
attributes: [
|
||
{ key: 'ozon:Type_0', name: '类型', value: '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',
|
||
);
|
||
|
||
const duplicate = await seedStoreCategory(
|
||
`m35-duplicate-${suffix}`,
|
||
'duplicate-type',
|
||
'M3.5 类型',
|
||
'M3.5 来源 › 另一分支 › M3.5 类型',
|
||
);
|
||
seededCategoryIds.push(duplicate.id);
|
||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||
await tx.query(
|
||
`UPDATE atlas.platform_product_categories
|
||
SET parent_id = $1, source_payload = '{"type_name":"M3.5 类型"}'::jsonb
|
||
WHERE id = $2`,
|
||
[root.id, duplicate.id],
|
||
);
|
||
await tx.query(
|
||
`UPDATE atlas.product_platform_profiles
|
||
SET platform_product_category_id = NULL, platform_type_id = NULL,
|
||
category_source = NULL
|
||
WHERE id = $1`,
|
||
[first.profileId],
|
||
);
|
||
});
|
||
const ambiguous = await request(
|
||
`/api/products/${first.productId}/editor`,
|
||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||
);
|
||
assert.equal(ambiguous.response.status, 200);
|
||
assert.equal(ambiguous.body.data.profile.category.id, null);
|
||
assert.equal(ambiguous.body.data.captured_category_match.status, 'ambiguous');
|
||
assert.equal(ambiguous.body.data.captured_category_match.candidates.length, 2);
|
||
|
||
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,
|
||
tags: item.tags,
|
||
description: item.description,
|
||
physical_spec: item.physical_spec,
|
||
package_spec: item.package_spec,
|
||
offer_id: item.listing.offer_id,
|
||
media_asset_ids: item.id === 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],
|
||
);
|
||
const additionalAssets = await database.workspaceTransaction(workspaceId, async (tx) => {
|
||
const ids: string[] = [];
|
||
for (let index = 0; index < 29; index += 1) {
|
||
const checksum = randomUUID().replaceAll('-', '').repeat(2);
|
||
const objectKey = `m36/${randomUUID()}.png`;
|
||
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/png', 1024, $2, 1200, 900) RETURNING id`,
|
||
[objectKey, checksum],
|
||
);
|
||
const assetId = created.rows[0].id;
|
||
ids.push(assetId);
|
||
if (index === 0) {
|
||
await tx.query(
|
||
`INSERT INTO atlas.media_upload_intents
|
||
(workspace_id, purpose, storage_provider, bucket, object_key,
|
||
expected_content_type, expected_byte_size, expected_checksum_sha256,
|
||
status, asset_id, expires_at, completed_at, product_id)
|
||
VALUES (current_setting('app.workspace_id')::uuid, 'product_image',
|
||
'test', 'test', $1, 'image/png', 1024, $2,
|
||
'ready', $3, now() + interval '1 hour', now(), $4)`,
|
||
[objectKey, checksum, assetId, first.productId],
|
||
);
|
||
} else {
|
||
await tx.query(
|
||
`INSERT INTO atlas.product_image_imports
|
||
(workspace_id, source_url, asset_id, product_id)
|
||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3)`,
|
||
[`https://example.test/${randomUUID()}.png`, assetId, first.productId],
|
||
);
|
||
}
|
||
}
|
||
return ids;
|
||
});
|
||
const thirtyImages = {
|
||
...payload,
|
||
variants: payload.variants.map((item: any) => ({
|
||
...item,
|
||
media_asset_ids: item.id === variant.id ? [asset, ...additionalAssets] : [],
|
||
})),
|
||
};
|
||
const expanded = await request(`/api/products/${first.productId}/editor-draft`, {
|
||
method: 'PUT',
|
||
headers: headers({
|
||
'X-Atlas-Platform-Id': platformId,
|
||
'Idempotency-Key': randomUUID(),
|
||
'If-Match': `"${saved.body.data.product_revision}:${saved.body.data.profile_revision}"`,
|
||
}),
|
||
body: JSON.stringify(thirtyImages),
|
||
});
|
||
assert.equal(expanded.response.status, 200);
|
||
const expandedSnapshot = await request(`/api/products/${first.productId}/editor`, {
|
||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||
});
|
||
assert.equal(expandedSnapshot.body.data.variants[0].media.length, 30);
|
||
const uploaded = await request(`/api/media-assets/product-uploads?product_id=${first.productId}`, {
|
||
headers: headers(),
|
||
});
|
||
assert.equal(uploaded.response.status, 200);
|
||
assert.ok(uploaded.body.data.some((item: any) => item.id === additionalAssets[0]));
|
||
const usedDeletion = await request(`/api/products/${first.productId}/image-deletions`, {
|
||
method: 'POST', headers: headers({ 'Idempotency-Key': randomUUID() }),
|
||
body: JSON.stringify({ asset_id: additionalAssets[0] }),
|
||
});
|
||
assert.equal(usedDeletion.response.status, 409);
|
||
assert.equal(usedDeletion.body.error.code, 'MEDIA_ASSET_IN_USE');
|
||
const invalidDeletion = await request(`/api/products/${first.productId}/image-deletions`, {
|
||
method: 'POST', headers: headers({ 'Idempotency-Key': randomUUID() }), body: '{}',
|
||
});
|
||
assert.equal(invalidDeletion.response.status, 422);
|
||
|
||
const imported = await request(`/api/media-assets/product-imports?product_id=${first.productId}`, {
|
||
headers: headers(),
|
||
});
|
||
assert.equal(imported.response.status, 200);
|
||
assert.ok(imported.body.data.some((item: any) => item.id === additionalAssets[1]));
|
||
const unscoped = await request('/api/media-assets/product-uploads', { headers: headers() });
|
||
assert.equal(unscoped.response.status, 422);
|
||
for (const kind of ['product-uploads', 'product-imports']) {
|
||
const unrelated = await request(`/api/media-assets/${kind}?product_id=${randomUUID()}`, { headers: headers() });
|
||
assert.equal(unrelated.response.status, 200);
|
||
assert.deepEqual(unrelated.body.data, []);
|
||
}
|
||
const privateImport = await request('/api/media-assets/product-imports', {
|
||
method: 'POST',
|
||
headers: headers({ 'Idempotency-Key': randomUUID() }),
|
||
body: JSON.stringify({ product_id: first.productId, url: 'https://127.0.0.1/private.png' }),
|
||
});
|
||
assert.equal(privateImport.response.status, 422);
|
||
assert.equal(privateImport.body.error.code, 'PRODUCT_IMAGE_IMPORT_INVALID');
|
||
});
|
||
|
||
it('当前商品的 3:4 处理记录可重新加入 SKU 并保存', async () => {
|
||
const before = await request(`/api/products/${first.productId}/editor`, {
|
||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||
});
|
||
const sourceAssetId = before.body.data.variants[0].media[0].asset_id as string;
|
||
const outputAssetId = await database.workspaceTransaction(workspaceId, async (tx) => {
|
||
const output = await tx.query<{ id: string }>(
|
||
`INSERT INTO atlas.media_assets
|
||
(workspace_id, storage_provider, bucket, object_key, content_type,
|
||
byte_size, checksum_sha256, width_px, height_px)
|
||
VALUES ($1, 'test', 'test', $2, 'image/jpeg', 1024, $3, 1200, 1600)
|
||
RETURNING id`,
|
||
[workspaceId, `processed/${randomUUID()}.jpg`, randomUUID().replaceAll('-', '').repeat(2)],
|
||
);
|
||
await tx.query(
|
||
`INSERT INTO atlas.media_aspect_ratio_derivatives
|
||
(workspace_id, product_id, source_asset_id, output_asset_id, status, completed_at)
|
||
VALUES ($1, $2, $3, $4, 'succeeded', now())`,
|
||
[workspaceId, first.productId, sourceAssetId, output.rows[0].id],
|
||
);
|
||
return output.rows[0].id;
|
||
});
|
||
const snapshot = before.body.data;
|
||
const payload = {
|
||
ai_source_material: snapshot.source_material,
|
||
ai_source_overridden_fields: snapshot.source_material_state.overridden_fields,
|
||
common: { ...snapshot.product.common,
|
||
default_physical_spec: snapshot.product.default_physical_spec,
|
||
default_package_spec: snapshot.product.default_package_spec },
|
||
profile: {
|
||
target_store_id: snapshot.profile.target_store.id,
|
||
fulfillment: snapshot.profile.fulfillment,
|
||
title: snapshot.profile.title,
|
||
description: snapshot.profile.description,
|
||
tags: snapshot.profile.tags,
|
||
platform_product_category_id: snapshot.profile.category.id,
|
||
category_source: snapshot.profile.category.source,
|
||
},
|
||
attributes: snapshot.attributes.map((attribute: any) => ({
|
||
scope: attribute.scope, variant_id: attribute.variant_id,
|
||
platform_attribute_id: attribute.platform_attribute_id,
|
||
complex_id: attribute.complex_id, values: attribute.values,
|
||
language_code: attribute.language_code, source: attribute.source,
|
||
confirmation_status: attribute.confirmation_status,
|
||
})),
|
||
variants: snapshot.variants.map((item: any) => ({
|
||
id: item.id, client_key: null, copy_from_variant_id: null,
|
||
display_name: item.display_name, source_sku: item.source_sku,
|
||
specification_values: item.specification_values,
|
||
purchase_cost_cny: item.purchase_cost_cny,
|
||
tags: item.tags, description: item.description,
|
||
physical_spec: item.physical_spec, package_spec: item.package_spec,
|
||
offer_id: item.listing.offer_id,
|
||
media_asset_ids: item.id === snapshot.variants[0].id
|
||
? [outputAssetId] : item.media.map((media: any) => media.asset_id),
|
||
})),
|
||
};
|
||
const saved = await request(`/api/products/${first.productId}/editor-draft`, {
|
||
method: 'PUT',
|
||
headers: headers({
|
||
'X-Atlas-Platform-Id': platformId,
|
||
'Idempotency-Key': randomUUID(),
|
||
'If-Match': `"${snapshot.product.revision}:${snapshot.profile.revision}"`,
|
||
}),
|
||
body: JSON.stringify(payload),
|
||
});
|
||
assert.equal(saved.response.status, 200);
|
||
const after = await request(`/api/products/${first.productId}/editor`, {
|
||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||
});
|
||
assert.equal(after.body.data.variants[0].media[0].asset_id, outputAssetId);
|
||
});
|
||
|
||
it('独立保存采集描述会持久化覆盖值并保持幂等', async () => {
|
||
const created = await createCollectionItem('独立保存描述测试商品');
|
||
const entered = await request(
|
||
`/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,
|
||
'独立持久化后的采集商品描述',
|
||
);
|
||
});
|
||
|
||
it('俄文标题按通用或逐 SKU 模式校验,并持久保存切换结果', async () => {
|
||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||
const store = await tx.query<{ id: string }>(
|
||
`SELECT id FROM atlas.stores
|
||
WHERE workspace_id = current_setting('app.workspace_id')::uuid
|
||
AND platform_id = $1 AND is_default = true
|
||
LIMIT 1`,
|
||
[platformId],
|
||
);
|
||
if (!store.rows.length)
|
||
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,
|
||
'俄文标题测试店铺', $2, 'RUB', true, 'active', 'valid')`,
|
||
[platformId, `editor-title-${randomUUID()}`],
|
||
);
|
||
});
|
||
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 profileId = entered.body.data.profile_id as string;
|
||
const missing = await request(
|
||
`/api/product-platform-profiles/${profileId}/validation`,
|
||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||
);
|
||
assert.equal(missing.response.status, 200);
|
||
assert.ok(
|
||
missing.body.data.profile.issues.some(
|
||
(issue: { code: string; field: string }) =>
|
||
issue.code === 'PUBLISH_TITLE_REQUIRED' &&
|
||
issue.field === 'profile.title',
|
||
),
|
||
);
|
||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||
tx.query(
|
||
`UPDATE atlas.product_platform_profiles SET title = $1 WHERE id = $2`,
|
||
['Название товара', profileId],
|
||
),
|
||
);
|
||
const filled = await request(
|
||
`/api/product-platform-profiles/${profileId}/validation`,
|
||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||
);
|
||
assert.equal(filled.response.status, 200);
|
||
assert.ok(
|
||
!filled.body.data.profile.issues.some(
|
||
(issue: { code: string }) => issue.code === 'PUBLISH_TITLE_REQUIRED',
|
||
),
|
||
);
|
||
|
||
const beforeModeSwitch = await request(`/api/products/${created.productId}/editor`, {
|
||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||
});
|
||
const snapshot = beforeModeSwitch.body.data;
|
||
assert.equal(snapshot.profile.title_mode, 'common');
|
||
const draft = {
|
||
ai_source_material: snapshot.source_material,
|
||
ai_source_overridden_fields: snapshot.source_material_state.overridden_fields,
|
||
common: {
|
||
...snapshot.product.common,
|
||
default_physical_spec: snapshot.product.default_physical_spec,
|
||
default_package_spec: snapshot.product.default_package_spec,
|
||
},
|
||
profile: {
|
||
target_store_id: snapshot.profile.target_store.id,
|
||
fulfillment: snapshot.profile.fulfillment,
|
||
title: null as string | null,
|
||
title_mode: 'variant',
|
||
description: snapshot.profile.description,
|
||
tags: snapshot.profile.tags,
|
||
platform_product_category_id: snapshot.profile.category.id,
|
||
category_source: snapshot.profile.category.source,
|
||
},
|
||
variants: snapshot.variants.map((variant: any) => ({
|
||
id: variant.id,
|
||
client_key: null,
|
||
display_name: variant.display_name,
|
||
source_sku: variant.source_sku,
|
||
specification_values: variant.specification_values,
|
||
purchase_cost_cny: variant.purchase_cost_cny,
|
||
title: null,
|
||
tags: variant.tags,
|
||
description: variant.description,
|
||
physical_spec: variant.physical_spec,
|
||
package_spec: variant.package_spec,
|
||
offer_id: variant.listing.offer_id,
|
||
media_asset_ids: variant.media.map((item: { asset_id: string }) => item.asset_id),
|
||
})),
|
||
};
|
||
const saveTitleMode = async (body: typeof draft, productRevision: number, profileRevision: number) =>
|
||
request(`/api/products/${created.productId}/editor-draft`, {
|
||
method: 'PUT',
|
||
headers: headers({
|
||
'X-Atlas-Platform-Id': platformId,
|
||
'Idempotency-Key': randomUUID(),
|
||
'If-Match': `"${productRevision}:${profileRevision}"`,
|
||
}),
|
||
body: JSON.stringify(body),
|
||
});
|
||
const variantMode = await saveTitleMode(
|
||
draft,
|
||
snapshot.product.revision,
|
||
snapshot.profile.revision,
|
||
);
|
||
assert.equal(variantMode.response.status, 200, JSON.stringify(variantMode.body));
|
||
const blankVariant = await request(
|
||
`/api/product-platform-profiles/${profileId}/validation`,
|
||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||
);
|
||
assert.ok(
|
||
!blankVariant.body.data.profile.issues.some(
|
||
(issue: { code: string }) => issue.code === 'PUBLISH_TITLE_REQUIRED',
|
||
),
|
||
);
|
||
assert.ok(
|
||
blankVariant.body.data.variants[0].issues.some(
|
||
(issue: { code: string; field: string }) =>
|
||
issue.code === 'PUBLISH_TITLE_REQUIRED' &&
|
||
issue.field === `variants.${snapshot.variants[0].id}.title`,
|
||
),
|
||
);
|
||
const afterVariantMode = await request(`/api/products/${created.productId}/editor`, {
|
||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||
});
|
||
assert.equal(afterVariantMode.body.data.profile.title_mode, 'variant');
|
||
assert.equal(afterVariantMode.body.data.profile.title, null);
|
||
const filledVariant = await saveTitleMode(
|
||
{
|
||
...draft,
|
||
variants: [{ ...draft.variants[0], title: 'Название SKU' }],
|
||
},
|
||
afterVariantMode.body.data.product.revision,
|
||
afterVariantMode.body.data.profile.revision,
|
||
);
|
||
assert.equal(filledVariant.response.status, 200, JSON.stringify(filledVariant.body));
|
||
const readyTitle = await request(
|
||
`/api/product-platform-profiles/${profileId}/validation`,
|
||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||
);
|
||
assert.ok(
|
||
!readyTitle.body.data.variants[0].issues.some(
|
||
(issue: { code: string }) => issue.code === 'PUBLISH_TITLE_REQUIRED',
|
||
),
|
||
);
|
||
const afterFilledVariant = await request(`/api/products/${created.productId}/editor`, {
|
||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||
});
|
||
const commonMode = await saveTitleMode(
|
||
{
|
||
...draft,
|
||
profile: { ...draft.profile, title: '通用俄文标题', title_mode: 'common' },
|
||
variants: [{ ...draft.variants[0], title: null }],
|
||
},
|
||
afterFilledVariant.body.data.product.revision,
|
||
afterFilledVariant.body.data.profile.revision,
|
||
);
|
||
assert.equal(commonMode.response.status, 200, JSON.stringify(commonMode.body));
|
||
const afterCommonMode = await request(`/api/products/${created.productId}/editor`, {
|
||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||
});
|
||
assert.equal(afterCommonMode.body.data.profile.title_mode, 'common');
|
||
assert.equal(afterCommonMode.body.data.variants[0].title, null);
|
||
});
|
||
|
||
it('包装重量模式独立保存,并允许 SKU 已确认重量继承通用尺寸', async () => {
|
||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||
const store = await tx.query<{ id: string }>(
|
||
`SELECT id FROM atlas.stores
|
||
WHERE workspace_id = current_setting('app.workspace_id')::uuid
|
||
AND platform_id = $1 AND is_default = true LIMIT 1`,
|
||
[platformId],
|
||
);
|
||
if (!store.rows.length)
|
||
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,
|
||
'包装规格模式测试店铺', $2, 'RUB', true, 'active', 'valid')`,
|
||
[platformId, `editor-package-${randomUUID()}`],
|
||
);
|
||
});
|
||
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 }),
|
||
});
|
||
assert.equal(before.response.status, 200);
|
||
const snapshot = before.body.data;
|
||
const variant = snapshot.variants[0];
|
||
const payload = {
|
||
ai_source_material: snapshot.source_material,
|
||
ai_source_overridden_fields: snapshot.source_material_state.overridden_fields,
|
||
common: {
|
||
...snapshot.product.common,
|
||
default_physical_spec: snapshot.product.default_physical_spec,
|
||
default_package_spec: {
|
||
length_mm: '120',
|
||
width_mm: '60',
|
||
height_mm: '30',
|
||
shipping_weight_g: null,
|
||
status: 'missing',
|
||
},
|
||
package_weight_mode: 'variant',
|
||
package_dimensions_mode: 'common',
|
||
},
|
||
profile: {
|
||
target_store_id: snapshot.profile.target_store.id,
|
||
fulfillment: 'rfbs',
|
||
title: snapshot.profile.title,
|
||
description: snapshot.profile.description,
|
||
tags: snapshot.profile.tags,
|
||
platform_product_category_id: snapshot.profile.category.id,
|
||
category_source: snapshot.profile.category.source,
|
||
},
|
||
variants: [
|
||
{
|
||
id: variant.id,
|
||
client_key: null,
|
||
display_name: variant.display_name,
|
||
source_sku: variant.source_sku,
|
||
specification_values: variant.specification_values,
|
||
purchase_cost_cny: variant.purchase_cost_cny,
|
||
title: variant.title,
|
||
tags: variant.tags,
|
||
description: variant.description,
|
||
physical_spec: variant.physical_spec,
|
||
package_spec: {
|
||
length_mm: null,
|
||
width_mm: null,
|
||
height_mm: null,
|
||
shipping_weight_g: '400',
|
||
status: 'confirmed',
|
||
},
|
||
offer_id: variant.listing.offer_id,
|
||
media_asset_ids: variant.media.map((item: { asset_id: string }) => item.asset_id),
|
||
},
|
||
],
|
||
};
|
||
const saved = await request(`/api/products/${created.productId}/editor-draft`, {
|
||
method: 'PUT',
|
||
headers: headers({
|
||
'X-Atlas-Platform-Id': platformId,
|
||
'Idempotency-Key': randomUUID(),
|
||
'If-Match': `"${snapshot.product.revision}:${snapshot.profile.revision}"`,
|
||
}),
|
||
body: JSON.stringify(payload),
|
||
});
|
||
assert.equal(saved.response.status, 200, JSON.stringify(saved.body));
|
||
const after = await request(`/api/products/${created.productId}/editor`, {
|
||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||
});
|
||
assert.equal(after.body.data.product.package_weight_mode, 'variant');
|
||
assert.equal(after.body.data.product.package_dimensions_mode, 'common');
|
||
assert.equal(after.body.data.product.default_package_spec.shipping_weight_g, null);
|
||
assert.equal(after.body.data.variants[0].package_spec.shipping_weight_g, '400.000');
|
||
assert.equal(after.body.data.variants[0].effective_package_spec.length_mm, '120.000');
|
||
assert.equal(after.body.data.variants[0].effective_package_spec.status, 'confirmed');
|
||
const {
|
||
package_weight_mode: _weightMode,
|
||
package_dimensions_mode: _dimensionsMode,
|
||
...legacyCommon
|
||
} = payload.common;
|
||
const legacySave = await request(`/api/products/${created.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, common: legacyCommon }),
|
||
});
|
||
assert.equal(legacySave.response.status, 200, JSON.stringify(legacySave.body));
|
||
const unchangedMode = await request(`/api/products/${created.productId}/editor`, {
|
||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||
});
|
||
assert.equal(unchangedMode.body.data.product.package_weight_mode, 'variant');
|
||
});
|
||
});
|