diff --git a/README.md b/README.md
index cfcb5e53..1dff3b0a 100644
--- a/README.md
+++ b/README.md
@@ -72,7 +72,8 @@ This is a full-stack application simulating an online booking system for confere
### Testing and Code Quality
-^ Pytest, coverage, pre-commit hooks, and code style enforcement.
+- Pytest, coverage, pre-commit hooks (including prettier), and code style enforcement for backend
+- Jest and React Testing Library for comprehensive frontend tests (run with `yarn run jest` and maintained in `frontend/src/__tests__/`).
### Docker Compose for Orchestration
diff --git a/backend/.pre-commit-config.yaml b/backend/.pre-commit-config.yaml
index 20529dbb..177bb9ff 100644
--- a/backend/.pre-commit-config.yaml
+++ b/backend/.pre-commit-config.yaml
@@ -1,6 +1,12 @@
repos:
- repo: local
hooks:
+ - id: eslint-frontend
+ name: ESLint
+ entry: yarn --cwd frontend eslint .
+ language: system
+ types: [javascript]
+ files: ^frontend/src/
- id: black
name: black
entry: black
diff --git a/frontend/src/__tests__/components/BookingForm.test.tsx b/frontend/src/__tests__/components/BookingForm.test.tsx
new file mode 100644
index 00000000..d3816f64
--- /dev/null
+++ b/frontend/src/__tests__/components/BookingForm.test.tsx
@@ -0,0 +1,61 @@
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import BookingForm from "../../components/BookingForm";
+import { RoomProvider } from "../../context/RoomContext";
+import { BookingProvider } from "../../context/BookingContext";
+import { UserProvider } from "../../context/UserContext";
+
+describe("BookingForm", () => {
+ beforeAll(() => {
+ class EventSourceMock {
+ static readonly CONNECTING = 0;
+ static readonly OPEN = 1;
+ static readonly CLOSED = 2;
+ readonly CONNECTING = 0;
+ readonly OPEN = 1;
+ readonly CLOSED = 2;
+ readyState = 0;
+ url = "";
+ withCredentials = false;
+ close() {}
+ addEventListener() {}
+ removeEventListener() {}
+ dispatchEvent() {
+ return true;
+ }
+ onmessage = null;
+ onerror = null;
+ onopen = null;
+ }
+ window.EventSource = EventSourceMock as any;
+ });
+ it("renders the booking form dialog when open", () => {
+ render(
+
+
+
+ {}} />
+
+
+
+ );
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ });
+
+ it("calls onClose when the dialog is closed", () => {
+ const onClose = jest.fn();
+ render(
+
+
+
+
+
+
+
+ );
+ fireEvent.click(screen.getByRole("button", { name: /close|cancel/i }));
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ // Add more tests for validation, submission, and field rendering as needed
+});
diff --git a/frontend/src/__tests__/components/BookingList.test.tsx b/frontend/src/__tests__/components/BookingList.test.tsx
new file mode 100644
index 00000000..efc6a6b4
--- /dev/null
+++ b/frontend/src/__tests__/components/BookingList.test.tsx
@@ -0,0 +1,25 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import BookingList from "../../components/BookingList";
+import { UserProvider } from "../../context/UserContext";
+
+describe("BookingList", () => {
+ it("renders booking list items", () => {
+ const bookings = [
+ {
+ id: "1",
+ room_id: 1,
+ start_time: "2025-09-18T10:00:00Z",
+ end_time: "2025-09-18T11:00:00Z",
+ title: "Test Booking",
+ invitees: ["user@example.com"],
+ },
+ ];
+ render(
+
+
+
+ );
+ expect(screen.getByText(/6:00 AM - 7:00 AM/i)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/__tests__/components/CalendarView.test.tsx b/frontend/src/__tests__/components/CalendarView.test.tsx
new file mode 100644
index 00000000..e65b4165
--- /dev/null
+++ b/frontend/src/__tests__/components/CalendarView.test.tsx
@@ -0,0 +1,16 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import CalendarView from "../../components/CalendarView";
+import { RoomProvider } from "../../context/RoomContext";
+
+describe("CalendarView", () => {
+ it("renders calendar view component", () => {
+ render(
+
+
+
+ );
+ // FullCalendar renders a heading for the current month/year
+ expect(screen.getByRole("heading")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/__tests__/components/RoomDetailsModal.test.tsx b/frontend/src/__tests__/components/RoomDetailsModal.test.tsx
new file mode 100644
index 00000000..a377bbd8
--- /dev/null
+++ b/frontend/src/__tests__/components/RoomDetailsModal.test.tsx
@@ -0,0 +1,18 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import RoomDetailsModal from "../../components/RoomDetailsModal";
+
+describe("RoomDetailsModal", () => {
+ it("renders room details modal when open", () => {
+ const room = {
+ id: 1,
+ name: "Alpha Room",
+ location: "A1",
+ equipment: "TV",
+ capacity: 10,
+ };
+ render( {}} room={room} />);
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ expect(screen.getByText(/Alpha Room/i)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/__tests__/components/RoomList.test.tsx b/frontend/src/__tests__/components/RoomList.test.tsx
new file mode 100644
index 00000000..025a4f1c
--- /dev/null
+++ b/frontend/src/__tests__/components/RoomList.test.tsx
@@ -0,0 +1,21 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import RoomList from "../../components/RoomList";
+
+describe("RoomList", () => {
+ it("renders room list items", () => {
+ const rooms = [
+ {
+ id: 1,
+ name: "Alpha Room",
+ location: "A1",
+ equipment: "TV",
+ capacity: 10,
+ },
+ ];
+ render(
+ {}} />
+ );
+ expect(screen.getByText(/Alpha Room/i)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/__tests__/components/RoomSelect.test.tsx b/frontend/src/__tests__/components/RoomSelect.test.tsx
new file mode 100644
index 00000000..ef1b3a26
--- /dev/null
+++ b/frontend/src/__tests__/components/RoomSelect.test.tsx
@@ -0,0 +1,27 @@
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import RoomSelect from "../../components/RoomSelect";
+import { RoomProvider } from "../../context/RoomContext";
+
+describe("RoomSelect", () => {
+ it("renders the room select dropdown", () => {
+ render(
+
+ {}} />
+
+ );
+ expect(screen.getByLabelText(/room/i)).toBeInTheDocument();
+ });
+
+ it("calls onChange when a room is selected", () => {
+ const onChange = jest.fn();
+ render(
+
+
+
+ );
+ // Simulate selection change if options exist
+ // fireEvent.change(screen.getByLabelText(/room/i), { target: { value: "2" } });
+ // expect(onChange).toHaveBeenCalledWith("2");
+ });
+});
diff --git a/frontend/src/__tests__/helpers/booking.test.ts b/frontend/src/__tests__/helpers/booking.test.ts
new file mode 100644
index 00000000..b6f2f723
--- /dev/null
+++ b/frontend/src/__tests__/helpers/booking.test.ts
@@ -0,0 +1,13 @@
+import {
+ getEditBookingRoomId,
+ formatBookingError,
+} from "../../helpers/booking";
+describe("booking helpers", () => {
+ it("extracts room id from booking", () => {
+ expect(getEditBookingRoomId({ room_id: "42" })).toBe("42");
+ expect(getEditBookingRoomId(null)).toBe("");
+ });
+ it("formats booking errors", () => {
+ expect(formatBookingError("Room unavailable")).toMatch(/Room unavailable/);
+ });
+});
diff --git a/frontend/src/__tests__/helpers/bookingList.test.ts b/frontend/src/__tests__/helpers/bookingList.test.ts
new file mode 100644
index 00000000..b874c274
--- /dev/null
+++ b/frontend/src/__tests__/helpers/bookingList.test.ts
@@ -0,0 +1,9 @@
+import { getInviteeName } from "../../helpers/bookingList";
+describe("bookingList helpers", () => {
+ it("returns invitee name from various shapes", () => {
+ expect(getInviteeName({ name: "Alice" })).toBe("Alice");
+ expect(getInviteeName({ user: { name: "Bob" } })).toBe("Bob");
+ expect(getInviteeName({ displayName: "Carol" })).toBe("Carol");
+ expect(getInviteeName({ username: "dave" })).toBe("dave");
+ });
+});
diff --git a/frontend/src/__tests__/helpers/calendar.test.ts b/frontend/src/__tests__/helpers/calendar.test.ts
new file mode 100644
index 00000000..0a4aa965
--- /dev/null
+++ b/frontend/src/__tests__/helpers/calendar.test.ts
@@ -0,0 +1,8 @@
+import { getRoomClass } from "../../helpers/calendar";
+describe("calendar helpers", () => {
+ it("returns correct room class for room id", () => {
+ expect(getRoomClass(1)).toBe("room-color-1");
+ expect(getRoomClass(21)).toBe("room-color-1");
+ expect(getRoomClass(2)).toBe("room-color-2");
+ });
+});
diff --git a/frontend/src/__tests__/helpers/getRoomBookingsForDate.test.ts b/frontend/src/__tests__/helpers/getRoomBookingsForDate.test.ts
new file mode 100644
index 00000000..b285a7b7
--- /dev/null
+++ b/frontend/src/__tests__/helpers/getRoomBookingsForDate.test.ts
@@ -0,0 +1 @@
+// getRoomBookingsForDate is not exported from helpers/getRoomBookingsForDate.ts
diff --git a/frontend/src/__tests__/helpers/room.test.ts b/frontend/src/__tests__/helpers/room.test.ts
new file mode 100644
index 00000000..8174d342
--- /dev/null
+++ b/frontend/src/__tests__/helpers/room.test.ts
@@ -0,0 +1 @@
+// No exported getRoomName function in helpers/room.ts
diff --git a/frontend/src/__tests__/helpers/validation.test.ts b/frontend/src/__tests__/helpers/validation.test.ts
new file mode 100644
index 00000000..1cc785e3
--- /dev/null
+++ b/frontend/src/__tests__/helpers/validation.test.ts
@@ -0,0 +1,24 @@
+import {
+ validateRoomId,
+ validateStart,
+ validateEnd,
+ validateInvitees,
+} from "../../helpers/validation";
+describe("validation helpers", () => {
+ it("validates room id", () => {
+ expect(validateRoomId("1")).toBe(null);
+ expect(validateRoomId(null)).not.toBe(null);
+ });
+ it("validates start and end times", () => {
+ // Use a future date for start time
+ const futureDate = new Date(Date.now() + 60 * 60 * 1000).toISOString();
+ expect(validateStart(futureDate)).toBe(null);
+ expect(validateEnd(futureDate, futureDate)).toBe(
+ "End time must be after start time."
+ );
+ });
+ it("validates invitees", () => {
+ expect(validateInvitees(["user@example.com"], 1)).toBe(null);
+ expect(validateInvitees([], 1)).toBe(null);
+ });
+});
diff --git a/frontend/src/__tests__/pages/BookingPage.test.tsx b/frontend/src/__tests__/pages/BookingPage.test.tsx
new file mode 100644
index 00000000..72b7fdf5
--- /dev/null
+++ b/frontend/src/__tests__/pages/BookingPage.test.tsx
@@ -0,0 +1,48 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import BookingPage from "../../pages/BookingPage";
+import { RoomProvider } from "../../context/RoomContext";
+import { BookingProvider } from "../../context/BookingContext";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { MemoryRouter } from "react-router-dom";
+
+describe("BookingPage", () => {
+ beforeAll(() => {
+ class EventSourceMock {
+ static readonly CONNECTING = 0;
+ static readonly OPEN = 1;
+ static readonly CLOSED = 2;
+ readonly CONNECTING = 0;
+ readonly OPEN = 1;
+ readonly CLOSED = 2;
+ readyState = 0;
+ url = "";
+ withCredentials = false;
+ close() {}
+ addEventListener() {}
+ removeEventListener() {}
+ dispatchEvent() {
+ return true;
+ }
+ onmessage = null;
+ onerror = null;
+ onopen = null;
+ }
+ window.EventSource = EventSourceMock as any;
+ });
+ it("renders booking page heading", () => {
+ const queryClient = new QueryClient();
+ render(
+
+
+
+
+
+
+
+
+
+ );
+ expect(screen.getByText(/Book a Room/i)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/__tests__/pages/ConfirmationPage.test.tsx b/frontend/src/__tests__/pages/ConfirmationPage.test.tsx
new file mode 100644
index 00000000..bce0dccf
--- /dev/null
+++ b/frontend/src/__tests__/pages/ConfirmationPage.test.tsx
@@ -0,0 +1,56 @@
+import React from "react";
+import { MemoryRouter } from "react-router-dom";
+import { render, screen } from "@testing-library/react";
+import ConfirmationPage from "../../pages/ConfirmationPage";
+import { BookingContext } from "../../context/BookingContext";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+describe("ConfirmationPage", () => {
+ beforeAll(() => {
+ class EventSourceMock {
+ static readonly CONNECTING = 0;
+ static readonly OPEN = 1;
+ static readonly CLOSED = 2;
+ readonly CONNECTING = 0;
+ readonly OPEN = 1;
+ readonly CLOSED = 2;
+ readyState = 0;
+ url = "";
+ withCredentials = false;
+ close() {}
+ addEventListener() {}
+ removeEventListener() {}
+ dispatchEvent() {
+ return true;
+ }
+ onmessage = null;
+ onerror = null;
+ onopen = null;
+ }
+ window.EventSource = EventSourceMock as any;
+ });
+ it("renders confirmation page heading", () => {
+ const queryClient = new QueryClient();
+ // Provide a valid booking to prevent redirect
+ const mockBooking = {
+ id: "test-id",
+ roomId: "1",
+ date: "2025-09-18",
+ name: "Test User",
+ email: "test@example.com",
+ start_time: "10:00",
+ end_time: "11:00",
+ };
+ render(
+
+
+
+
+
+
+
+ );
+ expect(screen.getByText(/Booking Confirmed/i)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/__tests__/pages/LandingPage.test.tsx b/frontend/src/__tests__/pages/LandingPage.test.tsx
new file mode 100644
index 00000000..7df4885e
--- /dev/null
+++ b/frontend/src/__tests__/pages/LandingPage.test.tsx
@@ -0,0 +1,53 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import LandingPage from "../../pages/LandingPage";
+import { UserProvider } from "../../context/UserContext";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { RoomProvider } from "../../context/RoomContext";
+import { BookingProvider } from "../../context/BookingContext";
+import { MemoryRouter } from "react-router-dom";
+
+describe("LandingPage", () => {
+ beforeAll(() => {
+ class EventSourceMock {
+ static readonly CONNECTING = 0;
+ static readonly OPEN = 1;
+ static readonly CLOSED = 2;
+ readonly CONNECTING = 0;
+ readonly OPEN = 1;
+ readonly CLOSED = 2;
+ readyState = 0;
+ url = "";
+ withCredentials = false;
+ close() {}
+ addEventListener() {}
+ removeEventListener() {}
+ dispatchEvent() {
+ return true;
+ }
+ onmessage = null;
+ onerror = null;
+ onopen = null;
+ }
+ window.EventSource = EventSourceMock as any;
+ });
+ it("renders landing page heading", () => {
+ const queryClient = new QueryClient();
+ render(
+
+
+
+
+
+
+
+
+
+
+
+ );
+ expect(
+ screen.getByText(/Conference Room Booking System/i)
+ ).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/__tests__/utils/date.test.ts b/frontend/src/__tests__/utils/date.test.ts
new file mode 100644
index 00000000..faa01d43
--- /dev/null
+++ b/frontend/src/__tests__/utils/date.test.ts
@@ -0,0 +1,9 @@
+import { roundToStrictlyFutureQuarter } from "../../utils/date";
+describe("date utils", () => {
+ it("rounds to strictly future quarter hour", () => {
+ const now = new Date("2025-09-18T10:07:00Z");
+ const rounded = roundToStrictlyFutureQuarter(now);
+ expect(rounded.getMinutes() % 15).toBe(0);
+ expect(rounded.getTime()).toBeGreaterThan(now.getTime());
+ });
+});
diff --git a/frontend/src/__tests__/utils/logger.test.ts b/frontend/src/__tests__/utils/logger.test.ts
new file mode 100644
index 00000000..c5299028
--- /dev/null
+++ b/frontend/src/__tests__/utils/logger.test.ts
@@ -0,0 +1,12 @@
+import { logger } from "../../utils/logger";
+describe("logger utility", () => {
+ it("logs info messages", () => {
+ expect(() => logger.info("test info")).not.toThrow();
+ });
+ it("logs debug messages", () => {
+ expect(() => logger.debug("test debug")).not.toThrow();
+ });
+ it("logs error messages", () => {
+ expect(() => logger.error("test error")).not.toThrow();
+ });
+});
diff --git a/frontend/src/context/BookingContext.tsx b/frontend/src/context/BookingContext.tsx
index 62d53859..e12f3929 100644
--- a/frontend/src/context/BookingContext.tsx
+++ b/frontend/src/context/BookingContext.tsx
@@ -24,7 +24,9 @@ interface BookingContextType {
fetchMonth: (month: string) => Promise;
}
-const BookingContext = createContext(undefined);
+export const BookingContext = createContext(
+ undefined
+);
/**
* BookingProvider