using Google.Protobuf.WellKnownTypes; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// /// Round-trip + edge tests for the that bridges /// the canonical (proto). /// /// /// C3 (Task 2.5): the canonical record carries the ScadaBridge domain fields inside /// DetailsJson; the proto contract is unchanged (24-field wire). Domain fields /// are read back as typed properties via AsRow(). ForwardState is /// site-storage-only (never on the wire) and IngestedAtUtc is central-set /// (left null by the mapper), so neither survives the proto round-trip. /// public class AuditEventDtoMapperTests { [Fact] public void ToDto_FromDto_Roundtrip_FullyPopulated_PreservesAllFields() { var occurredAt = new DateTime(2026, 5, 20, 10, 15, 30, 123, DateTimeKind.Utc); var ingestedAt = new DateTimeOffset(new DateTime(2026, 5, 20, 10, 15, 31, 0, DateTimeKind.Utc)); var correlationId = Guid.NewGuid(); var executionId = Guid.NewGuid(); var parentExecutionId = Guid.NewGuid(); var eventId = Guid.NewGuid(); var original = ScadaBridgeAuditEventFactory.Create( channel: AuditChannel.ApiOutbound, kind: AuditKind.ApiCallCached, status: AuditStatus.Forwarded, eventId: eventId, occurredAtUtc: occurredAt, actor: "design-key", target: "weather-api", sourceNode: "node-a", correlationId: correlationId, executionId: executionId, parentExecutionId: parentExecutionId, sourceSiteId: "site-1", sourceInstanceId: "Pump01", sourceScript: "OnDemand", httpStatus: 200, durationMs: 42, errorMessage: "transient timeout", errorDetail: "stack-trace", requestSummary: "GET /weather", responseSummary: "{ \"ok\": true }", payloadTruncated: true, extra: "{ \"retryCount\": 1 }", ingestedAtUtc: ingestedAt); var dto = AuditEventDtoMapper.ToDto(original); var roundTripped = AuditEventDtoMapper.FromDto(dto); var o = original.AsRow(); var rt = roundTripped.AsRow(); Assert.Equal(o.EventId, rt.EventId); Assert.Equal(o.OccurredAtUtc, rt.OccurredAtUtc); Assert.Equal(o.Channel, rt.Channel); Assert.Equal(o.Kind, rt.Kind); Assert.Equal(o.CorrelationId, rt.CorrelationId); Assert.Equal(o.ExecutionId, rt.ExecutionId); Assert.Equal(o.ParentExecutionId, rt.ParentExecutionId); Assert.Equal(o.SourceSiteId, rt.SourceSiteId); Assert.Equal(o.SourceNode, rt.SourceNode); Assert.Equal(o.SourceInstanceId, rt.SourceInstanceId); Assert.Equal(o.SourceScript, rt.SourceScript); Assert.Equal(o.Actor, rt.Actor); Assert.Equal(o.Target, rt.Target); Assert.Equal(o.Status, rt.Status); Assert.Equal(o.HttpStatus, rt.HttpStatus); Assert.Equal(o.DurationMs, rt.DurationMs); Assert.Equal(o.ErrorMessage, rt.ErrorMessage); Assert.Equal(o.ErrorDetail, rt.ErrorDetail); Assert.Equal(o.RequestSummary, rt.RequestSummary); Assert.Equal(o.ResponseSummary, rt.ResponseSummary); Assert.Equal(o.PayloadTruncated, rt.PayloadTruncated); Assert.Equal(o.Extra, rt.Extra); // ForwardState is site-storage-only (never on the wire); IngestedAtUtc is // central-set at ingest, so the mapper leaves it null on the wire. Assert.Null(rt.IngestedAtUtc); } [Fact] public void ToDto_NullableStringFields_BecomeEmptyString() { var evt = ScadaBridgeAuditEventFactory.Create( channel: AuditChannel.Notification, kind: AuditKind.NotifySend, status: AuditStatus.Submitted); // all string? fields left null; CorrelationId null var dto = AuditEventDtoMapper.ToDto(evt); Assert.Equal(string.Empty, dto.CorrelationId); Assert.Equal(string.Empty, dto.ExecutionId); Assert.Equal(string.Empty, dto.ParentExecutionId); Assert.Equal(string.Empty, dto.SourceSiteId); Assert.Equal(string.Empty, dto.SourceNode); Assert.Equal(string.Empty, dto.SourceInstanceId); Assert.Equal(string.Empty, dto.SourceScript); Assert.Equal(string.Empty, dto.Actor); Assert.Equal(string.Empty, dto.Target); Assert.Equal(string.Empty, dto.ErrorMessage); Assert.Equal(string.Empty, dto.ErrorDetail); Assert.Equal(string.Empty, dto.RequestSummary); Assert.Equal(string.Empty, dto.ResponseSummary); Assert.Equal(string.Empty, dto.Extra); } [Fact] public void FromDto_EmptyString_BecomesNullProperty() { var dto = new AuditEventDto { EventId = Guid.NewGuid().ToString(), OccurredAtUtc = Timestamp.FromDateTime(DateTime.UtcNow), Channel = nameof(AuditChannel.ApiOutbound), Kind = nameof(AuditKind.ApiCall), Status = nameof(AuditStatus.Submitted), CorrelationId = string.Empty, ExecutionId = string.Empty, ParentExecutionId = string.Empty, SourceSiteId = string.Empty, SourceNode = string.Empty, SourceInstanceId = string.Empty, SourceScript = string.Empty, Actor = string.Empty, Target = string.Empty, ErrorMessage = string.Empty, ErrorDetail = string.Empty, RequestSummary = string.Empty, ResponseSummary = string.Empty, Extra = string.Empty }; var evt = AuditEventDtoMapper.FromDto(dto).AsRow(); Assert.Null(evt.CorrelationId); Assert.Null(evt.ExecutionId); Assert.Null(evt.ParentExecutionId); Assert.Null(evt.SourceSiteId); Assert.Null(evt.SourceNode); Assert.Null(evt.SourceInstanceId); Assert.Null(evt.SourceScript); Assert.Null(evt.Actor); Assert.Null(evt.Target); Assert.Null(evt.ErrorMessage); Assert.Null(evt.ErrorDetail); Assert.Null(evt.RequestSummary); Assert.Null(evt.ResponseSummary); Assert.Null(evt.Extra); } [Fact] public void ToDto_OccurredAtUtc_PreservesUtcKind() { var occurredAt = new DateTime(2026, 5, 20, 8, 0, 0, DateTimeKind.Utc); var evt = ScadaBridgeAuditEventFactory.Create( channel: AuditChannel.DbOutbound, kind: AuditKind.DbWrite, status: AuditStatus.Delivered, occurredAtUtc: occurredAt); var dto = AuditEventDtoMapper.ToDto(evt); var roundTripped = AuditEventDtoMapper.FromDto(dto).AsRow(); Assert.Equal(DateTimeKind.Utc, roundTripped.OccurredAtUtc.Kind); Assert.Equal(occurredAt, roundTripped.OccurredAtUtc); } [Fact] public void ToDto_NullableInt_BecomesNullInt32Value() { var evt = ScadaBridgeAuditEventFactory.Create( channel: AuditChannel.Notification, kind: AuditKind.NotifySend, status: AuditStatus.Submitted, httpStatus: null, durationMs: null); var dto = AuditEventDtoMapper.ToDto(evt); Assert.Null(dto.HttpStatus); Assert.Null(dto.DurationMs); } [Fact] public void FromDto_NullInt32Value_BecomesNullProperty() { var dto = new AuditEventDto { EventId = Guid.NewGuid().ToString(), OccurredAtUtc = Timestamp.FromDateTime(DateTime.UtcNow), Channel = nameof(AuditChannel.ApiInbound), Kind = nameof(AuditKind.InboundRequest), Status = nameof(AuditStatus.Delivered) // HttpStatus + DurationMs intentionally left absent }; Assert.Null(dto.HttpStatus); Assert.Null(dto.DurationMs); var evt = AuditEventDtoMapper.FromDto(dto).AsRow(); Assert.Null(evt.HttpStatus); Assert.Null(evt.DurationMs); } [Fact] public void ToDto_EnumValues_StoredAsStringNames() { var evt = ScadaBridgeAuditEventFactory.Create( channel: AuditChannel.ApiOutbound, kind: AuditKind.ApiCallCached, status: AuditStatus.Parked); var dto = AuditEventDtoMapper.ToDto(evt); Assert.Equal("ApiOutbound", dto.Channel); Assert.Equal("ApiCallCached", dto.Kind); Assert.Equal("Parked", dto.Status); } [Fact] public void AuditEventDto_round_trip_preserves_SourceNode() { var evt = ScadaBridgeAuditEventFactory.Create( channel: AuditChannel.ApiOutbound, kind: AuditKind.ApiCall, status: AuditStatus.Delivered, sourceNode: "node-a"); var dto = AuditEventDtoMapper.ToDto(evt); // Wire form: empty-string-means-null convention; populated value // travels verbatim. Assert.Equal("node-a", dto.SourceNode); var roundTripped = AuditEventDtoMapper.FromDto(dto); Assert.Equal("node-a", roundTripped.SourceNode); } [Fact] public void AuditEventDto_round_trip_preserves_null_SourceNode() { var evt = ScadaBridgeAuditEventFactory.Create( channel: AuditChannel.ApiOutbound, kind: AuditKind.ApiCall, status: AuditStatus.Delivered, sourceNode: null); var dto = AuditEventDtoMapper.ToDto(evt); // ToDto collapses null → empty on the wire… Assert.Equal(string.Empty, dto.SourceNode); var roundTripped = AuditEventDtoMapper.FromDto(dto); // …and FromDto rehydrates empty → null. Assert.Null(roundTripped.SourceNode); } /// /// C3 hardening (Task 2.5): a DTO that carries an unknown/renamed enum string /// for Channel, Kind, or Status must NOT throw on ; /// it degrades gracefully to the same fallbacks used by AuditRowProjection.Decompose /// (ApiInbound / InboundRequest / Submitted). /// [Fact] public void FromDto_UnknownEnumStrings_DoNotThrow_YieldFallbackValues() { var dto = new AuditEventDto { EventId = Guid.NewGuid().ToString(), OccurredAtUtc = Timestamp.FromDateTime(DateTime.UtcNow), Channel = "ObsoleteChannelV0", // unknown — not a declared AuditChannel member Kind = "LegacyKindName", // unknown — not a declared AuditKind member Status = "RenamedStatus99", // unknown — not a declared AuditStatus member }; // Must not throw (previously would throw ArgumentException from Enum.Parse). var row = AuditEventDtoMapper.FromDto(dto).AsRow(); Assert.Equal(AuditChannel.ApiInbound, row.Channel); Assert.Equal(AuditKind.InboundRequest, row.Kind); Assert.Equal(AuditStatus.Submitted, row.Status); } }