513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
1505 lines
67 KiB
C#
1505 lines
67 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
|
|
|
// Regression coverage for repositories / InstanceLocator that lacked direct tests, and for
|
|
// inconsistent constructor null-guarding.
|
|
|
|
public class ExternalSystemRepositoryTests : IDisposable
|
|
{
|
|
private readonly ScadaBridgeDbContext _context;
|
|
private readonly ExternalSystemRepository _repository;
|
|
|
|
/// <summary>Initializes a new instance of the <c>ExternalSystemRepositoryTests</c> class.</summary>
|
|
public ExternalSystemRepositoryTests()
|
|
{
|
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
|
_repository = new ExternalSystemRepository(_context);
|
|
}
|
|
|
|
/// <summary>Releases the resources held by this test class.</summary>
|
|
public void Dispose()
|
|
{
|
|
_context.Database.CloseConnection();
|
|
_context.Dispose();
|
|
}
|
|
|
|
/// <summary>Verifies that an added external system round-trips through get by id.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that the external system timeout seconds value round-trips.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddExternalSystem_TimeoutSeconds_RoundTrips()
|
|
{
|
|
var def = new ExternalSystemDefinition("Sys", "https://example.test", "ApiKey")
|
|
{
|
|
TimeoutSeconds = 15,
|
|
};
|
|
await _repository.AddExternalSystemAsync(def);
|
|
await _repository.SaveChangesAsync();
|
|
|
|
var loaded = await _repository.GetExternalSystemByIdAsync(def.Id);
|
|
Assert.NotNull(loaded);
|
|
Assert.Equal(15, loaded!.TimeoutSeconds);
|
|
}
|
|
|
|
/// <summary>Verifies that getting methods by external system id returns only the parent system's methods.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that deleting a database connection removes the entity.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that constructing an ExternalSystemRepository with a null context throws an ArgumentNullException.</summary>
|
|
[Fact]
|
|
public void Constructor_NullContext_Throws()
|
|
{
|
|
Assert.Throws<ArgumentNullException>(() => new ExternalSystemRepository(null!));
|
|
}
|
|
|
|
// ── Name-keyed repository lookups ──
|
|
|
|
/// <summary>Verifies that getting an external system by name returns the matching row.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that getting an external system by a missing name returns null.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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"));
|
|
}
|
|
|
|
/// <summary>Verifies that getting a method by name returns the method scoped to its parent system.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that getting a method by a missing name returns null.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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"));
|
|
}
|
|
|
|
/// <summary>Verifies that getting a database connection by name returns the matching row.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that getting a database connection by a missing name returns null.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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 ScadaBridgeDbContext _context;
|
|
private readonly NotificationRepository _repository;
|
|
|
|
/// <summary>Initializes a new instance of the <c>NotificationRepositoryTests</c> class.</summary>
|
|
public NotificationRepositoryTests()
|
|
{
|
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
|
_repository = new NotificationRepository(_context);
|
|
}
|
|
|
|
/// <summary>Releases the resources held by this test class.</summary>
|
|
public void Dispose()
|
|
{
|
|
_context.Database.CloseConnection();
|
|
_context.Dispose();
|
|
}
|
|
|
|
/// <summary>Verifies that an added notification list with recipients round-trips.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that an added SMTP configuration round-trips through get by id.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that an added SMS configuration round-trips through get by id.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddSmsConfiguration_AndGetById_RoundTrips()
|
|
{
|
|
var sms = new SmsConfiguration("ACbyid123", "+14155550111");
|
|
await _repository.AddSmsConfigurationAsync(sms);
|
|
await _repository.SaveChangesAsync();
|
|
|
|
var loaded = await _repository.GetSmsConfigurationByIdAsync(sms.Id);
|
|
Assert.NotNull(loaded);
|
|
Assert.Equal("ACbyid123", loaded!.AccountSid);
|
|
}
|
|
|
|
/// <summary>Verifies that deleting a notification list removes the entity.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that an added SMS configuration round-trips.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddSmsConfiguration_RoundTrips()
|
|
{
|
|
var sms = new SmsConfiguration("ACtest123", "+14155550100");
|
|
await _repository.AddSmsConfigurationAsync(sms);
|
|
await _repository.SaveChangesAsync();
|
|
|
|
var loaded = await _repository.GetSmsConfigurationAsync();
|
|
Assert.NotNull(loaded);
|
|
Assert.Equal("ACtest123", loaded!.AccountSid);
|
|
Assert.Equal("+14155550100", loaded.FromNumber);
|
|
|
|
// Update a field and verify GetAllSmsConfigurationsAsync reflects it.
|
|
loaded.FromNumber = "+14155550199";
|
|
await _repository.UpdateSmsConfigurationAsync(loaded);
|
|
await _repository.SaveChangesAsync();
|
|
|
|
var all = await _repository.GetAllSmsConfigurationsAsync();
|
|
Assert.Single(all);
|
|
Assert.Equal("+14155550199", all[0].FromNumber);
|
|
|
|
// Delete and verify removal.
|
|
await _repository.DeleteSmsConfigurationAsync(loaded.Id);
|
|
await _repository.SaveChangesAsync();
|
|
|
|
Assert.Null(await _repository.GetSmsConfigurationAsync());
|
|
}
|
|
|
|
/// <summary>Verifies that a notification list persists its type.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that constructing a NotificationRepository with a null context throws an ArgumentNullException.</summary>
|
|
[Fact]
|
|
public void Constructor_NullContext_Throws()
|
|
{
|
|
Assert.Throws<ArgumentNullException>(() => new NotificationRepository(null!));
|
|
}
|
|
}
|
|
|
|
public class NotificationOutboxConfigurationTests : IDisposable
|
|
{
|
|
private readonly ScadaBridgeDbContext _context;
|
|
|
|
/// <summary>Initializes a new instance of the <c>NotificationOutboxConfigurationTests</c> class.</summary>
|
|
public NotificationOutboxConfigurationTests()
|
|
{
|
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
|
}
|
|
|
|
/// <summary>Releases the resources held by this test class.</summary>
|
|
public void Dispose()
|
|
{
|
|
_context.Database.CloseConnection();
|
|
_context.Dispose();
|
|
}
|
|
|
|
/// <summary>Verifies that a fully populated notification round-trips.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that a null origin execution id round-trips as null.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Notification_NullOriginExecutionId_RoundTripsAsNull()
|
|
{
|
|
// 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);
|
|
}
|
|
|
|
/// <summary>Verifies that a null origin parent execution id round-trips as null.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that the notification status is persisted as a string.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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 (notification-outbox feature).
|
|
public class NotificationOutboxRepositoryTests : IDisposable
|
|
{
|
|
private readonly ScadaBridgeDbContext _context;
|
|
private readonly NotificationOutboxRepository _repository;
|
|
|
|
/// <summary>Initializes a new instance of the <c>NotificationOutboxRepositoryTests</c> class.</summary>
|
|
public NotificationOutboxRepositoryTests()
|
|
{
|
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
|
_repository = new NotificationOutboxRepository(_context);
|
|
}
|
|
|
|
/// <summary>Releases the resources held by this test class.</summary>
|
|
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,
|
|
};
|
|
}
|
|
|
|
// InsertIfNotExistsAsync coverage lives in
|
|
// tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/NotificationOutboxRepositoryIntegrationTests.cs
|
|
// — the method is raw-SQL (IF NOT EXISTS … INSERT) so it must execute against
|
|
// SQL Server, not the SQLite in-memory provider this class uses.
|
|
|
|
/// <summary>Verifies that get-due returns pending and due retrying notifications, ordered by creation time and capped at the batch size.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that get by id returns the row when present and null otherwise.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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"));
|
|
}
|
|
|
|
/// <summary>Verifies that an update persists the status transition.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
Assert.True(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The write is a targeted UPDATE that bypasses the change tracker, so a row
|
|
/// deleted between the read and the write simply matches nothing. The row
|
|
/// count is the ONLY not-found signal available, and callers (the operator
|
|
/// retry/discard one-shots) depend on it to avoid reporting success against a
|
|
/// purged notification.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UpdateAsync_RowPurgedBeforeWrite_ReturnsFalse()
|
|
{
|
|
var id = Guid.NewGuid().ToString();
|
|
_context.Notifications.Add(MakeNotification(id, NotificationStatus.Parked));
|
|
await _context.SaveChangesAsync();
|
|
_context.ChangeTracker.Clear();
|
|
|
|
var loaded = await _repository.GetByIdAsync(id);
|
|
Assert.NotNull(loaded);
|
|
|
|
// The daily retention purge removes the row while the operator's request
|
|
// is in flight.
|
|
await _context.Notifications.Where(n => n.NotificationId == id).ExecuteDeleteAsync();
|
|
_context.ChangeTracker.Clear();
|
|
|
|
loaded!.Status = NotificationStatus.Pending;
|
|
Assert.False(await _repository.UpdateAsync(loaded));
|
|
}
|
|
|
|
/// <summary>Verifies that a conditional update from Parked applies and returns true when the status is Parked.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task TryUpdateFromParkedAsync_StatusIsParked_UpdatesAndReturnsTrue()
|
|
{
|
|
var id = Guid.NewGuid().ToString();
|
|
_context.Notifications.Add(MakeNotification(id, NotificationStatus.Parked));
|
|
await _context.SaveChangesAsync();
|
|
_context.ChangeTracker.Clear();
|
|
|
|
var loaded = await _repository.GetByIdAsync(id);
|
|
loaded!.Status = NotificationStatus.Pending;
|
|
loaded.RetryCount = 0;
|
|
loaded.NextAttemptAt = null;
|
|
loaded.LastError = null;
|
|
Assert.True(await _repository.TryUpdateFromParkedAsync(loaded));
|
|
|
|
_context.ChangeTracker.Clear();
|
|
var reloaded = await _context.Notifications.FindAsync(id);
|
|
Assert.Equal(NotificationStatus.Pending, reloaded!.Status);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Direct CAS-miss: the row is currently Delivered (never Parked in this
|
|
/// scenario), so the conditional WHERE Status == Parked matches nothing and
|
|
/// the row is left completely untouched.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task TryUpdateFromParkedAsync_StatusIsNotParked_ReturnsFalse_AndLeavesRowUnchanged()
|
|
{
|
|
var id = Guid.NewGuid().ToString();
|
|
var deliveredAt = new DateTimeOffset(2026, 5, 19, 9, 0, 0, TimeSpan.Zero);
|
|
_context.Notifications.Add(MakeNotification(
|
|
id, NotificationStatus.Delivered, deliveredAt: deliveredAt));
|
|
await _context.SaveChangesAsync();
|
|
_context.ChangeTracker.Clear();
|
|
|
|
var staleDiscard = MakeNotification(id, NotificationStatus.Discarded);
|
|
Assert.False(await _repository.TryUpdateFromParkedAsync(staleDiscard));
|
|
|
|
_context.ChangeTracker.Clear();
|
|
var reloaded = await _context.Notifications.FindAsync(id);
|
|
Assert.Equal(NotificationStatus.Delivered, reloaded!.Status);
|
|
Assert.Equal(deliveredAt, reloaded.DeliveredAt);
|
|
}
|
|
|
|
/// <summary>Verifies that a conditional update from Parked returns false when the row was purged before the write.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task TryUpdateFromParkedAsync_RowPurgedBeforeWrite_ReturnsFalse()
|
|
{
|
|
var id = Guid.NewGuid().ToString();
|
|
_context.Notifications.Add(MakeNotification(id, NotificationStatus.Parked));
|
|
await _context.SaveChangesAsync();
|
|
_context.ChangeTracker.Clear();
|
|
|
|
var loaded = await _repository.GetByIdAsync(id);
|
|
Assert.NotNull(loaded);
|
|
|
|
await _context.Notifications.Where(n => n.NotificationId == id).ExecuteDeleteAsync();
|
|
_context.ChangeTracker.Clear();
|
|
|
|
loaded!.Status = NotificationStatus.Discarded;
|
|
Assert.False(await _repository.TryUpdateFromParkedAsync(loaded));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deterministic (non-sleep) proof of the race:
|
|
/// the operator reads a Parked row, but before its write lands the
|
|
/// dispatcher (or a concurrent second operator action) has already moved
|
|
/// the row to Delivered. <see cref="TryUpdateFromParkedAsync"/>'s CAS must
|
|
/// refuse the stale write and leave the Delivered row exactly as it is.
|
|
/// The companion fact below proves the general-purpose
|
|
/// <see cref="NotificationOutboxRepository.UpdateAsync"/> — the method the
|
|
/// operator path used PRE-FIX — has no such guard, which is the mechanism
|
|
/// the finding describes.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task TryUpdateFromParkedAsync_ConcurrentlyDelivered_ReturnsFalse_AndDoesNotOverwrite()
|
|
{
|
|
var id = Guid.NewGuid().ToString();
|
|
_context.Notifications.Add(MakeNotification(id, NotificationStatus.Parked));
|
|
await _context.SaveChangesAsync();
|
|
_context.ChangeTracker.Clear();
|
|
|
|
// The operator's read (this snapshot is carried forward stale below).
|
|
var operatorRead = await _repository.GetByIdAsync(id);
|
|
Assert.NotNull(operatorRead);
|
|
|
|
// The dispatcher's own write lands first, in the window between the
|
|
// operator's read and write.
|
|
var deliveredAt = new DateTimeOffset(2026, 5, 19, 9, 0, 0, TimeSpan.Zero);
|
|
var dispatcherRead = await _repository.GetByIdAsync(id);
|
|
dispatcherRead!.Status = NotificationStatus.Delivered;
|
|
dispatcherRead.DeliveredAt = deliveredAt;
|
|
Assert.True(await _repository.UpdateAsync(dispatcherRead));
|
|
_context.ChangeTracker.Clear();
|
|
|
|
// The operator's stale in-memory copy still says Parked; it attempts a Discard.
|
|
operatorRead!.Status = NotificationStatus.Discarded;
|
|
Assert.False(await _repository.TryUpdateFromParkedAsync(operatorRead));
|
|
|
|
_context.ChangeTracker.Clear();
|
|
var reloaded = await _context.Notifications.FindAsync(id);
|
|
Assert.Equal(NotificationStatus.Delivered, reloaded!.Status);
|
|
Assert.Equal(deliveredAt, reloaded.DeliveredAt);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Companion to <see cref="TryUpdateFromParkedAsync_ConcurrentlyDelivered_ReturnsFalse_AndDoesNotOverwrite"/>:
|
|
/// runs the IDENTICAL interleaving through the unconditional
|
|
/// <see cref="NotificationOutboxRepository.UpdateAsync"/> instead — the
|
|
/// method the operator Retry/Discard path called pre-fix — and shows it
|
|
/// silently clobbers the Delivered row. This documents the exact defect
|
|
/// fixed by routing the operator path through
|
|
/// <see cref="TryUpdateFromParkedAsync"/>: UpdateAsync itself is unchanged
|
|
/// (the dispatcher still needs an unconditional write), so this scenario
|
|
/// remains "true" by design — it is the operator actor's call-site choice
|
|
/// that was the bug, proven fixed by the actor-level tests in
|
|
/// NotificationOutbox.Tests (Discard_ConcurrentlyDelivered_...).
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UpdateAsync_Unconditional_WouldOverwriteConcurrentlyDeliveredRow_DemonstratingP5_4Mechanism()
|
|
{
|
|
var id = Guid.NewGuid().ToString();
|
|
_context.Notifications.Add(MakeNotification(id, NotificationStatus.Parked));
|
|
await _context.SaveChangesAsync();
|
|
_context.ChangeTracker.Clear();
|
|
|
|
var operatorRead = await _repository.GetByIdAsync(id);
|
|
Assert.NotNull(operatorRead);
|
|
|
|
var deliveredAt = new DateTimeOffset(2026, 5, 19, 9, 0, 0, TimeSpan.Zero);
|
|
var dispatcherRead = await _repository.GetByIdAsync(id);
|
|
dispatcherRead!.Status = NotificationStatus.Delivered;
|
|
dispatcherRead.DeliveredAt = deliveredAt;
|
|
Assert.True(await _repository.UpdateAsync(dispatcherRead));
|
|
_context.ChangeTracker.Clear();
|
|
|
|
operatorRead!.Status = NotificationStatus.Discarded;
|
|
// The unconditional UPDATE has no status guard, so it "succeeds" here —
|
|
// this is the bug. It clobbers the Delivered row to Discarded and drops
|
|
// DeliveredAt, which is exactly the silent-overwrite hazard the conditional update closes
|
|
// by moving the operator path to TryUpdateFromParkedAsync.
|
|
Assert.True(await _repository.UpdateAsync(operatorRead));
|
|
|
|
_context.ChangeTracker.Clear();
|
|
var reloaded = await _context.Notifications.FindAsync(id);
|
|
Assert.Equal(NotificationStatus.Discarded, reloaded!.Status);
|
|
Assert.Null(reloaded.DeliveredAt);
|
|
}
|
|
|
|
/// <summary>Verifies that a query applies its filters, orders by creation time descending and paginates.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that a query's subject keyword filter matches by containment.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that a stuck-only query returns non-terminal rows older than the cutoff.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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());
|
|
}
|
|
|
|
/// <summary>Verifies that a query's from/to filter applies to the creation time.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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());
|
|
}
|
|
|
|
/// <summary>Verifies that a query's status and type filters apply.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that deleting terminal rows older than the cutoff removes only those rows and leaves the others.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that computing KPIs populates the snapshot fields.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that computing KPIs with no non-terminal rows yields a null oldest-pending age.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that constructing a NotificationOutboxRepository with a null context throws an ArgumentNullException.</summary>
|
|
[Fact]
|
|
public void Constructor_NullContext_Throws()
|
|
{
|
|
Assert.Throws<ArgumentNullException>(() => new NotificationOutboxRepository(null!));
|
|
}
|
|
}
|
|
|
|
public class SiteRepositoryTests : IDisposable
|
|
{
|
|
private readonly ScadaBridgeDbContext _context;
|
|
private readonly SiteRepository _repository;
|
|
|
|
/// <summary>Initializes a new instance of the <c>SiteRepositoryTests</c> class.</summary>
|
|
public SiteRepositoryTests()
|
|
{
|
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
|
_repository = new SiteRepository(_context);
|
|
}
|
|
|
|
/// <summary>Releases the resources held by this test class.</summary>
|
|
public void Dispose()
|
|
{
|
|
_context.Database.CloseConnection();
|
|
_context.Dispose();
|
|
}
|
|
|
|
/// <summary>Verifies that an added site round-trips through get by identifier.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that deleting a site through the stub-attach path removes the entity.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that deleting a data connection through the stub-attach path removes the entity.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that getting instances by site id returns only that site's instances.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that getting instances referencing a data connection returns an empty list when there are no bindings.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task GetInstancesReferencingDataConnection_NoBindings_ReturnsEmpty()
|
|
{
|
|
var site = new Site("Site1", "S-001");
|
|
var template = new Template("T1");
|
|
_context.Sites.Add(site);
|
|
_context.Templates.Add(template);
|
|
await _context.SaveChangesAsync();
|
|
|
|
var conn = new DataConnection("Conn1", "OpcUa", site.Id);
|
|
_context.DataConnections.Add(conn);
|
|
_context.Instances.Add(new Instance("I1") { SiteId = site.Id, TemplateId = template.Id });
|
|
await _context.SaveChangesAsync();
|
|
|
|
var result = await _repository.GetInstancesReferencingDataConnectionAsync(conn.Id);
|
|
Assert.Empty(result);
|
|
}
|
|
|
|
/// <summary>Verifies that getting instances referencing a data connection returns the instance bound to it.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task GetInstancesReferencingDataConnection_OneBinding_ReturnsThatInstance()
|
|
{
|
|
var site = new Site("Site1", "S-001");
|
|
var template = new Template("T1");
|
|
_context.Sites.Add(site);
|
|
_context.Templates.Add(template);
|
|
await _context.SaveChangesAsync();
|
|
|
|
var conn = new DataConnection("Conn1", "OpcUa", site.Id);
|
|
_context.DataConnections.Add(conn);
|
|
var instance = new Instance("I1") { SiteId = site.Id, TemplateId = template.Id };
|
|
_context.Instances.Add(instance);
|
|
await _context.SaveChangesAsync();
|
|
|
|
_context.InstanceConnectionBindings.Add(
|
|
new InstanceConnectionBinding("Attr1") { InstanceId = instance.Id, DataConnectionId = conn.Id });
|
|
await _context.SaveChangesAsync();
|
|
|
|
var result = await _repository.GetInstancesReferencingDataConnectionAsync(conn.Id);
|
|
Assert.Single(result);
|
|
Assert.Equal(instance.Id, result[0].Id);
|
|
}
|
|
|
|
/// <summary>Verifies that getting instances referencing a data connection returns one instance when two bindings belong to the same instance.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task GetInstancesReferencingDataConnection_TwoBindingsSameInstance_ReturnsOne()
|
|
{
|
|
var site = new Site("Site1", "S-001");
|
|
var template = new Template("T1");
|
|
_context.Sites.Add(site);
|
|
_context.Templates.Add(template);
|
|
await _context.SaveChangesAsync();
|
|
|
|
var conn = new DataConnection("Conn1", "OpcUa", site.Id);
|
|
_context.DataConnections.Add(conn);
|
|
var instance = new Instance("I1") { SiteId = site.Id, TemplateId = template.Id };
|
|
_context.Instances.Add(instance);
|
|
await _context.SaveChangesAsync();
|
|
|
|
// Two distinct bindings (different attributes) on the SAME instance both
|
|
// reference the connection -> the result must be deduped to one instance.
|
|
_context.InstanceConnectionBindings.Add(
|
|
new InstanceConnectionBinding("Attr1") { InstanceId = instance.Id, DataConnectionId = conn.Id });
|
|
_context.InstanceConnectionBindings.Add(
|
|
new InstanceConnectionBinding("Attr2") { InstanceId = instance.Id, DataConnectionId = conn.Id });
|
|
await _context.SaveChangesAsync();
|
|
|
|
var result = await _repository.GetInstancesReferencingDataConnectionAsync(conn.Id);
|
|
Assert.Single(result);
|
|
Assert.Equal(instance.Id, result[0].Id);
|
|
}
|
|
|
|
/// <summary>Verifies that constructing a SiteRepository with a null context throws an ArgumentNullException.</summary>
|
|
[Fact]
|
|
public void Constructor_NullContext_Throws()
|
|
{
|
|
Assert.Throws<ArgumentNullException>(() => new SiteRepository(null!));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A data connection's protocol and primary/backup configuration are flattening
|
|
/// inputs — <c>FlatteningService</c> packages them into the flattened config's
|
|
/// <c>Connections</c> map and <c>RevisionHashService</c> folds them into the
|
|
/// revision hash. Without a watermark bump on save, the process-wide
|
|
/// <c>StaleInstanceProbe</c> memo and the flatten-session caches keep serving the
|
|
/// pre-edit hash, so an instance whose deployed config genuinely drifted reads as
|
|
/// up to date. (The bump is unattributed — a connection has no owning template —
|
|
/// so both the global and structure counters must move.)
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task SaveChanges_DataConnectionEdit_BumpsWatermark()
|
|
{
|
|
var watermark = new TemplateGraphWatermark();
|
|
var repository = new SiteRepository(_context, watermark);
|
|
|
|
var site = new Site("Site1", "S-001");
|
|
await repository.AddSiteAsync(site);
|
|
await repository.SaveChangesAsync();
|
|
|
|
var afterSiteOnly = (watermark.Global, watermark.StructureVersion);
|
|
|
|
var conn = new DataConnection("Conn1", "OpcUa", site.Id);
|
|
await repository.AddDataConnectionAsync(conn);
|
|
await repository.SaveChangesAsync();
|
|
|
|
Assert.True(watermark.Global > afterSiteOnly.Global, "adding a data connection did not bump the global watermark");
|
|
Assert.True(watermark.StructureVersion > afterSiteOnly.StructureVersion,
|
|
"adding a data connection did not bump the structure watermark");
|
|
|
|
var afterAdd = (watermark.Global, watermark.StructureVersion);
|
|
|
|
conn.Protocol = "MxGateway";
|
|
await repository.UpdateDataConnectionAsync(conn);
|
|
await repository.SaveChangesAsync();
|
|
|
|
Assert.True(watermark.Global > afterAdd.Global, "editing a data connection did not bump the watermark");
|
|
|
|
var afterUpdate = watermark.Global;
|
|
|
|
await repository.DeleteDataConnectionAsync(conn.Id);
|
|
await repository.SaveChangesAsync();
|
|
|
|
Assert.True(watermark.Global > afterUpdate, "deleting a data connection did not bump the watermark");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The bump is scoped to the entity that actually feeds the flattener: a save
|
|
/// that touches no data connection must leave the watermark alone, or every
|
|
/// site/area edit would needlessly invalidate the whole flatten cache.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task SaveChanges_WithoutDataConnectionChange_DoesNotBumpWatermark()
|
|
{
|
|
var watermark = new TemplateGraphWatermark();
|
|
var repository = new SiteRepository(_context, watermark);
|
|
|
|
var site = new Site("Site1", "S-001");
|
|
await repository.AddSiteAsync(site);
|
|
await repository.SaveChangesAsync();
|
|
|
|
Assert.Equal(0, watermark.Global);
|
|
Assert.Equal(0, watermark.StructureVersion);
|
|
}
|
|
}
|
|
|
|
public class DeploymentManagerRepositoryTests : IDisposable
|
|
{
|
|
private readonly ScadaBridgeDbContext _context;
|
|
private readonly DeploymentManagerRepository _repository;
|
|
|
|
/// <summary>Initializes a new instance of the <c>DeploymentManagerRepositoryTests</c> class.</summary>
|
|
public DeploymentManagerRepositoryTests()
|
|
{
|
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
|
_repository = new DeploymentManagerRepository(_context);
|
|
}
|
|
|
|
/// <summary>Releases the resources held by this test class.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Verifies that getting the current deployment status returns the most recently added record.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that when records share a timestamp, the current deployment status picks the highest id as a deterministic tiebreaker.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task GetCurrentDeploymentStatus_SameTickRecords_DeterministicTiebreakerPicksHighestId()
|
|
{
|
|
// Deployments are insert-only, so two records for the
|
|
// same instance can tie on DeployedAt when created within the same clock tick
|
|
// (rapid redeploy, or redeploy right after a timed-out attempt). Without a
|
|
// secondary sort key the "current" read is non-deterministic. The repository
|
|
// now orders by DeployedAt DESC, THEN Id DESC, so the most recently inserted
|
|
// (highest Id) row wins the tie deterministically.
|
|
var instance = await SeedInstanceAsync();
|
|
var sameInstant = DateTimeOffset.UtcNow;
|
|
|
|
await _repository.AddDeploymentRecordAsync(
|
|
new DeploymentRecord("d-old", "admin") { InstanceId = instance.Id, DeployedAt = sameInstant });
|
|
await _repository.SaveChangesAsync();
|
|
await _repository.AddDeploymentRecordAsync(
|
|
new DeploymentRecord("d-new", "admin") { InstanceId = instance.Id, DeployedAt = sameInstant });
|
|
await _repository.SaveChangesAsync();
|
|
|
|
var current = await _repository.GetCurrentDeploymentStatusAsync(instance.Id);
|
|
Assert.NotNull(current);
|
|
// d-new was inserted second → it has the higher Id and must win the tie.
|
|
Assert.Equal("d-new", current!.DeploymentId);
|
|
}
|
|
|
|
/// <summary>Verifies that deleting a deployment record through the stub-attach path removes the entity.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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;
|
|
var rowVersion = record.RowVersion ?? Array.Empty<byte>();
|
|
_context.ChangeTracker.Clear();
|
|
|
|
await _repository.DeleteDeploymentRecordAsync(id, rowVersion);
|
|
await _repository.SaveChangesAsync();
|
|
|
|
Assert.Null(await _repository.GetDeploymentRecordByIdAsync(id));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Optimistic concurrency was added to
|
|
/// <c>DeleteDeploymentRecordAsync(int id, byte[] expectedRowVersion)</c> — the stub-attach
|
|
/// path now seeds <c>OriginalValues["RowVersion"]</c> from the caller's last-observed
|
|
/// value so the generated SQL becomes <c>DELETE … WHERE Id = @id AND RowVersion = @prior</c>.
|
|
/// This test pins the production-shape happy path: caller holds the entity's CURRENT
|
|
/// RowVersion, clears the change-tracker (i.e. no tracked instance — exactly the M&V
|
|
/// admin / handler shape), calls Delete with that token, and the delete completes
|
|
/// without throwing <see cref="Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException"/>.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task DeleteDeploymentRecord_CurrentRowVersion_StubAttachPath_DeleteSucceeds()
|
|
{
|
|
var instance = await SeedInstanceAsync();
|
|
var record = new DeploymentRecord("d-rv-001", "admin")
|
|
{
|
|
InstanceId = instance.Id,
|
|
DeployedAt = DateTimeOffset.UtcNow,
|
|
};
|
|
await _repository.AddDeploymentRecordAsync(record);
|
|
await _repository.SaveChangesAsync();
|
|
|
|
// Capture the entity's CURRENT RowVersion (the one the caller would have
|
|
// read from a prior GetDeploymentRecordByIdAsync), then detach so the
|
|
// delete travels through the stub-attach branch (no tracked entity).
|
|
var id = record.Id;
|
|
var currentRowVersion = record.RowVersion ?? Array.Empty<byte>();
|
|
_context.ChangeTracker.Clear();
|
|
|
|
// No NotSupported/Concurrency exception should fire on this code path.
|
|
await _repository.DeleteDeploymentRecordAsync(id, currentRowVersion);
|
|
var affected = await _repository.SaveChangesAsync();
|
|
|
|
Assert.Equal(1, affected);
|
|
Assert.Null(await _repository.GetDeploymentRecordByIdAsync(id));
|
|
}
|
|
|
|
/// <summary>Verifies that deleting an instance first removes its deployment records, which have a restrict foreign key.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that getting an instance by id includes its native alarm source overrides.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task GetInstanceByIdAsync_IncludesNativeAlarmSourceOverrides()
|
|
{
|
|
// Regression: NativeAlarmSourceOverrides was omitted from the Include chain.
|
|
var instance = await SeedInstanceAsync();
|
|
instance.NativeAlarmSourceOverrides.Add(
|
|
new InstanceNativeAlarmSourceOverride("src/AlarmA") { InstanceId = instance.Id });
|
|
await _context.SaveChangesAsync();
|
|
_context.ChangeTracker.Clear();
|
|
|
|
var loaded = await _repository.GetInstanceByIdAsync(instance.Id);
|
|
|
|
Assert.NotNull(loaded);
|
|
Assert.Single(loaded!.NativeAlarmSourceOverrides);
|
|
Assert.Equal("src/AlarmA", loaded.NativeAlarmSourceOverrides.First().SourceCanonicalName);
|
|
}
|
|
|
|
/// <summary>Verifies that getting an instance by unique name includes its native alarm source overrides.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task GetInstanceByUniqueNameAsync_IncludesNativeAlarmSourceOverrides()
|
|
{
|
|
// Regression: NativeAlarmSourceOverrides was omitted from the Include chain.
|
|
var instance = await SeedInstanceAsync();
|
|
instance.NativeAlarmSourceOverrides.Add(
|
|
new InstanceNativeAlarmSourceOverride("src/AlarmB") { InstanceId = instance.Id });
|
|
await _context.SaveChangesAsync();
|
|
_context.ChangeTracker.Clear();
|
|
|
|
var loaded = await _repository.GetInstanceByUniqueNameAsync(instance.UniqueName);
|
|
|
|
Assert.NotNull(loaded);
|
|
Assert.Single(loaded!.NativeAlarmSourceOverrides);
|
|
Assert.Equal("src/AlarmB", loaded.NativeAlarmSourceOverrides.First().SourceCanonicalName);
|
|
}
|
|
|
|
/// <summary>Verifies that constructing a DeploymentManagerRepository with a null context throws an ArgumentNullException.</summary>
|
|
[Fact]
|
|
public void Constructor_NullContext_Throws()
|
|
{
|
|
Assert.Throws<ArgumentNullException>(() => new DeploymentManagerRepository(null!));
|
|
}
|
|
}
|
|
|
|
public class InstanceLocatorTests : IDisposable
|
|
{
|
|
private readonly ScadaBridgeDbContext _context;
|
|
private readonly InstanceLocator _locator;
|
|
|
|
/// <summary>Initializes a new instance of the <c>InstanceLocatorTests</c> class.</summary>
|
|
public InstanceLocatorTests()
|
|
{
|
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
|
_locator = new InstanceLocator(_context);
|
|
}
|
|
|
|
/// <summary>Releases the resources held by this test class.</summary>
|
|
public void Dispose()
|
|
{
|
|
_context.Database.CloseConnection();
|
|
_context.Dispose();
|
|
}
|
|
|
|
/// <summary>Verifies that getting the site id for an existing instance returns the site identifier.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that getting the site id for a missing instance returns null.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task GetSiteIdForInstance_WhenInstanceNotFound_ReturnsNull()
|
|
{
|
|
var result = await _locator.GetSiteIdForInstanceAsync("DoesNotExist");
|
|
Assert.Null(result);
|
|
}
|
|
|
|
/// <summary>Verifies that constructing an InstanceLocator with a null context throws an ArgumentNullException.</summary>
|
|
[Fact]
|
|
public void Constructor_NullContext_Throws()
|
|
{
|
|
Assert.Throws<ArgumentNullException>(() => new InstanceLocator(null!));
|
|
}
|
|
}
|