7b0b9c7365
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.
133 lines
5.0 KiB
C#
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),
|
|
};
|
|
}
|
|
}
|