fix(edit): 类目问题修改
This commit is contained in:
@@ -126,6 +126,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
'DELETE FROM atlas.product_attributes WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.product_variant_listing_media WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.product_variant_platform_listings WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
@@ -157,6 +161,9 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [
|
||||
workspaceId,
|
||||
]);
|
||||
await tx.query('DELETE FROM atlas.media_assets WHERE workspace_id = $1', [
|
||||
workspaceId,
|
||||
]);
|
||||
});
|
||||
await database.query(
|
||||
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
|
||||
@@ -1058,7 +1065,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await tx.query(
|
||||
`UPDATE atlas.collection_items
|
||||
SET source_platform_id = $1,
|
||||
SET source_kind = 'platform', source_platform_id = $1,
|
||||
normalized_payload = $2::jsonb
|
||||
WHERE id = $3`,
|
||||
[
|
||||
@@ -1193,4 +1200,97 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('M3.6 仅允许当前 SKU 已转存候选作为有序正式素材', async () => {
|
||||
const before = await request(`/api/products/${first.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
const variant = before.body.data.variants[0];
|
||||
const asset = await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const created = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.media_assets
|
||||
(workspace_id, storage_provider, bucket, object_key, content_type,
|
||||
byte_size, checksum_sha256, width_px, height_px)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, 'test', 'test', $1,
|
||||
'image/jpeg', 1024, $2, 1200, 900)
|
||||
RETURNING id`,
|
||||
[`m36/${randomUUID()}.jpg`, randomUUID().replaceAll('-', '')],
|
||||
);
|
||||
const snapshot = await tx.query<{ source_snapshot_revision: string }>(
|
||||
`SELECT source_snapshot_revision::text FROM atlas.collection_items WHERE id = $1`,
|
||||
[first.collectionItemId],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.collection_asset_transfers
|
||||
(workspace_id, collection_item_id, product_id, source_variant_id, role,
|
||||
source_url, status, asset_id, source_snapshot_revision, completed_at)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3,
|
||||
'source_sku', 'https://example.test/m36.jpg', 'succeeded', $4, $5, now())`,
|
||||
[
|
||||
first.collectionItemId,
|
||||
first.productId,
|
||||
variant.id,
|
||||
created.rows[0].id,
|
||||
snapshot.rows[0].source_snapshot_revision,
|
||||
],
|
||||
);
|
||||
return created.rows[0].id;
|
||||
});
|
||||
const payload = {
|
||||
ai_source_material: before.body.data.source_material,
|
||||
ai_source_overridden_fields:
|
||||
before.body.data.source_material_state.overridden_fields,
|
||||
common: {
|
||||
...before.body.data.product.common,
|
||||
default_physical_spec: before.body.data.product.default_physical_spec,
|
||||
default_package_spec: before.body.data.product.default_package_spec,
|
||||
},
|
||||
profile: {
|
||||
target_store_id: before.body.data.profile.target_store.id,
|
||||
fulfillment: before.body.data.profile.fulfillment,
|
||||
title: before.body.data.profile.title,
|
||||
description: before.body.data.profile.description,
|
||||
tags: before.body.data.profile.tags,
|
||||
store_product_category_id: before.body.data.profile.category.id,
|
||||
category_source: before.body.data.profile.category.source,
|
||||
},
|
||||
attributes: before.body.data.attributes.map((attribute: any) => ({
|
||||
scope: attribute.scope,
|
||||
variant_id: attribute.variant_id,
|
||||
platform_attribute_id: attribute.platform_attribute_id,
|
||||
complex_id: attribute.complex_id,
|
||||
values: attribute.values,
|
||||
language_code: attribute.language_code,
|
||||
source: attribute.source,
|
||||
confirmation_status: attribute.confirmation_status,
|
||||
})),
|
||||
variants: before.body.data.variants.map((item: any) => ({
|
||||
id: item.id,
|
||||
client_key: null,
|
||||
copy_from_variant_id: null,
|
||||
display_name: item.display_name,
|
||||
source_sku: item.source_sku,
|
||||
specification_values: item.specification_values,
|
||||
purchase_cost_cny: item.purchase_cost_cny,
|
||||
physical_spec: item.physical_spec,
|
||||
package_spec: item.package_spec,
|
||||
offer_id: item.listing.offer_id,
|
||||
media_asset_ids: item.id === variant.id ? [asset] : [],
|
||||
})),
|
||||
};
|
||||
const saved = await request(`/api/products/${first.productId}/editor-draft`, {
|
||||
method: 'PUT',
|
||||
headers: headers({
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`,
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
assert.equal(saved.response.status, 200);
|
||||
const after = await request(`/api/products/${first.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
assert.deepEqual(after.body.data.variants[0].media.map((item: any) => item.asset_id), [asset]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
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 { afterAll, beforeAll, describe, it } from 'vitest';
|
||||
import { DATABASE } from '../src/database/database.module.js';
|
||||
import { createApplication } from '../src/main.js';
|
||||
import { OutboxDispatcher } from '../../worker/src/outbox-dispatcher.js';
|
||||
import { scheduleStoreCategoryTreeSync } from '../../worker/src/store-category-tree-scheduler.js';
|
||||
|
||||
let app: NestExpressApplication;
|
||||
let database: Database;
|
||||
let baseUrl = '';
|
||||
let workspaceId = '';
|
||||
let userId = '';
|
||||
let token = '';
|
||||
let platformId = '';
|
||||
let storeId = '';
|
||||
let secretId = '';
|
||||
|
||||
async function request(path: string, init: RequestInit = {}) {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: { 'Content-Type': 'application/json', ...init.headers },
|
||||
});
|
||||
return { response, body: (await response.json()) as any };
|
||||
}
|
||||
|
||||
async function scheduleWithStoreLock() {
|
||||
return database.workspaceTransaction(workspaceId, async (transaction) => {
|
||||
await transaction.query(
|
||||
'SELECT id FROM atlas.stores WHERE id = $1 FOR UPDATE',
|
||||
[storeId],
|
||||
);
|
||||
return scheduleStoreCategoryTreeSync(transaction, {
|
||||
storeId,
|
||||
platformId,
|
||||
secretId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('门店类目树 Worker 调度集成测试', () => {
|
||||
beforeAll(async () => {
|
||||
Object.assign(process.env, {
|
||||
NODE_ENV: 'test',
|
||||
SWAGGER_ENABLED: 'false',
|
||||
AUTH_REGISTER_IP_LIMIT: '100',
|
||||
AUTH_REGISTER_EMAIL_LIMIT: '100',
|
||||
});
|
||||
const created = await createApplication();
|
||||
app = created.app;
|
||||
database = app.get<Database>(DATABASE);
|
||||
await app.listen(0, '127.0.0.1');
|
||||
baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
|
||||
const registration = await request('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: `catalog-worker-${randomUUID()}@example.test`,
|
||||
password: 'correct-horse-battery-staple-123',
|
||||
password_confirmation: 'correct-horse-battery-staple-123',
|
||||
}),
|
||||
});
|
||||
assert.equal(registration.response.status, 201);
|
||||
workspaceId = registration.body.data.workspace.id;
|
||||
userId = registration.body.data.user.id;
|
||||
token = registration.body.data.access_token;
|
||||
const platforms = await request('/api/platforms', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
platformId = platforms.body.data[0].id;
|
||||
const seeded = await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const 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 (current_setting('app.workspace_id')::uuid, $1, '类目 Worker 测试店',
|
||||
$2, 'RUB', true, 'active', 'valid')
|
||||
RETURNING id`,
|
||||
[platformId, `catalog-worker-${randomUUID()}`],
|
||||
);
|
||||
const secret = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.store_secrets
|
||||
(workspace_id, store_id, kind, secret_ciphertext, secret_nonce,
|
||||
encryption_key_version, fingerprint, status)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, 'seller_api_key',
|
||||
$2, $3, 'test', $4, 'valid')
|
||||
RETURNING id`,
|
||||
[
|
||||
store.rows[0].id,
|
||||
Buffer.from('catalog-worker-api-key-must-not-leak'),
|
||||
Buffer.from('catalog-worker-nonce'),
|
||||
randomUUID(),
|
||||
],
|
||||
);
|
||||
return { storeId: store.rows[0].id, secretId: secret.rows[0].id };
|
||||
});
|
||||
storeId = seeded.storeId;
|
||||
secretId = seeded.secretId;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await tx.query('DELETE FROM atlas.outbox_events WHERE workspace_id = $1', [workspaceId]);
|
||||
await tx.query('DELETE FROM atlas.operations WHERE workspace_id = $1', [workspaceId]);
|
||||
await tx.query('DELETE FROM atlas.store_product_categories WHERE workspace_id = $1', [workspaceId]);
|
||||
await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [workspaceId]);
|
||||
});
|
||||
await database.query('DELETE FROM atlas.workspace_memberships WHERE user_id = $1', [userId]);
|
||||
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [workspaceId]);
|
||||
await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('空缓存的并发验证只创建一个无密钥的树同步任务', async () => {
|
||||
const results = await Promise.all([scheduleWithStoreLock(), scheduleWithStoreLock()]);
|
||||
assert.equal(results.filter((result) => result.scheduled).length, 1);
|
||||
assert.ok(results.some((result) => !result.scheduled && result.reason === 'already_queued'));
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const events = await tx.query<{ event_payload: unknown }>(
|
||||
`SELECT event_payload FROM atlas.outbox_events
|
||||
WHERE aggregate_id = $1 AND event_type = 'store_category_tree_sync.requested'`,
|
||||
[storeId],
|
||||
);
|
||||
assert.equal(events.rows.length, 1);
|
||||
const payload = JSON.stringify(events.rows[0].event_payload);
|
||||
assert.ok(!payload.includes('catalog-worker-api-key-must-not-leak'));
|
||||
assert.ok(!payload.includes('catalog-worker-nonce'));
|
||||
});
|
||||
});
|
||||
|
||||
it('已有已同步的门店缓存时跳过 Ozon 请求调度', async () => {
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await tx.query(
|
||||
`UPDATE atlas.operations SET status = 'succeeded', completed_at = now()
|
||||
WHERE kind = 'store_category_tree_sync' AND resource_id = $1`,
|
||||
[storeId],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.store_product_categories
|
||||
(workspace_id, store_id, platform_id, external_category_id, external_type_id,
|
||||
name, path, synced_at)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2,
|
||||
'worker-cache', 'worker-type', '已缓存类目', '已缓存类目', now())`,
|
||||
[storeId, platformId],
|
||||
);
|
||||
});
|
||||
const result = await scheduleWithStoreLock();
|
||||
assert.deepEqual(result, { scheduled: false, reason: 'cache_available' });
|
||||
});
|
||||
|
||||
it('为 Ozon 暂时失败配置五次指数退避', async () => {
|
||||
const enqueued: Array<{ name: string; options: Record<string, unknown> }> = [];
|
||||
const dispatcher = new OutboxDispatcher(
|
||||
{
|
||||
query: async () => ({ rows: [{ id: workspaceId }] }),
|
||||
workspaceTransaction: async (_workspaceId: string, work: any) =>
|
||||
work({
|
||||
query: async (sql: string) =>
|
||||
sql.includes('RETURNING event.id')
|
||||
? {
|
||||
rows: [
|
||||
{
|
||||
id: randomUUID(),
|
||||
workspace_id: workspaceId,
|
||||
event_type: 'store_category_tree_sync.requested',
|
||||
event_payload: {
|
||||
store_id: storeId,
|
||||
secret_id: secretId,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: { rows: [] },
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
add: async (name: string, _data: unknown, options: Record<string, unknown>) => {
|
||||
enqueued.push({ name, options });
|
||||
},
|
||||
} as never,
|
||||
'worker-test',
|
||||
);
|
||||
await dispatcher.dispatchBatch();
|
||||
assert.deepEqual(enqueued, [
|
||||
{
|
||||
name: 'store_category_tree_sync.requested',
|
||||
options: {
|
||||
jobId: enqueued[0]?.options.jobId,
|
||||
attempts: 5,
|
||||
backoff: { type: 'exponential', delay: 5_000 },
|
||||
removeOnComplete: 1_000,
|
||||
removeOnFail: 1_000,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user