refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
using System.Reflection;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests;
|
||||
|
||||
public class ArchitecturalConstraintTests
|
||||
{
|
||||
private static readonly Assembly CommonsAssembly = typeof(ZB.MOM.WW.ScadaBridge.Commons.Types.RetryPolicy).Assembly;
|
||||
|
||||
private static string GetCsprojPath()
|
||||
{
|
||||
// Walk up from test output to find the Commons csproj
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "src", "ZB.MOM.WW.ScadaBridge.Commons", "ZB.MOM.WW.ScadaBridge.Commons.csproj");
|
||||
if (File.Exists(candidate))
|
||||
return candidate;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
throw new InvalidOperationException("Could not find ZB.MOM.WW.ScadaBridge.Commons.csproj");
|
||||
}
|
||||
|
||||
private static string GetCommonsSourceDirectory()
|
||||
{
|
||||
var csproj = GetCsprojPath();
|
||||
return Path.GetDirectoryName(csproj)!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Csproj_ShouldNotHaveForbiddenPackageReferences()
|
||||
{
|
||||
var csprojPath = GetCsprojPath();
|
||||
var doc = XDocument.Load(csprojPath);
|
||||
var ns = doc.Root!.GetDefaultNamespace();
|
||||
|
||||
var forbiddenPrefixes = new[] { "Akka.", "Microsoft.AspNetCore.", "Microsoft.EntityFrameworkCore." };
|
||||
|
||||
var packageRefs = doc.Descendants(ns + "PackageReference")
|
||||
.Select(e => e.Attribute("Include")?.Value)
|
||||
.Where(v => v != null)
|
||||
.ToList();
|
||||
|
||||
foreach (var pkg in packageRefs)
|
||||
{
|
||||
foreach (var prefix in forbiddenPrefixes)
|
||||
{
|
||||
Assert.False(pkg!.StartsWith(prefix, StringComparison.OrdinalIgnoreCase),
|
||||
$"Commons has forbidden package reference: {pkg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assembly_ShouldNotReferenceForbiddenAssemblies()
|
||||
{
|
||||
var forbiddenPrefixes = new[] { "Akka.", "Microsoft.AspNetCore.", "Microsoft.EntityFrameworkCore." };
|
||||
|
||||
var referencedAssemblies = CommonsAssembly.GetReferencedAssemblies();
|
||||
|
||||
foreach (var asmName in referencedAssemblies)
|
||||
{
|
||||
foreach (var prefix in forbiddenPrefixes)
|
||||
{
|
||||
Assert.False(asmName.Name!.StartsWith(prefix, StringComparison.OrdinalIgnoreCase),
|
||||
$"Commons references forbidden assembly: {asmName.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Commons_ShouldNotContainServiceOrActorImplementations()
|
||||
{
|
||||
// Heuristic: class has > 3 public methods that are neither constructors,
|
||||
// property accessors, common Object overrides, nor interface-implementation
|
||||
// methods (a dictionary wrapper exposing ContainsKey/TryGetValue/GetEnumerator
|
||||
// via IReadOnlyDictionary isn't a service — that's just the interface).
|
||||
var types = CommonsAssembly.GetTypes()
|
||||
.Where(t => t.IsClass && !t.IsAbstract && !t.IsInterface);
|
||||
|
||||
foreach (var type in types)
|
||||
{
|
||||
var interfaceMethodNames = type.GetInterfaces()
|
||||
.SelectMany(i => i.GetMethods())
|
||||
.Select(m => m.Name)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var publicMethods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
|
||||
.Where(m => !m.IsSpecialName) // excludes property getters/setters
|
||||
.Where(m => !m.Name.StartsWith("<")) // excludes compiler-generated
|
||||
.Where(m => m.Name != "ToString" && m.Name != "GetHashCode" &&
|
||||
m.Name != "Equals" && m.Name != "Deconstruct" &&
|
||||
m.Name != "PrintMembers" && m.Name != "GetType")
|
||||
.Where(m => !interfaceMethodNames.Contains(m.Name))
|
||||
.ToList();
|
||||
|
||||
Assert.True(publicMethods.Count <= 3,
|
||||
$"Type {type.FullName} has {publicMethods.Count} public non-interface methods ({string.Join(", ", publicMethods.Select(m => m.Name))}), which suggests it may contain service/actor logic");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SourceFiles_ShouldNotContainToLocalTime()
|
||||
{
|
||||
var sourceDir = GetCommonsSourceDirectory();
|
||||
var csFiles = Directory.GetFiles(sourceDir, "*.cs", SearchOption.AllDirectories);
|
||||
|
||||
foreach (var file in csFiles)
|
||||
{
|
||||
var content = File.ReadAllText(file);
|
||||
Assert.DoesNotContain("ToLocalTime()", content);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllEntities_ShouldHaveNoEfAttributes()
|
||||
{
|
||||
var efAttributeNames = new HashSet<string>
|
||||
{
|
||||
"KeyAttribute", "ForeignKeyAttribute", "TableAttribute",
|
||||
"ColumnAttribute", "RequiredAttribute", "MaxLengthAttribute",
|
||||
"StringLengthAttribute", "DatabaseGeneratedAttribute",
|
||||
"NotMappedAttribute", "IndexAttribute", "InversePropertyAttribute"
|
||||
};
|
||||
|
||||
var entityTypes = CommonsAssembly.GetTypes()
|
||||
.Where(t => t.IsClass && !t.IsAbstract && t.Namespace != null
|
||||
&& t.Namespace.Contains(".Entities."));
|
||||
|
||||
foreach (var type in entityTypes)
|
||||
{
|
||||
foreach (var attr in type.GetCustomAttributes(true))
|
||||
{
|
||||
Assert.DoesNotContain(attr.GetType().Name, efAttributeNames);
|
||||
}
|
||||
|
||||
foreach (var prop in type.GetProperties())
|
||||
{
|
||||
foreach (var attr in prop.GetCustomAttributes(true))
|
||||
{
|
||||
Assert.False(efAttributeNames.Contains(attr.GetType().Name),
|
||||
$"{type.Name}.{prop.Name} has EF attribute {attr.GetType().Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DebugStreamEvent_ShouldNotExist()
|
||||
{
|
||||
// DebugStreamEvent was removed when debug streaming moved from ClusterClient to gRPC.
|
||||
// Events now flow via SiteStreamManager → StreamRelayActor → gRPC channel.
|
||||
var type = CommonsAssembly.GetTypes().FirstOrDefault(t => t.Name == "DebugStreamEvent");
|
||||
Assert.Null(type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllEnums_ShouldBeSingularNamed()
|
||||
{
|
||||
var enums = CommonsAssembly.GetTypes().Where(t => t.IsEnum);
|
||||
|
||||
foreach (var enumType in enums)
|
||||
{
|
||||
var name = enumType.Name;
|
||||
// Singular names should not end with 's' (except words ending in 'ss' or 'us' which are singular)
|
||||
Assert.False(name.EndsWith("s") && !name.EndsWith("ss") && !name.EndsWith("us"),
|
||||
$"Enum {name} appears to be plural");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllMessageTypes_ShouldBeRecords()
|
||||
{
|
||||
var messageTypes = CommonsAssembly.GetTypes()
|
||||
.Where(t => t.Namespace != null
|
||||
&& t.Namespace.Contains(".Messages.")
|
||||
&& !t.IsEnum && !t.IsInterface
|
||||
&& !(t.IsAbstract && t.IsSealed) // exclude static classes (utilities)
|
||||
&& !t.Name.StartsWith("<") // exclude compiler-generated types
|
||||
&& (t.IsClass || (t.IsValueType && !t.IsPrimitive)));
|
||||
|
||||
foreach (var type in messageTypes)
|
||||
{
|
||||
var cloneMethod = type.GetMethod("<Clone>$", BindingFlags.Public | BindingFlags.Instance);
|
||||
Assert.True(cloneMethod != null,
|
||||
$"{type.FullName} in Messages namespace should be a record type");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyEntities_ShouldHaveDateTimeOffsetTimestamps()
|
||||
{
|
||||
// Spot-check key entity timestamp properties
|
||||
var deploymentRecord = CommonsAssembly.GetType("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord");
|
||||
Assert.NotNull(deploymentRecord);
|
||||
Assert.Equal(typeof(DateTimeOffset), deploymentRecord!.GetProperty("DeployedAt")!.PropertyType);
|
||||
Assert.Equal(typeof(DateTimeOffset?), deploymentRecord.GetProperty("CompletedAt")!.PropertyType);
|
||||
|
||||
var artifactRecord = CommonsAssembly.GetType("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.SystemArtifactDeploymentRecord");
|
||||
Assert.NotNull(artifactRecord);
|
||||
Assert.Equal(typeof(DateTimeOffset), artifactRecord!.GetProperty("DeployedAt")!.PropertyType);
|
||||
|
||||
var auditEntry = CommonsAssembly.GetType("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditLogEntry");
|
||||
Assert.NotNull(auditEntry);
|
||||
Assert.Equal(typeof(DateTimeOffset), auditEntry!.GetProperty("Timestamp")!.PropertyType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// ConfigurationDatabase-012: the <see cref="ApiKey"/> entity must never carry the
|
||||
/// plaintext bearer credential as a persisted field — only its deterministic hash.
|
||||
/// </summary>
|
||||
public class ApiKeyTests
|
||||
{
|
||||
[Fact]
|
||||
public void ApiKey_HasNoPlaintextKeyValueProperty()
|
||||
{
|
||||
// The plaintext key is shown to the operator once at creation and is never
|
||||
// persisted. The entity must therefore expose KeyHash, not KeyValue.
|
||||
var properties = typeof(ApiKey).GetProperties().Select(p => p.Name).ToArray();
|
||||
|
||||
Assert.DoesNotContain("KeyValue", properties);
|
||||
Assert.Contains("KeyHash", properties);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_FromPlaintext_StoresHashNotPlaintext()
|
||||
{
|
||||
var key = new ApiKey("MES-Production", "the-secret-key-value");
|
||||
|
||||
Assert.NotEqual("the-secret-key-value", key.KeyHash);
|
||||
Assert.Equal(ApiKeyHasher.Default.Hash("the-secret-key-value"), key.KeyHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromHash_StoresHashVerbatim()
|
||||
{
|
||||
var key = ApiKey.FromHash("RecipeManager-Dev", "precomputed-hash-value");
|
||||
|
||||
Assert.Equal("RecipeManager-Dev", key.Name);
|
||||
Assert.Equal("precomputed-hash-value", key.KeyHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullArguments_Throw()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new ApiKey(null!, "value"));
|
||||
Assert.Throws<ArgumentNullException>(() => new ApiKey("name", (string)null!));
|
||||
Assert.Throws<ArgumentNullException>(() => ApiKey.FromHash(null!, "hash"));
|
||||
Assert.Throws<ArgumentNullException>(() => ApiKey.FromHash("name", null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="AuditEvent"/> behaves as an init-only record:
|
||||
/// every property reads back as constructed, and <c>with</c> expressions
|
||||
/// produce a new instance with a single property changed.
|
||||
/// </summary>
|
||||
public class AuditEventTests
|
||||
{
|
||||
[Fact]
|
||||
public void Construction_AllPropertiesReadBack()
|
||||
{
|
||||
var eventId = Guid.NewGuid();
|
||||
var occurredAt = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
|
||||
var ingestedAt = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc);
|
||||
var corrId = Guid.NewGuid();
|
||||
var execId = Guid.NewGuid();
|
||||
|
||||
var evt = new AuditEvent
|
||||
{
|
||||
EventId = eventId,
|
||||
OccurredAtUtc = occurredAt,
|
||||
IngestedAtUtc = ingestedAt,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
CorrelationId = corrId,
|
||||
ExecutionId = execId,
|
||||
SourceSiteId = "site-01",
|
||||
SourceInstanceId = "inst-7",
|
||||
SourceScript = "OnAlarm",
|
||||
Actor = "system",
|
||||
Target = "WeatherAPI",
|
||||
Status = AuditStatus.Delivered,
|
||||
HttpStatus = 200,
|
||||
DurationMs = 42,
|
||||
ErrorMessage = null,
|
||||
ErrorDetail = null,
|
||||
RequestSummary = "GET /forecast",
|
||||
ResponseSummary = "{\"temp\":21}",
|
||||
PayloadTruncated = false,
|
||||
Extra = "{}",
|
||||
ForwardState = AuditForwardState.Forwarded
|
||||
};
|
||||
|
||||
Assert.Equal(eventId, evt.EventId);
|
||||
Assert.Equal(occurredAt, evt.OccurredAtUtc);
|
||||
Assert.Equal(ingestedAt, evt.IngestedAtUtc);
|
||||
Assert.Equal(AuditChannel.ApiOutbound, evt.Channel);
|
||||
Assert.Equal(AuditKind.ApiCall, evt.Kind);
|
||||
Assert.Equal(corrId, evt.CorrelationId);
|
||||
Assert.Equal(execId, evt.ExecutionId);
|
||||
Assert.Equal("site-01", evt.SourceSiteId);
|
||||
Assert.Equal("inst-7", evt.SourceInstanceId);
|
||||
Assert.Equal("OnAlarm", evt.SourceScript);
|
||||
Assert.Equal("system", evt.Actor);
|
||||
Assert.Equal("WeatherAPI", evt.Target);
|
||||
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
||||
Assert.Equal(200, evt.HttpStatus);
|
||||
Assert.Equal(42, evt.DurationMs);
|
||||
Assert.Null(evt.ErrorMessage);
|
||||
Assert.Null(evt.ErrorDetail);
|
||||
Assert.Equal("GET /forecast", evt.RequestSummary);
|
||||
Assert.Equal("{\"temp\":21}", evt.ResponseSummary);
|
||||
Assert.False(evt.PayloadTruncated);
|
||||
Assert.Equal("{}", evt.Extra);
|
||||
Assert.Equal(AuditForwardState.Forwarded, evt.ForwardState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullableProperties_AcceptNull()
|
||||
{
|
||||
var evt = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
IngestedAtUtc = null,
|
||||
Channel = AuditChannel.Notification,
|
||||
Kind = AuditKind.NotifySend,
|
||||
CorrelationId = null,
|
||||
ExecutionId = null,
|
||||
SourceSiteId = null,
|
||||
SourceInstanceId = null,
|
||||
SourceScript = null,
|
||||
Actor = null,
|
||||
Target = null,
|
||||
Status = AuditStatus.Submitted,
|
||||
HttpStatus = null,
|
||||
DurationMs = null,
|
||||
ErrorMessage = null,
|
||||
ErrorDetail = null,
|
||||
RequestSummary = null,
|
||||
ResponseSummary = null,
|
||||
PayloadTruncated = false,
|
||||
Extra = null,
|
||||
ForwardState = null
|
||||
};
|
||||
|
||||
Assert.Null(evt.IngestedAtUtc);
|
||||
Assert.Null(evt.CorrelationId);
|
||||
Assert.Null(evt.ExecutionId);
|
||||
Assert.Null(evt.SourceSiteId);
|
||||
Assert.Null(evt.SourceInstanceId);
|
||||
Assert.Null(evt.SourceScript);
|
||||
Assert.Null(evt.Actor);
|
||||
Assert.Null(evt.Target);
|
||||
Assert.Null(evt.HttpStatus);
|
||||
Assert.Null(evt.DurationMs);
|
||||
Assert.Null(evt.ErrorMessage);
|
||||
Assert.Null(evt.ErrorDetail);
|
||||
Assert.Null(evt.RequestSummary);
|
||||
Assert.Null(evt.ResponseSummary);
|
||||
Assert.Null(evt.Extra);
|
||||
Assert.Null(evt.ForwardState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditEvent_carries_SourceNode_through_init()
|
||||
{
|
||||
// SourceNode identifies the cluster node that emitted the event (site
|
||||
// node-a/node-b or central-a/central-b). It's an additive nullable
|
||||
// init-only property — defaults to null when omitted, round-trips its
|
||||
// value when set, and is preserved through `with` expressions.
|
||||
var evtDefault = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Submitted,
|
||||
PayloadTruncated = false
|
||||
};
|
||||
Assert.Null(evtDefault.SourceNode);
|
||||
|
||||
var evtStamped = evtDefault with { SourceNode = "node-a" };
|
||||
Assert.Equal("node-a", evtStamped.SourceNode);
|
||||
Assert.Null(evtDefault.SourceNode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_ProducesNewInstance_WithSingleFieldChanged()
|
||||
{
|
||||
var original = new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Submitted,
|
||||
PayloadTruncated = false
|
||||
};
|
||||
|
||||
var updated = original with { Status = AuditStatus.Delivered };
|
||||
|
||||
Assert.NotSame(original, updated);
|
||||
Assert.Equal(AuditStatus.Submitted, original.Status);
|
||||
Assert.Equal(AuditStatus.Delivered, updated.Status);
|
||||
Assert.Equal(original.EventId, updated.EventId);
|
||||
Assert.NotEqual(original, updated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the <see cref="SiteCall"/> central operational entity carries the
|
||||
/// SourceNode column (additive, nullable) through init-only construction and
|
||||
/// <c>with</c> expressions. Sibling to <see cref="AuditEventTests"/>.
|
||||
/// </summary>
|
||||
public class SiteCallTests
|
||||
{
|
||||
private static SiteCall MinimalRow() => new()
|
||||
{
|
||||
TrackedOperationId = TrackedOperationId.New(),
|
||||
Channel = "ApiOutbound",
|
||||
Target = "ERP.GetOrder",
|
||||
SourceSite = "site-01",
|
||||
Status = "Submitted",
|
||||
RetryCount = 0,
|
||||
CreatedAtUtc = new DateTime(2026, 5, 23, 12, 0, 0, DateTimeKind.Utc),
|
||||
UpdatedAtUtc = new DateTime(2026, 5, 23, 12, 0, 0, DateTimeKind.Utc),
|
||||
IngestedAtUtc = new DateTime(2026, 5, 23, 12, 0, 1, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void SiteCall_carries_SourceNode()
|
||||
{
|
||||
// SourceNode identifies the cluster node that emitted the cached call
|
||||
// (site node-a/node-b or central-a/central-b). Additive nullable init
|
||||
// property — defaults to null on rows ingested before the column
|
||||
// existed, and round-trips its value via `with` expressions.
|
||||
var row = MinimalRow();
|
||||
Assert.Null(row.SourceNode);
|
||||
|
||||
var stamped = row with { SourceNode = "node-b" };
|
||||
Assert.Equal("node-b", stamped.SourceNode);
|
||||
Assert.Null(row.SourceNode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities;
|
||||
|
||||
public class EntityConventionTests
|
||||
{
|
||||
private static readonly Assembly CommonsAssembly = typeof(ZB.MOM.WW.ScadaBridge.Commons.Types.RetryPolicy).Assembly;
|
||||
|
||||
private static IEnumerable<Type> GetEntityTypes() =>
|
||||
CommonsAssembly.GetTypes()
|
||||
.Where(t => t.IsClass && !t.IsAbstract && t.Namespace != null
|
||||
&& t.Namespace.Contains(".Entities."));
|
||||
|
||||
[Fact]
|
||||
public void AllEntities_ShouldHaveNoEfAttributes()
|
||||
{
|
||||
var efAttributeNames = new HashSet<string>
|
||||
{
|
||||
"KeyAttribute", "ForeignKeyAttribute", "TableAttribute",
|
||||
"ColumnAttribute", "RequiredAttribute", "MaxLengthAttribute",
|
||||
"StringLengthAttribute", "DatabaseGeneratedAttribute",
|
||||
"NotMappedAttribute", "IndexAttribute", "InversePropertyAttribute"
|
||||
};
|
||||
|
||||
foreach (var entityType in GetEntityTypes())
|
||||
{
|
||||
// Check class-level attributes
|
||||
var classAttrs = entityType.GetCustomAttributes(true);
|
||||
foreach (var attr in classAttrs)
|
||||
{
|
||||
Assert.DoesNotContain(attr.GetType().Name, efAttributeNames);
|
||||
}
|
||||
|
||||
// Check property-level attributes
|
||||
foreach (var prop in entityType.GetProperties())
|
||||
{
|
||||
var propAttrs = prop.GetCustomAttributes(true);
|
||||
foreach (var attr in propAttrs)
|
||||
{
|
||||
Assert.False(efAttributeNames.Contains(attr.GetType().Name),
|
||||
$"Entity {entityType.Name}.{prop.Name} has EF attribute {attr.GetType().Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllTimestampProperties_ShouldBeDateTimeOffset()
|
||||
{
|
||||
var timestampNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Timestamp", "DeployedAt", "CompletedAt", "GeneratedAt",
|
||||
"ReportTimestamp", "SnapshotTimestamp"
|
||||
};
|
||||
|
||||
foreach (var entityType in GetEntityTypes())
|
||||
{
|
||||
foreach (var prop in entityType.GetProperties())
|
||||
{
|
||||
if (timestampNames.Contains(prop.Name))
|
||||
{
|
||||
var underlyingType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
|
||||
Assert.True(underlyingType == typeof(DateTimeOffset),
|
||||
$"{entityType.Name}.{prop.Name} should be DateTimeOffset but is {prop.PropertyType.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigationProperties_ShouldBeICollection()
|
||||
{
|
||||
foreach (var entityType in GetEntityTypes())
|
||||
{
|
||||
foreach (var prop in entityType.GetProperties())
|
||||
{
|
||||
if (prop.PropertyType.IsGenericType &&
|
||||
typeof(IEnumerable).IsAssignableFrom(prop.PropertyType) &&
|
||||
prop.PropertyType != typeof(string))
|
||||
{
|
||||
Assert.True(
|
||||
prop.PropertyType.GetGenericTypeDefinition() == typeof(ICollection<>),
|
||||
$"{entityType.Name}.{prop.Name} should be ICollection<T> but is {prop.PropertyType.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the <see cref="Notification"/> outbox entity's constructor defaults
|
||||
/// and null-argument guards on required reference-type parameters.
|
||||
/// </summary>
|
||||
public class NotificationEntityTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_SetsDefaults()
|
||||
{
|
||||
var n = new Notification("id-1", NotificationType.Email, "ops-team", "subj", "body", "SiteA");
|
||||
Assert.Equal(NotificationStatus.Pending, n.Status);
|
||||
Assert.Equal(0, n.RetryCount);
|
||||
Assert.Equal("id-1", n.NotificationId);
|
||||
Assert.Equal(NotificationType.Email, n.Type);
|
||||
Assert.Equal("ops-team", n.ListName);
|
||||
Assert.Equal("SiteA", n.SourceSiteId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginExecutionId_DefaultsToNull_AndIsSettable()
|
||||
{
|
||||
// Audit Log #23: OriginExecutionId carries the originating script
|
||||
// execution's id from the site so the dispatcher can echo it onto
|
||||
// NotifyDeliver rows. Null for notifications submitted before the
|
||||
// column existed; settable from the NotificationSubmit message.
|
||||
var n = new Notification("id-1", NotificationType.Email, "ops-team", "subj", "body", "SiteA");
|
||||
Assert.Null(n.OriginExecutionId);
|
||||
|
||||
var executionId = Guid.NewGuid();
|
||||
n.OriginExecutionId = executionId;
|
||||
Assert.Equal(executionId, n.OriginExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OriginParentExecutionId_DefaultsToNull_AndIsSettable()
|
||||
{
|
||||
// Audit Log ParentExecutionId: OriginParentExecutionId carries the
|
||||
// routed run's parent ExecutionId from the site so the dispatcher can
|
||||
// echo it onto NotifyDeliver rows. Null for non-routed runs, or for
|
||||
// notifications submitted before the column existed.
|
||||
var n = new Notification("id-1", NotificationType.Email, "ops-team", "subj", "body", "SiteA");
|
||||
Assert.Null(n.OriginParentExecutionId);
|
||||
|
||||
var parentExecutionId = Guid.NewGuid();
|
||||
n.OriginParentExecutionId = parentExecutionId;
|
||||
Assert.Equal(parentExecutionId, n.OriginParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SourceNode_DefaultsToNull_AndIsSettable()
|
||||
{
|
||||
// SourceNode identifies the cluster node that emitted the notification
|
||||
// (site node-a/node-b or central-a/central-b). Additive nullable
|
||||
// property — defaults to null on rows submitted before the column
|
||||
// existed, and round-trips its value when set.
|
||||
var n = new Notification("id-1", NotificationType.Email, "ops-team", "subj", "body", "SiteA");
|
||||
Assert.Null(n.SourceNode);
|
||||
|
||||
n.SourceNode = "node-a";
|
||||
Assert.Equal("node-a", n.SourceNode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullArguments_Throw()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new Notification(null!, NotificationType.Email, "list", "s", "b", "SiteA"));
|
||||
Assert.Throws<ArgumentNullException>(() => new Notification("id", NotificationType.Email, null!, "s", "b", "SiteA"));
|
||||
Assert.Throws<ArgumentNullException>(() => new Notification("id", NotificationType.Email, "list", null!, "b", "SiteA"));
|
||||
Assert.Throws<ArgumentNullException>(() => new Notification("id", NotificationType.Email, "list", "s", null!, "SiteA"));
|
||||
Assert.Throws<ArgumentNullException>(() => new Notification("id", NotificationType.Email, "list", "s", "b", null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities;
|
||||
|
||||
public class TemplateFolderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_SetsName()
|
||||
{
|
||||
var folder = new TemplateFolder("Dev");
|
||||
|
||||
Assert.Equal("Dev", folder.Name);
|
||||
Assert.Null(folder.ParentFolderId);
|
||||
Assert.Equal(0, folder.SortOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullName_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new TemplateFolder(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Template_FolderId_DefaultsToNull()
|
||||
{
|
||||
var template = new Template("X");
|
||||
Assert.Null(template.FolderId);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using System.Reflection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Interfaces.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reflection-level contract guards for the Audit Log (#23) writer interfaces.
|
||||
/// Locks in method signature so DI bindings + adapter implementations stay aligned.
|
||||
/// </summary>
|
||||
public class AuditWriterContractTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(typeof(IAuditWriter))]
|
||||
[InlineData(typeof(ICentralAuditWriter))]
|
||||
public void WriteAsync_HasExpectedSignature(Type writerType)
|
||||
{
|
||||
var method = writerType.GetMethod("WriteAsync", BindingFlags.Instance | BindingFlags.Public);
|
||||
Assert.NotNull(method);
|
||||
Assert.Equal(typeof(Task), method!.ReturnType);
|
||||
|
||||
var parameters = method.GetParameters();
|
||||
Assert.Equal(2, parameters.Length);
|
||||
Assert.Equal(typeof(AuditEvent), parameters[0].ParameterType);
|
||||
Assert.Equal(typeof(CancellationToken), parameters[1].ParameterType);
|
||||
Assert.True(parameters[1].HasDefaultValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IAuditWriter_AndICentralAuditWriter_AreDistinctTypes()
|
||||
{
|
||||
Assert.NotEqual(typeof(IAuditWriter), typeof(ICentralAuditWriter));
|
||||
Assert.False(typeof(IAuditWriter).IsAssignableFrom(typeof(ICentralAuditWriter)));
|
||||
Assert.False(typeof(ICentralAuditWriter).IsAssignableFrom(typeof(IAuditWriter)));
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Interfaces.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ExternalCallResult"/>, in particular the Commons-021
|
||||
/// thread-safe lazy parse of <c>Response</c>. The pre-fix implementation used
|
||||
/// two mutable fields (<c>_response</c>/<c>_responseParsed</c>) with no
|
||||
/// synchronization, so concurrent readers could each construct a fresh
|
||||
/// <c>DynamicJsonElement</c> and one would overwrite the other. The fix moves
|
||||
/// the parse onto a <c>Lazy<dynamic?></c> with
|
||||
/// <c>LazyThreadSafetyMode.ExecutionAndPublication</c> (the default), which
|
||||
/// guarantees one parse and one shared result for all readers.
|
||||
/// </summary>
|
||||
public class ExternalCallResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void Response_NullOrEmptyJson_ReturnsNull()
|
||||
{
|
||||
var withNull = new ExternalCallResult(Success: true, ResponseJson: null, ErrorMessage: null);
|
||||
var withEmpty = new ExternalCallResult(Success: true, ResponseJson: string.Empty, ErrorMessage: null);
|
||||
|
||||
Assert.Null(withNull.Response);
|
||||
Assert.Null(withEmpty.Response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Response_ParsesJsonIntoDynamicElement()
|
||||
{
|
||||
var result = new ExternalCallResult(Success: true, ResponseJson: "{\"answer\": 42}", ErrorMessage: null);
|
||||
|
||||
// dynamic property access is the production usage pattern.
|
||||
dynamic? response = result.Response;
|
||||
Assert.NotNull(response);
|
||||
int answer = (int)response!.answer;
|
||||
Assert.Equal(42, answer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commons-021: concurrent readers must observe the same parsed instance
|
||||
/// (a `Lazy<T>` invariant). Under the pre-fix code two threads could
|
||||
/// both produce a fresh `DynamicJsonElement` and one would win the race —
|
||||
/// `ReferenceEquals` would then occasionally fail. With the fix every
|
||||
/// reader observes the single Lazy-published value, so the assertion
|
||||
/// holds for every pair of observers.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Response_ConcurrentReads_ReturnSameInstance()
|
||||
{
|
||||
// A larger payload makes the parse window wider so the race, if
|
||||
// present, is more likely to fire. The same property — single
|
||||
// published instance — must hold for any payload, though.
|
||||
var json = "{\"items\":[{\"name\":\"a\"},{\"name\":\"b\"},{\"name\":\"c\"}],\"count\":3}";
|
||||
var result = new ExternalCallResult(Success: true, ResponseJson: json, ErrorMessage: null);
|
||||
|
||||
const int observerCount = 64;
|
||||
var barrier = new Barrier(observerCount);
|
||||
var observed = new object?[observerCount];
|
||||
|
||||
Parallel.For(0, observerCount, i =>
|
||||
{
|
||||
// Force all observers to call `Response` at the same instant so
|
||||
// they collide on the lazy parse rather than each finding it
|
||||
// already-published.
|
||||
barrier.SignalAndWait();
|
||||
observed[i] = result.Response;
|
||||
});
|
||||
|
||||
var first = observed[0];
|
||||
Assert.NotNull(first);
|
||||
for (var i = 1; i < observerCount; i++)
|
||||
{
|
||||
Assert.Same(first, observed[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// WP-9 (Phase 8): Message contract compatibility tests.
|
||||
/// Verifies forward compatibility (unknown fields), backward compatibility (missing optional fields),
|
||||
/// and version skew scenarios for all critical message types.
|
||||
/// </summary>
|
||||
public class CompatibilityTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
// ── Forward Compatibility: unknown fields are ignored ──
|
||||
|
||||
[Fact]
|
||||
public void ForwardCompat_DeployInstanceCommand_UnknownFieldIgnored()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"DeploymentId": "dep-1",
|
||||
"InstanceUniqueName": "inst-1",
|
||||
"RevisionHash": "abc123",
|
||||
"FlattenedConfigurationJson": "{}",
|
||||
"DeployedBy": "admin",
|
||||
"Timestamp": "2025-01-01T00:00:00+00:00",
|
||||
"FutureField": "unknown-value",
|
||||
"AnotherNewField": 42
|
||||
}
|
||||
""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<DeployInstanceCommand>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal("dep-1", msg!.DeploymentId);
|
||||
Assert.Equal("abc123", msg.RevisionHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardCompat_SiteHealthReport_UnknownFieldIgnored()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"SiteId": "site-01",
|
||||
"SequenceNumber": 5,
|
||||
"ReportTimestamp": "2025-01-01T00:00:00+00:00",
|
||||
"DataConnectionStatuses": {},
|
||||
"TagResolutionCounts": {},
|
||||
"ScriptErrorCount": 0,
|
||||
"AlarmEvaluationErrorCount": 0,
|
||||
"StoreAndForwardBufferDepths": {},
|
||||
"DeadLetterCount": 0,
|
||||
"FutureMetric": 99
|
||||
}
|
||||
""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<SiteHealthReport>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal("site-01", msg!.SiteId);
|
||||
Assert.Equal(5, msg.SequenceNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardCompat_ScriptCallRequest_UnknownFieldIgnored()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"CorrelationId": "corr-1",
|
||||
"InstanceUniqueName": "inst-1",
|
||||
"ScriptName": "OnTrigger",
|
||||
"Parameters": {},
|
||||
"Timestamp": "2025-01-01T00:00:00+00:00",
|
||||
"NewExecutionMode": "parallel"
|
||||
}
|
||||
""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<ScriptCallRequest>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal("corr-1", msg!.CorrelationId);
|
||||
Assert.Equal("OnTrigger", msg.ScriptName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardCompat_AttributeValueChanged_UnknownFieldIgnored()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"InstanceUniqueName": "inst-1",
|
||||
"AttributeName": "Temperature",
|
||||
"TagPath": "opc:ns=2;s=Temp",
|
||||
"Value": 42.5,
|
||||
"Quality": "Good",
|
||||
"Timestamp": "2025-01-01T00:00:00+00:00",
|
||||
"SourceInfo": {"origin": "future-feature"}
|
||||
}
|
||||
""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<AttributeValueChanged>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal("Temperature", msg!.AttributeName);
|
||||
}
|
||||
|
||||
// ── Backward Compatibility: missing optional fields ──
|
||||
|
||||
[Fact]
|
||||
public void BackwardCompat_DeploymentStatusResponse_MissingErrorMessage()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"DeploymentId": "dep-1",
|
||||
"InstanceUniqueName": "inst-1",
|
||||
"Status": 2,
|
||||
"Timestamp": "2025-01-01T00:00:00+00:00"
|
||||
}
|
||||
""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<DeploymentStatusResponse>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal("dep-1", msg!.DeploymentId);
|
||||
Assert.Null(msg.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackwardCompat_ScriptCallResult_MissingReturnValue()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"CorrelationId": "corr-1",
|
||||
"Success": false,
|
||||
"ErrorMessage": "Script not found"
|
||||
}
|
||||
""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<ScriptCallResult>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.False(msg!.Success);
|
||||
Assert.Null(msg.ReturnValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackwardCompat_DeployArtifactsCommand_MissingOptionalLists()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"DeploymentId": "dep-1",
|
||||
"Timestamp": "2025-01-01T00:00:00+00:00"
|
||||
}
|
||||
""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<DeployArtifactsCommand>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal("dep-1", msg!.DeploymentId);
|
||||
Assert.Null(msg.SharedScripts);
|
||||
Assert.Null(msg.ExternalSystems);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackwardCompat_InstanceLifecycleResponse_MissingErrorMessage()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"CommandId": "cmd-1",
|
||||
"InstanceUniqueName": "inst-1",
|
||||
"Success": true,
|
||||
"Timestamp": "2025-01-01T00:00:00+00:00"
|
||||
}
|
||||
""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<InstanceLifecycleResponse>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.True(msg!.Success);
|
||||
Assert.Null(msg.ErrorMessage);
|
||||
}
|
||||
|
||||
// ── Version Skew: old message format still deserializable ──
|
||||
|
||||
[Fact]
|
||||
public void VersionSkew_OldDeployCommand_DeserializesWithDefaults()
|
||||
{
|
||||
// Simulate an older version that only had DeploymentId and InstanceUniqueName
|
||||
var json = """
|
||||
{
|
||||
"DeploymentId": "dep-old",
|
||||
"InstanceUniqueName": "inst-old",
|
||||
"RevisionHash": "old-hash",
|
||||
"FlattenedConfigurationJson": "{}",
|
||||
"DeployedBy": "admin",
|
||||
"Timestamp": "2024-06-01T00:00:00+00:00"
|
||||
}
|
||||
""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<DeployInstanceCommand>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal("dep-old", msg!.DeploymentId);
|
||||
Assert.Equal("old-hash", msg.RevisionHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VersionSkew_OldHealthReport_DeserializesCorrectly()
|
||||
{
|
||||
// Older version without DeadLetterCount
|
||||
var json = """
|
||||
{
|
||||
"SiteId": "site-old",
|
||||
"SequenceNumber": 1,
|
||||
"ReportTimestamp": "2024-06-01T00:00:00+00:00",
|
||||
"DataConnectionStatuses": {"conn1": 0},
|
||||
"TagResolutionCounts": {},
|
||||
"ScriptErrorCount": 0,
|
||||
"AlarmEvaluationErrorCount": 0,
|
||||
"StoreAndForwardBufferDepths": {}
|
||||
}
|
||||
""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<SiteHealthReport>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal("site-old", msg!.SiteId);
|
||||
Assert.Equal(0, msg.DeadLetterCount); // Default value
|
||||
}
|
||||
|
||||
// ── Round-trip serialization for all key message types ──
|
||||
|
||||
// Communication-016: RoundTrip_ConnectionStateChanged_Succeeds removed
|
||||
// alongside the dead ConnectionStateChanged message record. No production
|
||||
// code emits or receives this message — disconnect detection is owned by
|
||||
// the gRPC keepalive and the Ask-timeout path.
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_AlarmStateChanged_Succeeds()
|
||||
{
|
||||
var msg = new AlarmStateChanged("inst-1", "HighTemp", AlarmState.Active, 1, DateTimeOffset.UtcNow);
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<AlarmStateChanged>(json, Options);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal(AlarmState.Active, deserialized!.State);
|
||||
Assert.Equal("HighTemp", deserialized.AlarmName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_HeartbeatMessage_Succeeds()
|
||||
{
|
||||
var msg = new HeartbeatMessage("site-01", "node-a", true, DateTimeOffset.UtcNow);
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<HeartbeatMessage>(json, Options);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal("site-01", deserialized!.SiteId);
|
||||
Assert.Equal("node-a", deserialized.NodeHostname);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_DisableInstanceCommand_Succeeds()
|
||||
{
|
||||
var msg = new DisableInstanceCommand("cmd-1", "inst-1", DateTimeOffset.UtcNow);
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<DisableInstanceCommand>(json, Options);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal("cmd-1", deserialized!.CommandId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_EnableInstanceCommand_Succeeds()
|
||||
{
|
||||
var msg = new EnableInstanceCommand("cmd-2", "inst-1", DateTimeOffset.UtcNow);
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<EnableInstanceCommand>(json, Options);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal("cmd-2", deserialized!.CommandId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_DeleteInstanceCommand_Succeeds()
|
||||
{
|
||||
var msg = new DeleteInstanceCommand("cmd-3", "inst-1", DateTimeOffset.UtcNow);
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<DeleteInstanceCommand>(json, Options);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal("cmd-3", deserialized!.CommandId);
|
||||
}
|
||||
|
||||
// ── Additive-only evolution: new fields added as nullable ──
|
||||
|
||||
[Fact]
|
||||
public void AdditiveEvolution_NewNullableFields_DoNotBreakDeserialization()
|
||||
{
|
||||
// The design mandates additive-only evolution for message contracts.
|
||||
// New fields must be nullable/optional so old producers don't break new consumers.
|
||||
// This test verifies the pattern works with System.Text.Json.
|
||||
|
||||
var minimalJson = """{"DeploymentId":"dep-1","InstanceUniqueName":"inst-1","Status":1,"Timestamp":"2025-01-01T00:00:00+00:00"}""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<DeploymentStatusResponse>(minimalJson, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.Null(msg!.ErrorMessage); // Optional field defaults to null
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnumDeserialization_UnknownValue_HandledGracefully()
|
||||
{
|
||||
// If a newer version adds a new enum value, older consumers should handle it.
|
||||
// System.Text.Json will deserialize unknown numeric enum values as the numeric value.
|
||||
var json = """{"DeploymentId":"dep-1","InstanceUniqueName":"inst-1","Status":99,"Timestamp":"2025-01-01T00:00:00+00:00"}""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<DeploymentStatusResponse>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal((DeploymentStatus)99, msg!.Status);
|
||||
}
|
||||
|
||||
// ── DeploymentManager-006: query-the-site-before-redeploy contracts ──
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_DeploymentStateQueryRequest_Succeeds()
|
||||
{
|
||||
var msg = new DeploymentStateQueryRequest("corr-1", "inst-1", DateTimeOffset.UtcNow);
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<DeploymentStateQueryRequest>(json, Options);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal("corr-1", deserialized!.CorrelationId);
|
||||
Assert.Equal("inst-1", deserialized.InstanceUniqueName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_DeploymentStateQueryResponse_Deployed_Succeeds()
|
||||
{
|
||||
var msg = new DeploymentStateQueryResponse(
|
||||
"corr-1", "inst-1", true, "dep-9", "sha256:abc", DateTimeOffset.UtcNow);
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<DeploymentStateQueryResponse>(json, Options);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.True(deserialized!.IsDeployed);
|
||||
Assert.Equal("dep-9", deserialized.AppliedDeploymentId);
|
||||
Assert.Equal("sha256:abc", deserialized.AppliedRevisionHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_DeploymentStateQueryResponse_NotDeployed_NullApplied()
|
||||
{
|
||||
// When the instance is not deployed at the site, the applied identity
|
||||
// fields are null — verified to survive a JSON round-trip.
|
||||
var msg = new DeploymentStateQueryResponse(
|
||||
"corr-1", "inst-1", false, null, null, DateTimeOffset.UtcNow);
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<DeploymentStateQueryResponse>(json, Options);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.False(deserialized!.IsDeployed);
|
||||
Assert.Null(deserialized.AppliedDeploymentId);
|
||||
Assert.Null(deserialized.AppliedRevisionHash);
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for Commons-008 — <see cref="SetConnectionBindingsCommand"/>
|
||||
/// previously declared its bindings as <c>IReadOnlyList<(string, int)></c>.
|
||||
/// A <c>ValueTuple</c> serializes as <c>Item1</c>/<c>Item2</c> and cannot evolve
|
||||
/// additively (REQ-COM-5a). It is now a named <see cref="ConnectionBinding"/> record.
|
||||
/// </summary>
|
||||
public class ConnectionBindingSerializationTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConnectionBinding_SerializesWithNamedProperties()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new ConnectionBinding("Temperature", 42));
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
Assert.Equal(JsonValueKind.String, doc.RootElement.GetProperty("AttributeName").ValueKind);
|
||||
Assert.Equal("Temperature", doc.RootElement.GetProperty("AttributeName").GetString());
|
||||
Assert.Equal(42, doc.RootElement.GetProperty("DataConnectionId").GetInt32());
|
||||
|
||||
// The ValueTuple failure mode: Item1/Item2 must NOT appear.
|
||||
Assert.False(doc.RootElement.TryGetProperty("Item1", out _));
|
||||
Assert.False(doc.RootElement.TryGetProperty("Item2", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetConnectionBindingsCommand_RoundTripsThroughJson()
|
||||
{
|
||||
var original = new SetConnectionBindingsCommand(
|
||||
7,
|
||||
new List<ConnectionBinding>
|
||||
{
|
||||
new("Speed", 5),
|
||||
new("Mode", 11),
|
||||
});
|
||||
|
||||
var json = JsonSerializer.Serialize(original);
|
||||
var deserialized = JsonSerializer.Deserialize<SetConnectionBindingsCommand>(json);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal(7, deserialized!.InstanceId);
|
||||
Assert.Equal(2, deserialized.Bindings.Count);
|
||||
Assert.Equal("Speed", deserialized.Bindings[0].AttributeName);
|
||||
Assert.Equal(5, deserialized.Bindings[0].DataConnectionId);
|
||||
Assert.Equal("Mode", deserialized.Bindings[1].AttributeName);
|
||||
Assert.Equal(11, deserialized.Bindings[1].DataConnectionId);
|
||||
|
||||
// ConnectionBinding is a record: each element compares by value.
|
||||
Assert.Equal(original.Bindings, deserialized.Bindings);
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log (#23) telemetry handoff: envelope + pull request/response DTOs.
|
||||
/// At-least-once from sites; idempotent at central on <see cref="AuditEvent.EventId"/>.
|
||||
/// </summary>
|
||||
public class AuditTelemetryMessagesTests
|
||||
{
|
||||
private static AuditEvent MakeEvent(Guid? id = null) => new()
|
||||
{
|
||||
EventId = id ?? Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
PayloadTruncated = false
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void AuditTelemetryEnvelope_ConstructsWithThreeEvents_AndIsEnumerable()
|
||||
{
|
||||
var envelopeId = Guid.NewGuid();
|
||||
var events = new List<AuditEvent> { MakeEvent(), MakeEvent(), MakeEvent() };
|
||||
|
||||
var envelope = new AuditTelemetryEnvelope(envelopeId, "site-01", events);
|
||||
|
||||
Assert.Equal(envelopeId, envelope.EnvelopeId);
|
||||
Assert.Equal("site-01", envelope.SourceSiteId);
|
||||
Assert.Equal(3, envelope.Events.Count);
|
||||
|
||||
// Enumerable round-trip
|
||||
var collected = new List<AuditEvent>();
|
||||
foreach (var e in envelope.Events)
|
||||
{
|
||||
collected.Add(e);
|
||||
}
|
||||
Assert.Equal(3, collected.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditTelemetryEnvelope_IsImmutable_RecordEqualityOnReferenceIdentityOfList()
|
||||
{
|
||||
// The record's value equality compares the IReadOnlyList reference; two envelopes
|
||||
// built with the same list instance + same fields must be equal, but using a
|
||||
// different list instance (even with equal content) must NOT be equal.
|
||||
var events = new List<AuditEvent> { MakeEvent() } as IReadOnlyList<AuditEvent>;
|
||||
var envelopeId = Guid.NewGuid();
|
||||
var a = new AuditTelemetryEnvelope(envelopeId, "site-01", events);
|
||||
var b = new AuditTelemetryEnvelope(envelopeId, "site-01", events);
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
|
||||
var withDifferentSite = a with { SourceSiteId = "site-02" };
|
||||
Assert.NotEqual(a, withDifferentSite);
|
||||
Assert.Equal("site-02", withDifferentSite.SourceSiteId);
|
||||
Assert.Equal("site-01", a.SourceSiteId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PullAuditEventsRequest_ConstructsAndIsImmutable()
|
||||
{
|
||||
var since = new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc);
|
||||
var request = new PullAuditEventsRequest("site-01", since, 100);
|
||||
|
||||
Assert.Equal("site-01", request.SourceSiteId);
|
||||
Assert.Equal(since, request.SinceUtc);
|
||||
Assert.Equal(100, request.BatchSize);
|
||||
|
||||
var bigger = request with { BatchSize = 500 };
|
||||
Assert.Equal(100, request.BatchSize);
|
||||
Assert.Equal(500, bigger.BatchSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PullAuditEventsResponse_ConstructsWithMoreAvailableTrue_AndIsEnumerable()
|
||||
{
|
||||
var events = new List<AuditEvent> { MakeEvent(), MakeEvent() };
|
||||
var response = new PullAuditEventsResponse(events, MoreAvailable: true);
|
||||
|
||||
Assert.True(response.MoreAvailable);
|
||||
Assert.Equal(2, response.Events.Count);
|
||||
|
||||
var collected = new List<AuditEvent>();
|
||||
foreach (var e in response.Events)
|
||||
{
|
||||
collected.Add(e);
|
||||
}
|
||||
Assert.Equal(2, collected.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PullAuditEventsResponse_WithExpression_ChangesSingleField()
|
||||
{
|
||||
var response = new PullAuditEventsResponse(new List<AuditEvent>(), MoreAvailable: false);
|
||||
var updated = response with { MoreAvailable = true };
|
||||
|
||||
Assert.False(response.MoreAvailable);
|
||||
Assert.True(updated.MoreAvailable);
|
||||
}
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (M3 Bundle A — Task A4) — tests for the combined
|
||||
/// audit + operational telemetry packet emitted per cached-call lifecycle event
|
||||
/// (<c>Submit</c> → per-attempt <c>ApiCallCached</c> / <c>DbWriteCached</c> →
|
||||
/// terminal <c>Resolve</c>). The site emits one packet per event; central writes
|
||||
/// <c>AuditLog</c> + <c>SiteCalls</c> in one MS SQL transaction.
|
||||
/// </summary>
|
||||
public class CachedCallTelemetryTests
|
||||
{
|
||||
private static readonly DateTime FixedNowUtc = new(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private const string SiteId = "site-77";
|
||||
private const string InstanceName = "Plant.Pump42";
|
||||
private const string SourceScript = "ScriptActor:OnTick";
|
||||
|
||||
private static AuditEvent BuildAuditEvent(
|
||||
TrackedOperationId trackedId,
|
||||
AuditKind kind,
|
||||
AuditStatus status,
|
||||
Guid? correlationId = null,
|
||||
string? errorMessage = null,
|
||||
int? httpStatus = null)
|
||||
{
|
||||
return new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = FixedNowUtc,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = kind,
|
||||
CorrelationId = correlationId ?? trackedId.Value,
|
||||
SourceSiteId = SiteId,
|
||||
SourceInstanceId = InstanceName,
|
||||
SourceScript = SourceScript,
|
||||
Target = "ERP.GetOrder",
|
||||
Status = status,
|
||||
HttpStatus = httpStatus,
|
||||
ErrorMessage = errorMessage,
|
||||
PayloadTruncated = false,
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
};
|
||||
}
|
||||
|
||||
private static SiteCallOperational BuildOperational(
|
||||
TrackedOperationId trackedId,
|
||||
AuditStatus status,
|
||||
int retryCount,
|
||||
string? lastError = null,
|
||||
int? httpStatus = null,
|
||||
DateTime? terminalAtUtc = null)
|
||||
{
|
||||
return new SiteCallOperational(
|
||||
TrackedOperationId: trackedId,
|
||||
Channel: nameof(AuditChannel.ApiOutbound),
|
||||
Target: "ERP.GetOrder",
|
||||
SourceSite: SiteId,
|
||||
// SourceNode: actual stamping arrives with Task 14; for now the
|
||||
// packet builder leaves the column null so existing assertions on
|
||||
// the packet's other fields stay intact.
|
||||
SourceNode: null,
|
||||
Status: status.ToString(),
|
||||
RetryCount: retryCount,
|
||||
LastError: lastError,
|
||||
HttpStatus: httpStatus,
|
||||
CreatedAtUtc: FixedNowUtc,
|
||||
UpdatedAtUtc: terminalAtUtc ?? FixedNowUtc,
|
||||
TerminalAtUtc: terminalAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubmitPacket_AuditCarriesCachedSubmit_AndOperationalRetryCountZero()
|
||||
{
|
||||
var trackedId = TrackedOperationId.New();
|
||||
var audit = BuildAuditEvent(trackedId, AuditKind.CachedSubmit, AuditStatus.Submitted);
|
||||
var operational = BuildOperational(trackedId, AuditStatus.Submitted, retryCount: 0);
|
||||
|
||||
var packet = new CachedCallTelemetry(audit, operational);
|
||||
|
||||
Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind);
|
||||
Assert.Equal(AuditStatus.Submitted, packet.Audit.Status);
|
||||
Assert.Equal(nameof(AuditStatus.Submitted), packet.Operational.Status);
|
||||
Assert.Equal(0, packet.Operational.RetryCount);
|
||||
Assert.Null(packet.Operational.TerminalAtUtc);
|
||||
Assert.Equal(trackedId, packet.Operational.TrackedOperationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttemptedPacket_AuditCarriesApiCallCached_RetryCountAlignsBetweenAuditAndOperational()
|
||||
{
|
||||
var trackedId = TrackedOperationId.New();
|
||||
var audit = BuildAuditEvent(
|
||||
trackedId,
|
||||
AuditKind.ApiCallCached,
|
||||
AuditStatus.Attempted,
|
||||
errorMessage: "HTTP 503 from ERP",
|
||||
httpStatus: 503);
|
||||
var operational = BuildOperational(
|
||||
trackedId,
|
||||
AuditStatus.Attempted,
|
||||
retryCount: 2,
|
||||
lastError: "HTTP 503 from ERP",
|
||||
httpStatus: 503);
|
||||
|
||||
var packet = new CachedCallTelemetry(audit, operational);
|
||||
|
||||
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
|
||||
Assert.Equal(AuditStatus.Attempted, packet.Audit.Status);
|
||||
Assert.Equal(nameof(AuditStatus.Attempted), packet.Operational.Status);
|
||||
// Retry-count alignment: the operational row carries the canonical N;
|
||||
// the audit row's error/http surface the same attempt's outcome.
|
||||
Assert.Equal(packet.Audit.ErrorMessage, packet.Operational.LastError);
|
||||
Assert.Equal(packet.Audit.HttpStatus, packet.Operational.HttpStatus);
|
||||
Assert.Equal(2, packet.Operational.RetryCount);
|
||||
Assert.Null(packet.Operational.TerminalAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttemptedPacket_DbWriteCached_CarriesDbWriteCachedKind()
|
||||
{
|
||||
var trackedId = TrackedOperationId.New();
|
||||
var audit = BuildAuditEvent(
|
||||
trackedId,
|
||||
AuditKind.DbWriteCached,
|
||||
AuditStatus.Attempted,
|
||||
errorMessage: "Timeout",
|
||||
httpStatus: null);
|
||||
var operational = BuildOperational(
|
||||
trackedId,
|
||||
AuditStatus.Attempted,
|
||||
retryCount: 1,
|
||||
lastError: "Timeout");
|
||||
|
||||
var packet = new CachedCallTelemetry(audit, operational);
|
||||
|
||||
Assert.Equal(AuditKind.DbWriteCached, packet.Audit.Kind);
|
||||
Assert.Equal(AuditStatus.Attempted, packet.Audit.Status);
|
||||
Assert.Equal(1, packet.Operational.RetryCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AuditStatus.Delivered)]
|
||||
[InlineData(AuditStatus.Failed)]
|
||||
[InlineData(AuditStatus.Parked)]
|
||||
[InlineData(AuditStatus.Discarded)]
|
||||
public void ResolvePacket_AuditCarriesCachedResolve_OperationalTerminalAtUtcSet(AuditStatus terminalStatus)
|
||||
{
|
||||
var trackedId = TrackedOperationId.New();
|
||||
var terminalAt = FixedNowUtc.AddMinutes(5);
|
||||
var audit = BuildAuditEvent(trackedId, AuditKind.CachedResolve, terminalStatus);
|
||||
var operational = BuildOperational(
|
||||
trackedId,
|
||||
terminalStatus,
|
||||
retryCount: 3,
|
||||
terminalAtUtc: terminalAt);
|
||||
|
||||
var packet = new CachedCallTelemetry(audit, operational);
|
||||
|
||||
Assert.Equal(AuditKind.CachedResolve, packet.Audit.Kind);
|
||||
Assert.Equal(terminalStatus, packet.Audit.Status);
|
||||
Assert.Equal(terminalStatus.ToString(), packet.Operational.Status);
|
||||
Assert.NotNull(packet.Operational.TerminalAtUtc);
|
||||
Assert.Equal(terminalAt, packet.Operational.TerminalAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CachedCallTelemetry_RoundTripEquality()
|
||||
{
|
||||
var trackedId = TrackedOperationId.New();
|
||||
var audit = BuildAuditEvent(trackedId, AuditKind.CachedSubmit, AuditStatus.Submitted);
|
||||
var operational = BuildOperational(trackedId, AuditStatus.Submitted, retryCount: 0);
|
||||
|
||||
var a = new CachedCallTelemetry(audit, operational);
|
||||
var b = new CachedCallTelemetry(audit, operational);
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
|
||||
var differentOperational = operational with { RetryCount = 1 };
|
||||
var c = a with { Operational = differentOperational };
|
||||
|
||||
Assert.NotEqual(a, c);
|
||||
Assert.Equal(0, a.Operational.RetryCount);
|
||||
Assert.Equal(1, c.Operational.RetryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCallOperational_RoundTripEquality_AndWithExpression()
|
||||
{
|
||||
var trackedId = TrackedOperationId.New();
|
||||
var a = BuildOperational(trackedId, AuditStatus.Submitted, retryCount: 0);
|
||||
var b = BuildOperational(trackedId, AuditStatus.Submitted, retryCount: 0);
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
|
||||
var withDifferentRetry = a with { RetryCount = 5 };
|
||||
Assert.NotEqual(a, withDifferentRetry);
|
||||
Assert.Equal(0, a.RetryCount);
|
||||
Assert.Equal(5, withDifferentRetry.RetryCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Reflection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ManagementCommandRegistry"/>, including the Commons-004
|
||||
/// regression: <c>GetCommandName</c> and <c>Resolve</c> must be symmetric — every
|
||||
/// type for which <c>GetCommandName</c> yields a name must round-trip back to the
|
||||
/// same type via <c>Resolve</c>.
|
||||
/// </summary>
|
||||
public class ManagementCommandRegistryTests
|
||||
{
|
||||
private static IEnumerable<Type> RegisteredCommandTypes() =>
|
||||
typeof(ManagementEnvelope).Assembly.GetTypes()
|
||||
.Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace
|
||||
&& t.Name.EndsWith("Command", StringComparison.Ordinal)
|
||||
&& !t.IsAbstract);
|
||||
|
||||
[Fact]
|
||||
public void GetCommandName_Resolve_RoundTrips_ForEveryRegisteredCommand()
|
||||
{
|
||||
foreach (var type in RegisteredCommandTypes())
|
||||
{
|
||||
var name = ManagementCommandRegistry.GetCommandName(type);
|
||||
var resolved = ManagementCommandRegistry.Resolve(name);
|
||||
Assert.Equal(type, resolved);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_KnownCommand_ReturnsType()
|
||||
{
|
||||
var type = ManagementCommandRegistry.Resolve("CreateSite");
|
||||
Assert.Equal(typeof(CreateSiteCommand), type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_UnknownCommand_ReturnsNull()
|
||||
{
|
||||
Assert.Null(ManagementCommandRegistry.Resolve("NoSuchCommand"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_IsCaseInsensitive()
|
||||
{
|
||||
Assert.Equal(typeof(CreateSiteCommand), ManagementCommandRegistry.Resolve("createsite"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commons-004: <c>GetCommandName</c> previously stripped a <c>Command</c> suffix
|
||||
/// from <em>any</em> type, producing names the registry cannot resolve. It must
|
||||
/// only return a name for a command type the registry actually contains.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetCommandName_UnregisteredCommandType_Throws()
|
||||
{
|
||||
// A *Command type that is not in the Messages.Management namespace.
|
||||
Assert.Throws<ArgumentException>(
|
||||
() => ManagementCommandRegistry.GetCommandName(typeof(UnregisteredFakeCommand)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCommandName_NonCommandType_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(
|
||||
() => ManagementCommandRegistry.GetCommandName(typeof(string)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCommandName_RegisteredCommand_ReturnsStrippedName()
|
||||
{
|
||||
Assert.Equal("CreateSite", ManagementCommandRegistry.GetCommandName(typeof(CreateSiteCommand)));
|
||||
}
|
||||
|
||||
/// <summary>A *Command record outside the Management namespace, for the negative test.</summary>
|
||||
private record UnregisteredFakeCommand(int Id);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
|
||||
|
||||
public class MessageConventionTests
|
||||
{
|
||||
private static readonly Assembly CommonsAssembly = typeof(ZB.MOM.WW.ScadaBridge.Commons.Types.RetryPolicy).Assembly;
|
||||
|
||||
private static IEnumerable<Type> GetMessageTypes() =>
|
||||
CommonsAssembly.GetTypes()
|
||||
.Where(t => t.Namespace != null
|
||||
&& t.Namespace.Contains(".Messages.")
|
||||
&& !t.IsEnum
|
||||
&& !t.IsInterface
|
||||
&& !(t.IsAbstract && t.IsSealed) // exclude static classes (utilities)
|
||||
&& !t.Name.StartsWith("<") // exclude compiler-generated types
|
||||
&& (t.IsClass || (t.IsValueType && !t.IsPrimitive)));
|
||||
|
||||
[Fact]
|
||||
public void AllMessageTypes_ShouldBeRecords()
|
||||
{
|
||||
foreach (var type in GetMessageTypes())
|
||||
{
|
||||
// Records have a compiler-generated <Clone>$ method
|
||||
var cloneMethod = type.GetMethod("<Clone>$", BindingFlags.Public | BindingFlags.Instance);
|
||||
Assert.True(cloneMethod != null,
|
||||
$"{type.FullName} in Messages namespace should be a record type");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllMessageTimestampProperties_ShouldBeDateTimeOffset()
|
||||
{
|
||||
foreach (var type in GetMessageTypes())
|
||||
{
|
||||
foreach (var prop in type.GetProperties())
|
||||
{
|
||||
if (prop.Name.Contains("Timestamp") || prop.Name == "GeneratedAt" || prop.Name == "DeployedAt")
|
||||
{
|
||||
var underlyingType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
|
||||
Assert.True(underlyingType == typeof(DateTimeOffset),
|
||||
$"{type.Name}.{prop.Name} should be DateTimeOffset but is {prop.PropertyType.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonRoundTrip_DeployInstanceCommand_ShouldSucceed()
|
||||
{
|
||||
var msg = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment.DeployInstanceCommand(
|
||||
"dep-1", "instance-1", "abc123", "{}", "admin", DateTimeOffset.UtcNow);
|
||||
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment.DeployInstanceCommand>(json);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal(msg.DeploymentId, deserialized!.DeploymentId);
|
||||
Assert.Equal(msg.InstanceUniqueName, deserialized.InstanceUniqueName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonForwardCompatibility_UnknownField_ShouldDeserialize()
|
||||
{
|
||||
// Simulate a newer version with an extra field
|
||||
var json = """{"DeploymentId":"dep-1","InstanceUniqueName":"inst-1","RevisionHash":"abc","FlattenedConfigurationJson":"{}","DeployedBy":"admin","Timestamp":"2025-01-01T00:00:00+00:00","NewField":"extra"}""";
|
||||
|
||||
var deserialized = JsonSerializer.Deserialize<ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment.DeployInstanceCommand>(json);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal("dep-1", deserialized!.DeploymentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonBackwardCompatibility_MissingOptionalField_ShouldDeserialize()
|
||||
{
|
||||
// DeploymentStatusResponse has nullable ErrorMessage
|
||||
var json = """{"DeploymentId":"dep-1","InstanceUniqueName":"inst-1","Status":2,"Timestamp":"2025-01-01T00:00:00+00:00"}""";
|
||||
|
||||
var deserialized = JsonSerializer.Deserialize<ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment.DeploymentStatusResponse>(json);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal("dep-1", deserialized!.DeploymentId);
|
||||
Assert.Null(deserialized.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonRoundTrip_SiteHealthReport_ShouldSucceed()
|
||||
{
|
||||
var msg = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Health.SiteHealthReport(
|
||||
"site-1", 1, DateTimeOffset.UtcNow,
|
||||
new Dictionary<string, ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.ConnectionHealth>
|
||||
{
|
||||
["conn1"] = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.ConnectionHealth.Connected
|
||||
},
|
||||
new Dictionary<string, ZB.MOM.WW.ScadaBridge.Commons.Messages.Health.TagResolutionStatus>
|
||||
{
|
||||
["conn1"] = new(10, 8)
|
||||
},
|
||||
0, 0,
|
||||
new Dictionary<string, int> { ["queue1"] = 5 },
|
||||
0, 0, 0, 0);
|
||||
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<ZB.MOM.WW.ScadaBridge.Commons.Messages.Health.SiteHealthReport>(json);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal("site-1", deserialized!.SiteId);
|
||||
Assert.Equal(1, deserialized.SequenceNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonRoundTrip_DeployArtifactsCommand_ShouldSucceed()
|
||||
{
|
||||
var msg = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts.DeployArtifactsCommand(
|
||||
"dep-1",
|
||||
new List<ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts.SharedScriptArtifact>
|
||||
{
|
||||
new("script1", "code", null, null)
|
||||
},
|
||||
null, null, null, null, null,
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts.DeployArtifactsCommand>(json);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal("dep-1", deserialized!.DeploymentId);
|
||||
Assert.Single(deserialized.SharedScripts!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Notification Outbox: construction and value-equality tests for the
|
||||
/// site/central notification message contracts and the outbox UI query/action contracts.
|
||||
/// </summary>
|
||||
public class NotificationMessagesTests
|
||||
{
|
||||
// ── Task 7: site/central notification message contracts ──
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_PositionalConstruction_SetsAllFields()
|
||||
{
|
||||
var enqueuedAt = DateTimeOffset.UtcNow;
|
||||
var msg = new NotificationSubmit(
|
||||
"notif-1", "Operators", "Tank overflow", "Tank 3 has overflowed.",
|
||||
"site-01", "inst-7", "OnAlarm", enqueuedAt);
|
||||
|
||||
Assert.Equal("notif-1", msg.NotificationId);
|
||||
Assert.Equal("Operators", msg.ListName);
|
||||
Assert.Equal("Tank overflow", msg.Subject);
|
||||
Assert.Equal("Tank 3 has overflowed.", msg.Body);
|
||||
Assert.Equal("site-01", msg.SourceSiteId);
|
||||
Assert.Equal("inst-7", msg.SourceInstanceId);
|
||||
Assert.Equal("OnAlarm", msg.SourceScript);
|
||||
Assert.Equal(enqueuedAt, msg.SiteEnqueuedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_AllowsNullOptionalSourceFields()
|
||||
{
|
||||
var msg = new NotificationSubmit(
|
||||
"notif-2", "Operators", "Subject", "Body",
|
||||
"site-01", null, null, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Null(msg.SourceInstanceId);
|
||||
Assert.Null(msg.SourceScript);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_OriginExecutionId_DefaultsToNull()
|
||||
{
|
||||
// Audit Log #23: OriginExecutionId is an additive trailing member — a
|
||||
// submit built without it (old call sites / old serialized payloads)
|
||||
// leaves the id null.
|
||||
var msg = new NotificationSubmit(
|
||||
"notif-3", "Operators", "Subject", "Body",
|
||||
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Null(msg.OriginExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_OriginExecutionId_RoundTripsWhenSupplied()
|
||||
{
|
||||
var executionId = Guid.NewGuid();
|
||||
var msg = new NotificationSubmit(
|
||||
"notif-4", "Operators", "Subject", "Body",
|
||||
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow, executionId);
|
||||
|
||||
Assert.Equal(executionId, msg.OriginExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_OriginExecutionId_SurvivesJsonRoundTrip()
|
||||
{
|
||||
// The buffered S&F payload IS a serialized NotificationSubmit; the
|
||||
// forwarder deserializes it, so OriginExecutionId must survive JSON.
|
||||
var executionId = Guid.NewGuid();
|
||||
var msg = new NotificationSubmit(
|
||||
"notif-5", "Operators", "Subject", "Body",
|
||||
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow, executionId);
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(msg);
|
||||
var roundTripped = System.Text.Json.JsonSerializer.Deserialize<NotificationSubmit>(json);
|
||||
|
||||
Assert.NotNull(roundTripped);
|
||||
Assert.Equal(executionId, roundTripped!.OriginExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_OriginParentExecutionId_DefaultsToNull()
|
||||
{
|
||||
// Audit Log ParentExecutionId: OriginParentExecutionId is an additive
|
||||
// trailing member — a submit built without it (old call sites / old
|
||||
// serialized payloads, or non-routed runs) leaves the id null.
|
||||
var msg = new NotificationSubmit(
|
||||
"notif-6", "Operators", "Subject", "Body",
|
||||
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Null(msg.OriginParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_OriginParentExecutionId_RoundTripsWhenSupplied()
|
||||
{
|
||||
var executionId = Guid.NewGuid();
|
||||
var parentExecutionId = Guid.NewGuid();
|
||||
var msg = new NotificationSubmit(
|
||||
"notif-7", "Operators", "Subject", "Body",
|
||||
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow,
|
||||
executionId, parentExecutionId);
|
||||
|
||||
Assert.Equal(parentExecutionId, msg.OriginParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_OriginParentExecutionId_SurvivesJsonRoundTrip()
|
||||
{
|
||||
// The buffered S&F payload IS a serialized NotificationSubmit; the
|
||||
// forwarder deserializes it, so OriginParentExecutionId must survive JSON.
|
||||
var executionId = Guid.NewGuid();
|
||||
var parentExecutionId = Guid.NewGuid();
|
||||
var msg = new NotificationSubmit(
|
||||
"notif-8", "Operators", "Subject", "Body",
|
||||
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow,
|
||||
executionId, parentExecutionId);
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(msg);
|
||||
var roundTripped = System.Text.Json.JsonSerializer.Deserialize<NotificationSubmit>(json);
|
||||
|
||||
Assert.NotNull(roundTripped);
|
||||
Assert.Equal(parentExecutionId, roundTripped!.OriginParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_carries_SourceNode()
|
||||
{
|
||||
// SourceNode is an additive trailing member — old call sites and old
|
||||
// serialized payloads leave it null. When supplied it round-trips
|
||||
// through both construction and JSON (the buffered S&F payload IS a
|
||||
// serialized NotificationSubmit).
|
||||
var defaulted = new NotificationSubmit(
|
||||
"notif-9", "Operators", "Subject", "Body",
|
||||
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow);
|
||||
Assert.Null(defaulted.SourceNode);
|
||||
|
||||
var stamped = new NotificationSubmit(
|
||||
"notif-10", "Operators", "Subject", "Body",
|
||||
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow,
|
||||
OriginExecutionId: null,
|
||||
OriginParentExecutionId: null,
|
||||
SourceNode: "node-a");
|
||||
Assert.Equal("node-a", stamped.SourceNode);
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(stamped);
|
||||
var roundTripped = System.Text.Json.JsonSerializer.Deserialize<NotificationSubmit>(json);
|
||||
Assert.NotNull(roundTripped);
|
||||
Assert.Equal("node-a", roundTripped!.SourceNode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch()
|
||||
{
|
||||
var enqueuedAt = DateTimeOffset.UtcNow;
|
||||
var a = new NotificationSubmit("n", "L", "S", "B", "site", "inst", "scr", enqueuedAt);
|
||||
var b = new NotificationSubmit("n", "L", "S", "B", "site", "inst", "scr", enqueuedAt);
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmitAck_WithExpression_ChangesSingleField()
|
||||
{
|
||||
var ack = new NotificationSubmitAck("notif-1", true, null);
|
||||
var rejected = ack with { Accepted = false, Error = "duplicate" };
|
||||
|
||||
Assert.True(ack.Accepted);
|
||||
Assert.False(rejected.Accepted);
|
||||
Assert.Equal("duplicate", rejected.Error);
|
||||
Assert.Equal("notif-1", rejected.NotificationId);
|
||||
Assert.NotEqual(ack, rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationStatusQuery_PositionalConstruction_SetsAllFields()
|
||||
{
|
||||
var msg = new NotificationStatusQuery("corr-1", "notif-9");
|
||||
|
||||
Assert.Equal("corr-1", msg.CorrelationId);
|
||||
Assert.Equal("notif-9", msg.NotificationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationStatusResponse_PositionalConstruction_SetsAllFields()
|
||||
{
|
||||
var deliveredAt = DateTimeOffset.UtcNow;
|
||||
var msg = new NotificationStatusResponse(
|
||||
"corr-1", true, "Delivered", 2, null, deliveredAt);
|
||||
|
||||
Assert.Equal("corr-1", msg.CorrelationId);
|
||||
Assert.True(msg.Found);
|
||||
Assert.Equal("Delivered", msg.Status);
|
||||
Assert.Equal(2, msg.RetryCount);
|
||||
Assert.Null(msg.LastError);
|
||||
Assert.Equal(deliveredAt, msg.DeliveredAt);
|
||||
}
|
||||
|
||||
// ── Task 8: outbox UI query/action contracts ──
|
||||
|
||||
[Fact]
|
||||
public void NotificationOutboxQueryRequest_PositionalConstruction_SetsAllFields()
|
||||
{
|
||||
var from = DateTimeOffset.UtcNow.AddDays(-1);
|
||||
var to = DateTimeOffset.UtcNow;
|
||||
var msg = new NotificationOutboxQueryRequest(
|
||||
"corr-1", "Stuck", "Email", "site-01", "Operators", true, "overflow",
|
||||
from, to, 2, 50);
|
||||
|
||||
Assert.Equal("corr-1", msg.CorrelationId);
|
||||
Assert.Equal("Stuck", msg.StatusFilter);
|
||||
Assert.Equal("Email", msg.TypeFilter);
|
||||
Assert.Equal("site-01", msg.SourceSiteFilter);
|
||||
Assert.Equal("Operators", msg.ListNameFilter);
|
||||
Assert.True(msg.StuckOnly);
|
||||
Assert.Equal("overflow", msg.SubjectKeyword);
|
||||
Assert.Equal(from, msg.From);
|
||||
Assert.Equal(to, msg.To);
|
||||
Assert.Equal(2, msg.PageNumber);
|
||||
Assert.Equal(50, msg.PageSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSummary_ValueEquality_EqualWhenAllFieldsMatch()
|
||||
{
|
||||
var createdAt = DateTimeOffset.UtcNow;
|
||||
var a = new NotificationSummary(
|
||||
"n", "Email", "Ops", "S", "Pending", 1, null, "site-01", "inst-1",
|
||||
createdAt, null, false);
|
||||
var b = new NotificationSummary(
|
||||
"n", "Email", "Ops", "S", "Pending", 1, null, "site-01", "inst-1",
|
||||
createdAt, null, false);
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationOutboxQueryResponse_PositionalConstruction_SetsAllFields()
|
||||
{
|
||||
var summary = new NotificationSummary(
|
||||
"n", "Email", "Ops", "S", "Delivered", 0, null, "site-01", null,
|
||||
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, false);
|
||||
var msg = new NotificationOutboxQueryResponse(
|
||||
"corr-1", true, null, new[] { summary }, 1);
|
||||
|
||||
Assert.Equal("corr-1", msg.CorrelationId);
|
||||
Assert.True(msg.Success);
|
||||
Assert.Null(msg.ErrorMessage);
|
||||
Assert.Single(msg.Notifications);
|
||||
Assert.Equal(1, msg.TotalCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryNotificationRequestAndResponse_RoundTripFields()
|
||||
{
|
||||
var request = new RetryNotificationRequest("corr-1", "notif-1");
|
||||
var response = new RetryNotificationResponse("corr-1", true, null);
|
||||
|
||||
Assert.Equal("corr-1", request.CorrelationId);
|
||||
Assert.Equal("notif-1", request.NotificationId);
|
||||
Assert.True(response.Success);
|
||||
Assert.Null(response.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscardNotificationRequestAndResponse_RoundTripFields()
|
||||
{
|
||||
var request = new DiscardNotificationRequest("corr-1", "notif-1");
|
||||
var response = new DiscardNotificationResponse("corr-1", false, "not found");
|
||||
|
||||
Assert.Equal("notif-1", request.NotificationId);
|
||||
Assert.False(response.Success);
|
||||
Assert.Equal("not found", response.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationKpiResponse_WithExpression_ChangesSingleField()
|
||||
{
|
||||
var kpi = new NotificationKpiResponse(
|
||||
"corr-1", Success: true, ErrorMessage: null, 10, 2, 1, 5, TimeSpan.FromMinutes(3));
|
||||
var updated = kpi with { QueueDepth = 12 };
|
||||
|
||||
Assert.True(kpi.Success);
|
||||
Assert.Null(kpi.ErrorMessage);
|
||||
Assert.Equal(10, kpi.QueueDepth);
|
||||
Assert.Equal(12, updated.QueueDepth);
|
||||
Assert.Equal(2, updated.StuckCount);
|
||||
Assert.Equal(TimeSpan.FromMinutes(3), updated.OldestPendingAge);
|
||||
Assert.NotEqual(kpi, updated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationKpiRequest_PositionalConstruction_SetsCorrelationId()
|
||||
{
|
||||
var msg = new NotificationKpiRequest("corr-1");
|
||||
Assert.Equal("corr-1", msg.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerSiteNotificationKpiRequest_CarriesCorrelationId()
|
||||
{
|
||||
var request = new PerSiteNotificationKpiRequest("corr-1");
|
||||
Assert.Equal("corr-1", request.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerSiteNotificationKpiResponse_CarriesPerSiteSnapshots()
|
||||
{
|
||||
var sites = new[]
|
||||
{
|
||||
new SiteNotificationKpiSnapshot("plant-a", 3, 1, 0, 10, TimeSpan.FromMinutes(4)),
|
||||
};
|
||||
var response = new PerSiteNotificationKpiResponse("corr-1", Success: true, ErrorMessage: null, sites);
|
||||
|
||||
Assert.True(response.Success);
|
||||
Assert.Null(response.ErrorMessage);
|
||||
Assert.Single(response.Sites);
|
||||
Assert.Equal("plant-a", response.Sites[0].SourceSiteId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Site Call Audit (#22): construction, value-equality and optionality tests
|
||||
/// for the Site Calls UI query / KPI / detail message contracts. Mirrors the
|
||||
/// Notification Outbox <c>NotificationMessagesTests</c> coverage of the read
|
||||
/// side, scoped to the contracts the Site Calls page consumes.
|
||||
/// </summary>
|
||||
public class SiteCallQueriesTests
|
||||
{
|
||||
[Fact]
|
||||
public void SiteCallQueryRequest_PositionalConstruction_SetsAllFields()
|
||||
{
|
||||
var afterCreated = DateTime.UtcNow;
|
||||
var afterId = Guid.NewGuid();
|
||||
var request = new SiteCallQueryRequest(
|
||||
"corr-1", "Parked", "plant-a", "ApiOutbound", "ERP.GetOrder", true,
|
||||
new DateTime(2026, 5, 1), new DateTime(2026, 5, 20), afterCreated, afterId, 50);
|
||||
|
||||
Assert.Equal("corr-1", request.CorrelationId);
|
||||
Assert.Equal("Parked", request.StatusFilter);
|
||||
Assert.Equal("plant-a", request.SourceSiteFilter);
|
||||
Assert.Equal("ApiOutbound", request.ChannelFilter);
|
||||
Assert.Equal("ERP.GetOrder", request.TargetKeyword);
|
||||
Assert.True(request.StuckOnly);
|
||||
Assert.Equal(new DateTime(2026, 5, 1), request.FromUtc);
|
||||
Assert.Equal(new DateTime(2026, 5, 20), request.ToUtc);
|
||||
Assert.Equal(afterCreated, request.AfterCreatedAtUtc);
|
||||
Assert.Equal(afterId, request.AfterId);
|
||||
Assert.Equal(50, request.PageSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCallQueryRequest_AllowsNullOptionalFilters()
|
||||
{
|
||||
var request = new SiteCallQueryRequest(
|
||||
"corr-2", null, null, null, null, false, null, null, null, null, 25);
|
||||
|
||||
Assert.Null(request.StatusFilter);
|
||||
Assert.Null(request.SourceSiteFilter);
|
||||
Assert.Null(request.ChannelFilter);
|
||||
Assert.Null(request.TargetKeyword);
|
||||
Assert.False(request.StuckOnly);
|
||||
Assert.Null(request.FromUtc);
|
||||
Assert.Null(request.AfterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCallQueryResponse_ValueEquality_EqualWhenAllFieldsMatch()
|
||||
{
|
||||
var a = new SiteCallQueryResponse("c", true, null, Array.Empty<SiteCallSummary>(), null, null);
|
||||
var b = new SiteCallQueryResponse("c", true, null, Array.Empty<SiteCallSummary>(), null, null);
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCallSummary_CarriesEntityColumnsAndStuckFlag()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var created = DateTime.UtcNow.AddMinutes(-30);
|
||||
var summary = new SiteCallSummary(
|
||||
id, "plant-a", "DbOutbound", "InventoryDb", "Retrying", 3,
|
||||
"transient 503", 503, created, created.AddMinutes(1), null, IsStuck: true);
|
||||
|
||||
Assert.Equal(id, summary.TrackedOperationId);
|
||||
Assert.Equal("DbOutbound", summary.Channel);
|
||||
Assert.Equal("InventoryDb", summary.Target);
|
||||
Assert.Equal("Retrying", summary.Status);
|
||||
Assert.Equal(3, summary.RetryCount);
|
||||
Assert.Equal(503, summary.HttpStatus);
|
||||
Assert.Null(summary.TerminalAtUtc);
|
||||
Assert.True(summary.IsStuck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCallDetailResponse_MissingRow_HasNullDetail()
|
||||
{
|
||||
var response = new SiteCallDetailResponse("c", false, "site call not found", null);
|
||||
|
||||
Assert.False(response.Success);
|
||||
Assert.Null(response.Detail);
|
||||
Assert.Equal("site call not found", response.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCallKpiResponse_FailureShape_ZeroesKpiFields()
|
||||
{
|
||||
var response = new SiteCallKpiResponse(
|
||||
"c", Success: false, ErrorMessage: "db down",
|
||||
BufferedCount: 0, ParkedCount: 0, FailedLastInterval: 0,
|
||||
DeliveredLastInterval: 0, OldestPendingAge: null, StuckCount: 0);
|
||||
|
||||
Assert.False(response.Success);
|
||||
Assert.Equal("db down", response.ErrorMessage);
|
||||
Assert.Equal(0, response.BufferedCount);
|
||||
Assert.Null(response.OldestPendingAge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerSiteSiteCallKpiResponse_CarriesPerSiteSnapshots()
|
||||
{
|
||||
var response = new PerSiteSiteCallKpiResponse(
|
||||
"c", true, null,
|
||||
new[]
|
||||
{
|
||||
new SiteCallSiteKpiSnapshot("plant-a", 4, 1, 0, 9, TimeSpan.FromMinutes(15), 2),
|
||||
});
|
||||
|
||||
Assert.True(response.Success);
|
||||
var site = Assert.Single(response.Sites);
|
||||
Assert.Equal("plant-a", site.SourceSite);
|
||||
Assert.Equal(4, site.BufferedCount);
|
||||
Assert.Equal(2, site.StuckCount);
|
||||
Assert.Equal(TimeSpan.FromMinutes(15), site.OldestPendingAge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCallKpiSnapshot_OldestPendingAge_IsNullableForEmptyTable()
|
||||
{
|
||||
var snapshot = new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0);
|
||||
Assert.Null(snapshot.OldestPendingAge);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// ConfigurationDatabase-012: the inbound-API bearer credential is stored as a
|
||||
/// deterministic keyed hash (HMAC-SHA256 with a server-side pepper) rather than
|
||||
/// plaintext. These tests pin the hasher contract that the entity, the validator,
|
||||
/// and the management create-path all depend on.
|
||||
/// </summary>
|
||||
public class ApiKeyHasherTests
|
||||
{
|
||||
[Fact]
|
||||
public void Hash_IsDeterministic_SameInputSameOutput()
|
||||
{
|
||||
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
|
||||
|
||||
var first = hasher.Hash("some-api-key-value");
|
||||
var second = hasher.Hash("some-api-key-value");
|
||||
|
||||
Assert.Equal(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_DoesNotEqualPlaintext()
|
||||
{
|
||||
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
|
||||
|
||||
var hash = hasher.Hash("some-api-key-value");
|
||||
|
||||
Assert.NotEqual("some-api-key-value", hash);
|
||||
Assert.DoesNotContain("some-api-key-value", hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_DifferentInputs_ProduceDifferentHashes()
|
||||
{
|
||||
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
|
||||
|
||||
Assert.NotEqual(hasher.Hash("key-one"), hasher.Hash("key-two"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_DifferentPeppers_ProduceDifferentHashes()
|
||||
{
|
||||
var a = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
|
||||
var b = new ApiKeyHasher("a-different-but-equally-long-pepper-val");
|
||||
|
||||
// The pepper binds the hash to the server: a stolen DB dump is useless
|
||||
// without the pepper because the same key hashes differently under it.
|
||||
Assert.NotEqual(a.Hash("same-api-key"), b.Hash("same-api-key"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("too-short")]
|
||||
public void Constructor_MissingOrWeakPepper_FailsFast(string? pepper)
|
||||
{
|
||||
// The pepper must be present and of meaningful length; a missing or weak
|
||||
// pepper is a deployment misconfiguration and must fail loudly.
|
||||
Assert.Throws<ArgumentException>(() => new ApiKeyHasher(pepper!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_NullInput_Throws()
|
||||
{
|
||||
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => hasher.Hash(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_IsUsableWithoutAPepper()
|
||||
{
|
||||
// The unpeppered default exists for tests and non-production wiring; it is
|
||||
// still a one-way HMAC-SHA256, just without the server-binding pepper.
|
||||
var hash = ApiKeyHasher.Default.Hash("some-api-key-value");
|
||||
|
||||
Assert.NotEqual("some-api-key-value", hash);
|
||||
Assert.Equal(ApiKeyHasher.Default.Hash("some-api-key-value"), hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (M8): tests for the shared lax multi-value query-param parsers
|
||||
/// used by the ManagementService + CentralUI audit endpoints and the
|
||||
/// <c>AuditLogPage</c> drill-in parser. The contract under test: parse each
|
||||
/// repeated value independently, silently drop unparseable/blank elements, and
|
||||
/// collapse an empty result to <c>null</c>.
|
||||
/// </summary>
|
||||
public class AuditQueryParamParsersTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseEnumList_NullInput_ReturnsNull()
|
||||
{
|
||||
Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditChannel>(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEnumList_EmptyInput_ReturnsNull()
|
||||
{
|
||||
Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditChannel>(Array.Empty<string?>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEnumList_AllValuesValid_ParsesEverything()
|
||||
{
|
||||
var result = AuditQueryParamParsers.ParseEnumList<AuditChannel>(
|
||||
new[] { "ApiOutbound", "DbOutbound" });
|
||||
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEnumList_IsCaseInsensitive()
|
||||
{
|
||||
var result = AuditQueryParamParsers.ParseEnumList<AuditChannel>(new[] { "apioutbound" });
|
||||
Assert.Equal(new[] { AuditChannel.ApiOutbound }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEnumList_DropsUnparseableElement_KeepsTheRest()
|
||||
{
|
||||
var result = AuditQueryParamParsers.ParseEnumList<AuditChannel>(
|
||||
new[] { "ApiOutbound", "NotAChannel", "Notification" });
|
||||
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEnumList_AllValuesUnparseable_ReturnsNull()
|
||||
{
|
||||
Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditStatus>(new[] { "Bogus", "" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseStringList_NullInput_ReturnsNull()
|
||||
{
|
||||
Assert.Null(AuditQueryParamParsers.ParseStringList(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseStringList_TrimsValuesAndDropsBlanks()
|
||||
{
|
||||
var result = AuditQueryParamParsers.ParseStringList(
|
||||
new[] { " site-1 ", "", " ", "site-2", null });
|
||||
Assert.Equal(new[] { "site-1", "site-2" }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseStringList_AllBlank_ReturnsNull()
|
||||
{
|
||||
Assert.Null(AuditQueryParamParsers.ParseStringList(new[] { "", " ", null }));
|
||||
}
|
||||
}
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.DataConnections;
|
||||
|
||||
public class OpcUaEndpointConfigSerializerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Serialize_TypedRoundtrip_PreservesAllFields()
|
||||
{
|
||||
var original = new OpcUaEndpointConfig
|
||||
{
|
||||
EndpointUrl = "opc.tcp://plant-a:4840",
|
||||
SecurityMode = OpcUaSecurityMode.SignAndEncrypt,
|
||||
AutoAcceptUntrustedCerts = false,
|
||||
SessionTimeoutMs = 90000,
|
||||
OperationTimeoutMs = 20000,
|
||||
PublishingIntervalMs = 500,
|
||||
SamplingIntervalMs = 250,
|
||||
QueueSize = 50,
|
||||
KeepAliveCount = 5,
|
||||
LifetimeCount = 15,
|
||||
MaxNotificationsPerPublish = 200,
|
||||
Heartbeat = new OpcUaHeartbeatConfig { TagPath = "Sensors.HB", MaxSilenceSeconds = 60 }
|
||||
};
|
||||
|
||||
var json = OpcUaEndpointConfigSerializer.Serialize(original);
|
||||
var (round, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(json);
|
||||
|
||||
Assert.False(isLegacy);
|
||||
Assert.Equal(original.EndpointUrl, round.EndpointUrl);
|
||||
Assert.Equal(original.SecurityMode, round.SecurityMode);
|
||||
Assert.Equal(original.AutoAcceptUntrustedCerts, round.AutoAcceptUntrustedCerts);
|
||||
Assert.Equal(original.SessionTimeoutMs, round.SessionTimeoutMs);
|
||||
Assert.Equal(original.OperationTimeoutMs, round.OperationTimeoutMs);
|
||||
Assert.Equal(original.PublishingIntervalMs, round.PublishingIntervalMs);
|
||||
Assert.Equal(original.SamplingIntervalMs, round.SamplingIntervalMs);
|
||||
Assert.Equal(original.QueueSize, round.QueueSize);
|
||||
Assert.Equal(original.KeepAliveCount, round.KeepAliveCount);
|
||||
Assert.Equal(original.LifetimeCount, round.LifetimeCount);
|
||||
Assert.Equal(original.MaxNotificationsPerPublish, round.MaxNotificationsPerPublish);
|
||||
Assert.NotNull(round.Heartbeat);
|
||||
Assert.Equal("Sensors.HB", round.Heartbeat!.TagPath);
|
||||
Assert.Equal(60, round.Heartbeat.MaxSilenceSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_UsesCamelCase()
|
||||
{
|
||||
var config = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" };
|
||||
var json = OpcUaEndpointConfigSerializer.Serialize(config);
|
||||
Assert.Contains("\"endpointUrl\"", json);
|
||||
Assert.DoesNotContain("\"EndpointUrl\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_SecurityModeAsCamelCaseString()
|
||||
{
|
||||
var config = new OpcUaEndpointConfig { SecurityMode = OpcUaSecurityMode.SignAndEncrypt };
|
||||
var json = OpcUaEndpointConfigSerializer.Serialize(config);
|
||||
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
Assert.Equal("signAndEncrypt", doc.RootElement.GetProperty("securityMode").GetString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Deserialize_NullOrEmpty_ReturnsDefaults(string? input)
|
||||
{
|
||||
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(input);
|
||||
|
||||
Assert.False(isLegacy);
|
||||
Assert.Equal("", config.EndpointUrl);
|
||||
Assert.Equal(60000, config.SessionTimeoutMs);
|
||||
Assert.Null(config.Heartbeat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_LegacyFlatDict_IsLegacyTrue_PopulatesFields()
|
||||
{
|
||||
var legacyJson = """
|
||||
{
|
||||
"endpoint": "opc.tcp://legacy:4840",
|
||||
"SessionTimeoutMs": "45000",
|
||||
"SamplingIntervalMs": "500",
|
||||
"SecurityMode": "Sign",
|
||||
"AutoAcceptUntrustedCerts": "false",
|
||||
"HeartbeatTagPath": "Old.Heartbeat",
|
||||
"HeartbeatMaxSilence": "20"
|
||||
}
|
||||
""";
|
||||
|
||||
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(legacyJson);
|
||||
|
||||
Assert.True(isLegacy);
|
||||
Assert.Equal("opc.tcp://legacy:4840", config.EndpointUrl);
|
||||
Assert.Equal(45000, config.SessionTimeoutMs);
|
||||
Assert.Equal(500, config.SamplingIntervalMs);
|
||||
Assert.Equal(OpcUaSecurityMode.Sign, config.SecurityMode);
|
||||
Assert.False(config.AutoAcceptUntrustedCerts);
|
||||
Assert.NotNull(config.Heartbeat);
|
||||
Assert.Equal("Old.Heartbeat", config.Heartbeat!.TagPath);
|
||||
Assert.Equal(20, config.Heartbeat.MaxSilenceSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_LegacyWithEndpointUrlPascalKey_Works()
|
||||
{
|
||||
var legacyJson = """{"EndpointUrl":"opc.tcp://x:4840"}""";
|
||||
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(legacyJson);
|
||||
Assert.True(isLegacy);
|
||||
Assert.Equal("opc.tcp://x:4840", config.EndpointUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_LegacyWithMixedTypeValues_PreservesAllFields()
|
||||
{
|
||||
// Real-world legacy producer emitted mixed JSON value types:
|
||||
// string for endpoint, number for publishInterval, etc.
|
||||
var legacyJson = """
|
||||
{"endpoint":"opc.tcp://scadabridge-opcua:50000","securityMode":"None","publishInterval":1000,"AutoAcceptUntrustedCerts":false,"SessionTimeoutMs":45000}
|
||||
""";
|
||||
|
||||
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(legacyJson);
|
||||
|
||||
Assert.True(isLegacy);
|
||||
Assert.Equal("opc.tcp://scadabridge-opcua:50000", config.EndpointUrl);
|
||||
Assert.Equal(OpcUaSecurityMode.None, config.SecurityMode);
|
||||
Assert.False(config.AutoAcceptUntrustedCerts);
|
||||
Assert.Equal(45000, config.SessionTimeoutMs);
|
||||
// publishInterval is not a recognized key (the real key is PublishingIntervalMs);
|
||||
// FromFlatDict ignores unknown keys, so PublishingIntervalMs stays at its POCO default of 1000.
|
||||
Assert.Equal(1000, config.PublishingIntervalMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_LegacyParsed_StatusIsLegacy()
|
||||
{
|
||||
var (_, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize("""{"endpoint":"opc.tcp://x:4840"}""");
|
||||
Assert.True(isLegacy);
|
||||
|
||||
var result = OpcUaEndpointConfigSerializer.Deserialize("""{"endpoint":"opc.tcp://x:4840"}""");
|
||||
Assert.Equal(OpcUaConfigParseStatus.Legacy, result.Status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not json at all")]
|
||||
[InlineData("[1,2,3]")]
|
||||
[InlineData("\"just a string\"")]
|
||||
public void Deserialize_Malformed_ReportsMalformedNotLegacy(string input)
|
||||
{
|
||||
var result = OpcUaEndpointConfigSerializer.Deserialize(input);
|
||||
|
||||
// Genuinely unparseable input must NOT be reported as a recoverable legacy row.
|
||||
Assert.Equal(OpcUaConfigParseStatus.Malformed, result.Status);
|
||||
Assert.False(result.IsLegacy);
|
||||
Assert.Equal("", result.Config.EndpointUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_ObjectWithoutEndpointUrl_ParsesAsLegacy()
|
||||
{
|
||||
// A flat object with unrecognized keys is still a parseable legacy row, not malformed.
|
||||
var result = OpcUaEndpointConfigSerializer.Deserialize("{\"foo\":123}");
|
||||
Assert.Equal(OpcUaConfigParseStatus.Legacy, result.Status);
|
||||
Assert.True(result.IsLegacy);
|
||||
}
|
||||
|
||||
// ── Commons-014 regression: a corrupt typed row must not be mislabelled Legacy ──
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_TypedShapeWithInvalidEnum_ReportsMalformedNotLegacy()
|
||||
{
|
||||
// The row IS the current typed shape (it has endpointUrl) but an enum-valued
|
||||
// field holds an unrecognised string. Typed deserialization throws JsonException;
|
||||
// it must NOT fall through to the legacy path and be reported as Legacy.
|
||||
var json = """{"endpointUrl":"opc.tcp://x:4840","securityMode":"NotARealMode"}""";
|
||||
|
||||
var result = OpcUaEndpointConfigSerializer.Deserialize(json);
|
||||
|
||||
Assert.Equal(OpcUaConfigParseStatus.Malformed, result.Status);
|
||||
Assert.False(result.IsLegacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_TypedShapeWithWrongTypeField_ReportsMalformedNotLegacy()
|
||||
{
|
||||
// endpointUrl present (typed shape) but a numeric field holds a non-numeric token.
|
||||
var json = """{"endpointUrl":"opc.tcp://x:4840","sessionTimeoutMs":"not-a-number"}""";
|
||||
|
||||
var result = OpcUaEndpointConfigSerializer.Deserialize(json);
|
||||
|
||||
Assert.Equal(OpcUaConfigParseStatus.Malformed, result.Status);
|
||||
Assert.False(result.IsLegacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_ValidTypedRow_StillReportsTyped()
|
||||
{
|
||||
// Guard: a clean typed row is still classified Typed after the Commons-014 fix.
|
||||
var json = """{"endpointUrl":"opc.tcp://x:4840","securityMode":"sign"}""";
|
||||
|
||||
var result = OpcUaEndpointConfigSerializer.Deserialize(json);
|
||||
|
||||
Assert.Equal(OpcUaConfigParseStatus.Typed, result.Status);
|
||||
Assert.Equal("opc.tcp://x:4840", result.Config.EndpointUrl);
|
||||
Assert.Equal(OpcUaSecurityMode.Sign, result.Config.SecurityMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_TwoElementDeconstruction_StillWorks()
|
||||
{
|
||||
// Backward-compat: existing callers deconstruct into (Config, IsLegacy).
|
||||
var (config, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(
|
||||
"""{"endpointUrl":"opc.tcp://x:4840"}""");
|
||||
Assert.False(isLegacy);
|
||||
Assert.Equal("opc.tcp://x:4840", config.EndpointUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToFlatDict_OmitsNullHeartbeat()
|
||||
{
|
||||
var config = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" };
|
||||
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
|
||||
|
||||
Assert.Equal("opc.tcp://x:4840", dict["endpoint"]);
|
||||
Assert.Equal("60000", dict["SessionTimeoutMs"]);
|
||||
Assert.Equal("None", dict["SecurityMode"]);
|
||||
Assert.Equal("True", dict["AutoAcceptUntrustedCerts"]);
|
||||
Assert.False(dict.ContainsKey("HeartbeatTagPath"));
|
||||
Assert.False(dict.ContainsKey("HeartbeatMaxSilence"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToFlatDict_IncludesHeartbeat_WhenSet()
|
||||
{
|
||||
var config = new OpcUaEndpointConfig
|
||||
{
|
||||
EndpointUrl = "opc.tcp://x:4840",
|
||||
Heartbeat = new OpcUaHeartbeatConfig { TagPath = "HB.Tag", MaxSilenceSeconds = 45 }
|
||||
};
|
||||
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
|
||||
|
||||
Assert.Equal("HB.Tag", dict["HeartbeatTagPath"]);
|
||||
Assert.Equal("45", dict["HeartbeatMaxSilence"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromFlatDict_RoundTripsAllKeys()
|
||||
{
|
||||
var dict = new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = "opc.tcp://x:4840",
|
||||
["SessionTimeoutMs"] = "30000",
|
||||
["SecurityMode"] = "Sign",
|
||||
["HeartbeatTagPath"] = "Hb",
|
||||
["HeartbeatMaxSilence"] = "15"
|
||||
};
|
||||
var config = OpcUaEndpointConfigSerializer.FromFlatDict(dict);
|
||||
|
||||
Assert.Equal("opc.tcp://x:4840", config.EndpointUrl);
|
||||
Assert.Equal(30000, config.SessionTimeoutMs);
|
||||
Assert.Equal(OpcUaSecurityMode.Sign, config.SecurityMode);
|
||||
Assert.NotNull(config.Heartbeat);
|
||||
Assert.Equal("Hb", config.Heartbeat!.TagPath);
|
||||
}
|
||||
|
||||
// ── Layer A/B extensions: subscription tuning, auth, deadband ──
|
||||
|
||||
[Fact]
|
||||
public void Serialize_RoundtripsNewSubscriptionScalars()
|
||||
{
|
||||
var original = new OpcUaEndpointConfig
|
||||
{
|
||||
EndpointUrl = "opc.tcp://x:4840",
|
||||
DiscardOldest = false,
|
||||
SubscriptionPriority = 200,
|
||||
SubscriptionDisplayName = "ScadaBridge-Primary",
|
||||
TimestampsToReturn = OpcUaTimestampsToReturn.Both
|
||||
};
|
||||
|
||||
var json = OpcUaEndpointConfigSerializer.Serialize(original);
|
||||
var (round, isLegacy) = OpcUaEndpointConfigSerializer.Deserialize(json);
|
||||
|
||||
Assert.False(isLegacy);
|
||||
Assert.False(round.DiscardOldest);
|
||||
Assert.Equal((byte)200, round.SubscriptionPriority);
|
||||
Assert.Equal("ScadaBridge-Primary", round.SubscriptionDisplayName);
|
||||
Assert.Equal(OpcUaTimestampsToReturn.Both, round.TimestampsToReturn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_RoundtripsDeadband()
|
||||
{
|
||||
var original = new OpcUaEndpointConfig
|
||||
{
|
||||
EndpointUrl = "opc.tcp://x:4840",
|
||||
Deadband = new OpcUaDeadbandConfig
|
||||
{
|
||||
Type = OpcUaDeadbandType.Percent,
|
||||
Value = 2.5
|
||||
}
|
||||
};
|
||||
|
||||
var json = OpcUaEndpointConfigSerializer.Serialize(original);
|
||||
var (round, _) = OpcUaEndpointConfigSerializer.Deserialize(json);
|
||||
|
||||
Assert.NotNull(round.Deadband);
|
||||
Assert.Equal(OpcUaDeadbandType.Percent, round.Deadband!.Type);
|
||||
Assert.Equal(2.5, round.Deadband.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(OpcUaUserTokenType.UsernamePassword, "user1", "pass1", "", "")]
|
||||
[InlineData(OpcUaUserTokenType.X509Certificate, "", "", "/etc/pki/client.pfx", "pfxpass")]
|
||||
[InlineData(OpcUaUserTokenType.Anonymous, "", "", "", "")]
|
||||
public void Serialize_RoundtripsUserIdentity(
|
||||
OpcUaUserTokenType tokenType, string user, string pass, string certPath, string certPass)
|
||||
{
|
||||
var original = new OpcUaEndpointConfig
|
||||
{
|
||||
EndpointUrl = "opc.tcp://x:4840",
|
||||
UserIdentity = new OpcUaUserIdentityConfig
|
||||
{
|
||||
TokenType = tokenType,
|
||||
Username = user,
|
||||
Password = pass,
|
||||
CertificatePath = certPath,
|
||||
CertificatePassword = certPass
|
||||
}
|
||||
};
|
||||
|
||||
var json = OpcUaEndpointConfigSerializer.Serialize(original);
|
||||
var (round, _) = OpcUaEndpointConfigSerializer.Deserialize(json);
|
||||
|
||||
Assert.NotNull(round.UserIdentity);
|
||||
Assert.Equal(tokenType, round.UserIdentity!.TokenType);
|
||||
Assert.Equal(user, round.UserIdentity.Username);
|
||||
Assert.Equal(pass, round.UserIdentity.Password);
|
||||
Assert.Equal(certPath, round.UserIdentity.CertificatePath);
|
||||
Assert.Equal(certPass, round.UserIdentity.CertificatePassword);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_NullUserIdentityAndDeadband_OmittedFromTypedJson()
|
||||
{
|
||||
// Default config: UserIdentity and Deadband are null. Roundtrip should
|
||||
// preserve nulls (anonymous = no auth needed in flattened JSON either).
|
||||
var original = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" };
|
||||
var json = OpcUaEndpointConfigSerializer.Serialize(original);
|
||||
var (round, _) = OpcUaEndpointConfigSerializer.Deserialize(json);
|
||||
|
||||
Assert.Null(round.UserIdentity);
|
||||
Assert.Null(round.Deadband);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToFlatDict_IncludesNewScalars()
|
||||
{
|
||||
var config = new OpcUaEndpointConfig
|
||||
{
|
||||
EndpointUrl = "opc.tcp://x:4840",
|
||||
DiscardOldest = false,
|
||||
SubscriptionPriority = 50,
|
||||
SubscriptionDisplayName = "ScadaBridge-Edge",
|
||||
TimestampsToReturn = OpcUaTimestampsToReturn.Server
|
||||
};
|
||||
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
|
||||
|
||||
Assert.Equal("False", dict["DiscardOldest"]);
|
||||
Assert.Equal("50", dict["SubscriptionPriority"]);
|
||||
Assert.Equal("ScadaBridge-Edge", dict["SubscriptionDisplayName"]);
|
||||
Assert.Equal("Server", dict["TimestampsToReturn"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToFlatDict_OmitsNullUserIdentityAndDeadband()
|
||||
{
|
||||
var config = new OpcUaEndpointConfig { EndpointUrl = "opc.tcp://x:4840" };
|
||||
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
|
||||
|
||||
Assert.False(dict.ContainsKey("UserIdentity.TokenType"));
|
||||
Assert.False(dict.ContainsKey("UserIdentity.Username"));
|
||||
Assert.False(dict.ContainsKey("Deadband.Type"));
|
||||
Assert.False(dict.ContainsKey("Deadband.Value"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToFlatDict_IncludesUserIdentity_WhenSet()
|
||||
{
|
||||
var config = new OpcUaEndpointConfig
|
||||
{
|
||||
EndpointUrl = "opc.tcp://x:4840",
|
||||
UserIdentity = new OpcUaUserIdentityConfig
|
||||
{
|
||||
TokenType = OpcUaUserTokenType.UsernamePassword,
|
||||
Username = "alice",
|
||||
Password = "secret"
|
||||
}
|
||||
};
|
||||
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
|
||||
|
||||
Assert.Equal("UsernamePassword", dict["UserIdentity.TokenType"]);
|
||||
Assert.Equal("alice", dict["UserIdentity.Username"]);
|
||||
Assert.Equal("secret", dict["UserIdentity.Password"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToFlatDict_IncludesDeadband_WhenSet()
|
||||
{
|
||||
var config = new OpcUaEndpointConfig
|
||||
{
|
||||
EndpointUrl = "opc.tcp://x:4840",
|
||||
Deadband = new OpcUaDeadbandConfig { Type = OpcUaDeadbandType.Percent, Value = 1.5 }
|
||||
};
|
||||
var dict = OpcUaEndpointConfigSerializer.ToFlatDict(config);
|
||||
|
||||
Assert.Equal("Percent", dict["Deadband.Type"]);
|
||||
Assert.Equal("1.5", dict["Deadband.Value"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromFlatDict_MaterializesUserIdentity()
|
||||
{
|
||||
var dict = new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = "opc.tcp://x:4840",
|
||||
["UserIdentity.TokenType"] = "UsernamePassword",
|
||||
["UserIdentity.Username"] = "bob",
|
||||
["UserIdentity.Password"] = "hunter2"
|
||||
};
|
||||
var config = OpcUaEndpointConfigSerializer.FromFlatDict(dict);
|
||||
|
||||
Assert.NotNull(config.UserIdentity);
|
||||
Assert.Equal(OpcUaUserTokenType.UsernamePassword, config.UserIdentity!.TokenType);
|
||||
Assert.Equal("bob", config.UserIdentity.Username);
|
||||
Assert.Equal("hunter2", config.UserIdentity.Password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromFlatDict_MaterializesDeadband()
|
||||
{
|
||||
var dict = new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = "opc.tcp://x:4840",
|
||||
["Deadband.Type"] = "Absolute",
|
||||
["Deadband.Value"] = "0.25"
|
||||
};
|
||||
var config = OpcUaEndpointConfigSerializer.FromFlatDict(dict);
|
||||
|
||||
Assert.NotNull(config.Deadband);
|
||||
Assert.Equal(OpcUaDeadbandType.Absolute, config.Deadband!.Type);
|
||||
Assert.Equal(0.25, config.Deadband.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromFlatDict_AnonymousTokenTypeStillMaterializesUserIdentity()
|
||||
{
|
||||
// Explicit Anonymous TokenType (different from "missing") materializes the sub-object.
|
||||
var dict = new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = "opc.tcp://x:4840",
|
||||
["UserIdentity.TokenType"] = "Anonymous"
|
||||
};
|
||||
var config = OpcUaEndpointConfigSerializer.FromFlatDict(dict);
|
||||
|
||||
Assert.NotNull(config.UserIdentity);
|
||||
Assert.Equal(OpcUaUserTokenType.Anonymous, config.UserIdentity!.TokenType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using System.Dynamic;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="DynamicJsonElement"/>, including the Commons-002 regression:
|
||||
/// a wrapped element must remain valid for deferred (script-time) access even after
|
||||
/// the <see cref="JsonDocument"/> it was parsed from has been disposed.
|
||||
/// </summary>
|
||||
public class DynamicJsonElementTests
|
||||
{
|
||||
private static DynamicJsonElement Wrap(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return new DynamicJsonElement(doc.RootElement);
|
||||
// doc is disposed here — a wrapper that retained a non-cloned element would
|
||||
// now throw ObjectDisposedException on the first member access.
|
||||
}
|
||||
|
||||
// ── Commons-002 regression: lifetime independence from the source document ──
|
||||
|
||||
[Fact]
|
||||
public void MemberAccess_WorksAfterSourceDocumentDisposed()
|
||||
{
|
||||
dynamic obj = Wrap("""{ "name": "pump", "id": 7 }""");
|
||||
|
||||
Assert.Equal("pump", (string)obj.name);
|
||||
Assert.Equal(7, (int)obj.id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndexAccess_WorksAfterSourceDocumentDisposed()
|
||||
{
|
||||
dynamic obj = Wrap("""{ "items": [ "a", "b", "c" ] }""");
|
||||
|
||||
Assert.Equal("b", obj.items[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedAccess_WorksAfterSourceDocumentDisposed()
|
||||
{
|
||||
dynamic obj = Wrap("""{ "outer": { "inner": { "value": 42 } } }""");
|
||||
|
||||
Assert.Equal(42, (int)obj.outer.inner.value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToString_WorksAfterSourceDocumentDisposed()
|
||||
{
|
||||
dynamic obj = Wrap("""{ "label": "site-1" }""");
|
||||
|
||||
Assert.Equal("site-1", obj.label.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Access_SurvivesGarbageCollection_OfSourceDocument()
|
||||
{
|
||||
// No reference to the source document is held anywhere; force collection
|
||||
// and finalization to prove the wrapper does not depend on it.
|
||||
var obj = MakeWrapperAndDropDocument();
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
|
||||
dynamic d = obj;
|
||||
Assert.Equal("ok", (string)d.status);
|
||||
}
|
||||
|
||||
private static DynamicJsonElement MakeWrapperAndDropDocument()
|
||||
{
|
||||
var doc = JsonDocument.Parse("""{ "status": "ok" }""");
|
||||
var wrapper = new DynamicJsonElement(doc.RootElement);
|
||||
doc.Dispose();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// ── Basic conversion / access behavior ──────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Convert_NumberToInt()
|
||||
{
|
||||
dynamic obj = Wrap("""{ "n": 123 }""");
|
||||
Assert.Equal(123, (int)obj.n);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_BoolFromJson()
|
||||
{
|
||||
dynamic obj = Wrap("""{ "flag": true }""");
|
||||
Assert.True((bool)obj.flag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingMember_Throws()
|
||||
{
|
||||
// TryGetMember returns false for an absent property, so the dynamic binder
|
||||
// surfaces a RuntimeBinderException — the standard DynamicObject contract.
|
||||
dynamic obj = Wrap("""{ "a": 1 }""");
|
||||
Assert.Throws<Microsoft.CSharp.RuntimeBinder.RuntimeBinderException>(
|
||||
() => { var _ = obj.doesNotExist; });
|
||||
}
|
||||
|
||||
// ── Commons-006 regression: TryConvert(object) must never null out a present value ──
|
||||
|
||||
[Fact]
|
||||
public void TryConvert_ObjectTarget_OnPresentValue_ReturnsNonNull()
|
||||
{
|
||||
// Directly exercise the DynamicObject.TryConvert contract for an `object`
|
||||
// target: a present JSON object/array/string must not convert to null.
|
||||
using var objDoc = JsonDocument.Parse("""{ "x": 1 }""");
|
||||
var objWrapper = new DynamicJsonElement(objDoc.RootElement);
|
||||
var convBinder = (ConvertBinder)Microsoft.CSharp.RuntimeBinder.Binder.Convert(
|
||||
Microsoft.CSharp.RuntimeBinder.CSharpBinderFlags.None, typeof(object), typeof(DynamicJsonElementTests));
|
||||
|
||||
Assert.True(objWrapper.TryConvert(convBinder, out var result));
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryConvert_ObjectTarget_OnJsonNull_ReturnsNull()
|
||||
{
|
||||
// Only a genuinely null JSON value converts to a null object.
|
||||
using var doc = JsonDocument.Parse("""{ "v": null }""");
|
||||
var nullWrapper = new DynamicJsonElement(doc.RootElement.GetProperty("v"));
|
||||
var convBinder = (ConvertBinder)Microsoft.CSharp.RuntimeBinder.Binder.Convert(
|
||||
Microsoft.CSharp.RuntimeBinder.CSharpBinderFlags.None, typeof(object), typeof(DynamicJsonElementTests));
|
||||
|
||||
Assert.True(nullWrapper.TryConvert(convBinder, out var result));
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryConvert_NonObjectTarget_OnUnconvertibleValue_ReportsFailure()
|
||||
{
|
||||
// Requesting int from a JSON string is genuinely unconvertible: TryConvert
|
||||
// must report false rather than a null success.
|
||||
using var doc = JsonDocument.Parse("""{ "s": "not-a-number" }""");
|
||||
var strWrapper = new DynamicJsonElement(doc.RootElement.GetProperty("s"));
|
||||
var convBinder = (ConvertBinder)Microsoft.CSharp.RuntimeBinder.Binder.Convert(
|
||||
Microsoft.CSharp.RuntimeBinder.CSharpBinderFlags.None, typeof(int), typeof(DynamicJsonElementTests));
|
||||
|
||||
Assert.False(strWrapper.TryConvert(convBinder, out var result));
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
// ── Commons-013 regression: integral index values other than int must work ──
|
||||
|
||||
[Fact]
|
||||
public void IndexAccess_WithLongIndex_Works()
|
||||
{
|
||||
// DynamicJsonElement.Wrap surfaces JSON numbers as long; an index computed
|
||||
// from a wrapped JSON number (obj.items[obj.count - 1]) arrives as a long.
|
||||
dynamic obj = Wrap("""{ "items": [ "a", "b", "c" ] }""");
|
||||
long idx = 1L;
|
||||
|
||||
Assert.Equal("b", obj.items[idx]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndexAccess_WithIndexDerivedFromWrappedJsonNumber_Works()
|
||||
{
|
||||
// The exact failing case from Commons-013: count is a wrapped JSON number
|
||||
// (unwrapped as long), so count - 1 is a long.
|
||||
dynamic obj = Wrap("""{ "items": [ "a", "b", "c" ], "count": 3 }""");
|
||||
|
||||
Assert.Equal("c", obj.items[obj.count - 1]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)0, "a")]
|
||||
[InlineData((short)1, "b")]
|
||||
public void IndexAccess_WithWideningIntegralIndex_Works(object index, string expected)
|
||||
{
|
||||
dynamic obj = Wrap("""{ "items": [ "a", "b", "c" ] }""");
|
||||
|
||||
Assert.Equal(expected, obj.items[index]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndexAccess_WithLongIndexOutOfRange_Throws()
|
||||
{
|
||||
// An out-of-range long index is still rejected (binder surfaces the error).
|
||||
dynamic obj = Wrap("""{ "items": [ "a" ] }""");
|
||||
long idx = 5L;
|
||||
|
||||
Assert.Throws<Microsoft.CSharp.RuntimeBinder.RuntimeBinderException>(
|
||||
() => { var _ = obj.items[idx]; });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
public class EnumTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(typeof(DataType), new[] { "Boolean", "Int32", "Float", "Double", "String", "DateTime", "Binary" })]
|
||||
[InlineData(typeof(InstanceState), new[] { "NotDeployed", "Enabled", "Disabled" })]
|
||||
[InlineData(typeof(DeploymentStatus), new[] { "Pending", "InProgress", "Success", "Failed" })]
|
||||
[InlineData(typeof(AlarmState), new[] { "Active", "Normal" })]
|
||||
[InlineData(typeof(AlarmLevel), new[] { "None", "Low", "LowLow", "High", "HighHigh" })]
|
||||
[InlineData(typeof(AlarmTriggerType), new[] { "ValueMatch", "RangeViolation", "RateOfChange", "HiLo", "Expression" })]
|
||||
[InlineData(typeof(ConnectionHealth), new[] { "Connected", "Disconnected", "Connecting", "Error" })]
|
||||
[InlineData(typeof(NotificationStatus), new[] { "Pending", "Retrying", "Delivered", "Parked", "Discarded" })]
|
||||
[InlineData(typeof(NotificationType), new[] { "Email" })]
|
||||
public void Enum_ShouldHaveExpectedValues(Type enumType, string[] expectedNames)
|
||||
{
|
||||
var actualNames = Enum.GetNames(enumType);
|
||||
Assert.Equal(expectedNames, actualNames);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(DataType))]
|
||||
[InlineData(typeof(InstanceState))]
|
||||
[InlineData(typeof(DeploymentStatus))]
|
||||
[InlineData(typeof(AlarmState))]
|
||||
[InlineData(typeof(AlarmLevel))]
|
||||
[InlineData(typeof(AlarmTriggerType))]
|
||||
[InlineData(typeof(ConnectionHealth))]
|
||||
[InlineData(typeof(NotificationStatus))]
|
||||
[InlineData(typeof(NotificationType))]
|
||||
public void Enum_ShouldBeSingularNamed(Type enumType)
|
||||
{
|
||||
// Singular names should not end with 's' (except 'Status' which is singular)
|
||||
var name = enumType.Name;
|
||||
Assert.False(name.EndsWith("es") && !name.EndsWith("Status"),
|
||||
$"Enum {name} appears to be plural (ends with 'es').");
|
||||
Assert.False(name.EndsWith("s") && !name.EndsWith("ss") && !name.EndsWith("us"),
|
||||
$"Enum {name} appears to be plural (ends with 's').");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Asserts the exact member sets of the Audit Log (#23) enums.
|
||||
/// Lock-in tests; any addition/removal/rename is a deliberate design change
|
||||
/// that must come with a corresponding update to alog.md §4.
|
||||
/// </summary>
|
||||
public class AuditEnumTests
|
||||
{
|
||||
[Fact]
|
||||
public void AuditChannel_HasExactlyExpectedMembers()
|
||||
{
|
||||
var expected = new[] { "ApiOutbound", "DbOutbound", "Notification", "ApiInbound" };
|
||||
var actual = Enum.GetValues(typeof(AuditChannel))
|
||||
.Cast<AuditChannel>()
|
||||
.Select(x => x.ToString())
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(expected.Length, actual.Length);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditKind_HasExactlyTenExpectedMembers()
|
||||
{
|
||||
var expected = new[]
|
||||
{
|
||||
"ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached",
|
||||
"NotifySend", "NotifyDeliver", "InboundRequest", "InboundAuthFailure",
|
||||
"CachedSubmit", "CachedResolve",
|
||||
};
|
||||
var actual = Enum.GetValues(typeof(AuditKind))
|
||||
.Cast<AuditKind>()
|
||||
.Select(x => x.ToString())
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(10, actual.Length);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditStatus_HasExactlyEightExpectedMembers()
|
||||
{
|
||||
var expected = new[]
|
||||
{
|
||||
"Submitted", "Forwarded", "Attempted", "Delivered",
|
||||
"Failed", "Parked", "Discarded", "Skipped",
|
||||
};
|
||||
var actual = Enum.GetValues(typeof(AuditStatus))
|
||||
.Cast<AuditStatus>()
|
||||
.Select(x => x.ToString())
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(8, actual.Length);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditForwardState_HasExactlyExpectedMembers()
|
||||
{
|
||||
var expected = new[] { "Pending", "Forwarded", "Reconciled" };
|
||||
var actual = Enum.GetValues(typeof(AuditForwardState))
|
||||
.Cast<AuditForwardState>()
|
||||
.Select(x => x.ToString())
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(expected.Length, actual.Length);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Commons-010: coverage for the small computed-property logic on
|
||||
/// <see cref="ConfigurationDiff"/> and <see cref="ScriptScope"/>.
|
||||
/// </summary>
|
||||
public class FlatteningAndScriptScopeTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConfigurationDiff_NoChanges_HasChangesIsFalse()
|
||||
{
|
||||
var diff = new ConfigurationDiff { InstanceUniqueName = "inst-1" };
|
||||
|
||||
Assert.False(diff.HasChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigurationDiff_WithAttributeChange_HasChangesIsTrue()
|
||||
{
|
||||
var diff = new ConfigurationDiff
|
||||
{
|
||||
InstanceUniqueName = "inst-1",
|
||||
AlarmChanges = new[]
|
||||
{
|
||||
new DiffEntry<ResolvedAlarm> { CanonicalName = "HiAlarm", ChangeType = DiffChangeType.Added }
|
||||
}
|
||||
};
|
||||
|
||||
Assert.True(diff.HasChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptScope_Root_HasNoParent()
|
||||
{
|
||||
Assert.False(ScriptScope.Root.HasParent);
|
||||
Assert.Null(ScriptScope.Root.ParentPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptScope_WithParentPath_HasParentIsTrue()
|
||||
{
|
||||
var scope = new ScriptScope("Pump1.Motor", "Pump1");
|
||||
|
||||
Assert.True(scope.HasParent);
|
||||
Assert.Equal("Pump1", scope.ParentPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
public class ResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void Success_ShouldCreateSuccessfulResult()
|
||||
{
|
||||
var result = Result<int>.Success(42);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.IsFailure);
|
||||
Assert.Equal(42, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_ShouldCreateFailedResult()
|
||||
{
|
||||
var result = Result<int>.Failure("something went wrong");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal("something went wrong", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Value_OnFailure_ShouldThrow()
|
||||
{
|
||||
var result = Result<int>.Failure("error");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Error_OnSuccess_ShouldThrow()
|
||||
{
|
||||
var result = Result<int>.Success(42);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_OnSuccess_ShouldCallOnSuccess()
|
||||
{
|
||||
var result = Result<int>.Success(42);
|
||||
|
||||
var output = result.Match(
|
||||
v => $"value={v}",
|
||||
e => $"error={e}");
|
||||
|
||||
Assert.Equal("value=42", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Match_OnFailure_ShouldCallOnFailure()
|
||||
{
|
||||
var result = Result<int>.Failure("bad");
|
||||
|
||||
var output = result.Match(
|
||||
v => $"value={v}",
|
||||
e => $"error={e}");
|
||||
|
||||
Assert.Equal("error=bad", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Success_WithNullableReferenceType_ShouldWork()
|
||||
{
|
||||
var result = Result<string>.Success("hello");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("hello", result.Value);
|
||||
}
|
||||
|
||||
// ── Commons-011 regression: a failed Result must always carry a message ──
|
||||
|
||||
[Fact]
|
||||
public void Failure_WithNullError_ShouldThrow()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => Result<int>.Failure(null!));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Failure_WithBlankError_ShouldThrow(string error)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => Result<int>.Failure(error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
public class RetryPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void RetryPolicy_ShouldBeImmutableRecord()
|
||||
{
|
||||
var policy = new RetryPolicy(3, TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(3, policy.MaxRetries);
|
||||
Assert.Equal(TimeSpan.FromSeconds(5), policy.Delay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryPolicy_WithExpression_ShouldCreateNewInstance()
|
||||
{
|
||||
var original = new RetryPolicy(3, TimeSpan.FromSeconds(5));
|
||||
var modified = original with { MaxRetries = 5 };
|
||||
|
||||
Assert.Equal(3, original.MaxRetries);
|
||||
Assert.Equal(5, modified.MaxRetries);
|
||||
Assert.Equal(original.Delay, modified.Delay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryPolicy_EqualValues_ShouldBeEqual()
|
||||
{
|
||||
var a = new RetryPolicy(3, TimeSpan.FromSeconds(5));
|
||||
var b = new RetryPolicy(3, TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(a, b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Collections;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Commons-010: coverage for <see cref="ScriptArgs.Normalize"/> — the script-call
|
||||
/// parameter normalizer (dictionary / anonymous-object / primitive-rejection paths).
|
||||
/// </summary>
|
||||
public class ScriptArgsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Normalize_Null_ReturnsNull()
|
||||
{
|
||||
Assert.Null(ScriptArgs.Normalize(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_ReadOnlyDictionary_ReturnedAsIs()
|
||||
{
|
||||
IReadOnlyDictionary<string, object?> input =
|
||||
new Dictionary<string, object?> { ["a"] = 1 };
|
||||
|
||||
var result = ScriptArgs.Normalize(input);
|
||||
|
||||
Assert.Same(input, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_PlainDictionary_ReturnedAsIs()
|
||||
{
|
||||
// Dictionary<string,object?> implements IReadOnlyDictionary, so it matches the
|
||||
// first switch arm and is returned by reference (no defensive copy).
|
||||
var input = new Dictionary<string, object?> { ["a"] = 1 };
|
||||
|
||||
var result = ScriptArgs.Normalize(input);
|
||||
|
||||
Assert.Same(input, result);
|
||||
Assert.Equal(1, result!["a"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_NonGenericDictionary_KeysStringified()
|
||||
{
|
||||
IDictionary raw = new Hashtable { [42] = "answer" };
|
||||
|
||||
var result = ScriptArgs.Normalize(raw);
|
||||
|
||||
Assert.Equal("answer", result!["42"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_AnonymousObject_PropertiesBecomeEntries()
|
||||
{
|
||||
var result = ScriptArgs.Normalize(new { name = "Bob", count = 3 });
|
||||
|
||||
Assert.Equal("Bob", result!["name"]);
|
||||
Assert.Equal(3, result["count"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(42)]
|
||||
[InlineData(true)]
|
||||
[InlineData(3.14)]
|
||||
public void Normalize_Primitive_Throws(object primitive)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => ScriptArgs.Normalize(primitive));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_String_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => ScriptArgs.Normalize("hello"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_Decimal_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => ScriptArgs.Normalize(9.99m));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
public class ScriptParametersTests
|
||||
{
|
||||
// ── Non-nullable scalar Get<T> ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Get_Int_ExactTypeMatch()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = 42 });
|
||||
Assert.Equal(42, p.Get<int>("x"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Int_FromLong()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = 42L });
|
||||
Assert.Equal(42, p.Get<int>("x"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Int_FromString()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = "42" });
|
||||
Assert.Equal(42, p.Get<int>("x"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Int_MissingKey_Throws()
|
||||
{
|
||||
var p = new ScriptParameters();
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int>("x"));
|
||||
Assert.Contains("Parameter 'x' not found", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Int_NullValue_Throws()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = null });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int>("x"));
|
||||
Assert.Contains("Parameter 'x' value is null", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Int_Unparsable_Throws()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = "abc" });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int>("x"));
|
||||
Assert.Contains("could not be parsed as Int32", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_String_DirectReturn()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["s"] = "hello" });
|
||||
Assert.Equal("hello", p.Get<string>("s"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_String_NullValue_Throws()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["s"] = null });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<string>("s"));
|
||||
Assert.Contains("value is null", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_String_FromInt()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["s"] = 42 });
|
||||
Assert.Equal("42", p.Get<string>("s"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Bool_True()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["b"] = true });
|
||||
Assert.True(p.Get<bool>("b"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Double_FromLong()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["d"] = 42L });
|
||||
Assert.Equal(42.0, p.Get<double>("d"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Double_FromFloat()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["d"] = 3.14f });
|
||||
Assert.Equal(3.14, p.Get<double>("d"), 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Long_FromInt()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["l"] = 42 });
|
||||
Assert.Equal(42L, p.Get<long>("l"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Float_FromDouble()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["f"] = 3.14 });
|
||||
Assert.Equal(3.14f, p.Get<float>("f"), 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_DateTime_FromString()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["dt"] = "2026-03-22T10:30:00Z" });
|
||||
var result = p.Get<DateTime>("dt");
|
||||
Assert.Equal(new DateTime(2026, 3, 22, 10, 30, 0, DateTimeKind.Utc), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_DateTime_ExactType()
|
||||
{
|
||||
var dt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["dt"] = dt });
|
||||
Assert.Equal(dt, p.Get<DateTime>("dt"));
|
||||
}
|
||||
|
||||
// ── Nullable scalar Get<T?> ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Get_NullableInt_MissingKey_ReturnsNull()
|
||||
{
|
||||
var p = new ScriptParameters();
|
||||
Assert.Null(p.Get<int?>("x"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_NullableInt_NullValue_ReturnsNull()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = null });
|
||||
Assert.Null(p.Get<int?>("x"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_NullableInt_ValidValue_ReturnsValue()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = 42L });
|
||||
Assert.Equal(42, p.Get<int?>("x"));
|
||||
}
|
||||
|
||||
// Commons-003: a parameter that is *present but unconvertible* is a caller/script
|
||||
// bug and must throw — not be silently mapped to null (which a script would
|
||||
// misread as "not supplied"). Genuinely absent/null still returns null.
|
||||
[Fact]
|
||||
public void Get_NullableInt_PresentButUnparsable_Throws()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = "banana" });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int?>("x"));
|
||||
Assert.Contains("could not be parsed as Int32", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_NullableInt_PresentButOverflowing_Throws()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = long.MaxValue });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int?>("x"));
|
||||
Assert.Contains("could not be parsed as Int32", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_NullableDateTime_PresentButUnparsable_Throws()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["dt"] = "not-a-date" });
|
||||
Assert.Throws<ScriptParameterException>(() => p.Get<DateTime?>("dt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_NullableDouble_ValidValue()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["d"] = 3.14 });
|
||||
Assert.Equal(3.14, p.Get<double?>("d"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_NullableBool_ValidValue()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["b"] = true });
|
||||
Assert.True(p.Get<bool?>("b"));
|
||||
}
|
||||
|
||||
// ── Array Get<T[]> ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Get_IntArray_FromListOfLongs()
|
||||
{
|
||||
var list = new List<object?> { 1L, 2L, 3L };
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["arr"] = list });
|
||||
Assert.Equal(new[] { 1, 2, 3 }, p.Get<int[]>("arr"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_IntArray_EmptyList()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["arr"] = new List<object?>() });
|
||||
Assert.Empty(p.Get<int[]>("arr"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_IntArray_NonList_Throws()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["arr"] = "not a list" });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int[]>("arr"));
|
||||
Assert.Contains("is not a list or array", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_IntArray_UnparsableElement_ThrowsWithIndex()
|
||||
{
|
||||
var list = new List<object?> { 1L, 2L, "bad" };
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["arr"] = list });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int[]>("arr"));
|
||||
Assert.Contains("element at index 2", ex.Message);
|
||||
Assert.Contains("'bad'", ex.Message);
|
||||
Assert.Contains("Int32", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_IntArray_NullElement_Throws()
|
||||
{
|
||||
var list = new List<object?> { 1L, null, 3L };
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["arr"] = list });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int[]>("arr"));
|
||||
Assert.Contains("element at index 1 is null", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_StringArray_FromListOfStrings()
|
||||
{
|
||||
var list = new List<object?> { "a", "b", "c" };
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["arr"] = list });
|
||||
Assert.Equal(new[] { "a", "b", "c" }, p.Get<string[]>("arr"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_DoubleArray_FromListOfNumbers()
|
||||
{
|
||||
var list = new List<object?> { 1.1, 2.2, 3.3 };
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["arr"] = list });
|
||||
Assert.Equal(new[] { 1.1, 2.2, 3.3 }, p.Get<double[]>("arr"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_IntArray_MissingKey_Throws()
|
||||
{
|
||||
var p = new ScriptParameters();
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int[]>("arr"));
|
||||
Assert.Contains("not found", ex.Message);
|
||||
}
|
||||
|
||||
// ── List<T> Get<List<T>> ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Get_ListInt_FromListOfLongs()
|
||||
{
|
||||
var list = new List<object?> { 10L, 20L, 30L };
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["items"] = list });
|
||||
var result = p.Get<List<int>>("items");
|
||||
Assert.Equal(new List<int> { 10, 20, 30 }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_ListString_FromListOfStrings()
|
||||
{
|
||||
var list = new List<object?> { "x", "y" };
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["items"] = list });
|
||||
Assert.Equal(new List<string> { "x", "y" }, p.Get<List<string>>("items"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_ListInt_UnparsableElement_ThrowsWithIndex()
|
||||
{
|
||||
var list = new List<object?> { 1L, "oops" };
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["items"] = list });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<List<int>>("items"));
|
||||
Assert.Contains("element at index 1", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_ListDouble_FromMixedNumbers()
|
||||
{
|
||||
var list = new List<object?> { 1L, 2.5, 3 };
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["items"] = list });
|
||||
var result = p.Get<List<double>>("items");
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(1.0, result[0]);
|
||||
Assert.Equal(2.5, result[1]);
|
||||
Assert.Equal(3.0, result[2]);
|
||||
}
|
||||
|
||||
// ── Backward compatibility ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Indexer_Works()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["key"] = "val" });
|
||||
Assert.Equal("val", p["key"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsKey_Works()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["key"] = 1 });
|
||||
Assert.True(p.ContainsKey("key"));
|
||||
Assert.False(p.ContainsKey("missing"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetValue_Works()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["key"] = 42 });
|
||||
Assert.True(p.TryGetValue("key", out var val));
|
||||
Assert.Equal(42, val);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Count_Works()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["a"] = 1, ["b"] = 2 });
|
||||
Assert.Equal(2, p.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enumeration_Works()
|
||||
{
|
||||
var dict = new Dictionary<string, object?> { ["a"] = 1, ["b"] = 2 };
|
||||
var p = new ScriptParameters(dict);
|
||||
var keys = p.Select(kv => kv.Key).OrderBy(k => k).ToList();
|
||||
Assert.Equal(new[] { "a", "b" }, keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyConstructor_ProducesEmptyDictionary()
|
||||
{
|
||||
var p = new ScriptParameters();
|
||||
Assert.Empty(p);
|
||||
Assert.False(p.ContainsKey("anything"));
|
||||
}
|
||||
|
||||
// ── Edge cases ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Get_Double_FromInt()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["d"] = 42 });
|
||||
Assert.Equal(42.0, p.Get<double>("d"));
|
||||
}
|
||||
|
||||
// ── JsonElement values (from JSON deserialization) ─────────────
|
||||
|
||||
[Fact]
|
||||
public void Get_Int_FromJsonElement()
|
||||
{
|
||||
using var doc = JsonDocument.Parse("{\"x\": 42}");
|
||||
var element = doc.RootElement.GetProperty("x").Clone();
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = element });
|
||||
Assert.Equal(42, p.Get<int>("x"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_IntArray_FromJsonElementList()
|
||||
{
|
||||
// Simulates what JsonSerializer.Deserialize<List<object?>> produces
|
||||
using var doc = JsonDocument.Parse("[10, 20, 30]");
|
||||
var list = JsonSerializer.Deserialize<List<object?>>(doc.RootElement.GetRawText())!;
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["items"] = list });
|
||||
Assert.Equal(new[] { 10, 20, 30 }, p.Get<int[]>("items"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_ListInt_FromJsonElementList()
|
||||
{
|
||||
using var doc = JsonDocument.Parse("[1, 2, 3]");
|
||||
var list = JsonSerializer.Deserialize<List<object?>>(doc.RootElement.GetRawText())!;
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["items"] = list });
|
||||
Assert.Equal(new List<int> { 1, 2, 3 }, p.Get<List<int>>("items"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_String_FromJsonElement()
|
||||
{
|
||||
using var doc = JsonDocument.Parse("{\"s\": \"hello\"}");
|
||||
var element = doc.RootElement.GetProperty("s").Clone();
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["s"] = element });
|
||||
Assert.Equal("hello", p.Get<string>("s"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Double_FromJsonElement()
|
||||
{
|
||||
using var doc = JsonDocument.Parse("{\"d\": 3.14}");
|
||||
var element = doc.RootElement.GetProperty("d").Clone();
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["d"] = element });
|
||||
Assert.Equal(3.14, p.Get<double>("d"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Bool_FromJsonElement()
|
||||
{
|
||||
using var doc = JsonDocument.Parse("{\"b\": true}");
|
||||
var element = doc.RootElement.GetProperty("b").Clone();
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["b"] = element });
|
||||
Assert.True(p.Get<bool>("b"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Int_OverflowFromLong_Throws()
|
||||
{
|
||||
var p = new ScriptParameters(new Dictionary<string, object?> { ["x"] = long.MaxValue });
|
||||
var ex = Assert.Throws<ScriptParameterException>(() => p.Get<int>("x"));
|
||||
Assert.Contains("could not be parsed as Int32", ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="SiteCallOperational"/> — the positional record carried on
|
||||
/// the combined <c>CachedCallTelemetry</c> packet — round-trips the SourceNode
|
||||
/// field through positional construction (where the parameter sits between
|
||||
/// <c>SourceSite</c> and <c>Status</c>, mirroring the central <c>SiteCalls</c>
|
||||
/// table column order).
|
||||
/// </summary>
|
||||
public class SiteCallOperationalTests
|
||||
{
|
||||
[Fact]
|
||||
public void SiteCallOperational_carries_SourceNode()
|
||||
{
|
||||
// SourceNode identifies the cluster node that emitted the cached call
|
||||
// (site node-a/node-b or central-a/central-b). Nullable — callsites
|
||||
// pass null until INodeIdentityProvider stamping arrives in Task 14.
|
||||
var trackedId = TrackedOperationId.New();
|
||||
var nowUtc = new DateTime(2026, 5, 23, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var defaulted = new SiteCallOperational(
|
||||
TrackedOperationId: trackedId,
|
||||
Channel: "ApiOutbound",
|
||||
Target: "ERP.GetOrder",
|
||||
SourceSite: "site-01",
|
||||
SourceNode: null,
|
||||
Status: "Submitted",
|
||||
RetryCount: 0,
|
||||
LastError: null,
|
||||
HttpStatus: null,
|
||||
CreatedAtUtc: nowUtc,
|
||||
UpdatedAtUtc: nowUtc,
|
||||
TerminalAtUtc: null);
|
||||
Assert.Null(defaulted.SourceNode);
|
||||
|
||||
var stamped = defaulted with { SourceNode = "node-a" };
|
||||
Assert.Equal("node-a", stamped.SourceNode);
|
||||
Assert.Null(defaulted.SourceNode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
public class SiteNotificationKpiSnapshotTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_AssignsAllMembers()
|
||||
{
|
||||
var snapshot = new SiteNotificationKpiSnapshot(
|
||||
SourceSiteId: "plant-a",
|
||||
QueueDepth: 5,
|
||||
StuckCount: 2,
|
||||
ParkedCount: 1,
|
||||
DeliveredLastInterval: 40,
|
||||
OldestPendingAge: TimeSpan.FromMinutes(12));
|
||||
|
||||
Assert.Equal("plant-a", snapshot.SourceSiteId);
|
||||
Assert.Equal(5, snapshot.QueueDepth);
|
||||
Assert.Equal(2, snapshot.StuckCount);
|
||||
Assert.Equal(1, snapshot.ParkedCount);
|
||||
Assert.Equal(40, snapshot.DeliveredLastInterval);
|
||||
Assert.Equal(TimeSpan.FromMinutes(12), snapshot.OldestPendingAge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldestPendingAge_IsNullableForSitesWithNoBacklog()
|
||||
{
|
||||
var snapshot = new SiteNotificationKpiSnapshot("plant-b", 0, 0, 0, 0, null);
|
||||
Assert.Null(snapshot.OldestPendingAge);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for Commons-001: the check-then-act race between the timer
|
||||
/// callback (<c>OnTimerElapsed</c>) and <c>OnValueReceived</c> / <c>Stop</c> / <c>Start</c>.
|
||||
///
|
||||
/// The original implementation guarded firing with a single <c>volatile bool</c> that
|
||||
/// was both read by the callback and reset by the caller threads. Because the
|
||||
/// check-then-set was not atomic with the timer reschedule, a callback that had
|
||||
/// already entered could raise <c>Stale</c> after the period it was scheduled for
|
||||
/// had been cancelled or restarted — a spurious staleness signal that, for a
|
||||
/// connection-health monitor, triggers an unnecessary reconnect.
|
||||
///
|
||||
/// These tests use the internal <c>CallbackEnteredHook</c> seam to deterministically
|
||||
/// interleave a caller-thread operation with an in-flight callback.
|
||||
/// </summary>
|
||||
public class StaleTagMonitorRaceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// A value arrives (<c>OnValueReceived</c>) while a previous-period timer callback
|
||||
/// is in flight, before that callback decides whether to fire. The old period has
|
||||
/// been superseded, so the in-flight callback must not raise <c>Stale</c>
|
||||
/// immediately; <c>Stale</c> may only fire later, for the fresh period, after a
|
||||
/// full <c>MaxSilence</c> with no further values.
|
||||
///
|
||||
/// With the original single-volatile-bool guard the in-flight callback fired
|
||||
/// <c>Stale</c> right after the value arrived (a spurious, wrong-moment signal);
|
||||
/// this test detects that by checking how soon the fire lands after the value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Stale_DoesNotFirePromptly_WhenValueArrivesWhileCallbackInFlight()
|
||||
{
|
||||
var maxSilence = TimeSpan.FromMilliseconds(60);
|
||||
using var monitor = new StaleTagMonitor(maxSilence);
|
||||
|
||||
DateTime? valueArrivedAt = null;
|
||||
DateTime? staleFiredAt = null;
|
||||
monitor.Stale += () => staleFiredAt ??= DateTime.UtcNow;
|
||||
|
||||
// When the (old-period) callback is entered, simulate a fresh value arriving
|
||||
// on another thread before the callback's fire decision.
|
||||
monitor.CallbackEnteredHook = () =>
|
||||
{
|
||||
monitor.CallbackEnteredHook = null; // only intercept the first callback
|
||||
valueArrivedAt = DateTime.UtcNow;
|
||||
monitor.OnValueReceived();
|
||||
};
|
||||
|
||||
monitor.Start();
|
||||
|
||||
// Wait well past the intercepted callback and the fresh period's deadline.
|
||||
Thread.Sleep(300);
|
||||
monitor.Stop();
|
||||
|
||||
// The fresh period legitimately goes stale, so a fire is expected — but it
|
||||
// must land roughly MaxSilence after the value, not immediately. A spurious
|
||||
// wrong-moment fire from the superseded callback would land within a few ms.
|
||||
Assert.NotNull(valueArrivedAt);
|
||||
Assert.NotNull(staleFiredAt);
|
||||
var delay = staleFiredAt.Value - valueArrivedAt.Value;
|
||||
Assert.True(delay >= maxSilence * 0.5,
|
||||
$"Stale fired only {delay.TotalMilliseconds:F0}ms after the value arrived; " +
|
||||
$"expected at least {maxSilence.TotalMilliseconds * 0.5:F0}ms — the in-flight " +
|
||||
"callback fired spuriously for the superseded period.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>Stop</c> races an in-flight timer callback. Once monitoring is stopped no
|
||||
/// <c>Stale</c> signal may be delivered, even for a callback that had already
|
||||
/// been entered.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Stale_DoesNotFire_WhenStopRacesInFlightCallback()
|
||||
{
|
||||
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(30));
|
||||
var staleCount = 0;
|
||||
monitor.Stale += () => Interlocked.Increment(ref staleCount);
|
||||
|
||||
monitor.CallbackEnteredHook = () =>
|
||||
{
|
||||
monitor.CallbackEnteredHook = null;
|
||||
monitor.Stop();
|
||||
};
|
||||
|
||||
monitor.Start();
|
||||
Thread.Sleep(200);
|
||||
|
||||
Assert.Equal(0, staleCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>Start</c> (a restart) races an in-flight callback from the prior run. The
|
||||
/// old callback belongs to a superseded period and must not fire; the new period
|
||||
/// fires exactly once on its own deadline.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Stale_FiresOnceForNewPeriod_WhenRestartRacesInFlightCallback()
|
||||
{
|
||||
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(30));
|
||||
var staleCount = 0;
|
||||
monitor.Stale += () => Interlocked.Increment(ref staleCount);
|
||||
|
||||
monitor.CallbackEnteredHook = () =>
|
||||
{
|
||||
monitor.CallbackEnteredHook = null;
|
||||
monitor.Start(); // restart — supersedes the in-flight callback's period
|
||||
};
|
||||
|
||||
monitor.Start();
|
||||
|
||||
// Old callback must be suppressed; the restarted period fires exactly once.
|
||||
Thread.Sleep(250);
|
||||
monitor.Stop();
|
||||
|
||||
Assert.Equal(1, staleCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
public class StaleTagMonitorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_ZeroTimeSpan_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new StaleTagMonitor(TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativeTimeSpan_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new StaleTagMonitor(TimeSpan.FromSeconds(-1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stale_FiresAfterMaxSilence()
|
||||
{
|
||||
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(100));
|
||||
var staleCount = 0;
|
||||
monitor.Stale += () => Interlocked.Increment(ref staleCount);
|
||||
monitor.Start();
|
||||
|
||||
await Task.Delay(300);
|
||||
Assert.Equal(1, staleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stale_FiresOnlyOnce()
|
||||
{
|
||||
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(50));
|
||||
var staleCount = 0;
|
||||
monitor.Stale += () => Interlocked.Increment(ref staleCount);
|
||||
monitor.Start();
|
||||
|
||||
await Task.Delay(300);
|
||||
Assert.Equal(1, staleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnValueReceived_ResetsTimer()
|
||||
{
|
||||
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(200));
|
||||
var staleCount = 0;
|
||||
monitor.Stale += () => Interlocked.Increment(ref staleCount);
|
||||
monitor.Start();
|
||||
|
||||
// Keep resetting before the 200ms deadline
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await Task.Delay(100);
|
||||
monitor.OnValueReceived();
|
||||
}
|
||||
|
||||
// Should not have gone stale
|
||||
Assert.Equal(0, staleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnValueReceived_AllowsStaleAfterSilence()
|
||||
{
|
||||
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(100));
|
||||
var staleCount = 0;
|
||||
monitor.Stale += () => Interlocked.Increment(ref staleCount);
|
||||
monitor.Start();
|
||||
|
||||
// Reset once
|
||||
await Task.Delay(50);
|
||||
monitor.OnValueReceived();
|
||||
|
||||
// Then go silent
|
||||
await Task.Delay(250);
|
||||
Assert.Equal(1, staleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnValueReceived_ResetsStaleFlag_AllowsSecondFire()
|
||||
{
|
||||
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(100));
|
||||
var staleCount = 0;
|
||||
monitor.Stale += () => Interlocked.Increment(ref staleCount);
|
||||
monitor.Start();
|
||||
|
||||
// Wait for first stale
|
||||
await Task.Delay(250);
|
||||
Assert.Equal(1, staleCount);
|
||||
|
||||
// Reset — should allow second stale fire
|
||||
monitor.OnValueReceived();
|
||||
await Task.Delay(250);
|
||||
Assert.Equal(2, staleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stop_PreventsStale()
|
||||
{
|
||||
using var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(50));
|
||||
var staleCount = 0;
|
||||
monitor.Stale += () => Interlocked.Increment(ref staleCount);
|
||||
monitor.Start();
|
||||
monitor.Stop();
|
||||
|
||||
await Task.Delay(200);
|
||||
Assert.Equal(0, staleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_PreventsStale()
|
||||
{
|
||||
var monitor = new StaleTagMonitor(TimeSpan.FromMilliseconds(50));
|
||||
var staleCount = 0;
|
||||
monitor.Stale += () => Interlocked.Increment(ref staleCount);
|
||||
monitor.Start();
|
||||
monitor.Dispose();
|
||||
|
||||
await Task.Delay(200);
|
||||
Assert.Equal(0, staleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxSilence_ReturnsConfiguredValue()
|
||||
{
|
||||
using var monitor = new StaleTagMonitor(TimeSpan.FromSeconds(42));
|
||||
Assert.Equal(TimeSpan.FromSeconds(42), monitor.MaxSilence);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (M3): tests for the strongly-typed cached-operation identifier
|
||||
/// produced by <c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c> and
|
||||
/// surfaced to scripts via <c>Tracking.Status(id)</c>.
|
||||
/// </summary>
|
||||
public class TrackedOperationIdTests
|
||||
{
|
||||
[Fact]
|
||||
public void New_ProducesUniqueIds()
|
||||
{
|
||||
var a = TrackedOperationId.New();
|
||||
var b = TrackedOperationId.New();
|
||||
|
||||
Assert.NotEqual(a, b);
|
||||
Assert.NotEqual(Guid.Empty, a.Value);
|
||||
Assert.NotEqual(Guid.Empty, b.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_RoundTrip_PreservesValue()
|
||||
{
|
||||
var original = TrackedOperationId.New();
|
||||
var serialized = original.ToString();
|
||||
|
||||
var parsed = TrackedOperationId.Parse(serialized);
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.Equal(original.Value, parsed.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_InvalidInput_ReturnsFalse()
|
||||
{
|
||||
Assert.False(TrackedOperationId.TryParse("not-a-guid", out var result));
|
||||
Assert.Equal(default, result);
|
||||
|
||||
Assert.False(TrackedOperationId.TryParse(null, out var nullResult));
|
||||
Assert.Equal(default, nullResult);
|
||||
|
||||
Assert.False(TrackedOperationId.TryParse(string.Empty, out var emptyResult));
|
||||
Assert.Equal(default, emptyResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_ValidInput_ReturnsTrueAndId()
|
||||
{
|
||||
var original = TrackedOperationId.New();
|
||||
var serialized = original.ToString();
|
||||
|
||||
Assert.True(TrackedOperationId.TryParse(serialized, out var parsed));
|
||||
Assert.Equal(original, parsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equality_BasedOnValue()
|
||||
{
|
||||
var guid = Guid.NewGuid();
|
||||
var a = new TrackedOperationId(guid);
|
||||
var b = new TrackedOperationId(guid);
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.True(a == b);
|
||||
Assert.False(a != b);
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToString_StandardGuidFormat()
|
||||
{
|
||||
var guid = Guid.Parse("12345678-1234-1234-1234-1234567890ab");
|
||||
var id = new TrackedOperationId(guid);
|
||||
|
||||
// "D" format: 32 hex digits separated by hyphens (8-4-4-4-12).
|
||||
Assert.Equal("12345678-1234-1234-1234-1234567890ab", id.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Commons-015: <see cref="EncryptionMetadata"/> must reject malformed envelopes at
|
||||
/// the type boundary (unknown algorithm, unsupported KDF, sub-minimum or over-cap
|
||||
/// iteration counts, null salt/IV). Valid construction must round-trip the fields.
|
||||
/// </summary>
|
||||
public sealed class EncryptionMetadataTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_WithDocumentedValues_Succeeds()
|
||||
{
|
||||
// 600_000 is the design-doc production value; "abc"/"def" are placeholder
|
||||
// Base64 strings, kept short for test legibility.
|
||||
var meta = new EncryptionMetadata(
|
||||
Algorithm: "AES-256-GCM",
|
||||
Kdf: "PBKDF2-SHA256",
|
||||
Iterations: 600_000,
|
||||
SaltB64: "abc",
|
||||
IvB64: "def");
|
||||
|
||||
Assert.Equal("AES-256-GCM", meta.Algorithm);
|
||||
Assert.Equal("PBKDF2-SHA256", meta.Kdf);
|
||||
Assert.Equal(600_000, meta.Iterations);
|
||||
Assert.Equal("abc", meta.SaltB64);
|
||||
Assert.Equal("def", meta.IvB64);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("AES-128-CBC")] // weaker algorithm
|
||||
[InlineData("AES-256-CBC")] // unauthenticated mode
|
||||
[InlineData("aes-256-gcm")] // case must match exactly
|
||||
[InlineData("")]
|
||||
[InlineData("FOO")]
|
||||
public void Constructor_UnknownAlgorithm_Throws(string algorithm)
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new EncryptionMetadata(
|
||||
Algorithm: algorithm,
|
||||
Kdf: "PBKDF2-SHA256",
|
||||
Iterations: 600_000,
|
||||
SaltB64: "abc",
|
||||
IvB64: "def"));
|
||||
|
||||
Assert.Equal("Algorithm", ex.ParamName);
|
||||
Assert.Contains("AES-256-GCM", ex.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PBKDF2-SHA1")] // weaker hash
|
||||
[InlineData("argon2id")] // unsupported KDF
|
||||
[InlineData("pbkdf2-sha256")] // case must match
|
||||
[InlineData("")]
|
||||
public void Constructor_UnknownKdf_Throws(string kdf)
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new EncryptionMetadata(
|
||||
Algorithm: "AES-256-GCM",
|
||||
Kdf: kdf,
|
||||
Iterations: 600_000,
|
||||
SaltB64: "abc",
|
||||
IvB64: "def"));
|
||||
|
||||
Assert.Equal("Kdf", ex.ParamName);
|
||||
Assert.Contains("PBKDF2-SHA256", ex.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(1)]
|
||||
[InlineData(99_999)] // one below the floor
|
||||
[InlineData(10_000_001)] // one above the ceiling
|
||||
[InlineData(int.MaxValue)]
|
||||
public void Constructor_IterationsOutOfRange_Throws(int iterations)
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new EncryptionMetadata(
|
||||
Algorithm: "AES-256-GCM",
|
||||
Kdf: "PBKDF2-SHA256",
|
||||
Iterations: iterations,
|
||||
SaltB64: "abc",
|
||||
IvB64: "def"));
|
||||
|
||||
Assert.Equal("Iterations", ex.ParamName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(100_000)] // OWASP minimum (exact)
|
||||
[InlineData(600_000)] // design-doc production value
|
||||
[InlineData(10_000_000)] // ceiling (exact)
|
||||
public void Constructor_IterationsAtBoundary_Succeeds(int iterations)
|
||||
{
|
||||
// Exercises the inclusive boundary check on both ends.
|
||||
var meta = new EncryptionMetadata(
|
||||
Algorithm: "AES-256-GCM",
|
||||
Kdf: "PBKDF2-SHA256",
|
||||
Iterations: iterations,
|
||||
SaltB64: "abc",
|
||||
IvB64: "def");
|
||||
|
||||
Assert.Equal(iterations, meta.Iterations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullSalt_Throws()
|
||||
{
|
||||
// null is rejected; empty is permitted (the seed pattern used by BundleSerializer.Pack).
|
||||
Assert.Throws<ArgumentNullException>(() => new EncryptionMetadata(
|
||||
Algorithm: "AES-256-GCM",
|
||||
Kdf: "PBKDF2-SHA256",
|
||||
Iterations: 600_000,
|
||||
SaltB64: null!,
|
||||
IvB64: "def"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullIv_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new EncryptionMetadata(
|
||||
Algorithm: "AES-256-GCM",
|
||||
Kdf: "PBKDF2-SHA256",
|
||||
Iterations: 600_000,
|
||||
SaltB64: "abc",
|
||||
IvB64: null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_EmptySaltAndIv_Succeeds_ForSeedPattern()
|
||||
{
|
||||
// BundleSerializer.Pack re-stamps salt/iv from the ciphertext it actually
|
||||
// writes, so callers (BundleExporter) construct a seed instance with empty
|
||||
// placeholders. Validation must therefore accept empty here.
|
||||
var seed = new EncryptionMetadata(
|
||||
Algorithm: "AES-256-GCM",
|
||||
Kdf: "PBKDF2-SHA256",
|
||||
Iterations: 600_000,
|
||||
SaltB64: string.Empty,
|
||||
IvB64: string.Empty);
|
||||
|
||||
Assert.Equal(string.Empty, seed.SaltB64);
|
||||
Assert.Equal(string.Empty, seed.IvB64);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Commons-020: focused shape / round-trip tests for the Transport (#24) record DTOs
|
||||
/// — <see cref="BundleManifest"/>, <see cref="ExportSelection"/>,
|
||||
/// <see cref="ImportPreview"/>, <see cref="ImportResolution"/>, and
|
||||
/// <see cref="ImportResult"/>. These records cross the Central UI ⇆ bundle file boundary
|
||||
/// via System.Text.Json, so a positional/tuple slip would break bundles in the field.
|
||||
/// EncryptionMetadata has its own focused tests under EncryptionMetadataTests.cs
|
||||
/// (Commons-015) and is reused here only to populate manifest fixtures.
|
||||
/// </summary>
|
||||
public sealed class TransportRecordsTests
|
||||
{
|
||||
// STM: TransportRecordsTests-Commons-020 marker — used by grep verification.
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// BundleManifest
|
||||
// --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BundleManifest_Constructor_RoundTripsAllFields()
|
||||
{
|
||||
var summary = new BundleSummary(
|
||||
Templates: 2, TemplateFolders: 1, SharedScripts: 3,
|
||||
ExternalSystems: 1, DbConnections: 0, NotificationLists: 1,
|
||||
SmtpConfigs: 1, ApiKeys: 0, ApiMethods: 4);
|
||||
var contents = new List<ManifestContentEntry>
|
||||
{
|
||||
new("Template", "Pump", 1, new List<string> { "Shared.Helpers" }),
|
||||
new("Template", "Valve", 2, Array.Empty<string>()),
|
||||
};
|
||||
|
||||
var manifest = new BundleManifest(
|
||||
BundleFormatVersion: 1,
|
||||
SchemaVersion: "1.0",
|
||||
CreatedAtUtc: new DateTimeOffset(2026, 5, 28, 12, 0, 0, TimeSpan.Zero),
|
||||
SourceEnvironment: "cli",
|
||||
ExportedBy: "alice",
|
||||
ScadaBridgeVersion: "0.9.0",
|
||||
ContentHash: "sha256:deadbeef",
|
||||
Encryption: null,
|
||||
Summary: summary,
|
||||
Contents: contents);
|
||||
|
||||
Assert.Equal(1, manifest.BundleFormatVersion);
|
||||
Assert.Equal("1.0", manifest.SchemaVersion);
|
||||
Assert.Equal("cli", manifest.SourceEnvironment);
|
||||
Assert.Equal("alice", manifest.ExportedBy);
|
||||
Assert.Equal("0.9.0", manifest.ScadaBridgeVersion);
|
||||
Assert.Equal("sha256:deadbeef", manifest.ContentHash);
|
||||
Assert.Null(manifest.Encryption);
|
||||
Assert.Equal(summary, manifest.Summary);
|
||||
Assert.Equal(2, manifest.Contents.Count);
|
||||
Assert.Equal("Pump", manifest.Contents[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BundleManifest_JsonRoundTrip_PreservesAllFields()
|
||||
{
|
||||
var encryption = new EncryptionMetadata(
|
||||
Algorithm: "AES-256-GCM",
|
||||
Kdf: "PBKDF2-SHA256",
|
||||
Iterations: 600_000,
|
||||
SaltB64: "c2FsdA==",
|
||||
IvB64: "aXY=");
|
||||
var summary = new BundleSummary(1, 0, 0, 0, 0, 0, 0, 0, 0);
|
||||
var manifest = new BundleManifest(
|
||||
BundleFormatVersion: 1,
|
||||
SchemaVersion: "1.0",
|
||||
CreatedAtUtc: new DateTimeOffset(2026, 5, 28, 12, 0, 0, TimeSpan.Zero),
|
||||
SourceEnvironment: "ui",
|
||||
ExportedBy: "bob",
|
||||
ScadaBridgeVersion: "0.9.0",
|
||||
ContentHash: "sha256:abc",
|
||||
Encryption: encryption,
|
||||
Summary: summary,
|
||||
Contents: new List<ManifestContentEntry>
|
||||
{
|
||||
new("Template", "Pump", 7, new List<string> { "dep-a" }),
|
||||
});
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, JsonOpts);
|
||||
var rt = JsonSerializer.Deserialize<BundleManifest>(json, JsonOpts);
|
||||
|
||||
Assert.NotNull(rt);
|
||||
Assert.Equal(manifest.SourceEnvironment, rt!.SourceEnvironment);
|
||||
Assert.Equal(manifest.ContentHash, rt.ContentHash);
|
||||
Assert.Equal(manifest.Summary, rt.Summary);
|
||||
Assert.Single(rt.Contents);
|
||||
Assert.Equal("Pump", rt.Contents[0].Name);
|
||||
Assert.Equal(7, rt.Contents[0].Version);
|
||||
Assert.NotNull(rt.Encryption);
|
||||
Assert.Equal("AES-256-GCM", rt.Encryption!.Algorithm);
|
||||
Assert.Equal(600_000, rt.Encryption.Iterations);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// ExportSelection
|
||||
// --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ExportSelection_Constructor_PreservesAllIdLists()
|
||||
{
|
||||
var sel = new ExportSelection(
|
||||
TemplateIds: new[] { 1, 2, 3 },
|
||||
SharedScriptIds: new[] { 10 },
|
||||
ExternalSystemIds: Array.Empty<int>(),
|
||||
DatabaseConnectionIds: new[] { 20, 21 },
|
||||
NotificationListIds: Array.Empty<int>(),
|
||||
SmtpConfigurationIds: new[] { 30 },
|
||||
ApiKeyIds: new[] { 40, 41 },
|
||||
ApiMethodIds: new[] { 50 },
|
||||
IncludeDependencies: true);
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3 }, sel.TemplateIds);
|
||||
Assert.Single(sel.SharedScriptIds);
|
||||
Assert.Empty(sel.ExternalSystemIds);
|
||||
Assert.Equal(2, sel.DatabaseConnectionIds.Count);
|
||||
Assert.True(sel.IncludeDependencies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportSelection_JsonRoundTrip_PreservesIncludeDependenciesAndIds()
|
||||
{
|
||||
var sel = new ExportSelection(
|
||||
TemplateIds: new[] { 1, 2 },
|
||||
SharedScriptIds: Array.Empty<int>(),
|
||||
ExternalSystemIds: new[] { 5 },
|
||||
DatabaseConnectionIds: Array.Empty<int>(),
|
||||
NotificationListIds: Array.Empty<int>(),
|
||||
SmtpConfigurationIds: Array.Empty<int>(),
|
||||
ApiKeyIds: Array.Empty<int>(),
|
||||
ApiMethodIds: Array.Empty<int>(),
|
||||
IncludeDependencies: false);
|
||||
|
||||
var json = JsonSerializer.Serialize(sel, JsonOpts);
|
||||
var rt = JsonSerializer.Deserialize<ExportSelection>(json, JsonOpts);
|
||||
|
||||
Assert.NotNull(rt);
|
||||
Assert.Equal(sel.TemplateIds, rt!.TemplateIds);
|
||||
Assert.Equal(sel.ExternalSystemIds, rt.ExternalSystemIds);
|
||||
Assert.False(rt.IncludeDependencies);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// ImportPreview
|
||||
// --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ImportPreview_Constructor_AllowsAllConflictKinds()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var items = new List<ImportPreviewItem>
|
||||
{
|
||||
new("Template", "Pump", ExistingVersion: 1, IncomingVersion: 1, Kind: ConflictKind.Identical, FieldDiffJson: null, BlockerReason: null),
|
||||
new("Template", "Valve", ExistingVersion: 1, IncomingVersion: 2, Kind: ConflictKind.Modified, FieldDiffJson: "{\"name\":\"Valve\"}", BlockerReason: null),
|
||||
new("Template", "New", ExistingVersion: null, IncomingVersion: 1, Kind: ConflictKind.New, FieldDiffJson: null, BlockerReason: null),
|
||||
new("Template", "Bad", ExistingVersion: 1, IncomingVersion: 5, Kind: ConflictKind.Blocker, FieldDiffJson: null, BlockerReason: "Parameters property mismatch"),
|
||||
};
|
||||
|
||||
var preview = new ImportPreview(sessionId, items);
|
||||
|
||||
Assert.Equal(sessionId, preview.SessionId);
|
||||
Assert.Equal(4, preview.Items.Count);
|
||||
Assert.Equal(ConflictKind.Identical, preview.Items[0].Kind);
|
||||
Assert.Equal(ConflictKind.Modified, preview.Items[1].Kind);
|
||||
Assert.Equal(ConflictKind.New, preview.Items[2].Kind);
|
||||
Assert.Equal(ConflictKind.Blocker, preview.Items[3].Kind);
|
||||
Assert.Equal("Parameters property mismatch", preview.Items[3].BlockerReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportPreview_JsonRoundTrip_PreservesConflictKindAndOptionalFields()
|
||||
{
|
||||
var preview = new ImportPreview(
|
||||
SessionId: Guid.NewGuid(),
|
||||
Items: new List<ImportPreviewItem>
|
||||
{
|
||||
new("Template", "X", 1, 2, ConflictKind.Modified, "{}", null),
|
||||
new("Template", "Y", null, 1, ConflictKind.New, null, null),
|
||||
});
|
||||
|
||||
var json = JsonSerializer.Serialize(preview, JsonOpts);
|
||||
var rt = JsonSerializer.Deserialize<ImportPreview>(json, JsonOpts);
|
||||
|
||||
Assert.NotNull(rt);
|
||||
Assert.Equal(preview.SessionId, rt!.SessionId);
|
||||
Assert.Equal(2, rt.Items.Count);
|
||||
Assert.Equal(ConflictKind.Modified, rt.Items[0].Kind);
|
||||
Assert.Equal(ConflictKind.New, rt.Items[1].Kind);
|
||||
Assert.Null(rt.Items[1].ExistingVersion);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// ImportResolution
|
||||
// --------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(ResolutionAction.Add, null)]
|
||||
[InlineData(ResolutionAction.Overwrite, null)]
|
||||
[InlineData(ResolutionAction.Skip, null)]
|
||||
[InlineData(ResolutionAction.Rename, "NewName")]
|
||||
public void ImportResolution_Constructor_PreservesAllActions(ResolutionAction action, string? renameTo)
|
||||
{
|
||||
var res = new ImportResolution("Template", "Pump", action, renameTo);
|
||||
Assert.Equal("Template", res.EntityType);
|
||||
Assert.Equal("Pump", res.Name);
|
||||
Assert.Equal(action, res.Action);
|
||||
Assert.Equal(renameTo, res.RenameTo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportResolution_JsonRoundTrip_PreservesRenameTo()
|
||||
{
|
||||
var res = new ImportResolution("Template", "Pump", ResolutionAction.Rename, "Pump_v2");
|
||||
|
||||
var json = JsonSerializer.Serialize(res, JsonOpts);
|
||||
var rt = JsonSerializer.Deserialize<ImportResolution>(json, JsonOpts);
|
||||
|
||||
Assert.NotNull(rt);
|
||||
Assert.Equal(ResolutionAction.Rename, rt!.Action);
|
||||
Assert.Equal("Pump_v2", rt.RenameTo);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// ImportResult
|
||||
// --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ImportResult_Constructor_PreservesAllCountersAndStaleIds()
|
||||
{
|
||||
var bundleImportId = Guid.NewGuid();
|
||||
var result = new ImportResult(
|
||||
BundleImportId: bundleImportId,
|
||||
Added: 3,
|
||||
Overwritten: 1,
|
||||
Skipped: 2,
|
||||
Renamed: 1,
|
||||
StaleInstanceIds: new List<int> { 100, 200, 300 },
|
||||
AuditEventCorrelation: "audit-corr-001");
|
||||
|
||||
Assert.Equal(bundleImportId, result.BundleImportId);
|
||||
Assert.Equal(3, result.Added);
|
||||
Assert.Equal(1, result.Overwritten);
|
||||
Assert.Equal(2, result.Skipped);
|
||||
Assert.Equal(1, result.Renamed);
|
||||
Assert.Equal(new[] { 100, 200, 300 }, result.StaleInstanceIds);
|
||||
Assert.Equal("audit-corr-001", result.AuditEventCorrelation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportResult_JsonRoundTrip_PreservesCountsAndCorrelation()
|
||||
{
|
||||
var result = new ImportResult(
|
||||
BundleImportId: Guid.NewGuid(),
|
||||
Added: 5,
|
||||
Overwritten: 0,
|
||||
Skipped: 0,
|
||||
Renamed: 0,
|
||||
StaleInstanceIds: Array.Empty<int>(),
|
||||
AuditEventCorrelation: "audit-corr-xyz");
|
||||
|
||||
var json = JsonSerializer.Serialize(result, JsonOpts);
|
||||
var rt = JsonSerializer.Deserialize<ImportResult>(json, JsonOpts);
|
||||
|
||||
Assert.NotNull(rt);
|
||||
Assert.Equal(result.BundleImportId, rt!.BundleImportId);
|
||||
Assert.Equal(5, rt.Added);
|
||||
Assert.Empty(rt.StaleInstanceIds);
|
||||
Assert.Equal("audit-corr-xyz", rt.AuditEventCorrelation);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Record equality sanity (catches positional/tuple slip)
|
||||
// --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TransportRecords_RecordValueEquality()
|
||||
{
|
||||
var a = new ImportResolution("Template", "Pump", ResolutionAction.Add, null);
|
||||
var b = new ImportResolution("Template", "Pump", ResolutionAction.Add, null);
|
||||
Assert.Equal(a, b);
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
|
||||
var c = a with { Action = ResolutionAction.Overwrite };
|
||||
Assert.NotEqual(a, c);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Globalization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ValueFormatter"/>. Includes the Commons-012 regression:
|
||||
/// formatting must be culture-invariant because the formatter feeds non-UI contexts
|
||||
/// (gRPC stream events, logs) where locale-dependent output would be inconsistent.
|
||||
/// </summary>
|
||||
public class ValueFormatterTests
|
||||
{
|
||||
[Fact]
|
||||
public void FormatDisplayValue_Null_ReturnsEmptyString()
|
||||
{
|
||||
Assert.Equal("", ValueFormatter.FormatDisplayValue(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatDisplayValue_String_ReturnsValueUnchanged()
|
||||
{
|
||||
Assert.Equal("hello", ValueFormatter.FormatDisplayValue("hello"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatDisplayValue_Collection_JoinsWithComma()
|
||||
{
|
||||
Assert.Equal("1,2,3", ValueFormatter.FormatDisplayValue(new[] { 1, 2, 3 }));
|
||||
}
|
||||
|
||||
// ── Commons-012 regression: culture-invariant numeric/date formatting ──
|
||||
|
||||
[Fact]
|
||||
public void FormatDisplayValue_Double_UsesInvariantCulture_RegardlessOfThreadCulture()
|
||||
{
|
||||
var original = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
// German uses a comma as the decimal separator; invariant uses a dot.
|
||||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
Assert.Equal("3.14", ValueFormatter.FormatDisplayValue(3.14));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatDisplayValue_DateTime_UsesInvariantCulture_RegardlessOfThreadCulture()
|
||||
{
|
||||
var original = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
var dt = new DateTime(2026, 5, 16, 0, 0, 0, DateTimeKind.Utc);
|
||||
var invariant = dt.ToString(CultureInfo.InvariantCulture);
|
||||
Assert.Equal(invariant, ValueFormatter.FormatDisplayValue(dt));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormatDisplayValue_CollectionOfDoubles_UsesInvariantCulture()
|
||||
{
|
||||
var original = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
Assert.Equal("1.5,2.5", ValueFormatter.FormatDisplayValue(new[] { 1.5, 2.5 }));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = original;
|
||||
}
|
||||
}
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Validators;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Validators;
|
||||
|
||||
public class OpcUaEndpointConfigValidatorTests
|
||||
{
|
||||
private static OpcUaEndpointConfig Valid() => new()
|
||||
{
|
||||
EndpointUrl = "opc.tcp://plant-a:4840",
|
||||
// Defaults satisfy the spec: Lifetime(30) >= 3 * KeepAlive(10).
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Validate_DefaultsWithValidUrl_IsValid()
|
||||
{
|
||||
var result = OpcUaEndpointConfigValidator.Validate(Valid());
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MissingEndpointUrl_Fails()
|
||||
{
|
||||
var c = Valid();
|
||||
c.EndpointUrl = "";
|
||||
var r = OpcUaEndpointConfigValidator.Validate(c);
|
||||
Assert.False(r.IsValid);
|
||||
Assert.Contains(r.Errors, e =>
|
||||
e.EntityName == "EndpointUrl"
|
||||
&& e.Category == ValidationCategory.ConnectionConfig
|
||||
&& e.Message.Contains("required", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("http://x")]
|
||||
[InlineData("opc.tcp://")]
|
||||
[InlineData("not a url")]
|
||||
public void Validate_BadEndpointUrl_Fails(string url)
|
||||
{
|
||||
var c = Valid();
|
||||
c.EndpointUrl = url;
|
||||
var r = OpcUaEndpointConfigValidator.Validate(c);
|
||||
Assert.False(r.IsValid);
|
||||
Assert.Contains(r.Errors, e => e.EntityName == "EndpointUrl");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_LifetimeLessThanThreeTimesKeepAlive_Fails()
|
||||
{
|
||||
var c = Valid();
|
||||
c.KeepAliveCount = 10;
|
||||
c.LifetimeCount = 29; // 3*10 = 30; 29 < 30 → invalid
|
||||
var r = OpcUaEndpointConfigValidator.Validate(c);
|
||||
Assert.False(r.IsValid);
|
||||
Assert.Contains(r.Errors, e => e.EntityName == "LifetimeCount");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(OpcUaEndpointConfig.SessionTimeoutMs))]
|
||||
[InlineData(nameof(OpcUaEndpointConfig.OperationTimeoutMs))]
|
||||
[InlineData(nameof(OpcUaEndpointConfig.PublishingIntervalMs))]
|
||||
[InlineData(nameof(OpcUaEndpointConfig.SamplingIntervalMs))]
|
||||
public void Validate_NonPositiveTiming_Fails(string field)
|
||||
{
|
||||
var c = Valid();
|
||||
typeof(OpcUaEndpointConfig).GetProperty(field)!.SetValue(c, 0);
|
||||
var r = OpcUaEndpointConfigValidator.Validate(c);
|
||||
Assert.False(r.IsValid);
|
||||
Assert.Contains(r.Errors, e => e.EntityName == field);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_QueueSizeZero_Fails()
|
||||
{
|
||||
var c = Valid();
|
||||
c.QueueSize = 0;
|
||||
var r = OpcUaEndpointConfigValidator.Validate(c);
|
||||
Assert.Contains(r.Errors, e => e.EntityName == "QueueSize");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HeartbeatEnabledNoTagPath_Fails()
|
||||
{
|
||||
var c = Valid();
|
||||
c.Heartbeat = new OpcUaHeartbeatConfig { TagPath = "", MaxSilenceSeconds = 30 };
|
||||
var r = OpcUaEndpointConfigValidator.Validate(c);
|
||||
Assert.Contains(r.Errors, e => e.EntityName == "Heartbeat.TagPath");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_HeartbeatNonPositiveSilence_Fails()
|
||||
{
|
||||
var c = Valid();
|
||||
c.Heartbeat = new OpcUaHeartbeatConfig { TagPath = "Hb", MaxSilenceSeconds = 0 };
|
||||
var r = OpcUaEndpointConfigValidator.Validate(c);
|
||||
Assert.Contains(r.Errors, e => e.EntityName == "Heartbeat.MaxSilenceSeconds");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_FieldPrefix_AppliedToEveryError()
|
||||
{
|
||||
var c = Valid();
|
||||
c.EndpointUrl = "";
|
||||
c.QueueSize = 0;
|
||||
var r = OpcUaEndpointConfigValidator.Validate(c, fieldPrefix: "Primary.");
|
||||
Assert.All(r.Errors, e => Assert.StartsWith("Primary.", e.EntityName!));
|
||||
Assert.Contains(r.Errors, e => e.EntityName == "Primary.EndpointUrl");
|
||||
Assert.Contains(r.Errors, e => e.EntityName == "Primary.QueueSize");
|
||||
}
|
||||
|
||||
// ── Layer B extensions: auth, deadband, subscription display name ──
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptySubscriptionDisplayName_Fails()
|
||||
{
|
||||
var c = Valid();
|
||||
c.SubscriptionDisplayName = "";
|
||||
var r = OpcUaEndpointConfigValidator.Validate(c);
|
||||
Assert.Contains(r.Errors, e => e.EntityName == "SubscriptionDisplayName");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_UserIdentityAnonymous_NoExtraFieldsRequired()
|
||||
{
|
||||
var c = Valid();
|
||||
c.UserIdentity = new OpcUaUserIdentityConfig { TokenType = OpcUaUserTokenType.Anonymous };
|
||||
var r = OpcUaEndpointConfigValidator.Validate(c);
|
||||
Assert.True(r.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_UsernamePasswordWithoutUsername_Fails()
|
||||
{
|
||||
var c = Valid();
|
||||
c.UserIdentity = new OpcUaUserIdentityConfig
|
||||
{
|
||||
TokenType = OpcUaUserTokenType.UsernamePassword,
|
||||
Username = "",
|
||||
Password = "secret"
|
||||
};
|
||||
var r = OpcUaEndpointConfigValidator.Validate(c);
|
||||
Assert.Contains(r.Errors, e => e.EntityName == "UserIdentity.Username");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_X509WithoutCertificatePath_Fails()
|
||||
{
|
||||
var c = Valid();
|
||||
c.UserIdentity = new OpcUaUserIdentityConfig
|
||||
{
|
||||
TokenType = OpcUaUserTokenType.X509Certificate,
|
||||
CertificatePath = ""
|
||||
};
|
||||
var r = OpcUaEndpointConfigValidator.Validate(c);
|
||||
Assert.Contains(r.Errors, e => e.EntityName == "UserIdentity.CertificatePath");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_UsernamePasswordWithUsername_Passes()
|
||||
{
|
||||
var c = Valid();
|
||||
c.UserIdentity = new OpcUaUserIdentityConfig
|
||||
{
|
||||
TokenType = OpcUaUserTokenType.UsernamePassword,
|
||||
Username = "alice",
|
||||
Password = ""
|
||||
};
|
||||
var r = OpcUaEndpointConfigValidator.Validate(c);
|
||||
Assert.True(r.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DeadbandWithNonPositiveValue_Fails()
|
||||
{
|
||||
var c = Valid();
|
||||
c.Deadband = new OpcUaDeadbandConfig { Type = OpcUaDeadbandType.Absolute, Value = 0 };
|
||||
var r = OpcUaEndpointConfigValidator.Validate(c);
|
||||
Assert.Contains(r.Errors, e => e.EntityName == "Deadband.Value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DeadbandWithPositiveValue_Passes()
|
||||
{
|
||||
var c = Valid();
|
||||
c.Deadband = new OpcUaDeadbandConfig { Type = OpcUaDeadbandType.Percent, Value = 1.5 };
|
||||
var r = OpcUaEndpointConfigValidator.Validate(c);
|
||||
Assert.True(r.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_UserIdentityErrorsPrefixed_WithFieldPrefix()
|
||||
{
|
||||
var c = Valid();
|
||||
c.UserIdentity = new OpcUaUserIdentityConfig
|
||||
{
|
||||
TokenType = OpcUaUserTokenType.UsernamePassword,
|
||||
Username = ""
|
||||
};
|
||||
var r = OpcUaEndpointConfigValidator.Validate(c, fieldPrefix: "Primary.");
|
||||
Assert.Contains(r.Errors, e => e.EntityName == "Primary.UserIdentity.Username");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user