diff --git a/OpenNest.Server.Tests/NestApiTests.cs b/OpenNest.Server.Tests/NestApiTests.cs index 0257fb9f..5f88bc0a 100644 --- a/OpenNest.Server.Tests/NestApiTests.cs +++ b/OpenNest.Server.Tests/NestApiTests.cs @@ -216,7 +216,7 @@ public sealed class NestApiTests () => new HttpRequestMessage(HttpMethod.Get, $"/api/nests/{unknown}/file"), () => new HttpRequestMessage(HttpMethod.Put, $"/api/nests/{unknown}/file") { - Content = Multipart(JsonSerializer.Serialize(record, JsonOptions), new byte[] { 1, 2, 3 }), + Content = Multipart(JsonSerializer.Serialize(record, JsonOptions), TestNests.Archive(1, 2, 3)), }, () => new HttpRequestMessage(HttpMethod.Put, $"/api/nests/{unknown}/metadata") { @@ -287,15 +287,15 @@ public sealed class NestApiTests content.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/form-data; boundary=expected-boundary"); return content; case "missing boundary": - var unbounded = Multipart(metadata, new byte[] { 1 }); + var unbounded = Multipart(metadata, TestNests.Archive(1)); unbounded.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/form-data"); return unbounded; case "missing metadata": - return Multipart(null, new byte[] { 1 }); + return Multipart(null, TestNests.Archive(1)); case "invalid metadata JSON": - return Multipart("{not-json", new byte[] { 1 }); + return Multipart("{not-json", TestNests.Archive(1)); case "null metadata": - return Multipart("null", new byte[] { 1 }); + return Multipart("null", TestNests.Archive(1)); case "missing file": return Multipart(metadata, null); case "empty file": diff --git a/OpenNest.Server.Tests/NestArchiveFieldsTests.cs b/OpenNest.Server.Tests/NestArchiveFieldsTests.cs new file mode 100644 index 00000000..840e86a0 --- /dev/null +++ b/OpenNest.Server.Tests/NestArchiveFieldsTests.cs @@ -0,0 +1,184 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using OpenNest.Data; +using OpenNest.Geometry; +using OpenNest.IO; + +namespace OpenNest.Server.Tests; + +/// +/// Plate count, part count, material and thickness describe the uploaded archive: the server +/// reads them from its nest.json, whatever the client claims, and refuses files it cannot read. +/// +public sealed class NestArchiveFieldsTests +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } }; + + [Fact] + public async Task Upload_TakesArchiveFieldsFromTheFileNotTheClient() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var archive = NestApiTests.Archive(TwoPlateNest()); + + using var response = await client.PostAsync("/api/nests", NestRevisionTests.Body(Claimed(), archive)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var stored = (await response.Content.ReadFromJsonAsync(Options))!; + Assert.Equal(2, stored.PlateCount); + Assert.Equal(3, stored.PartCount); + Assert.Equal("Synthetic alloy", stored.Material); + Assert.Equal(0.25, stored.Thickness); + // Same values the desktop computed by reading the archive back in full. + using var stream = new MemoryStream(archive, writable: false); + var expected = NestRecordFactory.FromNest(new NestReader(stream).Read(), stored.Id, archive.LongLength); + Assert.Equal( + (expected.PlateCount, expected.PartCount, expected.Material, expected.Thickness), + (stored.PlateCount, stored.PartCount, stored.Material, stored.Thickness)); + // Client-owned fields are still the client's. + Assert.Equal("Claimed name", stored.Name); + } + + [Fact] + public async Task UpdateFile_TakesArchiveFieldsFromTheNewFile() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var id = await Create(client, NestApiTests.Archive(TwoPlateNest())); + client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\""); + + using var response = await client.PutAsync($"/api/nests/{id}/file", + NestRevisionTests.Body(Claimed(), NestApiTests.Archive(NestApiTests.SyntheticNest()))); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var stored = (await response.Content.ReadFromJsonAsync(Options))!; + Assert.Equal((1, 1), (stored.PlateCount, stored.PartCount)); + } + + [Fact] + public async Task MetadataOnlySave_KeepsArchiveCountsAndAcceptsEditedMaterialAndThickness() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var id = await Create(client, NestApiTests.Archive(TwoPlateNest())); + client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\""); + var edit = Claimed(); + edit.Material = "Edited alloy"; + edit.Thickness = 0.5; + + using var response = await client.PutAsJsonAsync($"/api/nests/{id}/metadata", edit, Options); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var stored = (await response.Content.ReadFromJsonAsync(Options))!; + // The archive is unchanged, so its counts are too; material and thickness are + // editable record fields the desktop applies when it opens the nest. + Assert.Equal((2, 3), (stored.PlateCount, stored.PartCount)); + Assert.Equal(("Edited alloy", 0.5), (stored.Material, stored.Thickness)); + Assert.Equal(2, stored.Revision); + } + + [Theory] + [InlineData("not a zip")] + [InlineData("no nest.json")] + [InlineData("nest.json not JSON")] + [InlineData("nest.json null")] + public async Task Upload_FileThatIsNotANest_Returns400AndStoresNothing(string row) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + + using var response = await client.PostAsync("/api/nests", NestRevisionTests.Body(Claimed(), NotANest(row))); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); + Assert.Empty(reopened.List()); + } + + [Theory] + [InlineData("not a zip")] + [InlineData("no nest.json")] + public async Task UpdateFile_FileThatIsNotANest_Returns400AndLeavesTheRecord(string row) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var original = TestNests.Archive(1); + var id = await Create(client, original); + client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\""); + + using var response = await client.PutAsync($"/api/nests/{id}/file", NestRevisionTests.Body(Claimed(), NotANest(row))); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(original, await client.GetByteArrayAsync($"/api/nests/{id}/file")); + using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); + Assert.Equal(1, reopened.Get(id)!.Revision); + } + + // Hand-written nest.json for the reader fallbacks NestWriter never produces. + [Theory] + // Thickness that is not positive falls back to the plate defaults'. + [InlineData("""{"thickness":0,"plateDefaults":{"thickness":0.375}}""", "", 0.375, 0, 0)] + // An explicit null material falls back to the plate defaults'; an absent one is empty. + [InlineData("""{"material":null,"plateDefaults":{"material":{"name":"Default"}}}""", "Default", 0, 0, 0)] + [InlineData("""{"plateDefaults":{"material":{"name":"Default"}}}""", "", 0, 0, 0)] + // A part whose drawing is missing is dropped, as NestReader drops it. + [InlineData("""{"drawings":[{"id":1}],"plates":[{"id":1,"parts":[{"drawingId":1},{"drawingId":7}]}]}""", "", 0, 1, 1)] + // A trailing plate with nothing on it is the new-plate slot; one with only cut-offs counts. + [InlineData("""{"drawings":[{"id":1}],"plates":[{"id":1,"parts":[{"drawingId":1}]},{"id":2}]}""", "", 0, 1, 1)] + [InlineData("""{"drawings":[{"id":1}],"plates":[{"id":1,"parts":[{"drawingId":1}]},{"id":2,"cutOffs":[{}]}]}""", "", 0, 2, 1)] + // Plates are ordered by id, as NestReader orders them, before finding the last one. + [InlineData("""{"drawings":[{"id":1}],"plates":[{"id":2},{"id":1,"parts":[{"drawingId":1}]}]}""", "", 0, 1, 1)] + public async Task Upload_FollowsNestReaderFallbacks(string nestJson, string material, double thickness, + int plateCount, int partCount) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + + using var response = await client.PostAsync("/api/nests", + NestRevisionTests.Body(Claimed(), TestNests.Zip("nest.json", nestJson))); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var stored = (await response.Content.ReadFromJsonAsync(Options))!; + Assert.Equal((material, thickness, plateCount, partCount), + (stored.Material, stored.Thickness, stored.PlateCount, stored.PartCount)); + } + + /// A client record whose archive-derived fields are all wrong. + private static NestRecord Claimed() => new() + { + Name = "Claimed name", + Material = "Claimed alloy", + Thickness = 9, + PlateCount = 99, + PartCount = 99, + }; + + private static Nest TwoPlateNest() + { + var nest = NestApiTests.SyntheticNest(); + var drawing = nest.Drawings.Single(); + var plate = new Plate(new Size(20, 30)) { Quantity = 1 }; + plate.Parts.Add(new Part(drawing, new Vector(2, 3))); + plate.Parts.Add(new Part(drawing, new Vector(10, 3))); + nest.Plates.Add(plate); + return nest; + } + + private static byte[] NotANest(string row) => row switch + { + "not a zip" => Encoding.UTF8.GetBytes("nest.json"), + "no nest.json" => TestNests.Zip("readme.txt", "not a nest"), + "nest.json not JSON" => TestNests.Zip("nest.json", "{not json"), + "nest.json null" => TestNests.Zip("nest.json", "null"), + _ => throw new ArgumentOutOfRangeException(nameof(row), row, null), + }; + + private static async Task Create(HttpClient client, byte[] archive) + { + using var created = await client.PostAsync("/api/nests", NestRevisionTests.Body(Claimed(), archive)); + Assert.Equal(HttpStatusCode.OK, created.StatusCode); + return (await created.Content.ReadFromJsonAsync(Options))!.Id; + } +} diff --git a/OpenNest.Server.Tests/NestHistoryClientTests.cs b/OpenNest.Server.Tests/NestHistoryClientTests.cs index 21061713..dc5f282f 100644 --- a/OpenNest.Server.Tests/NestHistoryClientTests.cs +++ b/OpenNest.Server.Tests/NestHistoryClientTests.cs @@ -135,8 +135,8 @@ public sealed class NestHistoryClientTests 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 first = TestNests.Archive(80, 75, 0, 1, 255); + var second = TestNests.Archive(80, 75, 0, 2, 254); var created = await repository.UploadAsync(first, new NestRecord { Id = id, Name = "Synthetic" }); Assert.Equal(1, created.Revision); @@ -163,9 +163,9 @@ public sealed class NestHistoryClientTests 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" }); + var created = await repository.UploadAsync(TestNests.Archive(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)); + Assert.Equal(TestNests.Archive(1, 2, 3), await repository.GetRevisionFileAsync(created.Id, 1)); } } diff --git a/OpenNest.Server.Tests/NestMutationTests.cs b/OpenNest.Server.Tests/NestMutationTests.cs index 60500ed9..f3ecaf60 100644 --- a/OpenNest.Server.Tests/NestMutationTests.cs +++ b/OpenNest.Server.Tests/NestMutationTests.cs @@ -13,7 +13,7 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var original = await repository.UploadAsync([0, 255, 1], new NestRecord { Name = "Fixture" }); + var original = await repository.UploadAsync(TestNests.Archive(0, 255, 1), new NestRecord { Name = "Fixture" }); original.Comments = "Edited"; var saved = await repository.UpdateMetadataAsync(original.Id, original); Assert.Equal(2, saved.Revision); @@ -24,10 +24,10 @@ public sealed class NestMutationTests var history = await repository.GetRevisionsAsync(saved.Id); Assert.Equal(new[] { "Edited", "" }, history.Items.Select(r => r.Comments)); foreach (var revision in history.Items) - Assert.Equal(new byte[] { 0, 255, 1 }, await repository.GetRevisionFileAsync(saved.Id, revision.Revision)); + Assert.Equal(TestNests.Archive(0, 255, 1), await repository.GetRevisionFileAsync(saved.Id, revision.Revision)); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal(2, reopened.History(saved.Id, 0, 100)!.Total); - Assert.Equal(new byte[] { 0, 255, 1 }, reopened.GetFile(saved.Id)); + Assert.Equal(TestNests.Archive(0, 255, 1), reopened.GetFile(saved.Id)); } [Theory] @@ -39,7 +39,7 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + var saved = await repository.UploadAsync(TestNests.Archive(1), new NestRecord { Name = "Fixture" }); if (header is not null) client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", header); using var response = await client.PutAsJsonAsync($"/api/nests/{saved.Id}/metadata", new NestRecord { Name = "Rejected" }); Assert.Equal(status, (int)response.StatusCode); @@ -54,7 +54,7 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + var saved = await repository.UploadAsync(TestNests.Archive(1), new NestRecord { Name = "Fixture" }); FailSnapshots(factory.DatabasePath); saved.Comments = "Rejected"; Assert.Contains("500", (await Assert.ThrowsAsync(() => repository.UpdateMetadataAsync(saved.Id, saved))).Message); @@ -69,7 +69,7 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var saved = await repository.UploadAsync([0, 255], new NestRecord { Name = "Fixture" }); + var saved = await repository.UploadAsync(TestNests.Archive(0, 255), new NestRecord { Name = "Fixture" }); var deleted = await repository.DeleteAsync(saved.Id, 1); Assert.True(deleted.Deleted); Assert.Equal(2, deleted.Revision); @@ -85,16 +85,16 @@ public sealed class NestMutationTests // Recovery is explicit and bounded, never ordinary search. Assert.Equal(saved.Id, Assert.Single((await repository.QueryDeletedAsync(new NestQuery { Limit = 1 })).Items).Id); Assert.True((await repository.GetDeletedMetadataAsync(saved.Id))!.Deleted); - Assert.Equal(new byte[] { 0, 255 }, await repository.GetDeletedFileAsync(saved.Id)); + Assert.Equal(TestNests.Archive(0, 255), await repository.GetDeletedFileAsync(saved.Id)); var history = await repository.GetDeletedRevisionsAsync(saved.Id); Assert.Equal(new[] { true, false }, history.Items.Select(r => r.Deleted)); foreach (var item in history.Items) - Assert.Equal(new byte[] { 0, 255 }, await repository.GetDeletedRevisionFileAsync(saved.Id, item.Revision)); + Assert.Equal(TestNests.Archive(0, 255), await repository.GetDeletedRevisionFileAsync(saved.Id, item.Revision)); var recovered = await repository.RecoverAsync(saved.Id, 2); Assert.False(recovered.Deleted); Assert.Empty((await repository.QueryDeletedAsync(new NestQuery())).Items); Assert.Equal(3, (await repository.GetMetadataAsync(saved.Id))!.Revision); - Assert.Equal(new byte[] { 0, 255 }, await repository.GetFileAsync(saved.Id)); + Assert.Equal(TestNests.Archive(0, 255), await repository.GetFileAsync(saved.Id)); Assert.Equal(3, (await repository.GetRevisionsAsync(saved.Id)).Total); } @@ -110,7 +110,7 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + var saved = await repository.UploadAsync(TestNests.Archive(1), new NestRecord { Name = "Fixture" }); if (recover) await repository.DeleteAsync(saved.Id, 1); using var request = new HttpRequestMessage(recover ? HttpMethod.Post : HttpMethod.Delete, recover ? $"/api/nests/deleted/{saved.Id}/recover" : $"/api/nests/{saved.Id}"); @@ -119,7 +119,7 @@ public sealed class NestMutationTests Assert.Equal(status, (int)response.StatusCode); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal(recover ? 2 : 1, reopened.History(saved.Id, 0, 100, recover)!.Total); - Assert.Equal(new byte[] { 1 }, reopened.GetFile(saved.Id, recover)); + Assert.Equal(TestNests.Archive(1), reopened.GetFile(saved.Id, recover)); Assert.Equal(recover, reopened.Get(saved.Id, recover)!.Deleted); } @@ -131,14 +131,14 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + var saved = await repository.UploadAsync(TestNests.Archive(1), new NestRecord { Name = "Fixture" }); if (recover) await repository.DeleteAsync(saved.Id, 1); FailSnapshots(factory.DatabasePath); Assert.Contains("500", (await Assert.ThrowsAsync(() => recover ? repository.RecoverAsync(saved.Id, 2) : repository.DeleteAsync(saved.Id, 1))).Message); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal(recover ? 2 : 1, reopened.History(saved.Id, 0, 100, recover)!.Total); - Assert.Equal(new byte[] { 1 }, reopened.GetFile(saved.Id, recover)); + Assert.Equal(TestNests.Archive(1), reopened.GetFile(saved.Id, recover)); } [Fact] @@ -147,8 +147,8 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var first = await repository.UploadAsync([0, 255], new NestRecord { Name = "Original", Comments = "Before" }); - var second = await repository.UpdateFileAsync(first.Id, [3, 4], new NestRecord { Name = "Changed" }, 1); + var first = await repository.UploadAsync(TestNests.Archive(0, 255), new NestRecord { Name = "Original", Comments = "Before" }); + var second = await repository.UpdateFileAsync(first.Id, TestNests.Archive(3, 4), new NestRecord { Name = "Changed" }, 1); var before = await repository.GetRevisionsAsync(first.Id); var restored = await repository.RestoreAsync(first.Id, 1, 2); Assert.Equal(1, restored.RestoredFromRevision); @@ -157,14 +157,14 @@ public sealed class NestMutationTests Assert.Equal(first.Id, current.Id); Assert.Equal("Original", current.Name); Assert.Equal("Before", current.Comments); - Assert.Equal(new byte[] { 0, 255 }, await repository.GetFileAsync(first.Id)); - Assert.Equal(new byte[] { 3, 4 }, await repository.GetRevisionFileAsync(first.Id, 2)); + Assert.Equal(TestNests.Archive(0, 255), await repository.GetFileAsync(first.Id)); + Assert.Equal(TestNests.Archive(3, 4), await repository.GetRevisionFileAsync(first.Id, 2)); var after = await repository.GetRevisionsAsync(first.Id); Assert.Equal(3, after.Total); Assert.Equal(System.Text.Json.JsonSerializer.Serialize(before.Items), System.Text.Json.JsonSerializer.Serialize(after.Items.Skip(1))); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal(3, reopened.History(first.Id, 0, 100)!.Total); - Assert.Equal(new byte[] { 0, 255 }, reopened.GetFile(first.Id)); + Assert.Equal(TestNests.Archive(0, 255), reopened.GetFile(first.Id)); } [Theory] @@ -178,14 +178,14 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + var saved = await repository.UploadAsync(TestNests.Archive(1), new NestRecord { Name = "Fixture" }); using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/nests/{saved.Id}/revisions/{source}/restore"); if (header is not null) request.Headers.TryAddWithoutValidation("If-Match", header); using var response = await client.SendAsync(request); Assert.Equal(status, (int)response.StatusCode); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal(1, reopened.History(saved.Id, 0, 100)!.Total); - Assert.Equal(new byte[] { 1 }, reopened.GetFile(saved.Id)); + Assert.Equal(TestNests.Archive(1), reopened.GetFile(saved.Id)); Assert.Null(reopened.Get(saved.Id)!.RestoredFromRevision); } @@ -195,7 +195,7 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + var saved = await repository.UploadAsync(TestNests.Archive(1), new NestRecord { Name = "Fixture" }); var restored = await repository.RestoreAsync(saved.Id, 1, 1); Assert.Equal(2, restored.Revision); Assert.Equal(1, restored.RestoredFromRevision); @@ -203,7 +203,7 @@ public sealed class NestMutationTests Assert.Contains("500", (await Assert.ThrowsAsync(() => repository.RestoreAsync(saved.Id, 1, 2))).Message); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal(2, reopened.History(saved.Id, 0, 100)!.Total); - Assert.Equal(new byte[] { 1 }, reopened.GetFile(saved.Id)); + Assert.Equal(TestNests.Archive(1), reopened.GetFile(saved.Id)); Assert.Equal(1, reopened.Get(saved.Id)!.RestoredFromRevision); } @@ -217,7 +217,7 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + var saved = await repository.UploadAsync(TestNests.Archive(1), new NestRecord { Name = "Fixture" }); if (operation != "recover") await repository.DeleteAsync(saved.Id, 1); foreach (var id in new[] { saved.Id, Guid.NewGuid() }) { @@ -249,7 +249,7 @@ public sealed class NestMutationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); - var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + var saved = await repository.UploadAsync(TestNests.Archive(1), new NestRecord { Name = "Fixture" }); var expected = 1; if (operation == "recover") { await repository.DeleteAsync(saved.Id, 1); expected = 2; } var attempts = Enumerable.Range(0, 2).Select(async i => @@ -273,7 +273,7 @@ public sealed class NestMutationTests Assert.Single(statuses.Where(s => s == (operation is "delete" or "recover" ? HttpStatusCode.NotFound : HttpStatusCode.Conflict))); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal(expected + 1, reopened.History(saved.Id, 0, 100, operation == "delete")!.Total); - Assert.Equal(new byte[] { 1 }, reopened.GetRevisionFile(saved.Id, 1, operation == "delete")); + Assert.Equal(TestNests.Archive(1), reopened.GetRevisionFile(saved.Id, 1, operation == "delete")); } private static void FailSnapshots(string path) diff --git a/OpenNest.Server.Tests/NestRevisionTests.cs b/OpenNest.Server.Tests/NestRevisionTests.cs index 0c8aecd1..efd65876 100644 --- a/OpenNest.Server.Tests/NestRevisionTests.cs +++ b/OpenNest.Server.Tests/NestRevisionTests.cs @@ -36,20 +36,20 @@ public sealed class NestRevisionTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); var record = new NestRecord { Name = "Initial" }; - using var created = await client.PostAsync("/api/nests", Body(record, [1, 2])); + using var created = await client.PostAsync("/api/nests", Body(record, TestNests.Archive(1, 2))); var current = (await created.Content.ReadFromJsonAsync(Options))!; Assert.Equal(1, current.Revision); record.Name = "Changed"; client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\""); - using var updated = await client.PutAsync($"/api/nests/{current.Id}/file", Body(record, [3, 4])); + using var updated = await client.PutAsync($"/api/nests/{current.Id}/file", Body(record, TestNests.Archive(3, 4))); Assert.Equal(HttpStatusCode.OK, updated.StatusCode); var latest = (await updated.Content.ReadFromJsonAsync(Options))!; Assert.Equal(2, latest.Revision); - Assert.Equal(new byte[] { 1, 2 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/revisions/1/file")); - Assert.Equal(new byte[] { 3, 4 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/file")); + Assert.Equal(TestNests.Archive(1, 2), await client.GetByteArrayAsync($"/api/nests/{current.Id}/revisions/1/file")); + Assert.Equal(TestNests.Archive(3, 4), await client.GetByteArrayAsync($"/api/nests/{current.Id}/file")); client.DefaultRequestHeaders.Remove("If-Match"); client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"2\""); - using var retry = await client.PutAsync($"/api/nests/{current.Id}/file", Body(record, [3, 4])); + using var retry = await client.PutAsync($"/api/nests/{current.Id}/file", Body(record, TestNests.Archive(3, 4))); Assert.Equal(latest.SavedAt, (await retry.Content.ReadFromJsonAsync(Options))!.SavedAt); var page = await client.GetFromJsonAsync($"/api/nests/{current.Id}/revisions?limit=1", Options); Assert.Equal(2, page!.Total); @@ -67,12 +67,12 @@ public sealed class NestRevisionTests Guid id; using (var client = factory.CreateClient()) { - using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1])); + using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, TestNests.Archive(1))); id = (await created.Content.ReadFromJsonAsync(Options))!.Id; if (header is not null) client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", header); - using var response = await client.PutAsync($"/api/nests/{id}/file", Body(new NestRecord { Name = "Rejected" }, [2])); + using var response = await client.PutAsync($"/api/nests/{id}/file", Body(new NestRecord { Name = "Rejected" }, TestNests.Archive(2))); Assert.Equal(status, (int)response.StatusCode); - Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{id}/file")); + Assert.Equal(TestNests.Archive(1), await client.GetByteArrayAsync($"/api/nests/{id}/file")); } using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal("Initial", reopened.Get(id)!.Name); @@ -84,7 +84,7 @@ public sealed class NestRevisionTests { using var factory = new ServerFactory(); using var client = factory.CreateClient(); - using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1])); + using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, TestNests.Archive(1))); var current = (await created.Content.ReadFromJsonAsync(Options))!; using (var connection = new SqliteConnection($"Data Source={factory.DatabasePath};Pooling=False")) { @@ -94,9 +94,9 @@ public sealed class NestRevisionTests command.ExecuteNonQuery(); } client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\""); - using var failed = await client.PutAsync($"/api/nests/{current.Id}/file", Body(new NestRecord { Name = "Rejected" }, [2])); + using var failed = await client.PutAsync($"/api/nests/{current.Id}/file", Body(new NestRecord { Name = "Rejected" }, TestNests.Archive(2))); Assert.Equal(HttpStatusCode.InternalServerError, failed.StatusCode); - Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/file")); + Assert.Equal(TestNests.Archive(1), await client.GetByteArrayAsync($"/api/nests/{current.Id}/file")); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal("Initial", reopened.Get(current.Id)!.Name); Assert.Equal(1, reopened.History(current.Id, 0, 100)!.Total); @@ -106,7 +106,7 @@ public sealed class NestRevisionTests { using var factory = new ServerFactory(); var ids = new[] { Guid.NewGuid(), Guid.NewGuid() }; - var bytes = new[] { new byte[] { 0, 1, 255 }, new byte[] { 7, 8, 9, 0 } }; + var bytes = new[] { TestNests.Archive(0, 1, 255), TestNests.Archive(7, 8, 9, 0) }; var timestamp = new DateTime(2026, 1, 2, 3, 4, 5); using (var connection = new SqliteConnection($"Data Source={factory.DatabasePath};Pooling=False")) { @@ -166,11 +166,11 @@ public sealed class NestRevisionTests { using var factory = new ServerFactory(); using var client = factory.CreateClient(); - using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1])); + using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, TestNests.Archive(1))); var current = (await created.Content.ReadFromJsonAsync(Options))!; client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\""); var responses = await Task.WhenAll(Enumerable.Range(2, 2).Select(i => - client.PutAsync($"/api/nests/{current.Id}/file", Body(new NestRecord { Name = $"Writer {i}" }, [(byte)i])))); + client.PutAsync($"/api/nests/{current.Id}/file", Body(new NestRecord { Name = $"Writer {i}" }, TestNests.Archive((byte)i))))); try { Assert.Single(responses.Where(r => r.StatusCode == HttpStatusCode.OK)); @@ -178,7 +178,7 @@ public sealed class NestRevisionTests Assert.Equal(2, (await conflict.Content.ReadFromJsonAsync(Options))!.Revision); var history = await client.GetFromJsonAsync($"/api/nests/{current.Id}/revisions", Options); Assert.Equal(new[] { 2, 1 }, history!.Items.Select(r => r.Revision)); - Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/revisions/1/file")); + Assert.Equal(TestNests.Archive(1), await client.GetByteArrayAsync($"/api/nests/{current.Id}/revisions/1/file")); } finally { foreach (var response in responses) response.Dispose(); } } @@ -192,19 +192,19 @@ public sealed class NestRevisionTests { using var factory = new ServerFactory(); using var client = factory.CreateClient(); - using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1])); + using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, TestNests.Archive(1))); var current = (await created.Content.ReadFromJsonAsync(Options))!; if (header is not null) client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", header); using var response = route == "delete" ? await client.DeleteAsync($"/api/nests/{current.Id}") : await client.PutAsync($"/api/nests/{current.Id}/{route}", new StringContent("Malformed")); Assert.Equal(status, (int)response.StatusCode); - Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/file")); + Assert.Equal(TestNests.Archive(1), await client.GetByteArrayAsync($"/api/nests/{current.Id}/file")); var history = await client.GetFromJsonAsync($"/api/nests/{current.Id}/revisions", Options); Assert.Equal(1, history!.Total); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal("Initial", reopened.Get(current.Id)!.Name); - Assert.Equal(new byte[] { 1 }, reopened.GetRevisionFile(current.Id, 1)); + Assert.Equal(TestNests.Archive(1), reopened.GetRevisionFile(current.Id, 1)); } [Fact] @@ -214,7 +214,7 @@ public sealed class NestRevisionTests using var client = factory.CreateClient(); using var unknown = await client.GetAsync($"/api/nests/{Guid.NewGuid()}/revisions"); Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode); - using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1])); + using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, TestNests.Archive(1))); var current = (await created.Content.ReadFromJsonAsync(Options))!; using var missing = await client.GetAsync($"/api/nests/{current.Id}/revisions/2/file"); Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); diff --git a/OpenNest.Server.Tests/NestUploadValidationTests.cs b/OpenNest.Server.Tests/NestUploadValidationTests.cs index 3667680a..e43b2fb3 100644 --- a/OpenNest.Server.Tests/NestUploadValidationTests.cs +++ b/OpenNest.Server.Tests/NestUploadValidationTests.cs @@ -21,16 +21,16 @@ public sealed class NestUploadValidationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); var id = Guid.NewGuid(); - using var created = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Id = id, Name = "Original" }, [1])); + using var created = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Id = id, Name = "Original" }, TestNests.Archive(1))); Assert.Equal(HttpStatusCode.OK, created.StatusCode); - using var duplicate = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Id = id, Name = "Replacement" }, [2])); + using var duplicate = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Id = id, Name = "Replacement" }, TestNests.Archive(2))); Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode); var current = (await duplicate.Content.ReadFromJsonAsync(Options))!; Assert.Equal(id, current.Id); Assert.Equal("Original", current.Name); - Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{id}/file")); + Assert.Equal(TestNests.Archive(1), await client.GetByteArrayAsync($"/api/nests/{id}/file")); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal(1, reopened.History(id, 0, 100)!.Total); } @@ -41,20 +41,20 @@ public sealed class NestUploadValidationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); var id = Guid.NewGuid(); - using var created = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Id = id, Name = "Original" }, [1])); + using var created = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Id = id, Name = "Original" }, TestNests.Archive(1))); using var delete = new HttpRequestMessage(HttpMethod.Delete, $"/api/nests/{id}"); delete.Headers.TryAddWithoutValidation("If-Match", "\"1\""); using var deleted = await client.SendAsync(delete); Assert.Equal(HttpStatusCode.OK, deleted.StatusCode); - using var duplicate = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Id = id, Name = "Replacement" }, [2])); + using var duplicate = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Id = id, Name = "Replacement" }, TestNests.Archive(2))); Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode); var current = (await duplicate.Content.ReadFromJsonAsync(Options))!; Assert.True(current.Deleted); Assert.Equal(2, current.Revision); Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/nests/{id}")).StatusCode); - Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/deleted/{id}/file")); + Assert.Equal(TestNests.Archive(1), await client.GetByteArrayAsync($"/api/nests/deleted/{id}/file")); } [Theory] @@ -71,7 +71,7 @@ public sealed class NestUploadValidationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); - using var response = await client.PostAsync("/api/nests", RawBody(metadata, [1])); + using var response = await client.PostAsync("/api/nests", RawBody(metadata, TestNests.Archive(1))); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); @@ -88,7 +88,7 @@ public sealed class NestUploadValidationTests using var factory = new ServerFactory(); using var client = factory.CreateClient(); - using var response = await client.PostAsync("/api/nests", RawBody(metadata, [1])); + using var response = await client.PostAsync("/api/nests", RawBody(metadata, TestNests.Archive(1))); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(expected, (await response.Content.ReadFromJsonAsync(Options))!.Status); @@ -104,7 +104,7 @@ public sealed class NestUploadValidationTests var id = await Create(client); client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\""); - using var response = await client.PutAsync($"/api/nests/{id}/file", RawBody(metadata, [2])); + using var response = await client.PutAsync($"/api/nests/{id}/file", RawBody(metadata, TestNests.Archive(2))); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); await AssertUnchanged(factory, client, id); @@ -130,14 +130,14 @@ public sealed class NestUploadValidationTests private static async Task Create(HttpClient client) { - using var created = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Name = "Original" }, [1])); + using var created = await client.PostAsync("/api/nests", NestRevisionTests.Body(new NestRecord { Name = "Original" }, TestNests.Archive(1))); Assert.Equal(HttpStatusCode.OK, created.StatusCode); return (await created.Content.ReadFromJsonAsync(Options))!.Id; } private static async Task AssertUnchanged(ServerFactory factory, HttpClient client, Guid id) { - Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{id}/file")); + Assert.Equal(TestNests.Archive(1), await client.GetByteArrayAsync($"/api/nests/{id}/file")); using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); var current = reopened.Get(id)!; Assert.Equal("Original", current.Name); diff --git a/OpenNest.Server.Tests/TestNests.cs b/OpenNest.Server.Tests/TestNests.cs new file mode 100644 index 00000000..128f8be0 --- /dev/null +++ b/OpenNest.Server.Tests/TestNests.cs @@ -0,0 +1,32 @@ +using System.IO.Compression; +using System.Text; + +namespace OpenNest.Server.Tests; + +/// +/// Smallest archives the server accepts as nests. The tag names the nest, so different tags +/// give different bytes, and fixed entry times make the same tag give identical bytes; tests +/// compare stored archives exactly, as they did with arbitrary byte payloads. +/// +internal static class TestNests +{ + private static readonly DateTimeOffset EntryTime = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public static byte[] Archive(params byte[] tag) => + Zip("nest.json", $$"""{"version":2,"name":"tag {{Convert.ToHexString(tag)}}","drawings":[],"plates":[]}"""); + + /// A valid ZIP holding ; not necessarily a nest. + public static byte[] Zip(string name, string content) + { + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = archive.CreateEntry(name); + entry.LastWriteTime = EntryTime; + using var writer = new StreamWriter(entry.Open(), Encoding.UTF8); + writer.Write(content); + } + + return stream.ToArray(); + } +} diff --git a/OpenNest.Server/NestArchive.cs b/OpenNest.Server/NestArchive.cs new file mode 100644 index 00000000..71eabee3 --- /dev/null +++ b/OpenNest.Server/NestArchive.cs @@ -0,0 +1,126 @@ +using System.IO.Compression; +using System.Text.Json; +using OpenNest.Data; + +namespace OpenNest.Server; + +/// +/// Reads the facts a nest record states about its archive straight from the uploaded +/// .nest, so the stored record always describes the stored bytes. Only +/// nest.json is parsed: programs and geometry are never rebuilt, which keeps the +/// check cheap and keeps the server off OpenNest.IO. The fallbacks mirror +/// NestReader; NestArchiveTests pins them against real NestWriter output. +/// +internal static class NestArchive +{ + /// Largest nest.json the server will decompress. + public const int MaxNestJsonBytes = 64 * 1024 * 1024; + + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); + + /// + /// Overwrites the record's plate count, part count, material and thickness with the + /// archive's own. Returns an error when the bytes are not a readable nest archive. + /// + public static string? Apply(NestRecord record, byte[] file) + { + NestJson? nest; + try + { + using var archive = new ZipArchive(new MemoryStream(file, writable: false), ZipArchiveMode.Read); + var entry = archive.GetEntry("nest.json"); + if (entry is null) + return "The file is not a nest archive: it has no nest.json."; + if (entry.Length > MaxNestJsonBytes) + return "The nest archive's nest.json is too large."; + using var json = new MemoryStream(); + using (var stream = entry.Open()) + CopyBounded(stream, json); + json.Position = 0; + nest = JsonSerializer.Deserialize(json, Options); + } + catch (Exception ex) when (ex is InvalidDataException or JsonException or IOException) + { + return "The file is not a readable nest archive."; + } + + if (nest is null) + return "The nest archive's nest.json is empty."; + + var defaults = nest.PlateDefaults ?? new PlateDefaultsJson(); + // NestReader: an absent nest-level material is an empty one; only an explicit null + // falls back to the plate defaults. Thickness falls back when not positive. + record.Material = (nest.Material ?? defaults.Material)?.Name ?? ""; + record.Thickness = nest.Thickness > 0 ? nest.Thickness : defaults.Thickness; + + // NestReader drops parts whose drawing is missing. Cut-off parts are never written as + // parts, so every remaining part counts. + var drawingIds = (nest.Drawings ?? new()).Select(d => d.Id).ToHashSet(); + var plates = (nest.Plates ?? new()).OrderBy(p => p.Id).ToList(); + record.PartCount = plates.Sum(p => (p.Parts ?? new()).Count(part => drawingIds.Contains(part.DrawingId))); + // PlateDisplayNumbering.DisplayedPlateCount: a trailing plate with nothing on it is the + // editor's new-plate slot, not a plate of the nest. + var last = plates.LastOrDefault(); + var trailingEmpty = last is not null + && !(last.Parts ?? new()).Any(part => drawingIds.Contains(part.DrawingId)) + && (last.CutOffs ?? new()).Count == 0; + record.PlateCount = plates.Count - (trailingEmpty ? 1 : 0); + return null; + } + + // The declared entry size is untrusted; stop at the cap however much the data inflates. + private static void CopyBounded(Stream source, Stream destination) + { + var buffer = new byte[81920]; + long total = 0; + int read; + while ((read = source.Read(buffer, 0, buffer.Length)) > 0) + { + total += read; + if (total > MaxNestJsonBytes) + throw new InvalidDataException("nest.json exceeds the size limit."); + destination.Write(buffer, 0, read); + } + } + + private sealed class NestJson + { + public double Thickness { get; set; } + public MaterialJson? Material { get; set; } = new(); + public PlateDefaultsJson? PlateDefaults { get; set; } + public List? Drawings { get; set; } + public List? Plates { get; set; } + } + + private sealed class PlateDefaultsJson + { + public double Thickness { get; set; } + public MaterialJson? Material { get; set; } = new(); + } + + private sealed class MaterialJson + { + public string? Name { get; set; } = ""; + } + + private sealed class DrawingJson + { + public int Id { get; set; } + } + + private sealed class PlateJson + { + public int Id { get; set; } + public List? Parts { get; set; } + public List? CutOffs { get; set; } + } + + private sealed class PartJson + { + public int DrawingId { get; set; } + } + + private sealed class CutOffJson + { + } +} diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index c8af6537..5cada1d5 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -179,6 +179,12 @@ public sealed partial class NestDatabase : IDisposable var current = Get(id); if (current is null) return null; if (current.Revision != expectedRevision) throw new RevisionConflictException(current); + if (file is null) + { + // The counts describe the archive, which a metadata-only save leaves unchanged. + record.PlateCount = current.PlateCount; + record.PartCount = current.PartCount; + } file ??= GetFile(id)!; if (SameMetadata(current, record) && GetFile(id)!.AsSpan().SequenceEqual(file)) return current; diff --git a/OpenNest.Server/Program.cs b/OpenNest.Server/Program.cs index 5aeb2292..8c57959b 100644 --- a/OpenNest.Server/Program.cs +++ b/OpenNest.Server/Program.cs @@ -203,7 +203,12 @@ static async Task<(NestRecord? Record, byte[]? File, IResult? Error)> ReadMultip using var stream = filePart.OpenReadStream(); using var buffer = new MemoryStream((int)filePart.Length); await stream.CopyToAsync(buffer); - return (record, buffer.ToArray(), null); + var file = buffer.ToArray(); + + // Counts, material and thickness come from the archive itself, not the client's claim. + if (NestArchive.Apply(record, file) is { } archiveError) + return (null, null, Results.BadRequest(archiveError)); + return (record, file, null); } // Entry-point type for in-memory integration tests (WebApplicationFactory). diff --git a/docs/nest-storage.md b/docs/nest-storage.md index 98fbeee5..2cbe7204 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -70,6 +70,9 @@ carry string text fields (`null` is 400) and a defined `status`: a name, or for existing callers its integer value (0 quote, 1 toBeCut, 2 hasBeenCut, 3 draft); an undefined value is 400 and stores nothing. A server older than `draft` refuses it, so upgrade the server before desktops that can set it. +An uploaded `file` (POST and PUT `/file`) must be a `.nest` archive with a readable +`nest.json`, or the request is 400 and stores nothing. The server reads only that +entry, never programs or geometry, and takes the archive-derived fields below from it. | Field | Type | Notes | |---|---|---| @@ -77,11 +80,11 @@ so upgrade the server before desktops that can set it. | `name` | string | | | `customer` | string | | | `dateCreated` / `dateModified` | datetime | Set by the client from the nest's own metadata. | -| `material` | string | Material name (type), e.g. `Stainless`. | +| `material` | string | Material name (type), e.g. `Stainless`. Read from the uploaded archive (nest material, else plate defaults' on an explicit null); a metadata-only PUT may change it, and the desktop applies the record's value when opening the nest. | | `materialGrade` | string | Optional; default blank. Older servers drop it. A blank grade on a record saved before schema v8 is treated as unrecorded: opening keeps the archive's grade while the material name matches. | -| `thickness` | number | | +| `thickness` | number | Read from the uploaded archive (nest thickness, else plate defaults' when not positive); editable like `material`. | | `status` | `"draft"` \| `"quote"` \| `"toBeCut"` \| `"hasBeenCut"` | Default `quote`. | -| `plateCount` / `partCount` | integer | Computed client-side at save time (non-cutoff parts only). | +| `plateCount` / `partCount` | integer | Server-computed from the uploaded archive (non-cutoff parts only; a trailing plate with no parts or cut-offs is not counted); ignored on upload and kept by a metadata-only PUT. | | `comments` | string | | | `madeBy` | string | | | `fileSize` | integer | Server-computed; ignored on upload. | diff --git a/scripts/Server.Tests/Program.cs b/scripts/Server.Tests/Program.cs index 34cf8fe2..fd3a70d1 100644 --- a/scripts/Server.Tests/Program.cs +++ b/scripts/Server.Tests/Program.cs @@ -191,14 +191,14 @@ internal static class Program var before = await Snapshot(repository, ct); Check(before.Count > 0, "Limit checks require seeded records on an owned server."); - // Opaque synthetic bytes (the server does not parse archives) above the 64 KiB form - // memory threshold, so the non-root runtime must buffer the part to its temp directory. - var accepted = new byte[29_000_000]; - RandomNumberGenerator.Fill(accepted); + // A readable nest archive (the server parses its nest.json) padded with incompressible + // bytes above the 64 KiB form memory threshold, so the non-root runtime must buffer the + // part to its temp directory. + var accepted = PaddedArchive(29_000_000); var created = await repository.UploadAsync(accepted, new NestRecord { Name = "Synthetic upload-limit probe", - Comments = "Random bytes; deleted by the same smoke stage", + Comments = "Random padding; deleted by the same smoke stage", }, ct); Check(created.Id != Guid.Empty && created.FileSize == accepted.LongLength, "A near-limit upload must be stored with its exact size."); @@ -363,6 +363,23 @@ internal static class Program return stream.ToArray(); } + /// A minimal nest archive with a stored random entry, close to bytes. + private static byte[] PaddedArchive(int size) + { + using var stream = new MemoryStream(); + using (var zip = new System.IO.Compression.ZipArchive(stream, System.IO.Compression.ZipArchiveMode.Create, leaveOpen: true)) + { + using (var writer = new StreamWriter(zip.CreateEntry("nest.json").Open())) + writer.Write("""{"version":2,"name":"Synthetic upload-limit probe","drawings":[],"plates":[]}"""); + var padding = new byte[size - 1024]; + RandomNumberGenerator.Fill(padding); + using var entry = zip.CreateEntry("padding.bin", System.IO.Compression.CompressionLevel.NoCompression).Open(); + entry.Write(padding); + } + + return stream.ToArray(); + } + private static GeometryState Geometry(Nest nest) => new() { PlateWidth = nest.Plates[0].Size.Width,