361 lines
16 KiB
Python
361 lines
16 KiB
Python
"""
|
|
Copyright 2024-2026 ReplyPier
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
"""
|
|
|
|
import json
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
import app.crm.base as crm_base
|
|
from app.core.config import settings
|
|
from app.crm.base import CrmAuthError, LeadPayload, OAuthTokens
|
|
from app.crm.pipedrive import PipedriveAdapter
|
|
from app.crm.registry import get_adapter
|
|
|
|
API_DOMAIN = "https://acme.pipedrive.com"
|
|
|
|
|
|
@pytest.fixture
|
|
def adapter():
|
|
return PipedriveAdapter()
|
|
|
|
|
|
@pytest.fixture
|
|
def tokens():
|
|
return OAuthTokens(access_token="at", refresh_token="rt", api_domain=API_DOMAIN,
|
|
external_account_id="777", display_name="Acme")
|
|
|
|
|
|
@pytest.fixture
|
|
def payload():
|
|
return LeadPayload(
|
|
lead_response_id=uuid.uuid4(),
|
|
email="jane@acme.com", name="Jane Doe", phone="+15550100",
|
|
summary="Wants a demo", custom_fields={"Team size": "40"},
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def http(monkeypatch):
|
|
"""MockTransport routed by url substring; records requests in order."""
|
|
recorded = []
|
|
responses = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
recorded.append(request)
|
|
for fragment, (status, body) in responses.items():
|
|
if fragment in str(request.url):
|
|
return httpx.Response(status, json=body)
|
|
return httpx.Response(200, json={})
|
|
|
|
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
monkeypatch.setattr(crm_base, "_http_client", client)
|
|
return {"requests": recorded, "respond": responses}
|
|
|
|
|
|
class TestRegistry:
|
|
|
|
def test_both_adapters_resolve(self):
|
|
assert get_adapter("hubspot") is not None
|
|
assert get_adapter("pipedrive") is not None
|
|
assert get_adapter("salesforce") is None # phase 2
|
|
|
|
|
|
class TestTokenFlows:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exchange_code_captures_api_domain_and_company(self, adapter, http,
|
|
monkeypatch):
|
|
monkeypatch.setattr(settings, "PIPEDRIVE_CLIENT_ID", "cid")
|
|
monkeypatch.setattr(settings, "PIPEDRIVE_CLIENT_SECRET", "sek")
|
|
http["respond"]["oauth.pipedrive.com/oauth/token"] = (200, {
|
|
"access_token": "new-at", "refresh_token": "new-rt",
|
|
"expires_in": 3600, "api_domain": API_DOMAIN})
|
|
http["respond"]["users/me"] = (200, {
|
|
"data": {"company_id": 777, "company_name": "Acme"}})
|
|
|
|
tokens = await adapter.exchange_code("code-1", "https://app/cb")
|
|
|
|
assert tokens.api_domain == API_DOMAIN
|
|
assert tokens.external_account_id == "777"
|
|
assert tokens.display_name == "Acme"
|
|
# Sliding refresh expiry stamped ~60 days out.
|
|
assert tokens.refresh_token_expires_at > datetime.now(timezone.utc) + timedelta(days=59)
|
|
# Token endpoint used HTTP Basic (client_id:secret), not body params.
|
|
token_request = http["requests"][0]
|
|
assert token_request.headers["Authorization"].startswith("Basic ")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejects_non_pipedrive_api_domain(self, adapter, http, monkeypatch):
|
|
monkeypatch.setattr(settings, "PIPEDRIVE_CLIENT_ID", "cid")
|
|
monkeypatch.setattr(settings, "PIPEDRIVE_CLIENT_SECRET", "sek")
|
|
# A non-pipedrive api_domain must not become our request base (SSRF guard).
|
|
http["respond"]["oauth.pipedrive.com/oauth/token"] = (200, {
|
|
"access_token": "a", "refresh_token": "r", "expires_in": 3600,
|
|
"api_domain": "https://evil.example.com"})
|
|
http["respond"]["users/me"] = (200, {"data": {}})
|
|
tokens = await adapter.exchange_code("code-1", "https://app/cb")
|
|
assert tokens.api_domain is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_slides_window_and_keeps_identity(self, adapter, http, tokens):
|
|
http["respond"]["oauth/token"] = (200, {
|
|
"access_token": "at2", "expires_in": 3600})
|
|
refreshed = await adapter.refresh_tokens(tokens)
|
|
assert refreshed.access_token == "at2"
|
|
assert refreshed.refresh_token == "rt" # not rotated → kept
|
|
assert refreshed.api_domain == API_DOMAIN # kept from old tokens
|
|
assert refreshed.external_account_id == "777"
|
|
assert refreshed.refresh_token_expires_at is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_rejection_is_auth_error(self, adapter, http, tokens):
|
|
http["respond"]["oauth/token"] = (400, {"error": "invalid_grant"})
|
|
with pytest.raises(CrmAuthError):
|
|
await adapter.refresh_tokens(tokens)
|
|
|
|
|
|
class TestPushLead:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_new_person_new_lead_with_note(self, adapter, http, tokens, payload):
|
|
http["respond"]["persons/search"] = (200, {"data": {"items": []}})
|
|
http["respond"]["api/v2/persons"] = (201, {"data": {"id": 42}})
|
|
# Fragment order matters: the open-lead listing (GET …/leads?person_id=…)
|
|
# must match before the creation POST's fragment.
|
|
http["respond"]["api/v1/leads?"] = (200, {"data": []})
|
|
http["respond"]["api/v1/leads"] = (201, {"data": {"id": "lead-1"}})
|
|
http["respond"]["api/v1/notes"] = (201, {"data": {"id": 9}})
|
|
|
|
result = await adapter.push_lead(tokens, payload)
|
|
|
|
assert result.ok and result.action == "created"
|
|
assert result.contact_id == "42"
|
|
assert result.secondary_id == "lead-1"
|
|
assert result.record_url == f"{API_DOMAIN}/person/42"
|
|
|
|
urls = [str(r.url) for r in http["requests"]]
|
|
assert "persons/search" in urls[0]
|
|
assert urls[1].endswith("/api/v2/persons")
|
|
# Lead listing (open-lead check) precedes lead creation.
|
|
assert "api/v1/leads?" in urls[2]
|
|
assert urls[3].endswith("/api/v1/leads")
|
|
assert urls[4].endswith("/api/v1/notes")
|
|
|
|
person = json.loads(http["requests"][1].content)
|
|
assert person["emails"][0]["value"] == "jane@acme.com"
|
|
note = json.loads(http["requests"][4].content)
|
|
assert note["lead_id"] == "lead-1"
|
|
assert "<b>Team size:</b> 40" in note["content"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_company_links_organization(self, adapter, http, tokens):
|
|
payload = LeadPayload(lead_response_id=uuid.uuid4(), email="jane@acme.com",
|
|
name="Jane Doe", company="Acme Inc")
|
|
http["respond"]["organizations/search"] = (200, {"data": {"items": []}})
|
|
http["respond"]["api/v2/organizations"] = (201, {"data": {"id": 77}})
|
|
http["respond"]["persons/search"] = (200, {"data": {"items": []}})
|
|
http["respond"]["api/v2/persons"] = (201, {"data": {"id": 42}})
|
|
http["respond"]["api/v1/leads?"] = (200, {"data": []})
|
|
http["respond"]["api/v1/leads"] = (201, {"data": {"id": "lead-1"}})
|
|
|
|
result = await adapter.push_lead(tokens, payload)
|
|
|
|
assert result.ok
|
|
# Org found-or-created, then the person body carries org_id.
|
|
org_create = next(r for r in http["requests"]
|
|
if r.method == "POST" and r.url.path.endswith("/api/v2/organizations"))
|
|
assert json.loads(org_create.content)["name"] == "Acme Inc"
|
|
person_create = next(r for r in http["requests"]
|
|
if r.method == "POST" and r.url.path.endswith("/api/v2/persons"))
|
|
assert json.loads(person_create.content)["org_id"] == 77
|
|
# The Lead carries its own organization_id (separate from the person's).
|
|
lead_create = next(r for r in http["requests"]
|
|
if r.method == "POST" and r.url.path.endswith("/api/v1/leads"))
|
|
assert json.loads(lead_create.content)["organization_id"] == 77
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_company_skips_org_calls(self, adapter, http, tokens, payload):
|
|
# payload has no company → no organization API calls at all.
|
|
http["respond"]["persons/search"] = (200, {"data": {"items": []}})
|
|
http["respond"]["api/v2/persons"] = (201, {"data": {"id": 42}})
|
|
http["respond"]["api/v1/leads?"] = (200, {"data": []})
|
|
http["respond"]["api/v1/leads"] = (201, {"data": {"id": "lead-1"}})
|
|
await adapter.push_lead(tokens, payload)
|
|
assert not any("organizations" in str(r.url) for r in http["requests"])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_existing_person_gets_blanks_filled_only(self, adapter, http,
|
|
tokens, payload):
|
|
http["respond"]["persons/search"] = (200, {"data": {"items": [
|
|
{"item": {"id": 42, "name": "Jane Doe", "phones": []}}]}})
|
|
http["respond"]["api/v2/persons/42"] = (200, {"data": {"id": 42}})
|
|
http["respond"]["api/v1/leads?"] = (200, {"data": []})
|
|
http["respond"]["api/v1/leads"] = (201, {"data": {"id": "lead-1"}})
|
|
|
|
result = await adapter.push_lead(tokens, payload)
|
|
assert result.ok and result.action == "updated"
|
|
|
|
patch = next(r for r in http["requests"] if r.method == "PATCH")
|
|
body = json.loads(patch.content)
|
|
assert "name" not in body # already set — not clobbered
|
|
assert body["phones"][0]["value"] == "+15550100" # blank → filled
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_lead_skips_lead_creation(self, adapter, http, tokens, payload):
|
|
http["respond"]["persons/search"] = (200, {"data": {"items": [
|
|
{"item": {"id": 42, "name": "Jane Doe", "phones": [{"value": "+1"}]}}]}})
|
|
http["respond"]["api/v1/leads?"] = (200, {"data": [{"id": "existing-lead"}]})
|
|
|
|
result = await adapter.push_lead(tokens, payload)
|
|
|
|
assert result.ok and result.secondary_id is None
|
|
# No POST to /api/v1/leads and no note.
|
|
posts = [r for r in http["requests"] if r.method == "POST"]
|
|
assert posts == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lead_listing_failure_retries_instead_of_duplicating(
|
|
self, adapter, http, tokens, payload):
|
|
http["respond"]["persons/search"] = (200, {"data": {"items": [
|
|
{"item": {"id": 42, "name": "Jane Doe", "phones": [{"value": "+1"}]}}]}})
|
|
http["respond"]["api/v1/leads?"] = (500, {"error": "flaky"})
|
|
|
|
result = await adapter.push_lead(tokens, payload)
|
|
|
|
assert not result.ok and result.retryable
|
|
# No lead was blindly created while the open-lead check was failing.
|
|
assert [r for r in http["requests"] if r.method == "POST"] == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_401_flags_auth_failure(self, adapter, http, tokens, payload):
|
|
http["respond"]["persons/search"] = (401, {"error": "unauthorized"})
|
|
result = await adapter.push_lead(tokens, payload)
|
|
assert not result.ok and result.auth_failed
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_person_create_429_is_retryable(self, adapter, http, monkeypatch,
|
|
tokens, payload):
|
|
def handler(request):
|
|
if "persons/search" in str(request.url):
|
|
return httpx.Response(200, json={"data": {"items": []}})
|
|
return httpx.Response(429, headers={"Retry-After": "5"}, json={})
|
|
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
monkeypatch.setattr(crm_base, "_http_client", client)
|
|
|
|
result = await adapter.push_lead(tokens, payload)
|
|
assert not result.ok and result.retryable
|
|
assert result.retry_after_seconds == 5
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_network_error_is_retryable(self, adapter, monkeypatch, tokens, payload):
|
|
def handler(request):
|
|
raise httpx.ConnectError("connection refused")
|
|
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
monkeypatch.setattr(crm_base, "_http_client", client)
|
|
|
|
result = await adapter.push_lead(tokens, payload)
|
|
assert not result.ok and result.retryable
|
|
|
|
|
|
class TestConnectionLifecycle:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_test_connection_ok(self, adapter, http, tokens):
|
|
http["respond"]["users/me"] = (200, {"data": {"company_name": "Acme"}})
|
|
info = await adapter.test_connection(tokens)
|
|
assert info == {"ok": True, "account_name": "Acme", "error": None}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revoke_never_raises(self, adapter, monkeypatch, tokens):
|
|
def handler(request):
|
|
raise httpx.ConnectError("connection refused")
|
|
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
monkeypatch.setattr(crm_base, "_http_client", client)
|
|
await adapter.revoke(tokens) # must not raise
|
|
|
|
|
|
FAKE_OAUTH_BASE = "http://127.0.0.1:14020/pipedrive-oauth"
|
|
FAKE_API_BASE = "http://127.0.0.1:14020/pipedrive"
|
|
|
|
|
|
@pytest.fixture
|
|
def reload_pipedrive(monkeypatch):
|
|
"""Re-import app.crm.pipedrive under the given Pipedrive bases, then restore it."""
|
|
import importlib
|
|
import app.crm.pipedrive as pipedrive_module
|
|
|
|
names = ("PIPEDRIVE_OAUTH_BASE", "PIPEDRIVE_API_BASE")
|
|
|
|
def _reload(**env):
|
|
for name in names:
|
|
monkeypatch.delenv(name, raising=False)
|
|
for name, value in env.items():
|
|
monkeypatch.setenv(name, value)
|
|
return importlib.reload(pipedrive_module)
|
|
|
|
yield _reload
|
|
for name in names:
|
|
monkeypatch.delenv(name, raising=False)
|
|
importlib.reload(pipedrive_module)
|
|
|
|
|
|
class TestHostOverrides:
|
|
|
|
def test_urls_default_to_pipedrive(self, reload_pipedrive, monkeypatch):
|
|
pipedrive = reload_pipedrive()
|
|
monkeypatch.setattr(settings, "PIPEDRIVE_CLIENT_ID", "cid")
|
|
assert pipedrive.AUTHORIZE_URL == "https://oauth.pipedrive.com/oauth/authorize"
|
|
assert pipedrive.TOKEN_URL == "https://oauth.pipedrive.com/oauth/token"
|
|
assert pipedrive.REVOKE_URL == "https://oauth.pipedrive.com/oauth/revoke"
|
|
assert pipedrive.PipedriveAdapter().get_authorize_url("s", "https://app/cb").startswith(
|
|
"https://oauth.pipedrive.com/oauth/authorize?client_id=cid&")
|
|
# Without the override only https pipedrive.com hosts are trusted.
|
|
assert pipedrive._validated_api_domain(API_DOMAIN) == API_DOMAIN
|
|
assert pipedrive._validated_api_domain(FAKE_API_BASE) is None
|
|
|
|
def test_bases_are_overridable(self, reload_pipedrive):
|
|
pipedrive = reload_pipedrive(PIPEDRIVE_OAUTH_BASE=FAKE_OAUTH_BASE + "/",
|
|
PIPEDRIVE_API_BASE=FAKE_API_BASE + "/")
|
|
assert pipedrive.AUTHORIZE_URL == f"{FAKE_OAUTH_BASE}/oauth/authorize"
|
|
assert pipedrive.TOKEN_URL == f"{FAKE_OAUTH_BASE}/oauth/token"
|
|
assert pipedrive.REVOKE_URL == f"{FAKE_OAUTH_BASE}/oauth/revoke"
|
|
assert pipedrive._validated_api_domain(FAKE_API_BASE + "/") == FAKE_API_BASE
|
|
# The override widens the guard by exactly one origin.
|
|
assert pipedrive._validated_api_domain(API_DOMAIN) == API_DOMAIN
|
|
assert pipedrive._validated_api_domain("http://127.0.0.1:14020/other") is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_install_flow_reaches_the_override(self, reload_pipedrive, http, monkeypatch):
|
|
pipedrive = reload_pipedrive(PIPEDRIVE_OAUTH_BASE=FAKE_OAUTH_BASE,
|
|
PIPEDRIVE_API_BASE=FAKE_API_BASE)
|
|
monkeypatch.setattr(settings, "PIPEDRIVE_CLIENT_ID", "cid")
|
|
monkeypatch.setattr(settings, "PIPEDRIVE_CLIENT_SECRET", "sek")
|
|
http["respond"]["/oauth/token"] = (200, {
|
|
"access_token": "at", "refresh_token": "rt", "expires_in": 3600,
|
|
"api_domain": FAKE_API_BASE})
|
|
http["respond"]["users/me"] = (200, {
|
|
"data": {"company_id": 777, "company_name": "Acme"}})
|
|
|
|
tokens = await pipedrive.PipedriveAdapter().exchange_code("code-1", "https://app/cb")
|
|
|
|
assert [str(r.url) for r in http["requests"]] == [
|
|
f"{FAKE_OAUTH_BASE}/oauth/token", f"{FAKE_API_BASE}/api/v1/users/me"]
|
|
assert tokens.api_domain == FAKE_API_BASE
|
|
assert tokens.external_account_id == "777"
|