mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Orchestrate an abandonment from a deal's recorded job 🟥
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d884edd456
commit
83af3ff4d0
2 changed files with 193 additions and 1 deletions
|
|
@ -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(
|
||||
|
|
|
|||
171
tests/orchestration/test_abri_orchestrator_abandon_job.py
Normal file
171
tests/orchestration/test_abri_orchestrator_abandon_job.py
Normal 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")
|
||||
Loading…
Add table
Reference in a new issue