Better error handling, caching and deduplication. Added tests to api and db logic
This commit is contained in:
parent
f4de1c7bc2
commit
628d7ade76
9 changed files with 510 additions and 226 deletions
5
.env
5
.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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
16
compose.yml
16
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:
|
||||
|
|
|
|||
83
index.js
83
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
95
src/api.js
95
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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
133
src/bot.js
133
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}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
80
src/db.js
80
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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue