Polish dashboard charts and add frontend tests (Stage 6)
Task 6.1 - Consistent loading/error/empty states across all charts: - Add ChartStateWrapper shared component with Spinner, error MessageBar + Retry button, and friendly empty message - Expose reload() in useBanTrend, useJailDistribution, useDashboardCountryData hooks - Update BanTrendChart and JailDistributionChart to use ChartStateWrapper - Add empty state to TopCountriesBarChart and TopCountriesPieChart - Replace manual loading/error logic in DashboardPage with ChartStateWrapper Task 6.2 - Frontend tests (5 files, 20 tests): - Install Vitest v4, jsdom, @testing-library/react, @testing-library/jest-dom - Add vitest.config.ts (separate from vite.config.ts to avoid Vite v5/v7 clash) - Add src/setupTests.ts with jest-dom matchers and ResizeObserver/matchMedia stubs - Tests: ChartStateWrapper (7), BanTrendChart (4), JailDistributionChart (4), TopCountriesPieChart (2), TopCountriesBarChart (3) Task 6.3 - Full QA: - ruff: clean - mypy --strict: 52 files, no issues - pytest: 497 passed - tsc --noEmit: clean - eslint: clean (added test-file override for explicit-function-return-type) - vite build: success
This commit is contained in:
90
frontend/src/components/__tests__/BanTrendChart.test.tsx
Normal file
90
frontend/src/components/__tests__/BanTrendChart.test.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { BanTrendChart } from "../BanTrendChart";
|
||||
import * as useBanTrendModule from "../../hooks/useBanTrend";
|
||||
import type { UseBanTrendResult } from "../../hooks/useBanTrend";
|
||||
import type { BanTrendBucket } from "../../types/ban";
|
||||
|
||||
vi.mock("recharts", () => ({
|
||||
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
AreaChart: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="area-chart">{children}</div>
|
||||
),
|
||||
Area: () => null,
|
||||
CartesianGrid: () => null,
|
||||
XAxis: () => null,
|
||||
YAxis: () => null,
|
||||
Tooltip: () => null,
|
||||
defs: () => null,
|
||||
linearGradient: () => null,
|
||||
stop: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useBanTrend");
|
||||
|
||||
function wrap(ui: React.ReactElement) {
|
||||
return render(
|
||||
<FluentProvider theme={webLightTheme}>{ui}</FluentProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const defaultResult: UseBanTrendResult = {
|
||||
buckets: [],
|
||||
bucketSize: "1h",
|
||||
isLoading: false,
|
||||
error: null,
|
||||
reload: vi.fn(),
|
||||
};
|
||||
|
||||
function mockHook(overrides: Partial<UseBanTrendResult>) {
|
||||
vi.mocked(useBanTrendModule.useBanTrend).mockReturnValue({
|
||||
...defaultResult,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useBanTrendModule.useBanTrend).mockReturnValue(defaultResult);
|
||||
});
|
||||
|
||||
describe("BanTrendChart", () => {
|
||||
it("shows a spinner while loading", () => {
|
||||
mockHook({ isLoading: true });
|
||||
wrap(<BanTrendChart timeRange="24h" origin="all" />);
|
||||
expect(screen.getByRole("progressbar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error message and retry button on error", async () => {
|
||||
const reload = vi.fn();
|
||||
mockHook({ error: "Failed to fetch trend", reload });
|
||||
const user = userEvent.setup();
|
||||
wrap(<BanTrendChart timeRange="24h" origin="all" />);
|
||||
expect(screen.getByText("Failed to fetch trend")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /retry/i }));
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows empty state when all buckets have zero count", () => {
|
||||
const emptyBuckets: BanTrendBucket[] = [
|
||||
{ timestamp: "2024-01-01T00:00:00Z", count: 0 },
|
||||
{ timestamp: "2024-01-01T01:00:00Z", count: 0 },
|
||||
];
|
||||
mockHook({ buckets: emptyBuckets });
|
||||
wrap(<BanTrendChart timeRange="24h" origin="all" />);
|
||||
expect(screen.getByText("No bans in this time range.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders chart when data is present", () => {
|
||||
const buckets: BanTrendBucket[] = [
|
||||
{ timestamp: "2024-01-01T00:00:00Z", count: 5 },
|
||||
{ timestamp: "2024-01-01T01:00:00Z", count: 12 },
|
||||
];
|
||||
mockHook({ buckets });
|
||||
wrap(<BanTrendChart timeRange="24h" origin="all" />);
|
||||
expect(screen.getByTestId("area-chart")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
123
frontend/src/components/__tests__/ChartStateWrapper.test.tsx
Normal file
123
frontend/src/components/__tests__/ChartStateWrapper.test.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { ChartStateWrapper } from "../ChartStateWrapper";
|
||||
|
||||
function wrap(ui: React.ReactElement) {
|
||||
return render(
|
||||
<FluentProvider theme={webLightTheme}>{ui}</FluentProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ChartStateWrapper", () => {
|
||||
it("renders a spinner when isLoading is true", () => {
|
||||
wrap(
|
||||
<ChartStateWrapper
|
||||
isLoading={true}
|
||||
error={null}
|
||||
onRetry={vi.fn()}
|
||||
isEmpty={false}
|
||||
>
|
||||
<div>chart</div>
|
||||
</ChartStateWrapper>,
|
||||
);
|
||||
expect(screen.getByRole("progressbar")).toBeInTheDocument();
|
||||
expect(screen.queryByText("chart")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an error bar with retry button when error is set", () => {
|
||||
const onRetry = vi.fn();
|
||||
wrap(
|
||||
<ChartStateWrapper
|
||||
isLoading={false}
|
||||
error="Network error"
|
||||
onRetry={onRetry}
|
||||
isEmpty={false}
|
||||
>
|
||||
<div>chart</div>
|
||||
</ChartStateWrapper>,
|
||||
);
|
||||
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText("chart")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onRetry when Retry button is clicked", async () => {
|
||||
const onRetry = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
wrap(
|
||||
<ChartStateWrapper
|
||||
isLoading={false}
|
||||
error="Oops"
|
||||
onRetry={onRetry}
|
||||
isEmpty={false}
|
||||
>
|
||||
<div>chart</div>
|
||||
</ChartStateWrapper>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /retry/i }));
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders the empty message when isEmpty is true", () => {
|
||||
wrap(
|
||||
<ChartStateWrapper
|
||||
isLoading={false}
|
||||
error={null}
|
||||
onRetry={vi.fn()}
|
||||
isEmpty={true}
|
||||
emptyMessage="Nothing here."
|
||||
>
|
||||
<div>chart</div>
|
||||
</ChartStateWrapper>,
|
||||
);
|
||||
expect(screen.getByText("Nothing here.")).toBeInTheDocument();
|
||||
expect(screen.queryByText("chart")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders children when not loading, no error, and not empty", () => {
|
||||
wrap(
|
||||
<ChartStateWrapper
|
||||
isLoading={false}
|
||||
error={null}
|
||||
onRetry={vi.fn()}
|
||||
isEmpty={false}
|
||||
>
|
||||
<div>chart content</div>
|
||||
</ChartStateWrapper>,
|
||||
);
|
||||
expect(screen.getByText("chart content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prioritises error state over loading state", () => {
|
||||
wrap(
|
||||
<ChartStateWrapper
|
||||
isLoading={true}
|
||||
error="Some error"
|
||||
onRetry={vi.fn()}
|
||||
isEmpty={false}
|
||||
>
|
||||
<div>chart</div>
|
||||
</ChartStateWrapper>,
|
||||
);
|
||||
expect(screen.getByText("Some error")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses default empty message when none is provided", () => {
|
||||
wrap(
|
||||
<ChartStateWrapper
|
||||
isLoading={false}
|
||||
error={null}
|
||||
onRetry={vi.fn()}
|
||||
isEmpty={true}
|
||||
>
|
||||
<div>chart</div>
|
||||
</ChartStateWrapper>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("No bans in this time range."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { JailDistributionChart } from "../JailDistributionChart";
|
||||
import * as useJailDistributionModule from "../../hooks/useJailDistribution";
|
||||
import type { UseJailDistributionResult } from "../../hooks/useJailDistribution";
|
||||
|
||||
vi.mock("recharts", () => ({
|
||||
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
BarChart: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="bar-chart">{children}</div>
|
||||
),
|
||||
Bar: () => null,
|
||||
CartesianGrid: () => null,
|
||||
XAxis: () => null,
|
||||
YAxis: () => null,
|
||||
Tooltip: () => null,
|
||||
Cell: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useJailDistribution");
|
||||
|
||||
function wrap(ui: React.ReactElement) {
|
||||
return render(
|
||||
<FluentProvider theme={webLightTheme}>{ui}</FluentProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const defaultResult: UseJailDistributionResult = {
|
||||
jails: [],
|
||||
total: 0,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
reload: vi.fn(),
|
||||
};
|
||||
|
||||
function mockHook(overrides: Partial<UseJailDistributionResult>) {
|
||||
vi.mocked(useJailDistributionModule.useJailDistribution).mockReturnValue({
|
||||
...defaultResult,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useJailDistributionModule.useJailDistribution).mockReturnValue(
|
||||
defaultResult,
|
||||
);
|
||||
});
|
||||
|
||||
describe("JailDistributionChart", () => {
|
||||
it("shows a spinner while loading", () => {
|
||||
mockHook({ isLoading: true });
|
||||
wrap(<JailDistributionChart timeRange="24h" origin="all" />);
|
||||
expect(screen.getByRole("progressbar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error message and retry button on error", async () => {
|
||||
const reload = vi.fn();
|
||||
mockHook({ error: "Request failed", reload });
|
||||
const user = userEvent.setup();
|
||||
wrap(<JailDistributionChart timeRange="24h" origin="all" />);
|
||||
expect(screen.getByText("Request failed")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /retry/i }));
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows empty state when jails array is empty", () => {
|
||||
mockHook({ jails: [], total: 0 });
|
||||
wrap(<JailDistributionChart timeRange="24h" origin="all" />);
|
||||
expect(screen.getByText(/no bans/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders chart when jail data is present", () => {
|
||||
mockHook({
|
||||
jails: [
|
||||
{ jail: "sshd", count: 42 },
|
||||
{ jail: "nginx-http-auth", count: 7 },
|
||||
],
|
||||
total: 49,
|
||||
});
|
||||
wrap(<JailDistributionChart timeRange="24h" origin="all" />);
|
||||
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { TopCountriesBarChart } from "../TopCountriesBarChart";
|
||||
|
||||
vi.mock("recharts", () => ({
|
||||
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
BarChart: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="bar-chart">{children}</div>
|
||||
),
|
||||
Bar: () => null,
|
||||
CartesianGrid: () => null,
|
||||
XAxis: () => null,
|
||||
YAxis: () => null,
|
||||
Tooltip: () => null,
|
||||
Cell: () => null,
|
||||
}));
|
||||
|
||||
function wrap(ui: React.ReactElement) {
|
||||
return render(
|
||||
<FluentProvider theme={webLightTheme}>{ui}</FluentProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TopCountriesBarChart", () => {
|
||||
it("shows empty state when countries is empty", () => {
|
||||
wrap(<TopCountriesBarChart countries={{}} countryNames={{}} />);
|
||||
expect(screen.getByText("No bans in this time range.")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("bar-chart")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders bar chart when country data is provided", () => {
|
||||
wrap(
|
||||
<TopCountriesBarChart
|
||||
countries={{ DE: 50, US: 30, FR: 15 }}
|
||||
countryNames={{ DE: "Germany", US: "United States", FR: "France" }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render more than 20 bars (TOP_N limit)", () => {
|
||||
// Build 30 countries — only top 20 should appear in the chart
|
||||
const countries: Record<string, number> = {};
|
||||
const countryNames: Record<string, string> = {};
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const code = `C${String(i).padStart(2, "0")}`;
|
||||
countries[code] = 30 - i;
|
||||
countryNames[code] = `Country ${String(i)}`;
|
||||
}
|
||||
wrap(<TopCountriesBarChart countries={countries} countryNames={countryNames} />);
|
||||
// Chart should render (not show empty state) with data present
|
||||
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { TopCountriesPieChart } from "../TopCountriesPieChart";
|
||||
|
||||
vi.mock("recharts", () => ({
|
||||
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
PieChart: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="pie-chart">{children}</div>
|
||||
),
|
||||
Pie: () => null,
|
||||
Cell: () => null,
|
||||
Tooltip: () => null,
|
||||
Legend: () => null,
|
||||
}));
|
||||
|
||||
function wrap(ui: React.ReactElement) {
|
||||
return render(
|
||||
<FluentProvider theme={webLightTheme}>{ui}</FluentProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TopCountriesPieChart", () => {
|
||||
it("shows empty state when countries is empty", () => {
|
||||
wrap(<TopCountriesPieChart countries={{}} countryNames={{}} />);
|
||||
expect(screen.getByText("No bans in this time range.")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("pie-chart")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders pie chart when country data is provided", () => {
|
||||
wrap(
|
||||
<TopCountriesPieChart
|
||||
countries={{ DE: 30, US: 20, CN: 10 }}
|
||||
countryNames={{ DE: "Germany", US: "United States", CN: "China" }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("pie-chart")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user