Now that VIEW/MATERIALIZED_VIEW always wrap via bigquery_query() (the prior `legacy_wrap_views=True` branch behavior, made unconditional in the previous commit), the toggle has no semantic meaning and is removed across the codebase. Production code: - app/api/admin.py: drop the field from _OPTIONAL_FIELDS["data_source"] ["bigquery"]["fields"] and from _BQ_OPTIONAL_FIELD_DEFAULTS, plus the comment block above the defaults dict. - config/instance.yaml.example: drop the example snippet. - src/orchestrator.py: update the inner-objects skip-branch comment to reflect the new BQ behavior (the skip itself stays — keboola use_extension=False still inserts _meta rows without inner views). - app/web/templates/admin_tables.html: rewrite operator copy in the register and edit forms to reflect always-wrap. Tests: - tests/test_admin_server_config.py (TestServerConfigBigQueryFields): flip assertions from "field IS present" to "field NOT present" on legacy_wrap_views. Drop the test_post_persists_legacy_wrap_views test since the field no longer exists. - tests/test_admin_server_config_known_fields.py: same flip on the known-fields registry assertion. - tests/test_bigquery_extractor.py: drop the obsolete test_view_entity_does_not_create_master_view_by_default (asserted the bug we fixed) and test_legacy_wrap_views_toggle_restores_old_behavior (toggle no longer meaningful). Update remaining test docstrings. Operators with `legacy_wrap_views: true` set in their overlay get the new (equivalent) behavior automatically — the unrecognized key is silently ignored by the YAML loader. Operators with `false` get the issue-#160 fix as a behavior change, not a regression. Spec gate updated: production code grep gate grep -rn 'legacy_wrap_views' connectors app src config cli must return zero. tests/ excluded — historical "removed in #160" breadcrumbs and `assert "X" not in fields` regression guards retained as anti-regression signals.
342 lines
14 KiB
Python
342 lines
14 KiB
Python
"""Tests for the known-fields registry exposure in /admin/server-config.
|
|
|
|
The /admin/server-config UI used to render only fields that already existed
|
|
in instance.yaml — operators couldn't discover optional knobs like
|
|
``data_source.bigquery.billing_project`` without reading the docs or hitting
|
|
runtime errors. The known-fields registry lets the backend declare "these
|
|
fields are valid for this section even when YAML omits them" so the UI can
|
|
render them as dashed placeholders alongside the populated values.
|
|
|
|
This test file proves the wiring at three layers:
|
|
|
|
1. GET response carries `known_fields`
|
|
2. The HTML shell ships the CSS class + JS hook the renderer needs
|
|
3. Registry entries surface when the YAML doesn't list the field
|
|
"""
|
|
|
|
|
|
def _auth(token):
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
def test_get_server_config_returns_known_fields(seeded_app):
|
|
"""J2: GET response includes known_fields registry."""
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
r = c.get("/api/admin/server-config", headers=_auth(token))
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert "known_fields" in body
|
|
assert isinstance(body["known_fields"], dict)
|
|
# Smoke fixture: data_source.bigquery.billing_project must be in the registry.
|
|
bq = body["known_fields"].get("data_source", {}).get("bigquery", {})
|
|
fields = bq.get("fields", {})
|
|
assert "billing_project" in fields, body["known_fields"]
|
|
assert "hint" in fields["billing_project"]
|
|
|
|
|
|
def test_known_field_billing_project_renders_in_ui(seeded_app, monkeypatch, tmp_path):
|
|
"""J3: renderer ships the CSS class + reads known_fields from the API.
|
|
|
|
We can't assert the dashed input directly (the page is shell-only — the
|
|
JS fills `#cfg-sections` from the GET response after the HTML loads).
|
|
Instead verify the static template ships the two markers the renderer
|
|
needs: the `is-unset` CSS class and a `known_fields` reference in the
|
|
JS. The two together prove the wiring exists.
|
|
"""
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
state = tmp_path / "state"
|
|
state.mkdir(parents=True, exist_ok=True)
|
|
import yaml as _yaml
|
|
(state / "instance.yaml").write_text(_yaml.dump({
|
|
"data_source": {"type": "bigquery", "bigquery": {"project": "p"}},
|
|
}))
|
|
import app.instance_config as ic
|
|
ic._instance_config = None
|
|
try:
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
c.cookies.set("access_token", token)
|
|
try:
|
|
r = c.get("/admin/server-config", headers={"Accept": "text/html"})
|
|
finally:
|
|
c.cookies.clear()
|
|
assert r.status_code == 200, r.text
|
|
body = r.text
|
|
assert "is-unset" in body, "cfg-field.is-unset CSS class missing"
|
|
assert "known_fields" in body, "renderer JS needs to consume known_fields"
|
|
finally:
|
|
ic._instance_config = None
|
|
|
|
|
|
def test_known_field_value_unset_when_yaml_missing(seeded_app, monkeypatch, tmp_path):
|
|
"""J3 indirectly: when the YAML has no billing_project, the GET still
|
|
omits it from sections (it's not there to surface), but the registry
|
|
entry tells the UI it's a valid optional field worth exposing."""
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
state = tmp_path / "state"
|
|
state.mkdir(parents=True, exist_ok=True)
|
|
import yaml as _yaml
|
|
(state / "instance.yaml").write_text(_yaml.dump({
|
|
"data_source": {"type": "bigquery", "bigquery": {"project": "data-proj"}},
|
|
}))
|
|
import app.instance_config as ic
|
|
ic._instance_config = None
|
|
try:
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
r = c.get("/api/admin/server-config", headers=_auth(token))
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
bq_section = body.get("sections", {}).get("data_source", {}).get("bigquery", {})
|
|
# billing_project must be discoverable via known_fields even when
|
|
# absent from YAML — the registry is the single source for which
|
|
# optional knobs exist.
|
|
assert "billing_project" in body["known_fields"]["data_source"]["bigquery"]["fields"]
|
|
# The pre-existing _ensure_bq_optional_fields helper still seeds a
|
|
# default into the section payload, so billing_project shows up
|
|
# there too — that's fine, the registry exposes the *schema*
|
|
# (kind/hint) the UI needs to render the field nicely. What matters
|
|
# is that the registry is present so subagents 2-4 can populate
|
|
# fields that *don't* have a corresponding seed helper.
|
|
assert isinstance(bq_section, dict)
|
|
finally:
|
|
ic._instance_config = None
|
|
|
|
|
|
def test_known_fields_covers_all_editable_sections(seeded_app):
|
|
"""The registry has an entry (even if empty) for every editable section
|
|
so subagents 2-4 know where to add their entries without having to
|
|
decide whether the section needs a new top-level key.
|
|
"""
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
r = c.get("/api/admin/server-config", headers=_auth(token))
|
|
body = r.json()
|
|
editable = set(body["editable_sections"])
|
|
known = set(body["known_fields"].keys())
|
|
# Every editable section must have a (possibly empty) known_fields entry.
|
|
missing = editable - known
|
|
assert not missing, f"sections without a known_fields slot: {sorted(missing)}"
|
|
|
|
|
|
# ── Part A: structured nested-field rendering ───────────────────────────────
|
|
|
|
|
|
def test_nested_field_renders_as_structured_form_not_json_blob(seeded_app, monkeypatch, tmp_path):
|
|
"""Renderer J3 upgrade: registry-declared nested fields get individual
|
|
inputs with dotted-path data-key, not a single JSON textarea for the
|
|
parent object. The JS renderer must contain the dotted-path collection
|
|
logic so subfields round-trip as a structured patch.
|
|
"""
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
state = tmp_path / "state"
|
|
state.mkdir(parents=True, exist_ok=True)
|
|
import yaml as _yaml
|
|
(state / "instance.yaml").write_text(_yaml.dump({
|
|
"data_source": {"type": "bigquery", "bigquery": {"project": "p"}},
|
|
}))
|
|
import app.instance_config as ic
|
|
ic._instance_config = None
|
|
try:
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
c.cookies.set("access_token", token)
|
|
try:
|
|
r = c.get("/admin/server-config", headers={"Accept": "text/html"})
|
|
finally:
|
|
c.cookies.clear()
|
|
assert r.status_code == 200, r.text
|
|
body = r.text
|
|
# Renderer must ship the structured nested-field path. The JS uses
|
|
# dotted-path data-key for child inputs (e.g. data-key="bigquery.billing_project")
|
|
# and the collector reconstructs nested patches.
|
|
assert "nested-field" in body or "renderNestedField" in body or "dotted" in body or 'data-nested' in body, \
|
|
"renderer JS must support structured nested-field rendering"
|
|
# The collector must understand dotted-path keys (parent.child) and
|
|
# rebuild a nested patch from them — replaces the old JSON-textarea path.
|
|
assert "splitDotted" in body or '.split(".")' in body or "dotKey" in body or "nestedKey" in body, \
|
|
"collector JS must rebuild nested patches from dotted-path keys"
|
|
# Display-only mode must be GONE — child rows are now first-class inputs.
|
|
assert "data-display-only" not in body, \
|
|
"display-only fallback path must be removed; child fields are now editable"
|
|
finally:
|
|
ic._instance_config = None
|
|
|
|
|
|
# ── Part B: registry population ─────────────────────────────────────────────
|
|
|
|
|
|
def test_bigquery_subfields_populated(seeded_app):
|
|
"""Every documented BigQuery optional knob is in the registry under
|
|
data_source.bigquery.fields with the right kind."""
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
r = c.get("/api/admin/server-config", headers=_auth(token))
|
|
assert r.status_code == 200
|
|
fields = r.json()["known_fields"]["data_source"]["bigquery"]["fields"]
|
|
assert "billing_project" in fields
|
|
assert "max_bytes_per_materialize" in fields
|
|
# legacy_wrap_views was removed in #160 — VIEW/MATERIALIZED_VIEW are now
|
|
# always wrapped via bigquery_query() (the previous opt-in path).
|
|
assert "legacy_wrap_views" not in fields, \
|
|
"legacy_wrap_views config knob was removed; #160 makes the wrap behavior unconditional"
|
|
assert fields["max_bytes_per_materialize"]["kind"] == "int"
|
|
assert fields["max_bytes_per_materialize"]["default"] == 10737418240
|
|
|
|
|
|
def test_keboola_registry_entries_present(seeded_app):
|
|
"""Keboola subfields exposed for hint discoverability."""
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
r = c.get("/api/admin/server-config", headers=_auth(token))
|
|
fields = r.json()["known_fields"]["data_source"]["keboola"]["fields"]
|
|
assert "stack_url" in fields
|
|
assert "project_id" in fields
|
|
|
|
|
|
def test_ai_base_url_populated(seeded_app):
|
|
"""AI section exposes base_url + structured_output."""
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
r = c.get("/api/admin/server-config", headers=_auth(token))
|
|
fields = r.json()["known_fields"]["ai"]
|
|
assert "base_url" in fields
|
|
assert "structured_output" in fields
|
|
assert fields["structured_output"]["kind"] == "select"
|
|
assert fields["structured_output"]["default"] == "auto"
|
|
|
|
|
|
def test_openmetadata_is_editable_section_with_known_fields(seeded_app):
|
|
"""openmetadata is a new editable section with full registry."""
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
r = c.get("/api/admin/server-config", headers=_auth(token))
|
|
body = r.json()
|
|
assert "openmetadata" in body["editable_sections"]
|
|
fields = body["known_fields"].get("openmetadata", {})
|
|
assert "url" in fields
|
|
assert "token" in fields
|
|
assert fields["token"]["kind"] == "secret"
|
|
assert "verify_ssl" in fields
|
|
assert fields["verify_ssl"]["kind"] == "bool"
|
|
assert fields["verify_ssl"]["default"] is True
|
|
assert "cache_ttl_seconds" in fields
|
|
assert fields["cache_ttl_seconds"]["kind"] == "int"
|
|
|
|
|
|
def test_desktop_is_editable_section(seeded_app):
|
|
"""desktop is a new editable section with jwt_secret marked secret."""
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
r = c.get("/api/admin/server-config", headers=_auth(token))
|
|
body = r.json()
|
|
assert "desktop" in body["editable_sections"]
|
|
fields = body["known_fields"].get("desktop", {})
|
|
assert "jwt_issuer" in fields
|
|
assert "jwt_secret" in fields
|
|
assert fields["jwt_secret"]["kind"] == "secret"
|
|
assert "url_scheme" in fields
|
|
|
|
|
|
def test_post_openmetadata_section_persists(seeded_app, tmp_path, monkeypatch):
|
|
"""openmetadata is now in _EDITABLE_SECTIONS; POST flow accepts it."""
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
state = tmp_path / "state"
|
|
state.mkdir(parents=True, exist_ok=True)
|
|
import app.instance_config as ic
|
|
ic._instance_config = None
|
|
try:
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
r = c.post(
|
|
"/api/admin/server-config",
|
|
headers=_auth(token),
|
|
json={
|
|
"sections": {
|
|
"openmetadata": {
|
|
"url": "https://om.example.com",
|
|
"cache_ttl_seconds": 1800,
|
|
"verify_ssl": True,
|
|
},
|
|
},
|
|
},
|
|
)
|
|
assert r.status_code in (200, 204), r.text
|
|
# Verify it landed on disk.
|
|
import yaml as _yaml
|
|
loaded = _yaml.safe_load((state / "instance.yaml").read_text())
|
|
assert loaded["openmetadata"]["url"] == "https://om.example.com"
|
|
assert loaded["openmetadata"]["cache_ttl_seconds"] == 1800
|
|
finally:
|
|
ic._instance_config = None
|
|
|
|
|
|
def test_post_desktop_section_persists(seeded_app, tmp_path, monkeypatch):
|
|
"""desktop section accepts patches via the standard editor flow."""
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
state = tmp_path / "state"
|
|
state.mkdir(parents=True, exist_ok=True)
|
|
import app.instance_config as ic
|
|
ic._instance_config = None
|
|
try:
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
r = c.post(
|
|
"/api/admin/server-config",
|
|
headers=_auth(token),
|
|
json={"sections": {"desktop": {"jwt_issuer": "data-analyst"}}},
|
|
)
|
|
assert r.status_code in (200, 204), r.text
|
|
import yaml as _yaml
|
|
loaded = _yaml.safe_load((state / "instance.yaml").read_text())
|
|
assert loaded["desktop"]["jwt_issuer"] == "data-analyst"
|
|
finally:
|
|
ic._instance_config = None
|
|
|
|
|
|
def test_save_section_with_nested_field_merges_correctly(seeded_app, tmp_path, monkeypatch):
|
|
"""When the renderer ships a dotted-path patch (e.g. bigquery.billing_project=X),
|
|
the API merges it into the existing data_source.bigquery dict without wiping
|
|
the other keys (project, location, type)."""
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
state = tmp_path / "state"
|
|
state.mkdir(parents=True, exist_ok=True)
|
|
import yaml as _yaml
|
|
(state / "instance.yaml").write_text(_yaml.dump({
|
|
"data_source": {
|
|
"type": "bigquery",
|
|
"bigquery": {"project": "data-proj", "location": "us-central1"},
|
|
},
|
|
}))
|
|
import app.instance_config as ic
|
|
ic._instance_config = None
|
|
try:
|
|
c = seeded_app["client"]
|
|
token = seeded_app["admin_token"]
|
|
# Patch only billing_project nested under bigquery — type/project/location
|
|
# must survive the merge.
|
|
r = c.post(
|
|
"/api/admin/server-config",
|
|
headers=_auth(token),
|
|
json={
|
|
"sections": {
|
|
"data_source": {
|
|
"bigquery": {
|
|
"billing_project": "billing-proj",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
assert r.status_code in (200, 204), r.text
|
|
|
|
# Re-read from disk to verify the deep-merge preserved siblings.
|
|
loaded = _yaml.safe_load((state / "instance.yaml").read_text())
|
|
bq = loaded["data_source"]["bigquery"]
|
|
assert bq.get("project") == "data-proj", bq
|
|
assert bq.get("location") == "us-central1", bq
|
|
assert bq.get("billing_project") == "billing-proj", bq
|
|
assert loaded["data_source"]["type"] == "bigquery"
|
|
finally:
|
|
ic._instance_config = None
|