CentralAuditWriter injects INodeIdentityProvider and stamps the event before handing to the repository. AuditLogRepository.InsertIfNotExistsAsync now includes SourceNode in the INSERT column list. Caller-provided value wins (supports any future direct-write callsite that already has its own node id).
1031 lines
46 KiB
C#
1031 lines
46 KiB
C#
using Microsoft.Data.SqlClient;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ScadaLink.Commons.Entities.Audit;
|
|
using ScadaLink.Commons.Types.Audit;
|
|
using ScadaLink.Commons.Types.Enums;
|
|
using ScadaLink.ConfigurationDatabase;
|
|
using ScadaLink.ConfigurationDatabase.Repositories;
|
|
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
|
|
using Xunit;
|
|
|
|
namespace ScadaLink.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<AuditEvent>()
|
|
.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<AuditEvent>()
|
|
.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<AuditEvent>()
|
|
.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.
|
|
var second = first with { ErrorMessage = "second-should-be-ignored" };
|
|
await repo.InsertIfNotExistsAsync(second);
|
|
|
|
await using var readContext = CreateContext();
|
|
var loaded = await readContext.Set<AuditEvent>()
|
|
.Where(e => e.SourceSiteId == siteId)
|
|
.ToListAsync();
|
|
|
|
Assert.Single(loaded);
|
|
Assert.Equal("first", loaded[0].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.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.Channel, new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }));
|
|
Assert.DoesNotContain(rows, r => r.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.Status, new[] { AuditStatus.Failed, AuditStatus.Parked }));
|
|
Assert.DoesNotContain(rows, r => r.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.SourceSiteId, new[] { siteA, siteB }));
|
|
Assert.DoesNotContain(rows, r => r.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.SourceSiteId));
|
|
}
|
|
|
|
[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.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.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.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.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<AuditEvent>()
|
|
.Where(e => e.SourceSiteId == siteId)
|
|
.CountAsync();
|
|
|
|
Assert.Equal(1, count);
|
|
}
|
|
|
|
[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.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 drop-DROP-INDEX → CREATE-staging → SWITCH PARTITION →
|
|
// DROP-staging → CREATE-INDEX dance documented in alog.md §4. These tests
|
|
// verify the side effects an outsider can observe:
|
|
// * rows in the targeted month are removed
|
|
// * rows in OTHER months are NOT touched
|
|
// * UX_AuditLog_EventId still exists after a successful switch
|
|
// * InsertIfNotExistsAsync's first-write-wins idempotency still holds
|
|
// after a switch (the rebuilt index is real)
|
|
// * a thrown SqlException leaves UX_AuditLog_EventId rebuilt (the CATCH
|
|
// branch's recovery path runs)
|
|
|
|
[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<AuditEvent>()
|
|
.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_RebuildsUxIndex_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));
|
|
|
|
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(1, indexExists);
|
|
}
|
|
|
|
[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
|
|
// (UX_AuditLog_EventId is the index that enables idempotency; if the
|
|
// rebuild left it broken, this insert would silently produce a duplicate
|
|
// row and the count assertion below would catch it).
|
|
var dup = preExisting with { ErrorMessage = "second-should-be-ignored-after-switch" };
|
|
await repo.InsertIfNotExistsAsync(dup);
|
|
|
|
await using var readContext = CreateContext();
|
|
var rows = await readContext.Set<AuditEvent>()
|
|
.Where(e => e.SourceSiteId == siteId)
|
|
.ToListAsync();
|
|
|
|
Assert.Single(rows);
|
|
Assert.Equal(preExisting.EventId, rows[0].EventId);
|
|
// First-write-wins: the original ErrorMessage (null) survives.
|
|
Assert.Null(rows[0].ErrorMessage);
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task SwitchOutPartitionAsync_PartialFailure_RebuildsUxIndex_RaisesException()
|
|
{
|
|
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 rebuilds the
|
|
// unique index — 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 in the production SQL guarantees UX_AuditLog_EventId
|
|
// is rebuilt regardless of which step failed inside the TRY.
|
|
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(1, 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 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>(ScadaLinkDbContext 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 ScadaLinkDbContext CreateContext()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
|
.UseSqlServer(_fixture.ConnectionString)
|
|
.Options;
|
|
return new ScadaLinkDbContext(options);
|
|
}
|
|
|
|
private static string NewSiteId() =>
|
|
"test-bundle-d-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
|
|
|
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) =>
|
|
new()
|
|
{
|
|
EventId = Guid.NewGuid(),
|
|
OccurredAtUtc = occurredAtUtc,
|
|
Channel = channel,
|
|
Kind = kind,
|
|
Status = status,
|
|
SourceSiteId = siteId,
|
|
SourceNode = sourceNode,
|
|
ErrorMessage = errorMessage,
|
|
ExecutionId = executionId,
|
|
ParentExecutionId = parentExecutionId,
|
|
};
|
|
}
|