Files
scadalink-design/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs

941 lines
36 KiB
C#

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<ArgumentNullException>(() => 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<ArgumentNullException>(() => 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 originParentExecutionId = 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,
OriginParentExecutionId = originParentExecutionId,
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);
Assert.Equal(originParentExecutionId, loaded.OriginParentExecutionId);
}
[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_NullOriginParentExecutionId_RoundTripsAsNull()
{
// Audit Log ParentExecutionId: OriginParentExecutionId is an additive
// nullable column — notifications from non-routed runs (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!.OriginParentExecutionId);
}
[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<string>($"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<ArgumentNullException>(() => 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<ArgumentNullException>(() => 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<Instance> 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<ArgumentNullException>(() => 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<ArgumentNullException>(() => new InstanceLocator(null!));
}
}