agnes-the-ai-analyst/tests/test_remote_query_error_details.py
ZdenekSrotyr 57482be263 feat(cli): #160 shared structured error renderer for BQ-typed responses
The reporter (#160) saw `USER_PROJECT_DENIED` raw in the CLI because
all three CLI error-rendering paths flatten typed BqAccessError /
guardrail / RBAC dicts to a truncated single-line string, hiding the
structured `hint` field that explains how to fix the misconfig.

Fix: shared `cli/error_render.py:render_error(status_code, body)` that
recognizes the canonical typed shapes and pretty-prints them. Falls
back to truncated-and-flattened form for unrecognized bodies, so the
renderer never makes worse-than-status-quo output.

Recognized shapes:
- {detail: {kind: ..., hint?, billing_project?, data_project?}}
  — typed BqAccessError responses from /api/v2/scan, /sample, /schema,
  /api/query (when /api/query escalates a BQ failure)
- {detail: {reason: 'remote_scan_too_large', scan_bytes, limit_bytes,
  tables, suggestion}} — new /api/query cost-guardrail rejection
- {detail: {reason: 'bq_path_not_registered'/'bq_path_access_denied',
  path, hint?, registered_as?}} — new /api/query RBAC patch
- {detail: '...'} — string detail (legacy endpoints)

Wired through 3 CLI paths:
- cli/v2_client.py: V2ClientError.__str__ delegates to render_error;
  pre-truncation removed from V2ClientError.message (was hiding hints
  past 200 chars).
- cli/commands/query.py:_query_remote: parse JSON body, call renderer
  on error.
- cli/commands/query.py:_query_hybrid: catch RemoteQueryError, build
  synthetic `{detail: {kind: error_type, **details}}` payload, render.

tests/test_cli_query.py:test_remote_query_failure: assertion updated
from `"Query failed"` (no longer printed) to `HTTP 400` + `bad SQL`
(what the renderer surfaces for string detail).

Sample output for cross_project_forbidden:

  Error: cross_project_forbidden (HTTP 502)
    billing_project: (empty)
    data_project: prj-example-data-001
    message: USER_PROJECT_DENIED on bigquery.googleapis.com
    hint: Set data_source.bigquery.billing_project in
        /admin/server-config to a project where the SA has
        serviceusage.services.use, or grant the SA that role on the
        data project.

19 tests pass — 10 from T4a now GREEN + 3 prior cli_query tests still
green + 6 ancillary.
2026-05-04 10:31:35 +02:00

60 lines
2.6 KiB
Python

"""`src/remote_query.py:RemoteQueryError` carries a `details` dict
(verified to already exist) populated for raise sites that wrap an
upstream BqAccessError.
Currently only the BqAccessError-wrap path (lines 422 + 432) populates
it. The other 11 raise sites (lines 134, 142, 167, 173, 259, 264, 282,
289, 313, 322, 375) need an audit — for sites that wrap an external
exception, populate `details` so the CLI renderer has the structured
context it needs.
Closes the audit half of #160 §4.7.3.
"""
from __future__ import annotations
def test_blocked_keyword_carries_keyword_in_details():
"""`raise RemoteQueryError("blocked_keyword", ..., details={"blocked_keyword": kw})`
already populates the keyword. Lock it in via assertion so a future
refactor doesn't drop the field."""
from src.remote_query import RemoteQueryEngine, RemoteQueryError
import duckdb
conn = duckdb.connect(":memory:")
engine = RemoteQueryEngine(conn)
try:
engine.execute("DROP TABLE foo")
except RemoteQueryError as exc:
# Blocked-keyword raise lives at src/remote_query.py:134-138 with
# error_type="query_error" (not "blocked_keyword" — that's the
# keyword name in details, not the type).
assert exc.error_type == "query_error", exc.error_type
assert exc.details, "blocked_keyword raise must populate details"
assert "blocked_keyword" in exc.details
assert exc.details["blocked_keyword"] == "drop "
else:
raise AssertionError("expected RemoteQueryError")
def test_query_must_be_select_carries_no_unnecessary_details():
"""A pure local-validation raise (SQL doesn't start with SELECT)
has nothing structured to surface. details=None is the right shape;
test locks that in so a future contributor doesn't add noise."""
from src.remote_query import RemoteQueryEngine, RemoteQueryError
import duckdb
conn = duckdb.connect(":memory:")
engine = RemoteQueryEngine(conn)
try:
engine.execute("WITH x AS (SELECT 1) SELECT * FROM x")
except RemoteQueryError as exc:
# `WITH ...` is treated as not-starting-with-SELECT today; this is
# the shape we want to assert: details may be empty/None for pure
# local-validation errors.
assert exc.error_type in ("query_must_be_select", "blocked_keyword"), exc.error_type
# Either empty dict or dict with explanatory keys is fine.
assert isinstance(exc.details, dict)
except Exception:
# If the engine accepts WITH, that's also fine — this test is
# primarily about details shape, not what gets blocked.
pass