mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 15:01:07 -04:00
The server becomes the system of record for shared drawings (cut type, thickness, material and grade, customer, notes). Schema v4 adds `drawings` (current row) and `drawing_revisions` (immutable) to the same SQLite file under the existing monitor; the nest tables are untouched and a v3 upgrade keeps every nest row byte-identical. `/api/drawings` follows the nest revision contract: client-allocated ids, If-Match on every existing-drawing write (428/400/409 with no state change), archive instead of delete, and one transaction for promotion plus snapshot. Geometry is an opaque package (a zero-plate .nest) the server never parses. Enum fields accept single names only, because the JSON enum converter would otherwise accept numbers and comma combinations. Material, grade and customer filters match SharedListNames keys so spacing and case cannot split a drawing family; thickness is stored and compared at four decimals. Shared bounds live in OpenNest.Data so the client can refuse the same requests before sending. If-Match parsing moves to one helper used by nests and drawings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
240 lines
11 KiB
C#
240 lines
11 KiB
C#
using System.Reflection;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Microsoft.Data.Sqlite;
|
|
using OpenNest.Data;
|
|
using static OpenNest.Server.Tests.DrawingLibraryFixtures;
|
|
|
|
namespace OpenNest.Server.Tests;
|
|
|
|
public sealed class DrawingLibraryDatabaseTests
|
|
{
|
|
[Fact]
|
|
public void V3Upgrade_KeepsNestRowsByteIdenticalAndIsIdempotent()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
CreateVersion3Database(factory.DatabasePath);
|
|
var before = NestDigest(factory.DatabasePath);
|
|
|
|
for (var open = 0; open < 2; open++)
|
|
{
|
|
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
|
|
Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items);
|
|
Assert.Equal(before, NestDigest(factory.DatabasePath));
|
|
Assert.Equal(4L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
|
}
|
|
|
|
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
|
|
var record = Record();
|
|
upgraded.InsertDrawing(record.Id, record, Package());
|
|
Assert.Equal(1, upgraded.GetDrawing(record.Id)!.Revision);
|
|
Assert.Equal(2, upgraded.List().Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void NewerSchema_IsRefused()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using (new NestDatabase(factory.DatabasePath, pooling: false)) { }
|
|
Execute(factory.DatabasePath, "PRAGMA user_version=5");
|
|
|
|
Assert.Throws<InvalidOperationException>(() => new NestDatabase(factory.DatabasePath, pooling: false));
|
|
}
|
|
|
|
[Fact]
|
|
public void FailedV4Migration_RollsBackAndCanRestart()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
CreateVersion3Database(factory.DatabasePath);
|
|
var before = NestDigest(factory.DatabasePath);
|
|
Execute(factory.DatabasePath, "CREATE TABLE drawing_revisions (fixture TEXT)");
|
|
|
|
Assert.Throws<SqliteException>(() => new NestDatabase(factory.DatabasePath, pooling: false));
|
|
|
|
Assert.Equal(3L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
|
Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name='drawings'"));
|
|
Assert.Equal(before, NestDigest(factory.DatabasePath));
|
|
Execute(factory.DatabasePath, "DROP TABLE drawing_revisions");
|
|
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
|
Assert.Equal(2, database.List().Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void DirectWrites_EnforceTheSharedRules()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
|
var record = Record();
|
|
|
|
Assert.Throws<ArgumentException>(() => database.InsertDrawing(Guid.Empty, record, Package()));
|
|
Assert.Throws<ArgumentException>(() => database.InsertDrawing(record.Id, record, new byte[] { 1, 2, 3, 4 }));
|
|
record.CutType = (CutType)7;
|
|
Assert.Throws<ArgumentException>(() => database.InsertDrawing(record.Id, record, Package()));
|
|
Assert.Throws<ArgumentException>(() => database.QueryDrawings(new DrawingLibraryQuery { Limit = 501 }));
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => database.DrawingHistory(record.Id, 0, 201));
|
|
Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items);
|
|
}
|
|
|
|
// Code-level ownership gate, as for the nest operations: hold the database's monitor
|
|
// and observe a dedicated synchronous caller waiting on it.
|
|
[Theory]
|
|
[InlineData("GetDrawing")]
|
|
[InlineData("GetDrawingPackage")]
|
|
[InlineData("QueryDrawings")]
|
|
[InlineData("InsertDrawing")]
|
|
[InlineData("UpdateDrawing")]
|
|
[InlineData("UpdateDrawingMetadata")]
|
|
[InlineData("SetDrawingArchived")]
|
|
[InlineData("DrawingHistory")]
|
|
[InlineData("GetDrawingRevisionPackage")]
|
|
public async Task Concurrency_EveryDrawingOperationWaitsForTheSameMonitor(string operation)
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
|
var record = Record();
|
|
database.InsertDrawing(record.Id, record, Package());
|
|
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 "GetDrawing": database.GetDrawing(record.Id); break;
|
|
case "GetDrawingPackage": database.GetDrawingPackage(record.Id); break;
|
|
case "QueryDrawings": database.QueryDrawings(new DrawingLibraryQuery { Search = "bracket" }); break;
|
|
case "InsertDrawing": database.InsertDrawing(Guid.NewGuid(), record, Package("other")); break;
|
|
case "UpdateDrawing": database.UpdateDrawing(record.Id, record, Package("changed"), 1); break;
|
|
case "UpdateDrawingMetadata": database.UpdateDrawing(record.Id, Record("Renamed", record.Id), null, 1); break;
|
|
case "SetDrawingArchived": database.SetDrawingArchived(record.Id, true, 1); break;
|
|
case "DrawingHistory": database.DrawingHistory(record.Id, 0, 100); break;
|
|
case "GetDrawingRevisionPackage": database.GetDrawingRevisionPackage(record.Id, 1); 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_AllDrawingSqlStepsOwnTheMonitor()
|
|
{
|
|
using var factory = new ServerFactory();
|
|
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
|
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
|
|
{
|
|
var record = Record();
|
|
Action[] operations =
|
|
[
|
|
() => database.InsertDrawing(record.Id, record, Package()),
|
|
() => database.GetDrawing(record.Id),
|
|
() => database.GetDrawingPackage(record.Id),
|
|
() => database.QueryDrawings(new DrawingLibraryQuery { Search = "Synthetic", CutType = CutType.Laser, Thickness = 0.25 }),
|
|
() => database.UpdateDrawing(record.Id, record, Package("changed"), 1),
|
|
() => database.UpdateDrawing(record.Id, Record("Renamed", record.Id), null, 2),
|
|
() => database.SetDrawingArchived(record.Id, true, 3),
|
|
() => database.SetDrawingArchived(record.Id, false, 4),
|
|
() => database.DrawingHistory(record.Id, 0, 100),
|
|
() => database.GetDrawingRevisionPackage(record.Id, 1),
|
|
];
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>A real schema-v3 file: v4 is additive, so dropping its tables restores v3 exactly.</summary>
|
|
private static void CreateVersion3Database(string path)
|
|
{
|
|
using (var database = new NestDatabase(path, pooling: false))
|
|
{
|
|
var first = new NestRecord { Name = "Synthetic nest A", Customer = "Synthetic", Material = "Alloy", Thickness = 0.25 };
|
|
var second = new NestRecord { Name = "Synthetic nest B", Comments = "Notes" };
|
|
var a = database.Insert(Guid.NewGuid(), first, new byte[] { 0, 1, 255 });
|
|
database.Insert(Guid.NewGuid(), second, new byte[] { 7, 8, 9 });
|
|
first.Comments = "Edited";
|
|
database.Update(a.Id, first, new byte[] { 2, 3 }, 1);
|
|
database.SetDeleted(a.Id, true, 2);
|
|
database.SetDeleted(a.Id, false, 3);
|
|
}
|
|
|
|
Execute(path, "DROP TABLE drawing_revisions; DROP TABLE drawings; PRAGMA user_version=3;");
|
|
}
|
|
|
|
private static string NestDigest(string path)
|
|
{
|
|
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
|
|
connection.Open();
|
|
var text = new StringBuilder();
|
|
foreach (var sql in new[] { "SELECT * FROM nests ORDER BY id", "SELECT * FROM nest_revisions ORDER BY id, revision" })
|
|
{
|
|
using var command = connection.CreateCommand();
|
|
command.CommandText = sql;
|
|
using var reader = command.ExecuteReader();
|
|
while (reader.Read())
|
|
{
|
|
for (var i = 0; i < reader.FieldCount; i++)
|
|
{
|
|
var value = reader.GetValue(i);
|
|
text.Append(reader.GetName(i)).Append('=')
|
|
.Append(value is byte[] bytes ? Convert.ToHexString(SHA256.HashData(bytes)) : Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture))
|
|
.Append(';');
|
|
}
|
|
text.Append('\n');
|
|
}
|
|
}
|
|
return text.ToString();
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|