phase: 191_source_name_labels_tags
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:
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user