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