Files
OpenNest/OpenNest.Server.Tests/NestApiTests.cs
T
ajandClaude Opus 5.5 64e24fc6c8 feat(server): take nest record counts, material and thickness from the archive
The server stored whatever plate count, part count, material and
thickness the client claimed, so a record could disagree with its own
archive, and a bulk importer had to read each written .nest back in full
just to describe it (about a quarter of the PEP archive import's time).

Uploads (POST and PUT /file) now read only the archive's nest.json -
never programs or geometry, and without referencing OpenNest.IO - and
overwrite those fields, following NestReader's fallbacks for thickness,
material, parts with a missing drawing and the trailing new-plate slot.
A file without a readable nest.json is 400 and stores nothing, so the
server can guarantee the record describes the bytes; nest.json
decompression stops at 64 MB. A metadata-only PUT keeps the stored
counts because the archive is unchanged; material and thickness stay
editable there, since the desktop applies the record's values on open.

Server tests that uploaded placeholder bytes now upload small
deterministic archives, and the container smoke's near-limit probe is a
padded real archive. New tests pin the server's values to a full
NestReader read of the same archive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:14:06 -04:00

426 lines
17 KiB
C#

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;
/// <summary>
/// Production HTTP routes + real SQLite, driven through the desktop app's own
/// <see cref="RemoteNestRepository"/> and <see cref="NestSaveSession"/>.
/// Every test owns a fresh server and database.
/// </summary>
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<NestDatabase>().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<string, string> InvalidUploads()
{
var data = new TheoryData<string, string>();
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<HttpRequestMessage>[]
{
() => 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), TestNests.Archive(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, TestNests.Archive(1));
unbounded.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/form-data");
return unbounded;
case "missing metadata":
return Multipart(null, TestNests.Archive(1));
case "invalid metadata JSON":
return Multipart("{not-json", TestNests.Archive(1));
case "null metadata":
return Multipart("null", TestNests.Archive(1));
case "missing file":
return Multipart(metadata, null);
case "empty file":
return Multipart(metadata, Array.Empty<byte>());
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<NestRecord>(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();
}
/// <summary>One in-memory server, its HTTP client, and the real storage client over it.</summary>
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<NestRecord> SeedAsync()
{
var nest = SyntheticNest();
return await new NestSaveSession().SaveAsync(Repository, Url, nest, Archive(nest));
}
/// <summary>Every record's metadata plus its archive hash, in id order.</summary>
public async Task<string> SnapshotAsync()
{
var lines = new List<string>();
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();
}
}
}