文件
OmniCard/backend/test/modules/team/quota.test.ts
wikiboss eec259a3a7 feat: 完成全系统30个功能点开发与E2E全链路验收 (8轮调度32个子代理闭环)
- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换
- 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片
- 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转
- 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出
- 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志
- 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票
- 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏
- 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
2026-09-20 23:47:47 +08:00

405 行
15 KiB
TypeScript

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,
} 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 当前用量已达上限时,抛出 RESOURCE_LIMIT_EXCEEDED 阻断异常', async () => {
await expect(
quotaService.assertQuotaAvailable(101, 'seats', 1, {
plan: 'free',
currentUsed: 1,
})
).rejects.toThrow('RESOURCE_LIMIT_EXCEEDED');
});
it('3.3 增加批次数量 (additionalCount) 导致超限时,同样抛出 RESOURCE_LIMIT_EXCEEDED', async () => {
// 当前 8/10,增加 3 个 => 11 > 10,应拦截阻断
await expect(
quotaService.assertQuotaAvailable(101, 'seats', 3, {
plan: 'pro',
currentUsed: 8,
})
).rejects.toThrow('RESOURCE_LIMIT_EXCEEDED');
// 增加 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('RESOURCE_LIMIT_EXCEEDED');
}
});
});
// ==========================================
// 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<AdminTeamBillingController['ok']>);
});
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);
});
});
});