mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
A changed booking amends the deal's OpenHousing appointment 🟥
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
3e11dc54e5
commit
a93346ec7f
2 changed files with 207 additions and 0 deletions
|
|
@ -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(
|
||||
|
|
|
|||
180
tests/orchestration/test_abri_orchestrator_amend_job.py
Normal file
180
tests/orchestration/test_abri_orchestrator_amend_job.py
Normal 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")
|
||||
Loading…
Add table
Reference in a new issue