298 lines
11 KiB
C#
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);
|
|
}
|
|
}
|