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,