209 lines
8.0 KiB
C#
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();
|
|
}
|
|
}
|