Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/routes/admin/settings/analytics.test.tsx
T

213 lines
7.8 KiB
TypeScript

import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter } from "react-router";
import AdminSettingsAnalytics, { action } from "./analytics";
import { getOrCreateAppConfiguration, updateAppConfiguration } from "~/utils/db/appConfiguration.db.server";
import { createAdminLog } from "~/utils/db/logs.db.server";
import { verifyUserHasPermission } from "~/utils/helpers/.server/PermissionsService";
// EU-Sterilisation (R-P1a): Google-Analytics-Feld aus den Admin-Analytics-Settings entfernt.
// Verbleibend sind die datenschutzfreundlichen EU-Tracker SimpleAnalytics + Plausible.
let routeLoaderData = {
title: "Analytics Settings",
appConfiguration: {
analytics: {
enabled: true,
simpleAnalytics: false,
plausibleAnalytics: true,
},
},
};
let routeActionData: { success?: string; error?: string; fieldErrors?: Record<string, string[]> } | undefined;
let canUpdate = true;
vi.mock("react-router", async () => {
const actual = await vi.importActual<typeof import("react-router")>("react-router");
return {
...actual,
Form: ({ children, ...props }: React.ComponentProps<"form">) => <form {...props}>{children}</form>,
useLoaderData: () => routeLoaderData,
useActionData: () => routeActionData,
};
});
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("~/locale/i18next.server", () => ({
getTranslations: vi.fn(async () => ({ t: (key: string) => key })),
}));
vi.mock("~/utils/data/useAdminData", () => ({
useAdminData: () => ({ permissions: canUpdate ? ["admin.settings.analytics.update"] : [] }),
}));
vi.mock("~/utils/helpers/PermissionsHelper", () => ({
getUserHasPermission: vi.fn(() => canUpdate),
}));
vi.mock("~/utils/helpers/.server/PermissionsService", () => ({
verifyUserHasPermission: vi.fn(),
}));
vi.mock("~/utils/session.server", () => ({
validateCSRFToken: vi.fn(),
}));
vi.mock("~/utils/data/useRootData", () => ({
useRootData: () => ({ csrf: "csrf-test-token" }),
}));
vi.mock("~/utils/db/appConfiguration.db.server", () => ({
getAppConfiguration: vi.fn(),
getOrCreateAppConfiguration: vi.fn(),
updateAppConfiguration: vi.fn(),
}));
vi.mock("~/utils/db/logs.db.server", () => ({
createAdminLog: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
vi.mock("~/components/ui/layouts/EditPageLayout", () => ({
default: ({ children }: { children: React.ReactNode }) => <main>{children}</main>,
}));
vi.mock("~/components/ui/buttons/BackButtonWithTitle", () => ({
default: ({ children }: { children: React.ReactNode }) => <h1>{children}</h1>,
}));
vi.mock("~/components/ui/sections/SettingSection", () => ({
default: ({ title, children }: { title: React.ReactNode; children: React.ReactNode }) => (
<section>
{title}
{children}
</section>
),
}));
vi.mock("~/components/ui/input/InputCheckboxWithDescription", () => ({
default: ({ name, title, value, disabled }: { name: string; title: string; value?: boolean; disabled?: boolean }) => (
<label>
{title}
<input name={name} type="checkbox" defaultChecked={value} disabled={disabled} />
</label>
),
}));
vi.mock("~/components/ui/button", () => ({
Button: ({ children, ...props }: React.ComponentProps<"button">) => <button {...props}>{children}</button>,
}));
const verifyUserHasPermissionMock = vi.mocked(verifyUserHasPermission);
const getOrCreateAppConfigurationMock = vi.mocked(getOrCreateAppConfiguration);
const updateAppConfigurationMock = vi.mocked(updateAppConfiguration);
const createAdminLogMock = vi.mocked(createAdminLog);
function postRequest(form: FormData) {
return new Request("https://admin.example.com/settings/analytics?token=secret", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(form as unknown as Record<string, string>),
});
}
function validForm() {
const form = new FormData();
form.set("action", "update");
form.set("enabled", "on");
form.set("simpleAnalytics", "on");
return form;
}
describe("admin analytics settings route", () => {
beforeEach(() => {
vi.clearAllMocks();
routeActionData = undefined;
canUpdate = true;
verifyUserHasPermissionMock.mockResolvedValue(undefined as never);
getOrCreateAppConfigurationMock.mockResolvedValue(undefined as never);
updateAppConfigurationMock.mockResolvedValue(undefined as never);
createAdminLogMock.mockResolvedValue(undefined as never);
});
it("rejects invalid actions before permission, database, or audit side effects", async () => {
const form = validForm();
form.set("action", "delete");
const response = await action({ request: postRequest(form), params: {}, context: {}, unstable_pattern: "admin/settings/analytics" });
const body = await (response as Response).json();
expect((response as Response).status).toBe(400);
expect(body.fieldErrors.action).toEqual(["Invalid action."]);
expect(verifyUserHasPermissionMock).not.toHaveBeenCalled();
expect(getOrCreateAppConfigurationMock).not.toHaveBeenCalled();
expect(updateAppConfigurationMock).not.toHaveBeenCalled();
expect(createAdminLogMock).not.toHaveBeenCalled();
});
it("propagates permission rejection for valid mutations without database or audit side effects", async () => {
const rejection = new Response("Forbidden", { status: 403 });
verifyUserHasPermissionMock.mockRejectedValue(rejection);
await expect(action({ request: postRequest(validForm()), params: {}, context: {}, unstable_pattern: "admin/settings/analytics" })).rejects.toBe(rejection);
expect(verifyUserHasPermissionMock).toHaveBeenCalledWith(expect.any(Request), "admin.settings.analytics.update");
expect(getOrCreateAppConfigurationMock).not.toHaveBeenCalled();
expect(updateAppConfigurationMock).not.toHaveBeenCalled();
expect(createAdminLogMock).not.toHaveBeenCalled();
});
it("updates valid settings and writes an audit payload", async () => {
const response = await action({ request: postRequest(validForm()), params: {}, context: {}, unstable_pattern: "admin/settings/analytics" });
const body = await (response as Response).json();
expect((response as Response).status).toBe(200);
expect(body.success).toBe("shared.updated");
expect(updateAppConfigurationMock).toHaveBeenCalledWith({
analyticsEnabled: true,
analyticsSimpleAnalytics: true,
analyticsPlausibleAnalytics: false,
});
expect(createAdminLogMock).toHaveBeenCalledWith(expect.any(Request), "admin.settings.updated", expect.any(String));
const auditPayload = createAdminLogMock.mock.calls[0][2];
expect(auditPayload).toContain("analytics");
expect(auditPayload).toContain("analyticsEnabled");
expect(auditPayload).toContain('"analyticsEnabled":true');
expect(auditPayload).not.toContain("token=secret");
});
it("renders accessible action and field errors", () => {
routeActionData = { error: "shared.invalidForm", fieldErrors: { action: ["Invalid action."] } };
render(
<MemoryRouter>
<AdminSettingsAnalytics />
</MemoryRouter>
);
const alert = screen.getByRole("alert");
expect(alert).toHaveTextContent("shared.invalidForm");
expect(alert).toHaveTextContent("action: Invalid action.");
});
it("preserves save button and checkbox disabled state when the admin lacks update permission", () => {
canUpdate = false;
render(
<MemoryRouter>
<AdminSettingsAnalytics />
</MemoryRouter>
);
expect(screen.getByLabelText("shared.enabled")).toBeDisabled();
expect(screen.getByLabelText("analytics.configuration.simpleAnalyticsEnabled")).toBeDisabled();
expect(screen.getByRole("button", { name: "shared.save" })).toBeDisabled();
});
});