import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react"; import { expect, test, vi, beforeEach, afterEach, describe } from "vitest"; import ProfilePage 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", "manage_own_profile"], }; let mockIsLoading = false; const mockLogout = vi.fn(); const mockHasRole = vi.fn((role: string) => mockUser?.roles.includes(role) ?? false); const mockHasPermission = vi.fn( (permission: string) => mockUser?.permissions.includes(permission) ?? false ); vi.mock("../auth-context", () => ({ useAuth: () => ({ user: mockUser, isLoading: mockIsLoading, logout: mockLogout, hasRole: mockHasRole, hasPermission: mockHasPermission, }), Permission: { VIEW_COUNTER: "view_counter", INCREMENT_COUNTER: "increment_counter", USE_SUM: "use_sum", VIEW_AUDIT: "view_audit", MANAGE_OWN_PROFILE: "manage_own_profile", MANAGE_INVITES: "manage_invites", VIEW_OWN_INVITES: "view_own_invites", BOOK_APPOINTMENT: "book_appointment", VIEW_OWN_APPOINTMENTS: "view_own_appointments", CANCEL_OWN_APPOINTMENT: "cancel_own_appointment", MANAGE_AVAILABILITY: "manage_availability", VIEW_ALL_APPOINTMENTS: "view_all_appointments", CANCEL_ANY_APPOINTMENT: "cancel_any_appointment", }, })); // Mock profile data const mockProfileData = { contact_email: "contact@example.com", telegram: "@testuser", signal: "signal.42", nostr_npub: "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzqujme", }; beforeEach(() => { vi.clearAllMocks(); // Reset to authenticated regular user mockUser = { id: 1, email: "test@example.com", roles: ["regular"], permissions: ["view_counter", "increment_counter", "use_sum", "manage_own_profile"], }; mockIsLoading = false; mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false); mockHasPermission.mockImplementation( (permission: string) => mockUser?.permissions.includes(permission) ?? false ); }); afterEach(() => { cleanup(); }); describe("ProfilePage - Display", () => { test("renders loading state initially", () => { mockIsLoading = true; vi.spyOn(global, "fetch").mockImplementation(() => new Promise(() => {})); render(); expect(screen.getByText("Loading...")).toBeDefined(); }); test("renders profile page title", async () => { vi.spyOn(global, "fetch").mockResolvedValue({ ok: true, json: () => Promise.resolve(mockProfileData), } as Response); render(); await waitFor(() => { expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); }); }); test("displays login email as read-only", async () => { vi.spyOn(global, "fetch").mockResolvedValue({ ok: true, json: () => Promise.resolve(mockProfileData), } as Response); render(); await waitFor(() => { const loginEmailInput = screen.getByDisplayValue("test@example.com"); expect(loginEmailInput).toBeDefined(); expect(loginEmailInput).toHaveProperty("disabled", true); }); }); test("shows read-only badge for login email", async () => { vi.spyOn(global, "fetch").mockResolvedValue({ ok: true, json: () => Promise.resolve(mockProfileData), } as Response); render(); await waitFor(() => { expect(screen.getByText("Read only")).toBeDefined(); }); }); test("shows hint about login email", async () => { vi.spyOn(global, "fetch").mockResolvedValue({ ok: true, json: () => Promise.resolve(mockProfileData), } as Response); render(); await waitFor(() => { expect(screen.getByText(/cannot be changed/i)).toBeDefined(); }); }); test("displays contact details section hint", async () => { vi.spyOn(global, "fetch").mockResolvedValue({ ok: true, json: () => Promise.resolve(mockProfileData), } as Response); render(); await waitFor(() => { expect(screen.getByText(/communication purposes only/i)).toBeDefined(); }); }); test("displays fetched profile data", async () => { vi.spyOn(global, "fetch").mockResolvedValue({ ok: true, json: () => Promise.resolve(mockProfileData), } as Response); render(); await waitFor(() => { expect(screen.getByDisplayValue("contact@example.com")).toBeDefined(); expect(screen.getByDisplayValue("@testuser")).toBeDefined(); expect(screen.getByDisplayValue("signal.42")).toBeDefined(); expect(screen.getByDisplayValue(mockProfileData.nostr_npub)).toBeDefined(); }); }); test("fetches profile with credentials", async () => { const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ ok: true, json: () => Promise.resolve(mockProfileData), } as Response); render(); await waitFor(() => { expect(fetchSpy).toHaveBeenCalledWith( "http://localhost:8000/api/profile", expect.objectContaining({ credentials: "include", }) ); }); }); test("displays empty fields when profile has null values", async () => { vi.spyOn(global, "fetch").mockResolvedValue({ ok: true, json: () => Promise.resolve({ contact_email: null, telegram: null, signal: null, nostr_npub: null, }), } as Response); render(); await waitFor(() => { // Check that inputs exist with empty values (placeholders shown) const telegramInput = document.getElementById("telegram") as HTMLInputElement; expect(telegramInput.value).toBe(""); }); }); }); describe("ProfilePage - Navigation", () => { test("shows nav links for regular user", async () => { vi.spyOn(global, "fetch").mockResolvedValue({ ok: true, json: () => Promise.resolve(mockProfileData), } as Response); render(); await waitFor(() => { expect(screen.getByText("Counter")).toBeDefined(); expect(screen.getByText("Sum")).toBeDefined(); }); }); test("highlights My Profile in nav", async () => { vi.spyOn(global, "fetch").mockResolvedValue({ ok: true, json: () => Promise.resolve(mockProfileData), } as Response); render(); await waitFor(() => { // My Profile should be visible (as current page indicator) const navItems = screen.getAllByText("My Profile"); expect(navItems.length).toBeGreaterThan(0); }); }); }); describe("ProfilePage - Access Control", () => { test("redirects to login when not authenticated", async () => { mockUser = null; render(); await waitFor(() => { expect(mockPush).toHaveBeenCalledWith("/login"); }); }); test("redirects admin to audit page", async () => { mockUser = { id: 1, email: "admin@example.com", roles: ["admin"], permissions: ["view_audit"], }; render(); await waitFor(() => { expect(mockPush).toHaveBeenCalledWith("/audit"); }); }); test("does not fetch profile for admin user", async () => { mockUser = { id: 1, email: "admin@example.com", roles: ["admin"], permissions: ["view_audit"], }; const fetchSpy = vi.spyOn(global, "fetch"); render(); // Give it a moment to potentially fetch await new Promise((r) => setTimeout(r, 100)); expect(fetchSpy).not.toHaveBeenCalled(); }); }); describe("ProfilePage - Loading State", () => { test("does not redirect while loading", () => { mockIsLoading = true; mockUser = null; render(); expect(mockPush).not.toHaveBeenCalled(); }); test("shows loading indicator while loading", () => { mockIsLoading = true; render(); expect(screen.getByText("Loading...")).toBeDefined(); }); }); describe("ProfilePage - Form Behavior", () => { test("submit button is disabled when no changes", async () => { vi.spyOn(global, "fetch").mockResolvedValue({ ok: true, json: () => Promise.resolve(mockProfileData), } as Response); render(); await waitFor(() => { const submitButton = screen.getByRole("button", { name: /save changes/i }); expect(submitButton).toHaveProperty("disabled", true); }); }); test("submit button is enabled after field changes", async () => { vi.spyOn(global, "fetch").mockResolvedValue({ ok: true, json: () => Promise.resolve(mockProfileData), } as Response); render(); await waitFor(() => { expect(screen.getByDisplayValue("@testuser")).toBeDefined(); }); const telegramInput = screen.getByDisplayValue("@testuser"); fireEvent.change(telegramInput, { target: { value: "@newhandle" } }); await waitFor(() => { const submitButton = screen.getByRole("button", { name: /save changes/i }); expect(submitButton).toHaveProperty("disabled", false); }); }); test("auto-prepends @ to telegram when user starts with letter", async () => { vi.spyOn(global, "fetch").mockResolvedValue({ ok: true, json: () => Promise.resolve({ contact_email: null, telegram: null, signal: null, nostr_npub: null, }), } as Response); render(); await waitFor(() => { expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); }); const telegramInput = document.getElementById("telegram") as HTMLInputElement; // Type a letter without @ - should auto-prepend @ fireEvent.change(telegramInput, { target: { value: "myhandle" } }); expect(telegramInput.value).toBe("@myhandle"); }); test("does not auto-prepend @ if user types @ first", async () => { vi.spyOn(global, "fetch").mockResolvedValue({ ok: true, json: () => Promise.resolve({ contact_email: null, telegram: null, signal: null, nostr_npub: null, }), } as Response); render(); await waitFor(() => { expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); }); const telegramInput = document.getElementById("telegram") as HTMLInputElement; // User types @ first - no auto-prepend fireEvent.change(telegramInput, { target: { value: "@myhandle" } }); expect(telegramInput.value).toBe("@myhandle"); }); }); describe("ProfilePage - Form Submission", () => { test("shows success toast after successful save", async () => { const fetchSpy = vi .spyOn(global, "fetch") .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ contact_email: null, telegram: null, signal: null, nostr_npub: null, }), } as Response) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ contact_email: "new@example.com", telegram: null, signal: null, nostr_npub: null, }), } as Response); render(); await waitFor(() => { expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); }); // Change email const emailInput = document.getElementById("contact_email") as HTMLInputElement; fireEvent.change(emailInput, { target: { value: "new@example.com" } }); // Submit const submitButton = screen.getByRole("button", { name: /save changes/i }); fireEvent.click(submitButton); await waitFor(() => { expect(screen.getByText(/saved successfully/i)).toBeDefined(); }); // Verify PUT was called expect(fetchSpy).toHaveBeenCalledWith( "http://localhost:8000/api/profile", expect.objectContaining({ method: "PUT", credentials: "include", }) ); }); test("shows inline errors from backend validation", async () => { vi.spyOn(global, "fetch") .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ contact_email: null, telegram: null, signal: null, nostr_npub: null, }), } as Response) .mockResolvedValueOnce({ ok: false, status: 422, json: () => Promise.resolve({ detail: { field_errors: { telegram: "Backend error: invalid handle", }, }, }), } as Response); render(); await waitFor(() => { expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); }); // Enter a value that passes frontend validation but fails backend const telegramInput = document.getElementById("telegram") as HTMLInputElement; fireEvent.change(telegramInput, { target: { value: "@validfrontend" } }); // Submit const submitButton = screen.getByRole("button", { name: /save changes/i }); fireEvent.click(submitButton); await waitFor(() => { expect(screen.getByText(/backend error/i)).toBeDefined(); }); }); test("shows error toast on network failure", async () => { vi.spyOn(global, "fetch") .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ contact_email: null, telegram: null, signal: null, nostr_npub: null, }), } as Response) .mockRejectedValueOnce(new Error("Network error")); render(); await waitFor(() => { expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); }); // Change something const emailInput = document.getElementById("contact_email") as HTMLInputElement; fireEvent.change(emailInput, { target: { value: "new@example.com" } }); // Submit const submitButton = screen.getByRole("button", { name: /save changes/i }); fireEvent.click(submitButton); await waitFor(() => { expect(screen.getByText(/network error/i)).toBeDefined(); }); }); test("submit button shows 'Saving...' while submitting", async () => { let resolveSubmit: (value: Response) => void; const submitPromise = new Promise((resolve) => { resolveSubmit = resolve; }); vi.spyOn(global, "fetch") .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ contact_email: null, telegram: null, signal: null, nostr_npub: null, }), } as Response) .mockReturnValueOnce(submitPromise as Promise); render(); await waitFor(() => { expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); }); // Change something const emailInput = document.getElementById("contact_email") as HTMLInputElement; fireEvent.change(emailInput, { target: { value: "new@example.com" } }); // Submit const submitButton = screen.getByRole("button", { name: /save changes/i }); fireEvent.click(submitButton); await waitFor(() => { expect(screen.getByText("Saving...")).toBeDefined(); }); // Resolve the promise resolveSubmit!({ ok: true, json: () => Promise.resolve({ contact_email: "new@example.com", telegram: null, signal: null, nostr_npub: null, }), } as Response); await waitFor(() => { expect(screen.getByRole("button", { name: /save changes/i })).toBeDefined(); }); }); });