Files
atlas-seller-kit/apps/api/test/extension.e2e.test.ts
T
2026-09-08 16:19:38 +08:00

333 lines
13 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';
let app: NestExpressApplication;
let database: Database;
let baseUrl = '';
let workspaceId = '';
let userId = '';
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_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;
const 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 first = await request('/api/extension/materials', {
method: 'POST',
headers: {
Authorization: `Bearer ${webAccessToken}`,
'Idempotency-Key': randomUUID(),
},
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);
// 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.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 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);
});
});