milestone 29: activity — history, live and policy simulation on one surface
Gates / test (push) Successful in 1m40s
Gates / package (push) Successful in 3m58s
Gates / container (push) Successful in 14s
CI / gates (push) Successful in 12m51s
Gates / frontend (push) Successful in 1m18s
Gates / test-aarch64 (push) Successful in 6m57s
Gates / test (push) Successful in 1m40s
Gates / package (push) Successful in 3m58s
Gates / container (push) Successful in 14s
CI / gates (push) Successful in 12m51s
Gates / frontend (push) Successful in 1m18s
Gates / test-aarch64 (push) Successful in 6m57s
query log, live and lookup merge into /activity. history filters live in the url, so a pasted link or back/forward reproduces the exact view; the result column separates servfail and nxdomain from success in the list. live is follow-by-default with freeze, and a streamed row opens its in-memory provenance detail — no correlation invented for rows sqlite has not written. lookup survives as the current policy simulation under /activity/test. investigation links carry absolute bounds, and the diagnostics page now honors since/until instead of ignoring them. the old routes are gone without aliases.
This commit is contained in:
@@ -0,0 +1,554 @@
|
||||
/**
|
||||
* Activity in history mode, through the real router: the URL is the applied
|
||||
* state, so nothing here can be checked by rendering the page on its own.
|
||||
*/
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor, 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 type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
|
||||
import { queryRow } from "@/features/queries/provenanceFixture";
|
||||
|
||||
function client(id: number, ip: string, name: string, learnedName: string): Client {
|
||||
return {
|
||||
id,
|
||||
ip,
|
||||
name,
|
||||
learned_name: learnedName,
|
||||
group_id: 1,
|
||||
group: "default",
|
||||
hand_edited: name !== "",
|
||||
first_seen: 1_700_000_000,
|
||||
last_seen: 1_700_000_100,
|
||||
};
|
||||
}
|
||||
|
||||
const CLIENTS: Client[] = [
|
||||
client(1, "192.0.2.10", "Kitchen Pi", "pi.lan"),
|
||||
client(2, "192.0.2.11", "", "laptop.lan"),
|
||||
client(3, "192.0.2.12", "", ""),
|
||||
];
|
||||
|
||||
function row(id: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow {
|
||||
return queryRow(id, { ts: 1_700_000_000 + id, domain, upstream: "udp://9.9.9.9:53", ...overrides });
|
||||
}
|
||||
|
||||
const COMPLETE: Coverage = { complete: true, available_since: 1_600_000_000 };
|
||||
|
||||
/** The blocked row every page fixture reuses. */
|
||||
const BLOCKED = {
|
||||
blocked: true,
|
||||
policy_action: "block",
|
||||
policy_reason: "blocklist_wildcard",
|
||||
route_kind: "blocked",
|
||||
upstream: "",
|
||||
} as const satisfies Partial<QueryRow>;
|
||||
|
||||
const PAGES: Record<string, QueriesPage> = {
|
||||
"/api/queries": {
|
||||
queries: [
|
||||
row(20, "first.example", { qtype: 65, cache_hit: true, route_kind: "cache" }),
|
||||
row(19, "ads.example", { ...BLOCKED, response_time_us: null, cache_hit: null }),
|
||||
],
|
||||
next_before: 19,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
"/api/queries?before=19": {
|
||||
queries: [row(5, "older.example")],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
"/api/queries?domain=ads": {
|
||||
queries: [row(19, "ads.example", BLOCKED)],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
"/api/queries?domain=ads&blocked=true": {
|
||||
queries: [row(19, "ads.example", BLOCKED)],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
"/api/queries?since=1700000000": {
|
||||
queries: [row(20, "first.example")],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
};
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function json(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
|
||||
|
||||
/** The shell's own requests, which every test serves the same way. */
|
||||
function stubFetch(handler: (url: string) => Response | Promise<Response>) {
|
||||
fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/version") return Promise.resolve(json(VERSION));
|
||||
return Promise.resolve(handler(url));
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
}
|
||||
|
||||
function fromPages(url: string): Response {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
const payload = PAGES[url];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json(payload);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stubFetch(fromPages);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderPage(path = "/activity") {
|
||||
const queryClient = createQueryClient();
|
||||
const history = createMemoryHistory({ initialEntries: [path] });
|
||||
const router = createAppRouter(history, queryClient);
|
||||
render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
return { queryClient, history };
|
||||
}
|
||||
|
||||
/** Every `/api/queries` URL the run asked for, list pages only. */
|
||||
function queryCalls(): string[] {
|
||||
return fetchMock.mock.calls
|
||||
.map((call) => String(call[0]))
|
||||
.filter((url) => url === "/api/queries" || url.startsWith("/api/queries?"));
|
||||
}
|
||||
|
||||
test("renders the first page with the seven columns filled in", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
expect(screen.getAllByRole("columnheader").map((header) => header.textContent)).toEqual([
|
||||
"Time",
|
||||
"Domain",
|
||||
"Client",
|
||||
"Type",
|
||||
"Result",
|
||||
"Route",
|
||||
"Duration",
|
||||
]);
|
||||
const first = screen.getByText("first.example").closest("tr")!;
|
||||
expect(within(first).getByText("HTTPS")).toBeTruthy();
|
||||
expect(within(first).getByText("NOERROR")).toBeTruthy();
|
||||
expect(within(first).getByText("Cache")).toBeTruthy();
|
||||
expect(within(first).getByText("1.2 ms")).toBeTruthy();
|
||||
|
||||
const blocked = screen.getByText("ads.example").closest("tr")!;
|
||||
// The Result cell says Blocked even though the client saw NOERROR, and the
|
||||
// Route cell says how: this is the pair the old Status column could not show.
|
||||
expect(within(blocked).getAllByText("Blocked")).toHaveLength(2);
|
||||
expect(within(blocked).getByText("—")).toBeTruthy();
|
||||
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json({
|
||||
queries: [
|
||||
row(20, "named.example", { client_ip: "192.0.2.10" }),
|
||||
row(19, "learned.example", { client_ip: "192.0.2.11" }),
|
||||
row(18, "nameless.example", { client_ip: "192.0.2.12" }),
|
||||
row(17, "stranger.example", { client_ip: "192.0.2.99" }),
|
||||
],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
} satisfies QueriesPage);
|
||||
});
|
||||
|
||||
renderPage();
|
||||
|
||||
// A hand-typed name wins outright; the learned name never surfaces for it.
|
||||
const named = await screen.findByText("Kitchen Pi");
|
||||
expect(named.getAttribute("title")).toBe("192.0.2.10");
|
||||
expect(screen.queryByText("pi.lan")).toBeNull();
|
||||
|
||||
// A learned name reads muted and nothing more here: the "learned" tag would
|
||||
// repeat on every row of the table, so the Clients page carries it instead.
|
||||
const learned = screen.getByText("laptop.lan");
|
||||
expect(learned.getAttribute("title")).toBe("192.0.2.11");
|
||||
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
||||
|
||||
// A known client with neither name, and a client the loaded list has never
|
||||
// seen, both fall back to the bare address with no tooltip standing in.
|
||||
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
|
||||
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
|
||||
});
|
||||
|
||||
test("load more appends the next page and stops at the end of the log", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await screen.findByText("older.example");
|
||||
|
||||
expect(screen.getByText("first.example")).toBeTruthy();
|
||||
expect(screen.getByText(/Showing 3 queries — end of log/)).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).toBeNull();
|
||||
});
|
||||
|
||||
test("applying a filter puts it in the url, refetches, and resets the accumulated list", async () => {
|
||||
const { history } = renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await screen.findByText("older.example");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
expect(history.location.search).toContain("domain=ads");
|
||||
expect(screen.getByText("ads.example")).toBeTruthy();
|
||||
expect(screen.queryByText("first.example")).toBeNull();
|
||||
expect(screen.queryByText("older.example")).toBeNull();
|
||||
});
|
||||
|
||||
test("a load-more that resolves after a filter change is discarded", async () => {
|
||||
let releaseLoadMore: () => void = () => {};
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/queries?before=19") {
|
||||
return new Promise<Response>((resolve) => {
|
||||
releaseLoadMore = () => resolve(json(PAGES["/api/queries?before=19"]));
|
||||
});
|
||||
}
|
||||
return fromPages(url);
|
||||
});
|
||||
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
|
||||
releaseLoadMore();
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(screen.queryByText("older.example")).toBeNull();
|
||||
expect(screen.getByText(/Showing 1 query /)).toBeTruthy();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
test("load more is disabled while a filter change shows placeholder data, then uses the fresh cursor", async () => {
|
||||
let releaseFiltered: () => void = () => {};
|
||||
const filteredPage: QueriesPage = {
|
||||
queries: [row(19, "ads.example", BLOCKED)],
|
||||
next_before: 7,
|
||||
coverage: COMPLETE,
|
||||
};
|
||||
const filteredOlderPage: QueriesPage = {
|
||||
queries: [row(3, "ads.older.example")],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
};
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/queries?domain=ads") {
|
||||
return new Promise<Response>((resolve) => {
|
||||
releaseFiltered = () => resolve(json(filteredPage));
|
||||
});
|
||||
}
|
||||
if (url === "/api/queries?domain=ads&before=7") return json(filteredOlderPage);
|
||||
return fromPages(url);
|
||||
});
|
||||
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
|
||||
const staleButton = await screen.findByRole("button", { name: "Load more" });
|
||||
expect(staleButton).toHaveProperty("disabled", true);
|
||||
fireEvent.click(staleButton);
|
||||
expect(queryCalls()).not.toContain("/api/queries?domain=ads&before=19");
|
||||
|
||||
releaseFiltered();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("first.example")).toBeNull();
|
||||
});
|
||||
|
||||
const freshButton = screen.getByRole("button", { name: "Load more" });
|
||||
expect(freshButton).toHaveProperty("disabled", false);
|
||||
fireEvent.click(freshButton);
|
||||
await screen.findByText("ads.older.example");
|
||||
|
||||
expect(queryCalls()).toContain("/api/queries?domain=ads&before=7");
|
||||
expect(screen.getByText(/Showing 2 queries — end of log/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a background refetch after new rows arrive leaves no gap between the loaded pages", async () => {
|
||||
// The newest-100 window moves up while the reader has a second page open.
|
||||
// Refetching only the first page would drop n20 and n19 out of the middle
|
||||
// of the table; the second page must be replayed from the fresh cursor.
|
||||
const before: Record<string, QueriesPage> = {
|
||||
"/api/queries": {
|
||||
queries: [row(20, "n20.example"), row(19, "n19.example")],
|
||||
next_before: 19,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
"/api/queries?before=19": {
|
||||
queries: [row(18, "n18.example"), row(17, "n17.example")],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
};
|
||||
const after: Record<string, QueriesPage> = {
|
||||
"/api/queries": {
|
||||
queries: [row(22, "n22.example"), row(21, "n21.example")],
|
||||
next_before: 21,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
"/api/queries?before=21": {
|
||||
queries: [row(20, "n20.example"), row(19, "n19.example"), row(18, "n18.example"), row(17, "n17.example")],
|
||||
next_before: null,
|
||||
coverage: COMPLETE,
|
||||
},
|
||||
};
|
||||
let live = before;
|
||||
stubFetch((url) => {
|
||||
const payload = live[url];
|
||||
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json(payload);
|
||||
});
|
||||
|
||||
const { queryClient } = renderPage();
|
||||
await screen.findByText("n20.example");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await screen.findByText("n17.example");
|
||||
|
||||
live = after;
|
||||
await act(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["queries"] });
|
||||
});
|
||||
|
||||
await screen.findByText("n22.example");
|
||||
const shown = screen.getAllByText(/^n\d+\.example$/).map((cell) => cell.textContent);
|
||||
expect(shown).toEqual(["n22.example", "n21.example", "n20.example", "n19.example", "n18.example", "n17.example"]);
|
||||
expect(screen.getByText(/Showing 6 queries — end of log/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a 401 on load more routes through handleUnauthorized instead of the inline error", async () => {
|
||||
const assign = vi.fn();
|
||||
vi.stubGlobal("location", { pathname: "/activity", search: "", assign });
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/queries?before=19") {
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
return fromPages(url);
|
||||
});
|
||||
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await waitFor(() => {
|
||||
expect(assign).toHaveBeenCalledWith(`/login?redirect=${encodeURIComponent("/activity")}`);
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(screen.queryByText(/Failed to load more/)).toBeNull();
|
||||
});
|
||||
|
||||
test("each row links into its own detail page, carrying the investigation with it", async () => {
|
||||
renderPage("/activity?mode=history&since=1700000000");
|
||||
await screen.findByText("first.example");
|
||||
|
||||
const link = screen.getByRole("link", { name: "first.example" });
|
||||
expect(link.getAttribute("href")).toContain("/activity/queries/20");
|
||||
expect(link.getAttribute("href")).toContain("since=1700000000");
|
||||
// An <a href> is in the tab order by default; nothing here may opt it out.
|
||||
expect(link.getAttribute("tabindex")).toBeNull();
|
||||
});
|
||||
|
||||
test("a pruned window tells the reader when history starts", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json({
|
||||
queries: [row(20, "kept.example")],
|
||||
next_before: null,
|
||||
coverage: { complete: false, available_since: 1_700_000_000 },
|
||||
} satisfies QueriesPage);
|
||||
});
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("kept.example");
|
||||
expect(screen.getByText(/Query history is available from/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a complete window shows no coverage notice", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
expect(screen.queryByText(/Query history is available from/)).toBeNull();
|
||||
});
|
||||
|
||||
test("a ?domain= link seeds the filter form and fetches that domain on arrival", async () => {
|
||||
renderPage("/activity?domain=ads");
|
||||
|
||||
await screen.findByText("ads.example");
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "ads");
|
||||
expect(screen.queryByText("first.example")).toBeNull();
|
||||
});
|
||||
|
||||
test("history forwards exactly the six normalized filter fields and nothing else", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||
});
|
||||
renderPage(
|
||||
"/activity?mode=history&domain=%20ads%20&client=192.0.2.5&blocked=true&since=1700000000&until=1700000600&bogus=1&limit=9999",
|
||||
);
|
||||
|
||||
await screen.findByText("No queries match the current filters.");
|
||||
expect(queryCalls()).toEqual([
|
||||
"/api/queries?domain=ads&client=192.0.2.5&blocked=true&since=1700000000&until=1700000600",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a rejected search parameter is dropped rather than guessed at", async () => {
|
||||
renderPage("/activity?since=1.5&blocked=%22true%22&domain=%20%20");
|
||||
|
||||
await screen.findByText("first.example");
|
||||
// Nothing survived validation, so the request is the unfiltered one.
|
||||
expect(queryCalls()).toEqual(["/api/queries"]);
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
|
||||
});
|
||||
|
||||
test("the form draft follows the url back and forward, seconds included", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||
});
|
||||
// A bound with non-zero seconds: the round trip has to keep them, and the
|
||||
// untouched field has to carry the original number rather than re-parse.
|
||||
const seeded = 1_700_000_017;
|
||||
const { history } = renderPage(`/activity?mode=history&domain=first&since=${seeded}`);
|
||||
|
||||
const domainInput = await screen.findByLabelText("Domain contains");
|
||||
expect(domainInput).toHaveProperty("value", "first");
|
||||
const sinceInput = screen.getByLabelText("Since") as HTMLInputElement;
|
||||
expect(sinceInput.value).toContain(":37");
|
||||
|
||||
fireEvent.change(domainInput, { target: { value: "second" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
await waitFor(() => expect(history.location.search).toContain("domain=second"));
|
||||
// The untouched Since bound applied as the exact second it was seeded with.
|
||||
expect(queryCalls()).toContain(`/api/queries?domain=second&since=${seeded}`);
|
||||
|
||||
act(() => history.back());
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "first");
|
||||
});
|
||||
|
||||
act(() => history.forward());
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "second");
|
||||
});
|
||||
});
|
||||
|
||||
/** 2 a.m. on the EU spring-forward date: an hour that exists in some zones and not others. */
|
||||
const DST_WALL_TIME = "2026-03-29T02:30:00";
|
||||
|
||||
/**
|
||||
* Whether that wall time names an instant in the timezone the suite runs in.
|
||||
* `new Date` slides a spring-forward gap silently forward, so an hour or minute
|
||||
* that comes back different from the one written *is* the gap.
|
||||
*/
|
||||
function wallTimeExists(text: string): boolean {
|
||||
const written = /T(\d{2}):(\d{2})/.exec(text)!;
|
||||
const parsed = new Date(text);
|
||||
return parsed.getHours() === Number(written[1]) && parsed.getMinutes() === Number(written[2]);
|
||||
}
|
||||
|
||||
test("a wall-clock time the daylight-saving jump skips is refused, not silently moved", async () => {
|
||||
// One expected outcome per timezone, decided here rather than accepted from
|
||||
// the page: in a zone with the jump the bound must be refused outright, and
|
||||
// in a zone without it the same text is an ordinary instant that applies.
|
||||
const inGap = !wallTimeExists(DST_WALL_TIME);
|
||||
const unix = Math.floor(new Date(DST_WALL_TIME).getTime() / 1000);
|
||||
stubFetch((url) => {
|
||||
if (url === `/api/queries?since=${unix}`) {
|
||||
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||
}
|
||||
return fromPages(url);
|
||||
});
|
||||
|
||||
const { history } = renderPage();
|
||||
await screen.findByText("first.example");
|
||||
const callsBefore = queryCalls().length;
|
||||
const searchBefore = history.location.search;
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Since"), { target: { value: DST_WALL_TIME } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
|
||||
if (inGap) {
|
||||
expect(screen.getByRole("alert").textContent).toContain("daylight saving");
|
||||
// Refused means refused: no navigation, and no request for the hour the
|
||||
// operator did not ask for.
|
||||
expect(history.location.search).toBe(searchBefore);
|
||||
expect(queryCalls()).toHaveLength(callsBefore);
|
||||
return;
|
||||
}
|
||||
|
||||
await waitFor(() => expect(history.location.search).toContain(`since=${unix}`));
|
||||
expect(queryCalls()).toContain(`/api/queries?since=${unix}`);
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
test("the policy simulation is reachable from the header, with no rows to click through", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url === "/api/groups") return json({ groups: [{ id: 1, name: "default", safe_search: false }] });
|
||||
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||
});
|
||||
|
||||
const { history } = renderPage();
|
||||
// An empty log is exactly the case a row-borne link cannot serve.
|
||||
await screen.findByText("No queries logged yet.");
|
||||
|
||||
const link = screen.getByRole("link", { name: "Current policy simulation" });
|
||||
expect(link.getAttribute("href")).toBe("/activity/test");
|
||||
|
||||
fireEvent.click(link);
|
||||
await screen.findByRole("heading", { level: 1, name: "Current policy simulation" });
|
||||
expect(history.location.pathname).toBe("/activity/test");
|
||||
});
|
||||
|
||||
test("Clear empties the url as well as the form", async () => {
|
||||
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
|
||||
await screen.findByText("ads.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||
await waitFor(() => {
|
||||
expect(history.location.search).not.toContain("domain");
|
||||
});
|
||||
expect(history.location.search).not.toContain("blocked");
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
|
||||
});
|
||||
Reference in New Issue
Block a user