""" 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 import pytest from app.models.crm import CrmConnectionStatus, CrmSyncJobStatus from app.models.lead_capture import LeadCaptureResponse from app.repositories.crm import ( CrmConnectionRepository, CrmSyncJobRepository, MAX_ATTEMPTS, ) @pytest.fixture def conn_repo(db): return CrmConnectionRepository(db) @pytest.fixture def job_repo(db): return CrmSyncJobRepository(db) @pytest.fixture def test_lead_response(db, test_organization, test_agent, test_customer) -> LeadCaptureResponse: 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"}, consent=True, qualified=True, ) db.add(response) db.commit() db.refresh(response) return response CREDS = {"access_token": "at-1", "refresh_token": "rt-1", "api_domain": None} def _aware(dt: datetime) -> datetime: """SQLite loses tzinfo on refresh; normalize for comparisons (PG keeps it).""" return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) def _connect(conn_repo, org_id, provider="hubspot", external_id="hub-123", **kwargs): return conn_repo.create_or_update( organization_id=org_id, provider=provider, external_account_id=external_id, credentials=CREDS, display_name="Acme Portal", **kwargs, ) class TestCrmConnectionRepository: def test_credentials_round_trip_encrypted(self, conn_repo, test_organization): conn = _connect(conn_repo, test_organization.id) # Stored blob is ciphertext, decrypts back to the original dict. assert "at-1" not in conn.encrypted_credentials assert conn_repo.get_credentials(conn) == CREDS def test_create_or_update_is_an_upsert(self, conn_repo, test_organization): first = _connect(conn_repo, test_organization.id) conn_repo.set_status(first, CrmConnectionStatus.EXPIRED.value, last_error="dead token") reconnected = _connect(conn_repo, test_organization.id, external_id="hub-456") assert reconnected.id == first.id # same row, not a duplicate assert reconnected.external_account_id == "hub-456" assert reconnected.status == CrmConnectionStatus.ACTIVE.value assert reconnected.last_error is None def test_get_active_ignores_expired(self, conn_repo, test_organization): conn = _connect(conn_repo, test_organization.id) assert conn_repo.get_active(test_organization.id, "hubspot") is not None conn_repo.set_status(conn, CrmConnectionStatus.EXPIRED.value) assert conn_repo.get_active(test_organization.id, "hubspot") is None def test_save_credentials_mirrors_expiries(self, conn_repo, test_organization): conn = _connect(conn_repo, test_organization.id, provider="pipedrive", external_id="pd-1") access_exp = datetime.now(timezone.utc) + timedelta(hours=1) refresh_exp = datetime.now(timezone.utc) + timedelta(days=60) conn = conn_repo.save_credentials( conn, {"access_token": "at-2", "refresh_token": "rt-2"}, access_token_expires_at=access_exp, refresh_token_expires_at=refresh_exp, ) assert conn_repo.get_credentials(conn)["access_token"] == "at-2" assert conn.access_token_expires_at is not None assert conn.refresh_token_expires_at is not None assert conn.last_refreshed_at is not None def test_list_needing_refresh(self, conn_repo, db, test_organization): conn = _connect(conn_repo, test_organization.id, provider="pipedrive", external_id="pd-2") cutoff = datetime.now(timezone.utc) - timedelta(hours=24) # Freshly connected → not due. assert conn_repo.list_needing_refresh("pipedrive", cutoff) == [] conn.last_refreshed_at = datetime.now(timezone.utc) - timedelta(days=2) db.commit() assert [c.id for c in conn_repo.list_needing_refresh("pipedrive", cutoff)] == [conn.id] # Expired connections are never swept. conn_repo.set_status(conn, CrmConnectionStatus.EXPIRED.value) assert conn_repo.list_needing_refresh("pipedrive", cutoff) == [] class TestCrmSyncJobRepository: def test_enqueue_is_idempotent(self, job_repo, test_organization, test_lead_response): job = job_repo.enqueue(test_organization.id, test_lead_response.id, "hubspot") assert job is not None assert job.status == CrmSyncJobStatus.PENDING.value # Same lead + provider → no second job. assert job_repo.enqueue(test_organization.id, test_lead_response.id, "hubspot") is None # Same lead, other provider → its own job. other = job_repo.enqueue(test_organization.id, test_lead_response.id, "pipedrive") assert other is not None and other.id != job.id def test_claim_marks_processing_and_respects_due_time( self, job_repo, db, test_organization, test_lead_response): due = job_repo.enqueue(test_organization.id, test_lead_response.id, "hubspot") future = job_repo.enqueue(test_organization.id, test_lead_response.id, "pipedrive") future.next_attempt_at = datetime.now(timezone.utc) + timedelta(hours=1) db.commit() claimed = job_repo.claim_batch(limit=10) assert [j.id for j in claimed] == [due.id] assert claimed[0].status == CrmSyncJobStatus.PROCESSING.value assert claimed[0].started_at is not None # Already-claimed jobs are not claimed again. assert job_repo.claim_batch(limit=10) == [] def test_reclaim_stale_returns_crashed_claims( self, job_repo, db, test_organization, test_lead_response): job = job_repo.enqueue(test_organization.id, test_lead_response.id, "hubspot") job_repo.claim_batch(limit=1) db.refresh(job) job.started_at = datetime.now(timezone.utc) - timedelta(minutes=30) db.commit() assert job_repo.reclaim_stale(older_than_minutes=10) == 1 db.refresh(job) assert job.status == CrmSyncJobStatus.PENDING.value assert job.started_at is None def test_complete_stamps_result_and_connection( self, job_repo, conn_repo, test_organization, test_lead_response): conn = _connect(conn_repo, test_organization.id) job = job_repo.enqueue(test_organization.id, test_lead_response.id, "hubspot") job = job_repo.complete(job, {"action": "created", "contact_id": "42"}, connection_id=conn.id) assert job.status == CrmSyncJobStatus.COMPLETED.value assert job.result["contact_id"] == "42" assert job.connection_id == conn.id def test_schedule_retry_backs_off_then_fails_terminally( self, job_repo, test_organization, test_lead_response): job = job_repo.enqueue(test_organization.id, test_lead_response.id, "hubspot") before = datetime.now(timezone.utc) job = job_repo.schedule_retry(job, "HTTP 502") assert job.status == CrmSyncJobStatus.PENDING.value assert job.attempts == 1 assert _aware(job.next_attempt_at) > before job.attempts = MAX_ATTEMPTS - 1 job = job_repo.schedule_retry(job, "HTTP 502 again") assert job.status == CrmSyncJobStatus.FAILED.value assert job.attempts == MAX_ATTEMPTS def test_retry_after_wins_over_backoff_when_longer( self, job_repo, test_organization, test_lead_response): job = job_repo.enqueue(test_organization.id, test_lead_response.id, "hubspot") job = job_repo.schedule_retry(job, "HTTP 429", retry_after_seconds=7200) # 7200 > capped backoff (3600) → provider's Retry-After is honored. assert _aware(job.next_attempt_at) >= datetime.now(timezone.utc) + timedelta(seconds=7000) def test_skip_pending_for_connection( self, job_repo, test_organization, test_lead_response): job = job_repo.enqueue(test_organization.id, test_lead_response.id, "hubspot") count = job_repo.skip_pending_for_connection( test_organization.id, "hubspot", "app uninstalled") assert count == 1 assert job_repo.claim_batch(limit=10) == [] def test_count_recent_failures(self, job_repo, test_organization, test_lead_response): job = job_repo.enqueue(test_organization.id, test_lead_response.id, "hubspot") job_repo.fail(job, "boom") assert job_repo.count_recent_failures(test_organization.id, "hubspot") == 1 assert job_repo.count_recent_failures(test_organization.id, "pipedrive") == 0 def test_reopen_failed_requeues(self, job_repo, test_organization, test_lead_response): job = job_repo.enqueue(test_organization.id, test_lead_response.id, "hubspot") job.attempts = 7 job_repo.fail(job, "no_connection") assert job_repo.reopen_failed(test_organization.id, "hubspot") == 1 job_repo.db.refresh(job) assert job.status == CrmSyncJobStatus.PENDING.value assert job.attempts == 0 # Other providers untouched. assert job_repo.reopen_failed(test_organization.id, "pipedrive") == 0