Orchestrate an abandonment from a deal's recorded job 🟥

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-07 15:40:25 +00:00
parent d884edd456
commit 83af3ff4d0
2 changed files with 193 additions and 1 deletions

View file

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

View file

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