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; /// /// 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. /// [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(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(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(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 CreateTestCompanyAsync() { var createResponse = await _graphqlClient.MutateAsync(""" 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(); 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; } }