refactor(search): pre-push review fixes for PR #750

Address findings surfaced by `pre-push-review` after the round 8 sweep:

- Add deck verifier symmetry tests (404, transient 5xx, unexpected
  exception, non-numeric metadata) so deck has the same shape as the
  notes/news/files verifiers. Also add unexpected-exception tests for
  the news and file verifiers, which had `except Exception` branches
  no test was reaching. Keeps the registry-style verifier coverage
  uniform.
- Modernize sibling field types in `VectorSyncState`, `AppContext`,
  and `OAuthAppContext` from `Optional[X]` to `X | None`, matching the
  `eviction_task_group: TaskGroup | None` field added in the round 8
  diff (resolves the inconsistency flagged by A6). The lone remaining
  `Optional` import is dropped.
- Reverse cross-reference direction in the verifier docstrings: the
  later-defined `_verify_deck_cards` and `_verify_news_items` now
  point at `_verify_notes` as the canonical hoisted-cast pattern,
  rather than `_verify_notes` forward-referring to verifiers defined
  below it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-01 21:57:38 +02:00
co-authored by Claude Opus 4.7
parent 06fe3916e7
commit 3153c9dac4
3 changed files with 195 additions and 23 deletions
+17 -19
View File
@@ -9,7 +9,7 @@ import traceback
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import AsyncExitStack, asynccontextmanager from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional, cast from typing import cast
from urllib.parse import urlparse from urllib.parse import urlparse
import anyio import anyio
@@ -316,10 +316,10 @@ class VectorSyncState:
and FastMCP session lifespans (where MCP tools need access to the streams). and FastMCP session lifespans (where MCP tools need access to the streams).
""" """
document_send_stream: Optional[MemoryObjectSendStream] = None document_send_stream: MemoryObjectSendStream | None = None
document_receive_stream: Optional[MemoryObjectReceiveStream] = None document_receive_stream: MemoryObjectReceiveStream | None = None
shutdown_event: Optional[anyio.Event] = None shutdown_event: anyio.Event | None = None
scanner_wake_event: Optional[anyio.Event] = None scanner_wake_event: anyio.Event | None = None
# Long-lived task group used for fire-and-forget background work spawned # Long-lived task group used for fire-and-forget background work spawned
# from the request path (e.g. ADR-019 verify-on-read eviction). Set by the # from the request path (e.g. ADR-019 verify-on-read eviction). Set by the
# starlette lifespan after entering its task group; cleared on shutdown. # starlette lifespan after entering its task group; cleared on shutdown.
@@ -335,11 +335,11 @@ class AppContext:
"""Application context for BasicAuth mode.""" """Application context for BasicAuth mode."""
client: NextcloudClient client: NextcloudClient
storage: Optional["RefreshTokenStorage"] = None storage: "RefreshTokenStorage | None" = None
document_send_stream: Optional[MemoryObjectSendStream] = None document_send_stream: MemoryObjectSendStream | None = None
document_receive_stream: Optional[MemoryObjectReceiveStream] = None document_receive_stream: MemoryObjectReceiveStream | None = None
shutdown_event: Optional[anyio.Event] = None shutdown_event: anyio.Event | None = None
scanner_wake_event: Optional[anyio.Event] = None scanner_wake_event: anyio.Event | None = None
@property @property
def eviction_task_group(self) -> TaskGroup | None: def eviction_task_group(self) -> TaskGroup | None:
@@ -357,16 +357,14 @@ class OAuthAppContext:
nextcloud_host: str nextcloud_host: str
token_verifier: object # UnifiedTokenVerifier (ADR-005 compliant) token_verifier: object # UnifiedTokenVerifier (ADR-005 compliant)
refresh_token_storage: Optional["RefreshTokenStorage"] = None refresh_token_storage: "RefreshTokenStorage | None" = None
oauth_client: Optional[object] = None oauth_client: object | None = None
oauth_provider: str = "nextcloud" # "nextcloud" or "keycloak" oauth_provider: str = "nextcloud" # "nextcloud" or "keycloak"
server_client_id: Optional[str] = ( server_client_id: str | None = None # MCP server's OAuth client ID (static or DCR)
None # MCP server's OAuth client ID (static or DCR) document_send_stream: MemoryObjectSendStream | None = None
) document_receive_stream: MemoryObjectReceiveStream | None = None
document_send_stream: Optional[MemoryObjectSendStream] = None shutdown_event: anyio.Event | None = None
document_receive_stream: Optional[MemoryObjectReceiveStream] = None scanner_wake_event: anyio.Event | None = None
shutdown_event: Optional[anyio.Event] = None
scanner_wake_event: Optional[anyio.Event] = None
@property @property
def eviction_task_group(self) -> TaskGroup | None: def eviction_task_group(self) -> TaskGroup | None:
+5 -4
View File
@@ -72,8 +72,9 @@ async def _verify_notes(
doc_id = result.id doc_id = result.id
# Parse defensively before the network call so a malformed payload # Parse defensively before the network call so a malformed payload
# produces a specific log line, not a generic "unexpected error" # produces a specific log line, not a generic "unexpected error"
# from the catch-all ``except Exception`` below. The parallel # from the catch-all ``except Exception`` below. ``_verify_notes``
# implementation in ``_verify_deck_cards`` follows the same pattern. # is the canonical shape; ``_verify_deck_cards`` and
# ``_verify_news_items`` mirror this hoisted-cast pattern.
try: try:
note_id_int = int(doc_id) note_id_int = int(doc_id)
except (TypeError, ValueError) as e: except (TypeError, ValueError) as e:
@@ -210,8 +211,8 @@ async def _verify_deck_cards(
# Parse defensively before the network call so a malformed payload # Parse defensively before the network call so a malformed payload
# produces a specific log line, not a generic "unexpected error" # produces a specific log line, not a generic "unexpected error"
# from the catch-all ``except Exception`` below. The parallel # from the catch-all ``except Exception`` below. Mirrors the
# implementation in ``_verify_news_items`` follows the same pattern. # canonical hoisted-cast pattern in ``_verify_notes``.
try: try:
board_id_int = int(board_id) board_id_int = int(board_id)
stack_id_int = int(stack_id) stack_id_int = int(stack_id)
+173
View File
@@ -289,6 +289,31 @@ async def test_verify_news_items_transient_keeps_all(mocker):
assert result == {1, 2, 3} assert result == {1, 2, 3}
@pytest.mark.unit
async def test_verify_news_items_unexpected_exception_keeps_all(mocker):
"""A non-HTTP exception from get_items must keep all results (fail open).
Covers the catch-all ``except Exception`` branch that exists so a bug
in the News client (or an httpx connection error) cannot silently shrink
the result set.
"""
news_client = SimpleNamespace(
get_items=mocker.AsyncMock(side_effect=RuntimeError("news client boom"))
)
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"),
],
_sem(),
)
assert result == {1, 2}
@pytest.mark.unit @pytest.mark.unit
async def test_verify_news_items_non_numeric_id_keeps_only_bad_item(mocker): async def test_verify_news_items_non_numeric_id_keeps_only_bad_item(mocker):
"""A non-numeric doc_id is fail-open per item, not per batch. """A non-numeric doc_id is fail-open per item, not per batch.
@@ -480,6 +505,28 @@ async def test_verify_files_transient_5xx_keeps(mocker):
assert result == {7} assert result == {7}
@pytest.mark.unit
async def test_verify_files_unexpected_exception_keeps(mocker):
"""A non-HTTP exception from get_file_info must not drop the result.
The catch-all ``except Exception`` branch in the file verifier exists
so a bug in the WebDAV client (or an httpx ConnectError on a flaky
network) cannot silently shrink result pages.
"""
webdav_client = SimpleNamespace(
get_file_info=mocker.AsyncMock(side_effect=RuntimeError("dav blew up"))
)
client = SimpleNamespace(webdav=webdav_client, username="alice")
result = await _verify_files(
client,
[_make_result(8, doc_type="file", metadata={"path": "y.txt"})],
_sem(),
)
assert result == {8}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Deck card verifier # Deck card verifier
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -530,6 +577,132 @@ async def test_verify_deck_cards_403_drops(mocker):
assert result == set() assert result == set()
@pytest.mark.unit
async def test_verify_deck_cards_404_drops(mocker):
"""Card deleted from the board → 404 from get_card → drop."""
deck_client = SimpleNamespace(
get_card=mocker.AsyncMock(side_effect=_http_error(404))
)
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_transient_5xx_keeps(mocker):
"""Transient 5xx from get_card must NOT silently shrink results."""
deck_client = SimpleNamespace(
get_card=mocker.AsyncMock(side_effect=_http_error(502))
)
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}
@pytest.mark.unit
async def test_verify_deck_cards_unexpected_exception_keeps(mocker):
"""Non-HTTP exception from get_card → fail-open, keep result."""
deck_client = SimpleNamespace(
get_card=mocker.AsyncMock(side_effect=RuntimeError("deck client boom"))
)
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}
@pytest.mark.unit
async def test_verify_deck_cards_non_numeric_metadata_keeps(mocker):
"""Non-numeric board_id/stack_id/card_id must fail open before the API call.
The hoisted ``int()`` casts in ``_verify_deck_cards`` produce a
type-specific log line; the catch-all path must never run.
"""
deck_client = SimpleNamespace(
get_card=mocker.AsyncMock(side_effect=AssertionError("must not be called"))
)
client = SimpleNamespace(deck=deck_client, username="alice")
# Non-numeric board_id
result = await _verify_deck_cards(
client,
[
_make_result(
42,
doc_type="deck_card",
metadata={"board_id": "not-a-number", "stack_id": 2},
)
],
_sem(),
)
assert result == {42}
# Non-numeric stack_id
result = await _verify_deck_cards(
client,
[
_make_result(
43,
doc_type="deck_card",
metadata={"board_id": 1, "stack_id": "bad"},
)
],
_sem(),
)
assert result == {43}
# Non-numeric card_id (doc_id itself)
result = await _verify_deck_cards(
client,
[
_make_result(
"card-uuid",
doc_type="deck_card",
metadata={"board_id": 1, "stack_id": 2},
)
],
_sem(),
)
assert result == {"card-uuid"}
deck_client.get_card.assert_not_awaited()
@pytest.mark.unit @pytest.mark.unit
async def test_verify_deck_cards_missing_metadata_keeps_unverified(mocker): 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.""" """Legacy data without board_id/stack_id → keep, do NOT iterate or call API."""