feat(category): 类目树、属性、属性字典维护,新增系统管理员管理类目数据
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Database } from '@atlas/database';
|
||||
import { describe, it } from 'vitest';
|
||||
import {
|
||||
parseInput,
|
||||
sourceInputSchema,
|
||||
syncInputSchema,
|
||||
} from '../src/platform-catalog-admin/platform-catalog-admin.models.js';
|
||||
import { hasSystemAdminGrant } from '../src/platform-catalog-admin/system-admin.guard.js';
|
||||
|
||||
describe('平台目录系统管理员边界', () => {
|
||||
it('只有未撤销且用户有效的数据库授权才被视为系统管理员', async () => {
|
||||
const queries: Array<{ sql: string; parameters: unknown[] }> = [];
|
||||
const database = {
|
||||
query: async (sql: string, parameters: unknown[]) => {
|
||||
queries.push({ sql, parameters });
|
||||
return { rowCount: 1, rows: [{ '?column?': 1 }] };
|
||||
},
|
||||
} as unknown as Database;
|
||||
|
||||
assert.equal(await hasSystemAdminGrant(database, 'user-1'), true);
|
||||
assert.deepEqual(queries[0].parameters, ['user-1']);
|
||||
assert.match(queries[0].sql, /revoked_at IS NULL/);
|
||||
assert.match(queries[0].sql, /user_row\.status = 'active'/);
|
||||
});
|
||||
|
||||
it('同步请求不能混入其他作用域字段', () => {
|
||||
assert.throws(() => parseInput(syncInputSchema, {
|
||||
scope: 'tree',
|
||||
category_id: '3f7e8462-c23c-4a38-ae86-0f3d78503870',
|
||||
}));
|
||||
assert.throws(() => parseInput(syncInputSchema, {
|
||||
scope: 'dictionary',
|
||||
attribute_id: 'not-a-uuid',
|
||||
}));
|
||||
});
|
||||
|
||||
it('同步来源仅接受 Client-Id 与 Api-Key', () => {
|
||||
assert.deepEqual(parseInput(sourceInputSchema, {
|
||||
client_id: ' 12345 ',
|
||||
api_key: ' secret ',
|
||||
}), { client_id: '12345', api_key: 'secret' });
|
||||
assert.throws(() => parseInput(sourceInputSchema, {
|
||||
client_id: '12345',
|
||||
api_key: 'secret',
|
||||
workspace_id: 'forbidden',
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -68,7 +68,14 @@ async function seedStoreCategory(
|
||||
(workspace_id, store_id, platform_id, external_category_id, external_type_id, name, path)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6)
|
||||
RETURNING id`,
|
||||
[storeId, platformId, externalCategoryId, externalTypeId, name, path],
|
||||
[
|
||||
storeId,
|
||||
platformId,
|
||||
externalCategoryId,
|
||||
externalTypeId,
|
||||
name,
|
||||
path,
|
||||
],
|
||||
);
|
||||
return { id: category.rows[0].id, storeId };
|
||||
});
|
||||
@@ -115,9 +122,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
'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.operations WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.product_unmapped_attributes WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
@@ -161,9 +169,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
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 tx.query(
|
||||
'DELETE FROM atlas.media_assets WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
});
|
||||
await database.query(
|
||||
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
|
||||
@@ -852,38 +861,64 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('M3.4 按当前类目返回动态属性,并将类目切换后的旧属性保留为未映射草稿', async () => {
|
||||
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 背包');
|
||||
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);
|
||||
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
||||
`INSERT INTO atlas.store_product_category_attributes
|
||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`INSERT INTO atlas.store_product_category_attributes
|
||||
(workspace_id, store_id, platform_id, store_product_category_id, platform_attribute_id, name,
|
||||
value_type, is_required, is_collection, max_value_count, dictionary_id, is_aspect)
|
||||
VALUES
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, 'material', '材质', 'dictionary', true, false, 1, $5, false),
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, 'color', '颜色', 'dictionary', true, false, 1, $6, true),
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '85', '品牌', 'text', true, false, 1, NULL, false),
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '9048', '型号名称', 'text', true, false, 1, NULL, false),
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '8229', '类型', 'text', true, false, 1, NULL, false),
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '23171', '主题标签', 'text', false, true, 10, NULL, false),
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '11254', 'JSON 富内容', 'text', false, false, 1, NULL, false),
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, '4191', '简介', 'text', true, false, 1, NULL, false),
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $4, 'capacity', '容量', 'integer', false, false, 1, NULL, false)`,
|
||||
[
|
||||
categoryA.storeId,
|
||||
platformId,
|
||||
categoryA.id,
|
||||
categoryB.id,
|
||||
materialDictionaryId,
|
||||
colorDictionaryId,
|
||||
],
|
||||
));
|
||||
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
||||
`INSERT INTO atlas.store_attribute_dictionary_values
|
||||
[
|
||||
categoryA.storeId,
|
||||
platformId,
|
||||
categoryA.id,
|
||||
categoryB.id,
|
||||
materialDictionaryId,
|
||||
colorDictionaryId,
|
||||
],
|
||||
),
|
||||
);
|
||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`INSERT INTO atlas.store_attribute_dictionary_values
|
||||
(workspace_id, store_id, platform_id, store_product_category_id, dictionary_id, external_value_id, value)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, 'cotton', '棉'),
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, $5, 'black', '黑色')`,
|
||||
[categoryA.storeId, platformId, categoryA.id, materialDictionaryId, colorDictionaryId],
|
||||
));
|
||||
[
|
||||
categoryA.storeId,
|
||||
platformId,
|
||||
categoryA.id,
|
||||
materialDictionaryId,
|
||||
colorDictionaryId,
|
||||
],
|
||||
),
|
||||
);
|
||||
const categories = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/category-options?q=M3.4`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
@@ -900,6 +935,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
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,
|
||||
@@ -945,7 +984,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
},
|
||||
{
|
||||
scope: 'variant',
|
||||
variant_id: variants[0].id,
|
||||
variant_id: attributeVariantId,
|
||||
platform_attribute_id: 'color',
|
||||
complex_id: '0',
|
||||
values: ['black'],
|
||||
@@ -953,6 +992,16 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
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,
|
||||
};
|
||||
@@ -981,6 +1030,48 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
).is_aspect,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
schema.body.data.find(
|
||||
(item: { platform_attribute_id: string }) =>
|
||||
item.platform_attribute_id === '85',
|
||||
).editor_field,
|
||||
'brand',
|
||||
);
|
||||
assert.equal(
|
||||
schema.body.data.find(
|
||||
(item: { platform_attribute_id: string }) =>
|
||||
item.platform_attribute_id === '9048',
|
||||
).editor_field,
|
||||
'model',
|
||||
);
|
||||
assert.equal(
|
||||
schema.body.data.find(
|
||||
(item: { platform_attribute_id: string }) =>
|
||||
item.platform_attribute_id === '8229',
|
||||
).editor_field,
|
||||
'category',
|
||||
);
|
||||
assert.equal(
|
||||
schema.body.data.find(
|
||||
(item: { platform_attribute_id: string }) =>
|
||||
item.platform_attribute_id === '23171',
|
||||
).editor_field,
|
||||
'tags',
|
||||
);
|
||||
assert.equal(
|
||||
schema.body.data.find(
|
||||
(item: { platform_attribute_id: string }) =>
|
||||
item.platform_attribute_id === '11254',
|
||||
).editor_field,
|
||||
'rich_content',
|
||||
);
|
||||
assert.equal(
|
||||
schema.body.data.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/${materialDictionaryId}?category_id=${categoryA.id}`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
@@ -988,19 +1079,174 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
assert.deepEqual(dictionary.body.data.items, [
|
||||
{ value_id: 'cotton', value: '棉' },
|
||||
]);
|
||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`INSERT INTO atlas.store_attribute_dictionary_values
|
||||
(workspace_id, store_id, platform_id, store_product_category_id,
|
||||
dictionary_id, external_value_id, value)
|
||||
SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, $4,
|
||||
'page-' || value::text, '测试选项' || lpad(value::text, 3, '0')
|
||||
FROM generate_series(1, 205) AS series(value)`,
|
||||
[categoryA.storeId, platformId, categoryA.id, materialDictionaryId],
|
||||
),
|
||||
);
|
||||
const dictionaryPageOne = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialDictionaryId}?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/${materialDictionaryId}?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/${materialDictionaryId}?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.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,
|
||||
@@ -1059,8 +1305,18 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
|
||||
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 手动叶');
|
||||
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(
|
||||
@@ -1089,14 +1345,14 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
[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,
|
||||
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',
|
||||
@@ -1111,29 +1367,36 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
[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,
|
||||
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 测试类目');
|
||||
const category = await seedStoreCategory(
|
||||
`m35-category-${randomUUID()}`,
|
||||
'm35-type',
|
||||
'M3.5 测试类目',
|
||||
'测试 › M3.5 测试类目',
|
||||
);
|
||||
seededCategoryIds.push(category.id);
|
||||
seededDictionaryIds.push(dictionaryId);
|
||||
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
||||
`INSERT INTO atlas.store_product_category_attributes
|
||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`INSERT INTO atlas.store_product_category_attributes
|
||||
(workspace_id, store_id, platform_id, store_product_category_id, platform_attribute_id, name,
|
||||
value_type, is_required, is_collection, max_value_count, dictionary_id, is_aspect)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, 'm35-color', 'M3.5 颜色', 'dictionary', false, false, 1, $4, false)`,
|
||||
[category.storeId, platformId, category.id, dictionaryId],
|
||||
));
|
||||
[category.storeId, platformId, category.id, dictionaryId],
|
||||
),
|
||||
);
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const profile = await tx.query<{ target_store_id: string }>(
|
||||
'SELECT target_store_id FROM atlas.product_platform_profiles WHERE id = $1',
|
||||
@@ -1164,7 +1427,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
},
|
||||
);
|
||||
assert.equal(schema.response.status, 202);
|
||||
assert.equal(schema.body.data.operation.kind, 'store_category_attribute_sync');
|
||||
assert.equal(
|
||||
schema.body.data.operation.kind,
|
||||
'store_category_attribute_sync',
|
||||
);
|
||||
const dictionary = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${dictionaryId}/syncs?category_id=${category.id}&q=%E9%BB%91%E8%89%B2`,
|
||||
{
|
||||
@@ -1176,74 +1442,107 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
},
|
||||
);
|
||||
assert.equal(dictionary.response.status, 202);
|
||||
assert.equal(dictionary.body.data.operation.kind, 'store_attribute_dictionary_sync');
|
||||
assert.equal(
|
||||
dictionary.body.data.operation.kind,
|
||||
'store_attribute_dictionary_sync',
|
||||
);
|
||||
const initialDictionary = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${dictionaryId}/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<{ event_type: string; event_payload: unknown }>(
|
||||
const events = await tx.query<{
|
||||
event_type: string;
|
||||
event_payload: unknown;
|
||||
}>(
|
||||
`SELECT event_type, event_payload FROM atlas.outbox_events
|
||||
WHERE aggregate_id = $1 AND event_type LIKE 'store_%_sync.requested'
|
||||
ORDER BY created_at`,
|
||||
[first.profileId],
|
||||
);
|
||||
assert.equal(events.rows.length, 2);
|
||||
assert.equal(events.rows.length, 3);
|
||||
assert.deepEqual(
|
||||
events.rows.map((event) => event.event_type),
|
||||
events.rows.map((event) => event.event_type).sort(),
|
||||
[
|
||||
'store_category_attribute_sync.requested',
|
||||
'store_attribute_dictionary_sync.requested',
|
||||
'store_attribute_dictionary_sync.requested',
|
||||
'store_category_attribute_sync.requested',
|
||||
],
|
||||
);
|
||||
assert.ok(
|
||||
events.rows.every(
|
||||
(event) =>
|
||||
!JSON.stringify(event.event_payload).includes('m35-test-ciphertext'),
|
||||
!JSON.stringify(event.event_payload).includes(
|
||||
'm35-test-ciphertext',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('M3.6 仅允许当前 SKU 已转存候选作为有序正式素材', async () => {
|
||||
const before = await request(`/api/products/${first.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
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
|
||||
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
|
||||
[
|
||||
`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;
|
||||
});
|
||||
[
|
||||
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,
|
||||
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,
|
||||
@@ -1278,19 +1577,25 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
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),
|
||||
});
|
||||
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]);
|
||||
assert.deepEqual(
|
||||
after.body.data.variants[0].media.map((item: any) => item.asset_id),
|
||||
[asset],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user