test(membership): add quota, guard, and controller unit tests
All checks were successful
Deploy API Server / build-and-unit (push) Successful in 39s
Deploy API Server / current-integration (push) Successful in 33s
Deploy API Server / backward-compat (push) Successful in 0s
Deploy API Server / deploy (push) Successful in 1m5s

- EffectiveQuotaService: 11 tests (shadow/enforced/legacy, P2002, unlimited)
- QuotaGuardService: 8 tests (check, error codes, confirm/cancel)
- MembershipController: 3 tests (me, plans, no txId leak)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-26 20:34:33 +08:00
parent fef0695845
commit 1d6a04a8b3
3 changed files with 183 additions and 0 deletions

View File

@ -0,0 +1,84 @@
import { Test, TestingModule } from '@nestjs/testing';
import { EffectiveQuotaService } from './effective-quota.service';
import { EffectiveMembershipService } from './effective-membership.service';
import { PrismaService } from '../../infrastructure/database/prisma.service';
describe('EffectiveQuotaService', () => {
let svc: EffectiveQuotaService;
let prisma: any;
beforeEach(async () => {
prisma = {
membershipPlan: { findUnique: jest.fn().mockResolvedValue({ code:'premium', planQuotas:[{ quotaType:'quiz_generation', limit:30, unit:'count', windowType:'daily', isUnlimited:false }] }) },
quotaUsage: { findMany: jest.fn().mockResolvedValue([]), findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id:'r1' }), findUnique: jest.fn(), deleteMany: jest.fn(), updateMany: jest.fn() },
};
const mockMembership = { compute: jest.fn().mockResolvedValue({ effectivePlan:'premium', grants:[], appAccountToken:null }) };
const mod: TestingModule = await Test.createTestingModule({
providers: [EffectiveQuotaService, { provide: PrismaService, useValue: prisma }, { provide: EffectiveMembershipService, useValue: mockMembership }],
}).compile();
svc = mod.get(EffectiveQuotaService);
svc.setMode('shadow');
});
it('computeEffectiveQuota returns plan limits', async () => {
const q = await svc.computeEffectiveQuota('u1');
expect(q.planCode).toBe('premium');
expect(q.limits).toHaveLength(1);
expect(q.limits[0].quotaType).toBe('quiz_generation');
});
it('reserve returns allowed=true when under limit', async () => {
const r = await svc.reserve('u1', 'quiz_generation', 'op1');
expect(r.allowed).toBe(true);
});
it('reserve returns allowed=true in shadow mode even when over limit', async () => {
prisma.quotaUsage.findMany.mockResolvedValue([{ amount:31 }]);
const r = await svc.reserve('u1', 'quiz_generation', 'op1');
expect(r.allowed).toBe(true); // shadow mode doesn't block
});
it('reserve returns allowed=false in enforced mode when over limit', async () => {
svc.setMode('enforced');
prisma.quotaUsage.findMany.mockResolvedValue([{ amount:31 }]);
const r = await svc.reserve('u1', 'quiz_generation', 'op1');
expect(r.allowed).toBe(false);
});
it('reserve creates record and returns reservationId', async () => {
svc.setMode('enforced');
const r = await svc.reserve('u1', 'quiz_generation', 'op_new');
expect(prisma.quotaUsage.create).toHaveBeenCalled();
expect(r.reservationId).toBe('r1');
});
it('reserve with P2002 returns existing record', async () => {
svc.setMode('enforced');
prisma.quotaUsage.create.mockRejectedValue({ code:'P2002' });
prisma.quotaUsage.findUnique.mockResolvedValue({ id:'existing' });
const r = await svc.reserve('u1', 'quiz_generation', 'op_p2002');
expect(r.reservationId).toBe('existing');
});
it('unlimited quota always allowed', async () => {
prisma.membershipPlan.findUnique.mockResolvedValue({ code:'premium', planQuotas:[{ quotaType:'ai_chat_messages', limit:0, unit:'count', windowType:'monthly', isUnlimited:true }] });
const r = await svc.reserve('u1', 'ai_chat_messages', 'op1');
expect(r.allowed).toBe(true);
expect(r.remaining).toBe(-1);
});
it('missing quotaType returns allowed', async () => {
const r = await svc.reserve('u1', 'unknown_type', 'op1');
expect(r.allowed).toBe(true);
});
it('legacy mode returns allowed always', async () => {
svc.setMode('legacy');
prisma.quotaUsage.findMany.mockResolvedValue([{ amount:999 }]);
const r = await svc.reserve('u1', 'quiz_generation', 'op1');
expect(r.allowed).toBe(true);
});
it('commit succeeds', async () => { await svc.commit('op1'); });
it('release calls deleteMany', async () => { await svc.release('op1'); expect(prisma.quotaUsage.deleteMany).toHaveBeenCalled(); });
});

View File

@ -0,0 +1,40 @@
import { MembershipController } from './membership.controller';
import { MyMembershipResponse } from '../membership/../../ios-projects/AIStudyApp/AIStudyApp/Core/Models/MembershipModels';
// Use inline types for tests
type MockMembership = { effectivePlan: string; grants: any[]; appAccountToken: string | null };
describe('MembershipController', () => {
let ctrl: MembershipController;
let svc: any; let prisma: any;
beforeEach(() => {
svc = { compute: jest.fn(), getEntitlements: jest.fn() };
prisma = { membershipPlan: { findMany: jest.fn() } };
ctrl = new MembershipController(svc, prisma);
});
it('GET /membership/me returns effectivePlan + grants + token', async () => {
svc.compute.mockResolvedValue({ effectivePlan:'premium', grants:[{ source:'apple',status:'active',planCode:'premium',planName:'Premium',startedAt:'2026-01-01',expiresAt:null,autoRenewStatus:'on',environment:'Production' }], appAccountToken:'uuid-1' });
svc.getEntitlements.mockResolvedValue({ planCode:'premium', entitlements:{ maxKnowledgeBases:10, maxStorageBytes:10737418240, maxFileSizeBytes:52428800, monthlyOcrPages:50, monthlyVisionPages:30, monthlyChatCount:300, monthlyAiAnalysisCount:0, monthlyRecallCount:0, monthlyCardGenCount:0, monthlyQuizCount:0 } });
const r = await ctrl.getMyMembership({ user:{ id:'u1' } });
expect(r.effectivePlan).toBe('premium');
expect(r.grants).toHaveLength(1);
expect(r.appAccountToken).toBe('uuid-1');
expect(r.entitlements).toBeDefined();
});
it('GET /membership/me does not expose transactionId', async () => {
svc.compute.mockResolvedValue({ effectivePlan:'free', grants:[], appAccountToken:null });
svc.getEntitlements.mockResolvedValue({ planCode:'free', entitlements:{ maxKnowledgeBases:1, maxStorageBytes:1073741824, maxFileSizeBytes:10485760, monthlyOcrPages:0, monthlyVisionPages:0, monthlyChatCount:30, monthlyAiAnalysisCount:0, monthlyRecallCount:0, monthlyCardGenCount:0, monthlyQuizCount:0 } });
const r = await ctrl.getMyMembership({ user:{ id:'u1' } });
expect(r.transactionId).toBeUndefined();
expect(r.originalTransactionId).toBeUndefined();
});
it('GET /membership/plans returns active plans', async () => {
prisma.membershipPlan.findMany.mockResolvedValue([{ code:'free', name:'Free', maxKnowledgeBases:1, maxStorageBytes:BigInt(1073741824), maxFileSizeBytes:BigInt(10485760), monthlyOcrPages:0, monthlyVisionPages:0, monthlyChatCount:30, monthlyAiAnalysisCount:0, monthlyRecallCount:0, monthlyCardGenCount:0, monthlyQuizCount:0, priceMonthly:0, priceYearly:0 }]);
const r = await ctrl.getPlans();
expect(r.plans).toHaveLength(1);
expect(r.plans[0].planCode).toBe('free');
});
});

View File

@ -0,0 +1,59 @@
import { Test, TestingModule } from '@nestjs/testing';
import { QuotaGuardService } from './quota-guard.service';
import { EffectiveQuotaService } from './effective-quota.service';
import { PrismaService } from '../../infrastructure/database/prisma.service';
describe('QuotaGuardService', () => {
let guard: QuotaGuardService;
let mockQuota: any;
const allowed = { allowed:true, quotaType:'quiz_generation', limit:30, used:5, remaining:25 };
const denied = { allowed:false, quotaType:'quiz_generation', limit:30, used:31, remaining:0, upgradeAvailable:true };
beforeEach(async () => {
mockQuota = {
reserve: jest.fn(), commit: jest.fn(), release: jest.fn(), setMode: jest.fn(), getMode: jest.fn().mockReturnValue('shadow'), computeEffectiveQuota: jest.fn(),
};
const mod: TestingModule = await Test.createTestingModule({
providers: [QuotaGuardService, { provide: EffectiveQuotaService, useValue: mockQuota }, { provide: PrismaService, useValue: {} }],
}).compile();
guard = mod.get(QuotaGuardService);
});
it('check calls reserve with correct args', async () => {
mockQuota.reserve.mockResolvedValue(allowed);
await guard.check('u1', 'quiz_generation', 'op1');
expect(mockQuota.reserve).toHaveBeenCalledWith('u1', 'quiz_generation', 'op1', 1);
});
it('returns upgradeAvailable=true when denied', async () => {
mockQuota.reserve.mockResolvedValue(denied);
const r = await guard.check('u1', 'quiz_generation', 'op1');
expect(r.allowed).toBe(false);
expect(r.upgradeAvailable).toBe(true);
});
it('buildError returns QUOTA_EXCEEDED for ai types', () => {
const err = guard.buildError(denied);
expect(err.errorCode).toBe('QUOTA_EXCEEDED');
expect(err.upgradeAvailable).toBe(true);
});
it('buildError returns STORAGE_LIMIT_EXCEEDED for storage_bytes', () => {
const err = guard.buildError({ ...denied, quotaType: 'storage_bytes' });
expect(err.errorCode).toBe('STORAGE_LIMIT_EXCEEDED');
});
it('buildError returns KNOWLEDGE_BASE_LIMIT_EXCEEDED', () => {
const err = guard.buildError({ ...denied, quotaType: 'knowledge_base_count' });
expect(err.errorCode).toBe('KNOWLEDGE_BASE_LIMIT_EXCEEDED');
});
it('buildError returns FILE_SIZE_LIMIT_EXCEEDED', () => {
const err = guard.buildError({ ...denied, quotaType: 'file_size_bytes' });
expect(err.errorCode).toBe('FILE_SIZE_LIMIT_EXCEEDED');
});
it('confirm delegates to quota service', async () => { await guard.confirm('op1'); expect(mockQuota.commit).toHaveBeenCalledWith('op1'); });
it('cancel delegates to quota service', async () => { await guard.cancel('op1'); expect(mockQuota.release).toHaveBeenCalledWith('op1'); });
});