Add unit tests for DashboardPdfStatementService to validate PDF generation with entries and transfers

This commit is contained in:
2026-04-03 12:33:48 +02:00
parent 69181e66b0
commit 9aa1fee49e
9 changed files with 448 additions and 3 deletions

2
.vscode/launch.json vendored
View File

@@ -6,7 +6,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/src/Duempelkas.Desktop/bin/Debug/net10.0/Duempelkas.Desktop.dll",
"program": "${workspaceFolder}/src/Duempelkas.Desktop/bin/Debug/net10.0/Duempelkas.Desktop.exe",
"args": [],
"cwd": "${workspaceFolder}/src/Duempelkas.Desktop",
"stopAtEntry": false,

View File

@@ -2,8 +2,11 @@
@inject IAccountService AccountService
@inject IBackupService BackupService
@inject ISettingsService SettingsService
@inject IPdfStatementService PdfStatementService
@inject IFileSaveService FileSaveService
@inject IJSRuntime JsRuntime
@inject NavigationManager NavigationManager
@using System.Globalization
<div class="container-fluid">
@@ -29,8 +32,22 @@
<button class="btn-nav btn-warning" @onclick="HandleRestoreAsync">
<i class="bi bi-arrow-counterclockwise"></i> Restore
</button>
|
<button class="btn-nav btn-dark" @onclick="HandleDashboardExportAsync">
<i class="bi bi-file-earmark-pdf"></i> PDF
</button>
</div>
@if (accounts != null && accounts.Any())
{
<div class="alert alert-secondary py-2 px-3 mb-3" role="status">
<strong>Summe aller Konten:</strong>
<span class="ms-2 @(TotalClubBalance >= 0 ? "text-success" : "text-danger")">
@FormatCurrency(TotalClubBalance)
</span>
</div>
}
@if (!string.IsNullOrWhiteSpace(operationMessage))
{
<div class="alert @operationMessageClass mb-3" role="alert">
@@ -68,6 +85,18 @@
OnSave="HandleSaveClubName" OnCancel="() => showEditClubName = false" />
}
@if (!string.IsNullOrWhiteSpace(savedPdfPath))
{
<ConfirmDialog Title="PDF öffnen"
Message="Die PDF wurde gespeichert. Möchten Sie sie jetzt öffnen?"
ConfirmText="Ja, öffnen"
CancelText="Nein"
ConfirmButtonClass="btn btn-primary"
ConfirmIconClass="bi bi-box-arrow-up-right"
OnConfirm="HandleOpenSavedPdf"
OnCancel="CancelOpenSavedPdf" />
}
@code {
private List<AccountSummaryDto>? accounts;
private bool showAddAccount;
@@ -75,7 +104,9 @@
private string clubName = string.Empty;
private string? operationMessage;
private string operationMessageClass = "alert-info";
private string? savedPdfPath;
private string DisplayClubName => string.IsNullOrWhiteSpace(clubName) ? "Mein Verein" : clubName;
private decimal TotalClubBalance => accounts?.Sum(a => a.TotalBalance) ?? 0m;
protected override async Task OnInitializedAsync()
{
@@ -136,9 +167,35 @@
}
private async Task HandleDashboardExportAsync()
{
var pdf = await PdfStatementService.GenerateDashboardStatementAsync();
savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{DisplayClubName}_Übersicht.pdf");
}
private async Task HandleOpenSavedPdf()
{
if (!string.IsNullOrWhiteSpace(savedPdfPath))
{
await FileSaveService.OpenFileAsync(savedPdfPath);
}
savedPdfPath = null;
}
private void CancelOpenSavedPdf()
{
savedPdfPath = null;
}
private void SetOperationMessage(string message, bool success)
{
operationMessage = message;
operationMessageClass = success ? "alert-success" : "alert-danger";
}
private static string FormatCurrency(decimal amount)
{
return amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE")) + " €";
}
}

View File

@@ -3,4 +3,5 @@ namespace Duempelkas.App.Services;
public interface IPdfStatementService
{
Task<byte[]> GenerateStatementAsync(int accountId, bool currentYearOnly);
Task<byte[]> GenerateDashboardStatementAsync();
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -3,6 +3,7 @@
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<OutputType Condition="$([MSBuild]::IsOSPlatform('Windows'))">WinExe</OutputType>
<ApplicationIcon>Assets\app-icon.ico</ApplicationIcon>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>$([System.DateTime]::Now.ToString('yy')).$([System.DateTime]::Now.DayOfYear)</Version>
@@ -23,6 +24,9 @@
<Content Include="wwwroot\**">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Assets\app-icon.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>

View File

@@ -3,18 +3,49 @@ using Duempelkas.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Photino.Blazor;
using System.Runtime.InteropServices;
namespace Duempelkas.Desktop;
class Program
{
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern int SetCurrentProcessExplicitAppUserModelID(string appID);
[STAThread]
static void Main(string[] args)
{
if (OperatingSystem.IsWindows())
{
// Ensure Windows taskbar does not group this process under generic dotnet host identity.
SetCurrentProcessExplicitAppUserModelID("Duempelkas.Desktop");
}
var appBuilder = PhotinoBlazorAppBuilder.CreateDefault(args);
var exeDirectory = Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory;
var dbPath = Path.Combine(exeDirectory, "duempelkas.db");
// Use a per-user writable DB location so debugging via dotnet host works reliably.
var appDataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Duempelkas");
Directory.CreateDirectory(appDataDir);
var dbPath = Path.Combine(appDataDir, "duempelkas.db");
// One-time compatibility: keep existing data if older versions stored DB next to the app executable.
var legacyExeDirectory = Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory;
var legacyDbPath = Path.Combine(legacyExeDirectory, "duempelkas.db");
if (!File.Exists(dbPath) && File.Exists(legacyDbPath))
{
try
{
File.Copy(legacyDbPath, dbPath);
}
catch
{
// Ignore copy errors and continue with a fresh DB.
}
}
appBuilder.Services.AddInfrastructure($"Data Source={dbPath}");
appBuilder.RootComponents.Add<Duempelkas.App.Components.App>("app");
@@ -23,6 +54,13 @@ class Program
app.MainWindow.StartUrl = PhotinoWebViewManager.AppBaseUri;
// Force the native window icon for title bar + taskbar representation.
var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "app-icon.ico");
if (File.Exists(iconPath))
{
app.MainWindow.SetIconFile(iconPath);
}
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<FinanceDbContext>();

View File

@@ -1,6 +1,7 @@
using System.Globalization;
using Duempelkas.App.Services;
using Duempelkas.App.Services.Models;
using Duempelkas.Domain.Entities;
using Duempelkas.Domain.Enums;
using Duempelkas.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -138,6 +139,182 @@ public class PdfStatementService : IPdfStatementService
return document.GeneratePdf();
}
public async Task<byte[]> GenerateDashboardStatementAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
var accounts = await db.Accounts
.OrderBy(a => a.Name)
.ToListAsync();
var accountById = accounts.ToDictionary(a => a.Id);
var clubName = await _settingsService.GetClubNameAsync() ?? "Mein Verein";
var transfers = await db.TransferLinks
.Include(tl => tl.SourceEntry).ThenInclude(e => e.Account)
.Include(tl => tl.TargetEntry).ThenInclude(e => e.Account)
.ToListAsync();
var transferByEntryId = new Dictionary<int, TransferLink>();
foreach (var transfer in transfers)
{
transferByEntryId[transfer.SourceEntryId] = transfer;
transferByEntryId[transfer.TargetEntryId] = transfer;
}
var entries = await db.Entries
.Include(e => e.Account)
.Where(e => !e.IsDeleted)
.OrderBy(e => e.Date)
.ThenBy(e => e.CreatedUtc)
.ToListAsync();
var movementByAccountId = entries
.GroupBy(e => e.AccountId)
.ToDictionary(
g => g.Key,
g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount));
var totalClubBalance = accounts.Sum(a => a.CarryoverBalance + movementByAccountId.GetValueOrDefault(a.Id));
var processedTransferIds = new HashSet<int>();
var rows = new List<DashboardStatementRow>();
foreach (var entry in entries)
{
if (!transferByEntryId.TryGetValue(entry.Id, out var transfer))
{
rows.Add(new DashboardStatementRow(
entry.DisplayId,
entry.Date,
entry.Title,
new Dictionary<int, decimal>
{
[entry.AccountId] = entry.Type == EntryType.Income ? entry.Amount : -entry.Amount
}));
continue;
}
if (!processedTransferIds.Add(transfer.Id))
continue;
var source = transfer.SourceEntry;
var target = transfer.TargetEntry;
if (source.IsDeleted || target.IsDeleted)
continue;
var transferTitle = $"{source.Title} ({source.Account.Name} -> {target.Account.Name})";
rows.Add(new DashboardStatementRow(
source.DisplayId,
source.Date,
transferTitle,
new Dictionary<int, decimal>
{
[source.AccountId] = -source.Amount,
[target.AccountId] = target.Amount
}));
}
rows = rows
.OrderBy(r => ParseDisplayIdYear(r.DisplayId))
.ThenBy(r => ParseDisplayIdSequence(r.DisplayId))
.ThenBy(r => r.Date)
.ThenBy(r => r.Title)
.ToList();
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4.Landscape());
page.MarginHorizontal(24);
page.MarginVertical(20);
page.DefaultTextStyle(x => x.FontSize(9));
page.Header().Column(col =>
{
col.Item().Text(clubName).Bold().FontSize(18);
col.Item().Text("Übersicht aller Konten").FontSize(13).FontColor(Colors.Grey.Darken1);
col.Item().PaddingTop(2).Text($"Gesamtvermögen: {FormatCurrency(totalClubBalance)}")
.FontSize(10)
.SemiBold()
.FontColor(totalClubBalance >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
col.Item().PaddingTop(4).Text($"Erstellt am: {DateTime.Now:dd.MM.yyyy}").FontSize(8).FontColor(Colors.Grey.Medium);
col.Item().PaddingTop(8).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
});
page.Content().PaddingTop(10).Column(content =>
{
content.Item().Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.ConstantColumn(70);
columns.ConstantColumn(70);
columns.RelativeColumn(2);
foreach (var _ in accounts)
columns.RelativeColumn();
});
table.Header(header =>
{
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
.PaddingBottom(4).Text("LFDNR").SemiBold();
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
.PaddingBottom(4).Text("Datum").SemiBold();
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
.PaddingBottom(4).Text("Titel").SemiBold();
foreach (var account in accounts)
{
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
.PaddingBottom(4).AlignRight().Text(account.Name).SemiBold();
}
});
foreach (var row in rows)
{
BodyCell(table, row.DisplayId);
BodyCell(table, row.Date.ToString("dd.MM.yyyy"));
BodyCell(table, row.Title);
foreach (var account in accounts)
{
if (row.AmountByAccountId.TryGetValue(account.Id, out var amount))
{
BodyAmountCell(table, amount);
}
else
{
BodyCell(table, string.Empty, alignRight: true);
}
}
}
});
content.Item().PaddingTop(8).AlignRight().Text(text =>
{
text.Span("Gesamtvermögen: ").SemiBold();
text.Span(FormatCurrency(totalClubBalance))
.SemiBold()
.FontColor(totalClubBalance >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
});
});
page.Footer().AlignCenter().Text(text =>
{
text.Span("Seite ");
text.CurrentPageNumber();
text.Span(" von ");
text.TotalPages();
});
});
});
return document.GeneratePdf();
}
private static void SummaryRow(ColumnDescriptor col, string label, decimal value)
{
col.Item().PaddingVertical(2).Row(row =>
@@ -152,4 +329,40 @@ public class PdfStatementService : IPdfStatementService
{
return amount.ToString("N2", DeLocale) + " €";
}
private static void BodyCell(TableDescriptor table, string text, bool alignRight = false)
{
var cell = table.Cell()
.BorderBottom(0.5f)
.BorderColor(Colors.Grey.Lighten2)
.PaddingVertical(3);
var content = alignRight ? cell.AlignRight() : cell;
content.Text(text);
}
private static void BodyAmountCell(TableDescriptor table, decimal amount)
{
table.Cell()
.BorderBottom(0.5f)
.BorderColor(Colors.Grey.Lighten2)
.PaddingVertical(3)
.AlignRight()
.Text($"{(amount >= 0 ? "+" : "")}{FormatCurrency(Math.Abs(amount))}")
.FontColor(amount >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
}
private static int ParseDisplayIdYear(string displayId)
{
var parts = displayId.Split('-');
return parts.Length == 2 && int.TryParse(parts[0], out var year) ? year : int.MaxValue;
}
private static int ParseDisplayIdSequence(string displayId)
{
var parts = displayId.Split('-');
return parts.Length == 2 && int.TryParse(parts[1], out var seq) ? seq : int.MaxValue;
}
private sealed record DashboardStatementRow(string DisplayId, DateTime Date, string Title, Dictionary<int, decimal> AmountByAccountId);
}

View File

@@ -0,0 +1,132 @@
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<FinanceDbContext>()
.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");
}
public void Dispose()
{
_db.Database.CloseConnection();
_db.Dispose();
}
private sealed class TestDbContextFactory : IDbContextFactory<FinanceDbContext>
{
private readonly DbContextOptions<FinanceDbContext> _options;
public TestDbContextFactory(DbContextOptions<FinanceDbContext> options)
{
_options = options;
}
public FinanceDbContext CreateDbContext() => new(_options);
public Task<FinanceDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new FinanceDbContext(_options));
}
private sealed class FixedSettingsService : ISettingsService
{
private readonly string? _clubName;
public FixedSettingsService(string? clubName)
{
_clubName = clubName;
}
public Task<string?> GetClubNameAsync() => Task.FromResult(_clubName);
public Task SetClubNameAsync(string? clubName) => Task.CompletedTask;
}
}