Phase 0.2: Remove frontend deprecated code

- Delete pages: sum, audit, admin/random-jobs
- Delete old homepage (counter) and create redirect page
- Update Header.tsx: remove Counter, Sum, Audit, Random Jobs nav items
- Update auth-context.tsx: remove VIEW_COUNTER, INCREMENT_COUNTER, USE_SUM permissions
- Update profile/page.test.tsx: fix nav link assertions
This commit is contained in:
counterweight 2025-12-22 18:09:09 +01:00
parent 5bad1e7e17
commit a5c1eccb4b
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
9 changed files with 27 additions and 1260 deletions

View file

@ -1,229 +0,0 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Permission } from "../../auth-context";
import { api } from "../../api";
import { sharedStyles } from "../../styles/shared";
import { Header } from "../../components/Header";
import { useRequireAuth } from "../../hooks/useRequireAuth";
import { components } from "../../generated/api";
type RandomNumberOutcome = components["schemas"]["RandomNumberOutcomeResponse"];
export default function AdminRandomJobsPage() {
const [outcomes, setOutcomes] = useState<RandomNumberOutcome[]>([]);
const [error, setError] = useState<string | null>(null);
const [isLoadingData, setIsLoadingData] = useState(true);
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.VIEW_AUDIT,
fallbackRedirect: "/",
});
const fetchOutcomes = useCallback(async () => {
setError(null);
try {
const data = await api.get<RandomNumberOutcome[]>("/api/audit/random-jobs");
setOutcomes(data);
} catch (err) {
setOutcomes([]);
setError(err instanceof Error ? err.message : "Failed to load outcomes");
} finally {
setIsLoadingData(false);
}
}, []);
useEffect(() => {
if (user && isAuthorized) {
fetchOutcomes();
}
}, [user, isAuthorized, fetchOutcomes]);
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString();
};
if (isLoading) {
return (
<main style={styles.main}>
<div style={styles.loader}>Loading...</div>
</main>
);
}
if (!user || !isAuthorized) {
return null;
}
return (
<main style={styles.main}>
<Header currentPage="admin-random-jobs" />
<div style={styles.content}>
<div style={styles.tableCard}>
<div style={styles.tableHeader}>
<h2 style={styles.tableTitle}>Random Number Job Outcomes</h2>
<span style={styles.totalCount}>{outcomes.length} outcomes</span>
</div>
<div style={styles.tableWrapper}>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>ID</th>
<th style={styles.th}>Job ID</th>
<th style={styles.th}>Triggered By</th>
<th style={styles.th}>Value</th>
<th style={styles.th}>Duration</th>
<th style={styles.th}>Status</th>
<th style={styles.th}>Created At</th>
</tr>
</thead>
<tbody>
{error && (
<tr>
<td colSpan={7} style={styles.errorRow}>
{error}
</td>
</tr>
)}
{!error && isLoadingData && (
<tr>
<td colSpan={7} style={styles.emptyRow}>
Loading...
</td>
</tr>
)}
{!error && !isLoadingData && outcomes.length === 0 && (
<tr>
<td colSpan={7} style={styles.emptyRow}>
No job outcomes yet
</td>
</tr>
)}
{!error &&
!isLoadingData &&
outcomes.map((outcome) => (
<tr key={outcome.id} style={styles.tr}>
<td style={styles.tdNum}>{outcome.id}</td>
<td style={styles.tdNum}>{outcome.job_id}</td>
<td style={styles.td}>{outcome.triggered_by_email}</td>
<td style={styles.tdValue}>{outcome.value}</td>
<td style={styles.tdNum}>{outcome.duration_ms}ms</td>
<td style={styles.td}>
<span style={styles.statusBadge}>{outcome.status}</span>
</td>
<td style={styles.tdDate}>{formatDate(outcome.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</main>
);
}
const pageStyles: Record<string, React.CSSProperties> = {
content: {
flex: 1,
padding: "2rem",
overflowY: "auto",
},
tableCard: {
background: "rgba(255, 255, 255, 0.03)",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "20px",
padding: "1.5rem",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
maxWidth: "1200px",
margin: "0 auto",
},
tableHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
},
tableTitle: {
fontFamily: "'Instrument Serif', Georgia, serif",
fontSize: "1.5rem",
fontWeight: 400,
color: "#fff",
margin: 0,
},
totalCount: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.4)",
},
tableWrapper: {
overflowX: "auto",
},
table: {
width: "100%",
borderCollapse: "collapse",
fontFamily: "'DM Sans', system-ui, sans-serif",
},
th: {
textAlign: "left",
padding: "0.75rem 1rem",
fontSize: "0.75rem",
fontWeight: 600,
color: "rgba(255, 255, 255, 0.4)",
textTransform: "uppercase",
letterSpacing: "0.05em",
borderBottom: "1px solid rgba(255, 255, 255, 0.08)",
},
tr: {
borderBottom: "1px solid rgba(255, 255, 255, 0.04)",
},
td: {
padding: "0.875rem 1rem",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.7)",
},
tdNum: {
padding: "0.875rem 1rem",
fontSize: "0.875rem",
color: "rgba(255, 255, 255, 0.9)",
fontFamily: "'DM Mono', monospace",
},
tdValue: {
padding: "0.875rem 1rem",
fontSize: "1rem",
color: "#a78bfa",
fontWeight: 600,
fontFamily: "'DM Mono', monospace",
},
tdDate: {
padding: "0.875rem 1rem",
fontSize: "0.75rem",
color: "rgba(255, 255, 255, 0.4)",
},
statusBadge: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "0.7rem",
fontWeight: 500,
padding: "0.25rem 0.5rem",
borderRadius: "4px",
textTransform: "uppercase",
background: "rgba(34, 197, 94, 0.2)",
color: "rgba(34, 197, 94, 0.9)",
},
emptyRow: {
padding: "2rem 1rem",
textAlign: "center",
color: "rgba(255, 255, 255, 0.3)",
fontSize: "0.875rem",
},
errorRow: {
padding: "2rem 1rem",
textAlign: "center",
color: "#f87171",
fontSize: "0.875rem",
},
};
const styles = { ...sharedStyles, ...pageStyles };

View file

@ -1,176 +0,0 @@
import { render, screen, waitFor, cleanup } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import AuditPage from "./page";
// Mock next/navigation
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
}));
// Default mock values for admin user
let mockUser: { id: number; email: string; roles: string[]; permissions: string[] } | null = {
id: 1,
email: "admin@example.com",
roles: ["admin"],
permissions: ["view_audit"],
};
let mockIsLoading = false;
const mockLogout = vi.fn();
const mockHasPermission = vi.fn(
(permission: string) => mockUser?.permissions.includes(permission) ?? false
);
const mockHasRole = vi.fn((role: string) => mockUser?.roles.includes(role) ?? false);
vi.mock("../auth-context", () => ({
useAuth: () => ({
user: mockUser,
isLoading: mockIsLoading,
logout: mockLogout,
hasPermission: mockHasPermission,
hasRole: mockHasRole,
}),
Permission: {
VIEW_COUNTER: "view_counter",
INCREMENT_COUNTER: "increment_counter",
USE_SUM: "use_sum",
VIEW_AUDIT: "view_audit",
},
}));
// Mock fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
beforeEach(() => {
vi.clearAllMocks();
mockUser = {
id: 1,
email: "admin@example.com",
roles: ["admin"],
permissions: ["view_audit"],
};
mockIsLoading = false;
mockHasPermission.mockImplementation(
(permission: string) => mockUser?.permissions.includes(permission) ?? false
);
mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false);
// Default: successful empty response
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ records: [], total: 0, page: 1, per_page: 10, total_pages: 1 }),
});
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("AuditPage", () => {
it("shows loading state", () => {
mockIsLoading = true;
render(<AuditPage />);
expect(screen.getByText("Loading...")).toBeTruthy();
});
it("redirects to login when not authenticated", async () => {
mockUser = null;
render(<AuditPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/login");
});
});
it("redirects to home when user lacks audit permission", async () => {
mockUser = {
id: 1,
email: "user@example.com",
roles: ["regular"],
permissions: ["view_counter"],
};
mockHasPermission.mockReturnValue(false);
render(<AuditPage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/");
});
});
it("displays error message when API fetch fails", async () => {
mockFetch.mockRejectedValue(new Error("Network error"));
render(<AuditPage />);
await waitFor(() => {
// Both tables should show errors since both calls fail
const errors = screen.getAllByText("Network error");
expect(errors.length).toBeGreaterThan(0);
});
});
it("displays error when API returns non-ok response", async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ detail: "Internal server error" }),
});
render(<AuditPage />);
await waitFor(() => {
const errors = screen.getAllByText("Request failed: 500");
expect(errors.length).toBeGreaterThan(0);
});
});
it("displays records when fetch succeeds", async () => {
const counterResponse = {
records: [
{
id: 1,
user_email: "recorduser@example.com",
value_before: 0,
value_after: 1,
created_at: "2024-01-01T00:00:00Z",
},
],
total: 1,
page: 1,
per_page: 10,
total_pages: 1,
};
const sumResponse = {
records: [],
total: 0,
page: 1,
per_page: 10,
total_pages: 1,
};
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(counterResponse),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(sumResponse),
});
render(<AuditPage />);
await waitFor(() => {
expect(screen.getByText("recorduser@example.com")).toBeTruthy();
});
});
it("shows table headers", async () => {
render(<AuditPage />);
await waitFor(() => {
// Check for counter table headers
expect(screen.getByText("Counter Activity")).toBeTruthy();
expect(screen.getByText("Sum Activity")).toBeTruthy();
});
});
});

View file

@ -1,242 +0,0 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Permission } from "../auth-context";
import { api } from "../api";
import { layoutStyles, cardStyles, tableStyles, paginationStyles } from "../styles/shared";
import { Header } from "../components/Header";
import { useRequireAuth } from "../hooks/useRequireAuth";
import { components } from "../generated/api";
// Use generated types from OpenAPI schema
type _CounterRecord = components["schemas"]["CounterRecordResponse"];
type _SumRecord = components["schemas"]["SumRecordResponse"];
type PaginatedCounterRecords = components["schemas"]["PaginatedResponse_CounterRecordResponse_"];
type PaginatedSumRecords = components["schemas"]["PaginatedResponse_SumRecordResponse_"];
export default function AuditPage() {
const [counterData, setCounterData] = useState<PaginatedCounterRecords | null>(null);
const [sumData, setSumData] = useState<PaginatedSumRecords | null>(null);
const [counterError, setCounterError] = useState<string | null>(null);
const [sumError, setSumError] = useState<string | null>(null);
const [counterPage, setCounterPage] = useState(1);
const [sumPage, setSumPage] = useState(1);
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.VIEW_AUDIT,
fallbackRedirect: "/",
});
const fetchCounterRecords = useCallback(async (page: number) => {
setCounterError(null);
try {
const data = await api.get<PaginatedCounterRecords>(
`/api/audit/counter?page=${page}&per_page=10`
);
setCounterData(data);
} catch (err) {
setCounterData(null);
setCounterError(err instanceof Error ? err.message : "Failed to load counter records");
}
}, []);
const fetchSumRecords = useCallback(async (page: number) => {
setSumError(null);
try {
const data = await api.get<PaginatedSumRecords>(`/api/audit/sum?page=${page}&per_page=10`);
setSumData(data);
} catch (err) {
setSumData(null);
setSumError(err instanceof Error ? err.message : "Failed to load sum records");
}
}, []);
useEffect(() => {
if (user && isAuthorized) {
fetchCounterRecords(counterPage);
}
}, [user, counterPage, isAuthorized, fetchCounterRecords]);
useEffect(() => {
if (user && isAuthorized) {
fetchSumRecords(sumPage);
}
}, [user, sumPage, isAuthorized, fetchSumRecords]);
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString();
};
if (isLoading) {
return (
<main style={layoutStyles.main}>
<div style={layoutStyles.loader}>Loading...</div>
</main>
);
}
if (!user || !isAuthorized) {
return null;
}
return (
<main style={layoutStyles.main}>
<Header currentPage="audit" />
<div style={layoutStyles.contentScrollable}>
<div style={styles.tablesContainer}>
{/* Counter Records Table */}
<div style={cardStyles.tableCard}>
<div style={tableStyles.tableHeader}>
<h2 style={tableStyles.tableTitle}>Counter Activity</h2>
<span style={tableStyles.totalCount}>{counterData?.total ?? 0} records</span>
</div>
<div style={tableStyles.tableWrapper}>
<table style={tableStyles.table}>
<thead>
<tr>
<th style={tableStyles.th}>User</th>
<th style={tableStyles.th}>Before</th>
<th style={tableStyles.th}>After</th>
<th style={tableStyles.th}>Date</th>
</tr>
</thead>
<tbody>
{counterError && (
<tr>
<td colSpan={4} style={tableStyles.errorRow}>
{counterError}
</td>
</tr>
)}
{!counterError &&
counterData?.records.map((record) => (
<tr key={record.id} style={tableStyles.tr}>
<td style={tableStyles.td}>{record.user_email}</td>
<td style={tableStyles.tdNum}>{record.value_before}</td>
<td style={tableStyles.tdNum}>{record.value_after}</td>
<td style={tableStyles.tdDate}>{formatDate(record.created_at)}</td>
</tr>
))}
{!counterError && (!counterData || counterData.records.length === 0) && (
<tr>
<td colSpan={4} style={tableStyles.emptyRow}>
No records yet
</td>
</tr>
)}
</tbody>
</table>
</div>
{counterData && counterData.total_pages > 1 && (
<div style={paginationStyles.pagination}>
<button
onClick={() => setCounterPage((p) => Math.max(1, p - 1))}
disabled={counterPage === 1}
style={paginationStyles.pageBtn}
>
</button>
<span style={paginationStyles.pageInfo}>
{counterPage} / {counterData.total_pages}
</span>
<button
onClick={() => setCounterPage((p) => Math.min(counterData.total_pages, p + 1))}
disabled={counterPage === counterData.total_pages}
style={paginationStyles.pageBtn}
>
</button>
</div>
)}
</div>
{/* Sum Records Table */}
<div style={cardStyles.tableCard}>
<div style={tableStyles.tableHeader}>
<h2 style={tableStyles.tableTitle}>Sum Activity</h2>
<span style={tableStyles.totalCount}>{sumData?.total ?? 0} records</span>
</div>
<div style={tableStyles.tableWrapper}>
<table style={tableStyles.table}>
<thead>
<tr>
<th style={tableStyles.th}>User</th>
<th style={tableStyles.th}>A</th>
<th style={tableStyles.th}>B</th>
<th style={tableStyles.th}>Result</th>
<th style={tableStyles.th}>Date</th>
</tr>
</thead>
<tbody>
{sumError && (
<tr>
<td colSpan={5} style={tableStyles.errorRow}>
{sumError}
</td>
</tr>
)}
{!sumError &&
sumData?.records.map((record) => (
<tr key={record.id} style={tableStyles.tr}>
<td style={tableStyles.td}>{record.user_email}</td>
<td style={tableStyles.tdNum}>{record.a}</td>
<td style={tableStyles.tdNum}>{record.b}</td>
<td style={styles.tdResult}>{record.result}</td>
<td style={tableStyles.tdDate}>{formatDate(record.created_at)}</td>
</tr>
))}
{!sumError && (!sumData || sumData.records.length === 0) && (
<tr>
<td colSpan={5} style={tableStyles.emptyRow}>
No records yet
</td>
</tr>
)}
</tbody>
</table>
</div>
{sumData && sumData.total_pages > 1 && (
<div style={paginationStyles.pagination}>
<button
onClick={() => setSumPage((p) => Math.max(1, p - 1))}
disabled={sumPage === 1}
style={paginationStyles.pageBtn}
>
</button>
<span style={paginationStyles.pageInfo}>
{sumPage} / {sumData.total_pages}
</span>
<button
onClick={() => setSumPage((p) => Math.min(sumData.total_pages, p + 1))}
disabled={sumPage === sumData.total_pages}
style={paginationStyles.pageBtn}
>
</button>
</div>
)}
</div>
</div>
</div>
</main>
);
}
// Page-specific styles only
const styles: Record<string, React.CSSProperties> = {
tablesContainer: {
display: "flex",
flexDirection: "column",
gap: "2rem",
maxWidth: "1200px",
margin: "0 auto",
},
tdResult: {
padding: "0.875rem 1rem",
fontSize: "0.875rem",
color: "#a78bfa",
fontWeight: 600,
fontFamily: "'DM Sans', monospace",
},
};

View file

@ -12,9 +12,6 @@ export type PermissionType = components["schemas"]["Permission"];
// The type annotation ensures compile-time validation against the generated schema.
// Adding a new permission in the backend will cause a type error here until updated.
export const Permission: Record<string, PermissionType> = {
VIEW_COUNTER: "view_counter",
INCREMENT_COUNTER: "increment_counter",
USE_SUM: "use_sum",
VIEW_AUDIT: "view_audit",
FETCH_PRICE: "fetch_price",
MANAGE_OWN_PROFILE: "manage_own_profile",

View file

@ -8,17 +8,13 @@ import constants from "../../../shared/constants.json";
const { ADMIN, REGULAR } = constants.roles;
type PageId =
| "counter"
| "sum"
| "profile"
| "invites"
| "booking"
| "appointments"
| "audit"
| "admin-invites"
| "admin-availability"
| "admin-appointments"
| "admin-random-jobs"
| "admin-price-history";
interface HeaderProps {
@ -34,8 +30,6 @@ interface NavItem {
}
const REGULAR_NAV_ITEMS: NavItem[] = [
{ id: "counter", label: "Counter", href: "/" },
{ id: "sum", label: "Sum", href: "/sum" },
{ id: "booking", label: "Book", href: "/booking", regularOnly: true },
{ id: "appointments", label: "Appointments", href: "/appointments", regularOnly: true },
{ id: "invites", label: "My Invites", href: "/invites", regularOnly: true },
@ -43,11 +37,9 @@ const REGULAR_NAV_ITEMS: NavItem[] = [
];
const ADMIN_NAV_ITEMS: NavItem[] = [
{ id: "audit", label: "Audit", href: "/audit", adminOnly: true },
{ id: "admin-invites", label: "Invites", href: "/admin/invites", adminOnly: true },
{ id: "admin-availability", label: "Availability", href: "/admin/availability", adminOnly: true },
{ id: "admin-appointments", label: "Appointments", href: "/admin/appointments", adminOnly: true },
{ id: "admin-random-jobs", label: "Random Jobs", href: "/admin/random-jobs", adminOnly: true },
{ id: "admin-price-history", label: "Prices", href: "/admin/price-history", adminOnly: true },
];

View file

@ -1,263 +0,0 @@
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { expect, test, vi, beforeEach, afterEach, describe } from "vitest";
import Home from "./page";
// Mock next/navigation
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: mockPush,
}),
}));
// Default mock values
let mockUser: { id: number; email: string; roles: string[]; permissions: string[] } | null = {
id: 1,
email: "test@example.com",
roles: ["regular"],
permissions: ["view_counter", "increment_counter", "use_sum"],
};
let mockIsLoading = false;
const mockLogout = vi.fn();
const mockHasPermission = vi.fn(
(permission: string) => mockUser?.permissions.includes(permission) ?? false
);
const mockHasRole = vi.fn((role: string) => mockUser?.roles.includes(role) ?? false);
vi.mock("./auth-context", () => ({
useAuth: () => ({
user: mockUser,
isLoading: mockIsLoading,
logout: mockLogout,
hasPermission: mockHasPermission,
hasRole: mockHasRole,
}),
Permission: {
VIEW_COUNTER: "view_counter",
INCREMENT_COUNTER: "increment_counter",
USE_SUM: "use_sum",
VIEW_AUDIT: "view_audit",
},
}));
beforeEach(() => {
vi.clearAllMocks();
// Reset to authenticated state
mockUser = {
id: 1,
email: "test@example.com",
roles: ["regular"],
permissions: ["view_counter", "increment_counter", "use_sum"],
};
mockIsLoading = false;
mockHasPermission.mockImplementation(
(permission: string) => mockUser?.permissions.includes(permission) ?? false
);
mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false);
});
afterEach(() => {
cleanup();
});
describe("Home - Authenticated", () => {
test("renders loading state when isLoading is true", () => {
mockIsLoading = true;
vi.spyOn(global, "fetch").mockImplementation(() => new Promise(() => {}));
render(<Home />);
expect(screen.getByText("Loading...")).toBeDefined();
});
test("renders user email in header", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: 42 }),
} as Response);
render(<Home />);
expect(screen.getByText("test@example.com")).toBeDefined();
});
test("renders sign out button", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: 42 }),
} as Response);
render(<Home />);
expect(screen.getByText("Sign out")).toBeDefined();
});
test("clicking sign out calls logout and redirects", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: 42 }),
} as Response);
render(<Home />);
fireEvent.click(screen.getByText("Sign out"));
await waitFor(() => {
expect(mockLogout).toHaveBeenCalled();
expect(mockPush).toHaveBeenCalledWith("/login");
});
});
test("renders counter value after fetch", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: 42 }),
} as Response);
render(<Home />);
await waitFor(() => {
expect(screen.getByText("42")).toBeDefined();
});
});
test("fetches counter with credentials", async () => {
const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: 0 }),
} as Response);
render(<Home />);
await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledWith(
"http://localhost:8000/api/counter",
expect.objectContaining({
credentials: "include",
})
);
});
});
test("renders increment button", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: 0 }),
} as Response);
render(<Home />);
expect(screen.getByText("Increment")).toBeDefined();
});
test("clicking increment button calls API with credentials", async () => {
const fetchSpy = vi
.spyOn(global, "fetch")
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: 0 }) } as Response)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: 1 }) } as Response);
render(<Home />);
await waitFor(() => expect(screen.getByText("0")).toBeDefined());
fireEvent.click(screen.getByText("Increment"));
await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledWith(
"http://localhost:8000/api/counter/increment",
expect.objectContaining({
method: "POST",
credentials: "include",
})
);
});
});
test("clicking increment updates displayed count", async () => {
vi.spyOn(global, "fetch")
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: 0 }) } as Response)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: 1 }) } as Response);
render(<Home />);
await waitFor(() => expect(screen.getByText("0")).toBeDefined());
fireEvent.click(screen.getByText("Increment"));
await waitFor(() => expect(screen.getByText("1")).toBeDefined());
});
});
describe("Home - Unauthenticated", () => {
test("redirects to login when not authenticated", async () => {
mockUser = null;
render(<Home />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/login");
});
});
test("returns null when not authenticated", () => {
mockUser = null;
const { container } = render(<Home />);
// Should render nothing (just redirects)
expect(container.querySelector("main")).toBeNull();
});
test("does not fetch counter when no user", () => {
mockUser = null;
const fetchSpy = vi.spyOn(global, "fetch");
render(<Home />);
expect(fetchSpy).not.toHaveBeenCalled();
});
});
describe("Home - Loading State", () => {
test("does not redirect while loading", () => {
mockIsLoading = true;
mockUser = null;
render(<Home />);
expect(mockPush).not.toHaveBeenCalled();
});
test("shows loading indicator while loading", () => {
mockIsLoading = true;
render(<Home />);
expect(screen.getByText("Loading...")).toBeDefined();
});
});
describe("Home - Navigation", () => {
test("shows My Profile link for regular user", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
json: () => Promise.resolve({ value: 0 }),
} as Response);
render(<Home />);
await waitFor(() => {
expect(screen.getByText("My Profile")).toBeDefined();
});
});
test("does not show My Profile link for admin user", async () => {
mockUser = {
id: 1,
email: "admin@example.com",
roles: ["admin"],
permissions: ["view_counter", "view_audit"],
};
vi.spyOn(global, "fetch").mockResolvedValue({
json: () => Promise.resolve({ value: 0 }),
} as Response);
render(<Home />);
// Wait for render - admin sees admin nav (Audit, Invites) not regular nav
await waitFor(() => {
expect(screen.getByText("Audit")).toBeDefined();
});
expect(screen.queryByText("My Profile")).toBeNull();
});
});

View file

@ -1,103 +1,39 @@
"use client";
import { useEffect, useState } from "react";
import { Permission } from "./auth-context";
import { api } from "./api";
import { layoutStyles, cardStyles, buttonStyles } from "./styles/shared";
import { Header } from "./components/Header";
import { useRequireAuth } from "./hooks/useRequireAuth";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "./auth-context";
import { layoutStyles } from "./styles/shared";
import constants from "../../shared/constants.json";
const { ADMIN, REGULAR } = constants.roles;
export default function Home() {
const [count, setCount] = useState<number | null>(null);
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.VIEW_COUNTER,
});
const { user, isLoading, hasRole } = useAuth();
const router = useRouter();
useEffect(() => {
if (user && isAuthorized) {
api
.get<{ value: number }>("/api/counter")
.then((data) => setCount(data.value))
.catch(() => setCount(null));
if (isLoading) return;
if (!user) {
router.replace("/login");
return;
}
}, [user, isAuthorized]);
const increment = async () => {
const data = await api.post<{ value: number }>("/api/counter/increment");
setCount(data.value);
};
if (isLoading) {
return (
<main style={layoutStyles.main}>
<div style={layoutStyles.loader}>Loading...</div>
</main>
);
}
if (!user || !isAuthorized) {
return null;
}
// Redirect based on role
if (hasRole(ADMIN)) {
router.replace("/admin/appointments");
} else if (hasRole(REGULAR)) {
router.replace("/booking");
} else {
// User with no roles - redirect to login
router.replace("/login");
}
}, [user, isLoading, hasRole, router]);
return (
<main style={layoutStyles.main}>
<Header currentPage="counter" />
<div style={layoutStyles.contentCentered}>
<div style={styles.counterCard}>
<span style={styles.counterLabel}>Current Count</span>
<h1 style={styles.counter}>{count === null ? "..." : count}</h1>
<button onClick={increment} style={styles.incrementBtn}>
<span style={styles.plusIcon}>+</span>
Increment
</button>
</div>
</div>
<div style={layoutStyles.loader}>Redirecting...</div>
</main>
);
}
// Page-specific styles only - truly unique to this page
const styles: Record<string, React.CSSProperties> = {
counterCard: {
...cardStyles.card,
borderRadius: "32px",
padding: "4rem 5rem",
textAlign: "center",
},
counterLabel: {
fontFamily: "'DM Sans', system-ui, sans-serif",
display: "block",
color: "rgba(255, 255, 255, 0.4)",
fontSize: "0.875rem",
textTransform: "uppercase",
letterSpacing: "0.1em",
marginBottom: "1rem",
},
counter: {
fontFamily: "'Instrument Serif', Georgia, serif",
fontSize: "8rem",
fontWeight: 400,
color: "#fff",
margin: 0,
lineHeight: 1,
background: "linear-gradient(135deg, #fff 0%, #a78bfa 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
backgroundClip: "text",
},
incrementBtn: {
...buttonStyles.primaryButton,
marginTop: "2.5rem",
padding: "1rem 2.5rem",
fontSize: "1.125rem",
borderRadius: "16px",
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
},
plusIcon: {
fontSize: "1.5rem",
fontWeight: 400,
},
};

View file

@ -211,8 +211,8 @@ describe("ProfilePage - Navigation", () => {
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByText("Counter")).toBeDefined();
expect(screen.getByText("Sum")).toBeDefined();
expect(screen.getByText("Book")).toBeDefined();
expect(screen.getByText("Appointments")).toBeDefined();
});
});

View file

@ -1,248 +0,0 @@
"use client";
import { useState } from "react";
import { Permission } from "../auth-context";
import { api } from "../api";
import { sharedStyles } from "../styles/shared";
import { Header } from "../components/Header";
import { useRequireAuth } from "../hooks/useRequireAuth";
export default function SumPage() {
const [a, setA] = useState("");
const [b, setB] = useState("");
const [result, setResult] = useState<number | null>(null);
const [showResult, setShowResult] = useState(false);
const [error, setError] = useState<string | null>(null);
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.USE_SUM,
});
const handleSum = async () => {
const numA = parseFloat(a) || 0;
const numB = parseFloat(b) || 0;
setError(null);
try {
const data = await api.post<{ result: number }>("/api/sum", { a: numA, b: numB });
setResult(data.result);
setShowResult(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Calculation failed");
}
};
const handleReset = () => {
setA("");
setB("");
setResult(null);
setShowResult(false);
setError(null);
};
if (isLoading) {
return (
<main style={styles.main}>
<div style={styles.loader}>Loading...</div>
</main>
);
}
if (!user || !isAuthorized) {
return null;
}
return (
<main style={styles.main}>
<Header currentPage="sum" />
<div style={styles.content}>
<div style={styles.card}>
<span style={styles.label}>Sum Calculator</span>
{!showResult ? (
<div style={styles.inputSection}>
<div style={styles.inputRow}>
<div style={styles.inputWrapper}>
<input
type="number"
value={a}
onChange={(e) => setA(e.target.value)}
placeholder="0"
style={styles.input}
aria-label="First number"
/>
</div>
<span style={styles.operator}>+</span>
<div style={styles.inputWrapper}>
<input
type="number"
value={b}
onChange={(e) => setB(e.target.value)}
placeholder="0"
style={styles.input}
aria-label="Second number"
/>
</div>
</div>
<button onClick={handleSum} style={styles.sumBtn} disabled={a === "" && b === ""}>
<span style={styles.equalsIcon}>=</span>
Calculate
</button>
{error && <div style={styles.error}>{error}</div>}
</div>
) : (
<div style={styles.resultSection}>
<div style={styles.equation}>
<span style={styles.equationNum}>{parseFloat(a) || 0}</span>
<span style={styles.equationOp}>+</span>
<span style={styles.equationNum}>{parseFloat(b) || 0}</span>
<span style={styles.equationOp}>=</span>
</div>
<h1 style={styles.result}>{result}</h1>
<button onClick={handleReset} style={styles.resetBtn}>
<span style={styles.resetIcon}></span>
Try Again
</button>
</div>
)}
</div>
</div>
</main>
);
}
const pageStyles: Record<string, React.CSSProperties> = {
card: {
background: "rgba(255, 255, 255, 0.03)",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: "32px",
padding: "3rem 4rem",
textAlign: "center",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
minWidth: "400px",
},
label: {
fontFamily: "'DM Sans', system-ui, sans-serif",
display: "block",
color: "rgba(255, 255, 255, 0.4)",
fontSize: "0.875rem",
textTransform: "uppercase",
letterSpacing: "0.1em",
marginBottom: "2rem",
},
inputSection: {
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "2rem",
},
inputRow: {
display: "flex",
alignItems: "center",
gap: "1rem",
},
inputWrapper: {
position: "relative",
},
input: {
fontFamily: "'Instrument Serif', Georgia, serif",
fontSize: "3rem",
fontWeight: 400,
width: "120px",
padding: "0.75rem 1rem",
textAlign: "center",
background: "rgba(255, 255, 255, 0.05)",
border: "2px solid rgba(255, 255, 255, 0.1)",
borderRadius: "16px",
color: "#fff",
outline: "none",
transition: "border-color 0.2s, box-shadow 0.2s",
},
operator: {
fontFamily: "'Instrument Serif', Georgia, serif",
fontSize: "3rem",
color: "#a78bfa",
fontWeight: 400,
},
sumBtn: {
fontFamily: "'DM Sans', system-ui, sans-serif",
padding: "1rem 2.5rem",
fontSize: "1.125rem",
fontWeight: 600,
background: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)",
color: "#fff",
border: "none",
borderRadius: "16px",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
transition: "transform 0.2s, box-shadow 0.2s",
boxShadow: "0 4px 14px rgba(99, 102, 241, 0.4)",
},
equalsIcon: {
fontSize: "1.5rem",
fontWeight: 400,
},
resultSection: {
display: "flex",
flexDirection: "column",
alignItems: "center",
},
equation: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "0.5rem",
},
equationNum: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.25rem",
color: "rgba(255, 255, 255, 0.6)",
},
equationOp: {
fontFamily: "'DM Sans', system-ui, sans-serif",
fontSize: "1.25rem",
color: "rgba(139, 92, 246, 0.8)",
},
result: {
fontFamily: "'Instrument Serif', Georgia, serif",
fontSize: "7rem",
fontWeight: 400,
color: "#fff",
margin: 0,
lineHeight: 1,
background: "linear-gradient(135deg, #fff 0%, #a78bfa 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
backgroundClip: "text",
},
resetBtn: {
fontFamily: "'DM Sans', system-ui, sans-serif",
marginTop: "2rem",
padding: "0.875rem 2rem",
fontSize: "1rem",
fontWeight: 500,
background: "rgba(255, 255, 255, 0.05)",
color: "rgba(255, 255, 255, 0.7)",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "12px",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
transition: "all 0.2s",
},
resetIcon: {
fontSize: "1.25rem",
},
error: {
fontFamily: "'DM Sans', system-ui, sans-serif",
color: "#f87171",
fontSize: "0.875rem",
marginTop: "0.5rem",
},
};
const styles = { ...sharedStyles, ...pageStyles };