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]
|
||||
|
||||
@@ -291,6 +291,7 @@ class _GatedImport:
|
||||
limit: int | None = None,
|
||||
session: object = None,
|
||||
progress: Callable[[str, str, int, int], None] | None = None,
|
||||
ignore_by_root: dict[str, list[str]] | None = None, # phase 89
|
||||
) -> ImportSummary:
|
||||
self.prune_flags.append(prune)
|
||||
if progress is not None:
|
||||
@@ -1352,3 +1353,92 @@ def test_models_down_fails_the_run_and_leaves_folder_and_row(
|
||||
# The scan never ran: no docs, no stray temps.
|
||||
assert _docs(upload_client) == []
|
||||
assert [p.name for p in uploads.iterdir()] == ["homelab"]
|
||||
|
||||
|
||||
# --- phase 89: re-uploads honor the row's saved ignore list -------------------
|
||||
|
||||
|
||||
def test_reupload_honors_saved_ignore_list(
|
||||
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
|
||||
) -> None:
|
||||
"""Phase 89: a re-upload of an EXISTING source honors the ignore
|
||||
list saved on its row. First scan (fresh row, no list) indexes the
|
||||
ignored file too; once the list is saved via the API, re-uploading
|
||||
the same archive again scans only the kept file — the previously
|
||||
indexed ignored file leaves the KB (prune), and the run lands
|
||||
``success`` with counts that exclude it. The upload itself keeps
|
||||
every file on disk: the ignore is about the index, not the folder."""
|
||||
uploads = tmp_path / "uploads"
|
||||
_point_at(monkeypatch, uploads)
|
||||
_real_llm(monkeypatch)
|
||||
files = {
|
||||
"keep.md": "# Keep\nin scope\n",
|
||||
"ignore/secret.md": "# Secret\nignored\n",
|
||||
}
|
||||
r = _post(upload_client, "docs.tar.gz", _targz_bytes(files))
|
||||
assert r.status_code == 202, r.text
|
||||
status = _wait_status(upload_client)
|
||||
assert status["state"] == "success", status
|
||||
# First scan: the fresh row has no list → both files are indexed.
|
||||
assert status["detail"]["files"] == 2
|
||||
assert _docs(upload_client) == [("docs", "ignore/secret.md"), ("docs", "keep.md")]
|
||||
|
||||
# Save the ignore list on the row via the API (phase 89, task 03).
|
||||
folder = uploads / "docs"
|
||||
row = _row(db, str(folder))
|
||||
assert row is not None
|
||||
r = upload_client.patch(
|
||||
f"/api/git-sources/{row.id}", json={"ignore_paths": ["ignore/"]}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["ignore_paths"] == ["ignore"] # stored normalized
|
||||
|
||||
# Re-upload the SAME archive (same name → in-place replace).
|
||||
r = _post(upload_client, "docs.tar.gz", _targz_bytes(files))
|
||||
assert r.status_code == 202, r.text
|
||||
status = _wait_status(upload_client)
|
||||
assert status["state"] == "success", status
|
||||
detail = status["detail"]
|
||||
# The scan walked only keep.md (unchanged); the ignored file never
|
||||
# entered the walk, so prune dropped the first scan's row for it.
|
||||
assert detail["files"] == 1
|
||||
assert detail["added"] == 0
|
||||
assert detail["updated"] == 0
|
||||
assert detail["unchanged"] == 1
|
||||
assert detail["pruned"] == 1
|
||||
assert _docs(upload_client) == [("docs", "keep.md")]
|
||||
|
||||
# The re-upload left the row's list in place (the existing row is
|
||||
# untouched by the upsert), and the folder keeps every file.
|
||||
row_after = _row(db, str(folder))
|
||||
assert row_after is not None
|
||||
assert row_after.ignore_paths == ["ignore"]
|
||||
assert {p.name for p in folder.iterdir()} == {"keep.md", "ignore"}
|
||||
|
||||
|
||||
def test_upload_new_source_without_list_imports_everything(
|
||||
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
|
||||
) -> None:
|
||||
"""Phase 89 regression: an upload of a NEW source name (no row yet,
|
||||
hence no ignore list) imports everything in the archive — including
|
||||
nested files — exactly as before phase 89."""
|
||||
uploads = tmp_path / "uploads"
|
||||
_point_at(monkeypatch, uploads)
|
||||
_real_llm(monkeypatch)
|
||||
files = {
|
||||
"alpha.md": "# Alpha\nroot file\n",
|
||||
"sub/deep.md": "# Deep\nnested file\n",
|
||||
}
|
||||
r = _post(upload_client, "fresh.tar.gz", _targz_bytes(files))
|
||||
assert r.status_code == 202, r.text
|
||||
status = _wait_status(upload_client)
|
||||
assert status["state"] == "success", status
|
||||
detail = status["detail"]
|
||||
assert detail["files"] == 2
|
||||
assert detail["added"] == 2
|
||||
# The fresh row carries the server-default empty list: nothing was
|
||||
# ignored.
|
||||
row = _row(db, str(uploads / "fresh"))
|
||||
assert row is not None
|
||||
assert (row.ignore_paths or []) == []
|
||||
assert _docs(upload_client) == [("fresh", "alpha.md"), ("fresh", "sub/deep.md")]
|
||||
|
||||
@@ -43,14 +43,15 @@ def _settings(git_sources: str = "", sources_dir: str = "~/bor-sources") -> Sett
|
||||
return Settings(_env_file=None, git_sources=git_sources, sources_dir=sources_dir) # pyright: ignore[reportCallIssue]
|
||||
|
||||
|
||||
def _git_row(url: str) -> GitSource:
|
||||
return GitSource(url=url, kind="git")
|
||||
def _git_row(url: str, ignore_paths: list[str] | None = None) -> GitSource:
|
||||
return GitSource(url=url, kind="git", ignore_paths=ignore_paths or [])
|
||||
|
||||
|
||||
def _local_row(path: str) -> GitSource:
|
||||
def _local_row(path: str, ignore_paths: list[str] | None = None) -> GitSource:
|
||||
"""A local row as the phase-38 API stores it: the expanded path in
|
||||
both ``path`` and the NOT-NULL ``url`` location column."""
|
||||
return GitSource(url=path, kind="local", path=path)
|
||||
both ``path`` and the NOT-NULL ``url`` location column (plus the
|
||||
phase-89 ignore list, empty by default)."""
|
||||
return GitSource(url=path, kind="local", path=path, ignore_paths=ignore_paths or [])
|
||||
|
||||
|
||||
class FakeImportSources:
|
||||
@@ -66,8 +67,12 @@ class FakeImportSources:
|
||||
*,
|
||||
prune: bool = False,
|
||||
limit: int | None = None,
|
||||
ignore_by_root: dict[str, list[str]] | None = None, # phase 89
|
||||
) -> ImportSummary:
|
||||
self.calls.append({"sources": list(sources), "prune": prune, "limit": limit})
|
||||
self.calls.append(
|
||||
{"sources": list(sources), "prune": prune, "limit": limit,
|
||||
"ignore_by_root": ignore_by_root}
|
||||
)
|
||||
return ImportSummary(files=1, added=1)
|
||||
|
||||
|
||||
@@ -146,9 +151,10 @@ def test_resolve_sources_git_urls_cloned_into_sources_dir(
|
||||
sources_dir=str(tmp_path / "bor"),
|
||||
)
|
||||
|
||||
sources = import_docs._resolve_sources(None, settings)
|
||||
sources, ignore_map = import_docs._resolve_sources(None, settings)
|
||||
|
||||
assert sources == [tmp_path / "bor" / "homelab", tmp_path / "bor" / "deploy"]
|
||||
assert ignore_map == {} # phase 89: no row carries a list → empty map
|
||||
assert calls == [
|
||||
("https://host/a/homelab.git", tmp_path / "bor" / "homelab"),
|
||||
("git@host:user/deploy.git", tmp_path / "bor" / "deploy"),
|
||||
@@ -163,9 +169,10 @@ def test_resolve_sources_cli_source_wins(
|
||||
settings = _settings(git_sources="https://host/a/repo.git")
|
||||
manual = tmp_path / "Manual"
|
||||
|
||||
sources = import_docs._resolve_sources([manual], settings)
|
||||
sources, ignore_map = import_docs._resolve_sources([manual], settings)
|
||||
|
||||
assert sources == [manual]
|
||||
assert ignore_map == {} # phase 89: manual dirs have no rows → no ignore
|
||||
assert calls == [] # git is never touched when --source is given
|
||||
|
||||
|
||||
@@ -186,9 +193,10 @@ def test_resolve_sources_db_rows_win_over_env(
|
||||
sources_dir=str(tmp_path / "bor"),
|
||||
)
|
||||
|
||||
sources = import_docs._resolve_sources(None, settings)
|
||||
sources, ignore_map = import_docs._resolve_sources(None, settings)
|
||||
|
||||
assert sources == [tmp_path / "bor" / "only"]
|
||||
assert ignore_map == {} # phase 89: no row carries a list → empty map
|
||||
assert calls == [("https://db.example/only.git", tmp_path / "bor" / "only")]
|
||||
|
||||
|
||||
@@ -197,8 +205,102 @@ def test_resolve_sources_defaults_when_nothing_configured(
|
||||
) -> None:
|
||||
# Both origins empty (the resolver's ``([], "env")``) → legacy dirs.
|
||||
monkeypatch.setattr(import_docs, "effective_sources", lambda db: ([], "env"))
|
||||
sources = import_docs._resolve_sources(None, _settings())
|
||||
sources, ignore_map = import_docs._resolve_sources(None, _settings())
|
||||
assert sources == [p.expanduser() for p in import_docs.DEFAULT_SOURCES]
|
||||
assert ignore_map == {} # phase 89: the legacy fallback has no rows
|
||||
|
||||
|
||||
def test_resolve_sources_rows_branch_builds_ignore_map(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Phase 89: the rows branch returns each row's ignore list keyed by
|
||||
the resolved root string — the local row's directory, the git row's
|
||||
checkout dir; a row without a list contributes nothing to the map."""
|
||||
calls, fake = _fake_clone_factory()
|
||||
monkeypatch.setattr(import_docs, "clone_or_pull", fake)
|
||||
local_dir = tmp_path / "LocalDocs"
|
||||
local_dir.mkdir()
|
||||
monkeypatch.setattr(
|
||||
import_docs,
|
||||
"effective_sources",
|
||||
lambda db: (
|
||||
[
|
||||
_git_row("https://db.example/only.git"),
|
||||
_local_row(str(local_dir), ignore_paths=["ignore/"]),
|
||||
],
|
||||
"db",
|
||||
),
|
||||
)
|
||||
settings = _settings(sources_dir=str(tmp_path / "bor"))
|
||||
|
||||
sources, ignore_map = import_docs._resolve_sources(None, settings)
|
||||
|
||||
assert sources == [tmp_path / "bor" / "only", local_dir]
|
||||
# Keyed by the SAME string the importer sees (the root, not the name).
|
||||
assert ignore_map == {str(local_dir): ["ignore/"]}
|
||||
|
||||
|
||||
def test_resolve_sources_two_rows_sharing_root_string_extend(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Phase 89 collision rule: two rows resolving to the SAME root
|
||||
string (the sibling/repo-name edge — ``…/shared`` and
|
||||
``…/shared.git``) get the UNION of their lists (extend, not
|
||||
replace), in row order."""
|
||||
calls, fake = _fake_clone_factory()
|
||||
monkeypatch.setattr(import_docs, "clone_or_pull", fake)
|
||||
monkeypatch.setattr(
|
||||
import_docs,
|
||||
"effective_sources",
|
||||
lambda db: (
|
||||
[
|
||||
_git_row("https://a.example/shared", ignore_paths=["a/"]),
|
||||
_git_row("https://a.example/shared.git", ignore_paths=["b"]),
|
||||
],
|
||||
"db",
|
||||
),
|
||||
)
|
||||
settings = _settings(sources_dir=str(tmp_path / "bor"))
|
||||
|
||||
sources, ignore_map = import_docs._resolve_sources(None, settings)
|
||||
|
||||
shared = str(tmp_path / "bor" / "shared")
|
||||
assert sources == [tmp_path / "bor" / "shared", tmp_path / "bor" / "shared"]
|
||||
assert ignore_map == {shared: ["a/", "b"]} # union, row order
|
||||
|
||||
|
||||
def test_main_rows_branch_passes_ignore_map_to_import(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Phase 89: a ``kind=local`` row carrying ``ignore_paths`` →
|
||||
``main`` passes the per-root map to ``import_sources`` (keyed by
|
||||
the directory string, prune flag unchanged)."""
|
||||
settings = _settings(sources_dir=str(tmp_path / "bor"))
|
||||
monkeypatch.setattr(import_docs, "get_settings", lambda: settings)
|
||||
local_dir = tmp_path / "LocalDocs"
|
||||
local_dir.mkdir()
|
||||
(local_dir / "keep.md").write_text("# Keep\nin scope\n", encoding="utf-8")
|
||||
(local_dir / "ignore").mkdir()
|
||||
(local_dir / "ignore" / "secret.md").write_text("# Secret\nignored\n",
|
||||
encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
import_docs,
|
||||
"effective_sources",
|
||||
lambda db: ([_local_row(str(local_dir), ignore_paths=["ignore/"])], "db"),
|
||||
)
|
||||
fake_import = FakeImportSources()
|
||||
monkeypatch.setattr(import_docs, "import_sources", fake_import)
|
||||
_stub_bump(monkeypatch)
|
||||
|
||||
rc = import_docs.main([])
|
||||
|
||||
assert rc == 0
|
||||
call = fake_import.calls[0]
|
||||
assert call["sources"] == [local_dir]
|
||||
assert call["ignore_by_root"] == {str(local_dir): ["ignore/"]}
|
||||
assert call["prune"] is False # the CLI's no-prune default is unchanged
|
||||
|
||||
|
||||
# --- main() ----------------------------------------------------------------
|
||||
@@ -306,9 +408,10 @@ def test_resolve_sources_mixed_git_and_local(
|
||||
sources_dir=str(tmp_path / "bor"),
|
||||
)
|
||||
|
||||
sources = import_docs._resolve_sources(None, settings)
|
||||
sources, ignore_map = import_docs._resolve_sources(None, settings)
|
||||
|
||||
assert sources == [tmp_path / "bor" / "only", local_dir]
|
||||
assert ignore_map == {} # phase 89: neither row carries a list
|
||||
assert calls == [("https://db.example/only.git", tmp_path / "bor" / "only")]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Integration: phase-89 ignore paths through the real import pipeline.
|
||||
|
||||
Phase 89 (TODO.md L3): per-source ignore lists — source-relative path
|
||||
prefixes that are never walked, hence never embedded and never
|
||||
summarized (A1), and previously indexed files that newly match a
|
||||
pattern are pruned on the next ``prune=True`` run (A2). Mirrors the
|
||||
fixture-tree + mock-LLM pattern of ``test_importer_e2e.py``: a
|
||||
``tmp_path`` source dir named ``IgnoreFix`` run through the real
|
||||
``import_sources`` into the compose Postgres, with
|
||||
:class:`tests.fakes.FakeEmbedder` as the deterministic LLM stand-in.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Chunk, Document
|
||||
from app.rag.importer import import_sources
|
||||
from tests.fakes import FakeEmbedder
|
||||
|
||||
NAME = "IgnoreFix"
|
||||
|
||||
|
||||
def _write(root: Path, rel: str, content: str) -> None:
|
||||
path = root / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def _tree(tmp_path: Path, name: str = NAME) -> Path:
|
||||
"""The fixture tree: two kept files + one md and one txt under ``ignore/``."""
|
||||
root = tmp_path / name
|
||||
_write(root, "keep.md", "# Keep\n\nkept body\n")
|
||||
_write(root, "ignore/secret.md", "# Secret\n\nSECRET-CONTENT\n")
|
||||
_write(root, "ignore/notes.txt", "IGNORED-TEXT-CONTENT\n")
|
||||
_write(root, "top.txt", "TOP-TEXT-CONTENT\n")
|
||||
return root
|
||||
|
||||
|
||||
def _reset(db: Session) -> None:
|
||||
# House cleanup pattern (tests/integration/test_importer_e2e.py).
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_ignored_files_never_indexed(db: Session, tmp_path: Path) -> None:
|
||||
_reset(db)
|
||||
root = _tree(tmp_path)
|
||||
llm = FakeEmbedder()
|
||||
|
||||
summary = asyncio.run(
|
||||
import_sources(
|
||||
[root], llm, session=db, ignore_by_root={str(root): ["ignore/"]}
|
||||
)
|
||||
)
|
||||
# Only the two kept files are walked — the ignore/ subtree is
|
||||
# invisible to the pipeline.
|
||||
assert summary.files == 2
|
||||
assert summary.errors == 0
|
||||
# The kept non-markdown file IS summarized; the ignored .txt is not.
|
||||
assert summary.summaries == 1
|
||||
assert summary.summary_errors == 0
|
||||
|
||||
docs = db.scalars(select(Document)).all()
|
||||
assert {(d.source, d.path) for d in docs} == {
|
||||
(NAME, "keep.md"),
|
||||
(NAME, "top.txt"),
|
||||
}
|
||||
# No documents row for an ignored file — hence no chunks rows for it,
|
||||
# no embedding call, and no summary column value, by construction.
|
||||
for rel in ("ignore/secret.md", "ignore/notes.txt"):
|
||||
assert not any(d.path == rel for d in docs)
|
||||
assert not any("SECRET-CONTENT" in c.content for c in db.scalars(select(Chunk)).all())
|
||||
for texts in llm.calls: # every embed batch
|
||||
assert not any(
|
||||
"SECRET-CONTENT" in t or "IGNORED-TEXT-CONTENT" in t for t in texts
|
||||
)
|
||||
# Exactly one summary call (top.txt) — the ignored files never reached
|
||||
# the lite model.
|
||||
assert len(llm.chat_calls) == 1
|
||||
user = next(m["content"] for m in llm.chat_calls[0] if m["role"] == "user")
|
||||
assert "TOP-TEXT-CONTENT" in user
|
||||
assert "SECRET-CONTENT" not in user and "IGNORED-TEXT-CONTENT" not in user
|
||||
top = next(d for d in docs if d.path == "top.txt")
|
||||
assert top.summary is not None
|
||||
_reset(db)
|
||||
|
||||
|
||||
def test_newly_ignored_file_pruned_on_next_prune_run(db: Session, tmp_path: Path) -> None:
|
||||
_reset(db)
|
||||
root = tmp_path / NAME
|
||||
_write(root, "keep.md", "# Keep\n\nkept body\n")
|
||||
_write(root, "top.txt", "TOP-TEXT-CONTENT\n")
|
||||
# Exactly ONE file under ignore/ so the A2 prune count pins it.
|
||||
_write(root, "ignore/secret.md", "# Secret\n\nSECRET-CONTENT\n")
|
||||
llm = FakeEmbedder()
|
||||
|
||||
# First run — no map (omitted entirely): everything is indexed,
|
||||
# including ignore/secret.md.
|
||||
s1 = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert (s1.files, s1.added) == (3, 3)
|
||||
secret = db.scalar(select(Document).where(Document.path == "ignore/secret.md"))
|
||||
assert secret is not None
|
||||
|
||||
# Second run — the owner adds "ignore" (no trailing slash: A1
|
||||
# normalization) and prunes. The file newly matches, never enters
|
||||
# ``seen``, and leaves the index (A2 — the A9 junk-precedent).
|
||||
s2 = asyncio.run(
|
||||
import_sources(
|
||||
[root],
|
||||
llm,
|
||||
session=db,
|
||||
prune=True,
|
||||
ignore_by_root={str(root): ["ignore"]},
|
||||
)
|
||||
)
|
||||
assert s2.files == 2
|
||||
assert s2.pruned == 1
|
||||
assert (
|
||||
db.scalar(select(Document).where(Document.path == "ignore/secret.md")) is None
|
||||
)
|
||||
_reset(db)
|
||||
|
||||
|
||||
def test_progress_total_excludes_ignored(db: Session, tmp_path: Path) -> None:
|
||||
_reset(db)
|
||||
root = _tree(tmp_path)
|
||||
llm = FakeEmbedder()
|
||||
calls: list[tuple[str, str, int, int]] = []
|
||||
|
||||
def progress(source: str, rel: str, done: int, total: int) -> None:
|
||||
calls.append((source, rel, done, total))
|
||||
|
||||
summary = asyncio.run(
|
||||
import_sources(
|
||||
[root],
|
||||
llm,
|
||||
session=db,
|
||||
progress=progress,
|
||||
ignore_by_root={str(root): ["ignore/"]},
|
||||
)
|
||||
)
|
||||
assert summary.files == 2
|
||||
# The phase-64 pre-walk uses the same per-root tuple as the loop:
|
||||
# ``total`` counts ONLY the non-ignored files, and the hook fired
|
||||
# exactly once per imported file.
|
||||
assert [c[2] for c in calls] == [1, 2] # done
|
||||
assert {c[3] for c in calls} == {2} # total — never counts ignored files
|
||||
assert {c[1] for c in calls} == {"keep.md", "top.txt"}
|
||||
_reset(db)
|
||||
|
||||
|
||||
def test_no_map_behavior_is_byte_identical(db: Session, tmp_path: Path) -> None:
|
||||
_reset(db)
|
||||
root = _tree(tmp_path)
|
||||
llm = FakeEmbedder()
|
||||
|
||||
# ``ignore_by_root=None`` (the default): all four files import exactly
|
||||
# as pre-phase-89 callers see them.
|
||||
summary = asyncio.run(import_sources([root], llm, session=db, ignore_by_root=None))
|
||||
assert summary.files == 4
|
||||
docs = db.scalars(select(Document)).all()
|
||||
assert {(d.source, d.path) for d in docs} == {
|
||||
(NAME, "keep.md"),
|
||||
(NAME, "top.txt"),
|
||||
(NAME, "ignore/secret.md"),
|
||||
(NAME, "ignore/notes.txt"),
|
||||
}
|
||||
_reset(db)
|
||||
|
||||
|
||||
def test_unlisted_source_unaffected(db: Session, tmp_path: Path) -> None:
|
||||
_reset(db)
|
||||
root_a = _tree(tmp_path, name="IgnoreFixA")
|
||||
root_b = tmp_path / "IgnoreFixB"
|
||||
_write(root_b, "one.md", "# One\n\none body\n")
|
||||
_write(root_b, "two.txt", "TWO-TEXT-CONTENT\n")
|
||||
llm = FakeEmbedder()
|
||||
|
||||
# The map keys ONLY the first root — the second imports everything.
|
||||
summary = asyncio.run(
|
||||
import_sources(
|
||||
[root_a, root_b],
|
||||
llm,
|
||||
session=db,
|
||||
ignore_by_root={str(root_a): ["ignore"]},
|
||||
)
|
||||
)
|
||||
# A: keep.md + top.txt (the whole ignore/ subtree is dropped) · B: one.md + two.txt
|
||||
assert summary.files == 4
|
||||
docs = db.scalars(select(Document)).all()
|
||||
assert {(d.source, d.path) for d in docs} == {
|
||||
("IgnoreFixA", "keep.md"),
|
||||
("IgnoreFixA", "top.txt"),
|
||||
("IgnoreFixB", "one.md"),
|
||||
("IgnoreFixB", "two.txt"),
|
||||
}
|
||||
assert not any(d.path == "ignore/secret.md" for d in docs)
|
||||
_reset(db)
|
||||
@@ -238,6 +238,10 @@ class FakeImportSources:
|
||||
# Phase 64 (task 02): the progress hook the runner passes (a live
|
||||
# closure while wired, None if the wiring regresses).
|
||||
self.progress_hooks: list[object] = []
|
||||
# Phase 89: the per-root ignore map the runner builds from the
|
||||
# rows' ``ignore_paths`` (keyed by the root string the importer
|
||||
# sees; two rows sharing a root string get the union).
|
||||
self.ignore_maps: list[dict[str, list[str]]] = []
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
@@ -248,11 +252,13 @@ class FakeImportSources:
|
||||
limit: int | None = None,
|
||||
session: Session | None = None,
|
||||
progress: Callable[[str, str, int, int], None] | None = None,
|
||||
ignore_by_root: dict[str, list[str]] | None = None,
|
||||
) -> ImportSummary:
|
||||
self.sources.append(list(sources))
|
||||
self.llms.append(llm)
|
||||
self.prune_flags.append(prune)
|
||||
self.progress_hooks.append(progress)
|
||||
self.ignore_maps.append(ignore_by_root or {})
|
||||
if self.delay:
|
||||
await asyncio.sleep(self.delay)
|
||||
return self.summary
|
||||
@@ -747,6 +753,7 @@ def test_import_error_is_reported_with_credentials_masked(
|
||||
limit: int | None = None,
|
||||
session: Session | None = None,
|
||||
progress: Callable[[str, str, int, int], None] | None = None,
|
||||
ignore_by_root: dict[str, list[str]] | None = None,
|
||||
) -> ImportSummary:
|
||||
raise EmbeddingError(
|
||||
"embeddings request to https://user:secret@aipi.reeseapps.com/v1 "
|
||||
@@ -897,3 +904,128 @@ def test_probe_runs_before_source_resolution(
|
||||
assert order == ["probe", "effective_sources"]
|
||||
assert clone_calls == []
|
||||
assert "short-circuit" in body["error"] # the spy aborted the run
|
||||
|
||||
|
||||
# --- phase 89: per-row ignore lists ------------------------------------------
|
||||
|
||||
|
||||
def test_local_row_ignore_paths_excluded_from_sync(
|
||||
sync_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
db: Session,
|
||||
tmp_path: Path,
|
||||
clean_documents: None,
|
||||
) -> None:
|
||||
"""Phase 89: a local row carrying ``ignore_paths`` — the button
|
||||
sync skips every matching file: the ignored file never lands in the
|
||||
KB (no document row — hence no embedding, no summary), and the
|
||||
success detail's ``files``/``added`` counts exclude it, while the
|
||||
kept file imports as usual."""
|
||||
local_dir = tmp_path / "LocalDocs"
|
||||
local_dir.mkdir()
|
||||
(local_dir / "keep.md").write_text("# Keep\nin scope\n", encoding="utf-8")
|
||||
(local_dir / "ignore").mkdir()
|
||||
(local_dir / "ignore" / "secret.md").write_text("# Secret\nignored\n",
|
||||
encoding="utf-8")
|
||||
db.add(
|
||||
GitSource(
|
||||
url=str(local_dir), kind="local", path=str(local_dir),
|
||||
ignore_paths=["ignore/"], # any spelling — the importer normalizes
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
_stub_env(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"get_settings",
|
||||
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||
)
|
||||
_real_llm(monkeypatch) # real import_sources, deterministic embeddings
|
||||
|
||||
_login(sync_client)
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
body = _poll(sync_client, "success")
|
||||
|
||||
# The ignored file is not counted: only keep.md was walked.
|
||||
assert body["detail"]["files"] == 1
|
||||
assert body["detail"]["added"] == 1
|
||||
assert body["detail"]["errors"] == 0
|
||||
# The KB holds exactly the kept file — ignore/secret.md is absent.
|
||||
docs = [(d["source"], d["path"]) for d in sync_client.get("/api/docs").json()["documents"]]
|
||||
assert docs == [("LocalDocs", "keep.md")]
|
||||
|
||||
|
||||
def test_sync_builds_ignore_map_by_root_string_with_union(
|
||||
sync_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
db: Session,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Phase 89 wiring (fake import): the runner keys the map by the
|
||||
SAME root string the importer sees, and two rows sharing that root
|
||||
string (the sibling/repo-name edge — ``…/shared`` and
|
||||
``…/shared.git`` clone into the same checkout dir) get the UNION
|
||||
of their lists, in row order; a row with an empty list contributes
|
||||
nothing."""
|
||||
url_a = f"file://{tmp_path / 'shared'}"
|
||||
url_b = f"{url_a}.git" # same repo name → same checkout dir
|
||||
# Distinct added_at: the resolver orders by (added_at, id) — a
|
||||
# same-timestamp pair would tie-break on the random uuid.
|
||||
db.add(GitSource(url=url_a, kind="git", ignore_paths=["a/"],
|
||||
added_at=datetime(2026, 1, 1, tzinfo=UTC)))
|
||||
db.add(GitSource(url=url_b, kind="git", ignore_paths=["b"],
|
||||
added_at=datetime(2026, 1, 2, tzinfo=UTC)))
|
||||
db.commit()
|
||||
_stub_env(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"get_settings",
|
||||
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||
)
|
||||
_, fake_clone = _fake_clone()
|
||||
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
||||
_stub_probe(monkeypatch)
|
||||
fake_import = FakeImportSources(ImportSummary(files=1, added=1))
|
||||
monkeypatch.setattr(sync_api, "import_sources", fake_import)
|
||||
monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True))
|
||||
|
||||
_login(sync_client)
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
_poll(sync_client, "success")
|
||||
|
||||
shared = str(tmp_path / "bor" / "shared")
|
||||
# Both rows resolve to the SAME checkout (the collision itself) and
|
||||
# the map holds their union, keyed by that one root string.
|
||||
assert fake_import.sources == [[tmp_path / "bor" / "shared", tmp_path / "bor" / "shared"]]
|
||||
assert fake_import.ignore_maps == [{shared: ["a/", "b"]}]
|
||||
|
||||
|
||||
def test_sync_without_ignore_lists_passes_empty_map(
|
||||
sync_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
db: Session,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Phase 89 regression: rows without a list → the runner passes an
|
||||
EMPTY map (the importer's byte-identical pre-phase-89 behavior)."""
|
||||
local_dir = tmp_path / "LocalDocs"
|
||||
local_dir.mkdir()
|
||||
(local_dir / "notes.md").write_text("# Notes\nplain row\n", encoding="utf-8")
|
||||
_seed_local(db, local_dir) # no ignore_paths → []
|
||||
_stub_env(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"get_settings",
|
||||
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||
)
|
||||
_stub_probe(monkeypatch)
|
||||
fake_import = FakeImportSources(ImportSummary(files=1, added=1))
|
||||
monkeypatch.setattr(sync_api, "import_sources", fake_import)
|
||||
monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True))
|
||||
|
||||
_login(sync_client)
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
_poll(sync_client, "success")
|
||||
|
||||
assert fake_import.sources == [[local_dir]]
|
||||
assert fake_import.ignore_maps == [{}] # no row carried a list
|
||||
|
||||
Reference in New Issue
Block a user