Merge branch 'fix/v147-nightly' into release/v1.47.0

This commit is contained in:
Marc-André Bombeck committed 2026-11-03 22:30:50 +01:00
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 () => {
+20 -1
View File
@@ -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(
+7 -2
View File
@@ -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",