diff --git a/src/CommitmentsV2/SFA.DAS.CommitmentsV2.ExternalHandlers/EventHandlers/LearningResumedEventHandler.cs b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.ExternalHandlers/EventHandlers/LearningResumedEventHandler.cs new file mode 100644 index 000000000..73e16e00c --- /dev/null +++ b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.ExternalHandlers/EventHandlers/LearningResumedEventHandler.cs @@ -0,0 +1,105 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NServiceBus; +using SFA.DAS.CommitmentsV2.Data; +using SFA.DAS.CommitmentsV2.Domain.Exceptions; +using SFA.DAS.CommitmentsV2.Messages.Commands; +using SFA.DAS.CommitmentsV2.Models; +using SFA.DAS.CommitmentsV2.Shared.Extensions; +using SFA.DAS.CommitmentsV2.Types; +using SFA.DAS.Learning.Types; + +namespace SFA.DAS.CommitmentsV2.ExternalHandlers.EventHandlers; + +public class LearningResumedEventHandler( + Lazy dbContext, + ILogger logger) + : IHandleMessages +{ + public async Task Handle(LearningResumedEvent message, IMessageHandlerContext context) + { + try + { + logger.LogInformation(" Started executing {Event}", nameof(LearningResumedEvent)); + + if (message is null) + { + logger.LogInformation("Event received null message : {Event}", nameof(LearningResumedEvent)); + return; + } + + logger.LogInformation("LearningResumedEvent for ApprenticeshipId {ApprenticeshipId} with ResumeDate {ResumeDate}", + message.ApprenticeshipId, message.ResumeDate); + var db = dbContext.Value; + var apprentice = await db.Apprenticeships + .Include(a => a.Cohort) + .ThenInclude(c => c.Provider) + .SingleOrDefaultAsync(t => t.Id == message.ApprenticeshipId); + + if (apprentice == null) + { + throw new DomainException(nameof(apprentice), $"Apprenticeship with Id {message.ApprenticeshipId} not found."); + } + + if (apprentice.PaymentStatus == PaymentStatus.Active && !apprentice.PauseDate.HasValue) + { + logger.LogInformation("Apprenticeship {ApprenticeshipId} is already active and resumed.", apprentice.Id); + } + else + { + ValidateResumeDate(message.ResumeDate, apprentice); + + apprentice.SetIlrResumed(message.ResumeDate); + + var historyCommand = new StoreLearningHistoryCommand + { + ApprenticeshipId = message.ApprenticeshipId, + Source = LearningSourceType.ILRStatusChange, + ChangeType = LearningChangeType.AutoApproved, + LearningKey = message.LearningKey, + AppliedDate = message.Created, + Description = $"Learning has been resumed on {message.ResumeDate.ToGdsFormat()}" + }; + await context.Send(historyCommand); + + logger.LogInformation(" Executing {Event} completed", nameof(LearningResumedEvent)); + } + } + catch (Exception e) + { + logger.LogError(e, "Error processing LearningResumedEventHandler for ApprenticeshipId {0}", message?.ApprenticeshipId); + throw; + } + } + + private void ValidateResumeDate(DateTime resumeDate, Apprenticeship apprenticeship) + { + if (apprenticeship.PaymentStatus == PaymentStatus.Completed || apprenticeship.PaymentStatus == PaymentStatus.Withdrawn) + { + throw new DomainException(nameof(resumeDate), "Learning cannot be Resumed if Payment Status is Completed or Withdrawn. Unable to resume apprenticeship"); + } + + if (apprenticeship.StartDate.HasValue && apprenticeship.StartDate.Value.Date >= resumeDate.Date) + { + throw new DomainException(nameof(resumeDate), "Invalid resume date. Learner not started."); + } + + if (apprenticeship.EndDate.HasValue && resumeDate.Date >= apprenticeship.EndDate.Value.Date) + { + throw new DomainException(nameof(resumeDate), "Invalid resume date. Resume date cannot be on or after the end date."); + } + + if (apprenticeship.PauseDate.HasValue && resumeDate.Date < apprenticeship.PauseDate.Value.Date) + { + throw new DomainException(nameof(resumeDate), "Invalid resume date. Resume date cannot be before the pausedate."); + } + + if (!apprenticeship.PauseDate.HasValue && resumeDate.Date != DateTime.MinValue) + { + logger.LogInformation("Apprenticeship paused date is missing for apprenticeship {ApprenticeshipId}.", apprenticeship.Id); + } + } +} \ No newline at end of file diff --git a/src/CommitmentsV2/SFA.DAS.CommitmentsV2.ExternalHandlers/LearningEvents/LearningResumedEvent.cs b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.ExternalHandlers/LearningEvents/LearningResumedEvent.cs new file mode 100644 index 000000000..04fa58adc --- /dev/null +++ b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.ExternalHandlers/LearningEvents/LearningResumedEvent.cs @@ -0,0 +1,13 @@ +using System; + +namespace SFA.DAS.Learning.Types; + +// Replace with SFA.DAS.Learning.Types NuGet package when LearningResumedEvent is published. + +public class LearningResumedEvent +{ + public Guid LearningKey { get; set; } + public long ApprenticeshipId { get; set; } + public DateTime Created { get; set; } + public DateTime ResumeDate { get; set; } +} \ No newline at end of file diff --git a/src/CommitmentsV2/SFA.DAS.CommitmentsV2.ExternalMessageHandlers.UnitTests/EventHandlers/LearningResumedEventHandlerTests.cs b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.ExternalMessageHandlers.UnitTests/EventHandlers/LearningResumedEventHandlerTests.cs new file mode 100644 index 000000000..5e565575e --- /dev/null +++ b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.ExternalMessageHandlers.UnitTests/EventHandlers/LearningResumedEventHandlerTests.cs @@ -0,0 +1,318 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using AutoFixture; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using NServiceBus; +using NUnit.Framework; +using SFA.DAS.CommitmentsV2.Configuration; +using SFA.DAS.CommitmentsV2.Data; +using SFA.DAS.CommitmentsV2.Domain.Exceptions; +using SFA.DAS.CommitmentsV2.ExternalHandlers.EventHandlers; +using SFA.DAS.CommitmentsV2.Messages.Commands; +using SFA.DAS.CommitmentsV2.Messages.Events; +using SFA.DAS.CommitmentsV2.Models; +using SFA.DAS.CommitmentsV2.Shared.Extensions; +using SFA.DAS.CommitmentsV2.Types; +using SFA.DAS.Encoding; +using SFA.DAS.Learning.Types; +using SFA.DAS.UnitOfWork.Context; + +namespace SFA.DAS.CommitmentsV2.ExternalMessageHandlers.UnitTests.EventHandlers; + +public class LearningResumedEventHandlerTests +{ + public LearningResumedEventHandlerTestsFixture _fixture; + + [SetUp] + public void Arrange() + { + _fixture = new LearningResumedEventHandlerTestsFixture(); + } + + [TearDown] + public void TearDown() => _fixture.Dispose(); + + [Test] + public async Task Handle_WhenLearningResumedEventReceived() + { + await _fixture.SetPauseDate(DateTime.UtcNow.AddMonths(1)).SetResumeDate(DateTime.UtcNow.AddMonths(2)).Handle(); + _fixture.VerifyLearnerResumed(); + _fixture.VerifyStoreLearnerHistoryCommandIsSent(); + _fixture.VerifyLearningResumedEventIsPublished(); + } + + [Test] + public async Task ThenThrowsDomainException_WhenResumeDateIsEarlierThanLearningStartDate() + { + var act = async () => await _fixture.SetStartDate(DateTime.UtcNow.AddMonths(4)).SetResumeDate(DateTime.UtcNow.AddMonths(3)).Handle(); + await act.Should().ThrowAsync().Where(ex => ex.DomainErrors.First().ErrorMessage.Contains("Learner not started")); + _fixture.VerifyLearningResumedEventIsNotPublished(); + } + + [Test] + public async Task ThenThrowsDomainException_WhenResumeDateIsOnLearningStartDate() + { + var act = async () => await _fixture.SetStartDate(DateTime.UtcNow.AddMonths(3)).SetResumeDate(DateTime.UtcNow.AddMonths(3)).Handle(); + await act.Should().ThrowAsync().Where(ex => ex.DomainErrors.First().ErrorMessage.Contains("Learner not started")); + _fixture.VerifyLearningResumedEventIsNotPublished(); + } + + [Test] + public async Task ThenThrowsDomainException_WhenApprenticeshipNotfound() + { + var act = async () => await _fixture.SetEventApprenticeshipId(_fixture.fixture.Create()).Handle(); + await act.Should().ThrowAsync().Where(ex => ex.DomainErrors.First().ErrorMessage.Contains("not found")); + _fixture.VerifyLearningResumedEventIsNotPublished(); + } + + [Test] + public async Task ThenThrowsDomainException_WhenResumeDateIsAfterLearningEndDate() + { + var act = async () => await _fixture.SetEndDate(DateTime.UtcNow.AddMonths(5)).SetResumeDate(DateTime.UtcNow.AddMonths(6)).Handle(); + await act.Should().ThrowAsync().Where(ex => ex.DomainErrors.First().ErrorMessage.Contains("Resume date cannot be on or after the end date")); + _fixture.VerifyLearningResumedEventIsNotPublished(); + } + + [Test] + public async Task ThenThrowsDomainException_WhenResumeDateIsOnLearningEndDate() + { + var act = async () => await _fixture.SetEndDate(DateTime.UtcNow.AddMonths(3)).SetResumeDate(DateTime.UtcNow.AddMonths(3)).Handle(); + await act.Should().ThrowAsync().Where(ex => ex.DomainErrors.First().ErrorMessage.Contains("Resume date cannot be on or after the end date")); + _fixture.VerifyLearningResumedEventIsNotPublished(); + } + + [Test] + public async Task ThenThrowsDomainException_WhenResumeDateIsAfterPauseDate() + { + var act = async () => await _fixture.SetPauseDate(DateTime.UtcNow.AddMonths(5)).SetResumeDate(DateTime.UtcNow.AddMonths(4)).Handle(); + await act.Should().ThrowAsync().Where(ex => ex.DomainErrors.First().ErrorMessage.Contains("Resume date cannot be before the pausedate")); + _fixture.VerifyLearningResumedEventIsNotPublished(); + } + + [Test] + public async Task Handle_WhenLearningResumedEventReceivedOnPauseDate() + { + await _fixture.SetPauseDate(DateTime.UtcNow.AddMonths(2)).SetResumeDate(DateTime.UtcNow.AddMonths(2)).Handle(); + _fixture.VerifyLearnerResumed(); + _fixture.VerifyStoreLearnerHistoryCommandIsSent(); + _fixture.VerifyLearningResumedEventIsPublished(); + } + + [Test] + public async Task Handle_WhenLearningResumedEventReceivedAndNoPauseDate() + { + await _fixture.SetResumeDate(DateTime.UtcNow.AddMonths(2)).Handle(); + _fixture.VerifyLearnerResumed(); + _fixture.VerifyStoreLearnerHistoryCommandIsSent(); + _fixture.VerifyLearningResumedEventIsPublished(); + _fixture.VerifyLoggerLoggedInformation($"Apprenticeship paused date is missing for apprenticeship"); + } + + [Test] + [TestCase(PaymentStatus.Withdrawn)] + [TestCase(PaymentStatus.Completed)] + public async Task ThenThrowsDomainException_WhenStatusIsWithdrawn(PaymentStatus status) + { + var act = async () => await _fixture.SetPaymentStatus(status).Handle(); + await act.Should().ThrowAsync().Where(ex => ex.DomainErrors.First().ErrorMessage.Contains("Learning cannot be Resumed if Payment Status is Completed or Withdrawn")); + _fixture.VerifyLearningResumedEventIsNotPublished(); + } + + [Test] + public async Task ThenLogstheInformation_WhenStatusIsActive() + { + await _fixture.SetPaymentStatus(PaymentStatus.Active).Handle(); + _fixture.VerifyLearningResumedEventIsNotPublished(); + _fixture.VerifyStoreLearningHistoryCommandIsNotSent(); + _fixture.VerifyLoggerLoggedInformation($"Apprenticeship {_fixture.apprenticeshipId} is already active and resumed."); + } + + [Test] + public async Task ThenLogstheInformation_WhenEventIsNull() + { + await _fixture.SetEventAsNull().Handle(); + _fixture.VerifyLoggerLoggedInformation($"Event received null message : {nameof(LearningResumedEvent)}"); + } +} + +public class LearningResumedEventHandlerTestsFixture +{ + public Fixture fixture { get; set; } + private ProviderCommitmentsDbContext _dbContext; + private Mock> _mockLogger; + private Mock _mockContext; + private LearningResumedEventHandler _handler; + private LearningResumedEvent _event; + private Mock _mockEncodingService; + private CommitmentsV2Configuration _commitmentsV2Configuration; + public UnitOfWorkContext UnitOfWorkContext { get; set; } + public long apprenticeshipId { get; set; } + + public LearningResumedEventHandlerTestsFixture() + { + fixture = new Fixture(); + _mockLogger = new Mock>(); + _mockContext = new Mock(); + _mockEncodingService = new Mock(); + _mockEncodingService.Setup(x => x.Encode(It.IsAny(), EncodingType.ApprenticeshipId)).Returns("APP123"); + _mockEncodingService.Setup(x => x.Encode(It.IsAny(), EncodingType.AccountId)).Returns("ACC123"); + + UnitOfWorkContext = new UnitOfWorkContext(); + + _event = fixture.Create(); + + _dbContext = new ProviderCommitmentsDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString(), b => b.EnableNullChecks(false)) + .Options); + + apprenticeshipId = fixture.Create(); + _event.ApprenticeshipId = apprenticeshipId; + + var provider = new Provider() + { + UkPrn = 12345, + Name = "Test Provider" + }; + + var cohort = new Cohort + { + Id = fixture.Create(), + WithParty = Party.Provider, + Reference = fixture.Create(), + Provider = provider, + EmployerAccountId = 101 + }; + + var Apprenticeship = new Apprenticeship + { + Id = apprenticeshipId, + HasLearnerDataChanges = false, + FirstName = "Test", + LastName = "User", + DateOfBirth = DateTime.UtcNow.AddYears(-20), + Uln = fixture.Create().ToString(), + Cohort = cohort, + StartDate = DateTime.UtcNow.AddMonths(1), + EndDate = DateTime.UtcNow.AddMonths(13) + }; + + _dbContext.Cohorts.Add(cohort); + _dbContext.Apprenticeships.Add(Apprenticeship); + _dbContext.SaveChanges(); + + _handler = new LearningResumedEventHandler(new Lazy(() => _dbContext), + _mockLogger.Object); + } + + public LearningResumedEventHandlerTestsFixture SetStartDate(DateTime startDate) + { + var apprenticeship = _dbContext.Apprenticeships.Find(apprenticeshipId); + apprenticeship.StartDate = startDate; + _dbContext.SaveChanges(); + return this; + } + + public LearningResumedEventHandlerTestsFixture SetEndDate(DateTime endDate) + { + var apprenticeship = _dbContext.Apprenticeships.Find(apprenticeshipId); + apprenticeship.EndDate = endDate; + _dbContext.SaveChanges(); + return this; + } + + public LearningResumedEventHandlerTestsFixture SetPauseDate(DateTime pauseDate) + { + var apprenticeship = _dbContext.Apprenticeships.Find(apprenticeshipId); + apprenticeship.PauseDate = pauseDate; + _dbContext.SaveChanges(); + return this; + } + + public LearningResumedEventHandlerTestsFixture SetResumeDate(DateTime resumeDate) + { + _event.ResumeDate = resumeDate; + return this; + } + + public LearningResumedEventHandlerTestsFixture SetPaymentStatus(PaymentStatus status) + { + var apprenticeship = _dbContext.Apprenticeships.Find(apprenticeshipId); + apprenticeship.PaymentStatus = status; + _dbContext.SaveChanges(); + return this; + } + + public LearningResumedEventHandlerTestsFixture SetEventApprenticeshipId(long id) + { + _event.ApprenticeshipId = id; + return this; + } + + public LearningResumedEventHandlerTestsFixture SetEventAsNull() + { + _event = null; + return this; + } + + public async Task Handle() + { + await _handler.Handle(_event, _mockContext.Object); + } + + public void VerifyStoreLearnerHistoryCommandIsSent() + { + _mockContext.Verify(x => x.Send(It.Is(c => + c.ApprenticeshipId == _event.ApprenticeshipId && + c.Source == Types.LearningSourceType.ILRStatusChange && + c.ChangeType == Types.LearningChangeType.AutoApproved && + c.LearningKey == _event.LearningKey && + c.AppliedDate == _event.Created && + c.Description == $"Learning has been resumed on {_event.ResumeDate.ToGdsFormat()}" + ), It.IsAny()), Times.Once); + } + + public void VerifyLearningResumedEventIsPublished() + { + _ = _dbContext.Apprenticeships.Find(_event.ApprenticeshipId); + var resumedEvent = UnitOfWorkContext.GetEvents().OfType().First(); + resumedEvent.Should().NotBeNull(); + resumedEvent.ResumedOn.Date.Should().Be(_event.ResumeDate.Date); + resumedEvent.ApprenticeshipId.Should().Be(_event.ApprenticeshipId); + resumedEvent.ResumedViaILR.Should().BeTrue(); + } + + public void VerifyLearningResumedEventIsNotPublished() + { + var resumedEvent = UnitOfWorkContext.GetEvents().OfType().FirstOrDefault(); + resumedEvent.Should().BeNull(); + } + + public void VerifyStoreLearningHistoryCommandIsNotSent() + { + _mockContext.Verify(x => x.Send(It.IsAny(), It.IsAny()), Times.Never); + } + + public void VerifyLearnerResumed() + { + var updatedApprenticeship = _dbContext.Apprenticeships.Find(apprenticeshipId); + updatedApprenticeship.Should().NotBeNull(); + updatedApprenticeship.PaymentStatus.Should().Be(PaymentStatus.Active); + updatedApprenticeship.PauseDate.Should().BeNull(); + } + + public void VerifyLoggerLoggedInformation(string message) + { + _mockLogger.Verify(x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains(message)), + null, + It.IsAny>()), Times.AtLeastOnce); + } + + public void Dispose() => _dbContext?.Dispose(); +} \ No newline at end of file diff --git a/src/CommitmentsV2/SFA.DAS.CommitmentsV2.Messages/Events/ApprenticeshipResumedEvent.cs b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.Messages/Events/ApprenticeshipResumedEvent.cs index 29d31128b..cb3f568ac 100644 --- a/src/CommitmentsV2/SFA.DAS.CommitmentsV2.Messages/Events/ApprenticeshipResumedEvent.cs +++ b/src/CommitmentsV2/SFA.DAS.CommitmentsV2.Messages/Events/ApprenticeshipResumedEvent.cs @@ -6,5 +6,6 @@ public class ApprenticeshipResumedEvent { public long ApprenticeshipId { get; set; } public DateTime ResumedOn { get; set; } + public bool ResumedViaILR { get; set; } = false; } } diff --git a/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Models/Apprenticeship.cs b/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Models/Apprenticeship.cs index 27db2ed17..e474fa088 100644 --- a/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Models/Apprenticeship.cs +++ b/src/CommitmentsV2/SFA.DAS.CommitmentsV2/Models/Apprenticeship.cs @@ -1052,4 +1052,24 @@ public void SetIlrPaused(DateTime pausedDate) PausedViaILR = true }); } + + public void SetIlrResumed(DateTime resumedDate) + { + StartTrackingSession(UserAction.ResumeApprenticeship, Party.None, Cohort.EmployerAccountId, Cohort.ProviderId, null); + + ChangeTrackingSession.TrackUpdate(this); + + PaymentStatus = PaymentStatus.Active; + PauseDate = null; + ApprenticeshipStatus = ApprenticeshipStatus.Live; + + ChangeTrackingSession.CompleteTrackingSession(); + + Publish(() => new ApprenticeshipResumedEvent + { + ApprenticeshipId = Id, + ResumedOn = resumedDate, + ResumedViaILR = true + }); + } } \ No newline at end of file