using Microsoft.EntityFrameworkCore; using ScadaLink.Commons.Entities.Notifications; using ScadaLink.Commons.Types.Enums; using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase.Repositories; using ScadaLink.ConfigurationDatabase.Tests.Migrations; using Xunit; namespace ScadaLink.ConfigurationDatabase.Tests.Repositories; /// /// CD-015 race-fix integration tests for /// . The method /// is raw-SQL (IF NOT EXISTS … INSERT) matching the AuditLog and SiteCalls /// idempotent-insert pattern; it must execute against a real SQL Server schema, /// so this class uses rather than the SQLite /// in-memory provider used by . /// public class NotificationOutboxRepositoryIntegrationTests : IClassFixture { 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 ScadaLinkDbContext CreateContext() { var options = new DbContextOptionsBuilder() .UseSqlServer(_fixture.ConnectionString) .Options; return new ScadaLinkDbContext(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), }; } }