diff --git a/.gitignore b/.gitignore index b6b7ed2e4..aabccddee 100644 --- a/.gitignore +++ b/.gitignore @@ -507,6 +507,9 @@ web/Areas/Effort/Scripts/AnalysisOutput/ web/Areas/Effort/Scripts/RemediationOutput/ web/Areas/Effort/Scripts/Effort_Database_Schema_And_Data_LEGACY.txt +# PhoneLists migration script outputs +web/Areas/Personnel/Scripts/AnalysisOutput/ + # Code-quality tool outputs (fallow cache + jscpd + ReSharper reports) .fallow/ VueApp/.fallow/ diff --git a/VueApp/.fallowrc.json b/VueApp/.fallowrc.json index f3d919fd4..3d0ab74c5 100644 --- a/VueApp/.fallowrc.json +++ b/VueApp/.fallowrc.json @@ -16,6 +16,7 @@ "src/ClinicalScheduler/index.html", "src/Computing/index.html", "src/Effort/index.html", + "src/Personnel/index.html", "src/Students/index.html" ], "ignorePatterns": [ diff --git a/VueApp/src/CMS/components/FileFormDialog.vue b/VueApp/src/CMS/components/FileFormDialog.vue index 6b09d8d30..d4f5426fb 100644 --- a/VueApp/src/CMS/components/FileFormDialog.vue +++ b/VueApp/src/CMS/components/FileFormDialog.vue @@ -1,19 +1,121 @@ - - + + {{ displayFile?.friendlyName }} + + Link: + + {{ displayFile?.friendlyUrl }} + + + Copy link + + + + + + + + + + + + + + + + + + + + + + + + + + + + - {{ isEdit ? "Edit File" : "Add File" }} + File name already exists - - - - - {{ displayFile?.friendlyName }} - - Link: - - {{ displayFile?.friendlyUrl }} - - - Copy link - - - - - - - - - - - - - - - - - - - - - - - - - - - {{ formError }} - - - - - - - - - {{ isEdit ? "Save Changes" : "Upload" }} - - - - - - - - - - File name already exists - - - - - - - {{ form.upload?.name }} already exists in {{ form.folder }}{{ conflictDetail }}. Choose how to continue: - - - + + {{ form.upload?.name }} already exists in {{ form.folder }}{{ conflictDetail }}. Choose how to continue: + + + + + + + + - - - - - - {{ conflictChoice === "rename" ? "Upload with new name" : "Overwrite" }} - - - - - + {{ conflictChoice === "rename" ? "Upload with new name" : "Overwrite" }} + + + @@ -238,7 +178,7 @@ import { useFetch } from "@/composables/ViperFetch" import { useUnsavedChanges } from "@/composables/use-unsaved-changes" import PermissionSelector from "@/CMS/components/PermissionSelector.vue" import PersonSelector from "@/CMS/components/PersonSelector.vue" -import StatusBanner from "@/components/StatusBanner.vue" +import RecordFormDialog from "@/components/RecordFormDialog.vue" import { CMS_ACCEPTED_EXTENSIONS } from "@/CMS/file-types" import type { CmsFile, CmsFileNameCheck, CmsFilePerson } from "@/CMS/types/" @@ -260,7 +200,6 @@ const { get, postForm, putForm, createUrlSearchParams } = useFetch() const acceptedExtensions = CMS_ACCEPTED_EXTENSIONS const isEdit = computed(() => props.file !== null) -const formRef = ref() const saving = ref(false) const formError = ref("") @@ -361,13 +300,6 @@ function resetForm() { conflict.value = null showConflict.value = false formError.value = "" - formRef.value?.resetValidation() -} - -async function handleClose() { - if (await confirmClose()) { - emit("update:modelValue", false) - } } // The q-form focuses the first invalid field on a failed submit; this surfaces a matching diff --git a/VueApp/src/CMS/components/PersonSelector.vue b/VueApp/src/CMS/components/PersonSelector.vue index cdc25a60e..08db7b2d6 100644 --- a/VueApp/src/CMS/components/PersonSelector.vue +++ b/VueApp/src/CMS/components/PersonSelector.vue @@ -43,8 +43,8 @@ diff --git a/VueApp/src/Personnel/App.vue b/VueApp/src/Personnel/App.vue new file mode 100644 index 000000000..9ef43f3b5 --- /dev/null +++ b/VueApp/src/Personnel/App.vue @@ -0,0 +1,14 @@ + + + + + + + diff --git a/VueApp/src/Personnel/__tests__/modified-summary.test.ts b/VueApp/src/Personnel/__tests__/modified-summary.test.ts new file mode 100644 index 000000000..fbed94871 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/modified-summary.test.ts @@ -0,0 +1,62 @@ +import { mount } from "@vue/test-utils" +import { Quasar } from "quasar" +import ModifiedSummary from "../components/ModifiedSummary.vue" + +/** + * ModifiedSummary renders the " Modified by " footer both record dialogs + * show in edit mode. The two absent cases are what it exists to get right: a record nobody has + * touched, and one whose author was never recorded. + */ + +function mountSummary(props: { label: string; date: Date | string | null; by: string | null }) { + return mount(ModifiedSummary, { + props, + global: { plugins: [Quasar] }, + }) +} + +describe("modifiedSummary.vue", () => { + it("shows the formatted date and the person who made the change", () => { + expect.hasAssertions() + + const wrapper = mountSummary({ + label: "Dean/Director", + date: new Date(2026, 5, 1), + by: "jdoe", + }) + + expect(wrapper.text()).toContain("Dean/Director Modified") + expect(wrapper.text()).toContain("by jdoe") + expect(wrapper.text()).not.toContain("Never") + }) + + it("accepts the ISO string form the API actually delivers", () => { + expect.hasAssertions() + // The record models type these as Date, but JSON hands over a string, so the component + // has to render the same either way. + const wrapper = mountSummary({ + label: "Dean/Director", + date: "2026-06-01T12:30:00", + by: "jdoe", + }) + + expect(wrapper.text()).not.toContain("Never") + }) + + it("reads as Never when the record has no modified date", () => { + expect.hasAssertions() + + const wrapper = mountSummary({ label: "Admin Staff", date: null, by: "jdoe" }) + + expect(wrapper.text()).toContain("Admin Staff Modified Never") + }) + + it("omits the by clause when no author was recorded", () => { + expect.hasAssertions() + // Legacy rows carry a date but no author, and "by" on its own would read as an + // unfinished sentence. + const wrapper = mountSummary({ label: "Admin Staff", date: new Date(2026, 5, 1), by: null }) + + expect(wrapper.text()).not.toContain("by") + }) +}) diff --git a/VueApp/src/Personnel/__tests__/person-selector.test.ts b/VueApp/src/Personnel/__tests__/person-selector.test.ts new file mode 100644 index 000000000..8b61fd117 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/person-selector.test.ts @@ -0,0 +1,99 @@ +import { mount } from "@vue/test-utils" +import { Quasar } from "quasar" +import PersonSelector from "../components/PersonSelector.vue" +import { searchPeopleOptions } from "../services/phone-person-options-service" +import type { AugmentedViperPerson } from "../types/phone-types" + +/** + * Personnel's PersonSelector is never mounted for real by the dialog tests (they stub it away + * to isolate what those tests are about), so nothing else exercises its actual wiring: passing + * listCode through to searchPeopleOptions, reflecting results into QSelect's options, and the + * clear-to-sparse-fallback behavior on selection. + */ + +vi.mock("../services/phone-person-options-service", () => ({ + searchPeopleOptions: vi.fn<(...args: unknown[]) => unknown>(), +})) + +function mountSelector(listCode = "") { + return mount(PersonSelector, { + props: { modelValue: { iamId: "", fullName: "" }, label: "Employee", listCode }, + global: { plugins: [Quasar] }, + }) +} + +function applyUpdate(fn: () => void): void { + fn() +} + +async function triggerFilter(wrapper: ReturnType, value: string): Promise { + await wrapper.findComponent({ name: "QSelect" }).vm.$emit("filter", value, applyUpdate) +} + +describe("personSelector.vue", () => { + it("scopes the search to the given listCode", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(searchPeopleOptions).mockResolvedValue([]) + const wrapper = mountSelector("VMDO") + + await triggerFilter(wrapper, "smith") + + expect(searchPeopleOptions).toHaveBeenCalledWith("smith", "VMDO") + }) + + it("reflects the search results into the QSelect options", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const people: AugmentedViperPerson[] = [ + { + personId: 1, + firstName: "Amy", + lastName: "Smith", + fullName: "Amy Smith", + iamId: "asmith", + currentEmployee: true, + mailId: "asmith", + phoneData: null, + }, + ] + vi.mocked(searchPeopleOptions).mockResolvedValue(people) + const wrapper = mountSelector() + + await triggerFilter(wrapper, "smith") + + expect(wrapper.findComponent({ name: "QSelect" }).props("options")).toStrictEqual(people) + }) + + it("emits the selected person when one is chosen", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const person: AugmentedViperPerson = { + personId: 1, + firstName: "Amy", + lastName: "Smith", + fullName: "Amy Smith", + iamId: "asmith", + currentEmployee: true, + mailId: "asmith", + phoneData: null, + } + const wrapper = mountSelector() + + await wrapper.findComponent({ name: "QSelect" }).vm.$emit("update:model-value", person) + + expect(wrapper.emitted("update:modelValue")).toStrictEqual([[person]]) + }) + + it("emits a sparse fallback person when the selection is cleared", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const wrapper = mountSelector() + + await wrapper.findComponent({ name: "QSelect" }).vm.$emit("update:model-value", null) + + const emitted = wrapper.emitted("update:modelValue") + expect(emitted).toHaveLength(1) + expect((emitted![0]![0] as AugmentedViperPerson).iamId).toBe("") + }) +}) diff --git a/VueApp/src/Personnel/__tests__/phone-list-add-record-dialog.test.ts b/VueApp/src/Personnel/__tests__/phone-list-add-record-dialog.test.ts new file mode 100644 index 000000000..302a3a545 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/phone-list-add-record-dialog.test.ts @@ -0,0 +1,199 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { createRouter, createWebHistory } from "vue-router" +import { Quasar, Notify } from "quasar" +import { createPinia, setActivePinia } from "pinia" +import PhoneListAddRecordDialog from "../components/PhoneListAddRecordDialog.vue" +import { phoneListUnitService } from "../services/phone-list-unit-service" +import type { PhoneListDisplayRecord } from "../types/phone-list-phone-types" +import { apiResult } from "./test-utils" + +/** + * PhoneListAddRecordDialog covers add vs edit flows for a unit-scoped phone record. Add mode + * requires an employee to be picked via PersonSelector (stubbed here, mirroring CMS's + * FileFormDialog tests) before it will submit; edit mode has the employee pre-filled from + * editData and no PersonSelector at all. These tests mount the real dialog + RecordFormDialog + + * QForm so submission goes through the real validation gate, and mock the unit service so no + * network call happens. + */ + +vi.mock("../services/phone-list-unit-service", () => ({ + phoneListUnitService: { + addUnitPersonData: vi.fn<(...args: unknown[]) => unknown>(), + updateUnitPersonData: vi.fn<(...args: unknown[]) => unknown>(), + }, +})) + +const selectorStub = { + props: ["modelValue", "label", "listCode"], + emits: ["update:modelValue"], + template: "", +} + +function mountDialog(props: { + modelValue: boolean + unit: { name: string; id: number } + listCode: string + editData?: PhoneListDisplayRecord | null +}) { + const pinia = createPinia() + setActivePinia(pinia) + const router = createRouter({ + history: createWebHistory(), + routes: [{ path: "/", component: { template: "" } }], + }) + return mount(PhoneListAddRecordDialog, { + props, + global: { + plugins: [[Quasar, { plugins: { Notify } }], router, pinia], + stubs: { PersonSelector: selectorStub }, + }, + attachTo: document.body, + }) +} + +function editRecord(overrides: Partial = {}): PhoneListDisplayRecord { + return { + fullName: "Amy Smith", + name: "Smith, Amy", + employeeIam: "asmith", + employeeMailId: "asmith", + phone: "530-555-1000", + directPhone: "530-555-2000", + office: "Room 100", + listFirst: false, + unitPersonId: 7, + unitId: 10, + unitName: "Dean's Office", + modifiedBy: "jdoe", + modifiedDate: null, + ...overrides, + } +} + +async function submitDialogForm(wrapper: ReturnType): Promise { + await flushPromises() + await wrapper.findComponent({ name: "QForm" }).find("form").trigger("submit") + await flushPromises() +} + +function bodyText(): string { + return document.body.textContent ?? "" +} + +// QDialog teleports its content to document.body, so a wrapper-scoped find() misses it - +// query the document directly, mirroring the bodyText() helper above. +function personSelectorExists(): boolean { + return document.querySelector(".selector-stub") !== null +} + +function resetTestState(): void { + vi.clearAllMocks() + document.body.innerHTML = "" +} + +describe("phoneListAddRecordDialog.vue - add vs edit mode", () => { + it("shows the PersonSelector picker in add mode, and hides the employee/modified-by summary", async () => { + expect.hasAssertions() + resetTestState() + mountDialog({ modelValue: true, unit: { name: "Dean's Office", id: 10 }, listCode: "VMDO" }) + await flushPromises() + + expect(bodyText()).toContain("Add Phone Record") + expect(bodyText()).toContain("Upload") + expect(personSelectorExists()).toBeTruthy() + expect(bodyText()).not.toContain("Employee:") + expect(bodyText()).not.toContain("Modified By:") + }) + + it("shows the employee/modified-by summary in edit mode, and hides the PersonSelector picker", async () => { + expect.hasAssertions() + resetTestState() + mountDialog({ + modelValue: true, + unit: { name: "Dean's Office", id: 10 }, + listCode: "VMDO", + editData: editRecord(), + }) + await flushPromises() + + expect(bodyText()).toContain("Edit Phone Record") + expect(bodyText()).toContain("Employee: Smith, Amy") + expect(bodyText()).toContain("Modified By: jdoe") + expect(bodyText()).toContain("Save Changes") + expect(personSelectorExists()).toBeFalsy() + }) + + it("blocks submit with a validation message when no employee is selected in add mode", async () => { + expect.hasAssertions() + resetTestState() + const wrapper = mountDialog({ modelValue: true, unit: { name: "Dean's Office", id: 10 }, listCode: "VMDO" }) + + await submitDialogForm(wrapper) + + expect(phoneListUnitService.addUnitPersonData).not.toHaveBeenCalled() + expect(bodyText()).toContain("Please select an employee.") + }) + + it("submits the update payload and emits saved + close in edit mode", async () => { + expect.hasAssertions() + resetTestState() + vi.mocked(phoneListUnitService.updateUnitPersonData).mockResolvedValue(apiResult({ result: true })) + const wrapper = mountDialog({ + modelValue: true, + unit: { name: "Dean's Office", id: 10 }, + listCode: "VMDO", + editData: editRecord(), + }) + + await submitDialogForm(wrapper) + + expect(phoneListUnitService.updateUnitPersonData).toHaveBeenCalledWith( + "VMDO", + 7, + expect.objectContaining({ unitId: 10, employeeIam: "asmith", phone: "530-555-1000" }), + ) + expect(wrapper.emitted("saved")).toBeTruthy() + expect(wrapper.emitted("update:modelValue")).toContainEqual([false]) + }) + + it("empties the form when the record being edited is cleared", async () => { + expect.hasAssertions() + resetTestState() + const wrapper = mountDialog({ + modelValue: true, + unit: { name: "Dean's Office", id: 10 }, + listCode: "VMDO", + editData: editRecord(), + }) + await flushPromises() + + // The dialog re-derives its form whenever editData changes, and closing an edit clears + // it. Nothing to edit has to read as every field blank, not as the previous record + // lingering in the inputs the next time the dialog opens to add. + await wrapper.setProps({ editData: null }) + await flushPromises() + + expect(bodyText()).not.toContain("Room 100") + expect(bodyText()).not.toContain("530-555-2000") + }) + + it("keeps the ListFirst flag off when the record being edited is cleared", async () => { + expect.hasAssertions() + resetTestState() + vi.mocked(phoneListUnitService.addUnitPersonData).mockResolvedValue(apiResult({ result: true })) + const wrapper = mountDialog({ + modelValue: true, + unit: { name: "Dean's Office", id: 10 }, + listCode: "VMDO", + editData: editRecord({ listFirst: true }), + }) + await flushPromises() + + await wrapper.setProps({ editData: null }) + await flushPromises() + + // ListFirst is the one non-string field, so a cleared record has to fall back to false + // rather than carrying the previous row's flag into the next add. + expect(wrapper.findComponent({ name: "QCheckbox" }).props("modelValue")).toBeFalsy() + }) +}) diff --git a/VueApp/src/Personnel/__tests__/phone-list-data-fetch.test.ts b/VueApp/src/Personnel/__tests__/phone-list-data-fetch.test.ts new file mode 100644 index 000000000..d93457396 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/phone-list-data-fetch.test.ts @@ -0,0 +1,128 @@ +import { getPhoneListData } from "../composables/phone-list-data-fetch" +import { phoneListUnitService } from "../services/phone-list-unit-service" +import type { PhoneListUnitAPIResponse, PhoneListUnitPerson } from "../types/phone-list-phone-types" + +vi.mock("../services/phone-list-unit-service", () => ({ + phoneListUnitService: { + getUnitsByList: vi.fn<(...args: unknown[]) => unknown>(), + }, +})) + +function makeUnitPerson(overrides: Partial = {}): PhoneListUnitPerson { + return { + phoneListUnitPersonId: 1, + phoneListUnitId: 10, + personIam: "person01", + listFirst: false, + phoneListUnit: null, + person: { + personIam: "person01", + phone: "530-555-1000", + directPhone: "530-555-2000", + office: "Room 100", + modifiedDate: null, + modifiedBy: null, + unitPersons: null, + phoneListUnitPersons: null, + viperPerson: { + personId: 1, + firstName: "Ada", + lastName: "Lovelace", + fullName: "Ada Lovelace", + iamId: "person01", + currentEmployee: true, + mailId: "alovelace", + }, + viperModPerson: null, + }, + modifiedBy: null, + modifiedDate: null, + viperModPerson: null, + ...overrides, + } +} + +function makeUnit(persons: PhoneListUnitPerson[]): PhoneListUnitAPIResponse { + return { + phoneListUnitId: 10, + phoneListId: 1, + name: "Dean's Office", + sortOrder: null, + phoneList: null, + phoneListUnitPersons: persons, + } +} + +describe("getPhoneListData()", () => { + it("omits the direct phone column for non-internal, view-only callers", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(phoneListUnitService.getUnitsByList).mockResolvedValue([makeUnit([makeUnitPerson()])]) + + const units = await getPhoneListData("VMDO", false, false) + + const columnNames = units[0]!.cols!.map((c) => c.name) + expect(columnNames).not.toContain("directPhone") + expect(columnNames).not.toContain("edit") + expect(columnNames).not.toContain("delete") + expect(columnNames).not.toContain("listFirst") + }) + + it("shows the direct phone column for internal viewers even outside edit mode", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(phoneListUnitService.getUnitsByList).mockResolvedValue([makeUnit([makeUnitPerson()])]) + + const units = await getPhoneListData("VMDO", false, true) + + const columnNames = units[0]!.cols!.map((c) => c.name) + expect(columnNames).toContain("directPhone") + expect(columnNames).not.toContain("edit") + }) + + it("shows maintain-only columns in edit mode regardless of internal status", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(phoneListUnitService.getUnitsByList).mockResolvedValue([makeUnit([makeUnitPerson()])]) + + const units = await getPhoneListData("VMDO", true, false) + + const columnNames = units[0]!.cols!.map((c) => c.name) + expect(columnNames).toContain("directPhone") + expect(columnNames).toContain("listFirst") + expect(columnNames).toContain("edit") + expect(columnNames).toContain("delete") + }) + + it("drops rows for former employees whose person record is gone, and falls back to a sparse name otherwise", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(phoneListUnitService.getUnitsByList).mockResolvedValue([ + makeUnit([ + makeUnitPerson({ person: null }), + makeUnitPerson({ + phoneListUnitPersonId: 2, + personIam: "person02", + person: { + personIam: "person02", + phone: "530-555-3000", + directPhone: "530-555-4000", + office: "Room 200", + modifiedDate: null, + modifiedBy: null, + unitPersons: null, + phoneListUnitPersons: null, + viperPerson: null, + viperModPerson: null, + }, + }), + ]), + ]) + + const units = await getPhoneListData("VMDO", false, false) + + expect(units[0]!.rows).toHaveLength(1) + expect(units[0]!.rows[0]!.name).toBe(", ") + expect(units[0]!.rows[0]!.fullName).toBe("") + }) +}) diff --git a/VueApp/src/Personnel/__tests__/phone-list-maintain.test.ts b/VueApp/src/Personnel/__tests__/phone-list-maintain.test.ts new file mode 100644 index 000000000..afbff49ad --- /dev/null +++ b/VueApp/src/Personnel/__tests__/phone-list-maintain.test.ts @@ -0,0 +1,226 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { Quasar, Notify } from "quasar" +import PhoneListMaintain from "../pages/PhoneListMaintain.vue" +import PhoneListAddRecordDialog from "../components/PhoneListAddRecordDialog.vue" +import { getPhoneListData } from "../composables/phone-list-data-fetch.ts" +import { phoneListService } from "../services/phone-list-service.ts" +import { phoneListUnitService } from "../services/phone-list-unit-service.ts" +import type { PhoneListUnit } from "../types/phone-list-phone-types" +import { apiError } from "./test-utils" + +/** + * PhoneListMaintain shows a StatusBanner only when a delete/save action reports an error + * (v-if="errorMessage"). Reaching that state means driving the real child chain (PhoneListUnitTable + * -> RecordActionButton "delete" -> the confirm dialog -> phoneListUnitService), since errorMessage + * is page-local state with no prop/route to set it directly. + */ + +vi.mock("../composables/phone-list-data-fetch.ts", () => ({ + getPhoneListData: vi.fn<(...args: unknown[]) => unknown>(), +})) +vi.mock("../services/phone-list-service.ts", () => ({ + phoneListService: { getPhoneListInfo: vi.fn<(...args: unknown[]) => unknown>() }, +})) +vi.mock("../services/phone-list-unit-service.ts", () => ({ + phoneListUnitService: { + addUnitPersonData: vi.fn<(...args: unknown[]) => unknown>(), + updateUnitPersonData: vi.fn<(...args: unknown[]) => unknown>(), + deleteUnitPersonData: vi.fn<(...args: unknown[]) => unknown>(), + }, +})) +const mockReplace = vi.fn<(...args: unknown[]) => unknown>() +vi.mock("vue-router", () => ({ + useRoute: () => ({ params: { code: "VMDO" } }), + useRouter: () => ({ replace: (...args: unknown[]) => mockReplace(...args) }), +})) +// Stub only the public useQuasar export, so the toast the page raises can be asserted directly. +// Quasar components resolve $q through their own internals, so QTable and friends still render. +const { mockNotify } = vi.hoisted(() => ({ mockNotify: vi.fn<(...args: unknown[]) => unknown>() })) +vi.mock("quasar", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useQuasar: () => ({ notify: mockNotify }) } +}) +vi.mock("@/composables/use-confirm-dialog", () => ({ + useConfirmDialog: () => ({ confirmAction: vi.fn<(...args: unknown[]) => unknown>().mockResolvedValue(true) }), +})) + +/** Stubs a list the caller may maintain, which is the precondition for the editor to render. */ +function stubListInfo(canMaintain = true): void { + vi.mocked(phoneListService.getPhoneListInfo).mockResolvedValue({ + phoneListId: 1, + code: "VMDO", + name: "Dean's Office Phone List", + canMaintain, + canViewDirectPhone: true, + }) +} + +function unitWithDeletableRow(): PhoneListUnit { + return { + name: "Dean's Office", + id: 10, + cols: [ + { name: "name", label: "Name", field: "name", align: "left" }, + { name: "edit", label: "Edit", field: "edit", align: "left" }, + { name: "delete", label: "Delete", field: "delete", align: "left" }, + ], + rows: [ + { + fullName: "Amy Smith", + name: "Smith, Amy", + employeeIam: "asmith", + employeeMailId: "asmith", + phone: "530-555-1000", + directPhone: "530-555-2000", + office: "Room 100", + listFirst: false, + unitPersonId: 7, + unitId: 10, + unitName: "Dean's Office", + modifiedBy: null, + modifiedDate: null, + }, + ], + } +} + +const personSelectorStub = { + props: ["modelValue", "label", "listCode"], + emits: ["update:modelValue"], + template: "", +} + +function mountPage() { + return mount(PhoneListMaintain, { + global: { + plugins: [[Quasar, { plugins: { Notify } }]], + stubs: { PersonSelector: personSelectorStub }, + }, + }) +} + +function findAddButton(wrapper: ReturnType) { + return wrapper.findAllComponents({ name: "QBtn" }).find((btn) => btn.props("icon") === "add") +} + +describe("phoneListMaintain.vue - error banner", () => { + it("hides the error banner on a normal load", async () => { + expect.hasAssertions() + vi.clearAllMocks() + stubListInfo() + // RecordFormDialog's QDialog teleports to document.body, so clear any leftover content + // from a previous test's dialog before asserting on document.body.textContent. + document.body.innerHTML = "" + vi.mocked(getPhoneListData).mockResolvedValue([]) + + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.findComponent({ name: "StatusBanner" }).exists()).toBeFalsy() + }) + + it("raises a toast carrying the server message when a delete fails", async () => { + expect.hasAssertions() + vi.clearAllMocks() + stubListInfo() + // RecordFormDialog's QDialog teleports to document.body, so clear any leftover content + // from a previous test's dialog before asserting on document.body.textContent. + document.body.innerHTML = "" + vi.mocked(getPhoneListData).mockResolvedValue([unitWithDeletableRow()]) + vi.mocked(phoneListUnitService.deleteUnitPersonData).mockResolvedValue(apiError(["Failed to delete record"])) + const wrapper = mountPage() + await flushPromises() + + const deleteButton = wrapper + .findAllComponents({ name: "RecordActionButton" }) + .find((btn) => btn.props("action") === "delete") + expect(deleteButton).toBeTruthy() + await deleteButton!.vm.$emit("action") + await flushPromises() + + // A failed delete is transient, so it is reported as a toast rather than the banner, + // which would otherwise persist past the reload that follows. + expect(wrapper.findComponent({ name: "StatusBanner" }).exists()).toBeFalsy() + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: "negative", message: "Failed to delete record" }), + ) + }) + + it("opens the add dialog scoped to the clicked unit", async () => { + expect.hasAssertions() + vi.clearAllMocks() + stubListInfo() + // RecordFormDialog's QDialog teleports to document.body, so clear any leftover content + // from a previous test's dialog before asserting on document.body.textContent. + document.body.innerHTML = "" + vi.mocked(getPhoneListData).mockResolvedValue([unitWithDeletableRow()]) + const wrapper = mountPage() + await flushPromises() + + await findAddButton(wrapper)!.trigger("click") + await flushPromises() + + const dialog = wrapper.findComponent(PhoneListAddRecordDialog) + expect(dialog.props("modelValue")).toBeTruthy() + expect(dialog.props("unit")).toMatchObject({ name: "Dean's Office", id: 10 }) + }) + + it("opens the edit dialog pre-filled when edit is clicked on a row", async () => { + expect.hasAssertions() + vi.clearAllMocks() + stubListInfo() + // RecordFormDialog's QDialog teleports to document.body, so clear any leftover content + // from a previous test's dialog before asserting on document.body.textContent. + document.body.innerHTML = "" + vi.mocked(getPhoneListData).mockResolvedValue([unitWithDeletableRow()]) + const wrapper = mountPage() + await flushPromises() + + const editButton = wrapper + .findAllComponents({ name: "RecordActionButton" }) + .find((btn) => btn.props("action") === "edit") + expect(editButton).toBeTruthy() + await editButton!.vm.$emit("action") + await flushPromises() + + const dialog = wrapper.findComponent(PhoneListAddRecordDialog) + expect(dialog.props("modelValue")).toBeTruthy() + // RecordFormDialog's QDialog teleports its content to document.body. + expect(document.body.textContent).toContain("Edit Phone Record") + expect(document.body.textContent).toContain("Employee: Smith, Amy") + }) + + it("redirects away, without fetching rows, when the caller cannot maintain the list", async () => { + expect.hasAssertions() + vi.clearAllMocks() + document.body.innerHTML = "" + // The maintain role is the list's own, so no static route guard can gate this page. + // The API rejects the writes regardless; this keeps a non-maintainer out of an editor + // whose every save would fail. + stubListInfo(false) + + mountPage() + await flushPromises() + + expect(mockReplace).toHaveBeenCalledWith({ name: "PersonnelHome" }) + expect(getPhoneListData).not.toHaveBeenCalled() + }) + + it("reloads the phone data when the dialog reports a save", async () => { + expect.hasAssertions() + vi.clearAllMocks() + stubListInfo() + // RecordFormDialog's QDialog teleports to document.body, so clear any leftover content + // from a previous test's dialog before asserting on document.body.textContent. + document.body.innerHTML = "" + vi.mocked(getPhoneListData).mockResolvedValue([]) + const wrapper = mountPage() + await flushPromises() + const callsBeforeSave = vi.mocked(getPhoneListData).mock.calls.length + + await wrapper.findComponent(PhoneListAddRecordDialog).vm.$emit("saved", true) + await flushPromises() + + expect(vi.mocked(getPhoneListData).mock.calls.length).toBeGreaterThan(callsBeforeSave) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/phone-list-modified-date-service.test.ts b/VueApp/src/Personnel/__tests__/phone-list-modified-date-service.test.ts new file mode 100644 index 000000000..19ef8ea80 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/phone-list-modified-date-service.test.ts @@ -0,0 +1,29 @@ +import { phoneListModifiedDateService } from "../services/phone-list-modified-date-service" + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ get: (...args: unknown[]) => mockGet(...args) }), +})) + +describe("phoneListModifiedDateService()", () => { + it("returns the modified date scoped to the given list code", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: "2026-01-01T00:00:00" }) + + const result = await phoneListModifiedDateService.getModifiedDate("VMDO") + + expect(mockGet).toHaveBeenCalledWith(expect.stringContaining("phonelist/VMDO/modifiedDate")) + expect(result).toBe("2026-01-01T00:00:00") + }) + + it("returns null when there is no modified date on record", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: null }) + + const result = await phoneListModifiedDateService.getModifiedDate("VMDO") + + expect(result).toBeNull() + }) +}) diff --git a/VueApp/src/Personnel/__tests__/phone-list-route-changes.test.ts b/VueApp/src/Personnel/__tests__/phone-list-route-changes.test.ts new file mode 100644 index 000000000..7050e9922 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/phone-list-route-changes.test.ts @@ -0,0 +1,119 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { Quasar, Notify } from "quasar" +import { createRouter, createMemoryHistory } from "vue-router" +import PhoneList from "../pages/PhoneList.vue" +import PhoneListMaintain from "../pages/PhoneListMaintain.vue" +import { getPhoneListData } from "../composables/phone-list-data-fetch" +import { phoneListModifiedDateService } from "../services/phone-list-modified-date-service.ts" +import { phoneListService } from "../services/phone-list-service.ts" + +/** + * Every unit list renders through one :code route, so Vue Router reuses the page component when + * the code changes instead of remounting it - a mounted hook fires only for the first list. These + * tests drive a real router rather than a stubbed useRoute, because that reuse is exactly the + * behaviour at issue: a stubbed route cannot reproduce it. + * + * On the maintain page the stale-code case is worse than stale display, since listCode is what + * scopes every write the page sends. + */ + +vi.mock("../composables/phone-list-data-fetch", () => ({ + getPhoneListData: vi.fn<(...args: unknown[]) => unknown>(), +})) +vi.mock("../services/phone-list-modified-date-service.ts", () => ({ + phoneListModifiedDateService: { getModifiedDate: vi.fn<(...args: unknown[]) => unknown>() }, +})) +vi.mock("../services/phone-list-service.ts", () => ({ + phoneListService: { getPhoneListInfo: vi.fn<(...args: unknown[]) => unknown>() }, +})) +vi.mock("../services/phone-list-unit-service.ts", () => ({ + phoneListUnitService: { deleteUnitPersonData: vi.fn<(...args: unknown[]) => unknown>() }, +})) +vi.mock("@/composables/use-confirm-dialog", () => ({ + useConfirmDialog: () => ({ confirmAction: vi.fn<(...args: unknown[]) => unknown>() }), +})) + +function stubList(code: string) { + vi.mocked(phoneListService.getPhoneListInfo).mockResolvedValue({ + phoneListId: 1, + code, + name: `${code} Phone List`, + canMaintain: true, + canViewDirectPhone: true, + }) +} + +/** Mounts the page behind a real router so param-only navigation reuses the component. */ +async function mountUnderRouter(component: unknown) { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: "/Personnel/PhoneList/:code", component: component as never, name: "PhoneList" }, + { path: "/Personnel/", component: { template: "" }, name: "PersonnelHome" }, + ], + }) + const wrapper = mount( + { template: "" }, + { global: { plugins: [[Quasar, { plugins: { Notify } }], router] } }, + ) + await router.push("/Personnel/PhoneList/VMDO") + await router.isReady() + await flushPromises() + return { router, wrapper } +} + +describe("phone list pages - changing the route code", () => { + it("refetches the newly named list when the code changes", async () => { + expect.hasAssertions() + vi.clearAllMocks() + stubList("VMDO") + vi.mocked(phoneListModifiedDateService.getModifiedDate).mockResolvedValue(null) + vi.mocked(getPhoneListData).mockResolvedValue([]) + + const { router } = await mountUnderRouter(PhoneList) + expect(phoneListService.getPhoneListInfo).toHaveBeenCalledWith("VMDO") + + stubList("OTHER") + await router.push("/Personnel/PhoneList/OTHER") + await flushPromises() + + expect(phoneListService.getPhoneListInfo).toHaveBeenLastCalledWith("OTHER") + expect(getPhoneListData).toHaveBeenLastCalledWith("OTHER", false, true) + }) + + it("shows the new list's name rather than the previous one", async () => { + expect.hasAssertions() + vi.clearAllMocks() + stubList("VMDO") + vi.mocked(phoneListModifiedDateService.getModifiedDate).mockResolvedValue(null) + vi.mocked(getPhoneListData).mockResolvedValue([]) + + const { router, wrapper } = await mountUnderRouter(PhoneList) + expect(wrapper.find("h1").text()).toBe("VMDO Phone List") + + stubList("OTHER") + await router.push("/Personnel/PhoneList/OTHER") + await flushPromises() + + expect(wrapper.find("h1").text()).toBe("OTHER Phone List") + }) + + it("rescopes the maintain page to the new list, so edits go to the right one", async () => { + expect.hasAssertions() + vi.clearAllMocks() + stubList("VMDO") + vi.mocked(getPhoneListData).mockResolvedValue([]) + + const { router, wrapper } = await mountUnderRouter(PhoneListMaintain) + + stubList("OTHER") + await router.push("/Personnel/PhoneList/OTHER") + await flushPromises() + + // The dialog receives listCode and passes it to every save. A stale value here would + // write the edits for the new list into the previous one. + const dialog = wrapper.findComponent({ name: "PhoneListAddRecordDialog" }) + expect(dialog.props("listCode")).toBe("OTHER") + expect(getPhoneListData).toHaveBeenLastCalledWith("OTHER", true, true) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/phone-list-service.test.ts b/VueApp/src/Personnel/__tests__/phone-list-service.test.ts new file mode 100644 index 000000000..31d54f57f --- /dev/null +++ b/VueApp/src/Personnel/__tests__/phone-list-service.test.ts @@ -0,0 +1,66 @@ +import { phoneListService } from "../services/phone-list-service" + +/** + * The "null on failure" convention is what PhoneList.vue and PhoneListMaintain.vue branch on to + * decide whether to show a not-found banner, so a regression here would surface as a silently + * blank page rather than a caught error. Lookup is by the list's stable code, so a list renamed + * for display keeps resolving. + */ + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ + get: (...args: unknown[]) => mockGet(...args), + }), +})) + +const listInfo = { + phoneListId: 5, + code: "VMDO", + name: "Dean's Office", + canMaintain: true, + canViewDirectPhone: true, +} + +describe("phoneListService()", () => { + it("returns the list info on success", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: listInfo }) + + const result = await phoneListService.getPhoneListInfo("VMDO") + + expect(mockGet).toHaveBeenCalledWith(expect.stringContaining("phonelist/VMDO")) + expect(result).toStrictEqual(listInfo) + }) + + it("percent-encodes a code so it cannot break out of the path segment", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: listInfo }) + + await phoneListService.getPhoneListInfo("a/b c") + + expect(mockGet).toHaveBeenCalledWith(expect.stringContaining("phonelist/a%2Fb%20c")) + }) + + it("returns null when the request fails", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: false, result: null }) + + const result = await phoneListService.getPhoneListInfo("VMDO") + + expect(result).toBeNull() + }) + + it("returns null when the request succeeds but finds no matching list", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: null }) + + const result = await phoneListService.getPhoneListInfo("Nonexistent") + + expect(result).toBeNull() + }) +}) diff --git a/VueApp/src/Personnel/__tests__/phone-list-unit-service.test.ts b/VueApp/src/Personnel/__tests__/phone-list-unit-service.test.ts new file mode 100644 index 000000000..8dec9daed --- /dev/null +++ b/VueApp/src/Personnel/__tests__/phone-list-unit-service.test.ts @@ -0,0 +1,92 @@ +import { phoneListUnitService } from "../services/phone-list-unit-service" + +/** + * Every call is addressed by the list's code. The API resolves that code to a list and runs its + * own permission check against that list, so the code in the path is what scopes the request - + * these tests pin that the code actually reaches the URL. + */ + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +const mockPost = vi.fn<(...args: unknown[]) => unknown>() +const mockPut = 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), + del: (...args: unknown[]) => mockDel(...args), + }), +})) + +const formData = { + unitId: 1, + office: "", + employeeIam: "person01", + phone: "", + directPhone: "", + listFirst: false, +} + +describe("phoneListUnitService()", () => { + it("returns the API results for getUnitsByList", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const units = [ + { + phoneListUnitId: 1, + phoneListId: 1, + name: "Dean's Office", + sortOrder: null, + phoneList: null, + phoneListUnitPersons: [], + }, + ] + mockGet.mockResolvedValue({ success: true, result: units }) + + const result = await phoneListUnitService.getUnitsByList("VMDO") + + expect(mockGet).toHaveBeenCalledWith(expect.stringContaining("phonelist/VMDO/units")) + expect(result).toStrictEqual(units) + }) + + it("normalizes a null or empty getUnitsByList result to an empty array", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: false, result: null }) + + const result = await phoneListUnitService.getUnitsByList("VMDO") + + expect(result).toStrictEqual([]) + }) + + it("posts new unit-person data to the unitPerson endpoint for the given list", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockPost.mockResolvedValue({ success: true, result: true }) + + await phoneListUnitService.addUnitPersonData("VMDO", formData) + + expect(mockPost).toHaveBeenCalledWith(expect.stringContaining("phonelist/VMDO/unitPerson"), formData) + }) + + it("puts updated unit-person data under the list code and record id", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockPut.mockResolvedValue({ success: true, result: true }) + + await phoneListUnitService.updateUnitPersonData("VMDO", 7, formData) + + expect(mockPut).toHaveBeenCalledWith(expect.stringContaining("phonelist/VMDO/unitPerson/7"), formData) + }) + + it("deletes a unit-person record under the list code", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockDel.mockResolvedValue({ success: true, result: true }) + + await phoneListUnitService.deleteUnitPersonData("VMDO", 7) + + expect(mockDel).toHaveBeenCalledWith(expect.stringContaining("phonelist/VMDO/unitPerson/7")) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/phone-list-unit-table.test.ts b/VueApp/src/Personnel/__tests__/phone-list-unit-table.test.ts new file mode 100644 index 000000000..507875f2c --- /dev/null +++ b/VueApp/src/Personnel/__tests__/phone-list-unit-table.test.ts @@ -0,0 +1,115 @@ +import { mount } from "@vue/test-utils" +import { Quasar } from "quasar" +import PhoneListUnitTable from "../components/PhoneListUnitTable.vue" +import type { PhoneListDisplayRecord, PhoneListUnit } from "../types/phone-list-phone-types" + +/** + * PhoneListUnitTable is the UI enforcement point for the same maintain/internal-access + * permission model already covered at the service layer (PhoneListUnitService, + * PhonePersonLookupService): isMaintain gates the "add" button and swaps the name cell between + * a maintainer-facing default render and a viewer-facing mailto link (or plain text when the + * person has no mail id). + */ + +const cols = [ + { name: "name", label: "Name", field: "name", align: "left" as const }, + { name: "listFirst", label: "List First", field: "listFirst", align: "center" as const }, + { name: "edit", label: "Edit", field: "edit", align: "left" as const }, + { name: "delete", label: "Delete", field: "delete", align: "left" as const }, +] + +function makeRow(overrides: Partial = {}): PhoneListDisplayRecord { + return { + fullName: "Amy Smith", + name: "Smith, Amy", + employeeIam: "asmith", + employeeMailId: "asmith", + phone: "530-555-1000", + directPhone: "530-555-2000", + office: "Room 100", + listFirst: false, + unitPersonId: 1, + unitId: 10, + unitName: "Dean's Office", + modifiedBy: null, + modifiedDate: null, + ...overrides, + } +} + +function makeUnit(rows: PhoneListDisplayRecord[]): PhoneListUnit { + return { name: "Dean's Office", id: 10, cols, rows } +} + +function mountTable(props: { unit: PhoneListUnit; loading: boolean; isMaintain: boolean; search: string }) { + return mount(PhoneListUnitTable, { + props, + global: { plugins: [Quasar] }, + }) +} + +// QTable always renders a .q-table__title element from its own :title prop, so it can't +// distinguish the custom top-left slot (the "add" button) from the base title - check for the +// button itself instead. +function hasAddButton(wrapper: ReturnType): boolean { + return wrapper.findAllComponents({ name: "QBtn" }).some((btn) => btn.props("icon") === "add") +} + +describe("phoneListUnitTable.vue - isMaintain gating", () => { + it("shows the add button and skips the mailto-link cell when isMaintain is true", () => { + expect.hasAssertions() + const wrapper = mountTable({ + unit: makeUnit([makeRow({ employeeMailId: "asmith" })]), + loading: false, + isMaintain: true, + search: "", + }) + + expect(hasAddButton(wrapper)).toBeTruthy() + expect(wrapper.find("a[href^='mailto:']").exists()).toBeFalsy() + }) + + it("hides the add button and links the name as a mailto anchor when the row has a mail id and isMaintain is false", () => { + expect.hasAssertions() + const wrapper = mountTable({ + unit: makeUnit([makeRow({ employeeMailId: "asmith", name: "Smith, Amy" })]), + loading: false, + isMaintain: false, + search: "", + }) + + expect(hasAddButton(wrapper)).toBeFalsy() + const link = wrapper.find("a[href='mailto:asmith@ucdavis.edu']") + expect(link.exists()).toBeTruthy() + expect(link.text()).toBe("Smith, Amy") + }) + + it("shows plain text instead of a mailto link when the row has no mail id and isMaintain is false", () => { + expect.hasAssertions() + const wrapper = mountTable({ + unit: makeUnit([makeRow({ employeeMailId: "", name: "Smith, Amy" })]), + loading: false, + isMaintain: false, + search: "", + }) + + expect(wrapper.find("a[href^='mailto:']").exists()).toBeFalsy() + expect(wrapper.text()).toContain("Smith, Amy") + }) + + it("shows a check icon only on the row where listFirst is true", () => { + expect.hasAssertions() + const wrapper = mountTable({ + unit: makeUnit([ + makeRow({ unitPersonId: 1, name: "First, Person", listFirst: true }), + makeRow({ unitPersonId: 2, name: "Second, Person", listFirst: false }), + ]), + loading: false, + isMaintain: true, + search: "", + }) + + const checkIcons = wrapper.findAllComponents({ name: "QIcon" }).filter((icon) => icon.props("name") === "check") + expect(checkIcons).toHaveLength(1) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/phone-list.test.ts b/VueApp/src/Personnel/__tests__/phone-list.test.ts new file mode 100644 index 000000000..ba11ff7db --- /dev/null +++ b/VueApp/src/Personnel/__tests__/phone-list.test.ts @@ -0,0 +1,126 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { Quasar, Notify } from "quasar" +import PhoneList from "../pages/PhoneList.vue" +import { getPhoneListData } from "../composables/phone-list-data-fetch" +import { phoneListModifiedDateService } from "../services/phone-list-modified-date-service.ts" +import { phoneListService } from "../services/phone-list-service.ts" + +/** + * PhoneList is the read-only view of any unit list, resolved from the :code route param. It + * hides its "Updated"/internal banner while the initial fetch is in flight (v-if="!loading"), + * and within that block only shows the "FOR INTERNAL USE ONLY" notice for callers with + * direct-phone access - the UI-level counterpart of the canViewDirectPhone flag already covered + * at the composable/service layer. + */ + +vi.mock("../composables/phone-list-data-fetch", () => ({ + getPhoneListData: vi.fn<(...args: unknown[]) => unknown>(), +})) +vi.mock("../services/phone-list-modified-date-service.ts", () => ({ + phoneListModifiedDateService: { getModifiedDate: vi.fn<(...args: unknown[]) => unknown>() }, +})) +vi.mock("../services/phone-list-service.ts", () => ({ + phoneListService: { getPhoneListInfo: vi.fn<(...args: unknown[]) => unknown>() }, +})) +vi.mock("vue-router", () => ({ + useRoute: () => ({ params: { code: "VMDO" } }), +})) + +// Never actually resolves, to simulate a fetch that's still in flight. +function neverResolves(): Promise { + // eslint-disable-next-line avoid-new, no-empty-function -- deliberately pending forever, to simulate an in-flight fetch + return new Promise(() => {}) +} + +function mountPage() { + return mount(PhoneList, { + global: { plugins: [[Quasar, { plugins: { Notify } }]] }, + }) +} + +function stubListInfo(canViewDirectPhone: boolean): void { + vi.clearAllMocks() + vi.mocked(phoneListService.getPhoneListInfo).mockResolvedValue({ + phoneListId: 1, + code: "VMDO", + name: "Dean's Office Phone List", + canMaintain: false, + canViewDirectPhone, + }) + vi.mocked(phoneListModifiedDateService.getModifiedDate).mockResolvedValue(null) +} + +describe("phoneList.vue - loading, naming, and internal-use banner", () => { + it("hides the Updated/internal-use block while the initial fetch is in flight", async () => { + expect.hasAssertions() + stubListInfo(true) + // Never resolves, so the fetch stays in flight: loading flips true (in onMounted) and + // never flips back. + vi.mocked(getPhoneListData).mockReturnValue(neverResolves()) + + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.text()).not.toContain("Click on a name to send an email") + expect(wrapper.text()).not.toContain("FOR INTERNAL USE ONLY") + }) + + it("shows the internal-use notice once loaded, for a caller with internal access", async () => { + expect.hasAssertions() + stubListInfo(true) + vi.mocked(getPhoneListData).mockResolvedValue([]) + + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.text()).toContain("Click on a name to send an email") + expect(wrapper.text()).toContain("FOR INTERNAL USE ONLY") + }) + + it("hides the internal-use notice once loaded, for a caller without internal access", async () => { + expect.hasAssertions() + stubListInfo(false) + vi.mocked(getPhoneListData).mockResolvedValue([]) + + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.text()).toContain("Click on a name to send an email") + expect(wrapper.text()).not.toContain("FOR INTERNAL USE ONLY") + }) + + it("takes its heading from the list rather than hard-coded page copy", async () => { + expect.hasAssertions() + stubListInfo(false) + vi.mocked(getPhoneListData).mockResolvedValue([]) + + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.find("h1").text()).toBe("Dean's Office Phone List") + }) + + it("fetches the list named by the route param", async () => { + expect.hasAssertions() + stubListInfo(false) + vi.mocked(getPhoneListData).mockResolvedValue([]) + + mountPage() + await flushPromises() + + expect(phoneListService.getPhoneListInfo).toHaveBeenCalledWith("VMDO") + expect(getPhoneListData).toHaveBeenCalledWith("VMDO", false, false) + }) + + it("reports an unknown list code instead of rendering an empty list", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(phoneListService.getPhoneListInfo).mockResolvedValue(null) + + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.text()).toContain("could not be found") + expect(getPhoneListData).not.toHaveBeenCalled() + }) +}) diff --git a/VueApp/src/Personnel/__tests__/phone-person-options-service.test.ts b/VueApp/src/Personnel/__tests__/phone-person-options-service.test.ts new file mode 100644 index 000000000..0a248a120 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/phone-person-options-service.test.ts @@ -0,0 +1,69 @@ +import { searchPeopleOptions } from "../services/phone-person-options-service" + +/** + * SearchPeopleOptions deliberately returns null (not []) on a failed request, so + * usePersonSearch's callers can fall back safely via `result ?? []` while still distinguishing + * "no matches" from "the fetch failed" for anyone who cares to. + */ + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ + get: (...args: unknown[]) => mockGet(...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 + }, + }), +})) + +describe("searchPeopleOptions()", () => { + it("returns the matching people, scoped to the given list code", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const people = [ + { + personId: 1, + firstName: "Amy", + lastName: "Smith", + fullName: "Amy Smith", + iamId: "asmith", + currentEmployee: true, + mailId: "asmith", + phoneData: null, + }, + ] + mockGet.mockResolvedValue({ success: true, result: people }) + + const result = await searchPeopleOptions("Smith", "VMDO") + + expect(mockGet).toHaveBeenCalledWith(expect.stringContaining("search=Smith")) + expect(mockGet).toHaveBeenCalledWith(expect.stringContaining("listCode=VMDO")) + expect(result).toStrictEqual(people) + }) + + it("returns an empty array (not null) when the search matches nobody", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: [] }) + + const result = await searchPeopleOptions("Nonexistent") + + expect(result).toStrictEqual([]) + }) + + it("returns null (not an empty array) when the request fails", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: false, result: null }) + + const result = await searchPeopleOptions("Smith") + + expect(result).toBeNull() + }) +}) diff --git a/VueApp/src/Personnel/__tests__/router-permissions.test.ts b/VueApp/src/Personnel/__tests__/router-permissions.test.ts new file mode 100644 index 000000000..994458107 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/router-permissions.test.ts @@ -0,0 +1,166 @@ +import { createPinia, setActivePinia } from "pinia" +import { useUserStore } from "@/store/UserStore" +import { router } from "../router" + +// The real beforeEach guard calls requireLogin, which hits the network and needs a Quasar/inject +// context. Stub it (Vitest hoists this above the imports) so the test exercises only the +// permission-driven redirect, not the auth plumbing. The spy is kept so the tests below can +// assert which permission prefix the guard asks for — that single call is the only thing that +// populates the permission set the route gate then reads. +const { mockRequireLogin } = vi.hoisted(() => ({ + mockRequireLogin: vi.fn<(...args: unknown[]) => unknown>(), +})) +vi.mock("@/composables/RequireLogin", () => ({ + useRequireLogin: () => ({ requireLogin: (...args: unknown[]) => mockRequireLogin(...args) }), + getLoginUrl: () => ({ value: "" }), +})) + +// Park on a neutral route first so the push to the target route is never a redundant +// navigation (which would resolve to a NavigationFailure and leave currentRoute unchanged). +async function goTo(path: string): Promise { + await router.push("/__reset__") + await router.push(path) +} + +/** + * Signs the caller in as far as the guard is concerned. isLoggedIn reads loginId, so that is the + * field that has to be set - a user object without it leaves the store logged out. + */ +function signIn() { + useUserStore().loadUser({ + firstName: "Test", + lastName: "Caller", + mailId: "caller", + loginId: "caller", + mothraId: "caller01", + userId: 1, + token: "", + emulating: false, + permissions: [], + }) +} + +function withPermissions(permissions: string[]) { + setActivePinia(createPinia()) + vi.clearAllMocks() + mockRequireLogin.mockResolvedValue(true) + useUserStore().setPermissions(permissions) +} + +describe("personnel router - permission loading", () => { + it("asks for the phone-list permissions the routes actually gate on", async () => { + expect.hasAssertions() + // SVMSecure.PhoneLists.* is the only permission set anything in this SPA reads, so the + // guard requests it directly. Asking for the area's own SVMSecure.Personnel prefix + // instead would leave the route gate below with nothing to match and need a second + // request to repair it. + withPermissions([]) + + await goTo("/Personnel/PhoneList/VMDO") + + expect(mockRequireLogin).toHaveBeenCalledWith(true, "SVMSecure.PhoneLists") + }) + + it("loads permissions in a single request per external navigation", async () => { + expect.hasAssertions() + // Only requireLogin populates permissions now. A second call here would mean the guard + // had gone back to topping the set up with its own extra fetch. + withPermissions([]) + await router.push("/__reset__") + mockRequireLogin.mockClear() + + await router.push("/Personnel/PhoneList/VMDO") + + expect(mockRequireLogin).toHaveBeenCalledExactlyOnceWith(true, "SVMSecure.PhoneLists") + }) + + it("skips re-authentication once the user is already logged in and navigating in-app", async () => { + expect.hasAssertions() + // Re-calling requireLogin on a tab switch would overwrite the permission array and + // flash the page, so an in-app navigation must not reach it again. + withPermissions(["SVMSecure.PhoneLists.SVMMaintain"]) + await goTo("/Personnel/PhoneList/VMDO") + signIn() + mockRequireLogin.mockClear() + + await router.push("/Personnel/SVMPhonesMaintain") + + expect(mockRequireLogin).not.toHaveBeenCalled() + }) + + it("abandons the navigation when login fails", async () => { + expect.hasAssertions() + withPermissions([]) + mockRequireLogin.mockResolvedValue(false) + + await goTo("/Personnel/PhoneList/VMDO") + + expect(router.currentRoute.value.path).not.toBe("/Personnel/PhoneList/VMDO") + }) +}) + +describe("personnel router - permission gating", () => { + it("redirects a caller without SVMMaintain away from SVMPhonesMaintain", async () => { + expect.hasAssertions() + withPermissions([]) + + await goTo("/Personnel/SVMPhonesMaintain") + + expect(router.currentRoute.value.name).toBe("PersonnelHome") + }) + + it("allows a caller with SVMMaintain onto SVMPhonesMaintain", async () => { + expect.hasAssertions() + withPermissions(["SVMSecure.PhoneLists.SVMMaintain"]) + + await goTo("/Personnel/SVMPhonesMaintain") + + expect(router.currentRoute.value.name).toBe("MaintainSchoolwidePhones") + }) + + it("allows navigation to unrestricted routes regardless of phone-list permissions", async () => { + expect.hasAssertions() + withPermissions([]) + + await goTo("/Personnel/PhoneList/VMDO") + + expect(router.currentRoute.value.name).toBe("PhoneList") + }) + + it("routes any list code through the one generic phone-list page", async () => { + expect.hasAssertions() + withPermissions([]) + + await goTo("/Personnel/PhoneList/SOMEOTHER") + + expect(router.currentRoute.value.name).toBe("PhoneList") + expect(router.currentRoute.value.params.code).toBe("SOMEOTHER") + }) + + it("lets the maintain route resolve without a static permission", async () => { + expect.hasAssertions() + // The required role is the list's own MaintainRole, so it cannot be known before the + // list is fetched. The page redirects on canMaintain=false and the API rejects writes; + // the router deliberately does not gate here. + withPermissions([]) + + await goTo("/Personnel/PhoneList/VMDO/Maintain") + + expect(router.currentRoute.value.name).toBe("MaintainPhoneList") + }) + + it("redirects the pre-Code VMDO paths so published links keep working", async () => { + expect.hasAssertions() + withPermissions([]) + + await goTo("/Personnel/VMDOPhones") + + expect(router.currentRoute.value.name).toBe("PhoneList") + expect(router.currentRoute.value.params.code).toBe("VMDO") + + await goTo("/Personnel/VMDOPhonesMaintain") + + expect(router.currentRoute.value.name).toBe("MaintainPhoneList") + expect(router.currentRoute.value.params.code).toBe("VMDO") + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-add-frequent-number-dialog.test.ts b/VueApp/src/Personnel/__tests__/svm-add-frequent-number-dialog.test.ts new file mode 100644 index 000000000..2a7956111 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-add-frequent-number-dialog.test.ts @@ -0,0 +1,104 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { Quasar, Notify } from "quasar" +import SVMAddFrequentNumberDialog from "../components/SVMAddFrequentNumberDialog.vue" +import { svmFrequentNumberService } from "../services/svm-frequent-number-service" +import type { SVMFrequentNumberRecord } from "../types/svm-phone-types" +import { apiResult } from "./test-utils" + +/** + * SVMAddFrequentNumberDialog is the one add/edit dialog with no custom validate() - + * useAddRecordDialog's validate always returns null, so both required fields (label, phone) are + * enforced entirely by QForm's own :rules. That's a different validation path than + * PhoneListAddRecordDialog/SVMAddRecordDialog exercise, where a custom validate() blocks submit. + */ + +vi.mock("../services/svm-frequent-number-service", () => ({ + svmFrequentNumberService: { + addFrequentNumber: vi.fn<(...args: unknown[]) => unknown>(), + updateFrequentNumber: vi.fn<(...args: unknown[]) => unknown>(), + }, +})) + +function mountDialog(props: { modelValue: boolean; editFrequentData?: SVMFrequentNumberRecord | null }) { + return mount(SVMAddFrequentNumberDialog, { + props, + global: { plugins: [[Quasar, { plugins: { Notify } }]] }, + attachTo: document.body, + }) +} + +async function submitDialogForm(wrapper: ReturnType): Promise { + await flushPromises() + await wrapper.findComponent({ name: "QForm" }).find("form").trigger("submit") + await flushPromises() +} + +function bodyText(): string { + return document.body.textContent ?? "" +} + +function resetTestState(): void { + vi.clearAllMocks() + document.body.innerHTML = "" +} + +describe("sVMAddFrequentNumberDialog.vue - add vs edit mode", () => { + it("shows the Add Frequent Number title and an Upload button in add mode", async () => { + expect.hasAssertions() + resetTestState() + mountDialog({ modelValue: true }) + await flushPromises() + + expect(bodyText()).toContain("Add Frequent Number") + expect(bodyText()).toContain("Upload") + }) + + it("shows the Edit Frequent Number title, pre-filled fields, and a Save Changes button in edit mode", async () => { + expect.hasAssertions() + resetTestState() + const wrapper = mountDialog({ + modelValue: true, + editFrequentData: { label: "Front Desk", phone: "530-555-1000", entryId: 5 }, + }) + await flushPromises() + + expect(bodyText()).toContain("Edit Frequent Number") + expect(bodyText()).toContain("Save Changes") + const inputs = wrapper.findAllComponents({ name: "QInput" }) + expect(inputs[0]!.props("modelValue")).toBe("Front Desk") + expect(inputs[1]!.props("modelValue")).toBe("530-555-1000") + }) + + it("blocks submit via QForm's own field rules when required fields are empty", async () => { + expect.hasAssertions() + resetTestState() + // No custom validate() exists on this dialog (unlike the other two), so this exercises + // QForm's native :rules gate instead of useAddRecordDialog's validate-then-block path. + const wrapper = mountDialog({ modelValue: true }) + + await submitDialogForm(wrapper) + + expect(svmFrequentNumberService.addFrequentNumber).not.toHaveBeenCalled() + expect(bodyText()).toContain("Please complete the required fields before saving.") + }) + + it("submits the update payload and emits saved + close in edit mode", async () => { + expect.hasAssertions() + resetTestState() + vi.mocked(svmFrequentNumberService.updateFrequentNumber).mockResolvedValue(apiResult({ result: true })) + const wrapper = mountDialog({ + modelValue: true, + editFrequentData: { label: "Front Desk", phone: "530-555-1000", entryId: 5 }, + }) + + await submitDialogForm(wrapper) + + expect(svmFrequentNumberService.updateFrequentNumber).toHaveBeenCalledWith(5, { + label: "Front Desk", + phone: "530-555-1000", + entryId: 5, + }) + expect(wrapper.emitted("saved")).toBeTruthy() + expect(wrapper.emitted("update:modelValue")).toContainEqual([false]) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-add-record-dialog.test.ts b/VueApp/src/Personnel/__tests__/svm-add-record-dialog.test.ts new file mode 100644 index 000000000..dc99dd890 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-add-record-dialog.test.ts @@ -0,0 +1,473 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { createRouter, createWebHistory } from "vue-router" +import { Quasar, Notify } from "quasar" +import { createPinia, setActivePinia } from "pinia" +import SVMAddRecordDialog from "../components/SVMAddRecordDialog.vue" +import { svmUnitService } from "../services/svm-unit-service" +import type { QSelectOption } from "quasar" +import type { SVMPhoneDisplayRecord, UnitAdminStaff, UnitFaxNumber, UnitOptions } from "../types/svm-phone-types" +import { apiResult } from "./test-utils" + +/** + * SVMAddRecordDialog covers add vs edit flows for a section-scoped SVM record. Unlike + * PhoneListAddRecordDialog, the Unit field is a real Quasar q-select gated by its own :rules + * (add mode only - edit mode swaps it for plain text), while the dean/director requirement is + * enforced by useAddRecordDialog's custom validate(), same as the other dialogs. Editing lets + * these tests reach that custom validation gate directly via editData, without needing to + * simulate picking an option in the real q-select. These tests mount the real dialog + + * RecordFormDialog + QForm and mock svmUnitService so no network call happens. + */ + +vi.mock("../services/svm-unit-service", () => ({ + svmUnitService: { + addUnitData: vi.fn<(...args: unknown[]) => unknown>(), + updateUnitData: vi.fn<(...args: unknown[]) => unknown>(), + }, +})) + +const selectorStub = { + props: ["modelValue", "label", "listCode"], + emits: ["update:modelValue"], + template: "", +} + +// Mirror the props the component actually declares, so a prop-shape change is a compile error +// here rather than a structural near-miss the mount silently accepts. +function mountDialog(props: { + modelValue: boolean + section: QSelectOption + units: UnitOptions[] + unitFaxNumbers: UnitFaxNumber[] + unitAdminStaff?: UnitAdminStaff[] + editData?: SVMPhoneDisplayRecord | null +}) { + const { unitAdminStaff = [], ...rest } = props + const pinia = createPinia() + setActivePinia(pinia) + const router = createRouter({ + history: createWebHistory(), + routes: [{ path: "/", component: { template: "" } }], + }) + return mount(SVMAddRecordDialog, { + props: { ...rest, unitAdminStaff }, + global: { + plugins: [[Quasar, { plugins: { Notify } }], router, pinia], + stubs: { PersonSelector: selectorStub }, + }, + attachTo: document.body, + }) +} + +function editRecord(overrides: Partial = {}): SVMPhoneDisplayRecord { + return { + sectionName: "VMDO", + unitName: "Dean's Office", + unitId: 10, + unitAbbrv: "DO", + officeLocation: "Room 100", + officeFax: "530-555-9999", + deanDirectorFullName: "Dean Person", + deanDirectorDisplayName: "Dean Person", + deanDirectorInterim: null, + deanDirectorIam: "dean01", + deanDirectorUnitPersonId: 1, + deanDirectorPhone: "530-555-1000", + deanDirectorModifiedDate: null, + deanDirectorModifiedBy: "jdoe", + adminStaffFullName: "Staff Person", + adminStaffDisplayName: "Staff Person", + adminStaffInterim: null, + adminStaffIam: "staff01", + adminStaffUnitPersonId: 2, + adminStaffPhone: "530-555-2000", + adminStaffModifiedDate: null, + adminStaffModifiedBy: "jdoe", + entryId: 1, + isOnlyRowForUnit: true, + ...overrides, + } +} + +async function submitDialogForm(wrapper: ReturnType): Promise { + await flushPromises() + await wrapper.findComponent({ name: "QForm" }).find("form").trigger("submit") + await flushPromises() +} + +function bodyText(): string { + return document.body.textContent ?? "" +} + +function resetTestState(): void { + vi.clearAllMocks() + document.body.innerHTML = "" +} + +describe("sVMAddRecordDialog.vue - add vs edit mode", () => { + it("shows the Add Phone Record title, Upload button, and the Unit q-select in add mode", async () => { + expect.hasAssertions() + resetTestState() + const wrapper = mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [], + unitFaxNumbers: [], + }) + await flushPromises() + + expect(bodyText()).toContain("Add Phone Record") + expect(bodyText()).toContain("Upload") + // The Unit q-select is add-mode-only; deanDirectorInterim/staffInterim selects are always + // present, so 3 selects (vs. 2 in edit mode) confirms it rendered. + expect(wrapper.findAllComponents({ name: "QSelect" })).toHaveLength(3) + }) + + it("hides the unit/modified-by summary text in add mode", async () => { + expect.hasAssertions() + resetTestState() + mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [], + unitFaxNumbers: [], + }) + await flushPromises() + + expect(bodyText()).not.toContain("Unit: ") + expect(bodyText()).not.toContain("Dean/Director Modified") + expect(bodyText()).not.toContain("Admin Staff Modified") + }) + + it("autofills the fax from the picked unit and submits it under the selected unit in add mode", async () => { + expect.hasAssertions() + resetTestState() + const wrapper = mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [{ section: 1, units: [{ label: "Dean's Office", value: "10" }] }], + unitFaxNumbers: [{ unitId: 10, fax: "530-555-9999" }], + }) + await flushPromises() + + // UnitId (number) vs. the q-select option's value (string "10"): a prior regression + // compared these directly and always missed, silently skipping the fax autofill. + await wrapper + .findAllComponents({ name: "QSelect" })[0]! + .vm.$emit("update:model-value", { label: "Dean's Office", value: "10" }) + await flushPromises() + const [deanSelector] = wrapper.findAllComponents(selectorStub) + await deanSelector!.vm.$emit("update:model-value", { iamId: "dean01", fullName: "Dean Person" }) + vi.mocked(svmUnitService.addUnitData).mockResolvedValue(apiResult({ result: true })) + + await submitDialogForm(wrapper) + + expect(svmUnitService.addUnitData).toHaveBeenCalledWith("10", expect.objectContaining({ fax: "530-555-9999" })) + }) +}) + +// Split from the describe above so neither function grows past the linter's line-count budget. +describe("sVMAddRecordDialog.vue - admin staff autofill in add mode", () => { + it("autofills the admin staff from the picked unit's existing staff record in add mode", async () => { + expect.hasAssertions() + resetTestState() + const wrapper = mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [{ section: 1, units: [{ label: "Dean's Office", value: "10" }] }], + unitFaxNumbers: [], + unitAdminStaff: [ + { + unitId: 10, + staffIam: "staff01", + staffFullName: "Staff Person", + staffPhone: "530-555-2000", + staffInterim: "Interim", + staffUnitPersonId: 2, + }, + ], + }) + await flushPromises() + + await wrapper + .findAllComponents({ name: "QSelect" })[0]! + .vm.$emit("update:model-value", { label: "Dean's Office", value: "10" }) + await flushPromises() + + // Adding another leader to a unit that already has staff should start the form with that + // staff record - same as edit mode - rather than leaving it for the user to look up again. + const [deanSelector, staffSelector] = wrapper.findAllComponents(selectorStub) + expect(staffSelector!.props("modelValue")).toMatchObject({ iamId: "staff01", fullName: "Staff Person" }) + + await deanSelector!.vm.$emit("update:model-value", { iamId: "dean01", fullName: "Dean Person" }) + vi.mocked(svmUnitService.addUnitData).mockResolvedValue(apiResult({ result: true })) + await submitDialogForm(wrapper) + + expect(svmUnitService.addUnitData).toHaveBeenCalledWith( + "10", + expect.objectContaining({ + staffIam: "staff01", + staffPhone: "530-555-2000", + staffInterim: "Interim", + staffUnitPerson: 2, + }), + ) + }) + + it("clears the admin staff fields when switching to a unit with no existing staff record", async () => { + expect.hasAssertions() + resetTestState() + const wrapper = mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [ + { + section: 1, + units: [ + { label: "Dean's Office", value: "10" }, + { label: "Anatomy", value: "20" }, + ], + }, + ], + unitFaxNumbers: [], + unitAdminStaff: [ + { + unitId: 10, + staffIam: "staff01", + staffFullName: "Staff Person", + staffPhone: "530-555-2000", + staffInterim: "", + staffUnitPersonId: 2, + }, + ], + }) + await flushPromises() + const [unitSelect] = wrapper.findAllComponents({ name: "QSelect" }) + + await unitSelect!.vm.$emit("update:model-value", { label: "Dean's Office", value: "10" }) + await flushPromises() + await unitSelect!.vm.$emit("update:model-value", { label: "Anatomy", value: "20" }) + await flushPromises() + + // Otherwise unit 10's staff would silently be carried over and saved under unit 20. + const [deanSelector, staffSelector] = wrapper.findAllComponents(selectorStub) + expect(staffSelector!.props("modelValue")).toMatchObject({ iamId: "", fullName: "" }) + + await deanSelector!.vm.$emit("update:model-value", { iamId: "dean02", fullName: "Other Dean" }) + vi.mocked(svmUnitService.addUnitData).mockResolvedValue(apiResult({ result: true })) + await submitDialogForm(wrapper) + + expect(svmUnitService.addUnitData).toHaveBeenCalledWith( + "20", + expect.objectContaining({ staffIam: "", staffPhone: "", staffUnitPerson: -1 }), + ) + }) +}) + +describe("sVMAddRecordDialog.vue - edit mode", () => { + it("shows the Edit Phone Record title, unit summary, and Save Changes button in edit mode", async () => { + expect.hasAssertions() + resetTestState() + mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [], + unitFaxNumbers: [], + editData: editRecord(), + }) + await flushPromises() + + expect(bodyText()).toContain("Edit Phone Record") + expect(bodyText()).toContain("Unit: Dean's Office") + expect(bodyText()).toContain("Save Changes") + }) + + it("shows the modified-by summary and hides the Unit q-select in edit mode", async () => { + expect.hasAssertions() + resetTestState() + const wrapper = mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [], + unitFaxNumbers: [], + editData: editRecord(), + }) + await flushPromises() + + expect(bodyText()).toContain("Dean/Director Modified") + expect(bodyText()).toContain("Admin Staff Modified") + expect(bodyText()).toContain("by jdoe") + expect(wrapper.findAllComponents({ name: "QSelect" })).toHaveLength(2) + }) + + it("blocks submit with a validation message when the record has no leadership", async () => { + expect.hasAssertions() + resetTestState() + const wrapper = mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [], + unitFaxNumbers: [], + editData: editRecord({ deanDirectorIam: "", deanDirectorFullName: "" }), + }) + + await submitDialogForm(wrapper) + + expect(svmUnitService.updateUnitData).not.toHaveBeenCalled() + expect(bodyText()).toContain("Must specify leadership.") + }) + + it("submits the update payload and emits saved + close in edit mode", async () => { + expect.hasAssertions() + resetTestState() + vi.mocked(svmUnitService.updateUnitData).mockResolvedValue(apiResult({ result: true })) + const wrapper = mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [], + unitFaxNumbers: [], + editData: editRecord(), + }) + + await submitDialogForm(wrapper) + + expect(svmUnitService.updateUnitData).toHaveBeenCalledWith( + 10, + expect.objectContaining({ + fax: "530-555-9999", + location: "Room 100", + deanIam: "dean01", + deanPhone: "530-555-1000", + deanUnitPerson: 1, + staffIam: "staff01", + staffPhone: "530-555-2000", + staffUnitPerson: 2, + }), + ) + expect(wrapper.emitted("saved")).toBeTruthy() + expect(wrapper.emitted("update:modelValue")).toContainEqual([false]) + }) + + it("carries the interim wording through to the update payload when the record has one", async () => { + expect.hasAssertions() + resetTestState() + vi.mocked(svmUnitService.updateUnitData).mockResolvedValue(apiResult({ result: true })) + const wrapper = mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [], + unitFaxNumbers: [], + editData: editRecord({ deanDirectorInterim: "Interim", adminStaffInterim: "Vice" }), + }) + + await submitDialogForm(wrapper) + + expect(svmUnitService.updateUnitData).toHaveBeenCalledWith( + 10, + expect.objectContaining({ deanInterim: "Interim", staffInterim: "Vice" }), + ) + }) + + it("submits blank interim values when the record carries none", async () => { + expect.hasAssertions() + resetTestState() + vi.mocked(svmUnitService.updateUnitData).mockResolvedValue(apiResult({ result: true })) + const wrapper = mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [], + unitFaxNumbers: [], + editData: editRecord(), + }) + + await submitDialogForm(wrapper) + + expect(svmUnitService.updateUnitData).toHaveBeenCalledWith( + 10, + expect.objectContaining({ deanInterim: "", staffInterim: "" }), + ) + }) +}) + +describe("sVMAddRecordDialog.vue - form field wiring", () => { + it("autofills each phone field from the person picked for that role", async () => { + expect.hasAssertions() + resetTestState() + const wrapper = mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [], + unitFaxNumbers: [], + }) + await flushPromises() + const [deanSelector, staffSelector] = wrapper.findAllComponents(selectorStub) + + await deanSelector!.vm.$emit("update:modelValue", { phoneData: { phone: "530-555-1111" } }) + await staffSelector!.vm.$emit("update:modelValue", { phoneData: { phone: "530-555-2222" } }) + await flushPromises() + + // Each picker fills only its own phone field; crossing them would quietly file one + // person's number under the other. QInput order in add mode is location, fax, + // dean/director phone, admin staff phone. + const inputs = wrapper.findAllComponents({ name: "QInput" }) + expect(inputs[2]!.props("modelValue")).toBe("530-555-1111") + expect(inputs[3]!.props("modelValue")).toBe("530-555-2222") + }) + + it("clears the phone field when the person for that role is unset", async () => { + expect.hasAssertions() + resetTestState() + const wrapper = mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [], + unitFaxNumbers: [], + }) + await flushPromises() + const [deanSelector] = wrapper.findAllComponents(selectorStub) + await deanSelector!.vm.$emit("update:modelValue", { phoneData: { phone: "530-555-1111" } }) + await flushPromises() + + await deanSelector!.vm.$emit("update:modelValue", null) + await flushPromises() + + expect(wrapper.findAllComponents({ name: "QInput" })[2]!.props("modelValue")).toBe("") + }) + + it("requires a unit before an add can be submitted", async () => { + expect.hasAssertions() + resetTestState() + const wrapper = mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [{ section: 1, units: [{ label: "Dean's Office", value: "10" }] }], + unitFaxNumbers: [], + }) + + await submitDialogForm(wrapper) + + expect(bodyText()).toContain("Please select a unit") + expect(svmUnitService.addUnitData).not.toHaveBeenCalled() + }) + + it("empties the form when the record being edited is cleared", async () => { + expect.hasAssertions() + resetTestState() + const wrapper = mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [], + unitFaxNumbers: [], + editData: editRecord(), + }) + await flushPromises() + + // The dialog re-derives its form whenever editData changes, and closing an edit clears + // it. Nothing to edit has to read as every field blank, not as the previous record + // lingering in the inputs the next time the dialog opens to add. + await wrapper.setProps({ editData: null }) + await flushPromises() + + expect(bodyText()).not.toContain("Room 100") + expect(bodyText()).not.toContain("530-555-9999") + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-data-fetch.test.ts b/VueApp/src/Personnel/__tests__/svm-data-fetch.test.ts new file mode 100644 index 000000000..21f849a8f --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-data-fetch.test.ts @@ -0,0 +1,420 @@ +import { getFrequentlyCalledNumbers, getSVMData } from "../composables/svm-data-fetch" +import { svmFrequentNumberService } from "../services/svm-frequent-number-service" +import { svmSectionService } from "../services/svm-section-service" +import { svmUnitService } from "../services/svm-unit-service" +import type { + SVMFrequentNumberAPIResponse, + SVMSectionAPIResponse, + SVMUnitAPIResponse, + SVMUnitPerson, +} from "../types/svm-phone-types" + +vi.mock("../services/svm-section-service", () => ({ + svmSectionService: { getSections: vi.fn<(...args: unknown[]) => unknown>() }, +})) +vi.mock("../services/svm-unit-service", () => ({ + svmUnitService: { getAllUnits: vi.fn<(...args: unknown[]) => unknown>() }, +})) +vi.mock("../services/svm-frequent-number-service", () => ({ + svmFrequentNumberService: { getFrequentNumbers: vi.fn<(...args: unknown[]) => unknown>() }, +})) + +function makeSection(overrides: Partial = {}): SVMSectionAPIResponse { + return { + sectionId: 1, + name: "VMDO", + includeAbbrv: false, + unitName: "Unit", + directorTitle: "Director", + sortOrder: 1, + ...overrides, + } +} + +function makeUnitPerson(overrides: Partial = {}): SVMUnitPerson { + return { + unitPersonId: 1, + unitId: 10, + personIam: "dean01", + office: "Room 100", + posType: "Dean", + interim: null, + modifiedDate: null, + modifiedBy: null, + unit: null, + person: { + personIam: "dean01", + phone: "530-555-1000", + directPhone: "", + office: "Room 100", + modifiedDate: null, + modifiedBy: null, + unitPersons: null, + phoneListUnitPersons: null, + viperPerson: { + personId: 1, + firstName: "Dean", + lastName: "Person", + fullName: "Dean Person", + iamId: "dean01", + currentEmployee: true, + mailId: "", + }, + viperModPerson: null, + }, + viperModPerson: null, + ...overrides, + } +} + +function makeUnit(overrides: Partial = {}): SVMUnitAPIResponse { + return { + unitId: 10, + sectionId: 1, + name: "Dean's Office", + abbrv: "DO", + sortOrder: 1, + fax: "530-555-9999", + section: null, + unitPersons: [], + ...overrides, + } +} + +/** A unit whose only active person is admin staff - no dean/director row. */ +function staffOnlyUnit(unitId: number, unitPersonId: number, iamId: string): SVMUnitAPIResponse { + return makeUnit({ + unitId, + name: `Unit ${unitId}`, + unitPersons: [ + makeUnitPerson({ + unitPersonId, + unitId, + personIam: iamId, + posType: "Staff", + person: { + personIam: iamId, + phone: "530-555-2000", + directPhone: "", + office: "Room 100", + modifiedDate: null, + modifiedBy: null, + unitPersons: null, + phoneListUnitPersons: null, + viperPerson: { + personId: unitPersonId, + firstName: "Staff", + lastName: iamId, + fullName: `Staff ${iamId}`, + iamId, + currentEmployee: true, + mailId: "", + }, + viperModPerson: null, + }, + }), + ], + }) +} + +describe("getSVMData()", () => { + it("builds view-mode columns with location/phone/fax fields, and edit-mode columns with edit/delete", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection()]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([]) + + const view = await getSVMData(false) + const edit = await getSVMData(true) + + const viewCols = view.newSections[0]!.cols!.map((c) => c.name) + const editCols = edit.newSections[0]!.cols!.map((c) => c.name) + expect(viewCols).toStrictEqual([ + "unitName", + "location", + "deanDirector", + "dirPhone", + "fax", + "adminStaff", + "adminPhone", + ]) + expect(editCols).toStrictEqual(["unitName", "deanDirector", "adminStaff", "edit", "delete"]) + }) + + it("includes the abbreviation column only in view mode when the section flags includeAbbrv", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection({ includeAbbrv: true })]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([]) + + const view = await getSVMData(false) + const edit = await getSVMData(true) + + expect(view.newSections[0]!.cols!.map((c) => c.name)).toContain("abbreviation") + expect(edit.newSections[0]!.cols!.map((c) => c.name)).not.toContain("abbreviation") + }) + + it("splits leaders from the shared admin staff record and appends the interim suffix", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection()]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([ + makeUnit({ + unitPersons: [ + makeUnitPerson({ interim: "Interim" }), + makeUnitPerson({ + unitPersonId: 2, + personIam: "staff01", + posType: "Staff", + person: { + personIam: "staff01", + phone: "530-555-2000", + directPhone: "", + office: "Room 100", + modifiedDate: null, + modifiedBy: null, + unitPersons: null, + phoneListUnitPersons: null, + viperPerson: { + personId: 2, + firstName: "Staff", + lastName: "Person", + fullName: "Staff Person", + iamId: "staff01", + currentEmployee: true, + mailId: "", + }, + viperModPerson: null, + }, + }), + ], + }), + ]) + + const { newSections } = await getSVMData(false) + + expect(newSections[0]!.rows).toHaveLength(1) + const row = newSections[0]!.rows[0]! + expect(row.deanDirectorDisplayName).toBe("Dean Person (Interim)") + expect(row.adminStaffDisplayName).toBe("Staff Person") + expect(row.adminStaffIam).toBe("staff01") + }) + + it("produces no rows, without throwing, when a unit has no unitPersons", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection()]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([makeUnit({ unitPersons: null })]) + + const { newSections } = await getSVMData(false) + + expect(newSections[0]!.rows).toStrictEqual([]) + }) + + it("blanks the admin staff fields when a unit has a leader but no assigned staff", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection()]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([makeUnit({ unitPersons: [makeUnitPerson()] })]) + + const { newSections } = await getSVMData(false) + + expect(newSections[0]!.rows).toHaveLength(1) + const row = newSections[0]!.rows[0]! + expect(row.adminStaffDisplayName).toBe("") + expect(row.adminStaffUnitPersonId).toBe(-1) + }) + + it("falls back to an empty person, without throwing, when a leader's person record is null", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection()]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([ + makeUnit({ unitPersons: [makeUnitPerson({ person: null })] }), + ]) + + const { newSections } = await getSVMData(false) + + expect(newSections[0]!.rows).toHaveLength(1) + const row = newSections[0]!.rows[0]! + expect(row.deanDirectorFullName).toBe("") + expect(row.deanDirectorIam).toBe("") + expect(row.deanDirectorPhone).toBe("") + }) + + it("keeps a staff-only unit visible by standing in a blank dean/director", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection()]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([staffOnlyUnit(10, 7, "staff01")]) + + const { newSections } = await getSVMData(false) + + // Rows are emitted per leader, so without the stand-in this unit would produce no row at + // all and the staff member would silently vanish from the list. + expect(newSections[0]!.rows).toHaveLength(1) + const row = newSections[0]!.rows[0]! + expect(row.adminStaffIam).toBe("staff01") + expect(row.adminStaffUnitPersonId).toBe(7) + expect(row.deanDirectorDisplayName).toBe("") + // Must stay -1 so SVMPhonesMaintain's delete guard skips the nonexistent dean row. + expect(row.deanDirectorUnitPersonId).toBe(-1) + }) + + it("gives staff-only rows distinct row keys", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection()]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([ + staffOnlyUnit(10, 7, "staff01"), + staffOnlyUnit(11, 8, "staff02"), + ]) + + const { newSections } = await getSVMData(false) + + // The q-table row-key comes from entryId. The stand-in leader carries -1, so falling back + // to the staff's id is what keeps two staff-only units in one section from colliding and + // letting Vue reuse one row's DOM for the other. + const keys = newSections[0]!.rows.map((r) => r.entryId) + expect(keys).toStrictEqual([7, 8]) + }) + + it("flags a row as the last for its unit only when the unit yields one row", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection()]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([ + makeUnit({ + unitPersons: [ + makeUnitPerson({ unitPersonId: 1, personIam: "dean01" }), + makeUnitPerson({ unitPersonId: 2, personIam: "dean02" }), + ], + }), + ]) + + const { newSections: twoLeaders } = await getSVMData(false) + + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection()]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([makeUnit({ unitPersons: [makeUnitPerson()] })]) + + const { newSections: oneLeader } = await getSVMData(false) + + // The delete confirmation reads this to decide whether to name the admin staff as being + // removed, since the API keeps them while any leader row still lists them. + expect(twoLeaders[0]!.rows.map((r) => r.isOnlyRowForUnit)).toStrictEqual([false, false]) + expect(oneLeader[0]!.rows[0]!.isOnlyRowForUnit).toBeTruthy() + }) +}) + +describe("getSVMData() - row shaping edge cases", () => { + it("ignores a unit person carrying no PosType, who is neither a leader nor the staff", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection()]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([ + makeUnit({ + unitPersons: [makeUnitPerson(), makeUnitPerson({ unitPersonId: 2, posType: null })], + }), + ]) + + const { newSections } = await getSVMData(false) + + // Only the Dean row: the PosType-less record is not a leader, so it gets no row of its + // own, and it is not the staff either, so it does not fill the admin staff fields. + expect(newSections[0]!.rows).toHaveLength(1) + expect(newSections[0]!.rows[0]!.adminStaffUnitPersonId).toBe(-1) + }) + + it("hides a row whose leader and staff both lack a record to key it on", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection()]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([ + makeUnit({ unitPersons: [makeUnitPerson({ unitPersonId: -1 })] }), + ]) + + const { newSections } = await getSVMData(false) + + // The row key doubles as the delete target, so a row that cannot supply one is not + // rendered at all rather than shown with a key that deletes nothing. + expect(newSections[0]!.rows).toStrictEqual([]) + }) + + it("blanks a leader's phone when their record carries none", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection()]) + const leader = makeUnitPerson() + leader.person!.phone = null + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([makeUnit({ unitPersons: [leader] })]) + + const { newSections } = await getSVMData(false) + + expect(newSections[0]!.rows[0]!.deanDirectorPhone).toBe("") + }) +}) + +describe("getSVMData() - section grouping", () => { + it("fetches units once and groups them onto their own sections", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([ + makeSection({ sectionId: 1, name: "VMDO" }), + makeSection({ sectionId: 2, name: "Departments" }), + ]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([ + makeUnit({ unitId: 10, sectionId: 1, name: "Dean's Office", unitPersons: [makeUnitPerson()] }), + makeUnit({ + unitId: 20, + sectionId: 2, + name: "Anatomy", + unitPersons: [makeUnitPerson({ unitPersonId: 5, unitId: 20 })], + }), + ]) + + const { newSections, newUnitOptions } = await getSVMData(false) + + // One request for the whole list rather than one per section, so page load no longer + // costs a round trip per section. + expect(svmUnitService.getAllUnits).toHaveBeenCalledOnce() + expect(newSections[0]!.rows.map((r) => r.unitName)).toStrictEqual(["Dean's Office"]) + expect(newSections[1]!.rows.map((r) => r.unitName)).toStrictEqual(["Anatomy"]) + expect(newUnitOptions).toStrictEqual([ + { section: 1, units: [{ label: "Dean's Office", value: "10" }] }, + { section: 2, units: [{ label: "Anatomy", value: "20" }] }, + ]) + }) + + it("leaves a section with no units empty rather than borrowing another section's", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([ + makeSection({ sectionId: 1, name: "VMDO" }), + makeSection({ sectionId: 2, name: "Departments" }), + ]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([ + makeUnit({ unitId: 20, sectionId: 2, unitPersons: [makeUnitPerson({ unitId: 20 })] }), + ]) + + const { newSections } = await getSVMData(false) + + expect(newSections[0]!.rows).toStrictEqual([]) + expect(newSections[1]!.rows).toHaveLength(1) + }) +}) + +describe("getFrequentlyCalledNumbers()", () => { + it("maps numberId to entryId", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const apiResponse: SVMFrequentNumberAPIResponse[] = [ + { numberId: 5, label: "Front Desk", phone: "530-555-1000", sortOrder: null }, + ] + vi.mocked(svmFrequentNumberService.getFrequentNumbers).mockResolvedValue(apiResponse) + + const results = await getFrequentlyCalledNumbers() + + expect(results).toStrictEqual([{ label: "Front Desk", phone: "530-555-1000", entryId: 5 }]) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-frequent-number-service.test.ts b/VueApp/src/Personnel/__tests__/svm-frequent-number-service.test.ts new file mode 100644 index 000000000..4fa5760af --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-frequent-number-service.test.ts @@ -0,0 +1,46 @@ +import { svmFrequentNumberService } from "../services/svm-frequent-number-service" + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +const mockPost = vi.fn<(...args: unknown[]) => unknown>() +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + put: vi.fn<(...args: unknown[]) => unknown>(), + del: vi.fn<(...args: unknown[]) => unknown>(), + }), +})) + +describe("svmFrequentNumberService()", () => { + it("returns the frequent numbers on success", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const numbers = [{ numberId: 1, label: "Front Desk", phone: "530-555-1000", sortOrder: null }] + mockGet.mockResolvedValue({ success: true, result: numbers }) + + const result = await svmFrequentNumberService.getFrequentNumbers() + + expect(result).toStrictEqual(numbers) + }) + + it("normalizes a failed request to an empty array", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: false, result: null }) + + const result = await svmFrequentNumberService.getFrequentNumbers() + + expect(result).toStrictEqual([]) + }) + + it("posts new frequent number data to the base endpoint", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockPost.mockResolvedValue({ success: true, result: true }) + const formData = { label: "Front Desk", phone: "530-555-1000", entryId: -1 } + + await svmFrequentNumberService.addFrequentNumber(formData) + + expect(mockPost).toHaveBeenCalledWith(expect.stringContaining("frequentnumbers"), formData) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-frequent-number-table.test.ts b/VueApp/src/Personnel/__tests__/svm-frequent-number-table.test.ts new file mode 100644 index 000000000..b7e27a8f3 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-frequent-number-table.test.ts @@ -0,0 +1,35 @@ +import { mount } from "@vue/test-utils" +import { Quasar } from "quasar" +import SVMFrequentNumberTable from "../components/SVMFrequentNumberTable.vue" +import type { SVMFrequentNumberRecord } from "../types/svm-phone-types" + +const numbers: SVMFrequentNumberRecord[] = [{ label: "Front Desk", phone: "530-555-1000", entryId: 1 }] + +function mountTable(editRecords: boolean) { + return mount(SVMFrequentNumberTable, { + props: { frequentNumbers: numbers, loading: false, editRecords, search: "" }, + global: { plugins: [Quasar] }, + }) +} + +function hasAddButton(wrapper: ReturnType): boolean { + return wrapper.findAllComponents({ name: "QBtn" }).some((btn) => btn.props("icon") === "add") +} + +describe("sVMFrequentNumberTable.vue - editRecords gating", () => { + it("shows the add button and edit/delete action buttons when editRecords is true", () => { + expect.hasAssertions() + const wrapper = mountTable(true) + + expect(hasAddButton(wrapper)).toBeTruthy() + expect(wrapper.findAllComponents({ name: "RecordActionButton" })).toHaveLength(2) + }) + + it("hides the add button and edit/delete action buttons when editRecords is false", () => { + expect.hasAssertions() + const wrapper = mountTable(false) + + expect(hasAddButton(wrapper)).toBeFalsy() + expect(wrapper.findAllComponents({ name: "RecordActionButton" })).toHaveLength(0) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-modified-date-service.test.ts b/VueApp/src/Personnel/__tests__/svm-modified-date-service.test.ts new file mode 100644 index 000000000..5f76d3bf0 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-modified-date-service.test.ts @@ -0,0 +1,29 @@ +import { svmModifiedDateService } from "../services/svm-modified-date-service" + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ get: (...args: unknown[]) => mockGet(...args) }), +})) + +describe("svmModifiedDateService()", () => { + it("returns the modified date", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: "2026-01-01T00:00:00" }) + + const result = await svmModifiedDateService.getModifiedDate() + + expect(mockGet).toHaveBeenCalledWith(expect.stringContaining("svm/modifiedDate")) + expect(result).toBe("2026-01-01T00:00:00") + }) + + it("returns null when there is no modified date on record", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: null }) + + const result = await svmModifiedDateService.getModifiedDate() + + expect(result).toBeNull() + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-phone-section-table.test.ts b/VueApp/src/Personnel/__tests__/svm-phone-section-table.test.ts new file mode 100644 index 000000000..0c04f56ac --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-phone-section-table.test.ts @@ -0,0 +1,37 @@ +import { mount } from "@vue/test-utils" +import { Quasar } from "quasar" +import SVMPhoneSectionTable from "../components/SVMPhoneSectionTable.vue" +import type { SVMPhoneSection } from "../types/svm-phone-types" + +const cols = [{ name: "unitName", label: "Unit", field: "unitName", align: "left" as const }] + +function makeSection(): SVMPhoneSection { + return { title: "VMDO", id: 1, cols, rows: [] } +} + +function mountTable(isModify: boolean) { + return mount(SVMPhoneSectionTable, { + props: { section: makeSection(), loading: false, isModify, search: "" }, + global: { plugins: [Quasar] }, + }) +} + +function hasAddButton(wrapper: ReturnType): boolean { + return wrapper.findAllComponents({ name: "QBtn" }).some((btn) => btn.props("icon") === "add") +} + +describe("sVMPhoneSectionTable.vue - isModify gating", () => { + it("shows the add button when isModify is true", () => { + expect.hasAssertions() + const wrapper = mountTable(true) + + expect(hasAddButton(wrapper)).toBeTruthy() + }) + + it("hides the add button when isModify is false", () => { + expect.hasAssertions() + const wrapper = mountTable(false) + + expect(hasAddButton(wrapper)).toBeFalsy() + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-phones-maintain.test.ts b/VueApp/src/Personnel/__tests__/svm-phones-maintain.test.ts new file mode 100644 index 000000000..53ecf36f6 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-phones-maintain.test.ts @@ -0,0 +1,384 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { Quasar, Notify } from "quasar" +import SVMPhonesMaintain from "../pages/SVMPhonesMaintain.vue" +import SVMAddRecordDialog from "../components/SVMAddRecordDialog.vue" +import SVMAddFrequentNumberDialog from "../components/SVMAddFrequentNumberDialog.vue" +import SVMFrequentNumberTable from "../components/SVMFrequentNumberTable.vue" +import { getFrequentlyCalledNumbers, getSVMData } from "../composables/svm-data-fetch.ts" +import { svmUnitService } from "../services/svm-unit-service" +import { svmFrequentNumberService } from "../services/svm-frequent-number-service.ts" +import type { SVMFrequentNumberRecord, SVMPhoneDisplayRecord, SVMPhoneSection } from "../types/svm-phone-types" +import { apiError, apiResult } from "./test-utils" + +/** + * SVMPhonesMaintain reports delete outcomes as toasts rather than as a page banner, since the + * reload that follows a delete would wipe page-local state before the user read it. Reaching one + * means driving the real child chain (SVMPhoneSectionTable / SVMFrequentNumberTable -> + * RecordActionButton -> the confirm dialog -> the service), because the page exposes no state a + * test could set directly. + * + * The page maintains two independent lists - unit rows and frequently called numbers - through + * near-identical add/edit/delete paths, so both are exercised here. + */ + +vi.mock("../composables/svm-data-fetch.ts", () => ({ + getSVMData: vi.fn<(...args: unknown[]) => unknown>(), + getFrequentlyCalledNumbers: vi.fn<(...args: unknown[]) => unknown>(), +})) +vi.mock("../services/svm-unit-service", () => ({ + svmUnitService: { + addUnitData: vi.fn<(...args: unknown[]) => unknown>(), + updateUnitData: vi.fn<(...args: unknown[]) => unknown>(), + deleteRow: vi.fn<(...args: unknown[]) => unknown>(), + }, +})) +vi.mock("../services/svm-frequent-number-service.ts", () => ({ + svmFrequentNumberService: { + addFrequentNumber: vi.fn<(...args: unknown[]) => unknown>(), + updateFrequentNumber: vi.fn<(...args: unknown[]) => unknown>(), + deleteFrequentNumber: vi.fn<(...args: unknown[]) => unknown>(), + }, +})) +// Stub only the public useQuasar export, so the toasts the page raises can be asserted directly. +// Quasar components resolve $q through their own internals, so QTable and friends still render. +const { mockNotify } = vi.hoisted(() => ({ mockNotify: vi.fn<(...args: unknown[]) => unknown>() })) +vi.mock("quasar", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useQuasar: () => ({ notify: mockNotify }) } +}) + +const { mockConfirmAction } = vi.hoisted(() => ({ + mockConfirmAction: vi.fn<(...args: unknown[]) => unknown>(), +})) +vi.mock("@/composables/use-confirm-dialog", () => ({ + useConfirmDialog: () => ({ confirmAction: mockConfirmAction }), +})) + +function sectionWithDeletableRow(rowOverrides: Partial = {}): SVMPhoneSection { + return { + title: "VMDO", + id: 1, + cols: [ + { name: "unitName", label: "Unit", field: "unitName", align: "left" }, + { name: "edit", label: "Edit", field: "edit", align: "left" }, + { name: "delete", label: "Delete", field: "delete", align: "left" }, + ], + rows: [ + { + sectionName: "VMDO", + unitName: "Dean's Office", + unitId: 10, + unitAbbrv: "DO", + officeLocation: "Room 100", + officeFax: "530-555-9999", + deanDirectorFullName: "Dean Person", + deanDirectorDisplayName: "Dean Person", + deanDirectorInterim: null, + deanDirectorIam: "dean01", + deanDirectorUnitPersonId: 1, + deanDirectorPhone: "530-555-1000", + deanDirectorModifiedDate: null, + deanDirectorModifiedBy: null, + adminStaffFullName: null, + adminStaffDisplayName: "", + adminStaffInterim: null, + adminStaffIam: null, + adminStaffUnitPersonId: null, + adminStaffPhone: null, + adminStaffModifiedDate: null, + adminStaffModifiedBy: null, + entryId: 1, + isOnlyRowForUnit: true, + ...rowOverrides, + }, + ], + } +} + +const frequentNumber: SVMFrequentNumberRecord = { label: "Front Desk", phone: "530-555-1000", entryId: 7 } + +const personSelectorStub = { + props: ["modelValue", "label", "listId"], + emits: ["update:modelValue"], + template: "", +} + +/** + * Mounts the page with the data the test cares about, waiting out the onMounted load. + * Anything not named here loads empty. + */ +async function mountPage(data: { sections?: SVMPhoneSection[]; frequentNumbers?: SVMFrequentNumberRecord[] } = {}) { + vi.mocked(getSVMData).mockResolvedValue({ + newSections: data.sections ?? [], + newUnitOptions: [], + newUnitFaxNumbers: [], + newUnitAdminStaff: [], + }) + vi.mocked(getFrequentlyCalledNumbers).mockResolvedValue(data.frequentNumbers ?? []) + + const wrapper = mount(SVMPhonesMaintain, { + global: { + plugins: [[Quasar, { plugins: { Notify } }]], + stubs: { PersonSelector: personSelectorStub }, + }, + }) + await flushPromises() + return wrapper +} + +type Page = Awaited> + +function findAddButton(root: Pick) { + return root.findAllComponents({ name: "QBtn" }).find((btn) => btn.props("icon") === "add") +} + +/** Clicks an edit/delete button within one table, so the two tables' buttons cannot be confused. */ +async function clickAction(root: Pick, action: "edit" | "delete"): Promise { + const button = root.findAllComponents({ name: "RecordActionButton" }).find((btn) => btn.props("action") === action) + expect(button).toBeTruthy() + await button!.vm.$emit("action") + await flushPromises() +} + +/** + * Per-test reset, called as the first line of each test rather than from a beforeEach, since + * vitest/no-hooks is on and the rest of the suite keeps its setup inside the test body. + * Pass false to decline the confirmation dialog. + */ +function resetMocks(confirmed = true) { + vi.clearAllMocks() + mockConfirmAction.mockResolvedValue(confirmed) + // RecordFormDialog's QDialog teleports to document.body, which outlives the wrapper, so a + // previous test's dialog would still be there when asserting on document.body.textContent. + document.body.innerHTML = "" +} + +describe("sVMPhonesMaintain.vue - delete outcomes", () => { + it("raises no toast on a normal load", async () => { + expect.hasAssertions() + resetMocks() + + await mountPage() + + expect(mockNotify).not.toHaveBeenCalled() + }) + + it("raises a toast carrying the server message when a delete fails", async () => { + expect.hasAssertions() + resetMocks() + vi.mocked(svmUnitService.deleteRow).mockResolvedValue(apiError(["Failed to delete record"])) + const wrapper = await mountPage({ sections: [sectionWithDeletableRow()] }) + + await clickAction(wrapper, "delete") + + // A failed delete is transient, so it is reported as a toast rather than a banner, + // which would otherwise persist past the reload that follows. + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: "negative", message: "Failed to delete record" }), + ) + }) + + it("opens the add dialog scoped to the clicked section", async () => { + expect.hasAssertions() + resetMocks() + const wrapper = await mountPage({ sections: [sectionWithDeletableRow()] }) + + await findAddButton(wrapper.findComponent({ name: "SVMPhoneSectionTable" }))!.trigger("click") + await flushPromises() + + const dialog = wrapper.findComponent(SVMAddRecordDialog) + expect(dialog.props("modelValue")).toBeTruthy() + expect(dialog.props("section")).toStrictEqual({ label: "VMDO", value: "1" }) + }) + + it("opens the edit dialog pre-filled when edit is clicked on a row", async () => { + expect.hasAssertions() + resetMocks() + const wrapper = await mountPage({ sections: [sectionWithDeletableRow()] }) + + await clickAction(wrapper.findComponent({ name: "SVMPhoneSectionTable" }), "edit") + + const dialog = wrapper.findComponent(SVMAddRecordDialog) + expect(dialog.props("modelValue")).toBeTruthy() + // RecordFormDialog's QDialog teleports its content to document.body. + expect(document.body.textContent).toContain("Edit Phone Record") + expect(document.body.textContent).toContain("Unit: Dean's Office") + }) + + it("reloads the phone data when the dialog reports a save", async () => { + expect.hasAssertions() + resetMocks() + const wrapper = await mountPage() + const callsBeforeSave = vi.mocked(getSVMData).mock.calls.length + + await wrapper.findComponent(SVMAddRecordDialog).vm.$emit("saved", true) + await flushPromises() + + expect(vi.mocked(getSVMData).mock.calls.length).toBeGreaterThan(callsBeforeSave) + }) +}) + +describe("sVMPhonesMaintain.vue - delete confirmation", () => { + it("names both people in the delete confirmation when the row is the unit's last", async () => { + expect.hasAssertions() + resetMocks(false) + const wrapper = await mountPage({ + sections: [sectionWithDeletableRow({ adminStaffFullName: "Staff Person" })], + }) + + await clickAction(wrapper, "delete") + + // IsOnlyRowForUnit means no other row is left to list the admin staff, so this delete + // really does remove both. + expect(mockConfirmAction).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("Dean Person and Staff Person") }), + ) + }) + + it("names only the dean/director when another row still lists the admin staff", async () => { + expect.hasAssertions() + resetMocks(false) + const wrapper = await mountPage({ + sections: [sectionWithDeletableRow({ adminStaffFullName: "Staff Person", isOnlyRowForUnit: false })], + }) + + await clickAction(wrapper, "delete") + + // The admin staff survives this delete, so promising their removal would be wrong. + const [confirmArgs] = vi.mocked(mockConfirmAction).mock.calls[0] as [{ message: string }] + expect(confirmArgs.message).toContain("Dean Person") + expect(confirmArgs.message).not.toContain("Staff Person") + }) + + it("does not delete anything when the confirmation is declined", async () => { + expect.hasAssertions() + resetMocks(false) + const wrapper = await mountPage({ sections: [sectionWithDeletableRow()] }) + + await clickAction(wrapper, "delete") + + expect(svmUnitService.deleteRow).not.toHaveBeenCalled() + }) + + it("deletes the row by its row key, in a single call", async () => { + expect.hasAssertions() + resetMocks() + vi.mocked(svmUnitService.deleteRow).mockResolvedValue(apiResult({ result: true })) + const wrapper = await mountPage({ + sections: [sectionWithDeletableRow({ adminStaffFullName: "Staff Person" })], + }) + + await clickAction(wrapper, "delete") + + // One call, not one per underlying record: the leader and the admin staff are removed + // together server-side, so the pair cannot half-apply. + expect(svmUnitService.deleteRow).toHaveBeenCalledExactlyOnceWith(1) + }) +}) + +describe("sVMPhonesMaintain.vue - frequently called numbers", () => { + it("opens the frequent number dialog in add mode", async () => { + expect.hasAssertions() + resetMocks() + const wrapper = await mountPage({ frequentNumbers: [frequentNumber] }) + + await findAddButton(wrapper.findComponent(SVMFrequentNumberTable))!.trigger("click") + await flushPromises() + + const dialog = wrapper.findComponent(SVMAddFrequentNumberDialog) + expect(dialog.props("modelValue")).toBeTruthy() + // Null rather than a leftover row: the dialog reads this prop to decide add vs edit. + expect(dialog.props("editFrequentData")).toBeNull() + }) + + it("opens the frequent number dialog pre-filled when edit is clicked on a row", async () => { + expect.hasAssertions() + resetMocks() + const wrapper = await mountPage({ frequentNumbers: [frequentNumber] }) + + await clickAction(wrapper.findComponent(SVMFrequentNumberTable), "edit") + + const dialog = wrapper.findComponent(SVMAddFrequentNumberDialog) + expect(dialog.props("modelValue")).toBeTruthy() + expect(dialog.props("editFrequentData")).toStrictEqual(frequentNumber) + }) + + it("clears the edited row when the dialog closes, so the next add starts blank", async () => { + expect.hasAssertions() + resetMocks() + const wrapper = await mountPage({ frequentNumbers: [frequentNumber] }) + await clickAction(wrapper.findComponent(SVMFrequentNumberTable), "edit") + + // Closing the dialog is what resets the edit target. Without it, the next add would open + // pre-filled with this row and overwrite it on save. + await wrapper.findComponent(SVMAddFrequentNumberDialog).vm.$emit("update:modelValue", false) + await flushPromises() + + expect(wrapper.findComponent(SVMAddFrequentNumberDialog).props("editFrequentData")).toBeNull() + }) + + it("names the number's location in the delete confirmation", async () => { + expect.hasAssertions() + resetMocks(false) + const wrapper = await mountPage({ frequentNumbers: [frequentNumber] }) + + await clickAction(wrapper.findComponent(SVMFrequentNumberTable), "delete") + + expect(mockConfirmAction).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("Front Desk") }), + ) + }) + + it("does not delete a frequent number when the confirmation is declined", async () => { + expect.hasAssertions() + resetMocks(false) + const wrapper = await mountPage({ frequentNumbers: [frequentNumber] }) + + await clickAction(wrapper.findComponent(SVMFrequentNumberTable), "delete") + + expect(svmFrequentNumberService.deleteFrequentNumber).not.toHaveBeenCalled() + }) + + it("deletes the frequent number by its entry id and reloads", async () => { + expect.hasAssertions() + resetMocks() + vi.mocked(svmFrequentNumberService.deleteFrequentNumber).mockResolvedValue(apiResult({ result: true })) + const wrapper = await mountPage({ frequentNumbers: [frequentNumber] }) + const callsBeforeDelete = vi.mocked(getFrequentlyCalledNumbers).mock.calls.length + + await clickAction(wrapper.findComponent(SVMFrequentNumberTable), "delete") + + expect(svmFrequentNumberService.deleteFrequentNumber).toHaveBeenCalledExactlyOnceWith(7) + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: "positive", message: "Record deleted" }), + ) + expect(vi.mocked(getFrequentlyCalledNumbers).mock.calls.length).toBeGreaterThan(callsBeforeDelete) + }) + + it("raises a toast carrying the server message when a frequent number delete fails", async () => { + expect.hasAssertions() + resetMocks() + vi.mocked(svmFrequentNumberService.deleteFrequentNumber).mockResolvedValue( + apiError(["Failed to delete record"]), + ) + const wrapper = await mountPage({ frequentNumbers: [frequentNumber] }) + + await clickAction(wrapper.findComponent(SVMFrequentNumberTable), "delete") + + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: "negative", message: "Failed to delete record" }), + ) + }) + + it("reloads the phone data when the frequent number dialog reports a save", async () => { + expect.hasAssertions() + resetMocks() + const wrapper = await mountPage() + const callsBeforeSave = vi.mocked(getFrequentlyCalledNumbers).mock.calls.length + + await wrapper.findComponent(SVMAddFrequentNumberDialog).vm.$emit("saved", true) + await flushPromises() + + expect(vi.mocked(getFrequentlyCalledNumbers).mock.calls.length).toBeGreaterThan(callsBeforeSave) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-phones.test.ts b/VueApp/src/Personnel/__tests__/svm-phones.test.ts new file mode 100644 index 000000000..5b66c13fb --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-phones.test.ts @@ -0,0 +1,66 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { Quasar } from "quasar" +import SVMPhones from "../pages/SVMPhones.vue" +import { getFrequentlyCalledNumbers, getSVMData } from "../composables/svm-data-fetch" +import { svmModifiedDateService } from "../services/svm-modified-date-service.ts" + +/** + * SVMPhones hides its "Updated" line while the initial fetch is in flight (v-if="!loading"). + * Unlike PhoneList, the SVM list has no isInternal-gated content: DirectPhone is always + * blanked for SVM data (see PhoneSVMUnitService), so there's no internal-use banner here. + */ + +vi.mock("../composables/svm-data-fetch", () => ({ + getSVMData: vi.fn<(...args: unknown[]) => unknown>(), + getFrequentlyCalledNumbers: vi.fn<(...args: unknown[]) => unknown>(), +})) +vi.mock("../services/svm-modified-date-service.ts", () => ({ + svmModifiedDateService: { getModifiedDate: vi.fn<(...args: unknown[]) => unknown>() }, +})) + +// Never actually resolves, to simulate a fetch that's still in flight. +function neverResolves(): Promise { + // eslint-disable-next-line avoid-new, no-empty-function -- deliberately pending forever, to simulate an in-flight fetch + return new Promise(() => {}) +} + +function mountPage() { + return mount(SVMPhones, { + global: { plugins: [Quasar] }, + }) +} + +function stubDataServices(): void { + vi.clearAllMocks() + vi.mocked(getFrequentlyCalledNumbers).mockResolvedValue([]) + vi.mocked(svmModifiedDateService.getModifiedDate).mockResolvedValue(null) +} + +describe("sVMPhones.vue - loading", () => { + it("hides the Updated line while the initial fetch is in flight", async () => { + expect.hasAssertions() + stubDataServices() + vi.mocked(getSVMData).mockReturnValue(neverResolves()) + + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.text()).not.toContain("Updated") + }) + + it("shows the Updated line once the initial fetch resolves", async () => { + expect.hasAssertions() + stubDataServices() + vi.mocked(getSVMData).mockResolvedValue({ + newSections: [], + newUnitOptions: [], + newUnitFaxNumbers: [], + newUnitAdminStaff: [], + }) + + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.text()).toContain("Updated") + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-section-service.test.ts b/VueApp/src/Personnel/__tests__/svm-section-service.test.ts new file mode 100644 index 000000000..2f7b930f5 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-section-service.test.ts @@ -0,0 +1,31 @@ +import { svmSectionService } from "../services/svm-section-service" + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ get: (...args: unknown[]) => mockGet(...args) }), +})) + +describe("svmSectionService()", () => { + it("returns the sections on success", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const sections = [ + { sectionId: 1, name: "VMDO", includeAbbrv: false, unitName: null, directorTitle: "Dean", sortOrder: 1 }, + ] + mockGet.mockResolvedValue({ success: true, result: sections }) + + const result = await svmSectionService.getSections() + + expect(result).toStrictEqual(sections) + }) + + it("normalizes a failed request to an empty array", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: false, result: null }) + + const result = await svmSectionService.getSections() + + expect(result).toStrictEqual([]) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-unit-service.test.ts b/VueApp/src/Personnel/__tests__/svm-unit-service.test.ts new file mode 100644 index 000000000..efead17ef --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-unit-service.test.ts @@ -0,0 +1,81 @@ +import { svmUnitService } from "../services/svm-unit-service" + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +const mockPost = vi.fn<(...args: unknown[]) => unknown>() +const mockPut = 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), + del: (...args: unknown[]) => mockDel(...args), + }), +})) + +describe("svmUnitService()", () => { + it("returns the units for a section on success", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const units = [ + { + unitId: 10, + sectionId: 1, + name: "Dean's Office", + abbrv: "DO", + sortOrder: null, + fax: null, + section: null, + unitPersons: null, + }, + ] + mockGet.mockResolvedValue({ success: true, result: units }) + + const result = await svmUnitService.getAllUnits() + + expect(mockGet).toHaveBeenCalledWith(expect.stringContaining("svm/units")) + expect(result).toStrictEqual(units) + }) + + it("normalizes a failed request to an empty array", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: false, result: null }) + + const result = await svmUnitService.getAllUnits() + + expect(result).toStrictEqual([]) + }) + + it("posts new unit data to the units endpoint", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockPost.mockResolvedValue({ success: true, result: true }) + const dto = { + fax: "", + location: "", + deanIam: "dean01", + deanPhone: "", + deanInterim: "", + deanUnitPerson: -1, + staffIam: "", + staffPhone: "", + staffInterim: "", + staffUnitPerson: -1, + } + + await svmUnitService.addUnitData(10, dto) + + expect(mockPost).toHaveBeenCalledWith(expect.stringContaining("units/10"), dto) + }) + + it("deletes a list row by its row key", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockDel.mockResolvedValue({ success: true, result: true }) + + await svmUnitService.deleteRow(7) + + expect(mockDel).toHaveBeenCalledWith(expect.stringContaining("rows/7")) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/test-utils.ts b/VueApp/src/Personnel/__tests__/test-utils.ts new file mode 100644 index 000000000..c0bb86f73 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/test-utils.ts @@ -0,0 +1,26 @@ +import type { Result } from "@/composables/ViperFetch" + +/** + * Builds a full ViperFetch Result for mocking a service call. + * + * The service layer resolves to the whole Result, not just the interesting fields, so a mock + * that supplies only `success`/`result`/`errors` fails to type-check against the real return + * type. This fills in the plumbing fields the tests never assert on. + */ +function apiResult(overrides: Partial = {}): Result { + return { + result: null, + errors: [], + success: true, + pagination: null, + status: 200, + ...overrides, + } +} + +/** A failed call carrying server-supplied error messages. */ +function apiError(errors: string[]): Result { + return apiResult({ success: false, errors, status: 400 }) +} + +export { apiResult, apiError } diff --git a/VueApp/src/Personnel/__tests__/use-add-record-dialog.test.ts b/VueApp/src/Personnel/__tests__/use-add-record-dialog.test.ts new file mode 100644 index 000000000..9226b9633 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/use-add-record-dialog.test.ts @@ -0,0 +1,111 @@ +import { useAddRecordDialog } from "../composables/use-add-record-dialog" +import type { SaveOutcome } from "../composables/use-add-record-dialog" + +const { mockNotify } = vi.hoisted(() => ({ + mockNotify: vi.fn<(...args: unknown[]) => unknown>(), +})) + +vi.mock("quasar", () => ({ + useQuasar: () => ({ notify: mockNotify }), +})) + +type Form = { label: string } + +// Match the real sendSave signature rather than a loose (...args: unknown[]) mock, so a change +// to what the composable passes its save callback is a compile error here instead of a silent +// mismatch that the mock happily absorbs. +type SendSave = (form: Form, isEdit: boolean) => Promise + +function makeSendSave(outcome?: SaveOutcome) { + const mock = vi.fn() + if (outcome) { + mock.mockResolvedValue(outcome) + } + return mock +} + +type Deferred = { promise: Promise; resolve: (value: T) => void } + +// A controllable pending promise, so a save can be held open mid-flight. The `as` cast lets +// `resolve` be filled in by the executor without a separate uninitialized declaration. +function createDeferred(): Deferred { + const deferred = {} as Deferred + // eslint-disable-next-line avoid-new -- a controllable pending promise is the point of this helper + deferred.promise = new Promise((resolve) => { + deferred.resolve = resolve + }) + return deferred +} + +function buildOptions(overrides: Partial>[0]> = {}) { + return { + editData: () => null, + emptyForm: () => ({ label: "" }), + formFromEditData: () => ({ label: "" }), + validate: () => null, + sendSave: makeSendSave({ success: true, result: { id: 1 }, errors: null }), + onSaved: vi.fn<(result: unknown) => void>(), + onClose: vi.fn<() => void>(), + ...overrides, + } +} + +describe("useAddRecordDialog()", () => { + it("surfaces the validation error and never calls sendSave when the form is invalid", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const sendSave = makeSendSave() + const { save, formError } = useAddRecordDialog(buildOptions({ validate: () => "Label is required.", sendSave })) + + await save() + + expect(sendSave).not.toHaveBeenCalled() + expect(formError.value).toBe("Label is required.") + }) + + it("notifies, saves the result, and closes the dialog on a successful save", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const onSaved = vi.fn<(result: unknown) => void>() + const onClose = vi.fn<() => void>() + const sendSave = makeSendSave({ success: true, result: { id: 42 }, errors: null }) + const { save } = useAddRecordDialog(buildOptions({ sendSave, onSaved, onClose })) + + await save() + + expect(onSaved).toHaveBeenCalledWith({ id: 42 }) + expect(onClose).toHaveBeenCalledWith() + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: "positive" })) + }) + + it("surfaces the server error and leaves the dialog open on a failed save", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const onSaved = vi.fn<(result: unknown) => void>() + const onClose = vi.fn<() => void>() + const sendSave = makeSendSave({ success: false, errors: ["Phone number is already in use."] }) + const { save, formError } = useAddRecordDialog(buildOptions({ sendSave, onSaved, onClose })) + + await save() + + expect(formError.value).toBe("Phone number is already in use.") + expect(onSaved).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() + }) + + it("ignores a second save call while the first is still in flight", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const deferredSave = createDeferred() + const sendSave = vi.fn(() => deferredSave.promise) + const { save } = useAddRecordDialog(buildOptions({ sendSave })) + + const firstSave = save() + await save() + + expect(sendSave).toHaveBeenCalledOnce() + + deferredSave.resolve({ success: true, result: null, errors: null }) + await firstSave + }) +}) diff --git a/VueApp/src/Personnel/components/ModifiedSummary.vue b/VueApp/src/Personnel/components/ModifiedSummary.vue new file mode 100644 index 000000000..a6ad5abf9 --- /dev/null +++ b/VueApp/src/Personnel/components/ModifiedSummary.vue @@ -0,0 +1,42 @@ + + + {{ label }} Modified + {{ formattedDate || "Never" }} + by {{ by }} + + + + diff --git a/VueApp/src/Personnel/components/PersonSelector.vue b/VueApp/src/Personnel/components/PersonSelector.vue new file mode 100644 index 000000000..d253e4c3b --- /dev/null +++ b/VueApp/src/Personnel/components/PersonSelector.vue @@ -0,0 +1,63 @@ + + + + + + {{ opt.fullName }} + + + + + + {{ scope.opt.fullName ?? scope.opt.iamId }} + + + + + No matching people + + + + + + diff --git a/VueApp/src/Personnel/components/PhoneListAddRecordDialog.vue b/VueApp/src/Personnel/components/PhoneListAddRecordDialog.vue new file mode 100644 index 000000000..730e42884 --- /dev/null +++ b/VueApp/src/Personnel/components/PhoneListAddRecordDialog.vue @@ -0,0 +1,182 @@ + + + Unit: {{ form.unit.name }} + + updateForm($event)" + > + Employee: {{ form.employee.fullName }} + + + + + + + + + + + + + Modified By: {{ editData?.modifiedBy }} + + Modified Date: + {{ formatDate(editData?.modifiedDate?.toString() ?? "") || "Never" }} + + + + + + diff --git a/VueApp/src/Personnel/components/PhoneListUnitTable.vue b/VueApp/src/Personnel/components/PhoneListUnitTable.vue new file mode 100644 index 000000000..f19f7f309 --- /dev/null +++ b/VueApp/src/Personnel/components/PhoneListUnitTable.vue @@ -0,0 +1,86 @@ + + + + + + {{ unit.name }} + + + + + + + {{ props.row.name }} + + + {{ props.row.name }} + + + + + + + + + + + + + + + + + + + + + diff --git a/VueApp/src/Personnel/components/RecordActionButton.vue b/VueApp/src/Personnel/components/RecordActionButton.vue new file mode 100644 index 000000000..0cfce5a64 --- /dev/null +++ b/VueApp/src/Personnel/components/RecordActionButton.vue @@ -0,0 +1,28 @@ + + + {{ label }} + + + + diff --git a/VueApp/src/Personnel/components/SVMAddFrequentNumberDialog.vue b/VueApp/src/Personnel/components/SVMAddFrequentNumberDialog.vue new file mode 100644 index 000000000..c2848866c --- /dev/null +++ b/VueApp/src/Personnel/components/SVMAddFrequentNumberDialog.vue @@ -0,0 +1,99 @@ + + + + + + + + + diff --git a/VueApp/src/Personnel/components/SVMAddRecordDialog.vue b/VueApp/src/Personnel/components/SVMAddRecordDialog.vue new file mode 100644 index 000000000..cb9223b96 --- /dev/null +++ b/VueApp/src/Personnel/components/SVMAddRecordDialog.vue @@ -0,0 +1,296 @@ + + + Section: {{ form.section.label }} + + + Unit: {{ form.unit.label }} + + + + + + + + + + + + + + + + + + + + + + + diff --git a/VueApp/src/Personnel/components/SVMFrequentNumberTable.vue b/VueApp/src/Personnel/components/SVMFrequentNumberTable.vue new file mode 100644 index 000000000..128af6a9a --- /dev/null +++ b/VueApp/src/Personnel/components/SVMFrequentNumberTable.vue @@ -0,0 +1,73 @@ + + + + + + Frequently Called Numbers + + + + + + + + + + + + + + + + + + diff --git a/VueApp/src/Personnel/components/SVMPhoneSectionTable.vue b/VueApp/src/Personnel/components/SVMPhoneSectionTable.vue new file mode 100644 index 000000000..418dbcbdc --- /dev/null +++ b/VueApp/src/Personnel/components/SVMPhoneSectionTable.vue @@ -0,0 +1,60 @@ + + + + + + {{ section.title }} + + + + + + + + + + + + + + + + + + diff --git a/VueApp/src/Personnel/composables/phone-list-data-fetch.ts b/VueApp/src/Personnel/composables/phone-list-data-fetch.ts new file mode 100644 index 000000000..8d8c124bf --- /dev/null +++ b/VueApp/src/Personnel/composables/phone-list-data-fetch.ts @@ -0,0 +1,99 @@ +import { phoneListUnitService } from "../services/phone-list-unit-service" +import { getSparseAugmentedViperPerson } from "./use-person-helper" +import type { QTableProps } from "quasar" +import type { PhoneListUnit, PhoneListDisplayRecord } from "../types/phone-list-phone-types" + +/** + * The columns a phone-list table shows. Direct numbers are for maintainers and for the people on + * the list itself; the row controls are for maintainers alone. The backend enforces both + * independently, so this only decides what is rendered. + */ +function buildColumns(isEdit: boolean, isInternal: boolean): QTableProps["columns"] { + const cols: QTableProps["columns"] = [ + { name: "name", label: "Name", field: "name", align: "left", sortable: true }, + { name: "phone", label: "Phone", field: "phone", align: "left", sortable: false }, + ] + if (isInternal || isEdit) { + cols.push({ + name: "directPhone", + label: "Direct Phone", + field: "directPhone", + align: "left", + sortable: false, + }) + } + cols.push({ name: "office", label: "Office", field: "office", align: "left", sortable: false }) + if (isEdit) { + cols.push( + { + name: "listFirst", + label: "List First", + field: "listFirst", + align: "center", + sortable: false, + }, + { + name: "edit", + label: "Edit", + field: "edit", + align: "left", + sortable: false, + }, + { + name: "delete", + label: "Delete", + field: "delete", + align: "left", + sortable: false, + }, + ) + } + return cols +} + +// Retrieve all units and associated people for the given list code (e.g., VDMO). +// The data included depends on whether this is for editing or displaying the list. +// isInternal also affects displayed columns, but the backend independently +// enforces permissions to ensure no unintended data gets through. +async function getPhoneListData(code: string, isEdit: boolean, isInternal: boolean) { + const units: PhoneListUnit[] = [] + const r = await phoneListUnitService.getUnitsByList(code) + for (const unit of r) { + const rows: PhoneListDisplayRecord[] = [] + const cols = buildColumns(isEdit, isInternal) + for (const unitPerson of unit.phoneListUnitPersons) { + // If a person is no longer an active employee, + // person or viperPerson may be null. + // Display only active employees. + if (unitPerson.person !== null) { + if (unitPerson.person.viperPerson === null) { + unitPerson.person.viperPerson = getSparseAugmentedViperPerson() + } + rows.push({ + unitId: unit.phoneListUnitId, + unitPersonId: unitPerson.phoneListUnitPersonId, + fullName: unitPerson.person.viperPerson.fullName, + name: `${unitPerson.person.viperPerson.lastName}, ${unitPerson.person.viperPerson.firstName}`, + employeeIam: unitPerson.person.personIam, + employeeMailId: unitPerson.person.viperPerson.mailId, + phone: unitPerson.person.phone, + directPhone: unitPerson.person.directPhone, + office: unitPerson.person.office, + listFirst: unitPerson.listFirst, + unitName: unit.name, + modifiedBy: unitPerson.viperModPerson?.fullName ?? "", + modifiedDate: unitPerson.modifiedDate, + }) + } + } + units.push({ + name: unit.name, + id: unit.phoneListUnitId, + cols, + rows, + }) + } + return units +} + +export { getPhoneListData } diff --git a/VueApp/src/Personnel/composables/svm-data-fetch.ts b/VueApp/src/Personnel/composables/svm-data-fetch.ts new file mode 100644 index 000000000..4ad8f20a7 --- /dev/null +++ b/VueApp/src/Personnel/composables/svm-data-fetch.ts @@ -0,0 +1,323 @@ +import { svmFrequentNumberService } from "../services/svm-frequent-number-service" +import { svmSectionService } from "../services/svm-section-service" +import { svmUnitService } from "../services/svm-unit-service" +import { getEmptyPhonePerson, getEmptySVMUnitPerson, getSparseAugmentedViperPerson } from "./use-person-helper" +import type { QSelectOption, QTableProps } from "quasar" +import type { + SVMFrequentNumberAPIResponse, + SVMFrequentNumberRecord, + SVMPhoneDisplayRecord, + SVMPhoneSection, + SVMSectionAPIResponse, + SVMUnitAPIResponse, + SVMUnitPerson, + UnitAdminStaff, + UnitFaxNumber, + UnitOptions, +} from "../types/svm-phone-types" + +// Creates a framework for the sections in the SVM list, accounting for different +// columns in edit mode. +async function getSections(isEdit: boolean): Promise { + const sections: SVMPhoneSection[] = [] + const r: SVMSectionAPIResponse[] = await svmSectionService.getSections() + r.forEach((result: SVMSectionAPIResponse) => { + const cols: QTableProps["columns"] = [] + let { unitName } = result + if (!unitName) { + unitName = "" + } + if (isEdit) { + cols.push( + { name: "unitName", label: unitName, field: "unitName", align: "left", sortable: true }, + { + name: "deanDirector", + label: result.directorTitle, + field: "deanDirectorDisplayName", + align: "left", + sortable: true, + }, + { + name: "adminStaff", + label: "Admin Staff", + field: "adminStaffDisplayName", + align: "left", + sortable: true, + }, + { name: "edit", label: "Edit", field: "edit", align: "left" }, + { name: "delete", label: "Delete", field: "delete", align: "left" }, + ) + } else { + cols.push({ + name: "unitName", + label: unitName, + field: "unitName", + align: "left", + sortable: true, + }) + if (result.includeAbbrv) { + cols.push({ + name: "abbreviation", + label: "Abbrv", + field: "unitAbbrv", + align: "left", + sortable: true, + }) + } + cols.push( + { name: "location", label: "Location", field: "officeLocation", align: "left", sortable: true }, + { + name: "deanDirector", + label: result.directorTitle, + field: "deanDirectorDisplayName", + align: "left", + sortable: true, + }, + { name: "dirPhone", label: "Phone", field: "deanDirectorPhone", align: "left" }, + { name: "fax", label: "Fax", field: "officeFax", align: "left" }, + { + name: "adminStaff", + label: "Admin Staff", + field: "adminStaffDisplayName", + align: "left", + sortable: true, + }, + { name: "adminPhone", label: "Phone", field: "adminStaffPhone", align: "left" }, + ) + } + sections.push({ + rows: [], + cols, + title: result.name, + id: result.sectionId, + }) + }) + return sections +} + +// Helper function to full in empty/default values for the person +// associated with an SVMUnitPerson, if needed. +function populateEmptyPerson(unitPerson: SVMUnitPerson): SVMUnitPerson { + if (unitPerson.person === null) { + unitPerson.person = getEmptyPhonePerson() + } + if (unitPerson.person.viperPerson === null) { + unitPerson.person.viperPerson = getSparseAugmentedViperPerson() + } + return unitPerson +} + +// Returns data about the given admin staff for inclusion in a row in an SVM table. +function getAdminStaffData(staff: SVMUnitPerson | null) { + if (staff === null) { + return { + adminStaffFullName: "", + adminStaffDisplayName: "", + adminStaffInterim: "", + adminStaffIam: "", + adminStaffUnitPersonId: -1, + adminStaffPhone: "", + adminStaffModifiedBy: null, + adminStaffModifiedDate: null, + } + } + let staffDisplayName = "" + // Ensures staff.person.viperPerson is not null. + const populatedStaff = populateEmptyPerson(staff) + staffDisplayName = populatedStaff.person!.viperPerson!.fullName + if (populatedStaff.interim) { + staffDisplayName += ` (${populatedStaff.interim})` + } + return { + adminStaffFullName: staff.person!.viperPerson!.fullName, + adminStaffDisplayName: staffDisplayName, + adminStaffInterim: staff.interim ?? "", + adminStaffIam: staff.person!.personIam, + adminStaffUnitPersonId: staff.unitPersonId, + adminStaffPhone: staff.person!.phone ?? "", + adminStaffModifiedBy: staff.person!.viperModPerson?.fullName ?? null, + adminStaffModifiedDate: staff.person!.modifiedDate, + } +} + +// Helper function to return the staff data data to populate into an add dialog box, +// or an empty array if no data should be populated. +function unitAdminStaffEntries( + unitId: number, + staff: SVMUnitPerson | null, + adminStaffPartialRow: ReturnType, +): UnitAdminStaff[] { + if (staff === null) { + return [] + } + return [ + { + unitId, + staffIam: adminStaffPartialRow.adminStaffIam, + staffFullName: adminStaffPartialRow.adminStaffFullName, + staffPhone: adminStaffPartialRow.adminStaffPhone, + staffInterim: adminStaffPartialRow.adminStaffInterim, + staffUnitPersonId: adminStaffPartialRow.adminStaffUnitPersonId, + }, + ] +} + +/** + * Splits a unit's people into the leaders, each of which becomes a row of its own, and the one + * admin staff member the unit shares across every one of those rows. Anyone carrying no PosType + * is neither and is left out. + */ +function partitionUnitPeople(unitPersons: SVMUnitPerson[]): { + leaders: SVMUnitPerson[] + staff: SVMUnitPerson | null +} { + const leaders: SVMUnitPerson[] = [] + let staff: SVMUnitPerson | null = null + for (const unitPerson of unitPersons) { + if (unitPerson.posType === "Staff") { + staff = unitPerson + } else if (unitPerson?.posType) { + leaders.push(unitPerson) + } + } + return { leaders, staff } +} + +/** + * The row key the list renders and the delete endpoint takes. The placeholder standing in for a + * departed director has no record of its own, so it falls back to the staff row; a unit with + * neither has nothing to show and returns null. + */ +function resolveEntryId(leader: SVMUnitPerson, staff: SVMUnitPerson | null): number | null { + const entryId = leader.unitPersonId === -1 ? staff?.unitPersonId : leader.unitPersonId + return entryId === undefined || entryId === -1 ? null : entryId +} + +/** + * The leader half of a display row. The caller has already run populateEmptyPerson, so person + * and viperPerson are both present. + */ +function buildLeaderRow({ + section, + unit, + leader, + isOnlyRowForUnit, +}: { + section: SVMPhoneSection + unit: SVMUnitAPIResponse + leader: SVMUnitPerson + isOnlyRowForUnit: boolean +}) { + const person = leader.person! + const viperPerson = person.viperPerson! + let displayName = viperPerson.fullName + if (leader.interim) { + displayName += ` (${leader.interim})` + } + return { + sectionName: section.title, + unitName: unit.name, + unitId: unit.unitId, + unitAbbrv: unit.abbrv, + officeLocation: leader.office, + officeFax: unit.fax, + deanDirectorFullName: viperPerson.fullName, + deanDirectorDisplayName: displayName, + deanDirectorInterim: leader.interim ?? "", + deanDirectorIam: person.personIam, + deanDirectorUnitPersonId: leader.unitPersonId, + deanDirectorPhone: person.phone ?? "", + deanDirectorModifiedBy: person.viperModPerson?.fullName ?? null, + deanDirectorModifiedDate: person.modifiedDate, + // The admin staff belongs to the unit, so the API keeps them until no leader row is + // left. Recording that here lets the delete confirmation name everyone it removes. + isOnlyRowForUnit, + } +} + +// Populates and returns the data used to populate the SVM phone list and +// to autocomplete fields when adding or editing rows. +// Autopopulated data includes a unit's fax number and admin staff. +async function getSVMData(isEdit: boolean) { + const unitOptions: UnitOptions[] = [] + const unitFaxNumbers: UnitFaxNumber[] = [] + const unitAdminStaff: UnitAdminStaff[] = [] + const [sections, allUnits] = await Promise.all([getSections(isEdit), svmUnitService.getAllUnits()]) + const unitsBySection = new Map() + for (const unit of allUnits) { + const sectionUnits = unitsBySection.get(unit.sectionId) + if (sectionUnits === undefined) { + unitsBySection.set(unit.sectionId, [unit]) + } else { + sectionUnits.push(unit) + } + } + + for (const section of sections) { + const r = unitsBySection.get(section.id) ?? [] + const units: QSelectOption[] = [] + const rows: SVMPhoneDisplayRecord[] = [] + r.forEach((result: SVMUnitAPIResponse) => { + units.push({ label: result.name ?? "", value: result.unitId.toString() }) + unitFaxNumbers.push({ unitId: result.unitId, fax: result.fax ?? "" }) + if (result.unitPersons === null) { + return + } + const { leaders, staff } = partitionUnitPeople(result.unitPersons) + const adminStaffPartialRow = getAdminStaffData(staff) + // Lets the add dialog auto-populate the admin staff fields when adding another + // leader to a unit that already has one. + unitAdminStaff.push(...unitAdminStaffEntries(result.unitId, staff, adminStaffPartialRow)) + // The front end prevents new units from having a director but no admin staff, + // but if the listed director is no longer a current employee, + // we still want to display the admin staff and show that there is no active director. + if (leaders.length === 0 && staff !== null) { + leaders.push(getEmptySVMUnitPerson(result.unitId)) + } + for (const leader of leaders) { + // Ensures leader.person.viperPerson is not null. + const populatedLeader = populateEmptyPerson(leader) + const entryId = resolveEntryId(populatedLeader, staff) + if (entryId !== null) { + rows.push({ + ...buildLeaderRow({ + section, + unit: result, + leader: populatedLeader, + isOnlyRowForUnit: leaders.length === 1, + }), + ...adminStaffPartialRow, + entryId, + }) + } + } + }) + unitOptions.push({ section: section.id, units }) + section.rows = rows + } + return { + newSections: sections, + newUnitOptions: unitOptions, + newUnitFaxNumbers: unitFaxNumbers, + newUnitAdminStaff: unitAdminStaff, + } +} + +// Queries and returns the frequently called numbers displayed at the bottom +// of SVM phone list pages. +async function getFrequentlyCalledNumbers(): Promise { + const rows: SVMFrequentNumberRecord[] = [] + + const r = await svmFrequentNumberService.getFrequentNumbers() + r.forEach((result: SVMFrequentNumberAPIResponse) => { + rows.push({ + label: result.label, + phone: result.phone, + entryId: result.numberId, + }) + }) + + return rows +} + +export { getSVMData, getFrequentlyCalledNumbers } diff --git a/VueApp/src/Personnel/composables/use-add-record-dialog.ts b/VueApp/src/Personnel/composables/use-add-record-dialog.ts new file mode 100644 index 000000000..cb97d21b0 --- /dev/null +++ b/VueApp/src/Personnel/composables/use-add-record-dialog.ts @@ -0,0 +1,110 @@ +import { computed, ref, watch } from "vue" +import { useQuasar } from "quasar" +import type { Ref } from "vue" + +interface SaveOutcome { + success: boolean + result?: any + errors: string[] | null +} + +interface UseAddRecordDialogOptions { + editData: () => TEditData | null | undefined + /** Watched to reset the form when the enclosing context (e.g. unit/section) changes. Omit if there is none. */ + resetOn?: () => unknown + emptyForm: () => TForm + formFromEditData: () => TForm + validate: (form: TForm) => string | null + sendSave: (form: TForm, isEdit: boolean) => Promise + onSaved: (result: any) => void + onClose: () => void + /** Used in the default save-failure message: "Failed to save/upload {recordLabel}". */ + recordLabel?: string +} + +/** + * Shared submit lifecycle for the phone-area "add/edit record" dialogs (PhoneList, SVM, + * SVM frequent numbers). + */ +export function useAddRecordDialog({ + editData, + resetOn, + emptyForm, + formFromEditData, + validate, + sendSave, + onSaved, + onClose, + recordLabel = "phone record", +}: UseAddRecordDialogOptions) { + const $q = useQuasar() + + const saving = ref(false) + const formError = ref("") + const isEdit = computed(() => editData() !== undefined && editData() !== null) + + const form = ref(isEdit.value ? formFromEditData() : emptyForm()) as Ref + + watch(editData, () => { + form.value = formFromEditData() + }) + if (resetOn) { + watch(resetOn, () => { + form.value = emptyForm() + }) + } + + function resetForm() { + form.value = emptyForm() + formError.value = "" + } + + function onValidationError() { + formError.value = "Please complete the required fields before saving." + } + + function reportSaveError(res: { errors: string[] | null }) { + formError.value = res.errors?.[0] ?? `Failed to ${isEdit.value ? "save" : "upload"} ${recordLabel}` + } + + async function save() { + // The saving flag flips synchronously before the first await, so a second submit - a + // double click, or Enter while the button is already spinning - returns here instead + // of sending the record twice. + if (saving.value) { + return + } + formError.value = "" + + const validationError = validate(form.value) + if (validationError) { + reportSaveError({ errors: [validationError] }) + return + } + + saving.value = true + const res = await sendSave(form.value, isEdit.value) + saving.value = false + + if (!res.success) { + reportSaveError(res) + return + } + + $q.notify({ type: "positive", message: isEdit.value ? "Phone record updated" : "Phone record created" }) + onSaved(res.result) + onClose() + } + + return { + form, + saving, + formError, + isEdit, + save, + resetForm, + onValidationError, + } +} + +export type { SaveOutcome } diff --git a/VueApp/src/Personnel/composables/use-person-helper.ts b/VueApp/src/Personnel/composables/use-person-helper.ts new file mode 100644 index 000000000..e0dfad859 --- /dev/null +++ b/VueApp/src/Personnel/composables/use-person-helper.ts @@ -0,0 +1,50 @@ +import type { PhonePerson, AugmentedViperPerson } from "../types/phone-types" +import type { SVMUnitPerson } from "../types/svm-phone-types" + +// Helper functions to populate empty or nearly-empty AugmentedViperPersons, +// PhonePersons, and SVMUnitPerson. +function getEmptySVMUnitPerson(unitId: number): SVMUnitPerson { + return { + unitPersonId: -1, + unitId, + personIam: "", + office: "", + posType: "", + interim: "", + modifiedDate: null, + modifiedBy: "", + unit: null, + person: getEmptyPhonePerson(), + viperModPerson: null, + } +} + +function getEmptyPhonePerson(): PhonePerson { + return { + personIam: "", + phone: "", + directPhone: "", + office: "", + modifiedDate: null, + modifiedBy: "", + unitPersons: null, + phoneListUnitPersons: null, + viperPerson: getSparseAugmentedViperPerson(), + viperModPerson: getSparseAugmentedViperPerson(), + } +} + +function getSparseAugmentedViperPerson(fullName = "", iamId = ""): AugmentedViperPerson { + return { + personId: -1, + firstName: "", + lastName: "", + fullName, + iamId, + currentEmployee: true, + mailId: "", + phoneData: null, + } +} + +export { getEmptySVMUnitPerson, getEmptyPhonePerson, getSparseAugmentedViperPerson } diff --git a/VueApp/src/Personnel/index.html b/VueApp/src/Personnel/index.html new file mode 100644 index 000000000..93ace49e1 --- /dev/null +++ b/VueApp/src/Personnel/index.html @@ -0,0 +1,12 @@ + + + + + + VIPER - Personnel + + + + + + \ No newline at end of file diff --git a/VueApp/src/Personnel/pages/Home.vue b/VueApp/src/Personnel/pages/Home.vue new file mode 100644 index 000000000..a7254de06 --- /dev/null +++ b/VueApp/src/Personnel/pages/Home.vue @@ -0,0 +1,6 @@ + + + + diff --git a/VueApp/src/Personnel/pages/PhoneList.vue b/VueApp/src/Personnel/pages/PhoneList.vue new file mode 100644 index 000000000..89b6a6d67 --- /dev/null +++ b/VueApp/src/Personnel/pages/PhoneList.vue @@ -0,0 +1,114 @@ + + {{ listName }} + + + If you wish to use a nickname, please go to the campus directory (http://directory.ucdavis.edu/) and update it. + + + + + {{ errorMessage }} + + + + + + FOR INTERNAL USE ONLY -- + Updated {{ formatDate(updatedDate?.toString() ?? "") || "Never" }} + + Click on a name to send an email + + + + + + + + + + + + diff --git a/VueApp/src/Personnel/pages/PhoneListMaintain.vue b/VueApp/src/Personnel/pages/PhoneListMaintain.vue new file mode 100644 index 000000000..828e29052 --- /dev/null +++ b/VueApp/src/Personnel/pages/PhoneListMaintain.vue @@ -0,0 +1,158 @@ + + {{ listName }} Maintenance + + + If an employee wishes to display a nickname, please direct them to the campus directory and change it there. + + + + + + {{ errorMessage }} + + + + + + + + + + + + + + diff --git a/VueApp/src/Personnel/pages/SVMPhones.vue b/VueApp/src/Personnel/pages/SVMPhones.vue new file mode 100644 index 000000000..05f7e0353 --- /dev/null +++ b/VueApp/src/Personnel/pages/SVMPhones.vue @@ -0,0 +1,67 @@ + + School of Veterinary Medicine Phone List + + + Updated {{ formatDate(updatedDate?.toString() ?? "") || "Never" }} + + + + + + + + + + + + + diff --git a/VueApp/src/Personnel/pages/SVMPhonesMaintain.vue b/VueApp/src/Personnel/pages/SVMPhonesMaintain.vue new file mode 100644 index 000000000..8bb341d5e --- /dev/null +++ b/VueApp/src/Personnel/pages/SVMPhonesMaintain.vue @@ -0,0 +1,184 @@ + + School of Veterinary Medicine Phone List Maintenance + + + + + + + + + + + + + + + + + + diff --git a/VueApp/src/Personnel/personnel.ts b/VueApp/src/Personnel/personnel.ts new file mode 100644 index 000000000..0ef55b5c5 --- /dev/null +++ b/VueApp/src/Personnel/personnel.ts @@ -0,0 +1,10 @@ +import { bootstrapSpa } from "@/shared/bootstrap-spa" +import { router } from "./router" +import App from "./App.vue" + +bootstrapSpa({ + areaPath: "/Personnel", + appComponent: App, + router, + provides: { apiURL: import.meta.env.VITE_API_URL }, +}) diff --git a/VueApp/src/Personnel/router/index.ts b/VueApp/src/Personnel/router/index.ts new file mode 100644 index 000000000..15fbadd42 --- /dev/null +++ b/VueApp/src/Personnel/router/index.ts @@ -0,0 +1,41 @@ +import { createSpaRouter } from "@/shared/create-spa-router" +import type { RouteLocationNormalized } from "vue-router" +import { routes } from "./routes" +import { useRequireLogin } from "@/composables/RequireLogin" +import { useUserStore } from "@/store/UserStore" +import { checkHasOnePermission } from "@/composables/CheckPagePermission" + +const router = createSpaRouter(routes) + +/** + * Signs the visitor in and loads the SVMSecure.PhoneLists permissions in the same request. + * Those roles are the only permissions anything in this SPA reads, so asking requireLogin for + * them directly leaves no second permission set to fetch or keep in sync. + * A null result means requireLogin reached no verdict, which is not a refusal. + */ +async function authenticate(to: RouteLocationNormalized): Promise { + const { requireLogin } = useRequireLogin(to) + const loginResult = await requireLogin(true, "SVMSecure.PhoneLists") + return loginResult === null || loginResult +} + +/** + * An in-app navigation (tab switch, list to list) is already signed in and already holds its + * permissions, so repeating the login round-trip would only cost a request and flash the page. + */ +function needsAuthentication(from: RouteLocationNormalized): boolean { + return from.matched.length === 0 || !useUserStore().isLoggedIn +} + +router.beforeEach(async (to, from) => { + if (needsAuthentication(from) && !(await authenticate(to))) { + return false + } + + const required = to.meta.permissions as string[] | null | undefined + if (required !== null && required !== undefined && !checkHasOnePermission(required)) { + return { name: "PersonnelHome" } + } +}) + +export { router } diff --git a/VueApp/src/Personnel/router/routes.ts b/VueApp/src/Personnel/router/routes.ts new file mode 100644 index 000000000..9de63e03c --- /dev/null +++ b/VueApp/src/Personnel/router/routes.ts @@ -0,0 +1,57 @@ +import ViperLayout from "@/layouts/ViperLayout.vue" +//Import ViperLayoutSimple from '@/layouts/ViperLayoutSimple.vue' + +const routes = [ + { + path: "/Personnel/", + alias: "/Personnel/Home", + meta: { layout: ViperLayout, allowUnAuth: false }, + component: () => import("@/Personnel/pages/Home.vue"), + name: "PersonnelHome", + }, + // Unit phone lists are addressed by their stable PhoneList.Code, so a new list is a row in + // phones.PhoneList plus a nav entry rather than another pair of near-identical pages. + { + path: "/Personnel/PhoneList/:code", + meta: { layout: ViperLayout, allowUnAuth: false }, + component: () => import("@/Personnel/pages/PhoneList.vue"), + name: "PhoneList", + }, + { + // No meta.permissions: the required role is the list's own MaintainRole, which is not + // known until the list is fetched. The page redirects if canMaintain comes back false, + // and the API rejects writes independently. + path: "/Personnel/PhoneList/:code/Maintain", + meta: { layout: ViperLayout, allowUnAuth: false }, + component: () => import("@/Personnel/pages/PhoneListMaintain.vue"), + name: "MaintainPhoneList", + }, + // The legacy paths so existing links and bookmarks keep working. + { + path: "/Personnel/VMDOPhones", + redirect: { name: "PhoneList", params: { code: "VMDO" } }, + }, + { + path: "/Personnel/VMDOPhonesMaintain", + redirect: { name: "MaintainPhoneList", params: { code: "VMDO" } }, + }, + { + path: "/Personnel/SVMPhones", + meta: { layout: ViperLayout, allowUnAuth: false }, + component: () => import("@/Personnel/pages/SVMPhones.vue"), + name: "SchoolwidePhones", + }, + { + path: "/Personnel/SVMPhonesMaintain", + meta: { layout: ViperLayout, allowUnAuth: false, permissions: ["SVMSecure.PhoneLists.SVMMaintain"] }, + component: () => import("@/Personnel/pages/SVMPhonesMaintain.vue"), + name: "MaintainSchoolwidePhones", + }, + { + path: "/:catchAll(.*)*", + meta: { layout: ViperLayout }, + component: () => import("@/pages/Error404.vue"), + }, +] + +export { routes } diff --git a/VueApp/src/Personnel/services/phone-list-modified-date-service.ts b/VueApp/src/Personnel/services/phone-list-modified-date-service.ts new file mode 100644 index 000000000..432d808ae --- /dev/null +++ b/VueApp/src/Personnel/services/phone-list-modified-date-service.ts @@ -0,0 +1,19 @@ +import { useFetch } from "@/composables/ViperFetch" + +const { get } = useFetch() + +/** + * Service for requesting the most recent modification date for the unit + * phone lists. + */ +class PhoneListModifiedDateService { + private baseUrl = `${import.meta.env.VITE_API_URL}phones/phonelist` + + async getModifiedDate(code: string): Promise { + const r = await get(`${this.baseUrl}/${encodeURIComponent(code)}/modifiedDate`) + return r.result ?? null + } +} + +const phoneListModifiedDateService = new PhoneListModifiedDateService() +export { phoneListModifiedDateService } diff --git a/VueApp/src/Personnel/services/phone-list-service.ts b/VueApp/src/Personnel/services/phone-list-service.ts new file mode 100644 index 000000000..c244d3ba7 --- /dev/null +++ b/VueApp/src/Personnel/services/phone-list-service.ts @@ -0,0 +1,27 @@ +import { useFetch } from "@/composables/ViperFetch" +import type { PhoneListInfo } from "../types/phone-list-phone-types" + +const { get } = useFetch() + +/** + * Service for PhoneList metadata. + */ +class PhoneListService { + private baseUrl = `${import.meta.env.VITE_API_URL}phones/phonelist` + + /** + * Resolves a list by its stable code. Returns null when the code is unknown or the request + * fails, so callers can notify and bail rather than rendering an empty list as if it were + * a list with no entries. + */ + async getPhoneListInfo(code: string): Promise { + const r = await get(`${this.baseUrl}/${encodeURIComponent(code)}`) + if (!r.success || !r.result) { + return null + } + return r.result as PhoneListInfo + } +} + +const phoneListService = new PhoneListService() +export { phoneListService } diff --git a/VueApp/src/Personnel/services/phone-list-unit-service.ts b/VueApp/src/Personnel/services/phone-list-unit-service.ts new file mode 100644 index 000000000..5f8ba40df --- /dev/null +++ b/VueApp/src/Personnel/services/phone-list-unit-service.ts @@ -0,0 +1,40 @@ +import { useFetch } from "@/composables/ViperFetch" +import type { PhoneListUnitAPIResponse, PhoneListUnitPersonDTO } from "../types/phone-list-phone-types" + +const { get, post, put, del } = useFetch() + +/** + * Service for PhoneListUnit. Every call is scoped to a list code, which the API resolves to a + * list and then uses for its own permission check. + */ +class PhoneListUnitService { + private baseUrl = `${import.meta.env.VITE_API_URL}phones/phonelist` + + private listUrl(code: string) { + return `${this.baseUrl}/${encodeURIComponent(code)}` + } + + async getUnitsByList(code: string): Promise { + const r = await get(`${this.listUrl(code)}/units`) + const results = r.result + if (!results || results.length === 0) { + return [] + } + return results as PhoneListUnitAPIResponse[] + } + + async addUnitPersonData(code: string, formData: PhoneListUnitPersonDTO) { + return await post(`${this.listUrl(code)}/unitPerson`, formData) + } + + async updateUnitPersonData(code: string, unitPersonId: number, formData: PhoneListUnitPersonDTO) { + return await put(`${this.listUrl(code)}/unitPerson/${unitPersonId}`, formData) + } + + async deleteUnitPersonData(code: string, deletionUnitPersonId: number) { + return await del(`${this.listUrl(code)}/unitPerson/${deletionUnitPersonId}`) + } +} + +const phoneListUnitService = new PhoneListUnitService() +export { phoneListUnitService } diff --git a/VueApp/src/Personnel/services/phone-person-options-service.ts b/VueApp/src/Personnel/services/phone-person-options-service.ts new file mode 100644 index 000000000..3b4853cdc --- /dev/null +++ b/VueApp/src/Personnel/services/phone-person-options-service.ts @@ -0,0 +1,16 @@ +import { useFetch } from "@/composables/ViperFetch" +import type { AugmentedViperPerson } from "../types/phone-types" + +/** + * Phone option-list lookups for people. Returns null on a + * failed request (vs an empty array) so callers can tell "no matches" from "the fetch failed". + */ +const { get, createUrlSearchParams } = useFetch() +const optionsUrl = `${import.meta.env.VITE_API_URL}phones/people` + +async function searchPeopleOptions(search: string, listCode: string = ""): Promise { + const res = await get(`${optionsUrl}?${createUrlSearchParams({ search, listCode })}`) + return res.success ? (res.result as AugmentedViperPerson[]) : null +} + +export { searchPeopleOptions } diff --git a/VueApp/src/Personnel/services/svm-frequent-number-service.ts b/VueApp/src/Personnel/services/svm-frequent-number-service.ts new file mode 100644 index 000000000..e7c78490f --- /dev/null +++ b/VueApp/src/Personnel/services/svm-frequent-number-service.ts @@ -0,0 +1,36 @@ +import { useFetch } from "@/composables/ViperFetch" +import type { SVMFrequentNumberAPIResponse, SVMFrequentNumberRecord } from "../types/svm-phone-types" + +const { get, post, put, del } = useFetch() + +/** + * Service for SVM Frequent Number API calls. + */ +class SVMFrequentNumberService { + private baseUrl = `${import.meta.env.VITE_API_URL}phones/svm/frequentnumbers` + + async getFrequentNumbers(): Promise { + const r = await get(this.baseUrl) + + const results = r.result + if (!results || results.length === 0) { + return [] + } + return results as SVMFrequentNumberAPIResponse[] + } + + async addFrequentNumber(formData: SVMFrequentNumberRecord) { + return await post(this.baseUrl, formData) + } + + async updateFrequentNumber(entryId: number, formData: SVMFrequentNumberRecord) { + return await put(`${this.baseUrl}/${entryId}`, formData) + } + + async deleteFrequentNumber(entryId: number) { + return await del(`${this.baseUrl}/${entryId}`) + } +} + +const svmFrequentNumberService = new SVMFrequentNumberService() +export { svmFrequentNumberService } diff --git a/VueApp/src/Personnel/services/svm-modified-date-service.ts b/VueApp/src/Personnel/services/svm-modified-date-service.ts new file mode 100644 index 000000000..6d1ed1566 --- /dev/null +++ b/VueApp/src/Personnel/services/svm-modified-date-service.ts @@ -0,0 +1,19 @@ +import { useFetch } from "@/composables/ViperFetch" + +const { get } = useFetch() + +/** + * Service for requesting the most recent modification date for the SVM + * phone list. + */ +class SVMModifiedDateService { + private baseUrl = `${import.meta.env.VITE_API_URL}phones/svm/modifiedDate` + + async getModifiedDate(): Promise { + const r = await get(this.baseUrl) + return r.result ?? null + } +} + +const svmModifiedDateService = new SVMModifiedDateService() +export { svmModifiedDateService } diff --git a/VueApp/src/Personnel/services/svm-section-service.ts b/VueApp/src/Personnel/services/svm-section-service.ts new file mode 100644 index 000000000..08427cc2a --- /dev/null +++ b/VueApp/src/Personnel/services/svm-section-service.ts @@ -0,0 +1,24 @@ +import { useFetch } from "@/composables/ViperFetch" +import type { SVMSectionAPIResponse } from "../types/svm-phone-types" + +const { get } = useFetch() + +/** + * Service for SVMSection API calls. + */ +class SVMSectionService { + private baseUrl = `${import.meta.env.VITE_API_URL}phones/svm/sections` + + async getSections(): Promise { + const r = await get(this.baseUrl) + + const results = r.result + if (!results || results.length === 0) { + return [] + } + return results as SVMSectionAPIResponse[] + } +} + +const svmSectionService = new SVMSectionService() +export { svmSectionService } diff --git a/VueApp/src/Personnel/services/svm-unit-service.ts b/VueApp/src/Personnel/services/svm-unit-service.ts new file mode 100644 index 000000000..a605de72c --- /dev/null +++ b/VueApp/src/Personnel/services/svm-unit-service.ts @@ -0,0 +1,43 @@ +import { useFetch } from "@/composables/ViperFetch" +import type { SVMUnitAPIResponse, SVMUnitNumberDTO } from "../types/svm-phone-types" + +const { get, put, post, del } = useFetch() + +/** + * Service for SVMUnit and related API calls. + */ +class SVMUnitService { + private baseUrl = `${import.meta.env.VITE_API_URL}phones/svm` + + /** + * Every unit on the list. The page renders all sections together, so these come back in one + * request and are grouped by sectionId client-side. + */ + async getAllUnits(): Promise { + const r = await get(`${this.baseUrl}/units`) + const results = r.result + if (!results || results.length === 0) { + return [] + } + return results as SVMUnitAPIResponse[] + } + + async addUnitData(unitId: number, formData: SVMUnitNumberDTO) { + return await post(`${this.baseUrl}/units/${unitId}`, formData) + } + + async updateUnitData(unitId: number, formData: SVMUnitNumberDTO) { + return await put(`${this.baseUrl}/units/${unitId}`, formData) + } + + /** + * Deletes one row of the SVM list. The server owns which underlying UnitPerson records that + * covers, so the caller passes only the row key the table renders. + */ + async deleteRow(entryId: number) { + return await del(`${this.baseUrl}/rows/${entryId}`) + } +} + +const svmUnitService = new SVMUnitService() +export { svmUnitService } diff --git a/VueApp/src/Personnel/types/phone-list-phone-types.ts b/VueApp/src/Personnel/types/phone-list-phone-types.ts new file mode 100644 index 000000000..1a478b743 --- /dev/null +++ b/VueApp/src/Personnel/types/phone-list-phone-types.ts @@ -0,0 +1,77 @@ +/** + * Types for department Phone Lists. + */ + +import type { QTableProps } from "quasar" +import type { PhonePerson, ViperPerson } from "./phone-types" + +type PhoneListInfo = { + phoneListId: number + code: string + name: string + canMaintain: boolean + canViewDirectPhone: boolean +} + +type PhoneListUnitPersonDTO = { + unitId: number + office: string + employeeIam: string + phone: string + directPhone: string + listFirst: boolean +} + +type PhoneListUnitPerson = { + phoneListUnitPersonId: number + phoneListUnitId: number + personIam: string + listFirst: boolean + phoneListUnit: null + person: PhonePerson | null + modifiedBy: string | null + modifiedDate: Date | null + viperModPerson: ViperPerson | null +} + +type PhoneListUnitAPIResponse = { + phoneListUnitId: number + phoneListId: number + name: string + sortOrder: number | null + // Key returned by API but always null for this use case. + phoneList: null + phoneListUnitPersons: PhoneListUnitPerson[] +} + +type PhoneListDisplayRecord = { + fullName: string + name: string + employeeIam?: string + employeeMailId: string + phone: string | null + directPhone?: string | null + office: string | null + listFirst: boolean + unitPersonId: number + unitId: number + unitName: string + modifiedBy: string | null + modifiedDate: Date | null +} + +type PhoneListUnit = { + name: string + id: number + cols: QTableProps["columns"] + rows: PhoneListDisplayRecord[] +} + +export type { + PhoneListInfo, + PhoneListUnitAPIResponse, + PhoneListUnitPerson, + PhoneListUnitPersonDTO, + PhoneListUnit, + PhoneListDisplayRecord, +} diff --git a/VueApp/src/Personnel/types/phone-types.ts b/VueApp/src/Personnel/types/phone-types.ts new file mode 100644 index 000000000..8a63f37b1 --- /dev/null +++ b/VueApp/src/Personnel/types/phone-types.ts @@ -0,0 +1,40 @@ +/** + * Types for general phone records reports. + */ + +type ViperPerson = { + personId: number + firstName: string + lastName: string + fullName: string + iamId: string + currentEmployee: boolean + mailId: string +} + +type PhonePerson = { + personIam: string + phone: string | null + directPhone: string | null + office: string | null + modifiedDate: Date | null + modifiedBy: string | null + // Returned by the API but not populated for this use case. + unitPersons: null + phoneListUnitPersons: null + viperPerson: ViperPerson | null + viperModPerson: ViperPerson | null +} + +type AugmentedViperPerson = { + personId: number + firstName: string + lastName: string + fullName: string + iamId: string + currentEmployee: boolean + mailId: string + phoneData: PhonePerson | null +} + +export type { ViperPerson, PhonePerson, AugmentedViperPerson } diff --git a/VueApp/src/Personnel/types/svm-phone-types.ts b/VueApp/src/Personnel/types/svm-phone-types.ts new file mode 100644 index 000000000..fc17e6dde --- /dev/null +++ b/VueApp/src/Personnel/types/svm-phone-types.ts @@ -0,0 +1,137 @@ +/** + * Types for the SVM Phone List. + */ + +import type { QSelectOption, QTableProps } from "quasar" +import type { ViperPerson, PhonePerson } from "./phone-types" + +type SVMPhoneDisplayRecord = { + sectionName: string | null + unitName: string | null + unitId: number | null + unitAbbrv: string | null + officeLocation: string | null + officeFax: string | null + deanDirectorFullName: string | null + deanDirectorDisplayName: string | null + deanDirectorInterim: string | null + deanDirectorIam: string | null + deanDirectorUnitPersonId: number | null + deanDirectorPhone: string | null + deanDirectorModifiedDate: Date | null + deanDirectorModifiedBy: string | null + adminStaffFullName: string | null + adminStaffDisplayName: string | null + adminStaffInterim: string | null + adminStaffIam: string | null + adminStaffUnitPersonId: number | null + adminStaffPhone: string | null + adminStaffModifiedDate: Date | null + adminStaffModifiedBy: string | null + entryId: number + /** + * True when this is the only row the unit produces. Deleting it therefore takes the admin + * staff with it, since no other row would be left to list them. + */ + isOnlyRowForUnit: boolean +} + +type SVMFrequentNumberRecord = { + label: string + phone: string + entryId: number +} + +type SVMPhoneSection = { + title: string + id: number + cols: QTableProps["columns"] + rows: SVMPhoneDisplayRecord[] +} + +type SVMSectionAPIResponse = { + sectionId: number + name: string + includeAbbrv: boolean + unitName: string | null + directorTitle: string + sortOrder: number +} + +type SVMUnitPerson = { + unitPersonId: number + unitId: number + personIam: string + office: string | null + posType: string | null + interim: string | null + modifiedDate: Date | null + modifiedBy: string | null + unit: null + person: PhonePerson | null + viperModPerson: ViperPerson | null +} + +type SVMUnitAPIResponse = { + unitId: number + sectionId: number + name: string | null + abbrv: string | null + sortOrder: number | null + fax: string | null + section: null + unitPersons: SVMUnitPerson[] | null +} + +type SVMFrequentNumberAPIResponse = { + numberId: number + label: string + phone: string + sortOrder: number | null +} + +type UnitOptions = { + section: number + units: QSelectOption[] +} + +type UnitFaxNumber = { + unitId: number + fax: string +} + +type UnitAdminStaff = { + unitId: number + staffIam: string + staffFullName: string + staffPhone: string + staffInterim: string + staffUnitPersonId: number +} + +type SVMUnitNumberDTO = { + fax: string + location: string + deanIam: string + deanPhone: string + deanInterim: string + deanUnitPerson: number + staffIam: string + staffPhone: string + staffInterim: string + staffUnitPerson: number +} + +export type { + SVMPhoneDisplayRecord, + SVMFrequentNumberRecord, + SVMPhoneSection, + SVMSectionAPIResponse, + SVMUnitPerson, + SVMUnitAPIResponse, + SVMFrequentNumberAPIResponse, + UnitOptions, + UnitFaxNumber, + UnitAdminStaff, + SVMUnitNumberDTO, +} diff --git a/VueApp/src/components/RecordFormDialog.vue b/VueApp/src/components/RecordFormDialog.vue new file mode 100644 index 000000000..e6fa78b90 --- /dev/null +++ b/VueApp/src/components/RecordFormDialog.vue @@ -0,0 +1,113 @@ + + + + + + {{ title }} + + + + + + + + + + + {{ formError }} + + + + + + + + + {{ isEdit ? "Save Changes" : submitLabel }} + + + + + + + + + diff --git a/VueApp/src/components/__tests__/record-form-dialog.test.ts b/VueApp/src/components/__tests__/record-form-dialog.test.ts new file mode 100644 index 000000000..5aed2dd17 --- /dev/null +++ b/VueApp/src/components/__tests__/record-form-dialog.test.ts @@ -0,0 +1,130 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { Quasar } from "quasar" +import RecordFormDialog from "../RecordFormDialog.vue" + +/** + * RecordFormDialog is the shared shell behind PhoneListAddRecordDialog, SVMAddRecordDialog, and + * SVMAddFrequentNumberDialog. This file tests functionality involving + * closing and canceling these dialogs. + */ + +function mountDialog(props: Partial["$props"]> = {}) { + return mount(RecordFormDialog, { + props: { + modelValue: true, + titleId: "test-dialog-title", + title: "Test Dialog", + isEdit: false, + saving: false, + formError: "", + submitLabel: "Upload", + ...props, + }, + global: { plugins: [Quasar] }, + attachTo: document.body, + }) +} + +function findButtonByLabel(wrapper: ReturnType, label: string) { + return wrapper.findAllComponents({ name: "QBtn" }).find((btn) => btn.props("label") === label) +} + +// QDialog teleports to a Quasar-managed portal outside the mounted wrapper, and its @keydown.escape +// listener sits on the teleported [role="dialog"] element - a plain VTU .trigger() on the wrapper +// doesn't reach it, so dispatch a real KeyboardEvent there instead. +async function pressEscape(): Promise { + const dialogEl = document.querySelector('[role="dialog"]') + dialogEl?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })) + await flushPromises() +} + +describe("recordFormDialog.vue - close behavior", () => { + it("emits update:modelValue false when Cancel is clicked, with no confirmClose guard", async () => { + expect.hasAssertions() + document.body.innerHTML = "" + const wrapper = mountDialog() + await flushPromises() + + await findButtonByLabel(wrapper, "Cancel")!.trigger("click") + + expect(wrapper.emitted("update:modelValue")).toStrictEqual([[false]]) + }) + + it("emits update:modelValue false when the header close button is clicked", async () => { + expect.hasAssertions() + document.body.innerHTML = "" + const wrapper = mountDialog() + await flushPromises() + + const closeButton = wrapper.findAllComponents({ name: "QBtn" }).find((btn) => btn.props("icon") === "close") + expect(closeButton).toBeTruthy() + await closeButton!.trigger("click") + + expect(wrapper.emitted("update:modelValue")).toStrictEqual([[false]]) + }) + + it("stays open when confirmClose resolves false", async () => { + expect.hasAssertions() + document.body.innerHTML = "" + const confirmClose = vi.fn<() => Promise>().mockResolvedValue(false) + const wrapper = mountDialog({ confirmClose }) + await flushPromises() + + await findButtonByLabel(wrapper, "Cancel")!.trigger("click") + + expect(confirmClose).toHaveBeenCalledOnce() + expect(wrapper.emitted("update:modelValue")).toBeFalsy() + }) + + it("closes when confirmClose resolves true", async () => { + expect.hasAssertions() + document.body.innerHTML = "" + const confirmClose = vi.fn<() => Promise>().mockResolvedValue(true) + const wrapper = mountDialog({ confirmClose }) + await flushPromises() + + await findButtonByLabel(wrapper, "Cancel")!.trigger("click") + + expect(wrapper.emitted("update:modelValue")).toStrictEqual([[false]]) + }) + + it("closes on Escape without a confirmClose guard", async () => { + expect.hasAssertions() + document.body.innerHTML = "" + const wrapper = mountDialog() + await flushPromises() + + await pressEscape() + + expect(wrapper.emitted("update:modelValue")).toStrictEqual([[false]]) + }) + + it("stays open on Escape when confirmClose resolves false", async () => { + expect.hasAssertions() + document.body.innerHTML = "" + const confirmClose = vi.fn<() => Promise>().mockResolvedValue(false) + const wrapper = mountDialog({ confirmClose }) + await flushPromises() + + await pressEscape() + + expect(confirmClose).toHaveBeenCalledOnce() + expect(wrapper.emitted("update:modelValue")).toBeFalsy() + }) + + it("resets form validation and emits hide when the dialog reports hide", async () => { + expect.hasAssertions() + document.body.innerHTML = "" + const wrapper = mountDialog() + await flushPromises() + const resetValidation = vi.fn<() => void>() + // FormRef is the mounted QForm instance; stub its resetValidation to confirm onHide calls it, + // without needing to actually fail validation first. + wrapper.findComponent({ name: "QForm" }).vm.resetValidation = resetValidation + + await wrapper.findComponent({ name: "QDialog" }).vm.$emit("hide") + + expect(resetValidation).toHaveBeenCalledOnce() + expect(wrapper.emitted("hide")).toBeTruthy() + }) +}) diff --git a/VueApp/src/composables/ViperFetch.ts b/VueApp/src/composables/ViperFetch.ts index 34888227f..ed87db70a 100644 --- a/VueApp/src/composables/ViperFetch.ts +++ b/VueApp/src/composables/ViperFetch.ts @@ -228,9 +228,11 @@ async function postForBlob( if (contentDisposition) { // Try to extract filename from Content-Disposition header // Format: attachment; filename="filename.ext" or attachment; filename=filename.ext - const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/) - if (filenameMatch && filenameMatch[1]) { - filename = filenameMatch[1].replaceAll(/['"]/g, "") + const filenameMatch = contentDisposition.match( + /filename[^;=\n]*=(?(?['"]).*?\k|[^;\n]*)/u, + ) + if (filenameMatch?.groups?.filename) { + filename = filenameMatch.groups.filename.replaceAll(/['"]/gu, "") } } @@ -303,3 +305,4 @@ function downloadBlob(blob: Blob, filename: string): void { } export { useFetch, postForBlob, downloadBlob, HTTP_STATUS } +export type { Result, Pagination } diff --git a/VueApp/src/composables/__tests__/use-person-search.test.ts b/VueApp/src/composables/__tests__/use-person-search.test.ts new file mode 100644 index 000000000..9c8d790cc --- /dev/null +++ b/VueApp/src/composables/__tests__/use-person-search.test.ts @@ -0,0 +1,82 @@ +import { usePersonSearch } from "../use-person-search" + +type Person = { iamId: string; fullName: string } + +type Deferred = { promise: Promise; resolve: (value: T) => void } + +// A controllable pending promise, for simulating a search that hasn't resolved yet. The `as` +// cast lets `resolve` be filled in by the executor without a separate uninitialized declaration. +function createDeferred(): Deferred { + const deferred = {} as Deferred + // eslint-disable-next-line avoid-new -- a controllable pending promise is the point of this helper + deferred.promise = new Promise((resolve) => { + deferred.resolve = resolve + }) + return deferred +} + +function applyUpdate(fn: () => void): void { + fn() +} + +function runFilter(searchPeople: (val: string, update: (fn: () => void) => void) => Promise, val: string) { + return searchPeople(val, applyUpdate) +} + +describe("usePersonSearch()", () => { + it("clears options without calling search when the term is below two characters", async () => { + expect.hasAssertions() + const search = vi.fn<(value: string) => Promise>() + const { searchPeople, options, loading } = usePersonSearch(search) + options.value = [{ iamId: "a", fullName: "Existing Person" }] + + await runFilter(searchPeople, "a") + + expect(search).not.toHaveBeenCalled() + expect(options.value).toStrictEqual([]) + expect(loading.value).toBeFalsy() + }) + + it("sets loading and populates options for a valid search", async () => { + expect.hasAssertions() + const results: Person[] = [{ iamId: "person01", fullName: "Amy Smith" }] + const search = vi.fn<(value: string) => Promise>().mockResolvedValue(results) + const { searchPeople, options } = usePersonSearch(search) + + await runFilter(searchPeople, " ab ") + + expect(search).toHaveBeenCalledWith("ab") + expect(options.value).toStrictEqual(results) + }) + + it("falls back to an empty list when search resolves null", async () => { + expect.hasAssertions() + const search = vi.fn<(value: string) => Promise>().mockResolvedValue(null) + const { searchPeople, options } = usePersonSearch(search) + + await runFilter(searchPeople, "ab") + + expect(options.value).toStrictEqual([]) + }) + + it("discards a slower, earlier response that resolves after a newer search", async () => { + expect.hasAssertions() + const first = createDeferred() + const second = createDeferred() + const search = vi + .fn<(value: string) => Promise>() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + const { searchPeople, options } = usePersonSearch(search) + + const firstFilter = runFilter(searchPeople, "first") + const secondFilter = runFilter(searchPeople, "second") + + second.resolve([{ iamId: "second", fullName: "Second Result" }]) + await secondFilter + first.resolve([{ iamId: "first", fullName: "First Result" }]) + await firstFilter + + expect(options.value).toStrictEqual([{ iamId: "second", fullName: "Second Result" }]) + }) +}) diff --git a/VueApp/src/composables/use-person-search.ts b/VueApp/src/composables/use-person-search.ts new file mode 100644 index 000000000..56634b78c --- /dev/null +++ b/VueApp/src/composables/use-person-search.ts @@ -0,0 +1,41 @@ +import { ref } from "vue" + +/** + * Debounced, out-of-order-safe server search for a QSelect's @filter handler. Shared by every + * PersonSelector variant (CMS, Personnel): the search/race-guard logic is identical across them, + * only the search function and result type differ per caller. + */ +function usePersonSearch(search: (value: string) => Promise) { + const options = ref([]) + const loading = ref(false) + // Guards against out-of-order responses: only the latest search may update options + let searchSeq = 0 + + async function searchPeople(val: string, update: (fn: () => void) => void) { + if (val.trim().length < 2) { + // Invalidate any in-flight search too, or its late response would repopulate + // the options we just cleared. + searchSeq += 1 + loading.value = false + update(() => { + options.value = [] + }) + return + } + searchSeq += 1 + const seq = searchSeq + loading.value = true + const result = await search(val.trim()) + if (seq !== searchSeq) { + return + } + loading.value = false + update(() => { + options.value = result ?? [] + }) + } + + return { options, loading, searchPeople } +} + +export { usePersonSearch } diff --git a/VueApp/vueapp.esproj b/VueApp/vueapp.esproj index 8513a0c6b..1c514331e 100644 --- a/VueApp/vueapp.esproj +++ b/VueApp/vueapp.esproj @@ -18,7 +18,9 @@ + + - \ No newline at end of file + diff --git a/test/Classes/Utilities/PersonSearchHelperTests.cs b/test/Classes/Utilities/PersonSearchHelperTests.cs new file mode 100644 index 000000000..3eaaa3971 --- /dev/null +++ b/test/Classes/Utilities/PersonSearchHelperTests.cs @@ -0,0 +1,137 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Classes.Utilities; + +namespace Viper.test.Classes.Utilities; + +/// +/// Tests for PersonSearchHelper, the shared "search current people by partial name" query shape +/// used by both the CMS file/permission pickers and the Personnel phone directory. A regression +/// here affects every autocomplete built on it. +/// +public class PersonSearchHelperTests +{ + private sealed class Person + { + public required string LastName { get; set; } + public required string FirstName { get; set; } + public string LoginId { get; set; } = ""; + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("a")] + [InlineData(" a ")] + public void Normalize_ReturnsNull_WhenBelowMinimumLength(string? search) + { + Assert.Null(PersonSearchHelper.Normalize(search)); + } + + [Fact] + public void Normalize_ReturnsTrimmedValue_WhenAtOrAboveMinimumLength() + { + var result = PersonSearchHelper.Normalize(" ab "); + + Assert.Equal("ab", result); + } + + [Fact] + public void NameMatches_MatchesLastCommaFirstForm() + { + var people = new[] { new Person { LastName = "Smith", FirstName = "Amy" } }.AsQueryable(); + var predicate = PersonSearchHelper.NameMatches(p => p.LastName, p => p.FirstName, "mith, A"); + + var results = people.Where(predicate).ToList(); + + Assert.Single(results); + } + + [Fact] + public void NameMatches_MatchesFirstSpaceLastForm() + { + var people = new[] { new Person { LastName = "Smith", FirstName = "Amy" } }.AsQueryable(); + var predicate = PersonSearchHelper.NameMatches(p => p.LastName, p => p.FirstName, "Amy Sm"); + + var results = people.Where(predicate).ToList(); + + Assert.Single(results); + } + + [Fact] + public void NameMatches_ExcludesNonMatchingPeople() + { + var people = new[] + { + new Person { LastName = "Smith", FirstName = "Amy" }, + new Person { LastName = "Jones", FirstName = "Bob" }, + }.AsQueryable(); + var predicate = PersonSearchHelper.NameMatches(p => p.LastName, p => p.FirstName, "Smith"); + + var results = people.Where(predicate).ToList(); + + var match = Assert.Single(results); + Assert.Equal("Smith", match.LastName); + } + + [Fact] + public void OrderAndCap_OrdersByLastNameThenFirstName_AndCapsToMaxResults() + { + var people = Enumerable.Range(0, 30) + .Select(i => new Person { LastName = $"Person{i:D2}", FirstName = "X" }) + .Reverse() + .AsQueryable(); + + var results = PersonSearchHelper.OrderAndCap(people, p => p.LastName, p => p.FirstName).ToList(); + + Assert.Equal(PersonSearchHelper.MaxResults, results.Count); + Assert.Equal("Person00", results[0].LastName); + Assert.Equal("Person24", results[^1].LastName); + } + + [Fact] + public void Or_IncludesRecordsMatchingEitherPredicate() + { + var people = new[] + { + new Person { LastName = "Smith", FirstName = "Amy", LoginId = "asmith" }, + new Person { LastName = "Jones", FirstName = "Bob", LoginId = "bjones" }, + }.AsQueryable(); + var namePredicate = PersonSearchHelper.NameMatches(p => p.LastName, p => p.FirstName, "Smith"); + var combined = namePredicate.Or(p => p.LoginId == "bjones"); + + var results = people.Where(combined).ToList(); + + Assert.Equal(2, results.Count); + } + + private sealed class SearchTestContext(DbContextOptions options) : DbContext(options) + { + public DbSet People => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity().HasKey(p => p.LastName); + } + + [Fact] + public void NameMatches_EmitsASqlParameter_RatherThanALiteral() + { + // These autocompletes fire per keystroke, so a term embedded as a literal would give every + // distinct search its own query plan. The literal form also drops the ESCAPE clause, which + // is what stops a typed % or _ from being treated as a wildcard. + var options = new DbContextOptionsBuilder() + .UseSqlServer("Server=none;Database=none;Trusted_Connection=True;") + .Options; + using var context = new SearchTestContext(options); + + var sql = context.People + .Where(PersonSearchHelper.NameMatches(p => p.LastName, p => p.FirstName, "smith")) + .ToQueryString(); + + // Assert on the predicate, not the whole string: ToQueryString prefixes a DECLARE that + // spells the value out for copy-paste even when the query itself is parameterized. + Assert.Contains("LIKE @", sql, StringComparison.Ordinal); + Assert.DoesNotContain("LIKE N'", sql, StringComparison.Ordinal); + Assert.Contains("ESCAPE", sql, StringComparison.Ordinal); + } +} diff --git a/test/Personnel/PhoneListControllerTests.cs b/test/Personnel/PhoneListControllerTests.cs new file mode 100644 index 000000000..dfeeb5e00 --- /dev/null +++ b/test/Personnel/PhoneListControllerTests.cs @@ -0,0 +1,165 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneListController. GetListInfo is what the client renders from before +/// it fetches any rows, so the two capability flags it reports have to match what the write and +/// read endpoints actually enforce: CanMaintain follows the list's own MaintainRole, and +/// CanViewDirectPhone is deliberately broader - a member of the list sees direct numbers without +/// being able to edit it. A flag that overstated either would show the client controls the API +/// then refuses. +/// +public sealed class PhoneListControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhoneListController _controller; + + private const string CallerIam = "caller01"; + private const string VmdoRole = "SVMSecure.PhoneLists.VMDOMaintain"; + + public PhoneListControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + var rapsContext = Substitute.For(); + var permissionsService = new PhonePermissionsService(rapsContext, _userHelper); + var phoneListService = new PhoneListService(_context); + var unitService = new PhoneListUnitService(_context, _userHelper, permissionsService); + + _controller = new PhoneListController(phoneListService, unitService, permissionsService); + + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 1, + Code = "VMDO", + Name = "Dean's Office", + MaintainRole = VmdoRole, + }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 1, PhoneListId = 1, Name = "Front Office" }); + _context.PhonePerson.Add(new PhonePerson { PersonIam = CallerIam, Phone = "530-555-1000" }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + private void GrantRole(string role) + { + _userHelper.HasPermission(Arg.Any(), Arg.Any(), role).Returns(true); + } + + /// Puts the caller on the list itself, which is not the same as maintaining it. + private void AddCallerToList() + { + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitPersonId = 1, + PhoneListUnitId = 1, + PersonIam = CallerIam, + ListFirst = false, + IsActive = true, + }); + _context.SaveChanges(); + } + + private async Task GetInfo(string code) + { + var result = await _controller.GetListInfo(code, TestContext.Current.CancellationToken); + var okResult = Assert.IsType(result.Result); + return Assert.IsType(okResult.Value); + } + + [Fact] + public async Task GetListInfo_ReturnsTheListIdentity() + { + var info = await GetInfo("VMDO"); + + Assert.Equal(1, info.PhoneListId); + Assert.Equal("VMDO", info.Code); + Assert.Equal("Dean's Office", info.Name); + } + + [Fact] + public async Task GetListInfo_ReportsNoCapabilities_ForACallerWithNeitherRoleNorMembership() + { + var info = await GetInfo("VMDO"); + + Assert.False(info.CanMaintain); + Assert.False(info.CanViewDirectPhone); + } + + [Fact] + public async Task GetListInfo_ReportsBothCapabilities_ForAMaintainer() + { + GrantRole(VmdoRole); + + var info = await GetInfo("VMDO"); + + Assert.True(info.CanMaintain); + Assert.True(info.CanViewDirectPhone); + } + + [Fact] + public async Task GetListInfo_ReportsDirectPhoneOnly_ForAMemberWhoCannotMaintain() + { + // Membership is what grants the direct-number view, so the two flags have to move + // independently: reporting CanMaintain here would offer edit controls the API refuses. + AddCallerToList(); + + var info = await GetInfo("VMDO"); + + Assert.False(info.CanMaintain); + Assert.True(info.CanViewDirectPhone); + } + + [Fact] + public async Task GetListInfo_IgnoresMembershipOfAnotherList() + { + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 2, + Code = "OTHER", + Name = "Some Other Unit", + MaintainRole = "SVMSecure.PhoneLists.OtherMaintain", + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + AddCallerToList(); + + var info = await GetInfo("OTHER"); + + Assert.False(info.CanViewDirectPhone); + } + + [Fact] + public async Task GetListInfo_ReturnsNotFound_ForAnUnknownCode() + { + var result = await _controller.GetListInfo("NOPE", TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } +} diff --git a/test/Personnel/PhoneListModifiedDateControllerTests.cs b/test/Personnel/PhoneListModifiedDateControllerTests.cs new file mode 100644 index 000000000..f97e34392 --- /dev/null +++ b/test/Personnel/PhoneListModifiedDateControllerTests.cs @@ -0,0 +1,140 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneListModifiedDateController, the endpoint clients poll to decide +/// whether their cached copy of a list is stale. Two properties matter: deleted rows still count +/// (a removal is a change the client has to pick up, and soft-deleted rows are the only record of +/// it), and the date is scoped to the list named in the route. +/// +public sealed class PhoneListModifiedDateControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneListModifiedDateController _controller; + + private static readonly DateTime Older = new(2026, 1, 1, 9, 0, 0, DateTimeKind.Local); + private static readonly DateTime Newer = new(2026, 6, 1, 9, 0, 0, DateTimeKind.Local); + + public PhoneListModifiedDateControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + var userHelper = Substitute.For(); + userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = "caller01", + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = "caller01", + }); + + var rapsContext = Substitute.For(); + var permissionsService = new PhonePermissionsService(rapsContext, userHelper); + var phoneListService = new PhoneListService(_context); + var unitService = new PhoneListUnitService(_context, userHelper, permissionsService); + + _controller = new PhoneListModifiedDateController(phoneListService, unitService); + + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 1, + Code = "VMDO", + Name = "Dean's Office", + MaintainRole = "SVMSecure.PhoneLists.VMDOMaintain", + }); + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 2, + Code = "OTHER", + Name = "Some Other Unit", + MaintainRole = "SVMSecure.PhoneLists.OtherMaintain", + }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 1, PhoneListId = 1, Name = "Front Office" }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 2, PhoneListId = 2, Name = "Other Office" }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + private void AddRow(int unitPersonId, int unitId, DateTime? modifiedDate, bool isActive = true) + { + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitPersonId = unitPersonId, + PhoneListUnitId = unitId, + PersonIam = "person01", + ListFirst = false, + IsActive = isActive, + ModifiedDate = modifiedDate, + }); + _context.SaveChanges(); + } + + private async Task GetDate(string code) + { + var result = await _controller.GetLastModifiedDate(code, TestContext.Current.CancellationToken); + var okResult = Assert.IsType(result.Result); + return (DateTime?)okResult.Value; + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsTheMostRecentDate() + { + AddRow(unitPersonId: 1, unitId: 1, Older); + AddRow(unitPersonId: 2, unitId: 1, Newer); + + Assert.Equal(Newer, await GetDate("VMDO")); + } + + [Fact] + public async Task GetLastModifiedDate_CountsDeletedRows() + { + // A removal is the change most likely to matter to a client holding stale rows, and the + // soft-deleted row is the only record that it happened. + AddRow(unitPersonId: 1, unitId: 1, Older); + AddRow(unitPersonId: 2, unitId: 1, Newer, isActive: false); + + Assert.Equal(Newer, await GetDate("VMDO")); + } + + [Fact] + public async Task GetLastModifiedDate_IgnoresAnotherListsRows() + { + AddRow(unitPersonId: 1, unitId: 1, Older); + AddRow(unitPersonId: 2, unitId: 2, Newer); + + Assert.Equal(Older, await GetDate("VMDO")); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsNull_WhenNothingHasBeenModified() + { + AddRow(unitPersonId: 1, unitId: 1, modifiedDate: null); + + Assert.Null(await GetDate("VMDO")); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsNotFound_ForAnUnknownCode() + { + var result = await _controller.GetLastModifiedDate("NOPE", TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } +} diff --git a/test/Personnel/PhoneListServiceTests.cs b/test/Personnel/PhoneListServiceTests.cs new file mode 100644 index 000000000..0401a9277 --- /dev/null +++ b/test/Personnel/PhoneListServiceTests.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhoneListService, the entry point every list-scoped request resolves a list +/// through. Lookup is by Code rather than Name so that renaming a list for display cannot +/// break the routes and API paths that address it. +/// +public sealed class PhoneListServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneListService _service; + + public PhoneListServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + _service = new PhoneListService(_context); + } + + public void Dispose() => _context.Dispose(); + + private void SeedLists() + { + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 1, + Code = "VMDO", + Name = "Dean's Office", + MaintainRole = "SVMSecure.PhoneLists.VMDOMaintain", + }); + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 2, + Code = "OTHER", + Name = "Some Other Unit", + MaintainRole = "SVMSecure.PhoneLists.OtherMaintain", + }); + _context.SaveChanges(); + } + + [Fact] + public async Task GetListByCode_ReturnsMatchingList() + { + SeedLists(); + + var result = await _service.GetListByCode("OTHER", TestContext.Current.CancellationToken); + + Assert.Equal(2, result.PhoneListId); + Assert.Equal("Some Other Unit", result.Name); + } + + [Fact] + public async Task GetListByCode_ResolvesIndependentlyOfDisplayName() + { + SeedLists(); + var list = await _context.PhoneList.SingleAsync(l => l.Code == "VMDO", TestContext.Current.CancellationToken); + list.Name = "Office of the Dean"; + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _service.GetListByCode("VMDO", TestContext.Current.CancellationToken); + + Assert.Equal(1, result.PhoneListId); + } + + [Fact] + public async Task GetListByCode_Throws_WhenCodeNotFound() + { + await Assert.ThrowsAsync( + () => _service.GetListByCode("NOPE", TestContext.Current.CancellationToken)); + } +} diff --git a/test/Personnel/PhoneListUnitControllerTests.cs b/test/Personnel/PhoneListUnitControllerTests.cs new file mode 100644 index 000000000..cea934723 --- /dev/null +++ b/test/Personnel/PhoneListUnitControllerTests.cs @@ -0,0 +1,234 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneListUnitController. Beyond the InvalidOperationException-to-400 +/// mapping, these cover the authorization model: write access is the role named by the target +/// list's own MaintainRole column, so holding one list's role must grant nothing on another, +/// and a record id from one list must not be reachable through another list's route. +/// +public sealed class PhoneListUnitControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhoneListUnitController _controller; + + private const string CallerIam = "caller01"; + private const string VmdoRole = "SVMSecure.PhoneLists.VMDOMaintain"; + private const string OtherRole = "SVMSecure.PhoneLists.OtherMaintain"; + + public PhoneListUnitControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + var rapsContext = Substitute.For(); + var permissionsService = new PhonePermissionsService(rapsContext, _userHelper); + var phoneListService = new PhoneListService(_context); + var unitService = new PhoneListUnitService(_context, _userHelper, permissionsService); + + _controller = new PhoneListUnitController(phoneListService, unitService, permissionsService); + + // Two lists, each with its own unit and its own maintain role. + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 1, + Code = "VMDO", + Name = "Dean's Office", + MaintainRole = VmdoRole, + }); + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 2, + Code = "OTHER", + Name = "Some Other Unit", + MaintainRole = OtherRole, + }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 1, PhoneListId = 1, Name = "Front Office" }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 2, PhoneListId = 2, Name = "Other Office" }); + _context.PhonePerson.Add(new PhonePerson { PersonIam = "person01", Phone = "530-555-1000" }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + /// Grants the caller exactly one maintain role. + private void GrantRole(string role) + { + _userHelper.HasPermission(Arg.Any(), Arg.Any(), role).Returns(true); + } + + private void AddUnitPersonRow(int unitPersonId, int unitId) + { + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitPersonId = unitPersonId, + PhoneListUnitId = unitId, + PersonIam = "person01", + ListFirst = false, + IsActive = true, + }); + _context.SaveChanges(); + } + + private static PhoneListUnitDataRequest Request(int unitId) => new() + { + UnitId = unitId, + EmployeeIam = "person01", + Phone = "530-555-1000", + DirectPhone = "530-555-2000", + Office = "Room 100", + }; + + [Fact] + public async Task GetUnits_ReturnsOk_WithUnitsForTheNamedList() + { + var result = await _controller.GetUnits("VMDO", TestContext.Current.CancellationToken); + + var okResult = Assert.IsType(result.Result); + var units = Assert.IsAssignableFrom>(okResult.Value); + Assert.Equal("Front Office", Assert.Single(units).Name); + } + + [Fact] + public async Task GetUnits_ReturnsNotFound_ForAnUnknownCode() + { + var result = await _controller.GetUnits("NOPE", TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task AddUnitPersonData_ReturnsOk_ForAMaintainerOfThatList() + { + GrantRole(VmdoRole); + + var result = await _controller.AddUnitPersonData("VMDO", Request(1), TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task AddUnitPersonData_IsForbidden_WithoutTheRoleForThatList() + { + var result = await _controller.AddUnitPersonData("VMDO", Request(1), TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task AddUnitPersonData_IsForbidden_ForAMaintainerOfADifferentList() + { + // Holding VMDOMaintain must not confer write access to the OTHER list, which is what a + // hard-coded permission attribute on the endpoint would have allowed. + GrantRole(VmdoRole); + + var result = await _controller.AddUnitPersonData("OTHER", Request(2), TestContext.Current.CancellationToken); + + Assert.IsType(result); + Assert.Empty(await _context.PhoneListUnitPerson.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task AddUnitPersonData_ReturnsBadRequest_WhenTheUnitBelongsToAnotherList() + { + // Unit 2 is on the OTHER list; routing through VMDO must not reach it. + GrantRole(VmdoRole); + + var result = await _controller.AddUnitPersonData("VMDO", Request(2), TestContext.Current.CancellationToken); + + Assert.IsType(result); + Assert.Empty(await _context.PhoneListUnitPerson.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task UpdateUnitPersonData_ReturnsBadRequest_WhenNotFound() + { + GrantRole(VmdoRole); + + var result = await _controller.UpdateUnitPersonData("VMDO", 999, Request(1), TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task UpdateUnitPersonData_ReturnsBadRequest_WhenTheRecordBelongsToAnotherList() + { + AddUnitPersonRow(unitPersonId: 5, unitId: 2); + GrantRole(VmdoRole); + + var result = await _controller.UpdateUnitPersonData("VMDO", 5, Request(1), TestContext.Current.CancellationToken); + + Assert.IsType(result); + var untouched = await _context.PhoneListUnitPerson + .FindAsync(new object?[] { 5 }, TestContext.Current.CancellationToken); + Assert.NotNull(untouched); + Assert.True(untouched.IsActive); + } + + [Fact] + public async Task DeleteUnitPersonData_ReturnsBadRequest_WhenNotFound() + { + GrantRole(VmdoRole); + + var result = await _controller.DeleteUnitPersonData("VMDO", 999, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteUnitPersonData_ReturnsOk_AndSoftDeletes_WhenFound() + { + AddUnitPersonRow(unitPersonId: 1, unitId: 1); + GrantRole(VmdoRole); + + var result = await _controller.DeleteUnitPersonData("VMDO", 1, TestContext.Current.CancellationToken); + + Assert.IsType(result); + var stillExists = await _context.PhoneListUnitPerson + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(stillExists); + Assert.False(stillExists.IsActive); + } + + [Fact] + public async Task DeleteUnitPersonData_LeavesTheRecordAlone_WhenItBelongsToAnotherList() + { + AddUnitPersonRow(unitPersonId: 5, unitId: 2); + GrantRole(VmdoRole); + + var result = await _controller.DeleteUnitPersonData("VMDO", 5, TestContext.Current.CancellationToken); + + Assert.IsType(result); + var untouched = await _context.PhoneListUnitPerson + .FindAsync(new object?[] { 5 }, TestContext.Current.CancellationToken); + Assert.NotNull(untouched); + Assert.True(untouched.IsActive); + } +} diff --git a/test/Personnel/PhoneListUnitServiceTests.cs b/test/Personnel/PhoneListUnitServiceTests.cs new file mode 100644 index 000000000..e7a89769d --- /dev/null +++ b/test/Personnel/PhoneListUnitServiceTests.cs @@ -0,0 +1,406 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhoneListUnitService, focused on the direct-phone visibility rule: +/// a caller may only see DirectPhone for a list if they hold the list's maintain +/// permission, OR they are themselves an active member of that list. +/// +public sealed class PhoneListUnitServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhoneListUnitService _service; + + private const string MaintainRole = "SVMSecure.PhoneLists.VMDOMaintain"; + private const string CallerIam = "caller01"; + + public PhoneListUnitServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + // rapsContext is unused when IUserHelper.HasPermission is mocked directly, + // so a bare substitute (no seeded roles) is sufficient here. + var rapsContext = Substitute.For(); + var permissionsService = new PhonePermissionsService(rapsContext, _userHelper); + + _service = new PhoneListUnitService(_context, _userHelper, permissionsService); + + SeedList(); + } + + public void Dispose() => _context.Dispose(); + + /// The seeded list, as the controller would hand it to the service. + private PhoneList TestList() => + _context.PhoneList.Single(l => l.PhoneListId == 1); + + private void AddUnit(int unitId, string name) + { + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = unitId, PhoneListId = 1, Name = name }); + _context.SaveChanges(); + } + + /// Puts a person on a unit, with the phone row the read paths expect them to have. + private void AddMember(int unitPersonId, int unitId, string personIam, bool listFirst) + { + _context.PhonePerson.Add(new PhonePerson { PersonIam = personIam, Phone = "530-555-0000" }); + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitPersonId = unitPersonId, + PhoneListUnitId = unitId, + PersonIam = personIam, + ListFirst = listFirst, + IsActive = true, + }); + _context.SaveChanges(); + } + + private async Task FindMember(int unitPersonId) => + await _context.PhoneListUnitPerson.FindAsync(new object?[] { unitPersonId }, TestContext.Current.CancellationToken); + + private void SeedList() + { + _context.PhoneList.Add(new PhoneList { PhoneListId = 1, Code = "VMDO", Name = "Dean's Office", MaintainRole = MaintainRole }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 1, PhoneListId = 1, Name = "Dean's Office" }); + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 1, + IamId = "listedperson", + FirstName = "Listed", + LastName = "Person", + FullName = "Listed Person", + CurrentEmployee = true, + }); + _context.PhonePerson.Add(new PhonePerson + { + PersonIam = "listedperson", + Phone = "530-555-1000", + DirectPhone = "530-555-2000", + Office = "Room 100", + }); + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitId = 1, + PersonIam = "listedperson", + ListFirst = false, + IsActive = true, + }); + _context.SaveChanges(); + } + + [Fact] + public async Task GetPhoneListUnits_MasksDirectPhone_WhenCallerHasNoAccess() + { + // Caller has neither the maintain permission nor a membership row on this list. + _userHelper.HasPermission(Arg.Any(), Arg.Any(), MaintainRole).Returns(false); + + var units = await _service.GetPhoneListUnits(TestList(), TestContext.Current.CancellationToken); + + var person = Assert.Single(Assert.Single(units).PhoneListUnitPersons); + Assert.Equal("530-555-1000", person.Person.Phone); + Assert.Equal("", person.Person.DirectPhone); + } + + [Fact] + public async Task GetPhoneListUnits_ShowsDirectPhone_WhenCallerIsListMember() + { + // No maintain permission, but the caller is themselves an active member of the list - + // membership alone should be enough to unlock direct numbers for that list. + _userHelper.HasPermission(Arg.Any(), Arg.Any(), MaintainRole).Returns(false); + _context.PhonePerson.Add(new PhonePerson { PersonIam = CallerIam, Phone = "", DirectPhone = "", Office = "" }); + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitId = 1, + PersonIam = CallerIam, + ListFirst = false, + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var units = await _service.GetPhoneListUnits(TestList(), TestContext.Current.CancellationToken); + + var person = Assert.Single(units.Single().PhoneListUnitPersons, p => p.PersonIam == "listedperson"); + Assert.Equal("530-555-2000", person.Person.DirectPhone); + } + + [Fact] + public async Task AddUnitPersonData_UnsetsPreviousListFirst_WhenNewPersonIsMarkedFirst() + { + var existingFirstPerson = Assert.Single(_context.PhoneListUnitPerson); + existingFirstPerson.ListFirst = true; + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 2, + IamId = "newperson", + FirstName = "New", + LastName = "Person", + FullName = "New Person", + CurrentEmployee = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = "newperson", + Phone = "530-555-5000", + DirectPhone = "530-555-6000", + Office = "Room 300", + ListFirst = true, + }; + + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + + Assert.False(existingFirstPerson.ListFirst); + var newPerson = await _context.PhoneListUnitPerson + .SingleAsync(p => p.PersonIam == "newperson", TestContext.Current.CancellationToken); + Assert.True(newPerson.ListFirst); + } + + [Fact] + public async Task AddUnitPersonData_CalledTwiceForSamePerson_UpsertsInsteadOfDuplicating() + { + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = "listedperson", + Phone = "530-555-9000", + DirectPhone = "530-555-9001", + Office = "Room 900", + ListFirst = false, + }; + + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + + var associations = await _context.PhoneListUnitPerson + .Where(p => p.PersonIam == "listedperson" && p.PhoneListUnitId == 1) + .ToListAsync(TestContext.Current.CancellationToken); + Assert.Single(associations); + + var phonePerson = await _context.PhonePerson + .SingleAsync(p => p.PersonIam == "listedperson", TestContext.Current.CancellationToken); + Assert.Equal("530-555-9000", phonePerson.Phone); + Assert.Equal("530-555-9001", phonePerson.DirectPhone); + Assert.Equal("Room 900", phonePerson.Office); + } + + [Fact] + public async Task AddUnitPersonData_TrimsWhitespace_ForANewPerson() + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 3, + IamId = "paddedperson", + FirstName = "Padded", + LastName = "Person", + FullName = "Padded Person", + CurrentEmployee = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = " paddedperson ", + Phone = " 530-555-9500 ", + DirectPhone = " 530-555-9501 ", + Office = " Room 950 ", + ListFirst = false, + }; + + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + + var phonePerson = await _context.PhonePerson + .SingleAsync(p => p.PersonIam == "paddedperson", TestContext.Current.CancellationToken); + Assert.Equal("530-555-9500", phonePerson.Phone); + Assert.Equal("Room 950", phonePerson.Office); + } + + [Fact] + public async Task AddUnitPersonData_CalledTwiceWithAPaddedIam_UpsertsInsteadOfDuplicating() + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 4, + IamId = "paddedtwice", + FirstName = "Padded", + LastName = "Twice", + FullName = "Padded Twice", + CurrentEmployee = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + // The existing-row lookup has to trim the same way the insert does. Matching a padded + // request against the trimmed PersonIam already stored finds nothing, so every resubmit + // would add another association row for the same person and unit. + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = " paddedtwice ", + Phone = " 530-555-9600 ", + DirectPhone = " 530-555-9601 ", + Office = " Room 960 ", + ListFirst = false, + }; + + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + + var association = Assert.Single(await _context.PhoneListUnitPerson + .Where(p => p.PersonIam == "paddedtwice" && p.PhoneListUnitId == 1) + .ToListAsync(TestContext.Current.CancellationToken)); + Assert.True(association.IsActive); + Assert.Equal("paddedtwice", association.PersonIam); + } + + [Fact] + public async Task UpdateUnitPersonData_UpdatesThePhonePerson_AndModifiedMetadata() + { + var unitPerson = Assert.Single(_context.PhoneListUnitPerson); + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = "listedperson", + Phone = " 530-555-7000 ", + DirectPhone = " 530-555-7001 ", + Office = " Room 700 ", + }; + + await _service.UpdateUnitPersonData( + 1, unitPerson.PhoneListUnitPersonId, request, TestContext.Current.CancellationToken); + + var phonePerson = await _context.PhonePerson + .FindAsync(new object?[] { "listedperson" }, TestContext.Current.CancellationToken); + Assert.NotNull(phonePerson); + Assert.Equal("530-555-7000", phonePerson.Phone); + Assert.Equal("530-555-7001", phonePerson.DirectPhone); + Assert.Equal("Room 700", phonePerson.Office); + Assert.Equal(CallerIam, unitPerson.ModifiedBy); + Assert.NotNull(unitPerson.ModifiedDate); + } + + [Fact] + public async Task UpdateUnitPersonData_ClearsListFirstOnTheRecordsOwnUnit_NotTheRequestedOne() + { + // The request carries a UnitId, but the record already knows which unit it lives in. Only + // the record's own unit may be cleared: taking the caller's word for it would let a + // mismatched UnitId unset the first-listed entry of an unrelated unit. + AddUnit(unitId: 2, name: "Other Office"); + AddMember(unitPersonId: 10, unitId: 1, personIam: "sameunitfirst", listFirst: true); + AddMember(unitPersonId: 20, unitId: 2, personIam: "otherunitfirst", listFirst: true); + var target = await _context.PhoneListUnitPerson + .SingleAsync(p => p.PersonIam == "listedperson", TestContext.Current.CancellationToken); + + var request = new PhoneListUnitDataRequest + { + UnitId = 2, + EmployeeIam = "listedperson", + Phone = "530-555-7000", + ListFirst = true, + }; + + await _service.UpdateUnitPersonData( + 1, target.PhoneListUnitPersonId, request, TestContext.Current.CancellationToken); + + Assert.True(target.ListFirst); + Assert.False((await FindMember(10))!.ListFirst); + Assert.True((await FindMember(20))!.ListFirst); + } + + [Fact] + public async Task UpdateUnitPersonData_LeavesTheExistingFirstEntry_WhenListFirstIsNotSet() + { + AddMember(unitPersonId: 10, unitId: 1, personIam: "sameunitfirst", listFirst: true); + var target = await _context.PhoneListUnitPerson + .SingleAsync(p => p.PersonIam == "listedperson", TestContext.Current.CancellationToken); + + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = "listedperson", + Phone = "530-555-7000", + ListFirst = false, + }; + + await _service.UpdateUnitPersonData( + 1, target.PhoneListUnitPersonId, request, TestContext.Current.CancellationToken); + + Assert.False(target.ListFirst); + Assert.True((await FindMember(10))!.ListFirst); + } + + [Fact] + public async Task DeleteUnitPersonData_SoftDeletes_KeepsRowButMarksInactive() + { + var unitPerson = Assert.Single(_context.PhoneListUnitPerson); + + await _service.DeleteUnitPersonData(1, unitPerson.PhoneListUnitPersonId, TestContext.Current.CancellationToken); + + var stillExists = await _context.PhoneListUnitPerson + .FindAsync(new object?[] { unitPerson.PhoneListUnitPersonId }, TestContext.Current.CancellationToken); + Assert.NotNull(stillExists); + Assert.False(stillExists.IsActive); + Assert.Equal(CallerIam, stillExists.ModifiedBy); + } + + [Fact] + public async Task DeleteUnitPersonData_Throws_WhenNotFound() + { + await Assert.ThrowsAsync( + () => _service.DeleteUnitPersonData(1, 9999, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task EditingAnAlreadyDeletedRecord_ReportsItAsRemoved_RatherThanResurrectingIt() + { + var unitPerson = Assert.Single(_context.PhoneListUnitPerson); + await _service.DeleteUnitPersonData(1, unitPerson.PhoneListUnitPersonId, TestContext.Current.CancellationToken); + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = "listedperson", + Phone = "530-555-9000", + }; + + // A maintainer whose page predates someone else's delete. Saving must not bring the row + // back, and the message becomes an error banner, so it is worded for that reader. + var ex = await Assert.ThrowsAsync( + () => _service.UpdateUnitPersonData( + 1, unitPerson.PhoneListUnitPersonId, request, TestContext.Current.CancellationToken)); + Assert.Equal("That record has already been removed.", ex.Message); + + var stillDeleted = await _context.PhoneListUnitPerson + .FindAsync(new object?[] { unitPerson.PhoneListUnitPersonId }, TestContext.Current.CancellationToken); + Assert.NotNull(stillDeleted); + Assert.False(stillDeleted.IsActive); + } +} diff --git a/test/Personnel/PhonePersonControllerTests.cs b/test/Personnel/PhonePersonControllerTests.cs new file mode 100644 index 000000000..cd692c5b1 --- /dev/null +++ b/test/Personnel/PhonePersonControllerTests.cs @@ -0,0 +1,204 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhonePersonController, the person picker behind the phone-record dialogs. +/// The controller does the merge itself rather than delegating it: two independent queries (people +/// from users.Person, phone rows from phones.Person) are joined in memory, so the cases worth +/// pinning are the ones the join can get wrong - a person with no phone row, a phone row with no +/// matching person - plus the direct-number masking, which depends on a list code supplied by the +/// caller and so must fail closed when that code is absent or bogus. +/// +public sealed class PhonePersonControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhonePersonController _controller; + + private const string VmdoRole = "SVMSecure.PhoneLists.VMDOMaintain"; + + public PhonePersonControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = "caller01", + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = "caller01", + }); + + var rapsContext = Substitute.For(); + var permissionsService = new PhonePermissionsService(rapsContext, _userHelper); + var phoneListService = new PhoneListService(_context); + var lookupService = new PhonePersonLookupService(_context, permissionsService); + + _controller = new PhonePersonController(phoneListService, lookupService); + + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 1, + Code = "VMDO", + Name = "Dean's Office", + MaintainRole = VmdoRole, + }); + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 1, + IamId = "person01", + FirstName = "Ada", + LastName = "Smithers", + FullName = "Ada Smithers", + CurrentEmployee = true, + MailId = "asmithers", + }); + _context.PhonePerson.Add(new PhonePerson + { + PersonIam = "person01", + Phone = "530-555-1000", + DirectPhone = "530-555-2000", + }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + private void GrantRole(string role) + { + _userHelper.HasPermission(Arg.Any(), Arg.Any(), role).Returns(true); + } + + private async Task> Search(string search, string? listCode = null) + { + var result = await _controller.GetCurrentEmployees(search, listCode, TestContext.Current.CancellationToken); + var okResult = Assert.IsType(result.Result); + return Assert.IsAssignableFrom>(okResult.Value); + } + + [Fact] + public async Task GetCurrentEmployees_MergesPhoneDataOntoTheMatchedPerson() + { + var results = await Search("Smithers"); + + var person = Assert.Single(results); + Assert.Equal("person01", person.IamId); + Assert.Equal("Ada Smithers", person.FullName); + Assert.NotNull(person.PhoneData); + Assert.Equal("530-555-1000", person.PhoneData.Phone); + } + + [Fact] + public async Task GetCurrentEmployees_MasksDirectPhone_WhenNoListIsNamed() + { + var results = await Search("Smithers"); + + Assert.Equal("", Assert.Single(results).PhoneData?.DirectPhone); + } + + [Fact] + public async Task GetCurrentEmployees_MasksDirectPhone_ForANonMaintainer() + { + var results = await Search("Smithers", "VMDO"); + + Assert.Equal("", Assert.Single(results).PhoneData?.DirectPhone); + } + + [Fact] + public async Task GetCurrentEmployees_ReturnsDirectPhone_ForAMaintainerOfTheNamedList() + { + GrantRole(VmdoRole); + + var results = await Search("Smithers", "VMDO"); + + Assert.Equal("530-555-2000", Assert.Single(results).PhoneData?.DirectPhone); + } + + [Fact] + public async Task GetCurrentEmployees_MasksDirectPhone_ForAnUnknownListCode() + { + // An unresolvable code drops to "no list", not "no permission check": holding the role + // must not be enough on its own, since the code is caller-supplied. + GrantRole(VmdoRole); + + var results = await Search("Smithers", "NOPE"); + + Assert.Equal("", Assert.Single(results).PhoneData?.DirectPhone); + } + + [Fact] + public async Task GetCurrentEmployees_ReturnsThePerson_WhenTheyHaveNoPhoneRow() + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 2, + IamId = "person02", + FirstName = "Bo", + LastName = "Smithfield", + FullName = "Bo Smithfield", + CurrentEmployee = true, + MailId = "bsmithfield", + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await Search("Smithfield"); + + var person = Assert.Single(results); + Assert.Equal("person02", person.IamId); + Assert.Null(person.PhoneData); + } + + [Fact] + public async Task GetCurrentEmployees_IgnoresPhoneRowsWithNoMatchingPerson() + { + // phones.Person outlives users.Person entries, so an orphaned phone row must not + // materialize as a pickable person. + _context.PhonePerson.Add(new PhonePerson { PersonIam = "ghost01", Phone = "530-555-9999" }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await Search("Smithers"); + + Assert.Equal("person01", Assert.Single(results).IamId); + } + + [Fact] + public async Task GetCurrentEmployees_ExcludesFormerEmployees() + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 3, + IamId = "person03", + FirstName = "Cy", + LastName = "Smithson", + FullName = "Cy Smithson", + CurrentEmployee = false, + MailId = "csmithson", + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + Assert.Empty(await Search("Smithson")); + } + + [Fact] + public async Task GetCurrentEmployees_ReturnsEmpty_ForASearchTermBelowTheMinimumLength() + { + Assert.Empty(await Search("S")); + } +} diff --git a/test/Personnel/PhonePersonLookupServiceTests.cs b/test/Personnel/PhonePersonLookupServiceTests.cs new file mode 100644 index 000000000..8b458288e --- /dev/null +++ b/test/Personnel/PhonePersonLookupServiceTests.cs @@ -0,0 +1,157 @@ +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhonePersonLookupService: GetPhonePeople implements the same +/// permission-based DirectPhone masking as PhoneListUnitService, but as an independent code +/// path used by the person-picker autocomplete, so a regression there wouldn't be caught by +/// the PhoneListUnitService tests. GetViperCurrentEmployees layers PersonSearchHelper on top +/// of the CurrentEmployee filter. +/// +public sealed class PhonePersonLookupServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhonePersonLookupService _service; + + private const string MaintainRole = "SVMSecure.PhoneLists.VMDOMaintain"; + private const string CallerIam = "caller01"; + + public PhonePersonLookupServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + var rapsContext = Substitute.For(); + var permissionsService = new PhonePermissionsService(rapsContext, _userHelper); + _service = new PhonePersonLookupService(_context, permissionsService); + + _context.PhoneList.Add(new PhoneList { PhoneListId = 1, Code = "VMDO", Name = "Dean's Office", MaintainRole = MaintainRole }); + _context.PhonePerson.Add(new PhonePerson + { + PersonIam = "person01", + Phone = "530-555-1000", + DirectPhone = "530-555-2000", + }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + /// The seeded list, as the controller would hand it to the service. + private PhoneList TestList() => + _context.PhoneList.Single(l => l.PhoneListId == 1); + + [Fact] + public async Task GetPhonePeople_MasksDirectPhone_WhenNoListSupplied() + { + var results = await _service.GetPhonePeople(["person01"], ct: TestContext.Current.CancellationToken); + + var person = Assert.Single(results); + Assert.Equal("", person.DirectPhone); + } + + [Fact] + public async Task GetPhonePeople_MasksDirectPhone_WhenCallerLacksMaintainAccessToList() + { + _userHelper.HasPermission(Arg.Any(), Arg.Any(), MaintainRole).Returns(false); + + var results = await _service.GetPhonePeople(["person01"], TestList(), ct: TestContext.Current.CancellationToken); + + var person = Assert.Single(results); + Assert.Equal("", person.DirectPhone); + } + + [Fact] + public async Task GetPhonePeople_ShowsDirectPhone_WhenCallerHasMaintainAccessToList() + { + _userHelper.HasPermission(Arg.Any(), Arg.Any(), MaintainRole).Returns(true); + + var results = await _service.GetPhonePeople(["person01"], TestList(), ct: TestContext.Current.CancellationToken); + + var person = Assert.Single(results); + Assert.Equal("530-555-2000", person.DirectPhone); + } + + [Fact] + public async Task GetPhonePeople_IgnoresBlankAndWhitespaceIamIds() + { + var results = await _service.GetPhonePeople( + ["person01", "", " ", null!], + ct: TestContext.Current.CancellationToken); + + Assert.Single(results); + } + + [Fact] + public async Task GetViperCurrentEmployees_ReturnsEmpty_WhenSearchBelowMinimumLength() + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 1, + IamId = "person01", + FirstName = "Amy", + LastName = "Smith", + FullName = "Amy Smith", + CurrentEmployee = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await _service.GetViperCurrentEmployees("a", TestContext.Current.CancellationToken); + + Assert.Empty(results); + } + + [Fact] + public async Task GetViperCurrentEmployees_ExcludesFormerEmployees() + { + _context.ViperPerson.AddRange( + new ViperPerson + { + PersonId = 1, + IamId = "person01", + FirstName = "Amy", + LastName = "Smith", + FullName = "Amy Smith", + CurrentEmployee = true, + }, + new ViperPerson + { + PersonId = 2, + IamId = "person02", + FirstName = "Amy", + LastName = "Smithson", + FullName = "Amy Smithson", + CurrentEmployee = false, + } + ); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await _service.GetViperCurrentEmployees("Smith", TestContext.Current.CancellationToken); + + var match = Assert.Single(results); + Assert.Equal("person01", match.IamId); + } +} diff --git a/test/Personnel/PhoneSVMFrequentNumberControllerTests.cs b/test/Personnel/PhoneSVMFrequentNumberControllerTests.cs new file mode 100644 index 000000000..c124be591 --- /dev/null +++ b/test/Personnel/PhoneSVMFrequentNumberControllerTests.cs @@ -0,0 +1,101 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller wiring tests for PhoneSVMFrequentNumberController: verifies the +/// InvalidOperationException-to-400 mapping used across the phones endpoints when +/// a maintain action targets a row that doesn't exist (or was already removed). +/// +public sealed class PhoneSVMFrequentNumberControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMFrequentNumberController _controller; + + public PhoneSVMFrequentNumberControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + var userHelper = Substitute.For(); + userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = "caller01", + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = "caller01", + }); + + _controller = new PhoneSVMFrequentNumberController(new PhoneSVMFrequentNumberService(_context, userHelper)); + } + + public void Dispose() => _context.Dispose(); + + [Fact] + public async Task UpdateFrequentNumber_ReturnsBadRequest_WhenNotFound() + { + var request = new SVMFrequentNumberRequest { Label = "Front Desk", Phone = "530-555-1000" }; + + var result = await _controller.UpdateFrequentNumber(999, request, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteFrequentNumber_ReturnsBadRequest_WhenNotFound() + { + var result = await _controller.DeleteFrequentNumber(999, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteFrequentNumber_ReturnsOk_AndSoftDeletes_WhenFound() + { + _context.SVMFrequentNumber.Add(new SVMFrequentNumber + { + NumberId = 1, + Label = "Pharmacy", + Phone = "530-555-2000", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _controller.DeleteFrequentNumber(1, TestContext.Current.CancellationToken); + + Assert.IsType(result); + var stillExists = await _context.SVMFrequentNumber + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(stillExists); + Assert.False(stillExists.IsActive); + } + + [Fact] + public async Task GetFrequentNumbers_ReturnsOnlyActiveNumbers() + { + _context.SVMFrequentNumber.AddRange( + new SVMFrequentNumber { NumberId = 1, Label = "Active Line", Phone = "1", IsActive = true }, + new SVMFrequentNumber { NumberId = 2, Label = "Retired Line", Phone = "2", IsActive = false } + ); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _controller.GetFrequentNumbers(TestContext.Current.CancellationToken); + + var okResult = Assert.IsType(result.Result); + var numbers = Assert.IsAssignableFrom>(okResult.Value); + Assert.Equal(["Active Line"], numbers.Select(n => n.Label)); + } +} diff --git a/test/Personnel/PhoneSVMFrequentNumberServiceTests.cs b/test/Personnel/PhoneSVMFrequentNumberServiceTests.cs new file mode 100644 index 000000000..629f64bf2 --- /dev/null +++ b/test/Personnel/PhoneSVMFrequentNumberServiceTests.cs @@ -0,0 +1,201 @@ +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhoneSVMFrequentNumberService, focused on the soft-delete +/// convention (rows are marked inactive rather than removed, so ModifiedDate keeps +/// tracking when the list last changed) and the SQL Server 2016-safe null-last +/// SortOrder ordering. +/// +public sealed class PhoneSVMFrequentNumberServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhoneSVMFrequentNumberService _service; + + private const string CallerIam = "caller01"; + + public PhoneSVMFrequentNumberServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + _service = new PhoneSVMFrequentNumberService(_context, _userHelper); + } + + public void Dispose() => _context.Dispose(); + + private void SeedNumber(string label, string phone, bool isActive = true) + { + _context.SVMFrequentNumber.Add(new SVMFrequentNumber + { + NumberId = 1, + Label = label, + Phone = phone, + IsActive = isActive, + }); + _context.SaveChanges(); + } + + private async Task FindNumber(int numberId) => + await _context.SVMFrequentNumber.FindAsync(new object?[] { numberId }, TestContext.Current.CancellationToken); + + [Fact] + public async Task AddFrequentNumber_SetsIsActiveTrue_AndModifiedMetadata() + { + var request = new SVMFrequentNumberRequest { Label = "Front Desk", Phone = "530-555-1000" }; + + await _service.AddFrequentNumber(request, TestContext.Current.CancellationToken); + + var saved = await _context.SVMFrequentNumber + .SingleAsync(n => n.Label == "Front Desk", TestContext.Current.CancellationToken); + Assert.True(saved.IsActive); + Assert.Equal(CallerIam, saved.ModifiedBy); + Assert.NotNull(saved.ModifiedDate); + } + + [Fact] + public async Task DeleteFrequentNumber_SoftDeletes_ExcludesRowFromGetSVMFrequentNumbers() + { + _context.SVMFrequentNumber.Add(new SVMFrequentNumber + { + NumberId = 1, + Label = "Pharmacy", + Phone = "530-555-2000", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + await _service.DeleteFrequentNumber(1, TestContext.Current.CancellationToken); + + var stillExists = await _context.SVMFrequentNumber + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(stillExists); + Assert.False(stillExists.IsActive); + + var activeNumbers = await _service.GetSVMFrequentNumbers(TestContext.Current.CancellationToken); + Assert.Empty(activeNumbers); + } + + [Fact] + public async Task DeleteFrequentNumber_Throws_WhenAlreadyInactive() + { + _context.SVMFrequentNumber.Add(new SVMFrequentNumber + { + NumberId = 1, + Label = "Retired Line", + Phone = "530-555-3000", + IsActive = false, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync( + () => _service.DeleteFrequentNumber(1, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task UpdateFrequentNumber_OverwritesFields_AndModifiedMetadata() + { + SeedNumber(label: "Front Desk", phone: "530-555-1000"); + var request = new SVMFrequentNumberRequest { Label = "Reception", Phone = "530-555-4000" }; + + await _service.UpdateFrequentNumber(1, request, TestContext.Current.CancellationToken); + + var saved = await FindNumber(1); + Assert.NotNull(saved); + Assert.Equal("Reception", saved.Label); + Assert.Equal("530-555-4000", saved.Phone); + Assert.Equal(CallerIam, saved.ModifiedBy); + Assert.NotNull(saved.ModifiedDate); + Assert.True(saved.IsActive); + } + + [Fact] + public async Task UpdateFrequentNumber_TrimsWhitespace() + { + SeedNumber(label: "Front Desk", phone: "530-555-1000"); + var request = new SVMFrequentNumberRequest { Label = " Reception ", Phone = " 530-555-4000 " }; + + await _service.UpdateFrequentNumber(1, request, TestContext.Current.CancellationToken); + + var saved = await FindNumber(1); + Assert.NotNull(saved); + Assert.Equal("Reception", saved.Label); + Assert.Equal("530-555-4000", saved.Phone); + } + + [Theory] + [InlineData("", "530-555-4000", "Location must not be empty.")] + // Whitespace-only rather than empty: the guard is IsNullOrWhiteSpace, and a bare "" would + // still pass if it were ever weakened to IsNullOrEmpty. + [InlineData(" ", "530-555-4000", "Location must not be empty.")] + [InlineData("Reception", "", "Phone Number must not be empty.")] + [InlineData("Reception", " ", "Phone Number must not be empty.")] + public async Task UpdateFrequentNumber_Throws_ForBlankFields(string label, string phone, string expectedMessage) + { + SeedNumber(label: "Front Desk", phone: "530-555-1000"); + var request = new SVMFrequentNumberRequest { Label = label, Phone = phone }; + + var ex = await Assert.ThrowsAsync( + () => _service.UpdateFrequentNumber(1, request, TestContext.Current.CancellationToken)); + + Assert.Equal(expectedMessage, ex.Message); + var unchanged = await FindNumber(1); + Assert.NotNull(unchanged); + Assert.Equal("Front Desk", unchanged.Label); + } + + [Fact] + public async Task UpdateFrequentNumber_Throws_WhenTheRowWasAlreadyDeleted() + { + // A maintainer whose page predates someone else's delete. Editing must not resurrect the + // row, which the IsActive half of the guard is what prevents - an id-only lookup would + // find the soft-deleted record and happily write to it. + SeedNumber(label: "Retired Line", phone: "530-555-3000", isActive: false); + var request = new SVMFrequentNumberRequest { Label = "Reception", Phone = "530-555-4000" }; + + await Assert.ThrowsAsync( + () => _service.UpdateFrequentNumber(1, request, TestContext.Current.CancellationToken)); + + var stillDeleted = await FindNumber(1); + Assert.NotNull(stillDeleted); + Assert.False(stillDeleted.IsActive); + Assert.Equal("Retired Line", stillDeleted.Label); + } + + [Fact] + public async Task GetSVMFrequentNumbers_OrdersRowsWithNoSortOrderLast() + { + _context.SVMFrequentNumber.AddRange( + new SVMFrequentNumber { NumberId = 1, Label = "Zebra Unsorted", Phone = "1", IsActive = true, SortOrder = null }, + new SVMFrequentNumber { NumberId = 2, Label = "Pharmacy", Phone = "2", IsActive = true, SortOrder = 2 }, + new SVMFrequentNumber { NumberId = 3, Label = "Front Desk", Phone = "3", IsActive = true, SortOrder = 1 } + ); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await _service.GetSVMFrequentNumbers(TestContext.Current.CancellationToken); + + Assert.Equal(["Front Desk", "Pharmacy", "Zebra Unsorted"], results.Select(r => r.Label)); + } +} diff --git a/test/Personnel/PhoneSVMModifiedDateControllerTests.cs b/test/Personnel/PhoneSVMModifiedDateControllerTests.cs new file mode 100644 index 000000000..531370460 --- /dev/null +++ b/test/Personnel/PhoneSVMModifiedDateControllerTests.cs @@ -0,0 +1,130 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneSVMModifiedDateController, which clients poll to decide whether their +/// cached SVM list is stale. The SVM page renders two independently-maintained datasets - frequent +/// numbers and unit people - behind one freshness date, so the endpoint has to report the later of +/// the two and stay correct when either side has never been modified. Reporting the earlier one +/// would leave a client believing its copy is current. +/// +public sealed class PhoneSVMModifiedDateControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMModifiedDateController _controller; + + private static readonly DateTime Older = new(2026, 1, 1, 9, 0, 0, DateTimeKind.Local); + private static readonly DateTime Newer = new(2026, 6, 1, 9, 0, 0, DateTimeKind.Local); + + public PhoneSVMModifiedDateControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + var userHelper = Substitute.For(); + userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = "caller01", + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = "caller01", + }); + + _controller = new PhoneSVMModifiedDateController( + new PhoneSVMFrequentNumberService(_context, userHelper), + new PhoneSVMUnitService(_context, userHelper)); + + _context.SVMUnit.Add(new SVMUnit { UnitId = 1, SectionId = 1, Name = "Dean's Office" }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + private void AddFrequentNumber(DateTime? modifiedDate) + { + _context.SVMFrequentNumber.Add(new SVMFrequentNumber + { + NumberId = 1, + Label = "Front Desk", + Phone = "530-555-1000", + IsActive = true, + ModifiedDate = modifiedDate, + }); + _context.SaveChanges(); + } + + private void AddUnitPerson(DateTime? modifiedDate) + { + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + ModifiedDate = modifiedDate, + }); + _context.SaveChanges(); + } + + private async Task GetDate() + { + var result = await _controller.GetLastModifiedDate(TestContext.Current.CancellationToken); + var okResult = Assert.IsType(result.Result); + return (DateTime?)okResult.Value; + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsNull_WhenNeitherDatasetHasBeenModified() + { + Assert.Null(await GetDate()); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsTheUnitPersonDate_WhenNoFrequentNumberHasOne() + { + AddUnitPerson(Newer); + + Assert.Equal(Newer, await GetDate()); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsTheFrequentNumberDate_WhenNoUnitPersonHasOne() + { + AddFrequentNumber(Newer); + + Assert.Equal(Newer, await GetDate()); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsTheUnitPersonDate_WhenItIsTheLater() + { + AddFrequentNumber(Older); + AddUnitPerson(Newer); + + Assert.Equal(Newer, await GetDate()); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsTheFrequentNumberDate_WhenItIsTheLater() + { + AddFrequentNumber(Newer); + AddUnitPerson(Older); + + Assert.Equal(Newer, await GetDate()); + } +} diff --git a/test/Personnel/PhoneSVMSectionControllerTests.cs b/test/Personnel/PhoneSVMSectionControllerTests.cs new file mode 100644 index 000000000..80775977e --- /dev/null +++ b/test/Personnel/PhoneSVMSectionControllerTests.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneSVMSectionController. Sections are the page's top-level grouping, so +/// the order they come back in is the order the page renders; unsorted sections fall to the end +/// alphabetically rather than jumping to the front on a null SortOrder. +/// +public sealed class PhoneSVMSectionControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMSectionController _controller; + + public PhoneSVMSectionControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + _controller = new PhoneSVMSectionController(new PhoneSVMSectionService(_context)); + } + + public void Dispose() => _context.Dispose(); + + private async Task> GetSections() + { + var result = await _controller.GetSections(TestContext.Current.CancellationToken); + var okResult = Assert.IsType(result.Result); + return Assert.IsAssignableFrom>(okResult.Value); + } + + [Fact] + public async Task GetSections_ReturnsSortedSectionsBeforeUnsortedOnes() + { + _context.SVMSection.AddRange( + new SVMSection { SectionId = 1, Name = "Anatomy", SortOrder = null }, + new SVMSection { SectionId = 2, Name = "Dean's Office", SortOrder = 1 }, + new SVMSection { SectionId = 3, Name = "Zoology", SortOrder = 2 } + ); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var sections = await GetSections(); + + Assert.Equal(["Dean's Office", "Zoology", "Anatomy"], sections.Select(s => s.Name)); + } + + [Fact] + public async Task GetSections_ReturnsNotFound_WhenThereAreNoSections() + { + var result = await _controller.GetSections(TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } +} diff --git a/test/Personnel/PhoneSVMSectionServiceTests.cs b/test/Personnel/PhoneSVMSectionServiceTests.cs new file mode 100644 index 000000000..dfd484fa8 --- /dev/null +++ b/test/Personnel/PhoneSVMSectionServiceTests.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhoneSVMSectionService, covering the SQL Server 2016-safe null-last +/// SortOrder ordering convention shared with PhoneListUnitService and +/// PhoneSVMFrequentNumberService. +/// +public sealed class PhoneSVMSectionServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMSectionService _service; + + public PhoneSVMSectionServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + _service = new PhoneSVMSectionService(_context); + } + + public void Dispose() => _context.Dispose(); + + [Fact] + public async Task GetSVMSections_ReturnsEmptyList_WhenNoSectionsExist() + { + var results = await _service.GetSVMSections(TestContext.Current.CancellationToken); + + Assert.Empty(results); + } + + [Fact] + public async Task GetSVMSections_OrdersRowsWithNoSortOrderLast() + { + _context.SVMSection.AddRange( + new SVMSection { SectionId = 1, Name = "Zebra Unsorted", SortOrder = null }, + new SVMSection { SectionId = 2, Name = "Registrar", SortOrder = 2 }, + new SVMSection { SectionId = 3, Name = "Dean's Office", SortOrder = 1 } + ); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await _service.GetSVMSections(TestContext.Current.CancellationToken); + + Assert.Equal(["Dean's Office", "Registrar", "Zebra Unsorted"], results.Select(r => r.Name)); + } +} diff --git a/test/Personnel/PhoneSVMUnitControllerTests.cs b/test/Personnel/PhoneSVMUnitControllerTests.cs new file mode 100644 index 000000000..8946163f4 --- /dev/null +++ b/test/Personnel/PhoneSVMUnitControllerTests.cs @@ -0,0 +1,206 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneSVMUnitController: the read shape the SVM page renders from, and the +/// InvalidOperationException-to-400 mapping on the three maintain endpoints. Unlike the per-list +/// controllers, write access here is a fixed role on the endpoint rather than a per-row lookup, so +/// what the controller itself decides is narrower - which makes the failure mapping the thing +/// worth pinning, since a 500 here would surface to a maintainer as an unexplained error banner +/// instead of the message the service wrote for them. +/// +public sealed class PhoneSVMUnitControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMUnitController _controller; + + private const string CallerIam = "caller01"; + + public PhoneSVMUnitControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + _context = new PhonesDbContext(options); + + var userHelper = Substitute.For(); + userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + _controller = new PhoneSVMUnitController(new PhoneSVMUnitService(_context, userHelper)); + + _context.SVMUnit.Add(new SVMUnit { UnitId = 1, SectionId = 1, Name = "Dean's Office" }); + // The read projection joins phones.Person to users.Person on a required relationship, so + // a phone row without its person is invisible to GetUnits. + AddPerson(personId: 1, "dean01", "Dinah", "Deanly", "530-555-1000"); + AddPerson(personId: 2, "staff01", "Sam", "Staffly", "530-555-2000"); + _context.SaveChanges(); + } + + private void AddPerson(int personId, string iamId, string firstName, string lastName, string phone) + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = personId, + IamId = iamId, + FirstName = firstName, + LastName = lastName, + FullName = $"{firstName} {lastName}", + CurrentEmployee = true, + }); + _context.PhonePerson.Add(new PhonePerson { PersonIam = iamId, Phone = phone }); + } + + public void Dispose() => _context.Dispose(); + + private void AddUnitPerson(int unitPersonId, string personIam, string posType, bool isActive = true) + { + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = unitPersonId, + UnitId = 1, + PersonIam = personIam, + PosType = posType, + IsActive = isActive, + }); + _context.SaveChanges(); + } + + private static SVMUnitDataRequest Request() => new() + { + Fax = "530-555-3000", + Location = "Room 100", + DeanIam = "dean01", + DeanPhone = "530-555-1000", + }; + + private async Task FindUnitPerson(int unitPersonId) => + await _context.SVMUnitPerson.FindAsync(new object?[] { unitPersonId }, TestContext.Current.CancellationToken); + + [Fact] + public async Task GetUnits_ReturnsOk_WithActivePeopleOnly() + { + AddUnitPerson(unitPersonId: 1, "dean01", "Dean"); + AddUnitPerson(unitPersonId: 2, "staff01", "Staff", isActive: false); + + var result = await _controller.GetUnits(TestContext.Current.CancellationToken); + + var okResult = Assert.IsType(result.Result); + var units = Assert.IsAssignableFrom>(okResult.Value); + var unit = Assert.Single(units); + Assert.Equal("dean01", Assert.Single(unit.UnitPersons).PersonIam); + } + + [Fact] + public async Task GetUnits_ReturnsOk_WithAnEmptyListWhenThereAreNoUnits() + { + // An empty SVM list is a legitimate state, not a 404: the page still renders its sections. + _context.SVMUnit.RemoveRange(_context.SVMUnit); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _controller.GetUnits(TestContext.Current.CancellationToken); + + var okResult = Assert.IsType(result.Result); + Assert.Empty(Assert.IsAssignableFrom>(okResult.Value)); + } + + [Fact] + public async Task AddUnitData_ReturnsBadRequest_ForAnUnknownUnit() + { + var result = await _controller.AddUnitData(999, Request(), TestContext.Current.CancellationToken); + + Assert.IsType(result); + Assert.Empty(await _context.SVMUnitPerson.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task AddUnitData_ReturnsOk_AndAddsTheLeader() + { + var result = await _controller.AddUnitData(1, Request(), TestContext.Current.CancellationToken); + + Assert.IsType(result); + var added = Assert.Single(await _context.SVMUnitPerson + .Where(p => p.IsActive) + .ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal("dean01", added.PersonIam); + Assert.Equal("Dean", added.PosType); + } + + [Fact] + public async Task UpdateUnitData_ReturnsBadRequest_ForAnUnknownUnit() + { + var result = await _controller.UpdateUnitData(999, Request(), TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task UpdateUnitData_ReturnsOk_AndReplacesTheNamedRow() + { + AddUnitPerson(unitPersonId: 1, "dean01", "Dean"); + var request = Request(); + request.DeanUnitPerson = 1; + + var result = await _controller.UpdateUnitData(1, request, TestContext.Current.CancellationToken); + + Assert.IsType(result); + var replaced = await FindUnitPerson(1); + Assert.NotNull(replaced); + Assert.False(replaced.IsActive); + Assert.Equal("Room 100", Assert.Single(await _context.SVMUnitPerson + .Where(p => p.IsActive) + .ToListAsync(TestContext.Current.CancellationToken)).Office); + } + + [Fact] + public async Task DeleteUnitRow_ReturnsBadRequest_WhenNotFound() + { + var result = await _controller.DeleteUnitRow(999, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteUnitRow_ReturnsBadRequest_WhenTheRowWasAlreadyRemoved() + { + // The realistic way to miss through the UI: two maintainers on the same list. + AddUnitPerson(unitPersonId: 1, "dean01", "Dean", isActive: false); + + var result = await _controller.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + var badRequest = Assert.IsType(result); + Assert.Equal("That record has already been removed.", badRequest.Value); + } + + [Fact] + public async Task DeleteUnitRow_ReturnsOk_AndSoftDeletes_WhenFound() + { + AddUnitPerson(unitPersonId: 1, "dean01", "Dean"); + + var result = await _controller.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + Assert.IsType(result); + var stillExists = await FindUnitPerson(1); + Assert.NotNull(stillExists); + Assert.False(stillExists.IsActive); + } +} diff --git a/test/Personnel/PhoneSVMUnitServiceTests.cs b/test/Personnel/PhoneSVMUnitServiceTests.cs new file mode 100644 index 000000000..3a87ad664 --- /dev/null +++ b/test/Personnel/PhoneSVMUnitServiceTests.cs @@ -0,0 +1,516 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhoneSVMUnitService, covering the two places its row handling is asymmetric. +/// DeleteUnitRow: a row is a leader plus the unit-wide admin staff, so deleting it removes the +/// leader and then the staff only once no other row still lists them - in one transaction, since +/// as separate per-record requests the pair could half-apply. +/// AddOrUpdateUnitData: one method serves both POST and PUT, so add and edit are distinguished +/// only by DeanUnitPerson/StaffUnitPerson - unset (-1) means "add another person to this unit" +/// and leaves existing rows alone, while a real id means "replace the person on that row" and +/// must deactivate it even though the incoming DeanIam/StaffIam no longer names its occupant. +/// +public sealed class PhoneSVMUnitServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMUnitService _service; + + private const string CallerIam = "caller01"; + + public PhoneSVMUnitServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + _context = new PhonesDbContext(options); + + var userHelper = Substitute.For(); + userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + _service = new PhoneSVMUnitService(_context, userHelper); + } + + public void Dispose() => _context.Dispose(); + + private void SeedUnit() + { + _context.SVMUnit.Add(new SVMUnit { UnitId = 1, SectionId = 1, Name = "Dean's Office" }); + _context.PhonePerson.Add(new PhonePerson { PersonIam = "dean01", Phone = "530-555-1000" }); + _context.PhonePerson.Add(new PhonePerson { PersonIam = "staff01", Phone = "530-555-2000" }); + _context.SaveChanges(); + } + + + [Fact] + public async Task DeleteUnitRow_SoftDeletesTheLeader_AndTheStaffItWasTheLastRowFor() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + // One call, not one per underlying record: the caller names the row, the service decides + // which records that covers. + await _service.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + Assert.Empty(await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive) + .ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DeleteUnitRow_KeepsTheStaff_WhenAnotherLeaderRowStillListsThem() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "dean02", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 3, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + await _service.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + var staffRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 3 }, TestContext.Current.CancellationToken); + Assert.NotNull(staffRow); + Assert.True(staffRow.IsActive); + + var survivingLeader = await _context.SVMUnitPerson + .FindAsync(new object?[] { 2 }, TestContext.Current.CancellationToken); + Assert.NotNull(survivingLeader); + Assert.True(survivingLeader.IsActive); + } + + [Fact] + public async Task DeleteUnitRow_RemovesTheStaff_WhenNamedForAStaffOnlyRow() + { + SeedUnit(); + // A unit with staff but no active leader renders one row keyed by the staff record, so + // that id is what the delete arrives with. + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + await _service.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + var staffRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(staffRow); + Assert.False(staffRow.IsActive); + } + + [Fact] + public async Task DeleteUnitRow_LeavesOtherUnitsAlone() + { + SeedUnit(); + _context.SVMUnit.Add(new SVMUnit { UnitId = 2, SectionId = 1, Name = "Another Unit" }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 2, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + await _service.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + var otherUnitStaff = await _context.SVMUnitPerson + .FindAsync(new object?[] { 2 }, TestContext.Current.CancellationToken); + Assert.NotNull(otherUnitStaff); + Assert.True(otherUnitStaff.IsActive); + } + + [Fact] + public async Task DeleteUnitRow_Throws_WhenNotFound() + { + await Assert.ThrowsAsync( + () => _service.DeleteUnitRow(999, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task AddOrUpdateUnitData_Throws_WhenUnitNotFound() + { + var request = new SVMUnitDataRequest { DeanIam = "dean01", DeanPhone = "530-555-1000" }; + + await Assert.ThrowsAsync( + () => _service.AddOrUpdateUnitData(999, request, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task AddOrUpdateUnitData_DeactivatesPreviousUnitPeople_AndAddsNewActiveRows() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var request = new SVMUnitDataRequest + { + Fax = " 530-555-9999 ", + Location = "Room 500", + DeanIam = "dean01", + DeanPhone = "530-555-1111", + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var oldRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(oldRow); + Assert.False(oldRow.IsActive); + + var newRow = await _context.SVMUnitPerson + .SingleAsync(p => p.IsActive && p.PersonIam == "dean01", TestContext.Current.CancellationToken); + Assert.Equal("Dean", newRow.PosType); + + var unit = await _context.SVMUnit.FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.Equal("530-555-9999", unit!.Fax); + } + + [Fact] + public async Task GetSVMUnits_AlwaysBlanksDirectPhone() + { + SeedUnit(); + var phonePerson = await _context.PhonePerson + .SingleAsync(p => p.PersonIam == "dean01", TestContext.Current.CancellationToken); + phonePerson.DirectPhone = "530-555-4000"; + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 1, + IamId = "dean01", + FirstName = "Dean", + LastName = "Person", + FullName = "Dean Person", + CurrentEmployee = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var units = await _service.GetSVMUnits(TestContext.Current.CancellationToken); + + var person = Assert.Single(Assert.Single(units).UnitPersons); + Assert.Equal("", person.Person.DirectPhone); + } + + [Fact] + public async Task AddOrUpdateUnitData_DeactivatesTheEditedRow_WhenTheDeanIsReplaced() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + // Editing the row for dean01 and choosing dean02 instead. The outgoing person is + // identified only by DeanUnitPerson, since DeanIam now names the incoming person. + var request = new SVMUnitDataRequest + { + Fax = "530-555-9999", + Location = "Room 500", + DeanIam = "dean02", + DeanPhone = "530-555-1112", + DeanUnitPerson = 1, + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var replacedRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(replacedRow); + Assert.False(replacedRow.IsActive); + + var activeLeader = Assert.Single(await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive && p.PosType != "Staff") + .ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal("dean02", activeLeader.PersonIam); + } + + [Fact] + public async Task AddOrUpdateUnitData_DeactivatesTheEditedRow_WhenTheStaffIsReplaced() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var request = new SVMUnitDataRequest + { + Location = "Room 500", + DeanIam = "dean01", + DeanPhone = "530-555-1000", + DeanUnitPerson = 1, + StaffIam = "staff02", + StaffPhone = "530-555-2002", + StaffUnitPerson = 2, + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var replacedRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 2 }, TestContext.Current.CancellationToken); + Assert.NotNull(replacedRow); + Assert.False(replacedRow.IsActive); + + var activeStaff = Assert.Single(await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive && p.PosType == "Staff") + .ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal("staff02", activeStaff.PersonIam); + } + + [Fact] + public async Task AddOrUpdateUnitData_LeavesOtherLeaderRowsActive_WhenOneLeaderIsReplaced() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "dean02", + PosType = "Dean", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + // A unit legitimately has several leader rows; replacing one must not disturb the rest. + var request = new SVMUnitDataRequest + { + Location = "Room 500", + DeanIam = "dean03", + DeanPhone = "530-555-1113", + DeanUnitPerson = 1, + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var untouchedRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 2 }, TestContext.Current.CancellationToken); + Assert.NotNull(untouchedRow); + Assert.True(untouchedRow.IsActive); + + var activeLeaders = await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive && p.PosType != "Staff") + .ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal(2, activeLeaders.Count); + Assert.Contains(activeLeaders, p => p.PersonIam == "dean02"); + Assert.Contains(activeLeaders, p => p.PersonIam == "dean03"); + } + + [Fact] + public async Task AddOrUpdateUnitData_KeepsExistingLeaders_WhenUnitPersonIdsAreUnset() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + // The add path leaves DeanUnitPerson/StaffUnitPerson at their -1 default, which is what + // separates "add another leader to this unit" from "replace the person on this row". + var request = new SVMUnitDataRequest + { + Location = "Room 500", + DeanIam = "dean02", + DeanPhone = "530-555-1112", + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var existingRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(existingRow); + Assert.True(existingRow.IsActive); + + var activeLeaders = await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive && p.PosType != "Staff") + .ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal(2, activeLeaders.Count); + } + + [Fact] + public async Task AddOrUpdateUnitData_RemovesStaff_WhenClearedFromTheEditedRow() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var request = new SVMUnitDataRequest + { + Location = "Room 500", + DeanIam = "dean01", + DeanPhone = "530-555-1000", + DeanUnitPerson = 1, + StaffIam = "", + StaffUnitPerson = 2, + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var clearedRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 2 }, TestContext.Current.CancellationToken); + Assert.NotNull(clearedRow); + Assert.False(clearedRow.IsActive); + + Assert.Empty(await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive && p.PosType == "Staff") + .ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DeleteUnitRow_ReportsAnAlreadyDeletedRow_AsRemoved() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + await _service.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + // A maintainer whose page predates someone else's delete. Deleting again must say so + // rather than silently repeating the cascade over rows that are already gone. + var ex = await Assert.ThrowsAsync( + () => _service.DeleteUnitRow(1, TestContext.Current.CancellationToken)); + Assert.Equal("That record has already been removed.", ex.Message); + } +} diff --git a/web/Areas/CMS/Controllers/CMSOptionsController.cs b/web/Areas/CMS/Controllers/CMSOptionsController.cs index 1a7419353..549f059a6 100644 --- a/web/Areas/CMS/Controllers/CMSOptionsController.cs +++ b/web/Areas/CMS/Controllers/CMSOptionsController.cs @@ -4,6 +4,8 @@ using Viper.Areas.RAPS.Services; using Viper.Classes; using Viper.Classes.SQLContext; +using Viper.Classes.Utilities; +using Viper.Models.AAUD; using Web.Authorization; namespace Viper.Areas.CMS.Controllers @@ -65,22 +67,23 @@ public async Task>> GetPermissions(CancellationToken c [HttpGet("people")] public async Task>> SearchPeople(string search, CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(search) || search.Trim().Length < 2) + var normalizedSearch = PersonSearchHelper.Normalize(search); + if (normalizedSearch == null) { return new List(); } - search = search.Trim(); - return await _aaudContext.AaudUsers + var namePredicate = PersonSearchHelper + .NameMatches(u => u.DisplayLastName, u => u.DisplayFirstName, normalizedSearch) + .Or(u => u.LoginId != null && u.LoginId.Contains(normalizedSearch)) + .Or(u => u.MailId != null && u.MailId.Contains(normalizedSearch)); + + var query = _aaudContext.AaudUsers .AsNoTracking() .Where(u => u.Current != 0 && u.IamId != null) - .Where(u => (u.DisplayLastName + ", " + u.DisplayFirstName).Contains(search) - || (u.DisplayFirstName + " " + u.DisplayLastName).Contains(search) - || (u.LoginId != null && u.LoginId.Contains(search)) - || (u.MailId != null && u.MailId.Contains(search))) - .OrderBy(u => u.DisplayLastName) - .ThenBy(u => u.DisplayFirstName) - .Take(25) + .Where(namePredicate); + + return await PersonSearchHelper.OrderAndCap(query, u => u.DisplayLastName, u => u.DisplayFirstName) .Select(u => new CmsPersonOption { IamId = u.IamId!, diff --git a/web/Areas/Personnel/Controllers/PhoneListController.cs b/web/Areas/Personnel/Controllers/PhoneListController.cs new file mode 100644 index 000000000..c657ad635 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneListController.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/phonelist")] + [Permission(Allow = "SVMSecure")] + public class PhoneListController( + PhoneListService phoneListService, + PhoneListUnitService phoneListUnitService, + PhonePermissionsService phonePermissionsService) : ApiController + { + private readonly PhoneListService _phoneListService = phoneListService; + private readonly PhoneListUnitService _phoneListUnitService = phoneListUnitService; + private readonly PhonePermissionsService _phonePermissionsService = phonePermissionsService; + + /// + /// Everything a client needs before it fetches rows: + /// Returns the list's display name plus this caller's permissions. + /// Returned together to reduce API calls on the front end. + /// The backend continues to enforce permissions, but the front end + /// knows what data to expect and display based on these results. + /// + [HttpGet("{code}")] + public async Task> GetListInfo(string code, CancellationToken ct = default) + { + try + { + var list = await _phoneListService.GetListByCode(code, ct); + return Ok(new PhoneListInfo + { + PhoneListId = list.PhoneListId, + Code = list.Code, + Name = list.Name, + CanMaintain = _phonePermissionsService.CanMaintainList(list), + CanViewDirectPhone = await _phoneListUnitService.CanViewDirectPhone(list, ct), + }); + } + catch (InvalidOperationException ex) + { + return NotFound(ex.Message); + } + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneListModifiedDateController.cs b/web/Areas/Personnel/Controllers/PhoneListModifiedDateController.cs new file mode 100644 index 000000000..a0ee9926a --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneListModifiedDateController.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/phonelist/{code}/modifiedDate")] + [Permission(Allow = "SVMSecure")] + public class PhoneListModifiedDateController( + PhoneListService phoneListService, + PhoneListUnitService phoneListUnitService) : ApiController + { + private readonly PhoneListService _phoneListService = phoneListService; + private readonly PhoneListUnitService _phoneListUnitService = phoneListUnitService; + + /// + /// Returns the latest modification date of a UnitPerson in this list. + /// Includes deleted rows. + /// + [HttpGet] + public async Task> GetLastModifiedDate(string code, CancellationToken ct = default) + { + try + { + var list = await _phoneListService.GetListByCode(code, ct); + var unitPersonDate = await _phoneListUnitService.GetUnitPersonModifiedDate(list.PhoneListId, ct); + return Ok(unitPersonDate); + } + catch (InvalidOperationException ex) + { + return NotFound(ex.Message); + } + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneListUnitController.cs b/web/Areas/Personnel/Controllers/PhoneListUnitController.cs new file mode 100644 index 000000000..f7bb1a398 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneListUnitController.cs @@ -0,0 +1,132 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + /// + /// Unit and unit-person endpoints for a phone list, addressed by the list's stable Code. + /// Write access is the role named by that list's MaintainRole column, so each list + /// can have separate permissions. + /// + [Route("/api/phones/phonelist/{code}")] + [Permission(Allow = "SVMSecure")] + public class PhoneListUnitController( + PhoneListService phoneListService, + PhoneListUnitService phoneListUnitService, + PhonePermissionsService phonePermissionsService) : ApiController + { + private readonly PhoneListService _phoneListService = phoneListService; + private readonly PhoneListUnitService _phoneListUnitService = phoneListUnitService; + private readonly PhonePermissionsService _phonePermissionsService = phonePermissionsService; + + /// + /// Resolves the list named in the route and confirms the caller may edit it. Returns the + /// list id on success, or the ActionResult to return to the caller on failure. + /// + private async Task<(int ListId, ActionResult? Failure)> ResolveListForMaintain(string code, CancellationToken ct) + { + PhoneList list; + try + { + list = await _phoneListService.GetListByCode(code, ct); + } + catch (InvalidOperationException ex) + { + return (0, NotFound(ex.Message)); + } + if (!_phonePermissionsService.CanMaintainList(list)) + { + return (0, Forbid()); + } + return (list.PhoneListId, null); + } + + /// + /// Retrieves the PhoneListUnits associated with a given list code, including the PhoneListUnitPersons + /// in that unit. + /// + [HttpGet("units")] + public async Task>> GetUnits(string code, CancellationToken ct = default) + { + try + { + var list = await _phoneListService.GetListByCode(code, ct); + var results = await _phoneListUnitService.GetPhoneListUnits(list, ct); + return Ok(results); + } + catch (InvalidOperationException ex) + { + return NotFound(ex.Message); + } + } + + /// + /// Adds a unit person to a given list, provided the user has appropriate permissions. + /// + [HttpPost("unitPerson")] + public async Task AddUnitPersonData(string code, PhoneListUnitDataRequest request, CancellationToken ct = default) + { + var (listId, failure) = await ResolveListForMaintain(code, ct); + if (failure != null) + { + return failure; + } + try + { + await _phoneListUnitService.AddUnitPersonData(listId, request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Updates a unit person in a given list, provided the user has appropriate permissions. + /// + [HttpPut("unitPerson/{unitPersonId}")] + public async Task UpdateUnitPersonData(string code, int unitPersonId, PhoneListUnitDataRequest request, CancellationToken ct = default) + { + var (listId, failure) = await ResolveListForMaintain(code, ct); + if (failure != null) + { + return failure; + } + try + { + await _phoneListUnitService.UpdateUnitPersonData(listId, unitPersonId, request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Deletes a unit person from a given list, provided the user has appropriate permissions. + /// + [HttpDelete("unitPerson/{unitPersonId}")] + public async Task DeleteUnitPersonData(string code, int unitPersonId, CancellationToken ct = default) + { + var (listId, failure) = await ResolveListForMaintain(code, ct); + if (failure != null) + { + return failure; + } + try + { + await _phoneListUnitService.DeleteUnitPersonData(listId, unitPersonId, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhonePersonController.cs b/web/Areas/Personnel/Controllers/PhonePersonController.cs new file mode 100644 index 000000000..efa54a533 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhonePersonController.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/people")] + [Permission(Allow = "SVMSecure")] + public class PhonePersonController( + PhoneListService phoneListService, + PhonePersonLookupService phonePersonService) : ApiController + { + private readonly PhoneListService _phoneListService = phoneListService; + private readonly PhonePersonLookupService _phonePersonService = phonePersonService; + + /// + /// Person picker for the phone-record dialogs. Only returns direct numbers if the user + /// can edit the current list (and so has access to the data). + /// + [HttpGet] + public async Task>> GetCurrentEmployees(string search, string? listCode = null, CancellationToken ct = default) + { + PhoneList? list = null; + if (!string.IsNullOrWhiteSpace(listCode)) + { + try + { + list = await _phoneListService.GetListByCode(listCode, ct); + } + catch (InvalidOperationException) + { + list = null; + } + } + + List viperResults = await _phonePersonService.GetViperCurrentEmployees(search, ct); + List iamIds = []; + foreach (ViperPerson result in viperResults) + { + iamIds.Add(result.IamId); + } + List phoneResults = await _phonePersonService.GetPhonePeople(iamIds, list, ct); + Dictionary mergedResultsDict = []; + foreach (ViperPerson result in viperResults) + { + mergedResultsDict[result.IamId] = PersonnelMapper.ToAugmentedViperPerson(result); + } + List matchingResults = [.. phoneResults.Where(x => mergedResultsDict.ContainsKey(x.PersonIam))]; + + foreach (PhonePerson result in matchingResults) + { + mergedResultsDict[result.PersonIam].AddPhoneData(result); + } + return Ok(mergedResultsDict.Values.ToList()); + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneSVMFrequentNumberController.cs b/web/Areas/Personnel/Controllers/PhoneSVMFrequentNumberController.cs new file mode 100644 index 000000000..94aa79f84 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneSVMFrequentNumberController.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/svm/frequentnumbers")] + [Permission(Allow = "SVMSecure")] + public class PhoneSVMFrequentNumberController(PhoneSVMFrequentNumberService phoneSVMFrequentNumberService) : ApiController + { + private readonly PhoneSVMFrequentNumberService _phoneSVMFrequentNumberService = phoneSVMFrequentNumberService; + + /// + /// Gets the list of frequently called numbers for the SVM Phone List. + /// + [HttpGet] + public async Task>> GetFrequentNumbers(CancellationToken ct = default) + { + var results = await _phoneSVMFrequentNumberService.GetSVMFrequentNumbers(ct); + return Ok(results); + } + + /// + /// Adds a frequently called number to the SVM Phone List. + /// + [HttpPost] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task AddFrequentNumber(SVMFrequentNumberRequest request, CancellationToken ct = default) + { + try + { + await _phoneSVMFrequentNumberService.AddFrequentNumber(request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Updates a frequently called number in the SVM Phone List. + /// + [HttpPut("{entryId}")] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task UpdateFrequentNumber(int entryId, SVMFrequentNumberRequest request, CancellationToken ct = default) + { + try + { + await _phoneSVMFrequentNumberService.UpdateFrequentNumber(entryId, request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Deletes a frequently called number from the SVM Phone List. + /// + [HttpDelete("{entryId}")] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task DeleteFrequentNumber(int entryId, CancellationToken ct = default) + { + try + { + await _phoneSVMFrequentNumberService.DeleteFrequentNumber(entryId, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneSVMModifiedDateController.cs b/web/Areas/Personnel/Controllers/PhoneSVMModifiedDateController.cs new file mode 100644 index 000000000..28407ab24 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneSVMModifiedDateController.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/svm/modifiedDate")] + [Permission(Allow = "SVMSecure")] + public class PhoneSVMModifiedDateController( + PhoneSVMFrequentNumberService phoneSVMFrequentNumberService, + PhoneSVMUnitService phoneSVMUnitService) : ApiController + { + private readonly PhoneSVMFrequentNumberService _phoneSVMFrequentNumberService = phoneSVMFrequentNumberService; + private readonly PhoneSVMUnitService _phoneSVMUnitService = phoneSVMUnitService; + + /// + /// Identfies when frequent numbers were last modified. + /// + [HttpGet] + public async Task> GetLastModifiedDate(CancellationToken ct = default) + { + var frequentNumberDate = await _phoneSVMFrequentNumberService.GetSVMFrequentNumbersModifiedDate(ct); + var unitPersonDate = await _phoneSVMUnitService.GetSVMUnitPersonModifiedDate(ct); + if (frequentNumberDate == null || unitPersonDate != null && unitPersonDate > frequentNumberDate) + { + return Ok(unitPersonDate); + } + return Ok(frequentNumberDate); + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneSVMSectionController.cs b/web/Areas/Personnel/Controllers/PhoneSVMSectionController.cs new file mode 100644 index 000000000..ceb19ce64 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneSVMSectionController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/svm/sections")] + [Permission(Allow = "SVMSecure")] + public class PhoneSVMSectionController(PhoneSVMSectionService phoneSVMSectionService) : ApiController + { + private readonly PhoneSVMSectionService _phoneSVMSectionService = phoneSVMSectionService; + + /// + /// Gets the sections to include in the SVM Phone List. + /// + [HttpGet] + public async Task>> GetSections(CancellationToken ct = default) + { + var results = await _phoneSVMSectionService.GetSVMSections(ct); + if (results.Count == 0) + { + return NotFound("No sections for the SVM Phone List were found."); + } + return Ok(results); + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneSVMUnitController.cs b/web/Areas/Personnel/Controllers/PhoneSVMUnitController.cs new file mode 100644 index 000000000..759e7a2ba --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneSVMUnitController.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/svm")] + [Permission(Allow = "SVMSecure")] + public class PhoneSVMUnitController(PhoneSVMUnitService phoneSVMUnitService) : ApiController + { + private readonly PhoneSVMUnitService _phoneSVMUnitService = phoneSVMUnitService; + + /// + /// Gets all units for every section in the SVM Phone List. + /// + [HttpGet("units")] + public async Task>> GetUnits(CancellationToken ct = default) + { + var results = await _phoneSVMUnitService.GetSVMUnits(ct); + return Ok(results); + } + + /// + /// Adds data to a unit for the SVM Phone List. + /// This affects both SVMUnit and SVMUnitPerson. + /// + [HttpPost("units/{unitId}")] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task AddUnitData(int unitId, SVMUnitDataRequest request, CancellationToken ct = default) + { + try + { + await _phoneSVMUnitService.AddOrUpdateUnitData(unitId, request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Updates data in a unit for the SVM Phone List. + /// This affects both SVMUnit and SVMUnitPerson. + /// + [HttpPut("units/{unitId}")] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task UpdateUnitData(int unitId, SVMUnitDataRequest request, CancellationToken ct = default) + { + try + { + await _phoneSVMUnitService.AddOrUpdateUnitData(unitId, request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Deletes one row of the SVM list, identified by the row key the list renders. + /// This may delete multiple SVMUnitPerson. + /// Handled this way to match the end user experience and wrap multiple + /// deletions in a transaction. + /// + [HttpDelete("rows/{entryId}")] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task DeleteUnitRow(int entryId, CancellationToken ct = default) + { + try + { + await _phoneSVMUnitService.DeleteUnitRow(entryId, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + } +} diff --git a/web/Areas/Personnel/Models/AugmentedViperPerson.cs b/web/Areas/Personnel/Models/AugmentedViperPerson.cs new file mode 100644 index 000000000..a7725fdee --- /dev/null +++ b/web/Areas/Personnel/Models/AugmentedViperPerson.cs @@ -0,0 +1,22 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Class for combining ViperPerson and PhonePerson results for name searches. + /// + public class AugmentedViperPerson + { + public int PersonId { get; set; } + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; + public string IamId { get; set; } = string.Empty; + public required bool CurrentEmployee { get; set; } + public string MailId { get; set; } = string.Empty; + public PhonePerson? PhoneData { get; set; } + + public void AddPhoneData(PhonePerson phonePerson) + { + this.PhoneData = phonePerson; + } + } +} diff --git a/web/Areas/Personnel/Models/PersonnelMapper.cs b/web/Areas/Personnel/Models/PersonnelMapper.cs new file mode 100644 index 000000000..71797c45b --- /dev/null +++ b/web/Areas/Personnel/Models/PersonnelMapper.cs @@ -0,0 +1,13 @@ +using Riok.Mapperly.Abstractions; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Mapperly mapper to create an AugmentedViperPerson from a ViperPerson. + /// + [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.None)] + public static partial class PersonnelMapper + { + public static partial AugmentedViperPerson ToAugmentedViperPerson(ViperPerson source); + } +} diff --git a/web/Areas/Personnel/Models/PhoneList.cs b/web/Areas/Personnel/Models/PhoneList.cs new file mode 100644 index 000000000..f56a35a8f --- /dev/null +++ b/web/Areas/Personnel/Models/PhoneList.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.PhoneList, + /// a (typically unit/department-level) grouping for phone numbers. + /// + public class PhoneList + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int PhoneListId { get; set; } + + /** + * Stable lookup key used in routes and API paths (e.g. "VMDO"). + * Allows changing Name without breaking links. + */ + public required string Code { get; set; } + public required string Name { get; set; } + public required string MaintainRole { get; set; } + + public virtual ICollection PhoneListUnits { get; set; } = []; + } +} diff --git a/web/Areas/Personnel/Models/PhoneListInfo.cs b/web/Areas/Personnel/Models/PhoneListInfo.cs new file mode 100644 index 000000000..18b30abad --- /dev/null +++ b/web/Areas/Personnel/Models/PhoneListInfo.cs @@ -0,0 +1,16 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// What a client needs to render a phone list before it fetches any rows: the display name + /// plus the caller's own capabilities. Allows the client to render correctly + /// based on the permissions that will be enforced on the back end. + /// + public class PhoneListInfo + { + public int PhoneListId { get; set; } + public required string Code { get; set; } + public required string Name { get; set; } + public bool CanMaintain { get; set; } + public bool CanViewDirectPhone { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/PhoneListUnit.cs b/web/Areas/Personnel/Models/PhoneListUnit.cs new file mode 100644 index 000000000..ff0b73b9f --- /dev/null +++ b/web/Areas/Personnel/Models/PhoneListUnit.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.PhoneListUnit, + /// a grouping within a phone list. + /// + public class PhoneListUnit + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int PhoneListUnitId { get; set; } + public required int PhoneListId { get; set; } + public required string Name { get; set; } + public int? SortOrder { get; set; } + + public virtual PhoneList PhoneList { get; set; } = null!; + public virtual ICollection PhoneListUnitPersons { get; set; } = []; + } +} diff --git a/web/Areas/Personnel/Models/PhoneListUnitDataRequests.cs b/web/Areas/Personnel/Models/PhoneListUnitDataRequests.cs new file mode 100644 index 000000000..1ac8a0fd6 --- /dev/null +++ b/web/Areas/Personnel/Models/PhoneListUnitDataRequests.cs @@ -0,0 +1,15 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Class for form data to create or edit a row in unit-specific phone list tables. + /// + public class PhoneListUnitDataRequest + { + public required int UnitId { get; set; } + public string Office { get; set; } = ""; + public required string EmployeeIam { get; set; } + public string Phone { get; set; } = ""; + public string DirectPhone { get; set; } = ""; + public bool ListFirst { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/PhoneListUnitPerson.cs b/web/Areas/Personnel/Models/PhoneListUnitPerson.cs new file mode 100644 index 000000000..133b30d00 --- /dev/null +++ b/web/Areas/Personnel/Models/PhoneListUnitPerson.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.PhoneListUnitPerson, + /// connecting people to a given unit for a phone list. + /// + public class PhoneListUnitPerson + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int PhoneListUnitPersonId { get; set; } + public required int PhoneListUnitId { get; set; } + public required string PersonIam { get; set; } + public required bool ListFirst { get; set; } + public required bool IsActive { get; set; } + public string? ModifiedBy { get; set; } + public DateTime? ModifiedDate { get; set; } + + public virtual PhoneListUnit PhoneListUnit { get; set; } = null!; + public virtual PhonePerson Person { get; set; } = null!; + public virtual ViperPerson? ViperModPerson { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/PhonePerson.cs b/web/Areas/Personnel/Models/PhonePerson.cs new file mode 100644 index 000000000..73c182cc4 --- /dev/null +++ b/web/Areas/Personnel/Models/PhonePerson.cs @@ -0,0 +1,21 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents data from phones.Person. + /// Ties a person to phone number and office data. + /// + public class PhonePerson + { + public required string PersonIam { get; set; } + public string? Phone { get; set; } + public string? DirectPhone { get; set; } + public string? Office { get; set; } + public DateTime? ModifiedDate { get; set; } + public string? ModifiedBy { get; set; } + + public virtual ICollection UnitPersons { get; set; } = []; + public virtual ICollection PhoneListUnitPersons { get; set; } = []; + public virtual ViperPerson? ViperPerson { get; set; } + public virtual ViperPerson? ViperModPerson { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/SVMFrequentNumber.cs b/web/Areas/Personnel/Models/SVMFrequentNumber.cs new file mode 100644 index 000000000..0e1da9e46 --- /dev/null +++ b/web/Areas/Personnel/Models/SVMFrequentNumber.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents data from phones.SVMFrequentNumber. + /// Provides a spot for additional important phone + /// numbers not tied to a specific person. + /// + public class SVMFrequentNumber + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int NumberId { get; set; } + public required string Label { get; set; } + public required string Phone { get; set; } + public int? SortOrder { get; set; } + public string? ModifiedBy { get; set; } + public DateTime? ModifiedDate { get; set; } + public bool IsActive { get; set; } + public virtual ViperPerson? ViperModPerson { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/SVMFrequentNumberRequests.cs b/web/Areas/Personnel/Models/SVMFrequentNumberRequests.cs new file mode 100644 index 000000000..89a365e84 --- /dev/null +++ b/web/Areas/Personnel/Models/SVMFrequentNumberRequests.cs @@ -0,0 +1,11 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Class for form data to create or edit a row in the SVM frequently called numbers table. + /// + public class SVMFrequentNumberRequest + { + public required string Label { get; set; } = ""; + public required string Phone { get; set; } = ""; + } +} diff --git a/web/Areas/Personnel/Models/SVMSection.cs b/web/Areas/Personnel/Models/SVMSection.cs new file mode 100644 index 000000000..4901b625b --- /dev/null +++ b/web/Areas/Personnel/Models/SVMSection.cs @@ -0,0 +1,18 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.SVMSection, + /// a grouping for the SVM Phone List. + /// + public class SVMSection + { + public required int SectionId { get; set; } + public string? Name { get; set; } + public bool? IncludeAbbrv { get; set; } + public string? UnitName { get; set; } + public string? DirectorTitle { get; set; } + public int? SortOrder { get; set; } + + public virtual ICollection Units { get; set; } = []; + } +} diff --git a/web/Areas/Personnel/Models/SVMUnit.cs b/web/Areas/Personnel/Models/SVMUnit.cs new file mode 100644 index 000000000..77459a484 --- /dev/null +++ b/web/Areas/Personnel/Models/SVMUnit.cs @@ -0,0 +1,22 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.SVMUnit, + /// a department, unit, or dean's office. + /// + public class SVMUnit + { + public required int UnitId { get; set; } + public required int SectionId { get; set; } + public string? Name { get; set; } + public string? Abbrv { get; set; } + public int? SortOrder { get; set; } + public string? Fax { get; set; } + public string? ModifiedBy { get; set; } + public DateTime? ModifiedDate { get; set; } + + public virtual SVMSection Section { get; set; } = null!; + public virtual ICollection UnitPersons { get; set; } = []; + public virtual ViperPerson? ViperModPerson { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/SVMUnitDataRequests.cs b/web/Areas/Personnel/Models/SVMUnitDataRequests.cs new file mode 100644 index 000000000..d6e64cf08 --- /dev/null +++ b/web/Areas/Personnel/Models/SVMUnitDataRequests.cs @@ -0,0 +1,19 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Class for form data to create or edit a row in the SVM table. + /// + public class SVMUnitDataRequest + { + public string Fax { get; set; } = ""; + public string Location { get; set; } = ""; + public string DeanIam { get; set; } = ""; + public string DeanPhone { get; set; } = ""; + public string DeanInterim { get; set; } = ""; + public int DeanUnitPerson { get; set; } = -1; + public string StaffIam { get; set; } = ""; + public string StaffPhone { get; set; } = ""; + public string StaffInterim { get; set; } = ""; + public int StaffUnitPerson { get; set; } = -1; + } +} diff --git a/web/Areas/Personnel/Models/SVMUnitPerson.cs b/web/Areas/Personnel/Models/SVMUnitPerson.cs new file mode 100644 index 000000000..fa5c08863 --- /dev/null +++ b/web/Areas/Personnel/Models/SVMUnitPerson.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.SVMUnitPerson, + /// connecting people in leadership and admin roles to a + /// given unit. + /// + public class SVMUnitPerson + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int UnitPersonId { get; set; } + public required int UnitId { get; set; } + public required string PersonIam { get; set; } + public string? Office { get; set; } + public string? PosType { get; set; } + public string? Interim { get; set; } + public DateTime? ModifiedDate { get; set; } + public string? ModifiedBy { get; set; } + public bool IsActive { get; set; } + + public virtual SVMUnit Unit { get; set; } = null!; + public virtual PhonePerson Person { get; set; } = null!; + public virtual ViperPerson? ViperModPerson { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/ViperPerson.cs b/web/Areas/Personnel/Models/ViperPerson.cs new file mode 100644 index 000000000..39c11f64a --- /dev/null +++ b/web/Areas/Personnel/Models/ViperPerson.cs @@ -0,0 +1,17 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Read-only entity for accessing users.Person table within PhonesDbContext. + /// Used for joining to get employee names without cross-context queries. + /// + public class ViperPerson + { + public int PersonId { get; set; } + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; + public required string IamId { get; set; } + public required bool CurrentEmployee { get; set; } + public string MailId { get; set; } = string.Empty; + } +} diff --git a/web/Areas/Personnel/PhonesDbContext.cs b/web/Areas/Personnel/PhonesDbContext.cs new file mode 100644 index 000000000..ee695b077 --- /dev/null +++ b/web/Areas/Personnel/PhonesDbContext.cs @@ -0,0 +1,216 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel.Models; + +namespace Viper.Areas.Personnel; + +/// +/// Entity Framework DbContext for the Personnel phone numbers system. +/// All tables are in the [phones] schema in the VIPER database. +/// +public class PhonesDbContext : DbContext +{ + public PhonesDbContext(DbContextOptions options) : base(options) + { + } + + // Core data tables + public virtual DbSet PhonePerson { get; set; } + public virtual DbSet SVMSection { get; set; } + public virtual DbSet SVMUnit { get; set; } + public virtual DbSet SVMUnitPerson { get; set; } + public virtual DbSet SVMFrequentNumber { get; set; } + public virtual DbSet PhoneList { get; set; } + public virtual DbSet PhoneListUnit { get; set; } + public virtual DbSet PhoneListUnitPerson { get; set; } + + // Read-only cross-schema reference (users schema in same database) + public virtual DbSet ViperPerson { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // PhonePerson (phones.Person) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.PersonIam); + entity.ToTable("Person", schema: "phones"); + + entity.Property(e => e.PersonIam).HasColumnName("PersonIam"); + entity.Property(e => e.Phone).HasColumnName("Phone").HasMaxLength(25); + entity.Property(e => e.DirectPhone).HasColumnName("DirectPhone").HasMaxLength(25); + entity.Property(e => e.Office).HasColumnName("Office").HasMaxLength(100); + entity.Property(e => e.ModifiedDate).HasColumnName("ModifiedDate"); + entity.Property(e => e.ModifiedBy).HasColumnName("ModifiedBy"); + + // Cross-schema FK to users.Person + entity.HasOne(e => e.ViperPerson) + .WithMany() + .HasForeignKey(e => e.PersonIam) + .HasPrincipalKey(e => e.IamId) + .IsRequired(); + + entity.HasOne(e => e.ViperModPerson) + .WithMany() + .HasForeignKey(e => e.ModifiedBy) + .HasPrincipalKey(e => e.IamId); + }); + + // SVMSection (phones.SVMSection) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.SectionId); + entity.ToTable("SVMSection", schema: "phones"); + + entity.Property(e => e.SectionId).HasColumnName("SectionId"); + entity.Property(e => e.Name).HasColumnName("Name").HasMaxLength(100); + entity.Property(e => e.IncludeAbbrv).HasColumnName("IncludeAbbrv"); + entity.Property(e => e.UnitName).HasColumnName("UnitName").HasMaxLength(50); + entity.Property(e => e.DirectorTitle).HasColumnName("DirectorTitle").HasMaxLength(50); + entity.Property(e => e.SortOrder).HasColumnName("SortOrder"); + }); + + // SVMUnit (phones.SVMUnit) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.UnitId); + entity.ToTable("SVMUnit", schema: "phones"); + + entity.Property(e => e.UnitId).HasColumnName("UnitId"); + entity.Property(e => e.SectionId).HasColumnName("SectionId"); + entity.Property(e => e.Name).HasColumnName("Name").HasMaxLength(100); + entity.Property(e => e.Fax).HasColumnName("Fax").HasMaxLength(25); + entity.Property(e => e.Abbrv).HasColumnName("Abbrv").HasMaxLength(20); + entity.Property(e => e.SortOrder).HasColumnName("SortOrder"); + entity.Property(e => e.ModifiedDate).HasColumnName("ModifiedDate"); + entity.Property(e => e.ModifiedBy).HasColumnName("ModifiedBy").HasMaxLength(10); + + entity.HasOne(e => e.Section) + .WithMany(s => s.Units) + .HasForeignKey(e => e.SectionId); + + entity.HasOne(e => e.ViperModPerson) + .WithMany() + .HasForeignKey(e => e.ModifiedBy) + .HasPrincipalKey(e => e.IamId); + }); + + // SVMUnitPerson (phones.SVMUnitPerson) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.UnitPersonId); + entity.ToTable("SVMUnitPerson", schema: "phones"); + + entity.Property(e => e.UnitPersonId).HasColumnName("UnitPersonId"); + entity.Property(e => e.UnitId).HasColumnName("UnitId"); + entity.Property(e => e.PersonIam).HasColumnName("PersonIam").HasMaxLength(10); + entity.Property(e => e.Office).HasColumnName("Office").HasMaxLength(50); + entity.Property(e => e.PosType).HasColumnName("PosType").HasMaxLength(25); + entity.Property(e => e.Interim).HasColumnName("Interim").HasMaxLength(10); + entity.Property(e => e.ModifiedDate).HasColumnName("ModifiedDate"); + entity.Property(e => e.ModifiedBy).HasColumnName("ModifiedBy").HasMaxLength(10); + entity.Property(e => e.IsActive).HasColumnName("IsActive"); + + entity.HasOne(e => e.Unit) + .WithMany(s => s.UnitPersons) + .HasForeignKey(e => e.UnitId); + + entity.HasOne(e => e.Person) + .WithMany(s => s.UnitPersons) + .HasForeignKey(e => e.PersonIam); + + entity.HasOne(e => e.ViperModPerson) + .WithMany() + .HasForeignKey(e => e.ModifiedBy) + .HasPrincipalKey(e => e.IamId); + }); + + // SVMFrequentNumber (phones.SVMFrequentNumber) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.NumberId); + entity.ToTable("SVMFrequentNumber", schema: "phones"); + + entity.Property(e => e.NumberId).HasColumnName("NumberId"); + entity.Property(e => e.Label).HasColumnName("Label").HasMaxLength(100); + entity.Property(e => e.Phone).HasColumnName("Phone").HasMaxLength(25); + entity.Property(e => e.SortOrder).HasColumnName("SortOrder"); + entity.Property(e => e.ModifiedDate).HasColumnName("ModifiedDate"); + entity.Property(e => e.ModifiedBy).HasColumnName("ModifiedBy").HasMaxLength(10); + entity.Property(e => e.IsActive).HasColumnName("IsActive"); + + entity.HasOne(e => e.ViperModPerson) + .WithMany() + .HasForeignKey(e => e.ModifiedBy) + .HasPrincipalKey(e => e.IamId); + }); + + // PhoneList (phones.PhoneList) + modelBuilder.Entity
- {{ form.upload?.name }} already exists in {{ form.folder }}{{ conflictDetail }}. Choose how to continue: -
+ {{ form.upload?.name }} already exists in {{ form.folder }}{{ conflictDetail }}. Choose how to continue: +
['"]).*?\k|[^;\n]*)/u, + ) + if (filenameMatch?.groups?.filename) { + filename = filenameMatch.groups.filename.replaceAll(/['"]/gu, "") } } @@ -303,3 +305,4 @@ function downloadBlob(blob: Blob, filename: string): void { } export { useFetch, postForBlob, downloadBlob, HTTP_STATUS } +export type { Result, Pagination } diff --git a/VueApp/src/composables/__tests__/use-person-search.test.ts b/VueApp/src/composables/__tests__/use-person-search.test.ts new file mode 100644 index 000000000..9c8d790cc --- /dev/null +++ b/VueApp/src/composables/__tests__/use-person-search.test.ts @@ -0,0 +1,82 @@ +import { usePersonSearch } from "../use-person-search" + +type Person = { iamId: string; fullName: string } + +type Deferred = { promise: Promise; resolve: (value: T) => void } + +// A controllable pending promise, for simulating a search that hasn't resolved yet. The `as` +// cast lets `resolve` be filled in by the executor without a separate uninitialized declaration. +function createDeferred(): Deferred { + const deferred = {} as Deferred + // eslint-disable-next-line avoid-new -- a controllable pending promise is the point of this helper + deferred.promise = new Promise((resolve) => { + deferred.resolve = resolve + }) + return deferred +} + +function applyUpdate(fn: () => void): void { + fn() +} + +function runFilter(searchPeople: (val: string, update: (fn: () => void) => void) => Promise, val: string) { + return searchPeople(val, applyUpdate) +} + +describe("usePersonSearch()", () => { + it("clears options without calling search when the term is below two characters", async () => { + expect.hasAssertions() + const search = vi.fn<(value: string) => Promise>() + const { searchPeople, options, loading } = usePersonSearch(search) + options.value = [{ iamId: "a", fullName: "Existing Person" }] + + await runFilter(searchPeople, "a") + + expect(search).not.toHaveBeenCalled() + expect(options.value).toStrictEqual([]) + expect(loading.value).toBeFalsy() + }) + + it("sets loading and populates options for a valid search", async () => { + expect.hasAssertions() + const results: Person[] = [{ iamId: "person01", fullName: "Amy Smith" }] + const search = vi.fn<(value: string) => Promise>().mockResolvedValue(results) + const { searchPeople, options } = usePersonSearch(search) + + await runFilter(searchPeople, " ab ") + + expect(search).toHaveBeenCalledWith("ab") + expect(options.value).toStrictEqual(results) + }) + + it("falls back to an empty list when search resolves null", async () => { + expect.hasAssertions() + const search = vi.fn<(value: string) => Promise>().mockResolvedValue(null) + const { searchPeople, options } = usePersonSearch(search) + + await runFilter(searchPeople, "ab") + + expect(options.value).toStrictEqual([]) + }) + + it("discards a slower, earlier response that resolves after a newer search", async () => { + expect.hasAssertions() + const first = createDeferred() + const second = createDeferred() + const search = vi + .fn<(value: string) => Promise>() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + const { searchPeople, options } = usePersonSearch(search) + + const firstFilter = runFilter(searchPeople, "first") + const secondFilter = runFilter(searchPeople, "second") + + second.resolve([{ iamId: "second", fullName: "Second Result" }]) + await secondFilter + first.resolve([{ iamId: "first", fullName: "First Result" }]) + await firstFilter + + expect(options.value).toStrictEqual([{ iamId: "second", fullName: "Second Result" }]) + }) +}) diff --git a/VueApp/src/composables/use-person-search.ts b/VueApp/src/composables/use-person-search.ts new file mode 100644 index 000000000..56634b78c --- /dev/null +++ b/VueApp/src/composables/use-person-search.ts @@ -0,0 +1,41 @@ +import { ref } from "vue" + +/** + * Debounced, out-of-order-safe server search for a QSelect's @filter handler. Shared by every + * PersonSelector variant (CMS, Personnel): the search/race-guard logic is identical across them, + * only the search function and result type differ per caller. + */ +function usePersonSearch(search: (value: string) => Promise) { + const options = ref([]) + const loading = ref(false) + // Guards against out-of-order responses: only the latest search may update options + let searchSeq = 0 + + async function searchPeople(val: string, update: (fn: () => void) => void) { + if (val.trim().length < 2) { + // Invalidate any in-flight search too, or its late response would repopulate + // the options we just cleared. + searchSeq += 1 + loading.value = false + update(() => { + options.value = [] + }) + return + } + searchSeq += 1 + const seq = searchSeq + loading.value = true + const result = await search(val.trim()) + if (seq !== searchSeq) { + return + } + loading.value = false + update(() => { + options.value = result ?? [] + }) + } + + return { options, loading, searchPeople } +} + +export { usePersonSearch } diff --git a/VueApp/vueapp.esproj b/VueApp/vueapp.esproj index 8513a0c6b..1c514331e 100644 --- a/VueApp/vueapp.esproj +++ b/VueApp/vueapp.esproj @@ -18,7 +18,9 @@ + + - \ No newline at end of file + diff --git a/test/Classes/Utilities/PersonSearchHelperTests.cs b/test/Classes/Utilities/PersonSearchHelperTests.cs new file mode 100644 index 000000000..3eaaa3971 --- /dev/null +++ b/test/Classes/Utilities/PersonSearchHelperTests.cs @@ -0,0 +1,137 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Classes.Utilities; + +namespace Viper.test.Classes.Utilities; + +/// +/// Tests for PersonSearchHelper, the shared "search current people by partial name" query shape +/// used by both the CMS file/permission pickers and the Personnel phone directory. A regression +/// here affects every autocomplete built on it. +/// +public class PersonSearchHelperTests +{ + private sealed class Person + { + public required string LastName { get; set; } + public required string FirstName { get; set; } + public string LoginId { get; set; } = ""; + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("a")] + [InlineData(" a ")] + public void Normalize_ReturnsNull_WhenBelowMinimumLength(string? search) + { + Assert.Null(PersonSearchHelper.Normalize(search)); + } + + [Fact] + public void Normalize_ReturnsTrimmedValue_WhenAtOrAboveMinimumLength() + { + var result = PersonSearchHelper.Normalize(" ab "); + + Assert.Equal("ab", result); + } + + [Fact] + public void NameMatches_MatchesLastCommaFirstForm() + { + var people = new[] { new Person { LastName = "Smith", FirstName = "Amy" } }.AsQueryable(); + var predicate = PersonSearchHelper.NameMatches(p => p.LastName, p => p.FirstName, "mith, A"); + + var results = people.Where(predicate).ToList(); + + Assert.Single(results); + } + + [Fact] + public void NameMatches_MatchesFirstSpaceLastForm() + { + var people = new[] { new Person { LastName = "Smith", FirstName = "Amy" } }.AsQueryable(); + var predicate = PersonSearchHelper.NameMatches(p => p.LastName, p => p.FirstName, "Amy Sm"); + + var results = people.Where(predicate).ToList(); + + Assert.Single(results); + } + + [Fact] + public void NameMatches_ExcludesNonMatchingPeople() + { + var people = new[] + { + new Person { LastName = "Smith", FirstName = "Amy" }, + new Person { LastName = "Jones", FirstName = "Bob" }, + }.AsQueryable(); + var predicate = PersonSearchHelper.NameMatches(p => p.LastName, p => p.FirstName, "Smith"); + + var results = people.Where(predicate).ToList(); + + var match = Assert.Single(results); + Assert.Equal("Smith", match.LastName); + } + + [Fact] + public void OrderAndCap_OrdersByLastNameThenFirstName_AndCapsToMaxResults() + { + var people = Enumerable.Range(0, 30) + .Select(i => new Person { LastName = $"Person{i:D2}", FirstName = "X" }) + .Reverse() + .AsQueryable(); + + var results = PersonSearchHelper.OrderAndCap(people, p => p.LastName, p => p.FirstName).ToList(); + + Assert.Equal(PersonSearchHelper.MaxResults, results.Count); + Assert.Equal("Person00", results[0].LastName); + Assert.Equal("Person24", results[^1].LastName); + } + + [Fact] + public void Or_IncludesRecordsMatchingEitherPredicate() + { + var people = new[] + { + new Person { LastName = "Smith", FirstName = "Amy", LoginId = "asmith" }, + new Person { LastName = "Jones", FirstName = "Bob", LoginId = "bjones" }, + }.AsQueryable(); + var namePredicate = PersonSearchHelper.NameMatches(p => p.LastName, p => p.FirstName, "Smith"); + var combined = namePredicate.Or(p => p.LoginId == "bjones"); + + var results = people.Where(combined).ToList(); + + Assert.Equal(2, results.Count); + } + + private sealed class SearchTestContext(DbContextOptions options) : DbContext(options) + { + public DbSet People => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity().HasKey(p => p.LastName); + } + + [Fact] + public void NameMatches_EmitsASqlParameter_RatherThanALiteral() + { + // These autocompletes fire per keystroke, so a term embedded as a literal would give every + // distinct search its own query plan. The literal form also drops the ESCAPE clause, which + // is what stops a typed % or _ from being treated as a wildcard. + var options = new DbContextOptionsBuilder() + .UseSqlServer("Server=none;Database=none;Trusted_Connection=True;") + .Options; + using var context = new SearchTestContext(options); + + var sql = context.People + .Where(PersonSearchHelper.NameMatches(p => p.LastName, p => p.FirstName, "smith")) + .ToQueryString(); + + // Assert on the predicate, not the whole string: ToQueryString prefixes a DECLARE that + // spells the value out for copy-paste even when the query itself is parameterized. + Assert.Contains("LIKE @", sql, StringComparison.Ordinal); + Assert.DoesNotContain("LIKE N'", sql, StringComparison.Ordinal); + Assert.Contains("ESCAPE", sql, StringComparison.Ordinal); + } +} diff --git a/test/Personnel/PhoneListControllerTests.cs b/test/Personnel/PhoneListControllerTests.cs new file mode 100644 index 000000000..dfeeb5e00 --- /dev/null +++ b/test/Personnel/PhoneListControllerTests.cs @@ -0,0 +1,165 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneListController. GetListInfo is what the client renders from before +/// it fetches any rows, so the two capability flags it reports have to match what the write and +/// read endpoints actually enforce: CanMaintain follows the list's own MaintainRole, and +/// CanViewDirectPhone is deliberately broader - a member of the list sees direct numbers without +/// being able to edit it. A flag that overstated either would show the client controls the API +/// then refuses. +/// +public sealed class PhoneListControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhoneListController _controller; + + private const string CallerIam = "caller01"; + private const string VmdoRole = "SVMSecure.PhoneLists.VMDOMaintain"; + + public PhoneListControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + var rapsContext = Substitute.For(); + var permissionsService = new PhonePermissionsService(rapsContext, _userHelper); + var phoneListService = new PhoneListService(_context); + var unitService = new PhoneListUnitService(_context, _userHelper, permissionsService); + + _controller = new PhoneListController(phoneListService, unitService, permissionsService); + + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 1, + Code = "VMDO", + Name = "Dean's Office", + MaintainRole = VmdoRole, + }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 1, PhoneListId = 1, Name = "Front Office" }); + _context.PhonePerson.Add(new PhonePerson { PersonIam = CallerIam, Phone = "530-555-1000" }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + private void GrantRole(string role) + { + _userHelper.HasPermission(Arg.Any(), Arg.Any(), role).Returns(true); + } + + /// Puts the caller on the list itself, which is not the same as maintaining it. + private void AddCallerToList() + { + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitPersonId = 1, + PhoneListUnitId = 1, + PersonIam = CallerIam, + ListFirst = false, + IsActive = true, + }); + _context.SaveChanges(); + } + + private async Task GetInfo(string code) + { + var result = await _controller.GetListInfo(code, TestContext.Current.CancellationToken); + var okResult = Assert.IsType(result.Result); + return Assert.IsType(okResult.Value); + } + + [Fact] + public async Task GetListInfo_ReturnsTheListIdentity() + { + var info = await GetInfo("VMDO"); + + Assert.Equal(1, info.PhoneListId); + Assert.Equal("VMDO", info.Code); + Assert.Equal("Dean's Office", info.Name); + } + + [Fact] + public async Task GetListInfo_ReportsNoCapabilities_ForACallerWithNeitherRoleNorMembership() + { + var info = await GetInfo("VMDO"); + + Assert.False(info.CanMaintain); + Assert.False(info.CanViewDirectPhone); + } + + [Fact] + public async Task GetListInfo_ReportsBothCapabilities_ForAMaintainer() + { + GrantRole(VmdoRole); + + var info = await GetInfo("VMDO"); + + Assert.True(info.CanMaintain); + Assert.True(info.CanViewDirectPhone); + } + + [Fact] + public async Task GetListInfo_ReportsDirectPhoneOnly_ForAMemberWhoCannotMaintain() + { + // Membership is what grants the direct-number view, so the two flags have to move + // independently: reporting CanMaintain here would offer edit controls the API refuses. + AddCallerToList(); + + var info = await GetInfo("VMDO"); + + Assert.False(info.CanMaintain); + Assert.True(info.CanViewDirectPhone); + } + + [Fact] + public async Task GetListInfo_IgnoresMembershipOfAnotherList() + { + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 2, + Code = "OTHER", + Name = "Some Other Unit", + MaintainRole = "SVMSecure.PhoneLists.OtherMaintain", + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + AddCallerToList(); + + var info = await GetInfo("OTHER"); + + Assert.False(info.CanViewDirectPhone); + } + + [Fact] + public async Task GetListInfo_ReturnsNotFound_ForAnUnknownCode() + { + var result = await _controller.GetListInfo("NOPE", TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } +} diff --git a/test/Personnel/PhoneListModifiedDateControllerTests.cs b/test/Personnel/PhoneListModifiedDateControllerTests.cs new file mode 100644 index 000000000..f97e34392 --- /dev/null +++ b/test/Personnel/PhoneListModifiedDateControllerTests.cs @@ -0,0 +1,140 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneListModifiedDateController, the endpoint clients poll to decide +/// whether their cached copy of a list is stale. Two properties matter: deleted rows still count +/// (a removal is a change the client has to pick up, and soft-deleted rows are the only record of +/// it), and the date is scoped to the list named in the route. +/// +public sealed class PhoneListModifiedDateControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneListModifiedDateController _controller; + + private static readonly DateTime Older = new(2026, 1, 1, 9, 0, 0, DateTimeKind.Local); + private static readonly DateTime Newer = new(2026, 6, 1, 9, 0, 0, DateTimeKind.Local); + + public PhoneListModifiedDateControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + var userHelper = Substitute.For(); + userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = "caller01", + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = "caller01", + }); + + var rapsContext = Substitute.For(); + var permissionsService = new PhonePermissionsService(rapsContext, userHelper); + var phoneListService = new PhoneListService(_context); + var unitService = new PhoneListUnitService(_context, userHelper, permissionsService); + + _controller = new PhoneListModifiedDateController(phoneListService, unitService); + + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 1, + Code = "VMDO", + Name = "Dean's Office", + MaintainRole = "SVMSecure.PhoneLists.VMDOMaintain", + }); + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 2, + Code = "OTHER", + Name = "Some Other Unit", + MaintainRole = "SVMSecure.PhoneLists.OtherMaintain", + }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 1, PhoneListId = 1, Name = "Front Office" }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 2, PhoneListId = 2, Name = "Other Office" }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + private void AddRow(int unitPersonId, int unitId, DateTime? modifiedDate, bool isActive = true) + { + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitPersonId = unitPersonId, + PhoneListUnitId = unitId, + PersonIam = "person01", + ListFirst = false, + IsActive = isActive, + ModifiedDate = modifiedDate, + }); + _context.SaveChanges(); + } + + private async Task GetDate(string code) + { + var result = await _controller.GetLastModifiedDate(code, TestContext.Current.CancellationToken); + var okResult = Assert.IsType(result.Result); + return (DateTime?)okResult.Value; + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsTheMostRecentDate() + { + AddRow(unitPersonId: 1, unitId: 1, Older); + AddRow(unitPersonId: 2, unitId: 1, Newer); + + Assert.Equal(Newer, await GetDate("VMDO")); + } + + [Fact] + public async Task GetLastModifiedDate_CountsDeletedRows() + { + // A removal is the change most likely to matter to a client holding stale rows, and the + // soft-deleted row is the only record that it happened. + AddRow(unitPersonId: 1, unitId: 1, Older); + AddRow(unitPersonId: 2, unitId: 1, Newer, isActive: false); + + Assert.Equal(Newer, await GetDate("VMDO")); + } + + [Fact] + public async Task GetLastModifiedDate_IgnoresAnotherListsRows() + { + AddRow(unitPersonId: 1, unitId: 1, Older); + AddRow(unitPersonId: 2, unitId: 2, Newer); + + Assert.Equal(Older, await GetDate("VMDO")); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsNull_WhenNothingHasBeenModified() + { + AddRow(unitPersonId: 1, unitId: 1, modifiedDate: null); + + Assert.Null(await GetDate("VMDO")); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsNotFound_ForAnUnknownCode() + { + var result = await _controller.GetLastModifiedDate("NOPE", TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } +} diff --git a/test/Personnel/PhoneListServiceTests.cs b/test/Personnel/PhoneListServiceTests.cs new file mode 100644 index 000000000..0401a9277 --- /dev/null +++ b/test/Personnel/PhoneListServiceTests.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhoneListService, the entry point every list-scoped request resolves a list +/// through. Lookup is by Code rather than Name so that renaming a list for display cannot +/// break the routes and API paths that address it. +/// +public sealed class PhoneListServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneListService _service; + + public PhoneListServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + _service = new PhoneListService(_context); + } + + public void Dispose() => _context.Dispose(); + + private void SeedLists() + { + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 1, + Code = "VMDO", + Name = "Dean's Office", + MaintainRole = "SVMSecure.PhoneLists.VMDOMaintain", + }); + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 2, + Code = "OTHER", + Name = "Some Other Unit", + MaintainRole = "SVMSecure.PhoneLists.OtherMaintain", + }); + _context.SaveChanges(); + } + + [Fact] + public async Task GetListByCode_ReturnsMatchingList() + { + SeedLists(); + + var result = await _service.GetListByCode("OTHER", TestContext.Current.CancellationToken); + + Assert.Equal(2, result.PhoneListId); + Assert.Equal("Some Other Unit", result.Name); + } + + [Fact] + public async Task GetListByCode_ResolvesIndependentlyOfDisplayName() + { + SeedLists(); + var list = await _context.PhoneList.SingleAsync(l => l.Code == "VMDO", TestContext.Current.CancellationToken); + list.Name = "Office of the Dean"; + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _service.GetListByCode("VMDO", TestContext.Current.CancellationToken); + + Assert.Equal(1, result.PhoneListId); + } + + [Fact] + public async Task GetListByCode_Throws_WhenCodeNotFound() + { + await Assert.ThrowsAsync( + () => _service.GetListByCode("NOPE", TestContext.Current.CancellationToken)); + } +} diff --git a/test/Personnel/PhoneListUnitControllerTests.cs b/test/Personnel/PhoneListUnitControllerTests.cs new file mode 100644 index 000000000..cea934723 --- /dev/null +++ b/test/Personnel/PhoneListUnitControllerTests.cs @@ -0,0 +1,234 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneListUnitController. Beyond the InvalidOperationException-to-400 +/// mapping, these cover the authorization model: write access is the role named by the target +/// list's own MaintainRole column, so holding one list's role must grant nothing on another, +/// and a record id from one list must not be reachable through another list's route. +/// +public sealed class PhoneListUnitControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhoneListUnitController _controller; + + private const string CallerIam = "caller01"; + private const string VmdoRole = "SVMSecure.PhoneLists.VMDOMaintain"; + private const string OtherRole = "SVMSecure.PhoneLists.OtherMaintain"; + + public PhoneListUnitControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + var rapsContext = Substitute.For(); + var permissionsService = new PhonePermissionsService(rapsContext, _userHelper); + var phoneListService = new PhoneListService(_context); + var unitService = new PhoneListUnitService(_context, _userHelper, permissionsService); + + _controller = new PhoneListUnitController(phoneListService, unitService, permissionsService); + + // Two lists, each with its own unit and its own maintain role. + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 1, + Code = "VMDO", + Name = "Dean's Office", + MaintainRole = VmdoRole, + }); + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 2, + Code = "OTHER", + Name = "Some Other Unit", + MaintainRole = OtherRole, + }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 1, PhoneListId = 1, Name = "Front Office" }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 2, PhoneListId = 2, Name = "Other Office" }); + _context.PhonePerson.Add(new PhonePerson { PersonIam = "person01", Phone = "530-555-1000" }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + /// Grants the caller exactly one maintain role. + private void GrantRole(string role) + { + _userHelper.HasPermission(Arg.Any(), Arg.Any(), role).Returns(true); + } + + private void AddUnitPersonRow(int unitPersonId, int unitId) + { + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitPersonId = unitPersonId, + PhoneListUnitId = unitId, + PersonIam = "person01", + ListFirst = false, + IsActive = true, + }); + _context.SaveChanges(); + } + + private static PhoneListUnitDataRequest Request(int unitId) => new() + { + UnitId = unitId, + EmployeeIam = "person01", + Phone = "530-555-1000", + DirectPhone = "530-555-2000", + Office = "Room 100", + }; + + [Fact] + public async Task GetUnits_ReturnsOk_WithUnitsForTheNamedList() + { + var result = await _controller.GetUnits("VMDO", TestContext.Current.CancellationToken); + + var okResult = Assert.IsType(result.Result); + var units = Assert.IsAssignableFrom>(okResult.Value); + Assert.Equal("Front Office", Assert.Single(units).Name); + } + + [Fact] + public async Task GetUnits_ReturnsNotFound_ForAnUnknownCode() + { + var result = await _controller.GetUnits("NOPE", TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task AddUnitPersonData_ReturnsOk_ForAMaintainerOfThatList() + { + GrantRole(VmdoRole); + + var result = await _controller.AddUnitPersonData("VMDO", Request(1), TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task AddUnitPersonData_IsForbidden_WithoutTheRoleForThatList() + { + var result = await _controller.AddUnitPersonData("VMDO", Request(1), TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task AddUnitPersonData_IsForbidden_ForAMaintainerOfADifferentList() + { + // Holding VMDOMaintain must not confer write access to the OTHER list, which is what a + // hard-coded permission attribute on the endpoint would have allowed. + GrantRole(VmdoRole); + + var result = await _controller.AddUnitPersonData("OTHER", Request(2), TestContext.Current.CancellationToken); + + Assert.IsType(result); + Assert.Empty(await _context.PhoneListUnitPerson.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task AddUnitPersonData_ReturnsBadRequest_WhenTheUnitBelongsToAnotherList() + { + // Unit 2 is on the OTHER list; routing through VMDO must not reach it. + GrantRole(VmdoRole); + + var result = await _controller.AddUnitPersonData("VMDO", Request(2), TestContext.Current.CancellationToken); + + Assert.IsType(result); + Assert.Empty(await _context.PhoneListUnitPerson.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task UpdateUnitPersonData_ReturnsBadRequest_WhenNotFound() + { + GrantRole(VmdoRole); + + var result = await _controller.UpdateUnitPersonData("VMDO", 999, Request(1), TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task UpdateUnitPersonData_ReturnsBadRequest_WhenTheRecordBelongsToAnotherList() + { + AddUnitPersonRow(unitPersonId: 5, unitId: 2); + GrantRole(VmdoRole); + + var result = await _controller.UpdateUnitPersonData("VMDO", 5, Request(1), TestContext.Current.CancellationToken); + + Assert.IsType(result); + var untouched = await _context.PhoneListUnitPerson + .FindAsync(new object?[] { 5 }, TestContext.Current.CancellationToken); + Assert.NotNull(untouched); + Assert.True(untouched.IsActive); + } + + [Fact] + public async Task DeleteUnitPersonData_ReturnsBadRequest_WhenNotFound() + { + GrantRole(VmdoRole); + + var result = await _controller.DeleteUnitPersonData("VMDO", 999, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteUnitPersonData_ReturnsOk_AndSoftDeletes_WhenFound() + { + AddUnitPersonRow(unitPersonId: 1, unitId: 1); + GrantRole(VmdoRole); + + var result = await _controller.DeleteUnitPersonData("VMDO", 1, TestContext.Current.CancellationToken); + + Assert.IsType(result); + var stillExists = await _context.PhoneListUnitPerson + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(stillExists); + Assert.False(stillExists.IsActive); + } + + [Fact] + public async Task DeleteUnitPersonData_LeavesTheRecordAlone_WhenItBelongsToAnotherList() + { + AddUnitPersonRow(unitPersonId: 5, unitId: 2); + GrantRole(VmdoRole); + + var result = await _controller.DeleteUnitPersonData("VMDO", 5, TestContext.Current.CancellationToken); + + Assert.IsType(result); + var untouched = await _context.PhoneListUnitPerson + .FindAsync(new object?[] { 5 }, TestContext.Current.CancellationToken); + Assert.NotNull(untouched); + Assert.True(untouched.IsActive); + } +} diff --git a/test/Personnel/PhoneListUnitServiceTests.cs b/test/Personnel/PhoneListUnitServiceTests.cs new file mode 100644 index 000000000..e7a89769d --- /dev/null +++ b/test/Personnel/PhoneListUnitServiceTests.cs @@ -0,0 +1,406 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhoneListUnitService, focused on the direct-phone visibility rule: +/// a caller may only see DirectPhone for a list if they hold the list's maintain +/// permission, OR they are themselves an active member of that list. +/// +public sealed class PhoneListUnitServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhoneListUnitService _service; + + private const string MaintainRole = "SVMSecure.PhoneLists.VMDOMaintain"; + private const string CallerIam = "caller01"; + + public PhoneListUnitServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + // rapsContext is unused when IUserHelper.HasPermission is mocked directly, + // so a bare substitute (no seeded roles) is sufficient here. + var rapsContext = Substitute.For(); + var permissionsService = new PhonePermissionsService(rapsContext, _userHelper); + + _service = new PhoneListUnitService(_context, _userHelper, permissionsService); + + SeedList(); + } + + public void Dispose() => _context.Dispose(); + + /// The seeded list, as the controller would hand it to the service. + private PhoneList TestList() => + _context.PhoneList.Single(l => l.PhoneListId == 1); + + private void AddUnit(int unitId, string name) + { + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = unitId, PhoneListId = 1, Name = name }); + _context.SaveChanges(); + } + + /// Puts a person on a unit, with the phone row the read paths expect them to have. + private void AddMember(int unitPersonId, int unitId, string personIam, bool listFirst) + { + _context.PhonePerson.Add(new PhonePerson { PersonIam = personIam, Phone = "530-555-0000" }); + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitPersonId = unitPersonId, + PhoneListUnitId = unitId, + PersonIam = personIam, + ListFirst = listFirst, + IsActive = true, + }); + _context.SaveChanges(); + } + + private async Task FindMember(int unitPersonId) => + await _context.PhoneListUnitPerson.FindAsync(new object?[] { unitPersonId }, TestContext.Current.CancellationToken); + + private void SeedList() + { + _context.PhoneList.Add(new PhoneList { PhoneListId = 1, Code = "VMDO", Name = "Dean's Office", MaintainRole = MaintainRole }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 1, PhoneListId = 1, Name = "Dean's Office" }); + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 1, + IamId = "listedperson", + FirstName = "Listed", + LastName = "Person", + FullName = "Listed Person", + CurrentEmployee = true, + }); + _context.PhonePerson.Add(new PhonePerson + { + PersonIam = "listedperson", + Phone = "530-555-1000", + DirectPhone = "530-555-2000", + Office = "Room 100", + }); + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitId = 1, + PersonIam = "listedperson", + ListFirst = false, + IsActive = true, + }); + _context.SaveChanges(); + } + + [Fact] + public async Task GetPhoneListUnits_MasksDirectPhone_WhenCallerHasNoAccess() + { + // Caller has neither the maintain permission nor a membership row on this list. + _userHelper.HasPermission(Arg.Any(), Arg.Any(), MaintainRole).Returns(false); + + var units = await _service.GetPhoneListUnits(TestList(), TestContext.Current.CancellationToken); + + var person = Assert.Single(Assert.Single(units).PhoneListUnitPersons); + Assert.Equal("530-555-1000", person.Person.Phone); + Assert.Equal("", person.Person.DirectPhone); + } + + [Fact] + public async Task GetPhoneListUnits_ShowsDirectPhone_WhenCallerIsListMember() + { + // No maintain permission, but the caller is themselves an active member of the list - + // membership alone should be enough to unlock direct numbers for that list. + _userHelper.HasPermission(Arg.Any(), Arg.Any(), MaintainRole).Returns(false); + _context.PhonePerson.Add(new PhonePerson { PersonIam = CallerIam, Phone = "", DirectPhone = "", Office = "" }); + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitId = 1, + PersonIam = CallerIam, + ListFirst = false, + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var units = await _service.GetPhoneListUnits(TestList(), TestContext.Current.CancellationToken); + + var person = Assert.Single(units.Single().PhoneListUnitPersons, p => p.PersonIam == "listedperson"); + Assert.Equal("530-555-2000", person.Person.DirectPhone); + } + + [Fact] + public async Task AddUnitPersonData_UnsetsPreviousListFirst_WhenNewPersonIsMarkedFirst() + { + var existingFirstPerson = Assert.Single(_context.PhoneListUnitPerson); + existingFirstPerson.ListFirst = true; + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 2, + IamId = "newperson", + FirstName = "New", + LastName = "Person", + FullName = "New Person", + CurrentEmployee = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = "newperson", + Phone = "530-555-5000", + DirectPhone = "530-555-6000", + Office = "Room 300", + ListFirst = true, + }; + + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + + Assert.False(existingFirstPerson.ListFirst); + var newPerson = await _context.PhoneListUnitPerson + .SingleAsync(p => p.PersonIam == "newperson", TestContext.Current.CancellationToken); + Assert.True(newPerson.ListFirst); + } + + [Fact] + public async Task AddUnitPersonData_CalledTwiceForSamePerson_UpsertsInsteadOfDuplicating() + { + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = "listedperson", + Phone = "530-555-9000", + DirectPhone = "530-555-9001", + Office = "Room 900", + ListFirst = false, + }; + + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + + var associations = await _context.PhoneListUnitPerson + .Where(p => p.PersonIam == "listedperson" && p.PhoneListUnitId == 1) + .ToListAsync(TestContext.Current.CancellationToken); + Assert.Single(associations); + + var phonePerson = await _context.PhonePerson + .SingleAsync(p => p.PersonIam == "listedperson", TestContext.Current.CancellationToken); + Assert.Equal("530-555-9000", phonePerson.Phone); + Assert.Equal("530-555-9001", phonePerson.DirectPhone); + Assert.Equal("Room 900", phonePerson.Office); + } + + [Fact] + public async Task AddUnitPersonData_TrimsWhitespace_ForANewPerson() + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 3, + IamId = "paddedperson", + FirstName = "Padded", + LastName = "Person", + FullName = "Padded Person", + CurrentEmployee = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = " paddedperson ", + Phone = " 530-555-9500 ", + DirectPhone = " 530-555-9501 ", + Office = " Room 950 ", + ListFirst = false, + }; + + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + + var phonePerson = await _context.PhonePerson + .SingleAsync(p => p.PersonIam == "paddedperson", TestContext.Current.CancellationToken); + Assert.Equal("530-555-9500", phonePerson.Phone); + Assert.Equal("Room 950", phonePerson.Office); + } + + [Fact] + public async Task AddUnitPersonData_CalledTwiceWithAPaddedIam_UpsertsInsteadOfDuplicating() + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 4, + IamId = "paddedtwice", + FirstName = "Padded", + LastName = "Twice", + FullName = "Padded Twice", + CurrentEmployee = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + // The existing-row lookup has to trim the same way the insert does. Matching a padded + // request against the trimmed PersonIam already stored finds nothing, so every resubmit + // would add another association row for the same person and unit. + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = " paddedtwice ", + Phone = " 530-555-9600 ", + DirectPhone = " 530-555-9601 ", + Office = " Room 960 ", + ListFirst = false, + }; + + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + + var association = Assert.Single(await _context.PhoneListUnitPerson + .Where(p => p.PersonIam == "paddedtwice" && p.PhoneListUnitId == 1) + .ToListAsync(TestContext.Current.CancellationToken)); + Assert.True(association.IsActive); + Assert.Equal("paddedtwice", association.PersonIam); + } + + [Fact] + public async Task UpdateUnitPersonData_UpdatesThePhonePerson_AndModifiedMetadata() + { + var unitPerson = Assert.Single(_context.PhoneListUnitPerson); + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = "listedperson", + Phone = " 530-555-7000 ", + DirectPhone = " 530-555-7001 ", + Office = " Room 700 ", + }; + + await _service.UpdateUnitPersonData( + 1, unitPerson.PhoneListUnitPersonId, request, TestContext.Current.CancellationToken); + + var phonePerson = await _context.PhonePerson + .FindAsync(new object?[] { "listedperson" }, TestContext.Current.CancellationToken); + Assert.NotNull(phonePerson); + Assert.Equal("530-555-7000", phonePerson.Phone); + Assert.Equal("530-555-7001", phonePerson.DirectPhone); + Assert.Equal("Room 700", phonePerson.Office); + Assert.Equal(CallerIam, unitPerson.ModifiedBy); + Assert.NotNull(unitPerson.ModifiedDate); + } + + [Fact] + public async Task UpdateUnitPersonData_ClearsListFirstOnTheRecordsOwnUnit_NotTheRequestedOne() + { + // The request carries a UnitId, but the record already knows which unit it lives in. Only + // the record's own unit may be cleared: taking the caller's word for it would let a + // mismatched UnitId unset the first-listed entry of an unrelated unit. + AddUnit(unitId: 2, name: "Other Office"); + AddMember(unitPersonId: 10, unitId: 1, personIam: "sameunitfirst", listFirst: true); + AddMember(unitPersonId: 20, unitId: 2, personIam: "otherunitfirst", listFirst: true); + var target = await _context.PhoneListUnitPerson + .SingleAsync(p => p.PersonIam == "listedperson", TestContext.Current.CancellationToken); + + var request = new PhoneListUnitDataRequest + { + UnitId = 2, + EmployeeIam = "listedperson", + Phone = "530-555-7000", + ListFirst = true, + }; + + await _service.UpdateUnitPersonData( + 1, target.PhoneListUnitPersonId, request, TestContext.Current.CancellationToken); + + Assert.True(target.ListFirst); + Assert.False((await FindMember(10))!.ListFirst); + Assert.True((await FindMember(20))!.ListFirst); + } + + [Fact] + public async Task UpdateUnitPersonData_LeavesTheExistingFirstEntry_WhenListFirstIsNotSet() + { + AddMember(unitPersonId: 10, unitId: 1, personIam: "sameunitfirst", listFirst: true); + var target = await _context.PhoneListUnitPerson + .SingleAsync(p => p.PersonIam == "listedperson", TestContext.Current.CancellationToken); + + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = "listedperson", + Phone = "530-555-7000", + ListFirst = false, + }; + + await _service.UpdateUnitPersonData( + 1, target.PhoneListUnitPersonId, request, TestContext.Current.CancellationToken); + + Assert.False(target.ListFirst); + Assert.True((await FindMember(10))!.ListFirst); + } + + [Fact] + public async Task DeleteUnitPersonData_SoftDeletes_KeepsRowButMarksInactive() + { + var unitPerson = Assert.Single(_context.PhoneListUnitPerson); + + await _service.DeleteUnitPersonData(1, unitPerson.PhoneListUnitPersonId, TestContext.Current.CancellationToken); + + var stillExists = await _context.PhoneListUnitPerson + .FindAsync(new object?[] { unitPerson.PhoneListUnitPersonId }, TestContext.Current.CancellationToken); + Assert.NotNull(stillExists); + Assert.False(stillExists.IsActive); + Assert.Equal(CallerIam, stillExists.ModifiedBy); + } + + [Fact] + public async Task DeleteUnitPersonData_Throws_WhenNotFound() + { + await Assert.ThrowsAsync( + () => _service.DeleteUnitPersonData(1, 9999, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task EditingAnAlreadyDeletedRecord_ReportsItAsRemoved_RatherThanResurrectingIt() + { + var unitPerson = Assert.Single(_context.PhoneListUnitPerson); + await _service.DeleteUnitPersonData(1, unitPerson.PhoneListUnitPersonId, TestContext.Current.CancellationToken); + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = "listedperson", + Phone = "530-555-9000", + }; + + // A maintainer whose page predates someone else's delete. Saving must not bring the row + // back, and the message becomes an error banner, so it is worded for that reader. + var ex = await Assert.ThrowsAsync( + () => _service.UpdateUnitPersonData( + 1, unitPerson.PhoneListUnitPersonId, request, TestContext.Current.CancellationToken)); + Assert.Equal("That record has already been removed.", ex.Message); + + var stillDeleted = await _context.PhoneListUnitPerson + .FindAsync(new object?[] { unitPerson.PhoneListUnitPersonId }, TestContext.Current.CancellationToken); + Assert.NotNull(stillDeleted); + Assert.False(stillDeleted.IsActive); + } +} diff --git a/test/Personnel/PhonePersonControllerTests.cs b/test/Personnel/PhonePersonControllerTests.cs new file mode 100644 index 000000000..cd692c5b1 --- /dev/null +++ b/test/Personnel/PhonePersonControllerTests.cs @@ -0,0 +1,204 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhonePersonController, the person picker behind the phone-record dialogs. +/// The controller does the merge itself rather than delegating it: two independent queries (people +/// from users.Person, phone rows from phones.Person) are joined in memory, so the cases worth +/// pinning are the ones the join can get wrong - a person with no phone row, a phone row with no +/// matching person - plus the direct-number masking, which depends on a list code supplied by the +/// caller and so must fail closed when that code is absent or bogus. +/// +public sealed class PhonePersonControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhonePersonController _controller; + + private const string VmdoRole = "SVMSecure.PhoneLists.VMDOMaintain"; + + public PhonePersonControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = "caller01", + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = "caller01", + }); + + var rapsContext = Substitute.For(); + var permissionsService = new PhonePermissionsService(rapsContext, _userHelper); + var phoneListService = new PhoneListService(_context); + var lookupService = new PhonePersonLookupService(_context, permissionsService); + + _controller = new PhonePersonController(phoneListService, lookupService); + + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 1, + Code = "VMDO", + Name = "Dean's Office", + MaintainRole = VmdoRole, + }); + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 1, + IamId = "person01", + FirstName = "Ada", + LastName = "Smithers", + FullName = "Ada Smithers", + CurrentEmployee = true, + MailId = "asmithers", + }); + _context.PhonePerson.Add(new PhonePerson + { + PersonIam = "person01", + Phone = "530-555-1000", + DirectPhone = "530-555-2000", + }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + private void GrantRole(string role) + { + _userHelper.HasPermission(Arg.Any(), Arg.Any(), role).Returns(true); + } + + private async Task> Search(string search, string? listCode = null) + { + var result = await _controller.GetCurrentEmployees(search, listCode, TestContext.Current.CancellationToken); + var okResult = Assert.IsType(result.Result); + return Assert.IsAssignableFrom>(okResult.Value); + } + + [Fact] + public async Task GetCurrentEmployees_MergesPhoneDataOntoTheMatchedPerson() + { + var results = await Search("Smithers"); + + var person = Assert.Single(results); + Assert.Equal("person01", person.IamId); + Assert.Equal("Ada Smithers", person.FullName); + Assert.NotNull(person.PhoneData); + Assert.Equal("530-555-1000", person.PhoneData.Phone); + } + + [Fact] + public async Task GetCurrentEmployees_MasksDirectPhone_WhenNoListIsNamed() + { + var results = await Search("Smithers"); + + Assert.Equal("", Assert.Single(results).PhoneData?.DirectPhone); + } + + [Fact] + public async Task GetCurrentEmployees_MasksDirectPhone_ForANonMaintainer() + { + var results = await Search("Smithers", "VMDO"); + + Assert.Equal("", Assert.Single(results).PhoneData?.DirectPhone); + } + + [Fact] + public async Task GetCurrentEmployees_ReturnsDirectPhone_ForAMaintainerOfTheNamedList() + { + GrantRole(VmdoRole); + + var results = await Search("Smithers", "VMDO"); + + Assert.Equal("530-555-2000", Assert.Single(results).PhoneData?.DirectPhone); + } + + [Fact] + public async Task GetCurrentEmployees_MasksDirectPhone_ForAnUnknownListCode() + { + // An unresolvable code drops to "no list", not "no permission check": holding the role + // must not be enough on its own, since the code is caller-supplied. + GrantRole(VmdoRole); + + var results = await Search("Smithers", "NOPE"); + + Assert.Equal("", Assert.Single(results).PhoneData?.DirectPhone); + } + + [Fact] + public async Task GetCurrentEmployees_ReturnsThePerson_WhenTheyHaveNoPhoneRow() + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 2, + IamId = "person02", + FirstName = "Bo", + LastName = "Smithfield", + FullName = "Bo Smithfield", + CurrentEmployee = true, + MailId = "bsmithfield", + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await Search("Smithfield"); + + var person = Assert.Single(results); + Assert.Equal("person02", person.IamId); + Assert.Null(person.PhoneData); + } + + [Fact] + public async Task GetCurrentEmployees_IgnoresPhoneRowsWithNoMatchingPerson() + { + // phones.Person outlives users.Person entries, so an orphaned phone row must not + // materialize as a pickable person. + _context.PhonePerson.Add(new PhonePerson { PersonIam = "ghost01", Phone = "530-555-9999" }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await Search("Smithers"); + + Assert.Equal("person01", Assert.Single(results).IamId); + } + + [Fact] + public async Task GetCurrentEmployees_ExcludesFormerEmployees() + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 3, + IamId = "person03", + FirstName = "Cy", + LastName = "Smithson", + FullName = "Cy Smithson", + CurrentEmployee = false, + MailId = "csmithson", + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + Assert.Empty(await Search("Smithson")); + } + + [Fact] + public async Task GetCurrentEmployees_ReturnsEmpty_ForASearchTermBelowTheMinimumLength() + { + Assert.Empty(await Search("S")); + } +} diff --git a/test/Personnel/PhonePersonLookupServiceTests.cs b/test/Personnel/PhonePersonLookupServiceTests.cs new file mode 100644 index 000000000..8b458288e --- /dev/null +++ b/test/Personnel/PhonePersonLookupServiceTests.cs @@ -0,0 +1,157 @@ +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhonePersonLookupService: GetPhonePeople implements the same +/// permission-based DirectPhone masking as PhoneListUnitService, but as an independent code +/// path used by the person-picker autocomplete, so a regression there wouldn't be caught by +/// the PhoneListUnitService tests. GetViperCurrentEmployees layers PersonSearchHelper on top +/// of the CurrentEmployee filter. +/// +public sealed class PhonePersonLookupServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhonePersonLookupService _service; + + private const string MaintainRole = "SVMSecure.PhoneLists.VMDOMaintain"; + private const string CallerIam = "caller01"; + + public PhonePersonLookupServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + var rapsContext = Substitute.For(); + var permissionsService = new PhonePermissionsService(rapsContext, _userHelper); + _service = new PhonePersonLookupService(_context, permissionsService); + + _context.PhoneList.Add(new PhoneList { PhoneListId = 1, Code = "VMDO", Name = "Dean's Office", MaintainRole = MaintainRole }); + _context.PhonePerson.Add(new PhonePerson + { + PersonIam = "person01", + Phone = "530-555-1000", + DirectPhone = "530-555-2000", + }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + /// The seeded list, as the controller would hand it to the service. + private PhoneList TestList() => + _context.PhoneList.Single(l => l.PhoneListId == 1); + + [Fact] + public async Task GetPhonePeople_MasksDirectPhone_WhenNoListSupplied() + { + var results = await _service.GetPhonePeople(["person01"], ct: TestContext.Current.CancellationToken); + + var person = Assert.Single(results); + Assert.Equal("", person.DirectPhone); + } + + [Fact] + public async Task GetPhonePeople_MasksDirectPhone_WhenCallerLacksMaintainAccessToList() + { + _userHelper.HasPermission(Arg.Any(), Arg.Any(), MaintainRole).Returns(false); + + var results = await _service.GetPhonePeople(["person01"], TestList(), ct: TestContext.Current.CancellationToken); + + var person = Assert.Single(results); + Assert.Equal("", person.DirectPhone); + } + + [Fact] + public async Task GetPhonePeople_ShowsDirectPhone_WhenCallerHasMaintainAccessToList() + { + _userHelper.HasPermission(Arg.Any(), Arg.Any(), MaintainRole).Returns(true); + + var results = await _service.GetPhonePeople(["person01"], TestList(), ct: TestContext.Current.CancellationToken); + + var person = Assert.Single(results); + Assert.Equal("530-555-2000", person.DirectPhone); + } + + [Fact] + public async Task GetPhonePeople_IgnoresBlankAndWhitespaceIamIds() + { + var results = await _service.GetPhonePeople( + ["person01", "", " ", null!], + ct: TestContext.Current.CancellationToken); + + Assert.Single(results); + } + + [Fact] + public async Task GetViperCurrentEmployees_ReturnsEmpty_WhenSearchBelowMinimumLength() + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 1, + IamId = "person01", + FirstName = "Amy", + LastName = "Smith", + FullName = "Amy Smith", + CurrentEmployee = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await _service.GetViperCurrentEmployees("a", TestContext.Current.CancellationToken); + + Assert.Empty(results); + } + + [Fact] + public async Task GetViperCurrentEmployees_ExcludesFormerEmployees() + { + _context.ViperPerson.AddRange( + new ViperPerson + { + PersonId = 1, + IamId = "person01", + FirstName = "Amy", + LastName = "Smith", + FullName = "Amy Smith", + CurrentEmployee = true, + }, + new ViperPerson + { + PersonId = 2, + IamId = "person02", + FirstName = "Amy", + LastName = "Smithson", + FullName = "Amy Smithson", + CurrentEmployee = false, + } + ); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await _service.GetViperCurrentEmployees("Smith", TestContext.Current.CancellationToken); + + var match = Assert.Single(results); + Assert.Equal("person01", match.IamId); + } +} diff --git a/test/Personnel/PhoneSVMFrequentNumberControllerTests.cs b/test/Personnel/PhoneSVMFrequentNumberControllerTests.cs new file mode 100644 index 000000000..c124be591 --- /dev/null +++ b/test/Personnel/PhoneSVMFrequentNumberControllerTests.cs @@ -0,0 +1,101 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller wiring tests for PhoneSVMFrequentNumberController: verifies the +/// InvalidOperationException-to-400 mapping used across the phones endpoints when +/// a maintain action targets a row that doesn't exist (or was already removed). +/// +public sealed class PhoneSVMFrequentNumberControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMFrequentNumberController _controller; + + public PhoneSVMFrequentNumberControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + var userHelper = Substitute.For(); + userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = "caller01", + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = "caller01", + }); + + _controller = new PhoneSVMFrequentNumberController(new PhoneSVMFrequentNumberService(_context, userHelper)); + } + + public void Dispose() => _context.Dispose(); + + [Fact] + public async Task UpdateFrequentNumber_ReturnsBadRequest_WhenNotFound() + { + var request = new SVMFrequentNumberRequest { Label = "Front Desk", Phone = "530-555-1000" }; + + var result = await _controller.UpdateFrequentNumber(999, request, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteFrequentNumber_ReturnsBadRequest_WhenNotFound() + { + var result = await _controller.DeleteFrequentNumber(999, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteFrequentNumber_ReturnsOk_AndSoftDeletes_WhenFound() + { + _context.SVMFrequentNumber.Add(new SVMFrequentNumber + { + NumberId = 1, + Label = "Pharmacy", + Phone = "530-555-2000", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _controller.DeleteFrequentNumber(1, TestContext.Current.CancellationToken); + + Assert.IsType(result); + var stillExists = await _context.SVMFrequentNumber + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(stillExists); + Assert.False(stillExists.IsActive); + } + + [Fact] + public async Task GetFrequentNumbers_ReturnsOnlyActiveNumbers() + { + _context.SVMFrequentNumber.AddRange( + new SVMFrequentNumber { NumberId = 1, Label = "Active Line", Phone = "1", IsActive = true }, + new SVMFrequentNumber { NumberId = 2, Label = "Retired Line", Phone = "2", IsActive = false } + ); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _controller.GetFrequentNumbers(TestContext.Current.CancellationToken); + + var okResult = Assert.IsType(result.Result); + var numbers = Assert.IsAssignableFrom>(okResult.Value); + Assert.Equal(["Active Line"], numbers.Select(n => n.Label)); + } +} diff --git a/test/Personnel/PhoneSVMFrequentNumberServiceTests.cs b/test/Personnel/PhoneSVMFrequentNumberServiceTests.cs new file mode 100644 index 000000000..629f64bf2 --- /dev/null +++ b/test/Personnel/PhoneSVMFrequentNumberServiceTests.cs @@ -0,0 +1,201 @@ +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhoneSVMFrequentNumberService, focused on the soft-delete +/// convention (rows are marked inactive rather than removed, so ModifiedDate keeps +/// tracking when the list last changed) and the SQL Server 2016-safe null-last +/// SortOrder ordering. +/// +public sealed class PhoneSVMFrequentNumberServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhoneSVMFrequentNumberService _service; + + private const string CallerIam = "caller01"; + + public PhoneSVMFrequentNumberServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + _service = new PhoneSVMFrequentNumberService(_context, _userHelper); + } + + public void Dispose() => _context.Dispose(); + + private void SeedNumber(string label, string phone, bool isActive = true) + { + _context.SVMFrequentNumber.Add(new SVMFrequentNumber + { + NumberId = 1, + Label = label, + Phone = phone, + IsActive = isActive, + }); + _context.SaveChanges(); + } + + private async Task FindNumber(int numberId) => + await _context.SVMFrequentNumber.FindAsync(new object?[] { numberId }, TestContext.Current.CancellationToken); + + [Fact] + public async Task AddFrequentNumber_SetsIsActiveTrue_AndModifiedMetadata() + { + var request = new SVMFrequentNumberRequest { Label = "Front Desk", Phone = "530-555-1000" }; + + await _service.AddFrequentNumber(request, TestContext.Current.CancellationToken); + + var saved = await _context.SVMFrequentNumber + .SingleAsync(n => n.Label == "Front Desk", TestContext.Current.CancellationToken); + Assert.True(saved.IsActive); + Assert.Equal(CallerIam, saved.ModifiedBy); + Assert.NotNull(saved.ModifiedDate); + } + + [Fact] + public async Task DeleteFrequentNumber_SoftDeletes_ExcludesRowFromGetSVMFrequentNumbers() + { + _context.SVMFrequentNumber.Add(new SVMFrequentNumber + { + NumberId = 1, + Label = "Pharmacy", + Phone = "530-555-2000", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + await _service.DeleteFrequentNumber(1, TestContext.Current.CancellationToken); + + var stillExists = await _context.SVMFrequentNumber + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(stillExists); + Assert.False(stillExists.IsActive); + + var activeNumbers = await _service.GetSVMFrequentNumbers(TestContext.Current.CancellationToken); + Assert.Empty(activeNumbers); + } + + [Fact] + public async Task DeleteFrequentNumber_Throws_WhenAlreadyInactive() + { + _context.SVMFrequentNumber.Add(new SVMFrequentNumber + { + NumberId = 1, + Label = "Retired Line", + Phone = "530-555-3000", + IsActive = false, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync( + () => _service.DeleteFrequentNumber(1, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task UpdateFrequentNumber_OverwritesFields_AndModifiedMetadata() + { + SeedNumber(label: "Front Desk", phone: "530-555-1000"); + var request = new SVMFrequentNumberRequest { Label = "Reception", Phone = "530-555-4000" }; + + await _service.UpdateFrequentNumber(1, request, TestContext.Current.CancellationToken); + + var saved = await FindNumber(1); + Assert.NotNull(saved); + Assert.Equal("Reception", saved.Label); + Assert.Equal("530-555-4000", saved.Phone); + Assert.Equal(CallerIam, saved.ModifiedBy); + Assert.NotNull(saved.ModifiedDate); + Assert.True(saved.IsActive); + } + + [Fact] + public async Task UpdateFrequentNumber_TrimsWhitespace() + { + SeedNumber(label: "Front Desk", phone: "530-555-1000"); + var request = new SVMFrequentNumberRequest { Label = " Reception ", Phone = " 530-555-4000 " }; + + await _service.UpdateFrequentNumber(1, request, TestContext.Current.CancellationToken); + + var saved = await FindNumber(1); + Assert.NotNull(saved); + Assert.Equal("Reception", saved.Label); + Assert.Equal("530-555-4000", saved.Phone); + } + + [Theory] + [InlineData("", "530-555-4000", "Location must not be empty.")] + // Whitespace-only rather than empty: the guard is IsNullOrWhiteSpace, and a bare "" would + // still pass if it were ever weakened to IsNullOrEmpty. + [InlineData(" ", "530-555-4000", "Location must not be empty.")] + [InlineData("Reception", "", "Phone Number must not be empty.")] + [InlineData("Reception", " ", "Phone Number must not be empty.")] + public async Task UpdateFrequentNumber_Throws_ForBlankFields(string label, string phone, string expectedMessage) + { + SeedNumber(label: "Front Desk", phone: "530-555-1000"); + var request = new SVMFrequentNumberRequest { Label = label, Phone = phone }; + + var ex = await Assert.ThrowsAsync( + () => _service.UpdateFrequentNumber(1, request, TestContext.Current.CancellationToken)); + + Assert.Equal(expectedMessage, ex.Message); + var unchanged = await FindNumber(1); + Assert.NotNull(unchanged); + Assert.Equal("Front Desk", unchanged.Label); + } + + [Fact] + public async Task UpdateFrequentNumber_Throws_WhenTheRowWasAlreadyDeleted() + { + // A maintainer whose page predates someone else's delete. Editing must not resurrect the + // row, which the IsActive half of the guard is what prevents - an id-only lookup would + // find the soft-deleted record and happily write to it. + SeedNumber(label: "Retired Line", phone: "530-555-3000", isActive: false); + var request = new SVMFrequentNumberRequest { Label = "Reception", Phone = "530-555-4000" }; + + await Assert.ThrowsAsync( + () => _service.UpdateFrequentNumber(1, request, TestContext.Current.CancellationToken)); + + var stillDeleted = await FindNumber(1); + Assert.NotNull(stillDeleted); + Assert.False(stillDeleted.IsActive); + Assert.Equal("Retired Line", stillDeleted.Label); + } + + [Fact] + public async Task GetSVMFrequentNumbers_OrdersRowsWithNoSortOrderLast() + { + _context.SVMFrequentNumber.AddRange( + new SVMFrequentNumber { NumberId = 1, Label = "Zebra Unsorted", Phone = "1", IsActive = true, SortOrder = null }, + new SVMFrequentNumber { NumberId = 2, Label = "Pharmacy", Phone = "2", IsActive = true, SortOrder = 2 }, + new SVMFrequentNumber { NumberId = 3, Label = "Front Desk", Phone = "3", IsActive = true, SortOrder = 1 } + ); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await _service.GetSVMFrequentNumbers(TestContext.Current.CancellationToken); + + Assert.Equal(["Front Desk", "Pharmacy", "Zebra Unsorted"], results.Select(r => r.Label)); + } +} diff --git a/test/Personnel/PhoneSVMModifiedDateControllerTests.cs b/test/Personnel/PhoneSVMModifiedDateControllerTests.cs new file mode 100644 index 000000000..531370460 --- /dev/null +++ b/test/Personnel/PhoneSVMModifiedDateControllerTests.cs @@ -0,0 +1,130 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneSVMModifiedDateController, which clients poll to decide whether their +/// cached SVM list is stale. The SVM page renders two independently-maintained datasets - frequent +/// numbers and unit people - behind one freshness date, so the endpoint has to report the later of +/// the two and stay correct when either side has never been modified. Reporting the earlier one +/// would leave a client believing its copy is current. +/// +public sealed class PhoneSVMModifiedDateControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMModifiedDateController _controller; + + private static readonly DateTime Older = new(2026, 1, 1, 9, 0, 0, DateTimeKind.Local); + private static readonly DateTime Newer = new(2026, 6, 1, 9, 0, 0, DateTimeKind.Local); + + public PhoneSVMModifiedDateControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + var userHelper = Substitute.For(); + userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = "caller01", + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = "caller01", + }); + + _controller = new PhoneSVMModifiedDateController( + new PhoneSVMFrequentNumberService(_context, userHelper), + new PhoneSVMUnitService(_context, userHelper)); + + _context.SVMUnit.Add(new SVMUnit { UnitId = 1, SectionId = 1, Name = "Dean's Office" }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + private void AddFrequentNumber(DateTime? modifiedDate) + { + _context.SVMFrequentNumber.Add(new SVMFrequentNumber + { + NumberId = 1, + Label = "Front Desk", + Phone = "530-555-1000", + IsActive = true, + ModifiedDate = modifiedDate, + }); + _context.SaveChanges(); + } + + private void AddUnitPerson(DateTime? modifiedDate) + { + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + ModifiedDate = modifiedDate, + }); + _context.SaveChanges(); + } + + private async Task GetDate() + { + var result = await _controller.GetLastModifiedDate(TestContext.Current.CancellationToken); + var okResult = Assert.IsType(result.Result); + return (DateTime?)okResult.Value; + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsNull_WhenNeitherDatasetHasBeenModified() + { + Assert.Null(await GetDate()); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsTheUnitPersonDate_WhenNoFrequentNumberHasOne() + { + AddUnitPerson(Newer); + + Assert.Equal(Newer, await GetDate()); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsTheFrequentNumberDate_WhenNoUnitPersonHasOne() + { + AddFrequentNumber(Newer); + + Assert.Equal(Newer, await GetDate()); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsTheUnitPersonDate_WhenItIsTheLater() + { + AddFrequentNumber(Older); + AddUnitPerson(Newer); + + Assert.Equal(Newer, await GetDate()); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsTheFrequentNumberDate_WhenItIsTheLater() + { + AddFrequentNumber(Newer); + AddUnitPerson(Older); + + Assert.Equal(Newer, await GetDate()); + } +} diff --git a/test/Personnel/PhoneSVMSectionControllerTests.cs b/test/Personnel/PhoneSVMSectionControllerTests.cs new file mode 100644 index 000000000..80775977e --- /dev/null +++ b/test/Personnel/PhoneSVMSectionControllerTests.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneSVMSectionController. Sections are the page's top-level grouping, so +/// the order they come back in is the order the page renders; unsorted sections fall to the end +/// alphabetically rather than jumping to the front on a null SortOrder. +/// +public sealed class PhoneSVMSectionControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMSectionController _controller; + + public PhoneSVMSectionControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + _controller = new PhoneSVMSectionController(new PhoneSVMSectionService(_context)); + } + + public void Dispose() => _context.Dispose(); + + private async Task> GetSections() + { + var result = await _controller.GetSections(TestContext.Current.CancellationToken); + var okResult = Assert.IsType(result.Result); + return Assert.IsAssignableFrom>(okResult.Value); + } + + [Fact] + public async Task GetSections_ReturnsSortedSectionsBeforeUnsortedOnes() + { + _context.SVMSection.AddRange( + new SVMSection { SectionId = 1, Name = "Anatomy", SortOrder = null }, + new SVMSection { SectionId = 2, Name = "Dean's Office", SortOrder = 1 }, + new SVMSection { SectionId = 3, Name = "Zoology", SortOrder = 2 } + ); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var sections = await GetSections(); + + Assert.Equal(["Dean's Office", "Zoology", "Anatomy"], sections.Select(s => s.Name)); + } + + [Fact] + public async Task GetSections_ReturnsNotFound_WhenThereAreNoSections() + { + var result = await _controller.GetSections(TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } +} diff --git a/test/Personnel/PhoneSVMSectionServiceTests.cs b/test/Personnel/PhoneSVMSectionServiceTests.cs new file mode 100644 index 000000000..dfd484fa8 --- /dev/null +++ b/test/Personnel/PhoneSVMSectionServiceTests.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhoneSVMSectionService, covering the SQL Server 2016-safe null-last +/// SortOrder ordering convention shared with PhoneListUnitService and +/// PhoneSVMFrequentNumberService. +/// +public sealed class PhoneSVMSectionServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMSectionService _service; + + public PhoneSVMSectionServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + _service = new PhoneSVMSectionService(_context); + } + + public void Dispose() => _context.Dispose(); + + [Fact] + public async Task GetSVMSections_ReturnsEmptyList_WhenNoSectionsExist() + { + var results = await _service.GetSVMSections(TestContext.Current.CancellationToken); + + Assert.Empty(results); + } + + [Fact] + public async Task GetSVMSections_OrdersRowsWithNoSortOrderLast() + { + _context.SVMSection.AddRange( + new SVMSection { SectionId = 1, Name = "Zebra Unsorted", SortOrder = null }, + new SVMSection { SectionId = 2, Name = "Registrar", SortOrder = 2 }, + new SVMSection { SectionId = 3, Name = "Dean's Office", SortOrder = 1 } + ); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await _service.GetSVMSections(TestContext.Current.CancellationToken); + + Assert.Equal(["Dean's Office", "Registrar", "Zebra Unsorted"], results.Select(r => r.Name)); + } +} diff --git a/test/Personnel/PhoneSVMUnitControllerTests.cs b/test/Personnel/PhoneSVMUnitControllerTests.cs new file mode 100644 index 000000000..8946163f4 --- /dev/null +++ b/test/Personnel/PhoneSVMUnitControllerTests.cs @@ -0,0 +1,206 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneSVMUnitController: the read shape the SVM page renders from, and the +/// InvalidOperationException-to-400 mapping on the three maintain endpoints. Unlike the per-list +/// controllers, write access here is a fixed role on the endpoint rather than a per-row lookup, so +/// what the controller itself decides is narrower - which makes the failure mapping the thing +/// worth pinning, since a 500 here would surface to a maintainer as an unexplained error banner +/// instead of the message the service wrote for them. +/// +public sealed class PhoneSVMUnitControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMUnitController _controller; + + private const string CallerIam = "caller01"; + + public PhoneSVMUnitControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + _context = new PhonesDbContext(options); + + var userHelper = Substitute.For(); + userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + _controller = new PhoneSVMUnitController(new PhoneSVMUnitService(_context, userHelper)); + + _context.SVMUnit.Add(new SVMUnit { UnitId = 1, SectionId = 1, Name = "Dean's Office" }); + // The read projection joins phones.Person to users.Person on a required relationship, so + // a phone row without its person is invisible to GetUnits. + AddPerson(personId: 1, "dean01", "Dinah", "Deanly", "530-555-1000"); + AddPerson(personId: 2, "staff01", "Sam", "Staffly", "530-555-2000"); + _context.SaveChanges(); + } + + private void AddPerson(int personId, string iamId, string firstName, string lastName, string phone) + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = personId, + IamId = iamId, + FirstName = firstName, + LastName = lastName, + FullName = $"{firstName} {lastName}", + CurrentEmployee = true, + }); + _context.PhonePerson.Add(new PhonePerson { PersonIam = iamId, Phone = phone }); + } + + public void Dispose() => _context.Dispose(); + + private void AddUnitPerson(int unitPersonId, string personIam, string posType, bool isActive = true) + { + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = unitPersonId, + UnitId = 1, + PersonIam = personIam, + PosType = posType, + IsActive = isActive, + }); + _context.SaveChanges(); + } + + private static SVMUnitDataRequest Request() => new() + { + Fax = "530-555-3000", + Location = "Room 100", + DeanIam = "dean01", + DeanPhone = "530-555-1000", + }; + + private async Task FindUnitPerson(int unitPersonId) => + await _context.SVMUnitPerson.FindAsync(new object?[] { unitPersonId }, TestContext.Current.CancellationToken); + + [Fact] + public async Task GetUnits_ReturnsOk_WithActivePeopleOnly() + { + AddUnitPerson(unitPersonId: 1, "dean01", "Dean"); + AddUnitPerson(unitPersonId: 2, "staff01", "Staff", isActive: false); + + var result = await _controller.GetUnits(TestContext.Current.CancellationToken); + + var okResult = Assert.IsType(result.Result); + var units = Assert.IsAssignableFrom>(okResult.Value); + var unit = Assert.Single(units); + Assert.Equal("dean01", Assert.Single(unit.UnitPersons).PersonIam); + } + + [Fact] + public async Task GetUnits_ReturnsOk_WithAnEmptyListWhenThereAreNoUnits() + { + // An empty SVM list is a legitimate state, not a 404: the page still renders its sections. + _context.SVMUnit.RemoveRange(_context.SVMUnit); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _controller.GetUnits(TestContext.Current.CancellationToken); + + var okResult = Assert.IsType(result.Result); + Assert.Empty(Assert.IsAssignableFrom>(okResult.Value)); + } + + [Fact] + public async Task AddUnitData_ReturnsBadRequest_ForAnUnknownUnit() + { + var result = await _controller.AddUnitData(999, Request(), TestContext.Current.CancellationToken); + + Assert.IsType(result); + Assert.Empty(await _context.SVMUnitPerson.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task AddUnitData_ReturnsOk_AndAddsTheLeader() + { + var result = await _controller.AddUnitData(1, Request(), TestContext.Current.CancellationToken); + + Assert.IsType(result); + var added = Assert.Single(await _context.SVMUnitPerson + .Where(p => p.IsActive) + .ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal("dean01", added.PersonIam); + Assert.Equal("Dean", added.PosType); + } + + [Fact] + public async Task UpdateUnitData_ReturnsBadRequest_ForAnUnknownUnit() + { + var result = await _controller.UpdateUnitData(999, Request(), TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task UpdateUnitData_ReturnsOk_AndReplacesTheNamedRow() + { + AddUnitPerson(unitPersonId: 1, "dean01", "Dean"); + var request = Request(); + request.DeanUnitPerson = 1; + + var result = await _controller.UpdateUnitData(1, request, TestContext.Current.CancellationToken); + + Assert.IsType(result); + var replaced = await FindUnitPerson(1); + Assert.NotNull(replaced); + Assert.False(replaced.IsActive); + Assert.Equal("Room 100", Assert.Single(await _context.SVMUnitPerson + .Where(p => p.IsActive) + .ToListAsync(TestContext.Current.CancellationToken)).Office); + } + + [Fact] + public async Task DeleteUnitRow_ReturnsBadRequest_WhenNotFound() + { + var result = await _controller.DeleteUnitRow(999, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteUnitRow_ReturnsBadRequest_WhenTheRowWasAlreadyRemoved() + { + // The realistic way to miss through the UI: two maintainers on the same list. + AddUnitPerson(unitPersonId: 1, "dean01", "Dean", isActive: false); + + var result = await _controller.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + var badRequest = Assert.IsType(result); + Assert.Equal("That record has already been removed.", badRequest.Value); + } + + [Fact] + public async Task DeleteUnitRow_ReturnsOk_AndSoftDeletes_WhenFound() + { + AddUnitPerson(unitPersonId: 1, "dean01", "Dean"); + + var result = await _controller.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + Assert.IsType(result); + var stillExists = await FindUnitPerson(1); + Assert.NotNull(stillExists); + Assert.False(stillExists.IsActive); + } +} diff --git a/test/Personnel/PhoneSVMUnitServiceTests.cs b/test/Personnel/PhoneSVMUnitServiceTests.cs new file mode 100644 index 000000000..3a87ad664 --- /dev/null +++ b/test/Personnel/PhoneSVMUnitServiceTests.cs @@ -0,0 +1,516 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhoneSVMUnitService, covering the two places its row handling is asymmetric. +/// DeleteUnitRow: a row is a leader plus the unit-wide admin staff, so deleting it removes the +/// leader and then the staff only once no other row still lists them - in one transaction, since +/// as separate per-record requests the pair could half-apply. +/// AddOrUpdateUnitData: one method serves both POST and PUT, so add and edit are distinguished +/// only by DeanUnitPerson/StaffUnitPerson - unset (-1) means "add another person to this unit" +/// and leaves existing rows alone, while a real id means "replace the person on that row" and +/// must deactivate it even though the incoming DeanIam/StaffIam no longer names its occupant. +/// +public sealed class PhoneSVMUnitServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMUnitService _service; + + private const string CallerIam = "caller01"; + + public PhoneSVMUnitServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + _context = new PhonesDbContext(options); + + var userHelper = Substitute.For(); + userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + _service = new PhoneSVMUnitService(_context, userHelper); + } + + public void Dispose() => _context.Dispose(); + + private void SeedUnit() + { + _context.SVMUnit.Add(new SVMUnit { UnitId = 1, SectionId = 1, Name = "Dean's Office" }); + _context.PhonePerson.Add(new PhonePerson { PersonIam = "dean01", Phone = "530-555-1000" }); + _context.PhonePerson.Add(new PhonePerson { PersonIam = "staff01", Phone = "530-555-2000" }); + _context.SaveChanges(); + } + + + [Fact] + public async Task DeleteUnitRow_SoftDeletesTheLeader_AndTheStaffItWasTheLastRowFor() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + // One call, not one per underlying record: the caller names the row, the service decides + // which records that covers. + await _service.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + Assert.Empty(await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive) + .ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DeleteUnitRow_KeepsTheStaff_WhenAnotherLeaderRowStillListsThem() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "dean02", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 3, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + await _service.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + var staffRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 3 }, TestContext.Current.CancellationToken); + Assert.NotNull(staffRow); + Assert.True(staffRow.IsActive); + + var survivingLeader = await _context.SVMUnitPerson + .FindAsync(new object?[] { 2 }, TestContext.Current.CancellationToken); + Assert.NotNull(survivingLeader); + Assert.True(survivingLeader.IsActive); + } + + [Fact] + public async Task DeleteUnitRow_RemovesTheStaff_WhenNamedForAStaffOnlyRow() + { + SeedUnit(); + // A unit with staff but no active leader renders one row keyed by the staff record, so + // that id is what the delete arrives with. + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + await _service.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + var staffRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(staffRow); + Assert.False(staffRow.IsActive); + } + + [Fact] + public async Task DeleteUnitRow_LeavesOtherUnitsAlone() + { + SeedUnit(); + _context.SVMUnit.Add(new SVMUnit { UnitId = 2, SectionId = 1, Name = "Another Unit" }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 2, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + await _service.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + var otherUnitStaff = await _context.SVMUnitPerson + .FindAsync(new object?[] { 2 }, TestContext.Current.CancellationToken); + Assert.NotNull(otherUnitStaff); + Assert.True(otherUnitStaff.IsActive); + } + + [Fact] + public async Task DeleteUnitRow_Throws_WhenNotFound() + { + await Assert.ThrowsAsync( + () => _service.DeleteUnitRow(999, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task AddOrUpdateUnitData_Throws_WhenUnitNotFound() + { + var request = new SVMUnitDataRequest { DeanIam = "dean01", DeanPhone = "530-555-1000" }; + + await Assert.ThrowsAsync( + () => _service.AddOrUpdateUnitData(999, request, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task AddOrUpdateUnitData_DeactivatesPreviousUnitPeople_AndAddsNewActiveRows() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var request = new SVMUnitDataRequest + { + Fax = " 530-555-9999 ", + Location = "Room 500", + DeanIam = "dean01", + DeanPhone = "530-555-1111", + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var oldRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(oldRow); + Assert.False(oldRow.IsActive); + + var newRow = await _context.SVMUnitPerson + .SingleAsync(p => p.IsActive && p.PersonIam == "dean01", TestContext.Current.CancellationToken); + Assert.Equal("Dean", newRow.PosType); + + var unit = await _context.SVMUnit.FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.Equal("530-555-9999", unit!.Fax); + } + + [Fact] + public async Task GetSVMUnits_AlwaysBlanksDirectPhone() + { + SeedUnit(); + var phonePerson = await _context.PhonePerson + .SingleAsync(p => p.PersonIam == "dean01", TestContext.Current.CancellationToken); + phonePerson.DirectPhone = "530-555-4000"; + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 1, + IamId = "dean01", + FirstName = "Dean", + LastName = "Person", + FullName = "Dean Person", + CurrentEmployee = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var units = await _service.GetSVMUnits(TestContext.Current.CancellationToken); + + var person = Assert.Single(Assert.Single(units).UnitPersons); + Assert.Equal("", person.Person.DirectPhone); + } + + [Fact] + public async Task AddOrUpdateUnitData_DeactivatesTheEditedRow_WhenTheDeanIsReplaced() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + // Editing the row for dean01 and choosing dean02 instead. The outgoing person is + // identified only by DeanUnitPerson, since DeanIam now names the incoming person. + var request = new SVMUnitDataRequest + { + Fax = "530-555-9999", + Location = "Room 500", + DeanIam = "dean02", + DeanPhone = "530-555-1112", + DeanUnitPerson = 1, + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var replacedRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(replacedRow); + Assert.False(replacedRow.IsActive); + + var activeLeader = Assert.Single(await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive && p.PosType != "Staff") + .ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal("dean02", activeLeader.PersonIam); + } + + [Fact] + public async Task AddOrUpdateUnitData_DeactivatesTheEditedRow_WhenTheStaffIsReplaced() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var request = new SVMUnitDataRequest + { + Location = "Room 500", + DeanIam = "dean01", + DeanPhone = "530-555-1000", + DeanUnitPerson = 1, + StaffIam = "staff02", + StaffPhone = "530-555-2002", + StaffUnitPerson = 2, + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var replacedRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 2 }, TestContext.Current.CancellationToken); + Assert.NotNull(replacedRow); + Assert.False(replacedRow.IsActive); + + var activeStaff = Assert.Single(await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive && p.PosType == "Staff") + .ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal("staff02", activeStaff.PersonIam); + } + + [Fact] + public async Task AddOrUpdateUnitData_LeavesOtherLeaderRowsActive_WhenOneLeaderIsReplaced() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "dean02", + PosType = "Dean", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + // A unit legitimately has several leader rows; replacing one must not disturb the rest. + var request = new SVMUnitDataRequest + { + Location = "Room 500", + DeanIam = "dean03", + DeanPhone = "530-555-1113", + DeanUnitPerson = 1, + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var untouchedRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 2 }, TestContext.Current.CancellationToken); + Assert.NotNull(untouchedRow); + Assert.True(untouchedRow.IsActive); + + var activeLeaders = await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive && p.PosType != "Staff") + .ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal(2, activeLeaders.Count); + Assert.Contains(activeLeaders, p => p.PersonIam == "dean02"); + Assert.Contains(activeLeaders, p => p.PersonIam == "dean03"); + } + + [Fact] + public async Task AddOrUpdateUnitData_KeepsExistingLeaders_WhenUnitPersonIdsAreUnset() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + // The add path leaves DeanUnitPerson/StaffUnitPerson at their -1 default, which is what + // separates "add another leader to this unit" from "replace the person on this row". + var request = new SVMUnitDataRequest + { + Location = "Room 500", + DeanIam = "dean02", + DeanPhone = "530-555-1112", + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var existingRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(existingRow); + Assert.True(existingRow.IsActive); + + var activeLeaders = await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive && p.PosType != "Staff") + .ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal(2, activeLeaders.Count); + } + + [Fact] + public async Task AddOrUpdateUnitData_RemovesStaff_WhenClearedFromTheEditedRow() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var request = new SVMUnitDataRequest + { + Location = "Room 500", + DeanIam = "dean01", + DeanPhone = "530-555-1000", + DeanUnitPerson = 1, + StaffIam = "", + StaffUnitPerson = 2, + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var clearedRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 2 }, TestContext.Current.CancellationToken); + Assert.NotNull(clearedRow); + Assert.False(clearedRow.IsActive); + + Assert.Empty(await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive && p.PosType == "Staff") + .ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DeleteUnitRow_ReportsAnAlreadyDeletedRow_AsRemoved() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + await _service.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + // A maintainer whose page predates someone else's delete. Deleting again must say so + // rather than silently repeating the cascade over rows that are already gone. + var ex = await Assert.ThrowsAsync( + () => _service.DeleteUnitRow(1, TestContext.Current.CancellationToken)); + Assert.Equal("That record has already been removed.", ex.Message); + } +} diff --git a/web/Areas/CMS/Controllers/CMSOptionsController.cs b/web/Areas/CMS/Controllers/CMSOptionsController.cs index 1a7419353..549f059a6 100644 --- a/web/Areas/CMS/Controllers/CMSOptionsController.cs +++ b/web/Areas/CMS/Controllers/CMSOptionsController.cs @@ -4,6 +4,8 @@ using Viper.Areas.RAPS.Services; using Viper.Classes; using Viper.Classes.SQLContext; +using Viper.Classes.Utilities; +using Viper.Models.AAUD; using Web.Authorization; namespace Viper.Areas.CMS.Controllers @@ -65,22 +67,23 @@ public async Task>> GetPermissions(CancellationToken c [HttpGet("people")] public async Task>> SearchPeople(string search, CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(search) || search.Trim().Length < 2) + var normalizedSearch = PersonSearchHelper.Normalize(search); + if (normalizedSearch == null) { return new List(); } - search = search.Trim(); - return await _aaudContext.AaudUsers + var namePredicate = PersonSearchHelper + .NameMatches(u => u.DisplayLastName, u => u.DisplayFirstName, normalizedSearch) + .Or(u => u.LoginId != null && u.LoginId.Contains(normalizedSearch)) + .Or(u => u.MailId != null && u.MailId.Contains(normalizedSearch)); + + var query = _aaudContext.AaudUsers .AsNoTracking() .Where(u => u.Current != 0 && u.IamId != null) - .Where(u => (u.DisplayLastName + ", " + u.DisplayFirstName).Contains(search) - || (u.DisplayFirstName + " " + u.DisplayLastName).Contains(search) - || (u.LoginId != null && u.LoginId.Contains(search)) - || (u.MailId != null && u.MailId.Contains(search))) - .OrderBy(u => u.DisplayLastName) - .ThenBy(u => u.DisplayFirstName) - .Take(25) + .Where(namePredicate); + + return await PersonSearchHelper.OrderAndCap(query, u => u.DisplayLastName, u => u.DisplayFirstName) .Select(u => new CmsPersonOption { IamId = u.IamId!, diff --git a/web/Areas/Personnel/Controllers/PhoneListController.cs b/web/Areas/Personnel/Controllers/PhoneListController.cs new file mode 100644 index 000000000..c657ad635 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneListController.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/phonelist")] + [Permission(Allow = "SVMSecure")] + public class PhoneListController( + PhoneListService phoneListService, + PhoneListUnitService phoneListUnitService, + PhonePermissionsService phonePermissionsService) : ApiController + { + private readonly PhoneListService _phoneListService = phoneListService; + private readonly PhoneListUnitService _phoneListUnitService = phoneListUnitService; + private readonly PhonePermissionsService _phonePermissionsService = phonePermissionsService; + + /// + /// Everything a client needs before it fetches rows: + /// Returns the list's display name plus this caller's permissions. + /// Returned together to reduce API calls on the front end. + /// The backend continues to enforce permissions, but the front end + /// knows what data to expect and display based on these results. + /// + [HttpGet("{code}")] + public async Task> GetListInfo(string code, CancellationToken ct = default) + { + try + { + var list = await _phoneListService.GetListByCode(code, ct); + return Ok(new PhoneListInfo + { + PhoneListId = list.PhoneListId, + Code = list.Code, + Name = list.Name, + CanMaintain = _phonePermissionsService.CanMaintainList(list), + CanViewDirectPhone = await _phoneListUnitService.CanViewDirectPhone(list, ct), + }); + } + catch (InvalidOperationException ex) + { + return NotFound(ex.Message); + } + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneListModifiedDateController.cs b/web/Areas/Personnel/Controllers/PhoneListModifiedDateController.cs new file mode 100644 index 000000000..a0ee9926a --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneListModifiedDateController.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/phonelist/{code}/modifiedDate")] + [Permission(Allow = "SVMSecure")] + public class PhoneListModifiedDateController( + PhoneListService phoneListService, + PhoneListUnitService phoneListUnitService) : ApiController + { + private readonly PhoneListService _phoneListService = phoneListService; + private readonly PhoneListUnitService _phoneListUnitService = phoneListUnitService; + + /// + /// Returns the latest modification date of a UnitPerson in this list. + /// Includes deleted rows. + /// + [HttpGet] + public async Task> GetLastModifiedDate(string code, CancellationToken ct = default) + { + try + { + var list = await _phoneListService.GetListByCode(code, ct); + var unitPersonDate = await _phoneListUnitService.GetUnitPersonModifiedDate(list.PhoneListId, ct); + return Ok(unitPersonDate); + } + catch (InvalidOperationException ex) + { + return NotFound(ex.Message); + } + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneListUnitController.cs b/web/Areas/Personnel/Controllers/PhoneListUnitController.cs new file mode 100644 index 000000000..f7bb1a398 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneListUnitController.cs @@ -0,0 +1,132 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + /// + /// Unit and unit-person endpoints for a phone list, addressed by the list's stable Code. + /// Write access is the role named by that list's MaintainRole column, so each list + /// can have separate permissions. + /// + [Route("/api/phones/phonelist/{code}")] + [Permission(Allow = "SVMSecure")] + public class PhoneListUnitController( + PhoneListService phoneListService, + PhoneListUnitService phoneListUnitService, + PhonePermissionsService phonePermissionsService) : ApiController + { + private readonly PhoneListService _phoneListService = phoneListService; + private readonly PhoneListUnitService _phoneListUnitService = phoneListUnitService; + private readonly PhonePermissionsService _phonePermissionsService = phonePermissionsService; + + /// + /// Resolves the list named in the route and confirms the caller may edit it. Returns the + /// list id on success, or the ActionResult to return to the caller on failure. + /// + private async Task<(int ListId, ActionResult? Failure)> ResolveListForMaintain(string code, CancellationToken ct) + { + PhoneList list; + try + { + list = await _phoneListService.GetListByCode(code, ct); + } + catch (InvalidOperationException ex) + { + return (0, NotFound(ex.Message)); + } + if (!_phonePermissionsService.CanMaintainList(list)) + { + return (0, Forbid()); + } + return (list.PhoneListId, null); + } + + /// + /// Retrieves the PhoneListUnits associated with a given list code, including the PhoneListUnitPersons + /// in that unit. + /// + [HttpGet("units")] + public async Task>> GetUnits(string code, CancellationToken ct = default) + { + try + { + var list = await _phoneListService.GetListByCode(code, ct); + var results = await _phoneListUnitService.GetPhoneListUnits(list, ct); + return Ok(results); + } + catch (InvalidOperationException ex) + { + return NotFound(ex.Message); + } + } + + /// + /// Adds a unit person to a given list, provided the user has appropriate permissions. + /// + [HttpPost("unitPerson")] + public async Task AddUnitPersonData(string code, PhoneListUnitDataRequest request, CancellationToken ct = default) + { + var (listId, failure) = await ResolveListForMaintain(code, ct); + if (failure != null) + { + return failure; + } + try + { + await _phoneListUnitService.AddUnitPersonData(listId, request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Updates a unit person in a given list, provided the user has appropriate permissions. + /// + [HttpPut("unitPerson/{unitPersonId}")] + public async Task UpdateUnitPersonData(string code, int unitPersonId, PhoneListUnitDataRequest request, CancellationToken ct = default) + { + var (listId, failure) = await ResolveListForMaintain(code, ct); + if (failure != null) + { + return failure; + } + try + { + await _phoneListUnitService.UpdateUnitPersonData(listId, unitPersonId, request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Deletes a unit person from a given list, provided the user has appropriate permissions. + /// + [HttpDelete("unitPerson/{unitPersonId}")] + public async Task DeleteUnitPersonData(string code, int unitPersonId, CancellationToken ct = default) + { + var (listId, failure) = await ResolveListForMaintain(code, ct); + if (failure != null) + { + return failure; + } + try + { + await _phoneListUnitService.DeleteUnitPersonData(listId, unitPersonId, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhonePersonController.cs b/web/Areas/Personnel/Controllers/PhonePersonController.cs new file mode 100644 index 000000000..efa54a533 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhonePersonController.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/people")] + [Permission(Allow = "SVMSecure")] + public class PhonePersonController( + PhoneListService phoneListService, + PhonePersonLookupService phonePersonService) : ApiController + { + private readonly PhoneListService _phoneListService = phoneListService; + private readonly PhonePersonLookupService _phonePersonService = phonePersonService; + + /// + /// Person picker for the phone-record dialogs. Only returns direct numbers if the user + /// can edit the current list (and so has access to the data). + /// + [HttpGet] + public async Task>> GetCurrentEmployees(string search, string? listCode = null, CancellationToken ct = default) + { + PhoneList? list = null; + if (!string.IsNullOrWhiteSpace(listCode)) + { + try + { + list = await _phoneListService.GetListByCode(listCode, ct); + } + catch (InvalidOperationException) + { + list = null; + } + } + + List viperResults = await _phonePersonService.GetViperCurrentEmployees(search, ct); + List iamIds = []; + foreach (ViperPerson result in viperResults) + { + iamIds.Add(result.IamId); + } + List phoneResults = await _phonePersonService.GetPhonePeople(iamIds, list, ct); + Dictionary mergedResultsDict = []; + foreach (ViperPerson result in viperResults) + { + mergedResultsDict[result.IamId] = PersonnelMapper.ToAugmentedViperPerson(result); + } + List matchingResults = [.. phoneResults.Where(x => mergedResultsDict.ContainsKey(x.PersonIam))]; + + foreach (PhonePerson result in matchingResults) + { + mergedResultsDict[result.PersonIam].AddPhoneData(result); + } + return Ok(mergedResultsDict.Values.ToList()); + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneSVMFrequentNumberController.cs b/web/Areas/Personnel/Controllers/PhoneSVMFrequentNumberController.cs new file mode 100644 index 000000000..94aa79f84 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneSVMFrequentNumberController.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/svm/frequentnumbers")] + [Permission(Allow = "SVMSecure")] + public class PhoneSVMFrequentNumberController(PhoneSVMFrequentNumberService phoneSVMFrequentNumberService) : ApiController + { + private readonly PhoneSVMFrequentNumberService _phoneSVMFrequentNumberService = phoneSVMFrequentNumberService; + + /// + /// Gets the list of frequently called numbers for the SVM Phone List. + /// + [HttpGet] + public async Task>> GetFrequentNumbers(CancellationToken ct = default) + { + var results = await _phoneSVMFrequentNumberService.GetSVMFrequentNumbers(ct); + return Ok(results); + } + + /// + /// Adds a frequently called number to the SVM Phone List. + /// + [HttpPost] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task AddFrequentNumber(SVMFrequentNumberRequest request, CancellationToken ct = default) + { + try + { + await _phoneSVMFrequentNumberService.AddFrequentNumber(request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Updates a frequently called number in the SVM Phone List. + /// + [HttpPut("{entryId}")] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task UpdateFrequentNumber(int entryId, SVMFrequentNumberRequest request, CancellationToken ct = default) + { + try + { + await _phoneSVMFrequentNumberService.UpdateFrequentNumber(entryId, request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Deletes a frequently called number from the SVM Phone List. + /// + [HttpDelete("{entryId}")] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task DeleteFrequentNumber(int entryId, CancellationToken ct = default) + { + try + { + await _phoneSVMFrequentNumberService.DeleteFrequentNumber(entryId, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneSVMModifiedDateController.cs b/web/Areas/Personnel/Controllers/PhoneSVMModifiedDateController.cs new file mode 100644 index 000000000..28407ab24 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneSVMModifiedDateController.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/svm/modifiedDate")] + [Permission(Allow = "SVMSecure")] + public class PhoneSVMModifiedDateController( + PhoneSVMFrequentNumberService phoneSVMFrequentNumberService, + PhoneSVMUnitService phoneSVMUnitService) : ApiController + { + private readonly PhoneSVMFrequentNumberService _phoneSVMFrequentNumberService = phoneSVMFrequentNumberService; + private readonly PhoneSVMUnitService _phoneSVMUnitService = phoneSVMUnitService; + + /// + /// Identfies when frequent numbers were last modified. + /// + [HttpGet] + public async Task> GetLastModifiedDate(CancellationToken ct = default) + { + var frequentNumberDate = await _phoneSVMFrequentNumberService.GetSVMFrequentNumbersModifiedDate(ct); + var unitPersonDate = await _phoneSVMUnitService.GetSVMUnitPersonModifiedDate(ct); + if (frequentNumberDate == null || unitPersonDate != null && unitPersonDate > frequentNumberDate) + { + return Ok(unitPersonDate); + } + return Ok(frequentNumberDate); + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneSVMSectionController.cs b/web/Areas/Personnel/Controllers/PhoneSVMSectionController.cs new file mode 100644 index 000000000..ceb19ce64 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneSVMSectionController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/svm/sections")] + [Permission(Allow = "SVMSecure")] + public class PhoneSVMSectionController(PhoneSVMSectionService phoneSVMSectionService) : ApiController + { + private readonly PhoneSVMSectionService _phoneSVMSectionService = phoneSVMSectionService; + + /// + /// Gets the sections to include in the SVM Phone List. + /// + [HttpGet] + public async Task>> GetSections(CancellationToken ct = default) + { + var results = await _phoneSVMSectionService.GetSVMSections(ct); + if (results.Count == 0) + { + return NotFound("No sections for the SVM Phone List were found."); + } + return Ok(results); + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneSVMUnitController.cs b/web/Areas/Personnel/Controllers/PhoneSVMUnitController.cs new file mode 100644 index 000000000..759e7a2ba --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneSVMUnitController.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/svm")] + [Permission(Allow = "SVMSecure")] + public class PhoneSVMUnitController(PhoneSVMUnitService phoneSVMUnitService) : ApiController + { + private readonly PhoneSVMUnitService _phoneSVMUnitService = phoneSVMUnitService; + + /// + /// Gets all units for every section in the SVM Phone List. + /// + [HttpGet("units")] + public async Task>> GetUnits(CancellationToken ct = default) + { + var results = await _phoneSVMUnitService.GetSVMUnits(ct); + return Ok(results); + } + + /// + /// Adds data to a unit for the SVM Phone List. + /// This affects both SVMUnit and SVMUnitPerson. + /// + [HttpPost("units/{unitId}")] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task AddUnitData(int unitId, SVMUnitDataRequest request, CancellationToken ct = default) + { + try + { + await _phoneSVMUnitService.AddOrUpdateUnitData(unitId, request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Updates data in a unit for the SVM Phone List. + /// This affects both SVMUnit and SVMUnitPerson. + /// + [HttpPut("units/{unitId}")] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task UpdateUnitData(int unitId, SVMUnitDataRequest request, CancellationToken ct = default) + { + try + { + await _phoneSVMUnitService.AddOrUpdateUnitData(unitId, request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Deletes one row of the SVM list, identified by the row key the list renders. + /// This may delete multiple SVMUnitPerson. + /// Handled this way to match the end user experience and wrap multiple + /// deletions in a transaction. + /// + [HttpDelete("rows/{entryId}")] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task DeleteUnitRow(int entryId, CancellationToken ct = default) + { + try + { + await _phoneSVMUnitService.DeleteUnitRow(entryId, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + } +} diff --git a/web/Areas/Personnel/Models/AugmentedViperPerson.cs b/web/Areas/Personnel/Models/AugmentedViperPerson.cs new file mode 100644 index 000000000..a7725fdee --- /dev/null +++ b/web/Areas/Personnel/Models/AugmentedViperPerson.cs @@ -0,0 +1,22 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Class for combining ViperPerson and PhonePerson results for name searches. + /// + public class AugmentedViperPerson + { + public int PersonId { get; set; } + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; + public string IamId { get; set; } = string.Empty; + public required bool CurrentEmployee { get; set; } + public string MailId { get; set; } = string.Empty; + public PhonePerson? PhoneData { get; set; } + + public void AddPhoneData(PhonePerson phonePerson) + { + this.PhoneData = phonePerson; + } + } +} diff --git a/web/Areas/Personnel/Models/PersonnelMapper.cs b/web/Areas/Personnel/Models/PersonnelMapper.cs new file mode 100644 index 000000000..71797c45b --- /dev/null +++ b/web/Areas/Personnel/Models/PersonnelMapper.cs @@ -0,0 +1,13 @@ +using Riok.Mapperly.Abstractions; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Mapperly mapper to create an AugmentedViperPerson from a ViperPerson. + /// + [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.None)] + public static partial class PersonnelMapper + { + public static partial AugmentedViperPerson ToAugmentedViperPerson(ViperPerson source); + } +} diff --git a/web/Areas/Personnel/Models/PhoneList.cs b/web/Areas/Personnel/Models/PhoneList.cs new file mode 100644 index 000000000..f56a35a8f --- /dev/null +++ b/web/Areas/Personnel/Models/PhoneList.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.PhoneList, + /// a (typically unit/department-level) grouping for phone numbers. + /// + public class PhoneList + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int PhoneListId { get; set; } + + /** + * Stable lookup key used in routes and API paths (e.g. "VMDO"). + * Allows changing Name without breaking links. + */ + public required string Code { get; set; } + public required string Name { get; set; } + public required string MaintainRole { get; set; } + + public virtual ICollection PhoneListUnits { get; set; } = []; + } +} diff --git a/web/Areas/Personnel/Models/PhoneListInfo.cs b/web/Areas/Personnel/Models/PhoneListInfo.cs new file mode 100644 index 000000000..18b30abad --- /dev/null +++ b/web/Areas/Personnel/Models/PhoneListInfo.cs @@ -0,0 +1,16 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// What a client needs to render a phone list before it fetches any rows: the display name + /// plus the caller's own capabilities. Allows the client to render correctly + /// based on the permissions that will be enforced on the back end. + /// + public class PhoneListInfo + { + public int PhoneListId { get; set; } + public required string Code { get; set; } + public required string Name { get; set; } + public bool CanMaintain { get; set; } + public bool CanViewDirectPhone { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/PhoneListUnit.cs b/web/Areas/Personnel/Models/PhoneListUnit.cs new file mode 100644 index 000000000..ff0b73b9f --- /dev/null +++ b/web/Areas/Personnel/Models/PhoneListUnit.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.PhoneListUnit, + /// a grouping within a phone list. + /// + public class PhoneListUnit + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int PhoneListUnitId { get; set; } + public required int PhoneListId { get; set; } + public required string Name { get; set; } + public int? SortOrder { get; set; } + + public virtual PhoneList PhoneList { get; set; } = null!; + public virtual ICollection PhoneListUnitPersons { get; set; } = []; + } +} diff --git a/web/Areas/Personnel/Models/PhoneListUnitDataRequests.cs b/web/Areas/Personnel/Models/PhoneListUnitDataRequests.cs new file mode 100644 index 000000000..1ac8a0fd6 --- /dev/null +++ b/web/Areas/Personnel/Models/PhoneListUnitDataRequests.cs @@ -0,0 +1,15 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Class for form data to create or edit a row in unit-specific phone list tables. + /// + public class PhoneListUnitDataRequest + { + public required int UnitId { get; set; } + public string Office { get; set; } = ""; + public required string EmployeeIam { get; set; } + public string Phone { get; set; } = ""; + public string DirectPhone { get; set; } = ""; + public bool ListFirst { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/PhoneListUnitPerson.cs b/web/Areas/Personnel/Models/PhoneListUnitPerson.cs new file mode 100644 index 000000000..133b30d00 --- /dev/null +++ b/web/Areas/Personnel/Models/PhoneListUnitPerson.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.PhoneListUnitPerson, + /// connecting people to a given unit for a phone list. + /// + public class PhoneListUnitPerson + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int PhoneListUnitPersonId { get; set; } + public required int PhoneListUnitId { get; set; } + public required string PersonIam { get; set; } + public required bool ListFirst { get; set; } + public required bool IsActive { get; set; } + public string? ModifiedBy { get; set; } + public DateTime? ModifiedDate { get; set; } + + public virtual PhoneListUnit PhoneListUnit { get; set; } = null!; + public virtual PhonePerson Person { get; set; } = null!; + public virtual ViperPerson? ViperModPerson { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/PhonePerson.cs b/web/Areas/Personnel/Models/PhonePerson.cs new file mode 100644 index 000000000..73c182cc4 --- /dev/null +++ b/web/Areas/Personnel/Models/PhonePerson.cs @@ -0,0 +1,21 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents data from phones.Person. + /// Ties a person to phone number and office data. + /// + public class PhonePerson + { + public required string PersonIam { get; set; } + public string? Phone { get; set; } + public string? DirectPhone { get; set; } + public string? Office { get; set; } + public DateTime? ModifiedDate { get; set; } + public string? ModifiedBy { get; set; } + + public virtual ICollection UnitPersons { get; set; } = []; + public virtual ICollection PhoneListUnitPersons { get; set; } = []; + public virtual ViperPerson? ViperPerson { get; set; } + public virtual ViperPerson? ViperModPerson { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/SVMFrequentNumber.cs b/web/Areas/Personnel/Models/SVMFrequentNumber.cs new file mode 100644 index 000000000..0e1da9e46 --- /dev/null +++ b/web/Areas/Personnel/Models/SVMFrequentNumber.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents data from phones.SVMFrequentNumber. + /// Provides a spot for additional important phone + /// numbers not tied to a specific person. + /// + public class SVMFrequentNumber + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int NumberId { get; set; } + public required string Label { get; set; } + public required string Phone { get; set; } + public int? SortOrder { get; set; } + public string? ModifiedBy { get; set; } + public DateTime? ModifiedDate { get; set; } + public bool IsActive { get; set; } + public virtual ViperPerson? ViperModPerson { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/SVMFrequentNumberRequests.cs b/web/Areas/Personnel/Models/SVMFrequentNumberRequests.cs new file mode 100644 index 000000000..89a365e84 --- /dev/null +++ b/web/Areas/Personnel/Models/SVMFrequentNumberRequests.cs @@ -0,0 +1,11 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Class for form data to create or edit a row in the SVM frequently called numbers table. + /// + public class SVMFrequentNumberRequest + { + public required string Label { get; set; } = ""; + public required string Phone { get; set; } = ""; + } +} diff --git a/web/Areas/Personnel/Models/SVMSection.cs b/web/Areas/Personnel/Models/SVMSection.cs new file mode 100644 index 000000000..4901b625b --- /dev/null +++ b/web/Areas/Personnel/Models/SVMSection.cs @@ -0,0 +1,18 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.SVMSection, + /// a grouping for the SVM Phone List. + /// + public class SVMSection + { + public required int SectionId { get; set; } + public string? Name { get; set; } + public bool? IncludeAbbrv { get; set; } + public string? UnitName { get; set; } + public string? DirectorTitle { get; set; } + public int? SortOrder { get; set; } + + public virtual ICollection Units { get; set; } = []; + } +} diff --git a/web/Areas/Personnel/Models/SVMUnit.cs b/web/Areas/Personnel/Models/SVMUnit.cs new file mode 100644 index 000000000..77459a484 --- /dev/null +++ b/web/Areas/Personnel/Models/SVMUnit.cs @@ -0,0 +1,22 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.SVMUnit, + /// a department, unit, or dean's office. + /// + public class SVMUnit + { + public required int UnitId { get; set; } + public required int SectionId { get; set; } + public string? Name { get; set; } + public string? Abbrv { get; set; } + public int? SortOrder { get; set; } + public string? Fax { get; set; } + public string? ModifiedBy { get; set; } + public DateTime? ModifiedDate { get; set; } + + public virtual SVMSection Section { get; set; } = null!; + public virtual ICollection UnitPersons { get; set; } = []; + public virtual ViperPerson? ViperModPerson { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/SVMUnitDataRequests.cs b/web/Areas/Personnel/Models/SVMUnitDataRequests.cs new file mode 100644 index 000000000..d6e64cf08 --- /dev/null +++ b/web/Areas/Personnel/Models/SVMUnitDataRequests.cs @@ -0,0 +1,19 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Class for form data to create or edit a row in the SVM table. + /// + public class SVMUnitDataRequest + { + public string Fax { get; set; } = ""; + public string Location { get; set; } = ""; + public string DeanIam { get; set; } = ""; + public string DeanPhone { get; set; } = ""; + public string DeanInterim { get; set; } = ""; + public int DeanUnitPerson { get; set; } = -1; + public string StaffIam { get; set; } = ""; + public string StaffPhone { get; set; } = ""; + public string StaffInterim { get; set; } = ""; + public int StaffUnitPerson { get; set; } = -1; + } +} diff --git a/web/Areas/Personnel/Models/SVMUnitPerson.cs b/web/Areas/Personnel/Models/SVMUnitPerson.cs new file mode 100644 index 000000000..fa5c08863 --- /dev/null +++ b/web/Areas/Personnel/Models/SVMUnitPerson.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.SVMUnitPerson, + /// connecting people in leadership and admin roles to a + /// given unit. + /// + public class SVMUnitPerson + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int UnitPersonId { get; set; } + public required int UnitId { get; set; } + public required string PersonIam { get; set; } + public string? Office { get; set; } + public string? PosType { get; set; } + public string? Interim { get; set; } + public DateTime? ModifiedDate { get; set; } + public string? ModifiedBy { get; set; } + public bool IsActive { get; set; } + + public virtual SVMUnit Unit { get; set; } = null!; + public virtual PhonePerson Person { get; set; } = null!; + public virtual ViperPerson? ViperModPerson { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/ViperPerson.cs b/web/Areas/Personnel/Models/ViperPerson.cs new file mode 100644 index 000000000..39c11f64a --- /dev/null +++ b/web/Areas/Personnel/Models/ViperPerson.cs @@ -0,0 +1,17 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Read-only entity for accessing users.Person table within PhonesDbContext. + /// Used for joining to get employee names without cross-context queries. + /// + public class ViperPerson + { + public int PersonId { get; set; } + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; + public required string IamId { get; set; } + public required bool CurrentEmployee { get; set; } + public string MailId { get; set; } = string.Empty; + } +} diff --git a/web/Areas/Personnel/PhonesDbContext.cs b/web/Areas/Personnel/PhonesDbContext.cs new file mode 100644 index 000000000..ee695b077 --- /dev/null +++ b/web/Areas/Personnel/PhonesDbContext.cs @@ -0,0 +1,216 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel.Models; + +namespace Viper.Areas.Personnel; + +/// +/// Entity Framework DbContext for the Personnel phone numbers system. +/// All tables are in the [phones] schema in the VIPER database. +/// +public class PhonesDbContext : DbContext +{ + public PhonesDbContext(DbContextOptions options) : base(options) + { + } + + // Core data tables + public virtual DbSet PhonePerson { get; set; } + public virtual DbSet SVMSection { get; set; } + public virtual DbSet SVMUnit { get; set; } + public virtual DbSet SVMUnitPerson { get; set; } + public virtual DbSet SVMFrequentNumber { get; set; } + public virtual DbSet PhoneList { get; set; } + public virtual DbSet PhoneListUnit { get; set; } + public virtual DbSet PhoneListUnitPerson { get; set; } + + // Read-only cross-schema reference (users schema in same database) + public virtual DbSet ViperPerson { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // PhonePerson (phones.Person) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.PersonIam); + entity.ToTable("Person", schema: "phones"); + + entity.Property(e => e.PersonIam).HasColumnName("PersonIam"); + entity.Property(e => e.Phone).HasColumnName("Phone").HasMaxLength(25); + entity.Property(e => e.DirectPhone).HasColumnName("DirectPhone").HasMaxLength(25); + entity.Property(e => e.Office).HasColumnName("Office").HasMaxLength(100); + entity.Property(e => e.ModifiedDate).HasColumnName("ModifiedDate"); + entity.Property(e => e.ModifiedBy).HasColumnName("ModifiedBy"); + + // Cross-schema FK to users.Person + entity.HasOne(e => e.ViperPerson) + .WithMany() + .HasForeignKey(e => e.PersonIam) + .HasPrincipalKey(e => e.IamId) + .IsRequired(); + + entity.HasOne(e => e.ViperModPerson) + .WithMany() + .HasForeignKey(e => e.ModifiedBy) + .HasPrincipalKey(e => e.IamId); + }); + + // SVMSection (phones.SVMSection) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.SectionId); + entity.ToTable("SVMSection", schema: "phones"); + + entity.Property(e => e.SectionId).HasColumnName("SectionId"); + entity.Property(e => e.Name).HasColumnName("Name").HasMaxLength(100); + entity.Property(e => e.IncludeAbbrv).HasColumnName("IncludeAbbrv"); + entity.Property(e => e.UnitName).HasColumnName("UnitName").HasMaxLength(50); + entity.Property(e => e.DirectorTitle).HasColumnName("DirectorTitle").HasMaxLength(50); + entity.Property(e => e.SortOrder).HasColumnName("SortOrder"); + }); + + // SVMUnit (phones.SVMUnit) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.UnitId); + entity.ToTable("SVMUnit", schema: "phones"); + + entity.Property(e => e.UnitId).HasColumnName("UnitId"); + entity.Property(e => e.SectionId).HasColumnName("SectionId"); + entity.Property(e => e.Name).HasColumnName("Name").HasMaxLength(100); + entity.Property(e => e.Fax).HasColumnName("Fax").HasMaxLength(25); + entity.Property(e => e.Abbrv).HasColumnName("Abbrv").HasMaxLength(20); + entity.Property(e => e.SortOrder).HasColumnName("SortOrder"); + entity.Property(e => e.ModifiedDate).HasColumnName("ModifiedDate"); + entity.Property(e => e.ModifiedBy).HasColumnName("ModifiedBy").HasMaxLength(10); + + entity.HasOne(e => e.Section) + .WithMany(s => s.Units) + .HasForeignKey(e => e.SectionId); + + entity.HasOne(e => e.ViperModPerson) + .WithMany() + .HasForeignKey(e => e.ModifiedBy) + .HasPrincipalKey(e => e.IamId); + }); + + // SVMUnitPerson (phones.SVMUnitPerson) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.UnitPersonId); + entity.ToTable("SVMUnitPerson", schema: "phones"); + + entity.Property(e => e.UnitPersonId).HasColumnName("UnitPersonId"); + entity.Property(e => e.UnitId).HasColumnName("UnitId"); + entity.Property(e => e.PersonIam).HasColumnName("PersonIam").HasMaxLength(10); + entity.Property(e => e.Office).HasColumnName("Office").HasMaxLength(50); + entity.Property(e => e.PosType).HasColumnName("PosType").HasMaxLength(25); + entity.Property(e => e.Interim).HasColumnName("Interim").HasMaxLength(10); + entity.Property(e => e.ModifiedDate).HasColumnName("ModifiedDate"); + entity.Property(e => e.ModifiedBy).HasColumnName("ModifiedBy").HasMaxLength(10); + entity.Property(e => e.IsActive).HasColumnName("IsActive"); + + entity.HasOne(e => e.Unit) + .WithMany(s => s.UnitPersons) + .HasForeignKey(e => e.UnitId); + + entity.HasOne(e => e.Person) + .WithMany(s => s.UnitPersons) + .HasForeignKey(e => e.PersonIam); + + entity.HasOne(e => e.ViperModPerson) + .WithMany() + .HasForeignKey(e => e.ModifiedBy) + .HasPrincipalKey(e => e.IamId); + }); + + // SVMFrequentNumber (phones.SVMFrequentNumber) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.NumberId); + entity.ToTable("SVMFrequentNumber", schema: "phones"); + + entity.Property(e => e.NumberId).HasColumnName("NumberId"); + entity.Property(e => e.Label).HasColumnName("Label").HasMaxLength(100); + entity.Property(e => e.Phone).HasColumnName("Phone").HasMaxLength(25); + entity.Property(e => e.SortOrder).HasColumnName("SortOrder"); + entity.Property(e => e.ModifiedDate).HasColumnName("ModifiedDate"); + entity.Property(e => e.ModifiedBy).HasColumnName("ModifiedBy").HasMaxLength(10); + entity.Property(e => e.IsActive).HasColumnName("IsActive"); + + entity.HasOne(e => e.ViperModPerson) + .WithMany() + .HasForeignKey(e => e.ModifiedBy) + .HasPrincipalKey(e => e.IamId); + }); + + // PhoneList (phones.PhoneList) + modelBuilder.Entity
|[^;\n]*)/u, + ) + if (filenameMatch?.groups?.filename) { + filename = filenameMatch.groups.filename.replaceAll(/['"]/gu, "") } } @@ -303,3 +305,4 @@ function downloadBlob(blob: Blob, filename: string): void { } export { useFetch, postForBlob, downloadBlob, HTTP_STATUS } +export type { Result, Pagination } diff --git a/VueApp/src/composables/__tests__/use-person-search.test.ts b/VueApp/src/composables/__tests__/use-person-search.test.ts new file mode 100644 index 000000000..9c8d790cc --- /dev/null +++ b/VueApp/src/composables/__tests__/use-person-search.test.ts @@ -0,0 +1,82 @@ +import { usePersonSearch } from "../use-person-search" + +type Person = { iamId: string; fullName: string } + +type Deferred = { promise: Promise; resolve: (value: T) => void } + +// A controllable pending promise, for simulating a search that hasn't resolved yet. The `as` +// cast lets `resolve` be filled in by the executor without a separate uninitialized declaration. +function createDeferred(): Deferred { + const deferred = {} as Deferred + // eslint-disable-next-line avoid-new -- a controllable pending promise is the point of this helper + deferred.promise = new Promise((resolve) => { + deferred.resolve = resolve + }) + return deferred +} + +function applyUpdate(fn: () => void): void { + fn() +} + +function runFilter(searchPeople: (val: string, update: (fn: () => void) => void) => Promise, val: string) { + return searchPeople(val, applyUpdate) +} + +describe("usePersonSearch()", () => { + it("clears options without calling search when the term is below two characters", async () => { + expect.hasAssertions() + const search = vi.fn<(value: string) => Promise>() + const { searchPeople, options, loading } = usePersonSearch(search) + options.value = [{ iamId: "a", fullName: "Existing Person" }] + + await runFilter(searchPeople, "a") + + expect(search).not.toHaveBeenCalled() + expect(options.value).toStrictEqual([]) + expect(loading.value).toBeFalsy() + }) + + it("sets loading and populates options for a valid search", async () => { + expect.hasAssertions() + const results: Person[] = [{ iamId: "person01", fullName: "Amy Smith" }] + const search = vi.fn<(value: string) => Promise>().mockResolvedValue(results) + const { searchPeople, options } = usePersonSearch(search) + + await runFilter(searchPeople, " ab ") + + expect(search).toHaveBeenCalledWith("ab") + expect(options.value).toStrictEqual(results) + }) + + it("falls back to an empty list when search resolves null", async () => { + expect.hasAssertions() + const search = vi.fn<(value: string) => Promise>().mockResolvedValue(null) + const { searchPeople, options } = usePersonSearch(search) + + await runFilter(searchPeople, "ab") + + expect(options.value).toStrictEqual([]) + }) + + it("discards a slower, earlier response that resolves after a newer search", async () => { + expect.hasAssertions() + const first = createDeferred() + const second = createDeferred() + const search = vi + .fn<(value: string) => Promise>() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + const { searchPeople, options } = usePersonSearch(search) + + const firstFilter = runFilter(searchPeople, "first") + const secondFilter = runFilter(searchPeople, "second") + + second.resolve([{ iamId: "second", fullName: "Second Result" }]) + await secondFilter + first.resolve([{ iamId: "first", fullName: "First Result" }]) + await firstFilter + + expect(options.value).toStrictEqual([{ iamId: "second", fullName: "Second Result" }]) + }) +}) diff --git a/VueApp/src/composables/use-person-search.ts b/VueApp/src/composables/use-person-search.ts new file mode 100644 index 000000000..56634b78c --- /dev/null +++ b/VueApp/src/composables/use-person-search.ts @@ -0,0 +1,41 @@ +import { ref } from "vue" + +/** + * Debounced, out-of-order-safe server search for a QSelect's @filter handler. Shared by every + * PersonSelector variant (CMS, Personnel): the search/race-guard logic is identical across them, + * only the search function and result type differ per caller. + */ +function usePersonSearch(search: (value: string) => Promise) { + const options = ref([]) + const loading = ref(false) + // Guards against out-of-order responses: only the latest search may update options + let searchSeq = 0 + + async function searchPeople(val: string, update: (fn: () => void) => void) { + if (val.trim().length < 2) { + // Invalidate any in-flight search too, or its late response would repopulate + // the options we just cleared. + searchSeq += 1 + loading.value = false + update(() => { + options.value = [] + }) + return + } + searchSeq += 1 + const seq = searchSeq + loading.value = true + const result = await search(val.trim()) + if (seq !== searchSeq) { + return + } + loading.value = false + update(() => { + options.value = result ?? [] + }) + } + + return { options, loading, searchPeople } +} + +export { usePersonSearch } diff --git a/VueApp/vueapp.esproj b/VueApp/vueapp.esproj index 8513a0c6b..1c514331e 100644 --- a/VueApp/vueapp.esproj +++ b/VueApp/vueapp.esproj @@ -18,7 +18,9 @@ + + - \ No newline at end of file + diff --git a/test/Classes/Utilities/PersonSearchHelperTests.cs b/test/Classes/Utilities/PersonSearchHelperTests.cs new file mode 100644 index 000000000..3eaaa3971 --- /dev/null +++ b/test/Classes/Utilities/PersonSearchHelperTests.cs @@ -0,0 +1,137 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Classes.Utilities; + +namespace Viper.test.Classes.Utilities; + +/// +/// Tests for PersonSearchHelper, the shared "search current people by partial name" query shape +/// used by both the CMS file/permission pickers and the Personnel phone directory. A regression +/// here affects every autocomplete built on it. +/// +public class PersonSearchHelperTests +{ + private sealed class Person + { + public required string LastName { get; set; } + public required string FirstName { get; set; } + public string LoginId { get; set; } = ""; + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("a")] + [InlineData(" a ")] + public void Normalize_ReturnsNull_WhenBelowMinimumLength(string? search) + { + Assert.Null(PersonSearchHelper.Normalize(search)); + } + + [Fact] + public void Normalize_ReturnsTrimmedValue_WhenAtOrAboveMinimumLength() + { + var result = PersonSearchHelper.Normalize(" ab "); + + Assert.Equal("ab", result); + } + + [Fact] + public void NameMatches_MatchesLastCommaFirstForm() + { + var people = new[] { new Person { LastName = "Smith", FirstName = "Amy" } }.AsQueryable(); + var predicate = PersonSearchHelper.NameMatches(p => p.LastName, p => p.FirstName, "mith, A"); + + var results = people.Where(predicate).ToList(); + + Assert.Single(results); + } + + [Fact] + public void NameMatches_MatchesFirstSpaceLastForm() + { + var people = new[] { new Person { LastName = "Smith", FirstName = "Amy" } }.AsQueryable(); + var predicate = PersonSearchHelper.NameMatches(p => p.LastName, p => p.FirstName, "Amy Sm"); + + var results = people.Where(predicate).ToList(); + + Assert.Single(results); + } + + [Fact] + public void NameMatches_ExcludesNonMatchingPeople() + { + var people = new[] + { + new Person { LastName = "Smith", FirstName = "Amy" }, + new Person { LastName = "Jones", FirstName = "Bob" }, + }.AsQueryable(); + var predicate = PersonSearchHelper.NameMatches(p => p.LastName, p => p.FirstName, "Smith"); + + var results = people.Where(predicate).ToList(); + + var match = Assert.Single(results); + Assert.Equal("Smith", match.LastName); + } + + [Fact] + public void OrderAndCap_OrdersByLastNameThenFirstName_AndCapsToMaxResults() + { + var people = Enumerable.Range(0, 30) + .Select(i => new Person { LastName = $"Person{i:D2}", FirstName = "X" }) + .Reverse() + .AsQueryable(); + + var results = PersonSearchHelper.OrderAndCap(people, p => p.LastName, p => p.FirstName).ToList(); + + Assert.Equal(PersonSearchHelper.MaxResults, results.Count); + Assert.Equal("Person00", results[0].LastName); + Assert.Equal("Person24", results[^1].LastName); + } + + [Fact] + public void Or_IncludesRecordsMatchingEitherPredicate() + { + var people = new[] + { + new Person { LastName = "Smith", FirstName = "Amy", LoginId = "asmith" }, + new Person { LastName = "Jones", FirstName = "Bob", LoginId = "bjones" }, + }.AsQueryable(); + var namePredicate = PersonSearchHelper.NameMatches(p => p.LastName, p => p.FirstName, "Smith"); + var combined = namePredicate.Or(p => p.LoginId == "bjones"); + + var results = people.Where(combined).ToList(); + + Assert.Equal(2, results.Count); + } + + private sealed class SearchTestContext(DbContextOptions options) : DbContext(options) + { + public DbSet People => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity().HasKey(p => p.LastName); + } + + [Fact] + public void NameMatches_EmitsASqlParameter_RatherThanALiteral() + { + // These autocompletes fire per keystroke, so a term embedded as a literal would give every + // distinct search its own query plan. The literal form also drops the ESCAPE clause, which + // is what stops a typed % or _ from being treated as a wildcard. + var options = new DbContextOptionsBuilder() + .UseSqlServer("Server=none;Database=none;Trusted_Connection=True;") + .Options; + using var context = new SearchTestContext(options); + + var sql = context.People + .Where(PersonSearchHelper.NameMatches(p => p.LastName, p => p.FirstName, "smith")) + .ToQueryString(); + + // Assert on the predicate, not the whole string: ToQueryString prefixes a DECLARE that + // spells the value out for copy-paste even when the query itself is parameterized. + Assert.Contains("LIKE @", sql, StringComparison.Ordinal); + Assert.DoesNotContain("LIKE N'", sql, StringComparison.Ordinal); + Assert.Contains("ESCAPE", sql, StringComparison.Ordinal); + } +} diff --git a/test/Personnel/PhoneListControllerTests.cs b/test/Personnel/PhoneListControllerTests.cs new file mode 100644 index 000000000..dfeeb5e00 --- /dev/null +++ b/test/Personnel/PhoneListControllerTests.cs @@ -0,0 +1,165 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneListController. GetListInfo is what the client renders from before +/// it fetches any rows, so the two capability flags it reports have to match what the write and +/// read endpoints actually enforce: CanMaintain follows the list's own MaintainRole, and +/// CanViewDirectPhone is deliberately broader - a member of the list sees direct numbers without +/// being able to edit it. A flag that overstated either would show the client controls the API +/// then refuses. +/// +public sealed class PhoneListControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhoneListController _controller; + + private const string CallerIam = "caller01"; + private const string VmdoRole = "SVMSecure.PhoneLists.VMDOMaintain"; + + public PhoneListControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + var rapsContext = Substitute.For(); + var permissionsService = new PhonePermissionsService(rapsContext, _userHelper); + var phoneListService = new PhoneListService(_context); + var unitService = new PhoneListUnitService(_context, _userHelper, permissionsService); + + _controller = new PhoneListController(phoneListService, unitService, permissionsService); + + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 1, + Code = "VMDO", + Name = "Dean's Office", + MaintainRole = VmdoRole, + }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 1, PhoneListId = 1, Name = "Front Office" }); + _context.PhonePerson.Add(new PhonePerson { PersonIam = CallerIam, Phone = "530-555-1000" }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + private void GrantRole(string role) + { + _userHelper.HasPermission(Arg.Any(), Arg.Any(), role).Returns(true); + } + + /// Puts the caller on the list itself, which is not the same as maintaining it. + private void AddCallerToList() + { + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitPersonId = 1, + PhoneListUnitId = 1, + PersonIam = CallerIam, + ListFirst = false, + IsActive = true, + }); + _context.SaveChanges(); + } + + private async Task GetInfo(string code) + { + var result = await _controller.GetListInfo(code, TestContext.Current.CancellationToken); + var okResult = Assert.IsType(result.Result); + return Assert.IsType(okResult.Value); + } + + [Fact] + public async Task GetListInfo_ReturnsTheListIdentity() + { + var info = await GetInfo("VMDO"); + + Assert.Equal(1, info.PhoneListId); + Assert.Equal("VMDO", info.Code); + Assert.Equal("Dean's Office", info.Name); + } + + [Fact] + public async Task GetListInfo_ReportsNoCapabilities_ForACallerWithNeitherRoleNorMembership() + { + var info = await GetInfo("VMDO"); + + Assert.False(info.CanMaintain); + Assert.False(info.CanViewDirectPhone); + } + + [Fact] + public async Task GetListInfo_ReportsBothCapabilities_ForAMaintainer() + { + GrantRole(VmdoRole); + + var info = await GetInfo("VMDO"); + + Assert.True(info.CanMaintain); + Assert.True(info.CanViewDirectPhone); + } + + [Fact] + public async Task GetListInfo_ReportsDirectPhoneOnly_ForAMemberWhoCannotMaintain() + { + // Membership is what grants the direct-number view, so the two flags have to move + // independently: reporting CanMaintain here would offer edit controls the API refuses. + AddCallerToList(); + + var info = await GetInfo("VMDO"); + + Assert.False(info.CanMaintain); + Assert.True(info.CanViewDirectPhone); + } + + [Fact] + public async Task GetListInfo_IgnoresMembershipOfAnotherList() + { + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 2, + Code = "OTHER", + Name = "Some Other Unit", + MaintainRole = "SVMSecure.PhoneLists.OtherMaintain", + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + AddCallerToList(); + + var info = await GetInfo("OTHER"); + + Assert.False(info.CanViewDirectPhone); + } + + [Fact] + public async Task GetListInfo_ReturnsNotFound_ForAnUnknownCode() + { + var result = await _controller.GetListInfo("NOPE", TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } +} diff --git a/test/Personnel/PhoneListModifiedDateControllerTests.cs b/test/Personnel/PhoneListModifiedDateControllerTests.cs new file mode 100644 index 000000000..f97e34392 --- /dev/null +++ b/test/Personnel/PhoneListModifiedDateControllerTests.cs @@ -0,0 +1,140 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneListModifiedDateController, the endpoint clients poll to decide +/// whether their cached copy of a list is stale. Two properties matter: deleted rows still count +/// (a removal is a change the client has to pick up, and soft-deleted rows are the only record of +/// it), and the date is scoped to the list named in the route. +/// +public sealed class PhoneListModifiedDateControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneListModifiedDateController _controller; + + private static readonly DateTime Older = new(2026, 1, 1, 9, 0, 0, DateTimeKind.Local); + private static readonly DateTime Newer = new(2026, 6, 1, 9, 0, 0, DateTimeKind.Local); + + public PhoneListModifiedDateControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + var userHelper = Substitute.For(); + userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = "caller01", + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = "caller01", + }); + + var rapsContext = Substitute.For(); + var permissionsService = new PhonePermissionsService(rapsContext, userHelper); + var phoneListService = new PhoneListService(_context); + var unitService = new PhoneListUnitService(_context, userHelper, permissionsService); + + _controller = new PhoneListModifiedDateController(phoneListService, unitService); + + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 1, + Code = "VMDO", + Name = "Dean's Office", + MaintainRole = "SVMSecure.PhoneLists.VMDOMaintain", + }); + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 2, + Code = "OTHER", + Name = "Some Other Unit", + MaintainRole = "SVMSecure.PhoneLists.OtherMaintain", + }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 1, PhoneListId = 1, Name = "Front Office" }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 2, PhoneListId = 2, Name = "Other Office" }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + private void AddRow(int unitPersonId, int unitId, DateTime? modifiedDate, bool isActive = true) + { + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitPersonId = unitPersonId, + PhoneListUnitId = unitId, + PersonIam = "person01", + ListFirst = false, + IsActive = isActive, + ModifiedDate = modifiedDate, + }); + _context.SaveChanges(); + } + + private async Task GetDate(string code) + { + var result = await _controller.GetLastModifiedDate(code, TestContext.Current.CancellationToken); + var okResult = Assert.IsType(result.Result); + return (DateTime?)okResult.Value; + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsTheMostRecentDate() + { + AddRow(unitPersonId: 1, unitId: 1, Older); + AddRow(unitPersonId: 2, unitId: 1, Newer); + + Assert.Equal(Newer, await GetDate("VMDO")); + } + + [Fact] + public async Task GetLastModifiedDate_CountsDeletedRows() + { + // A removal is the change most likely to matter to a client holding stale rows, and the + // soft-deleted row is the only record that it happened. + AddRow(unitPersonId: 1, unitId: 1, Older); + AddRow(unitPersonId: 2, unitId: 1, Newer, isActive: false); + + Assert.Equal(Newer, await GetDate("VMDO")); + } + + [Fact] + public async Task GetLastModifiedDate_IgnoresAnotherListsRows() + { + AddRow(unitPersonId: 1, unitId: 1, Older); + AddRow(unitPersonId: 2, unitId: 2, Newer); + + Assert.Equal(Older, await GetDate("VMDO")); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsNull_WhenNothingHasBeenModified() + { + AddRow(unitPersonId: 1, unitId: 1, modifiedDate: null); + + Assert.Null(await GetDate("VMDO")); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsNotFound_ForAnUnknownCode() + { + var result = await _controller.GetLastModifiedDate("NOPE", TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } +} diff --git a/test/Personnel/PhoneListServiceTests.cs b/test/Personnel/PhoneListServiceTests.cs new file mode 100644 index 000000000..0401a9277 --- /dev/null +++ b/test/Personnel/PhoneListServiceTests.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhoneListService, the entry point every list-scoped request resolves a list +/// through. Lookup is by Code rather than Name so that renaming a list for display cannot +/// break the routes and API paths that address it. +/// +public sealed class PhoneListServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneListService _service; + + public PhoneListServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + _service = new PhoneListService(_context); + } + + public void Dispose() => _context.Dispose(); + + private void SeedLists() + { + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 1, + Code = "VMDO", + Name = "Dean's Office", + MaintainRole = "SVMSecure.PhoneLists.VMDOMaintain", + }); + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 2, + Code = "OTHER", + Name = "Some Other Unit", + MaintainRole = "SVMSecure.PhoneLists.OtherMaintain", + }); + _context.SaveChanges(); + } + + [Fact] + public async Task GetListByCode_ReturnsMatchingList() + { + SeedLists(); + + var result = await _service.GetListByCode("OTHER", TestContext.Current.CancellationToken); + + Assert.Equal(2, result.PhoneListId); + Assert.Equal("Some Other Unit", result.Name); + } + + [Fact] + public async Task GetListByCode_ResolvesIndependentlyOfDisplayName() + { + SeedLists(); + var list = await _context.PhoneList.SingleAsync(l => l.Code == "VMDO", TestContext.Current.CancellationToken); + list.Name = "Office of the Dean"; + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _service.GetListByCode("VMDO", TestContext.Current.CancellationToken); + + Assert.Equal(1, result.PhoneListId); + } + + [Fact] + public async Task GetListByCode_Throws_WhenCodeNotFound() + { + await Assert.ThrowsAsync( + () => _service.GetListByCode("NOPE", TestContext.Current.CancellationToken)); + } +} diff --git a/test/Personnel/PhoneListUnitControllerTests.cs b/test/Personnel/PhoneListUnitControllerTests.cs new file mode 100644 index 000000000..cea934723 --- /dev/null +++ b/test/Personnel/PhoneListUnitControllerTests.cs @@ -0,0 +1,234 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneListUnitController. Beyond the InvalidOperationException-to-400 +/// mapping, these cover the authorization model: write access is the role named by the target +/// list's own MaintainRole column, so holding one list's role must grant nothing on another, +/// and a record id from one list must not be reachable through another list's route. +/// +public sealed class PhoneListUnitControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhoneListUnitController _controller; + + private const string CallerIam = "caller01"; + private const string VmdoRole = "SVMSecure.PhoneLists.VMDOMaintain"; + private const string OtherRole = "SVMSecure.PhoneLists.OtherMaintain"; + + public PhoneListUnitControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + var rapsContext = Substitute.For(); + var permissionsService = new PhonePermissionsService(rapsContext, _userHelper); + var phoneListService = new PhoneListService(_context); + var unitService = new PhoneListUnitService(_context, _userHelper, permissionsService); + + _controller = new PhoneListUnitController(phoneListService, unitService, permissionsService); + + // Two lists, each with its own unit and its own maintain role. + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 1, + Code = "VMDO", + Name = "Dean's Office", + MaintainRole = VmdoRole, + }); + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 2, + Code = "OTHER", + Name = "Some Other Unit", + MaintainRole = OtherRole, + }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 1, PhoneListId = 1, Name = "Front Office" }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 2, PhoneListId = 2, Name = "Other Office" }); + _context.PhonePerson.Add(new PhonePerson { PersonIam = "person01", Phone = "530-555-1000" }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + /// Grants the caller exactly one maintain role. + private void GrantRole(string role) + { + _userHelper.HasPermission(Arg.Any(), Arg.Any(), role).Returns(true); + } + + private void AddUnitPersonRow(int unitPersonId, int unitId) + { + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitPersonId = unitPersonId, + PhoneListUnitId = unitId, + PersonIam = "person01", + ListFirst = false, + IsActive = true, + }); + _context.SaveChanges(); + } + + private static PhoneListUnitDataRequest Request(int unitId) => new() + { + UnitId = unitId, + EmployeeIam = "person01", + Phone = "530-555-1000", + DirectPhone = "530-555-2000", + Office = "Room 100", + }; + + [Fact] + public async Task GetUnits_ReturnsOk_WithUnitsForTheNamedList() + { + var result = await _controller.GetUnits("VMDO", TestContext.Current.CancellationToken); + + var okResult = Assert.IsType(result.Result); + var units = Assert.IsAssignableFrom>(okResult.Value); + Assert.Equal("Front Office", Assert.Single(units).Name); + } + + [Fact] + public async Task GetUnits_ReturnsNotFound_ForAnUnknownCode() + { + var result = await _controller.GetUnits("NOPE", TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task AddUnitPersonData_ReturnsOk_ForAMaintainerOfThatList() + { + GrantRole(VmdoRole); + + var result = await _controller.AddUnitPersonData("VMDO", Request(1), TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task AddUnitPersonData_IsForbidden_WithoutTheRoleForThatList() + { + var result = await _controller.AddUnitPersonData("VMDO", Request(1), TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task AddUnitPersonData_IsForbidden_ForAMaintainerOfADifferentList() + { + // Holding VMDOMaintain must not confer write access to the OTHER list, which is what a + // hard-coded permission attribute on the endpoint would have allowed. + GrantRole(VmdoRole); + + var result = await _controller.AddUnitPersonData("OTHER", Request(2), TestContext.Current.CancellationToken); + + Assert.IsType(result); + Assert.Empty(await _context.PhoneListUnitPerson.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task AddUnitPersonData_ReturnsBadRequest_WhenTheUnitBelongsToAnotherList() + { + // Unit 2 is on the OTHER list; routing through VMDO must not reach it. + GrantRole(VmdoRole); + + var result = await _controller.AddUnitPersonData("VMDO", Request(2), TestContext.Current.CancellationToken); + + Assert.IsType(result); + Assert.Empty(await _context.PhoneListUnitPerson.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task UpdateUnitPersonData_ReturnsBadRequest_WhenNotFound() + { + GrantRole(VmdoRole); + + var result = await _controller.UpdateUnitPersonData("VMDO", 999, Request(1), TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task UpdateUnitPersonData_ReturnsBadRequest_WhenTheRecordBelongsToAnotherList() + { + AddUnitPersonRow(unitPersonId: 5, unitId: 2); + GrantRole(VmdoRole); + + var result = await _controller.UpdateUnitPersonData("VMDO", 5, Request(1), TestContext.Current.CancellationToken); + + Assert.IsType(result); + var untouched = await _context.PhoneListUnitPerson + .FindAsync(new object?[] { 5 }, TestContext.Current.CancellationToken); + Assert.NotNull(untouched); + Assert.True(untouched.IsActive); + } + + [Fact] + public async Task DeleteUnitPersonData_ReturnsBadRequest_WhenNotFound() + { + GrantRole(VmdoRole); + + var result = await _controller.DeleteUnitPersonData("VMDO", 999, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteUnitPersonData_ReturnsOk_AndSoftDeletes_WhenFound() + { + AddUnitPersonRow(unitPersonId: 1, unitId: 1); + GrantRole(VmdoRole); + + var result = await _controller.DeleteUnitPersonData("VMDO", 1, TestContext.Current.CancellationToken); + + Assert.IsType(result); + var stillExists = await _context.PhoneListUnitPerson + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(stillExists); + Assert.False(stillExists.IsActive); + } + + [Fact] + public async Task DeleteUnitPersonData_LeavesTheRecordAlone_WhenItBelongsToAnotherList() + { + AddUnitPersonRow(unitPersonId: 5, unitId: 2); + GrantRole(VmdoRole); + + var result = await _controller.DeleteUnitPersonData("VMDO", 5, TestContext.Current.CancellationToken); + + Assert.IsType(result); + var untouched = await _context.PhoneListUnitPerson + .FindAsync(new object?[] { 5 }, TestContext.Current.CancellationToken); + Assert.NotNull(untouched); + Assert.True(untouched.IsActive); + } +} diff --git a/test/Personnel/PhoneListUnitServiceTests.cs b/test/Personnel/PhoneListUnitServiceTests.cs new file mode 100644 index 000000000..e7a89769d --- /dev/null +++ b/test/Personnel/PhoneListUnitServiceTests.cs @@ -0,0 +1,406 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhoneListUnitService, focused on the direct-phone visibility rule: +/// a caller may only see DirectPhone for a list if they hold the list's maintain +/// permission, OR they are themselves an active member of that list. +/// +public sealed class PhoneListUnitServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhoneListUnitService _service; + + private const string MaintainRole = "SVMSecure.PhoneLists.VMDOMaintain"; + private const string CallerIam = "caller01"; + + public PhoneListUnitServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + // rapsContext is unused when IUserHelper.HasPermission is mocked directly, + // so a bare substitute (no seeded roles) is sufficient here. + var rapsContext = Substitute.For(); + var permissionsService = new PhonePermissionsService(rapsContext, _userHelper); + + _service = new PhoneListUnitService(_context, _userHelper, permissionsService); + + SeedList(); + } + + public void Dispose() => _context.Dispose(); + + /// The seeded list, as the controller would hand it to the service. + private PhoneList TestList() => + _context.PhoneList.Single(l => l.PhoneListId == 1); + + private void AddUnit(int unitId, string name) + { + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = unitId, PhoneListId = 1, Name = name }); + _context.SaveChanges(); + } + + /// Puts a person on a unit, with the phone row the read paths expect them to have. + private void AddMember(int unitPersonId, int unitId, string personIam, bool listFirst) + { + _context.PhonePerson.Add(new PhonePerson { PersonIam = personIam, Phone = "530-555-0000" }); + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitPersonId = unitPersonId, + PhoneListUnitId = unitId, + PersonIam = personIam, + ListFirst = listFirst, + IsActive = true, + }); + _context.SaveChanges(); + } + + private async Task FindMember(int unitPersonId) => + await _context.PhoneListUnitPerson.FindAsync(new object?[] { unitPersonId }, TestContext.Current.CancellationToken); + + private void SeedList() + { + _context.PhoneList.Add(new PhoneList { PhoneListId = 1, Code = "VMDO", Name = "Dean's Office", MaintainRole = MaintainRole }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 1, PhoneListId = 1, Name = "Dean's Office" }); + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 1, + IamId = "listedperson", + FirstName = "Listed", + LastName = "Person", + FullName = "Listed Person", + CurrentEmployee = true, + }); + _context.PhonePerson.Add(new PhonePerson + { + PersonIam = "listedperson", + Phone = "530-555-1000", + DirectPhone = "530-555-2000", + Office = "Room 100", + }); + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitId = 1, + PersonIam = "listedperson", + ListFirst = false, + IsActive = true, + }); + _context.SaveChanges(); + } + + [Fact] + public async Task GetPhoneListUnits_MasksDirectPhone_WhenCallerHasNoAccess() + { + // Caller has neither the maintain permission nor a membership row on this list. + _userHelper.HasPermission(Arg.Any(), Arg.Any(), MaintainRole).Returns(false); + + var units = await _service.GetPhoneListUnits(TestList(), TestContext.Current.CancellationToken); + + var person = Assert.Single(Assert.Single(units).PhoneListUnitPersons); + Assert.Equal("530-555-1000", person.Person.Phone); + Assert.Equal("", person.Person.DirectPhone); + } + + [Fact] + public async Task GetPhoneListUnits_ShowsDirectPhone_WhenCallerIsListMember() + { + // No maintain permission, but the caller is themselves an active member of the list - + // membership alone should be enough to unlock direct numbers for that list. + _userHelper.HasPermission(Arg.Any(), Arg.Any(), MaintainRole).Returns(false); + _context.PhonePerson.Add(new PhonePerson { PersonIam = CallerIam, Phone = "", DirectPhone = "", Office = "" }); + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitId = 1, + PersonIam = CallerIam, + ListFirst = false, + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var units = await _service.GetPhoneListUnits(TestList(), TestContext.Current.CancellationToken); + + var person = Assert.Single(units.Single().PhoneListUnitPersons, p => p.PersonIam == "listedperson"); + Assert.Equal("530-555-2000", person.Person.DirectPhone); + } + + [Fact] + public async Task AddUnitPersonData_UnsetsPreviousListFirst_WhenNewPersonIsMarkedFirst() + { + var existingFirstPerson = Assert.Single(_context.PhoneListUnitPerson); + existingFirstPerson.ListFirst = true; + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 2, + IamId = "newperson", + FirstName = "New", + LastName = "Person", + FullName = "New Person", + CurrentEmployee = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = "newperson", + Phone = "530-555-5000", + DirectPhone = "530-555-6000", + Office = "Room 300", + ListFirst = true, + }; + + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + + Assert.False(existingFirstPerson.ListFirst); + var newPerson = await _context.PhoneListUnitPerson + .SingleAsync(p => p.PersonIam == "newperson", TestContext.Current.CancellationToken); + Assert.True(newPerson.ListFirst); + } + + [Fact] + public async Task AddUnitPersonData_CalledTwiceForSamePerson_UpsertsInsteadOfDuplicating() + { + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = "listedperson", + Phone = "530-555-9000", + DirectPhone = "530-555-9001", + Office = "Room 900", + ListFirst = false, + }; + + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + + var associations = await _context.PhoneListUnitPerson + .Where(p => p.PersonIam == "listedperson" && p.PhoneListUnitId == 1) + .ToListAsync(TestContext.Current.CancellationToken); + Assert.Single(associations); + + var phonePerson = await _context.PhonePerson + .SingleAsync(p => p.PersonIam == "listedperson", TestContext.Current.CancellationToken); + Assert.Equal("530-555-9000", phonePerson.Phone); + Assert.Equal("530-555-9001", phonePerson.DirectPhone); + Assert.Equal("Room 900", phonePerson.Office); + } + + [Fact] + public async Task AddUnitPersonData_TrimsWhitespace_ForANewPerson() + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 3, + IamId = "paddedperson", + FirstName = "Padded", + LastName = "Person", + FullName = "Padded Person", + CurrentEmployee = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = " paddedperson ", + Phone = " 530-555-9500 ", + DirectPhone = " 530-555-9501 ", + Office = " Room 950 ", + ListFirst = false, + }; + + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + + var phonePerson = await _context.PhonePerson + .SingleAsync(p => p.PersonIam == "paddedperson", TestContext.Current.CancellationToken); + Assert.Equal("530-555-9500", phonePerson.Phone); + Assert.Equal("Room 950", phonePerson.Office); + } + + [Fact] + public async Task AddUnitPersonData_CalledTwiceWithAPaddedIam_UpsertsInsteadOfDuplicating() + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 4, + IamId = "paddedtwice", + FirstName = "Padded", + LastName = "Twice", + FullName = "Padded Twice", + CurrentEmployee = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + // The existing-row lookup has to trim the same way the insert does. Matching a padded + // request against the trimmed PersonIam already stored finds nothing, so every resubmit + // would add another association row for the same person and unit. + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = " paddedtwice ", + Phone = " 530-555-9600 ", + DirectPhone = " 530-555-9601 ", + Office = " Room 960 ", + ListFirst = false, + }; + + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + + var association = Assert.Single(await _context.PhoneListUnitPerson + .Where(p => p.PersonIam == "paddedtwice" && p.PhoneListUnitId == 1) + .ToListAsync(TestContext.Current.CancellationToken)); + Assert.True(association.IsActive); + Assert.Equal("paddedtwice", association.PersonIam); + } + + [Fact] + public async Task UpdateUnitPersonData_UpdatesThePhonePerson_AndModifiedMetadata() + { + var unitPerson = Assert.Single(_context.PhoneListUnitPerson); + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = "listedperson", + Phone = " 530-555-7000 ", + DirectPhone = " 530-555-7001 ", + Office = " Room 700 ", + }; + + await _service.UpdateUnitPersonData( + 1, unitPerson.PhoneListUnitPersonId, request, TestContext.Current.CancellationToken); + + var phonePerson = await _context.PhonePerson + .FindAsync(new object?[] { "listedperson" }, TestContext.Current.CancellationToken); + Assert.NotNull(phonePerson); + Assert.Equal("530-555-7000", phonePerson.Phone); + Assert.Equal("530-555-7001", phonePerson.DirectPhone); + Assert.Equal("Room 700", phonePerson.Office); + Assert.Equal(CallerIam, unitPerson.ModifiedBy); + Assert.NotNull(unitPerson.ModifiedDate); + } + + [Fact] + public async Task UpdateUnitPersonData_ClearsListFirstOnTheRecordsOwnUnit_NotTheRequestedOne() + { + // The request carries a UnitId, but the record already knows which unit it lives in. Only + // the record's own unit may be cleared: taking the caller's word for it would let a + // mismatched UnitId unset the first-listed entry of an unrelated unit. + AddUnit(unitId: 2, name: "Other Office"); + AddMember(unitPersonId: 10, unitId: 1, personIam: "sameunitfirst", listFirst: true); + AddMember(unitPersonId: 20, unitId: 2, personIam: "otherunitfirst", listFirst: true); + var target = await _context.PhoneListUnitPerson + .SingleAsync(p => p.PersonIam == "listedperson", TestContext.Current.CancellationToken); + + var request = new PhoneListUnitDataRequest + { + UnitId = 2, + EmployeeIam = "listedperson", + Phone = "530-555-7000", + ListFirst = true, + }; + + await _service.UpdateUnitPersonData( + 1, target.PhoneListUnitPersonId, request, TestContext.Current.CancellationToken); + + Assert.True(target.ListFirst); + Assert.False((await FindMember(10))!.ListFirst); + Assert.True((await FindMember(20))!.ListFirst); + } + + [Fact] + public async Task UpdateUnitPersonData_LeavesTheExistingFirstEntry_WhenListFirstIsNotSet() + { + AddMember(unitPersonId: 10, unitId: 1, personIam: "sameunitfirst", listFirst: true); + var target = await _context.PhoneListUnitPerson + .SingleAsync(p => p.PersonIam == "listedperson", TestContext.Current.CancellationToken); + + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = "listedperson", + Phone = "530-555-7000", + ListFirst = false, + }; + + await _service.UpdateUnitPersonData( + 1, target.PhoneListUnitPersonId, request, TestContext.Current.CancellationToken); + + Assert.False(target.ListFirst); + Assert.True((await FindMember(10))!.ListFirst); + } + + [Fact] + public async Task DeleteUnitPersonData_SoftDeletes_KeepsRowButMarksInactive() + { + var unitPerson = Assert.Single(_context.PhoneListUnitPerson); + + await _service.DeleteUnitPersonData(1, unitPerson.PhoneListUnitPersonId, TestContext.Current.CancellationToken); + + var stillExists = await _context.PhoneListUnitPerson + .FindAsync(new object?[] { unitPerson.PhoneListUnitPersonId }, TestContext.Current.CancellationToken); + Assert.NotNull(stillExists); + Assert.False(stillExists.IsActive); + Assert.Equal(CallerIam, stillExists.ModifiedBy); + } + + [Fact] + public async Task DeleteUnitPersonData_Throws_WhenNotFound() + { + await Assert.ThrowsAsync( + () => _service.DeleteUnitPersonData(1, 9999, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task EditingAnAlreadyDeletedRecord_ReportsItAsRemoved_RatherThanResurrectingIt() + { + var unitPerson = Assert.Single(_context.PhoneListUnitPerson); + await _service.DeleteUnitPersonData(1, unitPerson.PhoneListUnitPersonId, TestContext.Current.CancellationToken); + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = "listedperson", + Phone = "530-555-9000", + }; + + // A maintainer whose page predates someone else's delete. Saving must not bring the row + // back, and the message becomes an error banner, so it is worded for that reader. + var ex = await Assert.ThrowsAsync( + () => _service.UpdateUnitPersonData( + 1, unitPerson.PhoneListUnitPersonId, request, TestContext.Current.CancellationToken)); + Assert.Equal("That record has already been removed.", ex.Message); + + var stillDeleted = await _context.PhoneListUnitPerson + .FindAsync(new object?[] { unitPerson.PhoneListUnitPersonId }, TestContext.Current.CancellationToken); + Assert.NotNull(stillDeleted); + Assert.False(stillDeleted.IsActive); + } +} diff --git a/test/Personnel/PhonePersonControllerTests.cs b/test/Personnel/PhonePersonControllerTests.cs new file mode 100644 index 000000000..cd692c5b1 --- /dev/null +++ b/test/Personnel/PhonePersonControllerTests.cs @@ -0,0 +1,204 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhonePersonController, the person picker behind the phone-record dialogs. +/// The controller does the merge itself rather than delegating it: two independent queries (people +/// from users.Person, phone rows from phones.Person) are joined in memory, so the cases worth +/// pinning are the ones the join can get wrong - a person with no phone row, a phone row with no +/// matching person - plus the direct-number masking, which depends on a list code supplied by the +/// caller and so must fail closed when that code is absent or bogus. +/// +public sealed class PhonePersonControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhonePersonController _controller; + + private const string VmdoRole = "SVMSecure.PhoneLists.VMDOMaintain"; + + public PhonePersonControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = "caller01", + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = "caller01", + }); + + var rapsContext = Substitute.For(); + var permissionsService = new PhonePermissionsService(rapsContext, _userHelper); + var phoneListService = new PhoneListService(_context); + var lookupService = new PhonePersonLookupService(_context, permissionsService); + + _controller = new PhonePersonController(phoneListService, lookupService); + + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 1, + Code = "VMDO", + Name = "Dean's Office", + MaintainRole = VmdoRole, + }); + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 1, + IamId = "person01", + FirstName = "Ada", + LastName = "Smithers", + FullName = "Ada Smithers", + CurrentEmployee = true, + MailId = "asmithers", + }); + _context.PhonePerson.Add(new PhonePerson + { + PersonIam = "person01", + Phone = "530-555-1000", + DirectPhone = "530-555-2000", + }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + private void GrantRole(string role) + { + _userHelper.HasPermission(Arg.Any(), Arg.Any(), role).Returns(true); + } + + private async Task> Search(string search, string? listCode = null) + { + var result = await _controller.GetCurrentEmployees(search, listCode, TestContext.Current.CancellationToken); + var okResult = Assert.IsType(result.Result); + return Assert.IsAssignableFrom>(okResult.Value); + } + + [Fact] + public async Task GetCurrentEmployees_MergesPhoneDataOntoTheMatchedPerson() + { + var results = await Search("Smithers"); + + var person = Assert.Single(results); + Assert.Equal("person01", person.IamId); + Assert.Equal("Ada Smithers", person.FullName); + Assert.NotNull(person.PhoneData); + Assert.Equal("530-555-1000", person.PhoneData.Phone); + } + + [Fact] + public async Task GetCurrentEmployees_MasksDirectPhone_WhenNoListIsNamed() + { + var results = await Search("Smithers"); + + Assert.Equal("", Assert.Single(results).PhoneData?.DirectPhone); + } + + [Fact] + public async Task GetCurrentEmployees_MasksDirectPhone_ForANonMaintainer() + { + var results = await Search("Smithers", "VMDO"); + + Assert.Equal("", Assert.Single(results).PhoneData?.DirectPhone); + } + + [Fact] + public async Task GetCurrentEmployees_ReturnsDirectPhone_ForAMaintainerOfTheNamedList() + { + GrantRole(VmdoRole); + + var results = await Search("Smithers", "VMDO"); + + Assert.Equal("530-555-2000", Assert.Single(results).PhoneData?.DirectPhone); + } + + [Fact] + public async Task GetCurrentEmployees_MasksDirectPhone_ForAnUnknownListCode() + { + // An unresolvable code drops to "no list", not "no permission check": holding the role + // must not be enough on its own, since the code is caller-supplied. + GrantRole(VmdoRole); + + var results = await Search("Smithers", "NOPE"); + + Assert.Equal("", Assert.Single(results).PhoneData?.DirectPhone); + } + + [Fact] + public async Task GetCurrentEmployees_ReturnsThePerson_WhenTheyHaveNoPhoneRow() + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 2, + IamId = "person02", + FirstName = "Bo", + LastName = "Smithfield", + FullName = "Bo Smithfield", + CurrentEmployee = true, + MailId = "bsmithfield", + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await Search("Smithfield"); + + var person = Assert.Single(results); + Assert.Equal("person02", person.IamId); + Assert.Null(person.PhoneData); + } + + [Fact] + public async Task GetCurrentEmployees_IgnoresPhoneRowsWithNoMatchingPerson() + { + // phones.Person outlives users.Person entries, so an orphaned phone row must not + // materialize as a pickable person. + _context.PhonePerson.Add(new PhonePerson { PersonIam = "ghost01", Phone = "530-555-9999" }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await Search("Smithers"); + + Assert.Equal("person01", Assert.Single(results).IamId); + } + + [Fact] + public async Task GetCurrentEmployees_ExcludesFormerEmployees() + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 3, + IamId = "person03", + FirstName = "Cy", + LastName = "Smithson", + FullName = "Cy Smithson", + CurrentEmployee = false, + MailId = "csmithson", + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + Assert.Empty(await Search("Smithson")); + } + + [Fact] + public async Task GetCurrentEmployees_ReturnsEmpty_ForASearchTermBelowTheMinimumLength() + { + Assert.Empty(await Search("S")); + } +} diff --git a/test/Personnel/PhonePersonLookupServiceTests.cs b/test/Personnel/PhonePersonLookupServiceTests.cs new file mode 100644 index 000000000..8b458288e --- /dev/null +++ b/test/Personnel/PhonePersonLookupServiceTests.cs @@ -0,0 +1,157 @@ +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhonePersonLookupService: GetPhonePeople implements the same +/// permission-based DirectPhone masking as PhoneListUnitService, but as an independent code +/// path used by the person-picker autocomplete, so a regression there wouldn't be caught by +/// the PhoneListUnitService tests. GetViperCurrentEmployees layers PersonSearchHelper on top +/// of the CurrentEmployee filter. +/// +public sealed class PhonePersonLookupServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhonePersonLookupService _service; + + private const string MaintainRole = "SVMSecure.PhoneLists.VMDOMaintain"; + private const string CallerIam = "caller01"; + + public PhonePersonLookupServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + var rapsContext = Substitute.For(); + var permissionsService = new PhonePermissionsService(rapsContext, _userHelper); + _service = new PhonePersonLookupService(_context, permissionsService); + + _context.PhoneList.Add(new PhoneList { PhoneListId = 1, Code = "VMDO", Name = "Dean's Office", MaintainRole = MaintainRole }); + _context.PhonePerson.Add(new PhonePerson + { + PersonIam = "person01", + Phone = "530-555-1000", + DirectPhone = "530-555-2000", + }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + /// The seeded list, as the controller would hand it to the service. + private PhoneList TestList() => + _context.PhoneList.Single(l => l.PhoneListId == 1); + + [Fact] + public async Task GetPhonePeople_MasksDirectPhone_WhenNoListSupplied() + { + var results = await _service.GetPhonePeople(["person01"], ct: TestContext.Current.CancellationToken); + + var person = Assert.Single(results); + Assert.Equal("", person.DirectPhone); + } + + [Fact] + public async Task GetPhonePeople_MasksDirectPhone_WhenCallerLacksMaintainAccessToList() + { + _userHelper.HasPermission(Arg.Any(), Arg.Any(), MaintainRole).Returns(false); + + var results = await _service.GetPhonePeople(["person01"], TestList(), ct: TestContext.Current.CancellationToken); + + var person = Assert.Single(results); + Assert.Equal("", person.DirectPhone); + } + + [Fact] + public async Task GetPhonePeople_ShowsDirectPhone_WhenCallerHasMaintainAccessToList() + { + _userHelper.HasPermission(Arg.Any(), Arg.Any(), MaintainRole).Returns(true); + + var results = await _service.GetPhonePeople(["person01"], TestList(), ct: TestContext.Current.CancellationToken); + + var person = Assert.Single(results); + Assert.Equal("530-555-2000", person.DirectPhone); + } + + [Fact] + public async Task GetPhonePeople_IgnoresBlankAndWhitespaceIamIds() + { + var results = await _service.GetPhonePeople( + ["person01", "", " ", null!], + ct: TestContext.Current.CancellationToken); + + Assert.Single(results); + } + + [Fact] + public async Task GetViperCurrentEmployees_ReturnsEmpty_WhenSearchBelowMinimumLength() + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 1, + IamId = "person01", + FirstName = "Amy", + LastName = "Smith", + FullName = "Amy Smith", + CurrentEmployee = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await _service.GetViperCurrentEmployees("a", TestContext.Current.CancellationToken); + + Assert.Empty(results); + } + + [Fact] + public async Task GetViperCurrentEmployees_ExcludesFormerEmployees() + { + _context.ViperPerson.AddRange( + new ViperPerson + { + PersonId = 1, + IamId = "person01", + FirstName = "Amy", + LastName = "Smith", + FullName = "Amy Smith", + CurrentEmployee = true, + }, + new ViperPerson + { + PersonId = 2, + IamId = "person02", + FirstName = "Amy", + LastName = "Smithson", + FullName = "Amy Smithson", + CurrentEmployee = false, + } + ); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await _service.GetViperCurrentEmployees("Smith", TestContext.Current.CancellationToken); + + var match = Assert.Single(results); + Assert.Equal("person01", match.IamId); + } +} diff --git a/test/Personnel/PhoneSVMFrequentNumberControllerTests.cs b/test/Personnel/PhoneSVMFrequentNumberControllerTests.cs new file mode 100644 index 000000000..c124be591 --- /dev/null +++ b/test/Personnel/PhoneSVMFrequentNumberControllerTests.cs @@ -0,0 +1,101 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller wiring tests for PhoneSVMFrequentNumberController: verifies the +/// InvalidOperationException-to-400 mapping used across the phones endpoints when +/// a maintain action targets a row that doesn't exist (or was already removed). +/// +public sealed class PhoneSVMFrequentNumberControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMFrequentNumberController _controller; + + public PhoneSVMFrequentNumberControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + var userHelper = Substitute.For(); + userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = "caller01", + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = "caller01", + }); + + _controller = new PhoneSVMFrequentNumberController(new PhoneSVMFrequentNumberService(_context, userHelper)); + } + + public void Dispose() => _context.Dispose(); + + [Fact] + public async Task UpdateFrequentNumber_ReturnsBadRequest_WhenNotFound() + { + var request = new SVMFrequentNumberRequest { Label = "Front Desk", Phone = "530-555-1000" }; + + var result = await _controller.UpdateFrequentNumber(999, request, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteFrequentNumber_ReturnsBadRequest_WhenNotFound() + { + var result = await _controller.DeleteFrequentNumber(999, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteFrequentNumber_ReturnsOk_AndSoftDeletes_WhenFound() + { + _context.SVMFrequentNumber.Add(new SVMFrequentNumber + { + NumberId = 1, + Label = "Pharmacy", + Phone = "530-555-2000", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _controller.DeleteFrequentNumber(1, TestContext.Current.CancellationToken); + + Assert.IsType(result); + var stillExists = await _context.SVMFrequentNumber + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(stillExists); + Assert.False(stillExists.IsActive); + } + + [Fact] + public async Task GetFrequentNumbers_ReturnsOnlyActiveNumbers() + { + _context.SVMFrequentNumber.AddRange( + new SVMFrequentNumber { NumberId = 1, Label = "Active Line", Phone = "1", IsActive = true }, + new SVMFrequentNumber { NumberId = 2, Label = "Retired Line", Phone = "2", IsActive = false } + ); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _controller.GetFrequentNumbers(TestContext.Current.CancellationToken); + + var okResult = Assert.IsType(result.Result); + var numbers = Assert.IsAssignableFrom>(okResult.Value); + Assert.Equal(["Active Line"], numbers.Select(n => n.Label)); + } +} diff --git a/test/Personnel/PhoneSVMFrequentNumberServiceTests.cs b/test/Personnel/PhoneSVMFrequentNumberServiceTests.cs new file mode 100644 index 000000000..629f64bf2 --- /dev/null +++ b/test/Personnel/PhoneSVMFrequentNumberServiceTests.cs @@ -0,0 +1,201 @@ +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhoneSVMFrequentNumberService, focused on the soft-delete +/// convention (rows are marked inactive rather than removed, so ModifiedDate keeps +/// tracking when the list last changed) and the SQL Server 2016-safe null-last +/// SortOrder ordering. +/// +public sealed class PhoneSVMFrequentNumberServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhoneSVMFrequentNumberService _service; + + private const string CallerIam = "caller01"; + + public PhoneSVMFrequentNumberServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + _service = new PhoneSVMFrequentNumberService(_context, _userHelper); + } + + public void Dispose() => _context.Dispose(); + + private void SeedNumber(string label, string phone, bool isActive = true) + { + _context.SVMFrequentNumber.Add(new SVMFrequentNumber + { + NumberId = 1, + Label = label, + Phone = phone, + IsActive = isActive, + }); + _context.SaveChanges(); + } + + private async Task FindNumber(int numberId) => + await _context.SVMFrequentNumber.FindAsync(new object?[] { numberId }, TestContext.Current.CancellationToken); + + [Fact] + public async Task AddFrequentNumber_SetsIsActiveTrue_AndModifiedMetadata() + { + var request = new SVMFrequentNumberRequest { Label = "Front Desk", Phone = "530-555-1000" }; + + await _service.AddFrequentNumber(request, TestContext.Current.CancellationToken); + + var saved = await _context.SVMFrequentNumber + .SingleAsync(n => n.Label == "Front Desk", TestContext.Current.CancellationToken); + Assert.True(saved.IsActive); + Assert.Equal(CallerIam, saved.ModifiedBy); + Assert.NotNull(saved.ModifiedDate); + } + + [Fact] + public async Task DeleteFrequentNumber_SoftDeletes_ExcludesRowFromGetSVMFrequentNumbers() + { + _context.SVMFrequentNumber.Add(new SVMFrequentNumber + { + NumberId = 1, + Label = "Pharmacy", + Phone = "530-555-2000", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + await _service.DeleteFrequentNumber(1, TestContext.Current.CancellationToken); + + var stillExists = await _context.SVMFrequentNumber + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(stillExists); + Assert.False(stillExists.IsActive); + + var activeNumbers = await _service.GetSVMFrequentNumbers(TestContext.Current.CancellationToken); + Assert.Empty(activeNumbers); + } + + [Fact] + public async Task DeleteFrequentNumber_Throws_WhenAlreadyInactive() + { + _context.SVMFrequentNumber.Add(new SVMFrequentNumber + { + NumberId = 1, + Label = "Retired Line", + Phone = "530-555-3000", + IsActive = false, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync( + () => _service.DeleteFrequentNumber(1, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task UpdateFrequentNumber_OverwritesFields_AndModifiedMetadata() + { + SeedNumber(label: "Front Desk", phone: "530-555-1000"); + var request = new SVMFrequentNumberRequest { Label = "Reception", Phone = "530-555-4000" }; + + await _service.UpdateFrequentNumber(1, request, TestContext.Current.CancellationToken); + + var saved = await FindNumber(1); + Assert.NotNull(saved); + Assert.Equal("Reception", saved.Label); + Assert.Equal("530-555-4000", saved.Phone); + Assert.Equal(CallerIam, saved.ModifiedBy); + Assert.NotNull(saved.ModifiedDate); + Assert.True(saved.IsActive); + } + + [Fact] + public async Task UpdateFrequentNumber_TrimsWhitespace() + { + SeedNumber(label: "Front Desk", phone: "530-555-1000"); + var request = new SVMFrequentNumberRequest { Label = " Reception ", Phone = " 530-555-4000 " }; + + await _service.UpdateFrequentNumber(1, request, TestContext.Current.CancellationToken); + + var saved = await FindNumber(1); + Assert.NotNull(saved); + Assert.Equal("Reception", saved.Label); + Assert.Equal("530-555-4000", saved.Phone); + } + + [Theory] + [InlineData("", "530-555-4000", "Location must not be empty.")] + // Whitespace-only rather than empty: the guard is IsNullOrWhiteSpace, and a bare "" would + // still pass if it were ever weakened to IsNullOrEmpty. + [InlineData(" ", "530-555-4000", "Location must not be empty.")] + [InlineData("Reception", "", "Phone Number must not be empty.")] + [InlineData("Reception", " ", "Phone Number must not be empty.")] + public async Task UpdateFrequentNumber_Throws_ForBlankFields(string label, string phone, string expectedMessage) + { + SeedNumber(label: "Front Desk", phone: "530-555-1000"); + var request = new SVMFrequentNumberRequest { Label = label, Phone = phone }; + + var ex = await Assert.ThrowsAsync( + () => _service.UpdateFrequentNumber(1, request, TestContext.Current.CancellationToken)); + + Assert.Equal(expectedMessage, ex.Message); + var unchanged = await FindNumber(1); + Assert.NotNull(unchanged); + Assert.Equal("Front Desk", unchanged.Label); + } + + [Fact] + public async Task UpdateFrequentNumber_Throws_WhenTheRowWasAlreadyDeleted() + { + // A maintainer whose page predates someone else's delete. Editing must not resurrect the + // row, which the IsActive half of the guard is what prevents - an id-only lookup would + // find the soft-deleted record and happily write to it. + SeedNumber(label: "Retired Line", phone: "530-555-3000", isActive: false); + var request = new SVMFrequentNumberRequest { Label = "Reception", Phone = "530-555-4000" }; + + await Assert.ThrowsAsync( + () => _service.UpdateFrequentNumber(1, request, TestContext.Current.CancellationToken)); + + var stillDeleted = await FindNumber(1); + Assert.NotNull(stillDeleted); + Assert.False(stillDeleted.IsActive); + Assert.Equal("Retired Line", stillDeleted.Label); + } + + [Fact] + public async Task GetSVMFrequentNumbers_OrdersRowsWithNoSortOrderLast() + { + _context.SVMFrequentNumber.AddRange( + new SVMFrequentNumber { NumberId = 1, Label = "Zebra Unsorted", Phone = "1", IsActive = true, SortOrder = null }, + new SVMFrequentNumber { NumberId = 2, Label = "Pharmacy", Phone = "2", IsActive = true, SortOrder = 2 }, + new SVMFrequentNumber { NumberId = 3, Label = "Front Desk", Phone = "3", IsActive = true, SortOrder = 1 } + ); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await _service.GetSVMFrequentNumbers(TestContext.Current.CancellationToken); + + Assert.Equal(["Front Desk", "Pharmacy", "Zebra Unsorted"], results.Select(r => r.Label)); + } +} diff --git a/test/Personnel/PhoneSVMModifiedDateControllerTests.cs b/test/Personnel/PhoneSVMModifiedDateControllerTests.cs new file mode 100644 index 000000000..531370460 --- /dev/null +++ b/test/Personnel/PhoneSVMModifiedDateControllerTests.cs @@ -0,0 +1,130 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneSVMModifiedDateController, which clients poll to decide whether their +/// cached SVM list is stale. The SVM page renders two independently-maintained datasets - frequent +/// numbers and unit people - behind one freshness date, so the endpoint has to report the later of +/// the two and stay correct when either side has never been modified. Reporting the earlier one +/// would leave a client believing its copy is current. +/// +public sealed class PhoneSVMModifiedDateControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMModifiedDateController _controller; + + private static readonly DateTime Older = new(2026, 1, 1, 9, 0, 0, DateTimeKind.Local); + private static readonly DateTime Newer = new(2026, 6, 1, 9, 0, 0, DateTimeKind.Local); + + public PhoneSVMModifiedDateControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + var userHelper = Substitute.For(); + userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = "caller01", + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = "caller01", + }); + + _controller = new PhoneSVMModifiedDateController( + new PhoneSVMFrequentNumberService(_context, userHelper), + new PhoneSVMUnitService(_context, userHelper)); + + _context.SVMUnit.Add(new SVMUnit { UnitId = 1, SectionId = 1, Name = "Dean's Office" }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + private void AddFrequentNumber(DateTime? modifiedDate) + { + _context.SVMFrequentNumber.Add(new SVMFrequentNumber + { + NumberId = 1, + Label = "Front Desk", + Phone = "530-555-1000", + IsActive = true, + ModifiedDate = modifiedDate, + }); + _context.SaveChanges(); + } + + private void AddUnitPerson(DateTime? modifiedDate) + { + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + ModifiedDate = modifiedDate, + }); + _context.SaveChanges(); + } + + private async Task GetDate() + { + var result = await _controller.GetLastModifiedDate(TestContext.Current.CancellationToken); + var okResult = Assert.IsType(result.Result); + return (DateTime?)okResult.Value; + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsNull_WhenNeitherDatasetHasBeenModified() + { + Assert.Null(await GetDate()); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsTheUnitPersonDate_WhenNoFrequentNumberHasOne() + { + AddUnitPerson(Newer); + + Assert.Equal(Newer, await GetDate()); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsTheFrequentNumberDate_WhenNoUnitPersonHasOne() + { + AddFrequentNumber(Newer); + + Assert.Equal(Newer, await GetDate()); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsTheUnitPersonDate_WhenItIsTheLater() + { + AddFrequentNumber(Older); + AddUnitPerson(Newer); + + Assert.Equal(Newer, await GetDate()); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsTheFrequentNumberDate_WhenItIsTheLater() + { + AddFrequentNumber(Newer); + AddUnitPerson(Older); + + Assert.Equal(Newer, await GetDate()); + } +} diff --git a/test/Personnel/PhoneSVMSectionControllerTests.cs b/test/Personnel/PhoneSVMSectionControllerTests.cs new file mode 100644 index 000000000..80775977e --- /dev/null +++ b/test/Personnel/PhoneSVMSectionControllerTests.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneSVMSectionController. Sections are the page's top-level grouping, so +/// the order they come back in is the order the page renders; unsorted sections fall to the end +/// alphabetically rather than jumping to the front on a null SortOrder. +/// +public sealed class PhoneSVMSectionControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMSectionController _controller; + + public PhoneSVMSectionControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + _controller = new PhoneSVMSectionController(new PhoneSVMSectionService(_context)); + } + + public void Dispose() => _context.Dispose(); + + private async Task> GetSections() + { + var result = await _controller.GetSections(TestContext.Current.CancellationToken); + var okResult = Assert.IsType(result.Result); + return Assert.IsAssignableFrom>(okResult.Value); + } + + [Fact] + public async Task GetSections_ReturnsSortedSectionsBeforeUnsortedOnes() + { + _context.SVMSection.AddRange( + new SVMSection { SectionId = 1, Name = "Anatomy", SortOrder = null }, + new SVMSection { SectionId = 2, Name = "Dean's Office", SortOrder = 1 }, + new SVMSection { SectionId = 3, Name = "Zoology", SortOrder = 2 } + ); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var sections = await GetSections(); + + Assert.Equal(["Dean's Office", "Zoology", "Anatomy"], sections.Select(s => s.Name)); + } + + [Fact] + public async Task GetSections_ReturnsNotFound_WhenThereAreNoSections() + { + var result = await _controller.GetSections(TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } +} diff --git a/test/Personnel/PhoneSVMSectionServiceTests.cs b/test/Personnel/PhoneSVMSectionServiceTests.cs new file mode 100644 index 000000000..dfd484fa8 --- /dev/null +++ b/test/Personnel/PhoneSVMSectionServiceTests.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhoneSVMSectionService, covering the SQL Server 2016-safe null-last +/// SortOrder ordering convention shared with PhoneListUnitService and +/// PhoneSVMFrequentNumberService. +/// +public sealed class PhoneSVMSectionServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMSectionService _service; + + public PhoneSVMSectionServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + _service = new PhoneSVMSectionService(_context); + } + + public void Dispose() => _context.Dispose(); + + [Fact] + public async Task GetSVMSections_ReturnsEmptyList_WhenNoSectionsExist() + { + var results = await _service.GetSVMSections(TestContext.Current.CancellationToken); + + Assert.Empty(results); + } + + [Fact] + public async Task GetSVMSections_OrdersRowsWithNoSortOrderLast() + { + _context.SVMSection.AddRange( + new SVMSection { SectionId = 1, Name = "Zebra Unsorted", SortOrder = null }, + new SVMSection { SectionId = 2, Name = "Registrar", SortOrder = 2 }, + new SVMSection { SectionId = 3, Name = "Dean's Office", SortOrder = 1 } + ); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await _service.GetSVMSections(TestContext.Current.CancellationToken); + + Assert.Equal(["Dean's Office", "Registrar", "Zebra Unsorted"], results.Select(r => r.Name)); + } +} diff --git a/test/Personnel/PhoneSVMUnitControllerTests.cs b/test/Personnel/PhoneSVMUnitControllerTests.cs new file mode 100644 index 000000000..8946163f4 --- /dev/null +++ b/test/Personnel/PhoneSVMUnitControllerTests.cs @@ -0,0 +1,206 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneSVMUnitController: the read shape the SVM page renders from, and the +/// InvalidOperationException-to-400 mapping on the three maintain endpoints. Unlike the per-list +/// controllers, write access here is a fixed role on the endpoint rather than a per-row lookup, so +/// what the controller itself decides is narrower - which makes the failure mapping the thing +/// worth pinning, since a 500 here would surface to a maintainer as an unexplained error banner +/// instead of the message the service wrote for them. +/// +public sealed class PhoneSVMUnitControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMUnitController _controller; + + private const string CallerIam = "caller01"; + + public PhoneSVMUnitControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + _context = new PhonesDbContext(options); + + var userHelper = Substitute.For(); + userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + _controller = new PhoneSVMUnitController(new PhoneSVMUnitService(_context, userHelper)); + + _context.SVMUnit.Add(new SVMUnit { UnitId = 1, SectionId = 1, Name = "Dean's Office" }); + // The read projection joins phones.Person to users.Person on a required relationship, so + // a phone row without its person is invisible to GetUnits. + AddPerson(personId: 1, "dean01", "Dinah", "Deanly", "530-555-1000"); + AddPerson(personId: 2, "staff01", "Sam", "Staffly", "530-555-2000"); + _context.SaveChanges(); + } + + private void AddPerson(int personId, string iamId, string firstName, string lastName, string phone) + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = personId, + IamId = iamId, + FirstName = firstName, + LastName = lastName, + FullName = $"{firstName} {lastName}", + CurrentEmployee = true, + }); + _context.PhonePerson.Add(new PhonePerson { PersonIam = iamId, Phone = phone }); + } + + public void Dispose() => _context.Dispose(); + + private void AddUnitPerson(int unitPersonId, string personIam, string posType, bool isActive = true) + { + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = unitPersonId, + UnitId = 1, + PersonIam = personIam, + PosType = posType, + IsActive = isActive, + }); + _context.SaveChanges(); + } + + private static SVMUnitDataRequest Request() => new() + { + Fax = "530-555-3000", + Location = "Room 100", + DeanIam = "dean01", + DeanPhone = "530-555-1000", + }; + + private async Task FindUnitPerson(int unitPersonId) => + await _context.SVMUnitPerson.FindAsync(new object?[] { unitPersonId }, TestContext.Current.CancellationToken); + + [Fact] + public async Task GetUnits_ReturnsOk_WithActivePeopleOnly() + { + AddUnitPerson(unitPersonId: 1, "dean01", "Dean"); + AddUnitPerson(unitPersonId: 2, "staff01", "Staff", isActive: false); + + var result = await _controller.GetUnits(TestContext.Current.CancellationToken); + + var okResult = Assert.IsType(result.Result); + var units = Assert.IsAssignableFrom>(okResult.Value); + var unit = Assert.Single(units); + Assert.Equal("dean01", Assert.Single(unit.UnitPersons).PersonIam); + } + + [Fact] + public async Task GetUnits_ReturnsOk_WithAnEmptyListWhenThereAreNoUnits() + { + // An empty SVM list is a legitimate state, not a 404: the page still renders its sections. + _context.SVMUnit.RemoveRange(_context.SVMUnit); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _controller.GetUnits(TestContext.Current.CancellationToken); + + var okResult = Assert.IsType(result.Result); + Assert.Empty(Assert.IsAssignableFrom>(okResult.Value)); + } + + [Fact] + public async Task AddUnitData_ReturnsBadRequest_ForAnUnknownUnit() + { + var result = await _controller.AddUnitData(999, Request(), TestContext.Current.CancellationToken); + + Assert.IsType(result); + Assert.Empty(await _context.SVMUnitPerson.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task AddUnitData_ReturnsOk_AndAddsTheLeader() + { + var result = await _controller.AddUnitData(1, Request(), TestContext.Current.CancellationToken); + + Assert.IsType(result); + var added = Assert.Single(await _context.SVMUnitPerson + .Where(p => p.IsActive) + .ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal("dean01", added.PersonIam); + Assert.Equal("Dean", added.PosType); + } + + [Fact] + public async Task UpdateUnitData_ReturnsBadRequest_ForAnUnknownUnit() + { + var result = await _controller.UpdateUnitData(999, Request(), TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task UpdateUnitData_ReturnsOk_AndReplacesTheNamedRow() + { + AddUnitPerson(unitPersonId: 1, "dean01", "Dean"); + var request = Request(); + request.DeanUnitPerson = 1; + + var result = await _controller.UpdateUnitData(1, request, TestContext.Current.CancellationToken); + + Assert.IsType(result); + var replaced = await FindUnitPerson(1); + Assert.NotNull(replaced); + Assert.False(replaced.IsActive); + Assert.Equal("Room 100", Assert.Single(await _context.SVMUnitPerson + .Where(p => p.IsActive) + .ToListAsync(TestContext.Current.CancellationToken)).Office); + } + + [Fact] + public async Task DeleteUnitRow_ReturnsBadRequest_WhenNotFound() + { + var result = await _controller.DeleteUnitRow(999, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteUnitRow_ReturnsBadRequest_WhenTheRowWasAlreadyRemoved() + { + // The realistic way to miss through the UI: two maintainers on the same list. + AddUnitPerson(unitPersonId: 1, "dean01", "Dean", isActive: false); + + var result = await _controller.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + var badRequest = Assert.IsType(result); + Assert.Equal("That record has already been removed.", badRequest.Value); + } + + [Fact] + public async Task DeleteUnitRow_ReturnsOk_AndSoftDeletes_WhenFound() + { + AddUnitPerson(unitPersonId: 1, "dean01", "Dean"); + + var result = await _controller.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + Assert.IsType(result); + var stillExists = await FindUnitPerson(1); + Assert.NotNull(stillExists); + Assert.False(stillExists.IsActive); + } +} diff --git a/test/Personnel/PhoneSVMUnitServiceTests.cs b/test/Personnel/PhoneSVMUnitServiceTests.cs new file mode 100644 index 000000000..3a87ad664 --- /dev/null +++ b/test/Personnel/PhoneSVMUnitServiceTests.cs @@ -0,0 +1,516 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhoneSVMUnitService, covering the two places its row handling is asymmetric. +/// DeleteUnitRow: a row is a leader plus the unit-wide admin staff, so deleting it removes the +/// leader and then the staff only once no other row still lists them - in one transaction, since +/// as separate per-record requests the pair could half-apply. +/// AddOrUpdateUnitData: one method serves both POST and PUT, so add and edit are distinguished +/// only by DeanUnitPerson/StaffUnitPerson - unset (-1) means "add another person to this unit" +/// and leaves existing rows alone, while a real id means "replace the person on that row" and +/// must deactivate it even though the incoming DeanIam/StaffIam no longer names its occupant. +/// +public sealed class PhoneSVMUnitServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMUnitService _service; + + private const string CallerIam = "caller01"; + + public PhoneSVMUnitServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + _context = new PhonesDbContext(options); + + var userHelper = Substitute.For(); + userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + _service = new PhoneSVMUnitService(_context, userHelper); + } + + public void Dispose() => _context.Dispose(); + + private void SeedUnit() + { + _context.SVMUnit.Add(new SVMUnit { UnitId = 1, SectionId = 1, Name = "Dean's Office" }); + _context.PhonePerson.Add(new PhonePerson { PersonIam = "dean01", Phone = "530-555-1000" }); + _context.PhonePerson.Add(new PhonePerson { PersonIam = "staff01", Phone = "530-555-2000" }); + _context.SaveChanges(); + } + + + [Fact] + public async Task DeleteUnitRow_SoftDeletesTheLeader_AndTheStaffItWasTheLastRowFor() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + // One call, not one per underlying record: the caller names the row, the service decides + // which records that covers. + await _service.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + Assert.Empty(await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive) + .ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DeleteUnitRow_KeepsTheStaff_WhenAnotherLeaderRowStillListsThem() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "dean02", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 3, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + await _service.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + var staffRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 3 }, TestContext.Current.CancellationToken); + Assert.NotNull(staffRow); + Assert.True(staffRow.IsActive); + + var survivingLeader = await _context.SVMUnitPerson + .FindAsync(new object?[] { 2 }, TestContext.Current.CancellationToken); + Assert.NotNull(survivingLeader); + Assert.True(survivingLeader.IsActive); + } + + [Fact] + public async Task DeleteUnitRow_RemovesTheStaff_WhenNamedForAStaffOnlyRow() + { + SeedUnit(); + // A unit with staff but no active leader renders one row keyed by the staff record, so + // that id is what the delete arrives with. + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + await _service.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + var staffRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(staffRow); + Assert.False(staffRow.IsActive); + } + + [Fact] + public async Task DeleteUnitRow_LeavesOtherUnitsAlone() + { + SeedUnit(); + _context.SVMUnit.Add(new SVMUnit { UnitId = 2, SectionId = 1, Name = "Another Unit" }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 2, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + await _service.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + var otherUnitStaff = await _context.SVMUnitPerson + .FindAsync(new object?[] { 2 }, TestContext.Current.CancellationToken); + Assert.NotNull(otherUnitStaff); + Assert.True(otherUnitStaff.IsActive); + } + + [Fact] + public async Task DeleteUnitRow_Throws_WhenNotFound() + { + await Assert.ThrowsAsync( + () => _service.DeleteUnitRow(999, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task AddOrUpdateUnitData_Throws_WhenUnitNotFound() + { + var request = new SVMUnitDataRequest { DeanIam = "dean01", DeanPhone = "530-555-1000" }; + + await Assert.ThrowsAsync( + () => _service.AddOrUpdateUnitData(999, request, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task AddOrUpdateUnitData_DeactivatesPreviousUnitPeople_AndAddsNewActiveRows() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var request = new SVMUnitDataRequest + { + Fax = " 530-555-9999 ", + Location = "Room 500", + DeanIam = "dean01", + DeanPhone = "530-555-1111", + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var oldRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(oldRow); + Assert.False(oldRow.IsActive); + + var newRow = await _context.SVMUnitPerson + .SingleAsync(p => p.IsActive && p.PersonIam == "dean01", TestContext.Current.CancellationToken); + Assert.Equal("Dean", newRow.PosType); + + var unit = await _context.SVMUnit.FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.Equal("530-555-9999", unit!.Fax); + } + + [Fact] + public async Task GetSVMUnits_AlwaysBlanksDirectPhone() + { + SeedUnit(); + var phonePerson = await _context.PhonePerson + .SingleAsync(p => p.PersonIam == "dean01", TestContext.Current.CancellationToken); + phonePerson.DirectPhone = "530-555-4000"; + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 1, + IamId = "dean01", + FirstName = "Dean", + LastName = "Person", + FullName = "Dean Person", + CurrentEmployee = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var units = await _service.GetSVMUnits(TestContext.Current.CancellationToken); + + var person = Assert.Single(Assert.Single(units).UnitPersons); + Assert.Equal("", person.Person.DirectPhone); + } + + [Fact] + public async Task AddOrUpdateUnitData_DeactivatesTheEditedRow_WhenTheDeanIsReplaced() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + // Editing the row for dean01 and choosing dean02 instead. The outgoing person is + // identified only by DeanUnitPerson, since DeanIam now names the incoming person. + var request = new SVMUnitDataRequest + { + Fax = "530-555-9999", + Location = "Room 500", + DeanIam = "dean02", + DeanPhone = "530-555-1112", + DeanUnitPerson = 1, + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var replacedRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(replacedRow); + Assert.False(replacedRow.IsActive); + + var activeLeader = Assert.Single(await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive && p.PosType != "Staff") + .ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal("dean02", activeLeader.PersonIam); + } + + [Fact] + public async Task AddOrUpdateUnitData_DeactivatesTheEditedRow_WhenTheStaffIsReplaced() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var request = new SVMUnitDataRequest + { + Location = "Room 500", + DeanIam = "dean01", + DeanPhone = "530-555-1000", + DeanUnitPerson = 1, + StaffIam = "staff02", + StaffPhone = "530-555-2002", + StaffUnitPerson = 2, + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var replacedRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 2 }, TestContext.Current.CancellationToken); + Assert.NotNull(replacedRow); + Assert.False(replacedRow.IsActive); + + var activeStaff = Assert.Single(await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive && p.PosType == "Staff") + .ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal("staff02", activeStaff.PersonIam); + } + + [Fact] + public async Task AddOrUpdateUnitData_LeavesOtherLeaderRowsActive_WhenOneLeaderIsReplaced() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "dean02", + PosType = "Dean", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + // A unit legitimately has several leader rows; replacing one must not disturb the rest. + var request = new SVMUnitDataRequest + { + Location = "Room 500", + DeanIam = "dean03", + DeanPhone = "530-555-1113", + DeanUnitPerson = 1, + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var untouchedRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 2 }, TestContext.Current.CancellationToken); + Assert.NotNull(untouchedRow); + Assert.True(untouchedRow.IsActive); + + var activeLeaders = await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive && p.PosType != "Staff") + .ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal(2, activeLeaders.Count); + Assert.Contains(activeLeaders, p => p.PersonIam == "dean02"); + Assert.Contains(activeLeaders, p => p.PersonIam == "dean03"); + } + + [Fact] + public async Task AddOrUpdateUnitData_KeepsExistingLeaders_WhenUnitPersonIdsAreUnset() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + // The add path leaves DeanUnitPerson/StaffUnitPerson at their -1 default, which is what + // separates "add another leader to this unit" from "replace the person on this row". + var request = new SVMUnitDataRequest + { + Location = "Room 500", + DeanIam = "dean02", + DeanPhone = "530-555-1112", + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var existingRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(existingRow); + Assert.True(existingRow.IsActive); + + var activeLeaders = await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive && p.PosType != "Staff") + .ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal(2, activeLeaders.Count); + } + + [Fact] + public async Task AddOrUpdateUnitData_RemovesStaff_WhenClearedFromTheEditedRow() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var request = new SVMUnitDataRequest + { + Location = "Room 500", + DeanIam = "dean01", + DeanPhone = "530-555-1000", + DeanUnitPerson = 1, + StaffIam = "", + StaffUnitPerson = 2, + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var clearedRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 2 }, TestContext.Current.CancellationToken); + Assert.NotNull(clearedRow); + Assert.False(clearedRow.IsActive); + + Assert.Empty(await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive && p.PosType == "Staff") + .ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DeleteUnitRow_ReportsAnAlreadyDeletedRow_AsRemoved() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + await _service.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + // A maintainer whose page predates someone else's delete. Deleting again must say so + // rather than silently repeating the cascade over rows that are already gone. + var ex = await Assert.ThrowsAsync( + () => _service.DeleteUnitRow(1, TestContext.Current.CancellationToken)); + Assert.Equal("That record has already been removed.", ex.Message); + } +} diff --git a/web/Areas/CMS/Controllers/CMSOptionsController.cs b/web/Areas/CMS/Controllers/CMSOptionsController.cs index 1a7419353..549f059a6 100644 --- a/web/Areas/CMS/Controllers/CMSOptionsController.cs +++ b/web/Areas/CMS/Controllers/CMSOptionsController.cs @@ -4,6 +4,8 @@ using Viper.Areas.RAPS.Services; using Viper.Classes; using Viper.Classes.SQLContext; +using Viper.Classes.Utilities; +using Viper.Models.AAUD; using Web.Authorization; namespace Viper.Areas.CMS.Controllers @@ -65,22 +67,23 @@ public async Task>> GetPermissions(CancellationToken c [HttpGet("people")] public async Task>> SearchPeople(string search, CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(search) || search.Trim().Length < 2) + var normalizedSearch = PersonSearchHelper.Normalize(search); + if (normalizedSearch == null) { return new List(); } - search = search.Trim(); - return await _aaudContext.AaudUsers + var namePredicate = PersonSearchHelper + .NameMatches(u => u.DisplayLastName, u => u.DisplayFirstName, normalizedSearch) + .Or(u => u.LoginId != null && u.LoginId.Contains(normalizedSearch)) + .Or(u => u.MailId != null && u.MailId.Contains(normalizedSearch)); + + var query = _aaudContext.AaudUsers .AsNoTracking() .Where(u => u.Current != 0 && u.IamId != null) - .Where(u => (u.DisplayLastName + ", " + u.DisplayFirstName).Contains(search) - || (u.DisplayFirstName + " " + u.DisplayLastName).Contains(search) - || (u.LoginId != null && u.LoginId.Contains(search)) - || (u.MailId != null && u.MailId.Contains(search))) - .OrderBy(u => u.DisplayLastName) - .ThenBy(u => u.DisplayFirstName) - .Take(25) + .Where(namePredicate); + + return await PersonSearchHelper.OrderAndCap(query, u => u.DisplayLastName, u => u.DisplayFirstName) .Select(u => new CmsPersonOption { IamId = u.IamId!, diff --git a/web/Areas/Personnel/Controllers/PhoneListController.cs b/web/Areas/Personnel/Controllers/PhoneListController.cs new file mode 100644 index 000000000..c657ad635 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneListController.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/phonelist")] + [Permission(Allow = "SVMSecure")] + public class PhoneListController( + PhoneListService phoneListService, + PhoneListUnitService phoneListUnitService, + PhonePermissionsService phonePermissionsService) : ApiController + { + private readonly PhoneListService _phoneListService = phoneListService; + private readonly PhoneListUnitService _phoneListUnitService = phoneListUnitService; + private readonly PhonePermissionsService _phonePermissionsService = phonePermissionsService; + + /// + /// Everything a client needs before it fetches rows: + /// Returns the list's display name plus this caller's permissions. + /// Returned together to reduce API calls on the front end. + /// The backend continues to enforce permissions, but the front end + /// knows what data to expect and display based on these results. + /// + [HttpGet("{code}")] + public async Task> GetListInfo(string code, CancellationToken ct = default) + { + try + { + var list = await _phoneListService.GetListByCode(code, ct); + return Ok(new PhoneListInfo + { + PhoneListId = list.PhoneListId, + Code = list.Code, + Name = list.Name, + CanMaintain = _phonePermissionsService.CanMaintainList(list), + CanViewDirectPhone = await _phoneListUnitService.CanViewDirectPhone(list, ct), + }); + } + catch (InvalidOperationException ex) + { + return NotFound(ex.Message); + } + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneListModifiedDateController.cs b/web/Areas/Personnel/Controllers/PhoneListModifiedDateController.cs new file mode 100644 index 000000000..a0ee9926a --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneListModifiedDateController.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/phonelist/{code}/modifiedDate")] + [Permission(Allow = "SVMSecure")] + public class PhoneListModifiedDateController( + PhoneListService phoneListService, + PhoneListUnitService phoneListUnitService) : ApiController + { + private readonly PhoneListService _phoneListService = phoneListService; + private readonly PhoneListUnitService _phoneListUnitService = phoneListUnitService; + + /// + /// Returns the latest modification date of a UnitPerson in this list. + /// Includes deleted rows. + /// + [HttpGet] + public async Task> GetLastModifiedDate(string code, CancellationToken ct = default) + { + try + { + var list = await _phoneListService.GetListByCode(code, ct); + var unitPersonDate = await _phoneListUnitService.GetUnitPersonModifiedDate(list.PhoneListId, ct); + return Ok(unitPersonDate); + } + catch (InvalidOperationException ex) + { + return NotFound(ex.Message); + } + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneListUnitController.cs b/web/Areas/Personnel/Controllers/PhoneListUnitController.cs new file mode 100644 index 000000000..f7bb1a398 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneListUnitController.cs @@ -0,0 +1,132 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + /// + /// Unit and unit-person endpoints for a phone list, addressed by the list's stable Code. + /// Write access is the role named by that list's MaintainRole column, so each list + /// can have separate permissions. + /// + [Route("/api/phones/phonelist/{code}")] + [Permission(Allow = "SVMSecure")] + public class PhoneListUnitController( + PhoneListService phoneListService, + PhoneListUnitService phoneListUnitService, + PhonePermissionsService phonePermissionsService) : ApiController + { + private readonly PhoneListService _phoneListService = phoneListService; + private readonly PhoneListUnitService _phoneListUnitService = phoneListUnitService; + private readonly PhonePermissionsService _phonePermissionsService = phonePermissionsService; + + /// + /// Resolves the list named in the route and confirms the caller may edit it. Returns the + /// list id on success, or the ActionResult to return to the caller on failure. + /// + private async Task<(int ListId, ActionResult? Failure)> ResolveListForMaintain(string code, CancellationToken ct) + { + PhoneList list; + try + { + list = await _phoneListService.GetListByCode(code, ct); + } + catch (InvalidOperationException ex) + { + return (0, NotFound(ex.Message)); + } + if (!_phonePermissionsService.CanMaintainList(list)) + { + return (0, Forbid()); + } + return (list.PhoneListId, null); + } + + /// + /// Retrieves the PhoneListUnits associated with a given list code, including the PhoneListUnitPersons + /// in that unit. + /// + [HttpGet("units")] + public async Task>> GetUnits(string code, CancellationToken ct = default) + { + try + { + var list = await _phoneListService.GetListByCode(code, ct); + var results = await _phoneListUnitService.GetPhoneListUnits(list, ct); + return Ok(results); + } + catch (InvalidOperationException ex) + { + return NotFound(ex.Message); + } + } + + /// + /// Adds a unit person to a given list, provided the user has appropriate permissions. + /// + [HttpPost("unitPerson")] + public async Task AddUnitPersonData(string code, PhoneListUnitDataRequest request, CancellationToken ct = default) + { + var (listId, failure) = await ResolveListForMaintain(code, ct); + if (failure != null) + { + return failure; + } + try + { + await _phoneListUnitService.AddUnitPersonData(listId, request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Updates a unit person in a given list, provided the user has appropriate permissions. + /// + [HttpPut("unitPerson/{unitPersonId}")] + public async Task UpdateUnitPersonData(string code, int unitPersonId, PhoneListUnitDataRequest request, CancellationToken ct = default) + { + var (listId, failure) = await ResolveListForMaintain(code, ct); + if (failure != null) + { + return failure; + } + try + { + await _phoneListUnitService.UpdateUnitPersonData(listId, unitPersonId, request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Deletes a unit person from a given list, provided the user has appropriate permissions. + /// + [HttpDelete("unitPerson/{unitPersonId}")] + public async Task DeleteUnitPersonData(string code, int unitPersonId, CancellationToken ct = default) + { + var (listId, failure) = await ResolveListForMaintain(code, ct); + if (failure != null) + { + return failure; + } + try + { + await _phoneListUnitService.DeleteUnitPersonData(listId, unitPersonId, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhonePersonController.cs b/web/Areas/Personnel/Controllers/PhonePersonController.cs new file mode 100644 index 000000000..efa54a533 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhonePersonController.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/people")] + [Permission(Allow = "SVMSecure")] + public class PhonePersonController( + PhoneListService phoneListService, + PhonePersonLookupService phonePersonService) : ApiController + { + private readonly PhoneListService _phoneListService = phoneListService; + private readonly PhonePersonLookupService _phonePersonService = phonePersonService; + + /// + /// Person picker for the phone-record dialogs. Only returns direct numbers if the user + /// can edit the current list (and so has access to the data). + /// + [HttpGet] + public async Task>> GetCurrentEmployees(string search, string? listCode = null, CancellationToken ct = default) + { + PhoneList? list = null; + if (!string.IsNullOrWhiteSpace(listCode)) + { + try + { + list = await _phoneListService.GetListByCode(listCode, ct); + } + catch (InvalidOperationException) + { + list = null; + } + } + + List viperResults = await _phonePersonService.GetViperCurrentEmployees(search, ct); + List iamIds = []; + foreach (ViperPerson result in viperResults) + { + iamIds.Add(result.IamId); + } + List phoneResults = await _phonePersonService.GetPhonePeople(iamIds, list, ct); + Dictionary mergedResultsDict = []; + foreach (ViperPerson result in viperResults) + { + mergedResultsDict[result.IamId] = PersonnelMapper.ToAugmentedViperPerson(result); + } + List matchingResults = [.. phoneResults.Where(x => mergedResultsDict.ContainsKey(x.PersonIam))]; + + foreach (PhonePerson result in matchingResults) + { + mergedResultsDict[result.PersonIam].AddPhoneData(result); + } + return Ok(mergedResultsDict.Values.ToList()); + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneSVMFrequentNumberController.cs b/web/Areas/Personnel/Controllers/PhoneSVMFrequentNumberController.cs new file mode 100644 index 000000000..94aa79f84 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneSVMFrequentNumberController.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/svm/frequentnumbers")] + [Permission(Allow = "SVMSecure")] + public class PhoneSVMFrequentNumberController(PhoneSVMFrequentNumberService phoneSVMFrequentNumberService) : ApiController + { + private readonly PhoneSVMFrequentNumberService _phoneSVMFrequentNumberService = phoneSVMFrequentNumberService; + + /// + /// Gets the list of frequently called numbers for the SVM Phone List. + /// + [HttpGet] + public async Task>> GetFrequentNumbers(CancellationToken ct = default) + { + var results = await _phoneSVMFrequentNumberService.GetSVMFrequentNumbers(ct); + return Ok(results); + } + + /// + /// Adds a frequently called number to the SVM Phone List. + /// + [HttpPost] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task AddFrequentNumber(SVMFrequentNumberRequest request, CancellationToken ct = default) + { + try + { + await _phoneSVMFrequentNumberService.AddFrequentNumber(request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Updates a frequently called number in the SVM Phone List. + /// + [HttpPut("{entryId}")] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task UpdateFrequentNumber(int entryId, SVMFrequentNumberRequest request, CancellationToken ct = default) + { + try + { + await _phoneSVMFrequentNumberService.UpdateFrequentNumber(entryId, request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Deletes a frequently called number from the SVM Phone List. + /// + [HttpDelete("{entryId}")] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task DeleteFrequentNumber(int entryId, CancellationToken ct = default) + { + try + { + await _phoneSVMFrequentNumberService.DeleteFrequentNumber(entryId, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneSVMModifiedDateController.cs b/web/Areas/Personnel/Controllers/PhoneSVMModifiedDateController.cs new file mode 100644 index 000000000..28407ab24 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneSVMModifiedDateController.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/svm/modifiedDate")] + [Permission(Allow = "SVMSecure")] + public class PhoneSVMModifiedDateController( + PhoneSVMFrequentNumberService phoneSVMFrequentNumberService, + PhoneSVMUnitService phoneSVMUnitService) : ApiController + { + private readonly PhoneSVMFrequentNumberService _phoneSVMFrequentNumberService = phoneSVMFrequentNumberService; + private readonly PhoneSVMUnitService _phoneSVMUnitService = phoneSVMUnitService; + + /// + /// Identfies when frequent numbers were last modified. + /// + [HttpGet] + public async Task> GetLastModifiedDate(CancellationToken ct = default) + { + var frequentNumberDate = await _phoneSVMFrequentNumberService.GetSVMFrequentNumbersModifiedDate(ct); + var unitPersonDate = await _phoneSVMUnitService.GetSVMUnitPersonModifiedDate(ct); + if (frequentNumberDate == null || unitPersonDate != null && unitPersonDate > frequentNumberDate) + { + return Ok(unitPersonDate); + } + return Ok(frequentNumberDate); + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneSVMSectionController.cs b/web/Areas/Personnel/Controllers/PhoneSVMSectionController.cs new file mode 100644 index 000000000..ceb19ce64 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneSVMSectionController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/svm/sections")] + [Permission(Allow = "SVMSecure")] + public class PhoneSVMSectionController(PhoneSVMSectionService phoneSVMSectionService) : ApiController + { + private readonly PhoneSVMSectionService _phoneSVMSectionService = phoneSVMSectionService; + + /// + /// Gets the sections to include in the SVM Phone List. + /// + [HttpGet] + public async Task>> GetSections(CancellationToken ct = default) + { + var results = await _phoneSVMSectionService.GetSVMSections(ct); + if (results.Count == 0) + { + return NotFound("No sections for the SVM Phone List were found."); + } + return Ok(results); + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneSVMUnitController.cs b/web/Areas/Personnel/Controllers/PhoneSVMUnitController.cs new file mode 100644 index 000000000..759e7a2ba --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneSVMUnitController.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/svm")] + [Permission(Allow = "SVMSecure")] + public class PhoneSVMUnitController(PhoneSVMUnitService phoneSVMUnitService) : ApiController + { + private readonly PhoneSVMUnitService _phoneSVMUnitService = phoneSVMUnitService; + + /// + /// Gets all units for every section in the SVM Phone List. + /// + [HttpGet("units")] + public async Task>> GetUnits(CancellationToken ct = default) + { + var results = await _phoneSVMUnitService.GetSVMUnits(ct); + return Ok(results); + } + + /// + /// Adds data to a unit for the SVM Phone List. + /// This affects both SVMUnit and SVMUnitPerson. + /// + [HttpPost("units/{unitId}")] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task AddUnitData(int unitId, SVMUnitDataRequest request, CancellationToken ct = default) + { + try + { + await _phoneSVMUnitService.AddOrUpdateUnitData(unitId, request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Updates data in a unit for the SVM Phone List. + /// This affects both SVMUnit and SVMUnitPerson. + /// + [HttpPut("units/{unitId}")] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task UpdateUnitData(int unitId, SVMUnitDataRequest request, CancellationToken ct = default) + { + try + { + await _phoneSVMUnitService.AddOrUpdateUnitData(unitId, request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Deletes one row of the SVM list, identified by the row key the list renders. + /// This may delete multiple SVMUnitPerson. + /// Handled this way to match the end user experience and wrap multiple + /// deletions in a transaction. + /// + [HttpDelete("rows/{entryId}")] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task DeleteUnitRow(int entryId, CancellationToken ct = default) + { + try + { + await _phoneSVMUnitService.DeleteUnitRow(entryId, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + } +} diff --git a/web/Areas/Personnel/Models/AugmentedViperPerson.cs b/web/Areas/Personnel/Models/AugmentedViperPerson.cs new file mode 100644 index 000000000..a7725fdee --- /dev/null +++ b/web/Areas/Personnel/Models/AugmentedViperPerson.cs @@ -0,0 +1,22 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Class for combining ViperPerson and PhonePerson results for name searches. + /// + public class AugmentedViperPerson + { + public int PersonId { get; set; } + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; + public string IamId { get; set; } = string.Empty; + public required bool CurrentEmployee { get; set; } + public string MailId { get; set; } = string.Empty; + public PhonePerson? PhoneData { get; set; } + + public void AddPhoneData(PhonePerson phonePerson) + { + this.PhoneData = phonePerson; + } + } +} diff --git a/web/Areas/Personnel/Models/PersonnelMapper.cs b/web/Areas/Personnel/Models/PersonnelMapper.cs new file mode 100644 index 000000000..71797c45b --- /dev/null +++ b/web/Areas/Personnel/Models/PersonnelMapper.cs @@ -0,0 +1,13 @@ +using Riok.Mapperly.Abstractions; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Mapperly mapper to create an AugmentedViperPerson from a ViperPerson. + /// + [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.None)] + public static partial class PersonnelMapper + { + public static partial AugmentedViperPerson ToAugmentedViperPerson(ViperPerson source); + } +} diff --git a/web/Areas/Personnel/Models/PhoneList.cs b/web/Areas/Personnel/Models/PhoneList.cs new file mode 100644 index 000000000..f56a35a8f --- /dev/null +++ b/web/Areas/Personnel/Models/PhoneList.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.PhoneList, + /// a (typically unit/department-level) grouping for phone numbers. + /// + public class PhoneList + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int PhoneListId { get; set; } + + /** + * Stable lookup key used in routes and API paths (e.g. "VMDO"). + * Allows changing Name without breaking links. + */ + public required string Code { get; set; } + public required string Name { get; set; } + public required string MaintainRole { get; set; } + + public virtual ICollection PhoneListUnits { get; set; } = []; + } +} diff --git a/web/Areas/Personnel/Models/PhoneListInfo.cs b/web/Areas/Personnel/Models/PhoneListInfo.cs new file mode 100644 index 000000000..18b30abad --- /dev/null +++ b/web/Areas/Personnel/Models/PhoneListInfo.cs @@ -0,0 +1,16 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// What a client needs to render a phone list before it fetches any rows: the display name + /// plus the caller's own capabilities. Allows the client to render correctly + /// based on the permissions that will be enforced on the back end. + /// + public class PhoneListInfo + { + public int PhoneListId { get; set; } + public required string Code { get; set; } + public required string Name { get; set; } + public bool CanMaintain { get; set; } + public bool CanViewDirectPhone { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/PhoneListUnit.cs b/web/Areas/Personnel/Models/PhoneListUnit.cs new file mode 100644 index 000000000..ff0b73b9f --- /dev/null +++ b/web/Areas/Personnel/Models/PhoneListUnit.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.PhoneListUnit, + /// a grouping within a phone list. + /// + public class PhoneListUnit + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int PhoneListUnitId { get; set; } + public required int PhoneListId { get; set; } + public required string Name { get; set; } + public int? SortOrder { get; set; } + + public virtual PhoneList PhoneList { get; set; } = null!; + public virtual ICollection PhoneListUnitPersons { get; set; } = []; + } +} diff --git a/web/Areas/Personnel/Models/PhoneListUnitDataRequests.cs b/web/Areas/Personnel/Models/PhoneListUnitDataRequests.cs new file mode 100644 index 000000000..1ac8a0fd6 --- /dev/null +++ b/web/Areas/Personnel/Models/PhoneListUnitDataRequests.cs @@ -0,0 +1,15 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Class for form data to create or edit a row in unit-specific phone list tables. + /// + public class PhoneListUnitDataRequest + { + public required int UnitId { get; set; } + public string Office { get; set; } = ""; + public required string EmployeeIam { get; set; } + public string Phone { get; set; } = ""; + public string DirectPhone { get; set; } = ""; + public bool ListFirst { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/PhoneListUnitPerson.cs b/web/Areas/Personnel/Models/PhoneListUnitPerson.cs new file mode 100644 index 000000000..133b30d00 --- /dev/null +++ b/web/Areas/Personnel/Models/PhoneListUnitPerson.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.PhoneListUnitPerson, + /// connecting people to a given unit for a phone list. + /// + public class PhoneListUnitPerson + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int PhoneListUnitPersonId { get; set; } + public required int PhoneListUnitId { get; set; } + public required string PersonIam { get; set; } + public required bool ListFirst { get; set; } + public required bool IsActive { get; set; } + public string? ModifiedBy { get; set; } + public DateTime? ModifiedDate { get; set; } + + public virtual PhoneListUnit PhoneListUnit { get; set; } = null!; + public virtual PhonePerson Person { get; set; } = null!; + public virtual ViperPerson? ViperModPerson { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/PhonePerson.cs b/web/Areas/Personnel/Models/PhonePerson.cs new file mode 100644 index 000000000..73c182cc4 --- /dev/null +++ b/web/Areas/Personnel/Models/PhonePerson.cs @@ -0,0 +1,21 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents data from phones.Person. + /// Ties a person to phone number and office data. + /// + public class PhonePerson + { + public required string PersonIam { get; set; } + public string? Phone { get; set; } + public string? DirectPhone { get; set; } + public string? Office { get; set; } + public DateTime? ModifiedDate { get; set; } + public string? ModifiedBy { get; set; } + + public virtual ICollection UnitPersons { get; set; } = []; + public virtual ICollection PhoneListUnitPersons { get; set; } = []; + public virtual ViperPerson? ViperPerson { get; set; } + public virtual ViperPerson? ViperModPerson { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/SVMFrequentNumber.cs b/web/Areas/Personnel/Models/SVMFrequentNumber.cs new file mode 100644 index 000000000..0e1da9e46 --- /dev/null +++ b/web/Areas/Personnel/Models/SVMFrequentNumber.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents data from phones.SVMFrequentNumber. + /// Provides a spot for additional important phone + /// numbers not tied to a specific person. + /// + public class SVMFrequentNumber + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int NumberId { get; set; } + public required string Label { get; set; } + public required string Phone { get; set; } + public int? SortOrder { get; set; } + public string? ModifiedBy { get; set; } + public DateTime? ModifiedDate { get; set; } + public bool IsActive { get; set; } + public virtual ViperPerson? ViperModPerson { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/SVMFrequentNumberRequests.cs b/web/Areas/Personnel/Models/SVMFrequentNumberRequests.cs new file mode 100644 index 000000000..89a365e84 --- /dev/null +++ b/web/Areas/Personnel/Models/SVMFrequentNumberRequests.cs @@ -0,0 +1,11 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Class for form data to create or edit a row in the SVM frequently called numbers table. + /// + public class SVMFrequentNumberRequest + { + public required string Label { get; set; } = ""; + public required string Phone { get; set; } = ""; + } +} diff --git a/web/Areas/Personnel/Models/SVMSection.cs b/web/Areas/Personnel/Models/SVMSection.cs new file mode 100644 index 000000000..4901b625b --- /dev/null +++ b/web/Areas/Personnel/Models/SVMSection.cs @@ -0,0 +1,18 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.SVMSection, + /// a grouping for the SVM Phone List. + /// + public class SVMSection + { + public required int SectionId { get; set; } + public string? Name { get; set; } + public bool? IncludeAbbrv { get; set; } + public string? UnitName { get; set; } + public string? DirectorTitle { get; set; } + public int? SortOrder { get; set; } + + public virtual ICollection Units { get; set; } = []; + } +} diff --git a/web/Areas/Personnel/Models/SVMUnit.cs b/web/Areas/Personnel/Models/SVMUnit.cs new file mode 100644 index 000000000..77459a484 --- /dev/null +++ b/web/Areas/Personnel/Models/SVMUnit.cs @@ -0,0 +1,22 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.SVMUnit, + /// a department, unit, or dean's office. + /// + public class SVMUnit + { + public required int UnitId { get; set; } + public required int SectionId { get; set; } + public string? Name { get; set; } + public string? Abbrv { get; set; } + public int? SortOrder { get; set; } + public string? Fax { get; set; } + public string? ModifiedBy { get; set; } + public DateTime? ModifiedDate { get; set; } + + public virtual SVMSection Section { get; set; } = null!; + public virtual ICollection UnitPersons { get; set; } = []; + public virtual ViperPerson? ViperModPerson { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/SVMUnitDataRequests.cs b/web/Areas/Personnel/Models/SVMUnitDataRequests.cs new file mode 100644 index 000000000..d6e64cf08 --- /dev/null +++ b/web/Areas/Personnel/Models/SVMUnitDataRequests.cs @@ -0,0 +1,19 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Class for form data to create or edit a row in the SVM table. + /// + public class SVMUnitDataRequest + { + public string Fax { get; set; } = ""; + public string Location { get; set; } = ""; + public string DeanIam { get; set; } = ""; + public string DeanPhone { get; set; } = ""; + public string DeanInterim { get; set; } = ""; + public int DeanUnitPerson { get; set; } = -1; + public string StaffIam { get; set; } = ""; + public string StaffPhone { get; set; } = ""; + public string StaffInterim { get; set; } = ""; + public int StaffUnitPerson { get; set; } = -1; + } +} diff --git a/web/Areas/Personnel/Models/SVMUnitPerson.cs b/web/Areas/Personnel/Models/SVMUnitPerson.cs new file mode 100644 index 000000000..fa5c08863 --- /dev/null +++ b/web/Areas/Personnel/Models/SVMUnitPerson.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.SVMUnitPerson, + /// connecting people in leadership and admin roles to a + /// given unit. + /// + public class SVMUnitPerson + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int UnitPersonId { get; set; } + public required int UnitId { get; set; } + public required string PersonIam { get; set; } + public string? Office { get; set; } + public string? PosType { get; set; } + public string? Interim { get; set; } + public DateTime? ModifiedDate { get; set; } + public string? ModifiedBy { get; set; } + public bool IsActive { get; set; } + + public virtual SVMUnit Unit { get; set; } = null!; + public virtual PhonePerson Person { get; set; } = null!; + public virtual ViperPerson? ViperModPerson { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/ViperPerson.cs b/web/Areas/Personnel/Models/ViperPerson.cs new file mode 100644 index 000000000..39c11f64a --- /dev/null +++ b/web/Areas/Personnel/Models/ViperPerson.cs @@ -0,0 +1,17 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Read-only entity for accessing users.Person table within PhonesDbContext. + /// Used for joining to get employee names without cross-context queries. + /// + public class ViperPerson + { + public int PersonId { get; set; } + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; + public required string IamId { get; set; } + public required bool CurrentEmployee { get; set; } + public string MailId { get; set; } = string.Empty; + } +} diff --git a/web/Areas/Personnel/PhonesDbContext.cs b/web/Areas/Personnel/PhonesDbContext.cs new file mode 100644 index 000000000..ee695b077 --- /dev/null +++ b/web/Areas/Personnel/PhonesDbContext.cs @@ -0,0 +1,216 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel.Models; + +namespace Viper.Areas.Personnel; + +/// +/// Entity Framework DbContext for the Personnel phone numbers system. +/// All tables are in the [phones] schema in the VIPER database. +/// +public class PhonesDbContext : DbContext +{ + public PhonesDbContext(DbContextOptions options) : base(options) + { + } + + // Core data tables + public virtual DbSet PhonePerson { get; set; } + public virtual DbSet SVMSection { get; set; } + public virtual DbSet SVMUnit { get; set; } + public virtual DbSet SVMUnitPerson { get; set; } + public virtual DbSet SVMFrequentNumber { get; set; } + public virtual DbSet PhoneList { get; set; } + public virtual DbSet PhoneListUnit { get; set; } + public virtual DbSet PhoneListUnitPerson { get; set; } + + // Read-only cross-schema reference (users schema in same database) + public virtual DbSet ViperPerson { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // PhonePerson (phones.Person) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.PersonIam); + entity.ToTable("Person", schema: "phones"); + + entity.Property(e => e.PersonIam).HasColumnName("PersonIam"); + entity.Property(e => e.Phone).HasColumnName("Phone").HasMaxLength(25); + entity.Property(e => e.DirectPhone).HasColumnName("DirectPhone").HasMaxLength(25); + entity.Property(e => e.Office).HasColumnName("Office").HasMaxLength(100); + entity.Property(e => e.ModifiedDate).HasColumnName("ModifiedDate"); + entity.Property(e => e.ModifiedBy).HasColumnName("ModifiedBy"); + + // Cross-schema FK to users.Person + entity.HasOne(e => e.ViperPerson) + .WithMany() + .HasForeignKey(e => e.PersonIam) + .HasPrincipalKey(e => e.IamId) + .IsRequired(); + + entity.HasOne(e => e.ViperModPerson) + .WithMany() + .HasForeignKey(e => e.ModifiedBy) + .HasPrincipalKey(e => e.IamId); + }); + + // SVMSection (phones.SVMSection) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.SectionId); + entity.ToTable("SVMSection", schema: "phones"); + + entity.Property(e => e.SectionId).HasColumnName("SectionId"); + entity.Property(e => e.Name).HasColumnName("Name").HasMaxLength(100); + entity.Property(e => e.IncludeAbbrv).HasColumnName("IncludeAbbrv"); + entity.Property(e => e.UnitName).HasColumnName("UnitName").HasMaxLength(50); + entity.Property(e => e.DirectorTitle).HasColumnName("DirectorTitle").HasMaxLength(50); + entity.Property(e => e.SortOrder).HasColumnName("SortOrder"); + }); + + // SVMUnit (phones.SVMUnit) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.UnitId); + entity.ToTable("SVMUnit", schema: "phones"); + + entity.Property(e => e.UnitId).HasColumnName("UnitId"); + entity.Property(e => e.SectionId).HasColumnName("SectionId"); + entity.Property(e => e.Name).HasColumnName("Name").HasMaxLength(100); + entity.Property(e => e.Fax).HasColumnName("Fax").HasMaxLength(25); + entity.Property(e => e.Abbrv).HasColumnName("Abbrv").HasMaxLength(20); + entity.Property(e => e.SortOrder).HasColumnName("SortOrder"); + entity.Property(e => e.ModifiedDate).HasColumnName("ModifiedDate"); + entity.Property(e => e.ModifiedBy).HasColumnName("ModifiedBy").HasMaxLength(10); + + entity.HasOne(e => e.Section) + .WithMany(s => s.Units) + .HasForeignKey(e => e.SectionId); + + entity.HasOne(e => e.ViperModPerson) + .WithMany() + .HasForeignKey(e => e.ModifiedBy) + .HasPrincipalKey(e => e.IamId); + }); + + // SVMUnitPerson (phones.SVMUnitPerson) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.UnitPersonId); + entity.ToTable("SVMUnitPerson", schema: "phones"); + + entity.Property(e => e.UnitPersonId).HasColumnName("UnitPersonId"); + entity.Property(e => e.UnitId).HasColumnName("UnitId"); + entity.Property(e => e.PersonIam).HasColumnName("PersonIam").HasMaxLength(10); + entity.Property(e => e.Office).HasColumnName("Office").HasMaxLength(50); + entity.Property(e => e.PosType).HasColumnName("PosType").HasMaxLength(25); + entity.Property(e => e.Interim).HasColumnName("Interim").HasMaxLength(10); + entity.Property(e => e.ModifiedDate).HasColumnName("ModifiedDate"); + entity.Property(e => e.ModifiedBy).HasColumnName("ModifiedBy").HasMaxLength(10); + entity.Property(e => e.IsActive).HasColumnName("IsActive"); + + entity.HasOne(e => e.Unit) + .WithMany(s => s.UnitPersons) + .HasForeignKey(e => e.UnitId); + + entity.HasOne(e => e.Person) + .WithMany(s => s.UnitPersons) + .HasForeignKey(e => e.PersonIam); + + entity.HasOne(e => e.ViperModPerson) + .WithMany() + .HasForeignKey(e => e.ModifiedBy) + .HasPrincipalKey(e => e.IamId); + }); + + // SVMFrequentNumber (phones.SVMFrequentNumber) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.NumberId); + entity.ToTable("SVMFrequentNumber", schema: "phones"); + + entity.Property(e => e.NumberId).HasColumnName("NumberId"); + entity.Property(e => e.Label).HasColumnName("Label").HasMaxLength(100); + entity.Property(e => e.Phone).HasColumnName("Phone").HasMaxLength(25); + entity.Property(e => e.SortOrder).HasColumnName("SortOrder"); + entity.Property(e => e.ModifiedDate).HasColumnName("ModifiedDate"); + entity.Property(e => e.ModifiedBy).HasColumnName("ModifiedBy").HasMaxLength(10); + entity.Property(e => e.IsActive).HasColumnName("IsActive"); + + entity.HasOne(e => e.ViperModPerson) + .WithMany() + .HasForeignKey(e => e.ModifiedBy) + .HasPrincipalKey(e => e.IamId); + }); + + // PhoneList (phones.PhoneList) + modelBuilder.Entity