Compare commits

..
3 Commits
24 changed files with 604 additions and 234 deletions
+29
View File
@@ -0,0 +1,29 @@
# Umsetzung U01–U07
Reihenfolge und je ein eigener Commit:
1. **U01:** Gemeinsamer Datenpfad, SQLite-Online-Sicherung, geprüfte Wiederherstellung mit Rückfallsicherung und koordinierten Schreibzugriffen. Nur isolierte Testdaten wiederherstellen.
2. **U02:** Strukturierte Ergebnisse (Erfolg, Abbruch, Fehler), sichtbare Meldungen, sichere Dateiaktionen und Sperre während Dateioperationen.
3. **U03:** Nur wirksame Buchungen exportieren; Zeitraum, Anfangs-/Endstand und interne Umbuchungen erklären. PDF-Inhalte und Darstellung prüfen.
4. **U04:** Gemeinsamer Jahreskontext, sichtbarer Zeitraum, Hinweis und Rückweg bei abweichendem Buchungsjahr; Datum beibehalten.
5. **U05:** Alltagssprache, flexible Aktionsleiste, Wiederherstellung unter weiteren Aktionen, Hinweis zur Umbuchung.
6. **U06:** Konto und Art anzeigen, feldnahe Validierung. Komma oder Punkt als Dezimaltrenner, keine Tausendertrenner; höchstens zwei Nachkommastellen, positive Beträge. Art beim Bearbeiten fest: falsche Buchung löschen und neu erfassen.
7. **U07:** Speichersperre, Fortschritt, Fehler mit erhaltenen Eingaben, ausdrückliche Erfolgsmeldung und Hervorhebung.
Prüfung: gezielte automatisierte Regressionen, Debug/Release, Desktop-Prüfung und PDF-Sichtprüfung soweit verfügbar. Anschließend Commits pushen und PR-Beschreibung mit Ergebnissen und Grenzen aktualisieren. U08–U16 bleiben außerhalb dieses Auftrags.
## U01 – Sicherungsumfang
Die Sicherung enthält Konten, Anfangsbestände, Buchungen einschließlich gelöschter Zeilen, Umbuchungsverknüpfungen und Datenbankschema. Vereinsname, Farbdarstellung und Fenstergröße liegen außerhalb der Datenbank und sind **nicht enthalten**. Eine Kopie auf derselben Festplatte schützt nicht gegen Geräteausfall; dafür die Sicherung zusätzlich auf einem unabhängigen Datenträger aufbewahren.
Wiederherstellen akzeptiert nur vollständige Datenbanken derselben Schemaversion. Vorher wird die ausgewählte Datei als konsistenter SQLite-Snapshot zwischengespeichert und auf Integrität, Fremdschlüssel, Migrationen und alle Modellspalten geprüft. Die bestehende Datenbank erhält eine eindeutige `.before-restore-…bak` daneben. Diese Rückfallsicherungen werden nicht automatisch gelöscht; nach geprüfter Wiederherstellung bewusst archivieren. Anwendungsschreibzugriffe warten während der Wiederherstellung, SQLite koordiniert offene Leser. Der Austausch erfolgt mit der SQLite-Backup-API im bestehenden Datenbankhandle. Keine automatische Sicherung beim Beenden.
Prüfung U01: 16/16 Debug-Tests bestanden, darunter WAL-Rundlauf mit exaktem Buchungsstand und Rückfallsicherung, beschädigte/fremde SQLite-Datei und fehlende Quelle. Kein echter Vereinsdatenbestand wurde zurückgespielt.
## U02 – Dateiaktionen
Strukturierte Ergebnisse und gemeinsame Statusanzeige mit optionalen technischen Details, Speicherpfad und „Ordner öffnen“. Abbruch ist neutral. Dateiaktionen sperren erneutes Auslösen; Fehler beim Erzeugen oder Öffnen einer PDF bleiben in der aktuellen Ansicht sichtbar. Native Dateidialoge melden Start-/Prozessfehler und erhalten Unicode-Pfade. Gespeichert wird über eine temporäre Datei, damit Schreibfehler eine bestehende Datei nicht verstümmeln. Prüfung: 18/18 Debug-Tests bestanden.
## U03 – Gleiche Bedeutung in PDF und Oberfläche
Einzelkonto und Übersicht enthalten nur wirksame Buchungen. Zeitraum und Bedeutung des Endstands sind ausdrücklich benannt, auch bei „Alle Jahre“ mit Anfangsbestand. Im Einzelkonto zählen Umbuchungen als Zu-/Abgang; die Übersicht zeigt beide Konten in einer Zeile und erklärt die unveränderte Vereinssumme. Prüfung: 19/19 Debug-Tests. PDF-Textextraktion prüft 58,30 € Kontostand, 108,30 € Vereinssumme, ausgeschlossene gelöschte/spätere Buchungen und Richtung. Einzelkonto, gemeinsame Übersicht und Seiten 2/3 eines dreiseitigen Auszugs gerendert und visuell auf Lesbarkeit, wiederholte Kopfzeilen und Abschluss geprüft.
@@ -0,0 +1,40 @@
@inject IFileSaveService FileSaveService
@if (Result != null)
{
<div class="alert @(Result.Status == FileOperationStatus.Error ? "alert-danger" : Result.Status == FileOperationStatus.Success ? "alert-success" : "alert-info")" role="status" aria-live="polite" aria-atomic="true">
<p>@Result.Message</p>
@if (Result.Path != null)
{
<p class="text-break">@Result.Path</p>
<button class="btn btn-outline-secondary" disabled="@busy" @onclick="OpenFolder">Ordner öffnen</button>
}
@if (Result.Details != null)
{
<details><summary>Technische Details</summary><p class="text-break">@Result.Details</p></details>
}
@if (openResult != null)
{
<p role="alert">@openResult.Message</p>
@if (openResult.Details != null) { <details><summary>Technische Details</summary>@openResult.Details</details> }
}
</div>
}
@code {
[Parameter] public FileOperationResult? Result { get; set; }
private FileOperationResult? openResult;
private FileOperationResult? previous;
private bool busy;
protected override void OnParametersSet()
{
if (previous != Result) { openResult = null; previous = Result; }
}
private async Task OpenFolder()
{
if (busy || Result?.Path == null) return;
busy = true;
try { openResult = await FileSaveService.OpenFolderAsync(Result.Path); }
finally { busy = false; }
}
}
@@ -7,6 +7,9 @@
@inject NavigationManager Navigation
<div class="container-fluid">
<OperationNotice Result="fileResult" />
@if (fileBusy) { <p role="status" aria-live="polite">Dateiaktion läuft … Bitte warten.</p> }
@if (account == null)
{
<div class="text-center py-5">
@@ -35,7 +38,7 @@
<button class="btn btn-info btn-nav" @onclick="() => showAddTransfer = true">
<i class="bi bi-arrow-left-right"></i> Umbuchung
</button>
<button class="btn btn-dark btn-nav" @onclick="HandleExport">
<button class="btn btn-dark btn-nav" @onclick="HandleExport" disabled="@fileBusy">
<i class="bi bi-file-earmark-pdf"></i> PDF
</button>
|
@@ -32,6 +32,8 @@ public partial class AccountDetail
private string? confirmRestoreEntryTitle;
private string? savedPdfPath;
private FileOperationResult? fileResult;
private bool fileBusy;
private EntryDto? editingEntry;
private EntryDto? editingTransferEntry;
@@ -178,19 +180,39 @@ public partial class AccountDetail
private async Task HandleExport()
{
if (fileBusy) return;
fileBusy = true;
fileResult = null;
try
{
var pdf = await PdfStatementService.GenerateStatementAsync(AccountId, selectedYear);
var suffix = selectedYear.HasValue ? $"_{selectedYear.Value}" : "_Gesamt";
savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{account?.Name}{suffix}.pdf");
fileResult = await FileSaveService.SaveFileAsync(pdf, $"{account?.Name}{suffix}.pdf");
savedPdfPath = fileResult.Status == FileOperationStatus.Success ? fileResult.Path : null;
}
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
finally { fileBusy = false; }
}
private async Task HandleOpenSavedPdf()
{
if (fileBusy) return;
fileBusy = true;
fileResult = null;
try
{
if (!string.IsNullOrWhiteSpace(savedPdfPath))
{
await FileSaveService.OpenFileAsync(savedPdfPath);
fileResult = await FileSaveService.OpenFileAsync(savedPdfPath);
}
savedPdfPath = null;
}
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
finally { fileBusy = false; }
}
private void CancelOpenSavedPdf()
+6 -9
View File
@@ -8,6 +8,9 @@
@inject NavigationManager NavigationManager
<div class="container-fluid">
<OperationNotice Result="fileResult" />
@if (fileBusy) { <p role="status" aria-live="polite">Dateiaktion läuft … Bitte warten.</p> }
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center flex-grow-1" style="min-width: 0;">
@@ -25,14 +28,14 @@
<i class="bi bi-plus-lg"></i> Neues Konto
</button>
|
<button class="btn-nav btn-success" @onclick="HandleBackupAsync">
<button class="btn-nav btn-success" @onclick="HandleBackupAsync" disabled="@fileBusy">
<i class="bi bi-save"></i> Backup
</button>
<button class="btn-nav btn-warning" @onclick="HandleRestoreAsync">
<button class="btn-nav btn-warning" @onclick="HandleRestoreAsync" disabled="@fileBusy">
<i class="bi bi-arrow-counterclockwise"></i> Restore
</button>
|
<button class="btn-nav btn-dark" @onclick="HandleDashboardExportAsync">
<button class="btn-nav btn-dark" @onclick="HandleDashboardExportAsync" disabled="@fileBusy">
<i class="bi bi-file-earmark-pdf"></i> PDF
</button>
|
@@ -54,12 +57,6 @@
</div>
}
@if (!string.IsNullOrWhiteSpace(operationMessage))
{
<div class="alert @operationMessageClass mb-3" role="alert">
@operationMessage
</div>
}
@if (accounts == null)
{
+42 -23
View File
@@ -17,9 +17,9 @@ public partial class Dashboard
private int? selectedYear = DateTime.Now.Year;
private List<int> availableYears = [];
private string clubName = string.Empty;
private string? operationMessage;
private string operationMessageClass = "alert-info";
private string? savedPdfPath;
private FileOperationResult? fileResult;
private bool fileBusy;
private string currentTheme = "light";
#endregion
@@ -100,11 +100,16 @@ public partial class Dashboard
private async Task HandleBackupAsync()
{
var message = await BackupService.CreateBackupAsync();
if (message.Contains("fehlgeschlagen", StringComparison.OrdinalIgnoreCase))
if (fileBusy) return;
fileBusy = true;
fileResult = null;
try
{
SetOperationMessage(message, false);
fileResult = await BackupService.CreateBackupAsync();
}
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
finally { fileBusy = false; }
}
private Task HandleRestoreAsync()
@@ -120,25 +125,36 @@ public partial class Dashboard
private async Task HandleConfirmRestore()
{
showRestoreConfirm = false;
var message = await BackupService.RestoreBackupAsync();
var isSuccess = message.StartsWith("Wiederherstellung erfolgreich", StringComparison.OrdinalIgnoreCase);
if (fileBusy) return;
fileBusy = true;
fileResult = null;
try
{
showRestoreConfirm = false;
fileResult = await BackupService.RestoreBackupAsync();
if (fileResult.Status == FileOperationStatus.Success) await LoadAll();
if (isSuccess)
{
NavigationManager.NavigateTo("/", forceLoad: true);
}
else
{
SetOperationMessage(message, isSuccess);
}
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
finally { fileBusy = false; }
}
private async Task HandleDashboardExportAsync()
{
if (fileBusy) return;
fileBusy = true;
fileResult = null;
try
{
var pdf = await PdfStatementService.GenerateDashboardStatementAsync(selectedYear);
var suffix = selectedYear.HasValue ? $"_{selectedYear.Value}" : "_Gesamt";
savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{DisplayClubName}_Übersicht{suffix}.pdf");
fileResult = await FileSaveService.SaveFileAsync(pdf, $"{DisplayClubName}_Übersicht{suffix}.pdf");
savedPdfPath = fileResult.Status == FileOperationStatus.Success ? fileResult.Path : null;
}
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
finally { fileBusy = false; }
}
private void OpenYearFilterDialog()
@@ -160,12 +176,21 @@ public partial class Dashboard
private async Task HandleOpenSavedPdf()
{
if (fileBusy) return;
fileBusy = true;
fileResult = null;
try
{
if (!string.IsNullOrWhiteSpace(savedPdfPath))
{
await FileSaveService.OpenFileAsync(savedPdfPath);
fileResult = await FileSaveService.OpenFileAsync(savedPdfPath);
}
savedPdfPath = null;
}
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
finally { fileBusy = false; }
}
private void CancelOpenSavedPdf()
@@ -177,12 +202,6 @@ public partial class Dashboard
#region Helpers
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")) + " €";
@@ -1,7 +1,9 @@
using Duempelkas.App.Services.Models;
namespace Duempelkas.App.Services;
public interface IBackupService
{
Task<string> CreateBackupAsync();
Task<string> RestoreBackupAsync();
Task<FileOperationResult> CreateBackupAsync();
Task<FileOperationResult> RestoreBackupAsync();
}
@@ -0,0 +1,7 @@
namespace Duempelkas.App.Services;
public interface IFileDialogService
{
Task<string?> SaveAsync(string suggestedFileName);
Task<string?> OpenBackupAsync();
}
@@ -1,7 +1,10 @@
using Duempelkas.App.Services.Models;
namespace Duempelkas.App.Services;
public interface IFileSaveService
{
Task<string?> SaveFileAsync(byte[] content, string suggestedFileName);
Task OpenFileAsync(string filePath);
Task<FileOperationResult> SaveFileAsync(byte[] content, string suggestedFileName);
Task<FileOperationResult> OpenFileAsync(string filePath);
Task<FileOperationResult> OpenFolderAsync(string filePath);
}
@@ -0,0 +1,10 @@
namespace Duempelkas.App.Services.Models;
public enum FileOperationStatus { Success, Cancelled, Error }
public sealed record FileOperationResult(FileOperationStatus Status, string Message, string? Path = null, string? Details = null)
{
public static FileOperationResult Cancelled() => new(FileOperationStatus.Cancelled, "Vorgang abgebrochen. Es wurde nichts geändert.");
public static FileOperationResult Error(Exception error) => new(FileOperationStatus.Error,
"Die Dateiaktion konnte nicht abgeschlossen werden. Bitte den Speicherort und die Zugriffsrechte prüfen oder einen anderen Speicherort wählen.", Details: error.Message);
}
+7 -3
View File
@@ -38,11 +38,15 @@ class Program
{
try
{
File.Copy(legacyDbPath, dbPath);
using var source = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={legacyDbPath};Mode=ReadOnly");
using var target = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={dbPath}");
source.Open();
target.Open();
source.BackupDatabase(target);
}
catch
catch (Exception ex)
{
// Ignore copy errors and continue with a fresh DB.
throw new InvalidOperationException("Die vorhandene Datenbank konnte nicht ?bernommen werden. Der Start wurde abgebrochen; die urspr?ngliche Datei bleibt erhalten.", ex);
}
}
@@ -10,6 +10,7 @@ public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString)
{
services.AddSingleton(new DatabaseLocation(connectionString));
services.AddDbContext<FinanceDbContext>(options =>
options.UseSqlite(connectionString));
services.AddDbContextFactory<FinanceDbContext>(options =>
@@ -19,6 +20,7 @@ public static class DependencyInjection
services.AddScoped<IEntryService, EntryService>();
services.AddScoped<IBalanceQueryService, BalanceQueryService>();
services.AddScoped<IPdfStatementService, PdfStatementService>();
services.AddSingleton<IFileDialogService, FileDialogService>();
services.AddScoped<IFileSaveService, FileSaveService>();
services.AddSingleton<IBackupService, BackupService>();
services.AddSingleton<ISettingsService, SettingsService>();
@@ -11,7 +11,13 @@ public class AccountService : IAccountService
{
private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
public AccountService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory;
private readonly DatabaseLocation? _location;
public AccountService(IDbContextFactory<FinanceDbContext> dbFactory, DatabaseLocation? location = null)
{
_dbFactory = dbFactory;
_location = location;
}
public Task<List<AccountSummaryDto>> GetAllAccountsAsync()
{
@@ -20,6 +26,7 @@ public class AccountService : IAccountService
public async Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var accounts = await db.Accounts
@@ -71,6 +78,7 @@ public class AccountService : IAccountService
public async Task<AccountSummaryDto> GetAccountAsync(int accountId)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts
@@ -87,6 +95,7 @@ public class AccountService : IAccountService
public async Task<AccountSummaryDto> CreateAccountAsync(string name)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var account = new Account { Name = name };
@@ -97,6 +106,7 @@ public class AccountService : IAccountService
public async Task RenameAccountAsync(int accountId, string newName)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts.FindAsync(accountId)
@@ -107,6 +117,7 @@ public class AccountService : IAccountService
public async Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts.FindAsync(accountId)
@@ -123,6 +134,7 @@ public class AccountService : IAccountService
return;
}
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts.FindAsync(accountId)
@@ -138,6 +150,7 @@ public class AccountService : IAccountService
public async Task DeleteAccountAsync(int accountId)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts
@@ -1,3 +1,4 @@
using Duempelkas.App.Services.Models;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Duempelkas.App.Services;
@@ -7,140 +8,126 @@ namespace Duempelkas.Infrastructure.Services;
public class BackupService : IBackupService
{
private static readonly string DbPath = Path.Combine(
Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory,
"duempelkas.db");
private readonly DatabaseLocation location;
public async Task<string> CreateBackupAsync()
private readonly IFileDialogService dialogs;
public BackupService(DatabaseLocation location, IFileDialogService? dialogs = null)
{
if (!File.Exists(DbPath))
{
return "Keine Datenbankdatei gefunden.";
}
var suggestedFileName = $"duempelkas-backup-{DateTime.Now:yyMMdd-HHmm}.bak";
var backupPath = await ShowSaveDialogAsync(suggestedFileName);
if (string.IsNullOrWhiteSpace(backupPath))
{
return "Backup wurde abgebrochen.";
}
backupPath = Path.ChangeExtension(backupPath, ".bak");
try
{
File.Copy(DbPath, backupPath, overwrite: true);
return $"Backup gespeichert: {backupPath}";
}
catch (Exception ex)
{
return $"Backup fehlgeschlagen: {ex.Message}";
}
this.location = location;
this.dialogs = dialogs ?? new FileDialogService();
}
public async Task<string> RestoreBackupAsync()
public async Task<FileOperationResult> CreateBackupAsync()
{
var backupPath = await ShowOpenDialogAsync();
if (string.IsNullOrWhiteSpace(backupPath))
{
return "Wiederherstellung wurde abgebrochen.";
}
if (!File.Exists(backupPath))
{
return "Die gewählte Backup-Datei wurde nicht gefunden.";
}
try
{
// Ensure no pooled connection keeps stale handles to the previous DB file.
SqliteConnection.ClearAllPools();
var targetDirectory = Path.GetDirectoryName(DbPath);
if (!string.IsNullOrWhiteSpace(targetDirectory))
{
Directory.CreateDirectory(targetDirectory);
}
File.Copy(backupPath, DbPath, overwrite: true);
SqliteConnection.ClearAllPools();
return "Wiederherstellung erfolgreich. Die Datenbank wurde neu geladen.";
}
catch (Exception ex)
{
return $"Wiederherstellung fehlgeschlagen: {ex.Message}";
var path = await dialogs.SaveAsync($"duempelkas-backup-{DateTime.Now:yyyyMMdd-HHmmss}.bak");
if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled();
await CreateBackupToAsync(path);
return new(FileOperationStatus.Success, "Sicherung gespeichert.", path);
}
catch (Exception ex) { return FileOperationResult.Error(ex); }
}
private static Task<string?> ShowSaveDialogAsync(string suggestedFileName)
public async Task<FileOperationResult> RestoreBackupAsync()
{
return Task.Run(() =>
try
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
var path = await dialogs.OpenBackupAsync();
if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled();
var fallback = await RestoreFromAsync(path);
return new(FileOperationStatus.Success, "Sicherung zurückgespielt. Die bisherigen Daten liegen in der Rückfallsicherung:", fallback);
}
catch (Exception ex) { return FileOperationResult.Error(ex); }
}
public async Task CreateBackupToAsync(string path)
{
using var lease = await DatabaseLease.AcquireAsync(location);
RejectActivePath(path);
await Task.Run(() =>
{
var staged = System.IO.Path.GetFullPath(path) + $".{Guid.NewGuid():N}.tmp";
try
{
var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
return Path.Combine(documentsPath, suggestedFileName);
Snapshot(location.Path, staged);
File.Move(staged, path, overwrite: true);
}
var script = $@"
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.SaveFileDialog
$dialog.Filter = 'Backup-Dateien (*.bak)|*.bak'
$dialog.DefaultExt = 'bak'
$dialog.AddExtension = $true
$dialog.OverwritePrompt = $true
$dialog.FileName = '{suggestedFileName.Replace("'", "''")}'
$dialog.Title = 'Backup speichern'
if ($dialog.ShowDialog() -eq 'OK') {{ $dialog.FileName }} else {{ '' }}
";
return RunPowerShellDialog(script);
finally { if (File.Exists(staged)) File.Delete(staged); }
});
}
private static Task<string?> ShowOpenDialogAsync()
public async Task<string> RestoreFromAsync(string path)
{
return Task.Run(() =>
using var lease = await DatabaseLease.AcquireAsync(location);
return await Task.Run(() =>
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
RejectActivePath(path);
// Stage the selected database first, so a changing/removable source cannot
// change between validation and restore. BackupDatabase includes WAL data.
var staged = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"duempelkas-{Guid.NewGuid():N}.db");
try
{
return null;
Snapshot(path, staged);
Validate(staged);
var fallback = location.Path + $".before-restore-{DateTime.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}.bak";
Snapshot(location.Path, fallback);
// SQLite's online backup atomically replaces database contents, retaining
// the active file/handles and coordinating locks. Never copy over an open DB.
Snapshot(staged, location.Path);
return fallback;
}
var script = @"
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.OpenFileDialog
$dialog.Filter = 'Backup-Dateien (*.bak)|*.bak'
$dialog.Multiselect = $false
$dialog.Title = 'Backup auswählen'
if ($dialog.ShowDialog() -eq 'OK') { $dialog.FileName } else { '' }
";
return RunPowerShellDialog(script);
finally { if (File.Exists(staged)) File.Delete(staged); }
});
}
private static string? RunPowerShellDialog(string script)
private void RejectActivePath(string path)
{
var psi = new ProcessStartInfo
{
FileName = "powershell",
Arguments = $"-NoProfile -Command \"{script.Replace("\"", "\\\"") }\"",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(psi);
if (process == null)
{
return null;
}
var result = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
return string.IsNullOrWhiteSpace(result) ? null : result;
if (new[] { location.Path, location.Path + "-wal", location.Path + "-shm", location.Path + "-journal" }
.Contains(System.IO.Path.GetFullPath(path), StringComparer.OrdinalIgnoreCase))
throw new InvalidOperationException("Bitte eine andere Datei als die aktive Datenbank wählen.");
}
}
private static SqliteConnection Open(string path, SqliteOpenMode mode)
{
var connection = new SqliteConnection(new SqliteConnectionStringBuilder
{
DataSource = System.IO.Path.GetFullPath(path), Mode = mode, Pooling = false
}.ToString());
try { connection.Open(); return connection; }
catch { connection.Dispose(); throw; }
}
private static void Snapshot(string source, string destination)
{
if (string.Equals(System.IO.Path.GetFullPath(source), System.IO.Path.GetFullPath(destination), StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Quelle und Ziel müssen verschiedene Dateien sein.");
using var input = Open(source, SqliteOpenMode.ReadOnly);
using var output = Open(destination, SqliteOpenMode.ReadWriteCreate);
input.BackupDatabase(output);
}
private static void Validate(string path)
{
using var connection = Open(path, SqliteOpenMode.ReadOnly);
using var check = connection.CreateCommand();
check.CommandText = "PRAGMA integrity_check";
if (!string.Equals(check.ExecuteScalar()?.ToString(), "ok", StringComparison.Ordinal))
throw new InvalidOperationException("Die Sicherung ist beschädigt.");
check.CommandText = "PRAGMA foreign_key_check";
using (var reader = check.ExecuteReader())
if (reader.Read()) throw new InvalidOperationException("Die Sicherung enthält ungültige Verknüpfungen.");
var options = new Microsoft.EntityFrameworkCore.DbContextOptionsBuilder<Duempelkas.Infrastructure.Persistence.FinanceDbContext>();
Microsoft.EntityFrameworkCore.SqliteDbContextOptionsBuilderExtensions.UseSqlite(options, connection);
using var db = new Duempelkas.Infrastructure.Persistence.FinanceDbContext(options.Options);
var expected = Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.GetMigrations(db.Database).ToArray();
var applied = Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.GetAppliedMigrations(db.Database).ToArray();
if (!expected.SequenceEqual(applied))
throw new InvalidOperationException("Diese Sicherung passt nicht zu dieser Programmversion.");
// Materialize every mapped column to reject a forged/missing schema before touching active data.
_ = db.Accounts.ToArray();
_ = db.Entries.ToArray();
_ = db.TransferLinks.ToArray();
}
}
@@ -0,0 +1,12 @@
namespace Duempelkas.Infrastructure.Services;
internal sealed class DatabaseLease(SemaphoreSlim? gate) : IDisposable
{
public static async Task<DatabaseLease> AcquireAsync(DatabaseLocation? location)
{
if (location != null) await location.Writes.WaitAsync();
return new DatabaseLease(location?.Writes);
}
public void Dispose() => gate?.Release();
}
@@ -0,0 +1,12 @@
using Microsoft.Data.Sqlite;
namespace Duempelkas.Infrastructure.Services;
public sealed class DatabaseLocation(string connectionString)
{
public string Path { get; } = System.IO.Path.GetFullPath(new SqliteConnectionStringBuilder(connectionString).DataSource);
public string ConnectionString { get; } = connectionString;
// All application mutations and restore share this gate. SQLite's backup API
// also coordinates readers and external connections through SQLite locks.
public SemaphoreSlim Writes { get; } = new(1, 1);
}
@@ -11,7 +11,13 @@ public class EntryService : IEntryService
{
private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
public EntryService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory;
private readonly DatabaseLocation? _location;
public EntryService(IDbContextFactory<FinanceDbContext> dbFactory, DatabaseLocation? location = null)
{
_dbFactory = dbFactory;
_location = location;
}
public Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly)
{
@@ -21,6 +27,7 @@ public class EntryService : IEntryService
public async Task<List<EntryDto>> GetEntriesAsync(int accountId, int? year)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var query = db.Entries.Where(e => e.AccountId == accountId);
@@ -67,6 +74,7 @@ public class EntryService : IEntryService
public async Task<List<int>> GetEntryYearsAsync(int accountId)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
return await db.Entries
@@ -79,6 +87,7 @@ public class EntryService : IEntryService
public async Task<List<int>> GetAllEntryYearsAsync()
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
return await db.Entries
@@ -90,6 +99,7 @@ public class EntryService : IEntryService
public async Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var displayId = await GenerateDisplayIdAsync(db, date.Year);
@@ -114,6 +124,7 @@ public class EntryService : IEntryService
if (sourceAccountId == targetAccountId)
throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich.");
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
await using var transaction = await db.Database.BeginTransactionAsync();
@@ -163,6 +174,7 @@ public class EntryService : IEntryService
public async Task DeleteEntryAsync(int entryId)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var entry = await db.Entries.FindAsync(entryId)
@@ -185,6 +197,7 @@ public class EntryService : IEntryService
public async Task RestoreEntryAsync(int entryId)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var entry = await db.Entries.FindAsync(entryId)
@@ -207,6 +220,7 @@ public class EntryService : IEntryService
public async Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var entry = await db.Entries.FindAsync(entryId)
@@ -236,6 +250,7 @@ public class EntryService : IEntryService
public async Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount)
{
using var lease = await DatabaseLease.AcquireAsync(_location);
await using var db = await _dbFactory.CreateDbContextAsync();
var entry = await db.Entries.FindAsync(entryId)
@@ -0,0 +1,44 @@
using System.Diagnostics;
using System.Text;
using Duempelkas.App.Services;
namespace Duempelkas.Infrastructure.Services;
public sealed class FileDialogService : IFileDialogService
{
public Task<string?> SaveAsync(string suggestedFileName) => ShowAsync(suggestedFileName);
public Task<string?> OpenBackupAsync() => ShowAsync(null);
private static Task<string?> ShowAsync(string? suggestedFileName) => Task.Run(() =>
{
if (!OperatingSystem.IsWindows())
throw new PlatformNotSupportedException("Der Dateidialog ist auf diesem System nicht verfügbar.");
var save = suggestedFileName != null;
var extension = save ? (Path.GetExtension(suggestedFileName) ?? "") : ".bak";
var script = $$"""
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.{{(save ? "SaveFileDialog" : "OpenFileDialog")}}
$dialog.Filter = 'Dateien (*{{extension.Replace("'", "''")}})|*{{extension.Replace("'", "''")}}'
$dialog.Title = '{{(save ? "Datei speichern" : "Sicherung auswählen")}}'
{{(save ? "$dialog.OverwritePrompt = $true\n$dialog.AddExtension = $true\n$dialog.DefaultExt = '" + extension.TrimStart('.').Replace("'", "''") + "'\n$dialog.FileName = '" + suggestedFileName!.Replace("'", "''") + "'" : "$dialog.Multiselect = $false")}}
try { if ($dialog.ShowDialog() -eq 'OK') { [Console]::Write($dialog.FileName) } } finally { $dialog.Dispose() }
""";
var start = new ProcessStartInfo("powershell.exe")
{
RedirectStandardOutput = true, RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8, UseShellExecute = false, CreateNoWindow = true
};
start.ArgumentList.Add("-NoProfile");
start.ArgumentList.Add("-STA");
start.ArgumentList.Add("-EncodedCommand");
start.ArgumentList.Add(Convert.ToBase64String(Encoding.Unicode.GetBytes(script)));
using var process = Process.Start(start) ?? throw new IOException("Der Dateidialog konnte nicht gestartet werden.");
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0) throw new IOException("Der Dateidialog konnte nicht geöffnet werden. " + error);
return string.IsNullOrWhiteSpace(output) ? null : output;
});
}
@@ -1,90 +1,49 @@
using Duempelkas.App.Services.Models;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Duempelkas.App.Services;
namespace Duempelkas.Infrastructure.Services;
public class FileSaveService : IFileSaveService
public class FileSaveService(IFileDialogService dialogs) : IFileSaveService
{
public async Task<string?> SaveFileAsync(byte[] content, string suggestedFileName)
public async Task<FileOperationResult> SaveFileAsync(byte[] content, string suggestedFileName)
{
var filePath = await ShowSaveDialogAsync(suggestedFileName);
if (filePath == null) return null;
await File.WriteAllBytesAsync(filePath, content);
return filePath;
}
public Task OpenFileAsync(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
string? staged = null;
try
{
return Task.CompletedTask;
var path = await dialogs.SaveAsync(suggestedFileName);
if (string.IsNullOrWhiteSpace(path)) return FileOperationResult.Cancelled();
staged = path + $".{Guid.NewGuid():N}.tmp";
await File.WriteAllBytesAsync(staged, content);
File.Move(staged, path, overwrite: true);
return new(FileOperationStatus.Success, "Datei gespeichert.", path);
}
return Task.Run(() =>
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
Process.Start(new ProcessStartInfo
{
FileName = filePath,
UseShellExecute = true
});
return;
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
Process.Start("open", filePath);
return;
}
Process.Start("xdg-open", filePath);
});
catch (Exception ex) { return FileOperationResult.Error(ex); }
finally { try { if (staged != null && File.Exists(staged)) File.Delete(staged); } catch { } }
}
private static Task<string?> ShowSaveDialogAsync(string suggestedFileName)
public Task<FileOperationResult> OpenFileAsync(string filePath) => OpenAsync(filePath, false);
public Task<FileOperationResult> OpenFolderAsync(string filePath) => OpenAsync(filePath, true);
private static Task<FileOperationResult> OpenAsync(string filePath, bool folder) => Task.Run(() =>
{
return Task.Run(() =>
try
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// Fallback: save to Documents folder
var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
return Path.Combine(documentsPath, suggestedFileName);
}
var path = folder ? Path.GetDirectoryName(Path.GetFullPath(filePath))! : filePath;
if (folder ? !Directory.Exists(path) : !File.Exists(path))
throw new FileNotFoundException("Der gespeicherte Pfad ist nicht mehr vorhanden.");
var start = new ProcessStartInfo { UseShellExecute = true };
if (OperatingSystem.IsWindows()) start.FileName = path;
else { start.FileName = OperatingSystem.IsMacOS() ? "open" : "xdg-open"; start.ArgumentList.Add(path); }
using var process = Process.Start(start) ?? throw new IOException("Kein Programm zum Öffnen gefunden.");
return new FileOperationResult(FileOperationStatus.Success, folder ? "Ordner geöffnet." : "Datei geöffnet.");
}
catch (Exception ex)
{
return new FileOperationResult(FileOperationStatus.Error,
"Der Pfad konnte nicht geöffnet werden. Die gespeicherte Datei bleibt erhalten; bitte den Speicherort im Datei-Explorer öffnen.", filePath, ex.Message);
}
});
var extension = Path.GetExtension(suggestedFileName);
var filterName = extension == ".pdf" ? "PDF-Dateien" : "Dateien";
var filter = $"{filterName} (*{extension})|*{extension}|Alle Dateien (*.*)|*.*";
var script = $@"
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.SaveFileDialog
$dialog.Filter = '{filter}'
$dialog.FileName = '{suggestedFileName.Replace("'", "''")}'
$dialog.Title = 'Datei speichern'
if ($dialog.ShowDialog() -eq 'OK') {{ $dialog.FileName }} else {{ '' }}
";
var psi = new ProcessStartInfo
{
FileName = "powershell",
Arguments = $"-NoProfile -Command \"{script.Replace("\"", "\\\"\"")}\"",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(psi);
if (process == null) return null;
var result = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
return string.IsNullOrWhiteSpace(result) ? null : result;
});
}
}
@@ -66,6 +66,8 @@ public class PdfStatementService : IPdfStatementService
{
col.Item().Text(clubName).Bold().FontSize(20);
col.Item().Text(title).FontSize(14).FontColor(Colors.Grey.Darken1);
col.Item().Text(year.HasValue ? $"Zeitraum: 01.01.{year} bis 31.12.{year}" : "Zeitraum: Alle Jahre").FontSize(9);
col.Item().Text("Nur wirksame Buchungen. Umbuchungen zählen im einzelnen Konto als Zu- oder Abgang.").FontSize(8);
col.Item().PaddingTop(5).Text($"Erstellt am: {DateTime.Now:dd.MM.yyyy}").FontSize(8).FontColor(Colors.Grey.Medium);
col.Item().PaddingTop(10).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
});
@@ -94,7 +96,7 @@ public class PdfStatementService : IPdfStatementService
.PaddingBottom(5).AlignRight().Text("Betrag").SemiBold();
});
foreach (var entry in entries)
foreach (var entry in entries.Where(e => !e.IsDeleted))
{
var titleText = entry.Title;
if (entry.IsTransfer && !string.IsNullOrEmpty(entry.LinkedAccountName))
@@ -127,11 +129,16 @@ public class PdfStatementService : IPdfStatementService
SummaryRow(summaryCol, $"Übertrag von {year.Value - 1}:", balance.CarryoverBalance);
}
else
{
SummaryRow(summaryCol, "Anfangsbestand:", balance.CarryoverBalance);
}
SummaryRow(summaryCol, year.HasValue ? "Einnahmen:" : "Einnahmen gesamt:", incomeValue);
SummaryRow(summaryCol, year.HasValue ? "Ausgaben:" : "Ausgaben gesamt:", -expenseValue);
summaryCol.Item().PaddingTop(5).Row(row =>
{
row.RelativeItem().Text("Saldo:").Bold().FontSize(12);
row.RelativeItem().Text(year.HasValue ? $"Kontostand am 31.12.{year}:" : "Kontostand nach allen Buchungen:").Bold().FontSize(12);
row.AutoItem().Text(FormatCurrency(balance.TotalBalance)).Bold().FontSize(12)
.FontColor(balance.TotalBalance >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
});
@@ -302,6 +309,8 @@ public class PdfStatementService : IPdfStatementService
? $"Übersicht aller Konten ({year.Value})"
: "Übersicht aller Konten";
col.Item().Text(headerTitle).FontSize(13).FontColor(Colors.Grey.Darken1);
col.Item().Text(year.HasValue ? $"Zeitraum: 01.01.{year} bis 31.12.{year}; Endstände am 31.12.{year}." : "Zeitraum: Alle Jahre; Endstände nach allen Buchungen.").FontSize(9);
col.Item().Text("Nur wirksame Buchungen. Interne Umbuchungen erscheinen einmal mit Minus und Plus; die Vereinssumme bleibt unverändert.").FontSize(8);
col.Item().PaddingTop(2).Text($"Gesamtvermögen: {FormatCurrency(totalClubBalance)}")
.FontSize(10)
.SemiBold()
@@ -338,15 +347,14 @@ public class PdfStatementService : IPdfStatementService
}
});
if (year.HasValue && carryoverByAccountId is not null)
{
BodyCell(table, string.Empty);
BodyCell(table, string.Empty);
BodyCell(table, $"Übertrag von {year.Value - 1}", alignRight: true);
BodyCell(table, year.HasValue ? $"Übertrag von {year.Value - 1}" : "Anfangsbestand", alignRight: true);
foreach (var account in accounts)
{
BodyAmountCell(table, carryoverByAccountId.GetValueOrDefault(account.Id));
BodyAmountCell(table, carryoverByAccountId?.GetValueOrDefault(account.Id) ?? account.CarryoverBalance);
}
}
@@ -0,0 +1,84 @@
using Duempelkas.Domain.Enums;
using Duempelkas.Infrastructure;
using Duempelkas.Infrastructure.Persistence;
using Duempelkas.Infrastructure.Services;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Duempelkas.Tests;
public sealed class BackupServiceTests : IDisposable
{
private readonly string folder = Path.Combine(Path.GetTempPath(), "duempelkas-tests-" + Guid.NewGuid().ToString("N"));
private readonly ServiceProvider services;
private readonly BackupService backup;
private readonly EntryService entries;
private readonly int accountId;
public BackupServiceTests()
{
Directory.CreateDirectory(folder);
services = new ServiceCollection().AddInfrastructure($"Data Source={Path.Combine(folder, "active.db")};Pooling=False").BuildServiceProvider();
using var db = services.GetRequiredService<IDbContextFactory<FinanceDbContext>>().CreateDbContext();
db.Database.Migrate();
db.Database.ExecuteSqlRaw("PRAGMA journal_mode=WAL;");
db.Accounts.Add(new() { Name = "Isolierte Testkasse" });
db.SaveChanges();
accountId = db.Accounts.Single().Id;
backup = new BackupService(services.GetRequiredService<DatabaseLocation>());
entries = (EntryService)services.GetRequiredService<Duempelkas.App.Services.IEntryService>();
}
[Fact]
public async Task WalSnapshot_RestoresExactState_AndKeepsFallback()
{
var first = await entries.CreateEntryAsync(accountId, EntryType.Income, new(2026, 1, 1), "Gesichert", 12.50m);
var path = Path.Combine(folder, "snapshot.bak");
await backup.CreateBackupToAsync(path);
await entries.CreateEntryAsync(accountId, EntryType.Expense, new(2026, 1, 2), "Später", 4.20m);
var fallback = await backup.RestoreFromAsync(path);
var restored = await entries.GetEntriesAsync(accountId, (int?)null);
Assert.Single(restored);
Assert.Equal(first, restored[0]);
using var old = new SqliteConnection($"Data Source={fallback};Mode=ReadOnly;Pooling=False");
old.Open();
using var command = old.CreateCommand();
command.CommandText = "SELECT COUNT(*) FROM Entries";
Assert.Equal(2L, command.ExecuteScalar());
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task InvalidBackup_DoesNotChangeActiveData(bool sqlite)
{
await entries.CreateEntryAsync(accountId, EntryType.Income, new(2026, 1, 1), "Bleibt", 10m);
var path = Path.Combine(folder, "invalid.bak");
if (sqlite)
{
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "CREATE TABLE Unrelated (Id INTEGER)";
command.ExecuteNonQuery();
}
else await File.WriteAllTextAsync(path, "not a database");
await Assert.ThrowsAnyAsync<Exception>(() => backup.RestoreFromAsync(path));
Assert.Single(await entries.GetEntriesAsync(accountId, (int?)null));
}
[Fact]
public async Task MissingSource_AndActiveDestination_AreRejected()
{
await Assert.ThrowsAnyAsync<Exception>(() => backup.RestoreFromAsync(Path.Combine(folder, "missing.bak")));
await Assert.ThrowsAnyAsync<Exception>(() => backup.CreateBackupToAsync(Path.Combine(folder, "active.db")));
}
public void Dispose()
{
services.Dispose();
Directory.Delete(folder, true);
}
}
@@ -95,6 +95,62 @@ public class DashboardPdfStatementServiceTests : IDisposable
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<FinanceDbContext>().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();
@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="PdfPig" Version="0.1.16" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<PrivateAssets>all</PrivateAssets>
@@ -0,0 +1,41 @@
using Duempelkas.App.Services;
using Duempelkas.App.Services.Models;
using Duempelkas.Infrastructure.Services;
using Xunit;
namespace Duempelkas.Tests;
public class FileOperationTests
{
[Fact]
public async Task Cancel_IsNeutral()
{
var service = new FileSaveService(new Dialog(null));
Assert.Equal(FileOperationStatus.Cancelled, (await service.SaveFileAsync([1], "test.pdf")).Status);
}
[Fact]
public async Task SaveAndFailure_AreDistinct_AndOpeningMissingFileIsError()
{
var folder = Path.Combine(Path.GetTempPath(), "duempelkas-files-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(folder);
try
{
var path = Path.Combine(folder, "Prüfung.pdf");
var service = new FileSaveService(new Dialog(path));
Assert.Equal(FileOperationStatus.Success, (await service.SaveFileAsync([1, 2, 3], "test.pdf")).Status);
Assert.Equal(new byte[] { 1, 2, 3 }, await File.ReadAllBytesAsync(path));
// A directory cannot be overwritten as a file; the existing file is preserved.
Assert.Equal(FileOperationStatus.Error, (await new FileSaveService(new Dialog(folder)).SaveFileAsync([4], "test.pdf")).Status);
Assert.Equal(FileOperationStatus.Error, (await service.OpenFileAsync(Path.Combine(folder, "missing.pdf"))).Status);
Assert.Equal(new byte[] { 1, 2, 3 }, await File.ReadAllBytesAsync(path));
}
finally { Directory.Delete(folder, true); }
}
private sealed class Dialog(string? path) : IFileDialogService
{
public Task<string?> SaveAsync(string suggestedFileName) => Task.FromResult(path);
public Task<string?> OpenBackupAsync() => Task.FromResult(path);
}
}