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 ( work: (tx: DatabaseTransaction) => Promise, ) => { 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, ) => 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 => ({ ...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; 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; 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; 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'); }); });