Files
miti99bot/tests/modules/trading/handlers.test.js
tiennm99 86268341d1 feat: use real BIDV bid/ask rates for forex conversion
Replace hardcoded 0.5% spread with live buy/sell rates from BIDV bank
API. Buying USD uses bank's sell rate (higher), selling USD uses bank's
buy rate (lower). Reply shows both rates and actual spread percentage.
2026-04-14 16:53:07 +07:00

236 lines
7.3 KiB
JavaScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createStore } from "../../../src/db/create-store.js";
import {
handleBuy,
handleConvert,
handleSell,
handleTopup,
} from "../../../src/modules/trading/handlers.js";
import { savePortfolio } from "../../../src/modules/trading/portfolio.js";
import { handleStats } from "../../../src/modules/trading/stats-handler.js";
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
/** Build a fake grammY context with .match, .from, and .reply spy */
function makeCtx(match = "", userId = 42) {
const replies = [];
return {
match,
from: { id: userId },
reply: vi.fn((text) => replies.push(text)),
replies,
};
}
/** Stub global.fetch to return canned price data */
function stubFetch() {
global.fetch = vi.fn((url) => {
if (url.includes("coingecko")) {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
bitcoin: { vnd: 1500000000 },
ethereum: { vnd: 50000000 },
solana: { vnd: 3000000 },
"pax-gold": { vnd: 72000000 },
}),
});
}
if (url.includes("tcbs")) {
const ticker = url.match(/ticker=(\w+)/)?.[1];
const prices = { TCB: 25, VPB: 18, FPT: 120, VNM: 70, HPG: 28 };
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ data: [{ close: prices[ticker] || 25 }] }),
});
}
if (url.includes("bidv")) {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
data: [
{ currency: "USD", muaCk: "25,200", ban: "25,600" },
{ currency: "EUR", muaCk: "28,000", ban: "28,500" },
],
}),
});
}
return Promise.reject(new Error(`unexpected fetch: ${url}`));
});
}
describe("trading/handlers", () => {
let db;
beforeEach(() => {
db = createStore("trading", { KV: makeFakeKv() });
stubFetch();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("handleTopup", () => {
it("tops up VND", async () => {
const ctx = makeCtx("5000000");
await handleTopup(ctx, db);
expect(ctx.reply).toHaveBeenCalledOnce();
expect(ctx.replies[0]).toContain("5.000.000 VND");
});
it("tracks totalvnd", async () => {
const ctx = makeCtx("1000000");
await handleTopup(ctx, db);
const { getPortfolio } = await import("../../../src/modules/trading/portfolio.js");
const p = await getPortfolio(db, 42);
expect(p.totalvnd).toBe(1000000);
expect(p.currency.VND).toBe(1000000);
});
it("rejects missing args", async () => {
const ctx = makeCtx("");
await handleTopup(ctx, db);
expect(ctx.replies[0]).toContain("Usage:");
});
it("rejects negative amount", async () => {
const ctx = makeCtx("-100");
await handleTopup(ctx, db);
expect(ctx.replies[0]).toContain("positive");
});
});
describe("handleBuy", () => {
it("buys crypto with sufficient VND", async () => {
// seed VND balance
const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js");
const p = emptyPortfolio();
p.currency.VND = 20000000000;
p.totalvnd = 20000000000;
await savePortfolio(db, 42, p);
const ctx = makeCtx("0.01 BTC");
await handleBuy(ctx, db);
expect(ctx.replies[0]).toContain("Bought");
expect(ctx.replies[0]).toContain("BTC");
});
it("rejects buy with insufficient VND", async () => {
const ctx = makeCtx("1 BTC");
await handleBuy(ctx, db);
expect(ctx.replies[0]).toContain("Insufficient VND");
});
it("rejects unknown symbol", async () => {
const ctx = makeCtx("1 NOPE");
await handleBuy(ctx, db);
expect(ctx.replies[0]).toContain("Unknown symbol");
});
it("rejects fractional stock quantity", async () => {
const ctx = makeCtx("1.5 TCB");
await handleBuy(ctx, db);
expect(ctx.replies[0]).toContain("whole numbers");
});
it("rejects missing args", async () => {
const ctx = makeCtx("");
await handleBuy(ctx, db);
expect(ctx.replies[0]).toContain("Usage:");
});
});
describe("handleSell", () => {
it("sells crypto holding", async () => {
const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js");
const p = emptyPortfolio();
p.crypto.BTC = 0.5;
await savePortfolio(db, 42, p);
const ctx = makeCtx("0.1 BTC");
await handleSell(ctx, db);
expect(ctx.replies[0]).toContain("Sold");
expect(ctx.replies[0]).toContain("BTC");
});
it("rejects sell with insufficient holdings", async () => {
const ctx = makeCtx("1 BTC");
await handleSell(ctx, db);
expect(ctx.replies[0]).toContain("Insufficient BTC");
});
});
describe("handleConvert", () => {
it("converts USD to VND at buy rate (bank buys USD at lower price)", async () => {
const { emptyPortfolio, getPortfolio } = await import(
"../../../src/modules/trading/portfolio.js"
);
const p = emptyPortfolio();
p.currency.USD = 100;
await savePortfolio(db, 42, p);
const ctx = makeCtx("50 USD VND");
await handleConvert(ctx, db);
expect(ctx.replies[0]).toContain("Converted");
// buy rate = 25,200 → 50 * 25200 = 1,260,000 VND
const loaded = await getPortfolio(db, 42);
expect(loaded.currency.VND).toBe(50 * 25200);
expect(ctx.replies[0]).toContain("buy:");
expect(ctx.replies[0]).toContain("sell:");
});
it("converts VND to USD at sell rate (bank sells USD at higher price)", async () => {
const { emptyPortfolio, getPortfolio } = await import(
"../../../src/modules/trading/portfolio.js"
);
const p = emptyPortfolio();
p.currency.VND = 30000000;
await savePortfolio(db, 42, p);
const ctx = makeCtx("1000000 VND USD");
await handleConvert(ctx, db);
expect(ctx.replies[0]).toContain("Converted");
// sell rate = 25,600 → 1M / 25600 ≈ 39.0625 USD
const loaded = await getPortfolio(db, 42);
expect(loaded.currency.USD).toBeCloseTo(1000000 / 25600, 2);
});
it("rejects same currency conversion", async () => {
const ctx = makeCtx("100 VND VND");
await handleConvert(ctx, db);
expect(ctx.replies[0]).toContain("same currency");
});
it("rejects insufficient balance", async () => {
const ctx = makeCtx("100 USD VND");
await handleConvert(ctx, db);
expect(ctx.replies[0]).toContain("Insufficient USD");
});
});
describe("handleStats", () => {
it("shows empty portfolio for new user", async () => {
const ctx = makeCtx("");
await handleStats(ctx, db);
expect(ctx.replies[0]).toContain("Portfolio Summary");
expect(ctx.replies[0]).toContain("Total value:");
});
it("shows portfolio with assets", async () => {
const { emptyPortfolio } = await import("../../../src/modules/trading/portfolio.js");
const p = emptyPortfolio();
p.currency.VND = 5000000;
p.crypto.BTC = 0.01;
p.totalvnd = 20000000;
await savePortfolio(db, 42, p);
const ctx = makeCtx("");
await handleStats(ctx, db);
expect(ctx.replies[0]).toContain("BTC");
expect(ctx.replies[0]).toContain("P&L:");
});
});
});