Files
mcp-nextcloud/tests/unit/test_chunk_context_offset_gate.py
T
Chris CoutinhoandClaude Opus 4.7 51c1d42ea3 fix(chunk-context): address PR #767 round-2 review — gate, parity, doc
Latest reviewer comment flagged five items on top of the original PR. This
commit addresses every one:

🟡 1. Skip the offset-based Qdrant fallback for `doc_type=file` when
   `chunk_index` is supplied. Qdrant Cloud's strict mode rejects unindexed
   filter fields with HTTP 400, which `_get_chunk_from_qdrant` catches and
   logs at `logger.error` — masking real Qdrant problems in monitoring.
   Notes/cards keep the offset fallback (cheap, useful for legacy data).

🟡 2. Add a `logger.warning` and clarifying inline comment in the doc-text
   fallback path when `chunk_index` is None — surfaces the pre-existing
   "0/N misreport" so callers can detect it. Type-nullability propagation
   is deferred to a follow-up (out of scope for this hotfix).

🟢 3. Simplify `if chunk_text and doc_id_int is not None:` →
   `if chunk_text:` with an inner `assert doc_id_int is not None` for
   `ty` narrowing. The outer second clause was dead.

🟢 4. Add `doc_type` `FieldCondition` to the offset-based image lookup in
   both `visualization.py` and `viz_routes.py` for parity with the
   `chunk_index` branches.

🟢 5. Inline the `chunk_filter` local in `visualization.py` directly into
   the `must=[]` list (matches `viz_routes.py` style).

Adds `tests/unit/test_chunk_context_offset_gate.py` with three regression
tests covering the gate matrix: (file, with-index → skip offset),
(note, with-index → still tries offset), (file, no-index → still tries
offset). Lives at top-level rather than `tests/unit/search/` to side-step
a pre-existing circular-init issue in `nextcloud_mcp_server.search`.

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

138 lines
4.7 KiB
Python

"""Unit tests for `nextcloud_mcp_server.search.context.get_chunk_with_context`.
Focused on the chunk-lookup gate that decides whether to fall back from the
indexed `chunk_index` path to the unindexed `(chunk_start, chunk_end)` path.
The behaviour matters because Qdrant Cloud's strict mode rejects filters on
unindexed fields with HTTP 400 — a fall-through there surfaces a misleading
`logger.error` even when the caller's request would correctly resolve as a
404 via the file fast-fail.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# Import via the auth surface first to side-step a known circular-init issue
# in `nextcloud_mcp_server.search.__init__` when `search` is imported as the
# first entry point (also affects pre-existing tests under tests/unit/search/).
import nextcloud_mcp_server.auth.viz_routes # noqa: F401 (init-order fixup)
from nextcloud_mcp_server.search import context as context_module
from nextcloud_mcp_server.search.context import get_chunk_with_context
pytestmark = pytest.mark.unit
@pytest.fixture
def mock_nc_client() -> MagicMock:
return MagicMock()
class TestOffsetFallbackGate:
"""When chunk_index is provided AND doc_type=='file', the offset fallback
must be skipped — see PR #767 review (🟡 spurious Qdrant error log).
"""
async def test_file_with_chunk_index_skips_offset_fallback_on_miss(
self, mock_nc_client
):
with (
patch.object(
context_module,
"_get_chunk_by_index_from_qdrant",
new_callable=AsyncMock,
return_value=None,
) as mock_indexed,
patch.object(
context_module,
"_get_chunk_from_qdrant",
new_callable=AsyncMock,
return_value="should-not-be-returned",
) as mock_offset,
):
result = await get_chunk_with_context(
nc_client=mock_nc_client,
user_id="alice",
doc_id=12345,
doc_type="file",
chunk_start=0,
chunk_end=100,
chunk_index=3,
total_chunks=20,
)
assert result is None, "file fast-fail must return None on Qdrant miss"
mock_indexed.assert_awaited_once()
mock_offset.assert_not_awaited()
async def test_note_with_chunk_index_still_uses_offset_fallback(
self, mock_nc_client
):
"""Notes/deck cards keep the offset fallback (cheap, useful for legacy
data): the gate is file-specific.
"""
with (
patch.object(
context_module,
"_get_chunk_by_index_from_qdrant",
new_callable=AsyncMock,
return_value=None,
) as mock_indexed,
patch.object(
context_module,
"_get_chunk_from_qdrant",
new_callable=AsyncMock,
return_value=None,
) as mock_offset,
patch.object(
context_module,
"_fetch_document_text",
new_callable=AsyncMock,
return_value=None,
),
):
await get_chunk_with_context(
nc_client=mock_nc_client,
user_id="alice",
doc_id=42,
doc_type="note",
chunk_start=0,
chunk_end=10,
chunk_index=2,
total_chunks=5,
)
mock_indexed.assert_awaited_once()
mock_offset.assert_awaited_once()
async def test_file_without_chunk_index_uses_offset_fallback(self, mock_nc_client):
"""Files with no chunk_index supplied still use the offset path —
the gate only kicks in once the indexed lookup has been attempted.
"""
with (
patch.object(
context_module,
"_get_chunk_by_index_from_qdrant",
new_callable=AsyncMock,
return_value=None,
) as mock_indexed,
patch.object(
context_module,
"_get_chunk_from_qdrant",
new_callable=AsyncMock,
return_value=None,
) as mock_offset,
):
await get_chunk_with_context(
nc_client=mock_nc_client,
user_id="alice",
doc_id=12345,
doc_type="file",
chunk_start=0,
chunk_end=100,
chunk_index=None,
total_chunks=20,
)
mock_indexed.assert_not_awaited()
mock_offset.assert_awaited_once()