using Microsoft.EntityFrameworkCore; using ScadaLink.Commons.Entities.Deployment; using ScadaLink.Commons.Entities.ExternalSystems; using ScadaLink.Commons.Entities.Instances; using ScadaLink.Commons.Entities.Notifications; using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Entities.Templates; using ScadaLink.Commons.Types.Enums; using ScadaLink.Commons.Types.Notifications; using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase.Repositories; using ScadaLink.ConfigurationDatabase.Services; namespace ScadaLink.ConfigurationDatabase.Tests; // Regression coverage for ConfigurationDatabase-010 (repositories / InstanceLocator lacked // direct tests) and ConfigurationDatabase-011 (inconsistent constructor null-guarding). public class ExternalSystemRepositoryTests : IDisposable { private readonly ScadaLinkDbContext _context; private readonly ExternalSystemRepository _repository; public ExternalSystemRepositoryTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _repository = new ExternalSystemRepository(_context); } public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } [Fact] public async Task AddExternalSystem_AndGetById_RoundTrips() { var def = new ExternalSystemDefinition("Sys", "https://example.test", "ApiKey"); await _repository.AddExternalSystemAsync(def); await _repository.SaveChangesAsync(); var loaded = await _repository.GetExternalSystemByIdAsync(def.Id); Assert.NotNull(loaded); Assert.Equal("Sys", loaded!.Name); } [Fact] public async Task GetMethodsByExternalSystemId_FiltersByParent() { var def = new ExternalSystemDefinition("Sys", "https://example.test", "ApiKey"); await _repository.AddExternalSystemAsync(def); await _repository.SaveChangesAsync(); await _repository.AddExternalSystemMethodAsync( new ExternalSystemMethod("M1", "GET", "/m1") { ExternalSystemDefinitionId = def.Id }); await _repository.SaveChangesAsync(); var methods = await _repository.GetMethodsByExternalSystemIdAsync(def.Id); Assert.Single(methods); } [Fact] public async Task DeleteDatabaseConnection_RemovesEntity() { var conn = new DatabaseConnectionDefinition("Db", "Server=x;Database=y;"); await _repository.AddDatabaseConnectionAsync(conn); await _repository.SaveChangesAsync(); await _repository.DeleteDatabaseConnectionAsync(conn.Id); await _repository.SaveChangesAsync(); Assert.Null(await _repository.GetDatabaseConnectionByIdAsync(conn.Id)); } [Fact] public void Constructor_NullContext_Throws() { Assert.Throws(() => new ExternalSystemRepository(null!)); } // ── ExternalSystemGateway-011: name-keyed repository lookups ── [Fact] public async Task GetExternalSystemByName_ReturnsMatchingRow() { await _repository.AddExternalSystemAsync( new ExternalSystemDefinition("Alpha", "https://alpha.test", "ApiKey")); await _repository.AddExternalSystemAsync( new ExternalSystemDefinition("Beta", "https://beta.test", "Basic")); await _repository.SaveChangesAsync(); var loaded = await _repository.GetExternalSystemByNameAsync("Beta"); Assert.NotNull(loaded); Assert.Equal("Beta", loaded!.Name); Assert.Equal("https://beta.test", loaded.EndpointUrl); } [Fact] public async Task GetExternalSystemByName_MissingName_ReturnsNull() { await _repository.AddExternalSystemAsync( new ExternalSystemDefinition("Alpha", "https://alpha.test", "ApiKey")); await _repository.SaveChangesAsync(); Assert.Null(await _repository.GetExternalSystemByNameAsync("DoesNotExist")); } [Fact] public async Task GetMethodByName_ReturnsMethodScopedToParentSystem() { var sysA = new ExternalSystemDefinition("SysA", "https://a.test", "ApiKey"); var sysB = new ExternalSystemDefinition("SysB", "https://b.test", "ApiKey"); await _repository.AddExternalSystemAsync(sysA); await _repository.AddExternalSystemAsync(sysB); await _repository.SaveChangesAsync(); // Same method name on two different systems — the lookup must be scoped. await _repository.AddExternalSystemMethodAsync( new ExternalSystemMethod("getData", "GET", "/a") { ExternalSystemDefinitionId = sysA.Id }); await _repository.AddExternalSystemMethodAsync( new ExternalSystemMethod("getData", "POST", "/b") { ExternalSystemDefinitionId = sysB.Id }); await _repository.SaveChangesAsync(); var method = await _repository.GetMethodByNameAsync(sysB.Id, "getData"); Assert.NotNull(method); Assert.Equal(sysB.Id, method!.ExternalSystemDefinitionId); Assert.Equal("POST", method.HttpMethod); Assert.Equal("/b", method.Path); } [Fact] public async Task GetMethodByName_MissingMethod_ReturnsNull() { var sys = new ExternalSystemDefinition("SysA", "https://a.test", "ApiKey"); await _repository.AddExternalSystemAsync(sys); await _repository.SaveChangesAsync(); Assert.Null(await _repository.GetMethodByNameAsync(sys.Id, "noSuchMethod")); } [Fact] public async Task GetDatabaseConnectionByName_ReturnsMatchingRow() { await _repository.AddDatabaseConnectionAsync( new DatabaseConnectionDefinition("Plant", "Server=plant;Database=p;")); await _repository.AddDatabaseConnectionAsync( new DatabaseConnectionDefinition("Historian", "Server=hist;Database=h;")); await _repository.SaveChangesAsync(); var loaded = await _repository.GetDatabaseConnectionByNameAsync("Historian"); Assert.NotNull(loaded); Assert.Equal("Historian", loaded!.Name); Assert.Equal("Server=hist;Database=h;", loaded.ConnectionString); } [Fact] public async Task GetDatabaseConnectionByName_MissingName_ReturnsNull() { await _repository.AddDatabaseConnectionAsync( new DatabaseConnectionDefinition("Plant", "Server=plant;Database=p;")); await _repository.SaveChangesAsync(); Assert.Null(await _repository.GetDatabaseConnectionByNameAsync("DoesNotExist")); } } public class NotificationRepositoryTests : IDisposable { private readonly ScadaLinkDbContext _context; private readonly NotificationRepository _repository; public NotificationRepositoryTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _repository = new NotificationRepository(_context); } public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } [Fact] public async Task AddNotificationList_WithRecipients_RoundTrips() { var list = new NotificationList("Ops"); list.Recipients.Add(new NotificationRecipient("Ops Team", "ops@example.test")); await _repository.AddNotificationListAsync(list); await _repository.SaveChangesAsync(); var loaded = await _repository.GetListByNameAsync("Ops"); Assert.NotNull(loaded); var all = await _repository.GetAllNotificationListsAsync(); Assert.Single(all); Assert.Single(all[0].Recipients); } [Fact] public async Task AddSmtpConfiguration_AndGetById_RoundTrips() { var smtp = new SmtpConfiguration("smtp.example.test", "Basic", "from@example.test"); await _repository.AddSmtpConfigurationAsync(smtp); await _repository.SaveChangesAsync(); var loaded = await _repository.GetSmtpConfigurationByIdAsync(smtp.Id); Assert.NotNull(loaded); Assert.Equal("smtp.example.test", loaded!.Host); } [Fact] public async Task DeleteNotificationList_RemovesEntity() { var list = new NotificationList("ToDelete"); await _repository.AddNotificationListAsync(list); await _repository.SaveChangesAsync(); await _repository.DeleteNotificationListAsync(list.Id); await _repository.SaveChangesAsync(); Assert.Null(await _repository.GetNotificationListByIdAsync(list.Id)); } [Fact] public async Task NotificationList_PersistsType() { var list = new NotificationList("ops") { Type = NotificationType.Email }; await _repository.AddNotificationListAsync(list); await _repository.SaveChangesAsync(); _context.ChangeTracker.Clear(); var loaded = await _repository.GetListByNameAsync("ops"); Assert.Equal(NotificationType.Email, loaded!.Type); } [Fact] public void Constructor_NullContext_Throws() { Assert.Throws(() => new NotificationRepository(null!)); } } 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 originExecutionId = Guid.NewGuid(); 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", OriginExecutionId = originExecutionId, 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); Assert.Equal(originExecutionId, loaded.OriginExecutionId); } [Fact] public async Task Notification_NullOriginExecutionId_RoundTripsAsNull() { // Audit Log #23: OriginExecutionId is an additive nullable column — // notifications raised outside a script execution (or submitted before // the column existed) persist and reload it as null. var id = Guid.NewGuid().ToString(); var notification = new Notification(id, NotificationType.Email, "Ops List", "Subject", "Body", "site-north"); _context.Notifications.Add(notification); await _context.SaveChangesAsync(); _context.ChangeTracker.Clear(); var loaded = await _context.Notifications.FindAsync(id); Assert.NotNull(loaded); Assert.Null(loaded!.OriginExecutionId); } [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); } } // 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(() => new NotificationOutboxRepository(null!)); } } public class SiteRepositoryTests : IDisposable { private readonly ScadaLinkDbContext _context; private readonly SiteRepository _repository; public SiteRepositoryTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _repository = new SiteRepository(_context); } public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } [Fact] public async Task AddSite_AndGetByIdentifier_RoundTrips() { var site = new Site("Site1", "S-001"); await _repository.AddSiteAsync(site); await _repository.SaveChangesAsync(); var loaded = await _repository.GetSiteByIdentifierAsync("S-001"); Assert.NotNull(loaded); Assert.Equal("Site1", loaded!.Name); } [Fact] public async Task DeleteSite_ViaStubAttachPath_RemovesEntity() { // Exercises the stub-attach delete fallback: the entity is not tracked because the // ChangeTracker is cleared, forcing the Local-miss branch in DeleteSiteAsync. var site = new Site("Site1", "S-001"); await _repository.AddSiteAsync(site); await _repository.SaveChangesAsync(); var id = site.Id; _context.ChangeTracker.Clear(); await _repository.DeleteSiteAsync(id); await _repository.SaveChangesAsync(); Assert.Null(await _repository.GetSiteByIdAsync(id)); } [Fact] public async Task DeleteDataConnection_ViaStubAttachPath_RemovesEntity() { var site = new Site("Site1", "S-001"); await _repository.AddSiteAsync(site); await _repository.SaveChangesAsync(); var conn = new DataConnection("Conn1", "OpcUa", site.Id); await _repository.AddDataConnectionAsync(conn); await _repository.SaveChangesAsync(); var id = conn.Id; _context.ChangeTracker.Clear(); await _repository.DeleteDataConnectionAsync(id); await _repository.SaveChangesAsync(); Assert.Null(await _repository.GetDataConnectionByIdAsync(id)); } [Fact] public async Task GetInstancesBySiteId_FiltersBySite() { var site = new Site("Site1", "S-001"); var template = new Template("T1"); _context.Sites.Add(site); _context.Templates.Add(template); await _context.SaveChangesAsync(); _context.Instances.Add(new Instance("I1") { SiteId = site.Id, TemplateId = template.Id }); await _context.SaveChangesAsync(); var instances = await _repository.GetInstancesBySiteIdAsync(site.Id); Assert.Single(instances); } [Fact] public void Constructor_NullContext_Throws() { Assert.Throws(() => new SiteRepository(null!)); } } public class DeploymentManagerRepositoryTests : IDisposable { private readonly ScadaLinkDbContext _context; private readonly DeploymentManagerRepository _repository; public DeploymentManagerRepositoryTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _repository = new DeploymentManagerRepository(_context); } public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } private async Task SeedInstanceAsync() { var site = new Site("Site1", "S-001"); var template = new Template("T1"); _context.Sites.Add(site); _context.Templates.Add(template); await _context.SaveChangesAsync(); var instance = new Instance("Inst1") { SiteId = site.Id, TemplateId = template.Id }; _context.Instances.Add(instance); await _context.SaveChangesAsync(); return instance; } [Fact] public async Task AddDeploymentRecord_AndGetCurrentStatus_ReturnsMostRecent() { var instance = await SeedInstanceAsync(); await _repository.AddDeploymentRecordAsync( new DeploymentRecord("d-001", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow.AddHours(-1) }); await _repository.AddDeploymentRecordAsync( new DeploymentRecord("d-002", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow }); await _repository.SaveChangesAsync(); var current = await _repository.GetCurrentDeploymentStatusAsync(instance.Id); Assert.NotNull(current); Assert.Equal("d-002", current!.DeploymentId); } [Fact] public async Task DeleteDeploymentRecord_ViaStubAttachPath_RemovesEntity() { var instance = await SeedInstanceAsync(); var record = new DeploymentRecord("d-001", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow }; await _repository.AddDeploymentRecordAsync(record); await _repository.SaveChangesAsync(); var id = record.Id; _context.ChangeTracker.Clear(); await _repository.DeleteDeploymentRecordAsync(id); await _repository.SaveChangesAsync(); Assert.Null(await _repository.GetDeploymentRecordByIdAsync(id)); } [Fact] public async Task DeleteInstance_RemovesRestrictFkDeploymentRecordsFirst() { // DeploymentRecord has a Restrict FK to Instance; DeleteInstanceAsync must remove // the dependent deployment records explicitly or the delete would fail. var instance = await SeedInstanceAsync(); await _repository.AddDeploymentRecordAsync( new DeploymentRecord("d-001", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow }); await _repository.SaveChangesAsync(); await _repository.DeleteInstanceAsync(instance.Id); await _repository.SaveChangesAsync(); Assert.Null(await _repository.GetInstanceByIdAsync(instance.Id)); Assert.Empty(await _repository.GetDeploymentsByInstanceIdAsync(instance.Id)); } [Fact] public void Constructor_NullContext_Throws() { Assert.Throws(() => new DeploymentManagerRepository(null!)); } } public class InstanceLocatorTests : IDisposable { private readonly ScadaLinkDbContext _context; private readonly InstanceLocator _locator; public InstanceLocatorTests() { _context = SqliteTestHelper.CreateInMemoryContext(); _locator = new InstanceLocator(_context); } public void Dispose() { _context.Database.CloseConnection(); _context.Dispose(); } [Fact] public async Task GetSiteIdForInstance_WhenFound_ReturnsSiteIdentifier() { var site = new Site("Site1", "SITE-001"); var template = new Template("T1"); _context.Sites.Add(site); _context.Templates.Add(template); await _context.SaveChangesAsync(); _context.Instances.Add(new Instance("Pump1") { SiteId = site.Id, TemplateId = template.Id }); await _context.SaveChangesAsync(); var result = await _locator.GetSiteIdForInstanceAsync("Pump1"); Assert.Equal("SITE-001", result); } [Fact] public async Task GetSiteIdForInstance_WhenInstanceNotFound_ReturnsNull() { var result = await _locator.GetSiteIdForInstanceAsync("DoesNotExist"); Assert.Null(result); } [Fact] public void Constructor_NullContext_Throws() { Assert.Throws(() => new InstanceLocator(null!)); } }