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