/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-empty-function */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, within, waitFor, act } from "@testing-library/react"; import { KanbanBoard } from "./KanbanBoard"; import type { Task } from "@mosaic/shared"; import { TaskStatus, TaskPriority } from "@mosaic/shared"; import type { ToastContextValue } from "@mosaic/ui"; // Mock fetch globally global.fetch = vi.fn(); // Mock useToast hook from @mosaic/ui const mockShowToast = vi.fn(); vi.mock("@mosaic/ui", () => ({ useToast: (): ToastContextValue => ({ showToast: mockShowToast, removeToast: vi.fn(), }), })); // Mock the api client's apiPatch function const mockApiPatch = vi.fn<(endpoint: string, data: unknown) => Promise>(); vi.mock("@/lib/api/client", () => ({ apiPatch: (endpoint: string, data: unknown): Promise => mockApiPatch(endpoint, data), })); // Store drag event handlers for testing type DragEventHandler = (event: { active: { id: string }; over: { id: string } | null; }) => Promise | void; let capturedOnDragEnd: DragEventHandler | null = null; // Mock @dnd-kit modules vi.mock("@dnd-kit/core", async () => { const actual = await vi.importActual("@dnd-kit/core"); return { ...actual, DndContext: ({ children, onDragEnd, }: { children: React.ReactNode; onDragEnd?: DragEventHandler; }): React.JSX.Element => { // Capture the event handler for testing capturedOnDragEnd = onDragEnd ?? null; return
{children}
; }, }; }); vi.mock("@dnd-kit/sortable", () => ({ SortableContext: ({ children }: { children: React.ReactNode }): React.JSX.Element => (
{children}
), verticalListSortingStrategy: {}, useSortable: (): object => ({ attributes: {}, listeners: {}, setNodeRef: (): void => {}, transform: null, transition: null, }), })); const mockTasks: Task[] = [ { id: "task-1", title: "Design homepage", description: "Create wireframes for the new homepage", status: TaskStatus.NOT_STARTED, priority: TaskPriority.HIGH, dueDate: new Date("2026-02-01"), assigneeId: "user-1", creatorId: "user-1", workspaceId: "workspace-1", projectId: null, parentId: null, sortOrder: 0, metadata: {}, completedAt: null, createdAt: new Date("2026-01-28"), updatedAt: new Date("2026-01-28"), }, { id: "task-2", title: "Implement authentication", description: "Add OAuth support", status: TaskStatus.IN_PROGRESS, priority: TaskPriority.HIGH, dueDate: new Date("2026-01-30"), assigneeId: "user-2", creatorId: "user-1", workspaceId: "workspace-1", projectId: null, parentId: null, sortOrder: 1, metadata: {}, completedAt: null, createdAt: new Date("2026-01-28"), updatedAt: new Date("2026-01-28"), }, { id: "task-3", title: "Write unit tests", description: "Achieve 85% coverage", status: TaskStatus.PAUSED, priority: TaskPriority.MEDIUM, dueDate: new Date("2026-02-05"), assigneeId: "user-3", creatorId: "user-1", workspaceId: "workspace-1", projectId: null, parentId: null, sortOrder: 2, metadata: {}, completedAt: null, createdAt: new Date("2026-01-28"), updatedAt: new Date("2026-01-28"), }, { id: "task-4", title: "Deploy to production", description: "Set up CI/CD pipeline", status: TaskStatus.COMPLETED, priority: TaskPriority.LOW, dueDate: new Date("2026-01-25"), assigneeId: "user-1", creatorId: "user-1", workspaceId: "workspace-1", projectId: null, parentId: null, sortOrder: 3, metadata: {}, completedAt: new Date("2026-01-25"), createdAt: new Date("2026-01-20"), updatedAt: new Date("2026-01-25"), }, ]; describe("KanbanBoard", (): void => { const mockOnStatusChange = vi.fn(); beforeEach((): void => { vi.clearAllMocks(); mockShowToast.mockClear(); mockApiPatch.mockClear(); // Default: apiPatch succeeds mockApiPatch.mockResolvedValue({}); // Also set up fetch mock for other tests that may use it (global.fetch as ReturnType).mockResolvedValue({ ok: true, json: (): Promise => Promise.resolve({}), } as Response); }); describe("Rendering", (): void => { it("should render all four status columns with spec names", (): void => { render(); // Spec requires: todo, in_progress, review, done expect(screen.getByText("To Do")).toBeInTheDocument(); expect(screen.getByText("In Progress")).toBeInTheDocument(); expect(screen.getByText("Review")).toBeInTheDocument(); expect(screen.getByText("Done")).toBeInTheDocument(); }); it("should organize tasks by status into correct columns", (): void => { render(); const todoColumn = screen.getByTestId("column-NOT_STARTED"); const inProgressColumn = screen.getByTestId("column-IN_PROGRESS"); const reviewColumn = screen.getByTestId("column-PAUSED"); const doneColumn = screen.getByTestId("column-COMPLETED"); expect(within(todoColumn).getByText("Design homepage")).toBeInTheDocument(); expect(within(inProgressColumn).getByText("Implement authentication")).toBeInTheDocument(); expect(within(reviewColumn).getByText("Write unit tests")).toBeInTheDocument(); expect(within(doneColumn).getByText("Deploy to production")).toBeInTheDocument(); }); it("should render empty state when no tasks provided", (): void => { render(); expect(screen.getByText("To Do")).toBeInTheDocument(); expect(screen.getByText("In Progress")).toBeInTheDocument(); expect(screen.getByText("Review")).toBeInTheDocument(); expect(screen.getByText("Done")).toBeInTheDocument(); }); it("should handle undefined tasks array gracefully", (): void => { // @ts-expect-error Testing error case render(); expect(screen.getByText("To Do")).toBeInTheDocument(); }); }); describe("Task Cards", (): void => { it("should display task title on each card", (): void => { render(); expect(screen.getByText("Design homepage")).toBeInTheDocument(); expect(screen.getByText("Implement authentication")).toBeInTheDocument(); expect(screen.getByText("Write unit tests")).toBeInTheDocument(); expect(screen.getByText("Deploy to production")).toBeInTheDocument(); }); it("should display task priority badge", (): void => { render(); const highPriorityElements = screen.getAllByText("High"); const mediumPriorityElements = screen.getAllByText("Medium"); const lowPriorityElements = screen.getAllByText("Low"); expect(highPriorityElements.length).toBeGreaterThan(0); expect(mediumPriorityElements.length).toBeGreaterThan(0); expect(lowPriorityElements.length).toBeGreaterThan(0); }); it("should display due date when available", (): void => { // Use tasks with future due dates to ensure they render const tasksWithDates: Task[] = [ { ...mockTasks[0]!, dueDate: new Date("2026-03-15"), }, { ...mockTasks[1]!, dueDate: new Date("2026-04-20"), }, ]; render(); // Check that calendar icons are present for tasks with due dates const calendarIcons = screen.getAllByLabelText("Due date"); expect(calendarIcons.length).toBeGreaterThanOrEqual(2); }); it("should display assignee avatar when assignee data is provided", (): void => { const tasksWithAssignee: Task[] = [ { ...mockTasks[0]!, // Task type uses assigneeId, not assignee object assigneeId: "user-john", }, ]; render(); // Check that the component renders the board successfully expect(screen.getByTestId("kanban-grid")).toBeInTheDocument(); // Check that the task title is rendered expect(screen.getByText("Design homepage")).toBeInTheDocument(); }); }); describe("Drag and Drop", (): void => { it("should initialize DndContext for drag-and-drop", (): void => { render(); expect(screen.getByTestId("dnd-context")).toBeInTheDocument(); }); it("should have droppable columns", (): void => { render(); const columns = screen.getAllByTestId(/^column-/); expect(columns.length).toBe(4); }); }); describe("Status Update API Call", (): void => { it("should call PATCH /api/tasks/:id when status changes", (): void => { const fetchMock = global.fetch as ReturnType; fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ status: TaskStatus.IN_PROGRESS }), } as unknown as Response); render(); // Simulate drag end by calling the component's internal method // In a real test, we'd simulate actual drag-and-drop events // For now, we'll test the fetch call directly // This is a simplified test - full E2E would use Playwright expect(screen.getByTestId("dnd-context")).toBeInTheDocument(); }); it("should handle API errors gracefully", (): void => { const fetchMock = global.fetch as ReturnType; const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); fetchMock.mockResolvedValueOnce({ ok: false, statusText: "Internal Server Error", } as Response); render(); // Component should still render even if API fails expect(screen.getByText("To Do")).toBeInTheDocument(); consoleErrorSpy.mockRestore(); }); }); describe("Optimistic Updates and Rollback", (): void => { it("should apply optimistic update immediately on drag", async (): Promise => { // apiPatch is already mocked to succeed in beforeEach render(); // Verify initial state - task-1 is in NOT_STARTED column const todoColumn = screen.getByTestId("column-NOT_STARTED"); expect(within(todoColumn).getByText("Design homepage")).toBeInTheDocument(); // Trigger drag end event to move task-1 to IN_PROGRESS and wait for completion await act(async () => { if (capturedOnDragEnd) { const result = capturedOnDragEnd({ active: { id: "task-1" }, over: { id: TaskStatus.IN_PROGRESS }, }); if (result instanceof Promise) { await result; } } }); // After the drag completes, task should be in the new column (optimistic update persisted) const inProgressColumn = screen.getByTestId("column-IN_PROGRESS"); expect(within(inProgressColumn).getByText("Design homepage")).toBeInTheDocument(); // Verify the task is NOT in the original column anymore const todoColumnAfter = screen.getByTestId("column-NOT_STARTED"); expect(within(todoColumnAfter).queryByText("Design homepage")).not.toBeInTheDocument(); }); it("should persist update when API call succeeds", async (): Promise => { // apiPatch is already mocked to succeed in beforeEach render(); // Trigger drag end event await act(async () => { if (capturedOnDragEnd) { const result = capturedOnDragEnd({ active: { id: "task-1" }, over: { id: TaskStatus.IN_PROGRESS }, }); if (result instanceof Promise) { await result; } } }); // Wait for API call to complete await waitFor(() => { expect(mockApiPatch).toHaveBeenCalledWith("/api/tasks/task-1", { status: TaskStatus.IN_PROGRESS, }); }); // Verify task is in the new column after API success const inProgressColumn = screen.getByTestId("column-IN_PROGRESS"); expect(within(inProgressColumn).getByText("Design homepage")).toBeInTheDocument(); // Verify callback was called expect(mockOnStatusChange).toHaveBeenCalledWith("task-1", TaskStatus.IN_PROGRESS); }); it("should rollback to original position when API call fails", async (): Promise => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); // Mock API failure mockApiPatch.mockRejectedValueOnce(new Error("Network error")); render(); // Verify initial state - task-1 is in NOT_STARTED column const todoColumnBefore = screen.getByTestId("column-NOT_STARTED"); expect(within(todoColumnBefore).getByText("Design homepage")).toBeInTheDocument(); // Trigger drag end event await act(async () => { if (capturedOnDragEnd) { const result = capturedOnDragEnd({ active: { id: "task-1" }, over: { id: TaskStatus.IN_PROGRESS }, }); if (result instanceof Promise) { await result; } } }); // Wait for rollback to occur await waitFor(() => { // After rollback, task should be back in original column const todoColumnAfter = screen.getByTestId("column-NOT_STARTED"); expect(within(todoColumnAfter).getByText("Design homepage")).toBeInTheDocument(); }); // Verify callback was NOT called due to error expect(mockOnStatusChange).not.toHaveBeenCalled(); consoleErrorSpy.mockRestore(); }); it("should show error toast notification when API call fails", async (): Promise => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); // Mock API failure mockApiPatch.mockRejectedValueOnce(new Error("Server error")); render(); // Trigger drag end event await act(async () => { if (capturedOnDragEnd) { const result = capturedOnDragEnd({ active: { id: "task-1" }, over: { id: TaskStatus.IN_PROGRESS }, }); if (result instanceof Promise) { await result; } } }); // Wait for error handling await waitFor(() => { // Verify showToast was called with error message expect(mockShowToast).toHaveBeenCalledWith( "Unable to update task status. Please try again.", "error" ); }); consoleErrorSpy.mockRestore(); }); it("should not make API call when dropping on same column", async (): Promise => { const fetchMock = global.fetch as ReturnType; render(); // Trigger drag end event with same status await act(async () => { if (capturedOnDragEnd) { const result = capturedOnDragEnd({ active: { id: "task-1" }, over: { id: TaskStatus.NOT_STARTED }, // Same as task's current status }); if (result instanceof Promise) { await result; } } }); // No API call should be made expect(fetchMock).not.toHaveBeenCalled(); // No callback should be called expect(mockOnStatusChange).not.toHaveBeenCalled(); }); it("should handle drag cancel (no drop target)", async (): Promise => { const fetchMock = global.fetch as ReturnType; render(); // Trigger drag end event with no drop target await act(async () => { if (capturedOnDragEnd) { const result = capturedOnDragEnd({ active: { id: "task-1" }, over: null, }); if (result instanceof Promise) { await result; } } }); // Task should remain in original column const todoColumn = screen.getByTestId("column-NOT_STARTED"); expect(within(todoColumn).getByText("Design homepage")).toBeInTheDocument(); // No API call should be made expect(fetchMock).not.toHaveBeenCalled(); }); }); describe("Accessibility", (): void => { it("should have proper heading hierarchy", (): void => { render(); const h3Headings = screen.getAllByRole("heading", { level: 3 }); expect(h3Headings.length).toBe(4); }); it("should have keyboard-navigable task cards", (): void => { render(); const taskCards = screen.getAllByRole("article"); expect(taskCards.length).toBe(mockTasks.length); }); it("should announce column changes to screen readers", (): void => { render(); const columns = screen.getAllByRole("region"); expect(columns.length).toBeGreaterThan(0); columns.forEach((column) => { expect(column).toHaveAttribute("aria-label"); }); }); }); describe("Responsive Design", (): void => { it("should apply responsive grid classes", (): void => { const { container } = render( ); const boardGrid = container.querySelector('[data-testid="kanban-grid"]'); expect(boardGrid).toBeInTheDocument(); const className = boardGrid?.className ?? ""; expect(className).toMatch(/grid/); }); }); });