/** * @vitest-environment jsdom */ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Route, Router } from "wouter"; import { memoryLocation } from "wouter/memory-location"; import { apiClient } from "../api/client"; import { AuthProvider } from "../stores"; import { DeckDetailPage } from "./DeckDetailPage"; vi.mock("../api/client", () => ({ apiClient: { login: vi.fn(), logout: vi.fn(), isAuthenticated: vi.fn(), getTokens: vi.fn(), getAuthHeader: vi.fn(), rpc: { api: { decks: { $get: vi.fn(), $post: vi.fn(), }, }, }, }, ApiClientError: class ApiClientError extends Error { constructor( message: string, public status: number, public code?: string, ) { super(message); this.name = "ApiClientError"; } }, })); // Mock fetch globally const mockFetch = vi.fn(); global.fetch = mockFetch; const mockDeck = { id: "deck-1", name: "Japanese Vocabulary", description: "Common Japanese words", newCardsPerDay: 20, createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:00Z", }; const mockCards = [ { id: "card-1", deckId: "deck-1", front: "Hello", back: "こんにちは", state: 0, due: "2024-01-01T00:00:00Z", stability: 0, difficulty: 0, elapsedDays: 0, scheduledDays: 0, reps: 0, lapses: 0, lastReview: null, createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:00Z", deletedAt: null, syncVersion: 0, }, { id: "card-2", deckId: "deck-1", front: "Goodbye", back: "さようなら", state: 2, due: "2024-01-02T00:00:00Z", stability: 5.5, difficulty: 5.0, elapsedDays: 1, scheduledDays: 7, reps: 5, lapses: 1, lastReview: "2024-01-01T00:00:00Z", createdAt: "2024-01-01T00:00:00Z", updatedAt: "2024-01-01T00:00:00Z", deletedAt: null, syncVersion: 0, }, ]; function renderWithProviders(path = "/decks/deck-1") { const { hook } = memoryLocation({ path, static: true }); return render( , ); } describe("DeckDetailPage", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(apiClient.getTokens).mockReturnValue({ accessToken: "access-token", refreshToken: "refresh-token", }); vi.mocked(apiClient.isAuthenticated).mockReturnValue(true); vi.mocked(apiClient.getAuthHeader).mockReturnValue({ Authorization: "Bearer access-token", }); }); afterEach(() => { cleanup(); vi.restoreAllMocks(); }); it("renders back link and deck name", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: async () => ({ deck: mockDeck }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ cards: mockCards }), }); renderWithProviders(); await waitFor(() => { expect( screen.getByRole("heading", { name: "Japanese Vocabulary" }), ).toBeDefined(); }); expect(screen.getByText(/Back to Decks/)).toBeDefined(); expect(screen.getByText("Common Japanese words")).toBeDefined(); }); it("shows loading state while fetching data", async () => { mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves renderWithProviders(); // Loading state shows spinner (svg with animate-spin class) expect(document.querySelector(".animate-spin")).toBeDefined(); }); it("displays empty state when no cards exist", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: async () => ({ deck: mockDeck }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ cards: [] }), }); renderWithProviders(); await waitFor(() => { expect(screen.getByText("No cards yet")).toBeDefined(); }); expect(screen.getByText("Add notes to start studying")).toBeDefined(); }); it("displays list of cards", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: async () => ({ deck: mockDeck }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ cards: mockCards }), }); renderWithProviders(); await waitFor(() => { expect(screen.getByText("Hello")).toBeDefined(); }); expect(screen.getByText("こんにちは")).toBeDefined(); expect(screen.getByText("Goodbye")).toBeDefined(); expect(screen.getByText("さようなら")).toBeDefined(); }); it("displays card count", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: async () => ({ deck: mockDeck }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ cards: mockCards }), }); renderWithProviders(); await waitFor(() => { expect(screen.getByText("(2)")).toBeDefined(); }); }); it("displays card state labels", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: async () => ({ deck: mockDeck }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ cards: mockCards }), }); renderWithProviders(); await waitFor(() => { expect(screen.getByText("New")).toBeDefined(); }); expect(screen.getByText("Review")).toBeDefined(); }); it("displays card stats (reps and lapses)", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: async () => ({ deck: mockDeck }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ cards: mockCards }), }); renderWithProviders(); await waitFor(() => { expect(screen.getByText("0 reviews")).toBeDefined(); }); expect(screen.getByText("5 reviews")).toBeDefined(); expect(screen.getByText("1 lapses")).toBeDefined(); }); it("displays error on API failure for deck", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({ error: "Deck not found" }), }); renderWithProviders(); await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain("Deck not found"); }); }); it("displays error on API failure for cards", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: async () => ({ deck: mockDeck }), }) .mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({ error: "Failed to load cards" }), }); renderWithProviders(); await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( "Failed to load cards", ); }); }); it("allows retry after error", async () => { const user = userEvent.setup(); // First call fails for both deck and cards (they run in parallel) mockFetch .mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({ error: "Server error" }), }) .mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({ error: "Server error" }), }) // Second call (retry) succeeds .mockResolvedValueOnce({ ok: true, json: async () => ({ deck: mockDeck }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ cards: mockCards }), }); renderWithProviders(); await waitFor(() => { expect(screen.getByRole("alert")).toBeDefined(); }); await user.click(screen.getByRole("button", { name: "Retry" })); await waitFor(() => { expect( screen.getByRole("heading", { name: "Japanese Vocabulary" }), ).toBeDefined(); }); }); it("passes auth header when fetching data", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: async () => ({ deck: mockDeck }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ cards: [] }), }); renderWithProviders(); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1", { headers: { Authorization: "Bearer access-token" }, }); }); expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/cards", { headers: { Authorization: "Bearer access-token" }, }); }); it("does not show description if deck has none", async () => { const deckWithoutDescription = { ...mockDeck, description: null }; mockFetch .mockResolvedValueOnce({ ok: true, json: async () => ({ deck: deckWithoutDescription }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ cards: [] }), }); renderWithProviders(); await waitFor(() => { expect( screen.getByRole("heading", { name: "Japanese Vocabulary" }), ).toBeDefined(); }); // No description should be shown expect(screen.queryByText("Common Japanese words")).toBeNull(); }); describe("Delete Card", () => { it("shows Delete button for each card", async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: async () => ({ deck: mockDeck }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ cards: mockCards }), }); renderWithProviders(); await waitFor(() => { expect(screen.getByText("Hello")).toBeDefined(); }); const deleteButtons = screen.getAllByRole("button", { name: "Delete card", }); expect(deleteButtons.length).toBe(2); }); it("opens delete confirmation modal when Delete button is clicked", async () => { const user = userEvent.setup(); mockFetch .mockResolvedValueOnce({ ok: true, json: async () => ({ deck: mockDeck }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ cards: mockCards }), }); renderWithProviders(); await waitFor(() => { expect(screen.getByText("Hello")).toBeDefined(); }); const deleteButtons = screen.getAllByRole("button", { name: "Delete card", }); const firstDeleteButton = deleteButtons[0]; if (firstDeleteButton) { await user.click(firstDeleteButton); } expect(screen.getByRole("dialog")).toBeDefined(); expect( screen.getByRole("heading", { name: "Delete Card" }), ).toBeDefined(); }); it("closes delete modal when Cancel is clicked", async () => { const user = userEvent.setup(); mockFetch .mockResolvedValueOnce({ ok: true, json: async () => ({ deck: mockDeck }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ cards: mockCards }), }); renderWithProviders(); await waitFor(() => { expect(screen.getByText("Hello")).toBeDefined(); }); const deleteButtons = screen.getAllByRole("button", { name: "Delete card", }); const firstDeleteButton = deleteButtons[0]; if (firstDeleteButton) { await user.click(firstDeleteButton); } expect(screen.getByRole("dialog")).toBeDefined(); await user.click(screen.getByRole("button", { name: "Cancel" })); expect(screen.queryByRole("dialog")).toBeNull(); }); it("deletes card and refreshes list on confirmation", async () => { const user = userEvent.setup(); mockFetch // Initial load .mockResolvedValueOnce({ ok: true, json: async () => ({ deck: mockDeck }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ cards: mockCards }), }) // Delete request .mockResolvedValueOnce({ ok: true, json: async () => ({}), }) // Refresh cards after deletion .mockResolvedValueOnce({ ok: true, json: async () => ({ cards: [mockCards[1]] }), }); renderWithProviders(); await waitFor(() => { expect(screen.getByText("Hello")).toBeDefined(); }); const deleteButtons = screen.getAllByRole("button", { name: "Delete card", }); const firstDeleteButton = deleteButtons[0]; if (firstDeleteButton) { await user.click(firstDeleteButton); } // Find the Delete button in the modal (using the button's text content) const dialog = screen.getByRole("dialog"); const modalButtons = dialog.querySelectorAll("button"); // Find the button with "Delete" text (not "Cancel") const confirmDeleteButton = Array.from(modalButtons).find((btn) => btn.textContent?.includes("Delete"), ); if (confirmDeleteButton) { await user.click(confirmDeleteButton); } // Wait for modal to close and list to refresh await waitFor(() => { expect(screen.queryByRole("dialog")).toBeNull(); }); // Verify DELETE request was made expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/cards/card-1", { method: "DELETE", headers: { Authorization: "Bearer access-token" }, }); // Verify card count updated await waitFor(() => { expect(screen.getByText("(1)")).toBeDefined(); }); }); it("displays error when delete fails", async () => { const user = userEvent.setup(); mockFetch // Initial load .mockResolvedValueOnce({ ok: true, json: async () => ({ deck: mockDeck }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ cards: mockCards }), }) // Delete request fails .mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({ error: "Failed to delete card" }), }); renderWithProviders(); await waitFor(() => { expect(screen.getByText("Hello")).toBeDefined(); }); const deleteButtons = screen.getAllByRole("button", { name: "Delete card", }); const firstDeleteButton = deleteButtons[0]; if (firstDeleteButton) { await user.click(firstDeleteButton); } // Find the Delete button in the modal (using the button's text content) const dialog = screen.getByRole("dialog"); const modalButtons = dialog.querySelectorAll("button"); // Find the button with "Delete" text (not "Cancel") const confirmDeleteButton = Array.from(modalButtons).find((btn) => btn.textContent?.includes("Delete"), ); if (confirmDeleteButton) { await user.click(confirmDeleteButton); } // Error should be displayed in the modal await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( "Failed to delete card", ); }); }); }); });