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 Core Functionality", () => { 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"); }); 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", () => { it("should handle network errors gracefully without crashing", async () => { fetchRateMock.mockRejectedValue(new Error("Network error")); 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(); }); }); }); 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); }); });