arbret/frontend/app/profile/page.test.tsx
counterweight 37de6f70e0
Add Prettier for TypeScript formatting
- Install prettier
- Configure .prettierrc.json and .prettierignore
- Add npm scripts: format, format:check
- Add Makefile target: format-frontend
- Format all frontend files
2025-12-21 21:59:26 +01:00

545 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: ["view_counter", "increment_counter", "use_sum"],
};
let mockIsLoading = false;
const mockLogout = vi.fn();
const mockHasRole = vi.fn((role: string) => mockUser?.roles.includes(role) ?? false);
vi.mock("../auth-context", () => ({
useAuth: () => ({
user: mockUser,
isLoading: mockIsLoading,
logout: mockLogout,
hasRole: mockHasRole,
}),
}));
// 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"],
};
mockIsLoading = false;
mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false);
});
afterEach(() => {
cleanup();
});
describe("ProfilePage - Display", () => {
test("renders loading state initially", () => {
mockIsLoading = true;
vi.spyOn(global, "fetch").mockImplementation(() => new Promise(() => {}));
render(<ProfilePage />);
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(<ProfilePage />);
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(<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 () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockProfileData),
} as Response);
render(<ProfilePage />);
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(<ProfilePage />);
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(<ProfilePage />);
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(<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 () => {
const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockProfileData),
} as Response);
render(<ProfilePage />);
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(<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 () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockProfileData),
} as Response);
render(<ProfilePage />);
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(<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 audit page", async () => {
mockUser = {
id: 1,
email: "admin@example.com",
roles: ["admin"],
permissions: ["view_audit"],
};
render(<ProfilePage />);
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(<ProfilePage />);
// 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(<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 () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockProfileData),
} as Response);
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 () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockProfileData),
} as Response);
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 () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
} as Response);
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 () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
}),
} as Response);
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 () => {
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(<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 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(<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 () => {
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(<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: Response) => void;
const submitPromise = new Promise<Response>((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<Response>);
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!({
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();
});
});
});