test(vector): address PR #873 round-1 review

- Extract `_app_enabled` to a module-level helper so the gate predicate is
  unit-tested directly instead of via an inline copy that could drift.
- Move `import logging` to module scope in test_scanner_app_gating.py.
- Harden `get_enabled_apps` OCS-envelope parsing (`X or {}` / `or []`) so a
  present-but-null `ocs`/`data` coerces to empty instead of raising on
  `None.get`; add parametrized malformed-envelope tests.
- Use https:// in the test request URL (SonarCloud S5332 hotspot).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-07 20:04:00 +02:00
co-authored by Claude Opus 4.8
parent 2e609cbea7
commit b11d1b17a3
4 changed files with 51 additions and 23 deletions
+5 -1
View File
@@ -214,7 +214,11 @@ class NextcloudClient:
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
entries = data.get("ocs", {}).get("data", []) or [] # ``X or {}``/``or []`` (not ``.get(k, default)``) so a present-but-null
# ``ocs``/``data`` (``{"ocs": null}``) coerces to empty instead of
# raising AttributeError on ``None.get``.
ocs = data.get("ocs") or {}
entries = ocs.get("data") or []
enabled: set[str] = set() enabled: set[str] = set()
for entry in entries: for entry in entries:
# ``app`` is the canonical app id; ``id`` matches it for the apps we # ``app`` is the canonical app id; ``id`` matches it for the apps we
+13 -6
View File
@@ -269,6 +269,16 @@ async def _get_enabled_apps_or_none(
return None return None
def _app_enabled(app_id: str, enabled_apps: set[str] | None) -> bool:
"""Whether ``app_id`` should be scanned for the current user.
``enabled_apps is None`` means detection failed — every app is treated as
enabled (the scan-all fallback) so a transient navigation-endpoint failure
never silently halts indexing.
"""
return enabled_apps is None or app_id in enabled_apps
async def scan_user_documents( async def scan_user_documents(
user_id: str, user_id: str,
send_stream: TaskProducer, send_stream: TaskProducer,
@@ -355,9 +365,6 @@ async def scan_user_documents(
# detection failed: fall back to scanning every app (prior behaviour). # detection failed: fall back to scanning every app (prior behaviour).
enabled_apps = await _get_enabled_apps_or_none(nc_client, user_id, scan_id) enabled_apps = await _get_enabled_apps_or_none(nc_client, user_id, scan_id)
def _app_enabled(app_id: str) -> bool:
return enabled_apps is None or app_id in enabled_apps
# Notes (isolated so an uninstalled or disabled Notes app — whose API # Notes (isolated so an uninstalled or disabled Notes app — whose API
# returns 404 — cannot abort scanning of the other apps; this mirrors the # returns 404 — cannot abort scanning of the other apps; this mirrors the
# per-app try/except guards already wrapping files/news/deck below). # per-app try/except guards already wrapping files/news/deck below).
@@ -366,7 +373,7 @@ async def scan_user_documents(
current_time = time.time() current_time = time.time()
queued = 0 queued = 0
if _app_enabled("notes"): if _app_enabled("notes", enabled_apps):
try: try:
queued += await scan_notes( queued += await scan_notes(
user_id=user_id, user_id=user_id,
@@ -703,7 +710,7 @@ async def scan_user_documents(
# Scan News items (starred + unread) # Scan News items (starred + unread)
news_queued = 0 news_queued = 0
if _app_enabled("news"): if _app_enabled("news", enabled_apps):
try: try:
news_queued = await scan_news_items( news_queued = await scan_news_items(
user_id=user_id, user_id=user_id,
@@ -724,7 +731,7 @@ async def scan_user_documents(
# Scan Deck cards # Scan Deck cards
deck_queued = 0 deck_queued = 0
if _app_enabled("deck"): if _app_enabled("deck", enabled_apps):
try: try:
deck_queued = await scan_deck_cards( deck_queued = await scan_deck_cards(
user_id=user_id, user_id=user_id,
@@ -94,6 +94,20 @@ class TestGetEnabledApps:
assert await client.get_enabled_apps() == {"notes"} assert await client.get_enabled_apps() == {"notes"}
@pytest.mark.parametrize("body", [{}, {"ocs": None}, {"ocs": {"data": None}}])
async def test_malformed_envelope_returns_empty_set(self, body):
"""A missing/null ``ocs``/``data`` envelope yields an empty set rather
than raising — the scanner then gates every app off, and its own
fallback (``_get_enabled_apps_or_none``) keeps indexing safe."""
client = _make_client()
response = MagicMock()
response.raise_for_status = MagicMock()
response.json.return_value = body
client._client = AsyncMock()
client._client.get = AsyncMock(return_value=response)
assert await client.get_enabled_apps() == set()
class TestNormaliseSearchResult: class TestNormaliseSearchResult:
def test_adds_leading_slash_to_path(self): def test_adds_leading_slash_to_path(self):
+19 -16
View File
@@ -1,17 +1,22 @@
"""Unit tests for the vector scanner's enabled-app gating helper. """Unit tests for the vector scanner's enabled-app gating helpers.
``scan_user_documents`` skips polling apps the user doesn't have enabled (those ``scan_user_documents`` skips polling apps the user doesn't have enabled (those
polls 404 and flood tenant logs). ``_get_enabled_apps_or_none`` resolves the polls 404 and flood tenant logs). ``_get_enabled_apps_or_none`` resolves the
enabled-app set, returning ``None`` on any failure so the caller falls back to enabled-app set, returning ``None`` on any failure so the caller falls back to
scanning every app (the prior behaviour) rather than silently halting indexing. scanning every app (the prior behaviour) rather than silently halting indexing;
``_app_enabled`` is the gate predicate applied per app.
""" """
import logging
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import pytest import pytest
from httpx import HTTPStatusError, Request, Response from httpx import HTTPStatusError, Request, Response
from nextcloud_mcp_server.vector.scanner import _get_enabled_apps_or_none from nextcloud_mcp_server.vector.scanner import (
_app_enabled,
_get_enabled_apps_or_none,
)
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
@@ -27,33 +32,31 @@ async def test_returns_enabled_set_on_success():
async def test_returns_none_when_detection_raises(caplog): async def test_returns_none_when_detection_raises(caplog):
nc_client = AsyncMock() nc_client = AsyncMock()
request = Request("GET", "http://nc.test/ocs/v2.php/core/navigation/apps") request = Request("GET", "https://nc.test/ocs/v2.php/core/navigation/apps")
nc_client.get_enabled_apps = AsyncMock( nc_client.get_enabled_apps = AsyncMock(
side_effect=HTTPStatusError( side_effect=HTTPStatusError(
"boom", request=request, response=Response(503, request=request) "boom", request=request, response=Response(503, request=request)
) )
) )
import logging
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.vector.scanner") caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.vector.scanner")
result = await _get_enabled_apps_or_none(nc_client, "alice", scan_id=1234) result = await _get_enabled_apps_or_none(nc_client, "alice", scan_id=1234)
# None signals scan-all fallback; the inline gate treats `None` as # None signals scan-all fallback; _app_enabled treats `None` as
# "every app enabled" so indexing never silently stops. # "every app enabled" so indexing never silently stops.
assert result is None assert result is None
assert "scanning all apps" in caplog.text assert "scanning all apps" in caplog.text
def test_none_set_enables_every_app(): def test_none_set_enables_every_app():
"""The gate predicate used in scan_user_documents: a None set means """A None set means detection failed, so every app must be scanned."""
detection failed, so every app must be scanned (back-compat).""" assert _app_enabled("news", None) is True
assert _app_enabled("deck", None) is True
def app_enabled(app_id: str, enabled: set[str] | None) -> bool:
return enabled is None or app_id in enabled
assert app_enabled("news", None) is True def test_concrete_set_gates_precisely():
assert app_enabled("deck", None) is True """A resolved set scans only the apps it contains."""
# And a concrete set gates precisely. enabled = {"notes", "files"}
assert app_enabled("news", {"notes"}) is False assert _app_enabled("notes", enabled) is True
assert app_enabled("notes", {"notes"}) is True assert _app_enabled("news", enabled) is False
assert _app_enabled("deck", enabled) is False