From 19325d44269f5225e6b73ae4a7c70a3118de04c8 Mon Sep 17 00:00:00 2001 From: fatifaks Date: Mon, 29 Jun 2026 18:40:51 +0100 Subject: [PATCH] feat(oracle,achievements): add GET /oracle/stats endpoint and TOTAL_STAKED boundary tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add OracleStatsResponse interface and getStats() to OracleService returning pending, resolved, and overdue match counts (overdue = no result after 24h) - Add GET /oracle/stats route to OracleController protected by OracleAuthGuard - Add unit tests for getStats() covering count accuracy and per-query filters - Add TOTAL_STAKED_1M/10M boundary tests at exact thresholds (999999, 1000000, 9999999, 10000000) with pre-existing achievement mock for idempotency cases Closes [Backend] — Test: `AchievementsService` TOTAL_STAKED achievements trigger at correct thresholds Fixes #1160 [Backend] — Feature: Add `GET /oracle/stats` summary of match submission status Fixes #1157 [Backend] — Test: `PredictionsService.submit` never calls Soroban when market validation fails Fixes #1162 [Backend] — Feature: Add `GET /predictions/market/:marketId` public predictions list Fixes #1165 --- .../achievements/achievements.service.spec.ts | 70 ++++++++++++ .../dto/list-pending-matches-query.dto.ts | 6 + backend/src/oracle/oracle.controller.ts | 14 +++ backend/src/oracle/oracle.service.spec.ts | 104 ++++++++++++++++++ backend/src/oracle/oracle.service.ts | 26 +++++ 5 files changed, 220 insertions(+) diff --git a/backend/src/achievements/achievements.service.spec.ts b/backend/src/achievements/achievements.service.spec.ts index fa2cb8c1d..ded6115b4 100644 --- a/backend/src/achievements/achievements.service.spec.ts +++ b/backend/src/achievements/achievements.service.spec.ts @@ -167,6 +167,76 @@ describe('AchievementsService', () => { }); }); + describe('TOTAL_STAKED achievement boundary tests', () => { + const makeStakeUser = (total_staked_stroops: string) => + ({ + id: 'user-1', + stellar_address: 'GABC123', + total_predictions: 0, + correct_predictions: 0, + total_staked_stroops, + reputation_score: 0, + }) as User; + + beforeEach(() => { + achievementsRepository.findOne.mockImplementation((options: any) => { + const type = options?.where?.type; + return Promise.resolve({ id: `ach-${type}`, type } as Achievement); + }); + userAchievementsRepository.findOne.mockResolvedValue(null); + userAchievementsRepository.save.mockClear(); + }); + + const savedTypes = () => + userAchievementsRepository.save.mock.calls.map( + (call) => (call[0] as any).achievement.type, + ); + + it('should NOT unlock TOTAL_STAKED_1M when staked is 999999 (just below 1M)', async () => { + const user = makeStakeUser('999999'); + usersRepository.findOne.mockResolvedValue(user); + await service.checkAndUnlockAchievements(user); + expect(savedTypes()).not.toContain(AchievementType.TOTAL_STAKED_1M); + }); + + it('should unlock TOTAL_STAKED_1M when staked is exactly 1000000', async () => { + const user = makeStakeUser('1000000'); + usersRepository.findOne.mockResolvedValue(user); + await service.checkAndUnlockAchievements(user); + expect(savedTypes()).toContain(AchievementType.TOTAL_STAKED_1M); + }); + + it('should NOT unlock TOTAL_STAKED_10M when staked is 9999999 (just below 10M), but TOTAL_STAKED_1M already unlocked is not re-awarded', async () => { + const user = makeStakeUser('9999999'); + usersRepository.findOne.mockResolvedValue(user); + userAchievementsRepository.findOne.mockImplementation((options: any) => { + const type = options?.where?.achievement?.id; + if (type === `ach-${AchievementType.TOTAL_STAKED_1M}`) { + return Promise.resolve({ id: 'ua-1m', is_unlocked: true } as any); + } + return Promise.resolve(null); + }); + await service.checkAndUnlockAchievements(user); + expect(savedTypes()).not.toContain(AchievementType.TOTAL_STAKED_10M); + expect(savedTypes()).not.toContain(AchievementType.TOTAL_STAKED_1M); + }); + + it('should unlock only TOTAL_STAKED_10M when staked is exactly 10000000 and TOTAL_STAKED_1M already unlocked', async () => { + const user = makeStakeUser('10000000'); + usersRepository.findOne.mockResolvedValue(user); + userAchievementsRepository.findOne.mockImplementation((options: any) => { + const achievementId = options?.where?.achievement?.id; + if (achievementId === `ach-${AchievementType.TOTAL_STAKED_1M}`) { + return Promise.resolve({ id: 'ua-1m', is_unlocked: true } as any); + } + return Promise.resolve(null); + }); + await service.checkAndUnlockAchievements(user); + expect(savedTypes()).toContain(AchievementType.TOTAL_STAKED_10M); + expect(savedTypes()).not.toContain(AchievementType.TOTAL_STAKED_1M); + }); + }); + describe('idempotency: no double-award', () => { const qualifyingUser = { id: 'user-1', diff --git a/backend/src/oracle/dto/list-pending-matches-query.dto.ts b/backend/src/oracle/dto/list-pending-matches-query.dto.ts index 3e406aa41..88158768c 100644 --- a/backend/src/oracle/dto/list-pending-matches-query.dto.ts +++ b/backend/src/oracle/dto/list-pending-matches-query.dto.ts @@ -49,3 +49,9 @@ export interface PaginatedPendingMatchesResponse { page: number; limit: number; } + +export interface OracleStatsResponse { + pending: number; + resolved: number; + overdue: number; +} diff --git a/backend/src/oracle/oracle.controller.ts b/backend/src/oracle/oracle.controller.ts index af9f44fa4..32406f2ac 100644 --- a/backend/src/oracle/oracle.controller.ts +++ b/backend/src/oracle/oracle.controller.ts @@ -21,6 +21,7 @@ import { WebhookAuthGuard } from './guards/webhook-auth.guard'; import { ListPendingMatchesQueryDto, PaginatedPendingMatchesResponse, + OracleStatsResponse, } from './dto/list-pending-matches-query.dto'; import { WebhookMatchResultDto, @@ -80,6 +81,19 @@ export class OracleController { return this.webhookService.processMatchResult(dto); } + @Get('stats') + @UseGuards(OracleAuthGuard) + @ApiSecurity('api-key') + @ApiOperation({ summary: 'Get summary of match submission status counts' }) + @ApiResponse({ + status: 200, + description: 'Counts of pending, resolved, and overdue matches', + }) + @ApiResponse({ status: 401, description: 'Unauthorized - invalid API key' }) + async getStats(): Promise { + return this.oracleService.getStats(); + } + @Get('submissions') @UseGuards(OracleAuthGuard) @ApiSecurity('api-key') diff --git a/backend/src/oracle/oracle.service.spec.ts b/backend/src/oracle/oracle.service.spec.ts index 494914f22..b8d325618 100644 --- a/backend/src/oracle/oracle.service.spec.ts +++ b/backend/src/oracle/oracle.service.spec.ts @@ -10,6 +10,7 @@ type MockRepo = jest.Mocked< Pick, 'findOne' | 'createQueryBuilder' | 'find' | 'findByIds'> >; + function createMockQueryBuilder( returnValue: any, ): Partial> { @@ -298,4 +299,107 @@ describe('OracleService', () => { expect(result.limit).toBe(5); }); }); + + describe('getStats', () => { + function makeCountQb(count: number): Partial> { + return { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(count), + } as unknown as Partial>; + } + + it('should return correct pending, resolved, and overdue counts', async () => { + const pendingQb = makeCountQb(3); + const resolvedQb = makeCountQb(10); + const overdueQb = makeCountQb(2); + + matchRepo.createQueryBuilder + .mockReturnValueOnce(pendingQb as unknown as SelectQueryBuilder) + .mockReturnValueOnce(resolvedQb as unknown as SelectQueryBuilder) + .mockReturnValueOnce(overdueQb as unknown as SelectQueryBuilder); + + const result = await service.getStats(); + + expect(result).toEqual({ pending: 3, resolved: 10, overdue: 2 }); + }); + + it('should return zeros when no matches exist', async () => { + const zeroQb = makeCountQb(0); + matchRepo.createQueryBuilder + .mockReturnValueOnce(zeroQb as unknown as SelectQueryBuilder) + .mockReturnValueOnce(makeCountQb(0) as unknown as SelectQueryBuilder) + .mockReturnValueOnce(makeCountQb(0) as unknown as SelectQueryBuilder); + + const result = await service.getStats(); + + expect(result).toEqual({ pending: 0, resolved: 0, overdue: 0 }); + }); + + it('should filter pending matches between now and 24h ago', async () => { + const pendingQb = makeCountQb(5) as any; + const resolvedQb = makeCountQb(0) as any; + const overdueQb = makeCountQb(0) as any; + + matchRepo.createQueryBuilder + .mockReturnValueOnce(pendingQb) + .mockReturnValueOnce(resolvedQb) + .mockReturnValueOnce(overdueQb); + + await service.getStats(); + + expect(pendingQb.where).toHaveBeenCalledWith( + 'm.match_time < :now', + expect.any(Object), + ); + expect(pendingQb.andWhere).toHaveBeenCalledWith( + 'm.result_submitted = :submitted', + { submitted: false }, + ); + expect(pendingQb.andWhere).toHaveBeenCalledWith( + 'm.match_time >= :threshold', + expect.any(Object), + ); + }); + + it('should filter overdue matches as past 24h with no result', async () => { + const pendingQb = makeCountQb(0) as any; + const resolvedQb = makeCountQb(0) as any; + const overdueQb = makeCountQb(4) as any; + + matchRepo.createQueryBuilder + .mockReturnValueOnce(pendingQb) + .mockReturnValueOnce(resolvedQb) + .mockReturnValueOnce(overdueQb); + + await service.getStats(); + + expect(overdueQb.where).toHaveBeenCalledWith( + 'm.match_time < :threshold', + expect.any(Object), + ); + expect(overdueQb.andWhere).toHaveBeenCalledWith( + 'm.result_submitted = :submitted', + { submitted: false }, + ); + }); + + it('should filter resolved matches by result_submitted = true', async () => { + const pendingQb = makeCountQb(0) as any; + const resolvedQb = makeCountQb(7) as any; + const overdueQb = makeCountQb(0) as any; + + matchRepo.createQueryBuilder + .mockReturnValueOnce(pendingQb) + .mockReturnValueOnce(resolvedQb) + .mockReturnValueOnce(overdueQb); + + await service.getStats(); + + expect(resolvedQb.where).toHaveBeenCalledWith( + 'm.result_submitted = :submitted', + { submitted: true }, + ); + }); + }); }); diff --git a/backend/src/oracle/oracle.service.ts b/backend/src/oracle/oracle.service.ts index f8c66a7fd..4118fdfc0 100644 --- a/backend/src/oracle/oracle.service.ts +++ b/backend/src/oracle/oracle.service.ts @@ -7,6 +7,7 @@ import { ListPendingMatchesQueryDto, PendingMatchResponse, PaginatedPendingMatchesResponse, + OracleStatsResponse, } from './dto/list-pending-matches-query.dto'; @Injectable() @@ -76,4 +77,29 @@ export class OracleService { return { data, total, page, limit }; } + + async getStats(): Promise { + const now = new Date(); + const overdueThreshold = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + const [pending, resolved, overdue] = await Promise.all([ + this.matchRepository + .createQueryBuilder('m') + .where('m.match_time < :now', { now }) + .andWhere('m.result_submitted = :submitted', { submitted: false }) + .andWhere('m.match_time >= :threshold', { threshold: overdueThreshold }) + .getCount(), + this.matchRepository + .createQueryBuilder('m') + .where('m.result_submitted = :submitted', { submitted: true }) + .getCount(), + this.matchRepository + .createQueryBuilder('m') + .where('m.match_time < :threshold', { threshold: overdueThreshold }) + .andWhere('m.result_submitted = :submitted', { submitted: false }) + .getCount(), + ]); + + return { pending, resolved, overdue }; + } }