books/backend/Books.Api.Tests/AiBookkeeper/AiBookkeeperClientTests.cs
Nicolaj Hartmann 1f75c5d791 Add all backend domain, commands, repositories, and tests
This commit includes all previously untracked backend files:

Domain:
- Accounts, Attachments, BankConnections, Customers
- FiscalYears, Invoices, JournalEntryDrafts
- Orders, Products, UserAccess

Commands & Handlers:
- Full CQRS command structure for all domains

Repositories:
- PostgreSQL repositories for all read models
- Bank transaction and ledger repositories

GraphQL:
- Input types, scalars, and types for all entities
- Mutations and queries

Infrastructure:
- Banking integration (Enable Banking client)
- File storage, Invoicing, Reporting, SAF-T export
- Database migrations (003-029)

Tests:
- Integration tests for GraphQL endpoints
- Domain tests
- Invoicing and reporting tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:19:42 +01:00

365 lines
12 KiB
C#

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;
/// <summary>
/// Unit tests for AiBookkeeperClient.
/// Tests JSON parsing and suggestion generation from extraction data.
/// </summary>
[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<HttpMessageHandler>();
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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<AiBookkeeperClient>.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 }
]
};
}
}