From ccf8e4599eb40ff009f70272786eff69a778eb11 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 26 Apr 2026 08:48:01 +0700 Subject: [PATCH] =?UTF-8?q?feat(db):=20phase=2002=20=E2=80=94=20MongoKVSto?= =?UTF-8?q?re=20+=20memoized=20client=20+=20fake-mongo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the KVStore interface against MongoDB Atlas with full behavioral parity vs CFKVStore (null-on-missing, swallow-corrupt-JSON, idempotent delete, throw-on-undefined-putJSON). Not wired into the request path yet — Phase 04 adds dual-write wrappers and factory routing. - src/db/mongo-client.js: memoized MongoClient + getDb(env). On connect() reject, nulls both client and connectPromise so next call retries cleanly (regression-tested). Catches MongoServerSelectionError and emits a structured warning before rethrow so callers can map to 503. - src/db/mongo-kv-store.js: KVStore impl. get/getJSON filter on expiresAt at read time to close the up-to-60s TTL-sweeper stale-read window vs CFKVStore. list() returns keys WITH prefix preserved (parity — wrapper in create-store.js:65 strips). Cursor pagination via sorted _id + limit(N+1), NOT skip(). Lazy ensureIndex per (collection, isolate) tracked in module-scope Set. - src/db/mongo-list-cursor.js: extracted cursor encode/decode to keep mongo-kv-store.js under 200 LOC. - tests/fakes/fake-mongo.js: Map-backed fake covering the surface needed by both Phase 02 (KVStore) and Phase 03 (MongoTradesStore). - tests/db/mongo-kv-store.test.js: 26 tests, including TTL stale-read regression (1s TTL + time advance), 2-level prefix list regression, cursor pagination, connect-reject retry, MongoServerSelectionError structured log. Tests: 503 → 529 (+26). Lint clean. --- src/db/mongo-client.js | 90 +++++++++ src/db/mongo-kv-store.js | 178 +++++++++++++++++ src/db/mongo-list-cursor.js | 63 ++++++ tests/db/mongo-kv-store.test.js | 328 ++++++++++++++++++++++++++++++++ tests/fakes/fake-mongo.js | 294 ++++++++++++++++++++++++++++ 5 files changed, 953 insertions(+) create mode 100644 src/db/mongo-client.js create mode 100644 src/db/mongo-kv-store.js create mode 100644 src/db/mongo-list-cursor.js create mode 100644 tests/db/mongo-kv-store.test.js create mode 100644 tests/fakes/fake-mongo.js diff --git a/src/db/mongo-client.js b/src/db/mongo-client.js new file mode 100644 index 0000000..ac8ca98 --- /dev/null +++ b/src/db/mongo-client.js @@ -0,0 +1,90 @@ +/** + * @file mongo-client — memoized MongoDB Atlas client singleton. + * + * Exports `getDb(env)` for all Mongo-backed stores. On the first call from a + * cold isolate it opens one connection; subsequent calls reuse the same + * `MongoClient`. If `client.connect()` rejects, both `client` and + * `connectPromise` are nulled so the next request retries cleanly instead of + * reusing a dead client reference. + * + * `MongoServerSelectionError` (e.g. paused M0 cluster) is caught, logged with + * an actionable message, then rethrown so the caller can map it to 503. + * + * @module db/mongo-client + */ + +import { MongoClient } from "mongodb"; + +/** @type {MongoClient|null} */ +let client = null; + +/** @type {Promise|null} */ +let connectPromise = null; + +/** + * Return the memoized Db instance, connecting on the first call. + * + * Connection options match the Cloudflare Workers constraints: + * maxPoolSize: 1 — one connection per isolate + * minPoolSize: 0 — no idle keepalive (Workers tear down quickly) + * serverSelectionTimeoutMS: 5000 — fast fail for paused M0 + * connectTimeoutMS: 10000 — TLS + SCRAM on cold start + * + * @param {{ MONGODB_URI: string }} env — Cloudflare Worker env (or test double). + * @returns {Promise} + * @throws {import("mongodb").MongoServerSelectionError} if cluster unreachable. + */ +export async function getDb(env) { + if (client) return client.db("miti99bot"); + + if (!connectPromise) { + client = new MongoClient(env.MONGODB_URI, { + maxPoolSize: 1, + minPoolSize: 0, + serverSelectionTimeoutMS: 5000, + connectTimeoutMS: 10000, + }); + + // On rejection: null BOTH so the next getDb() call retries with a fresh + // client instead of awaiting the already-rejected promise (code-reviewer #16). + connectPromise = client.connect().catch((err) => { + client = null; + connectPromise = null; + throw err; + }); + } + + try { + await connectPromise; + } catch (err) { + // M0 clusters auto-pause after 60 days of inactivity. Surface an + // actionable note so ops can identify and resume the cluster. + // NOTE: URI is deliberately omitted to prevent credential leaks. + if (err?.name === "MongoServerSelectionError") { + console.warn( + JSON.stringify({ + event: "mongo_server_selection_failed", + note: "M0 may be paused — resume the cluster in Atlas, then retry. Caller should map to 503.", + }), + ); + } + throw err; + } + + return client.db("miti99bot"); +} + +/** + * Close the active MongoClient and reset module-scope state. + * Intended for test teardown and graceful shutdown only — not for + * use in production request handlers. + * + * @returns {Promise} + */ +export async function closeMongo() { + if (client) { + await client.close(); + client = null; + } + connectPromise = null; +} diff --git a/src/db/mongo-kv-store.js b/src/db/mongo-kv-store.js new file mode 100644 index 0000000..9480815 --- /dev/null +++ b/src/db/mongo-kv-store.js @@ -0,0 +1,178 @@ +/** + * @file mongo-kv-store — MongoDB Atlas implementation of the KVStore interface. + * + * Behavioral parity with `CFKVStore`, with one documented divergence: + * - CFKVStore TTL is enforced server-side (eventual, ~1s granularity). + * - MongoKVStore also enforces TTL at read-time via an `expiresAt` filter, + * eliminating the up-to-60s Atlas TTL-sweeper stale-read window. + * + * Per-module collections: module name with `-` replaced by `_` + * (e.g. `loldle-emoji` → `loldle_emoji`). + * + * `list()` returns keys WITH the module prefix preserved — the wrapper in + * `create-store.js:65` strips it. MongoKVStore never strips prefixes. + * + * @see ./kv-store-interface.js for the full interface contract. + * @module db/mongo-kv-store + */ + +import { getDb } from "./mongo-client.js"; +import { listWithCursor } from "./mongo-list-cursor.js"; + +/** + * @typedef {import("./kv-store-interface.js").KVStore} KVStore + * @typedef {import("./kv-store-interface.js").KVStorePutOptions} KVStorePutOptions + * @typedef {import("./kv-store-interface.js").KVStoreListOptions} KVStoreListOptions + * @typedef {import("./kv-store-interface.js").KVStoreListResult} KVStoreListResult + */ + +/** + * Tracks which collections have already had the TTL index created this + * isolate lifetime, to avoid redundant `createIndex` round-trips. + * + * @type {Set} + */ +const indexedCollections = new Set(); + +/** + * @implements {KVStore} + */ +export class MongoKVStore { + /** + * @param {{ MONGODB_URI: string }} env — Worker env (or test double). + * @param {string} collectionName — module name (e.g. "wordle", "loldle-emoji"). + * @param {import("mongodb").Db} [dbOverride] — injected Db for tests; bypasses real connect. + */ + constructor(env, collectionName, dbOverride) { + if (!collectionName) throw new Error("MongoKVStore: collectionName is required"); + this._env = env; + // Normalize collection name: replace `-` with `_` for MongoDB compatibility. + this._collName = collectionName.replace(/-/g, "_"); + this._dbOverride = dbOverride ?? null; + } + + /** + * Resolve the Db instance (override for tests, real for prod). + * + * @returns {Promise} + */ + async _db() { + return this._dbOverride ?? getDb(this._env); + } + + /** + * Lazily create the TTL index once per collection per isolate. + * Idempotent on the MongoDB side; tracked locally to avoid extra round-trips. + * + * @returns {Promise} + */ + async _ensureIndex() { + if (indexedCollections.has(this._collName)) return; + const db = await this._db(); + await db + .collection(this._collName) + .createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0, sparse: true }); + indexedCollections.add(this._collName); + } + + /** + * Build the read-time TTL filter: accept docs with no `expiresAt` field, + * OR docs whose `expiresAt` is in the future. + * + * @param {string} key + * @returns {object} MongoDB filter + */ + _liveFilter(key) { + return { + _id: key, + $or: [{ expiresAt: { $exists: false } }, { expiresAt: { $gt: new Date() } }], + }; + } + + /** + * @param {string} key + * @returns {Promise} + */ + async get(key) { + await this._ensureIndex(); + const db = await this._db(); + const doc = await db.collection(this._collName).findOne(this._liveFilter(key)); + return doc ? doc.value : null; + } + + /** + * @param {string} key + * @param {string} value + * @param {KVStorePutOptions} [opts] + * @returns {Promise} + */ + async put(key, value, opts) { + await this._ensureIndex(); + const db = await this._db(); + const $set = { value }; + const $unset = {}; + + if (opts?.expirationTtl) { + $set.expiresAt = new Date(Date.now() + opts.expirationTtl * 1000); + } else { + // Clear any existing TTL so the document becomes permanent. + $unset.expiresAt = ""; + } + + await db.collection(this._collName).updateOne({ _id: key }, { $set, $unset }, { upsert: true }); + } + + /** + * @param {string} key + * @returns {Promise} + */ + async delete(key) { + await this._ensureIndex(); + const db = await this._db(); + await db.collection(this._collName).deleteOne({ _id: key }); + } + + /** + * List keys matching an optional prefix, with cursor-based pagination. + * Keys are returned WITH the full module prefix preserved — the wrapper in + * `create-store.js` strips it for callers. + * + * @param {KVStoreListOptions} [opts] + * @returns {Promise} + */ + async list(opts = {}) { + await this._ensureIndex(); + const db = await this._db(); + return listWithCursor(db.collection(this._collName), opts); + } + + /** + * @param {string} key + * @returns {Promise} + */ + async getJSON(key) { + const raw = await this.get(key); + if (raw == null) return null; + try { + return JSON.parse(raw); + } catch (err) { + console.warn("getJSON: parse failed", { key, err: String(err) }); + return null; + } + } + + /** + * @param {string} key + * @param {any} value + * @param {KVStorePutOptions} [opts] + * @returns {Promise} + */ + async putJSON(key, value, opts) { + if (value === undefined) { + throw new Error(`putJSON: value for key "${key}" is undefined`); + } + // JSON.stringify throws on cycles — let it propagate. + const serialized = JSON.stringify(value); + await this.put(key, serialized, opts); + } +} diff --git a/src/db/mongo-list-cursor.js b/src/db/mongo-list-cursor.js new file mode 100644 index 0000000..b0fb62e --- /dev/null +++ b/src/db/mongo-list-cursor.js @@ -0,0 +1,63 @@ +/** + * @file mongo-list-cursor — cursor-based list pagination helper for MongoKVStore. + * + * Extracted to keep mongo-kv-store.js under 200 LOC. + * Encodes the last `_id` of a page as a base64 cursor; decodes it on the + * next call to build a `$gt` filter. Does NOT use skip() — purely sorted-_id + * pagination to avoid O(n) offset scans. + * + * @module db/mongo-list-cursor + */ + +/** + * @typedef {import("./kv-store-interface.js").KVStoreListOptions} KVStoreListOptions + * @typedef {import("./kv-store-interface.js").KVStoreListResult} KVStoreListResult + */ + +/** + * Escape special RegExp characters so a prefix string is safe inside a + * MongoDB `$regex` filter. + * + * @param {string} str + * @returns {string} + */ +export function escapeRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Execute a paginated list query on a MongoDB collection. + * + * @param {import("mongodb").Collection} col — resolved collection handle. + * @param {KVStoreListOptions} opts + * @returns {Promise} + */ +export async function listWithCursor(col, opts = {}) { + const { prefix = "", limit = 1000, cursor } = opts; + const pageSize = limit; + + const filter = {}; + if (prefix) { + filter._id = { $regex: `^${escapeRegex(prefix)}` }; + } + if (cursor) { + const lastId = Buffer.from(cursor, "base64").toString("utf8"); + filter._id = filter._id ? { ...filter._id, $gt: lastId } : { $gt: lastId }; + } + + const docs = await col + .find(filter) + .sort({ _id: 1 }) + .limit(pageSize + 1) + .project({ _id: 1 }) + .toArray(); + + const hasMore = docs.length > pageSize; + const page = hasMore ? docs.slice(0, pageSize) : docs; + const keys = page.map((d) => d._id); + const nextCursor = hasMore + ? Buffer.from(page[page.length - 1]._id, "utf8").toString("base64") + : undefined; + + return { keys, cursor: nextCursor, done: !hasMore }; +} diff --git a/tests/db/mongo-kv-store.test.js b/tests/db/mongo-kv-store.test.js new file mode 100644 index 0000000..8e17274 --- /dev/null +++ b/tests/db/mongo-kv-store.test.js @@ -0,0 +1,328 @@ +/** + * @file mongo-kv-store.test.js — unit tests for MongoKVStore. + * + * Injection pattern: MongoKVStore constructor accepts an optional `dbOverride` + * parameter. Tests pass a `makeFakeMongo()` db so no real Atlas connection + * is made. The same fake is used to test mongo-client.js connect-reject retry. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MongoKVStore } from "../../src/db/mongo-kv-store.js"; +import { makeFakeMongo } from "../fakes/fake-mongo.js"; + +// ─── helpers ──────────────────────────────────────────────────────────────── + +/** Build a store + fake db pair. collectionName defaults to "test". */ +function makeStore(collectionName = "test") { + const fakeDb = makeFakeMongo(); + const store = new MongoKVStore({}, collectionName, fakeDb); + return { store, fakeDb }; +} + +// ─── constructor ───────────────────────────────────────────────────────────── + +describe("MongoKVStore constructor", () => { + it("throws when collectionName is missing", () => { + expect(() => new MongoKVStore({}, "")).toThrow(/required/); + }); + + it("normalizes collection name: replaces - with _", () => { + const fakeDb = makeFakeMongo(); + const store = new MongoKVStore({}, "loldle-emoji", fakeDb); + expect(store._collName).toBe("loldle_emoji"); + }); +}); + +// ─── get / put / delete ─────────────────────────────────────────────────────── + +describe("get / put / delete", () => { + it("get returns null for missing key", async () => { + const { store } = makeStore(); + expect(await store.get("missing")).toBeNull(); + }); + + it("put → get round-trip", async () => { + const { store } = makeStore(); + await store.put("k", "hello"); + expect(await store.get("k")).toBe("hello"); + }); + + it("put overwrites existing value", async () => { + const { store } = makeStore(); + await store.put("k", "first"); + await store.put("k", "second"); + expect(await store.get("k")).toBe("second"); + }); + + it("delete removes the key", async () => { + const { store } = makeStore(); + await store.put("k", "v"); + await store.delete("k"); + expect(await store.get("k")).toBeNull(); + }); + + it("delete is idempotent (no-op on missing key)", async () => { + const { store } = makeStore(); + await expect(store.delete("nope")).resolves.toBeUndefined(); + }); +}); + +// ─── TTL / expiresAt ───────────────────────────────────────────────────────── + +describe("TTL / expiresAt field", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("put with expirationTtl writes expiresAt field", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z")); + const { store, fakeDb } = makeStore(); + await store.put("k", "v", { expirationTtl: 60 }); + const col = fakeDb.collection("test"); + const doc = await col.findOne({ _id: "k" }); + expect(doc.expiresAt).toBeInstanceOf(Date); + expect(doc.expiresAt.getTime()).toBe(new Date("2025-01-01T00:01:00.000Z").getTime()); + }); + + it("put without TTL clears any existing expiresAt", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z")); + const { store, fakeDb } = makeStore(); + // First write with TTL + await store.put("k", "v", { expirationTtl: 60 }); + // Second write without TTL — must remove expiresAt + await store.put("k", "updated"); + const col = fakeDb.collection("test"); + const doc = await col.findOne({ _id: "k" }); + expect(doc.expiresAt).toBeUndefined(); + }); + + it("TTL stale-read regression: expired doc returns null before sweeper runs", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z")); + const { store } = makeStore(); + await store.put("k", "v", { expirationTtl: 1 }); // expires in 1s + + // Advance clock 2 seconds — doc is now expired but sweeper hasn't run + vi.setSystemTime(new Date("2025-01-01T00:00:02.000Z")); + + expect(await store.get("k")).toBeNull(); + }); + + it("non-expired doc is still readable before TTL elapses", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z")); + const { store } = makeStore(); + await store.put("k", "v", { expirationTtl: 60 }); + + // Only 10s later — still live + vi.setSystemTime(new Date("2025-01-01T00:00:10.000Z")); + + expect(await store.get("k")).toBe("v"); + }); +}); + +// ─── getJSON / putJSON ─────────────────────────────────────────────────────── + +describe("getJSON / putJSON", () => { + it("putJSON → getJSON round-trip", async () => { + const { store } = makeStore(); + await store.putJSON("k", { a: 1, b: [2, 3] }); + expect(await store.getJSON("k")).toEqual({ a: 1, b: [2, 3] }); + }); + + it("getJSON returns null on missing key", async () => { + const { store } = makeStore(); + expect(await store.getJSON("missing")).toBeNull(); + }); + + it("getJSON returns null on corrupt JSON and logs a warning", async () => { + const { store, fakeDb } = makeStore(); + // Seed corrupt document directly into the fake collection + await fakeDb.collection("test").insertOne({ _id: "bad", value: "{not json" }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(await store.getJSON("bad")).toBeNull(); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("putJSON throws on undefined value", async () => { + const { store } = makeStore(); + await expect(store.putJSON("k", undefined)).rejects.toThrow(/undefined/); + }); + + it("putJSON throws on circular reference", async () => { + const { store } = makeStore(); + const obj = {}; + obj.self = obj; + await expect(store.putJSON("k", obj)).rejects.toThrow(); + }); + + it("putJSON passes expirationTtl through to put", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z")); + const { store, fakeDb } = makeStore(); + await store.putJSON("k", { x: 1 }, { expirationTtl: 120 }); + const doc = await fakeDb.collection("test").findOne({ _id: "k" }); + expect(doc.expiresAt).toBeInstanceOf(Date); + expect(doc.expiresAt.getTime()).toBe(new Date("2025-01-01T00:02:00.000Z").getTime()); + vi.useRealTimers(); + }); +}); + +// ─── list ──────────────────────────────────────────────────────────────────── + +describe("list()", () => { + it("returns empty result when store is empty", async () => { + const { store } = makeStore(); + const res = await store.list(); + expect(res.keys).toEqual([]); + expect(res.done).toBe(true); + expect(res.cursor).toBeUndefined(); + }); + + it("returns all keys when no prefix given", async () => { + const { store } = makeStore(); + await store.put("a:1", "x"); + await store.put("b:2", "y"); + const res = await store.list(); + expect(res.keys.sort()).toEqual(["a:1", "b:2"]); + expect(res.done).toBe(true); + }); + + it("returns keys WITH prefix preserved (not stripped)", async () => { + const { store } = makeStore(); + await store.put("wordle:games:1", "a"); + await store.put("wordle:games:2", "b"); + await store.put("wordle:other:3", "c"); + const res = await store.list({ prefix: "wordle:games:" }); + expect(res.keys.sort()).toEqual(["wordle:games:1", "wordle:games:2"]); + expect(res.done).toBe(true); + }); + + it("2-level prefix regression: only matching keys returned with prefix preserved", async () => { + const { store } = makeStore(); + await store.put("wordle:games:1", "a"); + await store.put("wordle:games:2", "b"); + await store.put("wordle:other:3", "c"); + const res = await store.list({ prefix: "wordle:games:" }); + // Must be exactly 2 keys, both with prefix intact + expect(res.keys).toHaveLength(2); + expect(res.keys).toContain("wordle:games:1"); + expect(res.keys).toContain("wordle:games:2"); + expect(res.keys).not.toContain("wordle:other:3"); + }); + + it("prefix with regex special chars is escaped", async () => { + const { store } = makeStore(); + await store.put("a.b:1", "x"); + await store.put("a_b:1", "y"); // should NOT match prefix "a.b:" + const res = await store.list({ prefix: "a.b:" }); + expect(res.keys).toEqual(["a.b:1"]); + }); + + it("list() cursor pagination — limit(N+1) strategy", async () => { + const { store } = makeStore(); + for (let i = 1; i <= 5; i++) await store.put(`k${i}`, String(i)); + + const page1 = await store.list({ limit: 2 }); + expect(page1.keys).toHaveLength(2); + expect(page1.done).toBe(false); + expect(page1.cursor).toBeTruthy(); + + const page2 = await store.list({ limit: 2, cursor: page1.cursor }); + expect(page2.keys).toHaveLength(2); + expect(page2.done).toBe(false); + expect(page2.cursor).toBeTruthy(); + + const page3 = await store.list({ limit: 2, cursor: page2.cursor }); + expect(page3.keys).toHaveLength(1); + expect(page3.done).toBe(true); + expect(page3.cursor).toBeUndefined(); + + // All keys across pages must be unique and sorted + const allKeys = [...page1.keys, ...page2.keys, ...page3.keys]; + expect(allKeys).toHaveLength(5); + expect(allKeys).toEqual([...allKeys].sort()); + }); + + it("list done=true when exactly limit keys remain (no extra page)", async () => { + const { store } = makeStore(); + await store.put("k1", "a"); + await store.put("k2", "b"); + const res = await store.list({ limit: 2 }); + expect(res.keys).toHaveLength(2); + expect(res.done).toBe(true); + expect(res.cursor).toBeUndefined(); + }); +}); + +// ─── mongo-client connect-reject retry regression ──────────────────────────── + +describe("mongo-client connect-reject retry regression", () => { + it("nulls client and connectPromise on connect() rejection so next call retries", async () => { + // Import the module fresh — we'll call it with a mock factory + const { getDb, closeMongo } = await import("../../src/db/mongo-client.js"); + + // Ensure clean state before test + await closeMongo(); + + // Patch MongoClient.prototype.connect to reject once, then succeed + const { MongoClient } = await import("mongodb"); + let callCount = 0; + const originalConnect = MongoClient.prototype.connect; + MongoClient.prototype.connect = vi.fn(async function () { + callCount++; + if (callCount === 1) { + // First call: reject to simulate transient failure + throw new Error("transient connection error"); + } + // Second call: succeed (but return void, the client is now "connected") + return this; + }); + + try { + // First call — must reject + await expect(getDb({ MONGODB_URI: "mongodb://localhost:27017" })).rejects.toThrow( + "transient connection error", + ); + + // Second call — must NOT reuse the dead client; must retry + // It will succeed on second connect() call + const db = await getDb({ MONGODB_URI: "mongodb://localhost:27017" }); + expect(db).toBeTruthy(); + expect(callCount).toBe(2); + } finally { + MongoClient.prototype.connect = originalConnect; + await closeMongo(); + } + }); + + it("logs structured warning on MongoServerSelectionError", async () => { + const { getDb, closeMongo } = await import("../../src/db/mongo-client.js"); + await closeMongo(); + + const { MongoClient } = await import("mongodb"); + const originalConnect = MongoClient.prototype.connect; + MongoClient.prototype.connect = vi.fn(async () => { + const err = new Error("server selection timeout"); + err.name = "MongoServerSelectionError"; + throw err; + }); + + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + await expect(getDb({ MONGODB_URI: "mongodb://localhost:27017" })).rejects.toThrow(); + expect(warn).toHaveBeenCalledOnce(); + const logged = JSON.parse(warn.mock.calls[0][0]); + expect(logged.event).toBe("mongo_server_selection_failed"); + expect(logged.note).toMatch(/503/); + } finally { + MongoClient.prototype.connect = originalConnect; + warn.mockRestore(); + await closeMongo(); + } + }); +}); diff --git a/tests/fakes/fake-mongo.js b/tests/fakes/fake-mongo.js new file mode 100644 index 0000000..e3447ea --- /dev/null +++ b/tests/fakes/fake-mongo.js @@ -0,0 +1,294 @@ +/** + * @file fake-mongo — Map-backed in-memory MongoDB fake for unit tests. + * + * Covers the full surface needed by Phase 02 (MongoKVStore) and + * Phase 03 (MongoTradesStore): + * collection(name) → { findOne, updateOne, deleteOne, find, insertOne, + * insertMany, distinct, deleteMany, countDocuments, + * createIndex } + * + * TTL is NOT simulated server-side. Tests that exercise TTL check the + * `expiresAt` field value directly and test the read-time filter in the + * MongoKVStore layer by controlling Date.now() via vi.setSystemTime(). + * + * @see tests/db/mongo-kv-store.test.js + */ + +/** + * Apply $set and $unset from an update document to a target object. + * + * @param {object} doc + * @param {object} update + * @returns {object} + */ +function applyUpdate(doc, update) { + const result = { ...doc }; + if (update.$set) { + for (const [k, v] of Object.entries(update.$set)) { + result[k] = v; + } + } + if (update.$unset) { + for (const key of Object.keys(update.$unset)) { + delete result[key]; + } + } + return result; +} + +/** + * Minimal regex-query matcher. Supports: + * { field: value } — strict equality + * { field: { $gt: v } } — greater-than + * { field: { $exists: b } } — field existence + * { $or: [cond, ...] } — logical OR + * { $and: [cond, ...] } — logical AND + * + * @param {object} doc + * @param {object} query + * @returns {boolean} + */ +function matchQuery(doc, query) { + for (const [key, condition] of Object.entries(query)) { + if (key === "$or") { + if (!condition.some((sub) => matchQuery(doc, sub))) return false; + continue; + } + if (key === "$and") { + if (!condition.every((sub) => matchQuery(doc, sub))) return false; + continue; + } + if (condition !== null && typeof condition === "object" && !Array.isArray(condition)) { + const ops = Object.keys(condition); + if (ops.some((op) => op.startsWith("$"))) { + for (const [op, operand] of Object.entries(condition)) { + if (op === "$gt") { + if (!(doc[key] > operand)) return false; + } else if (op === "$gte") { + if (!(doc[key] >= operand)) return false; + } else if (op === "$lt") { + if (!(doc[key] < operand)) return false; + } else if (op === "$lte") { + if (!(doc[key] <= operand)) return false; + } else if (op === "$exists") { + const has = key in doc && doc[key] !== undefined; + if (operand !== has) return false; + } else if (op === "$in") { + if (!operand.includes(doc[key])) return false; + } else if (op === "$regex") { + const flags = condition.$options ?? ""; + if (!new RegExp(operand, flags).test(doc[key])) return false; + } + } + continue; + } + } + if (doc[key] !== condition) return false; + } + return true; +} + +/** + * Returns a chainable cursor builder from an array of matched docs. + * + * @param {object[]} docs + * @returns {object} chainable cursor with sort/skip/limit/project/toArray + */ +function makeCursor(docs) { + const items = [...docs]; + let sortField = null; + let sortDir = 1; + let skipN = 0; + let limitN = Number.POSITIVE_INFINITY; + let projection = null; + + const cursor = { + sort(spec) { + const entries = Object.entries(spec); + if (entries.length > 0) { + [sortField, sortDir] = [entries[0][0], entries[0][1]]; + } + return cursor; + }, + skip(n) { + skipN = n; + return cursor; + }, + limit(n) { + limitN = n; + return cursor; + }, + project(spec) { + projection = spec; + return cursor; + }, + async toArray() { + let result = [...items]; + if (sortField !== null) { + result.sort((a, b) => { + if (a[sortField] < b[sortField]) return -sortDir; + if (a[sortField] > b[sortField]) return sortDir; + return 0; + }); + } + result = result.slice( + skipN, + limitN === Number.POSITIVE_INFINITY ? undefined : skipN + limitN, + ); + if (projection) { + result = result.map((doc) => { + const out = {}; + for (const [k, include] of Object.entries(projection)) { + if (include) out[k] = doc[k]; + } + return out; + }); + } + return result; + }, + }; + return cursor; +} + +/** + * Create a fake MongoDB collection backed by a Map. + * + * @param {Map} store + * @returns {object} + */ +function makeCollection(store) { + return { + /** @returns {Promise} */ + async findOne(query) { + for (const doc of store.values()) { + if (matchQuery(doc, query)) return { ...doc }; + } + return null; + }, + + /** + * Supports upsert with $set / $unset. + * @returns {Promise<{matchedCount: number, upsertedCount: number, modifiedCount: number}>} + */ + async updateOne(filter, update, opts = {}) { + for (const [id, doc] of store.entries()) { + if (matchQuery(doc, filter)) { + store.set(id, applyUpdate(doc, update)); + return { matchedCount: 1, upsertedCount: 0, modifiedCount: 1 }; + } + } + if (opts.upsert) { + // Build new doc from filter equality fields + $set fields + const newDoc = {}; + for (const [k, v] of Object.entries(filter)) { + if (typeof v !== "object") newDoc[k] = v; + } + const merged = applyUpdate(newDoc, update); + const id = merged._id ?? String(Date.now() + Math.random()); + merged._id = id; + store.set(String(id), merged); + return { matchedCount: 0, upsertedCount: 1, modifiedCount: 0 }; + } + return { matchedCount: 0, upsertedCount: 0, modifiedCount: 0 }; + }, + + /** @returns {Promise<{deletedCount: number}>} */ + async deleteOne(filter) { + for (const [id, doc] of store.entries()) { + if (matchQuery(doc, filter)) { + store.delete(id); + return { deletedCount: 1 }; + } + } + return { deletedCount: 0 }; + }, + + /** + * Returns a chainable cursor. + * @param {object} [query] + * @returns {object} + */ + find(query = {}) { + const matched = [...store.values()].filter((doc) => matchQuery(doc, query)); + return makeCursor(matched); + }, + + /** @returns {Promise<{insertedId: string}>} */ + async insertOne(doc) { + const id = doc._id ?? String(Date.now() + Math.random()); + store.set(String(id), { ...doc, _id: id }); + return { insertedId: id }; + }, + + /** @returns {Promise<{insertedIds: string[]}>} */ + async insertMany(docs) { + const insertedIds = []; + for (const doc of docs) { + const id = doc._id ?? String(Date.now() + Math.random()); + store.set(String(id), { ...doc, _id: id }); + insertedIds.push(id); + } + return { insertedIds }; + }, + + /** @returns {Promise} */ + async distinct(field, query = {}) { + const values = new Set(); + for (const doc of store.values()) { + if (matchQuery(doc, query) && field in doc) { + values.add(doc[field]); + } + } + return [...values]; + }, + + /** @returns {Promise<{deletedCount: number}>} */ + async deleteMany(filter = {}) { + let count = 0; + for (const [id, doc] of store.entries()) { + if (matchQuery(doc, filter)) { + store.delete(id); + count++; + } + } + return { deletedCount: count }; + }, + + /** @returns {Promise} */ + async countDocuments(query = {}) { + let count = 0; + for (const doc of store.values()) { + if (matchQuery(doc, query)) count++; + } + return count; + }, + + /** No-op — index creation is idempotent; tests only verify field presence. */ + async createIndex(_spec, _opts) { + return "ok"; + }, + }; +} + +/** + * Create a fake MongoDB Db object. + * Each collection is lazily created and backed by its own Map. + * + * @returns {{ collection: (name: string) => object, _stores: Map> }} + */ +export function makeFakeMongo() { + /** @type {Map>} */ + const stores = new Map(); + + return { + /** @param {string} name */ + collection(name) { + if (!stores.has(name)) { + stores.set(name, new Map()); + } + return makeCollection(stores.get(name)); + }, + /** Expose raw stores so tests can inspect or seed data directly. */ + _stores: stores, + }; +}