146 lines
5.4 KiB
TypeScript
146 lines
5.4 KiB
TypeScript
/**
|
|
* Tests for searchCache.ts — getOrCoalesce with ttlMs=0 bypass.
|
|
*
|
|
* Verifies:
|
|
* 1. ttlMs=0 bypasses the cache lookup (no stale data returned)
|
|
* 2. ttlMs=0 bypasses inflight coalescing (each concurrent call gets its own fetch)
|
|
* 3. Both concurrent callers receive { cached: false }
|
|
* 4. hits counter is NOT incremented for ttlMs=0 calls
|
|
* 5. ttlMs>0 still coalesces concurrent calls correctly
|
|
* 6. ?? operator in route.ts: cacheTTLMs=0 is not treated as falsy
|
|
*/
|
|
|
|
import { describe, it } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
getOrCoalesce,
|
|
computeCacheKey,
|
|
getCacheStats,
|
|
} from "../../open-sse/services/searchCache.ts";
|
|
|
|
// ── Helper ────────────────────────────────────────────────────────────────────
|
|
|
|
function makeKey(suffix = "") {
|
|
return computeCacheKey(`ttl-zero-test-${suffix}-${Date.now()}`, "test", "search", 10);
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
describe("getOrCoalesce — ttlMs=0 bypass", () => {
|
|
it("returns { cached: false } when ttlMs=0", async () => {
|
|
const key = makeKey("basic");
|
|
let callCount = 0;
|
|
const fetchFn = async () => {
|
|
callCount++;
|
|
return { result: "fresh" };
|
|
};
|
|
|
|
const result = await getOrCoalesce(key, 0, fetchFn);
|
|
|
|
assert.equal(result.cached, false, "cached flag must be false when ttlMs=0");
|
|
assert.deepEqual(result.data, { result: "fresh" });
|
|
assert.equal(callCount, 1);
|
|
});
|
|
|
|
it("does NOT coalesce concurrent calls when ttlMs=0 — each gets its own fetch", async () => {
|
|
const key = makeKey("concurrent");
|
|
let callCount = 0;
|
|
|
|
const fetchFn = async () => {
|
|
callCount++;
|
|
// Slight delay to ensure both calls overlap
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
return { id: callCount };
|
|
};
|
|
|
|
// Launch two concurrent calls with the SAME key and ttlMs=0
|
|
const [r1, r2] = await Promise.all([
|
|
getOrCoalesce(key, 0, fetchFn),
|
|
getOrCoalesce(key, 0, fetchFn),
|
|
]);
|
|
|
|
// Both should be independent — fetchFn must have been called twice
|
|
assert.equal(callCount, 2, "fetchFn must be called independently for each ttlMs=0 caller");
|
|
assert.equal(r1.cached, false, "r1.cached must be false");
|
|
assert.equal(r2.cached, false, "r2.cached must be false");
|
|
});
|
|
|
|
it("does NOT increment hits counter for ttlMs=0 calls", async () => {
|
|
const key = makeKey("hits");
|
|
const before = getCacheStats().hits;
|
|
|
|
await getOrCoalesce(key, 0, async () => "data");
|
|
await getOrCoalesce(key, 0, async () => "data");
|
|
|
|
const after = getCacheStats();
|
|
assert.equal(after.hits, before, "hits must not increment for ttlMs=0 calls");
|
|
});
|
|
|
|
it("does NOT store result in cache when ttlMs=0 — subsequent call still fetches fresh", async () => {
|
|
const key = makeKey("no-store");
|
|
let callCount = 0;
|
|
const fetchFn = async () => {
|
|
callCount++;
|
|
return callCount;
|
|
};
|
|
|
|
await getOrCoalesce(key, 0, fetchFn);
|
|
const r2 = await getOrCoalesce(key, 0, fetchFn);
|
|
|
|
assert.equal(callCount, 2, "second call must also invoke fetchFn (not serve from cache)");
|
|
assert.equal(r2.data, 2);
|
|
assert.equal(r2.cached, false);
|
|
});
|
|
|
|
it("coalesces concurrent calls when ttlMs>0 (baseline sanity check)", async () => {
|
|
const key = makeKey("coalesce-positive");
|
|
let callCount = 0;
|
|
|
|
const fetchFn = async () => {
|
|
callCount++;
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
return "shared";
|
|
};
|
|
|
|
// Both calls launched with same key and ttlMs>0 — should coalesce
|
|
const [r1, r2] = await Promise.all([
|
|
getOrCoalesce(key, 5000, fetchFn),
|
|
getOrCoalesce(key, 5000, fetchFn),
|
|
]);
|
|
|
|
assert.equal(
|
|
callCount,
|
|
1,
|
|
"fetchFn should be called exactly once when coalescing with ttlMs>0"
|
|
);
|
|
assert.equal(r1.data, "shared");
|
|
assert.equal(r2.data, "shared");
|
|
});
|
|
});
|
|
|
|
// ── ?? operator behaviour (mirrors route.ts fix: cacheTTLMs ?? defaultTTL) ──
|
|
|
|
describe("cacheTTLMs ?? default (route.ts fix)", () => {
|
|
it("cacheTTLMs=0 must NOT fall back to default when using ?? (nullish coalescing)", () => {
|
|
const DEFAULT_TTL = 5 * 60 * 1000;
|
|
|
|
// Old behaviour: `cacheTTLMs || DEFAULT_TTL` treats 0 as falsy → returns DEFAULT_TTL (WRONG)
|
|
const oldBehaviour = (cacheTTLMs) => cacheTTLMs || DEFAULT_TTL;
|
|
// New behaviour: `cacheTTLMs ?? DEFAULT_TTL` only falls back for null/undefined (CORRECT)
|
|
const newBehaviour = (cacheTTLMs) => cacheTTLMs ?? DEFAULT_TTL;
|
|
|
|
// With cacheTTLMs=0, old behaviour incorrectly returns DEFAULT_TTL
|
|
assert.equal(oldBehaviour(0), DEFAULT_TTL, "Sanity: old || behaviour treats 0 as falsy");
|
|
|
|
// With cacheTTLMs=0, new behaviour correctly returns 0
|
|
assert.equal(newBehaviour(0), 0, "?? must preserve 0 as a valid TTL");
|
|
|
|
// null/undefined still correctly fall back to DEFAULT_TTL in both
|
|
assert.equal(newBehaviour(null), DEFAULT_TTL, "null must fall back to DEFAULT_TTL");
|
|
assert.equal(newBehaviour(undefined), DEFAULT_TTL, "undefined must fall back to DEFAULT_TTL");
|
|
|
|
// Positive TTL is unaffected
|
|
assert.equal(newBehaviour(1000), 1000, "positive TTL must not be affected");
|
|
});
|
|
});
|