Skip to content
Merged
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
1,226 changes: 1,187 additions & 39 deletions frontend/package-lock.json

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
Expand Down Expand Up @@ -67,6 +68,8 @@
"devDependencies": {
"@eslint/js": "^9.36.0",
"@tailwindcss/postcss": "^4.1.14",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
Expand All @@ -76,9 +79,11 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"jsdom": "^27.0.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "npm:[email protected]"
"vite": "npm:[email protected]",
"vitest": "^4.0.4"
},
"overrides": {
"vite": "npm:[email protected]"
Expand Down
114 changes: 114 additions & 0 deletions frontend/src/__tests__/Browse.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { MemoryRouter } from "react-router-dom";
import Browse from "../pages/Browse";

describe("Browse Component", () => {
beforeEach(() => {
vi.spyOn(window, "alert").mockImplementation(() => {}); // prevent alert popups
});

afterEach(() => {
vi.restoreAllMocks();
});

it("renders the heading and product cards", () => {
render(
<MemoryRouter>
<Browse />
</MemoryRouter>
);

// Header
expect(screen.getByText(/Browse Products/i)).toBeInTheDocument();
expect(
screen.getByText(/Find what you need from fellow students/i)
).toBeInTheDocument();

// Product cards visible
expect(screen.getByText("Desk Lamp")).toBeInTheDocument();
expect(screen.getByText("C++ Programming Book")).toBeInTheDocument();
});

it("filters products by search query", () => {
render(
<MemoryRouter>
<Browse />
</MemoryRouter>
);

const searchInput = screen.getByPlaceholderText(
/Search for textbooks, electronics, notes/i
);

// Type "lamp" in search bar
fireEvent.change(searchInput, { target: { value: "lamp" } });

// Only Desk Lamp should show
expect(screen.getByText("Desk Lamp")).toBeInTheDocument();
expect(screen.queryByText("Laptop Stand")).not.toBeInTheDocument();
});

it("opens and applies filter drawer", () => {
render(
<MemoryRouter>
<Browse />
</MemoryRouter>
);

// Click Filter button
const filterButton = screen.getByRole("button", { name: /Filter/i });
fireEvent.click(filterButton);

// Select Electronics category
const categorySelect = screen.getByLabelText(/Category/i);
fireEvent.change(categorySelect, { target: { value: "electronics" } });

// Apply filters
const applyBtn = screen.getByRole("button", { name: /Apply Filters/i });
fireEvent.click(applyBtn);

expect(window.alert).toHaveBeenCalledWith(
expect.stringContaining("Category: electronics")
);
});

it("clears filters when clicking 'Clear'", () => {
render(
<MemoryRouter>
<Browse />
</MemoryRouter>
);

const filterButton = screen.getByRole("button", { name: /Filter/i });
fireEvent.click(filterButton);

// Change category to "notes"
const categorySelect = screen.getByLabelText(/Category/i);
fireEvent.change(categorySelect, { target: { value: "notes" } });

// Click Clear
const clearButton = screen.getByRole("button", { name: /Clear/i });
fireEvent.click(clearButton);

expect(categorySelect).toHaveValue("");
});

it("triggers alert when 'Add to Cart' is clicked", () => {
render(
<MemoryRouter>
<Browse />
</MemoryRouter>
);

const addToCartButton = screen.getAllByRole("button", {
name: /Add to Cart/i,
})[0];

fireEvent.click(addToCartButton);

expect(window.alert).toHaveBeenCalledWith(
expect.stringContaining("added to cart")
);
});
});
58 changes: 58 additions & 0 deletions frontend/src/__tests__/Navbar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
import Navbar from "@/components/Navbar";

// Helper to render with Router context
const renderNavbar = () => render(
<BrowserRouter>
<Navbar />
</BrowserRouter>
);

describe("Navbar Component", () => {

beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove("dark");
});

it("renders brand name and nav links", () => {
renderNavbar();
expect(screen.getByText("UniLoot")).toBeInTheDocument();
expect(screen.getByText("Home")).toBeInTheDocument();
expect(screen.getByText("Browse")).toBeInTheDocument();
expect(screen.getByText("Sell")).toBeInTheDocument();
});

it("renders Sign In and Sign Up buttons", () => {
renderNavbar();
expect(screen.getByRole("button", { name: /sign in/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /sign up/i })).toBeInTheDocument();
});

it("toggles mobile menu visibility", () => {
renderNavbar();
const menuButton = screen.getByRole("button", { name: "" }); // the menu (hamburger) icon button
fireEvent.click(menuButton);
expect(screen.getByText("Home")).toBeVisible();
fireEvent.click(menuButton);
expect(screen.queryByText("Home")).toBeInTheDocument(); // still in DOM but hidden
});

it("toggles theme between light and dark", () => {
renderNavbar();
const themeButton = screen.getAllByRole("button").find(btn => btn.innerHTML.includes("svg"));
expect(document.documentElement.classList.contains("dark")).toBe(false);
fireEvent.click(themeButton!);
expect(document.documentElement.classList.contains("dark")).toBe(true);
fireEvent.click(themeButton!);
expect(document.documentElement.classList.contains("dark")).toBe(false);
});

it("stores theme preference in localStorage", () => {
renderNavbar();
const themeButton = screen.getAllByRole("button").find(btn => btn.innerHTML.includes("svg"));
fireEvent.click(themeButton!);
expect(localStorage.getItem("theme")).toBe("dark");
});
});
122 changes: 122 additions & 0 deletions frontend/src/__tests__/SignIn.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import SignIn from "../pages/Signin";
import { BrowserRouter } from "react-router-dom";
import { mockLogin } from "../lib/api";

// Mock toast
vi.mock("../hooks/use-toast", () => ({
toast: vi.fn(),
}));

// Mock the API
vi.mock("../lib/api", async () => {
const actual = await vi.importActual("../lib/api");
return {
...actual,
mockLogin: vi.fn(),
};
});

describe("SignIn Component", () => {
const renderWithRouter = () =>
render(
<BrowserRouter>
<SignIn />
</BrowserRouter>
);

beforeEach(() => {
vi.clearAllMocks();
});

test("renders all form fields", () => {
renderWithRouter();

expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /sign in/i })).toBeInTheDocument();
});

test("shows validation errors for empty submission", async () => {
renderWithRouter();

fireEvent.click(screen.getByRole("button", { name: /sign in/i }));

await waitFor(() => {
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
});
});

test("shows error for invalid email", async () => {
renderWithRouter();

fireEvent.change(screen.getByLabelText(/email/i), {
target: { value: "invalidemail" },
});
fireEvent.change(screen.getByLabelText(/password/i), {
target: { value: "password123" },
});

fireEvent.input(screen.getByLabelText(/email/i), {
target: { value: "invalid-email" },
});
fireEvent.input(screen.getByLabelText(/password/i), {
target: { value: "password123" },
});
fireEvent.submit(screen.getByRole("button", { name: /sign in/i }));

// wait for react-hook-form to show validation message
await screen.findByText(/invalid email address/i);

});

test("submits form successfully", async () => {
(mockLogin as vi.Mock).mockResolvedValueOnce({
success: true,
message: "Login successful!",
user: { email: "[email protected]", id: "user-1" },
});

renderWithRouter();

fireEvent.change(screen.getByLabelText(/email/i), {
target: { value: "[email protected]" },
});
fireEvent.change(screen.getByLabelText(/password/i), {
target: { value: "password123" },
});

fireEvent.click(screen.getByRole("button", { name: /sign in/i }));

await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith({
email: "[email protected]",
password: "password123",
});
});
});

test("handles API error gracefully", async () => {
(mockLogin as vi.Mock).mockRejectedValueOnce({
success: false,
message: "Invalid credentials",
});

renderWithRouter();

fireEvent.change(screen.getByLabelText(/email/i), {
target: { value: "[email protected]" },
});
fireEvent.change(screen.getByLabelText(/password/i), {
target: { value: "wrongpass" },
});

fireEvent.click(screen.getByRole("button", { name: /sign in/i }));

await waitFor(() => {
expect(mockLogin).toHaveBeenCalled();
});
});
});
Loading
Loading