phase: 89_source_ignore_paths
All verification complete — TODO.md was already cleared in the roadmap commit; the two extra unit-test diffs are necessary fake-signature adaptations for the new keywords. Everything is green, no fixes were needed. ## Phase 89 — final verification pass: ALL GREEN **Verified (all 6 task files present in `complete/`):** - `git_sources.ignore_paths` JSONB column + migration 0013; `alembic downgrade -1 && upgrade head` round-trips (head `0013`) - Importer: `normalize_ignore_path`/`is_ignored`/`_ignore_for_root`, `ignore` in walk + progress pre-walk, `ignore_by_root` in `import_sources` - API: GET/POST carry list; admin-only `PATCH` (replace, 404/422 fixed details, anonymous 403) - Pipelines wired: `_run_sync`, `_run_upload` re-upload, `scripts/import_docs.py` - Sources-page box: dialog, §7.4 save lifecycle, `N ignored` tag, a11y; env rows get no box **Test/lint results:** - `uv run pytest` → 1808 passed - `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%) - `uv run pytest tests/e2e/test_source_ignore_paths.py -v --no-cov` → 6 passed (isolated, DB up) - Regressions in isolation: `test_git_sources_admin` 6, `test_archive_upload_sources` 5, `test_sync_button` 3, `test_smoke` 3 — all passed - `uv run ruff check . && uv run pyright` → clean (0 errors) **Completion criteria:** box→PATCH 200→count+GET round-trip ✅ · sync excludes `ignore/` (no docs/chunks/embeddings/summaries) + prunes newly-ignored (pruned==2) ✅ · no-mid-path rule E2E ✅ · PATCH 404/422/replace/clear/403 ✅ · full gate green ✅ · commit + phase move left to harness per rules. **Deviations:** none blocking — E2E pins `files == 4` (overview's "5" was an off-by-one vs its own 6-file tree, documented in-test); `tests/unit/test_importer.py` + `test_sync_button.py` test-double fakes extended for the new keywords (needed for the suite to stay green). **Next pending phase:** none — `todo/` holds only this phase.
This commit is contained in:
@@ -28,7 +28,14 @@ Contract under test:
|
||||
index as backstop); wrong field combinations (git without url, local
|
||||
without path, both kinds' fields) → 422;
|
||||
* DELETE — 204 and gone; an emptied table falls back to the env list
|
||||
again; unknown id → 404.
|
||||
again; unknown id → 404;
|
||||
* ignore paths (phase 89) — POST accepts the RAW box lines for both
|
||||
kinds (optional, absent → ``[]``), stored normalized (A1) with the A4
|
||||
fixed-detail 422s (shared gate with PATCH); GET reports each row's
|
||||
stored list (env rows ``[]``); ``PATCH /{source_id}`` (admin-only)
|
||||
replaces the list wholesale (A5 — an empty list clears all), 404
|
||||
unknown id, 422 fixed details for the A4 limits, the row otherwise
|
||||
unchanged.
|
||||
|
||||
``git_sources`` is global state: truncated around every test.
|
||||
"""
|
||||
@@ -92,6 +99,10 @@ def test_anonymous_gets_403_on_all_routes(client: TestClient, db: Session) -> No
|
||||
r = client.delete(f"/api/git-sources/{uuid.uuid4()}")
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
# The phase-89 ignore-list PATCH is gated the same way.
|
||||
r = client.patch(f"/api/git-sources/{uuid.uuid4()}", json={"ignore_paths": ["a"]})
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
# Nothing landed in the table.
|
||||
assert db.execute(text("SELECT count(*) FROM git_sources")).scalar_one() == 0
|
||||
|
||||
@@ -121,6 +132,8 @@ def test_get_empty_table_with_env_returns_env_rows(
|
||||
"url": "https://a.example.com/one.git",
|
||||
"path": None,
|
||||
"added_at": None,
|
||||
# Env rows have no DB row to store a list on (phase 89).
|
||||
"ignore_paths": [],
|
||||
},
|
||||
{
|
||||
"id": None,
|
||||
@@ -128,6 +141,7 @@ def test_get_empty_table_with_env_returns_env_rows(
|
||||
"url": "git@b.example.com:two.git",
|
||||
"path": None,
|
||||
"added_at": None,
|
||||
"ignore_paths": [],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -193,7 +207,10 @@ def test_post_creates_trimmed_and_list_stops_using_env(
|
||||
assert body["url"] == "https://new.example.com/repo.git" # trimmed
|
||||
uuid.UUID(body["id"])
|
||||
assert body["added_at"] is not None
|
||||
assert set(body) == {"id", "url", "added_at"}
|
||||
# Phase 89: the response gains ``ignore_paths`` — absent at create
|
||||
# time → ``[]``.
|
||||
assert set(body) == {"id", "url", "added_at", "ignore_paths"}
|
||||
assert body["ignore_paths"] == []
|
||||
|
||||
# The DB row now wins: from_env False, the env URL is gone from the list.
|
||||
body = admin_client.get("/api/git-sources").json()
|
||||
@@ -303,8 +320,10 @@ def test_post_local_creates_stored_row_with_expanded_path(
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
# The phase-35 response shape is unchanged — the local row reports
|
||||
# its (expanded) path in ``url``; ``kind`` + ``path`` via GET.
|
||||
assert set(body) == {"id", "url", "added_at"}
|
||||
# its (expanded) path in ``url``; ``kind`` + ``path`` via GET;
|
||||
# phase 89 adds ``ignore_paths`` (absent → ``[]``).
|
||||
assert set(body) == {"id", "url", "added_at", "ignore_paths"}
|
||||
assert body["ignore_paths"] == []
|
||||
uuid.UUID(body["id"])
|
||||
assert body["url"] == str(real_dir)
|
||||
assert body["added_at"] is not None
|
||||
@@ -536,6 +555,7 @@ def test_delete_removes_row_and_falls_back_to_env(
|
||||
"url": "https://env.example.com/env.git",
|
||||
"path": None,
|
||||
"added_at": None,
|
||||
"ignore_paths": [], # env rows: no DB row to store a list on
|
||||
}
|
||||
]
|
||||
db.execute(text("TRUNCATE chunks, documents"))
|
||||
@@ -550,3 +570,205 @@ def test_delete_unknown_id_returns_404(admin_client: TestClient) -> None:
|
||||
|
||||
def test_delete_invalid_id_returns_422(admin_client: TestClient) -> None:
|
||||
assert admin_client.delete("/api/git-sources/not-a-uuid").status_code == 422
|
||||
|
||||
|
||||
# --- ignore paths (phase 89) ----------------------------------------------
|
||||
|
||||
|
||||
def test_post_stores_normalized_ignore_paths_both_kinds(
|
||||
admin_client: TestClient, tmp_path: Path
|
||||
) -> None:
|
||||
"""POST accepts the RAW box lines for both kinds (A1 normalization
|
||||
in the API layer) — the normalized list is stored and round-trips
|
||||
through GET."""
|
||||
r = admin_client.post(
|
||||
"/api/git-sources",
|
||||
json={
|
||||
"url": "https://example.com/ig.git",
|
||||
"ignore_paths": ["/my/files/", " my/files2 ", "x"],
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["ignore_paths"] == ["my/files", "my/files2", "x"]
|
||||
|
||||
real_dir = tmp_path / "ig"
|
||||
real_dir.mkdir()
|
||||
r = admin_client.post(
|
||||
"/api/git-sources",
|
||||
json={"kind": "local", "path": str(real_dir), "ignore_paths": ["//skip/", "keep"]},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["ignore_paths"] == ["skip", "keep"]
|
||||
|
||||
# Round-trip through GET (keyed by url — (added_at, id) order of two
|
||||
# same-millisecond inserts is not the point under test).
|
||||
by_url = {
|
||||
s["url"]: s["ignore_paths"] for s in admin_client.get("/api/git-sources").json()["sources"]
|
||||
}
|
||||
assert by_url["https://example.com/ig.git"] == ["my/files", "my/files2", "x"]
|
||||
assert by_url[str(real_dir)] == ["skip", "keep"]
|
||||
|
||||
|
||||
def test_post_without_ignore_paths_reports_empty_list(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""Absent ``ignore_paths`` at create time → ``[]`` — both in the 201
|
||||
response and in the GET round-trip (the migration's server default)."""
|
||||
r = admin_client.post("/api/git-sources", json={"url": "https://example.com/none.git"})
|
||||
assert r.status_code == 201
|
||||
assert r.json()["ignore_paths"] == []
|
||||
body = admin_client.get("/api/git-sources").json()
|
||||
assert body["sources"][0]["ignore_paths"] == []
|
||||
# The stored value is the JSONB server default, not Python-only.
|
||||
row = db.scalars(select(GitSource)).one()
|
||||
assert row.ignore_paths == []
|
||||
|
||||
|
||||
def test_post_rejects_invalid_ignore_paths_like_patch(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""POST shares ``_validate_ignore_paths`` with PATCH — the same A4
|
||||
fixed 422 details; nothing is stored."""
|
||||
for payload, detail in (
|
||||
([" "], "ignore paths must be non-empty"),
|
||||
([f"e{i}" for i in range(201)], "a source has at most 200 ignore paths"),
|
||||
(["a" * 501], "an ignore path exceeds 500 characters"),
|
||||
):
|
||||
r = admin_client.post(
|
||||
"/api/git-sources",
|
||||
json={"url": "https://example.com/bad-ignore.git", "ignore_paths": payload},
|
||||
)
|
||||
assert r.status_code == 422, f"{detail!r}: {r.text}"
|
||||
assert r.json()["detail"] == detail
|
||||
assert db.execute(text("SELECT count(*) FROM git_sources")).scalar_one() == 0
|
||||
|
||||
|
||||
def test_get_reports_stored_ignore_paths_and_env_rows_empty(
|
||||
admin_client: TestClient, db: Session, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""GET reports each DB row's stored list; env-fallback rows (no DB
|
||||
row to store a list on) report ``[]`` with ``from_env: true``."""
|
||||
db.add(GitSource(url="https://example.com/stored.git", ignore_paths=["docs/drafts"]))
|
||||
db.commit()
|
||||
body = admin_client.get("/api/git-sources").json()
|
||||
assert body["from_env"] is False
|
||||
assert body["sources"][0]["ignore_paths"] == ["docs/drafts"]
|
||||
|
||||
db.execute(text("DELETE FROM git_sources"))
|
||||
db.commit()
|
||||
monkeypatch.setattr(
|
||||
git_sources_api,
|
||||
"get_settings",
|
||||
lambda: _settings("https://a.example.com/env.git"),
|
||||
)
|
||||
body = admin_client.get("/api/git-sources").json()
|
||||
assert body["from_env"] is True
|
||||
assert body["sources"] == [
|
||||
{
|
||||
"id": None,
|
||||
"kind": "git",
|
||||
"url": "https://a.example.com/env.git",
|
||||
"path": None,
|
||||
"added_at": None,
|
||||
"ignore_paths": [],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_patch_replaces_ignore_paths_including_clear(admin_client: TestClient) -> None:
|
||||
"""PATCH 200 — REPLACE semantics (A5): the body list, normalized,
|
||||
becomes the row's whole list; an empty list clears all; ``id``/
|
||||
``url``/``added_at`` are unchanged; the response is the
|
||||
``GitSourceOut`` shape incl. the new list."""
|
||||
created = admin_client.post(
|
||||
"/api/git-sources",
|
||||
json={"url": "https://example.com/patch.git", "ignore_paths": ["old/"]},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
before = created.json()
|
||||
|
||||
r = admin_client.patch(f"/api/git-sources/{before['id']}", json={"ignore_paths": ["a/", "b"]})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert set(body) == {"id", "url", "added_at", "ignore_paths"}
|
||||
assert body["ignore_paths"] == ["a", "b"] # normalized
|
||||
for key in ("id", "url", "added_at"):
|
||||
assert body[key] == before[key]
|
||||
# Round-trip through GET.
|
||||
assert (
|
||||
admin_client.get("/api/git-sources").json()["sources"][0]["ignore_paths"] == ["a", "b"]
|
||||
)
|
||||
|
||||
# An empty list clears all.
|
||||
r = admin_client.patch(f"/api/git-sources/{before['id']}", json={"ignore_paths": []})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["ignore_paths"] == []
|
||||
assert admin_client.get("/api/git-sources").json()["sources"][0]["ignore_paths"] == []
|
||||
|
||||
|
||||
def test_patch_missing_field_is_422(admin_client: TestClient) -> None:
|
||||
"""``ignore_paths`` is REQUIRED (replace semantics, A5) — an absent
|
||||
field is a 422 with the model's own detail."""
|
||||
created = admin_client.post("/api/git-sources", json={"url": "https://example.com/req.git"})
|
||||
assert created.status_code == 201
|
||||
r = admin_client.patch(f"/api/git-sources/{created.json()['id']}", json={})
|
||||
assert r.status_code == 422
|
||||
# The model's own (Pydantic) detail — the missing required field.
|
||||
assert any(
|
||||
item.get("loc") == ["body", "ignore_paths"] for item in r.json()["detail"]
|
||||
)
|
||||
|
||||
|
||||
def test_patch_unknown_id_returns_404(admin_client: TestClient) -> None:
|
||||
r = admin_client.patch(f"/api/git-sources/{uuid.uuid4()}", json={"ignore_paths": ["a"]})
|
||||
assert r.status_code == 404
|
||||
assert r.json() == {"detail": "git source not found"}
|
||||
|
||||
|
||||
def test_patch_invalid_id_returns_422(admin_client: TestClient) -> None:
|
||||
r = admin_client.patch("/api/git-sources/not-a-uuid", json={"ignore_paths": []})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_patch_422s_are_fixed_details(admin_client: TestClient) -> None:
|
||||
"""The A4 422s are exact fixed strings (never echoing the input),
|
||||
and a rejected PATCH leaves the row's list unchanged."""
|
||||
created = admin_client.post(
|
||||
"/api/git-sources", json={"url": "https://example.com/v.git", "ignore_paths": ["keep"]}
|
||||
)
|
||||
assert created.status_code == 201
|
||||
sid = created.json()["id"]
|
||||
|
||||
for payload, detail in (
|
||||
# Whitespace-only → empty after normalization: 422, not a silent
|
||||
# drop (the UI drops blank lines client-side; the API is
|
||||
# defensive).
|
||||
([" "], "ignore paths must be non-empty"),
|
||||
(list(f"e{i}" for i in range(201)), "a source has at most 200 ignore paths"),
|
||||
(["a" * 501], "an ignore path exceeds 500 characters"),
|
||||
):
|
||||
r = admin_client.patch(f"/api/git-sources/{sid}", json={"ignore_paths": payload})
|
||||
assert r.status_code == 422, f"{detail!r}: {r.text}"
|
||||
assert r.json()["detail"] == detail
|
||||
# Nothing changed by the failed PATCHes.
|
||||
assert admin_client.get("/api/git-sources").json()["sources"][0]["ignore_paths"] == ["keep"]
|
||||
|
||||
|
||||
def test_patch_accepts_a4_boundaries(admin_client: TestClient) -> None:
|
||||
"""Exactly 200 entries and a 500-char entry (post-normalization)
|
||||
are the accepted edge of A4."""
|
||||
created = admin_client.post("/api/git-sources", json={"url": "https://example.com/bnd.git"})
|
||||
assert created.status_code == 201
|
||||
sid = created.json()["id"]
|
||||
|
||||
r = admin_client.patch(
|
||||
f"/api/git-sources/{sid}", json={"ignore_paths": [f"e/{i}" for i in range(200)]}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert len(r.json()["ignore_paths"]) == 200
|
||||
|
||||
long_entry = "a" * 500
|
||||
assert len(long_entry) == 500
|
||||
r = admin_client.patch(f"/api/git-sources/{sid}", json={"ignore_paths": [long_entry]})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["ignore_paths"] == [long_entry]
|
||||
|
||||
Reference in New Issue
Block a user