Skip to content

Commit 7e79ce6

Browse files
authored
Merge pull request #1320 from jhayniffy/feature/games-tests-and-pagination
Feature/games tests and pagination
2 parents 9322d24 + 1f9797f commit 7e79ce6

7 files changed

Lines changed: 439 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -374,11 +374,11 @@
374374

375375
### Performance Improvements
376376

377-
* optimise gas usage in earnings contract hot paths ([#966](https://github.com/MyFanss/MyFans/issues/966)) ([799bd81](https://github.com/MyFanss/MyFans/commit/799bd811bdd156c51e59a24ae827c0cc6e0a6b18))
378-
* optimise gas usage in test-consumer contract hot paths ([#986](https://github.com/MyFanss/MyFans/issues/986)) ([97d059e](https://github.com/MyFanss/MyFans/commit/97d059ec23188aee5f4442a0887dc245f6fda972))
379-
* Optimize creator profile page performance ([d8ba3ca](https://github.com/MyFanss/MyFans/commit/d8ba3caa3cf605404a033eb7760b7719bc15ded6)), closes [#415](https://github.com/MyFanss/MyFans/issues/415)
377+
* optimise gas usage in earnings contract hot paths ([#966](https://github.com/jhayniffy/MyFans/issues/966)) ([799bd81](https://github.com/jhayniffy/MyFans/commit/799bd811bdd156c51e59a24ae827c0cc6e0a6b18))
378+
* optimise gas usage in test-consumer contract hot paths ([#986](https://github.com/jhayniffy/MyFans/issues/986)) ([97d059e](https://github.com/jhayniffy/MyFans/commit/97d059ec23188aee5f4442a0887dc245f6fda972))
379+
* Optimize creator profile page performance ([d8ba3ca](https://github.com/jhayniffy/MyFans/commit/d8ba3caa3cf605404a033eb7760b7719bc15ded6)), closes [#415](https://github.com/jhayniffy/MyFans/issues/415)
380380

381381

382382
### Reverts
383383

384-
* Revert "feat(backend): subscription reconciler job with dry-run and audit logging" ([7b179fc](https://github.com/MyFanss/MyFans/commit/7b179fcc47dc7454dbc903c7fb019ad0642ab769))
384+
* Revert "feat(backend): subscription reconciler job with dry-run and audit logging" ([7b179fc](https://github.com/jhayniffy/MyFans/commit/7b179fcc47dc7454dbc903c7fb019ad0642ab769))
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { validate } from 'class-validator';
2+
import { plainToInstance } from 'class-transformer';
3+
import { JoinGameDto } from './join-game.dto';
4+
5+
describe('JoinGameDto', () => {
6+
async function validateDto(plain: object) {
7+
const dto = plainToInstance(JoinGameDto, plain);
8+
return validate(dto);
9+
}
10+
11+
describe('userId', () => {
12+
it('accepts a valid UUID', async () => {
13+
const errors = await validateDto({ userId: '550e8400-e29b-41d4-a716-446655440000' });
14+
expect(errors).toHaveLength(0);
15+
});
16+
17+
it('rejects missing userId', async () => {
18+
const errors = await validateDto({});
19+
20+
expect(errors.length).toBeGreaterThan(0);
21+
const userIdError = errors.find(e => e.property === 'userId');
22+
expect(userIdError).toBeDefined();
23+
});
24+
25+
it('rejects empty string', async () => {
26+
const errors = await validateDto({ userId: '' });
27+
28+
expect(errors.length).toBeGreaterThan(0);
29+
const userIdError = errors.find(e => e.property === 'userId');
30+
expect(userIdError).toBeDefined();
31+
expect(userIdError?.constraints).toHaveProperty('isUuid');
32+
});
33+
34+
it('rejects non-UUID string', async () => {
35+
const errors = await validateDto({ userId: 'not-a-uuid' });
36+
37+
expect(errors.length).toBeGreaterThan(0);
38+
const userIdError = errors.find(e => e.property === 'userId');
39+
expect(userIdError).toBeDefined();
40+
expect(userIdError?.constraints).toHaveProperty('isUuid');
41+
});
42+
43+
it('rejects numeric value', async () => {
44+
const errors = await validateDto({ userId: 12345 });
45+
46+
expect(errors.length).toBeGreaterThan(0);
47+
const userIdError = errors.find(e => e.property === 'userId');
48+
expect(userIdError).toBeDefined();
49+
});
50+
51+
it('rejects null value', async () => {
52+
const errors = await validateDto({ userId: null });
53+
54+
expect(errors.length).toBeGreaterThan(0);
55+
const userIdError = errors.find(e => e.property === 'userId');
56+
expect(userIdError).toBeDefined();
57+
});
58+
59+
it('rejects UUID with invalid format', async () => {
60+
const errors = await validateDto({ userId: '550e8400-e29b-41d4-a716' });
61+
62+
expect(errors.length).toBeGreaterThan(0);
63+
const userIdError = errors.find(e => e.property === 'userId');
64+
expect(userIdError).toBeDefined();
65+
expect(userIdError?.constraints).toHaveProperty('isUuid');
66+
});
67+
68+
it('accepts v4 UUID', async () => {
69+
const errors = await validateDto({ userId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479' });
70+
expect(errors).toHaveLength(0);
71+
});
72+
});
73+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { IsOptional, IsEnum } from 'class-validator';
2+
import { ApiPropertyOptional } from '@nestjs/swagger';
3+
import { PaginationDto } from '../../common/dto';
4+
import { GameStatus } from '../entities/game.entity';
5+
6+
export class ListGamesDto extends PaginationDto {
7+
@ApiPropertyOptional({
8+
description: 'Filter by game status',
9+
enum: GameStatus,
10+
})
11+
@IsOptional()
12+
@IsEnum(GameStatus)
13+
status?: GameStatus;
14+
}

backend/src/games/games.controller.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
1-
import { Controller, Post, Param, Body, HttpCode, HttpStatus } from '@nestjs/common';
2-
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
1+
import { Controller, Get, Post, Param, Body, Query, HttpCode, HttpStatus } from '@nestjs/common';
2+
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
33
import { GamesService } from './games.service';
44
import { JoinGameDto } from './dto/join-game.dto';
5+
import { ListGamesDto } from './dto/list-games.dto';
56

67
@ApiTags('games')
78
@Controller({ path: 'games', version: '1' })
89
export class GamesController {
910
constructor(private readonly gamesService: GamesService) {}
1011

12+
@Get()
13+
@ApiOperation({ summary: 'List games with pagination' })
14+
@ApiQuery({ name: 'page', required: false, description: 'Page number (1-based)' })
15+
@ApiQuery({ name: 'limit', required: false, description: 'Items per page (1-100)' })
16+
@ApiQuery({ name: 'status', required: false, description: 'Filter by game status' })
17+
@ApiResponse({ status: 200, description: 'Paginated list of games' })
18+
async findAll(@Query() listGamesDto: ListGamesDto) {
19+
return await this.gamesService.findAll(listGamesDto);
20+
}
21+
1122
@Post(':id/join')
1223
@HttpCode(HttpStatus.CREATED)
1324
@ApiOperation({ summary: 'Join a game' })
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { getRepositoryToken } from '@nestjs/typeorm';
3+
import { DataSource } from 'typeorm';
4+
import { GamesService } from './games.service';
5+
import { Game, GameStatus } from './entities/game.entity';
6+
import { Player } from './entities/player.entity';
7+
8+
describe('GamesService', () => {
9+
let service: GamesService;
10+
let mockManager: Record<string, jest.Mock>;
11+
12+
const mockGame: Partial<Game> = {
13+
id: '550e8400-e29b-41d4-a716-446655440000',
14+
status: GameStatus.PENDING,
15+
number_of_players: 4,
16+
players: [],
17+
game_settings: {
18+
starting_cash: 1500,
19+
randomize_turn_order: false,
20+
},
21+
};
22+
23+
const mockPlayer: Partial<Player> = {
24+
id: '660e8400-e29b-41d4-a716-446655440001',
25+
game_id: mockGame.id!,
26+
user_id: '770e8400-e29b-41d4-a716-446655440002',
27+
balance: 1500,
28+
turn_order: 1,
29+
};
30+
31+
beforeEach(async () => {
32+
mockManager = {
33+
findOne: jest.fn(),
34+
create: jest.fn(),
35+
save: jest.fn(),
36+
};
37+
38+
const module: TestingModule = await Test.createTestingModule({
39+
providers: [
40+
GamesService,
41+
{
42+
provide: getRepositoryToken(Game),
43+
useValue: {},
44+
},
45+
{
46+
provide: getRepositoryToken(Player),
47+
useValue: {},
48+
},
49+
{
50+
provide: DataSource,
51+
useValue: {
52+
transaction: jest.fn((cb: (manager: typeof mockManager) => Promise<unknown>) =>
53+
cb(mockManager),
54+
),
55+
},
56+
},
57+
],
58+
}).compile();
59+
60+
service = module.get<GamesService>(GamesService);
61+
});
62+
63+
it('should be defined', () => {
64+
expect(service).toBeDefined();
65+
});
66+
67+
describe('joinGame', () => {
68+
it('should successfully join a pending game', async () => {
69+
mockManager.findOne
70+
.mockResolvedValueOnce(mockGame)
71+
.mockResolvedValueOnce(null);
72+
mockManager.create.mockReturnValue(mockPlayer);
73+
mockManager.save.mockResolvedValue(mockPlayer);
74+
75+
const result = await service.joinGame(mockGame.id!, mockPlayer.user_id!);
76+
77+
expect(result).toEqual(mockPlayer);
78+
expect(mockManager.findOne).toHaveBeenCalledWith(Game, {
79+
where: { id: mockGame.id },
80+
relations: ['players'],
81+
lock: { mode: 'pessimistic_write' },
82+
});
83+
expect(mockManager.create).toHaveBeenCalledWith(Player, {
84+
game_id: mockGame.id,
85+
user_id: mockPlayer.user_id,
86+
balance: mockGame.game_settings!.starting_cash,
87+
turn_order: 1,
88+
});
89+
expect(mockManager.save).toHaveBeenCalledWith(Player, mockPlayer);
90+
});
91+
92+
it('should assign sequential turn_order when randomize_turn_order is false', async () => {
93+
const gameWith2Players = {
94+
...mockGame,
95+
players: [{ id: 'p1' }, { id: 'p2' }],
96+
};
97+
mockManager.findOne
98+
.mockResolvedValueOnce(gameWith2Players)
99+
.mockResolvedValueOnce(null);
100+
mockManager.create.mockReturnValue(mockPlayer);
101+
mockManager.save.mockResolvedValue(mockPlayer);
102+
103+
await service.joinGame(mockGame.id!, mockPlayer.user_id!);
104+
105+
expect(mockManager.create).toHaveBeenCalledWith(
106+
Player,
107+
expect.objectContaining({ turn_order: 3 }),
108+
);
109+
});
110+
111+
it('should assign random turn_order when randomize_turn_order is true', async () => {
112+
const randomizedGame = {
113+
...mockGame,
114+
game_settings: { starting_cash: 1500, randomize_turn_order: true },
115+
};
116+
mockManager.findOne
117+
.mockResolvedValueOnce(randomizedGame)
118+
.mockResolvedValueOnce(null);
119+
mockManager.create.mockReturnValue(mockPlayer);
120+
mockManager.save.mockResolvedValue(mockPlayer);
121+
122+
await service.joinGame(mockGame.id!, mockPlayer.user_id!);
123+
124+
const createCall = mockManager.create.mock.calls[0][1];
125+
expect(createCall.turn_order).toBeGreaterThanOrEqual(0);
126+
expect(createCall.turn_order).toBeLessThan(1000);
127+
});
128+
129+
it('should use starting_cash from game_settings as player balance', async () => {
130+
const customCashGame = {
131+
...mockGame,
132+
game_settings: { starting_cash: 3000, randomize_turn_order: false },
133+
};
134+
mockManager.findOne
135+
.mockResolvedValueOnce(customCashGame)
136+
.mockResolvedValueOnce(null);
137+
mockManager.create.mockReturnValue(mockPlayer);
138+
mockManager.save.mockResolvedValue(mockPlayer);
139+
140+
await service.joinGame(mockGame.id!, mockPlayer.user_id!);
141+
142+
expect(mockManager.create).toHaveBeenCalledWith(
143+
Player,
144+
expect.objectContaining({ balance: 3000 }),
145+
);
146+
});
147+
148+
it('should throw NotFoundException when game does not exist', async () => {
149+
mockManager.findOne.mockResolvedValueOnce(null);
150+
151+
await expect(service.joinGame('nonexistent-id', mockPlayer.user_id!))
152+
.rejects.toThrow('Game not found');
153+
});
154+
155+
it('should throw BadRequestException when game is not PENDING', async () => {
156+
const inProgressGame = { ...mockGame, status: GameStatus.IN_PROGRESS };
157+
mockManager.findOne.mockResolvedValueOnce(inProgressGame);
158+
159+
await expect(service.joinGame(mockGame.id!, mockPlayer.user_id!))
160+
.rejects.toThrow('Game is not in PENDING status');
161+
});
162+
163+
it('should throw BadRequestException when game is full', async () => {
164+
const fullGame = {
165+
...mockGame,
166+
number_of_players: 2,
167+
players: [{ id: 'p1' }, { id: 'p2' }],
168+
};
169+
mockManager.findOne.mockResolvedValueOnce(fullGame);
170+
171+
await expect(service.joinGame(mockGame.id!, mockPlayer.user_id!))
172+
.rejects.toThrow('Game is full');
173+
});
174+
175+
it('should throw BadRequestException when player already joined', async () => {
176+
mockManager.findOne
177+
.mockResolvedValueOnce(mockGame)
178+
.mockResolvedValueOnce({ id: 'existing-player' });
179+
180+
await expect(service.joinGame(mockGame.id!, mockPlayer.user_id!))
181+
.rejects.toThrow('Player already joined this game');
182+
});
183+
});
184+
});

backend/src/games/games.service.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm';
33
import { Repository, DataSource } from 'typeorm';
44
import { Game, GameStatus } from './entities/game.entity';
55
import { Player } from './entities/player.entity';
6+
import { ListGamesDto } from './dto/list-games.dto';
7+
import { PaginatedResponseDto } from '../common/dto/paginated-response.dto';
68

79
@Injectable()
810
export class GamesService {
@@ -14,6 +16,27 @@ export class GamesService {
1416
private dataSource: DataSource,
1517
) {}
1618

19+
async findAll(listGamesDto: ListGamesDto): Promise<PaginatedResponseDto<Game>> {
20+
const { page = 1, limit = 20, status } = listGamesDto;
21+
22+
const queryBuilder = this.gameRepository
23+
.createQueryBuilder('game')
24+
.orderBy('game.created_at', 'DESC');
25+
26+
if (status) {
27+
queryBuilder.where('game.status = :status', { status });
28+
}
29+
30+
const total = await queryBuilder.getCount();
31+
32+
const data = await queryBuilder
33+
.skip((page - 1) * limit)
34+
.take(limit)
35+
.getMany();
36+
37+
return new PaginatedResponseDto(data, total, page, limit);
38+
}
39+
1740
async joinGame(gameId: string, userId: string): Promise<Player> {
1841
return await this.dataSource.transaction(async (manager) => {
1942
const game = await manager.findOne(Game, {

0 commit comments

Comments
 (0)