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:
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user