diff --git a/src/qodev_apollo_api/client.py b/src/qodev_apollo_api/client.py index fcd9404..15557c7 100644 --- a/src/qodev_apollo_api/client.py +++ b/src/qodev_apollo_api/client.py @@ -279,21 +279,27 @@ async def find_contact_by_linkedin_url( create_if_missing: bool = False, contact_stage_id: str | None = None, ) -> str | None: - """Find contact using 3-tier fallback strategy. + """Find an existing contact by LinkedIn URL (2-tier lookup). Strategy: - 1. Search by LinkedIn URL (exact match) - 2. Fallback to name search (if unique match) - 3. People database search for auto-creation (if enabled) + 1. Search existing contacts by LinkedIn URL (exact match). + 2. Fall back to a name search among existing contacts (unique match whose + normalized URL equals the target). + + Auto-creation from Apollo's people database (the former Step 3) is no + longer possible: ``/mixed_people/api_search`` returns teaser data only + (no ``linkedin_url``, an obfuscated last name, no email), so a URL match + can never succeed and there isn't enough data to create a usable contact. Args: linkedin_url: LinkedIn profile URL - person_name: Person's full name (for fallback search) - create_if_missing: Auto-create from people database if not found - contact_stage_id: Stage ID to assign when creating + person_name: Person's full name (for the fallback search) + create_if_missing: Deprecated no-op — retained for backwards + compatibility. Logs a warning when set; never creates a contact. + contact_stage_id: Deprecated no-op (was only used for auto-creation). Returns: - Contact ID if found/created, None otherwise + Contact ID if found, None otherwise. """ normalized_url = normalize_linkedin_url(linkedin_url) @@ -322,34 +328,18 @@ async def find_contact_by_linkedin_url( # Ambiguous - multiple contacts with same name and URL return None - # Step 3: People database search for auto-creation + # Auto-creation from the people database is no longer possible — Apollo's + # /mixed_people/api_search returns teaser data (no linkedin_url, obfuscated + # last name, no email), so a URL match can't be made nor a usable contact + # created. Warn instead of silently doing nothing. if create_if_missing and person_name: - people_result = await self._post( - "/mixed_people/search", - { - "q_keywords": person_name, - "per_page": 10, - }, + logger.warning( + "find_contact_by_linkedin_url: create_if_missing is no longer supported — " + "Apollo's people search returns teaser data without linkedin_url, so no " + "contact was created for %r. Use enrichment/reveal + create_contact instead.", + person_name, ) - people = people_result.get("people", []) - for person in people: - person_url = person.get("linkedin_url", "") - if person_url and normalize_linkedin_url(person_url) == normalized_url: - # Create contact from people database - create_data = { - "first_name": person.get("first_name", ""), - "last_name": person.get("last_name", ""), - "linkedin_url": person.get("linkedin_url"), - "title": person.get("title"), - "person_id": person.get("id"), - } - if contact_stage_id: - create_data["contact_stage_id"] = contact_stage_id - - created = await self.create_contact(**create_data) - return created.id - return None # ======================================================================== @@ -673,13 +663,20 @@ async def enrich_person(self, email: str) -> dict: async def search_people(self, **filters) -> dict: """Search people in Apollo's global database. + Uses ``/mixed_people/api_search``; the older ``/mixed_people/search`` is + deprecated for API callers (returns 422). Note that this endpoint returns + **teaser data only** — ``first_name``, ``last_name_obfuscated``, ``title`` + and ``organization``, but not full name / email / linkedin_url (those + require a separate enrichment/reveal step and consume credits). + Args: - **filters: Search filters (q_keywords, person_titles, person_locations, etc.) + **filters: Search filters (q_keywords, person_titles, person_seniorities, + person_locations, q_organization_domains_list, etc.). Returns: - Search results dictionary + Raw Apollo response dict: ``people`` (list) and ``total_entries`` (int). """ - return await self._post("/mixed_people/search", filters) + return await self._post("/mixed_people/api_search", filters) # ======================================================================== # NOTES diff --git a/tests/test_client.py b/tests/test_client.py index 8d6f9be..c2fb199 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1289,17 +1289,17 @@ async def test_enrich_person(client: ApolloClient): async def test_search_people(client: ApolloClient): - """Test POST /mixed_people/search.""" + """Test POST /mixed_people/api_search (the old /mixed_people/search is deprecated).""" client._client.request.return_value = _make_response( - {"people": [{"id": "p1", "name": "Alice"}]} + {"people": [{"id": "p1", "first_name": "Alice"}], "total_entries": 1} ) result = await client.search_people(q_keywords="Alice") - assert result == {"people": [{"id": "p1", "name": "Alice"}]} + assert result == {"people": [{"id": "p1", "first_name": "Alice"}], "total_entries": 1} call_args = client._client.request.call_args - assert call_args[0] == ("POST", "/mixed_people/search") + assert call_args[0] == ("POST", "/mixed_people/api_search") payload = call_args[1]["json"] assert payload["q_keywords"] == "Alice" @@ -1453,82 +1453,38 @@ async def test_find_by_linkedin_url_step2_ambiguous(client: ApolloClient): assert result is None -async def test_find_by_linkedin_url_step3_create(client: ApolloClient): - """Test people DB match → creates contact.""" +async def test_find_by_linkedin_url_create_if_missing_is_noop(client: ApolloClient, caplog): + """create_if_missing no longer auto-creates: Apollo's api_search returns teaser + data (no linkedin_url), so the former Step 3 is gone. It warns and returns None, + and never POSTs to /contacts.""" step1_response = _make_response({"contacts": [], "pagination": {"total_entries": 0}}) - # Step 2: Name search — no URL match step2_response = _make_response({"contacts": [], "pagination": {"total_entries": 0}}) - # Step 3: People DB search - step3_response = _make_response( - { - "people": [ - { - "id": "person_1", - "first_name": "Alice", - "last_name": "Smith", - "linkedin_url": "https://www.linkedin.com/in/alice", - "title": "CTO", - } - ] - } - ) - # Step 3: Create contact - step4_response = _make_response( - {"contact": {"id": "c_new", "first_name": "Alice", "last_name": "Smith"}} - ) - client._client.request.side_effect = [ - step1_response, - step2_response, - step3_response, - step4_response, - ] - - result = await client.find_contact_by_linkedin_url( - "https://www.linkedin.com/in/alice", - person_name="Alice Smith", - create_if_missing=True, - contact_stage_id="stage_1", - ) + client._client.request.side_effect = [step1_response, step2_response] - assert result == "c_new" + with caplog.at_level("WARNING", logger="qodev_apollo_api.client"): + result = await client.find_contact_by_linkedin_url( + "https://www.linkedin.com/in/alice", + person_name="Alice Smith", + create_if_missing=True, + contact_stage_id="stage_1", + ) - # Verify create_contact was called with correct data - create_call = client._client.request.call_args_list[3] - assert create_call[0] == ("POST", "/contacts") - payload = create_call[1]["json"] - assert payload["first_name"] == "Alice" - assert payload["last_name"] == "Smith" - assert payload["person_id"] == "person_1" - assert payload["contact_stage_id"] == "stage_1" + assert result is None + # Only the two lookup searches ran — no people-search POST, no contact creation. + assert client._client.request.call_count == 2 + assert all(call[0][1] != "/contacts" for call in client._client.request.call_args_list) + assert any("create_if_missing is no longer supported" in r.message for r in caplog.records) async def test_find_by_linkedin_url_not_found(client: ApolloClient): - """Test all three steps fail → None.""" + """Both existing-contact lookups fail → None.""" step1_response = _make_response({"contacts": [], "pagination": {"total_entries": 0}}) step2_response = _make_response({"contacts": [], "pagination": {"total_entries": 0}}) - # Step 3: People DB — no URL match - step3_response = _make_response( - { - "people": [ - { - "id": "person_1", - "first_name": "Bob", - "last_name": "Jones", - "linkedin_url": "https://www.linkedin.com/in/bob", - } - ] - } - ) - client._client.request.side_effect = [ - step1_response, - step2_response, - step3_response, - ] + client._client.request.side_effect = [step1_response, step2_response] result = await client.find_contact_by_linkedin_url( "https://www.linkedin.com/in/alice", person_name="Alice Smith", - create_if_missing=True, ) assert result is None diff --git a/uv.lock b/uv.lock index d08fb05..e693ac7 100644 --- a/uv.lock +++ b/uv.lock @@ -407,7 +407,7 @@ wheels = [ [[package]] name = "qodev-apollo-api" -version = "0.3.2" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "httpx" },