mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
356 lines
9.3 KiB
TypeScript
356 lines
9.3 KiB
TypeScript
/**
|
|
* Parity for `GET /api/sync/changes` while its domain pages move into
|
|
* `src/lib/sync/`.
|
|
*
|
|
* One account holds live and deleted rows in all five domains (a sealed note
|
|
* on a measurement and a mood entry, a mood context, a recorded and an
|
|
* auto-recorded intake, cycle days and cycles), plus a raw sample that a live
|
|
* hourly row folded away. The walk drains the feed in small pages to the
|
|
* end, pulls once caught up, then asks with a cursor past retention, a
|
|
* cursor from before a reset, the cycle module switched off, and a refused
|
|
* query. Every row carries a fixed id and `updatedAt`, so the keyset order
|
|
* and the opaque cursor are the same on every run.
|
|
*/
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
|
import { GET } from "@/app/api/sync/changes/route";
|
|
import { encryptNote } from "@/lib/crypto/note-cipher";
|
|
import { encodeCursor } from "@/lib/sync/cursor";
|
|
|
|
import { callRoute, type RouteAuth } from "../../../kit/call-route";
|
|
import { makeMedication, makeSession, makeUser } from "../../../kit/factories";
|
|
import {
|
|
eventFacts,
|
|
expectParity,
|
|
stable,
|
|
wideEventRecorder,
|
|
} from "../../../kit/parity";
|
|
import { getPrismaClient, truncateAllTables } from "../../setup";
|
|
|
|
vi.mock("@/lib/logging/transports", async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<typeof import("@/lib/logging/transports")>();
|
|
const { wideEventRecorder: recorder } = await import("../../../kit/parity");
|
|
return {
|
|
...actual,
|
|
emitIfSampled: (event: (typeof recorder.events)[number]) => {
|
|
recorder.events.push(event);
|
|
},
|
|
};
|
|
});
|
|
|
|
const prisma = getPrismaClient();
|
|
const NOW = new Date("2026-10-15T12:00:00.000Z");
|
|
const MIN = 60_000;
|
|
const DAY = 86_400_000;
|
|
|
|
let userId: string;
|
|
let owner: RouteAuth;
|
|
|
|
/** An `updatedAt` some minutes before the clock, one per row. */
|
|
function touched(minutesAgo: number): Date {
|
|
return new Date(NOW.getTime() - minutesAgo * MIN);
|
|
}
|
|
|
|
async function seed(): Promise<void> {
|
|
const deletedAt = touched(30);
|
|
await prisma.measurement.createMany({
|
|
data: [
|
|
{
|
|
id: "m-01",
|
|
userId,
|
|
type: "WEIGHT",
|
|
value: 81.2,
|
|
unit: "kg",
|
|
measuredAt: new Date("2026-10-14T06:30:00.000Z"),
|
|
source: "MANUAL",
|
|
notesEncrypted: encryptNote("after the run"),
|
|
updatedAt: touched(300),
|
|
},
|
|
{
|
|
id: "m-02",
|
|
userId,
|
|
type: "PULSE",
|
|
value: 61,
|
|
unit: "bpm",
|
|
measuredAt: new Date("2026-10-14T07:00:00.000Z"),
|
|
source: "WITHINGS",
|
|
externalId: "w-pulse-1",
|
|
updatedAt: touched(290),
|
|
},
|
|
{
|
|
id: "m-03",
|
|
userId,
|
|
type: "WEIGHT",
|
|
value: 81.6,
|
|
unit: "kg",
|
|
measuredAt: new Date("2026-10-13T06:30:00.000Z"),
|
|
source: "WITHINGS",
|
|
externalId: "w-weight-2",
|
|
deletedAt,
|
|
updatedAt: touched(280),
|
|
},
|
|
// The hourly row and a raw sample it folded away, four months back:
|
|
// the sample's tombstone is the fold's, not a deletion anyone made.
|
|
{
|
|
id: "m-04",
|
|
userId,
|
|
type: "PULSE",
|
|
value: 64,
|
|
unit: "bpm",
|
|
measuredAt: new Date("2026-06-17T09:30:00.000Z"),
|
|
source: "APPLE_HEALTH",
|
|
externalId: "stats:HKQuantityTypeIdentifierHeartRate:2026-06-17T09",
|
|
updatedAt: touched(270),
|
|
},
|
|
{
|
|
id: "m-05",
|
|
userId,
|
|
type: "PULSE",
|
|
value: 66,
|
|
unit: "bpm",
|
|
measuredAt: new Date("2026-06-17T09:12:00.000Z"),
|
|
source: "APPLE_HEALTH",
|
|
externalId: "uuid-raw-pulse-1",
|
|
deletedAt: touched(260),
|
|
updatedAt: touched(260),
|
|
},
|
|
{
|
|
id: "m-06",
|
|
userId,
|
|
type: "BLOOD_PRESSURE_SYS",
|
|
value: 128,
|
|
unit: "mmHg",
|
|
measuredAt: new Date("2026-10-14T08:00:00.000Z"),
|
|
source: "MANUAL",
|
|
updatedAt: touched(250),
|
|
},
|
|
],
|
|
});
|
|
await prisma.moodEntry.createMany({
|
|
data: [
|
|
{
|
|
id: "mood-01",
|
|
userId,
|
|
date: "2026-10-14",
|
|
tz: "Europe/Berlin",
|
|
mood: "GUT",
|
|
score: 4,
|
|
moodA1: 7,
|
|
stressA2: 3,
|
|
tags: JSON.stringify(["slept well"]),
|
|
noteEncrypted: encryptNote("a calm morning"),
|
|
moodLoggedAt: new Date("2026-10-14T07:00:00.000Z"),
|
|
source: "MANUAL",
|
|
updatedAt: touched(240),
|
|
},
|
|
{
|
|
id: "mood-02",
|
|
userId,
|
|
date: "2026-10-13",
|
|
tz: "Europe/Berlin",
|
|
mood: "SCHLECHT",
|
|
score: 2,
|
|
moodA1: 3,
|
|
moodLoggedAt: new Date("2026-10-13T20:00:00.000Z"),
|
|
source: "MANUAL",
|
|
deletedAt,
|
|
updatedAt: touched(230),
|
|
},
|
|
],
|
|
});
|
|
await prisma.moodContext.create({
|
|
data: {
|
|
moodEntryId: "mood-01",
|
|
userId,
|
|
workStatus: "regular",
|
|
workMinutes: 480,
|
|
},
|
|
});
|
|
const medication = await makeMedication({ id: userId });
|
|
await prisma.medicationIntakeEvent.createMany({
|
|
data: [
|
|
{
|
|
id: "in-01",
|
|
userId,
|
|
medicationId: medication.id,
|
|
scheduledFor: new Date("2026-10-14T06:00:00.000Z"),
|
|
takenAt: new Date("2026-10-14T06:10:00.000Z"),
|
|
source: "WEB",
|
|
updatedAt: touched(220),
|
|
},
|
|
{
|
|
id: "in-02",
|
|
userId,
|
|
medicationId: medication.id,
|
|
scheduledFor: new Date("2026-10-13T06:00:00.000Z"),
|
|
takenAt: new Date("2026-10-13T06:00:00.000Z"),
|
|
autoRecorded: true,
|
|
source: "WEB",
|
|
updatedAt: touched(210),
|
|
},
|
|
{
|
|
id: "in-03",
|
|
userId,
|
|
medicationId: medication.id,
|
|
scheduledFor: new Date("2026-10-12T06:00:00.000Z"),
|
|
skipped: true,
|
|
source: "WEB",
|
|
deletedAt,
|
|
updatedAt: touched(200),
|
|
},
|
|
],
|
|
});
|
|
await prisma.menstrualCycle.createMany({
|
|
data: [
|
|
{
|
|
id: "cy-01",
|
|
userId,
|
|
startDate: "2026-09-12",
|
|
endDate: "2026-10-09",
|
|
lengthDays: 28,
|
|
periodEndDate: "2026-09-16",
|
|
updatedAt: touched(190),
|
|
},
|
|
{
|
|
id: "cy-02",
|
|
userId,
|
|
startDate: "2026-10-10",
|
|
updatedAt: touched(180),
|
|
},
|
|
{
|
|
id: "cy-03",
|
|
userId,
|
|
startDate: "2026-08-01",
|
|
deletedAt,
|
|
updatedAt: touched(170),
|
|
},
|
|
],
|
|
});
|
|
await prisma.cycleDayLog.createMany({
|
|
data: [
|
|
{
|
|
id: "cd-01",
|
|
userId,
|
|
date: "2026-10-10",
|
|
cycleId: "cy-02",
|
|
flow: "MEDIUM",
|
|
updatedAt: touched(160),
|
|
},
|
|
{
|
|
id: "cd-02",
|
|
userId,
|
|
date: "2026-10-11",
|
|
cycleId: "cy-02",
|
|
flow: "LIGHT",
|
|
basalBodyTempC: 36.4,
|
|
externalId: "hk-cycle-1",
|
|
updatedAt: touched(150),
|
|
},
|
|
{
|
|
id: "cd-03",
|
|
userId,
|
|
date: "2026-10-09",
|
|
externalId: "hk-cycle-0",
|
|
deletedAt,
|
|
updatedAt: touched(140),
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
type Feed = { cursor: string | null; hasMore: boolean };
|
|
|
|
async function pull(
|
|
name: string,
|
|
query: Record<string, string | number>,
|
|
): Promise<Feed> {
|
|
wideEventRecorder.take();
|
|
const res = await callRoute<Feed>(GET, {
|
|
method: "GET",
|
|
route: "/api/sync/changes",
|
|
query,
|
|
auth: owner,
|
|
});
|
|
const events = wideEventRecorder.take();
|
|
expect(events.length).toBeGreaterThan(0);
|
|
await expectParity(
|
|
`sync-changes.${name}`,
|
|
stable(
|
|
{ status: res.status, body: res.body, events: events.map(eventFacts) },
|
|
{ since: NOW },
|
|
),
|
|
);
|
|
return res.data;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
vi.useFakeTimers({ toFake: ["Date"], now: NOW });
|
|
await truncateAllTables(prisma);
|
|
const user = await makeUser({ gender: "FEMALE", timezone: "UTC" });
|
|
userId = user.id;
|
|
owner = await makeSession(user);
|
|
await prisma.cycleProfile.create({ data: { userId } });
|
|
await seed();
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe("GET /api/sync/changes parity", () => {
|
|
let firstCursor: string;
|
|
|
|
it("drains the feed in small pages to the end", async () => {
|
|
const first = await pull("01-first-page", { limit: 2 });
|
|
expect(first.hasMore).toBe(true);
|
|
firstCursor = first.cursor!;
|
|
const second = await pull("02-second-page", {
|
|
limit: 2,
|
|
cursor: first.cursor!,
|
|
});
|
|
const rest = await pull("03-the-rest", {
|
|
limit: 500,
|
|
cursor: second.cursor!,
|
|
});
|
|
expect(rest.hasMore).toBe(false);
|
|
await pull("04-caught-up", { cursor: rest.cursor! });
|
|
});
|
|
|
|
it("a cursor past retention, and one from before a reset", async () => {
|
|
await pull("05-past-retention", {
|
|
cursor: encodeCursor(
|
|
{
|
|
measurements: { updatedAtMs: NOW.getTime() - 200 * DAY, id: "m-01" },
|
|
},
|
|
NOW.getTime(),
|
|
),
|
|
});
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: { syncResetAt: new Date(NOW.getTime() + MIN) },
|
|
});
|
|
await pull("06-before-reset", { cursor: firstCursor });
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: { syncResetAt: null },
|
|
});
|
|
});
|
|
|
|
it("the cycle module switched off", async () => {
|
|
await prisma.cycleProfile.update({
|
|
where: { userId },
|
|
data: { cycleTrackingEnabled: false },
|
|
});
|
|
await pull("07-cycle-off", { limit: 500 });
|
|
await prisma.cycleProfile.update({
|
|
where: { userId },
|
|
data: { cycleTrackingEnabled: null },
|
|
});
|
|
});
|
|
|
|
it("a refused query", async () => {
|
|
await pull("08-invalid", { limit: 0 });
|
|
});
|
|
});
|