From 761595309be6fad15dfb6df9f748c0a3f4890169 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 19 May 2026 00:55:58 -0400 Subject: [PATCH] feat(notification-outbox): add Notification EF configuration and DbSet --- .../NotificationOutboxConfiguration.cs | 31 +++++++ .../ScadaLinkDbContext.cs | 1 + .../RepositoryCoverageTests.cs | 91 +++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs new file mode 100644 index 0000000..ba6b814 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using ScadaLink.Commons.Entities.Notifications; + +namespace ScadaLink.ConfigurationDatabase.Configurations; + +/// +/// EF Core mapping for the central notification outbox entity. +/// and are intentionally left unconstrained +/// (nullable nvarchar(max)) as they carry variable-length JSON / target snapshots. +/// +public class NotificationOutboxConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Notifications"); + builder.HasKey(n => n.NotificationId); + builder.Property(n => n.NotificationId).HasMaxLength(64); + builder.Property(n => n.Type).HasConversion().HasMaxLength(32).IsRequired(); + builder.Property(n => n.Status).HasConversion().HasMaxLength(32).IsRequired(); + builder.Property(n => n.ListName).HasMaxLength(200).IsRequired(); + builder.Property(n => n.Subject).HasMaxLength(1000).IsRequired(); + builder.Property(n => n.Body).IsRequired(); + builder.Property(n => n.LastError).HasMaxLength(4000); + builder.Property(n => n.SourceSiteId).HasMaxLength(100).IsRequired(); + builder.Property(n => n.SourceInstanceId).HasMaxLength(200); + builder.Property(n => n.SourceScript).HasMaxLength(200); + builder.HasIndex(n => new { n.Status, n.NextAttemptAt }); + builder.HasIndex(n => new { n.SourceSiteId, n.CreatedAt }); + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs b/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs index d908e93..c1db4a7 100644 --- a/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs +++ b/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs @@ -69,6 +69,7 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext public DbSet NotificationLists => Set(); public DbSet NotificationRecipients => Set(); public DbSet SmtpConfigurations => Set(); + public DbSet Notifications => Set(); // Scripts public DbSet SharedScripts => Set(); diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs index 0951f0c..9de3b56 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs @@ -243,6 +243,97 @@ public class NotificationRepositoryTests : IDisposable } } +public class NotificationOutboxConfigurationTests : IDisposable +{ + private readonly ScadaLinkDbContext _context; + + public NotificationOutboxConfigurationTests() + { + _context = SqliteTestHelper.CreateInMemoryContext(); + } + + public void Dispose() + { + _context.Database.CloseConnection(); + _context.Dispose(); + } + + [Fact] + public async Task Notification_FullyPopulated_RoundTrips() + { + var id = Guid.NewGuid().ToString(); + var siteEnqueuedAt = new DateTimeOffset(2026, 5, 19, 8, 0, 0, TimeSpan.Zero); + var createdAt = new DateTimeOffset(2026, 5, 19, 8, 0, 5, TimeSpan.Zero); + var lastAttemptAt = new DateTimeOffset(2026, 5, 19, 8, 1, 0, TimeSpan.Zero); + var nextAttemptAt = new DateTimeOffset(2026, 5, 19, 8, 2, 0, TimeSpan.Zero); + var deliveredAt = new DateTimeOffset(2026, 5, 19, 8, 3, 0, TimeSpan.Zero); + + var notification = new Notification(id, NotificationType.Email, "Ops List", + "High Tank Level", "Tank 4 exceeded the high level threshold.", "site-north") + { + TypeData = "{\"channel\":\"email\"}", + Status = NotificationStatus.Retrying, + RetryCount = 3, + LastError = "SMTP timeout", + ResolvedTargets = "ops@example.test;duty@example.test", + SourceInstanceId = "instance-42", + SourceScript = "TankLevelAlarm", + SiteEnqueuedAt = siteEnqueuedAt, + CreatedAt = createdAt, + LastAttemptAt = lastAttemptAt, + NextAttemptAt = nextAttemptAt, + DeliveredAt = deliveredAt, + }; + + _context.Notifications.Add(notification); + await _context.SaveChangesAsync(); + _context.ChangeTracker.Clear(); + + var loaded = await _context.Notifications.FindAsync(id); + + Assert.NotNull(loaded); + Assert.Equal(id, loaded!.NotificationId); + Assert.Equal(NotificationType.Email, loaded.Type); + Assert.Equal(NotificationStatus.Retrying, loaded.Status); + Assert.Equal("Ops List", loaded.ListName); + Assert.Equal("High Tank Level", loaded.Subject); + Assert.Equal("Tank 4 exceeded the high level threshold.", loaded.Body); + Assert.Equal("{\"channel\":\"email\"}", loaded.TypeData); + Assert.Equal(3, loaded.RetryCount); + Assert.Equal("SMTP timeout", loaded.LastError); + Assert.Equal("ops@example.test;duty@example.test", loaded.ResolvedTargets); + Assert.Equal("site-north", loaded.SourceSiteId); + Assert.Equal("instance-42", loaded.SourceInstanceId); + Assert.Equal("TankLevelAlarm", loaded.SourceScript); + Assert.Equal(siteEnqueuedAt, loaded.SiteEnqueuedAt); + Assert.Equal(createdAt, loaded.CreatedAt); + Assert.Equal(lastAttemptAt, loaded.LastAttemptAt); + Assert.Equal(nextAttemptAt, loaded.NextAttemptAt); + Assert.Equal(deliveredAt, loaded.DeliveredAt); + } + + [Fact] + public async Task Notification_StatusPersistsAsString() + { + var id = Guid.NewGuid().ToString(); + var notification = new Notification(id, NotificationType.Email, "Ops List", + "Subject", "Body", "site-north") + { + Status = NotificationStatus.Parked, + }; + + _context.Notifications.Add(notification); + await _context.SaveChangesAsync(); + _context.ChangeTracker.Clear(); + + var statusText = await _context.Database + .SqlQuery($"SELECT Status AS Value FROM Notifications WHERE NotificationId = {id}") + .SingleAsync(); + + Assert.Equal("Parked", statusText); + } +} + public class SiteRepositoryTests : IDisposable { private readonly ScadaLinkDbContext _context;