From baacbb35d40ae262556991acd9cced20edecd7be Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Thu, 18 Dec 2025 20:03:44 -0800 Subject: [PATCH 1/5] feat(effort): VPR-18 Manual course import, edit and delete - Implement CoursesController with CRUD endpoints and Banner search - Add CourseService with duplicate detection and enrollment management - Create Vue CourseList page with add/edit/import dialogs - Add unit and integration tests for controller and service layers --- .../__tests__/course-add-dialog.test.ts | 171 ++++ .../__tests__/course-edit-dialog.test.ts | 216 +++++ .../__tests__/course-import-dialog.test.ts | 166 ++++ .../src/Effort/components/CourseAddDialog.vue | 215 +++++ .../Effort/components/CourseEditDialog.vue | 163 ++++ .../Effort/components/CourseImportDialog.vue | 362 +++++++++ .../composables/use-effort-permissions.ts | 13 + VueApp/src/Effort/layouts/EffortLayout.vue | 35 +- VueApp/src/Effort/pages/CourseList.vue | 385 +++++++++ VueApp/src/Effort/router/routes.ts | 6 + VueApp/src/Effort/services/effort-service.ts | 179 ++++- VueApp/src/Effort/types/index.ts | 45 ++ VueApp/src/composables/ViperFetch.ts | 9 +- test/Effort/CourseServiceTests.cs | 751 ++++++++++++++++++ test/Effort/CoursesControllerTests.cs | 739 +++++++++++++++++ test/Effort/EffortIntegrationTestBase.cs | 402 ++++++++++ .../EffortPermissionIntegrationTests.cs | 541 +++++++++++++ .../Effort/Controllers/CoursesController.cs | 393 +++++++++ .../DTOs/Requests/CreateCourseRequest.cs | 39 + .../DTOs/Requests/ImportCourseRequest.cs | 23 + .../DTOs/Requests/UpdateCourseRequest.cs | 20 + .../DTOs/Requests/UpdateEnrollmentRequest.cs | 13 + .../Models/DTOs/Responses/BannerCourseDto.cs | 55 ++ web/Areas/Effort/Services/CourseService.cs | 475 +++++++++++ .../Effort/Services/EffortAuditService.cs | 5 + web/Areas/Effort/Services/ICourseService.cs | 137 ++++ .../Effort/Services/IEffortAuditService.cs | 6 + web/Program.cs | 1 + 28 files changed, 5560 insertions(+), 5 deletions(-) create mode 100644 VueApp/src/Effort/__tests__/course-add-dialog.test.ts create mode 100644 VueApp/src/Effort/__tests__/course-edit-dialog.test.ts create mode 100644 VueApp/src/Effort/__tests__/course-import-dialog.test.ts create mode 100644 VueApp/src/Effort/components/CourseAddDialog.vue create mode 100644 VueApp/src/Effort/components/CourseEditDialog.vue create mode 100644 VueApp/src/Effort/components/CourseImportDialog.vue create mode 100644 VueApp/src/Effort/pages/CourseList.vue create mode 100644 test/Effort/CourseServiceTests.cs create mode 100644 test/Effort/CoursesControllerTests.cs create mode 100644 test/Effort/EffortIntegrationTestBase.cs create mode 100644 test/Effort/Integration/EffortPermissionIntegrationTests.cs create mode 100644 web/Areas/Effort/Controllers/CoursesController.cs create mode 100644 web/Areas/Effort/Models/DTOs/Requests/CreateCourseRequest.cs create mode 100644 web/Areas/Effort/Models/DTOs/Requests/ImportCourseRequest.cs create mode 100644 web/Areas/Effort/Models/DTOs/Requests/UpdateCourseRequest.cs create mode 100644 web/Areas/Effort/Models/DTOs/Requests/UpdateEnrollmentRequest.cs create mode 100644 web/Areas/Effort/Models/DTOs/Responses/BannerCourseDto.cs create mode 100644 web/Areas/Effort/Services/CourseService.cs create mode 100644 web/Areas/Effort/Services/ICourseService.cs diff --git a/VueApp/src/Effort/__tests__/course-add-dialog.test.ts b/VueApp/src/Effort/__tests__/course-add-dialog.test.ts new file mode 100644 index 00000000..d70f0171 --- /dev/null +++ b/VueApp/src/Effort/__tests__/course-add-dialog.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { ref } from "vue" +import { setActivePinia, createPinia } from "pinia" + +/** + * Tests for CourseAddDialog error handling and validation behavior. + * + * These tests validate that the component properly handles errors + * when creating courses manually. + * + * The actual component UI is tested via Playwright MCP (see SMOKETEST-EFFORT-COURSE.md). + */ + +// Mock the effort service +const mockCreateCourse = vi.fn() +vi.mock("../services/effort-service", () => ({ + effortService: { + createCourse: (...args: unknown[]) => mockCreateCourse(...args), + }, +})) + +// Validation helper functions (moved outside tests for consistent-function-scoping) +const isValidEnrollment = (v: number) => v >= 0 && Number.isInteger(v) +const isValidUnits = (v: number) => v >= 0 + +describe("CourseAddDialog - Error Handling", () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + describe("Create Course Error States", () => { + it("should capture error message when API returns success: false", () => { + const error = ref(null) + + const result = { success: false, error: "Course already exists for this term" } + + if (!result.success) { + error.value = result.error ?? "Failed to create course" + } + + expect(error.value).toBe("Course already exists for this term") + }) + + it("should use default message when API returns no error message", () => { + const error = ref(null) + + const result = { success: false, error: null } + + if (!result.success) { + error.value = result.error ?? "Failed to create course" + } + + expect(error.value).toBe("Failed to create course") + }) + }) + + describe("Service Mock Behavior", () => { + it("effortService.createCourse returns success with course data", async () => { + mockCreateCourse.mockResolvedValue({ success: true, course: { id: 1 } }) + + const result = await mockCreateCourse() + + expect(result.success).toBeTruthy() + expect(result.course.id).toBe(1) + }) + + it("effortService.createCourse returns failure with error message", async () => { + mockCreateCourse.mockResolvedValue({ + success: false, + error: "Course already exists", + }) + + const result = await mockCreateCourse() + + expect(result.success).toBeFalsy() + expect(result.error).toBe("Course already exists") + }) + + it("effortService.createCourse rejects with exception", async () => { + mockCreateCourse.mockRejectedValue(new Error("Database error")) + + await expect(mockCreateCourse()).rejects.toThrow("Database error") + }) + }) + + describe("Form Data Normalization", () => { + it("should trim and uppercase subject code", () => { + const input = " dvm " + const normalized = input.trim().toUpperCase() + + expect(normalized).toBe("DVM") + }) + + it("should trim and uppercase course number", () => { + const input = " 443r " + const normalized = input.trim().toUpperCase() + + expect(normalized).toBe("443R") + }) + + it("should trim CRN", () => { + const input = " 12345 " + const normalized = input.trim() + + expect(normalized).toBe("12345") + }) + }) + + describe("Validation Logic", () => { + it("should validate CRN is 5 digits", () => { + const crnRegex = /^\d{5}$/ + + expect(crnRegex.test("12345")).toBeTruthy() + expect(crnRegex.test("1234")).toBeFalsy() + expect(crnRegex.test("123456")).toBeFalsy() + expect(crnRegex.test("abcde")).toBeFalsy() + }) + + it("should validate enrollment is non-negative integer", () => { + expect(isValidEnrollment(0)).toBeTruthy() + expect(isValidEnrollment(25)).toBeTruthy() + expect(isValidEnrollment(-1)).toBeFalsy() + expect(isValidEnrollment(2.5)).toBeFalsy() + }) + + it("should validate units is non-negative", () => { + expect(isValidUnits(0)).toBeTruthy() + expect(isValidUnits(4)).toBeTruthy() + expect(isValidUnits(0.5)).toBeTruthy() + expect(isValidUnits(-1)).toBeFalsy() + }) + + it("should validate subject code max length", () => { + const maxLength = 3 + + expect("DVM".length <= maxLength).toBeTruthy() + expect("DVMX".length <= maxLength).toBeFalsy() + }) + }) + + describe("Form Reset Behavior", () => { + it("should reset form data to defaults", () => { + const defaultDepartment = "APC" + const formData = ref({ + subjCode: "OLD", + crseNumb: "999", + seqNumb: "002", + crn: "99999", + enrollment: 50, + units: 5, + custDept: "VME", + }) + + // Simulate reset logic + formData.value = { + subjCode: "", + crseNumb: "", + seqNumb: "", + crn: "", + enrollment: 0, + units: 0, + custDept: defaultDepartment, + } + + expect(formData.value.subjCode).toBe("") + expect(formData.value.enrollment).toBe(0) + expect(formData.value.custDept).toBe("APC") + }) + }) +}) diff --git a/VueApp/src/Effort/__tests__/course-edit-dialog.test.ts b/VueApp/src/Effort/__tests__/course-edit-dialog.test.ts new file mode 100644 index 00000000..e33a1c7e --- /dev/null +++ b/VueApp/src/Effort/__tests__/course-edit-dialog.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { ref } from "vue" +import { setActivePinia, createPinia } from "pinia" + +/** + * Tests for CourseEditDialog error handling and validation behavior. + * + * These tests validate that the component properly handles errors + * when updating courses. + * + * The actual component UI is tested via Playwright MCP (see SMOKETEST-EFFORT-COURSE.md). + */ + +// Mock the effort service +const mockUpdateCourse = vi.fn() +const mockUpdateCourseEnrollment = vi.fn() +vi.mock("../services/effort-service", () => ({ + effortService: { + updateCourse: (...args: unknown[]) => mockUpdateCourse(...args), + updateCourseEnrollment: (...args: unknown[]) => mockUpdateCourseEnrollment(...args), + }, +})) + +// Validation helper functions (moved outside tests for consistent-function-scoping) +const isRCourse = (crseNumb: string) => crseNumb.toUpperCase().endsWith("R") +const isValidEnrollment = (v: number) => v >= 0 +const isValidUnits = (v: number) => v >= 0 + +describe("CourseEditDialog - Error Handling", () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + describe("Update Course Error States", () => { + it("should capture error message when API returns success: false", () => { + const error = ref(null) + + const result = { success: false, error: "Failed to update course" } + + if (!result.success) { + error.value = result.error ?? "Failed to update course" + } + + expect(error.value).toBe("Failed to update course") + }) + + it("should use default message when API returns no error message", () => { + const error = ref(null) + + const result = { success: false, error: null } + + if (!result.success) { + error.value = result.error ?? "Failed to update course" + } + + expect(error.value).toBe("Failed to update course") + }) + }) + + describe("Service Mock Behavior - Full Update", () => { + it("effortService.updateCourse returns success", async () => { + mockUpdateCourse.mockResolvedValue({ success: true }) + + const result = await mockUpdateCourse(1, { enrollment: 30, units: 4, custDept: "DVM" }) + + expect(result.success).toBeTruthy() + }) + + it("effortService.updateCourse returns failure with error message", async () => { + mockUpdateCourse.mockResolvedValue({ + success: false, + error: "Invalid department code", + }) + + const result = await mockUpdateCourse() + + expect(result.success).toBeFalsy() + expect(result.error).toBe("Invalid department code") + }) + + it("effortService.updateCourse rejects with exception", async () => { + mockUpdateCourse.mockRejectedValue(new Error("Database error")) + + await expect(mockUpdateCourse()).rejects.toThrow("Database error") + }) + }) + + describe("Service Mock Behavior - Enrollment Only", () => { + it("effortService.updateCourseEnrollment returns success", async () => { + mockUpdateCourseEnrollment.mockResolvedValue({ success: true }) + + const result = await mockUpdateCourseEnrollment(2, 75) + + expect(result.success).toBeTruthy() + }) + + it("effortService.updateCourseEnrollment returns failure", async () => { + mockUpdateCourseEnrollment.mockResolvedValue({ + success: false, + error: "Cannot update enrollment for non-R course", + }) + + const result = await mockUpdateCourseEnrollment() + + expect(result.success).toBeFalsy() + expect(result.error).toBe("Cannot update enrollment for non-R course") + }) + }) + + describe("R-Course Detection Logic", () => { + it("should identify R-course by course number suffix", () => { + expect(isRCourse("443R")).toBeTruthy() + expect(isRCourse("443r")).toBeTruthy() + expect(isRCourse("443")).toBeFalsy() + expect(isRCourse("R443")).toBeFalsy() + }) + }) + + describe("Validation Logic", () => { + it("should validate enrollment is non-negative", () => { + expect(isValidEnrollment(0)).toBeTruthy() + expect(isValidEnrollment(30)).toBeTruthy() + expect(isValidEnrollment(-1)).toBeFalsy() + }) + + it("should validate units is non-negative", () => { + expect(isValidUnits(0)).toBeTruthy() + expect(isValidUnits(4)).toBeTruthy() + expect(isValidUnits(-1)).toBeFalsy() + }) + }) + + describe("Form Data Population", () => { + it("should populate form with course data", () => { + const course = { + id: 1, + enrollment: 20, + units: 4, + custDept: "DVM", + } + + const formData = ref({ + enrollment: 0, + units: 0, + custDept: "", + }) + + // Simulate form population + formData.value = { + enrollment: course.enrollment, + units: course.units, + custDept: course.custDept, + } + + expect(formData.value.enrollment).toBe(20) + expect(formData.value.units).toBe(4) + expect(formData.value.custDept).toBe("DVM") + }) + + it("should handle null course gracefully", () => { + const course = null + const formData = ref({ + enrollment: 0, + units: 0, + custDept: "", + }) + + // Simulate null handling + if (course) { + formData.value = { + enrollment: course.enrollment, + units: course.units, + custDept: course.custDept, + } + } + + // Form should remain unchanged + expect(formData.value.enrollment).toBe(0) + }) + }) + + describe("Edit Mode Selection", () => { + it("should use updateCourseEnrollment for enrollment-only mode", async () => { + const enrollmentOnly = true + const courseId = 2 + const newEnrollment = 75 + + if (enrollmentOnly) { + mockUpdateCourseEnrollment.mockResolvedValue({ success: true }) + await mockUpdateCourseEnrollment(courseId, newEnrollment) + } else { + await mockUpdateCourse(courseId, {}) + } + + expect(mockUpdateCourseEnrollment).toHaveBeenCalledWith(2, 75) + expect(mockUpdateCourse).not.toHaveBeenCalled() + }) + + it("should use updateCourse for full edit mode", async () => { + const enrollmentOnly = false + const courseId = 1 + const updateData = { enrollment: 30, units: 4, custDept: "DVM" } + + if (enrollmentOnly) { + await mockUpdateCourseEnrollment(courseId, updateData.enrollment) + } else { + mockUpdateCourse.mockResolvedValue({ success: true }) + await mockUpdateCourse(courseId, updateData) + } + + expect(mockUpdateCourse).toHaveBeenCalledWith(1, updateData) + expect(mockUpdateCourseEnrollment).not.toHaveBeenCalled() + }) + }) +}) diff --git a/VueApp/src/Effort/__tests__/course-import-dialog.test.ts b/VueApp/src/Effort/__tests__/course-import-dialog.test.ts new file mode 100644 index 00000000..1a2dc71f --- /dev/null +++ b/VueApp/src/Effort/__tests__/course-import-dialog.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { ref } from "vue" +import { setActivePinia, createPinia } from "pinia" + +/** + * Tests for CourseImportDialog error handling behavior. + * + * These tests validate that the component properly displays error feedback + * to users when import operations fail, rather than showing generic errors. + * + * The actual component UI is tested via Playwright MCP (see SMOKETEST-EFFORT-COURSE.md). + */ + +// Mock the effort service +const mockSearchBannerCourses = vi.fn() +const mockImportCourse = vi.fn() +vi.mock("../services/effort-service", () => ({ + effortService: { + searchBannerCourses: (...args: unknown[]) => mockSearchBannerCourses(...args), + importCourse: (...args: unknown[]) => mockImportCourse(...args), + }, +})) + +describe("CourseImportDialog - Error Handling", () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + describe("Import Error States", () => { + it("should capture error message when API returns success: false", () => { + const importError = ref(null) + + // Simulate the error handling logic from the component + const result = { success: false, error: "Course DVM 443 with 4 units already exists for this term" } + + if (!result.success) { + importError.value = result.error ?? "Failed to import course" + } + + expect(importError.value).toBe("Course DVM 443 with 4 units already exists for this term") + }) + + it("should use default message when API returns no error message", () => { + const importError = ref(null) + + const result = { success: false, error: null } + + if (!result.success) { + importError.value = result.error ?? "Failed to import course" + } + + expect(importError.value).toBe("Failed to import course") + }) + + it("should capture error message from thrown exception", () => { + const importError = ref(null) + + // Simulate exception handling logic + try { + throw new Error("Failed to import course. Please check all field values are valid.") + } catch (err) { + importError.value = err instanceof Error ? err.message : "Failed to import course" + } + + expect(importError.value).toBe("Failed to import course. Please check all field values are valid.") + }) + + it("should handle non-Error exceptions gracefully", () => { + const importError = ref(null) + + // Simulate handling a non-Error value (e.g., from external code) + const nonErrorValue: unknown = "string error" + importError.value = nonErrorValue instanceof Error ? nonErrorValue.message : "Failed to import course" + + expect(importError.value).toBe("Failed to import course") + }) + }) + + describe("Search Error States", () => { + it("should capture search error from thrown exception", () => { + const searchError = ref("") + + try { + throw new Error("Search failed") + } catch (err) { + searchError.value = err instanceof Error ? err.message : "Error searching for courses" + } + + expect(searchError.value).toBe("Search failed") + }) + + it("should use default message for non-Error exceptions", () => { + const searchError = ref("") + + // Simulate handling a non-Error value (e.g., from external code) + const nonErrorValue: unknown = "unknown error" + searchError.value = nonErrorValue instanceof Error ? nonErrorValue.message : "Error searching for courses" + + expect(searchError.value).toBe("Error searching for courses") + }) + }) + + describe("Service Mock Behavior", () => { + it("effortService.importCourse rejects with specific error message", async () => { + mockImportCourse.mockRejectedValue( + new Error("Failed to import course. Please check all field values are valid."), + ) + + await expect(mockImportCourse()).rejects.toThrow("Failed to import course") + }) + + it("effortService.importCourse rejects with duplicate error message", async () => { + mockImportCourse.mockRejectedValue(new Error("Course DVM 443 with 4 units already exists for this term")) + + await expect(mockImportCourse()).rejects.toThrow("already exists") + }) + + it("effortService.searchBannerCourses rejects with error message", async () => { + mockSearchBannerCourses.mockRejectedValue(new Error("Search failed")) + + await expect(mockSearchBannerCourses()).rejects.toThrow("Search failed") + }) + + it("effortService.importCourse returns success with course data", async () => { + mockImportCourse.mockResolvedValue({ success: true, course: { id: 1, crn: "12345" } }) + + const result = await mockImportCourse() + + expect(result.success).toBeTruthy() + expect(result.course.id).toBe(1) + }) + + it("effortService.importCourse returns failure with error message", async () => { + mockImportCourse.mockResolvedValue({ + success: false, + error: "Course already exists", + }) + + const result = await mockImportCourse() + + expect(result.success).toBeFalsy() + expect(result.error).toBe("Course already exists") + }) + }) + + describe("Error Reset Behavior", () => { + it("should clear import error when starting new import", () => { + const importError = ref("Previous error") + + // Simulate the startImport logic + importError.value = null + + expect(importError.value).toBeNull() + }) + + it("should clear search error when dialog opens", () => { + const searchError = ref("Previous search error") + + // Simulate the dialog open watcher logic + searchError.value = "" + + expect(searchError.value).toBe("") + }) + }) +}) diff --git a/VueApp/src/Effort/components/CourseAddDialog.vue b/VueApp/src/Effort/components/CourseAddDialog.vue new file mode 100644 index 00000000..19d7b4c0 --- /dev/null +++ b/VueApp/src/Effort/components/CourseAddDialog.vue @@ -0,0 +1,215 @@ + + + diff --git a/VueApp/src/Effort/components/CourseEditDialog.vue b/VueApp/src/Effort/components/CourseEditDialog.vue new file mode 100644 index 00000000..14ff68a0 --- /dev/null +++ b/VueApp/src/Effort/components/CourseEditDialog.vue @@ -0,0 +1,163 @@ + + + diff --git a/VueApp/src/Effort/components/CourseImportDialog.vue b/VueApp/src/Effort/components/CourseImportDialog.vue new file mode 100644 index 00000000..726edeeb --- /dev/null +++ b/VueApp/src/Effort/components/CourseImportDialog.vue @@ -0,0 +1,362 @@ + + + diff --git a/VueApp/src/Effort/composables/use-effort-permissions.ts b/VueApp/src/Effort/composables/use-effort-permissions.ts index 53a6c753..75299cd0 100644 --- a/VueApp/src/Effort/composables/use-effort-permissions.ts +++ b/VueApp/src/Effort/composables/use-effort-permissions.ts @@ -12,6 +12,11 @@ const EffortPermissions = { ManageTerms: "SVMSecure.Effort.ManageTerms", VerifyEffort: "SVMSecure.Effort.VerifyEffort", ViewAudit: "SVMSecure.Effort.ViewAudit", + // Course permissions + ImportCourse: "SVMSecure.Effort.ImportCourse", + EditCourse: "SVMSecure.Effort.EditCourse", + DeleteCourse: "SVMSecure.Effort.DeleteCourse", + ManageRCourseEnrollment: "SVMSecure.Effort.ManageRCourseEnrollment", } as const /** @@ -34,6 +39,10 @@ function useEffortPermissions() { const hasManageTerms = computed(() => hasPermission(EffortPermissions.ManageTerms)) const hasVerifyEffort = computed(() => hasPermission(EffortPermissions.VerifyEffort)) const hasViewAudit = computed(() => hasPermission(EffortPermissions.ViewAudit)) + const hasImportCourse = computed(() => hasPermission(EffortPermissions.ImportCourse)) + const hasEditCourse = computed(() => hasPermission(EffortPermissions.EditCourse)) + const hasDeleteCourse = computed(() => hasPermission(EffortPermissions.DeleteCourse)) + const hasManageRCourseEnrollment = computed(() => hasPermission(EffortPermissions.ManageRCourseEnrollment)) const isAdmin = computed(() => hasViewAllDepartments.value) return { @@ -44,6 +53,10 @@ function useEffortPermissions() { hasManageTerms, hasVerifyEffort, hasViewAudit, + hasImportCourse, + hasEditCourse, + hasDeleteCourse, + hasManageRCourseEnrollment, isAdmin, permissions: computed(() => userStore.userInfo.permissions), } diff --git a/VueApp/src/Effort/layouts/EffortLayout.vue b/VueApp/src/Effort/layouts/EffortLayout.vue index ee4b21c0..1ccfd7ac 100644 --- a/VueApp/src/Effort/layouts/EffortLayout.vue +++ b/VueApp/src/Effort/layouts/EffortLayout.vue @@ -194,6 +194,19 @@ + + + + Courses + + + + + diff --git a/VueApp/src/Effort/router/routes.ts b/VueApp/src/Effort/router/routes.ts index fc3597ad..e8a2d8c7 100644 --- a/VueApp/src/Effort/router/routes.ts +++ b/VueApp/src/Effort/router/routes.ts @@ -24,6 +24,12 @@ const routes = [ component: () => import("@/Effort/pages/TermManagement.vue"), name: "TermManagement", }, + { + path: "/Effort/courses", + meta: { layout: EffortLayout }, + component: () => import("@/Effort/pages/CourseList.vue"), + name: "CourseList", + }, { path: "/Effort/audit", meta: { layout: EffortLayout, permissions: ["SVMSecure.Effort.ViewAudit"] }, diff --git a/VueApp/src/Effort/services/effort-service.ts b/VueApp/src/Effort/services/effort-service.ts index cf1be563..68b106be 100644 --- a/VueApp/src/Effort/services/effort-service.ts +++ b/VueApp/src/Effort/services/effort-service.ts @@ -1,7 +1,15 @@ import { useFetch } from "@/composables/ViperFetch" -import type { TermDto, AvailableTermDto } from "../types" +import type { + TermDto, + AvailableTermDto, + CourseDto, + BannerCourseDto, + CreateCourseRequest, + UpdateCourseRequest, + ImportCourseRequest, +} from "../types" -const { get, post, del } = useFetch() +const { get, post, put, del, patch } = useFetch() /** * Service for Effort API calls. @@ -111,6 +119,173 @@ class EffortService { } return response.result as AvailableTermDto[] } + + // Course Operations + + /** + * Get all courses for a term, optionally filtered by department. + */ + async getCourses(termCode: number, dept?: string): Promise { + const params = new URLSearchParams({ termCode: termCode.toString() }) + if (dept) { + params.append("dept", dept) + } + const response = await get(`${this.baseUrl}/courses?${params.toString()}`) + if (!response.success || !Array.isArray(response.result)) { + return [] + } + return response.result as CourseDto[] + } + + /** + * Get a single course by ID. + */ + async getCourse(courseId: number): Promise { + const response = await get(`${this.baseUrl}/courses/${courseId}`) + if (!response.success || !response.result) { + return null + } + return response.result as CourseDto + } + + /** + * Search for courses in Banner. + */ + async searchBannerCourses( + termCode: number, + filters: { subjCode?: string; crseNumb?: string; crn?: string } = {}, + ): Promise { + const params = new URLSearchParams({ termCode: termCode.toString() }) + if (filters.subjCode) { + params.append("subjCode", filters.subjCode) + } + if (filters.crseNumb) { + params.append("crseNumb", filters.crseNumb) + } + if (filters.crn) { + params.append("crn", filters.crn) + } + + const response = await get(`${this.baseUrl}/courses/search?${params.toString()}`) + if (!response.success || !Array.isArray(response.result)) { + let errorMessage = "Search failed" + if (typeof response.errors === "string") { + errorMessage = response.errors + } else if (Array.isArray(response.errors)) { + errorMessage = response.errors.join(", ") + } + throw new Error(errorMessage) + } + return response.result as BannerCourseDto[] + } + + /** + * Import a course from Banner. + */ + async importCourse( + request: ImportCourseRequest, + ): Promise<{ success: boolean; course?: CourseDto; error?: string }> { + const response = await post(`${this.baseUrl}/courses/import`, request) + if (!response.success) { + let errorMessage = "Failed to import course" + if (typeof response.errors === "string") { + errorMessage = response.errors + } else if (Array.isArray(response.errors)) { + errorMessage = response.errors.join(", ") + } + return { success: false, error: errorMessage } + } + return { success: true, course: response.result as CourseDto } + } + + /** + * Create a course manually. + */ + async createCourse( + request: CreateCourseRequest, + ): Promise<{ success: boolean; course?: CourseDto; error?: string }> { + const response = await post(`${this.baseUrl}/courses`, request) + if (!response.success) { + let errorMessage = "Failed to create course" + if (typeof response.errors === "string") { + errorMessage = response.errors + } else if (Array.isArray(response.errors)) { + errorMessage = response.errors.join(", ") + } + return { success: false, error: errorMessage } + } + return { success: true, course: response.result as CourseDto } + } + + /** + * Update a course (full update - requires EditCourse permission). + */ + async updateCourse( + courseId: number, + request: UpdateCourseRequest, + ): Promise<{ success: boolean; course?: CourseDto; error?: string }> { + const response = await put(`${this.baseUrl}/courses/${courseId}`, request) + if (!response.success) { + let errorMessage = "Failed to update course" + if (typeof response.errors === "string") { + errorMessage = response.errors + } else if (Array.isArray(response.errors)) { + errorMessage = response.errors.join(", ") + } + return { success: false, error: errorMessage } + } + return { success: true, course: response.result as CourseDto } + } + + /** + * Update only the enrollment for an R-course (requires ManageRCourseEnrollment permission). + */ + async updateCourseEnrollment( + courseId: number, + enrollment: number, + ): Promise<{ success: boolean; course?: CourseDto; error?: string }> { + const response = await patch(`${this.baseUrl}/courses/${courseId}/enrollment`, { enrollment }) + if (!response.success) { + let errorMessage = "Failed to update enrollment" + if (typeof response.errors === "string") { + errorMessage = response.errors + } else if (Array.isArray(response.errors)) { + errorMessage = response.errors.join(", ") + } + return { success: false, error: errorMessage } + } + return { success: true, course: response.result as CourseDto } + } + + /** + * Delete a course. + */ + async deleteCourse(courseId: number): Promise { + const response = await del(`${this.baseUrl}/courses/${courseId}`) + return response.success + } + + /** + * Check if a course can be deleted. + */ + async canDeleteCourse(courseId: number): Promise<{ canDelete: boolean; recordCount: number }> { + const response = await get(`${this.baseUrl}/courses/${courseId}/can-delete`) + if (!response.success || !response.result) { + return { canDelete: false, recordCount: 0 } + } + return response.result as { canDelete: boolean; recordCount: number } + } + + /** + * Get valid custodial department codes. + */ + async getDepartments(): Promise { + const response = await get(`${this.baseUrl}/courses/departments`) + if (!response.success || !Array.isArray(response.result)) { + return [] + } + return response.result as string[] + } } const effortService = new EffortService() diff --git a/VueApp/src/Effort/types/index.ts b/VueApp/src/Effort/types/index.ts index 5188684a..3edf8b81 100644 --- a/VueApp/src/Effort/types/index.ts +++ b/VueApp/src/Effort/types/index.ts @@ -106,6 +106,47 @@ type TermOptionDto = { termName: string } +// Course management types +type BannerCourseDto = { + crn: string + subjCode: string + crseNumb: string + seqNumb: string + title: string + enrollment: number + unitType: string // F=Fixed, V=Variable + unitLow: number + unitHigh: number + deptCode: string + courseCode: string + isVariableUnits: boolean + alreadyImported: boolean + importedUnitValues: number[] +} + +type CreateCourseRequest = { + termCode: number + crn: string + subjCode: string + crseNumb: string + seqNumb: string + enrollment: number + units: number + custDept: string +} + +type UpdateCourseRequest = { + enrollment: number + units: number + custDept: string +} + +type ImportCourseRequest = { + termCode: number + crn: string + units?: number // For variable-unit courses +} + export type { TermDto, PersonDto, @@ -116,4 +157,8 @@ export type { ChangeDetail, ModifierInfo, TermOptionDto, + BannerCourseDto, + CreateCourseRequest, + UpdateCourseRequest, + ImportCourseRequest, } diff --git a/VueApp/src/composables/ViperFetch.ts b/VueApp/src/composables/ViperFetch.ts index c8d48e10..e76d5724 100644 --- a/VueApp/src/composables/ViperFetch.ts +++ b/VueApp/src/composables/ViperFetch.ts @@ -230,7 +230,14 @@ function useFetch() { return await fetchWrapper(url, options) } - return { get, post, put, del, createUrlSearchParams } + async function patch(url: string = "", body: any = {}, options: any = {}): Promise { + options.method = "PATCH" + options.body = JSON.stringify(body) + addHeader(options, "Content-Type", "application/json") + return await fetchWrapper(url, options) + } + + return { get, post, put, del, patch, createUrlSearchParams } } export { useFetch, postForBlob } diff --git a/test/Effort/CourseServiceTests.cs b/test/Effort/CourseServiceTests.cs new file mode 100644 index 00000000..1c43cc72 --- /dev/null +++ b/test/Effort/CourseServiceTests.cs @@ -0,0 +1,751 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging; +using Moq; +using Viper.Areas.Effort; +using Viper.Areas.Effort.Models.DTOs.Requests; +using Viper.Areas.Effort.Models.DTOs.Responses; +using Viper.Areas.Effort.Models.Entities; +using Viper.Areas.Effort.Services; +using Viper.Classes.SQLContext; +using Viper.Models.Courses; + +namespace Viper.test.Effort; + +/// +/// Unit tests for CourseService course management operations. +/// +public sealed class CourseServiceTests : IDisposable +{ + private readonly EffortDbContext _context; + private readonly CoursesContext _coursesContext; + private readonly Mock _auditServiceMock; + private readonly Mock> _loggerMock; + private readonly CourseService _courseService; + + public CourseServiceTests() + { + var effortOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + + var coursesOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + + _context = new EffortDbContext(effortOptions); + _coursesContext = new CoursesContext(coursesOptions); + _auditServiceMock = new Mock(); + _loggerMock = new Mock>(); + + // Setup synchronous audit methods used within transactions + _auditServiceMock + .Setup(s => s.AddCourseChangeAudit(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())); + + _courseService = new CourseService(_context, _coursesContext, _auditServiceMock.Object, _loggerMock.Object); + } + + public void Dispose() + { + _context.Dispose(); + _coursesContext.Dispose(); + } + + #region GetCoursesAsync Tests + + [Fact] + public async Task GetCoursesAsync_ReturnsAllCoursesForTerm_OrderedBySubjCodeCrseNumbSeqNumb() + { + // Arrange + _context.Courses.AddRange( + new EffortCourse { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "VME", CrseNumb = "200", SeqNumb = "001", Enrollment = 10, Units = 4, CustDept = "VME" }, + new EffortCourse { Id = 2, TermCode = 202410, Crn = "12346", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }, + new EffortCourse { Id = 3, TermCode = 202410, Crn = "12347", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "002", Enrollment = 15, Units = 4, CustDept = "DVM" } + ); + await _context.SaveChangesAsync(); + + // Act + var courses = await _courseService.GetCoursesAsync(202410); + + // Assert + Assert.Equal(3, courses.Count); + Assert.Equal("DVM", courses[0].SubjCode); + Assert.Equal("001", courses[0].SeqNumb); + Assert.Equal("DVM", courses[1].SubjCode); + Assert.Equal("002", courses[1].SeqNumb); + Assert.Equal("VME", courses[2].SubjCode); + } + + [Fact] + public async Task GetCoursesAsync_FiltersByDepartment_WhenDepartmentProvided() + { + // Arrange + _context.Courses.AddRange( + new EffortCourse { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "VME", CrseNumb = "200", SeqNumb = "001", Enrollment = 10, Units = 4, CustDept = "VME" }, + new EffortCourse { Id = 2, TermCode = 202410, Crn = "12346", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" } + ); + await _context.SaveChangesAsync(); + + // Act + var courses = await _courseService.GetCoursesAsync(202410, "DVM"); + + // Assert + Assert.Single(courses); + Assert.Equal("DVM", courses[0].CustDept); + } + + [Fact] + public async Task GetCoursesAsync_ReturnsEmptyList_WhenNoCoursesExistForTerm() + { + // Act + var courses = await _courseService.GetCoursesAsync(999999); + + // Assert + Assert.Empty(courses); + } + + #endregion + + #region GetCourseAsync Tests + + [Fact] + public async Task GetCourseAsync_ReturnsCourse_WhenCourseExists() + { + // Arrange + _context.Courses.Add(new EffortCourse { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }); + await _context.SaveChangesAsync(); + + // Act + var course = await _courseService.GetCourseAsync(1); + + // Assert + Assert.NotNull(course); + Assert.Equal(1, course.Id); + Assert.Equal("DVM", course.SubjCode); + Assert.Equal("443", course.CrseNumb); + } + + [Fact] + public async Task GetCourseAsync_ReturnsNull_WhenCourseDoesNotExist() + { + // Act + var course = await _courseService.GetCourseAsync(999); + + // Assert + Assert.Null(course); + } + + #endregion + + #region CreateCourseAsync Tests + + [Fact] + public async Task CreateCourseAsync_CreatesCourse_WithValidData() + { + // Arrange + _context.Terms.Add(new EffortTerm { TermCode = 202410 }); + await _context.SaveChangesAsync(); + + var request = new CreateCourseRequest + { + TermCode = 202410, + Crn = "99999", + SubjCode = "TST", + CrseNumb = "101", + SeqNumb = "001", + Enrollment = 25, + Units = 4, + CustDept = "DVM" + }; + + // Act + var course = await _courseService.CreateCourseAsync(request); + + // Assert + Assert.NotNull(course); + Assert.Equal("99999", course.Crn); + Assert.Equal("TST", course.SubjCode); + Assert.Equal("101", course.CrseNumb); + Assert.Equal(25, course.Enrollment); + Assert.Equal(4, course.Units); + Assert.Equal("DVM", course.CustDept); + + // Verify saved to database + var savedCourse = await _context.Courses.FirstOrDefaultAsync(c => c.Crn == "99999"); + Assert.NotNull(savedCourse); + } + + [Fact] + public async Task CreateCourseAsync_TrimsAndUppercasesStringFields() + { + // Arrange + _context.Terms.Add(new EffortTerm { TermCode = 202410 }); + await _context.SaveChangesAsync(); + + var request = new CreateCourseRequest + { + TermCode = 202410, + Crn = " 99999 ", + SubjCode = " tst ", + CrseNumb = " 101 ", + SeqNumb = " 001 ", + Enrollment = 25, + Units = 4, + CustDept = "DVM" + }; + + // Act + var course = await _courseService.CreateCourseAsync(request); + + // Assert + Assert.Equal("99999", course.Crn); + Assert.Equal("TST", course.SubjCode); + Assert.Equal("101", course.CrseNumb); + Assert.Equal("001", course.SeqNumb); + Assert.Equal("DVM", course.CustDept); + } + + [Fact] + public void IsValidCustodialDepartment_ReturnsFalse_ForInvalidDepartment() + { + // Act & Assert + Assert.False(_courseService.IsValidCustodialDepartment("INVALID")); + } + + [Fact] + public void IsValidCustodialDepartment_ReturnsTrue_ForValidDepartment() + { + // Act & Assert + Assert.True(_courseService.IsValidCustodialDepartment("DVM")); + Assert.True(_courseService.IsValidCustodialDepartment("dvm")); // Case insensitive + } + + [Fact] + public async Task CourseExistsAsync_ReturnsTrue_WhenCourseExists() + { + // Arrange + _context.Courses.Add(new EffortCourse { Id = 1, TermCode = 202410, Crn = "99999", SubjCode = "TST", CrseNumb = "101", SeqNumb = "001", Enrollment = 25, Units = 4, CustDept = "DVM" }); + await _context.SaveChangesAsync(); + + // Act & Assert + Assert.True(await _courseService.CourseExistsAsync(202410, "99999", 4)); + } + + [Fact] + public async Task CourseExistsAsync_ReturnsFalse_WhenCourseDoesNotExist() + { + // Act & Assert + Assert.False(await _courseService.CourseExistsAsync(202410, "99999", 4)); + } + + [Fact] + public async Task CourseExistsAsync_ReturnsFalse_WhenSameCrnDifferentUnits() + { + // Arrange - course exists with 4 units + _context.Courses.Add(new EffortCourse { Id = 1, TermCode = 202410, Crn = "99999", SubjCode = "TST", CrseNumb = "101", SeqNumb = "001", Enrollment = 25, Units = 4, CustDept = "DVM" }); + await _context.SaveChangesAsync(); + + // Act & Assert - checking for 2 units should return false + Assert.False(await _courseService.CourseExistsAsync(202410, "99999", 2)); + } + + [Fact] + public async Task CreateCourseAsync_CreatesAuditEntry() + { + // Arrange + _context.Terms.Add(new EffortTerm { TermCode = 202410 }); + await _context.SaveChangesAsync(); + + var request = new CreateCourseRequest + { + TermCode = 202410, + Crn = "99999", + SubjCode = "TST", + CrseNumb = "101", + SeqNumb = "001", + Enrollment = 25, + Units = 4, + CustDept = "DVM" + }; + + // Act + await _courseService.CreateCourseAsync(request); + + // Assert + _auditServiceMock.Verify( + s => s.AddCourseChangeAudit( + It.IsAny(), + 202410, + "CreateCourse", + null, + It.IsAny()), + Times.Once); + } + + #endregion + + #region UpdateCourseAsync Tests + + [Fact] + public async Task UpdateCourseAsync_UpdatesCourse_WithValidData() + { + // Arrange + _context.Courses.Add(new EffortCourse { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }); + await _context.SaveChangesAsync(); + + var request = new UpdateCourseRequest + { + Enrollment = 30, + Units = 5, + CustDept = "VME" + }; + + // Act + var course = await _courseService.UpdateCourseAsync(1, request); + + // Assert + Assert.NotNull(course); + Assert.Equal(30, course.Enrollment); + Assert.Equal(5, course.Units); + Assert.Equal("VME", course.CustDept); + } + + [Fact] + public async Task UpdateCourseAsync_ReturnsNull_WhenCourseDoesNotExist() + { + // Arrange + var request = new UpdateCourseRequest + { + Enrollment = 30, + Units = 5, + CustDept = "VME" + }; + + // Act + var course = await _courseService.UpdateCourseAsync(999, request); + + // Assert + Assert.Null(course); + } + + [Fact] + public async Task UpdateCourseAsync_ThrowsArgumentException_ForInvalidCustodialDepartment() + { + // Arrange + _context.Courses.Add(new EffortCourse { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }); + await _context.SaveChangesAsync(); + + var request = new UpdateCourseRequest + { + Enrollment = 30, + Units = 5, + CustDept = "INVALID" + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _courseService.UpdateCourseAsync(1, request) + ); + Assert.Contains("Invalid custodial department", exception.Message); + } + + [Fact] + public async Task UpdateCourseAsync_CreatesAuditEntryWithOldAndNewValues() + { + // Arrange + _context.Courses.Add(new EffortCourse { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }); + await _context.SaveChangesAsync(); + + var request = new UpdateCourseRequest + { + Enrollment = 30, + Units = 5, + CustDept = "VME" + }; + + // Act + await _courseService.UpdateCourseAsync(1, request); + + // Assert + _auditServiceMock.Verify( + s => s.AddCourseChangeAudit( + 1, + 202410, + "UpdateCourse", + It.IsAny(), // Old values + It.IsAny()), // New values + Times.Once); + } + + #endregion + + #region UpdateCourseEnrollmentAsync Tests + + [Fact] + public async Task UpdateCourseEnrollmentAsync_UpdatesEnrollment_ForRCourse() + { + // Arrange - R-course ends with 'R' + _context.Courses.Add(new EffortCourse { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443R", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }); + await _context.SaveChangesAsync(); + + // Act + var course = await _courseService.UpdateCourseEnrollmentAsync(1, 50); + + // Assert + Assert.NotNull(course); + Assert.Equal(50, course.Enrollment); + } + + [Fact] + public async Task UpdateCourseEnrollmentAsync_ThrowsInvalidOperationException_ForNonRCourse() + { + // Arrange - Non R-course (doesn't end with 'R') + _context.Courses.Add(new EffortCourse { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }); + await _context.SaveChangesAsync(); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _courseService.UpdateCourseEnrollmentAsync(1, 50) + ); + Assert.Contains("not an R-course", exception.Message); + } + + [Fact] + public async Task UpdateCourseEnrollmentAsync_ReturnsNull_WhenCourseDoesNotExist() + { + // Act + var course = await _courseService.UpdateCourseEnrollmentAsync(999, 50); + + // Assert + Assert.Null(course); + } + + #endregion + + #region DeleteCourseAsync Tests + + [Fact] + public async Task DeleteCourseAsync_DeletesCourse_WhenCourseExists() + { + // Arrange + _context.Courses.Add(new EffortCourse { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }); + await _context.SaveChangesAsync(); + + // Act + var result = await _courseService.DeleteCourseAsync(1); + + // Assert + Assert.True(result); + Assert.Null(await _context.Courses.FindAsync(1)); + } + + [Fact] + public async Task DeleteCourseAsync_ReturnsFalse_WhenCourseDoesNotExist() + { + // Act + var result = await _courseService.DeleteCourseAsync(999); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task DeleteCourseAsync_DeletesAssociatedRecords() + { + // Arrange + var course = new EffortCourse { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }; + _context.Courses.Add(course); + await _context.SaveChangesAsync(); + + _context.Records.AddRange( + new EffortRecord { Id = 1, TermCode = 202410, CourseId = 1, PersonId = 100 }, + new EffortRecord { Id = 2, TermCode = 202410, CourseId = 1, PersonId = 101 } + ); + await _context.SaveChangesAsync(); + + // Act + var result = await _courseService.DeleteCourseAsync(1); + + // Assert + Assert.True(result); + Assert.Empty(await _context.Records.Where(r => r.CourseId == 1).ToListAsync()); + } + + [Fact] + public async Task DeleteCourseAsync_CreatesAuditEntry() + { + // Arrange + _context.Courses.Add(new EffortCourse { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }); + await _context.SaveChangesAsync(); + + // Act + await _courseService.DeleteCourseAsync(1); + + // Assert + _auditServiceMock.Verify( + s => s.AddCourseChangeAudit( + 1, + 202410, + "DeleteCourse", + It.IsAny(), // Course info + null), + Times.Once); + } + + #endregion + + #region CanDeleteCourseAsync Tests + + [Fact] + public async Task CanDeleteCourseAsync_ReturnsTrueAndZeroCount_WhenNoRecords() + { + // Arrange + _context.Courses.Add(new EffortCourse { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }); + await _context.SaveChangesAsync(); + + // Act + var (canDelete, recordCount) = await _courseService.CanDeleteCourseAsync(1); + + // Assert + Assert.True(canDelete); + Assert.Equal(0, recordCount); + } + + [Fact] + public async Task CanDeleteCourseAsync_ReturnsTrueWithRecordCount_WhenRecordsExist() + { + // Arrange + _context.Courses.Add(new EffortCourse { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }); + _context.Records.AddRange( + new EffortRecord { Id = 1, TermCode = 202410, CourseId = 1, PersonId = 100 }, + new EffortRecord { Id = 2, TermCode = 202410, CourseId = 1, PersonId = 101 }, + new EffortRecord { Id = 3, TermCode = 202410, CourseId = 1, PersonId = 102 } + ); + await _context.SaveChangesAsync(); + + // Act + var (canDelete, recordCount) = await _courseService.CanDeleteCourseAsync(1); + + // Assert + Assert.True(canDelete); // Always true - deletion is allowed + Assert.Equal(3, recordCount); + } + + #endregion + + #region GetValidCustodialDepartments Tests + + [Fact] + public void GetValidCustodialDepartments_ReturnsAllValidDepartments() + { + // Act + var departments = _courseService.GetValidCustodialDepartments(); + + // Assert + Assert.Contains("APC", departments); + Assert.Contains("VMB", departments); + Assert.Contains("VME", departments); + Assert.Contains("VSR", departments); + Assert.Contains("PMI", departments); + Assert.Contains("PHR", departments); + Assert.Contains("UNK", departments); + Assert.Contains("DVM", departments); + Assert.Contains("VET", departments); + Assert.Equal(9, departments.Count); + } + + #endregion + + #region SearchBannerCoursesAsync Tests + + [Fact] + public async Task SearchBannerCoursesAsync_ReturnsMatchingCourses_BySubjCode() + { + // Arrange + _coursesContext.Baseinfos.AddRange( + new Baseinfo { BaseinfoPkey = "20241012345", BaseinfoTermCode = "202410", BaseinfoCrn = "12345", BaseinfoSubjCode = "DVM ", BaseinfoCrseNumb = "443 ", BaseinfoSeqNumb = "001", BaseinfoEnrollment = 20, BaseinfoUnitType = "F", BaseinfoUnitLow = 4, BaseinfoUnitHigh = 4, BaseinfoDeptCode = "72030", BaseinfoCollCode = "VM", BaseinfoTitle = "Clinical Medicine", BaseinfoXlistFlag = "N", BaseinfoXlistGroup = "" }, + new Baseinfo { BaseinfoPkey = "20241012346", BaseinfoTermCode = "202410", BaseinfoCrn = "12346", BaseinfoSubjCode = "VME ", BaseinfoCrseNumb = "200 ", BaseinfoSeqNumb = "001", BaseinfoEnrollment = 10, BaseinfoUnitType = "F", BaseinfoUnitLow = 3, BaseinfoUnitHigh = 3, BaseinfoDeptCode = "72030", BaseinfoCollCode = "VM", BaseinfoTitle = "Veterinary Medicine", BaseinfoXlistFlag = "N", BaseinfoXlistGroup = "" } + ); + await _coursesContext.SaveChangesAsync(); + + // Act + var courses = await _courseService.SearchBannerCoursesAsync(202410, subjCode: "DVM"); + + // Assert + Assert.Single(courses); + Assert.Equal("DVM", courses[0].SubjCode); + } + + [Fact] + public async Task SearchBannerCoursesAsync_ReturnsCourse_ByCrn() + { + // Arrange + _coursesContext.Baseinfos.Add( + new Baseinfo { BaseinfoPkey = "20241012345", BaseinfoTermCode = "202410", BaseinfoCrn = "12345", BaseinfoSubjCode = "DVM ", BaseinfoCrseNumb = "443 ", BaseinfoSeqNumb = "001", BaseinfoEnrollment = 20, BaseinfoUnitType = "F", BaseinfoUnitLow = 4, BaseinfoUnitHigh = 4, BaseinfoDeptCode = "72030", BaseinfoCollCode = "VM", BaseinfoTitle = "Clinical Medicine", BaseinfoXlistFlag = "N", BaseinfoXlistGroup = "" } + ); + await _coursesContext.SaveChangesAsync(); + + // Act + var courses = await _courseService.SearchBannerCoursesAsync(202410, crn: "12345"); + + // Assert + Assert.Single(courses); + Assert.Equal("12345", courses[0].Crn); + } + + [Fact] + public async Task SearchBannerCoursesAsync_MarksAlreadyImportedCourses() + { + // Arrange + _coursesContext.Baseinfos.Add( + new Baseinfo { BaseinfoPkey = "20241012345", BaseinfoTermCode = "202410", BaseinfoCrn = "12345", BaseinfoSubjCode = "DVM ", BaseinfoCrseNumb = "443 ", BaseinfoSeqNumb = "001", BaseinfoEnrollment = 20, BaseinfoUnitType = "F", BaseinfoUnitLow = 4, BaseinfoUnitHigh = 4, BaseinfoDeptCode = "72030", BaseinfoCollCode = "VM", BaseinfoTitle = "Clinical Medicine", BaseinfoXlistFlag = "N", BaseinfoXlistGroup = "" } + ); + await _coursesContext.SaveChangesAsync(); + + // Add to Effort (already imported) + _context.Courses.Add(new EffortCourse { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }); + await _context.SaveChangesAsync(); + + // Act + var courses = await _courseService.SearchBannerCoursesAsync(202410, crn: "12345"); + + // Assert + Assert.Single(courses); + Assert.True(courses[0].AlreadyImported); + } + + #endregion + + #region ImportCourseFromBannerAsync Tests + + [Fact] + public async Task ImportCourseFromBannerAsync_ImportsCourse_WithCorrectDataMapping() + { + // Arrange + var bannerCourse = new BannerCourseDto + { + Crn = "12345", + SubjCode = "DVM", + CrseNumb = "443", + SeqNumb = "001", + Enrollment = 20, + UnitType = "F", + UnitLow = 4, + UnitHigh = 4, + DeptCode = "72030" + }; + + var request = new ImportCourseRequest + { + TermCode = 202410, + Crn = "12345" + }; + + // Act + var course = await _courseService.ImportCourseFromBannerAsync(request, bannerCourse); + + // Assert + Assert.NotNull(course); + Assert.Equal("12345", course.Crn); + Assert.Equal("DVM", course.SubjCode); + Assert.Equal("443", course.CrseNumb); + Assert.Equal("001", course.SeqNumb); + Assert.Equal(20, course.Enrollment); + Assert.Equal(4, course.Units); + Assert.Equal("VME", course.CustDept); // 72030 maps to VME + } + + [Fact] + public async Task GetBannerCourseAsync_ReturnsNull_WhenCourseNotFoundInBanner() + { + // Act + var bannerCourse = await _courseService.GetBannerCourseAsync(202410, "99999"); + + // Assert + Assert.Null(bannerCourse); + } + + [Fact] + public async Task GetBannerCourseAsync_ReturnsCourse_WhenCourseExists() + { + // Arrange + _coursesContext.Baseinfos.Add( + new Baseinfo { BaseinfoPkey = "20241012345", BaseinfoTermCode = "202410", BaseinfoCrn = "12345", BaseinfoSubjCode = "DVM ", BaseinfoCrseNumb = "443 ", BaseinfoSeqNumb = "001", BaseinfoEnrollment = 20, BaseinfoUnitType = "F", BaseinfoUnitLow = 4, BaseinfoUnitHigh = 4, BaseinfoDeptCode = "72030", BaseinfoCollCode = "VM", BaseinfoTitle = "Clinical Medicine", BaseinfoXlistFlag = "N", BaseinfoXlistGroup = "" } + ); + await _coursesContext.SaveChangesAsync(); + + // Act + var bannerCourse = await _courseService.GetBannerCourseAsync(202410, "12345"); + + // Assert + Assert.NotNull(bannerCourse); + Assert.Equal("12345", bannerCourse.Crn); + Assert.Equal("DVM", bannerCourse.SubjCode); + } + + [Fact] + public async Task ImportCourseFromBannerAsync_HandlesVariableUnitCourse_WithSpecifiedUnits() + { + // Arrange + var bannerCourse = new BannerCourseDto + { + Crn = "12345", + SubjCode = "DVM", + CrseNumb = "443", + SeqNumb = "001", + Enrollment = 20, + UnitType = "V", + UnitLow = 1, + UnitHigh = 4, + DeptCode = "72030" + }; + + var request = new ImportCourseRequest + { + TermCode = 202410, + Crn = "12345", + Units = 3 + }; + + // Act + var course = await _courseService.ImportCourseFromBannerAsync(request, bannerCourse); + + // Assert + Assert.Equal(3, course.Units); + } + + [Fact] + public async Task ImportCourseFromBannerAsync_UsesUnitLow_WhenNoUnitsSpecifiedForVariableCourse() + { + // Arrange + var bannerCourse = new BannerCourseDto + { + Crn = "12345", + SubjCode = "DVM", + CrseNumb = "443", + SeqNumb = "001", + Enrollment = 20, + UnitType = "V", + UnitLow = 1, + UnitHigh = 4, + DeptCode = "72030" + }; + + var request = new ImportCourseRequest + { + TermCode = 202410, + Crn = "12345" + // Units not specified + }; + + // Act + var course = await _courseService.ImportCourseFromBannerAsync(request, bannerCourse); + + // Assert - should use UnitLow + Assert.Equal(1, course.Units); + } + + #endregion +} diff --git a/test/Effort/CoursesControllerTests.cs b/test/Effort/CoursesControllerTests.cs new file mode 100644 index 00000000..aa789b95 --- /dev/null +++ b/test/Effort/CoursesControllerTests.cs @@ -0,0 +1,739 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using Viper.Areas.Effort.Controllers; +using Viper.Areas.Effort.Models.DTOs.Requests; +using Viper.Areas.Effort.Models.DTOs.Responses; +using Viper.Areas.Effort.Services; + +namespace Viper.test.Effort; + +/// +/// Unit tests for CoursesController API endpoints. +/// +public sealed class CoursesControllerTests +{ + private readonly Mock _courseServiceMock; + private readonly Mock _permissionServiceMock; + private readonly Mock> _loggerMock; + private readonly CoursesController _controller; + + public CoursesControllerTests() + { + _courseServiceMock = new Mock(); + _permissionServiceMock = new Mock(); + _loggerMock = new Mock>(); + + _controller = new CoursesController( + _courseServiceMock.Object, + _permissionServiceMock.Object, + _loggerMock.Object); + + SetupControllerContext(); + } + + private void SetupControllerContext() + { + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + RequestServices = serviceProvider + } + }; + } + + #region GetCourses Tests + + [Fact] + public async Task GetCourses_ReturnsOk_WithCourseList() + { + // Arrange + var courses = new List + { + new CourseDto { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }, + new CourseDto { Id = 2, TermCode = 202410, Crn = "12346", SubjCode = "VME", CrseNumb = "200", SeqNumb = "001", Enrollment = 15, Units = 3, CustDept = "VME" } + }; + _permissionServiceMock.Setup(s => s.HasFullAccessAsync(It.IsAny())).ReturnsAsync(true); + _courseServiceMock.Setup(s => s.GetCoursesAsync(202410, null, It.IsAny())) + .ReturnsAsync(courses); + + // Act + var result = await _controller.GetCourses(202410); + + // Assert + var okResult = Assert.IsType(result.Result); + var returnedCourses = Assert.IsAssignableFrom>(okResult.Value); + Assert.Equal(2, returnedCourses.Count()); + } + + [Fact] + public async Task GetCourses_FiltersByDepartment_WhenProvided() + { + // Arrange + var courses = new List + { + new CourseDto { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" } + }; + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync("DVM", It.IsAny())).ReturnsAsync(true); + _permissionServiceMock.Setup(s => s.HasFullAccessAsync(It.IsAny())).ReturnsAsync(true); + _courseServiceMock.Setup(s => s.GetCoursesAsync(202410, "DVM", It.IsAny())) + .ReturnsAsync(courses); + + // Act + var result = await _controller.GetCourses(202410, "DVM"); + + // Assert + var okResult = Assert.IsType(result.Result); + var returnedCourses = Assert.IsAssignableFrom>(okResult.Value); + Assert.Single(returnedCourses); + } + + #endregion + + #region GetCourse Tests + + [Fact] + public async Task GetCourse_ReturnsOk_WhenCourseExists() + { + // Arrange + var course = new CourseDto { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }; + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())) + .ReturnsAsync(course); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync("DVM", It.IsAny())).ReturnsAsync(true); + + // Act + var result = await _controller.GetCourse(1); + + // Assert + var okResult = Assert.IsType(result.Result); + var returnedCourse = Assert.IsType(okResult.Value); + Assert.Equal(1, returnedCourse.Id); + } + + [Fact] + public async Task GetCourse_ReturnsNotFound_WhenCourseDoesNotExist() + { + // Arrange + _courseServiceMock.Setup(s => s.GetCourseAsync(999, It.IsAny())) + .ReturnsAsync((CourseDto?)null); + + // Act + var result = await _controller.GetCourse(999); + + // Assert + var notFoundResult = Assert.IsType(result.Result); + Assert.Contains("999", notFoundResult.Value?.ToString()); + } + + #endregion + + #region CreateCourse Tests + + [Fact] + public async Task CreateCourse_ReturnsCreated_WithCourseDto() + { + // Arrange + var request = new CreateCourseRequest + { + TermCode = 202410, + Crn = "99999", + SubjCode = "TST", + CrseNumb = "101", + SeqNumb = "001", + Enrollment = 25, + Units = 4, + CustDept = "DVM" + }; + var createdCourse = new CourseDto { Id = 10, TermCode = 202410, Crn = "99999", SubjCode = "TST", CrseNumb = "101", SeqNumb = "001", Enrollment = 25, Units = 4, CustDept = "DVM" }; + + _courseServiceMock.Setup(s => s.IsValidCustodialDepartment("DVM")).Returns(true); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync("DVM", It.IsAny())).ReturnsAsync(true); + _courseServiceMock.Setup(s => s.CourseExistsAsync(202410, "99999", 4, It.IsAny())).ReturnsAsync(false); + _courseServiceMock.Setup(s => s.CreateCourseAsync(request, It.IsAny())) + .ReturnsAsync(createdCourse); + + // Act + var result = await _controller.CreateCourse(request); + + // Assert + var createdResult = Assert.IsType(result.Result); + Assert.Equal(201, createdResult.StatusCode); + var returnedCourse = Assert.IsType(createdResult.Value); + Assert.Equal(10, returnedCourse.Id); + } + + [Fact] + public async Task CreateCourse_ReturnsBadRequest_WhenUserNotAuthorizedForDepartment() + { + // Arrange + var request = new CreateCourseRequest + { + TermCode = 202410, + Crn = "99999", + SubjCode = "TST", + CrseNumb = "101", + SeqNumb = "001", + Enrollment = 25, + Units = 4, + CustDept = "VME" + }; + + _courseServiceMock.Setup(s => s.IsValidCustodialDepartment("VME")).Returns(true); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync("VME", It.IsAny())).ReturnsAsync(false); + + // Act + var result = await _controller.CreateCourse(request); + + // Assert + var badRequestResult = Assert.IsType(result.Result); + Assert.Equal(400, badRequestResult.StatusCode); + Assert.Contains("Invalid custodial department", badRequestResult.Value?.ToString()); + } + + [Fact] + public async Task CreateCourse_ReturnsConflict_ForDuplicateCourse() + { + // Arrange + var request = new CreateCourseRequest + { + TermCode = 202410, + Crn = "99999", + SubjCode = "TST", + CrseNumb = "101", + SeqNumb = "001", + Enrollment = 25, + Units = 4, + CustDept = "DVM" + }; + + _courseServiceMock.Setup(s => s.IsValidCustodialDepartment("DVM")).Returns(true); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync("DVM", It.IsAny())).ReturnsAsync(true); + _courseServiceMock.Setup(s => s.CourseExistsAsync(202410, "99999", 4, It.IsAny())).ReturnsAsync(true); + + // Act + var result = await _controller.CreateCourse(request); + + // Assert + var conflictResult = Assert.IsType(result.Result); + Assert.Equal(409, conflictResult.StatusCode); + Assert.Contains("already exists", conflictResult.Value?.ToString()); + } + + [Fact] + public async Task CreateCourse_ReturnsBadRequest_ForInvalidCustodialDepartment() + { + // Arrange + var request = new CreateCourseRequest + { + TermCode = 202410, + Crn = "99999", + SubjCode = "TST", + CrseNumb = "101", + SeqNumb = "001", + Enrollment = 25, + Units = 4, + CustDept = "INVALID" + }; + + _courseServiceMock.Setup(s => s.IsValidCustodialDepartment("INVALID")).Returns(false); + + // Act + var result = await _controller.CreateCourse(request); + + // Assert + var badRequestResult = Assert.IsType(result.Result); + Assert.Equal(400, badRequestResult.StatusCode); + Assert.Contains("Invalid custodial department", badRequestResult.Value?.ToString()); + } + + [Fact] + public async Task CreateCourse_ReturnsBadRequest_ForDbUpdateException() + { + // Arrange + var request = new CreateCourseRequest + { + TermCode = 202410, + Crn = "99999", + SubjCode = "TST", + CrseNumb = "101", + SeqNumb = "001", + Enrollment = 25, + Units = 4, + CustDept = "DVM" + }; + + _courseServiceMock.Setup(s => s.IsValidCustodialDepartment("DVM")).Returns(true); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync("DVM", It.IsAny())).ReturnsAsync(true); + _courseServiceMock.Setup(s => s.CourseExistsAsync(202410, "99999", 4, It.IsAny())).ReturnsAsync(false); + _courseServiceMock.Setup(s => s.CreateCourseAsync(request, It.IsAny())) + .ThrowsAsync(new DbUpdateException("Database constraint violation")); + + // Act + var result = await _controller.CreateCourse(request); + + // Assert + var badRequestResult = Assert.IsType(result.Result); + Assert.Equal(400, badRequestResult.StatusCode); + Assert.Contains("Failed to save course", badRequestResult.Value?.ToString()); + } + + #endregion + + #region UpdateCourse Tests + + [Fact] + public async Task UpdateCourse_ReturnsOk_WithUpdatedCourse() + { + // Arrange + var existingCourse = new CourseDto { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }; + var request = new UpdateCourseRequest + { + Enrollment = 30, + Units = 5, + CustDept = "VME" + }; + var updatedCourse = new CourseDto { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 30, Units = 5, CustDept = "VME" }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(existingCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync("DVM", It.IsAny())).ReturnsAsync(true); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync("VME", It.IsAny())).ReturnsAsync(true); + _courseServiceMock.Setup(s => s.UpdateCourseAsync(1, request, It.IsAny())) + .ReturnsAsync(updatedCourse); + + // Act + var result = await _controller.UpdateCourse(1, request); + + // Assert + var okResult = Assert.IsType(result.Result); + var returnedCourse = Assert.IsType(okResult.Value); + Assert.Equal(30, returnedCourse.Enrollment); + Assert.Equal(5, returnedCourse.Units); + } + + [Fact] + public async Task UpdateCourse_ReturnsNotFound_WhenUserNotAuthorizedForNewDepartment() + { + // Arrange + var existingCourse = new CourseDto { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }; + var request = new UpdateCourseRequest + { + Enrollment = 30, + Units = 5, + CustDept = "VME" + }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(existingCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync("DVM", It.IsAny())).ReturnsAsync(true); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync("VME", It.IsAny())).ReturnsAsync(false); + + // Act + var result = await _controller.UpdateCourse(1, request); + + // Assert + var notFoundResult = Assert.IsType(result.Result); + Assert.Contains("1", notFoundResult.Value?.ToString()); + } + + [Fact] + public async Task UpdateCourse_AllowsSameDepartment_WithoutNewDepartmentAuth() + { + // Arrange - User can update course within their authorized department without needing extra check + var existingCourse = new CourseDto { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }; + var request = new UpdateCourseRequest + { + Enrollment = 30, + Units = 5, + CustDept = "DVM" + }; + var updatedCourse = new CourseDto { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 30, Units = 5, CustDept = "DVM" }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(existingCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync("DVM", It.IsAny())).ReturnsAsync(true); + _courseServiceMock.Setup(s => s.UpdateCourseAsync(1, request, It.IsAny())) + .ReturnsAsync(updatedCourse); + + // Act + var result = await _controller.UpdateCourse(1, request); + + // Assert + var okResult = Assert.IsType(result.Result); + var returnedCourse = Assert.IsType(okResult.Value); + Assert.Equal(30, returnedCourse.Enrollment); + } + + [Fact] + public async Task UpdateCourse_ReturnsNotFound_WhenCourseDoesNotExist() + { + // Arrange + var request = new UpdateCourseRequest + { + Enrollment = 30, + Units = 5, + CustDept = "VME" + }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(999, It.IsAny())).ReturnsAsync((CourseDto?)null); + + // Act + var result = await _controller.UpdateCourse(999, request); + + // Assert + var notFoundResult = Assert.IsType(result.Result); + Assert.Contains("999", notFoundResult.Value?.ToString()); + } + + [Fact] + public async Task UpdateCourse_ReturnsBadRequest_ForInvalidCustodialDepartment() + { + // Arrange + var existingCourse = new CourseDto { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }; + var request = new UpdateCourseRequest + { + Enrollment = 30, + Units = 5, + CustDept = "INVALID" + }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(existingCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync("DVM", It.IsAny())).ReturnsAsync(true); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync("INVALID", It.IsAny())).ReturnsAsync(true); + _courseServiceMock.Setup(s => s.UpdateCourseAsync(1, request, It.IsAny())) + .ThrowsAsync(new ArgumentException("Invalid custodial department: INVALID")); + + // Act + var result = await _controller.UpdateCourse(1, request); + + // Assert + var badRequestResult = Assert.IsType(result.Result); + Assert.Contains("Invalid custodial department", badRequestResult.Value?.ToString()); + } + + [Fact] + public async Task UpdateCourse_ReturnsBadRequest_ForDbUpdateException() + { + // Arrange + var existingCourse = new CourseDto { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }; + var request = new UpdateCourseRequest + { + Enrollment = 30, + Units = 5, + CustDept = "VME" + }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(existingCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync("DVM", It.IsAny())).ReturnsAsync(true); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync("VME", It.IsAny())).ReturnsAsync(true); + _courseServiceMock.Setup(s => s.UpdateCourseAsync(1, request, It.IsAny())) + .ThrowsAsync(new DbUpdateException("Database constraint violation")); + + // Act + var result = await _controller.UpdateCourse(1, request); + + // Assert + var badRequestResult = Assert.IsType(result.Result); + Assert.Contains("Failed to update course", badRequestResult.Value?.ToString()); + } + + #endregion + + #region UpdateCourseEnrollment Tests + + [Fact] + public async Task UpdateCourseEnrollment_ReturnsOk_ForRCourse() + { + // Arrange + var existingCourse = new CourseDto { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443R", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }; + var request = new UpdateEnrollmentRequest { Enrollment = 50 }; + var updatedCourse = new CourseDto { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443R", SeqNumb = "001", Enrollment = 50, Units = 4, CustDept = "DVM" }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(existingCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync("DVM", It.IsAny())).ReturnsAsync(true); + _courseServiceMock.Setup(s => s.UpdateCourseEnrollmentAsync(1, 50, It.IsAny())) + .ReturnsAsync(updatedCourse); + + // Act + var result = await _controller.UpdateCourseEnrollment(1, request); + + // Assert + var okResult = Assert.IsType(result.Result); + var returnedCourse = Assert.IsType(okResult.Value); + Assert.Equal(50, returnedCourse.Enrollment); + } + + [Fact] + public async Task UpdateCourseEnrollment_ReturnsNotFound_WhenCourseDoesNotExist() + { + // Arrange + var request = new UpdateEnrollmentRequest { Enrollment = 50 }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(999, It.IsAny())).ReturnsAsync((CourseDto?)null); + + // Act + var result = await _controller.UpdateCourseEnrollment(999, request); + + // Assert + Assert.IsType(result.Result); + } + + [Fact] + public async Task UpdateCourseEnrollment_ReturnsBadRequest_ForNonRCourse() + { + // Arrange + var existingCourse = new CourseDto { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }; + var request = new UpdateEnrollmentRequest { Enrollment = 50 }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(existingCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync("DVM", It.IsAny())).ReturnsAsync(true); + _courseServiceMock.Setup(s => s.UpdateCourseEnrollmentAsync(1, 50, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Course is not an R-course")); + + // Act + var result = await _controller.UpdateCourseEnrollment(1, request); + + // Assert + var badRequestResult = Assert.IsType(result.Result); + Assert.Contains("R-course", badRequestResult.Value?.ToString()); + } + + #endregion + + #region DeleteCourse Tests + + [Fact] + public async Task DeleteCourse_ReturnsNoContent_OnSuccess() + { + // Arrange + var existingCourse = new CourseDto { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(existingCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync("DVM", It.IsAny())).ReturnsAsync(true); + _courseServiceMock.Setup(s => s.DeleteCourseAsync(1, It.IsAny())) + .ReturnsAsync(true); + + // Act + var result = await _controller.DeleteCourse(1); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task DeleteCourse_ReturnsNotFound_WhenCourseDoesNotExist() + { + // Arrange + _courseServiceMock.Setup(s => s.GetCourseAsync(999, It.IsAny())).ReturnsAsync((CourseDto?)null); + + // Act + var result = await _controller.DeleteCourse(999); + + // Assert + var notFoundResult = Assert.IsType(result); + Assert.Contains("999", notFoundResult.Value?.ToString()); + } + + #endregion + + #region CanDeleteCourse Tests + + [Fact] + public async Task CanDeleteCourse_ReturnsOk_WithDeleteInfo() + { + // Arrange + var existingCourse = new CourseDto { Id = 1, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(existingCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync("DVM", It.IsAny())).ReturnsAsync(true); + _courseServiceMock.Setup(s => s.CanDeleteCourseAsync(1, It.IsAny())) + .ReturnsAsync((true, 5)); + + // Act + var result = await _controller.CanDeleteCourse(1); + + // Assert + var okResult = Assert.IsType(result.Result); + Assert.NotNull(okResult.Value); + } + + #endregion + + #region GetDepartments Tests + + [Fact] + public async Task GetDepartments_ReturnsOk_WithDepartmentList() + { + // Arrange + var departments = new List { "APC", "VMB", "VME", "VSR", "PMI", "PHR", "UNK", "DVM", "VET" }; + _courseServiceMock.Setup(s => s.GetValidCustodialDepartments()) + .Returns(departments); + _permissionServiceMock.Setup(s => s.HasFullAccessAsync(It.IsAny())).ReturnsAsync(true); + + // Act + var result = await _controller.GetDepartments(); + + // Assert + var okResult = Assert.IsType(result.Result); + var returnedDepartments = Assert.IsAssignableFrom>(okResult.Value); + Assert.Equal(9, returnedDepartments.Count()); + } + + #endregion + + #region ImportCourse Tests + + [Fact] + public async Task ImportCourse_ReturnsCreated_OnSuccess() + { + // Arrange + var request = new ImportCourseRequest + { + TermCode = 202410, + Crn = "12345" + }; + var bannerCourse = new BannerCourseDto { Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, UnitType = "F", UnitLow = 4, UnitHigh = 4, DeptCode = "72030" }; + var importedCourse = new CourseDto { Id = 10, TermCode = 202410, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "VME" }; + + _courseServiceMock.Setup(s => s.GetBannerCourseAsync(202410, "12345", It.IsAny())).ReturnsAsync(bannerCourse); + _courseServiceMock.Setup(s => s.CourseExistsAsync(202410, "12345", 4, It.IsAny())).ReturnsAsync(false); + _courseServiceMock.Setup(s => s.GetCustodialDepartmentForBannerCode("72030")).Returns("VME"); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync("VME", It.IsAny())).ReturnsAsync(true); + _courseServiceMock.Setup(s => s.ImportCourseFromBannerAsync(request, bannerCourse, It.IsAny())) + .ReturnsAsync(importedCourse); + + // Act + var result = await _controller.ImportCourse(request); + + // Assert + var createdResult = Assert.IsType(result.Result); + Assert.Equal(201, createdResult.StatusCode); + } + + [Fact] + public async Task ImportCourse_ReturnsNotFound_WhenUserNotAuthorizedForTargetDepartment() + { + // Arrange + var request = new ImportCourseRequest + { + TermCode = 202410, + Crn = "12345" + }; + var bannerCourse = new BannerCourseDto { Crn = "12345", SubjCode = "VME", CrseNumb = "200", SeqNumb = "001", Enrollment = 20, UnitType = "F", UnitLow = 4, UnitHigh = 4, DeptCode = "72030" }; + + _courseServiceMock.Setup(s => s.GetBannerCourseAsync(202410, "12345", It.IsAny())).ReturnsAsync(bannerCourse); + _courseServiceMock.Setup(s => s.CourseExistsAsync(202410, "12345", 4, It.IsAny())).ReturnsAsync(false); + _courseServiceMock.Setup(s => s.GetCustodialDepartmentForBannerCode("72030")).Returns("VME"); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync("VME", It.IsAny())).ReturnsAsync(false); + + // Act + var result = await _controller.ImportCourse(request); + + // Assert + var notFoundResult = Assert.IsType(result.Result); + Assert.Contains("not found in Banner", notFoundResult.Value?.ToString()); + } + + [Fact] + public async Task ImportCourse_ReturnsConflict_WhenCourseAlreadyExists() + { + // Arrange + var request = new ImportCourseRequest + { + TermCode = 202410, + Crn = "12345" + }; + var bannerCourse = new BannerCourseDto { Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, UnitType = "F", UnitLow = 4, UnitHigh = 4, DeptCode = "72030" }; + + _courseServiceMock.Setup(s => s.GetBannerCourseAsync(202410, "12345", It.IsAny())).ReturnsAsync(bannerCourse); + _courseServiceMock.Setup(s => s.CourseExistsAsync(202410, "12345", 4, It.IsAny())).ReturnsAsync(true); + + // Act + var result = await _controller.ImportCourse(request); + + // Assert + var conflictResult = Assert.IsType(result.Result); + Assert.Contains("already exists", conflictResult.Value?.ToString()); + } + + [Fact] + public async Task ImportCourse_ReturnsNotFound_WhenBannerCourseNotFound() + { + // Arrange + var request = new ImportCourseRequest + { + TermCode = 202410, + Crn = "99999" + }; + + _courseServiceMock.Setup(s => s.GetBannerCourseAsync(202410, "99999", It.IsAny())).ReturnsAsync((BannerCourseDto?)null); + + // Act + var result = await _controller.ImportCourse(request); + + // Assert + var notFoundResult = Assert.IsType(result.Result); + Assert.Contains("not found in Banner", notFoundResult.Value?.ToString()); + } + + [Fact] + public async Task ImportCourse_ReturnsBadRequest_WhenUnitsOutOfRange() + { + // Arrange + var request = new ImportCourseRequest + { + TermCode = 202410, + Crn = "12345", + Units = 10 // Out of range + }; + var bannerCourse = new BannerCourseDto { Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, UnitType = "V", UnitLow = 1, UnitHigh = 4, DeptCode = "72030" }; + + _courseServiceMock.Setup(s => s.GetBannerCourseAsync(202410, "12345", It.IsAny())).ReturnsAsync(bannerCourse); + + // Act + var result = await _controller.ImportCourse(request); + + // Assert + var badRequestResult = Assert.IsType(result.Result); + Assert.Contains("must be between", badRequestResult.Value?.ToString()); + } + + #endregion + + #region SearchBannerCourses Tests + + [Fact] + public async Task SearchBannerCourses_ReturnsOk_WithResults() + { + // Arrange + var bannerCourses = new List + { + new BannerCourseDto { Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20 } + }; + _courseServiceMock.Setup(s => s.SearchBannerCoursesAsync(202410, "DVM", null, null, It.IsAny())) + .ReturnsAsync(bannerCourses); + + // Act + var result = await _controller.SearchBannerCourses(202410, "DVM"); + + // Assert + var okResult = Assert.IsType(result.Result); + var returnedCourses = Assert.IsAssignableFrom>(okResult.Value); + Assert.Single(returnedCourses); + } + + [Fact] + public async Task SearchBannerCourses_ReturnsBadRequest_WhenNoSearchParametersProvided() + { + // Act + var result = await _controller.SearchBannerCourses(202410); + + // Assert + var badRequestResult = Assert.IsType(result.Result); + Assert.Contains("At least one search parameter", badRequestResult.Value?.ToString()); + } + + #endregion +} diff --git a/test/Effort/EffortIntegrationTestBase.cs b/test/Effort/EffortIntegrationTestBase.cs new file mode 100644 index 00000000..f8d156a7 --- /dev/null +++ b/test/Effort/EffortIntegrationTestBase.cs @@ -0,0 +1,402 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Viper.Areas.Effort; +using Viper.Areas.Effort.Constants; +using Viper.Areas.Effort.Models.Entities; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Effort; + +/// +/// Base class for Effort integration tests that require AAUD and RAPS contexts. +/// Provides pre-configured contexts and common setup methods to eliminate +/// repetitive test data setup across integration test files. +/// +public abstract class EffortIntegrationTestBase : IDisposable +{ + // Test user constants + public const string TestUserMothraId = "testuser"; + public const string TestUserLoginId = "testuser"; + public const string TestUserDisplayName = "Test User"; + public const int TestUserAaudId = 999; + + // Department constants + public const string DvmDepartment = "DVM"; + public const string VmeDepartment = "VME"; + public const string ApcDepartment = "APC"; + + // Term constants + public const int TestTermCode = 202410; + + protected readonly EffortDbContext EffortContext; + protected readonly RAPSContext RapsContext; + protected readonly Mock MockUserHelper; + + protected EffortIntegrationTestBase() + { + // Create real in-memory database for Entity Framework operations + var effortOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + EffortContext = new EffortDbContext(effortOptions); + + // Create RAPS context for permission checking + RapsContext = CreateRAPSContext(); + + // Setup UserHelper mock + MockUserHelper = new Mock(); + + // Seed basic test data + SeedBasicTestData(); + } + + /// + /// Creates and configures an in-memory RAPS context with Effort permissions + /// + private static RAPSContext CreateRAPSContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + var context = new RAPSContext(options); + + // Setup HttpHelper.Cache for UserHelper permission caching + var memoryCache = new MemoryCache(new MemoryCacheOptions()); + Viper.HttpHelper.Configure(memoryCache, null!, null!, null!, null!, null!); + + // Create standard Effort permissions + var permissions = new List + { + new() { PermissionId = 1, Permission = EffortPermissions.Base, Description = "Effort Base Permission" }, + new() { PermissionId = 2, Permission = EffortPermissions.ViewAllDepartments, Description = "View All Departments" }, + new() { PermissionId = 3, Permission = EffortPermissions.ViewDept, Description = "View Department" }, + new() { PermissionId = 4, Permission = EffortPermissions.EditCourse, Description = "Edit Course" }, + new() { PermissionId = 5, Permission = EffortPermissions.ImportCourse, Description = "Import Course" }, + new() { PermissionId = 6, Permission = EffortPermissions.DeleteCourse, Description = "Delete Course" }, + new() { PermissionId = 7, Permission = EffortPermissions.EditEffort, Description = "Edit Effort" }, + new() { PermissionId = 8, Permission = EffortPermissions.CreateEffort, Description = "Create Effort" }, + new() { PermissionId = 9, Permission = EffortPermissions.DeleteEffort, Description = "Delete Effort" }, + new() { PermissionId = 10, Permission = EffortPermissions.VerifyEffort, Description = "Verify Effort" }, + new() { PermissionId = 11, Permission = EffortPermissions.ManageRCourseEnrollment, Description = "Manage R Course Enrollment" }, + new() { PermissionId = 12, Permission = EffortPermissions.ViewAudit, Description = "View Audit" }, + new() { PermissionId = 13, Permission = EffortPermissions.ManageTerms, Description = "Manage Terms" } + }; + + context.TblPermissions.AddRange(permissions); + context.SaveChanges(); + return context; + } + + /// + /// Seeds basic test data required for integration tests + /// + private void SeedBasicTestData() + { + // Add test term + EffortContext.Terms.Add(new EffortTerm + { + TermCode = TestTermCode, + Status = "Open", + CreatedDate = DateTime.UtcNow + }); + + // Add test courses + var courses = new[] + { + new EffortCourse { Id = 1, TermCode = TestTermCode, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = DvmDepartment }, + new EffortCourse { Id = 2, TermCode = TestTermCode, Crn = "12346", SubjCode = "VME", CrseNumb = "200", SeqNumb = "001", Enrollment = 15, Units = 3, CustDept = VmeDepartment }, + new EffortCourse { Id = 3, TermCode = TestTermCode, Crn = "12347", SubjCode = "APC", CrseNumb = "100", SeqNumb = "001", Enrollment = 10, Units = 2, CustDept = ApcDepartment } + }; + EffortContext.Courses.AddRange(courses); + + // Add test persons + var persons = new[] + { + new EffortPerson { PersonId = TestUserAaudId, TermCode = TestTermCode, FirstName = "Test", LastName = "User", EffortDept = DvmDepartment }, + new EffortPerson { PersonId = 1001, TermCode = TestTermCode, FirstName = "Alice", LastName = "Johnson", EffortDept = VmeDepartment }, + new EffortPerson { PersonId = 1002, TermCode = TestTermCode, FirstName = "Bob", LastName = "Smith", EffortDept = ApcDepartment } + }; + EffortContext.Persons.AddRange(persons); + + EffortContext.SaveChanges(); + } + + /// + /// Creates a test user with standard properties + /// + protected static AaudUser CreateTestUser(string? mothraId = null, int? aaudUserId = null) => new() + { + MothraId = mothraId ?? TestUserMothraId, + LoginId = mothraId ?? TestUserLoginId, + DisplayFullName = TestUserDisplayName, + AaudUserId = aaudUserId ?? TestUserAaudId + }; + + /// + /// Sets up user with ViewAllDepartments (full access) permission + /// + protected void SetupUserWithFullAccess() + { + AddMemberPermissions(TestUserMothraId, + EffortPermissions.Base, + EffortPermissions.ViewAllDepartments); + + SetupUserWithPermissionsForIntegration(TestUserMothraId, new[] + { + EffortPermissions.Base, + EffortPermissions.ViewAllDepartments + }); + } + + /// + /// Sets up user with department-level view permission + /// + protected void SetupUserWithDepartmentViewAccess(params string[] departments) + { + AddMemberPermissions(TestUserMothraId, + EffortPermissions.Base, + EffortPermissions.ViewDept); + + SetupUserWithPermissionsForIntegration(TestUserMothraId, new[] + { + EffortPermissions.Base, + EffortPermissions.ViewDept + }); + + // Add UserAccess entries for departments + foreach (var dept in departments) + { + EffortContext.UserAccess.Add(new UserAccess + { + PersonId = TestUserAaudId, + DepartmentCode = dept, + IsActive = true + }); + } + EffortContext.SaveChanges(); + } + + /// + /// Sets up user with course edit permission + /// + protected void SetupUserWithEditCoursePermission() + { + AddMemberPermissions(TestUserMothraId, + EffortPermissions.Base, + EffortPermissions.EditCourse); + + SetupUserWithPermissionsForIntegration(TestUserMothraId, new[] + { + EffortPermissions.Base, + EffortPermissions.EditCourse + }); + } + + /// + /// Sets up user with course import permission + /// + protected void SetupUserWithImportCoursePermission() + { + AddMemberPermissions(TestUserMothraId, + EffortPermissions.Base, + EffortPermissions.ImportCourse); + + SetupUserWithPermissionsForIntegration(TestUserMothraId, new[] + { + EffortPermissions.Base, + EffortPermissions.ImportCourse + }); + } + + /// + /// Sets up user with course delete permission + /// + protected void SetupUserWithDeleteCoursePermission() + { + AddMemberPermissions(TestUserMothraId, + EffortPermissions.Base, + EffortPermissions.DeleteCourse); + + SetupUserWithPermissionsForIntegration(TestUserMothraId, new[] + { + EffortPermissions.Base, + EffortPermissions.DeleteCourse + }); + } + + /// + /// Sets up user with effort edit permission for a department + /// + protected void SetupUserWithEditEffortPermission(params string[] departments) + { + AddMemberPermissions(TestUserMothraId, + EffortPermissions.Base, + EffortPermissions.ViewDept, + EffortPermissions.EditEffort); + + SetupUserWithPermissionsForIntegration(TestUserMothraId, new[] + { + EffortPermissions.Base, + EffortPermissions.ViewDept, + EffortPermissions.EditEffort + }); + + // Add UserAccess entries for departments + foreach (var dept in departments) + { + EffortContext.UserAccess.Add(new UserAccess + { + PersonId = TestUserAaudId, + DepartmentCode = dept, + IsActive = true + }); + } + EffortContext.SaveChanges(); + } + + /// + /// Sets up user with self-service (VerifyEffort) permission only + /// + protected void SetupUserWithSelfServiceAccess() + { + AddMemberPermissions(TestUserMothraId, + EffortPermissions.Base, + EffortPermissions.VerifyEffort); + + SetupUserWithPermissionsForIntegration(TestUserMothraId, new[] + { + EffortPermissions.Base, + EffortPermissions.VerifyEffort + }); + } + + /// + /// Sets up user with base permission only (view-only, no edit) + /// + protected void SetupUserWithBasePermissionOnly() + { + AddMemberPermissions(TestUserMothraId, EffortPermissions.Base); + + SetupUserWithPermissionsForIntegration(TestUserMothraId, new[] + { + EffortPermissions.Base + }); + } + + /// + /// Sets up user with no permissions + /// + protected void SetupUserWithNoPermissions() + { + var testUser = CreateTestUser(); + MockUserHelper.Setup(x => x.GetCurrentUser()).Returns(testUser); + MockUserHelper.Setup(x => x.HasPermission(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(false); + } + + /// + /// Sets up null user (unauthenticated) + /// + protected void SetupNullUser() + { + MockUserHelper.Setup(x => x.GetCurrentUser()).Returns((AaudUser?)null); + } + + /// + /// Sets up user permissions for integration tests + /// + protected void SetupUserWithPermissionsForIntegration(string? userMothraId, IEnumerable permissions, int? aaudUserId = null) + { + if (userMothraId == null) + { + MockUserHelper.Setup(x => x.GetCurrentUser()).Returns((AaudUser?)null); + return; + } + + var testUser = CreateTestUser(userMothraId, aaudUserId); + MockUserHelper.Setup(x => x.GetCurrentUser()).Returns(testUser); + + // Default all permissions to false + MockUserHelper.Setup(x => x.HasPermission(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(false); + + // Set up the specific permissions to return true + foreach (var permission in permissions) + { + MockUserHelper.Setup(x => x.HasPermission(RapsContext, testUser, permission)) + .Returns(true); + } + } + + /// + /// Adds member permissions to RAPS context + /// + protected void AddMemberPermissions(string mothraId, params string[] permissions) + { + foreach (var permission in permissions) + { + var permissionEntity = RapsContext.TblPermissions.FirstOrDefault(p => p.Permission == permission); + if (permissionEntity != null) + { + RapsContext.TblMemberPermissions.Add(new Viper.Models.RAPS.TblMemberPermission + { + MemberId = mothraId, + PermissionId = permissionEntity.PermissionId, + Access = 1, + StartDate = DateTime.Today.AddYears(-1), + EndDate = null + }); + } + } + RapsContext.SaveChanges(); + } + + /// + /// Sets up authenticated HttpContext with required services for controllers + /// + protected void SetupControllerContext(ControllerBase controller) + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddScoped(_ => RapsContext); + serviceCollection.AddScoped(_ => EffortContext); + var serviceProvider = serviceCollection.BuildServiceProvider(); + + var httpContext = new DefaultHttpContext + { + RequestServices = serviceProvider, + User = new System.Security.Claims.ClaimsPrincipal( + new System.Security.Claims.ClaimsIdentity( + new[] { new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, TestUserLoginId) }, + "test" + ) + ) + }; + + controller.ControllerContext = new ControllerContext + { + HttpContext = httpContext + }; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + EffortContext?.Dispose(); + RapsContext?.Dispose(); + } + } +} diff --git a/test/Effort/Integration/EffortPermissionIntegrationTests.cs b/test/Effort/Integration/EffortPermissionIntegrationTests.cs new file mode 100644 index 00000000..62d5814a --- /dev/null +++ b/test/Effort/Integration/EffortPermissionIntegrationTests.cs @@ -0,0 +1,541 @@ +using Viper.Areas.Effort.Models.Entities; +using Viper.Areas.Effort.Services; + +namespace Viper.test.Effort.Integration; + +/// +/// Integration tests for the Effort permission service architecture. +/// Tests the complete flow of permission checks for department-level, +/// full access, and self-service permission models. +/// +public class EffortPermissionIntegrationTests : EffortIntegrationTestBase +{ + private readonly EffortPermissionService _permissionService; + + public EffortPermissionIntegrationTests() + { + _permissionService = new EffortPermissionService( + EffortContext, + RapsContext, + MockUserHelper.Object + ); + } + + #region Full Access (ViewAllDepartments) Tests + + [Fact] + public async Task FullAccess_HasFullAccessAsync_ReturnsTrue() + { + // Arrange + SetupUserWithFullAccess(); + + // Act + var hasFullAccess = await _permissionService.HasFullAccessAsync(); + + // Assert + Assert.True(hasFullAccess); + } + + [Fact] + public async Task FullAccess_CanViewAnyDepartment() + { + // Arrange + SetupUserWithFullAccess(); + + // Act + var canViewDvm = await _permissionService.CanViewDepartmentAsync(DvmDepartment); + var canViewVme = await _permissionService.CanViewDepartmentAsync(VmeDepartment); + var canViewApc = await _permissionService.CanViewDepartmentAsync(ApcDepartment); + + // Assert + Assert.True(canViewDvm); + Assert.True(canViewVme); + Assert.True(canViewApc); + } + + [Fact] + public async Task FullAccess_CanEditAnyDepartment() + { + // Arrange - Full access user also needs EditEffort permission + SetupUserWithFullAccess(); + + // Act + var canEditDvm = await _permissionService.CanEditDepartmentAsync(DvmDepartment); + var canEditVme = await _permissionService.CanEditDepartmentAsync(VmeDepartment); + + // Assert - Full access bypasses department filtering + Assert.True(canEditDvm); + Assert.True(canEditVme); + } + + [Fact] + public async Task FullAccess_GetAuthorizedDepartmentsAsync_ReturnsEmptyList() + { + // Arrange - Full access means ALL departments + SetupUserWithFullAccess(); + + // Act + var departments = await _permissionService.GetAuthorizedDepartmentsAsync(); + + // Assert - Empty list indicates full access (all departments) + Assert.Empty(departments); + } + + [Fact] + public async Task FullAccess_CanViewAnyPersonEffort() + { + // Arrange + SetupUserWithFullAccess(); + + // Act - Can view any person regardless of department + var canViewDvmPerson = await _permissionService.CanViewPersonEffortAsync(TestUserAaudId, TestTermCode); + var canViewVmePerson = await _permissionService.CanViewPersonEffortAsync(1001, TestTermCode); + var canViewApcPerson = await _permissionService.CanViewPersonEffortAsync(1002, TestTermCode); + + // Assert + Assert.True(canViewDvmPerson); + Assert.True(canViewVmePerson); + Assert.True(canViewApcPerson); + } + + #endregion + + #region Department-Level Access (ViewDept) Tests + + [Fact] + public async Task DepartmentAccess_HasDepartmentLevelAccessAsync_ReturnsTrue() + { + // Arrange + SetupUserWithDepartmentViewAccess(DvmDepartment); + + // Act + var hasDeptAccess = await _permissionService.HasDepartmentLevelAccessAsync(); + + // Assert + Assert.True(hasDeptAccess); + } + + [Fact] + public async Task DepartmentAccess_CanViewOnlyAuthorizedDepartments() + { + // Arrange - User only has access to DVM + SetupUserWithDepartmentViewAccess(DvmDepartment); + + // Act + var canViewDvm = await _permissionService.CanViewDepartmentAsync(DvmDepartment); + var canViewVme = await _permissionService.CanViewDepartmentAsync(VmeDepartment); + var canViewApc = await _permissionService.CanViewDepartmentAsync(ApcDepartment); + + // Assert + Assert.True(canViewDvm); + Assert.False(canViewVme); + Assert.False(canViewApc); + } + + [Fact] + public async Task DepartmentAccess_CanViewMultipleAuthorizedDepartments() + { + // Arrange - User has access to DVM and VME + SetupUserWithDepartmentViewAccess(DvmDepartment, VmeDepartment); + + // Act + var canViewDvm = await _permissionService.CanViewDepartmentAsync(DvmDepartment); + var canViewVme = await _permissionService.CanViewDepartmentAsync(VmeDepartment); + var canViewApc = await _permissionService.CanViewDepartmentAsync(ApcDepartment); + + // Assert + Assert.True(canViewDvm); + Assert.True(canViewVme); + Assert.False(canViewApc); + } + + [Fact] + public async Task DepartmentAccess_GetAuthorizedDepartmentsAsync_ReturnsUserDepartments() + { + // Arrange + SetupUserWithDepartmentViewAccess(DvmDepartment, VmeDepartment); + + // Act + var departments = await _permissionService.GetAuthorizedDepartmentsAsync(); + + // Assert + Assert.Equal(2, departments.Count); + Assert.Contains(DvmDepartment, departments); + Assert.Contains(VmeDepartment, departments); + } + + [Fact] + public async Task DepartmentAccess_CanOnlyViewPersonsInAuthorizedDepartment() + { + // Arrange - User only has access to DVM + SetupUserWithDepartmentViewAccess(DvmDepartment); + + // Act + var canViewDvmPerson = await _permissionService.CanViewPersonEffortAsync(TestUserAaudId, TestTermCode); + var canViewVmePerson = await _permissionService.CanViewPersonEffortAsync(1001, TestTermCode); + var canViewApcPerson = await _permissionService.CanViewPersonEffortAsync(1002, TestTermCode); + + // Assert + Assert.True(canViewDvmPerson); + Assert.False(canViewVmePerson); + Assert.False(canViewApcPerson); + } + + #endregion + + #region Edit Permission Tests + + [Fact] + public async Task EditEffort_CanOnlyEditAuthorizedDepartments() + { + // Arrange - User has edit permission for DVM only + SetupUserWithEditEffortPermission(DvmDepartment); + + // Act + var canEditDvm = await _permissionService.CanEditDepartmentAsync(DvmDepartment); + var canEditVme = await _permissionService.CanEditDepartmentAsync(VmeDepartment); + + // Assert + Assert.True(canEditDvm); + Assert.False(canEditVme); + } + + [Fact] + public async Task EditEffort_CanOnlyEditPersonsInAuthorizedDepartment() + { + // Arrange - User has edit permission for DVM only + SetupUserWithEditEffortPermission(DvmDepartment); + + // Act + var canEditDvmPerson = await _permissionService.CanEditPersonEffortAsync(TestUserAaudId, TestTermCode); + var canEditVmePerson = await _permissionService.CanEditPersonEffortAsync(1001, TestTermCode); + + // Assert + Assert.True(canEditDvmPerson); + Assert.False(canEditVmePerson); + } + + [Fact] + public async Task EditEffort_WithoutPermission_CannotEditAnyDepartment() + { + // Arrange - User has only ViewDept, not EditEffort + SetupUserWithDepartmentViewAccess(DvmDepartment); + + // Act + var canEditDvm = await _permissionService.CanEditDepartmentAsync(DvmDepartment); + + // Assert - Cannot edit even their own department without EditEffort permission + Assert.False(canEditDvm); + } + + #endregion + + #region Self-Service Access (VerifyEffort) Tests + + [Fact] + public async Task SelfService_HasSelfServiceAccessAsync_ReturnsTrue() + { + // Arrange + SetupUserWithSelfServiceAccess(); + + // Act + var hasSelfService = await _permissionService.HasSelfServiceAccessAsync(); + + // Assert + Assert.True(hasSelfService); + } + + [Fact] + public async Task SelfService_CanViewOwnEffort() + { + // Arrange - Self-service user can only view their own effort + SetupUserWithSelfServiceAccess(); + + // Act + var canViewOwn = await _permissionService.CanViewPersonEffortAsync(TestUserAaudId, TestTermCode); + + // Assert + Assert.True(canViewOwn); + } + + [Fact] + public async Task SelfService_CannotViewOtherPersonEffort() + { + // Arrange - Self-service only + SetupUserWithSelfServiceAccess(); + + // Act + var canViewOther = await _permissionService.CanViewPersonEffortAsync(1001, TestTermCode); + + // Assert + Assert.False(canViewOther); + } + + [Fact] + public void SelfService_IsCurrentUser_ReturnsTrue() + { + // Arrange + SetupUserWithSelfServiceAccess(); + + // Act + var isCurrentUser = _permissionService.IsCurrentUser(TestUserAaudId); + + // Assert + Assert.True(isCurrentUser); + } + + [Fact] + public void SelfService_IsCurrentUser_ReturnsFalseForOtherPerson() + { + // Arrange + SetupUserWithSelfServiceAccess(); + + // Act + var isCurrentUser = _permissionService.IsCurrentUser(1001); + + // Assert + Assert.False(isCurrentUser); + } + + #endregion + + #region No Permission / Null User Tests + + [Fact] + public async Task NoPermissions_HasFullAccessAsync_ReturnsFalse() + { + // Arrange + SetupUserWithNoPermissions(); + + // Act + var hasFullAccess = await _permissionService.HasFullAccessAsync(); + + // Assert + Assert.False(hasFullAccess); + } + + [Fact] + public async Task NoPermissions_HasDepartmentLevelAccessAsync_ReturnsFalse() + { + // Arrange + SetupUserWithNoPermissions(); + + // Act + var hasDeptAccess = await _permissionService.HasDepartmentLevelAccessAsync(); + + // Assert + Assert.False(hasDeptAccess); + } + + [Fact] + public async Task NoPermissions_CannotViewAnyDepartment() + { + // Arrange + SetupUserWithNoPermissions(); + + // Act + var canViewDvm = await _permissionService.CanViewDepartmentAsync(DvmDepartment); + var canViewVme = await _permissionService.CanViewDepartmentAsync(VmeDepartment); + + // Assert + Assert.False(canViewDvm); + Assert.False(canViewVme); + } + + [Fact] + public async Task NoPermissions_CannotViewAnyPersonEffort() + { + // Arrange + SetupUserWithNoPermissions(); + + // Act + var canViewPerson = await _permissionService.CanViewPersonEffortAsync(TestUserAaudId, TestTermCode); + + // Assert + Assert.False(canViewPerson); + } + + [Fact] + public async Task NullUser_HasFullAccessAsync_ReturnsFalse() + { + // Arrange + SetupNullUser(); + + // Act + var hasFullAccess = await _permissionService.HasFullAccessAsync(); + + // Assert + Assert.False(hasFullAccess); + } + + [Fact] + public async Task NullUser_CannotViewAnyDepartment() + { + // Arrange + SetupNullUser(); + + // Act + var canViewDvm = await _permissionService.CanViewDepartmentAsync(DvmDepartment); + + // Assert + Assert.False(canViewDvm); + } + + [Fact] + public void NullUser_GetCurrentPersonId_ReturnsZero() + { + // Arrange + SetupNullUser(); + + // Act + var personId = _permissionService.GetCurrentPersonId(); + + // Assert + Assert.Equal(0, personId); + } + + #endregion + + #region Base Permission Only Tests + + [Fact] + public async Task BasePermissionOnly_HasFullAccessAsync_ReturnsFalse() + { + // Arrange + SetupUserWithBasePermissionOnly(); + + // Act + var hasFullAccess = await _permissionService.HasFullAccessAsync(); + + // Assert + Assert.False(hasFullAccess); + } + + [Fact] + public async Task BasePermissionOnly_HasDepartmentLevelAccessAsync_ReturnsFalse() + { + // Arrange - Base permission does not include ViewDept + SetupUserWithBasePermissionOnly(); + + // Act + var hasDeptAccess = await _permissionService.HasDepartmentLevelAccessAsync(); + + // Assert + Assert.False(hasDeptAccess); + } + + [Fact] + public async Task BasePermissionOnly_CannotViewAnyDepartment() + { + // Arrange + SetupUserWithBasePermissionOnly(); + + // Act + var canViewDvm = await _permissionService.CanViewDepartmentAsync(DvmDepartment); + + // Assert + Assert.False(canViewDvm); + } + + #endregion + + #region Permission Hierarchy Tests + + [Fact] + public async Task FullAccess_OverridesDepartmentLevelAccess() + { + // Arrange - User has ViewAllDepartments (full access) + SetupUserWithFullAccess(); + + // Act - Should have access to all departments regardless of UserAccess entries + var hasDeptAccess = await _permissionService.HasDepartmentLevelAccessAsync(); + var canViewAllDepts = await _permissionService.CanViewDepartmentAsync(ApcDepartment); + + // Assert + Assert.False(hasDeptAccess); // ViewDept is not set + Assert.True(canViewAllDepts); // But full access grants view to all + } + + [Fact] + public async Task DepartmentAccess_CaseInsensitive() + { + // Arrange + SetupUserWithDepartmentViewAccess(DvmDepartment); + + // Act - Test case insensitivity + var canViewUppercase = await _permissionService.CanViewDepartmentAsync("DVM"); + var canViewLowercase = await _permissionService.CanViewDepartmentAsync("dvm"); + var canViewMixed = await _permissionService.CanViewDepartmentAsync("Dvm"); + + // Assert + Assert.True(canViewUppercase); + Assert.True(canViewLowercase); + Assert.True(canViewMixed); + } + + [Fact] + public async Task InactiveUserAccess_IsIgnored() + { + // Arrange - Add inactive UserAccess entry + SetupUserWithDepartmentViewAccess(DvmDepartment); + + // Add an inactive entry for VME + EffortContext.UserAccess.Add(new UserAccess + { + PersonId = TestUserAaudId, + DepartmentCode = VmeDepartment, + IsActive = false // Inactive! + }); + await EffortContext.SaveChangesAsync(); + + // Act + var canViewVme = await _permissionService.CanViewDepartmentAsync(VmeDepartment); + + // Assert - Inactive access should be ignored + Assert.False(canViewVme); + } + + #endregion + + #region Person Effort with ReportUnit Tests + + [Fact] + public async Task DepartmentAccess_CanViewPersonByReportUnit() + { + // Arrange - Add a person with different EffortDept but matching ReportUnit + var personWithReportUnit = new EffortPerson + { + PersonId = 2000, + TermCode = TestTermCode, + FirstName = "Charlie", + LastName = "Brown", + EffortDept = ApcDepartment, // Different from user's department + ReportUnit = DvmDepartment // But ReportUnit matches + }; + EffortContext.Persons.Add(personWithReportUnit); + await EffortContext.SaveChangesAsync(); + + SetupUserWithDepartmentViewAccess(DvmDepartment); + + // Act + var canViewPerson = await _permissionService.CanViewPersonEffortAsync(2000, TestTermCode); + + // Assert - Should be able to view via ReportUnit match + Assert.True(canViewPerson); + } + + [Fact] + public async Task DepartmentAccess_CannotViewPersonWithNoMatchingDeptOrReportUnit() + { + // Arrange - Person in completely different department with no matching ReportUnit + SetupUserWithDepartmentViewAccess(DvmDepartment); + + // Act + var canViewApcPerson = await _permissionService.CanViewPersonEffortAsync(1002, TestTermCode); + + // Assert + Assert.False(canViewApcPerson); + } + + #endregion +} diff --git a/web/Areas/Effort/Controllers/CoursesController.cs b/web/Areas/Effort/Controllers/CoursesController.cs new file mode 100644 index 00000000..440e8a6b --- /dev/null +++ b/web/Areas/Effort/Controllers/CoursesController.cs @@ -0,0 +1,393 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Effort.Constants; +using Viper.Areas.Effort.Models.DTOs.Requests; +using Viper.Areas.Effort.Models.DTOs.Responses; +using Viper.Areas.Effort.Services; +using Web.Authorization; + +namespace Viper.Areas.Effort.Controllers; + +/// +/// API controller for course operations in the Effort system. +/// +[Route("/api/effort/courses")] +[Permission(Allow = $"{EffortPermissions.ViewDept},{EffortPermissions.ViewAllDepartments}")] +public class CoursesController : BaseEffortController +{ + private readonly ICourseService _courseService; + private readonly IEffortPermissionService _permissionService; + + public CoursesController( + ICourseService courseService, + IEffortPermissionService permissionService, + ILogger logger) : base(logger) + { + _courseService = courseService; + _permissionService = permissionService; + } + + /// + /// Verifies the user is authorized to access a course by its department. + /// Returns null if authorized, or a NotFound result if not. + /// Note: Authorization for target departments in Create/Import/Update uses inline checks + /// with different error responses to prevent department enumeration attacks. + /// + private async Task<(CourseDto? course, ActionResult? errorResult)> GetAuthorizedCourseAsync(int id, CancellationToken ct) + { + var course = await _courseService.GetCourseAsync(id, ct); + if (course == null || !await _permissionService.CanViewDepartmentAsync(course.CustDept, ct)) + { + return (null, NotFound($"Course {id} not found")); + } + return (course, null); + } + + /// + /// Get all courses for a term, optionally filtered by department. + /// Non-admin users are restricted to their authorized departments. + /// + [HttpGet] + public async Task>> GetCourses( + [FromQuery] int termCode, + [FromQuery] string? dept = null, + CancellationToken ct = default) + { + SetExceptionContext("termCode", termCode); + if (!string.IsNullOrEmpty(dept)) + { + SetExceptionContext("dept", dept); + } + + // If department specified, verify user can view it + if (!string.IsNullOrEmpty(dept) && !await _permissionService.CanViewDepartmentAsync(dept, ct)) + { + // Return empty list instead of 403 to prevent department enumeration + return Ok(Array.Empty()); + } + + // For non-full-access users, filter to authorized departments + if (!await _permissionService.HasFullAccessAsync(ct)) + { + var authorizedDepts = await _permissionService.GetAuthorizedDepartmentsAsync(ct); + if (authorizedDepts.Count == 0) + { + return Ok(Array.Empty()); + } + + // If no dept specified, get courses for all authorized departments + if (string.IsNullOrEmpty(dept)) + { + var allCourses = new List(); + foreach (var authorizedDept in authorizedDepts) + { + var deptCourses = await _courseService.GetCoursesAsync(termCode, authorizedDept, ct); + allCourses.AddRange(deptCourses); + } + return Ok(allCourses.OrderBy(c => c.SubjCode).ThenBy(c => c.CrseNumb)); + } + } + + var courses = await _courseService.GetCoursesAsync(termCode, dept, ct); + return Ok(courses); + } + + /// + /// Get a single course by ID. + /// Non-admin users can only view courses in their authorized departments. + /// + [HttpGet("{id:int}")] + public async Task> GetCourse(int id, CancellationToken ct = default) + { + SetExceptionContext("courseId", id); + + var course = await _courseService.GetCourseAsync(id, ct); + if (course == null) + { + _logger.LogWarning("Course not found: {CourseId}", id); + return NotFound($"Course {id} not found"); + } + + // Verify user can view the course's custodial department + // Return 404 (not 403) to prevent enumeration attacks + if (!await _permissionService.CanViewDepartmentAsync(course.CustDept, ct)) + { + _logger.LogWarning("User not authorized to view course {CourseId} in department {Dept}", id, course.CustDept); + return NotFound($"Course {id} not found"); + } + + return Ok(course); + } + + /// + /// Search for courses in Banner by subject code, course number, and/or CRN. + /// + [HttpGet("search")] + [Permission(Allow = EffortPermissions.ImportCourse)] + public async Task>> SearchBannerCourses( + [FromQuery] int termCode, + [FromQuery] string? subjCode = null, + [FromQuery] string? crseNumb = null, + [FromQuery] string? crn = null, + CancellationToken ct = default) + { + SetExceptionContext("termCode", termCode); + + if (string.IsNullOrWhiteSpace(subjCode) && string.IsNullOrWhiteSpace(crseNumb) && string.IsNullOrWhiteSpace(crn)) + { + return BadRequest("At least one search parameter (subjCode, crseNumb, or crn) is required"); + } + + var courses = await _courseService.SearchBannerCoursesAsync(termCode, subjCode, crseNumb, crn, ct); + return Ok(courses); + } + + /// + /// Import a course from Banner into the Effort system. + /// + [HttpPost("import")] + [Permission(Allow = EffortPermissions.ImportCourse)] + public async Task> ImportCourse([FromBody] ImportCourseRequest request, CancellationToken ct = default) + { + SetExceptionContext("termCode", request.TermCode); + SetExceptionContext("crn", request.Crn); + + // Validate: Course exists in Banner + var bannerCourse = await _courseService.GetBannerCourseAsync(request.TermCode, request.Crn, ct); + if (bannerCourse == null) + { + _logger.LogWarning("Course {Crn} not found in Banner for term {TermCode}", request.Crn, request.TermCode); + return NotFound($"Course with CRN {request.Crn} not found in Banner for term {request.TermCode}"); + } + + // Validate: Units in range for variable-unit courses + var isVariable = bannerCourse.UnitType == "V"; + decimal units; + if (isVariable && request.Units.HasValue) + { + units = request.Units.Value; + if (units < bannerCourse.UnitLow || units > bannerCourse.UnitHigh) + { + _logger.LogWarning("Units {Units} out of range [{Low}-{High}] for course {Crn}", + units, bannerCourse.UnitLow, bannerCourse.UnitHigh, request.Crn); + return BadRequest($"Units {units} must be between {bannerCourse.UnitLow} and {bannerCourse.UnitHigh}"); + } + } + else + { + units = bannerCourse.UnitLow; + } + + // Validate: Course not already imported with same units + if (await _courseService.CourseExistsAsync(request.TermCode, request.Crn, units, ct)) + { + _logger.LogWarning("Course {Crn} with {Units} units already exists for term {TermCode}", + request.Crn, units, request.TermCode); + return Conflict($"Course {bannerCourse.SubjCode} {bannerCourse.CrseNumb} with {units} units already exists for this term"); + } + + var targetDept = _courseService.GetCustodialDepartmentForBannerCode(bannerCourse.DeptCode); + if (!await _permissionService.CanViewDepartmentAsync(targetDept, ct)) + { + _logger.LogWarning("User not authorized for department {CustDept} when importing course {Crn}", + targetDept, request.Crn); + return NotFound($"Course with CRN {request.Crn} not found in Banner for term {request.TermCode}"); + } + + try + { + var course = await _courseService.ImportCourseFromBannerAsync(request, bannerCourse, ct); + _logger.LogInformation("Course imported: {Crn} for term {TermCode}", + request.Crn, request.TermCode); + return CreatedAtAction(nameof(GetCourse), new { id = course.Id }, course); + } + catch (DbUpdateException ex) + { + _logger.LogWarning(ex, "Database error importing course {Crn}: {Message}", request.Crn, ex.InnerException?.Message ?? ex.Message); + return BadRequest("Failed to import course. Please check all field values are valid."); + } + } + + /// + /// Manually create a course in the Effort system. + /// + [HttpPost] + [Permission(Allow = EffortPermissions.EditCourse)] + public async Task> CreateCourse([FromBody] CreateCourseRequest request, CancellationToken ct = default) + { + SetExceptionContext("termCode", request.TermCode); + SetExceptionContext("crn", request.Crn); + + // Validate: Custodial department is valid + if (!_courseService.IsValidCustodialDepartment(request.CustDept)) + { + _logger.LogWarning("Invalid custodial department: {CustDept}", request.CustDept); + return BadRequest($"Invalid custodial department: {request.CustDept}"); + } + + if (!await _permissionService.CanViewDepartmentAsync(request.CustDept, ct)) + { + _logger.LogWarning("User not authorized for department {CustDept}", request.CustDept); + return BadRequest($"Invalid custodial department: {request.CustDept}"); + } + + // Validate: Course not already exists with same CRN and units + if (await _courseService.CourseExistsAsync(request.TermCode, request.Crn, request.Units, ct)) + { + _logger.LogWarning("Course {Crn} with {Units} units already exists for term {TermCode}", + request.Crn, request.Units, request.TermCode); + return Conflict($"Course with CRN {request.Crn} and {request.Units} units already exists for this term"); + } + + try + { + var course = await _courseService.CreateCourseAsync(request, ct); + _logger.LogInformation("Course created manually: {SubjCode} {CrseNumb} for term {TermCode}", + request.SubjCode, request.CrseNumb, request.TermCode); + return CreatedAtAction(nameof(GetCourse), new { id = course.Id }, course); + } + catch (DbUpdateException ex) + { + _logger.LogWarning(ex, "Database error creating course: {Message}", ex.InnerException?.Message ?? ex.Message); + return BadRequest("Failed to save course. Please check all field values are valid."); + } + } + + /// + /// Update an existing course (full update - requires EditCourse permission). + /// + [HttpPut("{id:int}")] + [Permission(Allow = EffortPermissions.EditCourse)] + public async Task> UpdateCourse(int id, [FromBody] UpdateCourseRequest request, CancellationToken ct = default) + { + SetExceptionContext("courseId", id); + + var (existingCourse, errorResult) = await GetAuthorizedCourseAsync(id, ct); + if (errorResult != null) return errorResult; + + if (existingCourse != null && + !string.Equals(existingCourse.CustDept, request.CustDept, StringComparison.OrdinalIgnoreCase) && + !await _permissionService.CanViewDepartmentAsync(request.CustDept, ct)) + { + return NotFound($"Course {id} not found"); + } + + try + { + var course = await _courseService.UpdateCourseAsync(id, request, ct); + if (course == null) + { + _logger.LogWarning("Course not found for update: {CourseId}", id); + return NotFound($"Course {id} not found"); + } + + _logger.LogInformation("Course updated: {CourseId}", id); + return Ok(course); + } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, "Invalid course update data: {Message}", ex.Message); + return BadRequest(ex.Message); + } + catch (DbUpdateException ex) + { + _logger.LogWarning(ex, "Database error updating course: {Message}", ex.InnerException?.Message ?? ex.Message); + return BadRequest("Failed to update course. Please check all field values are valid."); + } + } + + /// + /// Update only the enrollment for an R-course. + /// This is a restricted endpoint for users with ManageRCourseEnrollment permission. + /// + [HttpPatch("{id:int}/enrollment")] + [Permission(Allow = EffortPermissions.ManageRCourseEnrollment)] + public async Task> UpdateCourseEnrollment(int id, [FromBody] UpdateEnrollmentRequest request, CancellationToken ct = default) + { + SetExceptionContext("courseId", id); + + var (_, errorResult) = await GetAuthorizedCourseAsync(id, ct); + if (errorResult != null) return errorResult; + + try + { + var course = await _courseService.UpdateCourseEnrollmentAsync(id, request.Enrollment, ct); + if (course == null) + { + _logger.LogWarning("Course not found for enrollment update: {CourseId}", id); + return NotFound($"Course {id} not found"); + } + + _logger.LogInformation("Course enrollment updated: {CourseId}", id); + return Ok(course); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Cannot update enrollment for course {CourseId}: {Message}", id, ex.Message); + return BadRequest(ex.Message); + } + } + + /// + /// Delete a course and all associated effort records. + /// + [HttpDelete("{id:int}")] + [Permission(Allow = EffortPermissions.DeleteCourse)] + public async Task DeleteCourse(int id, CancellationToken ct = default) + { + SetExceptionContext("courseId", id); + + var (_, errorResult) = await GetAuthorizedCourseAsync(id, ct); + if (errorResult != null) return errorResult; + + var deleted = await _courseService.DeleteCourseAsync(id, ct); + if (!deleted) + { + _logger.LogWarning("Course not found for delete: {CourseId}", id); + return NotFound($"Course {id} not found"); + } + + _logger.LogInformation("Course deleted: {CourseId}", id); + return NoContent(); + } + + /// + /// Check if a course can be deleted and get the count of associated effort records. + /// + [HttpGet("{id:int}/can-delete")] + [Permission(Allow = EffortPermissions.DeleteCourse)] + public async Task> CanDeleteCourse(int id, CancellationToken ct = default) + { + SetExceptionContext("courseId", id); + + var (_, errorResult) = await GetAuthorizedCourseAsync(id, ct); + if (errorResult != null) return errorResult; + + var (canDelete, recordCount) = await _courseService.CanDeleteCourseAsync(id, ct); + return Ok(new { canDelete, recordCount }); + } + + /// + /// Get the list of valid custodial department codes. + /// Non-admin users only see their authorized departments. + /// + [HttpGet("departments")] + public async Task>> GetDepartments(CancellationToken ct = default) + { + var allDepartments = _courseService.GetValidCustodialDepartments(); + + // Full access users see all departments + if (await _permissionService.HasFullAccessAsync(ct)) + { + return Ok(allDepartments); + } + + // Non-admin users only see their authorized departments + var authorizedDepts = await _permissionService.GetAuthorizedDepartmentsAsync(ct); + var filteredDepts = allDepartments + .Where(d => authorizedDepts.Contains(d, StringComparer.OrdinalIgnoreCase)) + .ToList(); + + return Ok(filteredDepts); + } +} diff --git a/web/Areas/Effort/Models/DTOs/Requests/CreateCourseRequest.cs b/web/Areas/Effort/Models/DTOs/Requests/CreateCourseRequest.cs new file mode 100644 index 00000000..dbe787b2 --- /dev/null +++ b/web/Areas/Effort/Models/DTOs/Requests/CreateCourseRequest.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; + +namespace Viper.Areas.Effort.Models.DTOs.Requests; + +/// +/// Request DTO for manually creating a course in the Effort system. +/// Used for courses that are not in Banner or need custom values. +/// +public class CreateCourseRequest +{ + [Required] + public required int TermCode { get; set; } + + [Required] + [RegularExpression(@"^\d{5}$", ErrorMessage = "CRN must be exactly 5 digits")] + public string Crn { get; set; } = string.Empty; + + [Required] + [StringLength(3, MinimumLength = 1)] + public string SubjCode { get; set; } = string.Empty; + + [Required] + [StringLength(5, MinimumLength = 1)] + public string CrseNumb { get; set; } = string.Empty; + + [Required] + [StringLength(3, MinimumLength = 1)] + public string SeqNumb { get; set; } = string.Empty; + + [Range(0, int.MaxValue)] + public int Enrollment { get; set; } + + [Range(0, 99.99)] + public decimal Units { get; set; } + + [Required] + [StringLength(6, MinimumLength = 1)] + public string CustDept { get; set; } = string.Empty; +} diff --git a/web/Areas/Effort/Models/DTOs/Requests/ImportCourseRequest.cs b/web/Areas/Effort/Models/DTOs/Requests/ImportCourseRequest.cs new file mode 100644 index 00000000..a7d6d262 --- /dev/null +++ b/web/Areas/Effort/Models/DTOs/Requests/ImportCourseRequest.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace Viper.Areas.Effort.Models.DTOs.Requests; + +/// +/// Request DTO for importing a course from Banner into the Effort system. +/// +public class ImportCourseRequest +{ + [Required] + public required int TermCode { get; set; } + + [Required] + [RegularExpression(@"^\d{5}$", ErrorMessage = "CRN must be exactly 5 digits")] + public string Crn { get; set; } = string.Empty; + + /// + /// Units for variable-unit courses. If null, uses the default (UnitLow) from Banner. + /// For fixed-unit courses, this is ignored. + /// + [Range(0, 99.99)] + public decimal? Units { get; set; } +} diff --git a/web/Areas/Effort/Models/DTOs/Requests/UpdateCourseRequest.cs b/web/Areas/Effort/Models/DTOs/Requests/UpdateCourseRequest.cs new file mode 100644 index 00000000..effe2cbc --- /dev/null +++ b/web/Areas/Effort/Models/DTOs/Requests/UpdateCourseRequest.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace Viper.Areas.Effort.Models.DTOs.Requests; + +/// +/// Request DTO for updating a course in the Effort system. +/// Only enrollment, units, and custodial department can be modified. +/// +public class UpdateCourseRequest +{ + [Range(0, int.MaxValue)] + public int Enrollment { get; set; } + + [Range(0, 99.99)] + public decimal Units { get; set; } + + [Required] + [StringLength(6, MinimumLength = 1)] + public string CustDept { get; set; } = string.Empty; +} diff --git a/web/Areas/Effort/Models/DTOs/Requests/UpdateEnrollmentRequest.cs b/web/Areas/Effort/Models/DTOs/Requests/UpdateEnrollmentRequest.cs new file mode 100644 index 00000000..c29419fe --- /dev/null +++ b/web/Areas/Effort/Models/DTOs/Requests/UpdateEnrollmentRequest.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Viper.Areas.Effort.Models.DTOs.Requests; + +/// +/// Request DTO for updating only the enrollment of an R-course. +/// Used by users with ManageRCourseEnrollment permission. +/// +public class UpdateEnrollmentRequest +{ + [Range(0, int.MaxValue)] + public int Enrollment { get; set; } +} diff --git a/web/Areas/Effort/Models/DTOs/Responses/BannerCourseDto.cs b/web/Areas/Effort/Models/DTOs/Responses/BannerCourseDto.cs new file mode 100644 index 00000000..1fecc2f4 --- /dev/null +++ b/web/Areas/Effort/Models/DTOs/Responses/BannerCourseDto.cs @@ -0,0 +1,55 @@ +namespace Viper.Areas.Effort.Models.DTOs.Responses; + +/// +/// DTO for Banner course information retrieved from the courses database. +/// Used for course import search results. +/// +public class BannerCourseDto +{ + public string Crn { get; set; } = string.Empty; + public string SubjCode { get; set; } = string.Empty; + public string CrseNumb { get; set; } = string.Empty; + public string SeqNumb { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public int Enrollment { get; set; } + + /// + /// F = Fixed units, V = Variable units + /// + public string UnitType { get; set; } = string.Empty; + + /// + /// Minimum units (or the fixed value for fixed-unit courses). + /// + public decimal UnitLow { get; set; } + + /// + /// Maximum units (same as UnitLow for fixed-unit courses). + /// + public decimal UnitHigh { get; set; } + + /// + /// Banner department code (e.g., "72030" which maps to VME). + /// + public string DeptCode { get; set; } = string.Empty; + + /// + /// Combined course code (e.g., "VET 410"). + /// + public string CourseCode => $"{SubjCode.Trim()} {CrseNumb.Trim()}"; + + /// + /// True if this is a variable-unit course. + /// + public bool IsVariableUnits => UnitType == "V"; + + /// + /// True if this course has already been imported into the Effort system for this term. + /// + public bool AlreadyImported { get; set; } + + /// + /// List of unit values already imported for variable-unit courses. + /// + public List ImportedUnitValues { get; set; } = new(); +} diff --git a/web/Areas/Effort/Services/CourseService.cs b/web/Areas/Effort/Services/CourseService.cs new file mode 100644 index 00000000..5ee2d791 --- /dev/null +++ b/web/Areas/Effort/Services/CourseService.cs @@ -0,0 +1,475 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Effort.Constants; +using Viper.Areas.Effort.Models.DTOs.Requests; +using Viper.Areas.Effort.Models.DTOs.Responses; +using Viper.Areas.Effort.Models.Entities; +using Viper.Classes.SQLContext; + +namespace Viper.Areas.Effort.Services; + +/// +/// Service for course-related operations in the Effort system. +/// +public class CourseService : ICourseService +{ + private readonly EffortDbContext _context; + private readonly CoursesContext _coursesContext; + private readonly IEffortAuditService _auditService; + private readonly ILogger _logger; + + /// + /// Valid custodial department codes for the Effort system. + /// + private static readonly List ValidCustDepts = new() + { + "APC", "VMB", "VME", "VSR", "PMI", "PHR", "UNK", "DVM", "VET" + }; + + /// + /// Mapping from Banner department codes to Effort custodial departments. + /// + private static readonly Dictionary BannerDeptMapping = new() + { + { "72030", "VME" }, + { "72035", "VSR" }, + { "72037", "APC" }, + { "72047", "VMB" }, + { "72057", "PMI" }, + { "72067", "PHR" } + }; + + public CourseService( + EffortDbContext context, + CoursesContext coursesContext, + IEffortAuditService auditService, + ILogger logger) + { + _context = context; + _coursesContext = coursesContext; + _auditService = auditService; + _logger = logger; + } + + public async Task> GetCoursesAsync(int termCode, string? department = null, CancellationToken ct = default) + { + var query = _context.Courses + .AsNoTracking() + .Where(c => c.TermCode == termCode); + + if (!string.IsNullOrWhiteSpace(department)) + { + query = query.Where(c => c.CustDept == department); + } + + var courses = await query + .OrderBy(c => c.SubjCode) + .ThenBy(c => c.CrseNumb) + .ThenBy(c => c.SeqNumb) + .ToListAsync(ct); + + return courses.Select(ToDto).ToList(); + } + + public async Task GetCourseAsync(int courseId, CancellationToken ct = default) + { + var course = await _context.Courses + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == courseId, ct); + + return course == null ? null : ToDto(course); + } + + public async Task> SearchBannerCoursesAsync(int termCode, string? subjCode = null, + string? crseNumb = null, string? crn = null, CancellationToken ct = default) + { + var termCodeStr = termCode.ToString(); + + var query = _coursesContext.Baseinfos + .AsNoTracking() + .Where(b => b.BaseinfoTermCode == termCodeStr); + + if (!string.IsNullOrWhiteSpace(subjCode)) + { + var upperSubjCode = subjCode.ToUpperInvariant(); + query = query.Where(b => b.BaseinfoSubjCode.Trim() == upperSubjCode); + } + + if (!string.IsNullOrWhiteSpace(crseNumb)) + { + var upperCrseNumb = crseNumb.ToUpperInvariant(); + query = query.Where(b => b.BaseinfoCrseNumb.Trim() == upperCrseNumb); + } + + if (!string.IsNullOrWhiteSpace(crn)) + { + query = query.Where(b => b.BaseinfoCrn.Trim() == crn.Trim()); + } + + var bannerCourses = await query + .OrderBy(b => b.BaseinfoSubjCode) + .ThenBy(b => b.BaseinfoCrseNumb) + .ThenBy(b => b.BaseinfoSeqNumb) + .Take(100) + .ToListAsync(ct); + + // Check which courses are already imported + var crns = bannerCourses.Select(b => b.BaseinfoCrn.Trim()).Distinct().ToList(); + var importedCourses = await _context.Courses + .AsNoTracking() + .Where(c => c.TermCode == termCode && crns.Contains(c.Crn)) + .Select(c => new { c.Crn, c.Units }) + .ToListAsync(ct); + + var importedByCrn = importedCourses + .GroupBy(c => c.Crn.Trim()) + .ToDictionary(g => g.Key, g => g.Select(c => c.Units).ToList()); + + return bannerCourses.Select(b => + { + var crnTrimmed = b.BaseinfoCrn.Trim(); + var isFixed = b.BaseinfoUnitType == "F"; + var importedUnits = importedByCrn.GetValueOrDefault(crnTrimmed, new List()); + + return new BannerCourseDto + { + Crn = crnTrimmed, + SubjCode = b.BaseinfoSubjCode.Trim(), + CrseNumb = b.BaseinfoCrseNumb.Trim(), + SeqNumb = b.BaseinfoSeqNumb.Trim(), + Title = b.BaseinfoDescTitle ?? b.BaseinfoTitle, + Enrollment = b.BaseinfoEnrollment, + UnitType = b.BaseinfoUnitType, + UnitLow = b.BaseinfoUnitLow, + UnitHigh = b.BaseinfoUnitHigh, + DeptCode = b.BaseinfoDeptCode.Trim(), + AlreadyImported = isFixed && importedUnits.Count > 0, + ImportedUnitValues = importedUnits + }; + }).ToList(); + } + + public async Task GetBannerCourseAsync(int termCode, string crn, CancellationToken ct = default) + { + var termCodeStr = termCode.ToString(); + var crnTrimmed = crn.Trim(); + + var bannerCourse = await _coursesContext.Baseinfos + .AsNoTracking() + .FirstOrDefaultAsync(b => b.BaseinfoTermCode == termCodeStr && b.BaseinfoCrn.Trim() == crnTrimmed, ct); + + if (bannerCourse == null) + { + return null; + } + + // Check if already imported + var importedUnits = await _context.Courses + .AsNoTracking() + .Where(c => c.TermCode == termCode && c.Crn.Trim() == crnTrimmed) + .Select(c => c.Units) + .ToListAsync(ct); + + var isFixed = bannerCourse.BaseinfoUnitType == "F"; + + return new BannerCourseDto + { + Crn = crnTrimmed, + SubjCode = bannerCourse.BaseinfoSubjCode.Trim(), + CrseNumb = bannerCourse.BaseinfoCrseNumb.Trim(), + SeqNumb = bannerCourse.BaseinfoSeqNumb.Trim(), + Title = bannerCourse.BaseinfoDescTitle ?? bannerCourse.BaseinfoTitle, + Enrollment = bannerCourse.BaseinfoEnrollment, + UnitType = bannerCourse.BaseinfoUnitType, + UnitLow = bannerCourse.BaseinfoUnitLow, + UnitHigh = bannerCourse.BaseinfoUnitHigh, + DeptCode = bannerCourse.BaseinfoDeptCode.Trim(), + AlreadyImported = isFixed && importedUnits.Count > 0, + ImportedUnitValues = importedUnits + }; + } + + public async Task CourseExistsAsync(int termCode, string crn, decimal units, CancellationToken ct = default) + { + return await _context.Courses + .AnyAsync(c => c.TermCode == termCode && c.Crn.Trim() == crn.Trim() && c.Units == units, ct); + } + + public async Task ImportCourseFromBannerAsync(ImportCourseRequest request, BannerCourseDto bannerCourse, CancellationToken ct = default) + { + // Determine units - controller has validated units are in range if variable + var isVariable = bannerCourse.UnitType == "V"; + decimal units; + if (isVariable && request.Units.HasValue) + { + units = request.Units.Value; + } + else + { + units = bannerCourse.UnitLow; + } + + // Determine custodial department from Banner department mapping + var custDept = GetCustodialDepartment(bannerCourse.DeptCode); + + // Create the course + var course = new EffortCourse + { + Crn = bannerCourse.Crn, + TermCode = request.TermCode, + SubjCode = bannerCourse.SubjCode, + CrseNumb = bannerCourse.CrseNumb, + SeqNumb = bannerCourse.SeqNumb, + Enrollment = bannerCourse.Enrollment, + Units = units, + CustDept = custDept + }; + + await using var transaction = await _context.Database.BeginTransactionAsync(ct); + + _context.Courses.Add(course); + await _context.SaveChangesAsync(ct); + + _auditService.AddCourseChangeAudit(course.Id, course.TermCode, EffortAuditActions.CreateCourse, + null, + new + { + course.Crn, + course.SubjCode, + course.CrseNumb, + course.SeqNumb, + course.Enrollment, + course.Units, + course.CustDept + }); + await _context.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + + _logger.LogInformation("Imported course {SubjCode} {CrseNumb}-{SeqNumb} (CRN: {Crn}) for term {TermCode}", + course.SubjCode, course.CrseNumb, course.SeqNumb, course.Crn, course.TermCode); + + return ToDto(course); + } + + public async Task CreateCourseAsync(CreateCourseRequest request, CancellationToken ct = default) + { + var course = new EffortCourse + { + Crn = request.Crn.Trim().ToUpperInvariant(), + TermCode = request.TermCode, + SubjCode = request.SubjCode.Trim().ToUpperInvariant(), + CrseNumb = request.CrseNumb.Trim().ToUpperInvariant(), + SeqNumb = request.SeqNumb.Trim().ToUpperInvariant(), + Enrollment = request.Enrollment, + Units = request.Units, + CustDept = request.CustDept.Trim().ToUpperInvariant() + }; + + await using var transaction = await _context.Database.BeginTransactionAsync(ct); + + _context.Courses.Add(course); + await _context.SaveChangesAsync(ct); + + _auditService.AddCourseChangeAudit(course.Id, course.TermCode, EffortAuditActions.CreateCourse, + null, + new + { + course.Crn, + course.SubjCode, + course.CrseNumb, + course.SeqNumb, + course.Enrollment, + course.Units, + course.CustDept + }); + await _context.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + + _logger.LogInformation("Created manual course {SubjCode} {CrseNumb}-{SeqNumb} (CRN: {Crn}) for term {TermCode}", + course.SubjCode, course.CrseNumb, course.SeqNumb, course.Crn, course.TermCode); + + return ToDto(course); + } + + public async Task UpdateCourseAsync(int courseId, UpdateCourseRequest request, CancellationToken ct = default) + { + var course = await _context.Courses.FirstOrDefaultAsync(c => c.Id == courseId, ct); + if (course == null) + { + return null; + } + + // Validate custodial department + if (!ValidCustDepts.Contains(request.CustDept.ToUpperInvariant())) + { + throw new ArgumentException($"Invalid custodial department: {request.CustDept}"); + } + + var oldValues = new + { + course.Enrollment, + course.Units, + course.CustDept + }; + + course.Enrollment = request.Enrollment; + course.Units = request.Units; + course.CustDept = request.CustDept.ToUpperInvariant(); + + await using var transaction = await _context.Database.BeginTransactionAsync(ct); + + _auditService.AddCourseChangeAudit(course.Id, course.TermCode, EffortAuditActions.UpdateCourse, + oldValues, + new + { + course.Enrollment, + course.Units, + course.CustDept + }); + await _context.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + + _logger.LogInformation("Updated course {CourseId} ({SubjCode} {CrseNumb})", + courseId, course.SubjCode, course.CrseNumb); + + return ToDto(course); + } + + public async Task UpdateCourseEnrollmentAsync(int courseId, int enrollment, CancellationToken ct = default) + { + var course = await _context.Courses.FirstOrDefaultAsync(c => c.Id == courseId, ct); + if (course == null) + { + return null; + } + + // Enforce R-course restriction: course number must end with 'R' + if (!course.CrseNumb.Trim().EndsWith("R", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Course {course.SubjCode} {course.CrseNumb} is not an R-course. Only R-courses can be updated with this permission."); + } + + var oldEnrollment = course.Enrollment; + course.Enrollment = enrollment; + + await using var transaction = await _context.Database.BeginTransactionAsync(ct); + + _auditService.AddCourseChangeAudit(course.Id, course.TermCode, EffortAuditActions.UpdateCourse, + new { Enrollment = oldEnrollment }, + new { Enrollment = enrollment }); + await _context.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + + _logger.LogInformation("Updated R-course enrollment {CourseId} ({SubjCode} {CrseNumb}): {OldEnrollment} -> {NewEnrollment}", + courseId, course.SubjCode, course.CrseNumb, oldEnrollment, enrollment); + + return ToDto(course); + } + + public async Task DeleteCourseAsync(int courseId, CancellationToken ct = default) + { + var course = await _context.Courses + .Include(c => c.Records) + .FirstOrDefaultAsync(c => c.Id == courseId, ct); + + if (course == null) + { + return false; + } + + var courseInfo = new + { + course.Crn, + course.SubjCode, + course.CrseNumb, + course.SeqNumb, + course.Enrollment, + course.Units, + course.CustDept, + RecordCount = course.Records.Count + }; + + await using var transaction = await _context.Database.BeginTransactionAsync(ct); + + // Delete associated effort records first (cascade) + if (course.Records.Count > 0) + { + _context.Records.RemoveRange(course.Records); + _logger.LogInformation("Deleted {RecordCount} effort records for course {CourseId}", + course.Records.Count, courseId); + } + + _context.Courses.Remove(course); + + _auditService.AddCourseChangeAudit(courseId, course.TermCode, EffortAuditActions.DeleteCourse, + courseInfo, + null); + + await _context.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + + _logger.LogInformation("Deleted course {CourseId} ({SubjCode} {CrseNumb})", + courseId, courseInfo.SubjCode, courseInfo.CrseNumb); + + return true; + } + + public async Task<(bool CanDelete, int RecordCount)> CanDeleteCourseAsync(int courseId, CancellationToken ct = default) + { + var recordCount = await _context.Records + .CountAsync(r => r.CourseId == courseId, ct); + + // Course can always be deleted, but we return the record count so the UI can warn the user + return (true, recordCount); + } + + public List GetValidCustodialDepartments() + { + return ValidCustDepts.ToList(); + } + + public bool IsValidCustodialDepartment(string departmentCode) + { + return ValidCustDepts.Contains(departmentCode.ToUpperInvariant()); + } + + public string GetCustodialDepartmentForBannerCode(string bannerDeptCode) + { + return GetCustodialDepartment(bannerDeptCode); + } + + /// + /// Get the custodial department based on Banner department code. + /// + private static string GetCustodialDepartment(string bannerDeptCode) + { + if (BannerDeptMapping.TryGetValue(bannerDeptCode, out var custDept)) + { + return custDept; + } + + // Check if it's a valid SVM department code directly + if (ValidCustDepts.Contains(bannerDeptCode.ToUpperInvariant())) + { + return bannerDeptCode.ToUpperInvariant(); + } + + return "UNK"; + } + + private static CourseDto ToDto(EffortCourse course) + { + return new CourseDto + { + Id = course.Id, + Crn = course.Crn.Trim(), + TermCode = course.TermCode, + SubjCode = course.SubjCode.Trim(), + CrseNumb = course.CrseNumb.Trim(), + SeqNumb = course.SeqNumb.Trim(), + Enrollment = course.Enrollment, + Units = course.Units, + CustDept = course.CustDept.Trim() + }; + } +} diff --git a/web/Areas/Effort/Services/EffortAuditService.cs b/web/Areas/Effort/Services/EffortAuditService.cs index 58021b8f..0f8bccac 100644 --- a/web/Areas/Effort/Services/EffortAuditService.cs +++ b/web/Areas/Effort/Services/EffortAuditService.cs @@ -87,6 +87,11 @@ public void AddTermChangeAudit(int termCode, string action, object? oldValues, o AddAuditEntry(EffortAuditTables.Terms, termCode, termCode, action, SerializeChanges(oldValues, newValues)); } + public void AddCourseChangeAudit(int courseId, int termCode, string action, object? oldValues, object? newValues) + { + AddAuditEntry(EffortAuditTables.Courses, courseId, termCode, action, SerializeChanges(oldValues, newValues)); + } + public void AddImportAudit(int termCode, string action, string details) { AddAuditEntry(EffortAuditTables.Terms, termCode, termCode, action, details); diff --git a/web/Areas/Effort/Services/ICourseService.cs b/web/Areas/Effort/Services/ICourseService.cs new file mode 100644 index 00000000..da27366d --- /dev/null +++ b/web/Areas/Effort/Services/ICourseService.cs @@ -0,0 +1,137 @@ +using Viper.Areas.Effort.Models.DTOs.Requests; +using Viper.Areas.Effort.Models.DTOs.Responses; + +namespace Viper.Areas.Effort.Services; + +/// +/// Service for course-related operations in the Effort system. +/// +public interface ICourseService +{ + /// + /// Get all courses for a term, optionally filtered by department. + /// + /// The term code. + /// Optional department filter. + /// Cancellation token. + /// List of courses. + Task> GetCoursesAsync(int termCode, string? department = null, CancellationToken ct = default); + + /// + /// Get a single course by ID. + /// + /// The course ID. + /// Cancellation token. + /// The course, or null if not found. + Task GetCourseAsync(int courseId, CancellationToken ct = default); + + /// + /// Search for courses in Banner by subject code, course number, and/or CRN. + /// + /// The term code to search in. + /// Optional subject code filter. + /// Optional course number filter. + /// Optional CRN filter. + /// Cancellation token. + /// List of Banner courses matching the criteria. + Task> SearchBannerCoursesAsync(int termCode, string? subjCode = null, + string? crseNumb = null, string? crn = null, CancellationToken ct = default); + + /// + /// Get a single Banner course by term code and CRN. + /// Used by the controller to validate before import. + /// + /// The term code. + /// The course reference number. + /// Cancellation token. + /// The Banner course, or null if not found. + Task GetBannerCourseAsync(int termCode, string crn, CancellationToken ct = default); + + /// + /// Check if a course with the given key already exists in the Effort system. + /// Used by the controller to check for duplicates before import/create. + /// + /// The term code. + /// The course reference number. + /// The unit value. + /// Cancellation token. + /// True if a course with this key exists, false otherwise. + Task CourseExistsAsync(int termCode, string crn, decimal units, CancellationToken ct = default); + + /// + /// Import a course from Banner into the Effort system. + /// The controller should validate the Banner course exists and check for duplicates before calling this. + /// + /// The import request with term code, CRN, and optional units. + /// The pre-fetched Banner course data. + /// Cancellation token. + /// The created course. + Task ImportCourseFromBannerAsync(ImportCourseRequest request, BannerCourseDto bannerCourse, CancellationToken ct = default); + + /// + /// Manually create a course in the Effort system (for courses not in Banner). + /// + /// The course creation request. + /// Cancellation token. + /// The created course. + Task CreateCourseAsync(CreateCourseRequest request, CancellationToken ct = default); + + /// + /// Update an existing course (full update). + /// + /// The course ID to update. + /// The update request with new values. + /// Cancellation token. + /// The updated course, or null if not found. + Task UpdateCourseAsync(int courseId, UpdateCourseRequest request, CancellationToken ct = default); + + /// + /// Update only the enrollment for an R-course. + /// This enforces that only R-courses (course number ending with 'R') can be updated. + /// + /// The course ID to update. + /// The new enrollment value. + /// Cancellation token. + /// The updated course, or null if not found. + /// Thrown if the course is not an R-course. + Task UpdateCourseEnrollmentAsync(int courseId, int enrollment, CancellationToken ct = default); + + /// + /// Delete a course and all associated effort records. + /// + /// The course ID to delete. + /// Cancellation token. + /// True if deleted, false if not found. + Task DeleteCourseAsync(int courseId, CancellationToken ct = default); + + /// + /// Get deletion info for a course. Returns record count so the UI can warn + /// the user about associated effort records that will be cascade-deleted. + /// Users with DeleteCourse permission can always delete. + /// + /// The course ID. + /// Cancellation token. + /// Tuple with canDelete flag (always true for authorized users) and count of associated effort records for UI warning. + Task<(bool CanDelete, int RecordCount)> CanDeleteCourseAsync(int courseId, CancellationToken ct = default); + + /// + /// Get the list of valid custodial department codes. + /// + /// List of valid department codes. + List GetValidCustodialDepartments(); + + /// + /// Check if a custodial department code is valid. + /// + /// The department code to validate. + /// True if valid, false otherwise. + bool IsValidCustodialDepartment(string departmentCode); + + /// + /// Get the custodial department code that would be assigned for a Banner department code. + /// Used by the controller to check authorization before import. + /// + /// The Banner department code. + /// The mapped custodial department code. + string GetCustodialDepartmentForBannerCode(string bannerDeptCode); +} diff --git a/web/Areas/Effort/Services/IEffortAuditService.cs b/web/Areas/Effort/Services/IEffortAuditService.cs index 72bb3f4b..030e096b 100644 --- a/web/Areas/Effort/Services/IEffortAuditService.cs +++ b/web/Areas/Effort/Services/IEffortAuditService.cs @@ -31,6 +31,12 @@ Task LogTermChangeAsync(int termCode, string action, /// void AddTermChangeAudit(int termCode, string action, object? oldValues, object? newValues); + /// + /// Add a course change audit entry to the context without saving. + /// Use this within a transaction where the caller manages SaveChangesAsync. + /// + void AddCourseChangeAudit(int courseId, int termCode, string action, object? oldValues, object? newValues); + /// /// Add an import audit entry to the context without saving. /// Use this within a transaction where the caller manages SaveChangesAsync. diff --git a/web/Program.cs b/web/Program.cs index 8379bad5..576443fb 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -225,6 +225,7 @@ void ConfigureDbContextOptions(DbContextOptionsBuilder options) builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); // Add in a custom ClaimsTransformer that injects user ROLES builder.Services.AddTransient(); From a0631ec709efe6d7be521834745020d49b471240 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Thu, 18 Dec 2025 22:33:34 -0800 Subject: [PATCH 2/5] refactor(effort): consolidate error handling and simplify units logic - Add extractErrorMessage helper for API error extraction - Simplify units assignment using ternary operator --- VueApp/src/Effort/services/effort-service.ts | 68 +++++++++----------- web/Areas/Effort/Services/CourseService.cs | 12 +--- 2 files changed, 33 insertions(+), 47 deletions(-) diff --git a/VueApp/src/Effort/services/effort-service.ts b/VueApp/src/Effort/services/effort-service.ts index 68b106be..187f9b5a 100644 --- a/VueApp/src/Effort/services/effort-service.ts +++ b/VueApp/src/Effort/services/effort-service.ts @@ -17,6 +17,22 @@ const { get, post, put, del, patch } = useFetch() class EffortService { private baseUrl = `${import.meta.env.VITE_API_URL}effort` + private static extractErrorMessage(errors: unknown, fallback: string): string { + if (typeof errors === "string") { + return errors + } + if (Array.isArray(errors)) { + return errors.join(", ") + } + if (errors && typeof errors === "object") { + const values = Object.values(errors) + if (values.length > 0) { + return values.flat().join(", ") + } + } + return fallback + } + /** * Get all terms with effort status. */ @@ -82,13 +98,7 @@ class EffortService { async closeTerm(termCode: number): Promise<{ success: boolean; error?: string }> { const response = await post(`${this.baseUrl}/terms/${termCode}/close`, {}) if (!response.success) { - let errorMessage = "Failed to close term" - if (typeof response.errors === "string") { - errorMessage = response.errors - } else if (Array.isArray(response.errors)) { - errorMessage = response.errors.join(", ") - } - return { success: false, error: errorMessage } + return { success: false, error: EffortService.extractErrorMessage(response.errors, "Failed to close term") } } return { success: true } } @@ -168,13 +178,7 @@ class EffortService { const response = await get(`${this.baseUrl}/courses/search?${params.toString()}`) if (!response.success || !Array.isArray(response.result)) { - let errorMessage = "Search failed" - if (typeof response.errors === "string") { - errorMessage = response.errors - } else if (Array.isArray(response.errors)) { - errorMessage = response.errors.join(", ") - } - throw new Error(errorMessage) + throw new Error(EffortService.extractErrorMessage(response.errors, "Search failed")) } return response.result as BannerCourseDto[] } @@ -187,13 +191,10 @@ class EffortService { ): Promise<{ success: boolean; course?: CourseDto; error?: string }> { const response = await post(`${this.baseUrl}/courses/import`, request) if (!response.success) { - let errorMessage = "Failed to import course" - if (typeof response.errors === "string") { - errorMessage = response.errors - } else if (Array.isArray(response.errors)) { - errorMessage = response.errors.join(", ") + return { + success: false, + error: EffortService.extractErrorMessage(response.errors, "Failed to import course"), } - return { success: false, error: errorMessage } } return { success: true, course: response.result as CourseDto } } @@ -206,13 +207,10 @@ class EffortService { ): Promise<{ success: boolean; course?: CourseDto; error?: string }> { const response = await post(`${this.baseUrl}/courses`, request) if (!response.success) { - let errorMessage = "Failed to create course" - if (typeof response.errors === "string") { - errorMessage = response.errors - } else if (Array.isArray(response.errors)) { - errorMessage = response.errors.join(", ") + return { + success: false, + error: EffortService.extractErrorMessage(response.errors, "Failed to create course"), } - return { success: false, error: errorMessage } } return { success: true, course: response.result as CourseDto } } @@ -226,13 +224,10 @@ class EffortService { ): Promise<{ success: boolean; course?: CourseDto; error?: string }> { const response = await put(`${this.baseUrl}/courses/${courseId}`, request) if (!response.success) { - let errorMessage = "Failed to update course" - if (typeof response.errors === "string") { - errorMessage = response.errors - } else if (Array.isArray(response.errors)) { - errorMessage = response.errors.join(", ") + return { + success: false, + error: EffortService.extractErrorMessage(response.errors, "Failed to update course"), } - return { success: false, error: errorMessage } } return { success: true, course: response.result as CourseDto } } @@ -246,13 +241,10 @@ class EffortService { ): Promise<{ success: boolean; course?: CourseDto; error?: string }> { const response = await patch(`${this.baseUrl}/courses/${courseId}/enrollment`, { enrollment }) if (!response.success) { - let errorMessage = "Failed to update enrollment" - if (typeof response.errors === "string") { - errorMessage = response.errors - } else if (Array.isArray(response.errors)) { - errorMessage = response.errors.join(", ") + return { + success: false, + error: EffortService.extractErrorMessage(response.errors, "Failed to update enrollment"), } - return { success: false, error: errorMessage } } return { success: true, course: response.result as CourseDto } } diff --git a/web/Areas/Effort/Services/CourseService.cs b/web/Areas/Effort/Services/CourseService.cs index 5ee2d791..fe522af0 100644 --- a/web/Areas/Effort/Services/CourseService.cs +++ b/web/Areas/Effort/Services/CourseService.cs @@ -198,15 +198,9 @@ public async Task ImportCourseFromBannerAsync(ImportCourseRequest req { // Determine units - controller has validated units are in range if variable var isVariable = bannerCourse.UnitType == "V"; - decimal units; - if (isVariable && request.Units.HasValue) - { - units = request.Units.Value; - } - else - { - units = bannerCourse.UnitLow; - } + var units = isVariable && request.Units.HasValue + ? request.Units.Value + : bannerCourse.UnitLow; // Determine custodial department from Banner department mapping var custDept = GetCustodialDepartment(bannerCourse.DeptCode); From 9af7dd2e372bf9f18c93ced8d7a3887a5d626f06 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Fri, 19 Dec 2025 00:29:22 -0800 Subject: [PATCH 3/5] feat(effort): VPR-24 Add term-scoped URL routing - Use path params (/Effort/:termCode/courses, /audit) instead of query strings - Require term selection for Courses nav; Audit supports global view - Sync URL with dropdown changes and browser back/forward navigation --- VueApp/src/Effort/layouts/EffortLayout.vue | 25 ++++++++----- VueApp/src/Effort/pages/AuditList.vue | 37 ++++++++++++------- VueApp/src/Effort/pages/CourseList.vue | 42 +++++++++++++++++----- VueApp/src/Effort/pages/EffortHome.vue | 34 ++++++++++++++++-- VueApp/src/Effort/pages/TermManagement.vue | 17 +++++++-- VueApp/src/Effort/router/routes.ts | 26 ++++++++++++-- 6 files changed, 143 insertions(+), 38 deletions(-) diff --git a/VueApp/src/Effort/layouts/EffortLayout.vue b/VueApp/src/Effort/layouts/EffortLayout.vue index 1ccfd7ac..10e97fe6 100644 --- a/VueApp/src/Effort/layouts/EffortLayout.vue +++ b/VueApp/src/Effort/layouts/EffortLayout.vue @@ -186,7 +186,11 @@ v-if="hasManageTerms" clickable v-ripple - :to="{ name: 'TermManagement' }" + :to=" + currentTerm + ? { name: 'TermManagement', query: { termCode: currentTerm.termCode } } + : { name: 'TermManagement' } + " class="leftNavLink" > @@ -196,10 +200,10 @@ @@ -207,12 +211,16 @@ - + @@ -390,13 +398,14 @@ async function loadCurrentTerm(termCode: number | null) { if (termCode) { currentTerm.value = await effortService.getTerm(termCode) } else { - // Try to get the currently open term as default display - currentTerm.value = await effortService.getCurrentTerm() + // No term selected - don't show any term until user picks one + currentTerm.value = null } } +// Watch both route params and query params for termCode watch( - () => route.params.termCode, + () => route.params.termCode || route.query.termCode, (newTermCode) => { const termCode = newTermCode ? parseInt(newTermCode as string, 10) : null loadCurrentTerm(termCode) diff --git a/VueApp/src/Effort/pages/AuditList.vue b/VueApp/src/Effort/pages/AuditList.vue index fe7bbbfe..339cd448 100644 --- a/VueApp/src/Effort/pages/AuditList.vue +++ b/VueApp/src/Effort/pages/AuditList.vue @@ -527,15 +527,15 @@ function getActionColor(action: string): string { return "grey" } -async function loadFilterOptions() { +async function loadFilterOptions(termCode: number | null = null) { const [termsResult, actionsResult, modifiersResult, instructorsResult, subjectCodesResult, courseNumbersResult] = await Promise.all([ effortService.getTerms(), effortAuditService.getActions(), effortAuditService.getModifiers(), effortAuditService.getInstructors(), - effortAuditService.getSubjectCodes(filter.value.termCode), - effortAuditService.getCourseNumbers(filter.value.termCode), + effortAuditService.getSubjectCodes(termCode), + effortAuditService.getCourseNumbers(termCode), ]) terms.value = termsResult actions.value = actionsResult @@ -643,9 +643,6 @@ async function loadAuditRows(props: { pagination: QTableProps["pagination"] }) { function updateUrlParams() { const query: Record = {} - if (filter.value.termCode !== null) { - query.termCode = filter.value.termCode.toString() - } if (filter.value.action !== null) { query.action = filter.value.action } @@ -671,7 +668,12 @@ function updateUrlParams() { query.courseNumber = filter.value.courseNumber } - router.replace({ query }) + // Navigate to term-specific route or generic audit route + if (filter.value.termCode !== null) { + router.replace({ name: "EffortAuditWithTerm", params: { termCode: filter.value.termCode.toString() }, query }) + } else { + router.replace({ name: "EffortAudit", query }) + } } function buildSearchParams(page: number, perPage: number, sortBy: string, descending: boolean): URLSearchParams { @@ -734,7 +736,7 @@ async function clearFilter() { courseNumbers.value = newCourseNumbers allSubjectCodes.value = newSubjectCodes allCourseNumbers.value = newCourseNumbers - router.replace({ query: {} }) + router.replace({ name: "EffortAudit", query: {} }) await applyFilters() } @@ -815,11 +817,15 @@ watch( ) onMounted(async () => { - // Restore filter state from URL query parameters + const paramTermCode = route.params.termCode const q = route.query - if (q.termCode) { - filter.value.termCode = parseInt(q.termCode as string, 10) - } + const parsedTermCode = paramTermCode + ? parseInt(paramTermCode as string, 10) + : q.termCode + ? parseInt(q.termCode as string, 10) + : null + const urlTermCode = Number.isFinite(parsedTermCode) ? parsedTermCode : null + if (q.action) { filter.value.action = q.action as string } @@ -845,7 +851,12 @@ onMounted(async () => { filter.value.courseNumber = q.courseNumber as string } - await loadFilterOptions() + await loadFilterOptions(urlTermCode) + + // Set termCode after terms loaded to prevent flash of raw number in dropdown + if (urlTermCode !== null) { + filter.value.termCode = urlTermCode + } // Update bidirectional filter options based on URL params if (filter.value.subjectCode) { diff --git a/VueApp/src/Effort/pages/CourseList.vue b/VueApp/src/Effort/pages/CourseList.vue index 84ec1381..37d14fd6 100644 --- a/VueApp/src/Effort/pages/CourseList.vue +++ b/VueApp/src/Effort/pages/CourseList.vue @@ -16,7 +16,6 @@ dense options-dense outlined - @update:model-value="loadCourses" />
@@ -28,7 +27,6 @@ options-dense outlined clearable - @update:model-value="loadCourses" />
@@ -105,8 +103,8 @@ + diff --git a/VueApp/src/Effort/pages/TermManagement.vue b/VueApp/src/Effort/pages/TermManagement.vue index 4f3288ec..a9968257 100644 --- a/VueApp/src/Effort/pages/TermManagement.vue +++ b/VueApp/src/Effort/pages/TermManagement.vue @@ -269,10 +269,10 @@
- Back to Term Selection + {{ currentTermCode ? "Back" : "Back to Term Selection" }}
@@ -280,16 +280,27 @@