import { jest } from "@jest/globals"; // Manual DB mock: getPool() returns whatever fake client the current test set. let client; const getPool = jest.fn(async () => client); jest.unstable_mockModule("../db/index.js", () => ({ getPool })); const { PgSessionStore, expiryOf } = await import("./sessionStore.js"); const ONE_DAY_MS = 86_400_000; // A fake pooled client whose query() returns `rows` and that records release(). function mockClient(rows = []) { return { query: jest.fn(async () => ({ rows })), release: jest.fn(async () => {}), }; } // Store with the prune timer disabled; memoryTtlMs chosen per test. const makeStore = (opts = {}) => new PgSessionStore({ pruneIntervalMs: 0, ...opts }); // Promisify express-session's node-style callbacks for cleaner assertions. const call = (fn) => new Promise((resolve, reject) => fn((err, res) => (err ? reject(err) : resolve(res)))); beforeEach(() => { getPool.mockClear(); client = mockClient(); }); describe("expiryOf", () => { it("uses the cookie's expiry when present", () => { const when = new Date("2030-01-01T00:00:00Z"); expect(expiryOf({ cookie: { expires: when } }).getTime()).toBe(when.getTime()); }); it("defaults to ~one day out when no cookie expiry", () => { const before = Date.now(); const got = expiryOf({}).getTime(); expect(got).toBeGreaterThanOrEqual(before + ONE_DAY_MS - 50); expect(got).toBeLessThanOrEqual(Date.now() + ONE_DAY_MS + 50); }); }); describe("PgSessionStore memory + PG fallback", () => { it("get() falls back to Postgres on a cold cache and filters expired rows in SQL", async () => { client = mockClient([{ sess: { user_id: 7 }, expire: new Date(Date.now() + ONE_DAY_MS).toISOString() }]); const store = makeStore(); const sess = await call((cb) => store.get("sid-1", cb)); expect(sess).toEqual({ user_id: 7 }); expect(getPool).toHaveBeenCalledTimes(1); const [sql, params] = client.query.mock.calls[0]; expect(sql).toContain("expire > NOW()"); expect(params).toEqual(["sid-1"]); expect(client.release).toHaveBeenCalledTimes(1); }); it("get() serves a warm in-memory session WITHOUT querying Postgres", async () => { const store = makeStore({ memoryTtlMs: 10_000 }); await call((cb) => store.set("sid-2", { user_id: 1, cookie: {} }, cb)); // write-through populates memory getPool.mockClear(); const sess = await call((cb) => store.get("sid-2", cb)); expect(sess).toMatchObject({ user_id: 1 }); expect(getPool).not.toHaveBeenCalled(); // the whole point: no per-request query }); it("get() re-reads Postgres once the in-memory copy goes stale", async () => { client = mockClient([{ sess: { user_id: 5 }, expire: new Date(Date.now() + ONE_DAY_MS).toISOString() }]); const store = makeStore({ memoryTtlMs: 0 }); // memory never counts as fresh await call((cb) => store.set("sid", { user_id: 5, cookie: {} }, cb)); getPool.mockClear(); const sess = await call((cb) => store.get("sid", cb)); expect(getPool).toHaveBeenCalledTimes(1); // stale -> fell back to Postgres expect(sess).toEqual({ user_id: 5 }); }); it("get() drops an expired in-memory entry without a query", async () => { const store = makeStore({ memoryTtlMs: 10_000 }); await call((cb) => store.set("exp", { user_id: 1, cookie: { expires: new Date(Date.now() - 1000) } }, cb)); getPool.mockClear(); const sess = await call((cb) => store.get("exp", cb)); expect(sess).toBeNull(); expect(getPool).not.toHaveBeenCalled(); }); it("get() returns null when no row matches", async () => { client = mockClient([]); const sess = await call((cb) => makeStore().get("missing", cb)); expect(sess).toBeNull(); }); it("set() write-through upserts to Postgres (bumping updated_ts) and populates memory", async () => { const store = makeStore({ memoryTtlMs: 10_000 }); const when = new Date("2030-06-01T00:00:00Z"); await call((cb) => store.set("sid-3", { cookie: { expires: when }, user_id: 1 }, cb)); const [sql, params] = client.query.mock.calls[0]; expect(sql).toContain("ON CONFLICT (sid) DO UPDATE"); expect(sql).toContain("updated_ts = NOW()"); expect(params[0]).toBe("sid-3"); expect(params[2]).toEqual(when); getPool.mockClear(); const sess = await call((cb) => store.get("sid-3", cb)); expect(getPool).not.toHaveBeenCalled(); // served from memory after the write-through expect(sess).toMatchObject({ user_id: 1 }); }); it("destroy() removes the session from memory and Postgres", async () => { const store = makeStore({ memoryTtlMs: 10_000 }); await call((cb) => store.set("sid-4", { user_id: 1, cookie: {} }, cb)); client.query.mockClear(); await call((cb) => store.destroy("sid-4", cb)); expect(client.query.mock.calls[0][0]).toContain("DELETE FROM session WHERE sid = $1"); expect(client.query.mock.calls[0][1]).toEqual(["sid-4"]); // Memory was cleared -> the next get falls back to Postgres (now empty). client = mockClient([]); getPool.mockClear(); expect(await call((cb) => store.get("sid-4", cb))).toBeNull(); expect(getPool).toHaveBeenCalledTimes(1); }); it("touch() bumps only the expiry", async () => { await call((cb) => makeStore().touch("sid-5", {}, cb)); expect(client.query.mock.calls[0][0]).toContain("UPDATE session SET expire = $2"); }); it("prune() sweeps expired rows from Postgres", async () => { const store = makeStore(); await store.prune(); expect(client.query.mock.calls[0][0]).toContain("DELETE FROM session WHERE expire <= NOW()"); expect(client.release).toHaveBeenCalledTimes(1); }); it("releases the client and surfaces the error when a query fails", async () => { client = { query: jest.fn(async () => { throw new Error("db down"); }), release: jest.fn(async () => {}), }; await expect(call((cb) => makeStore({ memoryTtlMs: 0 }).get("sid", cb))).rejects.toThrow("db down"); expect(client.release).toHaveBeenCalledTimes(1); }); it("surfaces the error and calls back exactly once when acquiring a client fails", async () => { getPool.mockImplementationOnce(async () => { throw new Error("pool exhausted"); }); const store = makeStore({ memoryTtlMs: 0 }); const cb = jest.fn(); await new Promise((resolve) => store.get("sid", (...args) => { cb(...args); resolve(); })); expect(cb).toHaveBeenCalledTimes(1); expect(cb.mock.calls[0][0]).toBeInstanceOf(Error); expect(cb.mock.calls[0][0].message).toBe("pool exhausted"); }); });