diff --git a/admin/src/settings/SettingsScreen.test.tsx b/admin/src/settings/SettingsScreen.test.tsx index 51c6784..94a0609 100644 --- a/admin/src/settings/SettingsScreen.test.tsx +++ b/admin/src/settings/SettingsScreen.test.tsx @@ -432,14 +432,18 @@ describe("SettingsScreen — preferences", () => { it("a 422 shows the API detail naming the field and keeps the edit", async () => { const { form, onClose } = await renderPreferences(); - fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "0" } }); - writeResponse = () => jsonResponse({ detail: "horizon_days must be >= 1" }, 422); + fireEvent.change(field(form, "Notify target"), { target: { value: "notify.nobody" } }); + writeResponse = () => jsonResponse({ detail: "notify_target is not a notify service" }, 422); save(form); const message = await screen.findByTestId("preferences-message"); - expect(message.textContent).toBe("The preferences could not be saved: horizon_days must be >= 1"); + expect(message.textContent).toBe( + "The preferences could not be saved: notify_target is not a notify service", + ); expect(message.getAttribute("role")).toBe("alert"); - expect(field(form, "Planning horizon (days)").value).toBe("0"); - expect(writes()).toEqual([{ method: "PATCH", path: SETTINGS_PATH, body: { horizon_days: 0 } }]); + expect(field(form, "Notify target").value).toBe("notify.nobody"); + expect(writes()).toEqual([ + { method: "PATCH", path: SETTINGS_PATH, body: { notify_target: "notify.nobody" } }, + ]); expect(onClose).not.toHaveBeenCalled(); }); @@ -453,12 +457,17 @@ describe("SettingsScreen — preferences", () => { expect(checkbox(form, "Celebration").checked).toBe(true); }); - it("a malformed horizon is rejected before any request", async () => { + it.each([ + ["empty", ""], + ["zero", "0"], + ["oversized (Infinity would serialize as null and reset the field)", "9".repeat(400)], + ["past the safe-integer range", "123456789012345678901"], + ])("a %s horizon is rejected before any request", async (_case, value) => { const { form } = await renderPreferences(); - fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "" } }); + fireEvent.change(field(form, "Planning horizon (days)"), { target: { value } }); save(form); expect(screen.getByTestId("preferences-message").textContent).toBe( - "Planning horizon must be a whole number of days.", + "Planning horizon must be a whole number of days, at least 1.", ); expect(writes()).toEqual([]); }); diff --git a/admin/src/settings/SettingsScreen.tsx b/admin/src/settings/SettingsScreen.tsx index fdc16f2..57c46d4 100644 --- a/admin/src/settings/SettingsScreen.tsx +++ b/admin/src/settings/SettingsScreen.tsx @@ -262,7 +262,12 @@ function preferencesDraft(settings: HouseholdSettings): PreferencesDraft { /** Only the fields that differ from `loaded`, or a validation message. */ function buildChanges(draft: PreferencesDraft, loaded: HouseholdSettings): SettingsChanges | string { const horizon = draft.horizon_days.trim(); - if (!/^\d+$/.test(horizon)) return "Planning horizon must be a whole number of days."; + const horizonDays = Number(horizon); + // A digit string too long for a safe integer would become Infinity, which + // JSON serializes as null, and the API reads null as "reset to default". + if (!/^\d+$/.test(horizon) || !Number.isSafeInteger(horizonDays) || horizonDays < 1) { + return "Planning horizon must be a whole number of days, at least 1."; + } const times: [TimeField, string][] = [ ["day_rollover_time", "Day rollover"], ...NOTIFICATIONS.map((n): [TimeField, string] => [n.time, `${n.label} time`]), @@ -273,7 +278,7 @@ function buildChanges(draft: PreferencesDraft, loaded: HouseholdSettings): Setti const next: HouseholdSettings = { ...draft, - horizon_days: Number(horizon), + horizon_days: horizonDays, notify_target: draft.notify_target.trim(), }; const changes: SettingsChanges = {};