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