421 lines
16 KiB
TypeScript
421 lines
16 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';
|
|
|
|
type SuccessResponse<T> = { data: T };
|
|
type ErrorResponse = { error: { code: string } };
|
|
type Session = {
|
|
access_token: string;
|
|
user: { id: string };
|
|
workspace: { id: string };
|
|
};
|
|
type Store = {
|
|
id: string;
|
|
name: string;
|
|
is_default: boolean;
|
|
status: string;
|
|
credential_status: string;
|
|
revision: number;
|
|
};
|
|
|
|
const password = 'correct-horse-battery-staple-123';
|
|
const testUsers: Array<{ userId: string; workspaceId: string }> = [];
|
|
let app: NestExpressApplication;
|
|
let database: Database;
|
|
let baseUrl: string;
|
|
|
|
function email(label: string) {
|
|
return `${label}-${randomUUID()}@example.test`;
|
|
}
|
|
|
|
async function request(path: string, init: RequestInit = {}) {
|
|
const response = await fetch(`${baseUrl}${path}`, {
|
|
...init,
|
|
headers: { 'Content-Type': 'application/json', ...init.headers },
|
|
});
|
|
return {
|
|
response,
|
|
body: response.status === 204 ? undefined : await response.json(),
|
|
};
|
|
}
|
|
|
|
function success<T>(body: unknown): SuccessResponse<T> {
|
|
assert.ok(body && typeof body === 'object' && 'data' in body);
|
|
return body as SuccessResponse<T>;
|
|
}
|
|
|
|
function error(body: unknown): ErrorResponse {
|
|
assert.ok(body && typeof body === 'object' && 'error' in body);
|
|
return body as ErrorResponse;
|
|
}
|
|
|
|
async function register(label: string): Promise<Session> {
|
|
const result = await request('/api/auth/register', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
email: email(label),
|
|
password,
|
|
password_confirmation: password,
|
|
}),
|
|
});
|
|
assert.equal(result.response.status, 201);
|
|
const session = success<Session>(result.body).data;
|
|
testUsers.push({
|
|
userId: session.user.id,
|
|
workspaceId: session.workspace.id,
|
|
});
|
|
return session;
|
|
}
|
|
|
|
function headers(session: Session, idempotencyKey?: string) {
|
|
return {
|
|
Authorization: `Bearer ${session.access_token}`,
|
|
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
|
|
};
|
|
}
|
|
|
|
describe('门店 HTTP 集成测试', () => {
|
|
beforeAll(async () => {
|
|
Object.assign(process.env, {
|
|
NODE_ENV: 'test',
|
|
SWAGGER_ENABLED: 'false',
|
|
AUTH_LOGIN_IP_LIMIT: '100',
|
|
AUTH_LOGIN_EMAIL_LIMIT: '100',
|
|
AUTH_REGISTER_IP_LIMIT: '100',
|
|
AUTH_REGISTER_EMAIL_LIMIT: '100',
|
|
AUTH_REFRESH_IP_LIMIT: '100',
|
|
});
|
|
const application = await createApplication();
|
|
app = application.app;
|
|
database = app.get<Database>(DATABASE);
|
|
await app.listen(0, '127.0.0.1');
|
|
const address = app.getHttpServer().address() as AddressInfo;
|
|
baseUrl = `http://127.0.0.1:${address.port}`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
for (const user of testUsers) {
|
|
// 门店测试会创建操作和 Outbox;先在工作空间事务内清理这些受 RLS 保护的记录。
|
|
await database.workspaceTransaction(
|
|
user.workspaceId,
|
|
async (tx) => {
|
|
await tx.query(
|
|
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
|
|
[user.workspaceId],
|
|
);
|
|
await tx.query(
|
|
'DELETE FROM atlas.outbox_events WHERE workspace_id = $1',
|
|
[user.workspaceId],
|
|
);
|
|
await tx.query(
|
|
'DELETE FROM atlas.operations WHERE workspace_id = $1',
|
|
[user.workspaceId],
|
|
);
|
|
await tx.query(
|
|
'DELETE FROM atlas.stores WHERE workspace_id = $1',
|
|
[user.workspaceId],
|
|
);
|
|
},
|
|
);
|
|
await database.query(
|
|
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
|
|
[user.userId],
|
|
);
|
|
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [
|
|
user.workspaceId,
|
|
]);
|
|
await database.query('DELETE FROM atlas.users WHERE id = $1', [
|
|
user.userId,
|
|
]);
|
|
}
|
|
await app.close();
|
|
});
|
|
|
|
it('创建门店时加密保存凭据、创建操作,并能安全重放相同请求', async () => {
|
|
const owner = await register('stores-owner');
|
|
const platforms = await request('/api/platforms', {
|
|
headers: headers(owner),
|
|
});
|
|
assert.equal(platforms.response.status, 200);
|
|
const platform = success<
|
|
Array<{
|
|
id: string;
|
|
code: string;
|
|
default_market_code: string | null;
|
|
default_locale: string | null;
|
|
}>
|
|
>(platforms.body).data.find((item) => item.code === 'OZON');
|
|
assert.ok(platform);
|
|
assert.equal(platform.default_market_code, 'RU');
|
|
assert.equal(platform.default_locale, 'ru-RU');
|
|
|
|
const idempotencyKey = randomUUID();
|
|
const createBody = {
|
|
platform_id: platform.id,
|
|
name: '测试 Ozon 主店',
|
|
client_id: `client-${randomUUID()}`,
|
|
api_key: 'secret-only-in-request',
|
|
currency_code: 'RUB',
|
|
is_default: false,
|
|
note: '首家门店仍应成为默认门店',
|
|
};
|
|
const created = await request('/api/stores', {
|
|
method: 'POST',
|
|
headers: headers(owner, idempotencyKey),
|
|
body: JSON.stringify(createBody),
|
|
});
|
|
assert.equal(created.response.status, 202);
|
|
const createdData = success<{
|
|
store: Store & { platform_client_id: string };
|
|
operation: { id: string; status: string };
|
|
}>(created.body).data;
|
|
assert.equal(createdData.store.is_default, true);
|
|
assert.equal(createdData.store.status, 'verifying');
|
|
assert.equal(createdData.store.credential_status, 'unknown');
|
|
assert.equal(
|
|
createdData.store.platform_client_id,
|
|
createBody.client_id,
|
|
);
|
|
assert.equal(createdData.operation.status, 'queued');
|
|
assert.equal(
|
|
JSON.stringify(created.body).includes(createBody.api_key),
|
|
false,
|
|
);
|
|
|
|
const replay = await request('/api/stores', {
|
|
method: 'POST',
|
|
headers: headers(owner, idempotencyKey),
|
|
body: JSON.stringify(createBody),
|
|
});
|
|
assert.equal(replay.response.status, 202);
|
|
assert.deepEqual(replay.body, created.body);
|
|
|
|
const mismatchedReplay = await request('/api/stores', {
|
|
method: 'POST',
|
|
headers: headers(owner, idempotencyKey),
|
|
body: JSON.stringify({ ...createBody, name: '不同请求' }),
|
|
});
|
|
assert.equal(mismatchedReplay.response.status, 409);
|
|
assert.equal(
|
|
error(mismatchedReplay.body).error.code,
|
|
'IDEMPOTENCY_KEY_REUSED',
|
|
);
|
|
|
|
const context = await request('/api/platform-context', {
|
|
headers: {
|
|
...headers(owner),
|
|
'X-Atlas-Platform-Id': platform.id,
|
|
},
|
|
});
|
|
assert.equal(context.response.status, 200);
|
|
assert.equal(
|
|
success<{ store_count: number; default_store: { id: string } }>(
|
|
context.body,
|
|
).data.store_count,
|
|
1,
|
|
);
|
|
|
|
const operation = await request(
|
|
`/api/operations/${createdData.operation.id}`,
|
|
{ headers: headers(owner) },
|
|
);
|
|
assert.equal(operation.response.status, 200);
|
|
assert.equal(
|
|
success<{ status: string }>(operation.body).data.status,
|
|
'queued',
|
|
);
|
|
});
|
|
|
|
it('同步刷新门店类目:等待 Ozon 返回并完整落库后才成功', async () => {
|
|
const owner = await register('stores-category-refresh');
|
|
const platforms = await request('/api/platforms', {
|
|
headers: headers(owner),
|
|
});
|
|
const platform = success<Array<{ id: string; code: string }>>(
|
|
platforms.body,
|
|
).data.find((item) => item.code === 'OZON');
|
|
assert.ok(platform);
|
|
const store = await createStore(owner, platform.id, '类目刷新测试店');
|
|
await database.workspaceTransaction(owner.workspace.id, async (tx) => {
|
|
await tx.query(
|
|
`UPDATE atlas.stores
|
|
SET status = 'active', credential_status = 'valid'
|
|
WHERE id = $1`,
|
|
[store.id],
|
|
);
|
|
await tx.query(
|
|
`UPDATE atlas.store_secrets SET status = 'valid'
|
|
WHERE store_id = $1 AND kind = 'seller_api_key' AND revoked_at IS NULL`,
|
|
[store.id],
|
|
);
|
|
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,
|
|
'cached-category', 'cached-type', '已有缓存', '已有缓存', now())`,
|
|
[store.id, platform.id],
|
|
);
|
|
});
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
let ozonCalls = 0;
|
|
globalThis.fetch = async (input, init) => {
|
|
if (String(input).startsWith('https://api-seller.ozon.ru/')) {
|
|
ozonCalls += 1;
|
|
assert.equal(init?.method, 'POST');
|
|
return new Response(
|
|
JSON.stringify({
|
|
result: [
|
|
{
|
|
description_category_id: 17028750,
|
|
category_name: '汽车脚垫和车座套',
|
|
disabled: false,
|
|
children: [
|
|
{
|
|
type_name: '汽车罩',
|
|
type_id: 93587,
|
|
disabled: false,
|
|
children: [],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
);
|
|
}
|
|
return originalFetch(input, init);
|
|
};
|
|
try {
|
|
const idempotencyKey = randomUUID();
|
|
const first = await request(`/api/stores/${store.id}/category-tree-syncs`, {
|
|
method: 'POST',
|
|
headers: headers(owner, idempotencyKey),
|
|
});
|
|
assert.equal(first.response.status, 200);
|
|
assert.deepEqual(
|
|
success<{ category_count: number; active_category_count: number }>(first.body).data,
|
|
{ category_count: 2, active_category_count: 1 },
|
|
);
|
|
|
|
const replay = await request(`/api/stores/${store.id}/category-tree-syncs`, {
|
|
method: 'POST',
|
|
headers: headers(owner, idempotencyKey),
|
|
});
|
|
assert.equal(replay.response.status, 200);
|
|
assert.deepEqual(replay.body, first.body);
|
|
assert.equal(ozonCalls, 1);
|
|
|
|
await database.workspaceTransaction(owner.workspace.id, async (tx) => {
|
|
const categories = await tx.query<{
|
|
external_category_id: string;
|
|
external_type_id: string;
|
|
name: string;
|
|
path: string;
|
|
is_active: boolean;
|
|
}>(
|
|
`SELECT external_category_id, external_type_id, name, path, is_active
|
|
FROM atlas.store_product_categories
|
|
WHERE store_id = $1 ORDER BY path`,
|
|
[store.id],
|
|
);
|
|
assert.deepEqual(categories.rows.filter((row) => row.is_active), [
|
|
{
|
|
external_category_id: '17028750',
|
|
external_type_id: '93587',
|
|
name: '汽车罩',
|
|
path: '汽车脚垫和车座套 › 汽车罩',
|
|
is_active: true,
|
|
},
|
|
]);
|
|
});
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it('更新版本、切换默认门店、删除和工作空间隔离都遵守契约', async () => {
|
|
const owner = await register('stores-lifecycle');
|
|
const otherOwner = await register('stores-isolation');
|
|
const platforms = await request('/api/platforms', {
|
|
headers: headers(owner),
|
|
});
|
|
const platform = success<Array<{ id: string; code: string }>>(
|
|
platforms.body,
|
|
).data.find((item) => item.code === 'OZON');
|
|
assert.ok(platform);
|
|
|
|
const first = await createStore(owner, platform.id, '第一家门店');
|
|
const updated = await request(`/api/stores/${first.id}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
...headers(owner, randomUUID()),
|
|
'If-Match': `"${first.revision}"`,
|
|
},
|
|
body: JSON.stringify({ name: '第一家门店(已修改)' }),
|
|
});
|
|
assert.equal(updated.response.status, 200);
|
|
const updatedStore = success<Store>(updated.body).data;
|
|
assert.equal(updatedStore.revision, first.revision + 1);
|
|
|
|
const stale = await request(`/api/stores/${first.id}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
...headers(owner, randomUUID()),
|
|
'If-Match': `"${first.revision}"`,
|
|
},
|
|
body: JSON.stringify({ name: '过期修改' }),
|
|
});
|
|
assert.equal(stale.response.status, 409);
|
|
assert.equal(error(stale.body).error.code, 'VERSION_CONFLICT');
|
|
|
|
const second = await createStore(owner, platform.id, '第二家门店');
|
|
const makeDefault = await request(
|
|
`/api/stores/${second.id}/set-default`,
|
|
{
|
|
method: 'POST',
|
|
headers: headers(owner, randomUUID()),
|
|
},
|
|
);
|
|
assert.equal(makeDefault.response.status, 200);
|
|
assert.equal(success<Store>(makeDefault.body).data.is_default, true);
|
|
|
|
const foreignRead = await request(`/api/stores/${first.id}`, {
|
|
headers: headers(otherOwner),
|
|
});
|
|
assert.equal(foreignRead.response.status, 404);
|
|
|
|
const deleted = await request(`/api/stores/${first.id}`, {
|
|
method: 'DELETE',
|
|
headers: headers(owner, randomUUID()),
|
|
});
|
|
assert.equal(deleted.response.status, 204);
|
|
const absent = await request(`/api/stores/${first.id}`, {
|
|
headers: headers(owner),
|
|
});
|
|
assert.equal(absent.response.status, 404);
|
|
});
|
|
});
|
|
|
|
async function createStore(owner: Session, platformId: string, name: string) {
|
|
const result = await request('/api/stores', {
|
|
method: 'POST',
|
|
headers: headers(owner, randomUUID()),
|
|
body: JSON.stringify({
|
|
platform_id: platformId,
|
|
name,
|
|
client_id: `client-${randomUUID()}`,
|
|
api_key: `key-${randomUUID()}`,
|
|
currency_code: 'RUB',
|
|
}),
|
|
});
|
|
assert.equal(result.response.status, 202);
|
|
return success<{ store: Store }>(result.body).data.store;
|
|
}
|