feat(site-call-audit): additive TrackedOperationId query filter for ?correlationId= deep links
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
@@ -22,6 +22,11 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
|||||||
/// Requested page size. The actor clamps this to the <c>[1, 200]</c> range, so
|
/// Requested page size. The actor clamps this to the <c>[1, 200]</c> range, so
|
||||||
/// the effective ceiling is 200 rows per page regardless of the value sent.
|
/// the effective ceiling is 200 rows per page regardless of the value sent.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="TrackedOperationIdFilter">
|
||||||
|
/// Exact-match on one operation (the <c>?correlationId=</c> deep link from the
|
||||||
|
/// Flow Debugger / Audit Log); <c>null</c> = no filter. Additive, trailing
|
||||||
|
/// optional so existing positional callers are unaffected.
|
||||||
|
/// </param>
|
||||||
public sealed record SiteCallQueryRequest(
|
public sealed record SiteCallQueryRequest(
|
||||||
string CorrelationId,
|
string CorrelationId,
|
||||||
string? StatusFilter,
|
string? StatusFilter,
|
||||||
@@ -34,7 +39,8 @@ public sealed record SiteCallQueryRequest(
|
|||||||
DateTime? AfterCreatedAtUtc,
|
DateTime? AfterCreatedAtUtc,
|
||||||
Guid? AfterId,
|
Guid? AfterId,
|
||||||
int PageSize,
|
int PageSize,
|
||||||
string? SourceNodeFilter = null);
|
string? SourceNodeFilter = null,
|
||||||
|
Guid? TrackedOperationIdFilter = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A single <c>SiteCalls</c> row summarised for the Site Calls UI grid. Carries
|
/// A single <c>SiteCalls</c> row summarised for the Site Calls UI grid. Carries
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
|||||||
/// <c>"site-plant-a-node-a"</c>). Exact match; <c>null</c> means "do not
|
/// <c>"site-plant-a-node-a"</c>). Exact match; <c>null</c> means "do not
|
||||||
/// constrain". Rows with NULL <c>SourceNode</c> are excluded when set.
|
/// constrain". Rows with NULL <c>SourceNode</c> are excluded when set.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="TrackedOperationId">
|
||||||
|
/// Restrict to exactly one cached call by its primary key — the
|
||||||
|
/// <c>?correlationId=</c> deep link from the Flow Debugger / Audit Log.
|
||||||
|
/// <c>null</c> means "do not constrain". AND-combined with every other field,
|
||||||
|
/// so a mismatching <see cref="SourceSite"/> (etc.) still yields no rows.
|
||||||
|
/// </param>
|
||||||
public sealed record SiteCallQueryFilter(
|
public sealed record SiteCallQueryFilter(
|
||||||
string? Channel = null,
|
string? Channel = null,
|
||||||
string? SourceSite = null,
|
string? SourceSite = null,
|
||||||
@@ -39,4 +45,5 @@ public sealed record SiteCallQueryFilter(
|
|||||||
DateTime? FromUtc = null,
|
DateTime? FromUtc = null,
|
||||||
DateTime? ToUtc = null,
|
DateTime? ToUtc = null,
|
||||||
DateTime? StuckCutoffUtc = null,
|
DateTime? StuckCutoffUtc = null,
|
||||||
string? SourceNode = null);
|
string? SourceNode = null,
|
||||||
|
Guid? TrackedOperationId = null);
|
||||||
|
|||||||
+8
@@ -294,6 +294,12 @@ WHERE TrackedOperationId = @Id
|
|||||||
var fromUtc = filter.FromUtc;
|
var fromUtc = filter.FromUtc;
|
||||||
var toUtc = filter.ToUtc;
|
var toUtc = filter.ToUtc;
|
||||||
var stuckCutoff = filter.StuckCutoffUtc;
|
var stuckCutoff = filter.StuckCutoffUtc;
|
||||||
|
// TrackedOperationId is a varchar(36) column holding the "D"-format GUID
|
||||||
|
// string (see SiteCallEntityTypeConfiguration), so the exact-match filter
|
||||||
|
// is compared as a string — same shape as the @AfterId cursor below. A
|
||||||
|
// uniqueidentifier parameter would make SQL Server convert the COLUMN side
|
||||||
|
// (higher type precedence) and lose the PK seek.
|
||||||
|
var trackedOperationIdString = filter.TrackedOperationId?.ToString("D");
|
||||||
|
|
||||||
// The stuck predicate (TerminalAtUtc IS NULL AND CreatedAtUtc < cutoff)
|
// The stuck predicate (TerminalAtUtc IS NULL AND CreatedAtUtc < cutoff)
|
||||||
// is pushed into SQL here — both columns are plain (no value converter)
|
// is pushed into SQL here — both columns are plain (no value converter)
|
||||||
@@ -315,6 +321,7 @@ WHERE (@Channel IS NULL OR Channel = @Channel)
|
|||||||
AND (@SourceNode IS NULL OR SourceNode = @SourceNode)
|
AND (@SourceNode IS NULL OR SourceNode = @SourceNode)
|
||||||
AND (@Status IS NULL OR Status = @Status)
|
AND (@Status IS NULL OR Status = @Status)
|
||||||
AND (@Target IS NULL OR Target = @Target)
|
AND (@Target IS NULL OR Target = @Target)
|
||||||
|
AND (@TrackedOperationId IS NULL OR TrackedOperationId = @TrackedOperationId)
|
||||||
AND (@FromUtc IS NULL OR CreatedAtUtc >= @FromUtc)
|
AND (@FromUtc IS NULL OR CreatedAtUtc >= @FromUtc)
|
||||||
AND (@ToUtc IS NULL OR CreatedAtUtc <= @ToUtc)
|
AND (@ToUtc IS NULL OR CreatedAtUtc <= @ToUtc)
|
||||||
AND (@StuckCutoff IS NULL OR (TerminalAtUtc IS NULL AND CreatedAtUtc < @StuckCutoff))
|
AND (@StuckCutoff IS NULL OR (TerminalAtUtc IS NULL AND CreatedAtUtc < @StuckCutoff))
|
||||||
@@ -338,6 +345,7 @@ OPTION (RECOMPILE);";
|
|||||||
Param("@SourceNode", SqlDbType.VarChar, filter.SourceNode),
|
Param("@SourceNode", SqlDbType.VarChar, filter.SourceNode),
|
||||||
Param("@Status", SqlDbType.VarChar, filter.Status),
|
Param("@Status", SqlDbType.VarChar, filter.Status),
|
||||||
Param("@Target", SqlDbType.VarChar, filter.Target),
|
Param("@Target", SqlDbType.VarChar, filter.Target),
|
||||||
|
Param("@TrackedOperationId", SqlDbType.VarChar, trackedOperationIdString),
|
||||||
Param("@FromUtc", SqlDbType.DateTime2, fromUtc),
|
Param("@FromUtc", SqlDbType.DateTime2, fromUtc),
|
||||||
Param("@ToUtc", SqlDbType.DateTime2, toUtc),
|
Param("@ToUtc", SqlDbType.DateTime2, toUtc),
|
||||||
Param("@StuckCutoff", SqlDbType.DateTime2, stuckCutoff),
|
Param("@StuckCutoff", SqlDbType.DateTime2, stuckCutoff),
|
||||||
|
|||||||
@@ -832,7 +832,8 @@ public class SiteCallAuditActor : ReceiveActor
|
|||||||
// keyset cursor, so the page is always honest (full pages, no empty
|
// keyset cursor, so the page is always honest (full pages, no empty
|
||||||
// pages with a non-null next cursor).
|
// pages with a non-null next cursor).
|
||||||
StuckCutoffUtc: request.StuckOnly ? stuckCutoff : null,
|
StuckCutoffUtc: request.StuckOnly ? stuckCutoff : null,
|
||||||
SourceNode: NullIfBlank(request.SourceNodeFilter));
|
SourceNode: NullIfBlank(request.SourceNodeFilter),
|
||||||
|
TrackedOperationId: request.TrackedOperationIdFilter);
|
||||||
|
|
||||||
var pageSize = Math.Clamp(request.PageSize, 1, MaxPageSize);
|
var pageSize = Math.Clamp(request.PageSize, 1, MaxPageSize);
|
||||||
var paging = new SiteCallPaging(
|
var paging = new SiteCallPaging(
|
||||||
|
|||||||
+34
@@ -438,6 +438,40 @@ public class SiteCallAuditRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
Assert.All(rows, r => Assert.Equal(siteA, r.SourceSite));
|
Assert.All(rows, r => Assert.Equal(siteA, r.SourceSite));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task QueryAsync_FilterByTrackedOperationId_ReturnsExactlyThatRow()
|
||||||
|
{
|
||||||
|
// Flow Debugger follow-ups Task 2: SiteCallQueryFilter.TrackedOperationId is
|
||||||
|
// an exact PK match (the ?correlationId= deep link). The column is varchar(36)
|
||||||
|
// holding the "D"-format GUID string, so the predicate must compare as a
|
||||||
|
// string — a uniqueidentifier parameter would convert the column side and
|
||||||
|
// defeat the PK seek.
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var site = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new SiteCallAuditRepository(context);
|
||||||
|
|
||||||
|
var idA = TrackedOperationId.New();
|
||||||
|
var idB = TrackedOperationId.New();
|
||||||
|
var t0 = new DateTime(2026, 9, 6, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
await repo.UpsertAsync(NewRow(idA, sourceSite: site, createdAtUtc: t0));
|
||||||
|
await repo.UpsertAsync(NewRow(idB, sourceSite: site, createdAtUtc: t0.AddMinutes(1)));
|
||||||
|
|
||||||
|
var rows = await repo.QueryAsync(
|
||||||
|
new SiteCallQueryFilter(TrackedOperationId: idA.Value),
|
||||||
|
new SiteCallPaging(PageSize: 10));
|
||||||
|
|
||||||
|
var row = Assert.Single(rows);
|
||||||
|
Assert.Equal(idA, row.TrackedOperationId);
|
||||||
|
|
||||||
|
// Composes with the other predicates: a mismatching site yields nothing.
|
||||||
|
var none = await repo.QueryAsync(
|
||||||
|
new SiteCallQueryFilter(SourceSite: NewSiteId(), TrackedOperationId: idA.Value),
|
||||||
|
new SiteCallPaging(PageSize: 10));
|
||||||
|
Assert.Empty(none);
|
||||||
|
}
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task QueryAsync_KeysetPaging_NoOverlap()
|
public async Task QueryAsync_KeysetPaging_NoOverlap()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -268,6 +268,45 @@ public class SiteCallAuditActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
|||||||
Assert.Equal("Attempted", response.SiteCalls[0].Status);
|
Assert.Equal("Attempted", response.SiteCalls[0].Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task SiteCallQueryRequest_FilterByTrackedOperationId_ReturnsOnlyThatRow()
|
||||||
|
{
|
||||||
|
// Flow Debugger follow-ups Task 2: the ?correlationId= deep link pushes
|
||||||
|
// one operation id into SiteCallQueryRequest.TrackedOperationIdFilter —
|
||||||
|
// the actor must thread it onto SiteCallQueryFilter.TrackedOperationId
|
||||||
|
// so the page returns exactly that row and nothing else from the site.
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new SiteCallAuditRepository(context);
|
||||||
|
var actor = CreateActor(repo);
|
||||||
|
|
||||||
|
var t0 = new DateTime(2026, 9, 6, 14, 0, 0, DateTimeKind.Utc);
|
||||||
|
var rowA = NewRow(TrackedOperationId.New(), siteId, status: "Attempted", createdAtUtc: t0);
|
||||||
|
var rowB = NewRow(
|
||||||
|
TrackedOperationId.New(), siteId, status: "Delivered",
|
||||||
|
createdAtUtc: t0.AddMinutes(1), terminal: true);
|
||||||
|
await repo.UpsertAsync(rowA);
|
||||||
|
await repo.UpsertAsync(rowB);
|
||||||
|
|
||||||
|
actor.Tell(
|
||||||
|
new SiteCallQueryRequest(
|
||||||
|
"corr-op", StatusFilter: null, SourceSiteFilter: siteId, ChannelFilter: null,
|
||||||
|
TargetKeyword: null, StuckOnly: false, FromUtc: null, ToUtc: null,
|
||||||
|
AfterCreatedAtUtc: null, AfterId: null, PageSize: 50,
|
||||||
|
TrackedOperationIdFilter: rowA.TrackedOperationId.Value),
|
||||||
|
TestActor);
|
||||||
|
|
||||||
|
var response = ExpectMsg<SiteCallQueryResponse>(TimeSpan.FromSeconds(10));
|
||||||
|
Assert.True(response.Success);
|
||||||
|
Assert.Single(response.SiteCalls);
|
||||||
|
Assert.Equal(rowA.TrackedOperationId.Value, response.SiteCalls[0].TrackedOperationId);
|
||||||
|
Assert.Equal("Attempted", response.SiteCalls[0].Status);
|
||||||
|
// No further page — the exact-match filter yields at most one row.
|
||||||
|
Assert.Equal(rowA.TrackedOperationId.Value, response.NextAfterId);
|
||||||
|
}
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task SiteCallQueryRequest_KeysetPaging_AdvancesViaCursor()
|
public async Task SiteCallQueryRequest_KeysetPaging_AdvancesViaCursor()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user