arbret/frontend/app/profile/page.test.tsx
counterweight a6fa6a8012
Refactor API layer into structured domain-specific modules
- Created new api/ directory with domain-specific API modules:
  - api/client.ts: Base API client with error handling
  - api/auth.ts: Authentication endpoints
  - api/exchange.ts: Exchange/price endpoints
  - api/trades.ts: User trade endpoints
  - api/profile.ts: Profile management endpoints
  - api/invites.ts: Invite endpoints
  - api/admin.ts: Admin endpoints
  - api/index.ts: Centralized exports

- Migrated all API calls from ad-hoc api.get/post/put to typed domain APIs
- Updated all imports across codebase
- Fixed test mocks to use new API structure
- Fixed type issues in validation utilities
- Removed old api.ts file

Benefits:
- Type-safe endpoints (no more string typos)
- Centralized API surface (easy to discover endpoints)
- Better organization (domain-specific modules)
- Uses generated OpenAPI types automatically
2025-12-25 20:32:11 +01:00

532 lines
15 KiB
TypeScript

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: [
"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,
}),
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(() => {}));
render(<ProfilePage />);
expect(screen.getByText("Loading...")).toBeDefined();
});
test("renders profile page title", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
});
});
test("displays login email as read-only", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<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);
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByText("Read only")).toBeDefined();
});
});
test("shows hint about login email", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByText(/cannot be changed/i)).toBeDefined();
});
});
test("displays contact details section hint", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByText(/communication purposes only/i)).toBeDefined();
});
});
test("displays fetched profile data", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<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);
render(<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,
});
render(<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);
render(<ProfilePage />);
await waitFor(() => {
expect(screen.getByText("Exchange")).toBeDefined();
expect(screen.getByText("My Trades")).toBeDefined();
});
});
test("highlights My Profile in nav", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
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(<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"],
};
render(<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"],
};
render(<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;
render(<ProfilePage />);
expect(mockPush).not.toHaveBeenCalled();
});
test("shows loading indicator while loading", () => {
mockIsLoading = true;
render(<ProfilePage />);
expect(screen.getByText("Loading...")).toBeDefined();
});
});
describe("ProfilePage - Form Behavior", () => {
test("submit button is disabled when no changes", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
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 () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<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: /save changes/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,
});
render(<ProfilePage />);
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 () => {
mockGetProfile.mockResolvedValue({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
});
render(<ProfilePage />);
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 () => {
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,
});
render(<ProfilePage />);
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 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",
},
},
})
);
render(<ProfilePage />);
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 () => {
mockGetProfile.mockResolvedValue({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
});
mockUpdateProfile.mockRejectedValue(new Error("Network error"));
render(<ProfilePage />);
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: {
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);
render(<ProfilePage />);
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!({
contact_email: "new@example.com",
telegram: null,
signal: null,
nostr_npub: null,
});
await waitFor(() => {
expect(screen.getByRole("button", { name: /save changes/i })).toBeDefined();
});
});
});