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 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); 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) VALUES (current_setting('app.workspace_id')::uuid, 'product_image', 'test', 'test', $1, 'image/png', 1024, $2, 'ready', $3, now() + interval '1 hour', now())`, [objectKey, checksum, assetId], ); } else { await tx.query( `INSERT INTO atlas.product_image_imports (workspace_id, source_url, asset_id) VALUES (current_setting('app.workspace_id')::uuid, $1, $2)`, [`https://example.test/${randomUUID()}.png`, assetId], ); } } 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', { headers: headers(), }); assert.equal(uploaded.response.status, 200); assert.ok(uploaded.body.data.some((item: any) => item.id === additionalAssets[0])); const imported = await request('/api/media-assets/product-imports', { headers: headers(), }); assert.equal(imported.response.status, 200); assert.ok(imported.body.data.some((item: any) => item.id === additionalAssets[1])); const privateImport = await request('/api/media-assets/product-imports', { method: 'POST', headers: headers({ 'Idempotency-Key': randomUUID() }), body: JSON.stringify({ 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('俄文发布标题为空时发布就绪校验提示必填,填写后移除提示', 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', ), ); }); 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'); }); });