Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/tests-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ jobs:
strategy:
fail-fast: false
matrix:
# NC34 GA 2026-06-09: replace "32" with "34" here and in the two jobs below once nextcloud:34 publishes.
nextcloud-version: ["32", "33"]
services:
nextcloud:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ export NEXTCLOUD_PASSWORD=admin
pytest tests/integration/ -v
```

CI automatically runs integration tests against Nextcloud 32 and 33 Docker containers.
CI automatically runs integration tests against the two newest released Nextcloud versions — currently 32 and 33 — using the official Docker images. Nextcloud 34 (GA 2026-06-09) joins the matrix once its image is published on Docker Hub.

## About This Project

Expand Down
48 changes: 47 additions & 1 deletion scripts/seed_pagination_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"""

import sys
import time
import xml.etree.ElementTree as ET

import niquests
Expand Down Expand Up @@ -172,6 +173,39 @@ def seed_contacts(s: niquests.Session, url: str, user: str) -> None:
print(f" {COUNT} contacts with categories and varied name structures")


def _fetch_all_comments(s: niquests.Session, url: str, file_id: str) -> list[tuple[str, str, str]]:
"""Return all comments on file_id as list of (id, message, creation_ts)."""
out: list[tuple[str, str, str]] = []
offset = 0
while True:
report = s.request(
"REPORT",
f"{url}/remote.php/dav/comments/files/{file_id}",
data=(
'<?xml version="1.0" encoding="utf-8"?>'
'<oc:filter-comments xmlns:oc="http://owncloud.org/ns">'
f"<oc:limit>100</oc:limit><oc:offset>{offset}</oc:offset>"
"</oc:filter-comments>"
),
headers={"Content-Type": "application/xml"},
)
batch: list[tuple[str, str, str]] = []
for resp_el in ET.fromstring(report.text).findall("{DAV:}response"):
href = resp_el.find("{DAV:}href")
msg_el = resp_el.find(".//{http://owncloud.org/ns}message")
ts_el = resp_el.find(".//{http://owncloud.org/ns}creationDateTime")
if href is not None and msg_el is not None and msg_el.text and ts_el is not None:
cid = href.text.rstrip("/").split("/")[-1]
batch.append((cid, msg_el.text, ts_el.text or ""))
if not batch:
break
out.extend(batch)
if len(batch) < 100:
break
offset += 100
return out


def seed_comments(s: niquests.Session, url: str, user: str) -> None:
"""Create a dedicated file and add many comments to it."""
dav = f"{url}/remote.php/dav/files/{user}"
Expand All @@ -196,13 +230,25 @@ def seed_comments(s: niquests.Session, url: str, user: str) -> None:
return
file_id = fileid_el.text

expected = {f"Pagination test comment {i:03d}" for i in range(1, COUNT + 1)}
existing = _fetch_all_comments(s, url, file_id)
expected_ts = [ts for _, msg, ts in existing if msg in expected]
if expected.issubset({m for _, m, _ in existing}) and len(set(expected_ts)) >= COUNT:
print(f" {COUNT} comments on file {file_id} (already present with distinct timestamps)")
return

for cid, _, _ in existing:
s.delete(f"{url}/remote.php/dav/comments/files/{file_id}/{cid}")

for i in range(1, COUNT + 1):
s.post(
f"{url}/remote.php/dav/comments/files/{file_id}",
json={"actorType": "users", "verb": "comment", "message": f"Pagination test comment {i:03d}"},
headers={"Content-Type": "application/json"},
)
print(f" {COUNT} comments on file {file_id}")
if i < COUNT:
time.sleep(1.05)
print(f" {COUNT} comments on file {file_id} (reset and re-created with 1.05s spacing for stable pagination)")


def main() -> None:
Expand Down
39 changes: 26 additions & 13 deletions tests/integration/test_pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,25 +51,38 @@ async def _ensure_pagination_conversations(nc_mcp: McpTestHelper) -> None:


async def _ensure_pagination_events(nc_mcp: McpTestHelper) -> None:
"""Create calendar events if they don't already exist."""
result = json.loads(
await nc_mcp.call(
"get_events",
calendar_id="personal",
start="2027-06-01T00:00:00Z",
end="2027-06-30T23:59:59Z",
limit=200,
"""Create calendar events if they don't already exist.

Idempotency is keyed on the event summary text, not UID -- create_event
generates random server-side UUIDs, so a UID-based check would think no
events exist and add a fresh batch on every run, accumulating duplicates.
"""
expected_summaries = {f"Pagination Test Event {i:03d}" for i in range(1, ITEM_COUNT + 1)}
existing_summaries: set[str] = set()
offset = 0
while True:
result = json.loads(
await nc_mcp.call(
"get_events",
calendar_id="personal",
start="2027-06-01T00:00:00Z",
end="2027-06-30T23:59:59Z",
limit=500,
offset=offset,
)
)
)
existing_uids = {e["uid"] for e in result["data"]}
existing_summaries.update(e.get("summary", "") for e in result["data"])
if not result["pagination"]["has_more"] or expected_summaries.issubset(existing_summaries):
break
offset += 500
for i in range(1, ITEM_COUNT + 1):
uid = f"{PREFIX}-event-{i:03d}"
if uid not in existing_uids:
summary = f"Pagination Test Event {i:03d}"
if summary not in existing_summaries:
hour = i % 24
await nc_mcp.call(
"create_event",
calendar_id="personal",
summary=f"Pagination Test Event {i:03d}",
summary=summary,
start=f"2027-06-01T{hour:02d}:00:00Z",
end=f"2027-06-01T{hour:02d}:30:00Z",
)
Expand Down
46 changes: 34 additions & 12 deletions tests/integration/test_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,18 +100,40 @@ async def test_search_with_limit(self, nc_mcp: McpTestHelper) -> None:

@pytest.mark.asyncio
async def test_search_pagination(self, nc_mcp: McpTestHelper) -> None:
result1 = await nc_mcp.call("unified_search", provider="files", term="pagtest", limit=2)
data1 = json.loads(result1)
if not data1["has_more"]:
pytest.skip("Not enough pagtest files for pagination test")
cursor = str(data1["cursor"])

result2 = await nc_mcp.call("unified_search", provider="files", term="pagtest", limit=2, cursor=cursor)
data2 = json.loads(result2)
assert data2["entries"]
titles1 = {e["title"] for e in data1["entries"]}
titles2 = {e["title"] for e in data2["entries"]}
assert not titles1.intersection(titles2), "Pages should not overlap"
# NC's FilesSearchProvider issues its DB query without an ORDER BY, so on Postgres
# the offset-based pages can overlap or skip rows (SQLite happens to be stable by
# insertion order). Full enumeration of the seeded files is therefore not guaranteed,
# so we verify the pagination *contract* instead: limit is respected, the cursor
# advances, follow-up pages keep returning results, and paging surfaces more distinct
# results than a single page holds.
limit = 5
first = json.loads(await nc_mcp.call("unified_search", provider="files", term="pagtest", limit=limit))
assert len(first["entries"]) <= limit
if not first["has_more"]:
pytest.skip("Not enough pagtest files seeded for a multi-page search")
assert first["cursor"], "has_more is true, so the response must carry a cursor"

seen = {e["title"] for e in first["entries"]}
cursor = str(first["cursor"])
pages = 1
for _ in range(40):
data = json.loads(
await nc_mcp.call("unified_search", provider="files", term="pagtest", limit=limit, cursor=cursor)
)
entries = data["entries"]
assert len(entries) <= limit
if entries:
seen.update(e["title"] for e in entries)
pages += 1
if not data["has_more"]:
break
assert data["cursor"], "has_more is true, so the response must carry a cursor"
next_cursor = str(data["cursor"])
assert next_cursor != cursor, "Cursor must advance across pages"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
cursor = next_cursor

assert pages >= 2, "Expected pagination to yield more than one page"
assert len(seen) > limit, f"Pagination should surface more than one page of distinct results; got {len(seen)}"

@pytest.mark.asyncio
async def test_search_nonexistent_provider_raises(self, nc_mcp: McpTestHelper) -> None:
Expand Down
6 changes: 5 additions & 1 deletion tests/integration/test_user_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ async def test_get_other_user_status(self, nc_mcp: McpTestHelper) -> None:
async def test_get_own_status_when_never_set(self, nc_mcp: McpTestHelper) -> None:
"""A fresh user who never set a status gets a 404 from the API.
The tool should handle this gracefully and return a default offline status."""
# Cleanup uses nc_mcp.client (admin, captured at fixture setup) directly rather than
# the delete_user tool, because create_server(fresh_config) below swaps the global
# client singleton to the fresh non-admin user -- so the tool's get_client() would
# return the fresh client (closed and unauthorized) and silently leak the test user.
try:
await nc_mcp.call("create_user", user_id="mcp-fresh-status", password="t3St*Pw!xQ9#mK2z")
fresh_config = Config(
Expand All @@ -64,7 +68,7 @@ async def test_get_own_status_when_never_set(self, nc_mcp: McpTestHelper) -> Non
await fresh_helper.client.close()
finally:
with contextlib.suppress(Exception):
await nc_mcp.call("delete_user", user_id="mcp-fresh-status")
await nc_mcp.client.ocs_delete("cloud/users/mcp-fresh-status")

@pytest.mark.asyncio
async def test_get_nonexistent_user_status(self, nc_mcp: McpTestHelper) -> None:
Expand Down