- 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>
82 lines
2.9 KiB
Python
82 lines
2.9 KiB
Python
"""Unit tests for the scanner's admin-consent backstop deletion.
|
|
|
|
When an admin disables a text source (note/news_item/deck_card), the scanner
|
|
skips its scan_* function, so the in-function deletion-tracking never runs. The
|
|
backstop enqueues deletes for any indexed points of the disabled type, mirroring
|
|
the files path — but only on a concrete allow-set (never on fail-open None).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from typing import cast
|
|
from unittest.mock import AsyncMock
|
|
|
|
from nextcloud_mcp_server.vector import scanner as scanner_module
|
|
from nextcloud_mcp_server.vector.queue.ports import TaskProducer
|
|
from nextcloud_mcp_server.vector.scanner import _enqueue_deletes_for_disabled_types
|
|
|
|
|
|
def _producer(send: AsyncMock) -> TaskProducer:
|
|
"""A minimal stand-in for the TaskProducer protocol (only ``send`` is used)."""
|
|
return cast(TaskProducer, SimpleNamespace(send=send))
|
|
|
|
|
|
def _patch_qdrant(monkeypatch, points_by_type: dict[str, list[str]]):
|
|
client = AsyncMock()
|
|
|
|
def fake_scroll(
|
|
*, collection_name, scroll_filter, with_payload, with_vectors, limit, offset
|
|
):
|
|
# must=[user_id, doc_type] — doc_type is the second condition.
|
|
doc_type = scroll_filter.must[1].match.value
|
|
points = [
|
|
SimpleNamespace(payload={"doc_id": doc_id})
|
|
for doc_id in points_by_type.get(doc_type, [])
|
|
]
|
|
return (points, None)
|
|
|
|
client.scroll.side_effect = fake_scroll
|
|
monkeypatch.setattr(
|
|
scanner_module, "get_qdrant_client", AsyncMock(return_value=client)
|
|
)
|
|
monkeypatch.setattr(
|
|
scanner_module,
|
|
"get_settings",
|
|
lambda: SimpleNamespace(get_collection_name=lambda: "c"),
|
|
)
|
|
|
|
|
|
async def test_enqueues_deletes_for_disabled_text_type(monkeypatch):
|
|
_patch_qdrant(monkeypatch, {"note": ["n1", "n2"], "deck_card": ["d1"]})
|
|
sent: list = []
|
|
stream = _producer(AsyncMock(side_effect=lambda t: sent.append(t)))
|
|
|
|
# note disabled; news_item + deck_card still allowed.
|
|
allowed = frozenset({"file", "news_item", "deck_card"})
|
|
queued = await _enqueue_deletes_for_disabled_types("alice", stream, allowed, 1)
|
|
|
|
assert queued == 2
|
|
assert {t.doc_id for t in sent} == {"n1", "n2"}
|
|
assert all(t.operation == "delete" and t.doc_type == "note" for t in sent)
|
|
|
|
|
|
async def test_noop_when_allowed_is_none(monkeypatch):
|
|
# Fail-open: a transient capability read must never trigger deletion.
|
|
send = AsyncMock()
|
|
queued = await _enqueue_deletes_for_disabled_types(
|
|
"alice", _producer(send), None, 1
|
|
)
|
|
assert queued == 0
|
|
send.assert_not_called()
|
|
|
|
|
|
async def test_noop_when_all_text_types_allowed(monkeypatch):
|
|
send = AsyncMock()
|
|
allowed = frozenset({"note", "news_item", "deck_card", "file"})
|
|
queued = await _enqueue_deletes_for_disabled_types(
|
|
"alice", _producer(send), allowed, 1
|
|
)
|
|
assert queued == 0
|
|
send.assert_not_called()
|