mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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:
parent
9d4d764f48
commit
98674ae895
3 changed files with 221 additions and 0 deletions
18
infrastructure/hubspot/deal_properties_client.py
Normal file
18
infrastructure/hubspot/deal_properties_client.py
Normal 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
|
||||
82
orchestration/abri_log_job_orchestrator.py
Normal file
82
orchestration/abri_log_job_orchestrator.py
Normal 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
|
||||
121
tests/orchestration/test_abri_log_job_orchestrator.py
Normal file
121
tests/orchestration/test_abri_log_job_orchestrator.py
Normal 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",
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue