513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
410 lines
19 KiB
C#
410 lines
19 KiB
C#
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteCallAudit.Tests;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="SiteCallAuditActor"/>
|
|
/// relaying operator Retry/Discard on a parked Site Call down to the owning
|
|
/// site. The relay routes a <see cref="RetryParkedOperation"/> /
|
|
/// <see cref="DiscardParkedOperation"/> command via a <see cref="SiteEnvelope"/>
|
|
/// to the <see cref="ZB.MOM.WW.ScadaBridge.Communication.Actors.CentralCommunicationActor"/>
|
|
/// (stood in by a <c>TestProbe</c> here) and awaits the site's
|
|
/// <see cref="ParkedOperationActionAck"/>. These tests never touch the
|
|
/// <c>SiteCalls</c> repository — central never mutates the mirror row.
|
|
/// </summary>
|
|
public class SiteCallRelayTests : TestKit
|
|
{
|
|
/// <summary>
|
|
/// A repository that fails every call — the relay path must NEVER touch the
|
|
/// <c>SiteCalls</c> table (central is not the source of truth), so any
|
|
/// invocation here is a test failure surfaced as an exception.
|
|
/// </summary>
|
|
private sealed class ThrowingRepository : ISiteCallAuditRepository
|
|
{
|
|
/// <inheritdoc />
|
|
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
|
|
throw new InvalidOperationException("relay must not write the SiteCalls row");
|
|
|
|
/// <inheritdoc />
|
|
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
|
|
throw new InvalidOperationException("relay must not read the SiteCalls row");
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<SiteCall>> QueryAsync(
|
|
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
|
|
throw new InvalidOperationException("relay must not query the SiteCalls table");
|
|
|
|
/// <inheritdoc />
|
|
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
|
throw new InvalidOperationException("relay must not purge");
|
|
|
|
/// <inheritdoc />
|
|
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
|
throw new InvalidOperationException("relay must not compute KPIs");
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
|
throw new InvalidOperationException("relay must not compute per-site KPIs");
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
|
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
|
throw new InvalidOperationException("relay must not compute per-node KPIs");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a <see cref="SiteCallAuditActor"/> with a throwing repository and a
|
|
/// short relay timeout, and registers <paramref name="centralComm"/> as the
|
|
/// central→site transport.
|
|
/// </summary>
|
|
private IActorRef CreateActor(IActorRef centralComm)
|
|
{
|
|
var options = new SiteCallAuditOptions { RelayTimeout = TimeSpan.FromMilliseconds(500) };
|
|
// Register row 61: stub provider in place of the deleted repository ctor.
|
|
var provider = TestSupport.StubRepositoryProvider.Build(new ThrowingRepository());
|
|
var actor = Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
|
|
provider,
|
|
options,
|
|
NullLogger<SiteCallAuditActor>.Instance)));
|
|
actor.Tell(new RegisterCentralCommunication(centralComm));
|
|
return actor;
|
|
}
|
|
|
|
/// <summary>Verifies that retrying a site call routes a RetryParkedOperation command to the owning site.</summary>
|
|
[Fact]
|
|
public void RetrySiteCall_RoutesRetryParkedOperation_ToOwningSite()
|
|
{
|
|
var central = CreateTestProbe();
|
|
var actor = CreateActor(central.Ref);
|
|
|
|
var id = Guid.NewGuid();
|
|
actor.Tell(new RetrySiteCallRequest("corr-1", id, "site-north"));
|
|
|
|
// The relay must wrap a RetryParkedOperation in a SiteEnvelope addressed
|
|
// to the owning site.
|
|
var envelope = central.ExpectMsg<SiteEnvelope>();
|
|
Assert.Equal("site-north", envelope.SiteId);
|
|
var relay = Assert.IsType<RetryParkedOperation>(envelope.Message);
|
|
Assert.Equal(id, relay.TrackedOperationId.Value);
|
|
|
|
// The site applies it and acks; the relay reports Applied.
|
|
central.Reply(new ParkedOperationActionAck(relay.CorrelationId, Applied: true));
|
|
|
|
var response = ExpectMsg<RetrySiteCallResponse>();
|
|
Assert.Equal("corr-1", response.CorrelationId);
|
|
Assert.Equal(SiteCallRelayOutcome.Applied, response.Outcome);
|
|
Assert.True(response.Success);
|
|
Assert.True(response.SiteReachable);
|
|
Assert.Null(response.ErrorMessage);
|
|
}
|
|
|
|
/// <summary>Verifies that discarding a site call routes a DiscardParkedOperation command to the owning site.</summary>
|
|
[Fact]
|
|
public void DiscardSiteCall_RoutesDiscardParkedOperation_ToOwningSite()
|
|
{
|
|
var central = CreateTestProbe();
|
|
var actor = CreateActor(central.Ref);
|
|
|
|
var id = Guid.NewGuid();
|
|
actor.Tell(new DiscardSiteCallRequest("corr-2", id, "site-south"));
|
|
|
|
var envelope = central.ExpectMsg<SiteEnvelope>();
|
|
Assert.Equal("site-south", envelope.SiteId);
|
|
var relay = Assert.IsType<DiscardParkedOperation>(envelope.Message);
|
|
Assert.Equal(id, relay.TrackedOperationId.Value);
|
|
|
|
central.Reply(new ParkedOperationActionAck(relay.CorrelationId, Applied: true));
|
|
|
|
var response = ExpectMsg<DiscardSiteCallResponse>();
|
|
Assert.Equal(SiteCallRelayOutcome.Applied, response.Outcome);
|
|
Assert.True(response.Success);
|
|
}
|
|
|
|
/// <summary>Verifies that a site reply of not applied is reported as the call not being parked.</summary>
|
|
[Fact]
|
|
public void RetrySiteCall_SiteRepliesNotApplied_ReportsNotParked()
|
|
{
|
|
var central = CreateTestProbe();
|
|
var actor = CreateActor(central.Ref);
|
|
|
|
actor.Tell(new RetrySiteCallRequest("corr-3", Guid.NewGuid(), "site-north"));
|
|
|
|
var envelope = central.ExpectMsg<SiteEnvelope>();
|
|
var relay = (RetryParkedOperation)envelope.Message;
|
|
// The site found nothing parked — a definitive answer, not a failure.
|
|
central.Reply(new ParkedOperationActionAck(relay.CorrelationId, Applied: false));
|
|
|
|
var response = ExpectMsg<RetrySiteCallResponse>();
|
|
Assert.Equal(SiteCallRelayOutcome.NotParked, response.Outcome);
|
|
Assert.False(response.Success);
|
|
Assert.True(response.SiteReachable);
|
|
}
|
|
|
|
/// <summary>Verifies that a site error reply is reported as a failed operation.</summary>
|
|
[Fact]
|
|
public void RetrySiteCall_SiteRepliesError_ReportsOperationFailed()
|
|
{
|
|
var central = CreateTestProbe();
|
|
var actor = CreateActor(central.Ref);
|
|
|
|
actor.Tell(new RetrySiteCallRequest("corr-4", Guid.NewGuid(), "site-north"));
|
|
|
|
var envelope = central.ExpectMsg<SiteEnvelope>();
|
|
var relay = (RetryParkedOperation)envelope.Message;
|
|
central.Reply(new ParkedOperationActionAck(
|
|
relay.CorrelationId, Applied: false, "Parked message handler not available"));
|
|
|
|
var response = ExpectMsg<RetrySiteCallResponse>();
|
|
Assert.Equal(SiteCallRelayOutcome.OperationFailed, response.Outcome);
|
|
Assert.False(response.Success);
|
|
// The site WAS reached — this is an operation failure, not unreachable.
|
|
Assert.True(response.SiteReachable);
|
|
Assert.NotNull(response.ErrorMessage);
|
|
}
|
|
|
|
/// <summary>Verifies that a retry to a site that never replies is reported as site unreachable.</summary>
|
|
[Fact]
|
|
public void RetrySiteCall_SiteNeverReplies_ReportsSiteUnreachable()
|
|
{
|
|
// A central comm probe that silently drops the relay — models an offline
|
|
// site / no route to the site: the Ask times out.
|
|
var central = CreateTestProbe();
|
|
var actor = CreateActor(central.Ref);
|
|
|
|
actor.Tell(new RetrySiteCallRequest("corr-5", Guid.NewGuid(), "site-offline"));
|
|
|
|
central.ExpectMsg<SiteEnvelope>();
|
|
// Probe does not reply — the relay Ask times out (RelayTimeout = 500ms).
|
|
|
|
var response = ExpectMsg<RetrySiteCallResponse>(TimeSpan.FromSeconds(3));
|
|
Assert.Equal(SiteCallRelayOutcome.SiteUnreachable, response.Outcome);
|
|
Assert.False(response.Success);
|
|
// The distinct unreachable signal the UI relies on.
|
|
Assert.False(response.SiteReachable);
|
|
Assert.NotNull(response.ErrorMessage);
|
|
}
|
|
|
|
/// <summary>Verifies that a discard to a site that never replies is reported as site unreachable.</summary>
|
|
[Fact]
|
|
public void DiscardSiteCall_SiteNeverReplies_ReportsSiteUnreachable()
|
|
{
|
|
var central = CreateTestProbe();
|
|
var actor = CreateActor(central.Ref);
|
|
|
|
actor.Tell(new DiscardSiteCallRequest("corr-6", Guid.NewGuid(), "site-offline"));
|
|
|
|
central.ExpectMsg<SiteEnvelope>();
|
|
|
|
var response = ExpectMsg<DiscardSiteCallResponse>(TimeSpan.FromSeconds(3));
|
|
Assert.Equal(SiteCallRelayOutcome.SiteUnreachable, response.Outcome);
|
|
Assert.False(response.SiteReachable);
|
|
}
|
|
|
|
/// <summary>Verifies that a retry issued before central communication is registered is reported as site unreachable.</summary>
|
|
[Fact]
|
|
public void RetrySiteCall_BeforeCentralCommunicationRegistered_ReportsSiteUnreachable()
|
|
{
|
|
// No RegisterCentralCommunication — the actor has no transport to reach
|
|
// any site, so the only honest answer is "unreachable".
|
|
var options = new SiteCallAuditOptions { RelayTimeout = TimeSpan.FromMilliseconds(500) };
|
|
var provider = TestSupport.StubRepositoryProvider.Build(new ThrowingRepository());
|
|
var actor = Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
|
|
provider,
|
|
options,
|
|
NullLogger<SiteCallAuditActor>.Instance)));
|
|
|
|
actor.Tell(new RetrySiteCallRequest("corr-7", Guid.NewGuid(), "site-north"));
|
|
|
|
var response = ExpectMsg<RetrySiteCallResponse>();
|
|
Assert.Equal(SiteCallRelayOutcome.SiteUnreachable, response.Outcome);
|
|
Assert.False(response.SiteReachable);
|
|
}
|
|
|
|
// ── Operator-identity audit on a successful relay ──
|
|
|
|
/// <summary>Captures the central direct-write audit events emitted by the relay.</summary>
|
|
private sealed class RecordingCentralAuditWriter : ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.ICentralAuditWriter
|
|
{
|
|
/// <summary>The central direct-write audit rows captured so far.</summary>
|
|
public List<AuditRowProjection.AuditRowValues> Events { get; } = new();
|
|
|
|
/// <summary>Records the audit event for later assertions.</summary>
|
|
/// <param name="evt">The audit event to record.</param>
|
|
/// <param name="ct">A token to cancel the operation.</param>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public Task WriteAsync(ZB.MOM.WW.Audit.AuditEvent evt, CancellationToken ct = default)
|
|
{
|
|
lock (Events)
|
|
{
|
|
Events.Add(evt.AsRow());
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A repository whose <see cref="GetAsync"/> returns one fixed row (so the relay
|
|
/// can read its Channel for the audit event) but still throws on every MUTATING
|
|
/// call — central never writes the mirror row on the relay path.
|
|
/// </summary>
|
|
private sealed class RowReturningRepository : ISiteCallAuditRepository
|
|
{
|
|
private readonly SiteCall _row;
|
|
/// <summary>Creates the repository around the fixed row it returns from <c>GetAsync</c>.</summary>
|
|
/// <param name="row">The site call row returned for its own tracked operation ID.</param>
|
|
public RowReturningRepository(SiteCall row) { _row = row; }
|
|
|
|
/// <inheritdoc />
|
|
public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
|
|
Task.FromResult<SiteCall?>(_row.TrackedOperationId == id ? _row : null);
|
|
|
|
/// <inheritdoc />
|
|
public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) =>
|
|
throw new InvalidOperationException("relay must not write the SiteCalls row");
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<SiteCall>> QueryAsync(
|
|
SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
|
|
throw new InvalidOperationException("relay must not query the SiteCalls table");
|
|
|
|
/// <inheritdoc />
|
|
public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default) =>
|
|
throw new InvalidOperationException("relay must not purge");
|
|
|
|
/// <inheritdoc />
|
|
public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
|
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
|
throw new InvalidOperationException("relay must not compute KPIs");
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
|
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
|
throw new InvalidOperationException("relay must not compute per-site KPIs");
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
|
|
DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
|
|
throw new InvalidOperationException("relay must not compute per-node KPIs");
|
|
}
|
|
|
|
private static SiteCall MakeRow(Guid id, string channel = "ApiOutbound") => new()
|
|
{
|
|
TrackedOperationId = new TrackedOperationId(id),
|
|
Channel = channel,
|
|
Target = "ERP.GetOrder",
|
|
SourceSite = "site-north",
|
|
SourceNode = "node-a",
|
|
Status = "Parked",
|
|
RetryCount = 3,
|
|
CreatedAtUtc = DateTime.UtcNow,
|
|
UpdatedAtUtc = DateTime.UtcNow,
|
|
IngestedAtUtc = DateTime.UtcNow,
|
|
};
|
|
|
|
private (IActorRef Actor, RecordingCentralAuditWriter Writer) CreateActorWithAudit(IActorRef centralComm, SiteCall row)
|
|
{
|
|
var writer = new RecordingCentralAuditWriter();
|
|
var options = new SiteCallAuditOptions { RelayTimeout = TimeSpan.FromMilliseconds(500) };
|
|
// The audit writer now reaches the actor through the provider (production resolves it
|
|
// the same way, from the root provider) rather than through a ctor parameter.
|
|
var provider = TestSupport.StubRepositoryProvider.Build(
|
|
new RowReturningRepository(row),
|
|
auditWriter: (ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.ICentralAuditWriter)writer);
|
|
var actor = Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
|
|
provider,
|
|
options,
|
|
NullLogger<SiteCallAuditActor>.Instance)));
|
|
actor.Tell(new RegisterCentralCommunication(centralComm));
|
|
return (actor, writer);
|
|
}
|
|
|
|
/// <summary>Verifies that a retry that the site applied emits a cached-resolve submitted audit row that records the operator.</summary>
|
|
[Fact]
|
|
public void RetrySiteCall_WhenApplied_EmitsCachedResolveSubmitted_WithOperator()
|
|
{
|
|
var central = CreateTestProbe();
|
|
var id = Guid.NewGuid();
|
|
var (actor, writer) = CreateActorWithAudit(central.Ref, MakeRow(id, channel: "ApiOutbound"));
|
|
|
|
actor.Tell(new RetrySiteCallRequest("corr-a", id, "site-north", RequestedBy: "jdoe"));
|
|
|
|
var envelope = central.ExpectMsg<SiteEnvelope>();
|
|
var relay = (RetryParkedOperation)envelope.Message;
|
|
central.Reply(new ParkedOperationActionAck(relay.CorrelationId, Applied: true));
|
|
|
|
ExpectMsg<RetrySiteCallResponse>(r => r.Success);
|
|
AwaitAssert(() =>
|
|
{
|
|
List<AuditRowProjection.AuditRowValues> events;
|
|
lock (writer.Events) { events = writer.Events.ToList(); }
|
|
Assert.Single(events);
|
|
var evt = events[0];
|
|
Assert.Equal(Commons.Types.Enums.AuditChannel.ApiOutbound, evt.Channel);
|
|
Assert.Equal(Commons.Types.Enums.AuditKind.CachedResolve, evt.Kind);
|
|
Assert.Equal(Commons.Types.Enums.AuditStatus.Submitted, evt.Status);
|
|
Assert.Equal("jdoe", evt.Actor);
|
|
Assert.Equal(id, evt.CorrelationId);
|
|
});
|
|
}
|
|
|
|
/// <summary>Verifies that a retry that the site did not apply emits no audit row.</summary>
|
|
[Fact]
|
|
public void RetrySiteCall_WhenNotApplied_EmitsNoAuditRow()
|
|
{
|
|
var central = CreateTestProbe();
|
|
var id = Guid.NewGuid();
|
|
var (actor, writer) = CreateActorWithAudit(central.Ref, MakeRow(id));
|
|
|
|
actor.Tell(new RetrySiteCallRequest("corr-b", id, "site-north", RequestedBy: "jdoe"));
|
|
|
|
var envelope = central.ExpectMsg<SiteEnvelope>();
|
|
var relay = (RetryParkedOperation)envelope.Message;
|
|
// Nothing was parked at the site — no state change, so no operator audit row.
|
|
central.Reply(new ParkedOperationActionAck(relay.CorrelationId, Applied: false));
|
|
|
|
ExpectMsg<RetrySiteCallResponse>(r => r.Outcome == SiteCallRelayOutcome.NotParked);
|
|
// Give any erroneous emission a chance to land, then assert none did.
|
|
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
lock (writer.Events) { Assert.Empty(writer.Events); }
|
|
}
|
|
|
|
/// <summary>Verifies that a discard that the site applied emits a cached-resolve discarded audit row that records the operator.</summary>
|
|
[Fact]
|
|
public void DiscardSiteCall_WhenApplied_EmitsCachedResolveDiscarded_WithOperator()
|
|
{
|
|
var central = CreateTestProbe();
|
|
var id = Guid.NewGuid();
|
|
var (actor, writer) = CreateActorWithAudit(central.Ref, MakeRow(id, channel: "DbOutbound"));
|
|
|
|
actor.Tell(new DiscardSiteCallRequest("corr-c", id, "site-north", RequestedBy: "jdoe"));
|
|
|
|
var envelope = central.ExpectMsg<SiteEnvelope>();
|
|
var relay = (DiscardParkedOperation)envelope.Message;
|
|
central.Reply(new ParkedOperationActionAck(relay.CorrelationId, Applied: true));
|
|
|
|
ExpectMsg<DiscardSiteCallResponse>(r => r.Success);
|
|
AwaitAssert(() =>
|
|
{
|
|
List<AuditRowProjection.AuditRowValues> events;
|
|
lock (writer.Events) { events = writer.Events.ToList(); }
|
|
Assert.Single(events);
|
|
var evt = events[0];
|
|
Assert.Equal(Commons.Types.Enums.AuditChannel.DbOutbound, evt.Channel);
|
|
Assert.Equal(Commons.Types.Enums.AuditStatus.Discarded, evt.Status);
|
|
Assert.Equal("jdoe", evt.Actor);
|
|
});
|
|
}
|
|
}
|