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

Uses xUnit, Moq, FluentAssertions, EF Core InMemory

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

584 lines
18 KiB
C#

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
}