import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import nock from "nock"; import { BigNumber } from "bignumber.js"; vi.mock("../src/db.js", () => ({ insertIntoDB: vi.fn().mockResolvedValue(undefined), initDB: vi.fn().mockResolvedValue(undefined), closePool: vi.fn().mockResolvedValue(undefined), })); vi.mock("../src/logger.js", () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn(), }, })); describe("Bot", () => { const CONFIG = { pair: "BTC-USD", interval: 100, threshold: 0.01, }; let BotClass; let bot; let fetchRateMock; let onAlertSpy; beforeEach(async () => { vi.resetModules(); 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(() => { vi.clearAllMocks(); }); 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(); }); 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("Threshold Detection", () => { beforeEach(async () => { fetchRateMock.mockResolvedValue(new BigNumber("50000.00")); await bot.loop(); fetchRateMock.mockReset(); }); it("should NOT alert when change is below threshold", async () => { fetchRateMock.mockResolvedValue(new BigNumber("50004.99")); await bot.loop(); expect(onAlertSpy).not.toHaveBeenCalled(); expect(bot.lastPrice.toFixed(2)).toBe("50000.00"); }); it("should alert when price moves exactly at threshold", async () => { fetchRateMock.mockResolvedValue(new BigNumber("50005.00")); await bot.loop(); 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 price movement", async () => { fetchRateMock.mockResolvedValue(new BigNumber("49995.00")); await bot.loop(); expect(onAlertSpy).toHaveBeenCalledTimes(1); expect(onAlertSpy.mock.calls[0][0].direction).toBe("DOWN"); }); }); 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(); }); }); }); 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", () => { it("should return a BigNumber on success", async () => { const scope = nock("https://api.uphold.com") .get("/v0/ticker/BTC-USD") .reply(200, { ask: "60000" }); const price = await api.fetchRate("BTC-USD"); expect(price).toBeInstanceOf(BigNumber); expect(price.toFixed(0)).toBe("60000"); expect(scope.isDone()).toBe(true); }); it("should retry on failure and eventually success", async () => { const scope = nock("https://api.uphold.com") .get("/v0/ticker/BTC-USD") .replyWithError("Socket Hangup") // Fail 1 .get("/v0/ticker/BTC-USD") .reply(200, { ask: "60000" }); // Success 2 const price = await api.fetchRate("BTC-USD"); 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"); await expect(api.fetchRate("BTC-USD")).rejects.toThrow("Persistent Fail"); expect(scope.isDone()).toBe(true); }); 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); }); }); });