mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
A parity file is one account walked through a route's cases, each request reading what the earlier ones stored, and the recorded files pin that sequence. The nightly shuffle reordered the steps and every snapshot after the first moved. Keep each walk in order with shuffle: false, wrapping the files whose top-level blocks build on each other, and note the rule for order-bound files in the testing guide.
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", { shuffle: false }, () => {
|
|
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 });
|
|
});
|
|
});
|