215 lines
7.0 KiB
TypeScript
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();
|
|
});
|
|
});
|