Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/root.theme-preference.test.tsx
T

215 lines
7.0 KiB
TypeScript

// @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<string, string> = {
"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();
});
});