mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 05:01:05 -04:00
Schema v13 adds shared_settings and shared_setting_revisions behind /api/settings: one revision-guarded JSON document per kind and key, with every revision (deletions included) kept with who saved it. Recreating a deleted document continues its revision count. OpenNest.Data gains the client (RemoteSharedSettings), a synchronous store that keeps the last copy read for offline use and refuses writes while the server is unreachable (SharedSettingsStore), a machine provider over it, and JSON parse/serialize entry points for clamp settings and nest defaults. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
194 lines
9.9 KiB
C#
194 lines
9.9 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using Microsoft.Data.Sqlite;
|
|
using OpenNest.Data;
|
|
|
|
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(13L, 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);
|
|
}
|
|
|
|
private static void Execute(string path, string sql)
|
|
{
|
|
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
|
|
connection.Open();
|
|
using var command = connection.CreateCommand();
|
|
command.CommandText = sql;
|
|
command.ExecuteNonQuery();
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|