test(server): drawing library end to end

Proves Slice 1's contract through the production routes and the real client:
a code-built drawing is packaged, uploaded, found by cut type, material, grade
and thickness among decoys, downloaded and read back with identical program
text; a stale If-Match update raises a conflict and leaves the current
revision, its package hash and the history unchanged. Archive, restore,
metadata edits and duplicate ids are exercised the same way.

Documents the /api/drawings contract, schema v4 and the fact that the backup
manifest and restore check do not yet verify drawings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-16 15:33:03 -04:00
1 parent 0c1eb3ad65
commit 8b63c042b5
2 files changed
+199

No files matched your search

@@ -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;
/// <summary>
/// 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.
/// </summary>
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<DrawingLibraryConflictException>(() =>
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<DrawingLibraryConflictException>(() => 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));
}
}
+58
View File
@@ -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