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 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)`, [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('预检、并发幂等、精度快照、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, })); 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, '1999.1234'); 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('只校验所选 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, '1999.1234'); 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'); }); });