The vector-sync scanner expanded a tagged folder into its PDF descendants via `WebdavClient.find_by_type(scope=dir)` with no result limit. A WebDAV SEARCH with no `<d:nresults>` returns only Nextcloud's default page (~100 on the affected instance), so large tagged folders were silently truncated and most documents were never queued for indexing (e.g. a 220-file folder yielded 100). Add `search_files_all`, which pages the SEARCH to completion. It uses `<d:firstresult>` offset paging where supported and, because Nextcloud 31 ignores offset (verified against a live instance), detects the repeated page and falls back to a single bounded fetch with an explicit large `<d:nresults>`. `find_all_by_type` wraps this and is now used for tagged-folder expansion; `find_by_type` is unchanged for the interactive MCP tools. Crossing `WEBDAV_SEARCH_MAX_RESULTS` logs a warning and increments the new `astrolabe_document_scan_truncated_total` metric, so a coverage cap can never again hide files silently. Scope: this fixes discovery only. Cross-user double-processing of identical shared files (point-ID collisions) is tracked separately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
159 lines
5.2 KiB
Python
159 lines
5.2 KiB
Python
"""Unit tests for paged WebDAV SEARCH (complete folder discovery).
|
|
|
|
These cover ``WebDAVClient.search_files_all`` -- the helper the vector-sync
|
|
scanner uses to expand a tagged folder into *all* its descendants, rather than
|
|
just Nextcloud's default ~100-result SEARCH page (which silently truncated large
|
|
folders and left documents unindexed).
|
|
|
|
The behaviour we pin:
|
|
* offset paging when the server honours ``<d:firstresult>``;
|
|
* automatic fallback to a single bounded fetch when the server *ignores*
|
|
offset (the real Nextcloud 31 behaviour -- a page repeats already-seen rows);
|
|
* a single short page terminates immediately;
|
|
* crossing ``max_results`` warns + increments the truncation metric;
|
|
* ``_build_search_xml`` emits the offset element only when asked.
|
|
"""
|
|
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from nextcloud_mcp_server.client.webdav import WebDAVClient
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
|
|
def _make_client(mocker) -> Any:
|
|
# Returned as Any so tests can reassign mocked search methods without
|
|
# tripping ty's invalid-assignment on the real WebDAVClient signatures.
|
|
client: Any = WebDAVClient(mocker.AsyncMock(spec=httpx.AsyncClient), "alice")
|
|
return client
|
|
|
|
|
|
def _corpus(n: int) -> list[dict]:
|
|
return [{"file_id": i, "path": f"/dir/f{i}.pdf"} for i in range(n)]
|
|
|
|
|
|
async def test_single_short_page_returns_all_in_one_call(mocker):
|
|
"""A folder smaller than the page size resolves in a single SEARCH."""
|
|
client = _make_client(mocker)
|
|
client.search_files = AsyncMock(return_value=_corpus(10))
|
|
|
|
results = await client.search_files_all(scope="dir", page_size=500)
|
|
|
|
assert [r["file_id"] for r in results] == list(range(10))
|
|
client.search_files.assert_awaited_once()
|
|
|
|
|
|
async def test_offset_honored_pages_through_entire_corpus(mocker):
|
|
"""When the server honours offset, every page is fetched until exhausted."""
|
|
client = _make_client(mocker)
|
|
corpus = _corpus(250)
|
|
|
|
async def fake(*, limit, offset=0, **_):
|
|
return corpus[offset : offset + limit]
|
|
|
|
client.search_files = AsyncMock(side_effect=fake)
|
|
|
|
results = await client.search_files_all(scope="dir", page_size=100)
|
|
|
|
assert [r["file_id"] for r in results] == list(range(250))
|
|
# 100, 100, 50 -> three pages, no fallback needed
|
|
assert client.search_files.await_count == 3
|
|
|
|
|
|
async def test_offset_ignored_falls_back_to_single_fetch(mocker):
|
|
"""Real Nextcloud ignores offset; we must still return the full corpus."""
|
|
client = _make_client(mocker)
|
|
corpus = _corpus(250)
|
|
|
|
async def fake(*, limit, offset=0, **_):
|
|
# offset IGNORED: always return the first ``limit`` rows.
|
|
return corpus[:limit]
|
|
|
|
client.search_files = AsyncMock(side_effect=fake)
|
|
|
|
results = await client.search_files_all(scope="dir", page_size=100)
|
|
|
|
# page0 (0..99) -> page1(offset=100) repeats 0..99 -> detected ->
|
|
# single fetch with the large ceiling returns everything.
|
|
assert [r["file_id"] for r in results] == list(range(250))
|
|
|
|
|
|
async def test_truncation_warns_and_increments_metric(mocker):
|
|
"""Hitting max_results must surface (warn + metric), never silently drop."""
|
|
client = _make_client(mocker)
|
|
corpus = _corpus(20)
|
|
|
|
async def fake(*, limit, offset=0, **_):
|
|
return corpus[offset : offset + limit]
|
|
|
|
client.search_files = AsyncMock(side_effect=fake)
|
|
metric = mocker.patch(
|
|
"nextcloud_mcp_server.client.webdav.document_scan_truncated_total"
|
|
)
|
|
|
|
results = await client.search_files_all(scope="dir", page_size=5, max_results=5)
|
|
|
|
assert len(results) == 5
|
|
metric.inc.assert_called_once()
|
|
|
|
|
|
async def test_offset_ignored_fallback_truncation_metric(mocker):
|
|
"""The fallback path also reports truncation when it hits the ceiling."""
|
|
client = _make_client(mocker)
|
|
corpus = _corpus(40)
|
|
|
|
async def fake(*, limit, offset=0, **_):
|
|
return corpus[:limit] # offset ignored
|
|
|
|
client.search_files = AsyncMock(side_effect=fake)
|
|
metric = mocker.patch(
|
|
"nextcloud_mcp_server.client.webdav.document_scan_truncated_total"
|
|
)
|
|
|
|
results = await client.search_files_all(scope="dir", page_size=10, max_results=10)
|
|
|
|
assert len(results) == 10
|
|
metric.inc.assert_called_once()
|
|
|
|
|
|
async def test_find_all_by_type_delegates_to_search_files_all(mocker):
|
|
client = _make_client(mocker)
|
|
client.search_files_all = AsyncMock(return_value=_corpus(3))
|
|
|
|
results = await client.find_all_by_type("application/pdf", scope="dir")
|
|
|
|
assert len(results) == 3
|
|
kwargs = client.search_files_all.await_args.kwargs
|
|
assert kwargs["scope"] == "dir"
|
|
assert "fileid" in kwargs["properties"]
|
|
assert "application/pdf" in kwargs["where_conditions"]
|
|
|
|
|
|
def test_build_search_xml_emits_offset_only_when_set(mocker):
|
|
client = _make_client(mocker)
|
|
|
|
paged = client._build_search_xml(
|
|
scope="dir",
|
|
where_conditions="",
|
|
properties=["fileid"],
|
|
order_by=None,
|
|
limit=100,
|
|
offset=200,
|
|
)
|
|
assert "<d:nresults>100</d:nresults>" in paged
|
|
assert "<d:firstresult>200</d:firstresult>" in paged
|
|
|
|
unlimited = client._build_search_xml(
|
|
scope="dir",
|
|
where_conditions="",
|
|
properties=["fileid"],
|
|
order_by=None,
|
|
limit=None,
|
|
offset=None,
|
|
)
|
|
assert "<d:limit>" not in unlimited
|