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
70 changes: 70 additions & 0 deletions backend/src/achievements/achievements.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions backend/src/oracle/dto/list-pending-matches-query.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,9 @@ export interface PaginatedPendingMatchesResponse {
page: number;
limit: number;
}

export interface OracleStatsResponse {
pending: number;
resolved: number;
overdue: number;
}
14 changes: 14 additions & 0 deletions backend/src/oracle/oracle.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { WebhookAuthGuard } from './guards/webhook-auth.guard';
import {
ListPendingMatchesQueryDto,
PaginatedPendingMatchesResponse,
OracleStatsResponse,
} from './dto/list-pending-matches-query.dto';
import {
WebhookMatchResultDto,
Expand Down Expand Up @@ -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<OracleStatsResponse> {
return this.oracleService.getStats();
}

@Get('submissions')
@UseGuards(OracleAuthGuard)
@ApiSecurity('api-key')
Expand Down
104 changes: 104 additions & 0 deletions backend/src/oracle/oracle.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type MockRepo = jest.Mocked<
Pick<Repository<any>, 'findOne' | 'createQueryBuilder' | 'find' | 'findByIds'>
>;


function createMockQueryBuilder<T>(
returnValue: any,
): Partial<SelectQueryBuilder<T>> {
Expand Down Expand Up @@ -298,4 +299,107 @@ describe('OracleService', () => {
expect(result.limit).toBe(5);
});
});

describe('getStats', () => {
function makeCountQb(count: number): Partial<SelectQueryBuilder<any>> {
return {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getCount: jest.fn().mockResolvedValue(count),
} as unknown as Partial<SelectQueryBuilder<any>>;
}

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<any>)
.mockReturnValueOnce(resolvedQb as unknown as SelectQueryBuilder<any>)
.mockReturnValueOnce(overdueQb as unknown as SelectQueryBuilder<any>);

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<any>)
.mockReturnValueOnce(makeCountQb(0) as unknown as SelectQueryBuilder<any>)
.mockReturnValueOnce(makeCountQb(0) as unknown as SelectQueryBuilder<any>);

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 },
);
});
});
});
26 changes: 26 additions & 0 deletions backend/src/oracle/oracle.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ListPendingMatchesQueryDto,
PendingMatchResponse,
PaginatedPendingMatchesResponse,
OracleStatsResponse,
} from './dto/list-pending-matches-query.dto';

@Injectable()
Expand Down Expand Up @@ -76,4 +77,29 @@ export class OracleService {

return { data, total, page, limit };
}

async getStats(): Promise<OracleStatsResponse> {
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 };
}
}
Loading