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 type { ProductPublishJob } from '@atlas/contracts'; import { ozonHashtagProblem } from '@atlas/contracts'; import type { S3Client } from '@aws-sdk/client-s3'; import { beforeAll, afterAll, describe, it } from 'vitest'; import { DATABASE } from '../src/database/database.module.js'; import { createApplication } from '../src/main.js'; import { runProductPublish, type PublishDependencies, } from '../../worker/src/product-publish.js'; import { OzonRateLimitError, OzonRetryableError, } from '../../worker/src/ozon-client.js'; let app: NestExpressApplication, db: Database, base = '', workspace = '', token = '', platform = '', store = '', category = ''; const storage = {} as S3Client; async function request( path: string, method = 'GET', body?: unknown, extra: Record = {}, ) { const res = await fetch(`${base}/api${path}`, { method, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'X-Atlas-Platform-Id': platform, ...extra, }, body: body === undefined ? undefined : JSON.stringify(body), }); return { status: res.status, body: (await res.json()) as any }; } async function fixture(count = 2, mediaCount = 1) { const created = await request( '/collection-items', 'POST', { title: '发布测试商品' }, { 'Idempotency-Key': randomUUID() }, ); assert.equal(created.status, 201, JSON.stringify(created)); const product = created.body.data.product_id; const entered = await request( `/collection-items/${created.body.data.collection_item.id}/editor-contexts`, 'POST', {}, { 'Idempotency-Key': randomUUID() }, ); assert.equal(entered.status, 201, JSON.stringify(entered)); const profile = entered.body.data.profile_id; const ids: string[] = []; await db.workspaceTransaction(workspace, async (tx) => { await tx.query( `UPDATE atlas.products SET brand='Без бренда',model='TEST-MODEL',default_package_length_mm=240,default_package_width_mm=120,default_package_height_mm=60,default_shipping_weight_g=350,package_spec_status='confirmed' WHERE id=$1`, [product], ); await tx.query( `UPDATE atlas.product_platform_profiles SET title='Тестовый товар',fulfillment='rfbs',platform_product_category_id=$2,platform_type_id='1002' WHERE id=$1`, [profile, category], ); const first = ( await tx.query<{ id: string }>( 'SELECT id FROM atlas.product_variants WHERE product_id=$1', [product], ) ).rows[0].id; ids.push(first); for (let i = 1; i < count; i++) ids.push( ( await tx.query<{ id: string }>( `INSERT INTO atlas.product_variants(workspace_id,product_id,display_name,sort_order) VALUES($1,$2,$3,$4) RETURNING id`, [workspace, product, `SKU ${i}`, i], ) ).rows[0].id, ); for (let i = 0; i < ids.length; i++) { const variant = ids[i]; await tx.query( `INSERT INTO atlas.product_variant_platform_listings(workspace_id,product_id,profile_id,platform_id,product_variant_id,store_id) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING`, [workspace, product, profile, platform, variant, store], ); const quote = ( await tx.query<{ id: string }>( `INSERT INTO atlas.pricing_quotes(workspace_id,store_id,platform_id,product_id,profile_id,product_variant_id,fulfillment,purchase_cost_cny,package_weight_kg,package_length_cm,package_width_cm,package_height_cm,fx_cny_to_rub,input_snapshot,status,confirmed_at) VALUES($1,$2,$3,$4,$5,$6,'rfbs',20,0.350,24,12,6,12,'{}','confirmed',now()) RETURNING id`, [workspace, store, platform, product, profile, variant], ) ).rows[0].id; const listing = ( await tx.query<{ id: string }>( `UPDATE atlas.product_variant_platform_listings SET offer_id=$2,selling_price=1999.1234,list_price=2999.1234,currency_code='RUB',pricing_quote_id=$3,pricing_valid=true WHERE profile_id=$1 AND product_variant_id=$4 RETURNING id`, [profile, `TEST-${randomUUID()}`, quote, variant], ) ).rows[0].id; for (let mediaIndex = 0; mediaIndex < mediaCount; mediaIndex++) { const asset = ( await tx.query<{ id: string }>( `INSERT INTO atlas.media_assets(workspace_id,storage_provider,bucket,object_key,content_type,byte_size,checksum_sha256) VALUES($1,'qiniu_kodo','test-bucket',$2,'image/png',100,$3) RETURNING id`, [workspace, randomUUID(), randomUUID()], ) ).rows[0].id; await tx.query( `INSERT INTO atlas.product_variant_listing_media(workspace_id,product_id,profile_id,listing_id,product_variant_id,asset_id,sort_order) VALUES($1,$2,$3,$4,$5,$6,$7)`, [workspace, product, profile, listing, variant, asset, mediaIndex], ); } } }); return { profile, product, ids }; } async function publish( f: Awaited>, ids = f.ids, key = randomUUID(), ) { return request( `/product-platform-profiles/${f.profile}/publish-jobs`, 'POST', { variant_ids: ids }, { 'Idempotency-Key': key, 'If-Match': '"1:1"' }, ); } async function run( job: ProductPublishJob, requestFn: PublishDependencies['request'], images: PublishDependencies['images'] = async () => [ 'https://images.example.test/fixture.png', ], ) { await runProductPublish( db, storage, { eventId: randomUUID(), workspaceId: workspace, eventType: 'product_publish.requested', payload: { publish_job_id: job.id }, }, { request: requestFn, images, decrypt: () => 'fixture-key', }, ); const response = await request(`/product-publish-jobs/${job.id}`); assert.equal(response.status, 200); return response.body.data as ProductPublishJob; } describe('首次发布 HTTP / 数据库 / Worker 闭环', () => { beforeAll(async () => { Object.assign(process.env, { NODE_ENV: 'test', SWAGGER_ENABLED: 'false', LOG_LEVEL: 'error', AUTH_REGISTER_IP_LIMIT: '100', AUTH_REGISTER_EMAIL_LIMIT: '100', OZON_PUBLISH_ENABLED: 'true', }); ({ app } = await createApplication()); db = app.get(DATABASE); await app.listen(0, '127.0.0.1'); base = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`; const registration = await fetch(`${base}/api/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: `publish-${randomUUID()}@example.test`, password: 'correct-horse-battery-staple-123', password_confirmation: 'correct-horse-battery-staple-123', }), }); const data = (await registration.json()) as any; assert.equal(registration.status, 201, JSON.stringify(data)); workspace = data.data.workspace.id; token = data.data.access_token; platform = (await request('/platforms')).body.data[0].id; await db.workspaceTransaction(workspace, async (tx) => { store = ( await tx.query<{ id: string }>( `INSERT INTO atlas.stores(workspace_id,platform_id,name,platform_client_id,currency_code,is_default,status,credential_status) VALUES($1,$2,'发布测试店铺',$3,'RUB',true,'active','valid') RETURNING id`, [workspace, platform, randomUUID()], ) ).rows[0].id; await tx.query( `INSERT INTO atlas.store_secrets(workspace_id,store_id,kind,secret_ciphertext,secret_nonce,encryption_key_version,fingerprint,status) VALUES($1,$2,'seller_api_key',$3,$4,'v1','fixture','valid')`, [workspace, store, Buffer.from('fixture'), Buffer.alloc(12)], ); 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,'1002','发布测试类目','发布测试类目') RETURNING id`, [platform, `${Date.now()}`], ) ).rows[0].id; await tx.query( `INSERT INTO atlas.platform_product_category_attributes(platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_required,is_aspect,max_value_count) VALUES($1,$2,'9048','型号','text',true,false,1),($1,$2,'23171','#主题标签','text',false,false,1)`, [platform, category], ); await tx.query( `INSERT INTO atlas.platform_catalog_resource_states(platform_id,scope_key,scope,category_id,last_success_at,is_complete) VALUES($1,$2,'attributes',$3,now(),true)`, [platform, `attributes:${category}`, category], ); }); }); afterAll(async () => { await app?.close(); }); it('商品颜色按属性 ID 支持多选保存、重读和发布,其他单值属性仍限制数量', async () => { const f = await fixture(1); const dictionaryId = `color-${randomUUID()}`; await db.workspaceTransaction(workspace, async tx => { const ownCategory = (await tx.query<{ id: string }>( `INSERT INTO atlas.platform_product_categories(platform_id,external_category_id,external_type_id,name,path) VALUES($1,$2,'1002','颜色多选测试','颜色多选测试') RETURNING id`, [platform, String(Date.now()) + '96'], )).rows[0].id; const color = (await 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_aspect,is_collection,max_value_count,dictionary_id) VALUES($1,$2,'10096','Цвет товара','dictionary',true,true,false,1,$3) RETURNING id`, [platform, ownCategory, dictionaryId], )).rows[0].id; await tx.query(`INSERT INTO atlas.platform_product_category_attributes (platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_aspect,is_collection,max_value_count) VALUES($1,$2,'200001','单值测试','text',true,false,1)`, [platform, ownCategory]); 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,'61579','红色'),($1,$2,$3,$4,'61581','蓝色')`, [platform, ownCategory, color, dictionaryId]); await tx.query(`INSERT INTO atlas.platform_catalog_resource_states (platform_id,scope_key,scope,category_id,attribute_id,last_success_at,is_complete) VALUES($1,'attributes:' || $2::text,'attributes',$2::uuid,NULL,now(),true), ($1,'dictionary:' || $3::text,'dictionary',$2::uuid,$3::uuid,now(),true)`, [platform, ownCategory, color]); await tx.query('UPDATE atlas.product_platform_profiles SET platform_product_category_id=$2 WHERE id=$1', [f.profile, ownCategory]); await tx.query('UPDATE atlas.product_variant_platform_listings SET pricing_quote_id=NULL WHERE profile_id=$1', [f.profile]); await tx.query(`UPDATE atlas.media_assets SET width_px=800,height_px=800 WHERE id IN (SELECT asset_id FROM atlas.product_variant_listing_media WHERE profile_id=$1)`, [f.profile]); }); const before = (await request(`/products/${f.product}/editor`)).body.data; const colorDraft = { scope: 'variant', variant_id: f.ids[0], platform_attribute_id: '10096', complex_id: '0', values: ['61579', '61581'], language_code: null, source: 'manual', confirmation_status: 'confirmed', }; const draft = { ai_source_material: before.source_material, ai_source_overridden_fields: before.source_material_state.overridden_fields, common: { ...before.product.common, default_physical_spec: before.product.default_physical_spec, default_package_spec: before.product.default_package_spec }, profile: { target_store_id: store, fulfillment: before.profile.fulfillment, title: before.profile.title, description: before.profile.description, tags: before.profile.tags, platform_product_category_id: before.profile.category.id, category_source: 'manual' }, attributes: [colorDraft], variants: before.variants.map((v: any) => ({ id: v.id, client_key: null, display_name: v.display_name, source_sku: v.source_sku, specification_values: v.specification_values, purchase_cost_cny: v.purchase_cost_cny, title: v.title, tags: v.tags, description: v.description, physical_spec: v.physical_spec, package_spec: v.package_spec, offer_id: v.listing.offer_id, media_asset_ids: v.media.map((m: any) => m.asset_id), })), }; const save = (attributes = draft.attributes) => request(`/products/${f.product}/editor-draft`, 'PUT', { ...draft, attributes }, { 'Idempotency-Key': randomUUID(), 'If-Match': '"1:1"' }); const invalidSingle = await save([{ ...colorDraft, platform_attribute_id: '200001', values: ['one', 'two'] }]); assert.equal(invalidSingle.status, 422); assert.equal(invalidSingle.body.error.code, 'ATTRIBUTE_VALUE_COUNT_INVALID'); const invalidDictionary = await save([{ ...colorDraft, values: ['61579', '99999999'] }]); assert.equal(invalidDictionary.status, 422); const saved = await save(); assert.equal(saved.status, 200, JSON.stringify(saved)); const after = (await request(`/products/${f.product}/editor`)).body.data; assert.deepEqual(after.attributes.find((a: any) => a.platform_attribute_id === '10096').values, ['61579', '61581']); // fixture 使用预先确认的模拟报价;草稿保存后恢复其有效标记,仅验证颜色发布映射。 await db.workspaceTransaction(workspace, tx => tx.query( `UPDATE atlas.product_variant_platform_listings listing SET pricing_valid=true, pricing_quote_id=(SELECT quote.id FROM atlas.pricing_quotes quote WHERE quote.profile_id=listing.profile_id AND quote.product_variant_id=listing.product_variant_id LIMIT 1) WHERE listing.profile_id=$1`, [f.profile])); const job = await request(`/product-platform-profiles/${f.profile}/publish-jobs`, 'POST', { variant_ids: f.ids }, { 'Idempotency-Key': randomUUID(), 'If-Match': `"${after.product.revision}:${after.profile.revision}"` }); assert.equal(job.status, 202, JSON.stringify(job)); const snapshot = await db.workspaceTransaction(workspace, async tx => (await tx.query<{ snapshot: { attributes: Array<{ id: number; values: unknown[] }> } }>( 'SELECT snapshot FROM atlas.product_publish_job_items WHERE job_id=$1', [job.body.data.publish_job.id])).rows[0].snapshot); assert.deepEqual(snapshot.attributes.find(a => a.id === 10096)?.values, [ { dictionary_value_id: 61579, value: '红色' }, { dictionary_value_id: 61581, value: '蓝色' }, ]); }); it('简介保留 SKU 覆盖和 SPU 继承的换行,Ozon 请求转为 br 且不修改快照', async () => { const f = await fixture(); const common = 'Общее описание\r\n\r\nХарактеристики:\r\n• Один & два'; const own = 'Описание SKU\n\nРазмер < 10\n• Красный'; await db.workspaceTransaction(workspace, async tx => { await tx.query(`INSERT INTO atlas.platform_product_category_attributes (platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_required,is_aspect,max_value_count) VALUES($1,$2,'4191','简介','text',false,false,1) ON CONFLICT DO NOTHING`, [platform, category]); await tx.query('UPDATE atlas.product_platform_profiles SET description=$2 WHERE id=$1', [f.profile, common]); await tx.query('UPDATE atlas.product_variant_platform_listings SET description=$3 WHERE profile_id=$1 AND product_variant_id=$2', [f.profile,f.ids[0],own]); }); const created = await publish(f); assert.equal(created.status, 202, JSON.stringify(created)); const job = created.body.data.publish_job as ProductPublishJob; const snapshots = () => db.workspaceTransaction(workspace, async tx => (await tx.query<{ variant_id: string; snapshot: { attributes: Array<{ id: number; values: Array<{ value: string }> }> }; }>('SELECT variant_id,snapshot FROM atlas.product_publish_job_items WHERE job_id=$1', [job.id])).rows); const before = await snapshots(); for (const row of before) assert.equal(row.snapshot.attributes.find(a=>a.id===4191)?.values[0].value, row.variant_id === f.ids[0] ? own : common); let submitted = false; await run(job, async (_client, _key, path, body) => { if (path === '/v3/product/info/list') return { items: [] }; assert.equal(path, '/v3/product/import'); submitted = true; const items = (body as { items: Array<{ attributes: Array<{ id: number; values: Array<{ value: string }> }> }> }).items; assert.deepEqual(new Set(items.map(item=>item.attributes.find(a=>a.id===4191)?.values[0].value)), new Set([ 'Описание SKU

Размер < 10
• Красный', 'Общее описание

Характеристики:
• Один & два', ])); return { result: { task_id: 778899 } }; }); assert.equal(submitted,true); assert.deepEqual(await snapshots(), before); }); it('多个主题标签合并为一个 Ozon 属性值,SKU 覆盖与继承均保留', async () => { const f = await fixture(); await db.workspaceTransaction(workspace, async (tx) => { await tx.query( 'UPDATE atlas.product_platform_profiles SET tags=$2::jsonb WHERE id=$1', [f.profile, JSON.stringify(['#boho', '#loft'])], ); await tx.query( 'UPDATE atlas.product_variant_platform_listings SET tags=$3::jsonb WHERE profile_id=$1 AND product_variant_id=$2', [f.profile, f.ids[0], JSON.stringify(['#kpop', '#oldmoney'])], ); }); const created = await publish(f); assert.equal(created.status, 202, JSON.stringify(created)); const job = created.body.data.publish_job as ProductPublishJob; await run(job, async (_client, _key, path, body) => { if (path === '/v3/product/info/list') return { items: [] }; assert.equal(path, '/v3/product/import'); const items = body.items as Array<{ attributes: Array<{ id: number; values: Array<{ value: string }>; }>; }>; assert.deepEqual( items.map( (item) => item.attributes.find( (attribute) => attribute.id === 23171, )?.values, ), [[{ value: '#kpop #oldmoney' }], [{ value: '#boho #loft' }]], ); return { result: { task_id: 23171 } }; }); }); it('主题标签支持 30 个标签,空标签不提交属性', async () => { for (const tags of [ [], Array.from({ length: 30 }, (_, index) => `#tag${index}`), ]) { const f = await fixture(1); await db.workspaceTransaction(workspace, (tx) => tx.query( 'UPDATE atlas.product_platform_profiles SET tags=$2::jsonb WHERE id=$1', [f.profile, JSON.stringify(tags)], ), ); const created = await publish(f); assert.equal(created.status, 202, JSON.stringify(created)); const result = await db.workspaceTransaction(workspace, (tx) => tx.query<{ snapshot: { attributes: Array<{ id: number; values: Array<{ value: string }>; }>; }; }>( 'SELECT snapshot FROM atlas.product_publish_job_items WHERE job_id=$1', [created.body.data.publish_job.id], ), ); const attribute = result.rows[0].snapshot.attributes.find( (item) => item.id === 23171, ); assert.deepEqual( attribute?.values, tags.length ? [{ value: tags.join(' ') }] : undefined, ); } }); it('主题标签只接受单个井号、字母数字下划线及 30 字符边界', () => { assert.equal(ozonHashtagProblem(['#стиль_2026', '#boho']), null); assert.equal(ozonHashtagProblem(['#' + 'я'.repeat(29)]), null); for (const tag of [ 'стиль', '#стиль мода', '#стиль-мода', '#стиль/мода', '#стиль#мода', '#стиль😀', '#', '#' + 'я'.repeat(30), ]) assert.ok(ozonHashtagProblem([tag]), tag); assert.ok( ozonHashtagProblem(Array.from({ length: 31 }, () => '#стиль')), ); }); it('历史不合规主题标签在预检与创建发布时拦截,不影响合法 SKU 覆盖', async () => { const f = await fixture(); await db.workspaceTransaction(workspace, async (tx) => { await tx.query( 'UPDATE atlas.product_platform_profiles SET tags=$2::jsonb WHERE id=$1', [f.profile, JSON.stringify(['спортивный стиль'])], ); await tx.query( 'UPDATE atlas.product_variant_platform_listings SET tags=$3::jsonb WHERE profile_id=$1 AND product_variant_id=$2', [f.profile, f.ids[0], JSON.stringify(['#спортивный_стиль'])], ); }); const preflight = await request( `/product-platform-profiles/${f.profile}/publish-preflights`, 'POST', { variant_ids: f.ids }, ); assert.equal(preflight.status, 200, JSON.stringify(preflight)); assert.equal(preflight.body.data.status, 'blocked'); const validation = preflight.body.data.validation; assert.equal( validation.variants.find( (v: { variant_id: string }) => v.variant_id === f.ids[0], ).ready, true, ); assert.ok( validation.variants .find((v: { variant_id: string }) => v.variant_id === f.ids[1]) .issues.some( (issue: { code: string; field: string }) => issue.code === 'OZON_HASHTAG_INVALID' && issue.field === 'profile.tags', ), ); const rejected = await publish(f); assert.equal(rejected.status, 422); assert.ok( rejected.body.error.details.some( (issue: { code: string }) => issue.code === 'OZON_HASHTAG_INVALID', ), ); const selected = await publish(f, [f.ids[0]]); assert.equal(selected.status, 202, JSON.stringify(selected)); const invalidSku = await fixture(1); await db.workspaceTransaction(workspace, (tx) => tx.query( 'UPDATE atlas.product_variant_platform_listings SET tags=$3::jsonb WHERE profile_id=$1 AND product_variant_id=$2', [ invalidSku.profile, invalidSku.ids[0], JSON.stringify(['#стиль-мода']), ], ), ); const response = await publish(invalidSku); assert.equal(response.status, 422); assert.ok( response.body.error.details.some( (issue: { code: string; field: string }) => issue.code === 'OZON_HASHTAG_INVALID' && issue.field === `variants.${invalidSku.ids[0]}.tags`, ), ); }); it('预检、并发幂等、精度快照、Outbox 与跨租户隔离', async () => { const f = await fixture(); const key = randomUUID(); const [a, b] = await Promise.all([ publish(f, undefined, key), publish(f, undefined, key), ]); assert.equal(a.status, 202, JSON.stringify(a)); assert.deepEqual(a.body, b.body); const job = a.body.data.publish_job as ProductPublishJob; assert.equal(job.items.length, 2); const facts = await db.workspaceTransaction(workspace, async (tx) => ({ items: ( await tx.query( 'SELECT snapshot FROM atlas.product_publish_job_items WHERE job_id=$1', [job.id], ) ).rows, events: ( await tx.query( 'SELECT event_payload FROM atlas.outbox_events WHERE aggregate_id=$1', [job.id], ) ).rows, listings: ( await tx.query( 'SELECT selling_price::text, list_price::text FROM atlas.product_variant_platform_listings WHERE profile_id=$1', [f.profile], ) ).rows, })); assert.equal(facts.events.length, 1); assert.deepEqual(facts.events[0].event_payload, { publish_job_id: job.id, }); assert.equal(facts.items[0].snapshot.price, '2999.1234'); assert.equal(facts.listings[0].selling_price, '1999.1234'); assert.equal(facts.listings[0].list_price, '2999.1234'); assert.ok(!('old_price' in facts.items[0].snapshot)); assert.equal(facts.items[0].snapshot.depth, 240); assert.ok(!('vat' in facts.items[0].snapshot)); assert.ok(!('images' in facts.items[0].snapshot)); const other = randomUUID(); assert.equal( ( await db.workspaceTransaction(other, (tx) => tx.query( 'SELECT id FROM atlas.product_publish_jobs WHERE id=$1', [job.id], ), ) ).rows.length, 0, ); assert.equal( ( await request( `/product-publish-jobs/${job.id}`, 'GET', undefined, { 'X-Atlas-Platform-Id': randomUUID() }, ) ).status, 404, ); assert.equal( (await publish(f)).body.error.code, 'SKU_IMPORT_IN_PROGRESS', ); assert.equal( ( await publish(f, [ ...Array.from({ length: 51 }, () => randomUUID()), ]) ).status, 422, ); assert.equal( ( await request( `/product-platform-profiles/${f.profile}/publish-jobs`, 'POST', { variant_ids: f.ids }, { 'Idempotency-Key': randomUUID(), 'If-Match': '"2:1"' }, ) ).body.error.code, 'VERSION_CONFLICT', ); }); it('首次价格使用划线价;零折扣仍可提交,缺失或零划线价阻断预检和创建', async () => { const equal = await fixture(1); await db.workspaceTransaction(workspace, (tx) => tx.query( 'UPDATE atlas.product_variant_platform_listings SET list_price=selling_price WHERE profile_id=$1', [equal.profile], ), ); const created = await publish(equal); assert.equal(created.status, 202, JSON.stringify(created)); await run(created.body.data.publish_job, async (_client, _key, path, body) => { if (path === '/v3/product/info/list') return { items: [] }; assert.equal(path, '/v3/product/import'); const item = (body.items as any[])[0]; assert.equal(item.price, '1999.1234'); assert.ok(!('old_price' in item)); return { result: { task_id: 987654 } }; }); for (const listPrice of [null, '0']) { const f = await fixture(1); await db.workspaceTransaction(workspace, (tx) => tx.query( 'UPDATE atlas.product_variant_platform_listings SET list_price=$2, selling_price=CASE WHEN $2::numeric=0 THEN 0 ELSE selling_price END WHERE profile_id=$1', [f.profile, listPrice], ), ); const preflight = await request( `/product-platform-profiles/${f.profile}/publish-preflights`, 'POST', { variant_ids: f.ids }, { 'Idempotency-Key': randomUUID(), 'If-Match': '"1:1"' }, ); assert.equal(preflight.status, 200, JSON.stringify(preflight)); assert.equal(preflight.body.data.validation.variants[0].ready, false); assert.ok(preflight.body.data.validation.variants[0].issues.some( (issue: { code: string; field: string }) => issue.code === 'LIST_PRICE_REQUIRED' && issue.field === `variants.${f.ids[0]}.list_price`, )); const rejected = await publish(f); assert.equal(rejected.status, 422); assert.ok(rejected.body.error.details.some( (issue: { code: string }) => issue.code === 'LIST_PRICE_REQUIRED', )); } }); it('只校验所选 SKU;不就绪的未选 SKU 不阻断部分上架', async () => { const f = await fixture(); await db.workspaceTransaction(workspace, (tx) => tx.query( `UPDATE atlas.product_variant_platform_listings SET offer_id=NULL WHERE profile_id=$1 AND product_variant_id=$2`, [f.profile, f.ids[1]], ), ); const all = await publish(f); assert.equal(all.status, 422); assert.equal(all.body.error.code, 'PUBLISH_VALIDATION_FAILED'); const partial = await publish(f, [f.ids[0]]); assert.equal(partial.status, 202, JSON.stringify(partial)); assert.equal(partial.body.data.publish_job.item_count, 1); }); it('混合结果持久化,成功锁定型号/货号,重复消息不重复上报', async () => { const f = await fixture(); let job = (await publish(f)).body.data.publish_job as ProductPublishJob; let imports = 0; job = await run(job, async (_client, _key, path, body) => { if (path === '/v3/product/info/list') return { items: [] }; assert.equal(path, '/v3/product/import'); imports++; const items = body.items as any[]; assert.equal(items.length, 2); assert.equal(items[0].price, '2999.1234'); assert.ok(!('old_price' in items[0])); assert.ok(!('vat' in items[0])); assert.ok(!('barcode' in items[0])); assert.ok(!('description' in items[0])); return { result: { task_id: 123456 } }; }); assert.equal(job.items[0].status, 'polling'); const offers = job.items.map((i) => i.offer_id); job = await run(job, async (_c, _k, path) => path === '/v2/product/pictures/info' ? ({ items: [{ product_id: 101, primary_photo: ['https://images.example.test/fixture.png'], photo: [], errors: [] }], }) : ({ result: { items: [ { offer_id: offers[0], product_id: 101, status: 'imported', errors: [], }, { offer_id: offers[1], status: 'failed', errors: [ { code: 'BAD_IMAGE', message: '失败 https://private.example.test/a?token=hidden', }, ], }, ], }, })); assert.equal(job.status, 'partially_succeeded'); assert.equal( job.items.find((i) => i.status === 'imported')?.platform_product_id, '101', ); assert.ok(!JSON.stringify(job).includes('token=')); await assert.rejects( db.workspaceTransaction(workspace, (tx) => tx.query( `UPDATE atlas.products SET model='CHANGED' WHERE id=$1`, [f.product], ), ), /PRODUCT_MODEL_LOCKED/, ); await assert.rejects( db.workspaceTransaction(workspace, (tx) => tx.query( `UPDATE atlas.product_variant_platform_listings SET offer_id='CHANGED' WHERE profile_id=$1 AND status='imported'`, [f.profile], ), ), /SKU_ALREADY_IMPORTED/, ); await run(job, async () => { imports++; throw new Error('终态不应再访问平台'); }); assert.equal(imports, 1); }); it('响应丢失后只按货号对账,绝不重复导入', async () => { const f = await fixture(1); let job = (await publish(f)).body.data.publish_job as ProductPublishJob; let imports = 0; job = await run(job, async (_c, _k, path) => { if (path === '/v3/product/info/list') return { items: [] }; imports++; throw new OzonRetryableError('response lost'); }); assert.equal(job.status, 'reconciling'); job = await run(job, async (_c, _k, path) => { assert.equal(path, '/v3/product/info/list'); return { items: [] }; }); assert.equal(job.status, 'reconciling'); assert.equal(imports, 1); job = await run(job, async (_c, _k, path) => ({ items: path === '/v2/product/pictures/info' ? [{ product_id: 999, primary_photo: ['https://images.example.test/fixture.png'], photo: [], errors: [] }] : [{ offer_id: job.items[0].offer_id, id: 999 }], })); assert.equal(job.status, 'succeeded'); assert.equal(imports, 1); }); it('导入成功后等待图片结果,缺图警告保留到 HTTP 结果与 SKU,并且不重复导入', async () => { const f = await fixture(1, 3); let job = (await publish(f)).body.data.publish_job as ProductPublishJob; let imports = 0; job = await run(job, async (_c, _k, path) => { if (path === '/v3/product/info/list') return { items: [] }; assert.equal(path, '/v3/product/import'); imports++; return { result: { task_id: 456 } }; }); const imported = { result: { items: [{ offer_id: job.items[0].offer_id, product_id: 102, status: 'imported', errors: [] }] } }; job = await run(job, async (_c, _k, path) => path === '/v1/product/import/info' ? imported : { items: [{ product_id: 102, primary_photo: [], photo: [], errors: [] }] }); assert.equal(job.status, 'running'); assert.equal(job.items[0].status, 'polling'); job = await run(job, async (_c, _k, path) => path === '/v1/product/import/info' ? { result: { items: [{ ...imported.result.items[0], errors: [{ code: 'primary_image_load_failed', message: 'https://private.example.test/primary.png?token=hidden' }] }] }, } : { items: [{ product_id: 102, primary_photo: ['https://images.example.test/second.png'], photo: [], errors: [{ url: 'https://private.example.test/primary.png?token=hidden', message: 'request process failed: unknown error host unreachable' }, { url: 'https://private.example.test/third.png?token=hidden', message: 'failed https://private.example.test/third.png?token=hidden' }] }] }); assert.equal(job.status, 'succeeded'); assert.equal(job.items[0].status, 'imported'); assert.equal(job.items[0].platform_product_id, '102'); assert.ok(job.items[0].errors.some((error) => error.code === 'OZON_IMAGES_INCOMPLETE' && error.message.includes('1/3'))); assert.ok(job.items[0].errors.some((error) => error.code === 'primary_image_load_failed')); assert.ok(job.items[0].errors.some((error) => error.message.includes('全球加速'))); assert.ok(!JSON.stringify(job).includes('token=')); assert.ok(!JSON.stringify(job).includes('private.example.test')); const listing = (await db.workspaceTransaction(workspace, tx => tx.query( 'SELECT status,last_error FROM atlas.product_variant_platform_listings WHERE profile_id=$1', [f.profile], ))).rows[0]; assert.equal(listing.status, 'imported'); assert.deepEqual(listing.last_error, job.items[0].errors); await run(job, async () => { imports++; throw new Error('终态不得重复上报'); }); assert.equal(imports, 1); }); it('图片全部到齐才报告无警告成功,长时间未齐时以已导入并提示缺图结束', async () => { for (const completePictures of [true, false]) { const f = await fixture(1, 3); let job = (await publish(f)).body.data.publish_job as ProductPublishJob; job = await run(job, async (_c, _k, path) => path === '/v3/product/info/list' ? { items: [] } : { result: { task_id: 457 } }); if (!completePictures) await db.workspaceTransaction(workspace, tx => tx.query( 'UPDATE atlas.product_publish_jobs SET poll_count=121 WHERE id=$1', [job.id], )); job = await run(job, async (_c, _k, path) => path === '/v1/product/import/info' ? { result: { items: [{ offer_id: job.items[0].offer_id, product_id: 103, status: 'imported', errors: [] }] } } : { items: [{ product_id: 103, primary_photo: completePictures ? ['https://images.example.test/1.png'] : [], photo: completePictures ? ['https://images.example.test/2.png', 'https://images.example.test/3.png'] : [], errors: [] }] }); assert.equal(job.status, 'succeeded'); assert.equal(job.items[0].status, 'imported'); assert.equal(job.items[0].errors.length, completePictures ? 0 : 1); } }); it('任一图片准备失败时整批不提交,并隐藏存储路径', async () => { const f = await fixture(2); const created = (await publish(f)).body.data .publish_job as ProductPublishJob; let prepared = 0; const job = await run( created, async (_c, _k, path) => { assert.equal(path, '/v3/product/info/list'); return { items: [] }; }, async () => { if (++prepared === 2) throw new Error('AccessDenied secret/object/key'); return ['https://images.example.test/fixture.png']; }, ); assert.equal(job.status, 'failed'); assert.ok(job.items.every((i) => i.status === 'failed')); assert.ok(!JSON.stringify(job).includes('secret/object')); const source = await db.workspaceTransaction(workspace, (tx) => tx.query( 'SELECT submit_started_at FROM atlas.product_publish_jobs WHERE id=$1', [job.id], ), ); assert.equal(source.rows[0].submit_started_at, null); }); it('远端已有同货号时跳过,不调用更新接口;429 延迟后允许提交', async () => { const existing = await fixture(1); let job = (await publish(existing)).body.data .publish_job as ProductPublishJob; job = await run(job, async (_c, _k, path) => { assert.equal(path, '/v3/product/info/list'); return { items: [{ offer_id: job.items[0].offer_id, id: 88 }] }; }); assert.equal(job.items[0].status, 'skipped'); assert.equal(job.items[0].errors[0].code, 'OZON_OFFER_ALREADY_EXISTS'); const f = await fixture(1); job = (await publish(f)).body.data.publish_job as ProductPublishJob; job = await run(job, async (_c, _k, path) => { if (path === '/v3/product/info/list') return { items: [] }; throw new OzonRateLimitError(120); }); assert.equal(job.items[0].status, 'queued'); const source = await db.workspaceTransaction(workspace, (tx) => tx.query( 'SELECT submit_started_at FROM atlas.product_publish_jobs WHERE id=$1', [job.id], ), ); assert.equal(source.rows[0].submit_started_at, null); job = await run(job, async (_c, _k, path) => path === '/v3/product/info/list' ? { items: [] } : { result: { task_id: 987 } }, ); assert.equal(job.items[0].status, 'polling'); }); });