using System.Net; using OpenNest.Data; namespace OpenNest.Server.Tests; public sealed class NestHistoryClientTests { [Fact] public async Task LostCreateReply_BlocksReplayAndReconcilesSameId() { using var factory = new ServerFactory(); using var http = factory.CreateDefaultClient(new LostReplyHandler()); using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString()); var session = new NestSaveSession(); var nest = NestApiTests.SyntheticNest(); var archive = NestApiTests.Archive(nest); await Assert.ThrowsAsync(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive)); var pendingId = session.PendingCreateId; Assert.NotEqual(Guid.Empty, pendingId); Assert.True(session.RequiresReload); Assert.Single(await repository.ListAsync()); await Assert.ThrowsAsync(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive)); Assert.Single(await repository.ListAsync()); var reconciled = await session.ReconcilePendingCreateAsync(repository, http.BaseAddress.ToString()); Assert.Equal(pendingId, reconciled!.Id); Assert.Equal(pendingId, session.RemoteId); Assert.Equal(Guid.Empty, session.PendingCreateId); Assert.Single(await repository.ListAsync()); Assert.Equal(archive, await repository.GetRevisionFileAsync(pendingId, 1)); } private sealed class LostReplyHandler : DelegatingHandler { private bool lost; protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) { var response = await base.SendAsync(request, ct); if (request.Method == HttpMethod.Post && !lost) { lost = true; response.Dispose(); throw new IOException("Response lost after commit."); } return response; } } [Fact] public async Task LostCreateReply_ChangedServerMetadataCannotBindWithoutReload() { using var factory = new ServerFactory(); using var http = factory.CreateDefaultClient(new LostReplyHandler()); using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString()); var session = new NestSaveSession(); var nest = NestApiTests.SyntheticNest(); var archive = NestApiTests.Archive(nest); await Assert.ThrowsAsync(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive)); var id = session.PendingCreateId; var current = (await repository.GetMetadataAsync(id))!; current.Comments = "Changed by another editor"; await repository.UpdateMetadataAsync(id, current, current.Revision); await Assert.ThrowsAsync(() => session.ReconcilePendingCreateAsync(repository, http.BaseAddress.ToString())); Assert.Equal(Guid.Empty, session.RemoteId); Assert.True(session.RequiresReload); await Assert.ThrowsAsync(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive)); Assert.Single(await repository.ListAsync()); } private sealed class FirstPostFailsBeforeSend : DelegatingHandler { private bool failed; protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) { if (request.Method == HttpMethod.Post && !failed) { failed = true; throw new IOException("Upload was not sent."); } return base.SendAsync(request, ct); } } [Fact] public async Task AbsentPendingUpload_RetriesSameIdOnlyAfterExplicitReconciliation() { using var factory = new ServerFactory(); using var http = factory.CreateDefaultClient(new FirstPostFailsBeforeSend()); using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString()); var session = new NestSaveSession(); var nest = NestApiTests.SyntheticNest(); var archive = NestApiTests.Archive(nest); await Assert.ThrowsAsync(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive)); var pending = session.PendingCreateId; Assert.Empty(await repository.ListAsync()); await Assert.ThrowsAsync(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive)); Assert.Null(await session.ReconcilePendingCreateAsync(repository, http.BaseAddress.ToString())); Assert.False(session.RequiresReload); var created = await session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive); Assert.Equal(pending, created.Id); Assert.Equal(1, created.Revision); Assert.Single(await repository.ListAsync()); } [Fact] public async Task AbsentPendingSaveAs_NeverUpdatesBoundOriginalOnRetry() { using var factory = new ServerFactory(); using var seedHttp = factory.CreateClient(); using var seed = new RemoteNestRepository(seedHttp, seedHttp.BaseAddress!.ToString()); var nest = NestApiTests.SyntheticNest(); var originalBytes = NestApiTests.Archive(nest); var original = await seed.UploadAsync(originalBytes, new NestRecord { Name = "Original" }); using var http = factory.CreateDefaultClient(new FirstPostFailsBeforeSend()); using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString()); var session = new NestSaveSession(); session.Bind(original.Id, http.BaseAddress.ToString(), original.Revision); nest.Name = "Copied nest"; var copyBytes = NestApiTests.Archive(nest); await Assert.ThrowsAsync(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, copyBytes, saveCopy: true)); var pending = session.PendingCreateId; Assert.Null(await session.ReconcilePendingCreateAsync(repository, http.BaseAddress.ToString())); await Assert.ThrowsAsync(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, copyBytes)); var copy = await session.SaveAsync(repository, http.BaseAddress.ToString(), nest, copyBytes, saveCopy: true); Assert.Equal(pending, copy.Id); Assert.NotEqual(original.Id, copy.Id); Assert.Equal(1, (await repository.GetMetadataAsync(original.Id))!.Revision); Assert.Equal(originalBytes, await repository.GetFileAsync(original.Id)); Assert.Equal(copyBytes, await repository.GetFileAsync(copy.Id)); } [Fact] public async Task RealClient_CreatesAndUpdatesOneNestWithExactHistoricalArchives() { using var factory = new ServerFactory(); using var http = factory.CreateClient(); using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString()); var id = Guid.NewGuid(); var first = new byte[] { 80, 75, 0, 1, 255 }; var second = new byte[] { 80, 75, 0, 2, 254 }; var created = await repository.UploadAsync(first, new NestRecord { Id = id, Name = "Synthetic" }); Assert.Equal(1, created.Revision); var updated = await repository.UpdateFileAsync(id, second, new NestRecord { Id = id, Name = "Synthetic two" }, 1); Assert.Equal(2, updated.Revision); var page = await repository.GetRevisionsAsync(id, limit: 1); Assert.Equal(2, page.Total); Assert.Equal(2, Assert.Single(page.Items).Revision); Assert.Equal("Synthetic", Assert.Single((await repository.GetRevisionsAsync(id, offset: 1, limit: 1)).Items).Name); Assert.Equal(first, await repository.GetRevisionFileAsync(id, 1)); Assert.Equal(second, await repository.GetRevisionFileAsync(id, 2)); Assert.Equal(second, await repository.GetFileAsync(id)); var conflict = await Assert.ThrowsAsync(() => repository.UpdateFileAsync(id, first, created, 1)); Assert.Contains("409", conflict.Message); Assert.Equal(2, (await repository.GetRevisionsAsync(id, limit: 1)).Total); Assert.Equal(second, await repository.GetFileAsync(id)); } [Fact] public async Task LegacyUnguardedDelete_IsRefusedWithoutMutation() { using var factory = new ServerFactory(); using var http = factory.CreateClient(); using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString()); var created = await repository.UploadAsync([1, 2, 3], new NestRecord { Name = "Synthetic" }); using var response = await http.DeleteAsync($"/api/nests/{created.Id}"); Assert.Equal((HttpStatusCode)428, response.StatusCode); Assert.Equal(new byte[] { 1, 2, 3 }, await repository.GetRevisionFileAsync(created.Id, 1)); } }