487 lines
19 KiB
C#
487 lines
19 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using ScadaLink.Commons.Entities.Audit;
|
|
using ScadaLink.Commons.Types;
|
|
using ScadaLink.Commons.Types.Audit;
|
|
using ScadaLink.ConfigurationDatabase;
|
|
using ScadaLink.ConfigurationDatabase.Repositories;
|
|
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
|
|
using Xunit;
|
|
|
|
namespace ScadaLink.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 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 ScadaLinkDbContext CreateContext()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
|
.UseSqlServer(_fixture.ConnectionString)
|
|
.Options;
|
|
return new ScadaLinkDbContext(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)
|
|
{
|
|
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(),
|
|
Status = status,
|
|
RetryCount = retryCount,
|
|
LastError = lastError,
|
|
HttpStatus = httpStatus,
|
|
CreatedAtUtc = created,
|
|
UpdatedAtUtc = updated,
|
|
TerminalAtUtc = terminalAt,
|
|
IngestedAtUtc = DateTime.UtcNow,
|
|
};
|
|
}
|
|
}
|