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:
parent
f7553df05d
commit
f86ec8b62d
11 changed files with 214 additions and 37 deletions
|
|
@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "../auth-context";
|
import { useAuth } from "../auth-context";
|
||||||
import { sharedStyles } from "../styles/shared";
|
import { sharedStyles } from "../styles/shared";
|
||||||
import constants from "../../../shared/constants.json";
|
import constants from "../../../shared/constants.json";
|
||||||
|
import { LanguageSelector } from "./LanguageSelector";
|
||||||
|
|
||||||
const { ADMIN, REGULAR } = constants.roles;
|
const { ADMIN, REGULAR } = constants.roles;
|
||||||
|
|
||||||
|
|
@ -80,6 +81,7 @@ export function Header({ currentPage }: HeaderProps) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div style={sharedStyles.userInfo}>
|
<div style={sharedStyles.userInfo}>
|
||||||
|
<LanguageSelector />
|
||||||
<span style={sharedStyles.userEmail}>{user.email}</span>
|
<span style={sharedStyles.userEmail}>{user.email}</span>
|
||||||
<button onClick={handleLogout} style={sharedStyles.logoutBtn}>
|
<button onClick={handleLogout} style={sharedStyles.logoutBtn}>
|
||||||
Sign out
|
Sign out
|
||||||
|
|
|
||||||
105
frontend/app/components/LanguageSelector.tsx
Normal file
105
frontend/app/components/LanguageSelector.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { useLanguage, type Locale } from "../hooks/useLanguage";
|
||||||
|
import { sharedStyles } from "../styles/shared";
|
||||||
|
|
||||||
|
const LANGUAGES: Array<{ code: Locale; name: string; flag: string }> = [
|
||||||
|
{ code: "es", name: "Español", flag: "🇪🇸" },
|
||||||
|
{ code: "en", name: "English", flag: "🇬🇧" },
|
||||||
|
{ code: "ca", name: "Català", flag: "🇪🇸" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function LanguageSelector() {
|
||||||
|
const { locale, setLocale } = useLanguage();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const currentLanguage = LANGUAGES.find((lang) => lang.code === locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={dropdownRef} style={{ position: "relative" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
style={{
|
||||||
|
...sharedStyles.logoutBtn,
|
||||||
|
marginRight: "0.75rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{currentLanguage?.flag}</span>
|
||||||
|
<span>{currentLanguage?.name}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "100%",
|
||||||
|
right: 0,
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
backgroundColor: "white",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
|
||||||
|
zIndex: 1000,
|
||||||
|
minWidth: "150px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{LANGUAGES.map((lang) => (
|
||||||
|
<button
|
||||||
|
key={lang.code}
|
||||||
|
onClick={() => {
|
||||||
|
setLocale(lang.code);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
textAlign: "left",
|
||||||
|
border: "none",
|
||||||
|
backgroundColor: locale === lang.code ? "#f3f4f6" : "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (locale !== lang.code) {
|
||||||
|
e.currentTarget.style.backgroundColor = "#f9fafb";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (locale !== lang.code) {
|
||||||
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{lang.flag}</span>
|
||||||
|
<span>{lang.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 { expect, test, vi, beforeEach, afterEach } from "vitest";
|
||||||
import LoginPage from "./page";
|
import LoginPage from "./page";
|
||||||
|
import { renderWithProviders } from "../test-utils";
|
||||||
|
|
||||||
const mockPush = vi.fn();
|
const mockPush = vi.fn();
|
||||||
vi.mock("next/navigation", () => ({
|
vi.mock("next/navigation", () => ({
|
||||||
useRouter: () => ({ push: mockPush }),
|
useRouter: () => ({ push: mockPush }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockLogin = vi.fn();
|
||||||
vi.mock("../auth-context", () => ({
|
vi.mock("../auth-context", () => ({
|
||||||
useAuth: () => ({ login: vi.fn() }),
|
useAuth: () => ({ login: mockLogin }),
|
||||||
|
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
test("renders login form with title", () => {
|
test("renders login form with title", () => {
|
||||||
render(<LoginPage />);
|
renderWithProviders(<LoginPage />);
|
||||||
expect(screen.getByText("Welcome back")).toBeDefined();
|
expect(screen.getByText("Welcome back")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders email and password inputs", () => {
|
test("renders email and password inputs", () => {
|
||||||
render(<LoginPage />);
|
renderWithProviders(<LoginPage />);
|
||||||
expect(screen.getByLabelText("Email")).toBeDefined();
|
expect(screen.getByLabelText("Email")).toBeDefined();
|
||||||
expect(screen.getByLabelText("Password")).toBeDefined();
|
expect(screen.getByLabelText("Password")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders sign in button", () => {
|
test("renders sign in button", () => {
|
||||||
render(<LoginPage />);
|
renderWithProviders(<LoginPage />);
|
||||||
expect(screen.getByRole("button", { name: "Sign in" })).toBeDefined();
|
expect(screen.getByRole("button", { name: "Sign in" })).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders link to signup", () => {
|
test("renders link to signup", () => {
|
||||||
render(<LoginPage />);
|
renderWithProviders(<LoginPage />);
|
||||||
expect(screen.getByText("Sign up")).toBeDefined();
|
expect(screen.getByText("Sign up")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "../auth-context";
|
import { useAuth } from "../auth-context";
|
||||||
import { authFormStyles as styles } from "../styles/auth-form";
|
import { authFormStyles as styles } from "../styles/auth-form";
|
||||||
|
import { LanguageSelector } from "../components/LanguageSelector";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
|
|
@ -30,6 +31,9 @@ export default function LoginPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={styles.main}>
|
<main style={styles.main}>
|
||||||
|
<div style={{ position: "absolute", top: "1rem", right: "1rem" }}>
|
||||||
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<div style={styles.card}>
|
<div style={styles.card}>
|
||||||
<div style={styles.header}>
|
<div style={styles.header}>
|
||||||
|
|
|
||||||
|
|
@ -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 { expect, test, vi, beforeEach, afterEach, describe } from "vitest";
|
||||||
import ProfilePage from "./page";
|
import ProfilePage from "./page";
|
||||||
|
import { renderWithProviders } from "../test-utils";
|
||||||
|
|
||||||
// Mock next/navigation
|
// Mock next/navigation
|
||||||
const mockPush = vi.fn();
|
const mockPush = vi.fn();
|
||||||
|
|
@ -37,6 +38,7 @@ vi.mock("../auth-context", () => ({
|
||||||
hasRole: mockHasRole,
|
hasRole: mockHasRole,
|
||||||
hasPermission: mockHasPermission,
|
hasPermission: mockHasPermission,
|
||||||
}),
|
}),
|
||||||
|
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
Permission: {
|
Permission: {
|
||||||
VIEW_AUDIT: "view_audit",
|
VIEW_AUDIT: "view_audit",
|
||||||
FETCH_PRICE: "fetch_price",
|
FETCH_PRICE: "fetch_price",
|
||||||
|
|
@ -112,14 +114,14 @@ describe("ProfilePage - Display", () => {
|
||||||
mockIsLoading = true;
|
mockIsLoading = true;
|
||||||
mockGetProfile.mockImplementation(() => new Promise(() => {}));
|
mockGetProfile.mockImplementation(() => new Promise(() => {}));
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
expect(screen.getByText("Loading...")).toBeDefined();
|
expect(screen.getByText("Loading...")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders profile page title", async () => {
|
test("renders profile page title", async () => {
|
||||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
@ -128,7 +130,7 @@ describe("ProfilePage - Display", () => {
|
||||||
test("displays login email as read-only", async () => {
|
test("displays login email as read-only", async () => {
|
||||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const loginEmailInput = screen.getByDisplayValue("test@example.com");
|
const loginEmailInput = screen.getByDisplayValue("test@example.com");
|
||||||
expect(loginEmailInput).toBeDefined();
|
expect(loginEmailInput).toBeDefined();
|
||||||
|
|
@ -139,7 +141,7 @@ describe("ProfilePage - Display", () => {
|
||||||
test("shows read-only badge for login email", async () => {
|
test("shows read-only badge for login email", async () => {
|
||||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Read only")).toBeDefined();
|
expect(screen.getByText("Read only")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
@ -148,7 +150,7 @@ describe("ProfilePage - Display", () => {
|
||||||
test("shows hint about login email", async () => {
|
test("shows hint about login email", async () => {
|
||||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/cannot be changed/i)).toBeDefined();
|
expect(screen.getByText(/cannot be changed/i)).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
@ -157,7 +159,7 @@ describe("ProfilePage - Display", () => {
|
||||||
test("displays contact details section hint", async () => {
|
test("displays contact details section hint", async () => {
|
||||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/communication purposes only/i)).toBeDefined();
|
expect(screen.getByText(/communication purposes only/i)).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
@ -166,7 +168,7 @@ describe("ProfilePage - Display", () => {
|
||||||
test("displays fetched profile data", async () => {
|
test("displays fetched profile data", async () => {
|
||||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByDisplayValue("contact@example.com")).toBeDefined();
|
expect(screen.getByDisplayValue("contact@example.com")).toBeDefined();
|
||||||
expect(screen.getByDisplayValue("@testuser")).toBeDefined();
|
expect(screen.getByDisplayValue("@testuser")).toBeDefined();
|
||||||
|
|
@ -178,7 +180,7 @@ describe("ProfilePage - Display", () => {
|
||||||
test("fetches profile with credentials", async () => {
|
test("fetches profile with credentials", async () => {
|
||||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockGetProfile).toHaveBeenCalled();
|
expect(mockGetProfile).toHaveBeenCalled();
|
||||||
|
|
@ -193,7 +195,7 @@ describe("ProfilePage - Display", () => {
|
||||||
nostr_npub: null,
|
nostr_npub: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Check that inputs exist with empty values (placeholders shown)
|
// Check that inputs exist with empty values (placeholders shown)
|
||||||
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
|
const telegramInput = document.getElementById("telegram") as HTMLInputElement;
|
||||||
|
|
@ -206,7 +208,7 @@ describe("ProfilePage - Navigation", () => {
|
||||||
test("shows nav links for regular user", async () => {
|
test("shows nav links for regular user", async () => {
|
||||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Exchange")).toBeDefined();
|
expect(screen.getByText("Exchange")).toBeDefined();
|
||||||
expect(screen.getByText("My Trades")).toBeDefined();
|
expect(screen.getByText("My Trades")).toBeDefined();
|
||||||
|
|
@ -216,7 +218,7 @@ describe("ProfilePage - Navigation", () => {
|
||||||
test("highlights My Profile in nav", async () => {
|
test("highlights My Profile in nav", async () => {
|
||||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// My Profile should be visible (as current page indicator)
|
// My Profile should be visible (as current page indicator)
|
||||||
const navItems = screen.getAllByText("My Profile");
|
const navItems = screen.getAllByText("My Profile");
|
||||||
|
|
@ -229,7 +231,7 @@ describe("ProfilePage - Access Control", () => {
|
||||||
test("redirects to login when not authenticated", async () => {
|
test("redirects to login when not authenticated", async () => {
|
||||||
mockUser = null;
|
mockUser = null;
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockPush).toHaveBeenCalledWith("/login");
|
expect(mockPush).toHaveBeenCalledWith("/login");
|
||||||
|
|
@ -244,7 +246,7 @@ describe("ProfilePage - Access Control", () => {
|
||||||
permissions: ["view_all_exchanges"],
|
permissions: ["view_all_exchanges"],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockPush).toHaveBeenCalledWith("/admin/trades");
|
expect(mockPush).toHaveBeenCalledWith("/admin/trades");
|
||||||
|
|
@ -259,7 +261,7 @@ describe("ProfilePage - Access Control", () => {
|
||||||
permissions: ["view_audit"],
|
permissions: ["view_audit"],
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
|
|
||||||
// Give it a moment to potentially fetch
|
// Give it a moment to potentially fetch
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
@ -272,7 +274,7 @@ describe("ProfilePage - Loading State", () => {
|
||||||
mockIsLoading = true;
|
mockIsLoading = true;
|
||||||
mockUser = null;
|
mockUser = null;
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
|
|
||||||
expect(mockPush).not.toHaveBeenCalled();
|
expect(mockPush).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
@ -280,7 +282,7 @@ describe("ProfilePage - Loading State", () => {
|
||||||
test("shows loading indicator while loading", () => {
|
test("shows loading indicator while loading", () => {
|
||||||
mockIsLoading = true;
|
mockIsLoading = true;
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
|
|
||||||
expect(screen.getByText("Loading...")).toBeDefined();
|
expect(screen.getByText("Loading...")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
@ -290,7 +292,7 @@ describe("ProfilePage - Form Behavior", () => {
|
||||||
test("submit button is disabled when no changes", async () => {
|
test("submit button is disabled when no changes", async () => {
|
||||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const submitButton = screen.getByRole("button", { name: /save changes/i });
|
const submitButton = screen.getByRole("button", { name: /save changes/i });
|
||||||
expect(submitButton).toHaveProperty("disabled", true);
|
expect(submitButton).toHaveProperty("disabled", true);
|
||||||
|
|
@ -300,7 +302,7 @@ describe("ProfilePage - Form Behavior", () => {
|
||||||
test("submit button is enabled after field changes", async () => {
|
test("submit button is enabled after field changes", async () => {
|
||||||
mockGetProfile.mockResolvedValue(mockProfileData);
|
mockGetProfile.mockResolvedValue(mockProfileData);
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByDisplayValue("@testuser")).toBeDefined();
|
expect(screen.getByDisplayValue("@testuser")).toBeDefined();
|
||||||
|
|
@ -323,7 +325,7 @@ describe("ProfilePage - Form Behavior", () => {
|
||||||
nostr_npub: null,
|
nostr_npub: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||||
|
|
@ -345,7 +347,7 @@ describe("ProfilePage - Form Behavior", () => {
|
||||||
nostr_npub: null,
|
nostr_npub: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||||
|
|
@ -382,7 +384,7 @@ describe("ProfilePage - Form Submission", () => {
|
||||||
nostr_npub: null,
|
nostr_npub: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||||
|
|
@ -428,7 +430,7 @@ describe("ProfilePage - Form Submission", () => {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||||
|
|
@ -456,7 +458,7 @@ describe("ProfilePage - Form Submission", () => {
|
||||||
});
|
});
|
||||||
mockUpdateProfile.mockRejectedValue(new Error("Network error"));
|
mockUpdateProfile.mockRejectedValue(new Error("Network error"));
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||||
|
|
@ -499,7 +501,7 @@ describe("ProfilePage - Form Submission", () => {
|
||||||
});
|
});
|
||||||
mockUpdateProfile.mockReturnValue(submitPromise);
|
mockUpdateProfile.mockReturnValue(submitPromise);
|
||||||
|
|
||||||
render(<ProfilePage />);
|
renderWithProviders(<ProfilePage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
expect(screen.getByRole("heading", { name: "My Profile" })).toBeDefined();
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useRouter, useParams } from "next/navigation";
|
import { useRouter, useParams } from "next/navigation";
|
||||||
import { useAuth } from "../../auth-context";
|
import { useAuth } from "../../auth-context";
|
||||||
|
import { LanguageSelector } from "../../components/LanguageSelector";
|
||||||
|
|
||||||
export default function SignupWithCodePage() {
|
export default function SignupWithCodePage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
@ -36,6 +37,9 @@ export default function SignupWithCodePage() {
|
||||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div style={{ position: "absolute", top: "1rem", right: "1rem" }}>
|
||||||
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
Redirecting...
|
Redirecting...
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 { expect, test, vi, beforeEach, afterEach } from "vitest";
|
||||||
import SignupPage from "./page";
|
import SignupPage from "./page";
|
||||||
|
import { renderWithProviders } from "../test-utils";
|
||||||
|
|
||||||
const mockPush = vi.fn();
|
const mockPush = vi.fn();
|
||||||
vi.mock("next/navigation", () => ({
|
vi.mock("next/navigation", () => ({
|
||||||
|
|
@ -8,30 +9,32 @@ vi.mock("next/navigation", () => ({
|
||||||
useSearchParams: () => ({ get: () => null }),
|
useSearchParams: () => ({ get: () => null }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockRegister = vi.fn();
|
||||||
vi.mock("../auth-context", () => ({
|
vi.mock("../auth-context", () => ({
|
||||||
useAuth: () => ({ user: null, register: vi.fn() }),
|
useAuth: () => ({ user: null, register: mockRegister }),
|
||||||
|
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
test("renders signup form with title", () => {
|
test("renders signup form with title", () => {
|
||||||
render(<SignupPage />);
|
renderWithProviders(<SignupPage />);
|
||||||
// Step 1 shows "Join with Invite" title (invite code entry)
|
// Step 1 shows "Join with Invite" title (invite code entry)
|
||||||
expect(screen.getByRole("heading", { name: "Join with Invite" })).toBeDefined();
|
expect(screen.getByRole("heading", { name: "Join with Invite" })).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders invite code input", () => {
|
test("renders invite code input", () => {
|
||||||
render(<SignupPage />);
|
renderWithProviders(<SignupPage />);
|
||||||
expect(screen.getByLabelText("Invite Code")).toBeDefined();
|
expect(screen.getByLabelText("Invite Code")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders continue button", () => {
|
test("renders continue button", () => {
|
||||||
render(<SignupPage />);
|
renderWithProviders(<SignupPage />);
|
||||||
expect(screen.getByRole("button", { name: "Continue" })).toBeDefined();
|
expect(screen.getByRole("button", { name: "Continue" })).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders link to login", () => {
|
test("renders link to login", () => {
|
||||||
render(<SignupPage />);
|
renderWithProviders(<SignupPage />);
|
||||||
expect(screen.getByText("Sign in")).toBeDefined();
|
expect(screen.getByText("Sign in")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useAuth } from "../auth-context";
|
import { useAuth } from "../auth-context";
|
||||||
import { invitesApi } from "../api";
|
import { invitesApi } from "../api";
|
||||||
import { authFormStyles as styles } from "../styles/auth-form";
|
import { authFormStyles as styles } from "../styles/auth-form";
|
||||||
|
import { LanguageSelector } from "../components/LanguageSelector";
|
||||||
|
|
||||||
function SignupContent() {
|
function SignupContent() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
@ -107,6 +108,9 @@ function SignupContent() {
|
||||||
if (isCheckingInitialCode) {
|
if (isCheckingInitialCode) {
|
||||||
return (
|
return (
|
||||||
<main style={styles.main}>
|
<main style={styles.main}>
|
||||||
|
<div style={{ position: "absolute", top: "1rem", right: "1rem" }}>
|
||||||
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<div style={styles.card}>
|
<div style={styles.card}>
|
||||||
<div style={{ textAlign: "center", color: "rgba(255,255,255,0.6)" }}>
|
<div style={{ textAlign: "center", color: "rgba(255,255,255,0.6)" }}>
|
||||||
|
|
@ -122,6 +126,9 @@ function SignupContent() {
|
||||||
if (!inviteValid) {
|
if (!inviteValid) {
|
||||||
return (
|
return (
|
||||||
<main style={styles.main}>
|
<main style={styles.main}>
|
||||||
|
<div style={{ position: "absolute", top: "1rem", right: "1rem" }}>
|
||||||
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<div style={styles.card}>
|
<div style={styles.card}>
|
||||||
<div style={styles.header}>
|
<div style={styles.header}>
|
||||||
|
|
@ -189,6 +196,9 @@ function SignupContent() {
|
||||||
// Step 2: Enter email and password
|
// Step 2: Enter email and password
|
||||||
return (
|
return (
|
||||||
<main style={styles.main}>
|
<main style={styles.main}>
|
||||||
|
<div style={{ position: "absolute", top: "1rem", right: "1rem" }}>
|
||||||
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<div style={styles.card}>
|
<div style={styles.card}>
|
||||||
<div style={styles.header}>
|
<div style={styles.header}>
|
||||||
|
|
|
||||||
16
frontend/app/test-utils.tsx
Normal file
16
frontend/app/test-utils.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Providers>
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</Providers>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderWithProviders(ui: ReactElement, options?: Omit<RenderOptions, "wrapper">) {
|
||||||
|
return render(ui, { wrapper: AllProviders, ...options });
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
include: ["app/**/*.test.{ts,tsx}"],
|
include: ["app/**/*.test.{ts,tsx}"],
|
||||||
|
setupFiles: ["./vitest.setup.ts"],
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: "v8",
|
provider: "v8",
|
||||||
reporter: ["text", "html"],
|
reporter: ["text", "html"],
|
||||||
|
|
|
||||||
27
frontend/vitest.setup.ts
Normal file
27
frontend/vitest.setup.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const localStorageMock = (() => {
|
||||||
|
let store: Record<string, string> = {};
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue