From c547f829575dc38b20d3a003cf51a168af82a20d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 19 May 2026 01:13:36 -0400 Subject: [PATCH] feat(notification-outbox): add notification message and outbox query contracts --- .../Notification/NotificationMessages.cs | 42 ++++ .../Notification/NotificationOutboxQueries.cs | 92 +++++++++ .../Messages/NotificationMessagesTests.cs | 188 ++++++++++++++++++ 3 files changed, 322 insertions(+) create mode 100644 src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs create mode 100644 src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs create mode 100644 tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs diff --git a/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs b/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs new file mode 100644 index 0000000..d5652ea --- /dev/null +++ b/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs @@ -0,0 +1,42 @@ +namespace ScadaLink.Commons.Messages.Notification; + +/// +/// Site -> Central: submit a notification for central delivery. +/// Fire-and-forget with ack; the site retries until a is received. +/// +public record NotificationSubmit( + string NotificationId, + string ListName, + string Subject, + string Body, + string SourceSiteId, + string? SourceInstanceId, + string? SourceScript, + DateTimeOffset SiteEnqueuedAt); + +/// +/// Central -> Site: ack sent after the notification row is persisted. +/// Idempotent — safe to re-send for the same . +/// +public record NotificationSubmitAck( + string NotificationId, + bool Accepted, + string? Error); + +/// +/// Site -> Central: query the central delivery status for a . +/// +public record NotificationStatusQuery( + string CorrelationId, + string NotificationId); + +/// +/// Central -> Site: response carrying the current delivery status for a queried notification. +/// +public record NotificationStatusResponse( + string CorrelationId, + bool Found, + string Status, + int RetryCount, + string? LastError, + DateTimeOffset? DeliveredAt); diff --git a/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs b/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs new file mode 100644 index 0000000..9bb7c4d --- /dev/null +++ b/src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs @@ -0,0 +1,92 @@ +namespace ScadaLink.Commons.Messages.Notification; + +/// +/// Outbox UI -> Central: paginated, filtered query over the notification outbox. +/// All filter fields are optional; restricts results to stuck notifications. +/// +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); + +/// +/// A single notification row summarised for outbox UI display. +/// +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); + +/// +/// Central -> Outbox UI: paginated response for a . +/// +public record NotificationOutboxQueryResponse( + string CorrelationId, + bool Success, + string? ErrorMessage, + IReadOnlyList Notifications, + int TotalCount); + +/// +/// Outbox UI -> Central: request to immediately retry delivery of a notification. +/// +public record RetryNotificationRequest( + string CorrelationId, + string NotificationId); + +/// +/// Central -> Outbox UI: result of a . +/// +public record RetryNotificationResponse( + string CorrelationId, + bool Success, + string? ErrorMessage); + +/// +/// Outbox UI -> Central: request to discard (cancel) a pending or stuck notification. +/// +public record DiscardNotificationRequest( + string CorrelationId, + string NotificationId); + +/// +/// Central -> Outbox UI: result of a . +/// +public record DiscardNotificationResponse( + string CorrelationId, + bool Success, + string? ErrorMessage); + +/// +/// Outbox UI -> Central: request for the notification outbox KPI summary. +/// +public record NotificationKpiRequest( + string CorrelationId); + +/// +/// Central -> Outbox UI: KPI summary for the notification outbox dashboard. +/// +public record NotificationKpiResponse( + string CorrelationId, + int QueueDepth, + int StuckCount, + int ParkedCount, + int DeliveredLastInterval, + TimeSpan? OldestPendingAge); diff --git a/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs b/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs new file mode 100644 index 0000000..0fba09e --- /dev/null +++ b/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs @@ -0,0 +1,188 @@ +using ScadaLink.Commons.Messages.Notification; + +namespace ScadaLink.Commons.Tests.Messages; + +/// +/// Notification Outbox: construction and value-equality tests for the +/// site/central notification message contracts and the outbox UI query/action contracts. +/// +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); + } +}