diff --git a/src/qodev_apollo_api/client.py b/src/qodev_apollo_api/client.py index e862cde..6ee6a70 100644 --- a/src/qodev_apollo_api/client.py +++ b/src/qodev_apollo_api/client.py @@ -46,6 +46,20 @@ logger = logging.getLogger(__name__) +# Filters accepted by POST /accounts/search (besides page/per_page, which are +# passed explicitly). Apollo *silently drops* unrecognised keys and returns an +# unfiltered default page, so we validate against this allowlist and raise +# instead of returning wrong data. See ``ApolloClient.search_accounts``. +ACCOUNT_SEARCH_FILTERS = frozenset( + { + "q_organization_name", + "account_stage_ids", + "account_label_ids", + "sort_by_field", + "sort_ascending", + } +) + class ApolloClient: """Async Apollo.io API client with context manager support.""" @@ -350,11 +364,32 @@ async def search_accounts( Args: page: Page number (default 1) limit: Results per page (default 100, max 100) - **filters: Additional filters (q_organization_name, account_stage_ids, etc.) + **filters: Search filters. Must be keys Apollo actually supports + (see ``ACCOUNT_SEARCH_FILTERS``): ``q_organization_name``, + ``account_stage_ids``, ``account_label_ids``, ``sort_by_field``, + ``sort_ascending``. Returns: Paginated response with Account items + + Raises: + ValueError: If an unrecognised filter key is passed. Apollo silently + ignores unknown keys and returns an unfiltered default page (which + looks like a real match), so we fail loudly instead of returning the + wrong accounts. Note ``query=`` is **not** a valid filter — use + ``q_organization_name=`` to search by name. """ + unknown = set(filters) - ACCOUNT_SEARCH_FILTERS + if unknown: + raise ValueError( + "Unknown account search filter(s): " + + ", ".join(sorted(unknown)) + + ". Apollo silently ignores unrecognised keys and returns an unfiltered " + "default page. Supported filters: " + + ", ".join(sorted(ACCOUNT_SEARCH_FILTERS)) + + " (to search by name use q_organization_name=)." + ) + data = {"page": page, "per_page": min(limit, 100), **filters} result = await self._post("/accounts/search", data) diff --git a/tests/test_client.py b/tests/test_client.py index 6406a78..1740718 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -251,6 +251,45 @@ async def test_search_accounts(client: ApolloClient): assert result.items[0].name == "Acme Corp" +async def test_search_accounts_rejects_unknown_filter(client: ApolloClient): + """An unrecognised filter raises instead of returning a wrong default page. + + Regression: Apollo silently drops unknown keys (e.g. ``query=``) and returns + an unfiltered default list that looks like a real match. + """ + with pytest.raises(ValueError, match="Unknown account search filter"): + await client.search_accounts(query="Red and Bundle") + + # The bad request must never reach Apollo. + client._client.request.assert_not_called() + + +async def test_search_accounts_error_names_the_bad_key_and_suggests_fix(client: ApolloClient): + """The message names the offending key and points to q_organization_name.""" + with pytest.raises(ValueError, match=r"query.*q_organization_name"): + await client.search_accounts(query="x") + + +async def test_search_accounts_allows_documented_filters(client: ApolloClient): + """All allowlisted keys pass validation and reach the request body.""" + client._client.request.return_value = _make_response( + {"accounts": [], "pagination": {"total_entries": 0}} + ) + + await client.search_accounts( + q_organization_name="Acme", + account_stage_ids=["s1"], + account_label_ids=["l1"], + sort_by_field="account_created_at", + sort_ascending=True, + ) + + body = client._client.request.call_args[1]["json"] + assert body["q_organization_name"] == "Acme" + assert body["account_stage_ids"] == ["s1"] + assert body["sort_by_field"] == "account_created_at" + + async def test_search_deals(client: ApolloClient): """Test POST /opportunities/search returns PaginatedResponse[Deal].""" client._client.request.return_value = _make_response(