Add tests and documentation updates for log preview and regex tester hooks

- Add useLogPreview.test.ts with comprehensive test coverage
- Add useRegexTester.test.ts with comprehensive test coverage
- Update Docs/Tasks.md and Docs/Web-Development.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-23 09:14:58 +02:00
parent 3fba69970c
commit 1bcc336c9b
6 changed files with 242 additions and 56 deletions

View File

@@ -1,46 +1,3 @@
### TASK-ABORT-04 — `useIpLookup` Has No AbortController or Unmount Guard
**Where found**
`frontend/src/hooks/useIpLookup.ts`. The hook performs an async fetch but has no `AbortController`, no `useEffect` cleanup, and no unmount guard. If the component that calls this hook unmounts before the fetch completes (e.g. the user navigates away), the `.then()` callback still fires and calls `setResult` / `setLoading` on an unmounted component.
**Goal**
Add a standard `AbortController` pattern:
```ts
const abortRef = useRef<AbortController | null>(null);
const lookup = useCallback(async (ip: string): Promise<void> => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
try {
const result = await fetchIpInfo(ip, ctrl.signal);
if (ctrl.signal.aborted) return;
setResult(result);
} catch (err) {
if (ctrl.signal.aborted) return;
// handle error
} finally {
if (!ctrl.signal.aborted) setLoading(false);
}
}, []);
```
Add a `useEffect` cleanup that calls `abortRef.current?.abort()` on unmount.
**Possible traps and issues**
- `fetchIpInfo` (in `api/jails.ts` or equivalent) must accept `signal` for the abort to actually cancel the HTTP request. Check whether it already does; if not, add it as part of TASK-ABORT-01.
- This hook is likely called from a popover or panel that can close while a lookup is running; unmount cancellation is the primary concern here.
**Docs changes needed**
None required.
**Why this is needed**
Calling React state setters on unmounted components is a React antipattern that produces console warnings in development and can cause subtle state corruption if the component re-mounts quickly.
---
## State Management
---
### TASK-STATE-01 — Auth Errors Silently Swallowed in `useRegexTester` and `useLogPreview`

View File

@@ -488,6 +488,7 @@ if (data.length > MAX_VISIBLE_BANS) { ... }
## 11. Error Handling
- Wrap API calls in `try-catch` inside hooks — components should never see raw exceptions.
- **All hook catch blocks must use `handleFetchError` rather than directly calling `setError`.** This ensures auth errors (401/403) are routed to the global session-expiry flow instead of displaying confusing error text in the UI. Use the pattern: `handleFetchError(err, setError, "User-friendly fallback message")`.
- Display user-friendly error messages — never expose stack traces or raw server responses in the UI.
- Use an **error boundary** (`ErrorBoundary` component) at the page level to catch unexpected render errors.
- Log errors to the console (or a future logging service) with sufficient context for debugging.

View File

@@ -0,0 +1,126 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { ApiError } from "../../api/client";
import { useLogPreview } from "../useLogPreview";
vi.mock("../../api/config", () => ({
previewLog: vi.fn(),
}));
import { previewLog } from "../../api/config";
describe("useLogPreview", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it("returns initial state with preview and loading false", () => {
const { result } = renderHook(() => useLogPreview());
expect(result.current.preview).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
it("sets preview on successful run", async () => {
const mockResponse = {
lines: [{ line: "test", matched: true, groups: [] }],
total_lines: 1,
matched_count: 1,
regex_error: null,
};
vi.mocked(previewLog).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useLogPreview());
await act(async () => {
await result.current.run({
log_path: "/var/log/test.log",
fail_regex: "pattern",
num_lines: 100,
});
});
expect(result.current.preview).toEqual(mockResponse);
expect(result.current.error).toBeNull();
expect(result.current.loading).toBe(false);
});
it("sets error for normal errors via handleFetchError", async () => {
vi.mocked(previewLog).mockRejectedValue(new Error("Network error"));
const { result } = renderHook(() => useLogPreview());
await act(async () => {
await result.current.run({
log_path: "/var/log/test.log",
fail_regex: "pattern",
num_lines: 100,
});
});
expect(result.current.error).toBe("Network error");
expect(result.current.loading).toBe(false);
});
it("ignores auth errors (401) via handleFetchError", async () => {
vi.mocked(previewLog).mockRejectedValue(new ApiError(401, "Unauthorized"));
const { result } = renderHook(() => useLogPreview());
await act(async () => {
await result.current.run({
log_path: "/var/log/test.log",
fail_regex: "pattern",
num_lines: 100,
});
});
expect(result.current.error).toBeNull();
expect(result.current.loading).toBe(false);
});
it("ignores auth errors (403) via handleFetchError", async () => {
vi.mocked(previewLog).mockRejectedValue(new ApiError(403, "Forbidden"));
const { result } = renderHook(() => useLogPreview());
await act(async () => {
await result.current.run({
log_path: "/var/log/test.log",
fail_regex: "pattern",
num_lines: 100,
});
});
expect(result.current.error).toBeNull();
expect(result.current.loading).toBe(false);
});
it("sets loading to false in finally block", async () => {
vi.mocked(previewLog).mockResolvedValue({
lines: [],
total_lines: 0,
matched_count: 0,
regex_error: null,
});
const { result } = renderHook(() => useLogPreview());
expect(result.current.loading).toBe(false);
await act(async () => {
await result.current.run({
log_path: "/var/log/test.log",
fail_regex: "pattern",
num_lines: 100,
});
});
expect(result.current.loading).toBe(false);
});
});

View File

@@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { ApiError } from "../../api/client";
import { useRegexTester } from "../useRegexTester";
vi.mock("../../api/config", () => ({
testRegex: vi.fn(),
}));
import { testRegex } from "../../api/config";
describe("useRegexTester", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it("returns initial state with result and testing false", () => {
const { result } = renderHook(() => useRegexTester());
expect(result.current.result).toBeNull();
expect(result.current.testing).toBe(false);
expect(result.current.error).toBeNull();
});
it("sets result on successful test", async () => {
const mockResponse = { matched: true, groups: ["host"], error: null };
vi.mocked(testRegex).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useRegexTester());
await act(async () => {
await result.current.test({ log_line: "test", fail_regex: "pattern" });
});
expect(result.current.result).toEqual(mockResponse);
expect(result.current.error).toBeNull();
expect(result.current.testing).toBe(false);
});
it("sets error for normal errors via handleFetchError", async () => {
vi.mocked(testRegex).mockRejectedValue(new Error("Network error"));
const { result } = renderHook(() => useRegexTester());
await act(async () => {
await result.current.test({ log_line: "test", fail_regex: "pattern" });
});
expect(result.current.error).toBe("Network error");
expect(result.current.testing).toBe(false);
});
it("ignores auth errors (401) via handleFetchError", async () => {
vi.mocked(testRegex).mockRejectedValue(new ApiError(401, "Unauthorized"));
const { result } = renderHook(() => useRegexTester());
await act(async () => {
await result.current.test({ log_line: "test", fail_regex: "pattern" });
});
expect(result.current.error).toBeNull();
expect(result.current.testing).toBe(false);
});
it("ignores auth errors (403) via handleFetchError", async () => {
vi.mocked(testRegex).mockRejectedValue(new ApiError(403, "Forbidden"));
const { result } = renderHook(() => useRegexTester());
await act(async () => {
await result.current.test({ log_line: "test", fail_regex: "pattern" });
});
expect(result.current.error).toBeNull();
expect(result.current.testing).toBe(false);
});
it("sets testing to false in finally block", async () => {
vi.mocked(testRegex).mockResolvedValue({
matched: false,
groups: [],
error: null,
});
const { result } = renderHook(() => useRegexTester());
expect(result.current.testing).toBe(false);
await act(async () => {
await result.current.test({
log_line: "test",
fail_regex: "pattern",
});
});
expect(result.current.testing).toBe(false);
});
});

View File

@@ -4,11 +4,13 @@
import { useCallback, useState } from "react";
import { previewLog } from "../api/config";
import { handleFetchError } from "../utils/fetchError";
import type { LogPreviewRequest, LogPreviewResponse } from "../types/config";
export interface UseLogPreviewResult {
preview: LogPreviewResponse | null;
loading: boolean;
error: string | null;
run: (req: LogPreviewRequest) => Promise<void>;
}
@@ -18,25 +20,20 @@ export interface UseLogPreviewResult {
export function useLogPreview(): UseLogPreviewResult {
const [preview, setPreview] = useState<LogPreviewResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const run = useCallback(async (req: LogPreviewRequest): Promise<void> => {
setLoading(true);
try {
const resp = await previewLog(req);
setPreview(resp);
setError(null);
} catch (err: unknown) {
if (err instanceof Error) {
setPreview({
lines: [],
total_lines: 0,
matched_count: 0,
regex_error: err.message,
});
}
handleFetchError(err, setError, "Log preview failed");
} finally {
setLoading(false);
}
}, []);
return { preview, loading, run };
return { preview, loading, error, run };
}

View File

@@ -4,11 +4,13 @@
import { useCallback, useState } from "react";
import { testRegex } from "../api/config";
import { handleFetchError } from "../utils/fetchError";
import type { RegexTestRequest, RegexTestResponse } from "../types/config";
export interface UseRegexTesterResult {
result: RegexTestResponse | null;
testing: boolean;
error: string | null;
test: (req: RegexTestRequest) => Promise<void>;
}
@@ -18,20 +20,20 @@ export interface UseRegexTesterResult {
export function useRegexTester(): UseRegexTesterResult {
const [result, setResult] = useState<RegexTestResponse | null>(null);
const [testing, setTesting] = useState(false);
const [error, setError] = useState<string | null>(null);
const test = useCallback(async (req: RegexTestRequest): Promise<void> => {
setTesting(true);
try {
const resp = await testRegex(req);
setResult(resp);
setError(null);
} catch (err: unknown) {
if (err instanceof Error) {
setResult({ matched: false, groups: [], error: err.message });
}
handleFetchError(err, setError, "Regex test failed");
} finally {
setTesting(false);
}
}, []);
return { result, testing, test };
return { result, testing, error, test };
}