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:
124
frontend/src/__tests__/DashboardPage.test.tsx
Normal file
124
frontend/src/__tests__/DashboardPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
95
frontend/src/__tests__/LoginPage.test.tsx
Normal file
95
frontend/src/__tests__/LoginPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
173
frontend/src/__tests__/ServerCard.handlers.test.tsx
Normal file
173
frontend/src/__tests__/ServerCard.handlers.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
116
frontend/src/__tests__/ServerCard.test.tsx
Normal file
116
frontend/src/__tests__/ServerCard.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
104
frontend/src/__tests__/Sidebar.test.tsx
Normal file
104
frontend/src/__tests__/Sidebar.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
57
frontend/src/__tests__/StatusLed.test.tsx
Normal file
57
frontend/src/__tests__/StatusLed.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
69
frontend/src/__tests__/api.test.ts
Normal file
69
frontend/src/__tests__/api.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
47
frontend/src/__tests__/auth.store.test.ts
Normal file
47
frontend/src/__tests__/auth.store.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
1
frontend/src/__tests__/setup.ts
Normal file
1
frontend/src/__tests__/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom";
|
||||
74
frontend/src/__tests__/ui.store.test.ts
Normal file
74
frontend/src/__tests__/ui.store.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
216
frontend/src/__tests__/useServers.test.tsx
Normal file
216
frontend/src/__tests__/useServers.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
131
frontend/src/__tests__/useWebSocket.test.tsx
Normal file
131
frontend/src/__tests__/useWebSocket.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user