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
8 changes: 6 additions & 2 deletions backend/src/achievements/achievements.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,11 +263,15 @@ describe('AchievementsService', () => {

beforeEach(() => {
usersRepository.findOne.mockResolvedValue(qualifyingUser);
achievementsRepository.findOne.mockResolvedValue(firstPredictionAchievement);
achievementsRepository.findOne.mockResolvedValue(
firstPredictionAchievement,
);
// First invocation: no existing record yet → save triggers
userAchievementsRepository.findOne.mockResolvedValueOnce(null);
// Second invocation: record already exists → save is skipped
userAchievementsRepository.findOne.mockResolvedValue(existingUserAchievement);
userAchievementsRepository.findOne.mockResolvedValue(
existingUserAchievement,
);
});

it('should save FIRST_PREDICTION exactly once when checkAndUnlockAchievements is called twice', async () => {
Expand Down
8 changes: 6 additions & 2 deletions backend/src/admin/admin.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,12 +257,16 @@ describe('AdminService (Verified Addresses)', () => {

it('should throw ConflictException if user is already banned', async () => {
userRepository.findOne.mockResolvedValue({ id: 'u1', is_banned: true });
await expect(service.banUser('u1', 'reason', 'admin')).rejects.toThrow('User is already banned');
await expect(service.banUser('u1', 'reason', 'admin')).rejects.toThrow(
'User is already banned',
);
});

it('should throw BadRequestException if user is not banned', async () => {
userRepository.findOne.mockResolvedValue({ id: 'u1', is_banned: false });
await expect(service.unbanUser('u1', 'admin')).rejects.toThrow('User is not banned');
await expect(service.unbanUser('u1', 'admin')).rejects.toThrow(
'User is not banned',
);
});
});
});
9 changes: 5 additions & 4 deletions backend/src/auth/auth.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,11 @@ describe('Auth E2E — challenge → verify flow', () => {

mockUserPreferencesRepository.findOneBy.mockResolvedValue(null);
mockUserPreferencesRepository.create.mockImplementation(
(dto: { userId: string }) => ({
id: 'prefs-uuid',
userId: dto.userId,
} as UserPreferences),
(dto: { userId: string }) =>
({
id: 'prefs-uuid',
userId: dto.userId,
}) as UserPreferences,
);
mockUserPreferencesRepository.save.mockImplementation(
(prefs: UserPreferences) => Promise.resolve(prefs),
Expand Down
12 changes: 6 additions & 6 deletions backend/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,12 @@ describe('AuthService', () => {
usersRepository.create.mockReturnValue(savedUser);
usersRepository.save.mockResolvedValue(savedUser);
preferencesRepository.findOneBy.mockResolvedValue(null);
preferencesRepository.create.mockReturnValue(
{ userId: savedUser.id } as UserPreferences,
);
preferencesRepository.save.mockResolvedValue(
{ userId: savedUser.id } as UserPreferences,
);
preferencesRepository.create.mockReturnValue({
userId: savedUser.id,
} as UserPreferences);
preferencesRepository.save.mockResolvedValue({
userId: savedUser.id,
} as UserPreferences);

const user = await service.verifySignature(address, 'signed-hex');

Expand Down
5 changes: 1 addition & 4 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,7 @@ export class AuthService implements OnModuleInit {
private findValidChallengeForAddress(stellar_address: string): string | null {
const now = Date.now();
for (const [key, entry] of this.challengeCache.entries()) {
if (
key.endsWith(`:${stellar_address}`) &&
now <= entry.expiresAt
) {
if (key.endsWith(`:${stellar_address}`) && now <= entry.expiresAt) {
return key;
}
}
Expand Down
18 changes: 18 additions & 0 deletions backend/src/cache/warming.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,29 @@ describe('CacheWarmingService', () => {
{ data: [], total: 0 },
300000,
);

expect(cacheManager.set).toHaveBeenCalledWith(
CACHE_WARMING_KEYS.trendingEvents,
{
data: [{ id: 'popular-1' }, { id: 'popular-2' }],
total: 2,
},
300000,
);

// At least one popular market detail key
expect(cacheManager.set).toHaveBeenCalledWith(
CACHE_WARMING_KEYS.popularEventDetail('popular-1'),
{ id: 'popular-1' },
300000,
);

// Platform statistics (if applicable)
expect(cacheManager.set).toHaveBeenCalledWith(
CACHE_WARMING_KEYS.platformStatistics,
{ categories: [] },
300000,
);
expect(result.failed).toEqual([]);
expect(result.warmed).toEqual(
expect.arrayContaining([
Expand Down
7 changes: 6 additions & 1 deletion backend/src/common/common.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ import { IdempotencyInterceptor } from './idempotency/idempotency.interceptor';
TypeOrmModule.forFeature([IdempotencyKey]),
],
providers: [FilteringService, IdempotencyService, IdempotencyInterceptor],
exports: [JwtModule, FilteringService, IdempotencyService, IdempotencyInterceptor],
exports: [
JwtModule,
FilteringService,
IdempotencyService,
IdempotencyInterceptor,
],
})
export class CommonModule {}
18 changes: 14 additions & 4 deletions backend/src/common/idempotency/idempotency.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ export class IdempotencyInterceptor implements NestInterceptor {
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<unknown>> {
const request = context.switchToHttp().getRequest<Request & { user: { id: string } }>();
const request = context
.switchToHttp()
.getRequest<Request & { user: { id: string } }>();
const key = request.headers[IDEMPOTENCY_HEADER];

if (!key || typeof key !== 'string') {
Expand All @@ -33,7 +35,9 @@ export class IdempotencyInterceptor implements NestInterceptor {
}

const requestHash = createHash('sha256')
.update(`${request.method}:${request.originalUrl}:${JSON.stringify(request.body ?? {})}`)
.update(
`${request.method}:${request.originalUrl}:${JSON.stringify(request.body ?? {})}`,
)
.digest('hex');

const result = await this.idempotencyService.acquire(
Expand All @@ -60,8 +64,14 @@ export class IdempotencyInterceptor implements NestInterceptor {
const { record } = result;
return next.handle().pipe(
tap((data) => {
const response = context.switchToHttp().getResponse<{ statusCode: number }>();
void this.idempotencyService.complete(record.id, response.statusCode, data);
const response = context
.switchToHttp()
.getResponse<{ statusCode: number }>();
void this.idempotencyService.complete(
record.id,
response.statusCode,
data,
);
}),
catchError((err) => {
void this.idempotencyService.release(record.id);
Expand Down
6 changes: 5 additions & 1 deletion backend/src/common/idempotency/idempotency.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ export class IdempotencyService {
* Atomically claims a key for a user. Relies on the unique (key, userId)
* index to detect a concurrent or prior request with the same key.
*/
async acquire(key: string, userId: string, requestHash: string): Promise<AcquireResult> {
async acquire(
key: string,
userId: string,
requestHash: string,
): Promise<AcquireResult> {
try {
const inserted = await this.repository.save(
this.repository.create({
Expand Down
10 changes: 8 additions & 2 deletions backend/src/competitions/competitions.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,14 @@ export class CompetitionsController {
@UseGuards(BanGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'Update a competition before it starts (creator only)' })
@ApiResponse({ status: 200, description: 'Competition updated', type: Competition })
@ApiOperation({
summary: 'Update a competition before it starts (creator only)',
})
@ApiResponse({
status: 200,
description: 'Competition updated',
type: Competition,
})
@ApiResponse({ status: 400, description: 'Competition already started' })
@ApiResponse({ status: 403, description: 'Only the creator can update' })
@ApiResponse({ status: 404, description: 'Competition not found' })
Expand Down
3 changes: 2 additions & 1 deletion backend/src/competitions/competitions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,8 @@ export class CompetitionsService {
}

if (dto.title !== undefined) competition.title = dto.title;
if (dto.description !== undefined) competition.description = dto.description;
if (dto.description !== undefined)
competition.description = dto.description;
if (dto.prize_pool_stroops !== undefined)
competition.prize_pool_stroops = dto.prize_pool_stroops;
if (dto.max_participants !== undefined)
Expand Down
10 changes: 8 additions & 2 deletions backend/src/competitions/dto/update-competition.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,18 @@ export class UpdateCompetitionDto {
@MaxLength(2000)
description?: string;

@ApiPropertyOptional({ description: 'Prize pool in stroops', example: '10000000000' })
@ApiPropertyOptional({
description: 'Prize pool in stroops',
example: '10000000000',
})
@IsOptional()
@IsNumberString()
prize_pool_stroops?: string;

@ApiPropertyOptional({ description: 'Max number of participants', example: 200 })
@ApiPropertyOptional({
description: 'Max number of participants',
example: 200,
})
@IsOptional()
@IsInt()
@Min(2)
Expand Down
9 changes: 7 additions & 2 deletions backend/src/creator-events/creator-events.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,13 @@ export class CreatorEventsController {
@Get(':id/matches/upcoming')
@UseInterceptors(CacheInterceptor)
@CacheTTL(60)
@ApiOperation({ summary: 'Get upcoming (unresolved, future) matches for an event' })
@ApiResponse({ status: 200, description: 'Upcoming matches ordered by match time' })
@ApiOperation({
summary: 'Get upcoming (unresolved, future) matches for an event',
})
@ApiResponse({
status: 200,
description: 'Upcoming matches ordered by match time',
})
@ApiResponse({ status: 404, description: 'Event not found' })
getUpcomingMatches(@Param('id') id: string) {
return this.creatorEventsService.getUpcomingMatches(id);
Expand Down
40 changes: 30 additions & 10 deletions backend/src/creator-events/creator-events.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ describe('CreatorEventsService searchEvents', () => {
expect(queryBuilder.andWhere).toHaveBeenCalledWith(expect.any(Object)); // Brackets

jest.clearAllMocks();

await service.searchEvents({
q: 'league',
status: CreatorEventSearchStatus.Upcoming,
Expand Down Expand Up @@ -238,7 +238,7 @@ describe('CreatorEventsService getUpcomingMatches', () => {
let creatorEventRepository: { findOne: jest.Mock };

const futureDate = new Date(Date.now() + 86_400_000); // +1 day
const pastDate = new Date(Date.now() - 86_400_000); // -1 day
const pastDate = new Date(Date.now() - 86_400_000); // -1 day

const mockEvent = { id: 'event-uuid', on_chain_event_id: 42 } as any;

Expand All @@ -250,18 +250,28 @@ describe('CreatorEventsService getUpcomingMatches', () => {
getMany: jest.fn(),
};

matchRepository = { createQueryBuilder: jest.fn().mockReturnValue(matchQb) };
creatorEventRepository = { findOne: jest.fn().mockResolvedValue(mockEvent) };
matchRepository = {
createQueryBuilder: jest.fn().mockReturnValue(matchQb),
};
creatorEventRepository = {
findOne: jest.fn().mockResolvedValue(mockEvent),
};

const module = await Test.createTestingModule({
providers: [
CreatorEventsService,
{ provide: ContractService, useValue: {} },
{ provide: getRepositoryToken(CreatorEvent), useValue: creatorEventRepository },
{
provide: getRepositoryToken(CreatorEvent),
useValue: creatorEventRepository,
},
{ provide: getRepositoryToken(Match), useValue: matchRepository },
{ provide: getRepositoryToken(MatchPrediction), useValue: {} },
{ provide: getRepositoryToken(User), useValue: {} },
{ provide: getRepositoryToken(CreatorEventLeaderboardEntry), useValue: {} },
{
provide: getRepositoryToken(CreatorEventLeaderboardEntry),
useValue: {},
},
{ provide: getRepositoryToken(CreatorEventPayout), useValue: {} },
],
}).compile();
Expand All @@ -272,7 +282,11 @@ describe('CreatorEventsService getUpcomingMatches', () => {
it('returns only future unresolved matches ordered by match_time ASC', async () => {
const upcoming = [
{ id: 'm1', match_time: futureDate, result_submitted: false },
{ id: 'm2', match_time: new Date(futureDate.getTime() + 3600_000), result_submitted: false },
{
id: 'm2',
match_time: new Date(futureDate.getTime() + 3600_000),
result_submitted: false,
},
] as any[];

const qb = matchRepository.createQueryBuilder();
Expand All @@ -281,8 +295,12 @@ describe('CreatorEventsService getUpcomingMatches', () => {
const result = await service.getUpcomingMatches('42');

expect(result).toEqual(upcoming);
expect(qb.where).toHaveBeenCalledWith('match.event_id = :eventId', { eventId: mockEvent.id });
expect(qb.andWhere).toHaveBeenCalledWith('match.match_time > :now', { now: expect.any(Date) });
expect(qb.where).toHaveBeenCalledWith('match.event_id = :eventId', {
eventId: mockEvent.id,
});
expect(qb.andWhere).toHaveBeenCalledWith('match.match_time > :now', {
now: expect.any(Date),
});
expect(qb.andWhere).toHaveBeenCalledWith('match.result_submitted = false');
expect(qb.orderBy).toHaveBeenCalledWith('match.match_time', 'ASC');
});
Expand All @@ -298,6 +316,8 @@ describe('CreatorEventsService getUpcomingMatches', () => {
it('throws NotFoundException when event does not exist', async () => {
creatorEventRepository.findOne.mockResolvedValue(null);

await expect(service.getUpcomingMatches('999')).rejects.toThrow(NotFoundException);
await expect(service.getUpcomingMatches('999')).rejects.toThrow(
NotFoundException,
);
});
});
13 changes: 8 additions & 5 deletions backend/src/creator-events/creator-events.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,9 @@ export class CreatorEventsService {
async getEventMatches(
eventId: string,
query: ListMatchesQueryDto,
): Promise<Array<ContractMatch & { predictionCount: number; userPrediction?: string }>> {
): Promise<
Array<ContractMatch & { predictionCount: number; userPrediction?: string }>
> {
const event = await this.contractService.getEvent(eventId);
if (!event) {
throw new NotFoundException(`Event ${eventId} not found`);
Expand Down Expand Up @@ -670,10 +672,11 @@ export class CreatorEventsService {
case CreatorEventSearchStatus.Finished:
queryBuilder.andWhere(
new Brackets((qb) => {
qb.where('creatorEvent.end_time < :now', { now: new Date() }).orWhere(
'creatorEvent.is_active = :isActive',
{ isActive: false },
);
qb.where('creatorEvent.end_time < :now', {
now: new Date(),
}).orWhere('creatorEvent.is_active = :isActive', {
isActive: false,
});
}),
);
break;
Expand Down
9 changes: 7 additions & 2 deletions backend/src/creator-events/dto/event-by-code-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,14 @@ export class EventByCodeResponseDto {
@ApiPropertyOptional({ description: 'Campaign banner URL' })
bannerUrl?: string | null;

@ApiPropertyOptional({ description: 'Whether the campaign has been finalized' })
@ApiPropertyOptional({
description: 'Whether the campaign has been finalized',
})
isFinalized?: boolean;

@ApiPropertyOptional({ type: [Number], description: 'Reward split percentages' })
@ApiPropertyOptional({
type: [Number],
description: 'Reward split percentages',
})
rewardDistribution?: number[];
}
9 changes: 7 additions & 2 deletions backend/src/creator-events/dto/event-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,15 @@ export class EventResponseDto {
@ApiPropertyOptional({ description: 'Campaign banner URL' })
bannerUrl?: string | null;

@ApiPropertyOptional({ description: 'Whether the campaign has been finalized' })
@ApiPropertyOptional({
description: 'Whether the campaign has been finalized',
})
isFinalized?: boolean;

@ApiPropertyOptional({ type: [Number], description: 'Reward split percentages' })
@ApiPropertyOptional({
type: [Number],
description: 'Reward split percentages',
})
rewardDistribution?: number[];

@ApiPropertyOptional({ description: 'Number of winners' })
Expand Down
Loading
Loading