Files
Lucky_Wheel/apps/api/src/modules/spin-engine/spin-concurrency.spec.ts
T
2026-09-10 01:58:29 +03:30

231 lines
8.0 KiB
TypeScript

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>(SpinEngineService);
prisma = module.get<PrismaService>(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 } });
});
});