diff --git a/VueApp/src/CMS/__tests__/cms-home.test.ts b/VueApp/src/CMS/__tests__/cms-home.test.ts index f892de107..1783f3467 100644 --- a/VueApp/src/CMS/__tests__/cms-home.test.ts +++ b/VueApp/src/CMS/__tests__/cms-home.test.ts @@ -138,3 +138,79 @@ describe("cmsHome.vue - permission-gated sections", () => { expect(wrapper.findAllComponents({ name: "QBtn" })).toHaveLength(0) }) }) + +/** + * Delegated editors (no ManageContentBlocks) get a "Blocks you can edit" card listing GET /editable + * results, linking each to its editor. It shows only for non-managers with a non-empty list, and it + * suppresses the no-access banner. Managers use the normal Content Blocks card, so they never fetch + * or render this card. These specs run last because they replace the shared get() implementation. + */ +describe("cmsHome.vue - delegated editable blocks card", () => { + const EDITABLE = [ + { + contentBlockId: 11, + title: "Alpha", + friendlyName: "alpha", + viperSectionPath: "/a", + page: null, + modifiedOn: "2024-01-01T00:00:00", + modifiedBy: "u", + }, + { + contentBlockId: 12, + title: null, + friendlyName: "beta", + viperSectionPath: "/b", + page: null, + modifiedOn: "2024-01-02T00:00:00", + modifiedBy: "u", + }, + ] + + function routeEditable(items: unknown[]) { + getMock.mockImplementation((...args: unknown[]) => { + const url = args[0] as unknown as string + if (url.includes("editable")) { + return Promise.resolve({ success: true, result: items }) + } + return Promise.resolve({ success: true, result: [] }) + }) + } + + function editLinks(wrapper: Awaited>) { + return wrapper + .findAllComponents({ name: "QBtn" }) + .filter((b) => (b.props("to") as { name?: string } | undefined)?.name === "CmsContentBlockEdit") + } + + it("lists editable blocks with edit links and suppresses the no-access banner for a non-manager", async () => { + routeEditable(EDITABLE) + const wrapper = await mountHome(["SVMSecure.CMS"]) + await flushPromises() + await flushPromises() + + expect(wrapper.text()).toContain("Blocks you can edit") + expect(wrapper.text()).not.toContain("does not have access to any CMS tools") + + // Falls back to the friendly name when a block has no title (block 12 -> "beta"). + expect(editLinks(wrapper).map((b) => b.props("label"))).toStrictEqual(["Alpha", "beta"]) + expect(editLinks(wrapper)[0]!.props("to")).toStrictEqual({ + name: "CmsContentBlockEdit", + params: { id: 11 }, + }) + }) + + it("does not fetch or show the editable card for a manager", async () => { + // The shared get() mock accumulates calls across tests; clear it so this assertion only + // sees this manager mount's requests. + getMock.mockClear() + routeEditable(EDITABLE) + const wrapper = await mountHome() // full admin + await flushPromises() + + expect(wrapper.text()).not.toContain("Blocks you can edit") + // Managers still get the normal tool card, and never hit the editable endpoint. + expect(wrapper.text()).toContain("Content Blocks") + expect(getMock.mock.calls.map((c) => c[0] as unknown as string).some((u) => u.includes("editable"))).toBe(false) + }) +}) diff --git a/VueApp/src/CMS/__tests__/content-block-edit-delegated.test.ts b/VueApp/src/CMS/__tests__/content-block-edit-delegated.test.ts new file mode 100644 index 000000000..3d8cf3738 --- /dev/null +++ b/VueApp/src/CMS/__tests__/content-block-edit-delegated.test.ts @@ -0,0 +1,206 @@ +import ContentBlockEdit from "@/CMS/pages/ContentBlockEdit.vue" +import { useUserStore } from "@/store/UserStore" +import { mountCms, flushPromises, createTestRouter } from "./test-utils" + +/** + * Delegated editing: a user WITHOUT ManageContentBlocks/CreateContentBlock who reaches an existing + * block edits only its content and files. The settings sidebar collapses to a read-only summary + * (no inputs, no permission selectors, no public-access toggle), the content editor and attached + * files stay functional, and Save issues the content-only PATCH (with the same lastModifiedOn + * concurrency stamp + 409 conflict dialog as the full save). Mock ViperFetch; the inline uploader + * is stubbed with a controllable commit(). + */ + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +const mockPost = vi.fn<(...args: unknown[]) => unknown>() +const mockPut = vi.fn<(...args: unknown[]) => unknown>() +const mockPatch = vi.fn<(...args: unknown[]) => unknown>() +const mockDel = vi.fn<(...args: unknown[]) => unknown>() +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + put: (...args: unknown[]) => mockPut(...args), + patch: (...args: unknown[]) => mockPatch(...args), + del: (...args: unknown[]) => mockDel(...args), + createUrlSearchParams: (obj: Record) => { + const params = new URLSearchParams() + for (const [k, v] of Object.entries(obj)) { + if (v !== null && v !== undefined) { + params.append(k, v.toString()) + } + } + return params + }, + }), +})) + +const BLOCK = { + contentBlockId: 7, + content: "

hello

", + title: "Welcome", + system: "Viper", + application: null, + page: "home", + viperSectionPath: "/apps", + blockOrder: 2, + friendlyName: "welcome", + allowPublicAccess: false, + modifiedOn: "2024-03-01T12:00:00", + modifiedBy: "editor", + deletedOn: null, + permissions: ["SVMSecure.CMS"], + editPermissions: ["SVMSecure.CMS.Delegate"], + files: [{ fileGuid: "f1", friendlyName: "a.pdf", url: "/files/a.pdf" }], +} + +function routeGet() { + mockGet.mockImplementation((...args: unknown[]) => { + const url = args[0] as string + if (url.includes("/history")) { + return Promise.resolve({ success: true, result: [] }) + } + return Promise.resolve({ success: true, result: structuredClone(BLOCK) }) + }) +} + +// QEditor relies on document.execCommand and rich-text DOM that happy-dom lacks; stub it with a +// minimal v-model textarea so block.content still binds without exercising the real editor. +const qEditorStub = { + name: "QEditor", + props: ["modelValue"], + emits: ["update:modelValue"], + methods: { + getContentEl() { + return null + }, + }, + template: `