Tagging an existing file/folder emits only OCP\SystemTag\MapperEvent — never a Node*Event — so tagged PDFs were previously only picked up by the hourly scanner. Subscribe to the tag event and reconcile membership so adding/removing the `vector-index` tag (re)indexes in near-real time. - webhook_presets: add OCP\SystemTag\MapperEvent to the files_sync preset (NC 32+, where MapperEvent gained getWebhookSerializable(); harmless on older servers — it just never fires). - webhook_parser: parse MapperEvent (objectType=files) into a path-less file "reconcile" task. The payload carries only a fileid + tagIds (no name/path), so assign and unassign both collapse to a reconcile. - processor._reconcile_tag_event: resolve the fileid against the user's current vector-index PDFs (find_files_by_tag). Present -> index with the resolved path/etag; absent -> flip to delete. Naturally handles "an unrelated tag changed" and a tagged folder's own fileid (no-op; the scanner still expands folders to descendants). - Unit tests for the parser branch and the reconcile. The matching admin-UI preset change ships separately in the astrolabe app repo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
310 lines
8.5 KiB
Python
310 lines
8.5 KiB
Python
"""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
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_non_numeric_time_field_returns_none():
|
|
"""A malformed ``time`` field must not raise ValueError out of the parser."""
|
|
payload = {
|
|
"user": {"uid": "admin"},
|
|
"time": "not-a-number",
|
|
"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_missing_time_field_defaults_to_zero():
|
|
payload = {
|
|
"user": {"uid": "admin"},
|
|
"event": {
|
|
"class": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
|
"node": {"id": 1, "path": "/admin/files/Notes/foo.md"},
|
|
},
|
|
}
|
|
|
|
task = extract_document_task(payload)
|
|
assert task is not None
|
|
assert task.modified_at == 0
|
|
|
|
|
|
# --- SystemTag MapperEvent (assign/unassign) -------------------------------
|
|
#
|
|
# NC 32+ serializes the event as {eventType, objectType, objectId, tagIds}.
|
|
# Both directions collapse to a path-less file "reconcile" task; the processor
|
|
# resolves current vector-index membership to decide index-vs-delete.
|
|
|
|
_TAG_MAPPER = "OCP\\SystemTag\\MapperEvent"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_tag_assign_event_returns_reconcile_task():
|
|
payload = {
|
|
"user": {"uid": "alice"},
|
|
"time": 1762850245,
|
|
"event": {
|
|
"class": _TAG_MAPPER,
|
|
"eventType": "OCP\\SystemTag\\ISystemTagObjectMapper::assignTags",
|
|
"objectType": "files",
|
|
"objectId": "478087",
|
|
"tagIds": [7],
|
|
},
|
|
}
|
|
|
|
task = extract_document_task(payload)
|
|
assert task is not None
|
|
assert task.user_id == "alice"
|
|
assert task.doc_type == "file"
|
|
assert task.doc_id == "478087"
|
|
assert task.operation == "index"
|
|
# No path in the payload -> the processor reconciles membership.
|
|
assert task.file_path is None
|
|
assert task.modified_at == 1762850245
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_tag_unassign_event_also_returns_reconcile_task():
|
|
"""Unassign collapses to the same reconcile (processor flips to delete)."""
|
|
payload = {
|
|
"user": {"uid": "alice"},
|
|
"time": 1762850245,
|
|
"event": {
|
|
"class": _TAG_MAPPER,
|
|
"eventType": "OCP\\SystemTag\\ISystemTagObjectMapper::unassignTags",
|
|
"objectType": "files",
|
|
"objectId": "478087",
|
|
"tagIds": [7],
|
|
},
|
|
}
|
|
|
|
task = extract_document_task(payload)
|
|
assert task is not None
|
|
assert task.doc_type == "file"
|
|
assert task.operation == "index"
|
|
assert task.file_path is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_tag_event_non_files_object_type_returns_none():
|
|
"""Tags on non-file objects (e.g. comments) don't drive vector sync."""
|
|
payload = {
|
|
"user": {"uid": "alice"},
|
|
"time": 1762850245,
|
|
"event": {
|
|
"class": _TAG_MAPPER,
|
|
"eventType": "OCP\\SystemTag\\ISystemTagObjectMapper::assignTags",
|
|
"objectType": "comments",
|
|
"objectId": "12",
|
|
"tagIds": [7],
|
|
},
|
|
}
|
|
|
|
assert extract_document_task(payload) is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_tag_event_missing_object_id_returns_none():
|
|
payload = {
|
|
"user": {"uid": "alice"},
|
|
"time": 1762850245,
|
|
"event": {
|
|
"class": _TAG_MAPPER,
|
|
"eventType": "OCP\\SystemTag\\ISystemTagObjectMapper::assignTags",
|
|
"objectType": "files",
|
|
"objectId": "",
|
|
"tagIds": [7],
|
|
},
|
|
}
|
|
|
|
assert extract_document_task(payload) is None
|