Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions src/__tests__/EmployeeTable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { ReactNode } from "react";

import EmployeeTable from "../pages/EmployeeTable";
import * as employeeApi from "../services/employeeApi";
import type { Employee } from "../types/employee";

// ── Mocks ─────────────────────────────────────────────────────────────────────

// Isolate from the real dnd-kit browser APIs
vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children }: { children: ReactNode }) => <>{children}</>,
closestCenter: vi.fn(),
KeyboardSensor: class {},
PointerSensor: class {},
useSensor: vi.fn(),
useSensors: vi.fn(() => []),
}));

vi.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: { children: ReactNode }) => <>{children}</>,
sortableKeyboardCoordinates: vi.fn(),
verticalListSortingStrategy: vi.fn(),
arrayMove: vi.fn((arr: unknown[]) => arr),
}));

// Thin stand-in so we can assert row rendering without mounting useSortable
vi.mock("../components/SortableRow", () => ({
SortableRow: ({ employee }: { employee: Employee }) => (
<tr data-testid="employee-row">
<td>{employee.name}</td>
<td>{employee.role}</td>
</tr>
),
}));

vi.mock("../services/employeeApi", () => ({
fetchEmployees: vi.fn(),
updateEmployeeOrder: vi.fn(),
}));

// Allow per-test control of mutationError
const mockReorder = vi.fn();
let mockMutationError: Error | null = null;

vi.mock("../hooks/useUpdateEmployeeOrder", () => ({
useUpdateEmployeeOrder: () => ({
mutate: mockReorder,
error: mockMutationError,
}),
EMPLOYEES_QUERY_KEY: ["employees"],
}));

// ── Fixtures ───────────────────────────────────────────────────────────────────

const EMPLOYEES: Employee[] = [
{
id: "1",
name: "Alice",
role: "Engineer",
walletAddress: "ADDR1",
currency: "USDC",
salary: 5000,
orderIndex: 0,
},
{
id: "2",
name: "Bob",
role: "Designer",
walletAddress: "ADDR2",
currency: "USDC",
salary: 4500,
orderIndex: 1,
},
];

function createClient() {
return new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
}

function renderTable(queryClient = createClient()) {
return render(
<QueryClientProvider client={queryClient}>
<EmployeeTable />
</QueryClientProvider>,
);
}

// ── Tests ──────────────────────────────────────────────────────────────────────

describe("EmployeeTable", () => {
beforeEach(() => {
vi.clearAllMocks();
mockMutationError = null;
});

it("shows a loading indicator while data is in-flight", () => {
vi.mocked(employeeApi.fetchEmployees).mockReturnValue(
new Promise(() => {}),
);

renderTable();

expect(screen.getByText(/loading employees/i)).toBeInTheDocument();
});

it("shows an error message when fetchEmployees rejects", async () => {
vi.mocked(employeeApi.fetchEmployees).mockRejectedValue(new Error("500"));

renderTable();

await waitFor(() => {
expect(screen.getByText(/failed to load employees/i)).toBeInTheDocument();
});
});

it("renders the table headings and a row for each employee", async () => {
vi.mocked(employeeApi.fetchEmployees).mockResolvedValue(EMPLOYEES);

renderTable();

await waitFor(() => {
expect(screen.getByText("Name")).toBeInTheDocument();
});

expect(screen.getByText("Role")).toBeInTheDocument();
expect(screen.getByText("Wallet")).toBeInTheDocument();
expect(screen.getByText("Currency")).toBeInTheDocument();
expect(screen.getByText("Salary")).toBeInTheDocument();

const rows = screen.getAllByTestId("employee-row");
expect(rows).toHaveLength(EMPLOYEES.length);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
});

it("shows a mutation error alert when the reorder API call fails", async () => {
vi.mocked(employeeApi.fetchEmployees).mockResolvedValue(EMPLOYEES);
mockMutationError = new Error("Reorder failed");

renderTable();

await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
});

expect(screen.getByRole("alert")).toHaveTextContent(/reorder failed/i);
expect(screen.getByRole("alert")).toHaveTextContent(
/previous order has been restored/i,
);
});

it("does not show the alert banner when there is no mutation error", async () => {
vi.mocked(employeeApi.fetchEmployees).mockResolvedValue(EMPLOYEES);
mockMutationError = null;

renderTable();

await waitFor(() => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});

it("renders a table element with thead and tbody", async () => {
vi.mocked(employeeApi.fetchEmployees).mockResolvedValue(EMPLOYEES);

const { container } = renderTable();

await waitFor(() => {
expect(container.querySelector("table")).toBeInTheDocument();
});

expect(container.querySelector("thead")).toBeInTheDocument();
expect(container.querySelector("tbody")).toBeInTheDocument();
});
});
125 changes: 125 additions & 0 deletions src/__tests__/SortableRow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { SortableRow } from "../components/SortableRow";
import type { Employee } from "../types/employee";

// ── Mocks ─────────────────────────────────────────────────────────────────────

vi.mock("@dnd-kit/sortable", () => ({
useSortable: vi.fn(() => ({
attributes: { role: "button" },
listeners: { onKeyDown: vi.fn() },
setNodeRef: vi.fn(),
setActivatorNodeRef: vi.fn(),
transform: null,
transition: undefined,
isDragging: false,
})),
}));

vi.mock("@dnd-kit/utilities", () => ({
CSS: { Transform: { toString: () => "" } },
}));

// CSS modules resolve to plain objects in jsdom — no extra mock needed.

// Fixtures

const EMPLOYEE: Employee = {
id: "emp-1",
name: "Alice Nguyen",
role: "Engineer",
walletAddress: "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGPWQTESTADDR",
currency: "USDC",
salary: 5000,
orderIndex: 0,
};

function renderRow(employee: Employee = EMPLOYEE) {
// SortableRow renders a <tr> which requires a table context to be valid HTML.
return render(
<table>
<tbody>
<SortableRow employee={employee} />
</tbody>
</table>,
);
}

// ── Tests ──────────────────────────────────────────────────────────────────────

describe("SortableRow", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("renders the employee name, role, currency and salary", () => {
renderRow();

expect(screen.getByText("Alice Nguyen")).toBeInTheDocument();
expect(screen.getByText("Engineer")).toBeInTheDocument();
expect(screen.getByText("USDC")).toBeInTheDocument();
expect(screen.getByText("5,000")).toBeInTheDocument();
});

it("truncates the wallet address and preserves full address in title", () => {
renderRow();

const walletSpan = screen.getByTitle(EMPLOYEE.walletAddress);
expect(walletSpan).toBeInTheDocument();

// First 6 chars + ellipsis + last 4 chars
const expected = `${EMPLOYEE.walletAddress.slice(0, 6)}…${EMPLOYEE.walletAddress.slice(-4)}`;
expect(walletSpan.textContent).toBe(expected);
});

it("renders a drag handle button with the correct aria-label", () => {
renderRow();

const handle = screen.getByRole("button", { name: "Reorder employee" });
expect(handle).toBeInTheDocument();
});

it("drag handle has tabIndex={0} for keyboard accessibility", () => {
renderRow();

const handle = screen.getByRole("button", { name: "Reorder employee" });
expect(handle).toHaveAttribute("tabindex", "0");
});

it("grip SVG icon has aria-hidden so screen readers skip it", () => {
renderRow();

const svg = screen
.getByRole("button", { name: "Reorder employee" })
.querySelector("svg");

expect(svg).toHaveAttribute("aria-hidden", "true");
});

it("applies the dragging class when isDragging is true", async () => {
const { useSortable } = await import("@dnd-kit/sortable");
vi.mocked(useSortable).mockReturnValueOnce({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
setActivatorNodeRef: vi.fn(),
transform: null,
transition: undefined,
isDragging: true,
// cast because real return type has many more fields we don't need
} as unknown as ReturnType<typeof useSortable>);

const { container } = renderRow();
const row = container.querySelector("tr");

// The CSS module class name in jsdom tests resolves to the key string.
expect(row?.className).toContain("dragging");
});

it("does not apply a className when isDragging is false", () => {
const { container } = renderRow();
const row = container.querySelector("tr");
expect(row?.className).toBeFalsy();
});
});
Loading