b211911f98
v2-ci / unit-tests (push) Failing after 13m22s
Akka.TestKit.Xunit2 -> Akka.TestKit.Xunit 1.5.62 (xunit.v3 TestKit; needs xunit.v3 3.2.2, set via VersionOverride on the four projects). Removes the xunit 2.9.3 pin. IAsyncLifetime returns ValueTask, SqlServerFactAttribute gains the source-info ctor, and xUnit1051 call sites pass TestContext.Current.CancellationToken. Test counts unchanged. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
258 lines
11 KiB
C#
258 lines
11 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
|
|
|
/// <summary>
|
|
/// Gitea #535 — every UNS authoring path applies the segment rule the deploy gate applies.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The bug was not that a bad name crashed anything: it saved perfectly, and the <b>next
|
|
/// deployment</b> failed with <c>UnsSegmentInvalid</c>. Because a deployment snapshots the whole
|
|
/// database, one badly-named area blocked every cluster's deploy, discovered by someone other
|
|
/// than whoever typed it.
|
|
/// </para>
|
|
/// <para>
|
|
/// So these are rejection tests, and each is paired with a name that must still be <b>accepted</b>
|
|
/// — a guard that refused everything would satisfy the rejection half alone. They live at the
|
|
/// service, not the form, because a form attribute cannot cover the CSV import and this repo has
|
|
/// no bUnit to reach a form with.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class UnsSegmentGuardTests
|
|
{
|
|
private static (UnsTreeService Service, string DbName) Fresh()
|
|
{
|
|
var dbName = $"uns-segment-{Guid.NewGuid():N}";
|
|
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
|
|
}
|
|
|
|
private static void SeedAreaAndLine(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.SaveChanges();
|
|
}
|
|
|
|
private static EquipmentInput Input(string name, string machineCode) =>
|
|
new(name, machineCode, "LINE-1",
|
|
ZTag: null, SAPID: null, Manufacturer: null, Model: null, SerialNumber: null,
|
|
HardwareRevision: null, SoftwareRevision: null, YearOfConstruction: null,
|
|
AssetLocation: null, ManufacturerUri: null, DeviceManualUri: null, Enabled: true);
|
|
|
|
/// <summary>The two names that actually caused the outage, plus the neighbouring shapes.</summary>
|
|
public static TheoryData<string> BadNames() => new()
|
|
{
|
|
"Packaging", // the reported case: a capital
|
|
"Line 1", // the reported case: a space
|
|
"line_1", // underscore is not in the segment alphabet
|
|
"line/1", // a slash would split into two NodeId levels
|
|
"", // empty
|
|
" ", // whitespace
|
|
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // 33 chars
|
|
};
|
|
|
|
/// <summary>Names that must keep working — the control for every rejection below.</summary>
|
|
public static TheoryData<string> GoodNames() => new()
|
|
{
|
|
"packaging",
|
|
"line-1",
|
|
"a",
|
|
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // exactly 32
|
|
UnsSegment.DefaultSegment, // the gate accepts it, so the UI must too
|
|
};
|
|
|
|
// --- Area ---------------------------------------------------------------------------------
|
|
|
|
[Theory]
|
|
[MemberData(nameof(BadNames))]
|
|
public async Task CreateArea_rejects_a_name_the_deploy_gate_would_reject(string name)
|
|
{
|
|
var (service, _) = Fresh();
|
|
|
|
var result = await service.CreateAreaAsync("MAIN", "AREA-NEW", name, null, TestContext.Current.CancellationToken);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Error.ShouldNotBeNull();
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(GoodNames))]
|
|
public async Task CreateArea_accepts_a_valid_segment(string name)
|
|
{
|
|
var (service, _) = Fresh();
|
|
|
|
(await service.CreateAreaAsync("MAIN", "AREA-NEW", name, null, TestContext.Current.CancellationToken)).Ok.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renaming is the other way in, and the one an operator reaches after the name is already
|
|
/// deployed — so it is guarded too, and the guard runs before the row is touched.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task UpdateArea_rejects_a_rename_to_an_invalid_segment_and_leaves_the_row_alone()
|
|
{
|
|
var (service, dbName) = Fresh();
|
|
await service.CreateAreaAsync("MAIN", "AREA-1", "packaging", null, TestContext.Current.CancellationToken);
|
|
var rowVersion = Load(dbName).UnsAreas.Single(a => a.UnsAreaId == "AREA-1").RowVersion;
|
|
|
|
(await service.UpdateAreaAsync("AREA-1", "Packaging", null, "MAIN", rowVersion, TestContext.Current.CancellationToken)).Ok.ShouldBeFalse();
|
|
Load(dbName).UnsAreas.Single(a => a.UnsAreaId == "AREA-1").Name.ShouldBe("packaging");
|
|
|
|
(await service.UpdateAreaAsync("AREA-1", "assembly", null, "MAIN", rowVersion, TestContext.Current.CancellationToken)).Ok.ShouldBeTrue();
|
|
Load(dbName).UnsAreas.Single(a => a.UnsAreaId == "AREA-1").Name.ShouldBe("assembly");
|
|
}
|
|
|
|
// --- Line ---------------------------------------------------------------------------------
|
|
|
|
[Theory]
|
|
[MemberData(nameof(BadNames))]
|
|
public async Task CreateLine_rejects_a_name_the_deploy_gate_would_reject(string name)
|
|
{
|
|
var (service, dbName) = Fresh();
|
|
SeedAreaAndLine(dbName);
|
|
|
|
var result = await service.CreateLineAsync("AREA-1", "LINE-NEW", name, null, TestContext.Current.CancellationToken);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Error.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateLine_accepts_a_valid_segment()
|
|
{
|
|
var (service, dbName) = Fresh();
|
|
SeedAreaAndLine(dbName);
|
|
|
|
(await service.CreateLineAsync("AREA-1", "LINE-NEW", "line-2", null, TestContext.Current.CancellationToken)).Ok.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateLine_rejects_a_rename_to_an_invalid_segment()
|
|
{
|
|
var (service, dbName) = Fresh();
|
|
SeedAreaAndLine(dbName);
|
|
var rowVersion = Load(dbName).UnsLines.Single(l => l.UnsLineId == "LINE-1").RowVersion;
|
|
|
|
(await service.UpdateLineAsync("LINE-1", "Line 1", null, "AREA-1", rowVersion, TestContext.Current.CancellationToken)).Ok.ShouldBeFalse();
|
|
(await service.UpdateLineAsync("LINE-1", "line-1", null, "AREA-1", rowVersion, TestContext.Current.CancellationToken)).Ok.ShouldBeTrue();
|
|
}
|
|
|
|
// --- Equipment ----------------------------------------------------------------------------
|
|
|
|
[Theory]
|
|
[MemberData(nameof(BadNames))]
|
|
public async Task CreateEquipment_rejects_a_name_the_deploy_gate_would_reject(string name)
|
|
{
|
|
var (service, dbName) = Fresh();
|
|
SeedAreaAndLine(dbName);
|
|
|
|
(await service.CreateEquipmentAsync(Input(name, "mc_1"), TestContext.Current.CancellationToken)).Ok.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateEquipment_accepts_a_valid_segment()
|
|
{
|
|
var (service, dbName) = Fresh();
|
|
SeedAreaAndLine(dbName);
|
|
|
|
(await service.CreateEquipmentAsync(Input("packer-1", "mc_1"), TestContext.Current.CancellationToken)).Ok.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateEquipment_rejects_a_rename_to_an_invalid_segment()
|
|
{
|
|
var (service, dbName) = Fresh();
|
|
SeedAreaAndLine(dbName);
|
|
var created = await service.CreateEquipmentAsync(Input("packer-1", "mc_1"), TestContext.Current.CancellationToken);
|
|
created.Ok.ShouldBeTrue();
|
|
|
|
var row = Load(dbName).Equipment.Single();
|
|
|
|
(await service.UpdateEquipmentAsync(row.EquipmentId, Input("Packer 1", "mc_1"), row.RowVersion, TestContext.Current.CancellationToken))
|
|
.Ok.ShouldBeFalse();
|
|
(await service.UpdateEquipmentAsync(row.EquipmentId, Input("packer-2", "mc_1"), row.RowVersion, TestContext.Current.CancellationToken))
|
|
.Ok.ShouldBeTrue();
|
|
}
|
|
|
|
// --- CSV import ---------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// The bulk path is the one a form attribute could never have covered, and the one where a bad
|
|
/// row does the most damage — an import of hundreds lands, and the fleet stops deploying.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A bad name is an <b>error</b>, not a skip: a skip means "you already have this", which would
|
|
/// tell the operator the row is present when it is not.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task Import_reports_an_invalid_name_as_an_error_and_still_inserts_the_good_rows()
|
|
{
|
|
var (service, dbName) = Fresh();
|
|
SeedAreaAndLine(dbName);
|
|
|
|
var result = await service.ImportEquipmentAsync(
|
|
[
|
|
Input("packer-1", "mc_1"),
|
|
Input("Packer 2", "mc_2"),
|
|
Input("packer-3", "mc_3"),
|
|
], TestContext.Current.CancellationToken);
|
|
|
|
result.Inserted.ShouldBe(2);
|
|
result.Skipped.ShouldBe(0, "a bad name is not a row the operator already has");
|
|
result.Errors.ShouldHaveSingleItem().ShouldContain("mc_2");
|
|
|
|
Load(dbName).Equipment.Select(e => e.Name).OrderBy(n => n)
|
|
.ShouldBe(["packer-1", "packer-3"]);
|
|
}
|
|
|
|
private static Configuration.OtOpcUaConfigDbContext Load(string dbName) =>
|
|
UnsTreeTestDb.CreateNamed(dbName);
|
|
}
|
|
|
|
/// <summary>The shared rule itself — one authority, so the gate and the AdminUI cannot drift (#535).</summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class UnsSegmentTests
|
|
{
|
|
/// <summary>
|
|
/// The attribute pattern the forms use must accept exactly what <see cref="UnsSegment.IsValid"/>
|
|
/// accepts. They are two expressions of one rule, and a form stricter than the service is a field
|
|
/// an operator cannot satisfy; a form looser than it is the #535 bug again.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("packaging")]
|
|
[InlineData("line-1")]
|
|
[InlineData("_default")]
|
|
[InlineData("Packaging")]
|
|
[InlineData("Line 1")]
|
|
[InlineData("line_1")]
|
|
[InlineData("")]
|
|
[InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]
|
|
public void The_attribute_pattern_agrees_with_IsValid(string candidate)
|
|
{
|
|
var byAttribute = System.Text.RegularExpressions.Regex.IsMatch(
|
|
candidate, UnsSegment.AttributePattern);
|
|
|
|
byAttribute.ShouldBe(UnsSegment.IsValid(candidate));
|
|
}
|
|
|
|
/// <summary>
|
|
/// <see cref="UnsSegment.Pattern"/> alone does NOT admit <c>_default</c>. Pinned because using it
|
|
/// on a form — the obvious mistake, and what the equipment form did — makes the UI stricter than
|
|
/// the deploy gate, so a <c>_default</c> row becomes uneditable through the AdminUI.
|
|
/// </summary>
|
|
[Fact]
|
|
public void The_bare_pattern_excludes_the_default_segment_but_IsValid_admits_it()
|
|
{
|
|
System.Text.RegularExpressions.Regex
|
|
.IsMatch(UnsSegment.DefaultSegment, UnsSegment.Pattern).ShouldBeFalse();
|
|
UnsSegment.IsValid(UnsSegment.DefaultSegment).ShouldBeTrue();
|
|
}
|
|
}
|