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:
Marc-André Bombeck committed 2026-10-22 05:32:48 +02:00
1 parent 8b89b65f25
commit 4ff3f3eb5d
6 files changed
+1478

No files matched your search

+234
View File
@@ -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();
}),
);
});
});
+233
View File
@@ -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,
);
});
});
+93
View File
@@ -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,
});