using System.Globalization; using System.Text.Json.Nodes; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; /// /// The opt-in write path (design §3.4, lmxopcua#753): allowWrites + the tag's "writable": true /// + a single-row model, executed as a parameterised UPDATE over catalog-validated identifiers that /// commits only when exactly one row matched. Over SQLite, end to end through . /// public sealed class SqlWriteTests { private const string DriverInstanceId = "sql-write"; // ---- tag opt-in parse ---- [Fact] public void TryParse_readsWritable_andDefaultsItOff() { SqlEquipmentTagParser.TryParse(Kv(writable: true), "a", out var on).ShouldBeTrue(); on.Writable.ShouldBeTrue(); SqlEquipmentTagParser.TryParse(Kv(writable: null), "b", out var absent).ShouldBeTrue(); absent.Writable.ShouldBeFalse(); } [Theory] [InlineData("\"true\"")] [InlineData("1")] [InlineData("null")] public void TryParse_aNonBooleanWritable_refusesTheTag_andTheGateSaysWhy(string writable) { var json = $$"""{"model":"KeyValue","table":"T","keyColumn":"k","keyValue":"x","valueColumn":"v","writable":{{writable}}}"""; SqlEquipmentTagParser.TryParse(json, "a", out _).ShouldBeFalse(); SqlTagDefinitionFactory.Inspect(json).ShouldContain(w => w.Contains("writable", StringComparison.Ordinal)); } // ---- planner: the only statement a write can become ---- [Fact] public void Plan_keyValue_isASingleRowUpdate_withTheValueAndKeyBound() { var tag = new SqlTagDefinition("A", SqlTagModel.KeyValue, "dbo.TagValues", KeyColumn: "tag_name", KeyValue: "Line1.Speed", ValueColumn: "num_value", Writable: true); SqlWritePlanner.TryPlan(tag, 12.5, new SqlServerDialect(), out var plan).ShouldBe(SqlStatusCodes.Good); plan!.SqlText.ShouldBe("UPDATE [dbo].[TagValues] SET [num_value] = @v WHERE [tag_name] = @k"); plan.ParameterBindNames.ShouldBe(["@v", "@k"]); plan.Parameters.ShouldBe([12.5, "Line1.Speed"]); } [Fact] public void Plan_wideRowWherePair_updatesTheSelectedRowsColumn_valueBeforeKeyForPositionalProviders() { var tag = new SqlTagDefinition("A", SqlTagModel.WideRow, "plant.status", ColumnName: "setpoint", RowSelectorColumn: "station_id", RowSelectorValue: "7", Writable: true); SqlWritePlanner.TryPlan(tag, 3, new OdbcDialect(), out var plan).ShouldBe(SqlStatusCodes.Good); plan!.SqlText.ShouldBe("UPDATE \"plant\".\"status\" SET \"setpoint\" = ? WHERE \"station_id\" = ?"); plan.Parameters.ShouldBe([3, "7"]); // marker order: SET value, then WHERE key } [Fact] public void Plan_aHostileValueIsBound_neverConcatenated() { const string hostile = "x'; DROP TABLE TagValues; --"; var tag = new SqlTagDefinition("A", SqlTagModel.KeyValue, "T", KeyColumn: "k", KeyValue: "x", ValueColumn: "v"); SqlWritePlanner.TryPlan(tag, hostile, new SqlServerDialect(), out var plan).ShouldBe(SqlStatusCodes.Good); plan!.SqlText.ShouldNotContain("DROP"); plan.Parameters[0].ShouldBe(hostile); } [Fact] public void Plan_refusesModelsWithNoSingleRowTarget() { var newest = new SqlTagDefinition("A", SqlTagModel.WideRow, "T", ColumnName: "c", RowSelectorTopByTimestamp: "ts"); var query = new SqlTagDefinition("B", SqlTagModel.Query, string.Empty, ColumnName: "c", QueryName: "q"); SqlWritePlanner.TryPlan(newest, 1, new SqlServerDialect(), out _).ShouldBe(SqlStatusCodes.BadNotWritable); SqlWritePlanner.TryPlan(query, 1, new SqlServerDialect(), out _).ShouldBe(SqlStatusCodes.BadNotWritable); } [Fact] public void Plan_coercesToTheDeclaredType_andRefusesWhatWillNotCoerce() { var tag = new SqlTagDefinition("A", SqlTagModel.KeyValue, "T", KeyColumn: "k", KeyValue: "x", ValueColumn: "v", DeclaredType: DriverDataType.Int32); SqlWritePlanner.TryPlan(tag, "41", new SqlServerDialect(), out var plan).ShouldBe(SqlStatusCodes.Good); plan!.Parameters[0].ShouldBe(41); SqlWritePlanner.TryPlan(tag, "not a number", new SqlServerDialect(), out _).ShouldBe(SqlStatusCodes.BadTypeMismatch); SqlWritePlanner.TryPlan(tag with { DeclaredType = null }, new object(), new SqlServerDialect(), out _) .ShouldBe(SqlStatusCodes.BadTypeMismatch); } // ---- the gates, end to end ---- [Fact] public async Task Write_withAllowWritesOff_isRefused_andTheTableIsUntouched() { using var fixture = new SqlitePollFixture(); await using var driver = await StartAsync(fixture, allowWrites: false, Entry("Speed", Kv(writable: true))); (await WriteAsync(driver, "Speed", 99.0)).ShouldBe(SqlStatusCodes.BadNotWritable); ValueOf(fixture, SqlitePollFixture.PresentKey).ShouldBe(SqlitePollFixture.PresentValue); } [Fact] public async Task Write_toATagThatDidNotOptIn_isRefused_evenWithAllowWritesOn() { using var fixture = new SqlitePollFixture(); await using var driver = await StartAsync(fixture, allowWrites: true, Entry("Speed", Kv(writable: null))); (await WriteAsync(driver, "Speed", 99.0)).ShouldBe(SqlStatusCodes.BadNotWritable); ValueOf(fixture, SqlitePollFixture.PresentKey).ShouldBe(SqlitePollFixture.PresentValue); } [Fact] public async Task Write_allGatesOpen_updatesTheRow_andAReadSeesIt() { using var fixture = new SqlitePollFixture(); await using var driver = await StartAsync(fixture, allowWrites: true, Entry("Speed", Kv(writable: true)), Entry("Pressure", Kv(writable: true, key: SqlitePollFixture.SecondPresentKey))); (await WriteAsync(driver, "Speed", 99.5)).ShouldBe(SqlStatusCodes.Good); ValueOf(fixture, SqlitePollFixture.PresentKey).ShouldBe(99.5); ValueOf(fixture, SqlitePollFixture.SecondPresentKey).ShouldBe(SqlitePollFixture.SecondPresentValue); // only one row var read = (await driver.ReadAsync(["Speed"], CancellationToken.None))[0]; Convert.ToDouble(read.Value, CultureInfo.InvariantCulture).ShouldBe(99.5); } [Fact] public async Task Write_aWideRowWherePair_updatesThatStationsColumnOnly() { using var fixture = new SqlitePollFixture(); await using var driver = await StartAsync(fixture, allowWrites: true, Entry("Oven7", $$""" {"model":"WideRow","table":"{{SqlitePollFixture.WideRowTable}}","columnName":"oven_temp", "rowSelector":{"whereColumn":"station_id","whereValue":7},"writable":true} """)); (await WriteAsync(driver, "Oven7", 190.0)).ShouldBe(SqlStatusCodes.Good); Scalar(fixture, "SELECT oven_temp FROM LatestStatus WHERE station_id = 7").ShouldBe(190.0); Scalar(fixture, "SELECT oven_temp FROM LatestStatus WHERE station_id = 8").ShouldBe(SqlitePollFixture.NewestStationOvenTemp); } [Fact] public async Task Write_toAnAbsentKey_isBadNoData_andInsertsNothing() { using var fixture = new SqlitePollFixture(); await using var driver = await StartAsync(fixture, allowWrites: true, Entry("Missing", Kv(writable: true, key: SqlitePollFixture.AbsentKey))); (await WriteAsync(driver, "Missing", 1.0)).ShouldBe(SqlStatusCodes.BadNoData); Scalar(fixture, "SELECT COUNT(*) FROM TagValues").ShouldBe(3.0); } /// /// A key that matches two rows would update both. The write is rolled back and refused, so neither row /// changes — the write-side twin of the read path's duplicate-key refusal. /// [Fact] public async Task Write_matchingMoreThanOneRow_isRolledBack_andRefused() { using var fixture = new SqlitePollFixture(); fixture.Execute($"INSERT INTO TagValues (tag_name, num_value, sample_ts) VALUES ('{SqlitePollFixture.PresentKey}', 7.0, '2026-07-24T11:00:00Z')"); await using var driver = await StartAsync(fixture, allowWrites: true, Entry("Speed", Kv(writable: true))); (await WriteAsync(driver, "Speed", 55.0)).ShouldBe(SqlStatusCodes.BadConfigurationError); Scalar(fixture, $"SELECT COUNT(*) FROM TagValues WHERE num_value = 55.0").ShouldBe(0.0); } [Fact] public async Task Write_toAnUnknownOrGateRejectedTag_isBadNodeIdUnknown() { using var fixture = new SqlitePollFixture(); await using var driver = await StartAsync(fixture, allowWrites: true, Entry("Ghost", """ {"model":"KeyValue","table":"NoSuchTable","keyColumn":"k","keyValue":"x","valueColumn":"v","writable":true} """)); (await WriteAsync(driver, "Ghost", 1.0)).ShouldBe(SqlStatusCodes.BadNodeIdUnknown); // the catalog gate rejected it (await WriteAsync(driver, "Nobody", 1.0)).ShouldBe(SqlStatusCodes.BadNodeIdUnknown); } [Fact] public async Task Write_aHostileString_isStoredVerbatim() { using var fixture = new SqlitePollFixture(); fixture.Execute("CREATE TABLE Notes (k TEXT NOT NULL, note TEXT NULL)"); fixture.Execute("INSERT INTO Notes VALUES ('a', 'old')"); const string hostile = "x'); DROP TABLE Notes; --"; await using var driver = await StartAsync(fixture, allowWrites: true, Entry("Note", """ {"model":"KeyValue","table":"Notes","keyColumn":"k","keyValue":"a","valueColumn":"note","writable":true,"type":"String"} """)); (await WriteAsync(driver, "Note", hostile)).ShouldBe(SqlStatusCodes.Good); using var command = fixture.Connection.CreateCommand(); command.CommandText = "SELECT note FROM Notes WHERE k = 'a'"; command.ExecuteScalar().ShouldBe(hostile); } [Fact] public async Task Discover_advertisesOperate_onlyWhereAWriteCouldSucceed() { using var fixture = new SqlitePollFixture(); await using var driver = await StartAsync(fixture, allowWrites: true, Entry("Writable", Kv(writable: true)), Entry("ReadOnly", Kv(writable: null)), Entry("Newest", $$""" {"model":"WideRow","table":"{{SqlitePollFixture.WideRowTable}}","columnName":"oven_temp", "rowSelector":{"topByTimestamp":"sample_ts"},"writable":true} """)); var builder = new ClassCapturingBuilder(); await driver.DiscoverAsync(builder, CancellationToken.None); builder.Classes["Writable"].ShouldBe(SecurityClassification.Operate); builder.Classes["ReadOnly"].ShouldBe(SecurityClassification.ViewOnly); builder.Classes["Newest"].ShouldBe(SecurityClassification.ViewOnly); // no single-row target } // ---- helpers ---- private static string Kv(bool? writable, string key = SqlitePollFixture.PresentKey) { var o = new JsonObject { ["model"] = "KeyValue", ["table"] = SqlitePollFixture.KeyValueTable, ["keyColumn"] = SqlitePollFixture.KeyColumn, ["keyValue"] = key, ["valueColumn"] = SqlitePollFixture.ValueColumn, ["type"] = "Float64", }; if (writable is { } w) o["writable"] = w; return o.ToJsonString(); } private static RawTagEntry Entry(string rawPath, string json) => new(rawPath, json, WriteIdempotent: false); private static async Task WriteAsync(SqlDriver driver, string reference, object? value) => (await ((IWritable)driver).WriteAsync([new WriteRequest(reference, value)], CancellationToken.None)) .ShouldHaveSingleItem().StatusCode; private static double ValueOf(SqlitePollFixture fixture, string key) => Scalar(fixture, $"SELECT num_value FROM TagValues WHERE tag_name = '{key}'"); private static double Scalar(SqlitePollFixture fixture, string sql) { using var command = fixture.Connection.CreateCommand(); command.CommandText = sql; return Convert.ToDouble(command.ExecuteScalar(), CultureInfo.InvariantCulture); } private static async Task StartAsync(SqlitePollFixture fixture, bool allowWrites, params RawTagEntry[] tags) { var driver = new SqlDriver( new SqlDriverOptions { RawTags = tags, OperationTimeout = TimeSpan.FromSeconds(15), CommandTimeout = TimeSpan.FromSeconds(10), AllowWrites = allowWrites, }, DriverInstanceId, new SqliteDialect(), fixture.ConnectionString, fixture.Factory); await driver.InitializeAsync("{}", CancellationToken.None); return driver; } /// Records each discovered variable's security classification. private sealed class ClassCapturingBuilder : IAddressSpaceBuilder { public Dictionary Classes { get; } = []; public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) { Classes[attributeInfo.FullName] = attributeInfo.SecurityClass; return new Handle(attributeInfo.FullName); } public void AddProperty(string browseName, DriverDataType dataType, object? value) { } private sealed class Handle(string fullReference) : IVariableHandle { public string FullReference => fullReference; public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink(); private sealed class Sink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } } } } }