From 98674ae895fc356d2a621c94f141146ac86c34bf Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 6 Jul 2026 15:50:07 +0000 Subject: [PATCH] =?UTF-8?q?Log=20an=20OpenHousing=20job=20from=20a=20confi?= =?UTF-8?q?rmed=20survey=20booking's=20deal=20fields=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../hubspot/deal_properties_client.py | 18 +++ orchestration/abri_log_job_orchestrator.py | 82 ++++++++++++ .../test_abri_log_job_orchestrator.py | 121 ++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 infrastructure/hubspot/deal_properties_client.py create mode 100644 orchestration/abri_log_job_orchestrator.py create mode 100644 tests/orchestration/test_abri_log_job_orchestrator.py diff --git a/infrastructure/hubspot/deal_properties_client.py b/infrastructure/hubspot/deal_properties_client.py new file mode 100644 index 000000000..eb2e132d4 --- /dev/null +++ b/infrastructure/hubspot/deal_properties_client.py @@ -0,0 +1,18 @@ +from hubspot.client import Client # type: ignore[reportMissingTypeStubs] + + +class HubspotDealPropertiesClient: + """Focused HubSpot client for writing single deal properties. + + Deliberately separate from the legacy HubspotClient (frozen for the + refactor tracked in issue #1473): the SDK client is constructor-injected, + and SDK errors are re-raised scrubbed of response bodies. + """ + + def __init__(self, sdk_client: Client) -> None: + self._client: Client = sdk_client + + def write_deal_property( + self, deal_id: str, property_name: str, value: str + ) -> None: + raise NotImplementedError diff --git a/orchestration/abri_log_job_orchestrator.py b/orchestration/abri_log_job_orchestrator.py new file mode 100644 index 000000000..b31cfe1c3 --- /dev/null +++ b/orchestration/abri_log_job_orchestrator.py @@ -0,0 +1,82 @@ +from dataclasses import dataclass +from datetime import date +from typing import Optional, Protocol, Union + +from domain.abri.models import AbriRequestRejected, PlaceRef +from infrastructure.abri.abri_client import AbriClient +from infrastructure.hubspot.deal_properties_client import HubspotDealPropertiesClient + +# The HubSpot deal property that carries the OpenHousing job number; the +# property ID is fixed externally, the domain and database call it job_no. +JOB_NO_DEAL_PROPERTY = "client_booking_reference" + + +@dataclass(frozen=True) +class ConfirmedSurveyBooking: + """The deal fields a confirmed survey booking contributes to a LogJob.""" + + deal_id: str + place_ref: PlaceRef + deal_name: str + confirmed_survey_date: date + confirmed_survey_time: Optional[str] + + +@dataclass(frozen=True) +class LogJobSummary: + """PII-free record of a logged job: safe to log and persist.""" + + deal_id: str + job_no: str + + +LogJobOrchestrationResult = Union[LogJobSummary, AbriRequestRejected] + + +class DealDatabaseGateway(Protocol): + def record_job_no(self, deal_id: str, job_no: str) -> None: ... + + +class LogJobWriteBackError(Exception): + """A job logged in OpenHousing whose job_no could not be written back. + + Carries the orphaned job_no so an operator can paste it into HubSpot + manually; the run must never retry log_job, which would log a duplicate + job in OpenHousing. + """ + + def __init__(self, message: str, deal_id: str, job_no: str) -> None: + self.deal_id = deal_id + self.job_no = job_no + super().__init__(f"{message} (deal: {deal_id}, orphaned job_no: {job_no})") + + +def client_ref_for_deal(deal_id: str) -> str: + """The client_ref sent to OpenHousing: the HubSpot deal ID, for now. + + UNCONFIRMED with Abri: whether client_ref must be unique per job (a deal + re-logging after a cancellation would repeat the bare deal ID) is an open + question on issue #1478. Swap the derivation here once Abri answers. + """ + raise NotImplementedError + + +class AbriLogJobOrchestrator: + """Logs an OpenHousing job for a deal and records the returned job_no. + + HubSpot is written first (it is the source of truth); the database + second, so a failed database write self-heals on the next scrape. + """ + + def __init__( + self, + abri_client: AbriClient, + hubspot: HubspotDealPropertiesClient, + deal_database: DealDatabaseGateway, + ) -> None: + self._abri = abri_client + self._hubspot = hubspot + self._deal_database = deal_database + + def run(self, booking: ConfirmedSurveyBooking) -> LogJobOrchestrationResult: + raise NotImplementedError diff --git a/tests/orchestration/test_abri_log_job_orchestrator.py b/tests/orchestration/test_abri_log_job_orchestrator.py new file mode 100644 index 000000000..afdc02f39 --- /dev/null +++ b/tests/orchestration/test_abri_log_job_orchestrator.py @@ -0,0 +1,121 @@ +import xml.etree.ElementTree as ET +from datetime import date +from pathlib import Path +from typing import List, Tuple +from unittest.mock import MagicMock, patch + +import pytest + +from domain.abri.models import PlaceRef +from infrastructure.abri.abri_client import AbriClient +from infrastructure.abri.config import AbriConfig +from infrastructure.hubspot.deal_properties_client import HubspotDealPropertiesClient +from orchestration.abri_log_job_orchestrator import ( + AbriLogJobOrchestrator, + ConfirmedSurveyBooking, +) + +FIXTURE_DIR = Path(__file__).parents[1] / "abri" +ENDPOINT_URL = "https://relay.example.test/api/DomnaRelay?code=test-function-key" +DEAL_ID = "9876543210" + +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, 18), + 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]] = [] + + def record_job_no(self, deal_id: str, job_no: str) -> None: + self.recorded_job_nos.append((deal_id, job_no)) + + +@pytest.fixture() +def mock_session() -> MagicMock: + return MagicMock() + + +@pytest.fixture() +def sdk_client() -> MagicMock: + return MagicMock() + + +@pytest.fixture() +def deal_database() -> FakeDealDatabase: + return FakeDealDatabase() + + +@pytest.fixture() +def orchestrator( + mock_session: MagicMock, + sdk_client: MagicMock, + deal_database: FakeDealDatabase, +) -> AbriLogJobOrchestrator: + with patch( + "infrastructure.abri.abri_client.requests.Session", + return_value=mock_session, + ): + abri_client = AbriClient(config=CONFIG) + return AbriLogJobOrchestrator( + abri_client=abri_client, + hubspot=HubspotDealPropertiesClient(sdk_client=sdk_client), + deal_database=deal_database, + ) + + +# --- outbound request: the LogJob envelope is built from the deal's fields --- + + +def test_run_sends_a_logjob_envelope_built_from_the_deals_fields( + orchestrator: AbriLogJobOrchestrator, mock_session: MagicMock +) -> None: + # Arrange + mock_session.post.return_value.content = _load_fixture( + "abri_relay_logjob_success_response.xml" + ) + + # Act + orchestrator.run(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 == { + "place_ref": "1007165", + "std_job_code": "SCSEXT", + "client": "HSG", + "short_description": "Domna condition survey - 49 Admers Crescent", + "long_description": ( + "Domna condition survey visit at 49 Admers Crescent, " + "booked by Domna operations." + ), + "client_ref": DEAL_ID, + "appointment_date": "18/06/2026", + "appointment_time": "PM", + "resource": "NAULKH", + "resource_group": "Surveyors", + }