milestone 32: task-shaped configuration, file mode as a rendering, config status api

This commit is contained in:
2026-08-22 22:42:50 +02:00
parent c99a37d170
commit 24521ab9a9
89 changed files with 6101 additions and 3750 deletions
+218 -97
View File
@@ -1,121 +1,78 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { ClientName, type ClientNames } from "./clientNames";
import { BASE, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures";
const GROUPS = {
groups: [
{ id: 1, name: "default", safe_search: false },
{ id: 2, name: "kids", safe_search: true },
],
};
const CLIENTS = {
clients: [
{
id: 1,
ip: "192.168.1.10",
name: "laptop",
learned_name: "laptop-1.lan",
group_id: 1,
group: "default",
hand_edited: true,
first_seen: 1700000000,
last_seen: 1700003600,
},
{
id: 2,
ip: "192.168.1.11",
name: "",
learned_name: "kids-tablet.lan",
group_id: 2,
group: "kids",
hand_edited: false,
first_seen: 1700000000,
last_seen: 1700007200,
},
],
};
const PREFIXES = {
client_prefixes: [{ id: 1, prefix: "192.168.1.0/24", group_id: 2, group: "kids", priority: 100 }],
};
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
function stubFetch(map: Record<string, unknown>) {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const key = `${init?.method ?? "GET"} ${String(input)}`;
const payload = map[key];
if (payload === undefined) {
return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 });
}
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
function clientRow(ip: string): HTMLElement {
const row = screen.getByText(ip).closest("tr");
if (row === null) throw new Error(`no client row for ${ip}`);
return row;
}
async function renderClientsPage(map: Record<string, unknown>) {
stubFetch(map);
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/clients"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
await screen.findByRole("heading", { name: "Clients" });
function assignmentsSection(): HTMLElement {
const heading = screen.getByRole("heading", { name: /Network assignments/ });
const section = heading.closest("section");
if (section === null) throw new Error("no network assignments section");
return section;
}
const BASE = {
"GET /api/clients": CLIENTS,
"GET /api/client-prefixes": PREFIXES,
"GET /api/groups": GROUPS,
"GET /api/version": VERSION,
};
/**
* Editing a client writes configuration, so the affordance is absent until the
* status query says the database owns it — never disabled, never assumed.
*/
async function unlockedEdit(index: number): Promise<HTMLButtonElement> {
const buttons = await screen.findAllByRole("button", { name: "Edit" });
return buttons[index] as HTMLButtonElement;
}
afterEach(() => {
vi.unstubAllGlobals();
});
test("renders the client table with group names and one hand-edited badge", async () => {
await renderClientsPage(BASE);
test("renders the identity-first table: address, name, group, first and last seen", async () => {
await renderClientsPage();
const headers = screen.getAllByRole("columnheader").map((cell) => cell.textContent);
expect(headers).toEqual(["Address", "Name", "Group", "First seen", "Last seen", "Actions"]);
expect(screen.getByText("192.168.1.10")).toBeTruthy();
expect(screen.getByText("192.168.1.11")).toBeTruthy();
expect(screen.getByText("laptop")).toBeTruthy();
expect(screen.getAllByText("edited")).toHaveLength(1);
expect(screen.getAllByRole("cell", { name: "kids" })).toHaveLength(1);
});
test("a named row shows the typed name and hides the learned one", async () => {
await renderClientsPage(BASE);
await renderClientsPage();
expect(screen.getByText("laptop")).toBeTruthy();
expect(screen.queryByText("laptop-1.lan")).toBeNull();
});
test("an unnamed row shows the learned name with the learned affordance", async () => {
await renderClientsPage(BASE);
test("an unnamed row shows the learned name with the learned marker", async () => {
await renderClientsPage();
// The cell holds the learned name followed by the tag, so the match is on
// The cell holds the learned name followed by the marker, so the match is on
// the containing span rather than on a bare text node.
const learned = screen.getByText(
(content, element) => element?.tagName === "SPAN" && content.startsWith("kids-tablet.lan"),
);
// The affordance is text, not colour, so a screen reader announces it too.
// The marker is text, not colour, so a screen reader announces it too.
expect(within(learned).getByText("learned")).toBeTruthy();
});
test("the learned marker is a Clients-page affordance: query tables render the same name without it", () => {
const names: ClientNames = new Map([["192.168.1.11", { name: "", learned_name: "kids-tablet.lan" }]]);
render(<ClientName ip="192.168.1.11" names={names} />);
expect(screen.getByText("kids-tablet.lan")).toBeTruthy();
expect(screen.queryByText("learned")).toBeNull();
});
test("the address links to the client's detail page", async () => {
await renderClientsPage();
const link = within(clientRow("192.168.1.10")).getByRole("link", { name: "192.168.1.10" });
expect(link.getAttribute("href")).toBe("/clients/1");
});
test("shows the DNS-activity empty state when there are no clients", async () => {
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
@@ -124,9 +81,9 @@ test("shows the DNS-activity empty state when there are no clients", async () =>
});
test("edit opens a dialog seeded with the client's name and group", async () => {
await renderClientsPage(BASE);
await renderClientsPage();
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
fireEvent.click(await unlockedEdit(0));
// The dialog portals out of the table, so every field query is scoped to it.
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
expect((dialog.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
@@ -135,9 +92,9 @@ test("edit opens a dialog seeded with the client's name and group", async () =>
});
test("the group picker offers every group and reports the choice", async () => {
await renderClientsPage(BASE);
await renderClientsPage();
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
fireEvent.click(await unlockedEdit(0));
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
fireEvent.click(dialog.getByRole("button", { name: /Group$/ }));
@@ -148,14 +105,178 @@ test("the group picker offers every group and reports the choice", async () => {
expect(screen.getByRole("button", { name: /Group$/ }).textContent).toContain("kids");
});
test("prefix editor starts clean and dirties on add", async () => {
await renderClientsPage(BASE);
test("network assignments start clean and dirty on add", async () => {
await renderClientsPage();
expect((screen.getByLabelText("Prefix 1") as HTMLInputElement).value).toBe("192.168.1.0/24");
const save = screen.getByRole("button", { name: "Save prefixes" }) as HTMLButtonElement;
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
expect(range.value).toBe("192.168.1.0/24");
const save = screen.getByRole("button", { name: "Save assignments" }) as HTMLButtonElement;
expect(save.disabled).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Add prefix" }));
fireEvent.click(screen.getByRole("button", { name: "Add range" }));
expect(save.disabled).toBe(false);
expect((screen.getByLabelText("Prefix 2") as HTMLInputElement).value).toBe("");
expect((screen.getByLabelText("Range 2") as HTMLInputElement).value).toBe("");
});
test("?group= filters the list to that group", async () => {
const { router } = await renderClientsPage();
await router.navigate({ to: "/clients", search: { group: 2 } });
await waitFor(() => expect(screen.queryByText("192.168.1.10")).toBeNull());
expect(screen.getByText("192.168.1.11")).toBeTruthy();
expect(screen.getByText("Showing clients in kids.")).toBeTruthy();
});
test("an unknown group id filters to nothing and offers a way out", async () => {
const { router } = await renderClientsPage();
await router.navigate({ to: "/clients", search: { group: 999 } });
await screen.findByText("No group with id 999 exists.");
expect(screen.getByText("No clients match this filter.")).toBeTruthy();
expect(screen.queryByRole("table")).toBeNull();
fireEvent.click(screen.getByRole("link", { name: "Clear filter" }));
await waitFor(() => expect(screen.getByText("192.168.1.10")).toBeTruthy());
expect(router.state.location.search).toEqual({});
});
test("file mode drops every edit affordance and keeps the observed delete live (R2-4)", async () => {
await renderClientsPage({ ...BASE, "GET /api/config/status": MANAGED_FILE });
await screen.findAllByLabelText(/Managed by \/etc\/nxdns\/config\.zon/);
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
const declared = clientRow("192.168.1.10");
const observed = clientRow("192.168.1.11");
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
});
test("file mode renders network assignments with no mutation control at all (R2-4)", async () => {
await renderClientsPage({ ...BASE, "GET /api/config/status": MANAGED_FILE });
const section = await waitFor(() => {
const found = assignmentsSection();
if (found.querySelector("table") === null) throw new Error("still editing");
return found;
});
expect(within(section).getByText("192.168.1.0/24")).toBeTruthy();
expect(
section.querySelectorAll(
"input, textarea, select, [role='combobox'], [role='checkbox'], [contenteditable], button",
),
).toHaveLength(0);
});
test("a failed config status exposes no configuration mutation, and still deletes an observed client (R3-4)", async () => {
await renderClientsPage({ ...BASE, "GET /api/config/status": undefined });
await screen.findAllByLabelText(/Configuration status unavailable/);
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
expect(within(assignmentsSection()).getByText(/Configuration status unavailable/)).toBeTruthy();
const observed = clientRow("192.168.1.11");
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
const declared = clientRow("192.168.1.10");
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
});
// The status query polls, so authority can turn while a dialog or a delete
// confirmation is already open. `undefined` is the failed status: the fetch stub
// answers 404 for a key it does not hold.
//
// Both statuses lock the declared delete, but only file authority proves the
// file declares the row; the anchors keep the two sentences apart.
const DECLARED_NOTE = /^This client is declared in the configuration file/;
const UNKNOWN_NOTE = /^nxdns cannot say whether this client is declared/;
describe.each([
["file authority", MANAGED_FILE, DECLARED_NOTE, UNKNOWN_NOTE],
["a failed status", undefined, UNKNOWN_NOTE, DECLARED_NOTE],
])("authority turning to %s under an open affordance", (_label, status, lockNote, otherNote) => {
test("locks the open edit dialog's save path and leaves cancel working", async () => {
const map = { ...BASE };
const { queryClient } = await renderClientsPage(map);
fireEvent.click(await unlockedEdit(0));
expect(
within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" })).getByRole("button", {
name: "Save",
}),
).toBeTruthy();
await setConfigStatus(map, queryClient, status);
// The draft survives; only the save path goes, and it says why.
const dialog = screen.getByRole("dialog", { name: "Edit client 192.168.1.10" });
expect(within(dialog).queryByRole("button", { name: "Save" })).toBeNull();
expect((within(dialog).getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
expect(within(dialog).getByText(/can no longer be saved/)).toBeTruthy();
expect(within(dialog).getByLabelText(/^Locked\./)).toBeTruthy();
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
test("locks an open declared-client delete confirmation and leaves cancel working", async () => {
const map = { ...BASE };
const { queryClient, fetchMock } = await renderClientsPage(map);
// The Edit affordance appearing is the proof authority resolved to
// database; the transition under test starts from there.
await unlockedEdit(0);
const declared = clientRow("192.168.1.10");
fireEvent.click(within(declared).getByRole("button", { name: "Delete" }));
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Confirm delete" })).toBeTruthy();
await setConfigStatus(map, queryClient, status);
const confirming = clientRow("192.168.1.10");
expect(within(confirming).queryByRole("button", { name: "Confirm delete" })).toBeNull();
expect(within(confirming).getByText(lockNote)).toBeTruthy();
expect(within(confirming).queryByText(otherNote)).toBeNull();
expect(within(confirming).getByLabelText(/^Locked\./)).toBeTruthy();
fireEvent.click(within(confirming).getByRole("button", { name: "Cancel" }));
await waitFor(() =>
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" })).toBeTruthy(),
);
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
});
test("keeps an open observed-client delete confirmation live (R3-4)", async () => {
const map = { ...BASE, "DELETE /api/clients/2": {} };
const { queryClient, fetchMock } = await renderClientsPage(map);
// The Edit affordance appearing is the proof authority resolved to
// database; the transition under test starts from there.
await unlockedEdit(0);
const observed = clientRow("192.168.1.11");
fireEvent.click(within(observed).getByRole("button", { name: "Delete" }));
await setConfigStatus(map, queryClient, status);
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Confirm delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.filter(
([input, init]) => init?.method === "DELETE" && String(input).endsWith("/2"),
),
).toHaveLength(1),
);
});
});
test("a pending config status holds the same line as a failed one (R3-4)", async () => {
// The status request never settles, so authority stays pending for the whole
// test: nothing configuration owns may be offered on that guess.
await renderClientsPage({ ...BASE, "GET /api/config/status": NEVER });
await screen.findAllByLabelText(/Checking which configuration source/);
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
expect(within(assignmentsSection()).getByText(/Checking which configuration source/)).toBeTruthy();
const observed = clientRow("192.168.1.11");
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
});