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:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -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);
}
}
@@ -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)));
}
}
@@ -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&lt;dynamic?&gt;</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&lt;T&gt;` 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);
}
}
@@ -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&lt;(string, int)&gt;</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);
}
}
@@ -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);
}
}
@@ -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 }));
}
}
@@ -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;
}
}
}
@@ -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>