Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/NotificationOutboxRepositoryIntegrationTests.cs
T
Joseph Doherty 7b0b9c7365 refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
2026-05-28 09:37:45 -04:00

133 lines
5.0 KiB
C#

using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories;
/// <summary>
/// CD-015 race-fix integration tests for
/// <see cref="NotificationOutboxRepository.InsertIfNotExistsAsync"/>. The method
/// is raw-SQL (<c>IF NOT EXISTS … INSERT</c>) matching the AuditLog and SiteCalls
/// idempotent-insert pattern; it must execute against a real SQL Server schema,
/// so this class uses <see cref="MsSqlMigrationFixture"/> rather than the SQLite
/// in-memory provider used by <see cref="RepositoryCoverageTests"/>.
/// </summary>
public class NotificationOutboxRepositoryIntegrationTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public NotificationOutboxRepositoryIntegrationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
[SkippableFact]
public async Task InsertIfNotExistsAsync_FreshId_InsertsAndReturnsTrue()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var id = Guid.NewGuid().ToString();
await using var context = CreateContext();
var repo = new NotificationOutboxRepository(context);
var inserted = await repo.InsertIfNotExistsAsync(MakeNotification(id));
Assert.True(inserted);
await using var readContext = CreateContext();
var loaded = await readContext.Notifications.FindAsync(id);
Assert.NotNull(loaded);
Assert.Equal("Subject", loaded!.Subject);
}
[SkippableFact]
public async Task InsertIfNotExistsAsync_DuplicateId_ReturnsFalseAndLeavesExistingRow()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var id = Guid.NewGuid().ToString();
await using (var context = CreateContext())
{
var repo = new NotificationOutboxRepository(context);
await repo.InsertIfNotExistsAsync(MakeNotification(id, subject: "Original"));
}
await using (var context = CreateContext())
{
var repo = new NotificationOutboxRepository(context);
var inserted = await repo.InsertIfNotExistsAsync(MakeNotification(id, subject: "Changed"));
Assert.False(inserted);
}
await using var readContext = CreateContext();
var loaded = await readContext.Notifications.FindAsync(id);
Assert.NotNull(loaded);
Assert.Equal("Original", loaded!.Subject);
}
[SkippableFact]
public async Task InsertIfNotExistsAsync_ConcurrentInserts_SameId_OnlyOneRow()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// CD-015 race coverage. The IF NOT EXISTS … INSERT pattern has a
// check-then-act window: two concurrent sessions can both pass the
// EXISTS check and both attempt the INSERT — the loser surfaces as a
// SqlException with Number 2601/2627. The site→central handoff is
// documented at-least-once with insert-if-not-exists, so this collision
// IS the expected contention mode. The race losers MUST be swallowed
// (not bubbled) so the site doesn't retry the same NotificationId
// forever. Final row count must be exactly 1; no exceptions thrown.
var id = Guid.NewGuid().ToString();
await Parallel.ForEachAsync(
Enumerable.Range(0, 50),
new ParallelOptions { MaxDegreeOfParallelism = 50 },
async (_, ct) =>
{
await using var context = CreateContext();
var repo = new NotificationOutboxRepository(context);
await repo.InsertIfNotExistsAsync(MakeNotification(id), ct);
});
await using var readContext = CreateContext();
var count = await readContext.Notifications
.Where(n => n.NotificationId == id)
.CountAsync();
Assert.Equal(1, count);
}
// --- helpers ------------------------------------------------------------
private ScadaBridgeDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
return new ScadaBridgeDbContext(options);
}
private static Notification MakeNotification(
string id,
NotificationStatus status = NotificationStatus.Pending,
string subject = "Subject")
{
return new Notification(
id,
NotificationType.Email,
"Ops List",
subject,
"Body",
"site-cd015")
{
Status = status,
CreatedAt = new DateTimeOffset(2026, 5, 20, 10, 0, 0, TimeSpan.Zero),
SiteEnqueuedAt = new DateTimeOffset(2026, 5, 20, 9, 59, 0, TimeSpan.Zero),
};
}
}