using FluentAssertions; using LedgerFlow.Core.Domain; using LedgerFlow.Core.Matching; using LedgerFlow.Infrastructure.Erp; using LedgerFlow.Infrastructure.Persistence; using LedgerFlow.Infrastructure.Persistence.Entities; using LedgerFlow.Infrastructure.Pipeline; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace LedgerFlow.Infrastructure.Tests; /// /// The extract→match→post orchestration against a real (in-memory) DbContext and a fake ERP: /// clean invoices post and are recorded as Posted; dirty ones land in the exception queue with /// their typed exceptions; and a posted invoice arriving twice is caught by the history check. /// public class InvoiceProcessorTests { private sealed class FakeErp : IErpPostingClient { public List Posted { get; } = []; public Task PostAsync(Invoice invoice, CancellationToken cancellationToken = default) { Posted.Add(invoice.InvoiceNumber); return Task.FromResult(new ErpPostingResult(true, $"ERP-{invoice.InvoiceNumber}")); } } private static LedgerFlowDbContext NewDb() => new(new DbContextOptionsBuilder() .UseInMemoryDatabase($"ledgerflow-{Guid.NewGuid():N}") .Options); private static InvoiceProcessor Processor(LedgerFlowDbContext db, FakeErp erp) => new(new ThreeWayMatcher(), db, erp, NullLogger.Instance); private static (Invoice Invoice, PurchaseOrder Po, GoodsReceipt Receipt) CleanTrio() { var line = new InvoiceLine { Sku = "SKU-1", Description = "Widget", Quantity = 10m, UnitPrice = new Money(5.00m, "EUR"), }; var invoice = new Invoice { InvoiceNumber = "INV-1", SupplierId = "SUP-001", InvoiceDate = new DateOnly(2026, 6, 1), PurchaseOrderNumber = "PO-1", Total = new Money(50.00m, "EUR"), Lines = [line], }; var po = new PurchaseOrder { PurchaseOrderNumber = "PO-1", SupplierId = "SUP-001", Currency = "EUR", Lines = [new PurchaseOrderLine { Sku = "SKU-1", OrderedQuantity = 10m, UnitPrice = new Money(5.00m, "EUR") }], }; var receipt = new GoodsReceipt { ReceiptNumber = "GR-1", PurchaseOrderNumber = "PO-1", ReceivedOn = new DateOnly(2026, 5, 30), Lines = [new GoodsReceiptLine { Sku = "SKU-1", ReceivedQuantity = 10m }], }; return (invoice, po, receipt); } [Fact] public async Task CleanInvoice_IsPostedToErp_AndRecordedAsPosted() { await using var db = NewDb(); var erp = new FakeErp(); var (invoice, po, receipt) = CleanTrio(); var result = await Processor(db, erp).ProcessAsync(invoice, po, [receipt]); result.Decision.Should().Be(MatchDecision.AutoPost); erp.Posted.Should().ContainSingle().Which.Should().Be("INV-1"); var record = await db.Invoices.Include(i => i.Exceptions).SingleAsync(); record.Status.Should().Be(InvoiceStatus.Posted); record.Exceptions.Should().BeEmpty(); record.ResolvedAt.Should().NotBeNull(); } [Fact] public async Task DirtyInvoice_LandsInExceptionQueue_AndIsNotPosted() { await using var db = NewDb(); var erp = new FakeErp(); var (invoice, po, receipt) = CleanTrio(); var overpriced = invoice with { Lines = [invoice.Lines[0] with { UnitPrice = new Money(9.99m, "EUR") }], Total = new Money(99.90m, "EUR"), }; var result = await Processor(db, erp).ProcessAsync(overpriced, po, [receipt]); result.Decision.Should().Be(MatchDecision.Review); erp.Posted.Should().BeEmpty(); var record = await db.Invoices.Include(i => i.Exceptions).SingleAsync(); record.Status.Should().Be(InvoiceStatus.NeedsReview); record.Exceptions.Should().Contain(e => e.Code == ExceptionCode.PriceOverTolerance); } [Fact] public async Task PostedInvoiceArrivingAgain_IsFlaggedAsDuplicate() { await using var db = NewDb(); var erp = new FakeErp(); var processor = Processor(db, erp); var (invoice, po, receipt) = CleanTrio(); await processor.ProcessAsync(invoice, po, [receipt]); // first pass: posts var second = await processor.ProcessAsync(invoice, po, [receipt]); // resent email second.Decision.Should().Be(MatchDecision.Review); second.Exceptions.Should().Contain(e => e.Code == ExceptionCode.DuplicateInvoice); erp.Posted.Should().HaveCount(1, "the duplicate must not post a second time"); } }