Merge pull request #849 from cbcoutinho/fix/vector-sync-pagination-churn-observability
fix: paginate tagged-folder SEARCH so the vector-sync scanner discovers all files
This commit is contained in:
@@ -244,7 +244,10 @@ class NextcloudClient:
|
||||
for dir_info in tagged_dirs:
|
||||
dir_path = dir_info.get("path", "").strip("/")
|
||||
try:
|
||||
descendants = await self.webdav.find_by_type(
|
||||
# find_all_by_type pages past Nextcloud's default ~100-result
|
||||
# SEARCH page so every tagged-folder descendant is discovered;
|
||||
# find_by_type would silently cap a large folder.
|
||||
descendants = await self.webdav.find_all_by_type(
|
||||
mime_type_filter, scope=dir_path
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -6,13 +6,25 @@ import xml.etree.ElementTree as ET
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import unquote
|
||||
from xml.sax.saxutils import escape as xml_escape
|
||||
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.observability.metrics import document_scan_truncated_total
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Paging defaults for WebDAV SEARCH. Nextcloud's SEARCH returns a server-default
|
||||
# page (~100 results) when no ``<d:nresults>`` is sent, silently truncating large
|
||||
# folders. ``search_files_all`` pages explicitly to fetch the complete result set.
|
||||
WEBDAV_SEARCH_PAGE_SIZE = 500
|
||||
# Hard ceiling so a pathologically large folder can't drive an unbounded crawl.
|
||||
# Crossing it is logged as a truncation warning (and surfaced via a metric) so the
|
||||
# cap can never again silently hide files.
|
||||
WEBDAV_SEARCH_MAX_RESULTS = 50000
|
||||
|
||||
|
||||
class WebDAVClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud WebDAV operations."""
|
||||
@@ -620,6 +632,7 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
properties: Optional[List[str]] = None,
|
||||
order_by: Optional[List[Tuple[str, str]]] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search for files using WebDAV SEARCH method (RFC 5323).
|
||||
|
||||
@@ -629,6 +642,10 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
properties: List of property names to retrieve (defaults to basic set)
|
||||
order_by: List of (property, direction) tuples for sorting, e.g. [("getlastmodified", "descending")]
|
||||
limit: Maximum number of results to return
|
||||
offset: Number of leading results to skip (``<d:firstresult>``). Note
|
||||
that not every Nextcloud release honours offset paging; callers
|
||||
that need guaranteed completeness should use ``search_files_all``,
|
||||
which detects an ignored offset and falls back.
|
||||
|
||||
Returns:
|
||||
List of file/directory dictionaries with requested properties
|
||||
@@ -651,6 +668,7 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
properties=properties,
|
||||
order_by=order_by,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
# The SEARCH endpoint is at the dav root
|
||||
@@ -679,6 +697,164 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
logger.error("Unexpected error during search: %s", e)
|
||||
raise e
|
||||
|
||||
async def search_files_all(
|
||||
self,
|
||||
scope: str = "",
|
||||
where_conditions: Optional[str] = None,
|
||||
properties: Optional[List[str]] = None,
|
||||
order_by: Optional[List[Tuple[str, str]]] = None,
|
||||
page_size: int = WEBDAV_SEARCH_PAGE_SIZE,
|
||||
max_results: int = WEBDAV_SEARCH_MAX_RESULTS,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Fetch the *complete* SEARCH result set, paging past the server default.
|
||||
|
||||
A plain ``search_files`` with no ``limit`` returns only Nextcloud's default
|
||||
page (~100), silently dropping the rest of a large folder. This method pages
|
||||
with ``<d:firstresult>`` until a short page signals the end. If the server
|
||||
ignores the offset (a page repeats results already seen), it falls back to a
|
||||
single fetch with an explicit large ``nresults`` so completeness never depends
|
||||
on offset support.
|
||||
|
||||
Args:
|
||||
scope: Directory path to search in (empty string for user root)
|
||||
where_conditions: XML where-clause conditions
|
||||
properties: Properties to retrieve (must include ``fileid`` for dedup)
|
||||
order_by: Optional sort order
|
||||
page_size: Results requested per page
|
||||
max_results: Hard ceiling; crossing it logs a truncation warning and
|
||||
increments ``webdav_search_truncated_total``
|
||||
|
||||
Returns:
|
||||
All matching file/directory dicts, de-duplicated by file id / path.
|
||||
"""
|
||||
paged = await self._search_offset_paged(
|
||||
scope, where_conditions, properties, order_by, page_size, max_results
|
||||
)
|
||||
# ``None`` signals the server ignored the offset (or an offset page
|
||||
# failed) -- fetch everything in one bounded request instead.
|
||||
if paged is None:
|
||||
return await self._single_fetch_fallback(
|
||||
scope, where_conditions, properties, order_by, max_results
|
||||
)
|
||||
self._warn_if_truncated(len(paged), scope, max_results)
|
||||
return paged[:max_results]
|
||||
|
||||
async def _search_offset_paged(
|
||||
self,
|
||||
scope: str,
|
||||
where_conditions: Optional[str],
|
||||
properties: Optional[List[str]],
|
||||
order_by: Optional[List[Tuple[str, str]]],
|
||||
page_size: int,
|
||||
max_results: int,
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Page the SEARCH with ``<d:firstresult>`` until exhausted.
|
||||
|
||||
Returns the accumulated rows, or ``None`` when the server ignores the
|
||||
offset (a page repeats already-seen rows, or an offset page errors) and
|
||||
the caller should fall back to a single bounded fetch.
|
||||
"""
|
||||
|
||||
def _key(item: Dict[str, Any]) -> Any:
|
||||
# file_id is globally unique; path is the stable fallback when a
|
||||
# producer omits fileid. ``id(item)`` is a last resort so an item
|
||||
# missing both never collapses into another under a shared ``None``
|
||||
# key (which would silently drop rows from the result set).
|
||||
# ``is not None`` rather than truthiness so a (hypothetical)
|
||||
# file_id of 0 isn't treated as absent.
|
||||
file_id = item.get("file_id")
|
||||
if file_id is not None:
|
||||
return file_id
|
||||
return item.get("path") or id(item)
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
seen: set[Any] = set()
|
||||
offset = 0
|
||||
|
||||
while len(results) < max_results:
|
||||
try:
|
||||
page = await self.search_files(
|
||||
scope=scope,
|
||||
where_conditions=where_conditions,
|
||||
properties=properties,
|
||||
order_by=order_by,
|
||||
limit=page_size,
|
||||
offset=offset,
|
||||
)
|
||||
except Exception:
|
||||
# A failure on the very first page is a real error, not a
|
||||
# paging quirk -- surface it. A later page failing means offset
|
||||
# paging is unusable; signal a fallback rather than lose the tail.
|
||||
if offset == 0:
|
||||
raise
|
||||
logger.warning(
|
||||
"WebDAV SEARCH offset page failed for scope %r; "
|
||||
"falling back to single fetch",
|
||||
scope,
|
||||
)
|
||||
return None
|
||||
|
||||
if not page:
|
||||
break
|
||||
|
||||
fresh = [item for item in page if _key(item) not in seen]
|
||||
|
||||
# Server ignored the offset (returned an already-seen page); signal
|
||||
# the caller to re-fetch in one bounded request. The accumulated
|
||||
# ``results`` are intentionally discarded -- the single fetch is
|
||||
# authoritative and re-returns them, so nothing is lost.
|
||||
if offset > 0 and not fresh:
|
||||
logger.warning(
|
||||
"WebDAV SEARCH ignored offset for scope %r; "
|
||||
"falling back to single fetch (limit=%d)",
|
||||
scope,
|
||||
max_results,
|
||||
)
|
||||
return None
|
||||
|
||||
for item in fresh:
|
||||
seen.add(_key(item))
|
||||
results.append(item)
|
||||
|
||||
# A short page means we've reached the end of the result set.
|
||||
if len(page) < page_size:
|
||||
break
|
||||
|
||||
offset += page_size
|
||||
|
||||
return results
|
||||
|
||||
async def _single_fetch_fallback(
|
||||
self,
|
||||
scope: str,
|
||||
where_conditions: Optional[str],
|
||||
properties: Optional[List[str]],
|
||||
order_by: Optional[List[Tuple[str, str]]],
|
||||
max_results: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Single SEARCH with a large explicit ``nresults`` (offset-free fallback)."""
|
||||
results = await self.search_files(
|
||||
scope=scope,
|
||||
where_conditions=where_conditions,
|
||||
properties=properties,
|
||||
order_by=order_by,
|
||||
limit=max_results,
|
||||
)
|
||||
self._warn_if_truncated(len(results), scope, max_results)
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _warn_if_truncated(count: int, scope: str, max_results: int) -> None:
|
||||
"""Warn + count when a SEARCH hit the ceiling, so a cap is never silent."""
|
||||
if count >= max_results:
|
||||
document_scan_truncated_total.inc()
|
||||
logger.warning(
|
||||
"WebDAV SEARCH reached max_results=%d for scope %r; "
|
||||
"results may be truncated -- raise WEBDAV_SEARCH_MAX_RESULTS",
|
||||
max_results,
|
||||
scope,
|
||||
)
|
||||
|
||||
def _build_search_xml(
|
||||
self,
|
||||
scope: str,
|
||||
@@ -686,6 +862,7 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
properties: List[str],
|
||||
order_by: Optional[List[Tuple[str, str]]],
|
||||
limit: Optional[int],
|
||||
offset: Optional[int] = None,
|
||||
) -> str:
|
||||
"""Build the XML body for a SEARCH request."""
|
||||
# Construct the scope path
|
||||
@@ -716,10 +893,18 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
else:
|
||||
orderby_xml = ""
|
||||
|
||||
# Build limit clause
|
||||
limit_xml = (
|
||||
f"<d:limit><d:nresults>{limit}</d:nresults></d:limit>" if limit else ""
|
||||
)
|
||||
# Build limit clause. ``<d:nresults>`` caps the page size; ``<d:firstresult>``
|
||||
# is the paging offset. Nextcloud silently ignores an unsupported offset
|
||||
# (returns the first page again) rather than erroring -- ``search_files_all``
|
||||
# detects that non-progress and falls back to a single bounded fetch.
|
||||
limit_parts = []
|
||||
if limit:
|
||||
limit_parts.append(f"<d:nresults>{limit}</d:nresults>")
|
||||
# ``is not None`` (not truthiness) so a future explicit offset=0 is
|
||||
# emitted rather than silently dropped.
|
||||
if offset is not None:
|
||||
limit_parts.append(f"<d:firstresult>{offset}</d:firstresult>")
|
||||
limit_xml = f"<d:limit>{''.join(limit_parts)}</d:limit>" if limit_parts else ""
|
||||
|
||||
# Construct the full SEARCH XML
|
||||
search_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -942,13 +1127,56 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
|
||||
# Find all PDFs
|
||||
results = await find_by_type("application/pdf")
|
||||
|
||||
Note:
|
||||
With ``limit=None`` this returns only Nextcloud's default SEARCH page
|
||||
(~100 results), so it truncates large folders. Use ``find_all_by_type``
|
||||
when complete coverage matters (e.g. building an indexing work-list).
|
||||
"""
|
||||
where_conditions, properties = self._type_search_args(mime_type)
|
||||
return await self.search_files(
|
||||
scope=scope,
|
||||
where_conditions=where_conditions,
|
||||
properties=properties,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
async def find_all_by_type(
|
||||
self, mime_type: str, scope: str = ""
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Find *all* files of a MIME type, paging past the SEARCH default page.
|
||||
|
||||
Unlike ``find_by_type`` (single default-capped page), this pages the SEARCH
|
||||
to completion so a large tagged folder is fully discovered. Used by the
|
||||
vector-sync scanner's tagged-folder expansion, where a missed file means a
|
||||
document that is never indexed.
|
||||
|
||||
Args:
|
||||
mime_type: MIME type to search for (supports % wildcard)
|
||||
scope: Directory path to search in (empty string for user root)
|
||||
|
||||
Returns:
|
||||
All matching files (bounded by ``WEBDAV_SEARCH_MAX_RESULTS``).
|
||||
"""
|
||||
where_conditions, properties = self._type_search_args(mime_type)
|
||||
return await self.search_files_all(
|
||||
scope=scope,
|
||||
where_conditions=where_conditions,
|
||||
properties=properties,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _type_search_args(mime_type: str) -> Tuple[str, List[str]]:
|
||||
"""Build the where-clause + property list for a MIME-type SEARCH."""
|
||||
# Escape so a caller-supplied MIME type can't break the SEARCH XML or
|
||||
# inject elements. All current callers pass literal strings, but this
|
||||
# keeps the boundary safe for any future user-supplied value.
|
||||
where_conditions = f"""
|
||||
<d:like>
|
||||
<d:prop>
|
||||
<d:getcontenttype/>
|
||||
</d:prop>
|
||||
<d:literal>{mime_type}</d:literal>
|
||||
<d:literal>{xml_escape(mime_type)}</d:literal>
|
||||
</d:like>
|
||||
"""
|
||||
|
||||
@@ -964,13 +1192,7 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
"getetag",
|
||||
"fileid",
|
||||
]
|
||||
|
||||
return await self.search_files(
|
||||
scope=scope,
|
||||
where_conditions=where_conditions,
|
||||
properties=properties,
|
||||
limit=limit,
|
||||
)
|
||||
return where_conditions, properties
|
||||
|
||||
async def list_favorites(
|
||||
self, scope: str = "", limit: Optional[int] = None
|
||||
|
||||
@@ -278,6 +278,18 @@ documents_indexed_total = Counter(
|
||||
["source", "status"], # source: note | file | deck_card | news_item
|
||||
)
|
||||
|
||||
# --- Document discovery / coverage ------------------------------------------
|
||||
#
|
||||
# Fires when a paged WebDAV SEARCH (folder-expansion during a scan) hits the
|
||||
# WEBDAV_SEARCH_MAX_RESULTS ceiling, meaning the discovered file set was capped
|
||||
# and some tagged documents may never be queued for indexing. This is the
|
||||
# alertable signal that prevents the old *silent* 100-result truncation from
|
||||
# recurring. Tenant is the Kubernetes ``namespace`` label, as elsewhere.
|
||||
document_scan_truncated_total = Counter(
|
||||
"astrolabe_document_scan_truncated_total",
|
||||
"Times a folder-expansion SEARCH hit the result ceiling (coverage truncated)",
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Database Metrics
|
||||
# =============================================================================
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
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
|
||||
``WebDAVClient.find_all_by_type`` to resolve a system tag (and any tagged
|
||||
folders) into a flat list of files.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
@@ -13,13 +14,15 @@ import pytest
|
||||
from nextcloud_mcp_server.client import NextcloudClient, _normalise_search_result
|
||||
|
||||
|
||||
def _make_client() -> NextcloudClient:
|
||||
def _make_client() -> Any:
|
||||
"""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.
|
||||
a stub instance whose ``webdav`` attribute we can replace. Returned as
|
||||
``Any`` so tests can freely reassign mocked methods on the sub-clients
|
||||
without fighting the real ``WebDAVClient`` signatures.
|
||||
"""
|
||||
client = NextcloudClient.__new__(NextcloudClient)
|
||||
client: Any = NextcloudClient.__new__(NextcloudClient)
|
||||
client.username = "alice"
|
||||
client.webdav = AsyncMock()
|
||||
return client
|
||||
@@ -99,7 +102,7 @@ class TestFindFilesByTag:
|
||||
result = await client.find_files_by_tag("vector-index")
|
||||
|
||||
assert result == []
|
||||
client.webdav.find_by_type.assert_not_called()
|
||||
client.webdav.find_all_by_type.assert_not_called()
|
||||
|
||||
async def test_directly_tagged_files_pass_through_with_mime_filter(self):
|
||||
client = _make_client()
|
||||
@@ -127,7 +130,7 @@ class TestFindFilesByTag:
|
||||
|
||||
assert {f["id"] for f in result} == {1}
|
||||
# No tagged dirs → no SEARCH walk.
|
||||
client.webdav.find_by_type.assert_not_called()
|
||||
client.webdav.find_all_by_type.assert_not_called()
|
||||
|
||||
async def test_expands_tagged_directory_into_pdf_descendants(self):
|
||||
client = _make_client()
|
||||
@@ -144,7 +147,7 @@ class TestFindFilesByTag:
|
||||
]
|
||||
)
|
||||
# Search inside the folder returns two PDFs.
|
||||
client.webdav.find_by_type = AsyncMock(
|
||||
client.webdav.find_all_by_type = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"file_id": 11,
|
||||
@@ -174,8 +177,8 @@ class TestFindFilesByTag:
|
||||
assert f["last_modified_timestamp"] is not None
|
||||
# SEARCH was scoped to the tagged folder (no leading slash) and
|
||||
# forwarded the requested MIME type as the positional first arg.
|
||||
client.webdav.find_by_type.assert_awaited_once()
|
||||
call_args = client.webdav.find_by_type.await_args
|
||||
client.webdav.find_all_by_type.assert_awaited_once()
|
||||
call_args = client.webdav.find_all_by_type.await_args
|
||||
assert call_args.args[0] == "application/pdf"
|
||||
assert call_args.kwargs["scope"] == "corpus"
|
||||
|
||||
@@ -199,7 +202,7 @@ class TestFindFilesByTag:
|
||||
},
|
||||
]
|
||||
)
|
||||
client.webdav.find_by_type = AsyncMock(
|
||||
client.webdav.find_all_by_type = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"file_id": 11,
|
||||
@@ -244,7 +247,9 @@ class TestFindFilesByTag:
|
||||
},
|
||||
]
|
||||
)
|
||||
client.webdav.find_by_type = AsyncMock(side_effect=RuntimeError("REPORT 500"))
|
||||
client.webdav.find_all_by_type = AsyncMock(
|
||||
side_effect=RuntimeError("REPORT 500")
|
||||
)
|
||||
|
||||
import logging
|
||||
|
||||
@@ -282,10 +287,10 @@ class TestFindFilesByTag:
|
||||
# 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()
|
||||
client.webdav.find_all_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
|
||||
"""find_all_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()
|
||||
@@ -300,7 +305,7 @@ class TestFindFilesByTag:
|
||||
}
|
||||
]
|
||||
)
|
||||
client.webdav.find_by_type = AsyncMock(
|
||||
client.webdav.find_all_by_type = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"file_id": 50,
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"""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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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_offset_page_exception_falls_back_to_single_fetch(mocker):
|
||||
"""If an offset page *raises* (e.g. server rejects firstresult), the
|
||||
exception fallback must still return the full corpus -- and it must be
|
||||
awaited (regression guard for a missing ``await``)."""
|
||||
client = _make_client(mocker)
|
||||
corpus = _corpus(80)
|
||||
|
||||
def fake(*, limit, offset=0, **_):
|
||||
if offset > 0:
|
||||
raise RuntimeError("server rejected <d:firstresult>")
|
||||
return corpus[:limit]
|
||||
|
||||
client.search_files = AsyncMock(side_effect=fake)
|
||||
|
||||
results = await client.search_files_all(scope="dir", page_size=50)
|
||||
|
||||
# page0 (0..49) fills; page1(offset=50) raises -> fallback single fetch
|
||||
# returns the whole corpus. A non-awaited coroutine would fail these.
|
||||
assert isinstance(results, list)
|
||||
assert [r["file_id"] for r in results] == list(range(80))
|
||||
|
||||
|
||||
async def test_offset_page_exception_at_offset_zero_propagates(mocker):
|
||||
"""A failure on the very first page is a real error, not a paging quirk."""
|
||||
client = _make_client(mocker)
|
||||
client.search_files = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
await client.search_files_all(scope="dir", page_size=50)
|
||||
|
||||
|
||||
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_type_search_args_escapes_mime_type(mocker):
|
||||
"""A MIME value with XML metacharacters must not break / inject into the SEARCH."""
|
||||
client = _make_client(mocker)
|
||||
where, properties = client._type_search_args("application/pdf<&>")
|
||||
# The injected metacharacters are escaped inside the <d:literal>, so they
|
||||
# can't break the SEARCH XML or introduce new elements.
|
||||
assert "pdf<&>" in where
|
||||
assert "pdf<&>" not in where
|
||||
assert "fileid" in properties
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user