using System.Net; using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; using Moq.Protected; using TrueCV.Domain.Entities; using TrueCV.Infrastructure.Configuration; using TrueCV.Infrastructure.Data; using TrueCV.Infrastructure.ExternalApis; using TrueCV.Infrastructure.Services; namespace TrueCV.Tests.Services; public class CompanyVerifierServiceTests : IDisposable { private readonly Mock _mockHttpHandler; private readonly Mock> _mockServiceLogger; private readonly Mock> _mockClientLogger; private readonly ApplicationDbContext _dbContext; private readonly CompanyVerifierService _sut; private readonly HttpClient _httpClient; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNameCaseInsensitive = true }; public CompanyVerifierServiceTests() { _mockHttpHandler = new Mock(); _mockServiceLogger = new Mock>(); _mockClientLogger = new Mock>(); _httpClient = new HttpClient(_mockHttpHandler.Object); var settings = Options.Create(new CompaniesHouseSettings { ApiKey = "test-api-key", BaseUrl = "https://api.company-information.service.gov.uk" }); var client = new CompaniesHouseClient(_httpClient, settings, _mockClientLogger.Object); var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; _dbContext = new ApplicationDbContext(options); _sut = new CompanyVerifierService(client, _dbContext, _mockServiceLogger.Object); } public void Dispose() { _httpClient.Dispose(); _dbContext.Dispose(); GC.SuppressFinalize(this); } #region VerifyCompanyAsync Tests [Fact] public async Task VerifyCompanyAsync_ExactMatch_ReturnsVerifiedWith100PercentScore() { // Arrange const string companyName = "ACME Corporation Ltd"; var searchResponse = CreateSearchResponse(new[] { CreateSearchItem("12345678", "ACME Corporation Ltd", "active") }); SetupHttpResponse(HttpStatusCode.OK, searchResponse); // Act var result = await _sut.VerifyCompanyAsync(companyName, null, null); // Assert result.IsVerified.Should().BeTrue(); result.MatchScore.Should().Be(100); result.MatchedCompanyName.Should().Be("ACME Corporation Ltd"); result.MatchedCompanyNumber.Should().Be("12345678"); result.ClaimedCompany.Should().Be(companyName); } [Fact] public async Task VerifyCompanyAsync_FuzzyMatchAboveThreshold_ReturnsVerified() { // Arrange // Using names that will produce a match score above 70% threshold const string claimedName = "ACME Corporation"; const string actualName = "ACME Corporation Ltd"; var searchResponse = CreateSearchResponse(new[] { CreateSearchItem("12345678", actualName, "active") }); SetupHttpResponse(HttpStatusCode.OK, searchResponse); // Act var result = await _sut.VerifyCompanyAsync(claimedName, null, null); // Assert result.IsVerified.Should().BeTrue(); result.MatchScore.Should().BeGreaterThanOrEqualTo(70); result.MatchedCompanyName.Should().Be(actualName); } [Fact] public async Task VerifyCompanyAsync_FuzzyMatchBelowThreshold_ReturnsNotVerified() { // Arrange const string claimedName = "Totally Different Company"; const string actualName = "ACME Corporation Ltd"; var searchResponse = CreateSearchResponse(new[] { CreateSearchItem("12345678", actualName, "active") }); SetupHttpResponse(HttpStatusCode.OK, searchResponse); // Act var result = await _sut.VerifyCompanyAsync(claimedName, null, null); // Assert result.IsVerified.Should().BeFalse(); result.MatchScore.Should().Be(0); result.MatchedCompanyName.Should().BeNull(); result.VerificationNotes.Should().Contain("70%"); } [Fact] public async Task VerifyCompanyAsync_CachedCompany_ReturnsCachedWithoutApiCall() { // Arrange const string companyName = "Cached Company Ltd"; var cachedCompany = new CompanyCache { CompanyNumber = "99999999", CompanyName = "Cached Company Ltd", Status = "active", CachedAt = DateTime.UtcNow.AddDays(-5) // Within 30-day window }; _dbContext.CompanyCache.Add(cachedCompany); await _dbContext.SaveChangesAsync(); // Act var result = await _sut.VerifyCompanyAsync(companyName, null, null); // Assert result.IsVerified.Should().BeTrue(); result.MatchedCompanyNumber.Should().Be("99999999"); result.VerificationNotes.Should().Contain("cache"); // Verify API was NOT called (no HTTP setup means it would fail if called) _mockHttpHandler.Protected().Verify( "SendAsync", Times.Never(), ItExpr.IsAny(), ItExpr.IsAny()); } [Fact] public async Task VerifyCompanyAsync_ExpiredCache_TriggersNewApiCall() { // Arrange const string companyName = "Expired Company Ltd"; var expiredCachedCompany = new CompanyCache { CompanyNumber = "88888888", CompanyName = "Expired Company Ltd", Status = "active", CachedAt = DateTime.UtcNow.AddDays(-35) // Beyond 30-day window }; _dbContext.CompanyCache.Add(expiredCachedCompany); await _dbContext.SaveChangesAsync(); var searchResponse = CreateSearchResponse(new[] { CreateSearchItem("12345678", "Expired Company Ltd", "active") }); SetupHttpResponse(HttpStatusCode.OK, searchResponse); // Act var result = await _sut.VerifyCompanyAsync(companyName, null, null); // Assert result.IsVerified.Should().BeTrue(); result.MatchedCompanyNumber.Should().Be("12345678"); // From API, not cache // Verify API WAS called _mockHttpHandler.Protected().Verify( "SendAsync", Times.Once(), ItExpr.Is(r => r.RequestUri!.ToString().Contains("search/companies")), ItExpr.IsAny()); } [Fact] public async Task VerifyCompanyAsync_NoMatchingCompany_ReturnsNotVerified() { // Arrange const string companyName = "NonExistent Company Ltd"; var searchResponse = CreateSearchResponse(Array.Empty()); SetupHttpResponse(HttpStatusCode.OK, searchResponse); // Act var result = await _sut.VerifyCompanyAsync(companyName, null, null); // Assert result.IsVerified.Should().BeFalse(); result.MatchScore.Should().Be(0); result.MatchedCompanyName.Should().BeNull(); result.VerificationNotes.Should().Contain("No matching company"); } [Fact] public async Task VerifyCompanyAsync_ApiRateLimitError_ReturnsNotVerifiedWithAppropriateMessage() { // Arrange const string companyName = "Rate Limited Company"; SetupHttpResponse(HttpStatusCode.TooManyRequests, null); // Act var result = await _sut.VerifyCompanyAsync(companyName, null, null); // Assert result.IsVerified.Should().BeFalse(); result.MatchScore.Should().Be(0); result.VerificationNotes.Should().Contain("rate limiting"); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public async Task VerifyCompanyAsync_EmptyOrNullCompanyName_ThrowsArgumentException(string? companyName) { // Act var act = () => _sut.VerifyCompanyAsync(companyName!, null, null); // Assert await act.Should().ThrowAsync(); } [Fact] public async Task VerifyCompanyAsync_WithDates_IncludesDatesInResult() { // Arrange const string companyName = "Dated Company Ltd"; var startDate = new DateOnly(2020, 1, 15); var endDate = new DateOnly(2023, 6, 30); var searchResponse = CreateSearchResponse(new[] { CreateSearchItem("12345678", "Dated Company Ltd", "active") }); SetupHttpResponse(HttpStatusCode.OK, searchResponse); // Act var result = await _sut.VerifyCompanyAsync(companyName, startDate, endDate); // Assert result.ClaimedStartDate.Should().Be(startDate); result.ClaimedEndDate.Should().Be(endDate); } [Fact] public async Task VerifyCompanyAsync_MultipleMatches_ReturnsBestMatch() { // Arrange const string companyName = "Tech Solutions Ltd"; var searchResponse = CreateSearchResponse(new[] { CreateSearchItem("11111111", "Tech Solutions International", "active"), CreateSearchItem("22222222", "Tech Solutions Ltd", "active"), CreateSearchItem("33333333", "Old Tech Solutions", "dissolved") }); SetupHttpResponse(HttpStatusCode.OK, searchResponse); // Act var result = await _sut.VerifyCompanyAsync(companyName, null, null); // Assert result.IsVerified.Should().BeTrue(); result.MatchScore.Should().Be(100); result.MatchedCompanyNumber.Should().Be("22222222"); } [Fact] public async Task VerifyCompanyAsync_CachesNewlyVerifiedCompany() { // Arrange const string companyName = "New Cacheable Company Ltd"; var searchResponse = CreateSearchResponse(new[] { CreateSearchItem("77777777", "New Cacheable Company Ltd", "active", "2010-05-15") }); SetupHttpResponse(HttpStatusCode.OK, searchResponse); // Act await _sut.VerifyCompanyAsync(companyName, null, null); // Assert var cachedEntry = await _dbContext.CompanyCache .FirstOrDefaultAsync(c => c.CompanyNumber == "77777777"); cachedEntry.Should().NotBeNull(); cachedEntry!.CompanyName.Should().Be("New Cacheable Company Ltd"); cachedEntry.Status.Should().Be("active"); } [Fact] public async Task VerifyCompanyAsync_UpdatesExistingCacheEntry() { // Arrange const string companyName = "Updated Company Ltd"; var existingCache = new CompanyCache { CompanyNumber = "55555555", CompanyName = "Old Company Name", Status = "active", CachedAt = DateTime.UtcNow.AddDays(-40) }; _dbContext.CompanyCache.Add(existingCache); await _dbContext.SaveChangesAsync(); var searchResponse = CreateSearchResponse(new[] { CreateSearchItem("55555555", "Updated Company Ltd", "dissolved") }); SetupHttpResponse(HttpStatusCode.OK, searchResponse); // Act await _sut.VerifyCompanyAsync(companyName, null, null); // Assert var cachedEntry = await _dbContext.CompanyCache .FirstOrDefaultAsync(c => c.CompanyNumber == "55555555"); cachedEntry.Should().NotBeNull(); cachedEntry!.CompanyName.Should().Be("Updated Company Ltd"); cachedEntry.Status.Should().Be("dissolved"); cachedEntry.CachedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); } [Fact] public async Task VerifyCompanyAsync_CaseInsensitiveMatching_ReturnsVerified() { // Arrange const string claimedName = "acme corporation ltd"; const string actualName = "ACME CORPORATION LTD"; var searchResponse = CreateSearchResponse(new[] { CreateSearchItem("12345678", actualName, "active") }); SetupHttpResponse(HttpStatusCode.OK, searchResponse); // Act var result = await _sut.VerifyCompanyAsync(claimedName, null, null); // Assert result.IsVerified.Should().BeTrue(); result.MatchScore.Should().Be(100); } #endregion #region SearchCompaniesAsync Tests [Fact] public async Task SearchCompaniesAsync_ValidQuery_ReturnsSearchResults() { // Arrange const string query = "Tech"; var searchResponse = CreateSearchResponse(new[] { CreateSearchItem("11111111", "Tech Solutions Ltd", "active", "2015-03-20", "London, UK"), CreateSearchItem("22222222", "Tech Innovations Inc", "active", "2018-07-10", "Manchester, UK") }); SetupHttpResponse(HttpStatusCode.OK, searchResponse); // Act var results = await _sut.SearchCompaniesAsync(query); // Assert results.Should().HaveCount(2); results[0].CompanyNumber.Should().Be("11111111"); results[0].CompanyName.Should().Be("Tech Solutions Ltd"); results[0].CompanyStatus.Should().Be("active"); results[0].IncorporationDate.Should().Be(new DateOnly(2015, 3, 20)); results[0].AddressSnippet.Should().Be("London, UK"); } [Fact] public async Task SearchCompaniesAsync_NoResults_ReturnsEmptyList() { // Arrange const string query = "NonexistentXYZ123"; var searchResponse = CreateSearchResponse(Array.Empty()); SetupHttpResponse(HttpStatusCode.OK, searchResponse); // Act var results = await _sut.SearchCompaniesAsync(query); // Assert results.Should().BeEmpty(); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public async Task SearchCompaniesAsync_EmptyOrNullQuery_ThrowsArgumentException(string? query) { // Act var act = () => _sut.SearchCompaniesAsync(query!); // Assert await act.Should().ThrowAsync(); } [Fact] public async Task SearchCompaniesAsync_NullCompanyStatus_ReturnsUnknownStatus() { // Arrange const string query = "Unknown Status Corp"; var searchResponse = CreateSearchResponse(new[] { CreateSearchItem("12345678", "Unknown Status Corp", null) }); SetupHttpResponse(HttpStatusCode.OK, searchResponse); // Act var results = await _sut.SearchCompaniesAsync(query); // Assert results.Should().HaveCount(1); results[0].CompanyStatus.Should().Be("Unknown"); } [Fact] public async Task SearchCompaniesAsync_InvalidDateFormat_ReturnsNullIncorporationDate() { // Arrange const string query = "Invalid Date Corp"; var searchResponse = CreateSearchResponse(new[] { CreateSearchItem("12345678", "Invalid Date Corp", "active", "invalid-date") }); SetupHttpResponse(HttpStatusCode.OK, searchResponse); // Act var results = await _sut.SearchCompaniesAsync(query); // Assert results.Should().HaveCount(1); results[0].IncorporationDate.Should().BeNull(); } [Fact] public async Task SearchCompaniesAsync_PassesThroughToApiCorrectly() { // Arrange const string query = "TechCompany"; var searchResponse = CreateSearchResponse(Array.Empty()); SetupHttpResponse(HttpStatusCode.OK, searchResponse); // Act await _sut.SearchCompaniesAsync(query); // Assert _mockHttpHandler.Protected().Verify( "SendAsync", Times.Once(), ItExpr.Is(r => r.Method == HttpMethod.Get && r.RequestUri!.ToString().Contains("search/companies") && r.RequestUri.ToString().Contains(query)), ItExpr.IsAny()); } #endregion #region Helper Methods private void SetupHttpResponse(HttpStatusCode statusCode, T? content) { var response = new HttpResponseMessage(statusCode); if (content != null) { response.Content = JsonContent.Create(content, options: JsonOptions); } _mockHttpHandler .Protected() .Setup>( "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .ReturnsAsync(response); } private static CompaniesHouseSearchResponseDto CreateSearchResponse( IEnumerable items) { var itemList = items.ToList(); return new CompaniesHouseSearchResponseDto { TotalResults = itemList.Count, ItemsPerPage = 20, StartIndex = 0, Items = itemList }; } private static CompaniesHouseSearchItemDto CreateSearchItem( string companyNumber, string title, string? status, string? dateOfCreation = null, string? addressSnippet = null) { return new CompaniesHouseSearchItemDto { CompanyNumber = companyNumber, Title = title, CompanyStatus = status, DateOfCreation = dateOfCreation, AddressSnippet = addressSnippet }; } #endregion #region Test DTOs (matching API response format with snake_case) /// /// DTO for test serialization to match Companies House API snake_case format /// private sealed record CompaniesHouseSearchResponseDto { public int TotalResults { get; init; } public int ItemsPerPage { get; init; } public int StartIndex { get; init; } public List Items { get; init; } = []; } private sealed record CompaniesHouseSearchItemDto { public required string CompanyNumber { get; init; } public required string Title { get; init; } public string? CompanyStatus { get; init; } public string? CompanyType { get; init; } public string? DateOfCreation { get; init; } public string? DateOfCessation { get; init; } public string? AddressSnippet { get; init; } } #endregion }