get_wikijs_namespace() returns '/users/{user}' with a leading slash while
Wiki.js search results carry paths without one, so the prefix filter in
search_pages rejected every result - wiki search returned empty for every
tenant. Found by cross-repo integration verification; the librarian now
gets real search results. Compare slash-normalized on both sides.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
148 lines
5.5 KiB
Python
148 lines
5.5 KiB
Python
"""
|
|
Unit tests for WikiJSClient page listing and pagination (offline).
|
|
|
|
Wiki.js 2.x `pages.list` supports only a `limit` argument (no offset), and
|
|
the limit is applied BEFORE Wiki.js's own visibility filter — fewer pages
|
|
than requested does NOT mean the listing is complete. Exhaustive listing
|
|
therefore grows the limit until the returned count stops increasing.
|
|
These tests mock the GraphQL layer.
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock
|
|
|
|
from src.clients.wikijs_client import WikiJSClient
|
|
|
|
|
|
def make_pages(count_other: int, count_user: int):
|
|
"""Build a fake TITLE-ordered page listing: 'other/' pages sort before 'users/jp/' pages."""
|
|
pages = [
|
|
{"id": i, "path": f"other/p{i:03d}", "title": f"A{i:03d}", "tags": []}
|
|
for i in range(count_other)
|
|
]
|
|
pages += [
|
|
{"id": count_other + i, "path": f"users/jp/p{i:03d}", "title": f"Z{i:03d}", "tags": ["projects"]}
|
|
for i in range(count_user)
|
|
]
|
|
return pages
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
"""WikiJSClient with a mocked GraphQL layer serving 138 pages (100 other + 38 user)."""
|
|
client = WikiJSClient("http://wiki.test", "")
|
|
client._all_pages = make_pages(100, 38)
|
|
client._requested_limits = []
|
|
|
|
async def fake_execute(query, variables=None):
|
|
limit = variables["limit"]
|
|
client._requested_limits.append(limit)
|
|
return {"pages": {"list": client._all_pages[:limit]}}
|
|
|
|
client._execute_query = fake_execute
|
|
return client
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestListAllPagesPagination:
|
|
"""list_all_pages must exhaust the listing via a limit-growth loop."""
|
|
|
|
async def test_grows_limit_until_count_stabilizes(self, client):
|
|
pages = await client.list_all_pages(batch_size=50)
|
|
|
|
# 50 -> 50, 100 -> 100, 200 -> 138, 400 -> 138 (stable) done
|
|
assert client._requested_limits == [50, 100, 200, 400]
|
|
assert len(pages) == 138
|
|
|
|
async def test_confirms_completeness_with_second_fetch(self, client):
|
|
"""A single not-full batch is NOT trusted (limit precedes Wiki.js's
|
|
visibility filter); a confirming fetch at a doubled limit runs."""
|
|
pages = await client.list_all_pages(batch_size=500)
|
|
|
|
assert client._requested_limits == [500, 1000]
|
|
assert len(pages) == 138
|
|
|
|
async def test_pre_filter_limit_does_not_truncate(self, client):
|
|
"""Regression (observed live): Wiki.js applies `limit` before its
|
|
visibility filter, so limit=100 returned 43 pages while 140 existed.
|
|
The old `len < limit` stop condition silently dropped pages."""
|
|
all_pages = client._all_pages
|
|
|
|
async def fake_execute(query, variables=None):
|
|
limit = variables["limit"]
|
|
client._requested_limits.append(limit)
|
|
# Only ~half the pages within the limit window are visible
|
|
return {"pages": {"list": all_pages[: limit // 2]}}
|
|
|
|
client._execute_query = fake_execute
|
|
|
|
pages = await client.list_all_pages(batch_size=100)
|
|
|
|
# 100 -> 50 (< limit, but NOT complete), 200 -> 100, 400 -> 138,
|
|
# 800 -> 138 (stable) done
|
|
assert len(pages) == 138
|
|
|
|
async def test_path_prefix_filter_after_exhaustion(self, client):
|
|
"""All 38 user pages are returned even though they sort last (beyond batch_size)."""
|
|
pages = await client.list_all_pages(path_prefix="users/jp", batch_size=50)
|
|
|
|
assert len(pages) == 38
|
|
assert all(p["path"].startswith("users/jp") for p in pages)
|
|
|
|
async def test_empty_wiki(self, client):
|
|
client._all_pages = []
|
|
|
|
pages = await client.list_all_pages(batch_size=100)
|
|
|
|
assert pages == []
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestListPagesLimitAfterFilter:
|
|
"""list_pages must apply `limit` AFTER client-side filters, not before."""
|
|
|
|
async def test_prefix_filter_with_small_limit(self, client):
|
|
"""Old defect: API limit=5 returned 5 'other/' pages, filter dropped all -> 0 results."""
|
|
pages = await client.list_pages(path_prefix="users/jp", limit=5)
|
|
|
|
assert len(pages) == 5
|
|
assert all(p["path"].startswith("users/jp") for p in pages)
|
|
|
|
async def test_tag_filter_with_small_limit(self, client):
|
|
pages = await client.list_pages(tags=["projects"], limit=10)
|
|
|
|
assert len(pages) == 10
|
|
assert all("projects" in p["tags"] for p in pages)
|
|
|
|
async def test_unfiltered_passes_limit_to_api(self, client):
|
|
pages = await client.list_pages(limit=7)
|
|
|
|
assert client._requested_limits == [7]
|
|
assert len(pages) == 7
|
|
|
|
async def test_tags_normalized_to_list(self, client):
|
|
client._all_pages = [{"id": 1, "path": "home", "title": "Home", "tags": None}]
|
|
|
|
pages = await client.list_pages(limit=10)
|
|
|
|
assert pages[0]["tags"] == []
|
|
|
|
|
|
class TestSearchPagesPrefixNormalization:
|
|
"""search_pages must match Wiki.js paths (no leading slash) against
|
|
get_wikijs_namespace prefixes (leading slash)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slashed_prefix_matches_unslashed_paths(self):
|
|
client = WikiJSClient("http://wiki.test", "k")
|
|
client._execute_query = AsyncMock(return_value={
|
|
"pages": {"search": {"results": [
|
|
{"id": "399", "path": "users/llm_tester/docker-guide",
|
|
"title": "Docker Guide", "description": ""},
|
|
{"id": "1", "path": "users/jpmschweitzer/other",
|
|
"title": "Other", "description": ""},
|
|
]}}
|
|
})
|
|
results = await client.search_pages("docker", path_prefix="/users/llm_tester")
|
|
assert [r["id"] for r in results] == ["399"]
|