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

@@ -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