feat(notification-outbox): add NotificationOutbox repository
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
using ScadaLink.Commons.Entities.Notifications;
|
||||||
|
using ScadaLink.Commons.Types.Notifications;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data access for the central notification outbox — the queue of <see cref="Notification"/>
|
||||||
|
/// rows the outbox actor drains, retries, and audits. Distinct from
|
||||||
|
/// <see cref="INotificationRepository"/>, which manages notification list configuration.
|
||||||
|
/// </summary>
|
||||||
|
public interface INotificationOutboxRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Inserts <paramref name="n"/> only if no row with the same
|
||||||
|
/// <see cref="Notification.NotificationId"/> exists. Returns <c>true</c> when a new
|
||||||
|
/// row was inserted, <c>false</c> when an existing row was left untouched.
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> InsertIfNotExistsAsync(Notification n, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns notifications ready for a delivery attempt: <c>Pending</c> rows, plus
|
||||||
|
/// <c>Retrying</c> rows whose <c>NextAttemptAt</c> is at or before <paramref name="now"/>.
|
||||||
|
/// Terminal rows are excluded. Ordered by <c>CreatedAt</c> ascending, capped at
|
||||||
|
/// <paramref name="batchSize"/>.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<Notification>> GetDueAsync(DateTimeOffset now, int batchSize, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Returns the notification with the given id, or <c>null</c>.</summary>
|
||||||
|
Task<Notification?> GetByIdAsync(string notificationId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Marks <paramref name="n"/> modified and persists it (status transitions).</summary>
|
||||||
|
Task UpdateAsync(Notification n, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a page of notifications matching <paramref name="filter"/>, ordered by
|
||||||
|
/// <c>CreatedAt</c> descending, together with the total matching count.
|
||||||
|
/// </summary>
|
||||||
|
Task<(IReadOnlyList<Notification> Rows, int TotalCount)> QueryAsync(
|
||||||
|
NotificationOutboxFilter filter, int pageNumber, int pageSize, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulk-deletes terminal rows (Delivered/Parked/Discarded) whose <c>CreatedAt</c> is
|
||||||
|
/// older than <paramref name="cutoff"/>. Returns the number of rows deleted.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> DeleteTerminalOlderThanAsync(DateTimeOffset cutoff, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes a point-in-time <see cref="NotificationKpiSnapshot"/>. The stuck and
|
||||||
|
/// delivered cutoffs are supplied by the caller; the current time used for
|
||||||
|
/// <c>OldestPendingAge</c> is captured inside the method.
|
||||||
|
/// </summary>
|
||||||
|
Task<NotificationKpiSnapshot> ComputeKpisAsync(
|
||||||
|
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Persists pending changes tracked on the underlying context.</summary>
|
||||||
|
Task<int> SaveChangesAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace ScadaLink.Commons.Types.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Point-in-time operational metrics for the central notification outbox,
|
||||||
|
/// surfaced on the health dashboard.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="QueueDepth">Count of non-terminal rows (Pending + Retrying).</param>
|
||||||
|
/// <param name="StuckCount">
|
||||||
|
/// Count of non-terminal rows (Pending/Retrying) whose <c>CreatedAt</c> is older
|
||||||
|
/// than the supplied stuck cutoff.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ParkedCount">Count of rows in the Parked status.</param>
|
||||||
|
/// <param name="DeliveredLastInterval">
|
||||||
|
/// Count of Delivered rows whose <c>DeliveredAt</c> is at or after the supplied
|
||||||
|
/// "delivered since" timestamp.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="OldestPendingAge">
|
||||||
|
/// Age of the oldest non-terminal row (<c>now - min(CreatedAt)</c>), or <c>null</c>
|
||||||
|
/// when there are no non-terminal rows.
|
||||||
|
/// </param>
|
||||||
|
public record NotificationKpiSnapshot(
|
||||||
|
int QueueDepth,
|
||||||
|
int StuckCount,
|
||||||
|
int ParkedCount,
|
||||||
|
int DeliveredLastInterval,
|
||||||
|
TimeSpan? OldestPendingAge);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Types.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query filter for the central notification outbox. All members are optional;
|
||||||
|
/// an unset member means "no constraint on that dimension".
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Status">Restrict to a single lifecycle status.</param>
|
||||||
|
/// <param name="Type">Restrict to a single delivery channel.</param>
|
||||||
|
/// <param name="SourceSiteId">Restrict to notifications originating at a given site.</param>
|
||||||
|
/// <param name="ListName">Restrict to a single notification list.</param>
|
||||||
|
/// <param name="SubjectKeyword">Substring matched against <c>Subject</c>.</param>
|
||||||
|
/// <param name="StuckOnly">
|
||||||
|
/// When <c>true</c>, restrict to non-terminal rows (Pending/Retrying) whose
|
||||||
|
/// <c>CreatedAt</c> is older than <see cref="StuckCutoff"/>.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="StuckCutoff">Rows with <c>CreatedAt</c> older than this count as stuck.</param>
|
||||||
|
/// <param name="From">Inclusive lower bound on <c>CreatedAt</c>.</param>
|
||||||
|
/// <param name="To">Inclusive upper bound on <c>CreatedAt</c>.</param>
|
||||||
|
public record NotificationOutboxFilter(
|
||||||
|
NotificationStatus? Status = null,
|
||||||
|
NotificationType? Type = null,
|
||||||
|
string? SourceSiteId = null,
|
||||||
|
string? ListName = null,
|
||||||
|
string? SubjectKeyword = null,
|
||||||
|
bool StuckOnly = false,
|
||||||
|
DateTimeOffset? StuckCutoff = null,
|
||||||
|
DateTimeOffset? From = null,
|
||||||
|
DateTimeOffset? To = null);
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ScadaLink.Commons.Entities.Notifications;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.Commons.Types.Notifications;
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EF Core data access for the central notification outbox. See
|
||||||
|
/// <see cref="INotificationOutboxRepository"/> for the behaviour contract.
|
||||||
|
/// </summary>
|
||||||
|
public class NotificationOutboxRepository : INotificationOutboxRepository
|
||||||
|
{
|
||||||
|
private readonly ScadaLinkDbContext _context;
|
||||||
|
|
||||||
|
// Statuses that represent a finished notification lifecycle. Non-terminal is the complement.
|
||||||
|
private static readonly NotificationStatus[] TerminalStatuses =
|
||||||
|
{
|
||||||
|
NotificationStatus.Delivered,
|
||||||
|
NotificationStatus.Parked,
|
||||||
|
NotificationStatus.Discarded,
|
||||||
|
};
|
||||||
|
|
||||||
|
public NotificationOutboxRepository(ScadaLinkDbContext context)
|
||||||
|
{
|
||||||
|
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> InsertIfNotExistsAsync(Notification n, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var exists = await _context.Notifications
|
||||||
|
.AnyAsync(x => x.NotificationId == n.NotificationId, ct);
|
||||||
|
if (exists)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.Notifications.AddAsync(n, ct);
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Notification>> GetDueAsync(
|
||||||
|
DateTimeOffset now, int batchSize, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await _context.Notifications
|
||||||
|
.Where(n => n.Status == NotificationStatus.Pending
|
||||||
|
|| (n.Status == NotificationStatus.Retrying
|
||||||
|
&& n.NextAttemptAt != null
|
||||||
|
&& n.NextAttemptAt <= now))
|
||||||
|
.OrderBy(n => n.CreatedAt)
|
||||||
|
.Take(batchSize)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Notification?> GetByIdAsync(string notificationId, CancellationToken ct = default)
|
||||||
|
=> await _context.Notifications.FindAsync(new object[] { notificationId }, ct);
|
||||||
|
|
||||||
|
public async Task UpdateAsync(Notification n, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
_context.Notifications.Update(n);
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(IReadOnlyList<Notification> Rows, int TotalCount)> QueryAsync(
|
||||||
|
NotificationOutboxFilter filter, int pageNumber, int pageSize, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var query = _context.Notifications.AsQueryable();
|
||||||
|
|
||||||
|
if (filter.Status is { } status)
|
||||||
|
{
|
||||||
|
query = query.Where(n => n.Status == status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.Type is { } type)
|
||||||
|
{
|
||||||
|
query = query.Where(n => n.Type == type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(filter.SourceSiteId))
|
||||||
|
{
|
||||||
|
query = query.Where(n => n.SourceSiteId == filter.SourceSiteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(filter.ListName))
|
||||||
|
{
|
||||||
|
query = query.Where(n => n.ListName == filter.ListName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(filter.SubjectKeyword))
|
||||||
|
{
|
||||||
|
query = query.Where(n => n.Subject.Contains(filter.SubjectKeyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.StuckOnly && filter.StuckCutoff is { } stuckCutoff)
|
||||||
|
{
|
||||||
|
query = query.Where(n =>
|
||||||
|
(n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying)
|
||||||
|
&& n.CreatedAt < stuckCutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.From is { } from)
|
||||||
|
{
|
||||||
|
query = query.Where(n => n.CreatedAt >= from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.To is { } to)
|
||||||
|
{
|
||||||
|
query = query.Where(n => n.CreatedAt <= to);
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync(ct);
|
||||||
|
|
||||||
|
var rows = await query
|
||||||
|
.OrderByDescending(n => n.CreatedAt)
|
||||||
|
.Skip((pageNumber - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return (rows, totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> DeleteTerminalOlderThanAsync(DateTimeOffset cutoff, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await _context.Notifications
|
||||||
|
.Where(n => TerminalStatuses.Contains(n.Status) && n.CreatedAt < cutoff)
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NotificationKpiSnapshot> ComputeKpisAsync(
|
||||||
|
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
var queueDepth = await _context.Notifications
|
||||||
|
.CountAsync(n => n.Status == NotificationStatus.Pending
|
||||||
|
|| n.Status == NotificationStatus.Retrying, ct);
|
||||||
|
|
||||||
|
var stuckCount = await _context.Notifications
|
||||||
|
.CountAsync(n => (n.Status == NotificationStatus.Pending
|
||||||
|
|| n.Status == NotificationStatus.Retrying)
|
||||||
|
&& n.CreatedAt < stuckCutoff, ct);
|
||||||
|
|
||||||
|
var parkedCount = await _context.Notifications
|
||||||
|
.CountAsync(n => n.Status == NotificationStatus.Parked, ct);
|
||||||
|
|
||||||
|
var deliveredLastInterval = await _context.Notifications
|
||||||
|
.CountAsync(n => n.Status == NotificationStatus.Delivered
|
||||||
|
&& n.DeliveredAt != null
|
||||||
|
&& n.DeliveredAt >= deliveredSince, ct);
|
||||||
|
|
||||||
|
// Oldest non-terminal CreatedAt. The DateTimeOffset value converter makes a SQL
|
||||||
|
// Min aggregate awkward, so order ascending and take the first instead.
|
||||||
|
var nonTerminal = _context.Notifications
|
||||||
|
.Where(n => n.Status == NotificationStatus.Pending
|
||||||
|
|| n.Status == NotificationStatus.Retrying);
|
||||||
|
|
||||||
|
TimeSpan? oldestPendingAge = null;
|
||||||
|
if (await nonTerminal.AnyAsync(ct))
|
||||||
|
{
|
||||||
|
var oldestCreatedAt = await nonTerminal
|
||||||
|
.OrderBy(n => n.CreatedAt)
|
||||||
|
.Select(n => n.CreatedAt)
|
||||||
|
.FirstAsync(ct);
|
||||||
|
oldestPendingAge = now - oldestCreatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NotificationKpiSnapshot(
|
||||||
|
QueueDepth: queueDepth,
|
||||||
|
StuckCount: stuckCount,
|
||||||
|
ParkedCount: parkedCount,
|
||||||
|
DeliveredLastInterval: deliveredLastInterval,
|
||||||
|
OldestPendingAge: oldestPendingAge);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> SaveChangesAsync(CancellationToken ct = default)
|
||||||
|
=> await _context.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<ISiteRepository, SiteRepository>();
|
services.AddScoped<ISiteRepository, SiteRepository>();
|
||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||||
|
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
|
||||||
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
||||||
services.AddScoped<IAuditService, AuditService>();
|
services.AddScoped<IAuditService, AuditService>();
|
||||||
services.AddScoped<IInstanceLocator, InstanceLocator>();
|
services.AddScoped<IInstanceLocator, InstanceLocator>();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using ScadaLink.Commons.Entities.Notifications;
|
|||||||
using ScadaLink.Commons.Entities.Sites;
|
using ScadaLink.Commons.Entities.Sites;
|
||||||
using ScadaLink.Commons.Entities.Templates;
|
using ScadaLink.Commons.Entities.Templates;
|
||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.Commons.Types.Notifications;
|
||||||
using ScadaLink.ConfigurationDatabase;
|
using ScadaLink.ConfigurationDatabase;
|
||||||
using ScadaLink.ConfigurationDatabase.Repositories;
|
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||||
using ScadaLink.ConfigurationDatabase.Services;
|
using ScadaLink.ConfigurationDatabase.Services;
|
||||||
@@ -334,6 +335,342 @@ public class NotificationOutboxConfigurationTests : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Coverage for the Notification Outbox repository (Task 5 of the notification-outbox feature).
|
||||||
|
public class NotificationOutboxRepositoryTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ScadaLinkDbContext _context;
|
||||||
|
private readonly NotificationOutboxRepository _repository;
|
||||||
|
|
||||||
|
public NotificationOutboxRepositoryTests()
|
||||||
|
{
|
||||||
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||||
|
_repository = new NotificationOutboxRepository(_context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_context.Database.CloseConnection();
|
||||||
|
_context.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Notification MakeNotification(
|
||||||
|
string id,
|
||||||
|
NotificationStatus status = NotificationStatus.Pending,
|
||||||
|
DateTimeOffset? createdAt = null,
|
||||||
|
DateTimeOffset? nextAttemptAt = null,
|
||||||
|
DateTimeOffset? deliveredAt = null,
|
||||||
|
string listName = "Ops List",
|
||||||
|
string subject = "Subject",
|
||||||
|
string sourceSiteId = "site-north",
|
||||||
|
NotificationType type = NotificationType.Email)
|
||||||
|
{
|
||||||
|
return new Notification(id, type, listName, subject, "Body", sourceSiteId)
|
||||||
|
{
|
||||||
|
Status = status,
|
||||||
|
CreatedAt = createdAt ?? new DateTimeOffset(2026, 5, 19, 8, 0, 0, TimeSpan.Zero),
|
||||||
|
NextAttemptAt = nextAttemptAt,
|
||||||
|
DeliveredAt = deliveredAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InsertIfNotExistsAsync_NewRow_InsertsAndReturnsTrue()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
var inserted = await _repository.InsertIfNotExistsAsync(MakeNotification(id));
|
||||||
|
|
||||||
|
Assert.True(inserted);
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
Assert.NotNull(await _context.Notifications.FindAsync(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InsertIfNotExistsAsync_DuplicateId_ReturnsFalseAndLeavesExistingRow()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid().ToString();
|
||||||
|
await _repository.InsertIfNotExistsAsync(MakeNotification(id, subject: "Original"));
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var inserted = await _repository.InsertIfNotExistsAsync(MakeNotification(id, subject: "Changed"));
|
||||||
|
|
||||||
|
Assert.False(inserted);
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
var loaded = await _context.Notifications.FindAsync(id);
|
||||||
|
Assert.Equal("Original", loaded!.Subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDueAsync_ReturnsPendingAndDueRetrying_OrderedByCreatedAt_CappedAtBatchSize()
|
||||||
|
{
|
||||||
|
var now = new DateTimeOffset(2026, 5, 19, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
var pendingOld = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
|
||||||
|
createdAt: now.AddMinutes(-30));
|
||||||
|
var pendingNew = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
|
||||||
|
createdAt: now.AddMinutes(-10));
|
||||||
|
var retryingDue = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Retrying,
|
||||||
|
createdAt: now.AddMinutes(-20), nextAttemptAt: now.AddMinutes(-1));
|
||||||
|
var retryingNotDue = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Retrying,
|
||||||
|
createdAt: now.AddMinutes(-25), nextAttemptAt: now.AddMinutes(5));
|
||||||
|
var delivered = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Delivered,
|
||||||
|
createdAt: now.AddMinutes(-40));
|
||||||
|
var parked = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Parked,
|
||||||
|
createdAt: now.AddMinutes(-45));
|
||||||
|
|
||||||
|
_context.Notifications.AddRange(pendingOld, pendingNew, retryingDue, retryingNotDue, delivered, parked);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var due = await _repository.GetDueAsync(now, batchSize: 10);
|
||||||
|
|
||||||
|
// pendingOld, retryingDue, pendingNew are due; ordered by CreatedAt ascending.
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { pendingOld.NotificationId, retryingDue.NotificationId, pendingNew.NotificationId },
|
||||||
|
due.Select(n => n.NotificationId).ToArray());
|
||||||
|
|
||||||
|
var capped = await _repository.GetDueAsync(now, batchSize: 2);
|
||||||
|
Assert.Equal(2, capped.Count);
|
||||||
|
Assert.Equal(pendingOld.NotificationId, capped[0].NotificationId);
|
||||||
|
Assert.Equal(retryingDue.NotificationId, capped[1].NotificationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByIdAsync_ReturnsRowOrNull()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid().ToString();
|
||||||
|
_context.Notifications.Add(MakeNotification(id));
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
Assert.NotNull(await _repository.GetByIdAsync(id));
|
||||||
|
Assert.Null(await _repository.GetByIdAsync("does-not-exist"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_PersistsStatusTransition()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid().ToString();
|
||||||
|
_context.Notifications.Add(MakeNotification(id, NotificationStatus.Pending));
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var loaded = await _repository.GetByIdAsync(id);
|
||||||
|
loaded!.Status = NotificationStatus.Delivered;
|
||||||
|
loaded.DeliveredAt = new DateTimeOffset(2026, 5, 19, 9, 0, 0, TimeSpan.Zero);
|
||||||
|
await _repository.UpdateAsync(loaded);
|
||||||
|
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
var reloaded = await _context.Notifications.FindAsync(id);
|
||||||
|
Assert.Equal(NotificationStatus.Delivered, reloaded!.Status);
|
||||||
|
Assert.Equal(new DateTimeOffset(2026, 5, 19, 9, 0, 0, TimeSpan.Zero), reloaded.DeliveredAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueryAsync_AppliesFilters_OrdersByCreatedAtDescending_AndPaginates()
|
||||||
|
{
|
||||||
|
var baseTime = new DateTimeOffset(2026, 5, 19, 8, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
// 5 matching rows for site-north / Ops List, plus noise.
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
_context.Notifications.Add(MakeNotification(
|
||||||
|
Guid.NewGuid().ToString(), NotificationStatus.Pending,
|
||||||
|
createdAt: baseTime.AddMinutes(i),
|
||||||
|
subject: $"Tank Level {i}"));
|
||||||
|
}
|
||||||
|
_context.Notifications.Add(MakeNotification(Guid.NewGuid().ToString(),
|
||||||
|
sourceSiteId: "site-south", subject: "Other Site"));
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var filter = new NotificationOutboxFilter(SourceSiteId: "site-north", ListName: "Ops List");
|
||||||
|
var (rows, total) = await _repository.QueryAsync(filter, pageNumber: 1, pageSize: 3);
|
||||||
|
|
||||||
|
Assert.Equal(5, total);
|
||||||
|
Assert.Equal(3, rows.Count);
|
||||||
|
// Descending by CreatedAt: Tank Level 4, 3, 2.
|
||||||
|
Assert.Equal("Tank Level 4", rows[0].Subject);
|
||||||
|
Assert.Equal("Tank Level 3", rows[1].Subject);
|
||||||
|
Assert.Equal("Tank Level 2", rows[2].Subject);
|
||||||
|
|
||||||
|
var (page2, _) = await _repository.QueryAsync(filter, pageNumber: 2, pageSize: 3);
|
||||||
|
Assert.Equal(2, page2.Count);
|
||||||
|
Assert.Equal("Tank Level 1", page2[0].Subject);
|
||||||
|
Assert.Equal("Tank Level 0", page2[1].Subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueryAsync_SubjectKeyword_UsesContains()
|
||||||
|
{
|
||||||
|
_context.Notifications.Add(MakeNotification(Guid.NewGuid().ToString(), subject: "Tank Level High"));
|
||||||
|
_context.Notifications.Add(MakeNotification(Guid.NewGuid().ToString(), subject: "Pump Failure"));
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var (rows, total) = await _repository.QueryAsync(
|
||||||
|
new NotificationOutboxFilter(SubjectKeyword: "Level"), pageNumber: 1, pageSize: 10);
|
||||||
|
|
||||||
|
Assert.Equal(1, total);
|
||||||
|
Assert.Equal("Tank Level High", rows[0].Subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueryAsync_StuckOnly_ReturnsNonTerminalRowsOlderThanCutoff()
|
||||||
|
{
|
||||||
|
var cutoff = new DateTimeOffset(2026, 5, 19, 10, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
var stuckPending = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
|
||||||
|
createdAt: cutoff.AddHours(-2), subject: "Stuck Pending");
|
||||||
|
var stuckRetrying = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Retrying,
|
||||||
|
createdAt: cutoff.AddHours(-3), subject: "Stuck Retrying");
|
||||||
|
var freshPending = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
|
||||||
|
createdAt: cutoff.AddHours(1), subject: "Fresh");
|
||||||
|
var oldDelivered = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Delivered,
|
||||||
|
createdAt: cutoff.AddHours(-5), subject: "Old Delivered");
|
||||||
|
|
||||||
|
_context.Notifications.AddRange(stuckPending, stuckRetrying, freshPending, oldDelivered);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var (rows, total) = await _repository.QueryAsync(
|
||||||
|
new NotificationOutboxFilter(StuckOnly: true, StuckCutoff: cutoff),
|
||||||
|
pageNumber: 1, pageSize: 10);
|
||||||
|
|
||||||
|
Assert.Equal(2, total);
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { "Stuck Pending", "Stuck Retrying" },
|
||||||
|
rows.Select(r => r.Subject).OrderBy(s => s).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueryAsync_FromTo_FilterAgainstCreatedAt()
|
||||||
|
{
|
||||||
|
var baseTime = new DateTimeOffset(2026, 5, 19, 8, 0, 0, TimeSpan.Zero);
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
_context.Notifications.Add(MakeNotification(Guid.NewGuid().ToString(),
|
||||||
|
createdAt: baseTime.AddHours(i), subject: $"Row {i}"));
|
||||||
|
}
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var (rows, total) = await _repository.QueryAsync(
|
||||||
|
new NotificationOutboxFilter(From: baseTime.AddHours(1), To: baseTime.AddHours(3)),
|
||||||
|
pageNumber: 1, pageSize: 10);
|
||||||
|
|
||||||
|
Assert.Equal(3, total);
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { "Row 1", "Row 2", "Row 3" },
|
||||||
|
rows.Select(r => r.Subject).OrderBy(s => s).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueryAsync_StatusAndTypeFilters()
|
||||||
|
{
|
||||||
|
_context.Notifications.Add(MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Parked,
|
||||||
|
subject: "Parked Row"));
|
||||||
|
_context.Notifications.Add(MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
|
||||||
|
subject: "Pending Row"));
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var (rows, total) = await _repository.QueryAsync(
|
||||||
|
new NotificationOutboxFilter(Status: NotificationStatus.Parked, Type: NotificationType.Email),
|
||||||
|
pageNumber: 1, pageSize: 10);
|
||||||
|
|
||||||
|
Assert.Equal(1, total);
|
||||||
|
Assert.Equal("Parked Row", rows[0].Subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteTerminalOlderThanAsync_DeletesTerminalRowsOlderThanCutoff_LeavesOthers()
|
||||||
|
{
|
||||||
|
var cutoff = new DateTimeOffset(2026, 5, 19, 10, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
var oldDelivered = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Delivered,
|
||||||
|
createdAt: cutoff.AddHours(-1));
|
||||||
|
var oldParked = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Parked,
|
||||||
|
createdAt: cutoff.AddHours(-2));
|
||||||
|
var oldDiscarded = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Discarded,
|
||||||
|
createdAt: cutoff.AddHours(-3));
|
||||||
|
var recentDelivered = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Delivered,
|
||||||
|
createdAt: cutoff.AddHours(1));
|
||||||
|
var oldPending = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
|
||||||
|
createdAt: cutoff.AddHours(-4));
|
||||||
|
|
||||||
|
_context.Notifications.AddRange(oldDelivered, oldParked, oldDiscarded, recentDelivered, oldPending);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var deleted = await _repository.DeleteTerminalOlderThanAsync(cutoff);
|
||||||
|
|
||||||
|
Assert.Equal(3, deleted);
|
||||||
|
var remaining = await _context.Notifications.Select(n => n.NotificationId).ToListAsync();
|
||||||
|
Assert.Equal(2, remaining.Count);
|
||||||
|
Assert.Contains(recentDelivered.NotificationId, remaining);
|
||||||
|
Assert.Contains(oldPending.NotificationId, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ComputeKpisAsync_ComputesSnapshotFields()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var stuckCutoff = now.AddMinutes(-30);
|
||||||
|
var deliveredSince = now.AddHours(-1);
|
||||||
|
|
||||||
|
var oldestPending = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
|
||||||
|
createdAt: now.AddHours(-2)); // stuck
|
||||||
|
var freshPending = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
|
||||||
|
createdAt: now.AddMinutes(-5)); // not stuck
|
||||||
|
var stuckRetrying = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Retrying,
|
||||||
|
createdAt: now.AddMinutes(-45)); // stuck
|
||||||
|
var parked = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Parked,
|
||||||
|
createdAt: now.AddHours(-3));
|
||||||
|
var recentlyDelivered = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Delivered,
|
||||||
|
createdAt: now.AddHours(-4), deliveredAt: now.AddMinutes(-10));
|
||||||
|
var oldDelivered = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Delivered,
|
||||||
|
createdAt: now.AddHours(-5), deliveredAt: now.AddHours(-2));
|
||||||
|
|
||||||
|
_context.Notifications.AddRange(oldestPending, freshPending, stuckRetrying, parked,
|
||||||
|
recentlyDelivered, oldDelivered);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var kpis = await _repository.ComputeKpisAsync(stuckCutoff, deliveredSince);
|
||||||
|
|
||||||
|
Assert.Equal(3, kpis.QueueDepth); // 2 pending + 1 retrying
|
||||||
|
Assert.Equal(2, kpis.StuckCount); // oldestPending + stuckRetrying
|
||||||
|
Assert.Equal(1, kpis.ParkedCount); // parked
|
||||||
|
Assert.Equal(1, kpis.DeliveredLastInterval); // recentlyDelivered only
|
||||||
|
Assert.NotNull(kpis.OldestPendingAge);
|
||||||
|
// Oldest non-terminal row is oldestPending (created ~2h ago).
|
||||||
|
Assert.True(kpis.OldestPendingAge!.Value >= TimeSpan.FromMinutes(115));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ComputeKpisAsync_NoNonTerminalRows_OldestPendingAgeIsNull()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
_context.Notifications.Add(MakeNotification(Guid.NewGuid().ToString(),
|
||||||
|
NotificationStatus.Delivered, createdAt: now.AddHours(-1), deliveredAt: now.AddMinutes(-5)));
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var kpis = await _repository.ComputeKpisAsync(now.AddMinutes(-30), now.AddHours(-1));
|
||||||
|
|
||||||
|
Assert.Equal(0, kpis.QueueDepth);
|
||||||
|
Assert.Equal(0, kpis.StuckCount);
|
||||||
|
Assert.Null(kpis.OldestPendingAge);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_NullContext_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentNullException>(() => new NotificationOutboxRepository(null!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public class SiteRepositoryTests : IDisposable
|
public class SiteRepositoryTests : IDisposable
|
||||||
{
|
{
|
||||||
private readonly ScadaLinkDbContext _context;
|
private readonly ScadaLinkDbContext _context;
|
||||||
|
|||||||
Reference in New Issue
Block a user