From 76133193df0e7821bfe95841bc1b984946491583 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 16 Oct 2026 11:11:06 -0400 Subject: [PATCH] feat(server): drawing library schema and routes The server becomes the system of record for shared drawings (cut type, thickness, material and grade, customer, notes). Schema v4 adds `drawings` (current row) and `drawing_revisions` (immutable) to the same SQLite file under the existing monitor; the nest tables are untouched and a v3 upgrade keeps every nest row byte-identical. `/api/drawings` follows the nest revision contract: client-allocated ids, If-Match on every existing-drawing write (428/400/409 with no state change), archive instead of delete, and one transaction for promotion plus snapshot. Geometry is an opaque package (a zero-plate .nest) the server never parses. Enum fields accept single names only, because the JSON enum converter would otherwise accept numbers and comma combinations. Material, grade and customer filters match SharedListNames keys so spacing and case cannot split a drawing family; thickness is stored and compared at four decimals. Shared bounds live in OpenNest.Data so the client can refuse the same requests before sending. If-Match parsing moves to one helper used by nests and drawings. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/CutType.cs | 13 + OpenNest.Data/DrawingLibraryPage.cs | 15 + OpenNest.Data/DrawingLibraryQuery.cs | 68 +++ OpenNest.Data/DrawingLibraryRecord.cs | 50 ++ OpenNest.Data/DrawingLibraryRules.cs | 73 +++ OpenNest.Data/DrawingSortField.cs | 14 + .../DrawingLibraryApiTests.cs | 514 ++++++++++++++++++ .../DrawingLibraryDatabaseTests.cs | 239 ++++++++ .../DrawingLibraryFixtures.cs | 73 +++ .../DrawingLibraryQueryTests.cs | 230 ++++++++ OpenNest.Server/DrawingLibraryRequest.cs | 199 +++++++ OpenNest.Server/DrawingLibraryRoutes.cs | 162 ++++++ OpenNest.Server/IfMatch.cs | 22 + OpenNest.Server/NestDatabase.Drawings.cs | 339 ++++++++++++ OpenNest.Server/NestDatabase.cs | 9 +- OpenNest.Server/Program.cs | 23 +- 16 files changed, 2024 insertions(+), 19 deletions(-) create mode 100644 OpenNest.Core/CutType.cs create mode 100644 OpenNest.Data/DrawingLibraryPage.cs create mode 100644 OpenNest.Data/DrawingLibraryQuery.cs create mode 100644 OpenNest.Data/DrawingLibraryRecord.cs create mode 100644 OpenNest.Data/DrawingLibraryRules.cs create mode 100644 OpenNest.Data/DrawingSortField.cs create mode 100644 OpenNest.Server.Tests/DrawingLibraryApiTests.cs create mode 100644 OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs create mode 100644 OpenNest.Server.Tests/DrawingLibraryFixtures.cs create mode 100644 OpenNest.Server.Tests/DrawingLibraryQueryTests.cs create mode 100644 OpenNest.Server/DrawingLibraryRequest.cs create mode 100644 OpenNest.Server/DrawingLibraryRoutes.cs create mode 100644 OpenNest.Server/IfMatch.cs create mode 100644 OpenNest.Server/NestDatabase.Drawings.cs diff --git a/OpenNest.Core/CutType.cs b/OpenNest.Core/CutType.cs new file mode 100644 index 00000000..7ccb93df --- /dev/null +++ b/OpenNest.Core/CutType.cs @@ -0,0 +1,13 @@ +namespace OpenNest +{ + /// + /// Cutting process a library drawing is prepared for, stored by name so new + /// processes can be added without a migration. + /// + public enum CutType + { + Laser, + Waterjet, + Plasma, + } +} diff --git a/OpenNest.Data/DrawingLibraryPage.cs b/OpenNest.Data/DrawingLibraryPage.cs new file mode 100644 index 00000000..809b9f52 --- /dev/null +++ b/OpenNest.Data/DrawingLibraryPage.cs @@ -0,0 +1,15 @@ +namespace OpenNest.Data; + +/// One bounded page of library drawing metadata. +public sealed class DrawingLibraryPage +{ + /// At most records, in the requested order. + public IReadOnlyList Items { get; init; } = Array.Empty(); + + /// Number of records matching the request across all pages. + public int Total { get; init; } + + public int Offset { get; init; } + + public int Limit { get; init; } +} diff --git a/OpenNest.Data/DrawingLibraryQuery.cs b/OpenNest.Data/DrawingLibraryQuery.cs new file mode 100644 index 00000000..65503c5d --- /dev/null +++ b/OpenNest.Data/DrawingLibraryQuery.cs @@ -0,0 +1,68 @@ +using OpenNest; + +namespace OpenNest.Data; + +/// +/// One bounded page request for browsing the drawing library +/// (GET /api/drawings/query). The server filters and pages in SQL; the +/// client and server share these bounds. +/// +public sealed class DrawingLibraryQuery +{ + public const int DefaultLimit = 100; + public const int MaxLimit = 500; + public const int MaxSearchLength = 200; + + /// + /// Case-insensitive (ASCII) substring of name, customer or notes. Blank means no filter. + /// + public string Search { get; init; } = ""; + + /// Exact customer, compared by . Blank means any. + public string Customer { get; init; } = ""; + + /// Exact material name, compared by . Blank means any. + public string MaterialName { get; init; } = ""; + + /// Exact grade, compared by . Blank means any. + public string MaterialGrade { get; init; } = ""; + + /// Exact thickness after rounding to four decimals; null means any. + public double? Thickness { get; init; } + + public CutType? CutType { get; init; } + + public bool IncludeArchived { get; init; } + + /// Column to order by; ties are broken by id in the same direction. + public DrawingSortField Sort { get; init; } = DrawingSortField.SavedAt; + + /// Largest/newest/Z first when true (the default, newest saved first). + public bool Descending { get; init; } = true; + + public int Offset { get; init; } + + public int Limit { get; init; } = DefaultLimit; + + /// The search text as matched: trimmed, never null. + public string NormalizedSearch => (Search ?? "").Trim(); + + /// The first violated bound, or null when the query may be sent. + public string? GetValidationError() + { + if (!Enum.IsDefined(Sort)) + return "sort must be a supported column."; + if (CutType is { } cutType && !Enum.IsDefined(cutType)) + return "cutType must be Laser, Waterjet or Plasma."; + if (Thickness is { } thickness && (!double.IsFinite(thickness) || thickness < 0)) + return "thickness must be a finite number, zero or greater."; + if (Offset < 0) + return "offset must be zero or greater."; + if (Limit < 1 || Limit > MaxLimit) + return $"limit must be between 1 and {MaxLimit}."; + return DrawingLibraryRules.TextError("search", NormalizedSearch, MaxSearchLength) + ?? DrawingLibraryRules.TextError("customer", Customer ?? "", DrawingLibraryRules.MaxTextLength) + ?? DrawingLibraryRules.TextError("materialName", MaterialName ?? "", DrawingLibraryRules.MaxTextLength) + ?? DrawingLibraryRules.TextError("materialGrade", MaterialGrade ?? "", DrawingLibraryRules.MaxTextLength); + } +} diff --git a/OpenNest.Data/DrawingLibraryRecord.cs b/OpenNest.Data/DrawingLibraryRecord.cs new file mode 100644 index 00000000..ecc88a86 --- /dev/null +++ b/OpenNest.Data/DrawingLibraryRecord.cs @@ -0,0 +1,50 @@ +using OpenNest; + +namespace OpenNest.Data; + +/// +/// Server-side metadata for one revision of a shared library drawing. The geometry +/// travels separately as a drawing package (a single-drawing, zero-plate .nest archive). +/// +public sealed class DrawingLibraryRecord +{ + /// Allocated by the client before the first upload; kept by every revision. + public Guid Id { get; set; } + + /// Immutable revision number, starting at 1. Ignored on upload. + public int Revision { get; set; } + + public string Name { get; set; } = ""; + + public string Customer { get; set; } = ""; + + /// Post material name, for example "Carbon Steel". + public string MaterialName { get; set; } = ""; + + /// Alloy or grade within the material, for example "A36 HR". + public string MaterialGrade { get; set; } = ""; + + /// Thickness in , stored rounded to four decimals. + public double Thickness { get; set; } + + public Units Units { get; set; } = Units.Inches; + + public CutType CutType { get; set; } = CutType.Laser; + + public string Notes { get; set; } = ""; + + /// Hidden from browsing by default; still readable by id. + public bool Archived { get; set; } + + /// Lower-case hex SHA-256 of the package bytes. Server-computed. + public string PackageSha256 { get; set; } = ""; + + /// Package size in bytes. Server-computed. + public long PackageSize { get; set; } + + /// When the server stored this revision. Server-computed. + public DateTime SavedAt { get; set; } + + /// Who saved this revision, as reported by the client. + public string SavedBy { get; set; } = ""; +} diff --git a/OpenNest.Data/DrawingLibraryRules.cs b/OpenNest.Data/DrawingLibraryRules.cs new file mode 100644 index 00000000..c23a42d4 --- /dev/null +++ b/OpenNest.Data/DrawingLibraryRules.cs @@ -0,0 +1,73 @@ +using OpenNest; + +namespace OpenNest.Data; + +/// +/// Bounds shared by the drawing library client and server. The client checks them +/// before sending; the server refuses anything outside them with no state change. +/// +public static class DrawingLibraryRules +{ + /// Longest name, customer, material, grade or saved-by text. + public const int MaxTextLength = 200; + + public const int MaxNotesLength = 4000; + + /// Largest accepted drawing package (16 MiB). + public const int MaxPackageBytes = 16 * 1024 * 1024; + + public const string PackageTooLargeError = "The drawing package exceeds 16 MiB."; + + public const int DefaultRevisionLimit = 100; + + public const int MaxRevisionLimit = 200; + + /// Thickness as stored and compared: rounded to four decimals. + public static double NormalizeThickness(double thickness) => + System.Math.Round(thickness, 4, MidpointRounding.AwayFromZero); + + /// The first metadata rule breaks, or null when it may be saved. + public static string? GetMetadataError(DrawingLibraryRecord? record) + { + if (record is null) + return "Missing metadata."; + if (string.IsNullOrWhiteSpace(record.Name)) + return "name is required."; + return TextError("name", record.Name, MaxTextLength) + ?? TextError("customer", record.Customer, MaxTextLength) + ?? TextError("materialName", record.MaterialName, MaxTextLength) + ?? TextError("materialGrade", record.MaterialGrade, MaxTextLength) + ?? TextError("savedBy", record.SavedBy, MaxTextLength) + ?? TextError("notes", record.Notes, MaxNotesLength) + ?? (!double.IsFinite(record.Thickness) || record.Thickness < 0 + ? "thickness must be a finite number, zero or greater." + : null) + ?? (!Enum.IsDefined(record.Units) ? "units must be a supported unit name." : null) + ?? (!Enum.IsDefined(record.CutType) ? "cutType must be Laser, Waterjet or Plasma." : null); + } + + /// The first package rule breaks, or null when it may be stored. + public static string? GetPackageError(ReadOnlySpan package) + { + if (package.Length == 0) + return "The drawing package is empty."; + if (package.Length > MaxPackageBytes) + return PackageTooLargeError; + // Every ZIP archive, including an empty one, starts with a "PK" record signature. + if (package.Length < 4 || package[0] != (byte)'P' || package[1] != (byte)'K') + return "The drawing package is not a ZIP archive."; + return null; + } + + internal static string? TextError(string field, string? value, int maxLength) + { + if (value is null) + return $"{field} is required."; + if (value.Length > maxLength) + return $"{field} must be at most {maxLength} characters."; + // SQLite LIKE stops at an embedded NUL; refuse it everywhere for one rule. + if (value.Contains('\0')) + return $"{field} must not contain NUL characters."; + return null; + } +} diff --git a/OpenNest.Data/DrawingSortField.cs b/OpenNest.Data/DrawingSortField.cs new file mode 100644 index 00000000..c309827b --- /dev/null +++ b/OpenNest.Data/DrawingSortField.cs @@ -0,0 +1,14 @@ +namespace OpenNest.Data; + +/// Columns a drawing library query may order by (camelCase on the wire). +public enum DrawingSortField +{ + SavedAt, + Name, + Customer, + MaterialName, + MaterialGrade, + Thickness, + CutType, + Revision, +} diff --git a/OpenNest.Server.Tests/DrawingLibraryApiTests.cs b/OpenNest.Server.Tests/DrawingLibraryApiTests.cs new file mode 100644 index 00000000..66ba7877 --- /dev/null +++ b/OpenNest.Server.Tests/DrawingLibraryApiTests.cs @@ -0,0 +1,514 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Data.Sqlite; +using OpenNest.Data; +using static OpenNest.Server.Tests.DrawingLibraryFixtures; + +namespace OpenNest.Server.Tests; + +public sealed class DrawingLibraryApiTests +{ + [Fact] + public async Task Upload_StoresRevisionOneWithServerComputedFields() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var record = Record(); + record.Thickness = 0.123456; + record.Revision = 9; + record.PackageSha256 = "client value"; + record.PackageSize = 1; + var package = Package(); + + var saved = await Upload(client, record, package); + + Assert.Equal(record.Id, saved.Id); + Assert.Equal(1, saved.Revision); + Assert.Equal(0.1235, saved.Thickness); + Assert.Equal(Sha256(package), saved.PackageSha256); + Assert.Equal(package.LongLength, saved.PackageSize); + Assert.NotEqual(default, saved.SavedAt); + Assert.False(saved.Archived); + Assert.Equal(CutType.Laser, saved.CutType); + Assert.Equal("Synthetic operator", saved.SavedBy); + Assert.Equal(package, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/file")); + Assert.Equal(package, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/revisions/1/file")); + var fetched = await client.GetFromJsonAsync($"/api/drawings/{record.Id}", Options); + Assert.Equal(JsonSerializer.Serialize(saved), JsonSerializer.Serialize(fetched)); + } + + [Fact] + public async Task Upload_SerializesEnumsAsCamelCaseNames() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var record = Record(); + record.CutType = CutType.Waterjet; + await Upload(client, record, Package()); + + var json = await client.GetStringAsync($"/api/drawings/{record.Id}"); + + var node = JsonNode.Parse(json)!; + Assert.Equal("waterjet", node["cutType"]!.GetValue()); + Assert.Equal("inches", node["units"]!.GetValue()); + } + + [Fact] + public async Task Upload_ExistingId_ConflictsAndChangesNothing() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var record = Record(); + var package = Package("first"); + await Upload(client, record, package); + + record.Name = "Second writer"; + using var response = await client.PostAsync("/api/drawings", Body(record, Package("second"))); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + var current = (await response.Content.ReadFromJsonAsync(Options))!; + Assert.Equal("Synthetic bracket", current.Name); + Assert.Equal(1, current.Revision); + Assert.Equal(package, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/file")); + } + + public static TheoryData RejectedUploads() + { + var valid = JsonSerializer.Serialize(Record(), Options); + string With(string property, JsonNode? value) + { + var node = JsonNode.Parse(valid)!.AsObject(); + node[property] = value; + return node.ToJsonString(); + } + + return new TheoryData + { + { "empty id", With("id", Guid.Empty.ToString()), 400 }, + { "unknown cut type", With("cutType", "Oxyfuel"), 400 }, + { "integer cut type", With("cutType", 1), 400 }, + { "combined cut type", With("cutType", "Laser, Plasma"), 400 }, + { "null cut type", With("cutType", null), 400 }, + { "unknown units", With("units", "Feet"), 400 }, + { "integer units", With("units", 0), 400 }, + { "negative thickness", With("thickness", -0.25), 400 }, + { "blank name", With("name", " "), 400 }, + { "null name", With("name", null), 400 }, + { "long name", With("name", new string('n', 201)), 400 }, + { "NUL name", With("name", "a\0b"), 400 }, + { "null customer", With("customer", null), 400 }, + { "long customer", With("customer", new string('c', 201)), 400 }, + { "long material", With("materialName", new string('m', 201)), 400 }, + { "long grade", With("materialGrade", new string('g', 201)), 400 }, + { "long notes", With("notes", new string('x', 4001)), 400 }, + { "not an object", "[1]", 400 }, + { "malformed", "{", 400 }, + }; + } + + [Theory] + [MemberData(nameof(RejectedUploads))] + public async Task Upload_InvalidMetadata_IsRejectedWithNoState(string row, string metadata, int status) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + + using var response = await client.PostAsync("/api/drawings", Body(metadata, Package())); + + Assert.True(status == (int)response.StatusCode, row); + await AssertEmptyLibrary(client); + } + + [Fact] + public async Task Upload_BoundaryLengths_AreAccepted() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var record = Record(new string('n', 200)); + record.Notes = new string('x', 4000); + record.Customer = new string('c', 200); + + var saved = await Upload(client, record, Package()); + + Assert.Equal(200, saved.Name.Length); + Assert.Equal(4000, saved.Notes.Length); + } + + [Theory] + [InlineData("missing")] + [InlineData("empty")] + [InlineData("not a zip")] + [InlineData("too large")] + public async Task Upload_InvalidPackage_IsRejectedWithNoState(string row) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var package = row switch + { + "missing" => null, + "empty" => Array.Empty(), + "not a zip" => Encoding.UTF8.GetBytes("nest.json"), + _ => Enumerable.Repeat((byte)'P', DrawingLibraryRules.MaxPackageBytes + 1).ToArray(), + }; + + using var response = await client.PostAsync("/api/drawings", Body(Record(), package)); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + await AssertEmptyLibrary(client); + } + + [Fact] + public async Task Upload_PackageAtLimit_IsAccepted() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var package = new byte[DrawingLibraryRules.MaxPackageBytes]; + package[0] = (byte)'P'; + package[1] = (byte)'K'; + + var saved = await Upload(client, Record(), package); + + Assert.Equal(DrawingLibraryRules.MaxPackageBytes, saved.PackageSize); + } + + [Fact] + public async Task Upload_NotMultipartOrMissingMetadata_IsRejected() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + + using var json = await client.PostAsJsonAsync("/api/drawings", Record(), Options); + var withoutMetadata = new MultipartFormDataContent { { new ByteArrayContent(Package()), "package", "d.nest" } }; + using var missing = await client.PostAsync("/api/drawings", withoutMetadata); + + Assert.Equal(HttpStatusCode.BadRequest, json.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, missing.StatusCode); + await AssertEmptyLibrary(client); + } + + [Fact] + public async Task Reads_UnknownDrawingOrRevision_Are404() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var saved = await Upload(client, Record(), Package()); + var unknown = Guid.NewGuid(); + + foreach (var url in new[] + { + $"/api/drawings/{unknown}", $"/api/drawings/{unknown}/file", $"/api/drawings/{unknown}/revisions", + $"/api/drawings/{unknown}/revisions/1/file", $"/api/drawings/{saved.Id}/revisions/2/file", + }) + { + using var response = await client.GetAsync(url); + Assert.True(response.StatusCode == HttpStatusCode.NotFound, url); + } + + using var zero = await client.GetAsync($"/api/drawings/{saved.Id}/revisions/0/file"); + Assert.Equal(HttpStatusCode.BadRequest, zero.StatusCode); + } + + [Fact] + public async Task UpdateFile_AppendsRevisionAndKeepsHistoryExact() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var record = Record(); + var first = Package("first"); + await Upload(client, record, first); + var second = Package("second"); + record.Notes = "Changed geometry"; + + using var response = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/file", "\"1\"", Body(record, second))); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var saved = (await response.Content.ReadFromJsonAsync(Options))!; + Assert.Equal(2, saved.Revision); + Assert.Equal(Sha256(second), saved.PackageSha256); + Assert.Equal(second, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/file")); + Assert.Equal(first, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/revisions/1/file")); + Assert.Equal(second, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/revisions/2/file")); + var history = await client.GetFromJsonAsync($"/api/drawings/{record.Id}/revisions", Options); + Assert.Equal(new[] { 2, 1 }, history!.Items.Select(r => r.Revision)); + Assert.Equal(new[] { Sha256(second), Sha256(first) }, history.Items.Select(r => r.PackageSha256)); + Assert.Equal(new[] { "Changed geometry", "Synthetic notes" }, history.Items.Select(r => r.Notes)); + } + + [Fact] + public async Task UpdateFile_IdenticalSave_IsNoOp() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var record = Record(); + var package = Package(); + var saved = await Upload(client, record, package); + + using var response = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/file", "\"1\"", Body(record, package))); + + var current = (await response.Content.ReadFromJsonAsync(Options))!; + Assert.Equal(1, current.Revision); + Assert.Equal(saved.SavedAt, current.SavedAt); + var history = await client.GetFromJsonAsync($"/api/drawings/{record.Id}/revisions", Options); + Assert.Equal(1, history!.Total); + } + + [Fact] + public async Task UpdateMetadata_AppendsRevisionAndKeepsPackage() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var record = Record(); + var package = Package(); + await Upload(client, record, package); + record.CutType = CutType.Plasma; + record.MaterialGrade = "Grade B"; + + using var response = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/metadata", "\"1\"", + JsonContent.Create(record, options: Options))); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var saved = (await response.Content.ReadFromJsonAsync(Options))!; + Assert.Equal(2, saved.Revision); + Assert.Equal(CutType.Plasma, saved.CutType); + Assert.Equal("Grade B", saved.MaterialGrade); + Assert.Equal(Sha256(package), saved.PackageSha256); + Assert.Equal(package, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/revisions/2/file")); + } + + [Theory] + [InlineData("file", null, 428)] + [InlineData("file", "1", 400)] + [InlineData("file", "\"0\"", 400)] + [InlineData("file", "\"1\", \"2\"", 400)] + [InlineData("file", "\"2\"", 409)] + [InlineData("metadata", null, 428)] + [InlineData("metadata", "\"x\"", 400)] + [InlineData("metadata", "\"2\"", 409)] + [InlineData("archive", null, 428)] + [InlineData("archive", "\"2\"", 409)] + [InlineData("restore", "\"1\"", 409)] + public async Task GuardedWrite_Refusals_LeaveCurrentAndHistoryUnchanged(string route, string? ifMatch, int status) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var record = Record(); + var package = Package(); + var saved = await Upload(client, record, package); + record.Name = "Rejected"; + HttpContent? content = route switch + { + "file" => Body(record, Package("rejected")), + "metadata" => JsonContent.Create(record, options: Options), + _ => null, + }; + var method = route is "archive" or "restore" ? HttpMethod.Post : HttpMethod.Put; + + using var response = await client.SendAsync(Guarded(method, $"/api/drawings/{record.Id}/{route}", ifMatch, content)); + + Assert.Equal(status, (int)response.StatusCode); + if (status == 409) + Assert.Equal(1, (await response.Content.ReadFromJsonAsync(Options))!.Revision); + await AssertUnchanged(factory, client, saved, package); + } + + [Fact] + public async Task GuardedWrite_UnknownDrawing_Is404() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var record = Record(); + + using var file = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/file", "\"1\"", Body(record, Package()))); + using var metadata = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/metadata", "\"1\"", + JsonContent.Create(record, options: Options))); + using var archive = await client.SendAsync(Guarded(HttpMethod.Post, $"/api/drawings/{record.Id}/archive", "\"1\"")); + + Assert.Equal(HttpStatusCode.NotFound, file.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, metadata.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, archive.StatusCode); + await AssertEmptyLibrary(client); + } + + [Theory] + [InlineData("file")] + [InlineData("metadata")] + public async Task GuardedWrite_InvalidBodyOrMismatchedId_IsRejected(string route) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var record = Record(); + var package = Package(); + var saved = await Upload(client, record, package); + var other = Record("Other id"); + HttpContent invalid = route == "file" + ? Body("{\"name\":\"\"}", Package()) + : new StringContent("{\"name\":\"x\",\"cutType\":3}", Encoding.UTF8, "application/json"); + HttpContent mismatched = route == "file" ? Body(other, Package()) : JsonContent.Create(other, options: Options); + + using var invalidResponse = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/{route}", "\"1\"", invalid)); + using var mismatchedResponse = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/{route}", "\"1\"", mismatched)); + + Assert.Equal(HttpStatusCode.BadRequest, invalidResponse.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, mismatchedResponse.StatusCode); + await AssertUnchanged(factory, client, saved, package); + } + + [Fact] + public async Task UpdateMetadata_NonJsonBody_IsRejected() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var record = Record(); + var package = Package(); + var saved = await Upload(client, record, package); + + using var response = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/metadata", "\"1\"", + new StringContent(JsonSerializer.Serialize(record, Options), Encoding.UTF8, "text/plain"))); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + await AssertUnchanged(factory, client, saved, package); + } + + [Fact] + public async Task ArchiveAndRestore_HideFromBrowsingAndBlockEdits() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var record = Record(); + var package = Package(); + await Upload(client, record, package); + + using var archived = await client.SendAsync(Guarded(HttpMethod.Post, $"/api/drawings/{record.Id}/archive", "\"1\"")); + var archivedRecord = (await archived.Content.ReadFromJsonAsync(Options))!; + Assert.True(archivedRecord.Archived); + Assert.Equal(2, archivedRecord.Revision); + Assert.Equal(Sha256(package), archivedRecord.PackageSha256); + Assert.Equal(0, (await Query(client, "")).Total); + Assert.Equal(1, (await Query(client, "includeArchived=true")).Total); + Assert.True((await client.GetFromJsonAsync($"/api/drawings/{record.Id}", Options))!.Archived); + + using var again = await client.SendAsync(Guarded(HttpMethod.Post, $"/api/drawings/{record.Id}/archive", "\"2\"")); + Assert.Equal(HttpStatusCode.Conflict, again.StatusCode); + record.Name = "Edited while archived"; + using var edit = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/file", "\"2\"", Body(record, Package("x")))); + Assert.Equal(HttpStatusCode.Conflict, edit.StatusCode); + + using var restored = await client.SendAsync(Guarded(HttpMethod.Post, $"/api/drawings/{record.Id}/restore", "\"2\"")); + var restoredRecord = (await restored.Content.ReadFromJsonAsync(Options))!; + Assert.False(restoredRecord.Archived); + Assert.Equal(3, restoredRecord.Revision); + Assert.Equal("Synthetic bracket", restoredRecord.Name); + Assert.Equal(1, (await Query(client, "")).Total); + var history = await client.GetFromJsonAsync($"/api/drawings/{record.Id}/revisions", Options); + Assert.Equal(new[] { false, true, false }, history!.Items.Select(r => r.Archived)); + Assert.Equal(package, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/revisions/2/file")); + } + + [Fact] + public async Task FailedRevisionInsert_RollsBackCurrentRow() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var record = Record(); + var package = Package(); + var saved = await Upload(client, record, package); + using (var connection = new SqliteConnection($"Data Source={factory.DatabasePath};Pooling=False")) + { + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "CREATE TRIGGER fail_revision BEFORE INSERT ON drawing_revisions BEGIN SELECT RAISE(ABORT, 'fixture failure'); END"; + command.ExecuteNonQuery(); + } + record.Name = "Rejected"; + + using var failed = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/file", "\"1\"", Body(record, Package("x")))); + using var insert = await client.PostAsync("/api/drawings", Body(Record("New"), Package())); + + Assert.Equal(HttpStatusCode.InternalServerError, failed.StatusCode); + Assert.Equal(HttpStatusCode.InternalServerError, insert.StatusCode); + await AssertUnchanged(factory, client, saved, package); + Assert.Equal(1, (await Query(client, "")).Total); + } + + [Fact] + public async Task ConcurrentGuardedSaves_OneWinsAndOneConflicts() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var record = Record(); + await Upload(client, record, Package()); + + var responses = await Task.WhenAll(Enumerable.Range(2, 2).Select(i => + { + var writer = Record($"Writer {i}", record.Id); + return client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/file", "\"1\"", Body(writer, Package($"w{i}")))); + })); + try + { + Assert.Single(responses.Where(r => r.StatusCode == HttpStatusCode.OK)); + var conflict = Assert.Single(responses.Where(r => r.StatusCode == HttpStatusCode.Conflict)); + Assert.Equal(2, (await conflict.Content.ReadFromJsonAsync(Options))!.Revision); + var history = await client.GetFromJsonAsync($"/api/drawings/{record.Id}/revisions", Options); + Assert.Equal(new[] { 2, 1 }, history!.Items.Select(r => r.Revision)); + } + finally { foreach (var response in responses) response.Dispose(); } + } + + [Theory] + [InlineData("limit=0")] + [InlineData("limit=201")] + [InlineData("offset=-1")] + [InlineData("limit=1&limit=2")] + [InlineData("limit=no")] + [InlineData("unknown=1")] + public async Task History_InvalidPagination_IsRejected(string query) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var saved = await Upload(client, Record(), Package()); + + using var response = await client.GetAsync($"/api/drawings/{saved.Id}/revisions?{query}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task History_PagesNewestFirst() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var record = Record(); + await Upload(client, record, Package()); + for (var revision = 1; revision <= 3; revision++) + { + record.Notes = $"Revision {revision + 1}"; + using var response = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/metadata", + $"\"{revision}\"", JsonContent.Create(record, options: Options))); + response.EnsureSuccessStatusCode(); + } + + var page = await client.GetFromJsonAsync($"/api/drawings/{record.Id}/revisions?offset=1&limit=2", Options); + + Assert.Equal(4, page!.Total); + Assert.Equal(new[] { 3, 2 }, page.Items.Select(r => r.Revision)); + } + + internal static async Task Query(HttpClient client, string query) => + (await client.GetFromJsonAsync("/api/drawings/query?" + query, Options))!; + + private static async Task AssertEmptyLibrary(HttpClient client) => + Assert.Equal(0, (await Query(client, "includeArchived=true")).Total); + + private static async Task AssertUnchanged(ServerFactory factory, HttpClient client, DrawingLibraryRecord saved, byte[] package) + { + var current = await client.GetFromJsonAsync($"/api/drawings/{saved.Id}", Options); + Assert.Equal(JsonSerializer.Serialize(saved), JsonSerializer.Serialize(current)); + Assert.Equal(package, await client.GetByteArrayAsync($"/api/drawings/{saved.Id}/file")); + using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); + Assert.Equal(1, reopened.DrawingHistory(saved.Id, 0, 100)!.Total); + Assert.Equal(package, reopened.GetDrawingRevisionPackage(saved.Id, 1)); + } +} diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs new file mode 100644 index 00000000..e03075bd --- /dev/null +++ b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs @@ -0,0 +1,239 @@ +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Data.Sqlite; +using OpenNest.Data; +using static OpenNest.Server.Tests.DrawingLibraryFixtures; + +namespace OpenNest.Server.Tests; + +public sealed class DrawingLibraryDatabaseTests +{ + [Fact] + public void V3Upgrade_KeepsNestRowsByteIdenticalAndIsIdempotent() + { + using var factory = new ServerFactory(); + CreateVersion3Database(factory.DatabasePath); + var before = NestDigest(factory.DatabasePath); + + for (var open = 0; open < 2; open++) + { + using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) + Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items); + Assert.Equal(before, NestDigest(factory.DatabasePath)); + Assert.Equal(4L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + } + + using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); + var record = Record(); + upgraded.InsertDrawing(record.Id, record, Package()); + Assert.Equal(1, upgraded.GetDrawing(record.Id)!.Revision); + Assert.Equal(2, upgraded.List().Count); + } + + [Fact] + public void NewerSchema_IsRefused() + { + using var factory = new ServerFactory(); + using (new NestDatabase(factory.DatabasePath, pooling: false)) { } + Execute(factory.DatabasePath, "PRAGMA user_version=5"); + + Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false)); + } + + [Fact] + public void FailedV4Migration_RollsBackAndCanRestart() + { + using var factory = new ServerFactory(); + CreateVersion3Database(factory.DatabasePath); + var before = NestDigest(factory.DatabasePath); + Execute(factory.DatabasePath, "CREATE TABLE drawing_revisions (fixture TEXT)"); + + Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false)); + + Assert.Equal(3L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name='drawings'")); + Assert.Equal(before, NestDigest(factory.DatabasePath)); + Execute(factory.DatabasePath, "DROP TABLE drawing_revisions"); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + Assert.Equal(2, database.List().Count); + } + + [Fact] + public void DirectWrites_EnforceTheSharedRules() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var record = Record(); + + Assert.Throws(() => database.InsertDrawing(Guid.Empty, record, Package())); + Assert.Throws(() => database.InsertDrawing(record.Id, record, new byte[] { 1, 2, 3, 4 })); + record.CutType = (CutType)7; + Assert.Throws(() => database.InsertDrawing(record.Id, record, Package())); + Assert.Throws(() => database.QueryDrawings(new DrawingLibraryQuery { Limit = 501 })); + Assert.Throws(() => database.DrawingHistory(record.Id, 0, 201)); + Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items); + } + + // Code-level ownership gate, as for the nest operations: hold the database's monitor + // and observe a dedicated synchronous caller waiting on it. + [Theory] + [InlineData("GetDrawing")] + [InlineData("GetDrawingPackage")] + [InlineData("QueryDrawings")] + [InlineData("InsertDrawing")] + [InlineData("UpdateDrawing")] + [InlineData("UpdateDrawingMetadata")] + [InlineData("SetDrawingArchived")] + [InlineData("DrawingHistory")] + [InlineData("GetDrawingRevisionPackage")] + public async Task Concurrency_EveryDrawingOperationWaitsForTheSameMonitor(string operation) + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var record = Record(); + database.InsertDrawing(record.Id, record, Package()); + var sync = typeof(NestDatabase).GetField("_sync", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(database)!; + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var worker = new Thread(() => completed.SetResult(Xunit.Record.Exception(() => + { + switch (operation) + { + case "GetDrawing": database.GetDrawing(record.Id); break; + case "GetDrawingPackage": database.GetDrawingPackage(record.Id); break; + case "QueryDrawings": database.QueryDrawings(new DrawingLibraryQuery { Search = "bracket" }); break; + case "InsertDrawing": database.InsertDrawing(Guid.NewGuid(), record, Package("other")); break; + case "UpdateDrawing": database.UpdateDrawing(record.Id, record, Package("changed"), 1); break; + case "UpdateDrawingMetadata": database.UpdateDrawing(record.Id, Record("Renamed", record.Id), null, 1); break; + case "SetDrawingArchived": database.SetDrawingArchived(record.Id, true, 1); break; + case "DrawingHistory": database.DrawingHistory(record.Id, 0, 100); break; + case "GetDrawingRevisionPackage": database.GetDrawingRevisionPackage(record.Id, 1); break; + default: throw new InvalidOperationException($"Unknown test operation: {operation}"); + } + }))) + { IsBackground = true }; + + bool observedWaiting; + bool completedWhileHeld; + lock (sync) + { + worker.Start(); + observedWaiting = SpinWait.SpinUntil(() => completed.Task.IsCompleted || + (worker.ThreadState & ThreadState.WaitSleepJoin) != 0, TimeSpan.FromSeconds(10)); + completedWhileHeld = completed.Task.IsCompleted; + } + + Assert.True(worker.Join(TimeSpan.FromSeconds(10)), "Operation did not finish after releasing the monitor."); + Assert.True(observedWaiting, "Caller never reached the operation within the watchdog."); + Assert.False(completedWhileHeld, operation + " bypassed the shared monitor."); + Assert.Null(await completed.Task); + } + + [Fact] + public void Concurrency_AllDrawingSqlStepsOwnTheMonitor() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var sync = typeof(NestDatabase).GetField("_sync", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(database)!; + var connection = (SqliteConnection)typeof(NestDatabase) + .GetField("_connection", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(database)!; + var steps = 0; + var unownedSteps = 0; + SQLitePCL.raw.sqlite3_progress_handler(connection.Handle, 1, _ => + { + steps++; + if (!Monitor.IsEntered(sync)) + unownedSteps++; + return 0; + }, null); + try + { + var record = Record(); + Action[] operations = + [ + () => database.InsertDrawing(record.Id, record, Package()), + () => database.GetDrawing(record.Id), + () => database.GetDrawingPackage(record.Id), + () => database.QueryDrawings(new DrawingLibraryQuery { Search = "Synthetic", CutType = CutType.Laser, Thickness = 0.25 }), + () => database.UpdateDrawing(record.Id, record, Package("changed"), 1), + () => database.UpdateDrawing(record.Id, Record("Renamed", record.Id), null, 2), + () => database.SetDrawingArchived(record.Id, true, 3), + () => database.SetDrawingArchived(record.Id, false, 4), + () => database.DrawingHistory(record.Id, 0, 100), + () => database.GetDrawingRevisionPackage(record.Id, 1), + ]; + foreach (var operation in operations) + { + var before = steps; + operation(); + Assert.True(steps > before, "Operation did not execute the observed SQLite VM."); + Assert.Equal(0, unownedSteps); + } + } + finally + { + SQLitePCL.raw.sqlite3_progress_handler(connection.Handle, 0, null, null); + } + } + + /// A real schema-v3 file: v4 is additive, so dropping its tables restores v3 exactly. + private static void CreateVersion3Database(string path) + { + using (var database = new NestDatabase(path, pooling: false)) + { + var first = new NestRecord { Name = "Synthetic nest A", Customer = "Synthetic", Material = "Alloy", Thickness = 0.25 }; + var second = new NestRecord { Name = "Synthetic nest B", Comments = "Notes" }; + var a = database.Insert(Guid.NewGuid(), first, new byte[] { 0, 1, 255 }); + database.Insert(Guid.NewGuid(), second, new byte[] { 7, 8, 9 }); + first.Comments = "Edited"; + database.Update(a.Id, first, new byte[] { 2, 3 }, 1); + database.SetDeleted(a.Id, true, 2); + database.SetDeleted(a.Id, false, 3); + } + + Execute(path, "DROP TABLE drawing_revisions; DROP TABLE drawings; PRAGMA user_version=3;"); + } + + private static string NestDigest(string path) + { + using var connection = new SqliteConnection($"Data Source={path};Pooling=False"); + connection.Open(); + var text = new StringBuilder(); + foreach (var sql in new[] { "SELECT * FROM nests ORDER BY id", "SELECT * FROM nest_revisions ORDER BY id, revision" }) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + for (var i = 0; i < reader.FieldCount; i++) + { + var value = reader.GetValue(i); + text.Append(reader.GetName(i)).Append('=') + .Append(value is byte[] bytes ? Convert.ToHexString(SHA256.HashData(bytes)) : Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)) + .Append(';'); + } + text.Append('\n'); + } + } + return text.ToString(); + } + + private static void Execute(string path, string sql) + { + using var connection = new SqliteConnection($"Data Source={path};Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + private static object? Scalar(string path, string sql) + { + using var connection = new SqliteConnection($"Data Source={path};Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = sql; + return command.ExecuteScalar(); + } +} diff --git a/OpenNest.Server.Tests/DrawingLibraryFixtures.cs b/OpenNest.Server.Tests/DrawingLibraryFixtures.cs new file mode 100644 index 00000000..6be9da27 --- /dev/null +++ b/OpenNest.Server.Tests/DrawingLibraryFixtures.cs @@ -0,0 +1,73 @@ +using System.IO.Compression; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenNest.Data; + +namespace OpenNest.Server.Tests; + +/// Neutral, code-built drawing library fixtures for the server tests. +internal static class DrawingLibraryFixtures +{ + public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { Converters = { new JsonStringEnumConverter() } }; + + public static DrawingLibraryRecord Record(string name = "Synthetic bracket", Guid? id = null) => new() + { + Id = id ?? Guid.NewGuid(), + Name = name, + Customer = "Synthetic customer", + MaterialName = "Synthetic Steel", + MaterialGrade = "Grade A", + Thickness = 0.25, + Units = Units.Inches, + CutType = CutType.Laser, + Notes = "Synthetic notes", + SavedBy = "Synthetic operator", + }; + + /// A real ZIP archive whose single entry holds . + public static byte[] Package(string content = "synthetic") + { + using var buffer = new MemoryStream(); + using (var archive = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = archive.CreateEntry("nest.json"); + entry.LastWriteTime = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + using var stream = entry.Open(); + stream.Write(Encoding.UTF8.GetBytes(content)); + } + return buffer.ToArray(); + } + + public static string Sha256(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + + public static MultipartFormDataContent Body(string metadataJson, byte[]? package) + { + var body = new MultipartFormDataContent(); + body.Add(new StringContent(metadataJson), "metadata"); + if (package is not null) + body.Add(new ByteArrayContent(package), "package", "drawing.nest"); + return body; + } + + public static MultipartFormDataContent Body(DrawingLibraryRecord record, byte[]? package) => + Body(JsonSerializer.Serialize(record, Options), package); + + public static async Task Upload(HttpClient client, DrawingLibraryRecord record, byte[] package) + { + using var response = await client.PostAsync("/api/drawings", Body(record, package)); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync(Options))!; + } + + public static HttpRequestMessage Guarded(HttpMethod method, string url, string? ifMatch, HttpContent? content = null) + { + var request = new HttpRequestMessage(method, url) { Content = content }; + if (ifMatch is not null) + request.Headers.TryAddWithoutValidation("If-Match", ifMatch); + return request; + } +} diff --git a/OpenNest.Server.Tests/DrawingLibraryQueryTests.cs b/OpenNest.Server.Tests/DrawingLibraryQueryTests.cs new file mode 100644 index 00000000..d64cba75 --- /dev/null +++ b/OpenNest.Server.Tests/DrawingLibraryQueryTests.cs @@ -0,0 +1,230 @@ +using System.Net; +using System.Net.Http.Json; +using OpenNest.Data; +using static OpenNest.Server.Tests.DrawingLibraryFixtures; + +namespace OpenNest.Server.Tests; + +public sealed class DrawingLibraryQueryTests +{ + private static async Task> SeedNamed(HttpClient client) + { + var rows = new (string Name, string Customer, string Material, string Grade, double Thickness, CutType CutType, string Notes)[] + { + ("Alpha bracket", "Alpha Fabrication", "Carbon Steel", "A36 HR", 0.25, CutType.Laser, ""), + ("Beta plate", "beta works", "carbon steel", " a36 hr", 0.1875, CutType.Waterjet, "Gamma 50% notes"), + ("Delta_Under", "Gamma 50% Shop", "Stainless", "304", 0.25, CutType.Plasma, ""), + ("Echo", "Alpha Fabrication", "Carbon Steel", "A36 HR", 0.25004, CutType.Laser, ""), + }; + var saved = new Dictionary(); + foreach (var row in rows) + { + var record = Record(row.Name); + record.Customer = row.Customer; + record.MaterialName = row.Material; + record.MaterialGrade = row.Grade; + record.Thickness = row.Thickness; + record.CutType = row.CutType; + record.Notes = row.Notes; + saved[row.Name] = await Upload(client, record, Package(row.Name)); + } + + using var archived = await client.SendAsync(Guarded(HttpMethod.Post, $"/api/drawings/{saved["Echo"].Id}/archive", "\"1\"")); + archived.EnsureSuccessStatusCode(); + return saved; + } + + [Theory] + [InlineData("", "Alpha bracket|Beta plate|Delta_Under")] + [InlineData("includeArchived=true", "Alpha bracket|Beta plate|Delta_Under|Echo")] + [InlineData("includeArchived=false", "Alpha bracket|Beta plate|Delta_Under")] + [InlineData("cutType=laser", "Alpha bracket")] + [InlineData("cutType=LASER&includeArchived=true", "Alpha bracket|Echo")] + [InlineData("cutType=Waterjet", "Beta plate")] + [InlineData("materialName=CARBON%20%20steel%20", "Alpha bracket|Beta plate")] + [InlineData("materialGrade=a36%20HR", "Alpha bracket|Beta plate")] + [InlineData("customer=ALPHA%20fabrication", "Alpha bracket")] + [InlineData("customer=Beta%20Works", "Beta plate")] + [InlineData("thickness=0.25", "Alpha bracket|Delta_Under")] + [InlineData("thickness=0.25&includeArchived=true", "Alpha bracket|Delta_Under|Echo")] + [InlineData("thickness=0.1875", "Beta plate")] + [InlineData("thickness=0.1874", "")] + [InlineData("thickness=0.25&materialName=Carbon%20Steel&cutType=laser", "Alpha bracket")] + [InlineData("materialName=Stainless&cutType=laser", "")] + [InlineData("customer=&materialName=", "Alpha bracket|Beta plate|Delta_Under")] + [InlineData("search=0%25", "Beta plate|Delta_Under")] + [InlineData("search=_", "Delta_Under")] + [InlineData("search=%5C", "")] + [InlineData("search=%20GAMMA%20", "Beta plate|Delta_Under")] + [InlineData("search=stainless", "")] + [InlineData("search=bracket&cutType=plasma", "")] + public async Task Filters_MatchExactKeysAndLiteralSearch(string query, string expected) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + await SeedNamed(client); + + var page = await DrawingLibraryApiTests.Query(client, query + "&sort=name&order=asc"); + + Assert.Equal(expected, string.Join("|", page.Items.Select(r => r.Name))); + Assert.Equal(page.Items.Count, page.Total); + } + + [Theory] + [InlineData("name", false)] + [InlineData("name", true)] + [InlineData("customer", false)] + [InlineData("materialName", true)] + [InlineData("materialGrade", false)] + [InlineData("thickness", true)] + [InlineData("thickness", false)] + [InlineData("cutType", false)] + [InlineData("revision", true)] + public async Task Sort_MatchesOracleAndPagesPartitionMatches(string sort, bool descending) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + string[] names = { "beta", "Alpha", "alpha", "BETA", "gamma" }; + string[] grades = { "a36", "A36", "304" }; + double[] thicknesses = { 0.25, 0.1875, 0.25 }; + CutType[] cutTypes = { CutType.Plasma, CutType.Laser, CutType.Waterjet }; + var saved = new List(); + for (var i = 0; i < 13; i++) + { + var record = Record(names[i % names.Length]); + record.Customer = names[(i + 2) % names.Length]; + record.MaterialName = names[(i + 1) % names.Length]; + record.MaterialGrade = grades[i % grades.Length]; + record.Thickness = thicknesses[i % thicknesses.Length]; + record.CutType = cutTypes[i % cutTypes.Length]; + var uploaded = await Upload(client, record, Package(i.ToString())); + if (i % 4 == 0) + { + record.Notes = "edited"; + using var edit = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/metadata", "\"1\"", + JsonContent.Create(record, options: Options))); + uploaded = (await edit.Content.ReadFromJsonAsync(Options))!; + } + saved.Add(uploaded); + } + + var expected = Oracle(saved, sort, descending).Select(r => r.Id).ToList(); + var order = descending ? "desc" : "asc"; + var full = await DrawingLibraryApiTests.Query(client, $"sort={sort}&order={order}"); + var paged = new List(); + for (var offset = 0; offset < saved.Count; offset += 5) + paged.AddRange((await DrawingLibraryApiTests.Query(client, $"sort={sort}&order={order}&offset={offset}&limit=5")).Items.Select(r => r.Id)); + + Assert.Equal(expected, full.Items.Select(r => r.Id)); + Assert.Equal(expected, paged); + Assert.Equal(saved.Count, full.Total); + } + + [Fact] + public async Task DefaultOrder_IsNewestSavedFirst() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var first = await Upload(client, Record("First"), Package("1")); + var second = await Upload(client, Record("Second"), Package("2")); + + var page = await DrawingLibraryApiTests.Query(client, ""); + + var expected = new[] { first, second } + .OrderByDescending(r => r.SavedAt.ToString("o"), StringComparer.Ordinal) + .ThenByDescending(r => r.Id.ToString(), StringComparer.Ordinal) + .Select(r => r.Id); + Assert.Equal(expected, page.Items.Select(r => r.Id)); + } + + [Fact] + public async Task Limit_BoundsThePageAndTotalCountsEveryMatch() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + for (var i = 0; i < 3; i++) + await Upload(client, Record($"Drawing {i}"), Package(i.ToString())); + + var page = await DrawingLibraryApiTests.Query(client, "limit=2&offset=1"); + var maximum = await DrawingLibraryApiTests.Query(client, "limit=500"); + var beyond = await DrawingLibraryApiTests.Query(client, "offset=2147483647"); + + Assert.Equal(2, page.Items.Count); + Assert.Equal(3, page.Total); + Assert.Equal(1, page.Offset); + Assert.Equal(2, page.Limit); + Assert.Equal(3, maximum.Items.Count); + Assert.Empty(beyond.Items); + Assert.Equal(3, beyond.Total); + } + + [Theory] + [InlineData("unknown=1")] + [InlineData("limit=0")] + [InlineData("limit=501")] + [InlineData("limit=-1")] + [InlineData("offset=-1")] + [InlineData("offset=x")] + [InlineData("sort=1")] + [InlineData("sort=package")] + [InlineData("sort=name%20desc")] + [InlineData("order=up")] + [InlineData("cutType=1")] + [InlineData("cutType=Laser,Plasma")] + [InlineData("cutType=oxyfuel")] + [InlineData("cutType=")] + [InlineData("cutType=laser&cutType=plasma")] + [InlineData("thickness=-1")] + [InlineData("thickness=NaN")] + [InlineData("thickness=Infinity")] + [InlineData("thickness=1e3")] + [InlineData("thickness=")] + [InlineData("includeArchived=yes")] + [InlineData("search=a%00b")] + [InlineData("customer=a%00b")] + public async Task InvalidParameters_AreRejected(string query) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync("/api/drawings/query?" + query); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task OverlongSearchOrFilter_IsRejected() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + + using var search = await client.GetAsync("/api/drawings/query?search=" + new string('s', 201)); + using var customer = await client.GetAsync("/api/drawings/query?customer=" + new string('c', 201)); + using var boundary = await client.GetAsync("/api/drawings/query?search=" + new string('s', 200)); + + Assert.Equal(HttpStatusCode.BadRequest, search.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, customer.StatusCode); + Assert.Equal(HttpStatusCode.OK, boundary.StatusCode); + } + + /// Independent oracle: ASCII case-folded text or numeric value, then id, in one direction. + private static IEnumerable Oracle(List records, string sort, bool descending) + { + Func key = sort switch + { + "name" => r => r.Name.ToUpperInvariant(), + "customer" => r => r.Customer.ToUpperInvariant(), + "materialName" => r => r.MaterialName.ToUpperInvariant(), + "materialGrade" => r => r.MaterialGrade.ToUpperInvariant(), + "thickness" => r => r.Thickness, + "cutType" => r => r.CutType.ToString(), + "revision" => r => r.Revision, + _ => throw new ArgumentOutOfRangeException(nameof(sort)), + }; + var comparer = Comparer.Create((a, b) => + a is string x && b is string y ? string.CompareOrdinal(x, y) : a.CompareTo(b)); + return descending + ? records.OrderByDescending(key, comparer).ThenByDescending(r => r.Id.ToString(), StringComparer.Ordinal) + : records.OrderBy(key, comparer).ThenBy(r => r.Id.ToString(), StringComparer.Ordinal); + } +} diff --git a/OpenNest.Server/DrawingLibraryRequest.cs b/OpenNest.Server/DrawingLibraryRequest.cs new file mode 100644 index 00000000..b5c634ad --- /dev/null +++ b/OpenNest.Server/DrawingLibraryRequest.cs @@ -0,0 +1,199 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenNest; +using OpenNest.Data; + +namespace OpenNest.Server; + +/// +/// Parses drawing library requests. Unknown, repeated or out-of-range values are +/// rejected rather than ignored or clamped, and enum fields accept names only. +/// +internal static class DrawingLibraryRequest +{ + private static readonly string[] QueryKeys = + { + "search", "customer", "materialName", "materialGrade", "thickness", "cutType", + "includeArchived", "sort", "order", "offset", "limit", + }; + + // Integer enum values are refused; names are additionally checked before deserializing. + private static readonly JsonSerializerOptions MetadataOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false) }, + }; + + public static bool TryParseQuery(IQueryCollection values, out DrawingLibraryQuery query, out string error) + { + query = new DrawingLibraryQuery(); + foreach (var key in values.Keys) + { + if (!QueryKeys.Contains(key, StringComparer.OrdinalIgnoreCase)) + { + error = $"Unknown query parameter '{key}'."; + return false; + } + + if (values[key].Count > 1) + { + error = $"Query parameter '{key}' was given more than once."; + return false; + } + } + + var sort = DrawingSortField.SavedAt; + if (values.TryGetValue("sort", out var sortText) && !TryEnumName(sortText.ToString(), out sort)) + { + error = $"sort must be one of: {string.Join(", ", Enum.GetNames().Select(CamelCase))}."; + return false; + } + + var descending = true; + if (values.TryGetValue("order", out var orderText)) + { + if (string.Equals(orderText, "asc", StringComparison.OrdinalIgnoreCase)) + { + descending = false; + } + else if (!string.Equals(orderText, "desc", StringComparison.OrdinalIgnoreCase)) + { + error = "order must be asc or desc."; + return false; + } + } + + CutType? cutType = null; + if (values.TryGetValue("cutType", out var cutTypeText)) + { + if (!TryEnumName(cutTypeText.ToString(), out var parsed)) + { + error = "cutType must be Laser, Waterjet or Plasma."; + return false; + } + + cutType = parsed; + } + + double? thickness = null; + if (values.TryGetValue("thickness", out var thicknessText)) + { + if (!double.TryParse(thicknessText.ToString(), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var parsed) + || !double.IsFinite(parsed)) + { + error = "thickness must be a finite number, zero or greater."; + return false; + } + + thickness = parsed; + } + + var includeArchived = false; + if (values.TryGetValue("includeArchived", out var archivedText) && !bool.TryParse(archivedText.ToString(), out includeArchived)) + { + error = "includeArchived must be true or false."; + return false; + } + + var offset = 0; + var limit = DrawingLibraryQuery.DefaultLimit; + if (!TryInteger(values, "offset", ref offset, out error) || !TryInteger(values, "limit", ref limit, out error)) + return false; + + query = new DrawingLibraryQuery + { + Search = values["search"].ToString(), + Customer = values["customer"].ToString(), + MaterialName = values["materialName"].ToString(), + MaterialGrade = values["materialGrade"].ToString(), + Thickness = thickness, + CutType = cutType, + IncludeArchived = includeArchived, + Sort = sort, + Descending = descending, + Offset = offset, + Limit = limit, + }; + error = query.GetValidationError() ?? ""; + return error.Length == 0; + } + + /// Reads and validates one metadata JSON object. + public static bool TryReadMetadata(string? json, out DrawingLibraryRecord record, out string error) + { + record = new DrawingLibraryRecord(); + if (string.IsNullOrWhiteSpace(json)) + { + error = "Missing metadata."; + return false; + } + + try + { + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + error = "Metadata must be a JSON object."; + return false; + } + + // Enum converters also accept comma-separated combinations; allow single names only. + foreach (var property in document.RootElement.EnumerateObject()) + { + if (string.Equals(property.Name, "cutType", StringComparison.OrdinalIgnoreCase)) + { + if (property.Value.ValueKind != JsonValueKind.String || !TryEnumName(property.Value.GetString()!, out _)) + { + error = "cutType must be Laser, Waterjet or Plasma."; + return false; + } + } + else if (string.Equals(property.Name, "units", StringComparison.OrdinalIgnoreCase)) + { + if (property.Value.ValueKind != JsonValueKind.String || !TryEnumName(property.Value.GetString()!, out _)) + { + error = "units must be a supported unit name."; + return false; + } + } + } + + record = document.RootElement.Deserialize(MetadataOptions) ?? record; + } + catch (JsonException) + { + error = "Invalid metadata JSON."; + return false; + } + + error = DrawingLibraryRules.GetMetadataError(record) ?? ""; + return error.Length == 0; + } + + // Names only: Enum.TryParse would also accept numbers, combinations and undefined values. + private static bool TryEnumName(string text, out TEnum value) + where TEnum : struct, Enum + { + value = default; + return text.Length > 0 + && text.All(char.IsAsciiLetter) + && Enum.TryParse(text, ignoreCase: true, out value) + && Enum.IsDefined(value); + } + + private static string CamelCase(string name) => char.ToLowerInvariant(name[0]) + name[1..]; + + private static bool TryInteger(IQueryCollection values, string key, ref int value, out string error) + { + error = ""; + if (!values.TryGetValue(key, out var text)) + return true; + + if (int.TryParse(text.ToString(), NumberStyles.None, CultureInfo.InvariantCulture, out value)) + return true; + + error = $"{key} must be a non-negative integer."; + return false; + } +} diff --git a/OpenNest.Server/DrawingLibraryRoutes.cs b/OpenNest.Server/DrawingLibraryRoutes.cs new file mode 100644 index 00000000..de7c2564 --- /dev/null +++ b/OpenNest.Server/DrawingLibraryRoutes.cs @@ -0,0 +1,162 @@ +using System.Globalization; +using OpenNest.Data; + +namespace OpenNest.Server; + +/// +/// /api/drawings: the shared drawing library. Geometry travels as an opaque +/// drawing package; existing-drawing writes follow the nest If-Match contract +/// (428 missing, 400 malformed, 409 stale or wrong state with the current record). +/// +internal static class DrawingLibraryRoutes +{ + // Metadata is a few kilobytes at most (notes are limited to 4000 characters). + private const int MaxMetadataBytes = 64 * 1024; + + public static void MapDrawingLibrary(this IEndpointRouteBuilder app) + { + app.MapGet("/api/drawings/query", (HttpRequest request, NestDatabase db) => + DrawingLibraryRequest.TryParseQuery(request.Query, out var query, out var error) + ? Results.Ok(db.QueryDrawings(query)) + : Results.BadRequest(error)); + + app.MapGet("/api/drawings/{id:guid}", (Guid id, NestDatabase db) => + db.GetDrawing(id) is { } record ? Results.Ok(record) : Results.NotFound()); + + app.MapGet("/api/drawings/{id:guid}/file", (Guid id, NestDatabase db) => + db.GetDrawingPackage(id) is { } package + ? Results.File(package, "application/zip", $"{id}.nest") + : Results.NotFound()); + + app.MapGet("/api/drawings/{id:guid}/revisions", (Guid id, HttpRequest request, NestDatabase db) => + ReadHistory(id, request, db)); + + app.MapGet("/api/drawings/{id:guid}/revisions/{revision:int}/file", (Guid id, int revision, NestDatabase db) => + revision < 1 ? Results.BadRequest("Invalid revision.") : + db.GetDrawingRevisionPackage(id, revision) is { } package + ? Results.File(package, "application/zip", $"{id}-r{revision.ToString(CultureInfo.InvariantCulture)}.nest") + : Results.NotFound()); + + // Multipart: "metadata" JSON part + "package" part. The client allocates the id. + app.MapPost("/api/drawings", async (HttpContext http, NestDatabase db) => + { + var (record, package, error) = await ReadUpload(http); + if (error is not null) return error; + if (record!.Id == Guid.Empty) + return Results.BadRequest("id is required: allocate it before the first upload."); + try { return Results.Ok(db.InsertDrawing(record.Id, record, package!)); } + catch (DrawingConflictException conflict) { return Conflict(conflict); } + }); + + app.MapPut("/api/drawings/{id:guid}/file", async (Guid id, HttpContext http, NestDatabase db) => + { + var precondition = IfMatch.Read(http.Request, out var expected); + if (precondition is not null) return precondition; + var (record, package, error) = await ReadUpload(http); + if (error is not null) return error; + return Update(id, record!, package, expected, db); + }); + + app.MapPut("/api/drawings/{id:guid}/metadata", async (Guid id, HttpRequest request, NestDatabase db) => + { + var precondition = IfMatch.Read(request, out var expected); + if (precondition is not null) return precondition; + if (!request.HasJsonContentType()) + return Results.BadRequest("Expected application/json metadata."); + using var reader = new StreamReader(request.Body); + var json = await reader.ReadToEndAsync(request.HttpContext.RequestAborted); + if (json.Length > MaxMetadataBytes) + return Results.BadRequest("Metadata is too large."); + if (!DrawingLibraryRequest.TryReadMetadata(json, out var record, out var error)) + return Results.BadRequest(error); + return Update(id, record, null, expected, db); + }); + + app.MapPost("/api/drawings/{id:guid}/archive", (Guid id, HttpRequest request, NestDatabase db) => + SetArchived(id, true, request, db)); + + app.MapPost("/api/drawings/{id:guid}/restore", (Guid id, HttpRequest request, NestDatabase db) => + SetArchived(id, false, request, db)); + } + + private static IResult Update(Guid id, DrawingLibraryRecord record, byte[]? package, int expected, NestDatabase db) + { + if (record.Id != Guid.Empty && record.Id != id) + return Results.BadRequest("Metadata id does not match the drawing."); + try + { + var updated = db.UpdateDrawing(id, record, package, expected); + return updated is null ? Results.NotFound() : Results.Ok(updated); + } + catch (DrawingConflictException conflict) { return Conflict(conflict); } + } + + private static IResult SetArchived(Guid id, bool archived, HttpRequest request, NestDatabase db) + { + var precondition = IfMatch.Read(request, out var expected); + if (precondition is not null) return precondition; + try + { + var changed = db.SetDrawingArchived(id, archived, expected); + return changed is null ? Results.NotFound() : Results.Ok(changed); + } + catch (DrawingConflictException conflict) { return Conflict(conflict); } + } + + private static IResult Conflict(DrawingConflictException conflict) => + Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict); + + private static IResult ReadHistory(Guid id, HttpRequest request, NestDatabase db) + { + var offset = 0; + var limit = DrawingLibraryRules.DefaultRevisionLimit; + foreach (var parameter in request.Query) + { + if (parameter.Value.Count != 1 + || !int.TryParse(parameter.Value[0], NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var value)) + return Results.BadRequest("Invalid history pagination."); + switch (parameter.Key) + { + case "offset": offset = value; break; + case "limit": limit = value; break; + default: return Results.BadRequest("Unknown history parameter."); + } + } + if (offset < 0 || limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit) + return Results.BadRequest("Invalid history pagination."); + return db.DrawingHistory(id, offset, limit) is { } page ? Results.Ok(page) : Results.NotFound(); + } + + // Returns (record, package, error); the package is checked before it is buffered. + private static async Task<(DrawingLibraryRecord? Record, byte[]? Package, IResult? Error)> ReadUpload(HttpContext http) + { + if (!http.Request.HasFormContentType) + return (null, null, Results.BadRequest("Expected multipart/form-data with metadata and package parts.")); + + IFormCollection form; + try + { + form = await http.Request.ReadFormAsync(); + } + catch (Exception ex) when (ex is InvalidDataException || (ex is IOException && ex is not BadHttpRequestException)) + { + return (null, null, Results.BadRequest("Malformed multipart body.")); + } + + if (!DrawingLibraryRequest.TryReadMetadata(form["metadata"].FirstOrDefault(), out var record, out var error)) + return (null, null, Results.BadRequest(error)); + + var packagePart = form.Files["package"]; + if (packagePart is null || packagePart.Length == 0) + return (null, null, Results.BadRequest("Missing or empty 'package' part.")); + if (packagePart.Length > DrawingLibraryRules.MaxPackageBytes) + return (null, null, Results.BadRequest(DrawingLibraryRules.PackageTooLargeError)); + + using var stream = packagePart.OpenReadStream(); + using var buffer = new MemoryStream((int)packagePart.Length); + await stream.CopyToAsync(buffer); + var package = buffer.ToArray(); + var packageError = DrawingLibraryRules.GetPackageError(package); + return packageError is null ? (record, package, null) : (null, null, Results.BadRequest(packageError)); + } +} diff --git a/OpenNest.Server/IfMatch.cs b/OpenNest.Server/IfMatch.cs new file mode 100644 index 00000000..cae6fa3d --- /dev/null +++ b/OpenNest.Server/IfMatch.cs @@ -0,0 +1,22 @@ +namespace OpenNest.Server; + +/// The revision precondition every existing-record mutation requires. +internal static class IfMatch +{ + /// + /// Reads one quoted positive revision. Returns 428 when the header is missing and + /// 400 when it is malformed; null means holds the value. + /// + public static IResult? Read(HttpRequest request, out int revision) + { + revision = 0; + var values = request.Headers.IfMatch; + if (values.Count == 0) return Results.Json("Upgrade/reload required: send If-Match with the current revision.", statusCode: 428); + var text = values.Count == 1 ? values[0] : null; + if (text is null || text.Length < 3 || text[0] != '"' || text[^1] != '"' || + !text[1..^1].All(c => c is >= '0' and <= '9') || + !int.TryParse(text[1..^1], out revision) || revision < 1) + return Results.BadRequest("If-Match must contain one quoted positive integer revision."); + return null; + } +} diff --git a/OpenNest.Server/NestDatabase.Drawings.cs b/OpenNest.Server/NestDatabase.Drawings.cs new file mode 100644 index 00000000..36a20d9b --- /dev/null +++ b/OpenNest.Server/NestDatabase.Drawings.cs @@ -0,0 +1,339 @@ +using System.Security.Cryptography; +using Microsoft.Data.Sqlite; +using OpenNest; +using OpenNest.Data; + +namespace OpenNest.Server; + +/// +/// Drawing library storage: drawings holds each drawing's current revision and +/// drawing_revisions every immutable revision, both in the nest database file +/// and under the same monitor. Every change appends a revision in one transaction. +/// +public sealed partial class NestDatabase +{ + // Schema v4 is additive: the nest tables are untouched. + private const string DrawingColumnDefinitions = """ + id TEXT NOT NULL, + revision INTEGER NOT NULL, + name TEXT NOT NULL, + customer TEXT NOT NULL DEFAULT '', + nameKey TEXT NOT NULL, + customerKey TEXT NOT NULL DEFAULT '', + materialName TEXT NOT NULL DEFAULT '', + materialKey TEXT NOT NULL DEFAULT '', + materialGrade TEXT NOT NULL DEFAULT '', + gradeKey TEXT NOT NULL DEFAULT '', + thickness REAL NOT NULL, + units TEXT NOT NULL, + cutType TEXT NOT NULL, + notes TEXT NOT NULL DEFAULT '', + archived INTEGER NOT NULL DEFAULT 0, + packageSha256 TEXT NOT NULL, + packageSize INTEGER NOT NULL, + savedAt TEXT NOT NULL, + savedBy TEXT NOT NULL DEFAULT '', + package BLOB NOT NULL + """; + + private const string DrawingLibrarySchema = $""" + CREATE TABLE drawings ({DrawingColumnDefinitions}, PRIMARY KEY (id)); + CREATE TABLE drawing_revisions ({DrawingColumnDefinitions}); + CREATE UNIQUE INDEX ix_drawing_revisions_key ON drawing_revisions(id, revision); + CREATE INDEX ix_drawings_customer_name ON drawings(customerKey, nameKey); + CREATE INDEX ix_drawings_material ON drawings(materialKey); + CREATE INDEX ix_drawings_thickness ON drawings(thickness); + CREATE INDEX ix_drawings_savedAt ON drawings(savedAt DESC); + PRAGMA user_version=4; + """; + + private const string DrawingColumns = """ + id, revision, name, customer, materialName, materialGrade, thickness, units, + cutType, notes, archived, packageSha256, packageSize, savedAt, savedBy + """; + + private const string DrawingStoredColumns = DrawingColumns + ", nameKey, customerKey, materialKey, gradeKey, package"; + + // Case-insensitive (ASCII) substring; the text is always a bound, escaped LIKE parameter. + private const string DrawingSearchPredicate = """ + name LIKE $pattern ESCAPE '\' OR customer LIKE $pattern ESCAPE '\' OR notes LIKE $pattern ESCAPE '\' + """; + + public DrawingLibraryRecord? GetDrawing(Guid id) + { + lock (_sync) + { + using var command = _connection.CreateCommand(); + command.CommandText = $"SELECT {DrawingColumns} FROM drawings WHERE id=$id"; + command.Parameters.AddWithValue("$id", id.ToString()); + using var reader = command.ExecuteReader(); + return reader.Read() ? ReadDrawing(reader) : null; + } + } + + public byte[]? GetDrawingPackage(Guid id) + { + lock (_sync) + { + using var command = _connection.CreateCommand(); + command.CommandText = "SELECT package FROM drawings WHERE id=$id"; + command.Parameters.AddWithValue("$id", id.ToString()); + return command.ExecuteScalar() as byte[]; + } + } + + /// One bounded page of matching drawings plus the total, read in one monitor hold. + public DrawingLibraryPage QueryDrawings(DrawingLibraryQuery query) + { + ArgumentNullException.ThrowIfNull(query); + var error = query.GetValidationError(); + if (error is not null) + throw new ArgumentException(error, nameof(query)); + + var conditions = new List(); + if (!query.IncludeArchived) conditions.Add("archived=0"); + if (query.NormalizedSearch.Length > 0) conditions.Add("(" + DrawingSearchPredicate + ")"); + if (SharedListNames.Key(query.Customer).Length > 0) conditions.Add("customerKey=$customer"); + if (SharedListNames.Key(query.MaterialName).Length > 0) conditions.Add("materialKey=$material"); + if (SharedListNames.Key(query.MaterialGrade).Length > 0) conditions.Add("gradeKey=$grade"); + if (query.Thickness is not null) conditions.Add("thickness=$thickness"); + if (query.CutType is not null) conditions.Add("cutType=$cutType"); + var where = conditions.Count == 0 ? "" : "WHERE " + string.Join(" AND ", conditions); + + lock (_sync) + { + using var count = _connection.CreateCommand(); + count.CommandText = $"SELECT COUNT(*) FROM drawings {where}"; + AddDrawingFilterParameters(count, query); + var total = checked((int)(long)count.ExecuteScalar()!); + + using var command = _connection.CreateCommand(); + var direction = query.Descending ? "DESC" : "ASC"; + command.CommandText = $""" + SELECT {DrawingColumns} FROM drawings {where} + ORDER BY {DrawingSortColumn(query.Sort)} {direction}, id {direction} + LIMIT $limit OFFSET $offset + """; + AddDrawingFilterParameters(command, query); + command.Parameters.AddWithValue("$limit", query.Limit); + command.Parameters.AddWithValue("$offset", query.Offset); + var records = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + records.Add(ReadDrawing(reader)); + return new DrawingLibraryPage { Items = records, Total = total, Offset = query.Offset, Limit = query.Limit }; + } + } + + /// Stores revision 1 under a client-allocated id; an existing id is a conflict. + public DrawingLibraryRecord InsertDrawing(Guid id, DrawingLibraryRecord record, byte[] package) + { + if (id == Guid.Empty) throw new ArgumentException("A drawing id is required.", nameof(id)); + ValidateDrawing(record, package); + lock (_sync) + { + using var transaction = _connection.BeginTransaction(); + if (GetDrawing(id) is { } existing) + throw new DrawingConflictException(existing, "A library drawing with this id already exists."); + WriteDrawingRevision(id, record, package, 1, archived: false, insert: true, transaction); + transaction.Commit(); + return GetDrawing(id) ?? throw new InvalidOperationException("Insert did not persist."); + } + } + + /// + /// Appends a revision with new metadata and, when is not null, + /// new geometry. Returns null for an unknown id; an identical save is a no-op. + /// + public DrawingLibraryRecord? UpdateDrawing(Guid id, DrawingLibraryRecord record, byte[]? package, int expectedRevision) + { + lock (_sync) + { + using var transaction = _connection.BeginTransaction(); + var current = GetDrawing(id); + if (current is null) return null; + if (current.Revision != expectedRevision) + throw new DrawingConflictException(current, "The library drawing has changed. Reload before saving."); + if (current.Archived) + throw new DrawingConflictException(current, "The library drawing is archived. Restore it before saving."); + package ??= GetDrawingPackage(id)!; + ValidateDrawing(record, package); + if (SameDrawingMetadata(current, record) && GetDrawingPackage(id)!.AsSpan().SequenceEqual(package)) + return current; + + WriteDrawingRevision(id, record, package, checked(current.Revision + 1), archived: false, insert: false, transaction); + transaction.Commit(); + return GetDrawing(id); + } + } + + /// Archives or restores a drawing as a new revision. Returns null for an unknown id. + public DrawingLibraryRecord? SetDrawingArchived(Guid id, bool archived, int expectedRevision) + { + lock (_sync) + { + using var transaction = _connection.BeginTransaction(); + var current = GetDrawing(id); + if (current is null) return null; + if (current.Revision != expectedRevision) + throw new DrawingConflictException(current, "The library drawing has changed. Reload and try again."); + if (current.Archived == archived) + throw new DrawingConflictException(current, + archived ? "The library drawing is already archived." : "The library drawing is not archived."); + + WriteDrawingRevision(id, current, GetDrawingPackage(id)!, checked(current.Revision + 1), archived, insert: false, transaction); + transaction.Commit(); + return GetDrawing(id); + } + } + + public DrawingLibraryPage? DrawingHistory(Guid id, int offset, int limit) + { + if (offset < 0 || limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit) + throw new ArgumentOutOfRangeException(nameof(limit)); + lock (_sync) + { + if (GetDrawing(id) is null) return null; + using var count = _connection.CreateCommand(); + count.CommandText = "SELECT COUNT(*) FROM drawing_revisions WHERE id=$id"; + count.Parameters.AddWithValue("$id", id.ToString()); + var total = checked((int)(long)count.ExecuteScalar()!); + using var command = _connection.CreateCommand(); + command.CommandText = $"SELECT {DrawingColumns} FROM drawing_revisions WHERE id=$id ORDER BY revision DESC LIMIT $limit OFFSET $offset"; + command.Parameters.AddWithValue("$id", id.ToString()); + command.Parameters.AddWithValue("$limit", limit); + command.Parameters.AddWithValue("$offset", offset); + var records = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) records.Add(ReadDrawing(reader)); + return new DrawingLibraryPage { Items = records, Total = total, Offset = offset, Limit = limit }; + } + } + + public byte[]? GetDrawingRevisionPackage(Guid id, int revision) + { + lock (_sync) + { + using var command = _connection.CreateCommand(); + command.CommandText = "SELECT package FROM drawing_revisions WHERE id=$id AND revision=$revision"; + command.Parameters.AddWithValue("$id", id.ToString()); + command.Parameters.AddWithValue("$revision", revision); + return command.ExecuteScalar() as byte[]; + } + } + + private static void ValidateDrawing(DrawingLibraryRecord record, byte[] package) + { + var error = DrawingLibraryRules.GetMetadataError(record) ?? DrawingLibraryRules.GetPackageError(package); + if (error is not null) + throw new ArgumentException(error, nameof(record)); + } + + private static bool SameDrawingMetadata(DrawingLibraryRecord a, DrawingLibraryRecord b) => + a.Name == b.Name && a.Customer == b.Customer && a.MaterialName == b.MaterialName && + a.MaterialGrade == b.MaterialGrade && a.Thickness == DrawingLibraryRules.NormalizeThickness(b.Thickness) && + a.Units == b.Units && a.CutType == b.CutType && a.Notes == b.Notes; + + // Called only under _sync with the caller's transaction; promotion and snapshot are one write. + private void WriteDrawingRevision(Guid id, DrawingLibraryRecord record, byte[] package, int revision, + bool archived, bool insert, SqliteTransaction transaction) + { + using var command = _connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = insert + ? $""" + INSERT INTO drawings ({DrawingStoredColumns}) + VALUES ($id, $revision, $name, $customer, $materialName, $materialGrade, $thickness, $units, + $cutType, $notes, $archived, $sha, $size, $savedAt, $savedBy, + $nameKey, $customerKey, $materialKey, $gradeKey, $package) + """ + : """ + UPDATE drawings SET revision=$revision, name=$name, customer=$customer, + materialName=$materialName, materialGrade=$materialGrade, thickness=$thickness, + units=$units, cutType=$cutType, notes=$notes, archived=$archived, packageSha256=$sha, + packageSize=$size, savedAt=$savedAt, savedBy=$savedBy, nameKey=$nameKey, + customerKey=$customerKey, materialKey=$materialKey, gradeKey=$gradeKey, package=$package + WHERE id=$id + """; + command.Parameters.AddWithValue("$id", id.ToString()); + command.Parameters.AddWithValue("$revision", revision); + command.Parameters.AddWithValue("$name", record.Name); + command.Parameters.AddWithValue("$customer", record.Customer); + command.Parameters.AddWithValue("$materialName", record.MaterialName); + command.Parameters.AddWithValue("$materialGrade", record.MaterialGrade); + command.Parameters.AddWithValue("$thickness", DrawingLibraryRules.NormalizeThickness(record.Thickness)); + command.Parameters.AddWithValue("$units", record.Units.ToString()); + command.Parameters.AddWithValue("$cutType", record.CutType.ToString()); + command.Parameters.AddWithValue("$notes", record.Notes); + command.Parameters.AddWithValue("$archived", archived ? 1 : 0); + command.Parameters.AddWithValue("$sha", Convert.ToHexString(SHA256.HashData(package)).ToLowerInvariant()); + command.Parameters.AddWithValue("$size", package.LongLength); + command.Parameters.AddWithValue("$savedAt", DateTime.Now.ToString("o")); + command.Parameters.AddWithValue("$savedBy", record.SavedBy); + command.Parameters.AddWithValue("$nameKey", SharedListNames.Key(record.Name)); + command.Parameters.AddWithValue("$customerKey", SharedListNames.Key(record.Customer)); + command.Parameters.AddWithValue("$materialKey", SharedListNames.Key(record.MaterialName)); + command.Parameters.AddWithValue("$gradeKey", SharedListNames.Key(record.MaterialGrade)); + command.Parameters.AddWithValue("$package", package); + command.ExecuteNonQuery(); + + command.Parameters.Clear(); + command.CommandText = $"INSERT INTO drawing_revisions ({DrawingStoredColumns}) SELECT {DrawingStoredColumns} FROM drawings WHERE id=$id"; + command.Parameters.AddWithValue("$id", id.ToString()); + command.ExecuteNonQuery(); + } + + private static void AddDrawingFilterParameters(SqliteCommand command, DrawingLibraryQuery query) + { + AddSearchParameter(command, query.NormalizedSearch); + if (SharedListNames.Key(query.Customer).Length > 0) + command.Parameters.AddWithValue("$customer", SharedListNames.Key(query.Customer)); + if (SharedListNames.Key(query.MaterialName).Length > 0) + command.Parameters.AddWithValue("$material", SharedListNames.Key(query.MaterialName)); + if (SharedListNames.Key(query.MaterialGrade).Length > 0) + command.Parameters.AddWithValue("$grade", SharedListNames.Key(query.MaterialGrade)); + if (query.Thickness is { } thickness) + command.Parameters.AddWithValue("$thickness", DrawingLibraryRules.NormalizeThickness(thickness)); + if (query.CutType is { } cutType) + command.Parameters.AddWithValue("$cutType", cutType.ToString()); + } + + // Fixed allowlist: request text never reaches the ORDER BY clause. + private static string DrawingSortColumn(DrawingSortField field) => field switch + { + DrawingSortField.SavedAt => "savedAt", + DrawingSortField.Name => "name COLLATE NOCASE", + DrawingSortField.Customer => "customer COLLATE NOCASE", + DrawingSortField.MaterialName => "materialName COLLATE NOCASE", + DrawingSortField.MaterialGrade => "materialGrade COLLATE NOCASE", + DrawingSortField.Thickness => "thickness", + DrawingSortField.CutType => "cutType", + DrawingSortField.Revision => "revision", + _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort column."), + }; + + private static DrawingLibraryRecord ReadDrawing(SqliteDataReader reader) => new() + { + Id = Guid.Parse(reader.GetString(0)), + Revision = reader.GetInt32(1), + Name = reader.GetString(2), + Customer = reader.GetString(3), + MaterialName = reader.GetString(4), + MaterialGrade = reader.GetString(5), + Thickness = reader.GetDouble(6), + Units = Enum.Parse(reader.GetString(7)), + CutType = Enum.Parse(reader.GetString(8)), + Notes = reader.GetString(9), + Archived = reader.GetInt32(10) != 0, + PackageSha256 = reader.GetString(11), + PackageSize = reader.GetInt64(12), + SavedAt = DateTime.Parse(reader.GetString(13)), + SavedBy = reader.GetString(14), + }; +} + +/// A guarded drawing write found a different current state; nothing was changed. +public sealed class DrawingConflictException(DrawingLibraryRecord current, string message) : Exception(message) +{ + public DrawingLibraryRecord Current { get; } = current; +} diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index 22d116c6..25534646 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -9,7 +9,7 @@ namespace OpenNest.Server; /// so a PC that loses its local id mapping can still re-upload under a new id; /// updates keep the id. /// -public sealed class NestDatabase : IDisposable +public sealed partial class NestDatabase : IDisposable { // One reentrant monitor owns each complete connection operation, including // readers, nested Get readbacks, health checks, and disposal. @@ -316,7 +316,7 @@ public sealed class NestDatabase : IDisposable command.Transaction = transaction; command.CommandText = "PRAGMA user_version"; var version = (long)command.ExecuteScalar()!; - if (version > 3) throw new InvalidOperationException("Database schema is newer than this server."); + if (version > 4) throw new InvalidOperationException("Database schema is newer than this server."); if (version == 0) { command.CommandText = """ @@ -346,6 +346,11 @@ public sealed class NestDatabase : IDisposable """; command.ExecuteNonQuery(); } + if (version < 4) + { + command.CommandText = DrawingLibrarySchema; + command.ExecuteNonQuery(); + } transaction.Commit(); } } diff --git a/OpenNest.Server/Program.cs b/OpenNest.Server/Program.cs index b78629e9..eebe89ba 100644 --- a/OpenNest.Server/Program.cs +++ b/OpenNest.Server/Program.cs @@ -70,7 +70,7 @@ app.MapGet("/api/nests/{id:guid}/revisions/{revision:int}/file", (Guid id, int r app.MapPut("/api/nests/{id:guid}/file", async (Guid id, HttpContext http, NestDatabase db) => { - var precondition = ReadPrecondition(http.Request, out var expected); + var precondition = IfMatch.Read(http.Request, out var expected); if (precondition is not null) return precondition; var (record, file, error) = await ReadMultipart(http); if (error is not null) return error; @@ -87,7 +87,7 @@ app.MapPut("/api/nests/{id:guid}/file", async (Guid id, HttpContext http, NestDa app.MapPut("/api/nests/{id:guid}/metadata", async (Guid id, HttpRequest request, NestDatabase db) => { - var error = ReadPrecondition(request, out var expected); + var error = IfMatch.Read(request, out var expected); if (error is not null) return error; NestRecord? record; try { record = await request.ReadFromJsonAsync(); } @@ -120,7 +120,7 @@ app.MapGet("/api/nests/deleted/{id:guid}/revisions/{revision:int}/file", (Guid i app.MapPost("/api/nests/{id:guid}/revisions/{revision:int}/restore", (Guid id, int revision, HttpRequest request, NestDatabase db) => { - var error = ReadPrecondition(request, out var expected); + var error = IfMatch.Read(request, out var expected); if (error is not null) return error; if (revision < 1) return Results.BadRequest("Invalid revision."); try @@ -131,24 +131,13 @@ app.MapPost("/api/nests/{id:guid}/revisions/{revision:int}/restore", (Guid id, i catch (RevisionConflictException conflict) { return Results.Json(conflict.Current, statusCode: 409); } }); -app.Run(); +app.MapDrawingLibrary(); -static IResult? ReadPrecondition(HttpRequest request, out int revision) -{ - revision = 0; - var values = request.Headers.IfMatch; - if (values.Count == 0) return Results.Json("Upgrade/reload required: send If-Match with the current revision.", statusCode: 428); - var text = values.Count == 1 ? values[0] : null; - if (text is null || text.Length < 3 || text[0] != '"' || text[^1] != '"' || - !text[1..^1].All(c => c is >= '0' and <= '9') || - !int.TryParse(text[1..^1], out revision) || revision < 1) - return Results.BadRequest("If-Match must contain one quoted positive integer revision."); - return null; -} +app.Run(); static IResult ChangeVisibility(Guid id, bool deleted, HttpRequest request, NestDatabase db) { - var error = ReadPrecondition(request, out var expected); + var error = IfMatch.Read(request, out var expected); if (error is not null) return error; try {