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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions test/Effort/ClinicalImportServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ public ClinicalImportServiceTests()
.BatchResolveDepartmentsAsync(Arg.Any<List<string>>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => ci.Arg<List<string>>().ToDictionary(id => id, _ => "VME"));

_instructorServiceMock
.GetExcludedTitleCodesAsync(Arg.Any<CancellationToken>())
.Returns(new HashSet<string>(StringComparer.OrdinalIgnoreCase));

_service = new ClinicalImportService(
_context,
_viperContext,
Expand Down
11 changes: 10 additions & 1 deletion test/Effort/CourseServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Viper.Areas.Effort.Models.DTOs.Responses;
using Viper.Areas.Effort.Models.Entities;
using Viper.Areas.Effort.Services;
using Viper.Classes.SQLContext;

namespace Viper.test.Effort;

Expand All @@ -18,6 +19,7 @@ namespace Viper.test.Effort;
public sealed class CourseServiceTests : IDisposable
{
private readonly EffortDbContext _context;
private readonly CoursesContext _coursesContext;
private readonly IEffortAuditService _auditServiceMock;
private readonly ICourseClassificationService _classificationService;
private readonly ILogger<CourseService> _loggerMock;
Expand All @@ -31,6 +33,12 @@ public CourseServiceTests()
.Options;

_context = new EffortDbContext(effortOptions);

var coursesOptions = new DbContextOptionsBuilder<CoursesContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_coursesContext = new CoursesContext(coursesOptions);

_auditServiceMock = Substitute.For<IEffortAuditService>();
_classificationService = new CourseClassificationService();
_loggerMock = Substitute.For<ILogger<CourseService>>();
Expand All @@ -39,12 +47,13 @@ public CourseServiceTests()
_auditServiceMock
.AddCourseChangeAudit(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<object?>());

_courseService = new CourseService(_context, _auditServiceMock, _classificationService, _loggerMock);
_courseService = new CourseService(_context, _coursesContext, _auditServiceMock, _classificationService, _loggerMock);
}

public void Dispose()
{
_context.Dispose();
_coursesContext.Dispose();
}

#region GetCoursesAsync Tests
Expand Down
52 changes: 52 additions & 0 deletions test/Effort/CustodialDepartmentResolverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,56 @@ public void Resolve_ReturnsUNK_ForUnknownNumericCode()
}

#endregion

#region Custodial (IOR-resolved) dept code resolution

[Fact]
public void ResolveWithCustodialCode_MapsIorCustodialCode_WhenSubjectAndBaseinfoDeptUnknown()
{
// IMM 294 shape: subject "IMM" is not an SVM dept and the baseinfo dept is not one of the
// six academic depts, but vw_xtnd_baseinfo resolved custodial_dept_code 072067 (PHR) via the IOR.
var result = CustodialDepartmentResolver.ResolveWithCustodialCode("IMM", "ANS", "072067");
Assert.Equal("PHR", result);
}

[Fact]
public void ResolveWithCustodialCode_PadsShortCustodialCode_WithLeadingZeros()
{
var result = CustodialDepartmentResolver.ResolveWithCustodialCode("IMM", "ANS", "72067");
Assert.Equal("PHR", result);
}

[Fact]
public void ResolveWithCustodialCode_PrefersBaseinfoDept_WhenAlreadyValidSvmDept()
{
// Legacy tier 1: a baseinfo dept that is already an SVM dept wins over the custodial code.
var result = CustodialDepartmentResolver.ResolveWithCustodialCode("IMM", "APC", "072067");
Assert.Equal("APC", result);
}

[Fact]
public void ResolveWithCustodialCode_PrefersSubjectCode_WhenValidSvmDept()
{
var result = CustodialDepartmentResolver.ResolveWithCustodialCode("VME", "ANS", "072067");
Assert.Equal("VME", result);
}

[Fact]
public void ResolveWithCustodialCode_FallsBackToBaseinfoNumeric_WhenNoCustodialCode()
{
var result = CustodialDepartmentResolver.ResolveWithCustodialCode("IMM", "072030", null);
Assert.Equal("VME", result);
}

[Theory]
[InlineData("IMM", "ANS", null)]
[InlineData("IMM", "ANS", "999999")]
[InlineData(null, null, null)]
public void ResolveWithCustodialCode_ReturnsUNK_WhenNoMatch(string? subj, string? dept, string? custodial)
{
var result = CustodialDepartmentResolver.ResolveWithCustodialCode(subj, dept, custodial);
Assert.Equal("UNK", result);
}

#endregion
}
51 changes: 51 additions & 0 deletions test/Effort/EffortRecordServiceTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Viper.Areas.Effort;
Expand Down Expand Up @@ -71,6 +72,7 @@ public EffortRecordServiceTests()
courseClassificationService,
_rCourseServiceMock,
_userHelperMock,
Options.Create(new EffortSettings { AutoCreateGenericRCourse = true }),
_loggerMock);

SeedTestData();
Expand Down Expand Up @@ -1490,6 +1492,55 @@ await _rCourseServiceMock.Received(1).CreateRCourseEffortRecordAsync(
Arg.Any<CancellationToken>());
}

[Fact]
public async Task CreateEffortRecordAsync_DoesNotCallRCourseService_WhenAutoCreateDisabled()
{
// Arrange - first non-R-course record, but generic R-course auto-create disabled
var serviceWithRCourseDisabled = new EffortRecordService(
_context,
_rapsContext,
_auditServiceMock,
_instructorServiceMock,
new CourseClassificationService(),
_rCourseServiceMock,
_userHelperMock,
Options.Create(new EffortSettings { AutoCreateGenericRCourse = false }),
_loggerMock);

var newPersonId = 202;
_context.Persons.Add(new EffortPerson
{
PersonId = newPersonId,
TermCode = TestTermCode,
FirstName = "Disabled",
LastName = "Instructor",
EffortDept = "VME"
});
await _context.SaveChangesAsync(TestContext.Current.CancellationToken);

var request = new CreateEffortRecordRequest
{
PersonId = newPersonId,
TermCode = TestTermCode,
CourseId = TestCourseId, // VET 410 - not an R-course
EffortTypeId = "LEC",
RoleId = 1,
EffortValue = 40
};

// Act
var (result, _) = await serviceWithRCourseDisabled.CreateEffortRecordAsync(request, TestContext.Current.CancellationToken);

// Assert - record created, but no generic R-course auto-created
Assert.NotNull(result);
await _rCourseServiceMock.DidNotReceive().CreateRCourseEffortRecordAsync(
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<RCourseCreationContext>(),
Arg.Any<CancellationToken>());
}

[Fact]
public async Task CreateEffortRecordAsync_DoesNotCallRCourseService_WhenSecondNonRCourseAdded()
{
Expand Down
89 changes: 89 additions & 0 deletions test/Effort/HarvestServiceTests.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using Viper.Areas.Effort;
using Viper.Areas.Effort.Constants;
using Viper.Areas.Effort.Models.DTOs.Responses;
using Viper.Areas.Effort.Models.Entities;
using Viper.Areas.Effort.Services;
Expand Down Expand Up @@ -34,6 +36,11 @@ public sealed class HarvestServiceTests : IDisposable
private readonly ILogger<HarvestService> _loggerMock;
private readonly HarvestService _harvestService;

// R-course auto-creation enabled for the shared service so existing R-course tests
// exercise the generation path. Toggle-off behavior is covered by its own test.
private static readonly IOptions<EffortSettings> RCourseEnabledSettings =
Options.Create(new EffortSettings { AutoCreateGenericRCourse = true });

private const int TestTermCode = 202410;

public HarvestServiceTests()
Expand Down Expand Up @@ -93,6 +100,8 @@ public HarvestServiceTests()
.GetTitleCodesAsync(Arg.Any<CancellationToken>()).Returns(new List<TitleCodeDto>());
_instructorServiceMock
.GetDepartmentSimpleNameLookupAsync(Arg.Any<CancellationToken>()).Returns(new Dictionary<string, string>());
_instructorServiceMock
.GetExcludedTitleCodesAsync(Arg.Any<CancellationToken>()).Returns(new HashSet<string>(StringComparer.OrdinalIgnoreCase));
// Setup audit service mock for harvest operations
_auditServiceMock
.ClearAuditForTermAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()).Returns(Task.CompletedTask);
Expand Down Expand Up @@ -123,6 +132,7 @@ public HarvestServiceTests()
_instructorServiceMock,
_rCourseServiceMock,
_clinicalImportServiceMock,
RCourseEnabledSettings,
_loggerMock);
}

Expand Down Expand Up @@ -745,6 +755,7 @@ public async Task ExecuteHarvestAsync_CallsRCourseService_ForEligibleInstructors
_instructorServiceMock,
_rCourseServiceMock,
_clinicalImportServiceMock,
RCourseEnabledSettings,
_loggerMock);

// Act - Run harvest (R-course detection uses inline EndsWith("R") logic)
Expand All @@ -762,6 +773,83 @@ await _rCourseServiceMock.Received(1).CreateRCourseEffortRecordAsync(
Arg.Any<CancellationToken>());
}

[Fact]
public async Task ExecuteHarvestAsync_DoesNotCallRCourseService_WhenAutoCreateDisabled()
{
// Arrange - same eligible-instructor setup as the enabled case
_context.Terms.Add(new EffortTerm { TermCode = TestTermCode });
_context.EffortTypes.Add(new EffortType
{
Id = "LEC",
Description = "Lecture",
AllowedOnRCourses = true,
IsActive = true
});
await _context.SaveChangesAsync(TestContext.Current.CancellationToken);

var testPhase = new TestDataHarvestPhase(_context, TestTermCode);
var disabledSettings = Options.Create(new EffortSettings { AutoCreateGenericRCourse = false });

var harvestService = new HarvestService(
new List<IHarvestPhase> { testPhase },
_context,
_viperContext,
_coursesContext,
_crestContext,
_aaudContext,
_dictionaryContext,
_auditServiceMock,
_termServiceMock,
_instructorServiceMock,
_rCourseServiceMock,
_clinicalImportServiceMock,
disabledSettings,
_loggerMock);

// Act
var result = await harvestService.ExecuteHarvestAsync(TestTermCode, modifiedBy: 123, ct: TestContext.Current.CancellationToken);

// Assert - harvest succeeds but the generic R-course is not created
Assert.True(result.Success);
await _rCourseServiceMock.DidNotReceive().CreateRCourseEffortRecordAsync(
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<RCourseCreationContext>(),
Arg.Any<CancellationToken>());
}

[Fact]
public void ApplyDirectorCustodialDeptFallback_InheritsDirectorDept_OnlyForNonAcademicCourses()
{
// Arrange — three non-CREST courses: UNK with academic director, already-academic,
// and UNK with a non-academic director.
var courses = new List<HarvestCoursePreview>
{
new() { Crn = "10001", CustDept = "UNK", Source = EffortConstants.SourceNonCrest },
new() { Crn = "10002", CustDept = "PMI", Source = EffortConstants.SourceNonCrest },
new() { Crn = "10003", CustDept = "UNK", Source = EffortConstants.SourceNonCrest },
};
var effort = new List<HarvestRecordPreview>
{
new() { Crn = "10001", MothraId = "AAA", RoleId = EffortConstants.DirectorRoleId },
new() { Crn = "10003", MothraId = "CCC", RoleId = EffortConstants.DirectorRoleId },
};
var batchDepts = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["AAA"] = "VME",
["CCC"] = "UNK",
};

// Act
NonCrestHarvestPhase.ApplyDirectorCustodialDeptFallback(courses, effort, batchDepts);

// Assert
Assert.Equal("VME", courses[0].CustDept); // inherited from academic IOR
Assert.Equal("PMI", courses[1].CustDept); // already academic — unchanged
Assert.Equal("UNK", courses[2].CustDept); // IOR dept not academic — unchanged
}

/// <summary>
/// Creates a HarvestService that uses a single custom phase for testing.
/// </summary>
Expand All @@ -780,6 +868,7 @@ private HarvestService CreateServiceWithPhase(IHarvestPhase phase)
_instructorServiceMock,
_rCourseServiceMock,
_clinicalImportServiceMock,
RCourseEnabledSettings,
_loggerMock);
}

Expand Down
69 changes: 69 additions & 0 deletions test/Effort/InstructorServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1113,6 +1113,75 @@ public async Task BatchResolveDepartmentsAsync_ReturnsOverrideDept_WhenMothraIdH
Assert.Equal(expectedDept, result[overrideMothraId]);
}

[Fact]
public async Task BatchResolveDepartmentsAsync_ReturnsVme_ForChristineJohnson_WithNoAcademicJob()
{
// Christine Johnson (00129082) has no academic-department job in AAUD and a
// non-academic home/effort dept (072016), so the dept cannot be auto-derived.
// The override pins her to VME.
const string mothraId = "00129082";
_aaudContext.Ids.Add(new Id
{
IdsPKey = "CJOHNSON01",
IdsTermCode = "202410",
IdsMothraid = mothraId,
IdsClientid = mothraId
});
_aaudContext.Employees.Add(new Employee
{
EmpPKey = "CJOHNSON01",
EmpTermCode = "202410",
EmpClientid = mothraId,
EmpHomeDept = "072016",
EmpAltDeptCode = "",
EmpEffortHomeDept = "072016",
EmpSchoolDivision = "VM",
EmpCbuc = "99",
EmpStatus = "A"
});
await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken);

// Act
var result = await _instructorService.BatchResolveDepartmentsAsync([mothraId], 202410, TestContext.Current.CancellationToken);

// Assert
Assert.Equal("VME", result[mothraId]);
}

[Fact]
public async Task BatchResolveDepartmentsAsync_ReturnsVsr_ForMichaelMison_WithNonAcademicJob()
{
// Michael Mison (02493928) only has a VMTH job (non-academic); the override
// records his effort to VSR regardless of his AAUD job/employee depts.
const string mothraId = "02493928";
_aaudContext.Ids.Add(new Id
{
IdsPKey = "MISON00001",
IdsTermCode = "202410",
IdsMothraid = mothraId,
IdsClientid = mothraId
});
_aaudContext.Employees.Add(new Employee
{
EmpPKey = "MISON00001",
EmpTermCode = "202410",
EmpClientid = mothraId,
EmpHomeDept = "072000",
EmpAltDeptCode = "",
EmpEffortHomeDept = "072000",
EmpSchoolDivision = "VM",
EmpCbuc = "99",
EmpStatus = "A"
});
await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken);

// Act
var result = await _instructorService.BatchResolveDepartmentsAsync([mothraId], 202410, TestContext.Current.CancellationToken);

// Assert
Assert.Equal("VSR", result[mothraId]);
}

[Fact]
public async Task BatchResolveDepartmentsAsync_ReturnsAcademicDeptFromJobs_WhenAvailable()
{
Expand Down
Loading
Loading