Compare commits
6 Commits
feature/au
...
feature/au
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
849a011400 | ||
|
|
405de525ca | ||
|
|
77922abb33 | ||
|
|
5f544bfe1e | ||
|
|
aaa6df24cf | ||
|
|
e36f0bf9c8 |
@@ -6,78 +6,58 @@
|
|||||||
|
|
||||||
<div class="card mb-3" data-test="audit-filter-bar">
|
<div class="card mb-3" data-test="audit-filter-bar">
|
||||||
<div class="card-body py-2">
|
<div class="card-body py-2">
|
||||||
@* Channel chip multi-select. *@
|
@* All filters sit in one wrapped row. Kind / Status / Site use compact
|
||||||
<div class="mb-2" data-test="filter-channel">
|
MultiSelectDropdown controls; Channel is a single-select because the
|
||||||
<label class="form-label small mb-1">Channel</label>
|
Kind options narrow to the chosen channel — so the bar stays a row or
|
||||||
<div>
|
two tall instead of four stacked blocks of chip buttons. *@
|
||||||
@foreach (var channel in Enum.GetValues<AuditChannel>())
|
|
||||||
{
|
|
||||||
var selected = _model.Channels.Contains(channel);
|
|
||||||
<button type="button" data-test="chip-channel-@channel"
|
|
||||||
class="@ChipClass(selected)"
|
|
||||||
@onclick="() => ToggleChannel(channel)">
|
|
||||||
@channel
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@* Kind chip multi-select — narrowed by Channel selection. *@
|
|
||||||
<div class="mb-2" data-test="filter-kind">
|
|
||||||
<label class="form-label small mb-1">Kind</label>
|
|
||||||
<div>
|
|
||||||
@foreach (var kind in _model.VisibleKinds())
|
|
||||||
{
|
|
||||||
var selected = _model.Kinds.Contains(kind);
|
|
||||||
<button type="button" data-test="chip-kind-@kind"
|
|
||||||
class="@ChipClass(selected)"
|
|
||||||
@onclick="() => ToggleKind(kind)">
|
|
||||||
@kind
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@* Status chip multi-select. *@
|
|
||||||
<div class="mb-2" data-test="filter-status">
|
|
||||||
<label class="form-label small mb-1">Status</label>
|
|
||||||
<div>
|
|
||||||
@foreach (var status in Enum.GetValues<AuditStatus>())
|
|
||||||
{
|
|
||||||
var selected = _model.Statuses.Contains(status);
|
|
||||||
<button type="button" data-test="chip-status-@status"
|
|
||||||
class="@ChipClass(selected)"
|
|
||||||
@onclick="() => ToggleStatus(status)">
|
|
||||||
@status
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@* Site chip multi-select — populated from ISiteRepository. *@
|
|
||||||
<div class="mb-2" data-test="filter-site">
|
|
||||||
<label class="form-label small mb-1">Site</label>
|
|
||||||
<div>
|
|
||||||
@if (_sites.Count == 0)
|
|
||||||
{
|
|
||||||
<span class="text-muted small">No sites available.</span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@foreach (var site in _sites)
|
|
||||||
{
|
|
||||||
var selected = _model.SiteIdentifiers.Contains(site.SiteIdentifier);
|
|
||||||
<button type="button" data-test="chip-site-@site.SiteIdentifier"
|
|
||||||
class="@ChipClass(selected)"
|
|
||||||
@onclick="() => ToggleSite(site.SiteIdentifier)">
|
|
||||||
@site.Name
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-2 align-items-end">
|
<div class="row g-2 align-items-end">
|
||||||
|
@* Single-select: one channel at a time, so the Kind options below
|
||||||
|
narrow cleanly to that channel. "All channels" clears it. *@
|
||||||
|
<div class="col-auto" data-test="filter-channel">
|
||||||
|
<label class="form-label small mb-1" for="audit-channel">Channel</label>
|
||||||
|
<select id="audit-channel" data-test="filter-channel-select"
|
||||||
|
class="form-select form-select-sm" @bind="SelectedChannel">
|
||||||
|
<option value="">All channels</option>
|
||||||
|
@foreach (var channel in _channels)
|
||||||
|
{
|
||||||
|
<option value="@channel">@channel</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Kind options are narrowed by the Channel selection (VisibleKinds). *@
|
||||||
|
<div class="col-auto" data-test="filter-kind">
|
||||||
|
<label class="form-label small mb-1">Kind</label>
|
||||||
|
<div>
|
||||||
|
<MultiSelectDropdown TValue="AuditKind"
|
||||||
|
Items="_model.VisibleKinds()"
|
||||||
|
Selected="_model.Kinds"
|
||||||
|
DataTest="filter-kind-ms" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-auto" data-test="filter-status">
|
||||||
|
<label class="form-label small mb-1">Status</label>
|
||||||
|
<div>
|
||||||
|
<MultiSelectDropdown TValue="AuditStatus"
|
||||||
|
Items="_statuses"
|
||||||
|
Selected="_model.Statuses"
|
||||||
|
DataTest="filter-status-ms" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-auto" data-test="filter-site">
|
||||||
|
<label class="form-label small mb-1">Site</label>
|
||||||
|
<div>
|
||||||
|
<MultiSelectDropdown TValue="string"
|
||||||
|
Items="_siteIds"
|
||||||
|
Selected="_model.SiteIdentifiers"
|
||||||
|
Display="SiteName"
|
||||||
|
EmptyText="No sites available"
|
||||||
|
DataTest="filter-site-ms" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-auto" data-test="filter-time-range">
|
<div class="col-auto" data-test="filter-time-range">
|
||||||
<label class="form-label small mb-1" for="audit-time-range">Time range</label>
|
<label class="form-label small mb-1" for="audit-time-range">Time range</label>
|
||||||
<select id="audit-time-range" class="form-select form-select-sm"
|
<select id="audit-time-range" class="form-select form-select-sm"
|
||||||
|
|||||||
@@ -7,19 +7,32 @@ namespace ScadaLink.CentralUI.Components.Audit;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Filter bar for the central Audit Log page (#23 M7-T2). Owns the
|
/// Filter bar for the central Audit Log page (#23 M7-T2). Owns the
|
||||||
/// <see cref="AuditQueryModel"/> binding state, renders the 10 filter elements
|
/// <see cref="AuditQueryModel"/> binding state and renders the filter controls
|
||||||
/// plus the Errors-only toggle, and publishes a collapsed
|
/// — Channel as a single-select (one channel at a time, so the Kind options
|
||||||
/// <see cref="AuditLogQueryFilter"/> via <see cref="OnFilterChanged"/> when the
|
/// narrow to it cleanly); Kind / Status / Site as compact
|
||||||
/// user clicks Apply. See <see cref="AuditQueryModel"/> for the multi-select →
|
/// <see cref="ScadaLink.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
|
||||||
/// single-value collapse contract.
|
/// controls; plus the time range, free-text searches and the Errors-only
|
||||||
|
/// toggle — and publishes an <see cref="AuditLogQueryFilter"/> via
|
||||||
|
/// <see cref="OnFilterChanged"/> when the user clicks Apply. The selected
|
||||||
|
/// dimensions map through to the filter's list fields; see
|
||||||
|
/// <see cref="AuditQueryModel"/> for the Errors-only and time-range rules.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class AuditFilterBar
|
public partial class AuditFilterBar
|
||||||
{
|
{
|
||||||
private readonly AuditQueryModel _model = new();
|
private readonly AuditQueryModel _model = new();
|
||||||
private List<Site> _sites = new();
|
private List<Site> _sites = new();
|
||||||
|
|
||||||
|
/// <summary>Channel options — the full enum, fixed for the component's lifetime.</summary>
|
||||||
|
private static readonly IReadOnlyList<AuditChannel> _channels = Enum.GetValues<AuditChannel>();
|
||||||
|
|
||||||
|
/// <summary>Status options — the full enum, fixed for the component's lifetime.</summary>
|
||||||
|
private static readonly IReadOnlyList<AuditStatus> _statuses = Enum.GetValues<AuditStatus>();
|
||||||
|
|
||||||
|
/// <summary>Site identifiers in display order; rebuilt once when sites load.</summary>
|
||||||
|
private IReadOnlyList<string> _siteIds = Array.Empty<string>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Raised when the user clicks Apply. Carries the collapsed
|
/// Raised when the user clicks Apply. Carries the
|
||||||
/// <see cref="AuditLogQueryFilter"/> the parent page hands to
|
/// <see cref="AuditLogQueryFilter"/> the parent page hands to
|
||||||
/// <see cref="ScadaLink.CentralUI.Services.IAuditLogQueryService"/>.
|
/// <see cref="ScadaLink.CentralUI.Services.IAuditLogQueryService"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -51,10 +64,9 @@ public partial class AuditFilterBar
|
|||||||
_model.InstanceSearch = InitialInstanceSearch.Trim();
|
_model.InstanceSearch = InitialInstanceSearch.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Populate the Site dropdown at component init. Failure is non-fatal — the
|
||||||
// Populate the Site chips at component init. Failure is non-fatal — the chip
|
// dropdown just shows "No sites available." Sites are listed by Name to
|
||||||
// section just shows "No sites available." Sites are listed by Name to match
|
// match operator expectations from the Notification Report.
|
||||||
// operator expectations from the Notification Report.
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var sites = await SiteRepository.GetAllSitesAsync();
|
var sites = await SiteRepository.GetAllSitesAsync();
|
||||||
@@ -62,48 +74,52 @@ public partial class AuditFilterBar
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Swallowed: filter bar still renders without the Site chips. The page
|
// Swallowed: filter bar still renders without the Site options. The page
|
||||||
// surfaces site-load errors elsewhere (the grid query path).
|
// surfaces site-load errors elsewhere (the grid query path).
|
||||||
_sites = new();
|
_sites = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_siteIds = _sites.Select(s => s.SiteIdentifier).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleChannel(AuditChannel channel)
|
/// <summary>
|
||||||
|
/// Single-select Channel binding for the filter bar. The Audit Log filters one
|
||||||
|
/// channel at a time so the Kind options narrow cleanly to it; the model still
|
||||||
|
/// stores the selection as a set (0 or 1 entry) so <see cref="AuditQueryModel.ToFilter"/>
|
||||||
|
/// and <see cref="AuditQueryModel.VisibleKinds"/> are unchanged. <c>null</c> = all channels.
|
||||||
|
/// </summary>
|
||||||
|
private AuditChannel? SelectedChannel
|
||||||
{
|
{
|
||||||
if (!_model.Channels.Add(channel))
|
get => _model.Channels.Count > 0 ? _model.Channels.First() : null;
|
||||||
|
set
|
||||||
{
|
{
|
||||||
_model.Channels.Remove(channel);
|
_model.Channels.Clear();
|
||||||
}
|
if (value is { } channel)
|
||||||
|
{
|
||||||
|
_model.Channels.Add(channel);
|
||||||
|
}
|
||||||
|
|
||||||
// Drop Kind chips that fall outside the new visible set. Keeps "Channel and
|
OnChannelsChanged();
|
||||||
// Kind both picked" coherent — without this, removing a channel could leave
|
}
|
||||||
// stale Kind chips selected that no longer match any visible chip.
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs after the Channel selection changes. Drops any Kind selections that fell
|
||||||
|
/// outside the new visible set — without this, changing the channel could leave
|
||||||
|
/// stale Kind selections that no longer match any visible option.
|
||||||
|
/// </summary>
|
||||||
|
private void OnChannelsChanged()
|
||||||
|
{
|
||||||
var visible = _model.VisibleKinds().ToHashSet();
|
var visible = _model.VisibleKinds().ToHashSet();
|
||||||
_model.Kinds.RemoveWhere(k => !visible.Contains(k));
|
_model.Kinds.RemoveWhere(k => !visible.Contains(k));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleKind(AuditKind kind)
|
/// <summary>Display label for a site identifier — its friendly Name, id as fallback.</summary>
|
||||||
|
private string SiteName(string siteIdentifier)
|
||||||
{
|
{
|
||||||
if (!_model.Kinds.Add(kind))
|
var site = _sites.FirstOrDefault(s =>
|
||||||
{
|
string.Equals(s.SiteIdentifier, siteIdentifier, StringComparison.OrdinalIgnoreCase));
|
||||||
_model.Kinds.Remove(kind);
|
return site?.Name ?? siteIdentifier;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ToggleStatus(AuditStatus status)
|
|
||||||
{
|
|
||||||
if (!_model.Statuses.Add(status))
|
|
||||||
{
|
|
||||||
_model.Statuses.Remove(status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ToggleSite(string siteIdentifier)
|
|
||||||
{
|
|
||||||
if (!_model.SiteIdentifiers.Add(siteIdentifier))
|
|
||||||
{
|
|
||||||
_model.SiteIdentifiers.Remove(siteIdentifier);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ClearFilters()
|
private void ClearFilters()
|
||||||
@@ -129,11 +145,6 @@ public partial class AuditFilterBar
|
|||||||
await OnFilterChanged.InvokeAsync(filter);
|
await OnFilterChanged.InvokeAsync(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ChipClass(bool selected) =>
|
|
||||||
selected
|
|
||||||
? "btn btn-sm btn-primary me-1 mb-1"
|
|
||||||
: "btn btn-sm btn-outline-secondary me-1 mb-1";
|
|
||||||
|
|
||||||
private static string TimeRangeLabel(AuditTimeRangePreset preset) => preset switch
|
private static string TimeRangeLabel(AuditTimeRangePreset preset) => preset switch
|
||||||
{
|
{
|
||||||
AuditTimeRangePreset.Last5Minutes => "now − 5 min → now",
|
AuditTimeRangePreset.Last5Minutes => "now − 5 min → now",
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
@typeparam TValue
|
||||||
|
@*
|
||||||
|
Compact multi-select control: a Bootstrap dropdown whose toggle button
|
||||||
|
summarises the current selection over a checkbox menu. Replaces a wrapped
|
||||||
|
block of chip buttons with a single control of one row's height.
|
||||||
|
*@
|
||||||
|
<div class="dropdown msd" data-test="@DataTest">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-outline-secondary dropdown-toggle msd-toggle text-start"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
data-bs-auto-close="outside"
|
||||||
|
aria-expanded="false"
|
||||||
|
disabled="@(Items.Count == 0)"
|
||||||
|
data-test="@($"{DataTest}-toggle")">
|
||||||
|
<span class="msd-summary">@Summary()</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu msd-menu">
|
||||||
|
@if (Items.Count == 0)
|
||||||
|
{
|
||||||
|
<li><span class="dropdown-item-text text-muted small">@EmptyText</span></li>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var item in Items)
|
||||||
|
{
|
||||||
|
var isSelected = Selected.Contains(item);
|
||||||
|
<li>
|
||||||
|
<label class="dropdown-item msd-item">
|
||||||
|
<input type="checkbox"
|
||||||
|
class="form-check-input msd-check"
|
||||||
|
checked="@isSelected"
|
||||||
|
@onchange="() => Toggle(item)"
|
||||||
|
data-test="@($"{DataTest}-opt-{item}")" />
|
||||||
|
<span>@Display(item)</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Components.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A compact multi-select control: a Bootstrap dropdown whose toggle button
|
||||||
|
/// summarises the current selection ("All" when empty, the single item's label
|
||||||
|
/// when one is picked, or "N selected" otherwise) over a checkbox menu.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// It exists to keep multi-value filter controls one row tall instead of a
|
||||||
|
/// wrapped block of chip buttons. The component mutates the caller-owned
|
||||||
|
/// <see cref="Selected"/> collection in place and raises
|
||||||
|
/// <see cref="SelectionChanged"/> after every toggle so the parent can react
|
||||||
|
/// (re-render, prune dependent selections, …).
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Requires the Bootstrap JS bundle (loaded in <c>App.razor</c>) for the
|
||||||
|
/// dropdown toggle; <c>data-bs-auto-close="outside"</c> keeps the menu open
|
||||||
|
/// while the operator ticks several boxes.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TValue">The option value type (an enum or string).</typeparam>
|
||||||
|
public partial class MultiSelectDropdown<TValue> where TValue : notnull
|
||||||
|
{
|
||||||
|
/// <summary>The options shown in the menu, in display order.</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public IReadOnlyList<TValue> Items { get; set; } = Array.Empty<TValue>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The caller-owned selection set. Mutated in place by <see cref="Toggle"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public ICollection<TValue> Selected { get; set; } = default!;
|
||||||
|
|
||||||
|
/// <summary>Maps an option to its display label. Defaults to <c>ToString()</c>.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public Func<TValue, string> Display { get; set; } = static v => v.ToString() ?? string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Raised after each toggle, once <see cref="Selected"/> has been updated.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback SelectionChanged { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Summary text shown on the toggle button when nothing is selected.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public string AllLabel { get; set; } = "All";
|
||||||
|
|
||||||
|
/// <summary>Text shown in the menu when there are no options.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public string EmptyText { get; set; } = "None available";
|
||||||
|
|
||||||
|
/// <summary><c>data-test</c> root for this control, its toggle and its options.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public string DataTest { get; set; } = "multi-select";
|
||||||
|
|
||||||
|
private async Task Toggle(TValue item)
|
||||||
|
{
|
||||||
|
// ICollection.Remove returns false when the item was absent — that is the
|
||||||
|
// "not currently selected" case, so add it. This is a plain toggle.
|
||||||
|
if (!Selected.Remove(item))
|
||||||
|
{
|
||||||
|
Selected.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
await SelectionChanged.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Summary()
|
||||||
|
{
|
||||||
|
var count = Selected.Count;
|
||||||
|
if (count == 0)
|
||||||
|
{
|
||||||
|
return AllLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count == 1)
|
||||||
|
{
|
||||||
|
// Prefer the single selection's label over a bare "1 selected".
|
||||||
|
foreach (var item in Items)
|
||||||
|
{
|
||||||
|
if (Selected.Contains(item))
|
||||||
|
{
|
||||||
|
return Display(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one selected value is not in the current Items list (e.g. a Kind
|
||||||
|
// narrowed out by a Channel change before the parent pruned it).
|
||||||
|
return "1 selected";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{count} selected";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/* Compact multi-select dropdown. Tuned to sit inline with form-select-sm /
|
||||||
|
form-control-sm controls in a filter row. */
|
||||||
|
|
||||||
|
.msd-toggle {
|
||||||
|
min-width: 9rem;
|
||||||
|
max-width: 15rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep a long option list from running off-screen — scroll within the menu. */
|
||||||
|
.msd-menu {
|
||||||
|
max-height: 16rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The whole row is a <label> so a click anywhere toggles the checkbox; the
|
||||||
|
menu stays open thanks to data-bs-auto-close="outside". */
|
||||||
|
.msd-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Neutralise the default form-check-input top margin so the box lines up with
|
||||||
|
the option text inside the dropdown-item. */
|
||||||
|
.msd-check {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -420,7 +420,7 @@ public class ScriptRuntimeContext
|
|||||||
{
|
{
|
||||||
var elapsedMs = (int)((Stopwatch.GetTimestamp() - startTicks)
|
var elapsedMs = (int)((Stopwatch.GetTimestamp() - startTicks)
|
||||||
* 1000d / Stopwatch.Frequency);
|
* 1000d / Stopwatch.Frequency);
|
||||||
EmitCallAudit(systemName, methodName, occurredAtUtc, elapsedMs, result, thrown);
|
EmitCallAudit(systemName, methodName, occurredAtUtc, elapsedMs, result, thrown, parameters);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,7 +458,7 @@ public class ScriptRuntimeContext
|
|||||||
// Submitted row even if the immediate-delivery attempt happens to
|
// Submitted row even if the immediate-delivery attempt happens to
|
||||||
// resolve before this method returns.
|
// resolve before this method returns.
|
||||||
await EmitCachedSubmitTelemetryAsync(
|
await EmitCachedSubmitTelemetryAsync(
|
||||||
systemName, methodName, target, trackedId, occurredAtUtc, cancellationToken)
|
systemName, methodName, target, trackedId, occurredAtUtc, parameters, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
// Hand off to the existing cached-call path. The TrackedOperationId
|
// Hand off to the existing cached-call path. The TrackedOperationId
|
||||||
@@ -503,7 +503,7 @@ public class ScriptRuntimeContext
|
|||||||
if (result is { WasBuffered: false })
|
if (result is { WasBuffered: false })
|
||||||
{
|
{
|
||||||
await EmitImmediateTerminalTelemetryAsync(
|
await EmitImmediateTerminalTelemetryAsync(
|
||||||
systemName, methodName, target, trackedId, result, cancellationToken)
|
systemName, methodName, target, trackedId, result, parameters, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,6 +521,7 @@ public class ScriptRuntimeContext
|
|||||||
string target,
|
string target,
|
||||||
TrackedOperationId trackedId,
|
TrackedOperationId trackedId,
|
||||||
DateTime occurredAtUtc,
|
DateTime occurredAtUtc,
|
||||||
|
IReadOnlyDictionary<string, object?>? parameters,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (_cachedForwarder == null)
|
if (_cachedForwarder == null)
|
||||||
@@ -544,6 +545,8 @@ public class ScriptRuntimeContext
|
|||||||
SourceScript = _sourceScript,
|
SourceScript = _sourceScript,
|
||||||
Target = target,
|
Target = target,
|
||||||
Status = AuditStatus.Submitted,
|
Status = AuditStatus.Submitted,
|
||||||
|
// Submit precedes the call — request args only, no response yet.
|
||||||
|
RequestSummary = SerializeRequest(parameters),
|
||||||
ForwardState = AuditForwardState.Pending,
|
ForwardState = AuditForwardState.Pending,
|
||||||
},
|
},
|
||||||
Operational: new SiteCallOperational(
|
Operational: new SiteCallOperational(
|
||||||
@@ -599,6 +602,7 @@ public class ScriptRuntimeContext
|
|||||||
string target,
|
string target,
|
||||||
TrackedOperationId trackedId,
|
TrackedOperationId trackedId,
|
||||||
ExternalCallResult result,
|
ExternalCallResult result,
|
||||||
|
IReadOnlyDictionary<string, object?>? parameters,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (_cachedForwarder == null)
|
if (_cachedForwarder == null)
|
||||||
@@ -653,6 +657,8 @@ public class ScriptRuntimeContext
|
|||||||
Status = AuditStatus.Attempted,
|
Status = AuditStatus.Attempted,
|
||||||
HttpStatus = httpStatus,
|
HttpStatus = httpStatus,
|
||||||
ErrorMessage = result.Success ? null : result.ErrorMessage,
|
ErrorMessage = result.Success ? null : result.ErrorMessage,
|
||||||
|
RequestSummary = SerializeRequest(parameters),
|
||||||
|
ResponseSummary = result.ResponseJson,
|
||||||
ForwardState = AuditForwardState.Pending,
|
ForwardState = AuditForwardState.Pending,
|
||||||
},
|
},
|
||||||
Operational: new SiteCallOperational(
|
Operational: new SiteCallOperational(
|
||||||
@@ -712,6 +718,8 @@ public class ScriptRuntimeContext
|
|||||||
Status = auditTerminalStatus,
|
Status = auditTerminalStatus,
|
||||||
HttpStatus = httpStatus,
|
HttpStatus = httpStatus,
|
||||||
ErrorMessage = result.Success ? null : result.ErrorMessage,
|
ErrorMessage = result.Success ? null : result.ErrorMessage,
|
||||||
|
RequestSummary = SerializeRequest(parameters),
|
||||||
|
ResponseSummary = result.ResponseJson,
|
||||||
ForwardState = AuditForwardState.Pending,
|
ForwardState = AuditForwardState.Pending,
|
||||||
},
|
},
|
||||||
Operational: new SiteCallOperational(
|
Operational: new SiteCallOperational(
|
||||||
@@ -762,7 +770,8 @@ public class ScriptRuntimeContext
|
|||||||
DateTime occurredAtUtc,
|
DateTime occurredAtUtc,
|
||||||
int durationMs,
|
int durationMs,
|
||||||
ExternalCallResult? result,
|
ExternalCallResult? result,
|
||||||
Exception? thrown)
|
Exception? thrown,
|
||||||
|
IReadOnlyDictionary<string, object?>? parameters)
|
||||||
{
|
{
|
||||||
if (_auditWriter == null)
|
if (_auditWriter == null)
|
||||||
{
|
{
|
||||||
@@ -772,7 +781,8 @@ public class ScriptRuntimeContext
|
|||||||
AuditEvent evt;
|
AuditEvent evt;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
evt = BuildCallAuditEvent(systemName, methodName, occurredAtUtc, durationMs, result, thrown);
|
evt = BuildCallAuditEvent(
|
||||||
|
systemName, methodName, occurredAtUtc, durationMs, result, thrown, parameters);
|
||||||
}
|
}
|
||||||
catch (Exception buildEx)
|
catch (Exception buildEx)
|
||||||
{
|
{
|
||||||
@@ -828,7 +838,8 @@ public class ScriptRuntimeContext
|
|||||||
DateTime occurredAtUtc,
|
DateTime occurredAtUtc,
|
||||||
int durationMs,
|
int durationMs,
|
||||||
ExternalCallResult? result,
|
ExternalCallResult? result,
|
||||||
Exception? thrown)
|
Exception? thrown,
|
||||||
|
IReadOnlyDictionary<string, object?>? parameters)
|
||||||
{
|
{
|
||||||
// Status: Delivered on a Success result; Failed otherwise (the
|
// Status: Delivered on a Success result; Failed otherwise (the
|
||||||
// ExternalSystemClient already maps HTTP non-2xx + transient
|
// ExternalSystemClient already maps HTTP non-2xx + transient
|
||||||
@@ -885,13 +896,41 @@ public class ScriptRuntimeContext
|
|||||||
DurationMs = durationMs,
|
DurationMs = durationMs,
|
||||||
ErrorMessage = errorMessage,
|
ErrorMessage = errorMessage,
|
||||||
ErrorDetail = errorDetail,
|
ErrorDetail = errorDetail,
|
||||||
RequestSummary = null,
|
// Payload capture: the request arguments and the response body.
|
||||||
ResponseSummary = null,
|
// The audit writer's payload filter applies the configured size
|
||||||
|
// cap and header/secret redaction downstream — the emitter just
|
||||||
|
// hands over the raw values.
|
||||||
|
RequestSummary = SerializeRequest(parameters),
|
||||||
|
ResponseSummary = result?.ResponseJson,
|
||||||
PayloadTruncated = false,
|
PayloadTruncated = false,
|
||||||
Extra = null,
|
Extra = null,
|
||||||
ForwardState = AuditForwardState.Pending,
|
ForwardState = AuditForwardState.Pending,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialises the outbound-call argument dictionary into the JSON
|
||||||
|
/// <c>RequestSummary</c> stamped on <c>ApiOutbound</c> audit rows.
|
||||||
|
/// Returns <c>null</c> for a null/empty argument set. Serialization
|
||||||
|
/// failure is swallowed (returns <c>null</c>) — a payload that cannot be
|
||||||
|
/// summarised must never abort the best-effort audit emission.
|
||||||
|
/// </summary>
|
||||||
|
private static string? SerializeRequest(IReadOnlyDictionary<string, object?>? parameters)
|
||||||
|
{
|
||||||
|
if (parameters is null || parameters.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Serialize(parameters);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// Scenarios covered (per the M7-T16 brief):
|
/// Scenarios covered (per the M7-T16 brief):
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><c>FilterNarrowing</c> — channel chip narrows the results grid.</item>
|
/// <item><c>FilterNarrowing</c> — the channel filter narrows the results grid.</item>
|
||||||
/// <item><c>DrilldownDrawer_JsonPrettyPrint</c> — JSON request bodies pretty-print.</item>
|
/// <item><c>DrilldownDrawer_JsonPrettyPrint</c> — JSON request bodies pretty-print.</item>
|
||||||
/// <item><c>CopyAsCurlButton_VisibleOnApiInbound</c> — cURL action visible for API rows.</item>
|
/// <item><c>CopyAsCurlButton_VisibleOnApiInbound</c> — cURL action visible for API rows.</item>
|
||||||
/// <item><c>DrillInFromCorrelationId_AutoLoadsAuditLog</c> — query-string drill-in
|
/// <item><c>DrillInFromCorrelationId_AutoLoadsAuditLog</c> — query-string drill-in
|
||||||
@@ -45,7 +45,7 @@ public class AuditLogPageTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FilterNarrowing_ChannelChipShrinksGrid()
|
public async Task FilterNarrowing_ChannelFilterShrinksGrid()
|
||||||
{
|
{
|
||||||
// Skip with a clear message when MSSQL is not reachable — the rest of
|
// Skip with a clear message when MSSQL is not reachable — the rest of
|
||||||
// the Playwright suite is UI-only and does not need the DB, so this
|
// the Playwright suite is UI-only and does not need the DB, so this
|
||||||
@@ -91,13 +91,13 @@ public class AuditLogPageTests
|
|||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
// Pre-Apply, both rows are absent because the grid stays empty until
|
// Pre-Apply, both rows are absent because the grid stays empty until
|
||||||
// the user filters. Click the ApiOutbound chip then Apply.
|
// the user filters. Pick the ApiOutbound channel, then Apply.
|
||||||
await page.Locator("[data-test='chip-channel-ApiOutbound']").ClickAsync();
|
await page.Locator("[data-test='filter-channel-select']").SelectOptionAsync("ApiOutbound");
|
||||||
await page.Locator("[data-test='filter-apply']").ClickAsync();
|
await page.Locator("[data-test='filter-apply']").ClickAsync();
|
||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
// The seeded ApiOutbound row is visible; the DbOutbound row is not
|
// The seeded ApiOutbound row is visible; the DbOutbound row is not
|
||||||
// (it was filtered out by the channel chip).
|
// (it was filtered out by the channel filter).
|
||||||
var apiRow = page.Locator($"[data-test='grid-row-{apiEventId}']");
|
var apiRow = page.Locator($"[data-test='grid-row-{apiEventId}']");
|
||||||
var dbRow = page.Locator($"[data-test='grid-row-{dbEventId}']");
|
var dbRow = page.Locator($"[data-test='grid-row-{dbEventId}']");
|
||||||
await Assertions.Expect(apiRow).ToBeVisibleAsync();
|
await Assertions.Expect(apiRow).ToBeVisibleAsync();
|
||||||
|
|||||||
@@ -13,13 +13,18 @@ namespace ScadaLink.CentralUI.Tests.Components.Audit;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// bUnit tests for <see cref="AuditFilterBar"/> (#23 M7-T2 / Bundle B).
|
/// bUnit tests for <see cref="AuditFilterBar"/> (#23 M7-T2 / Bundle B).
|
||||||
///
|
///
|
||||||
/// The bar carries the 10 spec filter elements plus the Errors-only toggle. Tests
|
/// The bar carries the 10 spec filter elements plus the Errors-only toggle.
|
||||||
/// pin: (1) the full filter set renders; (2) Apply raises <c>OnFilterChanged</c>
|
/// Channel is a single-select <c><select data-test="filter-channel-select"></c>;
|
||||||
/// with collapsed values; (3) the Channel→Kind narrowing map drives Kind chip
|
/// Kind / Status / Site are
|
||||||
/// visibility; (4) the Errors-only toggle ORs <c>Failed</c> into Status when
|
/// <see cref="ScadaLink.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
|
||||||
/// Status is otherwise empty; (5) the "Last hour" preset populates
|
/// controls whose options are checkboxes tagged
|
||||||
/// <c>FromUtc</c> to roughly an hour before "now" — proves the time-window
|
/// <c>data-test="filter-<dim>-ms-opt-<value>"</c>. Tests pin:
|
||||||
/// collapse without freezing the clock.
|
/// (1) the full filter set renders; (2) Apply raises <c>OnFilterChanged</c> with
|
||||||
|
/// the selected values; (3) the Channel→Kind narrowing map drives Kind option
|
||||||
|
/// visibility; (4) the Errors-only toggle ORs the error statuses into Status when
|
||||||
|
/// Status is otherwise empty; (5) the "Last hour" preset populates <c>FromUtc</c>
|
||||||
|
/// to roughly an hour before "now" — proves the time-window collapse without
|
||||||
|
/// freezing the clock.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuditFilterBarTests : BunitContext
|
public class AuditFilterBarTests : BunitContext
|
||||||
{
|
{
|
||||||
@@ -71,8 +76,8 @@ public class AuditFilterBarTests : BunitContext
|
|||||||
var cut = Render<AuditFilterBar>(p => p
|
var cut = Render<AuditFilterBar>(p => p
|
||||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
|
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
|
||||||
|
|
||||||
// Drive UI: toggle a Channel chip, type in the Target search box, click Apply.
|
// Drive UI: pick a Channel, type in the Target search box, click Apply.
|
||||||
cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click();
|
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
|
||||||
cut.Find("[data-test=\"filter-target\"] input").Change("Plant-A-OPC");
|
cut.Find("[data-test=\"filter-target\"] input").Change("Plant-A-OPC");
|
||||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||||
|
|
||||||
@@ -82,23 +87,25 @@ public class AuditFilterBarTests : BunitContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Apply_WithMultipleChannelChips_PassesAllSelectedChannels()
|
public void ChangingChannel_ReplacesTheSelection_SingleSelect()
|
||||||
{
|
{
|
||||||
// Task 9: ToFilter no longer collapses the chip multi-select — every
|
// Channel is single-select: picking a second channel replaces the first
|
||||||
// selected channel chip reaches the filter's Channels list.
|
// rather than adding to it (the page filters one channel at a time).
|
||||||
AuditLogQueryFilter? captured = null;
|
AuditLogQueryFilter? captured = null;
|
||||||
var cut = Render<AuditFilterBar>(p => p
|
var cut = Render<AuditFilterBar>(p => p
|
||||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
|
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
|
||||||
|
|
||||||
cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click();
|
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
|
||||||
cut.Find("[data-test=\"chip-channel-Notification\"]").Click();
|
cut.Find("[data-test=\"filter-channel-select\"]").Change("Notification");
|
||||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||||
|
|
||||||
Assert.NotNull(captured);
|
Assert.NotNull(captured);
|
||||||
Assert.NotNull(captured!.Channels);
|
Assert.Equal(new[] { AuditChannel.Notification }, captured!.Channels);
|
||||||
Assert.Equal(2, captured.Channels!.Count);
|
|
||||||
Assert.Contains(AuditChannel.ApiOutbound, captured.Channels);
|
// Selecting "All channels" clears the channel filter entirely.
|
||||||
Assert.Contains(AuditChannel.Notification, captured.Channels);
|
cut.Find("[data-test=\"filter-channel-select\"]").Change(string.Empty);
|
||||||
|
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||||
|
Assert.Null(captured!.Channels);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -106,23 +113,23 @@ public class AuditFilterBarTests : BunitContext
|
|||||||
{
|
{
|
||||||
var cut = Render<AuditFilterBar>();
|
var cut = Render<AuditFilterBar>();
|
||||||
|
|
||||||
// With no Channel selected, every kind chip is in the DOM.
|
// With no Channel selected, every kind option is in the DOM.
|
||||||
foreach (var kind in Enum.GetValues<AuditKind>())
|
foreach (var kind in Enum.GetValues<AuditKind>())
|
||||||
{
|
{
|
||||||
Assert.Contains($"data-test=\"chip-kind-{kind}\"", cut.Markup);
|
Assert.Contains($"data-test=\"filter-kind-ms-opt-{kind}\"", cut.Markup);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select only ApiOutbound; Kind chips outside the channel-kind map drop out.
|
// Select ApiOutbound; Kind options outside the channel-kind map drop out.
|
||||||
cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click();
|
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
|
||||||
|
|
||||||
var apiKinds = AuditQueryModel.KindsByChannel[AuditChannel.ApiOutbound];
|
var apiKinds = AuditQueryModel.KindsByChannel[AuditChannel.ApiOutbound];
|
||||||
foreach (var kind in apiKinds)
|
foreach (var kind in apiKinds)
|
||||||
{
|
{
|
||||||
Assert.Contains($"data-test=\"chip-kind-{kind}\"", cut.Markup);
|
Assert.Contains($"data-test=\"filter-kind-ms-opt-{kind}\"", cut.Markup);
|
||||||
}
|
}
|
||||||
// Sanity: an unrelated kind is gone.
|
// Sanity: an unrelated kind is gone.
|
||||||
Assert.DoesNotContain($"data-test=\"chip-kind-{AuditKind.NotifySend}\"", cut.Markup);
|
Assert.DoesNotContain($"data-test=\"filter-kind-ms-opt-{AuditKind.NotifySend}\"", cut.Markup);
|
||||||
Assert.DoesNotContain($"data-test=\"chip-kind-{AuditKind.InboundRequest}\"", cut.Markup);
|
Assert.DoesNotContain($"data-test=\"filter-kind-ms-opt-{AuditKind.InboundRequest}\"", cut.Markup);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -144,8 +151,8 @@ public class AuditFilterBarTests : BunitContext
|
|||||||
Assert.Contains(AuditStatus.Parked, captured.Statuses);
|
Assert.Contains(AuditStatus.Parked, captured.Statuses);
|
||||||
Assert.Contains(AuditStatus.Discarded, captured.Statuses);
|
Assert.Contains(AuditStatus.Discarded, captured.Statuses);
|
||||||
|
|
||||||
// Now pin an explicit Status chip — Errors-only must yield (chip wins).
|
// Now pin an explicit Status option — Errors-only must yield (explicit wins).
|
||||||
cut.Find("[data-test=\"chip-status-Delivered\"]").Click();
|
cut.Find("[data-test=\"filter-status-ms-opt-Delivered\"]").Change(true);
|
||||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||||
|
|
||||||
Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses);
|
Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses);
|
||||||
@@ -160,8 +167,8 @@ public class AuditFilterBarTests : BunitContext
|
|||||||
var cut = Render<AuditFilterBar>(p => p
|
var cut = Render<AuditFilterBar>(p => p
|
||||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
|
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
|
||||||
|
|
||||||
cut.Find("[data-test=\"chip-status-Delivered\"]").Click();
|
cut.Find("[data-test=\"filter-status-ms-opt-Delivered\"]").Change(true);
|
||||||
cut.Find("[data-test=\"chip-status-Failed\"]").Click();
|
cut.Find("[data-test=\"filter-status-ms-opt-Failed\"]").Change(true);
|
||||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||||
|
|
||||||
Assert.NotNull(captured);
|
Assert.NotNull(captured);
|
||||||
|
|||||||
@@ -94,6 +94,42 @@ public class ExternalSystemCachedCallEmissionTests
|
|||||||
Assert.Null(packet.Operational.TerminalAtUtc);
|
Assert.Null(packet.Operational.TerminalAtUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CachedCall_ImmediateCompletion_CapturesRequestArgs_AndResponseBody()
|
||||||
|
{
|
||||||
|
var client = new Mock<IExternalSystemClient>();
|
||||||
|
client
|
||||||
|
.Setup(c => c.CachedCallAsync(
|
||||||
|
"ERP", "GetOrder",
|
||||||
|
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
|
||||||
|
InstanceName,
|
||||||
|
It.IsAny<CancellationToken>(),
|
||||||
|
It.IsAny<TrackedOperationId?>()))
|
||||||
|
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
|
||||||
|
var forwarder = new CapturingForwarder();
|
||||||
|
|
||||||
|
var helper = CreateHelper(client.Object, forwarder);
|
||||||
|
var args = new Dictionary<string, object?> { ["orderId"] = 42 };
|
||||||
|
await helper.CachedCall("ERP", "GetOrder", args);
|
||||||
|
|
||||||
|
// Immediate completion (WasBuffered=false) emits Submit, Attempted, Resolve.
|
||||||
|
Assert.Equal(3, forwarder.Telemetry.Count);
|
||||||
|
var submit = forwarder.Telemetry.Single(t => t.Audit.Kind == AuditKind.CachedSubmit);
|
||||||
|
var attempted = forwarder.Telemetry.Single(t => t.Audit.Kind == AuditKind.ApiCallCached);
|
||||||
|
var resolve = forwarder.Telemetry.Single(t => t.Audit.Kind == AuditKind.CachedResolve);
|
||||||
|
|
||||||
|
// Every row carries the request args; the two post-call rows also carry
|
||||||
|
// the response body (Submit precedes the call, so it has no response).
|
||||||
|
Assert.Equal("{\"orderId\":42}", submit.Audit.RequestSummary);
|
||||||
|
Assert.Null(submit.Audit.ResponseSummary);
|
||||||
|
|
||||||
|
Assert.Equal("{\"orderId\":42}", attempted.Audit.RequestSummary);
|
||||||
|
Assert.Equal("{\"ok\":true}", attempted.Audit.ResponseSummary);
|
||||||
|
|
||||||
|
Assert.Equal("{\"orderId\":42}", resolve.Audit.RequestSummary);
|
||||||
|
Assert.Equal("{\"ok\":true}", resolve.Audit.ResponseSummary);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CachedCall_ReturnsTrackedOperationId()
|
public async Task CachedCall_ReturnsTrackedOperationId()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -81,6 +81,29 @@ public class ExternalSystemCallAuditEmissionTests
|
|||||||
Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind);
|
Assert.Equal(DateTimeKind.Utc, evt.OccurredAtUtc.Kind);
|
||||||
Assert.NotEqual(Guid.Empty, evt.EventId);
|
Assert.NotEqual(Guid.Empty, evt.EventId);
|
||||||
Assert.False(evt.PayloadTruncated);
|
Assert.False(evt.PayloadTruncated);
|
||||||
|
// No call arguments → null request summary; the response body is captured.
|
||||||
|
Assert.Null(evt.RequestSummary);
|
||||||
|
Assert.Equal("{}", evt.ResponseSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Call_CapturesRequestArgs_AndResponseBody_OnTheAuditRow()
|
||||||
|
{
|
||||||
|
var client = new Mock<IExternalSystemClient>();
|
||||||
|
client
|
||||||
|
.Setup(c => c.CallAsync("Weather", "GetCurrent", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new ExternalCallResult(true, "{\"tempC\":11.4}", null));
|
||||||
|
var writer = new CapturingAuditWriter();
|
||||||
|
|
||||||
|
var helper = CreateHelper(client.Object, writer);
|
||||||
|
var args = new Dictionary<string, object?> { ["city"] = "Dublin" };
|
||||||
|
await helper.Call("Weather", "GetCurrent", args);
|
||||||
|
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
// RequestSummary is the serialized argument dictionary; ResponseSummary
|
||||||
|
// is the verbatim response body. (Cap + redaction are the writer's job.)
|
||||||
|
Assert.Equal("{\"city\":\"Dublin\"}", evt.RequestSummary);
|
||||||
|
Assert.Equal("{\"tempC\":11.4}", evt.ResponseSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user