Files
brain-of-reese/tests/integration/test_migration_0028.py
T
ducoterra 1200437bf8
Build and Push Containers / build-and-push-app (push) Successful in 4m4s
Build and Push Containers / build-and-push-db (push) Successful in 15s
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`.
2026-10-10 01:05:14 -04:00

284 lines
11 KiB
Python

"""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"]