import { ConfigModule, ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { CampaignStatus, PrizeType, StockType } from '@prisma/client'; import * as crypto from 'crypto'; import { PrismaModule } from '../../prisma/prisma.module'; import { PrismaService } from '../../prisma/prisma.service'; import { CampaignAvailabilityService } from '../campaign/campaign-availability.service'; import { ChanceModule } from '../chance/chance.module'; import { SmsService } from '../sms/sms.service'; import { PrizeCodeGenerator } from './prize-code-generator'; import { SpinEngineService } from './spin-engine.service'; import { WeightedRandomSelector } from './weighted-random-selector'; describe('SpinEngine Concurrency & Idempotency Integration Tests (AC-08, AC-09)', () => { let service: SpinEngineService; let prisma: PrismaService; const mockSmsService = { enqueueSpinResult: jest.fn().mockResolvedValue(true), enqueueOtp: jest.fn().mockResolvedValue(true), }; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }), PrismaModule, ChanceModule, ], providers: [ SpinEngineService, CampaignAvailabilityService, WeightedRandomSelector, PrizeCodeGenerator, { provide: SmsService, useValue: mockSmsService }, ], }).compile(); service = module.get(SpinEngineService); prisma = module.get(PrismaService); }); afterAll(async () => { await prisma.$disconnect(); }); it('AC-08 (Concurrency): 10 concurrent spins on Prize with stock=1 should yield EXACTLY 1 winner and stock=0 (never negative)', async () => { const timestamp = Date.now(); // 1) Create Test Campaign const campaign = await prisma.campaign.create({ data: { title: `Concurrency Test Campaign ${timestamp}`, slug: `concurrency-test-${timestamp}`, startAt: new Date(Date.now() - 86400000), // yesterday endAt: new Date(Date.now() + 86400000 * 30), // 30 days later status: CampaignStatus.ACTIVE, defaultChances: 5, }, }); // 2) Create 1 High-Value Prize with Stock = 1 and High Weight const iphonePrize = await prisma.prize.create({ data: { campaignId: campaign.id, title: 'تنها آیفون باقی‌مانده', type: PrizeType.PHYSICAL, stockType: StockType.LIMITED, initialStock: 1, remainingStock: 1, weight: 1000, sortOrder: 1, segmentColor: '#FFD700', }, }); // 3) Create Fallback Prize with Stock = 100 and Low Weight const discountPrize = await prisma.prize.create({ data: { campaignId: campaign.id, title: 'کد تخفیف عمومی', type: PrizeType.DISCOUNT_CODE, stockType: StockType.LIMITED, initialStock: 100, remainingStock: 100, weight: 1, sortOrder: 2, segmentColor: '#00CED1', }, }); // 4) Create 10 distinct users with 5 chances each const users = await Promise.all( Array.from({ length: 10 }).map(async (_, idx) => { const u = await prisma.user.create({ data: { mobile: `0999${timestamp.toString().slice(-4)}${idx.toString().padStart(3, '0')}`, status: 'ACTIVE', }, }); await prisma.userCampaign.create({ data: { userId: u.id, campaignId: campaign.id, totalChances: 5, usedChances: 0, }, }); return u; }), ); // 5) Fire 10 simultaneous Spin requests const spinPromises = users.map((user) => service.spin(user.id, campaign.id, crypto.randomUUID()), ); const results = await Promise.all(spinPromises); // Verify all 10 spins succeeded expect(results.length).toBe(10); results.forEach((r) => { expect(r.success).toBe(true); expect(r.remainingChances).toBe(4); }); // 6) CRITICAL VERIFICATION AC-08: // How many won the iPhone? const iphoneWinners = results.filter((r) => r.prize?.id === iphonePrize.id); expect(iphoneWinners.length).toBe(1); // Check remaining_stock in DB const updatedIphone = await prisma.prize.findUnique({ where: { id: iphonePrize.id }, }); expect(updatedIphone?.remainingStock).toBe(0); // EXACTLY 0, NEVER NEGATIVE! // Check other 9 got the fallback prize const discountWinners = results.filter((r) => r.prize?.id === discountPrize.id); expect(discountWinners.length).toBe(9); const updatedDiscount = await prisma.prize.findUnique({ where: { id: discountPrize.id }, }); expect(updatedDiscount?.remainingStock).toBe(91); // 100 - 9 = 91 // Cleanup await prisma.userPrize.deleteMany({ where: { campaignId: campaign.id } }); await prisma.spin.deleteMany({ where: { campaignId: campaign.id } }); await prisma.chanceTransaction.deleteMany({ where: { campaignId: campaign.id } }); await prisma.userCampaign.deleteMany({ where: { campaignId: campaign.id } }); await prisma.prize.deleteMany({ where: { campaignId: campaign.id } }); await prisma.campaign.delete({ where: { id: campaign.id } }); await prisma.user.deleteMany({ where: { id: { in: users.map((u) => u.id) } } }); }); it('AC-09 (Idempotency): Resending same request with identical idempotencyKey returns same result and consumes chance ONCE', async () => { const timestamp = Date.now(); const campaign = await prisma.campaign.create({ data: { title: `Idempotency Test ${timestamp}`, slug: `idempotency-test-${timestamp}`, startAt: new Date(Date.now() - 86400000), endAt: new Date(Date.now() + 86400000 * 10), status: CampaignStatus.ACTIVE, defaultChances: 3, }, }); const prize = await prisma.prize.create({ data: { campaignId: campaign.id, title: 'جایزه تستی', type: PrizeType.DISCOUNT_CODE, stockType: StockType.UNLIMITED, weight: 100, segmentColor: '#123456', }, }); const user = await prisma.user.create({ data: { mobile: `0998${timestamp.toString().slice(-7)}`, status: 'ACTIVE', }, }); await prisma.userCampaign.create({ data: { userId: user.id, campaignId: campaign.id, totalChances: 3, usedChances: 0, }, }); const idempotencyKey = crypto.randomUUID(); // First request const response1 = await service.spin(user.id, campaign.id, idempotencyKey); expect(response1.success).toBe(true); expect(response1.remainingChances).toBe(2); // Second request with SAME idempotencyKey const response2 = await service.spin(user.id, campaign.id, idempotencyKey); expect(response2.success).toBe(true); expect(response2.spinId).toBe(response1.spinId); expect(response2.prize?.id).toBe(response1.prize?.id); expect(response2.remainingChances).toBe(2); // Still 2, not decremented twice! // Verify in database: total spins with this key = 1, usedChances = 1 const spinRecords = await prisma.spin.count({ where: { idempotencyKey }, }); expect(spinRecords).toBe(1); const userCampaign = await prisma.userCampaign.findUnique({ where: { userId_campaignId: { userId: user.id, campaignId: campaign.id } }, }); expect(userCampaign?.usedChances).toBe(1); // Cleanup await prisma.userPrize.deleteMany({ where: { campaignId: campaign.id } }); await prisma.spin.deleteMany({ where: { campaignId: campaign.id } }); await prisma.chanceTransaction.deleteMany({ where: { campaignId: campaign.id } }); await prisma.userCampaign.deleteMany({ where: { campaignId: campaign.id } }); await prisma.prize.deleteMany({ where: { campaignId: campaign.id } }); await prisma.campaign.delete({ where: { id: campaign.id } }); await prisma.user.delete({ where: { id: user.id } }); }); });