650 lines
30 KiB
TypeScript
650 lines
30 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 { afterAll, beforeAll, describe, it } from 'vitest';
|
|
import { DATABASE } from '../src/database/database.module.js';
|
|
import { createApplication } from '../src/main.js';
|
|
import { PRODUCT_VARIANT_LIMIT } from '@atlas/contracts';
|
|
|
|
let app: NestExpressApplication;
|
|
let database: Database;
|
|
let baseUrl = '';
|
|
let workspaceId = '';
|
|
let userId = '';
|
|
let webAccessToken = '';
|
|
async function request(path: string, init: RequestInit = {}) {
|
|
const response = await fetch(`${baseUrl}${path}`, {
|
|
redirect: 'manual',
|
|
...init,
|
|
headers: { 'Content-Type': 'application/json', ...init.headers },
|
|
});
|
|
return {
|
|
response,
|
|
body:
|
|
response.status === 204 || response.status === 302
|
|
? undefined
|
|
: ((await response.json()) as any),
|
|
};
|
|
}
|
|
|
|
describe('扩展授权与采集 HTTP 集成测试', () => {
|
|
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}`;
|
|
});
|
|
afterAll(async () => {
|
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
|
await tx.query(
|
|
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
|
|
[workspaceId],
|
|
);
|
|
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.collection_asset_transfers WHERE workspace_id = $1',
|
|
[workspaceId],
|
|
);
|
|
await tx.query(
|
|
'DELETE FROM atlas.product_variant_platform_listings WHERE workspace_id = $1',
|
|
[workspaceId],
|
|
);
|
|
await tx.query(
|
|
'DELETE FROM atlas.product_platform_profiles WHERE workspace_id = $1',
|
|
[workspaceId],
|
|
);
|
|
await tx.query('DELETE FROM atlas.stores WHERE workspace_id = $1', [
|
|
workspaceId,
|
|
]);
|
|
await tx.query(
|
|
'DELETE FROM atlas.product_variants WHERE workspace_id = $1',
|
|
[workspaceId],
|
|
);
|
|
await tx.query(
|
|
'DELETE FROM atlas.products WHERE workspace_id = $1',
|
|
[workspaceId],
|
|
);
|
|
await tx.query(
|
|
'DELETE FROM atlas.collection_items WHERE workspace_id = $1',
|
|
[workspaceId],
|
|
);
|
|
await tx.query(
|
|
'DELETE FROM atlas.extension_authorization_codes WHERE workspace_id = $1',
|
|
[workspaceId],
|
|
);
|
|
await tx.query(
|
|
'DELETE FROM atlas.extension_sessions 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 session = await request('/api/extension/session');
|
|
assert.equal(session.response.status, 401);
|
|
assert.equal(session.body.error.code, 'SESSION_EXPIRED');
|
|
assert.equal(
|
|
session.body.error.message,
|
|
'Atlas ERP 登录已过期,请重新登录',
|
|
);
|
|
});
|
|
|
|
it('通过 Atlas Web access token 写入来源商品,并以新快照覆盖重复采集', async () => {
|
|
const registration = await request('/api/auth/register', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
email: `extension-${randomUUID()}@example.test`,
|
|
password: 'correct-horse-battery-staple-123',
|
|
password_confirmation: 'correct-horse-battery-staple-123',
|
|
}),
|
|
});
|
|
assert.equal(registration.response.status, 201);
|
|
userId = registration.body.data.user.id;
|
|
workspaceId = registration.body.data.workspace.id;
|
|
webAccessToken = registration.body.data.access_token as string;
|
|
const session = await request('/api/extension/session', {
|
|
headers: { Authorization: `Bearer ${webAccessToken}` },
|
|
});
|
|
assert.equal(session.response.status, 200);
|
|
assert.equal(session.body.data.workspace.id, workspaceId);
|
|
const payload = {
|
|
source: {
|
|
platform: 'ozon',
|
|
itemKey: '123456789',
|
|
canonicalUrl: 'https://www.ozon.ru/product/demo-123456789/',
|
|
collectedAt: new Date().toISOString(),
|
|
parserVersion: 'test-1',
|
|
},
|
|
common: {
|
|
title: { value: '来源商品', confidence: 'verified' },
|
|
sourcePrice: { amount: '99.90', currencyCode: 'RUB' },
|
|
attributes: [],
|
|
categories: [],
|
|
},
|
|
variantAxes: [],
|
|
variants: [
|
|
{
|
|
sourceVariantKey: '123456789',
|
|
sourceSku: '123456789',
|
|
displayName: '默认 SKU',
|
|
selections: [],
|
|
confidence: 'verified',
|
|
evidenceRefs: ['page'],
|
|
},
|
|
],
|
|
assets: [
|
|
{
|
|
url: 'https://images.example.test/product.jpg',
|
|
role: 'source_main',
|
|
relation: 'product_candidate',
|
|
sortOrder: 0,
|
|
evidenceRef: 'page',
|
|
},
|
|
],
|
|
evidence: [{ ref: 'page', kind: 'page', path: 'test' }],
|
|
};
|
|
const firstKey = randomUUID();
|
|
const first = await request('/api/extension/materials', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${webAccessToken}`,
|
|
'Idempotency-Key': firstKey,
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
assert.equal(first.response.status, 201);
|
|
assert.equal(first.body.data.reused, false);
|
|
assert.equal(first.body.data.collection_item.status, 'ready_for_edit');
|
|
assert.equal(first.body.data.materialization.pending_selection, 1);
|
|
assert.equal(first.body.data.materialization.queued, 0);
|
|
const editorEntry = new URL(first.body.data.editor_entry_path, 'http://atlas.example.test');
|
|
assert.equal(editorEntry.pathname, `/products/${first.body.data.product_id}/editor`);
|
|
assert.equal(editorEntry.searchParams.get('collection_item_id'), first.body.data.collection_item.id);
|
|
const replay = await request('/api/extension/materials', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${webAccessToken}`,
|
|
'Idempotency-Key': firstKey,
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
assert.deepEqual(replay.body, first.body);
|
|
const profilesBeforeEntry = await database.workspaceTransaction(workspaceId, (tx) =>
|
|
tx.query('SELECT id FROM atlas.product_platform_profiles WHERE product_id=$1', [first.body.data.product_id]),
|
|
);
|
|
assert.equal(profilesBeforeEntry.rowCount, 0);
|
|
const source1688 = {
|
|
source: {
|
|
platform: '1688', itemKey: '1688-price-image-test',
|
|
canonicalUrl: 'https://detail.1688.com/offer/123456789.html',
|
|
collectedAt: new Date().toISOString(), parserVersion: '1688-test',
|
|
},
|
|
common: { attributes: [], categories: [] },
|
|
variantAxes: [],
|
|
variants: [{
|
|
sourceVariantKey: '6100454176961', sourceSku: '6100454176961',
|
|
displayName: '六代目卡卡西25cm', selections: [],
|
|
sourcePrice: { amount: '31.335', currencyCode: 'CNY' },
|
|
confidence: 'verified', evidenceRefs: ['1688:sku-map'],
|
|
}],
|
|
assets: [{
|
|
url: 'https://cbu01.alicdn.com/sku.jpg', role: 'source_sku',
|
|
relation: 'axis_value', variantSourceKey: '6100454176961',
|
|
sortOrder: 0, evidenceRef: '1688:sku-map',
|
|
}],
|
|
evidence: [{ ref: '1688:sku-map', kind: 'state', path: 'skuModel.skuInfoMap' }],
|
|
};
|
|
const captured1688 = await request('/api/extension/materials', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${webAccessToken}`,
|
|
'Idempotency-Key': randomUUID(),
|
|
},
|
|
body: JSON.stringify(source1688),
|
|
});
|
|
assert.equal(captured1688.response.status, 201);
|
|
const associated1688 = await database.workspaceTransaction(workspaceId, async (tx) =>
|
|
tx.query<{ purchase_cost_cny: string; source_variant_id: string; variant_id: string }>(
|
|
`SELECT v.purchase_cost_cny::text, t.source_variant_id, v.id AS variant_id
|
|
FROM atlas.product_variants v
|
|
JOIN atlas.collection_asset_transfers t ON t.product_id = v.product_id
|
|
WHERE v.product_id = $1 AND t.role = 'source_sku'`,
|
|
[captured1688.body.data.product_id],
|
|
),
|
|
);
|
|
assert.equal(associated1688.rows[0]?.purchase_cost_cny, '31.3400');
|
|
assert.equal(associated1688.rows[0]?.source_variant_id, associated1688.rows[0]?.variant_id);
|
|
const refreshed1688 = await request('/api/extension/materials', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${webAccessToken}`,
|
|
'Idempotency-Key': randomUUID(),
|
|
},
|
|
body: JSON.stringify({
|
|
...source1688,
|
|
variants: [{
|
|
...source1688.variants[0],
|
|
sourcePrice: { amount: '32.525', currencyCode: 'CNY' },
|
|
}],
|
|
}),
|
|
});
|
|
assert.equal(refreshed1688.response.status, 200);
|
|
const updatedCost = await database.workspaceTransaction(workspaceId, async (tx) =>
|
|
tx.query<{ purchase_cost_cny: string }>(
|
|
`SELECT purchase_cost_cny::text FROM atlas.product_variants WHERE product_id = $1`,
|
|
[captured1688.body.data.product_id],
|
|
),
|
|
);
|
|
assert.equal(updatedCost.rows[0]?.purchase_cost_cny, '32.5300');
|
|
const platforms = await request('/api/platforms', {
|
|
headers: { Authorization: `Bearer ${webAccessToken}` },
|
|
});
|
|
const targetPlatform = platforms.body.data.find(
|
|
(platform: { code: string }) => platform.code === 'OZON',
|
|
);
|
|
assert.ok(targetPlatform);
|
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
|
await tx.query(
|
|
`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, '复采测试店铺',
|
|
$2, 'RUB', true, 'active', 'valid')`,
|
|
[targetPlatform.id, `refresh-${randomUUID()}`],
|
|
);
|
|
});
|
|
const editorContext = await request(
|
|
`/api/collection-items/${editorEntry.searchParams.get('collection_item_id')}/editor-contexts`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${webAccessToken}`,
|
|
'X-Atlas-Platform-Id': targetPlatform.id,
|
|
'Idempotency-Key': randomUUID(),
|
|
},
|
|
},
|
|
);
|
|
assert.equal(editorContext.response.status, 201);
|
|
assert.equal(editorContext.body.data.product_id, first.body.data.product_id);
|
|
assert.equal(editorContext.body.data.editor_path, editorEntry.pathname);
|
|
const readEditor = (productId: string) => request(
|
|
`/api/products/${productId}/editor`,
|
|
{ headers: {
|
|
Authorization: `Bearer ${webAccessToken}`,
|
|
'X-Atlas-Platform-Id': targetPlatform.id,
|
|
} },
|
|
);
|
|
const initialEditor = await readEditor(first.body.data.product_id);
|
|
assert.equal(initialEditor.response.status, 200);
|
|
assert.equal(initialEditor.body.data.product.common.purchase_url, payload.source.canonicalUrl);
|
|
const sourceLink = await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
|
'SELECT purchase_url FROM atlas.products WHERE id=$1',
|
|
[captured1688.body.data.product_id],
|
|
));
|
|
assert.equal(sourceLink.rows[0].purchase_url, source1688.source.canonicalUrl);
|
|
await request(`/api/collection-items/${captured1688.body.data.collection_item.id}/editor-contexts`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${webAccessToken}`,
|
|
'X-Atlas-Platform-Id': targetPlatform.id,
|
|
'Idempotency-Key': randomUUID(),
|
|
},
|
|
});
|
|
// 模拟历史采集商品:原来只有采集记录保存了页面地址,商品货源字段为空。
|
|
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
|
'UPDATE atlas.products SET purchase_url=NULL WHERE id=$1',
|
|
[captured1688.body.data.product_id],
|
|
));
|
|
const historicalEditor = await readEditor(captured1688.body.data.product_id);
|
|
assert.equal(historicalEditor.response.status, 200);
|
|
assert.equal(historicalEditor.body.data.product.common.purchase_url, source1688.source.canonicalUrl);
|
|
// Simulate real draft work before the source page is collected again.
|
|
// Re-collection must neither delete this SKU nor overwrite its Atlas
|
|
// fields / platform mapping just because a newer source snapshot arrives.
|
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
|
const variant = await tx.query<{ id: string }>(
|
|
`SELECT id FROM atlas.product_variants
|
|
WHERE product_id = $1 AND source_variant_key = '123456789'`,
|
|
[first.body.data.product_id],
|
|
);
|
|
await tx.query(
|
|
`UPDATE atlas.products SET source_title = '人工确认商品名', purchase_url = 'https://detail.1688.com/offer/888888888.html'
|
|
WHERE id = $1`,
|
|
[first.body.data.product_id],
|
|
);
|
|
await tx.query(
|
|
`UPDATE atlas.product_variants
|
|
SET display_name = '人工确认 SKU', purchase_cost_cny = '88.80'
|
|
WHERE id = $1`,
|
|
[variant.rows[0].id],
|
|
);
|
|
await tx.query(
|
|
`UPDATE atlas.product_variant_platform_listings
|
|
SET offer_id = 'manual-offer-001'
|
|
WHERE product_variant_id = $1`,
|
|
[variant.rows[0].id],
|
|
);
|
|
});
|
|
// A sibling Ozon URL has a different entered SKU. It must still reuse
|
|
// the item because the submitted real-SKU sets overlap at 123456789.
|
|
const refreshedPayload = {
|
|
...payload,
|
|
source: {
|
|
...payload.source,
|
|
itemKey: '987654321',
|
|
canonicalUrl: 'https://www.ozon.ru/product/demo-987654321/',
|
|
collectedAt: new Date(Date.now() + 1_000).toISOString(),
|
|
parserVersion: 'test-2',
|
|
},
|
|
common: {
|
|
...payload.common,
|
|
title: {
|
|
value: '第二次采集的中文标题',
|
|
confidence: 'verified',
|
|
},
|
|
sourcePrice: { amount: '109.90', currencyCode: 'RUB' },
|
|
},
|
|
variants: [
|
|
{
|
|
sourceVariantKey: '123456789',
|
|
sourceSku: '123456789',
|
|
displayName: '第一 SKU',
|
|
selections: [],
|
|
confidence: 'verified',
|
|
evidenceRefs: ['page'],
|
|
},
|
|
{
|
|
sourceVariantKey: '987654321',
|
|
sourceSku: '987654321',
|
|
displayName: '第二次采集 SKU',
|
|
selections: [],
|
|
confidence: 'verified',
|
|
evidenceRefs: ['page'],
|
|
},
|
|
],
|
|
assets: [
|
|
{
|
|
url: 'https://images.example.test/refreshed-product.jpg',
|
|
role: 'source_main',
|
|
relation: 'product_candidate',
|
|
sortOrder: 0,
|
|
evidenceRef: 'page',
|
|
},
|
|
{
|
|
url: 'https://videos.example.test/refreshed-product.mp4',
|
|
mediaType: 'video',
|
|
posterUrl:
|
|
'https://images.example.test/refreshed-video-poster.jpg',
|
|
role: 'source_main',
|
|
relation: 'product_candidate',
|
|
sortOrder: 1,
|
|
evidenceRef: 'page',
|
|
},
|
|
],
|
|
};
|
|
const second = await request('/api/extension/materials', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${webAccessToken}`,
|
|
'Idempotency-Key': randomUUID(),
|
|
},
|
|
body: JSON.stringify(refreshedPayload),
|
|
});
|
|
assert.equal(second.response.status, 200);
|
|
assert.equal(second.body.data.reused, true);
|
|
assert.equal(second.body.data.refreshed, true);
|
|
assert.equal(second.body.data.product_id, first.body.data.product_id);
|
|
assert.equal(second.body.data.editor_entry_path, first.body.data.editor_entry_path);
|
|
assert.equal(
|
|
second.body.data.collection_item.id,
|
|
first.body.data.collection_item.id,
|
|
);
|
|
assert.equal(second.body.data.materialization.pending_selection, 2);
|
|
assert.equal(second.body.data.materialization.queued, 0);
|
|
const editorAfterRefresh = await request(
|
|
`/api/products/${first.body.data.product_id}/editor`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${webAccessToken}`,
|
|
'X-Atlas-Platform-Id': targetPlatform.id,
|
|
},
|
|
},
|
|
);
|
|
assert.equal(editorAfterRefresh.response.status, 200);
|
|
assert.equal(
|
|
editorAfterRefresh.body.data.product.common.purchase_url,
|
|
'https://detail.1688.com/offer/888888888.html',
|
|
);
|
|
assert.equal(
|
|
editorAfterRefresh.body.data.product.common.source_title,
|
|
'人工确认商品名',
|
|
);
|
|
const originalVariant = editorAfterRefresh.body.data.variants.find(
|
|
(variant: { source_variant_key: string }) =>
|
|
variant.source_variant_key === '123456789',
|
|
);
|
|
assert.equal(originalVariant.display_name, '人工确认 SKU');
|
|
assert.equal(originalVariant.purchase_cost_cny, '88.80');
|
|
assert.equal(originalVariant.listing.offer_id, 'manual-offer-001');
|
|
const discoveredVariant = editorAfterRefresh.body.data.variants.find(
|
|
(variant: { source_variant_key: string }) =>
|
|
variant.source_variant_key === '987654321',
|
|
);
|
|
assert.ok(discoveredVariant);
|
|
assert.equal(discoveredVariant.listing.status, 'not_listed');
|
|
const inspection = await request(
|
|
`/api/collection-items/${first.body.data.collection_item.id}`,
|
|
{ headers: { Authorization: `Bearer ${webAccessToken}` } },
|
|
);
|
|
assert.equal(inspection.response.status, 200);
|
|
assert.equal(inspection.body.data.title, '第二次采集的中文标题');
|
|
assert.equal(
|
|
inspection.body.data.normalized_payload.source.parserVersion,
|
|
'test-2',
|
|
);
|
|
assert.equal(inspection.body.data.variants.length, 2);
|
|
assert.deepEqual(
|
|
inspection.body.data.variants.map(
|
|
(variant: { source_variant_key: string }) =>
|
|
variant.source_variant_key,
|
|
),
|
|
['123456789', '987654321'],
|
|
);
|
|
assert.equal(inspection.body.data.asset_transfers.length, 2);
|
|
assert.equal(
|
|
inspection.body.data.asset_transfers[0].source_url,
|
|
'https://images.example.test/refreshed-product.jpg',
|
|
);
|
|
assert.equal(
|
|
inspection.body.data.asset_transfers[0].status,
|
|
'pending_selection',
|
|
);
|
|
const videoTransfer = inspection.body.data.asset_transfers.find(
|
|
(transfer: { media_type: string }) =>
|
|
transfer.media_type === 'video',
|
|
);
|
|
assert.equal(
|
|
videoTransfer?.source_url,
|
|
'https://videos.example.test/refreshed-product.mp4',
|
|
);
|
|
assert.equal(
|
|
videoTransfer?.poster_url,
|
|
'https://images.example.test/refreshed-video-poster.jpg',
|
|
);
|
|
const eventsBeforeSelection = await database.workspaceTransaction(
|
|
workspaceId,
|
|
async (tx) =>
|
|
tx.query<{ count: string }>(
|
|
`SELECT count(*)::text AS count FROM atlas.outbox_events
|
|
WHERE event_type = 'collection.assets.materialize.requested'
|
|
AND event_payload->>'collection_item_id' = $1`,
|
|
[first.body.data.collection_item.id],
|
|
),
|
|
);
|
|
assert.equal(eventsBeforeSelection.rows[0]?.count, '0');
|
|
const materialization = await request(
|
|
`/api/collection-items/${first.body.data.collection_item.id}/asset-materializations`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${webAccessToken}`,
|
|
'Idempotency-Key': randomUUID(),
|
|
},
|
|
body: JSON.stringify({
|
|
transfer_ids: [inspection.body.data.asset_transfers[0].id],
|
|
}),
|
|
},
|
|
);
|
|
assert.equal(materialization.response.status, 202);
|
|
const afterSelection = await request(
|
|
`/api/collection-items/${first.body.data.collection_item.id}`,
|
|
{ headers: { Authorization: `Bearer ${webAccessToken}` } },
|
|
);
|
|
assert.equal(afterSelection.response.status, 200);
|
|
assert.ok(
|
|
['queued', 'running', 'failed', 'succeeded'].includes(
|
|
afterSelection.body.data.asset_transfers[0].status,
|
|
),
|
|
);
|
|
const eventsAfterSelection = await database.workspaceTransaction(
|
|
workspaceId,
|
|
async (tx) =>
|
|
tx.query<{ count: string }>(
|
|
`SELECT count(*)::text AS count FROM atlas.outbox_events
|
|
WHERE event_type = 'collection.assets.materialize.requested'
|
|
AND event_payload->>'collection_item_id' = $1`,
|
|
[first.body.data.collection_item.id],
|
|
),
|
|
);
|
|
assert.equal(eventsAfterSelection.rows[0]?.count, '1');
|
|
const transferWorkspace = await database.workspaceTransaction(
|
|
workspaceId,
|
|
async (tx) =>
|
|
tx.query<{ workspace_id: string }>(
|
|
'SELECT workspace_id FROM atlas.collection_asset_transfers WHERE id = $1',
|
|
[inspection.body.data.asset_transfers[0].id],
|
|
),
|
|
);
|
|
assert.equal(transferWorkspace.rows[0]?.workspace_id, workspaceId);
|
|
});
|
|
|
|
it('完整采集并保存 1000 个 SKU,复采保持身份,超限请求原子拒绝', async () => {
|
|
const variants = Array.from({ length: PRODUCT_VARIANT_LIMIT }, (_, index) => ({
|
|
sourceVariantKey: String(8_000_000_000_000 + index),
|
|
sourceSku: String(8_000_000_000_000 + index),
|
|
displayName: `来源 SKU ${index}`,
|
|
selections: [], confidence: 'verified', evidenceRefs: [],
|
|
sourcePrice: { amount: '20.00', currencyCode: 'CNY' },
|
|
}));
|
|
const payload = {
|
|
source: {
|
|
platform: '1688', itemKey: `large-${randomUUID()}`,
|
|
canonicalUrl: 'https://detail.1688.com/offer/123456789.html',
|
|
collectedAt: new Date().toISOString(), parserVersion: 'large-sku-test',
|
|
},
|
|
common: { title: { value: '多 SKU 来源商品', confidence: 'verified' }, attributes: [], categories: [] },
|
|
variantAxes: [], variants, assets: [], evidence: [],
|
|
};
|
|
const capture = (body: unknown) => request('/api/extension/materials', {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${webAccessToken}`, 'Idempotency-Key': randomUUID() },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const captured = await capture(payload);
|
|
assert.equal(captured.response.status, 201, JSON.stringify(captured.body));
|
|
assert.equal(captured.body.data.collection_item.variant_count, PRODUCT_VARIANT_LIMIT);
|
|
const collectionId = captured.body.data.collection_item.id;
|
|
const productId = captured.body.data.product_id;
|
|
const platforms = await request('/api/platforms', { headers: { Authorization: `Bearer ${webAccessToken}` } });
|
|
const platformId = platforms.body.data.find((platform: { code: string }) => platform.code === 'OZON').id;
|
|
const context = await request(`/api/collection-items/${collectionId}/editor-contexts`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${webAccessToken}`, 'X-Atlas-Platform-Id': platformId,
|
|
'Idempotency-Key': randomUUID(),
|
|
},
|
|
});
|
|
assert.equal(context.response.status, 201, JSON.stringify(context.body));
|
|
const read = () => request(`/api/products/${productId}/editor`, {
|
|
headers: { Authorization: `Bearer ${webAccessToken}`, 'X-Atlas-Platform-Id': platformId },
|
|
});
|
|
const before = await read();
|
|
assert.equal(before.response.status, 200);
|
|
const snapshot = before.body.data;
|
|
assert.equal(snapshot.variants.length, PRODUCT_VARIANT_LIMIT);
|
|
assert.equal(snapshot.variants.at(-1).source_sku, variants.at(-1)!.sourceSku);
|
|
const draft = {
|
|
ai_source_material: snapshot.source_material,
|
|
ai_source_overridden_fields: [],
|
|
common: {
|
|
...snapshot.product.common,
|
|
default_physical_spec: snapshot.product.default_physical_spec,
|
|
default_package_spec: snapshot.product.default_package_spec,
|
|
},
|
|
profile: {
|
|
target_store_id: snapshot.profile.target_store.id,
|
|
fulfillment: snapshot.profile.fulfillment, title: null, description: null, tags: [],
|
|
platform_product_category_id: null,
|
|
},
|
|
variants: snapshot.variants.map((variant: any, index: number) => ({
|
|
id: variant.id, client_key: null,
|
|
display_name: index === PRODUCT_VARIANT_LIMIT - 1 ? '末尾 SKU 已编辑' : variant.display_name,
|
|
source_sku: variant.source_sku, specification_values: variant.specification_values,
|
|
purchase_cost_cny: variant.purchase_cost_cny, physical_spec: variant.physical_spec,
|
|
package_spec: variant.package_spec, tags: [], description: null,
|
|
offer_id: null, media_asset_ids: [],
|
|
})),
|
|
};
|
|
const saved = await request(`/api/products/${productId}/editor-draft`, {
|
|
method: 'PUT', headers: {
|
|
Authorization: `Bearer ${webAccessToken}`, 'X-Atlas-Platform-Id': platformId,
|
|
'Idempotency-Key': randomUUID(),
|
|
'If-Match': `"${snapshot.product.revision}:${snapshot.profile.revision}"`,
|
|
}, body: JSON.stringify(draft),
|
|
});
|
|
assert.equal(saved.response.status, 200, JSON.stringify(saved.body));
|
|
const after = await read();
|
|
assert.equal(after.body.data.variants.length, PRODUCT_VARIANT_LIMIT);
|
|
assert.equal(after.body.data.variants.at(-1).display_name, '末尾 SKU 已编辑');
|
|
const refreshed = await capture(payload);
|
|
assert.equal(refreshed.response.status, 200);
|
|
assert.equal(refreshed.body.data.product_id, productId);
|
|
assert.equal(refreshed.body.data.collection_item.variant_count, PRODUCT_VARIANT_LIMIT);
|
|
const reread = await read();
|
|
assert.deepEqual(reread.body.data.variants.map((v: any) => v.id), snapshot.variants.map((v: any) => v.id));
|
|
assert.equal(reread.body.data.variants.at(-1).display_name, '末尾 SKU 已编辑');
|
|
const revision = reread.body.data.source_material.source_snapshot_revision;
|
|
const rejected = await capture({ ...payload, variants: [...variants, { ...variants[0], sourceVariantKey: 'extra-sku' }] });
|
|
assert.equal(rejected.response.status, 422);
|
|
assert.equal(rejected.body.error.details[0].field, 'variants');
|
|
const unchanged = await read();
|
|
assert.equal(unchanged.body.data.source_material.source_snapshot_revision, revision);
|
|
assert.equal(unchanged.body.data.variants.length, PRODUCT_VARIANT_LIMIT);
|
|
}, 60_000);
|
|
});
|