Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VueApp/src/CAHFS/router/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createSpaRouter } from "@/shared/createSpaRouter"
import { createSpaRouter } from "@/shared/create-spa-router"
import { routes } from "./routes"
import { useRequireLogin } from "@/composables/RequireLogin"
import { checkHasOnePermission } from "@/composables/CheckPagePermission"
Expand Down
140 changes: 140 additions & 0 deletions VueApp/src/CMS/__tests__/cms-home.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import CmsHome from "@/CMS/pages/CmsHome.vue"
import { useUserStore } from "@/store/UserStore"
import { mountCms, flushPromises } from "./test-utils"

// The hub checks the trash for files nearing the purge cutoff (file managers only).
const getMock = vi.fn<(...args: unknown[]) => Promise<{ success: boolean; result: unknown[] }>>(() =>
Promise.resolve({ success: true, result: [] }),
)
vi.mock("@/composables/ViperFetch", () => ({
useFetch: () => ({
get: getMock,
createUrlSearchParams: (obj: Record<string, string | number | null | undefined>) => {
const params = new URLSearchParams()
for (const [k, v] of Object.entries(obj)) {
if (v !== null && v !== undefined) {
params.append(k, v.toString())
}
}
return params
},
}),
}))

function trashedFile(friendlyName: string, purgeOn: string) {
return { fileGuid: `g-${friendlyName}`, friendlyName, purgeOn, deletedOn: "2024-01-01T00:00:00" }
}

function daysFromNow(days: number): string {
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString()
}

/**
* CmsHome is the permission-gated hub: each tool card (and each action inside a card) only shows
* when the user holds one of its permissions, the recent-activity rail mirrors the manage
* permissions, and users with no CMS access get an explanatory banner instead. RecentActivity is
* stubbed (it fetches its own data); these tests only assert which props gate its panels.
*/

const recentActivityStub = {
name: "RecentActivity",
props: ["showBlocks", "showFiles", "showLeftNavs"],
template: "<div class='recent-activity-stub' />",
}

// MountCms seeds the full CMS admin permission set; narrower personas are applied afterwards
// through the same user store the page reads reactively.
async function mountHome(permissions?: string[]) {
const wrapper = mountCms(CmsHome, { global: { stubs: { RecentActivity: recentActivityStub } } })
if (permissions) {
useUserStore().setPermissions(permissions)
await flushPromises()
}
await flushPromises()
return wrapper
}

function actionLabels(wrapper: Awaited<ReturnType<typeof mountHome>>): string[] {
return wrapper.findAllComponents({ name: "QBtn" }).map((b) => b.props("label") as string)
}

describe("cmsHome.vue - permission-gated sections", () => {
it("shows every tool card and the full activity rail for a CMS admin", async () => {
const wrapper = await mountHome()

// One box per left-nav group, in the nav's order (Link Collections is an action
// inside Content Blocks, mirroring CmsNavMenu.cs).
// h2 text includes the leading icon ligature name in happy-dom, hence stringContaining.
expect(wrapper.findAll("h2").map((h) => h.text())).toStrictEqual([
expect.stringContaining("Content Blocks"),
expect.stringContaining("Files"),
expect.stringContaining("Left Navigation"),
])
expect(actionLabels(wrapper)).toContain("Manage Link Collections")
const rail = wrapper.findComponent({ name: "RecentActivity" })
expect(rail.props("showBlocks")).toBeTruthy()
expect(rail.props("showFiles")).toBeTruthy()
expect(rail.props("showLeftNavs")).toBeTruthy()
})

it("shows a create-only user just the Content Blocks card with only the Add action", async () => {
const wrapper = await mountHome(["SVMSecure.CMS", "SVMSecure.CMS.CreateContentBlock"])

expect(wrapper.text()).toContain("Content Blocks")
expect(wrapper.text()).not.toContain("Link Collections")
expect(wrapper.text()).not.toContain("Left Navigation")
// Manage/History require ManageContentBlocks even inside a visible section.
expect(actionLabels(wrapper)).toStrictEqual(["Add Content Block"])
// No manage permission at all: the activity rail is hidden entirely.
expect(wrapper.findComponent({ name: "RecentActivity" }).exists()).toBeFalsy()
})

it("shows a files-only user the Files card, including the pre-filtered Trash deep-link", async () => {
const wrapper = await mountHome(["SVMSecure.CMS", "SVMSecure.CMS.AllFiles"])

expect(actionLabels(wrapper)).toStrictEqual(["Manage Files", "Add File", "Audit Trail", "Trash"])
const addFile = wrapper.findAllComponents({ name: "QBtn" }).find((b) => b.props("label") === "Add File")!
expect(addFile.props("to")).toStrictEqual({ name: "CmsFiles", query: { upload: "1" } })
const trash = wrapper.findAllComponents({ name: "QBtn" }).find((b) => b.props("label") === "Trash")!
expect(trash.props("to")).toStrictEqual({ name: "CmsFiles", query: { status: "deleted" } })

const rail = wrapper.findComponent({ name: "RecentActivity" })
expect(rail.props("showFiles")).toBeTruthy()
expect(rail.props("showBlocks")).toBeFalsy()
expect(rail.props("showLeftNavs")).toBeFalsy()
})

it("warns file managers when trashed files purge within a week, linking to the Trash", async () => {
getMock.mockResolvedValueOnce({
success: true,
result: [
trashedFile("soon.pdf", daysFromNow(2)),
trashedFile("soon2.pdf", daysFromNow(6)),
trashedFile("later.pdf", daysFromNow(20)),
],
})
const wrapper = await mountHome()

expect(wrapper.text()).toContain("2 trashed files will be permanently deleted within 7 days.")
expect(wrapper.find("a[href*='status=deleted']").exists()).toBeTruthy()
const trashFetch = getMock.mock.calls
.map((c) => c[0] as unknown as string)
.find((u) => u.includes("status=deleted"))!
expect(trashFetch).toContain("sortBy=deletedOn")
expect(trashFetch).toContain("descending=false")
})

it("shows no purge warning when nothing in the trash purges soon", async () => {
getMock.mockResolvedValueOnce({ success: true, result: [trashedFile("later.pdf", daysFromNow(20))] })
const wrapper = await mountHome()

expect(wrapper.text()).not.toContain("permanently deleted within")
})

it("tells a user with no CMS tool permissions that they have no access", async () => {
const wrapper = await mountHome(["SVMSecure.CMS"])

expect(wrapper.text()).toContain("Your account does not have access to any CMS tools.")
expect(wrapper.findAllComponents({ name: "QBtn" })).toHaveLength(0)
})
})
166 changes: 166 additions & 0 deletions VueApp/src/CMS/__tests__/content-block-edit-diff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import ContentBlockEdit from "@/CMS/pages/ContentBlockEdit.vue"
import { mountCms, flushPromises, createTestRouter } from "./test-utils"

/**
* Diff additions to ContentBlockEdit: selecting a version only sets selectedHistory (it no longer
* auto-loads), the "Diff vs current" / "Load into editor" buttons are disabled until a version is
* selected, and diffAgainstCurrent POSTs the editor's CURRENT content to the diff endpoint, opens
* ContentDiffDialog with the returned HTML, and notifies + closes on failure. Mock ViperFetch.
*/

const mockGet = vi.fn<(...args: unknown[]) => unknown>()
const mockPost = vi.fn<(...args: unknown[]) => unknown>()
const mockPut = 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),
createUrlSearchParams: (obj: Record<string, string | number | null | undefined>) => {
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: "<p>current editor content</p>",
title: "Welcome",
system: "Viper",
application: null,
page: null,
viperSectionPath: null,
blockOrder: 1,
friendlyName: "welcome",
allowPublicAccess: false,
modifiedOn: "2024-03-01T12:00:00",
modifiedBy: "editor",
deletedOn: null,
permissions: [],
files: [],
}

const HISTORY = [
{ contentHistoryId: 91, modifiedOn: "2024-02-01T10:00:00", modifiedBy: "bob" },
{ contentHistoryId: 92, modifiedOn: "2024-01-01T09:00:00", modifiedBy: "amy" },
]

function routeGet() {
mockGet.mockReset()
mockPost.mockReset()
mockGet.mockImplementation((...args: unknown[]) => {
const url = args[0] as string
if (url.includes("/section-paths")) {
return Promise.resolve({ success: true, result: [] })
}
if (url.includes("/history")) {
return Promise.resolve({ success: true, result: HISTORY })
}
// The single-block load (.../content/7).
return Promise.resolve({ success: true, result: { ...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: `<textarea :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />`,
}

async function mountEdit() {
const router = createTestRouter()
await router.push({ name: "CmsContentBlockEdit", params: { id: "7" } })
await router.isReady()
const wrapper = mountCms(ContentBlockEdit, { global: { stubs: { QEditor: qEditorStub } } }, router)
await flushPromises()
await flushPromises()
return wrapper
}

function diffButton(wrapper: Awaited<ReturnType<typeof mountEdit>>) {
return wrapper.findAllComponents({ name: "QBtn" }).find((b) => b.text().includes("Diff vs current"))!
}
function loadButton(wrapper: Awaited<ReturnType<typeof mountEdit>>) {
return wrapper.findAllComponents({ name: "QBtn" }).find((b) => b.text().includes("Load into editor"))!
}
function historySelect(wrapper: Awaited<ReturnType<typeof mountEdit>>) {
// The version-history select is the one labeled "Select a previous version".
return wrapper.findAllComponents({ name: "QSelect" }).find((s) => s.props("label") === "Select a previous version")!
}

describe("ContentBlockEdit.vue - history selection gating", () => {
beforeEach(() => routeGet())

it("renders the diff/load buttons disabled until a version is selected", async () => {
const wrapper = await mountEdit()
expect(diffButton(wrapper).props("disable")).toBeTruthy()
expect(loadButton(wrapper).props("disable")).toBeTruthy()
})

it("selecting a version only sets selectedHistory and does not fetch that version", async () => {
const wrapper = await mountEdit()
const getCallsBefore = mockGet.mock.calls.length
historySelect(wrapper).vm.$emit("update:modelValue", HISTORY[0])
await flushPromises()
// No extra GET for the version content; the buttons just enable.
expect(mockGet.mock.calls.length).toBe(getCallsBefore)
expect(diffButton(wrapper).props("disable")).toBeFalsy()
expect(loadButton(wrapper).props("disable")).toBeFalsy()
})
})

describe("ContentBlockEdit.vue - diffAgainstCurrent", () => {
beforeEach(() => routeGet())

it("POSTs the current editor content to the diff endpoint and opens the dialog with the result", async () => {
mockPost.mockResolvedValue({
success: true,
result: { content: "<ins>new</ins>", hasComparison: true, hasChanges: true },
})
const wrapper = await mountEdit()
historySelect(wrapper).vm.$emit("update:modelValue", HISTORY[0])
await flushPromises()

await diffButton(wrapper).trigger("click")
await flushPromises()

expect(mockPost).toHaveBeenCalledOnce()
const [url, payload] = mockPost.mock.calls[0]!
expect(url).toContain("CMS/content/7/history/91/diff")
// The CURRENT editor content is posted (so unsaved edits are diffed).
expect(payload).toEqual({ content: "<p>current editor content</p>" })

const dialog = wrapper.findComponent({ name: "ContentDiffDialog" })
expect(dialog.props("modelValue")).toBeTruthy()
expect(dialog.props("diffHtml")).toContain("new")
expect(dialog.props("subtitle")).toContain("to your current editor content")
})

it("notifies and closes the diff dialog when the diff POST fails", async () => {
mockPost.mockResolvedValue({ success: false, errors: ["diff failed"] })
const wrapper = await mountEdit()
historySelect(wrapper).vm.$emit("update:modelValue", HISTORY[0])
await flushPromises()

await diffButton(wrapper).trigger("click")
await flushPromises()

const dialog = wrapper.findComponent({ name: "ContentDiffDialog" })
expect(dialog.props("modelValue")).toBeFalsy()
expect(document.body.textContent).toContain("diff failed")
})
})
Comment thread
rlorenzo marked this conversation as resolved.
Loading