// @vitest-environment node import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("./utils/session.server", () => ({ createUserSession: vi.fn(async (userSession, redirectTo) => ({ userSession, redirectTo })), getUserInfo: vi.fn(), validateCSRFToken: vi.fn(), })); vi.mock("./utils/theme/userThemePreference.server", () => ({ nextLightOrDarkMode: vi.fn((current) => (current === "dark" ? "light" : "dark")), normalizeLightOrDarkMode: vi.fn((value) => (value === "dark" ? "dark" : "light")), persistUserLightOrDarkMode: vi.fn(), })); vi.mock("./utils/db/users.db.server", () => ({ getUser: vi.fn(), updateUserProfile: vi.fn(), })); vi.mock("./utils/data/.server/rootData", () => ({ loadRootData: vi.fn(), })); vi.mock("./utils/app/HostRouting.server", () => ({ getCanonicalHostRedirectUrl: vi.fn(() => null), })); vi.mock("./modules/events/services/.server/EventsService", () => ({ default: { create: vi.fn() }, })); vi.mock("./modules/users/services/AuthService", () => ({ default: { loginFromRequest: vi.fn() }, })); vi.mock("./modules/metrics/utils/defaultHeaders.server", () => ({ serverTimingHeaders: vi.fn(), })); vi.mock("./locale/i18next.server", () => ({ getTranslations: vi.fn(async () => ({ t: (key: string) => { const translations: Record = { "settings.theme.errors.invalidTheme": "Invalid theme", }; return translations[key] ?? key; }, })), })); vi.mock("remix-i18next/react", () => ({ useChangeLanguage: vi.fn() })); const { action, loader } = await import("./root"); const { createUserSession, getUserInfo, validateCSRFToken } = await import("./utils/session.server"); const { getUser } = await import("./utils/db/users.db.server"); const { getCanonicalHostRedirectUrl } = await import("./utils/app/HostRouting.server"); const { loadRootData } = await import("./utils/data/.server/rootData"); const EventsService = (await import("./modules/events/services/.server/EventsService")).default; const { nextLightOrDarkMode, persistUserLightOrDarkMode } = await import("./utils/theme/userThemePreference.server"); const { getTranslations } = await import("./locale/i18next.server"); describe("root theme preference action", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(getUserInfo).mockResolvedValue({ userId: "user-1", lightOrDarkMode: "light", lng: "en", cookies: [], }); vi.mocked(getUser).mockResolvedValue({ id: "user-1", email: "user@example.com", firstName: "User", lastName: "Example", avatar: null, phone: null, admin: null, defaultTenantId: null, createdAt: new Date("2026-01-01T00:00:00.000Z"), locale: "en", lightOrDarkMode: "light", }); }); it("redirects legacy host paths before loading root data", async () => { vi.mocked(getCanonicalHostRedirectUrl).mockReturnValueOnce("https://app.example.test/acme/dashboard"); try { await loader({ request: new Request("https://app.example.test/acme/dashboard"), params: {}, context: {}, unstable_pattern: "root", }); throw new Error("Expected root loader to redirect"); } catch (error) { expect(error).toBeInstanceOf(Response); expect((error as Response).status).toBe(308); expect((error as Response).headers.get("Location")).toBe("https://app.example.test/acme/dashboard"); } expect(loadRootData).not.toHaveBeenCalled(); }); it("redirects legacy host actions before reading session or form data", async () => { vi.mocked(getCanonicalHostRedirectUrl).mockReturnValueOnce("https://app.example.test/acme/settings/profile"); try { await action({ request: new Request("https://app.example.test/acme/settings/profile", { method: "POST", body: new URLSearchParams({ action: "setLocale" }) }), params: {}, context: {}, unstable_pattern: "root", }); throw new Error("Expected root action to redirect"); } catch (error) { expect(error).toBeInstanceOf(Response); expect((error as Response).status).toBe(308); expect((error as Response).headers.get("Location")).toBe("https://app.example.test/acme/settings/profile"); } expect(getUserInfo).not.toHaveBeenCalled(); }); async function toggleTheme() { const form = new URLSearchParams({ action: "toggleLightOrDarkMode", redirect: "/settings/profile", csrf: "csrf-token", }); return action({ request: new Request("https://example.test/root", { method: "POST", body: form }), params: {}, context: {}, unstable_pattern: "root", }); } it("persists the toggled light/dark preference before updating the session", async () => { await toggleTheme(); expect(nextLightOrDarkMode).toHaveBeenCalledWith("light"); // Root action pre-parses formData, so the helper now receives the // already-parsed body to avoid a second clone-and-parse round-trip // (B-009 follow-up — body-already-consumed crash). expect(validateCSRFToken).toHaveBeenCalledWith(expect.any(Request), { formData: expect.any(FormData) }); expect(getUser).toHaveBeenCalledWith("user-1"); expect(persistUserLightOrDarkMode).toHaveBeenCalledWith("user-1", "dark"); expect(EventsService.create).toHaveBeenCalledWith({ request: expect.any(Request), event: "user.preferences.updated", tenantId: null, userId: "user-1", data: { old: { lightOrDarkMode: "light" }, new: { lightOrDarkMode: "dark" }, user: { id: "user-1", email: "user@example.com" }, }, }); expect(createUserSession).toHaveBeenCalledWith( { userId: "user-1", lightOrDarkMode: "dark", lng: "en", cookies: [], }, "/settings/profile" ); }); it("does not persist a database preference for anonymous sessions", async () => { vi.mocked(getUserInfo).mockResolvedValueOnce({ userId: "", lightOrDarkMode: "dark", lng: "en", cookies: [], }); await toggleTheme(); expect(getUser).not.toHaveBeenCalled(); expect(persistUserLightOrDarkMode).not.toHaveBeenCalled(); expect(EventsService.create).not.toHaveBeenCalled(); expect(createUserSession).toHaveBeenCalledWith( { userId: "", lightOrDarkMode: "light", lng: "en", cookies: [], }, "/settings/profile" ); }); it("returns a localized error when setting an invalid theme", async () => { const form = new URLSearchParams({ action: "setTheme", redirect: "/settings/profile", csrf: "csrf-token", }); const response = await action({ request: new Request("https://example.test/root", { method: "POST", body: form }), params: {}, context: {}, unstable_pattern: "root", }); expect(getTranslations).toHaveBeenCalledWith(expect.any(Request)); await expect((response as Response).json()).resolves.toEqual({ error: "Invalid theme" }); expect(createUserSession).not.toHaveBeenCalled(); }); });