mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Merge pull request #1458 from Hestia-Homes/feature/abri-api-integration
Abri DomnaRelay client — foundation + LogJob vertical (strictly typed)
This commit is contained in:
commit
c0d36d1e67
20 changed files with 557 additions and 19 deletions
0
domain/abri/__init__.py
Normal file
0
domain/abri/__init__.py
Normal file
18
domain/abri/descriptions.py
Normal file
18
domain/abri/descriptions.py
Normal 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
32
domain/abri/models.py
Normal 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
13
domain/abri/slots.py
Normal 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"
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
0
infrastructure/abri/__init__.py
Normal file
0
infrastructure/abri/__init__.py
Normal file
113
infrastructure/abri/abri_client.py
Normal file
113
infrastructure/abri/abri_client.py
Normal 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)
|
||||
19
infrastructure/abri/config.py
Normal file
19
infrastructure/abri/config.py
Normal 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"],
|
||||
)
|
||||
23
infrastructure/abri/envelope.py
Normal file
23
infrastructure/abri/envelope.py
Normal 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)
|
||||
10
infrastructure/abri/errors.py
Normal file
10
infrastructure/abri/errors.py
Normal 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.
|
||||
"""
|
||||
6
tests/abri/abri_relay_logjob_relayerror_response.xml
Normal file
6
tests/abri/abri_relay_logjob_relayerror_response.xml
Normal 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 '1007176aa', street name '', house number '0', house name '', post code ''</message>
|
||||
</response>
|
||||
20
tests/abri/abri_relay_logjob_request_example.xml
Normal file
20
tests/abri/abri_relay_logjob_request_example.xml
Normal 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>
|
||||
6
tests/abri/abri_relay_logjob_success_response.xml
Normal file
6
tests/abri/abri_relay_logjob_success_response.xml
Normal 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>
|
||||
0
tests/domain/abri/__init__.py
Normal file
0
tests/domain/abri/__init__.py
Normal file
15
tests/domain/abri/test_descriptions.py
Normal file
15
tests/domain/abri/test_descriptions.py
Normal 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
|
||||
47
tests/domain/abri/test_slots.py
Normal file
47
tests/domain/abri/test_slots.py
Normal 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)
|
||||
0
tests/infrastructure/abri/__init__.py
Normal file
0
tests/infrastructure/abri/__init__.py
Normal file
194
tests/infrastructure/abri/test_abri_client.py
Normal file
194
tests/infrastructure/abri/test_abri_client.py
Normal 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())
|
||||
22
tests/infrastructure/abri/test_abri_config.py
Normal file
22
tests/infrastructure/abri/test_abri_config.py
Normal 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",
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue