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.
171 lines
7.1 KiB
C#
171 lines
7.1 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>
|
|
/// 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;
|
|
|
|
/// <summary>Initializes a new instance of the <c>NotificationOutboxRepositoryIntegrationTests</c> class.</summary>
|
|
/// <param name="fixture">The shared database fixture.</param>
|
|
public NotificationOutboxRepositoryIntegrationTests(MsSqlMigrationFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
}
|
|
|
|
/// <summary>Verifies that inserting a fresh id inserts the row and returns true.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that inserting a duplicate id returns false and leaves the existing row untouched.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that concurrent inserts of the same id produce exactly one row.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[SkippableFact]
|
|
public async Task InsertIfNotExistsAsync_ConcurrentInserts_SameId_OnlyOneRow()
|
|
{
|
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
|
|
|
// 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);
|
|
}
|
|
|
|
/// <summary>Verifies that filtering by notification id returns exactly that row.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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),
|
|
};
|
|
}
|
|
}
|