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