Files
SnapOtter/tests/unit/api/analytics-gate-mutation.test.ts
T
SnapOtterandGitHub 301e6eb01a test: coverage campaign and mutation testing across five packages (#628)
Coverage 83.6 to 87.36% lines, 81.63 to 84.14% branches. Mutation testing across five packages: image-engine 85, media-engine 92, doc-engine 87, shared+enterprise 86, apps/api security and jobs slice. Runs all five lanes weekly. Fixes the silently-broken mutation CI (babel pin), a redact-pdf envelope-shape test bug, an untested enterprise license valid-signature path, and an audit test that only exercised a hand-copied reproduction. Test and config only, no product code changes beyond the babel pin and one test-only oidc export. Full suite: 16,712 pass, 0 fail.
2026-07-24 17:36:57 +08:00

381 lines
14 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Mutation-focused coverage for analytics-gate.ts. The sibling
// analytics-gate.test.ts covers the env kill switches and the fail-closed
// anchor against the REAL module. This file mocks the seams the real one leaves
// untouched so the survived/no-cov branches die:
// - ANALYTICS_BAKED forced ON, so the baked-off override (L47) and the cached
// toggle value (L58/L67/L73) become observable instead of masked by the
// committed bake=false.
// - the db/index.js + drizzle-orm dynamic imports, to drive defaultReader
// (L24-L25) with real rows.
// - the jobs/types.js + jobs/connection.js dynamic imports, to drive the Redis
// gate listener (L105, L114-L116, L128) without a live Redis.
// --- hoisted mock state -----------------------------------------------------
const bakedConfig = vi.hoisted(() => ({
enabled: true, // forced ON so the toggle/override paths are observable
posthogApiKey: "",
posthogHost: "",
sentryDsn: "",
sentryDsnWeb: "",
posthogSampleRate: 1.0,
}));
// db/index.js seam: `rows` is what the mocked query resolves to.
const dbState = vi.hoisted(() => ({ rows: [] as Array<{ value: string }> }));
// jobs/connection.js seam: a fake ioredis subscriber that records calls.
const redisState = vi.hoisted(() => ({
subscriberSubscribe: vi.fn().mockResolvedValue(undefined),
subscriberOn: vi.fn(),
subscriberQuit: vi.fn().mockResolvedValue(undefined),
sharedPublish: vi.fn().mockResolvedValue(1),
messageHandler: null as null | (() => void),
createdSubscribers: 0,
}));
vi.mock("@snapotter/shared", async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return { ...actual, ANALYTICS_BAKED: bakedConfig };
});
vi.mock("../../../apps/api/src/db/index.js", () => ({
db: {
select: () => ({
from: () => ({
where: () => ({
limit: () => Promise.resolve(dbState.rows),
}),
}),
}),
},
schema: { settings: { key: "key", value: "value" } },
}));
vi.mock("drizzle-orm", () => ({ eq: () => "mocked-eq" }));
vi.mock("../../../apps/api/src/jobs/types.js", () => ({
bullPrefix: () => "test_prefix",
}));
vi.mock("../../../apps/api/src/jobs/connection.js", () => ({
createRedisSubscriberConnection: () => {
redisState.createdSubscribers += 1;
return {
on: (event: string, handler: () => void) => {
redisState.subscriberOn(event, handler);
if (event === "message") redisState.messageHandler = handler;
},
subscribe: redisState.subscriberSubscribe,
quit: redisState.subscriberQuit,
};
},
sharedRedis: () => ({ publish: redisState.sharedPublish }),
}));
type GateModule = typeof import("../../../apps/api/src/lib/analytics-gate.js");
let gate: GateModule;
const origEnv = process.env.NODE_ENV;
const origTelemetry = process.env.SNAPOTTER_TELEMETRY;
const origAnalyticsEnabled = process.env.ANALYTICS_ENABLED;
const origOverride = process.env.ANALYTICS_BAKED_OVERRIDE;
beforeEach(async () => {
bakedConfig.enabled = true;
dbState.rows = [];
redisState.subscriberSubscribe.mockClear();
redisState.subscriberOn.mockClear();
redisState.subscriberQuit.mockClear();
redisState.sharedPublish.mockClear();
redisState.messageHandler = null;
redisState.createdSubscribers = 0;
process.env.NODE_ENV = "test";
delete process.env.SNAPOTTER_TELEMETRY;
delete process.env.ANALYTICS_ENABLED;
delete process.env.ANALYTICS_BAKED_OVERRIDE;
vi.resetModules();
gate = await import("../../../apps/api/src/lib/analytics-gate.js");
gate.__resetGateForTests();
});
afterEach(() => {
vi.useRealTimers();
process.env.NODE_ENV = origEnv;
if (origTelemetry === undefined) delete process.env.SNAPOTTER_TELEMETRY;
else process.env.SNAPOTTER_TELEMETRY = origTelemetry;
if (origAnalyticsEnabled === undefined) delete process.env.ANALYTICS_ENABLED;
else process.env.ANALYTICS_ENABLED = origAnalyticsEnabled;
if (origOverride === undefined) delete process.env.ANALYTICS_BAKED_OVERRIDE;
else process.env.ANALYTICS_BAKED_OVERRIDE = origOverride;
});
// --- bakedEnabled override, observable because baked is mocked ON -----------
describe("bakedEnabled baked-off override (L47)", () => {
it("override=off flips a baked-ON default to disabled", () => {
process.env.ANALYTICS_BAKED_OVERRIDE = "off";
expect(gate.bakedEnabled()).toBe(false);
});
it("no override keeps the baked-ON default enabled", () => {
expect(gate.bakedEnabled()).toBe(true);
});
it("override=on keeps it enabled (L46)", () => {
process.env.ANALYTICS_BAKED_OVERRIDE = "on";
expect(gate.bakedEnabled()).toBe(true);
});
it("ignores the override under NODE_ENV=production and returns the mocked bake", () => {
process.env.NODE_ENV = "production";
process.env.ANALYTICS_BAKED_OVERRIDE = "off";
// Production ignores the override, so the mocked bake (true) wins.
expect(gate.bakedEnabled()).toBe(true);
});
});
// --- effective gate value, cache toggle (L54/L58/L65/L67/L73) ---------------
describe("analyticsEnabled reflects the cached toggle", () => {
it("serves ON when the reader reports enabled", async () => {
gate.__setReaderForTests(async () => true);
await gate.refreshAnalyticsGate();
expect(gate.analyticsEnabled()).toBe(true);
});
it("serves OFF when the reader reports disabled", async () => {
gate.__setReaderForTests(async () => false);
await gate.refreshAnalyticsGate();
expect(gate.analyticsEnabled()).toBe(false);
});
it("treats an absent toggle (undefined) as ON (L65)", async () => {
gate.__setReaderForTests(async () => undefined);
await gate.refreshAnalyticsGate();
expect(gate.analyticsEnabled()).toBe(true);
});
it("returns false immediately when baked is off, ignoring the cache (L54)", async () => {
gate.__setReaderForTests(async () => true);
await gate.refreshAnalyticsGate();
bakedConfig.enabled = false;
expect(gate.analyticsEnabled()).toBe(false);
});
});
// --- fail-closed anchor: knownDisabled = !on (L67) and its use (L73) --------
describe("fail-closed anchor pins knownDisabled = !on", () => {
it("does NOT force disabled after an enabled read then a read error", async () => {
// If L67 were `knownDisabled = on`, an enabled read would arm the anchor and
// this error path would wrongly flip the cache to false.
gate.__setReaderForTests(async () => true);
await gate.refreshAnalyticsGate();
expect(gate.analyticsEnabled()).toBe(true);
gate.__setReaderForTests(async () => {
throw new Error("db down");
});
await gate.refreshAnalyticsGate();
expect(gate.analyticsEnabled()).toBe(true);
});
it("keeps serving disabled after a disabled read then a read error (L73)", async () => {
gate.__setReaderForTests(async () => false);
await gate.refreshAnalyticsGate();
expect(gate.analyticsEnabled()).toBe(false);
gate.__setReaderForTests(async () => {
throw new Error("db down");
});
await gate.refreshAnalyticsGate();
expect(gate.analyticsEnabled()).toBe(false);
});
it("keeps the last enabled value across an error when disabled was never seen", async () => {
gate.__setReaderForTests(async () => undefined); // -> ON, knownDisabled stays false
await gate.refreshAnalyticsGate();
gate.__setReaderForTests(async () => {
throw new Error("db down");
});
await gate.refreshAnalyticsGate();
expect(gate.analyticsEnabled()).toBe(true);
});
});
// --- TTL staleness boundary (L55) -------------------------------------------
describe("analyticsEnabled TTL refresh boundary (L55)", () => {
const TTL_MS = 30_000;
it("does NOT background-refresh while the cache is fresh", async () => {
vi.useFakeTimers();
gate.__setReaderForTests(async () => true);
await gate.refreshAnalyticsGate(); // fetchedAt = now
expect(gate.analyticsEnabled()).toBe(true);
// Swap in a disabled reader but stay inside the TTL window: no refresh fires,
// so the cached ON value is still served.
gate.__setReaderForTests(async () => false);
vi.advanceTimersByTime(TTL_MS - 1);
expect(gate.analyticsEnabled()).toBe(true);
await vi.runAllTimersAsync();
expect(gate.analyticsEnabled()).toBe(true);
});
it("does NOT refresh at exactly TTL_MS (strict > boundary)", async () => {
vi.useFakeTimers();
gate.__setReaderForTests(async () => true);
await gate.refreshAnalyticsGate();
gate.__setReaderForTests(async () => false);
vi.advanceTimersByTime(TTL_MS); // delta === TTL_MS, `> TTL_MS` is false
// Reading serves cache; no background refresh is scheduled at the boundary.
expect(gate.analyticsEnabled()).toBe(true);
await vi.runAllTimersAsync();
expect(gate.analyticsEnabled()).toBe(true);
});
it("background-refreshes once the cache is older than TTL_MS", async () => {
vi.useFakeTimers();
gate.__setReaderForTests(async () => true);
await gate.refreshAnalyticsGate();
gate.__setReaderForTests(async () => false);
vi.advanceTimersByTime(TTL_MS + 1); // delta > TTL_MS -> schedule refresh
// The synchronous read still serves the stale cached value...
expect(gate.analyticsEnabled()).toBe(true);
// ...then the background refresh lands and flips it to disabled.
await vi.runAllTimersAsync();
expect(gate.analyticsEnabled()).toBe(false);
});
});
// --- primed flag: L68 set, L88 read, L96-L97 reset --------------------------
describe("gatePrimed lifecycle (L68, L88, L96-L97)", () => {
it("is false before any read and true after a successful read", async () => {
expect(gate.gatePrimed()).toBe(false);
gate.__setReaderForTests(async () => true);
await gate.refreshAnalyticsGate();
expect(gate.gatePrimed()).toBe(true);
});
it("becomes primed even when the read reports disabled", async () => {
gate.__setReaderForTests(async () => false);
await gate.refreshAnalyticsGate();
expect(gate.gatePrimed()).toBe(true);
});
it("stays UNprimed when the very first read throws", async () => {
gate.__setReaderForTests(async () => {
throw new Error("db down at boot");
});
await gate.refreshAnalyticsGate();
expect(gate.gatePrimed()).toBe(false);
});
it("__resetGateForTests clears primed and restores the ON default", async () => {
gate.__setReaderForTests(async () => false);
await gate.refreshAnalyticsGate();
expect(gate.gatePrimed()).toBe(true);
expect(gate.analyticsEnabled()).toBe(false);
gate.__resetGateForTests();
expect(gate.gatePrimed()).toBe(false);
expect(gate.analyticsEnabled()).toBe(true); // cachedEnabled back to ON default
});
});
// --- primeAnalyticsGate wraps refresh (L79) ---------------------------------
describe("primeAnalyticsGate (L79)", () => {
it("performs the initial read and primes the cache", async () => {
gate.__setReaderForTests(async () => false);
await gate.primeAnalyticsGate();
expect(gate.gatePrimed()).toBe(true);
expect(gate.analyticsEnabled()).toBe(false);
});
});
// --- defaultReader over the mocked db (L24-L25) -----------------------------
describe("defaultReader reads the settings row (L24-L25)", () => {
it("returns undefined when the settings row is absent (L24)", async () => {
dbState.rows = [];
gate.__setReaderForTests(null); // use the real defaultReader against mocked db
await gate.refreshAnalyticsGate();
// undefined -> default ON.
expect(gate.analyticsEnabled()).toBe(true);
expect(gate.gatePrimed()).toBe(true);
});
it("returns false when the stored value is 'false' (L25)", async () => {
dbState.rows = [{ value: "false" }];
gate.__setReaderForTests(null);
await gate.refreshAnalyticsGate();
expect(gate.analyticsEnabled()).toBe(false);
});
it("returns true when the stored value is anything other than 'false' (L25)", async () => {
dbState.rows = [{ value: "true" }];
gate.__setReaderForTests(null);
await gate.refreshAnalyticsGate();
expect(gate.analyticsEnabled()).toBe(true);
});
it("treats an empty-string stored value as enabled (only 'false' disables)", async () => {
dbState.rows = [{ value: "" }];
gate.__setReaderForTests(null);
await gate.refreshAnalyticsGate();
expect(gate.analyticsEnabled()).toBe(true);
});
});
// --- Redis gate listener (L105, L114-L116, L128) ----------------------------
describe("Redis gate listener wiring", () => {
it("subscribes to <prefix>:analytics-gate and refreshes on a message", async () => {
await gate.startAnalyticsGateListener();
expect(redisState.createdSubscribers).toBe(1);
// L105/L115: channel name is `${bullPrefix()}:analytics-gate`.
expect(redisState.subscriberSubscribe).toHaveBeenCalledWith("test_prefix:analytics-gate");
// L114: an error handler is registered.
expect(redisState.subscriberOn).toHaveBeenCalledWith("error", expect.any(Function));
// L116: a message handler is registered.
expect(redisState.subscriberOn).toHaveBeenCalledWith("message", expect.any(Function));
// Firing the message handler triggers a gate refresh (disabled reader).
gate.__setReaderForTests(async () => false);
expect(redisState.messageHandler).toBeTypeOf("function");
redisState.messageHandler?.();
await vi.waitFor(() => expect(gate.analyticsEnabled()).toBe(false));
});
it("publishes the invalidation on the same channel", async () => {
await gate.publishAnalyticsGateInvalidation();
expect(redisState.sharedPublish).toHaveBeenCalledWith("test_prefix:analytics-gate", "1");
});
it("stopAnalyticsGateListener quits the subscriber and is idempotent (L128)", async () => {
await gate.startAnalyticsGateListener();
await gate.stopAnalyticsGateListener();
expect(redisState.subscriberQuit).toHaveBeenCalledTimes(1);
// Second stop is a no-op: the guard means quit is not called again.
await gate.stopAnalyticsGateListener();
expect(redisState.subscriberQuit).toHaveBeenCalledTimes(1);
});
it("stopAnalyticsGateListener with no active subscriber does nothing", async () => {
await gate.stopAnalyticsGateListener();
expect(redisState.subscriberQuit).not.toHaveBeenCalled();
});
});