Better error handling, caching and deduplication. Added tests to api and db logic

This commit is contained in:
Vítor Vieira 2025-11-28 15:07:10 +00:00
parent f4de1c7bc2
commit 628d7ade76
9 changed files with 510 additions and 226 deletions

5
.env
View file

@ -1,3 +1,6 @@
PAIRS=BTC-USD,ETH-USD,XRP-USD PAIRS=BTC-USD,ETH-USD,XRP-USD,BTC-EUR,ETH-EUR,CNYUSD
INTERVAL=5000 INTERVAL=5000
THRESHOLD=0.05 THRESHOLD=0.05
POSTGRES_USER=uphold
POSTGRES_PASSWORD=uphold
POSTGRES_DB=uphold_events

View file

@ -1,12 +1,12 @@
FROM docker.io/node:20-alpine FROM docker.io/node:20-alpine
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
# RUN npm ci --omit=dev
RUN npm ci
COPY src/ ./src/
COPY index.js ./
RUN npm ci --omit=dev RUN addgroup -g 1001 nodejs && adduser -S -G nodejs -u 1001 nodejs
USER nodejs
COPY . .
ENTRYPOINT ["node", "index.js"] ENTRYPOINT ["node", "index.js"]
CMD ["--pairs", "BTC-USD"]

View file

@ -5,17 +5,19 @@ services:
db: db:
condition: service_healthy condition: service_healthy
environment: environment:
- DATABASE_URL=postgres://uphold:uphold_password@db:5432/uphold_alerts - DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
- PAIRS=${PAIRS:-BTC-USD,ETH-USD} - PAIRS=${PAIRS:-BTC-USD}
- INTERVAL=${INTERVAL:-5000} - INTERVAL=${INTERVAL:-5000}
- THRESHOLD=${THRESHOLD:-0.01} - THRESHOLD=${THRESHOLD:-0.01}
command: ["--pairs", "${PAIRS:-BTC-USD,ETH-USD}", "--interval", "${INTERVAL:-5000}", "--threshold", "${THRESHOLD:-0.01}"] healthcheck:
test: ["CMD", "node", "-e", "require('node:process').exit(require('fs').existsSync('/tmp/healthy') ? 0 : 1)"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
db: db:
image: docker.io/postgres:15-alpine image: docker.io/postgres:15-alpine
environment: env_file: ".env"
- POSTGRES_USER=uphold
- POSTGRES_PASSWORD=uphold_password
- POSTGRES_DB=uphold_alerts
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
healthcheck: healthcheck:

View file

@ -1,22 +1,39 @@
import { parseArgs } from "node:util"; import { parseArgs } from "node:util";
import { prefetchRates } from "./src/api.js";
import { Bot } from "./src/bot.js"; import { Bot } from "./src/bot.js";
import { initDB } from "./src/db.js"; import logger from "./src/logger.js";
import { initDB, insertIntoDB, closePool } from "./src/db.js";
process.on("uncaughtException", (err) => {
logger.fatal(err, "Uncaught exception. Application crashing...");
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
logger.fatal(reason, "Unhandled promise Rejection");
process.exit(1);
});
const options = { const options = {
pairs: { pairs: {
type: "string", type: "string",
short: "p", short: "p",
default: "BTC-USD", default: process.env.PAIRS || "BTC-USD",
}, },
interval: { interval: {
type: "string", type: "string",
short: "i", short: "i",
default: "5000", default: process.env.INTERVAL || "5000",
}, },
threshold: { threshold: {
type: "string", type: "string",
short: "t", short: "t",
default: "0.01", default: process.env.THRESHOLD || "0.01",
},
stats: {
type: "boolean",
default: process.env.STATS === "true",
description: "Show performance stats periodically",
}, },
}; };
@ -31,36 +48,58 @@ const pairs = values.pairs.split(",").map((p) => p.trim());
const interval = parseInt(values.interval, 10); const interval = parseInt(values.interval, 10);
const threshold = parseFloat(values.threshold); const threshold = parseFloat(values.threshold);
// Basic validation if (isNaN(interval) || interval < 100) {
if (isNaN(interval) || interval < 1000) { logger.error("Interval must be a number larger than 100ms");
console.error("Error: Interval must be a number >= 1000ms"); process.exit(1);
}
if (pairs.length === 0 || pairs.some((p) => !p.match(/^[A-Z]+-?[A-Z]+$/))) {
logger.error("Invalid pair format (expected BTC-USD or CNYUSD)");
process.exit(1); process.exit(1);
} }
async function run() { async function main() {
console.log("Starting Uphold interview bot..."); logger.info("Uphold price alert bot starting...");
console.log( logger.info(
`Configuration: Pairs=[${pairs.join(", ")}] Interval=${interval}ms Threshold=${threshold}%`, `[WATCHING] ${pairs.join(", ")} | Every ${interval}ms | Threshold ${threshold}%`,
); );
if (process.env.DATABASE_URL) {
await initDB(); await initDB();
} else {
console.warn("[Warning] No DATABASE_URL found. Alerts will NOT be saved."); try {
await prefetchRates(pairs);
} catch (err) {
logger.error(err, "Critical failure during cache warming");
process.exit(1);
} }
const bots = pairs.map((pair) => new Bot(pair, interval, threshold)); const handleAlert = async (alertData) => {
await insertIntoDB(alertData);
};
const bots = pairs.map((pair) => {
return new Bot(
{
pair,
interval,
threshold,
},
handleAlert,
);
});
bots.forEach((b) => b.start()); bots.forEach((b) => b.start());
process.on("SIGINT", () => { const shutdown = async () => {
console.log("\nShutting down..."); logger.info("Shutting down...");
bots.forEach((bot) => bot.stop()); bots.forEach((b) => b.stop());
await closePool();
process.exit(0); process.exit(0);
}); };
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
} }
run().catch((err) => { main().catch((err) => {
console.error("Fatal Error:", err); logger.fatal(err, "Fatal error in main loop");
process.exit(1); process.exit(1);
}); });

View file

@ -13,10 +13,14 @@
"description": "Technical project for Backend Engineer role at Uphold", "description": "Technical project for Backend Engineer role at Uphold",
"dependencies": { "dependencies": {
"bignumber.js": "^9.3.1", "bignumber.js": "^9.3.1",
"pg": "^8.16.3" "p-queue": "^9.0.1",
"pg": "^8.16.3",
"pino": "^10.1.0",
"quick-lru": "^7.3.0"
}, },
"devDependencies": { "devDependencies": {
"nock": "^14.0.10", "nock": "^14.0.10",
"pino-pretty": "^13.1.2",
"vitest": "^4.0.14" "vitest": "^4.0.14"
} }
} }

View file

@ -1,16 +1,93 @@
import { BigNumber } from "bignumber.js"; import { BigNumber } from "bignumber.js";
import PQueue from "p-queue";
import logger from "./logger.js";
export async function fetchRate(pair) { const cache = new Map();
const url = `https://api.uphold.com/v0/ticker/${pair}`; const inflight = new Map();
const response = await fetch(url);
if (!response.ok) { const CACHE_TTL = 4500;
throw new Error(`Response status ${response.status}`);
const queue = new PQueue({ interval: 1000, intervalCap: 15 });
export async function fetchRate(pair, forceRefresh = false) {
const key = pair.toUpperCase();
if (!forceRefresh) {
const entry = cache.get(key);
if (entry && Date.now() - entry.ts < CACHE_TTL) {
return entry.price;
}
} }
const data = await response.json(); if (inflight.has(key)) {
return inflight.get(key);
}
const taskPromise = queue.add(async () => {
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
const res = await fetch(`https://api.uphold.com/v0/ticker/${key}`, {
signal: controller.signal,
});
clearTimeout(timeout);
if (res.status === 429) {
throw new Error("Rate limited");
}
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
if (!data.ask || isNaN(parseFloat(data.ask))) { if (!data.ask || isNaN(parseFloat(data.ask))) {
throw new Error("Invalid data format"); throw new Error("Invalid ask price");
} }
return new BigNumber(data.ask); const price = new BigNumber(data.ask);
cache.set(key, { price, ts: Date.now() });
return price;
} catch (err) {
const isLastAttempt = attempt === 3;
if (!isLastAttempt) {
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) {
logger.info({ pairs }, "Populating cache...");
const results = await Promise.allSettled(pairs.map((p) => fetchRate(p)));
const failed = results.filter((r) => r.status === "rejected");
if (failed.length > 0) {
logger.error(
{ count: failed.length },
"Some currencies failed to prefetch, have to wati for loop",
);
}
} }

View file

@ -1,82 +1,87 @@
import { fetchRate } from "./api.js"; import { fetchRate } from "./api.js";
import { insertIntoDB } from "./db.js"; import logger from "./logger.js";
export class Bot { export class Bot {
constructor(pair, interval, threshold) { constructor(config, onAlert) {
this.pair = pair; this.pair = config.pair;
this.interval = interval; this.interval = config.interval;
this.threshold = threshold; this.threshold = config.threshold;
this.onAlert = onAlert || (async () => {});
this.lastPrice = null; this.lastPrice = null;
this.running = null;
this.timer = null; this.timer = null;
this.running = false;
} }
start() { start() {
if (!this.running) { if (this.running) return;
this.running = true; this.running = true;
logger.info(
console.log(`[STARTED] Monitoring ${this.pair}`); `[${this.pair}] Monitoring started (${this.interval}ms, +/-${this.threshold}%)`,
this.check().catch((err) =>
console.error(
`[ERROR] Initialization failed for ${this.pair}: ${err.message}`,
),
); );
this.loop();
this.timer = setInterval(async () => {
try {
await this.check();
} catch (err) {
console.error(`[ERROR] ${this.pair}: ${err.message}`);
}
}, this.interval);
}
} }
stop() { stop() {
this.isRunning = false; if (!this.running) return;
if (this.timer) { this.running = false;
clearInterval(this.timer); if (this.timer) clearTimeout(this.timer);
} logger.info(`[${this.pair}] Stopped`);
} }
async check() { loop = async () => {
const currentPrice = await fetchRate(this.pair); try {
const price = await fetchRate(this.pair);
if (!this.lastPrice) { if (!this.lastPrice) {
this.lastPrice = currentPrice; this.lastPrice = price;
console.log(`[INIT] ${this.pair} set to ${currentPrice.toFixed(2)}`); logger.info(`[${this.pair}] Initial price: ${price.toFixed(2)}`);
return; } else {
} const change = price
.minus(this.lastPrice)
.dividedBy(this.lastPrice)
.multipliedBy(100);
const diff = currentPrice.minus(this.lastPrice); if (change.abs().gte(this.threshold)) {
const percentDiff = diff.dividedBy(this.lastPrice).multipliedBy(100); const dir = change.gt(0) ? "UP" : "DOWN";
logger.info(
if (percentDiff.abs().gte(this.threshold)) { {
const direction = diff.isPositive() ? "UP" : "DOWN";
const priceStr = currentPrice.toFixed(2);
const prevPriceStr = this.lastPrice.toFixed(2);
const pctNum = percentDiff.toNumber();
this.alert(direction, priceStr, pctNum);
insertIntoDB({
pair: this.pair, pair: this.pair,
direction, direction: dir,
previousPrice: prevPriceStr, change: `${change.toFixed(4)}%`,
newPrice: priceStr, prev: this.lastPrice.toFixed(6),
percentChange: pctNum, curr: price.toFixed(6),
},
`[ALERT] ${this.pair} ${dir} ${change.toFixed(4)}%`,
);
const alertData = {
pair: this.pair,
direction: dir,
previousPrice: this.lastPrice.toFixed(6),
newPrice: price.toFixed(6),
percentChange: change.toFixed(4),
interval: this.interval, interval: this.interval,
threshold: this.threshold, threshold: this.threshold,
}); };
this.lastPrice = currentPrice; try {
} await this.onAlert(alertData);
} } catch (err) {
logger.error(
alert(direction, priceStr, percentChange) { err,
const sign = direction === "UP" ? "+" : "-"; `[${this.pair}] Failed to process alert callback: ${err.message}`,
console.log(
`[ALERT] ${this.pair} ${direction} ${sign}${percentChange.toFixed(2)}% | Price: ${priceStr}`,
); );
} }
this.lastPrice = price;
}
}
} catch (err) {
logger.error(err, `[${this.pair}] ${err.message}`);
}
if (this.running) {
this.timer = setTimeout(this.loop, this.interval);
}
};
} }

View file

@ -1,12 +1,35 @@
import pg from "pg"; import pg from "pg";
import { setTimeout } from "timers/promises"; import { setTimeout } from "timers/promises";
import logger from "./logger.js";
const { Pool } = pg; const { Pool } = pg;
const pool = new Pool({ let pool = null;
export function getPool() {
if (pool) {
return pool;
}
if (!process.env.DATABASE_URL) {
return null;
}
pool = new Pool({
connectionString: process.env.DATABASE_URL, connectionString: process.env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
allowExitOnIdle: false,
}); });
pool.on("error", (err) => {
logger.error(err, "[DB] Unexpected error on idle client", err);
});
return pool;
}
const CREATE_TABLE_QUERY = ` const CREATE_TABLE_QUERY = `
CREATE TABLE IF NOT EXISTS alerts ( CREATE TABLE IF NOT EXISTS alerts (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@ -22,28 +45,47 @@ const CREATE_TABLE_QUERY = `
`; `;
export async function initDB() { export async function initDB() {
const currentPool = getPool();
if (!currentPool) {
logger.warn("[DB] specificiation missing. Running in memory");
return;
}
const maxRetries = 10; const maxRetries = 10;
const retryDelay = 2000; let delay = 2000;
for (let i = 0; i < maxRetries; i++) { for (let i = 0; i < maxRetries; i++) {
let client;
try { try {
await pool.query(CREATE_TABLE_QUERY); client = await currentPool.connect();
console.log("[DB] Database initialized and connected."); await client.query(CREATE_TABLE_QUERY);
logger.info("[DB] Database initialized and connected.");
return; return;
} catch (err) { } catch (err) {
if (i === maxRetries - 1) { if (i === maxRetries - 1) {
console.error("[DB] Failed to connect after all retries:", err.message); logger.error(
throw err; `[DB] Could not connect: ${err.message}. Continuing without DB.`,
}
console.log(
`[DB] Connection attempt ${i + 1} failed, retrying in ${retryDelay}ms...`,
); );
await setTimeout(retryDelay); // Lets not try that again
pool = null;
return;
}
logger.warn(
{ attempt: i + 1, error: err.message },
`[DB] Connection failed (${i + 1}/${maxRetries}): ${err.message}`,
);
await setTimeout(delay);
delay *= 2;
} finally {
if (client) client.release();
} }
} }
} }
export async function insertIntoDB(data) { export async function insertIntoDB(data) {
const currentPool = getPool();
if (!currentPool) return;
const query = ` const query = `
INSERT INTO alerts ( INSERT INTO alerts (
pair, direction, previous_price, new_price, percent_change, pair, direction, previous_price, new_price, percent_change,
@ -62,9 +104,19 @@ export async function insertIntoDB(data) {
]; ];
try { try {
await pool.query(query, values); await getPool().query(query, values);
console.log(`[DB] Event saved for ${data.pair}`); logger.info(`[DB] Event saved for ${data.pair}`);
} catch (err) { } catch (err) {
console.error(`[DB] Failed to save alert: ${err.message}`); // Re-throw so the caller knows there was a failure
logger.error(`[DB] Failed to save alert: ${err.message}`);
throw err;
}
}
export async function closePool() {
if (pool) {
await pool.end();
pool = null;
logger.info("[DB] Connection closed");
} }
} }

View file

@ -1,151 +1,253 @@
import { import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
describe,
it,
expect,
vi,
beforeEach,
afterEach,
beforeAll,
} from "vitest";
import nock from "nock"; import nock from "nock";
import { BigNumber } from "bignumber.js"; import { BigNumber } from "bignumber.js";
import { Bot } from "../src/bot.js";
vi.mock("console", () => ({
log: vi.fn(),
error: vi.fn(),
}));
vi.mock("../src/db.js", () => ({ vi.mock("../src/db.js", () => ({
insertIntoDB: vi.fn(), insertIntoDB: vi.fn().mockResolvedValue(undefined),
initDB: vi.fn(), initDB: vi.fn().mockResolvedValue(undefined),
closePool: vi.fn().mockResolvedValue(undefined),
})); }));
describe("Test Suite", () => { vi.mock("../src/logger.js", () => ({
const PAIR = "BTC-USD"; default: {
const INTERVAL = 100; info: vi.fn(),
const THRESHOLD = 0.01; warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
},
}));
describe("Bot Core Functionality", () => {
const CONFIG = {
pair: "BTC-USD",
interval: 100,
threshold: 0.01,
};
let BotClass;
let bot; let bot;
let fetchRateMock;
let onAlertSpy;
beforeAll(() => { beforeEach(async () => {
// Ensure BigNumber config matches what we expect vi.resetModules();
BigNumber.config({
DECIMAL_PLACES: 10,
ROUNDING_MODE: BigNumber.ROUND_HALF_UP,
});
});
beforeEach(() => { fetchRateMock = vi.fn();
bot = new Bot(PAIR, INTERVAL, THRESHOLD); vi.doMock("../src/api.js", () => ({
vi.useFakeTimers(); fetchRate: fetchRateMock,
prefetchRates: vi.fn(),
}));
const botModule = await import("../src/bot.js");
BotClass = botModule.Bot;
onAlertSpy = vi.fn();
bot = new BotClass(CONFIG, onAlertSpy);
bot.running = false;
}); });
afterEach(() => { afterEach(() => {
bot.stop(); vi.clearAllMocks();
vi.runAllTimers();
vi.useRealTimers();
nock.cleanAll();
vi.restoreAllMocks();
}); });
describe("Initialization and first fetch", () => { describe("Initialization", () => {
it("should set lastPrice on first successful API call", async () => { it("should initialize with correct parameters", () => {
nock("https://api.uphold.com") expect(bot.pair).toBe(CONFIG.pair);
.get(`/v0/ticker/${PAIR}`) expect(bot.interval).toBe(CONFIG.interval);
.reply(200, { ask: "60000.50", bid: "59990.00", currency: "USD" }); expect(bot.threshold).toBe(CONFIG.threshold);
expect(bot.running).toBe(false);
expect(bot.lastPrice).toBeNull();
});
await bot.check(); it("should set baseline price on first check without alerting", async () => {
fetchRateMock.mockResolvedValue(new BigNumber("60000.50"));
await bot.loop();
expect(bot.lastPrice).toBeDefined(); expect(bot.lastPrice).toBeDefined();
expect(bot.lastPrice.toFixed(2)).toBe("60000.50"); expect(bot.lastPrice.toFixed(2)).toBe("60000.50");
expect(onAlertSpy).not.toHaveBeenCalled();
}); });
}); });
describe("Price change detection", () => { describe("Threshold Detection", () => {
beforeEach(async () => { beforeEach(async () => {
// Establish baseline fetchRateMock.mockResolvedValue(new BigNumber("50000.00"));
nock("https://api.uphold.com") await bot.loop();
.get(`/v0/ticker/${PAIR}`) fetchRateMock.mockReset();
.reply(200, { ask: "50000.00" });
await bot.check();
nock.cleanAll();
}); });
it("should NOT alert when change is below threshold", async () => { it("should NOT alert when change is below threshold", async () => {
const alertSpy = vi.spyOn(bot, "alert"); fetchRateMock.mockResolvedValue(new BigNumber("50004.99"));
nock("https://api.uphold.com")
.get(`/v0/ticker/${PAIR}`)
.reply(200, { ask: "50004.99" }); // +0.00998%
await bot.check(); await bot.loop();
expect(alertSpy).not.toHaveBeenCalled(); expect(onAlertSpy).not.toHaveBeenCalled();
expect(bot.lastPrice.toFixed(2)).toBe("50000.00");
}); });
it("should alert when price moves exactly 0.01% (threshold)", async () => { it("should alert when price moves exactly at threshold", async () => {
const alertSpy = vi.spyOn(bot, "alert"); fetchRateMock.mockResolvedValue(new BigNumber("50005.00"));
nock("https://api.uphold.com")
.get(`/v0/ticker/${PAIR}`)
.reply(200, { ask: "50005.00" });
await bot.check(); await bot.loop();
// Check specifically for arguments passed to alert expect(onAlertSpy).toHaveBeenCalledTimes(1);
expect(alertSpy).toHaveBeenCalledWith("UP", "50005.00", 0.01); const alertData = onAlertSpy.mock.calls[0][0];
expect(alertData).toMatchObject({
pair: "BTC-USD",
direction: "UP",
previousPrice: "50000.000000",
newPrice: "50005.000000",
});
expect(bot.lastPrice.toFixed(2)).toBe("50005.00");
}); });
it("should alert on downward move", async () => { it("should alert on downward price movement", async () => {
const alertSpy = vi.spyOn(bot, "alert"); fetchRateMock.mockResolvedValue(new BigNumber("49995.00"));
nock("https://api.uphold.com")
.get(`/v0/ticker/${PAIR}`)
.reply(200, { ask: "49500.00" }); // -1%
await bot.check(); await bot.loop();
expect(alertSpy).toHaveBeenCalledWith("DOWN", "49500.00", -1); expect(onAlertSpy).toHaveBeenCalledTimes(1);
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("Alert method", () => { describe("Consecutive Alerts Logic", () => {
it("should log correct message for price increase", () => { it("should base next alert on last ALERT price, not last CHECKED price", async () => {
bot.lastPrice = new BigNumber("50000"); fetchRateMock.mockResolvedValue(new BigNumber("50000.00"));
const consoleSpy = vi.spyOn(console, "log"); await bot.loop();
bot.alert("UP", "50500.00", 1); fetchRateMock.mockResolvedValue(new BigNumber("50003.00"));
await bot.loop();
expect(onAlertSpy).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("UP")); fetchRateMock.mockResolvedValue(new BigNumber("50005.00"));
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("1.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("Polling mechanism", () => { describe("Error Handling", () => {
it("should repeatedly call check()", async () => { it("should handle network errors gracefully without crashing", async () => {
const checkSpy = vi.spyOn(bot, "check").mockResolvedValue(); fetchRateMock.mockRejectedValue(new Error("Network error"));
await expect(bot.loop()).resolves.not.toThrow();
bot.start();
vi.advanceTimersByTime(INTERVAL * 3);
await Promise.resolve();
expect(checkSpy).toHaveBeenCalledTimes(4);
});
}); });
describe("Error handling", () => { it("should handle alert callback errors", async () => {
it("should bubble up errors when check() is called directly", async () => { fetchRateMock.mockResolvedValue(new BigNumber("50000.00"));
nock("https://api.uphold.com") await bot.loop();
.get(`/v0/ticker/${PAIR}`)
.replyWithError("Connection Error");
await expect(bot.check()).rejects.toThrow("Connection Error"); fetchRateMock.mockResolvedValue(new BigNumber("51000.00"));
}); onAlertSpy.mockRejectedValue(new Error("DB Connection Lost"));
it("should reject invalid data formats", async () => { await expect(bot.loop()).resolves.not.toThrow();
nock("https://api.uphold.com")
.get(`/v0/ticker/${PAIR}`)
.reply(200, { bid: "100" });
await expect(bot.check()).rejects.toThrow("Invalid data format");
}); });
}); });
}); });
describe("API", () => {
let api;
beforeEach(async () => {
vi.resetModules();
nock.cleanAll();
vi.doMock("../src/api.js", async () => {
return await vi.importActual("../src/api.js");
});
api = await import("../src/api.js");
});
afterEach(() => {
nock.cleanAll();
});
describe("fetchRate Caching", () => {
it("should cache successful responses", async () => {
const scope = nock("https://api.uphold.com")
.get("/v0/ticker/BTC-USD")
.reply(200, { ask: "60000" });
const price1 = await api.fetchRate("BTC-USD");
expect(price1.toFixed(0)).toBe("60000");
const price2 = await api.fetchRate("BTC-USD");
expect(price2.toFixed(0)).toBe("60000");
expect(scope.isDone()).toBe(true);
});
it("should bypass cache if forceRefresh is true", async () => {
const scope = nock("https://api.uphold.com")
.get("/v0/ticker/BTC-USD")
.times(2)
.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")
.delay(100)
.reply(200, { ask: "60000" });
const p1 = api.fetchRate("BTC-USD", true);
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(scope.isDone()).toBe(true);
});
it("should throw after max retries", async () => {
const scope = nock("https://api.uphold.com")
.get("/v0/ticker/BTC-USD")
.times(3)
.replyWithError("Persistent Fail");
try {
await api.fetchRate("BTC-USD", true);
expect.fail("Should have thrown error");
} catch (err) {
expect(err.message).toContain("Persistent Fail");
}
expect(scope.isDone()).toBe(true);
}, 10000);
});
});