Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ScriptSourceServiceTests.cs
T

209 lines
8.0 KiB
C#

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;
/// <summary>
/// Verifies the inline script-source service methods on <see cref="UnsTreeService"/> 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
/// <c>SourceHash</c>) under its own optimistic-concurrency guard.
/// </summary>
/// <remarks>
/// The EF InMemory provider honours neither a stale <c>RowVersion</c> token nor the resulting
/// <see cref="DbUpdateConcurrencyException"/> on a same-row update, exactly as the sibling
/// <c>UnsTreeServiceVirtualTagTests</c> 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.
/// </remarks>
[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);
}
/// <summary>
/// Seeds a single script (<c>SCRIPT-1</c>) 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.
/// </summary>
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 -----
/// <summary>Loading a known script returns its source, concurrency token, and display name.</summary>
[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();
}
/// <summary>Loading an unknown script id returns null.</summary>
[Fact]
public async Task GetScriptSource_unknown_returns_null()
{
var (service, dbName) = Fresh();
SeedScriptAndUsages(dbName);
var loaded = await service.GetScriptSourceAsync("SCRIPT-NOPE");
loaded.ShouldBeNull();
}
// ----- CountVirtualTagsUsingScriptAsync -----
/// <summary>The usage count reflects only the virtual tags bound to the given script.</summary>
[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 -----
/// <summary>A save with the current token updates the source and recomputes the SHA-256 hash.</summary>
[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);
}
/// <summary>Saving a script id that no longer exists returns the not-found error.</summary>
[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.");
}
/// <summary>
/// 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
/// <see cref="DbUpdateConcurrencyException"/> 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.
/// </summary>
[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();
}
}