feat(cli): table output formatter for audit events (#23 M8)

This commit is contained in:
Joseph Doherty
2026-05-20 22:00:57 -04:00
parent 4b3a692170
commit d40ee85e14
3 changed files with 217 additions and 7 deletions

View File

@@ -0,0 +1,117 @@
using System.Text.Json;
using ScadaLink.CLI.Commands;
namespace ScadaLink.CLI.Tests.Commands;
/// <summary>
/// Tests for the <c>table</c> output formatter of the <c>scadalink audit query</c>
/// subcommand (Audit Log #23 M8-T6): header rendering, long-field truncation, the
/// empty-result-set case, and null-actor handling.
/// </summary>
public class AuditTableFormatterTests
{
private static IReadOnlyList<JsonElement> Events(string json)
{
using var doc = JsonDocument.Parse(json);
return doc.RootElement.EnumerateArray()
.Select(e => e.Clone())
.ToList();
}
[Fact]
public void Table_RendersHeaderRow_WithExpectedColumns()
{
var formatter = new TableAuditFormatter();
var output = new StringWriter();
formatter.WritePage(Events("[]"), output);
var firstLine = output.ToString()
.Split('\n', StringSplitOptions.RemoveEmptyEntries)[0];
foreach (var col in new[]
{
"OccurredAtUtc", "Channel", "Kind", "Status",
"Target", "Actor", "DurationMs", "HttpStatus",
})
{
Assert.Contains(col, firstLine);
}
}
[Fact]
public void Table_TruncatesLongTarget_WithEllipsis()
{
var formatter = new TableAuditFormatter();
var output = new StringWriter();
var longTarget = new string('x', 200);
formatter.WritePage(
Events($"[{{\"occurredAtUtc\":\"2026-05-20T12:00:00Z\",\"channel\":\"OutboundApi\"," +
$"\"kind\":\"SyncCall\",\"status\":\"Delivered\",\"target\":\"{longTarget}\"," +
$"\"actor\":\"multi-role\"}}]"),
output);
var text = output.ToString();
Assert.Contains("…", text);
// The full untruncated target must not appear verbatim.
Assert.DoesNotContain(longTarget, text);
}
[Fact]
public void Table_EmptyResultSet_RendersHeaderOnly_OrNoRowsMessage()
{
var formatter = new TableAuditFormatter();
var output = new StringWriter();
formatter.WritePage(Events("[]"), output);
var lines = output.ToString()
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
// Header only — no data rows. (A header line is always emitted so the
// column shape is visible even with zero results.)
Assert.Single(lines);
Assert.Contains("OccurredAtUtc", lines[0]);
}
[Fact]
public void Table_NullActor_RendersBlank()
{
var formatter = new TableAuditFormatter();
var output = new StringWriter();
formatter.WritePage(
Events("[{\"occurredAtUtc\":\"2026-05-20T12:00:00Z\",\"channel\":\"InboundApi\"," +
"\"kind\":\"ApiCall\",\"status\":\"Delivered\",\"target\":\"key-1\"," +
"\"actor\":null}]"),
output);
var lines = output.ToString()
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(2, lines.Length);
// The data row must not contain the literal "null" for the actor column.
Assert.DoesNotContain("null", lines[1]);
Assert.Contains("InboundApi", lines[1]);
}
[Fact]
public void Table_HeaderEmittedOncePerPage_DataRowsAligned()
{
var formatter = new TableAuditFormatter();
var output = new StringWriter();
formatter.WritePage(
Events("[{\"occurredAtUtc\":\"2026-05-20T12:00:00Z\",\"channel\":\"OutboundApi\"," +
"\"kind\":\"SyncCall\",\"status\":\"Delivered\",\"target\":\"weather-api\"," +
"\"actor\":\"multi-role\",\"durationMs\":42,\"httpStatus\":200}," +
"{\"occurredAtUtc\":\"2026-05-20T12:01:00Z\",\"channel\":\"Notification\"," +
"\"kind\":\"Send\",\"status\":\"Failed\",\"target\":\"ops-list\"," +
"\"actor\":\"scheduler\",\"durationMs\":7}]"),
output);
var lines = output.ToString()
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(3, lines.Length);
Assert.Contains("weather-api", lines[1]);
Assert.Contains("ops-list", lines[2]);
}
}