253 lines
7 KiB
JavaScript
253 lines
7 KiB
JavaScript
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);
|
|
});
|
|
});
|