292 lines
14 KiB
TypeScript
292 lines
14 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 = '';
|
|
let token = '';
|
|
let platformId = '';
|
|
let first: { collectionItemId: string; productId: string; profileId: string };
|
|
|
|
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()) as any),
|
|
};
|
|
}
|
|
|
|
function headers(extra: Record<string, string> = {}) {
|
|
return { Authorization: `Bearer ${token}`, ...extra };
|
|
}
|
|
|
|
async function createCollectionItem(title: string) {
|
|
const created = await request('/api/collection-items', {
|
|
method: 'POST',
|
|
headers: headers({ 'Idempotency-Key': randomUUID() }),
|
|
body: JSON.stringify({ title }),
|
|
});
|
|
assert.equal(created.response.status, 201);
|
|
return {
|
|
collectionItemId: created.body.data.collection_item.id as string,
|
|
productId: created.body.data.product_id as string,
|
|
};
|
|
}
|
|
|
|
describe('商品编辑 M3.0 / M3.1 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}`;
|
|
const registration = await request('/api/auth/register', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
email: `editor-${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;
|
|
token = registration.body.data.access_token;
|
|
const platforms = await request('/api/platforms', { headers: headers() });
|
|
platformId = platforms.body.data[0].id;
|
|
});
|
|
|
|
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.product_unmapped_attributes WHERE workspace_id = $1', [workspaceId]);
|
|
await tx.query('DELETE FROM atlas.product_attributes 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.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.product_platform_profiles 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.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 created = await createCollectionItem('编辑器测试商品 A');
|
|
const missingStore = await request(`/api/collection-items/${created.collectionItemId}/editor-contexts`, {
|
|
method: 'POST',
|
|
headers: headers({ 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': randomUUID() }),
|
|
});
|
|
assert.equal(missingStore.response.status, 422);
|
|
assert.equal(missingStore.body.error.code, 'DEFAULT_STORE_REQUIRED');
|
|
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, 'M3 测试店铺', $2, 'RUB', true, 'active', 'valid')`,
|
|
[platformId, `editor-${randomUUID()}`],
|
|
);
|
|
});
|
|
const entered = await request(`/api/collection-items/${created.collectionItemId}/editor-contexts`, {
|
|
method: 'POST',
|
|
headers: headers({ 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': randomUUID() }),
|
|
});
|
|
assert.equal(entered.response.status, 201);
|
|
first = {
|
|
collectionItemId: created.collectionItemId,
|
|
productId: created.productId,
|
|
profileId: entered.body.data.profile_id,
|
|
};
|
|
const enteredAgain = await request(`/api/collection-items/${created.collectionItemId}/editor-contexts`, {
|
|
method: 'POST',
|
|
headers: headers({ 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': randomUUID() }),
|
|
});
|
|
assert.equal(enteredAgain.response.status, 200);
|
|
assert.equal(enteredAgain.body.data.profile_id, first.profileId);
|
|
const editor = await request(`/api/products/${first.productId}/editor`, {
|
|
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
|
});
|
|
assert.equal(editor.response.status, 200);
|
|
assert.equal(editor.body.data.product.id, first.productId);
|
|
assert.equal(editor.body.data.profile.id, first.profileId);
|
|
assert.equal(editor.body.data.variants.length, 1);
|
|
assert.equal(editor.body.data.variants[0].listing.status, 'not_listed');
|
|
});
|
|
|
|
it('数据库拒绝跨商品 SKU 与 profile 的关联,并保留未映射属性草稿', async () => {
|
|
const second = await createCollectionItem('编辑器测试商品 B');
|
|
const secondContext = await request(`/api/collection-items/${second.collectionItemId}/editor-contexts`, {
|
|
method: 'POST',
|
|
headers: headers({ 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': randomUUID() }),
|
|
});
|
|
assert.equal(secondContext.response.status, 201);
|
|
const link = await database.workspaceTransaction(workspaceId, async (tx) => {
|
|
const secondVariant = await tx.query<{ id: string }>(
|
|
'SELECT id FROM atlas.product_variants WHERE product_id = $1 AND deleted_at IS NULL',
|
|
[second.productId],
|
|
);
|
|
const firstProfile = await tx.query<{ target_store_id: string }>(
|
|
'SELECT target_store_id FROM atlas.product_platform_profiles WHERE id = $1',
|
|
[first.profileId],
|
|
);
|
|
return {
|
|
variantId: secondVariant.rows[0].id,
|
|
storeId: firstProfile.rows[0].target_store_id,
|
|
};
|
|
});
|
|
await assert.rejects(
|
|
database.workspaceTransaction(workspaceId, async (tx) =>
|
|
tx.query(
|
|
`INSERT INTO atlas.product_variant_platform_listings
|
|
(workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id)
|
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5)`,
|
|
[second.productId, first.profileId, platformId, link.variantId, link.storeId],
|
|
),
|
|
),
|
|
{ code: '23503' },
|
|
);
|
|
await database.workspaceTransaction(workspaceId, async (tx) => {
|
|
await tx.query(
|
|
`INSERT INTO atlas.product_unmapped_attributes
|
|
(workspace_id, product_id, profile_id, platform_id, original_platform_attribute_id,
|
|
original_name, values, reason)
|
|
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, 'legacy-material',
|
|
'旧类目材质', '["棉"]'::jsonb, 'category_changed')`,
|
|
[first.productId, first.profileId, platformId],
|
|
);
|
|
});
|
|
const editor = await request(`/api/products/${first.productId}/editor`, {
|
|
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
|
});
|
|
assert.equal(editor.response.status, 200);
|
|
assert.equal(editor.body.data.unmapped_attributes.length, 1);
|
|
assert.equal(editor.body.data.unmapped_attributes[0].original_name, '旧类目材质');
|
|
assert.equal(editor.body.data.warnings[0].code, 'UNMAPPED_ATTRIBUTES_PENDING');
|
|
});
|
|
|
|
it('原子保存草稿会更新双版本、创建 SKU,并拒绝过期版本覆盖', async () => {
|
|
const before = await request(`/api/products/${first.productId}/editor`, {
|
|
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
|
});
|
|
const existing = before.body.data.variants[0];
|
|
const payload = {
|
|
common: {
|
|
source_title: '已由编辑器确认的商品名', brand: 'Atlas', model: null,
|
|
barcode: null, purchase_url: null, supply_note: 'M3.1 集成测试',
|
|
},
|
|
profile: {
|
|
target_store_id: before.body.data.profile.target_store.id,
|
|
fulfillment: 'rfbs', title: 'Ozon 测试标题', description: null,
|
|
tags: ['测试', '草稿'],
|
|
},
|
|
variants: [
|
|
{
|
|
id: existing.id, client_key: null, copy_from_variant_id: null,
|
|
display_name: '默认 SKU(已编辑)', source_sku: existing.source_sku,
|
|
purchase_cost_cny: '88.50', offer_id: 'm3-variant-a',
|
|
},
|
|
{
|
|
id: null, client_key: 'new-variant-b', copy_from_variant_id: existing.id,
|
|
display_name: '新增 SKU', source_sku: null,
|
|
purchase_cost_cny: '99.00', offer_id: 'm3-variant-b',
|
|
},
|
|
],
|
|
};
|
|
const key = randomUUID();
|
|
const saved = await request(`/api/products/${first.productId}/editor-draft`, {
|
|
method: 'PUT',
|
|
headers: headers({
|
|
'X-Atlas-Platform-Id': platformId,
|
|
'Idempotency-Key': key,
|
|
'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`,
|
|
}),
|
|
body: JSON.stringify(payload),
|
|
});
|
|
assert.equal(saved.response.status, 200);
|
|
assert.equal(saved.body.data.created_variants.length, 1);
|
|
assert.equal(saved.body.data.product_revision, before.body.data.product.revision + 1);
|
|
assert.equal(saved.body.data.profile_revision, before.body.data.profile.revision + 1);
|
|
const replay = await request(`/api/products/${first.productId}/editor-draft`, {
|
|
method: 'PUT',
|
|
headers: headers({
|
|
'X-Atlas-Platform-Id': platformId,
|
|
'Idempotency-Key': key,
|
|
'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`,
|
|
}),
|
|
body: JSON.stringify(payload),
|
|
});
|
|
assert.deepEqual(replay.body, saved.body);
|
|
const after = await request(`/api/products/${first.productId}/editor`, {
|
|
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
|
});
|
|
assert.equal(after.body.data.product.common.source_title, '已由编辑器确认的商品名');
|
|
assert.equal(after.body.data.profile.title, 'Ozon 测试标题');
|
|
assert.equal(after.body.data.variants.length, 2);
|
|
assert.equal(after.body.data.variants[0].listing.offer_id, 'm3-variant-a');
|
|
const stale = 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(stale.response.status, 409);
|
|
assert.equal(stale.body.error.code, 'VERSION_CONFLICT');
|
|
const removeSecond = { ...payload, variants: [payload.variants[0]] };
|
|
const removed = await request(`/api/products/${first.productId}/editor-draft`, {
|
|
method: 'PUT',
|
|
headers: headers({
|
|
'X-Atlas-Platform-Id': platformId,
|
|
'Idempotency-Key': randomUUID(),
|
|
'If-Match': `"${after.body.data.product.revision}:${after.body.data.profile.revision}"`,
|
|
}),
|
|
body: JSON.stringify(removeSecond),
|
|
});
|
|
assert.equal(removed.response.status, 200);
|
|
const replacement = {
|
|
...removeSecond,
|
|
variants: [
|
|
removeSecond.variants[0],
|
|
{
|
|
id: null, client_key: 'replacement-variant-b', copy_from_variant_id: existing.id,
|
|
display_name: '替代 SKU', source_sku: null,
|
|
purchase_cost_cny: '99.00', offer_id: 'm3-variant-b',
|
|
},
|
|
],
|
|
};
|
|
const reusedOffer = await request(`/api/products/${first.productId}/editor-draft`, {
|
|
method: 'PUT',
|
|
headers: headers({
|
|
'X-Atlas-Platform-Id': platformId,
|
|
'Idempotency-Key': randomUUID(),
|
|
'If-Match': `"${removed.body.data.product_revision}:${removed.body.data.profile_revision}"`,
|
|
}),
|
|
body: JSON.stringify(replacement),
|
|
});
|
|
assert.equal(reusedOffer.response.status, 200);
|
|
});
|
|
});
|