feat(cli): compact table projection for audit-config query (register row 57)

The command passed no `tableProjector`, so `--format table` handed the renderer
an `AuditLogQueryResult` OBJECT. That takes the generic Property/Value branch,
which printed the entire `entries` array as raw JSON inside a single cell. JSON
output was and remains correct, and is the primary format for this command.

Adds `AuditConfigTableProjection`, mirroring `TemplateTableProjection`:
string->string over the raw body, applied only on the table path, returning the
input verbatim when it is not JSON or not a recognised shape so the renderer's
own fallbacks still apply. It unwraps the envelope to a row array — id,
timestamp, user, action, entityType, entityName, entityId — and drops
`afterStateJson`, the full post-change entity snapshot, which is the audit
analogue of template's fat child arrays: one per row fills a terminal.
`bundleImportId` is dropped as mostly-null noise.

`totalCount` would be lost by a plain unwrap, so a truncated page gains a single
trailing row with a `note` cell ("showing 50 of 137 — use --page / --page-size
for the rest"). When the page IS the whole result set the column is not emitted
at all, so a small query's table stays exactly as narrow as its data, and the
note row carries no `id` so it cannot be mistaken for an entry.

A `--detail` flag skips the projection and renders the raw payload, matching
`template list` / `template get` — that plus `--format json` is how to reach
`afterStateJson`. A bare array root is also accepted, so the projection does not
regress if the payload is ever unwrapped upstream or the deprecated `audit-log`
alias meets an older server.

Tests build their fixtures by serialising the REAL AuditLogQueryResult /
AuditLogEntry types, so renaming a server property fails the test instead of
silently blanking a column. CLI README + Component-CLI.md document the table
shape, the omitted fields, and --detail.
This commit is contained in:
Joseph Doherty
2026-08-15 13:22:41 -04:00
parent be29bf471e
commit b2cab59cb9
5 changed files with 377 additions and 3 deletions
@@ -0,0 +1,182 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Tests for the compact <c>audit-config query</c> table projection (deferred-work
/// register row 57). The command passed no <c>tableProjector</c>, so the generic
/// renderer saw an <see cref="AuditLogQueryResult"/> OBJECT, took its Property/Value
/// branch, and printed the entire <c>entries</c> array as raw JSON inside one cell.
/// The projection unwraps the envelope to a row array, drops the per-entry
/// <c>afterStateJson</c> snapshot, and keeps <c>totalCount</c> visible as a trailing note.
/// </summary>
public class AuditConfigTableProjectionTests
{
/// <summary>
/// The real server shape, serialised from the REAL <see cref="AuditLogQueryResult"/>
/// / <see cref="AuditLogEntry"/> types rather than hand-written JSON, so renaming one
/// of their properties fails this test instead of silently blanking a column.
/// </summary>
private static string RealPayload(int totalCount) =>
JsonSerializer.Serialize(
new AuditLogQueryResult(
new[]
{
new AuditLogEntry("multi-role", "Created", "Template", "7", "PumpTemplate")
{
Id = 412,
AfterStateJson = "{\"id\":7,\"name\":\"PumpTemplate\",\"attributes\":[]}",
Timestamp = new DateTimeOffset(2026, 8, 15, 12, 0, 0, TimeSpan.Zero),
},
new AuditLogEntry("designer", "Deleted", "ExternalSystem", "3", "MES")
{
Id = 413,
AfterStateJson = null,
Timestamp = new DateTimeOffset(2026, 8, 15, 12, 5, 0, TimeSpan.Zero),
},
},
totalCount),
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
[Fact]
public void ProjectEntries_UnwrapsTheEnvelopeIntoRows()
{
var compact = AuditConfigTableProjection.ProjectEntries(RealPayload(totalCount: 2));
using var doc = JsonDocument.Parse(compact);
var root = doc.RootElement;
// An ARRAY is the whole point — the object envelope is what made the renderer
// print one Property/Value row with the entries blob in a single cell.
Assert.Equal(JsonValueKind.Array, root.ValueKind);
Assert.Equal(2, root.GetArrayLength());
var first = root[0];
Assert.Equal(412, first.GetProperty("id").GetInt32());
Assert.Equal("multi-role", first.GetProperty("user").GetString());
Assert.Equal("Created", first.GetProperty("action").GetString());
Assert.Equal("Template", first.GetProperty("entityType").GetString());
Assert.Equal("PumpTemplate", first.GetProperty("entityName").GetString());
Assert.Equal("7", first.GetProperty("entityId").GetString());
Assert.Contains("2026-08-15", first.GetProperty("timestamp").GetString());
var second = root[1];
Assert.Equal("Deleted", second.GetProperty("action").GetString());
Assert.Equal("MES", second.GetProperty("entityName").GetString());
}
[Fact]
public void ProjectEntries_DropsAfterStateJson()
{
var compact = AuditConfigTableProjection.ProjectEntries(RealPayload(totalCount: 2));
using var doc = JsonDocument.Parse(compact);
// The fat field must NOT survive — one entity snapshot per row fills the terminal,
// which is the audit analogue of template's child arrays.
Assert.False(doc.RootElement[0].TryGetProperty("afterStateJson", out _));
Assert.DoesNotContain("afterStateJson", compact, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ProjectEntries_TruncatedPage_AppendsTotalCountNote()
{
var compact = AuditConfigTableProjection.ProjectEntries(RealPayload(totalCount: 137));
using var doc = JsonDocument.Parse(compact);
var root = doc.RootElement;
// Two entries plus the note row.
Assert.Equal(3, root.GetArrayLength());
var note = root[2].GetProperty("note").GetString();
Assert.Contains("2 of 137", note);
// The note must not masquerade as an entry.
Assert.False(root[2].TryGetProperty("id", out _));
}
[Fact]
public void ProjectEntries_WholeResultSet_EmitsNoNoteColumn()
{
// When the page IS the result set the count adds nothing the operator cannot
// already see, so the column must not appear and widen every small query.
var compact = AuditConfigTableProjection.ProjectEntries(RealPayload(totalCount: 2));
using var doc = JsonDocument.Parse(compact);
Assert.Equal(2, doc.RootElement.GetArrayLength());
Assert.DoesNotContain("note", compact, StringComparison.Ordinal);
}
[Fact]
public void ProjectEntries_EmptyResult_StaysAnEmptyArray()
{
var json = JsonSerializer.Serialize(
new AuditLogQueryResult(Array.Empty<AuditLogEntry>(), 0),
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
var compact = AuditConfigTableProjection.ProjectEntries(json);
using var doc = JsonDocument.Parse(compact);
// The renderer prints "(no results)" for an empty array; an empty OBJECT would
// have printed an empty Property/Value table instead.
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
Assert.Equal(0, doc.RootElement.GetArrayLength());
}
[Fact]
public void ProjectEntries_BareArray_IsStillProjected()
{
// Tolerated so the projection does not regress if the payload is ever unwrapped
// upstream, or when the deprecated `audit-log` alias hits an older server.
const string bare = """
[ { "id": 1, "user": "u", "action": "Updated", "entityType": "Site",
"entityName": "SiteA", "entityId": "2", "timestamp": "2026-08-15T12:00:00+00:00",
"afterStateJson": "{\"big\":true}" } ]
""";
var compact = AuditConfigTableProjection.ProjectEntries(bare);
using var doc = JsonDocument.Parse(compact);
Assert.Equal(1, doc.RootElement.GetArrayLength());
Assert.Equal("SiteA", doc.RootElement[0].GetProperty("entityName").GetString());
Assert.False(doc.RootElement[0].TryGetProperty("afterStateJson", out _));
}
[Fact]
public void ProjectEntries_UnrecognisedObject_ReturnedVerbatim()
{
// e.g. an error envelope — leave it to the renderer's own fallbacks rather than
// projecting it into an empty table that hides the message.
const string other = """{ "error": "boom" }""";
Assert.Equal(other, AuditConfigTableProjection.ProjectEntries(other));
}
[Fact]
public void ProjectEntries_NonJson_ReturnedVerbatim()
{
const string notJson = "<html>proxy error</html>";
Assert.Equal(notJson, AuditConfigTableProjection.ProjectEntries(notJson));
}
[Fact]
public void ProjectEntries_IsSubstantiallySmallerThanTheRawPayload()
{
// The reported symptom was the whole entries array in one cell. A page of rows
// carrying real afterStateJson snapshots must collapse hard.
var entries = Enumerable.Range(0, 50).Select(i =>
new AuditLogEntry("designer", "Updated", "Template", i.ToString(), $"Template{i}")
{
Id = i,
AfterStateJson = new string('x', 2000),
Timestamp = DateTimeOffset.UnixEpoch.AddMinutes(i),
}).ToArray();
var json = JsonSerializer.Serialize(new AuditLogQueryResult(entries, entries.Length),
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
var compact = AuditConfigTableProjection.ProjectEntries(json);
Assert.True(compact.Length * 4 < json.Length,
$"Expected compact ({compact.Length}) to be far smaller than raw ({json.Length}).");
}
}