801 lines
36 KiB
C#
801 lines
36 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_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);
|
|
}
|
|
|
|
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) =>
|
|
new()
|
|
{
|
|
EventId = Guid.NewGuid(),
|
|
OccurredAtUtc = occurredAtUtc,
|
|
Channel = channel,
|
|
Kind = kind,
|
|
Status = status,
|
|
SourceSiteId = siteId,
|
|
ErrorMessage = errorMessage,
|
|
ExecutionId = executionId,
|
|
ParentExecutionId = parentExecutionId,
|
|
};
|
|
}
|