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 {