mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user