Files
mcp-nextcloud/tests/unit/test_webhook_endpoint.py
T
Chris CoutinhoandClaude Opus 4.7 2e2a098bee fix(webhooks): wire receiver to vector sync queue and fix registered URI
The /webhooks/nextcloud endpoint was a no-op stub that logged the
payload and returned 200 OK; webhook deletions never reached Qdrant.
Compounding that, _get_webhook_uri() registered the docker-compose
internal hostname (http://mcp:8000) with Nextcloud whenever
/.dockerenv existed — including ECS Fargate — so cloud deployments
were registering a URL NC could not resolve.

- New vector/webhook_parser.py extracts a DocumentTask from
  NodeCreatedEvent / NodeWrittenEvent / BeforeNodeDeletedEvent
  payloads scoped to */files/Notes/*.md (matching the registered
  preset filters).
- New vector/webhook_receiver.py pushes that task onto the same
  send-stream the scanner uses (app.state.document_send_stream),
  with 503 when sync is not running so NC retries delivery.
- _get_webhook_uri() now prefers NEXTCLOUD_MCP_SERVER_URL over the
  /.dockerenv branch, so the explicit public URL set on cloud tasks
  wins; docker-compose dev still falls back to the internal name when
  no public URL is configured.

Calendar / Tables event parsing is intentionally out of scope here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 03:13:50 +02:00

145 lines
4.3 KiB
Python

"""Unit tests for the ``/webhooks/nextcloud`` HTTP receiver.
Builds a minimal Starlette app around ``handle_nextcloud_webhook`` so we can
drive it with ``TestClient`` without standing up the full FastMCP server.
"""
import anyio
import pytest
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from nextcloud_mcp_server.vector.webhook_receiver import handle_nextcloud_webhook
pytestmark = pytest.mark.unit
def _make_app(send_stream=None) -> Starlette:
app = Starlette(
routes=[
Route("/webhooks/nextcloud", handle_nextcloud_webhook, methods=["POST"])
]
)
app.state.document_send_stream = send_stream
return app
_NOTE_CREATED = {
"user": {"uid": "admin", "displayName": "admin"},
"time": 1762850245,
"event": {
"class": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
"node": {
"id": 437,
"path": "/admin/files/Notes/Webhooks/Webhook Test Note.md",
},
},
}
_NOTE_DELETED = {
"user": {"uid": "alice"},
"time": 1762851093,
"event": {
"class": "OCP\\Files\\Events\\Node\\BeforeNodeDeletedEvent",
"node": {"id": 99, "path": "/alice/files/Notes/foo.md"},
},
}
def test_index_event_queues_task_and_returns_200():
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
with TestClient(app) as client:
response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED)
assert response.status_code == 200
assert response.json()["status"] == "queued"
assert response.json()["operation"] == "index"
assert response.json()["doc_id"] == "437"
task = receive_stream.receive_nowait()
assert task.user_id == "admin"
assert task.doc_id == "437"
assert task.operation == "index"
assert task.doc_type == "note"
def test_delete_event_queues_delete_task():
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
with TestClient(app) as client:
response = client.post("/webhooks/nextcloud", json=_NOTE_DELETED)
assert response.status_code == 200
assert response.json()["operation"] == "delete"
task = receive_stream.receive_nowait()
assert task.operation == "delete"
assert task.doc_id == "99"
assert task.user_id == "alice"
def test_unsupported_event_is_ignored():
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=4)
app = _make_app(send_stream=send_stream)
payload = {
"user": {"uid": "admin"},
"time": 1,
"event": {
"class": "OCP\\Calendar\\Events\\CalendarObjectCreatedEvent",
"objectData": {"id": 7},
},
}
with TestClient(app) as client:
response = client.post("/webhooks/nextcloud", json=payload)
assert response.status_code == 200
assert response.json()["status"] == "ignored"
with pytest.raises(anyio.WouldBlock):
receive_stream.receive_nowait()
def test_invalid_json_returns_400():
app = _make_app(send_stream=None)
with TestClient(app) as client:
response = client.post(
"/webhooks/nextcloud",
content=b"not json",
headers={"content-type": "application/json"},
)
assert response.status_code == 400
assert response.json()["status"] == "error"
def test_returns_503_when_send_stream_not_wired():
"""Vector sync not running → tell NC to retry instead of dropping the
event."""
app = _make_app(send_stream=None)
with TestClient(app) as client:
response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED)
assert response.status_code == 503
assert response.json()["status"] == "unavailable"
def test_returns_500_when_stream_is_closed():
send_stream, receive_stream = anyio.create_memory_object_stream(max_buffer_size=1)
receive_stream.close() # close receiver → send raises BrokenResourceError
app = _make_app(send_stream=send_stream)
with TestClient(app) as client:
response = client.post("/webhooks/nextcloud", json=_NOTE_CREATED)
assert response.status_code == 500
assert response.json()["status"] == "error"