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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
111ae988f9
commit
2e2a098bee
@@ -0,0 +1,188 @@
|
||||
"""Unit tests for the Nextcloud webhook payload parser.
|
||||
|
||||
Payload examples are taken from real Nextcloud captures recorded in
|
||||
``webhook-testing-findings.md``.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.vector.webhook_parser import extract_document_task
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_node_created_event_returns_index_task():
|
||||
payload = {
|
||||
"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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
task = extract_document_task(payload)
|
||||
|
||||
assert task is not None
|
||||
assert task.user_id == "admin"
|
||||
assert task.doc_id == "437"
|
||||
assert task.doc_type == "note"
|
||||
assert task.operation == "index"
|
||||
assert task.modified_at == 1762850245
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_node_written_event_returns_index_task():
|
||||
payload = {
|
||||
"user": {"uid": "admin", "displayName": "admin"},
|
||||
"time": 1762850960,
|
||||
"event": {
|
||||
"class": "OCP\\Files\\Events\\Node\\NodeWrittenEvent",
|
||||
"node": {
|
||||
"id": 437,
|
||||
"path": "/admin/files/Notes/Webhooks/Webhook Test Note.md",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
task = extract_document_task(payload)
|
||||
|
||||
assert task is not None
|
||||
assert task.operation == "index"
|
||||
assert task.doc_id == "437"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_before_node_deleted_event_returns_delete_task():
|
||||
payload = {
|
||||
"user": {"uid": "alice", "displayName": "Alice"},
|
||||
"time": 1762851093,
|
||||
"event": {
|
||||
"class": "OCP\\Files\\Events\\Node\\BeforeNodeDeletedEvent",
|
||||
"node": {
|
||||
"id": 437,
|
||||
"path": "/alice/files/Notes/Webhooks/Webhook Test Note.md",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
task = extract_document_task(payload)
|
||||
|
||||
assert task is not None
|
||||
assert task.user_id == "alice"
|
||||
assert task.operation == "delete"
|
||||
assert task.doc_id == "437"
|
||||
assert task.doc_type == "note"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_node_id_is_normalized_to_string():
|
||||
"""NC sends node.id as int; we always emit str (per ADR-010 §A.3)."""
|
||||
payload = {
|
||||
"user": {"uid": "admin"},
|
||||
"time": 1,
|
||||
"event": {
|
||||
"class": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||
"node": {"id": 437, "path": "/admin/files/Notes/foo.md"},
|
||||
},
|
||||
}
|
||||
|
||||
task = extract_document_task(payload)
|
||||
|
||||
assert task is not None
|
||||
assert isinstance(task.doc_id, str)
|
||||
assert task.doc_id == "437"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_path_outside_notes_returns_none():
|
||||
payload = {
|
||||
"user": {"uid": "admin"},
|
||||
"time": 1,
|
||||
"event": {
|
||||
"class": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||
"node": {"id": 1, "path": "/admin/files/Documents/foo.md"},
|
||||
},
|
||||
}
|
||||
|
||||
assert extract_document_task(payload) is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_non_markdown_inside_notes_returns_none():
|
||||
payload = {
|
||||
"user": {"uid": "admin"},
|
||||
"time": 1,
|
||||
"event": {
|
||||
"class": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||
"node": {"id": 1, "path": "/admin/files/Notes/image.png"},
|
||||
},
|
||||
}
|
||||
|
||||
assert extract_document_task(payload) is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_parent_folder_event_returns_none():
|
||||
"""Creating a note fires events for the parent folder too — ignore those."""
|
||||
payload = {
|
||||
"user": {"uid": "admin"},
|
||||
"time": 1,
|
||||
"event": {
|
||||
"class": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||
"node": {"id": 100, "path": "/admin/files/Notes/Webhooks"},
|
||||
},
|
||||
}
|
||||
|
||||
assert extract_document_task(payload) is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_unknown_event_class_returns_none():
|
||||
payload = {
|
||||
"user": {"uid": "admin"},
|
||||
"time": 1,
|
||||
"event": {
|
||||
"class": "OCP\\Calendar\\Events\\CalendarObjectCreatedEvent",
|
||||
"objectData": {"id": 7, "uri": "x.ics"},
|
||||
},
|
||||
}
|
||||
|
||||
assert extract_document_task(payload) is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_node_deleted_event_without_id_returns_none():
|
||||
"""``NodeDeletedEvent`` (no node.id) is not the registered event, but if
|
||||
one ever leaks through we ignore it rather than guess at the doc_id —
|
||||
the polling scanner will catch up via its grace period."""
|
||||
payload = {
|
||||
"user": {"uid": "admin"},
|
||||
"time": 1,
|
||||
"event": {
|
||||
"class": "OCP\\Files\\Events\\Node\\BeforeNodeDeletedEvent",
|
||||
"node": {"path": "/admin/files/Notes/foo.md"},
|
||||
},
|
||||
}
|
||||
|
||||
assert extract_document_task(payload) is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_missing_user_field_returns_none():
|
||||
payload = {
|
||||
"time": 1,
|
||||
"event": {
|
||||
"class": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||
"node": {"id": 1, "path": "/admin/files/Notes/foo.md"},
|
||||
},
|
||||
}
|
||||
|
||||
assert extract_document_task(payload) is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_empty_payload_returns_none():
|
||||
assert extract_document_task({}) is None
|
||||
Reference in New Issue
Block a user