fix(backup): use active SQLite database and validate safe restore (U01)
This commit is contained in:
@@ -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.
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string> 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<string> 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<string> 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<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();
|
||||
}
|
||||
|
||||
private static Task<string?> ShowSaveDialogAsync(string suggestedFileName)
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user