Files
RealCV/tests/TrueCV.Tests/Jobs/ProcessCVCheckJobTests.cs

1122 lines
37 KiB
C#
Raw Normal View History

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