diff --git a/nextcloud_mcp_server/api/vector_sync.py b/nextcloud_mcp_server/api/vector_sync.py index 000e2cd5..3944de33 100644 --- a/nextcloud_mcp_server/api/vector_sync.py +++ b/nextcloud_mcp_server/api/vector_sync.py @@ -121,16 +121,16 @@ async def purge_doc_types_route(request: Request) -> JSONResponse: # NOT purged (consent not yet enforced for them) — the scanner backstop # still catches these, but the caller shouldn't assume full success. failed = [dt for dt in dict.fromkeys(doc_types) if dt not in purged] - body: dict = {"purged": purged} + resp: dict = {"purged": purged} if failed: - body["failed"] = failed + resp["failed"] = failed logger.info( "Vector-sync purge by admin %s: purged=%s failed=%s", user_id, purged, failed, ) - return JSONResponse(body) + return JSONResponse(resp) except ProvisioningRequiredError as e: logger.info("Provisioning required for user %s: %s", user_id, e) diff --git a/nextcloud_mcp_server/vector/scanner.py b/nextcloud_mcp_server/vector/scanner.py index 907bb021..c1623e54 100644 --- a/nextcloud_mcp_server/vector/scanner.py +++ b/nextcloud_mcp_server/vector/scanner.py @@ -594,6 +594,9 @@ async def scan_user_documents( # indexed. The deletion-reconcile below then sees every indexed # file as "missing" and purges it after the grace period — the # backstop for the eager purge Astrolabe runs on disable. + # Asymmetry (intentional): files purge up to 1.5x scan_interval + # later than text types, which get immediate one-shot backstop + # deletes via _enqueue_deletes_for_disabled_types. logger.debug( "[SCAN-%s] Files disabled by admin for %s; skipping tagged-file discovery", scan_id, diff --git a/tests/unit/vector/test_scanner_consent_backstop.py b/tests/unit/vector/test_scanner_consent_backstop.py index 5b4cefd3..e87ebed9 100644 --- a/tests/unit/vector/test_scanner_consent_backstop.py +++ b/tests/unit/vector/test_scanner_consent_backstop.py @@ -73,6 +73,25 @@ async def test_enqueues_deletes_for_disabled_text_type(monkeypatch): assert all(t.operation == "delete" and t.doc_type == "note" for t in sent) +async def test_all_text_types_disabled_enqueues_all(monkeypatch): + # Admin disabled everything at once (empty allow-set): every text type's + # indexed points are enqueued for deletion in a single call. + _patch_qdrant( + monkeypatch, {"note": ["n1"], "news_item": ["ni1"], "deck_card": ["d1"]} + ) + sent: list = [] + stream = _producer(AsyncMock(side_effect=lambda t: sent.append(t))) + + queued = await _enqueue_deletes_for_disabled_types("alice", stream, frozenset(), 1) + + assert queued == 3 + assert {(t.doc_type, t.doc_id) for t in sent} == { + ("note", "n1"), + ("news_item", "ni1"), + ("deck_card", "d1"), + } + + async def test_noop_when_allowed_is_none(monkeypatch): # Fail-open: a transient capability read must never trigger deletion. send = AsyncMock()