mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-28 05:51:06 -04:00
Releasing a drawing set makes one nest per material group, so a kit run spans several nests. The server now records which set revision was released, the multiplier and the nests it made, so a run can be tracked across its nests and a nest can say where it came from. The client uploads every nest first and posts the release last: a failure part-way leaves ordinary nests and no release, never a release pointing at missing nests. For the same reason a release is accepted for an older set revision or an archived set, since the nests already exist by then. The release id is client-allocated and a nest belongs to at most one release, so a retried post gets 409 with the release that already holds it. Releases are append-only and read joined to each nest's current metadata, so status and deletion are always current. The tables join schema v7, as the set tables have not shipped yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
369 lines
17 KiB
C#
369 lines
17 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Reflection;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using Microsoft.Data.Sqlite;
|
|
using OpenNest.Data;
|
|
using static OpenNest.Server.Tests.DrawingLibraryFixtures;
|
|
using static OpenNest.Server.Tests.DrawingSetFixtures;
|
|
|
|
namespace OpenNest.Server.Tests;
|
|
|
|
public sealed class SetReleaseTests
|
|
{
|
|
[Fact]
|
|
public void Release_OfAStaleRevision_IsStoredAndJoinsItsSetNameAndNests()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
|
var drawing = AddDrawing(database);
|
|
var set = Set("Kit as released", members: (drawing, 1));
|
|
database.InsertDrawingSet(set.Id, set);
|
|
database.UpdateDrawingSet(set.Id, Set("Kit renamed later", set.Id, (drawing, 3)), 1);
|
|
var first = AddNest(database, "Nest one");
|
|
var second = AddNest(database, "Nest two");
|
|
var release = Release(1, first, second);
|
|
release.Nests[1].Thickness = 0.123456;
|
|
release.Nests[1].Units = Units.Millimeters;
|
|
release.Nests[1].CutType = CutType.Waterjet;
|
|
|
|
var stored = database.InsertSetRelease(set.Id, release)!;
|
|
|
|
Assert.Equal((release.Id, set.Id, 1, "Kit as released", 2), (stored.Id, stored.SetId, stored.SetRevision, stored.SetName, stored.Multiplier));
|
|
Assert.Equal(("Synthetic release", "Synthetic operator"), (stored.Notes, stored.ReleasedBy));
|
|
Assert.NotEqual(default, stored.ReleasedAt);
|
|
Assert.Equal(new[] { first, second }, stored.Nests.Select(n => n.NestId));
|
|
Assert.Equal(("Synthetic Steel 1", "Grade A", 0.1235, Units.Millimeters, CutType.Waterjet),
|
|
(stored.Nests[1].MaterialName, stored.Nests[1].MaterialGrade, stored.Nests[1].Thickness, stored.Nests[1].Units, stored.Nests[1].CutType));
|
|
Assert.Equal(("Nest one", NestStatus.Draft, false), (stored.Nests[0].Nest!.Name, stored.Nests[0].Nest!.Status, stored.Nests[0].Nest!.Deleted));
|
|
Assert.Equal(release.Id, database.ReleaseForNest(second)!.Id);
|
|
Assert.Null(database.ReleaseForNest(AddNest(database)));
|
|
}
|
|
|
|
[Fact]
|
|
public void JoinedNests_FollowStatusChangesAndDeletion()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
|
var set = Set();
|
|
database.InsertDrawingSet(set.Id, set);
|
|
var cut = AddNest(database, "Cut nest");
|
|
var removed = AddNest(database, "Removed nest");
|
|
database.InsertSetRelease(set.Id, Release(1, cut, removed));
|
|
|
|
var record = database.Get(cut)!;
|
|
record.Status = NestStatus.HasBeenCut;
|
|
database.UpdateMetadata(cut, record, 1);
|
|
database.SetDeleted(removed, true, 1);
|
|
|
|
var nests = database.SetReleases(set.Id, 0, 100)!.Items.Single().Nests;
|
|
Assert.Equal((NestStatus.HasBeenCut, false), (nests[0].Nest!.Status, nests[0].Nest!.Deleted));
|
|
Assert.True(nests[1].Nest!.Deleted);
|
|
Assert.True(database.ReleaseForNest(removed)!.Nests[1].Nest!.Deleted);
|
|
}
|
|
|
|
[Fact]
|
|
public void Releases_PageNewestFirstPerSetAndAcceptAnArchivedSet()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
|
var set = Set();
|
|
var other = Set("Other kit");
|
|
database.InsertDrawingSet(set.Id, set);
|
|
database.InsertDrawingSet(other.Id, other);
|
|
var ids = new List<Guid>();
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
var release = Release(1, AddNest(database));
|
|
ids.Add(release.Id);
|
|
database.InsertSetRelease(set.Id, release);
|
|
Thread.Sleep(5);
|
|
}
|
|
database.InsertSetRelease(other.Id, Release(1, AddNest(database)));
|
|
database.SetDrawingSetArchived(set.Id, true, 1);
|
|
var afterArchive = Release(1, AddNest(database));
|
|
Assert.NotNull(database.InsertSetRelease(set.Id, afterArchive));
|
|
ids.Add(afterArchive.Id);
|
|
|
|
var page = database.SetReleases(set.Id, 1, 2)!;
|
|
Assert.Equal(4, page.Total);
|
|
Assert.Equal(new[] { ids[2], ids[1] }, page.Items.Select(r => r.Id));
|
|
Assert.Single(database.SetReleases(other.Id, 0, 100)!.Items);
|
|
Assert.Null(database.SetReleases(Guid.NewGuid(), 0, 100));
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => database.SetReleases(set.Id, 0, 201));
|
|
}
|
|
|
|
[Fact]
|
|
public void Refusals_LeaveNoState()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
|
var set = Set();
|
|
database.InsertDrawingSet(set.Id, set);
|
|
var free = AddNest(database);
|
|
var taken = AddNest(database);
|
|
var deleted = AddNest(database);
|
|
database.SetDeleted(deleted, true, 1);
|
|
var owner = Release(1, taken);
|
|
database.InsertSetRelease(set.Id, owner);
|
|
|
|
Assert.Null(database.InsertSetRelease(Guid.NewGuid(), Release(1, free)));
|
|
Assert.Contains("no revision 2", Assert.Throws<ArgumentException>(() => database.InsertSetRelease(set.Id, Release(2, free))).Message);
|
|
Assert.Contains("does not exist", Assert.Throws<ArgumentException>(() => database.InsertSetRelease(set.Id, Release(1, free, Guid.NewGuid()))).Message);
|
|
Assert.Contains("is deleted", Assert.Throws<ArgumentException>(() => database.InsertSetRelease(set.Id, Release(1, free, deleted))).Message);
|
|
Assert.Throws<ArgumentException>(() => database.InsertSetRelease(set.Id, Release(1, free, free)));
|
|
Assert.Throws<ArgumentException>(() => database.InsertSetRelease(set.Id, Release(1)));
|
|
var noId = Release(1, free);
|
|
noId.Id = Guid.Empty;
|
|
Assert.Throws<ArgumentException>(() => database.InsertSetRelease(set.Id, noId));
|
|
var tooMany = Release(1, free);
|
|
tooMany.Multiplier = SetReleaseRules.MaxMultiplier + 1;
|
|
Assert.Throws<ArgumentException>(() => database.InsertSetRelease(set.Id, tooMany));
|
|
|
|
var claimed = Assert.Throws<SetReleaseConflictException>(() => database.InsertSetRelease(set.Id, Release(1, free, taken)));
|
|
Assert.Equal(owner.Id, claimed.Current.Id);
|
|
var reused = Release(1, free);
|
|
reused.Id = owner.Id;
|
|
Assert.Equal(owner.Id, Assert.Throws<SetReleaseConflictException>(() => database.InsertSetRelease(set.Id, reused)).Current.Id);
|
|
|
|
Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM set_releases"));
|
|
Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM set_release_nests"));
|
|
Assert.Null(database.ReleaseForNest(free));
|
|
}
|
|
|
|
// Code-level ownership gate, as for the set operations.
|
|
[Theory]
|
|
[InlineData("InsertSetRelease")]
|
|
[InlineData("SetReleases")]
|
|
[InlineData("ReleaseForNest")]
|
|
public async Task Concurrency_EveryReleaseOperationWaitsForTheSameMonitor(string operation)
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
|
var set = Set();
|
|
database.InsertDrawingSet(set.Id, set);
|
|
var nest = AddNest(database);
|
|
var next = AddNest(database);
|
|
database.InsertSetRelease(set.Id, Release(1, nest));
|
|
var sync = typeof(NestDatabase).GetField("_sync", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(database)!;
|
|
var completed = new TaskCompletionSource<Exception?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var worker = new Thread(() => completed.SetResult(Xunit.Record.Exception(() =>
|
|
{
|
|
switch (operation)
|
|
{
|
|
case "InsertSetRelease": database.InsertSetRelease(set.Id, Release(1, next)); break;
|
|
case "SetReleases": database.SetReleases(set.Id, 0, 100); break;
|
|
case "ReleaseForNest": database.ReleaseForNest(nest); 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_AllReleaseSqlStepsOwnTheMonitor()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
|
var set = Set();
|
|
database.InsertDrawingSet(set.Id, set);
|
|
var nest = AddNest(database);
|
|
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
|
|
{
|
|
Action[] operations =
|
|
[
|
|
() => database.InsertSetRelease(set.Id, Release(1, nest)),
|
|
() => database.SetReleases(set.Id, 0, 100),
|
|
() => database.ReleaseForNest(nest),
|
|
];
|
|
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);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Api_PostsAndReadsReleasesWithCamelCaseEnums()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var client = factory.CreateClient();
|
|
var set = await Create(client, Set());
|
|
var first = await AddNest(client, "Nest one");
|
|
var second = await AddNest(client, "Nest two");
|
|
var release = Release(1, first, second);
|
|
release.Nests[1].Units = Units.Millimeters;
|
|
release.Nests[1].CutType = CutType.Plasma;
|
|
|
|
using var posted = await client.PostAsync($"/api/sets/{set.Id}/releases", Json(JsonSerializer.Serialize(release, Options)));
|
|
|
|
Assert.Equal(HttpStatusCode.OK, posted.StatusCode);
|
|
var json = JsonNode.Parse(await posted.Content.ReadAsStringAsync())!;
|
|
Assert.Equal(set.Id.ToString(), json["setId"]!.GetValue<string>());
|
|
Assert.Equal("Synthetic kit", json["setName"]!.GetValue<string>());
|
|
Assert.Equal("millimeters", json["nests"]![1]!["units"]!.GetValue<string>());
|
|
Assert.Equal("plasma", json["nests"]![1]!["cutType"]!.GetValue<string>());
|
|
Assert.Equal("draft", json["nests"]![0]!["nest"]!["status"]!.GetValue<string>());
|
|
|
|
var page = (await client.GetFromJsonAsync<SetReleasePage>($"/api/sets/{set.Id}/releases", Options))!;
|
|
Assert.Equal(release.Id, page.Items.Single().Id);
|
|
Assert.Equal("Nest two", page.Items.Single().Nests[1].Nest!.Name);
|
|
var fromNest = (await client.GetFromJsonAsync<SetReleaseRecord>($"/api/nests/{second}/release", Options))!;
|
|
Assert.Equal((release.Id, 2), (fromNest.Id, fromNest.Multiplier));
|
|
|
|
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/nests/{await AddNest(client)}/release")).StatusCode);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/sets/{Guid.NewGuid()}/releases")).StatusCode);
|
|
Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync($"/api/sets/{set.Id}/releases?limit=0")).StatusCode);
|
|
using var unknownSet = await client.PostAsync($"/api/sets/{Guid.NewGuid()}/releases",
|
|
Json(JsonSerializer.Serialize(Release(1, await AddNest(client)), Options)));
|
|
Assert.Equal(HttpStatusCode.NotFound, unknownSet.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Api_Conflicts_ReturnTheReleaseHoldingTheIdOrNest()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var client = factory.CreateClient();
|
|
var set = await Create(client, Set());
|
|
var taken = await AddNest(client);
|
|
var owner = Release(1, taken);
|
|
using (await client.PostAsync($"/api/sets/{set.Id}/releases", Json(JsonSerializer.Serialize(owner, Options)))) { }
|
|
|
|
var reused = Release(1, await AddNest(client));
|
|
reused.Id = owner.Id;
|
|
foreach (var body in new[] { Release(1, await AddNest(client), taken), reused })
|
|
{
|
|
using var response = await client.PostAsync($"/api/sets/{set.Id}/releases", Json(JsonSerializer.Serialize(body, Options)));
|
|
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
|
Assert.Equal(owner.Id, (await response.Content.ReadFromJsonAsync<SetReleaseRecord>(Options))!.Id);
|
|
}
|
|
|
|
Assert.Equal(1, (await client.GetFromJsonAsync<SetReleasePage>($"/api/sets/{set.Id}/releases", Options))!.Total);
|
|
}
|
|
|
|
public static TheoryData<string, string> RejectedReleases()
|
|
{
|
|
var valid = JsonSerializer.Serialize(Release(1, NestA), Options);
|
|
string With(string property, JsonNode? value)
|
|
{
|
|
var node = JsonNode.Parse(valid)!.AsObject();
|
|
node[property] = value;
|
|
return node.ToJsonString();
|
|
}
|
|
string WithNest(string property, JsonNode? value)
|
|
{
|
|
var node = JsonNode.Parse(valid)!.AsObject();
|
|
node["nests"]![0]![property] = value;
|
|
return node.ToJsonString();
|
|
}
|
|
string Without(string property)
|
|
{
|
|
var node = JsonNode.Parse(valid)!.AsObject();
|
|
node.Remove(property);
|
|
return node.ToJsonString();
|
|
}
|
|
|
|
return new TheoryData<string, string>
|
|
{
|
|
{ "empty id", With("id", Guid.Empty.ToString()) },
|
|
{ "other set id", With("setId", Guid.NewGuid().ToString()) },
|
|
{ "zero set revision", With("setRevision", 0) },
|
|
{ "unknown set revision", With("setRevision", 2) },
|
|
{ "zero multiplier", With("multiplier", 0) },
|
|
{ "multiplier over the limit", With("multiplier", 1001) },
|
|
{ "long notes", With("notes", new string('x', 4001)) },
|
|
{ "null released by", With("releasedBy", null) },
|
|
{ "missing nests", Without("nests") },
|
|
{ "null nests", With("nests", null) },
|
|
{ "no nests", With("nests", new JsonArray()) },
|
|
{ "repeated nest", With("nests", new JsonArray(JsonNode.Parse(valid)!["nests"]![0]!.DeepClone(), JsonNode.Parse(valid)!["nests"]![0]!.DeepClone())) },
|
|
{ "unknown nest", WithNest("nestId", Guid.NewGuid().ToString()) },
|
|
{ "empty nest id", WithNest("nestId", Guid.Empty.ToString()) },
|
|
{ "integer units", WithNest("units", 0) },
|
|
{ "unknown units", WithNest("units", "Feet") },
|
|
{ "combined cut type", WithNest("cutType", "Laser, Plasma") },
|
|
{ "integer cut type", WithNest("cutType", 1) },
|
|
{ "negative thickness", WithNest("thickness", -1) },
|
|
{ "null material", WithNest("materialName", null) },
|
|
{ "long grade", WithNest("materialGrade", new string('g', 201)) },
|
|
{ "not an object", "[1]" },
|
|
{ "malformed", "{" },
|
|
};
|
|
}
|
|
|
|
private static readonly Guid NestA = Guid.Parse("00000000-0000-0000-0000-0000000000a1");
|
|
|
|
[Theory]
|
|
[MemberData(nameof(RejectedReleases))]
|
|
public async Task Api_InvalidRelease_IsRejectedWithNoState(string row, string json)
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var client = factory.CreateClient();
|
|
var set = await Create(client, Set());
|
|
await AddNest(client, id: NestA);
|
|
|
|
using var response = await client.PostAsync($"/api/sets/{set.Id}/releases", Json(json));
|
|
|
|
Assert.True(response.StatusCode == HttpStatusCode.BadRequest, row + ": " + response.StatusCode);
|
|
Assert.Equal(0, (await client.GetFromJsonAsync<SetReleasePage>($"/api/sets/{set.Id}/releases", Options))!.Total);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Api_JoinedNestInTheBody_IsIgnored()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var client = factory.CreateClient();
|
|
var set = await Create(client, Set());
|
|
var node = JsonNode.Parse(JsonSerializer.Serialize(Release(1, await AddNest(client)), Options))!;
|
|
node["nests"]![0]!["nest"] = new JsonObject { ["status"] = 99, ["name"] = 5 };
|
|
|
|
using var response = await client.PostAsync($"/api/sets/{set.Id}/releases", Json(node.ToJsonString()));
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|