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",
+ )