Merge feature/improved-company-matching into feature/ui-eye-friendly

Bring in AI-powered company name matching using Claude API.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 00:59:35 +01:00
7 changed files with 586 additions and 28 deletions

View File

@@ -0,0 +1,15 @@
using TrueCV.Application.Models;
namespace TrueCV.Application.Interfaces;
public interface ICompanyNameMatcherService
{
/// <summary>
/// Uses AI to semantically compare a company name from a CV against Companies House candidates.
/// Returns the best match with confidence score and reasoning.
/// </summary>
Task<SemanticMatchResult?> FindBestMatchAsync(
string cvCompanyName,
List<CompanyCandidate> candidates,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,33 @@
namespace TrueCV.Application.Models;
public record SemanticMatchResult
{
public required string CandidateCompanyName { get; init; }
public required string CandidateCompanyNumber { get; init; }
public required int ConfidenceScore { get; init; }
public required string MatchType { get; init; } // Exact, TradingName, Subsidiary, Parent, NoMatch
public required string Reasoning { get; init; }
public bool IsMatch => ConfidenceScore >= 70;
}
public record CompanyMatchRequest
{
public required string CVCompanyName { get; init; }
public required List<CompanyCandidate> Candidates { get; init; }
}
public record CompanyCandidate
{
public required string CompanyName { get; init; }
public required string CompanyNumber { get; init; }
public string? CompanyStatus { get; init; }
public string? DateOfCreation { get; init; }
}
public record AIMatchResponse
{
public required string BestMatchCompanyNumber { get; init; }
public required int ConfidenceScore { get; init; }
public required string MatchType { get; init; }
public required string Reasoning { get; init; }
}

View File

@@ -90,6 +90,7 @@ public static class DependencyInjection
// Register services
services.AddScoped<ICVParserService, CVParserService>();
services.AddScoped<ICompanyNameMatcherService, AICompanyNameMatcherService>();
services.AddScoped<ICompanyVerifierService, CompanyVerifierService>();
services.AddScoped<IEducationVerifierService, EducationVerifierService>();
services.AddScoped<ITimelineAnalyserService, TimelineAnalyserService>();

View File

@@ -98,9 +98,11 @@ public sealed class ProcessCVCheckJob
await _dbContext.SaveChangesAsync(cancellationToken);
// Step 5: Verify each employment entry (parallelized with rate limiting)
// Skip freelance entries as they cannot be verified against company registries
// Skip freelance, public sector, and charity entries as they cannot be verified against Companies House
var verificationTasks = cvData.Employment
.Where(e => !IsFreelance(e.CompanyName))
.Where(e => !IsFreelance(e.CompanyName) &&
!IsPublicSectorEmployer(e.CompanyName) &&
!IsCharityOrVoluntary(e.CompanyName))
.Select(async employment =>
{
var result = await _companyVerifierService.VerifyCompanyAsync(
@@ -135,6 +137,38 @@ public sealed class ProcessCVCheckJob
_logger.LogDebug("Skipped verification for freelance entry: {Company}", employment.CompanyName);
}
// Add public sector employers as auto-verified (not in Companies House)
foreach (var employment in cvData.Employment.Where(e => IsPublicSectorEmployer(e.CompanyName)))
{
verificationResults.Add(new CompanyVerificationResult
{
ClaimedCompany = employment.CompanyName,
IsVerified = true,
MatchScore = 100,
VerificationNotes = "Public sector employer - not registered at Companies House",
ClaimedJobTitle = employment.JobTitle,
JobTitlePlausible = true
});
_logger.LogDebug("Skipped verification for public sector employer: {Company}", employment.CompanyName);
}
// Add charities/voluntary organisations as auto-verified (registered with Charity Commission, not Companies House)
foreach (var employment in cvData.Employment.Where(e => IsCharityOrVoluntary(e.CompanyName)))
{
verificationResults.Add(new CompanyVerificationResult
{
ClaimedCompany = employment.CompanyName,
IsVerified = true,
MatchScore = 100,
VerificationNotes = "Charity/voluntary organisation - registered with Charity Commission",
ClaimedJobTitle = employment.JobTitle,
JobTitlePlausible = true
});
_logger.LogDebug("Skipped verification for charity/voluntary organisation: {Company}", employment.CompanyName);
}
// Step 5b: Verify director claims against Companies House officers
cvCheck.ProcessingStage = "Verifying Directors";
await _dbContext.SaveChangesAsync(cancellationToken);
@@ -486,10 +520,132 @@ public sealed class ProcessCVCheckJob
name == "freelancer" ||
name == "self-employed" ||
name == "self employed" ||
name == "selfemployed" ||
name == "contractor" ||
name.StartsWith("freelance ") ||
name.StartsWith("self-employed ") ||
name.StartsWith("self employed ") ||
name.Contains("(freelance)") ||
name.Contains("(self-employed)");
name.Contains("(self-employed)") ||
name.Contains("(self employed)") ||
name.Contains("(contractor)");
}
private static bool IsPublicSectorEmployer(string companyName)
{
if (string.IsNullOrWhiteSpace(companyName)) return false;
var name = companyName.Trim().ToLowerInvariant();
// Local authorities and councils
if (name.Contains(" mbc") || // Metropolitan Borough Council
name.Contains(" bc") || // Borough Council
name.Contains(" cc") || // County Council
name.Contains(" dc") || // District Council
name.EndsWith(" council") ||
name.Contains(" council ") ||
name.Contains("borough council") ||
name.Contains("county council") ||
name.Contains("district council") ||
name.Contains("city council") ||
name.Contains("town council") ||
name.Contains("parish council") ||
name.Contains("metropolitan") ||
name.Contains("local authority"))
{
return true;
}
// NHS and health
if (name.Contains("nhs") ||
name.Contains("national health service") ||
name.Contains("health trust") ||
name.Contains("hospital trust") ||
name.Contains("clinical commissioning") ||
name.Contains("primary care trust") ||
name.Contains("ambulance service") ||
name.Contains("ambulance trust"))
{
return true;
}
// Government departments and agencies
if (name.StartsWith("hm ") || // HM Revenue, HM Treasury, etc.
name.StartsWith("ministry of") ||
name.StartsWith("department of") ||
name.StartsWith("department for") ||
name.Contains("civil service") ||
name.Contains("home office") ||
name.Contains("cabinet office") ||
name.Contains("foreign office"))
{
return true;
}
// Emergency services
if (name.Contains("police") ||
name.Contains("fire service") ||
name.Contains("fire brigade") ||
name.Contains("fire and rescue"))
{
return true;
}
// Education (state sector)
if (name.Contains("academy trust") ||
name.Contains("multi academy") ||
name.Contains("education authority") ||
name.Contains("lea "))
{
return true;
}
return false;
}
private static bool IsCharityOrVoluntary(string companyName)
{
if (string.IsNullOrWhiteSpace(companyName)) return false;
var name = companyName.Trim().ToLowerInvariant();
// Well-known charities/voluntary organisations
var knownCharities = new[]
{
"girlguiding", "girl guiding", "girl guides",
"scouts", "scout association",
"red cross", "british red cross",
"st john ambulance", "st johns ambulance",
"rotary", "lions club",
"citizens advice", "cab ",
"oxfam", "save the children", "barnardos", "barnardo's",
"nspcc", "rspca", "rspb", "rnli",
"macmillan", "marie curie", "cancer research",
"british heart foundation", "bhf",
"age uk", "age concern",
"mind ", "samaritans",
"national trust", "english heritage",
"ymca", "ywca"
};
if (knownCharities.Any(c => name.Contains(c)))
{
return true;
}
// Generic charity indicators
if (name.Contains("charity") ||
name.Contains("charitable") ||
name.Contains("foundation") ||
name.Contains("trust ") ||
name.EndsWith(" trust") ||
name.Contains("volunteer") ||
name.Contains("voluntary"))
{
return true;
}
return false;
}
private async Task VerifyDirectorClaims(

View File

@@ -0,0 +1,185 @@
using System.Text.Json;
using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TrueCV.Application.Helpers;
using TrueCV.Application.Interfaces;
using TrueCV.Application.Models;
using TrueCV.Infrastructure.Configuration;
namespace TrueCV.Infrastructure.Services;
public sealed class AICompanyNameMatcherService : ICompanyNameMatcherService
{
private readonly AnthropicClient _anthropicClient;
private readonly ILogger<AICompanyNameMatcherService> _logger;
private const string SystemPrompt = """
You are a UK company name matching expert. Your task is to determine if a company name
from a CV matches any of the official company names from Companies House records.
You understand:
- Trading names vs registered names (e.g., "Tesco" = "TESCO PLC")
- Subsidiaries vs parent companies (e.g., "ASDA" might work for "ASDA STORES LIMITED")
- Common abbreviations (Ltd = Limited, PLC = Public Limited Company, CiC = Community Interest Company)
- That completely different words mean different companies (e.g., "Families First" "Families Against Conformity")
You must respond ONLY with valid JSON, no other text or markdown.
""";
private const string MatchingPrompt = """
Compare the company name from a CV against official Companies House records.
CV Company Name: "{CV_COMPANY}"
Companies House Candidates:
{CANDIDATES}
Determine which candidate (if any) is the SAME company as the CV entry.
Rules:
1. A match requires the companies to be the SAME organisation, not just similar names
2. "Families First CiC" is NOT the same as "FAMILIES AGAINST CONFORMITY LTD" - these are different organisations
3. "North Halifax Partnership" is NOT the same as "NORTH LIMITED" - completely different companies
4. Trading names should match their registered entity (e.g., "Tesco" matches "TESCO PLC")
5. Subsidiaries can match if clearly the same organisation (e.g., "ASDA" could match "ASDA STORES LIMITED")
6. If NO candidate is clearly the same company, return "NONE" as the best match
Respond with this exact JSON structure:
{
"bestMatchCompanyNumber": "string (company number of best match, or 'NONE' if no valid match)",
"confidenceScore": number (0-100, where 100 = certain match, 0 = no match),
"matchType": "string (Exact, TradingName, Subsidiary, Parent, NoMatch)",
"reasoning": "string (brief explanation of why this is or isn't a match)"
}
""";
public AICompanyNameMatcherService(
IOptions<AnthropicSettings> settings,
ILogger<AICompanyNameMatcherService> logger)
{
_logger = logger;
_anthropicClient = new AnthropicClient(settings.Value.ApiKey);
}
public async Task<SemanticMatchResult?> FindBestMatchAsync(
string cvCompanyName,
List<CompanyCandidate> candidates,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(cvCompanyName) || candidates.Count == 0)
{
return null;
}
_logger.LogDebug("Using AI to match '{CVCompany}' against {Count} candidates",
cvCompanyName, candidates.Count);
try
{
var candidatesText = string.Join("\n", candidates.Select((c, i) =>
$"{i + 1}. {c.CompanyName} (Number: {c.CompanyNumber}, Status: {c.CompanyStatus ?? "Unknown"})"));
var prompt = MatchingPrompt
.Replace("{CV_COMPANY}", cvCompanyName)
.Replace("{CANDIDATES}", candidatesText);
var messages = new List<Message>
{
new(RoleType.User, prompt)
};
var parameters = new MessageParameters
{
Model = "claude-sonnet-4-20250514",
MaxTokens = 1024,
Messages = messages,
System = [new SystemMessage(SystemPrompt)]
};
var response = await _anthropicClient.Messages.GetClaudeMessageAsync(parameters, cancellationToken);
var responseText = response.Content
.OfType<TextContent>()
.FirstOrDefault()?.Text;
if (string.IsNullOrWhiteSpace(responseText))
{
_logger.LogWarning("AI returned empty response for company matching");
return null;
}
responseText = CleanJsonResponse(responseText);
var aiResponse = JsonSerializer.Deserialize<AIMatchResponse>(responseText, JsonDefaults.CamelCase);
if (aiResponse is null)
{
_logger.LogWarning("Failed to deserialize AI response: {Response}", responseText);
return null;
}
_logger.LogDebug("AI match result: {CompanyNumber} with {Score}% confidence - {Reasoning}",
aiResponse.BestMatchCompanyNumber, aiResponse.ConfidenceScore, aiResponse.Reasoning);
// Find the matched candidate
if (aiResponse.BestMatchCompanyNumber == "NONE" || aiResponse.ConfidenceScore < 50)
{
return new SemanticMatchResult
{
CandidateCompanyName = "No match",
CandidateCompanyNumber = "NONE",
ConfidenceScore = 0,
MatchType = "NoMatch",
Reasoning = aiResponse.Reasoning
};
}
var matchedCandidate = candidates.FirstOrDefault(c =>
c.CompanyNumber.Equals(aiResponse.BestMatchCompanyNumber, StringComparison.OrdinalIgnoreCase));
if (matchedCandidate is null)
{
_logger.LogWarning("AI returned company number {Number} not in candidates list",
aiResponse.BestMatchCompanyNumber);
return null;
}
return new SemanticMatchResult
{
CandidateCompanyName = matchedCandidate.CompanyName,
CandidateCompanyNumber = matchedCandidate.CompanyNumber,
ConfidenceScore = aiResponse.ConfidenceScore,
MatchType = aiResponse.MatchType,
Reasoning = aiResponse.Reasoning
};
}
catch (Exception ex)
{
_logger.LogError(ex, "AI company matching failed for '{CVCompany}'", cvCompanyName);
return null; // Fall back to fuzzy matching
}
}
private static string CleanJsonResponse(string response)
{
var trimmed = response.Trim();
if (trimmed.StartsWith("```json", StringComparison.OrdinalIgnoreCase))
{
trimmed = trimmed[7..];
}
else if (trimmed.StartsWith("```"))
{
trimmed = trimmed[3..];
}
if (trimmed.EndsWith("```"))
{
trimmed = trimmed[..^3];
}
return trimmed.Trim();
}
}

View File

@@ -16,6 +16,7 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
{
private readonly CompaniesHouseClient _companiesHouseClient;
private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;
private readonly ICompanyNameMatcherService _aiMatcher;
private readonly ILogger<CompanyVerifierService> _logger;
private const int FuzzyMatchThreshold = 85;
@@ -75,10 +76,12 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
public CompanyVerifierService(
CompaniesHouseClient companiesHouseClient,
IDbContextFactory<ApplicationDbContext> dbContextFactory,
ICompanyNameMatcherService aiMatcher,
ILogger<CompanyVerifierService> logger)
{
_companiesHouseClient = companiesHouseClient;
_dbContextFactory = dbContextFactory;
_aiMatcher = aiMatcher;
_logger = logger;
}
@@ -119,7 +122,10 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
var searchQueries = GenerateSearchQueries(companyName);
_logger.LogDebug("Generated {Count} search queries for '{CompanyName}': {Queries}",
searchQueries.Count, companyName, string.Join(", ", searchQueries.Select(q => $"'{q}'")));
(CompaniesHouseSearchItem Item, int Score)? bestMatch = null;
// Collect all candidates from all search queries for AI matching
var allCandidates = new Dictionary<string, CompaniesHouseSearchItem>();
var fuzzyMatches = new List<(CompaniesHouseSearchItem Item, int Score)>();
foreach (var query in searchQueries)
{
@@ -131,25 +137,91 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
continue;
}
// Find best fuzzy match, preferring companies that existed at claimed start date
// Pass both original name and search query for matching flexibility
bestMatch = FindBestMatch(companyName, query, searchResponse.Items, startDate);
if (bestMatch is not null)
// Collect unique candidates
foreach (var item in searchResponse.Items)
{
_logger.LogDebug("Found match with query '{Query}': {Company}", query, bestMatch.Value.Item.Title);
break;
if (!string.IsNullOrWhiteSpace(item.CompanyNumber) &&
!allCandidates.ContainsKey(item.CompanyNumber))
{
allCandidates[item.CompanyNumber] = item;
}
}
// Find fuzzy matches (as before) for fallback
var fuzzyMatch = FindBestMatch(companyName, query, searchResponse.Items, startDate);
if (fuzzyMatch is not null)
{
fuzzyMatches.Add(fuzzyMatch.Value);
}
}
if (bestMatch is null)
if (allCandidates.Count == 0)
{
_logger.LogDebug("No valid match found for: {CompanyName} after trying {Count} queries", companyName, searchQueries.Count);
_logger.LogDebug("No candidates found for: {CompanyName} after trying {Count} queries", companyName, searchQueries.Count);
return CreateUnverifiedResult(companyName, startDate, endDate, jobTitle,
"Company name could not be verified against official records");
}
var match = bestMatch.Value;
// Use AI to find the best semantic match from all candidates
_logger.LogDebug("Using AI to match '{CompanyName}' against {Count} candidates", companyName, allCandidates.Count);
var candidatesForAI = allCandidates.Values
.Take(10) // Limit to top 10 candidates to reduce AI cost
.Select(c => new CompanyCandidate
{
CompanyName = c.Title,
CompanyNumber = c.CompanyNumber,
CompanyStatus = c.CompanyStatus,
DateOfCreation = c.DateOfCreation
})
.ToList();
var aiResult = await _aiMatcher.FindBestMatchAsync(companyName, candidatesForAI);
CompaniesHouseSearchItem? matchedItem = null;
int matchScore;
if (aiResult is not null && aiResult.IsMatch)
{
// AI found a valid match
matchedItem = allCandidates.GetValueOrDefault(aiResult.CandidateCompanyNumber);
matchScore = aiResult.ConfidenceScore;
_logger.LogInformation(
"AI matched '{ClaimedName}' to '{MatchedName}' with {Score}% confidence. Reasoning: {Reasoning}",
companyName, aiResult.CandidateCompanyName, aiResult.ConfidenceScore, aiResult.Reasoning);
}
else if (fuzzyMatches.Count > 0)
{
// AI didn't find a match - check if it explicitly rejected or just failed
if (aiResult?.MatchType == "NoMatch")
{
_logger.LogDebug("AI explicitly rejected all candidates for '{CompanyName}'. Reasoning: {Reasoning}",
companyName, aiResult?.Reasoning ?? "No match found");
return CreateUnverifiedResult(companyName, startDate, endDate, jobTitle,
"Company name could not be verified - no matching company found in official records");
}
// AI failed (API error, etc.) - fall back to fuzzy matching
_logger.LogWarning("AI matching failed for '{CompanyName}', falling back to fuzzy matching", companyName);
var bestFuzzy = fuzzyMatches.OrderByDescending(m => m.Score).First();
matchedItem = bestFuzzy.Item;
matchScore = bestFuzzy.Score;
}
else
{
_logger.LogDebug("No valid match found for: {CompanyName}", companyName);
return CreateUnverifiedResult(companyName, startDate, endDate, jobTitle,
"Company name could not be verified against official records");
}
if (matchedItem is null)
{
_logger.LogDebug("No valid match found for: {CompanyName}", companyName);
return CreateUnverifiedResult(companyName, startDate, endDate, jobTitle,
"Company name could not be verified against official records");
}
var match = (Item: matchedItem, Score: matchScore);
// Fetch full company details for additional data
var companyDetails = await _companiesHouseClient.GetCompanyAsync(match.Item.CompanyNumber);
@@ -607,6 +679,7 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
{
var itemTitle = item.Title.ToUpperInvariant();
var itemTitleLower = item.Title.ToLowerInvariant();
var itemCoreWords = ExtractCoreIdentifiers(item.Title);
// Validate that ALL core identifiers appear in the match
// "Lloyds Bowmaker" must have BOTH "LLOYDS" and "BOWMAKER" in the match
@@ -614,6 +687,19 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
var hasAllQueryCores = queryCoreWords.Count == 0 || queryCoreWords.All(w => itemTitle.Contains(w));
if (!hasAllOriginalCores && !hasAllQueryCores) return false;
// Additional check: ensure the match doesn't have too many EXTRA core words
// "Families First" should NOT match "Families Against Conformity" because
// "Against" and "Conformity" are extra significant words
if (coreWords.Count > 0 && hasAllOriginalCores)
{
var extraWordsInMatch = itemCoreWords.Count(w => !coreWords.Contains(w));
// If the match has more than 1 extra core word, it's likely a different company
if (extraWordsInMatch > 1 && itemCoreWords.Count > coreWords.Count + 1)
{
return false;
}
}
// Filter out non-employment entities unless explicitly searching for that type
if (!IsValidEmploymentEntity(itemTitleLower, searchEntityTypes))
{

View File

@@ -9,6 +9,8 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Moq.Protected;
using TrueCV.Application.Interfaces;
using TrueCV.Application.Models;
using TrueCV.Domain.Entities;
using TrueCV.Infrastructure.Configuration;
using TrueCV.Infrastructure.Data;
@@ -22,6 +24,7 @@ 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;
@@ -39,6 +42,7 @@ public class CompanyVerifierServiceTests : IDisposable
_mockHttpHandler = new Mock<HttpMessageHandler>();
_mockServiceLogger = new Mock<ILogger<CompanyVerifierService>>();
_mockClientLogger = new Mock<ILogger<CompaniesHouseClient>>();
_mockAiMatcher = new Mock<ICompanyNameMatcherService>();
_httpClient = new HttpClient(_mockHttpHandler.Object);
@@ -68,7 +72,57 @@ public class CompanyVerifierServiceTests : IDisposable
mockFactory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new ApplicationDbContext(_dbOptions));
_sut = new CompanyVerifierService(client, mockFactory.Object, _mockServiceLogger.Object);
// 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()
@@ -146,7 +200,7 @@ public class CompanyVerifierServiceTests : IDisposable
result.IsVerified.Should().BeFalse();
result.MatchScore.Should().Be(0);
result.MatchedCompanyName.Should().BeNull();
result.VerificationNotes.Should().Contain("70%");
result.VerificationNotes.Should().Contain("could not be verified");
}
[Fact]
@@ -171,7 +225,7 @@ public class CompanyVerifierServiceTests : IDisposable
// Assert
result.IsVerified.Should().BeTrue();
result.MatchedCompanyNumber.Should().Be("99999999");
result.VerificationNotes.Should().Contain("cache");
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(
@@ -211,10 +265,10 @@ public class CompanyVerifierServiceTests : IDisposable
result.IsVerified.Should().BeTrue();
result.MatchedCompanyNumber.Should().Be("12345678"); // From API, not cache
// Verify API WAS called
// Verify API WAS called (at least once - multiple queries are generated for matching)
_mockHttpHandler.Protected().Verify(
"SendAsync",
Times.Once(),
Times.AtLeastOnce(),
ItExpr.Is<HttpRequestMessage>(r => r.RequestUri!.ToString().Contains("search/companies")),
ItExpr.IsAny<CancellationToken>());
}
@@ -235,7 +289,7 @@ public class CompanyVerifierServiceTests : IDisposable
result.IsVerified.Should().BeFalse();
result.MatchScore.Should().Be(0);
result.MatchedCompanyName.Should().BeNull();
result.VerificationNotes.Should().Contain("No matching company");
result.VerificationNotes.Should().Contain("could not be verified");
}
[Fact]
@@ -525,20 +579,48 @@ public class CompanyVerifierServiceTests : IDisposable
private void SetupHttpResponse<T>(HttpStatusCode statusCode, T? content)
{
var response = new HttpResponseMessage(statusCode);
if (content != null)
{
response.Content = JsonContent.Create(content, options: JsonOptions);
}
// 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(response);
.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(