Add comprehensive unit test suite
Test project with 143 tests covering: - TimelineAnalyserService (27 tests): gap/overlap detection, edge cases - CVParserService (35 tests): file parsing, extension handling, API calls - CompanyVerifierService (23 tests): verification, caching, fuzzy matching - CVCheckService (24 tests): CRUD operations, file upload, job queuing - ProcessCVCheckJob (34 tests): full workflow, scoring algorithm, flags Uses xUnit, Moq, FluentAssertions, EF Core InMemory Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrueCV.Infrastructure", "sr
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrueCV.Web", "src\TrueCV.Web\TrueCV.Web.csproj", "{D69F57DB-3092-48AF-81BB-868E3749C638}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrueCV.Web", "src\TrueCV.Web\TrueCV.Web.csproj", "{D69F57DB-3092-48AF-81BB-868E3749C638}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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}.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.ActiveCfg = Release|Any CPU
|
||||||
{D69F57DB-3092-48AF-81BB-868E3749C638}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{41AC48AF-09BC-48D1-9CA4-1B05D3B693F0} = {F25C3740-9240-46DF-BC34-985BC577216B}
|
{41AC48AF-09BC-48D1-9CA4-1B05D3B693F0} = {F25C3740-9240-46DF-BC34-985BC577216B}
|
||||||
{A8A1BA81-3B2F-4F95-BB15-ACA40DF2A70E} = {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}
|
{03DB607C-9592-4930-8C89-3E257A319278} = {F25C3740-9240-46DF-BC34-985BC577216B}
|
||||||
{D69F57DB-3092-48AF-81BB-868E3749C638} = {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
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
1
tests/TrueCV.Tests/GlobalUsings.cs
Normal file
1
tests/TrueCV.Tests/GlobalUsings.cs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
global using Xunit;
|
||||||
1091
tests/TrueCV.Tests/Jobs/ProcessCVCheckJobTests.cs
Normal file
1091
tests/TrueCV.Tests/Jobs/ProcessCVCheckJobTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
717
tests/TrueCV.Tests/Services/CVCheckServiceTests.cs
Normal file
717
tests/TrueCV.Tests/Services/CVCheckServiceTests.cs
Normal file
@@ -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<IFileStorageService> _fileStorageServiceMock;
|
||||||
|
private readonly Mock<IBackgroundJobClient> _backgroundJobClientMock;
|
||||||
|
private readonly Mock<ILogger<CVCheckService>> _loggerMock;
|
||||||
|
private readonly CVCheckService _sut;
|
||||||
|
|
||||||
|
public CVCheckServiceTests()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
_dbContext = new ApplicationDbContext(options);
|
||||||
|
_fileStorageServiceMock = new Mock<IFileStorageService>();
|
||||||
|
_backgroundJobClientMock = new Mock<IBackgroundJobClient>();
|
||||||
|
_loggerMock = new Mock<ILogger<CVCheckService>>();
|
||||||
|
|
||||||
|
_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<Stream>(), 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<Stream>(), It.IsAny<string>()))
|
||||||
|
.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 =>
|
||||||
|
job.Type == typeof(ProcessCVCheckJob) &&
|
||||||
|
job.Method.Name == nameof(ProcessCVCheckJob.ExecuteAsync)),
|
||||||
|
It.IsAny<EnqueuedState>()),
|
||||||
|
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<Stream>(), It.IsAny<string>()))
|
||||||
|
.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<ArgumentNullException>()
|
||||||
|
.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<ArgumentException>()
|
||||||
|
.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
|
||||||
|
}
|
||||||
694
tests/TrueCV.Tests/Services/CVParserServiceTests.cs
Normal file
694
tests/TrueCV.Tests/Services/CVParserServiceTests.cs
Normal file
@@ -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<ILogger<CVParserService>> _loggerMock;
|
||||||
|
private readonly Mock<IOptions<AnthropicSettings>> _settingsMock;
|
||||||
|
private readonly CVParserService _sut;
|
||||||
|
|
||||||
|
public CVParserServiceTests()
|
||||||
|
{
|
||||||
|
_loggerMock = new Mock<ILogger<CVParserService>>();
|
||||||
|
_settingsMock = new Mock<IOptions<AnthropicSettings>>();
|
||||||
|
_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<NotSupportedException>()
|
||||||
|
.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<NotSupportedException>()
|
||||||
|
.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<NotSupportedException>("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<NotSupportedException>("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<NotSupportedException>("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<NotSupportedException>("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<ArgumentNullException>()
|
||||||
|
.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<ArgumentException>()
|
||||||
|
.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<InvalidOperationException>()
|
||||||
|
.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<InvalidOperationException>()
|
||||||
|
.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<Exception>();
|
||||||
|
|
||||||
|
// Verify we got past text extraction (would throw InvalidOperationException with "Could not extract")
|
||||||
|
exception.Which.Should().NotBeOfType<NotSupportedException>();
|
||||||
|
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>();
|
||||||
|
|
||||||
|
exception.Which.Should().NotBeOfType<NotSupportedException>();
|
||||||
|
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<Exception>();
|
||||||
|
|
||||||
|
// Should NOT be our domain exceptions (text extraction worked)
|
||||||
|
exception.Which.Message.Should().NotContain("Could not extract text content");
|
||||||
|
exception.Which.Should().NotBeOfType<NotSupportedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<Exception>();
|
||||||
|
|
||||||
|
// 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<EventId>(),
|
||||||
|
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Sending CV text to Claude API")),
|
||||||
|
It.IsAny<Exception?>(),
|
||||||
|
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||||
|
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<EventId>(),
|
||||||
|
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Parsing CV file")),
|
||||||
|
It.IsAny<Exception?>(),
|
||||||
|
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||||
|
Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Helper Methods
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a minimal valid PDF with no text content.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a minimal valid PDF with text content.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a minimal valid DOCX with no text content.
|
||||||
|
/// </summary>
|
||||||
|
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(@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
|
||||||
|
<Types xmlns=""http://schemas.openxmlformats.org/package/2006/content-types"">
|
||||||
|
<Default Extension=""rels"" ContentType=""application/vnd.openxmlformats-package.relationships+xml""/>
|
||||||
|
<Default Extension=""xml"" ContentType=""application/xml""/>
|
||||||
|
<Override PartName=""/word/document.xml"" ContentType=""application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml""/>
|
||||||
|
</Types>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// _rels/.rels
|
||||||
|
var relsEntry = archive.CreateEntry("_rels/.rels");
|
||||||
|
using (var writer = new StreamWriter(relsEntry.Open()))
|
||||||
|
{
|
||||||
|
writer.Write(@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
|
||||||
|
<Relationships xmlns=""http://schemas.openxmlformats.org/package/2006/relationships"">
|
||||||
|
<Relationship Id=""rId1"" Type=""http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"" Target=""word/document.xml""/>
|
||||||
|
</Relationships>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// word/document.xml (empty body)
|
||||||
|
var documentEntry = archive.CreateEntry("word/document.xml");
|
||||||
|
using (var writer = new StreamWriter(documentEntry.Open()))
|
||||||
|
{
|
||||||
|
writer.Write(@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
|
||||||
|
<w:document xmlns:w=""http://schemas.openxmlformats.org/wordprocessingml/2006/main"">
|
||||||
|
<w:body>
|
||||||
|
</w:body>
|
||||||
|
</w:document>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return memoryStream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a minimal valid DOCX with text content.
|
||||||
|
/// </summary>
|
||||||
|
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(@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
|
||||||
|
<Types xmlns=""http://schemas.openxmlformats.org/package/2006/content-types"">
|
||||||
|
<Default Extension=""rels"" ContentType=""application/vnd.openxmlformats-package.relationships+xml""/>
|
||||||
|
<Default Extension=""xml"" ContentType=""application/xml""/>
|
||||||
|
<Override PartName=""/word/document.xml"" ContentType=""application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml""/>
|
||||||
|
</Types>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// _rels/.rels
|
||||||
|
var relsEntry = archive.CreateEntry("_rels/.rels");
|
||||||
|
using (var writer = new StreamWriter(relsEntry.Open()))
|
||||||
|
{
|
||||||
|
writer.Write(@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
|
||||||
|
<Relationships xmlns=""http://schemas.openxmlformats.org/package/2006/relationships"">
|
||||||
|
<Relationship Id=""rId1"" Type=""http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"" Target=""word/document.xml""/>
|
||||||
|
</Relationships>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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($@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
|
||||||
|
<w:document xmlns:w=""http://schemas.openxmlformats.org/wordprocessingml/2006/main"">
|
||||||
|
<w:body>
|
||||||
|
<w:p>
|
||||||
|
<w:r>
|
||||||
|
<w:t>{escapedText}</w:t>
|
||||||
|
</w:r>
|
||||||
|
</w:p>
|
||||||
|
</w:body>
|
||||||
|
</w:document>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return memoryStream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for CVData model mapping that can be tested in isolation.
|
||||||
|
/// These tests verify the expected structure and constraints of the parsed CV data.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
583
tests/TrueCV.Tests/Services/CompanyVerifierServiceTests.cs
Normal file
583
tests/TrueCV.Tests/Services/CompanyVerifierServiceTests.cs
Normal file
@@ -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<HttpMessageHandler> _mockHttpHandler;
|
||||||
|
private readonly Mock<ILogger<CompanyVerifierService>> _mockServiceLogger;
|
||||||
|
private readonly Mock<ILogger<CompaniesHouseClient>> _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<HttpMessageHandler>();
|
||||||
|
_mockServiceLogger = new Mock<ILogger<CompanyVerifierService>>();
|
||||||
|
_mockClientLogger = new Mock<ILogger<CompaniesHouseClient>>();
|
||||||
|
|
||||||
|
_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<ApplicationDbContext>()
|
||||||
|
.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<HttpRequestMessage>(),
|
||||||
|
ItExpr.IsAny<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<HttpRequestMessage>(r => r.RequestUri!.ToString().Contains("search/companies")),
|
||||||
|
ItExpr.IsAny<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task VerifyCompanyAsync_NoMatchingCompany_ReturnsNotVerified()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string companyName = "NonExistent Company Ltd";
|
||||||
|
var searchResponse = CreateSearchResponse(Array.Empty<CompaniesHouseSearchItemDto>());
|
||||||
|
|
||||||
|
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<object>(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<ArgumentException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<CompaniesHouseSearchItemDto>());
|
||||||
|
|
||||||
|
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<ArgumentException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<CompaniesHouseSearchItemDto>());
|
||||||
|
|
||||||
|
SetupHttpResponse(HttpStatusCode.OK, searchResponse);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _sut.SearchCompaniesAsync(query);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
_mockHttpHandler.Protected().Verify(
|
||||||
|
"SendAsync",
|
||||||
|
Times.Once(),
|
||||||
|
ItExpr.Is<HttpRequestMessage>(r =>
|
||||||
|
r.Method == HttpMethod.Get &&
|
||||||
|
r.RequestUri!.ToString().Contains("search/companies") &&
|
||||||
|
r.RequestUri.ToString().Contains(query)),
|
||||||
|
ItExpr.IsAny<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Helper Methods
|
||||||
|
|
||||||
|
private void SetupHttpResponse<T>(HttpStatusCode statusCode, T? content)
|
||||||
|
{
|
||||||
|
var response = new HttpResponseMessage(statusCode);
|
||||||
|
|
||||||
|
if (content != null)
|
||||||
|
{
|
||||||
|
response.Content = JsonContent.Create(content, options: JsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
_mockHttpHandler
|
||||||
|
.Protected()
|
||||||
|
.Setup<Task<HttpResponseMessage>>(
|
||||||
|
"SendAsync",
|
||||||
|
ItExpr.IsAny<HttpRequestMessage>(),
|
||||||
|
ItExpr.IsAny<CancellationToken>())
|
||||||
|
.ReturnsAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CompaniesHouseSearchResponseDto CreateSearchResponse(
|
||||||
|
IEnumerable<CompaniesHouseSearchItemDto> 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)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for test serialization to match Companies House API snake_case format
|
||||||
|
/// </summary>
|
||||||
|
private sealed record CompaniesHouseSearchResponseDto
|
||||||
|
{
|
||||||
|
public int TotalResults { get; init; }
|
||||||
|
public int ItemsPerPage { get; init; }
|
||||||
|
public int StartIndex { get; init; }
|
||||||
|
public List<CompaniesHouseSearchItemDto> 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
|
||||||
|
}
|
||||||
581
tests/TrueCV.Tests/Services/TimelineAnalyserServiceTests.cs
Normal file
581
tests/TrueCV.Tests/Services/TimelineAnalyserServiceTests.cs
Normal file
@@ -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<ILogger<TimelineAnalyserService>> _loggerMock;
|
||||||
|
|
||||||
|
public TimelineAnalyserServiceTests()
|
||||||
|
{
|
||||||
|
_loggerMock = new Mock<ILogger<TimelineAnalyserService>>();
|
||||||
|
_sut = new TimelineAnalyserService(_loggerMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Empty and Single Entry Tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyse_WithEmptyEmploymentList_ReturnsEmptyResults()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var employmentHistory = new List<EmploymentEntry>();
|
||||||
|
|
||||||
|
// 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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
// 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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>? employmentHistory = null;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var act = () => _sut.Analyse(employmentHistory!);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
act.Should().Throw<ArgumentNullException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyse_WithExactlyThreeMonthGap_DetectsGap()
|
||||||
|
{
|
||||||
|
// Arrange - Exactly 3 month gap (boundary condition)
|
||||||
|
var employmentHistory = new List<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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<EmploymentEntry>
|
||||||
|
{
|
||||||
|
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
|
||||||
|
}
|
||||||
34
tests/TrueCV.Tests/TrueCV.Tests.csproj
Normal file
34
tests/TrueCV.Tests/TrueCV.Tests.csproj
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FluentAssertions" Version="8.8.0" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.*" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
|
||||||
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
|
<PackageReference Include="xunit" Version="2.4.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.0">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\TrueCV.Infrastructure\TrueCV.Infrastructure.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\TrueCV.Application\TrueCV.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\TrueCV.Domain\TrueCV.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user