diff --git a/.env b/.env index f4aa35e..7091f20 100644 --- a/.env +++ b/.env @@ -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 THRESHOLD=0.05 +POSTGRES_USER=uphold +POSTGRES_PASSWORD=uphold +POSTGRES_DB=uphold_events diff --git a/Containerfile b/Containerfile index 12dc925..e42a6f1 100644 --- a/Containerfile +++ b/Containerfile @@ -1,12 +1,12 @@ FROM docker.io/node:20-alpine - WORKDIR /app - COPY package*.json ./ +# RUN npm ci --omit=dev +RUN npm ci +COPY src/ ./src/ +COPY index.js ./ -RUN npm ci --omit=dev - -COPY . . +RUN addgroup -g 1001 nodejs && adduser -S -G nodejs -u 1001 nodejs +USER nodejs ENTRYPOINT ["node", "index.js"] -CMD ["--pairs", "BTC-USD"] diff --git a/compose.yml b/compose.yml index 5d1d8a2..84d6afb 100644 --- a/compose.yml +++ b/compose.yml @@ -5,17 +5,19 @@ services: db: condition: service_healthy environment: - - DATABASE_URL=postgres://uphold:uphold_password@db:5432/uphold_alerts - - PAIRS=${PAIRS:-BTC-USD,ETH-USD} + - DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + - PAIRS=${PAIRS:-BTC-USD} - INTERVAL=${INTERVAL:-5000} - 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: image: docker.io/postgres:15-alpine - environment: - - POSTGRES_USER=uphold - - POSTGRES_PASSWORD=uphold_password - - POSTGRES_DB=uphold_alerts + env_file: ".env" volumes: - pgdata:/var/lib/postgresql/data healthcheck: diff --git a/index.js b/index.js index e60b1a9..c952f19 100644 --- a/index.js +++ b/index.js @@ -1,22 +1,39 @@ import { parseArgs } from "node:util"; +import { prefetchRates } from "./src/api.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 = { pairs: { type: "string", short: "p", - default: "BTC-USD", + default: process.env.PAIRS || "BTC-USD", }, interval: { type: "string", short: "i", - default: "5000", + default: process.env.INTERVAL || "5000", }, threshold: { type: "string", 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 threshold = parseFloat(values.threshold); -// Basic validation -if (isNaN(interval) || interval < 1000) { - console.error("Error: Interval must be a number >= 1000ms"); +if (isNaN(interval) || interval < 100) { + logger.error("Interval must be a number larger than 100ms"); + 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); } -async function run() { - console.log("Starting Uphold interview bot..."); - console.log( - `Configuration: Pairs=[${pairs.join(", ")}] Interval=${interval}ms Threshold=${threshold}%`, +async function main() { + logger.info("Uphold price alert bot starting..."); + logger.info( + `[WATCHING] ${pairs.join(", ")} | Every ${interval}ms | Threshold ${threshold}%`, ); - if (process.env.DATABASE_URL) { - await initDB(); - } else { - console.warn("[Warning] No DATABASE_URL found. Alerts will NOT be saved."); + await initDB(); + + 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()); - process.on("SIGINT", () => { - console.log("\nShutting down..."); - bots.forEach((bot) => bot.stop()); + const shutdown = async () => { + logger.info("Shutting down..."); + bots.forEach((b) => b.stop()); + await closePool(); process.exit(0); - }); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); } -run().catch((err) => { - console.error("Fatal Error:", err); +main().catch((err) => { + logger.fatal(err, "Fatal error in main loop"); process.exit(1); }); diff --git a/package.json b/package.json index 9489680..bb97418 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,14 @@ "description": "Technical project for Backend Engineer role at Uphold", "dependencies": { "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": { "nock": "^14.0.10", + "pino-pretty": "^13.1.2", "vitest": "^4.0.14" } } diff --git a/src/api.js b/src/api.js index 8db0dbd..2ef8a7f 100644 --- a/src/api.js +++ b/src/api.js @@ -1,16 +1,93 @@ import { BigNumber } from "bignumber.js"; +import PQueue from "p-queue"; +import logger from "./logger.js"; -export async function fetchRate(pair) { - const url = `https://api.uphold.com/v0/ticker/${pair}`; - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Response status ${response.status}`); +const cache = new Map(); +const inflight = new Map(); + +const CACHE_TTL = 4500; + +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 (!data.ask || isNaN(parseFloat(data.ask))) { - throw new Error("Invalid data format"); + if (inflight.has(key)) { + return inflight.get(key); } - return new BigNumber(data.ask); + 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))) { + throw new Error("Invalid ask price"); + } + + 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", + ); + } } diff --git a/src/bot.js b/src/bot.js index 00fa015..4e420a1 100644 --- a/src/bot.js +++ b/src/bot.js @@ -1,82 +1,87 @@ import { fetchRate } from "./api.js"; -import { insertIntoDB } from "./db.js"; +import logger from "./logger.js"; export class Bot { - constructor(pair, interval, threshold) { - this.pair = pair; - this.interval = interval; - this.threshold = threshold; + constructor(config, onAlert) { + this.pair = config.pair; + this.interval = config.interval; + this.threshold = config.threshold; + this.onAlert = onAlert || (async () => {}); this.lastPrice = null; - this.running = null; this.timer = null; + this.running = false; } start() { - if (!this.running) { - this.running = true; - - console.log(`[STARTED] Monitoring ${this.pair}`); - - this.check().catch((err) => - console.error( - `[ERROR] Initialization failed for ${this.pair}: ${err.message}`, - ), - ); - - this.timer = setInterval(async () => { - try { - await this.check(); - } catch (err) { - console.error(`[ERROR] ${this.pair}: ${err.message}`); - } - }, this.interval); - } + if (this.running) return; + this.running = true; + logger.info( + `[${this.pair}] Monitoring started (${this.interval}ms, +/-${this.threshold}%)`, + ); + this.loop(); } stop() { - this.isRunning = false; - if (this.timer) { - clearInterval(this.timer); - } + if (!this.running) return; + this.running = false; + if (this.timer) clearTimeout(this.timer); + logger.info(`[${this.pair}] Stopped`); } - async check() { - const currentPrice = await fetchRate(this.pair); + loop = async () => { + try { + const price = await fetchRate(this.pair); - if (!this.lastPrice) { - this.lastPrice = currentPrice; - console.log(`[INIT] ${this.pair} set to ${currentPrice.toFixed(2)}`); - return; + if (!this.lastPrice) { + this.lastPrice = price; + logger.info(`[${this.pair}] Initial price: ${price.toFixed(2)}`); + } else { + const change = price + .minus(this.lastPrice) + .dividedBy(this.lastPrice) + .multipliedBy(100); + + if (change.abs().gte(this.threshold)) { + const dir = change.gt(0) ? "UP" : "DOWN"; + logger.info( + { + pair: this.pair, + direction: dir, + change: `${change.toFixed(4)}%`, + prev: this.lastPrice.toFixed(6), + 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, + threshold: this.threshold, + }; + + try { + await this.onAlert(alertData); + } catch (err) { + logger.error( + err, + `[${this.pair}] Failed to process alert callback: ${err.message}`, + ); + } + + this.lastPrice = price; + } + } + } catch (err) { + logger.error(err, `[${this.pair}] ${err.message}`); } - const diff = currentPrice.minus(this.lastPrice); - const percentDiff = diff.dividedBy(this.lastPrice).multipliedBy(100); - - 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, - direction, - previousPrice: prevPriceStr, - newPrice: priceStr, - percentChange: pctNum, - interval: this.interval, - threshold: this.threshold, - }); - - this.lastPrice = currentPrice; + if (this.running) { + this.timer = setTimeout(this.loop, this.interval); } - } - - alert(direction, priceStr, percentChange) { - const sign = direction === "UP" ? "+" : "-"; - console.log( - `[ALERT] ${this.pair} ${direction} ${sign}${percentChange.toFixed(2)}% | Price: ${priceStr}`, - ); - } + }; } diff --git a/src/db.js b/src/db.js index 3b2bdbd..cd746a6 100644 --- a/src/db.js +++ b/src/db.js @@ -1,11 +1,34 @@ import pg from "pg"; import { setTimeout } from "timers/promises"; +import logger from "./logger.js"; const { Pool } = pg; -const pool = new Pool({ - connectionString: process.env.DATABASE_URL, -}); +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, + 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 = ` CREATE TABLE IF NOT EXISTS alerts ( @@ -22,28 +45,47 @@ const CREATE_TABLE_QUERY = ` `; export async function initDB() { + const currentPool = getPool(); + if (!currentPool) { + logger.warn("[DB] specificiation missing. Running in memory"); + return; + } + const maxRetries = 10; - const retryDelay = 2000; + let delay = 2000; for (let i = 0; i < maxRetries; i++) { + let client; try { - await pool.query(CREATE_TABLE_QUERY); - console.log("[DB] Database initialized and connected."); + client = await currentPool.connect(); + await client.query(CREATE_TABLE_QUERY); + logger.info("[DB] Database initialized and connected."); return; } catch (err) { if (i === maxRetries - 1) { - console.error("[DB] Failed to connect after all retries:", err.message); - throw err; + logger.error( + `[DB] Could not connect: ${err.message}. Continuing without DB.`, + ); + // Lets not try that again + pool = null; + return; } - console.log( - `[DB] Connection attempt ${i + 1} failed, retrying in ${retryDelay}ms...`, + logger.warn( + { attempt: i + 1, error: err.message }, + `[DB] Connection failed (${i + 1}/${maxRetries}): ${err.message}`, ); - await setTimeout(retryDelay); + await setTimeout(delay); + delay *= 2; + } finally { + if (client) client.release(); } } } export async function insertIntoDB(data) { + const currentPool = getPool(); + if (!currentPool) return; + const query = ` INSERT INTO alerts ( pair, direction, previous_price, new_price, percent_change, @@ -62,9 +104,19 @@ export async function insertIntoDB(data) { ]; try { - await pool.query(query, values); - console.log(`[DB] Event saved for ${data.pair}`); + await getPool().query(query, values); + logger.info(`[DB] Event saved for ${data.pair}`); } 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"); } } diff --git a/tests/bot.test.js b/tests/bot.test.js index f7f1db0..4574314 100644 --- a/tests/bot.test.js +++ b/tests/bot.test.js @@ -1,151 +1,253 @@ -import { - describe, - it, - expect, - vi, - beforeEach, - afterEach, - beforeAll, -} from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import nock from "nock"; 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", () => ({ - insertIntoDB: vi.fn(), - initDB: vi.fn(), + insertIntoDB: vi.fn().mockResolvedValue(undefined), + initDB: vi.fn().mockResolvedValue(undefined), + closePool: vi.fn().mockResolvedValue(undefined), })); -describe("Test Suite", () => { - const PAIR = "BTC-USD"; - const INTERVAL = 100; - const THRESHOLD = 0.01; +vi.mock("../src/logger.js", () => ({ + default: { + info: vi.fn(), + 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 fetchRateMock; + let onAlertSpy; - beforeAll(() => { - // Ensure BigNumber config matches what we expect - BigNumber.config({ - DECIMAL_PLACES: 10, - ROUNDING_MODE: BigNumber.ROUND_HALF_UP, - }); - }); + beforeEach(async () => { + vi.resetModules(); - beforeEach(() => { - bot = new Bot(PAIR, INTERVAL, THRESHOLD); - vi.useFakeTimers(); + fetchRateMock = vi.fn(); + vi.doMock("../src/api.js", () => ({ + 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(() => { - bot.stop(); - vi.runAllTimers(); - vi.useRealTimers(); - nock.cleanAll(); - vi.restoreAllMocks(); + vi.clearAllMocks(); }); - describe("Initialization and first fetch", () => { - it("should set lastPrice on first successful API call", async () => { - nock("https://api.uphold.com") - .get(`/v0/ticker/${PAIR}`) - .reply(200, { ask: "60000.50", bid: "59990.00", currency: "USD" }); + describe("Initialization", () => { + it("should initialize with correct parameters", () => { + expect(bot.pair).toBe(CONFIG.pair); + expect(bot.interval).toBe(CONFIG.interval); + 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.toFixed(2)).toBe("60000.50"); + expect(onAlertSpy).not.toHaveBeenCalled(); }); }); - describe("Price change detection", () => { + describe("Threshold Detection", () => { beforeEach(async () => { - // Establish baseline - nock("https://api.uphold.com") - .get(`/v0/ticker/${PAIR}`) - .reply(200, { ask: "50000.00" }); - await bot.check(); - nock.cleanAll(); + fetchRateMock.mockResolvedValue(new BigNumber("50000.00")); + await bot.loop(); + fetchRateMock.mockReset(); }); it("should NOT alert when change is below threshold", async () => { - const alertSpy = vi.spyOn(bot, "alert"); - nock("https://api.uphold.com") - .get(`/v0/ticker/${PAIR}`) - .reply(200, { ask: "50004.99" }); // +0.00998% + fetchRateMock.mockResolvedValue(new BigNumber("50004.99")); - 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 () => { - const alertSpy = vi.spyOn(bot, "alert"); - nock("https://api.uphold.com") - .get(`/v0/ticker/${PAIR}`) - .reply(200, { ask: "50005.00" }); + it("should alert when price moves exactly at threshold", async () => { + fetchRateMock.mockResolvedValue(new BigNumber("50005.00")); - await bot.check(); + await bot.loop(); - // Check specifically for arguments passed to alert - expect(alertSpy).toHaveBeenCalledWith("UP", "50005.00", 0.01); + expect(onAlertSpy).toHaveBeenCalledTimes(1); + 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 () => { - const alertSpy = vi.spyOn(bot, "alert"); - nock("https://api.uphold.com") - .get(`/v0/ticker/${PAIR}`) - .reply(200, { ask: "49500.00" }); // -1% + it("should alert on downward price movement", async () => { + fetchRateMock.mockResolvedValue(new BigNumber("49995.00")); - 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", () => { - it("should log correct message for price increase", () => { - bot.lastPrice = new BigNumber("50000"); - const consoleSpy = vi.spyOn(console, "log"); + 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(); - 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")); - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("1.00%")); + 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("Polling mechanism", () => { - it("should repeatedly call check()", async () => { - const checkSpy = vi.spyOn(bot, "check").mockResolvedValue(); - - bot.start(); - - vi.advanceTimersByTime(INTERVAL * 3); - await Promise.resolve(); - - expect(checkSpy).toHaveBeenCalledTimes(4); - }); - }); - - describe("Error handling", () => { - it("should bubble up errors when check() is called directly", async () => { - nock("https://api.uphold.com") - .get(`/v0/ticker/${PAIR}`) - .replyWithError("Connection Error"); - - await expect(bot.check()).rejects.toThrow("Connection Error"); + describe("Error Handling", () => { + it("should handle network errors gracefully without crashing", async () => { + fetchRateMock.mockRejectedValue(new Error("Network error")); + await expect(bot.loop()).resolves.not.toThrow(); }); - it("should reject invalid data formats", async () => { - nock("https://api.uphold.com") - .get(`/v0/ticker/${PAIR}`) - .reply(200, { bid: "100" }); - await expect(bot.check()).rejects.toThrow("Invalid data format"); + 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(); }); }); }); + +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); + }); +});