231 lines
8.0 KiB
TypeScript
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 } });
|
|
});
|
|
});
|