Simplified api code, corrected tests

This commit is contained in:
Vítor Vieira 2025-11-28 17:39:31 +00:00
parent 628d7ade76
commit 8929d4cd6e
5 changed files with 47 additions and 150 deletions

View file

@ -1,3 +1,4 @@
import fs from "node:fs";
import { parseArgs } from "node:util"; import { parseArgs } from "node:util";
import { prefetchRates } from "./src/api.js"; import { prefetchRates } from "./src/api.js";
import { Bot } from "./src/bot.js"; import { Bot } from "./src/bot.js";
@ -56,6 +57,10 @@ if (pairs.length === 0 || pairs.some((p) => !p.match(/^[A-Z]+-?[A-Z]+$/))) {
logger.error("Invalid pair format (expected BTC-USD or CNYUSD)"); logger.error("Invalid pair format (expected BTC-USD or CNYUSD)");
process.exit(1); process.exit(1);
} }
if (threshold <= 0 || threshold > 100) {
logger.error("Threshold must be between 0 and 100");
process.exit(1);
}
async function main() { async function main() {
logger.info("Uphold price alert bot starting..."); logger.info("Uphold price alert bot starting...");
@ -72,6 +77,8 @@ async function main() {
process.exit(1); process.exit(1);
} }
fs.promises.writeFile("/tmp/healthy", "ok");
const handleAlert = async (alertData) => { const handleAlert = async (alertData) => {
await insertIntoDB(alertData); await insertIntoDB(alertData);
}; };
@ -91,6 +98,7 @@ async function main() {
const shutdown = async () => { const shutdown = async () => {
logger.info("Shutting down..."); logger.info("Shutting down...");
bots.forEach((b) => b.stop()); bots.forEach((b) => b.stop());
await new Promise((resolve) => setTimeout(resolve, 1000));
await closePool(); await closePool();
process.exit(0); process.exit(0);
}; };

View file

@ -2,32 +2,16 @@ import { BigNumber } from "bignumber.js";
import PQueue from "p-queue"; import PQueue from "p-queue";
import logger from "./logger.js"; import logger from "./logger.js";
const cache = new Map(); const queue = new PQueue({ interval: 1000, intervalCap: 10 });
const inflight = new Map();
const CACHE_TTL = 4500; export async function fetchRate(pair) {
const queue = new PQueue({ interval: 1000, intervalCap: 15 });
export async function fetchRate(pair, forceRefresh = false) {
const key = pair.toUpperCase(); const key = pair.toUpperCase();
if (!forceRefresh) { return queue.add(async () => {
const entry = cache.get(key);
if (entry && Date.now() - entry.ts < CACHE_TTL) {
return entry.price;
}
}
if (inflight.has(key)) {
return inflight.get(key);
}
const taskPromise = queue.add(async () => {
for (let attempt = 1; attempt <= 3; attempt++) { for (let attempt = 1; attempt <= 3; attempt++) {
try { try {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000); const timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(`https://api.uphold.com/v0/ticker/${key}`, { const res = await fetch(`https://api.uphold.com/v0/ticker/${key}`, {
signal: controller.signal, signal: controller.signal,
@ -35,12 +19,8 @@ export async function fetchRate(pair, forceRefresh = false) {
clearTimeout(timeout); clearTimeout(timeout);
if (res.status === 429) { if (res.status === 429) throw new Error("Rate limited");
throw new Error("Rate limited"); if (!res.ok) throw new Error(`HTTP ${res.status}`);
}
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json(); const data = await res.json();
@ -48,46 +28,22 @@ export async function fetchRate(pair, forceRefresh = false) {
throw new Error("Invalid ask price"); throw new Error("Invalid ask price");
} }
const price = new BigNumber(data.ask); return new BigNumber(data.ask);
cache.set(key, { price, ts: Date.now() });
return price;
} catch (err) { } catch (err) {
const isLastAttempt = attempt === 3; clearTimeout(timeout);
if (attempt === 3) throw err;
if (!isLastAttempt) { await new Promise((r) => setTimeout(r, 500 * Math.pow(2, attempt - 1)));
logger.warn(
{ pair: key, attempt, error: err.message },
"Fetch failed, retrying...",
);
await new Promise((r) =>
setTimeout(r, 1000 * Math.pow(2, attempt - 1)),
);
} else {
throw err;
}
} }
} }
}); });
inflight.set(key, taskPromise);
taskPromise
.finally(() => {
inflight.delete(key);
})
.catch(() => {});
return taskPromise;
} }
export async function prefetchRates(pairs) { export async function prefetchRates(pairs) {
logger.info({ pairs }, "Populating cache..."); logger.info({ pairs }, "Verifying pairs...");
const results = await Promise.allSettled(pairs.map((p) => fetchRate(p))); const results = await Promise.allSettled(pairs.map((p) => fetchRate(p)));
const failed = results.filter((r) => r.status === "rejected"); const failed = results.filter((r) => r.status === "rejected");
if (failed.length > 0) { if (failed.length > 0) {
logger.error( logger.warn(`[API] ${failed.length} pairs failed initial check.`);
{ count: failed.length },
"Some currencies failed to prefetch, have to wati for loop",
);
} }
} }

View file

@ -16,7 +16,7 @@ export class Bot {
if (this.running) return; if (this.running) return;
this.running = true; this.running = true;
logger.info( logger.info(
`[${this.pair}] Monitoring started (${this.interval}ms, +/-${this.threshold}%)`, `[${this.pair}] Monitoring every ${this.interval}ms, +/-${this.threshold}%`,
); );
this.loop(); this.loop();
} }

View file

@ -64,9 +64,8 @@ export async function initDB() {
} catch (err) { } catch (err) {
if (i === maxRetries - 1) { if (i === maxRetries - 1) {
logger.error( logger.error(
`[DB] Could not connect: ${err.message}. Continuing without DB.`, `[DB] Could not connect: ${err.message}. Running in memory`,
); );
// Lets not try that again
pool = null; pool = null;
return; return;
} }
@ -107,7 +106,6 @@ export async function insertIntoDB(data) {
await getPool().query(query, values); await getPool().query(query, values);
logger.info(`[DB] Event saved for ${data.pair}`); logger.info(`[DB] Event saved for ${data.pair}`);
} catch (err) { } catch (err) {
// Re-throw so the caller knows there was a failure
logger.error(`[DB] Failed to save alert: ${err.message}`); logger.error(`[DB] Failed to save alert: ${err.message}`);
throw err; throw err;
} }

View file

@ -17,7 +17,7 @@ vi.mock("../src/logger.js", () => ({
}, },
})); }));
describe("Bot Core Functionality", () => { describe("Bot", () => {
const CONFIG = { const CONFIG = {
pair: "BTC-USD", pair: "BTC-USD",
interval: 100, interval: 100,
@ -111,33 +111,6 @@ describe("Bot Core Functionality", () => {
expect(onAlertSpy).toHaveBeenCalledTimes(1); expect(onAlertSpy).toHaveBeenCalledTimes(1);
expect(onAlertSpy.mock.calls[0][0].direction).toBe("DOWN"); expect(onAlertSpy.mock.calls[0][0].direction).toBe("DOWN");
}); });
it("should alert on large price swing", async () => {
fetchRateMock.mockResolvedValue(new BigNumber("51000.00"));
await bot.loop();
expect(onAlertSpy).toHaveBeenCalledTimes(1);
expect(onAlertSpy.mock.calls[0][0].percentChange).toBe("2.0000");
});
});
describe("Consecutive Alerts Logic", () => {
it("should base next alert on last ALERT price, not last CHECKED price", async () => {
fetchRateMock.mockResolvedValue(new BigNumber("50000.00"));
await bot.loop();
fetchRateMock.mockResolvedValue(new BigNumber("50003.00"));
await bot.loop();
expect(onAlertSpy).not.toHaveBeenCalled();
fetchRateMock.mockResolvedValue(new BigNumber("50005.00"));
await bot.loop();
expect(onAlertSpy).toHaveBeenCalledTimes(1);
expect(onAlertSpy.mock.calls[0][0].previousPrice).toBe("50000.000000");
expect(onAlertSpy.mock.calls[0][0].newPrice).toBe("50005.000000");
});
}); });
describe("Error Handling", () => { describe("Error Handling", () => {
@ -145,16 +118,6 @@ describe("Bot Core Functionality", () => {
fetchRateMock.mockRejectedValue(new Error("Network error")); fetchRateMock.mockRejectedValue(new Error("Network error"));
await expect(bot.loop()).resolves.not.toThrow(); await expect(bot.loop()).resolves.not.toThrow();
}); });
it("should handle alert callback errors", async () => {
fetchRateMock.mockResolvedValue(new BigNumber("50000.00"));
await bot.loop();
fetchRateMock.mockResolvedValue(new BigNumber("51000.00"));
onAlertSpy.mockRejectedValue(new Error("DB Connection Lost"));
await expect(bot.loop()).resolves.not.toThrow();
});
}); });
}); });
@ -165,6 +128,7 @@ describe("API", () => {
vi.resetModules(); vi.resetModules();
nock.cleanAll(); nock.cleanAll();
// Import the actual module for these tests
vi.doMock("../src/api.js", async () => { vi.doMock("../src/api.js", async () => {
return await vi.importActual("../src/api.js"); return await vi.importActual("../src/api.js");
}); });
@ -176,60 +140,26 @@ describe("API", () => {
nock.cleanAll(); nock.cleanAll();
}); });
describe("fetchRate Caching", () => { describe("fetchRate", () => {
it("should cache successful responses", async () => { it("should return a BigNumber on success", async () => {
const scope = nock("https://api.uphold.com") const scope = nock("https://api.uphold.com")
.get("/v0/ticker/BTC-USD") .get("/v0/ticker/BTC-USD")
.reply(200, { ask: "60000" }); .reply(200, { ask: "60000" });
const price1 = await api.fetchRate("BTC-USD"); const price = await api.fetchRate("BTC-USD");
expect(price1.toFixed(0)).toBe("60000"); expect(price).toBeInstanceOf(BigNumber);
expect(price.toFixed(0)).toBe("60000");
const price2 = await api.fetchRate("BTC-USD");
expect(price2.toFixed(0)).toBe("60000");
expect(scope.isDone()).toBe(true); expect(scope.isDone()).toBe(true);
}); });
it("should bypass cache if forceRefresh is true", async () => { it("should retry on failure and eventually success", async () => {
const scope = nock("https://api.uphold.com") const scope = nock("https://api.uphold.com")
.get("/v0/ticker/BTC-USD") .get("/v0/ticker/BTC-USD")
.times(2) .replyWithError("Socket Hangup") // Fail 1
.reply(200, { ask: "60000" });
await api.fetchRate("BTC-USD");
await api.fetchRate("BTC-USD", true);
expect(scope.isDone()).toBe(true);
});
});
describe("Request Deduplication", () => {
it("should deduplicate concurrent requests", async () => {
const scope = nock("https://api.uphold.com")
.get("/v0/ticker/BTC-USD") .get("/v0/ticker/BTC-USD")
.delay(100) .reply(200, { ask: "60000" }); // Success 2
.reply(200, { ask: "60000" });
const p1 = api.fetchRate("BTC-USD", true); const price = await api.fetchRate("BTC-USD");
const p2 = api.fetchRate("BTC-USD", true);
const [r1, r2] = await Promise.all([p1, p2]);
expect(r1).toEqual(r2);
expect(scope.isDone()).toBe(true);
});
});
describe("Retry Logic", () => {
it("should retry on failure", async () => {
const scope = nock("https://api.uphold.com")
.get("/v0/ticker/BTC-USD")
.replyWithError("Socket Hangup")
.get("/v0/ticker/BTC-USD")
.reply(200, { ask: "60000" });
const price = await api.fetchRate("BTC-USD", true);
expect(price.toFixed(0)).toBe("60000"); expect(price.toFixed(0)).toBe("60000");
expect(scope.isDone()).toBe(true); expect(scope.isDone()).toBe(true);
}); });
@ -240,14 +170,19 @@ describe("API", () => {
.times(3) .times(3)
.replyWithError("Persistent Fail"); .replyWithError("Persistent Fail");
try { await expect(api.fetchRate("BTC-USD")).rejects.toThrow("Persistent Fail");
await api.fetchRate("BTC-USD", true); expect(scope.isDone()).toBe(true);
expect.fail("Should have thrown error"); });
} catch (err) {
expect(err.message).toContain("Persistent Fail"); it("should handle 429 Rate Limits by retrying and eventually throwing", async () => {
} const scope = nock("https://api.uphold.com")
.get("/v0/ticker/BTC-USD")
// TODO: Find better way to test, takes too long
.times(3)
.reply(429, { error: "Too Many Requests" });
await expect(api.fetchRate("BTC-USD")).rejects.toThrow("Rate limited");
expect(scope.isDone()).toBe(true); expect(scope.isDone()).toBe(true);
}, 10000); });
}); });
}); });