Files

342 lines
14 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.
"""
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock
import pytest
from app.crm.base import CrmPushResult, OAuthTokens
from app.services.conversation_token import VERIFIED_SCOPE
from app.crm.hubspot import HubSpotAdapter
from app.models.crm import (
CrmConnectionStatus, CrmCustomerSync, CrmSyncJob, CrmSyncJobStatus,
)
from app.models.customer import Customer
from app.models.lead_capture import LeadCaptureConfig, LeadCaptureResponse
from app.repositories.crm import (
CrmConnectionRepository, CrmCustomerSyncRepository,
)
from app.services.crm_sync import (
CrmManualSyncError, enqueue_crm_sync, process_job, sync_customer_to_crm,
)
from app.services.lead_capture import record_lead_capture
@pytest.fixture(autouse=True)
def _allow_crm_feature(monkeypatch):
"""Default the plan gate open so these tests exercise the sync logic, not
the enterprise plan machinery, regardless of whether the enterprise module
is installed. Gate-specific tests override this with False."""
monkeypatch.setattr("app.services.crm_sync.feature_allowed", lambda *a, **k: True)
@pytest.fixture
def config(db, test_agent):
cfg = LeadCaptureConfig(
agent_id=test_agent.id, enabled=True, require_consent=True,
crm_sync_target="hubspot",
fields=[{"key": "email", "standard": True, "enabled": True}],
)
db.add(cfg)
db.commit()
db.refresh(cfg)
return cfg
@pytest.fixture
def connection(db, test_organization):
return CrmConnectionRepository(db).create_or_update(
organization_id=test_organization.id,
provider="hubspot",
external_account_id="12345",
credentials={"access_token": "at", "refresh_token": "rt", "api_domain": None},
access_token_expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
)
@pytest.fixture
def lead(db, test_organization, test_agent, test_customer):
response = LeadCaptureResponse(
organization_id=test_organization.id, agent_id=test_agent.id,
customer_id=test_customer.id,
field_values={"email": "lead@example.com", "name": "Lead Person"},
summary="Wants a demo", consent=True, qualified=True,
)
db.add(response)
db.commit()
db.refresh(response)
return response
OK_RESULT = CrmPushResult(ok=True, action="created", contact_id="301",
record_url="https://app.hubspot.com/contacts/1/record/0-1/301")
class TestEnqueue:
def test_no_target_no_job(self, db, config, lead, connection):
config.crm_sync_target = "none"
db.commit()
assert enqueue_crm_sync(db, config, lead) is None
def test_no_connection_no_job(self, db, config, lead):
assert enqueue_crm_sync(db, config, lead) is None
def test_enqueues_once(self, db, config, lead, connection):
job = enqueue_crm_sync(db, config, lead)
assert job is not None and job.provider == "hubspot"
assert enqueue_crm_sync(db, config, lead) is None # idempotent
def test_plan_gate_fails_closed(self, db, config, lead, connection, monkeypatch):
monkeypatch.setattr("app.services.crm_sync.feature_allowed",
lambda *a, **k: False)
assert enqueue_crm_sync(db, config, lead) is None
class TestProcessJob:
@pytest.mark.asyncio
async def test_happy_path_completes_with_result(self, db, config, lead,
connection, monkeypatch):
monkeypatch.setattr(HubSpotAdapter, "push_lead", AsyncMock(return_value=OK_RESULT))
job = enqueue_crm_sync(db, config, lead)
job = await process_job(db, job)
assert job.status == CrmSyncJobStatus.COMPLETED.value
assert job.result["contact_id"] == "301"
assert job.connection_id == connection.id
@pytest.mark.asyncio
async def test_connection_missing_is_terminal(self, db, config, lead, connection,
monkeypatch):
job = enqueue_crm_sync(db, config, lead)
CrmConnectionRepository(db).set_status(connection,
CrmConnectionStatus.REVOKED.value)
job = await process_job(db, job)
assert job.status == CrmSyncJobStatus.FAILED.value
assert job.last_error == "no_connection"
@pytest.mark.asyncio
async def test_downgraded_org_is_skipped(self, db, config, lead, connection,
monkeypatch):
job = enqueue_crm_sync(db, config, lead)
monkeypatch.setattr("app.services.crm_sync.feature_allowed",
lambda *a, **k: False)
job = await process_job(db, job)
assert job.status == CrmSyncJobStatus.SKIPPED.value
@pytest.mark.asyncio
async def test_retryable_failure_backs_off(self, db, config, lead, connection,
monkeypatch):
monkeypatch.setattr(HubSpotAdapter, "push_lead", AsyncMock(
return_value=CrmPushResult(ok=False, error="HTTP 502", retryable=True)))
job = enqueue_crm_sync(db, config, lead)
job = await process_job(db, job)
assert job.status == CrmSyncJobStatus.PENDING.value
assert job.attempts == 1
@pytest.mark.asyncio
async def test_persistent_auth_failure_kills_connection(
self, db, config, lead, connection, monkeypatch):
monkeypatch.setattr(HubSpotAdapter, "push_lead", AsyncMock(
return_value=CrmPushResult(ok=False, error="HTTP 401", auth_failed=True)))
monkeypatch.setattr(HubSpotAdapter, "refresh_tokens", AsyncMock(
return_value=OAuthTokens(
access_token="at2", refresh_token="rt",
expires_at=datetime.now(timezone.utc) + timedelta(minutes=30))))
job = enqueue_crm_sync(db, config, lead)
job = await process_job(db, job)
assert job.status == CrmSyncJobStatus.FAILED.value
assert job.last_error.startswith("auth:")
db.refresh(connection)
assert connection.status == CrmConnectionStatus.EXPIRED.value
@pytest.mark.asyncio
async def test_auth_failure_recovers_after_forced_refresh(
self, db, config, lead, connection, monkeypatch):
push = AsyncMock(side_effect=[
CrmPushResult(ok=False, error="HTTP 401", auth_failed=True),
OK_RESULT,
])
monkeypatch.setattr(HubSpotAdapter, "push_lead", push)
monkeypatch.setattr(HubSpotAdapter, "refresh_tokens", AsyncMock(
return_value=OAuthTokens(
access_token="at2", refresh_token="rt",
expires_at=datetime.now(timezone.utc) + timedelta(minutes=30))))
job = enqueue_crm_sync(db, config, lead)
job = await process_job(db, job)
assert job.status == CrmSyncJobStatus.COMPLETED.value
assert push.await_count == 2
class TestLeadCaptureHook:
def test_recorded_lead_is_queued(self, db, config, connection,
test_organization, test_agent, test_customer,
monkeypatch):
response = record_lead_capture(
db, config,
organization_id=test_organization.id,
agent_id=test_agent.id,
customer_id=test_customer.id,
session_id=None,
lead_data={"email": "hooked@example.com"},
summary="s", consent=True,
scope=VERIFIED_SCOPE,
)
assert response is not None
jobs = db.query(CrmSyncJob).filter_by(lead_response_id=response.id).all()
assert len(jobs) == 1 and jobs[0].provider == "hubspot"
def test_enqueue_crash_never_breaks_capture(self, db, config, connection,
test_organization, test_agent,
test_customer, monkeypatch):
monkeypatch.setattr("app.services.crm_sync.enqueue_crm_sync",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")))
response = record_lead_capture(
db, config,
organization_id=test_organization.id,
agent_id=test_agent.id,
customer_id=test_customer.id,
session_id=None,
lead_data={"email": "hooked2@example.com"},
summary="s", consent=True,
scope=VERIFIED_SCOPE,
)
assert response is not None # lead recorded despite the CRM crash
class TestAutoSyncRecordsCustomerLink:
@pytest.mark.asyncio
async def test_completed_job_records_customer_sync(self, db, config, lead,
connection, monkeypatch):
monkeypatch.setattr(HubSpotAdapter, "push_lead", AsyncMock(return_value=OK_RESULT))
job = enqueue_crm_sync(db, config, lead)
await process_job(db, job)
# The People drawer reads this per-person link.
link = db.query(CrmCustomerSync).filter_by(
customer_id=lead.customer_id, provider="hubspot").first()
assert link is not None
assert link.record_url == OK_RESULT.record_url
@pytest.fixture
def synced_customer(db, test_organization):
cust = Customer(organization_id=test_organization.id,
email="nadia@example.com", full_name="Nadia Rahman")
db.add(cust)
db.commit()
db.refresh(cust)
return cust
class TestManualSync:
@pytest.mark.asyncio
async def test_pushes_and_records_link(self, db, connection, synced_customer,
monkeypatch):
monkeypatch.setattr(HubSpotAdapter, "push_lead", AsyncMock(return_value=OK_RESULT))
records = await sync_customer_to_crm(db, synced_customer)
assert len(records) == 1 and records[0].provider == "hubspot"
link = CrmCustomerSyncRepository(db).list_for_customer(synced_customer.id)
assert link and link[0].record_url == OK_RESULT.record_url
@pytest.mark.asyncio
async def test_pushes_full_captured_attributes_not_just_columns(
self, db, connection, test_organization, test_agent, monkeypatch):
# Customer row is sparse (no company column, phone dropped for lacking a
# country code) — the captured field_values hold the real details.
cust = Customer(organization_id=test_organization.id,
email="amit@gmail.com", full_name="Arun", phone=None)
db.add(cust)
db.commit()
db.refresh(cust)
db.add(LeadCaptureResponse(
organization_id=test_organization.id, agent_id=test_agent.id,
customer_id=cust.id, consent=True,
field_values={"email": "amit@gmail.com", "name": "Arun",
"company": "ReplyPier", "phone": "6362262617"}))
db.commit()
captured = {}
async def fake_push(self, tokens, payload):
captured["payload"] = payload
return OK_RESULT
monkeypatch.setattr(HubSpotAdapter, "push_lead", fake_push)
await sync_customer_to_crm(db, cust)
payload = captured["payload"]
assert payload.name == "Arun"
assert payload.company == "ReplyPier" # was silently dropped before
assert payload.phone == "6362262617" # from field_values, not the empty column
@pytest.mark.asyncio
async def test_includes_latest_conversation_summary(
self, db, connection, synced_customer, test_organization, test_agent, monkeypatch):
db.add(LeadCaptureResponse(
organization_id=test_organization.id, agent_id=test_agent.id,
customer_id=synced_customer.id, field_values={"email": synced_customer.email},
summary="40-person fintech, wants a Pro demo", consent=True))
db.commit()
captured = {}
async def fake_push(self, tokens, payload):
captured["summary"] = payload.summary
return OK_RESULT
monkeypatch.setattr(HubSpotAdapter, "push_lead", fake_push)
await sync_customer_to_crm(db, synced_customer)
assert captured["summary"] == "40-person fintech, wants a Pro demo"
@pytest.mark.asyncio
async def test_requires_email(self, db, connection, test_organization):
# Anonymous visitors carry a @noemail.com placeholder, not a real address.
anon = Customer(organization_id=test_organization.id,
email="anon-visitor@noemail.com")
db.add(anon); db.commit(); db.refresh(anon)
with pytest.raises(CrmManualSyncError, match="email"):
await sync_customer_to_crm(db, anon)
@pytest.mark.asyncio
async def test_requires_connection(self, db, synced_customer):
with pytest.raises(CrmManualSyncError, match="Connect a CRM"):
await sync_customer_to_crm(db, synced_customer)
@pytest.mark.asyncio
async def test_plan_gate(self, db, connection, synced_customer, monkeypatch):
monkeypatch.setattr("app.services.crm_sync.feature_allowed",
lambda *a, **k: False)
with pytest.raises(CrmManualSyncError, match="plan"):
await sync_customer_to_crm(db, synced_customer)
@pytest.mark.asyncio
async def test_provider_failure_surfaces(self, db, connection, synced_customer,
monkeypatch):
monkeypatch.setattr(HubSpotAdapter, "push_lead", AsyncMock(
return_value=CrmPushResult(ok=False, error="bad request")))
with pytest.raises(CrmManualSyncError):
await sync_customer_to_crm(db, synced_customer)