using System.Security.Cryptography; using System.Text; using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; /// /// Verifies the inline script-source service methods on that back the /// virtual-tag modal's script-editing panel: loading a script's source + concurrency token + name, /// counting how many virtual tags share a script, and saving an edited body (recomputing the SHA-256 /// SourceHash) under its own optimistic-concurrency guard. /// /// /// The EF InMemory provider honours neither a stale RowVersion token nor the resulting /// on a same-row update, exactly as the sibling /// UnsTreeServiceVirtualTagTests note — so the live concurrency-conflict catch (the /// "changed by someone else" message) cannot be exercised here and is verified against the real /// SQL Server in Task 12. These tests cover the load, count, success, and not-found contracts, plus /// the realistic case where the script vanished before the save lands (a concurrent delete), which /// the service reports as a failure rather than throwing. /// [Trait("Category", "Unit")] public sealed class ScriptSourceServiceTests { private static (UnsTreeService Service, string DbName) Fresh() { var dbName = $"uns-scriptsrc-{Guid.NewGuid():N}"; return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName); } /// /// Seeds a single script (SCRIPT-1) plus an area→line→equipment path carrying two virtual /// tags bound to it and a third bound to a different script, so the usage count is unambiguous. /// private static void SeedScriptAndUsages(string dbName) { using var db = UnsTreeTestDb.CreateNamed(dbName); db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = "MAIN", Name = "a" }); db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" }); db.Equipment.Add(new Equipment { EquipmentId = "EQ-1", EquipmentUuid = Guid.NewGuid(), UnsLineId = "LINE-1", Name = "machine-1", MachineCode = "machine_001", }); db.Scripts.Add(new Script { ScriptId = "SCRIPT-1", Name = "compute speed", SourceCode = "return 1;", SourceHash = HashOf("return 1;"), Language = "CSharp", }); db.Scripts.Add(new Script { ScriptId = "SCRIPT-2", Name = "other", SourceCode = "return 2;", SourceHash = HashOf("return 2;"), Language = "CSharp", }); // Two virtual tags use SCRIPT-1; one uses SCRIPT-2. db.VirtualTags.Add(new VirtualTag { VirtualTagId = "VTAG-A", EquipmentId = "EQ-1", Name = "vt_a", DataType = "Double", ScriptId = "SCRIPT-1", }); db.VirtualTags.Add(new VirtualTag { VirtualTagId = "VTAG-B", EquipmentId = "EQ-1", Name = "vt_b", DataType = "Double", ScriptId = "SCRIPT-1", }); db.VirtualTags.Add(new VirtualTag { VirtualTagId = "VTAG-C", EquipmentId = "EQ-1", Name = "vt_c", DataType = "Double", ScriptId = "SCRIPT-2", }); db.SaveChanges(); } private static string HashOf(string source) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(source))); // ----- GetScriptSourceAsync ----- /// Loading a known script returns its source, concurrency token, and display name. [Fact] public async Task GetScriptSource_returns_source_and_name() { var (service, dbName) = Fresh(); SeedScriptAndUsages(dbName); var loaded = await service.GetScriptSourceAsync("SCRIPT-1"); loaded.ShouldNotBeNull(); loaded.Value.SourceCode.ShouldBe("return 1;"); loaded.Value.Name.ShouldBe("compute speed"); loaded.Value.RowVersion.ShouldNotBeNull(); } /// Loading an unknown script id returns null. [Fact] public async Task GetScriptSource_unknown_returns_null() { var (service, dbName) = Fresh(); SeedScriptAndUsages(dbName); var loaded = await service.GetScriptSourceAsync("SCRIPT-NOPE"); loaded.ShouldBeNull(); } // ----- CountVirtualTagsUsingScriptAsync ----- /// The usage count reflects only the virtual tags bound to the given script. [Fact] public async Task CountVirtualTagsUsingScript_returns_count() { var (service, dbName) = Fresh(); SeedScriptAndUsages(dbName); (await service.CountVirtualTagsUsingScriptAsync("SCRIPT-1")).ShouldBe(2); (await service.CountVirtualTagsUsingScriptAsync("SCRIPT-2")).ShouldBe(1); (await service.CountVirtualTagsUsingScriptAsync("SCRIPT-NOPE")).ShouldBe(0); } // ----- UpdateScriptSourceAsync ----- /// A save with the current token updates the source and recomputes the SHA-256 hash. [Fact] public async Task UpdateScriptSource_updates_source_and_recomputes_hash() { var (service, dbName) = Fresh(); SeedScriptAndUsages(dbName); byte[] rv; string originalHash; using (var db = UnsTreeTestDb.CreateNamed(dbName)) { var script = db.Scripts.Single(s => s.ScriptId == "SCRIPT-1"); rv = script.RowVersion; originalHash = script.SourceHash; } const string newSource = "return 42;"; var result = await service.UpdateScriptSourceAsync("SCRIPT-1", newSource, rv); result.Ok.ShouldBeTrue(); result.Error.ShouldBeNull(); using var verify = UnsTreeTestDb.CreateNamed(dbName); var after = verify.Scripts.Single(s => s.ScriptId == "SCRIPT-1"); after.SourceCode.ShouldBe(newSource); after.SourceHash.ShouldBe(HashOf(newSource)); after.SourceHash.ShouldNotBe(originalHash); } /// Saving a script id that no longer exists returns the not-found error. [Fact] public async Task UpdateScriptSource_missing_returns_error() { var (service, dbName) = Fresh(); SeedScriptAndUsages(dbName); var result = await service.UpdateScriptSourceAsync("SCRIPT-NOPE", "x", []); result.Ok.ShouldBeFalse(); result.Error.ShouldBe("Script not found."); } /// /// A save whose target row was changed/removed by someone else after the panel captured its token /// surfaces a non-null error instead of throwing. InMemory cannot raise the real /// for a same-row stale token, so this exercises the /// concurrent-delete leg of the same race (the panel holds a now-stale token; the row is gone), /// which the service reports as a failure. The live "changed by someone else" concurrency message /// is verified against real SQL Server in Task 12. /// [Fact] public async Task UpdateScriptSource_stale_token_after_concurrent_delete_returns_error() { var (service, dbName) = Fresh(); SeedScriptAndUsages(dbName); byte[] staleRv; using (var db = UnsTreeTestDb.CreateNamed(dbName)) { staleRv = db.Scripts.Single(s => s.ScriptId == "SCRIPT-1").RowVersion; } // Another editor removes the row out from under us before our save lands. using (var other = UnsTreeTestDb.CreateNamed(dbName)) { var row = other.Scripts.Single(s => s.ScriptId == "SCRIPT-1"); other.Scripts.Remove(row); other.SaveChanges(); } var result = await service.UpdateScriptSourceAsync("SCRIPT-1", "return 99;", staleRv); result.Ok.ShouldBeFalse(); result.Error.ShouldNotBeNull(); } }