diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b6567d..5066d29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,3 +30,5 @@ jobs: python3 scripts/check-synthetic-nests.py --parallel 2 - name: Run OpenNest.IO.Tests run: dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj + - name: Run OpenNest.Server.Tests + run: dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index 2f0b300..425bbf7 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -49,7 +49,7 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' } - name: Run all test projects on Windows run: | - foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) { + foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) { dotnet test "$project/$project.csproj" -c Release --no-build --logger "trx;LogFileName=$project.trx" --results-directory TestResults if ($LASTEXITCODE -ne 0) { throw "$project failed." } } diff --git a/OpenNest.Server.Tests/NestApiTests.cs b/OpenNest.Server.Tests/NestApiTests.cs new file mode 100644 index 0000000..e7787ed --- /dev/null +++ b/OpenNest.Server.Tests/NestApiTests.cs @@ -0,0 +1,410 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +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 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); + 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 MetadataUpdate_ChangesFieldsWithoutTouchingArchiveOrFileSize() + { + using var server = new TestServerSession(); + var nest = SyntheticNest(); + var archive = Archive(nest); + var created = await new NestSaveSession().SaveAsync(server.Repository, server.Url, nest, archive); + + var change = Clone(created); + change.Name = "Renamed by metadata update"; + change.Status = NestStatus.HasBeenCut; + change.Comments = "Metadata-only change"; + change.FileSize = 1; // Server-computed; must be ignored. + var updated = await server.Repository.UpdateMetadataAsync(created.Id, change); + + Assert.Equal(created.Id, updated.Id); + Assert.Equal("Renamed by metadata update", updated.Name); + Assert.Equal(NestStatus.HasBeenCut, updated.Status); + Assert.Equal("Metadata-only change", updated.Comments); + Assert.Equal(archive.LongLength, updated.FileSize); + AssertSameMetadata(updated, (await server.Repository.GetMetadataAsync(created.Id))!); + Assert.Equal(archive, await server.Repository.GetFileAsync(created.Id)); + } + + [Fact] + public async Task Delete_RemovesRecordAndArchive() + { + using var server = new TestServerSession(); + var nest = SyntheticNest(); + var created = await new NestSaveSession().SaveAsync(server.Repository, server.Url, nest, Archive(nest)); + + await server.Repository.DeleteAsync(created.Id); + + Assert.Empty(await server.Repository.ListAsync()); + Assert.Null(await server.Repository.GetMetadataAsync(created.Id)); + Assert.Null(await server.Repository.GetFileAsync(created.Id)); + } + + public static TheoryData InvalidUploads() + { + var data = new TheoryData(); + foreach (var route in new[] { "create", "update" }) + { + foreach (var body in new[] + { + "not multipart", "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) }; + 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_WithInvalidJson_Returns400AndLeavesStoreUnchanged(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.BadRequest, 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(); + 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 "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; + 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(); + } + } +} diff --git a/OpenNest.Server.Tests/OpenNest.Server.Tests.csproj b/OpenNest.Server.Tests/OpenNest.Server.Tests.csproj new file mode 100644 index 0000000..79ac2fa --- /dev/null +++ b/OpenNest.Server.Tests/OpenNest.Server.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + diff --git a/OpenNest.Server.Tests/ServerFactory.cs b/OpenNest.Server.Tests/ServerFactory.cs new file mode 100644 index 0000000..31d81c0 --- /dev/null +++ b/OpenNest.Server.Tests/ServerFactory.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace OpenNest.Server.Tests; + +/// +/// Hosts the production routes in memory against a private, disposable SQLite file. +/// The production registration is replaced before anything +/// resolves it, so tests never open the default data/nests.db or an OPENNEST_DB path. +/// Disposing the factory disposes the host (and its database) before deleting the directory. +/// +public sealed class ServerFactory : WebApplicationFactory +{ + private bool _cleanedUp; + + public ServerFactory(string? databasePath = null) + { + DirectoryPath = Path.Combine(Path.GetTempPath(), "opennest-server-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(DirectoryPath); + DatabasePath = databasePath ?? Path.Combine(DirectoryPath, "nests.db"); + } + + /// Private temporary directory owned by this factory. + public string DirectoryPath { get; } + + public string DatabasePath { get; } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.AddSingleton(_ => new NestDatabase(DatabasePath)); + }); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (!disposing || _cleanedUp) + return; + + _cleanedUp = true; + // Pooled connections would keep the file open on Windows after the host disposed it. + SqliteConnection.ClearAllPools(); + if (Directory.Exists(DirectoryPath)) + Directory.Delete(DirectoryPath, recursive: true); + } +} diff --git a/OpenNest.Server/Program.cs b/OpenNest.Server/Program.cs index 0816880..24ad47f 100644 --- a/OpenNest.Server/Program.cs +++ b/OpenNest.Server/Program.cs @@ -25,11 +25,14 @@ var databasePath = args.FirstOrDefault(a => a.StartsWith("--database=", StringComparison.Ordinal))?.Split('=', 2)[1] ?? Environment.GetEnvironmentVariable("OPENNEST_DB") ?? Path.Combine("data", "nests.db"); -builder.Services.AddSingleton(new NestDatabase(databasePath)); +builder.Services.AddSingleton(_ => new NestDatabase(databasePath)); // Listen port: --urls or ASPNETCORE_URLS; Dockerfile defaults to 8090. var app = builder.Build(); +// Open the database now so an unusable data path fails startup, not the first request. +app.Services.GetRequiredService(); + app.MapGet("/healthz", () => Results.Ok(new { status = "ok" })); app.MapGet("/api/nests", (NestDatabase db) => Results.Ok(db.List())); @@ -127,3 +130,6 @@ internal static class MultipartJson Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, }; } + +// Entry-point type for in-memory integration tests (WebApplicationFactory). +public partial class Program { } diff --git a/OpenNest.sln b/OpenNest.sln index ea89aca..e90f9b7 100644 --- a/OpenNest.sln +++ b/OpenNest.sln @@ -50,6 +50,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Reporting", "OpenN EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Server", "OpenNest.Server\OpenNest.Server.csproj", "{A2FC654E-8F13-4AD7-BED2-455F2B690961}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Server.Tests", "OpenNest.Server.Tests\OpenNest.Server.Tests.csproj", "{91DCA585-CEBF-4B80-B239-591CD0C919FA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -312,6 +314,18 @@ Global {A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x64.Build.0 = Release|Any CPU {A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x86.ActiveCfg = Release|Any CPU {A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x86.Build.0 = Release|Any CPU + {91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|x64.ActiveCfg = Debug|Any CPU + {91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|x64.Build.0 = Debug|Any CPU + {91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|x86.ActiveCfg = Debug|Any CPU + {91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|x86.Build.0 = Debug|Any CPU + {91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|Any CPU.Build.0 = Release|Any CPU + {91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x64.ActiveCfg = Release|Any CPU + {91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x64.Build.0 = Release|Any CPU + {91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x86.ActiveCfg = Release|Any CPU + {91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docs/nest-storage.md b/docs/nest-storage.md index 5310a79..06db02d 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -62,7 +62,23 @@ outside the shop network without adding one. `OpenNest.Server.NestDatabase` uses SQLite (`Microsoft.Data.Sqlite`) with `journal_mode=WAL`. Each row holds the metadata columns plus the `.nest` archive as a `BLOB`. The database file path comes from `--database=`, -then `OPENNEST_DB`, defaulting to `./data/nests.db`. +then `OPENNEST_DB`, defaulting to `./data/nests.db`. The server opens the +database at startup, so an unusable path fails startup rather than the first request. + +## Server integration tests + +```sh +dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj +``` + +Cross-platform; runs in both CI workflows. Each test hosts the production routes in +memory (`WebApplicationFactory`) against its own temporary SQLite file and drives +them through the real `RemoteNestRepository` and `NestSaveSession`: create, list, +download, same-record update, copy, metadata-only update (archive and `fileSize` +unchanged), and delete. Missing/invalid upload parts must return 400 and unknown ids +404, both leaving every stored record and archive hash unchanged. Tests never open +`data/nests.db` or `OPENNEST_DB`; the temporary directory is removed when the host is +disposed. The [container smoke](#isolated-container-smoke) remains the image-level check. ## Running