366 lines
12 KiB
C#
366 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 }
|
||
|
|
]
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|