agnes-the-ai-analyst/tests/test_issue_266_bq_edit_modal_destruction.py
ZdenekSrotyr 56db622e36
release: 0.53.1 — fix #266 BQ Edit modal destroying bucket/source_table (#269)
Three client-side fixes in admin_tables.html plus a regression test
file pinning the server-side PUT contract the new JS relies on.

Bug 1 — saveBqTabEdit (synced/custom) nulled bucket/source_table on
every save; the null was supposed to clear stale state on a true
remote→materialized mode flip but fired on every save, silently
wiping persisted bucket/source_table when admin edited only the
description on an already-materialized row. Now gated by
_editOriginalQueryMode !== 'materialized'.

Bug 2/3 — _buildBigQueryPayload (synced/whole) at register time did
not send bucket/source_table — only source_query — so whole-table
materialized rows persisted with bucket=NULL. Edit modal then loaded
empty Dataset/Table inputs over a SELECT * SQL. Register now sends
both fields; _openEditBqModal additionally parses source_query as
a fallback for rows that registered pre-0.53.1.

Closes #266.
2026-05-12 17:29:56 +02:00

217 lines
9.1 KiB
Python

"""Regression coverage for #266 — Edit modal on BQ materialized rows
silently nulled bucket / source_table or showed empty inputs.
Three bugs traced from the Save chain (admin_tables.html JS → PUT
/api/admin/registry/{id} → table_registry upsert):
1. `saveBqTabEdit` (synced/custom) sent `bucket: null, source_table:
null` on every save — not just on a true remote→materialized mode
flip. An admin editing only the description of an already-
materialized custom-SQL row wiped persisted bucket/source_table.
2. `_buildBigQueryPayload` (synced/whole) at register time DID NOT
send bucket/source_table — only source_query. So whole-table
materialized rows persisted with bucket=NULL from day one. The
Edit modal then read empty `table.bucket` and rendered empty
Dataset/Table inputs over the SELECT * SQL.
3. `_openEditBqModal` populated the Dataset/Table inputs from
`table.bucket || ''` only — for whole-table rows registered pre-#266
(bucket=NULL), the inputs stayed empty. Saving with the empty
inputs would synthesize a broken `SELECT * FROM bq."".""` SQL.
These tests pin the server-side contract that the client now relies
on: PUTs that OMIT bucket/source_table keys preserve existing values
(thanks to `exclude_unset=True` in `app/api/admin.update_table`).
The template-grep test pins the JS-side fixes themselves.
"""
from __future__ import annotations
def _auth(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
def test_put_omitting_bucket_preserves_existing_value_on_materialized_row(
seeded_app, bq_instance, stub_bq_extractor,
):
"""Bug 1 fix contract: when the new Edit-modal save path omits
bucket/source_table from the JSON body on a no-op-mode save, the
server preserves the existing values.
Pre-#266 the JS sent `bucket: null, source_table: null` on every
save in the synced/custom branch — this test pins that an OMITTED
key on a custom-SQL materialized row preserves bucket. Same
invariant the v26 PUT-preservation tests pin for primary_key /
sync_strategy, but specific to bucket/source_table on a
materialized row (which the older tests didn't cover)."""
client = seeded_app["client"]
headers = _auth(seeded_app["admin_token"])
# Register a custom-SQL materialized BQ row that ALSO has bucket+
# source_table set. Both can coexist — bucket is documentary, the
# SQL is the source of truth. Pre-#266 a curl PUT could set this
# state; post-#266 the whole-table register flow also produces it.
custom_sql = (
"SELECT order_id, customer_id, total_usd "
"FROM `myproj.finance.orders` "
"WHERE event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)"
)
resp = client.post(
"/api/admin/register-table",
json={
"name": "preserve_bucket_test",
"source_type": "bigquery",
"query_mode": "materialized",
"bucket": "finance",
"source_table": "orders",
"source_query": custom_sql,
},
headers=headers,
)
assert resp.status_code in (200, 201, 202), resp.text
# PUT a description-only change — body OMITS bucket/source_table,
# mirroring the post-#266 JS payload-builder behavior on a no-op
# mode save.
resp = client.put(
"/api/admin/registry/preserve_bucket_test",
json={"description": "Customer orders, last 30 days"},
headers=headers,
)
assert resp.status_code == 200, resp.text
reg = client.get("/api/admin/registry", headers=headers).json()
row = next(t for t in reg["tables"] if t["id"] == "preserve_bucket_test")
assert row["bucket"] == "finance"
assert row["source_table"] == "orders"
assert row["description"] == "Customer orders, last 30 days"
assert row["source_query"] == custom_sql
assert row["query_mode"] == "materialized"
def test_put_explicit_null_clears_bucket_on_mode_flip(
seeded_app, bq_instance, stub_bq_extractor,
):
"""Bug 1 fix contract: a TRUE mode-flip save (remote → materialized
custom) still wants to clear stale bucket/source_table from the
old mode. The JS sends explicit null in that case; the server
persists NULL. This pins the existing exclude_unset semantics
that distinguish 'omitted' (preserve) from 'explicit null' (clear)
— see admin.py:2636-2654 inline comment."""
client = seeded_app["client"]
headers = _auth(seeded_app["admin_token"])
# Register remote BQ row with bucket+source_table.
resp = client.post(
"/api/admin/register-table",
json={
"name": "flip_remote_to_custom",
"source_type": "bigquery",
"bucket": "finance",
"source_table": "orders",
},
headers=headers,
)
assert resp.status_code in (200, 201, 202), resp.text
# Mode flip: remote → materialized custom-SQL. JS sends explicit
# null in this branch (its `_editOriginalQueryMode !== 'materialized'`
# condition fires).
new_sql = "SELECT 1 AS placeholder"
resp = client.put(
"/api/admin/registry/flip_remote_to_custom",
json={
"query_mode": "materialized",
"source_query": new_sql,
"bucket": None,
"source_table": None,
},
headers=headers,
)
assert resp.status_code == 200, resp.text
reg = client.get("/api/admin/registry", headers=headers).json()
row = next(t for t in reg["tables"] if t["id"] == "flip_remote_to_custom")
assert row["query_mode"] == "materialized"
assert row["bucket"] is None
assert row["source_table"] is None
assert row["source_query"] == new_sql
def test_register_whole_table_materialized_persists_bucket(
seeded_app, bq_instance, stub_bq_extractor,
):
"""Bug 2/3 fix contract: post-#266 the JS whole-table register
branch sends bucket+source_table alongside source_query so a
subsequent Edit modal can pre-fill those inputs from the
persisted values (instead of leaving them empty / re-parsing
SQL).
The server already accepts this shape — `_validate_bigquery_register_payload`
treats bucket/source_table as optional when source_query is
provided. This test pins that the persisted row carries both."""
client = seeded_app["client"]
headers = _auth(seeded_app["admin_token"])
# Mirror the post-#266 JS payload for synced/whole register.
resp = client.post(
"/api/admin/register-table",
json={
"name": "whole_table_with_bucket",
"source_type": "bigquery",
"query_mode": "materialized",
"bucket": "analytics",
"source_table": "page_views",
"source_query": 'SELECT * FROM bq."analytics"."page_views"',
},
headers=headers,
)
assert resp.status_code in (200, 201, 202), resp.text
reg = client.get("/api/admin/registry", headers=headers).json()
row = next(t for t in reg["tables"] if t["id"] == "whole_table_with_bucket")
assert row["bucket"] == "analytics"
assert row["source_table"] == "page_views"
assert row["query_mode"] == "materialized"
# source_query persisted verbatim (DuckDB three-part-alias form;
# the materialize wrapper translates this to BQ-native at exec).
assert 'bq."analytics"."page_views"' in row["source_query"]
def test_admin_tables_template_has_266_fixes():
"""Pin the three JS-side fixes in the rendered template. These
are tested by string presence rather than headless browser since
Agnes has no JS test harness. A future maintainer who reverts
one of them will trip an obvious failure here."""
from pathlib import Path
tpl = Path(__file__).parent.parent / "app" / "web" / "templates" / "admin_tables.html"
text = tpl.read_text(encoding="utf-8")
# Bug 1: saveBqTabEdit synced/custom branch must guard the null
# writes with a mode-flip check, not null unconditionally.
assert "_editOriginalQueryMode !== 'materialized'" in text, (
"Bug 1 regression: saveBqTabEdit nulls bucket/source_table "
"unconditionally — must guard on a real mode flip."
)
# The unconditional null pattern from pre-#266 must be GONE in
# the custom branch. We grep for the surrounding comment-tail.
assert "payload.bucket = null;\n payload.source_table = null;\n } else" not in text, (
"Bug 1 regression: the unconditional `payload.bucket = null` "
"block is back in the synced/custom branch."
)
# Bug 2/3: _buildBigQueryPayload synced/whole branch must include
# bucket+source_table in the JSON.
assert "bucket: dataset," in text, (
"Bug 2/3 regression: _buildBigQueryPayload whole-table branch "
"must send bucket alongside source_query so Edit can pre-fill."
)
# _openEditBqModal must parse dataset+source_table out of the
# source_query when bucket is empty (back-compat for rows
# registered pre-#266 with bucket=NULL).
assert "if (!preDataset && !preSourceTable && isAutoSelectStar)" in text, (
"Bug 2 regression: _openEditBqModal must fall back to parsing "
"source_query for pre-#266 whole-table rows with bucket=NULL."
)