mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
test: property tests for zones, statistics, cadence, crypto and backups
fast-check properties with one shared configuration (tests/property/): a fixed seed on every pull request, a fresh one and more cases at night through FC_SEED and FC_NUM_RUNS, and a printed seed with the shrunk counterexample on failure. - Time zones: wall clocks agree with Intl in twenty awkward zones, a wall clock maps back to an instant showing it, a skipped hour resolves forward on the same day, every instant lies inside the window of the day it is filed under, and day keys shift and count as inverses. - Statistics: every median in the tree says the same number, medians and spreads obey order, shift and scale, a least-squares fit recovers an exact line and composes from stored sums exactly, and the segmented regression recovers a noiseless interrupted line. - Cadence and compliance: weekly and daily schedules expect exactly what a day-by-day oracle expects, in any zone and on transition days, and the rate stays within bounds and never falls when a missed dose is taken. - Encryption: every codec opens what it sealed under the key it names, rotation moves the key id and nothing else, and a tampered value never opens to anything but the sealed bytes. - Backups: randomly generated accounts come back from a disaster-recovery restore with an identical canonical dump of every generated table.
This commit is contained in:
1 parent
8b89b65f25
commit
4ff3f3eb5d
6 files changed
+1478
No files matched your search
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Encryption at rest against its laws, on random plaintexts, key maps and
|
||||
* chunkings.
|
||||
*
|
||||
* The hand-written cases pin each refusal once; these say it for every
|
||||
* input: whatever is sealed opens to the same bytes under the key it names,
|
||||
* nothing that was tampered with opens at all, a rotation moves the key id
|
||||
* and nothing else, and the streamed codec does not care how the plaintext
|
||||
* was cut into chunks.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import fc from "fast-check";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
_resetCryptoCacheForTests,
|
||||
createStreamEncryptor,
|
||||
decrypt,
|
||||
decryptBytes,
|
||||
decryptStream,
|
||||
encrypt,
|
||||
encryptBytes,
|
||||
encryptUnderKeyId,
|
||||
extractKeyId,
|
||||
extractKeyIdFromBytes,
|
||||
extractStreamKeyId,
|
||||
reencryptBytesToActive,
|
||||
reencryptToActive,
|
||||
} from "../crypto";
|
||||
|
||||
import { assertProperty } from "../../../tests/property/fast-check";
|
||||
|
||||
const KEYS = {
|
||||
v1: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
v2: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
|
||||
k2026_b: "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
|
||||
} as const;
|
||||
type KeyId = keyof typeof KEYS;
|
||||
const keyIdArb = fc.constantFrom<KeyId>("v1", "v2", "k2026_b");
|
||||
|
||||
function useKeys(active: KeyId): void {
|
||||
vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify(KEYS));
|
||||
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", active);
|
||||
vi.stubEnv("ENCRYPTION_KEY", "");
|
||||
_resetCryptoCacheForTests();
|
||||
}
|
||||
|
||||
beforeEach(() => useKeys("v2"));
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
const textArb = fc.string({ unit: "grapheme", maxLength: 300 });
|
||||
const bytesArb = fc.uint8Array({ maxLength: 2_000 }).map((a) => Buffer.from(a));
|
||||
const aadArb = fc.option(fc.string({ maxLength: 40 }), { nil: undefined });
|
||||
|
||||
describe("string codec", () => {
|
||||
it("opens what it sealed, under the active key it names", () => {
|
||||
assertProperty(
|
||||
fc.property(keyIdArb, textArb, (active, text) => {
|
||||
useKeys(active);
|
||||
const sealed = encrypt(text);
|
||||
expect(extractKeyId(sealed)).toBe(active);
|
||||
expect(decrypt(sealed)).toBe(text);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("never seals the same text the same way twice", () => {
|
||||
assertProperty(
|
||||
fc.property(textArb, (text) => {
|
||||
expect(encrypt(text)).not.toBe(encrypt(text));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a value sealed under any configured key, whichever key is active", () => {
|
||||
assertProperty(
|
||||
fc.property(keyIdArb, keyIdArb, textArb, (sealer, active, text) => {
|
||||
useKeys(active);
|
||||
const sealed = encryptUnderKeyId(text, sealer);
|
||||
expect(decrypt(sealed)).toBe(text);
|
||||
const rotated = reencryptToActive(sealed);
|
||||
expect(extractKeyId(rotated)).toBe(active);
|
||||
expect(decrypt(rotated)).toBe(text);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a sealed value with any one character of its payload changed", () => {
|
||||
assertProperty(
|
||||
fc.property(textArb, fc.nat(), fc.nat(), (text, at, pick) => {
|
||||
const sealed = encrypt(text);
|
||||
const dot = sealed.indexOf(".");
|
||||
// A payload position, leaving the key id and the base64 padding alone.
|
||||
const body = sealed.slice(dot + 1).replace(/=+$/, "");
|
||||
const index = dot + 1 + (at % body.length);
|
||||
const alphabet =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
const original = sealed[index];
|
||||
const replacement = alphabet.replace(original, "")[pick % 63];
|
||||
const tampered =
|
||||
sealed.slice(0, index) + replacement + sealed.slice(index + 1);
|
||||
// A change confined to the unused low bits of the final base64
|
||||
// character decodes to the same bytes; anything else must not open.
|
||||
const sameBytes = Buffer.from(tampered.slice(dot + 1), "base64").equals(
|
||||
Buffer.from(sealed.slice(dot + 1), "base64"),
|
||||
);
|
||||
if (sameBytes) return;
|
||||
expect(() => decrypt(tampered)).toThrow();
|
||||
}),
|
||||
300,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("binary codec", () => {
|
||||
it("opens what it sealed, with the same purpose label, under the key it names", () => {
|
||||
assertProperty(
|
||||
fc.property(keyIdArb, bytesArb, aadArb, (active, bytes, aad) => {
|
||||
useKeys(active);
|
||||
const sealed = encryptBytes(bytes, aad);
|
||||
expect(extractKeyIdFromBytes(sealed)).toBe(active);
|
||||
expect(decryptBytes(sealed, aad).equals(bytes)).toBe(true);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a different purpose label", () => {
|
||||
assertProperty(
|
||||
fc.property(
|
||||
bytesArb,
|
||||
fc.string({ maxLength: 20 }),
|
||||
fc.string({ maxLength: 20 }),
|
||||
(bytes, a, b) => {
|
||||
fc.pre(a !== b);
|
||||
const sealed = encryptBytes(bytes, a);
|
||||
expect(() => decryptBytes(sealed, b)).toThrow();
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("never opens a payload with a flipped bit to anything but the original bytes", () => {
|
||||
assertProperty(
|
||||
fc.property(
|
||||
bytesArb,
|
||||
aadArb,
|
||||
fc.nat(),
|
||||
fc.integer({ min: 0, max: 7 }),
|
||||
(bytes, aad, at, bit) => {
|
||||
const sealed = encryptBytes(bytes, aad);
|
||||
const flipped = Buffer.from(sealed);
|
||||
const index = at % flipped.byteLength;
|
||||
flipped[index] ^= 1 << bit;
|
||||
// The ciphertext, IV and tag are authenticated, so a flip there
|
||||
// throws. The key-id header is not: it only selects the key, and a
|
||||
// flip of a key-id byte's top bit decodes to the same ASCII id.
|
||||
// Either way nothing but the sealed bytes can come out.
|
||||
let opened: Buffer;
|
||||
try {
|
||||
opened = decryptBytes(flipped, aad);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
expect(index).toBeLessThan(2 + flipped[1]);
|
||||
expect(opened.equals(bytes)).toBe(true);
|
||||
},
|
||||
),
|
||||
300,
|
||||
);
|
||||
});
|
||||
|
||||
it("rotates to the active key without touching the bytes", () => {
|
||||
assertProperty(
|
||||
fc.property(
|
||||
keyIdArb,
|
||||
keyIdArb,
|
||||
bytesArb,
|
||||
aadArb,
|
||||
(from, to, bytes, aad) => {
|
||||
useKeys(from);
|
||||
const sealed = encryptBytes(bytes, aad);
|
||||
useKeys(to);
|
||||
const rotated = reencryptBytesToActive(sealed, aad);
|
||||
expect(extractKeyIdFromBytes(rotated)).toBe(to);
|
||||
expect(decryptBytes(rotated, aad).equals(bytes)).toBe(true);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("streamed codec", () => {
|
||||
it("does not care how the plaintext was cut into chunks", () => {
|
||||
assertProperty(
|
||||
fc.property(
|
||||
keyIdArb,
|
||||
fc.array(fc.uint8Array({ maxLength: 400 }), { maxLength: 12 }),
|
||||
(active, chunks) => {
|
||||
useKeys(active);
|
||||
const encryptor = createStreamEncryptor();
|
||||
let stored = encryptor.header;
|
||||
for (const chunk of chunks)
|
||||
stored += encryptor.update(Buffer.from(chunk));
|
||||
stored += encryptor.final();
|
||||
expect(extractStreamKeyId(stored)).toBe(active);
|
||||
const expected = Buffer.concat(chunks.map((c) => Buffer.from(c)));
|
||||
expect(decryptStream(stored).equals(expected)).toBe(true);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a streamed value cut short", () => {
|
||||
assertProperty(
|
||||
fc.property(
|
||||
fc.uint8Array({ minLength: 1, maxLength: 400 }),
|
||||
fc.integer({ min: 4, max: 40 }),
|
||||
(bytes, cut) => {
|
||||
const encryptor = createStreamEncryptor();
|
||||
const stored =
|
||||
encryptor.header +
|
||||
encryptor.update(Buffer.from(bytes)) +
|
||||
encryptor.final();
|
||||
const truncated = stored.slice(0, stored.length - cut);
|
||||
expect(() => decryptStream(truncated)).toThrow();
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Medication cadence and compliance against a naive oracle and against the
|
||||
* laws a rate has to obey.
|
||||
*
|
||||
* The oracle walks one local calendar day at a time and asks the only
|
||||
* question a person asks: is today one of my dose days, and how many doses
|
||||
* does it hold? The engine answers the same question through recurrence
|
||||
* rules, eras, bands and the zone's transitions; any disagreement on a random
|
||||
* schedule, day and zone is a defect in one of the two, and the oracle is
|
||||
* small enough to be obviously right.
|
||||
*/
|
||||
import fc from "fast-check";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
expectedSlotCountForDay,
|
||||
type ComplianceSchedule,
|
||||
type IntakeEvent,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { shiftDateKey, weekdayOfDateKey } from "@/lib/tz/format";
|
||||
import { localDayWindow } from "@/lib/tz/local-day";
|
||||
|
||||
import {
|
||||
assertProperty,
|
||||
zoneArb,
|
||||
} from "../../../../../tests/property/fast-check";
|
||||
|
||||
const BYDAY = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"] as const;
|
||||
|
||||
const dayKeyArb = fc
|
||||
.date({
|
||||
min: new Date("2025-01-01T00:00:00Z"),
|
||||
max: new Date("2027-12-31T00:00:00Z"),
|
||||
noInvalidDate: true,
|
||||
})
|
||||
.map((d) => d.toISOString().slice(0, 10));
|
||||
|
||||
const timeArb = fc
|
||||
.tuple(fc.integer({ min: 0, max: 23 }), fc.constantFrom(0, 15, 30, 45))
|
||||
.map(
|
||||
([h, m]) => `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`,
|
||||
);
|
||||
|
||||
const timesArb = fc.uniqueArray(timeArb, { minLength: 1, maxLength: 3 });
|
||||
|
||||
const weekdaysArb = fc.uniqueArray(fc.integer({ min: 0, max: 6 }), {
|
||||
minLength: 1,
|
||||
maxLength: 7,
|
||||
});
|
||||
|
||||
function context(tz: string) {
|
||||
return buildComplianceMedicationContext(
|
||||
{
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2024-06-01T00:00:00Z"),
|
||||
},
|
||||
null,
|
||||
tz,
|
||||
);
|
||||
}
|
||||
|
||||
function weeklySchedule(
|
||||
weekdays: number[],
|
||||
times: string[],
|
||||
): ComplianceSchedule {
|
||||
return {
|
||||
windowStart: times[0],
|
||||
windowEnd: times[0],
|
||||
daysOfWeek: null,
|
||||
rrule: `FREQ=WEEKLY;BYDAY=${weekdays.map((d) => BYDAY[d]).join(",")}`,
|
||||
timesOfDay: [...times].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("expected doses per local day", () => {
|
||||
it("a weekly schedule expects its doses exactly on its weekdays", () => {
|
||||
assertProperty(
|
||||
fc.property(
|
||||
weekdaysArb,
|
||||
timesArb,
|
||||
dayKeyArb,
|
||||
zoneArb,
|
||||
(weekdays, times, key, tz) => {
|
||||
const { dayStart, dayEnd } = localDayWindow(key, tz);
|
||||
const count = expectedSlotCountForDay(
|
||||
[weeklySchedule(weekdays, times)],
|
||||
dayStart,
|
||||
dayEnd,
|
||||
context(tz),
|
||||
);
|
||||
const oracle = weekdays.includes(weekdayOfDateKey(key))
|
||||
? times.length
|
||||
: 0;
|
||||
expect(count).toBe(oracle);
|
||||
},
|
||||
),
|
||||
300,
|
||||
);
|
||||
});
|
||||
|
||||
it("a daily schedule expects every one of its times on every day", () => {
|
||||
assertProperty(
|
||||
fc.property(timesArb, dayKeyArb, zoneArb, (times, key, tz) => {
|
||||
const { dayStart, dayEnd } = localDayWindow(key, tz);
|
||||
const count = expectedSlotCountForDay(
|
||||
[
|
||||
{
|
||||
windowStart: times[0],
|
||||
windowEnd: times[0],
|
||||
daysOfWeek: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
timesOfDay: [...times].sort(),
|
||||
},
|
||||
],
|
||||
dayStart,
|
||||
dayEnd,
|
||||
context(tz),
|
||||
);
|
||||
expect(count).toBe(times.length);
|
||||
}),
|
||||
300,
|
||||
);
|
||||
});
|
||||
|
||||
it("an as-needed schedule never expects a dose", () => {
|
||||
assertProperty(
|
||||
fc.property(timesArb, dayKeyArb, zoneArb, (times, key, tz) => {
|
||||
const { dayStart, dayEnd } = localDayWindow(key, tz);
|
||||
const count = expectedSlotCountForDay(
|
||||
[
|
||||
{
|
||||
windowStart: times[0],
|
||||
windowEnd: times[0],
|
||||
daysOfWeek: null,
|
||||
scheduleType: "PRN",
|
||||
rrule: "FREQ=DAILY",
|
||||
timesOfDay: [...times].sort(),
|
||||
},
|
||||
],
|
||||
dayStart,
|
||||
dayEnd,
|
||||
context(tz),
|
||||
);
|
||||
expect(count).toBe(0);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("a week of a weekly schedule holds weekdays × times doses", () => {
|
||||
assertProperty(
|
||||
fc.property(
|
||||
weekdaysArb,
|
||||
timesArb,
|
||||
dayKeyArb,
|
||||
zoneArb,
|
||||
(weekdays, times, firstKey, tz) => {
|
||||
let total = 0;
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const { dayStart, dayEnd } = localDayWindow(
|
||||
shiftDateKey(firstKey, i),
|
||||
tz,
|
||||
);
|
||||
total += expectedSlotCountForDay(
|
||||
[weeklySchedule(weekdays, times)],
|
||||
dayStart,
|
||||
dayEnd,
|
||||
context(tz),
|
||||
);
|
||||
}
|
||||
expect(total).toBe(weekdays.length * times.length);
|
||||
},
|
||||
),
|
||||
80,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** A daily 08:00 schedule and a 14-day history with random outcomes. */
|
||||
const historyArb = fc.array(fc.constantFrom("taken", "skipped", "nothing"), {
|
||||
minLength: 14,
|
||||
maxLength: 14,
|
||||
});
|
||||
|
||||
const NOW = new Date("2026-03-15T12:00:00Z");
|
||||
const DAILY: ComplianceSchedule[] = [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
timesOfDay: ["08:00"],
|
||||
},
|
||||
];
|
||||
|
||||
function eventsFrom(outcomes: readonly string[]): IntakeEvent[] {
|
||||
return outcomes.flatMap((outcome, i): IntakeEvent[] => {
|
||||
const scheduledFor = new Date(
|
||||
Date.UTC(2026, 2, 15 - (outcomes.length - 1) + i, 8, 0),
|
||||
);
|
||||
if (outcome === "nothing") return [];
|
||||
return [
|
||||
{
|
||||
scheduledFor,
|
||||
takenAt:
|
||||
outcome === "taken"
|
||||
? new Date(scheduledFor.getTime() + 10 * 60_000)
|
||||
: null,
|
||||
skipped: outcome === "skipped",
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function rateOf(outcomes: readonly string[]) {
|
||||
return calculateCompliance(eventsFrom(outcomes), DAILY, 14, undefined, {
|
||||
now: NOW,
|
||||
medicationContext: context("UTC"),
|
||||
});
|
||||
}
|
||||
|
||||
describe("the compliance rate", () => {
|
||||
it("stays within 0 to 100 and never counts more doses than expected", () => {
|
||||
assertProperty(
|
||||
fc.property(historyArb, (outcomes) => {
|
||||
const result = rateOf(outcomes);
|
||||
expect(result.rate).toBeGreaterThanOrEqual(0);
|
||||
expect(result.rate).toBeLessThanOrEqual(100);
|
||||
expect(result.taken).toBeLessThanOrEqual(result.totalExpected);
|
||||
expect(result.taken).toBe(outcomes.filter((o) => o === "taken").length);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("never falls when a missed dose is taken", () => {
|
||||
assertProperty(
|
||||
fc.property(
|
||||
historyArb,
|
||||
fc.integer({ min: 0, max: 13 }),
|
||||
(outcomes, index) => {
|
||||
fc.pre(outcomes[index] === "nothing");
|
||||
const after = [...outcomes];
|
||||
after[index] = "taken";
|
||||
expect(rateOf(after).rate).toBeGreaterThanOrEqual(
|
||||
rateOf(outcomes).rate,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads 100 when every expected dose was taken and 0 when none was", () => {
|
||||
expect(rateOf(Array(14).fill("taken")).rate).toBe(100);
|
||||
expect(rateOf(Array(14).fill("nothing")).rate).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* The statistics helpers against their invariants, on random samples.
|
||||
*
|
||||
* Not a second implementation: each property is a law any correct version
|
||||
* obeys (order does not matter, shifting the data shifts the answer, a
|
||||
* spread is never negative), plus agreement between the several medians the
|
||||
* tree carries, which are separate code that has to say the same number.
|
||||
*/
|
||||
import fc from "fast-check";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { fitFromSums, linearFit, sumsOf } from "@/lib/stats/linear-fit";
|
||||
import { standardDeviation, variance } from "@/lib/stats/spread";
|
||||
import {
|
||||
defaultHacLag,
|
||||
fitSegmentedRegression,
|
||||
MIN_HAC_LAG,
|
||||
neweyWestLag,
|
||||
} from "@/lib/stats/segmented-regression";
|
||||
import { median as baselineMedian } from "@/lib/insights/derived/baseline";
|
||||
import { median as calendarMedian } from "@/lib/activity/calendar-math";
|
||||
import { median as cycleMedian } from "@/lib/cycle/prediction";
|
||||
import { median as scoreMedian, mean } from "@/lib/analytics/score/shared";
|
||||
import { percentile } from "@/lib/insights/strain-score";
|
||||
import { quantile } from "@/lib/analytics/mood-prognosis/validate";
|
||||
|
||||
import { assertProperty } from "../../../../tests/property/fast-check";
|
||||
|
||||
/**
|
||||
* Values on the scale and at the precision of health readings: hundredths
|
||||
* between -1 000 and 10 000. Subnormal doubles are not readings, and near
|
||||
* them the identities hold only to the last bit.
|
||||
*/
|
||||
const valueArb = fc
|
||||
.integer({ min: -100_000, max: 1_000_000 })
|
||||
.map((hundredths) => hundredths / 100);
|
||||
const sampleArb = fc.array(valueArb, { minLength: 1, maxLength: 60 });
|
||||
|
||||
function close(a: number, b: number, scale = 1): void {
|
||||
expect(Math.abs(a - b)).toBeLessThanOrEqual(1e-7 * Math.max(1, scale));
|
||||
}
|
||||
|
||||
function magnitude(values: readonly number[]): number {
|
||||
return values.reduce((m, v) => Math.max(m, Math.abs(v)), 1);
|
||||
}
|
||||
|
||||
describe("medians and quantiles", () => {
|
||||
it("every median in the tree says the same number", () => {
|
||||
assertProperty(
|
||||
fc.property(sampleArb, (values) => {
|
||||
const expected = baselineMedian([...values]);
|
||||
expect(cycleMedian(values)).toBe(expected);
|
||||
expect(scoreMedian(values)).toBe(expected);
|
||||
// The interpolating forms compute the middle pair as a + (b − a)/2
|
||||
// rather than (a + b)/2, which can differ in the last bit.
|
||||
const scale = magnitude(values);
|
||||
close(calendarMedian(values)!, expected, scale);
|
||||
close(percentile(values, 50), expected, scale);
|
||||
close(quantile(values, 0.5), expected, scale);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("a median ignores order, sits between the extremes, and moves with a shift", () => {
|
||||
assertProperty(
|
||||
fc.property(
|
||||
sampleArb,
|
||||
fc.double({ min: -500, max: 500, noNaN: true }),
|
||||
(values, shift) => {
|
||||
const m = scoreMedian(values);
|
||||
const reversed = [...values].reverse();
|
||||
expect(scoreMedian(reversed)).toBe(m);
|
||||
expect(m).toBeGreaterThanOrEqual(Math.min(...values));
|
||||
expect(m).toBeLessThanOrEqual(Math.max(...values));
|
||||
close(
|
||||
scoreMedian(values.map((v) => v + shift)),
|
||||
m + shift,
|
||||
magnitude(values) + Math.abs(shift),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("a quantile never decreases as the level rises", () => {
|
||||
assertProperty(
|
||||
fc.property(
|
||||
sampleArb,
|
||||
fc.double({ min: 0, max: 1, noNaN: true }),
|
||||
fc.double({ min: 0, max: 1, noNaN: true }),
|
||||
(values, a, b) => {
|
||||
const [lo, hi] = a <= b ? [a, b] : [b, a];
|
||||
expect(quantile(values, lo)).toBeLessThanOrEqual(
|
||||
quantile(values, hi),
|
||||
);
|
||||
expect(quantile(values, 0)).toBe(Math.min(...values));
|
||||
expect(quantile(values, 1)).toBe(Math.max(...values));
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("a mean sits between the extremes", () => {
|
||||
assertProperty(
|
||||
fc.property(sampleArb, (values) => {
|
||||
const m = mean(values);
|
||||
const eps = 1e-9 * magnitude(values);
|
||||
expect(m).toBeGreaterThanOrEqual(Math.min(...values) - eps);
|
||||
expect(m).toBeLessThanOrEqual(Math.max(...values) + eps);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spread", () => {
|
||||
it("is never negative, ignores a shift, and scales with the data", () => {
|
||||
assertProperty(
|
||||
fc.property(
|
||||
fc.array(valueArb, { minLength: 2, maxLength: 60 }),
|
||||
fc.double({ min: -500, max: 500, noNaN: true }),
|
||||
fc.double({ min: 0.01, max: 100, noNaN: true }),
|
||||
(values, shift, scale) => {
|
||||
const sd = standardDeviation(values, "population")!;
|
||||
expect(sd).toBeGreaterThanOrEqual(0);
|
||||
const tolerance = 1e-6 * (magnitude(values) + Math.abs(shift));
|
||||
expect(
|
||||
Math.abs(
|
||||
standardDeviation(
|
||||
values.map((v) => v + shift),
|
||||
"population",
|
||||
)! - sd,
|
||||
),
|
||||
).toBeLessThanOrEqual(tolerance);
|
||||
expect(
|
||||
Math.abs(
|
||||
standardDeviation(
|
||||
values.map((v) => v * scale),
|
||||
"population",
|
||||
)! -
|
||||
sd * scale,
|
||||
),
|
||||
).toBeLessThanOrEqual(tolerance * scale);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("puts the sample variance above the population variance by n/(n-1)", () => {
|
||||
assertProperty(
|
||||
fc.property(
|
||||
fc.array(valueArb, { minLength: 2, maxLength: 60 }),
|
||||
(values) => {
|
||||
const n = values.length;
|
||||
const population = variance(values, "population")!;
|
||||
const sample = variance(values, "sample")!;
|
||||
close(sample, (population * n) / (n - 1), magnitude(values) ** 2);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("is undefined where the divisor would be zero", () => {
|
||||
expect(variance([], "population")).toBeNull();
|
||||
expect(variance([3], "sample")).toBeNull();
|
||||
expect(variance([3], "population")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/** Points with at least two distinct x values, so a slope exists. */
|
||||
const pointsArb = fc
|
||||
.array(
|
||||
fc.record({
|
||||
x: fc.integer({ min: -400, max: 400 }),
|
||||
y: valueArb,
|
||||
}),
|
||||
{ minLength: 2, maxLength: 50 },
|
||||
)
|
||||
.filter((ps) => new Set(ps.map((p) => p.x)).size >= 2);
|
||||
|
||||
describe("least-squares line", () => {
|
||||
it("does not depend on the order of the points", () => {
|
||||
assertProperty(
|
||||
fc.property(pointsArb, (points) => {
|
||||
const fit = linearFit(
|
||||
points.map((p) => p.x),
|
||||
points.map((p) => p.y),
|
||||
)!;
|
||||
const reversed = [...points].reverse();
|
||||
const again = linearFit(
|
||||
reversed.map((p) => p.x),
|
||||
reversed.map((p) => p.y),
|
||||
)!;
|
||||
close(again.slope, fit.slope, magnitude(points.map((p) => p.y)));
|
||||
close(
|
||||
again.intercept,
|
||||
fit.intercept,
|
||||
magnitude(points.map((p) => p.y)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("recovers an exact line, and an affine change of y changes the fit the same way", () => {
|
||||
assertProperty(
|
||||
fc.property(
|
||||
fc.uniqueArray(fc.integer({ min: -400, max: 400 }), {
|
||||
minLength: 2,
|
||||
maxLength: 40,
|
||||
}),
|
||||
fc.double({ min: -50, max: 50, noNaN: true }),
|
||||
fc.double({ min: -1000, max: 1000, noNaN: true }),
|
||||
(xs, slope, intercept) => {
|
||||
const ys = xs.map((x) => slope * x + intercept);
|
||||
const fit = linearFit(xs, ys)!;
|
||||
const scale = Math.abs(intercept) + Math.abs(slope) * 400;
|
||||
close(fit.slope, slope, scale);
|
||||
close(fit.intercept, intercept, scale);
|
||||
// The mean point lies on the fitted line.
|
||||
close(fit.intercept + fit.slope * fit.meanX, fit.meanY, scale);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("composes from stored sums exactly as from the points", () => {
|
||||
assertProperty(
|
||||
fc.property(pointsArb, (points) => {
|
||||
const xs = points.map((p) => p.x);
|
||||
const ys = points.map((p) => p.y);
|
||||
expect(fitFromSums(sumsOf(xs, ys))).toEqual(linearFit(xs, ys));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps r² within [0, 1] when y varies", () => {
|
||||
assertProperty(
|
||||
fc.property(pointsArb, (points) => {
|
||||
const fit = linearFit(
|
||||
points.map((p) => p.x),
|
||||
points.map((p) => p.y),
|
||||
)!;
|
||||
if (fit.r2 === null) {
|
||||
expect(fit.syy).toBe(0);
|
||||
return;
|
||||
}
|
||||
expect(fit.r2).toBeGreaterThanOrEqual(0);
|
||||
expect(fit.r2).toBeLessThanOrEqual(1 + 1e-9);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a vertical line instead of inventing a slope", () => {
|
||||
assertProperty(
|
||||
fc.property(
|
||||
fc.integer({ min: -400, max: 400 }),
|
||||
fc.array(valueArb, { minLength: 2, maxLength: 20 }),
|
||||
(x, ys) => {
|
||||
expect(
|
||||
linearFit(
|
||||
ys.map(() => x),
|
||||
ys,
|
||||
),
|
||||
).toBeNull();
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("segmented regression and its Newey-West lag", () => {
|
||||
it("never lets the lag rule go down as the series grows, and applies at least a week", () => {
|
||||
assertProperty(
|
||||
fc.property(
|
||||
fc.integer({ min: 5, max: 5000 }),
|
||||
fc.integer({ min: 0, max: 5000 }),
|
||||
(n, extra) => {
|
||||
expect(neweyWestLag(n + extra)).toBeGreaterThanOrEqual(
|
||||
neweyWestLag(n),
|
||||
);
|
||||
expect(defaultHacLag(n)).toBeGreaterThanOrEqual(MIN_HAC_LAG);
|
||||
expect(defaultHacLag(n)).toBeGreaterThanOrEqual(neweyWestLag(n));
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("recovers a noiseless interrupted line with intervals that contain the estimates", () => {
|
||||
assertProperty(
|
||||
fc.property(
|
||||
fc.integer({ min: 12, max: 60 }),
|
||||
fc.integer({ min: 12, max: 60 }),
|
||||
fc.double({ min: -5, max: 5, noNaN: true }),
|
||||
fc.double({ min: -5, max: 5, noNaN: true }),
|
||||
fc.double({ min: -20, max: 20, noNaN: true }),
|
||||
fc.double({ min: -2, max: 2, noNaN: true }),
|
||||
(before, after, intercept, preSlope, levelChange, slopeChange) => {
|
||||
const points = [];
|
||||
for (let t = -before; t < after; t++) {
|
||||
const d = t >= 0 ? 1 : 0;
|
||||
points.push({
|
||||
t,
|
||||
y:
|
||||
intercept +
|
||||
preSlope * t +
|
||||
levelChange * d +
|
||||
slopeChange * t * d,
|
||||
});
|
||||
}
|
||||
const fit = fitSegmentedRegression(points)!;
|
||||
expect(fit).not.toBeNull();
|
||||
const scale = 100;
|
||||
close(fit.intercept.estimate, intercept, scale);
|
||||
close(fit.preSlope.estimate, preSlope, scale);
|
||||
close(fit.levelChange.estimate, levelChange, scale);
|
||||
close(fit.slopeChange.estimate, slopeChange, scale);
|
||||
for (const c of [
|
||||
fit.intercept,
|
||||
fit.preSlope,
|
||||
fit.levelChange,
|
||||
fit.slopeChange,
|
||||
]) {
|
||||
expect(c.ciLow).toBeLessThanOrEqual(c.estimate);
|
||||
expect(c.estimate).toBeLessThanOrEqual(c.ciHigh);
|
||||
expect(c.standardError).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
},
|
||||
),
|
||||
60,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a design with no points after the change", () => {
|
||||
assertProperty(
|
||||
fc.property(fc.array(valueArb, { minLength: 6, maxLength: 30 }), (ys) => {
|
||||
const points = ys.map((y, i) => ({ t: -(i + 1), y }));
|
||||
expect(fitSegmentedRegression(points)).toBeNull();
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Time-zone and day-boundary helpers against their laws, on random instants
|
||||
* in zones chosen for their awkward offsets and transitions.
|
||||
*
|
||||
* The hand-written cases next door pin the transitions somebody thought of;
|
||||
* these generate the ones nobody did. Each property states something a
|
||||
* person would notice if it broke: a reading filed under the wrong day, a
|
||||
* reminder an hour off on a transition day, a day that does not contain its
|
||||
* own instants.
|
||||
*/
|
||||
import fc from "fast-check";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
daysBetweenDateKeys,
|
||||
shiftDateKey,
|
||||
tzOffsetMinutes,
|
||||
userDayKey,
|
||||
weekdayOfDateKey,
|
||||
} from "@/lib/tz/format";
|
||||
import { localDayWindow, startOfLocalDayInTz } from "@/lib/tz/local-day";
|
||||
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
import { startOfUtcDay } from "@/lib/tz/start-of-utc-day";
|
||||
import { dateOnlyAtNoonUtc, dateOnlyKey } from "@/lib/tz/date-only";
|
||||
|
||||
import {
|
||||
assertProperty,
|
||||
instantArb,
|
||||
zoneArb,
|
||||
} from "../../../../tests/property/fast-check";
|
||||
|
||||
/** The oracle: what `Intl` itself reads off the wall clock. */
|
||||
function intlWallClock(date: Date, tz: string) {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
hourCycle: "h23",
|
||||
weekday: "short",
|
||||
}).formatToParts(date);
|
||||
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
|
||||
const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
return {
|
||||
year: Number(get("year")),
|
||||
month: Number(get("month")),
|
||||
day: Number(get("day")),
|
||||
hour: Number(get("hour")) % 24,
|
||||
minute: Number(get("minute")),
|
||||
second: Number(get("second")),
|
||||
weekday: weekdays.indexOf(get("weekday")),
|
||||
};
|
||||
}
|
||||
|
||||
const dayKeyArb = fc
|
||||
.date({
|
||||
min: new Date("1996-01-01T00:00:00Z"),
|
||||
max: new Date("2039-12-31T00:00:00Z"),
|
||||
noInvalidDate: true,
|
||||
})
|
||||
.map((d) => d.toISOString().slice(0, 10));
|
||||
|
||||
describe("wall clock in a zone", () => {
|
||||
it("reads the same wall clock as Intl for any instant and zone", () => {
|
||||
assertProperty(
|
||||
fc.property(instantArb, zoneArb, (date, tz) => {
|
||||
expect(wallClockInTz(date, tz)).toEqual(intlWallClock(date, tz));
|
||||
}),
|
||||
400,
|
||||
);
|
||||
});
|
||||
|
||||
it("maps a wall clock that exists back to an instant showing that wall clock", () => {
|
||||
assertProperty(
|
||||
fc.property(instantArb, zoneArb, (date, tz) => {
|
||||
const wall = wallClockInTz(date, tz);
|
||||
const back = zonedWallClockToUtc(wall, tz);
|
||||
const again = wallClockInTz(back, tz);
|
||||
expect(again).toEqual(wall);
|
||||
// Inside a repeated hour the inverse may pick the other occurrence;
|
||||
// it never lands further away than the largest DST shift.
|
||||
const truncated = Math.floor(date.getTime() / 1000) * 1000;
|
||||
expect(Math.abs(back.getTime() - truncated)).toBeLessThanOrEqual(
|
||||
2 * 3_600_000,
|
||||
);
|
||||
}),
|
||||
400,
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a wall clock inside a skipped hour forward, on the same day", () => {
|
||||
assertProperty(
|
||||
fc.property(
|
||||
instantArb,
|
||||
zoneArb,
|
||||
fc.integer({ min: 0, max: 23 }),
|
||||
fc.integer({ min: 0, max: 59 }),
|
||||
(date, tz, hour, minute) => {
|
||||
const day = wallClockInTz(date, tz);
|
||||
const wanted = { ...day, hour, minute, second: 0 };
|
||||
const resolved = zonedWallClockToUtc(wanted, tz);
|
||||
const shown = wallClockInTz(resolved, tz);
|
||||
const asIfUtc = (w: typeof wanted) =>
|
||||
Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute);
|
||||
const drift = asIfUtc(shown) - asIfUtc(wanted);
|
||||
// Either the wall clock exists and is hit exactly, or it falls in a
|
||||
// gap and is read with the offset before the gap: later, never
|
||||
// earlier, by at most the size of the gap.
|
||||
expect(drift).toBeGreaterThanOrEqual(0);
|
||||
expect(drift).toBeLessThanOrEqual(2 * 3_600_000);
|
||||
},
|
||||
),
|
||||
400,
|
||||
);
|
||||
});
|
||||
|
||||
it("agrees with the offset helper on the zone offset", () => {
|
||||
assertProperty(
|
||||
fc.property(instantArb, zoneArb, (date, tz) => {
|
||||
const wall = intlWallClock(date, tz);
|
||||
const asIfUtc = Date.UTC(
|
||||
wall.year,
|
||||
wall.month - 1,
|
||||
wall.day,
|
||||
wall.hour,
|
||||
wall.minute,
|
||||
wall.second,
|
||||
);
|
||||
const expected = Math.round((asIfUtc - date.getTime()) / 60_000);
|
||||
expect(tzOffsetMinutes(date, tz)).toBe(expected);
|
||||
}),
|
||||
300,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("local days", () => {
|
||||
it("files an instant under a day whose window contains it", () => {
|
||||
assertProperty(
|
||||
fc.property(instantArb, zoneArb, (date, tz) => {
|
||||
const key = userDayKey(date, tz);
|
||||
const { dayStart, dayEnd } = localDayWindow(key, tz);
|
||||
expect(dayStart.getTime()).toBeLessThanOrEqual(date.getTime());
|
||||
expect(date.getTime()).toBeLessThan(dayEnd.getTime());
|
||||
expect(startOfLocalDayInTz(date, tz).getTime()).toBe(
|
||||
dayStart.getTime(),
|
||||
);
|
||||
}),
|
||||
400,
|
||||
);
|
||||
});
|
||||
|
||||
it("never files a later instant under an earlier day", () => {
|
||||
assertProperty(
|
||||
fc.property(instantArb, instantArb, zoneArb, (a, b, tz) => {
|
||||
const [early, late] = a <= b ? [a, b] : [b, a];
|
||||
expect(userDayKey(early, tz) <= userDayKey(late, tz)).toBe(true);
|
||||
}),
|
||||
400,
|
||||
);
|
||||
});
|
||||
|
||||
it("makes every local day between 22 and 26 hours long and contiguous with the next", () => {
|
||||
assertProperty(
|
||||
fc.property(dayKeyArb, zoneArb, (key, tz) => {
|
||||
const today = localDayWindow(key, tz);
|
||||
const tomorrow = localDayWindow(shiftDateKey(key, 1), tz);
|
||||
const hours =
|
||||
(today.dayEnd.getTime() - today.dayStart.getTime()) / 3_600_000;
|
||||
expect(hours).toBeGreaterThanOrEqual(22);
|
||||
expect(hours).toBeLessThanOrEqual(26);
|
||||
expect(tomorrow.dayStart.getTime()).toBe(today.dayEnd.getTime());
|
||||
expect(userDayKey(today.dayStart, tz)).toBe(key);
|
||||
}),
|
||||
300,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("day keys", () => {
|
||||
it("shifts and counts days as inverses of each other", () => {
|
||||
assertProperty(
|
||||
fc.property(
|
||||
dayKeyArb,
|
||||
fc.integer({ min: -2000, max: 2000 }),
|
||||
(key, delta) => {
|
||||
const shifted = shiftDateKey(key, delta);
|
||||
expect(daysBetweenDateKeys(key, shifted)).toBe(delta);
|
||||
expect(shiftDateKey(shifted, -delta)).toBe(key);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the weekday across whole weeks and advances it by one per day", () => {
|
||||
assertProperty(
|
||||
fc.property(
|
||||
dayKeyArb,
|
||||
fc.integer({ min: -300, max: 300 }),
|
||||
(key, weeks) => {
|
||||
const weekday = weekdayOfDateKey(key);
|
||||
expect(weekdayOfDateKey(shiftDateKey(key, weeks * 7))).toBe(weekday);
|
||||
expect(weekdayOfDateKey(shiftDateKey(key, 1))).toBe(
|
||||
(weekday + 1) % 7,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("stores a date-only value at noon UTC and reads the same date back", () => {
|
||||
assertProperty(
|
||||
fc.property(dayKeyArb, (key) => {
|
||||
expect(dateOnlyKey(dateOnlyAtNoonUtc(key))).toBe(key);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("floors an instant to its UTC day, idempotently", () => {
|
||||
assertProperty(
|
||||
fc.property(instantArb, (date) => {
|
||||
const floor = startOfUtcDay(date);
|
||||
expect(floor.getTime()).toBeLessThanOrEqual(date.getTime());
|
||||
expect(date.getTime() - floor.getTime()).toBeLessThan(86_400_000);
|
||||
expect(startOfUtcDay(floor).getTime()).toBe(floor.getTime());
|
||||
expect(floor.toISOString().endsWith("T00:00:00.000Z")).toBe(true);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* A randomly generated account survives a disaster-recovery backup and
|
||||
* restore unchanged.
|
||||
*
|
||||
* `backup-round-trip.test.ts` proves one hand-seeded row of every model and
|
||||
* one value of every column. This file generates whole accounts instead:
|
||||
* measurements of mixed types and sources, medications with weekly and daily
|
||||
* schedules and intake histories, mood entries, and a handful of settings, in
|
||||
* combinations nobody wrote down. Each account is exported the way the weekly
|
||||
* worker exports it, the account is deleted and recreated under the same id,
|
||||
* the file is restored through the operator's restore route, and a canonical
|
||||
* dump of every generated table must come back identical.
|
||||
*
|
||||
* Seeded like every property test (tests/property/fast-check.ts); a failure
|
||||
* prints the seed and the smallest account that still fails.
|
||||
*/
|
||||
import fc from "fast-check";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
||||
|
||||
import { assertAsyncProperty } from "../property/fast-check";
|
||||
import { callRoute } from "../kit/call-route";
|
||||
import { makeAdmin, makeSession, makeUser } from "../kit/factories";
|
||||
import { POST as restoreBackup } from "./restore-job-driver";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const OWNER_ID = "random-account-owner";
|
||||
|
||||
const MEASUREMENTS: ReadonlyArray<{
|
||||
type: Prisma.MeasurementCreateManyInput["type"];
|
||||
unit: string;
|
||||
min: number;
|
||||
max: number;
|
||||
}> = [
|
||||
{ type: "WEIGHT", unit: "kg", min: 40, max: 160 },
|
||||
{ type: "BLOOD_PRESSURE_SYS", unit: "mmHg", min: 90, max: 190 },
|
||||
{ type: "BLOOD_PRESSURE_DIA", unit: "mmHg", min: 50, max: 120 },
|
||||
{ type: "PULSE", unit: "bpm", min: 40, max: 180 },
|
||||
{ type: "BLOOD_GLUCOSE", unit: "mg/dL", min: 50, max: 300 },
|
||||
{ type: "ACTIVITY_STEPS", unit: "steps", min: 0, max: 30_000 },
|
||||
];
|
||||
const SOURCES = ["MANUAL", "WITHINGS", "APPLE_HEALTH", "IMPORT"] as const;
|
||||
|
||||
const instant = fc
|
||||
.integer({
|
||||
min: Date.parse("2024-01-01T00:00:00Z") / 60_000,
|
||||
max: Date.parse("2026-09-30T00:00:00Z") / 60_000,
|
||||
})
|
||||
.map((minutes) => new Date(minutes * 60_000));
|
||||
|
||||
const measurementArb = fc.record({
|
||||
kind: fc.nat({ max: MEASUREMENTS.length - 1 }),
|
||||
source: fc.constantFrom(...SOURCES),
|
||||
measuredAt: instant,
|
||||
fraction: fc.double({ min: 0, max: 1, noNaN: true }),
|
||||
externalId: fc.option(fc.uuid(), { nil: null }),
|
||||
});
|
||||
|
||||
const scheduleArb = fc.oneof(
|
||||
fc.record({
|
||||
windowStart: fc.constantFrom("07:00", "08:00", "20:00"),
|
||||
daysOfWeek: fc.constantFrom(null, "1,3,5", "0,6"),
|
||||
rrule: fc.constant(null),
|
||||
timesOfDay: fc.constant<string[]>([]),
|
||||
}),
|
||||
fc.record({
|
||||
windowStart: fc.constantFrom("06:30", "12:00"),
|
||||
daysOfWeek: fc.constant(null),
|
||||
rrule: fc.constantFrom("FREQ=DAILY", "FREQ=WEEKLY;BYDAY=MO,TH"),
|
||||
timesOfDay: fc.uniqueArray(fc.constantFrom("06:30", "12:00", "21:15"), {
|
||||
minLength: 1,
|
||||
maxLength: 2,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const intakeArb = fc.record({
|
||||
scheduledFor: instant,
|
||||
outcome: fc.constantFrom("taken", "skipped", "missed"),
|
||||
});
|
||||
|
||||
const medicationArb = fc.record({
|
||||
name: fc.constantFrom("Ramipril", "Metformin", "Vitamin D", "Levothyroxin"),
|
||||
dose: fc.constantFrom("5mg", "500mg", "1000 IE", "50µg"),
|
||||
asNeeded: fc.boolean(),
|
||||
active: fc.boolean(),
|
||||
schedules: fc.array(scheduleArb, { minLength: 1, maxLength: 2 }),
|
||||
intakes: fc.uniqueArray(intakeArb, {
|
||||
maxLength: 5,
|
||||
selector: (i) => i.scheduledFor.getTime(),
|
||||
}),
|
||||
});
|
||||
|
||||
const MOODS = [
|
||||
["SUPER_GUT", 5],
|
||||
["GUT", 4],
|
||||
["OKAY", 3],
|
||||
["SCHLECHT", 2],
|
||||
["LAUSIG", 1],
|
||||
] as const;
|
||||
|
||||
const moodArb = fc.record({
|
||||
loggedAt: instant,
|
||||
level: fc.nat({ max: 4 }),
|
||||
dims: fc.option(fc.tuple(fc.nat({ max: 10 }), fc.nat({ max: 10 })), {
|
||||
nil: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const settingsArb = fc.record(
|
||||
{
|
||||
unitPreference: fc.constantFrom("metric", "imperial"),
|
||||
glucoseUnit: fc.constantFrom("mg/dL", "mmol/L"),
|
||||
timezone: fc.constantFrom(
|
||||
"Europe/Berlin",
|
||||
"America/New_York",
|
||||
"Asia/Kolkata",
|
||||
),
|
||||
heightCm: fc.integer({ min: 140, max: 210 }),
|
||||
hasDiabetes: fc.boolean(),
|
||||
},
|
||||
{ requiredKeys: [] },
|
||||
);
|
||||
|
||||
const accountArb = fc.record({
|
||||
measurements: fc.uniqueArray(measurementArb, {
|
||||
maxLength: 25,
|
||||
selector: (m) => `${m.kind}|${m.source}|${m.measuredAt.getTime()}`,
|
||||
}),
|
||||
medications: fc.array(medicationArb, { maxLength: 3 }),
|
||||
moods: fc.uniqueArray(moodArb, {
|
||||
maxLength: 6,
|
||||
selector: (m) => m.loggedAt.toISOString().slice(0, 10),
|
||||
}),
|
||||
settings: settingsArb,
|
||||
});
|
||||
|
||||
type Account = typeof accountArb extends fc.Arbitrary<infer T> ? T : never;
|
||||
|
||||
async function seed(account: Account): Promise<void> {
|
||||
await makeUser({ id: OWNER_ID, ...account.settings });
|
||||
await prisma.measurement.createMany({
|
||||
data: account.measurements.map((m) => {
|
||||
const kind = MEASUREMENTS[m.kind];
|
||||
const value =
|
||||
Math.round((kind.min + m.fraction * (kind.max - kind.min)) * 10) / 10;
|
||||
return {
|
||||
userId: OWNER_ID,
|
||||
type: kind.type,
|
||||
unit: kind.unit,
|
||||
value,
|
||||
source: m.source,
|
||||
measuredAt: m.measuredAt,
|
||||
externalId: m.externalId,
|
||||
};
|
||||
}),
|
||||
});
|
||||
for (const med of account.medications) {
|
||||
const created = await prisma.medication.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
name: med.name,
|
||||
dose: med.dose,
|
||||
asNeeded: med.asNeeded,
|
||||
active: med.active,
|
||||
schedules: {
|
||||
create: med.schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowStart,
|
||||
daysOfWeek: s.daysOfWeek,
|
||||
rrule: s.rrule,
|
||||
timesOfDay: s.timesOfDay,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.medicationIntakeEvent.createMany({
|
||||
data: med.intakes.map((i) => ({
|
||||
userId: OWNER_ID,
|
||||
medicationId: created.id,
|
||||
scheduledFor: i.scheduledFor,
|
||||
takenAt:
|
||||
i.outcome === "taken"
|
||||
? new Date(i.scheduledFor.getTime() + 600_000)
|
||||
: null,
|
||||
skipped: i.outcome === "skipped",
|
||||
})),
|
||||
});
|
||||
}
|
||||
await prisma.moodEntry.createMany({
|
||||
data: account.moods.map((m) => {
|
||||
const [mood, score] = MOODS[m.level];
|
||||
return {
|
||||
userId: OWNER_ID,
|
||||
date: m.loggedAt.toISOString().slice(0, 10),
|
||||
mood,
|
||||
score,
|
||||
moodLoggedAt: m.loggedAt,
|
||||
tz: "UTC",
|
||||
moodA1: m.dims?.[0] ?? null,
|
||||
stressA2: m.dims?.[1] ?? null,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Columns a restore legitimately rewrites: the bookkeeping Prisma stamps on
|
||||
* every write, and the per-row sync counters the restore bumps so a paired
|
||||
* client re-reads the rows.
|
||||
*/
|
||||
const VOLATILE = new Set(["updatedAt", "syncedAt", "syncVersion"]);
|
||||
|
||||
function canonical(rows: ReadonlyArray<object>): string[] {
|
||||
return rows
|
||||
.map((row) =>
|
||||
JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(row)
|
||||
.filter(([key]) => !VOLATILE.has(key))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => [
|
||||
key,
|
||||
value instanceof Date ? value.toISOString() : value,
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
.sort();
|
||||
}
|
||||
|
||||
async function dump(): Promise<Record<string, string[]>> {
|
||||
const where = { userId: OWNER_ID };
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: OWNER_ID },
|
||||
select: {
|
||||
unitPreference: true,
|
||||
glucoseUnit: true,
|
||||
timezone: true,
|
||||
heightCm: true,
|
||||
hasDiabetes: true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
user: canonical([user]),
|
||||
measurements: canonical(await prisma.measurement.findMany({ where })),
|
||||
medications: canonical(await prisma.medication.findMany({ where })),
|
||||
schedules: canonical(
|
||||
await prisma.medicationSchedule.findMany({
|
||||
where: { medication: where },
|
||||
}),
|
||||
),
|
||||
intakes: canonical(await prisma.medicationIntakeEvent.findMany({ where })),
|
||||
moods: canonical(await prisma.moodEntry.findMany({ where })),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
});
|
||||
|
||||
describe("a generated account", () => {
|
||||
it("comes back from a disaster-recovery backup exactly as it left", async () => {
|
||||
await assertAsyncProperty(
|
||||
fc.asyncProperty(accountArb, async (account) => {
|
||||
await truncateAllTables(prisma);
|
||||
await seed(account);
|
||||
const before = await dump();
|
||||
|
||||
let json = "";
|
||||
await streamFullBackupJson(
|
||||
prisma,
|
||||
OWNER_ID,
|
||||
(chunk) => {
|
||||
json += chunk;
|
||||
},
|
||||
{
|
||||
purpose: "disaster-recovery",
|
||||
exportedAt: new Date("2026-10-01T00:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
|
||||
await prisma.user.delete({ where: { id: OWNER_ID } });
|
||||
await makeUser({ id: OWNER_ID });
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
type: "RANDOM_ACCOUNT_ROUND_TRIP",
|
||||
data: await legacyStreamedBlobFrom(async (write) => {
|
||||
await write(json);
|
||||
}),
|
||||
},
|
||||
});
|
||||
const operator = await makeSession(await makeAdmin());
|
||||
const restored = await callRoute(restoreBackup, {
|
||||
method: "POST",
|
||||
route: "/api/admin/backups/{id}/restore",
|
||||
params: { id: backup.id },
|
||||
body: { confirm: "RESTORE" },
|
||||
auth: operator,
|
||||
contract: {
|
||||
skip: "the restore driver answers 200 with the job's report in place of the route's queued 202",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
restored.status,
|
||||
JSON.stringify(restored.body).slice(0, 300),
|
||||
).toBe(200);
|
||||
|
||||
expect(await dump()).toEqual(before);
|
||||
}),
|
||||
12,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* One set of fast-check parameters for every property test.
|
||||
*
|
||||
* A pull request runs each property with a fixed seed, so the same inputs
|
||||
* are generated on every machine and a red run is a red run on the next
|
||||
* attempt too. The nightly workflow sets `FC_SEED` to a fresh value, which is
|
||||
* how new inputs get tried without making a pull request flaky; a failure
|
||||
* prints the seed and the shrunk counterexample, and
|
||||
* `FC_SEED=<seed> pnpm test <file>` replays it exactly.
|
||||
*
|
||||
* `FC_NUM_RUNS` scales the number of generated cases (the nightly run uses
|
||||
* more). The default keeps every property file well under a second or two.
|
||||
*/
|
||||
import fc from "fast-check";
|
||||
|
||||
const DEFAULT_SEED = 20_261_022;
|
||||
|
||||
function positiveInt(raw: string | undefined): number | null {
|
||||
if (!raw) return null;
|
||||
const value = Number.parseInt(raw, 10);
|
||||
return Number.isInteger(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
export const PROPERTY_SEED = positiveInt(process.env.FC_SEED) ?? DEFAULT_SEED;
|
||||
|
||||
const SCALE = positiveInt(process.env.FC_NUM_RUNS);
|
||||
|
||||
/** Parameters for `fc.assert`; `numRuns` is the PR-run default per property. */
|
||||
export function propertyParameters<T>(
|
||||
numRuns = 100,
|
||||
overrides: fc.Parameters<T> = {},
|
||||
): fc.Parameters<T> {
|
||||
return {
|
||||
seed: PROPERTY_SEED,
|
||||
numRuns: SCALE ?? numRuns,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Run a property with the shared parameters. */
|
||||
export function assertProperty<T>(
|
||||
property: fc.IProperty<T>,
|
||||
numRuns?: number,
|
||||
): void {
|
||||
fc.assert(property, propertyParameters<T>(numRuns));
|
||||
}
|
||||
|
||||
/** The async counterpart of {@link assertProperty}. */
|
||||
export async function assertAsyncProperty<T>(
|
||||
property: fc.IAsyncProperty<T>,
|
||||
numRuns?: number,
|
||||
): Promise<void> {
|
||||
await fc.assert(property, propertyParameters<T>(numRuns));
|
||||
}
|
||||
|
||||
/**
|
||||
* IANA zones with the awkward cases in them: half- and quarter-hour offsets
|
||||
* (Kolkata, Kathmandu, Chatham, St John's), a 30-minute DST shift (Lord
|
||||
* Howe), southern-hemisphere DST (Santiago, Sydney, Auckland), a zone whose
|
||||
* DST starts at midnight (Santiago), the date line (Kiritimati, Apia), and a
|
||||
* zone with no DST at all (UTC, Kolkata).
|
||||
*/
|
||||
export const AWKWARD_ZONES = [
|
||||
"UTC",
|
||||
"Europe/Berlin",
|
||||
"Europe/London",
|
||||
"Europe/Dublin",
|
||||
"America/New_York",
|
||||
"America/Los_Angeles",
|
||||
"America/St_Johns",
|
||||
"America/Santiago",
|
||||
"America/Sao_Paulo",
|
||||
"Asia/Kolkata",
|
||||
"Asia/Kathmandu",
|
||||
"Asia/Tehran",
|
||||
"Australia/Lord_Howe",
|
||||
"Australia/Sydney",
|
||||
"Australia/Adelaide",
|
||||
"Pacific/Auckland",
|
||||
"Pacific/Chatham",
|
||||
"Pacific/Kiritimati",
|
||||
"Pacific/Apia",
|
||||
"Africa/Casablanca",
|
||||
] as const;
|
||||
|
||||
export const zoneArb = fc.constantFrom(...AWKWARD_ZONES);
|
||||
|
||||
/** Instants between 1995 and 2040, at millisecond resolution. */
|
||||
export const instantArb = fc.date({
|
||||
min: new Date("1995-01-01T00:00:00Z"),
|
||||
max: new Date("2040-12-31T23:59:59Z"),
|
||||
noInvalidDate: true,
|
||||
});
|
||||
Reference in new issue
Block a user