using System.Net; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.DependencyInjection; using OpenNest.Data; using OpenNest.Geometry; using OpenNest.IO; using CncProgram = OpenNest.CNC.Program; namespace OpenNest.Server.Tests; /// /// Production HTTP routes + real SQLite, driven through the desktop app's own /// and . /// Every test owns a fresh server and database. /// public sealed class NestApiTests { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, }; [Fact] public async Task Host_UsesPrivateDatabase_NotDefaultPath() { var defaultPath = Path.GetFullPath(Path.Combine("data", "nests.db")); Assert.False(File.Exists(defaultPath), "Precondition: no default database in the test directory."); using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var response = await client.GetAsync("/healthz"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.True(File.Exists(factory.DatabasePath)); Assert.False(File.Exists(defaultPath)); } [Fact] public async Task Health_WithOpenDatabase_ReturnsExistingSuccessShape() { using var factory = new ServerFactory(); using var client = factory.CreateClient(); using var response = await client.GetAsync("/healthz"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("{\"status\":\"ok\"}", await response.Content.ReadAsStringAsync()); } [Fact] public async Task Health_WithDisposedDatabase_Returns503WithoutStorageDetails() { using var factory = new ServerFactory(); using var client = factory.CreateClient(); factory.Services.GetRequiredService().Dispose(); using var response = await client.GetAsync("/healthz"); Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); Assert.Equal("{\"status\":\"unavailable\"}", await response.Content.ReadAsStringAsync()); } [Fact] public async Task Create_ThenListAndDownload_ReturnsExactRecordAndArchive() { using var server = new TestServerSession(); var nest = SyntheticNest(); var archive = Archive(nest); var session = new NestSaveSession(); var created = await session.SaveAsync(server.Repository, server.Url, nest, archive); Assert.NotEqual(Guid.Empty, created.Id); Assert.NotEqual(default, created.SavedAt); Assert.Equal(archive.LongLength, created.FileSize); AssertSameMetadata(Expected(nest, created, archive), created); var listed = Assert.Single(await server.Repository.ListAsync()); AssertSameMetadata(created, listed); AssertSameMetadata(created, (await server.Repository.GetMetadataAsync(created.Id))!); var downloaded = await server.Repository.GetFileAsync(created.Id); Assert.Equal(archive, downloaded); AssertArchive(downloaded!, nest); } [Fact] public async Task Save_AfterCreate_UpdatesSameRecordArchiveAndMetadata() { using var server = new TestServerSession(); var nest = SyntheticNest(); var session = new NestSaveSession(); var created = await session.SaveAsync(server.Repository, server.Url, nest, Archive(nest)); nest.Name = "Synthetic integration nest updated"; nest.Status = NestStatus.ToBeCut; nest.Plates[0].Parts[0].Location = new Vector(4, 5); var archive = Archive(nest); server.Client.DefaultRequestHeaders.IfMatch.Add(new EntityTagHeaderValue("\"1\"")); var updated = await session.SaveAsync(server.Repository, server.Url, nest, archive); Assert.Equal(created.Id, updated.Id); AssertSameMetadata(Expected(nest, updated, archive), updated); var listed = Assert.Single(await server.Repository.ListAsync()); AssertSameMetadata(updated, listed); var downloaded = await server.Repository.GetFileAsync(created.Id); Assert.Equal(archive, downloaded); AssertArchive(downloaded!, nest); } [Fact] public async Task SaveCopy_CreatesDistinctRecordAndLeavesOriginal() { using var server = new TestServerSession(); var nest = SyntheticNest(); var session = new NestSaveSession(); var originalArchive = Archive(nest); var original = await session.SaveAsync(server.Repository, server.Url, nest, originalArchive); nest.Name = "Synthetic integration nest copy"; var copyArchive = Archive(nest); var copy = await session.SaveAsync(server.Repository, server.Url, nest, copyArchive, saveCopy: true); Assert.NotEqual(original.Id, copy.Id); Assert.Equal(copy.Id, session.RemoteId); var ids = (await server.Repository.ListAsync()).Select(r => r.Id).Order().ToArray(); Assert.Equal(new[] { original.Id, copy.Id }.Order().ToArray(), ids); AssertSameMetadata(original, (await server.Repository.GetMetadataAsync(original.Id))!); Assert.Equal(originalArchive, await server.Repository.GetFileAsync(original.Id)); Assert.Equal(copyArchive, await server.Repository.GetFileAsync(copy.Id)); } [Fact] public async Task MetadataAndDelete_AreBlockedWithoutMutation() { using var server = new TestServerSession(); var created = await server.SeedAsync(); var before = await server.SnapshotAsync(); server.Client.DefaultRequestHeaders.IfMatch.Add(new EntityTagHeaderValue("\"1\"")); using var metadata = await server.Client.PutAsync($"/api/nests/{created.Id}/metadata", new StringContent("{}")); Assert.Equal(HttpStatusCode.BadRequest, metadata.StatusCode); server.Client.DefaultRequestHeaders.IfMatch.Clear(); using var delete = await server.Client.DeleteAsync($"/api/nests/{created.Id}"); Assert.Equal((HttpStatusCode)428, delete.StatusCode); Assert.Equal(before, await server.SnapshotAsync()); } public static TheoryData InvalidUploads() { var data = new TheoryData(); foreach (var route in new[] { "create", "update" }) { foreach (var body in new[] { "not multipart", "malformed multipart", "missing boundary", "missing metadata", "invalid metadata JSON", "null metadata", "missing file", "empty file", }) { data.Add(route, body); } } return data; } [Theory] [MemberData(nameof(InvalidUploads))] public async Task Upload_WithMissingOrMalformedParts_Returns400AndLeavesStoreUnchanged(string route, string body) { using var server = new TestServerSession(); var existing = await server.SeedAsync(); var before = await server.SnapshotAsync(); var method = route == "create" ? HttpMethod.Post : HttpMethod.Put; var path = route == "create" ? "/api/nests" : $"/api/nests/{existing.Id}/file"; using var request = new HttpRequestMessage(method, path) { Content = InvalidUploadContent(body) }; request.Headers.IfMatch.Add(new EntityTagHeaderValue("\"1\"")); using var response = await server.Client.SendAsync(request); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal(before, await server.SnapshotAsync()); } [Theory] [InlineData("{not-json")] [InlineData("null")] public async Task MetadataUpdate_WithoutPrecondition_Returns428AndLeavesStoreUnchanged(string json) { using var server = new TestServerSession(); var existing = await server.SeedAsync(); var before = await server.SnapshotAsync(); using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var response = await server.Client.PutAsync($"/api/nests/{existing.Id}/metadata", content); Assert.Equal((HttpStatusCode)428, response.StatusCode); Assert.Equal(before, await server.SnapshotAsync()); } [Fact] public async Task UnknownId_Returns404OnEveryRouteWithoutCreatingRecords() { using var server = new TestServerSession(); var existing = await server.SeedAsync(); var before = await server.SnapshotAsync(); var unknown = Guid.NewGuid(); var record = Clone(existing); record.Id = unknown; var requests = new Func[] { () => new HttpRequestMessage(HttpMethod.Get, $"/api/nests/{unknown}"), () => 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 }), }, () => new HttpRequestMessage(HttpMethod.Put, $"/api/nests/{unknown}/metadata") { Content = new StringContent(JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json"), }, () => new HttpRequestMessage(HttpMethod.Delete, $"/api/nests/{unknown}"), }; foreach (var create in requests) { using var request = create(); request.Headers.IfMatch.Add(new EntityTagHeaderValue("\"1\"")); using var response = await server.Client.SendAsync(request); Assert.True(response.StatusCode == HttpStatusCode.NotFound, $"{request.Method} {request.RequestUri} returned {(int)response.StatusCode}, expected 404."); } Assert.Equal(before, await server.SnapshotAsync()); } [Fact] public void Startup_WithUnusableDatabasePath_Fails() { var directory = Path.Combine(Path.GetTempPath(), "opennest-server-tests-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); try { // A regular file where the data directory should be: the database cannot be created. var blocker = Path.Combine(directory, "not-a-directory"); File.WriteAllText(blocker, ""); using var factory = new ServerFactory(Path.Combine(blocker, "nests.db")); var error = Record.Exception(() => factory.CreateClient()); Assert.NotNull(error); Assert.False(File.Exists(Path.Combine(blocker, "nests.db"))); } finally { Directory.Delete(directory, recursive: true); } } [Fact] public async Task Dispose_RemovesTemporaryDatabaseDirectory() { string directory; using (var server = new TestServerSession()) { await server.SeedAsync(); directory = server.Factory.DirectoryPath; Assert.True(File.Exists(server.Factory.DatabasePath)); } Assert.False(Directory.Exists(directory)); } private static HttpContent InvalidUploadContent(string body) { var metadata = JsonSerializer.Serialize(new NestRecord { Name = "Synthetic rejected upload" }, JsonOptions); switch (body) { case "not multipart": return new StringContent(metadata, Encoding.UTF8, "application/json"); case "malformed multipart": var content = new StringContent("--other-boundary\r\nnot a multipart body"); content.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/form-data; boundary=expected-boundary"); return content; case "missing boundary": var unbounded = Multipart(metadata, new byte[] { 1 }); unbounded.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/form-data"); return unbounded; case "missing metadata": return Multipart(null, new byte[] { 1 }); case "invalid metadata JSON": return Multipart("{not-json", new byte[] { 1 }); case "null metadata": return Multipart("null", new byte[] { 1 }); case "missing file": return Multipart(metadata, null); case "empty file": return Multipart(metadata, Array.Empty()); default: throw new ArgumentOutOfRangeException(nameof(body), body, null); } } private static MultipartFormDataContent Multipart(string? metadata, byte[]? file) { var content = new MultipartFormDataContent(); if (metadata is not null) content.Add(new StringContent(metadata, Encoding.UTF8, "application/json"), "metadata"); if (file is not null) content.Add(new ByteArrayContent(file), "file", "synthetic.nest"); return content; } private static NestRecord Expected(Nest nest, NestRecord saved, byte[] archive) { var expected = NestRecordFactory.FromNest(nest, saved.Id, archive.LongLength); expected.SavedAt = saved.SavedAt; expected.Revision = saved.Revision; return expected; } private static NestRecord Clone(NestRecord record) => JsonSerializer.Deserialize(JsonSerializer.Serialize(record, JsonOptions), JsonOptions)!; private static void AssertSameMetadata(NestRecord expected, NestRecord actual) => Assert.Equal(JsonSerializer.Serialize(expected, JsonOptions), JsonSerializer.Serialize(actual, JsonOptions)); private static void AssertArchive(byte[] archive, Nest expected) { using var stream = new MemoryStream(archive, writable: false); var reader = new NestReader(stream); var nest = reader.Read(); Assert.Empty(reader.Warnings); Assert.Equal(expected.Name, nest.Name); Assert.Equal(expected.Status, nest.Status); var part = Assert.Single(Assert.Single(nest.Plates).Parts); Assert.Equal(expected.Plates[0].Parts[0].Location.X, part.Location.X); Assert.Equal(expected.Plates[0].Parts[0].Location.Y, part.Location.Y); Assert.Equal(18, Assert.Single(nest.Drawings).Area); } internal static Nest SyntheticNest() { var program = new CncProgram(); program.MoveTo(0, 0); program.LineTo(6, 0); program.LineTo(6, 3); program.LineTo(0, 3); program.LineTo(0, 0); var drawing = new Drawing("Synthetic rectangle", program); drawing.Quantity.Required = 1; var nest = new Nest("Synthetic integration nest") { Customer = "Synthetic test customer", DateCreated = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Unspecified), Material = new Material("Synthetic alloy", "test-grade", 0.25), Thickness = 0.25, Status = NestStatus.Quote, Notes = "Neutral generated fixture; no customer data", MadeBy = "Synthetic test author", Units = Units.Inches, }; nest.Drawings.Add(drawing); var plate = new Plate(new Size(20, 30)) { Quantity = 1 }; plate.Parts.Add(new Part(drawing, new Vector(2, 3))); nest.Plates.Add(plate); return nest; } internal static byte[] Archive(Nest nest) { using var stream = new MemoryStream(); Assert.True(new NestWriter(nest).Write(stream)); return stream.ToArray(); } /// One in-memory server, its HTTP client, and the real storage client over it. private sealed class TestServerSession : IDisposable { public TestServerSession() { Factory = new ServerFactory(); Client = Factory.CreateClient(); Url = Client.BaseAddress!.ToString(); Repository = new RemoteNestRepository(Client, Url); } public ServerFactory Factory { get; } public HttpClient Client { get; } public string Url { get; } public RemoteNestRepository Repository { get; } public async Task SeedAsync() { var nest = SyntheticNest(); return await new NestSaveSession().SaveAsync(Repository, Url, nest, Archive(nest)); } /// Every record's metadata plus its archive hash, in id order. public async Task SnapshotAsync() { var lines = new List(); foreach (var record in (await Repository.ListAsync()).OrderBy(r => r.Id)) { var file = await Repository.GetFileAsync(record.Id); Assert.NotNull(file); lines.Add(JsonSerializer.Serialize(record, JsonOptions) + " " + Convert.ToHexString(SHA256.HashData(file!))); } return string.Join("\n", lines); } public void Dispose() { Repository.Dispose(); Client.Dispose(); Factory.Dispose(); } } }