import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MemoryAdapter, createSharedStore } from "@toolctrl/shared-store"; vi.mock("~/utils/cache.server", () => { const map = new Map(); return { cache: map, cachified: vi.fn(), clearCacheKey: vi.fn((key: string) => map.delete(key)), clearCacheKeyPrefix: vi.fn((prefix: string) => { for (const k of map.keys()) if (k.startsWith(prefix)) map.delete(k); }), __test_map: map, }; }); vi.mock("~/shared-store.server", async () => { const { MemoryAdapter, createSharedStore } = await import("@toolctrl/shared-store"); const adapter = new MemoryAdapter(); const store = createSharedStore({ namespace: "web-test", adapter }); return { getSharedStore: () => store, __setSharedStoreFactoryForTests: vi.fn(), __resetSharedStoreForTests: vi.fn(), }; }); import { getSubscriptionCache, subscribeSubscriptionCache, __resetSubscriptionCacheForTests, } from "./subscriptionCache.server"; import * as cacheServer from "~/utils/cache.server"; beforeEach(() => { __resetSubscriptionCacheForTests(); // clear shared cache.server map between tests (cacheServer as unknown as { __test_map: Map }).__test_map.clear(); vi.mocked(cacheServer.clearCacheKey).mockClear(); vi.mocked(cacheServer.clearCacheKeyPrefix).mockClear(); delete process.env.CACHE_INVALIDATION_ENABLED; delete process.env.CACHE_L1_TTL_SECONDS; }); afterEach(() => { __resetSubscriptionCacheForTests(); }); describe("subscription cache (two-tier)", () => { it("invalidate(tenantId) publishes + clears L2 and triggers local clearCacheKey via subscriber", async () => { const cache = getSubscriptionCache(); await subscribeSubscriptionCache(); await cache.invalidate("tenant-1"); // Give the in-process pubsub event loop a tick to flush. await new Promise((r) => setTimeout(r, 10)); expect(vi.mocked(cacheServer.clearCacheKey)).toHaveBeenCalledWith("tenantSubscription:tenant-1"); }); it("wildcard invalidate clears the cachified prefix", async () => { const cache = getSubscriptionCache(); await subscribeSubscriptionCache(); await cache.invalidate(); await new Promise((r) => setTimeout(r, 10)); expect(vi.mocked(cacheServer.clearCacheKeyPrefix)).toHaveBeenCalledWith("tenantSubscription:"); }); it("respects CACHE_INVALIDATION_ENABLED=false (subscribe is no-op, no publish)", async () => { process.env.CACHE_INVALIDATION_ENABLED = "false"; __resetSubscriptionCacheForTests(); const cache = getSubscriptionCache(); const dispose = await subscribeSubscriptionCache(); await cache.invalidate("tenant-2"); await new Promise((r) => setTimeout(r, 10)); // subscriber never wired because pubsub disabled → no clearCacheKey from remote path expect(vi.mocked(cacheServer.clearCacheKey)).not.toHaveBeenCalled(); await dispose(); }); it("subscribeSubscriptionCache is idempotent (returns same disposer)", async () => { const a = await subscribeSubscriptionCache(); const b = await subscribeSubscriptionCache(); expect(a).toBe(b); }); it("uses CACHE_L1_TTL_SECONDS override when present", async () => { process.env.CACHE_L1_TTL_SECONDS = "120"; __resetSubscriptionCacheForTests(); const cache = getSubscriptionCache(); // Smoke: a set/get round-trip works with overridden TTL. await cache.set("t1", null); expect(await cache.get("t1")).toBeNull(); }); }); // Silence the unused createSharedStore import — used only to load the package // (and force its types) for the mock factory above. vitest's mock factory is // hoisted so the dynamic import shape is the standard pattern here. void MemoryAdapter; void createSharedStore;