- AuditEventDto field 22, SiteCallOperationalDto field 12. Both follow the existing empty-string-means-null convention. - Mappers carry SourceNode end-to-end; round-trip tests cover both populated and null cases.
173 lines
5.8 KiB
C#
173 lines
5.8 KiB
C#
using Google.Protobuf;
|
|
using Google.Protobuf.WellKnownTypes;
|
|
using ScadaLink.Communication.Grpc;
|
|
|
|
namespace ScadaLink.Communication.Tests;
|
|
|
|
/// <summary>
|
|
/// Field-coverage + edge tests for the <see cref="SiteCallDtoMapper"/> that
|
|
/// decodes <see cref="SiteCallOperationalDto"/> (proto) into the
|
|
/// <see cref="ScadaLink.Commons.Entities.Audit.SiteCall"/> persistence entity.
|
|
/// Only the DTO→entity direction exists — nothing in the system maps a
|
|
/// <c>SiteCall</c> back onto the wire — so there is no round-trip test.
|
|
/// <c>IngestedAtUtc</c> is a site-side placeholder the central ingest actor
|
|
/// overwrites, so it is asserted as "recent UTC" rather than a fixed value.
|
|
/// </summary>
|
|
public class SiteCallDtoMapperTests
|
|
{
|
|
[Fact]
|
|
public void FromDto_FullyPopulated_MapsEveryField()
|
|
{
|
|
var trackedOperationId = Guid.NewGuid();
|
|
var createdAt = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
|
|
var updatedAt = new DateTime(2026, 5, 20, 10, 5, 0, DateTimeKind.Utc);
|
|
var terminalAt = new DateTime(2026, 5, 20, 10, 10, 0, DateTimeKind.Utc);
|
|
|
|
var dto = new SiteCallOperationalDto
|
|
{
|
|
TrackedOperationId = trackedOperationId.ToString(),
|
|
Channel = "ApiOutbound",
|
|
Target = "ERP.GetOrder",
|
|
SourceSite = "site-melbourne",
|
|
SourceNode = "node-a",
|
|
Status = "Delivered",
|
|
RetryCount = 3,
|
|
LastError = "transient 503",
|
|
HttpStatus = 200,
|
|
CreatedAtUtc = Timestamp.FromDateTime(createdAt),
|
|
UpdatedAtUtc = Timestamp.FromDateTime(updatedAt),
|
|
TerminalAtUtc = Timestamp.FromDateTime(terminalAt),
|
|
};
|
|
|
|
var entity = SiteCallDtoMapper.FromDto(dto);
|
|
|
|
Assert.Equal(trackedOperationId, entity.TrackedOperationId.Value);
|
|
Assert.Equal("ApiOutbound", entity.Channel);
|
|
Assert.Equal("ERP.GetOrder", entity.Target);
|
|
Assert.Equal("site-melbourne", entity.SourceSite);
|
|
Assert.Equal("node-a", entity.SourceNode);
|
|
Assert.Equal("Delivered", entity.Status);
|
|
Assert.Equal(3, entity.RetryCount);
|
|
Assert.Equal("transient 503", entity.LastError);
|
|
Assert.Equal(200, entity.HttpStatus);
|
|
Assert.Equal(createdAt, entity.CreatedAtUtc);
|
|
Assert.Equal(updatedAt, entity.UpdatedAtUtc);
|
|
Assert.Equal(terminalAt, entity.TerminalAtUtc);
|
|
}
|
|
|
|
[Fact]
|
|
public void FromDto_EmptyLastError_BecomesNull()
|
|
{
|
|
var dto = NewMinimalDto();
|
|
dto.LastError = string.Empty;
|
|
|
|
var entity = SiteCallDtoMapper.FromDto(dto);
|
|
|
|
Assert.Null(entity.LastError);
|
|
}
|
|
|
|
[Fact]
|
|
public void FromDto_AbsentHttpStatus_StaysNull()
|
|
{
|
|
// Int32Value wrapper unset on the wire — preserves true null semantics
|
|
// for non-API cached writes.
|
|
var dto = NewMinimalDto();
|
|
|
|
Assert.Null(dto.HttpStatus);
|
|
|
|
var entity = SiteCallDtoMapper.FromDto(dto);
|
|
|
|
Assert.Null(entity.HttpStatus);
|
|
}
|
|
|
|
[Fact]
|
|
public void FromDto_AbsentTerminalAt_StaysNull()
|
|
{
|
|
// Timestamp wrapper unset while the call is still active.
|
|
var dto = NewMinimalDto();
|
|
|
|
Assert.Null(dto.TerminalAtUtc);
|
|
|
|
var entity = SiteCallDtoMapper.FromDto(dto);
|
|
|
|
Assert.Null(entity.TerminalAtUtc);
|
|
}
|
|
|
|
[Fact]
|
|
public void FromDto_Timestamps_RehydrateAsUtcKind()
|
|
{
|
|
var dto = NewMinimalDto();
|
|
|
|
var entity = SiteCallDtoMapper.FromDto(dto);
|
|
|
|
Assert.Equal(DateTimeKind.Utc, entity.CreatedAtUtc.Kind);
|
|
Assert.Equal(DateTimeKind.Utc, entity.UpdatedAtUtc.Kind);
|
|
}
|
|
|
|
[Fact]
|
|
public void FromDto_IngestedAtUtc_StampedAsRecentPlaceholder()
|
|
{
|
|
// IngestedAtUtc is a site-side DateTime.UtcNow placeholder; the central
|
|
// ingest actor overwrites it inside the dual-write transaction.
|
|
var before = DateTime.UtcNow;
|
|
|
|
var entity = SiteCallDtoMapper.FromDto(NewMinimalDto());
|
|
|
|
var after = DateTime.UtcNow;
|
|
Assert.InRange(entity.IngestedAtUtc, before, after);
|
|
Assert.Equal(DateTimeKind.Utc, entity.IngestedAtUtc.Kind);
|
|
}
|
|
|
|
[Fact]
|
|
public void FromDto_Null_Throws()
|
|
{
|
|
Assert.Throws<ArgumentNullException>(() => SiteCallDtoMapper.FromDto(null!));
|
|
}
|
|
|
|
[Fact]
|
|
public void SiteCallOperationalDto_round_trip_preserves_SourceNode()
|
|
{
|
|
// Populated SourceNode travels verbatim across the wire and through
|
|
// the DTO→entity mapper.
|
|
var dto = NewMinimalDto();
|
|
dto.SourceNode = "node-a";
|
|
|
|
var bytes = dto.ToByteArray();
|
|
var onWire = SiteCallOperationalDto.Parser.ParseFrom(bytes);
|
|
Assert.Equal("node-a", onWire.SourceNode);
|
|
|
|
var entity = SiteCallDtoMapper.FromDto(onWire);
|
|
|
|
Assert.Equal("node-a", entity.SourceNode);
|
|
}
|
|
|
|
[Fact]
|
|
public void SiteCallOperationalDto_round_trip_preserves_null_SourceNode()
|
|
{
|
|
// The DTO uses an empty-string-means-null convention on the wire;
|
|
// FromDto rehydrates that back to a true null on the entity.
|
|
var dto = NewMinimalDto();
|
|
// SourceNode left at proto default (empty string) — semantically null.
|
|
|
|
var bytes = dto.ToByteArray();
|
|
var onWire = SiteCallOperationalDto.Parser.ParseFrom(bytes);
|
|
Assert.Equal(string.Empty, onWire.SourceNode);
|
|
|
|
var entity = SiteCallDtoMapper.FromDto(onWire);
|
|
|
|
Assert.Null(entity.SourceNode);
|
|
}
|
|
|
|
private static SiteCallOperationalDto NewMinimalDto() => new()
|
|
{
|
|
TrackedOperationId = Guid.NewGuid().ToString(),
|
|
Channel = "DbOutbound",
|
|
Target = "warehouse.dbo.WriteOrder",
|
|
SourceSite = "site-brisbane",
|
|
Status = "Submitted",
|
|
RetryCount = 0,
|
|
CreatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow),
|
|
UpdatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow),
|
|
};
|
|
}
|