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:
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