import time from pathlib import Path from typing import cast import pytest from fastapi import FastAPI from fastapi.testclient import TestClient def _collection(client: TestClient, csrf: str) -> str: response = client.post( "/collections", json={"name": "Books"}, headers={"X-CSRF-Token": csrf} ) return cast(str, response.json()["id"]) def _wait(client: TestClient, job_id: str) -> dict[str, object]: for _ in range(100): job = client.get(f"/jobs/{job_id}").json()["job"] if job["state"] not in ("queued", "running"): return cast(dict[str, object], job) time.sleep(0.01) raise AssertionError("job did not finish") def test_persistent_directory_request_enqueues_replayable_job( authenticated: tuple[TestClient, str], tmp_path: Path ) -> None: client, csrf = authenticated books = tmp_path / "docs" / "books" books.mkdir() (books / "a.txt").write_text("a") collection_id = _collection(client, csrf) response = client.post( f"/collections/{collection_id}/memberships", headers={"X-CSRF-Token": csrf}, json={"sources": [{"type": "directory", "path": "books", "mode": "persistent", "excluded_directories": []}]}, ) assert response.status_code == 202 assert response.json()["job"]["state"] == "queued" job_id = response.json()["job"]["id"] assert _wait(client, job_id)["state"] == "completed" rules = client.get(f"/collections/{collection_id}/directory-rules").json()["items"] assert len(rules) == 1 members = client.get(f"/collections/{collection_id}/documents").json()["items"] assert len(members) == 1 assert members[0]["contributing_rules"] == [rules[0]["id"]] def test_invalid_file_source_combination_is_rejected( authenticated: tuple[TestClient, str] ) -> None: client, csrf = authenticated collection_id = _collection(client, csrf) response = client.post( f"/collections/{collection_id}/memberships", headers={"X-CSRF-Token": csrf}, json={"sources": [{"type": "file", "path": "a.txt", "mode": "persistent"}]}, ) assert response.status_code == 422 def test_duplicate_normalized_sources_are_rejected( authenticated: tuple[TestClient, str] ) -> None: client, csrf = authenticated collection_id = _collection(client, csrf) response = client.post( f"/collections/{collection_id}/memberships", headers={"X-CSRF-Token": csrf}, json={"sources": [ {"type": "file", "path": "books/a.txt"}, {"type": "file", "path": "books//a.txt"}, ]}, ) assert response.status_code == 422 def test_strict_job_keeps_membership_empty_on_file_error( authenticated: tuple[TestClient, str], tmp_path: Path ) -> None: client, csrf = authenticated (tmp_path / "docs" / "good.txt").write_text("good") collection_id = _collection(client, csrf) response = client.post( f"/collections/{collection_id}/memberships", headers={"X-CSRF-Token": csrf}, json={"sources": [ {"type": "file", "path": "good.txt"}, {"type": "file", "path": "gone.txt"}, ], "fail_on_file_error": True}, ) assert _wait(client, response.json()["job"]["id"])["state"] == "failed" assert client.get(f"/collections/{collection_id}/documents").json()["items"] == [] def test_removing_one_rule_preserves_overlapping_membership( authenticated: tuple[TestClient, str], tmp_path: Path ) -> None: client, csrf = authenticated root = tmp_path / "docs" for name in ("books", "other"): directory = root / name directory.mkdir() (directory / "a.txt").write_text("same") collection_id = _collection(client, csrf) for name in ("books", "other"): response = client.post( f"/collections/{collection_id}/memberships", headers={"X-CSRF-Token": csrf}, json={"sources": [{"type": "directory", "path": name, "mode": "persistent"}]}, ) assert _wait(client, response.json()["job"]["id"])["state"] == "completed" rules = client.get(f"/collections/{collection_id}/directory-rules").json()["items"] response = client.delete( f"/collections/{collection_id}/directory-rules/{rules[0]['id']}", headers={"X-CSRF-Token": csrf}, ) assert _wait(client, response.json()["job"]["id"])["state"] == "completed" members = client.get(f"/collections/{collection_id}/documents").json()["items"] assert len(members) == 1 assert members[0]["contributing_rules"] == [rules[1]["id"]] def test_rule_exclusion_patch_removes_rule_membership( authenticated: tuple[TestClient, str], tmp_path: Path ) -> None: client, csrf = authenticated skipped = tmp_path / "docs" / "books" / "skip" skipped.mkdir(parents=True) (skipped / "a.txt").write_text("a") collection_id = _collection(client, csrf) response = client.post( f"/collections/{collection_id}/memberships", headers={"X-CSRF-Token": csrf}, json={"sources": [{"type": "directory", "path": "books", "mode": "persistent"}]}, ) assert _wait(client, response.json()["job"]["id"])["state"] == "completed" rule_id = client.get(f"/collections/{collection_id}/directory-rules").json()["items"][0]["id"] patch = client.patch( f"/collections/{collection_id}/directory-rules/{rule_id}", headers={"X-CSRF-Token": csrf}, json={"excluded_directories": ["books/skip"]}, ) assert _wait(client, patch.json()["job"]["id"])["state"] == "completed" assert client.get(f"/collections/{collection_id}/documents").json()["items"] == [] def test_remove_document_creates_override_then_direct_add_restores_it( authenticated: tuple[TestClient, str], tmp_path: Path ) -> None: client, csrf = authenticated books = tmp_path / "docs" / "books" books.mkdir() (books / "a.txt").write_text("a") collection_id = _collection(client, csrf) response = client.post( f"/collections/{collection_id}/memberships", headers={"X-CSRF-Token": csrf}, json={"sources": [{"type": "directory", "path": "books", "mode": "persistent"}]}, ) assert _wait(client, response.json()["job"]["id"])["state"] == "completed" documents = client.get(f"/collections/{collection_id}/documents").json()["items"] document_id = documents[0]["document_id"] removed = client.delete( f"/collections/{collection_id}/documents/{document_id}", headers={"X-CSRF-Token": csrf}, ) assert _wait(client, removed.json()["job"]["id"])["state"] == "completed" assert client.get(f"/collections/{collection_id}/documents").json()["items"] == [] added = client.post( f"/collections/{collection_id}/memberships", headers={"X-CSRF-Token": csrf}, json={"sources": [{"type": "file", "path": "books/a.txt"}]}, ) assert _wait(client, added.json()["job"]["id"])["state"] == "completed" members = client.get(f"/collections/{collection_id}/documents").json()["items"] assert len(members) == 1 assert members[0]["direct"] is True assert members[0]["override"] is False def test_rule_deletion_retry_reindexes_removed_membership( authenticated: tuple[TestClient, str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: client, csrf = authenticated books = tmp_path / "docs" / "books" books.mkdir() file = books / "a.txt" file.write_text("book") collection_id = _collection(client, csrf) created = client.post( f"/collections/{collection_id}/memberships", headers={"X-CSRF-Token": csrf}, json={"sources": [{"type": "directory", "path": "books", "mode": "persistent"}]}, ) assert _wait(client, created.json()["job"]["id"])["state"] == "completed" rule_id = client.get(f"/collections/{collection_id}/directory-rules").json()["items"][0]["id"] engine = cast(FastAPI, client.app).state.engine original = engine.force_reindex calls: list[tuple[str, ...]] = [] def fail_once(paths: tuple[str, ...]) -> None: calls.append(paths) if len(calls) == 1: raise RuntimeError("simulated indexing failure") original(paths) monkeypatch.setattr(engine, "force_reindex", fail_once) deleted = client.delete( f"/collections/{collection_id}/directory-rules/{rule_id}", headers={"X-CSRF-Token": csrf}, ) job_id = deleted.json()["job"]["id"] assert _wait(client, job_id)["state"] == "failed" retried = client.post(f"/jobs/{job_id}/retry", headers={"X-CSRF-Token": csrf}) assert retried.status_code == 202 assert _wait(client, job_id)["state"] == "completed" assert calls == [(str(file),), (str(file),)] def test_rule_patch_retry_reindexes_excluded_member( authenticated: tuple[TestClient, str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: client, csrf = authenticated skipped = tmp_path / "docs" / "books" / "skip" skipped.mkdir(parents=True) file = skipped / "a.txt" file.write_text("book") collection_id = _collection(client, csrf) created = client.post( f"/collections/{collection_id}/memberships", headers={"X-CSRF-Token": csrf}, json={"sources": [{"type": "directory", "path": "books", "mode": "persistent"}]}, ) assert _wait(client, created.json()["job"]["id"])["state"] == "completed" rule_id = client.get(f"/collections/{collection_id}/directory-rules").json()["items"][0]["id"] engine = cast(FastAPI, client.app).state.engine original = engine.force_reindex calls: list[tuple[str, ...]] = [] def fail_once(paths: tuple[str, ...]) -> None: calls.append(paths) if len(calls) == 1: raise RuntimeError("simulated indexing failure") original(paths) monkeypatch.setattr(engine, "force_reindex", fail_once) patched = client.patch( f"/collections/{collection_id}/directory-rules/{rule_id}", headers={"X-CSRF-Token": csrf}, json={"excluded_directories": ["books/skip"]}, ) job_id = patched.json()["job"]["id"] assert _wait(client, job_id)["state"] == "failed" client.post(f"/jobs/{job_id}/retry", headers={"X-CSRF-Token": csrf}) assert _wait(client, job_id)["state"] == "completed" assert calls == [(str(file),), (str(file),)] def test_rule_list_validates_pagination(authenticated: tuple[TestClient, str]) -> None: client, csrf = authenticated collection_id = _collection(client, csrf) response = client.get( f"/collections/{collection_id}/directory-rules", params={"limit": 201} ) assert response.status_code == 422 assert response.json()["error"]["code"] == "validation_failed"