using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.LocalDb.Internal; using ZB.MOM.WW.LocalDb.Replication; using ZB.MOM.WW.LocalDb.Replication.Internal; namespace ZB.MOM.WW.LocalDb.Tests; public sealed class MaintenanceTests : IDisposable { private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(10); private static readonly TimeSpan Tick = TimeSpan.FromMilliseconds(50); private readonly List _paths = []; private readonly List _disposables = []; public void Dispose() { foreach (var d in _disposables) d.Dispose(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); foreach (var p in _paths) { if (File.Exists(p)) File.Delete(p); if (File.Exists(p + "-wal")) File.Delete(p + "-wal"); if (File.Exists(p + "-shm")) File.Delete(p + "-shm"); } } private async Task<(SqliteLocalDb Db, OplogStore Store)> NewDbAsync(ReplicationOptions options) { var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db"); _paths.Add(path); var db = new SqliteLocalDb(new LocalDbOptions { Path = path }); _disposables.Add(db); await db.ExecuteAsync("CREATE TABLE orders (id INTEGER PRIMARY KEY, sku TEXT, qty INTEGER)"); db.RegisterReplicated("orders"); return (db, new OplogStore(db, options)); } private static MaintenanceBackgroundService NewService( OplogStore store, ReplicationOptions options, ILoggerFactory? loggerFactory = null) => new(store, Options.Create(options), loggerFactory ?? NullLoggerFactory.Instance); private static async Task WaitForAsync(Func> predicate, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { if (await predicate()) return; await Task.Delay(20); } throw new TimeoutException("Condition not reached within " + timeout); } [Fact] public async Task Maintenance_EnforcesCaps_SetsNeedsSnapshot() { var options = new ReplicationOptions { MaxOplogRows = 5, MaintenanceInterval = Tick }; var (db, store) = await NewDbAsync(options); for (var i = 1; i <= 20; i++) await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (@id, 'R', @id)", new { id = i }); var service = NewService(store, options); await service.StartAsync(CancellationToken.None); try { await WaitForAsync(async () => { var snap = await db.QueryAsync("SELECT needs_snapshot FROM __localdb_peer_state WHERE id = 1", x => x.GetInt64(0)); var depth = await db.QueryAsync("SELECT COUNT(*) FROM __localdb_oplog", x => x.GetInt64(0)); return snap[0] == 1 && depth[0] <= 5; }, WaitTimeout); } finally { await service.StopAsync(CancellationToken.None); } } [Fact] public async Task Maintenance_PrunesExpiredTombstones() { var options = new ReplicationOptions { MaintenanceInterval = Tick }; var (db, store) = await NewDbAsync(options); await db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'DOOMED', 1)"); await db.ExecuteAsync("DELETE FROM orders WHERE id = 1"); // Backdate the delete-trigger's tombstone far past any retention window. var updated = await db.ExecuteAsync( "UPDATE __localdb_row_version SET tombstone_utc = '2000-01-01T00:00:00.000Z' WHERE is_tombstone = 1"); Assert.Equal(1, updated); var service = NewService(store, options); await service.StartAsync(CancellationToken.None); try { await WaitForAsync(async () => { var rows = await db.QueryAsync( "SELECT COUNT(*) FROM __localdb_row_version WHERE is_tombstone = 1", x => x.GetInt64(0)); return rows[0] == 0; }, WaitTimeout); } finally { await service.StopAsync(CancellationToken.None); } } [Fact] public async Task Maintenance_SurvivesTickFailure() { var options = new ReplicationOptions { MaintenanceInterval = Tick }; var (db, store) = await NewDbAsync(options); // Deterministic per-tick failure: every EnforceCapsAsync now hits "no such table". await db.ExecuteAsync("DROP TABLE __localdb_oplog"); var logger = new ListLoggerFactory(); var service = NewService(store, options, logger); await service.StartAsync(CancellationToken.None); try { // At least ~5 failing ticks must have elapsed without killing the loop. await WaitForAsync( () => Task.FromResult(logger.Count(LogLevel.Warning) >= 5), WaitTimeout); Assert.NotNull(service.ExecuteTask); Assert.False(service.ExecuteTask!.IsCompleted, "maintenance loop died on a tick failure"); } finally { await service.StopAsync(CancellationToken.None); } Assert.True(service.ExecuteTask!.IsCompletedSuccessfully); } [Fact] public void Validator_NonPositiveMaintenanceInterval_Fails() { var result = new ReplicationOptionsValidator().Validate( null, new ReplicationOptions { MaintenanceInterval = TimeSpan.Zero }); Assert.True(result.Failed); Assert.Contains(result.Failures, f => f.Contains("MaintenanceInterval", StringComparison.Ordinal)); } [Fact] public void AddZbLocalDbReplication_RegistersMaintenance_BothRoles() { foreach (var peerAddress in new string?[] { null, "http://localhost" }) { var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db"); _paths.Add(path); var dict = new Dictionary { ["LocalDb:Path"] = path }; if (peerAddress is not null) dict["LocalDb:Replication:PeerAddress"] = peerAddress; var config = new ConfigurationBuilder().AddInMemoryCollection(dict).Build(); var services = new ServiceCollection(); services.AddLogging(); services.AddZbLocalDb(config, db => { using var conn = db.CreateConnection(); using var cmd = conn.CreateCommand(); cmd.CommandText = "CREATE TABLE orders (id INTEGER PRIMARY KEY)"; cmd.ExecuteNonQuery(); db.RegisterReplicated("orders"); }); services.AddZbLocalDbReplication(config); var provider = services.BuildServiceProvider(); _disposables.Add(provider); Assert.Contains(provider.GetServices(), s => s is MaintenanceBackgroundService); } } private sealed class ListLoggerFactory : ILoggerFactory { private readonly List _levels = []; public int Count(LogLevel level) { lock (_levels) return _levels.Count(l => l == level); } public ILogger CreateLogger(string categoryName) => new ListLogger(_levels); public void AddProvider(ILoggerProvider provider) { } public void Dispose() { } private sealed class ListLogger(List levels) : ILogger { public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; public bool IsEnabled(LogLevel logLevel) => true; public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { lock (levels) levels.Add(logLevel); } private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } } } }