Files
brain-of-reese/tests/integration/test_migration_0014.py
T
ducoterra d22d260b8b
Build and Push Containers / build-and-push-app (push) Successful in 5m43s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 91_admin_theme_tab
All verification is complete — this pass needed no code changes. Final report:

**Phase 91 — Admin Theme tab: final verification pass (all 6 tasks already in `complete/`)**

- Verified pre-paint theming end-to-end: `ui_settings` store + resolver, admin `GET/PUT /api/ui-settings`, `CachingMiddleware` inline-`<style id="bor-theme">` injection before `</head>` (incl. `/shared/<token>` prefix branch, unit-pinned), CSP sha256 exemption for the inline tag, Theme tab shell + `theme.js` editor, CSS-file theming fully retired.
- No defects found; zero changes made — working tree left exactly as the task executors left it.
- Tests: `uv run pytest --cov=app` → 1841 passed, 0 failed (TOTAL coverage **99%**; theming/ui_settings/caching all 100%); `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** in isolation.
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings.
- Criteria: (1) unset deployment byte-identical, no `#bor-theme` anywhere — ✓ (unit no-op test + E2E reset byte-compare); `rg "BOR_THEME|themes/"` → single hit is the permitted doc-history comment in `frontend/index.html`. (2) admin-only gate + 403s for anonymous and token users — ✓ (E2E test 3). (3) saved theme inline before `</head>` on every page incl. `/shared/<token>`, computed `--brand` on first paint for admin + anonymous — ✓ (E2E test 2 + unit). (4) reset → byte-identical; 5 contrast pairs warn <4.5:1, non-blocking — ✓ (E2E tests 4–5). (5) suite green, >90% coverage, lint clean — ✓. (6) commit deferred to harness per rules.
- Notable: `.agents/PLAN.md` is absent from the repo — the phase overview's Design section was used as the binding spec; no deviation resulted.
- Next pending phase: **none** — 91 is the last phase in `todo/`.
2026-09-09 17:22:24 -04:00

194 lines
7.9 KiB
Python

"""Integration: migration 0014 (ui_settings) schema contract.
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the house pattern of
``test_migration_0012.py`` (information_schema assertions on the state
the migration must leave). The tests target revision ``0014``
explicitly so later migrations cannot break them:
* upgrade 0013 → 0014 → the ``ui_settings`` table exists with the full
column contract (``id`` INTEGER PK; the 3 strings VARCHAR(300) NULL;
the 8 identity colors VARCHAR(7) NULL — NULL = default, B1); no
server defaults anywhere (a missing row means "defaults");
* an inserted id-1 row round-trips its values (the PUT upsert's shape);
* downgrade to 0013 → the table is gone (A13 — reversible), the rest of
the schema (e.g. ``api_tokens.token_hash``) survives;
* upgrade back to 0014 → 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.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:
count: Any = db.execute(
text(
"SELECT count(*) FROM information_schema.tables"
" WHERE table_schema = 'public' AND table_name = :t"
),
{"t": table},
).scalar()
assert count is not None, "information_schema count must be an int"
return int(count) == 1
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 _insert_row(db: Session, *, app_name: str | None, brand: str | None) -> None:
"""Insert the single row (the PUT upsert's shape) with two values set
and the rest NULL — the NULL = default state the resolver merges."""
db.execute(
text(
"INSERT INTO ui_settings (id, app_name, brand) VALUES (1, :n, :b)"
),
{"n": app_name, "b": brand},
)
db.commit()
def _delete_row(db: Session) -> None:
db.execute(text("DELETE FROM ui_settings WHERE id = 1"))
db.commit()
def test_upgrade_to_0014_adds_ui_settings(db: Session, alembic: Config) -> None:
"""Upgrade 0013 → 0014: the table exists with the full column
contract (the Integer PK, the 3 strings VARCHAR(300) NULL, the 8
colors VARCHAR(7) NULL — no server defaults anywhere: a missing row
means "defaults"); the table is absent at 0013."""
command.downgrade(alembic, "0013") # start from the pre-0014 state
assert _version(db) == "0013"
assert not _table_exists(db, "ui_settings"), "ui_settings must be absent at 0013"
command.upgrade(alembic, "0014")
assert _version(db) == "0014", "alembic_version must be at 0014"
assert _table_exists(db, "ui_settings"), "ui_settings must exist at 0014"
id_col = _column(db, "ui_settings", "id")
assert id_col is not None, "ui_settings.id is missing"
assert id_col[0] == "integer", "ui_settings.id must be INTEGER"
assert id_col[1] == "NO", "ui_settings.id must be NOT NULL (PK)"
for name in ("app_name", "input_placeholder", "footer_text"):
col = _column(db, "ui_settings", name)
assert col is not None, f"ui_settings.{name} is missing"
assert col[0] == "character varying", f"ui_settings.{name} must be VARCHAR"
assert col[1] == "YES", f"ui_settings.{name} must be NULL (env default, B1)"
assert col[2] is None, f"ui_settings.{name} must have no server default"
assert col[3] == 300, f"ui_settings.{name} must be String(300)"
for name in ("bg", "surface", "ink", "ink_soft", "line",
"brand", "brand_soft", "brand_ink"):
col = _column(db, "ui_settings", name)
assert col is not None, f"ui_settings.{name} is missing"
assert col[0] == "character varying", f"ui_settings.{name} must be VARCHAR"
assert col[1] == "YES", f"ui_settings.{name} must be NULL (the built-in, B1)"
assert col[2] is None, f"ui_settings.{name} must have no server default"
assert col[3] == 7, f"ui_settings.{name} must be String(7) — #rrggbb"
def test_inserted_id_1_row_round_trips_values(db: Session, alembic: Config) -> None:
"""At 0014, the single row (id 1, the PUT upsert's shape) round-trips
its set values verbatim and keeps the unset columns NULL."""
command.upgrade(alembic, "head")
_insert_row(db, app_name="Brain of Testy", brand="#818cf8")
try:
row = db.execute(
text(
"SELECT id, app_name, input_placeholder, footer_text, brand"
" FROM ui_settings WHERE id = 1"
)
).fetchone()
assert row is not None, "the ui_settings row must exist"
assert row[0] == 1, "the single row is always id 1"
assert row[1] == "Brain of Testy", "app_name must round-trip verbatim"
assert row[2] is None, "input_placeholder must stay NULL (the default)"
assert row[3] is None, "footer_text must stay NULL (the default)"
assert row[4] == "#818cf8", "brand must round-trip verbatim"
finally:
_delete_row(db)
def test_downgrade_to_0013_drops_the_table(db: Session, alembic: Config) -> None:
"""Downgrade to 0013: the table is gone (A13 — reversible) while the
rest of the schema survives."""
command.downgrade(alembic, "0013")
assert _version(db) == "0013"
assert not _table_exists(db, "ui_settings"), "ui_settings must be dropped"
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"
)
ignore_col = _column(db, "git_sources", "ignore_paths")
assert ignore_col is not None and ignore_col[0] == "jsonb", (
"git_sources.ignore_paths must survive the downgrade"
)
def test_upgrade_round_trip_restores_the_table(db: Session, alembic: Config) -> None:
"""Downgrade to 0013, then upgrade back to 0014: the table is back
with the column contract intact."""
command.downgrade(alembic, "0013")
command.upgrade(alembic, "0014")
assert _version(db) == "0014", "round-trip upgrade must land at 0014"
assert _table_exists(db, "ui_settings"), "ui_settings must be back"
id_col = _column(db, "ui_settings", "id")
assert id_col is not None and id_col[0] == "integer", (
"id must be INTEGER after the round-trip"
)
brand = _column(db, "ui_settings", "brand")
assert brand is not None and brand[1] == "YES", (
"brand must be VARCHAR NULL after the round-trip"
)
assert brand[3] == 7, "brand must be String(7) after the round-trip"