mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-07 18:55:44 -05:00
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:
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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user