feat(vector): expand tagged directories for include + apply EXCLUDED_TAGS in scanner

NextcloudClient.find_files_by_tag now mirrors the directory semantics
already used by the exclusion path (issue #710): when a tagged item is
a folder, walk its descendants via WebDAV SEARCH (Depth: infinity) and
include any files matching the MIME filter. Without this, tagging the
root of a corpus with `vector-index` indexed nothing because the tag
applies to the directory only, not to its children.

The vector scanner additionally consults EXCLUDED_TAGS now, so a folder
marked off-limits is skipped even if it (or an ancestor) carries the
include tag — defense-in-depth, matching the "exclusion wins" contract
already enforced by the MCP file tools.

Also addressed a recurring memory-style nit: pre-existing f-string log
lines in find_files_by_tag were converted to lazy %-style.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-07 00:29:37 +02:00
co-authored by Claude Opus 4.7
parent 55dc363a77
commit 43c6788555
4 changed files with 663 additions and 26 deletions
+172
View File
@@ -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
+322
View File
@@ -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}