Skip to content

Commit 76f4f56

Browse files
committed
feat(test-runner): ResponseTempTable.DebugTable — permanent response debug mirror
Captured responses live in per-connection temp tables that vanish with the test's rollback and connection close — impossible to examine in a query editor afterwards, and re-issuing the request from an .http file cannot reproduce a response that depended on the test's uncommitted transactional fixtures. New setting ResponseTempTable.DebugTable (default null = off): when set (e.g. "_responses_debug"), every captured response is ALSO mirrored into a PERMANENT table with that name, written on a separate autocommit pooled connection — immune to rollbacks and connection closes, append-only so parallel test files cannot collide. The temp-table semantics (assertions, naming, transactions) are completely unchanged. ONE table covers the whole run: each HTTP block adds one row with captured_at, test_file, block (that block's response-table name — Name, a MultiNamePattern ordinal, or the # @response name), method, path, status, body, content_type, headers, is_success. Recreated at the start of every run, so it always holds the LAST run. Enabling it prints a loud warning (debugging aid — do not enable in CI); mirror inserts are best-effort (a failure logs a warning, never fails the test). In the fresh-test-database workflow combine with Keep, or teardown drops the database and mirror. Note: the naive alternative ("Permanent": true on the response table) was rejected — parallel tests would collide on one permanent name, and CREATE TABLE is transactional, so the rollback would erase the table exactly when it was needed. Config synced (appsettings.json, ConfigTemplate.cs, ConfigDefaults.cs, ConfigSchemaGenerator.cs). Full suite green (2394). Live-verified on example 19: all three block-naming schemes distinguishable in one query, including the response body of a rolled-back fixture; rerun leaves exactly the last run's rows. Changelog + docs updated.
1 parent ba18c74 commit 76f4f56

8 files changed

Lines changed: 121 additions & 2 deletions

File tree

NpgsqlRestClient/Builder.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3093,6 +3093,7 @@ public TestRunnerOptions BuildTestRunnerOptions()
30933093
{
30943094
opt.ResponseTempTable.Name = _config.GetConfigStr("Name", rt) ?? opt.ResponseTempTable.Name;
30953095
opt.ResponseTempTable.MultiNamePattern = _config.GetConfigStr("MultiNamePattern", rt) ?? opt.ResponseTempTable.MultiNamePattern;
3096+
opt.ResponseTempTable.DebugTable = _config.GetConfigStr("DebugTable", rt) ?? opt.ResponseTempTable.DebugTable;
30963097
var cols = rt.GetSection("Columns");
30973098
if (cols.Exists())
30983099
{

NpgsqlRestClient/ConfigDefaults.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,6 +1124,7 @@ private static JsonObject GetTestRunnerDefaults()
11241124
{
11251125
["Name"] = "_response",
11261126
["MultiNamePattern"] = "_response_{n}",
1127+
["DebugTable"] = null,
11271128
["Columns"] = new JsonObject
11281129
{
11291130
["Status"] = "status",

NpgsqlRestClient/ConfigSchemaGenerator.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ public static partial class ConfigSchemaGenerator
266266
["TestRunner:LoggerName"] = "SourceContext name for the runner's own log channel; set its level independently under Log:MinimalLevels (defaults to Information).\nDiscovery/parsing log at Debug, each query and HTTP invocation at Verbose, `raise notice` by severity. The console PASS/FAIL report is separate.",
267267
["TestRunner:ResponseTempTable:Name"] = "Temp table name used when a test file has exactly ONE HTTP block.\nEach HTTP block gets its own fresh temp table (created without IF NOT EXISTS, so a duplicate name fails the test); writes are pg_temp-qualified. Per-block override: `# @response <name>`.",
268268
["TestRunner:ResponseTempTable:MultiNamePattern"] = "Temp table name pattern used when a test file has 2+ HTTP blocks.\n{n} is the 1-based block ordinal, e.g. \"_response_{n}\" => _response_1, _response_2, …",
269+
["TestRunner:ResponseTempTable:DebugTable"] = "Debugging aid: when set (e.g. \"_responses_debug\"), every captured response is ALSO mirrored into a PERMANENT table with this name — written on a separate autocommit connection, so it survives the test's rollback and connection close and can be examined in a query editor after the run.\nRecreated at the start of every run (holds the LAST run). ONE table covers everything: each HTTP block adds one row, with the block column recording that block's response-table name (Name, MultiNamePattern ordinal, or the # @response name) alongside test_file, method, path, status, body, content_type, headers, is_success, captured_at. Temp-table semantics unchanged. Do not enable in CI. Null (default) = off.",
269270
["TestRunner:ResponseTempTable:Columns"] = "Maps each response component to a column name. A null or empty name omits that column.",
270271
["TestRunner:Steps"] = "Named, reusable steps (name → step; same shape as Setup/Teardown entries). Reference them by name in Setup/Teardown, or from an individual test file's leading header comments:\n-- @setup StepName [StepName ...] (runs before that file); -- @teardown StepName [StepName ...] (runs after that file, always, best-effort); -- @connection Name (runs that file on a named ConnectionStrings entry).\nAnnotations are repeatable; names may be whitespace- or comma-separated and run in the order written.\nTest files can also reuse SQL scripts in place with psql-style includes: `\\i file` (cwd-relative) or `\\ir file` (relative to the including file) — executed on the test's connection, inside its transaction.",
271272
["TestRunner:Setup"] = "Run-once setup, BEFORE endpoint discovery. Steps run in the EXACT order written.\nEach entry is a step NAME from \"Steps\", or an inline object: { \"Command\": \"...\", \"WorkingDirectory\": \"...\" } (OS shell); { \"Sql\": \"...\" } | { \"SqlFile\": \"...\" } which run on the test connection, or set \"ConnectionName\" to run on another ConnectionStrings entry (e.g. an admin connection that runs `create database`).",

NpgsqlRestClient/ConfigTemplate.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1526,6 +1526,18 @@ public static partial class ConfigSchemaGenerator
15261526
"ResponseTempTable": {
15271527
"Name": "_response",
15281528
"MultiNamePattern": "_response_{n}",
1529+
//
1530+
// Debugging aid: when set (e.g. "_responses_debug"), every captured response is ALSO mirrored into a
1531+
// PERMANENT table with this name — written on a separate autocommit connection, so it survives the
1532+
// test's rollback and the connection close and can be examined in a query editor after the run.
1533+
// Recreated at the start of every run (always holds the LAST run). ONE table covers everything: each
1534+
// HTTP block adds one row, and the "block" column records that block's response-table name (Name,
1535+
// MultiNamePattern ordinal, or the # @response name) alongside test_file, method, path, status, body,
1536+
// content_type, headers, is_success, captured_at. The temp-table semantics are unchanged. Do not
1537+
// enable in CI. In the fresh-test-database workflow combine with "Keep": true, or teardown drops the
1538+
// database (and the mirror with it).
1539+
//
1540+
"DebugTable": null,
15291541
"Columns": {
15301542
"Status": "status",
15311543
"Body": "body",

NpgsqlRestClient/Testing/TestRunner.cs

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ public sealed class TestRunner
4444
private readonly NpgsqlRestOptions _rest;
4545
private readonly TestRunnerOptions _opt;
4646
private readonly string _connString;
47+
// The debug mirror writes on its own POOLED connections (unlike test connections, which are
48+
// deliberately non-pooled) — autocommit, outside any test transaction.
49+
private readonly string _debugConnString;
50+
private bool _debugTableWarned;
4751
private readonly IReadOnlyDictionary<string, string> _named;
4852
private readonly ILogger? _log;
4953
private readonly bool _logNotices;
@@ -108,6 +112,7 @@ public TestRunner(NpgsqlRestOptions rest, TestRunnerOptions opt, string baseConn
108112
// Always non-pooled: a fresh physical session per test (no temp-table / GUC / prepared-statement carryover).
109113
// In test mode baseConnectionString is already the test connection (TestRunner.ConnectionName, when set).
110114
_connString = new NpgsqlConnectionStringBuilder(baseConnectionString) { Pooling = false }.ConnectionString;
115+
_debugConnString = baseConnectionString;
111116
// Named ConnectionStrings entries for Setup/Teardown steps that target another connection (e.g. an
112117
// "Admin" maintenance connection that runs create/drop database).
113118
_named = namedConnections ?? new Dictionary<string, string>();
@@ -239,6 +244,7 @@ private async Task<int> ExecuteDiscoveredFilesAsync(RoutineEndpoint[] endpoints,
239244
{
240245
_failFast = false; // reset per run (watch mode reuses the runner across iterations)
241246
if (only is null) _invoked.Clear(); // coverage counts per full run (partial watch reruns don't report)
247+
await EnsureDebugTableAsync(ct); // debug mirror holds the LAST run — recreated per run
242248

243249
var files = only ?? DiscoverFiles();
244250
if (only is null)
@@ -710,7 +716,7 @@ private async Task<FileResult> RunFileAsync(string file, IReadOnlyDictionary<str
710716
else if (step is HttpStep http)
711717
{
712718
httpOrdinal++;
713-
sr = await InvokeHttpStepAsync(conn, http, ResolveResponseTable(http, httpOrdinal, httpTotal), lookup, ct);
719+
sr = await InvokeHttpStepAsync(conn, http, ResolveResponseTable(http, httpOrdinal, httpTotal), lookup, contextName, ct);
714720
}
715721
else
716722
{
@@ -842,7 +848,7 @@ private async Task<StepResult> ExecuteSqlStepAsync(
842848
}
843849

844850
private async Task<StepResult> InvokeHttpStepAsync(
845-
NpgsqlConnection conn, HttpStep step, string responseTable, IReadOnlyDictionary<string, RoutineEndpoint> lookup, CancellationToken ct)
851+
NpgsqlConnection conn, HttpStep step, string responseTable, IReadOnlyDictionary<string, RoutineEndpoint> lookup, string testFile, CancellationToken ct)
846852
{
847853
var httpName = $"{step.Method} {step.Path}";
848854
var pathOnly = StripQuery(step.Path);
@@ -894,6 +900,10 @@ private async Task<StepResult> InvokeHttpStepAsync(
894900
step.Method, step.Path, response.StatusCode, responseTable);
895901

896902
await WriteResponseAsync(conn, responseTable, response, ct);
903+
if (string.IsNullOrWhiteSpace(_opt.ResponseTempTable.DebugTable) is false)
904+
{
905+
await MirrorResponseAsync(testFile, responseTable, step, response, ct);
906+
}
897907

898908
// An HTTP block is an act step: it captures the response into the temp table and produces no test
899909
// of its own — the assertions are the boolean SELECTs that follow it. It only surfaces as a result
@@ -942,6 +952,82 @@ private async Task WriteResponseAsync(NpgsqlConnection conn, string table, Routi
942952
await ins.ExecuteNonQueryAsync(ct);
943953
}
944954

955+
/// <summary>
956+
/// Debug mirror (ResponseTempTable.DebugTable): recreate the PERMANENT mirror table at the start of a
957+
/// run and print the do-not-use-in-CI warning once. Runs on its own autocommit connection, so mirrored
958+
/// rows survive test rollbacks and connection closes — that is the whole point.
959+
/// </summary>
960+
private async Task EnsureDebugTableAsync(CancellationToken ct)
961+
{
962+
var table = _opt.ResponseTempTable.DebugTable;
963+
if (string.IsNullOrWhiteSpace(table))
964+
{
965+
return;
966+
}
967+
if (!ValidateIdentifier(table))
968+
{
969+
throw new InvalidOperationException($"invalid ResponseTempTable.DebugTable name '{table}'");
970+
}
971+
if (_debugTableWarned is false)
972+
{
973+
_debugTableWarned = true;
974+
_out.Line($"WARNING: ResponseTempTable.DebugTable is enabled — every captured response is written to the PERMANENT table \"{table}\" (debugging aid; do not enable in CI)", ConsoleColor.Yellow);
975+
_log?.LogWarning("ResponseTempTable.DebugTable enabled: mirroring responses into permanent table {Table}", table);
976+
}
977+
await using var conn = new NpgsqlConnection(_debugConnString);
978+
await conn.OpenAsync(ct);
979+
await using var cmd = conn.CreateCommand();
980+
cmd.CommandText = $"""
981+
drop table if exists "{table}";
982+
create table "{table}" (
983+
captured_at timestamptz not null default now(),
984+
test_file text not null,
985+
block text not null,
986+
method text not null,
987+
path text not null,
988+
status int,
989+
body text,
990+
content_type text,
991+
headers jsonb,
992+
is_success boolean
993+
)
994+
""";
995+
// The debug connection is plain Npgsql (SQL rewriting enabled is irrelevant here — no parameters),
996+
// but the client disables rewriting globally, so run the two statements separately.
997+
foreach (var statement in cmd.CommandText.Split(';', StringSplitOptions.RemoveEmptyEntries))
998+
{
999+
await using var one = new NpgsqlCommand(statement, conn);
1000+
await one.ExecuteNonQueryAsync(ct);
1001+
}
1002+
}
1003+
1004+
/// <summary>Best-effort mirror insert — a failure warns but never fails the test.</summary>
1005+
private async Task MirrorResponseAsync(string testFile, string block, HttpStep step, RoutineInvokeResult response, CancellationToken ct)
1006+
{
1007+
try
1008+
{
1009+
await using var conn = new NpgsqlConnection(_debugConnString);
1010+
await conn.OpenAsync(ct);
1011+
await using var ins = new NpgsqlCommand(
1012+
$"insert into \"{_opt.ResponseTempTable.DebugTable}\" (test_file, block, method, path, status, body, content_type, headers, is_success) values ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9)",
1013+
conn);
1014+
ins.Parameters.AddWithValue(testFile);
1015+
ins.Parameters.AddWithValue(block);
1016+
ins.Parameters.AddWithValue(step.Method.ToString());
1017+
ins.Parameters.AddWithValue(step.Path);
1018+
ins.Parameters.AddWithValue(response.StatusCode);
1019+
ins.Parameters.AddWithValue((object?)response.Body ?? DBNull.Value);
1020+
ins.Parameters.AddWithValue((object?)response.ContentType ?? DBNull.Value);
1021+
ins.Parameters.AddWithValue((object?)response.Headers ?? DBNull.Value);
1022+
ins.Parameters.AddWithValue(response.IsSuccess);
1023+
await ins.ExecuteNonQueryAsync(ct);
1024+
}
1025+
catch (Exception ex)
1026+
{
1027+
_log?.LogWarning("debug table write failed for {File}: {Message}", testFile, ex.Message);
1028+
}
1029+
}
1030+
9451031
// The temp-table name for an HTTP block: explicit `# @response` wins; otherwise a file with a single
9461032
// HTTP block uses Name (`_response`), and a file with 2+ blocks uses MultiNamePattern with {n} = the
9471033
// 1-based block ordinal (`_response_1`, `_response_2`, …).

NpgsqlRestClient/Testing/TestRunnerOptions.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,19 @@ public class ResponseTempTableOptions
112112
/// <summary>Temp table name pattern when a file has 2+ HTTP blocks. <c>{n}</c> = the 1-based block ordinal.</summary>
113113
public string MultiNamePattern { get; set; } = "_response_{n}";
114114

115+
/// <summary>
116+
/// Debugging aid: when set (e.g. "_responses_debug"), every captured response is ALSO mirrored into a
117+
/// PERMANENT table with this name — written on a separate autocommit connection, so it survives the
118+
/// test's rollback and the connection close, and can be examined in a query editor after the run.
119+
/// Recreated at the start of every run (always holds the LAST run). ONE table covers all blocks and
120+
/// files: each HTTP block adds one row, with the block column recording that block's response-table
121+
/// name (<see cref="Name"/>, a <see cref="MultiNamePattern"/> ordinal, or the `# @response` name)
122+
/// alongside test_file/method/path and the response columns. The temp-table semantics are unchanged.
123+
/// Null (default) = off. Do not enable in CI. In the fresh-test-database workflow combine with Keep,
124+
/// or teardown drops the database (and the mirror with it).
125+
/// </summary>
126+
public string? DebugTable { get; set; } = null;
127+
115128
public ResponseColumnOptions Columns { get; set; } = new();
116129
}
117130

NpgsqlRestClient/appsettings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1517,6 +1517,8 @@
15171517
"ResponseTempTable": {
15181518
"Name": "_response",
15191519
"MultiNamePattern": "_response_{n}",
1520+
1521+
"DebugTable": null,
15201522
"Columns": {
15211523
"Status": "status",
15221524
"Body": "body",

changelog/v3.19.0.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ select (select body::jsonb ->> 'email' from _response) = 'grace@example.com',
123123
'email is normalized to lowercase';
124124
```
125125

126+
**Debugging captured responses** — temp tables vanish with the test's rollback and connection, so you cannot inspect them afterwards (and re-issuing the request from an `.http` file cannot reproduce a response that depended on the test's uncommitted fixtures). Set **`ResponseTempTable.DebugTable`** (e.g. `"_responses_debug"`; default `null` = off) and every captured response is **also mirrored into a permanent table** — written on a separate autocommit connection, immune to rollbacks, recreated at the start of every run so it always holds the last run. One table covers everything: each HTTP block adds one row, with `test_file`, `block` (that block's response-table name — `_response`, a `_response_{n}` ordinal, or the `# @response` name), `method`, `path`, `status`, `body`, `content_type`, `headers`, `is_success`, and `captured_at` — so after a run you can open a query editor and dig into any response with jsonb operators. The temp-table semantics are unchanged; enabling it prints a loud warning (debugging aid — do not enable in CI). In the fresh-test-database workflow combine it with `Keep: true`, or teardown drops the database and the mirror with it.
127+
126128
### Reusing scripts: `\i` and `\ir` includes
127129

128130
Shared SQL — fixture inserts, utility scripts — can be spliced into any test with psql's include syntax on its own line:
@@ -287,6 +289,7 @@ The runner logs through its own channel — **`NpgsqlRestTest`** (configurable v
287289
"ResponseTempTable": {
288290
"Name": "_response", // table name when a file has ONE HTTP block
289291
"MultiNamePattern": "_response_{n}",// name pattern for 2+ blocks; {n} = 1-based block position
292+
"DebugTable": null, // debugging aid: ALSO mirror every response into this PERMANENT table (survives rollback; last run; not for CI)
290293
"Columns": { // response → column mapping; null/empty omits the column
291294
"Status": "status", "Body": "body", "ContentType": "content_type",
292295
"Headers": "headers", "IsSuccess": "is_success"

0 commit comments

Comments
 (0)