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,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);
}
}