feat(notification-outbox): add notification message and outbox query contracts
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
namespace ScadaLink.Commons.Messages.Notification;
|
||||
|
||||
/// <summary>
|
||||
/// Site -> Central: submit a notification for central delivery.
|
||||
/// Fire-and-forget with ack; the site retries until a <see cref="NotificationSubmitAck"/> is received.
|
||||
/// </summary>
|
||||
public record NotificationSubmit(
|
||||
string NotificationId,
|
||||
string ListName,
|
||||
string Subject,
|
||||
string Body,
|
||||
string SourceSiteId,
|
||||
string? SourceInstanceId,
|
||||
string? SourceScript,
|
||||
DateTimeOffset SiteEnqueuedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Central -> Site: ack sent after the notification row is persisted.
|
||||
/// Idempotent — safe to re-send for the same <see cref="NotificationId"/>.
|
||||
/// </summary>
|
||||
public record NotificationSubmitAck(
|
||||
string NotificationId,
|
||||
bool Accepted,
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Site -> Central: query the central delivery status for a <see cref="NotificationId"/>.
|
||||
/// </summary>
|
||||
public record NotificationStatusQuery(
|
||||
string CorrelationId,
|
||||
string NotificationId);
|
||||
|
||||
/// <summary>
|
||||
/// Central -> Site: response carrying the current delivery status for a queried notification.
|
||||
/// </summary>
|
||||
public record NotificationStatusResponse(
|
||||
string CorrelationId,
|
||||
bool Found,
|
||||
string Status,
|
||||
int RetryCount,
|
||||
string? LastError,
|
||||
DateTimeOffset? DeliveredAt);
|
||||
@@ -0,0 +1,92 @@
|
||||
namespace ScadaLink.Commons.Messages.Notification;
|
||||
|
||||
/// <summary>
|
||||
/// Outbox UI -> Central: paginated, filtered query over the notification outbox.
|
||||
/// All filter fields are optional; <see cref="StuckOnly"/> restricts results to stuck notifications.
|
||||
/// </summary>
|
||||
public record NotificationOutboxQueryRequest(
|
||||
string CorrelationId,
|
||||
string? StatusFilter,
|
||||
string? TypeFilter,
|
||||
string? SourceSiteFilter,
|
||||
string? ListNameFilter,
|
||||
bool StuckOnly,
|
||||
string? SubjectKeyword,
|
||||
DateTimeOffset? From,
|
||||
DateTimeOffset? To,
|
||||
int PageNumber,
|
||||
int PageSize);
|
||||
|
||||
/// <summary>
|
||||
/// A single notification row summarised for outbox UI display.
|
||||
/// </summary>
|
||||
public record NotificationSummary(
|
||||
string NotificationId,
|
||||
string Type,
|
||||
string ListName,
|
||||
string Subject,
|
||||
string Status,
|
||||
int RetryCount,
|
||||
string? LastError,
|
||||
string SourceSiteId,
|
||||
string? SourceInstanceId,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? DeliveredAt,
|
||||
bool IsStuck);
|
||||
|
||||
/// <summary>
|
||||
/// Central -> Outbox UI: paginated response for a <see cref="NotificationOutboxQueryRequest"/>.
|
||||
/// </summary>
|
||||
public record NotificationOutboxQueryResponse(
|
||||
string CorrelationId,
|
||||
bool Success,
|
||||
string? ErrorMessage,
|
||||
IReadOnlyList<NotificationSummary> Notifications,
|
||||
int TotalCount);
|
||||
|
||||
/// <summary>
|
||||
/// Outbox UI -> Central: request to immediately retry delivery of a notification.
|
||||
/// </summary>
|
||||
public record RetryNotificationRequest(
|
||||
string CorrelationId,
|
||||
string NotificationId);
|
||||
|
||||
/// <summary>
|
||||
/// Central -> Outbox UI: result of a <see cref="RetryNotificationRequest"/>.
|
||||
/// </summary>
|
||||
public record RetryNotificationResponse(
|
||||
string CorrelationId,
|
||||
bool Success,
|
||||
string? ErrorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Outbox UI -> Central: request to discard (cancel) a pending or stuck notification.
|
||||
/// </summary>
|
||||
public record DiscardNotificationRequest(
|
||||
string CorrelationId,
|
||||
string NotificationId);
|
||||
|
||||
/// <summary>
|
||||
/// Central -> Outbox UI: result of a <see cref="DiscardNotificationRequest"/>.
|
||||
/// </summary>
|
||||
public record DiscardNotificationResponse(
|
||||
string CorrelationId,
|
||||
bool Success,
|
||||
string? ErrorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Outbox UI -> Central: request for the notification outbox KPI summary.
|
||||
/// </summary>
|
||||
public record NotificationKpiRequest(
|
||||
string CorrelationId);
|
||||
|
||||
/// <summary>
|
||||
/// Central -> Outbox UI: KPI summary for the notification outbox dashboard.
|
||||
/// </summary>
|
||||
public record NotificationKpiResponse(
|
||||
string CorrelationId,
|
||||
int QueueDepth,
|
||||
int StuckCount,
|
||||
int ParkedCount,
|
||||
int DeliveredLastInterval,
|
||||
TimeSpan? OldestPendingAge);
|
||||
@@ -0,0 +1,188 @@
|
||||
using ScadaLink.Commons.Messages.Notification;
|
||||
|
||||
namespace ScadaLink.Commons.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Notification Outbox: construction and value-equality tests for the
|
||||
/// site/central notification message contracts and the outbox UI query/action contracts.
|
||||
/// </summary>
|
||||
public class NotificationMessagesTests
|
||||
{
|
||||
// ── Task 7: site/central notification message contracts ──
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_PositionalConstruction_SetsAllFields()
|
||||
{
|
||||
var enqueuedAt = DateTimeOffset.UtcNow;
|
||||
var msg = new NotificationSubmit(
|
||||
"notif-1", "Operators", "Tank overflow", "Tank 3 has overflowed.",
|
||||
"site-01", "inst-7", "OnAlarm", enqueuedAt);
|
||||
|
||||
Assert.Equal("notif-1", msg.NotificationId);
|
||||
Assert.Equal("Operators", msg.ListName);
|
||||
Assert.Equal("Tank overflow", msg.Subject);
|
||||
Assert.Equal("Tank 3 has overflowed.", msg.Body);
|
||||
Assert.Equal("site-01", msg.SourceSiteId);
|
||||
Assert.Equal("inst-7", msg.SourceInstanceId);
|
||||
Assert.Equal("OnAlarm", msg.SourceScript);
|
||||
Assert.Equal(enqueuedAt, msg.SiteEnqueuedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_AllowsNullOptionalSourceFields()
|
||||
{
|
||||
var msg = new NotificationSubmit(
|
||||
"notif-2", "Operators", "Subject", "Body",
|
||||
"site-01", null, null, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Null(msg.SourceInstanceId);
|
||||
Assert.Null(msg.SourceScript);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch()
|
||||
{
|
||||
var enqueuedAt = DateTimeOffset.UtcNow;
|
||||
var a = new NotificationSubmit("n", "L", "S", "B", "site", "inst", "scr", enqueuedAt);
|
||||
var b = new NotificationSubmit("n", "L", "S", "B", "site", "inst", "scr", enqueuedAt);
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmitAck_WithExpression_ChangesSingleField()
|
||||
{
|
||||
var ack = new NotificationSubmitAck("notif-1", true, null);
|
||||
var rejected = ack with { Accepted = false, Error = "duplicate" };
|
||||
|
||||
Assert.True(ack.Accepted);
|
||||
Assert.False(rejected.Accepted);
|
||||
Assert.Equal("duplicate", rejected.Error);
|
||||
Assert.Equal("notif-1", rejected.NotificationId);
|
||||
Assert.NotEqual(ack, rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationStatusQuery_PositionalConstruction_SetsAllFields()
|
||||
{
|
||||
var msg = new NotificationStatusQuery("corr-1", "notif-9");
|
||||
|
||||
Assert.Equal("corr-1", msg.CorrelationId);
|
||||
Assert.Equal("notif-9", msg.NotificationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationStatusResponse_PositionalConstruction_SetsAllFields()
|
||||
{
|
||||
var deliveredAt = DateTimeOffset.UtcNow;
|
||||
var msg = new NotificationStatusResponse(
|
||||
"corr-1", true, "Delivered", 2, null, deliveredAt);
|
||||
|
||||
Assert.Equal("corr-1", msg.CorrelationId);
|
||||
Assert.True(msg.Found);
|
||||
Assert.Equal("Delivered", msg.Status);
|
||||
Assert.Equal(2, msg.RetryCount);
|
||||
Assert.Null(msg.LastError);
|
||||
Assert.Equal(deliveredAt, msg.DeliveredAt);
|
||||
}
|
||||
|
||||
// ── Task 8: outbox UI query/action contracts ──
|
||||
|
||||
[Fact]
|
||||
public void NotificationOutboxQueryRequest_PositionalConstruction_SetsAllFields()
|
||||
{
|
||||
var from = DateTimeOffset.UtcNow.AddDays(-1);
|
||||
var to = DateTimeOffset.UtcNow;
|
||||
var msg = new NotificationOutboxQueryRequest(
|
||||
"corr-1", "Stuck", "Email", "site-01", "Operators", true, "overflow",
|
||||
from, to, 2, 50);
|
||||
|
||||
Assert.Equal("corr-1", msg.CorrelationId);
|
||||
Assert.Equal("Stuck", msg.StatusFilter);
|
||||
Assert.Equal("Email", msg.TypeFilter);
|
||||
Assert.Equal("site-01", msg.SourceSiteFilter);
|
||||
Assert.Equal("Operators", msg.ListNameFilter);
|
||||
Assert.True(msg.StuckOnly);
|
||||
Assert.Equal("overflow", msg.SubjectKeyword);
|
||||
Assert.Equal(from, msg.From);
|
||||
Assert.Equal(to, msg.To);
|
||||
Assert.Equal(2, msg.PageNumber);
|
||||
Assert.Equal(50, msg.PageSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSummary_ValueEquality_EqualWhenAllFieldsMatch()
|
||||
{
|
||||
var createdAt = DateTimeOffset.UtcNow;
|
||||
var a = new NotificationSummary(
|
||||
"n", "Email", "Ops", "S", "Pending", 1, null, "site-01", "inst-1",
|
||||
createdAt, null, false);
|
||||
var b = new NotificationSummary(
|
||||
"n", "Email", "Ops", "S", "Pending", 1, null, "site-01", "inst-1",
|
||||
createdAt, null, false);
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationOutboxQueryResponse_PositionalConstruction_SetsAllFields()
|
||||
{
|
||||
var summary = new NotificationSummary(
|
||||
"n", "Email", "Ops", "S", "Delivered", 0, null, "site-01", null,
|
||||
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, false);
|
||||
var msg = new NotificationOutboxQueryResponse(
|
||||
"corr-1", true, null, new[] { summary }, 1);
|
||||
|
||||
Assert.Equal("corr-1", msg.CorrelationId);
|
||||
Assert.True(msg.Success);
|
||||
Assert.Null(msg.ErrorMessage);
|
||||
Assert.Single(msg.Notifications);
|
||||
Assert.Equal(1, msg.TotalCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryNotificationRequestAndResponse_RoundTripFields()
|
||||
{
|
||||
var request = new RetryNotificationRequest("corr-1", "notif-1");
|
||||
var response = new RetryNotificationResponse("corr-1", true, null);
|
||||
|
||||
Assert.Equal("corr-1", request.CorrelationId);
|
||||
Assert.Equal("notif-1", request.NotificationId);
|
||||
Assert.True(response.Success);
|
||||
Assert.Null(response.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscardNotificationRequestAndResponse_RoundTripFields()
|
||||
{
|
||||
var request = new DiscardNotificationRequest("corr-1", "notif-1");
|
||||
var response = new DiscardNotificationResponse("corr-1", false, "not found");
|
||||
|
||||
Assert.Equal("notif-1", request.NotificationId);
|
||||
Assert.False(response.Success);
|
||||
Assert.Equal("not found", response.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationKpiResponse_WithExpression_ChangesSingleField()
|
||||
{
|
||||
var kpi = new NotificationKpiResponse(
|
||||
"corr-1", 10, 2, 1, 5, TimeSpan.FromMinutes(3));
|
||||
var updated = kpi with { QueueDepth = 12 };
|
||||
|
||||
Assert.Equal(10, kpi.QueueDepth);
|
||||
Assert.Equal(12, updated.QueueDepth);
|
||||
Assert.Equal(2, updated.StuckCount);
|
||||
Assert.Equal(TimeSpan.FromMinutes(3), updated.OldestPendingAge);
|
||||
Assert.NotEqual(kpi, updated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationKpiRequest_PositionalConstruction_SetsCorrelationId()
|
||||
{
|
||||
var msg = new NotificationKpiRequest("corr-1");
|
||||
Assert.Equal("corr-1", msg.CorrelationId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user