using System.Net;
using System.Text;
using Books.Api.AiBookkeeper;
using AwesomeAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Moq.Protected;
namespace Books.Api.Tests.AiBookkeeper;
///
/// Unit tests for AiBookkeeperClient.
/// Tests JSON parsing and suggestion generation from extraction data.
///
[Trait("Category", "Unit")]
public class AiBookkeeperClientTests
{
[Fact]
public async Task ProcessDocument_WithExtractionButNoSuggestion_GeneratesSuggestionFromExtraction()
{
// Arrange
var json = """
{
"success": true,
"extraction": {
"documentType": "invoice",
"vendor": { "name": "Test Leverandør", "cvr": "12345678" },
"invoiceNumber": "INV-001",
"date": "2024-01-15",
"totalAmount": 1250.00,
"amountExVat": 1000.00,
"vatAmount": 250.00,
"currency": "DKK"
}
}
""";
var client = CreateClientWithResponse(json);
var chartOfAccounts = CreateChartOfAccounts();
// Act
var result = await client.ProcessDocumentAsync(
new MemoryStream([1, 2, 3]),
"test.pdf",
"application/pdf",
chartOfAccounts);
// Assert
result.Success.Should().BeTrue();
result.Extraction.Should().NotBeNull();
result.Extraction!.Vendor.Should().Be("Test Leverandør");
result.Extraction.TotalAmount.Should().Be(1250.00m);
result.Extraction.AmountExVat.Should().Be(1000.00m);
result.Extraction.VatAmount.Should().Be(250.00m);
// Suggestion should be generated
result.Suggestion.Should().NotBeNull();
result.Suggestion!.Description.Should().Be("Test Leverandør");
result.Suggestion.Confidence.Should().Be(0.7m);
result.Suggestion.Lines.Should().HaveCount(3);
// Expense line (debit amountExVat) - Erhvervsstyrelsen standard 1610
var expenseLine = result.Suggestion.Lines[0];
expenseLine.StandardAccountNumber.Should().Be("1610");
expenseLine.DebitAmount.Should().Be(1000.00m);
expenseLine.CreditAmount.Should().Be(0m);
expenseLine.VatCode.Should().Be("I25"); // 25% VAT detected
// VAT line (debit vatAmount) - Erhvervsstyrelsen standard 7680
var vatLine = result.Suggestion.Lines[1];
vatLine.StandardAccountNumber.Should().Be("7680");
vatLine.DebitAmount.Should().Be(250.00m);
vatLine.CreditAmount.Should().Be(0m);
// Creditor line (credit totalAmount) - Erhvervsstyrelsen standard 7350
var creditorLine = result.Suggestion.Lines[2];
creditorLine.StandardAccountNumber.Should().Be("7350");
creditorLine.DebitAmount.Should().Be(0m);
creditorLine.CreditAmount.Should().Be(1250.00m);
}
[Fact]
public async Task ProcessDocument_WithNestedTotals_ExtractsTotalsCorrectly()
{
// Arrange - AI service returns totals as nested object
var json = """
{
"success": true,
"extraction": {
"documentType": "invoice",
"vendor": { "name": "Nested Totals Vendor" },
"totals": {
"grandTotal": 625.00,
"subtotal": 500.00,
"vatTotal": 125.00
}
}
}
""";
var client = CreateClientWithResponse(json);
// Act
var result = await client.ProcessDocumentAsync(
new MemoryStream([1, 2, 3]),
"test.pdf",
"application/pdf",
CreateChartOfAccounts());
// Assert
result.Extraction.Should().NotBeNull();
result.Extraction!.TotalAmount.Should().Be(625.00m);
result.Extraction.AmountExVat.Should().Be(500.00m);
result.Extraction.VatAmount.Should().Be(125.00m);
// Suggestion should be generated with correct amounts
result.Suggestion.Should().NotBeNull();
result.Suggestion!.Lines.Should().HaveCount(3);
result.Suggestion.Lines[0].DebitAmount.Should().Be(500.00m); // Expense
result.Suggestion.Lines[1].DebitAmount.Should().Be(125.00m); // VAT
result.Suggestion.Lines[2].CreditAmount.Should().Be(625.00m); // Creditor
}
[Fact]
public async Task ProcessDocument_WithoutVat_GeneratesTwoLineEntry()
{
// Arrange - No VAT in the extraction
var json = """
{
"success": true,
"extraction": {
"documentType": "receipt",
"vendor": { "name": "No VAT Vendor" },
"totalAmount": 500.00,
"amountExVat": 500.00,
"vatAmount": 0
}
}
""";
var client = CreateClientWithResponse(json);
// Act
var result = await client.ProcessDocumentAsync(
new MemoryStream([1, 2, 3]),
"test.pdf",
"application/pdf",
CreateChartOfAccounts());
// Assert - Should only have 2 lines (no VAT line)
result.Suggestion.Should().NotBeNull();
result.Suggestion!.Lines.Should().HaveCount(2);
// Expense line - Erhvervsstyrelsen standard 1610
result.Suggestion.Lines[0].StandardAccountNumber.Should().Be("1610");
result.Suggestion.Lines[0].DebitAmount.Should().Be(500.00m);
result.Suggestion.Lines[0].VatCode.Should().BeNull();
// Creditor line - Erhvervsstyrelsen standard 7350
result.Suggestion.Lines[1].StandardAccountNumber.Should().Be("7350");
result.Suggestion.Lines[1].CreditAmount.Should().Be(500.00m);
}
[Fact]
public async Task ProcessDocument_WithOnlyTotalAmount_UsesSameAmountForExpense()
{
// Arrange - Only total amount, no breakdown
var json = """
{
"success": true,
"extraction": {
"documentType": "receipt",
"vendor": { "name": "Simple Receipt" },
"totalAmount": 299.00
}
}
""";
var client = CreateClientWithResponse(json);
// Act
var result = await client.ProcessDocumentAsync(
new MemoryStream([1, 2, 3]),
"test.pdf",
"application/pdf",
CreateChartOfAccounts());
// Assert
result.Suggestion.Should().NotBeNull();
result.Suggestion!.Lines.Should().HaveCount(2);
// Expense line uses total (no VAT breakdown)
result.Suggestion.Lines[0].DebitAmount.Should().Be(299.00m);
// Creditor line
result.Suggestion.Lines[1].CreditAmount.Should().Be(299.00m);
}
[Fact]
public async Task ProcessDocument_WithNoTotalAmount_DoesNotGenerateSuggestion()
{
// Arrange - No amounts in extraction
var json = """
{
"success": true,
"extraction": {
"documentType": "unknown",
"vendor": { "name": "Unknown Document" }
}
}
""";
var client = CreateClientWithResponse(json);
// Act
var result = await client.ProcessDocumentAsync(
new MemoryStream([1, 2, 3]),
"test.pdf",
"application/pdf",
CreateChartOfAccounts());
// Assert - No suggestion generated (can't book without amounts)
result.Extraction.Should().NotBeNull();
result.Suggestion.Should().BeNull();
}
[Fact]
public async Task ProcessDocument_WithExistingSuggestion_UsesProvidedSuggestion()
{
// Arrange - AI provides its own suggestion
var json = """
{
"success": true,
"extraction": {
"documentType": "invoice",
"vendor": { "name": "Test Vendor" },
"totalAmount": 1000.00
},
"suggestedBooking": {
"description": "AI Suggested Description",
"confidence": 0.95,
"lines": [
{ "standardAccountNumber": "7320", "accountName": "Software", "debit": 800.00, "credit": 0 },
{ "standardAccountNumber": "6320", "accountName": "Moms", "debit": 200.00, "credit": 0 },
{ "standardAccountNumber": "6930", "accountName": "Kreditor", "debit": 0, "credit": 1000.00 }
]
}
}
""";
var client = CreateClientWithResponse(json);
// Act
var result = await client.ProcessDocumentAsync(
new MemoryStream([1, 2, 3]),
"test.pdf",
"application/pdf",
CreateChartOfAccounts());
// Assert - Should use AI's suggestion, not generate our own
result.Suggestion.Should().NotBeNull();
result.Suggestion!.Description.Should().Be("AI Suggested Description");
result.Suggestion.Confidence.Should().Be(0.95m);
result.Suggestion.Lines[0].StandardAccountNumber.Should().Be("7320");
}
[Fact]
public async Task ProcessDocument_WithNoVendorName_UsesDefaultDescription()
{
// Arrange - No vendor name
var json = """
{
"success": true,
"extraction": {
"documentType": "receipt",
"totalAmount": 150.00
}
}
""";
var client = CreateClientWithResponse(json);
// Act
var result = await client.ProcessDocumentAsync(
new MemoryStream([1, 2, 3]),
"test.pdf",
"application/pdf",
CreateChartOfAccounts());
// Assert
result.Suggestion.Should().NotBeNull();
result.Suggestion!.Description.Should().Be("Udgift");
}
[Fact]
public async Task ProcessDocument_GeneratedSuggestion_IsBalanced()
{
// Arrange
var json = """
{
"success": true,
"extraction": {
"documentType": "invoice",
"vendor": { "name": "Balance Test" },
"totalAmount": 1875.50,
"amountExVat": 1500.40,
"vatAmount": 375.10
}
}
""";
var client = CreateClientWithResponse(json);
// Act
var result = await client.ProcessDocumentAsync(
new MemoryStream([1, 2, 3]),
"test.pdf",
"application/pdf",
CreateChartOfAccounts());
// Assert - Total debits should equal total credits
result.Suggestion.Should().NotBeNull();
var totalDebits = result.Suggestion!.Lines.Sum(l => l.DebitAmount);
var totalCredits = result.Suggestion.Lines.Sum(l => l.CreditAmount);
totalDebits.Should().Be(totalCredits);
totalDebits.Should().Be(1875.50m);
}
private static AiBookkeeperClient CreateClientWithResponse(string jsonResponse)
{
var mockHandler = new Mock();
mockHandler
.Protected()
.Setup>(
"SendAsync",
ItExpr.IsAny(),
ItExpr.IsAny())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(jsonResponse, Encoding.UTF8, "application/json")
});
var httpClient = new HttpClient(mockHandler.Object)
{
BaseAddress = new Uri("http://localhost")
};
return new AiBookkeeperClient(httpClient, NullLogger.Instance);
}
private static ChartOfAccountsDto CreateChartOfAccounts()
{
return new ChartOfAccountsDto
{
CompanyId = "test-company",
Accounts =
[
new AiAccountDto { AccountNumber = "1000", Name = "Vareforbrug", AccountType = "cogs", VatCodeId = "I25" },
new AiAccountDto { AccountNumber = "6320", Name = "Indgaaende moms", AccountType = "asset", VatCodeId = null },
new AiAccountDto { AccountNumber = "6930", Name = "Leverandoerer", AccountType = "liability", VatCodeId = null }
]
};
}
}