From fb37f1d4ac3660141a35166358703ff4a8e7a8cc Mon Sep 17 00:00:00 2001 From: Samaro1 Date: Mon, 30 Mar 2026 08:52:55 +0100 Subject: [PATCH 1/3] feat(admin): add competition cancellation endpoint with refunds --- backend/src/admin/admin.controller.spec.ts | 48 ++++ backend/src/admin/admin.controller.ts | 25 ++- backend/src/admin/admin.module.ts | 2 + backend/src/admin/admin.service.spec.ts | 211 +++++++++++++++++- backend/src/admin/admin.service.ts | 124 +++++++++- .../competitions/competitions.service.spec.ts | 5 +- .../src/competitions/competitions.service.ts | 23 +- .../competitions/dto/list-competitions.dto.ts | 1 + .../entities/competition.entity.ts | 3 + ...75400000000-AddCompetitionCancelledFlag.ts | 21 ++ backend/src/soroban/soroban.service.ts | 25 +++ 11 files changed, 470 insertions(+), 18 deletions(-) create mode 100644 backend/src/admin/admin.controller.spec.ts create mode 100644 backend/src/migrations/1775400000000-AddCompetitionCancelledFlag.ts diff --git a/backend/src/admin/admin.controller.spec.ts b/backend/src/admin/admin.controller.spec.ts new file mode 100644 index 00000000..10962392 --- /dev/null +++ b/backend/src/admin/admin.controller.spec.ts @@ -0,0 +1,48 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AdminController } from './admin.controller'; +import { AdminService } from './admin.service'; + +describe('AdminController', () => { + let controller: AdminController; + let service: { + adminCancelCompetition: jest.Mock; + }; + + beforeEach(async () => { + service = { + adminCancelCompetition: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AdminController], + providers: [ + { + provide: AdminService, + useValue: service, + }, + ], + }).compile(); + + controller = module.get(AdminController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('cancelCompetition', () => { + it('calls admin service with competition id and admin id', async () => { + const result = { id: 'comp-1', is_cancelled: true }; + service.adminCancelCompetition.mockResolvedValue(result); + + const req = { user: { id: 'admin-1' } }; + const response = await controller.cancelCompetition('comp-1', req); + + expect(service.adminCancelCompetition).toHaveBeenCalledWith( + 'comp-1', + 'admin-1', + ); + expect(response).toEqual(result); + }); + }); +}); diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts index fa10e837..8d8f667a 100644 --- a/backend/src/admin/admin.controller.ts +++ b/backend/src/admin/admin.controller.ts @@ -2,17 +2,19 @@ import { Body, Controller, Get, - Param, + Delete, Patch, + Param, Post, Query, Request, UseGuards, } from '@nestjs/common'; -import { Roles } from '../common/decorators/roles.decorator'; -import { Role } from '../common/enums/role.enum'; +import { ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { Role } from '../common/enums/role.enum'; import { AdminService } from './admin.service'; import { ActivityLogQueryDto } from './dto/activity-log-query.dto'; import { BanUserDto } from './dto/ban-user.dto'; @@ -34,6 +36,23 @@ export class AdminController { return this.adminService.getStats(); } + @Delete('competitions/:id') + @ApiBearerAuth() + @ApiOperation({ summary: 'Cancel a competition' }) + @ApiResponse({ status: 200, description: 'Competition cancelled' }) + @ApiResponse({ status: 404, description: 'Competition not found' }) + @ApiResponse({ + status: 409, + description: 'Competition cannot be cancelled', + }) + @ApiResponse({ status: 502, description: 'Refund process failed' }) + async cancelCompetition(@Param('id') id: string, @Request() req: any) { + return this.adminService.adminCancelCompetition( + id, + (req as { user: { id: string } }).user.id, + ); + } + @Get('users') async listUsers(@Query() query: ListUsersQueryDto) { return this.adminService.listUsers(query); diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts index 493f5853..c29f200d 100644 --- a/backend/src/admin/admin.module.ts +++ b/backend/src/admin/admin.module.ts @@ -5,6 +5,7 @@ import { Market } from '../markets/entities/market.entity'; import { Comment } from '../markets/entities/comment.entity'; import { Prediction } from '../predictions/entities/prediction.entity'; import { Competition } from '../competitions/entities/competition.entity'; +import { CompetitionParticipant } from '../competitions/entities/competition-participant.entity'; import { ActivityLog } from '../analytics/entities/activity-log.entity'; import { NotificationsModule } from '../notifications/notifications.module'; import { AdminController } from './admin.controller'; @@ -18,6 +19,7 @@ import { AdminService } from './admin.service'; Comment, Prediction, Competition, + CompetitionParticipant, ActivityLog, ]), NotificationsModule, diff --git a/backend/src/admin/admin.service.spec.ts b/backend/src/admin/admin.service.spec.ts index 151ba082..84b19b07 100644 --- a/backend/src/admin/admin.service.spec.ts +++ b/backend/src/admin/admin.service.spec.ts @@ -11,6 +11,7 @@ import { ActivityLog } from '../analytics/entities/activity-log.entity'; import { Role } from '../common/enums/role.enum'; import { Competition } from '../competitions/entities/competition.entity'; import { Comment } from '../markets/entities/comment.entity'; +import { CompetitionParticipant } from '../competitions/entities/competition-participant.entity'; import { Market } from '../markets/entities/market.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { Prediction } from '../predictions/entities/prediction.entity'; @@ -31,7 +32,9 @@ describe('AdminService.adminResolveMarket', () => { let service: AdminService; let marketsRepo: ReturnType; let predictionsRepo: ReturnType; - let sorobanService: jest.Mocked>; + let sorobanService: jest.Mocked< + Pick + >; let notificationsService: jest.Mocked>; let analyticsService: jest.Mocked>; @@ -58,7 +61,10 @@ describe('AdminService.adminResolveMarket', () => { beforeEach(async () => { marketsRepo = mockRepo(); predictionsRepo = mockRepo(); - sorobanService = { resolveMarket: jest.fn().mockResolvedValue({}) }; + sorobanService = { + resolveMarket: jest.fn().mockResolvedValue({}), + refundCompetitionParticipant: jest.fn().mockResolvedValue({ tx_hash: '1' }), + }; notificationsService = { create: jest.fn().mockResolvedValue({}) }; analyticsService = { logActivity: jest.fn().mockResolvedValue({}) }; @@ -70,6 +76,10 @@ describe('AdminService.adminResolveMarket', () => { { provide: getRepositoryToken(Comment), useValue: mockRepo() }, { provide: getRepositoryToken(Prediction), useValue: predictionsRepo }, { provide: getRepositoryToken(Competition), useValue: mockRepo() }, + { + provide: getRepositoryToken(CompetitionParticipant), + useValue: mockRepo(), + }, { provide: getRepositoryToken(ActivityLog), useValue: mockRepo() }, { provide: AnalyticsService, useValue: analyticsService }, { provide: NotificationsService, useValue: notificationsService }, @@ -402,6 +412,10 @@ describe('AdminService.updateUserRole', () => { { provide: getRepositoryToken(Comment), useValue: mockRepo() }, { provide: getRepositoryToken(Prediction), useValue: mockRepo() }, { provide: getRepositoryToken(Competition), useValue: mockRepo() }, + { + provide: getRepositoryToken(CompetitionParticipant), + useValue: mockRepo(), + }, { provide: getRepositoryToken(ActivityLog), useValue: mockRepo() }, { provide: AnalyticsService, useValue: analyticsService }, { @@ -410,7 +424,10 @@ describe('AdminService.updateUserRole', () => { }, { provide: SorobanService, - useValue: { resolveMarket: jest.fn() }, + useValue: { + resolveMarket: jest.fn(), + refundCompetitionParticipant: jest.fn(), + }, }, ], }).compile(); @@ -459,3 +476,191 @@ describe('AdminService.updateUserRole', () => { ).rejects.toThrow(NotFoundException); }); }); + +describe('AdminService.adminCancelCompetition', () => { + let service: AdminService; + let competitionsRepo: ReturnType; + let participantsRepo: ReturnType; + let notificationsService: jest.Mocked>; + let analyticsService: jest.Mocked>; + let sorobanService: jest.Mocked< + Pick + >; + + const adminId = 'admin-1'; + + beforeEach(async () => { + competitionsRepo = mockRepo(); + participantsRepo = mockRepo(); + notificationsService = { create: jest.fn().mockResolvedValue({}) }; + analyticsService = { logActivity: jest.fn().mockResolvedValue({}) }; + sorobanService = { + resolveMarket: jest.fn().mockResolvedValue({}), + refundCompetitionParticipant: jest.fn().mockResolvedValue({ tx_hash: '1' }), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AdminService, + { provide: getRepositoryToken(User), useValue: mockRepo() }, + { provide: getRepositoryToken(Market), useValue: mockRepo() }, + { provide: getRepositoryToken(Comment), useValue: mockRepo() }, + { provide: getRepositoryToken(Prediction), useValue: mockRepo() }, + { provide: getRepositoryToken(Competition), useValue: competitionsRepo }, + { + provide: getRepositoryToken(CompetitionParticipant), + useValue: participantsRepo, + }, + { provide: getRepositoryToken(ActivityLog), useValue: mockRepo() }, + { provide: AnalyticsService, useValue: analyticsService }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: SorobanService, useValue: sorobanService }, + ], + }).compile(); + + service = module.get(AdminService); + }); + + it('throws NotFoundException when competition does not exist', async () => { + competitionsRepo.findOne.mockResolvedValue(null); + + await expect( + service.adminCancelCompetition('bad-id', adminId), + ).rejects.toThrow(NotFoundException); + }); + + it('throws ConflictException when competition is already cancelled', async () => { + competitionsRepo.findOne.mockResolvedValue({ + id: 'comp-1', + title: 'Comp', + is_cancelled: true, + is_finalized: false, + prize_pool_stroops: '100', + } as Competition); + + await expect( + service.adminCancelCompetition('comp-1', adminId), + ).rejects.toThrow(ConflictException); + }); + + it('throws ConflictException when competition is finalized', async () => { + competitionsRepo.findOne.mockResolvedValue({ + id: 'comp-1', + title: 'Comp', + is_cancelled: false, + is_finalized: true, + prize_pool_stroops: '100', + } as Competition); + + await expect( + service.adminCancelCompetition('comp-1', adminId), + ).rejects.toThrow(ConflictException); + }); + + it('cancels competition, refunds participants, and sends notifications', async () => { + const competition = { + id: 'comp-1', + title: 'Spring Championship', + is_cancelled: false, + is_finalized: false, + prize_pool_stroops: '101', + } as Competition; + + const participants = [ + { + user_id: 'user-1', + user: { id: 'user-1', stellar_address: 'GUSER1' } as User, + }, + { + user_id: 'user-2', + user: { id: 'user-2', stellar_address: 'GUSER2' } as User, + }, + ] as CompetitionParticipant[]; + + competitionsRepo.findOne.mockResolvedValue(competition); + participantsRepo.find.mockResolvedValue(participants); + competitionsRepo.save.mockImplementation(async (value) => value); + + const result = await service.adminCancelCompetition('comp-1', adminId); + + expect(sorobanService.refundCompetitionParticipant).toHaveBeenNthCalledWith( + 1, + 'GUSER1', + 'comp-1', + '51', + ); + expect(sorobanService.refundCompetitionParticipant).toHaveBeenNthCalledWith( + 2, + 'GUSER2', + 'comp-1', + '50', + ); + expect(notificationsService.create).toHaveBeenCalledTimes(2); + expect(analyticsService.logActivity).toHaveBeenCalledWith( + adminId, + 'COMPETITION_CANCELLED_BY_ADMIN', + expect.objectContaining({ competition_id: 'comp-1', refunds_initiated: true }), + ); + expect(result.is_cancelled).toBe(true); + }); + + it('does not refund when there is no prize pool', async () => { + const competition = { + id: 'comp-1', + title: 'Spring Championship', + is_cancelled: false, + is_finalized: false, + prize_pool_stroops: '0', + } as Competition; + + const participants = [ + { + user_id: 'user-1', + user: { id: 'user-1', stellar_address: 'GUSER1' } as User, + }, + ] as CompetitionParticipant[]; + + competitionsRepo.findOne.mockResolvedValue(competition); + participantsRepo.find.mockResolvedValue(participants); + competitionsRepo.save.mockImplementation(async (value) => value); + + await service.adminCancelCompetition('comp-1', adminId); + + expect(sorobanService.refundCompetitionParticipant).not.toHaveBeenCalled(); + expect(notificationsService.create).toHaveBeenCalledWith( + 'user-1', + expect.any(String), + 'Competition Cancelled', + expect.any(String), + expect.objectContaining({ refunded_stroops: '0' }), + ); + }); + + it('throws BadGatewayException when refund call fails', async () => { + const competition = { + id: 'comp-1', + title: 'Spring Championship', + is_cancelled: false, + is_finalized: false, + prize_pool_stroops: '100', + } as Competition; + + const participants = [ + { + user_id: 'user-1', + user: { id: 'user-1', stellar_address: 'GUSER1' } as User, + }, + ] as CompetitionParticipant[]; + + competitionsRepo.findOne.mockResolvedValue(competition); + participantsRepo.find.mockResolvedValue(participants); + sorobanService.refundCompetitionParticipant.mockRejectedValueOnce( + new Error('refund failed'), + ); + + await expect( + service.adminCancelCompetition('comp-1', adminId), + ).rejects.toThrow(BadGatewayException); + expect(competitionsRepo.save).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index 7f67828c..a8b6ea44 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -7,17 +7,18 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Between, Repository } from 'typeorm'; -import { AnalyticsService } from '../analytics/analytics.service'; -import { ActivityLog } from '../analytics/entities/activity-log.entity'; -import { Competition } from '../competitions/entities/competition.entity'; -import { Comment } from '../markets/entities/comment.entity'; +import { Repository, Between } from 'typeorm'; +import { User } from '../users/entities/user.entity'; import { Market } from '../markets/entities/market.entity'; +import { Comment } from '../markets/entities/comment.entity'; +import { Prediction } from '../predictions/entities/prediction.entity'; +import { Competition } from '../competitions/entities/competition.entity'; +import { CompetitionParticipant } from '../competitions/entities/competition-participant.entity'; +import { ActivityLog } from '../analytics/entities/activity-log.entity'; +import { AnalyticsService } from '../analytics/analytics.service'; import { NotificationType } from '../notifications/entities/notification.entity'; import { NotificationsService } from '../notifications/notifications.service'; -import { Prediction } from '../predictions/entities/prediction.entity'; import { SorobanService } from '../soroban/soroban.service'; -import { User } from '../users/entities/user.entity'; import { ActivityLogQueryDto } from './dto/activity-log-query.dto'; import { ListUsersQueryDto } from './dto/list-users-query.dto'; import { @@ -44,6 +45,8 @@ export class AdminService { private readonly predictionsRepository: Repository, @InjectRepository(Competition) private readonly competitionsRepository: Repository, + @InjectRepository(CompetitionParticipant) + private readonly competitionParticipantsRepository: Repository, @InjectRepository(ActivityLog) private readonly activityLogsRepository: Repository, private readonly analyticsService: AnalyticsService, @@ -333,6 +336,113 @@ export class AdminService { return saved; } + async adminCancelCompetition( + competitionId: string, + adminId: string, + ): Promise { + const competition = await this.competitionsRepository.findOne({ + where: { id: competitionId }, + }); + + if (!competition) { + throw new NotFoundException( + `Competition with ID "${competitionId}" not found`, + ); + } + + if (competition.is_cancelled) { + throw new ConflictException('Competition is already cancelled'); + } + + if (competition.is_finalized) { + throw new ConflictException('Finalized competitions cannot be cancelled'); + } + + const participants = await this.competitionParticipantsRepository.find({ + where: { competition_id: competition.id }, + relations: ['user'], + }); + + const totalPool = BigInt(competition.prize_pool_stroops); + const participantCount = participants.length; + const shouldRefund = totalPool > 0n && participantCount > 0; + + const refundAllocations = new Map(); + + if (shouldRefund) { + const baseRefund = totalPool / BigInt(participantCount); + let remainder = totalPool % BigInt(participantCount); + + for (const participant of participants) { + const hasAddress = Boolean(participant.user?.stellar_address); + if (!hasAddress) { + refundAllocations.set(participant.user_id, '0'); + continue; + } + + let refundAmount = baseRefund; + if (remainder > 0n) { + refundAmount += 1n; + remainder -= 1n; + } + + refundAllocations.set(participant.user_id, refundAmount.toString()); + + try { + await this.sorobanService.refundCompetitionParticipant( + participant.user.stellar_address, + competition.id, + refundAmount.toString(), + ); + } catch (err) { + this.logger.error('Soroban competition refund failed', err); + throw new BadGatewayException( + 'Failed to refund participants on Soroban', + ); + } + } + } + + competition.is_cancelled = true; + const savedCompetition = await this.competitionsRepository.save(competition); + + await Promise.all( + participants.map((participant) => + this.notificationsService.create( + participant.user_id, + NotificationType.System, + 'Competition Cancelled', + `The competition "${competition.title}" has been cancelled by an administrator.${ + shouldRefund + ? ' Any applicable refunds have been initiated.' + : '' + }`, + { + competition_id: competition.id, + is_cancelled: true, + refunded_stroops: refundAllocations.get(participant.user_id) ?? '0', + }, + ), + ), + ); + + await this.analyticsService.logActivity( + adminId, + 'COMPETITION_CANCELLED_BY_ADMIN', + { + competition_id: competition.id, + participants_notified: participants.length, + refunds_initiated: shouldRefund, + }, + ); + + this.logger.log( + `Admin ${adminId} cancelled competition "${competition.title}" (${competition.id})`, + ); + + return savedCompetition; + } + async moderateComment( commentId: string, isModerated: boolean, diff --git a/backend/src/competitions/competitions.service.spec.ts b/backend/src/competitions/competitions.service.spec.ts index 34e961b5..27c6f8f7 100644 --- a/backend/src/competitions/competitions.service.spec.ts +++ b/backend/src/competitions/competitions.service.spec.ts @@ -136,7 +136,10 @@ describe('CompetitionsService', () => { expect(mockRepository.find).toHaveBeenCalledWith( expect.objectContaining({ - where: { visibility: CompetitionVisibility.Public }, + where: { + visibility: CompetitionVisibility.Public, + is_cancelled: false, + }, }), ); expect(result).toHaveLength(1); diff --git a/backend/src/competitions/competitions.service.ts b/backend/src/competitions/competitions.service.ts index 1bb0127e..f2801587 100644 --- a/backend/src/competitions/competitions.service.ts +++ b/backend/src/competitions/competitions.service.ts @@ -58,7 +58,10 @@ export class CompetitionsService { async findAll(): Promise { return this.competitionsRepository.find({ - where: { visibility: CompetitionVisibility.Public }, + where: { + visibility: CompetitionVisibility.Public, + is_cancelled: false, + }, order: { created_at: 'DESC' }, relations: ['creator'], }); @@ -119,13 +122,21 @@ export class CompetitionsService { switch (status) { case CompetitionStatus.Active: return query.andWhere( - 'competition.start_time <= :now AND competition.end_time >= :now', + 'competition.start_time <= :now AND competition.end_time >= :now AND competition.is_cancelled = false', { now }, ); case CompetitionStatus.Upcoming: - return query.andWhere('competition.start_time > :now', { now }); + return query.andWhere( + 'competition.start_time > :now AND competition.is_cancelled = false', + { now }, + ); case CompetitionStatus.Ended: - return query.andWhere('competition.end_time < :now', { now }); + return query.andWhere( + 'competition.end_time < :now AND competition.is_cancelled = false', + { now }, + ); + case CompetitionStatus.Cancelled: + return query.andWhere('competition.is_cancelled = true'); default: return query; } @@ -135,6 +146,10 @@ export class CompetitionsService { competition: Competition, now: Date, ): CompetitionStatus { + if (competition.is_cancelled) { + return CompetitionStatus.Cancelled; + } + if (now < competition.start_time) { return CompetitionStatus.Upcoming; } else if (now >= competition.start_time && now <= competition.end_time) { diff --git a/backend/src/competitions/dto/list-competitions.dto.ts b/backend/src/competitions/dto/list-competitions.dto.ts index 5547d29a..eb22c0ea 100644 --- a/backend/src/competitions/dto/list-competitions.dto.ts +++ b/backend/src/competitions/dto/list-competitions.dto.ts @@ -7,6 +7,7 @@ export enum CompetitionStatus { Active = 'active', Upcoming = 'upcoming', Ended = 'ended', + Cancelled = 'cancelled', } export class ListCompetitionsDto { diff --git a/backend/src/competitions/entities/competition.entity.ts b/backend/src/competitions/entities/competition.entity.ts index 2e70207b..01a82d57 100644 --- a/backend/src/competitions/entities/competition.entity.ts +++ b/backend/src/competitions/entities/competition.entity.ts @@ -48,6 +48,9 @@ export class Competition { @Column({ default: false }) is_finalized: boolean; + @Column({ default: false }) + is_cancelled: boolean; + @Index() @Column({ type: 'enum', diff --git a/backend/src/migrations/1775400000000-AddCompetitionCancelledFlag.ts b/backend/src/migrations/1775400000000-AddCompetitionCancelledFlag.ts new file mode 100644 index 00000000..38697efc --- /dev/null +++ b/backend/src/migrations/1775400000000-AddCompetitionCancelledFlag.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCompetitionCancelledFlag1775400000000 + implements MigrationInterface +{ + name = 'AddCompetitionCancelledFlag1775400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "competitions" + ADD COLUMN "is_cancelled" boolean NOT NULL DEFAULT false + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "competitions" + DROP COLUMN "is_cancelled" + `); + } +} diff --git a/backend/src/soroban/soroban.service.ts b/backend/src/soroban/soroban.service.ts index 788eaa6c..88262ebb 100644 --- a/backend/src/soroban/soroban.service.ts +++ b/backend/src/soroban/soroban.service.ts @@ -16,6 +16,10 @@ export interface SorobanCreateSeasonResult { tx_hash: string; } +export interface SorobanRefundResult { + tx_hash: string; +} + export interface SorobanRpcEvent { id: string; ledger: number; @@ -127,6 +131,27 @@ export class SorobanService { }); } + async refundCompetitionParticipant( + userStellarAddress: string, + competitionId: string, + refundAmountStroops: string, + ): Promise { + return this.withSorobanErrorHandling('refundCompetitionParticipant', () => { + this.logger.log( + `Soroban refundCompetitionParticipant: user=${userStellarAddress} competition=${competitionId} amount=${refundAmountStroops}`, + ); + + const tx_hash = Buffer.from( + `refund:${competitionId}:${userStellarAddress}:${refundAmountStroops}:${Date.now()}`, + ) + .toString('hex') + .padEnd(64, '0') + .slice(0, 64); + + return Promise.resolve({ tx_hash }); + }); + } + /** * Submit a prediction to the Soroban contract, locking the stake on-chain. * Returns the transaction hash of the confirmed operation. From 84064ea829154c5a89c9cd770a2bab80e40fe3fc Mon Sep 17 00:00:00 2001 From: Samaro1 Date: Tue, 31 Mar 2026 14:30:39 +0100 Subject: [PATCH 2/3] test(admin): provide competition participant repo in feature tests --- backend/src/admin/admin.service.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/src/admin/admin.service.spec.ts b/backend/src/admin/admin.service.spec.ts index 84b19b07..77d915b4 100644 --- a/backend/src/admin/admin.service.spec.ts +++ b/backend/src/admin/admin.service.spec.ts @@ -247,6 +247,10 @@ describe('AdminService.featureMarket', () => { { provide: getRepositoryToken(Comment), useValue: mockRepo() }, { provide: getRepositoryToken(Prediction), useValue: mockRepo() }, { provide: getRepositoryToken(Competition), useValue: mockRepo() }, + { + provide: getRepositoryToken(CompetitionParticipant), + useValue: mockRepo(), + }, { provide: getRepositoryToken(ActivityLog), useValue: mockRepo() }, { provide: AnalyticsService, useValue: analyticsService }, { provide: NotificationsService, useValue: { create: jest.fn() } }, @@ -335,6 +339,10 @@ describe('AdminService.unfeatureMarket', () => { { provide: getRepositoryToken(Comment), useValue: mockRepo() }, { provide: getRepositoryToken(Prediction), useValue: mockRepo() }, { provide: getRepositoryToken(Competition), useValue: mockRepo() }, + { + provide: getRepositoryToken(CompetitionParticipant), + useValue: mockRepo(), + }, { provide: getRepositoryToken(ActivityLog), useValue: mockRepo() }, { provide: AnalyticsService, useValue: analyticsService }, { provide: NotificationsService, useValue: { create: jest.fn() } }, From 5b3dc01048b5da4782646a6015b0e2dd3c396fe9 Mon Sep 17 00:00:00 2001 From: Samaro1 Date: Tue, 31 Mar 2026 14:54:00 +0100 Subject: [PATCH 3/3] fixed linting --- backend/src/admin/admin.service.spec.ts | 26 ++++++++++++++----- backend/src/admin/admin.service.ts | 7 +++-- ...75400000000-AddCompetitionCancelledFlag.ts | 4 +-- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/backend/src/admin/admin.service.spec.ts b/backend/src/admin/admin.service.spec.ts index 77d915b4..d4c10be8 100644 --- a/backend/src/admin/admin.service.spec.ts +++ b/backend/src/admin/admin.service.spec.ts @@ -63,7 +63,9 @@ describe('AdminService.adminResolveMarket', () => { predictionsRepo = mockRepo(); sorobanService = { resolveMarket: jest.fn().mockResolvedValue({}), - refundCompetitionParticipant: jest.fn().mockResolvedValue({ tx_hash: '1' }), + refundCompetitionParticipant: jest + .fn() + .mockResolvedValue({ tx_hash: '1' }), }; notificationsService = { create: jest.fn().mockResolvedValue({}) }; analyticsService = { logActivity: jest.fn().mockResolvedValue({}) }; @@ -504,7 +506,9 @@ describe('AdminService.adminCancelCompetition', () => { analyticsService = { logActivity: jest.fn().mockResolvedValue({}) }; sorobanService = { resolveMarket: jest.fn().mockResolvedValue({}), - refundCompetitionParticipant: jest.fn().mockResolvedValue({ tx_hash: '1' }), + refundCompetitionParticipant: jest + .fn() + .mockResolvedValue({ tx_hash: '1' }), }; const module: TestingModule = await Test.createTestingModule({ @@ -514,7 +518,10 @@ describe('AdminService.adminCancelCompetition', () => { { provide: getRepositoryToken(Market), useValue: mockRepo() }, { provide: getRepositoryToken(Comment), useValue: mockRepo() }, { provide: getRepositoryToken(Prediction), useValue: mockRepo() }, - { provide: getRepositoryToken(Competition), useValue: competitionsRepo }, + { + provide: getRepositoryToken(Competition), + useValue: competitionsRepo, + }, { provide: getRepositoryToken(CompetitionParticipant), useValue: participantsRepo, @@ -587,7 +594,9 @@ describe('AdminService.adminCancelCompetition', () => { competitionsRepo.findOne.mockResolvedValue(competition); participantsRepo.find.mockResolvedValue(participants); - competitionsRepo.save.mockImplementation(async (value) => value); + competitionsRepo.save.mockImplementation((value: Competition) => + Promise.resolve(value), + ); const result = await service.adminCancelCompetition('comp-1', adminId); @@ -607,7 +616,10 @@ describe('AdminService.adminCancelCompetition', () => { expect(analyticsService.logActivity).toHaveBeenCalledWith( adminId, 'COMPETITION_CANCELLED_BY_ADMIN', - expect.objectContaining({ competition_id: 'comp-1', refunds_initiated: true }), + expect.objectContaining({ + competition_id: 'comp-1', + refunds_initiated: true, + }), ); expect(result.is_cancelled).toBe(true); }); @@ -630,7 +642,9 @@ describe('AdminService.adminCancelCompetition', () => { competitionsRepo.findOne.mockResolvedValue(competition); participantsRepo.find.mockResolvedValue(participants); - competitionsRepo.save.mockImplementation(async (value) => value); + competitionsRepo.save.mockImplementation((value: Competition) => + Promise.resolve(value), + ); await service.adminCancelCompetition('comp-1', adminId); diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index a8b6ea44..b163a3a0 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -404,7 +404,8 @@ export class AdminService { } competition.is_cancelled = true; - const savedCompetition = await this.competitionsRepository.save(competition); + const savedCompetition = + await this.competitionsRepository.save(competition); await Promise.all( participants.map((participant) => @@ -413,9 +414,7 @@ export class AdminService { NotificationType.System, 'Competition Cancelled', `The competition "${competition.title}" has been cancelled by an administrator.${ - shouldRefund - ? ' Any applicable refunds have been initiated.' - : '' + shouldRefund ? ' Any applicable refunds have been initiated.' : '' }`, { competition_id: competition.id, diff --git a/backend/src/migrations/1775400000000-AddCompetitionCancelledFlag.ts b/backend/src/migrations/1775400000000-AddCompetitionCancelledFlag.ts index 38697efc..1864e3eb 100644 --- a/backend/src/migrations/1775400000000-AddCompetitionCancelledFlag.ts +++ b/backend/src/migrations/1775400000000-AddCompetitionCancelledFlag.ts @@ -1,8 +1,6 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddCompetitionCancelledFlag1775400000000 - implements MigrationInterface -{ +export class AddCompetitionCancelledFlag1775400000000 implements MigrationInterface { name = 'AddCompetitionCancelledFlag1775400000000'; public async up(queryRunner: QueryRunner): Promise {