Files
miti99bot-js/tests/e2e/storage-roundtrip.test.js
tiennm99 ea7df56e2d feat(db,cron): phase 04 — dual-write wrappers + factory routing + drift verifier + e2e
The integration phase. Wires Phase 02 (MongoKVStore) and Phase 03
(MongoTradesStore + MongoSqlStore shim) into the request path behind
two env flags so KV and Atlas run side-by-side until cutover.

Storage routing
- DualKVStore + DualSqlStore: Promise.allSettled writes to BOTH backends,
  reads from primary only. Secondary failures log + enqueue onto a KV
  retry queue (__retry:mongo-failed:* / __retry:mongo-sql-failed:*).
  Primary failure throws. _kind="dual" sentinel for test seam.
- create-store.js + create-sql-store.js: full flag matrix (STORAGE_PRIMARY
  ∈ {kv,mongo}, DUAL_WRITE ∈ {0,1}, MONGODB_URI presence) with
  STUB_SENTINEL short-circuit for deploy-time. Post-cutover shape commented
  inline so Phase 07 simplification is mechanical.

Stub mongo for register
- scripts/stub-kv.js: STUB_SENTINEL constant + duck-typed stubMongo
  (no-op connect/close, throwing collection access). Replaces the
  originally-planned string sentinel which would have stalled register.js
  on serverSelectionTimeoutMS if ever passed to MongoClient (code-reviewer #2).
- scripts/register.js: stub env passes MONGODB_URI=STUB_SENTINEL,
  STORAGE_PRIMARY="kv", DUAL_WRITE="0". Asserted via vi.spyOn that
  MongoClient.prototype.connect is never reached.

Drift verifier cron (1/hr)
- src/cron/drift-verifier.js: drains both retry queues by re-attempting
  secondary writes, deletes on success. Spot-checks parity by sampling
  DRIFT_SAMPLE_N keys per module, hashing, logging mismatches.
- src/modules/cron-dispatcher.js: SYSTEM_CRONS array dispatched alongside
  module crons. Keeping system cron out of registry.crons preserves
  existing module-cron length tests and is the cleaner design.
- wrangler.toml: vars STORAGE_PRIMARY/DUAL_WRITE/DRIFT_SAMPLE_N + cron
  schedule "0 * * * *" added.

Trading wiring
- src/modules/registry.js: builds new MongoTradesStore(env) when Mongo
  is in play and threads it as tradesStore into trading module's init
  context. Trading module already accepted optional tradesStore (Phase 03
  backwards-compat) — D1 path remains for STORAGE_PRIMARY=kv + DUAL_WRITE=0.

Tests + verification
- tests/db/dual-kv-store.test.js, dual-sql-store.test.js: write-both,
  secondary-fail-logs+enqueues, primary-fail-throws, reads-primary-only,
  _kind sentinel.
- tests/db/stub-mongo-sentinel.test.js: spy on MongoClient.connect,
  assert zero calls across all flag-matrix combos.
- tests/cron/drift-verifier.test.js: queue drain, skip paths, error safety.
- tests/e2e/storage-roundtrip.test.js: wordle KV dual-write +
  trading MongoTradesStore against fake-mongo.

Tests: 577 → 638 (+61). register:dry passes without Atlas. Lint clean.

Concerns
- Drift-verifier parity-spot-check tests assert queue-drain only;
  full mismatch detection needs real Atlas (Vitest ES-module caching
  blocks reliable prototype patching). Verifier logic verified by
  inspection.
2026-04-26 09:02:07 +07:00

255 lines
8.9 KiB
JavaScript

/**
* @file storage-roundtrip.test.js — e2e storage integration test.
*
* Boots a fake env with:
* - MongoKVStore (backed by fake-mongo) for KV path (wordle)
* - MongoTradesStore (backed by fake-mongo) for SQL/trades path (trading)
* - DUAL_WRITE=1 so DualKVStore is used (CF KV + Mongo both written)
*
* Asserts that state written via the store abstractions is visible in BOTH
* backends — the CF KV fake AND the in-memory Mongo fake.
*
* This test intentionally avoids grammY context setup. It exercises the
* storage layer (the thing being migrated), not the Telegram handler.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { createStore } from "../../src/db/create-store.js";
import { MongoKVStore } from "../../src/db/mongo-kv-store.js";
import { MongoTradesStore } from "../../src/db/mongo-trades-store.js";
import { listTrades, recordTrade } from "../../src/modules/trading/history.js";
import { loadGame, loadStats, recordResult, saveGame } from "../../src/modules/wordle/state.js";
import { makeFakeKv } from "../fakes/fake-kv-namespace.js";
import { makeFakeMongo } from "../fakes/fake-mongo.js";
// ---------------------------------------------------------------------------
// Shared setup
// ---------------------------------------------------------------------------
let cfKv;
let fakeDb;
let env;
beforeEach(() => {
cfKv = makeFakeKv();
fakeDb = makeFakeMongo();
// env with real-looking MONGODB_URI so factories pick the dual-write path.
// MongoKVStore receives dbOverride (fakeDb) so no real Atlas connection happens.
env = {
KV: cfKv,
MONGODB_URI: "mongodb://fake-atlas",
STORAGE_PRIMARY: "kv",
DUAL_WRITE: "1",
};
});
// ---------------------------------------------------------------------------
// KV path — wordle game state
// ---------------------------------------------------------------------------
describe("KV dual-write — wordle game state", () => {
it("saveGame persists to both CF KV and MongoKVStore (fake-mongo)", async () => {
// Build the dual store: createStore uses DualKVStore with CF primary + Mongo secondary.
// Pass fakeDb as dbOverride so MongoKVStore talks to fake-mongo, not Atlas.
const mongoKvStore = new MongoKVStore(env, "wordle", fakeDb);
// Manually construct the dual store to inject fakeDb.
const { DualKVStore } = await import("../../src/db/dual-kv-store.js");
const { CFKVStore } = await import("../../src/db/cf-kv-store.js");
const cfStore = new CFKVStore(cfKv);
// Prefix wrapper that mirrors create-store.js behaviour.
function prefixedStore(base, prefix) {
return {
async get(key) {
return base.get(prefix + key);
},
async put(key, value, opts) {
return base.put(prefix + key, value, opts);
},
async delete(key) {
return base.delete(prefix + key);
},
async list(opts = {}) {
const fullPrefix = prefix + (opts.prefix ?? "");
const result = await base.list({
prefix: fullPrefix,
limit: opts.limit,
cursor: opts.cursor,
});
return {
keys: result.keys.map((k) => (k.startsWith(prefix) ? k.slice(prefix.length) : k)),
cursor: result.cursor,
done: result.done,
};
},
async getJSON(key) {
return base.getJSON(prefix + key);
},
async putJSON(key, value, opts) {
return base.putJSON(prefix + key, value, opts);
},
};
}
const dual = new DualKVStore(cfStore, mongoKvStore, cfKv);
const db = prefixedStore(dual, "wordle:");
const gameState = {
target: "crane",
guesses: [{ word: "audio", results: ["absent", "absent", "absent", "absent", "absent"] }],
solved: false,
startedAt: Date.now(),
};
await saveGame(db, 42, gameState);
// 1. CF KV should have the prefixed key.
expect(cfKv.store.has("wordle:game:42")).toBe(true);
const cfStored = JSON.parse(cfKv.store.get("wordle:game:42"));
expect(cfStored.target).toBe("crane");
// 2. Mongo fake should have the same key.
const mongoColl = fakeDb.collection("wordle");
const mongoDoc = await mongoColl.findOne({ _id: "wordle:game:42" });
expect(mongoDoc).not.toBeNull();
expect(JSON.parse(mongoDoc.value).target).toBe("crane");
// 3. loadGame reads from primary (CF KV).
const loaded = await loadGame(db, 42);
expect(loaded?.target).toBe("crane");
expect(loaded?.guesses).toHaveLength(1);
});
it("recordResult persists stats to both backends", async () => {
const mongoKvStore = new MongoKVStore(env, "wordle", fakeDb);
const { DualKVStore } = await import("../../src/db/dual-kv-store.js");
const { CFKVStore } = await import("../../src/db/cf-kv-store.js");
const cfStore = new CFKVStore(cfKv);
const dual = new DualKVStore(cfStore, mongoKvStore, cfKv);
function prefixedStore(base, prefix) {
return {
async get(key) {
return base.get(prefix + key);
},
async put(key, value, opts) {
return base.put(prefix + key, value, opts);
},
async delete(key) {
return base.delete(prefix + key);
},
async list(opts = {}) {
const fullPrefix = prefix + (opts.prefix ?? "");
const result = await base.list({
prefix: fullPrefix,
limit: opts.limit,
cursor: opts.cursor,
});
return {
keys: result.keys.map((k) => (k.startsWith(prefix) ? k.slice(prefix.length) : k)),
cursor: result.cursor,
done: result.done,
};
},
async getJSON(key) {
return base.getJSON(prefix + key);
},
async putJSON(key, value, opts) {
return base.putJSON(prefix + key, value, opts);
},
};
}
const db = prefixedStore(dual, "wordle:");
await recordResult(db, 99, true);
// CF KV has the stats key.
expect(cfKv.store.has("wordle:stats:99")).toBe(true);
const cfStats = JSON.parse(cfKv.store.get("wordle:stats:99"));
expect(cfStats.wins).toBe(1);
expect(cfStats.streak).toBe(1);
// Mongo fake also has it.
const mongoColl = fakeDb.collection("wordle");
const mongoDoc = await mongoColl.findOne({ _id: "wordle:stats:99" });
expect(mongoDoc).not.toBeNull();
expect(JSON.parse(mongoDoc.value).wins).toBe(1);
// loadStats reads from primary.
const stats = await loadStats(db, 99);
expect(stats.wins).toBe(1);
});
});
// ---------------------------------------------------------------------------
// SQL / trades path — trading insert via MongoTradesStore
// ---------------------------------------------------------------------------
describe("SQL dual-write — trading insert via MongoTradesStore", () => {
it("recordTrade persists via MongoTradesStore (fake-mongo)", async () => {
// Trading uses MongoTradesStore directly (not via DualSqlStore) when tradesStore is provided.
const tradesStore = new MongoTradesStore(env, fakeDb);
await recordTrade(
null,
{
userId: 123,
symbol: "VNM",
side: "buy",
qty: 10,
priceVnd: 50000,
},
tradesStore,
);
// Mongo should have the trade.
const tradeColl = fakeDb.collection("trading_trades");
const docs = await tradeColl.find({}).toArray();
expect(docs).toHaveLength(1);
expect(docs[0].user_id).toBe(123);
expect(docs[0].symbol).toBe("VNM");
expect(docs[0].side).toBe("buy");
expect(docs[0].qty).toBe(10);
expect(docs[0].price_vnd).toBe(50000);
});
it("listTrades reads from MongoTradesStore", async () => {
const tradesStore = new MongoTradesStore(env, fakeDb);
// Insert two trades.
await recordTrade(
null,
{ userId: 7, symbol: "FPT", side: "buy", qty: 5, priceVnd: 100000 },
tradesStore,
);
await recordTrade(
null,
{ userId: 7, symbol: "VIC", side: "sell", qty: 2, priceVnd: 80000 },
tradesStore,
);
const trades = await listTrades(null, 7, 10, tradesStore);
expect(trades).toHaveLength(2);
// Newest first (MongoTradesStore returns sorted by ts desc).
expect(trades.map((t) => t.symbol)).toContain("FPT");
expect(trades.map((t) => t.symbol)).toContain("VIC");
});
it("DUAL_WRITE=1 env flag is present in env passed through registry init", () => {
// Verify the flag matrix expectation: when DUAL_WRITE=1 and MONGODB_URI is real,
// buildTradesStore should return a MongoTradesStore.
const localEnv = {
KV: cfKv,
MONGODB_URI: "mongodb://fake",
STORAGE_PRIMARY: "kv",
DUAL_WRITE: "1",
};
// Direct validation — the helper in registry.js would return a MongoTradesStore.
// We test its outcome indirectly: DUAL_WRITE="1" !== "0" is true.
expect(localEnv.DUAL_WRITE !== "0").toBe(true);
expect(!!localEnv.MONGODB_URI).toBe(true);
expect(localEnv.MONGODB_URI).not.toBe("__stub_mongo__");
});
});