qodev-apollo-api 0.3.2__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/CHANGELOG.md +29 -0
  2. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/CLAUDE.md +20 -0
  3. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/PKG-INFO +1 -1
  4. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/pyproject.toml +1 -1
  5. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/src/qodev_apollo_api/client.py +279 -70
  6. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/tests/test_client.py +224 -105
  7. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/uv.lock +1 -1
  8. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/.github/workflows/ci.yml +0 -0
  9. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/.github/workflows/publish.yml +0 -0
  10. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/.gitignore +0 -0
  11. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/.pre-commit-config.yaml +0 -0
  12. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/LICENSE +0 -0
  13. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/Makefile +0 -0
  14. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/README.md +0 -0
  15. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/src/qodev_apollo_api/__init__.py +0 -0
  16. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/src/qodev_apollo_api/exceptions.py +0 -0
  17. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/src/qodev_apollo_api/models.py +0 -0
  18. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/src/qodev_apollo_api/py.typed +0 -0
  19. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/src/qodev_apollo_api/utils.py +0 -0
  20. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/tests/__init__.py +0 -0
  21. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/tests/integration/__init__.py +0 -0
  22. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/tests/integration/validate_all_models.py +0 -0
  23. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/tests/integration/validate_email_task_flow.py +0 -0
  24. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/tests/test_exceptions.py +0 -0
  25. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/tests/test_models.py +0 -0
  26. {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/tests/test_utils.py +0 -0
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-07-10
11
+
12
+ ### Fixed
13
+
14
+ - **People search** now uses `/mixed_people/api_search`; the old `/mixed_people/search` is deprecated for API callers (422). Note the new endpoint returns teaser data only (no full name/email/linkedin_url without a credit-consuming reveal). The `find_contact_by_linkedin_url` auto-creation step is retired accordingly (it warns; `create_if_missing` is a documented no-op).
15
+ - **Deal name search** uses `q_opportunity_name`, not `q_keywords` (which Apollo silently ignores on `/opportunities/search`). `DEAL_SEARCH_FILTERS` allows `q_opportunity_name` and rejects `q_keywords`.
16
+ - `list_contact_tasks` now filters the tasks search by `contact_ids` (the `/contacts/{id}/tasks` route was removed by Apollo — 404).
17
+ - `list_account_jobs` resolves the account's `organization_id` and reads `/organizations/{org_id}/job_postings` (the `/accounts/{id}/job_postings` route was removed).
18
+
19
+ ### Added
20
+
21
+ - Search-filter validation across **all** `search_*` methods: unknown filter keys raise (documented endpoints: contacts/deals/people/accounts) or warn (undocumented activity endpoints), preventing Apollo's silent-drop → unfiltered-default-page footgun.
22
+ - `search_*` docstrings now document each filter's empirically-verified accepted format (seniority enums, `"min,max"` employee ranges, location formats, dict ranges, email-status values, canonical `linkedin_url`).
23
+
24
+ ### Removed
25
+
26
+ - **Breaking:** `list_contact_calls` and `list_account_news` — Apollo removed the underlying routes (`/contacts/{id}/calls`, `/accounts/{id}/news`) with no working replacement.
27
+
28
+ ## [0.4.0] - 2026-07-09
29
+
30
+ ### Added
31
+
32
+ - `create_deal(name, **fields)` creates a deal/opportunity via `POST /opportunities`. `name` is the only required field; optional `owner_id`, `account_id`, `amount`, `opportunity_stage_id`, `closed_date` are forwarded as-is. Requires a **master** API key (non-master keys return 403). Live-verified against the real API.
33
+
34
+ ### Fixed
35
+
36
+ - `update_opportunity_roles(...)` now sends Apollo's expected **nested** role shape — `{"contact_id": …, "is_primary": …, "role": [{"opportunity_contact_role_type_id": …, "is_primary": …}]}` — instead of the flat `opportunity_contact_role_type_id` on the entry. The flat shape made Apollo 422 with `undefined method 'map' for nil`, so setting a contact's role on a deal failed every time. The public `RoleAssignment` interface is unchanged (callers still pass flat entries).
37
+ - `search_accounts(**filters)` now validates filter keys against an allowlist (`q_organization_name`, `account_stage_ids`, `account_label_ids`, `sort_by_field`, `sort_ascending`) and raises `ValueError` on unknown keys. Apollo silently ignores unrecognised keys and returns an unfiltered default page that looks like a real match (e.g. `query="…"` returned ~28k accounts, "Google" first) — fail-loud now prevents wrong-account attribution.
38
+
10
39
  ## [0.3.2] - 2026-07-08
11
40
 
12
41
  ### Fixed
@@ -110,6 +110,26 @@ Apollo stores notes in ProseMirror JSON format. This library automatically conve
110
110
  - Custom URLs can be added/removed
111
111
  - Always normalize and verify matches
112
112
 
113
+ **`accounts/search` silently drops unknown filter keys:**
114
+ - Passing an unrecognised filter (e.g. `query=` instead of `q_organization_name=`) does **not** error — Apollo ignores it and returns an unfiltered default page (~28k accounts, "Google" first) that looks like a real match.
115
+ - This once caused a wrong company to be attached to a deal + a duplicate account.
116
+ - `search_accounts` guards against it: it validates `**filters` against `ACCOUNT_SEARCH_FILTERS` (`q_organization_name`, `account_stage_ids`, `account_label_ids`, `sort_by_field`, `sort_ascending`) and raises `ValueError` on unknown keys. Same silent-drop risk applies to the other `search_*` methods — pass only documented filters.
117
+
118
+ **Search-filter formats (empirically verified — a wrong format is silently ignored or matches nothing, never an error):**
119
+ - **Deal name search is `q_opportunity_name`, NOT `q_keywords`.** Apollo silently ignores `q_keywords` on `/opportunities/search` (a nonsense keyword returns *every* deal). `q_opportunity_name` is in `DEAL_SEARCH_FILTERS`; `q_keywords` is not (passing it raises).
120
+ - **`organization_num_employees_ranges`** (people): `"min,max"` **comma** strings, e.g. `["1,10"]`, `["1000,5000"]`. A dash (`"1-10"`) is silently ignored (returns baseline).
121
+ - **`person_seniorities`** (people): lowercase enums — `owner, founder, c_suite, partner, vp, head, director, manager, senior, entry, intern`. Uppercase / free text match 0 rows.
122
+ - **`person_locations` / `organization_locations`**: country name, 2-letter code (`"US"` == `"United States"`), or `"City, State, Country"`.
123
+ - **`revenue_range` / `organization_num_jobs_range`**: dict `{"min": int, "max": int}`. **`contact_email_status`**: `verified | unverified | likely to engage | unavailable`.
124
+ - **`contacts` `linkedin_url`**: must be Apollo's canonical form `http://www.linkedin.com/in/<slug>` (http, lowercased, url-encoded) or it matches nothing.
125
+ - Full per-filter formats live in each `search_*` docstring.
126
+
127
+ **`opportunities/update_roles` needs the role type NESTED under a `role` array:**
128
+ - Correct per-entry shape: `{"contact_id": …, "is_primary": …, "role": [{"opportunity_contact_role_type_id": …, "is_primary": …}]}`.
129
+ - Sending `opportunity_contact_role_type_id` flat on the entry (no `role` key) makes Apollo 422 with `undefined method 'map' for nil`.
130
+ - `update_opportunity_roles` reshapes the flat `RoleAssignment` entries into this wire format; callers still pass flat entries.
131
+ - The endpoint **replaces** the full role set and works with a normal (non-master) API key.
132
+
113
133
  ## Testing Strategy
114
134
 
115
135
  ### Unit Tests
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qodev-apollo-api
3
- Version: 0.3.2
3
+ Version: 0.5.0
4
4
  Summary: Async Python client for Apollo.io CRM API
5
5
  Project-URL: Homepage, https://github.com/qodevai/apollo-api
6
6
  Project-URL: Repository, https://github.com/qodevai/apollo-api
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qodev-apollo-api"
3
- version = "0.3.2"
3
+ version = "0.5.0"
4
4
  description = "Async Python client for Apollo.io CRM API"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -46,6 +46,116 @@ from .utils import markdown_to_prosemirror, normalize_linkedin_url, prosemirror_
46
46
 
47
47
  logger = logging.getLogger(__name__)
48
48
 
49
+ # ---------------------------------------------------------------------------
50
+ # Search-filter allowlists
51
+ #
52
+ # Apollo's /search endpoints *silently drop* unrecognised filter keys and return
53
+ # an unfiltered default page that looks like a real match (e.g. a typo'd
54
+ # ``query=`` on accounts returned ~28k rows, "Google" first). To stop that, each
55
+ # search method validates its ``**filters`` against the relevant allowlist below.
56
+ #
57
+ # Endpoints with a documented, stable flat-filter vocabulary are validated
58
+ # *strictly* (raise on unknown). The activity endpoints below have no published
59
+ # filter docs, so an over-tight allowlist would reject valid filters — those are
60
+ # validated *leniently* (log a warning, still send the request). See
61
+ # ``_validate_search_filters``.
62
+ # ---------------------------------------------------------------------------
63
+
64
+ # Strict (raise on unknown) — documented flat-filter endpoints.
65
+ ACCOUNT_SEARCH_FILTERS = frozenset(
66
+ {
67
+ "q_organization_name",
68
+ "account_stage_ids",
69
+ "account_label_ids",
70
+ "sort_by_field",
71
+ "sort_ascending",
72
+ }
73
+ )
74
+ CONTACT_SEARCH_FILTERS = frozenset(
75
+ {
76
+ "q_keywords",
77
+ "contact_stage_ids",
78
+ "contact_label_ids",
79
+ "linkedin_url",
80
+ "sort_by_field",
81
+ "sort_ascending",
82
+ }
83
+ )
84
+ DEAL_SEARCH_FILTERS = frozenset(
85
+ {
86
+ # Deal *name* search is q_opportunity_name — NOT q_keywords, which Apollo
87
+ # silently ignores for /opportunities/search (verified: a nonsense keyword
88
+ # still returned every deal).
89
+ "q_opportunity_name",
90
+ "opportunity_stage_ids",
91
+ "sort_by_field",
92
+ "sort_ascending",
93
+ }
94
+ )
95
+ # search_people passes *everything* (incl. page/per_page) through **filters, so
96
+ # those are part of the allowlist here (unlike the methods with explicit args).
97
+ PEOPLE_SEARCH_FILTERS = frozenset(
98
+ {
99
+ "q_keywords",
100
+ "person_titles",
101
+ "include_similar_titles",
102
+ "person_seniorities",
103
+ "person_locations",
104
+ "organization_locations",
105
+ "organization_ids",
106
+ "organization_num_employees_ranges",
107
+ "q_organization_domains_list",
108
+ "revenue_range",
109
+ "currently_using_all_of_technology_uids",
110
+ "currently_using_any_of_technology_uids",
111
+ "currently_not_using_any_of_technology_uids",
112
+ "q_organization_job_titles",
113
+ "organization_job_locations",
114
+ "organization_num_jobs_range",
115
+ "organization_job_posted_at_range",
116
+ "contact_email_status",
117
+ "page",
118
+ "per_page",
119
+ }
120
+ )
121
+
122
+ # Lenient (warn on unknown) — undocumented activity endpoints. Seeded from known
123
+ # usage; incompleteness only costs a log line, never a broken call.
124
+ NOTE_SEARCH_FILTERS = frozenset({"contact_ids", "account_ids", "opportunity_ids", "q_keywords"})
125
+ CALL_SEARCH_FILTERS = frozenset({"contact_ids", "account_ids", "user_ids", "q_keywords"})
126
+ TASK_SEARCH_FILTERS = frozenset({"contact_ids", "account_ids", "opportunity_ids", "q_keywords"})
127
+ EMAIL_SEARCH_FILTERS = frozenset({"contact_ids", "emailer_campaign_ids", "q_keywords"})
128
+ CONVERSATION_SEARCH_FILTERS = frozenset({"q_keywords"})
129
+ CALENDAR_EVENT_SEARCH_FILTERS = frozenset({"contact_ids", "user_ids", "q_keywords"})
130
+
131
+
132
+ def _validate_search_filters(
133
+ filters: dict, allowed: frozenset[str], resource: str, *, strict: bool
134
+ ) -> None:
135
+ """Guard against Apollo silently dropping unknown ``**filters`` keys.
136
+
137
+ Apollo ignores unrecognised keys on its /search endpoints and returns an
138
+ unfiltered default page that looks like a real match. When ``strict``, raise
139
+ ``ValueError`` on any unknown key (documented endpoints); otherwise log a
140
+ warning and let the request through (undocumented endpoints, where the full
141
+ valid set isn't published and a hard allowlist would reject valid filters).
142
+ """
143
+ unknown = set(filters) - allowed
144
+ if not unknown:
145
+ return
146
+ # Strict allowlists are authoritative ("Supported filters"); lenient ones are
147
+ # seeded from known usage and may be incomplete ("Known filters"), so the
148
+ # wording doesn't imply the warned-about key is definitely invalid.
149
+ label = "Supported filters" if strict else "Known filters"
150
+ msg = (
151
+ f"Unknown {resource} search filter(s): {', '.join(sorted(unknown))}. "
152
+ f"Apollo silently ignores unrecognised keys and returns an unfiltered "
153
+ f"default page. {label}: {', '.join(sorted(allowed))}."
154
+ )
155
+ if strict:
156
+ raise ValueError(msg)
157
+ logger.warning(msg)
158
+
49
159
 
50
160
  class ApolloClient:
51
161
  """Async Apollo.io API client with context manager support."""
@@ -183,11 +293,23 @@ class ApolloClient:
183
293
  Args:
184
294
  page: Page number (default 1)
185
295
  limit: Results per page (default 100, max 100)
186
- **filters: Additional filters (q_keywords, contact_stage_ids, linkedin_url, etc.)
296
+ **filters: Contact search filters (see ``CONTACT_SEARCH_FILTERS``):
297
+
298
+ - ``q_keywords`` (str): free-text over name / title / company / email.
299
+ - ``contact_stage_ids`` (list[str]): stage IDs (see ``get_contact_stages``).
300
+ - ``contact_label_ids`` (list[str]): label / list IDs.
301
+ - ``linkedin_url`` (str): must be Apollo's **canonical** form —
302
+ ``http://www.linkedin.com/in/<slug>`` (http, lowercased,
303
+ url-encoded). A near-miss silently matches nothing.
304
+ - ``sort_by_field`` (str): ``contact_last_activity_date`` |
305
+ ``contact_email_last_opened_at`` | ``contact_email_last_clicked_at`` |
306
+ ``contact_created_at`` | ``contact_updated_at``.
307
+ - ``sort_ascending`` (bool).
187
308
 
188
309
  Returns:
189
310
  Paginated response with Contact items
190
311
  """
312
+ _validate_search_filters(filters, CONTACT_SEARCH_FILTERS, "contact", strict=True)
191
313
  data = {"page": page, "per_page": min(limit, 100), **filters}
192
314
  result = await self._post("/contacts/search", data)
193
315
 
@@ -265,21 +387,27 @@ class ApolloClient:
265
387
  create_if_missing: bool = False,
266
388
  contact_stage_id: str | None = None,
267
389
  ) -> str | None:
268
- """Find contact using 3-tier fallback strategy.
390
+ """Find an existing contact by LinkedIn URL (2-tier lookup).
269
391
 
270
392
  Strategy:
271
- 1. Search by LinkedIn URL (exact match)
272
- 2. Fallback to name search (if unique match)
273
- 3. People database search for auto-creation (if enabled)
393
+ 1. Search existing contacts by LinkedIn URL (exact match).
394
+ 2. Fall back to a name search among existing contacts (unique match whose
395
+ normalized URL equals the target).
396
+
397
+ Auto-creation from Apollo's people database (the former Step 3) is no
398
+ longer possible: ``/mixed_people/api_search`` returns teaser data only
399
+ (no ``linkedin_url``, an obfuscated last name, no email), so a URL match
400
+ can never succeed and there isn't enough data to create a usable contact.
274
401
 
275
402
  Args:
276
403
  linkedin_url: LinkedIn profile URL
277
- person_name: Person's full name (for fallback search)
278
- create_if_missing: Auto-create from people database if not found
279
- contact_stage_id: Stage ID to assign when creating
404
+ person_name: Person's full name (for the fallback search)
405
+ create_if_missing: Deprecated no-op — retained for backwards
406
+ compatibility. Logs a warning when set; never creates a contact.
407
+ contact_stage_id: Deprecated no-op (was only used for auto-creation).
280
408
 
281
409
  Returns:
282
- Contact ID if found/created, None otherwise
410
+ Contact ID if found, None otherwise.
283
411
  """
284
412
  normalized_url = normalize_linkedin_url(linkedin_url)
285
413
 
@@ -308,34 +436,18 @@ class ApolloClient:
308
436
  # Ambiguous - multiple contacts with same name and URL
309
437
  return None
310
438
 
311
- # Step 3: People database search for auto-creation
439
+ # Auto-creation from the people database is no longer possible — Apollo's
440
+ # /mixed_people/api_search returns teaser data (no linkedin_url, obfuscated
441
+ # last name, no email), so a URL match can't be made nor a usable contact
442
+ # created. Warn instead of silently doing nothing.
312
443
  if create_if_missing and person_name:
313
- people_result = await self._post(
314
- "/mixed_people/search",
315
- {
316
- "q_keywords": person_name,
317
- "per_page": 10,
318
- },
444
+ logger.warning(
445
+ "find_contact_by_linkedin_url: create_if_missing is no longer supported — "
446
+ "Apollo's people search returns teaser data without linkedin_url, so no "
447
+ "contact was created for %r. Use enrichment/reveal + create_contact instead.",
448
+ person_name,
319
449
  )
320
450
 
321
- people = people_result.get("people", [])
322
- for person in people:
323
- person_url = person.get("linkedin_url", "")
324
- if person_url and normalize_linkedin_url(person_url) == normalized_url:
325
- # Create contact from people database
326
- create_data = {
327
- "first_name": person.get("first_name", ""),
328
- "last_name": person.get("last_name", ""),
329
- "linkedin_url": person.get("linkedin_url"),
330
- "title": person.get("title"),
331
- "person_id": person.get("id"),
332
- }
333
- if contact_stage_id:
334
- create_data["contact_stage_id"] = contact_stage_id
335
-
336
- created = await self.create_contact(**create_data)
337
- return created.id
338
-
339
451
  return None
340
452
 
341
453
  # ========================================================================
@@ -350,11 +462,26 @@ class ApolloClient:
350
462
  Args:
351
463
  page: Page number (default 1)
352
464
  limit: Results per page (default 100, max 100)
353
- **filters: Additional filters (q_organization_name, account_stage_ids, etc.)
465
+ **filters: Account search filters (see ``ACCOUNT_SEARCH_FILTERS``):
466
+
467
+ - ``q_organization_name`` (str): company-name keyword.
468
+ - ``account_stage_ids`` (list[str]): account stage IDs.
469
+ - ``account_label_ids`` (list[str]): label / list IDs.
470
+ - ``sort_by_field`` (str): ``account_last_activity_date`` |
471
+ ``account_created_at`` | ``account_updated_at``.
472
+ - ``sort_ascending`` (bool).
354
473
 
355
474
  Returns:
356
475
  Paginated response with Account items
476
+
477
+ Raises:
478
+ ValueError: If an unrecognised filter key is passed. Apollo silently
479
+ ignores unknown keys and returns an unfiltered default page (which
480
+ looks like a real match), so we fail loudly instead of returning the
481
+ wrong accounts. Note ``query=`` is **not** a valid filter — use
482
+ ``q_organization_name=`` to search by name.
357
483
  """
484
+ _validate_search_filters(filters, ACCOUNT_SEARCH_FILTERS, "account", strict=True)
358
485
  data = {"page": page, "per_page": min(limit, 100), **filters}
359
486
  result = await self._post("/accounts/search", data)
360
487
 
@@ -391,11 +518,20 @@ class ApolloClient:
391
518
  Args:
392
519
  page: Page number (default 1)
393
520
  limit: Results per page (default 100, max 100)
394
- **filters: Additional filters (opportunity_stage_ids, q_keywords, etc.)
521
+ **filters: Deal search filters (see ``DEAL_SEARCH_FILTERS``):
522
+
523
+ - ``q_opportunity_name`` (str): deal-name keyword. **Use this, not
524
+ ``q_keywords``** — Apollo silently ignores ``q_keywords`` on
525
+ ``/opportunities/search`` (it returns every deal).
526
+ - ``opportunity_stage_ids`` (list[str]): deal stage IDs (see
527
+ ``list_all_stages``).
528
+ - ``sort_by_field`` (str): ``amount`` | ``is_closed`` | ``is_won``.
529
+ - ``sort_ascending`` (bool).
395
530
 
396
531
  Returns:
397
532
  Paginated response with Deal items
398
533
  """
534
+ _validate_search_filters(filters, DEAL_SEARCH_FILTERS, "deal", strict=True)
399
535
  data = {"page": page, "per_page": min(limit, 100), **filters}
400
536
  result = await self._post("/opportunities/search", data)
401
537
 
@@ -420,6 +556,25 @@ class ApolloClient:
420
556
  result = await self._get(f"/opportunities/{deal_id}")
421
557
  return Deal.model_validate(result.get("opportunity", {}))
422
558
 
559
+ async def create_deal(self, name: str, **fields) -> Deal:
560
+ """Create a new deal/opportunity.
561
+
562
+ Note:
563
+ Apollo requires a **master** API key for this endpoint; a non-master
564
+ key returns 403. ``name`` is the only required field.
565
+
566
+ Args:
567
+ name: Human-readable deal name (required).
568
+ **fields: Additional fields (owner_id, account_id, amount,
569
+ opportunity_stage_id, closed_date [YYYY-MM-DD], etc.).
570
+
571
+ Returns:
572
+ The created Deal model.
573
+ """
574
+ data = {"name": name, **fields}
575
+ result = await self._post("/opportunities", data)
576
+ return Deal.model_validate(result.get("opportunity", result))
577
+
423
578
  # ========================================================================
424
579
  # PIPELINES & STAGES
425
580
  # ========================================================================
@@ -545,7 +700,26 @@ class ApolloClient:
545
700
  Returns:
546
701
  The updated Deal.
547
702
  """
548
- data = {"opportunity_id": opportunity_id, "roles": roles}
703
+ # Apollo's endpoint expects each entry's role type *nested* under a ``role``
704
+ # array — sending ``opportunity_contact_role_type_id`` flat on the entry (with
705
+ # no ``role`` key) makes the server call ``.map`` on nil and 422 with
706
+ # "undefined method 'map' for nil". Reshape the flat RoleAssignment entries
707
+ # into the wire format the server actually accepts.
708
+ wire_roles: list[dict] = []
709
+ for entry in roles:
710
+ # RoleAssignment types is_primary as a bool; default to False when omitted.
711
+ # Avoid bool(...) coercion, which would turn a stray truthy non-bool (e.g.
712
+ # the string "false") into True.
713
+ is_primary = entry.get("is_primary", False)
714
+ role_obj: dict[str, Any] = {"is_primary": is_primary}
715
+ role_type_id = entry.get("opportunity_contact_role_type_id")
716
+ if role_type_id:
717
+ role_obj["opportunity_contact_role_type_id"] = role_type_id
718
+ wire_roles.append(
719
+ {"contact_id": entry["contact_id"], "is_primary": is_primary, "role": [role_obj]}
720
+ )
721
+
722
+ data = {"opportunity_id": opportunity_id, "roles": wire_roles}
549
723
  result = await self._post("/opportunities/update_roles", data)
550
724
  return Deal.model_validate(result.get("opportunity", result))
551
725
 
@@ -600,13 +774,53 @@ class ApolloClient:
600
774
  async def search_people(self, **filters) -> dict:
601
775
  """Search people in Apollo's global database.
602
776
 
777
+ Uses ``/mixed_people/api_search``; the older ``/mixed_people/search`` is
778
+ deprecated for API callers (returns 422). Note that this endpoint returns
779
+ **teaser data only** — ``first_name``, ``last_name_obfuscated``, ``title``
780
+ and ``organization``, but not full name / email / linkedin_url (those
781
+ require a separate enrichment/reveal step and consume credits).
782
+
603
783
  Args:
604
- **filters: Search filters (q_keywords, person_titles, person_locations, etc.)
784
+ **filters: People search filters (see ``PEOPLE_SEARCH_FILTERS``).
785
+ Formats matter — a wrong format is silently ignored or matches
786
+ nothing rather than erroring:
787
+
788
+ - ``q_keywords`` (str): free text.
789
+ - ``person_titles`` (list[str]): job titles, e.g. ``["CEO", "VP Sales"]``.
790
+ ``include_similar_titles`` (bool): broaden to related titles.
791
+ - ``person_seniorities`` (list[str]): **lowercase enums** —
792
+ ``owner, founder, c_suite, partner, vp, head, director, manager,
793
+ senior, entry, intern``. Uppercase / free text (``"VP"``,
794
+ ``"vice president"``) match **0** rows.
795
+ - ``person_locations`` / ``organization_locations`` (list[str]):
796
+ person / company location. Accepts a country name, a 2-letter
797
+ country code (``"US"`` == ``"United States"``), or
798
+ ``"City, State, Country"``.
799
+ - ``organization_num_employees_ranges`` (list[str]): company-size
800
+ buckets as **"min,max"** strings, e.g. ``["1,10"]``,
801
+ ``["1000,5000"]``. A dash (``"1-10"``) is **silently ignored**.
802
+ - ``q_organization_domains_list`` (list[str]): company domains,
803
+ e.g. ``["acme.com"]``. ``organization_ids`` (list[str]): Apollo org IDs.
804
+ - ``contact_email_status`` (list[str]): ``verified``, ``unverified``,
805
+ ``likely to engage``, ``unavailable``.
806
+ - ``revenue_range`` / ``organization_num_jobs_range`` (dict):
807
+ ``{"min": int, "max": int}``.
808
+ - ``organization_job_posted_at_range`` (dict): ``{"min": "YYYY-MM-DD",
809
+ "max": "YYYY-MM-DD"}``.
810
+ - ``currently_using_any_of_technology_uids`` /
811
+ ``currently_using_all_of_technology_uids`` /
812
+ ``currently_not_using_any_of_technology_uids`` (list[str]): tech
813
+ UIDs, e.g. ``["salesforce"]``.
814
+ - ``q_organization_job_titles`` (list[str]),
815
+ ``organization_job_locations`` (list[str]).
816
+ - ``page`` / ``per_page`` (int): pagination (this method has no
817
+ explicit page/limit args — pass them as filters).
605
818
 
606
819
  Returns:
607
- Search results dictionary
820
+ Raw Apollo response dict: ``people`` (list) and ``total_entries`` (int).
608
821
  """
609
- return await self._post("/mixed_people/search", filters)
822
+ _validate_search_filters(filters, PEOPLE_SEARCH_FILTERS, "people", strict=True)
823
+ return await self._post("/mixed_people/api_search", filters)
610
824
 
611
825
  # ========================================================================
612
826
  # NOTES
@@ -625,6 +839,7 @@ class ApolloClient:
625
839
  Returns:
626
840
  Paginated response with Note items (content converted to Markdown)
627
841
  """
842
+ _validate_search_filters(filters, NOTE_SEARCH_FILTERS, "note", strict=False)
628
843
  data = {"page": page, "per_page": min(limit, 100), **filters}
629
844
  result = await self._post("/notes/search", data)
630
845
 
@@ -710,6 +925,7 @@ class ApolloClient:
710
925
  Returns:
711
926
  Paginated response with Call items
712
927
  """
928
+ _validate_search_filters(filters, CALL_SEARCH_FILTERS, "call", strict=False)
713
929
  data = {"page": page, "per_page": min(limit, 100), **filters}
714
930
  result = await self._post("/phone_calls/search", data)
715
931
 
@@ -749,6 +965,7 @@ class ApolloClient:
749
965
  Returns:
750
966
  Paginated response with specific Task subclass items
751
967
  """
968
+ _validate_search_filters(filters, TASK_SEARCH_FILTERS, "task", strict=False)
752
969
  data: dict[str, Any] = {"page": page, "per_page": min(limit, 100), **filters}
753
970
  if task_type_cds is not None:
754
971
  data["task_type_cds"] = task_type_cds
@@ -792,6 +1009,7 @@ class ApolloClient:
792
1009
  Returns:
793
1010
  Paginated response with Email items
794
1011
  """
1012
+ _validate_search_filters(filters, EMAIL_SEARCH_FILTERS, "email", strict=False)
795
1013
  data = {"page": page, "per_page": min(limit, 100), **filters}
796
1014
  result = await self._post("/emailer_messages/search", data)
797
1015
 
@@ -1106,29 +1324,20 @@ class ApolloClient:
1106
1324
  )
1107
1325
  return EmailerMessage.model_validate(result.get("emailer_message", result))
1108
1326
 
1109
- async def list_contact_calls(self, contact_id: str) -> list[Call]:
1110
- """List calls for a contact.
1111
-
1112
- Args:
1113
- contact_id: Contact ID
1114
-
1115
- Returns:
1116
- List of Call models
1117
- """
1118
- result = await self._get(f"/contacts/{contact_id}/calls")
1119
- return [Call.model_validate(c) for c in result.get("calls", [])]
1120
-
1121
1327
  async def list_contact_tasks(self, contact_id: str) -> list[Task]:
1122
1328
  """List tasks for a contact.
1123
1329
 
1330
+ Apollo removed the ``/contacts/{id}/tasks`` sub-resource route (now 404),
1331
+ so this filters the tasks search by ``contact_ids`` instead.
1332
+
1124
1333
  Args:
1125
1334
  contact_id: Contact ID
1126
1335
 
1127
1336
  Returns:
1128
1337
  List of Task subclasses matching each task's type
1129
1338
  """
1130
- result = await self._get(f"/contacts/{contact_id}/tasks")
1131
- return [resolve_task(t) for t in result.get("tasks", [])]
1339
+ result = await self.search_tasks(contact_ids=[contact_id])
1340
+ return result.items
1132
1341
 
1133
1342
  # ========================================================================
1134
1343
  # CALENDAR EVENTS
@@ -1147,6 +1356,9 @@ class ApolloClient:
1147
1356
  Returns:
1148
1357
  Paginated response with CalendarEvent items
1149
1358
  """
1359
+ _validate_search_filters(
1360
+ filters, CALENDAR_EVENT_SEARCH_FILTERS, "calendar event", strict=False
1361
+ )
1150
1362
  data = {"page": page, "per_page": min(limit, 100), **filters}
1151
1363
  result = await self._post("/calendar_events/search", data)
1152
1364
 
@@ -1176,6 +1388,7 @@ class ApolloClient:
1176
1388
  Returns:
1177
1389
  Paginated response with Conversation items
1178
1390
  """
1391
+ _validate_search_filters(filters, CONVERSATION_SEARCH_FILTERS, "conversation", strict=False)
1179
1392
  data = {"page": page, "per_page": min(limit, 25), **filters}
1180
1393
  result = await self._post("/conversations/search", data)
1181
1394
 
@@ -1205,29 +1418,25 @@ class ApolloClient:
1205
1418
  # NEWS & JOBS
1206
1419
  # ========================================================================
1207
1420
 
1208
- async def list_account_news(self, account_id: str) -> list[dict]:
1209
- """List news articles for an account.
1210
-
1211
- Args:
1212
- account_id: Account ID
1213
-
1214
- Returns:
1215
- List of news article dictionaries
1216
- """
1217
- result = await self._get(f"/accounts/{account_id}/news")
1218
- return result.get("news", [])
1219
-
1220
1421
  async def list_account_jobs(self, account_id: str) -> list[dict]:
1221
1422
  """List job postings for an account.
1222
1423
 
1424
+ Apollo removed the ``/accounts/{id}/job_postings`` sub-resource route (now
1425
+ 404). Job postings live on the linked *organization*, so this resolves the
1426
+ account's ``organization_id`` and reads ``/organizations/{org_id}/job_postings``.
1427
+
1223
1428
  Args:
1224
- account_id: Account ID
1429
+ account_id: CRM account ID (its linked organization holds the postings).
1225
1430
 
1226
1431
  Returns:
1227
- List of job posting dictionaries
1432
+ List of job posting dictionaries (empty if the account has no linked
1433
+ organization).
1228
1434
  """
1229
- result = await self._get(f"/accounts/{account_id}/job_postings")
1230
- return result.get("job_postings", [])
1435
+ account = await self.get_account(account_id)
1436
+ if not account.organization_id:
1437
+ return []
1438
+ result = await self._get(f"/organizations/{account.organization_id}/job_postings")
1439
+ return result.get("organization_job_postings", [])
1231
1440
 
1232
1441
  # ========================================================================
1233
1442
  # USAGE & RATE LIMITS
@@ -251,6 +251,107 @@ async def test_search_accounts(client: ApolloClient):
251
251
  assert result.items[0].name == "Acme Corp"
252
252
 
253
253
 
254
+ async def test_search_accounts_rejects_unknown_filter(client: ApolloClient):
255
+ """An unrecognised filter raises instead of returning a wrong default page.
256
+
257
+ Regression: Apollo silently drops unknown keys (e.g. ``query=``) and returns
258
+ an unfiltered default list that looks like a real match.
259
+ """
260
+ with pytest.raises(ValueError, match="Unknown account search filter"):
261
+ await client.search_accounts(query="Red and Bundle")
262
+
263
+ # The bad request must never reach Apollo.
264
+ client._client.request.assert_not_called()
265
+
266
+
267
+ async def test_search_accounts_error_names_the_bad_key_and_suggests_fix(client: ApolloClient):
268
+ """The message names the offending key and points to q_organization_name."""
269
+ with pytest.raises(ValueError, match=r"query.*q_organization_name"):
270
+ await client.search_accounts(query="x")
271
+
272
+
273
+ async def test_search_accounts_allows_documented_filters(client: ApolloClient):
274
+ """All allowlisted keys pass validation and reach the request body."""
275
+ client._client.request.return_value = _make_response(
276
+ {"accounts": [], "pagination": {"total_entries": 0}}
277
+ )
278
+
279
+ await client.search_accounts(
280
+ q_organization_name="Acme",
281
+ account_stage_ids=["s1"],
282
+ account_label_ids=["l1"],
283
+ sort_by_field="account_created_at",
284
+ sort_ascending=True,
285
+ )
286
+
287
+ body = client._client.request.call_args[1]["json"]
288
+ assert body["q_organization_name"] == "Acme"
289
+ assert body["account_stage_ids"] == ["s1"]
290
+ assert body["sort_by_field"] == "account_created_at"
291
+
292
+
293
+ # --- Generalized search-filter validation (strict raise vs lenient warn) ------
294
+
295
+
296
+ @pytest.mark.parametrize(
297
+ "method,good_filter",
298
+ [
299
+ ("search_contacts", {"q_keywords": "x"}),
300
+ ("search_deals", {"opportunity_stage_ids": ["s1"]}),
301
+ ("search_people", {"person_titles": ["CEO"]}),
302
+ ],
303
+ )
304
+ async def test_strict_search_methods_reject_unknown_filter(client, method, good_filter):
305
+ """contacts/deals/people raise on an unknown key (like accounts) and never call Apollo."""
306
+ client._client.request.return_value = _make_response({})
307
+ with pytest.raises(ValueError, match=r"Unknown .* search filter"):
308
+ await getattr(client, method)(query="typo")
309
+ client._client.request.assert_not_called()
310
+
311
+ # A documented filter passes validation and reaches Apollo.
312
+ await getattr(client, method)(**good_filter)
313
+ assert client._client.request.called
314
+
315
+
316
+ async def test_search_people_allows_page_and_per_page(client: ApolloClient):
317
+ """search_people has no explicit page/limit, so page/per_page are valid filters."""
318
+ client._client.request.return_value = _make_response({"people": [], "contacts": []})
319
+ await client.search_people(q_keywords="x", page=2, per_page=50)
320
+ assert client._client.request.call_args[1]["json"]["per_page"] == 50
321
+
322
+
323
+ @pytest.mark.parametrize(
324
+ "method,endpoint_key",
325
+ [
326
+ ("search_notes", "notes"),
327
+ ("search_calls", "phone_calls"),
328
+ ("search_tasks", "tasks"),
329
+ ("search_emails", "emailer_messages"),
330
+ ("search_conversations", "conversations"),
331
+ ("search_calendar_events", "calendar_events"),
332
+ ],
333
+ )
334
+ async def test_lenient_search_methods_warn_but_still_send(client, method, endpoint_key, caplog):
335
+ """Activity endpoints log a warning on an unknown key but still send the request."""
336
+ client._client.request.return_value = _make_response({endpoint_key: [], "pagination": {}})
337
+
338
+ with caplog.at_level("WARNING", logger="qodev_apollo_api.client"):
339
+ await getattr(client, method)(bogus_filter="x")
340
+
341
+ assert any("Unknown" in r.message and "search filter" in r.message for r in caplog.records)
342
+ # Lenient: the request is still sent (unknown key forwarded, not blocked).
343
+ assert client._client.request.called
344
+ assert client._client.request.call_args[1]["json"]["bogus_filter"] == "x"
345
+
346
+
347
+ async def test_lenient_search_method_no_warn_on_known_filter(client, caplog):
348
+ """A known activity filter passes without a warning."""
349
+ client._client.request.return_value = _make_response({"notes": [], "pagination": {}})
350
+ with caplog.at_level("WARNING", logger="qodev_apollo_api.client"):
351
+ await client.search_notes(contact_ids=["c1"])
352
+ assert not any("Unknown" in r.message for r in caplog.records)
353
+
354
+
254
355
  async def test_search_deals(client: ApolloClient):
255
356
  """Test POST /opportunities/search returns PaginatedResponse[Deal]."""
256
357
  client._client.request.return_value = _make_response(
@@ -267,6 +368,20 @@ async def test_search_deals(client: ApolloClient):
267
368
  assert isinstance(result.items[0], Deal)
268
369
  assert result.items[0].name == "Big Deal"
269
370
 
371
+
372
+ async def test_search_deals_name_filter_is_q_opportunity_name(client: ApolloClient):
373
+ """Deal name search is ``q_opportunity_name``; ``q_keywords`` is silently ignored
374
+ by Apollo for /opportunities/search, so it's not an accepted filter (raises)."""
375
+ client._client.request.return_value = _make_response(
376
+ {"opportunities": [], "pagination": {"total_entries": 0}}
377
+ )
378
+
379
+ await client.search_deals(q_opportunity_name="NORRIQ")
380
+ assert client._client.request.call_args[1]["json"]["q_opportunity_name"] == "NORRIQ"
381
+
382
+ with pytest.raises(ValueError, match="Unknown deal search filter"):
383
+ await client.search_deals(q_keywords="NORRIQ")
384
+
270
385
  assert client._client.request.call_args[0] == ("POST", "/opportunities/search")
271
386
 
272
387
 
@@ -533,6 +648,43 @@ async def test_get_deal(client: ApolloClient):
533
648
  client._client.request.assert_called_once_with("GET", "/opportunities/d1")
534
649
 
535
650
 
651
+ async def test_create_deal(client: ApolloClient):
652
+ """Test POST /opportunities returns the created Deal with name + extra fields in the body."""
653
+ client._client.request.return_value = _make_response(
654
+ {"opportunity": {"id": "d9", "name": "New Deal", "amount": "1000"}}
655
+ )
656
+
657
+ result = await client.create_deal(
658
+ "New Deal", owner_id="o1", account_id="a1", amount=1000, opportunity_stage_id="st1"
659
+ )
660
+
661
+ assert isinstance(result, Deal)
662
+ assert result.id == "d9"
663
+
664
+ call_args = client._client.request.call_args
665
+ assert call_args[0] == ("POST", "/opportunities")
666
+ assert call_args[1]["json"] == {
667
+ "name": "New Deal",
668
+ "owner_id": "o1",
669
+ "account_id": "a1",
670
+ "amount": 1000,
671
+ "opportunity_stage_id": "st1",
672
+ }
673
+
674
+
675
+ async def test_create_deal_name_only(client: ApolloClient):
676
+ """Only ``name`` is required; the body carries nothing else."""
677
+ client._client.request.return_value = _make_response(
678
+ {"opportunity": {"id": "d10", "name": "Minimal"}}
679
+ )
680
+
681
+ result = await client.create_deal("Minimal")
682
+
683
+ assert isinstance(result, Deal)
684
+ assert result.id == "d10"
685
+ assert client._client.request.call_args[1]["json"] == {"name": "Minimal"}
686
+
687
+
536
688
  async def test_get_pipeline(client: ApolloClient):
537
689
  """Test GET /opportunity_pipelines/{id} returns Pipeline."""
538
690
  client._client.request.return_value = _make_response(
@@ -643,7 +795,11 @@ async def test_list_opportunity_contact_role_types(client: ApolloClient):
643
795
 
644
796
 
645
797
  async def test_update_opportunity_roles(client: ApolloClient):
646
- """Test POST /opportunities/update_roles returns the updated Deal with the roles body."""
798
+ """The flat RoleAssignment entries are reshaped into Apollo's nested ``role`` wire format.
799
+
800
+ Regression: sending ``opportunity_contact_role_type_id`` flat on the entry (no
801
+ ``role`` key) makes Apollo 422 with "undefined method 'map' for nil".
802
+ """
647
803
  client._client.request.return_value = _make_response(
648
804
  {"opportunity": {"id": "d1", "name": "Big Deal"}}
649
805
  )
@@ -659,7 +815,19 @@ async def test_update_opportunity_roles(client: ApolloClient):
659
815
 
660
816
  call_args = client._client.request.call_args
661
817
  assert call_args[0] == ("POST", "/opportunities/update_roles")
662
- assert call_args[1]["json"] == {"opportunity_id": "d1", "roles": roles}
818
+ assert call_args[1]["json"] == {
819
+ "opportunity_id": "d1",
820
+ "roles": [
821
+ {
822
+ "contact_id": "c1",
823
+ "is_primary": True,
824
+ "role": [{"is_primary": True, "opportunity_contact_role_type_id": "rt1"}],
825
+ },
826
+ # No role type → the nested role object carries only is_primary (never a
827
+ # flat/absent role type, which is what triggered the nil.map crash).
828
+ {"contact_id": "c2", "is_primary": False, "role": [{"is_primary": False}]},
829
+ ],
830
+ }
663
831
 
664
832
 
665
833
  async def test_list_custom_fields(client: ApolloClient):
@@ -703,24 +871,14 @@ async def test_get_contact_stages(client: ApolloClient):
703
871
  client._client.request.assert_called_once_with("GET", "/contact_stages")
704
872
 
705
873
 
706
- async def test_list_contact_calls(client: ApolloClient):
707
- """Test GET /contacts/{id}/calls returns list[Call]."""
708
- client._client.request.return_value = _make_response(
709
- {"calls": [{"id": "call1"}, {"id": "call2"}]}
710
- )
711
-
712
- result = await client.list_contact_calls("c1")
713
-
714
- assert isinstance(result, list)
715
- assert len(result) == 2
716
- assert all(isinstance(c, Call) for c in result)
717
- client._client.request.assert_called_once_with("GET", "/contacts/c1/calls")
718
-
719
-
720
874
  async def test_list_contact_tasks(client: ApolloClient):
721
- """Test GET /contacts/{id}/tasks returns list[Task]."""
875
+ """list_contact_tasks filters the tasks search by contact_ids (the old
876
+ /contacts/{id}/tasks route was removed by Apollo — now 404)."""
722
877
  client._client.request.return_value = _make_response(
723
- {"tasks": [{"id": "t1", "type": "call"}, {"id": "t2", "type": "contact_action_item"}]}
878
+ {
879
+ "tasks": [{"id": "t1", "type": "call"}, {"id": "t2", "type": "contact_action_item"}],
880
+ "pagination": {"total_entries": 2},
881
+ }
724
882
  )
725
883
 
726
884
  result = await client.list_contact_tasks("c1")
@@ -728,35 +886,40 @@ async def test_list_contact_tasks(client: ApolloClient):
728
886
  assert isinstance(result, list)
729
887
  assert len(result) == 2
730
888
  assert all(isinstance(t, BaseTask) for t in result)
731
- client._client.request.assert_called_once_with("GET", "/contacts/c1/tasks")
889
+ call_args = client._client.request.call_args
890
+ assert call_args[0] == ("POST", "/tasks/search")
891
+ assert call_args[1]["json"]["contact_ids"] == ["c1"]
732
892
 
733
893
 
734
- async def test_list_account_news(client: ApolloClient):
735
- """Test GET /accounts/{id}/news returns list[dict]."""
736
- client._client.request.return_value = _make_response(
737
- {"news": [{"title": "Big news"}, {"title": "Small news"}]}
738
- )
894
+ async def test_list_account_jobs(client: ApolloClient):
895
+ """list_account_jobs resolves the account's organization_id then reads
896
+ /organizations/{org_id}/job_postings (the old /accounts/{id}/job_postings
897
+ route was removed by Apollo — now 404)."""
898
+ client._client.request.side_effect = [
899
+ _make_response({"account": {"id": "a1", "organization_id": "org9"}}),
900
+ _make_response(
901
+ {"organization_job_postings": [{"title": "Engineer"}, {"title": "Designer"}]}
902
+ ),
903
+ ]
739
904
 
740
- result = await client.list_account_news("a1")
905
+ result = await client.list_account_jobs("a1")
741
906
 
742
- assert isinstance(result, list)
743
- assert len(result) == 2
744
- assert result[0]["title"] == "Big news"
745
- client._client.request.assert_called_once_with("GET", "/accounts/a1/news")
907
+ assert [j["title"] for j in result] == ["Engineer", "Designer"]
908
+ assert client._client.request.call_args_list[0][0] == ("GET", "/accounts/a1")
909
+ assert client._client.request.call_args_list[1][0] == (
910
+ "GET",
911
+ "/organizations/org9/job_postings",
912
+ )
746
913
 
747
914
 
748
- async def test_list_account_jobs(client: ApolloClient):
749
- """Test GET /accounts/{id}/job_postings returns list[dict]."""
750
- client._client.request.return_value = _make_response(
751
- {"job_postings": [{"title": "Engineer"}, {"title": "Designer"}]}
752
- )
915
+ async def test_list_account_jobs_no_organization(client: ApolloClient):
916
+ """An account with no linked organization returns [] without a second call."""
917
+ client._client.request.return_value = _make_response({"account": {"id": "a1"}})
753
918
 
754
919
  result = await client.list_account_jobs("a1")
755
920
 
756
- assert isinstance(result, list)
757
- assert len(result) == 2
758
- assert result[0]["title"] == "Engineer"
759
- client._client.request.assert_called_once_with("GET", "/accounts/a1/job_postings")
921
+ assert result == []
922
+ client._client.request.assert_called_once_with("GET", "/accounts/a1")
760
923
 
761
924
 
762
925
  # ============================================================================
@@ -1197,17 +1360,17 @@ async def test_enrich_person(client: ApolloClient):
1197
1360
 
1198
1361
 
1199
1362
  async def test_search_people(client: ApolloClient):
1200
- """Test POST /mixed_people/search."""
1363
+ """Test POST /mixed_people/api_search (the old /mixed_people/search is deprecated)."""
1201
1364
  client._client.request.return_value = _make_response(
1202
- {"people": [{"id": "p1", "name": "Alice"}]}
1365
+ {"people": [{"id": "p1", "first_name": "Alice"}], "total_entries": 1}
1203
1366
  )
1204
1367
 
1205
1368
  result = await client.search_people(q_keywords="Alice")
1206
1369
 
1207
- assert result == {"people": [{"id": "p1", "name": "Alice"}]}
1370
+ assert result == {"people": [{"id": "p1", "first_name": "Alice"}], "total_entries": 1}
1208
1371
 
1209
1372
  call_args = client._client.request.call_args
1210
- assert call_args[0] == ("POST", "/mixed_people/search")
1373
+ assert call_args[0] == ("POST", "/mixed_people/api_search")
1211
1374
  payload = call_args[1]["json"]
1212
1375
  assert payload["q_keywords"] == "Alice"
1213
1376
 
@@ -1361,82 +1524,38 @@ async def test_find_by_linkedin_url_step2_ambiguous(client: ApolloClient):
1361
1524
  assert result is None
1362
1525
 
1363
1526
 
1364
- async def test_find_by_linkedin_url_step3_create(client: ApolloClient):
1365
- """Test people DB match → creates contact."""
1527
+ async def test_find_by_linkedin_url_create_if_missing_is_noop(client: ApolloClient, caplog):
1528
+ """create_if_missing no longer auto-creates: Apollo's api_search returns teaser
1529
+ data (no linkedin_url), so the former Step 3 is gone. It warns and returns None,
1530
+ and never POSTs to /contacts."""
1366
1531
  step1_response = _make_response({"contacts": [], "pagination": {"total_entries": 0}})
1367
- # Step 2: Name search — no URL match
1368
1532
  step2_response = _make_response({"contacts": [], "pagination": {"total_entries": 0}})
1369
- # Step 3: People DB search
1370
- step3_response = _make_response(
1371
- {
1372
- "people": [
1373
- {
1374
- "id": "person_1",
1375
- "first_name": "Alice",
1376
- "last_name": "Smith",
1377
- "linkedin_url": "https://www.linkedin.com/in/alice",
1378
- "title": "CTO",
1379
- }
1380
- ]
1381
- }
1382
- )
1383
- # Step 3: Create contact
1384
- step4_response = _make_response(
1385
- {"contact": {"id": "c_new", "first_name": "Alice", "last_name": "Smith"}}
1386
- )
1387
- client._client.request.side_effect = [
1388
- step1_response,
1389
- step2_response,
1390
- step3_response,
1391
- step4_response,
1392
- ]
1393
-
1394
- result = await client.find_contact_by_linkedin_url(
1395
- "https://www.linkedin.com/in/alice",
1396
- person_name="Alice Smith",
1397
- create_if_missing=True,
1398
- contact_stage_id="stage_1",
1399
- )
1533
+ client._client.request.side_effect = [step1_response, step2_response]
1400
1534
 
1401
- assert result == "c_new"
1535
+ with caplog.at_level("WARNING", logger="qodev_apollo_api.client"):
1536
+ result = await client.find_contact_by_linkedin_url(
1537
+ "https://www.linkedin.com/in/alice",
1538
+ person_name="Alice Smith",
1539
+ create_if_missing=True,
1540
+ contact_stage_id="stage_1",
1541
+ )
1402
1542
 
1403
- # Verify create_contact was called with correct data
1404
- create_call = client._client.request.call_args_list[3]
1405
- assert create_call[0] == ("POST", "/contacts")
1406
- payload = create_call[1]["json"]
1407
- assert payload["first_name"] == "Alice"
1408
- assert payload["last_name"] == "Smith"
1409
- assert payload["person_id"] == "person_1"
1410
- assert payload["contact_stage_id"] == "stage_1"
1543
+ assert result is None
1544
+ # Only the two lookup searches ran — no people-search POST, no contact creation.
1545
+ assert client._client.request.call_count == 2
1546
+ assert all(call[0][1] != "/contacts" for call in client._client.request.call_args_list)
1547
+ assert any("create_if_missing is no longer supported" in r.message for r in caplog.records)
1411
1548
 
1412
1549
 
1413
1550
  async def test_find_by_linkedin_url_not_found(client: ApolloClient):
1414
- """Test all three steps fail → None."""
1551
+ """Both existing-contact lookups fail → None."""
1415
1552
  step1_response = _make_response({"contacts": [], "pagination": {"total_entries": 0}})
1416
1553
  step2_response = _make_response({"contacts": [], "pagination": {"total_entries": 0}})
1417
- # Step 3: People DB — no URL match
1418
- step3_response = _make_response(
1419
- {
1420
- "people": [
1421
- {
1422
- "id": "person_1",
1423
- "first_name": "Bob",
1424
- "last_name": "Jones",
1425
- "linkedin_url": "https://www.linkedin.com/in/bob",
1426
- }
1427
- ]
1428
- }
1429
- )
1430
- client._client.request.side_effect = [
1431
- step1_response,
1432
- step2_response,
1433
- step3_response,
1434
- ]
1554
+ client._client.request.side_effect = [step1_response, step2_response]
1435
1555
 
1436
1556
  result = await client.find_contact_by_linkedin_url(
1437
1557
  "https://www.linkedin.com/in/alice",
1438
1558
  person_name="Alice Smith",
1439
- create_if_missing=True,
1440
1559
  )
1441
1560
 
1442
1561
  assert result is None
@@ -407,7 +407,7 @@ wheels = [
407
407
 
408
408
  [[package]]
409
409
  name = "qodev-apollo-api"
410
- version = "0.3.2"
410
+ version = "0.4.0"
411
411
  source = { editable = "." }
412
412
  dependencies = [
413
413
  { name = "httpx" },