using System.Net; using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; using Moq.Protected; using RealCV.Application.Interfaces; using RealCV.Application.Models; using RealCV.Domain.Entities; using RealCV.Infrastructure.Configuration; using RealCV.Infrastructure.Data; using RealCV.Infrastructure.ExternalApis; using RealCV.Infrastructure.Services; namespace RealCV.Tests.Services; public class CompanyVerifierServiceTests : IDisposable { private readonly Mock _mockHttpHandler; private readonly Mock> _mockServiceLogger; private readonly Mock> _mockClientLogger; private readonly Mock _mockAiMatcher; private readonly ApplicationDbContext _dbContext; private readonly CompanyVerifierService _sut; private readonly HttpClient _httpClient; private readonly DbContextOptions _dbOptions; private readonly InMemoryDatabaseRoot _dbRoot; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNameCaseInsensitive = true }; public CompanyVerifierServiceTests() { _mockHttpHandler = new Mock(); _mockServiceLogger = new Mock>(); _mockClientLogger = new Mock>(); _mockAiMatcher = 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); // Use a shared InMemoryDatabaseRoot so all contexts share the same data store _dbRoot = new InMemoryDatabaseRoot(); var dbName = Guid.NewGuid().ToString(); _dbOptions = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: dbName, databaseRoot: _dbRoot) .ConfigureWarnings(w => w.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)) .Options; _dbContext = new ApplicationDbContext(_dbOptions); // Create a mock factory that returns a new context with the same in-memory database var mockFactory = new Mock>(); mockFactory.Setup(f => f.CreateDbContext()) .Returns(() => new ApplicationDbContext(_dbOptions)); mockFactory.Setup(f => f.CreateDbContextAsync(It.IsAny())) .ReturnsAsync(() => new ApplicationDbContext(_dbOptions)); // Setup AI matcher to return matching results for exact company name matches _mockAiMatcher.Setup(m => m.FindBestMatchAsync( It.IsAny(), It.IsAny>(), It.IsAny())) .Returns((string cvCompanyName, List candidates, CancellationToken _) => { // Find exact or close match in candidates var exactMatch = candidates.FirstOrDefault(c => c.CompanyName.Equals(cvCompanyName, StringComparison.OrdinalIgnoreCase)); if (exactMatch != null) { return Task.FromResult(new SemanticMatchResult { CandidateCompanyName = exactMatch.CompanyName, CandidateCompanyNumber = exactMatch.CompanyNumber, ConfidenceScore = 100, MatchType = "Exact", Reasoning = "Exact name match" }); } // Try fuzzy match for close names (e.g., with/without Ltd) var fuzzyMatch = candidates.FirstOrDefault(c => c.CompanyName.Contains(cvCompanyName, StringComparison.OrdinalIgnoreCase) || cvCompanyName.Contains(c.CompanyName.Replace(" Ltd", "").Replace(" Limited", ""), StringComparison.OrdinalIgnoreCase)); if (fuzzyMatch != null) { return Task.FromResult(new SemanticMatchResult { CandidateCompanyName = fuzzyMatch.CompanyName, CandidateCompanyNumber = fuzzyMatch.CompanyNumber, ConfidenceScore = 85, MatchType = "TradingName", Reasoning = "Similar name match" }); } return Task.FromResult(new SemanticMatchResult { CandidateCompanyName = "No match", CandidateCompanyNumber = "NONE", ConfidenceScore = 0, MatchType = "NoMatch", Reasoning = "No matching company found" }); }); _sut = new CompanyVerifierService(client, mockFactory.Object, _mockAiMatcher.Object, _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("could not be verified"); } [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().BeNull(); // Cached results have no specific notes // 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 (at least once - multiple queries are generated for matching) _mockHttpHandler.Protected().Verify( "SendAsync", Times.AtLeastOnce(), 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("could not be verified"); } [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 - use AsNoTracking to get fresh data from the store var cachedEntry = await _dbContext.CompanyCache .AsNoTracking() .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 - use AsNoTracking to get fresh data from the store var cachedEntry = await _dbContext.CompanyCache .AsNoTracking() .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) { // Return a fresh response for each call to avoid stream disposal issues // when multiple API calls are made (e.g., multiple search queries) // Also handle both search and company detail endpoints _mockHttpHandler .Protected() .Setup>( "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .ReturnsAsync((HttpRequestMessage request, CancellationToken _) => { var url = request.RequestUri?.ToString() ?? ""; var response = new HttpResponseMessage(statusCode); // For search requests, return the search response if (url.Contains("search/companies") && content != null) { response.Content = JsonContent.Create(content, options: JsonOptions); } // For company detail requests (e.g., /company/12345678), return a valid company response else if (url.Contains("/company/") && !url.Contains("search")) { // Extract company number from URL var companyNumber = url.Split("/company/").LastOrDefault()?.Split("/").FirstOrDefault()?.Split("?").FirstOrDefault() ?? "12345678"; // Return a minimal valid company response var companyResponse = new { company_number = companyNumber, company_name = "Test Company Ltd", company_status = "active", type = "ltd" }; response.Content = JsonContent.Create(companyResponse, options: JsonOptions); } else if (content != null) { response.Content = JsonContent.Create(content, options: JsonOptions); } return 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 }