mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge branch 'fix/v147-nightly' into release/v1.47.0
This commit is contained in:
commit
7cb67c2e75
61 files changed
+2983
-2280
No files matched your search
@@ -284,3 +284,14 @@ earns its place; do not extend the date.
|
||||
Order dependence is looked for on purpose: the nightly workflow runs both
|
||||
suites with `--sequence.shuffle` and prints the seed. Replay a failure with
|
||||
`--sequence.shuffle --sequence.seed=<seed>`.
|
||||
|
||||
A case resets or states what it reads: `vi.resetAllMocks()` (or
|
||||
`mockReset()`) where a case swaps in its own implementation, since
|
||||
`clearAllMocks` keeps it and a `mockResolvedValueOnce` the code never consumed
|
||||
answers the next case; a module cache dropped through its own invalidator; a
|
||||
row the case needs written by the case or its own `beforeAll`. A file that is
|
||||
one walk by design (a parity sequence, a migration's before and after, a
|
||||
dry run followed by the import and the same file again) says so in a comment
|
||||
and passes `{ shuffle: false }` to its `describe`, which keeps that block in
|
||||
order while the rest of the suite still shuffles. Wrap the whole file in one
|
||||
such block when its top-level blocks build on each other.
|
||||
@@ -60,6 +60,14 @@ import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin } from "@/lib/api-handler";
|
||||
|
||||
// Reset, not clear, before every case: cases replace the paused rows, the
|
||||
// settings row and the account groups, and a cleared mock would hand the
|
||||
// next case whatever the last one left there. A reset mock goes back to the
|
||||
// empty answer it was created with.
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const groupBy = vi.mocked(prisma.providerHealth.groupBy);
|
||||
const usageRows = vi.mocked(prisma.coachUsage.findMany);
|
||||
|
||||
@@ -101,7 +109,6 @@ function group(
|
||||
|
||||
describe("GET /api/admin/provider-health", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue({
|
||||
user: { id: "admin1" },
|
||||
} as never);
|
||||
@@ -302,7 +309,6 @@ describe("GET /api/admin/provider-health — pause and model check (v1.42)", ()
|
||||
const settings = vi.mocked(prisma.appSettings.findUnique);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue({
|
||||
user: { id: "admin" },
|
||||
} as never);
|
||||
|
||||
@@ -131,7 +131,10 @@ function putReq(
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset, not clear: cases replace the profile reads and the question store
|
||||
// with their own implementations, and a cleared mock keeps them. A reset
|
||||
// mock returns to the implementation it was created with.
|
||||
vi.resetAllMocks();
|
||||
requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -174,6 +174,10 @@ beforeEach(() => {
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(true);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// A session that signed in just now, the factory's own answer. Reset, not
|
||||
// only cleared: a case below swaps in a stale session, and `clearAllMocks`
|
||||
// keeps the implementation it left behind for whichever case runs next.
|
||||
vi.mocked(prisma.session.findUnique).mockReset();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
|
||||
@@ -84,6 +84,10 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(validateAttachmentCandidate).mockResolvedValue({ ok: true });
|
||||
// Open again for every case (the factory's own answer): the detach case
|
||||
// closes every capability, and `clearAllMocks` would carry that refusal
|
||||
// into whichever case runs next.
|
||||
vi.mocked(requireAiCapability).mockReset();
|
||||
});
|
||||
|
||||
describe("POST attach", () => {
|
||||
|
||||
@@ -276,7 +276,10 @@ function chatReq(body: Record<string, unknown>): Request {
|
||||
|
||||
describe("coach chat — tool-mode routing (F1)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset, not clear: cases swap in their own tool loop and parsers, and a
|
||||
// cleared mock would hand those to the next case. Every mock here goes
|
||||
// back to the implementation it was created with.
|
||||
vi.resetAllMocks();
|
||||
reserveBudget.mockResolvedValue({
|
||||
allowed: true,
|
||||
reserved: 3000,
|
||||
|
||||
@@ -52,6 +52,12 @@ import { primeLocales } from "@/__tests__/helpers/prime-locales";
|
||||
|
||||
primeLocales("de");
|
||||
|
||||
// Every case starts on an account that never set a module, whichever case
|
||||
// ran before it and left its own modules behind.
|
||||
beforeEach(() => {
|
||||
modulesRef.value = undefined;
|
||||
});
|
||||
|
||||
function renderSSR(node: React.ReactElement, locale: "en" | "de" = "de") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
@@ -162,10 +168,6 @@ describe("<DashboardHeader> — quick-add menu", () => {
|
||||
});
|
||||
|
||||
describe("<DashboardHeader> — Log workout", () => {
|
||||
beforeEach(() => {
|
||||
modulesRef.value = undefined;
|
||||
});
|
||||
|
||||
it("offers it beside the other capture entries", () => {
|
||||
modulesRef.value = { workouts: true };
|
||||
const html = renderSSR(
|
||||
@@ -187,10 +189,6 @@ describe("<DashboardHeader> — Log workout", () => {
|
||||
});
|
||||
|
||||
describe("<DashboardHeader> — every entry follows its module, as the capture picker does", () => {
|
||||
beforeEach(() => {
|
||||
modulesRef.value = undefined;
|
||||
});
|
||||
|
||||
it("withholds Log mood when the mood module is off", () => {
|
||||
modulesRef.value = { mood: false };
|
||||
const html = renderSSR(
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* off the illness page, its settings and every picker once a record held more
|
||||
* than that, and they could then be found only through search.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const calls = vi.hoisted(() => [] as string[]);
|
||||
|
||||
@@ -41,6 +41,12 @@ vi.mock("@/lib/api/api-fetch", () => ({
|
||||
import { useIllnessEpisodes } from "../use-illness";
|
||||
|
||||
describe("useIllnessEpisodes", () => {
|
||||
// Each case states its own journal and counts only its own requests.
|
||||
beforeEach(() => {
|
||||
calls.length = 0;
|
||||
journal.total = 230;
|
||||
});
|
||||
|
||||
it("reads page after page until the whole journal is in", async () => {
|
||||
const options = useIllnessEpisodes(true) as unknown as {
|
||||
queryFn: () => Promise<{ id: string }[]>;
|
||||
@@ -55,7 +61,6 @@ describe("useIllnessEpisodes", () => {
|
||||
});
|
||||
|
||||
it("stops on a full last page the route says is the end", async () => {
|
||||
calls.length = 0;
|
||||
journal.total = 200;
|
||||
const options = useIllnessEpisodes(true) as unknown as {
|
||||
queryFn: () => Promise<{ id: string }[]>;
|
||||
@@ -63,6 +68,5 @@ describe("useIllnessEpisodes", () => {
|
||||
expect(await options.queryFn()).toHaveLength(200);
|
||||
// Two full pages of 100, no third request for an empty one.
|
||||
expect(calls).toHaveLength(2);
|
||||
journal.total = 230;
|
||||
});
|
||||
});
|
||||
@@ -289,7 +289,10 @@ describe("<MeasurementForm> type", () => {
|
||||
await first.screen.getByLabelText("Value (kg)").fill("80");
|
||||
await first.save.click();
|
||||
await expect.poll(() => first.onSuccess.mock.calls.length).toBe(1);
|
||||
first.screen.unmount();
|
||||
// Awaited: the unmount runs inside `act`, and one left running overlaps
|
||||
// the next render's `act`, after which React commits nothing for the
|
||||
// rest of the file (every later case finds an empty container).
|
||||
await first.screen.unmount();
|
||||
|
||||
const { screen } = await mount();
|
||||
await expect
|
||||
|
||||
@@ -242,6 +242,11 @@ describe("useUrlFilterSync with FilterBar", () => {
|
||||
window.history.back();
|
||||
await expectSearch("?type=WEIGHT");
|
||||
await expect.element(pill("Type: Weight")).toBeInTheDocument();
|
||||
// Back to the newest entry. Left one step behind, the page keeps a
|
||||
// forward entry, and the next case's push replaces it instead of adding
|
||||
// one: `history.length` stays put and its count of entries is off by one.
|
||||
window.history.forward();
|
||||
await expectSearch("?type=PULSE");
|
||||
});
|
||||
|
||||
it("keeps the state in memory and the address untouched with syncToUrl off", async () => {
|
||||
|
||||
@@ -94,6 +94,9 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () =>
|
||||
// for 60 s; reset between tests so each fixture is read fresh.
|
||||
__resetCoachSnapshotCacheForTests();
|
||||
resolveModuleMapMock.mockResolvedValue(allModulesEnabled());
|
||||
// No readings unless a case seeds its own; the case that only inspects
|
||||
// the query used to inherit whatever rows the case before it left.
|
||||
prismaMock.measurement.findMany.mockResolvedValue([]);
|
||||
prismaMock.moodEntry.findMany.mockResolvedValue([]);
|
||||
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
|
||||
prismaMock.medication.findMany.mockResolvedValue([]);
|
||||
|
||||
@@ -129,9 +129,27 @@ function day(): Event[] {
|
||||
return events.sort((a, b) => a.at.localeCompare(b.at));
|
||||
}
|
||||
|
||||
const ALL_SCOPES_AT_WARM = new Set<string>();
|
||||
/**
|
||||
* Every scope the day dirties: run it once on `full` with no warm, which
|
||||
* enqueues each scope at least once. Each case derives it itself rather than
|
||||
* reading what an earlier case left behind.
|
||||
*/
|
||||
async function discoverWarmScopes(): Promise<Set<string>> {
|
||||
sim.notes.clear();
|
||||
sim.level = "FULL";
|
||||
for (const event of day()) {
|
||||
vi.setSystemTime(new Date(`2026-10-15T${event.at}:00Z`));
|
||||
await invalidateStatusInsightsForTypes("u1", event.types, {
|
||||
source: event.source,
|
||||
});
|
||||
}
|
||||
return new Set(sim.notes.keys());
|
||||
}
|
||||
|
||||
async function simulate(level: string | null): Promise<{
|
||||
async function simulate(
|
||||
level: string | null,
|
||||
warmScopes: ReadonlySet<string>,
|
||||
): Promise<{
|
||||
warm: number;
|
||||
ingest: number;
|
||||
bySource: Record<"sync" | "manual", number>;
|
||||
@@ -141,8 +159,8 @@ async function simulate(level: string | null): Promise<{
|
||||
sim.level = level;
|
||||
// The 04:30 warm regenerates every scope the day will touch.
|
||||
const warmAt = Date.parse("2026-10-15T04:30:00Z");
|
||||
for (const scope of ALL_SCOPES_AT_WARM) sim.notes.set(scope, warmAt);
|
||||
const warm = ALL_SCOPES_AT_WARM.size;
|
||||
for (const scope of warmScopes) sim.notes.set(scope, warmAt);
|
||||
const warm = warmScopes.size;
|
||||
const bySource = { sync: 0, manual: 0 };
|
||||
for (const event of day()) {
|
||||
vi.setSystemTime(new Date(`2026-10-15T${event.at}:00Z`));
|
||||
@@ -161,25 +179,14 @@ beforeEach(() => {
|
||||
|
||||
describe("generations per day on a watch-synced account", () => {
|
||||
it("counts the scopes once to seed the warm", async () => {
|
||||
// Discover every scope the day dirties: run it once on `full` with no
|
||||
// warm, which enqueues each scope at least once.
|
||||
ALL_SCOPES_AT_WARM.clear();
|
||||
sim.notes.clear();
|
||||
sim.level = "FULL";
|
||||
for (const event of day()) {
|
||||
vi.setSystemTime(new Date(`2026-10-15T${event.at}:00Z`));
|
||||
await invalidateStatusInsightsForTypes("u1", event.types, {
|
||||
source: event.source,
|
||||
});
|
||||
}
|
||||
for (const scope of sim.notes.keys()) ALL_SCOPES_AT_WARM.add(scope);
|
||||
expect(ALL_SCOPES_AT_WARM.size).toBe(19);
|
||||
expect((await discoverWarmScopes()).size).toBe(19);
|
||||
});
|
||||
|
||||
it("before v1.43 (every ingest at one hour) versus each level", async () => {
|
||||
const full = await simulate("FULL"); // the behaviour before v1.43
|
||||
const standard = await simulate("STANDARD");
|
||||
const frugal = await simulate("FRUGAL");
|
||||
const warmScopes = await discoverWarmScopes();
|
||||
const full = await simulate("FULL", warmScopes); // the behaviour before v1.43
|
||||
const standard = await simulate("STANDARD", warmScopes);
|
||||
const frugal = await simulate("FRUGAL", warmScopes);
|
||||
const result = { full, standard, frugal };
|
||||
process.stdout.write(`SIMULATION ${JSON.stringify(result)}\n`);
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
resolveReadOnlyStatusMiss,
|
||||
} from "@/lib/insights/status-cache";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
resolveDerivedAssessment,
|
||||
generateDerivedScoreAssessment,
|
||||
@@ -71,6 +72,9 @@ function okDerived(): Derived<unknown> {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// The zone resolver keeps a per-user cache for a minute; without this the
|
||||
// first case to run fills it and every later one skips the read.
|
||||
invalidateUserTimezone("u-ny");
|
||||
findUnique.mockResolvedValue({ timezone: "America/New_York" });
|
||||
});
|
||||
|
||||
|
||||
@@ -248,6 +248,13 @@ describe("handleDataBackup on a large instance", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.store.mockResolvedValue(undefined);
|
||||
// Stated here rather than inherited: without a payload the pass stores
|
||||
// nothing, and these cases used to pass only when another block's setup
|
||||
// had run first and left its payload behind.
|
||||
mocks.buildFullBackupPayload.mockResolvedValue({
|
||||
payload: { schemaVersion: "1", userId: "user-dr" },
|
||||
counts: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("backs up the accounts with the oldest copies first and stops on the budget", async () => {
|
||||
|
||||
@@ -244,11 +244,30 @@ describe("least-squares line", () => {
|
||||
return;
|
||||
}
|
||||
expect(fit.r2).toBeGreaterThanOrEqual(0);
|
||||
expect(fit.r2).toBeLessThanOrEqual(1 + 1e-9);
|
||||
expect(fit.r2).toBeLessThanOrEqual(1);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Shrunk counterexamples of the property above from two nightly seeds
|
||||
// (802398975 and 1070990592): two points on a line far from zero, where
|
||||
// the centred sums rounded r² above 1.
|
||||
it.each([
|
||||
[
|
||||
[0, 1],
|
||||
[-999.87, -999.97],
|
||||
],
|
||||
[
|
||||
[0, -1],
|
||||
[9996.28, 9993.55],
|
||||
],
|
||||
])("keeps r² at most 1 for x %j, y %j", (xs, ys) => {
|
||||
const fit = linearFit(xs, ys)!;
|
||||
expect(fit.r2).not.toBeNull();
|
||||
expect(fit.r2!).toBeLessThanOrEqual(1);
|
||||
expect(fit.r2!).toBeGreaterThan(0.999);
|
||||
});
|
||||
|
||||
it("refuses a vertical line instead of inventing a slope", () => {
|
||||
assertProperty(
|
||||
fc.property(
|
||||
|
||||
@@ -48,7 +48,9 @@ export function fitFromSums(sums: FitSums): LinearFit | null {
|
||||
if (n < 2) return null;
|
||||
const sxx = sumXx - (sumX * sumX) / n;
|
||||
const sxy = sumXy - (sumX * sumY) / n;
|
||||
const syy = sumYy - (sumY * sumY) / n;
|
||||
// A sum of squares is never negative; the subtraction can still land a
|
||||
// hair below zero when every y sits close to one large value.
|
||||
const syy = Math.max(0, sumYy - (sumY * sumY) / n);
|
||||
if (sxx === 0) return null;
|
||||
const slope = sxy / sxx;
|
||||
const meanX = sumX / n;
|
||||
@@ -56,7 +58,10 @@ export function fitFromSums(sums: FitSums): LinearFit | null {
|
||||
return {
|
||||
slope,
|
||||
intercept: meanY - slope * meanX,
|
||||
r2: syy === 0 ? null : (sxy * sxy) / (sxx * syy),
|
||||
// r² is at most 1 by Cauchy–Schwarz. With y far from zero and little
|
||||
// spread (two weighings of about 1000, a tenth apart) the centred sums
|
||||
// lose their last digits and the ratio came out at 1.00000004.
|
||||
r2: syy === 0 ? null : Math.min(1, (sxy * sxy) / (sxx * syy)),
|
||||
n,
|
||||
meanX,
|
||||
meanY,
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* The record-shaped backup slices, one rule at a time: the health score
|
||||
* day, the intraday curve, a medication with its composition and custom
|
||||
* category, an intake row, and the cycle's own symptoms.
|
||||
*
|
||||
* Like `backup-slices.test.ts`, each rule meets the value just inside and
|
||||
* just outside it. The defaults are pinned as well: a file written before a
|
||||
* field existed restores through them, so a default that drifted would
|
||||
* change what an older file means without any parse failing.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { customCycleSymptomSchema } from "@/lib/cycle/backup-schema";
|
||||
import { healthScoreRecordBackupSchema } from "@/lib/export/health-score-backup-schema";
|
||||
import { intradayProfileBackupSchema } from "@/lib/export/intraday-profile-backup-schema";
|
||||
import {
|
||||
customMedicationCategorySchema,
|
||||
intakeEventSchema,
|
||||
medicationIngredientSchema,
|
||||
medicationSchema,
|
||||
medicationSideEffectSchema,
|
||||
} from "@/lib/export/medications-backup-schema";
|
||||
|
||||
/** The paths a value is refused on, or `[]` when it parses. */
|
||||
function refused(schema: z.ZodType, value: unknown): string[] {
|
||||
const result = schema.safeParse(value);
|
||||
return result.success
|
||||
? []
|
||||
: result.error.issues.map((issue) => issue.path.join("."));
|
||||
}
|
||||
|
||||
function parsed<T>(schema: z.ZodType<T>, value: unknown): T {
|
||||
return schema.parse(value);
|
||||
}
|
||||
|
||||
const AT = "2026-07-01T08:00:00.000Z";
|
||||
const BAD_DAY_KEYS = [
|
||||
"x2026-07-01",
|
||||
"2026-07-01x",
|
||||
"226-07-01",
|
||||
"2026-7-01",
|
||||
"2026-07-1",
|
||||
"abcd-07-01",
|
||||
"2026-ab-01",
|
||||
"2026-07-ab",
|
||||
];
|
||||
|
||||
describe("the health score day", () => {
|
||||
const day = {
|
||||
id: "hs-1",
|
||||
dayKey: "2026-07-01",
|
||||
timezone: "Europe/Berlin",
|
||||
composite: 72,
|
||||
band: "green",
|
||||
scoreVersion: 3,
|
||||
composition: ["sleep", "activity"],
|
||||
pillarScores: { sleep: 80 },
|
||||
inputFingerprint: "a".repeat(64),
|
||||
computedAt: AT,
|
||||
};
|
||||
|
||||
it("takes a well-formed day and defaults the config stamp to null", () => {
|
||||
expect(refused(healthScoreRecordBackupSchema, day)).toEqual([]);
|
||||
const out = parsed(healthScoreRecordBackupSchema, day) as {
|
||||
configVersion: unknown;
|
||||
configChangedAt: unknown;
|
||||
};
|
||||
expect(out.configVersion).toBeNull();
|
||||
expect(out.configChangedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("wants an anchored YYYY-MM-DD day key", () => {
|
||||
for (const dayKey of BAD_DAY_KEYS) {
|
||||
expect(
|
||||
refused(healthScoreRecordBackupSchema, { ...day, dayKey }),
|
||||
dayKey,
|
||||
).toEqual(["dayKey"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses an empty id and an empty zone", () => {
|
||||
expect(refused(healthScoreRecordBackupSchema, { ...day, id: "" })).toEqual([
|
||||
"id",
|
||||
]);
|
||||
expect(
|
||||
refused(healthScoreRecordBackupSchema, { ...day, timezone: "" }),
|
||||
).toEqual(["timezone"]);
|
||||
});
|
||||
|
||||
it("holds the composite to 0 through 100", () => {
|
||||
for (const composite of [0, 100]) {
|
||||
expect(
|
||||
refused(healthScoreRecordBackupSchema, { ...day, composite }),
|
||||
).toEqual([]);
|
||||
}
|
||||
for (const composite of [-1, 101]) {
|
||||
expect(
|
||||
refused(healthScoreRecordBackupSchema, { ...day, composite }),
|
||||
).toEqual(["composite"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("takes every band the column allows and nothing else", () => {
|
||||
for (const band of ["green", "yellow", "red"]) {
|
||||
expect(refused(healthScoreRecordBackupSchema, { ...day, band })).toEqual(
|
||||
[],
|
||||
);
|
||||
}
|
||||
expect(
|
||||
refused(healthScoreRecordBackupSchema, { ...day, band: "" }),
|
||||
).toEqual(["band"]);
|
||||
});
|
||||
|
||||
it("wants at least one named pillar in the composition", () => {
|
||||
expect(
|
||||
refused(healthScoreRecordBackupSchema, { ...day, composition: ["a"] }),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
refused(healthScoreRecordBackupSchema, { ...day, composition: [] }),
|
||||
).toEqual(["composition"]);
|
||||
expect(
|
||||
refused(healthScoreRecordBackupSchema, {
|
||||
...day,
|
||||
composition: ["sleep", ""],
|
||||
}),
|
||||
).toEqual(["composition.1"]);
|
||||
});
|
||||
|
||||
it("wants a fingerprint of exactly 64 lowercase hex digits", () => {
|
||||
for (const inputFingerprint of [
|
||||
"a".repeat(63),
|
||||
"a".repeat(65),
|
||||
"g".repeat(64),
|
||||
`x${"a".repeat(64)}`,
|
||||
`${"a".repeat(64)}x`,
|
||||
"a",
|
||||
]) {
|
||||
expect(
|
||||
refused(healthScoreRecordBackupSchema, { ...day, inputFingerprint }),
|
||||
inputFingerprint,
|
||||
).toEqual(["inputFingerprint"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the intraday curve", () => {
|
||||
const curve = {
|
||||
id: "ip-1",
|
||||
type: "ACTIVITY_STEPS",
|
||||
dateKey: "2026-07-01",
|
||||
hourlyCumulative: [0, 120],
|
||||
dayTotal: 120,
|
||||
sampleCount: 2,
|
||||
timezone: "UTC",
|
||||
};
|
||||
|
||||
it("reads a file from before the hours travelled as a fold of samples", () => {
|
||||
const out = parsed(intradayProfileBackupSchema, curve) as {
|
||||
hourlyIncrements: unknown;
|
||||
basis: unknown;
|
||||
bucketCount: unknown;
|
||||
};
|
||||
expect(out.hourlyIncrements).toEqual([]);
|
||||
expect(out.basis).toBe("PER_SAMPLE");
|
||||
expect(out.bucketCount).toBeNull();
|
||||
});
|
||||
|
||||
it("takes both bases and refuses another", () => {
|
||||
for (const basis of ["PER_SAMPLE", "HOUR_BUCKETS"]) {
|
||||
expect(refused(intradayProfileBackupSchema, { ...curve, basis })).toEqual(
|
||||
[],
|
||||
);
|
||||
}
|
||||
expect(
|
||||
refused(intradayProfileBackupSchema, { ...curve, basis: "" }),
|
||||
).toEqual(["basis"]);
|
||||
});
|
||||
|
||||
it("wants an anchored day key, a zone and a non-empty id", () => {
|
||||
for (const dateKey of BAD_DAY_KEYS) {
|
||||
expect(
|
||||
refused(intradayProfileBackupSchema, { ...curve, dateKey }),
|
||||
dateKey,
|
||||
).toEqual(["dateKey"]);
|
||||
}
|
||||
expect(
|
||||
refused(intradayProfileBackupSchema, { ...curve, timezone: "" }),
|
||||
).toEqual(["timezone"]);
|
||||
expect(refused(intradayProfileBackupSchema, { ...curve, id: "" })).toEqual([
|
||||
"id",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a custom medication category", () => {
|
||||
const KEY = "custom:0f8fad5b-d9cb-469f-a165-70867728950e";
|
||||
|
||||
it("takes a readable label and defaults its order and state", () => {
|
||||
const out = parsed(customMedicationCategorySchema, {
|
||||
key: KEY,
|
||||
label: " Supplements ",
|
||||
}) as { label: unknown; sortOrder: unknown; isActive: unknown };
|
||||
expect(out.label).toBe("Supplements");
|
||||
expect(out.sortOrder).toBe(0);
|
||||
expect(out.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it("wants the custom: prefix and a 36-character id, anchored", () => {
|
||||
for (const key of [
|
||||
KEY.slice(0, -1),
|
||||
`${KEY}0`,
|
||||
`x${KEY}`,
|
||||
"custom:",
|
||||
"custom:zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz",
|
||||
]) {
|
||||
expect(
|
||||
refused(customMedicationCategorySchema, { key, label: "A" }),
|
||||
key,
|
||||
).toContain("key");
|
||||
}
|
||||
});
|
||||
|
||||
it("holds the label to 1 through 40 characters after trimming", () => {
|
||||
expect(
|
||||
refused(customMedicationCategorySchema, {
|
||||
key: KEY,
|
||||
label: "x".repeat(40),
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
refused(customMedicationCategorySchema, {
|
||||
key: KEY,
|
||||
label: "x".repeat(41),
|
||||
}),
|
||||
).toEqual(["label"]);
|
||||
// Blank after trimming: the length rule and the label-or-ciphertext rule
|
||||
// both refuse it.
|
||||
expect(
|
||||
refused(customMedicationCategorySchema, { key: KEY, label: " " }),
|
||||
).toEqual(["label", "label"]);
|
||||
});
|
||||
|
||||
it("refuses a negative order", () => {
|
||||
expect(
|
||||
refused(customMedicationCategorySchema, {
|
||||
key: KEY,
|
||||
label: "A",
|
||||
sortOrder: -1,
|
||||
}),
|
||||
).toEqual(["sortOrder"]);
|
||||
});
|
||||
|
||||
it("needs its label or its ciphertext, and names the label when both are missing", () => {
|
||||
expect(
|
||||
refused(customMedicationCategorySchema, {
|
||||
key: KEY,
|
||||
labelEncrypted: "QUJDRA==",
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
refused(customMedicationCategorySchema, {
|
||||
key: KEY,
|
||||
label: null,
|
||||
labelEncrypted: "QUJDRA==",
|
||||
}),
|
||||
).toEqual([]);
|
||||
const result = customMedicationCategorySchema.safeParse({ key: KEY });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
path: ["label"],
|
||||
message: "a custom category needs its label or its ciphertext",
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
refused(customMedicationCategorySchema, { key: KEY, label: null }),
|
||||
).toEqual(["label"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a medication", () => {
|
||||
it("restores a file from before its parts travelled with every part empty", () => {
|
||||
const out = parsed(medicationSchema, { name: "Ramipril", dose: "5 mg" });
|
||||
expect(out).toMatchObject({
|
||||
schedules: [],
|
||||
sideEffects: [],
|
||||
pauseEras: [],
|
||||
ingredients: [],
|
||||
doseChanges: [],
|
||||
inventoryItems: [],
|
||||
inventoryEvents: [],
|
||||
efficacyTargets: [],
|
||||
scheduleRevisions: [],
|
||||
});
|
||||
expect((out as { courses?: unknown }).courses).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses an empty name, id or ref and a category past 64 characters", () => {
|
||||
const base = { name: "Ramipril", dose: "5 mg" };
|
||||
expect(refused(medicationSchema, { ...base, name: "" })).toEqual(["name"]);
|
||||
expect(refused(medicationSchema, { ...base, id: "" })).toEqual(["id"]);
|
||||
expect(refused(medicationSchema, { ...base, ref: "" })).toEqual(["ref"]);
|
||||
expect(
|
||||
refused(medicationSchema, { ...base, category: "x".repeat(64) }),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
refused(medicationSchema, { ...base, category: "x".repeat(65) }),
|
||||
).toEqual(["category"]);
|
||||
});
|
||||
|
||||
it("names the side effect severity outside 1 through 5", () => {
|
||||
const effect = {
|
||||
occurredAt: AT,
|
||||
category: medicationSideEffectSchema.shape.category.options[0],
|
||||
entry: medicationSideEffectSchema.shape.entry.options[0],
|
||||
};
|
||||
for (const severity of [1, 5]) {
|
||||
expect(
|
||||
refused(medicationSideEffectSchema, { ...effect, severity }),
|
||||
).toEqual([]);
|
||||
}
|
||||
for (const severity of [0, 6]) {
|
||||
expect(
|
||||
refused(medicationSideEffectSchema, { ...effect, severity }),
|
||||
).toEqual(["severity"]);
|
||||
}
|
||||
expect(
|
||||
refused(medicationSideEffectSchema, { ...effect, severity: 3, id: "" }),
|
||||
).toEqual(["id"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a composition ingredient", () => {
|
||||
const ingredient = {
|
||||
nutrient: "vitamin_d",
|
||||
amountPerUnit: 20,
|
||||
unit: "µg",
|
||||
};
|
||||
|
||||
it("defaults its source to a hand entry and takes the label reader", () => {
|
||||
expect(
|
||||
(parsed(medicationIngredientSchema, ingredient) as { source: unknown })
|
||||
.source,
|
||||
).toBe("MANUAL");
|
||||
expect(
|
||||
refused(medicationIngredientSchema, {
|
||||
...ingredient,
|
||||
source: "LABEL_AI",
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
refused(medicationIngredientSchema, { ...ingredient, source: "" }),
|
||||
).toEqual(["source"]);
|
||||
});
|
||||
|
||||
it("holds the nutrient to 1 through 40 and the units to 8 characters", () => {
|
||||
const at = (patch: Record<string, unknown>) =>
|
||||
refused(medicationIngredientSchema, { ...ingredient, ...patch });
|
||||
expect(at({ nutrient: "x".repeat(40) })).toEqual([]);
|
||||
expect(at({ nutrient: "x".repeat(41) })).toEqual(["nutrient"]);
|
||||
expect(at({ nutrient: "" })).toEqual(["nutrient"]);
|
||||
expect(at({ unit: "x".repeat(8) })).toEqual([]);
|
||||
expect(at({ unit: "x".repeat(9) })).toEqual(["unit"]);
|
||||
expect(at({ unit: "" })).toEqual(["unit"]);
|
||||
expect(at({ enteredUnit: "x".repeat(8) })).toEqual([]);
|
||||
expect(at({ enteredUnit: "x".repeat(9) })).toEqual(["enteredUnit"]);
|
||||
expect(at({ id: "" })).toEqual(["id"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an intake row", () => {
|
||||
it("names its medication, and refuses an empty one or empty ids", () => {
|
||||
const row = { medication: "Ramipril", scheduledFor: AT };
|
||||
expect(refused(intakeEventSchema, row)).toEqual([]);
|
||||
expect(refused(intakeEventSchema, { ...row, medication: "" })).toEqual([
|
||||
"medication",
|
||||
]);
|
||||
expect(refused(intakeEventSchema, { ...row, id: "" })).toEqual(["id"]);
|
||||
expect(refused(intakeEventSchema, { ...row, medicationId: "" })).toEqual([
|
||||
"medicationId",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a custom cycle symptom", () => {
|
||||
const symptom = {
|
||||
key: "custom-cramps",
|
||||
labelKey: "cycle.symptom.custom",
|
||||
categoryId: "cat-1",
|
||||
};
|
||||
|
||||
it("defaults to active at the top of its list", () => {
|
||||
const out = parsed(customCycleSymptomSchema, symptom) as {
|
||||
isActive: unknown;
|
||||
sortOrder: unknown;
|
||||
};
|
||||
expect(out.isActive).toBe(true);
|
||||
expect(out.sortOrder).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses an empty key, label key, category or id", () => {
|
||||
for (const field of ["key", "labelKey", "categoryId", "id"]) {
|
||||
expect(
|
||||
refused(customCycleSymptomSchema, { ...symptom, [field]: "" }),
|
||||
field,
|
||||
).toEqual([field]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -211,8 +211,10 @@ describe("syncUserMeasurements — plausibility gate", () => {
|
||||
it("does not overwrite a stored reading with an out-of-band one", async () => {
|
||||
// The slot already holds a real pulse; the provider re-sends the same
|
||||
// instant carrying a decode slip. Ungated, the update branch rewrote the
|
||||
// good value with the bad one.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValueOnce([
|
||||
// good value with the bad one. Not `mockResolvedValueOnce`: the gate
|
||||
// drops the reading before the stored rows are read, so a once-value
|
||||
// would sit unconsumed and answer the next test's read instead.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "existing-row",
|
||||
type: "PULSE",
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@
|
||||
"src/lib/export/backup-schema-kit.ts",
|
||||
"src/lib/cycle/backup-schema.ts"
|
||||
],
|
||||
"floor": 41
|
||||
"floor": 53
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,7 +253,20 @@ async function readAll(
|
||||
return out;
|
||||
}
|
||||
|
||||
let baseline: Record<string, { status: number; body: unknown }> | null = null;
|
||||
type Reads = Record<string, { status: number; body: unknown }>;
|
||||
let baseline: Reads | null = null;
|
||||
|
||||
/**
|
||||
* The all-on reads, taken once in a world of their own and then cleared, so
|
||||
* a state case compares against them whichever case runs first.
|
||||
*/
|
||||
async function readBaseline(): Promise<Reads> {
|
||||
if (baseline === null) {
|
||||
baseline = await readAll(await enterState("S0", seed), "S0");
|
||||
await resetWorld();
|
||||
}
|
||||
return baseline;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetWorld();
|
||||
@@ -261,8 +274,7 @@ beforeEach(async () => {
|
||||
|
||||
describe("data routes under every AI state", () => {
|
||||
it("S0 (all on) is the baseline, and every read answers", async () => {
|
||||
const world = await enterState("S0", seed);
|
||||
baseline = await readAll(world, "S0");
|
||||
const baseline = await readBaseline();
|
||||
for (const [name, { status }] of Object.entries(baseline)) {
|
||||
expect(status, name).toBe(200);
|
||||
}
|
||||
@@ -284,12 +296,12 @@ describe("data routes under every AI state", () => {
|
||||
it.each(["S1", "S2", "S3", "S4", "S5", "S6", "S8"] as const)(
|
||||
"%s answers every read with the baseline's data",
|
||||
async (state) => {
|
||||
expect(baseline, "the S0 baseline runs first").not.toBeNull();
|
||||
const base = await readBaseline();
|
||||
const world = await enterState(state, seed);
|
||||
const reads = await readAll(world, state);
|
||||
for (const [name, { status, body }] of Object.entries(reads)) {
|
||||
expect(status, `${state} ${name}`).toBe(200);
|
||||
expect(body, `${state} ${name}`).toEqual(baseline![name].body);
|
||||
expect(body, `${state} ${name}`).toEqual(base[name].body);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* Means are compared to nine significant digits: the tier sums the same day
|
||||
* values in a different order. Extremes are compared exactly.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { Prisma, type MeasurementType } from "@/generated/prisma/client";
|
||||
@@ -246,6 +246,12 @@ beforeAll(async () => {
|
||||
}, 300_000);
|
||||
|
||||
describe("readAllTimeExtremes over the DAY tier", () => {
|
||||
// A clean tier over whatever rows are left for every case: some cases
|
||||
// break a tier row on purpose, and the next case must not read it.
|
||||
beforeEach(async () => {
|
||||
await recomputeUserRollups(USER, { granularities: ["DAY"] });
|
||||
}, 120_000);
|
||||
|
||||
it("reads the tier for every type", async () => {
|
||||
const covered = await prisma.measurementRollup.groupBy({
|
||||
by: ["type"],
|
||||
|
||||
@@ -1109,7 +1109,10 @@ async function recreateOwner(prisma: PrismaClient) {
|
||||
|
||||
// ── The cases ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("backup parity", () => {
|
||||
// One account through the numbered steps above, in order: each restore and
|
||||
// export reads what the step before it left. The order holds under a
|
||||
// shuffled run.
|
||||
describe("backup parity", { shuffle: false }, () => {
|
||||
const prisma = getPrismaClient();
|
||||
let adminId = "";
|
||||
let drJson = "";
|
||||
|
||||
@@ -147,7 +147,10 @@ afterAll(() => {
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("the nightly database dump", () => {
|
||||
// One backup directory through the cases in order: the first dump is the one
|
||||
// the retention, failure and restore cases count and restore. The order holds
|
||||
// under a shuffled run.
|
||||
describe("the nightly database dump", { shuffle: false }, () => {
|
||||
let dumpFile = "";
|
||||
|
||||
it("writes a checked dump and manifest, records the run and copies both off-host", async () => {
|
||||
|
||||
@@ -71,7 +71,10 @@ beforeAll(async () => {
|
||||
await prisma.$executeRawUnsafe("ANALYZE audit_logs");
|
||||
}, 120_000);
|
||||
|
||||
describe("runGeoBackfill", () => {
|
||||
// Three passes in order: the second must find what the first marked, the
|
||||
// third runs past the wait the first one set. The order holds under a
|
||||
// shuffled run.
|
||||
describe("runGeoBackfill", { shuffle: false }, () => {
|
||||
it("reads its candidates through the partial index", async () => {
|
||||
const before = await indexScans();
|
||||
const summary = await runGeoBackfill(prisma, NOW);
|
||||
|
||||
@@ -58,7 +58,7 @@ import v8 from "node:v8";
|
||||
import vm from "node:vm";
|
||||
|
||||
import type { Job } from "pg-boss";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { WideEvent } from "@/lib/logging/types";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
@@ -320,10 +320,6 @@ async function seed(points: number): Promise<void> {
|
||||
fake.upperBoundRequests = 0;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await seed(POINTS);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
@@ -353,6 +349,9 @@ function nextSlice(): AdmissionPayload | null {
|
||||
|
||||
describe(`Google Health backfill over ${DAYS} days of heart rate (#1220)`, () => {
|
||||
it("completes slice by slice, imports every reading once, and keeps the hourly sync running", async () => {
|
||||
// Its own history, as the case below seeds its own: neither reads what
|
||||
// the other left behind, whichever runs first.
|
||||
await seed(POINTS);
|
||||
const { drainIntegrationBackfillAdmission } =
|
||||
await import("@/lib/jobs/integration-backfill-drain");
|
||||
const { handleGoogleHealthSync } =
|
||||
|
||||
@@ -106,84 +106,93 @@ afterAll(async () => {
|
||||
if (column.udt_name === "measurement_type") await runMigration();
|
||||
});
|
||||
|
||||
describe("migration 0401 over enum-typed ledger rows", () => {
|
||||
it("starts from the enum column", async () => {
|
||||
expect((await typeColumn()).udt_name).toBe("measurement_type");
|
||||
});
|
||||
// Before the migration, the migration, after it: the cases are its steps,
|
||||
// in order, under a shuffled run too.
|
||||
describe(
|
||||
"migration 0401 over enum-typed ledger rows",
|
||||
{ shuffle: false },
|
||||
() => {
|
||||
it("starts from the enum column", async () => {
|
||||
expect((await typeColumn()).udt_name).toBe("measurement_type");
|
||||
});
|
||||
|
||||
it("keeps every row, its key and its dates, and seeds the background date", async () => {
|
||||
await runMigration();
|
||||
it("keeps every row, its key and its dates, and seeds the background date", async () => {
|
||||
await runMigration();
|
||||
|
||||
expect(await typeColumn()).toEqual({ data_type: "text", udt_name: "text" });
|
||||
const rows = await readRows();
|
||||
expect(rows.map((r) => r.type)).toEqual([
|
||||
"PULSE",
|
||||
"RESPIRATORY_RATE",
|
||||
"WEIGHT",
|
||||
]);
|
||||
const byType = Object.fromEntries(rows.map((r) => [r.type, r]));
|
||||
for (const [type, at] of Object.entries(RECEIVED)) {
|
||||
expect(byType[type].last_received_at.toISOString()).toBe(
|
||||
`${at.replace(" ", "T")}.000Z`,
|
||||
expect(await typeColumn()).toEqual({
|
||||
data_type: "text",
|
||||
udt_name: "text",
|
||||
});
|
||||
const rows = await readRows();
|
||||
expect(rows.map((r) => r.type)).toEqual([
|
||||
"PULSE",
|
||||
"RESPIRATORY_RATE",
|
||||
"WEIGHT",
|
||||
]);
|
||||
const byType = Object.fromEntries(rows.map((r) => [r.type, r]));
|
||||
for (const [type, at] of Object.entries(RECEIVED)) {
|
||||
expect(byType[type].last_received_at.toISOString()).toBe(
|
||||
`${at.replace(" ", "T")}.000Z`,
|
||||
);
|
||||
}
|
||||
expect(byType.WEIGHT.last_trigger).toBe("background");
|
||||
expect(byType.WEIGHT.last_new_sample_at?.toISOString()).toBe(
|
||||
"2026-09-01T06:00:00.000Z",
|
||||
);
|
||||
}
|
||||
expect(byType.WEIGHT.last_trigger).toBe("background");
|
||||
expect(byType.WEIGHT.last_new_sample_at?.toISOString()).toBe(
|
||||
"2026-09-01T06:00:00.000Z",
|
||||
);
|
||||
expect(byType.PULSE.last_new_sample_at).toBeNull();
|
||||
// Only a row whose latest arrival was a background one knows a date.
|
||||
expect(byType.WEIGHT.last_background_at?.toISOString()).toBe(
|
||||
"2026-09-01T06:00:00.000Z",
|
||||
);
|
||||
expect(byType.PULSE.last_background_at).toBeNull();
|
||||
expect(byType.RESPIRATORY_RATE.last_background_at).toBeNull();
|
||||
});
|
||||
expect(byType.PULSE.last_new_sample_at).toBeNull();
|
||||
// Only a row whose latest arrival was a background one knows a date.
|
||||
expect(byType.WEIGHT.last_background_at?.toISOString()).toBe(
|
||||
"2026-09-01T06:00:00.000Z",
|
||||
);
|
||||
expect(byType.PULSE.last_background_at).toBeNull();
|
||||
expect(byType.RESPIRATORY_RATE.last_background_at).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the primary key on (user, type)", async () => {
|
||||
const pkey = await prisma.$queryRaw<Array<{ indexdef: string }>>`
|
||||
it("keeps the primary key on (user, type)", async () => {
|
||||
const pkey = await prisma.$queryRaw<Array<{ indexdef: string }>>`
|
||||
SELECT indexdef FROM pg_indexes
|
||||
WHERE tablename = 'healthkit_type_syncs'
|
||||
AND indexname = 'healthkit_type_syncs_pkey'
|
||||
`;
|
||||
expect(pkey[0]?.indexdef).toContain("(user_id, type)");
|
||||
await expect(
|
||||
prisma.$executeRawUnsafe(
|
||||
`INSERT INTO "healthkit_type_syncs" ("user_id", "type", "last_received_at") VALUES ('${USER}', 'WEIGHT', now())`,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
expect(pkey[0]?.indexdef).toContain("(user_id, type)");
|
||||
await expect(
|
||||
prisma.$executeRawUnsafe(
|
||||
`INSERT INTO "healthkit_type_syncs" ("user_id", "type", "last_received_at") VALUES ('${USER}', 'WEIGHT', now())`,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("lets the writer upsert a measurement type and record a dose", async () => {
|
||||
const at = new Date("2026-10-01T09:00:00.000Z");
|
||||
await recordHealthKitTypeArrivals(prisma, {
|
||||
userId: USER,
|
||||
counts: new Map([
|
||||
["WEIGHT", { accepted: 1, newSamples: 1 }],
|
||||
["MEDICATION_DOSE", { accepted: 2, newSamples: 2 }],
|
||||
]),
|
||||
trigger: "foreground",
|
||||
at,
|
||||
it("lets the writer upsert a measurement type and record a dose", async () => {
|
||||
const at = new Date("2026-10-01T09:00:00.000Z");
|
||||
await recordHealthKitTypeArrivals(prisma, {
|
||||
userId: USER,
|
||||
counts: new Map([
|
||||
["WEIGHT", { accepted: 1, newSamples: 1 }],
|
||||
["MEDICATION_DOSE", { accepted: 2, newSamples: 2 }],
|
||||
]),
|
||||
trigger: "foreground",
|
||||
at,
|
||||
});
|
||||
const rows = await prisma.healthKitTypeSync.findMany({
|
||||
where: { userId: USER },
|
||||
orderBy: { type: "asc" },
|
||||
});
|
||||
expect(rows.map((r) => r.type)).toEqual([
|
||||
"MEDICATION_DOSE",
|
||||
"PULSE",
|
||||
"RESPIRATORY_RATE",
|
||||
"WEIGHT",
|
||||
]);
|
||||
const weight = rows.find((r) => r.type === "WEIGHT")!;
|
||||
expect(weight.lastReceivedAt.toISOString()).toBe(at.toISOString());
|
||||
expect(weight.lastTrigger).toBe("foreground");
|
||||
// The foreground arrival leaves the seeded background date standing.
|
||||
expect(weight.lastBackgroundAt?.toISOString()).toBe(
|
||||
"2026-09-01T06:00:00.000Z",
|
||||
);
|
||||
const dose = rows.find((r) => r.type === "MEDICATION_DOSE")!;
|
||||
expect(dose.lastNewSampleAt?.toISOString()).toBe(at.toISOString());
|
||||
expect(dose.lastBackgroundAt).toBeNull();
|
||||
});
|
||||
const rows = await prisma.healthKitTypeSync.findMany({
|
||||
where: { userId: USER },
|
||||
orderBy: { type: "asc" },
|
||||
});
|
||||
expect(rows.map((r) => r.type)).toEqual([
|
||||
"MEDICATION_DOSE",
|
||||
"PULSE",
|
||||
"RESPIRATORY_RATE",
|
||||
"WEIGHT",
|
||||
]);
|
||||
const weight = rows.find((r) => r.type === "WEIGHT")!;
|
||||
expect(weight.lastReceivedAt.toISOString()).toBe(at.toISOString());
|
||||
expect(weight.lastTrigger).toBe("foreground");
|
||||
// The foreground arrival leaves the seeded background date standing.
|
||||
expect(weight.lastBackgroundAt?.toISOString()).toBe(
|
||||
"2026-09-01T06:00:00.000Z",
|
||||
);
|
||||
const dose = rows.find((r) => r.type === "MEDICATION_DOSE")!;
|
||||
expect(dose.lastNewSampleAt?.toISOString()).toBe(at.toISOString());
|
||||
expect(dose.lastBackgroundAt).toBeNull();
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -177,7 +177,9 @@ beforeAll(async () => {
|
||||
await runMigration();
|
||||
}, 120_000);
|
||||
|
||||
describe("migration 0420", () => {
|
||||
// The migration's effects, then the refold and the rerun that change them:
|
||||
// the cases read in order, under a shuffled run too.
|
||||
describe("migration 0420", { shuffle: false }, () => {
|
||||
it("moves every Fitbit and Google Health SDNN row to HRV_RMSSD, tombstones included", async () => {
|
||||
const moved = await prisma.measurement.findMany({
|
||||
where: { userId: USER, source: { in: ["FITBIT", "GOOGLE_HEALTH"] } },
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* are failed through its own API.
|
||||
*/
|
||||
import { PgBoss } from "pg-boss";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
readActiveJobsStartedBefore,
|
||||
@@ -47,7 +47,7 @@ async function failOneJob(
|
||||
return jobId;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
async function startBoss(): Promise<void> {
|
||||
boss = new PgBoss({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
// No maintenance or monitoring timers — the test drives every transition.
|
||||
@@ -57,7 +57,15 @@ beforeAll(async () => {
|
||||
await boss.start();
|
||||
await boss.createQueue(QUEUE);
|
||||
await boss.createQueue(OTHER_QUEUE);
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
beforeAll(startBoss, 60_000);
|
||||
|
||||
// Every case reads the queue it filled itself: no job a case before it sent,
|
||||
// failed or left active is still there to be counted or fetched.
|
||||
beforeEach(async () => {
|
||||
await getPrismaClient().$executeRawUnsafe(`DELETE FROM pgboss.job`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await boss?.stop({ graceful: false, close: true });
|
||||
@@ -244,6 +252,8 @@ describe("no queue schema at all", () => {
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER SCHEMA pgboss_gone RENAME TO pgboss`,
|
||||
);
|
||||
// Running again for whichever case comes next.
|
||||
await startBoss();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -138,11 +138,14 @@ function byInstant(at: string) {
|
||||
String(a.type ?? "").localeCompare(String(b.type ?? ""));
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
/** This server: the seeded catalogue and the owner whose files are read. */
|
||||
async function seedServer(): Promise<void> {
|
||||
await truncateAllTables(prisma);
|
||||
await seedJsonImportCatalogue(prisma);
|
||||
({ ownerId } = await seedJsonImportOwner(prisma));
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(seedServer);
|
||||
|
||||
afterAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
@@ -350,6 +353,10 @@ describe("the JSON import takes the app's own files back", () => {
|
||||
});
|
||||
|
||||
describe("a full backup moved to another server", () => {
|
||||
// The case empties the database to stand in for another server. Put this
|
||||
// server back for the cases that run after it.
|
||||
afterAll(seedServer);
|
||||
|
||||
it("comes back equal, the account's own tags, groups and rated factor included", async () => {
|
||||
const original = await fullBackupOf(ownerId);
|
||||
expect(original.customMoodTags.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
@@ -77,11 +77,16 @@ describe("lab CSV import of a value without a unit", () => {
|
||||
});
|
||||
|
||||
it("refuses a real unit under the marker without one", async () => {
|
||||
// A marker of its own, kept in "1", rather than the one the case above
|
||||
// mints: the cases run in either order.
|
||||
await prisma.biomarker.create({
|
||||
data: { userId: USER, name: "LDL/HDL-Quotient", unit: "1" },
|
||||
});
|
||||
const result = await run(
|
||||
[
|
||||
"Analyt;Wert;Einheit;Datum",
|
||||
"Cholesterin/HDL-Quotient;3,4;mg/dL;02.03.2025",
|
||||
"Cholesterin/HDL-Quotient;3,2;1;03.03.2025",
|
||||
"LDL/HDL-Quotient;3,4;mg/dL;02.03.2025",
|
||||
"LDL/HDL-Quotient;3,2;1;03.03.2025",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(result.errors).toEqual([
|
||||
|
||||
@@ -126,244 +126,251 @@ type Dto = {
|
||||
rangeStatus: string;
|
||||
};
|
||||
|
||||
describe("POST /api/labs", () => {
|
||||
it("converts a reading in another unit and keeps what the report printed", async () => {
|
||||
const { status, json } = await call<Dto>("labs", "POST", {
|
||||
biomarkerId: "bm-glucose",
|
||||
value: 5.6,
|
||||
unit: "mmol/L",
|
||||
sourceReferenceLow: 3.9,
|
||||
sourceReferenceHigh: 6.0,
|
||||
takenAt: "2026-05-01T08:00:00.000Z",
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
const dto = json.data!;
|
||||
expect(dto.converted).toBe(true);
|
||||
expect(dto.unit).toBe("mg/dL");
|
||||
expect(dto.value).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9);
|
||||
expect(dto.sourceValue).toBe(5.6);
|
||||
expect(dto.sourceUnit).toBe("mmol/L");
|
||||
// The report's own window moved with the value.
|
||||
expect(dto.sourceReferenceLow).toBeCloseTo(3.9 * MGDL_PER_MMOL, 9);
|
||||
expect(dto.sourceReferenceHigh).toBeCloseTo(6.0 * MGDL_PER_MMOL, 9);
|
||||
// And the verdict is judged against it: 5.6 of 3.9-6.0 is in range.
|
||||
expect(dto.rangeStatus).toBe("in-range");
|
||||
// One record through the cases in order: the OCR commit is committed again
|
||||
// as a duplicate, and the edits and the list read the readings the earlier
|
||||
// cases wrote. The order holds under a shuffled run.
|
||||
describe("lab unit conversion, one record", { shuffle: false }, () => {
|
||||
describe("POST /api/labs", () => {
|
||||
it("converts a reading in another unit and keeps what the report printed", async () => {
|
||||
const { status, json } = await call<Dto>("labs", "POST", {
|
||||
biomarkerId: "bm-glucose",
|
||||
value: 5.6,
|
||||
unit: "mmol/L",
|
||||
sourceReferenceLow: 3.9,
|
||||
sourceReferenceHigh: 6.0,
|
||||
takenAt: "2026-05-01T08:00:00.000Z",
|
||||
});
|
||||
expect(status).toBe(201);
|
||||
const dto = json.data!;
|
||||
expect(dto.converted).toBe(true);
|
||||
expect(dto.unit).toBe("mg/dL");
|
||||
expect(dto.value).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9);
|
||||
expect(dto.sourceValue).toBe(5.6);
|
||||
expect(dto.sourceUnit).toBe("mmol/L");
|
||||
// The report's own window moved with the value.
|
||||
expect(dto.sourceReferenceLow).toBeCloseTo(3.9 * MGDL_PER_MMOL, 9);
|
||||
expect(dto.sourceReferenceHigh).toBeCloseTo(6.0 * MGDL_PER_MMOL, 9);
|
||||
// And the verdict is judged against it: 5.6 of 3.9-6.0 is in range.
|
||||
expect(dto.rangeStatus).toBe("in-range");
|
||||
|
||||
const row = await prisma.labResult.findUniqueOrThrow({
|
||||
where: { id: dto.id },
|
||||
const row = await prisma.labResult.findUniqueOrThrow({
|
||||
where: { id: dto.id },
|
||||
});
|
||||
expect(row.value).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9);
|
||||
expect(row.sourceValue).toBe(5.6);
|
||||
expect(row.sourceUnit).toBe("mmol/L");
|
||||
});
|
||||
|
||||
it("flags a converted reading outside the printed window as such", async () => {
|
||||
const { json } = await call<Dto>("labs", "POST", {
|
||||
biomarkerId: "bm-glucose",
|
||||
value: 7.2,
|
||||
unit: "mmol/L",
|
||||
sourceReferenceLow: 3.9,
|
||||
sourceReferenceHigh: 6.0,
|
||||
takenAt: "2026-05-02T08:00:00.000Z",
|
||||
});
|
||||
expect(json.data!.rangeStatus).toBe("above");
|
||||
});
|
||||
|
||||
it("stores a reading in the marker's unit as stated, with no conversion record", async () => {
|
||||
const { json } = await call<Dto>("labs", "POST", {
|
||||
biomarkerId: "bm-glucose",
|
||||
value: 95,
|
||||
unit: "mg/dL",
|
||||
takenAt: "2026-05-03T08:00:00.000Z",
|
||||
});
|
||||
const dto = json.data!;
|
||||
expect(dto.converted).toBe(false);
|
||||
expect(dto.value).toBe(95);
|
||||
expect(dto.sourceValue).toBeNull();
|
||||
expect(dto.sourceUnit).toBeNull();
|
||||
});
|
||||
|
||||
it("links a marker that had no key yet, the first time it is needed", async () => {
|
||||
const before = await prisma.biomarker.findUniqueOrThrow({
|
||||
where: { id: "bm-glucose" },
|
||||
});
|
||||
// The marker was created without a key in this test; the first conversion
|
||||
// above has already filled it.
|
||||
expect(before.analyteKey).toBe("fasting-glucose");
|
||||
});
|
||||
|
||||
it("still refuses a pair that cannot be converted, now with the reason", async () => {
|
||||
const { status, json } = await call("labs", "POST", {
|
||||
biomarkerId: "bm-free",
|
||||
value: 5.6,
|
||||
unit: "mmol/L",
|
||||
takenAt: "2026-05-04T08:00:00.000Z",
|
||||
});
|
||||
expect(status).toBe(422);
|
||||
expect(json.meta).toMatchObject({
|
||||
errorCode: "labs.unit.mismatch",
|
||||
markerUnit: "mg/dL",
|
||||
readingUnit: "mmol/L",
|
||||
reason: "unknown_analyte",
|
||||
});
|
||||
expect(
|
||||
await prisma.labResult.count({ where: { biomarkerId: "bm-free" } }),
|
||||
).toBe(0);
|
||||
});
|
||||
expect(row.value).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9);
|
||||
expect(row.sourceValue).toBe(5.6);
|
||||
expect(row.sourceUnit).toBe("mmol/L");
|
||||
});
|
||||
|
||||
it("flags a converted reading outside the printed window as such", async () => {
|
||||
const { json } = await call<Dto>("labs", "POST", {
|
||||
biomarkerId: "bm-glucose",
|
||||
value: 7.2,
|
||||
unit: "mmol/L",
|
||||
sourceReferenceLow: 3.9,
|
||||
sourceReferenceHigh: 6.0,
|
||||
takenAt: "2026-05-02T08:00:00.000Z",
|
||||
});
|
||||
expect(json.data!.rangeStatus).toBe("above");
|
||||
});
|
||||
|
||||
it("stores a reading in the marker's unit as stated, with no conversion record", async () => {
|
||||
const { json } = await call<Dto>("labs", "POST", {
|
||||
biomarkerId: "bm-glucose",
|
||||
value: 95,
|
||||
unit: "mg/dL",
|
||||
takenAt: "2026-05-03T08:00:00.000Z",
|
||||
});
|
||||
const dto = json.data!;
|
||||
expect(dto.converted).toBe(false);
|
||||
expect(dto.value).toBe(95);
|
||||
expect(dto.sourceValue).toBeNull();
|
||||
expect(dto.sourceUnit).toBeNull();
|
||||
});
|
||||
|
||||
it("links a marker that had no key yet, the first time it is needed", async () => {
|
||||
const before = await prisma.biomarker.findUniqueOrThrow({
|
||||
where: { id: "bm-glucose" },
|
||||
});
|
||||
// The marker was created without a key in this test; the first conversion
|
||||
// above has already filled it.
|
||||
expect(before.analyteKey).toBe("fasting-glucose");
|
||||
});
|
||||
|
||||
it("still refuses a pair that cannot be converted, now with the reason", async () => {
|
||||
const { status, json } = await call("labs", "POST", {
|
||||
biomarkerId: "bm-free",
|
||||
value: 5.6,
|
||||
unit: "mmol/L",
|
||||
takenAt: "2026-05-04T08:00:00.000Z",
|
||||
});
|
||||
expect(status).toBe(422);
|
||||
expect(json.meta).toMatchObject({
|
||||
errorCode: "labs.unit.mismatch",
|
||||
markerUnit: "mg/dL",
|
||||
readingUnit: "mmol/L",
|
||||
reason: "unknown_analyte",
|
||||
});
|
||||
expect(
|
||||
await prisma.labResult.count({ where: { biomarkerId: "bm-free" } }),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the OCR commit", () => {
|
||||
const row = {
|
||||
analyte: "Glucose",
|
||||
value: 5.0,
|
||||
unit: "mmol/L",
|
||||
referenceText: "3.9 - 5.6",
|
||||
takenAt: "2026-06-01T08:00:00.000Z",
|
||||
};
|
||||
|
||||
it("converts the row and reports it", async () => {
|
||||
const { status, json } = await call<{
|
||||
inserted: Dto[];
|
||||
skipped: unknown[];
|
||||
}>("ocr", "POST", { rows: [row] });
|
||||
expect(status).toBe(200);
|
||||
expect(json.data!.skipped).toEqual([]);
|
||||
const [inserted] = json.data!.inserted;
|
||||
expect(inserted.converted).toBe(true);
|
||||
expect(inserted.unit).toBe("mg/dL");
|
||||
expect(inserted.value).toBeCloseTo(5.0 * MGDL_PER_MMOL, 9);
|
||||
expect(inserted.sourceValue).toBe(5.0);
|
||||
expect(inserted.sourceUnit).toBe("mmol/L");
|
||||
expect(inserted.sourceReferenceLow).toBeCloseTo(3.9 * MGDL_PER_MMOL, 9);
|
||||
expect(inserted.sourceReferenceHigh).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9);
|
||||
});
|
||||
|
||||
it("treats the same scan committed again as a duplicate", async () => {
|
||||
const { json } = await call<{
|
||||
inserted: unknown[];
|
||||
skipped: Array<{ reason: string }>;
|
||||
}>("ocr", "POST", { rows: [row] });
|
||||
expect(json.data!.inserted).toEqual([]);
|
||||
expect(json.data!.skipped.map((s) => s.reason)).toEqual(["duplicate"]);
|
||||
});
|
||||
|
||||
it("tells the scan review which analyte the marker is, and flags a re-scan as a duplicate", async () => {
|
||||
const { annotateRow } = await import("@/lib/labs/ocr-extract");
|
||||
const scanned = {
|
||||
describe("the OCR commit", () => {
|
||||
const row = {
|
||||
analyte: "Glucose",
|
||||
value: 5.0,
|
||||
valueText: null,
|
||||
unit: "mmol/L",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
referenceText: null,
|
||||
takenAt: "2026-06-01",
|
||||
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
|
||||
referenceText: "3.9 - 5.6",
|
||||
takenAt: "2026-06-01T08:00:00.000Z",
|
||||
};
|
||||
const annotated = await annotateRow(USER, scanned, "2026-06-01", "UTC");
|
||||
expect(annotated).toMatchObject({
|
||||
biomarkerMatch: "existing",
|
||||
markerUnit: "mg/dL",
|
||||
markerAnalyteKey: "fasting-glucose",
|
||||
|
||||
it("converts the row and reports it", async () => {
|
||||
const { status, json } = await call<{
|
||||
inserted: Dto[];
|
||||
skipped: unknown[];
|
||||
}>("ocr", "POST", { rows: [row] });
|
||||
expect(status).toBe(200);
|
||||
expect(json.data!.skipped).toEqual([]);
|
||||
const [inserted] = json.data!.inserted;
|
||||
expect(inserted.converted).toBe(true);
|
||||
expect(inserted.unit).toBe("mg/dL");
|
||||
expect(inserted.value).toBeCloseTo(5.0 * MGDL_PER_MMOL, 9);
|
||||
expect(inserted.sourceValue).toBe(5.0);
|
||||
expect(inserted.sourceUnit).toBe("mmol/L");
|
||||
expect(inserted.sourceReferenceLow).toBeCloseTo(3.9 * MGDL_PER_MMOL, 9);
|
||||
expect(inserted.sourceReferenceHigh).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9);
|
||||
});
|
||||
// The converted reading from the commit above holds 90.09 mg/dL; the scan
|
||||
// says 5.0 mmol/L, which is what the report printed for it.
|
||||
expect(annotated.duplicateOf).not.toBeNull();
|
||||
|
||||
// A different value is not a duplicate.
|
||||
const other = await annotateRow(
|
||||
USER,
|
||||
{ ...scanned, value: 6.5 },
|
||||
"2026-06-01",
|
||||
"UTC",
|
||||
);
|
||||
expect(other.duplicateOf).toBeNull();
|
||||
it("treats the same scan committed again as a duplicate", async () => {
|
||||
const { json } = await call<{
|
||||
inserted: unknown[];
|
||||
skipped: Array<{ reason: string }>;
|
||||
}>("ocr", "POST", { rows: [row] });
|
||||
expect(json.data!.inserted).toEqual([]);
|
||||
expect(json.data!.skipped.map((s) => s.reason)).toEqual(["duplicate"]);
|
||||
});
|
||||
|
||||
// A marker the registry knows nothing about reports no analyte.
|
||||
const free = await annotateRow(
|
||||
USER,
|
||||
{ ...scanned, analyte: "My own marker", unit: "mg/dL" },
|
||||
"2026-06-01",
|
||||
"UTC",
|
||||
);
|
||||
expect(free.markerAnalyteKey).toBeNull();
|
||||
it("tells the scan review which analyte the marker is, and flags a re-scan as a duplicate", async () => {
|
||||
const { annotateRow } = await import("@/lib/labs/ocr-extract");
|
||||
const scanned = {
|
||||
analyte: "Glucose",
|
||||
value: 5.0,
|
||||
valueText: null,
|
||||
unit: "mmol/L",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
referenceText: null,
|
||||
takenAt: "2026-06-01",
|
||||
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
|
||||
};
|
||||
const annotated = await annotateRow(USER, scanned, "2026-06-01", "UTC");
|
||||
expect(annotated).toMatchObject({
|
||||
biomarkerMatch: "existing",
|
||||
markerUnit: "mg/dL",
|
||||
markerAnalyteKey: "fasting-glucose",
|
||||
});
|
||||
// The converted reading from the commit above holds 90.09 mg/dL; the scan
|
||||
// says 5.0 mmol/L, which is what the report printed for it.
|
||||
expect(annotated.duplicateOf).not.toBeNull();
|
||||
|
||||
// A different value is not a duplicate.
|
||||
const other = await annotateRow(
|
||||
USER,
|
||||
{ ...scanned, value: 6.5 },
|
||||
"2026-06-01",
|
||||
"UTC",
|
||||
);
|
||||
expect(other.duplicateOf).toBeNull();
|
||||
|
||||
// A marker the registry knows nothing about reports no analyte.
|
||||
const free = await annotateRow(
|
||||
USER,
|
||||
{ ...scanned, analyte: "My own marker", unit: "mg/dL" },
|
||||
"2026-06-01",
|
||||
"UTC",
|
||||
);
|
||||
expect(free.markerAnalyteKey).toBeNull();
|
||||
});
|
||||
|
||||
it("skips a row whose unit cannot be converted, as before", async () => {
|
||||
const { json } = await call<{
|
||||
inserted: unknown[];
|
||||
skipped: Array<{ reason: string }>;
|
||||
}>("ocr", "POST", {
|
||||
rows: [
|
||||
{
|
||||
analyte: "My own marker",
|
||||
value: 3,
|
||||
unit: "mmol/L",
|
||||
takenAt: "2026-06-02T08:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(json.data!.inserted).toEqual([]);
|
||||
expect(json.data!.skipped.map((s) => s.reason)).toEqual([
|
||||
"unit_mismatch",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips a row whose unit cannot be converted, as before", async () => {
|
||||
const { json } = await call<{
|
||||
inserted: unknown[];
|
||||
skipped: Array<{ reason: string }>;
|
||||
}>("ocr", "POST", {
|
||||
rows: [
|
||||
describe("editing and reading back", () => {
|
||||
it("clears the conversion record when the value is edited", async () => {
|
||||
const created = await call<Dto>("labs", "POST", {
|
||||
biomarkerId: "bm-glucose",
|
||||
value: 6.0,
|
||||
unit: "mmol/L",
|
||||
takenAt: "2026-07-01T08:00:00.000Z",
|
||||
});
|
||||
const id = created.json.data!.id;
|
||||
expect(created.json.data!.converted).toBe(true);
|
||||
|
||||
const edited = await call<Dto>("one", "PUT", { value: 110 }, id);
|
||||
expect(edited.status).toBe(200);
|
||||
expect(edited.json.data!.value).toBe(110);
|
||||
expect(edited.json.data!.converted).toBe(false);
|
||||
expect(edited.json.data!.sourceValue).toBeNull();
|
||||
expect(edited.json.data!.sourceUnit).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the conversion record when only the note is edited", async () => {
|
||||
const created = await call<Dto>("labs", "POST", {
|
||||
biomarkerId: "bm-glucose",
|
||||
value: 6.1,
|
||||
unit: "mmol/L",
|
||||
takenAt: "2026-07-02T08:00:00.000Z",
|
||||
});
|
||||
const id = created.json.data!.id;
|
||||
const storedValue = created.json.data!.value;
|
||||
// The web edit dialog sends the form back whole, the unchanged value
|
||||
// included; only a changed value clears the record.
|
||||
const edited = await call<Dto>(
|
||||
"one",
|
||||
"PUT",
|
||||
{
|
||||
analyte: "My own marker",
|
||||
value: 3,
|
||||
unit: "mmol/L",
|
||||
takenAt: "2026-06-02T08:00:00.000Z",
|
||||
value: storedValue,
|
||||
takenAt: "2026-07-02T09:00:00.000Z",
|
||||
note: "fasting",
|
||||
},
|
||||
],
|
||||
id,
|
||||
);
|
||||
expect(edited.status).toBe(200);
|
||||
expect(edited.json.data!.value).toBe(storedValue);
|
||||
expect(edited.json.data!.converted).toBe(true);
|
||||
expect(edited.json.data!.sourceValue).toBe(6.1);
|
||||
expect(edited.json.data!.sourceUnit).toBe("mmol/L");
|
||||
});
|
||||
expect(json.data!.inserted).toEqual([]);
|
||||
expect(json.data!.skipped.map((s) => s.reason)).toEqual(["unit_mismatch"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editing and reading back", () => {
|
||||
it("clears the conversion record when the value is edited", async () => {
|
||||
const created = await call<Dto>("labs", "POST", {
|
||||
biomarkerId: "bm-glucose",
|
||||
value: 6.0,
|
||||
unit: "mmol/L",
|
||||
takenAt: "2026-07-01T08:00:00.000Z",
|
||||
it("returns the fields on the list and on the single reading", async () => {
|
||||
const list = await call<{ results: Dto[] }>("labs", "GET");
|
||||
const converted = list.json.data!.results.filter((r) => r.converted);
|
||||
expect(converted.length).toBeGreaterThan(0);
|
||||
for (const r of converted) {
|
||||
expect(r.sourceValue).not.toBeNull();
|
||||
expect(r.sourceUnit).not.toBeNull();
|
||||
}
|
||||
const one = await call<Dto>("one", "GET", undefined, converted[0].id);
|
||||
expect(one.json.data!.converted).toBe(true);
|
||||
});
|
||||
const id = created.json.data!.id;
|
||||
expect(created.json.data!.converted).toBe(true);
|
||||
|
||||
const edited = await call<Dto>("one", "PUT", { value: 110 }, id);
|
||||
expect(edited.status).toBe(200);
|
||||
expect(edited.json.data!.value).toBe(110);
|
||||
expect(edited.json.data!.converted).toBe(false);
|
||||
expect(edited.json.data!.sourceValue).toBeNull();
|
||||
expect(edited.json.data!.sourceUnit).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the conversion record when only the note is edited", async () => {
|
||||
const created = await call<Dto>("labs", "POST", {
|
||||
biomarkerId: "bm-glucose",
|
||||
value: 6.1,
|
||||
unit: "mmol/L",
|
||||
takenAt: "2026-07-02T08:00:00.000Z",
|
||||
});
|
||||
const id = created.json.data!.id;
|
||||
const storedValue = created.json.data!.value;
|
||||
// The web edit dialog sends the form back whole, the unchanged value
|
||||
// included; only a changed value clears the record.
|
||||
const edited = await call<Dto>(
|
||||
"one",
|
||||
"PUT",
|
||||
{
|
||||
value: storedValue,
|
||||
takenAt: "2026-07-02T09:00:00.000Z",
|
||||
note: "fasting",
|
||||
},
|
||||
id,
|
||||
);
|
||||
expect(edited.status).toBe(200);
|
||||
expect(edited.json.data!.value).toBe(storedValue);
|
||||
expect(edited.json.data!.converted).toBe(true);
|
||||
expect(edited.json.data!.sourceValue).toBe(6.1);
|
||||
expect(edited.json.data!.sourceUnit).toBe("mmol/L");
|
||||
});
|
||||
|
||||
it("returns the fields on the list and on the single reading", async () => {
|
||||
const list = await call<{ results: Dto[] }>("labs", "GET");
|
||||
const converted = list.json.data!.results.filter((r) => r.converted);
|
||||
expect(converted.length).toBeGreaterThan(0);
|
||||
for (const r of converted) {
|
||||
expect(r.sourceValue).not.toBeNull();
|
||||
expect(r.sourceUnit).not.toBeNull();
|
||||
}
|
||||
const one = await call<Dto>("one", "GET", undefined, converted[0].id);
|
||||
expect(one.json.data!.converted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -55,18 +55,27 @@ describe("an update that changes nothing", () => {
|
||||
const row = await seedReading();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
// Read as a difference within the transaction. Since Postgres 15 the
|
||||
// `pg_stat_xact_*` views also carry counts an earlier transaction on the
|
||||
// same connection has not flushed yet (stats flush at most once a
|
||||
// second), so an update from the case before this one could show up.
|
||||
const hot = await prisma.$transaction(async (tx) => {
|
||||
const read = async () => {
|
||||
const [stats] = await tx.$queryRaw<Array<{ hot: bigint; upd: bigint }>>`
|
||||
SELECT n_tup_hot_upd AS hot, n_tup_upd AS upd
|
||||
FROM pg_stat_xact_user_tables WHERE relname = 'measurements'`;
|
||||
return { hot: Number(stats?.hot ?? 0), upd: Number(stats?.upd ?? 0) };
|
||||
};
|
||||
const before = await read();
|
||||
await tx.measurement.update({
|
||||
where: { id: row.id },
|
||||
data: { value: 72.3, deletedAt: null, syncVersion: { increment: 1 } },
|
||||
});
|
||||
const [stats] = await tx.$queryRaw<Array<{ hot: bigint; upd: bigint }>>`
|
||||
SELECT n_tup_hot_upd AS hot, n_tup_upd AS upd
|
||||
FROM pg_stat_xact_user_tables WHERE relname = 'measurements'`;
|
||||
return stats;
|
||||
const after = await read();
|
||||
return { hot: after.hot - before.hot, upd: after.upd - before.upd };
|
||||
});
|
||||
expect(Number(hot.upd)).toBe(1);
|
||||
expect(Number(hot.hot)).toBe(1);
|
||||
expect(hot.upd).toBe(1);
|
||||
expect(hot.hot).toBe(1);
|
||||
|
||||
const after = await prisma.measurement.findUniqueOrThrow({
|
||||
where: { id: row.id },
|
||||
|
||||
@@ -211,27 +211,32 @@ async function readersOfD1(method: PulseDayMeanMethod) {
|
||||
};
|
||||
}
|
||||
|
||||
describe("every reader follows the active method", () => {
|
||||
it("weighs every hour alike under hour_mean_v1", async () => {
|
||||
const seen = await readersOfD1("hour_mean_v1");
|
||||
for (const [reader, value] of Object.entries(seen)) {
|
||||
expect(value, reader).toBeCloseTo(V1, 6);
|
||||
}
|
||||
// One instance through the cases in order: the readers, the dry run, the
|
||||
// rebuild after apply (which reads the dry run's record) and the large
|
||||
// account last, whose rows every earlier count leaves out. The order holds
|
||||
// under a shuffled run.
|
||||
describe("the pulse day-mean method, one instance", { shuffle: false }, () => {
|
||||
describe("every reader follows the active method", () => {
|
||||
it("weighs every hour alike under hour_mean_v1", async () => {
|
||||
const seen = await readersOfD1("hour_mean_v1");
|
||||
for (const [reader, value] of Object.entries(seen)) {
|
||||
expect(value, reader).toBeCloseTo(V1, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it("weighs the lone reading's hour a third under weighted_hour_mean_v2", async () => {
|
||||
const seen = await readersOfD1("weighted_hour_mean_v2");
|
||||
for (const [reader, value] of Object.entries(seen)) {
|
||||
expect(value, reader).toBeCloseTo(V2, 6);
|
||||
}
|
||||
expect(Number(V2.toFixed(1))).toBe(60.9);
|
||||
setActivePulseDayMeanMethod("hour_mean_v1");
|
||||
});
|
||||
});
|
||||
|
||||
it("weighs the lone reading's hour a third under weighted_hour_mean_v2", async () => {
|
||||
const seen = await readersOfD1("weighted_hour_mean_v2");
|
||||
for (const [reader, value] of Object.entries(seen)) {
|
||||
expect(value, reader).toBeCloseTo(V2, 6);
|
||||
}
|
||||
expect(Number(V2.toFixed(1))).toBe(60.9);
|
||||
setActivePulseDayMeanMethod("hour_mean_v1");
|
||||
});
|
||||
});
|
||||
|
||||
/** Every table except the settings row, as one digest per table. */
|
||||
async function snapshotTables(): Promise<Record<string, string>> {
|
||||
const tables = await prisma.$queryRaw<Array<{ name: string }>>`
|
||||
/** Every table except the settings row, as one digest per table. */
|
||||
async function snapshotTables(): Promise<Record<string, string>> {
|
||||
const tables = await prisma.$queryRaw<Array<{ name: string }>>`
|
||||
SELECT table_name AS name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
@@ -239,147 +244,147 @@ async function snapshotTables(): Promise<Record<string, string>> {
|
||||
AND table_name NOT IN ('app_settings', '_prisma_migrations')
|
||||
ORDER BY table_name
|
||||
`;
|
||||
const out: Record<string, string> = {};
|
||||
for (const { name } of tables) {
|
||||
if (!/^[a-z0-9_]+$/.test(name)) throw new Error(`table name ${name}`);
|
||||
const [r] = await prisma.$queryRawUnsafe<Array<{ n: number; d: string }>>(
|
||||
`SELECT count(*)::int AS n,
|
||||
const out: Record<string, string> = {};
|
||||
for (const { name } of tables) {
|
||||
if (!/^[a-z0-9_]+$/.test(name)) throw new Error(`table name ${name}`);
|
||||
const [r] = await prisma.$queryRawUnsafe<Array<{ n: number; d: string }>>(
|
||||
`SELECT count(*)::int AS n,
|
||||
coalesce(md5(string_agg(t::text, '|' ORDER BY t::text)), '') AS d
|
||||
FROM "${name}" t`,
|
||||
);
|
||||
out[name] = `${r.n}:${r.d}`;
|
||||
);
|
||||
out[name] = `${r.n}:${r.d}`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The settings row without the dry-run column. */
|
||||
async function settingsWithoutDryRun() {
|
||||
const [r] = await prisma.$queryRaw<Array<{ j: Record<string, unknown> }>>`
|
||||
/** The settings row without the dry-run column. */
|
||||
async function settingsWithoutDryRun() {
|
||||
const [r] = await prisma.$queryRaw<Array<{ j: Record<string, unknown> }>>`
|
||||
SELECT to_jsonb(s) - 'pulse_day_mean_dry_run_json' - 'updated_at' AS j
|
||||
FROM app_settings s WHERE s.id = 'singleton'
|
||||
`;
|
||||
return r.j;
|
||||
}
|
||||
return r.j;
|
||||
}
|
||||
|
||||
describe("the dry run", () => {
|
||||
it("writes its result and nothing else", async () => {
|
||||
const tablesBefore = await snapshotTables();
|
||||
const settingsBefore = await settingsWithoutDryRun();
|
||||
expect(tablesBefore.measurement_rollups).not.toMatch(/^0:/);
|
||||
describe("the dry run", () => {
|
||||
it("writes its result and nothing else", async () => {
|
||||
const tablesBefore = await snapshotTables();
|
||||
const settingsBefore = await settingsWithoutDryRun();
|
||||
expect(tablesBefore.measurement_rollups).not.toMatch(/^0:/);
|
||||
|
||||
const outcome = await handlePulseDayMean(job("dry_run"));
|
||||
expect(outcome.ok).toBe(true);
|
||||
const outcome = await handlePulseDayMean(job("dry_run"));
|
||||
expect(outcome.ok).toBe(true);
|
||||
|
||||
expect(await snapshotTables()).toEqual(tablesBefore);
|
||||
expect(await settingsWithoutDryRun()).toEqual(settingsBefore);
|
||||
expect(await snapshotTables()).toEqual(tablesBefore);
|
||||
expect(await settingsWithoutDryRun()).toEqual(settingsBefore);
|
||||
|
||||
const stored = await prisma.appSettings.findUniqueOrThrow({
|
||||
where: { id: "singleton" },
|
||||
});
|
||||
const run = pulseDayMeanStoredRunsSchema.parse(
|
||||
stored.pulseDayMeanDryRunJson,
|
||||
);
|
||||
expect(run.status).toBe("done");
|
||||
expect(run.finishedAt).not.toBeNull();
|
||||
expect(run.result).toEqual({
|
||||
accounts: 2,
|
||||
daysCompared: 3,
|
||||
daysChangedOver1Bpm: 2,
|
||||
maxChangeBpm: 10,
|
||||
distribution: [
|
||||
{ bucket: "0-1", days: 1 },
|
||||
{ bucket: "1-2", days: 1 },
|
||||
{ bucket: "2-5", days: 0 },
|
||||
{ bucket: "5+", days: 1 },
|
||||
],
|
||||
perAccount: [
|
||||
{ account: "#1", days: 2, changedOver1Bpm: 1 },
|
||||
{ account: "#2", days: 1, changedOver1Bpm: 1 },
|
||||
],
|
||||
});
|
||||
// No account id leaves the job.
|
||||
expect(JSON.stringify(stored.pulseDayMeanDryRunJson)).not.toContain(A);
|
||||
expect(stored.pulseDayMeanMethod).toBe("hour_mean_v1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the rebuild after apply", () => {
|
||||
const dayMean = async (userId: string, day: number) =>
|
||||
(
|
||||
await readCanonicalRollupBuckets({
|
||||
userId,
|
||||
type: "PULSE",
|
||||
granularity: "DAY",
|
||||
from: new Date(day),
|
||||
to: new Date(day),
|
||||
toInclusive: true,
|
||||
})
|
||||
)[0]?.mean;
|
||||
|
||||
it("rewrites the PULSE rollups under the stored method", async () => {
|
||||
expect(await dayMean(A, D1)).toBeCloseTo(V1, 6);
|
||||
expect(await dayMean(B, D1)).toBeCloseTo(80, 6);
|
||||
|
||||
await prisma.appSettings.update({
|
||||
where: { id: "singleton" },
|
||||
data: { pulseDayMeanMethod: "weighted_hour_mean_v2" },
|
||||
});
|
||||
const outcome = await handlePulseDayMean(job("rebuild"));
|
||||
expect(outcome.ok).toBe(true);
|
||||
// The rebuild is on the record beside the dry run, with what it did.
|
||||
const { readPulseDayMeanStatus } =
|
||||
await import("@/lib/jobs/pulse-day-mean");
|
||||
const status = await readPulseDayMeanStatus();
|
||||
expect(status.lastRebuild).toMatchObject({ status: "done" });
|
||||
expect(status.lastRebuild?.accounts).toBeGreaterThan(0);
|
||||
expect(status.lastDryRun?.status).toBe("done");
|
||||
|
||||
expect(await dayMean(A, D1)).toBeCloseTo(V2, 6);
|
||||
expect(await dayMean(A, D2)).toBeCloseTo(70, 6);
|
||||
expect(await dayMean(B, D1)).toBeCloseTo(70, 6);
|
||||
|
||||
await prisma.appSettings.update({
|
||||
where: { id: "singleton" },
|
||||
data: { pulseDayMeanMethod: "hour_mean_v1" },
|
||||
const stored = await prisma.appSettings.findUniqueOrThrow({
|
||||
where: { id: "singleton" },
|
||||
});
|
||||
const run = pulseDayMeanStoredRunsSchema.parse(
|
||||
stored.pulseDayMeanDryRunJson,
|
||||
);
|
||||
expect(run.status).toBe("done");
|
||||
expect(run.finishedAt).not.toBeNull();
|
||||
expect(run.result).toEqual({
|
||||
accounts: 2,
|
||||
daysCompared: 3,
|
||||
daysChangedOver1Bpm: 2,
|
||||
maxChangeBpm: 10,
|
||||
distribution: [
|
||||
{ bucket: "0-1", days: 1 },
|
||||
{ bucket: "1-2", days: 1 },
|
||||
{ bucket: "2-5", days: 0 },
|
||||
{ bucket: "5+", days: 1 },
|
||||
],
|
||||
perAccount: [
|
||||
{ account: "#1", days: 2, changedOver1Bpm: 1 },
|
||||
{ account: "#2", days: 1, changedOver1Bpm: 1 },
|
||||
],
|
||||
});
|
||||
// No account id leaves the job.
|
||||
expect(JSON.stringify(stored.pulseDayMeanDryRunJson)).not.toContain(A);
|
||||
expect(stored.pulseDayMeanMethod).toBe("hour_mean_v1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("a large account under the production heap", () => {
|
||||
const BIG = "pulse-method-big";
|
||||
const YEARS = 2;
|
||||
/** The production container's heap ceiling. */
|
||||
const PROD_HEAP_MB = 524;
|
||||
/**
|
||||
* The seed's transactions run past Prisma's 5 s default under a loaded
|
||||
* host: a month took at most 0.55 s on a quiet machine and 9.9 s at a load
|
||||
* average near 95, which expired the transaction. The bound is the
|
||||
* fixture's, not the code's under test.
|
||||
*/
|
||||
const SEED_TRANSACTION = { maxWait: 30_000, timeout: 120_000 };
|
||||
describe("the rebuild after apply", () => {
|
||||
const dayMean = async (userId: string, day: number) =>
|
||||
(
|
||||
await readCanonicalRollupBuckets({
|
||||
userId,
|
||||
type: "PULSE",
|
||||
granularity: "DAY",
|
||||
from: new Date(day),
|
||||
to: new Date(day),
|
||||
toInclusive: true,
|
||||
})
|
||||
)[0]?.mean;
|
||||
|
||||
beforeAll(async () => {
|
||||
await prisma.user.create({
|
||||
data: { id: BIG, username: BIG, timezone: "UTC" },
|
||||
it("rewrites the PULSE rollups under the stored method", async () => {
|
||||
expect(await dayMean(A, D1)).toBeCloseTo(V1, 6);
|
||||
expect(await dayMean(B, D1)).toBeCloseTo(80, 6);
|
||||
|
||||
await prisma.appSettings.update({
|
||||
where: { id: "singleton" },
|
||||
data: { pulseDayMeanMethod: "weighted_hour_mean_v2" },
|
||||
});
|
||||
const outcome = await handlePulseDayMean(job("rebuild"));
|
||||
expect(outcome.ok).toBe(true);
|
||||
// The rebuild is on the record beside the dry run, with what it did.
|
||||
const { readPulseDayMeanStatus } =
|
||||
await import("@/lib/jobs/pulse-day-mean");
|
||||
const status = await readPulseDayMeanStatus();
|
||||
expect(status.lastRebuild).toMatchObject({ status: "done" });
|
||||
expect(status.lastRebuild?.accounts).toBeGreaterThan(0);
|
||||
expect(status.lastDryRun?.status).toBe("done");
|
||||
|
||||
expect(await dayMean(A, D1)).toBeCloseTo(V2, 6);
|
||||
expect(await dayMean(A, D2)).toBeCloseTo(70, 6);
|
||||
expect(await dayMean(B, D1)).toBeCloseTo(70, 6);
|
||||
|
||||
await prisma.appSettings.update({
|
||||
where: { id: "singleton" },
|
||||
data: { pulseDayMeanMethod: "hour_mean_v1" },
|
||||
});
|
||||
});
|
||||
// One reading a minute for two years, about a million rows, and every
|
||||
// seventh hour holding a single reading so the two methods disagree.
|
||||
//
|
||||
// Written a month at a time, each month in its own transaction with the
|
||||
// statement timeout lifted for that transaction only and a transaction
|
||||
// timeout sized for a loaded host (`SEED_TRANSACTION`): as one statement
|
||||
// the million-row insert outran the timeout on CI's shared Postgres, and
|
||||
// both timeouts guard the code under test, not this fixture.
|
||||
const end = new Date();
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
const start = new Date(end.getTime() - YEARS * 365 * DAY);
|
||||
const CHUNK = 30 * DAY;
|
||||
for (let from = start.getTime(); from < end.getTime(); from += CHUNK) {
|
||||
const chunkFrom = new Date(from);
|
||||
const chunkTo = new Date(Math.min(from + CHUNK, end.getTime()));
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SET LOCAL statement_timeout = 0`;
|
||||
await tx.$executeRaw`
|
||||
});
|
||||
|
||||
describe("a large account under the production heap", () => {
|
||||
const BIG = "pulse-method-big";
|
||||
const YEARS = 2;
|
||||
/** The production container's heap ceiling. */
|
||||
const PROD_HEAP_MB = 524;
|
||||
/**
|
||||
* The seed's transactions run past Prisma's 5 s default under a loaded
|
||||
* host: a month took at most 0.55 s on a quiet machine and 9.9 s at a load
|
||||
* average near 95, which expired the transaction. The bound is the
|
||||
* fixture's, not the code's under test.
|
||||
*/
|
||||
const SEED_TRANSACTION = { maxWait: 30_000, timeout: 120_000 };
|
||||
|
||||
beforeAll(async () => {
|
||||
await prisma.user.create({
|
||||
data: { id: BIG, username: BIG, timezone: "UTC" },
|
||||
});
|
||||
// One reading a minute for two years, about a million rows, and every
|
||||
// seventh hour holding a single reading so the two methods disagree.
|
||||
//
|
||||
// Written a month at a time, each month in its own transaction with the
|
||||
// statement timeout lifted for that transaction only and a transaction
|
||||
// timeout sized for a loaded host (`SEED_TRANSACTION`): as one statement
|
||||
// the million-row insert outran the timeout on CI's shared Postgres, and
|
||||
// both timeouts guard the code under test, not this fixture.
|
||||
const end = new Date();
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
const start = new Date(end.getTime() - YEARS * 365 * DAY);
|
||||
const CHUNK = 30 * DAY;
|
||||
for (let from = start.getTime(); from < end.getTime(); from += CHUNK) {
|
||||
const chunkFrom = new Date(from);
|
||||
const chunkTo = new Date(Math.min(from + CHUNK, end.getTime()));
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SET LOCAL statement_timeout = 0`;
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO measurements (id, user_id, type, unit, source, value, measured_at, created_at, updated_at)
|
||||
SELECT 'big-' || (extract(epoch FROM ts)::bigint / 60)::text, ${BIG}, 'PULSE', 'bpm', 'APPLE_HEALTH',
|
||||
60 + ((extract(epoch FROM ts)::bigint / 60) % 40),
|
||||
@@ -391,60 +396,63 @@ describe("a large account under the production heap", () => {
|
||||
) AS s(ts)
|
||||
WHERE extract(hour FROM ts)::int % 7 <> 3 OR extract(minute FROM ts)::int = 0
|
||||
`;
|
||||
}, SEED_TRANSACTION);
|
||||
}
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SET LOCAL statement_timeout = 0`;
|
||||
await tx.$executeRaw`ANALYZE measurements`;
|
||||
}, SEED_TRANSACTION);
|
||||
}
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SET LOCAL statement_timeout = 0`;
|
||||
await tx.$executeRaw`ANALYZE measurements`;
|
||||
}, SEED_TRANSACTION);
|
||||
}, 600_000);
|
||||
}, 600_000);
|
||||
|
||||
async function peakHeapMb(work: () => Promise<unknown>) {
|
||||
const base = process.memoryUsage().heapUsed;
|
||||
let peak = base;
|
||||
const timer = setInterval(() => {
|
||||
async function peakHeapMb(work: () => Promise<unknown>) {
|
||||
const base = process.memoryUsage().heapUsed;
|
||||
let peak = base;
|
||||
const timer = setInterval(() => {
|
||||
peak = Math.max(peak, process.memoryUsage().heapUsed);
|
||||
}, 25);
|
||||
try {
|
||||
await work();
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
}
|
||||
peak = Math.max(peak, process.memoryUsage().heapUsed);
|
||||
}, 25);
|
||||
try {
|
||||
await work();
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
return {
|
||||
grewMb: (peak - base) / 1_048_576,
|
||||
peakMb: peak / 1_048_576,
|
||||
};
|
||||
}
|
||||
peak = Math.max(peak, process.memoryUsage().heapUsed);
|
||||
return {
|
||||
grewMb: (peak - base) / 1_048_576,
|
||||
peakMb: peak / 1_048_576,
|
||||
};
|
||||
}
|
||||
|
||||
it("stays flat through the dry run and the rebuild", async () => {
|
||||
const rows = await prisma.measurement.count({ where: { userId: BIG } });
|
||||
expect(rows).toBeGreaterThan(900_000);
|
||||
it("stays flat through the dry run and the rebuild", async () => {
|
||||
const rows = await prisma.measurement.count({ where: { userId: BIG } });
|
||||
expect(rows).toBeGreaterThan(900_000);
|
||||
|
||||
const dry = await peakHeapMb(() => computePulseDayMeanDryRun());
|
||||
await prisma.appSettings.update({
|
||||
where: { id: "singleton" },
|
||||
data: { pulseDayMeanMethod: "weighted_hour_mean_v2" },
|
||||
});
|
||||
const rebuild = await peakHeapMb(() => handlePulseDayMean(job("rebuild")));
|
||||
process.stderr.write(
|
||||
`[pulse-day-mean heap] rows=${rows} dryRun grew=${dry.grewMb.toFixed(1)} MB peak=${dry.peakMb.toFixed(1)} MB; rebuild grew=${rebuild.grewMb.toFixed(1)} MB peak=${rebuild.peakMb.toFixed(1)} MB\n`,
|
||||
);
|
||||
// A window's aggregate rows, never the readings: a few MB at most. The
|
||||
// bound leaves room for the test runner's own churn and is still a small
|
||||
// slice of the production ceiling.
|
||||
expect(dry.grewMb).toBeLessThan(64);
|
||||
expect(rebuild.grewMb).toBeLessThan(64);
|
||||
expect(dry.grewMb).toBeLessThan(PROD_HEAP_MB / 8);
|
||||
const dry = await peakHeapMb(() => computePulseDayMeanDryRun());
|
||||
await prisma.appSettings.update({
|
||||
where: { id: "singleton" },
|
||||
data: { pulseDayMeanMethod: "weighted_hour_mean_v2" },
|
||||
});
|
||||
const rebuild = await peakHeapMb(() =>
|
||||
handlePulseDayMean(job("rebuild")),
|
||||
);
|
||||
process.stderr.write(
|
||||
`[pulse-day-mean heap] rows=${rows} dryRun grew=${dry.grewMb.toFixed(1)} MB peak=${dry.peakMb.toFixed(1)} MB; rebuild grew=${rebuild.grewMb.toFixed(1)} MB peak=${rebuild.peakMb.toFixed(1)} MB\n`,
|
||||
);
|
||||
// A window's aggregate rows, never the readings: a few MB at most. The
|
||||
// bound leaves room for the test runner's own churn and is still a small
|
||||
// slice of the production ceiling.
|
||||
expect(dry.grewMb).toBeLessThan(64);
|
||||
expect(rebuild.grewMb).toBeLessThan(64);
|
||||
expect(dry.grewMb).toBeLessThan(PROD_HEAP_MB / 8);
|
||||
|
||||
const days = await readCanonicalRollupBuckets({
|
||||
userId: BIG,
|
||||
type: "PULSE",
|
||||
granularity: "DAY",
|
||||
from: new Date(Date.now() - 400 * DAY),
|
||||
to: new Date(),
|
||||
toInclusive: true,
|
||||
});
|
||||
expect(days.length).toBeGreaterThan(300);
|
||||
}, 900_000);
|
||||
const days = await readCanonicalRollupBuckets({
|
||||
userId: BIG,
|
||||
type: "PULSE",
|
||||
granularity: "DAY",
|
||||
from: new Date(Date.now() - 400 * DAY),
|
||||
to: new Date(),
|
||||
toInclusive: true,
|
||||
});
|
||||
expect(days.length).toBeGreaterThan(300);
|
||||
}, 900_000);
|
||||
});
|
||||
});
|
||||
@@ -424,15 +424,35 @@ describe("statement budgets on the hot routes (real Postgres)", () => {
|
||||
externalId: `budget-hr-${i}`,
|
||||
};
|
||||
});
|
||||
const m = await countStatements(async () => {
|
||||
const res = await (POST as unknown as Handler)(
|
||||
const post = (body: unknown) =>
|
||||
(POST as unknown as Handler)(
|
||||
new NextRequest("http://localhost/api/measurements/batch", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ entries }),
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
{ params: Promise.resolve({}) },
|
||||
);
|
||||
// One reading a month earlier first, as the reads warm up: it fills the
|
||||
// process-level caches a running server holds, which this case would
|
||||
// otherwise pay for whenever it runs before the reads do.
|
||||
const warmAt = daysAgo(30).toISOString();
|
||||
const warm = await post({
|
||||
entries: [
|
||||
{
|
||||
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
|
||||
value: 60,
|
||||
unit: "count/min",
|
||||
startDate: warmAt,
|
||||
endDate: warmAt,
|
||||
externalId: "budget-hr-warm",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(warm.status, "batch warm-up").toBe(200);
|
||||
await evictUserCaches();
|
||||
const m = await countStatements(async () => {
|
||||
const res = await post({ entries });
|
||||
return {
|
||||
status: res.status,
|
||||
body: (await res.json()) as { data: { inserted: number } },
|
||||
|
||||
@@ -90,7 +90,9 @@ beforeAll(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("lab CSV import", () => {
|
||||
// One walk: a dry run, the import, the same file again. Each step reads what
|
||||
// the one before it wrote, so the order holds under a shuffled run.
|
||||
describe("lab CSV import", { shuffle: false }, () => {
|
||||
it("a dry run writes nothing and reports the plan", async () => {
|
||||
const result = await importLabsCsv({
|
||||
userId: USER,
|
||||
@@ -189,6 +191,14 @@ describe("lab CSV import", () => {
|
||||
});
|
||||
|
||||
describe("lab CSV import in a zone of the file's own", () => {
|
||||
// An account of its own, so its two draws are not in the other cases' counts.
|
||||
const ZONE_USER = "csv-import-zone-user";
|
||||
beforeAll(async () => {
|
||||
await prisma.user.create({
|
||||
data: { id: ZONE_USER, username: ZONE_USER, timezone: TZ },
|
||||
});
|
||||
});
|
||||
|
||||
it("cuts the duplicate day in the zone the file is read in", async () => {
|
||||
// Read in New York, these are draws on two days (1 and 2 March). In the
|
||||
// account's Berlin they both fall on 2 March, which used to make the
|
||||
@@ -199,7 +209,7 @@ describe("lab CSV import in a zone of the file's own", () => {
|
||||
"Zone marker;7;mg/dL;2025-03-02T02:00",
|
||||
].join("\n");
|
||||
const result = await importLabsCsv({
|
||||
userId: USER,
|
||||
userId: ZONE_USER,
|
||||
tz: TZ,
|
||||
stream: textToStream(csv),
|
||||
maxBytes: 1_000_000,
|
||||
@@ -381,6 +391,15 @@ describe("medication CSV import", () => {
|
||||
});
|
||||
|
||||
describe("medication CSV import, a crash between medication and category", () => {
|
||||
// An account of its own: the medication import case counts every
|
||||
// medication its account holds.
|
||||
const CRASH_USER = "csv-import-crash-user";
|
||||
beforeAll(async () => {
|
||||
await prisma.user.create({
|
||||
data: { id: CRASH_USER, username: CRASH_USER, timezone: TZ },
|
||||
});
|
||||
});
|
||||
|
||||
const CSV = ["name,dose,times", "Atorvastatin,20 mg,20:00"].join("\n");
|
||||
const query = medicationsCsvQuerySchema.parse({
|
||||
name: "0",
|
||||
@@ -393,7 +412,7 @@ describe("medication CSV import, a crash between medication and category", () =>
|
||||
categoryFault.on = true;
|
||||
await expect(
|
||||
importMedicationsCsv({
|
||||
userId: USER,
|
||||
userId: CRASH_USER,
|
||||
tz: TZ,
|
||||
stream: textToStream(CSV),
|
||||
maxBytes: 100_000,
|
||||
@@ -403,12 +422,12 @@ describe("medication CSV import, a crash between medication and category", () =>
|
||||
categoryFault.on = false;
|
||||
expect(
|
||||
await prisma.medication.count({
|
||||
where: { userId: USER, name: "Atorvastatin" },
|
||||
where: { userId: CRASH_USER, name: "Atorvastatin" },
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
const retry = await importMedicationsCsv({
|
||||
userId: USER,
|
||||
userId: CRASH_USER,
|
||||
tz: TZ,
|
||||
stream: textToStream(CSV),
|
||||
maxBytes: 100_000,
|
||||
@@ -416,7 +435,7 @@ describe("medication CSV import, a crash between medication and category", () =>
|
||||
});
|
||||
expect(retry).toMatchObject({ inserted: 1, duplicates: 0 });
|
||||
const med = await prisma.medication.findFirstOrThrow({
|
||||
where: { userId: USER, name: "Atorvastatin" },
|
||||
where: { userId: CRASH_USER, name: "Atorvastatin" },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(
|
||||
|
||||
@@ -157,6 +157,9 @@ export function defineRequestClockCases(expectedZone: string): void {
|
||||
});
|
||||
|
||||
it("cuts every window of the live aggregate on the request's clock", async () => {
|
||||
// The live path is the one without a tier; drop whatever the rollup
|
||||
// case built if it ran first.
|
||||
await prisma.measurementRollup.deleteMany({ where: { userId } });
|
||||
await expectWindows("live");
|
||||
});
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("GET /api/analytics parity", () => {
|
||||
describe("GET /api/analytics parity", { shuffle: false }, () => {
|
||||
it("01 a slice outside the closed set", async () => {
|
||||
await step("01-invalid-slice", { query: { slice: "summary" } });
|
||||
});
|
||||
|
||||
@@ -159,7 +159,7 @@ afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("GET /api/dashboard/summary parity", () => {
|
||||
describe("GET /api/dashboard/summary parity", { shuffle: false }, () => {
|
||||
it("01 cold, then warm", async () => {
|
||||
await step("01-cold");
|
||||
await step("02-warm");
|
||||
|
||||
@@ -169,7 +169,7 @@ afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("/api/dashboard/widgets parity", () => {
|
||||
describe("/api/dashboard/widgets parity", { shuffle: false }, () => {
|
||||
let lastToken = "";
|
||||
|
||||
it("01 the first read, filled into the cache", async () => {
|
||||
|
||||
@@ -215,129 +215,133 @@ afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("POST /api/documents/inbound parity", () => {
|
||||
it("a stored upload and the same bytes again", async () => {
|
||||
const body = () =>
|
||||
form(PDF("discharge"), "discharge.pdf", {
|
||||
title: "Discharge letter",
|
||||
kind: "DISCHARGE_LETTER",
|
||||
documentDate: "2026-10-02",
|
||||
episodeIds: [episodeId],
|
||||
// One walk: each request sees what the earlier ones stored, so the cases
|
||||
// keep their order even when a run shuffles (`shuffle: false`).
|
||||
describe("documents-inbound parity walk", { shuffle: false }, () => {
|
||||
describe("POST /api/documents/inbound parity", () => {
|
||||
it("a stored upload and the same bytes again", async () => {
|
||||
const body = () =>
|
||||
form(PDF("discharge"), "discharge.pdf", {
|
||||
title: "Discharge letter",
|
||||
kind: "DISCHARGE_LETTER",
|
||||
documentDate: "2026-10-02",
|
||||
episodeIds: [episodeId],
|
||||
});
|
||||
await upload("post-01-stored", body());
|
||||
await upload("post-02-duplicate", body());
|
||||
await upload(
|
||||
"post-03-second",
|
||||
form(PDF("labs"), "labs.pdf", {
|
||||
title: "Blood test",
|
||||
kind: "LAB_RESULT",
|
||||
documentDate: "2025-03-04",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("the refusals", async () => {
|
||||
await upload("post-04-no-file", form(null, "x", { title: "Nothing" }));
|
||||
await upload(
|
||||
"post-05-invalid-metadata",
|
||||
form(PDF("bad-kind"), "bad.pdf", { kind: "SHOPPING_LIST" }),
|
||||
);
|
||||
await upload("post-06-empty", form(Buffer.alloc(0), "empty.pdf"));
|
||||
await upload(
|
||||
"post-07-unsupported",
|
||||
form(
|
||||
Buffer.from("<!doctype html><html><body>hi</body></html>\n"),
|
||||
"page.html",
|
||||
),
|
||||
);
|
||||
await upload(
|
||||
"post-08-unknown-episode",
|
||||
form(PDF("orphan"), "orphan.pdf", {
|
||||
episodeIds: ["cmissingepisode000000000"],
|
||||
}),
|
||||
);
|
||||
await upload("post-09-half-key", form(PDF("half"), "half.pdf"), {
|
||||
query: { sourceSystem: "PAPERLESS" },
|
||||
});
|
||||
await upload("post-01-stored", body());
|
||||
await upload("post-02-duplicate", body());
|
||||
await upload(
|
||||
"post-03-second",
|
||||
form(PDF("labs"), "labs.pdf", {
|
||||
title: "Blood test",
|
||||
kind: "LAB_RESULT",
|
||||
documentDate: "2025-03-04",
|
||||
}),
|
||||
);
|
||||
await upload(
|
||||
"post-10-keys-differ",
|
||||
form(PDF("differ"), "differ.pdf", {
|
||||
sourceSystem: "PAPERLESS",
|
||||
sourceId: "77",
|
||||
}),
|
||||
{ query: { sourceSystem: "PAPERLESS", sourceId: "78" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("a narrow token storing under a source key", async () => {
|
||||
const stored = await upload(
|
||||
"post-11-token-stored",
|
||||
form(PDF("imported"), "imported.pdf", {
|
||||
sourceSystem: "PAPERLESS",
|
||||
sourceId: "41",
|
||||
aiRead: "defer",
|
||||
}),
|
||||
{ auth: writer },
|
||||
);
|
||||
await upload("post-12-token-query-key", form(PDF("imported"), "x.pdf"), {
|
||||
auth: writer,
|
||||
query: { sourceSystem: "PAPERLESS", sourceId: "41" },
|
||||
});
|
||||
await prisma.inboundDocument.update({
|
||||
where: { id: stored.id! },
|
||||
data: { deletedAt: new Date("2026-10-14T09:00:00.000Z") },
|
||||
});
|
||||
await upload(
|
||||
"post-13-token-deleted",
|
||||
form(PDF("imported"), "imported.pdf", {
|
||||
sourceSystem: "PAPERLESS",
|
||||
sourceId: "41",
|
||||
}),
|
||||
{ auth: writer },
|
||||
);
|
||||
});
|
||||
|
||||
it("the module switched off", async () => {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { modulePreferencesJson: { inboundDocuments: false } },
|
||||
});
|
||||
await upload("post-14-module-off", form(PDF("dark"), "dark.pdf"));
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { modulePreferencesJson: { inboundDocuments: true } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("the refusals", async () => {
|
||||
await upload("post-04-no-file", form(null, "x", { title: "Nothing" }));
|
||||
await upload(
|
||||
"post-05-invalid-metadata",
|
||||
form(PDF("bad-kind"), "bad.pdf", { kind: "SHOPPING_LIST" }),
|
||||
);
|
||||
await upload("post-06-empty", form(Buffer.alloc(0), "empty.pdf"));
|
||||
await upload(
|
||||
"post-07-unsupported",
|
||||
form(
|
||||
Buffer.from("<!doctype html><html><body>hi</body></html>\n"),
|
||||
"page.html",
|
||||
),
|
||||
);
|
||||
await upload(
|
||||
"post-08-unknown-episode",
|
||||
form(PDF("orphan"), "orphan.pdf", {
|
||||
episodeIds: ["cmissingepisode000000000"],
|
||||
}),
|
||||
);
|
||||
await upload("post-09-half-key", form(PDF("half"), "half.pdf"), {
|
||||
query: { sourceSystem: "PAPERLESS" },
|
||||
describe("GET /api/documents/inbound parity", () => {
|
||||
it("the default page, a search and the filters", async () => {
|
||||
await list("get-01-default", {});
|
||||
await list("get-02-search", { q: "blood" });
|
||||
await list("get-03-kind", { kind: "DISCHARGE_LETTER,LAB_RESULT" });
|
||||
await list("get-04-episode", { episodeId });
|
||||
await list("get-05-year", { year: 2025 });
|
||||
await list("get-06-range", { from: "2026-10-01", to: "2026-10-02" });
|
||||
});
|
||||
await upload(
|
||||
"post-10-keys-differ",
|
||||
form(PDF("differ"), "differ.pdf", {
|
||||
sourceSystem: "PAPERLESS",
|
||||
sourceId: "77",
|
||||
}),
|
||||
{ query: { sourceSystem: "PAPERLESS", sourceId: "78" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("a narrow token storing under a source key", async () => {
|
||||
const stored = await upload(
|
||||
"post-11-token-stored",
|
||||
form(PDF("imported"), "imported.pdf", {
|
||||
sourceSystem: "PAPERLESS",
|
||||
sourceId: "41",
|
||||
aiRead: "defer",
|
||||
}),
|
||||
{ auth: writer },
|
||||
);
|
||||
await upload("post-12-token-query-key", form(PDF("imported"), "x.pdf"), {
|
||||
auth: writer,
|
||||
query: { sourceSystem: "PAPERLESS", sourceId: "41" },
|
||||
it("a sorted page and the page after it", async () => {
|
||||
const first = await list("get-07-sorted", {
|
||||
sort: "title",
|
||||
order: "asc",
|
||||
limit: 1,
|
||||
});
|
||||
await list("get-08-next-page", {
|
||||
sort: "title",
|
||||
order: "asc",
|
||||
limit: 1,
|
||||
cursor: first.nextCursor!,
|
||||
});
|
||||
});
|
||||
await prisma.inboundDocument.update({
|
||||
where: { id: stored.id! },
|
||||
data: { deletedAt: new Date("2026-10-14T09:00:00.000Z") },
|
||||
});
|
||||
await upload(
|
||||
"post-13-token-deleted",
|
||||
form(PDF("imported"), "imported.pdf", {
|
||||
sourceSystem: "PAPERLESS",
|
||||
sourceId: "41",
|
||||
}),
|
||||
{ auth: writer },
|
||||
);
|
||||
});
|
||||
|
||||
it("the module switched off", async () => {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { modulePreferencesJson: { inboundDocuments: false } },
|
||||
});
|
||||
await upload("post-14-module-off", form(PDF("dark"), "dark.pdf"));
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { modulePreferencesJson: { inboundDocuments: true } },
|
||||
it("a refused query, and a delegate reading the record", async () => {
|
||||
await list("get-09-invalid", { sort: "size" });
|
||||
await switchSessionTo(delegate.sessionId, userId);
|
||||
await list("get-10-delegated", {}, delegate);
|
||||
await switchSessionTo(delegate.sessionId, null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/documents/inbound parity", () => {
|
||||
it("the default page, a search and the filters", async () => {
|
||||
await list("get-01-default", {});
|
||||
await list("get-02-search", { q: "blood" });
|
||||
await list("get-03-kind", { kind: "DISCHARGE_LETTER,LAB_RESULT" });
|
||||
await list("get-04-episode", { episodeId });
|
||||
await list("get-05-year", { year: 2025 });
|
||||
await list("get-06-range", { from: "2026-10-01", to: "2026-10-02" });
|
||||
});
|
||||
|
||||
it("a sorted page and the page after it", async () => {
|
||||
const first = await list("get-07-sorted", {
|
||||
sort: "title",
|
||||
order: "asc",
|
||||
limit: 1,
|
||||
});
|
||||
await list("get-08-next-page", {
|
||||
sort: "title",
|
||||
order: "asc",
|
||||
limit: 1,
|
||||
cursor: first.nextCursor!,
|
||||
});
|
||||
});
|
||||
|
||||
it("a refused query, and a delegate reading the record", async () => {
|
||||
await list("get-09-invalid", { sort: "size" });
|
||||
await switchSessionTo(delegate.sessionId, userId);
|
||||
await list("get-10-delegated", {}, delegate);
|
||||
await switchSessionTo(delegate.sessionId, null);
|
||||
});
|
||||
});
|
||||
@@ -199,7 +199,7 @@ afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("POST /api/import/csv parity", () => {
|
||||
describe("POST /api/import/csv parity", { shuffle: false }, () => {
|
||||
it("01 a dry run", async () => {
|
||||
await step("01-dry-run", FILE, { dryRun: "1" });
|
||||
});
|
||||
|
||||
@@ -263,7 +263,7 @@ afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("POST /api/import parity", () => {
|
||||
describe("POST /api/import parity", { shuffle: false }, () => {
|
||||
it("01 a first file", async () => {
|
||||
await step("01-first-file", FILE);
|
||||
});
|
||||
|
||||
@@ -408,7 +408,7 @@ afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("GET /api/insights/comprehensive parity", () => {
|
||||
describe("GET /api/insights/comprehensive parity", { shuffle: false }, () => {
|
||||
it("01 an anonymous call", async () => {
|
||||
await step("01-anonymous", { auth: anonymous });
|
||||
});
|
||||
|
||||
@@ -635,380 +635,394 @@ afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("GET /api/insights/generate parity", () => {
|
||||
it("G01 an anonymous call", async () => {
|
||||
await get("G01-anonymous", { auth: anonymous });
|
||||
});
|
||||
|
||||
it("G02 the AI analysis opt-out", async () => {
|
||||
await get("G02-opted-out", { auth: optedOut, userId: OPTED_OUT_ID });
|
||||
});
|
||||
|
||||
it("G03 no cached briefing", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
await get("G03-empty");
|
||||
});
|
||||
|
||||
it("G04 a briefing written today", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)),
|
||||
cachedAt: new Date(NOW.getTime() - HOUR),
|
||||
cachedLocale: "en",
|
||||
// One walk: each request sees what the earlier ones stored, so the cases
|
||||
// keep their order even when a run shuffles (`shuffle: false`).
|
||||
describe("insights-generate parity walk", { shuffle: false }, () => {
|
||||
describe("GET /api/insights/generate parity", () => {
|
||||
it("G01 an anonymous call", async () => {
|
||||
await get("G01-anonymous", { auth: anonymous });
|
||||
});
|
||||
await get("G04-fresh");
|
||||
});
|
||||
|
||||
it("G05 a briefing written yesterday", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - 20 * HOUR)),
|
||||
cachedAt: new Date(NOW.getTime() - 2 * HOUR),
|
||||
cachedLocale: "en",
|
||||
it("G02 the AI analysis opt-out", async () => {
|
||||
await get("G02-opted-out", { auth: optedOut, userId: OPTED_OUT_ID });
|
||||
});
|
||||
await get("G05-stale");
|
||||
});
|
||||
|
||||
it("G06 a briefing in another language", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)),
|
||||
cachedAt: new Date(NOW.getTime() - HOUR),
|
||||
cachedLocale: "de",
|
||||
});
|
||||
await get("G06-other-language");
|
||||
});
|
||||
|
||||
it("G07 an unparseable cache row", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: "{not json",
|
||||
cachedAt: new Date(NOW.getTime() - HOUR),
|
||||
});
|
||||
await get("G07-unparseable");
|
||||
});
|
||||
|
||||
it("G08 a legacy payload, untagged", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: { summary: "Legacy", recommendations: ["Drink water."] },
|
||||
cachedAt: new Date(NOW.getTime() - HOUR),
|
||||
});
|
||||
await get("G08-legacy");
|
||||
});
|
||||
|
||||
it("G09 a failure newer than the last good briefing", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - 3 * HOUR)),
|
||||
cachedAt: new Date(NOW.getTime() - 3 * HOUR),
|
||||
cachedLocale: "en",
|
||||
markers: [
|
||||
{
|
||||
reason: "all-providers-failed",
|
||||
at: new Date(NOW.getTime() - HOUR),
|
||||
httpStatus: 429,
|
||||
},
|
||||
],
|
||||
});
|
||||
await get("G09-held-after-failure");
|
||||
});
|
||||
|
||||
it("G10 a failure with no good briefing, and an older superseded one", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
markers: [
|
||||
{ reason: "provider-error", at: new Date(NOW.getTime() - 5 * HOUR) },
|
||||
{ reason: "invalid-json", at: new Date(NOW.getTime() - HOUR) },
|
||||
],
|
||||
});
|
||||
await get("G10-failed-empty");
|
||||
});
|
||||
|
||||
it("G11 a briefing the grounding gate withheld", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: cached(null, new Date(NOW.getTime() - 2 * HOUR)),
|
||||
cachedAt: new Date(NOW.getTime() - 2 * HOUR),
|
||||
cachedLocale: "en",
|
||||
markers: [
|
||||
{ reason: "briefing-ungrounded", at: new Date(NOW.getTime() - HOUR) },
|
||||
],
|
||||
});
|
||||
await get("G11-withheld");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/insights/generate parity", () => {
|
||||
it("P01 an anonymous call", async () => {
|
||||
await post("P01-anonymous", { force: true }, { auth: anonymous });
|
||||
});
|
||||
|
||||
it("P02 the AI analysis opt-out", async () => {
|
||||
await post(
|
||||
"P02-opted-out",
|
||||
{ force: true },
|
||||
{ auth: optedOut, userId: OPTED_OUT_ID },
|
||||
);
|
||||
});
|
||||
|
||||
it("P03 a body over the cap", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
await post("P03-body-too-large", { pad: "x".repeat(17 * 1024) });
|
||||
});
|
||||
|
||||
it("P04 a cached briefing written today", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)),
|
||||
cachedAt: new Date(NOW.getTime() - HOUR),
|
||||
cachedLocale: "en",
|
||||
});
|
||||
await post("P04-cache-hit", {});
|
||||
});
|
||||
|
||||
it("P05 a cached briefing from yesterday, regenerated", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - 20 * HOUR)),
|
||||
cachedAt: new Date(NOW.getTime() - 2 * HOUR),
|
||||
cachedLocale: "en",
|
||||
});
|
||||
seams.script.push({ content: reply(GROUNDED_BRIEFING) });
|
||||
await post("P05-yesterday-regenerated", {});
|
||||
});
|
||||
|
||||
it("P06 a cache in another language, regenerated", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)),
|
||||
cachedAt: new Date(NOW.getTime() - HOUR),
|
||||
cachedLocale: "de",
|
||||
});
|
||||
seams.script.push({ content: reply(GROUNDED_BRIEFING) });
|
||||
await post("P06-other-language-regenerated", {});
|
||||
});
|
||||
|
||||
it("P07 a briefingless cache when the hour's quota is spent", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: cached(null, new Date(NOW.getTime() - HOUR)),
|
||||
cachedAt: new Date(NOW.getTime() - HOUR),
|
||||
cachedLocale: "en",
|
||||
quotaUsed: 10,
|
||||
});
|
||||
await post("P07-quota-cached-fallback", {});
|
||||
});
|
||||
|
||||
it("P08 a forced generation when the hour's quota is spent", async () => {
|
||||
await resetBriefing(OWNER_ID, { quotaUsed: 10 });
|
||||
await post("P08-quota-refused");
|
||||
});
|
||||
|
||||
it("P09 a key no resolver can open", async () => {
|
||||
await resetBriefing(UNREADABLE_KEY_ID);
|
||||
await post(
|
||||
"P09-unreadable-key",
|
||||
{ force: true },
|
||||
{ auth: unreadableKey, userId: UNREADABLE_KEY_ID },
|
||||
);
|
||||
});
|
||||
|
||||
it("P10 a fresh generation", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({ content: reply(GROUNDED_BRIEFING), tokensUsed: 2100 });
|
||||
await post("P10-generated");
|
||||
});
|
||||
|
||||
it("P11 a reply the strict schema accepts", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({
|
||||
content: reply(GROUNDED_BRIEFING, { disclaimer: "Consult your doctor." }),
|
||||
});
|
||||
await post("P11-strict-reply");
|
||||
});
|
||||
|
||||
it("P12 a generation in German with the comparison, about-me and short form", async () => {
|
||||
await prisma.user.update({
|
||||
where: { id: OWNER_ID },
|
||||
data: {
|
||||
locale: "de",
|
||||
aiUsageLevel: "FRUGAL",
|
||||
dashboardWidgetsJson: { comparisonBaseline: "lastMonth" },
|
||||
},
|
||||
});
|
||||
await prisma.allergy.create({
|
||||
data: { userId: OWNER_ID, substance: "Penicillin" },
|
||||
});
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({ content: reply(GROUNDED_BRIEFING) });
|
||||
await post("P12-generated-de-short");
|
||||
await prisma.allergy.deleteMany({ where: { userId: OWNER_ID } });
|
||||
await prisma.user.update({
|
||||
where: { id: OWNER_ID },
|
||||
data: { locale: "en", aiUsageLevel: null, dashboardWidgetsJson: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("P13 an oversized raw payload, downgraded once", async () => {
|
||||
await prisma.user.update({
|
||||
where: { id: OWNER_ID },
|
||||
data: { insightsPrivacyMode: "raw" },
|
||||
});
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.oversize.push(6_100_000);
|
||||
seams.script.push({ content: reply(GROUNDED_BRIEFING) });
|
||||
await post("P13-downgraded");
|
||||
});
|
||||
|
||||
it("P14 an oversized aggregate too, downgraded twice", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.oversize.push(6_100_000, 5_400_000);
|
||||
seams.script.push({ content: reply(GROUNDED_BRIEFING) });
|
||||
await post("P14-hard-downgraded");
|
||||
});
|
||||
|
||||
it("P15 oversized on every rung", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.oversize.push(6_100_000, 5_400_000, 5_200_000);
|
||||
await post("P15-too-large");
|
||||
await prisma.user.update({
|
||||
where: { id: OWNER_ID },
|
||||
data: { insightsPrivacyMode: "aggregated" },
|
||||
});
|
||||
});
|
||||
|
||||
it("P16 the day's token ceiling", async () => {
|
||||
await resetBriefing(OWNER_ID, { spentToday: 5_000_000 });
|
||||
await post("P16-budget-exceeded");
|
||||
});
|
||||
|
||||
it("P17 every provider refused the credential", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({
|
||||
error: () => new AllProvidersFailedError([hop(401), hop(403, 2)]),
|
||||
});
|
||||
await post("P17-chain-auth");
|
||||
});
|
||||
|
||||
it("P18 every provider rate-limited", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({ error: () => new AllProvidersFailedError([hop(429)]) });
|
||||
await post("P18-chain-rate-limited");
|
||||
});
|
||||
|
||||
it("P19 a provider in a 5xx", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({
|
||||
error: () => new AllProvidersFailedError([hop(401), hop(502, 2)]),
|
||||
});
|
||||
await post("P19-chain-5xx");
|
||||
});
|
||||
|
||||
it("P20 every provider unreachable", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({
|
||||
error: () => new AllProvidersFailedError([hop(null), hop(null, 2)]),
|
||||
});
|
||||
await post("P20-chain-transport");
|
||||
});
|
||||
|
||||
it("P21 a mixed chain failure", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({
|
||||
error: () => new AllProvidersFailedError([hop(404), hop(429, 2)]),
|
||||
});
|
||||
await post("P21-chain-mixed");
|
||||
});
|
||||
|
||||
it("P22 one provider error per status class", async () => {
|
||||
for (const status of [401, 429, 500, 400]) {
|
||||
it("G03 no cached briefing", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({ error: providerError(status) });
|
||||
await post(`P22-provider-${status}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("P23 a reply that is not JSON", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({ content: "Sure! Here is your briefing." });
|
||||
await post("P23-invalid-json");
|
||||
});
|
||||
|
||||
it("P24 a reply cut off at the token ceiling", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({ content: '{"summary": "Your', finishReason: "length" });
|
||||
await post("P24-truncated");
|
||||
});
|
||||
|
||||
it("P25 an ungrounded briefing the retry fixes", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push(
|
||||
{ content: reply(UNGROUNDED_BRIEFING) },
|
||||
{ content: reply(GROUNDED_BRIEFING), tokensUsed: 900 },
|
||||
);
|
||||
await post("P25-grounding-retry-kept");
|
||||
});
|
||||
|
||||
it("P26 still ungrounded, the previous briefing stands in", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: cached(PREVIOUS_BRIEFING, new Date(NOW.getTime() - 26 * HOUR)),
|
||||
cachedAt: new Date(NOW.getTime() - 26 * HOUR),
|
||||
cachedLocale: "en",
|
||||
await get("G03-empty");
|
||||
});
|
||||
seams.script.push(
|
||||
{ content: reply(UNGROUNDED_BRIEFING) },
|
||||
{ content: reply(UNGROUNDED_BRIEFING) },
|
||||
);
|
||||
await post("P26-grounding-previous");
|
||||
});
|
||||
|
||||
it("P27 still ungrounded, nothing to stand in", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push(
|
||||
{ content: reply(UNGROUNDED_BRIEFING) },
|
||||
{ content: reply(UNGROUNDED_BRIEFING) },
|
||||
);
|
||||
await post("P27-grounding-stripped");
|
||||
});
|
||||
it("G04 a briefing written today", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)),
|
||||
cachedAt: new Date(NOW.getTime() - HOUR),
|
||||
cachedLocale: "en",
|
||||
});
|
||||
await get("G04-fresh");
|
||||
});
|
||||
|
||||
it("P28 an ungrounded briefing whose retry is not JSON", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push(
|
||||
{ content: reply(UNGROUNDED_BRIEFING) },
|
||||
{ content: "not json" },
|
||||
);
|
||||
await post("P28-grounding-retry-unparseable");
|
||||
});
|
||||
it("G05 a briefing written yesterday", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - 20 * HOUR)),
|
||||
cachedAt: new Date(NOW.getTime() - 2 * HOUR),
|
||||
cachedLocale: "en",
|
||||
});
|
||||
await get("G05-stale");
|
||||
});
|
||||
|
||||
it("P29 a reply the outbound screen blocks", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({
|
||||
content: reply(GROUNDED_BRIEFING, {
|
||||
summary:
|
||||
"Your 10-year cardiovascular risk is elevated — consider stepping your dose up.",
|
||||
}),
|
||||
it("G06 a briefing in another language", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)),
|
||||
cachedAt: new Date(NOW.getTime() - HOUR),
|
||||
cachedLocale: "de",
|
||||
});
|
||||
await get("G06-other-language");
|
||||
});
|
||||
|
||||
it("G07 an unparseable cache row", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: "{not json",
|
||||
cachedAt: new Date(NOW.getTime() - HOUR),
|
||||
});
|
||||
await get("G07-unparseable");
|
||||
});
|
||||
|
||||
it("G08 a legacy payload, untagged", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: { summary: "Legacy", recommendations: ["Drink water."] },
|
||||
cachedAt: new Date(NOW.getTime() - HOUR),
|
||||
});
|
||||
await get("G08-legacy");
|
||||
});
|
||||
|
||||
it("G09 a failure newer than the last good briefing", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - 3 * HOUR)),
|
||||
cachedAt: new Date(NOW.getTime() - 3 * HOUR),
|
||||
cachedLocale: "en",
|
||||
markers: [
|
||||
{
|
||||
reason: "all-providers-failed",
|
||||
at: new Date(NOW.getTime() - HOUR),
|
||||
httpStatus: 429,
|
||||
},
|
||||
],
|
||||
});
|
||||
await get("G09-held-after-failure");
|
||||
});
|
||||
|
||||
it("G10 a failure with no good briefing, and an older superseded one", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
markers: [
|
||||
{ reason: "provider-error", at: new Date(NOW.getTime() - 5 * HOUR) },
|
||||
{ reason: "invalid-json", at: new Date(NOW.getTime() - HOUR) },
|
||||
],
|
||||
});
|
||||
await get("G10-failed-empty");
|
||||
});
|
||||
|
||||
it("G11 a briefing the grounding gate withheld", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: cached(null, new Date(NOW.getTime() - 2 * HOUR)),
|
||||
cachedAt: new Date(NOW.getTime() - 2 * HOUR),
|
||||
cachedLocale: "en",
|
||||
markers: [
|
||||
{ reason: "briefing-ungrounded", at: new Date(NOW.getTime() - HOUR) },
|
||||
],
|
||||
});
|
||||
await get("G11-withheld");
|
||||
});
|
||||
await post("P29-outbound-screened", { force: true });
|
||||
});
|
||||
|
||||
it("P30 the about-me text changing during the generation", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({
|
||||
content: reply(GROUNDED_BRIEFING),
|
||||
during: async () => {
|
||||
await prisma.allergy.create({
|
||||
data: { userId: OWNER_ID, substance: "Latex" },
|
||||
});
|
||||
},
|
||||
describe("POST /api/insights/generate parity", () => {
|
||||
it("P01 an anonymous call", async () => {
|
||||
await post("P01-anonymous", { force: true }, { auth: anonymous });
|
||||
});
|
||||
await post("P30-profile-scope-changed");
|
||||
await prisma.allergy.deleteMany({ where: { userId: OWNER_ID } });
|
||||
});
|
||||
|
||||
it("P31 the profile changing during the generation", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({
|
||||
content: reply(GROUNDED_BRIEFING),
|
||||
during: async () => {
|
||||
await prisma.user.update({
|
||||
where: { id: OWNER_ID },
|
||||
data: { displayName: "Renamed Person" },
|
||||
});
|
||||
},
|
||||
it("P02 the AI analysis opt-out", async () => {
|
||||
await post(
|
||||
"P02-opted-out",
|
||||
{ force: true },
|
||||
{ auth: optedOut, userId: OPTED_OUT_ID },
|
||||
);
|
||||
});
|
||||
await post("P31-scope-changed");
|
||||
await prisma.user.update({
|
||||
where: { id: OWNER_ID },
|
||||
data: { displayName: "Parity Person" },
|
||||
|
||||
it("P03 a body over the cap", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
await post("P03-body-too-large", { pad: "x".repeat(17 * 1024) });
|
||||
});
|
||||
|
||||
it("P04 a cached briefing written today", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)),
|
||||
cachedAt: new Date(NOW.getTime() - HOUR),
|
||||
cachedLocale: "en",
|
||||
});
|
||||
await post("P04-cache-hit", {});
|
||||
});
|
||||
|
||||
it("P05 a cached briefing from yesterday, regenerated", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - 20 * HOUR)),
|
||||
cachedAt: new Date(NOW.getTime() - 2 * HOUR),
|
||||
cachedLocale: "en",
|
||||
});
|
||||
seams.script.push({ content: reply(GROUNDED_BRIEFING) });
|
||||
await post("P05-yesterday-regenerated", {});
|
||||
});
|
||||
|
||||
it("P06 a cache in another language, regenerated", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: cached(GROUNDED_BRIEFING, new Date(NOW.getTime() - HOUR)),
|
||||
cachedAt: new Date(NOW.getTime() - HOUR),
|
||||
cachedLocale: "de",
|
||||
});
|
||||
seams.script.push({ content: reply(GROUNDED_BRIEFING) });
|
||||
await post("P06-other-language-regenerated", {});
|
||||
});
|
||||
|
||||
it("P07 a briefingless cache when the hour's quota is spent", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: cached(null, new Date(NOW.getTime() - HOUR)),
|
||||
cachedAt: new Date(NOW.getTime() - HOUR),
|
||||
cachedLocale: "en",
|
||||
quotaUsed: 10,
|
||||
});
|
||||
await post("P07-quota-cached-fallback", {});
|
||||
});
|
||||
|
||||
it("P08 a forced generation when the hour's quota is spent", async () => {
|
||||
await resetBriefing(OWNER_ID, { quotaUsed: 10 });
|
||||
await post("P08-quota-refused");
|
||||
});
|
||||
|
||||
it("P09 a key no resolver can open", async () => {
|
||||
await resetBriefing(UNREADABLE_KEY_ID);
|
||||
await post(
|
||||
"P09-unreadable-key",
|
||||
{ force: true },
|
||||
{ auth: unreadableKey, userId: UNREADABLE_KEY_ID },
|
||||
);
|
||||
});
|
||||
|
||||
it("P10 a fresh generation", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({
|
||||
content: reply(GROUNDED_BRIEFING),
|
||||
tokensUsed: 2100,
|
||||
});
|
||||
await post("P10-generated");
|
||||
});
|
||||
|
||||
it("P11 a reply the strict schema accepts", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({
|
||||
content: reply(GROUNDED_BRIEFING, {
|
||||
disclaimer: "Consult your doctor.",
|
||||
}),
|
||||
});
|
||||
await post("P11-strict-reply");
|
||||
});
|
||||
|
||||
it("P12 a generation in German with the comparison, about-me and short form", async () => {
|
||||
await prisma.user.update({
|
||||
where: { id: OWNER_ID },
|
||||
data: {
|
||||
locale: "de",
|
||||
aiUsageLevel: "FRUGAL",
|
||||
dashboardWidgetsJson: { comparisonBaseline: "lastMonth" },
|
||||
},
|
||||
});
|
||||
await prisma.allergy.create({
|
||||
data: { userId: OWNER_ID, substance: "Penicillin" },
|
||||
});
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({ content: reply(GROUNDED_BRIEFING) });
|
||||
await post("P12-generated-de-short");
|
||||
await prisma.allergy.deleteMany({ where: { userId: OWNER_ID } });
|
||||
await prisma.user.update({
|
||||
where: { id: OWNER_ID },
|
||||
data: { locale: "en", aiUsageLevel: null, dashboardWidgetsJson: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("P13 an oversized raw payload, downgraded once", async () => {
|
||||
await prisma.user.update({
|
||||
where: { id: OWNER_ID },
|
||||
data: { insightsPrivacyMode: "raw" },
|
||||
});
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.oversize.push(6_100_000);
|
||||
seams.script.push({ content: reply(GROUNDED_BRIEFING) });
|
||||
await post("P13-downgraded");
|
||||
});
|
||||
|
||||
it("P14 an oversized aggregate too, downgraded twice", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.oversize.push(6_100_000, 5_400_000);
|
||||
seams.script.push({ content: reply(GROUNDED_BRIEFING) });
|
||||
await post("P14-hard-downgraded");
|
||||
});
|
||||
|
||||
it("P15 oversized on every rung", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.oversize.push(6_100_000, 5_400_000, 5_200_000);
|
||||
await post("P15-too-large");
|
||||
await prisma.user.update({
|
||||
where: { id: OWNER_ID },
|
||||
data: { insightsPrivacyMode: "aggregated" },
|
||||
});
|
||||
});
|
||||
|
||||
it("P16 the day's token ceiling", async () => {
|
||||
await resetBriefing(OWNER_ID, { spentToday: 5_000_000 });
|
||||
await post("P16-budget-exceeded");
|
||||
});
|
||||
|
||||
it("P17 every provider refused the credential", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({
|
||||
error: () => new AllProvidersFailedError([hop(401), hop(403, 2)]),
|
||||
});
|
||||
await post("P17-chain-auth");
|
||||
});
|
||||
|
||||
it("P18 every provider rate-limited", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({
|
||||
error: () => new AllProvidersFailedError([hop(429)]),
|
||||
});
|
||||
await post("P18-chain-rate-limited");
|
||||
});
|
||||
|
||||
it("P19 a provider in a 5xx", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({
|
||||
error: () => new AllProvidersFailedError([hop(401), hop(502, 2)]),
|
||||
});
|
||||
await post("P19-chain-5xx");
|
||||
});
|
||||
|
||||
it("P20 every provider unreachable", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({
|
||||
error: () => new AllProvidersFailedError([hop(null), hop(null, 2)]),
|
||||
});
|
||||
await post("P20-chain-transport");
|
||||
});
|
||||
|
||||
it("P21 a mixed chain failure", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({
|
||||
error: () => new AllProvidersFailedError([hop(404), hop(429, 2)]),
|
||||
});
|
||||
await post("P21-chain-mixed");
|
||||
});
|
||||
|
||||
it("P22 one provider error per status class", async () => {
|
||||
for (const status of [401, 429, 500, 400]) {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({ error: providerError(status) });
|
||||
await post(`P22-provider-${status}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("P23 a reply that is not JSON", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({ content: "Sure! Here is your briefing." });
|
||||
await post("P23-invalid-json");
|
||||
});
|
||||
|
||||
it("P24 a reply cut off at the token ceiling", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({
|
||||
content: '{"summary": "Your',
|
||||
finishReason: "length",
|
||||
});
|
||||
await post("P24-truncated");
|
||||
});
|
||||
|
||||
it("P25 an ungrounded briefing the retry fixes", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push(
|
||||
{ content: reply(UNGROUNDED_BRIEFING) },
|
||||
{ content: reply(GROUNDED_BRIEFING), tokensUsed: 900 },
|
||||
);
|
||||
await post("P25-grounding-retry-kept");
|
||||
});
|
||||
|
||||
it("P26 still ungrounded, the previous briefing stands in", async () => {
|
||||
await resetBriefing(OWNER_ID, {
|
||||
text: cached(PREVIOUS_BRIEFING, new Date(NOW.getTime() - 26 * HOUR)),
|
||||
cachedAt: new Date(NOW.getTime() - 26 * HOUR),
|
||||
cachedLocale: "en",
|
||||
});
|
||||
seams.script.push(
|
||||
{ content: reply(UNGROUNDED_BRIEFING) },
|
||||
{ content: reply(UNGROUNDED_BRIEFING) },
|
||||
);
|
||||
await post("P26-grounding-previous");
|
||||
});
|
||||
|
||||
it("P27 still ungrounded, nothing to stand in", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push(
|
||||
{ content: reply(UNGROUNDED_BRIEFING) },
|
||||
{ content: reply(UNGROUNDED_BRIEFING) },
|
||||
);
|
||||
await post("P27-grounding-stripped");
|
||||
});
|
||||
|
||||
it("P28 an ungrounded briefing whose retry is not JSON", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push(
|
||||
{ content: reply(UNGROUNDED_BRIEFING) },
|
||||
{ content: "not json" },
|
||||
);
|
||||
await post("P28-grounding-retry-unparseable");
|
||||
});
|
||||
|
||||
it("P29 a reply the outbound screen blocks", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({
|
||||
content: reply(GROUNDED_BRIEFING, {
|
||||
summary:
|
||||
"Your 10-year cardiovascular risk is elevated — consider stepping your dose up.",
|
||||
}),
|
||||
});
|
||||
await post("P29-outbound-screened", { force: true });
|
||||
});
|
||||
|
||||
it("P30 the about-me text changing during the generation", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({
|
||||
content: reply(GROUNDED_BRIEFING),
|
||||
during: async () => {
|
||||
await prisma.allergy.create({
|
||||
data: { userId: OWNER_ID, substance: "Latex" },
|
||||
});
|
||||
},
|
||||
});
|
||||
await post("P30-profile-scope-changed");
|
||||
await prisma.allergy.deleteMany({ where: { userId: OWNER_ID } });
|
||||
});
|
||||
|
||||
it("P31 the profile changing during the generation", async () => {
|
||||
await resetBriefing(OWNER_ID);
|
||||
seams.script.push({
|
||||
content: reply(GROUNDED_BRIEFING),
|
||||
during: async () => {
|
||||
await prisma.user.update({
|
||||
where: { id: OWNER_ID },
|
||||
data: { displayName: "Renamed Person" },
|
||||
});
|
||||
},
|
||||
});
|
||||
await post("P31-scope-changed");
|
||||
await prisma.user.update({
|
||||
where: { id: OWNER_ID },
|
||||
data: { displayName: "Parity Person" },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -239,7 +239,7 @@ beforeAll(async () => {
|
||||
delegate = { auth: session };
|
||||
});
|
||||
|
||||
describe("GET /api/measurements/series parity", () => {
|
||||
describe("GET /api/measurements/series parity", { shuffle: false }, () => {
|
||||
it("the plain readers", async () => {
|
||||
await step("01-weight", { kind: "weight" });
|
||||
await step("02-steps", { kind: "steps", days: 14 });
|
||||
|
||||
@@ -327,7 +327,7 @@ afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("POST /api/measurements/batch parity", () => {
|
||||
describe("POST /api/measurements/batch parity", { shuffle: false }, () => {
|
||||
it("01 a first sync of mixed entries", async () => {
|
||||
await step("01-first-sync", {
|
||||
entries: FIRST_SYNC,
|
||||
|
||||
@@ -220,185 +220,192 @@ afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("POST /api/measurements parity", () => {
|
||||
it("a single reading, its duplicate, a refused one", async () => {
|
||||
const weight = {
|
||||
type: "WEIGHT",
|
||||
value: 80.4,
|
||||
measuredAt: "2026-10-14T06:30:00.000Z",
|
||||
notes: "after the run",
|
||||
deviceType: "scale",
|
||||
};
|
||||
await post("post-01-single", weight);
|
||||
await post("post-02-single-duplicate", weight);
|
||||
await post("post-03-single-invalid", {
|
||||
type: "WEIGHT",
|
||||
value: "heavy",
|
||||
measuredAt: "2026-10-14T06:30:00.000Z",
|
||||
});
|
||||
await post("post-04-single-glucose", {
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 104,
|
||||
measuredAt: "2026-10-14T07:00:00.000Z",
|
||||
glucoseContext: "FASTING",
|
||||
symptomsPresent: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("a combined two-value reading, its duplicate, a refused one", async () => {
|
||||
const reading = [
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: 128,
|
||||
measuredAt: "2026-10-14T08:00:00.000Z",
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 82,
|
||||
measuredAt: "2026-10-14T08:00:00.000Z",
|
||||
},
|
||||
{
|
||||
type: "PULSE",
|
||||
value: 66,
|
||||
measuredAt: "2026-10-14T08:00:00.000Z",
|
||||
notes: "seated",
|
||||
symptomsPresent: true,
|
||||
},
|
||||
];
|
||||
await post("post-05-batch", reading);
|
||||
await post("post-06-batch-duplicate", reading);
|
||||
await post("post-07-batch-invalid", [{ type: "PULSE", value: -1 }]);
|
||||
});
|
||||
|
||||
it("a named sleep-stage segment, its re-post and its mirror", async () => {
|
||||
const segment = {
|
||||
type: "SLEEP_DURATION",
|
||||
sleepStage: "DEEP",
|
||||
startDate: "2026-10-14T01:00:00.000Z",
|
||||
endDate: "2026-10-14T02:30:00.000Z",
|
||||
externalId: "bridge-sleep-1",
|
||||
};
|
||||
await post("post-08-sleep-stage", segment);
|
||||
await post("post-09-sleep-stage-again", segment);
|
||||
await post("post-10-sleep-stage-mirror", {
|
||||
...segment,
|
||||
externalId: "hk-sleep-1",
|
||||
source: "APPLE_HEALTH",
|
||||
});
|
||||
await post("post-11-sleep-stage-invalid", {
|
||||
type: "SLEEP_DURATION",
|
||||
sleepStage: "DEEP",
|
||||
});
|
||||
});
|
||||
|
||||
it("a narrow bridge token with and without a source", async () => {
|
||||
const bridge = await makeToken(
|
||||
{ id: ownerId },
|
||||
{ permissions: [MEASUREMENTS_WRITE_SCOPE] },
|
||||
);
|
||||
await post(
|
||||
"post-12-bridge-with-source",
|
||||
{
|
||||
// One walk: each request sees what the earlier ones stored, so the cases
|
||||
// keep their order even when a run shuffles (`shuffle: false`).
|
||||
describe("measurements parity walk", { shuffle: false }, () => {
|
||||
describe("POST /api/measurements parity", () => {
|
||||
it("a single reading, its duplicate, a refused one", async () => {
|
||||
const weight = {
|
||||
type: "WEIGHT",
|
||||
value: 80.1,
|
||||
measuredAt: "2026-10-15T06:30:00.000Z",
|
||||
source: "MANUAL",
|
||||
},
|
||||
bridge,
|
||||
);
|
||||
await post(
|
||||
"post-13-bridge",
|
||||
{
|
||||
value: 80.4,
|
||||
measuredAt: "2026-10-14T06:30:00.000Z",
|
||||
notes: "after the run",
|
||||
deviceType: "scale",
|
||||
};
|
||||
await post("post-01-single", weight);
|
||||
await post("post-02-single-duplicate", weight);
|
||||
await post("post-03-single-invalid", {
|
||||
type: "WEIGHT",
|
||||
value: 80.1,
|
||||
measuredAt: "2026-10-15T06:30:00.000Z",
|
||||
},
|
||||
bridge,
|
||||
);
|
||||
});
|
||||
|
||||
it("a delegate writing into a shared record", async () => {
|
||||
const delegateUser = await makeUser({ timezone: "UTC" });
|
||||
await makeGrant({ id: ownerId }, delegateUser, { access: "WRITE" });
|
||||
const delegate = await makeSession(delegateUser);
|
||||
await switchSessionTo(delegate.sessionId, ownerId);
|
||||
await post(
|
||||
"post-14-delegated",
|
||||
{
|
||||
type: "PULSE",
|
||||
value: 71,
|
||||
measuredAt: "2026-10-15T07:00:00.000Z",
|
||||
},
|
||||
delegate,
|
||||
);
|
||||
await switchSessionTo(delegate.sessionId, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/measurements parity", () => {
|
||||
beforeAll(async () => {
|
||||
// Day totals for the cumulative list modes, from the phone.
|
||||
for (const [day, value] of [
|
||||
["2026-10-12", 7_100],
|
||||
["2026-10-13", 8_400],
|
||||
["2026-10-14", 5_100],
|
||||
] as const) {
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: ownerId,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date(`${day}T12:00:00.000Z`),
|
||||
externalId: `stats:${STEPS}:${day}`,
|
||||
aggregationProvenance: "HEALTHKIT_STATISTICS",
|
||||
},
|
||||
value: "heavy",
|
||||
measuredAt: "2026-10-14T06:30:00.000Z",
|
||||
});
|
||||
await post("post-04-single-glucose", {
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 104,
|
||||
measuredAt: "2026-10-14T07:00:00.000Z",
|
||||
glucoseContext: "FASTING",
|
||||
symptomsPresent: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("the plain page and its filters", async () => {
|
||||
await get("get-01-page", { limit: 50 });
|
||||
await get("get-02-type-sorted", {
|
||||
type: "PULSE",
|
||||
sortBy: "value",
|
||||
sortDir: "asc",
|
||||
});
|
||||
await get("get-03-filtered", {
|
||||
sourceEq: "MANUAL",
|
||||
valueMin: 70,
|
||||
valueMax: 130,
|
||||
from: "2026-10-14T00:00:00.000Z",
|
||||
to: "2026-10-15T23:59:59.000Z",
|
||||
limit: 2,
|
||||
offset: 1,
|
||||
|
||||
it("a combined two-value reading, its duplicate, a refused one", async () => {
|
||||
const reading = [
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: 128,
|
||||
measuredAt: "2026-10-14T08:00:00.000Z",
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 82,
|
||||
measuredAt: "2026-10-14T08:00:00.000Z",
|
||||
},
|
||||
{
|
||||
type: "PULSE",
|
||||
value: 66,
|
||||
measuredAt: "2026-10-14T08:00:00.000Z",
|
||||
notes: "seated",
|
||||
symptomsPresent: true,
|
||||
},
|
||||
];
|
||||
await post("post-05-batch", reading);
|
||||
await post("post-06-batch-duplicate", reading);
|
||||
await post("post-07-batch-invalid", [{ type: "PULSE", value: -1 }]);
|
||||
});
|
||||
|
||||
it("a named sleep-stage segment, its re-post and its mirror", async () => {
|
||||
const segment = {
|
||||
type: "SLEEP_DURATION",
|
||||
sleepStage: "DEEP",
|
||||
startDate: "2026-10-14T01:00:00.000Z",
|
||||
endDate: "2026-10-14T02:30:00.000Z",
|
||||
externalId: "bridge-sleep-1",
|
||||
};
|
||||
await post("post-08-sleep-stage", segment);
|
||||
await post("post-09-sleep-stage-again", segment);
|
||||
await post("post-10-sleep-stage-mirror", {
|
||||
...segment,
|
||||
externalId: "hk-sleep-1",
|
||||
source: "APPLE_HEALTH",
|
||||
});
|
||||
await post("post-11-sleep-stage-invalid", {
|
||||
type: "SLEEP_DURATION",
|
||||
sleepStage: "DEEP",
|
||||
});
|
||||
});
|
||||
|
||||
it("a narrow bridge token with and without a source", async () => {
|
||||
const bridge = await makeToken(
|
||||
{ id: ownerId },
|
||||
{ permissions: [MEASUREMENTS_WRITE_SCOPE] },
|
||||
);
|
||||
await post(
|
||||
"post-12-bridge-with-source",
|
||||
{
|
||||
type: "WEIGHT",
|
||||
value: 80.1,
|
||||
measuredAt: "2026-10-15T06:30:00.000Z",
|
||||
source: "MANUAL",
|
||||
},
|
||||
bridge,
|
||||
);
|
||||
await post(
|
||||
"post-13-bridge",
|
||||
{
|
||||
type: "WEIGHT",
|
||||
value: 80.1,
|
||||
measuredAt: "2026-10-15T06:30:00.000Z",
|
||||
},
|
||||
bridge,
|
||||
);
|
||||
});
|
||||
|
||||
it("a delegate writing into a shared record", async () => {
|
||||
const delegateUser = await makeUser({ timezone: "UTC" });
|
||||
await makeGrant({ id: ownerId }, delegateUser, { access: "WRITE" });
|
||||
const delegate = await makeSession(delegateUser);
|
||||
await switchSessionTo(delegate.sessionId, ownerId);
|
||||
await post(
|
||||
"post-14-delegated",
|
||||
{
|
||||
type: "PULSE",
|
||||
value: 71,
|
||||
measuredAt: "2026-10-15T07:00:00.000Z",
|
||||
},
|
||||
delegate,
|
||||
);
|
||||
await switchSessionTo(delegate.sessionId, null);
|
||||
});
|
||||
});
|
||||
|
||||
it("the list modes", async () => {
|
||||
await get("get-04-sleep", { type: "SLEEP_DURATION" });
|
||||
await get("get-05-drill-down", {
|
||||
type: "ACTIVITY_STEPS",
|
||||
dayKey: "2026-10-13",
|
||||
describe("GET /api/measurements parity", () => {
|
||||
beforeAll(async () => {
|
||||
// Day totals for the cumulative list modes, from the phone.
|
||||
for (const [day, value] of [
|
||||
["2026-10-12", 7_100],
|
||||
["2026-10-13", 8_400],
|
||||
["2026-10-14", 5_100],
|
||||
] as const) {
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: ownerId,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date(`${day}T12:00:00.000Z`),
|
||||
externalId: `stats:${STEPS}:${day}`,
|
||||
aggregationProvenance: "HEALTHKIT_STATISTICS",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
await get("get-06-day-grouped", { type: "ACTIVITY_STEPS", groupBy: "day" });
|
||||
await get("get-07-rollup-series", {
|
||||
type: "ACTIVITY_STEPS",
|
||||
source: "rollup",
|
||||
aggregate: "daily",
|
||||
from: "2026-10-10T00:00:00.000Z",
|
||||
to: "2026-10-15T23:59:59.000Z",
|
||||
});
|
||||
await get("get-08-live-aggregate", {
|
||||
type: "PULSE",
|
||||
aggregate: "weekly",
|
||||
from: "2026-09-01T00:00:00.000Z",
|
||||
to: "2026-10-15T23:59:59.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("a refused query", async () => {
|
||||
await get("get-09-invalid", { limit: 0, sortDir: "sideways" });
|
||||
it("the plain page and its filters", async () => {
|
||||
await get("get-01-page", { limit: 50 });
|
||||
await get("get-02-type-sorted", {
|
||||
type: "PULSE",
|
||||
sortBy: "value",
|
||||
sortDir: "asc",
|
||||
});
|
||||
await get("get-03-filtered", {
|
||||
sourceEq: "MANUAL",
|
||||
valueMin: 70,
|
||||
valueMax: 130,
|
||||
from: "2026-10-14T00:00:00.000Z",
|
||||
to: "2026-10-15T23:59:59.000Z",
|
||||
limit: 2,
|
||||
offset: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("the list modes", async () => {
|
||||
await get("get-04-sleep", { type: "SLEEP_DURATION" });
|
||||
await get("get-05-drill-down", {
|
||||
type: "ACTIVITY_STEPS",
|
||||
dayKey: "2026-10-13",
|
||||
});
|
||||
await get("get-06-day-grouped", {
|
||||
type: "ACTIVITY_STEPS",
|
||||
groupBy: "day",
|
||||
});
|
||||
await get("get-07-rollup-series", {
|
||||
type: "ACTIVITY_STEPS",
|
||||
source: "rollup",
|
||||
aggregate: "daily",
|
||||
from: "2026-10-10T00:00:00.000Z",
|
||||
to: "2026-10-15T23:59:59.000Z",
|
||||
});
|
||||
await get("get-08-live-aggregate", {
|
||||
type: "PULSE",
|
||||
aggregate: "weekly",
|
||||
from: "2026-09-01T00:00:00.000Z",
|
||||
to: "2026-10-15T23:59:59.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("a refused query", async () => {
|
||||
await get("get-09-invalid", { limit: 0, sortDir: "sideways" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -358,212 +358,216 @@ afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("GET /api/medications/{id} parity", () => {
|
||||
it("01 a rolling injection with stock, a composition and a custom category", async () => {
|
||||
await get("01-rolling", med.rolling);
|
||||
// One walk: each request sees what the earlier ones stored, so the cases
|
||||
// keep their order even when a run shuffles (`shuffle: false`).
|
||||
describe("medication-detail parity walk", { shuffle: false }, () => {
|
||||
describe("GET /api/medications/{id} parity", () => {
|
||||
it("01 a rolling injection with stock, a composition and a custom category", async () => {
|
||||
await get("01-rolling", med.rolling);
|
||||
});
|
||||
|
||||
it("02 a daily tablet without stock", async () => {
|
||||
await get("02-daily", med.tablet);
|
||||
});
|
||||
|
||||
it("03 a record-only medication", async () => {
|
||||
await get("03-record-only", med.recordOnly);
|
||||
});
|
||||
|
||||
it("04 another account's medication", async () => {
|
||||
await get("04-not-found", med.foreign);
|
||||
});
|
||||
|
||||
it("05 a reading delegate, and nobody", async () => {
|
||||
await get("05a-reader", med.rolling, reader);
|
||||
await get("05b-anonymous", med.rolling, { auth: anonymous });
|
||||
});
|
||||
});
|
||||
|
||||
it("02 a daily tablet without stock", async () => {
|
||||
await get("02-daily", med.tablet);
|
||||
describe("PUT /api/medications/{id} parity", () => {
|
||||
it("11 plain fields", async () => {
|
||||
await put("11-plain-fields", med.tablet, {
|
||||
name: "Daily tablet renamed",
|
||||
dose: "20mg",
|
||||
treatmentClass: "GENERIC",
|
||||
dosesPerUnit: 30,
|
||||
reorderLeadDays: 5,
|
||||
deliveryForm: "ORAL",
|
||||
notificationsEnabled: false,
|
||||
liveActivityEnabled: true,
|
||||
criticalAlarmEnabled: true,
|
||||
atcCode: "C09AA05",
|
||||
rxNormCode: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("12 a schedule replace whose dose time leaves the stale window", async () => {
|
||||
await put("12-schedule-replace", med.tablet, {
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "10:00",
|
||||
timesOfDay: ["11:30"],
|
||||
label: "Late morning",
|
||||
dose: "10mg",
|
||||
unitsPerDose: 2,
|
||||
reminderGraceMinutes: 30,
|
||||
},
|
||||
{
|
||||
windowStart: "20:00",
|
||||
windowEnd: "22:00",
|
||||
daysOfWeek: [1, 3, 5],
|
||||
intervalWeeks: 2,
|
||||
doseWindows: [{ timeOfDay: "20:00", start: "19:30", end: "21:00" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("13 the same schedule echoed back", async () => {
|
||||
await put("13-schedule-echo", med.tablet, {
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "11:30",
|
||||
windowEnd: "11:30",
|
||||
timesOfDay: ["11:30"],
|
||||
label: "Late morning",
|
||||
dose: "10mg",
|
||||
unitsPerDose: 2,
|
||||
reminderGraceMinutes: 30,
|
||||
rrule: "FREQ=DAILY",
|
||||
},
|
||||
{
|
||||
windowStart: "20:00",
|
||||
windowEnd: "22:00",
|
||||
daysOfWeek: [1, 3, 5],
|
||||
intervalWeeks: 2,
|
||||
doseWindows: [{ timeOfDay: "20:00", start: "19:30", end: "21:00" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("14 the top-level reminder window", async () => {
|
||||
await put("14-grace-bridge", med.tablet, { reminderGraceMinutes: 45 });
|
||||
});
|
||||
|
||||
it("15 a pause and a resume", async () => {
|
||||
await put("15a-pause", med.tablet, { active: false });
|
||||
await put("15b-resume", med.tablet, { active: true });
|
||||
});
|
||||
|
||||
it("16 automatic recording switched on", async () => {
|
||||
await put("16-auto-mark-taken", med.tablet, { autoMarkTaken: true });
|
||||
});
|
||||
|
||||
it("17 the category writes", async () => {
|
||||
await put("17a-custom-category", med.tablet, { category: CUSTOM_KEY });
|
||||
await put(
|
||||
"17b-native-other-kept",
|
||||
med.tablet,
|
||||
{ category: "OTHER" },
|
||||
{ auth: ownerBearer },
|
||||
);
|
||||
await put("17c-web-other", med.tablet, { category: "OTHER" });
|
||||
await put("17d-unknown-category", med.tablet, {
|
||||
category: "custom:0a6c2d4e-1111-4222-8333-944455556666",
|
||||
});
|
||||
});
|
||||
|
||||
it("18 a record-only medication and both tracking transitions", async () => {
|
||||
await put("18a-record-schedule-kept", med.recordOnly, {
|
||||
name: "Record only renamed",
|
||||
schedules: [daily("06:00", "07:00")],
|
||||
oneShot: false,
|
||||
});
|
||||
await put("18b-tracking-on", med.recordOnly, { trackIntake: true });
|
||||
await put("18c-tracking-off", med.recordOnly, { trackIntake: false });
|
||||
});
|
||||
|
||||
it("19 the as-needed flips and their refusals", async () => {
|
||||
await put("19a-scheduled-without-schedules", med.asNeeded, {
|
||||
asNeeded: false,
|
||||
});
|
||||
await put("19b-scheduled", med.asNeeded, {
|
||||
asNeeded: false,
|
||||
schedules: [daily("08:00", "09:00")],
|
||||
});
|
||||
await put("19c-as-needed-keeping-schedules", med.asNeeded, {
|
||||
asNeeded: true,
|
||||
});
|
||||
await put("19d-as-needed", med.asNeeded, {
|
||||
asNeeded: true,
|
||||
schedules: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("20 the one-shot refusals and a conversion", async () => {
|
||||
await put("20a-one-shot-two-schedules", med.booster, {
|
||||
oneShot: true,
|
||||
startsOn: "2026-10-20",
|
||||
schedules: [daily("09:00", "10:00"), daily("18:00", "19:00")],
|
||||
});
|
||||
await put("20b-one-shot-recurrence", med.booster, {
|
||||
oneShot: true,
|
||||
startsOn: "2026-10-20",
|
||||
schedules: [
|
||||
{ windowStart: "09:00", windowEnd: "10:00", rrule: "FREQ=DAILY" },
|
||||
],
|
||||
});
|
||||
await put("20c-one-shot", med.booster, {
|
||||
oneShot: true,
|
||||
startsOn: "2026-10-20",
|
||||
schedules: [{ windowStart: "09:00", windowEnd: "10:00" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("21 the course refusals and a course edit", async () => {
|
||||
await put("21a-course-overlap", med.course, { startsOn: "2026-08-20" });
|
||||
await put("21b-course-window-required", med.course, {
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
});
|
||||
await put("21c-course-end", med.course, { endsOn: "2026-12-31" });
|
||||
});
|
||||
|
||||
it("22 a units change on a medication with a composition", async () => {
|
||||
await put("22-composition-units", med.rolling, { unitsPerDose: 2 });
|
||||
});
|
||||
|
||||
it("23 a body the schema refuses, and another account's medication", async () => {
|
||||
await put("23a-invalid", med.tablet, {
|
||||
name: "",
|
||||
unitsPerDose: -1,
|
||||
schedules: "daily",
|
||||
});
|
||||
await put("23b-not-found", med.foreign, { name: "Taken over" });
|
||||
});
|
||||
|
||||
it("24 a delegate who may write, one who may manage, and nobody", async () => {
|
||||
await put("24a-writer", med.tablet, { name: "By a writer" }, writer);
|
||||
await put("24b-manager", med.tablet, { name: "By a manager" }, manager);
|
||||
await put(
|
||||
"24c-anonymous",
|
||||
med.tablet,
|
||||
{ name: "By nobody" },
|
||||
{ auth: anonymous },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("03 a record-only medication", async () => {
|
||||
await get("03-record-only", med.recordOnly);
|
||||
});
|
||||
describe("DELETE /api/medications/{id} parity", () => {
|
||||
it("31 the owner deletes a medication with a composition and an ingest token", async () => {
|
||||
await del("31-delete", med.doomed);
|
||||
});
|
||||
|
||||
it("04 another account's medication", async () => {
|
||||
await get("04-not-found", med.foreign);
|
||||
});
|
||||
it("32 the same medication again", async () => {
|
||||
await del("32-not-found", med.doomed);
|
||||
});
|
||||
|
||||
it("05 a reading delegate, and nobody", async () => {
|
||||
await get("05a-reader", med.rolling, reader);
|
||||
await get("05b-anonymous", med.rolling, { auth: anonymous });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/{id} parity", () => {
|
||||
it("11 plain fields", async () => {
|
||||
await put("11-plain-fields", med.tablet, {
|
||||
name: "Daily tablet renamed",
|
||||
dose: "20mg",
|
||||
treatmentClass: "GENERIC",
|
||||
dosesPerUnit: 30,
|
||||
reorderLeadDays: 5,
|
||||
deliveryForm: "ORAL",
|
||||
notificationsEnabled: false,
|
||||
liveActivityEnabled: true,
|
||||
criticalAlarmEnabled: true,
|
||||
atcCode: "C09AA05",
|
||||
rxNormCode: null,
|
||||
it("33 a managing delegate, and nobody", async () => {
|
||||
await del("33a-manager", med.booster, manager);
|
||||
await del("33b-anonymous", med.booster, { auth: anonymous });
|
||||
});
|
||||
});
|
||||
|
||||
it("12 a schedule replace whose dose time leaves the stale window", async () => {
|
||||
await put("12-schedule-replace", med.tablet, {
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "10:00",
|
||||
timesOfDay: ["11:30"],
|
||||
label: "Late morning",
|
||||
dose: "10mg",
|
||||
unitsPerDose: 2,
|
||||
reminderGraceMinutes: 30,
|
||||
},
|
||||
{
|
||||
windowStart: "20:00",
|
||||
windowEnd: "22:00",
|
||||
daysOfWeek: [1, 3, 5],
|
||||
intervalWeeks: 2,
|
||||
doseWindows: [{ timeOfDay: "20:00", start: "19:30", end: "21:00" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("13 the same schedule echoed back", async () => {
|
||||
await put("13-schedule-echo", med.tablet, {
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "11:30",
|
||||
windowEnd: "11:30",
|
||||
timesOfDay: ["11:30"],
|
||||
label: "Late morning",
|
||||
dose: "10mg",
|
||||
unitsPerDose: 2,
|
||||
reminderGraceMinutes: 30,
|
||||
rrule: "FREQ=DAILY",
|
||||
},
|
||||
{
|
||||
windowStart: "20:00",
|
||||
windowEnd: "22:00",
|
||||
daysOfWeek: [1, 3, 5],
|
||||
intervalWeeks: 2,
|
||||
doseWindows: [{ timeOfDay: "20:00", start: "19:30", end: "21:00" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("14 the top-level reminder window", async () => {
|
||||
await put("14-grace-bridge", med.tablet, { reminderGraceMinutes: 45 });
|
||||
});
|
||||
|
||||
it("15 a pause and a resume", async () => {
|
||||
await put("15a-pause", med.tablet, { active: false });
|
||||
await put("15b-resume", med.tablet, { active: true });
|
||||
});
|
||||
|
||||
it("16 automatic recording switched on", async () => {
|
||||
await put("16-auto-mark-taken", med.tablet, { autoMarkTaken: true });
|
||||
});
|
||||
|
||||
it("17 the category writes", async () => {
|
||||
await put("17a-custom-category", med.tablet, { category: CUSTOM_KEY });
|
||||
await put(
|
||||
"17b-native-other-kept",
|
||||
med.tablet,
|
||||
{ category: "OTHER" },
|
||||
{ auth: ownerBearer },
|
||||
);
|
||||
await put("17c-web-other", med.tablet, { category: "OTHER" });
|
||||
await put("17d-unknown-category", med.tablet, {
|
||||
category: "custom:0a6c2d4e-1111-4222-8333-944455556666",
|
||||
});
|
||||
});
|
||||
|
||||
it("18 a record-only medication and both tracking transitions", async () => {
|
||||
await put("18a-record-schedule-kept", med.recordOnly, {
|
||||
name: "Record only renamed",
|
||||
schedules: [daily("06:00", "07:00")],
|
||||
oneShot: false,
|
||||
});
|
||||
await put("18b-tracking-on", med.recordOnly, { trackIntake: true });
|
||||
await put("18c-tracking-off", med.recordOnly, { trackIntake: false });
|
||||
});
|
||||
|
||||
it("19 the as-needed flips and their refusals", async () => {
|
||||
await put("19a-scheduled-without-schedules", med.asNeeded, {
|
||||
asNeeded: false,
|
||||
});
|
||||
await put("19b-scheduled", med.asNeeded, {
|
||||
asNeeded: false,
|
||||
schedules: [daily("08:00", "09:00")],
|
||||
});
|
||||
await put("19c-as-needed-keeping-schedules", med.asNeeded, {
|
||||
asNeeded: true,
|
||||
});
|
||||
await put("19d-as-needed", med.asNeeded, {
|
||||
asNeeded: true,
|
||||
schedules: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("20 the one-shot refusals and a conversion", async () => {
|
||||
await put("20a-one-shot-two-schedules", med.booster, {
|
||||
oneShot: true,
|
||||
startsOn: "2026-10-20",
|
||||
schedules: [daily("09:00", "10:00"), daily("18:00", "19:00")],
|
||||
});
|
||||
await put("20b-one-shot-recurrence", med.booster, {
|
||||
oneShot: true,
|
||||
startsOn: "2026-10-20",
|
||||
schedules: [
|
||||
{ windowStart: "09:00", windowEnd: "10:00", rrule: "FREQ=DAILY" },
|
||||
],
|
||||
});
|
||||
await put("20c-one-shot", med.booster, {
|
||||
oneShot: true,
|
||||
startsOn: "2026-10-20",
|
||||
schedules: [{ windowStart: "09:00", windowEnd: "10:00" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("21 the course refusals and a course edit", async () => {
|
||||
await put("21a-course-overlap", med.course, { startsOn: "2026-08-20" });
|
||||
await put("21b-course-window-required", med.course, {
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
});
|
||||
await put("21c-course-end", med.course, { endsOn: "2026-12-31" });
|
||||
});
|
||||
|
||||
it("22 a units change on a medication with a composition", async () => {
|
||||
await put("22-composition-units", med.rolling, { unitsPerDose: 2 });
|
||||
});
|
||||
|
||||
it("23 a body the schema refuses, and another account's medication", async () => {
|
||||
await put("23a-invalid", med.tablet, {
|
||||
name: "",
|
||||
unitsPerDose: -1,
|
||||
schedules: "daily",
|
||||
});
|
||||
await put("23b-not-found", med.foreign, { name: "Taken over" });
|
||||
});
|
||||
|
||||
it("24 a delegate who may write, one who may manage, and nobody", async () => {
|
||||
await put("24a-writer", med.tablet, { name: "By a writer" }, writer);
|
||||
await put("24b-manager", med.tablet, { name: "By a manager" }, manager);
|
||||
await put(
|
||||
"24c-anonymous",
|
||||
med.tablet,
|
||||
{ name: "By nobody" },
|
||||
{ auth: anonymous },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/medications/{id} parity", () => {
|
||||
it("31 the owner deletes a medication with a composition and an ingest token", async () => {
|
||||
await del("31-delete", med.doomed);
|
||||
});
|
||||
|
||||
it("32 the same medication again", async () => {
|
||||
await del("32-not-found", med.doomed);
|
||||
});
|
||||
|
||||
it("33 a managing delegate, and nobody", async () => {
|
||||
await del("33a-manager", med.booster, manager);
|
||||
await del("33b-anonymous", med.booster, { auth: anonymous });
|
||||
});
|
||||
});
|
||||
@@ -375,7 +375,7 @@ afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("POST /api/medications/intake/bulk parity", () => {
|
||||
describe("POST /api/medications/intake/bulk parity", { shuffle: false }, () => {
|
||||
it("01 a mixed first batch", async () => {
|
||||
await post("01-mixed", mixedBatch());
|
||||
});
|
||||
|
||||
@@ -229,131 +229,135 @@ afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/{id}/intake/{eventId} parity", () => {
|
||||
it("01 a pending dose taken inside its own window", async () => {
|
||||
await put("01-take-in-place", events.pending, {
|
||||
takenAt: "2026-10-13T08:40:00.000Z",
|
||||
// One walk: each request sees what the earlier ones stored, so the cases
|
||||
// keep their order even when a run shuffles (`shuffle: false`).
|
||||
describe("medication-intake-event parity walk", { shuffle: false }, () => {
|
||||
describe("PUT /api/medications/{id}/intake/{eventId} parity", () => {
|
||||
it("01 a pending dose taken inside its own window", async () => {
|
||||
await put("01-take-in-place", events.pending, {
|
||||
takenAt: "2026-10-13T08:40:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("02 the same dose flipped to a skip", async () => {
|
||||
await put("02-skip-flip", events.pending, { skipped: true });
|
||||
});
|
||||
|
||||
it("03 a take corrected into the other window, onto a skipped slot", async () => {
|
||||
const res = await put("03-move-by-band", events.morningTaken, {
|
||||
takenAt: "2026-10-14T20:30:00.000Z",
|
||||
});
|
||||
events.moved = (res.data as { id: string }).id;
|
||||
});
|
||||
|
||||
it("04 a legacy take pinned onto a free slot", async () => {
|
||||
const res = await put("04-pin-free-slot", events.legacyTaken, {
|
||||
forceSlotInstant: "2026-10-12T20:00:00.000Z",
|
||||
});
|
||||
events.pinned = (res.data as { id: string }).id;
|
||||
});
|
||||
|
||||
it("05 a pin onto an instant that is no slot", async () => {
|
||||
await put("05-pin-not-a-slot", events.moved, {
|
||||
forceSlotInstant: "2026-10-12T15:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("06 a pin onto a slot another dose holds", async () => {
|
||||
await put("06-pin-occupied", events.moved, {
|
||||
forceSlotInstant: "2026-10-12T20:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("07 an unpin", async () => {
|
||||
const res = await put("07-unpin", events.pinned, {
|
||||
forceSlotInstant: null,
|
||||
});
|
||||
events.pinned = (res.data as { id: string }).id;
|
||||
});
|
||||
|
||||
it("08 an explicit slot in the body", async () => {
|
||||
const res = await put("08-explicit-slot", events.moved, {
|
||||
scheduledFor: "2026-10-14T08:00:00.000Z",
|
||||
takenAt: "2026-10-14T08:45:00.000Z",
|
||||
});
|
||||
events.moved = (res.data as { id: string }).id;
|
||||
});
|
||||
|
||||
it("09 a take before the start day", async () => {
|
||||
await put("09-before-start", events.pinned, {
|
||||
takenAt: "2026-09-20T08:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("10 a body the schema refuses", async () => {
|
||||
await put("10-invalid", events.pinned, {
|
||||
takenAt: "yesterday",
|
||||
skipped: "no",
|
||||
});
|
||||
});
|
||||
|
||||
it("11 a tombstoned dose, and a dose under another medication", async () => {
|
||||
await put("11a-tombstoned", events.tombstoned, { skipped: true });
|
||||
await put(
|
||||
"11b-other-medication",
|
||||
events.pinned,
|
||||
{ skipped: true },
|
||||
{ medicationId: oneShotId },
|
||||
);
|
||||
});
|
||||
|
||||
it("12 a delegate who may write, and one who may manage", async () => {
|
||||
await put(
|
||||
"12a-writer",
|
||||
events.pinned,
|
||||
{ takenAt: "2026-10-13T20:50:00.000Z" },
|
||||
writer,
|
||||
);
|
||||
await put(
|
||||
"12b-manager",
|
||||
events.pinned,
|
||||
{ takenAt: "2026-10-13T20:50:00.000Z" },
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
it("13 no credentials", async () => {
|
||||
await put(
|
||||
"13-anonymous",
|
||||
events.pinned,
|
||||
{ skipped: true },
|
||||
{
|
||||
auth: anonymous,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("02 the same dose flipped to a skip", async () => {
|
||||
await put("02-skip-flip", events.pending, { skipped: true });
|
||||
});
|
||||
|
||||
it("03 a take corrected into the other window, onto a skipped slot", async () => {
|
||||
const res = await put("03-move-by-band", events.morningTaken, {
|
||||
takenAt: "2026-10-14T20:30:00.000Z",
|
||||
describe("DELETE /api/medications/{id}/intake/{eventId} parity", () => {
|
||||
it("21 the owner undoes a dose", async () => {
|
||||
await del("21-delete", events.forDelete);
|
||||
});
|
||||
events.moved = (res.data as { id: string }).id;
|
||||
});
|
||||
|
||||
it("04 a legacy take pinned onto a free slot", async () => {
|
||||
const res = await put("04-pin-free-slot", events.legacyTaken, {
|
||||
forceSlotInstant: "2026-10-12T20:00:00.000Z",
|
||||
it("22 undoing the only dose of a one-shot medication", async () => {
|
||||
await del("22-delete-one-shot", events.oneShotDose, {
|
||||
medicationId: oneShotId,
|
||||
});
|
||||
});
|
||||
events.pinned = (res.data as { id: string }).id;
|
||||
});
|
||||
|
||||
it("05 a pin onto an instant that is no slot", async () => {
|
||||
await put("05-pin-not-a-slot", events.moved, {
|
||||
forceSlotInstant: "2026-10-12T15:00:00.000Z",
|
||||
it("23 a dose under another medication", async () => {
|
||||
await del("23-other-medication", events.forManagerDelete, {
|
||||
medicationId: oneShotId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("06 a pin onto a slot another dose holds", async () => {
|
||||
await put("06-pin-occupied", events.moved, {
|
||||
forceSlotInstant: "2026-10-12T20:00:00.000Z",
|
||||
it("24 a delegate who may write, and one who may manage", async () => {
|
||||
await del("24a-writer", events.forManagerDelete, writer);
|
||||
await del("24b-manager", events.forManagerDelete, manager);
|
||||
});
|
||||
});
|
||||
|
||||
it("07 an unpin", async () => {
|
||||
const res = await put("07-unpin", events.pinned, {
|
||||
forceSlotInstant: null,
|
||||
it("25 no credentials", async () => {
|
||||
await del("25-anonymous", events.forDelete, { auth: anonymous });
|
||||
});
|
||||
events.pinned = (res.data as { id: string }).id;
|
||||
});
|
||||
|
||||
it("08 an explicit slot in the body", async () => {
|
||||
const res = await put("08-explicit-slot", events.moved, {
|
||||
scheduledFor: "2026-10-14T08:00:00.000Z",
|
||||
takenAt: "2026-10-14T08:45:00.000Z",
|
||||
});
|
||||
events.moved = (res.data as { id: string }).id;
|
||||
});
|
||||
|
||||
it("09 a take before the start day", async () => {
|
||||
await put("09-before-start", events.pinned, {
|
||||
takenAt: "2026-09-20T08:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("10 a body the schema refuses", async () => {
|
||||
await put("10-invalid", events.pinned, {
|
||||
takenAt: "yesterday",
|
||||
skipped: "no",
|
||||
});
|
||||
});
|
||||
|
||||
it("11 a tombstoned dose, and a dose under another medication", async () => {
|
||||
await put("11a-tombstoned", events.tombstoned, { skipped: true });
|
||||
await put(
|
||||
"11b-other-medication",
|
||||
events.pinned,
|
||||
{ skipped: true },
|
||||
{ medicationId: oneShotId },
|
||||
);
|
||||
});
|
||||
|
||||
it("12 a delegate who may write, and one who may manage", async () => {
|
||||
await put(
|
||||
"12a-writer",
|
||||
events.pinned,
|
||||
{ takenAt: "2026-10-13T20:50:00.000Z" },
|
||||
writer,
|
||||
);
|
||||
await put(
|
||||
"12b-manager",
|
||||
events.pinned,
|
||||
{ takenAt: "2026-10-13T20:50:00.000Z" },
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
it("13 no credentials", async () => {
|
||||
await put(
|
||||
"13-anonymous",
|
||||
events.pinned,
|
||||
{ skipped: true },
|
||||
{
|
||||
auth: anonymous,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/medications/{id}/intake/{eventId} parity", () => {
|
||||
it("21 the owner undoes a dose", async () => {
|
||||
await del("21-delete", events.forDelete);
|
||||
});
|
||||
|
||||
it("22 undoing the only dose of a one-shot medication", async () => {
|
||||
await del("22-delete-one-shot", events.oneShotDose, {
|
||||
medicationId: oneShotId,
|
||||
});
|
||||
});
|
||||
|
||||
it("23 a dose under another medication", async () => {
|
||||
await del("23-other-medication", events.forManagerDelete, {
|
||||
medicationId: oneShotId,
|
||||
});
|
||||
});
|
||||
|
||||
it("24 a delegate who may write, and one who may manage", async () => {
|
||||
await del("24a-writer", events.forManagerDelete, writer);
|
||||
await del("24b-manager", events.forManagerDelete, manager);
|
||||
});
|
||||
|
||||
it("25 no credentials", async () => {
|
||||
await del("25-anonymous", events.forDelete, { auth: anonymous });
|
||||
});
|
||||
});
|
||||
@@ -317,138 +317,145 @@ afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("GET /api/medications/intake parity", () => {
|
||||
it("01 today's list", async () => {
|
||||
await get("01-today", { scope: "today" });
|
||||
// One walk: each request sees what the earlier ones stored, so the cases
|
||||
// keep their order even when a run shuffles (`shuffle: false`).
|
||||
describe("medication-intake-status parity walk", { shuffle: false }, () => {
|
||||
describe("GET /api/medications/intake parity", () => {
|
||||
it("01 today's list", async () => {
|
||||
await get("01-today", { scope: "today" });
|
||||
});
|
||||
|
||||
it("02 the compliance buckets, cold and from the cache", async () => {
|
||||
await get("02a-compliance-cold", { scope: "compliance", days: "7" });
|
||||
await get("02b-compliance-warm", { scope: "compliance", days: "7" });
|
||||
});
|
||||
|
||||
it("03 a query the schema refuses", async () => {
|
||||
await get("03-invalid", { scope: "yesterday", days: "0" });
|
||||
});
|
||||
|
||||
it("04 a reading delegate, and nobody", async () => {
|
||||
await get("04a-reader", { scope: "today" }, reader);
|
||||
await get("04b-anonymous", { scope: "today" }, { auth: anonymous });
|
||||
});
|
||||
});
|
||||
|
||||
it("02 the compliance buckets, cold and from the cache", async () => {
|
||||
await get("02a-compliance-cold", { scope: "compliance", days: "7" });
|
||||
await get("02b-compliance-warm", { scope: "compliance", days: "7" });
|
||||
});
|
||||
describe("POST /api/medications/intake parity", () => {
|
||||
it("11 a take in place", async () => {
|
||||
await post("11-take-in-place", {
|
||||
intakeId: ev.todayMorning,
|
||||
status: "taken",
|
||||
takenAt: "2026-10-15T08:30:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("03 a query the schema refuses", async () => {
|
||||
await get("03-invalid", { scope: "yesterday", days: "0" });
|
||||
});
|
||||
it("12 a take that re-binds to another slot", async () => {
|
||||
await post("12-take-slot-move", {
|
||||
intakeId: ev.moveMe,
|
||||
status: "taken",
|
||||
takenAt: "2026-10-14T20:30:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("04 a reading delegate, and nobody", async () => {
|
||||
await get("04a-reader", { scope: "today" }, reader);
|
||||
await get("04b-anonymous", { scope: "today" }, { auth: anonymous });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications/intake parity", () => {
|
||||
it("11 a take in place", async () => {
|
||||
await post("11-take-in-place", {
|
||||
intakeId: ev.todayMorning,
|
||||
status: "taken",
|
||||
takenAt: "2026-10-15T08:30:00.000Z",
|
||||
it("13 a pinned take, and a pin that is no slot", async () => {
|
||||
await post("13a-pin", {
|
||||
intakeId: ev.pinMe,
|
||||
status: "taken",
|
||||
takenAt: "2026-10-13T11:00:00.000Z",
|
||||
forceSlotInstant: "2026-10-13T08:00:00.000Z",
|
||||
});
|
||||
await post("13b-pin-not-a-slot", {
|
||||
intakeId: ev.snoozeMe,
|
||||
status: "taken",
|
||||
forceSlotInstant: "2026-10-11T15:00:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("12 a take that re-binds to another slot", async () => {
|
||||
await post("12-take-slot-move", {
|
||||
intakeId: ev.moveMe,
|
||||
status: "taken",
|
||||
takenAt: "2026-10-14T20:30:00.000Z",
|
||||
it("14 a skip over a recorded dose", async () => {
|
||||
await post("14-skip", { intakeId: ev.skipMe, status: "skipped" });
|
||||
});
|
||||
});
|
||||
|
||||
it("13 a pinned take, and a pin that is no slot", async () => {
|
||||
await post("13a-pin", {
|
||||
intakeId: ev.pinMe,
|
||||
status: "taken",
|
||||
takenAt: "2026-10-13T11:00:00.000Z",
|
||||
forceSlotInstant: "2026-10-13T08:00:00.000Z",
|
||||
});
|
||||
await post("13b-pin-not-a-slot", {
|
||||
intakeId: ev.snoozeMe,
|
||||
status: "taken",
|
||||
forceSlotInstant: "2026-10-11T15:00:00.000Z",
|
||||
it("15 a snooze with its end, and one without", async () => {
|
||||
await post("15a-snooze-until", {
|
||||
intakeId: ev.snoozeMe,
|
||||
status: "snoozed",
|
||||
snoozedUntil: "2026-10-15T13:00:00.000Z",
|
||||
});
|
||||
await post("15b-snooze-default", {
|
||||
intakeId: ev.snoozeMe,
|
||||
status: "snoozed",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("14 a skip over a recorded dose", async () => {
|
||||
await post("14-skip", { intakeId: ev.skipMe, status: "skipped" });
|
||||
});
|
||||
|
||||
it("15 a snooze with its end, and one without", async () => {
|
||||
await post("15a-snooze-until", {
|
||||
intakeId: ev.snoozeMe,
|
||||
status: "snoozed",
|
||||
snoozedUntil: "2026-10-15T13:00:00.000Z",
|
||||
});
|
||||
await post("15b-snooze-default", {
|
||||
intakeId: ev.snoozeMe,
|
||||
status: "snoozed",
|
||||
it("16 the not-found answers", async () => {
|
||||
await post("16a-unknown", {
|
||||
intakeId: "cmv0000000000000000000000",
|
||||
status: "taken",
|
||||
});
|
||||
await post("16b-foreign", { intakeId: ev.foreign, status: "taken" });
|
||||
await post("16c-tombstoned", {
|
||||
intakeId: ev.tombstoned,
|
||||
status: "taken",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("16 the not-found answers", async () => {
|
||||
await post("16a-unknown", {
|
||||
intakeId: "cmv0000000000000000000000",
|
||||
status: "taken",
|
||||
it("17 the record-only rule both ways", async () => {
|
||||
await post("17a-record-only-open", {
|
||||
intakeId: ev.recordOpen,
|
||||
status: "taken",
|
||||
});
|
||||
await post("17b-record-only-correction", {
|
||||
intakeId: ev.recordTaken,
|
||||
status: "taken",
|
||||
takenAt: "2026-10-13T07:20:00.000Z",
|
||||
});
|
||||
});
|
||||
await post("16b-foreign", { intakeId: ev.foreign, status: "taken" });
|
||||
await post("16c-tombstoned", { intakeId: ev.tombstoned, status: "taken" });
|
||||
});
|
||||
|
||||
it("17 the record-only rule both ways", async () => {
|
||||
await post("17a-record-only-open", {
|
||||
intakeId: ev.recordOpen,
|
||||
status: "taken",
|
||||
it("18 the injection-site refusal, and an allowed site", async () => {
|
||||
await post("18a-site-not-allowed", {
|
||||
intakeId: ev.penToday,
|
||||
status: "taken",
|
||||
takenAt: "2026-10-15T09:15:00.000Z",
|
||||
injectionSite: "ABDOMEN_RIGHT",
|
||||
});
|
||||
await post("18b-site", {
|
||||
intakeId: ev.penToday,
|
||||
status: "taken",
|
||||
takenAt: "2026-10-15T09:15:00.000Z",
|
||||
injectionSite: "ABDOMEN_LEFT",
|
||||
});
|
||||
});
|
||||
await post("17b-record-only-correction", {
|
||||
intakeId: ev.recordTaken,
|
||||
status: "taken",
|
||||
takenAt: "2026-10-13T07:20:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("18 the injection-site refusal, and an allowed site", async () => {
|
||||
await post("18a-site-not-allowed", {
|
||||
intakeId: ev.penToday,
|
||||
status: "taken",
|
||||
takenAt: "2026-10-15T09:15:00.000Z",
|
||||
injectionSite: "ABDOMEN_RIGHT",
|
||||
});
|
||||
await post("18b-site", {
|
||||
intakeId: ev.penToday,
|
||||
status: "taken",
|
||||
takenAt: "2026-10-15T09:15:00.000Z",
|
||||
injectionSite: "ABDOMEN_LEFT",
|
||||
it("19 a body the schema refuses", async () => {
|
||||
await post("19-invalid", { intakeId: "", status: "forgotten" });
|
||||
});
|
||||
});
|
||||
|
||||
it("19 a body the schema refuses", async () => {
|
||||
await post("19-invalid", { intakeId: "", status: "forgotten" });
|
||||
});
|
||||
|
||||
it("20 a delegate who marks an open dose, a snooze, a correction, a reader and nobody", async () => {
|
||||
await post(
|
||||
"20a-writer-take",
|
||||
{ intakeId: ev.todayEvening, status: "taken" },
|
||||
{ ...writer },
|
||||
);
|
||||
await post(
|
||||
"20b-writer-snooze-refused",
|
||||
{ intakeId: ev.snoozeMe, status: "snoozed" },
|
||||
writer,
|
||||
);
|
||||
await post(
|
||||
"20c-writer-correction-refused",
|
||||
{ intakeId: ev.resolved, status: "skipped" },
|
||||
writer,
|
||||
);
|
||||
await post(
|
||||
"20d-reader",
|
||||
{ intakeId: ev.snoozeMe, status: "taken" },
|
||||
reader,
|
||||
);
|
||||
await post(
|
||||
"20e-anonymous",
|
||||
{ intakeId: ev.snoozeMe, status: "taken" },
|
||||
{ auth: anonymous },
|
||||
);
|
||||
it("20 a delegate who marks an open dose, a snooze, a correction, a reader and nobody", async () => {
|
||||
await post(
|
||||
"20a-writer-take",
|
||||
{ intakeId: ev.todayEvening, status: "taken" },
|
||||
{ ...writer },
|
||||
);
|
||||
await post(
|
||||
"20b-writer-snooze-refused",
|
||||
{ intakeId: ev.snoozeMe, status: "snoozed" },
|
||||
writer,
|
||||
);
|
||||
await post(
|
||||
"20c-writer-correction-refused",
|
||||
{ intakeId: ev.resolved, status: "skipped" },
|
||||
writer,
|
||||
);
|
||||
await post(
|
||||
"20d-reader",
|
||||
{ intakeId: ev.snoozeMe, status: "taken" },
|
||||
reader,
|
||||
);
|
||||
await post(
|
||||
"20e-anonymous",
|
||||
{ intakeId: ev.snoozeMe, status: "taken" },
|
||||
{ auth: anonymous },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -343,189 +343,193 @@ afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("POST /api/medications/{id}/intake parity", () => {
|
||||
it("01 a take inside its band, onto the pending reminder row", async () => {
|
||||
await post("01-take-in-band", { takenAt: "2026-10-15T08:30:00.000Z" });
|
||||
});
|
||||
|
||||
it("02 an as-needed take recorded standalone, and its repeat inside the dedup window", async () => {
|
||||
await post(
|
||||
"02a-take-standalone",
|
||||
{ takenAt: "2026-10-15T11:00:00.000Z" },
|
||||
{ medicationId: med.asNeeded },
|
||||
);
|
||||
await post(
|
||||
"02b-take-standalone-repeat",
|
||||
{ takenAt: "2026-10-15T11:00:00.000Z" },
|
||||
{ medicationId: med.asNeeded },
|
||||
);
|
||||
});
|
||||
|
||||
it("03 a skip on a named slot, a slot-less skip, and a skip onto a standalone dose", async () => {
|
||||
await post("03a-skip-named-slot", {
|
||||
skipped: true,
|
||||
scheduledFor: "2026-10-15T20:00:00.000Z",
|
||||
// One walk: each request sees what the earlier ones stored, so the cases
|
||||
// keep their order even when a run shuffles (`shuffle: false`).
|
||||
describe("medication-intake parity walk", { shuffle: false }, () => {
|
||||
describe("POST /api/medications/{id}/intake parity", () => {
|
||||
it("01 a take inside its band, onto the pending reminder row", async () => {
|
||||
await post("01-take-in-band", { takenAt: "2026-10-15T08:30:00.000Z" });
|
||||
});
|
||||
await post("03b-skip-slotless", { skipped: true });
|
||||
await post(
|
||||
"03c-delegate-skip-standalone-refused",
|
||||
{ skipped: true, scheduledFor: "2026-10-15T11:00:00.000Z" },
|
||||
{ ...writer, medicationId: med.asNeeded, settle: () => refusalRows(1) },
|
||||
);
|
||||
await post(
|
||||
"03d-owner-skip-standalone",
|
||||
{ skipped: true, scheduledFor: "2026-10-15T11:00:00.000Z" },
|
||||
{ medicationId: med.asNeeded },
|
||||
);
|
||||
});
|
||||
|
||||
it("04 a pinned take and the two pin refusals", async () => {
|
||||
await post("04a-pin", {
|
||||
takenAt: "2026-10-14T11:00:00.000Z",
|
||||
forceSlotInstant: "2026-10-14T08:00:00.000Z",
|
||||
it("02 an as-needed take recorded standalone, and its repeat inside the dedup window", async () => {
|
||||
await post(
|
||||
"02a-take-standalone",
|
||||
{ takenAt: "2026-10-15T11:00:00.000Z" },
|
||||
{ medicationId: med.asNeeded },
|
||||
);
|
||||
await post(
|
||||
"02b-take-standalone-repeat",
|
||||
{ takenAt: "2026-10-15T11:00:00.000Z" },
|
||||
{ medicationId: med.asNeeded },
|
||||
);
|
||||
});
|
||||
await post("04b-pin-not-a-slot", {
|
||||
takenAt: "2026-10-14T16:00:00.000Z",
|
||||
forceSlotInstant: "2026-10-14T15:00:00.000Z",
|
||||
|
||||
it("03 a skip on a named slot, a slot-less skip, and a skip onto a standalone dose", async () => {
|
||||
await post("03a-skip-named-slot", {
|
||||
skipped: true,
|
||||
scheduledFor: "2026-10-15T20:00:00.000Z",
|
||||
});
|
||||
await post("03b-skip-slotless", { skipped: true });
|
||||
await post(
|
||||
"03c-delegate-skip-standalone-refused",
|
||||
{ skipped: true, scheduledFor: "2026-10-15T11:00:00.000Z" },
|
||||
{ ...writer, medicationId: med.asNeeded, settle: () => refusalRows(1) },
|
||||
);
|
||||
await post(
|
||||
"03d-owner-skip-standalone",
|
||||
{ skipped: true, scheduledFor: "2026-10-15T11:00:00.000Z" },
|
||||
{ medicationId: med.asNeeded },
|
||||
);
|
||||
});
|
||||
await post("04c-pin-occupied", {
|
||||
takenAt: "2026-10-14T23:00:00.000Z",
|
||||
forceSlotInstant: "2026-10-14T20:00:00.000Z",
|
||||
|
||||
it("04 a pinned take and the two pin refusals", async () => {
|
||||
await post("04a-pin", {
|
||||
takenAt: "2026-10-14T11:00:00.000Z",
|
||||
forceSlotInstant: "2026-10-14T08:00:00.000Z",
|
||||
});
|
||||
await post("04b-pin-not-a-slot", {
|
||||
takenAt: "2026-10-14T16:00:00.000Z",
|
||||
forceSlotInstant: "2026-10-14T15:00:00.000Z",
|
||||
});
|
||||
await post("04c-pin-occupied", {
|
||||
takenAt: "2026-10-14T23:00:00.000Z",
|
||||
forceSlotInstant: "2026-10-14T20:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("05 a backfill onto a named earlier slot", async () => {
|
||||
await post("05-named-backfill", {
|
||||
takenAt: "2026-10-15T11:30:00.000Z",
|
||||
scheduledFor: "2026-10-13T08:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("06 a body idempotency key and its replay", async () => {
|
||||
const body = {
|
||||
takenAt: "2026-10-13T20:30:00.000Z",
|
||||
idempotencyKey: "dose-key-0001",
|
||||
};
|
||||
await post("06a-body-key", body);
|
||||
await post("06b-body-key-replay", body);
|
||||
});
|
||||
|
||||
it("07 an Idempotency-Key header and its replay", async () => {
|
||||
const headers = {
|
||||
"idempotency-key": "4b7e1c9a-2d3f-4a5b-8c6d-7e8f9a0b1c2d",
|
||||
};
|
||||
const body = { takenAt: "2026-10-12T08:15:00.000Z" };
|
||||
await post("07a-header-key", body, { headers });
|
||||
await post("07b-header-key-replay", body, { headers });
|
||||
});
|
||||
|
||||
it("08 a native take", async () => {
|
||||
await post(
|
||||
"08-native",
|
||||
{ takenAt: "2026-10-11T20:10:00.000Z" },
|
||||
{ auth: ownerBearer },
|
||||
);
|
||||
});
|
||||
|
||||
it("09 a skip over a recorded dose, by the owner and by a delegate", async () => {
|
||||
await post(
|
||||
"09a-delegate-skip-refused",
|
||||
{ skipped: true, scheduledFor: "2026-10-14T20:00:00.000Z" },
|
||||
{ ...writer, settle: () => refusalRows(2) },
|
||||
);
|
||||
await post("09b-owner-skip-flip", {
|
||||
skipped: true,
|
||||
scheduledFor: "2026-10-14T20:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("10 a delegate's take on an open slot, a reader, and nobody", async () => {
|
||||
await post(
|
||||
"10a-writer-take",
|
||||
{ takenAt: "2026-10-11T08:20:00.000Z" },
|
||||
writer,
|
||||
);
|
||||
await post("10b-reader", { takenAt: "2026-10-10T08:20:00.000Z" }, reader);
|
||||
await post(
|
||||
"10c-anonymous",
|
||||
{ takenAt: "2026-10-10T08:20:00.000Z" },
|
||||
{ auth: anonymous },
|
||||
);
|
||||
});
|
||||
|
||||
it("11 the injection-site refusal, and a site with a dose override", async () => {
|
||||
await post(
|
||||
"11a-site-not-allowed",
|
||||
{ takenAt: "2026-10-15T09:10:00.000Z", injectionSite: "ABDOMEN_RIGHT" },
|
||||
{ medicationId: med.pen },
|
||||
);
|
||||
await post(
|
||||
"11b-site-and-dose",
|
||||
{
|
||||
takenAt: "2026-10-15T09:10:00.000Z",
|
||||
injectionSite: "ABDOMEN_LEFT",
|
||||
doseTaken: "0.5 pen",
|
||||
},
|
||||
{ medicationId: med.pen },
|
||||
);
|
||||
});
|
||||
|
||||
it("12 a one-shot dose", async () => {
|
||||
await post(
|
||||
"12-one-shot",
|
||||
{ takenAt: "2026-10-15T10:00:00.000Z" },
|
||||
{ medicationId: med.oneShot },
|
||||
);
|
||||
});
|
||||
|
||||
it("13 a body the schema refuses", async () => {
|
||||
await post("13-invalid", {
|
||||
takenAt: "yesterday",
|
||||
idempotencyKey: "<HKDoseEvent: 0x600003a1c0c0>",
|
||||
});
|
||||
});
|
||||
|
||||
it("14 another account's medication, and a record-only one", async () => {
|
||||
await post(
|
||||
"14a-not-found",
|
||||
{ takenAt: "2026-10-15T08:30:00.000Z" },
|
||||
{ medicationId: med.foreign },
|
||||
);
|
||||
await post(
|
||||
"14b-record-only",
|
||||
{ takenAt: "2026-10-15T07:10:00.000Z" },
|
||||
{ medicationId: med.recordOnly },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("05 a backfill onto a named earlier slot", async () => {
|
||||
await post("05-named-backfill", {
|
||||
takenAt: "2026-10-15T11:30:00.000Z",
|
||||
scheduledFor: "2026-10-13T08:00:00.000Z",
|
||||
describe("GET /api/medications/{id}/intake parity", () => {
|
||||
it("21 the default list", async () => {
|
||||
await list("21-default");
|
||||
});
|
||||
});
|
||||
|
||||
it("06 a body idempotency key and its replay", async () => {
|
||||
const body = {
|
||||
takenAt: "2026-10-13T20:30:00.000Z",
|
||||
idempotencyKey: "dose-key-0001",
|
||||
};
|
||||
await post("06a-body-key", body);
|
||||
await post("06b-body-key-replay", body);
|
||||
});
|
||||
|
||||
it("07 an Idempotency-Key header and its replay", async () => {
|
||||
const headers = {
|
||||
"idempotency-key": "4b7e1c9a-2d3f-4a5b-8c6d-7e8f9a0b1c2d",
|
||||
};
|
||||
const body = { takenAt: "2026-10-12T08:15:00.000Z" };
|
||||
await post("07a-header-key", body, { headers });
|
||||
await post("07b-header-key-replay", body, { headers });
|
||||
});
|
||||
|
||||
it("08 a native take", async () => {
|
||||
await post(
|
||||
"08-native",
|
||||
{ takenAt: "2026-10-11T20:10:00.000Z" },
|
||||
{ auth: ownerBearer },
|
||||
);
|
||||
});
|
||||
|
||||
it("09 a skip over a recorded dose, by the owner and by a delegate", async () => {
|
||||
await post(
|
||||
"09a-delegate-skip-refused",
|
||||
{ skipped: true, scheduledFor: "2026-10-14T20:00:00.000Z" },
|
||||
{ ...writer, settle: () => refusalRows(2) },
|
||||
);
|
||||
await post("09b-owner-skip-flip", {
|
||||
skipped: true,
|
||||
scheduledFor: "2026-10-14T20:00:00.000Z",
|
||||
it("22 a completed page sorted by take time", async () => {
|
||||
await list("22-completed-page", {
|
||||
status: "completed",
|
||||
sortBy: "takenAt",
|
||||
sortDir: "asc",
|
||||
limit: "3",
|
||||
offset: "1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("10 a delegate's take on an open slot, a reader, and nobody", async () => {
|
||||
await post(
|
||||
"10a-writer-take",
|
||||
{ takenAt: "2026-10-11T08:20:00.000Z" },
|
||||
writer,
|
||||
);
|
||||
await post("10b-reader", { takenAt: "2026-10-10T08:20:00.000Z" }, reader);
|
||||
await post(
|
||||
"10c-anonymous",
|
||||
{ takenAt: "2026-10-10T08:20:00.000Z" },
|
||||
{ auth: anonymous },
|
||||
);
|
||||
});
|
||||
|
||||
it("11 the injection-site refusal, and a site with a dose override", async () => {
|
||||
await post(
|
||||
"11a-site-not-allowed",
|
||||
{ takenAt: "2026-10-15T09:10:00.000Z", injectionSite: "ABDOMEN_RIGHT" },
|
||||
{ medicationId: med.pen },
|
||||
);
|
||||
await post(
|
||||
"11b-site-and-dose",
|
||||
{
|
||||
takenAt: "2026-10-15T09:10:00.000Z",
|
||||
injectionSite: "ABDOMEN_LEFT",
|
||||
doseTaken: "0.5 pen",
|
||||
},
|
||||
{ medicationId: med.pen },
|
||||
);
|
||||
});
|
||||
|
||||
it("12 a one-shot dose", async () => {
|
||||
await post(
|
||||
"12-one-shot",
|
||||
{ takenAt: "2026-10-15T10:00:00.000Z" },
|
||||
{ medicationId: med.oneShot },
|
||||
);
|
||||
});
|
||||
|
||||
it("13 a body the schema refuses", async () => {
|
||||
await post("13-invalid", {
|
||||
takenAt: "yesterday",
|
||||
idempotencyKey: "<HKDoseEvent: 0x600003a1c0c0>",
|
||||
it("23 the skips, and the takes", async () => {
|
||||
await list("23a-skipped", { status: "skipped" });
|
||||
await list("23b-taken", { status: "taken", sortDir: "asc" });
|
||||
});
|
||||
});
|
||||
|
||||
it("14 another account's medication, and a record-only one", async () => {
|
||||
await post(
|
||||
"14a-not-found",
|
||||
{ takenAt: "2026-10-15T08:30:00.000Z" },
|
||||
{ medicationId: med.foreign },
|
||||
);
|
||||
await post(
|
||||
"14b-record-only",
|
||||
{ takenAt: "2026-10-15T07:10:00.000Z" },
|
||||
{ medicationId: med.recordOnly },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/medications/{id}/intake parity", () => {
|
||||
it("21 the default list", async () => {
|
||||
await list("21-default");
|
||||
});
|
||||
|
||||
it("22 a completed page sorted by take time", async () => {
|
||||
await list("22-completed-page", {
|
||||
status: "completed",
|
||||
sortBy: "takenAt",
|
||||
sortDir: "asc",
|
||||
limit: "3",
|
||||
offset: "1",
|
||||
it("24 a query the schema refuses", async () => {
|
||||
await list("24-invalid", { limit: "lots", status: "maybe" });
|
||||
});
|
||||
});
|
||||
|
||||
it("23 the skips, and the takes", async () => {
|
||||
await list("23a-skipped", { status: "skipped" });
|
||||
await list("23b-taken", { status: "taken", sortDir: "asc" });
|
||||
});
|
||||
|
||||
it("24 a query the schema refuses", async () => {
|
||||
await list("24-invalid", { limit: "lots", status: "maybe" });
|
||||
});
|
||||
|
||||
it("25 another account's medication, a reading delegate, and nobody", async () => {
|
||||
await list("25a-not-found", {}, { medicationId: med.foreign });
|
||||
await list("25b-reader", {}, reader);
|
||||
await list("25c-anonymous", {}, { auth: anonymous });
|
||||
it("25 another account's medication, a reading delegate, and nobody", async () => {
|
||||
await list("25a-not-found", {}, { medicationId: med.foreign });
|
||||
await list("25b-reader", {}, reader);
|
||||
await list("25c-anonymous", {}, { auth: anonymous });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -138,197 +138,204 @@ afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("GET /api/medications parity", () => {
|
||||
it("01 the owner's list", async () => {
|
||||
await list("01-list");
|
||||
});
|
||||
// One walk: each request sees what the earlier ones stored, so the cases
|
||||
// keep their order even when a run shuffles (`shuffle: false`).
|
||||
describe("medications parity walk", { shuffle: false }, () => {
|
||||
describe("GET /api/medications parity", () => {
|
||||
it("01 the owner's list", async () => {
|
||||
await list("01-list");
|
||||
});
|
||||
|
||||
it("02 a reading delegate's view of it, and nobody's", async () => {
|
||||
await list("02a-list-reader", reader);
|
||||
await list("02b-list-anonymous", { auth: anonymous });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications parity", () => {
|
||||
it("11 a plain create", async () => {
|
||||
await create("11-plain", {
|
||||
name: "Plain tablet",
|
||||
dose: "5mg",
|
||||
schedules: [schedule("07:00", "09:00")],
|
||||
it("02 a reading delegate's view of it, and nobody's", async () => {
|
||||
await list("02a-list-reader", reader);
|
||||
await list("02b-list-anonymous", { auth: anonymous });
|
||||
});
|
||||
});
|
||||
|
||||
it("12 a full create with a custom category, a course and auto-take", async () => {
|
||||
await create("12-full", {
|
||||
name: "Full injection",
|
||||
dose: "1 pen",
|
||||
category: CUSTOM_KEY,
|
||||
treatmentClass: "GLP1",
|
||||
dosesPerUnit: 4,
|
||||
unitsPerDose: 0.5,
|
||||
reorderLeadDays: 7,
|
||||
deliveryForm: "INJECTION",
|
||||
trackInjectionSites: true,
|
||||
allowedInjectionSites: ["ABDOMEN_LEFT", "THIGH_RIGHT"],
|
||||
notificationsEnabled: false,
|
||||
liveActivityEnabled: true,
|
||||
criticalAlarmEnabled: true,
|
||||
atcCode: "A10BJ06",
|
||||
rxNormCode: "1991302",
|
||||
startsOn: "2026-10-10",
|
||||
endsOn: "2026-12-31",
|
||||
autoMarkTaken: true,
|
||||
schedules: [
|
||||
schedule("18:00", "20:00", {
|
||||
label: "Evening",
|
||||
dose: "0.5 pen",
|
||||
unitsPerDose: 1,
|
||||
timesOfDay: ["18:30"],
|
||||
reminderGraceMinutes: 45,
|
||||
rrule: "FREQ=WEEKLY;BYDAY=MO",
|
||||
doseWindows: [{ timeOfDay: "18:30", start: "18:00", end: "20:00" }],
|
||||
}),
|
||||
],
|
||||
describe("POST /api/medications parity", () => {
|
||||
it("11 a plain create", async () => {
|
||||
await create("11-plain", {
|
||||
name: "Plain tablet",
|
||||
dose: "5mg",
|
||||
schedules: [schedule("07:00", "09:00")],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("13 an as-needed create, a one-shot one and a PRN schedule", async () => {
|
||||
await create("13a-as-needed", {
|
||||
name: "As needed",
|
||||
dose: "400mg",
|
||||
category: "PAIN_RELIEF",
|
||||
asNeeded: true,
|
||||
it("12 a full create with a custom category, a course and auto-take", async () => {
|
||||
await create("12-full", {
|
||||
name: "Full injection",
|
||||
dose: "1 pen",
|
||||
category: CUSTOM_KEY,
|
||||
treatmentClass: "GLP1",
|
||||
dosesPerUnit: 4,
|
||||
unitsPerDose: 0.5,
|
||||
reorderLeadDays: 7,
|
||||
deliveryForm: "INJECTION",
|
||||
trackInjectionSites: true,
|
||||
allowedInjectionSites: ["ABDOMEN_LEFT", "THIGH_RIGHT"],
|
||||
notificationsEnabled: false,
|
||||
liveActivityEnabled: true,
|
||||
criticalAlarmEnabled: true,
|
||||
atcCode: "A10BJ06",
|
||||
rxNormCode: "1991302",
|
||||
startsOn: "2026-10-10",
|
||||
endsOn: "2026-12-31",
|
||||
autoMarkTaken: true,
|
||||
schedules: [
|
||||
schedule("18:00", "20:00", {
|
||||
label: "Evening",
|
||||
dose: "0.5 pen",
|
||||
unitsPerDose: 1,
|
||||
timesOfDay: ["18:30"],
|
||||
reminderGraceMinutes: 45,
|
||||
rrule: "FREQ=WEEKLY;BYDAY=MO",
|
||||
doseWindows: [{ timeOfDay: "18:30", start: "18:00", end: "20:00" }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
await create("13b-one-shot", {
|
||||
name: "One shot",
|
||||
dose: "1 dose",
|
||||
oneShot: true,
|
||||
startsOn: "2026-10-20",
|
||||
endsOn: "2026-11-20",
|
||||
schedules: [schedule("09:00", "11:00")],
|
||||
});
|
||||
await create("13c-prn-and-legacy-days", {
|
||||
name: "Mixed schedules",
|
||||
dose: "2mg",
|
||||
trackIntake: false,
|
||||
schedules: [
|
||||
schedule("06:00", "07:00", { scheduleType: "PRN" }),
|
||||
schedule("21:00", "22:00", { daysOfWeek: [1, 3, 5], intervalWeeks: 2 }),
|
||||
schedule("12:00", "13:00", { rollingIntervalDays: 3 }),
|
||||
],
|
||||
});
|
||||
await create("13d-ended-window", {
|
||||
name: "Ended window",
|
||||
dose: "3mg",
|
||||
endsOn: "2026-10-05",
|
||||
schedules: [schedule("08:00", "09:00")],
|
||||
});
|
||||
});
|
||||
|
||||
it("14 the refusals", async () => {
|
||||
await create("14a-unknown-category", {
|
||||
name: "Unknown category",
|
||||
dose: "1mg",
|
||||
category: "custom:0a6c2d4e-1111-4222-8333-944455556666",
|
||||
schedules: [schedule("08:00", "09:00")],
|
||||
it("13 an as-needed create, a one-shot one and a PRN schedule", async () => {
|
||||
await create("13a-as-needed", {
|
||||
name: "As needed",
|
||||
dose: "400mg",
|
||||
category: "PAIN_RELIEF",
|
||||
asNeeded: true,
|
||||
});
|
||||
await create("13b-one-shot", {
|
||||
name: "One shot",
|
||||
dose: "1 dose",
|
||||
oneShot: true,
|
||||
startsOn: "2026-10-20",
|
||||
endsOn: "2026-11-20",
|
||||
schedules: [schedule("09:00", "11:00")],
|
||||
});
|
||||
await create("13c-prn-and-legacy-days", {
|
||||
name: "Mixed schedules",
|
||||
dose: "2mg",
|
||||
trackIntake: false,
|
||||
schedules: [
|
||||
schedule("06:00", "07:00", { scheduleType: "PRN" }),
|
||||
schedule("21:00", "22:00", {
|
||||
daysOfWeek: [1, 3, 5],
|
||||
intervalWeeks: 2,
|
||||
}),
|
||||
schedule("12:00", "13:00", { rollingIntervalDays: 3 }),
|
||||
],
|
||||
});
|
||||
await create("13d-ended-window", {
|
||||
name: "Ended window",
|
||||
dose: "3mg",
|
||||
endsOn: "2026-10-05",
|
||||
schedules: [schedule("08:00", "09:00")],
|
||||
});
|
||||
});
|
||||
await create("14b-course-ends-before-start", {
|
||||
name: "Backwards course",
|
||||
dose: "1mg",
|
||||
startsOn: "2026-10-20",
|
||||
endsOn: "2026-10-10",
|
||||
schedules: [schedule("08:00", "09:00")],
|
||||
});
|
||||
await create("14c-one-shot-two-schedules", {
|
||||
name: "One shot twice",
|
||||
dose: "1mg",
|
||||
oneShot: true,
|
||||
startsOn: "2026-10-20",
|
||||
schedules: [schedule("08:00", "09:00"), schedule("20:00", "21:00")],
|
||||
});
|
||||
await create("14d-one-shot-recurrence", {
|
||||
name: "One shot recurring",
|
||||
dose: "1mg",
|
||||
oneShot: true,
|
||||
startsOn: "2026-10-20",
|
||||
schedules: [schedule("08:00", "09:00", { rrule: "FREQ=DAILY" })],
|
||||
});
|
||||
await create("14e-unstable-concept-id", {
|
||||
name: "Phantom",
|
||||
dose: "1mg",
|
||||
externalSource: "APPLE_HEALTH",
|
||||
externalId: "<HKMedicationConcept: 0x600003a1c0c0>",
|
||||
schedules: [schedule("08:00", "09:00")],
|
||||
});
|
||||
await create("14f-invalid", { name: "", dose: 5, schedules: "daily" });
|
||||
});
|
||||
|
||||
it("15 a mirrored create and its replay", async () => {
|
||||
const mirrored = {
|
||||
name: "Mirrored",
|
||||
dose: "50mg",
|
||||
externalSource: "APPLE_HEALTH",
|
||||
externalId: "hk-concept-0001",
|
||||
schedules: [schedule("08:00", "09:00")],
|
||||
};
|
||||
await create("15a-mirror", mirrored);
|
||||
await create("15b-mirror-replay", { ...mirrored, name: "Renamed" });
|
||||
});
|
||||
|
||||
it("16 the mirror cap", async () => {
|
||||
const capped = await makeUser({ timezone: "UTC" });
|
||||
await prisma.medication.createMany({
|
||||
data: Array.from({ length: 30 }, (_, i) => ({
|
||||
userId: capped.id,
|
||||
name: `Mirror ${String(i).padStart(2, "0")}`,
|
||||
it("14 the refusals", async () => {
|
||||
await create("14a-unknown-category", {
|
||||
name: "Unknown category",
|
||||
dose: "1mg",
|
||||
externalSource: "APPLE_HEALTH" as const,
|
||||
externalId: `hk-cap-${String(i).padStart(2, "0")}`,
|
||||
})),
|
||||
});
|
||||
await create(
|
||||
"16-mirror-cap",
|
||||
{
|
||||
name: "One too many",
|
||||
category: "custom:0a6c2d4e-1111-4222-8333-944455556666",
|
||||
schedules: [schedule("08:00", "09:00")],
|
||||
});
|
||||
await create("14b-course-ends-before-start", {
|
||||
name: "Backwards course",
|
||||
dose: "1mg",
|
||||
startsOn: "2026-10-20",
|
||||
endsOn: "2026-10-10",
|
||||
schedules: [schedule("08:00", "09:00")],
|
||||
});
|
||||
await create("14c-one-shot-two-schedules", {
|
||||
name: "One shot twice",
|
||||
dose: "1mg",
|
||||
oneShot: true,
|
||||
startsOn: "2026-10-20",
|
||||
schedules: [schedule("08:00", "09:00"), schedule("20:00", "21:00")],
|
||||
});
|
||||
await create("14d-one-shot-recurrence", {
|
||||
name: "One shot recurring",
|
||||
dose: "1mg",
|
||||
oneShot: true,
|
||||
startsOn: "2026-10-20",
|
||||
schedules: [schedule("08:00", "09:00", { rrule: "FREQ=DAILY" })],
|
||||
});
|
||||
await create("14e-unstable-concept-id", {
|
||||
name: "Phantom",
|
||||
dose: "1mg",
|
||||
externalSource: "APPLE_HEALTH",
|
||||
externalId: "hk-cap-30",
|
||||
externalId: "<HKMedicationConcept: 0x600003a1c0c0>",
|
||||
schedules: [schedule("08:00", "09:00")],
|
||||
},
|
||||
{ auth: await makeSession(capped), userIds: [capped.id] },
|
||||
);
|
||||
});
|
||||
});
|
||||
await create("14f-invalid", { name: "", dose: 5, schedules: "daily" });
|
||||
});
|
||||
|
||||
it("17 a retried create under one idempotency key", async () => {
|
||||
const body = {
|
||||
name: "Retried",
|
||||
dose: "10mg",
|
||||
schedules: [schedule("08:00", "09:00")],
|
||||
};
|
||||
const headers = {
|
||||
"idempotency-key": "6f9d2c4a-8b1e-4f3a-9d7c-5e2b1a0c9d8e",
|
||||
};
|
||||
await create("17a-idempotent-first", body, { headers });
|
||||
await create("17b-idempotent-replay", body, { headers });
|
||||
});
|
||||
it("15 a mirrored create and its replay", async () => {
|
||||
const mirrored = {
|
||||
name: "Mirrored",
|
||||
dose: "50mg",
|
||||
externalSource: "APPLE_HEALTH",
|
||||
externalId: "hk-concept-0001",
|
||||
schedules: [schedule("08:00", "09:00")],
|
||||
};
|
||||
await create("15a-mirror", mirrored);
|
||||
await create("15b-mirror-replay", { ...mirrored, name: "Renamed" });
|
||||
});
|
||||
|
||||
it("18 a delegate who may write, and one who may only read", async () => {
|
||||
const body = {
|
||||
name: "Brought by a delegate",
|
||||
dose: "20mg",
|
||||
schedules: [schedule("08:00", "09:00")],
|
||||
};
|
||||
await create("18a-writer", body, writer);
|
||||
await create("18b-reader", body, reader);
|
||||
});
|
||||
it("16 the mirror cap", async () => {
|
||||
const capped = await makeUser({ timezone: "UTC" });
|
||||
await prisma.medication.createMany({
|
||||
data: Array.from({ length: 30 }, (_, i) => ({
|
||||
userId: capped.id,
|
||||
name: `Mirror ${String(i).padStart(2, "0")}`,
|
||||
dose: "1mg",
|
||||
externalSource: "APPLE_HEALTH" as const,
|
||||
externalId: `hk-cap-${String(i).padStart(2, "0")}`,
|
||||
})),
|
||||
});
|
||||
await create(
|
||||
"16-mirror-cap",
|
||||
{
|
||||
name: "One too many",
|
||||
dose: "1mg",
|
||||
externalSource: "APPLE_HEALTH",
|
||||
externalId: "hk-cap-30",
|
||||
schedules: [schedule("08:00", "09:00")],
|
||||
},
|
||||
{ auth: await makeSession(capped), userIds: [capped.id] },
|
||||
);
|
||||
});
|
||||
|
||||
it("19 no credentials", async () => {
|
||||
await create(
|
||||
"19-anonymous",
|
||||
{ name: "Nobody", dose: "1mg" },
|
||||
{
|
||||
auth: anonymous,
|
||||
},
|
||||
);
|
||||
it("17 a retried create under one idempotency key", async () => {
|
||||
const body = {
|
||||
name: "Retried",
|
||||
dose: "10mg",
|
||||
schedules: [schedule("08:00", "09:00")],
|
||||
};
|
||||
const headers = {
|
||||
"idempotency-key": "6f9d2c4a-8b1e-4f3a-9d7c-5e2b1a0c9d8e",
|
||||
};
|
||||
await create("17a-idempotent-first", body, { headers });
|
||||
await create("17b-idempotent-replay", body, { headers });
|
||||
});
|
||||
|
||||
it("18 a delegate who may write, and one who may only read", async () => {
|
||||
const body = {
|
||||
name: "Brought by a delegate",
|
||||
dose: "20mg",
|
||||
schedules: [schedule("08:00", "09:00")],
|
||||
};
|
||||
await create("18a-writer", body, writer);
|
||||
await create("18b-reader", body, reader);
|
||||
});
|
||||
|
||||
it("19 no credentials", async () => {
|
||||
await create(
|
||||
"19-anonymous",
|
||||
{ name: "Nobody", dose: "1mg" },
|
||||
{
|
||||
auth: anonymous,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -214,7 +214,7 @@ afterAll(async () => {
|
||||
await prisma.moodTag.delete({ where: { id: SHORT_FACTOR_ID } });
|
||||
});
|
||||
|
||||
describe("POST /api/mood-entries/bulk parity", () => {
|
||||
describe("POST /api/mood-entries/bulk parity", { shuffle: false }, () => {
|
||||
it("01 a first drain", async () => {
|
||||
await step("01-first-drain", { entries: firstDrain });
|
||||
});
|
||||
|
||||
@@ -270,7 +270,7 @@ afterAll(() => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("GET /api/auth/oidc/callback parity", () => {
|
||||
describe("GET /api/auth/oidc/callback parity", { shuffle: false }, () => {
|
||||
it("01 SSO switched off", async () => {
|
||||
await step("01-disabled", { state: {} });
|
||||
process.env.OIDC_ISSUER_URL = ISSUER;
|
||||
|
||||
@@ -298,7 +298,7 @@ afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("GET /api/sync/changes parity", () => {
|
||||
describe("GET /api/sync/changes parity", { shuffle: false }, () => {
|
||||
let firstCursor: string;
|
||||
|
||||
it("drains the feed in small pages to the end", async () => {
|
||||
|
||||
@@ -176,63 +176,67 @@ beforeAll(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Apple Health write-back of a measurement token", () => {
|
||||
it("walks the cases in order", async () => {
|
||||
await step("01-summary", { kind: "summary" });
|
||||
await step("02-on", {
|
||||
kind: "patch",
|
||||
id: scale.tokenId,
|
||||
body: { mirrorToAppleHealth: true },
|
||||
describe(
|
||||
"the Apple Health write-back of a measurement token",
|
||||
{ shuffle: false },
|
||||
() => {
|
||||
it("walks the cases in order", async () => {
|
||||
await step("01-summary", { kind: "summary" });
|
||||
await step("02-on", {
|
||||
kind: "patch",
|
||||
id: scale.tokenId,
|
||||
body: { mirrorToAppleHealth: true },
|
||||
});
|
||||
await step("03-on-again", {
|
||||
kind: "patch",
|
||||
id: scale.tokenId,
|
||||
body: { mirrorToAppleHealth: true },
|
||||
});
|
||||
await step("04-list", { kind: "list" });
|
||||
const revoked = await callRoute(revoke, {
|
||||
method: "DELETE",
|
||||
route: "/api/tokens/{id}",
|
||||
params: { id: scale.tokenId },
|
||||
auth: owner,
|
||||
});
|
||||
expect(revoked.status).toBe(200);
|
||||
await step("05-off-revoked", {
|
||||
kind: "patch",
|
||||
id: scale.tokenId,
|
||||
body: { mirrorToAppleHealth: false },
|
||||
});
|
||||
await step("06-wildcard", {
|
||||
kind: "patch",
|
||||
id: wildcard.tokenId,
|
||||
body: { mirrorToAppleHealth: true },
|
||||
});
|
||||
await step("07-connector", {
|
||||
kind: "patch",
|
||||
id: connector.tokenId,
|
||||
body: { mirrorToAppleHealth: true },
|
||||
});
|
||||
await step("08-another-account", {
|
||||
kind: "patch",
|
||||
id: theirs.tokenId,
|
||||
body: { mirrorToAppleHealth: true },
|
||||
});
|
||||
await step("09-unknown", {
|
||||
kind: "patch",
|
||||
id: "missing-token",
|
||||
body: { mirrorToAppleHealth: true },
|
||||
});
|
||||
await step("10-invalid-body", {
|
||||
kind: "patch",
|
||||
id: scale.tokenId,
|
||||
body: { mirrorToAppleHealth: "yes", name: "Renamed" },
|
||||
});
|
||||
await step("11-bearer", {
|
||||
kind: "patch",
|
||||
id: scale.tokenId,
|
||||
body: { mirrorToAppleHealth: true },
|
||||
auth: wildcard,
|
||||
});
|
||||
await step("12-summary-bearer", { kind: "summary", auth: wildcard });
|
||||
});
|
||||
await step("03-on-again", {
|
||||
kind: "patch",
|
||||
id: scale.tokenId,
|
||||
body: { mirrorToAppleHealth: true },
|
||||
});
|
||||
await step("04-list", { kind: "list" });
|
||||
const revoked = await callRoute(revoke, {
|
||||
method: "DELETE",
|
||||
route: "/api/tokens/{id}",
|
||||
params: { id: scale.tokenId },
|
||||
auth: owner,
|
||||
});
|
||||
expect(revoked.status).toBe(200);
|
||||
await step("05-off-revoked", {
|
||||
kind: "patch",
|
||||
id: scale.tokenId,
|
||||
body: { mirrorToAppleHealth: false },
|
||||
});
|
||||
await step("06-wildcard", {
|
||||
kind: "patch",
|
||||
id: wildcard.tokenId,
|
||||
body: { mirrorToAppleHealth: true },
|
||||
});
|
||||
await step("07-connector", {
|
||||
kind: "patch",
|
||||
id: connector.tokenId,
|
||||
body: { mirrorToAppleHealth: true },
|
||||
});
|
||||
await step("08-another-account", {
|
||||
kind: "patch",
|
||||
id: theirs.tokenId,
|
||||
body: { mirrorToAppleHealth: true },
|
||||
});
|
||||
await step("09-unknown", {
|
||||
kind: "patch",
|
||||
id: "missing-token",
|
||||
body: { mirrorToAppleHealth: true },
|
||||
});
|
||||
await step("10-invalid-body", {
|
||||
kind: "patch",
|
||||
id: scale.tokenId,
|
||||
body: { mirrorToAppleHealth: "yes", name: "Renamed" },
|
||||
});
|
||||
await step("11-bearer", {
|
||||
kind: "patch",
|
||||
id: scale.tokenId,
|
||||
body: { mirrorToAppleHealth: true },
|
||||
auth: wildcard,
|
||||
});
|
||||
await step("12-summary-bearer", { kind: "summary", auth: wildcard });
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -248,64 +248,68 @@ afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("GET /api/workouts/{id} parity", () => {
|
||||
it("the detail reads", async () => {
|
||||
await step("get-01-canonical", { method: "GET", id: ids.run });
|
||||
await step("get-02-compact", {
|
||||
method: "GET",
|
||||
id: ids.run,
|
||||
query: { compact: "1" },
|
||||
// One walk: each request sees what the earlier ones stored, so the cases
|
||||
// keep their order even when a run shuffles (`shuffle: false`).
|
||||
describe("workout-detail parity walk", { shuffle: false }, () => {
|
||||
describe("GET /api/workouts/{id} parity", () => {
|
||||
it("the detail reads", async () => {
|
||||
await step("get-01-canonical", { method: "GET", id: ids.run });
|
||||
await step("get-02-compact", {
|
||||
method: "GET",
|
||||
id: ids.run,
|
||||
query: { compact: "1" },
|
||||
});
|
||||
await step("get-03-hidden-twin", { method: "GET", id: ids.twin });
|
||||
await step("get-04-ride", { method: "GET", id: ids.ride });
|
||||
await step("get-05-other-account", {
|
||||
method: "GET",
|
||||
id: ids.run,
|
||||
auth: stranger,
|
||||
});
|
||||
await step("get-06-missing", {
|
||||
method: "GET",
|
||||
id: "cmissingworkout000000000",
|
||||
});
|
||||
});
|
||||
await step("get-03-hidden-twin", { method: "GET", id: ids.twin });
|
||||
await step("get-04-ride", { method: "GET", id: ids.ride });
|
||||
await step("get-05-other-account", {
|
||||
method: "GET",
|
||||
id: ids.run,
|
||||
auth: stranger,
|
||||
});
|
||||
await step("get-06-missing", {
|
||||
method: "GET",
|
||||
id: "cmissingworkout000000000",
|
||||
|
||||
it("the module switched off", async () => {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { modulePreferencesJson: { workouts: false } },
|
||||
});
|
||||
await step("get-07-module-off", { method: "GET", id: ids.run });
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { modulePreferencesJson: {} },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("the module switched off", async () => {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { modulePreferencesJson: { workouts: false } },
|
||||
describe("DELETE /api/workouts/{id} parity", () => {
|
||||
it("a synced workout, a missing one, another account's", async () => {
|
||||
await step("delete-01-synced", {
|
||||
method: "DELETE",
|
||||
id: ids.ride,
|
||||
readBack: true,
|
||||
});
|
||||
await step("delete-02-missing", {
|
||||
method: "DELETE",
|
||||
id: "cmissingworkout000000000",
|
||||
});
|
||||
await step("delete-03-other-account", {
|
||||
method: "DELETE",
|
||||
id: ids.walk,
|
||||
auth: stranger,
|
||||
readBack: true,
|
||||
});
|
||||
});
|
||||
await step("get-07-module-off", { method: "GET", id: ids.run });
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { modulePreferencesJson: {} },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/workouts/{id} parity", () => {
|
||||
it("a synced workout, a missing one, another account's", async () => {
|
||||
await step("delete-01-synced", {
|
||||
method: "DELETE",
|
||||
id: ids.ride,
|
||||
readBack: true,
|
||||
});
|
||||
await step("delete-02-missing", {
|
||||
method: "DELETE",
|
||||
id: "cmissingworkout000000000",
|
||||
});
|
||||
await step("delete-03-other-account", {
|
||||
method: "DELETE",
|
||||
id: ids.walk,
|
||||
auth: stranger,
|
||||
readBack: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("a hand-entered workout and the record it set", async () => {
|
||||
await step("delete-04-manual", {
|
||||
method: "DELETE",
|
||||
id: ids.walk,
|
||||
readBack: true,
|
||||
it("a hand-entered workout and the record it set", async () => {
|
||||
await step("delete-04-manual", {
|
||||
method: "DELETE",
|
||||
id: ids.walk,
|
||||
readBack: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -238,7 +238,7 @@ afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("POST /api/workouts/batch parity", () => {
|
||||
describe("POST /api/workouts/batch parity", { shuffle: false }, () => {
|
||||
it("01 a first batch", async () => {
|
||||
await step("01-first-batch", { workouts: FIRST_BATCH });
|
||||
});
|
||||
|
||||
@@ -996,182 +996,194 @@ describe("every setting a person can choose", () => {
|
||||
expect(new Set(listed).size).toBe(listed.length);
|
||||
});
|
||||
|
||||
describe("set, read back, backed up, restored, read back again", () => {
|
||||
let owner: User;
|
||||
let session: SessionFixture;
|
||||
const before = new Map<number, unknown>();
|
||||
const meBefore = new Map<number, unknown>();
|
||||
let stored: Map<string, unknown>;
|
||||
let defaults: Map<string, unknown>;
|
||||
// One trip, in the order of its name: each leg reads what the leg before it
|
||||
// wrote. The order holds under a shuffled run.
|
||||
describe(
|
||||
"set, read back, backed up, restored, read back again",
|
||||
{ shuffle: false },
|
||||
() => {
|
||||
let owner: User;
|
||||
let session: SessionFixture;
|
||||
const before = new Map<number, unknown>();
|
||||
const meBefore = new Map<number, unknown>();
|
||||
let stored: Map<string, unknown>;
|
||||
let defaults: Map<string, unknown>;
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
owner = await makeUser({ onboardingCompletedAt: null });
|
||||
session = await makeSession(owner);
|
||||
defaults = columnsOf(await makeUser({ onboardingCompletedAt: null }));
|
||||
});
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
owner = await makeUser({ onboardingCompletedAt: null });
|
||||
session = await makeSession(owner);
|
||||
defaults = columnsOf(await makeUser({ onboardingCompletedAt: null }));
|
||||
});
|
||||
|
||||
it("stores each setting through its write route and answers it on its read route", async () => {
|
||||
const failures: string[] = [];
|
||||
for (const [index, entry] of entries(owner).entries()) {
|
||||
const label = entry.columns.join(", ");
|
||||
let written: RouteResult;
|
||||
let read: RouteResult;
|
||||
try {
|
||||
written = await call(entry.write, session);
|
||||
if (written.status >= 300) {
|
||||
failures.push(
|
||||
`${label}: write answered ${written.status} ${JSON.stringify(written.body).slice(0, 300)}`,
|
||||
);
|
||||
it("stores each setting through its write route and answers it on its read route", async () => {
|
||||
const failures: string[] = [];
|
||||
for (const [index, entry] of entries(owner).entries()) {
|
||||
const label = entry.columns.join(", ");
|
||||
let written: RouteResult;
|
||||
let read: RouteResult;
|
||||
try {
|
||||
written = await call(entry.write, session);
|
||||
if (written.status >= 300) {
|
||||
failures.push(
|
||||
`${label}: write answered ${written.status} ${JSON.stringify(written.body).slice(0, 300)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
read = await call(entry.read.call, session);
|
||||
} catch (error) {
|
||||
failures.push(`${label}: ${String(error).slice(0, 400)}`);
|
||||
continue;
|
||||
}
|
||||
read = await call(entry.read.call, session);
|
||||
} catch (error) {
|
||||
failures.push(`${label}: ${String(error).slice(0, 400)}`);
|
||||
continue;
|
||||
if (read.status !== 200) {
|
||||
failures.push(`${label}: read answered ${read.status}`);
|
||||
continue;
|
||||
}
|
||||
const value = entry.read.pick(read);
|
||||
before.set(index, value);
|
||||
if (
|
||||
"expected" in entry &&
|
||||
!isDeepStrictEqual(value, entry.expected)
|
||||
) {
|
||||
failures.push(
|
||||
`${label}: read back ${JSON.stringify(value)}, wrote ${JSON.stringify(entry.expected)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (read.status !== 200) {
|
||||
failures.push(`${label}: read answered ${read.status}`);
|
||||
continue;
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
|
||||
it("publishes the settings the account payload carries", async () => {
|
||||
const me = await callRoute(getMe, {
|
||||
method: "GET",
|
||||
route: "/api/auth/me",
|
||||
auth: session,
|
||||
});
|
||||
expect(me.status).toBe(200);
|
||||
const failures: string[] = [];
|
||||
for (const [index, entry] of entries(owner).entries()) {
|
||||
if (!entry.me) continue;
|
||||
const value = entry.me.pick(me.data);
|
||||
meBefore.set(index, value);
|
||||
if (
|
||||
"expected" in entry.me &&
|
||||
!isDeepStrictEqual(value, entry.me.expected)
|
||||
) {
|
||||
failures.push(
|
||||
`${entry.columns.join(", ")}: /api/auth/me has ${JSON.stringify(value)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const value = entry.read.pick(read);
|
||||
before.set(index, value);
|
||||
if ("expected" in entry && !isDeepStrictEqual(value, entry.expected)) {
|
||||
failures.push(
|
||||
`${label}: read back ${JSON.stringify(value)}, wrote ${JSON.stringify(entry.expected)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
|
||||
it("publishes the settings the account payload carries", async () => {
|
||||
const me = await callRoute(getMe, {
|
||||
method: "GET",
|
||||
route: "/api/auth/me",
|
||||
auth: session,
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
expect(me.status).toBe(200);
|
||||
const failures: string[] = [];
|
||||
for (const [index, entry] of entries(owner).entries()) {
|
||||
if (!entry.me) continue;
|
||||
const value = entry.me.pick(me.data);
|
||||
meBefore.set(index, value);
|
||||
if (
|
||||
"expected" in entry.me &&
|
||||
!isDeepStrictEqual(value, entry.me.expected)
|
||||
) {
|
||||
failures.push(
|
||||
`${entry.columns.join(", ")}: /api/auth/me has ${JSON.stringify(value)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
|
||||
it("moves every setting column off its default", async () => {
|
||||
stored = columnsOf(
|
||||
await prisma.user.findUniqueOrThrow({ where: { id: owner.id } }),
|
||||
);
|
||||
const untouched = SETTING_COLUMNS.filter(
|
||||
(column) =>
|
||||
comparable(stored.get(column)) === comparable(defaults.get(column)),
|
||||
);
|
||||
expect(
|
||||
untouched,
|
||||
"these columns still hold what a fresh account holds",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("comes back from a backup restored into a recreated account", async () => {
|
||||
const exported = await callRoute(exportFullBackup, {
|
||||
method: "GET",
|
||||
route: "/api/export/full-backup",
|
||||
auth: session,
|
||||
});
|
||||
expect(exported.status).toBe(200);
|
||||
|
||||
// The account is recreated under the same id, the way an operator
|
||||
// brings an account back on a new host, and nothing else of it remains.
|
||||
await prisma.user.delete({ where: { id: owner.id } });
|
||||
const fresh = await makeUser({
|
||||
id: owner.id,
|
||||
username: owner.username,
|
||||
email: owner.email,
|
||||
onboardingCompletedAt: null,
|
||||
});
|
||||
const admin = await makeAdmin();
|
||||
const operator = await makeSession(admin);
|
||||
|
||||
const uploaded = await callRoute<{ id: string }>(uploadBackup, {
|
||||
method: "POST",
|
||||
route: "/api/admin/backups/upload",
|
||||
body: exported.body,
|
||||
auth: operator,
|
||||
});
|
||||
expect(
|
||||
uploaded.status,
|
||||
JSON.stringify(uploaded.body).slice(0, 400),
|
||||
).toBeLessThan(300);
|
||||
const backupId = uploaded.data.id;
|
||||
|
||||
const restored = await callRoute(restoreBackup, {
|
||||
method: "POST",
|
||||
route: "/api/admin/backups/{id}/restore",
|
||||
params: { id: backupId },
|
||||
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, 400)).toBe(
|
||||
200,
|
||||
);
|
||||
|
||||
const after = columnsOf(
|
||||
await prisma.user.findUniqueOrThrow({ where: { id: fresh.id } }),
|
||||
);
|
||||
const lostInStorage = SETTING_COLUMNS.filter(
|
||||
(column) =>
|
||||
!SEALED.has(column) &&
|
||||
comparable(after.get(column)) !== comparable(stored.get(column)),
|
||||
);
|
||||
expect(
|
||||
lostInStorage.map(
|
||||
it("moves every setting column off its default", async () => {
|
||||
stored = columnsOf(
|
||||
await prisma.user.findUniqueOrThrow({ where: { id: owner.id } }),
|
||||
);
|
||||
const untouched = SETTING_COLUMNS.filter(
|
||||
(column) =>
|
||||
`${column}: ${comparable(stored.get(column))} -> ${comparable(after.get(column))}`,
|
||||
),
|
||||
"these columns came back with a different stored value",
|
||||
).toEqual([]);
|
||||
|
||||
const restoredSession = await makeSession(fresh);
|
||||
const failures: string[] = [];
|
||||
for (const [index, entry] of entries(fresh).entries()) {
|
||||
const read = await call(entry.read.call, restoredSession);
|
||||
const value =
|
||||
read.status === 200 ? entry.read.pick(read) : `status ${read.status}`;
|
||||
if (!isDeepStrictEqual(value, before.get(index))) {
|
||||
failures.push(
|
||||
`${entry.columns.join(", ")}: read ${JSON.stringify(before.get(index))} before, ${JSON.stringify(value)} after`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const me = await callRoute(getMe, {
|
||||
method: "GET",
|
||||
route: "/api/auth/me",
|
||||
auth: restoredSession,
|
||||
comparable(stored.get(column)) === comparable(defaults.get(column)),
|
||||
);
|
||||
expect(
|
||||
untouched,
|
||||
"these columns still hold what a fresh account holds",
|
||||
).toEqual([]);
|
||||
});
|
||||
for (const [index, entry] of entries(fresh).entries()) {
|
||||
if (!entry.me) continue;
|
||||
const value = entry.me.pick(me.data);
|
||||
if (!isDeepStrictEqual(value, meBefore.get(index))) {
|
||||
failures.push(
|
||||
`${entry.columns.join(", ")}: /api/auth/me had ${JSON.stringify(meBefore.get(index))}, now ${JSON.stringify(value)}`,
|
||||
);
|
||||
|
||||
it("comes back from a backup restored into a recreated account", async () => {
|
||||
const exported = await callRoute(exportFullBackup, {
|
||||
method: "GET",
|
||||
route: "/api/export/full-backup",
|
||||
auth: session,
|
||||
});
|
||||
expect(exported.status).toBe(200);
|
||||
|
||||
// The account is recreated under the same id, the way an operator
|
||||
// brings an account back on a new host, and nothing else of it remains.
|
||||
await prisma.user.delete({ where: { id: owner.id } });
|
||||
const fresh = await makeUser({
|
||||
id: owner.id,
|
||||
username: owner.username,
|
||||
email: owner.email,
|
||||
onboardingCompletedAt: null,
|
||||
});
|
||||
const admin = await makeAdmin();
|
||||
const operator = await makeSession(admin);
|
||||
|
||||
const uploaded = await callRoute<{ id: string }>(uploadBackup, {
|
||||
method: "POST",
|
||||
route: "/api/admin/backups/upload",
|
||||
body: exported.body,
|
||||
auth: operator,
|
||||
});
|
||||
expect(
|
||||
uploaded.status,
|
||||
JSON.stringify(uploaded.body).slice(0, 400),
|
||||
).toBeLessThan(300);
|
||||
const backupId = uploaded.data.id;
|
||||
|
||||
const restored = await callRoute(restoreBackup, {
|
||||
method: "POST",
|
||||
route: "/api/admin/backups/{id}/restore",
|
||||
params: { id: backupId },
|
||||
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, 400),
|
||||
).toBe(200);
|
||||
|
||||
const after = columnsOf(
|
||||
await prisma.user.findUniqueOrThrow({ where: { id: fresh.id } }),
|
||||
);
|
||||
const lostInStorage = SETTING_COLUMNS.filter(
|
||||
(column) =>
|
||||
!SEALED.has(column) &&
|
||||
comparable(after.get(column)) !== comparable(stored.get(column)),
|
||||
);
|
||||
expect(
|
||||
lostInStorage.map(
|
||||
(column) =>
|
||||
`${column}: ${comparable(stored.get(column))} -> ${comparable(after.get(column))}`,
|
||||
),
|
||||
"these columns came back with a different stored value",
|
||||
).toEqual([]);
|
||||
|
||||
const restoredSession = await makeSession(fresh);
|
||||
const failures: string[] = [];
|
||||
for (const [index, entry] of entries(fresh).entries()) {
|
||||
const read = await call(entry.read.call, restoredSession);
|
||||
const value =
|
||||
read.status === 200
|
||||
? entry.read.pick(read)
|
||||
: `status ${read.status}`;
|
||||
if (!isDeepStrictEqual(value, before.get(index))) {
|
||||
failures.push(
|
||||
`${entry.columns.join(", ")}: read ${JSON.stringify(before.get(index))} before, ${JSON.stringify(value)} after`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
});
|
||||
const me = await callRoute(getMe, {
|
||||
method: "GET",
|
||||
route: "/api/auth/me",
|
||||
auth: restoredSession,
|
||||
});
|
||||
for (const [index, entry] of entries(fresh).entries()) {
|
||||
if (!entry.me) continue;
|
||||
const value = entry.me.pick(me.data);
|
||||
if (!isDeepStrictEqual(value, meBefore.get(index))) {
|
||||
failures.push(
|
||||
`${entry.columns.join(", ")}: /api/auth/me had ${JSON.stringify(meBefore.get(index))}, now ${JSON.stringify(value)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -33,7 +33,7 @@
|
||||
* the generation leg asserts both halves — nothing enqueued for the manager,
|
||||
* something enqueued for the owner doing the identical thing.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -272,6 +272,8 @@ const STRICT_DRIVER_KEYS = new Set<string>();
|
||||
* It is the executable answer to "did this route actually do its work?".
|
||||
*/
|
||||
const REAL_EFFECT_KEYS = new Set<string>();
|
||||
/** The drivers whose effect case started in this run, filtered or not. */
|
||||
const EFFECT_CASES_STARTED = new Set<string>();
|
||||
const ROUTE_IMPORTS = import.meta.glob<Record<string, unknown>>(
|
||||
"/src/app/api/**/route.ts",
|
||||
);
|
||||
@@ -305,6 +307,7 @@ function manageContract<C>(name: string, c: ManageCase<C>) {
|
||||
STRICT_DRIVER_KEYS.add(matrixKey(c.contract));
|
||||
describe(name, () => {
|
||||
it("changes the owner's record and names the manager as actor", async () => {
|
||||
EFFECT_CASES_STARTED.add(matrixKey(c.contract));
|
||||
const owner = await makeUser("owner");
|
||||
const manager = await makeUser("manager");
|
||||
const ctx = await c.prepare(owner.id);
|
||||
@@ -381,6 +384,7 @@ function writeEffectContract<C>(name: string, c: ManageCase<C>) {
|
||||
});
|
||||
|
||||
it("writes for the owner and audits the WRITE actor", async () => {
|
||||
EFFECT_CASES_STARTED.add(matrixKey(c.contract));
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("writer");
|
||||
const ctx = await c.prepare(owner.id);
|
||||
@@ -3019,10 +3023,15 @@ describe("the complete MANAGE handler matrix", () => {
|
||||
expect(STRICT_DRIVER_KEYS.size).toBe(93);
|
||||
expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it("executes every registered driver through its owned effect and actor audit", () => {
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
|
||||
expect(REAL_EFFECT_KEYS.size).toBe(93);
|
||||
expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected);
|
||||
});
|
||||
// Every registered driver went through its owned effect and actor audit.
|
||||
// After all cases, not as one of them: the set fills as the effect cases
|
||||
// run, and a case placed last in the file was read first whenever the order
|
||||
// was shuffled. A run filtered down to some cases has nothing to say here.
|
||||
afterAll(() => {
|
||||
if (EFFECT_CASES_STARTED.size !== STRICT_DRIVER_KEYS.size) return;
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
|
||||
expect(REAL_EFFECT_KEYS.size).toBe(93);
|
||||
expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected);
|
||||
});
|
||||
Reference in new issue
Block a user