From b22afcde9f4cf6fec856ba1a2798f68b6ea2650b Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:22:09 +0000 Subject: [PATCH 01/29] =?UTF-8?q?Abri=20log=5Fjob=20returns=20the=20OpenHo?= =?UTF-8?q?using=20job=20number=20from=20the=20relay=20success=20response?= =?UTF-8?q?=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 --- domain/abri/__init__.py | 0 domain/abri/models.py | 32 ++++++++ infrastructure/abri/__init__.py | 0 infrastructure/abri/abri_client.py | 13 ++++ infrastructure/abri/config.py | 14 ++++ .../abri_relay_logjob_relayerror_response.xml | 6 ++ .../abri_relay_logjob_request_example.xml | 20 +++++ .../abri_relay_logjob_success_response.xml | 6 ++ tests/infrastructure/abri/__init__.py | 0 tests/infrastructure/abri/test_abri_client.py | 77 +++++++++++++++++++ 10 files changed, 168 insertions(+) create mode 100644 domain/abri/__init__.py create mode 100644 domain/abri/models.py create mode 100644 infrastructure/abri/__init__.py create mode 100644 infrastructure/abri/abri_client.py create mode 100644 infrastructure/abri/config.py create mode 100644 tests/abri/abri_relay_logjob_relayerror_response.xml create mode 100644 tests/abri/abri_relay_logjob_request_example.xml create mode 100644 tests/abri/abri_relay_logjob_success_response.xml create mode 100644 tests/infrastructure/abri/__init__.py create mode 100644 tests/infrastructure/abri/test_abri_client.py diff --git a/domain/abri/__init__.py b/domain/abri/__init__.py new file mode 100644 index 000000000..e69de29bb 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/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..2579ba341 --- /dev/null +++ b/infrastructure/abri/abri_client.py @@ -0,0 +1,13 @@ +import requests + +from domain.abri.models import LogJobRequest, LogJobResult +from infrastructure.abri.config import AbriConfig + + +class AbriClient: + def __init__(self, config: AbriConfig) -> None: + self._config = config + self._session = requests.Session() + + def log_job(self, request: LogJobRequest) -> LogJobResult: + raise NotImplementedError diff --git a/infrastructure/abri/config.py b/infrastructure/abri/config.py new file mode 100644 index 000000000..fe9fa6d8c --- /dev/null +++ b/infrastructure/abri/config.py @@ -0,0 +1,14 @@ +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": + raise NotImplementedError 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/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..40eaed7a2 --- /dev/null +++ b/tests/infrastructure/abri/test_abri_client.py @@ -0,0 +1,77 @@ +from datetime import date +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from domain.abri.models import JobLogged, LogJobRequest, PlaceRef +from infrastructure.abri.abri_client import AbriClient +from infrastructure.abri.config import AbriConfig + +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." + ), + ) From dd451a03a8ea4f41dc3c959bafa8fe73682448c1 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:22:56 +0000 Subject: [PATCH 02/29] =?UTF-8?q?Abri=20log=5Fjob=20returns=20the=20OpenHo?= =?UTF-8?q?using=20job=20number=20from=20the=20relay=20success=20response?= =?UTF-8?q?=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- infrastructure/abri/abri_client.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py index 2579ba341..a5c9ffaef 100644 --- a/infrastructure/abri/abri_client.py +++ b/infrastructure/abri/abri_client.py @@ -1,6 +1,8 @@ +import xml.etree.ElementTree as ET + import requests -from domain.abri.models import LogJobRequest, LogJobResult +from domain.abri.models import JobLogged, LogJobRequest, LogJobResult from infrastructure.abri.config import AbriConfig @@ -10,4 +12,12 @@ class AbriClient: self._session = requests.Session() def log_job(self, request: LogJobRequest) -> LogJobResult: - raise NotImplementedError + response = self._session.post(self._config.endpoint_url, data=b"") + root = ET.fromstring(response.content) + job_logged = root.find("Jobs/Job_logged") + if job_logged is None: + raise ValueError("Job_logged element missing from relay response") + return JobLogged( + job_no=job_logged.attrib["job_no"], + logged_info=job_logged.attrib["logged_info"], + ) From 4803ae40e1d810a169b3ecd313b48be78534b28b Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:23:42 +0000 Subject: [PATCH 03/29] =?UTF-8?q?Abri=20log=5Fjob=20sends=20the=20spec's?= =?UTF-8?q?=20recorded=20LogJob=20envelope=20to=20the=20relay=20endpoint?= =?UTF-8?q?=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 --- tests/infrastructure/abri/test_abri_client.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/infrastructure/abri/test_abri_client.py b/tests/infrastructure/abri/test_abri_client.py index 40eaed7a2..ef91dee1d 100644 --- a/tests/infrastructure/abri/test_abri_client.py +++ b/tests/infrastructure/abri/test_abri_client.py @@ -1,3 +1,4 @@ +import xml.etree.ElementTree as ET from datetime import date from pathlib import Path from unittest.mock import MagicMock, patch @@ -75,3 +76,26 @@ def test_log_job_returns_job_logged_with_openhousing_job_number( "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) == ET.canonicalize( + xml_data=expected_body + ) From 79ce9dc4b1a70958e66162ac42320174419b8edf Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:24:57 +0000 Subject: [PATCH 04/29] =?UTF-8?q?Abri=20log=5Fjob=20sends=20the=20spec's?= =?UTF-8?q?=20recorded=20LogJob=20envelope=20to=20the=20relay=20endpoint?= =?UTF-8?q?=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- infrastructure/abri/abri_client.py | 24 ++++++++++++++++++- infrastructure/abri/envelope.py | 23 ++++++++++++++++++ tests/infrastructure/abri/test_abri_client.py | 4 ++-- 3 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 infrastructure/abri/envelope.py diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py index a5c9ffaef..712ab49de 100644 --- a/infrastructure/abri/abri_client.py +++ b/infrastructure/abri/abri_client.py @@ -4,6 +4,11 @@ import requests from domain.abri.models import JobLogged, LogJobRequest, LogJobResult from infrastructure.abri.config import AbriConfig +from infrastructure.abri.envelope import serialise_relay_request + +STD_JOB_CODE = "SCSEXT" +CLIENT_CODE = "HSG" +RESOURCE_GROUP = "Surveyors" class AbriClient: @@ -12,7 +17,24 @@ class AbriClient: self._session = requests.Session() def log_job(self, request: LogJobRequest) -> LogJobResult: - response = self._session.post(self._config.endpoint_url, data=b"") + body = serialise_relay_request( + 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), + ], + username=self._config.username, + password=self._config.password, + ) + response = self._session.post(self._config.endpoint_url, data=body) root = ET.fromstring(response.content) job_logged = root.find("Jobs/Job_logged") if job_logged is None: 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/tests/infrastructure/abri/test_abri_client.py b/tests/infrastructure/abri/test_abri_client.py index ef91dee1d..0e0f8abb8 100644 --- a/tests/infrastructure/abri/test_abri_client.py +++ b/tests/infrastructure/abri/test_abri_client.py @@ -96,6 +96,6 @@ def test_log_job_sends_the_recorded_logjob_envelope_to_the_relay_endpoint( 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) == ET.canonicalize( - xml_data=expected_body + assert ET.canonicalize(xml_data=sent_body, strip_text=True) == ET.canonicalize( + xml_data=expected_body, strip_text=True ) From e473ea8a4f348d44fddb8c78eff7fa59d92c5371 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:25:49 +0000 Subject: [PATCH 05/29] =?UTF-8?q?Abri=20log=5Fjob=20surfaces=20an=20OpenHo?= =?UTF-8?q?using=20rejection=20as=20a=20typed=20result=20with=20verbatim?= =?UTF-8?q?=20code=20and=20message=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 --- tests/infrastructure/abri/test_abri_client.py | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/tests/infrastructure/abri/test_abri_client.py b/tests/infrastructure/abri/test_abri_client.py index 0e0f8abb8..aaaeb7d38 100644 --- a/tests/infrastructure/abri/test_abri_client.py +++ b/tests/infrastructure/abri/test_abri_client.py @@ -5,7 +5,12 @@ from unittest.mock import MagicMock, patch import pytest -from domain.abri.models import JobLogged, LogJobRequest, PlaceRef +from domain.abri.models import ( + AbriRequestRejected, + JobLogged, + LogJobRequest, + PlaceRef, +) from infrastructure.abri.abri_client import AbriClient from infrastructure.abri.config import AbriConfig @@ -99,3 +104,27 @@ def test_log_job_sends_the_recorded_logjob_envelope_to_the_relay_endpoint( 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 ''" + ), + ) From e0830169ad0210db57b3007c9f2b9e1166296993 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:26:23 +0000 Subject: [PATCH 06/29] =?UTF-8?q?Abri=20log=5Fjob=20surfaces=20an=20OpenHo?= =?UTF-8?q?using=20rejection=20as=20a=20typed=20result=20with=20verbatim?= =?UTF-8?q?=20code=20and=20message=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- infrastructure/abri/abri_client.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py index 712ab49de..bd63f056a 100644 --- a/infrastructure/abri/abri_client.py +++ b/infrastructure/abri/abri_client.py @@ -2,7 +2,12 @@ import xml.etree.ElementTree as ET import requests -from domain.abri.models import JobLogged, LogJobRequest, LogJobResult +from domain.abri.models import ( + AbriRequestRejected, + JobLogged, + LogJobRequest, + LogJobResult, +) from infrastructure.abri.config import AbriConfig from infrastructure.abri.envelope import serialise_relay_request @@ -36,6 +41,8 @@ class AbriClient: ) response = self._session.post(self._config.endpoint_url, data=body) root = ET.fromstring(response.content) + if root.tag == "response": + return self._parse_rejection(root) job_logged = root.find("Jobs/Job_logged") if job_logged is None: raise ValueError("Job_logged element missing from relay response") @@ -43,3 +50,12 @@ class AbriClient: job_no=job_logged.attrib["job_no"], logged_info=job_logged.attrib["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 ValueError("malformed relay failure response") + return AbriRequestRejected(code=code, message=message) From 9fefdf81aa8bbc78b5d9cf847f917975ba2d9dcf Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:27:14 +0000 Subject: [PATCH 07/29] =?UTF-8?q?Abri=20log=5Fjob=20raises=20a=20retriable?= =?UTF-8?q?=20transport=20error=20on=20an=20HTTP=20error=20status=20?= =?UTF-8?q?=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 --- infrastructure/abri/errors.py | 10 ++++++++++ tests/infrastructure/abri/test_abri_client.py | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 infrastructure/abri/errors.py 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/infrastructure/abri/test_abri_client.py b/tests/infrastructure/abri/test_abri_client.py index aaaeb7d38..d67624153 100644 --- a/tests/infrastructure/abri/test_abri_client.py +++ b/tests/infrastructure/abri/test_abri_client.py @@ -4,6 +4,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch import pytest +import requests from domain.abri.models import ( AbriRequestRejected, @@ -13,6 +14,7 @@ from domain.abri.models import ( ) from infrastructure.abri.abri_client import AbriClient from infrastructure.abri.config import AbriConfig +from infrastructure.abri.errors import AbriTransportError FIXTURE_DIR = Path(__file__).parents[2] / "abri" ENDPOINT_URL = "https://relay.example.test/api/DomnaRelay?code=test-function-key" @@ -128,3 +130,20 @@ def test_log_job_returns_rejection_with_openhousing_error_code_and_message_verba "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()) From d002a3defc5c8bfc385296395a6682960ecf4963 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:27:43 +0000 Subject: [PATCH 08/29] =?UTF-8?q?Abri=20log=5Fjob=20raises=20a=20retriable?= =?UTF-8?q?=20transport=20error=20on=20an=20HTTP=20error=20status=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- infrastructure/abri/abri_client.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py index bd63f056a..9731431e2 100644 --- a/infrastructure/abri/abri_client.py +++ b/infrastructure/abri/abri_client.py @@ -10,6 +10,7 @@ from domain.abri.models import ( ) from infrastructure.abri.config import AbriConfig from infrastructure.abri.envelope import serialise_relay_request +from infrastructure.abri.errors import AbriTransportError STD_JOB_CODE = "SCSEXT" CLIENT_CODE = "HSG" @@ -39,7 +40,11 @@ class AbriClient: username=self._config.username, password=self._config.password, ) - response = self._session.post(self._config.endpoint_url, data=body) + try: + response = self._session.post(self._config.endpoint_url, data=body) + response.raise_for_status() + except requests.HTTPError as error: + raise AbriTransportError(str(error)) from error root = ET.fromstring(response.content) if root.tag == "response": return self._parse_rejection(root) From 6b9f5a0140bd03653b3cdc985b87c2ab2e136956 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:28:19 +0000 Subject: [PATCH 09/29] =?UTF-8?q?Abri=20log=5Fjob=20raises=20a=20retriable?= =?UTF-8?q?=20transport=20error=20when=20the=20relay=20is=20unreachable=20?= =?UTF-8?q?=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 --- tests/infrastructure/abri/test_abri_client.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/infrastructure/abri/test_abri_client.py b/tests/infrastructure/abri/test_abri_client.py index d67624153..54146c825 100644 --- a/tests/infrastructure/abri/test_abri_client.py +++ b/tests/infrastructure/abri/test_abri_client.py @@ -147,3 +147,14 @@ def test_log_job_raises_transport_error_on_http_error_status( # 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()) From 95bb951b60126cb3492deae0de39cee1e7148019 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:28:39 +0000 Subject: [PATCH 10/29] =?UTF-8?q?Abri=20log=5Fjob=20raises=20a=20retriable?= =?UTF-8?q?=20transport=20error=20when=20the=20relay=20is=20unreachable=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- infrastructure/abri/abri_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py index 9731431e2..9d69efd86 100644 --- a/infrastructure/abri/abri_client.py +++ b/infrastructure/abri/abri_client.py @@ -43,7 +43,7 @@ class AbriClient: try: response = self._session.post(self._config.endpoint_url, data=body) response.raise_for_status() - except requests.HTTPError as error: + except requests.RequestException as error: raise AbriTransportError(str(error)) from error root = ET.fromstring(response.content) if root.tag == "response": From ff34872c245d74a007c6f485cffc7a4b33d27be7 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:29:08 +0000 Subject: [PATCH 11/29] =?UTF-8?q?Abri=20log=5Fjob=20treats=20a=20malformed?= =?UTF-8?q?=20relay=20response=20as=20retriable,=20never=20as=20success=20?= =?UTF-8?q?or=20rejection=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 --- tests/infrastructure/abri/test_abri_client.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/infrastructure/abri/test_abri_client.py b/tests/infrastructure/abri/test_abri_client.py index 54146c825..2b232455c 100644 --- a/tests/infrastructure/abri/test_abri_client.py +++ b/tests/infrastructure/abri/test_abri_client.py @@ -14,7 +14,7 @@ from domain.abri.models import ( ) from infrastructure.abri.abri_client import AbriClient from infrastructure.abri.config import AbriConfig -from infrastructure.abri.errors import AbriTransportError +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" @@ -158,3 +158,17 @@ def test_log_job_raises_transport_error_when_the_relay_is_unreachable( # 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()) From 0df1d866fee880068e9bc13a8383e375688c29b3 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:29:41 +0000 Subject: [PATCH 12/29] =?UTF-8?q?Abri=20log=5Fjob=20treats=20a=20malformed?= =?UTF-8?q?=20relay=20response=20as=20retriable,=20never=20as=20success=20?= =?UTF-8?q?or=20rejection=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- infrastructure/abri/abri_client.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py index 9d69efd86..302ec54f8 100644 --- a/infrastructure/abri/abri_client.py +++ b/infrastructure/abri/abri_client.py @@ -10,7 +10,7 @@ from domain.abri.models import ( ) from infrastructure.abri.config import AbriConfig from infrastructure.abri.envelope import serialise_relay_request -from infrastructure.abri.errors import AbriTransportError +from infrastructure.abri.errors import AbriResponseParseError, AbriTransportError STD_JOB_CODE = "SCSEXT" CLIENT_CODE = "HSG" @@ -45,7 +45,10 @@ class AbriClient: response.raise_for_status() except requests.RequestException as error: raise AbriTransportError(str(error)) from error - root = ET.fromstring(response.content) + try: + root = ET.fromstring(response.content) + except ET.ParseError as error: + raise AbriResponseParseError(str(error)) from error if root.tag == "response": return self._parse_rejection(root) job_logged = root.find("Jobs/Job_logged") From a325f65bf04aa223f20b5621736ad04d0573efa2 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:30:02 +0000 Subject: [PATCH 13/29] =?UTF-8?q?Abri=20log=5Fjob=20treats=20an=20unexpect?= =?UTF-8?q?edly=20shaped=20relay=20response=20as=20retriable=20?= =?UTF-8?q?=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 --- tests/infrastructure/abri/test_abri_client.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/infrastructure/abri/test_abri_client.py b/tests/infrastructure/abri/test_abri_client.py index 2b232455c..ab9452cb2 100644 --- a/tests/infrastructure/abri/test_abri_client.py +++ b/tests/infrastructure/abri/test_abri_client.py @@ -172,3 +172,22 @@ def test_log_job_raises_retriable_parse_error_on_a_malformed_response_body( # Act / Assert with pytest.raises(AbriResponseParseError): client.log_job(_spec_example_request()) + + +@pytest.mark.parametrize( + "body", + [ + b"", + 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()) From fef69145daf8ecfcfdecd1405d493202888cdeb7 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:30:29 +0000 Subject: [PATCH 14/29] =?UTF-8?q?Abri=20log=5Fjob=20treats=20an=20unexpect?= =?UTF-8?q?edly=20shaped=20relay=20response=20as=20retriable=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- infrastructure/abri/abri_client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py index 302ec54f8..e1049bd0e 100644 --- a/infrastructure/abri/abri_client.py +++ b/infrastructure/abri/abri_client.py @@ -53,7 +53,9 @@ class AbriClient: return self._parse_rejection(root) job_logged = root.find("Jobs/Job_logged") if job_logged is None: - raise ValueError("Job_logged element missing from relay response") + raise AbriResponseParseError( + "Job_logged element missing from relay response" + ) return JobLogged( job_no=job_logged.attrib["job_no"], logged_info=job_logged.attrib["logged_info"], @@ -65,5 +67,5 @@ class AbriClient: code = root.findtext("code") message = root.findtext("message") if success != "false" or code is None or message is None: - raise ValueError("malformed relay failure response") + raise AbriResponseParseError("malformed relay failure response") return AbriRequestRejected(code=code, message=message) From da4caea407a73b5608236fd215aee15c7b8081c3 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:31:12 +0000 Subject: [PATCH 15/29] =?UTF-8?q?Abri=20booking=20with=20no=20confirmed=20?= =?UTF-8?q?time=20books=20an=20all-day=20appointment=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 --- domain/abri/slots.py | 7 +++++++ tests/domain/abri/__init__.py | 0 tests/domain/abri/test_slots.py | 16 ++++++++++++++++ 3 files changed, 23 insertions(+) create mode 100644 domain/abri/slots.py create mode 100644 tests/domain/abri/__init__.py create mode 100644 tests/domain/abri/test_slots.py diff --git a/domain/abri/slots.py b/domain/abri/slots.py new file mode 100644 index 000000000..1f777f5ca --- /dev/null +++ b/domain/abri/slots.py @@ -0,0 +1,7 @@ +from typing import Optional + +from domain.abri.models import SlotCode + + +def slot_for_confirmed_time(confirmed_time: Optional[str]) -> SlotCode: + raise NotImplementedError 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_slots.py b/tests/domain/abri/test_slots.py new file mode 100644 index 000000000..3b587064e --- /dev/null +++ b/tests/domain/abri/test_slots.py @@ -0,0 +1,16 @@ +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" From 992ac243ba6f200f97e5633f13c7b5ceb6670c94 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:31:33 +0000 Subject: [PATCH 16/29] =?UTF-8?q?Abri=20booking=20with=20no=20confirmed=20?= =?UTF-8?q?time=20books=20an=20all-day=20appointment=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- domain/abri/slots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/domain/abri/slots.py b/domain/abri/slots.py index 1f777f5ca..dac582569 100644 --- a/domain/abri/slots.py +++ b/domain/abri/slots.py @@ -4,4 +4,4 @@ from domain.abri.models import SlotCode def slot_for_confirmed_time(confirmed_time: Optional[str]) -> SlotCode: - raise NotImplementedError + return "AD" From fcdab30b73a192d199af13d6bfd33dd1c41bbe0b Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:31:51 +0000 Subject: [PATCH 17/29] =?UTF-8?q?Abri=20confirmed=20time=20before=20midday?= =?UTF-8?q?=20books=20the=20morning=20slot=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 --- tests/domain/abri/test_slots.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/domain/abri/test_slots.py b/tests/domain/abri/test_slots.py index 3b587064e..14f59ae9d 100644 --- a/tests/domain/abri/test_slots.py +++ b/tests/domain/abri/test_slots.py @@ -14,3 +14,14 @@ def test_a_booking_with_no_confirmed_time_is_an_all_day_appointment( # 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" From cd29cbb0b8b3a5d3e7f50d920ce3ae4f0bdde8f2 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:32:15 +0000 Subject: [PATCH 18/29] =?UTF-8?q?Abri=20confirmed=20time=20before=20midday?= =?UTF-8?q?=20books=20the=20morning=20slot=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- domain/abri/slots.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/domain/abri/slots.py b/domain/abri/slots.py index dac582569..992426ee9 100644 --- a/domain/abri/slots.py +++ b/domain/abri/slots.py @@ -1,7 +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: - return "AD" + 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" From eccc03f9f24a34c8b6607727b215167defeb9bb3 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:32:49 +0000 Subject: [PATCH 19/29] =?UTF-8?q?Abri=20confirmed=20time=20at=20or=20after?= =?UTF-8?q?=20midday=20books=20the=20afternoon=20slot=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- tests/domain/abri/test_slots.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/domain/abri/test_slots.py b/tests/domain/abri/test_slots.py index 14f59ae9d..acc0a65fd 100644 --- a/tests/domain/abri/test_slots.py +++ b/tests/domain/abri/test_slots.py @@ -25,3 +25,14 @@ def test_a_confirmed_time_before_midday_books_the_morning_slot( # 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" From 2c660bdb5df7acacb37758c898a3d9825d74a041 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:33:12 +0000 Subject: [PATCH 20/29] =?UTF-8?q?Abri=20unparseable=20confirmed=20time=20f?= =?UTF-8?q?ails=20loudly=20rather=20than=20silently=20booking=20all=20day?= =?UTF-8?q?=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- tests/domain/abri/test_slots.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/domain/abri/test_slots.py b/tests/domain/abri/test_slots.py index acc0a65fd..fa5283691 100644 --- a/tests/domain/abri/test_slots.py +++ b/tests/domain/abri/test_slots.py @@ -36,3 +36,12 @@ def test_a_confirmed_time_at_or_after_midday_books_the_afternoon_slot( # 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) From caffa5c4e10bc3b29b40ade182406266a193f30d Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:33:40 +0000 Subject: [PATCH 21/29] =?UTF-8?q?Abri=20job=20descriptions=20identify=20th?= =?UTF-8?q?e=20visit=20as=20a=20Domna=20condition=20survey=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 --- domain/abri/descriptions.py | 11 +++++++++++ tests/domain/abri/test_descriptions.py | 15 +++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 domain/abri/descriptions.py create mode 100644 tests/domain/abri/test_descriptions.py diff --git a/domain/abri/descriptions.py b/domain/abri/descriptions.py new file mode 100644 index 000000000..c0940eb7b --- /dev/null +++ b/domain/abri/descriptions.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class JobDescriptions: + short_description: str + long_description: str + + +def build_job_descriptions(deal_name: str) -> JobDescriptions: + raise NotImplementedError 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 From f5fefd4d931e77da5c8e1902ba2e5968ebeae1ec Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:34:05 +0000 Subject: [PATCH 22/29] =?UTF-8?q?Abri=20job=20descriptions=20identify=20th?= =?UTF-8?q?e=20visit=20as=20a=20Domna=20condition=20survey=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- domain/abri/descriptions.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/domain/abri/descriptions.py b/domain/abri/descriptions.py index c0940eb7b..3a422e55f 100644 --- a/domain/abri/descriptions.py +++ b/domain/abri/descriptions.py @@ -8,4 +8,11 @@ class JobDescriptions: def build_job_descriptions(deal_name: str) -> JobDescriptions: - raise NotImplementedError + # 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." + ), + ) From 39055bafedb15d21dee5b0ebb5df525461adfe6c Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:34:27 +0000 Subject: [PATCH 23/29] =?UTF-8?q?Abri=20relay=20credentials=20and=20defaul?= =?UTF-8?q?ts=20hydrate=20from=20environment=20configuration=20?= =?UTF-8?q?=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 --- tests/infrastructure/abri/test_abri_config.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 tests/infrastructure/abri/test_abri_config.py 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", + ) From 62ad53df5a57bf09babacccd27c288d63f2b5771 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:34:53 +0000 Subject: [PATCH 24/29] =?UTF-8?q?Abri=20relay=20credentials=20and=20defaul?= =?UTF-8?q?ts=20hydrate=20from=20environment=20configuration=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- infrastructure/abri/config.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/infrastructure/abri/config.py b/infrastructure/abri/config.py index fe9fa6d8c..f2da18a77 100644 --- a/infrastructure/abri/config.py +++ b/infrastructure/abri/config.py @@ -11,4 +11,9 @@ class AbriConfig: @classmethod def from_env(cls, env: Mapping[str, str]) -> "AbriConfig": - raise NotImplementedError + 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"], + ) From c1c2a1947ef5affc7432ccd275e78c4dd3be8070 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:35:35 +0000 Subject: [PATCH 25/29] =?UTF-8?q?Abri=20log=5Fjob=20treats=20a=20logged-jo?= =?UTF-8?q?b=20response=20missing=20its=20job=20number=20as=20retriable=20?= =?UTF-8?q?=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 --- tests/infrastructure/abri/test_abri_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/infrastructure/abri/test_abri_client.py b/tests/infrastructure/abri/test_abri_client.py index ab9452cb2..133c74d8f 100644 --- a/tests/infrastructure/abri/test_abri_client.py +++ b/tests/infrastructure/abri/test_abri_client.py @@ -178,6 +178,7 @@ def test_log_job_raises_retriable_parse_error_on_a_malformed_response_body( "body", [ b"", + b'AD1', b"true", b"", ], From c23c74be6ba50446a9e4689de5e493e4ab6cf5e0 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:36:55 +0000 Subject: [PATCH 26/29] =?UTF-8?q?Abri=20log=5Fjob=20treats=20a=20logged-jo?= =?UTF-8?q?b=20response=20missing=20its=20job=20number=20as=20retriable=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- infrastructure/abri/abri_client.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py index e1049bd0e..99ef095e8 100644 --- a/infrastructure/abri/abri_client.py +++ b/infrastructure/abri/abri_client.py @@ -56,10 +56,13 @@ class AbriClient: raise AbriResponseParseError( "Job_logged element missing from relay response" ) - return JobLogged( - job_no=job_logged.attrib["job_no"], - logged_info=job_logged.attrib["logged_info"], - ) + 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: From 32b56050b0b373c3af61e3b9635f6bf5d1956fa7 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 14:37:40 +0000 Subject: [PATCH 27/29] =?UTF-8?q?Abri=20relay=20requests=20share=20one=20e?= =?UTF-8?q?nvelope,=20transport=20and=20rejection=20path=20across=20reques?= =?UTF-8?q?t=20types=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- infrastructure/abri/abri_client.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py index 99ef095e8..a0cedd5f7 100644 --- a/infrastructure/abri/abri_client.py +++ b/infrastructure/abri/abri_client.py @@ -1,4 +1,5 @@ import xml.etree.ElementTree as ET +from typing import List, Tuple, Union import requests @@ -23,7 +24,7 @@ class AbriClient: self._session = requests.Session() def log_job(self, request: LogJobRequest) -> LogJobResult: - body = serialise_relay_request( + outcome = self._exchange( request_type="logjob", parameters=[ ("place_ref", request.place_ref), @@ -37,6 +38,23 @@ class AbriClient: ("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]: + """Send one relay request; return the success root or the typed rejection. + + Shared by all four relay request types: transport failures raise + AbriTransportError, ambiguous bodies raise AbriResponseParseError, and + only a well-formed false body becomes a rejection. + """ + body = serialise_relay_request( + request_type=request_type, + parameters=parameters, username=self._config.username, password=self._config.password, ) @@ -51,6 +69,10 @@ class AbriClient: raise AbriResponseParseError(str(error)) from error if root.tag == "response": return self._parse_rejection(root) + return root + + @staticmethod + def _parse_job_logged(root: ET.Element) -> JobLogged: job_logged = root.find("Jobs/Job_logged") if job_logged is None: raise AbriResponseParseError( From a86607e420e07ce991ccb91e44bc78ef091a41d0 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 15:12:44 +0000 Subject: [PATCH 28/29] =?UTF-8?q?Abri=20relay=20exchange=20reads=20as=20se?= =?UTF-8?q?rialise,=20post,=20parse,=20then=20dispatch=20on=20the=20failur?= =?UTF-8?q?e=20document=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- infrastructure/abri/abri_client.py | 41 +++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py index a0cedd5f7..72a3c4767 100644 --- a/infrastructure/abri/abri_client.py +++ b/infrastructure/abri/abri_client.py @@ -39,51 +39,66 @@ class AbriClient: ("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]: - """Send one relay request; return the success root or the typed rejection. - - Shared by all four relay request types: transport failures raise - AbriTransportError, ambiguous bodies raise AbriResponseParseError, and - only a well-formed false body becomes a rejection. - """ - body = serialise_relay_request( + 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=body) + 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: - root = ET.fromstring(response.content) + return ET.fromstring(body) except ET.ParseError as error: raise AbriResponseParseError(str(error)) from error - if root.tag == "response": - return self._parse_rejection(root) - return root + + @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 @@ -91,6 +106,8 @@ class AbriClient: 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) From ff868156d1738047b2f5bd7e1c5abf0a4cdc32af Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 15:26:41 +0000 Subject: [PATCH 29/29] =?UTF-8?q?Abri=20job=20logging=20trigger=20is=20nam?= =?UTF-8?q?ed=20after=20the=20DomnaRelay=20route=20it=20fires=20?= =?UTF-8?q?=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 4 +-- etl/hubspot/tests/test_hubspot_deal_differ.py | 34 +++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) 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,