phase: 191_source_name_labels_tags
Build and Push Containers / build-and-push-app (push) Successful in 7m24s
Build and Push Containers / build-and-push-db (push) Successful in 36s

All gates re-verified against a stable tree. Report:

**Verified (all 6 tasks in `complete/`; this was the final pass)**
- `SourceLabel`+alembic `0030` (dev DB `alembic current` = `0030 (head)`, table present, 0 rows); admin-only `GET/PUT/DELETE /api/source-labels` (`require_admin` router-wide, `resolve_source_name`, partial-update caps/404s); Sources-page "Names, labels and tags" card (read-only derived-name `<code>`, per-row `saveInFlight`, 404 note that survives the settle, chips reuse `.git-source-hidden-count`); `question_name_hits` D2 rule; ONE `all_labels` read per turn in `chat.py` feeding both the `source_name_hit` flag and the prompt surfaces; `source_name_hit_bonus=0.005` in `_selection_order`; ` name=`/` label=` in `build_high_prompt` + `build_overview_prompt(names=…)`.
- **Completion criteria:** E2E `tests/e2e/test_source_name_labels.py` ✅ 7 passed (arms a–e: editor all kinds, control/named question, rename-never-breaks-identity, 404 note, reset) · full suite ✅ · coverage ✅ · README/AGENTS ✅ · phase dir in `complete/` ✅ · commit left to the harness.
- **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 7160 stmts / 16 missed = 99%** (>90%) · `uv run pytest tests/e2e/test_source_name_labels.py -v --no-cov` → **7 passed, exit 0** · regression E2E isolated, exit 0: `test_document_viewer`, `test_admin_auth`, `test_api_tokens`, `test_git_sources_admin`, `test_kb_tree`, `test_local_directory_sources` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors.
- **Battery (task 04 criterion):** `uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` at default vs `BOR_SOURCE_NAME_HIT_BONUS=0` → **`diff` empty (line-for-line identical)**, matching the recorded arms (dev KB is empty, so every question deflects; the observable boost is unit/E2E-pinned).
- **Documentation:** README — fixed the `/git-sources.html` rows (nav label is **Sources**) and cross-linked the phase-191 card from that section; full audit otherwise clean: all 86 `BOR_*` settings documented with defaults matching `app/config.py`, 0 broken file/dir references and 0 broken anchors in README+AGENTS, AGENTS rule 17 re-checked line-by-line against the code.
- **Notable / deviations:** (1) a **concurrent pipeline process** (`phased-execution/progress.mjs` for `191_…_00_phase`) was mutating this same working tree mid-pass — it rewrote `tests/e2e/test_source_name_labels.py` (11→7 tests), `frontend/assets/git-sources.js`, `tests/unit/test_source_labels_editor_ui.py` and `AGENTS.md` at 05:27; every gate above was (re-)run after those edits with the tree no source file newer than the green full run. (2) The task-06 report/phase text claims regression suites `test_chat_token_gate.py`, `test_git_sources_page.py`, `test_rag_browser.py` are green — **those files have never existed in git history** (those runs exit 4); I ran the real equivalents listed above instead (phase files are not mine to edit). (3) **No live changes**: the VM DB already has `source_labels` (0 rows) but the deployed app predates this uncommitted code, so deploying `/api/source-labels` to the live service is the owner's post-commit step, not part of this phase's criteria.
- **Next pending phase:** none — `191_source_name_labels_tags` is the only entry in `.agents/phases/todo/`.
This commit is contained in:
2026-10-19 06:13:32 -04:00
parent c6ffa050a6
commit 4f20c256d7
70 changed files with 7378 additions and 50 deletions
+17 -3
View File
@@ -236,7 +236,17 @@ def test_invalid_index_is_dropped_and_rebuilt(db: Session, alembic: Config) -> N
data, and timing a real interruption is nondeterministic; the dev/
test DB role is a superuser, and a role without catalog UPDATE
rights skips cleanly instead of leaving the DB inconsistent (the
stamp that follows the forced state, never before it)."""
stamp that follows the forced state, never before it).
Phase 191 fix: the stamp moves the version table ALL the way below
0029, so every LATER migration's objects must be genuinely dropped
first (``downgrade("0029")``) — a stamped-below head with a later
migration's table still physically present would make every
following ``upgrade(head)`` re-run that migration and die on
``relation already exists`` (observed with 0030's
``source_labels`` once it landed). The only lie that stays between
the stamp and the re-run is the corrupted index — exactly the
state this test is about."""
command.upgrade(alembic, "head")
assert _index_valid(db) is True, "precondition: a valid index to corrupt"
@@ -253,8 +263,12 @@ def test_invalid_index_is_dropped_and_rebuilt(db: Session, alembic: Config) -> N
db.rollback()
pytest.skip(f"forcing INVALID needs catalog UPDATE rights: {exc}")
# The stamp moves the version table WITHOUT touching the index, so
# the next upgrade() re-runs 0029 against the corrupted state.
# Drop every post-0029 migration's objects for real BEFORE the
# stamp (the phase-191 note above), then move the version table
# WITHOUT touching the index, so the next upgrade() re-runs 0029
# against the corrupted state and head is re-derivable cleanly.
db.rollback() # release the session before the DDL (house pattern)
command.downgrade(alembic, "0029")
command.stamp(alembic, "0028")
assert _version(db) == "0028"
assert _index_valid(db) is False, "the forced INVALID state must be visible"
+311
View File
@@ -0,0 +1,311 @@
"""Integration: migration 0030 (source_labels) schema contract
(phase 191, task 01).
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the house pattern of
``test_migration_0028.py`` (information_schema assertions on the state
the migration must leave). The tests target the 0029 → 0030 step
explicitly so later migrations cannot break them:
* upgrade 0029 → 0030 → the ``source_labels`` table exists with the
full column contract (D1) — ``source`` VARCHAR(120) NOT NULL and the
sole primary key (one label row per source; the join key is the
DERIVED ``documents.source`` name — an alias key, never a rename),
``display_name`` / ``label`` TEXT nullable (the ≤120 / ≤200 caps are
API-boundary concerns, the phase-121 plain-column pattern), ``tags``
JSONB NOT NULL with the ``'[]'`` server default (the phase-89
``ignore_paths`` shape — a source with no tags reads ``[]``, never
NULL), ``updated_at`` TIMESTAMPTZ NOT NULL with the now() server
default (house style) — while the 0029 schema
(``ix_chunks_embedding_hnsw``) survives;
* the NO-BACKFILL contract: right after the upgrade the table is EMPTY
(a source without a row behaves exactly as pre-phase);
* inserted rows round-trip (name/label/tags verbatim, ``updated_at``
stamped by the server default);
* a duplicate ``source`` raises ``IntegrityError`` (the PK pin) and a
NULL ``tags`` is rejected (NOT NULL — the reset is ``[]``, never
NULL);
* downgrade to 0029 → the table is GONE (A13 — reversible), the rest of
the schema (the HNSW index, ``app_settings``) survives;
* upgrade back to 0030 → 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:
# Release the test session's open transaction BEFORE the repair
# DDL (the 0028 fixture's lesson): psycopg3 keeps its snapshot
# alive while idle-in-transaction, and a later 0029
# CREATE INDEX CONCURRENTLY would wait on it forever.
db.rollback()
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 _hnsw_index_exists(db: Session) -> bool:
return (
db.execute(
text(
"SELECT 1 FROM pg_indexes"
" WHERE indexname = 'ix_chunks_embedding_hnsw'"
)
).scalar()
is not None
)
def _clear_rows(db: Session) -> None:
db.execute(text("DELETE FROM source_labels"))
db.commit()
def test_upgrade_to_0030_creates_source_labels(db: Session, alembic: Config) -> None:
"""Upgrade 0029 → 0030: the table exists with the full D1 column
contract, the table is ABSENT at 0029, and the 0029 HNSW index
survives the additive upgrade."""
command.downgrade(alembic, "0029") # start from the pre-0030 state
assert _version(db) == "0029"
assert not _table_exists(db, "source_labels"), (
"source_labels must be absent at 0029"
)
command.upgrade(alembic, "0030")
assert _version(db) == "0030", "alembic_version must be at 0030"
assert _table_exists(db, "source_labels"), "the table must exist at 0030"
source = _column(db, "source_labels", "source")
assert source is not None, "source_labels.source is missing"
assert source[0] == "character varying", "source must be VARCHAR"
assert source[1] == "NO", "source must be NOT NULL (PK)"
assert source[2] is None, "source must have no server default"
assert source[3] == 120, "source must be String(120) (mirrors documents.source)"
display = _column(db, "source_labels", "display_name")
assert display is not None, "source_labels.display_name is missing"
assert display[0] == "text", "display_name must be plain TEXT (caps are API-side)"
assert display[1] == "YES", "display_name must be nullable (NULL = no name)"
label = _column(db, "source_labels", "label")
assert label is not None, "source_labels.label is missing"
assert label[0] == "text", "label must be plain TEXT (caps are API-side)"
assert label[1] == "YES", "label must be nullable (NULL = no label)"
tags = _column(db, "source_labels", "tags")
assert tags is not None, "source_labels.tags is missing"
assert tags[0] == "jsonb", "tags must be JSONB (the ignore_paths shape)"
assert tags[1] == "NO", "tags must be NOT NULL"
assert tags[2] is not None and "[]" in str(tags[2]), (
"tags must carry the '[]' server default (no tags reads [], never NULL)"
)
updated = _column(db, "source_labels", "updated_at")
assert updated is not None, "source_labels.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, "source_labels") == ["source"], (
"source must be the sole primary key (one label row per source)"
)
# The NO-BACKFILL contract (D1): the migration creates an EMPTY
# table — every source keeps its pre-phase "no row" behavior.
count = db.execute(text("SELECT count(*) FROM source_labels")).scalar()
assert count == 0, "0030 must not backfill any label rows"
# The 0029 schema survives the additive upgrade.
assert _hnsw_index_exists(db), "ix_chunks_embedding_hnsw (0029) must survive"
def test_inserted_rows_round_trip(db: Session, alembic: Config) -> None:
"""At head, the D1 values round-trip verbatim (name / label / tags)
and ``updated_at`` is stamped by the server default."""
command.upgrade(alembic, "head")
try:
db.execute(
text(
"INSERT INTO source_labels (source, display_name, label, tags)"
" VALUES ('gitea-forge', 'the git server',"
" 'self-hosted forge + Gitea', '[\"gitea\", \"forge\"]'::jsonb)"
)
)
db.execute(
text(
"INSERT INTO source_labels (source)"
" VALUES ('bare-row')"
)
)
db.commit()
rows = db.execute(
text(
"SELECT source, display_name, label, tags, updated_at"
" FROM source_labels ORDER BY source"
)
).fetchall()
assert len(rows) == 2, "both rows must be stored"
assert rows[0][0] == "bare-row"
assert rows[0][1] is None and rows[0][2] is None
assert rows[0][3] == [], "the server default must read as the empty list"
assert rows[0][4] is not None, "updated_at must be stamped (server default)"
assert rows[1][0] == "gitea-forge", "source must round-trip verbatim"
assert rows[1][1] == "the git server"
assert rows[1][2] == "self-hosted forge + Gitea"
assert rows[1][3] == ["gitea", "forge"], "the tags array must round-trip"
finally:
_clear_rows(db)
def test_duplicate_source_is_rejected(db: Session, alembic: Config) -> None:
"""The PK is the uniqueness guard: two label rows for one source
would make "which name wins" undefined, so the second insert must
raise ``IntegrityError``."""
command.upgrade(alembic, "head")
try:
db.execute(
text("INSERT INTO source_labels (source) VALUES ('homelab')")
)
db.commit()
with pytest.raises(IntegrityError):
db.execute(
text(
"INSERT INTO source_labels (source, display_name)"
" VALUES ('homelab', 'second name')"
)
)
db.rollback() # the IntegrityError aborts the open transaction
finally:
_clear_rows(db)
def test_null_tags_is_rejected(db: Session, alembic: Config) -> None:
"""``tags`` is NOT NULL: "no tags" is ``[]`` (the server default),
never NULL — readers never branch on the None case."""
command.upgrade(alembic, "head")
try:
with pytest.raises(IntegrityError):
db.execute(
text(
"INSERT INTO source_labels (source, tags)"
" VALUES ('nulltags', NULL)"
)
)
db.rollback() # the IntegrityError aborts the open transaction
stored = db.execute(text("SELECT count(*) FROM source_labels")).scalar()
assert stored == 0, "the rejected row must not be stored"
finally:
_clear_rows(db)
def test_downgrade_to_0029_drops_the_table(db: Session, alembic: Config) -> None:
"""Downgrade 0030 → 0029: the table is gone (A13 — fully
reversible) while the rest of the schema survives (the 0029 HNSW
index, ``app_settings``). The names/labels/tags are re-enterable
from the Sources page — nothing outside this table was mutated."""
command.downgrade(alembic, "0029")
assert _version(db) == "0029"
assert not _table_exists(db, "source_labels"), "source_labels must be dropped"
assert _hnsw_index_exists(db), "ix_chunks_embedding_hnsw (0029) must survive"
strip = _column(db, "documents", "page_strip_mode")
assert strip is not None and strip[3] == 16, (
"documents.page_strip_mode must survive the downgrade"
)
key = _column(db, "app_settings", "key")
assert key is not None and key[3] == 100, "app_settings.key must survive"
def test_upgrade_round_trip_restores_the_table(db: Session, alembic: Config) -> None:
"""Downgrade to 0029, then upgrade back to 0030: the table is back
with the column contract and PK intact (A13 round-trip)."""
command.downgrade(alembic, "0029")
command.upgrade(alembic, "0030")
assert _version(db) == "0030", "round-trip upgrade must land at 0030"
assert _table_exists(db, "source_labels"), "the table must be back"
source = _column(db, "source_labels", "source")
assert source is not None, "source_labels.source must be back"
assert source[0] == "character varying", "source must be VARCHAR after the round-trip"
assert source[1] == "NO", "source must be NOT NULL after the round-trip"
assert source[3] == 120, "source must be String(120) after the round-trip"
tags = _column(db, "source_labels", "tags")
assert tags is not None, "source_labels.tags must be back"
assert tags[0] == "jsonb", "tags must be JSONB after the round-trip"
assert tags[1] == "NO", "tags must be NOT NULL after the round-trip"
assert _pk_columns(db, "source_labels") == ["source"]
+753
View File
@@ -0,0 +1,753 @@
"""Integration: the admin ``/api/source-labels`` surface (phase 191, task 02).
The wire half of "Sources need a name, a label, tag(s)" (``TODO.md`` L4)
— the catalogue the Sources page loads, the upsert its Save drives and
the reset its Reset button drives. Driven through the real app with the
house ``TestClient`` cookie jar (the ``test_ui_settings_api.py`` phase-91
fixture pattern: the admin login and the token-user matrix), against the
real Postgres (``podman compose up -d db``); no LLM involved.
Contract under test:
* **the gate** — an anonymous caller AND a signed-in token USER (role
``"user"``) get 403 ``{"detail": "admin only"}`` on all three routes
(the router-wide ``require_admin``, the phase-91/168 admin-surface
shape, one fixed detail with nothing to enumerate), and nothing lands;
* **the catalogue GET** — the union of the ``git_sources`` rows of BOTH
kinds and the indexed document sources no row derives (the uploads),
de-duplicated and sorted by ``source``, each entry exactly
``{source, display_name, label, tags}`` with its label row or the
``null``/``[]`` defaults; an env-only deployment (empty table) lists
the ``BOR_GIT_SOURCES`` names; three tables, three SELECTs — the
assembly never queries per source;
* **the PUT** — the round trip (set all three → 200 → the GET reads them
back), the partial-update shape (an omitted field is untouched, an
explicit ``null`` clears, ``[]`` clears the tags, an all-omitted body
is a 200 no-op), the trim-then-cap ordering, the case-insensitive tag
de-dupe preserving the first case, the framework's standard 422 for a
121-char name / 201-char label / 11 tags / 33-char tag / whitespace-only
tag / a key outside the shape — each leaving a pre-seeded row
BYTE-IDENTICAL — the 404 for a source that is neither a row name nor an
indexed document (nothing created), the 422 for a > 120-char path, and
the ``normalize_tags`` funnel (its ``ValueError`` is a 422, never a
500);
* **the DELETE** — 200 with the entry's post-reset state, the GET shows
the nulls after it, a second DELETE is 404;
* **the alias contract (D1)** — labelling a source touches
``source_labels`` and NOTHING else: ``documents.source``, the row's
derived name and the catalogue's identity are byte-identical after a
rename, which is what keeps a saved chat's ``source/path`` locator, a
shared page and a verbatim caption valid across a rename.
``source_labels``, ``git_sources`` and the catalogue are global state:
truncated around every test.
Requires: podman compose up -d db
"""
from __future__ import annotations
import uuid
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from typing import Any
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import event, text
from sqlalchemy.orm import Session
from app.api import source_labels as source_labels_api
from app.config import Settings
from app.db import engine
from app.main import app as fastapi_app
from app.models import Document, GitSource
from tests.conftest import ADMIN_PASSWORD
#: The catalogue entry's exact wire shape (``extra="forbid"`` makes the
#: omission structural — the row's ``updated_at`` never rides along).
_ENTRY_KEYS = {"source", "display_name", "label", "tags"}
@pytest.fixture(autouse=True)
def clean_state(db: Session) -> Iterator[None]:
"""Every table this module reads or writes is global state (the
catalogue is a FULL scan of the registry and the documents, so
another suite's rows would move the pins)."""
_wipe(db)
yield
_wipe(db)
def _wipe(db: Session) -> None:
db.execute(text("DELETE FROM source_labels"))
db.execute(text("TRUNCATE git_sources"))
db.execute(text("TRUNCATE chunks, documents"))
db.execute(text("TRUNCATE api_tokens"))
db.commit()
def _login(client: TestClient) -> TestClient:
client.post("/api/login", json={"password": ADMIN_PASSWORD})
return client
@pytest.fixture()
def env_sources(
monkeypatch: pytest.MonkeyPatch,
) -> Callable[[str], None]:
"""Pin the ``BOR_GIT_SOURCES`` fallback the way
``test_git_sources_api.py`` does — with a fresh ``Settings`` that
ignores the operator's ``.env`` (a dev ``.env`` with a repo in it
would otherwise BE the catalogue while the table is empty, the
phase-35 fallback). Returns the setter so one test can name the list
it wants; the empty string is the "nothing configured" state."""
def _set(git_sources: str = "") -> None:
monkeypatch.setattr(
"app.rag.git_sources.get_settings",
lambda: Settings(
_env_file=None, # pyright: ignore[reportCallIssue]
git_sources=git_sources,
),
)
return _set
def _doc(db: Session, source: str, path: str) -> Document:
"""One indexed document — the catalogue's (b) arm reads
``documents.source`` and NOTHING else."""
doc = Document(
id=uuid.uuid4(),
source=source,
path=path,
full_path=f"/srv/{source}/{path}",
title=path.rsplit("/", 1)[-1],
content=f"body of {path}",
content_hash="0" * 64,
)
db.add(doc)
return doc
def _seed_three_kinds(db: Session) -> None:
"""One git row, one local row and one upload-shaped document.
The git row is a ``file://`` URL (the phase-135 E2E-fixture form) and
carries the ``.git`` suffix, so the pin also proves the name is the
importer's DERIVED one (``repo_name``) rather than the raw URL; a
document exists under that derived name too, which is what the
de-duplication has to collapse.
"""
db.add_all(
[
GitSource(url="file:///srv/repos/gitea-forge.git", kind="git"),
GitSource(
url="/srv/docs/media-vault",
kind="local",
path="/srv/docs/media-vault",
),
]
)
db.add(_doc(db, "gitea-forge", "docs/app.md"))
db.add(_doc(db, "corpus-upload", "notes/a.md"))
db.commit()
@contextmanager
def _captured_statements() -> Iterator[list[str]]:
"""Every SQL statement the ENGINE runs inside the block.
The house statement-capture idiom (``test_import_session_retention``
/ ``test_prune_cascade_statements``): a local listener on the shared
engine, removed in ``finally`` — the request runs on another thread
under ``TestClient``, so the engine (not the session) is the place
both sides see.
"""
captured: list[str] = []
def _listen(
conn, cursor, statement, parameters, context, executemany
) -> None: # the engine's own signature — no types to restate
captured.append(statement)
event.listen(engine, "before_cursor_execute", _listen)
try:
yield captured
finally:
event.remove(engine, "before_cursor_execute", _listen)
def _selects(statements: list[str]) -> list[str]:
"""The SELECTs only, whitespace-collapsed for comparability, with
the pool's ``SELECT 1`` health check dropped — it is a checkout
artifact, not a read this endpoint asked for, and pinning it would
make the count depend on how warm the pool happens to be."""
return [
collapsed
for statement in statements
if (collapsed := " ".join(statement.split())).upper().startswith("SELECT")
and collapsed.upper() != "SELECT 1"
]
def _row(db: Session, source: str) -> dict[str, Any]:
"""One ``source_labels`` row read RAW — the pin never trusts the
shape under test to report its own storage."""
db.expire_all()
found = db.execute(
text(
"SELECT source, display_name, label, tags FROM source_labels"
" WHERE source = :s"
),
{"s": source},
).first()
if found is None:
return {}
return {
"source": found[0],
"display_name": found[1],
"label": found[2],
"tags": found[3],
}
# ---------------------------------------------------------------------------
# The admin gate (the phase-91 shape)
# ---------------------------------------------------------------------------
def test_anonymous_is_403_on_every_route(client: TestClient, db: Session) -> None:
"""Router-wide ``require_admin``: one fixed detail on all three
routes, and nothing landed (the ``test_ui_settings_api`` contract)."""
r = client.get("/api/source-labels")
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
r = client.put("/api/source-labels/gitea-forge", json={"display_name": "nope"})
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
r = client.delete("/api/source-labels/gitea-forge")
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
assert db.execute(text("SELECT count(*) FROM source_labels")).scalar_one() == 0
def test_token_user_is_403_on_every_route(client: TestClient) -> None:
"""A signed-in token USER is not the admin: the owner's naming surface
is closed to them on all three methods (the phase-79 token matrix —
the same leg the phase-91/168 admin surfaces pin)."""
admin = _login(TestClient(fastapi_app))
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"}
assert client.get("/api/source-labels").status_code == 403
r = client.put("/api/source-labels/gitea-forge", json={"display_name": "nope"})
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
assert client.delete("/api/source-labels/gitea-forge").status_code == 403
# ---------------------------------------------------------------------------
# GET — the catalogue
# ---------------------------------------------------------------------------
def test_get_empty_deployment_is_an_empty_catalogue(
admin_client: TestClient, env_sources: Callable[[str], None]
) -> None:
"""No rows and no env list: the response is the empty list, not a 404
and not a missing key — the page always has a shape to render."""
env_sources("")
r = admin_client.get("/api/source-labels")
assert r.status_code == 200, r.text
assert r.json() == {"labels": []}
def test_get_lists_git_local_and_upload_sources(
admin_client: TestClient, db: Session
) -> None:
"""The D5 union, sorted, de-duplicated, with the exact entry shape.
``gitea-forge`` (a git row, derived from the URL basename with the
``.git`` stripped) also has a document under that name — the entry
still appears exactly ONCE. ``corpus-upload`` has documents and no
row, so only arm (b) can list it (and the E2E needs it to).
"""
_seed_three_kinds(db)
r = admin_client.get("/api/source-labels")
assert r.status_code == 200, r.text
labels = r.json()["labels"]
assert [entry["source"] for entry in labels] == [
"corpus-upload",
"gitea-forge",
"media-vault",
]
for entry in labels:
assert set(entry) == _ENTRY_KEYS
assert entry["display_name"] is None
assert entry["label"] is None
assert entry["tags"] == []
def test_get_carries_the_stored_label_on_one_entry_only(
admin_client: TestClient, db: Session
) -> None:
"""One labelled row, two unlabelled ones: the label rides its own
entry and the others keep the null/``[]`` defaults (a label row is
data beside the name, never a rename — D1)."""
_seed_three_kinds(db)
assert (
admin_client.put(
"/api/source-labels/corpus-upload",
json={"display_name": "the uploads", "tags": ["upload"]},
).status_code
== 200
)
labels = admin_client.get("/api/source-labels").json()["labels"]
by_source = {entry["source"]: entry for entry in labels}
assert by_source["corpus-upload"] == {
"source": "corpus-upload",
"display_name": "the uploads",
"label": None,
"tags": ["upload"],
}
assert by_source["gitea-forge"]["display_name"] is None
assert by_source["media-vault"]["tags"] == []
def test_get_lists_env_source_names_while_the_table_is_empty(
admin_client: TestClient, env_sources: Callable[[str], None]
) -> None:
"""An env-only deployment (no ``git_sources`` rows): the
``BOR_GIT_SOURCES`` names are the catalogue, derived exactly as the
importer would name what it clones — and one of them is labelable."""
env_sources("https://a.example.com/one.git, git@b.example.com:two.git ,")
labels = admin_client.get("/api/source-labels").json()["labels"]
assert [entry["source"] for entry in labels] == ["one", "two"]
r = admin_client.put("/api/source-labels/one", json={"label": "the first repo"})
assert r.status_code == 200, r.text
assert r.json()["label"] == "the first repo"
def test_get_reads_three_tables_and_never_one_per_source(
admin_client: TestClient, db: Session
) -> None:
"""The one-query-per-table discipline: with four sources and one
label row the GET costs exactly three SELECTs — the registry, the
distinct document sources, the label rows. A per-source label lookup
(or a third existence query) fails this pin."""
_seed_three_kinds(db)
db.add(_doc(db, "third-source", "b.md"))
db.commit()
admin_client.put("/api/source-labels/media-vault", json={"display_name": "vault"})
with _captured_statements() as statements:
body = admin_client.get("/api/source-labels").json()
selects = _selects(statements)
assert [entry["source"] for entry in body["labels"]] == [
"corpus-upload",
"gitea-forge",
"media-vault",
"third-source",
]
assert len(selects) == 3, selects
assert sum(1 for s in selects if "FROM git_sources" in s) == 1
assert sum(1 for s in selects if "DISTINCT documents.source" in s) == 1
assert sum(1 for s in selects if "FROM source_labels" in s) == 1
# No whole-entity document read — the catalogue never hydrates content.
assert not any("documents.content" in s for s in selects), selects
# ---------------------------------------------------------------------------
# PUT — the upsert
# ---------------------------------------------------------------------------
def test_put_round_trips_all_three_fields(
admin_client: TestClient, db: Session
) -> None:
"""Set name + label + tags on a git source: 200 carries the stored
shape, the row exists, and the GET reads it back."""
_seed_three_kinds(db)
r = admin_client.put(
"/api/source-labels/gitea-forge",
json={
"display_name": "the git server",
"label": "self-hosted forge + Gitea",
"tags": ["gitea", "forge"],
},
)
assert r.status_code == 200, r.text
assert r.json() == {
"source": "gitea-forge",
"display_name": "the git server",
"label": "self-hosted forge + Gitea",
"tags": ["gitea", "forge"],
}
assert _row(db, "gitea-forge") == {
"source": "gitea-forge",
"display_name": "the git server",
"label": "self-hosted forge + Gitea",
"tags": ["gitea", "forge"],
}
entry = admin_client.get("/api/source-labels").json()["labels"]
assert entry[1]["display_name"] == "the git server"
def test_put_partial_update_shape(admin_client: TestClient, db: Session) -> None:
"""D5 three ways: a value SETS, an OMITTED field is untouched, an
explicit ``null`` CLEARS (and ``tags: []`` clears the tags while the
strings stay)."""
_seed_three_kinds(db)
admin_client.put(
"/api/source-labels/media-vault",
json={"display_name": "the media box", "label": "jellyfin + arrs", "tags": ["jellyfin"]},
)
# Only the label moves — the name and the tags keep their values.
r = admin_client.put("/api/source-labels/media-vault", json={"label": "arrs only"})
assert r.status_code == 200, r.text
assert r.json()["display_name"] == "the media box"
assert r.json()["label"] == "arrs only"
assert r.json()["tags"] == ["jellyfin"]
# An explicit null clears JUST that field.
r = admin_client.put("/api/source-labels/media-vault", json={"display_name": None})
assert r.status_code == 200, r.text
assert r.json() == {
"source": "media-vault",
"display_name": None,
"label": "arrs only",
"tags": ["jellyfin"],
}
# An empty tag list clears the tags; the label stays.
r = admin_client.put("/api/source-labels/media-vault", json={"tags": []})
assert r.json() == {
"source": "media-vault",
"display_name": None,
"label": "arrs only",
"tags": [],
}
assert _row(db, "media-vault")["tags"] == []
def test_put_all_omitted_body_is_a_200_no_op(
admin_client: TestClient, db: Session
) -> None:
"""The ``GitSourcePatchIn`` "all absent → 200 no-op" precedent: an
empty body changes nothing on a labelled row (and creates a defaults
row on an unlabelled one)."""
_seed_three_kinds(db)
admin_client.put(
"/api/source-labels/gitea-forge",
json={"display_name": "the git server", "tags": ["gitea"]},
)
r = admin_client.put("/api/source-labels/gitea-forge", json={})
assert r.status_code == 200, r.text
assert r.json() == {
"source": "gitea-forge",
"display_name": "the git server",
"label": None,
"tags": ["gitea"],
}
assert _row(db, "gitea-forge")["display_name"] == "the git server"
def test_put_trims_values_and_dedupes_tags(
admin_client: TestClient, db: Session
) -> None:
"""Trim before the caps (``GitSourceIn._trim_url``), and the ONE tag
rule: case-insensitive de-dupe with the FIRST case kept, order
otherwise preserved."""
_seed_three_kinds(db)
r = admin_client.put(
"/api/source-labels/gitea-forge",
json={
"display_name": " the git server ",
"label": " self-hosted forge ",
"tags": [" Gitea ", "gitea", "FORGE", "forge"],
},
)
assert r.status_code == 200, r.text
assert r.json()["display_name"] == "the git server"
assert r.json()["label"] == "self-hosted forge"
assert r.json()["tags"] == ["Gitea", "FORGE"]
@pytest.mark.parametrize(
("field", "value", "needle"),
[
("display_name", "x" * 121, "string_too_long"),
("label", "x" * 201, "string_too_long"),
("tags", ["tag"] * 11, "too_long"),
("tags", ["x" * 33], "string_too_long"),
("tags", [" "], "string_too_short"),
("tags", [""], "string_too_short"),
],
)
def test_put_validation_422s_leave_the_row_unchanged(
admin_client: TestClient,
db: Session,
field: str,
value: Any,
needle: str,
) -> None:
"""Every D5 cap is the framework's standard 422 (the pydantic body,
not a fixed ``detail``), names the offending field, and writes
NOTHING: the pre-seeded row is byte-identical afterwards.
The trim runs BEFORE the cap, so a 121-char value that trims to 120
is legal — the 120-char leg of each boundary is asserted too, or the
cap could silently be one short."""
_seed_three_kinds(db)
admin_client.put(
"/api/source-labels/gitea-forge",
json={"display_name": "the git server", "label": "forge", "tags": ["gitea"]},
)
before = _row(db, "gitea-forge")
if field == "display_name":
legal = "y" * 120
elif field == "label":
legal = "y" * 200
else:
legal = ["tag"] * 10 if len(value) == 11 else ["y" * 32]
r = admin_client.put("/api/source-labels/gitea-forge", json={field: value})
assert r.status_code == 422, r.text
detail = r.json()["detail"]
assert isinstance(detail, list), r.text
assert detail[0]["type"] == needle, detail
assert field in detail[0]["loc"], detail
assert _row(db, "gitea-forge") == before
assert admin_client.put(
"/api/source-labels/gitea-forge", json={field: legal}
).status_code == 200
def test_put_rejects_a_key_outside_the_shape(
admin_client: TestClient, db: Session
) -> None:
"""``extra="forbid"`` (the phase-91 voice): an undeclared key is a
422 and no row is created."""
_seed_three_kinds(db)
r = admin_client.put(
"/api/source-labels/media-vault",
json={"display_name": "vault", "source": "gitea-forge"},
)
assert r.status_code == 422, r.text
assert r.json()["detail"][0]["type"] == "extra_forbidden"
assert db.execute(text("SELECT count(*) FROM source_labels")).scalar_one() == 0
def test_put_rejects_a_malformed_tags_value(
admin_client: TestClient, db: Session
) -> None:
"""A ``tags`` value that is not a list of strings is pydantic's own
422, in its own voice (``list_type`` / ``string_type``): the trimmer
passes anything it does not understand straight through rather than
guessing, so the framework names the type and NOTHING is written."""
_seed_three_kinds(db)
r = admin_client.put("/api/source-labels/media-vault", json={"tags": "gitea"})
assert r.status_code == 422, r.text
assert r.json()["detail"][0]["type"] == "list_type"
r = admin_client.put(
"/api/source-labels/media-vault", json={"tags": ["gitea", 7]}
)
assert r.status_code == 422, r.text
assert r.json()["detail"][0]["type"] == "string_type"
assert db.execute(text("SELECT count(*) FROM source_labels")).scalar_one() == 0
def test_put_folds_empty_strings_to_null(admin_client: TestClient, db: Session) -> None:
"""An empty / whitespace-only ``display_name`` or ``label`` is the
CLEAR leg, not a stored empty string: "no name" and "an empty name"
must not be two different things for the prompt builders (task 05)."""
_seed_three_kinds(db)
admin_client.put(
"/api/source-labels/corpus-upload",
json={"display_name": "the uploads", "label": "the upload folder"},
)
r = admin_client.put(
"/api/source-labels/corpus-upload",
json={"display_name": " ", "label": ""},
)
assert r.status_code == 200, r.text
assert r.json()["display_name"] is None
assert r.json()["label"] is None
assert _row(db, "corpus-upload")["display_name"] is None
def test_put_404s_a_source_that_exists_nowhere(
admin_client: TestClient, db: Session
) -> None:
"""D5's no-orphan-row arm: neither a registry name nor an indexed
document → 404, and NOTHING is written (a dead label would boost
nothing and never appear in the UI to be cleared)."""
_seed_three_kinds(db)
r = admin_client.put("/api/source-labels/no-such-source", json={"label": "x"})
assert r.status_code == 404, r.text
assert r.json() == {"detail": "source not found"}
assert db.execute(text("SELECT count(*) FROM source_labels")).scalar_one() == 0
# A near-miss on the derived name is still unknown (no fuzzy leg).
r = admin_client.put("/api/source-labels/gitea-forg", json={"label": "x"})
assert r.status_code == 404, r.text
assert db.execute(text("SELECT count(*) FROM source_labels")).scalar_one() == 0
def test_put_422s_an_overlong_source_path(admin_client: TestClient) -> None:
"""The path param carries the ``documents.source`` 120 cap: a longer
name is the framework's standard 422 on the ``path`` location (it
never reaches the query)."""
r = admin_client.put(f"/api/source-labels/{'x' * 121}", json={"label": "x"})
assert r.status_code == 422, r.text
detail = r.json()["detail"]
assert detail[0]["type"] == "string_too_long"
assert detail[0]["loc"] == ["path", "source"]
def test_put_maps_the_tag_normalizer_error_to_422(
admin_client: TestClient,
db: Session,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The belt: ``normalize_tags`` is the ONE tag rule and its
``ValueError`` is the framework's 422 — never a 500. The request
shape caps the same numbers today, so the belt is pinned by
substituting the raising normalizer (the row stays byte-identical,
which is the part that must not regress)."""
_seed_three_kinds(db)
admin_client.put(
"/api/source-labels/gitea-forge", json={"display_name": "the git server"}
)
before = _row(db, "gitea-forge")
def _boom(_raw: list[str]) -> list[str]:
raise ValueError("at most 10 tags are allowed")
monkeypatch.setattr(source_labels_api, "normalize_tags", _boom)
r = admin_client.put("/api/source-labels/gitea-forge", json={"tags": ["gitea"]})
assert r.status_code == 422, r.text
assert r.json() == {"detail": "at most 10 tags are allowed"}
assert _row(db, "gitea-forge") == before
# ---------------------------------------------------------------------------
# DELETE — the reset
# ---------------------------------------------------------------------------
def test_delete_resets_then_404s(admin_client: TestClient, db: Session) -> None:
"""200 with the entry's post-reset state (the phase-168
``DELETE /api/admin/settings/{key}`` convention — the row re-renders
from the response it already has), the GET shows the nulls, and a
second DELETE is a 404 with one fixed detail."""
_seed_three_kinds(db)
admin_client.put(
"/api/source-labels/gitea-forge",
json={"display_name": "the git server", "label": "forge", "tags": ["gitea"]},
)
r = admin_client.delete("/api/source-labels/gitea-forge")
assert r.status_code == 200, r.text
assert r.json() == {
"source": "gitea-forge",
"display_name": None,
"label": None,
"tags": [],
}
assert db.execute(text("SELECT count(*) FROM source_labels")).scalar_one() == 0
entry = {e["source"]: e for e in admin_client.get("/api/source-labels").json()["labels"]}
assert entry["gitea-forge"] == {
"source": "gitea-forge",
"display_name": None,
"label": None,
"tags": [],
}
r = admin_client.delete("/api/source-labels/gitea-forge")
assert r.status_code == 404, r.text
assert r.json() == {"detail": "source label not found"}
def test_delete_works_for_a_source_that_has_vanished(
admin_client: TestClient, db: Session
) -> None:
"""The reset never consults the registry: a label whose source has
since been removed (phase 69's total removal) stays CLEARABLE, or a
label could never be taken back off a gone source."""
_seed_three_kinds(db)
admin_client.put("/api/source-labels/corpus-upload", json={"label": "the upload"})
db.execute(text("DELETE FROM documents WHERE source = 'corpus-upload'"))
db.commit()
r = admin_client.delete("/api/source-labels/corpus-upload")
assert r.status_code == 200, r.text
assert db.execute(text("SELECT count(*) FROM source_labels")).scalar_one() == 0
# ---------------------------------------------------------------------------
# The alias contract (D1)
# ---------------------------------------------------------------------------
def test_labelling_a_source_touches_nothing_but_the_label_row(
admin_client: TestClient, db: Session
) -> None:
"""The owner-confirmed alias contract, at the API: a rename writes
``source_labels`` and leaves ``documents.source`` (and with it every
``source/path`` locator, citation and KB-tree node) byte-identical —
and the catalogue's identity column stays the DERIVED name."""
_seed_three_kinds(db)
before = db.execute(
text("SELECT source, path FROM documents ORDER BY source, path")
).fetchall()
r = admin_client.put(
"/api/source-labels/gitea-forge", json={"display_name": "the git server"}
)
assert r.status_code == 200, r.text
db.expire_all()
after = db.execute(
text("SELECT source, path FROM documents ORDER BY source, path")
).fetchall()
assert after == before
assert [row[0] for row in after] == ["corpus-upload", "gitea-forge"]
catalogue = admin_client.get("/api/source-labels").json()["labels"]
assert [entry["source"] for entry in catalogue] == [
"corpus-upload",
"gitea-forge",
"media-vault",
]
assert catalogue[1]["display_name"] == "the git server"
# And the PUT still keys on the DERIVED name after the rename (the
# display name is never an address).
assert admin_client.put(
"/api/source-labels/the git server", json={"label": "nope"}
).status_code == 404