Phase 2: Language Context & Selector - Add language dropdown to Header and auth pages

- Create LanguageSelector component with dropdown (shows flag + name)
- Add LanguageSelector to Header (right side, near user email/logout)
- Add LanguageSelector to login, signup, and signup/[code] pages
- Create test-utils.tsx with renderWithProviders helper
- Add vitest.setup.ts to mock localStorage
- Update all test files to use renderWithProviders
- Language selector persists choice in localStorage
- HTML lang attribute updates dynamically based on selected language

All frontend and e2e tests passing.
This commit is contained in:
counterweight 2025-12-25 21:54:19 +01:00
parent f7553df05d
commit f86ec8b62d
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
11 changed files with 214 additions and 37 deletions

View file

@ -1,6 +1,7 @@
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
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();
@ -37,6 +38,7 @@ vi.mock("../auth-context", () => ({
hasRole: mockHasRole,
hasPermission: mockHasPermission,
}),
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Permission: {
VIEW_AUDIT: "view_audit",
FETCH_PRICE: "fetch_price",
@ -112,14 +114,14 @@ describe("ProfilePage - Display", () => {
mockIsLoading = true;
mockGetProfile.mockImplementation(() => new Promise(() => {}));
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
expect(screen.getByText("Loading...")).toBeDefined();
});
test("renders profile page title", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
});
@ -128,7 +130,7 @@ describe("ProfilePage - Display", () => {
test("displays login email as read-only", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
const loginEmailInput = screen.getByDisplayValue("test@example.com");
expect(loginEmailInput).toBeDefined();
@ -139,7 +141,7 @@ describe("ProfilePage - Display", () => {
test("shows read-only badge for login email", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByText("Read only")).toBeDefined();
});
@ -148,7 +150,7 @@ describe("ProfilePage - Display", () => {
test("shows hint about login email", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByText(/cannot be changed/i)).toBeDefined();
});
@ -157,7 +159,7 @@ describe("ProfilePage - Display", () => {
test("displays contact details section hint", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByText(/communication purposes only/i)).toBeDefined();
});
@ -166,7 +168,7 @@ describe("ProfilePage - Display", () => {
test("displays fetched profile data", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByDisplayValue("contact@example.com")).toBeDefined();
expect(screen.getByDisplayValue("@testuser")).toBeDefined();
@ -178,7 +180,7 @@ describe("ProfilePage - Display", () => {
test("fetches profile with credentials", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(mockGetProfile).toHaveBeenCalled();
@ -193,7 +195,7 @@ describe("ProfilePage - Display", () => {
nostr_npub: null,
});
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
// Check that inputs exist with empty values (placeholders shown)
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
@ -206,7 +208,7 @@ describe("ProfilePage - Navigation", () => {
test("shows nav links for regular user", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByText("Exchange")).toBeDefined();
expect(screen.getByText("My Trades")).toBeDefined();
@ -216,7 +218,7 @@ describe("ProfilePage - Navigation", () => {
test("highlights My Profile in nav", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
// My Profile should be visible (as current page indicator)
const navItems = screen.getAllByText("My Profile");
@ -229,7 +231,7 @@ describe("ProfilePage - Access Control", () => {
test("redirects to login when not authenticated", async () => {
mockUser = null;
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/login");
@ -244,7 +246,7 @@ describe("ProfilePage - Access Control", () => {
permissions: ["view_all_exchanges"],
};
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/admin/trades");
@ -259,7 +261,7 @@ describe("ProfilePage - Access Control", () => {
permissions: ["view_audit"],
};
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
// Give it a moment to potentially fetch
await new Promise((r) => setTimeout(r, 100));
@ -272,7 +274,7 @@ describe("ProfilePage - Loading State", () => {
mockIsLoading = true;
mockUser = null;
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
expect(mockPush).not.toHaveBeenCalled();
});
@ -280,7 +282,7 @@ describe("ProfilePage - Loading State", () => {
test("shows loading indicator while loading", () => {
mockIsLoading = true;
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
expect(screen.getByText("Loading...")).toBeDefined();
});
@ -290,7 +292,7 @@ describe("ProfilePage - Form Behavior", () => {
test("submit button is disabled when no changes", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
const submitButton = screen.getByRole("button", { name: /save changes/i });
expect(submitButton).toHaveProperty("disabled", true);
@ -300,7 +302,7 @@ describe("ProfilePage - Form Behavior", () => {
test("submit button is enabled after field changes", async () => {
mockGetProfile.mockResolvedValue(mockProfileData);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByDisplayValue("@testuser")).toBeDefined();
@ -323,7 +325,7 @@ describe("ProfilePage - Form Behavior", () => {
nostr_npub: null,
});
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
@ -345,7 +347,7 @@ describe("ProfilePage - Form Behavior", () => {
nostr_npub: null,
});
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
@ -382,7 +384,7 @@ describe("ProfilePage - Form Submission", () => {
nostr_npub: null,
});
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
@ -428,7 +430,7 @@ describe("ProfilePage - Form Submission", () => {
})
);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
@ -456,7 +458,7 @@ describe("ProfilePage - Form Submission", () => {
});
mockUpdateProfile.mockRejectedValue(new Error("Network error"));
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
@ -499,7 +501,7 @@ describe("ProfilePage - Form Submission", () => {
});
mockUpdateProfile.mockReturnValue(submitPromise);
render(<ProfilePage />);
renderWithProviders(<ProfilePage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();