feat(auditlog): multi-value AuditLogQueryFilter dimensions

This commit is contained in:
Joseph Doherty
2026-05-21 05:15:51 -04:00
parent b3b02a8cb6
commit 37c7a0e5ac
15 changed files with 196 additions and 77 deletions

View File

@@ -214,7 +214,7 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
var evt = Assert.Single(rows);
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);
@@ -282,7 +282,7 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
var evt = Assert.Single(rows);
Assert.Equal(AuditChannel.DbOutbound, evt.Channel);

View File

@@ -241,7 +241,7 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
await using var ctx = CreateContext();
var repo = new AuditLogRepository(ctx);
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 50));
// 1 Submit + 1 Attempted = 2 rows so far.
Assert.Equal(2, rows.Count);
@@ -257,7 +257,7 @@ public class NotifyDispatcherAuditTrailTests : TestKit, IClassFixture<MsSqlMigra
await using var ctx = CreateContext();
var repo = new AuditLogRepository(ctx);
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 50));
// 1 Submit + 2 Attempted + 1 Delivered terminal = 4 rows.
Assert.InRange(rows.Count, 3, 4);

View File

@@ -160,7 +160,7 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Single(rows);
Assert.Equal(evt.EventId, rows[0].EventId);
@@ -207,7 +207,7 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Single(rows);
Assert.Equal(evt.EventId, rows[0].EventId);
@@ -260,7 +260,7 @@ public class SyncCallEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMigrati
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Single(rows);
Assert.Equal(sharedId, rows[0].EventId);

View File

@@ -160,10 +160,10 @@ public class AuditExportEndpointsTests
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f =>
f.Channel == AuditChannel.ApiOutbound &&
f.Kind == AuditKind.ApiCall &&
f.Status == AuditStatus.Failed &&
f.SourceSiteId == "plant-a" &&
f.Channels != null && f.Channels.Count == 1 && f.Channels[0] == AuditChannel.ApiOutbound &&
f.Kinds != null && f.Kinds.Count == 1 && f.Kinds[0] == AuditKind.ApiCall &&
f.Statuses != null && f.Statuses.Count == 1 && f.Statuses[0] == AuditStatus.Failed &&
f.SourceSiteIds != null && f.SourceSiteIds.Count == 1 && f.SourceSiteIds[0] == "plant-a" &&
f.Target == "PaymentApi" &&
f.Actor == "apikey-1" &&
f.CorrelationId == Guid.Parse(correlationId) &&
@@ -188,10 +188,10 @@ public class AuditExportEndpointsTests
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f =>
f.Channel == null &&
f.Kind == null &&
f.Status == null &&
f.SourceSiteId == null &&
f.Channels == null &&
f.Kinds == null &&
f.Statuses == null &&
f.SourceSiteIds == null &&
f.Target == null &&
f.Actor == null &&
f.CorrelationId == null &&
@@ -216,7 +216,7 @@ public class AuditExportEndpointsTests
_ = await response.Content.ReadAsStringAsync();
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.Channel == null),
Arg.Is<AuditLogQueryFilter>(f => f.Channels == null),
Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>());
}

View File

@@ -77,7 +77,9 @@ public class AuditFilterBarTests : BunitContext
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Equal(AuditChannel.ApiOutbound, captured!.Channel);
// Task 8: the filter dimension is multi-value now; ToFilter still collapses
// the chip selection to a single-element list (Task 9 widens that).
Assert.Equal(new[] { AuditChannel.ApiOutbound }, captured!.Channels);
Assert.Equal("Plant-A-OPC", captured.Target);
}
@@ -117,14 +119,14 @@ public class AuditFilterBarTests : BunitContext
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
// Single-value filter contract: Failed leads the non-success set.
Assert.Equal(AuditStatus.Failed, captured!.Status);
// Single-value collapse contract (Task 8): Failed leads the non-success set.
Assert.Equal(new[] { AuditStatus.Failed }, captured!.Statuses);
// Now pin an explicit Status chip — Errors-only must yield (chip wins).
cut.Find("[data-test=\"chip-status-Delivered\"]").Click();
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Equal(AuditStatus.Delivered, captured!.Status);
Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses);
}
[Fact]

View File

@@ -36,10 +36,10 @@ public class AuditLogPageExportUrlTests
{
var corr = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
var filter = new AuditLogQueryFilter(
Channel: AuditChannel.ApiOutbound,
Kind: AuditKind.ApiCall,
Status: AuditStatus.Failed,
SourceSiteId: "plant-a",
Channels: new[] { AuditChannel.ApiOutbound },
Kinds: new[] { AuditKind.ApiCall },
Statuses: new[] { AuditStatus.Failed },
SourceSiteIds: new[] { "plant-a" },
Target: "PaymentApi",
Actor: "apikey-1",
CorrelationId: corr,
@@ -65,7 +65,7 @@ public class AuditLogPageExportUrlTests
[Fact]
public void BuildExportUrl_OnlyChannelSet_OmitsOtherParams()
{
var filter = new AuditLogQueryFilter(Channel: AuditChannel.Notification);
var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.Notification });
var url = AuditLogPage.BuildExportUrl(filter);

View File

@@ -197,7 +197,8 @@ public class AuditLogPageScaffoldTests : BunitContext
cut.WaitForAssertion(() =>
{
_queryService.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.SourceSiteId == "plant-a"),
Arg.Is<AuditLogQueryFilter>(f =>
f.SourceSiteIds != null && f.SourceSiteIds.Count == 1 && f.SourceSiteIds[0] == "plant-a"),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
});
@@ -218,7 +219,8 @@ public class AuditLogPageScaffoldTests : BunitContext
cut.WaitForAssertion(() =>
{
_queryService.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.Status == AuditStatus.Failed),
Arg.Is<AuditLogQueryFilter>(f =>
f.Statuses != null && f.Statuses.Count == 1 && f.Statuses[0] == AuditStatus.Failed),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
});

View File

@@ -34,7 +34,7 @@ public class AuditLogQueryServiceTests
public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository()
{
var repo = Substitute.For<IAuditLogRepository>();
var filter = new AuditLogQueryFilter(Channel: AuditChannel.ApiOutbound);
var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.ApiOutbound });
var paging = new AuditLogPaging(PageSize: 25);
var expected = new List<AuditEvent>
{
@@ -179,7 +179,7 @@ public class AuditLogQueryServiceTests
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
var sut = new AuditLogQueryService(scopeFactory, EmptyAggregator());
var filter = new AuditLogQueryFilter(Channel: AuditChannel.ApiOutbound);
var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.ApiOutbound });
// Fire two QueryAsync calls in parallel. With scope-per-query each gets a
// fresh DbContext, so this completes cleanly; with a shared scoped context

View File

@@ -91,7 +91,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(20)));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Equal(3, rows.Count);
@@ -114,13 +114,116 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), channel: AuditChannel.Notification));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(Channel: AuditChannel.Notification, SourceSiteId: siteId),
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()
{
@@ -137,7 +240,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.InsertIfNotExistsAsync(NewEvent(otherSiteId, occurredAtUtc: t0.AddMinutes(2)));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
@@ -160,7 +263,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(
SourceSiteId: siteId,
SourceSiteIds: new[] { siteId },
FromUtc: t0.AddMinutes(10),
ToUtc: t0.AddHours(1)),
new AuditLogPaging(PageSize: 10));
@@ -187,7 +290,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
}
var page1 = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 2));
Assert.Equal(2, page1.Count);
@@ -196,7 +299,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
var cursor = page1[^1];
var page2 = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(
PageSize: 2,
AfterOccurredAtUtc: cursor.OccurredAtUtc,
@@ -208,7 +311,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
var cursor2 = page2[^1];
var page3 = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteId: siteId),
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(
PageSize: 2,
AfterOccurredAtUtc: cursor2.OccurredAtUtc,
@@ -281,7 +384,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
await repo.InsertIfNotExistsAsync(e);
}
var filter = new AuditLogQueryFilter(SourceSiteId: siteId);
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { siteId });
var page1 = await repo.QueryAsync(filter, new AuditLogPaging(PageSize: 2));
Assert.Equal(2, page1.Count);