phase: 168_admin_settings_panel
**Phase 168 — Admin Settings panel — final verification: PASS** 1. **Implemented/verified:** `/settings.html` (admin-only) with the 75-field catalogue (`app/core/settings_catalog.py`), DB-over-env overrides in Postgres (`app_settings`, migration 0028 at head, `settings_overlay.py` resolver, `app/api/admin_settings.py` — 4 admin-only routes), live-apply on save with no restart + restart-required badges, Reset/Clear all, write-only secrets, `.env`-never-written, `BOR_SETTINGS_DB=0` kill switch. 2. **Phase E2E: 12/12** — run with `E2E_APP_PORT=8200` (ports 8123/8124 are held by an unrelated "undercooked" project; no process killed, no code change — the documented env override). 3. **Regression set: all 7 suites green in isolation** (theme tab, API tokens, configurable brand, global tuning, admin auth, cache busting, wide desktop column — 38 tests). 4. **Full suite: unit+integration exit 0 (4066 passed), app/ coverage 99%** (gate >90%); **ruff clean, pyright 0 errors / 0 warnings.** 5. **Log contract (rule 10) verified live:** per-turn + per-response byte-budget lines present; settings write lines carry key + action, **never the value**. 6. **Byte-budget audit: all endpoints under the 262,144 B budget**; settings catalogue 23,513 B (gzip 3,326). 7. **Defect fixed (one):** criterion 5 had no integration pin — added `test_bad_row_inserted_directly_never_bricks_boot` to `tests/integration/test_admin_settings_api.py` (malformed row inserted directly → fresh `create_app()` boots, row skipped with logged reason, rest of table applies, `/api/health` 200) — 12/12 green. 8. **Docs pass:** `README.md` and `AGENTS.md` reviewed in full — both consistent with the as-built feature (Settings page table, Settings section, Configuration-reference note, `.env.example` block); **no changes required**. 9. **Cleanup:** audit app killed (by PID); `app_settings` left empty (0 rows); all changes left in the working tree (no commit, per protocol). 10. **Completion criteria:** all 7 checkboxes verified green (criterion 7's commit is the harness's). 11. **Next pending phase:** `170_edit_bubble_sizing`.
This commit is contained in:
@@ -0,0 +1,518 @@
|
||||
"""Integration: the admin settings panel's read/write surface (phase 168,
|
||||
task 04) — ``/api/admin/settings`` GET / PUT ``/{key}`` / DELETE
|
||||
``/{key}`` / DELETE all, driven through the real app (TestClient keeps
|
||||
the cookie jar — the house ``test_ui_settings_api`` /
|
||||
``test_tokens_api`` pattern).
|
||||
|
||||
The pins (00_phase, Testing & Quality):
|
||||
|
||||
* the auth matrix — anonymous AND a signed-in token USER get 403 on ALL
|
||||
four routes (the router-wide ``require_admin`` closes the surface on
|
||||
every method), the admin 200s on all four;
|
||||
* the shape — ``items`` length == ``len(Settings.model_fields)`` (75),
|
||||
groups in ``GROUPS`` order (the stable order the E2E pins against),
|
||||
``totals.count`` / ``overridden`` correct, ``overlay_enabled`` true;
|
||||
on an empty table a field whose ``BOR_*`` var the test env sets
|
||||
reports ``source="env"``, an untouched one ``source="default"`` and
|
||||
none reports ``"db"``;
|
||||
* write → live — a saved ``relevance_threshold`` is in
|
||||
``get_settings()`` with NO restart (the cache clear + task-03 hooks
|
||||
are the whole job), the row is in the table with a non-null
|
||||
``updated_at``, and the DB value reaches the ANONYMOUS
|
||||
``/api/config`` endpoint through the same overlay;
|
||||
* errors — unknown key 404; the two LOCKED A3 keys 422 naming the env
|
||||
var; a bad shape 422 naming the field with NOTHING stored; a
|
||||
cross-field pair that only breaks in combination (the second PUT 422s
|
||||
and the first row is unchanged);
|
||||
* masking (LOCKED A4) — a stored secret reports ``masked``/``has_value``
|
||||
only, the plaintext appears NOWHERE in the raw response text, a blank
|
||||
secret PUT 422s, and a DELETE clears it;
|
||||
* reset — a DELETE ``/{key}`` restores the env value (idempotent);
|
||||
DELETE ``""`` clears everything (``{"cleared": 3}``);
|
||||
* the kill switch (D6) — ``BOR_SETTINGS_DB=0``: ``overlay_enabled``
|
||||
false, a PUT still stores the row, ``get_settings()`` still reports
|
||||
the env value — the switch disables the mechanism, not the panel;
|
||||
* the byte budget — the whole catalogue stays under the phase-167 INFO
|
||||
bar (64 KB), so it never floods the log;
|
||||
* fail-soft boot — a malformed row inserted DIRECTLY into the table
|
||||
(bypassing the API's 422 gate) never bricks: a fresh ``create_app()``
|
||||
runs with it in the table, the row is skipped with a logged reason,
|
||||
the rest of the table still applies, and ``/api/health`` is 200.
|
||||
|
||||
The module's teardown leaves NO row behind (the same discipline
|
||||
``test_admin_theme_tab.py`` applies to ``ui_settings`` — a surviving
|
||||
row would retune every later suite through the overlay).
|
||||
|
||||
Real Postgres (``podman compose up -d db``); no LLM involved.
|
||||
|
||||
Requires: podman compose up -d db
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api import chat as chat_api
|
||||
from app.config import Settings, get_settings, invalidate_settings
|
||||
from app.core import settings_overlay
|
||||
from app.core.settings_catalog import CATALOG, GROUPS
|
||||
from app.core.settings_overlay import register_invalidator
|
||||
from app.main import app as fastapi_app
|
||||
from app.main import create_app
|
||||
from tests.conftest import ADMIN_PASSWORD
|
||||
|
||||
API = "/api/admin/settings"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_state(
|
||||
db: Session, monkeypatch: pytest.MonkeyPatch
|
||||
) -> Iterator[None]:
|
||||
"""A clean ``app_settings`` table + the overlay switch ON + a fresh
|
||||
overlay/hook slate (the ``test_admin_settings_live`` idiom: reset
|
||||
the process-wide hook list and re-wire what ``create_app()``
|
||||
wires, so a save in this module runs the chat runtime reset).
|
||||
Teardown deletes the rows + invalidates + clears the cache — NO row
|
||||
may survive this module (a surviving row would retune every later
|
||||
suite through the overlay).
|
||||
"""
|
||||
settings_overlay.reset_state_for_tests()
|
||||
register_invalidator(chat_api.reset_runtime_caches)
|
||||
db.execute(text("DELETE FROM app_settings"))
|
||||
db.commit()
|
||||
monkeypatch.setenv("BOR_SETTINGS_DB", "1")
|
||||
invalidate_settings()
|
||||
yield
|
||||
db.execute(text("DELETE FROM app_settings"))
|
||||
db.commit()
|
||||
invalidate_settings()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _admin_client() -> TestClient:
|
||||
"""A fresh client signed in as the admin (the ``_admin_client``
|
||||
pattern from ``test_ui_settings_api``)."""
|
||||
c = TestClient(fastapi_app)
|
||||
r = c.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
|
||||
return c
|
||||
|
||||
|
||||
def _token_user_client(admin: TestClient, anon: TestClient) -> TestClient:
|
||||
"""A signed-in token USER (role ``"user"`` — issue the token the way
|
||||
``test_tokens_api`` does): the only non-admin identity, 403 on this
|
||||
surface on every method (the phase-79 token matrix contract)."""
|
||||
r = admin.post("/api/tokens", json={"label": "alice"})
|
||||
assert r.status_code == 201, r.text
|
||||
token = r.json()["token"]
|
||||
assert anon.post("/api/token-auth", json={"token": token}).status_code == 204
|
||||
return anon
|
||||
|
||||
|
||||
def _item(body: dict, key: str) -> dict:
|
||||
"""One catalogue item by key (the GET body's ``items`` list)."""
|
||||
for item in body["items"]:
|
||||
if item["key"] == key:
|
||||
return item
|
||||
raise AssertionError(f"catalogue item missing: {key}")
|
||||
|
||||
|
||||
def test_auth_matrix_all_four_routes(client: TestClient, db: Session) -> None:
|
||||
"""Anonymous AND token user: 403 ``admin only`` on ALL four routes
|
||||
(the router-wide dependency closes the surface on every method);
|
||||
the admin passes on all four."""
|
||||
admin = _admin_client()
|
||||
token_user = _token_user_client(admin, client)
|
||||
|
||||
for caller in (client, token_user):
|
||||
r = caller.get(API)
|
||||
assert r.status_code == 403 and r.json() == {"detail": "admin only"}
|
||||
r = caller.put(f"{API}/relevance_threshold", json={"value": "0.5"})
|
||||
assert r.status_code == 403 and r.json() == {"detail": "admin only"}
|
||||
r = caller.delete(f"{API}/relevance_threshold")
|
||||
assert r.status_code == 403 and r.json() == {"detail": "admin only"}
|
||||
r = caller.delete(API)
|
||||
assert r.status_code == 403 and r.json() == {"detail": "admin only"}
|
||||
|
||||
r = admin.get(API)
|
||||
assert r.status_code == 200
|
||||
r = admin.put(f"{API}/top_n_docs", json={"value": "3"})
|
||||
assert r.status_code == 200, r.text
|
||||
r = admin.delete(f"{API}/top_n_docs")
|
||||
assert r.status_code == 200, r.text
|
||||
r = admin.delete(API)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"cleared": 0}
|
||||
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0
|
||||
|
||||
|
||||
def test_catalogue_shape_and_sources_on_empty_table(admin_client) -> None:
|
||||
"""The whole catalogue, grouped and stable: 75 items (exactly
|
||||
``Settings.model_fields``), groups in ``GROUPS`` order (each group a
|
||||
contiguous block, the block order = ``GROUPS``), ``totals`` correct,
|
||||
``overlay_enabled`` true. On an empty table: the env-var-set field
|
||||
reports ``"env"``, the untouched one ``"default"`` and NOTHING
|
||||
reports ``"db"``."""
|
||||
r = admin_client.get(API)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
items = body["items"]
|
||||
assert len(items) == len(Settings.model_fields)
|
||||
assert body["totals"] == {
|
||||
"count": len(Settings.model_fields),
|
||||
"overridden": 0,
|
||||
}
|
||||
assert body["overlay_enabled"] is True
|
||||
|
||||
# The stable order: each group a contiguous block, block order GROUPS.
|
||||
groups_seen: list[str] = []
|
||||
for item in items:
|
||||
assert item["group"] in GROUPS
|
||||
if not groups_seen or groups_seen[-1] != item["group"]:
|
||||
groups_seen.append(item["group"])
|
||||
assert groups_seen == [g for g in GROUPS if g in groups_seen]
|
||||
assert len(groups_seen) == len(set(groups_seen))
|
||||
# Every item carries the full contract (the panel generates from it).
|
||||
for item in items:
|
||||
assert set(item) == {
|
||||
"key", "env_var", "group", "label", "type", "secret",
|
||||
"restart_required", "overridable", "source", "value",
|
||||
"masked", "has_value", "has_override", "db_value", "default",
|
||||
}
|
||||
assert item["env_var"] == "BOR_" + item["key"].upper()
|
||||
assert item["source"] in ("db", "env", "default")
|
||||
|
||||
# Empty table: env / default / never db.
|
||||
sources = {item["key"]: item["source"] for item in items}
|
||||
assert "db" not in sources.values()
|
||||
assert sources["input_placeholder"] == "env" # BOR_… set in tests/conftest.py
|
||||
assert sources["docs_work_dir"] == "default" # no BOR_… in the test env
|
||||
assert sources["relevance_threshold"] == "env" # pinned 0.30 in conftest
|
||||
|
||||
|
||||
def test_write_is_live_without_restart(admin_client, db: Session) -> None:
|
||||
"""Write → live (no restart): the saved value is in ``get_settings()``
|
||||
in the SAME process, the row is in the table with a non-null
|
||||
``updated_at``, the item reports ``source="db"`` and
|
||||
``totals.overridden == 1`` — and the DB value reaches the ANONYMOUS
|
||||
``/api/config`` endpoint through the same overlay (the DB-over-env
|
||||
rule is deployment-wide, not panel-local)."""
|
||||
assert get_settings().relevance_threshold == 0.30 # the conftest env value
|
||||
|
||||
r = admin_client.put(f"{API}/relevance_threshold", json={"value": "0.5"})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["key"] == "relevance_threshold"
|
||||
assert body["source"] == "db"
|
||||
assert body["has_override"] is True
|
||||
assert body["db_value"] == "0.5"
|
||||
assert get_settings().relevance_threshold == 0.5 # NO restart (task 03)
|
||||
|
||||
row = db.execute(
|
||||
text("SELECT value, updated_at FROM app_settings WHERE key = 'relevance_threshold'")
|
||||
).first()
|
||||
assert row is not None
|
||||
assert row[0] == "0.5"
|
||||
assert row[1] is not None # the server-side now()
|
||||
|
||||
r = admin_client.get(API)
|
||||
assert r.json()["totals"]["overridden"] == 1
|
||||
|
||||
# Visible through the public config endpoint (no auth) — the same
|
||||
# overlay retunes every consumer at once (D2).
|
||||
anon = TestClient(fastapi_app)
|
||||
r = anon.get("/api/config")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["input_placeholder"] == "Ask me anything…"
|
||||
r = admin_client.put(f"{API}/input_placeholder", json={"value": "Type your question…"})
|
||||
assert r.status_code == 200, r.text
|
||||
r = anon.get("/api/config")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["input_placeholder"] == "Type your question…"
|
||||
|
||||
|
||||
def test_put_errors_404_and_422(admin_client, db: Session) -> None:
|
||||
"""The write-path guards (LOCKED A5, A3, A4): unknown key 404; the
|
||||
two A3 keys 422 naming the env var; a bad shape 422 naming the FIELD
|
||||
with NOTHING stored; a cross-field pair that only breaks in
|
||||
combination — the second PUT 422s and the first row is unchanged."""
|
||||
r = admin_client.put(f"{API}/bogus_key", json={"value": "1"})
|
||||
assert r.status_code == 404
|
||||
assert r.json() == {"detail": "Unknown setting bogus_key"}
|
||||
|
||||
r = admin_client.put(f"{API}/database_url", json={"value": "postgresql://x"})
|
||||
assert r.status_code == 422
|
||||
assert "BOR_DATABASE_URL is not settable from the database" in r.json()["detail"]
|
||||
r = admin_client.put(f"{API}/embedding_dim", json={"value": "384"})
|
||||
assert r.status_code == 422
|
||||
assert "BOR_EMBEDDING_DIM is not settable from the database" in r.json()["detail"]
|
||||
|
||||
# A bad shape: names the field, stores nothing.
|
||||
r = admin_client.put(f"{API}/relevance_threshold", json={"value": "abc"})
|
||||
assert r.status_code == 422
|
||||
assert "relevance_threshold" in r.json()["detail"]
|
||||
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0
|
||||
|
||||
# The one list[str] field (K2): a malformed JSON array is a 422
|
||||
# naming the field (the _coerce ValueError branch), nothing stored.
|
||||
r = admin_client.put(f"{API}/suggestions", json={"value": "[not json"})
|
||||
assert r.status_code == 422
|
||||
assert "suggestions" in r.json()["detail"]
|
||||
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0
|
||||
# A well-formed JSON array stores fine (the JSON round-trip works).
|
||||
r = admin_client.put(
|
||||
f"{API}/suggestions", json={"value": '["How do I deploy gitea?"]'}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["db_value"] == '["How do I deploy gitea?"]'
|
||||
assert db.execute(
|
||||
text("SELECT value FROM app_settings WHERE key = 'suggestions'")
|
||||
).scalar() == '["How do I deploy gitea?"]'
|
||||
# (Clean up the row — the cross-field pin below counts rows.)
|
||||
assert admin_client.delete(f"{API}/suggestions").status_code == 200
|
||||
|
||||
# The cross-field pair (only breaks in combination): store
|
||||
# relevance_threshold=0.2 (valid alone), then lexical_support_floor=0.9
|
||||
# — above the DB-overridden threshold: the SECOND PUT 422s and the
|
||||
# first row is unchanged.
|
||||
r = admin_client.put(f"{API}/relevance_threshold", json={"value": "0.2"})
|
||||
assert r.status_code == 200, r.text
|
||||
r = admin_client.put(f"{API}/lexical_support_floor", json={"value": "0.9"})
|
||||
assert r.status_code == 422
|
||||
assert "lexical_support_floor" in r.json()["detail"]
|
||||
value = db.execute(
|
||||
text("SELECT value FROM app_settings WHERE key = 'relevance_threshold'")
|
||||
).scalar()
|
||||
assert value == "0.2" # the first row is unchanged
|
||||
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 1
|
||||
# The failed write did not disturb the live view.
|
||||
assert get_settings().relevance_threshold == 0.2
|
||||
|
||||
|
||||
def test_secrets_are_masked_and_write_only(admin_client, db: Session) -> None:
|
||||
"""LOCKED A4: a stored secret reports ``masked``/``has_value`` only —
|
||||
``value``/``db_value`` are ``None`` and the plaintext appears NOWHERE
|
||||
in the raw response text (asserted on ``response.text``, not the
|
||||
parsed model); a blank secret PUT 422s (a self-lockout — DELETE is
|
||||
the explicit clear); a DELETE clears it."""
|
||||
db.execute(
|
||||
text("INSERT INTO app_settings (key, value) VALUES ('llm_api_key', :v)"),
|
||||
{"v": "sk-secret-12345"},
|
||||
)
|
||||
db.commit()
|
||||
invalidate_settings()
|
||||
|
||||
r = admin_client.get(API)
|
||||
assert r.status_code == 200
|
||||
item = _item(r.json(), "llm_api_key")
|
||||
assert item["masked"] is True
|
||||
assert item["value"] is None
|
||||
assert item["db_value"] is None
|
||||
assert item["has_value"] is True # a non-empty value is in effect
|
||||
assert item["has_override"] is True
|
||||
assert item["source"] == "db"
|
||||
assert item["default"] == "" # masked: the code default is never shown
|
||||
assert "sk-secret-12345" not in r.text # NOWHERE in the raw body
|
||||
# The other write-only fields are masked too (set in the test env).
|
||||
for key in ("admin_password", "session_secret", "database_url"):
|
||||
other = _item(r.json(), key)
|
||||
assert other["masked"] is True and other["value"] is None
|
||||
|
||||
# A blank secret PUT is a 422 (an empty API key / session secret
|
||||
# would self-lockout — DELETE is the explicit clear).
|
||||
r = admin_client.put(f"{API}/llm_api_key", json={"value": " "})
|
||||
assert r.status_code == 422
|
||||
assert "BOR_LLM_API_KEY must not be blank" in r.json()["detail"]
|
||||
assert db.execute(
|
||||
text("SELECT value FROM app_settings WHERE key = 'llm_api_key'")
|
||||
).scalar() == "sk-secret-12345" # unchanged
|
||||
|
||||
# A DELETE clears the row — the effective value falls back to the
|
||||
# env value (whatever this deployment's env/.env provides — the
|
||||
# test only pins that it is NOT the stored secret and that the
|
||||
# item reports the fallback state).
|
||||
r = admin_client.delete(f"{API}/llm_api_key")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["source"] == "default" # no BOR_LLM_API_KEY in the PROCESS env
|
||||
assert r.json()["has_override"] is False
|
||||
assert r.json()["value"] is None
|
||||
assert r.json()["has_value"] == bool(get_settings().llm_api_key)
|
||||
assert get_settings().llm_api_key != "sk-secret-12345" # the override is gone
|
||||
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0
|
||||
|
||||
|
||||
def test_reset_to_env_and_clear_all(admin_client, db: Session) -> None:
|
||||
"""Reset semantics: DELETE ``/{key}`` deletes the row (idempotent — a
|
||||
double Reset is a success, not a 404) and the env value wins again;
|
||||
DELETE ``""`` clears every row (``{"cleared": 3}``) and
|
||||
``totals.overridden`` is back to 0."""
|
||||
r = admin_client.put(f"{API}/relevance_threshold", json={"value": "0.5"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert get_settings().relevance_threshold == 0.5
|
||||
|
||||
r = admin_client.delete(f"{API}/relevance_threshold")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["source"] == "env" # BOR_RELEVANCE_THRESHOLD is set (0.30)
|
||||
assert r.json()["has_override"] is False
|
||||
assert get_settings().relevance_threshold == 0.30 # the env value wins again
|
||||
# Idempotent: the double Reset is a success, not a 404.
|
||||
r = admin_client.delete(f"{API}/relevance_threshold")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Three rows, then "Clear all overrides".
|
||||
for key, value in (("relevance_threshold", "0.5"), ("llm_retries", "7"), ("top_n_docs", "3")):
|
||||
r = admin_client.put(f"{API}/{key}", json={"value": value})
|
||||
assert r.status_code == 200, r.text
|
||||
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 3
|
||||
|
||||
r = admin_client.delete(API)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"cleared": 3}
|
||||
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0
|
||||
assert get_settings().relevance_threshold == 0.30 # env values win again
|
||||
r = admin_client.get(API)
|
||||
assert r.json()["totals"]["overridden"] == 0
|
||||
|
||||
|
||||
def test_kill_switch_disables_the_mechanism_not_the_panel(
|
||||
admin_client, db: Session, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""D6: with ``BOR_SETTINGS_DB=0`` the catalogue reports
|
||||
``overlay_enabled`` false (the panel shows a banner instead of
|
||||
pretending a save took effect), a PUT STILL stores the row, and
|
||||
``get_settings()`` still reports the ENV value — the switch disables
|
||||
the mechanism, not the panel."""
|
||||
r = admin_client.put(f"{API}/relevance_threshold", json={"value": "0.5"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert get_settings().relevance_threshold == 0.5 # the switch was ON
|
||||
# Deterministic env base for the pin (machine-independent):
|
||||
monkeypatch.setenv("BOR_LLM_RETRIES", "3")
|
||||
invalidate_settings()
|
||||
|
||||
monkeypatch.setenv("BOR_SETTINGS_DB", "0")
|
||||
invalidate_settings()
|
||||
|
||||
r = admin_client.get(API)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["overlay_enabled"] is False
|
||||
assert r.json()["totals"]["overridden"] == 0 # no row is IN EFFECT
|
||||
item = _item(r.json(), "relevance_threshold")
|
||||
assert item["source"] == "env" # the row exists but is not in effect
|
||||
assert item["has_override"] is True
|
||||
assert item["db_value"] == "0.5" # the stored string is visible (non-secret)
|
||||
assert get_settings().relevance_threshold == 0.30 # the env value stands
|
||||
|
||||
# The panel stays writable: the PUT stores the row …
|
||||
r = admin_client.put(f"{API}/llm_retries", json={"value": "7"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 2
|
||||
# … but the env value stays in effect while the switch is off.
|
||||
assert get_settings().llm_retries == 3 # the env value (pinned 3 above)
|
||||
|
||||
|
||||
def test_catalogue_stays_under_the_byte_budget(admin_client) -> None:
|
||||
"""The whole catalogue stays under the phase-167 INFO bar (64 KB —
|
||||
``BOR_LOG_LARGE_RESPONSE_BYTES``), so ``GET /api/admin/settings``
|
||||
never floods the log (75 items ≈ 20 KB; the audit script re-verifies
|
||||
the 256 KB budget in task 06)."""
|
||||
r = admin_client.get(API)
|
||||
assert r.status_code == 200
|
||||
assert len(r.content) < 65_536
|
||||
|
||||
|
||||
def test_unknown_delete_is_a_404(admin_client) -> None:
|
||||
"""A DELETE of an UNKNOWN SETTING is a 404 like the PUT (a typo in
|
||||
the key is a client bug; the idempotency pin is about a known key
|
||||
whose row is already gone — the double Reset in
|
||||
``test_reset_to_env_and_clear_all``)."""
|
||||
r = admin_client.delete(f"{API}/bogus_key")
|
||||
assert r.status_code == 404
|
||||
assert r.json() == {"detail": "Unknown setting bogus_key"}
|
||||
|
||||
|
||||
def test_write_log_lines_carry_never_the_value(
|
||||
admin_client, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""AGENTS rule 10 / A4: one line per write (``settings action=…`` on
|
||||
the ``app.api.admin_settings`` logger) carrying the key and the
|
||||
action — NEVER the value (a secret write must not leak the
|
||||
plaintext into the log)."""
|
||||
caplog.set_level(logging.INFO, logger="app.api.admin_settings")
|
||||
|
||||
r = admin_client.put(f"{API}/llm_api_key", json={"value": "sk-log-secret-xyz"})
|
||||
assert r.status_code == 200, r.text
|
||||
set_lines = [rec.message for rec in caplog.records
|
||||
if rec.name == "app.api.admin_settings"]
|
||||
assert "settings action=set key=llm_api_key status=200" in set_lines
|
||||
assert "sk-log-secret-xyz" not in "\n".join(set_lines)
|
||||
|
||||
caplog.clear()
|
||||
r = admin_client.delete(f"{API}/llm_api_key")
|
||||
assert r.status_code == 200
|
||||
assert "settings action=clear key=llm_api_key" in [
|
||||
rec.message for rec in caplog.records
|
||||
if rec.name == "app.api.admin_settings"
|
||||
]
|
||||
|
||||
caplog.clear()
|
||||
admin_client.put(f"{API}/top_n_docs", json={"value": "3"})
|
||||
r = admin_client.delete(API)
|
||||
assert r.status_code == 200 and r.json() == {"cleared": 1}
|
||||
assert "settings action=clear_all count=1" in [
|
||||
rec.message for rec in caplog.records
|
||||
if rec.name == "app.api.admin_settings"
|
||||
]
|
||||
|
||||
|
||||
def test_bad_row_inserted_directly_never_bricks_boot(
|
||||
db: Session, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Completion criterion (00_phase, item 5): a malformed row written
|
||||
straight into ``app_settings`` (bypassing the API's 422 gate — the
|
||||
operator SQL, the failed migration) must never brick boot. A FRESH
|
||||
``create_app()`` — the full boot path: fail-loud auth gate, resolver
|
||||
prime, middleware wiring — runs with the bad row present, the row is
|
||||
SKIPPED with a logged reason, the rest of the table still applies,
|
||||
and ``/api/health`` is 200 (A5)."""
|
||||
# One bad row (the API's 422 gate would refuse it) + one good row:
|
||||
# the bad one is skipped, the good one stands.
|
||||
db.execute(text(
|
||||
"INSERT INTO app_settings (key, value)"
|
||||
" VALUES ('relevance_threshold', 'not-a-number')"
|
||||
))
|
||||
db.execute(text(
|
||||
"INSERT INTO app_settings (key, value) VALUES ('top_n_docs', '3')"
|
||||
))
|
||||
db.commit()
|
||||
caplog.set_level(logging.ERROR, logger="app.settings")
|
||||
|
||||
# The boot path with the bad row in the table — must not raise.
|
||||
app = create_app()
|
||||
|
||||
s = get_settings()
|
||||
env_only = Settings() # the env value the skipped key falls back to
|
||||
assert s.relevance_threshold == env_only.relevance_threshold
|
||||
assert s.top_n_docs == 3 # the rest of the table still applies
|
||||
assert any(
|
||||
"settings override ignored" in rec.message
|
||||
and "relevance_threshold" in rec.message
|
||||
for rec in caplog.records
|
||||
)
|
||||
|
||||
r = TestClient(app).get("/api/health")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_catalog_covers_every_settings_field(admin_client) -> None:
|
||||
"""The catalogue's keys are exactly ``Settings.model_fields`` (the
|
||||
API-level face of the unit completeness pin — a new setting cannot
|
||||
be missing from the panel's data)."""
|
||||
r = admin_client.get(API)
|
||||
assert r.status_code == 200
|
||||
assert {item["key"] for item in r.json()["items"]} == set(Settings.model_fields)
|
||||
assert set(CATALOG) == set(Settings.model_fields)
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Phase 168 (task 03): the end-to-end proof of "no restart", in-process.
|
||||
|
||||
After ``invalidate_settings()`` (the save-side wrapper), the NEXT
|
||||
request behaves under the new value — pinned for the three settings
|
||||
that are NOT covered by the cache clear alone or that prove it end
|
||||
to end: the retrieval honesty gate (``relevance_threshold`` — the
|
||||
per-use read, ``app/api/chat.py``'s ``plan_turn`` gate), the shared
|
||||
``LLMClient`` (rebuilt: model + timeout from the new ``Settings``
|
||||
snapshot, ``app/api/chat.py::get_llm``), and the chat concurrency
|
||||
semaphore (``chat_max_concurrent``). Plus the kill-switch counterpart
|
||||
(``BOR_SETTINGS_DB=0`` with rows present → env values are back) and
|
||||
the ``scripts/``-consumer surface (``import_extension_set``).
|
||||
|
||||
The rows are inserted directly through the ``db`` session (the admin
|
||||
API is task 04); the save itself is ``invalidate_settings()`` —
|
||||
exactly what task 04's PUT/DELETE call after the write.
|
||||
|
||||
Real Postgres (``podman compose up -d db``); no LLM involved.
|
||||
|
||||
Requires: podman compose up -d db
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api import chat as chat_api
|
||||
from app.config import get_settings, invalidate_settings
|
||||
from app.core import settings_overlay
|
||||
from app.core.settings_overlay import register_invalidator
|
||||
from app.models import Document
|
||||
from app.rag.retriever import RetrievedChunk
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_state(
|
||||
db: Session, monkeypatch: pytest.MonkeyPatch
|
||||
) -> Iterator[None]:
|
||||
"""A clean ``app_settings`` table + a fresh overlay/hook slate.
|
||||
|
||||
``reset_state_for_tests()`` clears the process-wide hook list (a
|
||||
prior suite's test may have wiped the hook ``app.main`` registered
|
||||
at import — re-wire what ``create_app()`` wires: the chat runtime
|
||||
reset), then the table is emptied and the switch is ON for the
|
||||
test. Teardown deletes the rows and invalidates (runs the reset
|
||||
hook — a test-built ``LLMClient``/semaphore snapshot never leaks
|
||||
into a later suite) + clears the cache; ``monkeypatch`` then
|
||||
restores ``BOR_SETTINGS_DB`` and the next resolution is the
|
||||
conftest-pinned env-only view.
|
||||
"""
|
||||
settings_overlay.reset_state_for_tests()
|
||||
register_invalidator(chat_api.reset_runtime_caches)
|
||||
db.execute(text("DELETE FROM app_settings"))
|
||||
db.commit()
|
||||
monkeypatch.setenv("BOR_SETTINGS_DB", "1")
|
||||
invalidate_settings()
|
||||
yield
|
||||
db.execute(text("DELETE FROM app_settings"))
|
||||
db.commit()
|
||||
invalidate_settings()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _save(db: Session, key: str, value: str) -> None:
|
||||
"""One upserted override row (the panel's Save, minus the API)."""
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO app_settings (key, value) VALUES (:k, :v)"
|
||||
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value"
|
||||
),
|
||||
{"k": key, "v": value},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _doc(title: str) -> Document:
|
||||
return Document(
|
||||
id=uuid.uuid4(),
|
||||
source="Homelab",
|
||||
path=f"{title.lower().replace(' ', '-')}.md",
|
||||
full_path="/tmp/doc.md",
|
||||
title=title,
|
||||
content=f"{title} content body for the gate pin",
|
||||
summary=None,
|
||||
content_hash="0" * 64,
|
||||
created_at=datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def _chunk(doc: Document, score: float, cosine: float) -> RetrievedChunk:
|
||||
"""A non-FTS candidate: *cosine* is the honesty-gate input."""
|
||||
return RetrievedChunk(
|
||||
chunk_id=uuid.uuid4(),
|
||||
position=0,
|
||||
content=doc.content[:32],
|
||||
score=score,
|
||||
document=doc,
|
||||
cosine=cosine,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the per-use read: the A8 gate behaves under the saved threshold
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_relevance_threshold_db_value_bites_in_the_gate(db: Session) -> None:
|
||||
base = get_settings().relevance_threshold
|
||||
if base >= 0.95:
|
||||
pytest.skip("env threshold too close to 1.0 to bracket a DB override")
|
||||
# A DB value over the base; a cosine BETWEEN them — grounded under
|
||||
# the base, deflected under the DB value (no FTS: fts_hits = 0).
|
||||
db_value = min(base + 0.2, 0.9)
|
||||
cosine = (base + db_value) / 2
|
||||
chunk = _chunk(_doc("Gate Pin"), score=0.1, cosine=cosine)
|
||||
assert chat_api.plan_turn([chunk], get_settings()).deflected is False
|
||||
_save(db, "relevance_threshold", str(db_value))
|
||||
invalidate_settings()
|
||||
assert get_settings().relevance_threshold == db_value
|
||||
# the SAME chunk, the SAME function, the overlay-fed settings —
|
||||
# the gate now deflects (no process restart in between)
|
||||
assert chat_api.plan_turn([chunk], get_settings()).deflected is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the LLM client snapshot: Save swaps the shared LLMClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_llm_client_rebuilt_from_saved_values(db: Session) -> None:
|
||||
_save(db, "llm_chat_model", "gpt-test")
|
||||
_save(db, "llm_timeout", "7")
|
||||
invalidate_settings()
|
||||
llm = chat_api.get_llm()
|
||||
# the rebuilt client holds the DB-overridden snapshot…
|
||||
assert llm.settings.llm_chat_model == "gpt-test"
|
||||
assert llm.settings.llm_timeout == 7.0
|
||||
# …and its AsyncOpenAI transport was built from it (base_url +
|
||||
# the timeout's connect value — the way llm.py's __init__ builds
|
||||
# it; the openai client enforces a trailing slash on base_url)
|
||||
assert llm._client.base_url == ( # pyright: ignore[reportPrivateUsage]
|
||||
llm.settings.llm_base_url.rstrip("/") + "/"
|
||||
)
|
||||
assert llm._client._client.timeout.connect == 7.0 # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
def test_llm_client_repoints_on_the_next_save(db: Session) -> None:
|
||||
_save(db, "llm_chat_model", "gpt-first")
|
||||
invalidate_settings()
|
||||
assert chat_api.get_llm().settings.llm_chat_model == "gpt-first"
|
||||
# the NEXT save re-points the singleton (not just the first)
|
||||
_save(db, "llm_chat_model", "gpt-second")
|
||||
invalidate_settings()
|
||||
llm = chat_api.get_llm()
|
||||
assert llm.settings.llm_chat_model == "gpt-second"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the chat concurrency semaphore follows the saved cap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chat_semaphore_follows_saved_cap(db: Session) -> None:
|
||||
base_cap = get_settings().chat_max_concurrent
|
||||
first = 3 if base_cap != 3 else 5
|
||||
_save(db, "chat_max_concurrent", str(first))
|
||||
invalidate_settings()
|
||||
assert get_settings().chat_max_concurrent == first
|
||||
sem = chat_api._get_chat_semaphore()
|
||||
assert sem._value == first # pyright: ignore[reportPrivateUsage]
|
||||
# the next save re-points the semaphore too
|
||||
second = 5 if first == 3 else 3
|
||||
_save(db, "chat_max_concurrent", str(second))
|
||||
invalidate_settings()
|
||||
rebuilt = chat_api._get_chat_semaphore()
|
||||
assert rebuilt is not sem
|
||||
assert rebuilt._value == second # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the kill switch: rows present, switch off → the env values are back
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_kill_switch_returns_env_values(
|
||||
db: Session, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
base = get_settings().input_placeholder
|
||||
_save(db, "input_placeholder", "DB-WINS-PLACEHOLDER")
|
||||
invalidate_settings()
|
||||
assert get_settings().input_placeholder == "DB-WINS-PLACEHOLDER"
|
||||
monkeypatch.setenv("BOR_SETTINGS_DB", "0")
|
||||
invalidate_settings()
|
||||
assert get_settings().input_placeholder == base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the scripts/ consumer: the importer reads the same overlay
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_import_extensions_override_reaches_script_consumer(db: Session) -> None:
|
||||
base = get_settings().import_extension_set
|
||||
# a REAL change — guard against a dev .env that already carries
|
||||
# exactly this set (a no-op would still pass the final assert)
|
||||
if base == {".md", ".txt", ".csv"}:
|
||||
saved, expect = "md,txt,csv,ini", {".md", ".txt", ".csv", ".ini"}
|
||||
else:
|
||||
saved, expect = "md,txt,csv", {".md", ".txt", ".csv"}
|
||||
_save(db, "import_extensions", saved)
|
||||
invalidate_settings()
|
||||
assert get_settings().import_extension_set == expect
|
||||
@@ -88,6 +88,7 @@ SHELL_BACKED_PAGES = {
|
||||
"/history.html": "index.html", # phase 76 task 03
|
||||
"/tokens.html": "index.html", # phase 79 task 06
|
||||
"/theme.html": "index.html", # phase 91 task 04
|
||||
"/settings.html": "index.html", # phase 168 task 05
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Integration: migration 0028 (app_settings) schema contract
|
||||
(phase 168, task 01).
|
||||
|
||||
Drives the **real Alembic engine** against the live dev database
|
||||
(``podman compose up -d db``), mirroring the house pattern of
|
||||
``test_migration_0017.py`` (information_schema assertions on the state
|
||||
the migration must leave). The tests target the 0027 → 0028 step
|
||||
explicitly so later migrations cannot break them:
|
||||
|
||||
* upgrade 0027 → 0028 → the ``app_settings`` table exists with the
|
||||
full column contract — ``key`` VARCHAR(100) NOT NULL and the
|
||||
primary key (one row per BARE ``Settings`` field name; the PK is
|
||||
the uniqueness guard — two rows for one setting would make
|
||||
"which one wins" undefined), ``value`` TEXT NOT NULL (the env-
|
||||
native string form; the reset is a DELETE, never an empty value),
|
||||
``updated_at`` TIMESTAMPTZ NOT NULL with the now() server default
|
||||
(house style) — while the 0027 schema (``documents.
|
||||
page_strip_mode``) survives;
|
||||
* inserted rows round-trip (``relevance_threshold`` / ``"0.55"`` and
|
||||
``import_extensions`` / ``"md,txt"``);
|
||||
* a duplicate ``key`` raises ``IntegrityError`` (the PK pin) and a
|
||||
NULL ``value`` is rejected (NOT NULL);
|
||||
* downgrade to 0027 → the table is GONE (A13 — reversible), the rest
|
||||
of the schema (``documents.page_strip_mode``, ``ui_settings``)
|
||||
survives;
|
||||
* upgrade back to 0028 → the table is back (round-trip).
|
||||
|
||||
The ``alembic`` fixture guarantees the DB ends at head even if a test
|
||||
fails or the process is interrupted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from alembic import command
|
||||
from app.db import db_available
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def alembic(db: Session) -> Iterator[Config]:
|
||||
"""Real Alembic config bound to the dev DB (URL from app settings).
|
||||
|
||||
Starts at head (repairs an interrupted earlier run); teardown
|
||||
upgrades to head no matter what happened, so the dev DB is never
|
||||
left below head.
|
||||
"""
|
||||
if not db_available():
|
||||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
|
||||
cfg.set_main_option("script_location", "alembic")
|
||||
command.upgrade(cfg, "head")
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
def _version(db: Session) -> str | None:
|
||||
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
||||
|
||||
|
||||
def _table_exists(db: Session, table: str) -> bool:
|
||||
return (
|
||||
db.execute(
|
||||
text("SELECT 1 FROM information_schema.tables WHERE table_name = :t"),
|
||||
{"t": table},
|
||||
).scalar()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _column(db: Session, table: str, column: str) -> tuple[Any, ...] | None:
|
||||
"""(data_type, is_nullable, column_default, character_maximum_length)
|
||||
for one table column."""
|
||||
row = db.execute(
|
||||
text(
|
||||
"SELECT data_type, is_nullable, column_default, character_maximum_length"
|
||||
" FROM information_schema.columns"
|
||||
" WHERE table_name = :t AND column_name = :c"
|
||||
),
|
||||
{"t": table, "c": column},
|
||||
).fetchone()
|
||||
return tuple(row) if row is not None else None
|
||||
|
||||
|
||||
def _pk_columns(db: Session, table: str) -> list[str]:
|
||||
"""The table's PRIMARY KEY columns in ordinal position."""
|
||||
rows = db.execute(
|
||||
text(
|
||||
"SELECT kcu.column_name"
|
||||
" FROM information_schema.table_constraints tc"
|
||||
" JOIN information_schema.key_column_usage kcu"
|
||||
" ON kcu.constraint_name = tc.constraint_name"
|
||||
" AND kcu.table_name = tc.table_name"
|
||||
" WHERE tc.table_name = :t"
|
||||
" AND tc.constraint_type = 'PRIMARY KEY'"
|
||||
" ORDER BY kcu.ordinal_position"
|
||||
),
|
||||
{"t": table},
|
||||
).fetchall()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def _clear_rows(db: Session) -> None:
|
||||
db.execute(text("DELETE FROM app_settings"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_upgrade_to_0028_creates_app_settings(db: Session, alembic: Config) -> None:
|
||||
"""Upgrade 0027 → 0028: the table exists with the full column
|
||||
contract (``key`` VARCHAR(100) NOT NULL + the sole PK; ``value``
|
||||
TEXT NOT NULL; ``updated_at`` TIMESTAMPTZ NOT NULL with the now()
|
||||
server default), and the table is ABSENT at 0027 while the 0027
|
||||
``documents`` schema survives the upgrade."""
|
||||
command.downgrade(alembic, "0027") # start from the pre-0028 state
|
||||
assert _version(db) == "0027"
|
||||
assert not _table_exists(db, "app_settings"), (
|
||||
"app_settings must be absent at 0027"
|
||||
)
|
||||
|
||||
command.upgrade(alembic, "0028")
|
||||
assert _version(db) == "0028", "alembic_version must be at 0028"
|
||||
assert _table_exists(db, "app_settings"), "the table must exist at 0028"
|
||||
|
||||
key = _column(db, "app_settings", "key")
|
||||
assert key is not None, "app_settings.key is missing"
|
||||
assert key[0] == "character varying", "key must be VARCHAR"
|
||||
assert key[1] == "NO", "key must be NOT NULL (PK)"
|
||||
assert key[2] is None, "key must have no server default"
|
||||
assert key[3] == 100, "key must be String(100)"
|
||||
|
||||
value = _column(db, "app_settings", "value")
|
||||
assert value is not None, "app_settings.value is missing"
|
||||
assert value[0] == "text", "value must be TEXT (the env-native string form)"
|
||||
assert value[1] == "NO", "value must be NOT NULL (reset is a DELETE, never empty)"
|
||||
|
||||
updated = _column(db, "app_settings", "updated_at")
|
||||
assert updated is not None, "app_settings.updated_at is missing"
|
||||
assert updated[0] == "timestamp with time zone", "updated_at must be TIMESTAMPTZ"
|
||||
assert updated[1] == "NO", "updated_at must be NOT NULL"
|
||||
assert updated[2] is not None and "now" in str(updated[2]), (
|
||||
"updated_at must carry the now() server default (house style)"
|
||||
)
|
||||
|
||||
assert _pk_columns(db, "app_settings") == ["key"], (
|
||||
"key must be the sole primary key (the uniqueness guard —"
|
||||
" two rows for one setting make 'which one wins' undefined)"
|
||||
)
|
||||
|
||||
# The 0027 schema survives the additive upgrade.
|
||||
strip = _column(db, "documents", "page_strip_mode")
|
||||
assert strip is not None, "documents.page_strip_mode (0027) must survive"
|
||||
assert strip[3] == 16, "page_strip_mode must stay String(16)"
|
||||
|
||||
|
||||
def test_inserted_rows_round_trip(db: Session, alembic: Config) -> None:
|
||||
"""At 0028, the env-native string forms round-trip verbatim
|
||||
(``relevance_threshold`` / ``"0.55"``, ``import_extensions`` /
|
||||
``"md,txt"``) and ``updated_at`` is stamped by the server default."""
|
||||
command.upgrade(alembic, "head")
|
||||
try:
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO app_settings (key, value)"
|
||||
" VALUES ('relevance_threshold', '0.55')"
|
||||
)
|
||||
)
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO app_settings (key, value)"
|
||||
" VALUES ('import_extensions', 'md,txt')"
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
rows = db.execute(
|
||||
text(
|
||||
"SELECT key, value, updated_at"
|
||||
" FROM app_settings ORDER BY key"
|
||||
)
|
||||
).fetchall()
|
||||
assert len(rows) == 2, "both rows must be stored"
|
||||
assert rows[0][0] == "import_extensions", "key must round-trip verbatim"
|
||||
assert rows[0][1] == "md,txt", "the CSV string form must round-trip"
|
||||
assert rows[0][2] is not None, "updated_at must be stamped (server default)"
|
||||
assert rows[1][0] == "relevance_threshold"
|
||||
assert rows[1][1] == "0.55", "the numeric string form must round-trip"
|
||||
finally:
|
||||
_clear_rows(db)
|
||||
|
||||
|
||||
def test_duplicate_key_is_rejected(db: Session, alembic: Config) -> None:
|
||||
"""The PK is the uniqueness guard: two rows for one setting would
|
||||
make 'which one wins' undefined, so the second insert must raise
|
||||
``IntegrityError``."""
|
||||
command.upgrade(alembic, "head")
|
||||
try:
|
||||
db.execute(
|
||||
text("INSERT INTO app_settings (key, value) VALUES ('llm_retries', '3')")
|
||||
)
|
||||
db.commit()
|
||||
with pytest.raises(IntegrityError):
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO app_settings (key, value)"
|
||||
" VALUES ('llm_retries', '5')"
|
||||
)
|
||||
)
|
||||
db.rollback() # the IntegrityError aborts the open transaction
|
||||
finally:
|
||||
_clear_rows(db)
|
||||
|
||||
|
||||
def test_null_value_is_rejected(db: Session, alembic: Config) -> None:
|
||||
"""``value`` is NOT NULL: 'clear the override' is a DELETE of the
|
||||
row, never a NULL/empty value, so the resolver never has to
|
||||
distinguish 'no override' from 'override to empty'."""
|
||||
command.upgrade(alembic, "head")
|
||||
try:
|
||||
with pytest.raises(IntegrityError):
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO app_settings (key, value)"
|
||||
" VALUES ('chat_max_concurrent', NULL)"
|
||||
)
|
||||
)
|
||||
db.rollback() # the IntegrityError aborts the open transaction
|
||||
stored = db.execute(
|
||||
text("SELECT count(*) FROM app_settings")
|
||||
).scalar()
|
||||
assert stored == 0, "the rejected row must not be stored"
|
||||
finally:
|
||||
_clear_rows(db)
|
||||
|
||||
|
||||
def test_downgrade_to_0027_drops_the_table(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade 0028 → 0027: the table is gone (A13 — fully
|
||||
reversible) while the rest of the schema survives (the 0027
|
||||
``documents.page_strip_mode`` column, ``ui_settings``,
|
||||
``api_tokens``). The values are re-enterable from the panel —
|
||||
they are an override of the env, not a source of truth."""
|
||||
command.downgrade(alembic, "0027")
|
||||
assert _version(db) == "0027"
|
||||
assert not _table_exists(db, "app_settings"), "app_settings must be dropped"
|
||||
|
||||
strip = _column(db, "documents", "page_strip_mode")
|
||||
assert strip is not None and strip[3] == 16, (
|
||||
"documents.page_strip_mode (0027) must survive the downgrade"
|
||||
)
|
||||
semantic = _column(db, "ui_settings", "ok_bg")
|
||||
assert semantic is not None and semantic[3] == 7, (
|
||||
"ui_settings.ok_bg must survive the downgrade"
|
||||
)
|
||||
token_col = _column(db, "api_tokens", "token_hash")
|
||||
assert token_col is not None and token_col[0] == "character varying", (
|
||||
"api_tokens.token_hash must survive the downgrade"
|
||||
)
|
||||
|
||||
|
||||
def test_upgrade_round_trip_restores_the_table(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade to 0027, then upgrade back to 0028: the table is back
|
||||
with the column contract and PK intact (A13 round-trip)."""
|
||||
command.downgrade(alembic, "0027")
|
||||
command.upgrade(alembic, "0028")
|
||||
assert _version(db) == "0028", "round-trip upgrade must land at 0028"
|
||||
assert _table_exists(db, "app_settings"), "the table must be back"
|
||||
|
||||
key = _column(db, "app_settings", "key")
|
||||
assert key is not None, "app_settings.key must be back"
|
||||
assert key[0] == "character varying", "key must be VARCHAR after the round-trip"
|
||||
assert key[1] == "NO", "key must be NOT NULL after the round-trip"
|
||||
assert key[3] == 100, "key must be String(100) after the round-trip"
|
||||
value = _column(db, "app_settings", "value")
|
||||
assert value is not None, "app_settings.value must be back"
|
||||
assert value[0] == "text", "value must be TEXT after the round-trip"
|
||||
assert value[1] == "NO", "value must be NOT NULL after the round-trip"
|
||||
assert _pk_columns(db, "app_settings") == ["key"]
|
||||
Reference in New Issue
Block a user