467 lines
18 KiB
TypeScript
467 lines
18 KiB
TypeScript
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
|
|
import { randomUUID, randomInt } from 'node:crypto';
|
|
import { loadEnvFile } from 'node:process';
|
|
import { Client } from 'pg';
|
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
|
import type {
|
|
CommissionImportRequest,
|
|
CommissionImportPublish,
|
|
} from '@atlas/contracts';
|
|
import { CommissionImportService } from '../src/pricing/commission-import.service.js';
|
|
import { parseCommissionDocument } from '../src/pricing/commission-import-parser.js';
|
|
import { IdempotencyService } from '../src/common/idempotency.service.js';
|
|
import { PricingService } from '../src/pricing/pricing.service.js';
|
|
import { fingerprint } from '../src/pricing/pricing-engine.js';
|
|
import type { AppConfig } from '../src/config/app-config.module.js';
|
|
loadEnvFile(new URL('../../../.env', import.meta.url));
|
|
const makeDocument = (
|
|
parent = '900000001',
|
|
child = '900000002',
|
|
rates = ['12.00', '14.00', '20.50'],
|
|
) => ({
|
|
code: 1,
|
|
msg: '获取成功',
|
|
data: [
|
|
{
|
|
id: 1,
|
|
label: '测试分组',
|
|
value: '测试分组',
|
|
cate_id: parent,
|
|
children: [
|
|
{
|
|
id: '1.1',
|
|
label: '测试类目',
|
|
value: '测试类目',
|
|
cate_id: child,
|
|
children: rates.map((rate, i) => ({
|
|
id: `1.1.${i + 1}`,
|
|
label: `${['售价 ≤ 1500₽', '1500₽ < 售价 ≤ 5000₽', '售价 > 5000₽'][i]} (${rate}%)`,
|
|
value: `${i + 1},${rate}`,
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
describe('佣金来源解析', () => {
|
|
it('保留三个精确区间并将百分数转换为比例', () => {
|
|
const c = parseCommissionDocument(makeDocument())[0];
|
|
expect(c.bands.map((b) => b.commission_rate)).toEqual([
|
|
'0.120000',
|
|
'0.140000',
|
|
'0.205000',
|
|
]);
|
|
expect(
|
|
c.bands.map((b) => [
|
|
b.min_price,
|
|
b.max_price,
|
|
b.min_inclusive,
|
|
b.max_inclusive,
|
|
]),
|
|
).toEqual([
|
|
['0', '1500', false, true],
|
|
['1500', '5000', false, true],
|
|
['5000', null, false, false],
|
|
]);
|
|
});
|
|
it('拒绝标签和值不一致、漏档、重复类目及超大文件', () => {
|
|
const d = makeDocument();
|
|
d.data[0].children[0].children[0].value = '1,15.00';
|
|
expect(() => parseCommissionDocument(d)).toThrow();
|
|
expect(() => parseCommissionDocument(makeDocument('1', '1'))).toThrow();
|
|
const missing = makeDocument();
|
|
missing.data[0].children[0].children.pop();
|
|
expect(() => parseCommissionDocument(missing)).toThrow();
|
|
expect(() =>
|
|
parseCommissionDocument({ x: 'a'.repeat(2 * 1024 * 1024) }),
|
|
).toThrow();
|
|
});
|
|
});
|
|
|
|
// 所有业务数据在测试事务内,结束一律回滚;不会发布或替换用户的实际佣金版本。
|
|
describe('佣金导入 PostgreSQL 集成', () => {
|
|
let client: Client,
|
|
db: Database,
|
|
service: CommissionImportService,
|
|
pricing: PricingService;
|
|
let user: string,
|
|
workspace: string,
|
|
platform: string,
|
|
store: string,
|
|
leaf: string,
|
|
oldId: string,
|
|
oldRule: string;
|
|
let request: CommissionImportRequest;
|
|
let savepoint = 0;
|
|
beforeEach(async () => {
|
|
const connection = new URL(process.env.MIGRATOR_DATABASE_URL!);
|
|
connection.username = process.env.POSTGRES_ADMIN_USER!;
|
|
connection.password = process.env.POSTGRES_ADMIN_PASSWORD!;
|
|
client = new Client({ connectionString: connection.toString() });
|
|
await client.connect();
|
|
await client.query('BEGIN');
|
|
user = randomUUID();
|
|
workspace = randomUUID();
|
|
await client.query(
|
|
`INSERT INTO atlas.users(id,email,password_hash) VALUES($1,$2,'test-only')`,
|
|
[user, `${user}@example.test`],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO atlas.workspaces(id,name,slug) VALUES($1,'佣金回滚测试',$2)`,
|
|
[workspace, workspace],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO atlas.workspace_memberships(workspace_id,user_id,role) VALUES($1,$2,'owner')`,
|
|
[workspace, user],
|
|
);
|
|
await client.query(
|
|
`INSERT INTO atlas.system_admin_grants(user_id) VALUES($1)`,
|
|
[user],
|
|
);
|
|
await client.query('SET LOCAL ROLE atlas_app');
|
|
await client.query(`SELECT set_config('app.workspace_id',$1,true)`, [
|
|
workspace,
|
|
]);
|
|
platform = (
|
|
await client.query(
|
|
`SELECT id FROM atlas.platforms WHERE upper(code::text)='OZON'`,
|
|
)
|
|
).rows[0].id;
|
|
store = (
|
|
await client.query(
|
|
`INSERT INTO atlas.stores(workspace_id,platform_id,name,platform_client_id,currency_code,status,credential_status) VALUES($1,$2,'佣金测试',$3,'RUB','active','valid') RETURNING id`,
|
|
[workspace, platform, randomUUID()],
|
|
)
|
|
).rows[0].id;
|
|
const p = String(randomInt(100000000, 999999990)),
|
|
c = String(Number(p) + 1);
|
|
const parent = (
|
|
await client.query(
|
|
`INSERT INTO atlas.platform_product_categories(platform_id,external_category_id,external_type_id,name,path,is_active) VALUES($1,$2,'0','测试分组','测试分组',false) RETURNING id`,
|
|
[platform, p],
|
|
)
|
|
).rows[0].id;
|
|
const cat = (
|
|
await client.query(
|
|
`INSERT INTO atlas.platform_product_categories(platform_id,parent_id,external_category_id,external_type_id,name,path,is_active) VALUES($1,$2,$3,'0','测试类目','测试分组 / 测试类目',false) RETURNING id`,
|
|
[platform, parent, c],
|
|
)
|
|
).rows[0].id;
|
|
leaf = (
|
|
await client.query(
|
|
`INSERT INTO atlas.platform_product_categories(platform_id,parent_id,external_category_id,external_type_id,name,path) VALUES($1,$2,$3,'991','测试类型','测试类型') RETURNING id`,
|
|
[platform, cat, c],
|
|
)
|
|
).rows[0].id;
|
|
request = {
|
|
platform_id: platform,
|
|
version: `qa-${randomUUID()}`,
|
|
source_reference: '自动化测试,事务回滚',
|
|
captured_on: '2026-09-28',
|
|
document: makeDocument(p, c),
|
|
};
|
|
const oldCategory = parseCommissionDocument(
|
|
makeDocument(p, c, ['10.00', '11.00', '12.00']),
|
|
)[0];
|
|
oldCategory.mapped_types = 1;
|
|
await client.query(
|
|
`UPDATE atlas.commission_imports SET is_current=false WHERE platform_id=$1 AND is_current`,
|
|
[platform],
|
|
);
|
|
oldId = (
|
|
await client.query(
|
|
`INSERT INTO atlas.commission_imports(platform_id,version,source_reference,captured_on,document,content_hash,categories,summary,activated_by,activated_at) VALUES($1,$2,'旧测试版本','2026-09-27','{}',$3,$4,'{}',$5,now()-interval '1 day') RETURNING id`,
|
|
[
|
|
platform,
|
|
`old-${randomUUID()}`,
|
|
fingerprint(randomUUID()),
|
|
JSON.stringify([oldCategory]),
|
|
user,
|
|
],
|
|
)
|
|
).rows[0].id;
|
|
const cc = (
|
|
await client.query(
|
|
`INSERT INTO atlas.commission_categories(platform_id,source_category_key,name,path,source,status) VALUES($1,$2,'测试类目',$3,'competitor_capture','active') RETURNING id`,
|
|
[platform, `maozierp:${c}`, `test-${c}`],
|
|
)
|
|
).rows[0].id;
|
|
oldRule = (
|
|
await client.query(
|
|
`INSERT INTO atlas.commission_category_rules(commission_category_id,fulfillment,market_code,currency_code,commission_rate,source,version,status,effective_from,import_id) VALUES($1,'rfbs','RU','RUB',0.1,'competitor_capture','old','active',now()-interval '1 day',$2) RETURNING id`,
|
|
[cc, oldId],
|
|
)
|
|
).rows[0].id;
|
|
await client.query(
|
|
`INSERT INTO atlas.platform_product_category_commission_mappings(platform_id,platform_product_category_id,commission_category_id,fulfillment,source,status,effective_from,import_id) VALUES($1,$2,$3,'rfbs','competitor_capture','active',now()-interval '1 day',$4)`,
|
|
[platform, leaf, cc, oldId],
|
|
);
|
|
const transaction = async <T>(
|
|
work: (tx: DatabaseTransaction) => Promise<T>,
|
|
) => {
|
|
const sp = `operation_${++savepoint}`;
|
|
await client.query(`SAVEPOINT ${sp}`);
|
|
try {
|
|
const result = await work(client);
|
|
await client.query(`RELEASE SAVEPOINT ${sp}`);
|
|
return result;
|
|
} catch (e) {
|
|
await client.query(`ROLLBACK TO SAVEPOINT ${sp}`);
|
|
throw e;
|
|
}
|
|
};
|
|
db = {
|
|
transaction,
|
|
query: (sql: string, args: unknown[]) =>
|
|
transaction((tx) => tx.query(sql, args)),
|
|
workspaceTransaction: (
|
|
id: string,
|
|
work: (tx: DatabaseTransaction) => Promise<unknown>,
|
|
) =>
|
|
transaction(async (tx) => {
|
|
await tx.query(
|
|
`SELECT set_config('app.workspace_id',$1,true)`,
|
|
[id],
|
|
);
|
|
return work(tx);
|
|
}),
|
|
} as unknown as Database;
|
|
const config = {
|
|
IDEMPOTENCY_KEY_SECRET: 'commission-test-idempotency-secret',
|
|
pricingAllowDraftRates: false,
|
|
} as AppConfig;
|
|
const idem = new IdempotencyService(db, config);
|
|
service = new CommissionImportService(db, idem);
|
|
pricing = new PricingService(db, idem, config);
|
|
});
|
|
afterEach(async () => {
|
|
if (client) {
|
|
await client.query('ROLLBACK');
|
|
await client.end();
|
|
}
|
|
});
|
|
const publishInput = async (
|
|
s: CommissionImportService,
|
|
r: CommissionImportRequest,
|
|
): Promise<CommissionImportPublish> => ({
|
|
...r,
|
|
preview_hash: (await s.preview(r)).preview_hash,
|
|
confirm_scope: true,
|
|
});
|
|
it('预览差异,启用三档规则,按内部及外部类目查询;旧规则保留', async () => {
|
|
const p = await service.preview(request);
|
|
expect(p.summary.eligible).toBe(1);
|
|
expect(p.categories[0].change).toBe('changed');
|
|
const r = await service.publish(
|
|
workspace,
|
|
user,
|
|
randomUUID(),
|
|
await publishInput(service, request),
|
|
);
|
|
expect(r.status).toBe(201);
|
|
const rules = await pricing.commissionRules(workspace, store, leaf);
|
|
expect(rules.bands.map((b) => b.commission_rate)).toEqual([
|
|
'0.120000',
|
|
'0.140000',
|
|
'0.205000',
|
|
]);
|
|
expect(
|
|
(
|
|
await pricing.commissionRules(
|
|
workspace,
|
|
store,
|
|
undefined,
|
|
p.categories[0].source_category_id,
|
|
)
|
|
).bands,
|
|
).toEqual(rules.bands);
|
|
expect(
|
|
(
|
|
await client.query(
|
|
`SELECT status,commission_rate FROM atlas.commission_category_rules WHERE id=$1`,
|
|
[oldRule],
|
|
)
|
|
).rows[0],
|
|
).toEqual({ status: 'archived', commission_rate: '0.100000' });
|
|
expect((await service.preview(request)).preview_hash).not.toBe(
|
|
p.preview_hash,
|
|
);
|
|
});
|
|
it('相同幂等请求只发布一次,旧预览不能再启用', async () => {
|
|
const input = await publishInput(service, request),
|
|
key = randomUUID();
|
|
const a = await service.publish(workspace, user, key, input);
|
|
expect(await service.publish(workspace, user, key, input)).toEqual(
|
|
JSON.parse(JSON.stringify(a)),
|
|
);
|
|
await expect(
|
|
service.publish(workspace, user, randomUUID(), input),
|
|
).rejects.toMatchObject({
|
|
response: { error: { code: 'COMMISSION_PREVIEW_STALE' } },
|
|
});
|
|
const fresh = await publishInput(service, request);
|
|
await expect(
|
|
service.publish(workspace, user, randomUUID(), fresh),
|
|
).rejects.toMatchObject({
|
|
response: { error: { code: 'COMMISSION_IMPORT_EXISTS' } },
|
|
});
|
|
});
|
|
it('0% 佣金可以启用并用于类目查询', async () => {
|
|
const d = request.document as ReturnType<typeof makeDocument>;
|
|
d.data[0].children[0].children = makeDocument('1', '2', [
|
|
'0.00',
|
|
'0.00',
|
|
'0.00',
|
|
]).data[0].children[0].children;
|
|
const p = await service.preview(request);
|
|
expect(p.summary).toMatchObject({
|
|
eligible: 1,
|
|
excluded: 0,
|
|
mapped_types: 1,
|
|
});
|
|
await service.publish(
|
|
workspace,
|
|
user,
|
|
randomUUID(),
|
|
await publishInput(service, request),
|
|
);
|
|
expect(
|
|
(await pricing.commissionRules(workspace, store, leaf)).bands.map(
|
|
(b) => b.commission_rate,
|
|
),
|
|
).toEqual(['0.000000', '0.000000', '0.000000']);
|
|
});
|
|
it('错误父类目被提示;无可映射规则时拒绝空版本', async () => {
|
|
const d = request.document as ReturnType<typeof makeDocument>;
|
|
d.data[0].cate_id = '123';
|
|
expect(
|
|
(await service.preview(request)).categories[0].issues.join(),
|
|
).toContain('父类目');
|
|
await expect(
|
|
service.publish(
|
|
workspace,
|
|
user,
|
|
randomUUID(),
|
|
await publishInput(service, request),
|
|
),
|
|
).rejects.toMatchObject({
|
|
response: { error: { code: 'COMMISSION_IMPORT_INVALID' } },
|
|
});
|
|
});
|
|
it('目录变化后必须重新预览', async () => {
|
|
const input = await publishInput(service, request);
|
|
await client.query(
|
|
`UPDATE atlas.platform_product_categories SET is_active=false WHERE id=$1`,
|
|
[leaf],
|
|
);
|
|
await expect(
|
|
service.publish(workspace, user, randomUUID(), input),
|
|
).rejects.toMatchObject({
|
|
response: { error: { code: 'COMMISSION_PREVIEW_STALE' } },
|
|
});
|
|
});
|
|
it('撤销管理员授权阻止发布,其他工作空间不能读取门店规则', async () => {
|
|
const input = await publishInput(service, request);
|
|
await client.query('RESET ROLE');
|
|
await client.query(
|
|
`UPDATE atlas.system_admin_grants SET revoked_at=now() WHERE user_id=$1`,
|
|
[user],
|
|
);
|
|
await client.query('SET LOCAL ROLE atlas_app');
|
|
await expect(
|
|
service.publish(workspace, user, randomUUID(), input),
|
|
).rejects.toMatchObject({
|
|
response: { error: { code: 'SYSTEM_ADMIN_REQUIRED' } },
|
|
});
|
|
await expect(
|
|
pricing.commissionRules(randomUUID(), store, leaf),
|
|
).rejects.toMatchObject({
|
|
response: { error: { code: 'STORE_NOT_FOUND' } },
|
|
});
|
|
});
|
|
it('有目录异常时无需额外确认,仍可启用其他可用类目', async () => {
|
|
const d = request.document as ReturnType<typeof makeDocument>;
|
|
d.data[0].children.push(
|
|
makeDocument('1', '899999999').data[0].children[0],
|
|
);
|
|
const preview = await service.preview(request);
|
|
expect(preview.summary).toMatchObject({ eligible: 1, excluded: 1 });
|
|
const input = await publishInput(service, request);
|
|
await service.publish(workspace, user, randomUUID(), input);
|
|
expect(
|
|
(await pricing.commissionRules(workspace, store, leaf)).bands,
|
|
).toHaveLength(3);
|
|
expect(
|
|
(
|
|
await service.detail((await service.list(platform))[0].id)
|
|
).categories.filter((c) => c.status === 'excluded'),
|
|
).toHaveLength(1);
|
|
});
|
|
it('后续写入失败时旧版本保持有效', async () => {
|
|
const input = await publishInput(service, request);
|
|
const original = client.query.bind(client);
|
|
const spy = vi.spyOn(client, 'query').mockImplementation(((
|
|
sql: string,
|
|
args: unknown[],
|
|
) => {
|
|
if (
|
|
typeof sql === 'string' &&
|
|
sql.includes(
|
|
'INSERT INTO atlas.platform_product_category_commission_mappings',
|
|
)
|
|
)
|
|
throw new Error('模拟映射写入失败');
|
|
return original(sql, args);
|
|
}) as typeof client.query);
|
|
try {
|
|
await expect(
|
|
service.publish(workspace, user, randomUUID(), input),
|
|
).rejects.toThrow('模拟映射写入失败');
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
expect(
|
|
(
|
|
await client.query(
|
|
'SELECT is_current FROM atlas.commission_imports WHERE id=$1',
|
|
[oldId],
|
|
)
|
|
).rows[0].is_current,
|
|
).toBe(true);
|
|
expect(
|
|
(
|
|
await client.query(
|
|
'SELECT status FROM atlas.commission_category_rules WHERE id=$1',
|
|
[oldRule],
|
|
)
|
|
).rows[0].status,
|
|
).toBe('active');
|
|
expect(
|
|
(
|
|
await client.query(
|
|
'SELECT id FROM atlas.commission_imports WHERE version=$1',
|
|
[request.version],
|
|
)
|
|
).rowCount,
|
|
).toBe(0);
|
|
});
|
|
it('数据库拒绝直接改写已启用的历史费率', async () => {
|
|
await expect(
|
|
db.transaction((tx) =>
|
|
tx.query(
|
|
`UPDATE atlas.commission_category_rules SET commission_rate=0.9 WHERE id=$1`,
|
|
[oldRule],
|
|
),
|
|
),
|
|
).rejects.toMatchObject({ code: '23514' });
|
|
expect(
|
|
(
|
|
await client.query(
|
|
`SELECT commission_rate FROM atlas.commission_category_rules WHERE id=$1`,
|
|
[oldRule],
|
|
)
|
|
).rows[0].commission_rate,
|
|
).toBe('0.100000');
|
|
});
|
|
});
|