feat(sprint3): EconomyModule TetardCoin + TwitchModule EventSub — migration + 36 tests
This commit is contained in:
@@ -10,6 +10,8 @@ import { ItemModule } from './item/item.module';
|
||||
import { MaterialModule } from './material/material.module';
|
||||
import { CraftModule } from './craft/craft.module';
|
||||
import { ForgeModule } from './forge/forge.module';
|
||||
import { EconomyModule } from './economy/economy.module';
|
||||
import { TwitchModule } from './twitch/twitch.module';
|
||||
import { HealthController } from './common/health.controller';
|
||||
|
||||
@Module({
|
||||
@@ -43,6 +45,8 @@ import { HealthController } from './common/health.controller';
|
||||
MaterialModule,
|
||||
CraftModule,
|
||||
ForgeModule,
|
||||
EconomyModule,
|
||||
TwitchModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
|
||||
13
src/economy/conversion.service.spec.ts
Normal file
13
src/economy/conversion.service.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ConversionService } from './conversion.service';
|
||||
|
||||
describe('ConversionService', () => {
|
||||
const service = new ConversionService();
|
||||
|
||||
it('10 Bits = 1 TC', () => expect(service.bitsToTC(10)).toBe(1));
|
||||
it('15 Bits = 1 TC (floor)', () => expect(service.bitsToTC(15)).toBe(1));
|
||||
it('0 Bits = 0 TC', () => expect(service.bitsToTC(0)).toBe(0));
|
||||
it('9 Bits = 0 TC', () => expect(service.bitsToTC(9)).toBe(0));
|
||||
it('100 Bits = 10 TC', () => expect(service.bitsToTC(100)).toBe(10));
|
||||
it('1000 Bits = 100 TC', () => expect(service.bitsToTC(1000)).toBe(100));
|
||||
it('throws on negative bits', () => expect(() => service.bitsToTC(-1)).toThrow());
|
||||
});
|
||||
11
src/economy/conversion.service.ts
Normal file
11
src/economy/conversion.service.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
const BITS_PER_TC = 10;
|
||||
|
||||
@Injectable()
|
||||
export class ConversionService {
|
||||
bitsToTC(bits: number): number {
|
||||
if (bits < 0) throw new Error('bits cannot be negative');
|
||||
return Math.floor(bits / BITS_PER_TC);
|
||||
}
|
||||
}
|
||||
13
src/economy/economy.module.ts
Normal file
13
src/economy/economy.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TetardCoin } from './entities/tetard-coin.entity';
|
||||
import { Transaction } from './entities/transaction.entity';
|
||||
import { TetardCoinService } from './tetard-coin.service';
|
||||
import { ConversionService } from './conversion.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([TetardCoin, Transaction])],
|
||||
providers: [TetardCoinService, ConversionService],
|
||||
exports: [TetardCoinService, ConversionService],
|
||||
})
|
||||
export class EconomyModule {}
|
||||
17
src/economy/entities/tetard-coin.entity.ts
Normal file
17
src/economy/entities/tetard-coin.entity.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, UpdateDateColumn, Index } from 'typeorm';
|
||||
|
||||
@Entity('tetard_coins')
|
||||
export class TetardCoin {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'user_id', type: 'varchar', length: 255, unique: true })
|
||||
userId: string;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
balance: number;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
20
src/economy/entities/transaction.entity.ts
Normal file
20
src/economy/entities/transaction.entity.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm';
|
||||
|
||||
@Entity('tc_transactions')
|
||||
export class Transaction {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'user_id', type: 'varchar', length: 255 })
|
||||
userId: string;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
amount: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
reason: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
}
|
||||
99
src/economy/tetard-coin.service.spec.ts
Normal file
99
src/economy/tetard-coin.service.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { TetardCoinService } from './tetard-coin.service';
|
||||
import { TetardCoin } from './entities/tetard-coin.entity';
|
||||
import { Transaction } from './entities/transaction.entity';
|
||||
|
||||
const makeTcRepo = () => ({
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn((dto) => ({ ...dto })),
|
||||
save: jest.fn(async (entity) => entity),
|
||||
});
|
||||
|
||||
const makeTxRepo = () => ({
|
||||
create: jest.fn((dto) => ({ ...dto })),
|
||||
save: jest.fn(async (entity) => entity),
|
||||
});
|
||||
|
||||
describe('TetardCoinService', () => {
|
||||
let service: TetardCoinService;
|
||||
let tcRepo: ReturnType<typeof makeTcRepo>;
|
||||
let txRepo: ReturnType<typeof makeTxRepo>;
|
||||
|
||||
beforeEach(async () => {
|
||||
tcRepo = makeTcRepo();
|
||||
txRepo = makeTxRepo();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
TetardCoinService,
|
||||
{ provide: getRepositoryToken(TetardCoin), useValue: tcRepo },
|
||||
{ provide: getRepositoryToken(Transaction), useValue: txRepo },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<TetardCoinService>(TetardCoinService);
|
||||
});
|
||||
|
||||
describe('earn()', () => {
|
||||
it('augmente la balance (nouvel utilisateur)', async () => {
|
||||
tcRepo.findOne.mockResolvedValue(null);
|
||||
expect(await service.earn('u1', 10)).toBe(10);
|
||||
});
|
||||
|
||||
it('augmente la balance (utilisateur existant)', async () => {
|
||||
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 50 });
|
||||
expect(await service.earn('u1', 30)).toBe(80);
|
||||
});
|
||||
|
||||
it('crée une entrée transaction audit', async () => {
|
||||
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 0 });
|
||||
await service.earn('u1', 20, 'subscription-t1');
|
||||
expect(txRepo.save).toHaveBeenCalledTimes(1);
|
||||
expect(txRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 'u1', amount: 20, reason: 'subscription-t1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throw si amount <= 0', async () => {
|
||||
await expect(service.earn('u1', 0)).rejects.toThrow(BadRequestException);
|
||||
await expect(service.earn('u1', -5)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spend()', () => {
|
||||
it('diminue la balance du montant correct', async () => {
|
||||
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 100 });
|
||||
expect(await service.spend('u1', 30, 'endurance')).toBe(70);
|
||||
});
|
||||
|
||||
it('throw si balance insuffisante (invariant ≥ 0)', async () => {
|
||||
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 10 });
|
||||
await expect(service.spend('u1', 15, 'guild')).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('throw si balance = 0', async () => {
|
||||
tcRepo.findOne.mockResolvedValue(null);
|
||||
await expect(service.spend('u1', 1, 'cosmetic')).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('crée une transaction négative (audit)', async () => {
|
||||
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 50 });
|
||||
await service.spend('u1', 20, 'forge');
|
||||
expect(txRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ amount: -20, reason: 'forge' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBalance()', () => {
|
||||
it('retourne 0 si aucun record', async () => {
|
||||
tcRepo.findOne.mockResolvedValue(null);
|
||||
expect(await service.getBalance('u1')).toBe(0);
|
||||
});
|
||||
|
||||
it('retourne la balance correcte', async () => {
|
||||
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 42 });
|
||||
expect(await service.getBalance('u1')).toBe(42);
|
||||
});
|
||||
});
|
||||
});
|
||||
52
src/economy/tetard-coin.service.ts
Normal file
52
src/economy/tetard-coin.service.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TetardCoin } from './entities/tetard-coin.entity';
|
||||
import { Transaction } from './entities/transaction.entity';
|
||||
|
||||
@Injectable()
|
||||
export class TetardCoinService {
|
||||
constructor(
|
||||
@InjectRepository(TetardCoin)
|
||||
private readonly tcRepo: Repository<TetardCoin>,
|
||||
@InjectRepository(Transaction)
|
||||
private readonly txRepo: Repository<Transaction>,
|
||||
) {}
|
||||
|
||||
async getBalance(userId: string): Promise<number> {
|
||||
const record = await this.tcRepo.findOne({ where: { userId } });
|
||||
return record?.balance ?? 0;
|
||||
}
|
||||
|
||||
async earn(userId: string, amount: number, reason = 'earn'): Promise<number> {
|
||||
if (amount <= 0) throw new BadRequestException('earn amount must be > 0');
|
||||
|
||||
let record = await this.tcRepo.findOne({ where: { userId } });
|
||||
if (!record) {
|
||||
record = this.tcRepo.create({ userId, balance: 0 });
|
||||
}
|
||||
record.balance += amount;
|
||||
await this.tcRepo.save(record);
|
||||
await this.txRepo.save(this.txRepo.create({ userId, amount, reason }));
|
||||
return record.balance;
|
||||
}
|
||||
|
||||
async spend(userId: string, amount: number, reason: string): Promise<number> {
|
||||
if (amount <= 0) throw new BadRequestException('spend amount must be > 0');
|
||||
|
||||
const record = await this.tcRepo.findOne({ where: { userId } });
|
||||
const current = record?.balance ?? 0;
|
||||
|
||||
// Invariant absolu : balance ne peut jamais être négative
|
||||
if (current < amount) {
|
||||
throw new BadRequestException(
|
||||
`Insufficient balance: has ${current} TC, needs ${amount} TC`,
|
||||
);
|
||||
}
|
||||
|
||||
record!.balance -= amount;
|
||||
await this.tcRepo.save(record!);
|
||||
await this.txRepo.save(this.txRepo.create({ userId, amount: -amount, reason }));
|
||||
return record!.balance;
|
||||
}
|
||||
}
|
||||
10
src/twitch/entities/processed-event.entity.ts
Normal file
10
src/twitch/entities/processed-event.entity.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Entity, PrimaryColumn, CreateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('processed_events')
|
||||
export class ProcessedEvent {
|
||||
@PrimaryColumn({ type: 'varchar', length: 255 })
|
||||
id: string;
|
||||
|
||||
@CreateDateColumn({ name: 'processed_at' })
|
||||
processedAt: Date;
|
||||
}
|
||||
148
src/twitch/twitch-event.service.spec.ts
Normal file
148
src/twitch/twitch-event.service.spec.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { TwitchEventService } from './twitch-event.service';
|
||||
import { ProcessedEvent } from './entities/processed-event.entity';
|
||||
import { TetardCoinService } from '../economy/tetard-coin.service';
|
||||
import { ConversionService } from '../economy/conversion.service';
|
||||
|
||||
const makeProcessedRepo = () => ({
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn((dto) => ({ ...dto })),
|
||||
save: jest.fn(async (entity) => entity),
|
||||
});
|
||||
|
||||
const makeTcService = () => ({
|
||||
earn: jest.fn(async () => 0),
|
||||
});
|
||||
|
||||
describe('TwitchEventService', () => {
|
||||
let service: TwitchEventService;
|
||||
let processedRepo: ReturnType<typeof makeProcessedRepo>;
|
||||
let tcService: ReturnType<typeof makeTcService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
processedRepo = makeProcessedRepo();
|
||||
tcService = makeTcService();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
TwitchEventService,
|
||||
ConversionService,
|
||||
{ provide: getRepositoryToken(ProcessedEvent), useValue: processedRepo },
|
||||
{ provide: TetardCoinService, useValue: tcService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<TwitchEventService>(TwitchEventService);
|
||||
});
|
||||
|
||||
// ─── Cheer tests ────────────────────────────────────────────────────────
|
||||
|
||||
describe('handleCheer()', () => {
|
||||
it('cheer 100 Bits → earn 10 TC (taux 10:1, pas de prime)', async () => {
|
||||
processedRepo.findOne.mockResolvedValue(null);
|
||||
await service.handleCheer('msg-1', 'user1', 100);
|
||||
expect(tcService.earn).toHaveBeenCalledWith('user1', 10, 'cheer:100bits');
|
||||
});
|
||||
|
||||
it('cheer 500 Bits → earn 55 TC (50 base + 5 prime seuil)', async () => {
|
||||
processedRepo.findOne.mockResolvedValue(null);
|
||||
await service.handleCheer('msg-2', 'user1', 500);
|
||||
expect(tcService.earn).toHaveBeenCalledWith('user1', 55, 'cheer:500bits');
|
||||
});
|
||||
|
||||
it('cheer 1000 Bits → earn 115 TC (100 base + 15 prime seuil)', async () => {
|
||||
processedRepo.findOne.mockResolvedValue(null);
|
||||
await service.handleCheer('msg-3', 'user1', 1000);
|
||||
expect(tcService.earn).toHaveBeenCalledWith('user1', 115, 'cheer:1000bits');
|
||||
});
|
||||
|
||||
it('cheer 5000 Bits → earn 575 TC (500 base + 75 prime seuil)', async () => {
|
||||
processedRepo.findOne.mockResolvedValue(null);
|
||||
await service.handleCheer('msg-4', 'user1', 5000);
|
||||
expect(tcService.earn).toHaveBeenCalledWith('user1', 575, 'cheer:5000bits');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Idempotence tests ───────────────────────────────────────────────────
|
||||
|
||||
describe('idempotence', () => {
|
||||
it('event déjà traité → pas de double crédit (cheer)', async () => {
|
||||
processedRepo.findOne.mockResolvedValue({ id: 'msg-dup', processedAt: new Date() });
|
||||
await service.handleCheer('msg-dup', 'user1', 100);
|
||||
expect(tcService.earn).not.toHaveBeenCalled();
|
||||
// Must not insert again
|
||||
expect(processedRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('event déjà traité → pas de double crédit (subscription)', async () => {
|
||||
processedRepo.findOne.mockResolvedValue({ id: 'msg-sub-dup', processedAt: new Date() });
|
||||
await service.handleSubscription('msg-sub-dup', 'user1', '1000', false);
|
||||
expect(tcService.earn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('premier event → inséré dans ProcessedEvent', async () => {
|
||||
processedRepo.findOne.mockResolvedValue(null);
|
||||
await service.handleCheer('msg-new', 'user1', 100);
|
||||
expect(processedRepo.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Subscription tests ──────────────────────────────────────────────────
|
||||
|
||||
describe('handleSubscription()', () => {
|
||||
it('T1 subscribe → earn 50 TC', async () => {
|
||||
processedRepo.findOne.mockResolvedValue(null);
|
||||
await service.handleSubscription('msg-s1', 'user1', '1000', false);
|
||||
expect(tcService.earn).toHaveBeenCalledWith('user1', 50, 'subscribe:tier1000');
|
||||
});
|
||||
|
||||
it('T2 subscribe → earn 120 TC', async () => {
|
||||
processedRepo.findOne.mockResolvedValue(null);
|
||||
await service.handleSubscription('msg-s2', 'user1', '2000', false);
|
||||
expect(tcService.earn).toHaveBeenCalledWith('user1', 120, 'subscribe:tier2000');
|
||||
});
|
||||
|
||||
it('T3 subscribe → earn 350 TC', async () => {
|
||||
processedRepo.findOne.mockResolvedValue(null);
|
||||
await service.handleSubscription('msg-s3', 'user1', '3000', false);
|
||||
expect(tcService.earn).toHaveBeenCalledWith('user1', 350, 'subscribe:tier3000');
|
||||
});
|
||||
|
||||
it('Prime subscribe → earn 30 TC', async () => {
|
||||
processedRepo.findOne.mockResolvedValue(null);
|
||||
await service.handleSubscription('msg-sp', 'user1', 'prime', false);
|
||||
expect(tcService.earn).toHaveBeenCalledWith('user1', 30, 'subscribe:tierprime');
|
||||
});
|
||||
|
||||
it('gift sub → gifter earns TC (gifterUserId)', async () => {
|
||||
processedRepo.findOne.mockResolvedValue(null);
|
||||
await service.handleSubscription('msg-gift', 'recipient', '1000', true, 'gifter1');
|
||||
expect(tcService.earn).toHaveBeenCalledWith('gifter1', 50, 'gift-sub:tier1000');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Threshold bonus unit tests ──────────────────────────────────────────
|
||||
|
||||
describe('getThresholdBonus()', () => {
|
||||
it('< 500 Bits → bonus 0', () => {
|
||||
expect(service.getThresholdBonus(100)).toBe(0);
|
||||
expect(service.getThresholdBonus(499)).toBe(0);
|
||||
});
|
||||
|
||||
it('500 Bits → bonus +5', () => {
|
||||
expect(service.getThresholdBonus(500)).toBe(5);
|
||||
expect(service.getThresholdBonus(999)).toBe(5);
|
||||
});
|
||||
|
||||
it('1000 Bits → bonus +15', () => {
|
||||
expect(service.getThresholdBonus(1000)).toBe(15);
|
||||
expect(service.getThresholdBonus(4999)).toBe(15);
|
||||
});
|
||||
|
||||
it('5000 Bits → bonus +75', () => {
|
||||
expect(service.getThresholdBonus(5000)).toBe(75);
|
||||
expect(service.getThresholdBonus(10000)).toBe(75);
|
||||
});
|
||||
});
|
||||
});
|
||||
96
src/twitch/twitch-event.service.ts
Normal file
96
src/twitch/twitch-event.service.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TetardCoinService } from '../economy/tetard-coin.service';
|
||||
import { ConversionService } from '../economy/conversion.service';
|
||||
import { ProcessedEvent } from './entities/processed-event.entity';
|
||||
|
||||
// Subscription tier rewards — economy-design.md
|
||||
const SUB_TC: Record<string, number> = {
|
||||
'1000': 50, // T1
|
||||
'2000': 120, // T2
|
||||
'3000': 350, // T3
|
||||
prime: 30,
|
||||
};
|
||||
|
||||
// Bits threshold bonuses — economy-design.md
|
||||
const BITS_BONUS: Array<{ threshold: number; bonus: number }> = [
|
||||
{ threshold: 5000, bonus: 75 },
|
||||
{ threshold: 1000, bonus: 15 },
|
||||
{ threshold: 500, bonus: 5 },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class TwitchEventService {
|
||||
private readonly logger = new Logger(TwitchEventService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ProcessedEvent)
|
||||
private readonly processedRepo: Repository<ProcessedEvent>,
|
||||
private readonly tcService: TetardCoinService,
|
||||
private readonly conversionService: ConversionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns true if already processed (idempotence guard).
|
||||
* If not seen before, inserts and returns false.
|
||||
*/
|
||||
async isAlreadyProcessed(messageId: string): Promise<boolean> {
|
||||
const existing = await this.processedRepo.findOne({ where: { id: messageId } });
|
||||
if (existing) return true;
|
||||
|
||||
await this.processedRepo.save(this.processedRepo.create({ id: messageId }));
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* channel.cheer — convert bits to TC with threshold bonus, then earn.
|
||||
*/
|
||||
async handleCheer(messageId: string, userId: string, bits: number): Promise<void> {
|
||||
if (await this.isAlreadyProcessed(messageId)) {
|
||||
this.logger.log(`Idempotent skip — messageId=${messageId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseTC = this.conversionService.bitsToTC(bits);
|
||||
const bonus = this.getThresholdBonus(bits);
|
||||
const total = baseTC + bonus;
|
||||
|
||||
await this.tcService.earn(userId, total, `cheer:${bits}bits`);
|
||||
this.logger.log(`Cheer processed: userId=${userId} bits=${bits} tc=${total} (base=${baseTC} bonus=${bonus})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* channel.subscribe / channel.subscription.gift
|
||||
* gifted: if true, gifter earns TC (senderUserId), recipient earns nothing.
|
||||
*/
|
||||
async handleSubscription(
|
||||
messageId: string,
|
||||
userId: string,
|
||||
tier: string,
|
||||
gifted = false,
|
||||
gifterUserId?: string,
|
||||
): Promise<void> {
|
||||
if (await this.isAlreadyProcessed(messageId)) {
|
||||
this.logger.log(`Idempotent skip — messageId=${messageId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tc = SUB_TC[tier] ?? SUB_TC['1000'];
|
||||
const earnUserId = gifted && gifterUserId ? gifterUserId : userId;
|
||||
const reason = gifted ? `gift-sub:tier${tier}` : `subscribe:tier${tier}`;
|
||||
|
||||
await this.tcService.earn(earnUserId, tc, reason);
|
||||
this.logger.log(`Subscription processed: userId=${earnUserId} tier=${tier} tc=${tc} gifted=${gifted}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute threshold bonus based on economy-design.md table.
|
||||
*/
|
||||
getThresholdBonus(bits: number): number {
|
||||
for (const { threshold, bonus } of BITS_BONUS) {
|
||||
if (bits >= threshold) return bonus;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
97
src/twitch/twitch-webhook.controller.spec.ts
Normal file
97
src/twitch/twitch-webhook.controller.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createHmac } from 'crypto';
|
||||
import { TwitchWebhookController } from './twitch-webhook.controller';
|
||||
import { TwitchEventService } from './twitch-event.service';
|
||||
|
||||
const SECRET = 'test-webhook-secret';
|
||||
|
||||
const makeEventService = () => ({
|
||||
handleCheer: jest.fn(),
|
||||
handleSubscription: jest.fn(),
|
||||
});
|
||||
|
||||
const makeConfigService = () => ({
|
||||
get: jest.fn((key: string) => (key === 'TWITCH_WEBHOOK_SECRET' ? SECRET : undefined)),
|
||||
});
|
||||
|
||||
function buildRequest(messageId: string, timestamp: string, bodyStr: string) {
|
||||
const rawBody = Buffer.from(bodyStr, 'utf8');
|
||||
const hmac = `sha256=${createHmac('sha256', SECRET)
|
||||
.update(`${messageId}${timestamp}${bodyStr}`)
|
||||
.digest('hex')}`;
|
||||
|
||||
return { rawBody, signature: hmac };
|
||||
}
|
||||
|
||||
describe('TwitchWebhookController — signature', () => {
|
||||
let controller: TwitchWebhookController;
|
||||
let eventService: ReturnType<typeof makeEventService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
eventService = makeEventService();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [TwitchWebhookController],
|
||||
providers: [
|
||||
{ provide: TwitchEventService, useValue: eventService },
|
||||
{ provide: ConfigService, useValue: makeConfigService() },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<TwitchWebhookController>(TwitchWebhookController);
|
||||
});
|
||||
|
||||
it('signature valide → traite la requête sans erreur', async () => {
|
||||
const msgId = 'valid-msg-id';
|
||||
const ts = new Date().toISOString();
|
||||
const bodyObj = {
|
||||
subscription: { type: 'channel.cheer' },
|
||||
event: { user_id: 'user1', bits_used: 100 },
|
||||
};
|
||||
const bodyStr = JSON.stringify(bodyObj);
|
||||
const { rawBody, signature } = buildRequest(msgId, ts, bodyStr);
|
||||
|
||||
const req = { rawBody } as any;
|
||||
|
||||
await expect(
|
||||
controller.handleWebhook(req, msgId, ts, signature, 'notification', bodyObj as any),
|
||||
).resolves.toEqual({ status: 'ok' });
|
||||
});
|
||||
|
||||
it('signature invalide → ForbiddenException (400/403)', async () => {
|
||||
const msgId = 'invalid-msg-id';
|
||||
const ts = new Date().toISOString();
|
||||
const bodyObj = {
|
||||
subscription: { type: 'channel.cheer' },
|
||||
event: { user_id: 'user1', bits_used: 100 },
|
||||
};
|
||||
const bodyStr = JSON.stringify(bodyObj);
|
||||
const rawBody = Buffer.from(bodyStr, 'utf8');
|
||||
const badSignature = 'sha256=invalidsignaturevalue00000000000000000000000000000000000000000000';
|
||||
|
||||
const req = { rawBody } as any;
|
||||
|
||||
await expect(
|
||||
controller.handleWebhook(req, msgId, ts, badSignature, 'notification', bodyObj as any),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
|
||||
expect(eventService.handleCheer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('signature manquante → ForbiddenException', async () => {
|
||||
const msgId = 'no-sig-msg';
|
||||
const ts = new Date().toISOString();
|
||||
const bodyObj = { subscription: { type: 'channel.cheer' }, event: {} };
|
||||
const bodyStr = JSON.stringify(bodyObj);
|
||||
const rawBody = Buffer.from(bodyStr, 'utf8');
|
||||
|
||||
const req = { rawBody } as any;
|
||||
|
||||
// Pass empty string as signature — length mismatch → ForbiddenException
|
||||
await expect(
|
||||
controller.handleWebhook(req, msgId, ts, '', 'notification', bodyObj as any),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
123
src/twitch/twitch-webhook.controller.ts
Normal file
123
src/twitch/twitch-webhook.controller.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Headers,
|
||||
Body,
|
||||
RawBodyRequest,
|
||||
Req,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { Request } from 'express';
|
||||
import { TwitchEventService } from './twitch-event.service';
|
||||
|
||||
@Controller('twitch')
|
||||
export class TwitchWebhookController {
|
||||
private readonly logger = new Logger(TwitchWebhookController.name);
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly twitchEventService: TwitchEventService,
|
||||
) {}
|
||||
|
||||
@Post('webhook')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async handleWebhook(
|
||||
@Req() req: RawBodyRequest<Request>,
|
||||
@Headers('twitch-eventsub-message-id') messageId: string,
|
||||
@Headers('twitch-eventsub-message-timestamp') timestamp: string,
|
||||
@Headers('twitch-eventsub-message-signature') signature: string,
|
||||
@Headers('twitch-eventsub-message-type') messageType: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
): Promise<{ status: string }> {
|
||||
// Validate HMAC-SHA256 signature — security: validate-then-verify pattern
|
||||
this.verifySignature(req, messageId, timestamp, signature);
|
||||
|
||||
// Twitch webhook verification challenge
|
||||
if (messageType === 'webhook_callback_verification') {
|
||||
return { status: body['challenge'] as string };
|
||||
}
|
||||
|
||||
if (messageType === 'revocation') {
|
||||
this.logger.warn(`Subscription revoked for messageId=${messageId}`);
|
||||
return { status: 'ok' };
|
||||
}
|
||||
|
||||
if (messageType !== 'notification') {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
|
||||
const subscription = body['subscription'] as Record<string, unknown>;
|
||||
const event = body['event'] as Record<string, unknown>;
|
||||
const eventType = (subscription?.['type'] as string) ?? '';
|
||||
|
||||
switch (eventType) {
|
||||
case 'channel.cheer':
|
||||
await this.handleCheer(messageId, event);
|
||||
break;
|
||||
|
||||
case 'channel.subscribe':
|
||||
await this.handleSubscribe(messageId, event);
|
||||
break;
|
||||
|
||||
case 'channel.subscription.gift':
|
||||
await this.handleGiftSub(messageId, event);
|
||||
break;
|
||||
|
||||
default:
|
||||
this.logger.debug(`Unhandled event type: ${eventType}`);
|
||||
}
|
||||
|
||||
return { status: 'ok' };
|
||||
}
|
||||
|
||||
// ─── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private verifySignature(
|
||||
req: RawBodyRequest<Request>,
|
||||
messageId: string,
|
||||
timestamp: string,
|
||||
signature: string,
|
||||
): void {
|
||||
const secret = this.configService.get<string>('TWITCH_WEBHOOK_SECRET');
|
||||
if (!secret) throw new ForbiddenException('Webhook secret not configured');
|
||||
|
||||
const rawBody = req.rawBody;
|
||||
if (!rawBody) throw new ForbiddenException('Raw body unavailable');
|
||||
|
||||
const hmacMessage = `${messageId}${timestamp}${rawBody.toString('utf8')}`;
|
||||
const expected = `sha256=${createHmac('sha256', secret).update(hmacMessage).digest('hex')}`;
|
||||
|
||||
// timingSafeEqual prevents timing attacks
|
||||
const sigBuf = Buffer.from(signature ?? '');
|
||||
const expBuf = Buffer.from(expected);
|
||||
|
||||
if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
|
||||
this.logger.warn(`Invalid signature for messageId=${messageId}`);
|
||||
throw new ForbiddenException('Invalid webhook signature');
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCheer(messageId: string, event: Record<string, unknown>): Promise<void> {
|
||||
const userId = event['user_id'] as string;
|
||||
const bits = Number(event['bits_used'] ?? 0);
|
||||
await this.twitchEventService.handleCheer(messageId, userId, bits);
|
||||
}
|
||||
|
||||
private async handleSubscribe(messageId: string, event: Record<string, unknown>): Promise<void> {
|
||||
const userId = event['user_id'] as string;
|
||||
const tier = event['tier'] as string;
|
||||
await this.twitchEventService.handleSubscription(messageId, userId, tier, false);
|
||||
}
|
||||
|
||||
private async handleGiftSub(messageId: string, event: Record<string, unknown>): Promise<void> {
|
||||
const gifterUserId = event['user_id'] as string;
|
||||
const tier = event['tier'] as string;
|
||||
// For gifts, gifter earns TC
|
||||
await this.twitchEventService.handleSubscription(messageId, gifterUserId, tier, true, gifterUserId);
|
||||
}
|
||||
}
|
||||
19
src/twitch/twitch.module.ts
Normal file
19
src/twitch/twitch.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { EconomyModule } from '../economy/economy.module';
|
||||
import { ProcessedEvent } from './entities/processed-event.entity';
|
||||
import { TwitchEventService } from './twitch-event.service';
|
||||
import { TwitchWebhookController } from './twitch-webhook.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
TypeOrmModule.forFeature([ProcessedEvent]),
|
||||
EconomyModule,
|
||||
],
|
||||
controllers: [TwitchWebhookController],
|
||||
providers: [TwitchEventService],
|
||||
exports: [TwitchEventService],
|
||||
})
|
||||
export class TwitchModule {}
|
||||
Reference in New Issue
Block a user