milestone 32: task-shaped configuration, file mode as a rendering, config status api
Gates / frontend (push) Successful in 1m22s
Gates / test (push) Successful in 1m54s
Gates / test-aarch64 (push) Successful in 7m57s
Gates / package (push) Successful in 5m29s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 32m51s
Gates / frontend (push) Successful in 1m22s
Gates / test (push) Successful in 1m54s
Gates / test-aarch64 (push) Successful in 7m57s
Gates / package (push) Successful in 5m29s
Gates / container (push) Successful in 10s
CI / gates (push) Successful in 32m51s
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* The Clients pages under a real router: both of them read the same list, and
|
||||
* the detail route is reached by deep link as often as by click, so the tests
|
||||
* drive the router rather than the components.
|
||||
*/
|
||||
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
import { createQueryClient } from "@/lib/queryClient";
|
||||
import { queryKeys } from "@/lib/queries";
|
||||
import { createAppRouter } from "@/routes";
|
||||
|
||||
export const GROUPS = {
|
||||
groups: [
|
||||
{ id: 1, name: "default", safe_search: false },
|
||||
{ id: 2, name: "kids", safe_search: true },
|
||||
],
|
||||
};
|
||||
|
||||
export 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export 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 };
|
||||
|
||||
export const DATABASE = { authority: "database", path: null, reconciled_at: null, restart_pending: false };
|
||||
export const MANAGED_FILE = {
|
||||
authority: "managed_file",
|
||||
path: "/etc/nxdns/config.zon",
|
||||
reconciled_at: 1754899200,
|
||||
restart_pending: false,
|
||||
};
|
||||
|
||||
export const BASE: Record<string, unknown> = {
|
||||
"GET /api/clients": CLIENTS,
|
||||
"GET /api/client-prefixes": PREFIXES,
|
||||
"GET /api/groups": GROUPS,
|
||||
"GET /api/version": VERSION,
|
||||
"GET /api/config/status": DATABASE,
|
||||
};
|
||||
|
||||
/** A request that never settles, so its query stays pending for the whole test. */
|
||||
export const NEVER = Symbol("never");
|
||||
|
||||
export function stubFetch(map: Record<string, unknown>): ReturnType<typeof vi.fn> {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const key = `${init?.method ?? "GET"} ${String(input)}`;
|
||||
const payload = map[key];
|
||||
if (payload === NEVER) return new Promise<Response>(() => {});
|
||||
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" },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
export function renderAt(path: string, map: Record<string, unknown>) {
|
||||
const fetchMock = stubFetch(map);
|
||||
const queryClient = createQueryClient();
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
return { router, fetchMock, queryClient };
|
||||
}
|
||||
|
||||
export async function renderClientsPage(map: Record<string, unknown> = BASE) {
|
||||
const handles = renderAt("/clients", map);
|
||||
await screen.findByRole("heading", { name: "Clients" });
|
||||
return handles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns configuration authority under a mounted page, the way the status
|
||||
* query's own poll would: the stub reads the map on each call, so replacing the
|
||||
* entry and refetching is the whole transition. `undefined` stands for a failed
|
||||
* status — the stub answers 404 for a key it does not hold.
|
||||
*/
|
||||
export async function setConfigStatus(
|
||||
map: Record<string, unknown>,
|
||||
queryClient: QueryClient,
|
||||
status: unknown,
|
||||
): Promise<void> {
|
||||
map["GET /api/config/status"] = status;
|
||||
await act(async () => {
|
||||
await queryClient.refetchQueries({ queryKey: queryKeys.configStatus });
|
||||
// The cache lands before its observers are notified — react-query defers
|
||||
// that notification — so the render pass needs one more turn inside act.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user