文件
OmniCard/backend/test/modules/team/quota.test.ts
wikiboss 62e0ae4435 feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:01:47 +08:00

528 行
19 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,
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<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);
});
});
});