mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
The walk now asks the token table first; the route test's Prisma double answers it with no switched token.
204 lines
6.8 KiB
TypeScript
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();
|
|
});
|
|
});
|