Skip to content

Commit 0e72cfe

Browse files
authored
Add LockingApi.getLeaderBoardV2 (safe-global#1550)
Adds LockingApi.getLeaderBoardV2 datasource function. Adds Holder entity.
1 parent 355baba commit 0e72cfe

File tree

5 files changed

+201
-0
lines changed

5 files changed

+201
-0
lines changed

src/datasources/locking-api/locking-api.service.spec.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { getAddress } from 'viem';
1515
import { rankBuilder } from '@/domain/locking/entities/__tests__/rank.builder';
1616
import { campaignBuilder } from '@/domain/locking/entities/__tests__/campaign.builder';
17+
import { holderBuilder } from '@/domain/locking/entities/__tests__/holder.builder';
1718

1819
const networkService = {
1920
get: jest.fn(),
@@ -264,6 +265,78 @@ describe('LockingApi', () => {
264265
});
265266
});
266267

268+
describe('getLeaderboardV2', () => {
269+
it('should get leaderboard v2', async () => {
270+
const campaignId = faker.string.uuid();
271+
const leaderboardV2Page = pageBuilder()
272+
.with('results', [holderBuilder().build(), holderBuilder().build()])
273+
.build();
274+
mockNetworkService.get.mockResolvedValueOnce({
275+
data: leaderboardV2Page,
276+
status: 200,
277+
});
278+
279+
const result = await service.getLeaderboardV2({ campaignId });
280+
281+
expect(result).toEqual(leaderboardV2Page);
282+
expect(mockNetworkService.get).toHaveBeenCalledWith({
283+
url: `${lockingBaseUri}/api/v2/leaderboard/${campaignId}`,
284+
networkRequest: {
285+
params: {
286+
limit: undefined,
287+
offset: undefined,
288+
},
289+
},
290+
});
291+
});
292+
293+
it('should forward pagination queries', async () => {
294+
const limit = faker.number.int();
295+
const offset = faker.number.int();
296+
const campaignId = faker.string.uuid();
297+
const leaderboardV2Page = pageBuilder()
298+
.with('results', [holderBuilder().build(), holderBuilder().build()])
299+
.build();
300+
mockNetworkService.get.mockResolvedValueOnce({
301+
data: leaderboardV2Page,
302+
status: 200,
303+
});
304+
305+
await service.getLeaderboardV2({ campaignId, limit, offset });
306+
307+
expect(mockNetworkService.get).toHaveBeenCalledWith({
308+
url: `${lockingBaseUri}/api/v2/leaderboard/${campaignId}`,
309+
networkRequest: {
310+
params: {
311+
limit,
312+
offset,
313+
},
314+
},
315+
});
316+
});
317+
318+
it('should forward error', async () => {
319+
const status = faker.internet.httpStatusCode({ types: ['serverError'] });
320+
const campaignId = faker.string.uuid();
321+
const error = new NetworkResponseError(
322+
new URL(`${lockingBaseUri}/api/v2/leaderboard/${campaignId}`),
323+
{
324+
status,
325+
} as Response,
326+
{
327+
message: 'Unexpected error',
328+
},
329+
);
330+
mockNetworkService.get.mockRejectedValueOnce(error);
331+
332+
await expect(service.getLeaderboardV2({ campaignId })).rejects.toThrow(
333+
new DataSourceError('Unexpected error', status),
334+
);
335+
336+
expect(mockNetworkService.get).toHaveBeenCalledTimes(1);
337+
});
338+
});
339+
267340
describe('getLockingHistory', () => {
268341
it('should get locking history', async () => {
269342
const safeAddress = getAddress(faker.finance.ethereumAddress());

src/datasources/locking-api/locking-api.service.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
import { Page } from '@/domain/entities/page.entity';
88
import { ILockingApi } from '@/domain/interfaces/locking-api.interface';
99
import { Campaign } from '@/domain/locking/entities/campaign.entity';
10+
import { Holder } from '@/domain/locking/entities/holder.entity';
1011
import { LockingEvent } from '@/domain/locking/entities/locking-event.entity';
1112
import { Rank } from '@/domain/locking/entities/rank.entity';
1213
import { Inject } from '@nestjs/common';
@@ -87,6 +88,28 @@ export class LockingApi implements ILockingApi {
8788
}
8889
}
8990

91+
async getLeaderboardV2(args: {
92+
campaignId: string;
93+
limit?: number;
94+
offset?: number;
95+
}): Promise<Page<Holder>> {
96+
try {
97+
const url = `${this.baseUri}/api/v2/leaderboard/${args.campaignId}`;
98+
const { data } = await this.networkService.get<Page<Holder>>({
99+
url,
100+
networkRequest: {
101+
params: {
102+
limit: args.limit,
103+
offset: args.offset,
104+
},
105+
},
106+
});
107+
return data;
108+
} catch (error) {
109+
throw this.httpErrorFactory.from(error);
110+
}
111+
}
112+
90113
async getLockingHistory(args: {
91114
safeAddress: `0x${string}`;
92115
limit?: number;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Builder, IBuilder } from '@/__tests__/builder';
2+
import { Holder } from '@/domain/locking/entities/holder.entity';
3+
import { faker } from '@faker-js/faker';
4+
import { getAddress } from 'viem';
5+
6+
export function holderBuilder(): IBuilder<Holder> {
7+
return new Builder<Holder>()
8+
.with('holder', getAddress(faker.finance.ethereumAddress()))
9+
.with('position', faker.number.int())
10+
.with('boost', faker.string.numeric())
11+
.with('points', faker.string.numeric())
12+
.with('boostedPoints', faker.string.numeric());
13+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { buildPageSchema } from '@/domain/entities/schemas/page.schema.factory';
2+
import { AddressSchema } from '@/validation/entities/schemas/address.schema';
3+
import { NumericStringSchema } from '@/validation/entities/schemas/numeric-string.schema';
4+
import { z } from 'zod';
5+
6+
export const HolderSchema = z.object({
7+
holder: AddressSchema,
8+
position: z.number(),
9+
boost: NumericStringSchema,
10+
points: NumericStringSchema,
11+
boostedPoints: NumericStringSchema,
12+
});
13+
14+
export const HolderPageSchema = buildPageSchema(HolderSchema);
15+
16+
export type Holder = z.infer<typeof HolderSchema>;
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { holderBuilder } from '@/domain/locking/entities/__tests__/holder.builder';
2+
import { HolderSchema } from '@/domain/locking/entities/holder.entity';
3+
import { faker } from '@faker-js/faker';
4+
import { getAddress } from 'viem';
5+
import { ZodError } from 'zod';
6+
7+
describe('HolderSchema', () => {
8+
it('should validate a valid holder', () => {
9+
const holder = holderBuilder().build();
10+
11+
const result = HolderSchema.safeParse(holder);
12+
13+
expect(result.success).toBe(true);
14+
});
15+
16+
it('should checksum the holder address', () => {
17+
const nonChecksummedAddress = faker.finance
18+
.ethereumAddress()
19+
.toLowerCase() as `0x${string}`;
20+
const holder = holderBuilder()
21+
.with('holder', nonChecksummedAddress)
22+
.build();
23+
24+
const result = HolderSchema.safeParse(holder);
25+
26+
expect(result.success && result.data.holder).toBe(
27+
getAddress(nonChecksummedAddress),
28+
);
29+
});
30+
31+
it('should not validate an invalid holder', () => {
32+
const holder = { invalid: 'holder' };
33+
34+
const result = HolderSchema.safeParse(holder);
35+
36+
expect(!result.success && result.error).toStrictEqual(
37+
new ZodError([
38+
{
39+
code: 'invalid_type',
40+
expected: 'string',
41+
received: 'undefined',
42+
path: ['holder'],
43+
message: 'Required',
44+
},
45+
{
46+
code: 'invalid_type',
47+
expected: 'number',
48+
received: 'undefined',
49+
path: ['position'],
50+
message: 'Required',
51+
},
52+
{
53+
code: 'invalid_type',
54+
expected: 'string',
55+
received: 'undefined',
56+
path: ['boost'],
57+
message: 'Required',
58+
},
59+
{
60+
code: 'invalid_type',
61+
expected: 'string',
62+
received: 'undefined',
63+
path: ['points'],
64+
message: 'Required',
65+
},
66+
{
67+
code: 'invalid_type',
68+
expected: 'string',
69+
received: 'undefined',
70+
path: ['boostedPoints'],
71+
message: 'Required',
72+
},
73+
]),
74+
);
75+
});
76+
});

0 commit comments

Comments
 (0)