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.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 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; 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!)); } }