Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/AuditEventDtoMapperTests.cs
T

298 lines
11 KiB
C#

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;
/// <summary>
/// Round-trip + edge tests for the <see cref="AuditEventDtoMapper"/> that bridges
/// the canonical <see cref="AuditEvent"/> ↔ <see cref="AuditEventDto"/> (proto).
/// </summary>
/// <remarks>
/// C3 (Task 2.5): the canonical record carries the ScadaBridge domain fields inside
/// <c>DetailsJson</c>; the proto contract is unchanged (24-field wire). Domain fields
/// are read back as typed properties via <c>AsRow()</c>. <c>ForwardState</c> is
/// site-storage-only (never on the wire) and <c>IngestedAtUtc</c> is central-set
/// (left null by the mapper), so neither survives the proto round-trip.
/// </remarks>
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);
}
/// <summary>
/// C3 hardening (Task 2.5): a DTO that carries an unknown/renamed enum string
/// for Channel, Kind, or Status must NOT throw on <see cref="AuditEventDtoMapper.FromDto"/>;
/// it degrades gracefully to the same fallbacks used by <c>AuditRowProjection.Decompose</c>
/// (ApiInbound / InboundRequest / Submitted).
/// </summary>
[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);
}
}