Skip to content

Commit 365c996

Browse files
authored
ENS Referrals API Versioning (#1554)
1 parent 220b71f commit 365c996

File tree

5 files changed

+510
-3
lines changed

5 files changed

+510
-3
lines changed

.changeset/tough-phones-cry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ensapi": minor
3+
---
4+
5+
Implemented API versioning for ENSAnalytics referral endpoints. Introduced explicit `/ensanalytics/v1/*` routes while preserving existing `/ensanalytics/*` routes as implicit v0.
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
import { testClient } from "hono/testing";
2+
import { describe, expect, it, vi } from "vitest"; // Or your preferred test runner
3+
4+
import { ENSNamespaceIds } from "@ensnode/datasources";
5+
6+
import type { EnsApiConfig } from "@/config/config.schema";
7+
8+
import * as middleware from "../middleware/referrer-leaderboard.middleware";
9+
10+
vi.mock("@/config", () => ({
11+
get default() {
12+
const mockedConfig: Pick<EnsApiConfig, "ensIndexerUrl" | "namespace"> = {
13+
ensIndexerUrl: new URL("https://ensnode.example.com"),
14+
namespace: ENSNamespaceIds.Mainnet,
15+
};
16+
17+
return mockedConfig;
18+
},
19+
}));
20+
21+
vi.mock("../middleware/referrer-leaderboard.middleware", () => ({
22+
referrerLeaderboardMiddleware: vi.fn(),
23+
}));
24+
25+
import {
26+
deserializeReferrerDetailResponse,
27+
deserializeReferrerLeaderboardPageResponse,
28+
ReferrerDetailResponseCodes,
29+
type ReferrerDetailResponseOk,
30+
ReferrerDetailTypeIds,
31+
ReferrerLeaderboardPageResponseCodes,
32+
type ReferrerLeaderboardPageResponseOk,
33+
} from "@namehash/ens-referrals";
34+
35+
import {
36+
emptyReferralLeaderboard,
37+
populatedReferrerLeaderboard,
38+
referrerLeaderboardPageResponseOk,
39+
} from "@/lib/ensanalytics/referrer-leaderboard/mocks";
40+
41+
import app from "./ensanalytics-api-v1";
42+
43+
describe("/ensanalytics/v1", () => {
44+
describe("/referrers", () => {
45+
it("returns requested records when referrer leaderboard has multiple pages of data", async () => {
46+
// Arrange: set `referrerLeaderboard` context var
47+
vi.mocked(middleware.referrerLeaderboardMiddleware).mockImplementation(async (c, next) => {
48+
c.set("referrerLeaderboard", populatedReferrerLeaderboard);
49+
return await next();
50+
});
51+
52+
// Arrange: all possible referrers on a single page response
53+
const allPossibleReferrers = referrerLeaderboardPageResponseOk.data.referrers;
54+
const allPossibleReferrersIterator = allPossibleReferrers[Symbol.iterator]();
55+
56+
// Arrange: create the test client from the app instance
57+
const client = testClient(app);
58+
const recordsPerPage = 10;
59+
60+
// Act: send test request to fetch 1st page
61+
const responsePage1 = await client.referrers
62+
.$get({ query: { recordsPerPage: `${recordsPerPage}`, page: "1" } }, {})
63+
.then((r) => r.json())
64+
.then(deserializeReferrerLeaderboardPageResponse);
65+
66+
// Act: send test request to fetch 2nd page
67+
const responsePage2 = await client.referrers
68+
.$get({ query: { recordsPerPage: `${recordsPerPage}`, page: "2" } }, {})
69+
.then((r) => r.json())
70+
.then(deserializeReferrerLeaderboardPageResponse);
71+
72+
// Act: send test request to fetch 3rd page
73+
const responsePage3 = await client.referrers
74+
.$get({ query: { recordsPerPage: `${recordsPerPage}`, page: "3" } }, {})
75+
.then((r) => r.json())
76+
.then(deserializeReferrerLeaderboardPageResponse);
77+
78+
// Assert: 1st page results
79+
const expectedResponsePage1 = {
80+
responseCode: ReferrerLeaderboardPageResponseCodes.Ok,
81+
data: {
82+
...populatedReferrerLeaderboard,
83+
pageContext: {
84+
endIndex: 9,
85+
hasNext: true,
86+
hasPrev: false,
87+
recordsPerPage: 10,
88+
page: 1,
89+
startIndex: 0,
90+
totalPages: 3,
91+
totalRecords: 29,
92+
},
93+
referrers: allPossibleReferrersIterator.take(recordsPerPage).toArray(),
94+
},
95+
} satisfies ReferrerLeaderboardPageResponseOk;
96+
97+
expect(responsePage1).toMatchObject(expectedResponsePage1);
98+
99+
// Assert: 2nd page results
100+
const expectedResponsePage2 = {
101+
responseCode: ReferrerLeaderboardPageResponseCodes.Ok,
102+
data: {
103+
...populatedReferrerLeaderboard,
104+
pageContext: {
105+
endIndex: 19,
106+
hasNext: true,
107+
hasPrev: true,
108+
recordsPerPage: 10,
109+
page: 2,
110+
startIndex: 10,
111+
totalPages: 3,
112+
totalRecords: 29,
113+
},
114+
referrers: allPossibleReferrersIterator.take(recordsPerPage).toArray(),
115+
},
116+
} satisfies ReferrerLeaderboardPageResponseOk;
117+
expect(responsePage2).toMatchObject(expectedResponsePage2);
118+
119+
// Assert: 3rd page results
120+
const expectedResponsePage3 = {
121+
responseCode: ReferrerLeaderboardPageResponseCodes.Ok,
122+
data: {
123+
...populatedReferrerLeaderboard,
124+
pageContext: {
125+
endIndex: 28,
126+
hasNext: false,
127+
hasPrev: true,
128+
recordsPerPage: 10,
129+
page: 3,
130+
startIndex: 20,
131+
totalPages: 3,
132+
totalRecords: 29,
133+
},
134+
referrers: allPossibleReferrersIterator.take(recordsPerPage).toArray(),
135+
},
136+
} satisfies ReferrerLeaderboardPageResponseOk;
137+
expect(responsePage3).toMatchObject(expectedResponsePage3);
138+
});
139+
140+
it("returns empty cached referrer leaderboard when there are no referrals yet", async () => {
141+
// Arrange: set `referrerLeaderboard` context var
142+
vi.mocked(middleware.referrerLeaderboardMiddleware).mockImplementation(async (c, next) => {
143+
c.set("referrerLeaderboard", emptyReferralLeaderboard);
144+
return await next();
145+
});
146+
147+
// Arrange: create the test client from the app instance
148+
const client = testClient(app);
149+
const recordsPerPage = 10;
150+
151+
// Act: send test request to fetch 1st page
152+
const response = await client.referrers
153+
.$get({ query: { recordsPerPage: `${recordsPerPage}`, page: "1" } }, {})
154+
.then((r) => r.json())
155+
.then(deserializeReferrerLeaderboardPageResponse);
156+
157+
// Assert: empty page results
158+
const expectedResponse = {
159+
responseCode: ReferrerLeaderboardPageResponseCodes.Ok,
160+
data: {
161+
...emptyReferralLeaderboard,
162+
pageContext: {
163+
hasNext: false,
164+
hasPrev: false,
165+
recordsPerPage: 10,
166+
page: 1,
167+
totalPages: 1,
168+
totalRecords: 0,
169+
},
170+
referrers: [],
171+
},
172+
} satisfies ReferrerLeaderboardPageResponseOk;
173+
174+
expect(response).toMatchObject(expectedResponse);
175+
});
176+
});
177+
178+
describe("/referrers/:referrer", () => {
179+
it("returns referrer metrics when referrer exists in leaderboard", async () => {
180+
// Arrange: set `referrerLeaderboard` context var with populated leaderboard
181+
vi.mocked(middleware.referrerLeaderboardMiddleware).mockImplementation(async (c, next) => {
182+
c.set("referrerLeaderboard", populatedReferrerLeaderboard);
183+
return await next();
184+
});
185+
186+
// Arrange: use a referrer address that exists in the leaderboard (rank 1)
187+
const existingReferrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e";
188+
const expectedMetrics = populatedReferrerLeaderboard.referrers.get(existingReferrer)!;
189+
const expectedAccurateAsOf = populatedReferrerLeaderboard.accurateAsOf;
190+
191+
// Act: send test request to fetch referrer detail
192+
const httpResponse = await app.request(`/referrers/${existingReferrer}`);
193+
const responseData = await httpResponse.json();
194+
const response = deserializeReferrerDetailResponse(responseData);
195+
196+
// Assert: response contains the expected referrer metrics
197+
const expectedResponse = {
198+
responseCode: ReferrerDetailResponseCodes.Ok,
199+
data: {
200+
type: ReferrerDetailTypeIds.Ranked,
201+
rules: populatedReferrerLeaderboard.rules,
202+
referrer: expectedMetrics,
203+
aggregatedMetrics: populatedReferrerLeaderboard.aggregatedMetrics,
204+
accurateAsOf: expectedAccurateAsOf,
205+
},
206+
} satisfies ReferrerDetailResponseOk;
207+
208+
expect(response).toMatchObject(expectedResponse);
209+
});
210+
211+
it("returns zero-score metrics when referrer does not exist in leaderboard", async () => {
212+
// Arrange: set `referrerLeaderboard` context var with populated leaderboard
213+
vi.mocked(middleware.referrerLeaderboardMiddleware).mockImplementation(async (c, next) => {
214+
c.set("referrerLeaderboard", populatedReferrerLeaderboard);
215+
return await next();
216+
});
217+
218+
// Arrange: use a referrer address that does NOT exist in the leaderboard
219+
const nonExistingReferrer = "0x0000000000000000000000000000000000000099";
220+
221+
// Act: send test request to fetch referrer detail
222+
const httpResponse = await app.request(`/referrers/${nonExistingReferrer}`);
223+
const responseData = await httpResponse.json();
224+
const response = deserializeReferrerDetailResponse(responseData);
225+
226+
// Assert: response contains zero-score metrics for the referrer
227+
// Rank should be null since they're not on the leaderboard
228+
const expectedAccurateAsOf = populatedReferrerLeaderboard.accurateAsOf;
229+
230+
expect(response.responseCode).toBe(ReferrerDetailResponseCodes.Ok);
231+
if (response.responseCode === ReferrerDetailResponseCodes.Ok) {
232+
expect(response.data.type).toBe(ReferrerDetailTypeIds.Unranked);
233+
expect(response.data.rules).toEqual(populatedReferrerLeaderboard.rules);
234+
expect(response.data.aggregatedMetrics).toEqual(
235+
populatedReferrerLeaderboard.aggregatedMetrics,
236+
);
237+
expect(response.data.referrer.referrer).toBe(nonExistingReferrer);
238+
expect(response.data.referrer.rank).toBe(null);
239+
expect(response.data.referrer.totalReferrals).toBe(0);
240+
expect(response.data.referrer.totalIncrementalDuration).toBe(0);
241+
expect(response.data.referrer.score).toBe(0);
242+
expect(response.data.referrer.isQualified).toBe(false);
243+
expect(response.data.referrer.finalScoreBoost).toBe(0);
244+
expect(response.data.referrer.finalScore).toBe(0);
245+
expect(response.data.referrer.awardPoolShare).toBe(0);
246+
expect(response.data.referrer.awardPoolApproxValue).toBe(0);
247+
expect(response.data.accurateAsOf).toBe(expectedAccurateAsOf);
248+
}
249+
});
250+
251+
it("returns zero-score metrics when leaderboard is empty", async () => {
252+
// Arrange: set `referrerLeaderboard` context var with empty leaderboard
253+
vi.mocked(middleware.referrerLeaderboardMiddleware).mockImplementation(async (c, next) => {
254+
c.set("referrerLeaderboard", emptyReferralLeaderboard);
255+
return await next();
256+
});
257+
258+
// Arrange: use any referrer address
259+
const referrer = "0x0000000000000000000000000000000000000001";
260+
261+
// Act: send test request to fetch referrer detail
262+
const httpResponse = await app.request(`/referrers/${referrer}`);
263+
const responseData = await httpResponse.json();
264+
const response = deserializeReferrerDetailResponse(responseData);
265+
266+
// Assert: response contains zero-score metrics for the referrer
267+
// Rank should be null since they're not on the leaderboard
268+
const expectedAccurateAsOf = emptyReferralLeaderboard.accurateAsOf;
269+
270+
expect(response.responseCode).toBe(ReferrerDetailResponseCodes.Ok);
271+
if (response.responseCode === ReferrerDetailResponseCodes.Ok) {
272+
expect(response.data.type).toBe(ReferrerDetailTypeIds.Unranked);
273+
expect(response.data.rules).toEqual(emptyReferralLeaderboard.rules);
274+
expect(response.data.aggregatedMetrics).toEqual(emptyReferralLeaderboard.aggregatedMetrics);
275+
expect(response.data.referrer.referrer).toBe(referrer);
276+
expect(response.data.referrer.rank).toBe(null);
277+
expect(response.data.referrer.totalReferrals).toBe(0);
278+
expect(response.data.referrer.totalIncrementalDuration).toBe(0);
279+
expect(response.data.referrer.score).toBe(0);
280+
expect(response.data.referrer.isQualified).toBe(false);
281+
expect(response.data.referrer.finalScoreBoost).toBe(0);
282+
expect(response.data.referrer.finalScore).toBe(0);
283+
expect(response.data.referrer.awardPoolShare).toBe(0);
284+
expect(response.data.referrer.awardPoolApproxValue).toBe(0);
285+
expect(response.data.accurateAsOf).toBe(expectedAccurateAsOf);
286+
}
287+
});
288+
289+
it("returns error response when leaderboard fails to load", async () => {
290+
// Arrange: set `referrerLeaderboard` context var with rejected promise
291+
vi.mocked(middleware.referrerLeaderboardMiddleware).mockImplementation(async (c, next) => {
292+
c.set("referrerLeaderboard", new Error("Database connection failed"));
293+
return await next();
294+
});
295+
296+
// Arrange: use any referrer address
297+
const referrer = "0x538e35b2888ed5bc58cf2825d76cf6265aa4e31e";
298+
299+
// Act: send test request to fetch referrer detail
300+
const httpResponse = await app.request(`/referrers/${referrer}`);
301+
const responseData = await httpResponse.json();
302+
const response = deserializeReferrerDetailResponse(responseData);
303+
304+
// Assert: response contains error
305+
expect(response.responseCode).toBe(ReferrerDetailResponseCodes.Error);
306+
if (response.responseCode === ReferrerDetailResponseCodes.Error) {
307+
expect(response.error).toBe("Service Unavailable");
308+
expect(response.errorMessage).toBe(
309+
"Referrer leaderboard data has not been successfully cached yet.",
310+
);
311+
}
312+
});
313+
});
314+
});

0 commit comments

Comments
 (0)