import { BaseController, CoolCommException } from '@cool-midway/core'; import { CardInfoEntity } from '../../../src/modules/card/entity/info'; import { BaseSysUserEntity } from '../../../src/modules/base/entity/sys/user'; import { NfcDeviceEntity } from '../../../src/modules/nfc/entity/device'; import { AdminTeamBillingController, adminTeamBillingCurdOption, } from '../../../src/modules/team/controller/admin/billing'; import { TeamQuotaService, DEFAULT_PLAN_PRICES, DEFAULT_PLAN_LIMITS, } from '../../../src/modules/team/service/quota'; import type { PlanLimits, QuotaCheckResult, ProrationResult, TenantPlanType, } from '../../../src/comm/types/omni'; describe('Point 23: 企业多坐席配额用量预警与升级差价计算 单元测试 (TDD)', () => { let quotaService: TeamQuotaService; let mockCardRepo: { count: jest.Mock }; let mockUserRepo: { count: jest.Mock }; let mockNfcRepo: { count: jest.Mock }; beforeEach(() => { mockCardRepo = { count: jest.fn(async () => 0) }; mockUserRepo = { count: jest.fn(async () => 0) }; mockNfcRepo = { count: jest.fn(async () => 0) }; quotaService = new TeamQuotaService(); // 注入 Mock Repositories quotaService.cardInfoEntity = mockCardRepo as unknown as typeof quotaService.cardInfoEntity; quotaService.baseSysUserEntity = mockUserRepo as unknown as typeof quotaService.baseSysUserEntity; quotaService.nfcDeviceEntity = mockNfcRepo as unknown as typeof quotaService.nfcDeviceEntity; }); // ========================================== // 1. 配额规格读取 (getPlanLimits) // ========================================== describe('1. 配额规格读取 (getPlanLimits)', () => { it('1.1 正确返回 Free 套餐配额上限 (名片 1, 坐席 1, NFC 1)', () => { const limits: PlanLimits = quotaService.getPlanLimits('free'); expect(limits.plan).toBe('free'); expect(limits.cardsLimit).toBe(1); expect(limits.seatsLimit).toBe(1); expect(limits.nfcDevicesLimit).toBe(1); }); it('1.2 正确返回 Pro 套餐配额上限 (名片 5, 坐席 10, NFC 10)', () => { const limits: PlanLimits = quotaService.getPlanLimits('pro'); expect(limits.plan).toBe('pro'); expect(limits.cardsLimit).toBe(5); expect(limits.seatsLimit).toBe(10); expect(limits.nfcDevicesLimit).toBe(10); }); it('1.3 正确返回 Enterprise 套餐配额上限 (名片 999, 坐席 100, NFC 100)', () => { const limits: PlanLimits = quotaService.getPlanLimits('enterprise'); expect(limits.plan).toBe('enterprise'); expect(limits.cardsLimit).toBe(999); expect(limits.seatsLimit).toBe(100); expect(limits.nfcDevicesLimit).toBe(100); }); it('1.4 传入未知或非法套餐规格时,默认安全回退到 Free 套餐', () => { const limits = quotaService.getPlanLimits('unknown' as unknown as TenantPlanType); expect(limits.plan).toBe('free'); expect(limits.cardsLimit).toBe(1); expect(limits.seatsLimit).toBe(1); expect(limits.nfcDevicesLimit).toBe(1); }); }); // ========================================== // 2. 用量统计与 80% 百分比预警 (checkQuota) // ========================================== describe('2. 用量统计与 80% 百分比预警 (checkQuota)', () => { it('2.1 用量低于 80% 时,warning 为 false (如 Pro 坐席 7/10 = 70%)', async () => { const result: QuotaCheckResult = await quotaService.checkQuota(101, 'seats', { plan: 'pro', currentUsed: 7, }); expect(result.tenantId).toBe(101); expect(result.resourceType).toBe('seats'); expect(result.plan).toBe('pro'); expect(result.used).toBe(7); expect(result.limit).toBe(10); expect(result.remaining).toBe(3); expect(result.percentage).toBe(70); expect(result.warning).toBe(false); expect(result.exceeded).toBe(false); }); it('2.2 用量达到恰好 80% 临界值时,触发预警 warning 为 true (如 Pro 坐席 8/10 = 80%)', async () => { const result: QuotaCheckResult = await quotaService.checkQuota(101, 'seats', { plan: 'pro', currentUsed: 8, }); expect(result.used).toBe(8); expect(result.limit).toBe(10); expect(result.remaining).toBe(2); expect(result.percentage).toBe(80); expect(result.warning).toBe(true); expect(result.exceeded).toBe(false); }); it('2.3 用量超过 80% 时保持预警 (如 Pro 名片 4/5 = 80%, Pro 坐席 9/10 = 90%)', async () => { const cardResult = await quotaService.checkQuota(101, 'cards', { plan: 'pro', currentUsed: 4, }); expect(cardResult.percentage).toBe(80); expect(cardResult.warning).toBe(true); expect(cardResult.exceeded).toBe(false); const seatResult = await quotaService.checkQuota(101, 'seats', { plan: 'pro', currentUsed: 9, }); expect(seatResult.percentage).toBe(90); expect(seatResult.warning).toBe(true); expect(seatResult.exceeded).toBe(false); }); it('2.4 用量达到 100% 满额时,warning 为 true,exceeded 为 true,remaining 为 0', async () => { const result = await quotaService.checkQuota(101, 'seats', { plan: 'pro', currentUsed: 10, }); expect(result.used).toBe(10); expect(result.limit).toBe(10); expect(result.remaining).toBe(0); expect(result.percentage).toBe(100); expect(result.warning).toBe(true); expect(result.exceeded).toBe(true); }); it('2.5 支持单数资源别名 (card, seat, nfc) 并正确映射', async () => { const cardRes = await quotaService.checkQuota(101, 'card', { plan: 'free', currentUsed: 1, }); expect(cardRes.limit).toBe(1); expect(cardRes.warning).toBe(true); expect(cardRes.exceeded).toBe(true); const nfcRes = await quotaService.checkQuota(101, 'nfc', { plan: 'pro', currentUsed: 8, }); expect(nfcRes.limit).toBe(10); expect(nfcRes.warning).toBe(true); }); it('2.6 未传入 currentUsed 时,自动通过 Repository 查询对应数据库计数', async () => { mockUserRepo.count.mockResolvedValueOnce(6); mockCardRepo.count.mockResolvedValueOnce(3); mockNfcRepo.count.mockResolvedValueOnce(5); const seatRes = await quotaService.checkQuota(202, 'seats', { plan: 'pro' }); expect(mockUserRepo.count).toHaveBeenCalled(); expect(seatRes.used).toBe(6); expect(seatRes.remaining).toBe(4); expect(seatRes.warning).toBe(false); const cardRes = await quotaService.checkQuota(202, 'cards', { plan: 'pro' }); expect(mockCardRepo.count).toHaveBeenCalled(); expect(cardRes.used).toBe(3); expect(cardRes.percentage).toBe(60); const nfcRes = await quotaService.checkQuota(202, 'nfcDevices', { plan: 'pro' }); expect(mockNfcRepo.count).toHaveBeenCalled(); expect(nfcRes.used).toBe(5); expect(nfcRes.percentage).toBe(50); }); }); // ========================================== // 3. 超限断言拦截 (assertQuotaAvailable) // ========================================== describe('3. 超限断言拦截 (assertQuotaAvailable)', () => { it('3.1 未超限时断言成功,不抛出异常并返回配额状态', async () => { await expect( quotaService.assertQuotaAvailable(101, 'seats', 1, { plan: 'pro', currentUsed: 7, }) ).resolves.toMatchObject({ used: 7, limit: 10, exceeded: false, }); }); it('3.2 当前用量已达上限时,抛出 CoolCommException 且中文说明已达上限', async () => { await expect( quotaService.assertQuotaAvailable(101, 'seats', 1, { plan: 'free', currentUsed: 1, }) ).rejects.toThrow(/已达上限/); }); it('3.3 增加批次数量 (additionalCount) 导致超限时,同样抛出已达上限', async () => { // 当前 8/10,增加 3 个 => 11 > 10,应拦截阻断 await expect( quotaService.assertQuotaAvailable(101, 'seats', 3, { plan: 'pro', currentUsed: 8, }) ).rejects.toThrow(/已达上限/); // 增加 2 个 => 10 <= 10,刚好达标,不抛出 await expect( quotaService.assertQuotaAvailable(101, 'seats', 2, { plan: 'pro', currentUsed: 8, }) ).resolves.toBeDefined(); }); it('3.4 异常类型必须为 CoolCommException,名片超限文案含已达上限', async () => { try { await quotaService.assertQuotaAvailable(101, 'cards', 1, { plan: 'free', currentUsed: 1, }); fail('应当抛出异常'); } catch (err) { expect(err).toBeInstanceOf(CoolCommException); expect((err as CoolCommException).message).toContain('已达上限'); expect((err as CoolCommException).message).toContain('名片'); } }); it('3.5 P33: Free 套餐名片配额用尽时阻断新增,文案为中文已达上限', async () => { await expect( quotaService.assertQuotaAvailable(101, 'cards', 1, { plan: 'free', currentUsed: 1, }) ).rejects.toMatchObject({ message: '当前套餐名片配额已达上限,请升级套餐', }); }); it('3.6 P33: Pro 套餐名片未超限时允许新增', async () => { await expect( quotaService.assertQuotaAvailable(101, 'cards', 1, { plan: 'pro', currentUsed: 4, }) ).resolves.toMatchObject({ used: 4, limit: 5, exceeded: false, }); }); it('3.7 R45: Free 名片上限必须来自套餐配置,按配置值放行', async () => { expect(DEFAULT_PLAN_LIMITS.free.cardsLimit).toBe(1); await expect( quotaService.assertQuotaAvailable(101, 'cards', 1, { plan: 'free', currentUsed: 0, }) ).resolves.toMatchObject({ used: 0, limit: DEFAULT_PLAN_LIMITS.free.cardsLimit, exceeded: false, }); }); it('3.8 R45: Free 套餐未配置 cardsLimit 时拒绝新增,不得当成无限', async () => { const original = DEFAULT_PLAN_LIMITS.free.cardsLimit; try { delete (DEFAULT_PLAN_LIMITS.free as { cardsLimit?: number }).cardsLimit; await expect( quotaService.assertQuotaAvailable(101, 'cards', 1, { plan: 'free', currentUsed: 0, }) ).rejects.toThrow(/未配置|未设置/); await expect( quotaService.assertQuotaAvailable(101, 'cards', 1, { plan: 'free', currentUsed: 0, }) ).rejects.toBeInstanceOf(CoolCommException); } finally { DEFAULT_PLAN_LIMITS.free.cardsLimit = original; } }); it('3.9 R45: Free 套餐 cardsLimit 为 NaN/Infinity 视为未设置上限,拒绝新增', async () => { const original = DEFAULT_PLAN_LIMITS.free.cardsLimit; try { DEFAULT_PLAN_LIMITS.free.cardsLimit = Number.NaN; await expect( quotaService.assertQuotaAvailable(101, 'cards', 1, { plan: 'free', currentUsed: 0, }) ).rejects.toThrow(/未配置|未设置/); DEFAULT_PLAN_LIMITS.free.cardsLimit = Number.POSITIVE_INFINITY; await expect( quotaService.assertQuotaAvailable(101, 'cards', 1, { plan: 'free', currentUsed: 0, }) ).rejects.toThrow(/未配置|未设置/); } finally { DEFAULT_PLAN_LIMITS.free.cardsLimit = original; } }); it('3.10 R45: 未配置上限的拒绝文案不得冒充 P33 配额用尽文案', async () => { const original = DEFAULT_PLAN_LIMITS.free.cardsLimit; try { delete (DEFAULT_PLAN_LIMITS.free as { cardsLimit?: number }).cardsLimit; try { await quotaService.assertQuotaAvailable(101, 'cards', 1, { plan: 'free', currentUsed: 0, }); fail('应当抛出异常'); } catch (err) { expect(err).toBeInstanceOf(CoolCommException); expect((err as CoolCommException).message).not.toBe( '当前套餐名片配额已达上限,请升级套餐' ); expect((err as CoolCommException).message).toMatch(/未配置|未设置/); } } finally { DEFAULT_PLAN_LIMITS.free.cardsLimit = original; } }); it('3.11 R45: checkQuota 在未配置上限时 exceeded=true、remaining=0,不得当成无限', async () => { const original = DEFAULT_PLAN_LIMITS.free.cardsLimit; try { delete (DEFAULT_PLAN_LIMITS.free as { cardsLimit?: number }).cardsLimit; const result = await quotaService.checkQuota(101, 'cards', { plan: 'free', currentUsed: 0, }); expect(result.limit).toBe(0); expect(result.remaining).toBe(0); expect(result.exceeded).toBe(true); expect(result.percentage).toBe(100); } finally { DEFAULT_PLAN_LIMITS.free.cardsLimit = original; } }); }); // ========================================== // 4. 套餐升级补差价精确折算 (calculateUpgradeProration) // ========================================== describe('4. 套餐升级补差价精确折算 (calculateUpgradeProration)', () => { it('4.1 Free 升级至 Pro,按天精确折算差价,四舍五入防精度损失', () => { // 假设 Pro 年费 299 元,剩余 180 天 / 365 天 // 差价 = (299 - 0) * (180 / 365) = 147.45205479... => 147.45 元 const result: ProrationResult = quotaService.calculateUpgradeProration( 'free', 'pro', 180, 365, { currentPrice: 0, targetPrice: 299 } ); expect(result.currentPlan).toBe('free'); expect(result.targetPlan).toBe('pro'); expect(result.currentPlanPrice).toBe(0); expect(result.targetPlanPrice).toBe(299); expect(result.remainingDays).toBe(180); expect(result.totalDays).toBe(365); expect(result.proratedCurrentCredit).toBe(0); expect(result.proratedTargetCost).toBe(147.45); expect(result.upgradeDiffAmount).toBe(147.45); }); it('4.2 Pro 升级至 Enterprise,按天精确折算补差价', () => { // Pro 年费 299 元,Enterprise 年费 1999 元,差额 1700 元 // 剩余 200 天 / 365 天 // 原套餐折合退款 Credit = 299 * (200 / 365) = 163.8356... => 163.84 // 目标套餐剩余应付 Cost = 1999 * (200 / 365) = 1095.3424... => 1095.34 // 补差价 = 1095.34 - 163.84 = 931.50 元 (或 1700 * 200 / 365 = 931.51 / 931.50) const result = quotaService.calculateUpgradeProration( 'pro', 'enterprise', 200, 365, { currentPrice: 299, targetPrice: 1999 } ); expect(result.currentPlanPrice).toBe(299); expect(result.targetPlanPrice).toBe(1999); expect(result.upgradeDiffAmount).toBeCloseTo(931.51, 1); expect(result.upgradeDiffAmount).toBeGreaterThan(0); }); it('4.3 使用系统默认价格表 (DEFAULT_PLAN_PRICES)', () => { expect(DEFAULT_PLAN_PRICES.free).toBe(0); expect(DEFAULT_PLAN_PRICES.pro).toBeGreaterThan(0); expect(DEFAULT_PLAN_PRICES.enterprise).toBeGreaterThan(DEFAULT_PLAN_PRICES.pro); const result = quotaService.calculateUpgradeProration( 'free', 'pro', 365, 365 ); // 满周期 365/365,差价应为 Pro 全额价格 expect(result.upgradeDiffAmount).toBe(DEFAULT_PLAN_PRICES.pro); }); it('4.4 同级平级升级或降级时,补差价为 0', () => { // 同级平级 const same = quotaService.calculateUpgradeProration('pro', 'pro', 100, 365); expect(same.upgradeDiffAmount).toBe(0); // 降级 (Enterprise -> Pro) const down = quotaService.calculateUpgradeProration('enterprise', 'pro', 100, 365); expect(down.upgradeDiffAmount).toBe(0); }); it('4.5 边界保护:剩余天数 <= 0 时,补差价为 0', () => { const zeroDays = quotaService.calculateUpgradeProration('free', 'pro', 0, 365); expect(zeroDays.upgradeDiffAmount).toBe(0); const negativeDays = quotaService.calculateUpgradeProration('free', 'pro', -10, 365); expect(negativeDays.upgradeDiffAmount).toBe(0); }); it('4.6 边界保护:总天数 totalDays 非法时抛出异常或安全兜底', () => { expect(() => { quotaService.calculateUpgradeProration('free', 'pro', 10, 0); }).toThrow(); }); it('4.7 高精度小数金额防精度丢失验证 (如 99.99 和 299.99)', () => { const result = quotaService.calculateUpgradeProration( 'free', 'pro', 30, 90, { currentPrice: 99.99, targetPrice: 299.99 } ); // 差价 (299.99 - 99.99) * (30 / 90) = 200 * (1 / 3) = 66.66666... => 66.67 expect(result.upgradeDiffAmount).toBe(66.67); // 必须为严格 2 位小数的浮点数,防类似 66.66666666666667 或 66.66999999999999 expect(Number(result.upgradeDiffAmount.toFixed(2))).toBe(result.upgradeDiffAmount); }); }); // ========================================== // 5. 控制器路由与集成配置约定 (AdminTeamBillingController) // ========================================== describe('5. 控制器路由与集成配置约定', () => { let controller: AdminTeamBillingController; beforeEach(() => { controller = new AdminTeamBillingController(); controller.teamQuotaService = quotaService; controller.ok = (data?: unknown) => ({ code: 1000, message: 'success', data, } as ReturnType); }); it('5.1 控制器应继承 BaseController 并正确声明 serviceApis', () => { expect(AdminTeamBillingController.prototype).toBeInstanceOf(BaseController); const methods = (adminTeamBillingCurdOption.serviceApis || []).map( (api: { method: string }) => api.method ); expect(methods).toContain('currentPlan'); expect(methods).toContain('getPlanLimits'); expect(methods).toContain('checkQuota'); expect(methods).toContain('assertQuotaAvailable'); expect(methods).toContain('calculateUpgradeProration'); }); it('5.2 postQuota 路由方法正确返回 code 1000 成功响应', async () => { const res = await controller.postQuota({ tenantId: 101, resourceType: 'seats', plan: 'pro', currentUsed: 8, }); expect(res.code).toBe(1000); expect(res.data.warning).toBe(true); expect(res.data.percentage).toBe(80); }); it('5.3 postProration 路由方法正确返回 code 1000 升级差价数据', async () => { const res = await controller.postProration({ currentPlan: 'free', targetPlan: 'pro', remainingDays: 180, totalDays: 365, currentPlanPrice: 0, targetPlanPrice: 299, }); expect(res.code).toBe(1000); expect(res.data.upgradeDiffAmount).toBe(147.45); }); it('5.4 postLimits 路由方法正确返回各套餐规格列表', async () => { const res = await controller.postLimits({ plan: 'pro' }); expect(res.code).toBe(1000); expect(res.data.seatsLimit).toBe(10); expect(res.data.cardsLimit).toBe(5); expect(res.data.nfcDevicesLimit).toBe(10); }); }); });