test: 补充权限、消息与会话服务回归用例
会话读取、接管、重分配与 Socket 订阅统一校验角色、组织与可见性。
This commit is contained in:
1 parent
8e3a4d95da
commit
164f5f9615
97 files changed
+37281
No files matched your search
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user