Feature 19 follow-up: Settings preferences section (#224) (#225)

Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
This commit was merged in pull request #225.
This commit is contained in:
2026-09-24 16:17:52 -04:00
committed by approver_agent
parent 1eb136fc67
commit 9cdc69ea72
4 changed files with 526 additions and 4 deletions
+196
View File
@@ -3,10 +3,25 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-li
import App from "../App";
import SettingsScreen, { SCROLL_BOTTOM_PADDING } from "./SettingsScreen";
import type { AdminChild } from "../api/definitions";
import type { HouseholdSettings } from "../api/settings";
import { storeTokens } from "../auth/oidc";
const CHILDREN_PATH = "/api/v1/admin/children";
const REORDER_PATH = `${CHILDREN_PATH}/reorder`;
const SETTINGS_PATH = "/api/v1/admin/settings";
const DEFAULT_SETTINGS: HouseholdSettings = {
horizon_days: 14,
day_rollover_time: "03:00",
notify_target: "notify.family",
morning_summary_time: "07:00",
afternoon_reminder_time: "15:30",
end_of_day_report_time: "20:00",
morning_summary_enabled: true,
afternoon_reminder_enabled: false,
end_of_day_report_enabled: true,
celebration_enabled: false,
};
function child(
id: number,
@@ -31,6 +46,7 @@ interface Call {
}
let children: AdminChild[];
let settings: HouseholdSettings;
/** Overrides the next write's response (the store is left untouched). */
let writeResponse: (() => Response) | null;
let fetchMock: ReturnType<typeof vi.fn>;
@@ -64,6 +80,11 @@ async function routeFetch(url: string, init: RequestInit = {}): Promise<Response
return respond();
}
if (path === CHILDREN_PATH && method === "GET") return jsonResponse({ children });
if (path === SETTINGS_PATH && method === "GET") return jsonResponse(settings);
if (path === SETTINGS_PATH && method === "PATCH") {
settings = { ...settings, ...body };
return jsonResponse(settings);
}
if (path === CHILDREN_PATH && method === "POST") {
const created = child(9, body.display_name, body.sort_order ?? 0, {
colour: body.colour ?? null,
@@ -124,6 +145,7 @@ describe("SettingsScreen — children", () => {
child(1, "Declan", 1, { colour: "#0ea5e9", avatar_ref: "fox" }),
child(3, "Rory", 3, { is_active: false }),
];
settings = { ...DEFAULT_SETTINGS };
writeResponse = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
@@ -316,3 +338,177 @@ describe("SettingsScreen — children", () => {
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Settings" }));
});
});
describe("SettingsScreen — preferences", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
children = [child(1, "Declan", 1)];
settings = { ...DEFAULT_SETTINGS };
writeResponse = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
async function renderPreferences(onClose = vi.fn()) {
render(<SettingsScreen onClose={onClose} />);
await screen.findByTestId("settings-child-1");
const form = await screen.findByRole("form", { name: "Preferences" });
return { form, onClose };
}
function checkbox(form: HTMLElement, label: string): HTMLInputElement {
return within(form).getByRole("checkbox", { name: label }) as HTMLInputElement;
}
function save(form: HTMLElement) {
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
}
it("renders the current value of all ten fields", async () => {
const { form } = await renderPreferences();
expect(field(form, "Planning horizon (days)").value).toBe("14");
expect(field(form, "Day rollover").value).toBe("03:00");
expect(field(form, "Notify target").value).toBe("notify.family");
expect(field(form, "Morning summary time").value).toBe("07:00");
expect(field(form, "Afternoon reminder time").value).toBe("15:30");
expect(field(form, "End-of-day report time").value).toBe("20:00");
expect(checkbox(form, "Morning summary").checked).toBe(true);
expect(checkbox(form, "Afternoon reminder").checked).toBe(false);
expect(checkbox(form, "End-of-day report").checked).toBe(true);
expect(checkbox(form, "Celebration").checked).toBe(false);
});
it("saving one changed field patches exactly that field", async () => {
const { form } = await renderPreferences();
fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } });
save(form);
await screen.findByText("Preferences saved.");
expect(writes()).toEqual([{ method: "PATCH", path: SETTINGS_PATH, body: { horizon_days: 21 } }]);
const reloaded = screen.getByRole("form", { name: "Preferences" });
expect(field(reloaded, "Planning horizon (days)").value).toBe("21");
});
it("saving several changed fields sends exactly those", async () => {
const { form } = await renderPreferences();
fireEvent.change(field(form, "Day rollover"), { target: { value: "04:15" } });
fireEvent.change(field(form, "Notify target"), { target: { value: " notify.parents " } });
fireEvent.change(field(form, "End-of-day report time"), { target: { value: "21:45" } });
fireEvent.click(checkbox(form, "Afternoon reminder"));
fireEvent.click(checkbox(form, "Celebration"));
save(form);
await screen.findByText("Preferences saved.");
expect(writes()).toEqual([
{
method: "PATCH",
path: SETTINGS_PATH,
body: {
day_rollover_time: "04:15",
notify_target: "notify.parents",
end_of_day_report_time: "21:45",
afternoon_reminder_enabled: true,
celebration_enabled: true,
},
},
]);
const reloaded = screen.getByRole("form", { name: "Preferences" });
expect(checkbox(reloaded, "Celebration").checked).toBe(true);
expect(field(reloaded, "Notify target").value).toBe("notify.parents");
});
it("with nothing changed it says so and sends no request", async () => {
const { form } = await renderPreferences();
fireEvent.click(checkbox(form, "Celebration"));
fireEvent.click(checkbox(form, "Celebration"));
save(form);
expect(screen.getByTestId("preferences-message").textContent).toBe("No changes to save.");
expect(writes()).toEqual([]);
});
it("a 422 shows the API detail naming the field and keeps the edit", async () => {
const { form, onClose } = await renderPreferences();
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: notify_target is not a notify service",
);
expect(message.getAttribute("role")).toBe("alert");
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();
});
it("a 403 on save shows the refusal message", async () => {
const { form } = await renderPreferences();
fireEvent.click(checkbox(form, "Celebration"));
writeResponse = () => jsonResponse({ detail: "forbidden" }, 403);
save(form);
const message = await screen.findByTestId("preferences-message");
expect(message.textContent).toContain("Your account is not in the nestquest-admins group.");
expect(checkbox(form, "Celebration").checked).toBe(true);
});
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 } });
save(form);
expect(screen.getByTestId("preferences-message").textContent).toBe(
"Planning horizon must be a whole number of days, at least 1.",
);
expect(writes()).toEqual([]);
});
it("disables every write control while a save is in flight (no double submit)", async () => {
const { form } = await renderPreferences();
let release!: () => void;
const gate = new Promise<void>((r) => {
release = r;
});
fetchMock.mockImplementationOnce(async (url: string, init?: RequestInit) => {
await gate;
return routeFetch(url, init);
});
fireEvent.click(checkbox(form, "Celebration"));
const button = within(form).getByRole("button", { name: "Save preferences" }) as HTMLButtonElement;
fireEvent.click(button);
fireEvent.click(button);
await waitFor(() => expect(button.disabled).toBe(true));
expect(checkbox(form, "Celebration").disabled).toBe(true);
expect(field(form, "Planning horizon (days)").disabled).toBe(true);
expect((row(1).getByRole("button", { name: "Edit Declan" }) as HTMLButtonElement).disabled).toBe(true);
expect(writes()).toHaveLength(1);
release();
await screen.findByText("Preferences saved.");
expect(writes()).toHaveLength(1);
});
it("a failed load shows the reason and Try again refetches", async () => {
let fail = true;
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
if (fail && new URL(url, "http://x").pathname === SETTINGS_PATH) {
return jsonResponse({ detail: "boom" }, 500);
}
return routeFetch(url, init);
});
render(<SettingsScreen onClose={vi.fn()} />);
const error = await screen.findByTestId("preferences-error");
expect(error.textContent).toContain("Preferences could not be loaded: boom");
fail = false;
fireEvent.click(within(error).getByRole("button", { name: "Try again" }));
await screen.findByRole("form", { name: "Preferences" });
});
});