Merge branch 'feature/notification-detail-body-recipient': detail modal shows body + recipients
The /notifications/report detail modal showed only NotificationSummary fields. Added a NotificationDetailRequest query (mirrors the existing outbox query plumbing — Commons contract + NotificationOutboxActor handler + CommunicationService method) that fetches the full Notification entity. The modal now fetches on open and renders the message Body (preformatted plain text, never MarkupString) and resolved recipients (ResolvedTargets, with a list-name fallback note when not yet resolved). 6 new tests.
This commit is contained in:
@@ -268,6 +268,64 @@
|
|||||||
<dd class="col-sm-9 text-danger">@d.LastError</dd>
|
<dd class="col-sm-9 text-danger">@d.LastError</dd>
|
||||||
}
|
}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
@* ── Recipients ── *@
|
||||||
|
<hr />
|
||||||
|
<h6 class="mb-2">Recipients</h6>
|
||||||
|
@if (_detailLoading)
|
||||||
|
{
|
||||||
|
<div class="text-muted small">
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||||
|
Loading details…
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_detailError != null)
|
||||||
|
{
|
||||||
|
<div class="text-danger small">@_detailError</div>
|
||||||
|
}
|
||||||
|
else if (_detail != null)
|
||||||
|
{
|
||||||
|
var recipients = ParseRecipients(_detail.ResolvedTargets);
|
||||||
|
if (recipients.Count > 0)
|
||||||
|
{
|
||||||
|
<ul class="mb-0">
|
||||||
|
@foreach (var recipient in recipients)
|
||||||
|
{
|
||||||
|
<li>@recipient</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="text-muted small">
|
||||||
|
Not yet resolved — recipients are resolved from list
|
||||||
|
"@d.ListName" at delivery time.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Body ── *@
|
||||||
|
<hr />
|
||||||
|
<h6 class="mb-2">Message body</h6>
|
||||||
|
@if (_detailLoading)
|
||||||
|
{
|
||||||
|
<div class="text-muted small">
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||||
|
Loading details…
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_detailError != null)
|
||||||
|
{
|
||||||
|
<div class="text-danger small">@_detailError</div>
|
||||||
|
}
|
||||||
|
else if (_detail != null)
|
||||||
|
{
|
||||||
|
@* Email bodies are plain text (design: BCC delivery, plain text).
|
||||||
|
Rendered as preformatted text — never as a MarkupString, which
|
||||||
|
would be an XSS vector. *@
|
||||||
|
<pre class="border rounded bg-light p-2 mb-0"
|
||||||
|
style="max-height: 320px; overflow: auto; white-space: pre-wrap; word-break: break-word;">@_detail.Body</pre>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@if (d.Status == "Parked")
|
@if (d.Status == "Parked")
|
||||||
@@ -304,6 +362,9 @@
|
|||||||
|
|
||||||
// Row detail modal
|
// Row detail modal
|
||||||
private NotificationSummary? _detailNotification;
|
private NotificationSummary? _detailNotification;
|
||||||
|
private NotificationDetail? _detail;
|
||||||
|
private bool _detailLoading;
|
||||||
|
private string? _detailError;
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
private string _statusFilter = string.Empty;
|
private string _statusFilter = string.Empty;
|
||||||
@@ -440,9 +501,80 @@
|
|||||||
_actionInProgress = false;
|
_actionInProgress = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ShowDetail(NotificationSummary n) => _detailNotification = n;
|
private async Task ShowDetail(NotificationSummary n)
|
||||||
|
{
|
||||||
|
// The summary fields render immediately; Body + recipients fill in once the
|
||||||
|
// full-detail fetch completes.
|
||||||
|
_detailNotification = n;
|
||||||
|
_detail = null;
|
||||||
|
_detailError = null;
|
||||||
|
_detailLoading = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
private void CloseDetail() => _detailNotification = null;
|
try
|
||||||
|
{
|
||||||
|
var response = await CommunicationService.GetNotificationDetailAsync(
|
||||||
|
new NotificationDetailRequest(Guid.NewGuid().ToString("N"), n.NotificationId));
|
||||||
|
if (response.Success && response.Detail != null)
|
||||||
|
{
|
||||||
|
_detail = response.Detail;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_detailError = response.ErrorMessage ?? "Failed to load notification detail.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_detailError = $"Failed to load notification detail: {ex.Message}";
|
||||||
|
}
|
||||||
|
_detailLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseDetail()
|
||||||
|
{
|
||||||
|
_detailNotification = null;
|
||||||
|
_detail = null;
|
||||||
|
_detailError = null;
|
||||||
|
_detailLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort parse of <c>ResolvedTargets</c> into individual recipient addresses.
|
||||||
|
/// The field may be a JSON string array, or a comma/semicolon-separated string.
|
||||||
|
/// Returns an empty list when null/empty.
|
||||||
|
/// </summary>
|
||||||
|
private static List<string> ParseRecipients(string? resolvedTargets)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(resolvedTargets))
|
||||||
|
{
|
||||||
|
return new List<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = resolvedTargets.Trim();
|
||||||
|
if (trimmed.StartsWith('['))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parsed = System.Text.Json.JsonSerializer.Deserialize<List<string>>(trimmed);
|
||||||
|
if (parsed != null)
|
||||||
|
{
|
||||||
|
return parsed
|
||||||
|
.Where(r => !string.IsNullOrWhiteSpace(r))
|
||||||
|
.Select(r => r.Trim())
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (System.Text.Json.JsonException)
|
||||||
|
{
|
||||||
|
// Not valid JSON — fall through to the delimiter-split path.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task RetryFromDetail(NotificationSummary n)
|
private async Task RetryFromDetail(NotificationSummary n)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -76,6 +76,49 @@ public record DiscardNotificationResponse(
|
|||||||
bool Success,
|
bool Success,
|
||||||
string? ErrorMessage);
|
string? ErrorMessage);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Outbox UI -> Central: request for the full detail of a single notification
|
||||||
|
/// (including Body and resolved recipients), for the report detail modal.
|
||||||
|
/// </summary>
|
||||||
|
public record NotificationDetailRequest(
|
||||||
|
string CorrelationId,
|
||||||
|
string NotificationId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central -> Outbox UI: full detail for one notification. On a repository fault or
|
||||||
|
/// missing row, Success is false / Detail is null and ErrorMessage carries the cause.
|
||||||
|
/// </summary>
|
||||||
|
public record NotificationDetailResponse(
|
||||||
|
string CorrelationId,
|
||||||
|
bool Success,
|
||||||
|
string? ErrorMessage,
|
||||||
|
NotificationDetail? Detail);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full notification detail for the report detail modal — everything in the grid's
|
||||||
|
/// NotificationSummary plus Body, ResolvedTargets (recipients), TypeData, SourceScript,
|
||||||
|
/// and the additional lifecycle timestamps.
|
||||||
|
/// </summary>
|
||||||
|
public record NotificationDetail(
|
||||||
|
string NotificationId,
|
||||||
|
string Type,
|
||||||
|
string ListName,
|
||||||
|
string Subject,
|
||||||
|
string Body,
|
||||||
|
string Status,
|
||||||
|
int RetryCount,
|
||||||
|
string? LastError,
|
||||||
|
string? ResolvedTargets,
|
||||||
|
string? TypeData,
|
||||||
|
string SourceSiteId,
|
||||||
|
string? SourceInstanceId,
|
||||||
|
string? SourceScript,
|
||||||
|
DateTimeOffset SiteEnqueuedAt,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
DateTimeOffset? LastAttemptAt,
|
||||||
|
DateTimeOffset? NextAttemptAt,
|
||||||
|
DateTimeOffset? DeliveredAt);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Outbox UI -> Central: request for the notification outbox KPI summary.
|
/// Outbox UI -> Central: request for the notification outbox KPI summary.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -275,6 +275,13 @@ public class CommunicationService
|
|||||||
request, _options.QueryTimeout, cancellationToken);
|
request, _options.QueryTimeout, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<NotificationDetailResponse> GetNotificationDetailAsync(
|
||||||
|
NotificationDetailRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetNotificationOutbox().Ask<NotificationDetailResponse>(
|
||||||
|
request, _options.QueryTimeout, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<NotificationKpiResponse> GetNotificationKpisAsync(
|
public async Task<NotificationKpiResponse> GetNotificationKpisAsync(
|
||||||
NotificationKpiRequest request, CancellationToken cancellationToken = default)
|
NotificationKpiRequest request, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
Receive<InternalMessages.PurgeComplete>(_ => { });
|
Receive<InternalMessages.PurgeComplete>(_ => { });
|
||||||
Receive<NotificationOutboxQueryRequest>(HandleQuery);
|
Receive<NotificationOutboxQueryRequest>(HandleQuery);
|
||||||
Receive<NotificationStatusQuery>(HandleStatusQuery);
|
Receive<NotificationStatusQuery>(HandleStatusQuery);
|
||||||
|
Receive<NotificationDetailRequest>(HandleDetailRequest);
|
||||||
Receive<RetryNotificationRequest>(HandleRetry);
|
Receive<RetryNotificationRequest>(HandleRetry);
|
||||||
Receive<DiscardNotificationRequest>(HandleDiscard);
|
Receive<DiscardNotificationRequest>(HandleDiscard);
|
||||||
Receive<NotificationKpiRequest>(HandleKpiRequest);
|
Receive<NotificationKpiRequest>(HandleKpiRequest);
|
||||||
@@ -674,6 +675,59 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
DeliveredAt: notification.DeliveredAt);
|
DeliveredAt: notification.DeliveredAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles a full-detail query for a single notification — backs the report detail
|
||||||
|
/// modal, which needs the Body and resolved recipients that the grid summary omits.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleDetailRequest(NotificationDetailRequest request)
|
||||||
|
{
|
||||||
|
var sender = Sender;
|
||||||
|
|
||||||
|
DetailAsync(request).PipeTo(
|
||||||
|
sender,
|
||||||
|
success: response => response,
|
||||||
|
failure: ex => new NotificationDetailResponse(
|
||||||
|
request.CorrelationId, Success: false,
|
||||||
|
ErrorMessage: ex.GetBaseException().Message, Detail: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<NotificationDetailResponse> DetailAsync(NotificationDetailRequest request)
|
||||||
|
{
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
|
||||||
|
var notification = await repository.GetByIdAsync(request.NotificationId);
|
||||||
|
|
||||||
|
if (notification is null)
|
||||||
|
{
|
||||||
|
return new NotificationDetailResponse(
|
||||||
|
request.CorrelationId, Success: false,
|
||||||
|
ErrorMessage: "notification not found", Detail: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var detail = new NotificationDetail(
|
||||||
|
notification.NotificationId,
|
||||||
|
notification.Type.ToString(),
|
||||||
|
notification.ListName,
|
||||||
|
notification.Subject,
|
||||||
|
notification.Body,
|
||||||
|
notification.Status.ToString(),
|
||||||
|
notification.RetryCount,
|
||||||
|
notification.LastError,
|
||||||
|
notification.ResolvedTargets,
|
||||||
|
notification.TypeData,
|
||||||
|
notification.SourceSiteId,
|
||||||
|
notification.SourceInstanceId,
|
||||||
|
notification.SourceScript,
|
||||||
|
notification.SiteEnqueuedAt,
|
||||||
|
notification.CreatedAt,
|
||||||
|
notification.LastAttemptAt,
|
||||||
|
notification.NextAttemptAt,
|
||||||
|
notification.DeliveredAt);
|
||||||
|
|
||||||
|
return new NotificationDetailResponse(
|
||||||
|
request.CorrelationId, Success: true, ErrorMessage: null, detail);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles a manual retry request. Only a <c>Parked</c> notification can be retried;
|
/// Handles a manual retry request. Only a <c>Parked</c> notification can be retried;
|
||||||
/// it is reset to <c>Pending</c> with a cleared retry count, next-attempt time, and
|
/// it is reset to <c>Pending</c> with a cleared retry count, next-attempt time, and
|
||||||
|
|||||||
@@ -29,6 +29,27 @@ public class NotificationReportDetailModalTests : BunitContext
|
|||||||
private readonly ActorSystem _system = ActorSystem.Create("notif-report-modal-tests");
|
private readonly ActorSystem _system = ActorSystem.Create("notif-report-modal-tests");
|
||||||
private readonly CommunicationService _comms;
|
private readonly CommunicationService _comms;
|
||||||
|
|
||||||
|
private NotificationDetailResponse _detailReply =
|
||||||
|
new("d", true, null, new NotificationDetail(
|
||||||
|
NotificationId: "notif-aaaaaaaa-1111-full-id",
|
||||||
|
Type: "Email",
|
||||||
|
ListName: "Ops On-Call",
|
||||||
|
Subject: "Pump fault at Plant-A",
|
||||||
|
Body: "Pump-001 tripped on overcurrent at 14:32. Investigate immediately.",
|
||||||
|
Status: "Parked",
|
||||||
|
RetryCount: 3,
|
||||||
|
LastError: "SMTP timeout connecting to mail relay",
|
||||||
|
ResolvedTargets: "[\"ops@example.com\",\"oncall@example.com\"]",
|
||||||
|
TypeData: null,
|
||||||
|
SourceSiteId: "plant-a",
|
||||||
|
SourceInstanceId: "Pump-001",
|
||||||
|
SourceScript: "PumpFault.csx",
|
||||||
|
SiteEnqueuedAt: DateTimeOffset.UtcNow.AddMinutes(-31),
|
||||||
|
CreatedAt: DateTimeOffset.UtcNow.AddMinutes(-30),
|
||||||
|
LastAttemptAt: DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||||
|
NextAttemptAt: null,
|
||||||
|
DeliveredAt: null));
|
||||||
|
|
||||||
private NotificationOutboxQueryResponse _queryReply =
|
private NotificationOutboxQueryResponse _queryReply =
|
||||||
new("q", true, null, new List<NotificationSummary>
|
new("q", true, null, new List<NotificationSummary>
|
||||||
{
|
{
|
||||||
@@ -149,6 +170,90 @@ public class NotificationReportDetailModalTests : BunitContext
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Modal_FetchesAndShowsBody()
|
||||||
|
{
|
||||||
|
var cut = Render<NotificationReportPage>();
|
||||||
|
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
||||||
|
|
||||||
|
var row = cut.FindAll("tbody tr")
|
||||||
|
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
||||||
|
row.DoubleClick();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
var modal = cut.Find(".modal.show");
|
||||||
|
Assert.Contains(
|
||||||
|
"Pump-001 tripped on overcurrent at 14:32. Investigate immediately.",
|
||||||
|
modal.TextContent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Modal_ShowsRecipients_FromResolvedTargets()
|
||||||
|
{
|
||||||
|
var cut = Render<NotificationReportPage>();
|
||||||
|
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
||||||
|
|
||||||
|
var row = cut.FindAll("tbody tr")
|
||||||
|
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
||||||
|
row.DoubleClick();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
var modal = cut.Find(".modal.show");
|
||||||
|
Assert.Contains("ops@example.com", modal.TextContent);
|
||||||
|
Assert.Contains("oncall@example.com", modal.TextContent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Modal_ShowsListFallback_WhenResolvedTargetsNull()
|
||||||
|
{
|
||||||
|
_detailReply = _detailReply with
|
||||||
|
{
|
||||||
|
Detail = _detailReply.Detail! with { ResolvedTargets = null },
|
||||||
|
};
|
||||||
|
|
||||||
|
var cut = Render<NotificationReportPage>();
|
||||||
|
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
||||||
|
|
||||||
|
var row = cut.FindAll("tbody tr")
|
||||||
|
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
||||||
|
row.DoubleClick();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
var modal = cut.Find(".modal.show");
|
||||||
|
Assert.Contains("Not yet resolved", modal.TextContent);
|
||||||
|
Assert.Contains("Ops On-Call", modal.TextContent);
|
||||||
|
Assert.Contains("at delivery time", modal.TextContent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Modal_ShowsError_WhenDetailFetchFails()
|
||||||
|
{
|
||||||
|
_detailReply = new NotificationDetailResponse("d", false, "detail store unavailable", null);
|
||||||
|
|
||||||
|
var cut = Render<NotificationReportPage>();
|
||||||
|
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
||||||
|
|
||||||
|
var row = cut.FindAll("tbody tr")
|
||||||
|
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
||||||
|
row.DoubleClick();
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
var modal = cut.Find(".modal.show");
|
||||||
|
// The error surfaces in the body/recipient sections...
|
||||||
|
Assert.Contains("detail store unavailable", modal.TextContent);
|
||||||
|
// ...but the summary fields (from the grid row) still render.
|
||||||
|
Assert.Contains("Ops On-Call", modal.TextContent);
|
||||||
|
Assert.Contains("notif-aaaaaaaa-1111-full-id", modal.TextContent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
if (disposing)
|
if (disposing)
|
||||||
@@ -163,6 +268,10 @@ public class NotificationReportDetailModalTests : BunitContext
|
|||||||
public ScriptedOutboxActor(NotificationReportDetailModalTests test)
|
public ScriptedOutboxActor(NotificationReportDetailModalTests test)
|
||||||
{
|
{
|
||||||
Receive<NotificationOutboxQueryRequest>(_ => Sender.Tell(test._queryReply));
|
Receive<NotificationOutboxQueryRequest>(_ => Sender.Tell(test._queryReply));
|
||||||
|
Receive<NotificationDetailRequest>(r => Sender.Tell(test._detailReply with
|
||||||
|
{
|
||||||
|
CorrelationId = r.CorrelationId,
|
||||||
|
}));
|
||||||
Receive<RetryNotificationRequest>(r =>
|
Receive<RetryNotificationRequest>(r =>
|
||||||
Sender.Tell(new RetryNotificationResponse(r.CorrelationId, true, null)));
|
Sender.Tell(new RetryNotificationResponse(r.CorrelationId, true, null)));
|
||||||
Receive<DiscardNotificationRequest>(r =>
|
Receive<DiscardNotificationRequest>(r =>
|
||||||
|
|||||||
@@ -306,6 +306,57 @@ public class NotificationOutboxActorQueryTests : TestKit
|
|||||||
Assert.Contains("not found", response.ErrorMessage);
|
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.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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]
|
[Fact]
|
||||||
public void KpiRequest_ComputesKpis_AndMapsSnapshot()
|
public void KpiRequest_ComputesKpis_AndMapsSnapshot()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user