test(membership): add quota, guard, and controller unit tests
- 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:
parent
fef0695845
commit
1d6a04a8b3
84
src/modules/membership/effective-quota.service.spec.ts
Normal file
84
src/modules/membership/effective-quota.service.spec.ts
Normal 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(); });
|
||||||
|
});
|
||||||
40
src/modules/membership/membership.controller.spec.ts
Normal file
40
src/modules/membership/membership.controller.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
59
src/modules/membership/quota-guard.service.spec.ts
Normal file
59
src/modules/membership/quota-guard.service.spec.ts
Normal 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'); });
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user