Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion src/qodev_apollo_api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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)

Expand Down
39 changes: 39 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading