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:"); }); }); });