From c8fe4585bfbb80090a96e0731090b23581d3ddc4 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 17 Jul 2026 13:00:47 +0000 Subject: [PATCH] Send the deal's third-party surveyor as the OpenHousing resource Carry third_party_surveyor_identifier on the Abri trigger message and through dispatch into the log and amend flows, booking the job against that resource. LogJobRequest gains the optional resource field AmendJobRequest already had; the client falls back to the configured default surveyor when the deal names no third-party resource, so existing deals behave unchanged. Co-Authored-By: Claude Fable 5 --- applications/abri/abri_trigger_request.py | 3 + applications/abri/dispatch.py | 4 ++ domain/abri/models.py | 1 + etl/hubspot/hubspot_deal_differ.py | 6 ++ etl/hubspot/tests/test_abri_flow_triggers.py | 24 ++++++++ infrastructure/abri/abri_client.py | 10 +++- orchestration/abri_orchestrator.py | 8 +++ .../abri/test_abri_trigger_request.py | 21 +++++++ tests/applications/abri/test_dispatch.py | 3 + tests/infrastructure/abri/test_abri_client.py | 55 +++++++++++++++++++ .../test_abri_orchestrator_amend_job.py | 27 +++++++++ .../test_abri_orchestrator_log_job.py | 24 ++++++++ 12 files changed, 185 insertions(+), 1 deletion(-) diff --git a/applications/abri/abri_trigger_request.py b/applications/abri/abri_trigger_request.py index 92ec5b8c0..8b89980d9 100644 --- a/applications/abri/abri_trigger_request.py +++ b/applications/abri/abri_trigger_request.py @@ -37,6 +37,9 @@ class AbriTriggerRequest(BaseModel): confirmed_survey_time: Optional[str] = None last_submission_date: Optional[date] = None outcome: Optional[str] = None + # OpenHousing's bookable resource for the survey; optional because the + # Abri client falls back to the configured default surveyor when absent. + third_party_surveyor_identifier: Optional[str] = None @model_validator(mode="after") def _each_fired_flow_has_its_fields(self) -> "AbriTriggerRequest": diff --git a/applications/abri/dispatch.py b/applications/abri/dispatch.py index 6187c6ea3..323b07ce3 100644 --- a/applications/abri/dispatch.py +++ b/applications/abri/dispatch.py @@ -88,6 +88,7 @@ def _run_amend(request: AbriTriggerRequest, flows: AbriFlows) -> str: deal_id=request.hubspot_deal_id, confirmed_survey_date=request.confirmed_survey_date, confirmed_survey_time=request.confirmed_survey_time, + third_party_surveyor_identifier=request.third_party_surveyor_identifier, ) ) if isinstance(outcome, AbriRequestRejected): @@ -121,6 +122,9 @@ def _run_log( deal_name=request.deal_name, confirmed_survey_date=request.confirmed_survey_date, confirmed_survey_time=request.confirmed_survey_time, + third_party_surveyor_identifier=( + request.third_party_surveyor_identifier + ), ) ) except LogJobWriteBackError as error: diff --git a/domain/abri/models.py b/domain/abri/models.py index df726608b..c0801810b 100644 --- a/domain/abri/models.py +++ b/domain/abri/models.py @@ -44,6 +44,7 @@ class LogJobRequest: appointment_time: SlotCode short_description: str long_description: str + resource: Optional[str] = None @dataclass(frozen=True) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 16a0cb709..967a8de5b 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -267,6 +267,12 @@ class HubspotDealDiffer: else None ), "confirmed_survey_time": new_deal.get("confirmed_survey_time"), + # Carried for the log/amend flows: sent to OpenHousing as the + # bookable resource, falling back to the configured default + # surveyor when absent. + "third_party_surveyor_identifier": new_deal.get( + "third_party_surveyor_identifier" + ), # Carried for the abandon flow: it dates the cancel to the confirmed # survey date, falling back to the last submission date. "last_submission_date": ( diff --git a/etl/hubspot/tests/test_abri_flow_triggers.py b/etl/hubspot/tests/test_abri_flow_triggers.py index e6f5631f6..5ca9a5ab1 100644 --- a/etl/hubspot/tests/test_abri_flow_triggers.py +++ b/etl/hubspot/tests/test_abri_flow_triggers.py @@ -66,9 +66,33 @@ def test_a_first_confirmed_survey_date_builds_a_log_job_message() -> None: "confirmed_survey_time": "14:30", "last_submission_date": None, "outcome": None, + "third_party_surveyor_identifier": None, } +def test_the_message_carries_the_deals_third_party_surveyor_identifier() -> None: + # Arrange + old_deal = make_old_deal(project_code=ABRI_PROJECT_CODE, confirmed_survey_date=None) + new_deal = make_new_deal( + confirmed_survey_date="2026-06-24", + confirmed_survey_time="14:30", + third_party_surveyor_identifier="THIRDPARTY", + ) + + # Act + message = HubspotDealDiffer.check_abri_triggers_and_construct_message( + hubspot_deal_id=DEAL_ID, + new_deal=new_deal, + new_project=None, + new_listing=LISTING, + old_deal=old_deal, + ) + + # Assert + assert message is not None + assert message["third_party_surveyor_identifier"] == "THIRDPARTY" + + def test_a_changed_confirmed_survey_date_builds_an_amend_job_message() -> None: # Arrange old_deal = make_old_deal( diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py index 66e094eab..e8284e648 100644 --- a/infrastructure/abri/abri_client.py +++ b/infrastructure/abri/abri_client.py @@ -56,6 +56,14 @@ class AbriClient: self._session = requests.Session() def log_job(self, request: LogJobRequest) -> LogJobResult: + # OpenHousing needs a bookable resource, so fall back to the + # configured surveyor when the request carries no explicit override + # (mirrors amend_job). + resource = ( + request.resource + if request.resource is not None + else self._config.default_resource + ) outcome = self._exchange( request_type="logjob", parameters=[ @@ -67,7 +75,7 @@ class AbriClient: ("client_ref", request.client_ref), ("appointment_date", _format_appointment_date(request.appointment_date)), ("appointment_time", request.appointment_time), - ("resource", self._config.default_resource), + ("resource", resource), ("resource_group", RESOURCE_GROUP), ], ) diff --git a/orchestration/abri_orchestrator.py b/orchestration/abri_orchestrator.py index c0aba0c93..64d43265e 100644 --- a/orchestration/abri_orchestrator.py +++ b/orchestration/abri_orchestrator.py @@ -112,6 +112,9 @@ class ConfirmedSurveyBooking: deal_name: str confirmed_survey_date: date confirmed_survey_time: Optional[str] + # OpenHousing calls it the resource; the HubSpot deal property (and the + # database column) call it the third-party surveyor identifier. + third_party_surveyor_identifier: Optional[str] = None @dataclass(frozen=True) @@ -138,6 +141,9 @@ class AppointmentChange: deal_id: str confirmed_survey_date: date confirmed_survey_time: Optional[str] + # OpenHousing calls it the resource; the HubSpot deal property (and the + # database column) call it the third-party surveyor identifier. + third_party_surveyor_identifier: Optional[str] = None AmendJobOrchestrationResult = Union[AppointmentAmended, AbriRequestRejected] @@ -283,6 +289,7 @@ class AbriOrchestrator: appointment_time=slot_for_confirmed_time( change.confirmed_survey_time ), + resource=change.third_party_surveyor_identifier, ) ) @@ -319,6 +326,7 @@ class AbriOrchestrator: ), short_description=descriptions.short_description, long_description=descriptions.long_description, + resource=booking.third_party_surveyor_identifier, ) ) diff --git a/tests/applications/abri/test_abri_trigger_request.py b/tests/applications/abri/test_abri_trigger_request.py index 5d723e3f0..74140656c 100644 --- a/tests/applications/abri/test_abri_trigger_request.py +++ b/tests/applications/abri/test_abri_trigger_request.py @@ -97,6 +97,27 @@ def test_a_message_naming_abandon_job_validates_with_only_the_deal_id() -> None: assert request.flows == ["abandon_job"] +def test_a_message_carries_the_third_party_surveyor_identifier() -> None: + # Arrange + message = _full_message() + message["third_party_surveyor_identifier"] = "THIRDPARTY" + + # Act + request = AbriTriggerRequest.model_validate(message) + + # Assert + assert request.third_party_surveyor_identifier == "THIRDPARTY" + + +def test_a_message_without_a_surveyor_identifier_parses_with_none() -> None: + # Arrange: optional because the Abri client falls back to the configured + # default surveyor when the deal names no third-party resource. + request = AbriTriggerRequest.model_validate(_full_message()) + + # Assert + assert request.third_party_surveyor_identifier is None + + def test_a_message_carries_the_outcome_for_the_abandonment_reason_mapping() -> None: # Arrange message = _full_message() diff --git a/tests/applications/abri/test_dispatch.py b/tests/applications/abri/test_dispatch.py index 4b7090ae2..7d898b155 100644 --- a/tests/applications/abri/test_dispatch.py +++ b/tests/applications/abri/test_dispatch.py @@ -40,6 +40,7 @@ def _request(flows: List[str]) -> AbriTriggerRequest: "confirmed_survey_time": "14:30", "last_submission_date": "2026-07-01", "outcome": "no answer", + "third_party_surveyor_identifier": "THIRDPARTY", } ) @@ -171,6 +172,7 @@ def test_flows_receive_the_deal_fields_carried_by_the_message( deal_id=DEAL_ID, confirmed_survey_date=date(2026, 6, 24), confirmed_survey_time="14:30", + third_party_surveyor_identifier="THIRDPARTY", ), ) assert log_call == ( @@ -181,6 +183,7 @@ def test_flows_receive_the_deal_fields_carried_by_the_message( deal_name="49 Admers Crescent", confirmed_survey_date=date(2026, 6, 24), confirmed_survey_time="14:30", + third_party_surveyor_identifier="THIRDPARTY", ), ) diff --git a/tests/infrastructure/abri/test_abri_client.py b/tests/infrastructure/abri/test_abri_client.py index 42ede432a..bfdc5c992 100644 --- a/tests/infrastructure/abri/test_abri_client.py +++ b/tests/infrastructure/abri/test_abri_client.py @@ -145,6 +145,61 @@ def test_log_job_sends_the_recorded_logjob_envelope_to_the_relay_endpoint( ) +def test_log_job_sends_the_requested_resource_instead_of_the_configured_default( + client: AbriClient, mock_session: MagicMock +) -> None: + # Arrange: a deal carrying a third-party surveyor identifier books the + # job against that resource, not the configured default surveyor. + mock_session.post.return_value.content = _load_fixture( + "abri_relay_logjob_success_response.xml" + ) + + # Act + client.log_job( + LogJobRequest( + client_ref="DOM51111", + place_ref=PlaceRef("1007165"), + appointment_date=date(2026, 6, 18), + appointment_time="PM", + short_description="Christian's SCS External Test.", + long_description="Christian's SCS External Test.", + resource="THIRDPARTY", + ) + ) + + # Assert + sent_body: bytes = mock_session.post.call_args.kwargs["data"] + resources = [ + parameter.get("attribute_value") + for parameter in ET.fromstring(sent_body).findall(".//Parameters") + if parameter.get("attribute") == "resource" + ] + assert resources == ["THIRDPARTY"] + + +def test_log_job_defaults_the_resource_to_the_configured_surveyor( + client: AbriClient, mock_session: MagicMock +) -> None: + # Arrange: OpenHousing needs a bookable resource, so a log without an + # explicit surveyor falls back to the configured default rather than + # omitting the parameter. + mock_session.post.return_value.content = _load_fixture( + "abri_relay_logjob_success_response.xml" + ) + + # Act + client.log_job(_spec_example_request()) + + # Assert + sent_body: bytes = mock_session.post.call_args.kwargs["data"] + resources = [ + parameter.get("attribute_value") + for parameter in ET.fromstring(sent_body).findall(".//Parameters") + if parameter.get("attribute") == "resource" + ] + assert resources == [CONFIG.default_resource] + + def test_log_job_declares_the_xml_body_content_type_the_relay_requires( client: AbriClient, mock_session: MagicMock ) -> None: diff --git a/tests/orchestration/test_abri_orchestrator_amend_job.py b/tests/orchestration/test_abri_orchestrator_amend_job.py index 1a3386a5b..82ee126b4 100644 --- a/tests/orchestration/test_abri_orchestrator_amend_job.py +++ b/tests/orchestration/test_abri_orchestrator_amend_job.py @@ -1,4 +1,5 @@ import xml.etree.ElementTree as ET +from dataclasses import replace from datetime import date from pathlib import Path from typing import Dict, List, Optional, Tuple @@ -120,6 +121,32 @@ def test_amend_job_sends_an_amendoptiappt_envelope_for_the_deals_recorded_job( } +def test_amend_job_books_the_deals_third_party_surveyor_as_the_resource( + orchestrator: AbriOrchestrator, + mock_session: MagicMock, + deal_database: FakeDealDatabase, +) -> None: + # Arrange: a deal carrying a third-party surveyor identifier amends the + # appointment against that resource instead of the configured default. + deal_database.job_nos[DEAL_ID] = JOB_NO + mock_session.post.return_value.content = _load_fixture( + "abri_relay_amendoptiappt_success_response.xml" + ) + + # Act + orchestrator.amend_job( + replace(CHANGE, third_party_surveyor_identifier="THIRDPARTY") + ) + + # Assert + 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 sent_parameters["resource"] == "THIRDPARTY" + + # --- happy path: the amended appointment is confirmed back --- diff --git a/tests/orchestration/test_abri_orchestrator_log_job.py b/tests/orchestration/test_abri_orchestrator_log_job.py index c47a7fcd2..9a5d2846d 100644 --- a/tests/orchestration/test_abri_orchestrator_log_job.py +++ b/tests/orchestration/test_abri_orchestrator_log_job.py @@ -1,6 +1,7 @@ import json import traceback import xml.etree.ElementTree as ET +from dataclasses import replace from datetime import date from pathlib import Path from typing import List, Optional, Tuple @@ -141,6 +142,29 @@ def test_log_job_sends_a_logjob_envelope_built_from_the_deals_fields( } +def test_log_job_books_the_deals_third_party_surveyor_as_the_resource( + orchestrator: AbriOrchestrator, mock_session: MagicMock +) -> None: + # Arrange: a deal carrying a third-party surveyor identifier books the + # job against that resource instead of the configured default surveyor. + mock_session.post.return_value.content = _load_fixture( + "abri_relay_logjob_success_response.xml" + ) + + # Act + orchestrator.log_job( + replace(BOOKING, third_party_surveyor_identifier="THIRDPARTY") + ) + + # Assert + 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 sent_parameters["resource"] == "THIRDPARTY" + + # --- happy path: the returned job_no lands in HubSpot, then the database ---