refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// WP-9 (Phase 8): Message contract compatibility tests.
|
||||
/// Verifies forward compatibility (unknown fields), backward compatibility (missing optional fields),
|
||||
/// and version skew scenarios for all critical message types.
|
||||
/// </summary>
|
||||
public class CompatibilityTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
// ── Forward Compatibility: unknown fields are ignored ──
|
||||
|
||||
[Fact]
|
||||
public void ForwardCompat_DeployInstanceCommand_UnknownFieldIgnored()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"DeploymentId": "dep-1",
|
||||
"InstanceUniqueName": "inst-1",
|
||||
"RevisionHash": "abc123",
|
||||
"FlattenedConfigurationJson": "{}",
|
||||
"DeployedBy": "admin",
|
||||
"Timestamp": "2025-01-01T00:00:00+00:00",
|
||||
"FutureField": "unknown-value",
|
||||
"AnotherNewField": 42
|
||||
}
|
||||
""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<DeployInstanceCommand>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal("dep-1", msg!.DeploymentId);
|
||||
Assert.Equal("abc123", msg.RevisionHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardCompat_SiteHealthReport_UnknownFieldIgnored()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"SiteId": "site-01",
|
||||
"SequenceNumber": 5,
|
||||
"ReportTimestamp": "2025-01-01T00:00:00+00:00",
|
||||
"DataConnectionStatuses": {},
|
||||
"TagResolutionCounts": {},
|
||||
"ScriptErrorCount": 0,
|
||||
"AlarmEvaluationErrorCount": 0,
|
||||
"StoreAndForwardBufferDepths": {},
|
||||
"DeadLetterCount": 0,
|
||||
"FutureMetric": 99
|
||||
}
|
||||
""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<SiteHealthReport>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal("site-01", msg!.SiteId);
|
||||
Assert.Equal(5, msg.SequenceNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardCompat_ScriptCallRequest_UnknownFieldIgnored()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"CorrelationId": "corr-1",
|
||||
"InstanceUniqueName": "inst-1",
|
||||
"ScriptName": "OnTrigger",
|
||||
"Parameters": {},
|
||||
"Timestamp": "2025-01-01T00:00:00+00:00",
|
||||
"NewExecutionMode": "parallel"
|
||||
}
|
||||
""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<ScriptCallRequest>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal("corr-1", msg!.CorrelationId);
|
||||
Assert.Equal("OnTrigger", msg.ScriptName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardCompat_AttributeValueChanged_UnknownFieldIgnored()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"InstanceUniqueName": "inst-1",
|
||||
"AttributeName": "Temperature",
|
||||
"TagPath": "opc:ns=2;s=Temp",
|
||||
"Value": 42.5,
|
||||
"Quality": "Good",
|
||||
"Timestamp": "2025-01-01T00:00:00+00:00",
|
||||
"SourceInfo": {"origin": "future-feature"}
|
||||
}
|
||||
""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<AttributeValueChanged>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal("Temperature", msg!.AttributeName);
|
||||
}
|
||||
|
||||
// ── Backward Compatibility: missing optional fields ──
|
||||
|
||||
[Fact]
|
||||
public void BackwardCompat_DeploymentStatusResponse_MissingErrorMessage()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"DeploymentId": "dep-1",
|
||||
"InstanceUniqueName": "inst-1",
|
||||
"Status": 2,
|
||||
"Timestamp": "2025-01-01T00:00:00+00:00"
|
||||
}
|
||||
""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<DeploymentStatusResponse>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal("dep-1", msg!.DeploymentId);
|
||||
Assert.Null(msg.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackwardCompat_ScriptCallResult_MissingReturnValue()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"CorrelationId": "corr-1",
|
||||
"Success": false,
|
||||
"ErrorMessage": "Script not found"
|
||||
}
|
||||
""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<ScriptCallResult>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.False(msg!.Success);
|
||||
Assert.Null(msg.ReturnValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackwardCompat_DeployArtifactsCommand_MissingOptionalLists()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"DeploymentId": "dep-1",
|
||||
"Timestamp": "2025-01-01T00:00:00+00:00"
|
||||
}
|
||||
""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<DeployArtifactsCommand>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal("dep-1", msg!.DeploymentId);
|
||||
Assert.Null(msg.SharedScripts);
|
||||
Assert.Null(msg.ExternalSystems);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackwardCompat_InstanceLifecycleResponse_MissingErrorMessage()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"CommandId": "cmd-1",
|
||||
"InstanceUniqueName": "inst-1",
|
||||
"Success": true,
|
||||
"Timestamp": "2025-01-01T00:00:00+00:00"
|
||||
}
|
||||
""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<InstanceLifecycleResponse>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.True(msg!.Success);
|
||||
Assert.Null(msg.ErrorMessage);
|
||||
}
|
||||
|
||||
// ── Version Skew: old message format still deserializable ──
|
||||
|
||||
[Fact]
|
||||
public void VersionSkew_OldDeployCommand_DeserializesWithDefaults()
|
||||
{
|
||||
// Simulate an older version that only had DeploymentId and InstanceUniqueName
|
||||
var json = """
|
||||
{
|
||||
"DeploymentId": "dep-old",
|
||||
"InstanceUniqueName": "inst-old",
|
||||
"RevisionHash": "old-hash",
|
||||
"FlattenedConfigurationJson": "{}",
|
||||
"DeployedBy": "admin",
|
||||
"Timestamp": "2024-06-01T00:00:00+00:00"
|
||||
}
|
||||
""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<DeployInstanceCommand>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal("dep-old", msg!.DeploymentId);
|
||||
Assert.Equal("old-hash", msg.RevisionHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VersionSkew_OldHealthReport_DeserializesCorrectly()
|
||||
{
|
||||
// Older version without DeadLetterCount
|
||||
var json = """
|
||||
{
|
||||
"SiteId": "site-old",
|
||||
"SequenceNumber": 1,
|
||||
"ReportTimestamp": "2024-06-01T00:00:00+00:00",
|
||||
"DataConnectionStatuses": {"conn1": 0},
|
||||
"TagResolutionCounts": {},
|
||||
"ScriptErrorCount": 0,
|
||||
"AlarmEvaluationErrorCount": 0,
|
||||
"StoreAndForwardBufferDepths": {}
|
||||
}
|
||||
""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<SiteHealthReport>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal("site-old", msg!.SiteId);
|
||||
Assert.Equal(0, msg.DeadLetterCount); // Default value
|
||||
}
|
||||
|
||||
// ── Round-trip serialization for all key message types ──
|
||||
|
||||
// Communication-016: RoundTrip_ConnectionStateChanged_Succeeds removed
|
||||
// alongside the dead ConnectionStateChanged message record. No production
|
||||
// code emits or receives this message — disconnect detection is owned by
|
||||
// the gRPC keepalive and the Ask-timeout path.
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_AlarmStateChanged_Succeeds()
|
||||
{
|
||||
var msg = new AlarmStateChanged("inst-1", "HighTemp", AlarmState.Active, 1, DateTimeOffset.UtcNow);
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<AlarmStateChanged>(json, Options);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal(AlarmState.Active, deserialized!.State);
|
||||
Assert.Equal("HighTemp", deserialized.AlarmName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_HeartbeatMessage_Succeeds()
|
||||
{
|
||||
var msg = new HeartbeatMessage("site-01", "node-a", true, DateTimeOffset.UtcNow);
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<HeartbeatMessage>(json, Options);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal("site-01", deserialized!.SiteId);
|
||||
Assert.Equal("node-a", deserialized.NodeHostname);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_DisableInstanceCommand_Succeeds()
|
||||
{
|
||||
var msg = new DisableInstanceCommand("cmd-1", "inst-1", DateTimeOffset.UtcNow);
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<DisableInstanceCommand>(json, Options);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal("cmd-1", deserialized!.CommandId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_EnableInstanceCommand_Succeeds()
|
||||
{
|
||||
var msg = new EnableInstanceCommand("cmd-2", "inst-1", DateTimeOffset.UtcNow);
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<EnableInstanceCommand>(json, Options);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal("cmd-2", deserialized!.CommandId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_DeleteInstanceCommand_Succeeds()
|
||||
{
|
||||
var msg = new DeleteInstanceCommand("cmd-3", "inst-1", DateTimeOffset.UtcNow);
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<DeleteInstanceCommand>(json, Options);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal("cmd-3", deserialized!.CommandId);
|
||||
}
|
||||
|
||||
// ── Additive-only evolution: new fields added as nullable ──
|
||||
|
||||
[Fact]
|
||||
public void AdditiveEvolution_NewNullableFields_DoNotBreakDeserialization()
|
||||
{
|
||||
// The design mandates additive-only evolution for message contracts.
|
||||
// New fields must be nullable/optional so old producers don't break new consumers.
|
||||
// This test verifies the pattern works with System.Text.Json.
|
||||
|
||||
var minimalJson = """{"DeploymentId":"dep-1","InstanceUniqueName":"inst-1","Status":1,"Timestamp":"2025-01-01T00:00:00+00:00"}""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<DeploymentStatusResponse>(minimalJson, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.Null(msg!.ErrorMessage); // Optional field defaults to null
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnumDeserialization_UnknownValue_HandledGracefully()
|
||||
{
|
||||
// If a newer version adds a new enum value, older consumers should handle it.
|
||||
// System.Text.Json will deserialize unknown numeric enum values as the numeric value.
|
||||
var json = """{"DeploymentId":"dep-1","InstanceUniqueName":"inst-1","Status":99,"Timestamp":"2025-01-01T00:00:00+00:00"}""";
|
||||
|
||||
var msg = JsonSerializer.Deserialize<DeploymentStatusResponse>(json, Options);
|
||||
Assert.NotNull(msg);
|
||||
Assert.Equal((DeploymentStatus)99, msg!.Status);
|
||||
}
|
||||
|
||||
// ── DeploymentManager-006: query-the-site-before-redeploy contracts ──
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_DeploymentStateQueryRequest_Succeeds()
|
||||
{
|
||||
var msg = new DeploymentStateQueryRequest("corr-1", "inst-1", DateTimeOffset.UtcNow);
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<DeploymentStateQueryRequest>(json, Options);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal("corr-1", deserialized!.CorrelationId);
|
||||
Assert.Equal("inst-1", deserialized.InstanceUniqueName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_DeploymentStateQueryResponse_Deployed_Succeeds()
|
||||
{
|
||||
var msg = new DeploymentStateQueryResponse(
|
||||
"corr-1", "inst-1", true, "dep-9", "sha256:abc", DateTimeOffset.UtcNow);
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<DeploymentStateQueryResponse>(json, Options);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.True(deserialized!.IsDeployed);
|
||||
Assert.Equal("dep-9", deserialized.AppliedDeploymentId);
|
||||
Assert.Equal("sha256:abc", deserialized.AppliedRevisionHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_DeploymentStateQueryResponse_NotDeployed_NullApplied()
|
||||
{
|
||||
// When the instance is not deployed at the site, the applied identity
|
||||
// fields are null — verified to survive a JSON round-trip.
|
||||
var msg = new DeploymentStateQueryResponse(
|
||||
"corr-1", "inst-1", false, null, null, DateTimeOffset.UtcNow);
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<DeploymentStateQueryResponse>(json, Options);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.False(deserialized!.IsDeployed);
|
||||
Assert.Null(deserialized.AppliedDeploymentId);
|
||||
Assert.Null(deserialized.AppliedRevisionHash);
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for Commons-008 — <see cref="SetConnectionBindingsCommand"/>
|
||||
/// previously declared its bindings as <c>IReadOnlyList<(string, int)></c>.
|
||||
/// A <c>ValueTuple</c> serializes as <c>Item1</c>/<c>Item2</c> and cannot evolve
|
||||
/// additively (REQ-COM-5a). It is now a named <see cref="ConnectionBinding"/> record.
|
||||
/// </summary>
|
||||
public class ConnectionBindingSerializationTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConnectionBinding_SerializesWithNamedProperties()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new ConnectionBinding("Temperature", 42));
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
Assert.Equal(JsonValueKind.String, doc.RootElement.GetProperty("AttributeName").ValueKind);
|
||||
Assert.Equal("Temperature", doc.RootElement.GetProperty("AttributeName").GetString());
|
||||
Assert.Equal(42, doc.RootElement.GetProperty("DataConnectionId").GetInt32());
|
||||
|
||||
// The ValueTuple failure mode: Item1/Item2 must NOT appear.
|
||||
Assert.False(doc.RootElement.TryGetProperty("Item1", out _));
|
||||
Assert.False(doc.RootElement.TryGetProperty("Item2", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetConnectionBindingsCommand_RoundTripsThroughJson()
|
||||
{
|
||||
var original = new SetConnectionBindingsCommand(
|
||||
7,
|
||||
new List<ConnectionBinding>
|
||||
{
|
||||
new("Speed", 5),
|
||||
new("Mode", 11),
|
||||
});
|
||||
|
||||
var json = JsonSerializer.Serialize(original);
|
||||
var deserialized = JsonSerializer.Deserialize<SetConnectionBindingsCommand>(json);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal(7, deserialized!.InstanceId);
|
||||
Assert.Equal(2, deserialized.Bindings.Count);
|
||||
Assert.Equal("Speed", deserialized.Bindings[0].AttributeName);
|
||||
Assert.Equal(5, deserialized.Bindings[0].DataConnectionId);
|
||||
Assert.Equal("Mode", deserialized.Bindings[1].AttributeName);
|
||||
Assert.Equal(11, deserialized.Bindings[1].DataConnectionId);
|
||||
|
||||
// ConnectionBinding is a record: each element compares by value.
|
||||
Assert.Equal(original.Bindings, deserialized.Bindings);
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log (#23) telemetry handoff: envelope + pull request/response DTOs.
|
||||
/// At-least-once from sites; idempotent at central on <see cref="AuditEvent.EventId"/>.
|
||||
/// </summary>
|
||||
public class AuditTelemetryMessagesTests
|
||||
{
|
||||
private static AuditEvent MakeEvent(Guid? id = null) => new()
|
||||
{
|
||||
EventId = id ?? Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
PayloadTruncated = false
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void AuditTelemetryEnvelope_ConstructsWithThreeEvents_AndIsEnumerable()
|
||||
{
|
||||
var envelopeId = Guid.NewGuid();
|
||||
var events = new List<AuditEvent> { MakeEvent(), MakeEvent(), MakeEvent() };
|
||||
|
||||
var envelope = new AuditTelemetryEnvelope(envelopeId, "site-01", events);
|
||||
|
||||
Assert.Equal(envelopeId, envelope.EnvelopeId);
|
||||
Assert.Equal("site-01", envelope.SourceSiteId);
|
||||
Assert.Equal(3, envelope.Events.Count);
|
||||
|
||||
// Enumerable round-trip
|
||||
var collected = new List<AuditEvent>();
|
||||
foreach (var e in envelope.Events)
|
||||
{
|
||||
collected.Add(e);
|
||||
}
|
||||
Assert.Equal(3, collected.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditTelemetryEnvelope_IsImmutable_RecordEqualityOnReferenceIdentityOfList()
|
||||
{
|
||||
// The record's value equality compares the IReadOnlyList reference; two envelopes
|
||||
// built with the same list instance + same fields must be equal, but using a
|
||||
// different list instance (even with equal content) must NOT be equal.
|
||||
var events = new List<AuditEvent> { MakeEvent() } as IReadOnlyList<AuditEvent>;
|
||||
var envelopeId = Guid.NewGuid();
|
||||
var a = new AuditTelemetryEnvelope(envelopeId, "site-01", events);
|
||||
var b = new AuditTelemetryEnvelope(envelopeId, "site-01", events);
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
|
||||
var withDifferentSite = a with { SourceSiteId = "site-02" };
|
||||
Assert.NotEqual(a, withDifferentSite);
|
||||
Assert.Equal("site-02", withDifferentSite.SourceSiteId);
|
||||
Assert.Equal("site-01", a.SourceSiteId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PullAuditEventsRequest_ConstructsAndIsImmutable()
|
||||
{
|
||||
var since = new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc);
|
||||
var request = new PullAuditEventsRequest("site-01", since, 100);
|
||||
|
||||
Assert.Equal("site-01", request.SourceSiteId);
|
||||
Assert.Equal(since, request.SinceUtc);
|
||||
Assert.Equal(100, request.BatchSize);
|
||||
|
||||
var bigger = request with { BatchSize = 500 };
|
||||
Assert.Equal(100, request.BatchSize);
|
||||
Assert.Equal(500, bigger.BatchSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PullAuditEventsResponse_ConstructsWithMoreAvailableTrue_AndIsEnumerable()
|
||||
{
|
||||
var events = new List<AuditEvent> { MakeEvent(), MakeEvent() };
|
||||
var response = new PullAuditEventsResponse(events, MoreAvailable: true);
|
||||
|
||||
Assert.True(response.MoreAvailable);
|
||||
Assert.Equal(2, response.Events.Count);
|
||||
|
||||
var collected = new List<AuditEvent>();
|
||||
foreach (var e in response.Events)
|
||||
{
|
||||
collected.Add(e);
|
||||
}
|
||||
Assert.Equal(2, collected.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PullAuditEventsResponse_WithExpression_ChangesSingleField()
|
||||
{
|
||||
var response = new PullAuditEventsResponse(new List<AuditEvent>(), MoreAvailable: false);
|
||||
var updated = response with { MoreAvailable = true };
|
||||
|
||||
Assert.False(response.MoreAvailable);
|
||||
Assert.True(updated.MoreAvailable);
|
||||
}
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (M3 Bundle A — Task A4) — tests for the combined
|
||||
/// audit + operational telemetry packet emitted per cached-call lifecycle event
|
||||
/// (<c>Submit</c> → per-attempt <c>ApiCallCached</c> / <c>DbWriteCached</c> →
|
||||
/// terminal <c>Resolve</c>). The site emits one packet per event; central writes
|
||||
/// <c>AuditLog</c> + <c>SiteCalls</c> in one MS SQL transaction.
|
||||
/// </summary>
|
||||
public class CachedCallTelemetryTests
|
||||
{
|
||||
private static readonly DateTime FixedNowUtc = new(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private const string SiteId = "site-77";
|
||||
private const string InstanceName = "Plant.Pump42";
|
||||
private const string SourceScript = "ScriptActor:OnTick";
|
||||
|
||||
private static AuditEvent BuildAuditEvent(
|
||||
TrackedOperationId trackedId,
|
||||
AuditKind kind,
|
||||
AuditStatus status,
|
||||
Guid? correlationId = null,
|
||||
string? errorMessage = null,
|
||||
int? httpStatus = null)
|
||||
{
|
||||
return new AuditEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = FixedNowUtc,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = kind,
|
||||
CorrelationId = correlationId ?? trackedId.Value,
|
||||
SourceSiteId = SiteId,
|
||||
SourceInstanceId = InstanceName,
|
||||
SourceScript = SourceScript,
|
||||
Target = "ERP.GetOrder",
|
||||
Status = status,
|
||||
HttpStatus = httpStatus,
|
||||
ErrorMessage = errorMessage,
|
||||
PayloadTruncated = false,
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
};
|
||||
}
|
||||
|
||||
private static SiteCallOperational BuildOperational(
|
||||
TrackedOperationId trackedId,
|
||||
AuditStatus status,
|
||||
int retryCount,
|
||||
string? lastError = null,
|
||||
int? httpStatus = null,
|
||||
DateTime? terminalAtUtc = null)
|
||||
{
|
||||
return new SiteCallOperational(
|
||||
TrackedOperationId: trackedId,
|
||||
Channel: nameof(AuditChannel.ApiOutbound),
|
||||
Target: "ERP.GetOrder",
|
||||
SourceSite: SiteId,
|
||||
// SourceNode: actual stamping arrives with Task 14; for now the
|
||||
// packet builder leaves the column null so existing assertions on
|
||||
// the packet's other fields stay intact.
|
||||
SourceNode: null,
|
||||
Status: status.ToString(),
|
||||
RetryCount: retryCount,
|
||||
LastError: lastError,
|
||||
HttpStatus: httpStatus,
|
||||
CreatedAtUtc: FixedNowUtc,
|
||||
UpdatedAtUtc: terminalAtUtc ?? FixedNowUtc,
|
||||
TerminalAtUtc: terminalAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubmitPacket_AuditCarriesCachedSubmit_AndOperationalRetryCountZero()
|
||||
{
|
||||
var trackedId = TrackedOperationId.New();
|
||||
var audit = BuildAuditEvent(trackedId, AuditKind.CachedSubmit, AuditStatus.Submitted);
|
||||
var operational = BuildOperational(trackedId, AuditStatus.Submitted, retryCount: 0);
|
||||
|
||||
var packet = new CachedCallTelemetry(audit, operational);
|
||||
|
||||
Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind);
|
||||
Assert.Equal(AuditStatus.Submitted, packet.Audit.Status);
|
||||
Assert.Equal(nameof(AuditStatus.Submitted), packet.Operational.Status);
|
||||
Assert.Equal(0, packet.Operational.RetryCount);
|
||||
Assert.Null(packet.Operational.TerminalAtUtc);
|
||||
Assert.Equal(trackedId, packet.Operational.TrackedOperationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttemptedPacket_AuditCarriesApiCallCached_RetryCountAlignsBetweenAuditAndOperational()
|
||||
{
|
||||
var trackedId = TrackedOperationId.New();
|
||||
var audit = BuildAuditEvent(
|
||||
trackedId,
|
||||
AuditKind.ApiCallCached,
|
||||
AuditStatus.Attempted,
|
||||
errorMessage: "HTTP 503 from ERP",
|
||||
httpStatus: 503);
|
||||
var operational = BuildOperational(
|
||||
trackedId,
|
||||
AuditStatus.Attempted,
|
||||
retryCount: 2,
|
||||
lastError: "HTTP 503 from ERP",
|
||||
httpStatus: 503);
|
||||
|
||||
var packet = new CachedCallTelemetry(audit, operational);
|
||||
|
||||
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
|
||||
Assert.Equal(AuditStatus.Attempted, packet.Audit.Status);
|
||||
Assert.Equal(nameof(AuditStatus.Attempted), packet.Operational.Status);
|
||||
// Retry-count alignment: the operational row carries the canonical N;
|
||||
// the audit row's error/http surface the same attempt's outcome.
|
||||
Assert.Equal(packet.Audit.ErrorMessage, packet.Operational.LastError);
|
||||
Assert.Equal(packet.Audit.HttpStatus, packet.Operational.HttpStatus);
|
||||
Assert.Equal(2, packet.Operational.RetryCount);
|
||||
Assert.Null(packet.Operational.TerminalAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttemptedPacket_DbWriteCached_CarriesDbWriteCachedKind()
|
||||
{
|
||||
var trackedId = TrackedOperationId.New();
|
||||
var audit = BuildAuditEvent(
|
||||
trackedId,
|
||||
AuditKind.DbWriteCached,
|
||||
AuditStatus.Attempted,
|
||||
errorMessage: "Timeout",
|
||||
httpStatus: null);
|
||||
var operational = BuildOperational(
|
||||
trackedId,
|
||||
AuditStatus.Attempted,
|
||||
retryCount: 1,
|
||||
lastError: "Timeout");
|
||||
|
||||
var packet = new CachedCallTelemetry(audit, operational);
|
||||
|
||||
Assert.Equal(AuditKind.DbWriteCached, packet.Audit.Kind);
|
||||
Assert.Equal(AuditStatus.Attempted, packet.Audit.Status);
|
||||
Assert.Equal(1, packet.Operational.RetryCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AuditStatus.Delivered)]
|
||||
[InlineData(AuditStatus.Failed)]
|
||||
[InlineData(AuditStatus.Parked)]
|
||||
[InlineData(AuditStatus.Discarded)]
|
||||
public void ResolvePacket_AuditCarriesCachedResolve_OperationalTerminalAtUtcSet(AuditStatus terminalStatus)
|
||||
{
|
||||
var trackedId = TrackedOperationId.New();
|
||||
var terminalAt = FixedNowUtc.AddMinutes(5);
|
||||
var audit = BuildAuditEvent(trackedId, AuditKind.CachedResolve, terminalStatus);
|
||||
var operational = BuildOperational(
|
||||
trackedId,
|
||||
terminalStatus,
|
||||
retryCount: 3,
|
||||
terminalAtUtc: terminalAt);
|
||||
|
||||
var packet = new CachedCallTelemetry(audit, operational);
|
||||
|
||||
Assert.Equal(AuditKind.CachedResolve, packet.Audit.Kind);
|
||||
Assert.Equal(terminalStatus, packet.Audit.Status);
|
||||
Assert.Equal(terminalStatus.ToString(), packet.Operational.Status);
|
||||
Assert.NotNull(packet.Operational.TerminalAtUtc);
|
||||
Assert.Equal(terminalAt, packet.Operational.TerminalAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CachedCallTelemetry_RoundTripEquality()
|
||||
{
|
||||
var trackedId = TrackedOperationId.New();
|
||||
var audit = BuildAuditEvent(trackedId, AuditKind.CachedSubmit, AuditStatus.Submitted);
|
||||
var operational = BuildOperational(trackedId, AuditStatus.Submitted, retryCount: 0);
|
||||
|
||||
var a = new CachedCallTelemetry(audit, operational);
|
||||
var b = new CachedCallTelemetry(audit, operational);
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
|
||||
var differentOperational = operational with { RetryCount = 1 };
|
||||
var c = a with { Operational = differentOperational };
|
||||
|
||||
Assert.NotEqual(a, c);
|
||||
Assert.Equal(0, a.Operational.RetryCount);
|
||||
Assert.Equal(1, c.Operational.RetryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCallOperational_RoundTripEquality_AndWithExpression()
|
||||
{
|
||||
var trackedId = TrackedOperationId.New();
|
||||
var a = BuildOperational(trackedId, AuditStatus.Submitted, retryCount: 0);
|
||||
var b = BuildOperational(trackedId, AuditStatus.Submitted, retryCount: 0);
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
|
||||
var withDifferentRetry = a with { RetryCount = 5 };
|
||||
Assert.NotEqual(a, withDifferentRetry);
|
||||
Assert.Equal(0, a.RetryCount);
|
||||
Assert.Equal(5, withDifferentRetry.RetryCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Reflection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ManagementCommandRegistry"/>, including the Commons-004
|
||||
/// regression: <c>GetCommandName</c> and <c>Resolve</c> must be symmetric — every
|
||||
/// type for which <c>GetCommandName</c> yields a name must round-trip back to the
|
||||
/// same type via <c>Resolve</c>.
|
||||
/// </summary>
|
||||
public class ManagementCommandRegistryTests
|
||||
{
|
||||
private static IEnumerable<Type> RegisteredCommandTypes() =>
|
||||
typeof(ManagementEnvelope).Assembly.GetTypes()
|
||||
.Where(t => t.Namespace == typeof(ManagementEnvelope).Namespace
|
||||
&& t.Name.EndsWith("Command", StringComparison.Ordinal)
|
||||
&& !t.IsAbstract);
|
||||
|
||||
[Fact]
|
||||
public void GetCommandName_Resolve_RoundTrips_ForEveryRegisteredCommand()
|
||||
{
|
||||
foreach (var type in RegisteredCommandTypes())
|
||||
{
|
||||
var name = ManagementCommandRegistry.GetCommandName(type);
|
||||
var resolved = ManagementCommandRegistry.Resolve(name);
|
||||
Assert.Equal(type, resolved);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_KnownCommand_ReturnsType()
|
||||
{
|
||||
var type = ManagementCommandRegistry.Resolve("CreateSite");
|
||||
Assert.Equal(typeof(CreateSiteCommand), type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_UnknownCommand_ReturnsNull()
|
||||
{
|
||||
Assert.Null(ManagementCommandRegistry.Resolve("NoSuchCommand"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_IsCaseInsensitive()
|
||||
{
|
||||
Assert.Equal(typeof(CreateSiteCommand), ManagementCommandRegistry.Resolve("createsite"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commons-004: <c>GetCommandName</c> previously stripped a <c>Command</c> suffix
|
||||
/// from <em>any</em> type, producing names the registry cannot resolve. It must
|
||||
/// only return a name for a command type the registry actually contains.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetCommandName_UnregisteredCommandType_Throws()
|
||||
{
|
||||
// A *Command type that is not in the Messages.Management namespace.
|
||||
Assert.Throws<ArgumentException>(
|
||||
() => ManagementCommandRegistry.GetCommandName(typeof(UnregisteredFakeCommand)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCommandName_NonCommandType_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(
|
||||
() => ManagementCommandRegistry.GetCommandName(typeof(string)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCommandName_RegisteredCommand_ReturnsStrippedName()
|
||||
{
|
||||
Assert.Equal("CreateSite", ManagementCommandRegistry.GetCommandName(typeof(CreateSiteCommand)));
|
||||
}
|
||||
|
||||
/// <summary>A *Command record outside the Management namespace, for the negative test.</summary>
|
||||
private record UnregisteredFakeCommand(int Id);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
|
||||
|
||||
public class MessageConventionTests
|
||||
{
|
||||
private static readonly Assembly CommonsAssembly = typeof(ZB.MOM.WW.ScadaBridge.Commons.Types.RetryPolicy).Assembly;
|
||||
|
||||
private static IEnumerable<Type> GetMessageTypes() =>
|
||||
CommonsAssembly.GetTypes()
|
||||
.Where(t => t.Namespace != null
|
||||
&& t.Namespace.Contains(".Messages.")
|
||||
&& !t.IsEnum
|
||||
&& !t.IsInterface
|
||||
&& !(t.IsAbstract && t.IsSealed) // exclude static classes (utilities)
|
||||
&& !t.Name.StartsWith("<") // exclude compiler-generated types
|
||||
&& (t.IsClass || (t.IsValueType && !t.IsPrimitive)));
|
||||
|
||||
[Fact]
|
||||
public void AllMessageTypes_ShouldBeRecords()
|
||||
{
|
||||
foreach (var type in GetMessageTypes())
|
||||
{
|
||||
// Records have a compiler-generated <Clone>$ method
|
||||
var cloneMethod = type.GetMethod("<Clone>$", BindingFlags.Public | BindingFlags.Instance);
|
||||
Assert.True(cloneMethod != null,
|
||||
$"{type.FullName} in Messages namespace should be a record type");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllMessageTimestampProperties_ShouldBeDateTimeOffset()
|
||||
{
|
||||
foreach (var type in GetMessageTypes())
|
||||
{
|
||||
foreach (var prop in type.GetProperties())
|
||||
{
|
||||
if (prop.Name.Contains("Timestamp") || prop.Name == "GeneratedAt" || prop.Name == "DeployedAt")
|
||||
{
|
||||
var underlyingType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
|
||||
Assert.True(underlyingType == typeof(DateTimeOffset),
|
||||
$"{type.Name}.{prop.Name} should be DateTimeOffset but is {prop.PropertyType.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonRoundTrip_DeployInstanceCommand_ShouldSucceed()
|
||||
{
|
||||
var msg = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment.DeployInstanceCommand(
|
||||
"dep-1", "instance-1", "abc123", "{}", "admin", DateTimeOffset.UtcNow);
|
||||
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment.DeployInstanceCommand>(json);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal(msg.DeploymentId, deserialized!.DeploymentId);
|
||||
Assert.Equal(msg.InstanceUniqueName, deserialized.InstanceUniqueName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonForwardCompatibility_UnknownField_ShouldDeserialize()
|
||||
{
|
||||
// Simulate a newer version with an extra field
|
||||
var json = """{"DeploymentId":"dep-1","InstanceUniqueName":"inst-1","RevisionHash":"abc","FlattenedConfigurationJson":"{}","DeployedBy":"admin","Timestamp":"2025-01-01T00:00:00+00:00","NewField":"extra"}""";
|
||||
|
||||
var deserialized = JsonSerializer.Deserialize<ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment.DeployInstanceCommand>(json);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal("dep-1", deserialized!.DeploymentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonBackwardCompatibility_MissingOptionalField_ShouldDeserialize()
|
||||
{
|
||||
// DeploymentStatusResponse has nullable ErrorMessage
|
||||
var json = """{"DeploymentId":"dep-1","InstanceUniqueName":"inst-1","Status":2,"Timestamp":"2025-01-01T00:00:00+00:00"}""";
|
||||
|
||||
var deserialized = JsonSerializer.Deserialize<ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment.DeploymentStatusResponse>(json);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal("dep-1", deserialized!.DeploymentId);
|
||||
Assert.Null(deserialized.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonRoundTrip_SiteHealthReport_ShouldSucceed()
|
||||
{
|
||||
var msg = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Health.SiteHealthReport(
|
||||
"site-1", 1, DateTimeOffset.UtcNow,
|
||||
new Dictionary<string, ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.ConnectionHealth>
|
||||
{
|
||||
["conn1"] = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.ConnectionHealth.Connected
|
||||
},
|
||||
new Dictionary<string, ZB.MOM.WW.ScadaBridge.Commons.Messages.Health.TagResolutionStatus>
|
||||
{
|
||||
["conn1"] = new(10, 8)
|
||||
},
|
||||
0, 0,
|
||||
new Dictionary<string, int> { ["queue1"] = 5 },
|
||||
0, 0, 0, 0);
|
||||
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<ZB.MOM.WW.ScadaBridge.Commons.Messages.Health.SiteHealthReport>(json);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal("site-1", deserialized!.SiteId);
|
||||
Assert.Equal(1, deserialized.SequenceNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonRoundTrip_DeployArtifactsCommand_ShouldSucceed()
|
||||
{
|
||||
var msg = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts.DeployArtifactsCommand(
|
||||
"dep-1",
|
||||
new List<ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts.SharedScriptArtifact>
|
||||
{
|
||||
new("script1", "code", null, null)
|
||||
},
|
||||
null, null, null, null, null,
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var deserialized = JsonSerializer.Deserialize<ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts.DeployArtifactsCommand>(json);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal("dep-1", deserialized!.DeploymentId);
|
||||
Assert.Single(deserialized.SharedScripts!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Notification Outbox: construction and value-equality tests for the
|
||||
/// site/central notification message contracts and the outbox UI query/action contracts.
|
||||
/// </summary>
|
||||
public class NotificationMessagesTests
|
||||
{
|
||||
// ── Task 7: site/central notification message contracts ──
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_PositionalConstruction_SetsAllFields()
|
||||
{
|
||||
var enqueuedAt = DateTimeOffset.UtcNow;
|
||||
var msg = new NotificationSubmit(
|
||||
"notif-1", "Operators", "Tank overflow", "Tank 3 has overflowed.",
|
||||
"site-01", "inst-7", "OnAlarm", enqueuedAt);
|
||||
|
||||
Assert.Equal("notif-1", msg.NotificationId);
|
||||
Assert.Equal("Operators", msg.ListName);
|
||||
Assert.Equal("Tank overflow", msg.Subject);
|
||||
Assert.Equal("Tank 3 has overflowed.", msg.Body);
|
||||
Assert.Equal("site-01", msg.SourceSiteId);
|
||||
Assert.Equal("inst-7", msg.SourceInstanceId);
|
||||
Assert.Equal("OnAlarm", msg.SourceScript);
|
||||
Assert.Equal(enqueuedAt, msg.SiteEnqueuedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_AllowsNullOptionalSourceFields()
|
||||
{
|
||||
var msg = new NotificationSubmit(
|
||||
"notif-2", "Operators", "Subject", "Body",
|
||||
"site-01", null, null, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Null(msg.SourceInstanceId);
|
||||
Assert.Null(msg.SourceScript);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_OriginExecutionId_DefaultsToNull()
|
||||
{
|
||||
// Audit Log #23: OriginExecutionId is an additive trailing member — a
|
||||
// submit built without it (old call sites / old serialized payloads)
|
||||
// leaves the id null.
|
||||
var msg = new NotificationSubmit(
|
||||
"notif-3", "Operators", "Subject", "Body",
|
||||
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Null(msg.OriginExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_OriginExecutionId_RoundTripsWhenSupplied()
|
||||
{
|
||||
var executionId = Guid.NewGuid();
|
||||
var msg = new NotificationSubmit(
|
||||
"notif-4", "Operators", "Subject", "Body",
|
||||
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow, executionId);
|
||||
|
||||
Assert.Equal(executionId, msg.OriginExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_OriginExecutionId_SurvivesJsonRoundTrip()
|
||||
{
|
||||
// The buffered S&F payload IS a serialized NotificationSubmit; the
|
||||
// forwarder deserializes it, so OriginExecutionId must survive JSON.
|
||||
var executionId = Guid.NewGuid();
|
||||
var msg = new NotificationSubmit(
|
||||
"notif-5", "Operators", "Subject", "Body",
|
||||
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow, executionId);
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(msg);
|
||||
var roundTripped = System.Text.Json.JsonSerializer.Deserialize<NotificationSubmit>(json);
|
||||
|
||||
Assert.NotNull(roundTripped);
|
||||
Assert.Equal(executionId, roundTripped!.OriginExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_OriginParentExecutionId_DefaultsToNull()
|
||||
{
|
||||
// Audit Log ParentExecutionId: OriginParentExecutionId is an additive
|
||||
// trailing member — a submit built without it (old call sites / old
|
||||
// serialized payloads, or non-routed runs) leaves the id null.
|
||||
var msg = new NotificationSubmit(
|
||||
"notif-6", "Operators", "Subject", "Body",
|
||||
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Null(msg.OriginParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_OriginParentExecutionId_RoundTripsWhenSupplied()
|
||||
{
|
||||
var executionId = Guid.NewGuid();
|
||||
var parentExecutionId = Guid.NewGuid();
|
||||
var msg = new NotificationSubmit(
|
||||
"notif-7", "Operators", "Subject", "Body",
|
||||
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow,
|
||||
executionId, parentExecutionId);
|
||||
|
||||
Assert.Equal(parentExecutionId, msg.OriginParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_OriginParentExecutionId_SurvivesJsonRoundTrip()
|
||||
{
|
||||
// The buffered S&F payload IS a serialized NotificationSubmit; the
|
||||
// forwarder deserializes it, so OriginParentExecutionId must survive JSON.
|
||||
var executionId = Guid.NewGuid();
|
||||
var parentExecutionId = Guid.NewGuid();
|
||||
var msg = new NotificationSubmit(
|
||||
"notif-8", "Operators", "Subject", "Body",
|
||||
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow,
|
||||
executionId, parentExecutionId);
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(msg);
|
||||
var roundTripped = System.Text.Json.JsonSerializer.Deserialize<NotificationSubmit>(json);
|
||||
|
||||
Assert.NotNull(roundTripped);
|
||||
Assert.Equal(parentExecutionId, roundTripped!.OriginParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_carries_SourceNode()
|
||||
{
|
||||
// SourceNode is an additive trailing member — old call sites and old
|
||||
// serialized payloads leave it null. When supplied it round-trips
|
||||
// through both construction and JSON (the buffered S&F payload IS a
|
||||
// serialized NotificationSubmit).
|
||||
var defaulted = new NotificationSubmit(
|
||||
"notif-9", "Operators", "Subject", "Body",
|
||||
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow);
|
||||
Assert.Null(defaulted.SourceNode);
|
||||
|
||||
var stamped = new NotificationSubmit(
|
||||
"notif-10", "Operators", "Subject", "Body",
|
||||
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow,
|
||||
OriginExecutionId: null,
|
||||
OriginParentExecutionId: null,
|
||||
SourceNode: "node-a");
|
||||
Assert.Equal("node-a", stamped.SourceNode);
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(stamped);
|
||||
var roundTripped = System.Text.Json.JsonSerializer.Deserialize<NotificationSubmit>(json);
|
||||
Assert.NotNull(roundTripped);
|
||||
Assert.Equal("node-a", roundTripped!.SourceNode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch()
|
||||
{
|
||||
var enqueuedAt = DateTimeOffset.UtcNow;
|
||||
var a = new NotificationSubmit("n", "L", "S", "B", "site", "inst", "scr", enqueuedAt);
|
||||
var b = new NotificationSubmit("n", "L", "S", "B", "site", "inst", "scr", enqueuedAt);
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmitAck_WithExpression_ChangesSingleField()
|
||||
{
|
||||
var ack = new NotificationSubmitAck("notif-1", true, null);
|
||||
var rejected = ack with { Accepted = false, Error = "duplicate" };
|
||||
|
||||
Assert.True(ack.Accepted);
|
||||
Assert.False(rejected.Accepted);
|
||||
Assert.Equal("duplicate", rejected.Error);
|
||||
Assert.Equal("notif-1", rejected.NotificationId);
|
||||
Assert.NotEqual(ack, rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationStatusQuery_PositionalConstruction_SetsAllFields()
|
||||
{
|
||||
var msg = new NotificationStatusQuery("corr-1", "notif-9");
|
||||
|
||||
Assert.Equal("corr-1", msg.CorrelationId);
|
||||
Assert.Equal("notif-9", msg.NotificationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationStatusResponse_PositionalConstruction_SetsAllFields()
|
||||
{
|
||||
var deliveredAt = DateTimeOffset.UtcNow;
|
||||
var msg = new NotificationStatusResponse(
|
||||
"corr-1", true, "Delivered", 2, null, deliveredAt);
|
||||
|
||||
Assert.Equal("corr-1", msg.CorrelationId);
|
||||
Assert.True(msg.Found);
|
||||
Assert.Equal("Delivered", msg.Status);
|
||||
Assert.Equal(2, msg.RetryCount);
|
||||
Assert.Null(msg.LastError);
|
||||
Assert.Equal(deliveredAt, msg.DeliveredAt);
|
||||
}
|
||||
|
||||
// ── Task 8: outbox UI query/action contracts ──
|
||||
|
||||
[Fact]
|
||||
public void NotificationOutboxQueryRequest_PositionalConstruction_SetsAllFields()
|
||||
{
|
||||
var from = DateTimeOffset.UtcNow.AddDays(-1);
|
||||
var to = DateTimeOffset.UtcNow;
|
||||
var msg = new NotificationOutboxQueryRequest(
|
||||
"corr-1", "Stuck", "Email", "site-01", "Operators", true, "overflow",
|
||||
from, to, 2, 50);
|
||||
|
||||
Assert.Equal("corr-1", msg.CorrelationId);
|
||||
Assert.Equal("Stuck", msg.StatusFilter);
|
||||
Assert.Equal("Email", msg.TypeFilter);
|
||||
Assert.Equal("site-01", msg.SourceSiteFilter);
|
||||
Assert.Equal("Operators", msg.ListNameFilter);
|
||||
Assert.True(msg.StuckOnly);
|
||||
Assert.Equal("overflow", msg.SubjectKeyword);
|
||||
Assert.Equal(from, msg.From);
|
||||
Assert.Equal(to, msg.To);
|
||||
Assert.Equal(2, msg.PageNumber);
|
||||
Assert.Equal(50, msg.PageSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSummary_ValueEquality_EqualWhenAllFieldsMatch()
|
||||
{
|
||||
var createdAt = DateTimeOffset.UtcNow;
|
||||
var a = new NotificationSummary(
|
||||
"n", "Email", "Ops", "S", "Pending", 1, null, "site-01", "inst-1",
|
||||
createdAt, null, false);
|
||||
var b = new NotificationSummary(
|
||||
"n", "Email", "Ops", "S", "Pending", 1, null, "site-01", "inst-1",
|
||||
createdAt, null, false);
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationOutboxQueryResponse_PositionalConstruction_SetsAllFields()
|
||||
{
|
||||
var summary = new NotificationSummary(
|
||||
"n", "Email", "Ops", "S", "Delivered", 0, null, "site-01", null,
|
||||
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, false);
|
||||
var msg = new NotificationOutboxQueryResponse(
|
||||
"corr-1", true, null, new[] { summary }, 1);
|
||||
|
||||
Assert.Equal("corr-1", msg.CorrelationId);
|
||||
Assert.True(msg.Success);
|
||||
Assert.Null(msg.ErrorMessage);
|
||||
Assert.Single(msg.Notifications);
|
||||
Assert.Equal(1, msg.TotalCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryNotificationRequestAndResponse_RoundTripFields()
|
||||
{
|
||||
var request = new RetryNotificationRequest("corr-1", "notif-1");
|
||||
var response = new RetryNotificationResponse("corr-1", true, null);
|
||||
|
||||
Assert.Equal("corr-1", request.CorrelationId);
|
||||
Assert.Equal("notif-1", request.NotificationId);
|
||||
Assert.True(response.Success);
|
||||
Assert.Null(response.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscardNotificationRequestAndResponse_RoundTripFields()
|
||||
{
|
||||
var request = new DiscardNotificationRequest("corr-1", "notif-1");
|
||||
var response = new DiscardNotificationResponse("corr-1", false, "not found");
|
||||
|
||||
Assert.Equal("notif-1", request.NotificationId);
|
||||
Assert.False(response.Success);
|
||||
Assert.Equal("not found", response.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationKpiResponse_WithExpression_ChangesSingleField()
|
||||
{
|
||||
var kpi = new NotificationKpiResponse(
|
||||
"corr-1", Success: true, ErrorMessage: null, 10, 2, 1, 5, TimeSpan.FromMinutes(3));
|
||||
var updated = kpi with { QueueDepth = 12 };
|
||||
|
||||
Assert.True(kpi.Success);
|
||||
Assert.Null(kpi.ErrorMessage);
|
||||
Assert.Equal(10, kpi.QueueDepth);
|
||||
Assert.Equal(12, updated.QueueDepth);
|
||||
Assert.Equal(2, updated.StuckCount);
|
||||
Assert.Equal(TimeSpan.FromMinutes(3), updated.OldestPendingAge);
|
||||
Assert.NotEqual(kpi, updated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationKpiRequest_PositionalConstruction_SetsCorrelationId()
|
||||
{
|
||||
var msg = new NotificationKpiRequest("corr-1");
|
||||
Assert.Equal("corr-1", msg.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerSiteNotificationKpiRequest_CarriesCorrelationId()
|
||||
{
|
||||
var request = new PerSiteNotificationKpiRequest("corr-1");
|
||||
Assert.Equal("corr-1", request.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerSiteNotificationKpiResponse_CarriesPerSiteSnapshots()
|
||||
{
|
||||
var sites = new[]
|
||||
{
|
||||
new SiteNotificationKpiSnapshot("plant-a", 3, 1, 0, 10, TimeSpan.FromMinutes(4)),
|
||||
};
|
||||
var response = new PerSiteNotificationKpiResponse("corr-1", Success: true, ErrorMessage: null, sites);
|
||||
|
||||
Assert.True(response.Success);
|
||||
Assert.Null(response.ErrorMessage);
|
||||
Assert.Single(response.Sites);
|
||||
Assert.Equal("plant-a", response.Sites[0].SourceSiteId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Site Call Audit (#22): construction, value-equality and optionality tests
|
||||
/// for the Site Calls UI query / KPI / detail message contracts. Mirrors the
|
||||
/// Notification Outbox <c>NotificationMessagesTests</c> coverage of the read
|
||||
/// side, scoped to the contracts the Site Calls page consumes.
|
||||
/// </summary>
|
||||
public class SiteCallQueriesTests
|
||||
{
|
||||
[Fact]
|
||||
public void SiteCallQueryRequest_PositionalConstruction_SetsAllFields()
|
||||
{
|
||||
var afterCreated = DateTime.UtcNow;
|
||||
var afterId = Guid.NewGuid();
|
||||
var request = new SiteCallQueryRequest(
|
||||
"corr-1", "Parked", "plant-a", "ApiOutbound", "ERP.GetOrder", true,
|
||||
new DateTime(2026, 5, 1), new DateTime(2026, 5, 20), afterCreated, afterId, 50);
|
||||
|
||||
Assert.Equal("corr-1", request.CorrelationId);
|
||||
Assert.Equal("Parked", request.StatusFilter);
|
||||
Assert.Equal("plant-a", request.SourceSiteFilter);
|
||||
Assert.Equal("ApiOutbound", request.ChannelFilter);
|
||||
Assert.Equal("ERP.GetOrder", request.TargetKeyword);
|
||||
Assert.True(request.StuckOnly);
|
||||
Assert.Equal(new DateTime(2026, 5, 1), request.FromUtc);
|
||||
Assert.Equal(new DateTime(2026, 5, 20), request.ToUtc);
|
||||
Assert.Equal(afterCreated, request.AfterCreatedAtUtc);
|
||||
Assert.Equal(afterId, request.AfterId);
|
||||
Assert.Equal(50, request.PageSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCallQueryRequest_AllowsNullOptionalFilters()
|
||||
{
|
||||
var request = new SiteCallQueryRequest(
|
||||
"corr-2", null, null, null, null, false, null, null, null, null, 25);
|
||||
|
||||
Assert.Null(request.StatusFilter);
|
||||
Assert.Null(request.SourceSiteFilter);
|
||||
Assert.Null(request.ChannelFilter);
|
||||
Assert.Null(request.TargetKeyword);
|
||||
Assert.False(request.StuckOnly);
|
||||
Assert.Null(request.FromUtc);
|
||||
Assert.Null(request.AfterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCallQueryResponse_ValueEquality_EqualWhenAllFieldsMatch()
|
||||
{
|
||||
var a = new SiteCallQueryResponse("c", true, null, Array.Empty<SiteCallSummary>(), null, null);
|
||||
var b = new SiteCallQueryResponse("c", true, null, Array.Empty<SiteCallSummary>(), null, null);
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCallSummary_CarriesEntityColumnsAndStuckFlag()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var created = DateTime.UtcNow.AddMinutes(-30);
|
||||
var summary = new SiteCallSummary(
|
||||
id, "plant-a", "DbOutbound", "InventoryDb", "Retrying", 3,
|
||||
"transient 503", 503, created, created.AddMinutes(1), null, IsStuck: true);
|
||||
|
||||
Assert.Equal(id, summary.TrackedOperationId);
|
||||
Assert.Equal("DbOutbound", summary.Channel);
|
||||
Assert.Equal("InventoryDb", summary.Target);
|
||||
Assert.Equal("Retrying", summary.Status);
|
||||
Assert.Equal(3, summary.RetryCount);
|
||||
Assert.Equal(503, summary.HttpStatus);
|
||||
Assert.Null(summary.TerminalAtUtc);
|
||||
Assert.True(summary.IsStuck);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCallDetailResponse_MissingRow_HasNullDetail()
|
||||
{
|
||||
var response = new SiteCallDetailResponse("c", false, "site call not found", null);
|
||||
|
||||
Assert.False(response.Success);
|
||||
Assert.Null(response.Detail);
|
||||
Assert.Equal("site call not found", response.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCallKpiResponse_FailureShape_ZeroesKpiFields()
|
||||
{
|
||||
var response = new SiteCallKpiResponse(
|
||||
"c", Success: false, ErrorMessage: "db down",
|
||||
BufferedCount: 0, ParkedCount: 0, FailedLastInterval: 0,
|
||||
DeliveredLastInterval: 0, OldestPendingAge: null, StuckCount: 0);
|
||||
|
||||
Assert.False(response.Success);
|
||||
Assert.Equal("db down", response.ErrorMessage);
|
||||
Assert.Equal(0, response.BufferedCount);
|
||||
Assert.Null(response.OldestPendingAge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerSiteSiteCallKpiResponse_CarriesPerSiteSnapshots()
|
||||
{
|
||||
var response = new PerSiteSiteCallKpiResponse(
|
||||
"c", true, null,
|
||||
new[]
|
||||
{
|
||||
new SiteCallSiteKpiSnapshot("plant-a", 4, 1, 0, 9, TimeSpan.FromMinutes(15), 2),
|
||||
});
|
||||
|
||||
Assert.True(response.Success);
|
||||
var site = Assert.Single(response.Sites);
|
||||
Assert.Equal("plant-a", site.SourceSite);
|
||||
Assert.Equal(4, site.BufferedCount);
|
||||
Assert.Equal(2, site.StuckCount);
|
||||
Assert.Equal(TimeSpan.FromMinutes(15), site.OldestPendingAge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteCallKpiSnapshot_OldestPendingAge_IsNullableForEmptyTable()
|
||||
{
|
||||
var snapshot = new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0);
|
||||
Assert.Null(snapshot.OldestPendingAge);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user