Files
brain-of-reese/tests/integration/test_ui_settings_api.py
T
ducoterra c6ffa050a6
Build and Push Containers / build-and-push-app (push) Successful in 2m9s
Build and Push Containers / build-and-push-db (push) Successful in 15s
phase: 190_app_name_settings_fix
All gates re-verified green. Report:

- **Verified (all 4 tasks already complete; this was the final pass):** `effective_settings` resolves the 3 brand strings panel-row → tab-row → env (`applied_override_keys`, presence-not-value); `PUT /api/ui-settings` ignores string body fields; Theme-tab form is colors-only (17 pickers, no `#theme-app-name`/`#theme-placeholder`/`#theme-footer`); the ONE shared `applyBrandFromConfig` re-applies title/`.brand-text`/footer/placeholder after a panel save-reset and a tab save-reset; boot path byte-identical (single `/api/config`).
- **Completion criteria:**
  - strings order panel→tab→env, both branches — ✅ `tests/unit/test_theming.py::test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row`, `..._fall_back_to_ui_settings_row_without_panel_row`, `..._per_field_panel_then_tab_then_env`, `..._ignore_panel_rows_when_the_overlay_is_off`
  - PUT ignores string fields + no 3 inputs in the tab — ✅ `tests/integration/test_ui_settings_api.py::test_put_ignores_string_fields_in_body` (+2) and the E2E `test_theme_tab_dom_is_colors_only`
  - live apply without reload + incident regression — ✅ `tests/e2e/test_app_name_settings_fix.py` tests 1–4
  - byte-identical boot, no double-apply — ✅ `tests/unit/test_brand_live.py` markers + E2E "exactly one /api/config"
  - suite green, coverage >90%, ruff+pyright clean — ✅
  - no behavior change in prior phases — ✅ (palette, phase-168 panel, env fallback suites all green)
  - atomic commit — **left to the harness** (pipeline rule overrides per-phase committing; no git write run)
- **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed, TOTAL 6941 16 99%**, exit 0 · `uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov` → **6 passed** in isolation · regression E2E (isolated, one process each, all exit 0): `test_smoke` `test_configurable_brand` `test_admin_theme_tab` `test_theme_save_and_coverage` `test_theme_semantic_completion` `test_themed_favicon` `test_dark_tech_theme` `test_admin_settings_panel` `test_admin_settings_layout` `test_settings_mobile_value_width` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors.
- **Documentation:** README — fixed "saving one repaints *every* open page" (only the open document repaints; other pages on next load) and a broken paragraph wrap in *Customizing the look*; AGENTS.md rule 16 — `test_chunks_hnsw_recall.py` now described accurately (index paths forced with `enable_seqscan`/`indexonlyscan`/`bitmapscan` off, unforced plan asserted only on a pristine relation); `tests/unit/test_theme_frontend.py` — two stale section headers ("20 form fields"/"20 inputs" → 17 color). No AGENTS.md settings-overlay/resolution-order clause exists, so per the phase none was added. Full env-var cross-check (`BOR_*` in `app/config.py` vs README) → 0 gaps; 0 missing file references.
- **Notable:** the working tree also carries this phase's flake hardening of the phase-188 `tests/integration/test_chunks_hnsw_recall.py` (verified legit and green). Live infra untouched; shared DB left clean (0 `app_settings`, 0 `ui_settings`). **Flagged, not changed:** `settings_overlay.applied_override_keys` lets a `ProgrammingError` (missing `app_settings`) propagate from public `GET /api/config`, where `load_overrides` deliberately fails soft — only reachable on an un-migrated (0027) DB, outside this phase's pinned A1 semantics, so left for the owner to rule on rather than silently deviating.
- **Next pending phase:** `191_source_name_labels_tags`.
2026-10-18 02:59:19 -04:00

470 lines
21 KiB
Python

"""Integration: the admin UI-settings gate + the /api/config effective
strings (phase 91, task 01).
The auth + public-contract half of task 01, driven through the real app
(TestClient keeps the cookie jar — the house ``test_auth_api`` /
``test_tokens_api`` admin-login pattern):
* the admin gate — ``GET /api/ui-settings`` and ``PUT`` are 403
``admin only`` for anonymous callers AND for a signed-in token USER
(role ``"user"`` — the router-wide ``require_admin`` closes the
surface on every method, the phase-79 token matrix contract), 200 for
the admin on both;
* ``/api/config`` effective strings (B1: DB-over-env) — an env-only
deployment (no ``ui_settings`` row) returns the env strings; after an
admin PUT, the ANONYMOUS ``/api/config`` returns the DB strings;
* the five-key /api/config contract: the retired CSS-file theming's
``theme`` key is GONE (task 03) — the app metadata, the docs flag,
and the two effective strings are the entire response.
Phase 190 (tasks 01 + 02) adds the brand-string OWNERSHIP pins (A1): the
Settings-panel row (``app_settings``, phase 168) beats a conflicting
``ui_settings`` (Theme-tab) row — the ``PUT /api/ui-settings`` string
bake is dead (the PUT ignores string fields in the body and never
writes the string columns), the GET reports the EFFECTIVE (panel)
strings read-only, and the anonymous ``/api/config`` boot fetch serves
the panel's name over a legacy baked-in tab row. Task 01 pinned those
three as documented-RED ``xfail(strict=True)`` cases; task 02 flipped
the resolution order and stopped the bake, so the markers are gone and
all three are GREEN. The colors half of the PUT contract stayed GREEN
throughout (the 17 colors, their normalization and the upsert behave
exactly as phases 91/92/93 left them); the STRINGS half of the older
phase-91 pins moved with the write path — a body string no longer
round-trips, it is ignored.
Real Postgres (``podman compose up -d db``); no LLM involved.
Requires: podman compose up -d db
"""
from __future__ import annotations
from collections.abc import Iterator
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.config import get_settings, invalidate_settings
from app.core import theming
from app.main import app as fastapi_app
from app.models import AppSetting, UiSettings
from tests.conftest import ADMIN_PASSWORD
#: The three brand strings and their distinct panel/tab/body values
#: (phase 190) — one triple per field so every pin reads as one story.
_PANEL_STRINGS = {
"app_name": "Panel Name",
"input_placeholder": "Panel placeholder…",
"footer_text": "Panel footer",
}
_TAB_STRINGS = {
"app_name": "Tab Name",
"input_placeholder": "Tab placeholder…",
"footer_text": "Tab footer",
}
_BODY_STRINGS = {
"app_name": "Baked By PUT",
"input_placeholder": "Baked placeholder…",
"footer_text": "Baked footer",
}
@pytest.fixture(autouse=True)
def clean_state(db: Session) -> Iterator[None]:
"""Both touched tables are global state: the single ui_settings row
and the api_tokens the token-user test creates (the house
TRUNCATE/DELETE reset pattern)."""
db.execute(text("DELETE FROM ui_settings"))
db.execute(text("TRUNCATE api_tokens"))
db.commit()
yield
db.execute(text("DELETE FROM ui_settings"))
db.execute(text("TRUNCATE api_tokens"))
db.commit()
def _admin_client() -> TestClient:
"""A fresh client signed in as the admin (the ``_admin_client``
pattern from test_auth_api.py)."""
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
@pytest.fixture()
def overlay_panel(db: Session, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""Phase 190: the settings overlay ON with an empty ``app_settings``
table (the ``test_admin_settings_api`` idiom) — the Settings panel's
write path is only IN EFFECT through the overlay, so the pins that
plant panel rows need the switch on. Teardown deletes the rows and
re-resolves the cache — NO row and no stale resolution may survive
this module (conftest pins the overlay OFF for every other suite)."""
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 _seed_tab_row(db: Session, **colors: str) -> None:
"""The single ``ui_settings`` row with the three TAB strings, plus
any colors the pin needs — committed directly (after task 02 the
API no longer writes strings, so the seed must not depend on the
behavior under test)."""
db.add(UiSettings(id=1, **_TAB_STRINGS, **colors))
db.commit()
def _seed_panel_rows(db: Session) -> None:
"""The three Settings-panel rows, committed (the panel writes them
through ``PUT /api/admin/settings/{key}`` — the resolver only ever
sees the table), then the settings cache is cleared so the next
resolution layers them over the env base."""
db.add_all(AppSetting(key=key, value=value) for key, value in _PANEL_STRINGS.items())
db.commit()
invalidate_settings()
def test_anonymous_get_and_put_403(client: TestClient) -> None:
"""Router-level ``require_admin``: both routes are 403 ``admin only``
for the unsigned-in caller (one fixed detail — no enumeration)."""
r = client.get("/api/ui-settings")
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
r = client.put("/api/ui-settings", json={"app_name": "nope"})
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
def test_token_user_get_and_put_403(client: TestClient) -> None:
"""A signed-in token USER (role ``"user"``) is NOT the admin: the
Theme tab's surface is closed to them on both methods (the
phase-79 token matrix contract — only the admin themes the
deployment)."""
admin = _admin_client()
r = admin.post("/api/tokens", json={"label": "alice"})
assert r.status_code == 201, r.text
token = r.json()["token"]
assert client.post("/api/token-auth", json={"token": token}).status_code == 204
assert client.get("/api/whoami").json() == {"authenticated": True, "role": "user"}
r = client.get("/api/ui-settings")
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
r = client.put("/api/ui-settings", json={"brand": "#123456"})
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
def test_admin_get_and_put_200(client: TestClient, db: Session) -> None:
"""The admin passes the gate on both methods: GET reports the
effective defaults (row missing), PUT persists + reports the new
effective COLORS, and a follow-up GET reads them back. Phase 190
(A1): the PUT body carries a brand string and the response reports
the ENV one instead — the string is ignored, the palette half of the
phase-91 round trip is unchanged."""
client.post("/api/login", json={"password": ADMIN_PASSWORD})
r = client.get("/api/ui-settings")
assert r.status_code == 200
body = r.json()
assert set(body) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS)
assert body["app_name"] == get_settings().app_name
assert {k: body[k] for k in theming.COLOR_FIELDS} == theming.BUILTIN_COLORS
r = client.put(
"/api/ui-settings",
json={"app_name": "Reese Brain", "brand": "#818cf8"},
)
assert r.status_code == 200, r.text
assert r.json()["app_name"] == get_settings().app_name # the string is ignored
assert r.json()["brand"] == "#818cf8"
r = client.get("/api/ui-settings")
assert r.status_code == 200
assert r.json()["app_name"] == get_settings().app_name # never stored (A1)
assert r.json()["brand"] == "#818cf8"
# Untouched fields stay at their defaults (DB-over-env / -built-in).
assert r.json()["footer_text"] == get_settings().footer_text
assert r.json()["bg"] == theming.BUILTIN_COLORS["bg"]
def test_admin_grid_line_validation_and_normalization(
client: TestClient, db: Session
) -> None:
"""Phase 92 (task 01): the 9th identity color against the LIVE API —
a bad hex is a 422 naming ``grid_line`` (same fixed detail as the
other 8); the built-in value stores NULL (the response still
reports the built-in — the no-op normalization); a non-built-in
value is stored and reported back. The admin gate itself is pinned
unchanged by the tests above (router-wide ``require_admin``)."""
client.post("/api/login", json={"password": ADMIN_PASSWORD})
r = client.put("/api/ui-settings", json={"grid_line": "nope"})
assert r.status_code == 422, r.text
assert r.json()["detail"] == "grid_line must be a #rrggbb hex color"
r = client.put("/api/ui-settings", json={"grid_line": "#4a2626"})
assert r.status_code == 200, r.text
assert r.json()["grid_line"] == theming.BUILTIN_COLORS["grid_line"]
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
assert row is not None, "the PUT upsert creates the id-1 row"
assert row.grid_line is None # built-in → NULL normalization
r = client.put("/api/ui-settings", json={"grid_line": "#123123"})
assert r.status_code == 200, r.text
assert r.json()["grid_line"] == "#123123"
r = client.get("/api/ui-settings")
assert r.status_code == 200
assert r.json()["grid_line"] == "#123123" # the stored value reads back
assert len(r.json()) == 20 # the 20-value shape (17 colors + 3 strings, phase 93)
def test_admin_semantic_fields_round_trip(client: TestClient, db: Session) -> None:
"""Phase 93 (task 01): the 8 semantic state colors against the LIVE
API — a bad hex is a 422 naming the field (same fixed detail as the
identity colors); a non-built-in value is lowercased on store and
reads back through GET; a built-in value stores NULL (the response
still reports the built-in); an absent (null) field stores NULL.
The response is the effective values for all 20 keys."""
client.post("/api/login", json={"password": ADMIN_PASSWORD})
r = client.put("/api/ui-settings", json={"ok_bg": "not-a-color"})
assert r.status_code == 422, r.text
assert r.json()["detail"] == "ok_bg must be a #rrggbb hex color"
r = client.put(
"/api/ui-settings",
json={"ok_ink": "#444444", "accent_bg": "#222222", "err_line": "#EFEFEF"},
)
assert r.status_code == 200, r.text
assert r.json()["ok_ink"] == "#444444" # stored + reported
assert r.json()["accent_bg"] == "#222222" # stored + reported
assert r.json()["err_line"] == "#efefef" # upper → lowercased on store
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
assert row is not None, "the PUT upsert creates the id-1 row"
assert row.ok_ink == "#444444"
assert row.accent_bg == "#222222"
assert row.err_line == "#efefef" # the stored column, lowercase
assert row.err_bg is None # absent (null) field → NULL
assert row.accent_ink is None # absent (null) field → NULL
r = client.get("/api/ui-settings")
assert r.status_code == 200
body = r.json()
assert set(body) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS)
assert len(body) == 20
# GET returns the EFFECTIVE values: the stored ones over the
# built-ins for the untouched 14 colors.
assert body["ok_ink"] == "#444444"
assert body["err_bg"] == theming.BUILTIN_COLORS["err_bg"]
assert body["accent_ink"] == theming.BUILTIN_COLORS["accent_ink"]
untouched = [k for k in theming.COLOR_FIELDS if k not in ("ok_ink", "accent_bg", "err_line")]
assert {k: body[k] for k in untouched} == {
k: theming.BUILTIN_COLORS[k] for k in untouched
} # the 14 untouched colors report their built-ins
# The built-in → NULL normalization: PUT the built-in back (one in
# uppercase) — the row's semantic columns return to NULL and the
# effective values are still the built-ins (no-op contract).
body_put = {"ok_ink": theming.BUILTIN_COLORS["ok_ink"].upper(),
"accent_bg": theming.BUILTIN_COLORS["accent_bg"],
"err_line": theming.BUILTIN_COLORS["err_line"]}
r = client.put("/api/ui-settings", json=body_put)
assert r.status_code == 200, r.text
db.expire_all()
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
assert row is not None
assert row.ok_ink is None # built-in (uppercase in) → NULL
assert row.accent_bg is None # built-in → NULL
assert row.err_line is None # built-in → NULL
for key in theming.COLOR_FIELDS:
assert r.json()[key] == theming.BUILTIN_COLORS[key]
def _config_keys() -> set[str]:
"""The /api/config key set after task 03 (phase 91): the retired
CSS-file theming's ``theme`` key is gone; phase 122 (task 01) added
the ``images`` flag — the six keys below are the contract."""
return {"app_name", "version", "docs_repo_configured",
"images", "input_placeholder", "footer_text"}
def test_api_config_env_only_deployment_returns_env_strings(client: TestClient) -> None:
"""B1 with an empty ui_settings table: /api/config serves the ENV
strings (the code defaults — conftest pins them) and the key set is
the five-key contract (the retired theming's ``theme`` key is gone
— task 03)."""
r = client.get("/api/config")
assert r.status_code == 200
body = r.json()
assert set(body) == _config_keys()
assert body["app_name"] == get_settings().app_name
assert body["input_placeholder"] == get_settings().input_placeholder
assert body["footer_text"] == get_settings().footer_text
def test_api_config_carries_no_theme_key(client: TestClient) -> None:
"""Phase 91 (task 03): the retired CSS-file theming left NO trace
in the endpoint — the response has no ``theme`` key at all (an
env-only deployment and a themed one answer with the same keys; the
colors are injected pre-paint, they never ride this fetch)."""
r = client.get("/api/config")
assert r.status_code == 200
assert "theme" not in r.json()
def test_api_config_returns_db_strings_after_admin_put(
client: TestClient, db: Session, overlay_panel: None
) -> None:
"""B1 with a set row: once the admin has saved the three brand
strings, the ANONYMOUS /api/config (the frontend's boot fetch — no
admin needed) serves them over the env values; the untouched fields
keep the env values; the six-key set is unchanged (the retired
``theme`` key is absent).
Phase 190 (A1) moved the writer: the strings are seeded through the
Settings panel's surface (``app_settings`` + the overlay, the phase
168 ``PUT /api/admin/settings/{key}`` rows), because the Theme-tab
PUT no longer writes them — the DB-over-env contract this pin owns
is the same one, now owned by the panel."""
_seed_panel_rows(db)
r = client.get("/api/config")
assert r.status_code == 200 # /api/config stays PUBLIC (no gate)
body = r.json()
assert set(body) == _config_keys()
assert body["app_name"] == _PANEL_STRINGS["app_name"]
assert body["input_placeholder"] == _PANEL_STRINGS["input_placeholder"]
assert body["footer_text"] == _PANEL_STRINGS["footer_text"]
# The colors never ride /api/config (the pre-paint injection is
# task 02; brand.js's surface is the five keys).
assert "theme" not in body
assert body["version"] == get_settings().app_version
# ---------- phase 190 (task 01): the brand-string ownership (A1) ----------
def test_put_ignores_string_fields_in_body(
client: TestClient, db: Session
) -> None:
"""A1 (owner-confirmed 2026-10-18): the Theme-tab PUT no longer
writes the three brand strings — a body carrying ``app_name`` /
``input_placeholder`` / ``footer_text`` DIFFERENT from the stored
row's values (and from the effective value) is still 200, but the
stored row's string columns stay EXACTLY as they were (re-GET shows
the old values, a direct row read shows no write). An old client's
save can therefore never re-bake a string and shadow the Settings
panel again.
GREEN since task 02 (phase 190 task 01 pinned it as a strict
``xfail`` on the defect: ``_validate_strings`` used to trim and
store every string in the body — that bake is the mechanism of the
``TODO.md`` L3 incident).
"""
_seed_tab_row(db, bg="#101010")
admin = _admin_client()
r = admin.put("/api/ui-settings", json={**_BODY_STRINGS, "bg": "#001122"})
assert r.status_code == 200, r.text
# The strings half — pre-fix the response round-trips the baked
# body value ("Baked By PUT"); post-fix the stored value stands.
assert r.json()["app_name"] == _TAB_STRINGS["app_name"]
assert r.json()["input_placeholder"] == _TAB_STRINGS["input_placeholder"]
assert r.json()["footer_text"] == _TAB_STRINGS["footer_text"]
# The stored row itself was never touched (not just the response).
db.expire_all()
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
assert row is not None
assert row.app_name == _TAB_STRINGS["app_name"]
assert row.input_placeholder == _TAB_STRINGS["input_placeholder"]
assert row.footer_text == _TAB_STRINGS["footer_text"]
# A follow-up GET shows the OLD values, not the body's.
r = admin.get("/api/ui-settings")
assert r.status_code == 200
assert r.json()["app_name"] == _TAB_STRINGS["app_name"]
def test_put_still_stores_the_colors_beside_the_ignored_strings(
client: TestClient, db: Session
) -> None:
"""The colors half of the same contract — GREEN pre-phase AND
post-phase: the 17 colors in the same body as the ignored strings
ARE stored and reported (the Theme tab stays a colors surface; the
string drop does not narrow the palette half by one color)."""
_seed_tab_row(db, bg="#101010")
admin = _admin_client()
r = admin.put("/api/ui-settings", json={**_BODY_STRINGS, "bg": "#001122"})
assert r.status_code == 200, r.text
assert r.json()["bg"] == "#001122" # GREEN pre-fix and post-fix
db.expire_all()
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
assert row is not None
assert row.bg == "#001122" # GREEN pre-fix and post-fix
assert row.brand is None # an unsent color stays NULL (the built-in reports)
r = admin.get("/api/ui-settings")
assert r.status_code == 200
assert r.json()["bg"] == "#001122"
def test_get_reports_effective_strings_read_only(
client: TestClient, db: Session, overlay_panel: None
) -> None:
"""A1 reaching the API: with a conflicting Settings-panel row,
``GET /api/ui-settings`` reports the EFFECTIVE (panel) value for
each string — the tab's read side stays the live view (old-client
compat), and it stays READ-ONLY (the GET never writes the row).
GREEN since task 02 flipped the order (phase 190 task 01 pinned it
as a strict ``xfail``: the endpoint used to report the baked
``ui_settings`` value).
"""
_seed_tab_row(db)
_seed_panel_rows(db)
admin = _admin_client()
r = admin.get("/api/ui-settings")
assert r.status_code == 200, r.text
body = r.json()
assert body["app_name"] == _PANEL_STRINGS["app_name"]
assert body["input_placeholder"] == _PANEL_STRINGS["input_placeholder"]
assert body["footer_text"] == _PANEL_STRINGS["footer_text"]
# The 20-value shape is unchanged (17 colors + 3 strings).
assert set(body) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS)
# Read-only: the GET never wrote the effective values into the row.
db.expire_all()
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
assert row is not None
assert row.app_name == _TAB_STRINGS["app_name"]
def test_api_config_reports_panel_strings_over_the_baked_tab_row(
client: TestClient, db: Session, overlay_panel: None
) -> None:
"""The owner-visible incident through the frontend's boot fetch
(phase 190, ``TODO.md`` L3): with a panel row AND a legacy baked-in
``ui_settings`` row, the ANONYMOUS ``GET /api/config`` — the very
call ``brand.js`` drives the title / brand / footer / placeholder
from — serves the Settings-panel strings, not the baked tab values.
GREEN since task 02 (phase 190 task 01 pinned it as a strict
``xfail``: pre-fix ``effective_settings`` resolved the tab row
first, so the open page kept the OLD name after every later panel
change — the incident as the browser sees it).
"""
_seed_tab_row(db)
_seed_panel_rows(db)
r = client.get("/api/config")
assert r.status_code == 200 # public, unchanged key set
body = r.json()
assert set(body) == _config_keys()
assert body["app_name"] == _PANEL_STRINGS["app_name"]
assert body["input_placeholder"] == _PANEL_STRINGS["input_placeholder"]
assert body["footer_text"] == _PANEL_STRINGS["footer_text"]