diff --git a/domain/abri/__init__.py b/domain/abri/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/domain/abri/descriptions.py b/domain/abri/descriptions.py new file mode 100644 index 000000000..3a422e55f --- /dev/null +++ b/domain/abri/descriptions.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class JobDescriptions: + short_description: str + long_description: str + + +def build_job_descriptions(deal_name: str) -> JobDescriptions: + # Placeholder copy — final wording is owned by Abri's schedulers (issue #1455). + return JobDescriptions( + short_description=f"Domna condition survey - {deal_name}", + long_description=( + f"Domna condition survey visit at {deal_name}, " + "booked by Domna operations." + ), + ) diff --git a/domain/abri/models.py b/domain/abri/models.py new file mode 100644 index 000000000..e160c3e3b --- /dev/null +++ b/domain/abri/models.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass +from datetime import date +from typing import Literal, NewType, Union + +PlaceRef = NewType("PlaceRef", str) + +SlotCode = Literal["AM", "PM", "AD"] + + +@dataclass(frozen=True) +class LogJobRequest: + client_ref: str + place_ref: PlaceRef + appointment_date: date + appointment_time: SlotCode + short_description: str + long_description: str + + +@dataclass(frozen=True) +class JobLogged: + job_no: str + logged_info: str + + +@dataclass(frozen=True) +class AbriRequestRejected: + code: str + message: str + + +LogJobResult = Union[JobLogged, AbriRequestRejected] diff --git a/domain/abri/slots.py b/domain/abri/slots.py new file mode 100644 index 000000000..992426ee9 --- /dev/null +++ b/domain/abri/slots.py @@ -0,0 +1,13 @@ +from datetime import datetime, time +from typing import Optional + +from domain.abri.models import SlotCode + +_MIDDAY = time(12, 0) + + +def slot_for_confirmed_time(confirmed_time: Optional[str]) -> SlotCode: + if confirmed_time is None or confirmed_time.strip() == "": + return "AD" + parsed = datetime.strptime(confirmed_time.strip(), "%H:%M").time() + return "AM" if parsed < _MIDDAY else "PM" diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 1a3dfc409..b693cf7a4 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -183,7 +183,7 @@ class HubspotDealDiffer: return False @staticmethod - def check_for_abri_survey_creation( + def check_for_abri_job_logging( new_deal: Dict[str, str], new_project: Optional[ProjectData], old_deal: HubspotDealData, @@ -205,7 +205,7 @@ class HubspotDealDiffer: if not HubspotDealDiffer._is_abri_condition_deal(new_deal, new_project): return False - # A first-time survey date is a creation, not an amendment. + # A first-time survey date logs a new job, not an amendment. if old_deal.confirmed_survey_date is None: return False diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index d1feb9a90..08e6c78a1 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -344,12 +344,12 @@ def test_magicplan_trigger__outcome_surveyed_uppercase__returns_true() -> None: assert result is True -# ================================== -# ABRI SURVEY CREATION TRIGGER TESTS -# ================================== +# ============================== +# ABRI JOB LOGGING TRIGGER TESTS +# ============================== -def test_abri_survey_creation__date_first_set_on_abri_deal__returns_true() -> None: +def test_abri_job_logging__date_first_set_on_abri_deal__returns_true() -> None: deal_id = uuid.uuid4() # Arrange @@ -365,7 +365,7 @@ def test_abri_survey_creation__date_first_set_on_abri_deal__returns_true() -> No ) # Act - result = HubspotDealDiffer.check_for_abri_survey_creation( + result = HubspotDealDiffer.check_for_abri_job_logging( new_deal=new_deal, new_project=None, old_deal=old_deal, @@ -375,7 +375,7 @@ def test_abri_survey_creation__date_first_set_on_abri_deal__returns_true() -> No assert result is True -def test_abri_survey_creation__date_already_set__returns_false() -> None: +def test_abri_job_logging__date_already_set__returns_false() -> None: deal_id = uuid.uuid4() # Arrange @@ -391,7 +391,7 @@ def test_abri_survey_creation__date_already_set__returns_false() -> None: ) # Act - result = HubspotDealDiffer.check_for_abri_survey_creation( + result = HubspotDealDiffer.check_for_abri_job_logging( new_deal=new_deal, new_project=None, old_deal=old_deal, @@ -401,7 +401,7 @@ def test_abri_survey_creation__date_already_set__returns_false() -> None: assert result is False -def test_abri_survey_creation__non_abri_project_code__returns_false() -> None: +def test_abri_job_logging__non_abri_project_code__returns_false() -> None: deal_id = uuid.uuid4() # Arrange @@ -417,7 +417,7 @@ def test_abri_survey_creation__non_abri_project_code__returns_false() -> None: ) # Act - result = HubspotDealDiffer.check_for_abri_survey_creation( + result = HubspotDealDiffer.check_for_abri_job_logging( new_deal=new_deal, new_project=None, old_deal=old_deal, @@ -427,7 +427,7 @@ def test_abri_survey_creation__non_abri_project_code__returns_false() -> None: assert result is False -def test_abri_survey_creation__project_code_cased_differently__returns_true() -> None: +def test_abri_job_logging__project_code_cased_differently__returns_true() -> None: deal_id = uuid.uuid4() # Arrange @@ -443,7 +443,7 @@ def test_abri_survey_creation__project_code_cased_differently__returns_true() -> ) # Act - result = HubspotDealDiffer.check_for_abri_survey_creation( + result = HubspotDealDiffer.check_for_abri_job_logging( new_deal=new_deal, new_project=None, old_deal=old_deal, @@ -461,7 +461,7 @@ def test_abri_survey_creation__project_code_cased_differently__returns_true() -> {"confirmed_survey_date": "not-a-date"}, ], ) -def test_abri_survey_creation__no_parseable_new_date__returns_false( +def test_abri_job_logging__no_parseable_new_date__returns_false( new_overrides: Dict[str, str], ) -> None: deal_id = uuid.uuid4() @@ -479,7 +479,7 @@ def test_abri_survey_creation__no_parseable_new_date__returns_false( ) # Act - result = HubspotDealDiffer.check_for_abri_survey_creation( + result = HubspotDealDiffer.check_for_abri_job_logging( new_deal=new_deal, new_project=None, old_deal=old_deal, @@ -489,7 +489,7 @@ def test_abri_survey_creation__no_parseable_new_date__returns_false( assert result is False -def test_abri_survey_creation__associated_abri_project_id__returns_true() -> None: +def test_abri_job_logging__associated_abri_project_id__returns_true() -> None: deal_id = uuid.uuid4() # Arrange @@ -509,7 +509,7 @@ def test_abri_survey_creation__associated_abri_project_id__returns_true() -> Non ) # Act - result = HubspotDealDiffer.check_for_abri_survey_creation( + result = HubspotDealDiffer.check_for_abri_job_logging( new_deal=new_deal, new_project=new_project, old_deal=old_deal, @@ -519,7 +519,7 @@ def test_abri_survey_creation__associated_abri_project_id__returns_true() -> Non assert result is True -def test_abri_survey_creation__non_abri_project_id_with_abri_code__returns_false() -> ( +def test_abri_job_logging__non_abri_project_id_with_abri_code__returns_false() -> ( None ): deal_id = uuid.uuid4() @@ -538,7 +538,7 @@ def test_abri_survey_creation__non_abri_project_id_with_abri_code__returns_false new_project = ProjectData(project_id="999999", name="Southern Retrofit") # Act - result = HubspotDealDiffer.check_for_abri_survey_creation( + result = HubspotDealDiffer.check_for_abri_job_logging( new_deal=new_deal, new_project=new_project, old_deal=old_deal, diff --git a/infrastructure/abri/__init__.py b/infrastructure/abri/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py new file mode 100644 index 000000000..72a3c4767 --- /dev/null +++ b/infrastructure/abri/abri_client.py @@ -0,0 +1,113 @@ +import xml.etree.ElementTree as ET +from typing import List, Tuple, Union + +import requests + +from domain.abri.models import ( + AbriRequestRejected, + JobLogged, + LogJobRequest, + LogJobResult, +) +from infrastructure.abri.config import AbriConfig +from infrastructure.abri.envelope import serialise_relay_request +from infrastructure.abri.errors import AbriResponseParseError, AbriTransportError + +STD_JOB_CODE = "SCSEXT" +CLIENT_CODE = "HSG" +RESOURCE_GROUP = "Surveyors" + + +class AbriClient: + def __init__(self, config: AbriConfig) -> None: + self._config = config + self._session = requests.Session() + + def log_job(self, request: LogJobRequest) -> LogJobResult: + outcome = self._exchange( + request_type="logjob", + parameters=[ + ("place_ref", request.place_ref), + ("std_job_code", STD_JOB_CODE), + ("client", CLIENT_CODE), + ("short_description", request.short_description), + ("long_description", request.long_description), + ("client_ref", request.client_ref), + ("appointment_date", request.appointment_date.strftime("%d/%m/%Y")), + ("appointment_time", request.appointment_time), + ("resource", self._config.default_resource), + ("resource_group", RESOURCE_GROUP), + ], + ) + + if isinstance(outcome, AbriRequestRejected): + return outcome + + return self._parse_job_logged(outcome) + + def _exchange( + self, request_type: str, parameters: List[Tuple[str, str]] + ) -> Union[ET.Element, AbriRequestRejected]: + envelope = serialise_relay_request( + request_type=request_type, + parameters=parameters, + username=self._config.username, + password=self._config.password, + ) + + reply = self._parse_reply(self._post(envelope)) + + if self._is_failure_document(reply): + return self._parse_rejection(reply) + + return reply + + def _post(self, envelope: bytes) -> bytes: + try: + response = self._session.post(self._config.endpoint_url, data=envelope) + response.raise_for_status() + except requests.RequestException as error: + raise AbriTransportError(str(error)) from error + + return response.content + + @staticmethod + def _parse_reply(body: bytes) -> ET.Element: + try: + return ET.fromstring(body) + except ET.ParseError as error: + raise AbriResponseParseError(str(error)) from error + + @staticmethod + def _is_failure_document(reply: ET.Element) -> bool: + return reply.tag == "response" + + @staticmethod + def _parse_job_logged(root: ET.Element) -> JobLogged: + job_logged = root.find("Jobs/Job_logged") + + if job_logged is None: + raise AbriResponseParseError( + "Job_logged element missing from relay response" + ) + + job_no = job_logged.get("job_no") + logged_info = job_logged.get("logged_info") + + if job_no is None or logged_info is None: + raise AbriResponseParseError( + "Job_logged element missing job_no or logged_info" + ) + + return JobLogged(job_no=job_no, logged_info=logged_info) + + @staticmethod + def _parse_rejection(root: ET.Element) -> AbriRequestRejected: + success = root.findtext("success") + code = root.findtext("code") + message = root.findtext("message") + + if success != "false" or code is None or message is None: + raise AbriResponseParseError("malformed relay failure response") + + return AbriRequestRejected(code=code, message=message) diff --git a/infrastructure/abri/config.py b/infrastructure/abri/config.py new file mode 100644 index 000000000..f2da18a77 --- /dev/null +++ b/infrastructure/abri/config.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from typing import Mapping + + +@dataclass(frozen=True) +class AbriConfig: + endpoint_url: str + username: str + password: str + default_resource: str + + @classmethod + def from_env(cls, env: Mapping[str, str]) -> "AbriConfig": + return cls( + endpoint_url=env["ABRI_RELAY_URL"], + username=env["ABRI_RELAY_USERNAME"], + password=env["ABRI_RELAY_PASSWORD"], + default_resource=env["ABRI_RELAY_DEFAULT_RESOURCE"], + ) diff --git a/infrastructure/abri/envelope.py b/infrastructure/abri/envelope.py new file mode 100644 index 000000000..b7eeb89cd --- /dev/null +++ b/infrastructure/abri/envelope.py @@ -0,0 +1,23 @@ +import xml.etree.ElementTree as ET +from typing import Sequence, Tuple + + +def serialise_relay_request( + request_type: str, + parameters: Sequence[Tuple[str, str]], + username: str, + password: str, +) -> bytes: + message = ET.Element("message") + header = ET.SubElement(message, "Header") + ET.SubElement(header, "Security", username=username, password=password) + body = ET.SubElement(message, "Body") + request = ET.SubElement(body, "Request", request_type=request_type) + for attribute, attribute_value in parameters: + ET.SubElement( + request, + "Parameters", + attribute=attribute, + attribute_value=attribute_value, + ) + return ET.tostring(message, encoding="utf-8", xml_declaration=True) diff --git a/infrastructure/abri/errors.py b/infrastructure/abri/errors.py new file mode 100644 index 000000000..39499293a --- /dev/null +++ b/infrastructure/abri/errors.py @@ -0,0 +1,10 @@ +class AbriTransportError(Exception): + """A transport-level relay failure; the request may be retried.""" + + +class AbriResponseParseError(AbriTransportError): + """An unparseable or unexpectedly-shaped relay response. + + Transport-class (retriable): the job may or may not have been logged, + so the outcome must never be treated as success or rejection. + """ diff --git a/tests/abri/abri_relay_logjob_relayerror_response.xml b/tests/abri/abri_relay_logjob_relayerror_response.xml new file mode 100644 index 000000000..ac6647828 --- /dev/null +++ b/tests/abri/abri_relay_logjob_relayerror_response.xml @@ -0,0 +1,6 @@ + + + false + RelayError + No property was found with UPRN/place_ref '1007176aa', street name '', house number '0', house name '', post code '' + diff --git a/tests/abri/abri_relay_logjob_request_example.xml b/tests/abri/abri_relay_logjob_request_example.xml new file mode 100644 index 000000000..9fe160990 --- /dev/null +++ b/tests/abri/abri_relay_logjob_request_example.xml @@ -0,0 +1,20 @@ + + +
+ +
+ + + + + + + + + + + + + + +
diff --git a/tests/abri/abri_relay_logjob_success_response.xml b/tests/abri/abri_relay_logjob_success_response.xml new file mode 100644 index 000000000..aabd14b40 --- /dev/null +++ b/tests/abri/abri_relay_logjob_success_response.xml @@ -0,0 +1,6 @@ + + + + AD0226519 + + diff --git a/tests/domain/abri/__init__.py b/tests/domain/abri/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/domain/abri/test_descriptions.py b/tests/domain/abri/test_descriptions.py new file mode 100644 index 000000000..593f62191 --- /dev/null +++ b/tests/domain/abri/test_descriptions.py @@ -0,0 +1,15 @@ +from domain.abri.descriptions import build_job_descriptions + + +def test_job_descriptions_identify_the_visit_as_a_domna_condition_survey() -> None: + # Arrange + deal_name = "49 Admers Crescent, Liphook" + + # Act + descriptions = build_job_descriptions(deal_name) + + # Assert + assert "Domna condition survey" in descriptions.short_description + assert deal_name in descriptions.short_description + assert "Domna condition survey" in descriptions.long_description + assert deal_name in descriptions.long_description diff --git a/tests/domain/abri/test_slots.py b/tests/domain/abri/test_slots.py new file mode 100644 index 000000000..fa5283691 --- /dev/null +++ b/tests/domain/abri/test_slots.py @@ -0,0 +1,47 @@ +from typing import Optional + +import pytest + +from domain.abri.slots import slot_for_confirmed_time + + +@pytest.mark.parametrize("confirmed_time", [None, "", " "]) +def test_a_booking_with_no_confirmed_time_is_an_all_day_appointment( + confirmed_time: Optional[str], +) -> None: + # Act + slot = slot_for_confirmed_time(confirmed_time) + + # Assert + assert slot == "AD" + + +@pytest.mark.parametrize("confirmed_time", ["08:00", "09:30", "11:59"]) +def test_a_confirmed_time_before_midday_books_the_morning_slot( + confirmed_time: str, +) -> None: + # Act + slot = slot_for_confirmed_time(confirmed_time) + + # Assert + assert slot == "AM" + + +@pytest.mark.parametrize("confirmed_time", ["12:00", "13:15", "16:59"]) +def test_a_confirmed_time_at_or_after_midday_books_the_afternoon_slot( + confirmed_time: str, +) -> None: + # Act + slot = slot_for_confirmed_time(confirmed_time) + + # Assert + assert slot == "PM" + + +@pytest.mark.parametrize("confirmed_time", ["half nine", "25:00", "9.30am"]) +def test_an_unparseable_confirmed_time_fails_loudly_rather_than_booking_all_day( + confirmed_time: str, +) -> None: + # Act / Assert + with pytest.raises(ValueError): + slot_for_confirmed_time(confirmed_time) diff --git a/tests/infrastructure/abri/__init__.py b/tests/infrastructure/abri/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/infrastructure/abri/test_abri_client.py b/tests/infrastructure/abri/test_abri_client.py new file mode 100644 index 000000000..133c74d8f --- /dev/null +++ b/tests/infrastructure/abri/test_abri_client.py @@ -0,0 +1,194 @@ +import xml.etree.ElementTree as ET +from datetime import date +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from domain.abri.models import ( + AbriRequestRejected, + JobLogged, + LogJobRequest, + PlaceRef, +) +from infrastructure.abri.abri_client import AbriClient +from infrastructure.abri.config import AbriConfig +from infrastructure.abri.errors import AbriResponseParseError, AbriTransportError + +FIXTURE_DIR = Path(__file__).parents[2] / "abri" +ENDPOINT_URL = "https://relay.example.test/api/DomnaRelay?code=test-function-key" + +CONFIG = AbriConfig( + endpoint_url=ENDPOINT_URL, + username="DomnaWeb", + password="", + default_resource="NAULKH", +) + + +def _load_fixture(name: str) -> bytes: + return (FIXTURE_DIR / name).read_bytes() + + +def _make_client(mock_session: MagicMock) -> AbriClient: + with patch( + "infrastructure.abri.abri_client.requests.Session", + return_value=mock_session, + ): + return AbriClient(config=CONFIG) + + +def _spec_example_request() -> LogJobRequest: + return 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.", + ) + + +@pytest.fixture() +def mock_session() -> MagicMock: + return MagicMock() + + +@pytest.fixture() +def client(mock_session: MagicMock) -> AbriClient: + return _make_client(mock_session) + + +# --- log_job: success --- + + +def test_log_job_returns_job_logged_with_openhousing_job_number( + client: AbriClient, mock_session: MagicMock +) -> None: + # Arrange + mock_session.post.return_value.content = _load_fixture( + "abri_relay_logjob_success_response.xml" + ) + + # Act + result = client.log_job(_spec_example_request()) + + # Assert + assert result == JobLogged( + job_no="AD0226519", + logged_info=( + "Job number AD0226519 has been successfully logged for " + "49 Admers Crescent, Liphook, Midsomer, XX99 IOP - " + "Short Description example job." + ), + ) + + +def test_log_job_sends_the_recorded_logjob_envelope_to_the_relay_endpoint( + client: AbriClient, mock_session: MagicMock +) -> None: + # Arrange + mock_session.post.return_value.content = _load_fixture( + "abri_relay_logjob_success_response.xml" + ) + + # Act + client.log_job(_spec_example_request()) + + # Assert + # Fixture is the spec's recorded request example, except resource_group is + # "Surveyors" (the documented default) where the example used "surveyors". + (url,) = mock_session.post.call_args.args + sent_body: bytes = mock_session.post.call_args.kwargs["data"] + expected_body = _load_fixture("abri_relay_logjob_request_example.xml") + assert url == ENDPOINT_URL + assert ET.canonicalize(xml_data=sent_body, strip_text=True) == ET.canonicalize( + xml_data=expected_body, strip_text=True + ) + + +# --- log_job: rejection --- + + +def test_log_job_returns_rejection_with_openhousing_error_code_and_message_verbatim( + client: AbriClient, mock_session: MagicMock +) -> None: + # Arrange + mock_session.post.return_value.content = _load_fixture( + "abri_relay_logjob_relayerror_response.xml" + ) + + # Act + result = client.log_job(_spec_example_request()) + + # Assert + assert result == AbriRequestRejected( + code="RelayError", + message=( + "No property was found with UPRN/place_ref '1007176aa', " + "street name '', house number '0', house name '', post code ''" + ), + ) + + +# --- log_job: transport failures --- + + +def test_log_job_raises_transport_error_on_http_error_status( + client: AbriClient, mock_session: MagicMock +) -> None: + # Arrange + mock_session.post.return_value.content = b"Server Error" + mock_session.post.return_value.raise_for_status.side_effect = requests.HTTPError( + "500 Server Error" + ) + + # Act / Assert + with pytest.raises(AbriTransportError): + client.log_job(_spec_example_request()) + + +def test_log_job_raises_transport_error_when_the_relay_is_unreachable( + client: AbriClient, mock_session: MagicMock +) -> None: + # Arrange + mock_session.post.side_effect = requests.ConnectionError("connection refused") + + # Act / Assert + with pytest.raises(AbriTransportError): + client.log_job(_spec_example_request()) + + +# --- log_job: ambiguous responses are retriable, never success or rejection --- + + +def test_log_job_raises_retriable_parse_error_on_a_malformed_response_body( + client: AbriClient, mock_session: MagicMock +) -> None: + # Arrange + mock_session.post.return_value.content = b"not xml at all <<<" + + # Act / Assert + with pytest.raises(AbriResponseParseError): + client.log_job(_spec_example_request()) + + +@pytest.mark.parametrize( + "body", + [ + b"", + b'AD1', + b"true", + b"", + ], +) +def test_log_job_raises_retriable_parse_error_on_an_unexpectedly_shaped_response( + client: AbriClient, mock_session: MagicMock, body: bytes +) -> None: + # Arrange + mock_session.post.return_value.content = body + + # Act / Assert + with pytest.raises(AbriResponseParseError): + client.log_job(_spec_example_request()) diff --git a/tests/infrastructure/abri/test_abri_config.py b/tests/infrastructure/abri/test_abri_config.py new file mode 100644 index 000000000..4e1d8c87d --- /dev/null +++ b/tests/infrastructure/abri/test_abri_config.py @@ -0,0 +1,22 @@ +from infrastructure.abri.config import AbriConfig + + +def test_config_hydrates_relay_credentials_and_defaults_from_environment() -> None: + # Arrange + env = { + "ABRI_RELAY_URL": "https://relay.example.test/api/DomnaRelay?code=key", + "ABRI_RELAY_USERNAME": "DomnaWeb", + "ABRI_RELAY_PASSWORD": "secret", + "ABRI_RELAY_DEFAULT_RESOURCE": "CBRYAN", + } + + # Act + config = AbriConfig.from_env(env) + + # Assert + assert config == AbriConfig( + endpoint_url="https://relay.example.test/api/DomnaRelay?code=key", + username="DomnaWeb", + password="secret", + default_resource="CBRYAN", + )