diff --git a/docs/usability-umsetzung.md b/docs/usability-umsetzung.md new file mode 100644 index 0000000..7fcfbba --- /dev/null +++ b/docs/usability-umsetzung.md @@ -0,0 +1,21 @@ +# 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. diff --git a/src/Duempelkas.Desktop/Program.cs b/src/Duempelkas.Desktop/Program.cs index ca31baa..695f454 100644 --- a/src/Duempelkas.Desktop/Program.cs +++ b/src/Duempelkas.Desktop/Program.cs @@ -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); } } diff --git a/src/Duempelkas.Infrastructure/DependencyInjection.cs b/src/Duempelkas.Infrastructure/DependencyInjection.cs index 6ca1431..acd070d 100644 --- a/src/Duempelkas.Infrastructure/DependencyInjection.cs +++ b/src/Duempelkas.Infrastructure/DependencyInjection.cs @@ -10,6 +10,7 @@ public static class DependencyInjection { public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString) { + services.AddSingleton(new DatabaseLocation(connectionString)); services.AddDbContext(options => options.UseSqlite(connectionString)); services.AddDbContextFactory(options => diff --git a/src/Duempelkas.Infrastructure/Services/AccountService.cs b/src/Duempelkas.Infrastructure/Services/AccountService.cs index 7ff4d18..030f4d0 100644 --- a/src/Duempelkas.Infrastructure/Services/AccountService.cs +++ b/src/Duempelkas.Infrastructure/Services/AccountService.cs @@ -11,7 +11,13 @@ public class AccountService : IAccountService { private readonly IDbContextFactory _dbFactory; - public AccountService(IDbContextFactory dbFactory) => _dbFactory = dbFactory; + private readonly DatabaseLocation? _location; + + public AccountService(IDbContextFactory dbFactory, DatabaseLocation? location = null) + { + _dbFactory = dbFactory; + _location = location; + } public Task> GetAllAccountsAsync() { @@ -20,6 +26,7 @@ public class AccountService : IAccountService public async Task> 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 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 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 diff --git a/src/Duempelkas.Infrastructure/Services/BackupService.cs b/src/Duempelkas.Infrastructure/Services/BackupService.cs index 7508389..62621fb 100644 --- a/src/Duempelkas.Infrastructure/Services/BackupService.cs +++ b/src/Duempelkas.Infrastructure/Services/BackupService.cs @@ -7,70 +7,121 @@ 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 BackupService(DatabaseLocation location) => this.location = location; public async Task CreateBackupAsync() { - 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}"; + var path = await ShowSaveDialogAsync($"duempelkas-backup-{DateTime.Now:yyyyMMdd-HHmmss}.bak"); + if (string.IsNullOrWhiteSpace(path)) return "Sicherung wurde abgebrochen."; + await CreateBackupToAsync(path); + return $"Sicherung gespeichert: {path}"; } + catch (Exception ex) { return $"Sicherung fehlgeschlagen: {ex.Message}"; } } public async Task RestoreBackupAsync() { - 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."; + var path = await ShowOpenDialogAsync(); + if (string.IsNullOrWhiteSpace(path)) return "Wiederherstellung wurde abgebrochen."; + var fallback = await RestoreFromAsync(path); + return $"Wiederherstellung erfolgreich. Rückfallsicherung: {fallback}"; } - catch (Exception ex) + catch (Exception ex) { return $"Wiederherstellung fehlgeschlagen: {ex.Message}"; } + } + + public async Task CreateBackupToAsync(string path) + { + using var lease = await DatabaseLease.AcquireAsync(location); + RejectActivePath(path); + await Task.Run(() => { - return $"Wiederherstellung fehlgeschlagen: {ex.Message}"; - } + var staged = System.IO.Path.GetFullPath(path) + $".{Guid.NewGuid():N}.tmp"; + try + { + Snapshot(location.Path, staged); + File.Move(staged, path, overwrite: true); + } + finally { if (File.Exists(staged)) File.Delete(staged); } + }); + } + + public async Task RestoreFromAsync(string path) + { + using var lease = await DatabaseLease.AcquireAsync(location); + return await Task.Run(() => + { + 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 + { + 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; + } + finally { if (File.Exists(staged)) File.Delete(staged); } + }); + } + + private void RejectActivePath(string path) + { + 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(); + 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(); } private static Task ShowSaveDialogAsync(string suggestedFileName) @@ -143,4 +194,4 @@ if ($dialog.ShowDialog() -eq 'OK') { $dialog.FileName } else { '' } return string.IsNullOrWhiteSpace(result) ? null : result; } -} \ No newline at end of file +} diff --git a/src/Duempelkas.Infrastructure/Services/DatabaseLease.cs b/src/Duempelkas.Infrastructure/Services/DatabaseLease.cs new file mode 100644 index 0000000..fdc0749 --- /dev/null +++ b/src/Duempelkas.Infrastructure/Services/DatabaseLease.cs @@ -0,0 +1,12 @@ +namespace Duempelkas.Infrastructure.Services; + +internal sealed class DatabaseLease(SemaphoreSlim? gate) : IDisposable +{ + public static async Task AcquireAsync(DatabaseLocation? location) + { + if (location != null) await location.Writes.WaitAsync(); + return new DatabaseLease(location?.Writes); + } + + public void Dispose() => gate?.Release(); +} diff --git a/src/Duempelkas.Infrastructure/Services/DatabaseLocation.cs b/src/Duempelkas.Infrastructure/Services/DatabaseLocation.cs new file mode 100644 index 0000000..bcf3970 --- /dev/null +++ b/src/Duempelkas.Infrastructure/Services/DatabaseLocation.cs @@ -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); +} diff --git a/src/Duempelkas.Infrastructure/Services/EntryService.cs b/src/Duempelkas.Infrastructure/Services/EntryService.cs index a27a4be..f7d9068 100644 --- a/src/Duempelkas.Infrastructure/Services/EntryService.cs +++ b/src/Duempelkas.Infrastructure/Services/EntryService.cs @@ -11,7 +11,13 @@ public class EntryService : IEntryService { private readonly IDbContextFactory _dbFactory; - public EntryService(IDbContextFactory dbFactory) => _dbFactory = dbFactory; + private readonly DatabaseLocation? _location; + + public EntryService(IDbContextFactory dbFactory, DatabaseLocation? location = null) + { + _dbFactory = dbFactory; + _location = location; + } public Task> GetEntriesAsync(int accountId, bool currentYearOnly) { @@ -21,6 +27,7 @@ public class EntryService : IEntryService public async Task> 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> 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> 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 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) diff --git a/tests/Duempelkas.Tests/BackupServiceTests.cs b/tests/Duempelkas.Tests/BackupServiceTests.cs new file mode 100644 index 0000000..3f012d3 --- /dev/null +++ b/tests/Duempelkas.Tests/BackupServiceTests.cs @@ -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>().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()); + entries = (EntryService)services.GetRequiredService(); + } + + [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(() => backup.RestoreFromAsync(path)); + Assert.Single(await entries.GetEntriesAsync(accountId, (int?)null)); + } + + [Fact] + public async Task MissingSource_AndActiveDestination_AreRejected() + { + await Assert.ThrowsAnyAsync(() => backup.RestoreFromAsync(Path.Combine(folder, "missing.bak"))); + await Assert.ThrowsAnyAsync(() => backup.CreateBackupToAsync(Path.Combine(folder, "active.db"))); + } + + public void Dispose() + { + services.Dispose(); + Directory.Delete(folder, true); + } +}