diff --git a/orchestration/abri_orchestrator.py b/orchestration/abri_orchestrator.py index d4b61b8ba..f4b82943a 100644 --- a/orchestration/abri_orchestrator.py +++ b/orchestration/abri_orchestrator.py @@ -1,12 +1,15 @@ from dataclasses import dataclass from datetime import date -from typing import Dict, List, Optional, Protocol, Tuple, Union +from typing import Callable, Dict, List, Optional, Protocol, Tuple, Union +from domain.abri.abandonment import abandon_reason_for_outcome from domain.abri.descriptions import build_job_descriptions from domain.abri.models import ( + AbandonJobRequest, AbriRequestRejected, AmendJobRequest, AppointmentAmended, + JobAbandoned, LogJobRequest, PlaceRef, Tenant, @@ -140,6 +143,17 @@ class AppointmentChange: AmendJobOrchestrationResult = Union[AppointmentAmended, AbriRequestRejected] +@dataclass(frozen=True) +class DealAbandonment: + """The deal fields a crossed-into-abandoned deal contributes to a cancel.""" + + deal_id: str + outcome: Optional[str] + + +AbandonJobOrchestrationResult = Union[JobAbandoned, AbriRequestRejected] + + class JobNoNotYetRecordedError(Exception): """An amendment arrived before the deal's job_no landed in the database. @@ -194,11 +208,13 @@ class AbriOrchestrator: deal_contacts: HubspotDealContactsClient, deal_properties: HubspotDealPropertiesClient, deal_database: DealDatabaseGateway, + clock: Callable[[], date] = date.today, ) -> None: self._abri = abri_client self._deal_contacts = deal_contacts self._deal_properties = deal_properties self._deal_database = deal_database + self._clock = clock def sync_tenant_data( self, place_ref: PlaceRef, deal_id: str @@ -254,6 +270,11 @@ class AbriOrchestrator: ) ) + def abandon_job( + self, abandonment: DealAbandonment + ) -> AbandonJobOrchestrationResult: + raise NotImplementedError + def log_job(self, booking: ConfirmedSurveyBooking) -> LogJobOrchestrationResult: descriptions = build_job_descriptions(booking.deal_name) outcome = self._abri.log_job( diff --git a/tests/orchestration/test_abri_orchestrator_abandon_job.py b/tests/orchestration/test_abri_orchestrator_abandon_job.py new file mode 100644 index 000000000..c12f4681b --- /dev/null +++ b/tests/orchestration/test_abri_orchestrator_abandon_job.py @@ -0,0 +1,171 @@ +import xml.etree.ElementTree as ET +from datetime import date +from pathlib import Path +from typing import Dict, List, Optional, Tuple +from unittest.mock import MagicMock, patch + +import pytest + +from domain.abri.models import AbriRequestRejected, JobAbandoned +from infrastructure.abri.abri_client import AbriClient +from infrastructure.abri.config import AbriConfig +from infrastructure.hubspot.deal_contacts_client import HubspotDealContactsClient +from infrastructure.hubspot.deal_properties_client import HubspotDealPropertiesClient +from orchestration.abri_orchestrator import ( + AbriOrchestrator, + DealAbandonment, + JobNoNotYetRecordedError, +) + +FIXTURE_DIR = Path(__file__).parents[1] / "abri" +ENDPOINT_URL = "https://relay.example.test/api/DomnaRelay?code=test-function-key" +DEAL_ID = "9876543210" +JOB_NO = "AC0439951" +TODAY = date(2026, 7, 7) + +CONFIG = AbriConfig( + endpoint_url=ENDPOINT_URL, + username="DomnaWeb", + password="", + default_resource="NAULKH", +) + +ABANDONMENT = DealAbandonment(deal_id=DEAL_ID, outcome="no answer") + + +def _load_fixture(name: str) -> bytes: + return (FIXTURE_DIR / name).read_bytes() + + +class FakeDealDatabase: + """In-memory stand-in for the deal-database gateway.""" + + def __init__(self) -> None: + self.recorded_job_nos: List[Tuple[str, str]] = [] + self.job_nos: Dict[str, str] = {} + + def record_job_no(self, deal_id: str, job_no: str) -> None: + self.recorded_job_nos.append((deal_id, job_no)) + self.job_nos[deal_id] = job_no + + def job_no_for_deal(self, deal_id: str) -> Optional[str]: + return self.job_nos.get(deal_id) + + +@pytest.fixture() +def mock_session() -> MagicMock: + return MagicMock() + + +@pytest.fixture() +def deal_database() -> FakeDealDatabase: + return FakeDealDatabase() + + +@pytest.fixture() +def orchestrator( + mock_session: MagicMock, + deal_database: FakeDealDatabase, +) -> AbriOrchestrator: + sdk_client = MagicMock() + with patch( + "infrastructure.abri.abri_client.requests.Session", + return_value=mock_session, + ): + abri_client = AbriClient(config=CONFIG) + return AbriOrchestrator( + abri_client=abri_client, + deal_contacts=HubspotDealContactsClient(sdk_client=sdk_client), + deal_properties=HubspotDealPropertiesClient(sdk_client=sdk_client), + deal_database=deal_database, + clock=lambda: TODAY, + ) + + +# --- outbound request: canceljob carries the job, reason and today's date --- + + +def test_abandon_job_sends_a_canceljob_envelope_for_the_deals_recorded_job( + orchestrator: AbriOrchestrator, + mock_session: MagicMock, + deal_database: FakeDealDatabase, +) -> None: + # Arrange + deal_database.job_nos[DEAL_ID] = JOB_NO + mock_session.post.return_value.content = _load_fixture( + "abri_relay_canceljob_success_response.xml" + ) + + # Act + orchestrator.abandon_job(ABANDONMENT) + + # Assert + (url,) = mock_session.post.call_args.args + sent_body: bytes = mock_session.post.call_args.kwargs["data"] + sent_parameters = { + parameter.get("attribute"): parameter.get("attribute_value") + for parameter in ET.fromstring(sent_body).findall("Body/Request/Parameters") + } + assert url == ENDPOINT_URL + assert sent_parameters == { + "job_no": JOB_NO, + "abandon_reason": "CARDED", + "date_abandoned": "07/07/2026", + } + + +# --- happy path: the cancelled job is confirmed back --- + + +def test_abandon_job_returns_the_abandoned_job( + orchestrator: AbriOrchestrator, + mock_session: MagicMock, + deal_database: FakeDealDatabase, +) -> None: + # Arrange + deal_database.job_nos[DEAL_ID] = JOB_NO + mock_session.post.return_value.content = _load_fixture( + "abri_relay_canceljob_success_response.xml" + ) + + # Act + result = orchestrator.abandon_job(ABANDONMENT) + + # Assert + assert result == JobAbandoned(job_no=JOB_NO) + + +# --- ordering race: abandonment before the job_no write-back has landed --- + + +def test_abandon_job_raises_a_retriable_error_when_no_job_no_has_been_recorded( + orchestrator: AbriOrchestrator, + mock_session: MagicMock, +) -> None: + # Act / Assert + with pytest.raises(JobNoNotYetRecordedError): + orchestrator.abandon_job(ABANDONMENT) + assert mock_session.post.call_count == 0 + + +# --- rejection passthrough: OpenHousing's actual reason, verbatim --- + + +def test_abandon_job_returns_the_openhousing_rejection_verbatim( + orchestrator: AbriOrchestrator, + mock_session: MagicMock, + deal_database: FakeDealDatabase, +) -> None: + # Arrange + deal_database.job_nos[DEAL_ID] = JOB_NO + mock_session.post.return_value.content = _load_fixture( + "abri_relay_canceljob_errordetails_response.xml" + ) + + # Act + result = orchestrator.abandon_job(ABANDONMENT) + + # Assert + assert isinstance(result, AbriRequestRejected) + assert result.code == "RelayError" + assert result.message.startswith("Job_no 1019905 is invalid")