fix(vector-sync): address PR review — dict guard, symmetric backstop, metrics
- purge route: 400 (not 500) on a valid-JSON non-object body
- scanner: backstop-purge admin-disabled note/news_item/deck_card points
(their deletion-tracking lives inside the skipped scan_* fns), mirroring the
files path; gated on a concrete allow-set so fail-open never deletes
- processor: record_ingest_dropped("admin_disabled") so consent-skipped index
tasks are observable/alertable
- app.py: list /api/v1/vector-sync/purge in the endpoints log line
- capabilities: drop empty-string doc types; return frozenset throughout
- purge: document the count-before-delete approximation
- tests: non-object body -> 400, ProvisioningRequiredError -> 428, cache TTL
expiry refetch, and the scanner consent backstop
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ef5b3f3873
commit
477fb02b0a
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import nextcloud_mcp_server.capabilities as cap
|
||||
from nextcloud_mcp_server.capabilities import (
|
||||
_parse_enabled_doc_types,
|
||||
@@ -84,22 +86,19 @@ def test_is_doc_type_allowed_empty_set_blocks_all():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, payload=None, raises: Exception | None = None):
|
||||
self._payload = payload
|
||||
self._raises = raises
|
||||
self.calls = 0
|
||||
|
||||
async def capabilities(self):
|
||||
self.calls += 1
|
||||
if self._raises is not None:
|
||||
raise self._raises
|
||||
return self._payload
|
||||
def _client(payload=None, raises: Exception | None = None) -> AsyncMock:
|
||||
"""An object with an async ``capabilities()`` method (AsyncMock-backed)."""
|
||||
m = AsyncMock()
|
||||
if raises is not None:
|
||||
m.capabilities.side_effect = raises
|
||||
else:
|
||||
m.capabilities.return_value = payload
|
||||
return m
|
||||
|
||||
|
||||
async def test_allowed_doc_types_parses_and_caches():
|
||||
clear_cache()
|
||||
client = _FakeClient(_payload(["note", "file"]))
|
||||
client = _client(_payload(["note", "file"]))
|
||||
|
||||
first = await allowed_doc_types(client, "alice")
|
||||
second = await allowed_doc_types(client, "alice")
|
||||
@@ -107,38 +106,53 @@ async def test_allowed_doc_types_parses_and_caches():
|
||||
assert first == frozenset({"note", "file"})
|
||||
assert second == frozenset({"note", "file"})
|
||||
# Second call served from the cache — only one OCS round-trip.
|
||||
assert client.calls == 1
|
||||
assert client.capabilities.await_count == 1
|
||||
|
||||
|
||||
async def test_allowed_doc_types_missing_block_returns_none():
|
||||
clear_cache()
|
||||
client = _FakeClient({"ocs": {"data": {"capabilities": {}}}})
|
||||
client = _client({"ocs": {"data": {"capabilities": {}}}})
|
||||
assert await allowed_doc_types(client, "bob") is None
|
||||
|
||||
|
||||
async def test_allowed_doc_types_fail_open_not_cached():
|
||||
clear_cache()
|
||||
client = _FakeClient(raises=RuntimeError("ocs down"))
|
||||
client = _client(raises=RuntimeError("ocs down"))
|
||||
|
||||
assert await allowed_doc_types(client, "carol") is None
|
||||
# Failures are not cached — the next call retries the OCS lookup.
|
||||
assert await allowed_doc_types(client, "carol") is None
|
||||
assert client.calls == 2
|
||||
assert client.capabilities.await_count == 2
|
||||
|
||||
|
||||
async def test_allowed_doc_types_cache_is_per_user():
|
||||
clear_cache()
|
||||
alice = _FakeClient(_payload(["note"]))
|
||||
bob = _FakeClient(_payload(["file"]))
|
||||
alice = _client(_payload(["note"]))
|
||||
bob = _client(_payload(["file"]))
|
||||
|
||||
assert await allowed_doc_types(alice, "alice") == frozenset({"note"})
|
||||
assert await allowed_doc_types(bob, "bob") == frozenset({"file"})
|
||||
|
||||
|
||||
async def test_allowed_doc_types_refetches_after_ttl(monkeypatch):
|
||||
clear_cache()
|
||||
client = _client(_payload(["note"]))
|
||||
|
||||
# Drive the module clock so the second call lands past the TTL window.
|
||||
clock = {"now": 1000.0}
|
||||
monkeypatch.setattr(cap.time, "monotonic", lambda: clock["now"])
|
||||
|
||||
await allowed_doc_types(client, "erin")
|
||||
clock["now"] += cap._CACHE_TTL_SECONDS + 1
|
||||
await allowed_doc_types(client, "erin")
|
||||
|
||||
assert client.capabilities.await_count == 2
|
||||
|
||||
|
||||
async def test_clear_cache_forces_refetch():
|
||||
clear_cache()
|
||||
client = _FakeClient(_payload(["note"]))
|
||||
client = _client(_payload(["note"]))
|
||||
await allowed_doc_types(client, "dave")
|
||||
cap.clear_cache()
|
||||
await allowed_doc_types(client, "dave")
|
||||
assert client.calls == 2
|
||||
assert client.capabilities.await_count == 2
|
||||
|
||||
Reference in New Issue
Block a user