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;
///
/// Bundle D (#23 M1) integration tests for . Uses
/// the same 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
/// SourceSiteId guid suffix so they neither collide with one another nor
/// require cleanup.
///
public class AuditLogRepositoryTests : IClassFixture
{
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()
.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()
.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(),
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_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()
.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()
.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(
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()
.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(
() => 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(
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 ScalarAsync(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()
.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) =>
new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = occurredAtUtc,
Channel = channel,
Kind = kind,
Status = status,
SourceSiteId = siteId,
ErrorMessage = errorMessage,
ExecutionId = executionId,
};
}