Model/tests/infrastructure/abri/test_abri_client.py
Daniel Roth 0d82f9e660 Declare the XML content type the Abri relay requires 🟥
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 10:07:04 +00:00

529 lines
15 KiB
Python

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 (
AbandonJobRequest,
AbandonReason,
AbriRequestRejected,
AmendJobRequest,
AppointmentAmended,
JobAbandoned,
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.",
)
def _spec_example_amend_request() -> AmendJobRequest:
return AmendJobRequest(
job_no="AC0439951",
appointment_date=date(2026, 6, 19),
appointment_time="PM",
)
def _spec_example_abandon_request() -> AbandonJobRequest:
return AbandonJobRequest(
job_no="AC0439951",
reason=AbandonReason.CARDED,
date_abandoned=date(2026, 7, 7),
)
@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
)
def test_log_job_declares_the_xml_body_content_type_the_relay_requires(
client: AbriClient, mock_session: MagicMock
) -> None:
# Arrange: the relay 400s an XML body posted without an XML content type.
mock_session.post.return_value.content = _load_fixture(
"abri_relay_logjob_success_response.xml"
)
# Act
client.log_job(_spec_example_request())
# Assert
headers: dict[str, str] = mock_session.post.call_args.kwargs["headers"]
assert headers["Content-Type"] == "application/xml"
# --- abandon_job: rejection (canceljob's own error-details shape) ---
def test_abandon_job_returns_rejection_from_the_canceljob_error_details_document(
client: AbriClient, mock_session: MagicMock
) -> None:
# Arrange: canceljob signals failure as an empty cancellation collection
# carrying an ErrorDetails element, not the shared failure envelope.
mock_session.post.return_value.content = _load_fixture(
"abri_relay_canceljob_errordetails_response.xml"
)
# Act
result = client.abandon_job(_spec_example_abandon_request())
# Assert
assert result == AbriRequestRejected(
code="RelayError",
message="Job_no 1019905 is invalid. Job not found.",
)
# --- abandon_job: rejection (the shared failure envelope) ---
def test_abandon_job_returns_rejection_from_the_shared_failure_envelope(
client: AbriClient, mock_session: MagicMock
) -> None:
# Arrange
mock_session.post.return_value.content = _load_fixture(
"abri_relay_canceljob_relayerror_response.xml"
)
# Act
result = client.abandon_job(_spec_example_abandon_request())
# Assert
assert result == AbriRequestRejected(
code="RelayError",
message="Job_no 1019905 is invalid. Job not found.",
)
# --- abandon_job: ambiguous responses are retriable, never success or rejection ---
def test_abandon_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.abandon_job(_spec_example_abandon_request())
@pytest.mark.parametrize(
"body",
[
# A cancellation collection with no JobCancelled and no ErrorDetails:
# genuinely ambiguous, so a dead job never churns as success/rejection.
b"<Root><CancelJobs /></Root>",
b"<Root></Root>",
# JobCancelled present but missing its job_no.
b"<Root><CancelJobs><JobCancelled /></CancelJobs></Root>",
# ErrorDetails present but missing code or message.
b'<Root><CancelJobs /><ErrorDetails message="x" /></Root>',
b'<Root><CancelJobs /><ErrorDetails code="RelayError" /></Root>',
# The shared envelope, but not a failure document.
b"<response><success>true</success></response>",
b"<unexpected />",
],
)
def test_abandon_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.abandon_job(_spec_example_abandon_request())
# --- abandon_job: transport failures ---
def test_abandon_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.abandon_job(_spec_example_abandon_request())
def test_abandon_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.abandon_job(_spec_example_abandon_request())
# --- 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())
# --- amend_job: success ---
def test_amend_job_returns_appointment_amended_echoing_the_response_attributes(
client: AbriClient, mock_session: MagicMock
) -> None:
# Arrange
mock_session.post.return_value.content = _load_fixture(
"abri_relay_amendoptiappt_success_response.xml"
)
# Act
result = client.amend_job(_spec_example_amend_request())
# Assert
assert result == AppointmentAmended(
job_no="AC0439951",
appointment_date="24/06/2026",
appointment_time="PM",
)
def test_amend_job_sends_the_recorded_amendoptiappt_envelope_to_the_relay_endpoint(
client: AbriClient, mock_session: MagicMock
) -> None:
# Arrange
mock_session.post.return_value.content = _load_fixture(
"abri_relay_amendoptiappt_success_response.xml"
)
# Act
client.amend_job(
AmendJobRequest(
job_no="AC0439951",
appointment_date=date(2026, 6, 19),
appointment_time="PM",
resource="NAULKH",
)
)
# Assert
(url,) = mock_session.post.call_args.args
sent_body: bytes = mock_session.post.call_args.kwargs["data"]
expected_body = _load_fixture("abri_relay_amendoptiappt_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
)
def test_amend_job_omits_the_resource_parameter_when_no_surveyor_is_provided(
client: AbriClient, mock_session: MagicMock
) -> None:
# Arrange
mock_session.post.return_value.content = _load_fixture(
"abri_relay_amendoptiappt_success_response.xml"
)
# Act
client.amend_job(_spec_example_amend_request())
# Assert
sent_body: bytes = mock_session.post.call_args.kwargs["data"]
sent_attributes = [
parameter.get("attribute")
for parameter in ET.fromstring(sent_body).findall(".//Parameters")
]
assert "resource" not in sent_attributes
# --- amend_job: rejection ---
def test_amend_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_amendoptiappt_relayerror_response.xml"
)
# Act
result = client.amend_job(_spec_example_amend_request())
# Assert
assert result == AbriRequestRejected(
code="RelayError",
message="Job_no 1019905 is invalid. Job not found.",
)
# --- amend_job: ambiguous responses are retriable, never success or rejection ---
@pytest.mark.parametrize(
"body",
[
b'<Root><AmendOptiAppt appointment_date="24/06/2026"'
b' appointment_time="PM" job_no="AC0439951" success="N" /></Root>',
b'<Root><AmendOptiAppt appointment_date="24/06/2026"'
b' appointment_time="PM" job_no="AC0439951" /></Root>',
b'<Root><AmendOptiAppt appointment_date="24/06/2026"'
b' appointment_time="PM" success="Y" /></Root>',
b"<Root></Root>",
b"<unexpected />",
],
)
def test_amend_job_raises_retriable_parse_error_when_the_amendment_is_unconfirmed(
client: AbriClient, mock_session: MagicMock, body: bytes
) -> None:
# Arrange
mock_session.post.return_value.content = body
# Act / Assert
with pytest.raises(AbriResponseParseError):
client.amend_job(_spec_example_amend_request())
# --- amend_job: transport failures ---
def test_amend_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.amend_job(_spec_example_amend_request())
def test_amend_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.amend_job(_spec_example_amend_request())
# --- abandon_job: success ---
def test_abandon_job_returns_job_abandoned_with_the_cancelled_job_number(
client: AbriClient, mock_session: MagicMock
) -> None:
# Arrange
mock_session.post.return_value.content = _load_fixture(
"abri_relay_canceljob_success_response.xml"
)
# Act
result = client.abandon_job(_spec_example_abandon_request())
# Assert
assert result == JobAbandoned(job_no="AC0439951")
def test_abandon_job_sends_the_recorded_canceljob_envelope_to_the_relay_endpoint(
client: AbriClient, mock_session: MagicMock
) -> None:
# Arrange
mock_session.post.return_value.content = _load_fixture(
"abri_relay_canceljob_success_response.xml"
)
# Act
client.abandon_job(_spec_example_abandon_request())
# Assert
(url,) = mock_session.post.call_args.args
sent_body: bytes = mock_session.post.call_args.kwargs["data"]
expected_body = _load_fixture("abri_relay_canceljob_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
)