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();
}
}