using Duempelkas.App.Services; using Duempelkas.Domain.Entities; using Duempelkas.Domain.Enums; using Duempelkas.Infrastructure.Persistence; using Duempelkas.Infrastructure.Services; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Xunit; namespace Duempelkas.Tests; public class DashboardPdfStatementServiceTests : IDisposable { private readonly FinanceDbContext _db; private readonly PdfStatementService _pdfService; private readonly string _connectionString = $"Data Source=duempelkas-dashboard-pdf-tests-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; public DashboardPdfStatementServiceTests() { var options = new DbContextOptionsBuilder() .UseSqlite(_connectionString) .Options; _db = new FinanceDbContext(options); _db.Database.OpenConnection(); _db.Database.EnsureCreated(); var dbFactory = new TestDbContextFactory(options); var entryService = new EntryService(dbFactory); var balanceQueryService = new BalanceQueryService(dbFactory); var settingsService = new FixedSettingsService("Testverein"); _pdfService = new PdfStatementService(dbFactory, entryService, balanceQueryService, settingsService); } [Fact] public async Task GenerateDashboardStatementAsync_WithBookingsAndTransfer_ReturnsPdf() { var barkasse = new Account { Name = "Barkasse" }; var girokonto = new Account { Name = "Girokonto" }; _db.Accounts.AddRange(barkasse, girokonto); await _db.SaveChangesAsync(); _db.Entries.Add(new Entry { AccountId = barkasse.Id, DisplayId = "2026-001", Type = EntryType.Income, Date = new DateTime(2026, 1, 12), Title = "Einkuenfte Sommerfest", Amount = 604.60m }); var transferExpense = new Entry { AccountId = barkasse.Id, DisplayId = "2026-002", Type = EntryType.Expense, Date = new DateTime(2026, 2, 16), Title = "Einzahlung", Amount = 600.00m }; var transferIncome = new Entry { AccountId = girokonto.Id, DisplayId = "2026-002", Type = EntryType.Income, Date = new DateTime(2026, 2, 16), Title = "Einzahlung", Amount = 600.00m }; _db.Entries.AddRange(transferExpense, transferIncome); await _db.SaveChangesAsync(); var transferLink = new TransferLink { SourceEntryId = transferExpense.Id, TargetEntryId = transferIncome.Id, Note = "Umbuchung" }; _db.TransferLinks.Add(transferLink); await _db.SaveChangesAsync(); transferExpense.TransferLinkId = transferLink.Id; transferIncome.TransferLinkId = transferLink.Id; await _db.SaveChangesAsync(); var pdf = await _pdfService.GenerateDashboardStatementAsync(); pdf.Should().NotBeNull(); pdf.Length.Should().BeGreaterThan(1000); System.Text.Encoding.ASCII.GetString(pdf.Take(4).ToArray()).Should().Be("%PDF"); } [Fact] public async Task Exports_ExcludeDeleted_ExplainTransfers_AndMatchBalances() { var source = new Account { Name = "Barkasse", CarryoverBalance = 100m }; var target = new Account { Name = "Girokonto" }; _db.Accounts.AddRange(source, target); await _db.SaveChangesAsync(); var options = new DbContextOptionsBuilder().UseSqlite(_connectionString).Options; var factory = new TestDbContextFactory(options); var entries = new EntryService(factory); await entries.CreateEntryAsync(source.Id, EntryType.Income, new(2026, 1, 1), "Mitgliedsbeitrag", 12.50m); await entries.CreateEntryAsync(source.Id, EntryType.Expense, new(2026, 1, 2), "Porto", 4.20m); await entries.CreateTransferAsync(source.Id, target.Id, new(2026, 1, 3), "Zur Bank", 50m); var deleted = await entries.CreateEntryAsync(source.Id, EntryType.Income, new(2026, 1, 4), "GELOESCHTE_TESTBUCHUNG", 999m); await entries.DeleteEntryAsync(deleted.Id); await entries.CreateEntryAsync(source.Id, EntryType.Income, new(2027, 1, 1), "ZUKUNFT_TESTBUCHUNG", 1m); var balance = await new BalanceQueryService(factory).GetAccountBalanceAsync(source.Id, 2026); Assert.Equal(58.30m, balance.TotalBalance); var single = await _pdfService.GenerateStatementAsync(source.Id, 2026); var combined = await _pdfService.GenerateDashboardStatementAsync(2026); foreach (var pdf in new[] { single, combined }) { var text = Extract(pdf); Assert.Contains("Mitgliedsbeitrag", text); Assert.Contains("Porto", text); Assert.Contains("Zur Bank", text); Assert.DoesNotContain("GELOESCHTE_TESTBUCHUNG", text); Assert.DoesNotContain("ZUKUNFT_TESTBUCHUNG", text); Assert.Contains("58,30", text); Assert.Contains("31.12.2026", text); } Assert.Contains("108,30", Extract(combined)); Assert.Contains("an Girokonto", Extract(single)); var all = Extract(await _pdfService.GenerateStatementAsync(source.Id, (int?)null)); Assert.Contains("Alle Jahre", all); Assert.Contains("Anfangsbestand", all); Assert.Contains("59,30", all); var evidence = Environment.GetEnvironmentVariable("DUEMPELKAS_PDF_EVIDENCE"); if (evidence != null) { Directory.CreateDirectory(evidence); await File.WriteAllBytesAsync(Path.Combine(evidence, "single.pdf"), single); await File.WriteAllBytesAsync(Path.Combine(evidence, "combined.pdf"), combined); // Additional pages exercise repeated headers and the final summary. for (var i = 0; i < 90; i++) await entries.CreateEntryAsync(source.Id, EntryType.Income, new(2026, 2, 1), $"Lesbarkeitsprüfung Buchung {i + 1}", 1m); await File.WriteAllBytesAsync(Path.Combine(evidence, "multipage.pdf"), await _pdfService.GenerateStatementAsync(source.Id, 2026)); } } private static string Extract(byte[] pdf) { using var document = UglyToad.PdfPig.PdfDocument.Open(pdf); return string.Join("\n", document.GetPages().Select(page => UglyToad.PdfPig.DocumentLayoutAnalysis.TextExtractor.ContentOrderTextExtractor.GetText(page))); } public void Dispose() { _db.Database.CloseConnection(); _db.Dispose(); } private sealed class TestDbContextFactory : IDbContextFactory { private readonly DbContextOptions _options; public TestDbContextFactory(DbContextOptions options) { _options = options; } public FinanceDbContext CreateDbContext() => new(_options); public Task CreateDbContextAsync(CancellationToken cancellationToken = default) => Task.FromResult(new FinanceDbContext(_options)); } private sealed class FixedSettingsService : ISettingsService { public Task GetShowDeletedEntriesAsync() => Task.FromResult(false); public Task SetShowDeletedEntriesAsync(bool show) => Task.CompletedTask; private readonly string? _clubName; public FixedSettingsService(string? clubName) { _clubName = clubName; } public Task GetClubNameAsync() => Task.FromResult(_clubName); public Task SetClubNameAsync(string? clubName) => Task.CompletedTask; } }