Files
HealthLog/src/app/api/sync/changes/__tests__/module-gate.test.ts
T
Marc-André Bombeck 0f9ee08a48 test(sync): give the module-gate double the mirror walk's token lookup
The walk now asks the token table first; the route test's Prisma double
answers it with no switched token.
2026-10-25 00:51:32 +02:00

204 lines
6.8 KiB
TypeScript

/**
* `GET /api/sync/changes` — cycle module gate.
*
* The delta feed multiplexes five domains over one cursor and used to serve
* `cycleDays` / `cycles` ungated, so an account with cycle tracking off got
* period days and per-day symptom rows over the very token that
* `/api/cycle/*` refuses with a 403.
*
* Behavioural, not structural: the assertions read the response body and the
* Prisma call log, so reverting the gate in the route turns them red.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
// The mirror walk's token lookup: no token switched, so no walk.
apiToken: { findMany: vi.fn(async () => []) },
moodEntry: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
cycleDayLog: { findMany: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
},
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { isModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
import { decodeCursor } from "@/lib/sync/cursor";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
const NOW = new Date("2026-07-01T12:00:00.000Z");
/** One live cycle-day row carrying a symptom — the payload that must not leak. */
const CYCLE_DAY_ROW = {
id: "cd-1",
userId: "user-1",
externalId: "ext-cd-1",
date: NOW,
flow: "MEDIUM",
notes: null,
notesEncrypted: null,
syncVersion: 3,
deletedAt: null,
updatedAt: NOW,
createdAt: NOW,
symptoms: [{ id: "sym-1", symptom: "CRAMPS", severity: 2 }],
};
const CYCLE_ROW = {
id: "mc-1",
userId: "user-1",
startDate: NOW,
endDate: null,
cycleLength: 28,
periodLength: 5,
predicted: false,
syncVersion: 2,
deletedAt: null,
updatedAt: NOW,
createdAt: NOW,
};
interface FeedBody {
data: {
changes: {
measurements: { upserts: unknown[]; tombstones: unknown[] };
cycleDays: { upserts: unknown[]; tombstones: unknown[] };
cycles: { upserts: unknown[]; tombstones: unknown[] };
};
cursor: string | null;
};
}
function req(query = ""): NextRequest {
return new NextRequest(`http://localhost/api/sync/changes${query}`);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
count: 1,
resetAt: Date.now(),
} as never);
// Non-cycle domains always return rows so a 403/empty-everything result
// cannot be mistaken for a passing "cycle omitted" assertion.
// The `measurementMirror` walk reads measurements too (the readings of
// tokens whose Apple Health setting changed); it finds none here.
const measurementRow = {
id: "m-1",
externalId: "ext-m-1",
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: NOW,
source: "MANUAL",
notes: null,
notesEncrypted: null,
syncVersion: 1,
deletedAt: null,
updatedAt: NOW,
};
vi.mocked(prisma.measurement.findMany).mockImplementation((async (args: {
where?: { ingestToken?: unknown };
}) => (args.where?.ingestToken ? [] : [measurementRow])) as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([
CYCLE_DAY_ROW,
] as never);
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([
CYCLE_ROW,
] as never);
});
describe("GET /api/sync/changes — cycle module gate", () => {
it("omits cycle rows and never queries them when the module is off", async () => {
vi.mocked(isModuleEnabled).mockResolvedValue(false);
const res = await GET(req());
// OMIT, not refuse — the other four domains must keep syncing.
expect(res.status).toBe(200);
const body = (await res.json()) as FeedBody;
expect(body.data.changes.cycleDays.upserts).toEqual([]);
expect(body.data.changes.cycleDays.tombstones).toEqual([]);
expect(body.data.changes.cycles.upserts).toEqual([]);
expect(body.data.changes.cycles.tombstones).toEqual([]);
// The rest of the feed is untouched — proves the empty cycle blocks are
// the gate, not a wholesale refusal.
expect(body.data.changes.measurements.upserts).toHaveLength(1);
// Not read out of Postgres at all.
expect(prisma.cycleDayLog.findMany).not.toHaveBeenCalled();
expect(prisma.menstrualCycle.findMany).not.toHaveBeenCalled();
// No symptom string anywhere in the serialised payload.
expect(JSON.stringify(body)).not.toContain("CRAMPS");
expect(JSON.stringify(body)).not.toContain("ext-cd-1");
});
it("serves cycle rows when the module is on", async () => {
vi.mocked(isModuleEnabled).mockResolvedValue(true);
const res = await GET(req());
expect(res.status).toBe(200);
const body = (await res.json()) as FeedBody;
expect(body.data.changes.cycleDays.upserts).toHaveLength(1);
expect(body.data.changes.cycles.upserts).toHaveLength(1);
expect(prisma.cycleDayLog.findMany).toHaveBeenCalledTimes(1);
expect(prisma.menstrualCycle.findMany).toHaveBeenCalledTimes(1);
});
it("gates on the cycle module key specifically", async () => {
vi.mocked(isModuleEnabled).mockResolvedValue(true);
await GET(req());
expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "cycle");
});
it("leaves the cycle cursor watermarks untouched while the module is off", async () => {
// A skipped domain must not advance its watermark, so re-enabling
// resumes from where the client left off rather than skipping the rows
// that changed while the module was hidden.
vi.mocked(isModuleEnabled).mockResolvedValue(false);
const res = await GET(req());
const body = (await res.json()) as FeedBody;
const cursor = decodeCursor(body.data.cursor as string);
expect(cursor).not.toBeNull();
expect(cursor!.cycleDays).toBeUndefined();
expect(cursor!.cycles).toBeUndefined();
// The domain that did return rows advanced normally.
expect(cursor!.measurements).toBeDefined();
});
});