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.
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/CHANGELOG.md +29 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/CLAUDE.md +20 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/PKG-INFO +1 -1
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/pyproject.toml +1 -1
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/src/qodev_apollo_api/client.py +279 -70
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/tests/test_client.py +224 -105
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/uv.lock +1 -1
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/.github/workflows/ci.yml +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/.github/workflows/publish.yml +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/.gitignore +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/.pre-commit-config.yaml +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/LICENSE +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/Makefile +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/README.md +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/src/qodev_apollo_api/__init__.py +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/src/qodev_apollo_api/exceptions.py +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/src/qodev_apollo_api/models.py +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/src/qodev_apollo_api/py.typed +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/src/qodev_apollo_api/utils.py +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/tests/__init__.py +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/tests/integration/__init__.py +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/tests/integration/validate_all_models.py +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/tests/integration/validate_email_task_flow.py +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/tests/test_exceptions.py +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/tests/test_models.py +0 -0
- {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
|
|
@@ -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:
|
|
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
|
|
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.
|
|
273
|
-
|
|
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:
|
|
279
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
314
|
-
"
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
820
|
+
Raw Apollo response dict: ``people`` (list) and ``total_entries`` (int).
|
|
608
821
|
"""
|
|
609
|
-
|
|
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.
|
|
1131
|
-
return
|
|
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:
|
|
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
|
-
|
|
1230
|
-
|
|
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
|
-
"""
|
|
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"] == {
|
|
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
|
-
"""
|
|
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
|
-
{
|
|
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.
|
|
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
|
|
735
|
-
"""
|
|
736
|
-
|
|
737
|
-
|
|
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.
|
|
905
|
+
result = await client.list_account_jobs("a1")
|
|
741
906
|
|
|
742
|
-
assert
|
|
743
|
-
assert
|
|
744
|
-
assert
|
|
745
|
-
|
|
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
|
|
749
|
-
"""
|
|
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
|
|
757
|
-
|
|
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", "
|
|
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", "
|
|
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/
|
|
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
|
|
1365
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1404
|
-
|
|
1405
|
-
assert
|
|
1406
|
-
|
|
1407
|
-
assert
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{qodev_apollo_api-0.3.2 → qodev_apollo_api-0.5.0}/tests/integration/validate_email_task_flow.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|