From 02931d7c632a8c8869d555d9036decbc2dce8a30 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Mon, 22 Dec 2025 18:43:58 -0800 Subject: [PATCH 1/3] feat(effort): VPR-19 Add course relationship linking - Support CrossList and Section parent-child relationships - Add CourseLinkDialog and CourseDetail page with relationship display - Create CourseRelationshipsController and service with validation - Prevent cycles and multiple-parent relationships --- .../__tests__/course-link-dialog.test.ts | 189 ++++++ .../Effort/components/CourseLinkDialog.vue | 339 +++++++++++ .../composables/use-effort-permissions.ts | 2 + VueApp/src/Effort/pages/CourseDetail.vue | 373 ++++++++++++ VueApp/src/Effort/pages/CourseList.vue | 43 +- VueApp/src/Effort/router/routes.ts | 15 + VueApp/src/Effort/services/effort-service.ts | 52 ++ VueApp/src/Effort/types/index.ts | 23 + test/Effort/CourseRelationshipServiceTests.cs | 553 ++++++++++++++++++ .../CourseRelationshipsControllerTests.cs | 526 +++++++++++++++++ .../CourseRelationshipsController.cs | 181 ++++++ web/Areas/Effort/EffortDbContext.cs | 4 + .../CreateCourseRelationshipRequest.cs | 22 + .../DTOs/Responses/CourseRelationshipDto.cs | 26 + .../Effort/Scripts/CreateEffortDatabase.cs | 6 +- .../Services/CourseRelationshipService.cs | 302 ++++++++++ .../Services/ICourseRelationshipService.cs | 62 ++ .../Effort/docs/Effort_Database_Schema.sql | 7 +- web/Program.cs | 1 + 19 files changed, 2721 insertions(+), 5 deletions(-) create mode 100644 VueApp/src/Effort/__tests__/course-link-dialog.test.ts create mode 100644 VueApp/src/Effort/components/CourseLinkDialog.vue create mode 100644 VueApp/src/Effort/pages/CourseDetail.vue create mode 100644 test/Effort/CourseRelationshipServiceTests.cs create mode 100644 test/Effort/CourseRelationshipsControllerTests.cs create mode 100644 web/Areas/Effort/Controllers/CourseRelationshipsController.cs create mode 100644 web/Areas/Effort/Models/DTOs/Requests/CreateCourseRelationshipRequest.cs create mode 100644 web/Areas/Effort/Models/DTOs/Responses/CourseRelationshipDto.cs create mode 100644 web/Areas/Effort/Services/CourseRelationshipService.cs create mode 100644 web/Areas/Effort/Services/ICourseRelationshipService.cs diff --git a/VueApp/src/Effort/__tests__/course-link-dialog.test.ts b/VueApp/src/Effort/__tests__/course-link-dialog.test.ts new file mode 100644 index 00000000..c8830621 --- /dev/null +++ b/VueApp/src/Effort/__tests__/course-link-dialog.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { ref } from "vue" +import { setActivePinia, createPinia } from "pinia" + +/** + * Tests for CourseLinkDialog error handling and filter behavior. + * + * These tests validate that the component properly handles errors + * and filters available courses correctly. + */ + +// Mock the effort service +const mockGetCourseRelationships = vi.fn() +const mockGetAvailableChildCourses = vi.fn() +const mockCreateCourseRelationship = vi.fn() +const mockDeleteCourseRelationship = vi.fn() + +vi.mock("../services/effort-service", () => ({ + effortService: { + getCourseRelationships: (...args: unknown[]) => mockGetCourseRelationships(...args), + getAvailableChildCourses: (...args: unknown[]) => mockGetAvailableChildCourses(...args), + createCourseRelationship: (...args: unknown[]) => mockCreateCourseRelationship(...args), + deleteCourseRelationship: (...args: unknown[]) => mockDeleteCourseRelationship(...args), + }, +})) + +// Sample course data for testing +const sampleCourses = [ + { id: 1, courseCode: "DVM 443", seqNumb: "001", crn: "12345", enrollment: 20, units: 4 }, + { id: 2, courseCode: "VME 200", seqNumb: "001", crn: "12346", enrollment: 15, units: 3 }, + { id: 3, courseCode: "APC 100", seqNumb: "002", crn: "99999", enrollment: 10, units: 2 }, +] + +// Filter function extracted from component logic +function filterCourses(courses: typeof sampleCourses, needle: string): typeof sampleCourses { + if (!needle) { + return courses + } + const lowerNeedle = needle.toLowerCase() + return courses.filter( + (c) => + c.courseCode.toLowerCase().includes(lowerNeedle) || + c.seqNumb.toLowerCase().includes(lowerNeedle) || + c.crn.toLowerCase().includes(lowerNeedle), + ) +} + +describe("CourseLinkDialog - Error Handling", () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + describe("Create Relationship Error States", () => { + it("should capture error message when API returns success: false", () => { + const error = ref(null) + + const result = { success: false, error: "Course is already a child of another course" } + + if (!result.success) { + error.value = result.error ?? "Failed to link course" + } + + expect(error.value).toBe("Course is already a child of another 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 link course" + } + + expect(error.value).toBe("Failed to link course") + }) + }) + + describe("Service Mock Behavior", () => { + it("createCourseRelationship returns success with relationship data", async () => { + mockCreateCourseRelationship.mockResolvedValue({ + success: true, + relationship: { id: 1, parentCourseId: 1, childCourseId: 2, relationshipType: "CrossList" }, + }) + + const result = await mockCreateCourseRelationship(1, { childCourseId: 2, relationshipType: "CrossList" }) + + expect(result.success).toBeTruthy() + expect(result.relationship.relationshipType).toBe("CrossList") + }) + + it("createCourseRelationship returns failure with error message", async () => { + mockCreateCourseRelationship.mockResolvedValue({ + success: false, + error: "Courses must be in the same term", + }) + + const result = await mockCreateCourseRelationship(1, { childCourseId: 99, relationshipType: "Section" }) + + expect(result.success).toBeFalsy() + expect(result.error).toBe("Courses must be in the same term") + }) + + it("deleteCourseRelationship returns true on success", async () => { + mockDeleteCourseRelationship.mockResolvedValue(true) + + const result = await mockDeleteCourseRelationship(1, 5) + + expect(result).toBeTruthy() + }) + + it("deleteCourseRelationship returns false on failure", async () => { + mockDeleteCourseRelationship.mockResolvedValue(false) + + const result = await mockDeleteCourseRelationship(1, 999) + + expect(result).toBeFalsy() + }) + }) +}) + +describe("CourseLinkDialog - Filter Logic", () => { + it("should return all courses when filter is empty", () => { + const result = filterCourses(sampleCourses, "") + + expect(result).toHaveLength(3) + }) + + it("should filter by course code", () => { + const result = filterCourses(sampleCourses, "DVM") + + expect(result).toHaveLength(1) + expect(result[0].courseCode).toBe("DVM 443") + }) + + it("should filter by course code case-insensitively", () => { + const result = filterCourses(sampleCourses, "dvm") + + expect(result).toHaveLength(1) + expect(result[0].courseCode).toBe("DVM 443") + }) + + it("should filter by CRN", () => { + const result = filterCourses(sampleCourses, "99999") + + expect(result).toHaveLength(1) + expect(result[0].crn).toBe("99999") + }) + + it("should filter by sequence number", () => { + const result = filterCourses(sampleCourses, "002") + + expect(result).toHaveLength(1) + expect(result[0].seqNumb).toBe("002") + }) + + it("should return empty array when no matches", () => { + const result = filterCourses(sampleCourses, "XYZ") + + expect(result).toHaveLength(0) + }) + + it("should match partial course code", () => { + const result = filterCourses(sampleCourses, "VM") + + expect(result).toHaveLength(2) // DVM and VME + }) +}) + +describe("CourseLinkDialog - State Reset", () => { + it("should reset state to defaults when dialog closes", () => { + const childRelationships = ref([{ id: 1 }]) + const availableCourses = ref([{ id: 2 }]) + const selectedChildCourse = ref({ id: 3 }) + const relationshipType = ref<"CrossList" | "Section">("Section") + + // Simulate dialog close reset + childRelationships.value = [] + availableCourses.value = [] + selectedChildCourse.value = null as unknown as { id: number } + relationshipType.value = "CrossList" + + expect(childRelationships.value).toHaveLength(0) + expect(availableCourses.value).toHaveLength(0) + expect(selectedChildCourse.value).toBeNull() + expect(relationshipType.value).toBe("CrossList") + }) +}) diff --git a/VueApp/src/Effort/components/CourseLinkDialog.vue b/VueApp/src/Effort/components/CourseLinkDialog.vue new file mode 100644 index 00000000..7f0e17a4 --- /dev/null +++ b/VueApp/src/Effort/components/CourseLinkDialog.vue @@ -0,0 +1,339 @@ + + + diff --git a/VueApp/src/Effort/composables/use-effort-permissions.ts b/VueApp/src/Effort/composables/use-effort-permissions.ts index c0ba39b2..f8d1e398 100644 --- a/VueApp/src/Effort/composables/use-effort-permissions.ts +++ b/VueApp/src/Effort/composables/use-effort-permissions.ts @@ -56,6 +56,7 @@ function useEffortPermissions() { const hasEditCourse = computed(() => hasPermission(EffortPermissions.EditCourse)) const hasDeleteCourse = computed(() => hasPermission(EffortPermissions.DeleteCourse)) const hasManageRCourseEnrollment = computed(() => hasPermission(EffortPermissions.ManageRCourseEnrollment)) + const hasLinkCourses = computed(() => hasPermission(EffortPermissions.LinkCourses)) const isAdmin = computed(() => hasViewAllDepartments.value) return { @@ -70,6 +71,7 @@ function useEffortPermissions() { hasEditCourse, hasDeleteCourse, hasManageRCourseEnrollment, + hasLinkCourses, isAdmin, permissions: computed(() => userStore.userInfo.permissions), } diff --git a/VueApp/src/Effort/pages/CourseDetail.vue b/VueApp/src/Effort/pages/CourseDetail.vue new file mode 100644 index 00000000..d7a5047b --- /dev/null +++ b/VueApp/src/Effort/pages/CourseDetail.vue @@ -0,0 +1,373 @@ + + + diff --git a/VueApp/src/Effort/pages/CourseList.vue b/VueApp/src/Effort/pages/CourseList.vue index 37d14fd6..7215ffd6 100644 --- a/VueApp/src/Effort/pages/CourseList.vue +++ b/VueApp/src/Effort/pages/CourseList.vue @@ -103,8 +103,14 @@ @@ -187,11 +212,13 @@ import type { QTableColumn } from "quasar" import CourseImportDialog from "../components/CourseImportDialog.vue" import CourseEditDialog from "../components/CourseEditDialog.vue" import CourseAddDialog from "../components/CourseAddDialog.vue" +import CourseLinkDialog from "../components/CourseLinkDialog.vue" const $q = useQuasar() const route = useRoute() const router = useRouter() -const { hasImportCourse, hasEditCourse, hasDeleteCourse, hasManageRCourseEnrollment, isAdmin } = useEffortPermissions() +const { hasImportCourse, hasEditCourse, hasDeleteCourse, hasManageRCourseEnrollment, hasLinkCourses, isAdmin } = + useEffortPermissions() // State const terms = ref([]) @@ -207,6 +234,7 @@ const pagination = ref({ rowsPerPage: 50 }) const showImportDialog = ref(false) const showEditDialog = ref(false) const showAddDialog = ref(false) +const showLinkDialog = ref(false) const selectedCourse = ref(null) const editEnrollmentOnly = ref(false) @@ -350,6 +378,11 @@ function openEditDialog(course: CourseDto) { showEditDialog.value = true } +function openLinkDialog(course: CourseDto) { + selectedCourse.value = course + showLinkDialog.value = true +} + function confirmDeleteCourse(course: CourseDto) { $q.dialog({ title: "Delete Course", @@ -397,6 +430,10 @@ function onCourseCreated() { loadCourses() } +function onRelationshipsUpdated() { + // Relationships were updated - no need to reload courses as relationships don't affect the course list display +} + onMounted(loadTerms) diff --git a/VueApp/src/Effort/router/routes.ts b/VueApp/src/Effort/router/routes.ts index ea844590..dee4c6bc 100644 --- a/VueApp/src/Effort/router/routes.ts +++ b/VueApp/src/Effort/router/routes.ts @@ -44,6 +44,21 @@ const routes = [ component: () => import("@/Effort/pages/CourseList.vue"), name: "CourseList", }, + { + path: `/Effort/:termCode(${TERM_CODE_PATTERN})/courses/:courseId(\\d+)`, + meta: { + layout: EffortLayout, + permissions: [ + "SVMSecure.Effort.ViewAllDepartments", + "SVMSecure.Effort.ImportCourse", + "SVMSecure.Effort.EditCourse", + "SVMSecure.Effort.DeleteCourse", + "SVMSecure.Effort.ManageRCourseEnrollment", + ], + }, + component: () => import("@/Effort/pages/CourseDetail.vue"), + name: "CourseDetail", + }, { 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 187f9b5a..deeff8e9 100644 --- a/VueApp/src/Effort/services/effort-service.ts +++ b/VueApp/src/Effort/services/effort-service.ts @@ -7,6 +7,9 @@ import type { CreateCourseRequest, UpdateCourseRequest, ImportCourseRequest, + CourseRelationshipDto, + CourseRelationshipsResult, + CreateCourseRelationshipRequest, } from "../types" const { get, post, put, del, patch } = useFetch() @@ -278,6 +281,55 @@ class EffortService { } return response.result as string[] } + + // Course Relationship Operations + + /** + * Get all relationships for a course (both as parent and child). + */ + async getCourseRelationships(courseId: number): Promise { + const response = await get(`${this.baseUrl}/courses/${courseId}/relationships`) + if (!response.success || !response.result) { + return { parentRelationship: null, childRelationships: [] } + } + return response.result as CourseRelationshipsResult + } + + /** + * Get courses available to be linked as children of a parent course. + */ + async getAvailableChildCourses(parentCourseId: number): Promise { + const response = await get(`${this.baseUrl}/courses/${parentCourseId}/relationships/available-children`) + if (!response.success || !Array.isArray(response.result)) { + return [] + } + return response.result as CourseDto[] + } + + /** + * Create a course relationship. + */ + async createCourseRelationship( + parentCourseId: number, + request: CreateCourseRelationshipRequest, + ): Promise<{ success: boolean; relationship?: CourseRelationshipDto; error?: string }> { + const response = await post(`${this.baseUrl}/courses/${parentCourseId}/relationships`, request) + if (!response.success) { + return { + success: false, + error: EffortService.extractErrorMessage(response.errors, "Failed to create relationship"), + } + } + return { success: true, relationship: response.result as CourseRelationshipDto } + } + + /** + * Delete a course relationship. + */ + async deleteCourseRelationship(parentCourseId: number, relationshipId: number): Promise { + const response = await del(`${this.baseUrl}/courses/${parentCourseId}/relationships/${relationshipId}`) + return response.success + } } const effortService = new EffortService() diff --git a/VueApp/src/Effort/types/index.ts b/VueApp/src/Effort/types/index.ts index 3edf8b81..de988b76 100644 --- a/VueApp/src/Effort/types/index.ts +++ b/VueApp/src/Effort/types/index.ts @@ -147,6 +147,26 @@ type ImportCourseRequest = { units?: number // For variable-unit courses } +// Course relationship types +type CourseRelationshipDto = { + id: number + parentCourseId: number + childCourseId: number + relationshipType: "CrossList" | "Section" + childCourse?: CourseDto + parentCourse?: CourseDto +} + +type CourseRelationshipsResult = { + parentRelationship: CourseRelationshipDto | null + childRelationships: CourseRelationshipDto[] +} + +type CreateCourseRelationshipRequest = { + childCourseId: number + relationshipType: "CrossList" | "Section" +} + export type { TermDto, PersonDto, @@ -161,4 +181,7 @@ export type { CreateCourseRequest, UpdateCourseRequest, ImportCourseRequest, + CourseRelationshipDto, + CourseRelationshipsResult, + CreateCourseRelationshipRequest, } diff --git a/test/Effort/CourseRelationshipServiceTests.cs b/test/Effort/CourseRelationshipServiceTests.cs new file mode 100644 index 00000000..2b60d912 --- /dev/null +++ b/test/Effort/CourseRelationshipServiceTests.cs @@ -0,0 +1,553 @@ +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.Entities; +using Viper.Areas.Effort.Services; + +namespace Viper.test.Effort; + +/// +/// Unit tests for CourseRelationshipService operations. +/// +public sealed class CourseRelationshipServiceTests : IDisposable +{ + private readonly EffortDbContext _context; + private readonly Mock _auditServiceMock; + private readonly Mock> _loggerMock; + private readonly CourseRelationshipService _service; + + private const int TestTermCode = 202410; + private const int DifferentTermCode = 202420; + private const string CrossListType = "CrossList"; + private const string SectionType = "Section"; + + public CourseRelationshipServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + + _context = new EffortDbContext(options); + _auditServiceMock = new Mock(); + _loggerMock = new Mock>(); + + _auditServiceMock + .Setup(s => s.AddCourseChangeAudit(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())); + + _service = new CourseRelationshipService(_context, _auditServiceMock.Object, _loggerMock.Object); + } + + public void Dispose() + { + _context.Dispose(); + } + + #region CreateRelationshipAsync Validation Tests + + [Fact] + public async Task CreateRelationshipAsync_ThrowsInvalidOperationException_WhenParentNotFound() + { + // Arrange + _context.Courses.Add(new EffortCourse { Id = 2, TermCode = TestTermCode, Crn = "12346", SubjCode = "VME", CrseNumb = "200", SeqNumb = "001", Enrollment = 10, Units = 3, CustDept = "VME" }); + await _context.SaveChangesAsync(); + + var request = new CreateCourseRelationshipRequest + { + ChildCourseId = 2, + RelationshipType = CrossListType + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _service.CreateRelationshipAsync(999, request) + ); + Assert.Contains("999", exception.Message); + Assert.Contains("not found", exception.Message); + } + + [Fact] + public async Task CreateRelationshipAsync_ThrowsInvalidOperationException_WhenChildNotFound() + { + // Arrange + _context.Courses.Add(new EffortCourse { Id = 1, TermCode = TestTermCode, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }); + await _context.SaveChangesAsync(); + + var request = new CreateCourseRelationshipRequest + { + ChildCourseId = 999, + RelationshipType = CrossListType + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _service.CreateRelationshipAsync(1, request) + ); + Assert.Contains("999", exception.Message); + Assert.Contains("not found", exception.Message); + } + + [Fact] + public async Task CreateRelationshipAsync_ThrowsInvalidOperationException_WhenTermsDoNotMatch() + { + // Arrange + _context.Courses.AddRange( + new EffortCourse { Id = 1, TermCode = TestTermCode, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }, + new EffortCourse { Id = 2, TermCode = DifferentTermCode, Crn = "12346", SubjCode = "VME", CrseNumb = "200", SeqNumb = "001", Enrollment = 10, Units = 3, CustDept = "VME" } + ); + await _context.SaveChangesAsync(); + + var request = new CreateCourseRelationshipRequest + { + ChildCourseId = 2, + RelationshipType = CrossListType + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _service.CreateRelationshipAsync(1, request) + ); + Assert.Contains("same term", exception.Message); + } + + [Fact] + public async Task CreateRelationshipAsync_ThrowsInvalidOperationException_WhenSelfLinking() + { + // Arrange + _context.Courses.Add(new EffortCourse { Id = 1, TermCode = TestTermCode, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }); + await _context.SaveChangesAsync(); + + var request = new CreateCourseRelationshipRequest + { + ChildCourseId = 1, + RelationshipType = CrossListType + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _service.CreateRelationshipAsync(1, request) + ); + Assert.Contains("itself", exception.Message); + } + + [Fact] + public async Task CreateRelationshipAsync_ThrowsInvalidOperationException_WhenChildAlreadyLinked() + { + // Arrange + _context.Courses.AddRange( + new EffortCourse { Id = 1, TermCode = TestTermCode, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }, + new EffortCourse { Id = 2, TermCode = TestTermCode, Crn = "12346", SubjCode = "VME", CrseNumb = "200", SeqNumb = "001", Enrollment = 10, Units = 3, CustDept = "VME" }, + new EffortCourse { Id = 3, TermCode = TestTermCode, Crn = "12347", SubjCode = "APC", CrseNumb = "100", SeqNumb = "001", Enrollment = 15, Units = 2, CustDept = "APC" } + ); + _context.CourseRelationships.Add(new CourseRelationship { Id = 1, ParentCourseId = 3, ChildCourseId = 2, RelationshipType = CrossListType }); + await _context.SaveChangesAsync(); + + var request = new CreateCourseRelationshipRequest + { + ChildCourseId = 2, + RelationshipType = CrossListType + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _service.CreateRelationshipAsync(1, request) + ); + Assert.Contains("already a child", exception.Message); + } + + [Fact] + public async Task CreateRelationshipAsync_ThrowsInvalidOperationException_WhenDuplicateExists() + { + // Arrange - Course 2 is already a child of course 1 + // The "already a child" check happens before "duplicate exists" check + // So attempting to add the same relationship will hit "already a child" first + _context.Courses.AddRange( + new EffortCourse { Id = 1, TermCode = TestTermCode, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }, + new EffortCourse { Id = 2, TermCode = TestTermCode, Crn = "12346", SubjCode = "VME", CrseNumb = "200", SeqNumb = "001", Enrollment = 10, Units = 3, CustDept = "VME" } + ); + _context.CourseRelationships.Add(new CourseRelationship { Id = 1, ParentCourseId = 1, ChildCourseId = 2, RelationshipType = CrossListType }); + await _context.SaveChangesAsync(); + + var request = new CreateCourseRelationshipRequest + { + ChildCourseId = 2, + RelationshipType = SectionType + }; + + // Act & Assert - Will fail on "already a child" check since child is already linked + var exception = await Assert.ThrowsAsync( + () => _service.CreateRelationshipAsync(1, request) + ); + Assert.Contains("already a child", exception.Message); + } + + [Fact] + public async Task CreateRelationshipAsync_ThrowsInvalidOperationException_WhenParentIsAlreadyChild() + { + // Arrange: Create hierarchy A -> B, then try to make B a parent of C (B -> C) + // This would create multi-level hierarchy A -> B -> C which should be prevented + _context.Courses.AddRange( + new EffortCourse { Id = 1, TermCode = TestTermCode, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }, + new EffortCourse { Id = 2, TermCode = TestTermCode, Crn = "12346", SubjCode = "VME", CrseNumb = "200", SeqNumb = "001", Enrollment = 10, Units = 3, CustDept = "VME" }, + new EffortCourse { Id = 3, TermCode = TestTermCode, Crn = "12347", SubjCode = "APC", CrseNumb = "100", SeqNumb = "001", Enrollment = 15, Units = 2, CustDept = "APC" } + ); + // Course 2 is already a child of course 1 + _context.CourseRelationships.Add(new CourseRelationship { Id = 1, ParentCourseId = 1, ChildCourseId = 2, RelationshipType = CrossListType }); + await _context.SaveChangesAsync(); + + // Try to make course 2 (which is a child) a parent of course 3 + var request = new CreateCourseRelationshipRequest + { + ChildCourseId = 3, + RelationshipType = CrossListType + }; + + // Act & Assert - Should fail because course 2 is already a child + var exception = await Assert.ThrowsAsync( + () => _service.CreateRelationshipAsync(2, request) + ); + Assert.Contains("cannot be a parent", exception.Message); + Assert.Contains("already a child", exception.Message); + } + + [Fact] + public async Task CreateRelationshipAsync_ThrowsInvalidOperationException_WhenChildIsAlreadyParent() + { + // Arrange: Create hierarchy B -> C, then try to make A a parent of B (A -> B) + // This would create multi-level hierarchy A -> B -> C which should be prevented + _context.Courses.AddRange( + new EffortCourse { Id = 1, TermCode = TestTermCode, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }, + new EffortCourse { Id = 2, TermCode = TestTermCode, Crn = "12346", SubjCode = "VME", CrseNumb = "200", SeqNumb = "001", Enrollment = 10, Units = 3, CustDept = "VME" }, + new EffortCourse { Id = 3, TermCode = TestTermCode, Crn = "12347", SubjCode = "APC", CrseNumb = "100", SeqNumb = "001", Enrollment = 15, Units = 2, CustDept = "APC" } + ); + // Course 2 is already a parent of course 3 + _context.CourseRelationships.Add(new CourseRelationship { Id = 1, ParentCourseId = 2, ChildCourseId = 3, RelationshipType = CrossListType }); + await _context.SaveChangesAsync(); + + // Try to make course 2 (which has children) a child of course 1 + var request = new CreateCourseRelationshipRequest + { + ChildCourseId = 2, + RelationshipType = CrossListType + }; + + // Act & Assert - Should fail because course 2 already has linked children + var exception = await Assert.ThrowsAsync( + () => _service.CreateRelationshipAsync(1, request) + ); + Assert.Contains("cannot be a child", exception.Message); + Assert.Contains("already has linked children", exception.Message); + } + + #endregion + + #region CreateRelationshipAsync Success Tests + + [Fact] + public async Task CreateRelationshipAsync_CreatesRelationship_WithValidData() + { + // Arrange + _context.Courses.AddRange( + new EffortCourse { Id = 1, TermCode = TestTermCode, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }, + new EffortCourse { Id = 2, TermCode = TestTermCode, Crn = "12346", SubjCode = "VME", CrseNumb = "200", SeqNumb = "001", Enrollment = 10, Units = 3, CustDept = "VME" } + ); + await _context.SaveChangesAsync(); + + var request = new CreateCourseRelationshipRequest + { + ChildCourseId = 2, + RelationshipType = CrossListType + }; + + // Act + var result = await _service.CreateRelationshipAsync(1, request); + + // Assert + Assert.NotNull(result); + Assert.Equal(1, result.ParentCourseId); + Assert.Equal(2, result.ChildCourseId); + Assert.Equal(CrossListType, result.RelationshipType); + Assert.NotNull(result.ChildCourse); + Assert.Equal("VME", result.ChildCourse.SubjCode); + + var savedRelationship = await _context.CourseRelationships.FirstOrDefaultAsync(); + Assert.NotNull(savedRelationship); + } + + [Fact] + public async Task CreateRelationshipAsync_CreatesAuditEntry() + { + // Arrange + _context.Courses.AddRange( + new EffortCourse { Id = 1, TermCode = TestTermCode, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }, + new EffortCourse { Id = 2, TermCode = TestTermCode, Crn = "12346", SubjCode = "VME", CrseNumb = "200", SeqNumb = "001", Enrollment = 10, Units = 3, CustDept = "VME" } + ); + await _context.SaveChangesAsync(); + + var request = new CreateCourseRelationshipRequest + { + ChildCourseId = 2, + RelationshipType = SectionType + }; + + // Act + await _service.CreateRelationshipAsync(1, request); + + // Assert + _auditServiceMock.Verify( + s => s.AddCourseChangeAudit( + 1, + TestTermCode, + "CreateCourseRelationship", + null, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task CreateRelationshipAsync_AllowsMultipleChildrenForSameParent() + { + // Arrange + _context.Courses.AddRange( + new EffortCourse { Id = 1, TermCode = TestTermCode, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }, + new EffortCourse { Id = 2, TermCode = TestTermCode, Crn = "12346", SubjCode = "VME", CrseNumb = "200", SeqNumb = "001", Enrollment = 10, Units = 3, CustDept = "VME" }, + new EffortCourse { Id = 3, TermCode = TestTermCode, Crn = "12347", SubjCode = "APC", CrseNumb = "100", SeqNumb = "001", Enrollment = 15, Units = 2, CustDept = "APC" } + ); + // Pre-seed the first relationship to avoid tracking issues with sequential creates + _context.CourseRelationships.Add(new CourseRelationship { Id = 1, ParentCourseId = 1, ChildCourseId = 2, RelationshipType = CrossListType }); + await _context.SaveChangesAsync(); + + // Act - Add second child + await _service.CreateRelationshipAsync(1, new CreateCourseRelationshipRequest { ChildCourseId = 3, RelationshipType = SectionType }); + + // Assert + var relationships = await _context.CourseRelationships.Where(r => r.ParentCourseId == 1).ToListAsync(); + Assert.Equal(2, relationships.Count); + } + + #endregion + + #region DeleteRelationshipAsync Tests + + [Fact] + public async Task DeleteRelationshipAsync_DeletesAndCreatesAuditEntry() + { + // Arrange + _context.Courses.AddRange( + new EffortCourse { Id = 1, TermCode = TestTermCode, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }, + new EffortCourse { Id = 2, TermCode = TestTermCode, Crn = "12346", SubjCode = "VME", CrseNumb = "200", SeqNumb = "001", Enrollment = 10, Units = 3, CustDept = "VME" } + ); + _context.CourseRelationships.Add(new CourseRelationship { Id = 1, ParentCourseId = 1, ChildCourseId = 2, RelationshipType = CrossListType }); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.DeleteRelationshipAsync(1); + + // Assert + Assert.True(result); + Assert.Null(await _context.CourseRelationships.FindAsync(1)); + + _auditServiceMock.Verify( + s => s.AddCourseChangeAudit( + 1, + TestTermCode, + "DeleteCourseRelationship", + It.IsAny(), + null), + Times.Once); + } + + [Fact] + public async Task DeleteRelationshipAsync_ReturnsFalse_WhenNotFound() + { + // Act + var result = await _service.DeleteRelationshipAsync(999); + + // Assert + Assert.False(result); + } + + #endregion + + #region GetAvailableChildCoursesAsync Tests + + [Fact] + public async Task GetAvailableChildCoursesAsync_ExcludesParentCourse() + { + // Arrange + _context.Courses.AddRange( + new EffortCourse { Id = 1, TermCode = TestTermCode, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }, + new EffortCourse { Id = 2, TermCode = TestTermCode, Crn = "12346", SubjCode = "VME", CrseNumb = "200", SeqNumb = "001", Enrollment = 10, Units = 3, CustDept = "VME" } + ); + await _context.SaveChangesAsync(); + + // Act + var available = await _service.GetAvailableChildCoursesAsync(1); + + // Assert + Assert.Single(available); + Assert.DoesNotContain(available, c => c.Id == 1); + } + + [Fact] + public async Task GetAvailableChildCoursesAsync_ExcludesAlreadyLinkedChildren() + { + // Arrange + _context.Courses.AddRange( + new EffortCourse { Id = 1, TermCode = TestTermCode, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }, + new EffortCourse { Id = 2, TermCode = TestTermCode, Crn = "12346", SubjCode = "VME", CrseNumb = "200", SeqNumb = "001", Enrollment = 10, Units = 3, CustDept = "VME" }, + new EffortCourse { Id = 3, TermCode = TestTermCode, Crn = "12347", SubjCode = "APC", CrseNumb = "100", SeqNumb = "001", Enrollment = 15, Units = 2, CustDept = "APC" }, + new EffortCourse { Id = 4, TermCode = TestTermCode, Crn = "12348", SubjCode = "PMI", CrseNumb = "300", SeqNumb = "001", Enrollment = 12, Units = 3, CustDept = "PMI" } + ); + _context.CourseRelationships.Add(new CourseRelationship { Id = 1, ParentCourseId = 3, ChildCourseId = 2, RelationshipType = CrossListType }); + await _context.SaveChangesAsync(); + + // Act + var available = await _service.GetAvailableChildCoursesAsync(1); + + // Assert: + // - Course 2 is already a child of course 3, should not be available + // - Course 3 is already a parent, should not be available (prevents multi-level hierarchy) + // - Only course 4 should be available + Assert.Single(available); + Assert.DoesNotContain(available, c => c.Id == 2); + Assert.DoesNotContain(available, c => c.Id == 3); + Assert.Contains(available, c => c.Id == 4); + } + + [Fact] + public async Task GetAvailableChildCoursesAsync_ExcludesCoursesWithChildren() + { + // Arrange: Parent courses cannot become children (prevents multi-level hierarchies) + _context.Courses.AddRange( + new EffortCourse { Id = 1, TermCode = TestTermCode, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }, + new EffortCourse { Id = 2, TermCode = TestTermCode, Crn = "12346", SubjCode = "VME", CrseNumb = "200", SeqNumb = "001", Enrollment = 10, Units = 3, CustDept = "VME" }, + new EffortCourse { Id = 3, TermCode = TestTermCode, Crn = "12347", SubjCode = "APC", CrseNumb = "100", SeqNumb = "001", Enrollment = 15, Units = 2, CustDept = "APC" } + ); + // Course 2 has a child (course 3), so course 2 cannot become a child of course 1 + _context.CourseRelationships.Add(new CourseRelationship { Id = 1, ParentCourseId = 2, ChildCourseId = 3, RelationshipType = CrossListType }); + await _context.SaveChangesAsync(); + + // Act + var available = await _service.GetAvailableChildCoursesAsync(1); + + // Assert: + // - Course 2 is a parent, should not be available as a child + // - Course 3 is already a child, should not be available + // - No courses available + Assert.Empty(available); + } + + [Fact] + public async Task GetAvailableChildCoursesAsync_OnlyIncludesSameTerm() + { + // Arrange + _context.Courses.AddRange( + new EffortCourse { Id = 1, TermCode = TestTermCode, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }, + new EffortCourse { Id = 2, TermCode = TestTermCode, Crn = "12346", SubjCode = "VME", CrseNumb = "200", SeqNumb = "001", Enrollment = 10, Units = 3, CustDept = "VME" }, + new EffortCourse { Id = 3, TermCode = DifferentTermCode, Crn = "12347", SubjCode = "APC", CrseNumb = "100", SeqNumb = "001", Enrollment = 15, Units = 2, CustDept = "APC" } + ); + await _context.SaveChangesAsync(); + + // Act + var available = await _service.GetAvailableChildCoursesAsync(1); + + // Assert + Assert.Single(available); + Assert.DoesNotContain(available, c => c.TermCode == DifferentTermCode); + } + + [Fact] + public async Task GetAvailableChildCoursesAsync_ReturnsEmpty_WhenParentNotFound() + { + // Act + var available = await _service.GetAvailableChildCoursesAsync(999); + + // Assert + Assert.Empty(available); + } + + #endregion + + #region GetRelationshipsForCourseAsync Tests + + [Fact] + public async Task GetRelationshipsForCourseAsync_ReturnsBothParentAndChildren() + { + // Arrange + _context.Courses.AddRange( + new EffortCourse { Id = 1, TermCode = TestTermCode, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }, + new EffortCourse { Id = 2, TermCode = TestTermCode, Crn = "12346", SubjCode = "VME", CrseNumb = "200", SeqNumb = "001", Enrollment = 10, Units = 3, CustDept = "VME" }, + new EffortCourse { Id = 3, TermCode = TestTermCode, Crn = "12347", SubjCode = "APC", CrseNumb = "100", SeqNumb = "001", Enrollment = 15, Units = 2, CustDept = "APC" } + ); + _context.CourseRelationships.AddRange( + new CourseRelationship { Id = 1, ParentCourseId = 1, ChildCourseId = 2, RelationshipType = CrossListType }, + new CourseRelationship { Id = 2, ParentCourseId = 3, ChildCourseId = 1, RelationshipType = SectionType } + ); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetRelationshipsForCourseAsync(1); + + // Assert - Course 1 is parent of 2 and child of 3 + Assert.NotNull(result.ParentRelationship); + Assert.Equal(3, result.ParentRelationship.ParentCourseId); + Assert.Single(result.ChildRelationships); + Assert.Equal(2, result.ChildRelationships[0].ChildCourseId); + } + + [Fact] + public async Task GetParentRelationshipAsync_ReturnsNull_WhenNoParent() + { + // Arrange + _context.Courses.Add(new EffortCourse { Id = 1, TermCode = TestTermCode, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetParentRelationshipAsync(1); + + // Assert + Assert.Null(result); + } + + #endregion + + #region GetRelationshipAsync Tests + + [Fact] + public async Task GetRelationshipAsync_ReturnsRelationship_WithBothCourses() + { + // Arrange + _context.Courses.AddRange( + new EffortCourse { Id = 1, TermCode = TestTermCode, Crn = "12345", SubjCode = "DVM", CrseNumb = "443", SeqNumb = "001", Enrollment = 20, Units = 4, CustDept = "DVM" }, + new EffortCourse { Id = 2, TermCode = TestTermCode, Crn = "12346", SubjCode = "VME", CrseNumb = "200", SeqNumb = "001", Enrollment = 10, Units = 3, CustDept = "VME" } + ); + _context.CourseRelationships.Add(new CourseRelationship { Id = 1, ParentCourseId = 1, ChildCourseId = 2, RelationshipType = CrossListType }); + await _context.SaveChangesAsync(); + + // Act + var result = await _service.GetRelationshipAsync(1); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.ParentCourse); + Assert.NotNull(result.ChildCourse); + Assert.Equal("DVM", result.ParentCourse.SubjCode); + Assert.Equal("VME", result.ChildCourse.SubjCode); + } + + [Fact] + public async Task GetRelationshipAsync_ReturnsNull_WhenNotFound() + { + // Act + var result = await _service.GetRelationshipAsync(999); + + // Assert + Assert.Null(result); + } + + #endregion +} diff --git a/test/Effort/CourseRelationshipsControllerTests.cs b/test/Effort/CourseRelationshipsControllerTests.cs new file mode 100644 index 00000000..6246a8e6 --- /dev/null +++ b/test/Effort/CourseRelationshipsControllerTests.cs @@ -0,0 +1,526 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +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 CourseRelationshipsController API endpoints. +/// +public sealed class CourseRelationshipsControllerTests +{ + private readonly Mock _relationshipServiceMock; + private readonly Mock _courseServiceMock; + private readonly Mock _permissionServiceMock; + private readonly Mock> _loggerMock; + private readonly CourseRelationshipsController _controller; + + private const int TestTermCode = 202410; + private const string DvmDept = "DVM"; + private const string VmeDept = "VME"; + private const string CrossListType = "CrossList"; + + public CourseRelationshipsControllerTests() + { + _relationshipServiceMock = new Mock(); + _courseServiceMock = new Mock(); + _permissionServiceMock = new Mock(); + _loggerMock = new Mock>(); + + _controller = new CourseRelationshipsController( + _relationshipServiceMock.Object, + _courseServiceMock.Object, + _permissionServiceMock.Object, + _loggerMock.Object); + + SetupControllerContext(); + } + + private void SetupControllerContext() + { + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + RequestServices = serviceProvider + } + }; + } + + private CourseDto CreateCourse(int id, string dept) => new() + { + Id = id, + TermCode = TestTermCode, + Crn = $"1234{id}", + SubjCode = dept, + CrseNumb = "443", + SeqNumb = "001", + Enrollment = 20, + Units = 4, + CustDept = dept + }; + + #region GetRelationships Tests + + [Fact] + public async Task GetRelationships_ReturnsOk_WithRelationships() + { + // Arrange + var parentCourse = CreateCourse(1, DvmDept); + var result = new CourseRelationshipsResult + { + ParentRelationship = null, + ChildRelationships = new List + { + new() { Id = 1, ParentCourseId = 1, ChildCourseId = 2, RelationshipType = CrossListType, ChildCourse = CreateCourse(2, DvmDept) } + } + }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(parentCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync(DvmDept, It.IsAny())).ReturnsAsync(true); + _permissionServiceMock.Setup(s => s.HasFullAccessAsync(It.IsAny())).ReturnsAsync(true); + _relationshipServiceMock.Setup(s => s.GetRelationshipsForCourseAsync(1, It.IsAny())).ReturnsAsync(result); + + // Act + var actionResult = await _controller.GetRelationships(1); + + // Assert + var okResult = Assert.IsType(actionResult.Result); + var returnedResult = Assert.IsType(okResult.Value); + Assert.Single(returnedResult.ChildRelationships); + } + + [Fact] + public async Task GetRelationships_ReturnsNotFound_WhenCourseNotFound() + { + // Arrange + _courseServiceMock.Setup(s => s.GetCourseAsync(999, It.IsAny())).ReturnsAsync((CourseDto?)null); + + // Act + var actionResult = await _controller.GetRelationships(999); + + // Assert + var notFoundResult = Assert.IsType(actionResult.Result); + Assert.Contains("999", notFoundResult.Value?.ToString()); + } + + [Fact] + public async Task GetRelationships_ReturnsNotFound_WhenUserUnauthorized() + { + // Arrange + var course = CreateCourse(1, DvmDept); + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(course); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync(DvmDept, It.IsAny())).ReturnsAsync(false); + + // Act + var actionResult = await _controller.GetRelationships(1); + + // Assert + Assert.IsType(actionResult.Result); + } + + [Fact] + public async Task GetRelationships_ReturnsOk_WithEmptyList() + { + // Arrange + var course = CreateCourse(1, DvmDept); + var result = new CourseRelationshipsResult + { + ParentRelationship = null, + ChildRelationships = new List() + }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(course); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync(DvmDept, It.IsAny())).ReturnsAsync(true); + _permissionServiceMock.Setup(s => s.HasFullAccessAsync(It.IsAny())).ReturnsAsync(true); + _relationshipServiceMock.Setup(s => s.GetRelationshipsForCourseAsync(1, It.IsAny())).ReturnsAsync(result); + + // Act + var actionResult = await _controller.GetRelationships(1); + + // Assert + var okResult = Assert.IsType(actionResult.Result); + var returnedResult = Assert.IsType(okResult.Value); + Assert.Empty(returnedResult.ChildRelationships); + } + + [Fact] + public async Task GetRelationships_ReturnsAllRelationships_WhenFullAccess() + { + // Arrange + var parentCourse = CreateCourse(1, DvmDept); + var result = new CourseRelationshipsResult + { + ParentRelationship = new CourseRelationshipDto + { + Id = 10, + ParentCourseId = 99, + ChildCourseId = 1, + RelationshipType = CrossListType, + ParentCourse = CreateCourse(99, VmeDept) + }, + ChildRelationships = new List + { + new() { Id = 1, ParentCourseId = 1, ChildCourseId = 2, RelationshipType = CrossListType, ChildCourse = CreateCourse(2, VmeDept) }, + new() { Id = 2, ParentCourseId = 1, ChildCourseId = 3, RelationshipType = CrossListType, ChildCourse = CreateCourse(3, DvmDept) } + } + }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(parentCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync(DvmDept, It.IsAny())).ReturnsAsync(true); + _permissionServiceMock.Setup(s => s.HasFullAccessAsync(It.IsAny())).ReturnsAsync(true); + _relationshipServiceMock.Setup(s => s.GetRelationshipsForCourseAsync(1, It.IsAny())).ReturnsAsync(result); + + // Act + var actionResult = await _controller.GetRelationships(1); + + // Assert + var okResult = Assert.IsType(actionResult.Result); + var returnedResult = Assert.IsType(okResult.Value); + Assert.Equal(2, returnedResult.ChildRelationships.Count); + Assert.NotNull(returnedResult.ParentRelationship); + } + + [Fact] + public async Task GetRelationships_FiltersChildRelationshipsByAuthorizedDepartments() + { + // Arrange + var parentCourse = CreateCourse(1, DvmDept); + var result = new CourseRelationshipsResult + { + ParentRelationship = null, + ChildRelationships = new List + { + new() { Id = 1, ParentCourseId = 1, ChildCourseId = 2, RelationshipType = CrossListType, ChildCourse = CreateCourse(2, VmeDept) }, + new() { Id = 2, ParentCourseId = 1, ChildCourseId = 3, RelationshipType = CrossListType, ChildCourse = CreateCourse(3, DvmDept) } + } + }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(parentCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync(DvmDept, It.IsAny())).ReturnsAsync(true); + _permissionServiceMock.Setup(s => s.HasFullAccessAsync(It.IsAny())).ReturnsAsync(false); + _permissionServiceMock.Setup(s => s.GetAuthorizedDepartmentsAsync(It.IsAny())).ReturnsAsync(new List { DvmDept }); + _relationshipServiceMock.Setup(s => s.GetRelationshipsForCourseAsync(1, It.IsAny())).ReturnsAsync(result); + + // Act + var actionResult = await _controller.GetRelationships(1); + + // Assert + var okResult = Assert.IsType(actionResult.Result); + var returnedResult = Assert.IsType(okResult.Value); + Assert.Single(returnedResult.ChildRelationships); + Assert.Equal(DvmDept, returnedResult.ChildRelationships.First().ChildCourse?.CustDept); + } + + [Fact] + public async Task GetRelationships_FiltersParentRelationshipByAuthorizedDepartment() + { + // Arrange + var parentCourse = CreateCourse(1, DvmDept); + var result = new CourseRelationshipsResult + { + ParentRelationship = new CourseRelationshipDto + { + Id = 10, + ParentCourseId = 99, + ChildCourseId = 1, + RelationshipType = CrossListType, + ParentCourse = CreateCourse(99, VmeDept) // User doesn't have access to VME + }, + ChildRelationships = new List() + }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(parentCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync(DvmDept, It.IsAny())).ReturnsAsync(true); + _permissionServiceMock.Setup(s => s.HasFullAccessAsync(It.IsAny())).ReturnsAsync(false); + _permissionServiceMock.Setup(s => s.GetAuthorizedDepartmentsAsync(It.IsAny())).ReturnsAsync(new List { DvmDept }); + _relationshipServiceMock.Setup(s => s.GetRelationshipsForCourseAsync(1, It.IsAny())).ReturnsAsync(result); + + // Act + var actionResult = await _controller.GetRelationships(1); + + // Assert + var okResult = Assert.IsType(actionResult.Result); + var returnedResult = Assert.IsType(okResult.Value); + Assert.Null(returnedResult.ParentRelationship); + } + + #endregion + + #region GetAvailableChildren Tests + + [Fact] + public async Task GetAvailableChildren_ReturnsAllCourses_WhenFullAccess() + { + // Arrange + var parentCourse = CreateCourse(1, DvmDept); + var availableCourses = new List + { + CreateCourse(2, VmeDept), + CreateCourse(3, DvmDept) + }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(parentCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync(DvmDept, It.IsAny())).ReturnsAsync(true); + _permissionServiceMock.Setup(s => s.HasFullAccessAsync(It.IsAny())).ReturnsAsync(true); + _relationshipServiceMock.Setup(s => s.GetAvailableChildCoursesAsync(1, It.IsAny())).ReturnsAsync(availableCourses); + + // Act + var actionResult = await _controller.GetAvailableChildren(1); + + // Assert + var okResult = Assert.IsType(actionResult.Result); + var returnedCourses = Assert.IsAssignableFrom>(okResult.Value); + Assert.Equal(2, returnedCourses.Count()); + } + + [Fact] + public async Task GetAvailableChildren_FiltersResultsByAuthorizedDepartments() + { + // Arrange + var parentCourse = CreateCourse(1, DvmDept); + var availableCourses = new List + { + CreateCourse(2, VmeDept), + CreateCourse(3, DvmDept) + }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(parentCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync(DvmDept, It.IsAny())).ReturnsAsync(true); + _permissionServiceMock.Setup(s => s.HasFullAccessAsync(It.IsAny())).ReturnsAsync(false); + _permissionServiceMock.Setup(s => s.GetAuthorizedDepartmentsAsync(It.IsAny())).ReturnsAsync(new List { DvmDept }); + _relationshipServiceMock.Setup(s => s.GetAvailableChildCoursesAsync(1, It.IsAny())).ReturnsAsync(availableCourses); + + // Act + var actionResult = await _controller.GetAvailableChildren(1); + + // Assert + var okResult = Assert.IsType(actionResult.Result); + var returnedCourses = Assert.IsAssignableFrom>(okResult.Value); + Assert.Single(returnedCourses); + Assert.Equal(DvmDept, returnedCourses.First().CustDept); + } + + #endregion + + #region CreateRelationship Tests + + [Fact] + public async Task CreateRelationship_ReturnsCreatedAtAction_OnSuccess() + { + // Arrange + var parentCourse = CreateCourse(1, DvmDept); + var childCourse = CreateCourse(2, VmeDept); + var request = new CreateCourseRelationshipRequest { ChildCourseId = 2, RelationshipType = CrossListType }; + var relationship = new CourseRelationshipDto + { + Id = 1, + ParentCourseId = 1, + ChildCourseId = 2, + RelationshipType = CrossListType, + ChildCourse = childCourse + }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(parentCourse); + _courseServiceMock.Setup(s => s.GetCourseAsync(2, It.IsAny())).ReturnsAsync(childCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync(DvmDept, It.IsAny())).ReturnsAsync(true); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync(VmeDept, It.IsAny())).ReturnsAsync(true); + _relationshipServiceMock.Setup(s => s.CreateRelationshipAsync(1, request, It.IsAny())).ReturnsAsync(relationship); + + // Act + var actionResult = await _controller.CreateRelationship(1, request); + + // Assert + var createdResult = Assert.IsType(actionResult.Result); + Assert.Equal(201, createdResult.StatusCode); + var returnedRelationship = Assert.IsType(createdResult.Value); + Assert.Equal(1, returnedRelationship.Id); + } + + [Fact] + public async Task CreateRelationship_ReturnsNotFound_WhenParentUnauthorized() + { + // Arrange + var parentCourse = CreateCourse(1, DvmDept); + var request = new CreateCourseRelationshipRequest { ChildCourseId = 2, RelationshipType = CrossListType }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(parentCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync(DvmDept, It.IsAny())).ReturnsAsync(false); + + // Act + var actionResult = await _controller.CreateRelationship(1, request); + + // Assert + Assert.IsType(actionResult.Result); + } + + [Fact] + public async Task CreateRelationship_ReturnsNotFound_WhenChildUnauthorized() + { + // Arrange + var parentCourse = CreateCourse(1, DvmDept); + var childCourse = CreateCourse(2, VmeDept); + var request = new CreateCourseRelationshipRequest { ChildCourseId = 2, RelationshipType = CrossListType }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(parentCourse); + _courseServiceMock.Setup(s => s.GetCourseAsync(2, It.IsAny())).ReturnsAsync(childCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync(DvmDept, It.IsAny())).ReturnsAsync(true); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync(VmeDept, It.IsAny())).ReturnsAsync(false); + + // Act + var actionResult = await _controller.CreateRelationship(1, request); + + // Assert + Assert.IsType(actionResult.Result); + } + + [Fact] + public async Task CreateRelationship_ReturnsBadRequest_ForInvalidOperation() + { + // Arrange + var parentCourse = CreateCourse(1, DvmDept); + var childCourse = CreateCourse(2, VmeDept); + var request = new CreateCourseRelationshipRequest { ChildCourseId = 2, RelationshipType = CrossListType }; + + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(parentCourse); + _courseServiceMock.Setup(s => s.GetCourseAsync(2, It.IsAny())).ReturnsAsync(childCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + _relationshipServiceMock.Setup(s => s.CreateRelationshipAsync(1, request, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Child course is already a child of another course")); + + // Act + var actionResult = await _controller.CreateRelationship(1, request); + + // Assert + var badRequestResult = Assert.IsType(actionResult.Result); + Assert.Contains("already a child", badRequestResult.Value?.ToString()); + } + + #endregion + + #region DeleteRelationship Tests + + [Fact] + public async Task DeleteRelationship_ReturnsNoContent_OnSuccess() + { + // Arrange + var parentCourse = CreateCourse(1, DvmDept); + var childCourse = CreateCourse(2, VmeDept); + var relationship = new CourseRelationshipDto + { + Id = 1, + ParentCourseId = 1, + ChildCourseId = 2, + RelationshipType = CrossListType + }; + + _relationshipServiceMock.Setup(s => s.GetRelationshipAsync(1, It.IsAny())).ReturnsAsync(relationship); + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(parentCourse); + _courseServiceMock.Setup(s => s.GetCourseAsync(2, It.IsAny())).ReturnsAsync(childCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync(DvmDept, It.IsAny())).ReturnsAsync(true); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync(VmeDept, It.IsAny())).ReturnsAsync(true); + _relationshipServiceMock.Setup(s => s.DeleteRelationshipAsync(1, It.IsAny())).ReturnsAsync(true); + + // Act + var actionResult = await _controller.DeleteRelationship(1, 1); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public async Task DeleteRelationship_ReturnsNotFound_WhenRelationshipNotFound() + { + // Arrange + _relationshipServiceMock.Setup(s => s.GetRelationshipAsync(999, It.IsAny())).ReturnsAsync((CourseRelationshipDto?)null); + + // Act + var actionResult = await _controller.DeleteRelationship(1, 999); + + // Assert + var notFoundResult = Assert.IsType(actionResult); + Assert.Contains("999", notFoundResult.Value?.ToString()); + } + + [Fact] + public async Task DeleteRelationship_ReturnsNotFound_WhenWrongParent() + { + // Arrange + var relationship = new CourseRelationshipDto + { + Id = 1, + ParentCourseId = 2, // Different parent + ChildCourseId = 3, + RelationshipType = CrossListType + }; + + _relationshipServiceMock.Setup(s => s.GetRelationshipAsync(1, It.IsAny())).ReturnsAsync(relationship); + + // Act + var actionResult = await _controller.DeleteRelationship(1, 1); // Trying to delete via parent 1 + + // Assert + var notFoundResult = Assert.IsType(actionResult); + Assert.Contains("not found for course", notFoundResult.Value?.ToString()); + } + + [Fact] + public async Task DeleteRelationship_ReturnsNotFound_WhenParentUnauthorized() + { + // Arrange + var parentCourse = CreateCourse(1, DvmDept); + var relationship = new CourseRelationshipDto + { + Id = 1, + ParentCourseId = 1, + ChildCourseId = 2, + RelationshipType = CrossListType + }; + + _relationshipServiceMock.Setup(s => s.GetRelationshipAsync(1, It.IsAny())).ReturnsAsync(relationship); + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(parentCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync(DvmDept, It.IsAny())).ReturnsAsync(false); + + // Act + var actionResult = await _controller.DeleteRelationship(1, 1); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public async Task DeleteRelationship_ReturnsNotFound_WhenChildUnauthorized() + { + // Arrange + var parentCourse = CreateCourse(1, DvmDept); + var childCourse = CreateCourse(2, VmeDept); + var relationship = new CourseRelationshipDto + { + Id = 1, + ParentCourseId = 1, + ChildCourseId = 2, + RelationshipType = CrossListType + }; + + _relationshipServiceMock.Setup(s => s.GetRelationshipAsync(1, It.IsAny())).ReturnsAsync(relationship); + _courseServiceMock.Setup(s => s.GetCourseAsync(1, It.IsAny())).ReturnsAsync(parentCourse); + _courseServiceMock.Setup(s => s.GetCourseAsync(2, It.IsAny())).ReturnsAsync(childCourse); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync(DvmDept, It.IsAny())).ReturnsAsync(true); + _permissionServiceMock.Setup(s => s.CanViewDepartmentAsync(VmeDept, It.IsAny())).ReturnsAsync(false); + + // Act + var actionResult = await _controller.DeleteRelationship(1, 1); + + // Assert + Assert.IsType(actionResult); + } + + #endregion +} diff --git a/web/Areas/Effort/Controllers/CourseRelationshipsController.cs b/web/Areas/Effort/Controllers/CourseRelationshipsController.cs new file mode 100644 index 00000000..e42e6a80 --- /dev/null +++ b/web/Areas/Effort/Controllers/CourseRelationshipsController.cs @@ -0,0 +1,181 @@ +using Microsoft.AspNetCore.Mvc; +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 relationship operations (cross-listing and sections). +/// +[Route("/api/effort/courses/{parentCourseId:int}/relationships")] +public class CourseRelationshipsController : BaseEffortController +{ + private readonly ICourseRelationshipService _relationshipService; + private readonly ICourseService _courseService; + private readonly IEffortPermissionService _permissionService; + + public CourseRelationshipsController( + ICourseRelationshipService relationshipService, + ICourseService courseService, + IEffortPermissionService permissionService, + ILogger logger) : base(logger) + { + _relationshipService = relationshipService; + _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. + /// + private async Task<(CourseDto? course, ActionResult? errorResult)> GetAuthorizedCourseAsync(int courseId, CancellationToken ct) + { + var course = await _courseService.GetCourseAsync(courseId, ct); + if (course == null || !await _permissionService.CanViewDepartmentAsync(course.CustDept, ct)) + { + return (null, NotFound($"Course {courseId} not found")); + } + return (course, null); + } + + /// + /// Get all relationships for a course (both parent and child relationships). + /// + [HttpGet] + [Permission(Allow = $"{EffortPermissions.ViewDept},{EffortPermissions.ViewAllDepartments}")] + public async Task> GetRelationships(int parentCourseId, CancellationToken ct = default) + { + SetExceptionContext("courseId", parentCourseId); + + var (_, errorResult) = await GetAuthorizedCourseAsync(parentCourseId, ct); + if (errorResult != null) return errorResult; + + var result = await _relationshipService.GetRelationshipsForCourseAsync(parentCourseId, ct); + + // Filter relationships to only those the user can access + var hasFullAccess = await _permissionService.HasFullAccessAsync(ct); + if (!hasFullAccess) + { + var authorizedDepts = await _permissionService.GetAuthorizedDepartmentsAsync(ct); + result.ChildRelationships = result.ChildRelationships + .Where(r => r.ChildCourse != null && + authorizedDepts.Contains(r.ChildCourse.CustDept, StringComparer.OrdinalIgnoreCase)) + .ToList(); + + if (result.ParentRelationship?.ParentCourse != null && + !authorizedDepts.Contains(result.ParentRelationship.ParentCourse.CustDept, StringComparer.OrdinalIgnoreCase)) + { + result.ParentRelationship = null; + } + } + + return Ok(result); + } + + /// + /// Get courses available to be linked as children of the specified parent course. + /// + [HttpGet("available-children")] + [Permission(Allow = EffortPermissions.LinkCourses)] + public async Task>> GetAvailableChildren(int parentCourseId, CancellationToken ct = default) + { + SetExceptionContext("courseId", parentCourseId); + + var (_, errorResult) = await GetAuthorizedCourseAsync(parentCourseId, ct); + if (errorResult != null) return errorResult; + + var availableCourses = await _relationshipService.GetAvailableChildCoursesAsync(parentCourseId, ct); + + // Filter to courses the user can access + var hasFullAccess = await _permissionService.HasFullAccessAsync(ct); + if (!hasFullAccess) + { + var authorizedDepts = await _permissionService.GetAuthorizedDepartmentsAsync(ct); + availableCourses = availableCourses + .Where(c => authorizedDepts.Contains(c.CustDept, StringComparer.OrdinalIgnoreCase)) + .ToList(); + } + + return Ok(availableCourses); + } + + /// + /// Create a new course relationship. + /// + [HttpPost] + [Permission(Allow = EffortPermissions.LinkCourses)] + public async Task> CreateRelationship( + int parentCourseId, + [FromBody] CreateCourseRelationshipRequest request, + CancellationToken ct = default) + { + SetExceptionContext("parentCourseId", parentCourseId); + SetExceptionContext("childCourseId", request.ChildCourseId); + + // Verify access to parent course + var (_, errorResult) = await GetAuthorizedCourseAsync(parentCourseId, ct); + if (errorResult != null) return errorResult; + + // Verify access to child course + var (_, childErrorResult) = await GetAuthorizedCourseAsync(request.ChildCourseId, ct); + if (childErrorResult != null) return childErrorResult; + + try + { + var relationship = await _relationshipService.CreateRelationshipAsync(parentCourseId, request, ct); + _logger.LogInformation("Created {Type} relationship: course {ParentId} -> {ChildId}", + request.RelationshipType, parentCourseId, request.ChildCourseId); + + return CreatedAtAction(nameof(GetRelationships), new { parentCourseId }, relationship); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Failed to create relationship: {Message}", ex.Message); + return BadRequest(ex.Message); + } + } + + /// + /// Delete a course relationship. + /// + [HttpDelete("{relationshipId:int}")] + [Permission(Allow = EffortPermissions.LinkCourses)] + public async Task DeleteRelationship(int parentCourseId, int relationshipId, CancellationToken ct = default) + { + SetExceptionContext("parentCourseId", parentCourseId); + SetExceptionContext("relationshipId", relationshipId); + + // Get the relationship to verify it belongs to the parent course + var relationship = await _relationshipService.GetRelationshipAsync(relationshipId, ct); + if (relationship == null) + { + return NotFound($"Relationship {relationshipId} not found"); + } + + if (relationship.ParentCourseId != parentCourseId) + { + return NotFound($"Relationship {relationshipId} not found for course {parentCourseId}"); + } + + // Verify access to parent course + var (_, errorResult) = await GetAuthorizedCourseAsync(parentCourseId, ct); + if (errorResult != null) return errorResult; + + // Verify access to child course + var (_, childErrorResult) = await GetAuthorizedCourseAsync(relationship.ChildCourseId, ct); + if (childErrorResult != null) return childErrorResult; + + var deleted = await _relationshipService.DeleteRelationshipAsync(relationshipId, ct); + if (!deleted) + { + return NotFound($"Relationship {relationshipId} not found"); + } + + _logger.LogInformation("Deleted relationship {RelationshipId}", relationshipId); + return NoContent(); + } +} diff --git a/web/Areas/Effort/EffortDbContext.cs b/web/Areas/Effort/EffortDbContext.cs index 0eab7ad1..507d2d4b 100644 --- a/web/Areas/Effort/EffortDbContext.cs +++ b/web/Areas/Effort/EffortDbContext.cs @@ -311,6 +311,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.ChildCourseId).HasColumnName("ChildCourseId"); entity.Property(e => e.RelationshipType).HasColumnName("RelationshipType").HasMaxLength(20); + // Each child course can only have one parent + entity.HasIndex(e => e.ChildCourseId, "IX_CourseRelationships_ChildCourseId") + .IsUnique(); + entity.HasOne(e => e.ParentCourse) .WithMany(c => c.ParentRelationships) .HasForeignKey(e => e.ParentCourseId); diff --git a/web/Areas/Effort/Models/DTOs/Requests/CreateCourseRelationshipRequest.cs b/web/Areas/Effort/Models/DTOs/Requests/CreateCourseRelationshipRequest.cs new file mode 100644 index 00000000..75e74beb --- /dev/null +++ b/web/Areas/Effort/Models/DTOs/Requests/CreateCourseRelationshipRequest.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace Viper.Areas.Effort.Models.DTOs.Requests; + +/// +/// Request DTO for creating a course relationship. +/// +public class CreateCourseRelationshipRequest +{ + /// + /// The ID of the course to be linked as a child. + /// + [Required] + public required int ChildCourseId { get; set; } + + /// + /// The type of relationship: "CrossList" or "Section". + /// + [Required] + [RegularExpression("^(CrossList|Section)$", ErrorMessage = "RelationshipType must be 'CrossList' or 'Section'")] + public string RelationshipType { get; set; } = string.Empty; +} diff --git a/web/Areas/Effort/Models/DTOs/Responses/CourseRelationshipDto.cs b/web/Areas/Effort/Models/DTOs/Responses/CourseRelationshipDto.cs new file mode 100644 index 00000000..dd215b21 --- /dev/null +++ b/web/Areas/Effort/Models/DTOs/Responses/CourseRelationshipDto.cs @@ -0,0 +1,26 @@ +namespace Viper.Areas.Effort.Models.DTOs.Responses; + +/// +/// DTO for course relationship data. +/// +public class CourseRelationshipDto +{ + public int Id { get; set; } + public int ParentCourseId { get; set; } + public int ChildCourseId { get; set; } + + /// + /// Relationship type: "CrossList" or "Section" + /// + public string RelationshipType { get; set; } = string.Empty; + + /// + /// Child course details (populated when viewing a parent's children). + /// + public CourseDto? ChildCourse { get; set; } + + /// + /// Parent course details (populated when viewing a child's parent). + /// + public CourseDto? ParentCourse { get; set; } +} diff --git a/web/Areas/Effort/Scripts/CreateEffortDatabase.cs b/web/Areas/Effort/Scripts/CreateEffortDatabase.cs index 4ab77e9c..77334411 100644 --- a/web/Areas/Effort/Scripts/CreateEffortDatabase.cs +++ b/web/Areas/Effort/Scripts/CreateEffortDatabase.cs @@ -1030,8 +1030,12 @@ CONSTRAINT PK_CourseRelationships PRIMARY KEY CLUSTERED (Id), CONSTRAINT FK_CourseRelationships_Parent FOREIGN KEY (ParentCourseId) REFERENCES [effort].[Courses](Id), CONSTRAINT FK_CourseRelationships_Child FOREIGN KEY (ChildCourseId) REFERENCES [effort].[Courses](Id), CONSTRAINT UQ_CourseRelationships UNIQUE (ParentCourseId, ChildCourseId), - CONSTRAINT CK_CourseRelationships_Type CHECK (RelationshipType IN ('Parent', 'Child', 'CrossList', 'Section')) + CONSTRAINT CK_CourseRelationships_Type CHECK (RelationshipType IN ('CrossList', 'Section')) ); + + -- Unique index to ensure each child course can only have one parent + CREATE UNIQUE NONCLUSTERED INDEX IX_CourseRelationships_ChildCourseId + ON [effort].[CourseRelationships](ChildCourseId); END"; cmd.ExecuteNonQuery(); Console.WriteLine(" ✓ CourseRelationships table created"); diff --git a/web/Areas/Effort/Services/CourseRelationshipService.cs b/web/Areas/Effort/Services/CourseRelationshipService.cs new file mode 100644 index 00000000..1d8eb08d --- /dev/null +++ b/web/Areas/Effort/Services/CourseRelationshipService.cs @@ -0,0 +1,302 @@ +using Microsoft.Data.SqlClient; +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; + +namespace Viper.Areas.Effort.Services; + +/// +/// Service for course relationship operations (cross-listing and sections). +/// +public class CourseRelationshipService : ICourseRelationshipService +{ + private readonly EffortDbContext _context; + private readonly IEffortAuditService _auditService; + private readonly ILogger _logger; + + public CourseRelationshipService( + EffortDbContext context, + IEffortAuditService auditService, + ILogger logger) + { + _context = context; + _auditService = auditService; + _logger = logger; + } + + public async Task GetRelationshipsForCourseAsync(int courseId, CancellationToken ct = default) + { + var result = new CourseRelationshipsResult(); + + // Check if this course is a child of another course + result.ParentRelationship = await GetParentRelationshipAsync(courseId, ct); + + // Get children of this course + result.ChildRelationships = await GetChildRelationshipsAsync(courseId, ct); + + return result; + } + + public async Task> GetChildRelationshipsAsync(int parentCourseId, CancellationToken ct = default) + { + var relationships = await _context.CourseRelationships + .AsNoTracking() + .Include(r => r.ChildCourse) + .Where(r => r.ParentCourseId == parentCourseId) + .OrderBy(r => r.ChildCourse.SubjCode) + .ThenBy(r => r.ChildCourse.CrseNumb) + .ThenBy(r => r.ChildCourse.SeqNumb) + .ToListAsync(ct); + + return relationships.Select(r => ToDto(r, includeChild: true)).ToList(); + } + + public async Task GetParentRelationshipAsync(int childCourseId, CancellationToken ct = default) + { + var relationship = await _context.CourseRelationships + .AsNoTracking() + .Include(r => r.ParentCourse) + .FirstOrDefaultAsync(r => r.ChildCourseId == childCourseId, ct); + + return relationship == null ? null : ToDto(relationship, includeParent: true); + } + + public async Task> GetAvailableChildCoursesAsync(int parentCourseId, CancellationToken ct = default) + { + // Get the parent course to know the term + var parentCourse = await _context.Courses + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == parentCourseId, ct); + + if (parentCourse == null) + { + return new List(); + } + + // Get IDs of courses that are already children (of any parent) + var existingChildIds = await _context.CourseRelationships + .AsNoTracking() + .Select(r => r.ChildCourseId) + .ToListAsync(ct); + + // Get IDs of courses that are already parents (have children) + // These cannot become children to prevent multi-level hierarchies + var existingParentIds = await _context.CourseRelationships + .AsNoTracking() + .Select(r => r.ParentCourseId) + .Distinct() + .ToListAsync(ct); + + // Get courses in the same term that: + // - Are not the parent course itself + // - Are not already a child of any parent + // - Are not already a parent (to prevent multi-level hierarchies) + var availableCourses = await _context.Courses + .AsNoTracking() + .Where(c => c.TermCode == parentCourse.TermCode + && c.Id != parentCourseId + && !existingChildIds.Contains(c.Id) + && !existingParentIds.Contains(c.Id)) + .OrderBy(c => c.SubjCode) + .ThenBy(c => c.CrseNumb) + .ThenBy(c => c.SeqNumb) + .ToListAsync(ct); + + return availableCourses.Select(ToCourseDto).ToList(); + } + + public async Task CreateRelationshipAsync(int parentCourseId, CreateCourseRelationshipRequest request, CancellationToken ct = default) + { + // Validate parent course exists + var parentCourse = await _context.Courses + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == parentCourseId, ct) + ?? throw new InvalidOperationException($"Parent course {parentCourseId} not found"); + + // Prevent multi-level hierarchies: a child cannot become a parent + var parentIsAlreadyChild = await _context.CourseRelationships + .AsNoTracking() + .AnyAsync(r => r.ChildCourseId == parentCourseId, ct); + + if (parentIsAlreadyChild) + { + throw new InvalidOperationException( + $"Course {parentCourse.SubjCode} {parentCourse.CrseNumb}-{parentCourse.SeqNumb} cannot be a parent because it is already a child of another course"); + } + + // Validate child course exists + var childCourse = await _context.Courses + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == request.ChildCourseId, ct) + ?? throw new InvalidOperationException($"Child course {request.ChildCourseId} not found"); + + // Prevent multi-level hierarchies: a parent cannot become a child + var childIsAlreadyParent = await _context.CourseRelationships + .AsNoTracking() + .AnyAsync(r => r.ParentCourseId == request.ChildCourseId, ct); + + if (childIsAlreadyParent) + { + throw new InvalidOperationException( + $"Course {childCourse.SubjCode} {childCourse.CrseNumb}-{childCourse.SeqNumb} cannot be a child because it already has linked children"); + } + + // Validate same term + if (parentCourse.TermCode != childCourse.TermCode) + { + throw new InvalidOperationException("Parent and child courses must be in the same term"); + } + + // Validate not linking to self + if (parentCourseId == request.ChildCourseId) + { + throw new InvalidOperationException("A course cannot be linked to itself"); + } + + // Check if child already has a parent + var existingParent = await _context.CourseRelationships + .AsNoTracking() + .FirstOrDefaultAsync(r => r.ChildCourseId == request.ChildCourseId, ct); + + if (existingParent != null) + { + throw new InvalidOperationException($"Course {childCourse.SubjCode} {childCourse.CrseNumb} is already a child of another course"); + } + + // Check for duplicate relationship + var existingRelationship = await _context.CourseRelationships + .AsNoTracking() + .FirstOrDefaultAsync(r => r.ParentCourseId == parentCourseId && r.ChildCourseId == request.ChildCourseId, ct); + + if (existingRelationship != null) + { + throw new InvalidOperationException("This relationship already exists"); + } + + // Create the relationship + var relationship = new CourseRelationship + { + ParentCourseId = parentCourseId, + ChildCourseId = request.ChildCourseId, + RelationshipType = request.RelationshipType + }; + + await using var transaction = await _context.Database.BeginTransactionAsync(ct); + + _context.CourseRelationships.Add(relationship); + + try + { + await _context.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (ex.InnerException is SqlException { Number: 2601 or 2627 }) + { + // Unique constraint violation - child already has a parent (race condition) + throw new InvalidOperationException( + $"Course {childCourse.SubjCode} {childCourse.CrseNumb} is already a child of another course"); + } + + // Log audit entry + _auditService.AddCourseChangeAudit(parentCourseId, parentCourse.TermCode, EffortAuditActions.CreateCourseRelationship, + null, + new + { + ChildCourseId = request.ChildCourseId, + ChildCourse = $"{childCourse.SubjCode} {childCourse.CrseNumb}-{childCourse.SeqNumb}", + RelationshipType = request.RelationshipType + }); + await _context.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + + _logger.LogInformation("Created {RelationshipType} relationship: {ParentCourse} -> {ChildCourse}", + request.RelationshipType, + $"{parentCourse.SubjCode} {parentCourse.CrseNumb}", + $"{childCourse.SubjCode} {childCourse.CrseNumb}"); + + // Return the created relationship with child course info + relationship.ChildCourse = childCourse; + return ToDto(relationship, includeChild: true); + } + + public async Task DeleteRelationshipAsync(int relationshipId, CancellationToken ct = default) + { + var relationship = await _context.CourseRelationships + .Include(r => r.ParentCourse) + .Include(r => r.ChildCourse) + .FirstOrDefaultAsync(r => r.Id == relationshipId, ct); + + if (relationship == null) + { + return false; + } + + var parentCourse = relationship.ParentCourse; + var childCourse = relationship.ChildCourse; + + await using var transaction = await _context.Database.BeginTransactionAsync(ct); + + _context.CourseRelationships.Remove(relationship); + + // Log audit entry + _auditService.AddCourseChangeAudit(relationship.ParentCourseId, parentCourse.TermCode, EffortAuditActions.DeleteCourseRelationship, + new + { + ChildCourseId = relationship.ChildCourseId, + ChildCourse = $"{childCourse.SubjCode} {childCourse.CrseNumb}-{childCourse.SeqNumb}", + RelationshipType = relationship.RelationshipType + }, + null); + + await _context.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + + _logger.LogInformation("Deleted {RelationshipType} relationship: {ParentCourse} -> {ChildCourse}", + relationship.RelationshipType, + $"{parentCourse.SubjCode} {parentCourse.CrseNumb}", + $"{childCourse.SubjCode} {childCourse.CrseNumb}"); + + return true; + } + + public async Task GetRelationshipAsync(int relationshipId, CancellationToken ct = default) + { + var relationship = await _context.CourseRelationships + .AsNoTracking() + .Include(r => r.ParentCourse) + .Include(r => r.ChildCourse) + .FirstOrDefaultAsync(r => r.Id == relationshipId, ct); + + return relationship == null ? null : ToDto(relationship, includeParent: true, includeChild: true); + } + + private static CourseRelationshipDto ToDto(CourseRelationship relationship, bool includeParent = false, bool includeChild = false) + { + return new CourseRelationshipDto + { + Id = relationship.Id, + ParentCourseId = relationship.ParentCourseId, + ChildCourseId = relationship.ChildCourseId, + RelationshipType = relationship.RelationshipType, + ParentCourse = includeParent && relationship.ParentCourse != null ? ToCourseDto(relationship.ParentCourse) : null, + ChildCourse = includeChild && relationship.ChildCourse != null ? ToCourseDto(relationship.ChildCourse) : null + }; + } + + private static CourseDto ToCourseDto(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/ICourseRelationshipService.cs b/web/Areas/Effort/Services/ICourseRelationshipService.cs new file mode 100644 index 00000000..1042be8c --- /dev/null +++ b/web/Areas/Effort/Services/ICourseRelationshipService.cs @@ -0,0 +1,62 @@ +using Viper.Areas.Effort.Models.DTOs.Requests; +using Viper.Areas.Effort.Models.DTOs.Responses; + +namespace Viper.Areas.Effort.Services; + +/// +/// Service interface for course relationship operations. +/// +public interface ICourseRelationshipService +{ + /// + /// Get all relationships for a course (both as parent and child). + /// + Task GetRelationshipsForCourseAsync(int courseId, CancellationToken ct = default); + + /// + /// Get child relationships for a parent course. + /// + Task> GetChildRelationshipsAsync(int parentCourseId, CancellationToken ct = default); + + /// + /// Get the parent relationship for a child course (if any). + /// + Task GetParentRelationshipAsync(int childCourseId, CancellationToken ct = default); + + /// + /// Get courses available to be linked as children of a parent course. + /// Excludes: the parent itself, courses already linked as children, and courses that are already children of another parent. + /// + Task> GetAvailableChildCoursesAsync(int parentCourseId, CancellationToken ct = default); + + /// + /// Create a new course relationship. + /// + Task CreateRelationshipAsync(int parentCourseId, CreateCourseRelationshipRequest request, CancellationToken ct = default); + + /// + /// Delete a course relationship. + /// + Task DeleteRelationshipAsync(int relationshipId, CancellationToken ct = default); + + /// + /// Get a relationship by ID. + /// + Task GetRelationshipAsync(int relationshipId, CancellationToken ct = default); +} + +/// +/// Result containing both parent and child relationships for a course. +/// +public class CourseRelationshipsResult +{ + /// + /// If this course is a child, the parent relationship. + /// + public CourseRelationshipDto? ParentRelationship { get; set; } + + /// + /// If this course is a parent, the list of child relationships. + /// + public List ChildRelationships { get; set; } = new(); +} diff --git a/web/Areas/Effort/docs/Effort_Database_Schema.sql b/web/Areas/Effort/docs/Effort_Database_Schema.sql index fa6b2ffd..8f6b2452 100644 --- a/web/Areas/Effort/docs/Effort_Database_Schema.sql +++ b/web/Areas/Effort/docs/Effort_Database_Schema.sql @@ -459,10 +459,15 @@ CREATE TABLE [effort].[CourseRelationships] ( CONSTRAINT FK_CourseRelationships_Parent FOREIGN KEY (ParentCourseId) REFERENCES [effort].[Courses](Id), CONSTRAINT FK_CourseRelationships_Child FOREIGN KEY (ChildCourseId) REFERENCES [effort].[Courses](Id), CONSTRAINT UQ_CourseRelationships UNIQUE (ParentCourseId, ChildCourseId), - CONSTRAINT CK_CourseRelationships_Type CHECK (RelationshipType IN ('Parent', 'Child', 'CrossList', 'Section')) + CONSTRAINT CK_CourseRelationships_Type CHECK (RelationshipType IN ('CrossList', 'Section')) ); GO +-- Unique index to ensure each child course can only have one parent +CREATE UNIQUE NONCLUSTERED INDEX IX_CourseRelationships_ChildCourseId +ON [effort].[CourseRelationships](ChildCourseId); +GO + -- ---------------------------------------------------------------------------- -- Table: Audits -- Description: Comprehensive audit trail for all effort data changes diff --git a/web/Program.cs b/web/Program.cs index 576443fb..a46009f0 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -226,6 +226,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 1c22ad4ab2e74e502aad49357380b4623d73a76a Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Tue, 23 Dec 2025 14:45:44 -0800 Subject: [PATCH 2/3] fix(effort): VPR-19 hide link button for child courses and fix race condition - Hide link button when course already has parent (enforces flat hierarchy) - Add ParentCourseId to CourseDto to track child status - Fix async race condition when navigating between courses quickly --- VueApp/src/Effort/pages/CourseDetail.vue | 24 +++++++++++++++---- VueApp/src/Effort/pages/CourseList.vue | 2 +- VueApp/src/Effort/types/index.ts | 2 ++ .../Effort/Models/DTOs/Responses/CourseDto.cs | 6 +++++ .../Services/CourseRelationshipService.cs | 11 +++++++++ web/Areas/Effort/Services/CourseService.cs | 12 +++++++--- 6 files changed, 48 insertions(+), 9 deletions(-) diff --git a/VueApp/src/Effort/pages/CourseDetail.vue b/VueApp/src/Effort/pages/CourseDetail.vue index d7a5047b..4919dd67 100644 --- a/VueApp/src/Effort/pages/CourseDetail.vue +++ b/VueApp/src/Effort/pages/CourseDetail.vue @@ -54,7 +54,7 @@ Edit course diff --git a/VueApp/src/Effort/pages/CourseList.vue b/VueApp/src/Effort/pages/CourseList.vue index 7215ffd6..b815e43c 100644 --- a/VueApp/src/Effort/pages/CourseList.vue +++ b/VueApp/src/Effort/pages/CourseList.vue @@ -120,7 +120,7 @@