Skip to content

Commit dd9b83b

Browse files
authored
Nextcloud 34 prep (CI/README) + Postgres integration-test stability (#54)
1 parent 735c4c5 commit dd9b83b

6 files changed

Lines changed: 114 additions & 28 deletions

File tree

.github/workflows/tests-integration.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ jobs:
1313
strategy:
1414
fail-fast: false
1515
matrix:
16+
# NC34 GA 2026-06-09: replace "32" with "34" here and in the two jobs below once nextcloud:34 publishes.
1617
nextcloud-version: ["32", "33"]
1718
services:
1819
nextcloud:

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,7 @@ export NEXTCLOUD_PASSWORD=admin
483483
pytest tests/integration/ -v
484484
```
485485

486-
CI automatically runs integration tests against Nextcloud 32 and 33 Docker containers.
486+
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.
487487

488488
## About This Project
489489

scripts/seed_pagination_data.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"""
1313

1414
import sys
15+
import time
1516
import xml.etree.ElementTree as ET
1617

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

174175

176+
def _fetch_all_comments(s: niquests.Session, url: str, file_id: str) -> list[tuple[str, str, str]]:
177+
"""Return all comments on file_id as list of (id, message, creation_ts)."""
178+
out: list[tuple[str, str, str]] = []
179+
offset = 0
180+
while True:
181+
report = s.request(
182+
"REPORT",
183+
f"{url}/remote.php/dav/comments/files/{file_id}",
184+
data=(
185+
'<?xml version="1.0" encoding="utf-8"?>'
186+
'<oc:filter-comments xmlns:oc="http://owncloud.org/ns">'
187+
f"<oc:limit>100</oc:limit><oc:offset>{offset}</oc:offset>"
188+
"</oc:filter-comments>"
189+
),
190+
headers={"Content-Type": "application/xml"},
191+
)
192+
batch: list[tuple[str, str, str]] = []
193+
for resp_el in ET.fromstring(report.text).findall("{DAV:}response"):
194+
href = resp_el.find("{DAV:}href")
195+
msg_el = resp_el.find(".//{http://owncloud.org/ns}message")
196+
ts_el = resp_el.find(".//{http://owncloud.org/ns}creationDateTime")
197+
if href is not None and msg_el is not None and msg_el.text and ts_el is not None:
198+
cid = href.text.rstrip("/").split("/")[-1]
199+
batch.append((cid, msg_el.text, ts_el.text or ""))
200+
if not batch:
201+
break
202+
out.extend(batch)
203+
if len(batch) < 100:
204+
break
205+
offset += 100
206+
return out
207+
208+
175209
def seed_comments(s: niquests.Session, url: str, user: str) -> None:
176210
"""Create a dedicated file and add many comments to it."""
177211
dav = f"{url}/remote.php/dav/files/{user}"
@@ -196,13 +230,25 @@ def seed_comments(s: niquests.Session, url: str, user: str) -> None:
196230
return
197231
file_id = fileid_el.text
198232

233+
expected = {f"Pagination test comment {i:03d}" for i in range(1, COUNT + 1)}
234+
existing = _fetch_all_comments(s, url, file_id)
235+
expected_ts = [ts for _, msg, ts in existing if msg in expected]
236+
if expected.issubset({m for _, m, _ in existing}) and len(set(expected_ts)) >= COUNT:
237+
print(f" {COUNT} comments on file {file_id} (already present with distinct timestamps)")
238+
return
239+
240+
for cid, _, _ in existing:
241+
s.delete(f"{url}/remote.php/dav/comments/files/{file_id}/{cid}")
242+
199243
for i in range(1, COUNT + 1):
200244
s.post(
201245
f"{url}/remote.php/dav/comments/files/{file_id}",
202246
json={"actorType": "users", "verb": "comment", "message": f"Pagination test comment {i:03d}"},
203247
headers={"Content-Type": "application/json"},
204248
)
205-
print(f" {COUNT} comments on file {file_id}")
249+
if i < COUNT:
250+
time.sleep(1.05)
251+
print(f" {COUNT} comments on file {file_id} (reset and re-created with 1.05s spacing for stable pagination)")
206252

207253

208254
def main() -> None:

tests/integration/test_pagination.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -51,25 +51,38 @@ async def _ensure_pagination_conversations(nc_mcp: McpTestHelper) -> None:
5151

5252

5353
async def _ensure_pagination_events(nc_mcp: McpTestHelper) -> None:
54-
"""Create calendar events if they don't already exist."""
55-
result = json.loads(
56-
await nc_mcp.call(
57-
"get_events",
58-
calendar_id="personal",
59-
start="2027-06-01T00:00:00Z",
60-
end="2027-06-30T23:59:59Z",
61-
limit=200,
54+
"""Create calendar events if they don't already exist.
55+
56+
Idempotency is keyed on the event summary text, not UID -- create_event
57+
generates random server-side UUIDs, so a UID-based check would think no
58+
events exist and add a fresh batch on every run, accumulating duplicates.
59+
"""
60+
expected_summaries = {f"Pagination Test Event {i:03d}" for i in range(1, ITEM_COUNT + 1)}
61+
existing_summaries: set[str] = set()
62+
offset = 0
63+
while True:
64+
result = json.loads(
65+
await nc_mcp.call(
66+
"get_events",
67+
calendar_id="personal",
68+
start="2027-06-01T00:00:00Z",
69+
end="2027-06-30T23:59:59Z",
70+
limit=500,
71+
offset=offset,
72+
)
6273
)
63-
)
64-
existing_uids = {e["uid"] for e in result["data"]}
74+
existing_summaries.update(e.get("summary", "") for e in result["data"])
75+
if not result["pagination"]["has_more"] or expected_summaries.issubset(existing_summaries):
76+
break
77+
offset += 500
6578
for i in range(1, ITEM_COUNT + 1):
66-
uid = f"{PREFIX}-event-{i:03d}"
67-
if uid not in existing_uids:
79+
summary = f"Pagination Test Event {i:03d}"
80+
if summary not in existing_summaries:
6881
hour = i % 24
6982
await nc_mcp.call(
7083
"create_event",
7184
calendar_id="personal",
72-
summary=f"Pagination Test Event {i:03d}",
85+
summary=summary,
7386
start=f"2027-06-01T{hour:02d}:00:00Z",
7487
end=f"2027-06-01T{hour:02d}:30:00Z",
7588
)

tests/integration/test_search.py

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -100,18 +100,40 @@ async def test_search_with_limit(self, nc_mcp: McpTestHelper) -> None:
100100

101101
@pytest.mark.asyncio
102102
async def test_search_pagination(self, nc_mcp: McpTestHelper) -> None:
103-
result1 = await nc_mcp.call("unified_search", provider="files", term="pagtest", limit=2)
104-
data1 = json.loads(result1)
105-
if not data1["has_more"]:
106-
pytest.skip("Not enough pagtest files for pagination test")
107-
cursor = str(data1["cursor"])
108-
109-
result2 = await nc_mcp.call("unified_search", provider="files", term="pagtest", limit=2, cursor=cursor)
110-
data2 = json.loads(result2)
111-
assert data2["entries"]
112-
titles1 = {e["title"] for e in data1["entries"]}
113-
titles2 = {e["title"] for e in data2["entries"]}
114-
assert not titles1.intersection(titles2), "Pages should not overlap"
103+
# NC's FilesSearchProvider issues its DB query without an ORDER BY, so on Postgres
104+
# the offset-based pages can overlap or skip rows (SQLite happens to be stable by
105+
# insertion order). Full enumeration of the seeded files is therefore not guaranteed,
106+
# so we verify the pagination *contract* instead: limit is respected, the cursor
107+
# advances, follow-up pages keep returning results, and paging surfaces more distinct
108+
# results than a single page holds.
109+
limit = 5
110+
first = json.loads(await nc_mcp.call("unified_search", provider="files", term="pagtest", limit=limit))
111+
assert len(first["entries"]) <= limit
112+
if not first["has_more"]:
113+
pytest.skip("Not enough pagtest files seeded for a multi-page search")
114+
assert first["cursor"], "has_more is true, so the response must carry a cursor"
115+
116+
seen = {e["title"] for e in first["entries"]}
117+
cursor = str(first["cursor"])
118+
pages = 1
119+
for _ in range(40):
120+
data = json.loads(
121+
await nc_mcp.call("unified_search", provider="files", term="pagtest", limit=limit, cursor=cursor)
122+
)
123+
entries = data["entries"]
124+
assert len(entries) <= limit
125+
if entries:
126+
seen.update(e["title"] for e in entries)
127+
pages += 1
128+
if not data["has_more"]:
129+
break
130+
assert data["cursor"], "has_more is true, so the response must carry a cursor"
131+
next_cursor = str(data["cursor"])
132+
assert next_cursor != cursor, "Cursor must advance across pages"
133+
cursor = next_cursor
134+
135+
assert pages >= 2, "Expected pagination to yield more than one page"
136+
assert len(seen) > limit, f"Pagination should surface more than one page of distinct results; got {len(seen)}"
115137

116138
@pytest.mark.asyncio
117139
async def test_search_nonexistent_provider_raises(self, nc_mcp: McpTestHelper) -> None:

tests/integration/test_user_status.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ async def test_get_other_user_status(self, nc_mcp: McpTestHelper) -> None:
4444
async def test_get_own_status_when_never_set(self, nc_mcp: McpTestHelper) -> None:
4545
"""A fresh user who never set a status gets a 404 from the API.
4646
The tool should handle this gracefully and return a default offline status."""
47+
# Cleanup uses nc_mcp.client (admin, captured at fixture setup) directly rather than
48+
# the delete_user tool, because create_server(fresh_config) below swaps the global
49+
# client singleton to the fresh non-admin user -- so the tool's get_client() would
50+
# return the fresh client (closed and unauthorized) and silently leak the test user.
4751
try:
4852
await nc_mcp.call("create_user", user_id="mcp-fresh-status", password="t3St*Pw!xQ9#mK2z")
4953
fresh_config = Config(
@@ -64,7 +68,7 @@ async def test_get_own_status_when_never_set(self, nc_mcp: McpTestHelper) -> Non
6468
await fresh_helper.client.close()
6569
finally:
6670
with contextlib.suppress(Exception):
67-
await nc_mcp.call("delete_user", user_id="mcp-fresh-status")
71+
await nc_mcp.client.ocs_delete("cloud/users/mcp-fresh-status")
6872

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

0 commit comments

Comments
 (0)