arbret/frontend/app/profile/page.test.tsx
counterweight d2fc7d8850
Fix e2e tests: Set English language before navigation
- Add context.addInitScript in beforeEach hooks to set English locale before page navigation
- Remove debugging code from useLanguage hook
- Remove unused setup file imports
- Fix exchange test to check for English text correctly
- All frontend tests passing
2025-12-25 22:35:27 +01:00

534 lines
15 KiB
TypeScript

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<typeof import("../api")>();
// 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(<ProfilePage />);
expect(screen.getByText("Cargando...")).toBeDefined();
});
test("renders profile page title", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Mi Perfil" })).toBeDefined();
});
});
test("displays login email as read-only", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
renderWithProviders(<ProfilePage />);
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(<ProfilePage />);
await waitFor(() => {
expect(screen.getByText("Solo lectura")).toBeDefined();
});
});
test("shows hint about login email", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByText(/no se puede cambiar/i)).toBeDefined();
});
});
test("displays contact details section hint", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByText(/fines de comunicación/i)).toBeDefined();
});
});
test("displays fetched profile data", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
renderWithProviders(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
await waitFor(() => {
expect(screen.getByText("Exchange")).toBeDefined();
expect(screen.getByText("Mis Operaciones")).toBeDefined();
});
});
test("highlights My Profile in nav", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
renderWithProviders(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
// 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(<ProfilePage />);
expect(mockPush).not.toHaveBeenCalled();
});
test("shows loading indicator while loading", () => {
mockIsLoading = true;
renderWithProviders(<ProfilePage />);
expect(screen.getByText("Cargando...")).toBeDefined();
});
});
describe("ProfilePage - Form Behavior", () => {
test("submit button is disabled when no changes", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
renderWithProviders(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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();
});
});
});