5d075f1374
Six adversarial-review findings in the central SQL/ingest layer. F1 (AuditLogRepository.InsertChunkAsync) — the set-based ingest declared each string parameter at its COLUMN width (Actor/Target 256, Action 64, Outcome 16, Category 32, SourceNode 64), so SqlClient truncated an over-long value at bind time and committed the mutilated row — silent, in an append-only store, with no PayloadTruncated flag — while the per-row and reconciliation paths sent the same value in full and let the server reject it with 2628. Bind at the value's own length instead; explicit SqlDbType is kept (it fixes the VALUES constructor's derived column types and datetime2 precision). Design: reject everywhere, truncate nowhere — matching today's per-row behaviour. F2 (SiteCallAuditRepository.UpsertAsync) — the single-statement upsert ran the monotonic UPDATE first and INSERTed only if nothing matched. Two writers racing the first packet of one TrackedOperationId (the cached dual-write and the reconciliation pull carry DIFFERENT lifecycle states) both matched nothing, and the loser then skipped its INSERT or swallowed a 2627 — dropping its Status/RetryCount/HttpStatus/TerminalAtUtc. Legs swapped to `IF NOT EXISTS … INSERT; UPDATE <monotonic>` — still one round trip, and the loser's UPDATE now lands on the winner's row. The duplicate-key catch re-runs the monotonic UPDATE for the same reason. Moved to raw SQL with explicitly-typed parameters so the intricate rank predicate exists in exactly one place (an untyped DateTime would bind as `datetime` and round the freshness tiebreaker). F3 (docs/plans/sql/*.sql) — filtered-index DDL failed with error 1934 under the documented `docker exec … sqlcmd` path, which defaults QUOTED_IDENTIFIER OFF; once IX_Notifications_Delivered exists, QI-OFF DML on Notifications fails too. All four scripts now open with `SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; GO` (own batch, so it is in force when the next batch parses), and the migration convention in Component-ConfigurationDatabase.md documents `sqlcmd -I`. Verified live: the pre-fix script fails 1934 without -I, the fixed one applies. F4 (SiteCallAuditActor) — the off-mailbox reconciliation/purge passes reuse the injected repository, so tests drove one DbContext from the pass and a mailbox handler concurrently. Serialized at the CALL via a private SerializedRepository wrapper applied only by the test constructors, rather than running the pass on-mailbox: production keeps its PipeTo shape untouched, and the existing "a blocked drain does not stall ingest/query/KPI" regression tests stay meaningful (they would have been invalidated by suspending the mailbox). F5 (AuditLogIngestActor) — when the batch failed because the 20 s IngestBudget expired, the per-row fallback reused the same expired token: N instant failures, N counter bumps, zero accepted. The fallback now gets a fresh 5 s budget (inside the 30 s outer Ask), and a blown budget bumps the failure counter ONCE for the batch instead of once per row. F6 (NotificationOutboxRepository.UpdateAsync) — ExecuteUpdate's row count was discarded, so an operator Retry/Discard of a notification the retention purge had already deleted reported success (the pre-ExecuteUpdate code threw DbUpdateConcurrencyException). UpdateAsync now returns whether a row matched; the operator one-shots answer "notification not found" and emit no audit row for the action that did not happen, while the dispatcher logs a warning (its delivery already happened; nothing to retry). GetByIdAsync switched to AsNoTracking since the write is out-of-band. Tests: 5 new SQL-backed regressions (over-long Target rejected on both paths + boundary round-trip; concurrent first-write and already-created-by-another-writer upserts; vanished-row UpdateAsync), a token-identity pin on the ingest fallback, a repository-concurrency detector for the SiteCallAudit passes, and vanished-row operator-path tests. The F1/F2/F4 regressions were each confirmed failing against the pre-fix code. Suites: ConfigurationDatabase 369, AuditLog 378, SiteCallAudit 66, NotificationOutbox 152 — all green, solution builds with 0 warnings.
587 lines
24 KiB
C#
587 lines
24 KiB
C#
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
using NSubstitute.ExceptionExtensions;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
|
|
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
|
|
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.TestSupport;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests;
|
|
|
|
/// <summary>
|
|
/// Task 15: Tests for the <see cref="NotificationOutboxActor"/> query surface — the
|
|
/// paginated outbox query, single-notification status query, manual retry and discard
|
|
/// of parked notifications, and the KPI snapshot.
|
|
/// </summary>
|
|
public class NotificationOutboxActorQueryTests : TestKit
|
|
{
|
|
private readonly INotificationOutboxRepository _repository =
|
|
OutboxRepositorySubstitute.Healthy();
|
|
|
|
private IServiceProvider BuildServiceProvider()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddScoped(_ => _repository);
|
|
return services.BuildServiceProvider();
|
|
}
|
|
|
|
private IActorRef CreateActor(NotificationOutboxOptions? options = null)
|
|
{
|
|
return Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
|
|
BuildServiceProvider(),
|
|
// A long dispatch interval keeps the dispatch loop from interfering with these tests.
|
|
options ?? new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
|
|
new NoOpCentralAuditWriter(),
|
|
NullLogger<NotificationOutboxActor>.Instance)));
|
|
}
|
|
|
|
private static Notification MakeNotification(
|
|
NotificationStatus status = NotificationStatus.Pending,
|
|
DateTimeOffset? createdAt = null,
|
|
int retryCount = 0,
|
|
string? lastError = null,
|
|
DateTimeOffset? deliveredAt = null)
|
|
{
|
|
return new Notification(
|
|
Guid.NewGuid().ToString(), NotificationType.Email, "ops-team", "Subject", "Body", "site-1")
|
|
{
|
|
Status = status,
|
|
CreatedAt = createdAt ?? DateTimeOffset.UtcNow,
|
|
RetryCount = retryCount,
|
|
LastError = lastError,
|
|
DeliveredAt = deliveredAt,
|
|
SourceInstanceId = "instance-42",
|
|
};
|
|
}
|
|
|
|
[Fact]
|
|
public void Query_PassesFilterFromRequest_AndMapsRowsToSummaries()
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var staleRow = MakeNotification(
|
|
status: NotificationStatus.Pending, createdAt: now - TimeSpan.FromHours(1));
|
|
var freshRow = MakeNotification(
|
|
status: NotificationStatus.Pending, createdAt: now);
|
|
_repository.QueryAsync(
|
|
Arg.Any<NotificationOutboxFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(((IReadOnlyList<Notification>)new[] { staleRow, freshRow }, 2));
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(
|
|
new NotificationOutboxQueryRequest(
|
|
CorrelationId: "corr-1",
|
|
StatusFilter: null,
|
|
TypeFilter: null,
|
|
SourceSiteFilter: "site-1",
|
|
ListNameFilter: "ops-team",
|
|
StuckOnly: false,
|
|
SubjectKeyword: "tank",
|
|
From: null,
|
|
To: null,
|
|
PageNumber: 2,
|
|
PageSize: 25),
|
|
TestActor);
|
|
|
|
var response = ExpectMsg<NotificationOutboxQueryResponse>();
|
|
Assert.Equal("corr-1", response.CorrelationId);
|
|
Assert.True(response.Success);
|
|
Assert.Null(response.ErrorMessage);
|
|
Assert.Equal(2, response.TotalCount);
|
|
Assert.Equal(2, response.Notifications.Count);
|
|
|
|
_repository.Received(1).QueryAsync(
|
|
Arg.Is<NotificationOutboxFilter>(f =>
|
|
f.SourceSiteId == "site-1" &&
|
|
f.ListName == "ops-team" &&
|
|
f.SubjectKeyword == "tank" &&
|
|
f.Status == null &&
|
|
f.Type == null &&
|
|
f.StuckOnly == false),
|
|
2, 25, Arg.Any<CancellationToken>());
|
|
|
|
// IsStuck: the hour-old Pending row is stuck, the just-created one is not.
|
|
var staleSummary = response.Notifications.Single(s => s.NotificationId == staleRow.NotificationId);
|
|
var freshSummary = response.Notifications.Single(s => s.NotificationId == freshRow.NotificationId);
|
|
Assert.True(staleSummary.IsStuck);
|
|
Assert.False(freshSummary.IsStuck);
|
|
Assert.Equal("Pending", staleSummary.Status);
|
|
Assert.Equal("Email", staleSummary.Type);
|
|
Assert.Equal("site-1", staleSummary.SourceSiteId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Query_WithStatusFilterString_ParsesToEnumOnFilter()
|
|
{
|
|
_repository.QueryAsync(
|
|
Arg.Any<NotificationOutboxFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(((IReadOnlyList<Notification>)Array.Empty<Notification>(), 0));
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(
|
|
new NotificationOutboxQueryRequest(
|
|
CorrelationId: "corr-2",
|
|
StatusFilter: "Parked",
|
|
TypeFilter: "Email",
|
|
SourceSiteFilter: null,
|
|
ListNameFilter: null,
|
|
StuckOnly: true,
|
|
SubjectKeyword: null,
|
|
From: null,
|
|
To: null,
|
|
PageNumber: 1,
|
|
PageSize: 50),
|
|
TestActor);
|
|
|
|
ExpectMsg<NotificationOutboxQueryResponse>();
|
|
|
|
_repository.Received(1).QueryAsync(
|
|
Arg.Is<NotificationOutboxFilter>(f =>
|
|
f.Status == NotificationStatus.Parked &&
|
|
f.Type == NotificationType.Email &&
|
|
f.StuckOnly == true &&
|
|
f.StuckCutoff != null),
|
|
1, 50, Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public void Query_PassesSourceNodeFilter_AndProjectsSourceNodeOntoSummary()
|
|
{
|
|
// Task 16: the Notifications page's new Node filter input pushes a
|
|
// value into NotificationOutboxQueryRequest.SourceNodeFilter; the actor
|
|
// must thread it onto NotificationOutboxFilter.SourceNode AND mirror
|
|
// the row's SourceNode column onto the response summaries.
|
|
var row = MakeNotification(status: NotificationStatus.Pending);
|
|
row.SourceNode = "central-a";
|
|
_repository.QueryAsync(
|
|
Arg.Any<NotificationOutboxFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(((IReadOnlyList<Notification>)new[] { row }, 1));
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(
|
|
new NotificationOutboxQueryRequest(
|
|
CorrelationId: "corr-node",
|
|
StatusFilter: null,
|
|
TypeFilter: null,
|
|
SourceSiteFilter: null,
|
|
ListNameFilter: null,
|
|
StuckOnly: false,
|
|
SubjectKeyword: null,
|
|
From: null,
|
|
To: null,
|
|
PageNumber: 1,
|
|
PageSize: 50,
|
|
SourceNodeFilter: "central-a"),
|
|
TestActor);
|
|
|
|
var response = ExpectMsg<NotificationOutboxQueryResponse>();
|
|
Assert.True(response.Success);
|
|
Assert.Equal("central-a", response.Notifications.Single().SourceNode);
|
|
|
|
_repository.Received(1).QueryAsync(
|
|
Arg.Is<NotificationOutboxFilter>(f => f.SourceNode == "central-a"),
|
|
1, 50, Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public void Query_RepositoryThrows_RepliesFailureWithEmptyList()
|
|
{
|
|
_repository.QueryAsync(
|
|
Arg.Any<NotificationOutboxFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.ThrowsAsync(new InvalidOperationException("db down"));
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(
|
|
new NotificationOutboxQueryRequest(
|
|
"corr-err", null, null, null, null, false, null, null, null, 1, 50),
|
|
TestActor);
|
|
|
|
var response = ExpectMsg<NotificationOutboxQueryResponse>();
|
|
Assert.Equal("corr-err", response.CorrelationId);
|
|
Assert.False(response.Success);
|
|
Assert.NotNull(response.ErrorMessage);
|
|
Assert.Contains("db down", response.ErrorMessage);
|
|
Assert.Empty(response.Notifications);
|
|
}
|
|
|
|
[Fact]
|
|
public void StatusQuery_Found_RepliesWithRowDetail()
|
|
{
|
|
var row = MakeNotification(
|
|
status: NotificationStatus.Retrying, retryCount: 3, lastError: "smtp timeout");
|
|
_repository.GetByIdAsync(row.NotificationId, Arg.Any<CancellationToken>()).Returns(row);
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(new NotificationStatusQuery("corr-3", row.NotificationId), TestActor);
|
|
|
|
var response = ExpectMsg<NotificationStatusResponse>();
|
|
Assert.Equal("corr-3", response.CorrelationId);
|
|
Assert.True(response.Found);
|
|
Assert.Equal("Retrying", response.Status);
|
|
Assert.Equal(3, response.RetryCount);
|
|
Assert.Equal("smtp timeout", response.LastError);
|
|
}
|
|
|
|
[Fact]
|
|
public void StatusQuery_NotFound_RepliesFoundFalse()
|
|
{
|
|
_repository.GetByIdAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns((Notification?)null);
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(new NotificationStatusQuery("corr-4", "missing-id"), TestActor);
|
|
|
|
var response = ExpectMsg<NotificationStatusResponse>();
|
|
Assert.Equal("corr-4", response.CorrelationId);
|
|
Assert.False(response.Found);
|
|
Assert.Equal(string.Empty, response.Status);
|
|
Assert.Equal(0, response.RetryCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Retry_ParkedNotification_ResetsToPending_AndSucceeds()
|
|
{
|
|
var row = MakeNotification(status: NotificationStatus.Parked, retryCount: 10, lastError: "gave up");
|
|
row.NextAttemptAt = DateTimeOffset.UtcNow;
|
|
_repository.GetByIdAsync(row.NotificationId, Arg.Any<CancellationToken>()).Returns(row);
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(new RetryNotificationRequest("corr-5", row.NotificationId), TestActor);
|
|
|
|
var response = ExpectMsg<RetryNotificationResponse>();
|
|
Assert.Equal("corr-5", response.CorrelationId);
|
|
Assert.True(response.Success);
|
|
Assert.Null(response.ErrorMessage);
|
|
|
|
_repository.Received(1).UpdateAsync(
|
|
Arg.Is<Notification>(n =>
|
|
n.NotificationId == row.NotificationId &&
|
|
n.Status == NotificationStatus.Pending &&
|
|
n.RetryCount == 0 &&
|
|
n.NextAttemptAt == null &&
|
|
n.LastError == null),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public void Retry_NonParkedNotification_Fails()
|
|
{
|
|
var row = MakeNotification(status: NotificationStatus.Delivered);
|
|
_repository.GetByIdAsync(row.NotificationId, Arg.Any<CancellationToken>()).Returns(row);
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(new RetryNotificationRequest("corr-6", row.NotificationId), TestActor);
|
|
|
|
var response = ExpectMsg<RetryNotificationResponse>();
|
|
Assert.False(response.Success);
|
|
Assert.NotNull(response.ErrorMessage);
|
|
_repository.DidNotReceive().UpdateAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public void Retry_MissingNotification_Fails()
|
|
{
|
|
_repository.GetByIdAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns((Notification?)null);
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(new RetryNotificationRequest("corr-7", "missing-id"), TestActor);
|
|
|
|
var response = ExpectMsg<RetryNotificationResponse>();
|
|
Assert.False(response.Success);
|
|
Assert.NotNull(response.ErrorMessage);
|
|
Assert.Contains("not found", response.ErrorMessage);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The row is read successfully but the retention purge deletes it before the
|
|
/// write lands, so the targeted UPDATE matches nothing. The operator must be
|
|
/// told the notification is gone — reporting a re-queue that never happened
|
|
/// is what the ExecuteUpdate rewrite silently introduced (the predecessor
|
|
/// threw DbUpdateConcurrencyException here).
|
|
/// </summary>
|
|
[Fact]
|
|
public void Retry_RowPurgedBeforeWrite_RepliesNotFound()
|
|
{
|
|
var row = MakeNotification(status: NotificationStatus.Parked, retryCount: 10, lastError: "gave up");
|
|
_repository.GetByIdAsync(row.NotificationId, Arg.Any<CancellationToken>()).Returns(row);
|
|
_repository.UpdateAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>()).Returns(false);
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(new RetryNotificationRequest("corr-vanished-retry", row.NotificationId), TestActor);
|
|
|
|
var response = ExpectMsg<RetryNotificationResponse>();
|
|
Assert.Equal("corr-vanished-retry", response.CorrelationId);
|
|
Assert.False(response.Success);
|
|
Assert.NotNull(response.ErrorMessage);
|
|
Assert.Contains("not found", response.ErrorMessage);
|
|
}
|
|
|
|
/// <summary>Discard half of <see cref="Retry_RowPurgedBeforeWrite_RepliesNotFound"/>.</summary>
|
|
[Fact]
|
|
public void Discard_RowPurgedBeforeWrite_RepliesNotFound()
|
|
{
|
|
var row = MakeNotification(status: NotificationStatus.Parked);
|
|
_repository.GetByIdAsync(row.NotificationId, Arg.Any<CancellationToken>()).Returns(row);
|
|
_repository.UpdateAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>()).Returns(false);
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(new DiscardNotificationRequest("corr-vanished-discard", row.NotificationId), TestActor);
|
|
|
|
var response = ExpectMsg<DiscardNotificationResponse>();
|
|
Assert.Equal("corr-vanished-discard", response.CorrelationId);
|
|
Assert.False(response.Success);
|
|
Assert.NotNull(response.ErrorMessage);
|
|
Assert.Contains("not found", response.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public void Discard_ParkedNotification_MarksDiscarded_AndSucceeds()
|
|
{
|
|
var row = MakeNotification(status: NotificationStatus.Parked);
|
|
_repository.GetByIdAsync(row.NotificationId, Arg.Any<CancellationToken>()).Returns(row);
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(new DiscardNotificationRequest("corr-8", row.NotificationId), TestActor);
|
|
|
|
var response = ExpectMsg<DiscardNotificationResponse>();
|
|
Assert.Equal("corr-8", response.CorrelationId);
|
|
Assert.True(response.Success);
|
|
|
|
_repository.Received(1).UpdateAsync(
|
|
Arg.Is<Notification>(n => n.Status == NotificationStatus.Discarded),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public void Discard_NonParkedNotification_Fails()
|
|
{
|
|
var row = MakeNotification(status: NotificationStatus.Pending);
|
|
_repository.GetByIdAsync(row.NotificationId, Arg.Any<CancellationToken>()).Returns(row);
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(new DiscardNotificationRequest("corr-9", row.NotificationId), TestActor);
|
|
|
|
var response = ExpectMsg<DiscardNotificationResponse>();
|
|
Assert.False(response.Success);
|
|
Assert.NotNull(response.ErrorMessage);
|
|
_repository.DidNotReceive().UpdateAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public void Discard_MissingNotification_Fails()
|
|
{
|
|
_repository.GetByIdAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns((Notification?)null);
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(new DiscardNotificationRequest("corr-10", "missing-id"), TestActor);
|
|
|
|
var response = ExpectMsg<DiscardNotificationResponse>();
|
|
Assert.False(response.Success);
|
|
Assert.Contains("not found", response.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public void DetailRequest_KnownId_ReturnsFullDetail_WithBodyAndResolvedTargets()
|
|
{
|
|
var row = MakeNotification(
|
|
status: NotificationStatus.Delivered, retryCount: 2, lastError: "transient blip");
|
|
row.Body = "Tank-7 has exceeded its high-level setpoint.";
|
|
row.ResolvedTargets = "[\"ops@example.com\",\"oncall@example.com\"]";
|
|
row.TypeData = "{\"priority\":\"high\"}";
|
|
row.SourceScript = "HighLevelAlarm.csx";
|
|
row.SourceNode = "node-a";
|
|
row.SiteEnqueuedAt = DateTimeOffset.UtcNow.AddMinutes(-5);
|
|
row.DeliveredAt = DateTimeOffset.UtcNow;
|
|
_repository.GetByIdAsync(row.NotificationId, Arg.Any<CancellationToken>()).Returns(row);
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(new NotificationDetailRequest("corr-d1", row.NotificationId), TestActor);
|
|
|
|
var response = ExpectMsg<NotificationDetailResponse>();
|
|
Assert.Equal("corr-d1", response.CorrelationId);
|
|
Assert.True(response.Success);
|
|
Assert.Null(response.ErrorMessage);
|
|
Assert.NotNull(response.Detail);
|
|
var detail = response.Detail!;
|
|
Assert.Equal(row.NotificationId, detail.NotificationId);
|
|
Assert.Equal("Email", detail.Type);
|
|
Assert.Equal("Delivered", detail.Status);
|
|
Assert.Equal("Tank-7 has exceeded its high-level setpoint.", detail.Body);
|
|
Assert.Equal("[\"ops@example.com\",\"oncall@example.com\"]", detail.ResolvedTargets);
|
|
Assert.Equal("{\"priority\":\"high\"}", detail.TypeData);
|
|
Assert.Equal("HighLevelAlarm.csx", detail.SourceScript);
|
|
Assert.Equal("instance-42", detail.SourceInstanceId);
|
|
Assert.Equal(2, detail.RetryCount);
|
|
Assert.Equal("transient blip", detail.LastError);
|
|
// SourceNode flows through the detail projection so the report detail
|
|
// modal binds uniformly to the detail record (was previously read off
|
|
// the summary).
|
|
Assert.Equal("node-a", detail.SourceNode);
|
|
}
|
|
|
|
[Fact]
|
|
public void DetailRequest_UnknownId_ReturnsNotFound()
|
|
{
|
|
_repository.GetByIdAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns((Notification?)null);
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(new NotificationDetailRequest("corr-d2", "missing-id"), TestActor);
|
|
|
|
var response = ExpectMsg<NotificationDetailResponse>();
|
|
Assert.Equal("corr-d2", response.CorrelationId);
|
|
Assert.False(response.Success);
|
|
Assert.Null(response.Detail);
|
|
Assert.NotNull(response.ErrorMessage);
|
|
Assert.Contains("not found", response.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public void KpiRequest_ComputesKpis_AndMapsSnapshot()
|
|
{
|
|
var snapshot = new NotificationKpiSnapshot(
|
|
QueueDepth: 7,
|
|
StuckCount: 2,
|
|
ParkedCount: 3,
|
|
DeliveredLastInterval: 12,
|
|
OldestPendingAge: TimeSpan.FromMinutes(4));
|
|
_repository.ComputeKpisAsync(
|
|
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
|
.Returns(snapshot);
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(new NotificationKpiRequest("corr-11"), TestActor);
|
|
|
|
var response = ExpectMsg<NotificationKpiResponse>();
|
|
Assert.Equal("corr-11", response.CorrelationId);
|
|
Assert.True(response.Success);
|
|
Assert.Null(response.ErrorMessage);
|
|
Assert.Equal(7, response.QueueDepth);
|
|
Assert.Equal(2, response.StuckCount);
|
|
Assert.Equal(3, response.ParkedCount);
|
|
Assert.Equal(12, response.DeliveredLastInterval);
|
|
Assert.Equal(TimeSpan.FromMinutes(4), response.OldestPendingAge);
|
|
|
|
_repository.Received(1).ComputeKpisAsync(
|
|
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public void KpiRequest_RepositoryThrows_RepliesFailureResponse()
|
|
{
|
|
_repository.ComputeKpisAsync(
|
|
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
|
.ThrowsAsync(new InvalidOperationException("kpi db down"));
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(new NotificationKpiRequest("corr-12"), TestActor);
|
|
|
|
// A repository fault yields a failure NotificationKpiResponse, not a Status.Failure.
|
|
var response = ExpectMsg<NotificationKpiResponse>();
|
|
Assert.Equal("corr-12", response.CorrelationId);
|
|
Assert.False(response.Success);
|
|
Assert.NotNull(response.ErrorMessage);
|
|
Assert.Contains("kpi db down", response.ErrorMessage);
|
|
Assert.Equal(0, response.QueueDepth);
|
|
Assert.Equal(0, response.StuckCount);
|
|
Assert.Equal(0, response.ParkedCount);
|
|
Assert.Equal(0, response.DeliveredLastInterval);
|
|
Assert.Null(response.OldestPendingAge);
|
|
}
|
|
|
|
[Fact]
|
|
public void PerSiteKpiRequest_RepliesWithPerSiteSnapshots()
|
|
{
|
|
_repository.ComputePerSiteKpisAsync(
|
|
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
|
.Returns(new List<SiteNotificationKpiSnapshot>
|
|
{
|
|
new("plant-a", 4, 1, 0, 9, TimeSpan.FromMinutes(7)),
|
|
});
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(new PerSiteNotificationKpiRequest("corr-ps"), TestActor);
|
|
|
|
var response = ExpectMsg<PerSiteNotificationKpiResponse>();
|
|
Assert.True(response.Success);
|
|
Assert.Null(response.ErrorMessage);
|
|
Assert.Equal("corr-ps", response.CorrelationId);
|
|
Assert.Single(response.Sites);
|
|
Assert.Equal("plant-a", response.Sites[0].SourceSiteId);
|
|
|
|
_repository.Received(1).ComputePerSiteKpisAsync(
|
|
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public void PerSiteKpiRequest_RepositoryFault_RepliesUnsuccessful()
|
|
{
|
|
_repository.ComputePerSiteKpisAsync(
|
|
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
|
.ThrowsAsync(new InvalidOperationException("db down"));
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(new PerSiteNotificationKpiRequest("corr-ps"), TestActor);
|
|
|
|
var response = ExpectMsg<PerSiteNotificationKpiResponse>();
|
|
Assert.False(response.Success);
|
|
Assert.Equal("corr-ps", response.CorrelationId);
|
|
Assert.NotNull(response.ErrorMessage);
|
|
Assert.Contains("db down", response.ErrorMessage);
|
|
Assert.Empty(response.Sites);
|
|
}
|
|
|
|
// ── Per-node KPI (T6: M5.2 per-node stuck-count KPIs) ──────────────────
|
|
|
|
[Fact]
|
|
public void PerNodeKpiRequest_RepliesWithPerNodeSnapshots()
|
|
{
|
|
_repository.ComputePerNodeKpisAsync(
|
|
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
|
.Returns(new List<NodeNotificationKpiSnapshot>
|
|
{
|
|
new("node-a", QueueDepth: 3, StuckCount: 1, ParkedCount: 0,
|
|
DeliveredLastInterval: 5, OldestPendingAge: TimeSpan.FromMinutes(12)),
|
|
});
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(new PerNodeNotificationKpiRequest("corr-pn"), TestActor);
|
|
|
|
var response = ExpectMsg<PerNodeNotificationKpiResponse>();
|
|
Assert.True(response.Success);
|
|
Assert.Null(response.ErrorMessage);
|
|
Assert.Equal("corr-pn", response.CorrelationId);
|
|
Assert.Single(response.Nodes);
|
|
Assert.Equal("node-a", response.Nodes[0].SourceNode);
|
|
Assert.Equal(1, response.Nodes[0].StuckCount);
|
|
|
|
_repository.Received(1).ComputePerNodeKpisAsync(
|
|
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public void PerNodeKpiRequest_RepositoryFault_RepliesUnsuccessful()
|
|
{
|
|
_repository.ComputePerNodeKpisAsync(
|
|
Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
|
.ThrowsAsync(new InvalidOperationException("node-kpi db down"));
|
|
var actor = CreateActor();
|
|
|
|
actor.Tell(new PerNodeNotificationKpiRequest("corr-pn"), TestActor);
|
|
|
|
var response = ExpectMsg<PerNodeNotificationKpiResponse>();
|
|
Assert.False(response.Success);
|
|
Assert.Equal("corr-pn", response.CorrelationId);
|
|
Assert.NotNull(response.ErrorMessage);
|
|
Assert.Contains("node-kpi db down", response.ErrorMessage);
|
|
Assert.Empty(response.Nodes);
|
|
}
|
|
}
|