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

1361 lines
62 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
[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) =>
ScadaBridgeAuditEventFactory.Create(
channel: channel,
kind: kind,
status: status,
occurredAtUtc: occurredAtUtc,
sourceNode: sourceNode,
sourceSiteId: siteId,
executionId: executionId,
parentExecutionId: parentExecutionId,
errorMessage: errorMessage);
}