Log an OpenHousing job from a confirmed survey booking's deal fields 🟥

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-06 15:50:07 +00:00
parent 9d4d764f48
commit 98674ae895
3 changed files with 221 additions and 0 deletions

View file

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

View file

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

View file

@ -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",
}