using System.Text.Json; using ScadaLink.Commons.Messages.Artifacts; using ScadaLink.Commons.Messages.Communication; using ScadaLink.Commons.Messages.Deployment; using ScadaLink.Commons.Messages.Health; using ScadaLink.Commons.Messages.Lifecycle; using ScadaLink.Commons.Messages.ScriptExecution; using ScadaLink.Commons.Messages.Streaming; using ScadaLink.Commons.Types.Enums; namespace ScadaLink.Commons.Tests.Messages; /// /// 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. /// 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(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(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(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(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(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(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(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(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(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(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 ── [Fact] public void RoundTrip_ConnectionStateChanged_Succeeds() { var msg = new ConnectionStateChanged("site-01", true, DateTimeOffset.UtcNow); var json = JsonSerializer.Serialize(msg); var deserialized = JsonSerializer.Deserialize(json, Options); Assert.NotNull(deserialized); Assert.Equal("site-01", deserialized!.SiteId); Assert.True(deserialized.IsConnected); } [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(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(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(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(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(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(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(json, Options); Assert.NotNull(msg); Assert.Equal((DeploymentStatus)99, msg!.Status); } }