books/backend/Books.Api.Tests/Integration/DocumentProcessingIntegrationTests.cs

261 lines
10 KiB
C#
Raw Normal View History

using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using AwesomeAssertions;
using Books.Api.EventFlow.Repositories;
using Books.Api.Tests.Helpers;
using Books.Api.Tests.Infrastructure;
namespace Books.Api.Tests.Integration;
/// <summary>
/// Integration tests for document processing with AI Bookkeeper.
/// These tests require the AI Bookkeeper service to be running at localhost:8080.
/// Mark with [Trait("Category", "ExternalService")] to allow filtering.
/// </summary>
[Collection(IntegrationTestCollection.Name)]
[Trait("Category", "ExternalService")]
public class DocumentProcessingIntegrationTests : IntegrationTestBase
{
private readonly GraphQLTestClient _graphqlClient;
public DocumentProcessingIntegrationTests(TestWebApplicationFactory factory) : base(factory)
{
_graphqlClient = new GraphQLTestClient(Client);
}
[Fact]
public async Task ProcessDocument_WithRealInvoice_ReturnsSuggestedJournalLines()
{
// Skip if test invoice doesn't exist
var invoicePath = GetTestInvoicePath();
if (!File.Exists(invoicePath))
{
Console.WriteLine($"SKIP: Test invoice not found at: {invoicePath}");
return;
}
// Arrange - Create company (which auto-creates chart of accounts)
var companyId = await CreateTestCompanyAsync();
// Wait for accounts to be created
await WaitForAccountsAsync(companyId);
// Create HTTP client for REST API (not GraphQL)
using var httpClient = Factory.CreateClient();
// Create multipart form content with invoice
using var content = new MultipartFormDataContent();
await using var fileStream = File.OpenRead(invoicePath);
var fileContent = new StreamContent(fileStream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "document", "test-invoice.pdf");
// Act - Call document processing endpoint
var response = await httpClient.PostAsync(
$"/api/documents/process?companyId={companyId}",
content);
// Assert
var responseBody = await response.Content.ReadAsStringAsync();
Console.WriteLine($"Response status: {response.StatusCode}");
Console.WriteLine($"Response body: {responseBody}");
// If AI service is unavailable, show helpful message
if (response.StatusCode == HttpStatusCode.ServiceUnavailable)
{
var error = JsonSerializer.Deserialize<JsonElement>(responseBody);
var code = error.GetProperty("code").GetString();
if (code == "AI_UNAVAILABLE")
{
Console.WriteLine("\n*** AI Bookkeeper service is not running at localhost:8080 ***");
Console.WriteLine("Start the AI service to run this test.\n");
return;
}
}
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = JsonSerializer.Deserialize<JsonElement>(responseBody);
// Verify extraction data
result.TryGetProperty("extraction", out var extraction).Should().BeTrue("should have extraction data");
extraction.TryGetProperty("vendor", out var vendorProp).Should().BeTrue("should extract vendor");
extraction.TryGetProperty("amount", out var amount).Should().BeTrue("should extract amount");
amount.GetDecimal().Should().BeGreaterThan(0, "amount should be positive");
// Verify suggested journal lines (the main thing we're testing!)
result.TryGetProperty("suggestedLines", out var suggestedLines).Should().BeTrue(
"should have suggestedLines - this is what we're testing!");
suggestedLines.GetArrayLength().Should().BeGreaterThanOrEqualTo(2,
"should have at least 2 lines (expense and creditor)");
// Verify lines are balanced
var totalDebits = 0m;
var totalCredits = 0m;
Console.WriteLine("\n=== Suggested Journal Lines ===");
foreach (var line in suggestedLines.EnumerateArray())
{
var accountNumber = line.TryGetProperty("accountNumber", out var an) ? an.GetString() : "-";
var accountName = line.GetProperty("accountName").GetString();
var debit = line.GetProperty("debitAmount").GetDecimal();
var credit = line.GetProperty("creditAmount").GetDecimal();
var vatCode = line.TryGetProperty("vatCode", out var vc) && vc.ValueKind != JsonValueKind.Null
? vc.GetString() : null;
totalDebits += debit;
totalCredits += credit;
Console.WriteLine($" {accountNumber,-6} {accountName,-30} Debit: {debit,10:N2} Credit: {credit,10:N2} VAT: {vatCode ?? "-"}");
}
totalDebits.Should().Be(totalCredits, "journal entry must be balanced");
// Verify draft was created
result.TryGetProperty("draftId", out var draftId).Should().BeTrue();
draftId.GetString().Should().NotBeNullOrEmpty();
Console.WriteLine($"\n=== Document Processing Result ===");
Console.WriteLine($"Draft ID: {draftId.GetString()}");
Console.WriteLine($"Vendor: {vendorProp.GetString()}");
Console.WriteLine($"Total Amount: {amount.GetDecimal():N2}");
Console.WriteLine($"Suggested Lines: {suggestedLines.GetArrayLength()}");
Console.WriteLine($"Total Debits: {totalDebits:N2}");
Console.WriteLine($"Total Credits: {totalCredits:N2}");
Console.WriteLine($"BALANCED: {(totalDebits == totalCredits ? "YES" : "NO")}");
}
[Fact]
public async Task ProcessDocument_WithParkingReceipt_GeneratesSuggestionFromExtraction()
{
// This test uses a receipt to verify our fallback suggestion generation
// works when AI doesn't provide suggestedBooking
var receiptPath = GetTestInvoicePath("parking_receipt_2582441883.pdf");
if (!File.Exists(receiptPath))
{
Console.WriteLine($"SKIP: Test receipt not found at: {receiptPath}");
return;
}
// Arrange
var companyId = await CreateTestCompanyAsync();
await WaitForAccountsAsync(companyId);
using var httpClient = Factory.CreateClient();
using var content = new MultipartFormDataContent();
await using var fileStream = File.OpenRead(receiptPath);
var fileContent = new StreamContent(fileStream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "document", "parking-receipt.pdf");
// Act
var response = await httpClient.PostAsync(
$"/api/documents/process?companyId={companyId}",
content);
var responseBody = await response.Content.ReadAsStringAsync();
Console.WriteLine($"Response: {responseBody}");
if (response.StatusCode == HttpStatusCode.ServiceUnavailable)
{
Console.WriteLine("\n*** AI Bookkeeper service is not running ***\n");
return;
}
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = JsonSerializer.Deserialize<JsonElement>(responseBody);
result.TryGetProperty("suggestedLines", out var lines).Should().BeTrue(
"should generate suggested lines from extraction data");
// Print the suggestions for manual verification
Console.WriteLine("\n=== Generated Suggestions (from extraction fallback) ===");
foreach (var line in lines.EnumerateArray())
{
var accountNumber = line.TryGetProperty("accountNumber", out var an) ? an.GetString() : "-";
var accountName = line.GetProperty("accountName").GetString();
var debit = line.GetProperty("debitAmount").GetDecimal();
var credit = line.GetProperty("creditAmount").GetDecimal();
var vatCode = line.TryGetProperty("vatCode", out var vc) && vc.ValueKind != JsonValueKind.Null
? vc.GetString() : null;
Console.WriteLine($" {accountNumber,-6} {accountName,-30} Debit: {debit,10:N2} Credit: {credit,10:N2} VAT: {vatCode ?? "-"}");
}
}
private async Task<string> CreateTestCompanyAsync()
{
var createResponse = await _graphqlClient.MutateAsync<CreateCompanyResponse>("""
mutation CreateCompany($input: CreateCompanyInput!) {
createCompany(input: $input) {
id
name
}
}
""",
new
{
input = new
{
name = $"AI Test Company {Guid.NewGuid():N}"[..30],
cvr = CvrGenerator.Generate()
}
});
createResponse.EnsureNoErrors();
return createResponse.Data!.CreateCompany!.Id;
}
private async Task WaitForAccountsAsync(string companyId)
{
// Wait for chart of accounts to be initialized (async event handler)
await Eventually.GetAsync(async () =>
{
var repo = GetService<IAccountRepository>();
var accounts = await repo.GetByCompanyIdAsync(companyId);
// Need at least the key accounts for booking: expense, VAT, creditor
return accounts.Count >= 50 ? accounts : null;
}, timeout: TimeSpan.FromSeconds(30));
}
private static string GetTestInvoicePath(string fileName = "invoice-F175490.pdf")
{
// Try to find test invoice relative to test assembly
var assemblyDir = Path.GetDirectoryName(typeof(DocumentProcessingIntegrationTests).Assembly.Location)!;
// Navigate up to find the test-invoices folder
var searchPaths = new[]
{
Path.Combine(assemblyDir, "..", "..", "..", "..", "..", "..", "account-suggestions", "test-invoices", fileName),
Path.Combine(assemblyDir, "..", "..", "..", "..", "account-suggestions", "test-invoices", fileName),
$"/Users/nicolajhartmann/projects/books/account-suggestions/test-invoices/{fileName}"
};
foreach (var path in searchPaths)
{
var fullPath = Path.GetFullPath(path);
if (File.Exists(fullPath))
{
return fullPath;
}
}
return searchPaths[^1]; // Return last (absolute) path for error message
}
// Response DTOs
private class CreateCompanyResponse
{
public CompanyDto? CreateCompany { get; set; }
}
private class CompanyDto
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
}
}