5d075f1374
Six adversarial-review findings in the central SQL/ingest layer. F1 (AuditLogRepository.InsertChunkAsync) — the set-based ingest declared each string parameter at its COLUMN width (Actor/Target 256, Action 64, Outcome 16, Category 32, SourceNode 64), so SqlClient truncated an over-long value at bind time and committed the mutilated row — silent, in an append-only store, with no PayloadTruncated flag — while the per-row and reconciliation paths sent the same value in full and let the server reject it with 2628. Bind at the value's own length instead; explicit SqlDbType is kept (it fixes the VALUES constructor's derived column types and datetime2 precision). Design: reject everywhere, truncate nowhere — matching today's per-row behaviour. F2 (SiteCallAuditRepository.UpsertAsync) — the single-statement upsert ran the monotonic UPDATE first and INSERTed only if nothing matched. Two writers racing the first packet of one TrackedOperationId (the cached dual-write and the reconciliation pull carry DIFFERENT lifecycle states) both matched nothing, and the loser then skipped its INSERT or swallowed a 2627 — dropping its Status/RetryCount/HttpStatus/TerminalAtUtc. Legs swapped to `IF NOT EXISTS … INSERT; UPDATE <monotonic>` — still one round trip, and the loser's UPDATE now lands on the winner's row. The duplicate-key catch re-runs the monotonic UPDATE for the same reason. Moved to raw SQL with explicitly-typed parameters so the intricate rank predicate exists in exactly one place (an untyped DateTime would bind as `datetime` and round the freshness tiebreaker). F3 (docs/plans/sql/*.sql) — filtered-index DDL failed with error 1934 under the documented `docker exec … sqlcmd` path, which defaults QUOTED_IDENTIFIER OFF; once IX_Notifications_Delivered exists, QI-OFF DML on Notifications fails too. All four scripts now open with `SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; GO` (own batch, so it is in force when the next batch parses), and the migration convention in Component-ConfigurationDatabase.md documents `sqlcmd -I`. Verified live: the pre-fix script fails 1934 without -I, the fixed one applies. F4 (SiteCallAuditActor) — the off-mailbox reconciliation/purge passes reuse the injected repository, so tests drove one DbContext from the pass and a mailbox handler concurrently. Serialized at the CALL via a private SerializedRepository wrapper applied only by the test constructors, rather than running the pass on-mailbox: production keeps its PipeTo shape untouched, and the existing "a blocked drain does not stall ingest/query/KPI" regression tests stay meaningful (they would have been invalidated by suspending the mailbox). F5 (AuditLogIngestActor) — when the batch failed because the 20 s IngestBudget expired, the per-row fallback reused the same expired token: N instant failures, N counter bumps, zero accepted. The fallback now gets a fresh 5 s budget (inside the 30 s outer Ask), and a blown budget bumps the failure counter ONCE for the batch instead of once per row. F6 (NotificationOutboxRepository.UpdateAsync) — ExecuteUpdate's row count was discarded, so an operator Retry/Discard of a notification the retention purge had already deleted reported success (the pre-ExecuteUpdate code threw DbUpdateConcurrencyException). UpdateAsync now returns whether a row matched; the operator one-shots answer "notification not found" and emit no audit row for the action that did not happen, while the dispatcher logs a warning (its delivery already happened; nothing to retry). GetByIdAsync switched to AsNoTracking since the write is out-of-band. Tests: 5 new SQL-backed regressions (over-long Target rejected on both paths + boundary round-trip; concurrent first-write and already-created-by-another-writer upserts; vanished-row UpdateAsync), a token-identity pin on the ingest fallback, a repository-concurrency detector for the SiteCallAudit passes, and vanished-row operator-path tests. The F1/F2/F4 regressions were each confirmed failing against the pre-fix code. Suites: ConfigurationDatabase 369, AuditLog 378, SiteCallAudit 66, NotificationOutbox 152 — all green, solution builds with 0 warnings.
1442 lines
66 KiB
C#
1442 lines
66 KiB
C#
using Microsoft.Data.SqlClient;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using ZB.MOM.WW.Audit;
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||
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 D (#23 M1) integration tests for <see cref="AuditLogRepository"/>. Uses
|
||
/// the same <see cref="MsSqlMigrationFixture"/> as the Bundle C migration tests so
|
||
/// raw-SQL paths (the IF NOT EXISTS insert, partition switch) execute against a
|
||
/// real partitioned schema. Tests scope all queries by a per-test
|
||
/// <c>SourceSiteId</c> guid suffix so they neither collide with one another nor
|
||
/// require cleanup.
|
||
/// </summary>
|
||
public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||
{
|
||
private readonly MsSqlMigrationFixture _fixture;
|
||
|
||
public AuditLogRepositoryTests(MsSqlMigrationFixture fixture)
|
||
{
|
||
_fixture = fixture;
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task InsertIfNotExistsAsync_FreshEvent_WritesOneRow()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
var evt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc));
|
||
await repo.InsertIfNotExistsAsync(evt);
|
||
|
||
// Re-read in a fresh context so we exercise the persisted row, not the
|
||
// (already-bypassed) change tracker.
|
||
await using var readContext = CreateContext();
|
||
var loaded = await readContext.Set<AuditLogRow>()
|
||
.Where(e => e.SourceSiteId == siteId)
|
||
.ToListAsync();
|
||
|
||
Assert.Single(loaded);
|
||
Assert.Equal(evt.EventId, loaded[0].EventId);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task InsertIfNotExistsAsync_PersistsSourceNode()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
var evt = NewEvent(
|
||
siteId,
|
||
occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||
sourceNode: "central-a");
|
||
await repo.InsertIfNotExistsAsync(evt);
|
||
|
||
await using var readContext = CreateContext();
|
||
var loaded = await readContext.Set<AuditLogRow>()
|
||
.Where(e => e.SourceSiteId == siteId)
|
||
.ToListAsync();
|
||
|
||
Assert.Single(loaded);
|
||
Assert.Equal("central-a", loaded[0].SourceNode);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task InsertIfNotExistsAsync_PersistsNullSourceNode()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
// Caller passes null SourceNode (e.g. an unconfigured node) — the
|
||
// column should persist as NULL, not as the empty string.
|
||
var evt = NewEvent(
|
||
siteId,
|
||
occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
|
||
sourceNode: null);
|
||
await repo.InsertIfNotExistsAsync(evt);
|
||
|
||
await using var readContext = CreateContext();
|
||
var loaded = await readContext.Set<AuditLogRow>()
|
||
.Where(e => e.SourceSiteId == siteId)
|
||
.ToListAsync();
|
||
|
||
Assert.Single(loaded);
|
||
Assert.Null(loaded[0].SourceNode);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task InsertIfNotExistsAsync_DuplicateEventId_IsNoOp_NoExceptionNoDuplicate()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
var occurredAt = new DateTime(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc);
|
||
var first = NewEvent(siteId, occurredAtUtc: occurredAt, errorMessage: "first");
|
||
await repo.InsertIfNotExistsAsync(first);
|
||
|
||
// Same EventId, different payload — first-write-wins, the second call is silently a no-op.
|
||
// C3 (Task 2.5): ErrorMessage rides in DetailsJson on the canonical record, so rebuild
|
||
// a sibling row carrying the same EventId via the factory (rather than a top-level `with`).
|
||
var second = ScadaBridgeAuditEventFactory.Create(
|
||
channel: AuditChannel.ApiOutbound,
|
||
kind: AuditKind.ApiCall,
|
||
status: AuditStatus.Delivered,
|
||
eventId: first.EventId,
|
||
occurredAtUtc: occurredAt,
|
||
sourceSiteId: siteId,
|
||
errorMessage: "second-should-be-ignored");
|
||
await repo.InsertIfNotExistsAsync(second);
|
||
|
||
await using var readContext = CreateContext();
|
||
var loaded = await readContext.Set<AuditLogRow>()
|
||
.Where(e => e.SourceSiteId == siteId)
|
||
.ToListAsync();
|
||
|
||
Assert.Single(loaded);
|
||
// C5 (Task 2.5): ErrorMessage rides in DetailsJson now — decode it to assert
|
||
// first-write-wins kept the original payload.
|
||
Assert.Equal("first", AuditDetailsCodec.Deserialize(loaded[0].DetailsJson).ErrorMessage);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task QueryAsync_ReturnsRowsInOccurredDescOrder()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
var t0 = new DateTime(2026, 5, 1, 9, 0, 0, DateTimeKind.Utc);
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(10)));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(20)));
|
||
|
||
var rows = await repo.QueryAsync(
|
||
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||
new AuditLogPaging(PageSize: 10));
|
||
|
||
Assert.Equal(3, rows.Count);
|
||
Assert.True(rows[0].OccurredAtUtc > rows[1].OccurredAtUtc);
|
||
Assert.True(rows[1].OccurredAtUtc > rows[2].OccurredAtUtc);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task QueryAsync_FilterByChannel()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
var t0 = new DateTime(2026, 5, 2, 9, 0, 0, DateTimeKind.Utc);
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, channel: AuditChannel.ApiOutbound));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), channel: AuditChannel.Notification));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), channel: AuditChannel.Notification));
|
||
|
||
var rows = await repo.QueryAsync(
|
||
new AuditLogQueryFilter(
|
||
Channels: new[] { AuditChannel.Notification },
|
||
SourceSiteIds: new[] { siteId }),
|
||
new AuditLogPaging(PageSize: 10));
|
||
|
||
Assert.Equal(2, rows.Count);
|
||
Assert.All(rows, r => Assert.Equal(AuditChannel.Notification, r.AsRow().Channel));
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task QueryAsync_FilterByMultipleChannels_ReturnsUnion()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
var t0 = new DateTime(2026, 5, 2, 14, 0, 0, DateTimeKind.Utc);
|
||
// One row per channel; the multi-value filter must return the union of
|
||
// ApiOutbound + Notification and exclude DbOutbound.
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, channel: AuditChannel.ApiOutbound));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), channel: AuditChannel.Notification));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), channel: AuditChannel.DbOutbound));
|
||
|
||
var rows = await repo.QueryAsync(
|
||
new AuditLogQueryFilter(
|
||
Channels: new[] { AuditChannel.ApiOutbound, AuditChannel.Notification },
|
||
SourceSiteIds: new[] { siteId }),
|
||
new AuditLogPaging(PageSize: 10));
|
||
|
||
Assert.Equal(2, rows.Count);
|
||
Assert.All(rows, r => Assert.Contains(r.AsRow().Channel, new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }));
|
||
Assert.DoesNotContain(rows, r => r.AsRow().Channel == AuditChannel.DbOutbound);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task QueryAsync_FilterByMultipleStatuses_ReturnsUnion()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
var t0 = new DateTime(2026, 5, 2, 15, 0, 0, DateTimeKind.Utc);
|
||
// Failed + Parked are requested; Delivered must be excluded.
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, status: AuditStatus.Failed));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), status: AuditStatus.Parked));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), status: AuditStatus.Delivered));
|
||
|
||
var rows = await repo.QueryAsync(
|
||
new AuditLogQueryFilter(
|
||
Statuses: new[] { AuditStatus.Failed, AuditStatus.Parked },
|
||
SourceSiteIds: new[] { siteId }),
|
||
new AuditLogPaging(PageSize: 10));
|
||
|
||
Assert.Equal(2, rows.Count);
|
||
Assert.All(rows, r => Assert.Contains(r.AsRow().Status, new[] { AuditStatus.Failed, AuditStatus.Parked }));
|
||
Assert.DoesNotContain(rows, r => r.AsRow().Status == AuditStatus.Delivered);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task QueryAsync_FilterByMultipleSourceSiteIds_ReturnsUnion()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteA = NewSiteId();
|
||
var siteB = NewSiteId();
|
||
var siteC = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
var t0 = new DateTime(2026, 5, 2, 16, 0, 0, DateTimeKind.Utc);
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteA, occurredAtUtc: t0));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteB, occurredAtUtc: t0.AddMinutes(1)));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteC, occurredAtUtc: t0.AddMinutes(2)));
|
||
|
||
var rows = await repo.QueryAsync(
|
||
new AuditLogQueryFilter(SourceSiteIds: new[] { siteA, siteB }),
|
||
new AuditLogPaging(PageSize: 10));
|
||
|
||
Assert.Equal(2, rows.Count);
|
||
Assert.All(rows, r => Assert.Contains(r.AsRow().SourceSiteId, new[] { siteA, siteB }));
|
||
Assert.DoesNotContain(rows, r => r.AsRow().SourceSiteId == siteC);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task QueryAsync_EmptyChannelList_DoesNotConstrain()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
var t0 = new DateTime(2026, 5, 2, 17, 0, 0, DateTimeKind.Utc);
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, channel: AuditChannel.ApiOutbound));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), channel: AuditChannel.Notification));
|
||
|
||
// An empty Channels list must mean "no filter" — NOT WHERE 1=0.
|
||
var rows = await repo.QueryAsync(
|
||
new AuditLogQueryFilter(
|
||
Channels: Array.Empty<AuditChannel>(),
|
||
SourceSiteIds: new[] { siteId }),
|
||
new AuditLogPaging(PageSize: 10));
|
||
|
||
Assert.Equal(2, rows.Count);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task QueryAsync_FilterBySourceSiteId()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
var otherSiteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
var t0 = new DateTime(2026, 5, 3, 9, 0, 0, DateTimeKind.Utc);
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1)));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(otherSiteId, occurredAtUtc: t0.AddMinutes(2)));
|
||
|
||
var rows = await repo.QueryAsync(
|
||
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||
new AuditLogPaging(PageSize: 10));
|
||
|
||
Assert.Equal(2, rows.Count);
|
||
Assert.All(rows, r => Assert.Equal(siteId, r.AsRow().SourceSiteId));
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
// Task 15: SourceNodes filter + GetDistinctSourceNodesAsync. Pins the new
|
||
// Node multi-select contract — non-empty list → SQL IN (…); NULL
|
||
// SourceNode rows are excluded when the filter is set; the distinct
|
||
// enumeration omits nulls and orders ascending.
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
|
||
[SkippableFact]
|
||
public async Task QueryAsync_FilterBySourceNode_ReturnsMatchingRows()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
var t0 = new DateTime(2026, 5, 4, 10, 0, 0, DateTimeKind.Utc);
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, sourceNode: "central-a"));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), sourceNode: "central-b"));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), sourceNode: null));
|
||
|
||
var rows = await repo.QueryAsync(
|
||
new AuditLogQueryFilter(
|
||
SourceSiteIds: new[] { siteId },
|
||
SourceNodes: new[] { "central-a" }),
|
||
new AuditLogPaging(PageSize: 10));
|
||
|
||
Assert.Single(rows);
|
||
Assert.Equal("central-a", rows[0].SourceNode);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task QueryAsync_FilterByMultipleSourceNodes_ReturnsUnion()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
var t0 = new DateTime(2026, 5, 4, 11, 0, 0, DateTimeKind.Utc);
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, sourceNode: "central-a"));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), sourceNode: "central-b"));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), sourceNode: "site-plant-a-node-a"));
|
||
|
||
var rows = await repo.QueryAsync(
|
||
new AuditLogQueryFilter(
|
||
SourceSiteIds: new[] { siteId },
|
||
SourceNodes: new[] { "central-a", "central-b" }),
|
||
new AuditLogPaging(PageSize: 10));
|
||
|
||
Assert.Equal(2, rows.Count);
|
||
Assert.All(rows, r => Assert.Contains(r.SourceNode, new[] { "central-a", "central-b" }));
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task GetDistinctSourceNodesAsync_ReturnsDistinctNonNullValues_Ordered()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
var t0 = new DateTime(2026, 5, 4, 12, 0, 0, DateTimeKind.Utc);
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, sourceNode: "central-b"));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), sourceNode: "central-a"));
|
||
// Duplicate of "central-a" — must collapse to one entry.
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), sourceNode: "central-a"));
|
||
// Null row — must be excluded entirely.
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), sourceNode: null));
|
||
|
||
var nodes = await repo.GetDistinctSourceNodesAsync();
|
||
|
||
// The whole table is scanned (no per-test scoping on this query), so the
|
||
// assertion is "our seeded values appear once each, in order" rather
|
||
// than a strict equality on the full result.
|
||
Assert.Contains("central-a", nodes);
|
||
Assert.Contains("central-b", nodes);
|
||
// No null entry — the WHERE SourceNode IS NOT NULL clause drops them.
|
||
Assert.DoesNotContain(nodes, n => n is null);
|
||
// Ordered ascending.
|
||
Assert.Equal(nodes.OrderBy(n => n, StringComparer.Ordinal), nodes);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task QueryAsync_FilterByExecutionId_ReturnsMatchingRows()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
var executionId = Guid.NewGuid();
|
||
var t0 = new DateTime(2026, 5, 3, 12, 0, 0, DateTimeKind.Utc);
|
||
// Two rows share the ExecutionId; one carries a different ExecutionId and
|
||
// one leaves it null — both must be excluded by the single-value filter.
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: executionId));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: executionId));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), executionId: Guid.NewGuid()));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), executionId: null));
|
||
|
||
var rows = await repo.QueryAsync(
|
||
new AuditLogQueryFilter(
|
||
SourceSiteIds: new[] { siteId },
|
||
ExecutionId: executionId),
|
||
new AuditLogPaging(PageSize: 10));
|
||
|
||
Assert.Equal(2, rows.Count);
|
||
Assert.All(rows, r => Assert.Equal(executionId, r.AsRow().ExecutionId));
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task QueryAsync_FilterByParentExecutionId_ReturnsMatchingRows()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
var parentExecutionId = Guid.NewGuid();
|
||
var t0 = new DateTime(2026, 5, 3, 13, 0, 0, DateTimeKind.Utc);
|
||
// Two rows share the ParentExecutionId; one carries a different
|
||
// ParentExecutionId and one leaves it null — both must be excluded by the
|
||
// single-value filter.
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, parentExecutionId: parentExecutionId));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), parentExecutionId: parentExecutionId));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), parentExecutionId: Guid.NewGuid()));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), parentExecutionId: null));
|
||
|
||
var rows = await repo.QueryAsync(
|
||
new AuditLogQueryFilter(
|
||
SourceSiteIds: new[] { siteId },
|
||
ParentExecutionId: parentExecutionId),
|
||
new AuditLogPaging(PageSize: 10));
|
||
|
||
Assert.Equal(2, rows.Count);
|
||
Assert.All(rows, r => Assert.Equal(parentExecutionId, r.AsRow().ParentExecutionId));
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task QueryAsync_FilterByTimeRange()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
var t0 = new DateTime(2026, 5, 4, 9, 0, 0, DateTimeKind.Utc);
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(30)));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddHours(2)));
|
||
|
||
var rows = await repo.QueryAsync(
|
||
new AuditLogQueryFilter(
|
||
SourceSiteIds: new[] { siteId },
|
||
FromUtc: t0.AddMinutes(10),
|
||
ToUtc: t0.AddHours(1)),
|
||
new AuditLogPaging(PageSize: 10));
|
||
|
||
Assert.Single(rows);
|
||
Assert.Equal(t0.AddMinutes(30), rows[0].OccurredAtUtc);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task QueryAsync_Keyset_NextPageStartsAfterCursor()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
var t0 = new DateTime(2026, 5, 5, 9, 0, 0, DateTimeKind.Utc);
|
||
// Five rows at one-minute intervals. 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.
|
||
for (var i = 0; i < 5; i++)
|
||
{
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(i)));
|
||
}
|
||
|
||
var page1 = await repo.QueryAsync(
|
||
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||
new AuditLogPaging(PageSize: 2));
|
||
|
||
Assert.Equal(2, page1.Count);
|
||
Assert.Equal(t0.AddMinutes(4), page1[0].OccurredAtUtc);
|
||
Assert.Equal(t0.AddMinutes(3), page1[1].OccurredAtUtc);
|
||
|
||
var cursor = page1[^1];
|
||
var page2 = await repo.QueryAsync(
|
||
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||
new AuditLogPaging(
|
||
PageSize: 2,
|
||
AfterOccurredAtUtc: cursor.AsRow().OccurredAtUtc,
|
||
AfterEventId: cursor.EventId));
|
||
|
||
Assert.Equal(2, page2.Count);
|
||
Assert.Equal(t0.AddMinutes(2), page2[0].OccurredAtUtc);
|
||
Assert.Equal(t0.AddMinutes(1), page2[1].OccurredAtUtc);
|
||
|
||
var cursor2 = page2[^1];
|
||
var page3 = await repo.QueryAsync(
|
||
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||
new AuditLogPaging(
|
||
PageSize: 2,
|
||
AfterOccurredAtUtc: cursor2.AsRow().OccurredAtUtc,
|
||
AfterEventId: cursor2.EventId));
|
||
|
||
Assert.Single(page3);
|
||
Assert.Equal(t0.AddMinutes(0), page3[0].OccurredAtUtc);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task InsertIfNotExistsAsync_ConcurrentDuplicateInserts_ProduceExactlyOneRow()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
|
||
// Single event used by every parallel call — same EventId, same payload.
|
||
// The repository's IF NOT EXISTS … INSERT pattern has a check-then-act
|
||
// race window between sessions; under concurrent load SQL Server can
|
||
// raise a unique-index violation (error 2601) on UX_AuditLog_EventId.
|
||
// Bundle A's hardening swallows 2601/2627 so duplicates collapse silently.
|
||
var evt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc));
|
||
|
||
// 50 parallel inserters, each with its own DbContext (DbContext is not
|
||
// thread-safe). Parallel.ForEachAsync aggregates exceptions, so a single
|
||
// unhandled 2601 from the repository would fail this test loudly.
|
||
await Parallel.ForEachAsync(
|
||
Enumerable.Range(0, 50),
|
||
new ParallelOptions { MaxDegreeOfParallelism = 50 },
|
||
async (_, ct) =>
|
||
{
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
await repo.InsertIfNotExistsAsync(evt, ct);
|
||
});
|
||
|
||
await using var readContext = CreateContext();
|
||
var count = await readContext.Set<AuditLogRow>()
|
||
.Where(e => e.SourceSiteId == siteId)
|
||
.CountAsync();
|
||
|
||
Assert.Equal(1, count);
|
||
}
|
||
|
||
// ------------------------------------------------------------------------
|
||
// WP2.2: set-based InsertManyIfNotExistsAsync
|
||
// ------------------------------------------------------------------------
|
||
|
||
[SkippableFact]
|
||
public async Task InsertManyIfNotExistsAsync_WritesEveryDistinctEvent()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
var baseTime = new DateTime(2026, 5, 21, 8, 0, 0, DateTimeKind.Utc);
|
||
var events = Enumerable.Range(0, 12)
|
||
.Select(i => NewEvent(siteId, occurredAtUtc: baseTime.AddSeconds(i)))
|
||
.ToList();
|
||
|
||
var inserted = await repo.InsertManyIfNotExistsAsync(events);
|
||
|
||
Assert.Equal(12, inserted);
|
||
|
||
await using var readContext = CreateContext();
|
||
var rows = await readContext.Set<AuditLogRow>()
|
||
.Where(e => e.SourceSiteId == siteId)
|
||
.ToListAsync();
|
||
|
||
Assert.Equal(12, rows.Count);
|
||
Assert.Equal(
|
||
events.Select(e => e.EventId).OrderBy(g => g).ToArray(),
|
||
rows.Select(r => r.EventId).OrderBy(g => g).ToArray());
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task InsertManyIfNotExistsAsync_DuplicateEventIdsWithinOnePacket_CollapseToOneRow()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
// A set-based INSERT … SELECT … WHERE NOT EXISTS only tests rows that are
|
||
// already COMMITTED, so two copies of one EventId inside a single VALUES
|
||
// constructor would both pass the anti-semi-join and collide on the
|
||
// clustered PK — taking the whole statement down with them. The repository
|
||
// de-duplicates the packet first (first-write-wins, matching the
|
||
// single-row contract), which this pins.
|
||
var occurred = new DateTime(2026, 5, 21, 9, 0, 0, DateTimeKind.Utc);
|
||
var first = NewEvent(siteId, occurredAtUtc: occurred);
|
||
var duplicate = ScadaBridgeAuditEventFactory.Create(
|
||
channel: AuditChannel.ApiOutbound,
|
||
kind: AuditKind.ApiCall,
|
||
status: AuditStatus.Delivered,
|
||
eventId: first.EventId,
|
||
occurredAtUtc: occurred,
|
||
sourceSiteId: siteId,
|
||
errorMessage: "duplicate-within-packet-should-be-ignored");
|
||
var other = NewEvent(siteId, occurredAtUtc: occurred.AddSeconds(1));
|
||
|
||
var inserted = await repo.InsertManyIfNotExistsAsync(
|
||
new[] { first, duplicate, other, duplicate });
|
||
|
||
Assert.Equal(2, inserted);
|
||
|
||
await using var readContext = CreateContext();
|
||
var rows = await readContext.Set<AuditLogRow>()
|
||
.Where(e => e.SourceSiteId == siteId)
|
||
.ToListAsync();
|
||
|
||
Assert.Equal(2, rows.Count);
|
||
|
||
// First-write-wins: the surviving row is the FIRST occurrence, so the
|
||
// duplicate's ErrorMessage never lands.
|
||
var stored = Assert.Single(rows, r => r.EventId == first.EventId);
|
||
Assert.Null(AuditDetailsCodec.Deserialize(stored.DetailsJson).ErrorMessage);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task InsertManyIfNotExistsAsync_DuplicateEventIdsAcrossPackets_AreIdempotent()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
var baseTime = new DateTime(2026, 5, 21, 10, 0, 0, DateTimeKind.Utc);
|
||
var packet = Enumerable.Range(0, 5)
|
||
.Select(i => NewEvent(siteId, occurredAtUtc: baseTime.AddSeconds(i)))
|
||
.ToList();
|
||
|
||
// A site retry / reconciliation pull re-delivers a packet that overlaps
|
||
// an already-ingested one. The second call must insert only the genuinely
|
||
// new rows and silently skip the rest (first-write-wins across packets).
|
||
var firstInserted = await repo.InsertManyIfNotExistsAsync(packet);
|
||
var extra = NewEvent(siteId, occurredAtUtc: baseTime.AddSeconds(99));
|
||
var secondInserted = await repo.InsertManyIfNotExistsAsync(
|
||
packet.Concat(new[] { extra }).ToList());
|
||
|
||
// A third, wholly-redundant replay writes nothing at all.
|
||
var thirdInserted = await repo.InsertManyIfNotExistsAsync(packet);
|
||
|
||
Assert.Equal(5, firstInserted);
|
||
Assert.Equal(1, secondInserted);
|
||
Assert.Equal(0, thirdInserted);
|
||
|
||
await using var readContext = CreateContext();
|
||
var rows = await readContext.Set<AuditLogRow>()
|
||
.Where(e => e.SourceSiteId == siteId)
|
||
.ToListAsync();
|
||
|
||
Assert.Equal(6, rows.Count);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task InsertManyIfNotExistsAsync_ChunksBeyondTheParameterCeiling()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
// 250 rows × 10 bound parameters = 2,500 — past SQL Server's 2,100
|
||
// parameter ceiling for a single statement, so this only succeeds if the
|
||
// repository chunks. Nulls in the optional columns (Target, SourceNode,
|
||
// CorrelationId, Actor) are left null on purpose: the VALUES constructor
|
||
// derives its column types from the parameters, so an untyped null would
|
||
// surface here as a conversion failure rather than silently.
|
||
var baseTime = new DateTime(2026, 5, 21, 11, 0, 0, DateTimeKind.Utc);
|
||
var events = Enumerable.Range(0, 250)
|
||
.Select(i => NewEvent(siteId, occurredAtUtc: baseTime.AddSeconds(i)))
|
||
.ToList();
|
||
|
||
var inserted = await repo.InsertManyIfNotExistsAsync(events);
|
||
|
||
Assert.Equal(250, inserted);
|
||
|
||
await using var readContext = CreateContext();
|
||
var count = await readContext.Set<AuditLogRow>()
|
||
.Where(e => e.SourceSiteId == siteId)
|
||
.CountAsync();
|
||
|
||
Assert.Equal(250, count);
|
||
}
|
||
|
||
/// <summary>
|
||
/// An over-long value must be REJECTED by the server on every write path, not
|
||
/// clipped to fit by the client. The set-based path used to declare each
|
||
/// string parameter at its COLUMN width (Target/Actor 256, Action 64,
|
||
/// Outcome 16, Category 32, SourceNode 64), which makes SqlClient truncate the
|
||
/// value at bind time and commit the shortened row — in an APPEND-ONLY audit
|
||
/// store, with no PayloadTruncated flag to admit it happened — while the
|
||
/// per-row and reconciliation paths sent the same value in full and let the
|
||
/// server raise 2628/8152. Reject-everywhere is the contract; this test pins
|
||
/// both halves of it.
|
||
/// </summary>
|
||
[SkippableFact]
|
||
public async Task InsertManyIfNotExistsAsync_TargetLongerThanColumn_IsRejected_NotTruncated()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
|
||
// dbo.AuditLog.Target is nvarchar(256).
|
||
var overlongTarget = new string('T', 300);
|
||
var evt = NewEvent(
|
||
siteId,
|
||
occurredAtUtc: new DateTime(2026, 5, 22, 12, 0, 0, DateTimeKind.Utc),
|
||
target: overlongTarget);
|
||
|
||
await using var batchContext = CreateContext();
|
||
var batchRepo = new AuditLogRepository(batchContext);
|
||
|
||
// 2628 = "String or binary data would be truncated in table …" (SQL 2019+),
|
||
// 8152 = its pre-2019 predecessor. Either proves the server saw the full value.
|
||
var batchEx = await Assert.ThrowsAsync<SqlException>(
|
||
() => batchRepo.InsertManyIfNotExistsAsync(new[] { evt }));
|
||
Assert.Contains(batchEx.Number, new[] { 2628, 8152 });
|
||
|
||
// The per-row path — which the batch path falls back to — rejects it too.
|
||
await using var rowContext = CreateContext();
|
||
var rowRepo = new AuditLogRepository(rowContext);
|
||
var rowEx = await Assert.ThrowsAsync<SqlException>(
|
||
() => rowRepo.InsertIfNotExistsAsync(evt));
|
||
Assert.Contains(rowEx.Number, new[] { 2628, 8152 });
|
||
|
||
// Nothing landed — in particular, no 256-character mutilated copy.
|
||
await using var readContext = CreateContext();
|
||
var rows = await readContext.Set<AuditLogRow>()
|
||
.Where(e => e.SourceSiteId == siteId)
|
||
.ToListAsync();
|
||
|
||
Assert.Empty(rows);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The companion to the rejection test: a value that FITS must still round-trip
|
||
/// through the set-based path byte for byte, at the exact column boundary.
|
||
/// </summary>
|
||
[SkippableFact]
|
||
public async Task InsertManyIfNotExistsAsync_TargetAtColumnLimit_RoundTripsIntact()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
var boundaryTarget = new string('T', 256);
|
||
var evt = NewEvent(
|
||
siteId,
|
||
occurredAtUtc: new DateTime(2026, 5, 22, 12, 30, 0, DateTimeKind.Utc),
|
||
target: boundaryTarget);
|
||
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
Assert.Equal(1, await repo.InsertManyIfNotExistsAsync(new[] { evt }));
|
||
|
||
await using var readContext = CreateContext();
|
||
var loaded = await readContext.Set<AuditLogRow>()
|
||
.Where(e => e.SourceSiteId == siteId)
|
||
.ToListAsync();
|
||
|
||
Assert.Single(loaded);
|
||
Assert.Equal(boundaryTarget, loaded[0].Target);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task InsertManyIfNotExistsAsync_EmptyBatch_IsANoOp()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
Assert.Equal(0, await repo.InsertManyIfNotExistsAsync(Array.Empty<AuditEvent>()));
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task QueryAsync_Keyset_SameOccurredAtUtc_TiebreaksOnEventId()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
// Four events all sharing the exact same OccurredAtUtc — the keyset
|
||
// cursor must lean on the EventId tiebreaker (descending) to page
|
||
// deterministically. Bundle D's reviewer flagged this as a deferred
|
||
// verification because it depends on EF Core 10 translating
|
||
// Guid.CompareTo against SQL Server's uniqueidentifier sort order.
|
||
var occurredAt = new DateTime(2026, 5, 20, 13, 0, 0, DateTimeKind.Utc);
|
||
|
||
// Build four distinct Guids; we don't care about the literal ordering
|
||
// produced by Guid.CompareTo — only that paging is deterministic and
|
||
// covers every row exactly once.
|
||
var events = Enumerable.Range(0, 4)
|
||
.Select(_ => NewEvent(siteId, occurredAtUtc: occurredAt))
|
||
.ToList();
|
||
|
||
foreach (var e in events)
|
||
{
|
||
await repo.InsertIfNotExistsAsync(e);
|
||
}
|
||
|
||
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { siteId });
|
||
|
||
var page1 = await repo.QueryAsync(filter, new AuditLogPaging(PageSize: 2));
|
||
Assert.Equal(2, page1.Count);
|
||
Assert.All(page1, r => Assert.Equal(occurredAt, r.OccurredAtUtc));
|
||
|
||
var cursor = page1[^1];
|
||
var page2 = await repo.QueryAsync(
|
||
filter,
|
||
new AuditLogPaging(
|
||
PageSize: 2,
|
||
AfterOccurredAtUtc: cursor.AsRow().OccurredAtUtc,
|
||
AfterEventId: cursor.EventId));
|
||
|
||
Assert.Equal(2, page2.Count);
|
||
Assert.All(page2, r => Assert.Equal(occurredAt, r.OccurredAtUtc));
|
||
|
||
var page1Ids = page1.Select(r => r.EventId).ToHashSet();
|
||
var page2Ids = page2.Select(r => r.EventId).ToHashSet();
|
||
|
||
// No overlap between pages.
|
||
Assert.Empty(page1Ids.Intersect(page2Ids));
|
||
|
||
// Every inserted EventId appears in exactly one of the two pages.
|
||
var allIds = page1Ids.Union(page2Ids).ToHashSet();
|
||
Assert.Equal(4, allIds.Count);
|
||
Assert.True(events.Select(e => e.EventId).ToHashSet().SetEquals(allIds));
|
||
}
|
||
|
||
// ------------------------------------------------------------------------
|
||
// M6-T4 Bundle C: SwitchOutPartitionAsync drop-and-rebuild integration tests
|
||
// ------------------------------------------------------------------------
|
||
//
|
||
// The partition-switch path replaces M1's NotSupportedException stub with
|
||
// the production CREATE-staging → SWITCH PARTITION → DROP-staging batch
|
||
// documented in alog.md §4. WP2.2 removed the index drop/rebuild that used to
|
||
// bracket it: uniqueness rides the partition-ALIGNED clustered PK
|
||
// (EventId, OccurredAtUtc), so SWITCH has nothing to object to. These tests
|
||
// verify the side effects an outsider can observe:
|
||
// * rows in the targeted month are removed
|
||
// * rows in OTHER months are NOT touched
|
||
// * no non-aligned UX_AuditLog_EventId is (re)created by a switch
|
||
// * InsertIfNotExistsAsync's first-write-wins idempotency still holds
|
||
// after a switch (the aligned key really does enforce it)
|
||
// * a thrown SqlException leaves the table intact with no orphaned staging
|
||
|
||
[SkippableFact]
|
||
public async Task SwitchOutPartitionAsync_OldPartition_RemovesRows_NewPartitionsKept()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
// Three distinct months — Jan, Feb, Mar 2026 — so the switch on Jan's
|
||
// boundary purges exactly one month's worth of rows. Boundary values
|
||
// come from the partition function's pre-seeded list (alog.md §4).
|
||
var janEvt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 1, 15, 10, 0, 0, DateTimeKind.Utc));
|
||
var febEvt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 2, 15, 10, 0, 0, DateTimeKind.Utc));
|
||
var marEvt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 3, 15, 10, 0, 0, DateTimeKind.Utc));
|
||
await repo.InsertIfNotExistsAsync(janEvt);
|
||
await repo.InsertIfNotExistsAsync(febEvt);
|
||
await repo.InsertIfNotExistsAsync(marEvt);
|
||
|
||
// Boundary value '2026-01-01' identifies the January 2026 partition under
|
||
// RANGE RIGHT semantics ($PARTITION returns the partition into which the
|
||
// boundary value itself falls — the partition whose lower bound is the
|
||
// boundary).
|
||
await repo.SwitchOutPartitionAsync(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||
|
||
await using var readContext = CreateContext();
|
||
var remaining = await readContext.Set<AuditLogRow>()
|
||
.Where(e => e.SourceSiteId == siteId)
|
||
.ToListAsync();
|
||
|
||
Assert.DoesNotContain(remaining, e => e.EventId == janEvt.EventId);
|
||
Assert.Contains(remaining, e => e.EventId == febEvt.EventId);
|
||
Assert.Contains(remaining, e => e.EventId == marEvt.EventId);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task SwitchOutPartitionAsync_LeavesNoNonAlignedUniqueIndex_AfterSwitch()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
// Pick a different month per test so successive test runs (which share
|
||
// the fixture's MSSQL database) don't tread on each other.
|
||
await repo.SwitchOutPartitionAsync(new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc));
|
||
|
||
// WP2.2: the switch must NOT recreate UX_AuditLog_EventId. Re-adding it
|
||
// would reinstate the offline drop/rebuild the purge was freed from and
|
||
// block the next SWITCH until something dropped it again.
|
||
await using var verifyContext = CreateContext();
|
||
var indexExists = await ScalarAsync<int>(
|
||
verifyContext,
|
||
"SELECT COUNT(*) FROM sys.indexes " +
|
||
"WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog');");
|
||
Assert.Equal(0, indexExists);
|
||
|
||
// The aligned clustered PK is what enforces uniqueness now, and the
|
||
// switch must leave it untouched.
|
||
var pkExists = await ScalarAsync<int>(
|
||
verifyContext,
|
||
"SELECT COUNT(*) FROM sys.indexes " +
|
||
"WHERE name = 'PK_AuditLog' AND object_id = OBJECT_ID('dbo.AuditLog') AND is_primary_key = 1;");
|
||
Assert.Equal(1, pkExists);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task SwitchOutPartitionAsync_InsertIfNotExistsAsync_StillEnforcesFirstWriteWins_AfterSwitch()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
// Pre-existing row in May 2026 — must survive a switch on a different
|
||
// (older) partition.
|
||
var preExisting = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc));
|
||
await repo.InsertIfNotExistsAsync(preExisting);
|
||
|
||
// Switch out the June 2026 partition (different month, empty).
|
||
await repo.SwitchOutPartitionAsync(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc));
|
||
|
||
// Re-attempting the same EventId after the switch must STILL be a no-op.
|
||
// The idempotency probe now seeks the aligned clustered PK's leading
|
||
// column instead of a dedicated unique index; if the switch had disturbed
|
||
// that key, this insert would silently produce a duplicate row and the
|
||
// count assertion below would catch it.
|
||
// C3 (Task 2.5): rebuild a sibling row with the same EventId via the factory
|
||
// (ErrorMessage rides in DetailsJson, so a top-level `with` no longer applies).
|
||
var dup = ScadaBridgeAuditEventFactory.Create(
|
||
channel: AuditChannel.ApiOutbound,
|
||
kind: AuditKind.ApiCall,
|
||
status: AuditStatus.Delivered,
|
||
eventId: preExisting.EventId,
|
||
occurredAtUtc: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc),
|
||
sourceSiteId: siteId,
|
||
errorMessage: "second-should-be-ignored-after-switch");
|
||
await repo.InsertIfNotExistsAsync(dup);
|
||
|
||
await using var readContext = CreateContext();
|
||
var rows = await readContext.Set<AuditLogRow>()
|
||
.Where(e => e.SourceSiteId == siteId)
|
||
.ToListAsync();
|
||
|
||
Assert.Single(rows);
|
||
Assert.Equal(preExisting.EventId, rows[0].EventId);
|
||
// First-write-wins: the original ErrorMessage (null) survives. C5 (Task 2.5):
|
||
// ErrorMessage rides in DetailsJson — decode it to assert.
|
||
Assert.Null(AuditDetailsCodec.Deserialize(rows[0].DetailsJson).ErrorMessage);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task SwitchOutPartitionAsync_PartialFailure_RaisesException_LeavesNoOrphanedStaging()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
// Force a deterministic switch failure with an inbound FOREIGN KEY:
|
||
// ALTER TABLE … SWITCH refuses to move rows out of a partition that's
|
||
// referenced by an FK from another table, raising msg 4928
|
||
// ("ALTER TABLE SWITCH statement failed because target table … has a
|
||
// foreign key …"). The CATCH branch then rolls back and drops the staging
|
||
// table — which the assertion below verifies.
|
||
//
|
||
// The probe table is uniquely named with a guid suffix so reruns of
|
||
// this test inside the same fixture DB never collide. We clean it up
|
||
// in the finally so the constraint never leaks into other tests.
|
||
var probeTable = $"AuditFkProbe_{Guid.NewGuid():N}".Substring(0, 32);
|
||
await using (var setup = new SqlConnection(_fixture.ConnectionString))
|
||
{
|
||
await setup.OpenAsync();
|
||
await using var cmd = setup.CreateCommand();
|
||
// Composite FK references AuditLog's composite PK (EventId, OccurredAtUtc).
|
||
cmd.CommandText =
|
||
$"CREATE TABLE dbo.[{probeTable}] ( " +
|
||
$" EventId uniqueidentifier NOT NULL, " +
|
||
$" OccurredAtUtc datetime2(7) NOT NULL, " +
|
||
$" CONSTRAINT FK_{probeTable}_AuditLog FOREIGN KEY (EventId, OccurredAtUtc) " +
|
||
$" REFERENCES dbo.AuditLog(EventId, OccurredAtUtc));";
|
||
await cmd.ExecuteNonQueryAsync();
|
||
}
|
||
|
||
try
|
||
{
|
||
var ex = await Assert.ThrowsAnyAsync<SqlException>(
|
||
() => repo.SwitchOutPartitionAsync(new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc)));
|
||
// Smoke-check the message references the SWITCH statement so we
|
||
// know we hit the engineered failure, not some unrelated error.
|
||
Assert.Contains("SWITCH", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
finally
|
||
{
|
||
// Always drop the probe table so the FK is gone before the next
|
||
// test runs against the shared fixture.
|
||
await using var cleanup = new SqlConnection(_fixture.ConnectionString);
|
||
await cleanup.OpenAsync();
|
||
await using var cmd = cleanup.CreateCommand();
|
||
cmd.CommandText =
|
||
$"IF OBJECT_ID('dbo.[{probeTable}]', 'U') IS NOT NULL DROP TABLE dbo.[{probeTable}];";
|
||
await cmd.ExecuteNonQueryAsync();
|
||
}
|
||
|
||
// The CATCH block drops the GUID-suffixed staging table regardless of
|
||
// which step failed inside the TRY, so a failed purge leaves no orphaned
|
||
// AuditLog_Staging_* object behind for the next tick to trip over.
|
||
await using var verifyContext = CreateContext();
|
||
var orphanedStaging = await ScalarAsync<int>(
|
||
verifyContext,
|
||
"SELECT COUNT(*) FROM sys.tables WHERE name LIKE 'AuditLog\\_Staging\\_%' ESCAPE '\\';");
|
||
Assert.Equal(0, orphanedStaging);
|
||
|
||
// And it must NOT have (re)created the non-aligned unique index: WP2.2
|
||
// deleted that rebuild entirely, so a failed switch cannot reintroduce
|
||
// the object that blocks the NEXT switch.
|
||
var indexExists = await ScalarAsync<int>(
|
||
verifyContext,
|
||
"SELECT COUNT(*) FROM sys.indexes " +
|
||
"WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog');");
|
||
Assert.Equal(0, indexExists);
|
||
}
|
||
|
||
// ------------------------------------------------------------------------
|
||
// M6-T4 Bundle C: GetPartitionBoundariesOlderThanAsync
|
||
// ------------------------------------------------------------------------
|
||
|
||
[SkippableFact]
|
||
public async Task GetPartitionBoundariesOlderThanAsync_ReturnsBoundaries_WithMaxOccurredOlderThanThreshold()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
// Seed events in two months: July 2026 (old) and August 2026 (new).
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: new DateTime(2026, 7, 10, 0, 0, 0, DateTimeKind.Utc)));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: new DateTime(2026, 8, 10, 0, 0, 0, DateTimeKind.Utc)));
|
||
|
||
// Threshold = Aug 1 2026 — July partition's MAX (July 10) is older;
|
||
// August partition's MAX (August 10) is newer. We expect only the July
|
||
// boundary back.
|
||
var threshold = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc);
|
||
var boundaries = await repo.GetPartitionBoundariesOlderThanAsync(threshold);
|
||
|
||
// The repo may also return EARLIER boundaries that have no data (their
|
||
// MAX is NULL → treated as "no data, nothing to purge" by the contract).
|
||
// We only assert the inclusion/exclusion that matters for our seeded
|
||
// rows.
|
||
Assert.Contains(new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), boundaries);
|
||
Assert.DoesNotContain(new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc), boundaries);
|
||
}
|
||
|
||
// ------------------------------------------------------------------------
|
||
// M7-T13 Bundle E: GetKpiSnapshotAsync — Health-dashboard Audit KPI tiles
|
||
// ------------------------------------------------------------------------
|
||
//
|
||
// The dashboard's "Audit volume" tile reads TotalEventsLastHour and the
|
||
// "Audit error rate" tile reads ErrorEventsLastHour / TotalEventsLastHour.
|
||
// The repository must (a) count rows whose OccurredAtUtc falls in
|
||
// [nowUtc - window, nowUtc] and (b) within that scope count rows whose
|
||
// Status ∈ {Failed, Parked, Discarded} as "error". BacklogTotal is left at
|
||
// zero here — the service layer composes it in from the health aggregator.
|
||
//
|
||
// To keep the test deterministic against the shared fixture DB, each test
|
||
// pins an obscure-distant nowUtc and seeds rows with OccurredAtUtc inside a
|
||
// narrow band centred on that anchor — no other test in this class seeds
|
||
// there, so the global count equals the seeded count for that band.
|
||
|
||
[SkippableFact]
|
||
public async Task GetKpiSnapshotAsync_WithMixedStatusRows_ReturnsCorrectTotalsAndErrors()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
// Anchor in November 2026 — no other test in this class seeds there.
|
||
var nowUtc = new DateTime(2026, 11, 20, 10, 0, 0, DateTimeKind.Utc);
|
||
// Seed 3 success + 1 Failed + 1 Parked + 1 Discarded inside the trailing
|
||
// 1h window; plus 1 row outside the window that must be excluded.
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-5), status: AuditStatus.Delivered));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-10), status: AuditStatus.Delivered));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-15), status: AuditStatus.Delivered));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-20), status: AuditStatus.Failed));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-25), status: AuditStatus.Parked));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-30), status: AuditStatus.Discarded));
|
||
// Outside-window row (2h before nowUtc).
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddHours(-2), status: AuditStatus.Failed));
|
||
// Submitted is in-flight, not an "error" — must NOT count toward errors.
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: nowUtc.AddMinutes(-2), status: AuditStatus.Submitted));
|
||
|
||
var snapshot = await repo.GetKpiSnapshotAsync(
|
||
window: TimeSpan.FromHours(1),
|
||
nowUtc: nowUtc);
|
||
|
||
// 7 rows fall in the trailing 1h window (3 Delivered + 1 Failed + 1 Parked + 1 Discarded + 1 Submitted).
|
||
// The 2h-before-nowUtc Failed row is excluded by the window.
|
||
Assert.Equal(7, snapshot.TotalEventsLastHour);
|
||
// Only Failed/Parked/Discarded count as errors → 3.
|
||
Assert.Equal(3, snapshot.ErrorEventsLastHour);
|
||
// The service layer fills BacklogTotal; the repo leaves it at 0.
|
||
Assert.Equal(0, snapshot.BacklogTotal);
|
||
// AsOfUtc echoes the anchor.
|
||
Assert.Equal(nowUtc, snapshot.AsOfUtc);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task GetKpiSnapshotAsync_EmptyWindow_ReturnsZeroTotals()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
// Anchor in December 2026 — no test seeds there, so the window is empty.
|
||
var nowUtc = new DateTime(2026, 12, 20, 10, 0, 0, DateTimeKind.Utc);
|
||
|
||
var snapshot = await repo.GetKpiSnapshotAsync(
|
||
window: TimeSpan.FromMinutes(1),
|
||
nowUtc: nowUtc);
|
||
|
||
Assert.Equal(0, snapshot.TotalEventsLastHour);
|
||
Assert.Equal(0, snapshot.ErrorEventsLastHour);
|
||
Assert.Equal(0, snapshot.BacklogTotal);
|
||
Assert.Equal(nowUtc, snapshot.AsOfUtc);
|
||
}
|
||
|
||
// ------------------------------------------------------------------------
|
||
// Audit Log ParentExecutionId (Task 8): GetExecutionTreeAsync
|
||
// ------------------------------------------------------------------------
|
||
//
|
||
// GetExecutionTreeAsync walks UP from any node to the chain root, then walks
|
||
// DOWN via a recursive CTE, returning one ExecutionTreeNode per distinct
|
||
// execution in the chain. These tests verify the observable behaviour:
|
||
// * a multi-level chain returns the full set regardless of entry node
|
||
// * a parent referenced only via ParentExecutionId (no rows of its own)
|
||
// still surfaces, as a RowCount = 0 stub node
|
||
// * pathological cyclic data is bounded by the MAXRECURSION guard and
|
||
// surfaces a SqlException rather than hanging
|
||
|
||
[SkippableFact]
|
||
public async Task GetExecutionTree_MultiLevelChain()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
// A 3-level chain: root -> mid -> leaf. Each execution emits two rows so
|
||
// RowCount aggregation is exercised; the child rows carry the parent's
|
||
// ExecutionId as ParentExecutionId. Each execution is given a DISTINCT
|
||
// channel, and its two rows carry DISTINCT statuses and timestamps, so
|
||
// the per-node Channels/Statuses sets and the FirstOccurred/LastOccurred
|
||
// span are meaningfully asserted (not all-defaults).
|
||
var rootExec = Guid.NewGuid();
|
||
var midExec = Guid.NewGuid();
|
||
var leafExec = Guid.NewGuid();
|
||
|
||
var t0 = new DateTime(2026, 10, 5, 9, 0, 0, DateTimeKind.Utc);
|
||
var rootT0 = t0;
|
||
var rootT1 = t0.AddMinutes(1);
|
||
var midT0 = t0.AddMinutes(2);
|
||
var midT1 = t0.AddMinutes(3);
|
||
var leafT0 = t0.AddMinutes(4);
|
||
var leafT1 = t0.AddMinutes(5);
|
||
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: rootT0, channel: AuditChannel.ApiOutbound, status: AuditStatus.Submitted, executionId: rootExec));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: rootT1, channel: AuditChannel.ApiOutbound, status: AuditStatus.Delivered, executionId: rootExec));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: midT0, channel: AuditChannel.DbOutbound, status: AuditStatus.Submitted, executionId: midExec, parentExecutionId: rootExec));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: midT1, channel: AuditChannel.DbOutbound, status: AuditStatus.Failed, executionId: midExec, parentExecutionId: rootExec));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: leafT0, channel: AuditChannel.Notification, status: AuditStatus.Submitted, executionId: leafExec, parentExecutionId: midExec));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: leafT1, channel: AuditChannel.Notification, status: AuditStatus.Parked, executionId: leafExec, parentExecutionId: midExec));
|
||
|
||
var expected = new[] { rootExec, midExec, leafExec };
|
||
|
||
// Entry point must not matter: leaf, middle node, and root all yield the
|
||
// same full chain.
|
||
foreach (var entry in new[] { leafExec, midExec, rootExec })
|
||
{
|
||
var tree = await repo.GetExecutionTreeAsync(entry);
|
||
|
||
Assert.Equal(3, tree.Count);
|
||
Assert.True(
|
||
expected.ToHashSet().SetEquals(tree.Select(n => n.ExecutionId)),
|
||
$"entry {entry} did not return the full chain");
|
||
|
||
var root = tree.Single(n => n.ExecutionId == rootExec);
|
||
var mid = tree.Single(n => n.ExecutionId == midExec);
|
||
var leaf = tree.Single(n => n.ExecutionId == leafExec);
|
||
|
||
Assert.Null(root.ParentExecutionId);
|
||
Assert.Equal(rootExec, mid.ParentExecutionId);
|
||
Assert.Equal(midExec, leaf.ParentExecutionId);
|
||
|
||
Assert.Equal(2, root.RowCount);
|
||
Assert.Equal(2, mid.RowCount);
|
||
Assert.Equal(2, leaf.RowCount);
|
||
|
||
// Each populated node aggregates its own rows' channels and
|
||
// statuses — distinct per execution, so a regression that mixes
|
||
// executions or drops the per-id aggregate would be caught.
|
||
Assert.Equal(
|
||
new[] { nameof(AuditChannel.ApiOutbound) },
|
||
root.Channels);
|
||
Assert.Equal(
|
||
new[] { nameof(AuditChannel.DbOutbound) },
|
||
mid.Channels);
|
||
Assert.Equal(
|
||
new[] { nameof(AuditChannel.Notification) },
|
||
leaf.Channels);
|
||
|
||
Assert.True(
|
||
new[] { nameof(AuditStatus.Submitted), nameof(AuditStatus.Delivered) }
|
||
.ToHashSet().SetEquals(root.Statuses));
|
||
Assert.True(
|
||
new[] { nameof(AuditStatus.Submitted), nameof(AuditStatus.Failed) }
|
||
.ToHashSet().SetEquals(mid.Statuses));
|
||
Assert.True(
|
||
new[] { nameof(AuditStatus.Submitted), nameof(AuditStatus.Parked) }
|
||
.ToHashSet().SetEquals(leaf.Statuses));
|
||
|
||
// Each populated node's timestamp span covers exactly its two rows.
|
||
Assert.Equal(rootT0, root.FirstOccurredAtUtc);
|
||
Assert.Equal(rootT1, root.LastOccurredAtUtc);
|
||
Assert.Equal(midT0, mid.FirstOccurredAtUtc);
|
||
Assert.Equal(midT1, mid.LastOccurredAtUtc);
|
||
Assert.Equal(leafT0, leaf.FirstOccurredAtUtc);
|
||
Assert.Equal(leafT1, leaf.LastOccurredAtUtc);
|
||
}
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task ExecutionTree_Bounds_EdgeScan_To_Root_Window()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
// arch-review 04 (P2): the down-walk Edges CTE is anchored to a
|
||
// [rootFirst - 1h, rootFirst + 7d) window around the root's first event, enabling
|
||
// partition elimination instead of a full-table DISTINCT scan. A genuine descendant
|
||
// stamped BEYOND that 7-day span is excluded by design; a normal in-window child is
|
||
// still returned.
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
var rootExec = Guid.NewGuid();
|
||
var childExec = Guid.NewGuid();
|
||
var farDescendantExec = Guid.NewGuid();
|
||
var unrelatedExec = Guid.NewGuid();
|
||
|
||
var t0 = new DateTime(2026, 10, 5, 9, 0, 0, DateTimeKind.Utc);
|
||
|
||
// Root + an in-window child (T0 + 5 min) — both must be returned.
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: rootExec));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(5), executionId: childExec, parentExecutionId: rootExec));
|
||
|
||
// A genuine descendant of the child, but stamped 30 days out — beyond the 7-day
|
||
// traversal bound, so it is documented-excluded from the tree.
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddDays(30), executionId: farDescendantExec, parentExecutionId: childExec));
|
||
|
||
// An unrelated execution far in time sharing no ancestry — never part of the chain
|
||
// (asserts the window bound does not accidentally pull in strangers).
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddDays(30), executionId: unrelatedExec));
|
||
|
||
var tree = await repo.GetExecutionTreeAsync(rootExec);
|
||
var ids = tree.Select(n => n.ExecutionId).ToHashSet();
|
||
|
||
Assert.Contains(rootExec, ids);
|
||
Assert.Contains(childExec, ids);
|
||
Assert.DoesNotContain(farDescendantExec, ids);
|
||
Assert.DoesNotContain(unrelatedExec, ids);
|
||
Assert.Equal(2, tree.Count);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task GetExecutionTree_StubParentNode()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
// The parent execution emitted no rows of its own (it performed no
|
||
// trust-boundary action, or its rows were purged). Only the child has
|
||
// rows, and they reference the parent via ParentExecutionId. The parent
|
||
// must still surface as a node — a RowCount = 0 stub.
|
||
var stubParentExec = Guid.NewGuid();
|
||
var childExec = Guid.NewGuid();
|
||
|
||
var t0 = new DateTime(2026, 10, 6, 9, 0, 0, DateTimeKind.Utc);
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: childExec, parentExecutionId: stubParentExec));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: childExec, parentExecutionId: stubParentExec));
|
||
|
||
// Entering by the child must surface BOTH the child and the stub parent.
|
||
var tree = await repo.GetExecutionTreeAsync(childExec);
|
||
|
||
Assert.Equal(2, tree.Count);
|
||
|
||
var stub = tree.Single(n => n.ExecutionId == stubParentExec);
|
||
var child = tree.Single(n => n.ExecutionId == childExec);
|
||
|
||
// The stub node: no rows, empty aggregate sets, null parent and timestamps.
|
||
Assert.Equal(0, stub.RowCount);
|
||
Assert.Empty(stub.Channels);
|
||
Assert.Empty(stub.Statuses);
|
||
Assert.Null(stub.ParentExecutionId);
|
||
Assert.Null(stub.FirstOccurredAtUtc);
|
||
Assert.Null(stub.LastOccurredAtUtc);
|
||
|
||
// The child node carries its rows and points at the stub parent.
|
||
Assert.Equal(2, child.RowCount);
|
||
Assert.Equal(stubParentExec, child.ParentExecutionId);
|
||
}
|
||
|
||
[SkippableFact]
|
||
public async Task GetExecutionTree_RespectsMaxRecursion()
|
||
{
|
||
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||
|
||
var siteId = NewSiteId();
|
||
await using var context = CreateContext();
|
||
var repo = new AuditLogRepository(context);
|
||
|
||
// Pathological cyclic data: A's rows point at B as parent, B's rows
|
||
// point back at A. The ParentExecutionId graph is acyclic by
|
||
// construction in production, but corrupt data must not hang the
|
||
// server. The downward recursive CTE's OPTION (MAXRECURSION 32) raises
|
||
// a SqlException when the cycle exceeds the guard; the method surfaces
|
||
// it rather than spinning forever.
|
||
var execA = Guid.NewGuid();
|
||
var execB = Guid.NewGuid();
|
||
|
||
var t0 = new DateTime(2026, 10, 7, 9, 0, 0, DateTimeKind.Utc);
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: execA, parentExecutionId: execB));
|
||
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: execB, parentExecutionId: execA));
|
||
|
||
// The call must complete (throw) quickly, not hang. A generous 30s
|
||
// ceiling distinguishes "bounded failure" from "infinite loop".
|
||
var call = repo.GetExecutionTreeAsync(execA);
|
||
var completed = await Task.WhenAny(call, Task.Delay(TimeSpan.FromSeconds(30)));
|
||
Assert.Same(call, completed);
|
||
|
||
// MAXRECURSION exceeded surfaces as a SqlException — bounded, not a hang.
|
||
await Assert.ThrowsAsync<SqlException>(() => call);
|
||
}
|
||
|
||
private async Task<T> ScalarAsync<T>(ScadaBridgeDbContext context, string sql)
|
||
{
|
||
var conn = context.Database.GetDbConnection();
|
||
if (conn.State != System.Data.ConnectionState.Open)
|
||
{
|
||
await conn.OpenAsync();
|
||
}
|
||
await using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = sql;
|
||
var result = await cmd.ExecuteScalarAsync();
|
||
if (result is null || result is DBNull)
|
||
{
|
||
return default!;
|
||
}
|
||
return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!;
|
||
}
|
||
|
||
// --- helpers ------------------------------------------------------------
|
||
|
||
private ScadaBridgeDbContext CreateContext()
|
||
{
|
||
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||
.UseSqlServer(_fixture.ConnectionString)
|
||
.Options;
|
||
return new ScadaBridgeDbContext(options);
|
||
}
|
||
|
||
private static string NewSiteId() =>
|
||
"test-bundle-d-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||
|
||
// C3 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared
|
||
// factory; the repository's transitional shim decomposes it into the 24-column
|
||
// AuditLogRow on INSERT and recomposes the canonical record on QUERY.
|
||
private static AuditEvent NewEvent(
|
||
string siteId,
|
||
DateTime occurredAtUtc,
|
||
AuditChannel channel = AuditChannel.ApiOutbound,
|
||
AuditKind kind = AuditKind.ApiCall,
|
||
AuditStatus status = AuditStatus.Delivered,
|
||
string? errorMessage = null,
|
||
Guid? executionId = null,
|
||
Guid? parentExecutionId = null,
|
||
string? sourceNode = null,
|
||
string? target = null) =>
|
||
ScadaBridgeAuditEventFactory.Create(
|
||
channel: channel,
|
||
kind: kind,
|
||
status: status,
|
||
occurredAtUtc: occurredAtUtc,
|
||
target: target,
|
||
sourceNode: sourceNode,
|
||
sourceSiteId: siteId,
|
||
executionId: executionId,
|
||
parentExecutionId: parentExecutionId,
|
||
errorMessage: errorMessage);
|
||
}
|