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/`.
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user