Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
52 changes: 52 additions & 0 deletions src/vertex/app/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,58 @@

> **Note**: This changelog consolidates all recent improvements, features, and fixes to the AirQo Vertex frontend.

## Version 2.0.8
**Released:** June 28, 2026

### Comprehensive Unit Testing & CI Integration

Introduced a robust unit testing infrastructure using Vitest and React Testing Library, alongside comprehensive CI/CD pipeline integration. This release focuses on improving code quality, ensuring robust component rendering, and validating API proxy behavior through extensive boundary testing.

<details>
<summary><strong>Test Infrastructure & CI Integration (3)</strong></summary>

- **Vitest & React Testing Library**: Replaced legacy test runners with a fast, modern Vitest setup configured to work seamlessly with Next.js App Router and absolute imports.
- **CI/CD Pipeline**: Added dedicated GitHub Actions workflows for automated type checking (`tsc --noEmit`) and unit testing (`vitest run --coverage`) on pull requests.
- **Testing Guidelines**: Introduced `TESTING.md` internal documentation to standardize component rendering, user-event interactions, and mocking strategies across the team.

</details>

<details>
<summary><strong>UI & Shared Component Testing (3)</strong></summary>

- **Core UI Components**: Added comprehensive tests for 30+ shadcn/radix UI components (Dialogs, Accords, Tooltips, Buttons, Inputs, Forms, etc.) verifying accessibility and interaction states without snapshot testing.
- **Shared Abstractions**: Extracted and tested shared wrappers including `ReusableTable`, `ReusableSelectInput`, `ReusableButton`, and `ReusableFileUpload`, ensuring robust prop forwarding and DOM nesting compliance.
- **Warning Resolutions**: Proactively fixed React DOM nesting warnings (e.g., `<a>` cannot appear as a descendant of `<a>`) and accessibility label warnings across components during test implementation.

</details>

<details>
<summary><strong>Service & API Boundary Testing (2)</strong></summary>

- **Mock Factories**: Created `apiResponseFactory.ts` and NextAuth session mocks to simulate full HTTP environments locally without hitting live staging endpoints or needing `axios-mock-adapter`.
- **Proxy Validation**: Validated both the Next.js App Router API handlers (`proxyClient`) and client-side Axios instances (`secureApiProxyClient`), verifying `JWT` vs `API_TOKEN` injection, error normalization, and custom event dispatching (`vertex-network-degraded`, `auth-token-expired`).

</details>

<details>
<summary><strong>Files Modified & Added (40+)</strong></summary>

- `package.json` [MODIFIED]
- `vitest.config.ts` [NEW]
- `.github/workflows/test.yml` [NEW]
- `src/vertex/app/_docs/internal/TESTING.md` [NEW]
- `src/vertex/components/ui/*.test.tsx` (30+ files) [NEW]
- `src/vertex/components/shared/**/*.test.tsx` [NEW]
- `src/vertex/core/services/*.test.ts` [NEW]
- `src/vertex/core/utils/*.test.ts` [NEW]
- `src/vertex/test/factories/apiResponseFactory.ts` [NEW]
- `src/vertex/test/mocks/nextAuth.ts` [NEW]
- `src/vertex/vitest.setup.ts` [NEW]

Comment thread
coderabbitai[bot] marked this conversation as resolved.
</details>

---

## Version 2.0.7
**Released:** June 17, 2026

Expand Down
28 changes: 28 additions & 0 deletions src/vertex/components/shared/button/ReusableButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import ReusableButton from "./ReusableButton";

describe("ReusableButton", () => {
it("renders as a button by default", () => {
render(<ReusableButton>Click me</ReusableButton>);
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
});

it("renders as an anchor when path is provided", () => {
render(<ReusableButton path="/home">Go Home</ReusableButton>);
expect(screen.getByRole("link", { name: "Go Home" })).toBeInTheDocument();
});

it("handles loading state", () => {
render(<ReusableButton loading>Submit</ReusableButton>);
const button = screen.getByRole("button", { name: "Submit" });
expect(button).toHaveAttribute("aria-busy", "true");
expect(button).toBeDisabled();
});

it("renders tooltip span when disabled with permission", () => {
render(<ReusableButton disabled permission="CREATE_USER">Action</ReusableButton>);
const actionText = screen.getByText("Action");
expect(actionText).toBeInTheDocument();
});
});
28 changes: 28 additions & 0 deletions src/vertex/components/shared/card/CardWrapper.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import Card from "./CardWrapper";

describe("CardWrapper", () => {
it("renders with basic content", () => {
render(<Card>Card Content</Card>);
expect(screen.getByText("Card Content")).toBeInTheDocument();
});

it("renders with headers and footers", () => {
render(
<Card header={<div data-testid="header">Header</div>} footer={<div data-testid="footer">Footer</div>}>
Content
</Card>
);
expect(screen.getByTestId("header")).toBeInTheDocument();
expect(screen.getByTestId("footer")).toBeInTheDocument();
});

it("handles clicks", async () => {
const handleClick = vi.fn();
render(<Card onClick={handleClick} testId="clickable-card">Content</Card>);
await userEvent.click(screen.getByTestId("clickable-card"));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import ReusableFileUpload from "./ReusableFileUpload";

describe("ReusableFileUpload", () => {
it("renders with placeholder and label", () => {
render(<ReusableFileUpload label="Upload File" />);
expect(screen.getByLabelText("Upload File")).toBeInTheDocument();
expect(screen.getByText("Upload file")).toBeInTheDocument(); // default placeholder
});

it("handles file selection", () => {
const handleChange = vi.fn();
render(<ReusableFileUpload label="Upload" onChange={handleChange} />);

const file = new File(["hello"], "hello.png", { type: "image/png" });
const input = screen.getByLabelText("Upload");

fireEvent.change(input, { target: { files: [file] } });
expect(handleChange).toHaveBeenCalledWith(file);
});

it("shows error state", () => {
render(<ReusableFileUpload label="Upload" error="File too large" />);
expect(screen.getByText("File too large")).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import ReusableInputField from "./ReusableInputField";

// Mock toast to avoid missing canvas/window functions in jsdom sometimes or to just isolate
vi.mock("../toast/ReusableToast", () => ({
default: vi.fn(),
}));

// Mock clipboard
Object.assign(navigator, {
clipboard: {
writeText: vi.fn(),
},
});

describe("ReusableInputField", () => {
it("renders basic input", () => {
render(<ReusableInputField label="Email" placeholder="Enter email" />);
expect(screen.getByLabelText("Email")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Enter email")).toBeInTheDocument();
});

it("shows error message", () => {
render(<ReusableInputField label="Email" error="Invalid email" />);
expect(screen.getByText("Invalid email")).toBeInTheDocument();
});

it("toggles password visibility", async () => {
render(<ReusableInputField label="Password" type="password" />);
const input = screen.getByLabelText("Password");
expect(input).toHaveAttribute("type", "password");

const toggleBtn = screen.getByRole("button", { name: /show password/i });
await userEvent.click(toggleBtn);
expect(input).toHaveAttribute("type", "text");

await userEvent.click(screen.getByRole("button", { name: /hide password/i }));
expect(input).toHaveAttribute("type", "password");
});

it("calls custom action", async () => {
const handleAction = vi.fn();
const MockIcon = () => <div data-testid="custom-icon" />;

render(
<ReusableInputField
label="Search"
customActionIcon={MockIcon}
onCustomAction={handleAction}
customActionLabel="Do search"
/>
);

const actionBtn = screen.getByRole("button", { name: "Do search" });
await userEvent.click(actionBtn);
expect(handleAction).toHaveBeenCalledTimes(1);
});
});
60 changes: 60 additions & 0 deletions src/vertex/components/shared/select/ReusableSelectInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import ReusableSelectInput from "./ReusableSelectInput";

window.HTMLElement.prototype.scrollIntoView = vi.fn();

describe("ReusableSelectInput", () => {
it("renders placeholder correctly", () => {
render(
<ReusableSelectInput label="Fruit" placeholder="Pick fruit">
<option value="apple">Apple</option>
</ReusableSelectInput>
);

const button = screen.getByRole("button");
expect(button).toBeInTheDocument();
expect(screen.getByText("Pick fruit")).toBeInTheDocument();
});

it("shows options and handles selection", async () => {
const handleChange = vi.fn();
render(
<ReusableSelectInput label="Fruit" onChange={handleChange}>
<option value="apple">Apple</option>
<option value="banana">Banana</option>
</ReusableSelectInput>
);

const button = screen.getByRole("button");
await userEvent.click(button);

expect(screen.getByRole("listbox")).toBeInTheDocument();
const bananaOption = screen.getByText("Banana");

await userEvent.click(bananaOption);

expect(handleChange).toHaveBeenCalledWith(
expect.objectContaining({
target: expect.objectContaining({ value: "banana" })
})
);
});

it("handles keyboard navigation", async () => {
render(
<ReusableSelectInput label="Fruit">
<option value="apple">Apple</option>
<option value="banana">Banana</option>
</ReusableSelectInput>
);

const button = screen.getByRole("button");
button.focus();

// Open with arrow down
fireEvent.keyDown(button, { key: "ArrowDown" });
expect(screen.getByRole("listbox")).toBeInTheDocument();
});
});
35 changes: 35 additions & 0 deletions src/vertex/components/shared/table/ReusableTable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import ReusableTable from "./ReusableTable";

vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: vi.fn() }),
usePathname: () => "/test-path",
useSearchParams: () => new URLSearchParams(),
}));

vi.mock("@/context/banner-context", () => ({
useBanner: () => ({ showBanner: vi.fn(), hideBanner: vi.fn() }),
}));

describe("ReusableTable", () => {
it("renders with empty data", () => {
render(<ReusableTable columns={[]} data={[]} title="Test Table" />);
expect(screen.getByText("Test Table")).toBeInTheDocument();
expect(screen.getByText("No data available")).toBeInTheDocument();
});

it("renders data correctly", () => {
const columns = [
{ key: "name", render: (val: unknown) => String(val) },
];
const data = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" }
];

render(<ReusableTable columns={columns as never} data={data} />);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
});
});
20 changes: 1 addition & 19 deletions src/vertex/components/shared/table/ReusableTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1472,7 +1472,7 @@ const ReusableTable = <T extends TableItem>({
)}

{/* Add CSS to hide scrollbar on thead */}
<style jsx>{`
<style>{`
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
Expand Down Expand Up @@ -1504,22 +1504,4 @@ const ReusableTable = <T extends TableItem>({
);
};

ReusableTable.defaultProps = {
title: "Table",
data: [],
columns: [],
searchable: true,
filterable: true,
filters: [],
pageSize: 10,
showPagination: true,
sortable: true,
pageSizeOptions: [5, 10, 20, 50, 100],
loading: false,
multiSelect: false,
actions: [],
tableId: undefined,
stickyHeader: false,
};

export default ReusableTable;
17 changes: 17 additions & 0 deletions src/vertex/components/shared/toast/ReusableToast.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { render } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { Toaster, toast } from "./ReusableToast";
import ReusableToast from "./ReusableToast";

describe("ReusableToast", () => {
it("can render the Toaster component", () => {
const { container } = render(<Toaster />);
expect(container).toBeInTheDocument();
});

it("exports toast methods", () => {
expect(typeof toast.success).toBe("function");
expect(typeof toast.error).toBe("function");
expect(typeof ReusableToast).toBe("function");
});
});
27 changes: 27 additions & 0 deletions src/vertex/components/ui/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import ErrorBoundary from "./ErrorBoundary";

const ThrowError = () => {
throw new Error("Test error");
};

describe("ErrorBoundary", () => {
it("catches errors and displays fallback", () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const preventError = (e: ErrorEvent) => e.preventDefault();
window.addEventListener("error", preventError);
try {
render(
<ErrorBoundary>
<ThrowError />
</ErrorBoundary>
);

expect(screen.getByText("Sorry.. there was an error")).toBeInTheDocument();
} finally {
window.removeEventListener("error", preventError);
consoleError.mockRestore();
}
});
});
Loading
Loading