using DodoSSH.Contracts; using static DodoSSH.Client.Sync.Tests.SyncHarness; namespace DodoSSH.Client.Sync.Tests; /// /// Deleting something the server has never heard of. /// /// /// /// Every delete used to queue a tombstone, which is right for an item the server holds and wrong for one it /// does not: it answers Invalid, the change is parked, and the user is left looking at a rejected /// change for an item they already deleted and a pending count that never reaches zero. Adding a host on a /// laptop with no network and changing your mind is enough to produce it. /// /// /// The interesting half of these tests is the other direction — that the shortcut does not swallow a real /// delete. A repository that quietly dropped tombstones would pass a test suite written only around the bug /// and would lose data on every machine but the one that pressed the button. /// /// public sealed class UnpushedDeleteTests { private static CancellationToken Token => TestContext.Current.CancellationToken; [Fact] public async Task CreatingAndDeletingBeforeAnySync_QueuesNothingAndPushesNothing() { using var harness = await CreateAsync(); var laptop = harness.First; var entityId = await laptop.CreateAsync(Host("scratch")); await laptop.DeleteAsync(entityId); (await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldBeEmpty(); (await laptop.ListAsync()).Items.ShouldBeEmpty(); var report = await laptop.SyncAsync(); report.Parked.ShouldBe(0, "there is nothing for the server to refuse"); report.Pushed.ShouldBe(0); harness.Server.RowCount.ShouldBe(0); } [Fact] public async Task CreatingEditingAndDeletingBeforeAnySync_AlsoQueuesNothing() { // Coalescing keeps the original expected version, so a create that has since been edited is still a // create. Reading the operation alone would see an Upsert with an ancestor and take it for an edit. using var harness = await CreateAsync(); var laptop = harness.First; var entityId = await laptop.CreateAsync(Host("scratch")); await laptop.UpdateAsync(entityId, Host("scratch", port: 2222)); await laptop.DeleteAsync(entityId); (await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldBeEmpty(); } [Fact] public async Task ACreateTheServerRefused_StopsBeingParkedWhenItIsDeleted() { // Parking is the pusher's record that the server said no, so a parked create is one the server // provably does not hold. Before this, deleting it replaced a parked create with a tombstone that // would be parked in its turn — the item could not be got rid of at all. using var harness = await CreateAsync(); var laptop = harness.First; harness.Server.DenyWrites = true; var entityId = await laptop.CreateAsync(Host("scratch")); var refused = await laptop.SyncAsync(); refused.Parked.ShouldBe(1); harness.Server.RowCount.ShouldBe(0); harness.Server.DenyWrites = false; await laptop.DeleteAsync(entityId); (await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldBeEmpty(); var after = await laptop.SyncAsync(); after.Parked.ShouldBe(0); } [Fact] public async Task DeletingAnItemTheServerHolds_StillQueuesATombstone() { // The guard on the whole change. A shortcut that fired here would delete the item on this machine // and nowhere else, and the next pull would bring it back. using var harness = await CreateAsync(); var laptop = harness.First; var entityId = await laptop.CreateAsync(Host("prod-db")); await laptop.SyncAsync(); harness.Server.RowCount.ShouldBe(1); await laptop.DeleteAsync(entityId); var queued = (await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldHaveSingleItem(); queued.Operation.ShouldBe(SyncOperation.Delete); await laptop.SyncAsync(); harness.Server.RowCount.ShouldBe(0); (await laptop.ListAsync()).Items.ShouldBeEmpty(); } /// /// The case the obvious test misses, and the one that would lose data. After a sync there is no queued /// row at all, so deleting a synced item never reaches the shortcut and proves nothing about it; the way /// to hold an unpushed Upsert over an item the server holds is to edit it offline. Dropping that /// row would delete the item here, leave it on the server, and bring it back on the next pull. Found by /// mutation — removing the expected-version guard left every other test in this file passing. /// [Fact] public async Task EditingASyncedItemOfflineAndThenDeletingIt_StillQueuesATombstone() { using var harness = await CreateAsync(); var laptop = harness.First; var entityId = await laptop.CreateAsync(Host("prod-db")); await laptop.SyncAsync(); // An unpushed edit: a queued Upsert whose expected version says the server already has this. await laptop.UpdateAsync(entityId, Host("prod-db", port: 2222)); var edit = (await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldHaveSingleItem(); edit.Operation.ShouldBe(SyncOperation.Upsert); edit.ExpectedVersion.ShouldNotBeNull(); await laptop.DeleteAsync(entityId); var tombstone = (await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldHaveSingleItem(); tombstone.Operation.ShouldBe(SyncOperation.Delete); await laptop.SyncAsync(); harness.Server.RowCount.ShouldBe(0, "the deletion has to reach the server, not just this machine"); (await laptop.ListAsync()).Items.ShouldBeEmpty(); } [Fact] public async Task ACreateThatWentOutAndWasNeverAnswered_StillQueuesATombstone() { // The ambiguous case, and the reason the attempt count is part of the test rather than just the // expected version. A create that was dispatched may have been applied by a server whose answer never // arrived. Dropping it locally would strand a row nobody can see and nobody can delete; a tombstone // that turns out to be unnecessary is refused once and can be dealt with. using var harness = await CreateAsync(); var laptop = harness.First; var entityId = await laptop.CreateAsync(Host("in-flight")); var queued = (await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldHaveSingleItem(); // Sent, with the answer lost. Marked directly because the engine does this immediately before the // call it may not survive, and reproducing a dropped response through the fake server would test the // fake rather than the repository. await laptop.Outbox.MarkDispatchedAsync(queued.Sequence, Token); await laptop.DeleteAsync(entityId); var tombstone = (await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldHaveSingleItem(); tombstone.Operation.ShouldBe(SyncOperation.Delete); } /// /// The path most likely to meet this in practice, which is why it gets its own test rather than trusting /// the generic repository: trust is pinned by connecting and withdrawn from the host editor, so trusting a /// rebuilt server and then withdrawing it before the next sync is a minute's work rather than a contrived /// sequence. It is the same code as the host case and would be caught by the same mutation — what this /// pins is that the generic repository really is what all four types go through. /// [Fact] public async Task TheSameHoldsForAPinTrustedAndForgottenOffline() { using var harness = await CreateAsync(); var laptop = harness.First; var entityId = await laptop.CreateKnownHostAsync(KnownHost()); await laptop.DeleteKnownHostAsync(entityId); (await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldBeEmpty(); (await laptop.ListKnownHostsAsync()).Items.ShouldBeEmpty(); } [Fact] public async Task TheSameHoldsForAKeyAddedAndRemovedOffline() { using var harness = await CreateAsync(); var laptop = harness.First; var entityId = await laptop.CreateKeyAsync(Key("scratch")); await laptop.DeleteKeyAsync(entityId); (await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldBeEmpty(); (await laptop.ListKeysAsync()).Items.ShouldBeEmpty(); } }