/** * @file Tests for trading/history — recordTrade, listTrades, formatTradesHtml, * createHistoryHandler, and buy/sell → D1 integration. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createSqlStore } from "../../../src/db/create-sql-store.js"; import { createHistoryHandler, formatTradesHtml, listTrades, recordTrade, } from "../../../src/modules/trading/history.js"; import { makeFakeD1 } from "../../fakes/fake-d1.js"; // ─── helpers ──────────────────────────────────────────────────────────────── /** Build a SqlStore backed by a fresh fake D1, pre-seeded with the table. */ function makeTestSql() { const fakeDb = makeFakeD1(); const sql = createSqlStore("trading", { DB: fakeDb }); return { fakeDb, sql }; } /** Minimal grammY ctx double. */ function makeCtx(match = "", userId = 123) { return { match, from: { id: userId }, reply: vi.fn(), }; } /** A canned trade payload. */ const TRADE = { userId: 1, symbol: "TCB", side: "buy", qty: 100, priceVnd: 25000 }; // ─── recordTrade ───────────────────────────────────────────────────────────── describe("recordTrade", () => { it("inserts a row into trading_trades", async () => { const { fakeDb, sql } = makeTestSql(); await recordTrade(sql, TRADE); expect(fakeDb.runLog).toHaveLength(1); expect(fakeDb.runLog[0].query).toMatch(/INSERT INTO trading_trades/i); const binds = fakeDb.runLog[0].binds; expect(binds[0]).toBe(1); // user_id expect(binds[1]).toBe("TCB"); // symbol expect(binds[2]).toBe("buy"); // side expect(binds[3]).toBe(100); // qty expect(binds[4]).toBe(25000); // price_vnd expect(typeof binds[5]).toBe("number"); // ts }); it("logs a warning and returns silently when sql is null", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); await expect(recordTrade(null, TRADE)).resolves.toBeUndefined(); expect(warn).toHaveBeenCalledWith(expect.stringContaining("no D1 binding")); warn.mockRestore(); }); it("logs an error and does NOT throw when the insert fails", async () => { const fakeDb = makeFakeD1(); // Make prepare().bind().run() throw. vi.spyOn(fakeDb, "prepare").mockReturnValue({ bind: () => ({ run: async () => { throw new Error("disk full"); }, }), }); const sql = createSqlStore("trading", { DB: fakeDb }); const error = vi.spyOn(console, "error").mockImplementation(() => {}); await expect(recordTrade(sql, TRADE)).resolves.toBeUndefined(); expect(error).toHaveBeenCalledWith( expect.stringContaining("recordTrade failed"), expect.any(Error), ); error.mockRestore(); }); }); // ─── listTrades ────────────────────────────────────────────────────────────── describe("listTrades", () => { it("returns [] when sql is null", async () => { expect(await listTrades(null, 1, 10)).toEqual([]); }); it("returns [] when table is empty", async () => { const { sql } = makeTestSql(); expect(await listTrades(sql, 1, 10)).toEqual([]); }); it("maps snake_case columns to camelCase Trade objects", async () => { const { fakeDb, sql } = makeTestSql(); fakeDb.seed("trading_trades", [ { id: 1, user_id: 1, symbol: "TCB", side: "buy", qty: 10, price_vnd: 25000, ts: 1000 }, ]); const trades = await listTrades(sql, 1, 10); expect(trades).toHaveLength(1); expect(trades[0]).toMatchObject({ id: 1, userId: 1, symbol: "TCB", side: "buy", qty: 10, priceVnd: 25000, ts: 1000, }); }); it("clamps limit below 1 to 1", async () => { const { fakeDb, sql } = makeTestSql(); fakeDb.seed("trading_trades", [ { id: 1, user_id: 1, symbol: "A", side: "buy", qty: 1, price_vnd: 100, ts: 1 }, ]); // Should not throw; clamps to 1 internally — binds[1] == 1. const trades = await listTrades(sql, 1, 0); // fake-d1 returns all seeded rows regardless of LIMIT bind, but we verify the bind. expect(fakeDb.queryLog[0].binds[1]).toBe(1); }); it("clamps limit above 50 to 50", async () => { const { fakeDb, sql } = makeTestSql(); fakeDb.seed("trading_trades", []); await listTrades(sql, 1, 999); expect(fakeDb.queryLog[0].binds[1]).toBe(50); }); }); // ─── formatTradesHtml ──────────────────────────────────────────────────────── describe("formatTradesHtml", () => { it("returns fallback message for empty array", () => { expect(formatTradesHtml([])).toBe("No trades recorded yet."); }); it("HTML-escapes symbols containing special characters", () => { const trade = { id: 1, userId: 1, symbol: "