using System.Text.Json; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Moq; using TrueCV.Application.Interfaces; using TrueCV.Application.Models; using TrueCV.Domain.Entities; using TrueCV.Domain.Enums; using TrueCV.Infrastructure.Data; using TrueCV.Infrastructure.Jobs; namespace TrueCV.Tests.Jobs; public sealed class ProcessCVCheckJobTests : IDisposable { private readonly ApplicationDbContext _dbContext; private readonly Mock _fileStorageServiceMock; private readonly Mock _cvParserServiceMock; private readonly Mock _companyVerifierServiceMock; private readonly Mock _educationVerifierServiceMock; private readonly Mock _timelineAnalyserServiceMock; private readonly Mock _auditServiceMock; private readonly Mock> _loggerMock; private readonly ProcessCVCheckJob _sut; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; public ProcessCVCheckJobTests() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; _dbContext = new ApplicationDbContext(options); _fileStorageServiceMock = new Mock(); _cvParserServiceMock = new Mock(); _companyVerifierServiceMock = new Mock(); _educationVerifierServiceMock = new Mock(); _timelineAnalyserServiceMock = new Mock(); _auditServiceMock = new Mock(); _loggerMock = new Mock>(); _sut = new ProcessCVCheckJob( _dbContext, _fileStorageServiceMock.Object, _cvParserServiceMock.Object, _companyVerifierServiceMock.Object, _educationVerifierServiceMock.Object, _timelineAnalyserServiceMock.Object, _auditServiceMock.Object, _loggerMock.Object); } public void Dispose() { _dbContext.Dispose(); } #region Status Updates [Fact] public async Task ExecuteAsync_UpdatesStatusToProcessingAtStart() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); SetupDefaultMocks(); // Create a flag to capture status change before completion CheckStatus? capturedStatusDuringProcessing = null; _fileStorageServiceMock .Setup(x => x.DownloadAsync(It.IsAny())) .ReturnsAsync(() => { // Capture the status when download is called (after status update to Processing) var check = _dbContext.CVChecks.AsNoTracking().First(c => c.Id == cvCheck.Id); capturedStatusDuringProcessing = check.Status; return new MemoryStream(); }); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert capturedStatusDuringProcessing.Should().Be(CheckStatus.Processing); } [Fact] public async Task ExecuteAsync_UpdatesStatusToCompletedWhenDone() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); SetupDefaultMocks(); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert _dbContext.ChangeTracker.Clear(); var updatedCheck = await _dbContext.CVChecks.FirstAsync(c => c.Id == cvCheck.Id); updatedCheck.Status.Should().Be(CheckStatus.Completed); updatedCheck.CompletedAt.Should().NotBeNull(); updatedCheck.CompletedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); } [Fact] public async Task ExecuteAsync_UpdatesStatusToFailedOnError() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); _fileStorageServiceMock .Setup(x => x.DownloadAsync(It.IsAny())) .ThrowsAsync(new InvalidOperationException("Storage error")); // Act var act = () => _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert await act.Should().ThrowAsync(); _dbContext.ChangeTracker.Clear(); var updatedCheck = await _dbContext.CVChecks.FirstAsync(c => c.Id == cvCheck.Id); updatedCheck.Status.Should().Be(CheckStatus.Failed); } #endregion #region File Operations [Fact] public async Task ExecuteAsync_DownloadsFileFromBlobStorage() { // Arrange var expectedBlobUrl = "https://storage.blob.core.windows.net/cvs/test-cv.pdf"; var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending, blobUrl: expectedBlobUrl); SetupDefaultMocks(); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert _fileStorageServiceMock.Verify( x => x.DownloadAsync(expectedBlobUrl), Times.Once); } #endregion #region CV Parsing [Fact] public async Task ExecuteAsync_ParsesCVAndSavesExtractedData() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending, fileName: "resume.pdf"); var expectedCVData = CreateTestCVData(); SetupDefaultMocks(cvData: expectedCVData); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert _cvParserServiceMock.Verify( x => x.ParseAsync(It.IsAny(), "resume.pdf", It.IsAny()), Times.Once); _dbContext.ChangeTracker.Clear(); var updatedCheck = await _dbContext.CVChecks.FirstAsync(c => c.Id == cvCheck.Id); updatedCheck.ExtractedDataJson.Should().NotBeNullOrEmpty(); var deserializedData = JsonSerializer.Deserialize(updatedCheck.ExtractedDataJson!, JsonOptions); deserializedData.Should().NotBeNull(); deserializedData!.FullName.Should().Be(expectedCVData.FullName); } #endregion #region Employment Verification [Fact] public async Task ExecuteAsync_VerifiesEachEmploymentEntry() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); var cvData = CreateTestCVData(employmentCount: 3); SetupDefaultMocks(cvData: cvData); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert _companyVerifierServiceMock.Verify( x => x.VerifyCompanyAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(3)); } #endregion #region Timeline Analysis [Fact] public async Task ExecuteAsync_AnalysesTimeline() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); var cvData = CreateTestCVData(); SetupDefaultMocks(cvData: cvData); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert _timelineAnalyserServiceMock.Verify( x => x.Analyse(It.Is>(list => list.Count == cvData.Employment.Count)), Times.Once); } #endregion #region Score Calculation - All Verified Companies [Fact] public async Task ExecuteAsync_CalculatesCorrectScore_WithAllVerifiedCompanies() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); var cvData = CreateTestCVData(employmentCount: 2); var verificationResults = cvData.Employment.Select(e => new CompanyVerificationResult { ClaimedCompany = e.CompanyName, IsVerified = true, MatchScore = 100, MatchedCompanyName = e.CompanyName }).ToList(); SetupDefaultMocks( cvData: cvData, verificationResults: verificationResults, timelineResult: CreateEmptyTimelineResult()); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert _dbContext.ChangeTracker.Clear(); var updatedCheck = await _dbContext.CVChecks.FirstAsync(c => c.Id == cvCheck.Id); updatedCheck.VeracityScore.Should().Be(100); } #endregion #region Score Calculation - Unverified Companies [Fact] public async Task ExecuteAsync_CalculatesCorrectScore_WithUnverifiedCompanies() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); var cvData = CreateTestCVData(employmentCount: 2); var verificationResults = new List { new() { ClaimedCompany = cvData.Employment[0].CompanyName, IsVerified = true, MatchScore = 100, MatchedCompanyName = cvData.Employment[0].CompanyName }, new() { ClaimedCompany = cvData.Employment[1].CompanyName, IsVerified = false, MatchScore = 0, VerificationNotes = "Company not found" } }; SetupDefaultMocks( cvData: cvData, verificationResults: verificationResults, timelineResult: CreateEmptyTimelineResult()); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert - Base 100 - 10 for unverified = 90 _dbContext.ChangeTracker.Clear(); var updatedCheck = await _dbContext.CVChecks.FirstAsync(c => c.Id == cvCheck.Id); updatedCheck.VeracityScore.Should().Be(90); } [Fact] public async Task ExecuteAsync_CalculatesCorrectScore_WithMultipleUnverifiedCompanies() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); var cvData = CreateTestCVData(employmentCount: 3); var verificationResults = cvData.Employment.Select(e => new CompanyVerificationResult { ClaimedCompany = e.CompanyName, IsVerified = false, MatchScore = 0, VerificationNotes = "Company not found" }).ToList(); SetupDefaultMocks( cvData: cvData, verificationResults: verificationResults, timelineResult: CreateEmptyTimelineResult()); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert - Base 100 - (3 * 10) = 70 _dbContext.ChangeTracker.Clear(); var updatedCheck = await _dbContext.CVChecks.FirstAsync(c => c.Id == cvCheck.Id); updatedCheck.VeracityScore.Should().Be(70); } #endregion #region Score Calculation - Gaps [Fact] public async Task ExecuteAsync_CalculatesCorrectScore_WithGaps() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); var cvData = CreateTestCVData(employmentCount: 1); var timelineResult = new TimelineAnalysisResult { TotalGapMonths = 5, TotalOverlapMonths = 0, Gaps = [ new TimelineGap { StartDate = new DateOnly(2023, 1, 1), EndDate = new DateOnly(2023, 6, 1), Months = 5 } ], Overlaps = [] }; SetupDefaultMocks( cvData: cvData, timelineResult: timelineResult); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert - Base 100 - (5 * 1) = 95 (5 month gap at 1 per month) _dbContext.ChangeTracker.Clear(); var updatedCheck = await _dbContext.CVChecks.FirstAsync(c => c.Id == cvCheck.Id); updatedCheck.VeracityScore.Should().Be(95); } [Fact] public async Task ExecuteAsync_CalculatesCorrectScore_WithGaps_MaxPenaltyOf10() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); var cvData = CreateTestCVData(employmentCount: 1); var timelineResult = new TimelineAnalysisResult { TotalGapMonths = 15, TotalOverlapMonths = 0, Gaps = [ new TimelineGap { StartDate = new DateOnly(2022, 1, 1), EndDate = new DateOnly(2023, 4, 1), Months = 15 // 15 months gap, but max penalty is 10 } ], Overlaps = [] }; SetupDefaultMocks( cvData: cvData, timelineResult: timelineResult); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert - Base 100 - min(15, 10) = 90 (max gap penalty is 10) _dbContext.ChangeTracker.Clear(); var updatedCheck = await _dbContext.CVChecks.FirstAsync(c => c.Id == cvCheck.Id); updatedCheck.VeracityScore.Should().Be(90); } [Fact] public async Task ExecuteAsync_CalculatesCorrectScore_WithMultipleGaps() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); var cvData = CreateTestCVData(employmentCount: 1); var timelineResult = new TimelineAnalysisResult { TotalGapMonths = 8, TotalOverlapMonths = 0, Gaps = [ new TimelineGap { StartDate = new DateOnly(2022, 1, 1), EndDate = new DateOnly(2022, 4, 1), Months = 3 }, new TimelineGap { StartDate = new DateOnly(2023, 1, 1), EndDate = new DateOnly(2023, 6, 1), Months = 5 } ], Overlaps = [] }; SetupDefaultMocks( cvData: cvData, timelineResult: timelineResult); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert - Base 100 - min(3,10) - min(5,10) = 100 - 3 - 5 = 92 _dbContext.ChangeTracker.Clear(); var updatedCheck = await _dbContext.CVChecks.FirstAsync(c => c.Id == cvCheck.Id); updatedCheck.VeracityScore.Should().Be(92); } #endregion #region Score Calculation - Overlaps [Fact] public async Task ExecuteAsync_CalculatesCorrectScore_WithOverlaps_NoDeductionIfLessThan2Months() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); var cvData = CreateTestCVData(employmentCount: 2); var timelineResult = new TimelineAnalysisResult { TotalGapMonths = 0, TotalOverlapMonths = 2, Gaps = [], Overlaps = [ new TimelineOverlap { Company1 = "Company A", Company2 = "Company B", OverlapStart = new DateOnly(2023, 1, 1), OverlapEnd = new DateOnly(2023, 3, 1), Months = 2 // Exactly 2 months - no penalty (transition period) } ] }; SetupDefaultMocks( cvData: cvData, timelineResult: timelineResult); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert - Base 100 - (2 - 2) * 2 = 100 (2 month transition allowed) _dbContext.ChangeTracker.Clear(); var updatedCheck = await _dbContext.CVChecks.FirstAsync(c => c.Id == cvCheck.Id); updatedCheck.VeracityScore.Should().Be(100); } [Fact] public async Task ExecuteAsync_CalculatesCorrectScore_WithOverlaps_DeductionIfMoreThan2Months() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); var cvData = CreateTestCVData(employmentCount: 2); var timelineResult = new TimelineAnalysisResult { TotalGapMonths = 0, TotalOverlapMonths = 5, Gaps = [], Overlaps = [ new TimelineOverlap { Company1 = "Company A", Company2 = "Company B", OverlapStart = new DateOnly(2023, 1, 1), OverlapEnd = new DateOnly(2023, 6, 1), Months = 5 } ] }; SetupDefaultMocks( cvData: cvData, timelineResult: timelineResult); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert - Base 100 - (5 - 2) * 2 = 100 - 6 = 94 _dbContext.ChangeTracker.Clear(); var updatedCheck = await _dbContext.CVChecks.FirstAsync(c => c.Id == cvCheck.Id); updatedCheck.VeracityScore.Should().Be(94); } [Fact] public async Task ExecuteAsync_CalculatesCorrectScore_WithMultipleOverlaps() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); var cvData = CreateTestCVData(employmentCount: 3); var timelineResult = new TimelineAnalysisResult { TotalGapMonths = 0, TotalOverlapMonths = 10, Gaps = [], Overlaps = [ new TimelineOverlap { Company1 = "Company A", Company2 = "Company B", OverlapStart = new DateOnly(2023, 1, 1), OverlapEnd = new DateOnly(2023, 6, 1), Months = 5 // (5-2)*2 = 6 penalty }, new TimelineOverlap { Company1 = "Company B", Company2 = "Company C", OverlapStart = new DateOnly(2024, 1, 1), OverlapEnd = new DateOnly(2024, 6, 1), Months = 5 // (5-2)*2 = 6 penalty } ] }; SetupDefaultMocks( cvData: cvData, timelineResult: timelineResult); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert - Base 100 - 6 - 6 = 88 _dbContext.ChangeTracker.Clear(); var updatedCheck = await _dbContext.CVChecks.FirstAsync(c => c.Id == cvCheck.Id); updatedCheck.VeracityScore.Should().Be(88); } #endregion #region CVFlag Records [Fact] public async Task ExecuteAsync_CreatesCVFlagRecordsForUnverifiedCompanies() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); var cvData = CreateTestCVData(employmentCount: 1); var verificationResults = new List { new() { ClaimedCompany = "Fake Corp", IsVerified = false, MatchScore = 0, VerificationNotes = "Company not found in registry" } }; SetupDefaultMocks( cvData: cvData, verificationResults: verificationResults, timelineResult: CreateEmptyTimelineResult()); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert _dbContext.ChangeTracker.Clear(); var flags = await _dbContext.CVFlags.Where(f => f.CVCheckId == cvCheck.Id).ToListAsync(); flags.Should().HaveCount(1); flags[0].Category.Should().Be(FlagCategory.Employment); flags[0].Severity.Should().Be(FlagSeverity.Warning); flags[0].Title.Should().Be("Unverified Company"); flags[0].ScoreImpact.Should().Be(-10); } [Fact] public async Task ExecuteAsync_CreatesCVFlagRecordsForGaps() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); var cvData = CreateTestCVData(employmentCount: 1); var timelineResult = new TimelineAnalysisResult { TotalGapMonths = 3, TotalOverlapMonths = 0, Gaps = [ new TimelineGap { StartDate = new DateOnly(2023, 1, 1), EndDate = new DateOnly(2023, 4, 1), Months = 3 } ], Overlaps = [] }; SetupDefaultMocks( cvData: cvData, timelineResult: timelineResult); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert _dbContext.ChangeTracker.Clear(); var flags = await _dbContext.CVFlags.Where(f => f.CVCheckId == cvCheck.Id).ToListAsync(); flags.Should().HaveCount(1); flags[0].Category.Should().Be(FlagCategory.Timeline); flags[0].Severity.Should().Be(FlagSeverity.Info); // Less than 6 months flags[0].Title.Should().Be("Employment Gap"); flags[0].ScoreImpact.Should().Be(-3); } [Fact] public async Task ExecuteAsync_CreatesCVFlagRecordsForGaps_WarningSeverityFor6PlusMonths() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); var cvData = CreateTestCVData(employmentCount: 1); var timelineResult = new TimelineAnalysisResult { TotalGapMonths = 8, TotalOverlapMonths = 0, Gaps = [ new TimelineGap { StartDate = new DateOnly(2023, 1, 1), EndDate = new DateOnly(2023, 9, 1), Months = 8 } ], Overlaps = [] }; SetupDefaultMocks( cvData: cvData, timelineResult: timelineResult); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert _dbContext.ChangeTracker.Clear(); var flags = await _dbContext.CVFlags.Where(f => f.CVCheckId == cvCheck.Id).ToListAsync(); flags.Should().HaveCount(1); flags[0].Severity.Should().Be(FlagSeverity.Warning); // 6+ months } [Fact] public async Task ExecuteAsync_CreatesCVFlagRecordsForOverlaps() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); var cvData = CreateTestCVData(employmentCount: 2); var timelineResult = new TimelineAnalysisResult { TotalGapMonths = 0, TotalOverlapMonths = 4, Gaps = [], Overlaps = [ new TimelineOverlap { Company1 = "Company A", Company2 = "Company B", OverlapStart = new DateOnly(2023, 1, 1), OverlapEnd = new DateOnly(2023, 5, 1), Months = 4 } ] }; SetupDefaultMocks( cvData: cvData, timelineResult: timelineResult); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert _dbContext.ChangeTracker.Clear(); var flags = await _dbContext.CVFlags.Where(f => f.CVCheckId == cvCheck.Id).ToListAsync(); flags.Should().HaveCount(1); flags[0].Category.Should().Be(FlagCategory.Timeline); flags[0].Severity.Should().Be(FlagSeverity.Warning); // Less than 6 months flags[0].Title.Should().Be("Employment Overlap"); flags[0].ScoreImpact.Should().Be(-4); // (4-2)*2 = 4 } [Fact] public async Task ExecuteAsync_CreatesCVFlagRecordsForOverlaps_CriticalSeverityFor6PlusMonths() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); var cvData = CreateTestCVData(employmentCount: 2); var timelineResult = new TimelineAnalysisResult { TotalGapMonths = 0, TotalOverlapMonths = 8, Gaps = [], Overlaps = [ new TimelineOverlap { Company1 = "Company A", Company2 = "Company B", OverlapStart = new DateOnly(2023, 1, 1), OverlapEnd = new DateOnly(2023, 9, 1), Months = 8 } ] }; SetupDefaultMocks( cvData: cvData, timelineResult: timelineResult); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert _dbContext.ChangeTracker.Clear(); var flags = await _dbContext.CVFlags.Where(f => f.CVCheckId == cvCheck.Id).ToListAsync(); flags.Should().HaveCount(1); flags[0].Severity.Should().Be(FlagSeverity.Critical); // 6+ months } [Fact] public async Task ExecuteAsync_CreatesAppropriateCVFlagRecords_MultipleIssues() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); var cvData = CreateTestCVData(employmentCount: 2); var verificationResults = new List { new() { ClaimedCompany = "Verified Corp", IsVerified = true, MatchScore = 100 }, new() { ClaimedCompany = "Unverified Inc", IsVerified = false, MatchScore = 0, VerificationNotes = "Not found" } }; var timelineResult = new TimelineAnalysisResult { TotalGapMonths = 3, TotalOverlapMonths = 0, Gaps = [ new TimelineGap { StartDate = new DateOnly(2023, 1, 1), EndDate = new DateOnly(2023, 4, 1), Months = 3 } ], Overlaps = [] }; SetupDefaultMocks( cvData: cvData, verificationResults: verificationResults, timelineResult: timelineResult); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert _dbContext.ChangeTracker.Clear(); var flags = await _dbContext.CVFlags.Where(f => f.CVCheckId == cvCheck.Id).ToListAsync(); flags.Should().HaveCount(2); // 1 unverified company + 1 gap flags.Should().Contain(f => f.Title == "Unverified Company"); flags.Should().Contain(f => f.Title == "Employment Gap"); } #endregion #region Non-Existent Check [Fact] public async Task ExecuteAsync_WithNonExistentCheckId_HandlesGracefully() { // Arrange var nonExistentId = Guid.NewGuid(); // Act await _sut.ExecuteAsync(nonExistentId, CancellationToken.None); // Assert - should complete without exception _fileStorageServiceMock.Verify( x => x.DownloadAsync(It.IsAny()), Times.Never); _cvParserServiceMock.Verify( x => x.ParseAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } #endregion #region Score Floor [Fact] public async Task ExecuteAsync_ScoreNeverGoesBelowZero() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); var cvData = CreateTestCVData(employmentCount: 15); // 15 unverified = -150 points var verificationResults = cvData.Employment.Select(e => new CompanyVerificationResult { ClaimedCompany = e.CompanyName, IsVerified = false, MatchScore = 0, VerificationNotes = "Not found" }).ToList(); var timelineResult = new TimelineAnalysisResult { TotalGapMonths = 50, // Large gaps TotalOverlapMonths = 20, Gaps = [ new TimelineGap { StartDate = new DateOnly(2020, 1, 1), EndDate = new DateOnly(2024, 3, 1), Months = 50 } ], Overlaps = [ new TimelineOverlap { Company1 = "A", Company2 = "B", OverlapStart = new DateOnly(2023, 1, 1), OverlapEnd = new DateOnly(2024, 9, 1), Months = 20 } ] }; SetupDefaultMocks( cvData: cvData, verificationResults: verificationResults, timelineResult: timelineResult); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert - Score should be 0, not negative // Penalties: 15 * 10 = 150 (unverified) + min(50,10) = 10 (gap) + (20-2)*2 = 36 (overlap) = 196 // 100 - 196 = -96, but should be clamped to 0 _dbContext.ChangeTracker.Clear(); var updatedCheck = await _dbContext.CVChecks.FirstAsync(c => c.Id == cvCheck.Id); updatedCheck.VeracityScore.Should().Be(0); } #endregion #region Veracity Report [Fact] public async Task ExecuteAsync_GeneratesVeracityReportJson() { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); var cvData = CreateTestCVData(employmentCount: 1); SetupDefaultMocks(cvData: cvData); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert _dbContext.ChangeTracker.Clear(); var updatedCheck = await _dbContext.CVChecks.FirstAsync(c => c.Id == cvCheck.Id); updatedCheck.ReportJson.Should().NotBeNullOrEmpty(); var report = JsonSerializer.Deserialize(updatedCheck.ReportJson!, JsonOptions); report.Should().NotBeNull(); report!.OverallScore.Should().Be(updatedCheck.VeracityScore); report.GeneratedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1)); } [Theory] [InlineData(0, "Excellent", 100)] // 0 unverified = 100 >= 90 = Excellent [InlineData(1, "Excellent", 90)] // 1 unverified = 90 >= 90 = Excellent [InlineData(2, "Good", 80)] // 2 unverified = 80 >= 75 = Good [InlineData(3, "Fair", 70)] // 3 unverified = 70 >= 60 = Fair (70 < 75) [InlineData(4, "Fair", 60)] // 4 unverified = 60 >= 60 = Fair [InlineData(5, "Poor", 50)] // 5 unverified = 50 >= 40 = Poor [InlineData(6, "Poor", 40)] // 6 unverified = 40 >= 40 = Poor [InlineData(7, "Very Poor", 30)] // 7 unverified = 30 < 40 = Very Poor [InlineData(10, "Very Poor", 0)] // 10 unverified = 0 < 40 = Very Poor public async Task ExecuteAsync_GeneratesCorrectScoreLabel(int unverifiedCount, string expectedLabel, int expectedScore) { // Arrange var cvCheck = await CreateCVCheckInDatabase(CheckStatus.Pending); // Use exact number of unverified companies to get predictable score var employmentCount = Math.Max(1, unverifiedCount); var cvData = CreateTestCVData(employmentCount: employmentCount); var verificationResults = cvData.Employment.Select((e, i) => new CompanyVerificationResult { ClaimedCompany = e.CompanyName, IsVerified = i >= unverifiedCount, // First N are unverified MatchScore = i >= unverifiedCount ? 100 : 0, VerificationNotes = i >= unverifiedCount ? null : "Not found" }).ToList(); SetupDefaultMocks( cvData: cvData, verificationResults: verificationResults, timelineResult: CreateEmptyTimelineResult()); // Act await _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None); // Assert _dbContext.ChangeTracker.Clear(); var updatedCheck = await _dbContext.CVChecks.FirstAsync(c => c.Id == cvCheck.Id); updatedCheck.VeracityScore.Should().Be(expectedScore); var report = JsonSerializer.Deserialize(updatedCheck.ReportJson!, JsonOptions); report!.ScoreLabel.Should().Be(expectedLabel); } #endregion #region Helper Methods private async Task CreateCVCheckInDatabase( CheckStatus status = CheckStatus.Pending, string blobUrl = "https://storage.blob.core.windows.net/cvs/test.pdf", string fileName = "test.pdf") { var cvCheck = new CVCheck { Id = Guid.NewGuid(), UserId = Guid.NewGuid(), OriginalFileName = fileName, BlobUrl = blobUrl, Status = status }; _dbContext.CVChecks.Add(cvCheck); await _dbContext.SaveChangesAsync(); _dbContext.ChangeTracker.Clear(); return cvCheck; } private void SetupDefaultMocks( CVData? cvData = null, List? verificationResults = null, List? educationResults = null, TimelineAnalysisResult? timelineResult = null) { cvData ??= CreateTestCVData(); timelineResult ??= CreateEmptyTimelineResult(); _fileStorageServiceMock .Setup(x => x.DownloadAsync(It.IsAny())) .ReturnsAsync(new MemoryStream()); _cvParserServiceMock .Setup(x => x.ParseAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(cvData); if (verificationResults != null) { var queue = new Queue(verificationResults); _companyVerifierServiceMock .Setup(x => x.VerifyCompanyAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(() => queue.Count > 0 ? queue.Dequeue() : CreateDefaultVerificationResult()); } else { _companyVerifierServiceMock .Setup(x => x.VerifyCompanyAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(CreateDefaultVerificationResult()); } _educationVerifierServiceMock .Setup(x => x.VerifyAll( It.IsAny>(), It.IsAny?>())) .Returns(educationResults ?? []); _timelineAnalyserServiceMock .Setup(x => x.Analyse(It.IsAny>())) .Returns(timelineResult); } private static CVData CreateTestCVData(int employmentCount = 1) { var employment = Enumerable.Range(0, employmentCount) .Select(i => new EmploymentEntry { CompanyName = $"Company {i + 1}", JobTitle = $"Developer {i + 1}", StartDate = new DateOnly(2020 + i, 1, 1), EndDate = new DateOnly(2021 + i, 1, 1) }) .ToList(); return new CVData { FullName = "John Doe", Email = "john.doe@example.com", Employment = employment }; } private static TimelineAnalysisResult CreateEmptyTimelineResult() { return new TimelineAnalysisResult { TotalGapMonths = 0, TotalOverlapMonths = 0, Gaps = [], Overlaps = [] }; } private static CompanyVerificationResult CreateDefaultVerificationResult() { return new CompanyVerificationResult { ClaimedCompany = "Test Company", IsVerified = true, MatchScore = 100, MatchedCompanyName = "Test Company Ltd" }; } #endregion }