diff --git a/nextcloud_mcp_server/client/__init__.py b/nextcloud_mcp_server/client/__init__.py index bb311af4..e8e989e1 100644 --- a/nextcloud_mcp_server/client/__init__.py +++ b/nextcloud_mcp_server/client/__init__.py @@ -1,5 +1,6 @@ import logging import os +from email.utils import parsedate_to_datetime from httpx import ( AsyncBaseTransport, @@ -46,6 +47,44 @@ async def log_response(response: Response): logger.debug("Response [%s] %s", response.status_code, response.text) +def _normalise_search_result(item: dict) -> dict: + """Normalise a webdav.search_files item to the get_files_by_tag shape. + + ``WebDAVClient.search_files`` and ``WebDAVClient.get_files_by_tag`` both + return per-file dicts but with subtly different keys (``file_id`` vs + ``id``) and path conventions (no leading slash vs leading slash). This + helper makes a search result interchangeable with a tagged-file result + so callers (notably the vector scanner) can consume both via one shape. + """ + path = item.get("path", "") + if path and not path.startswith("/"): + path = "/" + path + + last_modified_timestamp = item.get("last_modified_timestamp") + last_modified = item.get("last_modified") + if last_modified_timestamp is None and last_modified: + try: + last_modified_timestamp = int( + parsedate_to_datetime(last_modified).timestamp() + ) + except (TypeError, ValueError): + last_modified_timestamp = None + + file_id = item.get("file_id") if item.get("file_id") is not None else item.get("id") + + return { + "id": file_id, + "path": path, + "name": item.get("name") or (path.rsplit("/", 1)[-1] if path else ""), + "size": item.get("size", 0), + "content_type": item.get("content_type", ""), + "last_modified": last_modified, + "last_modified_timestamp": last_modified_timestamp, + "etag": item.get("etag"), + "is_directory": item.get("is_directory", False), + } + + class AsyncDisableCookieTransport(AsyncBaseTransport): """This Transport disable cookies from accumulating in the httpx AsyncClient @@ -164,53 +203,123 @@ class NextcloudClient: This method coordinates tag lookup and file retrieval via WebDAV: 1. Look up the tag ID by name - 2. Get all files with that tag (via REPORT with full metadata) - 3. Optionally filter by MIME type + 2. Get all entries (files and directories) with that tag via REPORT + 3. For each tagged directory, walk descendants matching ``mime_type_filter`` + via WebDAV SEARCH (``Depth: infinity``) so a tag on a folder applies + to every matching file beneath it. Mirrors the directory semantics + of :mod:`nextcloud_mcp_server.server.tag_exclusion` (issue #710). + 4. Dedupe by file id — a file directly tagged AND living under a + tagged ancestor is returned once. + + Directory expansion only runs when ``mime_type_filter`` is set: + without it, expanding a tagged folder would dump the user's entire + tree into the caller, which is almost never what the operator + wanted. Args: tag_name: Name of the system tag to search for (e.g., "vector-index") - mime_type_filter: Optional MIME type filter (e.g., "application/pdf") + mime_type_filter: Optional MIME type filter (e.g., "application/pdf"). + When set, also enables directory expansion. Returns: List of file dictionaries with WebDAV properties (path, size, content_type, etc.) Raises: - RuntimeError: If tag lookup or file query fails + RuntimeError: If tag lookup or the initial file query fails. A + failure walking one tagged directory is logged and skipped — other + directly-tagged files are still returned. Examples: - # Find all files with "vector-index" tag + # Find all files with "vector-index" tag (no directory expansion) files = await nc_client.find_files_by_tag("vector-index") - # Find only PDFs with the tag + # Find only PDFs with the tag, including PDFs under any folder + # that carries the tag pdfs = await nc_client.find_files_by_tag("vector-index", "application/pdf") """ - # Look up tag by name using WebDAV tag = await self.webdav.get_tag_by_name(tag_name) if not tag: - logger.debug(f"Tag '{tag_name}' not found, returning empty list") + logger.debug("Tag %r not found, returning empty list", tag_name) return [] - # Get files with this tag (returns full file info from REPORT) - files = await self.webdav.get_files_by_tag(tag["id"]) - if not files: - logger.debug(f"No files found with tag '{tag_name}'") + items = await self.webdav.get_files_by_tag(tag["id"]) + if not items: + logger.debug("No items found with tag %r", tag_name) return [] - logger.debug(f"Found {len(files)} files with tag '{tag_name}'") + logger.debug( + "Found %d directly-tagged item(s) with tag %r", len(items), tag_name + ) - # Apply MIME type filter if specified + # Split into directly-tagged files vs tagged directories. + by_id: dict[int, dict] = {} + tagged_dirs: list[dict] = [] + for item in items: + if item.get("is_directory"): + tagged_dirs.append(item) + continue + if mime_type_filter and not item.get("content_type", "").startswith( + mime_type_filter + ): + continue + file_id = item.get("id") + if file_id is None: + continue + by_id[file_id] = item + + # Expand each tagged directory into its descendant files matching + # the MIME filter. Skip when no MIME filter is set — see docstring. + if mime_type_filter and tagged_dirs: + for dir_info in tagged_dirs: + dir_path = dir_info.get("path", "").strip("/") + try: + descendants = await self.webdav.find_by_type( + mime_type_filter, scope=dir_path + ) + except Exception as e: + logger.warning( + "Tag-based directory walk failed for %r (tag %r): %s; " + "skipping descendants", + dir_path, + tag_name, + e, + ) + continue + + added = 0 + for d in descendants: + if d.get("is_directory"): + continue + file_id = d.get("file_id") or d.get("id") + if file_id is None: + continue + if file_id in by_id: + # Directly-tagged entry already wins; keeps the + # canonical shape from get_files_by_tag. + continue + by_id[file_id] = _normalise_search_result(d) + added += 1 + + logger.debug( + "Tag %r: directory %r expanded to %d descendant %s file(s)", + tag_name, + dir_path, + added, + mime_type_filter, + ) + + files = list(by_id.values()) if mime_type_filter: - filtered_files = [ - f - for f in files - if f.get("content_type", "").startswith(mime_type_filter) - ] logger.info( - f"Returning {len(filtered_files)} files with tag '{tag_name}' (filtered by {mime_type_filter})" + "Returning %d file(s) with tag %r (mime_type=%s, " + "%d directly-tagged folder(s) expanded)", + len(files), + tag_name, + mime_type_filter, + len(tagged_dirs), ) - return filtered_files - - logger.info(f"Returning {len(files)} files with tag '{tag_name}'") + else: + logger.info("Returning %d file(s) with tag %r", len(files), tag_name) return files def _get_webdav_base_path(self) -> str: diff --git a/nextcloud_mcp_server/vector/scanner.py b/nextcloud_mcp_server/vector/scanner.py index 6973fb9f..4f4edf3f 100644 --- a/nextcloud_mcp_server/vector/scanner.py +++ b/nextcloud_mcp_server/vector/scanner.py @@ -20,6 +20,10 @@ from nextcloud_mcp_server.client.news import NewsItemType from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.observability.metrics import record_vector_sync_scan from nextcloud_mcp_server.observability.tracing import trace_operation +from nextcloud_mcp_server.server.tag_exclusion import ( + get_excluded_file_paths, + is_path_excluded, +) from nextcloud_mcp_server.vector.placeholder import ( query_document_metadata, write_placeholder_point, @@ -410,15 +414,45 @@ async def scan_user_documents( nextcloud_file_ids = set() try: - # Find files with vector-index tag using OCS Tags API + # Find files with vector-index tag using OCS Tags API. + # find_files_by_tag also expands tagged directories into their + # PDF descendants (Depth: infinity SEARCH), so a tag on a + # folder applies to every PDF beneath it. settings = get_settings() tag_name = os.getenv("VECTOR_SYNC_PDF_TAG", "vector-index") - # Use NextcloudClient.find_files_by_tag() which uses proper OCS API - # and filters by PDF MIME type tagged_files = await nc_client.find_files_by_tag( tag_name, mime_type_filter="application/pdf" ) + # Apply EXCLUDED_TAGS as defense-in-depth: a folder marked + # off-limits via the exclusion tag must not be indexed even if + # it (or an ancestor) also carries the include tag. Mirrors the + # "exclusion wins" contract enforced by the MCP file tools. + try: + excluded_paths = await get_excluded_file_paths(nc_client.webdav) + except Exception as e: + logger.warning( + "[SCAN-%s] EXCLUDED_TAGS lookup failed (%s); " + "proceeding without exclusion filter", + scan_id, + e, + ) + excluded_paths = set() + if excluded_paths: + before = len(tagged_files) + tagged_files = [ + f + for f in tagged_files + if not is_path_excluded(f.get("path", ""), excluded_paths) + ] + skipped = before - len(tagged_files) + if skipped: + logger.info( + "[SCAN-%s] Skipped %d tagged file(s) under EXCLUDED_TAGS paths", + scan_id, + skipped, + ) + for file_info in tagged_files: # Files are already filtered by MIME type in find_files_by_tag() file_count += 1 diff --git a/tests/integration/test_tag_inclusion.py b/tests/integration/test_tag_inclusion.py new file mode 100644 index 00000000..6c8ab709 --- /dev/null +++ b/tests/integration/test_tag_inclusion.py @@ -0,0 +1,172 @@ +"""End-to-end integration tests for tag-based file inclusion in +``NextcloudClient.find_files_by_tag``. + +The vector scanner relies on this helper to enumerate files under the +``vector-index`` system tag (env: ``VECTOR_SYNC_PDF_TAG``). A user can +tag either an individual file *or* a folder; in the folder case the +tag should propagate to every matching descendant via a +``Depth: infinity`` WebDAV SEARCH. + +Mirror of ``test_tag_exclusion.py`` but for the *inclusion* path. +Catches integration-level issues that the unit tests in +``tests/unit/client/test_nextcloud_client.py`` cannot, such as +PROPFIND/REPORT/SEARCH semantics, Nextcloud's actual MIME-type +reporting for the test fixtures, and the order in which directly-tagged +files vs descendants are returned. +""" + +import logging +import uuid + +import pytest + +from nextcloud_mcp_server.client import NextcloudClient + +logger = logging.getLogger(__name__) +pytestmark = pytest.mark.integration + + +@pytest.fixture +async def included_tag_environment(nc_client: NextcloudClient): + """Provision a tag, a directly-tagged file, a tagged directory with + a child file, and an untagged sibling — all in a unique per-run + namespace. + + Yields a dict with the layout. Cleanup runs in reverse order: untag, + delete files. The tag itself is left behind (no public delete-tag + API on the client today; tags are cheap and unique-per-run). + """ + suffix = uuid.uuid4().hex[:8] + tag_name = f"mcp-include-{suffix}" + test_dir = f"mcp_tag_incl_{suffix}" + tagged_file = f"{test_dir}/tagged.txt" + tagged_dir = f"{test_dir}/inside_dir" + tagged_dir_child = f"{tagged_dir}/child.txt" + nested_dir = f"{tagged_dir}/nested" + nested_dir_child = f"{nested_dir}/deep.txt" + untagged_file = f"{test_dir}/untagged.txt" + + await nc_client.webdav.create_directory(test_dir) + await nc_client.webdav.create_directory(tagged_dir) + await nc_client.webdav.create_directory(nested_dir) + await nc_client.webdav.write_file(tagged_file, b"tagged file", "text/plain") + await nc_client.webdav.write_file( + tagged_dir_child, b"child of tagged dir", "text/plain" + ) + await nc_client.webdav.write_file( + nested_dir_child, b"deep nested under tagged dir", "text/plain" + ) + await nc_client.webdav.write_file(untagged_file, b"untagged sibling", "text/plain") + + tag = await nc_client.webdav.get_or_create_tag( + name=tag_name, user_visible=True, user_assignable=True + ) + assert tag["id"] is not None, "tag creation did not return an id" + + tagged_file_info = await nc_client.webdav.get_file_info(tagged_file) + tagged_dir_info = await nc_client.webdav.get_file_info(tagged_dir) + assert tagged_file_info is not None and tagged_dir_info is not None + + await nc_client.webdav.assign_tag_to_file(tagged_file_info["id"], tag["id"]) + await nc_client.webdav.assign_tag_to_file(tagged_dir_info["id"], tag["id"]) + + yield { + "tag_name": tag_name, + "tag_id": tag["id"], + "test_dir": test_dir, + "tagged_file": tagged_file, + "tagged_file_id": tagged_file_info["id"], + "tagged_dir": tagged_dir, + "tagged_dir_id": tagged_dir_info["id"], + "tagged_dir_child": tagged_dir_child, + "nested_dir_child": nested_dir_child, + "untagged_file": untagged_file, + } + + for file_id in (tagged_file_info["id"], tagged_dir_info["id"]): + try: + await nc_client.webdav.remove_tag_from_file(file_id, tag["id"]) + except Exception as e: + logger.warning("failed to untag file %s: %s", file_id, e) + try: + await nc_client.webdav.delete_resource(test_dir) + except Exception as e: + logger.warning("failed to delete %s: %s", test_dir, e) + + +def _basenames(files: list[dict]) -> set[str]: + """Return the basename of each file path for assertion convenience.""" + return {f["path"].rstrip("/").rsplit("/", 1)[-1] for f in files} + + +async def test_find_files_by_tag_includes_directly_tagged_file( + included_tag_environment, nc_client: NextcloudClient +): + """A file with the tag directly applied is returned, regardless of the + folder-walk machinery.""" + env = included_tag_environment + + files = await nc_client.find_files_by_tag( + env["tag_name"], mime_type_filter="text/plain" + ) + + names = _basenames(files) + assert "tagged.txt" in names + # untagged sibling outside any tagged directory must not appear + assert "untagged.txt" not in names + + +async def test_find_files_by_tag_expands_tagged_directory( + included_tag_environment, nc_client: NextcloudClient +): + """A tagged folder applies its tag to every matching descendant via + Depth: infinity SEARCH — including deeply nested files.""" + env = included_tag_environment + + files = await nc_client.find_files_by_tag( + env["tag_name"], mime_type_filter="text/plain" + ) + + names = _basenames(files) + # Direct child of the tagged folder + assert "child.txt" in names + # Grandchild — proves the walk is recursive, not single-level + assert "deep.txt" in names + + +async def test_find_files_by_tag_dedupes_directly_tagged_files_under_tagged_folder( + included_tag_environment, nc_client: NextcloudClient +): + """When a file is *both* directly tagged and lives under a tagged + folder, it is returned exactly once. Verifies the dedup-by-id path.""" + env = included_tag_environment + + # Tag the deep child directly so it appears via two paths. + deep_info = await nc_client.webdav.get_file_info(env["nested_dir_child"]) + assert deep_info is not None + await nc_client.webdav.assign_tag_to_file(deep_info["id"], env["tag_id"]) + + try: + files = await nc_client.find_files_by_tag( + env["tag_name"], mime_type_filter="text/plain" + ) + finally: + await nc_client.webdav.remove_tag_from_file(deep_info["id"], env["tag_id"]) + + ids = [f["id"] for f in files] + assert ids.count(deep_info["id"]) == 1, f"deep child returned more than once: {ids}" + + +async def test_find_files_by_tag_excludes_unrelated_paths( + included_tag_environment, nc_client: NextcloudClient +): + """A file in the same parent directory as the tagged folder, but not + under it, is not returned. Guards against an over-broad SEARCH scope.""" + env = included_tag_environment + + files = await nc_client.find_files_by_tag( + env["tag_name"], mime_type_filter="text/plain" + ) + + paths = {f["path"].lstrip("/") for f in files} + assert env["untagged_file"] not in paths diff --git a/tests/unit/client/test_nextcloud_client.py b/tests/unit/client/test_nextcloud_client.py new file mode 100644 index 00000000..04b95efe --- /dev/null +++ b/tests/unit/client/test_nextcloud_client.py @@ -0,0 +1,322 @@ +"""Unit tests for NextcloudClient orchestration logic. + +Currently covers ``find_files_by_tag``: the wrapper that combines +``WebDAVClient.get_tag_by_name``, ``WebDAVClient.get_files_by_tag``, and +``WebDAVClient.find_by_type`` to resolve a system tag (and any tagged +folders) into a flat list of files. +""" + +from unittest.mock import AsyncMock + +import pytest + +from nextcloud_mcp_server.client import NextcloudClient, _normalise_search_result + + +def _make_client() -> NextcloudClient: + """Build a NextcloudClient with mocked sub-clients. + + The client constructor opens an httpx session; we don't need it, just + a stub instance whose ``webdav`` attribute we can replace. + """ + client = NextcloudClient.__new__(NextcloudClient) + client.username = "alice" + client.webdav = AsyncMock() + return client + + +pytestmark = pytest.mark.unit + + +class TestNormaliseSearchResult: + def test_adds_leading_slash_to_path(self): + result = _normalise_search_result( + {"path": "Documents/foo.pdf", "file_id": 1, "is_directory": False} + ) + assert result["path"] == "/Documents/foo.pdf" + + def test_preserves_leading_slash_when_present(self): + result = _normalise_search_result( + {"path": "/Documents/foo.pdf", "file_id": 1, "is_directory": False} + ) + assert result["path"] == "/Documents/foo.pdf" + + def test_maps_file_id_to_id(self): + result = _normalise_search_result( + {"path": "/foo.pdf", "file_id": 99, "is_directory": False} + ) + assert result["id"] == 99 + + def test_falls_back_to_id_when_file_id_missing(self): + result = _normalise_search_result( + {"path": "/foo.pdf", "id": 7, "is_directory": False} + ) + assert result["id"] == 7 + + def test_computes_last_modified_timestamp(self): + result = _normalise_search_result( + { + "path": "/foo.pdf", + "file_id": 1, + "last_modified": "Wed, 01 Jan 2025 00:00:00 GMT", + } + ) + assert result["last_modified_timestamp"] == 1735689600 + + def test_preserves_existing_timestamp(self): + result = _normalise_search_result( + { + "path": "/foo.pdf", + "file_id": 1, + "last_modified_timestamp": 12345, + "last_modified": "Wed, 01 Jan 2025 00:00:00 GMT", + } + ) + assert result["last_modified_timestamp"] == 12345 + + def test_handles_unparseable_last_modified(self): + result = _normalise_search_result( + {"path": "/foo.pdf", "file_id": 1, "last_modified": "not-a-date"} + ) + assert result["last_modified_timestamp"] is None + + +class TestFindFilesByTag: + async def test_returns_empty_when_tag_missing(self): + client = _make_client() + client.webdav.get_tag_by_name = AsyncMock(return_value=None) + + result = await client.find_files_by_tag("does-not-exist") + + assert result == [] + client.webdav.get_files_by_tag.assert_not_called() + + async def test_returns_empty_when_no_tagged_items(self): + client = _make_client() + client.webdav.get_tag_by_name = AsyncMock(return_value={"id": 5}) + client.webdav.get_files_by_tag = AsyncMock(return_value=[]) + + result = await client.find_files_by_tag("vector-index") + + assert result == [] + client.webdav.find_by_type.assert_not_called() + + async def test_directly_tagged_files_pass_through_with_mime_filter(self): + client = _make_client() + client.webdav.get_tag_by_name = AsyncMock(return_value={"id": 5}) + client.webdav.get_files_by_tag = AsyncMock( + return_value=[ + { + "id": 1, + "path": "/Documents/a.pdf", + "content_type": "application/pdf", + "is_directory": False, + }, + { + "id": 2, + "path": "/Documents/notes.md", + "content_type": "text/markdown", + "is_directory": False, + }, + ] + ) + + result = await client.find_files_by_tag( + "vector-index", mime_type_filter="application/pdf" + ) + + assert {f["id"] for f in result} == {1} + # No tagged dirs → no SEARCH walk. + client.webdav.find_by_type.assert_not_called() + + async def test_expands_tagged_directory_into_pdf_descendants(self): + client = _make_client() + client.webdav.get_tag_by_name = AsyncMock(return_value={"id": 5}) + # One directly-tagged folder, no directly-tagged files. + client.webdav.get_files_by_tag = AsyncMock( + return_value=[ + { + "id": 100, + "path": "/corpus", + "content_type": "httpd/unix-directory", + "is_directory": True, + } + ] + ) + # Search inside the folder returns two PDFs. + client.webdav.find_by_type = AsyncMock( + return_value=[ + { + "file_id": 11, + "path": "corpus/arxiv/a.pdf", + "content_type": "application/pdf", + "is_directory": False, + "last_modified": "Wed, 01 Jan 2025 00:00:00 GMT", + }, + { + "file_id": 12, + "path": "corpus/arxiv/b.pdf", + "content_type": "application/pdf", + "is_directory": False, + "last_modified": "Wed, 01 Jan 2025 00:00:00 GMT", + }, + ] + ) + + result = await client.find_files_by_tag( + "vector-index", mime_type_filter="application/pdf" + ) + + assert {f["id"] for f in result} == {11, 12} + # Each result is normalised to the get_files_by_tag shape. + for f in result: + assert f["path"].startswith("/") + assert f["last_modified_timestamp"] is not None + # SEARCH was scoped to the tagged folder (no leading slash). + client.webdav.find_by_type.assert_awaited_once() + call_kwargs = client.webdav.find_by_type.await_args.kwargs + assert call_kwargs["scope"] == "corpus" + + async def test_dedupes_when_file_directly_tagged_and_under_tagged_folder(self): + client = _make_client() + client.webdav.get_tag_by_name = AsyncMock(return_value={"id": 5}) + client.webdav.get_files_by_tag = AsyncMock( + return_value=[ + { + "id": 11, + "path": "/corpus/arxiv/a.pdf", + "content_type": "application/pdf", + "is_directory": False, + "name": "a.pdf", + }, + { + "id": 100, + "path": "/corpus", + "content_type": "httpd/unix-directory", + "is_directory": True, + }, + ] + ) + client.webdav.find_by_type = AsyncMock( + return_value=[ + { + "file_id": 11, + "path": "corpus/arxiv/a.pdf", + "content_type": "application/pdf", + "is_directory": False, + }, + { + "file_id": 12, + "path": "corpus/arxiv/b.pdf", + "content_type": "application/pdf", + "is_directory": False, + }, + ] + ) + + result = await client.find_files_by_tag( + "vector-index", mime_type_filter="application/pdf" + ) + + # File 11 is included exactly once and keeps the directly-tagged + # entry's metadata (name from get_files_by_tag, not search). + assert sorted(f["id"] for f in result) == [11, 12] + assert next(f for f in result if f["id"] == 11)["name"] == "a.pdf" + + async def test_directory_walk_failure_skips_only_that_directory(self, caplog): + client = _make_client() + client.webdav.get_tag_by_name = AsyncMock(return_value={"id": 5}) + client.webdav.get_files_by_tag = AsyncMock( + return_value=[ + { + "id": 7, + "path": "/Documents/keep.pdf", + "content_type": "application/pdf", + "is_directory": False, + }, + { + "id": 100, + "path": "/broken", + "content_type": "httpd/unix-directory", + "is_directory": True, + }, + ] + ) + client.webdav.find_by_type = AsyncMock(side_effect=RuntimeError("REPORT 500")) + + import logging + + caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.client") + result = await client.find_files_by_tag( + "vector-index", mime_type_filter="application/pdf" + ) + + # Directly-tagged file survives even though the dir walk blew up. + assert {f["id"] for f in result} == {7} + assert "Tag-based directory walk failed" in caplog.text + + async def test_no_mime_filter_skips_directory_expansion(self): + client = _make_client() + client.webdav.get_tag_by_name = AsyncMock(return_value={"id": 5}) + client.webdav.get_files_by_tag = AsyncMock( + return_value=[ + { + "id": 7, + "path": "/Documents/keep.pdf", + "content_type": "application/pdf", + "is_directory": False, + }, + { + "id": 100, + "path": "/corpus", + "content_type": "httpd/unix-directory", + "is_directory": True, + }, + ] + ) + + result = await client.find_files_by_tag("vector-index") + + # Without a MIME filter, directory expansion would fan out + # uncontrollably — the helper deliberately skips it. + assert {f["id"] for f in result} == {7} + client.webdav.find_by_type.assert_not_called() + + async def test_skips_descendant_directories_in_search_results(self): + """find_by_type can return collections too (e.g. when the SEARCH + backend treats a folder's mime type as matching). Those must not + slip through and clobber file IDs.""" + client = _make_client() + client.webdav.get_tag_by_name = AsyncMock(return_value={"id": 5}) + client.webdav.get_files_by_tag = AsyncMock( + return_value=[ + { + "id": 100, + "path": "/corpus", + "content_type": "httpd/unix-directory", + "is_directory": True, + } + ] + ) + client.webdav.find_by_type = AsyncMock( + return_value=[ + { + "file_id": 50, + "path": "corpus/sub", + "content_type": "httpd/unix-directory", + "is_directory": True, + }, + { + "file_id": 51, + "path": "corpus/sub/a.pdf", + "content_type": "application/pdf", + "is_directory": False, + }, + ] + ) + + result = await client.find_files_by_tag( + "vector-index", mime_type_filter="application/pdf" + ) + + assert {f["id"] for f in result} == {51}