Merge pull request #1458 from Hestia-Homes/feature/abri-api-integration

Abri DomnaRelay client — foundation + LogJob vertical (strictly typed)
This commit is contained in:
Daniel Roth 2026-07-03 16:49:46 +01:00 committed by GitHub
commit c0d36d1e67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 557 additions and 19 deletions

0
domain/abri/__init__.py Normal file
View file

View file

@ -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."
),
)

32
domain/abri/models.py Normal file
View file

@ -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]

13
domain/abri/slots.py Normal file
View file

@ -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"

View file

@ -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

View file

@ -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,

View file

View file

@ -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)

View file

@ -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"],
)

View file

@ -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)

View file

@ -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.
"""

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<response>
<success>false</success>
<code>RelayError</code>
<message>No property was found with UPRN/place_ref &apos;1007176aa&apos;, street name &apos;&apos;, house number &apos;0&apos;, house name &apos;&apos;, post code &apos;&apos;</message>
</response>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" ?>
<message>
<Header>
<Security username="DomnaWeb" password="" />
</Header>
<Body>
<Request request_type="logjob">
<Parameters attribute="place_ref" attribute_value="1007165" />
<Parameters attribute="std_job_code" attribute_value="SCSEXT" />
<Parameters attribute="client" attribute_value="HSG" />
<Parameters attribute="short_description" attribute_value="Christian's SCS External Test." />
<Parameters attribute="long_description" attribute_value="Christian's SCS External Test." />
<Parameters attribute="client_ref" attribute_value="DOM51111" />
<Parameters attribute="appointment_date" attribute_value="18/06/2026" />
<Parameters attribute="appointment_time" attribute_value="PM" />
<Parameters attribute="resource" attribute_value="NAULKH" />
<Parameters attribute="resource_group" attribute_value="Surveyors" />
</Request>
</Body>
</message>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" ?>
<Root>
<Jobs>
<Job_logged job_no="AD0226519" logged_info="Job number AD0226519 has been successfully logged for 49 Admers Crescent, Liphook, Midsomer, XX99 IOP - Short Description example job.">AD0226519</Job_logged>
</Jobs>
</Root>

View file

View file

@ -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

View file

@ -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)

View file

View file

@ -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"<html>Server Error</html>"
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"<Root><Jobs></Jobs></Root>",
b'<Root><Jobs><Job_logged logged_info="x">AD1</Job_logged></Jobs></Root>',
b"<response><success>true</success></response>",
b"<unexpected />",
],
)
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())

View file

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