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>
260 lines
10 KiB
C#
260 lines
10 KiB
C#
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;
|
|
}
|
|
}
|