Files
mcp-nextcloud/tests/unit/search/test_verification.py
T
Chris CoutinhoandClaude Opus 4.7 3e981e647a refactor(search): address PR #750 round 7 review feedback
Round 7 raised 5 issues; this round addresses all of them and fixes
the underlying causes (not just the comments) where applicable so
they don't get re-flagged in future passes.

Critical:
- verified_count description in SemanticSearchResponse said "unique
  documents" but the value is len(verified_results), a chunk count.
  Description rewritten to accurately document chunk-level granularity
  AND explicitly call out the asymmetry with dropped_count (which
  counts unique (doc_id, doc_type) pairs).

- _verify_files false-eviction risk: the round-6 doc-only fix was
  re-flagged. Address at the source — widen WebDAVClient.get_file_info
  to raise HTTPStatusError on 404 (matching the rest of the client
  convention) and reserve None for the genuinely ambiguous
  malformed-PROPFIND case. _verify_files now keeps the result on None
  (cannot tell whether the file exists) and evicts only on a
  definitive HTTPStatusError 404. Tests updated; new test added for
  the malformed-XML keep-result path.

Non-critical:
- News verifier semaphore lifetime now explicitly documented: one
  slot held for one deduplicated fetch per search is the correct
  backpressure behaviour.

- Cross-reference comments in _verify_notes / _verify_deck_cards no
  longer claim "Mirrors X" pointing at functions defined later in
  the file; now use direction-neutral "parallel implementation in".

- accessible_by_type is mutated by concurrent run_verifier tasks; a
  comment explains why this is race-free under anyio's cooperative
  multitasking (distinct keys per task, no await between read and
  write) so a future reader doesn't add a redundant lock.

- Knock-on: tests/integration/test_rag.py wraps get_file_info in a
  try/except for the new contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:15:39 +02:00

747 lines
24 KiB
Python

"""Unit tests for verify-on-read (ADR-019)."""
from types import SimpleNamespace
import anyio
import httpx
import pytest
from httpx import HTTPStatusError
from nextcloud_mcp_server.search import verification
from nextcloud_mcp_server.search.algorithms import SearchResult
from nextcloud_mcp_server.search.verification import (
_verify_deck_cards,
_verify_files,
_verify_news_items,
_verify_notes,
get_supported_doc_types,
verify_search_results,
)
from nextcloud_mcp_server.vector.scanner import INDEXED_DOC_TYPES
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _sem(slots: int = 20) -> anyio.Semaphore:
return anyio.Semaphore(slots)
def _make_result(
doc_id: int | str,
doc_type: str = "note",
chunk_index: int = 0,
score: float = 0.9,
metadata: dict | None = None,
) -> SearchResult:
return SearchResult(
id=doc_id,
doc_type=doc_type,
title=f"{doc_type}_{doc_id}",
excerpt="...",
score=score,
chunk_index=chunk_index,
metadata=metadata,
)
def _http_error(status_code: int) -> HTTPStatusError:
request = httpx.Request("GET", "http://test.local/x")
response = httpx.Response(status_code=status_code, request=request)
return HTTPStatusError(f"{status_code}", request=request, response=response)
# ---------------------------------------------------------------------------
# Registry shape
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_supported_doc_types_covers_indexed_types():
"""ADR-019 CI guard: every doc_type indexed by the scanner has a verifier.
`INDEXED_DOC_TYPES` is the single source of truth in `vector/scanner.py`;
this test fails if a new indexed type is added without a registered
verifier in `search/verification.py`.
"""
assert get_supported_doc_types() >= INDEXED_DOC_TYPES
# ---------------------------------------------------------------------------
# Note verifier
# ---------------------------------------------------------------------------
@pytest.mark.unit
async def test_verify_notes_200_keeps_all(mocker):
notes_client = SimpleNamespace(
get_note=mocker.AsyncMock(return_value={"id": 1, "content": "x"})
)
client = SimpleNamespace(notes=notes_client, username="alice")
result = await _verify_notes(
client, [_make_result(1), _make_result(2), _make_result(3)], _sem()
)
assert result == {1, 2, 3}
assert notes_client.get_note.await_count == 3
@pytest.mark.unit
async def test_verify_notes_404_drops(mocker):
notes_client = SimpleNamespace(
get_note=mocker.AsyncMock(side_effect=_http_error(404))
)
client = SimpleNamespace(notes=notes_client, username="alice")
result = await _verify_notes(client, [_make_result(42)], _sem())
assert result == set()
@pytest.mark.unit
async def test_verify_notes_403_drops(mocker):
notes_client = SimpleNamespace(
get_note=mocker.AsyncMock(side_effect=_http_error(403))
)
client = SimpleNamespace(notes=notes_client, username="alice")
result = await _verify_notes(client, [_make_result(42)], _sem())
assert result == set()
@pytest.mark.unit
async def test_verify_notes_transient_5xx_keeps(mocker):
"""Transient errors must NOT silently shrink results."""
notes_client = SimpleNamespace(
get_note=mocker.AsyncMock(side_effect=_http_error(503))
)
client = SimpleNamespace(notes=notes_client, username="alice")
result = await _verify_notes(client, [_make_result(42)], _sem())
assert result == {42}
@pytest.mark.unit
async def test_verify_notes_unexpected_exception_keeps(mocker):
notes_client = SimpleNamespace(
get_note=mocker.AsyncMock(side_effect=RuntimeError("boom"))
)
client = SimpleNamespace(notes=notes_client, username="alice")
result = await _verify_notes(client, [_make_result(7)], _sem())
assert result == {7}
@pytest.mark.unit
async def test_verify_notes_non_numeric_id_keeps(mocker):
"""Non-numeric note id must not surface as a generic 'unexpected error'.
The defensive int() guard runs before the network call and produces a
type-specific log line; result is kept (fail-open).
"""
notes_client = SimpleNamespace(
get_note=mocker.AsyncMock(side_effect=AssertionError("must not be called"))
)
client = SimpleNamespace(notes=notes_client, username="alice")
result = await _verify_notes(client, [_make_result("not-a-number")], _sem())
assert result == {"not-a-number"}
notes_client.get_note.assert_not_awaited()
@pytest.mark.unit
async def test_verify_notes_mixed_outcomes(mocker):
"""Mix of accessible, deleted, and transient — only deleted is dropped."""
async def side_effect(note_id):
if note_id == 1:
return {"id": 1}
if note_id == 2:
raise _http_error(404) # deleted
if note_id == 3:
raise _http_error(500) # transient → keep
raise AssertionError(f"unexpected id {note_id}")
notes_client = SimpleNamespace(get_note=mocker.AsyncMock(side_effect=side_effect))
client = SimpleNamespace(notes=notes_client, username="alice")
result = await _verify_notes(
client, [_make_result(1), _make_result(2), _make_result(3)], _sem()
)
assert result == {1, 3}
# ---------------------------------------------------------------------------
# News batch verifier
# ---------------------------------------------------------------------------
@pytest.mark.unit
async def test_verify_news_items_intersects_with_fetched_set(mocker):
"""News verifier does ONE fetch and intersects, regardless of how many ids."""
news_client = SimpleNamespace(
get_items=mocker.AsyncMock(return_value=[{"id": 10}, {"id": 20}, {"id": 30}])
)
client = SimpleNamespace(news=news_client, username="alice")
result = await _verify_news_items(
client,
[
_make_result(10, doc_type="news_item"),
_make_result(20, doc_type="news_item"),
_make_result(99, doc_type="news_item"),
],
_sem(),
)
assert result == {10, 20}
assert news_client.get_items.await_count == 1
@pytest.mark.unit
async def test_verify_news_items_api_404_drops_all(mocker):
news_client = SimpleNamespace(
get_items=mocker.AsyncMock(side_effect=_http_error(404))
)
client = SimpleNamespace(news=news_client, username="alice")
result = await _verify_news_items(
client,
[
_make_result(1, doc_type="news_item"),
_make_result(2, doc_type="news_item"),
_make_result(3, doc_type="news_item"),
],
_sem(),
)
assert result == set()
@pytest.mark.unit
async def test_verify_news_items_api_403_drops_all(mocker):
"""News API 403 (e.g. user lost access to the app) drops all items."""
news_client = SimpleNamespace(
get_items=mocker.AsyncMock(side_effect=_http_error(403))
)
client = SimpleNamespace(news=news_client, username="alice")
result = await _verify_news_items(
client,
[
_make_result(1, doc_type="news_item"),
_make_result(2, doc_type="news_item"),
_make_result(3, doc_type="news_item"),
],
_sem(),
)
assert result == set()
@pytest.mark.unit
async def test_verify_news_items_transient_keeps_all(mocker):
news_client = SimpleNamespace(
get_items=mocker.AsyncMock(side_effect=_http_error(502))
)
client = SimpleNamespace(news=news_client, username="alice")
result = await _verify_news_items(
client,
[
_make_result(1, doc_type="news_item"),
_make_result(2, doc_type="news_item"),
_make_result(3, doc_type="news_item"),
],
_sem(),
)
assert result == {1, 2, 3}
@pytest.mark.unit
async def test_verify_news_items_non_numeric_id_keeps_all(mocker):
"""One non-numeric doc_id triggers fail-open for the WHOLE result set.
The intersection logic in `_verify_news_items` tries `int(d)` for each
incoming doc_id; a single non-numeric value (e.g. ``"abc"``) raises
ValueError inside the intersection loop. The except block must catch it
and return the full input set (fail open) rather than dropping anything.
This is intentional v1 behaviour — surfacing one bad id should not
drop legitimate adjacent results.
"""
news_client = SimpleNamespace(
get_items=mocker.AsyncMock(return_value=[{"id": 10}, {"id": 20}])
)
client = SimpleNamespace(news=news_client, username="alice")
doc_ids: list[int | str] = [10, 20, "abc"]
result = await _verify_news_items(
client,
[_make_result(d, doc_type="news_item") for d in doc_ids],
_sem(),
)
# Fail-open: every requested id is preserved, INCLUDING the bad one.
assert result == {10, 20, "abc"}
# ---------------------------------------------------------------------------
# File verifier
# ---------------------------------------------------------------------------
@pytest.mark.unit
async def test_verify_files_uses_path_from_metadata(mocker):
"""File verifier reads path from SearchResult.metadata, no Qdrant round-trip."""
webdav_client = SimpleNamespace(
get_file_info=mocker.AsyncMock(return_value={"id": 100})
)
client = SimpleNamespace(webdav=webdav_client, username="alice")
result = await _verify_files(
client,
[_make_result(100, doc_type="file", metadata={"path": "Documents/foo.txt"})],
_sem(),
)
assert result == {100}
webdav_client.get_file_info.assert_awaited_once_with("Documents/foo.txt")
@pytest.mark.unit
async def test_verify_files_404_drops(mocker):
"""get_file_info raising HTTPStatusError(404) is a definitive drop."""
webdav_client = SimpleNamespace(
get_file_info=mocker.AsyncMock(side_effect=_http_error(404))
)
client = SimpleNamespace(webdav=webdav_client, username="alice")
result = await _verify_files(
client,
[_make_result(123, doc_type="file", metadata={"path": "gone.txt"})],
_sem(),
)
assert result == set()
@pytest.mark.unit
async def test_verify_files_malformed_propfind_keeps_result(mocker):
"""get_file_info returning None means malformed PROPFIND — keep the result.
Per the contract change in webdav.py: ``None`` is now reserved for the
ambiguous "malformed XML" case. Real 404s raise HTTPStatusError. The
file verifier must NOT evict on the ambiguous case (we cannot tell
whether the file exists), only log a warning and keep the result.
"""
webdav_client = SimpleNamespace(get_file_info=mocker.AsyncMock(return_value=None))
client = SimpleNamespace(webdav=webdav_client, username="alice")
result = await _verify_files(
client,
[_make_result(123, doc_type="file", metadata={"path": "brittle.txt"})],
_sem(),
)
assert result == {123}, "ambiguous None must keep result, not evict"
@pytest.mark.unit
async def test_verify_files_403_drops(mocker):
"""get_file_info raising HTTPStatusError(403) is a definitive drop."""
webdav_client = SimpleNamespace(
get_file_info=mocker.AsyncMock(side_effect=_http_error(403))
)
client = SimpleNamespace(webdav=webdav_client, username="alice")
result = await _verify_files(
client,
[_make_result(124, doc_type="file", metadata={"path": "forbidden.txt"})],
_sem(),
)
assert result == set()
@pytest.mark.unit
async def test_verify_files_missing_path_metadata_keeps_unverified(mocker):
"""Without a path in metadata we cannot verify — fail open, don't drop."""
webdav_client = SimpleNamespace(
get_file_info=mocker.AsyncMock(side_effect=AssertionError("must not be called"))
)
client = SimpleNamespace(webdav=webdav_client, username="alice")
# No metadata at all
result = await _verify_files(client, [_make_result(555, doc_type="file")], _sem())
assert result == {555}
webdav_client.get_file_info.assert_not_awaited()
# Metadata present but no "path" key
result = await _verify_files(
client, [_make_result(556, doc_type="file", metadata={})], _sem()
)
assert result == {556}
webdav_client.get_file_info.assert_not_awaited()
@pytest.mark.unit
async def test_verify_files_transient_5xx_keeps(mocker):
webdav_client = SimpleNamespace(
get_file_info=mocker.AsyncMock(side_effect=_http_error(503))
)
client = SimpleNamespace(webdav=webdav_client, username="alice")
result = await _verify_files(
client,
[_make_result(7, doc_type="file", metadata={"path": "x.txt"})],
_sem(),
)
assert result == {7}
# ---------------------------------------------------------------------------
# Deck card verifier
# ---------------------------------------------------------------------------
@pytest.mark.unit
async def test_verify_deck_cards_uses_metadata_fast_path(mocker):
"""Deck verifier reads board_id+stack_id from metadata, no Qdrant round-trip."""
deck_client = SimpleNamespace(get_card=mocker.AsyncMock(return_value=object()))
client = SimpleNamespace(deck=deck_client, username="alice")
result = await _verify_deck_cards(
client,
[
_make_result(
42,
doc_type="deck_card",
metadata={"board_id": 1, "stack_id": 2},
)
],
_sem(),
)
assert result == {42}
deck_client.get_card.assert_awaited_once_with(board_id=1, stack_id=2, card_id=42)
@pytest.mark.unit
async def test_verify_deck_cards_403_drops(mocker):
"""Board unshared with user → 403 from get_card → drop."""
deck_client = SimpleNamespace(
get_card=mocker.AsyncMock(side_effect=_http_error(403))
)
client = SimpleNamespace(deck=deck_client, username="alice")
result = await _verify_deck_cards(
client,
[
_make_result(
42,
doc_type="deck_card",
metadata={"board_id": 1, "stack_id": 2},
)
],
_sem(),
)
assert result == set()
@pytest.mark.unit
async def test_verify_deck_cards_missing_metadata_keeps_unverified(mocker):
"""Legacy data without board_id/stack_id → keep, do NOT iterate or call API."""
deck_client = SimpleNamespace(
get_card=mocker.AsyncMock(side_effect=AssertionError("must not be called"))
)
client = SimpleNamespace(deck=deck_client, username="alice")
# No metadata at all
result = await _verify_deck_cards(
client, [_make_result(42, doc_type="deck_card")], _sem()
)
assert result == {42}
# Only board_id (stack_id missing)
result = await _verify_deck_cards(
client,
[_make_result(43, doc_type="deck_card", metadata={"board_id": 1})],
_sem(),
)
assert result == {43}
# Only stack_id (board_id missing)
result = await _verify_deck_cards(
client,
[_make_result(44, doc_type="deck_card", metadata={"stack_id": 2})],
_sem(),
)
assert result == {44}
deck_client.get_card.assert_not_awaited()
# ---------------------------------------------------------------------------
# Top-level verify_search_results
# ---------------------------------------------------------------------------
@pytest.mark.unit
async def test_verify_search_results_empty_input_passthrough():
client = SimpleNamespace(username="alice")
assert await verify_search_results(client, []) == ([], 0)
@pytest.mark.unit
async def test_verify_search_results_dedupes_chunks_per_document(mocker):
"""Two chunks of the same note → ONE call to the underlying verifier."""
spy = mocker.AsyncMock(return_value={1})
mocker.patch.dict(verification._VERIFIERS, {"note": spy}, clear=False)
mocker.patch.object(verification, "delete_document_points", mocker.AsyncMock())
results = [
_make_result(1, doc_type="note", chunk_index=0),
_make_result(1, doc_type="note", chunk_index=1),
_make_result(1, doc_type="note", chunk_index=2),
]
client = SimpleNamespace(username="alice")
kept, dropped_count = await verify_search_results(client, results)
assert len(kept) == 3 # all kept, all reference the same accessible doc
assert dropped_count == 0
spy.assert_awaited_once()
# Verifier received exactly one SearchResult (the deduplicated representative)
args, _kwargs = spy.call_args
assert len(args[1]) == 1
assert args[1][0].id == 1
# And a semaphore as the third arg
assert isinstance(args[2], anyio.Semaphore)
@pytest.mark.unit
async def test_verify_search_results_drops_inaccessible_and_evicts(mocker):
"""Inline-fallback path (no eviction_task_group): evict completes before return."""
spy_evict = mocker.AsyncMock()
mocker.patch.object(verification, "delete_document_points", spy_evict)
# Verifier reports note 1 accessible, note 99 not
note_verifier = mocker.AsyncMock(return_value={1})
mocker.patch.dict(verification._VERIFIERS, {"note": note_verifier}, clear=False)
results = [
_make_result(1, doc_type="note"),
_make_result(99, doc_type="note"),
]
client = SimpleNamespace(username="alice")
kept, dropped_count = await verify_search_results(client, results)
assert [r.id for r in kept] == [1]
assert dropped_count == 1
spy_evict.assert_awaited_once_with(99, "note", "alice")
@pytest.mark.unit
async def test_verify_search_results_fire_and_forget_eviction(mocker):
"""When eviction_task_group is provided, eviction does not block the response.
Validates the ADR-019 design: spawn evict() on the lifespan-owned task
group via start_soon so the search response returns immediately. The
eviction still runs (verified after the task group exits).
"""
eviction_started = anyio.Event()
eviction_may_complete = anyio.Event()
eviction_completed = anyio.Event()
async def slow_delete(doc_id, doc_type, user_id):
eviction_started.set()
await eviction_may_complete.wait()
eviction_completed.set()
mocker.patch.object(
verification,
"delete_document_points",
mocker.AsyncMock(side_effect=slow_delete),
)
note_verifier = mocker.AsyncMock(return_value=set()) # both inaccessible
mocker.patch.dict(verification._VERIFIERS, {"note": note_verifier}, clear=False)
results = [_make_result(99, doc_type="note")]
client = SimpleNamespace(username="alice")
async with anyio.create_task_group() as tg:
kept, dropped_count = await verify_search_results(
client, results, eviction_task_group=tg
)
# 1. Search response was returned …
assert kept == []
assert dropped_count == 1
# 2. … even though eviction has started but not finished.
await eviction_started.wait()
assert not eviction_completed.is_set()
# 3. Now allow eviction to complete; the task group exit awaits it.
eviction_may_complete.set()
# After the task group exits, the eviction must have run.
assert eviction_completed.is_set()
@pytest.mark.unit
async def test_verify_search_results_no_eviction_when_disabled(mocker):
spy_evict = mocker.AsyncMock()
mocker.patch.object(verification, "delete_document_points", spy_evict)
note_verifier = mocker.AsyncMock(return_value=set()) # all inaccessible
mocker.patch.dict(verification._VERIFIERS, {"note": note_verifier}, clear=False)
results = [_make_result(7, doc_type="note")]
client = SimpleNamespace(username="alice")
kept, dropped_count = await verify_search_results(
client, results, evict_on_missing=False
)
assert kept == []
assert dropped_count == 1
spy_evict.assert_not_awaited()
@pytest.mark.unit
async def test_verify_search_results_unknown_doc_type_passes_through(mocker, caplog):
"""No verifier registered for doc_type → keep, log a warning."""
spy_evict = mocker.AsyncMock()
mocker.patch.object(verification, "delete_document_points", spy_evict)
# Ensure no verifier for "calendar"
mocker.patch.dict(
verification._VERIFIERS,
{k: v for k, v in verification._VERIFIERS.items() if k != "calendar"},
clear=True,
)
results = [_make_result(1, doc_type="calendar")]
client = SimpleNamespace(username="alice")
kept, dropped_count = await verify_search_results(client, results)
assert len(kept) == 1
assert dropped_count == 0
spy_evict.assert_not_awaited()
@pytest.mark.unit
async def test_verify_search_results_verifier_blowup_keeps_all(mocker):
"""A verifier raising an unexpected exception must not silently drop results."""
spy_evict = mocker.AsyncMock()
mocker.patch.object(verification, "delete_document_points", spy_evict)
note_verifier = mocker.AsyncMock(side_effect=RuntimeError("qdrant down"))
mocker.patch.dict(verification._VERIFIERS, {"note": note_verifier}, clear=False)
results = [
_make_result(1, doc_type="note"),
_make_result(2, doc_type="note"),
]
client = SimpleNamespace(username="alice")
kept, dropped_count = await verify_search_results(client, results)
assert [r.id for r in kept] == [1, 2]
assert dropped_count == 0 # fail-open: nothing dropped
spy_evict.assert_not_awaited()
@pytest.mark.unit
async def test_verify_search_results_preserves_order(mocker):
"""Order of original results must be preserved after filtering."""
note_verifier = mocker.AsyncMock(return_value={1, 3})
mocker.patch.dict(verification._VERIFIERS, {"note": note_verifier}, clear=False)
mocker.patch.object(verification, "delete_document_points", mocker.AsyncMock())
results = [
_make_result(1, doc_type="note", score=0.9),
_make_result(2, doc_type="note", score=0.8),
_make_result(3, doc_type="note", score=0.7),
]
client = SimpleNamespace(username="alice")
kept, dropped_count = await verify_search_results(client, results)
assert [r.id for r in kept] == [1, 3]
assert dropped_count == 1
@pytest.mark.unit
async def test_verify_search_results_eviction_failure_does_not_propagate(mocker):
"""Eviction failures are logged, never raised — must not break search."""
mocker.patch.object(
verification,
"delete_document_points",
mocker.AsyncMock(side_effect=RuntimeError("qdrant down")),
)
note_verifier = mocker.AsyncMock(return_value=set())
mocker.patch.dict(verification._VERIFIERS, {"note": note_verifier}, clear=False)
client = SimpleNamespace(username="alice")
# Should NOT raise
kept, dropped_count = await verify_search_results(
client, [_make_result(1, doc_type="note")]
)
assert kept == []
assert dropped_count == 1
@pytest.mark.unit
async def test_verify_search_results_dispatches_per_doc_type_concurrently(mocker):
"""Mixed doc_types must be routed to their respective verifiers."""
note_verifier = mocker.AsyncMock(return_value={1})
file_verifier = mocker.AsyncMock(return_value={500})
mocker.patch.dict(
verification._VERIFIERS,
{"note": note_verifier, "file": file_verifier},
clear=False,
)
mocker.patch.object(verification, "delete_document_points", mocker.AsyncMock())
results = [
_make_result(1, doc_type="note"),
_make_result(500, doc_type="file", metadata={"path": "a.txt"}),
_make_result(999, doc_type="file", metadata={"path": "b.txt"}), # to be dropped
]
client = SimpleNamespace(username="alice")
kept, dropped_count = await verify_search_results(client, results)
assert {(r.id, r.doc_type) for r in kept} == {(1, "note"), (500, "file")}
assert dropped_count == 1
note_verifier.assert_awaited_once()
file_verifier.assert_awaited_once()
@pytest.mark.unit
async def test_verify_search_results_passes_semaphore_to_verifier(mocker):
"""The dispatcher must construct a Semaphore and pass it to verifiers."""
captured: dict[str, anyio.Semaphore] = {}
async def verifier(client, results, semaphore):
captured["sem"] = semaphore
return {r.id for r in results}
mocker.patch.dict(verification._VERIFIERS, {"note": verifier}, clear=False)
mocker.patch.object(verification, "delete_document_points", mocker.AsyncMock())
client = SimpleNamespace(username="alice")
await verify_search_results(client, [_make_result(1)], max_concurrent=5)
assert isinstance(captured["sem"], anyio.Semaphore)