Files
atlas-seller-kit/apps/api/test/product-publish.e2e.test.ts

866 lines
42 KiB
TypeScript

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<string, string> = {},
) {
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<ReturnType<typeof fixture>>,
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>(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<br><br>Размер &lt; 10<br>• Красный',
'Общее описание<br><br>Характеристики:<br>• Один &amp; два',
]));
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');
});
});