using DodoSSH.Client.Storage; using static DodoSSH.Client.Sync.Tests.SyncHarness; namespace DodoSSH.Client.Sync.Tests; /// /// A machine away for longer than the server keeps deletions (ADR 0003). /// /// /// The server forgets a deletion once it is past its retention and refuses any cursor from before it. The /// machine then reads everything again — which says nothing about what is gone — so what it held and did /// not read again is what has to go. Except the user's own unsent work, which is kept and said out loud. /// public sealed class TombstoneCollectionTests { [Fact] public async Task AnItemDeletedAndForgottenWhileAway_IsRemovedByTheReadFromTheBeginning() { using var harness = await CreateAsync(); var gone = await harness.Second.CreateAsync(Host("old-db")); var kept = await harness.Second.CreateAsync(Host("prod-db")); await harness.Second.SyncAsync(); await harness.First.SyncAsync(); await harness.Second.DeleteAsync(gone); await harness.Second.SyncAsync(); harness.Server.CollectTombstones(); var report = await harness.First.SyncAsync(); report.ResyncedFromStart.ShouldBeTrue(); report.Pruned.ShouldBe(1); (await harness.First.ListAsync()).Items.Select(item => item.EntityId).ShouldBe([kept]); } [Fact] public async Task TheOnlyItemInAVault_IsRemovedAsWell() { // The read from the beginning brings back nothing at all, so a sweep bounded by what it read would // reach nothing either. It is bounded by what the cache held when it started. using var harness = await CreateAsync(); var gone = await harness.Second.CreateAsync(Host("old-db")); await harness.Second.SyncAsync(); await harness.First.SyncAsync(); await harness.Second.DeleteAsync(gone); await harness.Second.SyncAsync(); harness.Server.CollectTombstones(); var report = await harness.First.SyncAsync(); report.Pruned.ShouldBe(1); (await harness.First.ListAsync()).Items.ShouldBeEmpty(); } [Fact] public async Task AnItemWithAnUnsentEdit_IsKept_PutBack_AndSaidSo() { using var harness = await CreateAsync(); var entityId = await harness.Second.CreateAsync(Host("old-db")); await harness.Second.SyncAsync(); await harness.First.SyncAsync(); await harness.First.UpdateAsync(entityId, Host("old-db, still needed")); await harness.Second.DeleteAsync(entityId); await harness.Second.SyncAsync(); harness.Server.CollectTombstones(); var report = await harness.First.SyncAsync(); report.Pruned.ShouldBe(0); report.Recreated.ShouldBe(1); report.NeedsAttention.ShouldBeTrue(); (await harness.First.FindAsync(entityId)).Secret.Label.ShouldBe("old-db, still needed"); harness.Server.Find(entityId).ShouldNotBeNull().IsDeleted.ShouldBeFalse(); (await harness.First.ConflictsAsync()) .ShouldContain(conflict => conflict.EntityId == entityId && conflict.Kind == ConflictKind.RecreatedAfterCollection); await harness.Second.SyncAsync(); (await harness.Second.FindAsync(entityId)).Secret.Label.ShouldBe("old-db, still needed"); } [Fact] public async Task AReadSpreadOverSeveralPasses_SweepsAtTheEnd_AndKeepsWhatWasPushedMeanwhile() { // Pages per pass are capped, so the read from the beginning can take more than one pass, and what it // has seen has to outlast each one. A host made here during it is pushed between the passes. using var harness = await CreateAsync(new SyncOptions { PullPageSize = 1, MaxPullPages = 2 }); harness.Server.MaxPullLimit = 1; var gone = await harness.Second.CreateAsync(Host("old-db")); var kept = new List(); for (var index = 0; index < 4; index++) { kept.Add(await harness.Second.CreateAsync(Host($"host-{index}"))); } // Settled by hand: one pass reads at most two pages, and each needs several. for (var pass = 0; pass < 6; pass++) { await harness.Second.SyncAsync(); await harness.First.SyncAsync(); } await harness.Second.DeleteAsync(gone); await harness.Second.SyncAsync(); harness.Server.CollectTombstones(); kept.Add(await harness.First.CreateAsync(Host("made-meanwhile"))); var pruned = 0; for (var pass = 0; pass < 10; pass++) { pruned += (await harness.First.SyncAsync()).Pruned; var state = await harness.First.SyncState.ReadAsync(VaultId, TestContext.Current.CancellationToken); if (state.SweepMark is null) { break; } // Mid-sweep, nothing has gone yet. pruned.ShouldBe(0); (await harness.First.ListAsync()).Items.Count.ShouldBe(6); } pruned.ShouldBe(1); (await harness.First.ListAsync()).Items.Select(item => item.EntityId).ShouldBe(kept, ignoreOrder: true); harness.Server.Find(kept[^1]).ShouldNotBeNull(); } }