feat: implement frontend with TDD (Part 8)

- Scaffold Vite + React 19 + TypeScript strict project
- Neumorphic dark design system (Tailwind v3, amber/orange LED accents)
- Zustand stores for auth (persist) and UI state (notifications, sidebar)
- TanStack Query v5 hooks for server CRUD operations
- WebSocket hook with reconnection backoff and query invalidation
- Components: StatusLed, Sidebar, ServerCard, LoginPage, DashboardPage
- Protected routing with auth guard
- Axios client with JWT interceptor and 401 redirect
- 68 tests across 11 test files (89% statement coverage, 90% function coverage)
- TDD workflow: RED validated, GREEN achieved, coverage verified
This commit is contained in:
Tran G. (Revernomad) Khoa
2026-04-16 23:53:25 +07:00
parent b17d199301
commit 88424675b5
43 changed files with 8144 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { DashboardPage } from "@/pages/DashboardPage";
import { useServers } from "@/hooks/useServers";
import type { Server } from "@/hooks/useServers";
const mockMutation = () => ({
mutateAsync: vi.fn(() => Promise.resolve()),
mutate: vi.fn(),
isPending: false,
isSuccess: false,
isError: false,
reset: vi.fn(),
});
vi.mock("@/hooks/useServers", () => ({
useServers: vi.fn(),
useStartServer: vi.fn(() => mockMutation()),
useStopServer: vi.fn(() => mockMutation()),
useRestartServer: vi.fn(() => mockMutation()),
useCreateServer: vi.fn(() => mockMutation()),
useDeleteServer: vi.fn(() => mockMutation()),
}));
vi.mock("@/hooks/useWebSocket", () => ({
useWebSocket: vi.fn(),
}));
const mockServer: Server = {
id: 1,
name: "Arma3 Test",
game_type: "arma3",
status: "running",
port: 2302,
max_players: 64,
current_players: 32,
restart_count: 0,
auto_restart: true,
created_at: "2026-01-01T00:00:00Z",
};
function renderDashboard() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Routes>
<Route path="*" element={<DashboardPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("DashboardPage", () => {
beforeEach(() => {
vi.mocked(useServers).mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
error: null,
} as unknown as ReturnType<typeof useServers>);
});
it("should show loading state", () => {
renderDashboard();
expect(screen.getByText("Loading servers...")).toBeInTheDocument();
});
it("should show error state", () => {
vi.mocked(useServers).mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: new Error("fail"),
} as unknown as ReturnType<typeof useServers>);
renderDashboard();
expect(screen.getByText("Failed to load servers")).toBeInTheDocument();
});
it("should show empty state when no servers", () => {
vi.mocked(useServers).mockReturnValue({
data: [],
isLoading: false,
isError: false,
error: null,
} as unknown as ReturnType<typeof useServers>);
renderDashboard();
expect(screen.getByText("No servers configured yet.")).toBeInTheDocument();
expect(screen.getByText("Add your first server")).toBeInTheDocument();
});
it("should render server cards", () => {
vi.mocked(useServers).mockReturnValue({
data: [mockServer],
isLoading: false,
isError: false,
error: null,
} as unknown as ReturnType<typeof useServers>);
renderDashboard();
expect(screen.getByText("Arma3 Test")).toBeInTheDocument();
expect(screen.getByText("1 server configured")).toBeInTheDocument();
});
it("should show Add Server link", () => {
vi.mocked(useServers).mockReturnValue({
data: [mockServer],
isLoading: false,
isError: false,
error: null,
} as unknown as ReturnType<typeof useServers>);
renderDashboard();
expect(screen.getByText("Add Server")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { LoginPage } from "@/pages/LoginPage";
vi.mock("@/lib/api", () => ({
apiClient: {
post: vi.fn(),
},
}));
import { apiClient } from "@/lib/api";
function renderLoginPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return {
user: userEvent.setup(),
...render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/login"]}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<div>Dashboard</div>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
),
};
}
describe("LoginPage", () => {
beforeEach(() => {
vi.mocked(apiClient.post).mockReset();
});
it("should render login form", () => {
renderLoginPage();
expect(screen.getByLabelText("Username")).toBeInTheDocument();
expect(screen.getByLabelText("Password")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /sign in/i })).toBeInTheDocument();
});
it("should show validation errors on empty submit", async () => {
const { user } = renderLoginPage();
await user.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByText("Username is required")).toBeInTheDocument();
});
});
it("should call API on valid submit", async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({
data: {
success: true,
data: {
access_token: "test-token",
user: { id: 1, username: "admin", role: "admin" as const },
},
},
});
const { user } = renderLoginPage();
await user.type(screen.getByLabelText("Username"), "admin");
await user.type(screen.getByLabelText("Password"), "password");
await user.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(apiClient.post).toHaveBeenCalledWith("/api/auth/login", {
username: "admin",
password: "password",
});
});
});
it("should show error on failed login", async () => {
vi.mocked(apiClient.post).mockRejectedValueOnce({
response: { data: { detail: "Invalid credentials" } },
});
const { user } = renderLoginPage();
await user.type(screen.getByLabelText("Username"), "admin");
await user.type(screen.getByLabelText("Password"), "wrong");
await user.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByText("Invalid credentials")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,173 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ServerCard } from "@/components/servers/ServerCard";
import type { Server } from "@/hooks/useServers";
import {
useStartServer,
useStopServer,
useRestartServer,
} from "@/hooks/useServers";
import { useUIStore } from "@/store/ui.store";
vi.mock("@/hooks/useServers", () => ({
useStartServer: vi.fn(),
useStopServer: vi.fn(),
useRestartServer: vi.fn(),
}));
const baseServer: Server = {
id: 1,
name: "Test Arma3",
game_type: "arma3",
status: "running",
port: 2302,
max_players: 64,
current_players: 32,
restart_count: 3,
auto_restart: true,
created_at: "2026-01-01T00:00:00Z",
};
function renderCard(server: Partial<Server> = {}) {
const fullServer: Server = { ...baseServer, ...server };
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return {
user: userEvent.setup(),
...render(
<QueryClientProvider client={queryClient}>
<ServerCard server={fullServer} />
</QueryClientProvider>,
),
};
}
function mockMutationResult(
overrides: Partial<{ mutateAsync: ReturnType<typeof vi.fn>; isPending: boolean }> = {},
) {
return {
mutateAsync: vi.fn(() => Promise.resolve()),
isPending: false,
isSuccess: false,
isError: false,
reset: vi.fn(),
mutate: vi.fn(),
...overrides,
};
}
describe("ServerCard handlers", () => {
beforeEach(() => {
useUIStore.setState({ notifications: [] });
vi.mocked(useStartServer).mockReturnValue(
mockMutationResult() as unknown as ReturnType<typeof useStartServer>,
);
vi.mocked(useStopServer).mockReturnValue(
mockMutationResult() as unknown as ReturnType<typeof useStopServer>,
);
vi.mocked(useRestartServer).mockReturnValue(
mockMutationResult() as unknown as ReturnType<typeof useRestartServer>,
);
});
it("should add success notification on start success", async () => {
const { user } = renderCard({ status: "stopped" });
await user.click(screen.getByLabelText("Start Test Arma3"));
const state = useUIStore.getState();
expect(state.notifications).toHaveLength(1);
expect(state.notifications[0].type).toBe("success");
});
it("should add error notification on start failure", async () => {
const startMutation = mockMutationResult({
mutateAsync: vi.fn(() => Promise.reject(new Error("fail"))),
});
vi.mocked(useStartServer).mockReturnValue(
startMutation as unknown as ReturnType<typeof useStartServer>,
);
const { user } = renderCard({ status: "stopped" });
await user.click(screen.getByLabelText("Start Test Arma3"));
const state = useUIStore.getState();
expect(state.notifications.some((n) => n.type === "error")).toBe(true);
});
it("should call stopServer on Stop click", async () => {
const stopMutation = mockMutationResult();
vi.mocked(useStopServer).mockReturnValue(
stopMutation as unknown as ReturnType<typeof useStopServer>,
);
const { user } = renderCard({ status: "running" });
await user.click(screen.getByLabelText("Stop Test Arma3"));
expect(stopMutation.mutateAsync).toHaveBeenCalledWith({ serverId: 1 });
});
it("should add error notification on stop failure", async () => {
const stopMutation = mockMutationResult({
mutateAsync: vi.fn(() => Promise.reject(new Error("fail"))),
});
vi.mocked(useStopServer).mockReturnValue(
stopMutation as unknown as ReturnType<typeof useStopServer>,
);
const { user } = renderCard({ status: "running" });
await user.click(screen.getByLabelText("Stop Test Arma3"));
const state = useUIStore.getState();
expect(state.notifications.some((n) => n.type === "error")).toBe(true);
});
it("should call restartServer on Restart click", async () => {
const restartMutation = mockMutationResult();
vi.mocked(useRestartServer).mockReturnValue(
restartMutation as unknown as ReturnType<typeof useRestartServer>,
);
const { user } = renderCard({ status: "running" });
await user.click(screen.getByLabelText("Restart Test Arma3"));
expect(restartMutation.mutateAsync).toHaveBeenCalledWith(1);
});
it("should add error notification on restart failure", async () => {
const restartMutation = mockMutationResult({
mutateAsync: vi.fn(() => Promise.reject(new Error("fail"))),
});
vi.mocked(useRestartServer).mockReturnValue(
restartMutation as unknown as ReturnType<typeof useRestartServer>,
);
const { user } = renderCard({ status: "running" });
await user.click(screen.getByLabelText("Restart Test Arma3"));
const state = useUIStore.getState();
expect(state.notifications.some((n) => n.type === "error")).toBe(true);
});
it("should disable Restart button when server is starting", () => {
renderCard({ status: "starting" });
const restartBtn = screen.getByLabelText("Restart Test Arma3");
expect(restartBtn).toBeDisabled();
});
it("should disable Stop and Restart when server is restarting", () => {
renderCard({ status: "restarting" });
expect(screen.getByLabelText("Stop Test Arma3")).toBeDisabled();
expect(screen.getByLabelText("Restart Test Arma3")).toBeDisabled();
});
it("should disable Start button while start is pending", () => {
vi.mocked(useStartServer).mockReturnValue(
mockMutationResult({ isPending: true }) as unknown as ReturnType<typeof useStartServer>,
);
renderCard({ status: "stopped" });
expect(screen.getByLabelText("Start Test Arma3")).toBeDisabled();
});
});

View File

@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ServerCard } from "@/components/servers/ServerCard";
import type { Server } from "@/hooks/useServers";
import {
useStartServer,
useStopServer,
useRestartServer,
} from "@/hooks/useServers";
vi.mock("@/hooks/useServers", () => ({
useStartServer: vi.fn(),
useStopServer: vi.fn(),
useRestartServer: vi.fn(),
}));
const mockMutation = (resolve?: boolean) => ({
mutateAsync: vi.fn(() => (resolve === false ? Promise.reject(new Error("fail")) : Promise.resolve())),
isPending: false,
});
function renderCard(server: Partial<Server> = {}) {
const fullServer: Server = {
id: 1,
name: "Test Arma3",
game_type: "arma3",
status: "running",
port: 2302,
max_players: 64,
current_players: 32,
restart_count: 3,
auto_restart: true,
created_at: "2026-01-01T00:00:00Z",
...server,
};
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return {
user: userEvent.setup(),
...render(
<QueryClientProvider client={queryClient}>
<ServerCard server={fullServer} />
</QueryClientProvider>,
),
};
}
describe("ServerCard", () => {
beforeEach(() => {
vi.mocked(useStartServer).mockReturnValue(mockMutation() as unknown as ReturnType<typeof useStartServer>);
vi.mocked(useStopServer).mockReturnValue(mockMutation() as unknown as ReturnType<typeof useStopServer>);
vi.mocked(useRestartServer).mockReturnValue(mockMutation() as unknown as ReturnType<typeof useRestartServer>);
});
it("should render server name and game type", () => {
renderCard();
expect(screen.getByText("Test Arma3")).toBeInTheDocument();
expect(screen.getByText("arma3")).toBeInTheDocument();
});
it("should display player count", () => {
renderCard();
expect(screen.getByText("32/64")).toBeInTheDocument();
});
it("should display port number", () => {
renderCard({ port: 2302 });
expect(screen.getByText("2302")).toBeInTheDocument();
});
it("should display restart count", () => {
renderCard({ restart_count: 3 });
expect(screen.getByText("3")).toBeInTheDocument();
});
it("should show Stop button when server is running", () => {
renderCard({ status: "running" });
expect(screen.getByLabelText("Stop Test Arma3")).toBeInTheDocument();
});
it("should show Start button when server is stopped", () => {
renderCard({ status: "stopped" });
expect(screen.getByLabelText("Start Test Arma3")).toBeInTheDocument();
});
it("should not show Start button when server is running", () => {
renderCard({ status: "running" });
expect(screen.queryByLabelText(/Start Test Arma3/)).not.toBeInTheDocument();
});
it("should show Restart button when server is running", () => {
renderCard({ status: "running" });
expect(screen.getByLabelText("Restart Test Arma3")).toBeInTheDocument();
});
it("should disable Stop button when server is starting", () => {
renderCard({ status: "starting" });
const stopBtn = screen.getByLabelText("Stop Test Arma3");
expect(stopBtn).toBeDisabled();
});
it("should call startServer on Start click", async () => {
const startMutation = mockMutation();
vi.mocked(useStartServer).mockReturnValue(startMutation as unknown as ReturnType<typeof useStartServer>);
const { user } = renderCard({ status: "stopped" });
await user.click(screen.getByLabelText("Start Test Arma3"));
expect(startMutation.mutateAsync).toHaveBeenCalledWith(1);
});
});

View File

@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Sidebar } from "@/components/layout/Sidebar";
import { useServers } from "@/hooks/useServers";
import type { Server } from "@/hooks/useServers";
vi.mock("@/hooks/useServers");
const mockServer: Server = {
id: 1,
name: "Test Server",
game_type: "arma3",
status: "running",
port: 2302,
max_players: 64,
current_players: 32,
restart_count: 2,
auto_restart: true,
created_at: "2026-01-01T00:00:00Z",
};
function renderSidebar(path = "/") {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="*" element={<Sidebar />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("Sidebar", () => {
beforeEach(() => {
vi.mocked(useServers).mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
error: null,
} as unknown as ReturnType<typeof useServers>);
});
it("should render the Languard branding", () => {
renderSidebar();
expect(screen.getByText("Languard")).toBeInTheDocument();
expect(screen.getByText("Server Manager")).toBeInTheDocument();
});
it("should render Dashboard link", () => {
renderSidebar();
expect(screen.getByText("Dashboard")).toBeInTheDocument();
});
it("should render Settings link", () => {
renderSidebar();
expect(screen.getByText("Settings")).toBeInTheDocument();
});
it("should show loading state while servers load", () => {
vi.mocked(useServers).mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
error: null,
} as unknown as ReturnType<typeof useServers>);
renderSidebar();
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
it("should render server list when loaded", () => {
vi.mocked(useServers).mockReturnValue({
data: [mockServer],
isLoading: false,
isError: false,
error: null,
} as unknown as ReturnType<typeof useServers>);
renderSidebar();
expect(screen.getByText("Test Server")).toBeInTheDocument();
expect(screen.getByText("arma3")).toBeInTheDocument();
});
it("should highlight active server", () => {
vi.mocked(useServers).mockReturnValue({
data: [mockServer],
isLoading: false,
isError: false,
error: null,
} as unknown as ReturnType<typeof useServers>);
const { container } = renderSidebar("/servers/1");
const link = container.querySelector(`a[href="/servers/1"]`);
expect(link).toBeTruthy();
});
});

View File

@@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { StatusLed } from "@/components/ui/StatusLed";
describe("StatusLed", () => {
it("should render with correct status class for running", () => {
const { container } = render(<StatusLed status="running" />);
const led = container.querySelector("[aria-hidden='true']");
expect(led).toHaveClass("status-led-running");
});
it("should render with correct status class for stopped", () => {
const { container } = render(<StatusLed status="stopped" />);
const led = container.querySelector("[aria-hidden='true']");
expect(led).toHaveClass("status-led-stopped");
});
it("should render with correct status class for crashed", () => {
const { container } = render(<StatusLed status="crashed" />);
const led = container.querySelector("[aria-hidden='true']");
expect(led).toHaveClass("status-led-crashed");
});
it("should render with correct status class for starting", () => {
const { container } = render(<StatusLed status="starting" />);
const led = container.querySelector("[aria-hidden='true']");
expect(led).toHaveClass("status-led-starting");
});
it("should render with correct status class for restarting", () => {
const { container } = render(<StatusLed status="restarting" />);
const led = container.querySelector("[aria-hidden='true']");
expect(led).toHaveClass("status-led-restarting");
});
it("should show label when showLabel is true", () => {
render(<StatusLed status="running" showLabel />);
expect(screen.getByText("Running")).toBeInTheDocument();
});
it("should not show label when showLabel is false", () => {
render(<StatusLed status="running" />);
expect(screen.queryByText("Running")).not.toBeInTheDocument();
});
it("should use small size when size is sm", () => {
const { container } = render(<StatusLed status="running" size="sm" />);
const led = container.querySelector("[aria-hidden='true']");
expect(led).toHaveClass("w-1.5", "h-1.5");
});
it("should use medium size when size is md", () => {
const { container } = render(<StatusLed status="running" size="md" />);
const led = container.querySelector("[aria-hidden='true']");
expect(led).toHaveClass("w-2", "h-2");
});
});

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { apiClient } from "@/lib/api";
import type { InternalAxiosRequestConfig } from "axios";
describe("apiClient", () => {
const originalToken = localStorage.getItem("languard_token");
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
if (originalToken) {
localStorage.setItem("languard_token", originalToken);
}
});
it("should not include Authorization header when no token exists", async () => {
const handler = apiClient.interceptors.request.handlers?.[0];
if (!handler?.fulfilled) return;
const config: InternalAxiosRequestConfig = {
headers: {} as InternalAxiosRequestConfig["headers"],
baseURL: "http://localhost:8000",
url: "/api/test",
method: "get",
};
const result = await handler.fulfilled(config);
expect(result.headers?.Authorization).toBeUndefined();
});
it("should include Authorization header when token exists", async () => {
localStorage.setItem("languard_token", "test-token-123");
const handler = apiClient.interceptors.request.handlers?.[0];
if (!handler?.fulfilled) return;
const config: InternalAxiosRequestConfig = {
headers: {} as InternalAxiosRequestConfig["headers"],
baseURL: "http://localhost:8000",
url: "/api/test",
method: "get",
};
const result = await handler.fulfilled(config);
expect(result.headers?.Authorization).toBe("Bearer test-token-123");
});
it("should clear token and redirect on 401 response", async () => {
const originalLocation = window.location;
Object.defineProperty(window, "location", {
value: { href: "" },
writable: true,
});
const mockError = {
response: { status: 401 },
config: {},
};
const handler = apiClient.interceptors.response.handlers?.[0];
if (handler?.rejected) {
await expect(handler.rejected(mockError as never)).rejects.toBeDefined();
}
expect(localStorage.getItem("languard_token")).toBeNull();
expect(window.location.href).toBe("/login");
Object.defineProperty(window, "location", { value: originalLocation });
});
});

View File

@@ -0,0 +1,47 @@
import { describe, it, expect, beforeEach } from "vitest";
import { useAuthStore } from "@/store/auth.store";
describe("useAuthStore", () => {
beforeEach(() => {
localStorage.clear();
useAuthStore.setState({
token: null,
user: null,
isAuthenticated: false,
});
});
it("should initialize with null token and user", () => {
const state = useAuthStore.getState();
expect(state.token).toBeNull();
expect(state.user).toBeNull();
expect(state.isAuthenticated).toBe(false);
});
it("should set auth state correctly", () => {
const { setAuth } = useAuthStore.getState();
const mockUser = { id: 1, username: "admin", role: "admin" as const };
setAuth("test-token", mockUser);
const state = useAuthStore.getState();
expect(state.token).toBe("test-token");
expect(state.user).toEqual(mockUser);
expect(state.isAuthenticated).toBe(true);
expect(localStorage.getItem("languard_token")).toBe("test-token");
});
it("should clear auth state correctly", () => {
const { setAuth, clearAuth } = useAuthStore.getState();
const mockUser = { id: 1, username: "admin", role: "admin" as const };
setAuth("test-token", mockUser);
clearAuth();
const state = useAuthStore.getState();
expect(state.token).toBeNull();
expect(state.user).toBeNull();
expect(state.isAuthenticated).toBe(false);
expect(localStorage.getItem("languard_token")).toBeNull();
});
});

View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom";

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { useUIStore } from "@/store/ui.store";
describe("useUIStore", () => {
beforeEach(() => {
useUIStore.setState({
sidebarOpen: true,
activeServerId: null,
notifications: [],
});
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("should initialize with default values", () => {
const state = useUIStore.getState();
expect(state.sidebarOpen).toBe(true);
expect(state.activeServerId).toBeNull();
expect(state.notifications).toEqual([]);
});
it("should toggle sidebar correctly", () => {
const { toggleSidebar } = useUIStore.getState();
toggleSidebar();
expect(useUIStore.getState().sidebarOpen).toBe(false);
toggleSidebar();
expect(useUIStore.getState().sidebarOpen).toBe(true);
});
it("should set active server correctly", () => {
const { setActiveServer } = useUIStore.getState();
setActiveServer(1);
expect(useUIStore.getState().activeServerId).toBe(1);
setActiveServer(null);
expect(useUIStore.getState().activeServerId).toBeNull();
});
it("should add notification with auto-remove", () => {
const { addNotification } = useUIStore.getState();
addNotification({ type: "success", message: "Test notification" });
const state = useUIStore.getState();
expect(state.notifications).toHaveLength(1);
expect(state.notifications[0]).toMatchObject({
type: "success",
message: "Test notification",
});
// Fast-forward to trigger auto-remove
vi.advanceTimersByTime(5000);
expect(useUIStore.getState().notifications).toHaveLength(0);
});
it("should remove notification by id", () => {
const { addNotification, removeNotification } = useUIStore.getState();
addNotification({ type: "success", message: "Test 1" });
addNotification({ type: "error", message: "Test 2" });
const notificationId = useUIStore.getState().notifications[0].id;
removeNotification(notificationId);
expect(useUIStore.getState().notifications).toHaveLength(1);
expect(useUIStore.getState().notifications[0].message).toBe("Test 2");
});
});

View File

@@ -0,0 +1,216 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import {
useServers,
useServer,
useStartServer,
useStopServer,
useRestartServer,
useCreateServer,
useDeleteServer,
} from "@/hooks/useServers";
vi.mock("@/lib/api", () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
import { apiClient } from "@/lib/api";
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};
}
describe("useServers", () => {
beforeEach(() => {
vi.mocked(apiClient.get).mockReset();
});
it("should fetch servers list", async () => {
const mockServers = [
{
id: 1,
name: "Arma3",
game_type: "arma3",
status: "running",
port: 2302,
max_players: 64,
current_players: 10,
restart_count: 0,
auto_restart: true,
created_at: "2026-01-01T00:00:00Z",
},
];
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: { success: true, data: mockServers },
});
const { result } = renderHook(() => useServers(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockServers);
expect(apiClient.get).toHaveBeenCalledWith("/api/servers");
});
});
describe("useServer", () => {
beforeEach(() => {
vi.mocked(apiClient.get).mockReset();
});
it("should fetch single server", async () => {
const mockServer = {
id: 1,
name: "Arma3",
game_type: "arma3",
status: "running",
port: 2302,
max_players: 64,
current_players: 10,
restart_count: 0,
auto_restart: true,
created_at: "2026-01-01T00:00:00Z",
};
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: { success: true, data: mockServer },
});
const { result } = renderHook(() => useServer(1), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockServer);
expect(apiClient.get).toHaveBeenCalledWith("/api/servers/1");
});
it("should not fetch when serverId is 0", () => {
renderHook(() => useServer(0), { wrapper: createWrapper() });
expect(apiClient.get).not.toHaveBeenCalled();
});
});
describe("useStartServer", () => {
beforeEach(() => {
vi.mocked(apiClient.post).mockReset();
});
it("should call start endpoint", async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { success: true } });
const { result } = renderHook(() => useStartServer(), {
wrapper: createWrapper(),
});
result.current.mutate(1);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.post).toHaveBeenCalledWith("/api/servers/1/start");
});
});
describe("useStopServer", () => {
beforeEach(() => {
vi.mocked(apiClient.post).mockReset();
});
it("should call stop endpoint", async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { success: true } });
const { result } = renderHook(() => useStopServer(), {
wrapper: createWrapper(),
});
result.current.mutate({ serverId: 1 });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.post).toHaveBeenCalledWith("/api/servers/1/stop", {
force: undefined,
});
});
it("should pass force flag", async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { success: true } });
const { result } = renderHook(() => useStopServer(), {
wrapper: createWrapper(),
});
result.current.mutate({ serverId: 1, force: true });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.post).toHaveBeenCalledWith("/api/servers/1/stop", {
force: true,
});
});
});
describe("useRestartServer", () => {
beforeEach(() => {
vi.mocked(apiClient.post).mockReset();
});
it("should call restart endpoint", async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { success: true } });
const { result } = renderHook(() => useRestartServer(), {
wrapper: createWrapper(),
});
result.current.mutate(1);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.post).toHaveBeenCalledWith("/api/servers/1/restart");
});
});
describe("useCreateServer", () => {
beforeEach(() => {
vi.mocked(apiClient.post).mockReset();
});
it("should call create endpoint", async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { success: true } });
const { result } = renderHook(() => useCreateServer(), {
wrapper: createWrapper(),
});
result.current.mutate({ name: "New Server" });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.post).toHaveBeenCalledWith("/api/servers", {
name: "New Server",
});
});
});
describe("useDeleteServer", () => {
beforeEach(() => {
vi.mocked(apiClient.delete).mockReset();
});
it("should call delete endpoint", async () => {
vi.mocked(apiClient.delete).mockResolvedValueOnce({ data: { success: true } });
const { result } = renderHook(() => useDeleteServer(), {
wrapper: createWrapper(),
});
result.current.mutate(1);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.delete).toHaveBeenCalledWith("/api/servers/1");
});
});

View File

@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { useWebSocket } from "@/hooks/useWebSocket";
import { useAuthStore } from "@/store/auth.store";
vi.mock("@/store/auth.store", () => ({
useAuthStore: vi.fn(),
}));
const mockInvalidateQueries = vi.fn();
vi.mock("@tanstack/react-query", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
return {
...actual,
useQueryClient: () => ({
invalidateQueries: mockInvalidateQueries,
}),
};
});
function createWrapper() {
const queryClient = new QueryClient();
return function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};
}
describe("useWebSocket", () => {
let mockWsInstance: {
close: ReturnType<typeof vi.fn>;
onopen: ((ev: Event) => void) | null;
onmessage: ((ev: MessageEvent) => void) | null;
onclose: ((ev: CloseEvent) => void) | null;
onerror: ((ev: Event) => void) | null;
};
let MockWebSocket: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
mockInvalidateQueries.mockClear();
const handlers = {
onopen: null as ((ev: Event) => void) | null,
onmessage: null as ((ev: MessageEvent) => void) | null,
onclose: null as ((ev: CloseEvent) => void) | null,
onerror: null as ((ev: Event) => void) | null,
};
mockWsInstance = {
close: vi.fn(),
get onopen() { return handlers.onopen; },
set onopen(v) { handlers.onopen = v; },
get onmessage() { return handlers.onmessage; },
set onmessage(v) { handlers.onmessage = v; },
get onclose() { return handlers.onclose; },
set onclose(v) { handlers.onclose = v; },
get onerror() { return handlers.onerror; },
set onerror(v) { handlers.onerror = v; },
};
MockWebSocket = vi.fn(function (this: typeof mockWsInstance, _url: string) {
return mockWsInstance;
});
vi.stubGlobal("WebSocket", MockWebSocket);
vi.mocked(useAuthStore).mockReturnValue({
token: "test-token",
} as unknown as ReturnType<typeof useAuthStore>);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("should not connect when no token exists", () => {
vi.mocked(useAuthStore).mockReturnValue({
token: null,
} as unknown as ReturnType<typeof useAuthStore>);
renderHook(() => useWebSocket(), { wrapper: createWrapper() });
expect(MockWebSocket).not.toHaveBeenCalled();
});
it("should create WebSocket connection when token exists", () => {
renderHook(() => useWebSocket(), { wrapper: createWrapper() });
expect(MockWebSocket).toHaveBeenCalled();
});
it("should include token in WebSocket URL", () => {
renderHook(() => useWebSocket(), { wrapper: createWrapper() });
const calledUrl = MockWebSocket.mock.calls[0][0] as string;
expect(calledUrl).toContain("token=test-token");
});
it("should invalidate queries on server_status event", () => {
renderHook(() => useWebSocket(), { wrapper: createWrapper() });
act(() => {
if (mockWsInstance.onmessage) {
mockWsInstance.onmessage({
data: JSON.stringify({
type: "server_status",
server_id: 1,
data: { status: "running" },
}),
} as unknown as MessageEvent);
}
});
expect(mockInvalidateQueries).toHaveBeenCalled();
});
it("should close WebSocket on unmount", () => {
const { unmount } = renderHook(() => useWebSocket(), {
wrapper: createWrapper(),
});
unmount();
expect(mockWsInstance.close).toHaveBeenCalled();
});
});