Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
Index,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { Competition } from './competition.entity';

@Entity('competition_participants')
@Index(['user_id', 'competition_id'], { unique: true })
export class CompetitionParticipant {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column({ type: 'uuid' })
user_id: string;

@Column({ type: 'uuid' })
competition_id: string;

@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;

@ManyToOne(() => Competition, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'competition_id' })
competition: Competition;

@Column({ default: 0 })
score: number;

@Column({ nullable: true })
rank: number;

@CreateDateColumn()
joined_at: Date;
}
49 changes: 49 additions & 0 deletions backend/src/users/dto/list-user-competitions.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsEnum, IsInt, Min } from 'class-validator';
import { Type } from 'class-transformer';

export enum UserCompetitionFilterStatus {
Active = 'active',
Completed = 'completed',
}

export class ListUserCompetitionsDto {
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;

@ApiPropertyOptional({ default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
limit?: number = 20;

@ApiPropertyOptional({ enum: UserCompetitionFilterStatus })
@IsOptional()
@IsEnum(UserCompetitionFilterStatus)
status?: UserCompetitionFilterStatus;
}

export class UserCompetitionResponseItem {
@ApiProperty()
id: string;

@ApiProperty()
title: string;

@ApiProperty()
rank: number | null;

@ApiProperty()
score: number;

@ApiProperty()
end_time: Date;

@ApiProperty({ example: 'active' })
status: string;
}
13 changes: 13 additions & 0 deletions backend/src/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
PaginatedPublicUserPredictionsResponse,
} from './dto/list-user-predictions.dto';

import { ListUserCompetitionsDto } from './dto/list-user-competitions.dto';

@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
Expand Down Expand Up @@ -89,4 +91,15 @@ export class UsersController {
): Promise<PaginatedPublicUserPredictionsResponse> {
return this.usersService.findPublicPredictionsByAddress(address, query);
}

@Get(':address/competitions')
@Public()
@ApiOperation({ summary: 'Get competitions a user has participated in' })
@ApiResponse({ status: 200, description: 'List of competitions' })
async getUserCompetitions(
@Param('address') address: string,
@Query() query: ListUserCompetitionsDto,
) {
return this.usersService.findUserCompetitions(address, query);
}
}
5 changes: 4 additions & 1 deletion backend/src/users/users.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ 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';

@Module({
imports: [TypeOrmModule.forFeature([User, Prediction])],
imports: [
TypeOrmModule.forFeature([User, Prediction, CompetitionParticipant]),
],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
Expand Down
118 changes: 47 additions & 71 deletions backend/src/users/users.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@
import { User } from './entities/user.entity';
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';

describe('UsersService', () => {
let service: UsersService;
let repository: Repository<User>;
let predictionsRepository: Repository<Prediction>;
let participantsRepository: Repository<CompetitionParticipant>;

const mockUser: User = {
id: '123e4567-e89b-12d3-a456-426614174000',
Expand All @@ -25,12 +28,12 @@
season_points: 100,
role: 'user',
is_banned: false,
ban_reason: "",
ban_reason: '',
banned_at: null,
banned_by: "",
banned_by: '',
created_at: new Date('2024-01-01'),
updated_at: new Date('2024-01-01'),
};
} as User;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
Expand All @@ -40,6 +43,8 @@
provide: getRepositoryToken(User),
useValue: {
findOneBy: jest.fn(),
save: jest.fn(),
find: jest.fn(),
},
},
{
Expand All @@ -48,6 +53,12 @@
createQueryBuilder: jest.fn(),
},
},
{
provide: getRepositoryToken(CompetitionParticipant),
useValue: {
createQueryBuilder: jest.fn(),
},
},
],
}).compile();

Expand All @@ -56,6 +67,9 @@
predictionsRepository = module.get<Repository<Prediction>>(
getRepositoryToken(Prediction),
);
participantsRepository = module.get<Repository<CompetitionParticipant>>(
getRepositoryToken(CompetitionParticipant),
);
});

it('should be defined', () => {
Expand Down Expand Up @@ -83,22 +97,12 @@
service.findByAddress('NONEXISTENT_ADDRESS'),
).rejects.toThrow(NotFoundException);
});

it('should throw NotFoundException with descriptive message', async () => {
jest.spyOn(repository, 'findOneBy').mockResolvedValue(null);
const address = 'GBRPYHIL2CI3WHZDTOOQFC6EB4RRJC3XNRBF7XNZFXNRBF7XNRBF7XN';

await expect(service.findByAddress(address)).rejects.toThrow(
new NotFoundException(`User with address ${address} not found`),
);
});
});

describe('findPublicPredictionsByAddress', () => {
it('should return only resolved-market predictions with outcome mapping', async () => {
describe('findUserCompetitions', () => {
it('should return paginated user competitions', async () => {
jest.spyOn(repository, 'findOneBy').mockResolvedValue(mockUser);

const now = new Date('2025-02-01T00:00:00.000Z');
const queryBuilder = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
Expand All @@ -109,67 +113,44 @@
getManyAndCount: jest.fn().mockResolvedValue([
[
{
id: 'pred-1',
chosen_outcome: 'YES',
stake_amount_stroops: '100',
payout_claimed: false,
payout_amount_stroops: '0',
tx_hash: null,
submitted_at: now,
market: {
id: 'mkt-1',
title: 'Resolved YES market',
end_time: now,
resolved_outcome: 'YES',
is_resolved: true,
is_cancelled: false,
},
},
{
id: 'pred-2',
chosen_outcome: 'NO',
stake_amount_stroops: '200',
payout_claimed: false,
payout_amount_stroops: '0',
tx_hash: null,
submitted_at: now,
market: {
id: 'mkt-2',
title: 'Resolved YES market',
end_time: now,
resolved_outcome: 'YES',
is_resolved: true,
is_cancelled: false,
rank: 1,
score: 100,
competition: {
id: 'comp-1',
title: 'Test Competition',
end_time: new Date(Date.now() + 10000),
},
},
],
2,
1,
]),
};

jest
.spyOn(predictionsRepository, 'createQueryBuilder')
.mockReturnValue(
queryBuilder as unknown as ReturnType<
Repository<Prediction>['createQueryBuilder']
>,
);
.spyOn(participantsRepository, 'createQueryBuilder')
.mockReturnValue(queryBuilder as any);

Check warning on line 131 in backend/src/users/users.service.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe argument of type `any` assigned to a parameter of type `SelectQueryBuilder<CompetitionParticipant>`

const result = await service.findPublicPredictionsByAddress(
const result = await service.findUserCompetitions(
mockUser.stellar_address,
new ListUserPredictionsDto(),
{
page: 1,
limit: 10,
status: UserCompetitionFilterStatus.Active,
},
);

expect(queryBuilder.andWhere).toHaveBeenCalledWith(
'market.is_resolved = true',
expect(result.data).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.data[0].title).toBe('Test Competition');
expect(queryBuilder.where).toHaveBeenCalledWith(
'participant.user_id = :userId',
{ userId: mockUser.id },
);
expect(result.total).toBe(2);
expect(result.data).toHaveLength(2);
expect(result.data[0].outcome).toBe('correct');
expect(result.data[1].outcome).toBe('incorrect');
});
});

it('should filter public predictions by outcome', async () => {
describe('findPublicPredictionsByAddress', () => {
it('should return only resolved-market predictions with outcome mapping', async () => {
jest.spyOn(repository, 'findOneBy').mockResolvedValue(mockUser);

const now = new Date('2025-02-01T00:00:00.000Z');
Expand Down Expand Up @@ -208,7 +189,7 @@
tx_hash: null,
submitted_at: now,
market: {
id: 'mkt-2',
id: 'mkt-1', // same market, different outcome to test 'incorrect'
title: 'Resolved YES market',
end_time: now,
resolved_outcome: 'YES',
Expand All @@ -223,20 +204,15 @@

jest
.spyOn(predictionsRepository, 'createQueryBuilder')
.mockReturnValue(
queryBuilder as unknown as ReturnType<
Repository<Prediction>['createQueryBuilder']
>,
);
.mockReturnValue(queryBuilder as any);

Check warning on line 207 in backend/src/users/users.service.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe argument of type `any` assigned to a parameter of type `SelectQueryBuilder<Prediction>`

const result = await service.findPublicPredictionsByAddress(
mockUser.stellar_address,
{ outcome: 'correct' } as ListUserPredictionsDto,
new ListUserPredictionsDto(),
);

expect(result.total).toBe(2);
expect(result.data).toHaveLength(1);
expect(result.data[0].outcome).toBe('correct');
expect(result.data[1].outcome).toBe('incorrect');
});
});
});
Loading
Loading