Skip to content

Commit 8f77315

Browse files
committed
Stabilize pagination, search, and user-status integration tests on Postgres
1 parent 1bd923e commit 8f77315

4 files changed

Lines changed: 95 additions & 20 deletions

File tree

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: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,25 +51,30 @@ 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."""
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+
"""
5560
result = json.loads(
5661
await nc_mcp.call(
5762
"get_events",
5863
calendar_id="personal",
5964
start="2027-06-01T00:00:00Z",
6065
end="2027-06-30T23:59:59Z",
61-
limit=200,
66+
limit=500,
6267
)
6368
)
64-
existing_uids = {e["uid"] for e in result["data"]}
69+
existing_summaries = {e.get("summary", "") for e in result["data"]}
6570
for i in range(1, ITEM_COUNT + 1):
66-
uid = f"{PREFIX}-event-{i:03d}"
67-
if uid not in existing_uids:
71+
summary = f"Pagination Test Event {i:03d}"
72+
if summary not in existing_summaries:
6873
hour = i % 24
6974
await nc_mcp.call(
7075
"create_event",
7176
calendar_id="personal",
72-
summary=f"Pagination Test Event {i:03d}",
77+
summary=summary,
7378
start=f"2027-06-01T{hour:02d}:00:00Z",
7479
end=f"2027-06-01T{hour:02d}:30:00Z",
7580
)

tests/integration/test_search.py

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -100,18 +100,38 @@ 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+
115+
seen = {e["title"] for e in first["entries"]}
116+
cursor = str(first["cursor"])
117+
pages = 1
118+
for _ in range(40):
119+
data = json.loads(
120+
await nc_mcp.call("unified_search", provider="files", term="pagtest", limit=limit, cursor=cursor)
121+
)
122+
entries = data["entries"]
123+
assert len(entries) <= limit
124+
if entries:
125+
seen.update(e["title"] for e in entries)
126+
pages += 1
127+
if not data["has_more"]:
128+
break
129+
next_cursor = str(data["cursor"])
130+
assert next_cursor != cursor, "Cursor must advance across pages"
131+
cursor = next_cursor
132+
133+
assert pages >= 2, "Expected pagination to yield more than one page"
134+
assert len(seen) > limit, f"Pagination should surface more than one page of distinct results; got {len(seen)}"
115135

116136
@pytest.mark.asyncio
117137
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)