Skip to content

Commit 0470163

Browse files
committed
✨ server: add maturity check and worker
1 parent 41b295e commit 0470163

4 files changed

Lines changed: 528 additions & 2 deletions

File tree

.changeset/dull-candies-cheer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@exactly/server": patch
3+
---
4+
5+
✨ add maturity check and worker

server/index.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import persona from "./hooks/persona";
2121
import androidFingerprints from "./utils/android/fingerprints";
2222
import appOrigin from "./utils/appOrigin";
2323
import { closeQueue as closeAccountQueue } from "./utils/createCredential";
24+
import { closeQueue as closeMaturityQueue, scheduleMaturityChecks } from "./utils/maturity";
2425
import { close as closeRedis } from "./utils/redis";
2526
import { closeAndFlush as closeSegment } from "./utils/segment";
2627

@@ -323,8 +324,15 @@ const server = serve(app);
323324
export async function close() {
324325
return new Promise((resolve, reject) => {
325326
server.close((error) => {
326-
Promise.allSettled([closeSentry(), closeRedis(), closeSegment(), database.$client.end(), closeAccountQueue()])
327-
.then((results) => {
327+
Promise.allSettled([
328+
closeSentry(),
329+
closeSegment(),
330+
database.$client.end(),
331+
closeMaturityQueue(),
332+
closeAccountQueue(),
333+
])
334+
.then(async (results) => {
335+
await closeRedis();
328336
if (error) reject(error);
329337
else if (results.some((result) => result.status === "rejected")) reject(new Error("closing services failed"));
330338
else resolve(null);
@@ -335,6 +343,10 @@ export async function close() {
335343
}
336344

337345
if (!process.env.VITEST) {
346+
scheduleMaturityChecks().catch((error: unknown) => {
347+
captureException(error, { level: "error", tags: { unhandled: true } });
348+
});
349+
338350
["SIGINT", "SIGTERM"].map((code) => {
339351
process.on(code, () => {
340352
close()

server/test/utils/maturity.test.ts

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
import "../mocks/onesignal";
2+
import "../mocks/sentry";
3+
4+
import { Redis } from "ioredis";
5+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
6+
7+
import { previewerAbi, previewerAddress } from "@exactly/common/generated/chain";
8+
import { MATURITY_INTERVAL } from "@exactly/lib";
9+
10+
import { closeQueue, processor, queue, scheduleMaturityChecks } from "../../utils/maturity";
11+
import * as onesignal from "../../utils/onesignal";
12+
import { close as closeRedis } from "../../utils/redis";
13+
14+
import type { CheckDebtsData } from "../../utils/maturity";
15+
import type { Job } from "bullmq";
16+
17+
const mocks = vi.hoisted(() => ({
18+
select: vi.fn(),
19+
readContract: vi.fn(),
20+
}));
21+
22+
vi.mock("../../database", () => ({
23+
default: { select: mocks.select },
24+
credentials: { account: "account" },
25+
}));
26+
27+
vi.mock("../../utils/publicClient", () => ({
28+
default: { readContract: mocks.readContract },
29+
}));
30+
31+
function mockAccounts(accounts: { account: string }[]) {
32+
const offsetMock = vi.fn().mockResolvedValueOnce(accounts).mockResolvedValueOnce([]);
33+
const limitMock = vi.fn().mockReturnValue({ offset: offsetMock });
34+
const orderByMock = vi.fn().mockReturnValue({ limit: limitMock });
35+
const fromMock = vi.fn().mockReturnValue({ orderBy: orderByMock });
36+
mocks.select.mockReturnValue({ from: fromMock });
37+
}
38+
39+
function mockExactly(
40+
result: { fixedBorrowPositions: { maturity: bigint; position: { fee: bigint; principal: bigint } }[] }[],
41+
) {
42+
mocks.readContract.mockResolvedValue(result);
43+
}
44+
45+
let testRedis: Redis;
46+
47+
describe("worker", () => {
48+
const sendPushNotification = vi.spyOn(onesignal, "sendPushNotification");
49+
50+
beforeAll(() => {
51+
if (!process.env.REDIS_URL) throw new Error("missing REDIS_URL");
52+
testRedis = new Redis(process.env.REDIS_URL);
53+
});
54+
55+
afterAll(async () => {
56+
await closeQueue();
57+
await closeRedis();
58+
await testRedis.quit();
59+
});
60+
61+
beforeEach(async () => {
62+
vi.clearAllMocks();
63+
await testRedis.flushdb();
64+
});
65+
66+
it("schedules maturity checks", async () => {
67+
await scheduleMaturityChecks();
68+
const jobs = await queue.getJobs(["delayed", "waiting"]);
69+
expect(jobs).toHaveLength(2);
70+
expect(jobs.map((index) => (index.data as CheckDebtsData).window).toSorted()).toStrictEqual(["1h", "24h"]);
71+
});
72+
73+
it("processes check-debts job with debt in single market", async () => {
74+
const account = "0x1234567890123456789012345678901234567890";
75+
const maturity = 1_234_567_890;
76+
const window = "24h";
77+
78+
mockAccounts([{ account }]);
79+
mockExactly([{ fixedBorrowPositions: [{ maturity: BigInt(maturity), position: { principal: 100n, fee: 0n } }] }]);
80+
81+
await processor({
82+
name: "check-debts",
83+
data: { maturity, window },
84+
} as unknown as Job<CheckDebtsData, unknown>);
85+
86+
expect(mocks.readContract).toHaveBeenCalledWith({
87+
address: previewerAddress,
88+
abi: previewerAbi,
89+
functionName: "exactly",
90+
args: [account],
91+
});
92+
const key = `notification:sent:${account}:${String(maturity)}:${window}`;
93+
const value = await testRedis.get(key);
94+
expect(value).not.toBeNull();
95+
const ttl = await testRedis.ttl(key);
96+
expect(ttl).toBeGreaterThan(0);
97+
expect(ttl).toBeLessThanOrEqual(86_400);
98+
expect(sendPushNotification).toHaveBeenCalledWith({
99+
userId: account,
100+
headings: { en: "Debt Maturity Alert" },
101+
contents: { en: "Your debt is due in 24 hours. Repay now to avoid liquidation." },
102+
});
103+
});
104+
105+
it("handles duplicate notification", async () => {
106+
const account = "0x1234567890123456789012345678901234567890";
107+
const maturity = 1_234_567_890;
108+
const window = "24h";
109+
110+
mockAccounts([{ account }]);
111+
await testRedis.set(`notification:sent:${account}:${String(maturity)}:${window}`, String(Date.now()), "EX", 86_400);
112+
113+
mockExactly([{ fixedBorrowPositions: [{ maturity: BigInt(maturity), position: { principal: 100n, fee: 0n } }] }]);
114+
115+
await processor({
116+
name: "check-debts",
117+
data: { maturity, window },
118+
} as unknown as Job<CheckDebtsData, unknown>);
119+
120+
expect(sendPushNotification).not.toHaveBeenCalled();
121+
});
122+
123+
it("handles position with principal = 0", async () => {
124+
const account = "0x1234567890123456789012345678901234567890";
125+
const maturity = 1_234_567_890;
126+
127+
mockAccounts([{ account }]);
128+
mockExactly([{ fixedBorrowPositions: [{ maturity: BigInt(maturity), position: { principal: 0n, fee: 0n } }] }]);
129+
130+
await processor({
131+
name: "check-debts",
132+
data: { maturity, window: "24h" },
133+
} as unknown as Job<CheckDebtsData, unknown>);
134+
135+
const keys = await testRedis.keys("notification:sent:*");
136+
expect(keys).toHaveLength(0);
137+
expect(sendPushNotification).not.toHaveBeenCalled();
138+
});
139+
140+
it("handles account with no debt in any market", async () => {
141+
const account = "0x1234567890123456789012345678901234567890";
142+
const maturity = 1_234_567_890;
143+
144+
mockAccounts([{ account }]);
145+
mockExactly([{ fixedBorrowPositions: [] }, { fixedBorrowPositions: [] }]);
146+
147+
await processor({
148+
name: "check-debts",
149+
data: { maturity, window: "24h" },
150+
} as unknown as Job<CheckDebtsData, unknown>);
151+
152+
const keys = await testRedis.keys("notification:sent:*");
153+
expect(keys).toHaveLength(0);
154+
expect(sendPushNotification).not.toHaveBeenCalled();
155+
});
156+
157+
it("detects debt across any market", async () => {
158+
const account = "0x1234567890123456789012345678901234567890";
159+
const maturity = 1_234_567_890;
160+
const window = "24h";
161+
162+
mockAccounts([{ account }]);
163+
mockExactly([
164+
{ fixedBorrowPositions: [] },
165+
{ fixedBorrowPositions: [] },
166+
{ fixedBorrowPositions: [{ maturity: BigInt(maturity), position: { principal: 50n, fee: 0n } }] },
167+
]);
168+
169+
await processor({
170+
name: "check-debts",
171+
data: { maturity, window },
172+
} as unknown as Job<CheckDebtsData, unknown>);
173+
174+
const key = `notification:sent:${account}:${String(maturity)}:${window}`;
175+
const value = await testRedis.get(key);
176+
expect(value).not.toBeNull();
177+
expect(sendPushNotification).toHaveBeenCalledWith({
178+
userId: account,
179+
headings: { en: "Debt Maturity Alert" },
180+
contents: { en: "Your debt is due in 24 hours. Repay now to avoid liquidation." },
181+
});
182+
});
183+
184+
it("handles exactly() call failure", async () => {
185+
const { captureException } = await import("@sentry/node");
186+
const account = "0x1234567890123456789012345678901234567890";
187+
const maturity = 1_234_567_890;
188+
189+
mockAccounts([{ account }]);
190+
mocks.readContract.mockRejectedValue(new Error("rpc error"));
191+
192+
await processor({
193+
name: "check-debts",
194+
data: { maturity, window: "24h" },
195+
} as unknown as Job<CheckDebtsData, unknown>);
196+
197+
expect(captureException).toHaveBeenCalledWith(expect.any(Error), { extra: { account } });
198+
const keys = await testRedis.keys("notification:sent:*");
199+
expect(keys).toHaveLength(0);
200+
expect(sendPushNotification).not.toHaveBeenCalled();
201+
});
202+
203+
it("does not set dedupe key when push notification fails", async () => {
204+
const account = "0x1234567890123456789012345678901234567890";
205+
const maturity = 1_234_567_890;
206+
const window = "24h";
207+
const { captureException } = await import("@sentry/node");
208+
209+
mockAccounts([{ account }]);
210+
mockExactly([{ fixedBorrowPositions: [{ maturity: BigInt(maturity), position: { principal: 100n, fee: 0n } }] }]);
211+
sendPushNotification.mockRejectedValueOnce(new Error("onesignal error"));
212+
213+
await processor({
214+
name: "check-debts",
215+
data: { maturity, window },
216+
} as unknown as Job<CheckDebtsData, unknown>);
217+
218+
expect(sendPushNotification).toHaveBeenCalledOnce();
219+
const key = `notification:sent:${account}:${String(maturity)}:${window}`;
220+
const value = await testRedis.get(key);
221+
expect(value).toBeNull();
222+
expect(captureException).toHaveBeenCalledWith(expect.any(Error), { level: "error" });
223+
});
224+
225+
it("skips stale window jobs on schedule", async () => {
226+
const now = Math.floor(Date.now() / 1000);
227+
const nextMaturity = now - (now % MATURITY_INTERVAL) + MATURITY_INTERVAL;
228+
vi.spyOn(Date, "now").mockReturnValue((nextMaturity + 1) * 1000);
229+
try {
230+
await scheduleMaturityChecks(nextMaturity - MATURITY_INTERVAL);
231+
const jobs = await queue.getJobs(["delayed", "waiting"]);
232+
const maturityJobs = jobs.filter((index) => (index.data as CheckDebtsData).maturity === nextMaturity);
233+
expect(maturityJobs).toHaveLength(0);
234+
} finally {
235+
vi.mocked(Date.now).mockRestore();
236+
}
237+
});
238+
239+
it("does not fail job when scheduleMaturityChecks throws in finally", async () => {
240+
const { captureException } = await import("@sentry/node");
241+
const now = Math.floor(Date.now() / 1000);
242+
const jobMaturity = now - (now % MATURITY_INTERVAL) + MATURITY_INTERVAL;
243+
244+
const offsetMock = vi.fn().mockResolvedValueOnce([]);
245+
const limitMock = vi.fn().mockReturnValue({ offset: offsetMock });
246+
const orderByMock = vi.fn().mockReturnValue({ limit: limitMock });
247+
const fromMock = vi.fn().mockReturnValue({ orderBy: orderByMock });
248+
mocks.select.mockReturnValue({ from: fromMock });
249+
250+
const addSpy = vi.spyOn(queue, "add").mockRejectedValue(new Error("redis down"));
251+
252+
await expect(
253+
processor({
254+
name: "check-debts",
255+
data: { maturity: jobMaturity, window: "1h" },
256+
} as unknown as Job<CheckDebtsData, unknown>),
257+
).resolves.not.toThrow();
258+
259+
expect(captureException).toHaveBeenCalledWith(expect.any(Error), { level: "fatal" });
260+
addSpy.mockRestore();
261+
});
262+
263+
it("should throw an error for unknown job names", async () => {
264+
const job = { name: "unknown", data: {} } as unknown as Job<CheckDebtsData>;
265+
await expect(processor(job)).rejects.toThrow("Unknown job name: unknown");
266+
});
267+
268+
it("reschedules on 1h window", async () => {
269+
const now = Math.floor(Date.now() / 1000);
270+
const jobMaturity = now - (now % MATURITY_INTERVAL) + MATURITY_INTERVAL;
271+
272+
const offsetMock = vi.fn().mockResolvedValueOnce([]);
273+
const limitMock = vi.fn().mockReturnValue({ offset: offsetMock });
274+
const orderByMock = vi.fn().mockReturnValue({ limit: limitMock });
275+
const fromMock = vi.fn().mockReturnValue({ orderBy: orderByMock });
276+
mocks.select.mockReturnValue({ from: fromMock });
277+
278+
await processor({
279+
name: "check-debts",
280+
data: { maturity: jobMaturity, window: "1h" },
281+
} as unknown as Job<CheckDebtsData, unknown>);
282+
283+
const expectedNextMaturity = jobMaturity + MATURITY_INTERVAL;
284+
const jobs = await queue.getJobs(["delayed", "waiting"]);
285+
expect(jobs).toHaveLength(2);
286+
const windows = jobs.map((index) => {
287+
const data = index.data as CheckDebtsData;
288+
return { maturity: data.maturity, window: data.window };
289+
});
290+
expect(windows).toContainEqual({ maturity: expectedNextMaturity, window: "24h" });
291+
expect(windows).toContainEqual({ maturity: expectedNextMaturity, window: "1h" });
292+
});
293+
});

0 commit comments

Comments
 (0)