From f484dd2d365fbb568ea94125e8649d3234fc9da2 Mon Sep 17 00:00:00 2001 From: Pheelips Date: Sun, 29 Mar 2026 19:56:23 +0100 Subject: [PATCH] feat(users): add GET /users/:address/markets with filters and pagination (#411) Public endpoint lists markets created by a user. Supports status (active, resolved, cancelled), sort_by (created_at, participant_count), and order (asc, desc). Adds unit tests and e2e coverage. Made-with: Cursor --- .../src/users/dto/list-user-markets.dto.ts | 70 ++++++++++ backend/src/users/users.controller.ts | 19 +++ backend/src/users/users.module.ts | 10 +- backend/src/users/users.service.spec.ts | 128 ++++++++++++++++++ backend/src/users/users.service.ts | 56 ++++++++ backend/test/users.e2e-spec.ts | 93 +++++++++++++ 6 files changed, 374 insertions(+), 2 deletions(-) create mode 100644 backend/src/users/dto/list-user-markets.dto.ts diff --git a/backend/src/users/dto/list-user-markets.dto.ts b/backend/src/users/dto/list-user-markets.dto.ts new file mode 100644 index 00000000..a57ac6e5 --- /dev/null +++ b/backend/src/users/dto/list-user-markets.dto.ts @@ -0,0 +1,70 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsOptional, + IsEnum, + IsInt, + Min, + Max, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export enum UserMarketFilterStatus { + Active = 'active', + Resolved = 'resolved', + Cancelled = 'cancelled', +} + +export enum UserMarketsSortBy { + CreatedAt = 'created_at', + ParticipantCount = 'participant_count', +} + +export enum UserMarketsSortOrder { + Asc = 'asc', + Desc = 'desc', +} + +export class ListUserMarketsDto { + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20, maximum: 50 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(50) + limit?: number = 20; + + @ApiPropertyOptional({ enum: UserMarketFilterStatus }) + @IsOptional() + @IsEnum(UserMarketFilterStatus) + status?: UserMarketFilterStatus; + + @ApiPropertyOptional({ + enum: UserMarketsSortBy, + default: UserMarketsSortBy.CreatedAt, + }) + @IsOptional() + @IsEnum(UserMarketsSortBy) + sort_by?: UserMarketsSortBy = UserMarketsSortBy.CreatedAt; + + @ApiPropertyOptional({ + enum: UserMarketsSortOrder, + default: UserMarketsSortOrder.Desc, + }) + @IsOptional() + @IsEnum(UserMarketsSortOrder) + order?: UserMarketsSortOrder = UserMarketsSortOrder.Desc; +} + +export class PaginatedUserMarketsResponse { + data: unknown[]; + total: number; + page: number; + limit: number; +} diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index ae20362f..7aee4cc8 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -23,6 +23,10 @@ import { } from './dto/list-user-predictions.dto'; import { ListUserCompetitionsDto } from './dto/list-user-competitions.dto'; +import { + ListUserMarketsDto, + PaginatedUserMarketsResponse, +} from './dto/list-user-markets.dto'; @Controller('users') export class UsersController { @@ -92,6 +96,21 @@ export class UsersController { return this.usersService.findPublicPredictionsByAddress(address, query); } + @Get(':address/markets') + @Public() + @UsePipes( + new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false }), + ) + @ApiOperation({ summary: "List markets created by a user (public)" }) + @ApiResponse({ status: 200, description: 'Paginated markets list' }) + @ApiResponse({ status: 404, description: 'User not found' }) + async getUserMarkets( + @Param('address') address: string, + @Query() query: ListUserMarketsDto, + ): Promise { + return this.usersService.findMarketsByAddress(address, query); + } + @Get(':address/competitions') @Public() @ApiOperation({ summary: 'Get competitions a user has participated in' }) diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index 084cb316..a8b1b078 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -4,11 +4,17 @@ import { User } from './entities/user.entity'; import { UsersService } from './users.service'; import { UsersController } from './users.controller'; import { Prediction } from '../predictions/entities/prediction.entity'; -import { CompetitionParticipant } from 'src/competitions/entities/competition-participant.entity'; +import { CompetitionParticipant } from '../competitions/entities/competition-participant.entity'; +import { Market } from '../markets/entities/market.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([User, Prediction, CompetitionParticipant]), + TypeOrmModule.forFeature([ + User, + Prediction, + CompetitionParticipant, + Market, + ]), ], controllers: [UsersController], providers: [UsersService], diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts index cc129b38..919d0239 100644 --- a/backend/src/users/users.service.spec.ts +++ b/backend/src/users/users.service.spec.ts @@ -8,12 +8,20 @@ import { Prediction } from '../predictions/entities/prediction.entity'; import { ListUserPredictionsDto } from './dto/list-user-predictions.dto'; import { CompetitionParticipant } from '../competitions/entities/competition-participant.entity'; import { UserCompetitionFilterStatus } from './dto/list-user-competitions.dto'; +import { Market } from '../markets/entities/market.entity'; +import { + ListUserMarketsDto, + UserMarketFilterStatus, + UserMarketsSortBy, + UserMarketsSortOrder, +} from './dto/list-user-markets.dto'; describe('UsersService', () => { let service: UsersService; let repository: Repository; let predictionsRepository: Repository; let participantsRepository: Repository; + let marketsRepository: Repository; const mockUser: User = { id: '123e4567-e89b-12d3-a456-426614174000', @@ -59,6 +67,12 @@ describe('UsersService', () => { createQueryBuilder: jest.fn(), }, }, + { + provide: getRepositoryToken(Market), + useValue: { + createQueryBuilder: jest.fn(), + }, + }, ], }).compile(); @@ -70,6 +84,9 @@ describe('UsersService', () => { participantsRepository = module.get>( getRepositoryToken(CompetitionParticipant), ); + marketsRepository = module.get>( + getRepositoryToken(Market), + ); }); it('should be defined', () => { @@ -215,4 +232,115 @@ describe('UsersService', () => { expect(result.data[1].outcome).toBe('incorrect'); }); }); + + describe('findMarketsByAddress', () => { + const queryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + queryBuilder.leftJoinAndSelect.mockReturnThis(); + queryBuilder.where.mockReturnThis(); + queryBuilder.andWhere.mockReturnThis(); + queryBuilder.orderBy.mockReturnThis(); + queryBuilder.skip.mockReturnThis(); + queryBuilder.take.mockReturnThis(); + }); + + it('should scope markets to creator and return pagination', async () => { + jest.spyOn(repository, 'findOneBy').mockResolvedValue(mockUser); + queryBuilder.getManyAndCount.mockResolvedValue([[], 0]); + jest + .spyOn(marketsRepository, 'createQueryBuilder') + .mockReturnValue(queryBuilder as any); + + const result = await service.findMarketsByAddress( + mockUser.stellar_address, + new ListUserMarketsDto(), + ); + + expect(queryBuilder.where).toHaveBeenCalledWith( + 'market.creatorId = :userId', + { userId: mockUser.id }, + ); + expect(queryBuilder.orderBy).toHaveBeenCalledWith( + 'market.created_at', + 'DESC', + ); + expect(result).toEqual({ data: [], total: 0, page: 1, limit: 20 }); + }); + + it('should filter active markets', async () => { + jest.spyOn(repository, 'findOneBy').mockResolvedValue(mockUser); + queryBuilder.getManyAndCount.mockResolvedValue([[], 0]); + jest + .spyOn(marketsRepository, 'createQueryBuilder') + .mockReturnValue(queryBuilder as any); + + await service.findMarketsByAddress(mockUser.stellar_address, { + status: UserMarketFilterStatus.Active, + } as ListUserMarketsDto); + + expect(queryBuilder.andWhere).toHaveBeenCalledWith( + 'market.is_resolved = false AND market.is_cancelled = false', + ); + }); + + it('should filter resolved markets', async () => { + jest.spyOn(repository, 'findOneBy').mockResolvedValue(mockUser); + queryBuilder.getManyAndCount.mockResolvedValue([[], 0]); + jest + .spyOn(marketsRepository, 'createQueryBuilder') + .mockReturnValue(queryBuilder as any); + + await service.findMarketsByAddress(mockUser.stellar_address, { + status: UserMarketFilterStatus.Resolved, + } as ListUserMarketsDto); + + expect(queryBuilder.andWhere).toHaveBeenCalledWith( + 'market.is_resolved = true', + ); + }); + + it('should filter cancelled markets', async () => { + jest.spyOn(repository, 'findOneBy').mockResolvedValue(mockUser); + queryBuilder.getManyAndCount.mockResolvedValue([[], 0]); + jest + .spyOn(marketsRepository, 'createQueryBuilder') + .mockReturnValue(queryBuilder as any); + + await service.findMarketsByAddress(mockUser.stellar_address, { + status: UserMarketFilterStatus.Cancelled, + } as ListUserMarketsDto); + + expect(queryBuilder.andWhere).toHaveBeenCalledWith( + 'market.is_cancelled = true', + ); + }); + + it('should sort by participant_count and order asc', async () => { + jest.spyOn(repository, 'findOneBy').mockResolvedValue(mockUser); + queryBuilder.getManyAndCount.mockResolvedValue([[], 0]); + jest + .spyOn(marketsRepository, 'createQueryBuilder') + .mockReturnValue(queryBuilder as any); + + await service.findMarketsByAddress(mockUser.stellar_address, { + sort_by: UserMarketsSortBy.ParticipantCount, + order: UserMarketsSortOrder.Asc, + } as ListUserMarketsDto); + + expect(queryBuilder.orderBy).toHaveBeenCalledWith( + 'market.participant_count', + 'ASC', + ); + }); + }); }); diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 22f6236d..9766a36f 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -16,6 +16,14 @@ import { ListUserCompetitionsDto, UserCompetitionFilterStatus, } from './dto/list-user-competitions.dto'; +import { Market } from '../markets/entities/market.entity'; +import { + ListUserMarketsDto, + PaginatedUserMarketsResponse, + UserMarketFilterStatus, + UserMarketsSortBy, + UserMarketsSortOrder, +} from './dto/list-user-markets.dto'; @Injectable() export class UsersService { @@ -27,6 +35,8 @@ export class UsersService { @InjectRepository(CompetitionParticipant) private readonly participantsRepository: Repository, + @InjectRepository(Market) + private readonly marketsRepository: Repository, ) {} async findAll(): Promise { @@ -168,4 +178,50 @@ export class UsersService { return { data, total, page, limit }; } + + async findMarketsByAddress( + stellar_address: string, + dto: ListUserMarketsDto, + ): Promise { + const user = await this.findByAddress(stellar_address); + const page = dto.page ?? 1; + const limit = Math.min(dto.limit ?? 20, 50); + const skip = (page - 1) * limit; + + const qb = this.marketsRepository + .createQueryBuilder('market') + .leftJoinAndSelect('market.creator', 'creator') + .where('market.creatorId = :userId', { userId: user.id }); + + if (dto.status) { + switch (dto.status) { + case UserMarketFilterStatus.Active: + qb.andWhere( + 'market.is_resolved = false AND market.is_cancelled = false', + ); + break; + case UserMarketFilterStatus.Resolved: + qb.andWhere('market.is_resolved = true'); + break; + case UserMarketFilterStatus.Cancelled: + qb.andWhere('market.is_cancelled = true'); + break; + } + } + + const sortColumn = + dto.sort_by === UserMarketsSortBy.ParticipantCount + ? 'market.participant_count' + : 'market.created_at'; + const sortDir = + (dto.order ?? UserMarketsSortOrder.Desc) === UserMarketsSortOrder.Asc + ? 'ASC' + : 'DESC'; + + qb.orderBy(sortColumn, sortDir).skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { data, total, page, limit }; + } } diff --git a/backend/test/users.e2e-spec.ts b/backend/test/users.e2e-spec.ts index 68fdae43..e2d8cf32 100644 --- a/backend/test/users.e2e-spec.ts +++ b/backend/test/users.e2e-spec.ts @@ -6,11 +6,13 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { User } from '../src/users/entities/user.entity'; import { Repository } from 'typeorm'; import { Prediction } from '../src/predictions/entities/prediction.entity'; +import { Market } from '../src/markets/entities/market.entity'; describe('Users (e2e)', () => { let app: INestApplication; let usersRepository: Repository; let predictionsRepository: Repository; + let marketsRepository: Repository; const mockUser: User = { id: '123e4567-e89b-12d3-a456-426614174000', @@ -40,6 +42,10 @@ describe('Users (e2e)', () => { .useValue({ createQueryBuilder: jest.fn(), }) + .overrideProvider(getRepositoryToken(Market)) + .useValue({ + createQueryBuilder: jest.fn(), + }) .compile(); app = moduleFixture.createNestApplication(); @@ -49,6 +55,9 @@ describe('Users (e2e)', () => { predictionsRepository = moduleFixture.get>( getRepositoryToken(Prediction), ); + marketsRepository = moduleFixture.get>( + getRepositoryToken(Market), + ); if (app) { await app.init(); } @@ -208,4 +217,88 @@ describe('Users (e2e)', () => { }); }); }); + + describe('GET /users/:address/markets', () => { + it('should return paginated markets for user address', () => { + jest.spyOn(usersRepository, 'findOneBy').mockResolvedValue(mockUser); + + const queryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + }; + + jest + .spyOn(marketsRepository, 'createQueryBuilder') + .mockReturnValue( + queryBuilder as unknown as ReturnType< + Repository['createQueryBuilder'] + >, + ); + + return request(app.getHttpServer()) + .get(`/users/${mockUser.stellar_address}/markets`) + .expect(200) + .expect((res: { body: { data: { data: unknown[]; total: number } } }) => { + expect(queryBuilder.where).toHaveBeenCalledWith( + 'market.creatorId = :userId', + { userId: mockUser.id }, + ); + expect(res.body.data.data).toEqual([]); + expect(res.body.data.total).toBe(0); + }); + }); + + it('should apply status=active filter query', () => { + jest.spyOn(usersRepository, 'findOneBy').mockResolvedValue(mockUser); + + const queryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + }; + + jest + .spyOn(marketsRepository, 'createQueryBuilder') + .mockReturnValue(queryBuilder as any); + + return request(app.getHttpServer()) + .get(`/users/${mockUser.stellar_address}/markets`) + .query({ status: 'active' }) + .expect(200) + .expect(() => { + expect(queryBuilder.andWhere).toHaveBeenCalledWith( + 'market.is_resolved = false AND market.is_cancelled = false', + ); + }); + }); + + it('should be accessible without authentication', () => { + jest.spyOn(usersRepository, 'findOneBy').mockResolvedValue(mockUser); + const queryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + }; + jest + .spyOn(marketsRepository, 'createQueryBuilder') + .mockReturnValue(queryBuilder as any); + + return request(app.getHttpServer()) + .get(`/users/${mockUser.stellar_address}/markets`) + .expect(200); + }); + }); });