diff --git a/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs b/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs new file mode 100644 index 00000000..3e60bd34 --- /dev/null +++ b/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs @@ -0,0 +1,141 @@ +using OpenNest.CNC; +using OpenNest.Data; +using OpenNest.Geometry; +using OpenNest.IO; +using CncProgram = OpenNest.CNC.Program; + +namespace OpenNest.Server.Tests; + +/// +/// The whole store path: a code-built drawing is packaged, uploaded through the real +/// client to the production routes, found by its filters, downloaded and read back. +/// +public sealed class DrawingLibraryEndToEndTests +{ + private static Drawing SyntheticDrawing(string name, double holeX) + { + var program = new CncProgram(); + program.Codes.Add(new RapidMove(new Vector(0, 0))); + program.Codes.Add(new LinearMove(new Vector(0, 6))); + program.Codes.Add(new LinearMove(new Vector(10, 6))); + program.Codes.Add(new LinearMove(new Vector(10, 0))); + program.Codes.Add(new LinearMove(new Vector(0, 0))); + program.Codes.Add(new RapidMove(new Vector(holeX, 2))); + program.Codes.Add(new LinearMove(new Vector(holeX + 2, 2))); + program.Codes.Add(new LinearMove(new Vector(holeX + 2, 4))); + program.Codes.Add(new LinearMove(new Vector(holeX, 4))); + program.Codes.Add(new LinearMove(new Vector(holeX, 2))); + return new Drawing(name, program) + { + Customer = "Synthetic customer", + Material = new Material("Synthetic Steel", "Grade A"), + }; + } + + private static DrawingLibraryRecord Metadata(Drawing drawing, CutType cutType, double thickness) => new() + { + Id = Guid.NewGuid(), + Name = drawing.Name, + Customer = drawing.Customer, + MaterialName = drawing.Material.Name, + MaterialGrade = drawing.Material.Grade, + Thickness = thickness, + Units = Units.Inches, + CutType = cutType, + Notes = "Synthetic library drawing", + SavedBy = "Synthetic operator", + }; + + [Fact] + public async Task PackagedDrawing_UploadsFindsAndReadsBackExactly() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + using var library = new RemoteDrawingLibrary(client, client.BaseAddress!.ToString()); + var drawing = SyntheticDrawing("Synthetic bracket", 2); + var package = DrawingPackage.Write(drawing, Units.Inches); + var record = Metadata(drawing, CutType.Laser, 0.25); + var decoyCut = Metadata(SyntheticDrawing("Synthetic decoy cut", 3), CutType.Waterjet, 0.25); + var decoyThickness = Metadata(SyntheticDrawing("Synthetic decoy thickness", 4), CutType.Laser, 0.1875); + + var saved = await library.UploadAsync(record, package); + await library.UploadAsync(decoyCut, DrawingPackage.Write(SyntheticDrawing(decoyCut.Name, 3), Units.Inches)); + await library.UploadAsync(decoyThickness, DrawingPackage.Write(SyntheticDrawing(decoyThickness.Name, 4), Units.Inches)); + var page = await library.QueryAsync(new DrawingLibraryQuery + { + CutType = CutType.Laser, + MaterialName = " synthetic STEEL", + MaterialGrade = "grade a", + Thickness = 0.25, + }); + var found = Assert.Single(page.Items); + var downloaded = await library.GetPackageAsync(found.Id); + var contents = DrawingPackage.Read(downloaded!); + + Assert.Equal(record.Id, found.Id); + Assert.Equal(1, saved.Revision); + Assert.Equal(package, downloaded); + Assert.Equal(Units.Inches, contents.Units); + Assert.Empty(contents.Warnings); + Assert.Equal(NestWriter.GetProgramText(drawing.Program), NestWriter.GetProgramText(contents.Drawing.Program)); + Assert.Equal(NestWriter.GetSubProgramsText(drawing.Program), NestWriter.GetSubProgramsText(contents.Drawing.Program)); + Assert.Equal("Synthetic bracket", contents.Drawing.Name); + } + + [Fact] + public async Task StaleUpdate_ConflictsAndLeavesTheCurrentRevision() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + using var library = new RemoteDrawingLibrary(client, client.BaseAddress!.ToString()); + var drawing = SyntheticDrawing("Synthetic bracket", 2); + var record = Metadata(drawing, CutType.Laser, 0.25); + var original = DrawingPackage.Write(drawing, Units.Inches); + await library.UploadAsync(record, original); + var moved = DrawingPackage.Write(SyntheticDrawing(drawing.Name, 5), Units.Inches); + var current = await library.UpdatePackageAsync(record.Id, record, moved, 1); + + var stale = DrawingPackage.Write(SyntheticDrawing(drawing.Name, 6), Units.Inches); + var conflict = await Assert.ThrowsAsync(() => + library.UpdatePackageAsync(record.Id, record, stale, 1)); + + Assert.Equal(2, current.Revision); + Assert.Equal(2, conflict.Current.Revision); + var after = await library.GetAsync(record.Id); + Assert.Equal(2, after!.Revision); + Assert.Equal(current.PackageSha256, after.PackageSha256); + Assert.Equal(moved, await library.GetPackageAsync(record.Id)); + Assert.Equal(original, await library.GetRevisionPackageAsync(record.Id, 1)); + var history = await library.GetRevisionsAsync(record.Id); + Assert.Equal(new[] { 2, 1 }, history!.Items.Select(r => r.Revision)); + } + + [Fact] + public async Task ArchiveRestoreAndMetadata_ThroughTheClient() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + using var library = new RemoteDrawingLibrary(client, client.BaseAddress!.ToString()); + var drawing = SyntheticDrawing("Synthetic bracket", 2); + var record = Metadata(drawing, CutType.Plasma, 0.5); + var package = DrawingPackage.Write(drawing, Units.Inches); + await library.UploadAsync(record, package); + + record.Notes = "Edited notes"; + var edited = await library.UpdateMetadataAsync(record.Id, record, 1); + var archived = await library.ArchiveAsync(record.Id, edited.Revision); + var hidden = await library.QueryAsync(new DrawingLibraryQuery()); + var listed = await library.QueryAsync(new DrawingLibraryQuery { IncludeArchived = true }); + var restored = await library.RestoreAsync(record.Id, archived.Revision); + var duplicate = await Assert.ThrowsAsync(() => library.UploadAsync(record, package)); + + Assert.Equal("Edited notes", edited.Notes); + Assert.True(archived.Archived); + Assert.Empty(hidden.Items); + Assert.Single(listed.Items); + Assert.False(restored.Archived); + Assert.Equal(4, restored.Revision); + Assert.Equal(4, duplicate.Current.Revision); + Assert.Equal(package, await library.GetPackageAsync(record.Id)); + } +} diff --git a/docs/nest-storage.md b/docs/nest-storage.md index 0544d980..d29f796c 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -141,6 +141,64 @@ only the requested page of metadata (never archive bytes): bounds before sending, and reports an HTTP 404 from an older server as a server version that must be updated. +## Drawing library endpoints + +The server also keeps a shared library of drawings (schema v4). The desktop +workflow is not built yet; these routes and `OpenNest.Data.RemoteDrawingLibrary` +are the storage layer for it. A drawing's geometry travels as an opaque +**drawing package**: a `.nest` archive holding exactly one drawing and no plates, +written and read on the client by `OpenNest.IO.DrawingPackage`. The server never +parses it. + +`OpenNest.Data.DrawingLibraryRecord` fields: `id` (client-allocated before the +first upload), `revision`, `name`, `customer`, `materialName`, `materialGrade`, +`thickness` (stored rounded to 4 decimals), `units` (`inches` | `millimeters`), +`cutType` (`laser` | `waterjet` | `plasma`), `notes`, `archived`, `savedBy`, and +the server-computed `packageSha256` (lower-case hex), `packageSize` and `savedAt`. + +| Method | Path | Body | Response | +|---|---|---|---| +| POST | `/api/drawings` | multipart: `metadata` (JSON record with a nonempty `id`) + `package` | Revision 1; 409 with the current record when the id exists | +| GET | `/api/drawings/query?search=&customer=&materialName=&materialGrade=&thickness=&cutType=&includeArchived=&sort=&order=&offset=&limit=` | — | `{ items, total, offset, limit }` | +| GET | `/api/drawings/{id}` / `/{id}/file` | — | Current record / package bytes (archived drawings included), or 404 | +| GET | `/api/drawings/{id}/revisions?offset=0&limit=100` | — | Revision page, newest first; limit 1–200 | +| GET | `/api/drawings/{id}/revisions/{revision}/file` | — | Exact immutable package bytes | +| PUT | `/api/drawings/{id}/file` | multipart: `metadata` + `package` | New revision | +| PUT | `/api/drawings/{id}/metadata` | JSON record | New revision with the same package | +| POST | `/api/drawings/{id}/archive` / `/restore` | — | New revision with `archived` changed | + +- Existing-drawing writes require `If-Match` exactly as for nests: 428 missing, + 400 malformed, 409 stale with the current record. Writing to an archived + drawing, archiving an archived one or restoring an active one is also 409. + None of these change data. There is no delete; archive hides a drawing from + the query unless `includeArchived=true`. +- Every change appends an immutable revision in the same transaction that + updates the current row, including metadata-only edits and archive/restore. + An identical save at the current revision is a no-op. +- Validation (400, no state change): `cutType` and `units` must be single enum + names (no numbers or combinations); `thickness` finite and at least 0; `name` + nonblank; name, customer, material, grade and savedBy at most 200 characters, + notes at most 4000; no NUL characters; package nonempty, at most 16 MiB and + starting with a ZIP signature. Metadata `id` on a PUT must be empty or match + the route. +- Query: `search` is a literal, case-insensitive (ASCII) substring of name, + customer or notes (`%`, `_`, `\` literal). `customer`, `materialName` and + `materialGrade` match exactly after `SharedListNames.Key` normalization + (trimmed, collapsed spaces, case-insensitive); blank means any. `thickness` + matches exactly after rounding to 4 decimals. `sort` is one of `savedAt` + (default), `name`, `customer`, `materialName`, `materialGrade`, `thickness`, + `cutType`, `revision`; ties break by `id`. Paging, `order` and the refusal of + unknown, repeated or out-of-range parameters follow the nest query. +- An older server answers 404 on these routes; the client reports that the + server must be updated and never falls back to another store. + +Schema v4 adds the `drawings` and `drawing_revisions` tables to the same file +and leaves the nest tables untouched. A server older than v4 refuses to open a +v4 database, so rolling back after the upgrade needs the pre-upgrade backup (see +[Backup, restore, and upgrade](#backup-restore-and-upgrade)). The stopped-volume +`tar` backup includes the drawing tables, but `nest_manifest.py` and +`restore_check` do not verify drawings yet. + ## Storage `OpenNest.Server.NestDatabase` uses SQLite (`Microsoft.Data.Sqlite`) with