mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
381 lines
14 KiB
TypeScript
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();
|
|
});
|
|
});
|