From e723b113e0f81b93f6b88f79441afe0a870c80af Mon Sep 17 00:00:00 2001 From: vbeni30 Date: Sun, 6 Sep 2026 01:15:19 +0300 Subject: [PATCH] feat(settings): complete #802 settings API integration gaps --- frontend/app/settings/actions.test.ts | 95 ++++++++ frontend/app/settings/layout.tsx | 63 +++++- frontend/app/settings/page.tsx | 13 ++ .../settings/etl-theme-panel.test.tsx | 95 ++++++++ .../settings/group-roles-panel.test.tsx | 83 +++++++ .../components/settings/roles-panel.test.tsx | 42 +++- .../settings/search-settings-panel.test.tsx | 64 ++++++ .../settings/tags-settings-panel.test.tsx | 85 +++++++ .../settings/user-roles-panel.test.tsx | 81 +++++++ frontend/lib/auth/types.ts | 3 + frontend/lib/settings/api.test.ts | 210 ++++++++++++++++++ frontend/next.config.ts | 8 +- 12 files changed, 825 insertions(+), 17 deletions(-) create mode 100644 frontend/app/settings/actions.test.ts create mode 100644 frontend/components/settings/etl-theme-panel.test.tsx create mode 100644 frontend/components/settings/group-roles-panel.test.tsx create mode 100644 frontend/components/settings/search-settings-panel.test.tsx create mode 100644 frontend/components/settings/tags-settings-panel.test.tsx create mode 100644 frontend/components/settings/user-roles-panel.test.tsx create mode 100644 frontend/lib/settings/api.test.ts diff --git a/frontend/app/settings/actions.test.ts b/frontend/app/settings/actions.test.ts new file mode 100644 index 00000000..13e74237 --- /dev/null +++ b/frontend/app/settings/actions.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})) + +vi.mock("@/lib/settings/api", () => ({ + addSiteMessage: vi.fn(), + deleteSiteMessage: vi.fn(), + updateEtl: vi.fn(), + updateTheme: vi.fn(), + updateSearchVisibility: vi.fn(), + createRole: vi.fn(), + updateRolePermission: vi.fn(), +})) + +import { revalidatePath } from "next/cache" +import { + addSiteMessage, + createRole, + updateRolePermission, + updateSearchVisibility, +} from "@/lib/settings/api" +import { + addSiteMessageAction, + createRoleAction, + updateRolePermissionAction, + updateSearchVisibilityAction, +} from "./actions" + +describe("settings actions", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("addSiteMessageAction returns data and revalidates on success", async () => { + vi.mocked(addSiteMessage).mockResolvedValueOnce({ + ok: true, + data: { id: 1, value: "Hello", description: null }, + }) + + const result = await addSiteMessageAction({ value: "Hello" }) + + expect(result).toEqual({ data: { id: 1, value: "Hello", description: null } }) + expect(revalidatePath).toHaveBeenCalledWith("/settings") + }) + + test("addSiteMessageAction returns error without revalidating on failure", async () => { + vi.mocked(addSiteMessage).mockResolvedValueOnce({ + ok: false, + message: "You do not have permission to view this content.", + code: "forbidden", + }) + + const result = await addSiteMessageAction({ value: "Hello" }) + + expect(result).toEqual({ error: "You do not have permission to view this content." }) + expect(revalidatePath).not.toHaveBeenCalled() + }) + + test("updateSearchVisibilityAction delegates to api layer", async () => { + vi.mocked(updateSearchVisibility).mockResolvedValueOnce({ ok: true, data: undefined }) + + const result = await updateSearchVisibilityAction("users", false) + + expect(updateSearchVisibility).toHaveBeenCalledWith("users", false, undefined) + expect(result).toEqual({ data: {} }) + expect(revalidatePath).toHaveBeenCalledWith("/settings") + }) + + test("createRoleAction returns validation error from api", async () => { + vi.mocked(createRole).mockResolvedValueOnce({ + ok: false, + message: "Name is required.", + code: "bad_request", + }) + + const result = await createRoleAction({ name: "" }) + + expect(result).toEqual({ error: "Name is required." }) + }) + + test("updateRolePermissionAction returns forbidden error", async () => { + vi.mocked(updateRolePermission).mockResolvedValueOnce({ + ok: false, + message: "You do not have permission to view this content.", + code: "forbidden", + }) + + const result = await updateRolePermissionAction(2, 4, true) + + expect(result).toEqual({ error: "You do not have permission to view this content." }) + expect(revalidatePath).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/app/settings/layout.tsx b/frontend/app/settings/layout.tsx index e389288c..af2cd223 100644 --- a/frontend/app/settings/layout.tsx +++ b/frontend/app/settings/layout.tsx @@ -18,19 +18,64 @@ export default async function SettingsLayout({ children }: { children: ReactNode const canEditRoles = !!user && hasPermission(user, "Edit Role Permissions") const canEditUsers = !!user && hasPermission(user, "Edit User Permissions") const canEditGroups = !!user && hasPermission(user, "Edit Group Permissions") + const canManageMetaFields = + !!user && (hasPermission(user, "Create Parameters") || hasPermission(user, "Delete Parameters")) + const canManageSiteSettings = !!user && hasPermission(user, "Manage Global Site Settings") const navItems = [ - { href: "#roles", label: "Role Configuration", icon: "fa-lock", show: canEditRoles }, - { href: "#user-roles", label: "User Roles", icon: "fa-user-lock", show: canEditUsers }, - { href: "#user-groups", label: "Group Roles", icon: "fa-users", show: canEditGroups }, - { href: "#meta-fields", label: "Meta Fields", icon: "fa-list-ul", show: true }, - { href: "#site-message", label: "Site Message", icon: "fa-comment", show: true }, - { href: "#search", label: "Search", icon: "fa-search", show: true }, - { href: "#theme", label: "Theme", icon: "fa-palette", show: true }, - { href: "#etl", label: "ETL", icon: "fa-database", show: true }, + { + id: "roles", + href: "#roles", + label: "Role Configuration", + icon: "fa-lock", + show: canEditRoles, + }, + { + id: "user-roles", + href: "#user-roles", + label: "User Roles", + icon: "fa-user-lock", + show: canEditUsers, + }, + { + id: "user-groups", + href: "#user-groups", + label: "Group Roles", + icon: "fa-users", + show: canEditGroups, + }, + { + id: "meta-fields", + href: "#meta-fields", + label: "Meta Fields", + icon: "fa-list-ul", + show: canManageMetaFields, + }, + { + id: "site-message", + href: "#site-message", + label: "Site Message", + icon: "fa-comment", + show: canManageSiteSettings, + }, + { + id: "search", + href: "#search", + label: "Search", + icon: "fa-search", + show: canManageSiteSettings, + }, + { + id: "theme", + href: "#theme", + label: "Theme", + icon: "fa-palette", + show: canManageSiteSettings, + }, + { id: "etl", href: "#etl", label: "ETL", icon: "fa-database", show: canManageSiteSettings }, ].filter((item) => item.show) - const defaultTab = canEditRoles ? "roles" : canEditUsers ? "user-roles" : "meta-fields" + const defaultTab = navItems[0]?.id ?? "roles" return ( Boolean(message)) + return ( <> {/* Tab panels — the SettingsTabController client component handles visibility via 'hidden' */} @@ -102,6 +114,7 @@ export default async function SettingsPage() {
+ {tagLoadErrors.length > 0 &&

{tagLoadErrors.join(" ")}

} ({ + updateEtlAction: vi.fn(), + updateThemeAction: vi.fn(), +})) + +describe("EtlThemePanel", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("saves ETL script successfully", async () => { + const user = userEvent.setup() + vi.mocked(updateEtlAction).mockResolvedValueOnce({ data: {} }) + + render( + , + ) + + await user.click(screen.getByRole("button", { name: /save etl/i })) + + await waitFor(() => { + expect(updateEtlAction).toHaveBeenCalledWith("SELECT 1") + expect(screen.getByText("ETL script saved.")).toBeInTheDocument() + }) + }) + + it("shows forbidden error when ETL save fails", async () => { + const user = userEvent.setup() + vi.mocked(updateEtlAction).mockResolvedValueOnce({ + error: "You do not have permission to view this content.", + }) + + render() + + await user.click(screen.getByRole("button", { name: /save etl/i })) + + await waitFor(() => { + expect( + screen.getByText("You do not have permission to view this content."), + ).toBeInTheDocument() + }) + }) + + it("restores default ETL into the textarea", async () => { + const user = userEvent.setup() + + render( + , + ) + + const textarea = screen.getByLabelText(/sql script/i) + expect(textarea).toHaveValue("custom sql") + + await user.click(screen.getByRole("button", { name: /restore default/i })) + + expect(textarea).toHaveValue("SELECT default") + }) + + it("saves theme CSS successfully", async () => { + const user = userEvent.setup() + vi.mocked(updateThemeAction).mockResolvedValueOnce({ data: {} }) + + render( + , + ) + + await user.click(screen.getByRole("button", { name: /save theme/i })) + + await waitFor(() => { + expect(updateThemeAction).toHaveBeenCalledWith("body { color: red; }") + expect(screen.getByText("Theme saved.")).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/components/settings/group-roles-panel.test.tsx b/frontend/components/settings/group-roles-panel.test.tsx new file mode 100644 index 00000000..541541a6 --- /dev/null +++ b/frontend/components/settings/group-roles-panel.test.tsx @@ -0,0 +1,83 @@ +import { render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { removeGroupRoleAction } from "@/app/settings/actions" +import { GroupRolesPanel } from "./group-roles-panel" + +vi.mock("@/app/settings/actions", () => ({ + addGroupRoleAction: vi.fn(), + removeGroupRoleAction: vi.fn(), + searchSettingsGroupsAction: vi.fn(), +})) + +vi.mock("@/components/settings/settings-typeahead", () => ({ + SettingsTypeahead: () =>
, +})) + +const AVAILABLE_ROLES = [ + { id: 1, name: "Administrator", permissions: [] }, + { id: 10, name: "Manager", permissions: [] }, +] + +const INITIAL_ASSIGNMENTS = [ + { groupId: 5, name: "Finance Team", roles: [{ id: 10, name: "Manager" }] }, +] + +describe("GroupRolesPanel", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders privileged groups", () => { + render( + , + ) + expect(screen.getByText("Finance Team")).toBeInTheDocument() + expect(screen.getAllByText("Manager").length).toBeGreaterThan(0) + }) + + it("shows validation error when group and role are missing", async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole("button", { name: /^save$/i })) + + expect(screen.getByText("Group and role are required.")).toBeInTheDocument() + }) + + it("shows forbidden error when remove fails", async () => { + const user = userEvent.setup() + vi.mocked(removeGroupRoleAction).mockResolvedValueOnce({ + error: "You do not have permission to view this content.", + }) + + render( + , + ) + + await user.click(screen.getByRole("button", { name: /remove role manager from finance team/i })) + + await waitFor(() => { + expect( + screen.getByText("You do not have permission to view this content."), + ).toBeInTheDocument() + }) + }) + + it("removes a group role successfully", async () => { + const user = userEvent.setup() + vi.mocked(removeGroupRoleAction).mockResolvedValueOnce({ data: {} }) + + render( + , + ) + + await user.click(screen.getByRole("button", { name: /remove role manager from finance team/i })) + + await waitFor(() => { + expect(removeGroupRoleAction).toHaveBeenCalledWith(5, 10) + expect(screen.queryByText("Finance Team")).not.toBeInTheDocument() + }) + }) +}) diff --git a/frontend/components/settings/roles-panel.test.tsx b/frontend/components/settings/roles-panel.test.tsx index f0dc7e7b..5aa03d82 100644 --- a/frontend/components/settings/roles-panel.test.tsx +++ b/frontend/components/settings/roles-panel.test.tsx @@ -1,7 +1,11 @@ import { render, screen, waitFor } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { beforeEach, describe, expect, it, vi } from "vitest" -import { deleteRoleAction, updateRolePermissionAction } from "@/app/settings/actions" +import { + deleteRoleAction, + updateRolePermissionAction, + createRoleAction, +} from "@/app/settings/actions" import type { PermissionDto, RoleDto } from "@/lib/settings/types" import { RolesPanel } from "./roles-panel" @@ -85,4 +89,40 @@ describe("RolesPanel", () => { expect(deleteRoleAction).toHaveBeenCalledWith(10) }) }) + + it("creates a role successfully", async () => { + const user = userEvent.setup() + vi.mocked(createRoleAction).mockResolvedValueOnce({ + data: { id: 99, name: "Executive", permissions: [] }, + }) + + render() + + const input = screen.getByPlaceholderText("executive") + await user.type(input, "Executive") + await user.click(screen.getByRole("button", { name: /^save$/i })) + + await waitFor(() => { + expect(createRoleAction).toHaveBeenCalledWith({ name: "Executive" }) + expect(input).toHaveValue("") + }) + }) + + it("shows forbidden error when create role fails", async () => { + const user = userEvent.setup() + vi.mocked(createRoleAction).mockResolvedValueOnce({ + error: "You do not have permission to view this content.", + }) + + render() + + await user.type(screen.getByPlaceholderText("executive"), "Executive") + await user.click(screen.getByRole("button", { name: /^save$/i })) + + await waitFor(() => { + expect( + screen.getByText("You do not have permission to view this content."), + ).toBeInTheDocument() + }) + }) }) diff --git a/frontend/components/settings/search-settings-panel.test.tsx b/frontend/components/settings/search-settings-panel.test.tsx new file mode 100644 index 00000000..cf0d114c --- /dev/null +++ b/frontend/components/settings/search-settings-panel.test.tsx @@ -0,0 +1,64 @@ +import { render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { + updateSearchReportTypeTextAction, + updateSearchVisibilityAction, +} from "@/app/settings/actions" +import { SearchSettingsPanel } from "./search-settings-panel" + +vi.mock("@/app/settings/actions", () => ({ + updateSearchVisibilityAction: vi.fn(), + updateSearchReportTypeTextAction: vi.fn(), +})) + +const INITIAL_DATA = { + visibility: { users: "Y", groups: "N", terms: "Y", initiatives: "Y", collections: "Y" }, + reportTypes: [{ id: 7, name: "Dashboard", shortName: "Dash", visible: true }], +} + +describe("SearchSettingsPanel", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders object visibility toggles", () => { + render() + expect(screen.getByText("Users")).toBeInTheDocument() + expect(screen.getByText("Groups")).toBeInTheDocument() + }) + + it("shows forbidden error when visibility update fails", async () => { + const user = userEvent.setup() + vi.mocked(updateSearchVisibilityAction).mockResolvedValueOnce({ + error: "You do not have permission to view this content.", + }) + + render() + + const checkboxes = screen.getAllByRole("checkbox") + await user.click(checkboxes[1]) + + await waitFor(() => { + expect( + screen.getByText("You do not have permission to view this content."), + ).toBeInTheDocument() + }) + }) + + it("saves report type text override", async () => { + const user = userEvent.setup() + vi.mocked(updateSearchReportTypeTextAction).mockResolvedValueOnce({ data: {} }) + + render() + + const input = screen.getByPlaceholderText("Dashboard") + await user.clear(input) + await user.type(input, "Executive Dashboard") + await user.click(screen.getByRole("button", { name: /^save$/i })) + + await waitFor(() => { + expect(updateSearchReportTypeTextAction).toHaveBeenCalledWith(7, "Executive Dashboard") + }) + }) +}) diff --git a/frontend/components/settings/tags-settings-panel.test.tsx b/frontend/components/settings/tags-settings-panel.test.tsx new file mode 100644 index 00000000..ea009d73 --- /dev/null +++ b/frontend/components/settings/tags-settings-panel.test.tsx @@ -0,0 +1,85 @@ +import { render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { createTagAction, deleteTagAction } from "@/app/settings/actions" +import { TagsSettingsPanel } from "./tags-settings-panel" + +vi.mock("@/app/settings/actions", () => ({ + createTagAction: vi.fn(), + deleteTagAction: vi.fn(), +})) + +const EMPTY_PROPS = { + organizationalValues: [], + estimatedRunFrequencies: [], + fragilities: [], + fragilityTags: [], + maintenanceSchedules: [], + maintenanceLogStatuses: [], + financialImpacts: [], + strategicImportances: [], + tags: [], +} + +describe("TagsSettingsPanel", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(window, "confirm").mockReturnValue(true) + }) + + it("adds a tag successfully", async () => { + const user = userEvent.setup() + vi.mocked(createTagAction).mockResolvedValueOnce({ + data: { id: 9, name: "Critical", description: null, used: 0 }, + }) + + render() + + const input = screen.getByPlaceholderText(/add organizational value/i) + await user.type(input, "Critical") + await user.click(screen.getAllByRole("button", { name: /^add$/i })[0]) + + await waitFor(() => { + expect(createTagAction).toHaveBeenCalledWith("organizational-values", { name: "Critical" }) + expect(screen.getByText("Critical")).toBeInTheDocument() + }) + }) + + it("shows forbidden error when create fails", async () => { + const user = userEvent.setup() + vi.mocked(createTagAction).mockResolvedValueOnce({ + error: "You do not have permission to view this content.", + }) + + render() + + const input = screen.getByPlaceholderText(/add organizational value/i) + await user.type(input, "Blocked") + await user.click(screen.getAllByRole("button", { name: /^add$/i })[0]) + + await waitFor(() => { + expect( + screen.getByText("You do not have permission to view this content."), + ).toBeInTheDocument() + }) + }) + + it("deletes a tag successfully", async () => { + const user = userEvent.setup() + vi.mocked(deleteTagAction).mockResolvedValueOnce({ data: {} }) + + render( + , + ) + + await user.click(screen.getByRole("button", { name: /delete legacy/i })) + + await waitFor(() => { + expect(deleteTagAction).toHaveBeenCalledWith("organizational-values", 3) + expect(screen.queryByText("Legacy")).not.toBeInTheDocument() + }) + }) +}) diff --git a/frontend/components/settings/user-roles-panel.test.tsx b/frontend/components/settings/user-roles-panel.test.tsx new file mode 100644 index 00000000..33d019ac --- /dev/null +++ b/frontend/components/settings/user-roles-panel.test.tsx @@ -0,0 +1,81 @@ +import { render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { removeUserRoleAction } from "@/app/settings/actions" +import { UserRolesPanel } from "./user-roles-panel" + +vi.mock("@/app/settings/actions", () => ({ + addUserRoleAction: vi.fn(), + removeUserRoleAction: vi.fn(), + searchSettingsUsersAction: vi.fn(), +})) + +vi.mock("@/components/settings/settings-typeahead", () => ({ + SettingsTypeahead: () =>
, +})) + +const AVAILABLE_ROLES = [ + { id: 1, name: "Administrator", permissions: [] }, + { id: 10, name: "Manager", permissions: [] }, +] + +const INITIAL_ASSIGNMENTS = [{ userId: 42, name: "Jane Doe", roles: [{ id: 10, name: "Manager" }] }] + +describe("UserRolesPanel", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders privileged users", () => { + render( + , + ) + expect(screen.getByText("Jane Doe")).toBeInTheDocument() + expect(screen.getAllByText("Manager").length).toBeGreaterThan(0) + }) + + it("shows validation error when user and role are missing", async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole("button", { name: /^save$/i })) + + expect(screen.getByText("User and role are required.")).toBeInTheDocument() + }) + + it("shows forbidden error when remove fails", async () => { + const user = userEvent.setup() + vi.mocked(removeUserRoleAction).mockResolvedValueOnce({ + error: "You do not have permission to view this content.", + }) + + render( + , + ) + + await user.click(screen.getByRole("button", { name: /remove role manager from jane doe/i })) + + await waitFor(() => { + expect( + screen.getByText("You do not have permission to view this content."), + ).toBeInTheDocument() + }) + }) + + it("removes a user role successfully", async () => { + const user = userEvent.setup() + vi.mocked(removeUserRoleAction).mockResolvedValueOnce({ data: {} }) + + render( + , + ) + + await user.click(screen.getByRole("button", { name: /remove role manager from jane doe/i })) + + await waitFor(() => { + expect(removeUserRoleAction).toHaveBeenCalledWith(42, 10) + expect(screen.queryByText("Jane Doe")).not.toBeInTheDocument() + }) + }) +}) diff --git a/frontend/lib/auth/types.ts b/frontend/lib/auth/types.ts index 50ccb768..9a8c1941 100644 --- a/frontend/lib/auth/types.ts +++ b/frontend/lib/auth/types.ts @@ -17,6 +17,9 @@ export const PERMISSIONS = [ "Delete Collection", "Create Initiative", "View Other User", + "Manage Global Site Settings", + "Create Parameters", + "Delete Parameters", ] as const export type Permission = (typeof PERMISSIONS)[number] diff --git a/frontend/lib/settings/api.test.ts b/frontend/lib/settings/api.test.ts new file mode 100644 index 00000000..0d435ca3 --- /dev/null +++ b/frontend/lib/settings/api.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" +import { AppError } from "@/lib/app-error" + +vi.mock("@/lib/auth", () => ({ + getToken: vi.fn(async () => "test-token"), +})) + +vi.mock("@/lib/api-base", () => ({ + getServerApiBase: vi.fn(() => "https://api.example.test"), +})) + +vi.mock("@/lib/http", () => ({ + apiFetchJson: vi.fn(), +})) + +import { getServerApiBase } from "@/lib/api-base" +import { getToken } from "@/lib/auth" +import { apiFetchJson } from "@/lib/http" +import { + addSiteMessage, + createTag, + deleteSiteMessage, + getSiteMessages, + updateRolePermission, + updateSearchVisibility, +} from "./api" + +const BASE = "https://api.example.test" + +function mockFetch(status: number, body: unknown = {}, contentType = "application/json") { + global.fetch = vi.fn(async () => { + if (status === 204) { + return new Response(null, { status: 204 }) + } + return new Response(typeof body === "string" ? body : JSON.stringify(body), { + status, + headers: { "Content-Type": contentType }, + }) + }) as typeof fetch +} + +describe("settings api", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getToken).mockResolvedValue("test-token") + vi.mocked(getServerApiBase).mockReturnValue(BASE) + }) + + describe("getSiteMessages", () => { + test("returns data on successful GET", async () => { + vi.mocked(apiFetchJson).mockResolvedValueOnce({ + ok: true, + data: [{ id: 1, value: "Welcome", description: null }], + }) + + const result = await getSiteMessages() + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data).toHaveLength(1) + } + expect(apiFetchJson).toHaveBeenCalledWith(`${BASE}/api/settings/site-messages`, { + headers: { Authorization: "Bearer test-token" }, + cache: "no-store", + }) + }) + + test("returns auth_required when token is missing", async () => { + vi.mocked(getToken).mockResolvedValue(null) + + const result = await getSiteMessages() + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe("auth_required") + } + }) + + test("returns forbidden message on 403 GET", async () => { + vi.mocked(apiFetchJson).mockResolvedValueOnce({ + ok: false, + error: new AppError({ code: "forbidden", status: 403 }), + }) + + const result = await getSiteMessages() + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe("forbidden") + expect(result.message).toMatch(/permission/i) + } + }) + }) + + describe("addSiteMessage", () => { + test("POSTs to the correct endpoint with body", async () => { + mockFetch(200, { id: 2, value: "New", description: null }) + + const result = await addSiteMessage({ value: "New" }) + + expect(result.ok).toBe(true) + expect(global.fetch).toHaveBeenCalledWith( + `${BASE}/api/settings/site-messages`, + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ value: "New" }), + }), + ) + }) + + test("returns validation error on 400", async () => { + mockFetch(400, { error: "Value is required." }) + + const result = await addSiteMessage({ value: "" }) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.message).toBe("Value is required.") + expect(result.code).toBe("bad_request") + } + }) + + test("returns forbidden on 403", async () => { + mockFetch(403, { error: "Forbidden" }) + + const result = await addSiteMessage({ value: "Test" }) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe("forbidden") + } + }) + }) + + describe("deleteSiteMessage", () => { + test("DELETEs the correct resource", async () => { + mockFetch(204) + + const result = await deleteSiteMessage(5) + + expect(result.ok).toBe(true) + expect(global.fetch).toHaveBeenCalledWith( + `${BASE}/api/settings/site-messages/5`, + expect.objectContaining({ method: "DELETE" }), + ) + }) + }) + + describe("updateSearchVisibility", () => { + test("PUTs visibility with reportTypeId query param", async () => { + mockFetch(204) + + const result = await updateSearchVisibility("reports", true, 42) + + expect(result.ok).toBe(true) + expect(global.fetch).toHaveBeenCalledWith( + `${BASE}/api/settings/search/reports/visibility?reportTypeId=42`, + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ visible: true }), + }), + ) + }) + }) + + describe("createTag", () => { + test("POSTs tag to the correct type endpoint", async () => { + mockFetch(200, { id: 1, name: "High", description: null, used: 0 }) + + const result = await createTag("organizational-values", { name: "High" }) + + expect(result.ok).toBe(true) + expect(global.fetch).toHaveBeenCalledWith( + `${BASE}/api/settings/tags/organizational-values`, + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ name: "High" }), + }), + ) + }) + }) + + describe("updateRolePermission", () => { + test("PUTs permission toggle", async () => { + mockFetch(204) + + const result = await updateRolePermission(2, 4, true) + + expect(result.ok).toBe(true) + expect(global.fetch).toHaveBeenCalledWith( + `${BASE}/api/settings/roles/2/permissions/4`, + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ enabled: true }), + }), + ) + }) + + test("returns service_unavailable when api base is missing", async () => { + vi.mocked(getServerApiBase).mockReturnValue(undefined as unknown as string) + + const result = await updateRolePermission(2, 4, true) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe("service_unavailable") + } + }) + }) +}) diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 08595581..6329572a 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -8,13 +8,7 @@ const nextConfig: NextConfig = { const apiBase = process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL if (!apiBase) return [] const normalized = apiBase.replace(/\/+$/, "") - const legacyPageRoutes = [ - "/settings", - "/analytics", - "/tasks", - "/terms", - "/users/settings", - ] + const legacyPageRoutes = ["/analytics", "/tasks", "/terms", "/users/settings"] const legacyAssetRoutes = ["/css/:path*", "/js/:path*", "/font/:path*", "/img/:path*"] return [