diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 24415a8..aa6f761 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,8 +6,8 @@ How `recoll-modern` is built today. Update in the same commit as any structural The Python service scaffold, SQLite schema, collection and membership repositories, document discovery/identity modules, durable job worker, engine adapter, authentication service, and initial -HTTP shell, membership routes, and job routes are built. Search and document retrieval remain to be -added. Recoll behaviour +HTTP shell, membership routes, job routes, scoped search, and document retrieval are built. Docker +packaging and real-container contract coverage remain. Recoll behaviour below was verified against a real container. The goal: a self-hosted service that wraps Recoll's indexing and full-text search behind (a) a modern @@ -32,7 +32,8 @@ web UI and (b) an MCP server, running in Docker, with several independent collec | `helper/` | C reader run by Recoll's `metadatacmds` for each indexed file | | `src/recoll_modern/engine/` | Shared Protocol, deterministic fake, and isolated Recoll Python/indexer adapter | | `src/recoll_modern/domain/auth.py`, `storage/auth.py` | Argon2 password checks, seeded administrator, hashed server-side sessions and CSRF verification | -| `src/recoll_modern/api/` | HTTP auth, health, file browsing, collection/membership/rule/job resources, prefix/proxy handling and JSON errors | +| `src/recoll_modern/api/` | HTTP auth, health, file browsing, collection/membership/rule/job resources, search, document retrieval, prefix/proxy handling and JSON errors | +| `src/recoll_modern/domain/search.py` | Validates explicit scope and fields, projects current SQLite identities and membership onto engine-ranked hits | | `tests/` | Automated service tests | Discovery produces content hashes and root-relative paths without writing to the document volume. @@ -109,6 +110,11 @@ Membership, rule, and reindex writes create versioned, durable jobs. The worker provenance, exports the membership map, and forces affected paths through the engine. Document listing derives effective membership from SQLite and includes direct and rule provenance. +Search passes all requested collection IDs into one engine query, then verifies hit paths and effective +membership in SQLite before returning them. Full-text and original-file routes resolve a live canonical +path under the configured root. Original responses use range-capable `FileResponse`, with inline display +limited to safe media types. + The resource groups are auth, health, read-only file browsing, collections and membership sources, directory rules, scoped search, document text/original retrieval, and indexing jobs. Search callers select returned fields; only `document_id` is unconditional, and inline full text is bounded. Every diff --git a/docs/TODO.md b/docs/TODO.md index 9b16126..54eb82a 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -15,12 +15,13 @@ Where the workflow stands (`AGENTS.md` -> Workflow: brainstorm -> spec -> plan - selectable search fields, subpath hosting and the packaging seam for deferred desktop spec 005. - Plan `docs/plans/001-search-engine-api.md`: **approved 2026-09-18.** Its listed Python runtime and development dependencies are approved. Execution follows the plan task by task. -- Plan Tasks 1–3 are complete and independently reviewed. Tasks 4–11 add discovery, content-hash +- Plan Tasks 1–3 are complete and independently reviewed. Tasks 4–12 add discovery, content-hash identity, additive membership, durable jobs, the atomic membership map, the engine boundary, - session authentication, the HTTP shell, and membership/rule/job routes. Their checkboxes are current - in the plan. The next task is scoped search and document retrieval. + session authentication, the HTTP shell, membership/rule/job routes, scoped search and document + retrieval. Their checkboxes are current in the plan. The next task is Docker packaging. -The next action is Task 12, "Expose scoped search and document retrieval." Do not repeat Tasks 1–11. +The next action is Task 13, "Package Recoll and the service with Docker Compose." Do not repeat Tasks +1–12. Things a resuming agent will otherwise get wrong: @@ -69,6 +70,10 @@ Things a resuming agent will otherwise get wrong: paths exist, and fail if `AGENTS.md` exceeds 150 lines. ## Known issues +- Original file download checks containment before returning `FileResponse`; harden against a local + symlink swap between that check and the response's file open before shipping to untrusted local + writers. The Compose document mount is read-only to the service, but host writers may still replace + entries concurrently. - The API test suite reports deprecation warnings from the installed FastAPI/Starlette TestClient dependency. Replace its client integration when the dependency stack supports the successor API. - Directory sources currently require a named child of the document root; decide whether the root diff --git a/docs/plans/001-search-engine-api.md b/docs/plans/001-search-engine-api.md index 083266b..632595e 100644 --- a/docs/plans/001-search-engine-api.md +++ b/docs/plans/001-search-engine-api.md @@ -845,7 +845,7 @@ git commit -m "feat: expose membership and job APIs" - Produces: `SearchService.search(SearchInput) -> SearchResult` - Produces: `GET /search`, `GET /documents/{hash}/text`, `GET /documents/{hash}/file` -- [ ] **Step 1: Write failing scope and projection tests** +- [x] **Step 1: Write failing scope and projection tests** ```python def test_search_requires_scope(client: TestClient) -> None: @@ -865,21 +865,21 @@ def test_field_projection_and_text_limit(client: TestClient) -> None: Add tests for unknown collections/fields, repeated collections, default fields, one ranked set, approximate total flag, 20/100 pagination, mount-safe links, missing documents, safe Content-Disposition, MIME type, and byte ranges (`206`, `Content-Range`, invalid range `416`). -- [ ] **Step 2: Run tests and observe failure** +- [x] **Step 2: Run tests and observe failure** Run: `uv run pytest tests/domain/test_search.py tests/api/test_search.py tests/api/test_documents.py -v` -- [ ] **Step 3: Implement search validation and projection** +- [x] **Step 3: Implement search validation and projection** Require nonblank `q`, at least one unique existing collection ID, `offset >= 0`, and `1 <= limit <= 100`. Allowed fields are exactly `title`, `author`, `path`, `mime_type`, `size`, `date`, `score`, `collections`, `snippets`, `text`, and `links`; always emit `document_id`. Default to all metadata, collections, and snippets. Set `fetch_text=True` only when requested, clamp `text_limit` to the configured maximum, slice by Unicode code points, and emit `text_truncated` only with text. Resolve hit collection IDs from SQLite, not stale engine metadata. Build links with `request.url_for`, so configured/trusted scope supplies scheme, host, and prefix. -- [ ] **Step 4: Implement full text and original streaming** +- [x] **Step 4: Implement full text and original streaming** Look up the live canonical path by hash and return `document_not_found` for unknown hashes or `document_missing` when none remains. Text delegates to `Engine.extracted_text`. Original files use Starlette `FileResponse`, which handles byte ranges, after a final containment/open check. Sanitize the download filename to remove CR/LF/quotes and use RFC 5987 encoding; use `attachment` for HTML/SVG and `inline` only for an allowlist of safe document/media types. -- [ ] **Step 5: Verify and commit** +- [x] **Step 5: Verify and commit** Run: `uv run pytest tests/domain/test_search.py tests/api/test_search.py tests/api/test_documents.py -v && uv run ruff check . && uv run mypy src tests` diff --git a/src/recoll_modern/api/documents.py b/src/recoll_modern/api/documents.py new file mode 100644 index 0000000..b9dbc04 --- /dev/null +++ b/src/recoll_modern/api/documents.py @@ -0,0 +1,53 @@ +"""Complete extracted text and safe original-file responses.""" + +import mimetypes +from pathlib import Path +from typing import cast + +from fastapi import APIRouter, Request +from fastapi.responses import FileResponse + +from recoll_modern.api.dependencies import require_user +from recoll_modern.domain.errors import AppError +from recoll_modern.engine.protocol import EngineUnavailable + +router = APIRouter() +SAFE_INLINE_TYPES = frozenset({ + "text/plain", "application/pdf", "image/png", "image/jpeg", "image/gif", + "audio/mpeg", "audio/ogg", "video/mp4", +}) + + +def _path(request: Request, document_hash: str) -> Path: + document = request.app.state.documents.get(document_hash) + if document is None: + raise AppError("document_not_found", "Document was not found", 404) + if document.canonical_path is None: + raise AppError("document_missing", "Document is missing", 404) + root = request.app.state.path_policy.root + path = (root / document.canonical_path).resolve() + if not path.is_relative_to(root) or not path.is_file(): + raise AppError("document_missing", "Document is missing", 404) + return cast(Path, path) + + +@router.get("/documents/{document_hash}/text", name="document_text") +def document_text(document_hash: str, request: Request) -> dict[str, str]: + require_user(request) + path = _path(request, document_hash) + try: + return {"text": request.app.state.engine.extracted_text(str(path))} + except EngineUnavailable: + raise AppError("engine_unavailable", "Search engine is unavailable", 503) from None + + +@router.get("/documents/{document_hash}/file", name="document_file") +def document_file(document_hash: str, request: Request) -> FileResponse: + require_user(request) + path = _path(request, document_hash) + filename = path.name.replace("\r", "_").replace("\n", "_").replace('"', "_") + media_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" + return FileResponse( + path, media_type=media_type, filename=filename, + content_disposition_type="inline" if media_type in SAFE_INLINE_TYPES else "attachment", + ) diff --git a/src/recoll_modern/api/search.py b/src/recoll_modern/api/search.py new file mode 100644 index 0000000..a27ca55 --- /dev/null +++ b/src/recoll_modern/api/search.py @@ -0,0 +1,36 @@ +"""Authenticated, explicitly scoped search endpoint.""" + +from fastapi import APIRouter, Request +from fastapi.encoders import jsonable_encoder + +from recoll_modern.api.dependencies import require_user +from recoll_modern.domain.search import SearchInput + +router = APIRouter() + + +@router.get("/search") +def search(request: Request, q: str = "", limit: int = 20, offset: int = 0, + text_limit: int | None = None) -> dict[str, object]: + require_user(request) + collections = tuple(request.query_params.getlist("collections")) + fields = ( + tuple(request.query_params.getlist("fields")) + if "fields" in request.query_params else None + ) + for collection_id in dict.fromkeys(collections): + request.app.state.collections.get(collection_id) + settings = request.app.state.settings + result = request.app.state.search.search(SearchInput( + q, collections, fields, limit, offset, + settings.inline_text_limit if text_limit is None else text_limit, + )) + for hit in result.hits: + if fields is not None and "links" in fields: + document_id = str(hit["document_id"]) + hit["links"] = { + "text": str(request.url_for("document_text", document_hash=document_id)), + "file": str(request.url_for("document_file", document_hash=document_id)), + } + return {"hits": jsonable_encoder(result.hits), "total": result.total, + "total_is_approximate": result.total_is_approximate} diff --git a/src/recoll_modern/app.py b/src/recoll_modern/app.py index 95de8aa..aa676b8 100644 --- a/src/recoll_modern/app.py +++ b/src/recoll_modern/app.py @@ -8,8 +8,10 @@ from datetime import timedelta from fastapi import FastAPI from recoll_modern.api import auth, collections, files, health +from recoll_modern.api import documents as document_routes from recoll_modern.api import jobs as job_routes from recoll_modern.api import memberships as membership_routes +from recoll_modern.api import search as search_routes from recoll_modern.api.errors import install_error_handlers from recoll_modern.api.middleware import PrefixMiddleware from recoll_modern.config import Settings @@ -17,6 +19,7 @@ from recoll_modern.domain.auth import AuthService from recoll_modern.domain.collections import CollectionService from recoll_modern.domain.jobs import Job from recoll_modern.domain.membership import MembershipService +from recoll_modern.domain.search import SearchService from recoll_modern.engine.protocol import Engine from recoll_modern.engine.recoll import RecollEngine from recoll_modern.indexing.handlers import MembershipJobHandler @@ -146,6 +149,9 @@ def create_app(settings: Settings | None = None, engine: Engine | None = None) - app.state.documents = documents app.state.jobs = jobs app.state.worker = worker + app.state.search = SearchService( + engine, documents, memberships, policy, settings.inline_text_limit_max + ) if memberships.list_rules(): jobs.enqueue("reconcile_rules", {"version": 1}, False) worker.start() @@ -163,4 +169,6 @@ def create_app(settings: Settings | None = None, engine: Engine | None = None) - app.include_router(collections.router) app.include_router(membership_routes.router) app.include_router(job_routes.router) + app.include_router(search_routes.router) + app.include_router(document_routes.router) return app diff --git a/src/recoll_modern/domain/search.py b/src/recoll_modern/domain/search.py new file mode 100644 index 0000000..696aad1 --- /dev/null +++ b/src/recoll_modern/domain/search.py @@ -0,0 +1,94 @@ +"""Validate scoped searches and project trusted, current document records.""" + +from dataclasses import dataclass +from pathlib import Path + +from recoll_modern.domain.errors import AppError +from recoll_modern.engine.protocol import Engine, EngineUnavailable, SearchRequest +from recoll_modern.indexing.paths import PathPolicy +from recoll_modern.storage.documents import DocumentRepository +from recoll_modern.storage.membership import MembershipRepository + +ALLOWED_FIELDS = frozenset({ + "title", "author", "path", "mime_type", "size", "date", "score", "collections", + "snippets", "text", "links", +}) +DEFAULT_FIELDS = ALLOWED_FIELDS - {"text", "links"} + + +@dataclass(frozen=True, slots=True) +class SearchInput: + query: str + collection_ids: tuple[str, ...] + fields: tuple[str, ...] | None = None + limit: int = 20 + offset: int = 0 + text_limit: int = 20_000 + + +@dataclass(frozen=True, slots=True) +class SearchResult: + hits: list[dict[str, object]] + total: int + total_is_approximate: bool + + +class SearchService: + def __init__( + self, engine: Engine, documents: DocumentRepository, + memberships: MembershipRepository, policy: PathPolicy, text_limit_max: int, + ) -> None: + self._engine = engine + self._documents = documents + self._memberships = memberships + self._policy = policy + self._text_limit_max = text_limit_max + + def search(self, search: SearchInput) -> SearchResult: + if not search.query.strip(): + raise AppError("invalid_query", "Query must not be empty", 400) + if not search.collection_ids: + raise AppError("scope_required", "At least one collection is required", 400) + if search.fields is not None and set(search.fields) - ALLOWED_FIELDS: + raise AppError("invalid_query", "Unknown search field", 400) + if not 1 <= search.limit <= 100 or search.offset < 0 or search.text_limit < 0: + raise AppError("validation_failed", "Invalid search bounds", 422) + fields = DEFAULT_FIELDS if search.fields is None else set(search.fields) + scope = tuple(dict.fromkeys(search.collection_ids)) + try: + page = self._engine.search(SearchRequest( + search.query.strip(), scope, search.limit, search.offset, "text" in fields + )) + except EngineUnavailable: + raise AppError("engine_unavailable", "Search engine is unavailable", 503) from None + results: list[dict[str, object]] = [] + for hit in page.hits: + try: + path = Path(hit.path) + relative = path.relative_to(self._policy.root).as_posix() + if not path.resolve().is_relative_to(self._policy.root): + continue + except ValueError: + continue + document = self._documents.by_path(relative) + if document is None or document.canonical_path != relative: + continue + current_collections = self._memberships.effective_collections(document.content_hash) + if not set(current_collections).intersection(scope): + continue + values: dict[str, object] = { + "title": hit.title, "author": hit.author, "path": relative, + "mime_type": hit.mime_type, "size": hit.size if hit.size is not None + else document.byte_size, + "date": hit.date, "score": hit.score, + "collections": current_collections, "snippets": list(hit.snippets), + } + item: dict[str, object] = {"document_id": document.content_hash} + item.update({name: values[name] for name in fields if name in values}) + if "text" in fields: + full = hit.text or "" + bound = min(search.text_limit, self._text_limit_max) + item["text"] = full[:bound] + item["text_truncated"] = len(full) > bound + results.append(item) + return SearchResult(results, page.total, page.total_is_approximate) diff --git a/src/recoll_modern/storage/documents.py b/src/recoll_modern/storage/documents.py index 5094e19..57c25e4 100644 --- a/src/recoll_modern/storage/documents.py +++ b/src/recoll_modern/storage/documents.py @@ -32,6 +32,15 @@ class DocumentRepository: with self._database.connect() as connection: return _record(connection, content_hash) + def by_path(self, relative_path: str) -> DocumentRecord | None: + with self._database.connect() as connection: + row = connection.execute( + "SELECT document_hash FROM document_paths " + "WHERE relative_path = ? AND missing_at IS NULL", + (relative_path,), + ).fetchone() + return _record(connection, str(row[0])) if row is not None else None + def known_paths_under(self, relative_directory: str) -> tuple[str, ...]: """Return known live paths beneath a directory for explicit reconciliation.""" prefix = relative_directory + "/" diff --git a/tests/api/test_documents.py b/tests/api/test_documents.py new file mode 100644 index 0000000..e55a6c6 --- /dev/null +++ b/tests/api/test_documents.py @@ -0,0 +1,64 @@ +"""Extracted text and safe original file retrieval.""" + +from pathlib import Path +from typing import cast + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from recoll_modern.engine.fake import FakeRecord +from recoll_modern.indexing.discovery import ObservedFile + + +def _document(client: TestClient, tmp_path: Path) -> str: + path = tmp_path / "docs" / "quoted\"name.txt" + path.write_bytes(b"abcdef") + digest = "b" * 64 + app = cast(FastAPI, client.app) + app.state.documents.observe_files([ObservedFile(path.name, digest, 6)]) + app.state.engine.records = [FakeRecord(str(path), "Title", "complete text", ())] + return digest + + +def test_text_and_byte_range(authenticated: tuple[TestClient, str], tmp_path: Path) -> None: + client, _ = authenticated + digest = _document(client, tmp_path) + text = client.get(f"/documents/{digest}/text") + assert text.status_code == 200 + assert text.json()["text"] == "complete text" + response = client.get(f"/documents/{digest}/file", headers={"Range": "bytes=1-3"}) + assert response.status_code == 206 + assert response.content == b"bcd" + assert response.headers["content-range"] == "bytes 1-3/6" + assert response.headers["content-disposition"] == 'inline; filename="quoted_name.txt"' + assert client.get( + f"/documents/{digest}/file", headers={"Range": "bytes=99-100"} + ).status_code == 416 + + +def test_unknown_and_missing_documents( + authenticated: tuple[TestClient, str], tmp_path: Path +) -> None: + client, _ = authenticated + assert client.get(f"/documents/{'0' * 64}/text").json()["error"]["code"] == ( + "document_not_found" + ) + digest = _document(client, tmp_path) + (tmp_path / "docs" / 'quoted"name.txt').unlink() + response = client.get(f"/documents/{digest}/file") + assert response.status_code == 404 + assert response.json()["error"]["code"] == "document_missing" + + +def test_html_is_attachment(authenticated: tuple[TestClient, str], tmp_path: Path) -> None: + client, _ = authenticated + path = tmp_path / "docs" / "danger.html" + path.write_text("") + digest = "d" * 64 + cast(FastAPI, client.app).state.documents.observe_files([ + ObservedFile("danger.html", digest, path.stat().st_size) + ]) + response = client.get(f"/documents/{digest}/file") + assert response.status_code == 200 + assert response.headers["content-disposition"].startswith("attachment;") + assert response.headers["content-type"].startswith("text/html") diff --git a/tests/api/test_search.py b/tests/api/test_search.py new file mode 100644 index 0000000..3ecfb94 --- /dev/null +++ b/tests/api/test_search.py @@ -0,0 +1,140 @@ +"""Scoped search transport and result projection.""" + +from pathlib import Path +from typing import cast + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from recoll_modern.engine.fake import FakeRecord +from recoll_modern.indexing.discovery import ObservedFile + + +def _seed(client: TestClient, csrf: str, tmp_path: Path) -> tuple[str, str]: + path = tmp_path / "docs" / "book.txt" + path.write_text("python documentation and examples") + collection_id = client.post( + "/collections", json={"name": "Books"}, headers={"X-CSRF-Token": csrf} + ).json()["id"] + digest = "a" * 64 + app = cast(FastAPI, client.app) + app.state.documents.observe_files([ObservedFile("book.txt", digest, path.stat().st_size)]) + app.state.memberships.add_direct(collection_id, digest) + app.state.engine.records = [FakeRecord( + str(path), "Python book", "python documentation and examples", (collection_id,) + )] + return collection_id, digest + + +def test_search_requires_scope(authenticated: tuple[TestClient, str]) -> None: + client, _ = authenticated + response = client.get("/search", params={"q": "python"}) + assert response.status_code == 400 + assert response.json()["error"]["code"] == "scope_required" + + +def test_projection_text_limit_and_scope( + authenticated: tuple[TestClient, str], tmp_path: Path +) -> None: + client, csrf = authenticated + collection_id, digest = _seed(client, csrf, tmp_path) + response = client.get("/search", params=[ + ("q", "python"), ("collections", collection_id), ("fields", "title"), + ("fields", "text"), ("text_limit", "12"), + ]) + assert response.status_code == 200 + hit = response.json()["hits"][0] + assert hit == {"document_id": digest, "title": "Python book", + "text": "python docum", "text_truncated": True} + assert response.json()["total"] == 1 + assert response.json()["total_is_approximate"] is False + + +def test_search_validates_fields_collections_and_pagination( + authenticated: tuple[TestClient, str] +) -> None: + client, csrf = authenticated + collection_id = client.post( + "/collections", json={"name": "Books"}, headers={"X-CSRF-Token": csrf} + ).json()["id"] + for params, status, code in [ + ({"q": " ", "collections": collection_id}, 400, "invalid_query"), + ({"q": "a", "collections": "c0000000000"}, 404, "collection_not_found"), + ({"q": "a", "collections": collection_id, "fields": "bad"}, 400, + "invalid_query"), + ({"q": "a", "collections": collection_id, "limit": 101}, 422, + "validation_failed"), + ]: + response = client.get("/search", params=params) + assert response.status_code == status + assert response.json()["error"]["code"] == code + + +def test_search_uses_sqlite_membership_and_generates_links( + authenticated: tuple[TestClient, str], tmp_path: Path +) -> None: + client, csrf = authenticated + collection_id, digest = _seed(client, csrf, tmp_path) + app = cast(FastAPI, client.app) + app.state.engine.records = [FakeRecord( + str(tmp_path / "docs" / "book.txt"), "Python", "python", (collection_id,), + )] + response = client.get("/search", params=[ + ("q", "python"), ("collections", collection_id), ("collections", collection_id), + ("fields", "links"), ("fields", "collections"), + ]) + assert response.status_code == 200 + hit = response.json()["hits"][0] + assert hit["document_id"] == digest + assert hit["collections"] == [collection_id] + assert hit["links"]["file"].endswith(f"/documents/{digest}/file") + + +def test_default_fields_omit_text_and_links( + authenticated: tuple[TestClient, str], tmp_path: Path +) -> None: + client, csrf = authenticated + collection_id, _ = _seed(client, csrf, tmp_path) + hit = client.get("/search", params={"q": "python", "collections": collection_id}).json()[ + "hits" + ][0] + assert "snippets" in hit and "score" in hit and "collections" in hit + assert "text" not in hit and "links" not in hit + + +def test_stale_engine_collections_do_not_leak_into_result( + authenticated: tuple[TestClient, str], tmp_path: Path +) -> None: + client, csrf = authenticated + collection_id, _ = _seed(client, csrf, tmp_path) + app = cast(FastAPI, client.app) + app.state.engine.records = [FakeRecord( + str(tmp_path / "docs" / "book.txt"), "Python", "python", + (collection_id, "c0000000000"), + )] + response = client.get("/search", params={"q": "python", "collections": collection_id}) + assert response.json()["hits"][0]["collections"] == [collection_id] + + +def test_links_honor_mount_prefix(prefixed_client: TestClient, tmp_path: Path) -> None: + client = prefixed_client + csrf = client.post( + "/recoll/auth/login", json={"username": "admin", "password": "secret-password"} + ).json()["csrf_token"] + path = tmp_path / "docs" / "book.txt" + path.write_text("python") + collection_id = client.post( + "/recoll/collections", json={"name": "Books"}, headers={"X-CSRF-Token": csrf} + ).json()["id"] + digest = "c" * 64 + app = cast(FastAPI, client.app) + app.state.documents.observe_files([ObservedFile("book.txt", digest, 6)]) + app.state.memberships.add_direct(collection_id, digest) + app.state.engine.records = [FakeRecord(str(path), "Python", "python", (collection_id,))] + response = client.get("/recoll/search", params=[ + ("q", "python"), ("collections", collection_id), ("fields", "links"), + ]) + assert response.status_code == 200 + assert response.json()["hits"][0]["links"]["file"].endswith( + f"/recoll/documents/{digest}/file" + ) diff --git a/tests/domain/test_search.py b/tests/domain/test_search.py new file mode 100644 index 0000000..288e087 --- /dev/null +++ b/tests/domain/test_search.py @@ -0,0 +1,36 @@ +"""Search projections use current membership and Unicode text bounds.""" + +from pathlib import Path + +from recoll_modern.domain.models import CollectionCreate +from recoll_modern.domain.search import SearchInput, SearchService +from recoll_modern.engine.fake import FakeEngine, FakeRecord +from recoll_modern.indexing.discovery import ObservedFile +from recoll_modern.indexing.paths import PathPolicy +from recoll_modern.storage.collections import SQLiteCollectionRepository +from recoll_modern.storage.database import Database +from recoll_modern.storage.documents import DocumentRepository +from recoll_modern.storage.membership import MembershipRepository + + +def test_unicode_limit_and_stale_membership_are_checked_against_sqlite(tmp_path: Path) -> None: + root = tmp_path / "docs" + root.mkdir() + path = root / "é.txt" + path.write_text("🦊alpha") + database = Database(tmp_path / "state.sqlite3") + database.migrate() + collection = SQLiteCollectionRepository(database).create(CollectionCreate("Books", None)) + documents = DocumentRepository(database) + digest = "e" * 64 + documents.observe_files([ObservedFile("é.txt", digest, path.stat().st_size)]) + memberships = MembershipRepository(database) + memberships.add_direct(collection.id, digest) + engine = FakeEngine([FakeRecord( + str(path), "Book", "🦊alpha", (collection.id, "c0000000000") + )]) + service = SearchService(engine, documents, memberships, PathPolicy(root), 2) + result = service.search(SearchInput("alpha", (collection.id,), ("text", "collections"), + text_limit=20)) + assert result.hits == [{"document_id": digest, "collections": [collection.id], + "text": "🦊a", "text_truncated": True}]