feat(customer): 网站客户身份签发与长期负责人绑定

可信客户身份由站点后端签发,长期负责人与本次接待人分开保存。
This commit is contained in:
WangShen007 committed 2026-10-10 21:15:00 +08:00
1 parent 2554eb7bc7
commit ad90b830c4
18 files changed
+3123

No files matched your search

View File
Whitespace-only changes.
+216
View File
@@ -0,0 +1,216 @@
"""
Copyright 2024-2026 ChatterMate
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
import httpx
import pytest
import app.crm.base as crm_base
from app.core.config import settings
from app.crm.base import CrmAuthError, CrmTransientError, LeadPayload, OAuthTokens
from app.crm.hubspot import HubSpotAdapter
@pytest.fixture
def adapter():
return HubSpotAdapter()
@pytest.fixture
def tokens():
return OAuthTokens(access_token="at", refresh_token="rt",
external_account_id="12345", display_name="acme.com")
@pytest.fixture
def payload():
return LeadPayload(
lead_response_id=uuid.uuid4(),
email="jane@acme.com", name="Jane Doe", company="Acme",
phone="+15550100", summary="Wants a demo",
custom_fields={"Team size": "40"},
)
@pytest.fixture
def http(monkeypatch):
"""Route CRM HTTP through a MockTransport; returns the recorded requests
plus a `respond` dict mapping url-substring -> (status, json_body)."""
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 TestAuthorizeUrl:
def test_contains_client_id_scope_state(self, adapter, monkeypatch):
monkeypatch.setattr(settings, "HUBSPOT_CLIENT_ID", "cid-1")
url = adapter.get_authorize_url("state-xyz", "https://app/cb")
assert url.startswith("https://app.hubspot.com/oauth/authorize?")
assert "client_id=cid-1" in url
assert "state=state-xyz" in url
assert "crm.objects.contacts.write" in url
class TestTokenFlows:
@pytest.mark.asyncio
async def test_exchange_code_returns_portal_identity(self, adapter, http):
http["respond"]["oauth/v1/token"] = (200, {
"access_token": "new-at", "refresh_token": "new-rt", "expires_in": 1800})
http["respond"]["oauth/v1/access-tokens/new-at"] = (200, {
"hub_id": 999, "hub_domain": "acme.hubspot.com"})
tokens = await adapter.exchange_code("code-1", "https://app/cb")
assert tokens.access_token == "new-at"
assert tokens.external_account_id == "999"
assert tokens.display_name == "acme.hubspot.com"
assert tokens.expires_at is not None
@pytest.mark.asyncio
async def test_refresh_rejection_is_auth_error(self, adapter, http, tokens):
http["respond"]["oauth/v1/token"] = (400, {"error": "invalid_grant"})
with pytest.raises(CrmAuthError):
await adapter.refresh_tokens(tokens)
@pytest.mark.asyncio
async def test_refresh_5xx_is_transient(self, adapter, http, tokens):
http["respond"]["oauth/v1/token"] = (502, {"error": "bad gateway"})
with pytest.raises(CrmTransientError):
await adapter.refresh_tokens(tokens)
@pytest.mark.asyncio
async def test_refresh_keeps_identity_and_refresh_token(self, adapter, http, tokens):
http["respond"]["oauth/v1/token"] = (200, {"access_token": "at2", "expires_in": 1800})
refreshed = await adapter.refresh_tokens(tokens)
assert refreshed.access_token == "at2"
# HubSpot doesn't rotate refresh tokens — the old one stays valid.
assert refreshed.refresh_token == "rt"
assert refreshed.external_account_id == "12345"
class TestPushLead:
@pytest.mark.asyncio
async def test_created_contact_with_note(self, adapter, http, tokens, payload):
http["respond"]["batch/upsert"] = (200, {
"results": [{"id": "301", "new": True}]})
http["respond"]["objects/notes"] = (201, {"id": "n1"})
result = await adapter.push_lead(tokens, payload)
assert result.ok and result.action == "created"
assert result.contact_id == "301"
assert result.record_url == "https://app.hubspot.com/contacts/12345/record/0-1/301"
upsert = json.loads(http["requests"][0].content)
props = upsert["inputs"][0]["properties"]
assert upsert["inputs"][0]["idProperty"] == "email"
assert props["firstname"] == "Jane" and props["lastname"] == "Doe"
assert props["lifecyclestage"] == "lead"
note = json.loads(http["requests"][1].content)
assert "<b>Team size:</b> 40" in note["properties"]["hs_note_body"]
assert note["associations"][0]["to"]["id"] == "301"
assert note["associations"][0]["types"][0]["associationTypeId"] == 202
@pytest.mark.asyncio
async def test_existing_contact_is_updated(self, adapter, http, tokens, payload):
http["respond"]["batch/upsert"] = (200, {"results": [{"id": "301", "new": False}]})
result = await adapter.push_lead(tokens, payload)
assert result.ok and result.action == "updated"
@pytest.mark.asyncio
async def test_note_failure_does_not_fail_push(self, adapter, http, tokens, payload):
http["respond"]["batch/upsert"] = (200, {"results": [{"id": "301", "new": True}]})
http["respond"]["objects/notes"] = (500, {"error": "boom"})
result = await adapter.push_lead(tokens, payload)
assert result.ok
@pytest.mark.asyncio
async def test_401_flags_auth_failure(self, adapter, http, tokens, payload):
http["respond"]["batch/upsert"] = (401, {"category": "EXPIRED_AUTHENTICATION"})
result = await adapter.push_lead(tokens, payload)
assert not result.ok and result.auth_failed and not result.retryable
@pytest.mark.asyncio
async def test_429_is_retryable_with_retry_after(self, adapter, http, monkeypatch,
tokens, payload):
def handler(request):
return httpx.Response(429, headers={"Retry-After": "11"}, 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 == 11
@pytest.mark.asyncio
async def test_5xx_is_retryable(self, adapter, http, tokens, payload):
http["respond"]["batch/upsert"] = (503, {"error": "unavailable"})
result = await adapter.push_lead(tokens, payload)
assert not result.ok and result.retryable and not result.auth_failed
@pytest.mark.asyncio
async def test_validation_4xx_is_permanent(self, adapter, http, tokens, payload):
http["respond"]["batch/upsert"] = (400, {"category": "VALIDATION_ERROR"})
result = await adapter.push_lead(tokens, payload)
assert not result.ok and not result.retryable and not result.auth_failed
@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"]["access-tokens/at"] = (200, {"hub_domain": "acme.hubspot.com"})
info = await adapter.test_connection(tokens)
assert info == {"ok": True, "account_name": "acme.hubspot.com", "error": None}
@pytest.mark.asyncio
async def test_test_connection_dead_token(self, adapter, http, tokens):
http["respond"]["access-tokens/at"] = (401, {})
info = await adapter.test_connection(tokens)
assert info["ok"] is False
@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
+168
View File
@@ -0,0 +1,168 @@
"""
Copyright 2024-2026 ChatterMate
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 uuid
from app.crm.base import LeadPayload, summarize_provider_error
from app.crm.mapping import (
build_customer_payload, build_lead_payload, build_note_body, split_name,
)
from app.models.customer import Customer
from app.models.lead_capture import LeadCaptureConfig, LeadCaptureResponse
def _response(**field_values) -> LeadCaptureResponse:
response = LeadCaptureResponse(field_values=field_values, summary="Wants a demo")
response.id = uuid.uuid4()
return response
def _config_with_custom_label() -> LeadCaptureConfig:
return LeadCaptureConfig(fields=[
{"key": "email", "standard": True, "enabled": True},
{"key": "custom_1", "standard": False, "label": "Team size", "enabled": True},
])
class TestBuildLeadPayload:
def test_standard_and_custom_fields(self):
payload = build_lead_payload(
_response(email="Jane@Acme.COM", name="Jane Doe", company="Acme",
phone="+15550100", custom_1="40"),
_config_with_custom_label(),
None,
)
assert payload.email == "jane@acme.com" # normalized for dedupe
assert payload.name == "Jane Doe"
assert payload.company == "Acme"
assert payload.phone == "+15550100"
assert payload.custom_fields == {"Team size": "40"}
assert payload.summary == "Wants a demo"
def test_customer_backfills_blanks(self):
customer = Customer(email="known@acme.com", full_name="Known Name",
phone="+15550999",
lead_source={"page_url": "https://acme.com/pricing"})
payload = build_lead_payload(_response(email=""), None, customer)
assert payload.email == "known@acme.com"
assert payload.name == "Known Name"
assert payload.phone == "+15550999"
assert payload.source_url == "https://acme.com/pricing"
def test_unlabeled_custom_field_keeps_its_key(self):
payload = build_lead_payload(_response(custom_2="yes"), None, None)
assert payload.custom_fields == {"custom_2": "yes"}
def test_empty_custom_values_dropped(self):
payload = build_lead_payload(_response(custom_1="", custom_2=None), None, None)
assert payload.custom_fields == {}
class TestBuildCustomerPayload:
def test_from_customer_fields(self):
customer = Customer(
id=uuid.uuid4(), email="Nadia@Example.COM", full_name="Nadia Rahman",
phone="+15550100", meta_data={"plan": "trial", "empty": ""},
lead_source={"page_url": "https://acme.com/pricing"})
payload = build_customer_payload(customer)
assert payload.email == "nadia@example.com" # normalized for dedupe
assert payload.name == "Nadia Rahman"
assert payload.phone == "+15550100"
assert payload.custom_fields == {"plan": "trial"} # blank dropped
assert payload.source_url == "https://acme.com/pricing"
def test_handles_missing_optional_fields(self):
customer = Customer(id=uuid.uuid4(), email="x@y.com")
payload = build_customer_payload(customer)
assert payload.email == "x@y.com"
assert payload.custom_fields == {}
assert payload.source_url is None
assert payload.summary is None
def test_summary_passthrough(self):
customer = Customer(id=uuid.uuid4(), email="x@y.com")
payload = build_customer_payload(customer, summary="Wants a demo")
assert payload.summary == "Wants a demo"
class TestSummarizeProviderError:
def test_strips_html_collapses_and_bounds(self):
s = summarize_provider_error("<b>Bad</b>\n request " + "x" * 500)
assert "<b>" not in s
assert "\n" not in s and " " not in s
assert len(s) <= 200
def test_handles_none(self):
assert summarize_provider_error(None) == ""
class TestSplitName:
def test_first_and_last(self):
assert split_name("Jane Doe") == ("Jane", "Doe")
def test_multi_part_puts_tail_as_last(self):
assert split_name("Jane van der Berg") == ("Jane van der", "Berg")
def test_single_name(self):
assert split_name("Cher") == ("Cher", "")
def test_empty(self):
assert split_name(None) == ("", "")
assert split_name(" ") == ("", "")
class TestBuildNoteBody:
def test_full_note_is_labelled_html(self):
body = build_note_body(LeadPayload(
lead_response_id=uuid.uuid4(), email="a@b.c",
summary="40-person fintech, wants a demo",
custom_fields={"Team size": "40"},
source_url="https://acme.com/pricing",
))
# HTML with <br> so the summary is a distinct, labelled line (notes render as HTML).
assert "<br>" in body
assert "<b>AI summary:</b> 40-person fintech, wants a demo" in body
assert "<b>Team size:</b> 40" in body
assert 'Captured on: <a href="https://acme.com/pricing">' in body
def test_minimal_note(self):
body = build_note_body(LeadPayload(lead_response_id=uuid.uuid4(), email="a@b.c"))
assert body == "<b>Lead captured by ChatterMate</b>"
def test_non_http_source_url_is_not_a_link(self):
body = build_note_body(LeadPayload(
lead_response_id=uuid.uuid4(), email="a@b.c",
source_url="javascript:alert(1)"))
assert "<a href" not in body # no clickable javascript: link
assert "Captured on:" in body
def test_http_source_url_is_a_link(self):
body = build_note_body(LeadPayload(
lead_response_id=uuid.uuid4(), email="a@b.c",
source_url="https://acme.com/x"))
assert '<a href="https://acme.com/x">' in body
def test_dynamic_values_are_escaped(self):
body = build_note_body(LeadPayload(
lead_response_id=uuid.uuid4(), email="a@b.c",
summary="wants <script>alert(1)</script> & more"))
assert "<script>" not in body
assert "&lt;script&gt;" in body and "&amp;" in body
+360
View File
@@ -0,0 +1,360 @@
"""
Copyright 2024-2026 ChatterMate
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"