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