diff --git a/frontend/app/components/Header.tsx b/frontend/app/components/Header.tsx index db88a04..ef59d0a 100644 --- a/frontend/app/components/Header.tsx +++ b/frontend/app/components/Header.tsx @@ -4,6 +4,7 @@ import { useRouter } from "next/navigation"; import { useAuth } from "../auth-context"; import { sharedStyles } from "../styles/shared"; import constants from "../../../shared/constants.json"; +import { LanguageSelector } from "./LanguageSelector"; const { ADMIN, REGULAR } = constants.roles; @@ -80,6 +81,7 @@ export function Header({ currentPage }: HeaderProps) { ))}
+ {user.email} + + {isOpen && ( +
+ {LANGUAGES.map((lang) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/app/login/page.test.tsx b/frontend/app/login/page.test.tsx index 78f09a5..9d1c9ba 100644 --- a/frontend/app/login/page.test.tsx +++ b/frontend/app/login/page.test.tsx @@ -1,36 +1,39 @@ -import { render, screen, cleanup } from "@testing-library/react"; +import { screen, cleanup } from "@testing-library/react"; import { expect, test, vi, beforeEach, afterEach } from "vitest"; import LoginPage from "./page"; +import { renderWithProviders } from "../test-utils"; const mockPush = vi.fn(); vi.mock("next/navigation", () => ({ useRouter: () => ({ push: mockPush }), })); +const mockLogin = vi.fn(); vi.mock("../auth-context", () => ({ - useAuth: () => ({ login: vi.fn() }), + useAuth: () => ({ login: mockLogin }), + AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })); beforeEach(() => vi.clearAllMocks()); afterEach(() => cleanup()); test("renders login form with title", () => { - render(); + renderWithProviders(); expect(screen.getByText("Welcome back")).toBeDefined(); }); test("renders email and password inputs", () => { - render(); + renderWithProviders(); expect(screen.getByLabelText("Email")).toBeDefined(); expect(screen.getByLabelText("Password")).toBeDefined(); }); test("renders sign in button", () => { - render(); + renderWithProviders(); expect(screen.getByRole("button", { name: "Sign in" })).toBeDefined(); }); test("renders link to signup", () => { - render(); + renderWithProviders(); expect(screen.getByText("Sign up")).toBeDefined(); }); diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index c518ab5..bb8d0ba 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "../auth-context"; import { authFormStyles as styles } from "../styles/auth-form"; +import { LanguageSelector } from "../components/LanguageSelector"; export default function LoginPage() { const [email, setEmail] = useState(""); @@ -30,6 +31,9 @@ export default function LoginPage() { return (
+
+ +
diff --git a/frontend/app/profile/page.test.tsx b/frontend/app/profile/page.test.tsx index 61957c8..ae1d4e5 100644 --- a/frontend/app/profile/page.test.tsx +++ b/frontend/app/profile/page.test.tsx @@ -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(); + renderWithProviders(); expect(screen.getByText("Loading...")).toBeDefined(); }); test("renders profile page title", async () => { mockGetProfile.mockResolvedValue(mockProfileData); - render(); + renderWithProviders(); 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(); + renderWithProviders(); 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(); + renderWithProviders(); 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(); + renderWithProviders(); 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(); + renderWithProviders(); 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(); + renderWithProviders(); 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(); + renderWithProviders(); await waitFor(() => { expect(mockGetProfile).toHaveBeenCalled(); @@ -193,7 +195,7 @@ describe("ProfilePage - Display", () => { nostr_npub: null, }); - render(); + renderWithProviders(); 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(); + renderWithProviders(); 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(); + renderWithProviders(); 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(); + renderWithProviders(); await waitFor(() => { expect(mockPush).toHaveBeenCalledWith("/login"); @@ -244,7 +246,7 @@ describe("ProfilePage - Access Control", () => { permissions: ["view_all_exchanges"], }; - render(); + renderWithProviders(); await waitFor(() => { expect(mockPush).toHaveBeenCalledWith("/admin/trades"); @@ -259,7 +261,7 @@ describe("ProfilePage - Access Control", () => { permissions: ["view_audit"], }; - render(); + renderWithProviders(); // 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(); + renderWithProviders(); expect(mockPush).not.toHaveBeenCalled(); }); @@ -280,7 +282,7 @@ describe("ProfilePage - Loading State", () => { test("shows loading indicator while loading", () => { mockIsLoading = true; - render(); + renderWithProviders(); 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(); + renderWithProviders(); 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(); + renderWithProviders(); await waitFor(() => { expect(screen.getByDisplayValue("@testuser")).toBeDefined(); @@ -323,7 +325,7 @@ describe("ProfilePage - Form Behavior", () => { nostr_npub: null, }); - render(); + renderWithProviders(); await waitFor(() => { expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); @@ -345,7 +347,7 @@ describe("ProfilePage - Form Behavior", () => { nostr_npub: null, }); - render(); + renderWithProviders(); await waitFor(() => { expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); @@ -382,7 +384,7 @@ describe("ProfilePage - Form Submission", () => { nostr_npub: null, }); - render(); + renderWithProviders(); await waitFor(() => { expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); @@ -428,7 +430,7 @@ describe("ProfilePage - Form Submission", () => { }) ); - render(); + renderWithProviders(); 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(); + renderWithProviders(); await waitFor(() => { expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); @@ -499,7 +501,7 @@ describe("ProfilePage - Form Submission", () => { }); mockUpdateProfile.mockReturnValue(submitPromise); - render(); + renderWithProviders(); await waitFor(() => { expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined(); diff --git a/frontend/app/signup/[code]/page.tsx b/frontend/app/signup/[code]/page.tsx index 065d2a4..0505e56 100644 --- a/frontend/app/signup/[code]/page.tsx +++ b/frontend/app/signup/[code]/page.tsx @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { useRouter, useParams } from "next/navigation"; import { useAuth } from "../../auth-context"; +import { LanguageSelector } from "../../components/LanguageSelector"; export default function SignupWithCodePage() { const params = useParams(); @@ -36,6 +37,9 @@ export default function SignupWithCodePage() { fontFamily: "'DM Sans', system-ui, sans-serif", }} > +
+ +
Redirecting...
); diff --git a/frontend/app/signup/page.test.tsx b/frontend/app/signup/page.test.tsx index b25d956..f4aec2a 100644 --- a/frontend/app/signup/page.test.tsx +++ b/frontend/app/signup/page.test.tsx @@ -1,6 +1,7 @@ -import { render, screen, cleanup } from "@testing-library/react"; +import { screen, cleanup } from "@testing-library/react"; import { expect, test, vi, beforeEach, afterEach } from "vitest"; import SignupPage from "./page"; +import { renderWithProviders } from "../test-utils"; const mockPush = vi.fn(); vi.mock("next/navigation", () => ({ @@ -8,30 +9,32 @@ vi.mock("next/navigation", () => ({ useSearchParams: () => ({ get: () => null }), })); +const mockRegister = vi.fn(); vi.mock("../auth-context", () => ({ - useAuth: () => ({ user: null, register: vi.fn() }), + useAuth: () => ({ user: null, register: mockRegister }), + AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })); beforeEach(() => vi.clearAllMocks()); afterEach(() => cleanup()); test("renders signup form with title", () => { - render(); + renderWithProviders(); // Step 1 shows "Join with Invite" title (invite code entry) expect(screen.getByRole("heading", { name: "Join with Invite" })).toBeDefined(); }); test("renders invite code input", () => { - render(); + renderWithProviders(); expect(screen.getByLabelText("Invite Code")).toBeDefined(); }); test("renders continue button", () => { - render(); + renderWithProviders(); expect(screen.getByRole("button", { name: "Continue" })).toBeDefined(); }); test("renders link to login", () => { - render(); + renderWithProviders(); expect(screen.getByText("Sign in")).toBeDefined(); }); diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index c08dcb6..e9c4717 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "../auth-context"; import { invitesApi } from "../api"; import { authFormStyles as styles } from "../styles/auth-form"; +import { LanguageSelector } from "../components/LanguageSelector"; function SignupContent() { const searchParams = useSearchParams(); @@ -107,6 +108,9 @@ function SignupContent() { if (isCheckingInitialCode) { return (
+
+ +
@@ -122,6 +126,9 @@ function SignupContent() { if (!inviteValid) { return (
+
+ +
@@ -189,6 +196,9 @@ function SignupContent() { // Step 2: Enter email and password return (
+
+ +
diff --git a/frontend/app/test-utils.tsx b/frontend/app/test-utils.tsx new file mode 100644 index 0000000..2b7ddde --- /dev/null +++ b/frontend/app/test-utils.tsx @@ -0,0 +1,16 @@ +import { ReactElement } from "react"; +import { render, RenderOptions } from "@testing-library/react"; +import { Providers } from "./components/Providers"; +import { AuthProvider } from "./auth-context"; + +function AllProviders({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export function renderWithProviders(ui: ReactElement, options?: Omit) { + return render(ui, { wrapper: AllProviders, ...options }); +} diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 1cc37d3..40727b8 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ test: { environment: "jsdom", include: ["app/**/*.test.{ts,tsx}"], + setupFiles: ["./vitest.setup.ts"], coverage: { provider: "v8", reporter: ["text", "html"], diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts new file mode 100644 index 0000000..3275f4d --- /dev/null +++ b/frontend/vitest.setup.ts @@ -0,0 +1,27 @@ +import { beforeEach } from "vitest"; + +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {}; + + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { + store[key] = value.toString(); + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + }; +})(); + +Object.defineProperty(window, "localStorage", { + value: localStorageMock, +}); + +beforeEach(() => { + localStorageMock.clear(); +});