Files
RealCV/tests/TrueCV.Tests/Jobs/ProcessCVCheckJobTests.cs
peter 89d1f7e33b Add comprehensive unit test suite
Test project with 143 tests covering:
- TimelineAnalyserService (27 tests): gap/overlap detection, edge cases
- CVParserService (35 tests): file parsing, extension handling, API calls
- CompanyVerifierService (23 tests): verification, caching, fuzzy matching
- CVCheckService (24 tests): CRUD operations, file upload, job queuing
- ProcessCVCheckJob (34 tests): full workflow, scoring algorithm, flags

Uses xUnit, Moq, FluentAssertions, EF Core InMemory

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 19:45:07 +01:00

1092 lines
35 KiB
C#

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<IFileStorageService> _fileStorageServiceMock;
private readonly Mock<ICVParserService> _cvParserServiceMock;
private readonly Mock<ICompanyVerifierService> _companyVerifierServiceMock;
private readonly Mock<ITimelineAnalyserService> _timelineAnalyserServiceMock;
private readonly Mock<ILogger<ProcessCVCheckJob>> _loggerMock;
private readonly ProcessCVCheckJob _sut;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public ProcessCVCheckJobTests()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_dbContext = new ApplicationDbContext(options);
_fileStorageServiceMock = new Mock<IFileStorageService>();
_cvParserServiceMock = new Mock<ICVParserService>();
_companyVerifierServiceMock = new Mock<ICompanyVerifierService>();
_timelineAnalyserServiceMock = new Mock<ITimelineAnalyserService>();
_loggerMock = new Mock<ILogger<ProcessCVCheckJob>>();
_sut = new ProcessCVCheckJob(
_dbContext,
_fileStorageServiceMock.Object,
_cvParserServiceMock.Object,
_companyVerifierServiceMock.Object,
_timelineAnalyserServiceMock.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<string>()))
.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<string>()))
.ThrowsAsync(new InvalidOperationException("Storage error"));
// Act
var act = () => _sut.ExecuteAsync(cvCheck.Id, CancellationToken.None);
// Assert
await act.Should().ThrowAsync<InvalidOperationException>();
_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<Stream>(), "resume.pdf"),
Times.Once);
_dbContext.ChangeTracker.Clear();
var updatedCheck = await _dbContext.CVChecks.FirstAsync(c => c.Id == cvCheck.Id);
updatedCheck.ExtractedDataJson.Should().NotBeNullOrEmpty();
var deserializedData = JsonSerializer.Deserialize<CVData>(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<string>(),
It.IsAny<DateOnly?>(),
It.IsAny<DateOnly?>()),
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<EmploymentEntry>>(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<CompanyVerificationResult>
{
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<CompanyVerificationResult>
{
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<CompanyVerificationResult>
{
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<string>()),
Times.Never);
_cvParserServiceMock.Verify(
x => x.ParseAsync(It.IsAny<Stream>(), It.IsAny<string>()),
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<VeracityReport>(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<VeracityReport>(updatedCheck.ReportJson!, JsonOptions);
report!.ScoreLabel.Should().Be(expectedLabel);
}
#endregion
#region Helper Methods
private async Task<CVCheck> 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<CompanyVerificationResult>? verificationResults = null,
TimelineAnalysisResult? timelineResult = null)
{
cvData ??= CreateTestCVData();
timelineResult ??= CreateEmptyTimelineResult();
_fileStorageServiceMock
.Setup(x => x.DownloadAsync(It.IsAny<string>()))
.ReturnsAsync(new MemoryStream());
_cvParserServiceMock
.Setup(x => x.ParseAsync(It.IsAny<Stream>(), It.IsAny<string>()))
.ReturnsAsync(cvData);
if (verificationResults != null)
{
var queue = new Queue<CompanyVerificationResult>(verificationResults);
_companyVerifierServiceMock
.Setup(x => x.VerifyCompanyAsync(
It.IsAny<string>(),
It.IsAny<DateOnly?>(),
It.IsAny<DateOnly?>()))
.ReturnsAsync(() => queue.Count > 0 ? queue.Dequeue() : CreateDefaultVerificationResult());
}
else
{
_companyVerifierServiceMock
.Setup(x => x.VerifyCompanyAsync(
It.IsAny<string>(),
It.IsAny<DateOnly?>(),
It.IsAny<DateOnly?>()))
.ReturnsAsync(CreateDefaultVerificationResult());
}
_timelineAnalyserServiceMock
.Setup(x => x.Analyse(It.IsAny<List<EmploymentEntry>>()))
.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
}