161 lines
6.5 KiB
JavaScript
161 lines
6.5 KiB
JavaScript
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");
|
|
});
|
|
});
|