- Renamed all directories (TrueCV.* -> RealCV.*) - Renamed all project files (.csproj) - Renamed solution file (TrueCV.sln -> RealCV.sln) - Updated all namespaces in C# and Razor files - Updated project references - Updated CSS variable names 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
684 lines
24 KiB
C#
684 lines
24 KiB
C#
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<HttpMessageHandler> _mockHttpHandler;
|
|
private readonly Mock<ILogger<CompanyVerifierService>> _mockServiceLogger;
|
|
private readonly Mock<ILogger<CompaniesHouseClient>> _mockClientLogger;
|
|
private readonly Mock<ICompanyNameMatcherService> _mockAiMatcher;
|
|
private readonly ApplicationDbContext _dbContext;
|
|
private readonly CompanyVerifierService _sut;
|
|
private readonly HttpClient _httpClient;
|
|
private readonly DbContextOptions<ApplicationDbContext> _dbOptions;
|
|
private readonly InMemoryDatabaseRoot _dbRoot;
|
|
|
|
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>>();
|
|
_mockAiMatcher = new Mock<ICompanyNameMatcherService>();
|
|
|
|
_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<ApplicationDbContext>()
|
|
.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<IDbContextFactory<ApplicationDbContext>>();
|
|
mockFactory.Setup(f => f.CreateDbContext())
|
|
.Returns(() => new ApplicationDbContext(_dbOptions));
|
|
mockFactory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(() => new ApplicationDbContext(_dbOptions));
|
|
|
|
// Setup AI matcher to return matching results for exact company name matches
|
|
_mockAiMatcher.Setup(m => m.FindBestMatchAsync(
|
|
It.IsAny<string>(),
|
|
It.IsAny<List<CompanyCandidate>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns((string cvCompanyName, List<CompanyCandidate> 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<SemanticMatchResult?>(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<SemanticMatchResult?>(new SemanticMatchResult
|
|
{
|
|
CandidateCompanyName = fuzzyMatch.CompanyName,
|
|
CandidateCompanyNumber = fuzzyMatch.CompanyNumber,
|
|
ConfidenceScore = 85,
|
|
MatchType = "TradingName",
|
|
Reasoning = "Similar name match"
|
|
});
|
|
}
|
|
|
|
return Task.FromResult<SemanticMatchResult?>(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<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 (at least once - multiple queries are generated for matching)
|
|
_mockHttpHandler.Protected().Verify(
|
|
"SendAsync",
|
|
Times.AtLeastOnce(),
|
|
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("could not be verified");
|
|
}
|
|
|
|
[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 - 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<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)
|
|
{
|
|
// 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<Task<HttpResponseMessage>>(
|
|
"SendAsync",
|
|
ItExpr.IsAny<HttpRequestMessage>(),
|
|
ItExpr.IsAny<CancellationToken>())
|
|
.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<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
|
|
}
|