Files
replypier/backend/tests/crm/test_crm_hubspot.py
T
WangShen007 20fef2b9f3 chore: settle product naming across app, widget and deployments
One word everywhere: ReplyPier in UI copy, package and image names, the
replypier.site domains (app/api/docs/help), REPLYPIER_* settings and the
--rp-* design tokens.

- public widget API is window.ReplyPier with replypierId / replypierBaseUrl /
  replypierConfig / replypierToken, loader at webclient/replypier.min.js;
  both bundles rebuilt from source
- NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and the demo runtime
  config rewritten; the demo config ships no third-party keys
- migration adds the ReplyPier managed model value to the AI model enum
- local compose project, database, role, volume and Docker network carry the
  same name
2026-10-26 00:57:22 +08:00

217 lines
8.8 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
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