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 = { 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(body: unknown): SuccessResponse { assert.ok(body && typeof body === 'object' && 'data' in body); return body as SuccessResponse; } function error(body: unknown): ErrorResponse { assert.ok(body && typeof body === 'object' && 'error' in body); return body as ErrorResponse; } async function register(label: string): Promise { 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(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); 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>( 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>( 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(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(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; }