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) { 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; 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,0)`, [workspace, product, profile, listing, variant, asset], ); } }); 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('多个主题标签合并为一个 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 () => ({ 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 () => ({ items: [{ offer_id: job.items[0].offer_id, id: 999 }], })); assert.equal(job.status, 'succeeded'); assert.equal(imports, 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'); }); });