diff --git a/orchestration/abri_orchestrator.py b/orchestration/abri_orchestrator.py index 920eaffcf..3238066f4 100644 --- a/orchestration/abri_orchestrator.py +++ b/orchestration/abri_orchestrator.py @@ -5,6 +5,8 @@ from typing import Dict, List, Optional, Protocol, Tuple, Union from domain.abri.descriptions import build_job_descriptions from domain.abri.models import ( AbriRequestRejected, + AmendJobRequest, + AppointmentAmended, LogJobRequest, PlaceRef, Tenant, @@ -123,6 +125,26 @@ LogJobOrchestrationResult = Union[LogJobSummary, AbriRequestRejected] class DealDatabaseGateway(Protocol): def record_job_no(self, deal_id: str, job_no: str) -> None: ... + def job_no_for_deal(self, deal_id: str) -> Optional[str]: ... + + +AmendJobOrchestrationResult = Union[AppointmentAmended, AbriRequestRejected] + + +class JobNoNotYetRecordedError(Exception): + """An amendment arrived before the deal's job_no landed in the database. + + Retriable: the message should be redelivered until the log flow's + write-back lands (or it dead-letters), so a rapid log-then-amend + sequence eventually converges. + """ + + def __init__(self, deal_id: str) -> None: + self.deal_id = deal_id + super().__init__( + f"no job_no recorded yet for deal {deal_id}; cannot amend its appointment" + ) + class LogJobWriteBackError(Exception): """A job logged in OpenHousing whose job_no could not be written back. @@ -208,6 +230,11 @@ class AbriOrchestrator: ), ) + def amend_job( + self, booking: ConfirmedSurveyBooking + ) -> AmendJobOrchestrationResult: + 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_amend_job.py b/tests/orchestration/test_abri_orchestrator_amend_job.py new file mode 100644 index 000000000..44b984c2a --- /dev/null +++ b/tests/orchestration/test_abri_orchestrator_amend_job.py @@ -0,0 +1,180 @@ +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, AppointmentAmended, PlaceRef +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, + ConfirmedSurveyBooking, + 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" + +CONFIG = AbriConfig( + endpoint_url=ENDPOINT_URL, + username="DomnaWeb", + password="", + default_resource="NAULKH", +) + +BOOKING = ConfirmedSurveyBooking( + deal_id=DEAL_ID, + place_ref=PlaceRef("1007165"), + deal_name="49 Admers Crescent", + confirmed_survey_date=date(2026, 6, 24), + confirmed_survey_time="14:30", +) + + +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, + ) + + +# --- outbound request: the AmendOptiAppt envelope targets the recorded job --- + + +def test_amend_job_sends_an_amendoptiappt_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_amendoptiappt_success_response.xml" + ) + + # Act + orchestrator.amend_job(BOOKING) + + # 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, + "action": "amend", + "appointment_date": "24/06/2026", + "appointment_time": "PM", + } + + +# --- happy path: the amended appointment is confirmed back --- + + +def test_amend_job_returns_the_amended_appointment( + 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_amendoptiappt_success_response.xml" + ) + + # Act + result = orchestrator.amend_job(BOOKING) + + # Assert + assert result == AppointmentAmended( + job_no=JOB_NO, + appointment_date="24/06/2026", + appointment_time="PM", + ) + + +# --- ordering race: amendment before the job_no write-back has landed --- + + +def test_amend_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.amend_job(BOOKING) + assert mock_session.post.call_count == 0 + + +# --- rejection passthrough: OpenHousing's actual reason, verbatim --- + + +def test_amend_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_amendoptiappt_relayerror_response.xml" + ) + + # Act + result = orchestrator.amend_job(BOOKING) + + # Assert + assert isinstance(result, AbriRequestRejected) + assert result.code == "RelayError" + assert result.message.startswith("Job_no 1019905 is invalid")