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(() => 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(() => 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(() => database.InsertDrawing(Guid.Empty, record, Package())); Assert.Throws(() => database.InsertDrawing(record.Id, record, new byte[] { 1, 2, 3, 4 })); record.CutType = (CutType)7; Assert.Throws(() => database.InsertDrawing(record.Id, record, Package())); Assert.Throws(() => database.QueryDrawings(new DrawingLibraryQuery { Limit = 501 })); Assert.Throws(() => 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(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); } } /// A real schema-v3 file: v4 is additive, so dropping its tables restores v3 exactly. 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(); } }