diff --git a/TrueCV.sln b/TrueCV.sln index 73dc0be..c28d864 100644 --- a/TrueCV.sln +++ b/TrueCV.sln @@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrueCV.Infrastructure", "sr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrueCV.Web", "src\TrueCV.Web\TrueCV.Web.csproj", "{D69F57DB-3092-48AF-81BB-868E3749C638}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{80890010-EDA6-418B-AD6C-5A9D875594C4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrueCV.Tests", "tests\TrueCV.Tests\TrueCV.Tests.csproj", "{4450D4F1-4EB9-445E-904B-1C57701493D8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -38,11 +42,16 @@ Global {D69F57DB-3092-48AF-81BB-868E3749C638}.Debug|Any CPU.Build.0 = Debug|Any CPU {D69F57DB-3092-48AF-81BB-868E3749C638}.Release|Any CPU.ActiveCfg = Release|Any CPU {D69F57DB-3092-48AF-81BB-868E3749C638}.Release|Any CPU.Build.0 = Release|Any CPU + {4450D4F1-4EB9-445E-904B-1C57701493D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4450D4F1-4EB9-445E-904B-1C57701493D8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4450D4F1-4EB9-445E-904B-1C57701493D8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4450D4F1-4EB9-445E-904B-1C57701493D8}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {41AC48AF-09BC-48D1-9CA4-1B05D3B693F0} = {F25C3740-9240-46DF-BC34-985BC577216B} {A8A1BA81-3B2F-4F95-BB15-ACA40DF2A70E} = {F25C3740-9240-46DF-BC34-985BC577216B} {03DB607C-9592-4930-8C89-3E257A319278} = {F25C3740-9240-46DF-BC34-985BC577216B} {D69F57DB-3092-48AF-81BB-868E3749C638} = {F25C3740-9240-46DF-BC34-985BC577216B} + {4450D4F1-4EB9-445E-904B-1C57701493D8} = {80890010-EDA6-418B-AD6C-5A9D875594C4} EndGlobalSection EndGlobal diff --git a/tests/TrueCV.Tests/GlobalUsings.cs b/tests/TrueCV.Tests/GlobalUsings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/tests/TrueCV.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/tests/TrueCV.Tests/Jobs/ProcessCVCheckJobTests.cs b/tests/TrueCV.Tests/Jobs/ProcessCVCheckJobTests.cs new file mode 100644 index 0000000..5f9150e --- /dev/null +++ b/tests/TrueCV.Tests/Jobs/ProcessCVCheckJobTests.cs @@ -0,0 +1,1091 @@ +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 _timelineAnalyserServiceMock; + 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(); + _timelineAnalyserServiceMock = new Mock(); + _loggerMock = new Mock>(); + + _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())) + .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"), + 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()), + 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()), + 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, + 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())) + .ReturnsAsync(cvData); + + if (verificationResults != null) + { + var queue = new Queue(verificationResults); + _companyVerifierServiceMock + .Setup(x => x.VerifyCompanyAsync( + 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())) + .ReturnsAsync(CreateDefaultVerificationResult()); + } + + _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 +} diff --git a/tests/TrueCV.Tests/Services/CVCheckServiceTests.cs b/tests/TrueCV.Tests/Services/CVCheckServiceTests.cs new file mode 100644 index 0000000..c05aa2a --- /dev/null +++ b/tests/TrueCV.Tests/Services/CVCheckServiceTests.cs @@ -0,0 +1,717 @@ +using System.Linq.Expressions; +using System.Text.Json; +using FluentAssertions; +using Hangfire; +using Hangfire.Common; +using Hangfire.States; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using TrueCV.Application.DTOs; +using TrueCV.Application.Interfaces; +using TrueCV.Application.Models; +using TrueCV.Domain.Entities; +using TrueCV.Domain.Enums; +using TrueCV.Infrastructure.Data; +using TrueCV.Infrastructure.Jobs; +using TrueCV.Infrastructure.Services; + +namespace TrueCV.Tests.Services; + +public sealed class CVCheckServiceTests : IDisposable +{ + private readonly ApplicationDbContext _dbContext; + private readonly Mock _fileStorageServiceMock; + private readonly Mock _backgroundJobClientMock; + private readonly Mock> _loggerMock; + private readonly CVCheckService _sut; + + public CVCheckServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + _dbContext = new ApplicationDbContext(options); + _fileStorageServiceMock = new Mock(); + _backgroundJobClientMock = new Mock(); + _loggerMock = new Mock>(); + + _sut = new CVCheckService( + _dbContext, + _fileStorageServiceMock.Object, + _backgroundJobClientMock.Object, + _loggerMock.Object); + } + + public void Dispose() + { + _dbContext.Dispose(); + } + + #region CreateCheckAsync Tests + + [Fact] + public async Task CreateCheckAsync_CreatesRecordWithCorrectData() + { + // Arrange + var userId = Guid.NewGuid(); + var fileName = "test-cv.pdf"; + var expectedBlobUrl = "https://storage.blob.core.windows.net/cvs/test-cv.pdf"; + + using var stream = new MemoryStream(); + _fileStorageServiceMock + .Setup(x => x.UploadAsync(It.IsAny(), fileName)) + .ReturnsAsync(expectedBlobUrl); + + // Act + var checkId = await _sut.CreateCheckAsync(userId, stream, fileName); + + // Assert + var savedCheck = await _dbContext.CVChecks.FirstOrDefaultAsync(c => c.Id == checkId); + + savedCheck.Should().NotBeNull(); + savedCheck!.UserId.Should().Be(userId); + savedCheck.OriginalFileName.Should().Be(fileName); + savedCheck.BlobUrl.Should().Be(expectedBlobUrl); + savedCheck.Status.Should().Be(CheckStatus.Pending); + savedCheck.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task CreateCheckAsync_UploadsFileToBlobStorage() + { + // Arrange + var userId = Guid.NewGuid(); + var fileName = "resume.docx"; + var expectedBlobUrl = "https://storage.blob.core.windows.net/cvs/resume.docx"; + + using var stream = new MemoryStream(new byte[] { 0x01, 0x02, 0x03 }); + _fileStorageServiceMock + .Setup(x => x.UploadAsync(stream, fileName)) + .ReturnsAsync(expectedBlobUrl); + + // Act + await _sut.CreateCheckAsync(userId, stream, fileName); + + // Assert + _fileStorageServiceMock.Verify( + x => x.UploadAsync(stream, fileName), + Times.Once); + } + + [Fact] + public async Task CreateCheckAsync_QueuesHangfireBackgroundJob() + { + // Arrange + var userId = Guid.NewGuid(); + var fileName = "cv.pdf"; + + using var stream = new MemoryStream(); + _fileStorageServiceMock + .Setup(x => x.UploadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("https://blob.url/cv.pdf"); + + // Act + var checkId = await _sut.CreateCheckAsync(userId, stream, fileName); + + // Assert + _backgroundJobClientMock.Verify( + x => x.Create( + It.Is(job => + job.Type == typeof(ProcessCVCheckJob) && + job.Method.Name == nameof(ProcessCVCheckJob.ExecuteAsync)), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task CreateCheckAsync_ReturnsNewCheckId() + { + // Arrange + var userId = Guid.NewGuid(); + var fileName = "my-cv.pdf"; + + using var stream = new MemoryStream(); + _fileStorageServiceMock + .Setup(x => x.UploadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("https://blob.url/my-cv.pdf"); + + // Act + var checkId = await _sut.CreateCheckAsync(userId, stream, fileName); + + // Assert + checkId.Should().NotBe(Guid.Empty); + + var savedCheck = await _dbContext.CVChecks.FirstOrDefaultAsync(c => c.Id == checkId); + savedCheck.Should().NotBeNull(); + } + + [Fact] + public async Task CreateCheckAsync_ThrowsArgumentNullException_WhenFileIsNull() + { + // Arrange + var userId = Guid.NewGuid(); + var fileName = "test.pdf"; + + // Act + var act = () => _sut.CreateCheckAsync(userId, null!, fileName); + + // Assert + await act.Should().ThrowAsync() + .WithParameterName("file"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task CreateCheckAsync_ThrowsArgumentException_WhenFileNameIsNullOrWhitespace(string? fileName) + { + // Arrange + var userId = Guid.NewGuid(); + using var stream = new MemoryStream(); + + // Act + var act = () => _sut.CreateCheckAsync(userId, stream, fileName!); + + // Assert + await act.Should().ThrowAsync() + .WithParameterName("fileName"); + } + + #endregion + + #region GetCheckAsync Tests + + [Fact] + public async Task GetCheckAsync_ReturnsNull_WhenCheckDoesNotExist() + { + // Arrange + var nonExistentId = Guid.NewGuid(); + + // Act + var result = await _sut.GetCheckAsync(nonExistentId); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task GetCheckAsync_ReturnsCorrectDto_WhenCheckExists() + { + // Arrange + var checkId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var createdAt = DateTime.UtcNow.AddHours(-1); + var completedAt = DateTime.UtcNow.AddMinutes(-30); + + var cvCheck = new CVCheck + { + Id = checkId, + UserId = userId, + OriginalFileName = "professional-cv.pdf", + BlobUrl = "https://blob.url/professional-cv.pdf", + Status = CheckStatus.Completed, + VeracityScore = 85, + CreatedAt = createdAt, + CompletedAt = completedAt + }; + + _dbContext.CVChecks.Add(cvCheck); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _sut.GetCheckAsync(checkId); + + // Assert + result.Should().NotBeNull(); + result!.Id.Should().Be(checkId); + result.OriginalFileName.Should().Be("professional-cv.pdf"); + result.Status.Should().Be("Completed"); + result.VeracityScore.Should().Be(85); + result.CompletedAt.Should().Be(completedAt); + } + + [Fact] + public async Task GetCheckAsync_ReturnsPendingStatus_ForNewCheck() + { + // Arrange + var checkId = Guid.NewGuid(); + + var cvCheck = new CVCheck + { + Id = checkId, + UserId = Guid.NewGuid(), + OriginalFileName = "new-cv.pdf", + BlobUrl = "https://blob.url/new-cv.pdf", + Status = CheckStatus.Pending, + CreatedAt = DateTime.UtcNow + }; + + _dbContext.CVChecks.Add(cvCheck); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _sut.GetCheckAsync(checkId); + + // Assert + result.Should().NotBeNull(); + result!.Status.Should().Be("Pending"); + result.VeracityScore.Should().BeNull(); + result.CompletedAt.Should().BeNull(); + } + + #endregion + + #region GetUserChecksAsync Tests + + [Fact] + public async Task GetUserChecksAsync_ReturnsEmptyList_WhenUserHasNoChecks() + { + // Arrange + var userId = Guid.NewGuid(); + + // Act + var result = await _sut.GetUserChecksAsync(userId); + + // Assert + result.Should().NotBeNull(); + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetUserChecksAsync_ReturnsAllChecksForUser() + { + // Arrange + var userId = Guid.NewGuid(); + + var check1 = new CVCheck + { + Id = Guid.NewGuid(), + UserId = userId, + OriginalFileName = "cv1.pdf", + BlobUrl = "https://blob.url/cv1.pdf", + Status = CheckStatus.Completed, + VeracityScore = 90, + CreatedAt = DateTime.UtcNow.AddDays(-2) + }; + + var check2 = new CVCheck + { + Id = Guid.NewGuid(), + UserId = userId, + OriginalFileName = "cv2.pdf", + BlobUrl = "https://blob.url/cv2.pdf", + Status = CheckStatus.Pending, + CreatedAt = DateTime.UtcNow.AddDays(-1) + }; + + var check3 = new CVCheck + { + Id = Guid.NewGuid(), + UserId = userId, + OriginalFileName = "cv3.pdf", + BlobUrl = "https://blob.url/cv3.pdf", + Status = CheckStatus.Processing, + CreatedAt = DateTime.UtcNow + }; + + _dbContext.CVChecks.AddRange(check1, check2, check3); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _sut.GetUserChecksAsync(userId); + + // Assert + result.Should().HaveCount(3); + result.Select(c => c.OriginalFileName) + .Should().Contain(new[] { "cv1.pdf", "cv2.pdf", "cv3.pdf" }); + } + + [Fact] + public async Task GetUserChecksAsync_DoesNotReturnOtherUsersChecks() + { + // Arrange + var userId1 = Guid.NewGuid(); + var userId2 = Guid.NewGuid(); + + var user1Check = new CVCheck + { + Id = Guid.NewGuid(), + UserId = userId1, + OriginalFileName = "user1-cv.pdf", + BlobUrl = "https://blob.url/user1-cv.pdf", + Status = CheckStatus.Completed, + CreatedAt = DateTime.UtcNow.AddHours(-1) + }; + + var user2Check1 = new CVCheck + { + Id = Guid.NewGuid(), + UserId = userId2, + OriginalFileName = "user2-cv-a.pdf", + BlobUrl = "https://blob.url/user2-cv-a.pdf", + Status = CheckStatus.Pending, + CreatedAt = DateTime.UtcNow.AddHours(-2) + }; + + var user2Check2 = new CVCheck + { + Id = Guid.NewGuid(), + UserId = userId2, + OriginalFileName = "user2-cv-b.pdf", + BlobUrl = "https://blob.url/user2-cv-b.pdf", + Status = CheckStatus.Completed, + CreatedAt = DateTime.UtcNow + }; + + _dbContext.CVChecks.AddRange(user1Check, user2Check1, user2Check2); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _sut.GetUserChecksAsync(userId1); + + // Assert + result.Should().HaveCount(1); + result.Single().OriginalFileName.Should().Be("user1-cv.pdf"); + result.Should().NotContain(c => c.OriginalFileName.StartsWith("user2")); + } + + [Fact] + public async Task GetUserChecksAsync_ReturnsChecksOrderedByCreatedAtDescending() + { + // Arrange + var userId = Guid.NewGuid(); + + // Create checks with specific IDs so we can update them after save + var oldestId = Guid.NewGuid(); + var middleId = Guid.NewGuid(); + var newestId = Guid.NewGuid(); + + var oldestCheck = new CVCheck + { + Id = oldestId, + UserId = userId, + OriginalFileName = "oldest.pdf", + BlobUrl = "https://blob.url/oldest.pdf", + Status = CheckStatus.Completed + }; + + var middleCheck = new CVCheck + { + Id = middleId, + UserId = userId, + OriginalFileName = "middle.pdf", + BlobUrl = "https://blob.url/middle.pdf", + Status = CheckStatus.Completed + }; + + var newestCheck = new CVCheck + { + Id = newestId, + UserId = userId, + OriginalFileName = "newest.pdf", + BlobUrl = "https://blob.url/newest.pdf", + Status = CheckStatus.Pending + }; + + // Add in random order - SaveChangesAsync will set CreatedAt to DateTime.UtcNow + _dbContext.CVChecks.AddRange(middleCheck, newestCheck, oldestCheck); + await _dbContext.SaveChangesAsync(); + + // Detach all entities to allow direct update + _dbContext.ChangeTracker.Clear(); + + // Now update CreatedAt timestamps using raw SQL to bypass SaveChangesAsync override + // Since InMemory database doesn't support raw SQL, we modify directly + var oldest = await _dbContext.CVChecks.FindAsync(oldestId); + var middle = await _dbContext.CVChecks.FindAsync(middleId); + var newest = await _dbContext.CVChecks.FindAsync(newestId); + + oldest!.CreatedAt = DateTime.UtcNow.AddDays(-7); + middle!.CreatedAt = DateTime.UtcNow.AddDays(-3); + newest!.CreatedAt = DateTime.UtcNow; + + // Mark as modified (not added) to bypass the CreatedAt override + _dbContext.Entry(oldest).State = EntityState.Modified; + _dbContext.Entry(middle).State = EntityState.Modified; + _dbContext.Entry(newest).State = EntityState.Modified; + + await _dbContext.SaveChangesAsync(); + _dbContext.ChangeTracker.Clear(); + + // Act + var result = await _sut.GetUserChecksAsync(userId); + + // Assert + result.Should().HaveCount(3); + result[0].OriginalFileName.Should().Be("newest.pdf"); + result[1].OriginalFileName.Should().Be("middle.pdf"); + result[2].OriginalFileName.Should().Be("oldest.pdf"); + } + + #endregion + + #region GetCheckForUserAsync Tests + + [Fact] + public async Task GetCheckForUserAsync_ReturnsNull_WhenCheckDoesNotExist() + { + // Arrange + var checkId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + + // Act + var result = await _sut.GetCheckForUserAsync(checkId, userId); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task GetCheckForUserAsync_ReturnsNull_WhenCheckBelongsToDifferentUser() + { + // Arrange + var checkId = Guid.NewGuid(); + var ownerId = Guid.NewGuid(); + var requestingUserId = Guid.NewGuid(); + + var cvCheck = new CVCheck + { + Id = checkId, + UserId = ownerId, + OriginalFileName = "owner-cv.pdf", + BlobUrl = "https://blob.url/owner-cv.pdf", + Status = CheckStatus.Completed, + CreatedAt = DateTime.UtcNow + }; + + _dbContext.CVChecks.Add(cvCheck); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _sut.GetCheckForUserAsync(checkId, requestingUserId); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task GetCheckForUserAsync_ReturnsCheck_WhenCheckBelongsToUser() + { + // Arrange + var checkId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + + var cvCheck = new CVCheck + { + Id = checkId, + UserId = userId, + OriginalFileName = "my-cv.pdf", + BlobUrl = "https://blob.url/my-cv.pdf", + Status = CheckStatus.Completed, + VeracityScore = 88, + CreatedAt = DateTime.UtcNow + }; + + _dbContext.CVChecks.Add(cvCheck); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _sut.GetCheckForUserAsync(checkId, userId); + + // Assert + result.Should().NotBeNull(); + result!.Id.Should().Be(checkId); + result.OriginalFileName.Should().Be("my-cv.pdf"); + result.VeracityScore.Should().Be(88); + } + + #endregion + + #region GetReportAsync Tests + + [Fact] + public async Task GetReportAsync_ReturnsNull_WhenCheckDoesNotExist() + { + // Arrange + var checkId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + + // Act + var result = await _sut.GetReportAsync(checkId, userId); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task GetReportAsync_ReturnsNull_WhenCheckBelongsToDifferentUser() + { + // Arrange + var checkId = Guid.NewGuid(); + var ownerId = Guid.NewGuid(); + var requestingUserId = Guid.NewGuid(); + + var report = CreateTestReport(); + var cvCheck = new CVCheck + { + Id = checkId, + UserId = ownerId, + OriginalFileName = "cv.pdf", + BlobUrl = "https://blob.url/cv.pdf", + Status = CheckStatus.Completed, + ReportJson = JsonSerializer.Serialize(report), + CreatedAt = DateTime.UtcNow + }; + + _dbContext.CVChecks.Add(cvCheck); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _sut.GetReportAsync(checkId, requestingUserId); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task GetReportAsync_ReturnsNull_WhenCheckIsNotCompleted() + { + // Arrange + var checkId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + + var cvCheck = new CVCheck + { + Id = checkId, + UserId = userId, + OriginalFileName = "cv.pdf", + BlobUrl = "https://blob.url/cv.pdf", + Status = CheckStatus.Processing, + CreatedAt = DateTime.UtcNow + }; + + _dbContext.CVChecks.Add(cvCheck); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _sut.GetReportAsync(checkId, userId); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task GetReportAsync_ReturnsNull_WhenReportJsonIsEmpty() + { + // Arrange + var checkId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + + var cvCheck = new CVCheck + { + Id = checkId, + UserId = userId, + OriginalFileName = "cv.pdf", + BlobUrl = "https://blob.url/cv.pdf", + Status = CheckStatus.Completed, + ReportJson = string.Empty, + CreatedAt = DateTime.UtcNow + }; + + _dbContext.CVChecks.Add(cvCheck); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _sut.GetReportAsync(checkId, userId); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task GetReportAsync_ReturnsReport_WhenCheckIsCompletedWithValidReport() + { + // Arrange + var checkId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + + var report = CreateTestReport(); + var cvCheck = new CVCheck + { + Id = checkId, + UserId = userId, + OriginalFileName = "cv.pdf", + BlobUrl = "https://blob.url/cv.pdf", + Status = CheckStatus.Completed, + ReportJson = JsonSerializer.Serialize(report), + CreatedAt = DateTime.UtcNow + }; + + _dbContext.CVChecks.Add(cvCheck); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _sut.GetReportAsync(checkId, userId); + + // Assert + result.Should().NotBeNull(); + result!.OverallScore.Should().Be(85); + result.ScoreLabel.Should().Be("Good"); + } + + [Fact] + public async Task GetReportAsync_ReturnsNull_WhenReportJsonIsInvalid() + { + // Arrange + var checkId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + + var cvCheck = new CVCheck + { + Id = checkId, + UserId = userId, + OriginalFileName = "cv.pdf", + BlobUrl = "https://blob.url/cv.pdf", + Status = CheckStatus.Completed, + ReportJson = "{ invalid json }", + CreatedAt = DateTime.UtcNow + }; + + _dbContext.CVChecks.Add(cvCheck); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _sut.GetReportAsync(checkId, userId); + + // Assert + result.Should().BeNull(); + } + + #endregion + + #region Helper Methods + + private static VeracityReport CreateTestReport() + { + return new VeracityReport + { + OverallScore = 85, + ScoreLabel = "Good", + EmploymentVerifications = [], + TimelineAnalysis = new TimelineAnalysisResult + { + Gaps = [], + Overlaps = [], + TotalGapMonths = 0, + TotalOverlapMonths = 0 + }, + Flags = [], + GeneratedAt = DateTime.UtcNow + }; + } + + #endregion +} diff --git a/tests/TrueCV.Tests/Services/CVParserServiceTests.cs b/tests/TrueCV.Tests/Services/CVParserServiceTests.cs new file mode 100644 index 0000000..b02502b --- /dev/null +++ b/tests/TrueCV.Tests/Services/CVParserServiceTests.cs @@ -0,0 +1,694 @@ +using System.Text; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using TrueCV.Application.Models; +using TrueCV.Infrastructure.Configuration; +using TrueCV.Infrastructure.Services; + +namespace TrueCV.Tests.Services; + +public sealed class CVParserServiceTests : IDisposable +{ + private readonly Mock> _loggerMock; + private readonly Mock> _settingsMock; + private readonly CVParserService _sut; + + public CVParserServiceTests() + { + _loggerMock = new Mock>(); + _settingsMock = new Mock>(); + _settingsMock.Setup(x => x.Value).Returns(new AnthropicSettings { ApiKey = "test-api-key" }); + + _sut = new CVParserService(_settingsMock.Object, _loggerMock.Object); + } + + public void Dispose() + { + // Clean up any resources if needed + } + + #region File Extension Tests + + [Theory] + [InlineData(".txt")] + [InlineData(".doc")] + [InlineData(".rtf")] + [InlineData(".xml")] + [InlineData(".html")] + [InlineData("")] + public async Task ParseAsync_WithUnsupportedFileExtension_ThrowsNotSupportedException(string extension) + { + // Arrange + using var stream = new MemoryStream(Encoding.UTF8.GetBytes("test content")); + var fileName = $"resume{extension}"; + + // Act + var act = () => _sut.ParseAsync(stream, fileName); + + // Assert + await act.Should().ThrowAsync() + .WithMessage($"*'{extension}'*not supported*"); + } + + [Fact] + public async Task ParseAsync_WithNoFileExtension_ThrowsNotSupportedException() + { + // Arrange + using var stream = new MemoryStream(Encoding.UTF8.GetBytes("test content")); + const string fileName = "resume"; + + // Act + var act = () => _sut.ParseAsync(stream, fileName); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*''*not supported*"); + } + + [Theory] + [InlineData("my resume.pdf")] + [InlineData("John Doe CV (2024).pdf")] + [InlineData("resume-final-v2.docx")] + [InlineData("CV_John_Doe.DOCX")] + public async Task ParseAsync_WithSpecialCharactersInFileName_HandlesExtensionCorrectly(string fileName) + { + // Arrange + using var stream = new MemoryStream(Encoding.UTF8.GetBytes("not valid content")); + + // Act + var act = () => _sut.ParseAsync(stream, fileName); + + // Assert + // Should attempt to parse (not throw NotSupportedException) + // Will fail on content parsing, but extension should be recognized + Exception? thrownException = null; + try + { + await act(); + } + catch (Exception ex) + { + thrownException = ex; + } + + thrownException.Should().NotBeNull(); + thrownException.Should().NotBeOfType("because file extension should be recognized"); + } + + [Fact] + public async Task ParseAsync_WithMultipleDotsInFileName_UsesLastExtension() + { + // Arrange + using var stream = new MemoryStream(Encoding.UTF8.GetBytes("not valid content")); + const string fileName = "my.resume.final.pdf"; + + // Act + var act = () => _sut.ParseAsync(stream, fileName); + + // Assert + // Should recognize .pdf as the extension (not .resume or .final) + Exception? thrownException = null; + try + { + await act(); + } + catch (Exception ex) + { + thrownException = ex; + } + + thrownException.Should().NotBeNull(); + thrownException.Should().NotBeOfType("because .pdf extension should be recognized"); + } + + [Theory] + [InlineData("resume.pdf")] + [InlineData("resume.PDF")] + [InlineData("resume.Pdf")] + [InlineData("resume.pDf")] + public async Task ParseAsync_WithPdfExtension_CaseInsensitive_AttemptsPdfParsing(string fileName) + { + // Arrange + // Create minimal valid-looking stream (empty PDF will fail at parsing, but this tests extension detection) + using var stream = new MemoryStream(Encoding.UTF8.GetBytes("not a valid pdf")); + + // Act + var act = () => _sut.ParseAsync(stream, fileName); + + // Assert + // Should attempt PDF parsing and fail (not throw NotSupportedException) + // PdfPig will throw when parsing invalid PDF content + Exception? thrownException = null; + try + { + await act(); + } + catch (Exception ex) + { + thrownException = ex; + } + + thrownException.Should().NotBeNull("because the method should throw for invalid PDF content"); + thrownException.Should().NotBeOfType("because PDF extension should be recognized"); + } + + [Theory] + [InlineData("resume.docx")] + [InlineData("resume.DOCX")] + [InlineData("resume.Docx")] + [InlineData("resume.dOcX")] + public async Task ParseAsync_WithDocxExtension_CaseInsensitive_AttemptsDocxParsing(string fileName) + { + // Arrange + // Create minimal stream (invalid DOCX will fail at parsing, but this tests extension detection) + using var stream = new MemoryStream(Encoding.UTF8.GetBytes("not a valid docx")); + + // Act + var act = () => _sut.ParseAsync(stream, fileName); + + // Assert + // Should attempt DOCX parsing and fail (not throw NotSupportedException) + // OpenXml will throw when parsing invalid DOCX content + Exception? thrownException = null; + try + { + await act(); + } + catch (Exception ex) + { + thrownException = ex; + } + + thrownException.Should().NotBeNull("because the method should throw for invalid DOCX content"); + thrownException.Should().NotBeOfType("because DOCX extension should be recognized"); + } + + #endregion + + #region Input Validation Tests + + [Fact] + public async Task ParseAsync_WithNullStream_ThrowsArgumentNullException() + { + // Arrange + Stream? stream = null; + + // Act + var act = () => _sut.ParseAsync(stream!, "resume.pdf"); + + // Assert + await act.Should().ThrowAsync() + .WithParameterName("fileStream"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task ParseAsync_WithNullOrEmptyFileName_ThrowsArgumentException(string? fileName) + { + // Arrange + using var stream = new MemoryStream(Encoding.UTF8.GetBytes("test")); + + // Act + var act = () => _sut.ParseAsync(stream, fileName!); + + // Assert + await act.Should().ThrowAsync() + .WithParameterName("fileName"); + } + + #endregion + + #region Empty File Content Tests + + [Fact] + public async Task ParseAsync_WithEmptyPdfContent_ThrowsInvalidOperationException() + { + // Arrange + // A minimal valid PDF structure that contains no text + var emptyPdfBytes = CreateMinimalPdfWithNoText(); + using var stream = new MemoryStream(emptyPdfBytes); + + // Act + var act = () => _sut.ParseAsync(stream, "empty.pdf"); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*Could not extract text content*"); + } + + [Fact] + public async Task ParseAsync_WithEmptyDocxContent_ThrowsInvalidOperationException() + { + // Arrange + var emptyDocxBytes = CreateMinimalDocxWithNoText(); + using var stream = new MemoryStream(emptyDocxBytes); + + // Act + var act = () => _sut.ParseAsync(stream, "empty.docx"); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*Could not extract text content*"); + } + + #endregion + + #region PDF Parsing Path Tests + + [Fact] + public async Task ParseAsync_WithValidPdfExtension_TriggersPdfParsingPath() + { + // Arrange + var pdfBytes = CreateMinimalPdfWithText("John Doe\njohn@example.com"); + using var stream = new MemoryStream(pdfBytes); + + // Act & Assert + // This will try to call Claude API which will fail, but we can verify + // it got past the PDF parsing stage by checking logs or the exception type + var act = () => _sut.ParseAsync(stream, "resume.pdf"); + + // The exception should NOT be NotSupportedException or InvalidOperationException about text extraction + // It should be a Claude API error (HttpRequestException or similar) + var exception = await act.Should().ThrowAsync(); + + // Verify we got past text extraction (would throw InvalidOperationException with "Could not extract") + exception.Which.Should().NotBeOfType(); + exception.Which.Message.Should().NotContain("Could not extract text content"); + } + + #endregion + + #region DOCX Parsing Path Tests + + [Fact] + public async Task ParseAsync_WithValidDocxExtension_TriggersDocxParsingPath() + { + // Arrange + var docxBytes = CreateMinimalDocxWithText("Jane Smith\njane@example.com"); + using var stream = new MemoryStream(docxBytes); + + // Act & Assert + // This will try to call Claude API which will fail, but we can verify + // it got past the DOCX parsing stage + var act = () => _sut.ParseAsync(stream, "resume.docx"); + + // The exception should NOT be NotSupportedException or InvalidOperationException about text extraction + var exception = await act.Should().ThrowAsync(); + + exception.Which.Should().NotBeOfType(); + exception.Which.Message.Should().NotContain("Could not extract text content"); + } + + #endregion + + #region Claude API Error Handling Tests + + [Fact] + public async Task ParseAsync_WhenClaudeApiFailsWithInvalidKey_ThrowsHttpRequestException() + { + // Arrange + // Using a test API key which will cause authentication failure + var docxBytes = CreateMinimalDocxWithText("John Doe\njohn@example.com\nSenior Developer at Tech Corp"); + using var stream = new MemoryStream(docxBytes); + + // Act + var act = () => _sut.ParseAsync(stream, "resume.docx"); + + // Assert + // The service should propagate the HTTP error from the Claude API + // (401 Unauthorized for invalid API key) + var exception = await act.Should().ThrowAsync(); + + // Should NOT be our domain exceptions (text extraction worked) + exception.Which.Message.Should().NotContain("Could not extract text content"); + exception.Which.Should().NotBeOfType(); + } + + [Fact] + public async Task ParseAsync_WithValidTextContent_AttemptsClaudeApiCall() + { + // Arrange + var pdfBytes = CreateMinimalPdfWithText("Jane Smith\njane@company.com\n+1-555-0123"); + using var stream = new MemoryStream(pdfBytes); + + // Act + var act = () => _sut.ParseAsync(stream, "resume.pdf"); + + // Assert + // With an invalid/test API key, we expect the API call to fail + // but this confirms we successfully extracted text and attempted the API call + var exception = await act.Should().ThrowAsync(); + + // Verify it got past text extraction phase + exception.Which.Message.Should().NotContain("Could not extract text content"); + + // Verify logging occurred for sending to Claude API + _loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Sending CV text to Claude API")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + #endregion + + #region Logging Tests + + [Fact] + public async Task ParseAsync_LogsDebugMessageWithFileName() + { + // Arrange + using var stream = new MemoryStream(Encoding.UTF8.GetBytes("test")); + const string fileName = "resume.txt"; // unsupported to fail early + + // Act + try + { + await _sut.ParseAsync(stream, fileName); + } + catch (NotSupportedException) + { + // Expected + } + + // Assert + _loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Parsing CV file")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + #endregion + + #region Helper Methods + + /// + /// Creates a minimal valid PDF with no text content. + /// + private static byte[] CreateMinimalPdfWithNoText() + { + // Minimal PDF 1.4 structure with empty page + const string pdfContent = @"%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >> +endobj +xref +0 4 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +trailer +<< /Size 4 /Root 1 0 R >> +startxref +191 +%%EOF"; + return Encoding.ASCII.GetBytes(pdfContent); + } + + /// + /// Creates a minimal valid PDF with text content. + /// + private static byte[] CreateMinimalPdfWithText(string text) + { + // Escape special characters for PDF + var escapedText = text.Replace("\\", "\\\\").Replace("(", "\\(").Replace(")", "\\)"); + + var pdfContent = $@"%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> +endobj +4 0 obj +<< /Length 100 >> +stream +BT +/F1 12 Tf +100 700 Td +({escapedText}) Tj +ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +xref +0 6 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000238 00000 n +0000000388 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +463 +%%EOF"; + return Encoding.ASCII.GetBytes(pdfContent); + } + + /// + /// Creates a minimal valid DOCX with no text content. + /// + private static byte[] CreateMinimalDocxWithNoText() + { + using var memoryStream = new MemoryStream(); + using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true)) + { + // [Content_Types].xml + var contentTypesEntry = archive.CreateEntry("[Content_Types].xml"); + using (var writer = new StreamWriter(contentTypesEntry.Open())) + { + writer.Write(@" + + + + +"); + } + + // _rels/.rels + var relsEntry = archive.CreateEntry("_rels/.rels"); + using (var writer = new StreamWriter(relsEntry.Open())) + { + writer.Write(@" + + +"); + } + + // word/document.xml (empty body) + var documentEntry = archive.CreateEntry("word/document.xml"); + using (var writer = new StreamWriter(documentEntry.Open())) + { + writer.Write(@" + + + +"); + } + } + + return memoryStream.ToArray(); + } + + /// + /// Creates a minimal valid DOCX with text content. + /// + private static byte[] CreateMinimalDocxWithText(string text) + { + using var memoryStream = new MemoryStream(); + using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true)) + { + // [Content_Types].xml + var contentTypesEntry = archive.CreateEntry("[Content_Types].xml"); + using (var writer = new StreamWriter(contentTypesEntry.Open())) + { + writer.Write(@" + + + + +"); + } + + // _rels/.rels + var relsEntry = archive.CreateEntry("_rels/.rels"); + using (var writer = new StreamWriter(relsEntry.Open())) + { + writer.Write(@" + + +"); + } + + // word/document.xml with text + var escapedText = System.Security.SecurityElement.Escape(text); + var documentEntry = archive.CreateEntry("word/document.xml"); + using (var writer = new StreamWriter(documentEntry.Open())) + { + writer.Write($@" + + + + + {escapedText} + + + +"); + } + } + + return memoryStream.ToArray(); + } + + #endregion +} + +/// +/// Tests for CVData model mapping that can be tested in isolation. +/// These tests verify the expected structure and constraints of the parsed CV data. +/// +/// +/// Note: Integration tests for full Claude API response parsing require a valid Anthropic API key. +/// These tests should be run separately with a configured test environment. +/// To properly unit test Claude API response handling, consider refactoring CVParserService +/// to accept an IAnthropicClient interface for dependency injection. +/// +public sealed class CVDataMappingTests +{ + [Fact] + public void CVData_RequiresFullName() + { + // Arrange & Act + var cvData = new CVData + { + FullName = "John Doe" + }; + + // Assert + cvData.FullName.Should().Be("John Doe"); + cvData.Email.Should().BeNull(); + cvData.Phone.Should().BeNull(); + cvData.Employment.Should().BeEmpty(); + cvData.Education.Should().BeEmpty(); + cvData.Skills.Should().BeEmpty(); + } + + [Fact] + public void CVData_WithAllFields_MapsCorrectly() + { + // Arrange & Act + var cvData = new CVData + { + FullName = "Jane Smith", + Email = "jane@example.com", + Phone = "+1-555-123-4567", + Employment = + [ + new EmploymentEntry + { + CompanyName = "Tech Corp", + JobTitle = "Senior Developer", + Location = "New York", + StartDate = new DateOnly(2020, 1, 1), + EndDate = null, + IsCurrent = true, + Description = "Leading development team" + } + ], + Education = + [ + new EducationEntry + { + Institution = "MIT", + Qualification = "BSc", + Subject = "Computer Science", + Grade = "3.9 GPA", + StartDate = new DateOnly(2012, 9, 1), + EndDate = new DateOnly(2016, 5, 15) + } + ], + Skills = ["C#", ".NET", "Azure", "SQL"] + }; + + // Assert + cvData.FullName.Should().Be("Jane Smith"); + cvData.Email.Should().Be("jane@example.com"); + cvData.Phone.Should().Be("+1-555-123-4567"); + + cvData.Employment.Should().HaveCount(1); + cvData.Employment[0].CompanyName.Should().Be("Tech Corp"); + cvData.Employment[0].IsCurrent.Should().BeTrue(); + cvData.Employment[0].EndDate.Should().BeNull(); + + cvData.Education.Should().HaveCount(1); + cvData.Education[0].Institution.Should().Be("MIT"); + cvData.Education[0].Qualification.Should().Be("BSc"); + + cvData.Skills.Should().HaveCount(4); + cvData.Skills.Should().Contain("C#"); + } + + [Fact] + public void EmploymentEntry_RequiresCompanyNameAndJobTitle() + { + // Arrange & Act + var entry = new EmploymentEntry + { + CompanyName = "Company", + JobTitle = "Developer" + }; + + // Assert + entry.CompanyName.Should().Be("Company"); + entry.JobTitle.Should().Be("Developer"); + entry.Location.Should().BeNull(); + entry.StartDate.Should().BeNull(); + entry.EndDate.Should().BeNull(); + entry.IsCurrent.Should().BeFalse(); + entry.Description.Should().BeNull(); + } + + [Fact] + public void EducationEntry_RequiresInstitution() + { + // Arrange & Act + var entry = new EducationEntry + { + Institution = "University" + }; + + // Assert + entry.Institution.Should().Be("University"); + entry.Qualification.Should().BeNull(); + entry.Subject.Should().BeNull(); + entry.Grade.Should().BeNull(); + entry.StartDate.Should().BeNull(); + entry.EndDate.Should().BeNull(); + } +} diff --git a/tests/TrueCV.Tests/Services/CompanyVerifierServiceTests.cs b/tests/TrueCV.Tests/Services/CompanyVerifierServiceTests.cs new file mode 100644 index 0000000..e7998bf --- /dev/null +++ b/tests/TrueCV.Tests/Services/CompanyVerifierServiceTests.cs @@ -0,0 +1,583 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Moq.Protected; +using TrueCV.Domain.Entities; +using TrueCV.Infrastructure.Configuration; +using TrueCV.Infrastructure.Data; +using TrueCV.Infrastructure.ExternalApis; +using TrueCV.Infrastructure.Services; + +namespace TrueCV.Tests.Services; + +public class CompanyVerifierServiceTests : IDisposable +{ + private readonly Mock _mockHttpHandler; + private readonly Mock> _mockServiceLogger; + private readonly Mock> _mockClientLogger; + private readonly ApplicationDbContext _dbContext; + private readonly CompanyVerifierService _sut; + private readonly HttpClient _httpClient; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + PropertyNameCaseInsensitive = true + }; + + public CompanyVerifierServiceTests() + { + _mockHttpHandler = new Mock(); + _mockServiceLogger = new Mock>(); + _mockClientLogger = new Mock>(); + + _httpClient = new HttpClient(_mockHttpHandler.Object); + + var settings = Options.Create(new CompaniesHouseSettings + { + ApiKey = "test-api-key", + BaseUrl = "https://api.company-information.service.gov.uk" + }); + + var client = new CompaniesHouseClient(_httpClient, settings, _mockClientLogger.Object); + + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + _dbContext = new ApplicationDbContext(options); + + _sut = new CompanyVerifierService(client, _dbContext, _mockServiceLogger.Object); + } + + public void Dispose() + { + _httpClient.Dispose(); + _dbContext.Dispose(); + GC.SuppressFinalize(this); + } + + #region VerifyCompanyAsync Tests + + [Fact] + public async Task VerifyCompanyAsync_ExactMatch_ReturnsVerifiedWith100PercentScore() + { + // Arrange + const string companyName = "ACME Corporation Ltd"; + var searchResponse = CreateSearchResponse(new[] + { + CreateSearchItem("12345678", "ACME Corporation Ltd", "active") + }); + + SetupHttpResponse(HttpStatusCode.OK, searchResponse); + + // Act + var result = await _sut.VerifyCompanyAsync(companyName, null, null); + + // Assert + result.IsVerified.Should().BeTrue(); + result.MatchScore.Should().Be(100); + result.MatchedCompanyName.Should().Be("ACME Corporation Ltd"); + result.MatchedCompanyNumber.Should().Be("12345678"); + result.ClaimedCompany.Should().Be(companyName); + } + + [Fact] + public async Task VerifyCompanyAsync_FuzzyMatchAboveThreshold_ReturnsVerified() + { + // Arrange + // Using names that will produce a match score above 70% threshold + const string claimedName = "ACME Corporation"; + const string actualName = "ACME Corporation Ltd"; + var searchResponse = CreateSearchResponse(new[] + { + CreateSearchItem("12345678", actualName, "active") + }); + + SetupHttpResponse(HttpStatusCode.OK, searchResponse); + + // Act + var result = await _sut.VerifyCompanyAsync(claimedName, null, null); + + // Assert + result.IsVerified.Should().BeTrue(); + result.MatchScore.Should().BeGreaterThanOrEqualTo(70); + result.MatchedCompanyName.Should().Be(actualName); + } + + [Fact] + public async Task VerifyCompanyAsync_FuzzyMatchBelowThreshold_ReturnsNotVerified() + { + // Arrange + const string claimedName = "Totally Different Company"; + const string actualName = "ACME Corporation Ltd"; + var searchResponse = CreateSearchResponse(new[] + { + CreateSearchItem("12345678", actualName, "active") + }); + + SetupHttpResponse(HttpStatusCode.OK, searchResponse); + + // Act + var result = await _sut.VerifyCompanyAsync(claimedName, null, null); + + // Assert + result.IsVerified.Should().BeFalse(); + result.MatchScore.Should().Be(0); + result.MatchedCompanyName.Should().BeNull(); + result.VerificationNotes.Should().Contain("70%"); + } + + [Fact] + public async Task VerifyCompanyAsync_CachedCompany_ReturnsCachedWithoutApiCall() + { + // Arrange + const string companyName = "Cached Company Ltd"; + var cachedCompany = new CompanyCache + { + CompanyNumber = "99999999", + CompanyName = "Cached Company Ltd", + Status = "active", + CachedAt = DateTime.UtcNow.AddDays(-5) // Within 30-day window + }; + + _dbContext.CompanyCache.Add(cachedCompany); + await _dbContext.SaveChangesAsync(); + + // Act + var result = await _sut.VerifyCompanyAsync(companyName, null, null); + + // Assert + result.IsVerified.Should().BeTrue(); + result.MatchedCompanyNumber.Should().Be("99999999"); + result.VerificationNotes.Should().Contain("cache"); + + // Verify API was NOT called (no HTTP setup means it would fail if called) + _mockHttpHandler.Protected().Verify( + "SendAsync", + Times.Never(), + ItExpr.IsAny(), + ItExpr.IsAny()); + } + + [Fact] + public async Task VerifyCompanyAsync_ExpiredCache_TriggersNewApiCall() + { + // Arrange + const string companyName = "Expired Company Ltd"; + var expiredCachedCompany = new CompanyCache + { + CompanyNumber = "88888888", + CompanyName = "Expired Company Ltd", + Status = "active", + CachedAt = DateTime.UtcNow.AddDays(-35) // Beyond 30-day window + }; + + _dbContext.CompanyCache.Add(expiredCachedCompany); + await _dbContext.SaveChangesAsync(); + + var searchResponse = CreateSearchResponse(new[] + { + CreateSearchItem("12345678", "Expired Company Ltd", "active") + }); + + SetupHttpResponse(HttpStatusCode.OK, searchResponse); + + // Act + var result = await _sut.VerifyCompanyAsync(companyName, null, null); + + // Assert + result.IsVerified.Should().BeTrue(); + result.MatchedCompanyNumber.Should().Be("12345678"); // From API, not cache + + // Verify API WAS called + _mockHttpHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(r => r.RequestUri!.ToString().Contains("search/companies")), + ItExpr.IsAny()); + } + + [Fact] + public async Task VerifyCompanyAsync_NoMatchingCompany_ReturnsNotVerified() + { + // Arrange + const string companyName = "NonExistent Company Ltd"; + var searchResponse = CreateSearchResponse(Array.Empty()); + + SetupHttpResponse(HttpStatusCode.OK, searchResponse); + + // Act + var result = await _sut.VerifyCompanyAsync(companyName, null, null); + + // Assert + result.IsVerified.Should().BeFalse(); + result.MatchScore.Should().Be(0); + result.MatchedCompanyName.Should().BeNull(); + result.VerificationNotes.Should().Contain("No matching company"); + } + + [Fact] + public async Task VerifyCompanyAsync_ApiRateLimitError_ReturnsNotVerifiedWithAppropriateMessage() + { + // Arrange + const string companyName = "Rate Limited Company"; + + SetupHttpResponse(HttpStatusCode.TooManyRequests, null); + + // Act + var result = await _sut.VerifyCompanyAsync(companyName, null, null); + + // Assert + result.IsVerified.Should().BeFalse(); + result.MatchScore.Should().Be(0); + result.VerificationNotes.Should().Contain("rate limiting"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task VerifyCompanyAsync_EmptyOrNullCompanyName_ThrowsArgumentException(string? companyName) + { + // Act + var act = () => _sut.VerifyCompanyAsync(companyName!, null, null); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task VerifyCompanyAsync_WithDates_IncludesDatesInResult() + { + // Arrange + const string companyName = "Dated Company Ltd"; + var startDate = new DateOnly(2020, 1, 15); + var endDate = new DateOnly(2023, 6, 30); + + var searchResponse = CreateSearchResponse(new[] + { + CreateSearchItem("12345678", "Dated Company Ltd", "active") + }); + + SetupHttpResponse(HttpStatusCode.OK, searchResponse); + + // Act + var result = await _sut.VerifyCompanyAsync(companyName, startDate, endDate); + + // Assert + result.ClaimedStartDate.Should().Be(startDate); + result.ClaimedEndDate.Should().Be(endDate); + } + + [Fact] + public async Task VerifyCompanyAsync_MultipleMatches_ReturnsBestMatch() + { + // Arrange + const string companyName = "Tech Solutions Ltd"; + var searchResponse = CreateSearchResponse(new[] + { + CreateSearchItem("11111111", "Tech Solutions International", "active"), + CreateSearchItem("22222222", "Tech Solutions Ltd", "active"), + CreateSearchItem("33333333", "Old Tech Solutions", "dissolved") + }); + + SetupHttpResponse(HttpStatusCode.OK, searchResponse); + + // Act + var result = await _sut.VerifyCompanyAsync(companyName, null, null); + + // Assert + result.IsVerified.Should().BeTrue(); + result.MatchScore.Should().Be(100); + result.MatchedCompanyNumber.Should().Be("22222222"); + } + + [Fact] + public async Task VerifyCompanyAsync_CachesNewlyVerifiedCompany() + { + // Arrange + const string companyName = "New Cacheable Company Ltd"; + var searchResponse = CreateSearchResponse(new[] + { + CreateSearchItem("77777777", "New Cacheable Company Ltd", "active", "2010-05-15") + }); + + SetupHttpResponse(HttpStatusCode.OK, searchResponse); + + // Act + await _sut.VerifyCompanyAsync(companyName, null, null); + + // Assert + var cachedEntry = await _dbContext.CompanyCache + .FirstOrDefaultAsync(c => c.CompanyNumber == "77777777"); + + cachedEntry.Should().NotBeNull(); + cachedEntry!.CompanyName.Should().Be("New Cacheable Company Ltd"); + cachedEntry.Status.Should().Be("active"); + } + + [Fact] + public async Task VerifyCompanyAsync_UpdatesExistingCacheEntry() + { + // Arrange + const string companyName = "Updated Company Ltd"; + var existingCache = new CompanyCache + { + CompanyNumber = "55555555", + CompanyName = "Old Company Name", + Status = "active", + CachedAt = DateTime.UtcNow.AddDays(-40) + }; + + _dbContext.CompanyCache.Add(existingCache); + await _dbContext.SaveChangesAsync(); + + var searchResponse = CreateSearchResponse(new[] + { + CreateSearchItem("55555555", "Updated Company Ltd", "dissolved") + }); + + SetupHttpResponse(HttpStatusCode.OK, searchResponse); + + // Act + await _sut.VerifyCompanyAsync(companyName, null, null); + + // Assert + var cachedEntry = await _dbContext.CompanyCache + .FirstOrDefaultAsync(c => c.CompanyNumber == "55555555"); + + cachedEntry.Should().NotBeNull(); + cachedEntry!.CompanyName.Should().Be("Updated Company Ltd"); + cachedEntry.Status.Should().Be("dissolved"); + cachedEntry.CachedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task VerifyCompanyAsync_CaseInsensitiveMatching_ReturnsVerified() + { + // Arrange + const string claimedName = "acme corporation ltd"; + const string actualName = "ACME CORPORATION LTD"; + + var searchResponse = CreateSearchResponse(new[] + { + CreateSearchItem("12345678", actualName, "active") + }); + + SetupHttpResponse(HttpStatusCode.OK, searchResponse); + + // Act + var result = await _sut.VerifyCompanyAsync(claimedName, null, null); + + // Assert + result.IsVerified.Should().BeTrue(); + result.MatchScore.Should().Be(100); + } + + #endregion + + #region SearchCompaniesAsync Tests + + [Fact] + public async Task SearchCompaniesAsync_ValidQuery_ReturnsSearchResults() + { + // Arrange + const string query = "Tech"; + var searchResponse = CreateSearchResponse(new[] + { + CreateSearchItem("11111111", "Tech Solutions Ltd", "active", "2015-03-20", "London, UK"), + CreateSearchItem("22222222", "Tech Innovations Inc", "active", "2018-07-10", "Manchester, UK") + }); + + SetupHttpResponse(HttpStatusCode.OK, searchResponse); + + // Act + var results = await _sut.SearchCompaniesAsync(query); + + // Assert + results.Should().HaveCount(2); + results[0].CompanyNumber.Should().Be("11111111"); + results[0].CompanyName.Should().Be("Tech Solutions Ltd"); + results[0].CompanyStatus.Should().Be("active"); + results[0].IncorporationDate.Should().Be(new DateOnly(2015, 3, 20)); + results[0].AddressSnippet.Should().Be("London, UK"); + } + + [Fact] + public async Task SearchCompaniesAsync_NoResults_ReturnsEmptyList() + { + // Arrange + const string query = "NonexistentXYZ123"; + var searchResponse = CreateSearchResponse(Array.Empty()); + + SetupHttpResponse(HttpStatusCode.OK, searchResponse); + + // Act + var results = await _sut.SearchCompaniesAsync(query); + + // Assert + results.Should().BeEmpty(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task SearchCompaniesAsync_EmptyOrNullQuery_ThrowsArgumentException(string? query) + { + // Act + var act = () => _sut.SearchCompaniesAsync(query!); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SearchCompaniesAsync_NullCompanyStatus_ReturnsUnknownStatus() + { + // Arrange + const string query = "Unknown Status Corp"; + var searchResponse = CreateSearchResponse(new[] + { + CreateSearchItem("12345678", "Unknown Status Corp", null) + }); + + SetupHttpResponse(HttpStatusCode.OK, searchResponse); + + // Act + var results = await _sut.SearchCompaniesAsync(query); + + // Assert + results.Should().HaveCount(1); + results[0].CompanyStatus.Should().Be("Unknown"); + } + + [Fact] + public async Task SearchCompaniesAsync_InvalidDateFormat_ReturnsNullIncorporationDate() + { + // Arrange + const string query = "Invalid Date Corp"; + var searchResponse = CreateSearchResponse(new[] + { + CreateSearchItem("12345678", "Invalid Date Corp", "active", "invalid-date") + }); + + SetupHttpResponse(HttpStatusCode.OK, searchResponse); + + // Act + var results = await _sut.SearchCompaniesAsync(query); + + // Assert + results.Should().HaveCount(1); + results[0].IncorporationDate.Should().BeNull(); + } + + [Fact] + public async Task SearchCompaniesAsync_PassesThroughToApiCorrectly() + { + // Arrange + const string query = "TechCompany"; + var searchResponse = CreateSearchResponse(Array.Empty()); + + SetupHttpResponse(HttpStatusCode.OK, searchResponse); + + // Act + await _sut.SearchCompaniesAsync(query); + + // Assert + _mockHttpHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(r => + r.Method == HttpMethod.Get && + r.RequestUri!.ToString().Contains("search/companies") && + r.RequestUri.ToString().Contains(query)), + ItExpr.IsAny()); + } + + #endregion + + #region Helper Methods + + private void SetupHttpResponse(HttpStatusCode statusCode, T? content) + { + var response = new HttpResponseMessage(statusCode); + + if (content != null) + { + response.Content = JsonContent.Create(content, options: JsonOptions); + } + + _mockHttpHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response); + } + + private static CompaniesHouseSearchResponseDto CreateSearchResponse( + IEnumerable items) + { + var itemList = items.ToList(); + return new CompaniesHouseSearchResponseDto + { + TotalResults = itemList.Count, + ItemsPerPage = 20, + StartIndex = 0, + Items = itemList + }; + } + + private static CompaniesHouseSearchItemDto CreateSearchItem( + string companyNumber, + string title, + string? status, + string? dateOfCreation = null, + string? addressSnippet = null) + { + return new CompaniesHouseSearchItemDto + { + CompanyNumber = companyNumber, + Title = title, + CompanyStatus = status, + DateOfCreation = dateOfCreation, + AddressSnippet = addressSnippet + }; + } + + #endregion + + #region Test DTOs (matching API response format with snake_case) + + /// + /// DTO for test serialization to match Companies House API snake_case format + /// + private sealed record CompaniesHouseSearchResponseDto + { + public int TotalResults { get; init; } + public int ItemsPerPage { get; init; } + public int StartIndex { get; init; } + public List Items { get; init; } = []; + } + + private sealed record CompaniesHouseSearchItemDto + { + public required string CompanyNumber { get; init; } + public required string Title { get; init; } + public string? CompanyStatus { get; init; } + public string? CompanyType { get; init; } + public string? DateOfCreation { get; init; } + public string? DateOfCessation { get; init; } + public string? AddressSnippet { get; init; } + } + + #endregion +} diff --git a/tests/TrueCV.Tests/Services/TimelineAnalyserServiceTests.cs b/tests/TrueCV.Tests/Services/TimelineAnalyserServiceTests.cs new file mode 100644 index 0000000..52e72d0 --- /dev/null +++ b/tests/TrueCV.Tests/Services/TimelineAnalyserServiceTests.cs @@ -0,0 +1,581 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using TrueCV.Application.Models; +using TrueCV.Infrastructure.Services; + +namespace TrueCV.Tests.Services; + +public class TimelineAnalyserServiceTests +{ + private readonly TimelineAnalyserService _sut; + private readonly Mock> _loggerMock; + + public TimelineAnalyserServiceTests() + { + _loggerMock = new Mock>(); + _sut = new TimelineAnalyserService(_loggerMock.Object); + } + + #region Empty and Single Entry Tests + + [Fact] + public void Analyse_WithEmptyEmploymentList_ReturnsEmptyResults() + { + // Arrange + var employmentHistory = new List(); + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + result.TotalGapMonths.Should().Be(0); + result.TotalOverlapMonths.Should().Be(0); + result.Gaps.Should().BeEmpty(); + result.Overlaps.Should().BeEmpty(); + } + + [Fact] + public void Analyse_WithSingleEmploymentEntry_ReturnsNoGapsOrOverlaps() + { + // Arrange + var employmentHistory = new List + { + CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2021, 12, 31)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + result.TotalGapMonths.Should().Be(0); + result.TotalOverlapMonths.Should().Be(0); + result.Gaps.Should().BeEmpty(); + result.Overlaps.Should().BeEmpty(); + } + + #endregion + + #region Gap Detection Tests + + [Fact] + public void Analyse_WithTwoConsecutiveJobs_ReturnsNoGaps() + { + // Arrange + var employmentHistory = new List + { + CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 12, 31)), + CreateEmployment("Company B", new DateOnly(2021, 1, 1), new DateOnly(2021, 12, 31)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + result.TotalGapMonths.Should().Be(0); + result.Gaps.Should().BeEmpty(); + } + + [Fact] + public void Analyse_WithThreeMonthGap_DetectsGapCorrectly() + { + // Arrange + var employmentHistory = new List + { + CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 6, 30)), + CreateEmployment("Company B", new DateOnly(2020, 10, 1), new DateOnly(2021, 12, 31)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + result.Gaps.Should().HaveCount(1); + result.Gaps[0].StartDate.Should().Be(new DateOnly(2020, 6, 30)); + result.Gaps[0].EndDate.Should().Be(new DateOnly(2020, 10, 1)); + result.Gaps[0].Months.Should().BeGreaterThanOrEqualTo(3); + result.TotalGapMonths.Should().BeGreaterThanOrEqualTo(3); + } + + [Fact] + public void Analyse_WithLargeGap_DetectsGapWithCorrectMonths() + { + // Arrange - 6 month gap + var employmentHistory = new List + { + CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 6, 15)), + CreateEmployment("Company B", new DateOnly(2021, 1, 15), new DateOnly(2021, 12, 31)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + result.Gaps.Should().HaveCount(1); + result.TotalGapMonths.Should().BeGreaterThanOrEqualTo(6); + } + + [Fact] + public void Analyse_WithSmallGapUnderThreeMonths_DoesNotFlagIt() + { + // Arrange - 2 month gap (should not be flagged) + var employmentHistory = new List + { + CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 6, 30)), + CreateEmployment("Company B", new DateOnly(2020, 8, 15), new DateOnly(2021, 12, 31)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + result.Gaps.Should().BeEmpty(); + result.TotalGapMonths.Should().Be(0); + } + + [Fact] + public void Analyse_WithOneMonthGap_DoesNotFlagIt() + { + // Arrange - 1 month gap + var employmentHistory = new List + { + CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 6, 30)), + CreateEmployment("Company B", new DateOnly(2020, 8, 1), new DateOnly(2021, 12, 31)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + result.Gaps.Should().BeEmpty(); + result.TotalGapMonths.Should().Be(0); + } + + #endregion + + #region Overlap Detection Tests + + [Fact] + public void Analyse_WithOverlappingJobsMoreThanTwoMonths_DetectsOverlap() + { + // Arrange - 4 month overlap + var employmentHistory = new List + { + CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 8, 31)), + CreateEmployment("Company B", new DateOnly(2020, 5, 1), new DateOnly(2021, 12, 31)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + result.Overlaps.Should().HaveCount(1); + result.Overlaps[0].Company1.Should().Be("Company A"); + result.Overlaps[0].Company2.Should().Be("Company B"); + result.Overlaps[0].Months.Should().BeGreaterThan(2); + result.TotalOverlapMonths.Should().BeGreaterThan(2); + } + + [Fact] + public void Analyse_WithOneMonthTransitionOverlap_DoesNotFlagIt() + { + // Arrange - 1 month overlap (transition period, should be allowed) + var employmentHistory = new List + { + CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 6, 15)), + CreateEmployment("Company B", new DateOnly(2020, 6, 1), new DateOnly(2021, 12, 31)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + result.Overlaps.Should().BeEmpty(); + result.TotalOverlapMonths.Should().Be(0); + } + + [Fact] + public void Analyse_WithTwoMonthTransitionOverlap_DoesNotFlagIt() + { + // Arrange - 2 month overlap (transition period, should be allowed) + var employmentHistory = new List + { + CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 7, 31)), + CreateEmployment("Company B", new DateOnly(2020, 6, 1), new DateOnly(2021, 12, 31)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + result.Overlaps.Should().BeEmpty(); + result.TotalOverlapMonths.Should().Be(0); + } + + [Fact] + public void Analyse_WithThreeMonthOverlap_DetectsOverlap() + { + // Arrange - 3 month overlap (exceeds 2 month allowed period) + var employmentHistory = new List + { + CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 8, 31)), + CreateEmployment("Company B", new DateOnly(2020, 6, 1), new DateOnly(2021, 12, 31)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + result.Overlaps.Should().HaveCount(1); + result.Overlaps[0].Months.Should().BeGreaterThan(2); + } + + #endregion + + #region Mixed Scenarios Tests + + [Fact] + public void Analyse_WithMultipleJobsMixedGapsAndOverlaps_DetectsAll() + { + // Arrange + var employmentHistory = new List + { + // Job 1: Jan 2019 - Dec 2019 + CreateEmployment("Company A", new DateOnly(2019, 1, 1), new DateOnly(2019, 12, 31)), + // Job 2: June 2020 - Dec 2020 (6 month gap after Job 1 - should be detected) + CreateEmployment("Company B", new DateOnly(2020, 6, 1), new DateOnly(2020, 12, 31)), + // Job 3: Sep 2020 - June 2021 (4 month overlap with Job 2 - should be detected) + CreateEmployment("Company C", new DateOnly(2020, 9, 1), new DateOnly(2021, 6, 30)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + result.Gaps.Should().HaveCount(1); + result.Gaps[0].StartDate.Should().Be(new DateOnly(2019, 12, 31)); + result.Gaps[0].EndDate.Should().Be(new DateOnly(2020, 6, 1)); + + result.Overlaps.Should().HaveCount(1); + result.Overlaps[0].Company1.Should().Be("Company B"); + result.Overlaps[0].Company2.Should().Be("Company C"); + } + + [Fact] + public void Analyse_WithMultipleGaps_SumsTotalGapMonths() + { + // Arrange + var employmentHistory = new List + { + CreateEmployment("Company A", new DateOnly(2019, 1, 1), new DateOnly(2019, 6, 30)), + // 4 month gap + CreateEmployment("Company B", new DateOnly(2019, 11, 1), new DateOnly(2020, 4, 30)), + // 5 month gap + CreateEmployment("Company C", new DateOnly(2020, 10, 1), new DateOnly(2021, 12, 31)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + result.Gaps.Should().HaveCount(2); + result.TotalGapMonths.Should().BeGreaterThanOrEqualTo(8); + } + + [Fact] + public void Analyse_WithMultipleOverlaps_SumsTotalOverlapMonths() + { + // Arrange + var employmentHistory = new List + { + CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 9, 30)), + CreateEmployment("Company B", new DateOnly(2020, 5, 1), new DateOnly(2021, 3, 31)), + CreateEmployment("Company C", new DateOnly(2020, 12, 1), new DateOnly(2021, 12, 31)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + result.Overlaps.Should().HaveCountGreaterThan(0); + result.TotalOverlapMonths.Should().BeGreaterThan(0); + } + + #endregion + + #region Null Date Handling Tests + + [Fact] + public void Analyse_WithAllNullStartDates_ReturnsEmptyResults() + { + // Arrange + var employmentHistory = new List + { + CreateEmployment("Company A", null, new DateOnly(2020, 12, 31)), + CreateEmployment("Company B", null, new DateOnly(2021, 12, 31)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + result.TotalGapMonths.Should().Be(0); + result.TotalOverlapMonths.Should().Be(0); + result.Gaps.Should().BeEmpty(); + result.Overlaps.Should().BeEmpty(); + } + + [Fact] + public void Analyse_WithSomeNullStartDates_FiltersThemOut() + { + // Arrange + var employmentHistory = new List + { + CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 6, 30)), + CreateEmployment("Company B", null, new DateOnly(2020, 12, 31)), + CreateEmployment("Company C", new DateOnly(2021, 1, 1), new DateOnly(2021, 12, 31)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + // Company B is filtered out, gap between A and C is 6 months + result.Gaps.Should().HaveCount(1); + result.Gaps[0].Months.Should().BeGreaterThanOrEqualTo(6); + } + + [Fact] + public void Analyse_WithMixedNullDates_HandlesGracefully() + { + // Arrange + var employmentHistory = new List + { + CreateEmployment("Company A", null, null), + CreateEmployment("Company B", new DateOnly(2020, 1, 1), new DateOnly(2020, 12, 31)), + CreateEmployment("Company C", null, new DateOnly(2021, 6, 30)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert - only Company B has valid start date, so no gaps/overlaps possible + result.Gaps.Should().BeEmpty(); + result.Overlaps.Should().BeEmpty(); + } + + #endregion + + #region Current Job (No End Date) Tests + + [Fact] + public void Analyse_WithCurrentJobNoEndDate_UsesTodayAsEndDate() + { + // Arrange + var employmentHistory = new List + { + CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 6, 30)), + CreateCurrentEmployment("Company B", new DateOnly(2020, 7, 1)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + result.Gaps.Should().BeEmpty(); + result.TotalGapMonths.Should().Be(0); + } + + [Fact] + public void Analyse_WithCurrentJobOverlappingPastJob_DetectsOverlap() + { + // Arrange - Current job started while still at previous job + var employmentHistory = new List + { + CreateEmployment("Company A", new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31)), + CreateCurrentEmployment("Company B", new DateOnly(2024, 6, 1)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + result.Overlaps.Should().HaveCount(1); + result.Overlaps[0].Company1.Should().Be("Company A"); + result.Overlaps[0].Company2.Should().Be("Company B"); + result.Overlaps[0].Months.Should().BeGreaterThan(2); + } + + [Fact] + public void Analyse_WithTwoCurrentJobs_DetectsLargeOverlap() + { + // Arrange - Two concurrent current positions (unusual but valid scenario) + var employmentHistory = new List + { + CreateCurrentEmployment("Company A", new DateOnly(2023, 1, 1)), + CreateCurrentEmployment("Company B", new DateOnly(2023, 6, 1)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert - Should detect substantial overlap since both run to today + result.Overlaps.Should().HaveCount(1); + result.TotalOverlapMonths.Should().BeGreaterThan(2); + } + + [Fact] + public void Analyse_WithGapBeforeCurrentJob_DetectsGap() + { + // Arrange + var employmentHistory = new List + { + CreateEmployment("Company A", new DateOnly(2023, 1, 1), new DateOnly(2023, 6, 30)), + CreateCurrentEmployment("Company B", new DateOnly(2024, 1, 1)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + result.Gaps.Should().HaveCount(1); + result.Gaps[0].Months.Should().BeGreaterThanOrEqualTo(6); + } + + #endregion + + #region Edge Cases and Ordering Tests + + [Fact] + public void Analyse_WithUnsortedEmployment_SortsByStartDate() + { + // Arrange - Jobs provided out of order + var employmentHistory = new List + { + CreateEmployment("Company C", new DateOnly(2022, 1, 1), new DateOnly(2022, 12, 31)), + CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 6, 30)), + CreateEmployment("Company B", new DateOnly(2021, 1, 1), new DateOnly(2021, 12, 31)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert - Should detect gap between A (ends June 2020) and B (starts Jan 2021) + result.Gaps.Should().HaveCount(1); + result.Gaps[0].StartDate.Should().Be(new DateOnly(2020, 6, 30)); + result.Gaps[0].EndDate.Should().Be(new DateOnly(2021, 1, 1)); + } + + [Fact] + public void Analyse_WithSameStartDate_HandlesCorrectly() + { + // Arrange - Two jobs starting on same date + var employmentHistory = new List + { + CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 12, 31)), + CreateEmployment("Company B", new DateOnly(2020, 1, 1), new DateOnly(2020, 8, 31)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert - Should detect overlap + result.Overlaps.Should().HaveCount(1); + } + + [Fact] + public void Analyse_WithNullList_ThrowsArgumentNullException() + { + // Arrange + List? employmentHistory = null; + + // Act + var act = () => _sut.Analyse(employmentHistory!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Analyse_WithExactlyThreeMonthGap_DetectsGap() + { + // Arrange - Exactly 3 month gap (boundary condition) + var employmentHistory = new List + { + CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 3, 31)), + CreateEmployment("Company B", new DateOnly(2020, 7, 1), new DateOnly(2021, 12, 31)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + result.Gaps.Should().HaveCount(1); + result.Gaps[0].Months.Should().BeGreaterThanOrEqualTo(3); + } + + [Fact] + public void Analyse_WithJobEndingSameDayNextStarts_NoGap() + { + // Arrange - Seamless transition + var employmentHistory = new List + { + CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 6, 30)), + CreateEmployment("Company B", new DateOnly(2020, 6, 30), new DateOnly(2021, 12, 31)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + result.Gaps.Should().BeEmpty(); + } + + [Fact] + public void Analyse_WithJobEndingAfterNextStarts_DetectsOverlapIfSignificant() + { + // Arrange - Job ends after next starts by more than 2 months + var employmentHistory = new List + { + CreateEmployment("Company A", new DateOnly(2020, 1, 1), new DateOnly(2020, 10, 31)), + CreateEmployment("Company B", new DateOnly(2020, 6, 1), new DateOnly(2021, 12, 31)) + }; + + // Act + var result = _sut.Analyse(employmentHistory); + + // Assert + result.Overlaps.Should().HaveCount(1); + result.Overlaps[0].Months.Should().BeGreaterThan(2); + } + + #endregion + + #region Helper Methods + + private static EmploymentEntry CreateEmployment(string companyName, DateOnly? startDate, DateOnly? endDate) + { + return new EmploymentEntry + { + CompanyName = companyName, + JobTitle = "Software Developer", + StartDate = startDate, + EndDate = endDate, + IsCurrent = false + }; + } + + private static EmploymentEntry CreateCurrentEmployment(string companyName, DateOnly startDate) + { + return new EmploymentEntry + { + CompanyName = companyName, + JobTitle = "Software Developer", + StartDate = startDate, + EndDate = null, + IsCurrent = true + }; + } + + #endregion +} diff --git a/tests/TrueCV.Tests/TrueCV.Tests.csproj b/tests/TrueCV.Tests/TrueCV.Tests.csproj new file mode 100644 index 0000000..ab6facd --- /dev/null +++ b/tests/TrueCV.Tests/TrueCV.Tests.csproj @@ -0,0 +1,34 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + +