import { screen, waitFor, cleanup, fireEvent } from "@testing-library/react"; import { expect, test, vi, beforeEach, afterEach, describe } from "vitest"; import ProfilePage from "./page"; import { renderWithProviders } from "../test-utils"; // 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: [ "create_exchange", "view_own_exchanges", "cancel_own_exchange", "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, }), AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}, Permission: { VIEW_AUDIT: "view_audit", FETCH_PRICE: "fetch_price", MANAGE_OWN_PROFILE: "manage_own_profile", MANAGE_INVITES: "manage_invites", VIEW_OWN_INVITES: "view_own_invites", CREATE_EXCHANGE: "create_exchange", VIEW_OWN_EXCHANGES: "view_own_exchanges", CANCEL_OWN_EXCHANGE: "cancel_own_exchange", MANAGE_AVAILABILITY: "manage_availability", VIEW_ALL_EXCHANGES: "view_all_exchanges", CANCEL_ANY_EXCHANGE: "cancel_any_exchange", COMPLETE_EXCHANGE: "complete_exchange", }, })); // Mock profileApi const mockGetProfile = vi.fn(); const mockUpdateProfile = vi.fn(); vi.mock("../api", async (importOriginal) => { const actual = await importOriginal(); // Create a getter that returns the mock functions return { ...actual, get profileApi() { return { getProfile: mockGetProfile, updateProfile: mockUpdateProfile, }; }, }; }); // 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: [ "create_exchange", "view_own_exchanges", "cancel_own_exchange", "manage_own_profile", ], }; mockIsLoading = false; mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false); mockHasPermission.mockImplementation( (permission: string) => mockUser?.permissions.includes(permission) ?? false ); // Reset API mocks mockGetProfile.mockResolvedValue(mockProfileData); mockUpdateProfile.mockResolvedValue(mockProfileData); }); afterEach(() => { cleanup(); }); describe("ProfilePage - Display", () => { test("renders loading state initially", () => { mockIsLoading = true; mockGetProfile.mockImplementation(() => new Promise(() => {})); renderWithProviders(); expect(screen.getByText("Cargando...")).toBeDefined(); }); test("renders profile page title", async () => { mockGetProfile.mockResolvedValue(mockProfileData); renderWithProviders(); await waitFor(() => { expect(screen.getByRole("heading", { name: "Mi Perfil" })).toBeDefined(); }); }); test("displays login email as read-only", async () => { mockGetProfile.mockResolvedValue(mockProfileData); renderWithProviders(); 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 () => { mockGetProfile.mockResolvedValue(mockProfileData); renderWithProviders(); await waitFor(() => { expect(screen.getByText("Solo lectura")).toBeDefined(); }); }); test("shows hint about login email", async () => { mockGetProfile.mockResolvedValue(mockProfileData); renderWithProviders(); await waitFor(() => { expect(screen.getByText(/no se puede cambiar/i)).toBeDefined(); }); }); test("displays contact details section hint", async () => { mockGetProfile.mockResolvedValue(mockProfileData); renderWithProviders(); await waitFor(() => { expect(screen.getByText(/fines de comunicación/i)).toBeDefined(); }); }); test("displays fetched profile data", async () => { mockGetProfile.mockResolvedValue(mockProfileData); renderWithProviders(); 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 () => { mockGetProfile.mockResolvedValue(mockProfileData); renderWithProviders(); await waitFor(() => { expect(mockGetProfile).toHaveBeenCalled(); }); }); test("displays empty fields when profile has null values", async () => { mockGetProfile.mockResolvedValue({ contact_email: null, telegram: null, signal: null, nostr_npub: null, }); renderWithProviders(); 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 () => { mockGetProfile.mockResolvedValue(mockProfileData); renderWithProviders(); await waitFor(() => { expect(screen.getByText("Exchange")).toBeDefined(); expect(screen.getByText("Mis Operaciones")).toBeDefined(); }); }); test("highlights My Profile in nav", async () => { mockGetProfile.mockResolvedValue(mockProfileData); renderWithProviders(); await waitFor(() => { // Mi Perfil should be visible (as current page indicator) const navItems = screen.getAllByText("Mi Perfil"); expect(navItems.length).toBeGreaterThan(0); }); }); }); describe("ProfilePage - Access Control", () => { test("redirects to login when not authenticated", async () => { mockUser = null; renderWithProviders(); await waitFor(() => { expect(mockPush).toHaveBeenCalledWith("/login"); }); }); test("redirects admin to admin trades page", async () => { mockUser = { id: 1, email: "admin@example.com", roles: ["admin"], permissions: ["view_all_exchanges"], }; renderWithProviders(); await waitFor(() => { expect(mockPush).toHaveBeenCalledWith("/admin/trades"); }); }); test("does not fetch profile for admin user", async () => { mockUser = { id: 1, email: "admin@example.com", roles: ["admin"], permissions: ["view_audit"], }; renderWithProviders(); // Give it a moment to potentially fetch await new Promise((r) => setTimeout(r, 100)); expect(mockGetProfile).not.toHaveBeenCalled(); }); }); describe("ProfilePage - Loading State", () => { test("does not redirect while loading", () => { mockIsLoading = true; mockUser = null; renderWithProviders(); expect(mockPush).not.toHaveBeenCalled(); }); test("shows loading indicator while loading", () => { mockIsLoading = true; renderWithProviders(); expect(screen.getByText("Cargando...")).toBeDefined(); }); }); describe("ProfilePage - Form Behavior", () => { test("submit button is disabled when no changes", async () => { mockGetProfile.mockResolvedValue(mockProfileData); renderWithProviders(); await waitFor(() => { const submitButton = screen.getByRole("button", { name: /guardar cambios/i }); expect(submitButton).toHaveProperty("disabled", true); }); }); test("submit button is enabled after field changes", async () => { mockGetProfile.mockResolvedValue(mockProfileData); renderWithProviders(); 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: /guardar cambios/i }); expect(submitButton).toHaveProperty("disabled", false); }); }); test("auto-prepends @ to telegram when user starts with letter", async () => { mockGetProfile.mockResolvedValue({ contact_email: null, telegram: null, signal: null, nostr_npub: null, }); renderWithProviders(); await waitFor(() => { expect(screen.getByRole("heading", { name: "Mi Perfil" })).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 () => { mockGetProfile.mockResolvedValue({ contact_email: null, telegram: null, signal: null, nostr_npub: null, }); renderWithProviders(); await waitFor(() => { expect(screen.getByRole("heading", { name: "Mi Perfil" })).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 () => { mockGetProfile .mockResolvedValueOnce({ contact_email: null, telegram: null, signal: null, nostr_npub: null, }) .mockResolvedValueOnce({ contact_email: "new@example.com", telegram: null, signal: null, nostr_npub: null, }); mockUpdateProfile.mockResolvedValue({ contact_email: "new@example.com", telegram: null, signal: null, nostr_npub: null, }); renderWithProviders(); await waitFor(() => { expect(screen.getByRole("heading", { name: "Mi Perfil" })).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: /guardar cambios/i }); fireEvent.click(submitButton); await waitFor(() => { expect(screen.getByText(/guardado exitosamente/i)).toBeDefined(); }); // Verify updateProfile was called expect(mockUpdateProfile).toHaveBeenCalledWith({ contact_email: "new@example.com", telegram: null, signal: null, nostr_npub: null, }); }); test("shows inline errors from backend validation", async () => { mockGetProfile.mockResolvedValue({ contact_email: null, telegram: null, signal: null, nostr_npub: null, }); // Import ApiError from the actual module (not mocked) const { ApiError } = await import("../api/client"); mockUpdateProfile.mockRejectedValue( new ApiError("Validation failed", 422, { detail: { field_errors: { telegram: "Backend error: invalid handle", }, }, }) ); renderWithProviders(); await waitFor(() => { expect(screen.getByRole("heading", { name: "Mi Perfil" })).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: /guardar cambios/i }); fireEvent.click(submitButton); await waitFor(() => { expect(screen.getByText(/backend error/i)).toBeDefined(); }); }); test("shows error toast on network failure", async () => { mockGetProfile.mockResolvedValue({ contact_email: null, telegram: null, signal: null, nostr_npub: null, }); mockUpdateProfile.mockRejectedValue(new Error("Network error")); renderWithProviders(); await waitFor(() => { expect(screen.getByRole("heading", { name: "Mi Perfil" })).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: /guardar cambios/i }); fireEvent.click(submitButton); await waitFor(() => { expect(screen.getByText(/network error/i)).toBeDefined(); }); }); test("submit button shows 'Saving...' while submitting", async () => { let resolveSubmit: (value: { contact_email: string | null; telegram: string | null; signal: string | null; nostr_npub: string | null; }) => void; const submitPromise = new Promise<{ contact_email: string | null; telegram: string | null; signal: string | null; nostr_npub: string | null; }>((resolve) => { resolveSubmit = resolve; }); mockGetProfile.mockResolvedValue({ contact_email: null, telegram: null, signal: null, nostr_npub: null, }); mockUpdateProfile.mockReturnValue(submitPromise); renderWithProviders(); await waitFor(() => { expect(screen.getByRole("heading", { name: "Mi Perfil" })).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: /guardar cambios/i }); fireEvent.click(submitButton); await waitFor(() => { expect(screen.getByText("Guardando...")).toBeDefined(); }); // Resolve the promise resolveSubmit!({ contact_email: "new@example.com", telegram: null, signal: null, nostr_npub: null, }); await waitFor(() => { expect(screen.getByRole("button", { name: /guardar cambios/i })).toBeDefined(); }); }); });