A changed booking amends the deal's OpenHousing appointment 🟥

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-07 09:56:54 +00:00
parent 3e11dc54e5
commit a93346ec7f
2 changed files with 207 additions and 0 deletions

View file

@ -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(

View file

@ -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")