Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/NotificationOutboxRepositoryIntegrationTests.cs
T

161 lines
6.3 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.Commons.Types.Notifications;
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);
}
[SkippableFact]
public async Task QueryAsync_NotificationIdFilter_ReturnsExactlyThatRow()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
// Flow Debugger / Audit Log ?correlationId= deep link: an exact match on one
// NotificationId must return that single row and count it alone.
var wantedId = Guid.NewGuid().ToString();
var otherId = Guid.NewGuid().ToString();
await using (var context = CreateContext())
{
var repo = new NotificationOutboxRepository(context);
await repo.InsertIfNotExistsAsync(MakeNotification(wantedId, subject: "Wanted"));
await repo.InsertIfNotExistsAsync(MakeNotification(otherId, subject: "Other"));
}
await using var queryContext = CreateContext();
var queryRepo = new NotificationOutboxRepository(queryContext);
var (rows, totalCount) = await queryRepo.QueryAsync(
new NotificationOutboxFilter(NotificationId: wantedId), pageNumber: 1, pageSize: 50);
Assert.Equal(1, totalCount);
var row = Assert.Single(rows);
Assert.Equal(wantedId, row.NotificationId);
Assert.Equal("Wanted", row.Subject);
}
// --- 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),
};
}
}