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.
This commit is contained in:
+1115
File diff suppressed because it is too large
Load Diff
+132
@@ -0,0 +1,132 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
+704
@@ -0,0 +1,704 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
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>
|
||||
/// Bundle B3 (#22, #23 M3) integration tests for <see cref="SiteCallAuditRepository"/>.
|
||||
/// Uses the same <see cref="MsSqlMigrationFixture"/> as the Bundle B2 migration tests so
|
||||
/// the monotonic-upsert SQL executes against the real <c>SiteCalls</c> schema. Each test
|
||||
/// scopes its data by minting a fresh <see cref="TrackedOperationId"/> (or a per-test
|
||||
/// <c>SourceSite</c> suffix) so tests neither collide nor require teardown.
|
||||
/// </summary>
|
||||
public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
private readonly MsSqlMigrationFixture _fixture;
|
||||
|
||||
public SiteCallAuditRepositoryTests(MsSqlMigrationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_FreshId_InsertsOneRow()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var id = TrackedOperationId.New();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
var row = NewRow(id, status: "Submitted", retryCount: 0);
|
||||
await repo.UpsertAsync(row);
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var loaded = await readContext.Set<SiteCall>()
|
||||
.Where(s => s.TrackedOperationId == id)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Single(loaded);
|
||||
Assert.Equal("Submitted", loaded[0].Status);
|
||||
Assert.Equal(0, loaded[0].RetryCount);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_AdvancedStatus_UpdatesRow()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var id = TrackedOperationId.New();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
// Submitted (rank 0) → Forwarded (rank 1) → Attempted (rank 2) — every
|
||||
// step strictly advances the rank, so each upsert must mutate the row.
|
||||
await repo.UpsertAsync(NewRow(id, status: "Submitted", retryCount: 0));
|
||||
await repo.UpsertAsync(NewRow(id, status: "Forwarded", retryCount: 0));
|
||||
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, lastError: "transient 503"));
|
||||
|
||||
var loaded = await repo.GetAsync(id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("Attempted", loaded!.Status);
|
||||
Assert.Equal(1, loaded.RetryCount);
|
||||
Assert.Equal("transient 503", loaded.LastError);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_OlderStatus_IsNoOp_RowUnchanged()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var id = TrackedOperationId.New();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
// First land Attempted (rank 2). A late-arriving Submitted (rank 0) must
|
||||
// NOT roll the row back — silent no-op.
|
||||
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 5, lastError: "transient"));
|
||||
var attemptedSnapshot = await repo.GetAsync(id);
|
||||
|
||||
await repo.UpsertAsync(NewRow(id, status: "Submitted", retryCount: 0, lastError: null));
|
||||
var afterStale = await repo.GetAsync(id);
|
||||
|
||||
Assert.NotNull(afterStale);
|
||||
Assert.Equal("Attempted", afterStale!.Status);
|
||||
Assert.Equal(5, afterStale.RetryCount);
|
||||
Assert.Equal("transient", afterStale.LastError);
|
||||
// UpdatedAtUtc should not have moved when the stale write was rejected.
|
||||
Assert.Equal(attemptedSnapshot!.UpdatedAtUtc, afterStale.UpdatedAtUtc);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_SameStatus_IsNoOp()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var id = TrackedOperationId.New();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, lastError: "first"));
|
||||
var snapshot = await repo.GetAsync(id);
|
||||
|
||||
// Same rank (2) — repository must treat this as a no-op (no fields move).
|
||||
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 2, lastError: "second"));
|
||||
var afterDuplicate = await repo.GetAsync(id);
|
||||
|
||||
Assert.NotNull(afterDuplicate);
|
||||
Assert.Equal("Attempted", afterDuplicate!.Status);
|
||||
Assert.Equal(1, afterDuplicate.RetryCount);
|
||||
Assert.Equal("first", afterDuplicate.LastError);
|
||||
Assert.Equal(snapshot!.UpdatedAtUtc, afterDuplicate.UpdatedAtUtc);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_TerminalOverTerminal_IsNoOp()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// Bundle B3 plan: terminal statuses share rank 3 and are mutually
|
||||
// exclusive — Delivered cannot overwrite Parked.
|
||||
var id = TrackedOperationId.New();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
await repo.UpsertAsync(NewRow(id, status: "Parked", retryCount: 3, lastError: "parked-reason", terminal: true));
|
||||
var afterPark = await repo.GetAsync(id);
|
||||
|
||||
await repo.UpsertAsync(NewRow(id, status: "Delivered", retryCount: 4, lastError: null, terminal: true));
|
||||
var afterDeliveredAttempt = await repo.GetAsync(id);
|
||||
|
||||
Assert.NotNull(afterDeliveredAttempt);
|
||||
Assert.Equal("Parked", afterDeliveredAttempt!.Status);
|
||||
Assert.Equal("parked-reason", afterDeliveredAttempt.LastError);
|
||||
Assert.Equal(afterPark!.UpdatedAtUtc, afterDeliveredAttempt.UpdatedAtUtc);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_ConcurrentInserts_SameId_OnlyOneRow()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// 50 parallel inserters with the same id. The IF NOT EXISTS … INSERT
|
||||
// pattern has a check-then-act race; concurrent losers must surface as
|
||||
// silent duplicate-key swallows, not thrown exceptions. Final row
|
||||
// count must be exactly 1.
|
||||
var id = TrackedOperationId.New();
|
||||
var row = NewRow(id, status: "Submitted", retryCount: 0);
|
||||
|
||||
await Parallel.ForEachAsync(
|
||||
Enumerable.Range(0, 50),
|
||||
new ParallelOptions { MaxDegreeOfParallelism = 50 },
|
||||
async (_, ct) =>
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
await repo.UpsertAsync(row, ct);
|
||||
});
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var count = await readContext.Set<SiteCall>()
|
||||
.Where(s => s.TrackedOperationId == id)
|
||||
.CountAsync();
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task GetAsync_KnownId_ReturnsRow()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var id = TrackedOperationId.New();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
await repo.UpsertAsync(NewRow(id, status: "Submitted", retryCount: 0));
|
||||
|
||||
var loaded = await repo.GetAsync(id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(id, loaded!.TrackedOperationId);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task GetAsync_UnknownId_ReturnsNull()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
var loaded = await repo.GetAsync(TrackedOperationId.New());
|
||||
Assert.Null(loaded);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task QueryAsync_FilterBySourceSite_ReturnsMatchingRows()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteA = NewSiteId();
|
||||
var siteB = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
var t0 = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), sourceSite: siteA, createdAtUtc: t0));
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), sourceSite: siteA, createdAtUtc: t0.AddMinutes(1)));
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), sourceSite: siteB, createdAtUtc: t0.AddMinutes(2)));
|
||||
|
||||
var rows = await repo.QueryAsync(
|
||||
new SiteCallQueryFilter(SourceSite: siteA),
|
||||
new SiteCallPaging(PageSize: 10));
|
||||
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.All(rows, r => Assert.Equal(siteA, r.SourceSite));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task QueryAsync_KeysetPaging_NoOverlap()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var site = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
// Five rows with distinct CreatedAtUtc. Page-size 2 → page 1 returns
|
||||
// minutes 4,3; cursor (minutes 3) → page 2 returns minutes 2,1; cursor
|
||||
// (minutes 1) → page 3 returns minute 0.
|
||||
var t0 = new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc);
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), sourceSite: site, createdAtUtc: t0.AddMinutes(i)));
|
||||
}
|
||||
|
||||
var page1 = await repo.QueryAsync(
|
||||
new SiteCallQueryFilter(SourceSite: site),
|
||||
new SiteCallPaging(PageSize: 2));
|
||||
Assert.Equal(2, page1.Count);
|
||||
Assert.Equal(t0.AddMinutes(4), page1[0].CreatedAtUtc);
|
||||
Assert.Equal(t0.AddMinutes(3), page1[1].CreatedAtUtc);
|
||||
|
||||
var cursor1 = page1[^1];
|
||||
var page2 = await repo.QueryAsync(
|
||||
new SiteCallQueryFilter(SourceSite: site),
|
||||
new SiteCallPaging(
|
||||
PageSize: 2,
|
||||
AfterCreatedAtUtc: cursor1.CreatedAtUtc,
|
||||
AfterId: cursor1.TrackedOperationId));
|
||||
Assert.Equal(2, page2.Count);
|
||||
Assert.Equal(t0.AddMinutes(2), page2[0].CreatedAtUtc);
|
||||
Assert.Equal(t0.AddMinutes(1), page2[1].CreatedAtUtc);
|
||||
|
||||
var cursor2 = page2[^1];
|
||||
var page3 = await repo.QueryAsync(
|
||||
new SiteCallQueryFilter(SourceSite: site),
|
||||
new SiteCallPaging(
|
||||
PageSize: 2,
|
||||
AfterCreatedAtUtc: cursor2.CreatedAtUtc,
|
||||
AfterId: cursor2.TrackedOperationId));
|
||||
Assert.Single(page3);
|
||||
Assert.Equal(t0.AddMinutes(0), page3[0].CreatedAtUtc);
|
||||
|
||||
// No overlap across pages.
|
||||
var allIds = page1.Concat(page2).Concat(page3).Select(r => r.TrackedOperationId).ToHashSet();
|
||||
Assert.Equal(5, allIds.Count);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task QueryAsync_StuckCutoff_ComposesWithKeysetPaging_NoEmptyPages()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var site = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
// Three stuck rows (non-terminal, created before the cutoff) interleaved
|
||||
// by CreatedAtUtc with non-stuck rows: recent non-terminal rows and an
|
||||
// old-but-terminal row. The stuck predicate is pushed into the SQL WHERE
|
||||
// alongside the keyset cursor, so each page must come back full of stuck
|
||||
// rows — never under-filled by a post-filter.
|
||||
var t0 = new DateTime(2026, 5, 20, 8, 0, 0, DateTimeKind.Utc);
|
||||
var cutoff = t0.AddMinutes(10);
|
||||
|
||||
var stuckIds = new List<TrackedOperationId>();
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var stuckId = TrackedOperationId.New();
|
||||
stuckIds.Add(stuckId);
|
||||
// Stuck: non-terminal, created before the cutoff.
|
||||
await repo.UpsertAsync(NewRow(
|
||||
stuckId, sourceSite: site, status: "Attempted",
|
||||
createdAtUtc: t0.AddMinutes(i)));
|
||||
// Not stuck: non-terminal but created after the cutoff.
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), sourceSite: site, status: "Attempted",
|
||||
createdAtUtc: cutoff.AddMinutes(i + 1)));
|
||||
// Not stuck: created before the cutoff but terminal.
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), sourceSite: site, status: "Delivered",
|
||||
createdAtUtc: t0.AddMinutes(i), terminal: true,
|
||||
terminalAtUtc: t0.AddMinutes(i + 1)));
|
||||
}
|
||||
|
||||
var filter = new SiteCallQueryFilter(SourceSite: site, StuckCutoffUtc: cutoff);
|
||||
|
||||
var page1 = await repo.QueryAsync(filter, new SiteCallPaging(PageSize: 2));
|
||||
Assert.Equal(2, page1.Count);
|
||||
Assert.All(page1, r => Assert.Null(r.TerminalAtUtc));
|
||||
Assert.All(page1, r => Assert.True(r.CreatedAtUtc < cutoff));
|
||||
|
||||
var cursor1 = page1[^1];
|
||||
var page2 = await repo.QueryAsync(
|
||||
filter,
|
||||
new SiteCallPaging(
|
||||
PageSize: 2,
|
||||
AfterCreatedAtUtc: cursor1.CreatedAtUtc,
|
||||
AfterId: cursor1.TrackedOperationId));
|
||||
// Only the third stuck row remains — no empty trailing page.
|
||||
Assert.Single(page2);
|
||||
Assert.Null(page2[0].TerminalAtUtc);
|
||||
Assert.True(page2[0].CreatedAtUtc < cutoff);
|
||||
|
||||
// Exactly the three stuck rows, no overlap, no non-stuck leakage.
|
||||
var returned = page1.Concat(page2).Select(r => r.TrackedOperationId).ToHashSet();
|
||||
Assert.Equal(stuckIds.ToHashSet(), returned);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task PurgeTerminalAsync_RemovesTerminalAndOld()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var site = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
// One row that's been Delivered for a long time (5 days ago) — should be purged.
|
||||
var oldId = TrackedOperationId.New();
|
||||
var fiveDaysAgo = DateTime.UtcNow.AddDays(-5);
|
||||
await repo.UpsertAsync(NewRow(
|
||||
oldId,
|
||||
sourceSite: site,
|
||||
status: "Delivered",
|
||||
retryCount: 1,
|
||||
createdAtUtc: fiveDaysAgo.AddMinutes(-1),
|
||||
updatedAtUtc: fiveDaysAgo,
|
||||
terminal: true,
|
||||
terminalAtUtc: fiveDaysAgo));
|
||||
|
||||
var purged = await repo.PurgeTerminalAsync(DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
Assert.True(purged >= 1, $"Expected at least one purged row; got {purged}.");
|
||||
Assert.Null(await repo.GetAsync(oldId));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task PurgeTerminalAsync_KeepsNonTerminalAndRecent()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var site = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
// Non-terminal row: never eligible.
|
||||
var activeId = TrackedOperationId.New();
|
||||
await repo.UpsertAsync(NewRow(
|
||||
activeId,
|
||||
sourceSite: site,
|
||||
status: "Attempted",
|
||||
retryCount: 1,
|
||||
createdAtUtc: DateTime.UtcNow.AddDays(-10),
|
||||
updatedAtUtc: DateTime.UtcNow.AddDays(-10),
|
||||
terminal: false));
|
||||
|
||||
// Recent terminal row: TerminalAtUtc within the keep window.
|
||||
var recentTerminalId = TrackedOperationId.New();
|
||||
await repo.UpsertAsync(NewRow(
|
||||
recentTerminalId,
|
||||
sourceSite: site,
|
||||
status: "Delivered",
|
||||
retryCount: 0,
|
||||
createdAtUtc: DateTime.UtcNow.AddHours(-2),
|
||||
updatedAtUtc: DateTime.UtcNow.AddHours(-1),
|
||||
terminal: true,
|
||||
terminalAtUtc: DateTime.UtcNow.AddHours(-1)));
|
||||
|
||||
// Purge older than 1 day — both rows must survive.
|
||||
await repo.PurgeTerminalAsync(DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
Assert.NotNull(await repo.GetAsync(activeId));
|
||||
Assert.NotNull(await repo.GetAsync(recentTerminalId));
|
||||
}
|
||||
|
||||
// --- KPI snapshot tests -------------------------------------------------
|
||||
|
||||
[SkippableFact]
|
||||
public async Task ComputeKpisAsync_CountsBufferedParkedFailedDeliveredAndStuck()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var site = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var stuckCutoff = now.AddMinutes(-10);
|
||||
var intervalSince = now.AddHours(-1);
|
||||
|
||||
// Buffered + stuck (non-terminal Attempted, created 30 min ago).
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), site, status: "Attempted", createdAtUtc: now.AddMinutes(-30)));
|
||||
// Buffered but NOT stuck (non-terminal Attempted, created 2 min ago).
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), site, status: "Attempted", createdAtUtc: now.AddMinutes(-2)));
|
||||
// Parked (terminal).
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), site, status: "Parked",
|
||||
createdAtUtc: now.AddMinutes(-5), updatedAtUtc: now.AddMinutes(-4),
|
||||
terminal: true, terminalAtUtc: now.AddMinutes(-4)));
|
||||
// Delivered within the interval.
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), site, status: "Delivered",
|
||||
createdAtUtc: now.AddMinutes(-4), updatedAtUtc: now.AddMinutes(-1),
|
||||
terminal: true, terminalAtUtc: now.AddMinutes(-1)));
|
||||
// Failed within the interval.
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), site, status: "Failed",
|
||||
createdAtUtc: now.AddMinutes(-6), updatedAtUtc: now.AddMinutes(-2),
|
||||
terminal: true, terminalAtUtc: now.AddMinutes(-2)));
|
||||
// Delivered OUTSIDE the interval (2 hours ago) — must not count.
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), site, status: "Delivered",
|
||||
createdAtUtc: now.AddHours(-3), updatedAtUtc: now.AddHours(-2),
|
||||
terminal: true, terminalAtUtc: now.AddHours(-2)));
|
||||
|
||||
var snapshot = await repo.ComputeKpisAsync(stuckCutoff, intervalSince);
|
||||
|
||||
// Counts are global; assert the floor since the table is shared with
|
||||
// other tests. The OUTSIDE-interval Delivered row proves the window
|
||||
// bounds the throughput counts.
|
||||
Assert.True(snapshot.BufferedCount >= 2);
|
||||
Assert.True(snapshot.ParkedCount >= 1);
|
||||
Assert.True(snapshot.StuckCount >= 1);
|
||||
Assert.True(snapshot.DeliveredLastInterval >= 1);
|
||||
Assert.True(snapshot.FailedLastInterval >= 1);
|
||||
Assert.NotNull(snapshot.OldestPendingAge);
|
||||
Assert.True(snapshot.OldestPendingAge >= TimeSpan.FromMinutes(25));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task ComputePerSiteKpisAsync_ScopesCountsToEachSite()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
var siteA = NewSiteId();
|
||||
var siteB = NewSiteId();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var stuckCutoff = now.AddMinutes(-10);
|
||||
var intervalSince = now.AddHours(-1);
|
||||
|
||||
// siteA: 2 buffered (one stuck), 1 parked.
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteA, status: "Attempted", createdAtUtc: now.AddMinutes(-30)));
|
||||
await repo.UpsertAsync(NewRow(TrackedOperationId.New(), siteA, status: "Attempted", createdAtUtc: now.AddMinutes(-2)));
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), siteA, status: "Parked",
|
||||
createdAtUtc: now.AddMinutes(-5), updatedAtUtc: now.AddMinutes(-4),
|
||||
terminal: true, terminalAtUtc: now.AddMinutes(-4)));
|
||||
// siteB: 1 delivered within interval only.
|
||||
await repo.UpsertAsync(NewRow(
|
||||
TrackedOperationId.New(), siteB, status: "Delivered",
|
||||
createdAtUtc: now.AddMinutes(-4), updatedAtUtc: now.AddMinutes(-1),
|
||||
terminal: true, terminalAtUtc: now.AddMinutes(-1)));
|
||||
|
||||
var perSite = await repo.ComputePerSiteKpisAsync(stuckCutoff, intervalSince);
|
||||
|
||||
var a = Assert.Single(perSite, s => s.SourceSite == siteA);
|
||||
Assert.Equal(2, a.BufferedCount);
|
||||
Assert.Equal(1, a.ParkedCount);
|
||||
Assert.Equal(1, a.StuckCount);
|
||||
Assert.NotNull(a.OldestPendingAge);
|
||||
|
||||
var b = Assert.Single(perSite, s => s.SourceSite == siteB);
|
||||
Assert.Equal(0, b.BufferedCount);
|
||||
Assert.Equal(1, b.DeliveredLastInterval);
|
||||
// siteB has no non-terminal rows — no oldest-pending age.
|
||||
Assert.Null(b.OldestPendingAge);
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
|
||||
private ScadaBridgeDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||
.UseSqlServer(_fixture.ConnectionString)
|
||||
.Options;
|
||||
return new ScadaBridgeDbContext(options);
|
||||
}
|
||||
|
||||
private static string NewSiteId() =>
|
||||
"site-b3-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
|
||||
private static SiteCall NewRow(
|
||||
TrackedOperationId id,
|
||||
string? sourceSite = null,
|
||||
string status = "Submitted",
|
||||
int retryCount = 0,
|
||||
string? lastError = null,
|
||||
int? httpStatus = null,
|
||||
DateTime? createdAtUtc = null,
|
||||
DateTime? updatedAtUtc = null,
|
||||
bool terminal = false,
|
||||
DateTime? terminalAtUtc = null,
|
||||
string? sourceNode = null)
|
||||
{
|
||||
var created = createdAtUtc ?? DateTime.UtcNow;
|
||||
var updated = updatedAtUtc ?? created;
|
||||
DateTime? terminalAt = terminal
|
||||
? (terminalAtUtc ?? updated)
|
||||
: null;
|
||||
|
||||
return new SiteCall
|
||||
{
|
||||
TrackedOperationId = id,
|
||||
Channel = "ApiOutbound",
|
||||
Target = "ERP.GetOrder",
|
||||
SourceSite = sourceSite ?? NewSiteId(),
|
||||
SourceNode = sourceNode,
|
||||
Status = status,
|
||||
RetryCount = retryCount,
|
||||
LastError = lastError,
|
||||
HttpStatus = httpStatus,
|
||||
CreatedAtUtc = created,
|
||||
UpdatedAtUtc = updated,
|
||||
TerminalAtUtc = terminalAt,
|
||||
IngestedAtUtc = DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
// --- SourceNode-stamping (Task 14) --------------------------------------
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_PersistsSourceNode_OnFreshInsert()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// SourceNode-stamping (Task 14): a fresh INSERT must persist the
|
||||
// SourceNode column verbatim — the central row carries the originating
|
||||
// site node name end-to-end.
|
||||
var id = TrackedOperationId.New();
|
||||
await using var context = CreateContext();
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
|
||||
await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: "node-a"));
|
||||
|
||||
var loaded = await repo.GetAsync(id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("node-a", loaded!.SourceNode);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_PreservesSourceNode_WhenLaterPacketCarriesNull()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// SourceNode-stamping (Task 14): the UPDATE uses
|
||||
// COALESCE(@SourceNode, SourceNode) so a subsequent packet that does
|
||||
// NOT carry a SourceNode (legacy / reconciliation pull from an
|
||||
// unstamped node) MUST NOT blank out the value the first packet set.
|
||||
// Combined with the monotonic-rank guard the Status advances but the
|
||||
// SourceNode survives.
|
||||
//
|
||||
// Each step uses a fresh DbContext — raw-SQL UPDATEs bypass the
|
||||
// change tracker, so reusing a single context whose entity is already
|
||||
// tracked masks the post-UPDATE state on a follow-up FindAsync.
|
||||
var id = TrackedOperationId.New();
|
||||
await using (var context = CreateContext())
|
||||
{
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
// First packet: stamped Submit from node-a.
|
||||
await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: "node-a"));
|
||||
}
|
||||
await using (var context = CreateContext())
|
||||
{
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
// Later packet: rank-advancing Attempted with null SourceNode.
|
||||
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, sourceNode: null));
|
||||
}
|
||||
|
||||
await using (var readContext = CreateContext())
|
||||
{
|
||||
var readRepo = new SiteCallAuditRepository(readContext);
|
||||
var loaded = await readRepo.GetAsync(id);
|
||||
Assert.NotNull(loaded);
|
||||
// SourceNode preserved despite the null on the later packet.
|
||||
Assert.Equal("node-a", loaded!.SourceNode);
|
||||
// Status advanced — proves the UPDATE branch actually ran.
|
||||
Assert.Equal("Attempted", loaded.Status);
|
||||
Assert.Equal(1, loaded.RetryCount);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_NonNullIncomingSourceNode_OverwritesPreviousValueOnRankAdvance()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// SourceNode-stamping (Task 14): per the COALESCE(@SourceNode,
|
||||
// SourceNode) semantics the column protects against a *null*
|
||||
// incoming value blanking a previously-stamped one, but a non-null
|
||||
// incoming value DOES replace the existing value on a rank-advancing
|
||||
// packet. This is the "last-non-null-wins on advance" behaviour the
|
||||
// SQL operator literally implements — see the comment in
|
||||
// SiteCallAuditRepository.UpsertAsync.
|
||||
//
|
||||
// In practice both stamps within a single lifecycle SHOULD carry the
|
||||
// same value (same node, same execution); a divergence would imply a
|
||||
// mid-lifecycle node change (e.g. failover handing off to node-b) and
|
||||
// letting the latest stamp through is arguably the right call. This
|
||||
// test pins the actual behaviour so we notice if the SQL gets
|
||||
// inverted (to a true first-write-wins COALESCE(SourceNode,
|
||||
// @SourceNode)) inadvertently.
|
||||
var id = TrackedOperationId.New();
|
||||
await using (var context = CreateContext())
|
||||
{
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: "node-a"));
|
||||
}
|
||||
await using (var context = CreateContext())
|
||||
{
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, sourceNode: "node-b"));
|
||||
}
|
||||
|
||||
await using (var readContext = CreateContext())
|
||||
{
|
||||
var readRepo = new SiteCallAuditRepository(readContext);
|
||||
var loaded = await readRepo.GetAsync(id);
|
||||
Assert.NotNull(loaded);
|
||||
// Incoming non-null wins — node-b replaces node-a on rank advance.
|
||||
Assert.Equal("node-b", loaded!.SourceNode);
|
||||
// Other monotonic fields advanced too — proves the UPDATE ran.
|
||||
Assert.Equal("Attempted", loaded.Status);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task UpsertAsync_FillsSourceNode_WhenInsertWasNullAndLaterPacketCarriesValue()
|
||||
{
|
||||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||
|
||||
// SourceNode-stamping (Task 14): when the column was left NULL by an
|
||||
// earlier unstamped packet, a later rank-advancing packet with a
|
||||
// non-null SourceNode fills it — the COALESCE(@SourceNode, SourceNode)
|
||||
// SQL operator returns @SourceNode when @SourceNode is non-null, so
|
||||
// the incoming value wins over the existing NULL. This is the
|
||||
// recovery path for an initially-unstamped lifecycle whose later
|
||||
// packets carry the node identity.
|
||||
//
|
||||
// The intermediate verification and final read use FRESH contexts —
|
||||
// FindAsync hits the change tracker first, so a cached entity from
|
||||
// an earlier read in the same context can mask a raw-SQL UPDATE.
|
||||
var id = TrackedOperationId.New();
|
||||
await using (var context = CreateContext())
|
||||
{
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
await repo.UpsertAsync(NewRow(id, status: "Submitted", sourceNode: null));
|
||||
}
|
||||
|
||||
// Verify the INSERT left SourceNode NULL via a fresh context.
|
||||
await using (var verifyContext = CreateContext())
|
||||
{
|
||||
var verifyRepo = new SiteCallAuditRepository(verifyContext);
|
||||
var afterInsert = await verifyRepo.GetAsync(id);
|
||||
Assert.NotNull(afterInsert);
|
||||
Assert.Null(afterInsert!.SourceNode);
|
||||
}
|
||||
|
||||
await using (var context = CreateContext())
|
||||
{
|
||||
var repo = new SiteCallAuditRepository(context);
|
||||
await repo.UpsertAsync(NewRow(id, status: "Attempted", retryCount: 1, sourceNode: "node-a"));
|
||||
}
|
||||
|
||||
await using (var readContext = CreateContext())
|
||||
{
|
||||
var readRepo = new SiteCallAuditRepository(readContext);
|
||||
var loaded = await readRepo.GetAsync(id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("node-a", loaded!.SourceNode);
|
||||
Assert.Equal("Attempted", loaded.Status);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user