feat(audit): ScadaBridge C1 — AuditDetails codec (deterministic) + AuditOutcome projection + canonical field builders + ZB.MOM.WW.Audit ref (Task 2.5)

Additive foundation only — no existing type/interface/emitter changed.
Commons now references ZB.MOM.WW.Audit 0.1.0 (Gitea feed, central PM pin).
Adds four pure new types in Commons/Types/Audit/:
  AuditDetails (sealed record, 17 domain fields, declaration-order = JSON key order)
  AuditDetailsCodec (static; single cached JsonSerializerOptions: camelCase, no-indent,
    WhenWritingNull, UnsafeRelaxedJsonEscaping — byte-deterministic across calls)
  AuditOutcomeProjector (static; InboundAuthFailure→Denied first, then Delivered→Success,
    Failed/Parked/Discarded→Failure, all others→Success)
  AuditFieldBuilders (static; BuildAction="{channel}.{kind}", BuildCategory=channel.ToString())
56 new tests in Commons.Tests/Types/Audit/ covering codec round-trip, byte-determinism
(hand-pinned expected JSON string), null/empty sentinel, full projection table,
InboundAuthFailure-Denied precedence, and Action/Category builders. All pass.
This commit is contained in:
Joseph Doherty
2026-06-02 10:42:51 -04:00
parent 4118452e72
commit 3d77dc003c
8 changed files with 566 additions and 0 deletions
@@ -0,0 +1,70 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
/// <summary>
/// ScadaBridge domain fields that travel inside <c>ZB.MOM.WW.Audit.AuditEvent.DetailsJson</c>.
/// All optional. <c>Channel</c>, <c>Kind</c>, and <c>Status</c> carry the string
/// representations of the corresponding ScadaBridge enums for stable JSON serialization.
/// <c>ForwardState</c> is deliberately absent — it remains a site-sidecar concern.
/// Serialized and deserialized by <see cref="AuditDetailsCodec"/>.
/// Property declaration order is load-bearing: <see cref="AuditDetailsCodec"/> relies on it for
/// byte-deterministic output (System.Text.Json respects declared order).
/// </summary>
/// <remarks>
/// C1 of the ScadaBridge audit re-architecture (Task 2.5). Pure types only.
/// </remarks>
public sealed record AuditDetails
{
/// <summary>Top-level audit channel (<c>AuditChannel.ToString()</c>).</summary>
public string? Channel { get; init; }
/// <summary>Specific event kind (<c>AuditKind.ToString()</c>).</summary>
public string? Kind { get; init; }
/// <summary>Lifecycle status (<c>AuditStatus.ToString()</c>).</summary>
public string? Status { get; init; }
/// <summary>Execution / operation identifier for the audited call.</summary>
public Guid? ExecutionId { get; init; }
/// <summary>Parent execution identifier for nested / cached operations.</summary>
public Guid? ParentExecutionId { get; init; }
/// <summary>Site identifier where the event originated.</summary>
public string? SourceSiteId { get; init; }
/// <summary>Instance (Galaxy instance / target resource) identifier.</summary>
public string? SourceInstanceId { get; init; }
/// <summary>Script name or endpoint that initiated the audited action.</summary>
public string? SourceScript { get; init; }
/// <summary>HTTP status code returned by the outbound or inbound call.</summary>
public int? HttpStatus { get; init; }
/// <summary>Elapsed duration of the audited action in milliseconds.</summary>
public int? DurationMs { get; init; }
/// <summary>Short error message when the action failed.</summary>
public string? ErrorMessage { get; init; }
/// <summary>Full error detail / stack excerpt when the action failed.</summary>
public string? ErrorDetail { get; init; }
/// <summary>Condensed request representation (URL, method, or payload summary).</summary>
public string? RequestSummary { get; init; }
/// <summary>Condensed response representation (status + body summary).</summary>
public string? ResponseSummary { get; init; }
/// <summary>
/// <c>true</c> if any string field in this record was truncated before serialization.
/// Included in JSON even when <c>false</c> (non-nullable bool, never null-omitted).
/// </summary>
public bool PayloadTruncated { get; init; }
/// <summary>Freeform extension JSON for fields not covered above.</summary>
public string? Extra { get; init; }
/// <summary>UTC instant when the central AuditLog store ingested this event.</summary>
public DateTimeOffset? IngestedAtUtc { get; init; }
}
@@ -0,0 +1,72 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
/// <summary>
/// Serializes and deserializes <see cref="AuditDetails"/> to/from the JSON string that
/// occupies <c>ZB.MOM.WW.Audit.AuditEvent.DetailsJson</c>.
/// </summary>
/// <remarks>
/// <para>
/// The options are DETERMINISTIC by design — this is a load-bearing contract:
/// canonical <c>AuditEvent</c> uses value-equality (record) and consumers dedup on it, so
/// two independent emitters must produce byte-identical <c>DetailsJson</c> for equal inputs.
/// The options that make this deterministic:
/// <list type="bullet">
/// <item><see cref="JsonNamingPolicy.CamelCase"/> — stable camelCase key names.</item>
/// <item><c>WriteIndented = false</c> — no whitespace variation.</item>
/// <item><see cref="JsonIgnoreCondition.WhenWritingNull"/> — null fields absent; non-nullable
/// <c>bool</c> fields (like <see cref="AuditDetails.PayloadTruncated"/>) always present.</item>
/// <item><see cref="JavaScriptEncoder.UnsafeRelaxedJsonEscaping"/> — angle brackets and other
/// extended characters (including <c>&lt;redacted&gt;</c> markers) are NOT percent-escaped,
/// preserving the exact byte value of any existing redaction strings.</item>
/// <item>Property declaration order on <see cref="AuditDetails"/> fixes key order —
/// System.Text.Json honours declared order, so serialization is stable across calls.</item>
/// </list>
/// </para>
/// <para>C1 of the ScadaBridge audit re-architecture (Task 2.5).</para>
/// </remarks>
public static class AuditDetailsCodec
{
/// <summary>
/// Single, cached, immutable options instance. Re-using one instance avoids repeated
/// reflection overhead and guarantees the same encoder is used across all serialization calls.
/// </summary>
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
/// <summary>
/// Serializes <paramref name="details"/> to a compact, deterministic JSON string
/// suitable for storage in <c>AuditEvent.DetailsJson</c>.
/// </summary>
public static string Serialize(AuditDetails details)
=> JsonSerializer.Serialize(details, Options);
/// <summary>
/// Deserializes a <see cref="AuditDetails"/> from <paramref name="json"/>.
/// Returns an empty (all-null) <see cref="AuditDetails"/> when <paramref name="json"/>
/// is <c>null</c>, empty, or whitespace — never throws.
/// </summary>
public static AuditDetails Deserialize(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return new AuditDetails();
try
{
return JsonSerializer.Deserialize<AuditDetails>(json, Options)
?? new AuditDetails();
}
catch (JsonException)
{
return new AuditDetails();
}
}
}
@@ -0,0 +1,33 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
/// <summary>
/// Builds the canonical <c>Action</c> and <c>Category</c> fields for
/// <c>ZB.MOM.WW.Audit.AuditEvent</c> from ScadaBridge's channel + kind enums.
/// </summary>
/// <remarks>
/// <para>
/// <list type="bullet">
/// <item><see cref="BuildAction"/> produces <c>"{channel}.{kind}"</c> — a stable dot-separated
/// identifier that maps directly onto the canonical <c>Action</c> field.</item>
/// <item><see cref="BuildCategory"/> produces <c>channel.ToString()</c> — a grouping token
/// that maps onto the canonical <c>Category</c> field.</item>
/// </list>
/// </para>
/// <para>C1 of the ScadaBridge audit re-architecture (Task 2.5).</para>
/// </remarks>
public static class AuditFieldBuilders
{
/// <summary>
/// Returns the canonical <c>Action</c> string: <c>"{channel}.{kind}"</c>.
/// </summary>
public static string BuildAction(AuditChannel channel, AuditKind kind)
=> $"{channel}.{kind}";
/// <summary>
/// Returns the canonical <c>Category</c> string: the channel name.
/// </summary>
public static string BuildCategory(AuditChannel channel)
=> channel.ToString();
}
@@ -0,0 +1,45 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
/// <summary>
/// Maps the ScadaBridge (<see cref="AuditStatus"/>, <see cref="AuditKind"/>) pair onto
/// the canonical <see cref="AuditOutcome"/> required by <c>ZB.MOM.WW.Audit.AuditEvent</c>.
/// </summary>
/// <remarks>
/// <para>Projection table:</para>
/// <list type="bullet">
/// <item><see cref="AuditKind.InboundAuthFailure"/> → <see cref="AuditOutcome.Denied"/>
/// (checked <b>first</b>, overrides any status).</item>
/// <item><see cref="AuditStatus.Delivered"/> → <see cref="AuditOutcome.Success"/>.</item>
/// <item><see cref="AuditStatus.Failed"/>, <see cref="AuditStatus.Parked"/>,
/// <see cref="AuditStatus.Discarded"/> → <see cref="AuditOutcome.Failure"/>.</item>
/// <item>All other statuses (<c>Submitted</c>, <c>Forwarded</c>, <c>Attempted</c>,
/// <c>Skipped</c>) → <see cref="AuditOutcome.Success"/>.</item>
/// </list>
/// <para>C1 of the ScadaBridge audit re-architecture (Task 2.5).</para>
/// </remarks>
public static class AuditOutcomeProjector
{
/// <summary>
/// Projects <paramref name="status"/> + <paramref name="kind"/> onto the canonical
/// <see cref="AuditOutcome"/>.
/// </summary>
public static AuditOutcome Project(AuditStatus status, AuditKind kind)
{
// Auth-failure kind takes absolute precedence — checked before any status rule.
if (kind == AuditKind.InboundAuthFailure)
return AuditOutcome.Denied;
return status switch
{
AuditStatus.Delivered => AuditOutcome.Success,
AuditStatus.Failed => AuditOutcome.Failure,
AuditStatus.Parked => AuditOutcome.Failure,
AuditStatus.Discarded => AuditOutcome.Failure,
// Submitted / Forwarded / Attempted / Skipped → in-progress or short-circuit → Success
_ => AuditOutcome.Success,
};
}
}
@@ -7,4 +7,8 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ZB.MOM.WW.Audit" />
</ItemGroup>
</Project>
@@ -0,0 +1,188 @@
using System.Text;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Audit;
/// <summary>
/// C1 codec tests: round-trip, byte-determinism, and null/empty sentinel.
/// Determinism is load-bearing — consumers dedup on canonical AuditEvent value-equality
/// which includes DetailsJson, so two emitters must produce identical bytes for equal inputs.
/// </summary>
public class AuditDetailsCodecTests
{
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
private static AuditDetails FullDetails() => new()
{
Channel = AuditChannel.ApiOutbound.ToString(),
Kind = AuditKind.ApiCall.ToString(),
Status = AuditStatus.Delivered.ToString(),
ExecutionId = new Guid("11111111-1111-1111-1111-111111111111"),
ParentExecutionId = new Guid("22222222-2222-2222-2222-222222222222"),
SourceSiteId = "site-a",
SourceInstanceId = "inst-001",
SourceScript = "MyScript",
HttpStatus = 200,
DurationMs = 42,
ErrorMessage = null,
ErrorDetail = null,
RequestSummary = "GET /api/data",
ResponseSummary = "{\"ok\":true}",
PayloadTruncated = false,
Extra = null,
IngestedAtUtc = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero),
};
// ---------------------------------------------------------------------------
// Round-trip: all non-null fields survive
// ---------------------------------------------------------------------------
[Fact]
public void RoundTrip_AllFields_Populated()
{
var original = FullDetails();
var json = AuditDetailsCodec.Serialize(original);
var restored = AuditDetailsCodec.Deserialize(json);
Assert.Equal(original.Channel, restored.Channel);
Assert.Equal(original.Kind, restored.Kind);
Assert.Equal(original.Status, restored.Status);
Assert.Equal(original.ExecutionId, restored.ExecutionId);
Assert.Equal(original.ParentExecutionId, restored.ParentExecutionId);
Assert.Equal(original.SourceSiteId, restored.SourceSiteId);
Assert.Equal(original.SourceInstanceId, restored.SourceInstanceId);
Assert.Equal(original.SourceScript, restored.SourceScript);
Assert.Equal(original.HttpStatus, restored.HttpStatus);
Assert.Equal(original.DurationMs, restored.DurationMs);
Assert.Equal(original.RequestSummary, restored.RequestSummary);
Assert.Equal(original.ResponseSummary, restored.ResponseSummary);
Assert.Equal(original.PayloadTruncated, restored.PayloadTruncated);
Assert.Equal(original.IngestedAtUtc, restored.IngestedAtUtc);
}
// ---------------------------------------------------------------------------
// Round-trip: all-null record
// ---------------------------------------------------------------------------
[Fact]
public void RoundTrip_AllNullOptionals_ReturnsEmptyDetails()
{
var empty = new AuditDetails();
var json = AuditDetailsCodec.Serialize(empty);
var restored = AuditDetailsCodec.Deserialize(json);
Assert.Null(restored.Channel);
Assert.Null(restored.Kind);
Assert.Null(restored.Status);
Assert.Null(restored.ExecutionId);
Assert.Null(restored.ParentExecutionId);
Assert.Null(restored.SourceSiteId);
Assert.Null(restored.SourceInstanceId);
Assert.Null(restored.SourceScript);
Assert.Null(restored.HttpStatus);
Assert.Null(restored.DurationMs);
Assert.Null(restored.ErrorMessage);
Assert.Null(restored.ErrorDetail);
Assert.Null(restored.RequestSummary);
Assert.Null(restored.ResponseSummary);
Assert.False(restored.PayloadTruncated);
Assert.Null(restored.Extra);
Assert.Null(restored.IngestedAtUtc);
}
// ---------------------------------------------------------------------------
// Null / empty JSON deserialize → empty AuditDetails (no throw)
// ---------------------------------------------------------------------------
[Fact]
public void Deserialize_Null_ReturnsEmpty()
{
var d = AuditDetailsCodec.Deserialize(null);
Assert.NotNull(d);
Assert.Null(d.Channel);
}
[Fact]
public void Deserialize_EmptyString_ReturnsEmpty()
{
var d = AuditDetailsCodec.Deserialize(string.Empty);
Assert.NotNull(d);
Assert.Null(d.Channel);
}
// ---------------------------------------------------------------------------
// Determinism: same input → byte-identical JSON across two calls
// ---------------------------------------------------------------------------
[Fact]
public void Serialize_SameInput_ProducesByteIdenticalOutput_AcrossCalls()
{
var details = FullDetails();
var json1 = AuditDetailsCodec.Serialize(details);
var json2 = AuditDetailsCodec.Serialize(details);
Assert.Equal(json1, json2);
Assert.Equal(
Encoding.UTF8.GetBytes(json1),
Encoding.UTF8.GetBytes(json2));
}
// ---------------------------------------------------------------------------
// Determinism: exact expected JSON string (camelCase key order = declaration order,
// null fields omitted, PayloadTruncated=false present because it is non-nullable bool)
// ---------------------------------------------------------------------------
[Fact]
public void Serialize_KnownInput_MatchesExpectedJson()
{
// Build a minimal deterministic fixture (only non-null fields we can pin exactly).
var details = new AuditDetails
{
Channel = "ApiOutbound",
Kind = "ApiCall",
Status = "Delivered",
ExecutionId = new Guid("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
SourceSiteId = "site-x",
HttpStatus = 200,
DurationMs = 10,
RequestSummary = "GET /ping",
ResponseSummary = "ok",
PayloadTruncated = false,
IngestedAtUtc = new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero),
};
// Expected: camelCase, no indent, nulls omitted, PayloadTruncated present (false is default for bool),
// key order follows property declaration order on AuditDetails.
// ingestedAtUtc serializes as ISO-8601 with offset.
const string expected =
"{\"channel\":\"ApiOutbound\"," +
"\"kind\":\"ApiCall\"," +
"\"status\":\"Delivered\"," +
"\"executionId\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\"," +
"\"sourceSiteId\":\"site-x\"," +
"\"httpStatus\":200," +
"\"durationMs\":10," +
"\"requestSummary\":\"GET /ping\"," +
"\"responseSummary\":\"ok\"," +
"\"payloadTruncated\":false," +
"\"ingestedAtUtc\":\"2026-01-02T03:04:05+00:00\"}";
var actual = AuditDetailsCodec.Serialize(details);
Assert.Equal(expected, actual);
}
// ---------------------------------------------------------------------------
// Redaction marker preserved verbatim (UnsafeRelaxedJsonEscaping must not mangle <>)
// ---------------------------------------------------------------------------
[Fact]
public void Serialize_RedactionMarker_IsPreservedVerbatim()
{
var details = new AuditDetails { RequestSummary = "<redacted>" };
var json = AuditDetailsCodec.Serialize(details);
Assert.Contains("\"<redacted>\"", json);
}
}
@@ -0,0 +1,44 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Audit;
/// <summary>
/// C1 canonical field builder tests: BuildAction and BuildCategory.
/// </summary>
public class AuditFieldBuildersTests
{
// ---------------------------------------------------------------------------
// BuildAction: "{channel}.{kind}"
// ---------------------------------------------------------------------------
[Theory]
[InlineData(AuditChannel.ApiOutbound, AuditKind.ApiCall, "ApiOutbound.ApiCall")]
[InlineData(AuditChannel.ApiOutbound, AuditKind.ApiCallCached, "ApiOutbound.ApiCallCached")]
[InlineData(AuditChannel.DbOutbound, AuditKind.DbWrite, "DbOutbound.DbWrite")]
[InlineData(AuditChannel.DbOutbound, AuditKind.DbWriteCached, "DbOutbound.DbWriteCached")]
[InlineData(AuditChannel.Notification, AuditKind.NotifySend, "Notification.NotifySend")]
[InlineData(AuditChannel.Notification, AuditKind.NotifyDeliver, "Notification.NotifyDeliver")]
[InlineData(AuditChannel.ApiInbound, AuditKind.InboundRequest, "ApiInbound.InboundRequest")]
[InlineData(AuditChannel.ApiInbound, AuditKind.InboundAuthFailure, "ApiInbound.InboundAuthFailure")]
[InlineData(AuditChannel.ApiOutbound, AuditKind.CachedSubmit, "ApiOutbound.CachedSubmit")]
[InlineData(AuditChannel.ApiOutbound, AuditKind.CachedResolve, "ApiOutbound.CachedResolve")]
public void BuildAction_ReturnsChannelDotKind(AuditChannel channel, AuditKind kind, string expected)
{
Assert.Equal(expected, AuditFieldBuilders.BuildAction(channel, kind));
}
// ---------------------------------------------------------------------------
// BuildCategory: channel.ToString()
// ---------------------------------------------------------------------------
[Theory]
[InlineData(AuditChannel.ApiOutbound, "ApiOutbound")]
[InlineData(AuditChannel.DbOutbound, "DbOutbound")]
[InlineData(AuditChannel.Notification, "Notification")]
[InlineData(AuditChannel.ApiInbound, "ApiInbound")]
public void BuildCategory_ReturnsChannelName(AuditChannel channel, string expected)
{
Assert.Equal(expected, AuditFieldBuilders.BuildCategory(channel));
}
}
@@ -0,0 +1,110 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Audit;
/// <summary>
/// C1 projection tests: the full AuditStatus × AuditKind → AuditOutcome table, plus
/// the InboundAuthFailure-Denied precedence that must be checked before any status rule.
/// </summary>
public class AuditOutcomeProjectorTests
{
// ---------------------------------------------------------------------------
// InboundAuthFailure → Denied, regardless of status (checked FIRST)
// ---------------------------------------------------------------------------
[Theory]
[InlineData(AuditStatus.Submitted)]
[InlineData(AuditStatus.Forwarded)]
[InlineData(AuditStatus.Attempted)]
[InlineData(AuditStatus.Delivered)]
[InlineData(AuditStatus.Failed)]
[InlineData(AuditStatus.Parked)]
[InlineData(AuditStatus.Discarded)]
[InlineData(AuditStatus.Skipped)]
public void InboundAuthFailure_AlwaysDenied_RegardlessOfStatus(AuditStatus status)
{
var outcome = AuditOutcomeProjector.Project(status, AuditKind.InboundAuthFailure);
Assert.Equal(AuditOutcome.Denied, outcome);
}
// ---------------------------------------------------------------------------
// Delivered → Success (for all non-auth-failure kinds)
// ---------------------------------------------------------------------------
[Theory]
[InlineData(AuditKind.ApiCall)]
[InlineData(AuditKind.ApiCallCached)]
[InlineData(AuditKind.DbWrite)]
[InlineData(AuditKind.DbWriteCached)]
[InlineData(AuditKind.NotifySend)]
[InlineData(AuditKind.NotifyDeliver)]
[InlineData(AuditKind.InboundRequest)]
[InlineData(AuditKind.CachedSubmit)]
[InlineData(AuditKind.CachedResolve)]
public void Delivered_Success_ForNonAuthFailureKinds(AuditKind kind)
{
var outcome = AuditOutcomeProjector.Project(AuditStatus.Delivered, kind);
Assert.Equal(AuditOutcome.Success, outcome);
}
// ---------------------------------------------------------------------------
// Failure statuses → Failure outcome
// ---------------------------------------------------------------------------
[Theory]
[InlineData(AuditStatus.Failed)]
[InlineData(AuditStatus.Parked)]
[InlineData(AuditStatus.Discarded)]
public void FailureStatuses_MapToFailureOutcome(AuditStatus status)
{
// Use a representative non-auth-failure kind
var outcome = AuditOutcomeProjector.Project(status, AuditKind.ApiCall);
Assert.Equal(AuditOutcome.Failure, outcome);
}
// InboundAuthFailure still wins over failure statuses
[Theory]
[InlineData(AuditStatus.Failed)]
[InlineData(AuditStatus.Parked)]
[InlineData(AuditStatus.Discarded)]
public void InboundAuthFailure_OverridesFailureStatuses(AuditStatus status)
{
var outcome = AuditOutcomeProjector.Project(status, AuditKind.InboundAuthFailure);
Assert.Equal(AuditOutcome.Denied, outcome);
}
// ---------------------------------------------------------------------------
// In-progress / short-circuit statuses → Success
// ---------------------------------------------------------------------------
[Theory]
[InlineData(AuditStatus.Submitted)]
[InlineData(AuditStatus.Forwarded)]
[InlineData(AuditStatus.Attempted)]
[InlineData(AuditStatus.Skipped)]
public void InProgressStatuses_MapToSuccess(AuditStatus status)
{
var outcome = AuditOutcomeProjector.Project(status, AuditKind.ApiCall);
Assert.Equal(AuditOutcome.Success, outcome);
}
// ---------------------------------------------------------------------------
// Full status × non-auth-failure kind cross-product (spot-check each status once more)
// ---------------------------------------------------------------------------
[Theory]
[InlineData(AuditStatus.Submitted, AuditKind.DbWrite, AuditOutcome.Success)]
[InlineData(AuditStatus.Forwarded, AuditKind.CachedSubmit, AuditOutcome.Success)]
[InlineData(AuditStatus.Attempted, AuditKind.NotifySend, AuditOutcome.Success)]
[InlineData(AuditStatus.Delivered, AuditKind.NotifyDeliver, AuditOutcome.Success)]
[InlineData(AuditStatus.Failed, AuditKind.DbWriteCached, AuditOutcome.Failure)]
[InlineData(AuditStatus.Parked, AuditKind.NotifySend, AuditOutcome.Failure)]
[InlineData(AuditStatus.Discarded, AuditKind.CachedResolve, AuditOutcome.Failure)]
[InlineData(AuditStatus.Skipped, AuditKind.InboundRequest, AuditOutcome.Success)]
public void ProjectionTable_MatchesSpec(AuditStatus status, AuditKind kind, AuditOutcome expected)
{
Assert.Equal(expected, AuditOutcomeProjector.Project(status, kind));
}
}