131 lines
5.2 KiB
C#
131 lines
5.2 KiB
C#
using JobTrackerApi.Services;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Xunit;
|
|
|
|
namespace JobTrackerApi.Tests;
|
|
|
|
public sealed class DatabaseBackupRunnerTests : IDisposable
|
|
{
|
|
private readonly string _root;
|
|
|
|
public DatabaseBackupRunnerTests()
|
|
{
|
|
_root = Path.Combine(Path.GetTempPath(), $"jt-backup-tests-{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(_root);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
SqliteConnection.ClearAllPools();
|
|
try { Directory.Delete(_root, recursive: true); } catch (IOException) { }
|
|
}
|
|
|
|
private string CreateSourceDb(out string connectionString)
|
|
{
|
|
var dbPath = Path.Combine(_root, "source.db");
|
|
connectionString = $"Data Source={dbPath}";
|
|
using var connection = new SqliteConnection(connectionString);
|
|
connection.Open();
|
|
using var command = connection.CreateCommand();
|
|
command.CommandText = "CREATE TABLE Sample (Id INTEGER PRIMARY KEY, Name TEXT); INSERT INTO Sample (Name) VALUES ('alpha'), ('beta');";
|
|
command.ExecuteNonQuery();
|
|
return dbPath;
|
|
}
|
|
|
|
private SqliteDatabaseBackupRunner CreateRunner(string connectionString, int retainCount = 14)
|
|
=> new(connectionString, Path.Combine(_root, "backups"), retainCount, NullLogger<SqliteDatabaseBackupRunner>.Instance);
|
|
|
|
[Fact]
|
|
public async Task RunOnce_creates_a_restorable_backup_file()
|
|
{
|
|
CreateSourceDb(out var connectionString);
|
|
var runner = CreateRunner(connectionString);
|
|
|
|
var backupPath = await runner.RunOnceAsync(CancellationToken.None);
|
|
|
|
Assert.NotNull(backupPath);
|
|
Assert.True(File.Exists(backupPath));
|
|
|
|
await using var verify = new SqliteConnection($"Data Source={backupPath}");
|
|
await verify.OpenAsync();
|
|
await using var count = verify.CreateCommand();
|
|
count.CommandText = "SELECT COUNT(*) FROM Sample";
|
|
Assert.Equal(2L, (long)(await count.ExecuteScalarAsync())!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunOnce_includes_committed_WAL_rows_in_an_independent_restorable_snapshot()
|
|
{
|
|
CreateSourceDb(out var connectionString);
|
|
await using var source = new SqliteConnection(connectionString);
|
|
await source.OpenAsync();
|
|
await using var write = source.CreateCommand();
|
|
write.CommandText = "PRAGMA journal_mode=WAL;";
|
|
Assert.Equal("wal", await write.ExecuteScalarAsync());
|
|
write.CommandText = "INSERT INTO Sample (Name) VALUES ('committed-in-wal');";
|
|
await write.ExecuteNonQueryAsync();
|
|
Assert.True(new FileInfo(Path.Combine(_root, "source.db-wal")).Length > 0);
|
|
|
|
var backupPath = await CreateRunner(connectionString).RunOnceAsync(CancellationToken.None);
|
|
Assert.NotNull(backupPath);
|
|
await using var backup = new SqliteConnection($"Data Source={backupPath};Mode=ReadOnly;Pooling=False");
|
|
await backup.OpenAsync();
|
|
await using var query = backup.CreateCommand();
|
|
query.CommandText = "PRAGMA integrity_check;";
|
|
Assert.Equal("ok", await query.ExecuteScalarAsync());
|
|
query.CommandText = "SELECT COUNT(*) FROM Sample;";
|
|
Assert.Equal(3L, await query.ExecuteScalarAsync());
|
|
query.CommandText = "SELECT Name FROM Sample WHERE Id=3;";
|
|
Assert.Equal("committed-in-wal", await query.ExecuteScalarAsync());
|
|
|
|
write.CommandText = "INSERT INTO Sample (Name) VALUES ('after-snapshot');";
|
|
await write.ExecuteNonQueryAsync();
|
|
query.CommandText = "SELECT COUNT(*) FROM Sample;";
|
|
Assert.Equal(3L, await query.ExecuteScalarAsync());
|
|
write.CommandText = "SELECT COUNT(*) FROM Sample;";
|
|
Assert.Equal(4L, await write.ExecuteScalarAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunOnce_prunes_backups_beyond_retention()
|
|
{
|
|
CreateSourceDb(out var connectionString);
|
|
var runner = CreateRunner(connectionString, retainCount: 2);
|
|
var backupsRoot = runner.BackupsRoot;
|
|
Directory.CreateDirectory(backupsRoot);
|
|
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
var stale = Path.Combine(backupsRoot, $"{SqliteDatabaseBackupRunner.BackupFilePrefix}stale{i}.db");
|
|
File.WriteAllText(stale, "stale");
|
|
File.SetLastWriteTimeUtc(stale, DateTime.UtcNow.AddDays(-10 - i));
|
|
}
|
|
|
|
await runner.RunOnceAsync(CancellationToken.None);
|
|
|
|
var remaining = Directory.GetFiles(backupsRoot, $"{SqliteDatabaseBackupRunner.BackupFilePrefix}*.db");
|
|
Assert.Equal(2, remaining.Length);
|
|
Assert.Contains(remaining, f => Path.GetFileName(f).Contains("stale0"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Latest_backup_timestamp_reflects_newest_file()
|
|
{
|
|
CreateSourceDb(out var connectionString);
|
|
var runner = CreateRunner(connectionString);
|
|
|
|
Assert.Null(runner.GetLatestBackupUtc());
|
|
|
|
Directory.CreateDirectory(runner.BackupsRoot);
|
|
var file = Path.Combine(runner.BackupsRoot, $"{SqliteDatabaseBackupRunner.BackupFilePrefix}x.db");
|
|
File.WriteAllText(file, "x");
|
|
var stamp = DateTime.UtcNow.AddHours(-3);
|
|
File.SetLastWriteTimeUtc(file, stamp);
|
|
|
|
var latest = runner.GetLatestBackupUtc();
|
|
Assert.NotNull(latest);
|
|
Assert.True(Math.Abs((latest!.Value - stamp).TotalSeconds) < 2);
|
|
}
|
|
}
|