diff --git a/OpenNest.Data/RemoteDrawingLibrary.cs b/OpenNest.Data/RemoteDrawingLibrary.cs new file mode 100644 index 00000000..0f0d5da1 --- /dev/null +++ b/OpenNest.Data/RemoteDrawingLibrary.cs @@ -0,0 +1,352 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenNest.Data; + +/// +/// Talks to the drawing library on the nest server (/api/drawings). Uploads are +/// multipart: a "metadata" JSON part and a "package" part (a drawing package). Requests +/// are checked against before sending. A server without +/// the library raises ; this client never falls back +/// to another store. Other failures surface as . +/// +public sealed class RemoteDrawingLibrary : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + }; + + private readonly HttpClient _httpClient; + private readonly bool _ownsClient; + private readonly Uri _baseUri; + + public RemoteDrawingLibrary(string baseUrl) + : this(new HttpClient(), baseUrl) + { + _ownsClient = true; + } + + /// For tests and hosts that pool HttpClient instances. + public RemoteDrawingLibrary(HttpClient httpClient, string baseUrl) + { + _httpClient = httpClient; + _baseUri = ServerUrl.ParseBase(baseUrl, nameof(baseUrl)); + } + + private Uri Url(string relative) => new(_baseUri, relative); + + /// + /// Stores revision 1 under record.Id, which the caller allocates first so an + /// ambiguous failure can be reconciled by id. An existing id raises + /// with the stored record. + /// + public async Task UploadAsync(DrawingLibraryRecord record, byte[] package, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + ValidateId(record.Id); + ValidateUpload(record, package); + using var content = BuildMultipart(record, package); + using var response = await _httpClient.PostAsync(Url("api/drawings"), content, cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + throw new ServerTooOldException(); + if (response.StatusCode == HttpStatusCode.Conflict) + throw new DrawingLibraryConflictException(await ReadRecord(response, cancellationToken)); + await EnsureSuccess(response, $"save drawing '{record.Name}' to the library", cancellationToken); + var saved = await ReadRecord(response, cancellationToken); + if (saved.Id != record.Id || saved.Revision != 1 || saved.PackageSha256 != Sha256(package)) + throw new IOException("The nest server returned a different drawing than was saved. Reload the library before saving again."); + return saved; + } + + public async Task QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + var error = query.GetValidationError(); + if (error is not null) + throw new ArgumentException(error, nameof(query)); + + using var response = await _httpClient.GetAsync(Url("api/drawings/query?" + BuildQueryString(query)), cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + throw new ServerTooOldException(); + await EnsureSuccess(response, "browse the drawing library", cancellationToken); + var page = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) + ?? throw new IOException("Nest server returned an empty drawing page."); + if (page.Items is null || page.Items.Count > query.Limit || page.Total < 0 || page.Items.Any(item => item is null)) + throw new IOException($"Nest server returned an invalid drawing page for a page limited to {query.Limit}."); + return page; + } + + /// The current record, including an archived one, or null for an unknown id. + public async Task GetAsync(Guid id, CancellationToken cancellationToken = default) + { + ValidateId(id); + using var response = await _httpClient.GetAsync(Url($"api/drawings/{id}"), cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + { + await ThrowIfServerTooOld(cancellationToken); + return null; + } + await EnsureSuccess(response, $"read library drawing {id}", cancellationToken); + var record = await ReadRecord(response, cancellationToken); + return record.Id == id ? record : throw new IOException("Nest server returned a different library drawing."); + } + + /// The current package bytes, or null for an unknown id. + public Task GetPackageAsync(Guid id, CancellationToken cancellationToken = default) + { + ValidateId(id); + return GetBytesAsync($"api/drawings/{id}/file", $"download library drawing {id}", cancellationToken); + } + + /// One page of immutable revisions, newest first, or null for an unknown id. + public async Task GetRevisionsAsync(Guid id, int offset = 0, + int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default) + { + ValidateId(id); + if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset)); + if (limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit) throw new ArgumentOutOfRangeException(nameof(limit)); + using var response = await _httpClient.GetAsync( + Url($"api/drawings/{id}/revisions?offset={offset.ToString(CultureInfo.InvariantCulture)}&limit={limit.ToString(CultureInfo.InvariantCulture)}"), + cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + { + await ThrowIfServerTooOld(cancellationToken); + return null; + } + await EnsureSuccess(response, $"read library drawing history {id}", cancellationToken); + var page = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) + ?? throw new IOException("Nest server returned an empty drawing history page."); + if (page.Items is null || page.Items.Count > limit || page.Offset != offset || page.Limit != limit + || page.Total < 0 || page.Items.Count > System.Math.Max(0L, (long)page.Total - offset) + || page.Items.Any(item => item is null || item.Revision < 1 || item.Id != id) + || page.Items.Zip(page.Items.Skip(1)).Any(pair => pair.First.Revision <= pair.Second.Revision)) + throw new IOException("Nest server returned an invalid or unbounded drawing history page."); + return page; + } + + /// The exact package of one revision, or null when the drawing or revision is unknown. + public Task GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) + { + ValidateId(id); + ValidateRevision(revision); + return GetBytesAsync($"api/drawings/{id}/revisions/{revision.ToString(CultureInfo.InvariantCulture)}/file", + $"download library drawing {id} revision {revision}", cancellationToken); + } + + /// Saves new geometry and metadata as the next revision when is current. + public async Task UpdatePackageAsync(Guid id, DrawingLibraryRecord record, byte[] package, + int expectedRevision, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + ValidateId(id); + ValidateRevision(expectedRevision); + ValidateUpload(record, package); + using var request = Guarded(HttpMethod.Put, $"api/drawings/{id}/file", expectedRevision); + request.Content = BuildMultipart(record, package); + var saved = await SendGuarded(request, id, expectedRevision, $"save library drawing {id}", cancellationToken); + if (saved.PackageSha256 != Sha256(package)) + throw new IOException("The nest server stored different drawing geometry. Reload the library drawing before saving again."); + return saved; + } + + /// Saves new metadata, keeping the geometry, as the next revision. + public async Task UpdateMetadataAsync(Guid id, DrawingLibraryRecord record, int expectedRevision, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + ValidateId(id); + ValidateRevision(expectedRevision); + var error = DrawingLibraryRules.GetMetadataError(record); + if (error is not null) + throw new ArgumentException(error, nameof(record)); + using var request = Guarded(HttpMethod.Put, $"api/drawings/{id}/metadata", expectedRevision); + request.Content = JsonContent.Create(record, options: JsonOptions); + return await SendGuarded(request, id, expectedRevision, $"edit library drawing {id}", cancellationToken); + } + + public Task ArchiveAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => + SetArchivedAsync(id, expectedRevision, "archive", cancellationToken); + + public Task RestoreAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => + SetArchivedAsync(id, expectedRevision, "restore", cancellationToken); + + private async Task SetArchivedAsync(Guid id, int expectedRevision, string action, + CancellationToken cancellationToken) + { + ValidateId(id); + ValidateRevision(expectedRevision); + using var request = Guarded(HttpMethod.Post, $"api/drawings/{id}/{action}", expectedRevision); + var changed = await SendGuarded(request, id, expectedRevision, $"{action} library drawing {id}", cancellationToken); + if (changed.Archived != (action == "archive")) + throw new IOException($"Nest server did not {action} library drawing {id}. Reload the library."); + return changed; + } + + private HttpRequestMessage Guarded(HttpMethod method, string relative, int expectedRevision) + { + var request = new HttpRequestMessage(method, Url(relative)); + request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\"")); + return request; + } + + private async Task SendGuarded(HttpRequestMessage request, Guid id, int expectedRevision, + string action, CancellationToken cancellationToken) + { + using var response = await _httpClient.SendAsync(request, cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + { + await ThrowIfServerTooOld(cancellationToken); + throw new IOException($"Library drawing {id} no longer exists on the nest server."); + } + if (response.StatusCode == HttpStatusCode.Conflict) + throw new DrawingLibraryConflictException(await ReadRecord(response, cancellationToken)); + await EnsureSuccess(response, action, cancellationToken); + var saved = await ReadRecord(response, cancellationToken); + if (saved.Id != id || saved.Revision < expectedRevision) + throw new IOException("Nest server returned an invalid library drawing revision. Reload the library before saving again."); + return saved; + } + + private async Task GetBytesAsync(string relative, string action, CancellationToken cancellationToken) + { + using var response = await _httpClient.GetAsync(Url(relative), cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + { + await ThrowIfServerTooOld(cancellationToken); + return null; + } + await EnsureSuccess(response, action, cancellationToken); + return await response.Content.ReadAsByteArrayAsync(cancellationToken); + } + + // A 404 for one drawing is ambiguous; the query route exists on every library-aware server. + private async Task ThrowIfServerTooOld(CancellationToken cancellationToken) + { + using var probe = await _httpClient.GetAsync(Url("api/drawings/query?limit=1"), cancellationToken); + if (probe.StatusCode == HttpStatusCode.NotFound) + throw new ServerTooOldException(); + } + + private static void ValidateUpload(DrawingLibraryRecord record, byte[] package) + { + ArgumentNullException.ThrowIfNull(package); + var error = DrawingLibraryRules.GetMetadataError(record) ?? DrawingLibraryRules.GetPackageError(package); + if (error is not null) + throw new ArgumentException(error, nameof(record)); + } + + private static void ValidateId(Guid id) + { + if (id == Guid.Empty) throw new ArgumentException("A library drawing id is required.", nameof(id)); + } + + private static void ValidateRevision(int revision) + { + if (revision < 1) throw new ArgumentOutOfRangeException(nameof(revision)); + } + + private static string Sha256(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + + private static string BuildQueryString(DrawingLibraryQuery query) + { + var text = new StringBuilder() + .Append("search=").Append(Uri.EscapeDataString(query.NormalizedSearch)) + .Append("&sort=").Append(JsonNamingPolicy.CamelCase.ConvertName(query.Sort.ToString())) + .Append("&order=").Append(query.Descending ? "desc" : "asc") + .Append("&offset=").Append(query.Offset.ToString(CultureInfo.InvariantCulture)) + .Append("&limit=").Append(query.Limit.ToString(CultureInfo.InvariantCulture)); + AppendFilter(text, "customer", query.Customer); + AppendFilter(text, "materialName", query.MaterialName); + AppendFilter(text, "materialGrade", query.MaterialGrade); + if (query.Thickness is { } thickness) + text.Append("&thickness=").Append(DrawingLibraryRules.NormalizeThickness(thickness).ToString("0.####", CultureInfo.InvariantCulture)); + if (query.CutType is { } cutType) + text.Append("&cutType=").Append(cutType.ToString()); + if (query.IncludeArchived) + text.Append("&includeArchived=true"); + return text.ToString(); + } + + private static void AppendFilter(StringBuilder text, string key, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + text.Append('&').Append(key).Append('=').Append(Uri.EscapeDataString(value)); + } + + private static MultipartFormDataContent BuildMultipart(DrawingLibraryRecord record, byte[] package) + { + var content = new MultipartFormDataContent(); + content.Add(new StringContent(JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json"), "metadata"); + var file = new ByteArrayContent(package); + file.Headers.ContentType = new MediaTypeHeaderValue("application/zip"); + content.Add(file, "package", "drawing.nest"); + return content; + } + + private static async Task ReadRecord(HttpResponseMessage response, CancellationToken cancellationToken) + { + var record = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + return record ?? throw new IOException("Nest server returned an empty library drawing record."); + } + + private static async Task EnsureSuccess(HttpResponseMessage response, string action, CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + return; + + var detail = ""; + try + { + detail = await response.Content.ReadAsStringAsync(cancellationToken); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException) + { + // The status code is the useful part; the body is best-effort. + } + + if (detail.Length > 200) + detail = detail[..200] + "…"; + throw new IOException( + $"Could not {action} on the nest server: HTTP {(int)response.StatusCode} {response.ReasonPhrase}. {detail}".TrimEnd()); + } + + public void Dispose() + { + if (_ownsClient) + _httpClient.Dispose(); + } +} + +/// The nest server predates the drawing library (it answered HTTP 404). +public sealed class ServerTooOldException : IOException +{ + public ServerTooOldException() + : base("This nest server does not support the drawing library (HTTP 404). Update the nest server to this OpenNest version.") + { + } +} + +/// A guarded library write found a newer or different current state; nothing was changed. +public sealed class DrawingLibraryConflictException : IOException +{ + public DrawingLibraryConflictException(DrawingLibraryRecord current) + : base(current.Archived + ? $"Library drawing '{current.Name}' is archived or was changed (current revision {current.Revision}). Reload before saving." + : $"Library drawing '{current.Name}' was changed by someone else (current revision {current.Revision}). Reload before saving.") + { + Current = current; + } + + /// The drawing's current server record. + public DrawingLibraryRecord Current { get; } +} diff --git a/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs b/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs new file mode 100644 index 00000000..9d92eab6 --- /dev/null +++ b/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs @@ -0,0 +1,232 @@ +using System.Net; +using System.Security.Cryptography; +using OpenNest.Data; + +namespace OpenNest.Tests.Data; + +public class RemoteDrawingLibraryTests +{ + private static readonly byte[] Package = { (byte)'P', (byte)'K', 3, 4, 9 }; + + private static string Sha(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + + private static DrawingLibraryRecord SampleRecord(Guid? id = null, int revision = 1) => new() + { + Id = id ?? Guid.NewGuid(), + Revision = revision, + Name = "Synthetic bracket", + Customer = "Synthetic customer", + MaterialName = "Synthetic Steel", + MaterialGrade = "Grade A", + Thickness = 0.25, + CutType = CutType.Waterjet, + PackageSha256 = Sha(Package), + PackageSize = Package.Length, + }; + + private static RemoteDrawingLibrary Library(StubHandler handler) => + new(new HttpClient(handler), "http://server:8090/base/"); + + [Fact] + public void Constructor_RejectsInvalidUrl() + { + Assert.Throws(() => new RemoteDrawingLibrary("not a url")); + Assert.Throws(() => new RemoteDrawingLibrary("ftp://host")); + } + + [Fact] + public async Task QueryAsync_SendsEscapedFiltersAndReadsPage() + { + var record = SampleRecord(); + var handler = new StubHandler(_ => StubHandler.Json(new { items = new[] { record }, total = 7, offset = 5, limit = 5 })); + using var library = Library(handler); + + var page = await library.QueryAsync(new DrawingLibraryQuery + { + Search = " 50%_\\ ", + Customer = "A&B", + MaterialName = "Carbon Steel", + MaterialGrade = " ", + Thickness = 0.123456, + CutType = CutType.Plasma, + IncludeArchived = true, + Sort = DrawingSortField.MaterialGrade, + Descending = false, + Offset = 5, + Limit = 5, + }); + + Assert.Equal( + "http://server:8090/base/api/drawings/query?search=50%25_%5C&sort=materialGrade&order=asc&offset=5&limit=5" + + "&customer=A%26B&materialName=Carbon%20Steel&thickness=0.1235&cutType=Plasma&includeArchived=true", + handler.Requests[0].RequestUri!.AbsoluteUri); + Assert.Equal(record.Id, Assert.Single(page.Items).Id); + Assert.Equal(CutType.Waterjet, page.Items[0].CutType); + Assert.Equal(7, page.Total); + } + + public static TheoryData OutOfBoundsQueries() => new() + { + new DrawingLibraryQuery { Offset = -1 }, + new DrawingLibraryQuery { Limit = 0 }, + new DrawingLibraryQuery { Limit = DrawingLibraryQuery.MaxLimit + 1 }, + new DrawingLibraryQuery { Search = new string('x', DrawingLibraryQuery.MaxSearchLength + 1) }, + new DrawingLibraryQuery { Customer = "a\0b" }, + new DrawingLibraryQuery { Sort = (DrawingSortField)99 }, + new DrawingLibraryQuery { CutType = (CutType)9 }, + new DrawingLibraryQuery { Thickness = double.NaN }, + new DrawingLibraryQuery { Thickness = -0.1 }, + }; + + [Theory] + [MemberData(nameof(OutOfBoundsQueries))] + public async Task QueryAsync_OutOfBounds_IsRefusedBeforeSending(DrawingLibraryQuery query) + { + var handler = new StubHandler(_ => throw new InvalidOperationException("must not send")); + using var library = Library(handler); + + await Assert.ThrowsAsync(() => library.QueryAsync(query)); + Assert.Empty(handler.Requests); + } + + [Fact] + public async Task QueryAsync_OversizedPage_IsRefused() + { + var handler = new StubHandler(_ => StubHandler.Json(new { items = new[] { SampleRecord(), SampleRecord() }, total = 2 })); + using var library = Library(handler); + + await Assert.ThrowsAsync(() => library.QueryAsync(new DrawingLibraryQuery { Limit = 1 })); + } + + [Fact] + public async Task OlderServer_IsReportedAndNeverFallsBack() + { + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + using var library = Library(handler); + var id = Guid.NewGuid(); + + await Assert.ThrowsAsync(() => library.QueryAsync(new DrawingLibraryQuery())); + await Assert.ThrowsAsync(() => library.UploadAsync(SampleRecord(), Package)); + await Assert.ThrowsAsync(() => library.GetAsync(id)); + await Assert.ThrowsAsync(() => library.GetPackageAsync(id)); + await Assert.ThrowsAsync(() => library.GetRevisionsAsync(id)); + await Assert.ThrowsAsync(() => library.UpdateMetadataAsync(id, SampleRecord(id), 1)); + Assert.All(handler.Requests, request => Assert.StartsWith("http://server:8090/base/api/drawings", request.RequestUri!.AbsoluteUri)); + } + + [Fact] + public async Task UnknownDrawingOnCurrentServer_IsNullOrNamedFailure() + { + var handler = new StubHandler(request => request.RequestUri!.AbsolutePath.EndsWith("/query", StringComparison.Ordinal) + ? StubHandler.Json(new { items = Array.Empty() }) + : new HttpResponseMessage(HttpStatusCode.NotFound)); + using var library = Library(handler); + var id = Guid.NewGuid(); + + Assert.Null(await library.GetAsync(id)); + Assert.Null(await library.GetPackageAsync(id)); + Assert.Null(await library.GetRevisionsAsync(id)); + Assert.Null(await library.GetRevisionPackageAsync(id, 2)); + var error = await Assert.ThrowsAsync(() => library.ArchiveAsync(id, 1)); + Assert.IsNotType(error); + Assert.Contains("no longer exists", error.Message); + } + + [Fact] + public async Task UploadAsync_InvalidInput_IsRefusedBeforeSending() + { + var handler = new StubHandler(_ => throw new InvalidOperationException("must not send")); + using var library = Library(handler); + var unnamed = SampleRecord(); + unnamed.Name = " "; + + await Assert.ThrowsAsync(() => library.UploadAsync(SampleRecord(Guid.Empty), Package)); + await Assert.ThrowsAsync(() => library.UploadAsync(unnamed, Package)); + await Assert.ThrowsAsync(() => library.UploadAsync(SampleRecord(), new byte[] { 1, 2, 3, 4 })); + await Assert.ThrowsAsync(() => library.UploadAsync(SampleRecord(), Array.Empty())); + await Assert.ThrowsAsync(() => library.ArchiveAsync(Guid.NewGuid(), 0)); + Assert.Empty(handler.Requests); + } + + [Fact] + public async Task UploadAsync_SendsMultipartAndVerifiesTheStoredPackage() + { + var record = SampleRecord(); + var handler = new StubHandler(_ => StubHandler.Json(record)); + using var library = Library(handler); + + var saved = await library.UploadAsync(record, Package); + + Assert.Equal(HttpMethod.Post, handler.Requests[0].Method); + Assert.EndsWith("/base/api/drawings", handler.Requests[0].RequestUri!.AbsoluteUri); + Assert.Contains("name=metadata", handler.RequestBodies[0]); + Assert.Contains("name=package", handler.RequestBodies[0]); + Assert.Contains("\"cutType\":\"waterjet\"", handler.RequestBodies[0]); + Assert.Equal(record.Id, saved.Id); + } + + [Fact] + public async Task UploadAsync_DifferentStoredPackageOrId_IsRefused() + { + var record = SampleRecord(); + var otherPackage = SampleRecord(record.Id); + otherPackage.PackageSha256 = "00"; + using var wrongHash = Library(new StubHandler(_ => StubHandler.Json(otherPackage))); + using var wrongId = Library(new StubHandler(_ => StubHandler.Json(SampleRecord()))); + + await Assert.ThrowsAsync(() => wrongHash.UploadAsync(record, Package)); + await Assert.ThrowsAsync(() => wrongId.UploadAsync(record, Package)); + } + + [Fact] + public async Task Conflicts_CarryTheCurrentRecord() + { + var current = SampleRecord(revision: 4); + var handler = new StubHandler(_ => StubHandler.Json(current, HttpStatusCode.Conflict)); + using var library = Library(handler); + + var upload = await Assert.ThrowsAsync(() => library.UploadAsync(SampleRecord(current.Id), Package)); + var update = await Assert.ThrowsAsync(() => + library.UpdatePackageAsync(current.Id, SampleRecord(current.Id), Package, 3)); + + Assert.Equal(4, upload.Current.Revision); + Assert.Equal(4, update.Current.Revision); + Assert.Equal("\"3\"", Assert.Single(handler.Requests[1].Headers.IfMatch).Tag); + } + + [Fact] + public async Task GuardedWrites_SendIfMatchAndVerifyTheResult() + { + var id = Guid.NewGuid(); + var archived = SampleRecord(id, 6); + archived.Archived = true; + var handler = new StubHandler(_ => StubHandler.Json(archived)); + using var library = Library(handler); + + var result = await library.ArchiveAsync(id, 5); + await Assert.ThrowsAsync(() => library.RestoreAsync(id, 5)); + await Assert.ThrowsAsync(() => library.UpdateMetadataAsync(id, SampleRecord(id), 7)); + + Assert.True(result.Archived); + Assert.Equal(HttpMethod.Post, handler.Requests[0].Method); + Assert.EndsWith($"/api/drawings/{id}/archive", handler.Requests[0].RequestUri!.AbsoluteUri); + Assert.Equal("\"5\"", Assert.Single(handler.Requests[0].Headers.IfMatch).Tag); + Assert.EndsWith($"/api/drawings/{id}/restore", handler.Requests[1].RequestUri!.AbsoluteUri); + Assert.Equal(HttpMethod.Put, handler.Requests[2].Method); + Assert.EndsWith($"/api/drawings/{id}/metadata", handler.Requests[2].RequestUri!.AbsoluteUri); + } + + [Fact] + public async Task GetRevisionsAsync_RefusesInvalidHistory() + { + var id = Guid.NewGuid(); + var unordered = new { items = new[] { SampleRecord(id, 1), SampleRecord(id, 2) }, total = 2, offset = 0, limit = 100 }; + var foreign = new { items = new[] { SampleRecord(Guid.NewGuid(), 1) }, total = 1, offset = 0, limit = 100 }; + using var first = Library(new StubHandler(_ => StubHandler.Json(unordered))); + using var second = Library(new StubHandler(_ => StubHandler.Json(foreign))); + + await Assert.ThrowsAsync(() => first.GetRevisionsAsync(id)); + await Assert.ThrowsAsync(() => second.GetRevisionsAsync(id)); + await Assert.ThrowsAsync(() => first.GetRevisionsAsync(id, limit: 201)); + } +}