first commit
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
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 } });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user