test: 补充权限、消息与会话服务回归用例

会话读取、接管、重分配与 Socket 订阅统一校验角色、组织与可见性。
This commit is contained in:
WangShen007 committed 2026-10-08 22:35:00 +08:00
1 parent 8e3a4d95da
commit 164f5f9615
97 files changed
+37281

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
"""
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.
"""
+341
View File
@@ -0,0 +1,341 @@
"""
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, CrmSyncJobRepository,
)
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)
+88
View File
@@ -0,0 +1,88 @@
"""
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 CrmAuthError, OAuthTokens
from app.crm.hubspot import HubSpotAdapter
from app.models.crm import CrmConnectionStatus
from app.repositories.crm import CrmConnectionRepository
from app.services.crm_tokens import get_valid_tokens
@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.mark.asyncio
async def test_returns_without_refresh_while_token_has_runway(db, connection, monkeypatch):
refresh = AsyncMock()
monkeypatch.setattr(HubSpotAdapter, "refresh_tokens", refresh)
tokens = await get_valid_tokens(db, connection)
assert tokens.access_token == "at"
refresh.assert_not_awaited()
@pytest.mark.asyncio
async def test_refreshes_and_persists_when_expiring(db, connection, monkeypatch):
connection.access_token_expires_at = datetime.now(timezone.utc) + timedelta(seconds=30)
db.commit()
monkeypatch.setattr(HubSpotAdapter, "refresh_tokens", AsyncMock(
return_value=OAuthTokens(
access_token="at2", refresh_token="rt",
expires_at=datetime.now(timezone.utc) + timedelta(minutes=30))))
tokens = await get_valid_tokens(db, connection)
assert tokens.access_token == "at2"
repo = CrmConnectionRepository(db)
assert repo.get_credentials(connection)["access_token"] == "at2"
assert connection.last_refreshed_at is not None
@pytest.mark.asyncio
async def test_force_refresh_ignores_runway(db, connection, monkeypatch):
refresh = AsyncMock(return_value=OAuthTokens(
access_token="at2", refresh_token="rt",
expires_at=datetime.now(timezone.utc) + timedelta(minutes=30)))
monkeypatch.setattr(HubSpotAdapter, "refresh_tokens", refresh)
await get_valid_tokens(db, connection, force_refresh=True)
refresh.assert_awaited_once()
@pytest.mark.asyncio
async def test_rejected_refresh_marks_connection_expired(db, connection, monkeypatch):
connection.access_token_expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
db.commit()
monkeypatch.setattr(HubSpotAdapter, "refresh_tokens", AsyncMock(
side_effect=CrmAuthError("invalid_grant")))
with pytest.raises(CrmAuthError):
await get_valid_tokens(db, connection)
db.refresh(connection)
assert connection.status == CrmConnectionStatus.EXPIRED.value
assert "invalid_grant" in connection.last_error
+102
View File
@@ -0,0 +1,102 @@
"""
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 pytest
from firebase_admin import credentials
from unittest.mock import patch, MagicMock, call, create_autospec
from app.services.firebase import initialize_firebase
import os
@pytest.fixture(autouse=True)
def mock_logger():
"""Mock logger for all tests"""
with patch('app.services.firebase.logger') as mock_log:
yield mock_log
@pytest.fixture
def mock_credential():
"""Create a mock Firebase credential"""
mock_cred = MagicMock(spec=credentials.Base)
return mock_cred
def test_initialize_firebase_success(mock_logger, mock_credential):
"""Test successful Firebase initialization"""
with patch('app.services.firebase.credentials.Certificate', return_value=mock_credential) as mock_cert, \
patch('app.services.firebase.initialize_app') as mock_init, \
patch('firebase_admin._apps', new={}), \
patch('app.services.firebase.get_app', side_effect=[ValueError(), None]), \
patch('os.path.isfile', return_value=True), \
patch('app.core.config.settings.FIREBASE_CREDENTIALS', 'path/to/creds.json'):
# Execute
initialize_firebase()
# Assert
mock_cert.assert_called_once()
mock_init.assert_called_once_with(mock_credential)
mock_logger.info.assert_has_calls([
call("Initializing Firebase..."),
call("Loading Firebase credentials from file path/to/creds.json"),
call("Firebase initialized successfully")
])
def test_initialize_firebase_development_mode(mock_logger, mock_credential):
"""Test Firebase initialization in development mode when credentials file is missing"""
with patch('app.services.firebase.credentials.Certificate', return_value=mock_credential) as mock_cert, \
patch('app.services.firebase.initialize_app') as mock_init, \
patch('firebase_admin._apps', new={}), \
patch('app.services.firebase.get_app', side_effect=[ValueError(), None]), \
patch('os.path.isfile', return_value=False), \
patch('app.core.config.settings.FIREBASE_CREDENTIALS', 'nonexistent/path.json'):
# Execute
initialize_firebase()
# Assert
mock_cert.assert_called_once()
cert_args = mock_cert.call_args[0][0]
assert cert_args['project_id'] == 'demo-project'
assert cert_args['type'] == 'service_account'
mock_init.assert_called_once_with(mock_credential)
mock_logger.warning.assert_called_once_with(
"Firebase credentials not found at nonexistent/path.json. Running in development mode."
)
def test_initialize_firebase_already_initialized(mock_logger):
"""Test Firebase initialization when already initialized"""
with patch('firebase_admin._apps', new={'[DEFAULT]': MagicMock()}), \
patch('app.services.firebase.get_app', return_value=MagicMock()):
# Execute
initialize_firebase()
# Assert
mock_logger.info.assert_called_once_with("Firebase already initialized")
def test_initialize_firebase_failure(mock_logger):
"""Test Firebase initialization failure"""
with patch('app.services.firebase.credentials.Certificate') as mock_cert, \
patch('firebase_admin._apps', new={}), \
patch('app.services.firebase.get_app', side_effect=ValueError()):
# Configure mock to raise exception
error_msg = "Failed to initialize"
mock_cert.side_effect = Exception(error_msg)
# Execute - function should handle exception gracefully, not raise it
initialize_firebase()
# Assert that error was logged but function continued without raising
mock_logger.error.assert_called_with(f"Error initializing Firebase: {error_msg}")
mock_logger.warning.assert_called_with("Continuing without Firebase initialization")
+816
View File
@@ -0,0 +1,816 @@
"""
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 pytest
from datetime import datetime, timedelta
from unittest.mock import patch, MagicMock, AsyncMock
from app.core.config import settings
from app.services.jira import JiraService
from app.models.jira import JiraToken
from app.core.exceptions import JiraAuthError
@pytest.fixture
def jira_service(monkeypatch):
"""Create a JiraService instance for testing.
Patches `settings`, not os.environ: the credentials are read from the
settings singleton, which is populated once at import, so setting the env
var after that point would never reach the service."""
monkeypatch.setattr(settings, "JIRA_CLIENT_ID", "test_client_id")
monkeypatch.setattr(settings, "JIRA_CLIENT_SECRET", "test_client_secret")
monkeypatch.setattr(settings, "JIRA_REDIRECT_URI", "https://example.com/callback")
return JiraService()
@pytest.fixture
def mock_token():
"""Create a mock JiraToken for testing."""
token = MagicMock(spec=JiraToken)
token.access_token = "test_access_token"
token.refresh_token = "test_refresh_token"
token.expires_at = datetime.utcnow() + timedelta(hours=1)
token.cloud_id = "test_cloud_id"
return token
@pytest.fixture
def expired_token():
"""Create a mock expired JiraToken for testing."""
token = MagicMock(spec=JiraToken)
token.access_token = "test_access_token"
token.refresh_token = "test_refresh_token"
token.expires_at = datetime.utcnow() - timedelta(hours=1)
token.cloud_id = "test_cloud_id"
return token
def test_get_authorization_url(jira_service):
"""Test generating the authorization URL."""
state = "test_state"
url = jira_service.get_authorization_url(state)
# Check that the URL contains all required parameters
assert "auth.atlassian.com/authorize" in url
assert "client_id=test_client_id" in url
assert "redirect_uri=https%3A%2F%2Fexample.com%2Fcallback" in url
assert f"state={state}" in url
assert "response_type=code" in url
assert "scope=" in url
def test_validate_token_valid(jira_service, mock_token):
"""Test validating a valid token."""
assert jira_service.validate_token(mock_token) is True
def test_validate_token_expired(jira_service, expired_token):
"""Test validating an expired token."""
assert jira_service.validate_token(expired_token) is False
def test_validate_token_none(jira_service):
"""Test validating a None token."""
assert jira_service.validate_token(None) is False
@pytest.mark.asyncio
async def test_exchange_code_for_token_success(jira_service):
"""Test exchanging code for token successfully."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": "new_access_token",
"refresh_token": "new_refresh_token",
"token_type": "Bearer",
"expires_in": 3600
}
with patch("requests.post", return_value=mock_response):
result = await jira_service.exchange_code_for_token("test_code")
assert result["access_token"] == "new_access_token"
assert result["refresh_token"] == "new_refresh_token"
assert result["token_type"] == "Bearer"
assert "expires_at" in result
@pytest.mark.asyncio
async def test_exchange_code_for_token_failure(jira_service):
"""Test exchanging code for token with failure."""
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.text = "Invalid code"
with patch("requests.post", return_value=mock_response):
with pytest.raises(JiraAuthError, match="Failed to exchange code for token"):
await jira_service.exchange_code_for_token("invalid_code")
@pytest.mark.asyncio
async def test_refresh_token_success(jira_service):
"""Test refreshing token successfully."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": "new_access_token",
"refresh_token": "new_refresh_token",
"token_type": "Bearer",
"expires_in": 3600
}
with patch("requests.post", return_value=mock_response):
result = await jira_service.refresh_token("test_refresh_token")
assert result["access_token"] == "new_access_token"
assert result["refresh_token"] == "new_refresh_token"
assert result["token_type"] == "Bearer"
assert "expires_at" in result
@pytest.mark.asyncio
async def test_refresh_token_without_new_refresh_token(jira_service):
"""Test refreshing token when no new refresh token is provided."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": "new_access_token",
"token_type": "Bearer",
"expires_in": 3600
}
with patch("requests.post", return_value=mock_response):
result = await jira_service.refresh_token("test_refresh_token")
assert result["access_token"] == "new_access_token"
assert result["refresh_token"] == "test_refresh_token" # Should keep the old refresh token
assert result["token_type"] == "Bearer"
assert "expires_at" in result
@pytest.mark.asyncio
async def test_refresh_token_failure(jira_service):
"""Test refreshing token with failure."""
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.text = "Invalid refresh token"
with patch("requests.post", return_value=mock_response):
with pytest.raises(JiraAuthError, match="Failed to refresh token"):
await jira_service.refresh_token("invalid_refresh_token")
@pytest.mark.asyncio
async def test_get_cloud_id_success(jira_service):
"""Test getting cloud ID successfully."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = [
{
"id": "test_cloud_id",
"url": "https://example.atlassian.net"
}
]
with patch("requests.get", return_value=mock_response):
result = await jira_service.get_cloud_id("test_access_token")
assert result["cloud_id"] == "test_cloud_id"
assert result["site_url"] == "https://example.atlassian.net"
@pytest.mark.asyncio
async def test_get_cloud_id_failure(jira_service):
"""Test getting cloud ID with failure."""
mock_response = MagicMock()
mock_response.status_code = 401
mock_response.text = "Unauthorized"
with patch("requests.get", return_value=mock_response):
with pytest.raises(JiraAuthError, match="Failed to get Jira Cloud ID"):
await jira_service.get_cloud_id("invalid_access_token")
@pytest.mark.asyncio
async def test_get_cloud_id_empty_resources(jira_service):
"""Test getting cloud ID with empty resources."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
with patch("requests.get", return_value=mock_response):
with pytest.raises(JiraAuthError, match="No Jira Cloud instances found"):
await jira_service.get_cloud_id("test_access_token")
@pytest.mark.asyncio
async def test_get_projects_success(jira_service):
"""Test getting projects successfully."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = [
{
"id": "10000",
"key": "TEST",
"name": "Test Project"
},
{
"id": "10001",
"key": "DEMO",
"name": "Demo Project"
}
]
with patch("requests.get", return_value=mock_response):
result = await jira_service.get_projects("test_access_token", "test_cloud_id")
assert len(result) == 2
assert result[0]["id"] == "10000"
assert result[0]["key"] == "TEST"
assert result[0]["name"] == "Test Project"
assert result[1]["id"] == "10001"
assert result[1]["key"] == "DEMO"
assert result[1]["name"] == "Demo Project"
@pytest.mark.asyncio
async def test_get_projects_failure(jira_service):
"""Test getting projects with failure."""
mock_response = MagicMock()
mock_response.status_code = 401
mock_response.text = "Unauthorized"
with patch("requests.get", return_value=mock_response):
with pytest.raises(JiraAuthError, match="Failed to get Jira projects"):
await jira_service.get_projects("invalid_access_token", "test_cloud_id")
@pytest.mark.asyncio
async def test_get_issue_types_success(jira_service):
"""Test getting issue types successfully."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"id": "10000",
"key": "TEST",
"name": "Test Project",
"issueTypes": [
{
"id": "10001",
"name": "Bug",
"description": "A bug in the system",
"iconUrl": "https://example.com/bug.png",
"subtask": False
},
{
"id": "10002",
"name": "Task",
"description": "A task to be completed",
"iconUrl": "https://example.com/task.png",
"subtask": False
},
{
"id": "10003",
"name": "Sub-task",
"description": "A sub-task",
"iconUrl": "https://example.com/subtask.png",
"subtask": True
}
]
}
with patch("requests.get", return_value=mock_response):
result = await jira_service.get_issue_types("test_access_token", "test_cloud_id", "TEST")
assert len(result) == 2 # Should exclude the sub-task
assert result[0]["id"] == "10001"
assert result[0]["name"] == "Bug"
assert result[1]["id"] == "10002"
assert result[1]["name"] == "Task"
@pytest.mark.asyncio
async def test_get_issue_types_failure(jira_service):
"""Test getting issue types with failure."""
mock_response = MagicMock()
mock_response.status_code = 404
mock_response.text = "Project not found"
with patch("requests.get", return_value=mock_response):
with pytest.raises(JiraAuthError, match="Failed to get Jira project"):
await jira_service.get_issue_types("test_access_token", "test_cloud_id", "INVALID")
@pytest.mark.asyncio
async def test_create_issue_success(jira_service):
"""Test creating an issue successfully."""
# Mock organization and db
organization = MagicMock()
organization.id = "test_org_id"
db = MagicMock()
token = MagicMock(spec=JiraToken)
token.organization_id = "test_org_id"
token.access_token = "test_access_token"
token.refresh_token = "test_refresh_token"
token.cloud_id = "test_cloud_id"
token.expires_at = datetime.utcnow() + timedelta(hours=1)
db.query.return_value.filter.return_value.first.return_value = token
# Mock issue data
issue_data = MagicMock()
issue_data.projectKey = "TEST"
issue_data.issueTypeId = "10001"
issue_data.summary = "Test Issue"
issue_data.description = "This is a test issue"
issue_data.priority = "High"
# Mock create_issue_internal
with patch.object(jira_service, "create_issue_internal", new_callable=AsyncMock) as mock_create_internal:
mock_create_internal.return_value = {
"id": "10000",
"key": "TEST-123",
"self": "https://example.atlassian.net/rest/api/3/issue/TEST-123"
}
result = await jira_service.create_issue(organization, db, issue_data)
assert result["id"] == "10000"
assert result["key"] == "TEST-123"
# Verify create_issue_internal was called with correct parameters
mock_create_internal.assert_called_once_with(
"test_access_token",
"test_cloud_id",
"TEST",
"10001",
"Test Issue",
"This is a test issue",
"High"
)
@pytest.mark.asyncio
async def test_create_issue_no_token(jira_service):
"""Test creating an issue when no token is found."""
# Mock organization and db
organization = MagicMock()
organization.id = "test_org_id"
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
# Mock issue data
issue_data = MagicMock()
with pytest.raises(JiraAuthError, match="No Jira connection found"):
await jira_service.create_issue(organization, db, issue_data)
@pytest.mark.asyncio
async def test_create_issue_expired_token(jira_service):
"""Test creating an issue with an expired token that gets refreshed."""
# Mock organization and db
organization = MagicMock()
organization.id = "test_org_id"
db = MagicMock()
token = MagicMock(spec=JiraToken)
token.organization_id = "test_org_id"
token.access_token = "old_access_token"
token.refresh_token = "test_refresh_token"
token.cloud_id = "test_cloud_id"
token.expires_at = datetime.utcnow() - timedelta(hours=1) # Expired
db.query.return_value.filter.return_value.first.return_value = token
# Mock issue data
issue_data = MagicMock()
issue_data.projectKey = "TEST"
issue_data.issueTypeId = "10001"
issue_data.summary = "Test Issue"
issue_data.description = "This is a test issue"
issue_data.priority = "High"
# Mock refresh_token
with patch.object(jira_service, "refresh_token", new_callable=AsyncMock) as mock_refresh:
mock_refresh.return_value = {
"access_token": "new_access_token",
"refresh_token": "new_refresh_token",
"token_type": "Bearer",
"expires_at": datetime.utcnow() + timedelta(hours=1)
}
# Mock create_issue_internal
with patch.object(jira_service, "create_issue_internal", new_callable=AsyncMock) as mock_create_internal:
mock_create_internal.return_value = {
"id": "10000",
"key": "TEST-123",
"self": "https://example.atlassian.net/rest/api/3/issue/TEST-123"
}
result = await jira_service.create_issue(organization, db, issue_data)
assert result["id"] == "10000"
assert result["key"] == "TEST-123"
# Verify refresh_token was called
mock_refresh.assert_called_once_with("test_refresh_token")
# Verify create_issue_internal was called with the new access token
mock_create_internal.assert_called_once_with(
"new_access_token",
"test_cloud_id",
"TEST",
"10001",
"Test Issue",
"This is a test issue",
"High"
)
# Verify db.commit was called to save the new token
db.commit.assert_called_once()
@pytest.mark.asyncio
async def test_create_issue_refresh_failure(jira_service):
"""Test creating an issue when token refresh fails."""
# Mock organization and db
organization = MagicMock()
organization.id = "test_org_id"
db = MagicMock()
token = MagicMock(spec=JiraToken)
token.organization_id = "test_org_id"
token.access_token = "old_access_token"
token.refresh_token = "test_refresh_token"
token.cloud_id = "test_cloud_id"
token.expires_at = datetime.utcnow() - timedelta(hours=1) # Expired
db.query.return_value.filter.return_value.first.return_value = token
# Mock issue data
issue_data = MagicMock()
# Mock refresh_token to raise an exception
with patch.object(jira_service, "refresh_token", new_callable=AsyncMock) as mock_refresh:
mock_refresh.side_effect = JiraAuthError("Failed to refresh token")
with pytest.raises(JiraAuthError, match="Jira token expired and could not be refreshed"):
await jira_service.create_issue(organization, db, issue_data)
@pytest.mark.asyncio
async def test_create_issue_internal_success(jira_service):
"""Test creating an issue internally successfully."""
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.json.return_value = {
"id": "10000",
"key": "TEST-123",
"self": "https://example.atlassian.net/rest/api/3/issue/TEST-123"
}
# Mock is_priority_available to return True
with patch.object(jira_service, "is_priority_available", new_callable=AsyncMock) as mock_priority_check:
mock_priority_check.return_value = True
with patch("requests.post", return_value=mock_response):
result = await jira_service.create_issue_internal(
"test_access_token",
"test_cloud_id",
"TEST",
"10001",
"Test Issue",
"This is a test issue",
"P1"
)
assert result["id"] == "10000"
assert result["key"] == "TEST-123"
# Verify is_priority_available was called
mock_priority_check.assert_called_once_with(
"test_access_token",
"test_cloud_id",
"TEST",
"10001"
)
@pytest.mark.asyncio
async def test_create_issue_internal_priority_not_available(jira_service):
"""Test creating an issue when priority field is not available."""
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.json.return_value = {
"id": "10000",
"key": "TEST-123",
"self": "https://example.atlassian.net/rest/api/3/issue/TEST-123"
}
# Mock is_priority_available to return False
with patch.object(jira_service, "is_priority_available", new_callable=AsyncMock) as mock_priority_check:
mock_priority_check.return_value = False
with patch("requests.post", return_value=mock_response):
result = await jira_service.create_issue_internal(
"test_access_token",
"test_cloud_id",
"TEST",
"10001",
"Test Issue",
"This is a test issue",
"P1"
)
assert result["id"] == "10000"
assert result["key"] == "TEST-123"
# Verify is_priority_available was called
mock_priority_check.assert_called_once_with(
"test_access_token",
"test_cloud_id",
"TEST",
"10001"
)
@pytest.mark.asyncio
async def test_create_issue_internal_failure(jira_service):
"""Test creating an issue internally with failure."""
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.text = "Invalid project key"
# Mock is_priority_available to return True
with patch.object(jira_service, "is_priority_available", new_callable=AsyncMock) as mock_priority_check:
mock_priority_check.return_value = True
with patch("requests.post", return_value=mock_response):
with pytest.raises(JiraAuthError, match="Failed to create Jira issue"):
await jira_service.create_issue_internal(
"test_access_token",
"test_cloud_id",
"INVALID",
"10001",
"Test Issue",
"This is a test issue",
"P1"
)
@pytest.mark.asyncio
async def test_get_priorities_success(jira_service):
"""Test getting priorities successfully."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = [
{
"id": "1",
"name": "Highest",
"description": "Highest priority",
"iconUrl": "https://example.com/highest.png"
},
{
"id": "2",
"name": "High",
"description": "High priority",
"iconUrl": "https://example.com/high.png"
}
]
with patch("requests.get", return_value=mock_response):
result = await jira_service.get_priorities("test_access_token", "test_cloud_id")
assert len(result) == 2
assert result[0]["id"] == "1"
assert result[0]["name"] == "Highest"
assert result[1]["id"] == "2"
assert result[1]["name"] == "High"
@pytest.mark.asyncio
async def test_get_priorities_failure(jira_service):
"""Test getting priorities with failure."""
mock_response = MagicMock()
mock_response.status_code = 401
mock_response.text = "Unauthorized"
with patch("requests.get", return_value=mock_response):
with pytest.raises(JiraAuthError, match="Failed to get Jira priorities"):
await jira_service.get_priorities("invalid_access_token", "test_cloud_id")
@pytest.mark.asyncio
async def test_check_field_availability_success(jira_service):
"""Test checking field availability successfully."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"projects": [
{
"issuetypes": [
{
"fields": {
"priority": {
"allowedValues": [
{"name": "Highest"},
{"name": "High"}
]
}
}
}
]
}
]
}
with patch("requests.get", return_value=mock_response):
result = await jira_service.check_field_availability(
"test_access_token",
"test_cloud_id",
"TEST",
"10001",
"priority"
)
assert result is True
@pytest.mark.asyncio
async def test_check_field_availability_field_not_found(jira_service):
"""Test checking field availability when field is not found."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"projects": [
{
"issuetypes": [
{
"fields": {
"summary": {},
"description": {}
}
}
]
}
]
}
with patch("requests.get", return_value=mock_response):
result = await jira_service.check_field_availability(
"test_access_token",
"test_cloud_id",
"TEST",
"10001",
"priority"
)
assert result is False
@pytest.mark.asyncio
async def test_check_field_availability_no_projects(jira_service):
"""Test checking field availability when no projects are found."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"projects": []
}
with patch("requests.get", return_value=mock_response):
result = await jira_service.check_field_availability(
"test_access_token",
"test_cloud_id",
"TEST",
"10001",
"priority"
)
assert result is False
@pytest.mark.asyncio
async def test_check_field_availability_no_issue_types(jira_service):
"""Test checking field availability when no issue types are found."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"projects": [
{
"issuetypes": []
}
]
}
with patch("requests.get", return_value=mock_response):
result = await jira_service.check_field_availability(
"test_access_token",
"test_cloud_id",
"TEST",
"10001",
"priority"
)
assert result is False
@pytest.mark.asyncio
async def test_check_field_availability_api_error(jira_service):
"""Test checking field availability when API returns an error."""
mock_response = MagicMock()
mock_response.status_code = 404
mock_response.text = "Project not found"
with patch("requests.get", return_value=mock_response):
result = await jira_service.check_field_availability(
"test_access_token",
"test_cloud_id",
"INVALID",
"10001",
"priority"
)
assert result is False
@pytest.mark.asyncio
async def test_is_priority_available(jira_service):
"""Test checking if priority field is available."""
with patch.object(jira_service, "check_field_availability", new_callable=AsyncMock) as mock_check:
mock_check.return_value = True
result = await jira_service.is_priority_available(
"test_access_token",
"test_cloud_id",
"TEST",
"10001"
)
assert result is True
mock_check.assert_called_once_with(
"test_access_token",
"test_cloud_id",
"TEST",
"10001",
"priority"
)
@pytest.fixture
def reload_jira_urls(monkeypatch):
"""Re-import app.services.jira under the given Atlassian bases, then restore it."""
import importlib
import app.services.jira as jira_module
def _reload(**env):
for name in ("JIRA_API_BASE", "JIRA_AUTH_BASE"):
monkeypatch.delenv(name, raising=False)
for name, value in env.items():
monkeypatch.setenv(name, value)
return importlib.reload(jira_module)
yield _reload
for name in ("JIRA_API_BASE", "JIRA_AUTH_BASE"):
monkeypatch.delenv(name, raising=False)
importlib.reload(jira_module)
def test_jira_urls_default_to_atlassian(reload_jira_urls):
jira = reload_jira_urls()
assert jira.jira_authorize_url() == "https://auth.atlassian.com/authorize"
assert jira.jira_token_url() == "https://auth.atlassian.com/oauth/token"
assert jira.jira_accessible_resources_url() == "https://api.atlassian.com/oauth/token/accessible-resources"
assert jira.jira_rest_url("cid", "issue") == "https://api.atlassian.com/ex/jira/cid/rest/api/3/issue"
def test_jira_bases_are_overridable(reload_jira_urls):
jira = reload_jira_urls(
JIRA_AUTH_BASE="http://127.0.0.1:14020/jira-auth/",
JIRA_API_BASE="http://127.0.0.1:14020/jira-api",
)
assert jira.jira_token_url() == "http://127.0.0.1:14020/jira-auth/oauth/token"
assert jira.jira_accessible_resources_url() == (
"http://127.0.0.1:14020/jira-api/oauth/token/accessible-resources"
)
assert jira.jira_rest_url("cid", "project") == "http://127.0.0.1:14020/jira-api/ex/jira/cid/rest/api/3/project"
service = jira.JiraService()
assert service.token_url == "http://127.0.0.1:14020/jira-auth/oauth/token"
assert service.get_authorization_url("abc").startswith("http://127.0.0.1:14020/jira-auth/authorize?")
assert "state=abc" in service.get_authorization_url("abc")
@@ -0,0 +1,382 @@
"""
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 pytest
from uuid import uuid4
from app.models.customer import Customer, LeadStage
from app.models.session_to_agent import SessionStatus, SessionToAgent
from app.services.conversation_token import VERIFIED_SCOPE
from app.models.lead_capture import LeadCaptureConfig, LeadCaptureResponse
from app.services.lead_capture import (
has_captured_lead, record_lead_capture, _valid_email,
)
@pytest.fixture
def config(db, test_agent):
"""A lead-capture config with the standard fields enabled + consent required."""
cfg = LeadCaptureConfig(
agent_id=test_agent.id,
enabled=True,
require_consent=True,
fields=[
{"key": "email", "standard": True, "enabled": True, "required": True},
{"key": "name", "standard": True, "enabled": True, "required": False},
{"key": "company", "standard": True, "enabled": True, "required": False},
],
)
db.add(cfg)
db.commit()
db.refresh(cfg)
return cfg
def _anon(db, org_id, email=None):
c = Customer(email=email or f"{uuid4().hex}@noemail.com", organization_id=org_id)
db.add(c)
db.commit()
db.refresh(c)
return c
def test_valid_email():
assert _valid_email("jane@acme.com")
assert not _valid_email("arun.com")
assert not _valid_email("")
assert not _valid_email(None)
assert not _valid_email("no-at-sign")
def test_has_captured_lead(db, test_agent, test_organization_id, config):
customer = _anon(db, test_organization_id)
assert has_captured_lead(db, customer.id, test_agent.id) is False
record_lead_capture(
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
customer_id=customer.id, session_id=None,
lead_data={"email": "jane@acme.com"}, summary="s", consent=True,
scope=VERIFIED_SCOPE,
)
assert has_captured_lead(db, customer.id, test_agent.id) is True
def test_no_capture_when_disabled(db, test_agent, test_organization_id, config):
config.enabled = False
db.commit()
customer = _anon(db, test_organization_id)
resp = record_lead_capture(
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
customer_id=customer.id, session_id=None,
lead_data={"email": "jane@acme.com"}, summary="s", consent=True,
scope=VERIFIED_SCOPE,
)
assert resp is None
assert has_captured_lead(db, customer.id, test_agent.id) is False
def test_no_capture_for_authenticated_customer(db, test_agent, test_organization_id, config):
# Integration-authenticated customer (identified via generate-token) — never a lead.
authed = Customer(email="portal@acme.com", organization_id=test_organization_id,
is_authenticated=True,
meta_data={"student_name": "Sam", "center_name": "MMCA"})
db.add(authed); db.commit(); db.refresh(authed)
resp = record_lead_capture(
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
customer_id=authed.id, session_id=None,
lead_data={"email": "portal@acme.com"}, summary="s", consent=True,
scope=VERIFIED_SCOPE,
)
assert resp is None
assert has_captured_lead(db, authed.id, test_agent.id) is False
def test_capture_for_customer_with_meta_data_but_not_authenticated(db, test_agent, test_organization_id, config):
# meta_data present but not authenticated (e.g. UTM data on an organic visitor) —
# must still be captured as a lead (regression: meta_data alone must not block).
from app.models.customer import Customer as _Customer
visitor = _Customer(email="anon@acme.com", organization_id=test_organization_id,
meta_data={"utm": "google"})
db.add(visitor); db.commit(); db.refresh(visitor)
resp = record_lead_capture(
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
customer_id=visitor.id, session_id=None,
lead_data={"email": "lead@acme.com"}, summary="s", consent=True,
scope=VERIFIED_SCOPE,
)
assert resp is not None
assert has_captured_lead(db, resp.customer_id, test_agent.id) is True
def test_record_requires_valid_email(db, test_agent, test_organization_id, config):
customer = _anon(db, test_organization_id)
resp = record_lead_capture(
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
customer_id=customer.id, session_id=None,
lead_data={"email": "not-an-email"}, summary=None, consent=True,
scope=VERIFIED_SCOPE,
)
assert resp is None
def test_record_requires_consent_when_configured(db, test_agent, test_organization_id, config):
customer = _anon(db, test_organization_id)
resp = record_lead_capture(
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
customer_id=customer.id, session_id=None,
lead_data={"email": "jane@acme.com"}, summary=None, consent=False,
scope=VERIFIED_SCOPE,
)
assert resp is None
def test_record_happy_path_promotes_and_stores(db, test_agent, test_organization_id, config):
customer = _anon(db, test_organization_id)
resp = record_lead_capture(
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
customer_id=customer.id, session_id=None,
lead_data={"email": "jane@acme.com", "name": "Jane", "company": "Acme"},
summary="Interested in a demo", consent=True, page_url="https://x.com/pricing",
scope=VERIFIED_SCOPE,
)
assert resp is not None
assert resp.field_values["email"] == "jane@acme.com"
assert resp.qualified is True
db.refresh(customer)
assert customer.lead_stage == LeadStage.LEAD
assert customer.lead_qualified_at is not None
assert (customer.lead_source or {}).get("page_url") == "https://x.com/pricing"
def test_record_filters_unconfigured_fields(db, test_agent, test_organization_id, config):
customer = _anon(db, test_organization_id)
resp = record_lead_capture(
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
customer_id=customer.id, session_id=None,
lead_data={"email": "jane@acme.com", "ssn": "leak"}, summary=None, consent=True,
scope=VERIFIED_SCOPE,
)
assert resp is not None
assert "ssn" not in (resp.field_values or {})
def test_record_no_consent_required(db, test_agent, test_organization_id):
cfg = LeadCaptureConfig(
agent_id=test_agent.id, enabled=True, require_consent=False,
fields=[{"key": "email", "standard": True, "enabled": True, "required": True}],
)
db.add(cfg); db.commit(); db.refresh(cfg)
customer = _anon(db, test_organization_id)
resp = record_lead_capture(
db, cfg, organization_id=test_organization_id, agent_id=test_agent.id,
customer_id=customer.id, session_id=None,
lead_data={"email": "bob@corp.com"}, summary=None, consent=False,
scope=VERIFIED_SCOPE,
)
assert resp is not None # consent not required → records anyway
def test_record_merges_into_existing_customer(db, test_agent, test_organization_id, config):
"""A visitor giving an email that already belongs to another customer merges."""
existing = Customer(email="dupe@acme.com", organization_id=test_organization_id,
full_name="Existing")
db.add(existing); db.commit(); db.refresh(existing)
anon = _anon(db, test_organization_id)
resp = record_lead_capture(
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
customer_id=anon.id, session_id=None,
lead_data={"email": "dupe@acme.com"}, summary=None, consent=True,
scope=VERIFIED_SCOPE,
)
assert resp is not None
assert resp.customer_id == existing.id # lead attached to the existing customer
db.refresh(anon)
assert anon.merged_into_customer_id == existing.id
assert anon.is_active is False
def test_merge_moves_phone_to_the_survivor(db, test_agent, test_organization_id, config):
"""Identifiers union on merge — and the phone MOVES rather than copies,
because two rows holding one phone would violate the (org, phone) unique
index at commit."""
from app.repositories.customer import CustomerRepository
from app.services.lead_capture import record_lead_capture
repo = CustomerRepository(db)
identified = repo.create_customer(
email="priya@example.com", organization_id=test_organization_id,
full_name="Priya")
anonymous = repo.create_customer(
email="911234567890@whatsapp.channel", organization_id=test_organization_id,
phone="+911234567890")
# The WhatsApp person tells the AI an email that already belongs to Priya.
record_lead_capture(
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
customer_id=anonymous.id, session_id=None,
lead_data={"email": "priya@example.com"}, summary=None, consent=True,
scope=VERIFIED_SCOPE,)
db.refresh(identified)
db.refresh(anonymous)
assert anonymous.merged_into_customer_id == identified.id
assert identified.phone == "+911234567890" # carried to the survivor
assert anonymous.phone is None # moved, not copied
def test_merge_never_leaves_a_live_number_on_the_tombstone(
db, test_agent, test_organization_id, config):
"""The branch the move test doesn't reach: when BOTH rows have a phone
there is nowhere to move the source's to. It must still be cleared —
a merged row is invisible in People, but phone lookup is the first thing
every inbound WhatsApp message does, so a number left on it would capture
that conversation onto a row nobody can see or edit."""
from app.repositories.customer import CustomerRepository
from app.services.lead_capture import record_lead_capture
repo = CustomerRepository(db)
identified = repo.create_customer(
email="priya@example.com", organization_id=test_organization_id,
full_name="Priya", phone="+447700900111") # work mobile
anonymous = repo.create_customer(
email="911234567890@whatsapp.channel", organization_id=test_organization_id,
phone="+911234567890") # personal mobile
record_lead_capture(
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
customer_id=anonymous.id, session_id=None,
lead_data={"email": "priya@example.com"}, summary=None, consent=True,
scope=VERIFIED_SCOPE,)
db.refresh(identified)
db.refresh(anonymous)
assert anonymous.merged_into_customer_id == identified.id
assert identified.phone == "+447700900111" # survivor keeps theirs
assert anonymous.phone is None # dropped, not left live
# And the dropped number resolves to nobody, rather than to a tombstone.
assert repo.get_customer_by_phone("+911234567890", test_organization_id) is None
# --- a widget visitor may only merge or edit a customer its own browser created ---
def _session(db, customer, org_id, key):
db.add(SessionToAgent(session_id=uuid4(), customer_id=customer.id, organization_id=org_id,
status=SessionStatus.OPEN, conversation_key=key))
db.commit()
def _visitor(db, org_id, key):
"""An anonymous customer the widget created for the browser holding `key`."""
c = Customer(email=f"{uuid4().hex}@noemail.com", organization_id=org_id, conversation_key=key)
db.add(c); db.commit(); db.refresh(c)
_session(db, c, org_id, key)
return c
def test_a_widget_visitor_becomes_a_lead_on_their_own_row(db, test_agent, test_organization_id, config):
visitor = _visitor(db, test_organization_id, "browser-a")
resp = record_lead_capture(
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
customer_id=visitor.id, session_id=None,
lead_data={"email": "new@acme.com", "name": "New"}, summary=None, consent=True,
scope="browser-a",
)
assert resp.customer_id == visitor.id
db.refresh(visitor)
assert visitor.email == "new@acme.com" and visitor.full_name == "New"
assert visitor.lead_stage == LeadStage.LEAD
def test_a_widget_visitor_is_not_merged_into_a_customer_who_already_existed(
db, test_agent, test_organization_id, config
):
"""A typed email is no proof of being that customer, and a merge would hand
the visitor's name and phone to the real one."""
existing = Customer(email="known@acme.com", organization_id=test_organization_id)
db.add(existing); db.commit(); db.refresh(existing)
visitor = _visitor(db, test_organization_id, "browser-a")
resp = record_lead_capture(
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
customer_id=visitor.id, session_id=None,
lead_data={"email": "known@acme.com", "name": "Visitor"}, summary=None, consent=True,
scope="browser-a",
)
assert resp.customer_id == visitor.id
db.refresh(visitor); db.refresh(existing)
assert visitor.merged_into_customer_id is None
assert existing.full_name is None
def test_a_claimed_customer_is_not_merged_away(db, test_agent, test_organization_id, config):
"""Bound to a known customer by a typed email, a visitor gives their own
address: merging would move the real customer's conversations to them."""
victim = Customer(email="victim@acme.com", full_name="Victim", organization_id=test_organization_id)
attacker = Customer(email="attacker@evil.com", organization_id=test_organization_id)
db.add_all([victim, attacker]); db.commit(); db.refresh(victim); db.refresh(attacker)
_session(db, victim, test_organization_id, "attacker-browser")
resp = record_lead_capture(
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
customer_id=victim.id, session_id=None,
lead_data={"email": "attacker@evil.com", "name": "Attacker"}, summary=None, consent=True,
scope="attacker-browser",
)
assert resp is None
assert has_captured_lead(db, victim.id, test_agent.id) is False # the real customer is still asked
db.refresh(victim)
assert victim.merged_into_customer_id is None
assert victim.full_name == "Victim"
assert victim.lead_stage != LeadStage.LEAD
def test_a_customer_with_no_conversations_is_not_owned_by_whoever_arrives(
db, test_agent, test_organization_id, config
):
"""A ticket or an import creates customers with no conversations; the first
browser to claim one must not get to rename it."""
known = Customer(email="ticket@acme.com", full_name="Ticket Person", organization_id=test_organization_id)
db.add(known); db.commit(); db.refresh(known)
record_lead_capture(
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
customer_id=known.id, session_id=None,
lead_data={"email": "ticket@acme.com", "name": "Someone Else"}, summary=None, consent=True,
scope="browser-a",
)
db.refresh(known)
assert known.full_name == "Ticket Person"
def test_a_visitor_without_a_scope_changes_nothing(db, test_agent, test_organization_id, config):
anon = _anon(db, test_organization_id)
resp = record_lead_capture(
db, config, organization_id=test_organization_id, agent_id=test_agent.id,
customer_id=anon.id, session_id=None,
lead_data={"email": "someone@acme.com", "name": "Someone"}, summary=None, consent=True,
scope=None,
)
assert resp is None
db.refresh(anon)
assert anon.full_name is None
assert anon.email.endswith("@noemail.com")
@@ -0,0 +1,135 @@
"""
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.
What a widget token may do to the customer it names.
"""
from uuid import uuid4
import pytest
from app.models.customer import Customer
from app.models.session_to_agent import SessionStatus, SessionToAgent
from app.services.conversation_token import VERIFIED_SCOPE, account_conversation_key
from app.services.widget_identity import (
can_act_as,
may_stand_for,
trusts_identity_in_conversation,
trusts_identity_in_session,
)
@pytest.fixture
def customer(db, test_organization) -> Customer:
"""A customer the widget created for the browser holding "browser-a"."""
c = Customer(email="known@acme.com", full_name="Known", organization_id=test_organization.id,
conversation_key="browser-a")
db.add(c)
db.commit()
db.refresh(c)
return c
def _session(db, customer, key) -> SessionToAgent:
session = SessionToAgent(session_id=uuid4(), customer_id=customer.id,
organization_id=customer.organization_id,
status=SessionStatus.OPEN, conversation_key=key)
db.add(session)
db.commit()
return session
def test_a_vouched_for_token_may_act_as_anyone_it_names(db, customer):
_session(db, customer, "browser-a")
assert can_act_as(db, customer, VERIFIED_SCOPE)
def test_a_widget_token_may_act_as_a_customer_whose_conversations_are_all_its_own(db, customer):
_session(db, customer, "browser-a")
assert can_act_as(db, customer, "browser-a")
def test_a_widget_token_may_not_act_as_a_customer_with_someone_elses_conversation(db, customer):
_session(db, customer, "browser-a")
_session(db, customer, "browser-b")
assert not can_act_as(db, customer, "browser-a")
def test_a_keyless_conversation_belongs_to_the_vouched_for_scope(db, customer):
_session(db, customer, None)
assert not can_act_as(db, customer, "browser-a")
def test_a_customer_another_browser_created_is_not_owned_even_without_conversations(db, customer):
assert not can_act_as(db, customer, "browser-b")
def test_a_legacy_token_owns_only_an_anonymous_customer_from_before_keys(db, test_organization):
anonymous = Customer(email="1738000000000@noemail.com", organization_id=test_organization.id)
known = Customer(email="someone@acme.com", organization_id=test_organization.id)
db.add_all([anonymous, known])
db.commit()
assert can_act_as(db, anonymous, "jti:abc")
assert not can_act_as(db, known, "jti:abc")
assert not can_act_as(db, anonymous, "browser-a")
def test_a_widget_token_may_not_act_as_a_signed_in_customer(db, customer):
customer.is_authenticated = True
db.commit()
assert not can_act_as(db, customer, "browser-a")
assert not may_stand_for(customer, "browser-a")
assert may_stand_for(customer, VERIFIED_SCOPE)
def test_no_scope_or_no_customer_grants_nothing(db, customer):
assert not can_act_as(db, customer, None)
assert not can_act_as(db, None, VERIFIED_SCOPE)
assert not may_stand_for(customer, None)
def test_what_we_know_is_only_recited_inside_the_customers_own_conversation(db, customer):
own = _session(db, customer, "browser-a")
assert trusts_identity_in_session(db, customer, own.session_id)
assert not trusts_identity_in_session(db, customer, uuid4())
claimed = _session(db, customer, "browser-b")
assert not trusts_identity_in_session(db, customer, claimed.session_id)
# And once someone else is in, not even the original browser is trusted:
# the customer is no longer theirs alone.
assert not trusts_identity_in_session(db, customer, own.session_id)
def test_a_vouched_for_conversation_is_trusted(db, customer):
verified = _session(db, customer, None)
assert trusts_identity_in_session(db, customer, verified.session_id)
def test_a_signed_in_account_may_stand_for_and_act_as_its_customer(db, customer):
"""The in-app support chat: our backend vouched for the account, so its key is
as trusted as the embedding app's - for the conversations it started."""
customer.is_authenticated = True
db.commit()
scope = account_conversation_key("user-1")
assert may_stand_for(customer, scope)
assert can_act_as(db, customer, scope)
assert trusts_identity_in_conversation(db, customer, _session(db, customer, scope))
def test_no_conversation_means_no_trust(db, customer):
assert not trusts_identity_in_conversation(db, customer, None)