mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-03 22:51:36 -05:00
A server Auto Nest result could only be reviewed in the session that sent it, and nothing stopped two desktops from keeping the same result. Phase 3 adds My Server Jobs, so results that finished while OpenNest was closed are easy to find, and records each review on the server. - Server: GET /api/nest-jobs lists jobs newest first with state, engine, submitter, times, queue position, review and the worker's summary (parts, sheets, utilization, unplaced). It filters to active, finished or unreviewed jobs and to a submitter (a bare user name matches that user on any workstation). An ETag turns unchanged polls into 304s. Schema v17 adds nestId, review and summary columns, only where missing. - Keep once: POST .../keep and .../discard take If-Match on the job revision and change an unreviewed result only once. A second desktop gets 409 with who kept it. DELETE removes finished jobs. A succeeded job keeps its package until it is reviewed, so a later session can rebuild the job. - Retention: OPENNEST_JOB_RETENTION_DAYS (default 7) deletes finished jobs with their results. It runs on claims and list reads. A malformed value fails startup instead of deleting results early. - Worker: reports the result's summary with the transcript. A job cancelled while its result arrived keeps no summary (found in the Docker smoke run). - Desktop: Nest > My Server Jobs polls the list every 3 s; polling rather than per-job SSE because the list must show other desktops' jobs and removals. Open rebuilds the job from job.json against the open nest's own drawings. New per-drawing geometry fingerprints make the freshness check work across sessions. Cancel Job and Remove act on the selected job. A status-bar item counts this user's results waiting for review; there is no popup. Auto Nest's review now records Keep on the server before it commits, and records Discard too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
177 lines
9.3 KiB
C#
177 lines
9.3 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using Microsoft.Data.Sqlite;
|
|
using OpenNest.Data;
|
|
using static OpenNest.Server.Tests.TestDatabase;
|
|
|
|
namespace OpenNest.Server.Tests;
|
|
|
|
/// <summary>
|
|
/// The settings every workstation shares (<c>/api/settings</c>): revision-guarded documents,
|
|
/// the v13 upgrade, the HTTP contract, and the desktop's store with its offline copy.
|
|
/// </summary>
|
|
public sealed class SharedSettingsTests
|
|
{
|
|
private const string PostKey = "Synthetic.Posts.Laser";
|
|
|
|
[Fact]
|
|
public void Database_RevisesConflictsAndContinuesRevisionsAfterADelete()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
|
|
|
var (created, isNew) = database.CreateSharedSetting("post", PostKey, Write("""{"feed":1}"""));
|
|
var (same, sameIsNew) = database.CreateSharedSetting("post", PostKey, Write("""{"feed":1}"""));
|
|
var updated = database.UpdateSharedSetting("post", PostKey, Write("""{"feed":2}"""), created.Revision);
|
|
var unchanged = database.UpdateSharedSetting("post", PostKey, Write("""{"feed":2}"""), updated!.Revision);
|
|
|
|
Assert.Equal((1, true, 1, false, 2, 2), (created.Revision, isNew, same.Revision, sameIsNew, updated.Revision, unchanged!.Revision));
|
|
var stale = Assert.Throws<SharedSettingConflictException>(() =>
|
|
database.UpdateSharedSetting("post", PostKey, Write("""{"feed":3}"""), created.Revision));
|
|
Assert.Equal(2, stale.Current.Revision);
|
|
Assert.Throws<SharedSettingConflictException>(() => database.CreateSharedSetting("post", PostKey, Write("""{"feed":3}""")));
|
|
|
|
Assert.True(database.DeleteSharedSetting("post", PostKey, "tester", 2));
|
|
Assert.Null(database.GetSharedSetting("post", PostKey));
|
|
Assert.Empty(database.ListSharedSettings());
|
|
Assert.Null(database.UpdateSharedSetting("post", PostKey, Write("""{"feed":4}"""), 3));
|
|
|
|
var (recreated, _) = database.CreateSharedSetting("post", PostKey, Write("""{"feed":5}"""));
|
|
Assert.Equal(4, recreated.Revision);
|
|
Assert.Equal(4L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM shared_setting_revisions"));
|
|
Assert.Equal("tester", Assert.Single(database.ListSharedSettings()).SavedBy);
|
|
}
|
|
|
|
[Fact]
|
|
public void Database_StoresMachinesUnderTheirLowerCaseId()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
|
var machine = new MachineConfig { Name = "Synthetic laser" };
|
|
|
|
database.CreateSharedSetting("machine", machine.Id.ToString().ToUpperInvariant(), Write(SharedMachineProvider.Serialize(machine)));
|
|
|
|
Assert.Equal(machine.Id.ToString("D"), Assert.Single(database.ListSharedSettings()).Key);
|
|
Assert.NotNull(database.GetSharedSetting("machine", machine.Id.ToString().ToUpperInvariant()));
|
|
Assert.Throws<ArgumentException>(() =>
|
|
database.CreateSharedSetting("machine", Guid.NewGuid().ToString(), Write(SharedMachineProvider.Serialize(machine))));
|
|
}
|
|
|
|
[Fact]
|
|
public void V12Upgrade_StartsWithNoSharedSettings()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using (new NestDatabase(factory.DatabasePath, pooling: false)) { }
|
|
Execute(factory.DatabasePath,
|
|
"DROP TABLE shared_setting_revisions; DROP TABLE shared_settings; PRAGMA user_version=12;");
|
|
|
|
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
|
|
|
|
Assert.Equal(17L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
|
Assert.Empty(upgraded.ListSharedSettings());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Api_FollowsTheCreateAndIfMatchContract()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var client = factory.CreateClient();
|
|
var url = $"/api/settings/post/{PostKey}";
|
|
|
|
var created = await client.PostAsJsonAsync(url, Write("""{"feed":1}"""));
|
|
var again = await client.PostAsJsonAsync(url, Write("""{"feed":1}"""));
|
|
var different = await client.PostAsJsonAsync(url, Write("""{"feed":2}"""));
|
|
var unguarded = await client.PutAsJsonAsync(url, Write("""{"feed":2}"""));
|
|
var stale = await Put(client, url, Write("""{"feed":2}"""), revision: 5);
|
|
var updated = await Put(client, url, Write("""{"feed":2}"""), revision: 1);
|
|
var unknownKind = await client.GetAsync($"/api/settings/printer/{PostKey}");
|
|
var badKey = await client.PostAsJsonAsync("/api/settings/clamps/other", Write("{}"));
|
|
var notObject = await client.PostAsJsonAsync("/api/settings/post/Other.Post", Write("[1]"));
|
|
var extraField = await client.PostAsync("/api/settings/post/Other.Post",
|
|
new StringContent("""{"json":"{}","savedBy":"","revision":1}""", MediaTypeHeaderValue.Parse("application/json")));
|
|
|
|
Assert.Equal(
|
|
new[] { HttpStatusCode.Created, HttpStatusCode.OK, HttpStatusCode.Conflict, (HttpStatusCode)428,
|
|
HttpStatusCode.Conflict, HttpStatusCode.OK, HttpStatusCode.BadRequest, HttpStatusCode.BadRequest,
|
|
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest },
|
|
new[] { created.StatusCode, again.StatusCode, different.StatusCode, unguarded.StatusCode, stale.StatusCode,
|
|
updated.StatusCode, unknownKind.StatusCode, badKey.StatusCode, notObject.StatusCode, extraField.StatusCode });
|
|
Assert.Equal(1, (await different.Content.ReadFromJsonAsync<SharedSettingRecord>())!.Revision);
|
|
Assert.Equal(2, (await updated.Content.ReadFromJsonAsync<SharedSettingRecord>())!.Revision);
|
|
|
|
using var delete = new HttpRequestMessage(HttpMethod.Delete, url + "?savedBy=tester");
|
|
delete.Headers.IfMatch.Add(new EntityTagHeaderValue("\"2\""));
|
|
Assert.Equal(HttpStatusCode.NoContent, (await client.SendAsync(delete)).StatusCode);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync(url)).StatusCode);
|
|
Assert.Empty((await client.GetFromJsonAsync<List<SharedSettingRecord>>("/api/settings"))!);
|
|
}
|
|
|
|
[Fact]
|
|
public void Store_SavesThroughTheServerAndTakesTheNewerDocumentOnAConflict()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var client = factory.CreateClient();
|
|
var cache = Path.Combine(factory.DirectoryPath, "cache", "settings.json");
|
|
using var mine = Store(client, cache, "me");
|
|
using var theirs = Store(client, Path.Combine(factory.DirectoryPath, "theirs.json"), "them");
|
|
var machine = new MachineConfig { Name = "Synthetic laser" };
|
|
|
|
Assert.True(mine.Refresh());
|
|
new SharedMachineProvider(mine).SaveMachine(machine);
|
|
mine.Save("post", PostKey, """{"feed":1}""");
|
|
Assert.True(theirs.Refresh());
|
|
theirs.Save("post", PostKey, """{"feed":2}""");
|
|
|
|
var conflict = Assert.Throws<SharedSettingSaveConflictException>(() => mine.Save("post", PostKey, """{"feed":3}"""));
|
|
Assert.Equal(("them", """{"feed":2}"""), (conflict.Current.SavedBy, mine.Get("post", PostKey)!.Json));
|
|
Assert.Equal("""{"feed":3}""", mine.Save("post", PostKey, """{"feed":3}""").Json);
|
|
|
|
var provider = new SharedMachineProvider(theirs);
|
|
Assert.Equal("Synthetic laser", Assert.Single(provider.GetMachines()).Name);
|
|
Assert.Equal(machine.Id, provider.GetMachine(machine.Id)!.Id);
|
|
provider.DeleteMachine(machine.Id);
|
|
Assert.True(mine.Refresh());
|
|
Assert.Empty(mine.List("machine"));
|
|
Assert.True(File.Exists(cache));
|
|
}
|
|
|
|
[Fact]
|
|
public void Store_ServesTheLastCopyReadOnlyWhileTheServerIsUnreachable()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var client = factory.CreateClient();
|
|
var cache = Path.Combine(factory.DirectoryPath, "settings.json");
|
|
using (var online = Store(client, cache, "me"))
|
|
{
|
|
online.Refresh();
|
|
online.Save("post", PostKey, """{"feed":1}""");
|
|
}
|
|
|
|
// Nothing listens on the discard port, so every request fails as an unreachable server does.
|
|
using var offline = new SharedSettingsStore(
|
|
new RemoteSharedSettings("http://127.0.0.1:9/", TimeSpan.FromSeconds(2)), cache, "me");
|
|
|
|
Assert.False(offline.Refresh());
|
|
Assert.True(offline.IsOffline);
|
|
Assert.NotNull(offline.ReadAt);
|
|
Assert.Equal("""{"feed":1}""", offline.Get("post", PostKey)!.Json);
|
|
Assert.Same(offline.Get("post", PostKey), offline.Save("post", PostKey, """{"feed":1}"""));
|
|
var refused = Assert.ThrowsAny<IOException>(() => offline.Save("post", PostKey, """{"feed":2}"""));
|
|
Assert.IsNotType<SharedSettingSaveConflictException>(refused);
|
|
Assert.Equal("""{"feed":1}""", offline.Get("post", PostKey)!.Json);
|
|
}
|
|
|
|
private static SharedSettingsStore Store(HttpClient client, string cache, string savedBy) =>
|
|
new(new RemoteSharedSettings(client, client.BaseAddress!.ToString()), cache, savedBy);
|
|
|
|
private static SharedSettingWrite Write(string json) => new() { Json = json, SavedBy = "tester" };
|
|
|
|
private static async Task<HttpResponseMessage> Put(HttpClient client, string url, SharedSettingWrite write, int revision)
|
|
{
|
|
using var request = new HttpRequestMessage(HttpMethod.Put, url) { Content = JsonContent.Create(write) };
|
|
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{revision}\""));
|
|
return await client.SendAsync(request);
|
|
}
|
|
}
|