milestone 19: hygiene sweep - dead ecs surface, single-source constants, tls classification, frontend state hazards, docker smoke network fix
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import BlocklistForm, { swallowMutationError } from "./BlocklistForm";
|
||||
|
||||
test("swallowMutationError drops an ApiError and rethrows anything else", () => {
|
||||
expect(() => swallowMutationError(new ApiError(400, "bad url"))).not.toThrow();
|
||||
expect(() => swallowMutationError(new TypeError("cannot read x of undefined"))).toThrow(TypeError);
|
||||
expect(() => swallowMutationError("not an error at all")).toThrow();
|
||||
});
|
||||
|
||||
test("a rejected submit leaves the typed values in place; a resolved one clears them", async () => {
|
||||
const rejecting = vi.fn(() => Promise.reject(new ApiError(400, "bad url")));
|
||||
const { rerender } = render(<BlocklistForm busy={false} error={null} onSubmit={rejecting} onCancel={undefined} />);
|
||||
const url = screen.getByLabelText("URL") as HTMLInputElement;
|
||||
const name = screen.getByLabelText("Name") as HTMLInputElement;
|
||||
fireEvent.change(url, { target: { value: "https://example.com/list.txt" } });
|
||||
fireEvent.change(name, { target: { value: "Example" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add source" }));
|
||||
|
||||
await waitFor(() => expect(rejecting).toHaveBeenCalledTimes(1));
|
||||
expect(url.value).toBe("https://example.com/list.txt");
|
||||
expect(name.value).toBe("Example");
|
||||
|
||||
const resolving = vi.fn(() => Promise.resolve());
|
||||
rerender(<BlocklistForm busy={false} error={null} onSubmit={resolving} onCancel={undefined} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add source" }));
|
||||
await waitFor(() => expect(url.value).toBe(""));
|
||||
expect(name.value).toBe("");
|
||||
});
|
||||
@@ -1,8 +1,19 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import type { Blocklist, BlocklistInput } from "@/lib/types";
|
||||
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
|
||||
|
||||
/**
|
||||
* Drops the rejection the page already renders inline below the form. Anything
|
||||
* else is a bug in this component and must reach the console instead of dying
|
||||
* silently in the submit handler.
|
||||
*/
|
||||
export function swallowMutationError(error: unknown): void {
|
||||
if (error instanceof ApiError) return;
|
||||
throw error;
|
||||
}
|
||||
|
||||
interface BlocklistFormProps {
|
||||
initial?: Blocklist;
|
||||
busy: boolean;
|
||||
@@ -20,13 +31,14 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel
|
||||
event.preventDefault();
|
||||
try {
|
||||
await onSubmit({ url: url.trim(), name: name.trim(), enabled });
|
||||
if (initial === undefined) {
|
||||
setUrl("");
|
||||
setName("");
|
||||
setEnabled(true);
|
||||
}
|
||||
} catch {
|
||||
// The page renders the mutation error inline below the form.
|
||||
} catch (error) {
|
||||
swallowMutationError(error);
|
||||
return;
|
||||
}
|
||||
if (initial === undefined) {
|
||||
setUrl("");
|
||||
setName("");
|
||||
setEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } 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 { clearRefreshStatus } from "@/features/blocklists/refreshStore";
|
||||
|
||||
const BLOCKLISTS = {
|
||||
blocklists: [
|
||||
@@ -42,6 +43,7 @@ const RESPONSES: Record<string, unknown> = {
|
||||
let resolveUpdate: ((response: Response) => void) | null;
|
||||
|
||||
beforeEach(() => {
|
||||
clearRefreshStatus();
|
||||
resolveUpdate = null;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -66,18 +68,35 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderBlocklistsRoute() {
|
||||
const queryClient = createQueryClient();
|
||||
function renderBlocklistsRoute(queryClient = createQueryClient()) {
|
||||
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/blocklists"] }), queryClient);
|
||||
render(
|
||||
const view = render(
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>,
|
||||
);
|
||||
return { queryClient, unmount: view.unmount };
|
||||
}
|
||||
|
||||
const SNAPSHOT = {
|
||||
sources: [
|
||||
{
|
||||
id: 1,
|
||||
state: "loaded",
|
||||
loaded: true,
|
||||
last_attempt: 1700000100,
|
||||
last_success: 1700000100,
|
||||
url: "https://example.com/hosts.txt",
|
||||
last_error: "",
|
||||
domains: 1200,
|
||||
wildcards: 12,
|
||||
skipped_regex: 4,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test("renders the source table and the status empty state", async () => {
|
||||
renderBlocklistsRoute();
|
||||
await screen.findByRole("heading", { name: "Blocklists" });
|
||||
@@ -149,7 +168,8 @@ test("update now disables the button, then replaces the status section from the
|
||||
expect(screen.getByText("12")).toBeTruthy();
|
||||
expect(screen.getByText("4")).toBeTruthy();
|
||||
expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull();
|
||||
expect(screen.getByText(/Update completed/)).toBeTruthy();
|
||||
// The store notifies one flush before the mutation's success state lands.
|
||||
await screen.findByText(/Update completed/);
|
||||
|
||||
await waitFor(() => {
|
||||
const idle = screen.getByRole("button", { name: "Update now" }) as HTMLButtonElement;
|
||||
@@ -175,3 +195,35 @@ test("update now shows a countdown when rate limited with Retry-After", async ()
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("Rate limited. Try again in 7s.");
|
||||
});
|
||||
|
||||
test("the refresh snapshot outlives the query cache's gcTime", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const { queryClient, unmount } = renderBlocklistsRoute();
|
||||
await screen.findByRole("heading", { name: "Blocklists" });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
|
||||
await screen.findByRole("button", { name: "Updating…" });
|
||||
resolveUpdate!(
|
||||
new Response(JSON.stringify(SNAPSHOT), {
|
||||
status: 202,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
await screen.findByText("loaded");
|
||||
|
||||
unmount();
|
||||
// Well past the default 5-minute gcTime: an unsubscribed cache entry is
|
||||
// collected by now, which is what used to erase the snapshot.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(6 * 60_000);
|
||||
});
|
||||
|
||||
renderBlocklistsRoute(queryClient);
|
||||
await screen.findByRole("heading", { name: "Blocklists" });
|
||||
expect(await screen.findByText("loaded")).toBeTruthy();
|
||||
expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
blocklistUpdateMutation,
|
||||
blocklistsQuery,
|
||||
blocklistsUpdateNowMutation,
|
||||
queryKeys,
|
||||
} from "@/lib/queries";
|
||||
import type { Blocklist, BlocklistInput, SourceStatus } from "@/lib/types";
|
||||
import type { Blocklist, BlocklistInput } from "@/lib/types";
|
||||
import BlocklistForm from "./BlocklistForm";
|
||||
import { useRefreshStatus } from "./refreshStore";
|
||||
import SourceStatusSection from "./SourceStatusSection";
|
||||
import {
|
||||
dangerLinkButtonClass,
|
||||
@@ -34,9 +34,7 @@ export default function BlocklistsPage() {
|
||||
const remove = useMutation(blocklistDeleteMutation(queryClient));
|
||||
const updateNow = useMutation(blocklistsUpdateNowMutation(queryClient));
|
||||
|
||||
// Fed only by the update-now 202 snapshot (no GET exists); the mutation's
|
||||
// state change re-renders this page right after setQueryData runs.
|
||||
const sources = queryClient.getQueryData<SourceStatus[]>(queryKeys.blocklistSources);
|
||||
const sources = useRefreshStatus();
|
||||
const namesById = new Map(blocklists.map((b) => [b.id, b.name]));
|
||||
|
||||
async function submitForm(input: BlocklistInput) {
|
||||
|
||||
@@ -7,7 +7,7 @@ function formatAttempt(unixSeconds: number): string {
|
||||
}
|
||||
|
||||
interface SourceStatusSectionProps {
|
||||
sources: SourceStatus[] | undefined;
|
||||
sources: SourceStatus[] | null;
|
||||
namesById: ReadonlyMap<number, string>;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function SourceStatusSection({ sources, namesById }: SourceStatus
|
||||
return (
|
||||
<section className="mt-8">
|
||||
<h2 className="text-lg font-medium">Source status</h2>
|
||||
{sources === undefined ? (
|
||||
{sources === null ? (
|
||||
<p className="mt-2 text-zinc-500">
|
||||
No status snapshot yet — run “Update now” to fetch status for every enabled source.
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { SourceStatus } from "@/lib/types";
|
||||
|
||||
// Client UI state, not server state: the snapshot exists only as the 202 body of
|
||||
// POST /api/blocklists/update and no GET can refetch it. Held here so it outlives
|
||||
// the query cache's gcTime instead of vanishing from an unsubscribed cache entry.
|
||||
let snapshot: SourceStatus[] | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function getSnapshot(): SourceStatus[] | null {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function setRefreshStatus(sources: SourceStatus[]): void {
|
||||
snapshot = sources;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function clearRefreshStatus(): void {
|
||||
snapshot = null;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function useRefreshStatus(): SourceStatus[] | null {
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
Reference in New Issue
Block a user