diff --git a/OpenNest.Data/NestHistorySession.cs b/OpenNest.Data/NestHistorySession.cs new file mode 100644 index 0000000..40291fd --- /dev/null +++ b/OpenNest.Data/NestHistorySession.cs @@ -0,0 +1,182 @@ +using System.Text.Json; + +namespace OpenNest.Data; + +/// +/// URL-bound, bounded history browsing. Historical reads never bind an editable document. +/// A selection change cancels and invalidates previous reads even if a backend ignores cancellation. +/// Mutations use the displayed current revision; an uncertain failure requires an explicit refresh. +/// +public sealed class NestHistorySession : IDisposable +{ + private readonly INestRepository repository; + private CancellationTokenSource? pending; + private int generation; + private bool disposed; + public string ServerUrl { get; } + public Guid NestId { get; private set; } + public bool Deleted { get; private set; } + public NestRecord? Current { get; private set; } + public NestRevisionPage? Page { get; private set; } + public bool IsMutating { get; private set; } + public const int PageSize = 50; + + public NestHistorySession(INestRepository repository, string serverUrl) + { + this.repository = repository ?? throw new ArgumentNullException(nameof(repository)); + ServerUrl = Normalize(serverUrl); + } + + public async Task LoadAsync(Guid id, bool deleted = false, int offset = 0, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(disposed, this); + if (IsMutating) throw new InvalidOperationException("Wait for the current operation to finish."); + if (id == Guid.Empty || offset < 0) throw new ArgumentOutOfRangeException(nameof(id)); + pending?.Cancel(); + var run = ++generation; + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + pending = cts; + NestId = id; + Deleted = deleted; + Current = null; + Page = null; + try + { + var record = deleted ? await repository.GetDeletedMetadataAsync(id, cts.Token) + : await repository.GetMetadataAsync(id, cts.Token); + if (record == null) throw new FileNotFoundException("This nest is no longer in this view. Refresh the list."); + ValidateRecord(record, id, deleted); + if (record.Revision < 1) throw new NotSupportedException("Upgrade the nest server to browse or change version history. Current nests can still be opened read-only."); + var page = deleted ? await repository.GetDeletedRevisionsAsync(id, offset, PageSize, cts.Token) + : await repository.GetRevisionsAsync(id, offset, PageSize, cts.Token); + if (page.Offset != offset || page.Items.Count > PageSize || page.Total < offset + page.Items.Count + || page.Items.Any(r => r.Id != id || r.Revision < 1)) + throw new InvalidDataException("The server returned invalid nest history."); + if (page.Items.Any(r => r.Revision > record.Revision)) + throw new IOException("The current version changed while loading history. Refresh to review the new current version."); + if (run != generation || cts.IsCancellationRequested || disposed) return false; + Current = record; + Page = page; + return true; + } + catch (Exception) when (run != generation || cts.IsCancellationRequested || disposed) { return false; } + finally { if (ReferenceEquals(pending, cts)) pending = null; } + } + + public async Task DownloadAsync(int revision, string serverUrl, CancellationToken ct = default) + { + EnsureReady(serverUrl); + var id = NestId; + var run = generation; + var snapshot = Page!.Items.SingleOrDefault(r => r.Revision == revision) + ?? throw new InvalidOperationException("Select a revision on the displayed page."); + var file = Deleted ? await repository.GetDeletedRevisionFileAsync(id, revision, ct) + : await repository.GetRevisionFileAsync(id, revision, ct); + ct.ThrowIfCancellationRequested(); + if (disposed || run != generation) return null; + if (file == null || file.LongLength != snapshot.FileSize) + throw new InvalidDataException("The revision archive is missing or does not match its metadata. Refresh before trying again."); + return file; + } + + public async Task RestoreAsync(int sourceRevision, string serverUrl, CancellationToken ct = default) + { + EnsureReady(serverUrl); + if (Deleted) throw new InvalidOperationException("Recover the deleted nest before restoring an earlier version."); + if (!Page!.Items.Any(r => r.Revision == sourceRevision)) throw new InvalidOperationException("Select a displayed revision."); + return await MutateAsync(() => repository.RestoreAsync(NestId, sourceRevision, Current!.Revision, ct), false, ct); + } + + public async Task RecoverAsync(string serverUrl, CancellationToken ct = default) + { + EnsureReady(serverUrl); + if (!Deleted) throw new InvalidOperationException("This nest is not deleted."); + return await MutateAsync(() => repository.RecoverAsync(NestId, Current!.Revision, ct), false, ct); + } + + public async Task DeleteAsync(string serverUrl, CancellationToken ct = default) + { + EnsureReady(serverUrl); + if (Deleted) throw new InvalidOperationException("This nest is already deleted."); + return await MutateAsync(() => repository.DeleteAsync(NestId, Current!.Revision, ct), true, ct); + } + + private async Task MutateAsync(Func> write, bool deleted, CancellationToken ct) + { + IsMutating = true; + var expected = Current!.Revision; + try + { + var result = await write(); + ValidateRecord(result, NestId, deleted); + if (result.Revision != expected + 1) throw new InvalidDataException("Invalid mutation revision. Refresh to reconcile."); + // Verify the exact target after the write; do not declare success from the response alone. + var verified = deleted ? await repository.GetDeletedMetadataAsync(NestId, ct) + : await repository.GetMetadataAsync(NestId, ct); + if (verified == null || verified.Id != NestId || verified.Revision < result.Revision || verified.Deleted != deleted) + throw new InvalidDataException("Could not verify the new current revision. Refresh to reconcile."); + ct.ThrowIfCancellationRequested(); + if (disposed) throw new OperationCanceledException("The history browser was closed. Reopen to reconcile."); + Current = verified; + Deleted = deleted; + Page = null; + return result; + } + catch { Current = null; throw; } + finally { IsMutating = false; } + } + + private void EnsureReady(string url) + { + ObjectDisposedException.ThrowIf(disposed, this); + if (Normalize(url) != ServerUrl) throw new InvalidOperationException("The nest server changed. Close this browser and reopen it on the selected server."); + if (Current == null || IsMutating) throw new InvalidOperationException("Refresh the current version before continuing."); + } + + private static void ValidateRecord(NestRecord record, Guid id, bool deleted) + { + if (record.Id != id || record.Deleted != deleted) throw new InvalidDataException("The server returned a different nest identity or visibility."); + } + + private static string Normalize(string? url) => (url ?? "").Trim().TrimEnd('/'); + + /// Read an immutable current snapshot and ensure metadata did not change during download. + public static async Task ReadCurrentAsync(INestRepository repository, Guid id, + string serverUrl, CancellationToken ct = default) + { + var before = await repository.GetMetadataAsync(id, ct) + ?? throw new FileNotFoundException("This nest no longer exists on the server."); + ValidateRecord(before, id, false); + var beforeMetadata = JsonSerializer.Serialize(before); + var file = before.Revision > 0 ? await repository.GetRevisionFileAsync(id, before.Revision, ct) + : await repository.GetFileAsync(id, ct); + var after = await repository.GetMetadataAsync(id, ct); + ct.ThrowIfCancellationRequested(); + if (after == null || JsonSerializer.Serialize(after) != beforeMetadata + || file == null || file.LongLength != before.FileSize) + throw new InvalidDataException("The current nest changed or its archive does not match its metadata. Refresh and open it again."); + return new NestCurrentSnapshot(before, file, Normalize(serverUrl)); + } + + public void Clear() + { + if (IsMutating) throw new InvalidOperationException("Wait for the current operation to finish."); + ++generation; + pending?.Cancel(); + NestId = Guid.Empty; + Current = null; + Page = null; + } + + public void Dispose() + { + disposed = true; + ++generation; + pending?.Cancel(); + Current = null; + Page = null; + } +} + +public sealed record NestCurrentSnapshot(NestRecord Metadata, byte[] Archive, string ServerUrl); diff --git a/OpenNest.Tests/Data/NestHistorySessionTests.cs b/OpenNest.Tests/Data/NestHistorySessionTests.cs new file mode 100644 index 0000000..a93b905 --- /dev/null +++ b/OpenNest.Tests/Data/NestHistorySessionTests.cs @@ -0,0 +1,232 @@ +using OpenNest.Data; + +namespace OpenNest.Tests.Data; + +public class NestHistorySessionTests +{ + [Fact] + public async Task BoundedHistory_DownloadIsByteExactAndReadOnly() + { + var repository = new Repository(); + using var session = new NestHistorySession(repository, "http://shop/"); + Assert.True(await session.LoadAsync(repository.Id)); + Assert.Equal(NestHistorySession.PageSize, repository.Limit); + Assert.Equal(repository.Bytes, await session.DownloadAsync(2, "http://shop")); + Assert.Equal(0, repository.Writes); + Assert.Equal(5, session.Current!.Revision); + Assert.Equal("Earlier notes", session.Page!.Items[0].Comments); + } + + [Fact] + public async Task Restore_UsesDisplayedPreconditionSameIdAndVerifiesCurrent() + { + var repository = new Repository(); + using var session = new NestHistorySession(repository, "http://shop"); + await session.LoadAsync(repository.Id); + var restored = await session.RestoreAsync(2, "http://shop"); + Assert.Equal(repository.Id, restored.Id); + Assert.Equal(6, restored.Revision); + Assert.Equal(5, repository.Expected); + Assert.Equal(2, repository.Source); + Assert.Equal(2, repository.MetadataReads); + Assert.Null(session.Page); + Assert.True(await session.LoadAsync(repository.Id)); + Assert.Equal(6, session.Current!.Revision); + } + + [Fact] + public async Task Recovery_IsExplicitAndUsesDeletedEndpoints() + { + var repository = new Repository { Deleted = true }; + using var session = new NestHistorySession(repository, "http://shop"); + await session.LoadAsync(repository.Id, true); + Assert.Equal(repository.Bytes, await session.DownloadAsync(2, "http://shop")); + Assert.Equal(1, repository.DeletedDownloads); + await Assert.ThrowsAsync(() => session.RestoreAsync(2, "http://shop")); + var recovered = await session.RecoverAsync("http://shop"); + Assert.False(recovered.Deleted); + Assert.Equal(6, recovered.Revision); + Assert.Equal(5, repository.Expected); + } + + [Fact] + public async Task FailedRestore_PreservesHistoryButDisablesBlindRetry() + { + var repository = new Repository { Fail = true }; + using var session = new NestHistorySession(repository, "http://shop"); + await session.LoadAsync(repository.Id); + var page = session.Page; + await Assert.ThrowsAsync(() => session.RestoreAsync(2, "http://shop")); + Assert.Same(page, session.Page); + Assert.Null(session.Current); + await Assert.ThrowsAsync(() => session.RestoreAsync(2, "http://shop")); + Assert.Equal(1, repository.Writes); + Assert.Equal(5, repository.Revision); + repository.Fail = false; + await session.LoadAsync(repository.Id); + Assert.NotNull(session.Current); + } + + [Fact] + public async Task ServerSwitch_RefusesDownloadRestoreAndRecoveryWithoutRequests() + { + var repository = new Repository(); + using var session = new NestHistorySession(repository, "http://shop"); + await session.LoadAsync(repository.Id); + await Assert.ThrowsAsync(() => session.DownloadAsync(2, "http://other")); + await Assert.ThrowsAsync(() => session.RestoreAsync(2, "http://other")); + await Assert.ThrowsAsync(() => session.RecoverAsync("http://other")); + Assert.Equal(0, repository.Downloads); + Assert.Equal(0, repository.Writes); + } + + [Fact] + public async Task StaleSelection_IgnoresBackendThatIgnoresCancellation() + { + var repository = new Repository(); + var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + repository.History = held.Task; + using var session = new NestHistorySession(repository, "http://shop"); + var first = session.LoadAsync(repository.Id); + repository.History = null; + var secondId = Guid.NewGuid(); + await session.LoadAsync(secondId); + held.SetResult(new NestRevisionPage { Items = [repository.Record(repository.Id, 2)], Total = 1, Limit = 50 }); + Assert.False(await first); + Assert.Equal(secondId, session.Current!.Id); + } + + [Fact] + public async Task CancelAndClear_NeverPublishLateHistory() + { + var repository = new Repository(); + var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + repository.History = held.Task; + using var session = new NestHistorySession(repository, "http://shop"); + using var cancellation = new CancellationTokenSource(); + var task = session.LoadAsync(repository.Id, cancellationToken: cancellation.Token); + cancellation.Cancel(); + session.Clear(); + held.SetResult(new NestRevisionPage { Items = [repository.Record(repository.Id, 2)], Total = 1, Limit = 50 }); + Assert.False(await task); + Assert.Null(session.Current); + Assert.Null(session.Page); + } + + [Fact] + public async Task LegacyServer_HistoryRefusesButCurrentReadRetainsZeroRevision() + { + var repository = new Repository { Revision = 0 }; + using var session = new NestHistorySession(repository, "http://shop"); + await Assert.ThrowsAsync(() => session.LoadAsync(repository.Id)); + var snapshot = await NestHistorySession.ReadCurrentAsync(repository, repository.Id, "http://shop/"); + Assert.Equal(0, snapshot.Metadata.Revision); + Assert.Equal("http://shop", snapshot.ServerUrl); + Assert.Equal(repository.Bytes, snapshot.Archive); + } + + [Fact] + public async Task CurrentOpen_UsesImmutableRevisionNotUnversionedFile() + { + var repository = new Repository(); + var snapshot = await NestHistorySession.ReadCurrentAsync(repository, repository.Id, "http://shop"); + Assert.Equal(5, repository.DownloadRevision); + Assert.Equal(0, repository.LegacyDownloads); + var save = new NestSaveSession(); + save.Bind(snapshot.Metadata.Id, snapshot.ServerUrl, snapshot.Metadata.Revision); + Assert.Equal(5, save.Revision); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CurrentOpen_RejectsChangedMetadataOrMismatchingArchive(bool concurrent) + { + var repository = new Repository { ChangeDuringDownload = concurrent, WrongSize = !concurrent }; + await Assert.ThrowsAsync(() => NestHistorySession.ReadCurrentAsync(repository, repository.Id, "http://shop")); + } + + [Fact] + public async Task Mutation_VerificationFailureIsNotSuccess() + { + var repository = new Repository { WrongVerification = true }; + using var session = new NestHistorySession(repository, "http://shop"); + await session.LoadAsync(repository.Id); + await Assert.ThrowsAsync(() => session.RestoreAsync(2, "http://shop")); + Assert.Null(session.Current); + Assert.Equal(1, repository.Writes); + } + + [Fact] + public async Task Download_SelectionChangedDuringReadDropsLateBytes() + { + var repository = new Repository(); + using var session = new NestHistorySession(repository, "http://shop"); + await session.LoadAsync(repository.Id); + var held = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + repository.File = held.Task; + var read = session.DownloadAsync(2, "http://shop"); + session.Clear(); + held.SetResult(repository.Bytes); + Assert.Null(await read); + } + + private sealed class Repository : INestRepository + { + public Guid Id { get; } = Guid.NewGuid(); + public byte[] Bytes { get; } = [1, 2, 3]; + public int Revision = 5; + public bool Deleted, Fail, ChangeDuringDownload, WrongSize, WrongVerification; + public int Limit, Writes, Expected, Source, MetadataReads, Downloads, DeletedDownloads, LegacyDownloads, DownloadRevision; + public Task? History; + public Task? File; + public NestRecord Record(Guid id, int revision) => new() + { + Id = id, + Revision = revision, + Deleted = Deleted, + FileSize = WrongSize ? 100 : Bytes.Length, + Comments = "Earlier notes", + }; + public Task GetMetadataAsync(Guid id, CancellationToken ct = default) + { + ++MetadataReads; + return Task.FromResult(Record(id, WrongVerification && Writes > 0 ? 1 : Revision)); + } + public Task GetDeletedMetadataAsync(Guid id, CancellationToken ct = default) => GetMetadataAsync(id, ct); + public Task GetRevisionsAsync(Guid id, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) + { + Limit = limit; + return History ?? Task.FromResult(new NestRevisionPage { Items = [Record(id, 2)], Total = offset + 1, Offset = offset, Limit = limit }); + } + public Task GetDeletedRevisionsAsync(Guid id, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => GetRevisionsAsync(id, offset, limit, cancellationToken); + public Task GetRevisionFileAsync(Guid id, int revisionNumber, CancellationToken cancellationToken = default) + { + Downloads++; + DownloadRevision = revisionNumber; + if (ChangeDuringDownload) Revision++; + return File ?? Task.FromResult(Bytes); + } + public Task GetDeletedRevisionFileAsync(Guid id, int revisionNumber, CancellationToken cancellationToken = default) + { + DeletedDownloads++; + return GetRevisionFileAsync(id, revisionNumber, cancellationToken); + } + public Task GetFileAsync(Guid id, CancellationToken ct = default) { LegacyDownloads++; return Task.FromResult(Bytes); } + public Task RestoreAsync(Guid id, int sourceRevision, int expectedRevision, CancellationToken ct = default) + { + Writes++; Expected = expectedRevision; Source = sourceRevision; + if (Fail) throw new IOException("Conflict: current version 6. Reload or Save As; no automatic retry."); + Deleted = false; + Revision++; + return Task.FromResult(Record(id, Revision)); + } + public Task RecoverAsync(Guid id, int expectedRevision, CancellationToken ct = default) => RestoreAsync(id, 0, expectedRevision, ct); + public Task> ListAsync(CancellationToken ct = default) => throw new NotImplementedException(); + public Task QueryAsync(NestQuery query, CancellationToken ct = default) => throw new NotImplementedException(); + public Task UploadAsync(byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException(); + public Task UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException(); + public Task UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException(); + public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException(); + } +} diff --git a/OpenNest.WinForms.Tests/Forms/NestHistoryFormTests.cs b/OpenNest.WinForms.Tests/Forms/NestHistoryFormTests.cs new file mode 100644 index 0000000..260f304 --- /dev/null +++ b/OpenNest.WinForms.Tests/Forms/NestHistoryFormTests.cs @@ -0,0 +1,86 @@ +using System.Reflection; +using System.Windows.Forms; +using OpenNest.Data; +using OpenNest.Forms; + +namespace OpenNest.WinForms.Tests.Forms; + +public class NestHistoryFormTests +{ + [Fact] + public void HistoricalSelection_IsReadOnlyAndRestoreIsDistinctFromCurrent() + { + StaTestThread.Run(() => + { + var repository = new Repository(); + using var form = new NestHistoryForm(repository, "http://shop", repository.Id); + form.Show(); + Application.DoEvents(); + var grid = Field(form, "revisions"); + Assert.Equal(2, grid.Rows.Count); + Assert.True(grid.ReadOnly); + Assert.Equal("Current", grid.Rows[0].Cells["State"].Value); + Assert.False(Field