mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 15:20:58 +00:00
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.
236 lines
7.3 KiB
JavaScript
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:");
|
|
});
|
|
});
|
|
});
|