"""Tests for the Prowler documentation search tool. Mintlify moved the docs search to a new endpoint that answers with page sections, so a result is a part of a page and has to read as one. """ import json from fastmcp import Client SEARCH = "/api/search/prowler" def search_match( path: str = "getting-started/installation", *, header: str = "Requirements", breadcrumbs: tuple[str, ...] = ("Get Started", "Installation"), anchor: str | None = "requirements", score: float = 4.9, ): """One match as Mintlify answers with it: a section of a page, not the page.""" return { "page": path, "header": header, "content": "Prowler runs on Python 3.9 or later.", "metadata": { "title": header, "breadcrumbs": list(breadcrumbs), "icon": "", "hash": anchor, "openapi": "", }, "score": score, } def stub_search_hit(docs_router, *matches): """Serve the search endpoint, with one default match when none are given.""" if not matches: matches = (search_match(),) return docs_router.add("POST", SEARCH, json={"results": list(matches)}) async def test_search_returns_the_matching_sections(mcp_root_server, docs_router): """Every field of a result, since the shape of one changed with the endpoint.""" stub_search_hit(docs_router) async with Client(mcp_root_server) as client: result = await client.call_tool("prowler_docs_search", {"term": "install"}) match = result.data[0] assert match["path"] == "getting-started/installation" # The page's title, so a result reads as more than the heading it matched. assert match["title"] == "Installation" assert match["section"] == "Requirements" assert match["breadcrumbs"] == ["Get Started", "Installation"] assert match["excerpt"] == "Prowler runs on Python 3.9 or later." assert match["score"] == 4.9 # Anchored: a match is a section, and the page it is on can be a long one. assert match["url"] == ( "https://docs.prowler.com/getting-started/installation#requirements" ) async def test_the_search_query_is_sent_as_the_api_expects_it( mcp_root_server, docs_router ): """The endpoint takes a POST body, not the payload the old one took.""" stub_search_hit(docs_router) async with Client(mcp_root_server) as client: await client.call_tool("prowler_docs_search", {"term": "install"}) request = docs_router.request_for("POST", SEARCH) assert json.loads(request.content) == {"query": "install", "filters": {}} async def test_a_section_with_no_anchor_links_to_the_page(mcp_root_server, docs_router): """The API sends "" for a page's first section and null for pages without anchors.""" stub_search_hit( docs_router, search_match(anchor=""), search_match(path="getting-started/requirements", anchor=None), ) async with Client(mcp_root_server) as client: result = await client.call_tool("prowler_docs_search", {"term": "install"}) assert result.data[0]["url"] == ( "https://docs.prowler.com/getting-started/installation" ) assert result.data[1]["url"] == ( "https://docs.prowler.com/getting-started/requirements" ) async def test_page_size_caps_a_response_the_api_did_not_size( mcp_root_server, docs_router ): """The endpoint takes no size argument, so the cap has to be applied here.""" stub_search_hit(docs_router, *(search_match() for _ in range(6))) async with Client(mcp_root_server) as client: result = await client.call_tool( "prowler_docs_search", {"term": "install", "page_size": 2} ) assert len(result.data) == 2