mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 13:21:31 +00:00
- trading_trades table (migration 0001) persists every buy/sell via optional onTrade callback - /history [n] command shows caller's last N trades (default 10, max 50), HTML-escaped - daily cron at 0 17 * * * trims to 1000/user + 10000/global via FIFO delete - persistence failure logs but does not fail the trade reply
196 lines
7.4 KiB
JavaScript
196 lines
7.4 KiB
JavaScript
/**
|
|
* @file Tests for trading/retention — trimTradesHandler per-user and global caps.
|
|
*
|
|
* Uses enhanced fake-d1 which supports:
|
|
* - SELECT DISTINCT user_id
|
|
* - SELECT id ... WHERE user_id = ? ORDER BY ts DESC LIMIT -1 OFFSET N
|
|
* - SELECT id ... ORDER BY ts DESC LIMIT -1 OFFSET N
|
|
* - DELETE FROM ... WHERE id IN (?, ...)
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createSqlStore } from "../../../src/db/create-sql-store.js";
|
|
import { trimTradesHandler } from "../../../src/modules/trading/retention.js";
|
|
import { makeFakeD1 } from "../../fakes/fake-d1.js";
|
|
|
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
/** Build a fresh sql store backed by a new fake D1. */
|
|
function makeTestSql() {
|
|
const fakeDb = makeFakeD1();
|
|
const sql = createSqlStore("trading", { DB: fakeDb });
|
|
return { fakeDb, sql };
|
|
}
|
|
|
|
/**
|
|
* Build N trade rows for a given user_id.
|
|
* ts increases from 1..N so that the LAST row (index N-1) has the highest ts
|
|
* and is therefore the "newest".
|
|
*
|
|
* @param {number} userId
|
|
* @param {number} count
|
|
* @param {number} [idOffset=0] — added to each id to keep ids globally unique.
|
|
* @returns {object[]}
|
|
*/
|
|
function makeTrades(userId, count, idOffset = 0) {
|
|
return Array.from({ length: count }, (_, i) => ({
|
|
id: idOffset + i + 1,
|
|
user_id: userId,
|
|
symbol: "TCB",
|
|
side: "buy",
|
|
qty: 1,
|
|
price_vnd: 1000,
|
|
ts: i + 1, // ts=1 is oldest, ts=count is newest
|
|
}));
|
|
}
|
|
|
|
/** Convenience: run trimTradesHandler with muted console output. */
|
|
async function runTrim(sql, caps) {
|
|
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
try {
|
|
await trimTradesHandler({}, { sql }, caps);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
}
|
|
|
|
/** Return all rows remaining in trading_trades via the fake table map. */
|
|
function allRows(fakeDb) {
|
|
return fakeDb.tables.get("trading_trades") ?? [];
|
|
}
|
|
|
|
// ─── sql=null guard ───────────────────────────────────────────────────────────
|
|
|
|
describe("trimTradesHandler — sql=null", () => {
|
|
it("returns without throwing when sql is null", async () => {
|
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
await expect(trimTradesHandler({}, { sql: null })).resolves.toBeUndefined();
|
|
logSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// ─── per-user trim ────────────────────────────────────────────────────────────
|
|
|
|
describe("trimTradesHandler — per-user trim", () => {
|
|
it("removes rows beyond cap, keeping newest", async () => {
|
|
const { fakeDb, sql } = makeTestSql();
|
|
// 5 trades for user 1, cap=3 → keeps ts=3,4,5 (newest 3), deletes ts=1,2.
|
|
fakeDb.seed("trading_trades", makeTrades(1, 5));
|
|
|
|
await runTrim(sql, { perUserCap: 3, globalCap: 1000 });
|
|
|
|
const remaining = allRows(fakeDb);
|
|
expect(remaining).toHaveLength(3);
|
|
// Remaining should be the 3 rows with highest ts.
|
|
const tsSorted = remaining.map((r) => r.ts).sort((a, b) => b - a);
|
|
expect(tsSorted).toEqual([5, 4, 3]);
|
|
});
|
|
|
|
it("leaves users with rows <= cap untouched", async () => {
|
|
const { fakeDb, sql } = makeTestSql();
|
|
// User 2 has 2 trades, cap=3 → none deleted.
|
|
fakeDb.seed("trading_trades", makeTrades(2, 2));
|
|
|
|
await runTrim(sql, { perUserCap: 3, globalCap: 1000 });
|
|
|
|
expect(allRows(fakeDb)).toHaveLength(2);
|
|
});
|
|
|
|
it("handles exactly cap rows — no deletion", async () => {
|
|
const { fakeDb, sql } = makeTestSql();
|
|
fakeDb.seed("trading_trades", makeTrades(1, 3));
|
|
|
|
await runTrim(sql, { perUserCap: 3, globalCap: 1000 });
|
|
|
|
expect(allRows(fakeDb)).toHaveLength(3);
|
|
});
|
|
|
|
it("trims multiple users independently", async () => {
|
|
const { fakeDb, sql } = makeTestSql();
|
|
// User 1: 5 trades (cap 3 → keep 3). User 2: 2 trades (cap 3 → keep 2).
|
|
fakeDb.seed("trading_trades", [...makeTrades(1, 5, 0), ...makeTrades(2, 2, 5)]);
|
|
|
|
await runTrim(sql, { perUserCap: 3, globalCap: 1000 });
|
|
|
|
const remaining = allRows(fakeDb);
|
|
// 3 from user1 + 2 from user2 = 5.
|
|
expect(remaining).toHaveLength(5);
|
|
const user1 = remaining.filter((r) => r.user_id === 1);
|
|
const user2 = remaining.filter((r) => r.user_id === 2);
|
|
expect(user1).toHaveLength(3);
|
|
expect(user2).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
// ─── global FIFO trim ─────────────────────────────────────────────────────────
|
|
|
|
describe("trimTradesHandler — global trim", () => {
|
|
it("keeps only globalCap newest rows across all users", async () => {
|
|
const { fakeDb, sql } = makeTestSql();
|
|
// 15 trades total: user1=8, user2=7. globalCap=10 → keep 10 newest.
|
|
const trades = [...makeTrades(1, 8, 0), ...makeTrades(2, 7, 8)];
|
|
// Re-assign ts values to be globally unique so ordering is deterministic.
|
|
trades.forEach((t, i) => {
|
|
t.ts = i + 1;
|
|
});
|
|
fakeDb.seed("trading_trades", trades);
|
|
|
|
await runTrim(sql, { perUserCap: 1000, globalCap: 10 });
|
|
|
|
expect(allRows(fakeDb)).toHaveLength(10);
|
|
// All remaining rows should have the top-10 ts values (6..15).
|
|
const tsSorted = allRows(fakeDb)
|
|
.map((r) => r.ts)
|
|
.sort((a, b) => a - b);
|
|
expect(tsSorted).toEqual([6, 7, 8, 9, 10, 11, 12, 13, 14, 15]);
|
|
});
|
|
|
|
it("leaves rows untouched when count <= globalCap", async () => {
|
|
const { fakeDb, sql } = makeTestSql();
|
|
fakeDb.seed("trading_trades", makeTrades(1, 5));
|
|
|
|
await runTrim(sql, { perUserCap: 1000, globalCap: 10 });
|
|
|
|
expect(allRows(fakeDb)).toHaveLength(5);
|
|
});
|
|
});
|
|
|
|
// ─── combined pass ────────────────────────────────────────────────────────────
|
|
|
|
describe("trimTradesHandler — combined per-user + global", () => {
|
|
it("applies per-user first, then global", async () => {
|
|
const { fakeDb, sql } = makeTestSql();
|
|
// User 1: 6 trades (perUserCap=4 → trim to 4).
|
|
// User 2: 6 trades (perUserCap=4 → trim to 4).
|
|
// After per-user: 8 rows total.
|
|
// globalCap=6 → trim to 6.
|
|
const trades = [...makeTrades(1, 6, 0), ...makeTrades(2, 6, 6)];
|
|
// Assign globally monotone ts values.
|
|
trades.forEach((t, i) => {
|
|
t.ts = i + 1;
|
|
});
|
|
fakeDb.seed("trading_trades", trades);
|
|
|
|
await runTrim(sql, { perUserCap: 4, globalCap: 6 });
|
|
|
|
expect(allRows(fakeDb)).toHaveLength(6);
|
|
});
|
|
});
|
|
|
|
// ─── idempotence ──────────────────────────────────────────────────────────────
|
|
|
|
describe("trimTradesHandler — idempotence", () => {
|
|
it("second run is a no-op when already within caps", async () => {
|
|
const { fakeDb, sql } = makeTestSql();
|
|
fakeDb.seed("trading_trades", makeTrades(1, 5));
|
|
|
|
await runTrim(sql, { perUserCap: 3, globalCap: 10 });
|
|
const afterFirst = allRows(fakeDb).length;
|
|
|
|
await runTrim(sql, { perUserCap: 3, globalCap: 10 });
|
|
const afterSecond = allRows(fakeDb).length;
|
|
|
|
expect(afterFirst).toBe(afterSecond);
|
|
});
|
|
});
|