From 1d6a04a8b36361cd361eca0629a6208448bc2608 Mon Sep 17 00:00:00 2001 From: wangdl Date: Fri, 26 Jun 2026 20:34:33 +0800 Subject: [PATCH] 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 --- .../effective-quota.service.spec.ts | 84 +++++++++++++++++++ .../membership/membership.controller.spec.ts | 40 +++++++++ .../membership/quota-guard.service.spec.ts | 59 +++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 src/modules/membership/effective-quota.service.spec.ts create mode 100644 src/modules/membership/membership.controller.spec.ts create mode 100644 src/modules/membership/quota-guard.service.spec.ts diff --git a/src/modules/membership/effective-quota.service.spec.ts b/src/modules/membership/effective-quota.service.spec.ts new file mode 100644 index 0000000..b43e6ff --- /dev/null +++ b/src/modules/membership/effective-quota.service.spec.ts @@ -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(); }); +}); diff --git a/src/modules/membership/membership.controller.spec.ts b/src/modules/membership/membership.controller.spec.ts new file mode 100644 index 0000000..015a156 --- /dev/null +++ b/src/modules/membership/membership.controller.spec.ts @@ -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'); + }); +}); diff --git a/src/modules/membership/quota-guard.service.spec.ts b/src/modules/membership/quota-guard.service.spec.ts new file mode 100644 index 0000000..25375c0 --- /dev/null +++ b/src/modules/membership/quota-guard.service.spec.ts @@ -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'); }); +});