phase: 190_app_name_settings_fix
Build and Push Containers / build-and-push-app (push) Successful in 2m9s
Build and Push Containers / build-and-push-db (push) Successful in 15s

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`.
This commit is contained in:
2026-10-18 02:59:19 -04:00
parent 47456a44df
commit c6ffa050a6
59 changed files with 4104 additions and 556 deletions
+214 -23
View File
@@ -17,6 +17,21 @@ The auth + public-contract half of task 01, driven through the real app
``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
@@ -30,12 +45,30 @@ from fastapi.testclient import TestClient
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.config import get_settings
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 UiSettings
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]:
@@ -60,6 +93,44 @@ def _admin_client() -> TestClient:
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)."""
@@ -94,7 +165,10 @@ def test_token_user_get_and_put_403(client: TestClient) -> None:
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 values, and a follow-up GET reads them back."""
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")
@@ -109,12 +183,12 @@ def test_admin_get_and_put_200(client: TestClient, db: Session) -> None:
json={"app_name": "Reese Brain", "brand": "#818cf8"},
)
assert r.status_code == 200, r.text
assert r.json()["app_name"] == "Reese Brain"
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"] == "Reese Brain"
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
@@ -248,31 +322,148 @@ def test_api_config_carries_no_theme_key(client: TestClient) -> None:
def test_api_config_returns_db_strings_after_admin_put(
client: TestClient, db: Session
client: TestClient, db: Session, overlay_panel: None
) -> None:
"""B1 with a set row: after an admin PUT, the ANONYMOUS /api/config
(the frontend's boot fetch — no admin needed) serves the DB strings
over the env values; the untouched fields keep the env values; the
five-key set is unchanged (the retired ``theme`` key is absent)."""
admin = _admin_client()
r = admin.put(
"/api/ui-settings",
json={
"app_name": "Brain of Testy",
"input_placeholder": "Ask the vault…",
"footer_text": "Powered by my own models",
},
)
assert r.status_code == 200, r.text
"""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"] == "Brain of Testy"
assert body["input_placeholder"] == "Ask the vault…"
assert body["footer_text"] == "Powered by my own models"
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"]