From c206a76b228937cb297bc6f4458dcf2c51300cf6 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Jul 2026 15:30:24 +0000 Subject: [PATCH 01/19] =?UTF-8?q?Abandon=20a=20job=20through=20the=20relay?= =?UTF-8?q?=20canceljob=20route=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- domain/abri/models.py | 26 ++++++++++ infrastructure/abri/abri_client.py | 6 +++ .../abri_relay_canceljob_request_example.xml | 13 +++++ .../abri_relay_canceljob_success_response.xml | 6 +++ tests/infrastructure/abri/test_abri_client.py | 50 +++++++++++++++++++ 5 files changed, 101 insertions(+) create mode 100644 tests/abri/abri_relay_canceljob_request_example.xml create mode 100644 tests/abri/abri_relay_canceljob_success_response.xml diff --git a/domain/abri/models.py b/domain/abri/models.py index d96d27fda..159d184f2 100644 --- a/domain/abri/models.py +++ b/domain/abri/models.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from datetime import date +from enum import Enum from typing import Literal, NewType, Optional, Tuple, Union PlaceRef = NewType("PlaceRef", str) @@ -7,6 +8,16 @@ PlaceRef = NewType("PlaceRef", str) SlotCode = Literal["AM", "PM", "AD"] +class AbandonReason(str, Enum): + """OpenHousing's coded reason a job was abandoned. + + Only CARDED is used for now; the full HubSpot-outcome-to-code table is a + documented follow-up (needs Abri's code list). + """ + + CARDED = "CARDED" + + @dataclass(frozen=True) class LogJobRequest: client_ref: str @@ -78,3 +89,18 @@ class TenancyData: GetTenantDataResult = Union[TenancyData, AbriRequestRejected] + + +@dataclass(frozen=True) +class AbandonJobRequest: + job_no: str + reason: AbandonReason + date_abandoned: date + + +@dataclass(frozen=True) +class JobAbandoned: + job_no: str + + +AbandonJobResult = Union[JobAbandoned, AbriRequestRejected] diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py index 80f9cde77..3b85903d0 100644 --- a/infrastructure/abri/abri_client.py +++ b/infrastructure/abri/abri_client.py @@ -5,11 +5,14 @@ from typing import Dict, List, Tuple, Union import requests from domain.abri.models import ( + AbandonJobRequest, + AbandonJobResult, AbriRequestRejected, AmendJobRequest, AmendJobResult, AppointmentAmended, GetTenantDataResult, + JobAbandoned, JobLogged, LogJobRequest, LogJobResult, @@ -94,6 +97,9 @@ class AbriClient: return self._parse_appointment_amended(outcome) + def abandon_job(self, request: AbandonJobRequest) -> AbandonJobResult: + raise NotImplementedError + def get_tenant_data(self, place_ref: PlaceRef) -> GetTenantDataResult: outcome = self._exchange( request_type="getSCSTenantData", diff --git a/tests/abri/abri_relay_canceljob_request_example.xml b/tests/abri/abri_relay_canceljob_request_example.xml new file mode 100644 index 000000000..cab9323bf --- /dev/null +++ b/tests/abri/abri_relay_canceljob_request_example.xml @@ -0,0 +1,13 @@ + + +
+ +
+ + + + + + + +
diff --git a/tests/abri/abri_relay_canceljob_success_response.xml b/tests/abri/abri_relay_canceljob_success_response.xml new file mode 100644 index 000000000..5d841e494 --- /dev/null +++ b/tests/abri/abri_relay_canceljob_success_response.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/infrastructure/abri/test_abri_client.py b/tests/infrastructure/abri/test_abri_client.py index 6ab6c7698..5def9f129 100644 --- a/tests/infrastructure/abri/test_abri_client.py +++ b/tests/infrastructure/abri/test_abri_client.py @@ -7,9 +7,12 @@ import pytest import requests from domain.abri.models import ( + AbandonJobRequest, + AbandonReason, AbriRequestRejected, AmendJobRequest, AppointmentAmended, + JobAbandoned, JobLogged, LogJobRequest, PlaceRef, @@ -60,6 +63,14 @@ def _spec_example_amend_request() -> AmendJobRequest: ) +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() @@ -348,3 +359,42 @@ def test_amend_job_raises_transport_error_when_the_relay_is_unreachable( # 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 + ) From 03f0f4d49015b4c2d7001ebf1dd6311873580af4 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Jul 2026 15:32:13 +0000 Subject: [PATCH 02/19] =?UTF-8?q?Abandon=20a=20job=20through=20the=20relay?= =?UTF-8?q?=20canceljob=20route=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- infrastructure/abri/abri_client.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py index 3b85903d0..e6863386f 100644 --- a/infrastructure/abri/abri_client.py +++ b/infrastructure/abri/abri_client.py @@ -98,7 +98,19 @@ class AbriClient: return self._parse_appointment_amended(outcome) def abandon_job(self, request: AbandonJobRequest) -> AbandonJobResult: - raise NotImplementedError + outcome = self._exchange( + request_type="canceljob", + parameters=[ + ("job_no", request.job_no), + ("abandon_reason", request.reason.value), + ("date_abandoned", _format_appointment_date(request.date_abandoned)), + ], + ) + + if isinstance(outcome, AbriRequestRejected): + return outcome + + return self._parse_job_abandoned(outcome) def get_tenant_data(self, place_ref: PlaceRef) -> GetTenantDataResult: outcome = self._exchange( @@ -197,6 +209,22 @@ class AbriClient: appointment_time=appointment_time, ) + @staticmethod + def _parse_job_abandoned(root: ET.Element) -> JobAbandoned: + job_cancelled = root.find(".//JobCancelled") + + if job_cancelled is None: + raise AbriResponseParseError( + "JobCancelled element missing from relay response" + ) + + job_no = job_cancelled.get("job_no") + + if job_no is None: + raise AbriResponseParseError("JobCancelled element missing job_no") + + return JobAbandoned(job_no=job_no) + @staticmethod def _parse_tenancy_data(root: ET.Element) -> TenancyData: tenancies = root.findall("Tenancy") From a384b87d8f0a862751b6340ce3c4db89d79318da Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Jul 2026 15:33:16 +0000 Subject: [PATCH 03/19] =?UTF-8?q?Surface=20both=20canceljob=20failure=20sh?= =?UTF-8?q?apes=20as=20verbatim=20rejections=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ..._relay_canceljob_errordetails_response.xml | 5 +++ ...ri_relay_canceljob_relayerror_response.xml | 6 +++ tests/infrastructure/abri/test_abri_client.py | 43 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 tests/abri/abri_relay_canceljob_errordetails_response.xml create mode 100644 tests/abri/abri_relay_canceljob_relayerror_response.xml diff --git a/tests/abri/abri_relay_canceljob_errordetails_response.xml b/tests/abri/abri_relay_canceljob_errordetails_response.xml new file mode 100644 index 000000000..674e0bfa9 --- /dev/null +++ b/tests/abri/abri_relay_canceljob_errordetails_response.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/abri/abri_relay_canceljob_relayerror_response.xml b/tests/abri/abri_relay_canceljob_relayerror_response.xml new file mode 100644 index 000000000..225563172 --- /dev/null +++ b/tests/abri/abri_relay_canceljob_relayerror_response.xml @@ -0,0 +1,6 @@ + + + false + RelayError + Job_no 1019905 is invalid. Job not found. + diff --git a/tests/infrastructure/abri/test_abri_client.py b/tests/infrastructure/abri/test_abri_client.py index 5def9f129..7c1d6ff99 100644 --- a/tests/infrastructure/abri/test_abri_client.py +++ b/tests/infrastructure/abri/test_abri_client.py @@ -129,6 +129,49 @@ def test_log_job_sends_the_recorded_logjob_envelope_to_the_relay_endpoint( ) +# --- 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.", + ) + + # --- log_job: rejection --- From 157f9fc4b45385620f005dba6c51db0043e3aa76 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Jul 2026 15:34:35 +0000 Subject: [PATCH 04/19] =?UTF-8?q?Surface=20both=20canceljob=20failure=20sh?= =?UTF-8?q?apes=20as=20verbatim=20rejections=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- infrastructure/abri/abri_client.py | 39 ++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py index e6863386f..7c9c018a9 100644 --- a/infrastructure/abri/abri_client.py +++ b/infrastructure/abri/abri_client.py @@ -110,7 +110,7 @@ class AbriClient: if isinstance(outcome, AbriRequestRejected): return outcome - return self._parse_job_abandoned(outcome) + return self._parse_abandon_result(outcome) def get_tenant_data(self, place_ref: PlaceRef) -> GetTenantDataResult: outcome = self._exchange( @@ -210,20 +210,39 @@ class AbriClient: ) @staticmethod - def _parse_job_abandoned(root: ET.Element) -> JobAbandoned: + def _parse_abandon_result(root: ET.Element) -> AbandonJobResult: + # canceljob diverges from the other flows: failure can arrive as a + # result document whose cancellation collection is empty and which + # carries an ErrorDetails element, rather than the shared failure + # envelope. A present JobCancelled is success; a present ErrorDetails + # is a business rejection. Neither present is genuinely ambiguous and + # must stay retriable — never silently a success or a rejection. job_cancelled = root.find(".//JobCancelled") + if job_cancelled is not None: + job_no = job_cancelled.get("job_no") + if job_no is None: + raise AbriResponseParseError("JobCancelled element missing job_no") + return JobAbandoned(job_no=job_no) - if job_cancelled is None: + error_details = root.find(".//ErrorDetails") + if error_details is not None: + return AbriClient._rejection_from_error_details(error_details) + + raise AbriResponseParseError( + "canceljob response has neither JobCancelled nor ErrorDetails" + ) + + @staticmethod + def _rejection_from_error_details(error_details: ET.Element) -> AbriRequestRejected: + code = error_details.get("code") + message = error_details.get("message") + + if code is None or message is None: raise AbriResponseParseError( - "JobCancelled element missing from relay response" + "canceljob ErrorDetails missing code or message" ) - job_no = job_cancelled.get("job_no") - - if job_no is None: - raise AbriResponseParseError("JobCancelled element missing job_no") - - return JobAbandoned(job_no=job_no) + return AbriRequestRejected(code=code, message=message) @staticmethod def _parse_tenancy_data(root: ET.Element) -> TenancyData: From 007e561af576b391f8119ade947c57a3e453a8e3 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Jul 2026 15:36:15 +0000 Subject: [PATCH 05/19] =?UTF-8?q?Keep=20ambiguous=20or=20failed-transport?= =?UTF-8?q?=20canceljob=20responses=20retriable=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- tests/infrastructure/abri/test_abri_client.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/infrastructure/abri/test_abri_client.py b/tests/infrastructure/abri/test_abri_client.py index 7c1d6ff99..fff0a3865 100644 --- a/tests/infrastructure/abri/test_abri_client.py +++ b/tests/infrastructure/abri/test_abri_client.py @@ -172,6 +172,76 @@ def test_abandon_job_returns_rejection_from_the_shared_failure_envelope( ) +# --- 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"", + b"", + # JobCancelled present but missing its job_no. + b"", + # ErrorDetails present but missing code or message. + b'', + b'', + # The shared envelope, but not a failure document. + b"true", + b"", + ], +) +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"Server Error" + 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 --- From 2512d428a1172930869df8380c3f219fc51a98ae Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Jul 2026 15:38:10 +0000 Subject: [PATCH 06/19] =?UTF-8?q?Map=20a=20HubSpot=20outcome=20to=20an=20a?= =?UTF-8?q?bandonment=20reason=20code=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- domain/abri/abandonment.py | 13 +++++++++++++ tests/domain/abri/test_abandonment.py | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 domain/abri/abandonment.py create mode 100644 tests/domain/abri/test_abandonment.py diff --git a/domain/abri/abandonment.py b/domain/abri/abandonment.py new file mode 100644 index 000000000..b91a25c6f --- /dev/null +++ b/domain/abri/abandonment.py @@ -0,0 +1,13 @@ +from typing import Optional + +from domain.abri.models import AbandonReason + + +def abandon_reason_for_outcome(outcome: Optional[str]) -> AbandonReason: + """The OpenHousing abandonment reason code for a HubSpot deal outcome. + + The single mapping point from HubSpot's outcome to a coded reason. For now + every outcome maps to CARDED; the real outcome-to-code table is a follow-up + (needs Abri's code list), and swapping it in is a one-place change here. + """ + raise NotImplementedError diff --git a/tests/domain/abri/test_abandonment.py b/tests/domain/abri/test_abandonment.py new file mode 100644 index 000000000..4c0cda5f1 --- /dev/null +++ b/tests/domain/abri/test_abandonment.py @@ -0,0 +1,20 @@ +from typing import Optional + +import pytest + +from domain.abri.abandonment import abandon_reason_for_outcome +from domain.abri.models import AbandonReason + + +@pytest.mark.parametrize( + "outcome", + ["no answer", "cancelled", "no show", "tenant refusal", "not viable", None], +) +def test_every_outcome_currently_maps_to_the_carded_reason( + outcome: Optional[str], +) -> None: + # Act + reason = abandon_reason_for_outcome(outcome) + + # Assert + assert reason == AbandonReason.CARDED From d884edd456d913abe028501726de10f47991d622 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Jul 2026 15:38:31 +0000 Subject: [PATCH 07/19] =?UTF-8?q?Map=20a=20HubSpot=20outcome=20to=20an=20a?= =?UTF-8?q?bandonment=20reason=20code=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- domain/abri/abandonment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/domain/abri/abandonment.py b/domain/abri/abandonment.py index b91a25c6f..400c184ae 100644 --- a/domain/abri/abandonment.py +++ b/domain/abri/abandonment.py @@ -10,4 +10,4 @@ def abandon_reason_for_outcome(outcome: Optional[str]) -> AbandonReason: every outcome maps to CARDED; the real outcome-to-code table is a follow-up (needs Abri's code list), and swapping it in is a one-place change here. """ - raise NotImplementedError + return AbandonReason.CARDED From 83af3ff4d018a38c4dd2ae76d700c17ab3ec57e4 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Jul 2026 15:40:25 +0000 Subject: [PATCH 08/19] =?UTF-8?q?Orchestrate=20an=20abandonment=20from=20a?= =?UTF-8?q?=20deal's=20recorded=20job=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- orchestration/abri_orchestrator.py | 23 ++- .../test_abri_orchestrator_abandon_job.py | 171 ++++++++++++++++++ 2 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 tests/orchestration/test_abri_orchestrator_abandon_job.py diff --git a/orchestration/abri_orchestrator.py b/orchestration/abri_orchestrator.py index d4b61b8ba..f4b82943a 100644 --- a/orchestration/abri_orchestrator.py +++ b/orchestration/abri_orchestrator.py @@ -1,12 +1,15 @@ from dataclasses import dataclass from datetime import date -from typing import Dict, List, Optional, Protocol, Tuple, Union +from typing import Callable, Dict, List, Optional, Protocol, Tuple, Union +from domain.abri.abandonment import abandon_reason_for_outcome from domain.abri.descriptions import build_job_descriptions from domain.abri.models import ( + AbandonJobRequest, AbriRequestRejected, AmendJobRequest, AppointmentAmended, + JobAbandoned, LogJobRequest, PlaceRef, Tenant, @@ -140,6 +143,17 @@ class AppointmentChange: AmendJobOrchestrationResult = Union[AppointmentAmended, AbriRequestRejected] +@dataclass(frozen=True) +class DealAbandonment: + """The deal fields a crossed-into-abandoned deal contributes to a cancel.""" + + deal_id: str + outcome: Optional[str] + + +AbandonJobOrchestrationResult = Union[JobAbandoned, AbriRequestRejected] + + class JobNoNotYetRecordedError(Exception): """An amendment arrived before the deal's job_no landed in the database. @@ -194,11 +208,13 @@ class AbriOrchestrator: deal_contacts: HubspotDealContactsClient, deal_properties: HubspotDealPropertiesClient, deal_database: DealDatabaseGateway, + clock: Callable[[], date] = date.today, ) -> None: self._abri = abri_client self._deal_contacts = deal_contacts self._deal_properties = deal_properties self._deal_database = deal_database + self._clock = clock def sync_tenant_data( self, place_ref: PlaceRef, deal_id: str @@ -254,6 +270,11 @@ class AbriOrchestrator: ) ) + def abandon_job( + self, abandonment: DealAbandonment + ) -> AbandonJobOrchestrationResult: + raise NotImplementedError + def log_job(self, booking: ConfirmedSurveyBooking) -> LogJobOrchestrationResult: descriptions = build_job_descriptions(booking.deal_name) outcome = self._abri.log_job( diff --git a/tests/orchestration/test_abri_orchestrator_abandon_job.py b/tests/orchestration/test_abri_orchestrator_abandon_job.py new file mode 100644 index 000000000..c12f4681b --- /dev/null +++ b/tests/orchestration/test_abri_orchestrator_abandon_job.py @@ -0,0 +1,171 @@ +import xml.etree.ElementTree as ET +from datetime import date +from pathlib import Path +from typing import Dict, List, Optional, Tuple +from unittest.mock import MagicMock, patch + +import pytest + +from domain.abri.models import AbriRequestRejected, JobAbandoned +from infrastructure.abri.abri_client import AbriClient +from infrastructure.abri.config import AbriConfig +from infrastructure.hubspot.deal_contacts_client import HubspotDealContactsClient +from infrastructure.hubspot.deal_properties_client import HubspotDealPropertiesClient +from orchestration.abri_orchestrator import ( + AbriOrchestrator, + DealAbandonment, + JobNoNotYetRecordedError, +) + +FIXTURE_DIR = Path(__file__).parents[1] / "abri" +ENDPOINT_URL = "https://relay.example.test/api/DomnaRelay?code=test-function-key" +DEAL_ID = "9876543210" +JOB_NO = "AC0439951" +TODAY = date(2026, 7, 7) + +CONFIG = AbriConfig( + endpoint_url=ENDPOINT_URL, + username="DomnaWeb", + password="", + default_resource="NAULKH", +) + +ABANDONMENT = DealAbandonment(deal_id=DEAL_ID, outcome="no answer") + + +def _load_fixture(name: str) -> bytes: + return (FIXTURE_DIR / name).read_bytes() + + +class FakeDealDatabase: + """In-memory stand-in for the deal-database gateway.""" + + def __init__(self) -> None: + self.recorded_job_nos: List[Tuple[str, str]] = [] + self.job_nos: Dict[str, str] = {} + + def record_job_no(self, deal_id: str, job_no: str) -> None: + self.recorded_job_nos.append((deal_id, job_no)) + self.job_nos[deal_id] = job_no + + def job_no_for_deal(self, deal_id: str) -> Optional[str]: + return self.job_nos.get(deal_id) + + +@pytest.fixture() +def mock_session() -> MagicMock: + return MagicMock() + + +@pytest.fixture() +def deal_database() -> FakeDealDatabase: + return FakeDealDatabase() + + +@pytest.fixture() +def orchestrator( + mock_session: MagicMock, + deal_database: FakeDealDatabase, +) -> AbriOrchestrator: + sdk_client = MagicMock() + with patch( + "infrastructure.abri.abri_client.requests.Session", + return_value=mock_session, + ): + abri_client = AbriClient(config=CONFIG) + return AbriOrchestrator( + abri_client=abri_client, + deal_contacts=HubspotDealContactsClient(sdk_client=sdk_client), + deal_properties=HubspotDealPropertiesClient(sdk_client=sdk_client), + deal_database=deal_database, + clock=lambda: TODAY, + ) + + +# --- outbound request: canceljob carries the job, reason and today's date --- + + +def test_abandon_job_sends_a_canceljob_envelope_for_the_deals_recorded_job( + orchestrator: AbriOrchestrator, + mock_session: MagicMock, + deal_database: FakeDealDatabase, +) -> None: + # Arrange + deal_database.job_nos[DEAL_ID] = JOB_NO + mock_session.post.return_value.content = _load_fixture( + "abri_relay_canceljob_success_response.xml" + ) + + # Act + orchestrator.abandon_job(ABANDONMENT) + + # Assert + (url,) = mock_session.post.call_args.args + sent_body: bytes = mock_session.post.call_args.kwargs["data"] + sent_parameters = { + parameter.get("attribute"): parameter.get("attribute_value") + for parameter in ET.fromstring(sent_body).findall("Body/Request/Parameters") + } + assert url == ENDPOINT_URL + assert sent_parameters == { + "job_no": JOB_NO, + "abandon_reason": "CARDED", + "date_abandoned": "07/07/2026", + } + + +# --- happy path: the cancelled job is confirmed back --- + + +def test_abandon_job_returns_the_abandoned_job( + orchestrator: AbriOrchestrator, + mock_session: MagicMock, + deal_database: FakeDealDatabase, +) -> None: + # Arrange + deal_database.job_nos[DEAL_ID] = JOB_NO + mock_session.post.return_value.content = _load_fixture( + "abri_relay_canceljob_success_response.xml" + ) + + # Act + result = orchestrator.abandon_job(ABANDONMENT) + + # Assert + assert result == JobAbandoned(job_no=JOB_NO) + + +# --- ordering race: abandonment before the job_no write-back has landed --- + + +def test_abandon_job_raises_a_retriable_error_when_no_job_no_has_been_recorded( + orchestrator: AbriOrchestrator, + mock_session: MagicMock, +) -> None: + # Act / Assert + with pytest.raises(JobNoNotYetRecordedError): + orchestrator.abandon_job(ABANDONMENT) + assert mock_session.post.call_count == 0 + + +# --- rejection passthrough: OpenHousing's actual reason, verbatim --- + + +def test_abandon_job_returns_the_openhousing_rejection_verbatim( + orchestrator: AbriOrchestrator, + mock_session: MagicMock, + deal_database: FakeDealDatabase, +) -> None: + # Arrange + deal_database.job_nos[DEAL_ID] = JOB_NO + mock_session.post.return_value.content = _load_fixture( + "abri_relay_canceljob_errordetails_response.xml" + ) + + # Act + result = orchestrator.abandon_job(ABANDONMENT) + + # Assert + assert isinstance(result, AbriRequestRejected) + assert result.code == "RelayError" + assert result.message.startswith("Job_no 1019905 is invalid") From ae1b1a97765ac7d8cf34decf7ad06c4e0be72687 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Jul 2026 15:41:09 +0000 Subject: [PATCH 09/19] =?UTF-8?q?Orchestrate=20an=20abandonment=20from=20a?= =?UTF-8?q?=20deal's=20recorded=20job=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- orchestration/abri_orchestrator.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/orchestration/abri_orchestrator.py b/orchestration/abri_orchestrator.py index f4b82943a..0de04f0dc 100644 --- a/orchestration/abri_orchestrator.py +++ b/orchestration/abri_orchestrator.py @@ -273,7 +273,17 @@ class AbriOrchestrator: def abandon_job( self, abandonment: DealAbandonment ) -> AbandonJobOrchestrationResult: - raise NotImplementedError + job_no = self._deal_database.job_no_for_deal(abandonment.deal_id) + if job_no is None: + raise JobNoNotYetRecordedError(deal_id=abandonment.deal_id) + + return self._abri.abandon_job( + AbandonJobRequest( + job_no=job_no, + reason=abandon_reason_for_outcome(abandonment.outcome), + date_abandoned=self._clock(), + ) + ) def log_job(self, booking: ConfirmedSurveyBooking) -> LogJobOrchestrationResult: descriptions = build_job_descriptions(booking.deal_name) From 8cc6ee9f76b20658a7e7b4ed820706c3557ab087 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Jul 2026 15:42:02 +0000 Subject: [PATCH 10/19] =?UTF-8?q?Accept=20an=20abandon=5Fjob=20flow=20carr?= =?UTF-8?q?ying=20the=20deal=20outcome=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../abri/test_abri_trigger_request.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/applications/abri/test_abri_trigger_request.py b/tests/applications/abri/test_abri_trigger_request.py index 9e9672a2b..24baca130 100644 --- a/tests/applications/abri/test_abri_trigger_request.py +++ b/tests/applications/abri/test_abri_trigger_request.py @@ -83,6 +83,33 @@ def test_a_message_without_a_confirmed_time_parses_for_logging() -> None: assert request.confirmed_survey_time is None +def test_a_message_naming_abandon_job_validates_with_only_the_deal_id() -> None: + # Arrange: abandonment needs no deal field beyond the deal id. + message = { + "hubspot_deal_id": "9876543210", + "flows": ["abandon_job"], + } + + # Act + request = AbriTriggerRequest.model_validate(message) + + # Assert + assert request.flows == ["abandon_job"] + + +def test_a_message_carries_the_outcome_for_the_abandonment_reason_mapping() -> None: + # Arrange + message = _full_message() + message["flows"] = ["abandon_job"] + message["outcome"] = "no answer" + + # Act + request = AbriTriggerRequest.model_validate(message) + + # Assert + assert request.outcome == "no answer" + + def test_a_message_naming_an_unknown_flow_fails_validation() -> None: # Arrange message = _full_message() From db5ff971d8a15e7c148e86bedaf3f7645c712926 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Jul 2026 15:42:34 +0000 Subject: [PATCH 11/19] =?UTF-8?q?Accept=20an=20abandon=5Fjob=20flow=20carr?= =?UTF-8?q?ying=20the=20deal=20outcome=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- applications/abri/abri_trigger_request.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/applications/abri/abri_trigger_request.py b/applications/abri/abri_trigger_request.py index 0a9346ca9..f272be7bd 100644 --- a/applications/abri/abri_trigger_request.py +++ b/applications/abri/abri_trigger_request.py @@ -11,13 +11,16 @@ from typing import Dict, List, Literal, Optional, Tuple from pydantic import BaseModel, ConfigDict, Field, model_validator -AbriFlow = Literal["amend_job", "log_job", "sync_tenant_data"] +AbriFlow = Literal["amend_job", "log_job", "sync_tenant_data", "abandon_job"] # The deal fields each flow reads; hubspot_deal_id is always required. +# abandon_job needs no field beyond the deal id (the outcome is optional, only +# feeding the reason-mapping seam). _FIELDS_BY_FLOW: Dict[AbriFlow, Tuple[str, ...]] = { "amend_job": ("confirmed_survey_date",), "log_job": ("place_ref", "deal_name", "confirmed_survey_date"), "sync_tenant_data": ("place_ref",), + "abandon_job": (), } @@ -30,6 +33,7 @@ class AbriTriggerRequest(BaseModel): deal_name: Optional[str] = None confirmed_survey_date: Optional[date] = None confirmed_survey_time: Optional[str] = None + outcome: Optional[str] = None @model_validator(mode="after") def _each_fired_flow_has_its_fields(self) -> "AbriTriggerRequest": From ecdcf12050780ee40c550ca7593f9ec758dc9831 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Jul 2026 15:45:07 +0000 Subject: [PATCH 12/19] =?UTF-8?q?Dispatch=20the=20abandon=20flow=20last=20?= =?UTF-8?q?for=20a=20fired=20deal-change=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- tests/applications/abri/test_dispatch.py | 100 ++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/tests/applications/abri/test_dispatch.py b/tests/applications/abri/test_dispatch.py index b41aa4d4b..9eac4fed7 100644 --- a/tests/applications/abri/test_dispatch.py +++ b/tests/applications/abri/test_dispatch.py @@ -5,11 +5,19 @@ import pytest from applications.abri.abri_trigger_request import AbriTriggerRequest from applications.abri.dispatch import AbriFlowRejectedError, dispatch_abri_flows -from domain.abri.models import AbriRequestRejected, AppointmentAmended, PlaceRef +from domain.abri.models import ( + AbriRequestRejected, + AppointmentAmended, + JobAbandoned, + PlaceRef, +) from orchestration.abri_orchestrator import ( + AbandonJobOrchestrationResult, AmendJobOrchestrationResult, AppointmentChange, ConfirmedSurveyBooking, + DealAbandonment, + JobNoNotYetRecordedError, LogJobOrchestrationResult, LogJobSummary, LogJobWriteBackError, @@ -30,6 +38,7 @@ def _request(flows: List[str]) -> AbriTriggerRequest: "deal_name": "49 Admers Crescent", "confirmed_survey_date": "2026-06-24", "confirmed_survey_time": "14:30", + "outcome": "no answer", } ) @@ -53,6 +62,10 @@ class FakeOrchestrator: contact_ids=("101", "102"), vulnerable_contact_count=1, ) + self.abandon_outcome: AbandonJobOrchestrationResult = JobAbandoned( + job_no="AC0439951" + ) + self.abandon_error: Optional[Exception] = None def amend_job(self, change: AppointmentChange) -> AmendJobOrchestrationResult: self.calls.append(("amend_job", change)) @@ -70,6 +83,14 @@ class FakeOrchestrator: self.calls.append(("sync_tenant_data", place_ref, deal_id)) return self.sync_outcome + def abandon_job( + self, abandonment: DealAbandonment + ) -> AbandonJobOrchestrationResult: + self.calls.append(("abandon_job", abandonment)) + if self.abandon_error is not None: + raise self.abandon_error + return self.abandon_outcome + class FakeDealDatabase: """In-memory stand-in for the deal-database gateway.""" @@ -233,6 +254,70 @@ def test_a_log_write_back_failure_raises_a_non_retriable_error( assert [call[0] for call in orchestrator.calls] == ["log_job"] +# --- abandon runs last, after amend, log and tenant sync --- + + +def test_abandon_runs_last_in_the_fixed_dispatch_order( + orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase +) -> None: + # Arrange + request = _request(["abandon_job", "sync_tenant_data", "log_job", "amend_job"]) + + # Act + dispatch_abri_flows(request, orchestrator, deal_database) + + # Assert + assert [call[0] for call in orchestrator.calls] == [ + "amend_job", + "log_job", + "sync_tenant_data", + "abandon_job", + ] + + +def test_the_abandon_flow_receives_the_deal_id_and_outcome_from_the_message( + orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase +) -> None: + # Arrange + request = _request(["abandon_job"]) + + # Act + dispatch_abri_flows(request, orchestrator, deal_database) + + # Assert + assert orchestrator.calls == [ + ("abandon_job", DealAbandonment(deal_id=DEAL_ID, outcome="no answer")) + ] + + +def test_an_abandon_rejection_raises_so_the_task_fails( + orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase +) -> None: + # Arrange + request = _request(["abandon_job"]) + orchestrator.abandon_outcome = AbriRequestRejected( + code="RelayError", message="Job_no AC0439951 is invalid. Job not found." + ) + + # Act / Assert + with pytest.raises(AbriFlowRejectedError) as exc_info: + dispatch_abri_flows(request, orchestrator, deal_database) + assert "abandon_job" in str(exc_info.value) + assert "RelayError" in str(exc_info.value) + + +def test_an_abandon_before_the_job_no_is_recorded_propagates_the_retriable_error( + orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase +) -> None: + # Arrange + request = _request(["abandon_job"]) + orchestrator.abandon_error = JobNoNotYetRecordedError(deal_id=DEAL_ID) + + # Act / Assert + with pytest.raises(JobNoNotYetRecordedError): + dispatch_abri_flows(request, orchestrator, deal_database) + + # --- the task output is a PII-free summary of what ran --- @@ -252,3 +337,16 @@ def test_dispatch_returns_a_pii_free_summary_of_what_ran( "log_job": "skipped: job AC0439951 already logged", "sync_tenant_data": "2 contacts created (1 vulnerable)", } + + +def test_the_summary_records_an_abandoned_job( + orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase +) -> None: + # Arrange + request = _request(["abandon_job"]) + + # Act + summary = dispatch_abri_flows(request, orchestrator, deal_database) + + # Assert + assert summary == {"abandon_job": "job AC0439951 abandoned"} From 8aa28415466d5840511e832ab7e514f3cdc34bd7 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Jul 2026 15:46:00 +0000 Subject: [PATCH 13/19] =?UTF-8?q?Dispatch=20the=20abandon=20flow=20last=20?= =?UTF-8?q?for=20a=20fired=20deal-change=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- applications/abri/dispatch.py | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/applications/abri/dispatch.py b/applications/abri/dispatch.py index f69981162..00f9223cc 100644 --- a/applications/abri/dispatch.py +++ b/applications/abri/dispatch.py @@ -1,11 +1,13 @@ """Dispatches the flows named in a trigger message through the orchestrator. The scraper decides which flows fire; this is a dumb dispatcher. Flows run -in a fixed, retry-safe order — amend, then log, then tenant sync — so that -redelivery re-runs idempotent flows before non-idempotent ones: amend is -naturally idempotent, log is guarded by the job-number check, and tenant -contact creation (not idempotent) runs last so its failure can never cause -a duplicate job log, and a log failure can never duplicate contacts. +in a fixed, retry-safe order — amend, then log, then tenant sync, then +abandon — so that redelivery re-runs idempotent flows before non-idempotent +ones: amend is naturally idempotent, log is guarded by the job-number check, +and tenant contact creation (not idempotent) runs before abandon so its +failure can never cause a duplicate job log, and a log failure can never +duplicate contacts. Abandon runs last so a cancellation can never interfere +with a log, amend or tenant sync in the same batch. """ from typing import Dict, Protocol @@ -13,9 +15,11 @@ from typing import Dict, Protocol from applications.abri.abri_trigger_request import AbriTriggerRequest from domain.abri.models import AbriRequestRejected, PlaceRef from orchestration.abri_orchestrator import ( + AbandonJobOrchestrationResult, AmendJobOrchestrationResult, AppointmentChange, ConfirmedSurveyBooking, + DealAbandonment, DealDatabaseGateway, LogJobOrchestrationResult, LogJobWriteBackError, @@ -35,6 +39,10 @@ class AbriFlows(Protocol): self, place_ref: PlaceRef, deal_id: str ) -> TenantDataSyncResult: ... + def abandon_job( + self, abandonment: DealAbandonment + ) -> AbandonJobOrchestrationResult: ... + class AbriFlowRejectedError(Exception): """An OpenHousing rejection, surfaced as a failure so the task fails. @@ -66,6 +74,8 @@ def dispatch_abri_flows( summary["log_job"] = _run_log(request, flows, deal_database) if "sync_tenant_data" in request.flows: summary["sync_tenant_data"] = _run_tenant_sync(request, flows) + if "abandon_job" in request.flows: + summary["abandon_job"] = _run_abandon(request, flows) return summary @@ -122,6 +132,21 @@ def _run_log( return f"job {outcome.job_no} logged" +def _run_abandon(request: AbriTriggerRequest, flows: AbriFlows) -> str: + # Runs last so a cancellation can never interfere with a log, amend or + # tenant sync in the same batch. A missing job_no raises the retriable + # not-yet-recorded error, which propagates so the task fails and redelivers. + outcome = flows.abandon_job( + DealAbandonment( + deal_id=request.hubspot_deal_id, + outcome=request.outcome, + ) + ) + if isinstance(outcome, AbriRequestRejected): + raise AbriFlowRejectedError(flow="abandon_job", rejection=outcome) + return f"job {outcome.job_no} abandoned" + + def _run_tenant_sync(request: AbriTriggerRequest, flows: AbriFlows) -> str: if request.place_ref is None: raise ValueError("sync_tenant_data fired without a place_ref") From 70fb6d5735d42752f1847b32337155899c34d7b9 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Jul 2026 15:52:55 +0000 Subject: [PATCH 14/19] Repair the Abri trigger-builder import after its move into HubspotDealDiffer The move in 3934e2d3 left the trigger-builder test and local smoke script importing the deleted etl.hubspot.abri_flow_triggers module; point them at HubspotDealDiffer.check_abri_triggers_and_construct_message so they collect and run again. Co-Authored-By: Claude Opus 4.8 --- etl/hubspot/tests/test_abri_flow_triggers.py | 18 +++++++++--------- scripts/smoke_test_abri_logjob_flow.py | 6 ++---- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/etl/hubspot/tests/test_abri_flow_triggers.py b/etl/hubspot/tests/test_abri_flow_triggers.py index c6868b70b..d8b82385d 100644 --- a/etl/hubspot/tests/test_abri_flow_triggers.py +++ b/etl/hubspot/tests/test_abri_flow_triggers.py @@ -3,7 +3,7 @@ from typing import Any, Dict import uuid from backend.app.db.models.hubspot_deal_data import HubspotDealData -from etl.hubspot.abri_flow_triggers import check_for_abri_triggers_and_construct_message +from etl.hubspot.hubspot_deal_differ import HubspotDealDiffer BASE_TIME = datetime(2025, 12, 1, 12, 0, 0) @@ -46,7 +46,7 @@ def test_a_first_confirmed_survey_date_builds_a_log_job_message() -> None: ) # Act - message = check_for_abri_triggers_and_construct_message( + message = HubspotDealDiffer.check_abri_triggers_and_construct_message( hubspot_deal_id=DEAL_ID, new_deal=new_deal, new_project=None, @@ -76,7 +76,7 @@ def test_a_changed_confirmed_survey_date_builds_an_amend_job_message() -> None: ) # Act - message = check_for_abri_triggers_and_construct_message( + message = HubspotDealDiffer.check_abri_triggers_and_construct_message( hubspot_deal_id=DEAL_ID, new_deal=new_deal, new_project=None, @@ -98,7 +98,7 @@ def test_a_first_expected_commencement_date_builds_a_tenant_sync_message() -> No new_deal = make_new_deal(expected_commencement_date="2026-07-01") # Act - message = check_for_abri_triggers_and_construct_message( + message = HubspotDealDiffer.check_abri_triggers_and_construct_message( hubspot_deal_id=DEAL_ID, new_deal=new_deal, new_project=None, @@ -131,7 +131,7 @@ def test_several_flows_firing_in_one_scrape_share_one_message() -> None: ) # Act - message = check_for_abri_triggers_and_construct_message( + message = HubspotDealDiffer.check_abri_triggers_and_construct_message( hubspot_deal_id=DEAL_ID, new_deal=new_deal, new_project=None, @@ -155,7 +155,7 @@ def test_no_message_when_no_abri_trigger_fires() -> None: new_deal = make_new_deal(outcome="left voicemail") # Act - message = check_for_abri_triggers_and_construct_message( + message = HubspotDealDiffer.check_abri_triggers_and_construct_message( hubspot_deal_id=DEAL_ID, new_deal=new_deal, new_project=None, @@ -173,7 +173,7 @@ def test_no_message_for_a_non_abri_deal() -> None: new_deal = make_new_deal(project_code="Other", confirmed_survey_date="2026-06-24") # Act - message = check_for_abri_triggers_and_construct_message( + message = HubspotDealDiffer.check_abri_triggers_and_construct_message( hubspot_deal_id=DEAL_ID, new_deal=new_deal, new_project=None, @@ -194,7 +194,7 @@ def test_abandonment_alone_stays_unwired_and_builds_no_message() -> None: new_deal = make_new_deal(number_of_attempts="3", outcome="no answer") # Act - message = check_for_abri_triggers_and_construct_message( + message = HubspotDealDiffer.check_abri_triggers_and_construct_message( hubspot_deal_id=DEAL_ID, new_deal=new_deal, new_project=None, @@ -218,7 +218,7 @@ def test_a_missing_listing_still_builds_the_message_without_a_place_ref() -> Non new_deal = make_new_deal(confirmed_survey_date="2026-06-24") # Act - message = check_for_abri_triggers_and_construct_message( + message = HubspotDealDiffer.check_abri_triggers_and_construct_message( hubspot_deal_id=DEAL_ID, new_deal=new_deal, new_project=None, diff --git a/scripts/smoke_test_abri_logjob_flow.py b/scripts/smoke_test_abri_logjob_flow.py index 36f0f40e8..61bae5bee 100644 --- a/scripts/smoke_test_abri_logjob_flow.py +++ b/scripts/smoke_test_abri_logjob_flow.py @@ -129,9 +129,7 @@ def main() -> None: from applications.abri.abri_trigger_request import AbriTriggerRequest from applications.abri.dispatch import dispatch_abri_flows from backend.app.db.models.hubspot_deal_data import HubspotDealData - from etl.hubspot.abri_flow_triggers import ( - check_for_abri_triggers_and_construct_message, - ) + from etl.hubspot.hubspot_deal_differ import HubspotDealDiffer from etl.hubspot.hubspotClient import HubspotClient from infrastructure.abri.abri_client import AbriClient from infrastructure.abri.config import AbriConfig @@ -198,7 +196,7 @@ def main() -> None: f"job_no={deal_row.client_booking_reference}" ) - message = check_for_abri_triggers_and_construct_message( + message = HubspotDealDiffer.check_abri_triggers_and_construct_message( hubspot_deal_id=DEAL_ID, new_deal=hubspot_deal, new_project=project, From aaa8c9763fda4e677a03c04044bb36b451506322 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Jul 2026 15:53:47 +0000 Subject: [PATCH 15/19] =?UTF-8?q?A=20deal=20crossing=20into=20abandonment?= =?UTF-8?q?=20builds=20an=20abandon=5Fjob=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 Opus 4.8 --- etl/hubspot/tests/test_abri_flow_triggers.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/etl/hubspot/tests/test_abri_flow_triggers.py b/etl/hubspot/tests/test_abri_flow_triggers.py index d8b82385d..91b3fcc59 100644 --- a/etl/hubspot/tests/test_abri_flow_triggers.py +++ b/etl/hubspot/tests/test_abri_flow_triggers.py @@ -62,6 +62,7 @@ def test_a_first_confirmed_survey_date_builds_a_log_job_message() -> None: "deal_name": "49 Admers Crescent", "confirmed_survey_date": "2026-06-24", "confirmed_survey_time": "14:30", + "outcome": None, } @@ -185,9 +186,9 @@ def test_no_message_for_a_non_abri_deal() -> None: assert message is None -def test_abandonment_alone_stays_unwired_and_builds_no_message() -> None: - # Arrange: the abandonment predicate would fire, but CancelJob does not - # exist yet, so no flow may be dispatched for it. +def test_a_deal_crossing_into_abandonment_builds_an_abandon_job_message() -> None: + # Arrange: attempts reach the threshold with a negative outcome, so the + # deal crosses into the abandoned state and the abandon flow fires. old_deal = make_old_deal( project_code=ABRI_PROJECT_CODE, number_of_attempts="2", outcome=None ) @@ -203,7 +204,9 @@ def test_abandonment_alone_stays_unwired_and_builds_no_message() -> None: ) # Assert - assert message is None + assert message is not None + assert message["flows"] == ["abandon_job"] + assert message["outcome"] == "no answer" # ========================== From 0d69d628e8b008f0a46528bb1cd20ceaf34d4c18 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Jul 2026 15:54:24 +0000 Subject: [PATCH 16/19] =?UTF-8?q?A=20deal=20crossing=20into=20abandonment?= =?UTF-8?q?=20builds=20an=20abandon=5Fjob=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 Opus 4.8 --- etl/hubspot/hubspot_deal_differ.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index e5b32ce29..99950c648 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -210,6 +210,10 @@ class HubspotDealDiffer: new_deal=new_deal, new_project=new_project, old_deal=old_deal ): flows.append("sync_tenant_data") + if HubspotDealDiffer.check_for_abri_deal_abandonment( + new_deal=new_deal, new_project=new_project, old_deal=old_deal + ): + flows.append("abandon_job") if not flows: return None @@ -227,6 +231,9 @@ class HubspotDealDiffer: else None ), "confirmed_survey_time": new_deal.get("confirmed_survey_time"), + # Carried for the abandon flow's reason-mapping seam; harmless to + # the other flows, which ignore it. + "outcome": new_deal.get("outcome"), } @staticmethod From 4c79fb3c8e336e7a0468ac9c558906e087b60b01 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Jul 2026 16:30:36 +0000 Subject: [PATCH 17/19] update comment --- domain/abri/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/domain/abri/models.py b/domain/abri/models.py index 159d184f2..a17cf82d5 100644 --- a/domain/abri/models.py +++ b/domain/abri/models.py @@ -12,7 +12,7 @@ class AbandonReason(str, Enum): """OpenHousing's coded reason a job was abandoned. Only CARDED is used for now; the full HubSpot-outcome-to-code table is a - documented follow-up (needs Abri's code list). + documented follow-up - enum based on Abri codes needs creating and mapping from hubspot """ CARDED = "CARDED" From 9ec629daf1cf1a647aab905b5f69c1d538b95a6c Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Jul 2026 16:43:25 +0000 Subject: [PATCH 18/19] correct missed lambda renames --- .github/workflows/deploy_terraform.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy_terraform.yml b/.github/workflows/deploy_terraform.yml index 510ac147a..8b9418efc 100644 --- a/.github/workflows/deploy_terraform.yml +++ b/.github/workflows/deploy_terraform.yml @@ -707,7 +707,7 @@ jobs: needs: [determine_stage, shared_terraform] uses: ./.github/workflows/_build_image.yml with: - ecr_repo: abri-${{ needs.determine_stage.outputs.stage }} + ecr_repo: abri_api-${{ needs.determine_stage.outputs.stage }} dockerfile_path: applications/abri/handler/Dockerfile build_context: . secrets: @@ -722,10 +722,10 @@ jobs: needs: [abri_image, determine_stage] uses: ./.github/workflows/_deploy_lambda.yml with: - lambda_name: abri - lambda_path: deployment/terraform/lambda/abri + lambda_name: abri_api + lambda_path: deployment/terraform/lambda/abri_api stage: ${{ needs.determine_stage.outputs.stage }} - ecr_repo: abri-${{ needs.determine_stage.outputs.stage }} + ecr_repo: abri_api-${{ needs.determine_stage.outputs.stage }} image_digest: ${{ needs.abri_image.outputs.image_digest }} terraform_apply: ${{ needs.determine_stage.outputs.terraform_apply }} secrets: @@ -820,7 +820,7 @@ jobs: # Deploy Hubspot ETL Lambda # ============================================================ hubspot_etl_lambda: - needs: [hubspot_etl_image, determine_stage, pashub_to_ara_lambda, magic_plan_lambda, abri_lambda] + needs: [hubspot_etl_image, determine_stage, pashub_to_ara_lambda, magic_plan_lambda, abri_api_lambda] uses: ./.github/workflows/_deploy_lambda.yml with: lambda_name: hubspot-etl-to-ara From 84d0e1e0d5347ad8c70027424f99474bd9d03233 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Jul 2026 08:51:39 +0000 Subject: [PATCH 19/19] =?UTF-8?q?Date=20an=20abandonment=20to=20the=20deal?= =?UTF-8?q?'s=20own=20dates,=20not=20now()=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit date_abandoned now resolves to the third failed attempt's confirmed survey date, falling back to its last submission date, rather than the processing date. This dates the OpenHousing cancellation to when the job actually lapsed even if the trigger message lags or redelivers. - domain: abandonment_date() encodes the confirmed-survey -> last- submission fallback - DealAbandonment carries both dates; abandon_job raises AbandonmentDateUnknownError when a deal has neither - last_submission_date now flows through the trigger message and request - the injected clock is dropped: nothing reads now() any more Co-Authored-By: Claude Opus 4.8 --- applications/abri/abri_trigger_request.py | 7 +- applications/abri/dispatch.py | 2 + domain/abri/abandonment.py | 15 ++++ etl/hubspot/hubspot_deal_differ.py | 8 ++ etl/hubspot/tests/test_abri_flow_triggers.py | 11 ++- orchestration/abri_orchestrator.py | 32 +++++-- .../abri/test_abri_trigger_request.py | 13 +++ tests/applications/abri/test_dispatch.py | 13 ++- tests/domain/abri/test_abandonment.py | 18 +++- .../test_abri_orchestrator_abandon_job.py | 85 ++++++++++++++++--- 10 files changed, 180 insertions(+), 24 deletions(-) diff --git a/applications/abri/abri_trigger_request.py b/applications/abri/abri_trigger_request.py index f272be7bd..92ec5b8c0 100644 --- a/applications/abri/abri_trigger_request.py +++ b/applications/abri/abri_trigger_request.py @@ -14,8 +14,10 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator AbriFlow = Literal["amend_job", "log_job", "sync_tenant_data", "abandon_job"] # The deal fields each flow reads; hubspot_deal_id is always required. -# abandon_job needs no field beyond the deal id (the outcome is optional, only -# feeding the reason-mapping seam). +# abandon_job requires no single field: it dates the cancel to whichever of the +# confirmed survey date or last submission date is present, so requiring either +# alone would reject valid messages; the orchestrator fails the flow if a deal +# carries neither. Its outcome is optional too, only feeding the reason seam. _FIELDS_BY_FLOW: Dict[AbriFlow, Tuple[str, ...]] = { "amend_job": ("confirmed_survey_date",), "log_job": ("place_ref", "deal_name", "confirmed_survey_date"), @@ -33,6 +35,7 @@ class AbriTriggerRequest(BaseModel): deal_name: Optional[str] = None confirmed_survey_date: Optional[date] = None confirmed_survey_time: Optional[str] = None + last_submission_date: Optional[date] = None outcome: Optional[str] = None @model_validator(mode="after") diff --git a/applications/abri/dispatch.py b/applications/abri/dispatch.py index 00f9223cc..6187c6ea3 100644 --- a/applications/abri/dispatch.py +++ b/applications/abri/dispatch.py @@ -140,6 +140,8 @@ def _run_abandon(request: AbriTriggerRequest, flows: AbriFlows) -> str: DealAbandonment( deal_id=request.hubspot_deal_id, outcome=request.outcome, + confirmed_survey_date=request.confirmed_survey_date, + last_submission_date=request.last_submission_date, ) ) if isinstance(outcome, AbriRequestRejected): diff --git a/domain/abri/abandonment.py b/domain/abri/abandonment.py index 400c184ae..abf6e638e 100644 --- a/domain/abri/abandonment.py +++ b/domain/abri/abandonment.py @@ -1,8 +1,23 @@ +from datetime import date from typing import Optional from domain.abri.models import AbandonReason +def abandonment_date( + confirmed_survey_date: Optional[date], + last_submission_date: Optional[date], +) -> Optional[date]: + """The real-world date a deal's job is abandoned on. + + The confirmed survey date of the failed third attempt, since that is when + the job actually lapsed; if no survey date was ever confirmed, the date the + third attempt was last submitted stands in. Returns None when the deal + carries neither date, leaving the caller to decide how to surface the gap. + """ + return confirmed_survey_date or last_submission_date + + def abandon_reason_for_outcome(outcome: Optional[str]) -> AbandonReason: """The OpenHousing abandonment reason code for a HubSpot deal outcome. diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 99950c648..eb48fd464 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -219,6 +219,7 @@ class HubspotDealDiffer: return None confirmed_survey_date = parse_hs_date(new_deal.get("confirmed_survey_date")) + last_submission_date = parse_hs_date(new_deal.get("last_submission_date")) listing = new_listing or {} return { "hubspot_deal_id": hubspot_deal_id, @@ -231,6 +232,13 @@ class HubspotDealDiffer: else None ), "confirmed_survey_time": new_deal.get("confirmed_survey_time"), + # Carried for the abandon flow: it dates the cancel to the confirmed + # survey date, falling back to the last submission date. + "last_submission_date": ( + last_submission_date.date().isoformat() + if last_submission_date is not None + else None + ), # Carried for the abandon flow's reason-mapping seam; harmless to # the other flows, which ignore it. "outcome": new_deal.get("outcome"), diff --git a/etl/hubspot/tests/test_abri_flow_triggers.py b/etl/hubspot/tests/test_abri_flow_triggers.py index 91b3fcc59..0e7eb320b 100644 --- a/etl/hubspot/tests/test_abri_flow_triggers.py +++ b/etl/hubspot/tests/test_abri_flow_triggers.py @@ -62,6 +62,7 @@ def test_a_first_confirmed_survey_date_builds_a_log_job_message() -> None: "deal_name": "49 Admers Crescent", "confirmed_survey_date": "2026-06-24", "confirmed_survey_time": "14:30", + "last_submission_date": None, "outcome": None, } @@ -192,7 +193,11 @@ def test_a_deal_crossing_into_abandonment_builds_an_abandon_job_message() -> Non old_deal = make_old_deal( project_code=ABRI_PROJECT_CODE, number_of_attempts="2", outcome=None ) - new_deal = make_new_deal(number_of_attempts="3", outcome="no answer") + # No confirmed survey date (an abandonment that never got surveyed), so the + # last submission date is what the cancel will be dated to. + new_deal = make_new_deal( + number_of_attempts="3", outcome="no answer", last_submission_date="2026-07-01" + ) # Act message = HubspotDealDiffer.check_abri_triggers_and_construct_message( @@ -203,10 +208,12 @@ def test_a_deal_crossing_into_abandonment_builds_an_abandon_job_message() -> Non old_deal=old_deal, ) - # Assert + # Assert: the abandon flow carries the date it will date the cancel from. assert message is not None assert message["flows"] == ["abandon_job"] assert message["outcome"] == "no answer" + assert message["confirmed_survey_date"] is None + assert message["last_submission_date"] == "2026-07-01" # ========================== diff --git a/orchestration/abri_orchestrator.py b/orchestration/abri_orchestrator.py index 0de04f0dc..c0aba0c93 100644 --- a/orchestration/abri_orchestrator.py +++ b/orchestration/abri_orchestrator.py @@ -1,8 +1,8 @@ from dataclasses import dataclass from datetime import date -from typing import Callable, Dict, List, Optional, Protocol, Tuple, Union +from typing import Dict, List, Optional, Protocol, Tuple, Union -from domain.abri.abandonment import abandon_reason_for_outcome +from domain.abri.abandonment import abandon_reason_for_outcome, abandonment_date from domain.abri.descriptions import build_job_descriptions from domain.abri.models import ( AbandonJobRequest, @@ -149,11 +149,29 @@ class DealAbandonment: deal_id: str outcome: Optional[str] + confirmed_survey_date: Optional[date] + last_submission_date: Optional[date] AbandonJobOrchestrationResult = Union[JobAbandoned, AbriRequestRejected] +class AbandonmentDateUnknownError(Exception): + """An abandonment whose real-world date cannot be resolved from the deal. + + The cancel is dated to the third attempt's confirmed survey date, falling + back to its last submission date; a deal carrying neither leaves nothing to + date the cancellation to, so the flow fails rather than invent a date. + """ + + def __init__(self, deal_id: str) -> None: + self.deal_id = deal_id + super().__init__( + f"deal {deal_id} has no confirmed survey date or last submission " + "date to date its abandonment to" + ) + + class JobNoNotYetRecordedError(Exception): """An amendment arrived before the deal's job_no landed in the database. @@ -208,13 +226,11 @@ class AbriOrchestrator: deal_contacts: HubspotDealContactsClient, deal_properties: HubspotDealPropertiesClient, deal_database: DealDatabaseGateway, - clock: Callable[[], date] = date.today, ) -> None: self._abri = abri_client self._deal_contacts = deal_contacts self._deal_properties = deal_properties self._deal_database = deal_database - self._clock = clock def sync_tenant_data( self, place_ref: PlaceRef, deal_id: str @@ -277,11 +293,17 @@ class AbriOrchestrator: if job_no is None: raise JobNoNotYetRecordedError(deal_id=abandonment.deal_id) + date_abandoned = abandonment_date( + abandonment.confirmed_survey_date, abandonment.last_submission_date + ) + if date_abandoned is None: + raise AbandonmentDateUnknownError(deal_id=abandonment.deal_id) + return self._abri.abandon_job( AbandonJobRequest( job_no=job_no, reason=abandon_reason_for_outcome(abandonment.outcome), - date_abandoned=self._clock(), + date_abandoned=date_abandoned, ) ) diff --git a/tests/applications/abri/test_abri_trigger_request.py b/tests/applications/abri/test_abri_trigger_request.py index 24baca130..5d723e3f0 100644 --- a/tests/applications/abri/test_abri_trigger_request.py +++ b/tests/applications/abri/test_abri_trigger_request.py @@ -110,6 +110,19 @@ def test_a_message_carries_the_outcome_for_the_abandonment_reason_mapping() -> N assert request.outcome == "no answer" +def test_a_message_carries_the_last_submission_date_for_abandonment_dating() -> None: + # Arrange + message = _full_message() + message["flows"] = ["abandon_job"] + message["last_submission_date"] = "2026-07-01" + + # Act + request = AbriTriggerRequest.model_validate(message) + + # Assert + assert request.last_submission_date == date(2026, 7, 1) + + def test_a_message_naming_an_unknown_flow_fails_validation() -> None: # Arrange message = _full_message() diff --git a/tests/applications/abri/test_dispatch.py b/tests/applications/abri/test_dispatch.py index 9eac4fed7..4b7090ae2 100644 --- a/tests/applications/abri/test_dispatch.py +++ b/tests/applications/abri/test_dispatch.py @@ -38,6 +38,7 @@ def _request(flows: List[str]) -> AbriTriggerRequest: "deal_name": "49 Admers Crescent", "confirmed_survey_date": "2026-06-24", "confirmed_survey_time": "14:30", + "last_submission_date": "2026-07-01", "outcome": "no answer", } ) @@ -275,7 +276,7 @@ def test_abandon_runs_last_in_the_fixed_dispatch_order( ] -def test_the_abandon_flow_receives_the_deal_id_and_outcome_from_the_message( +def test_the_abandon_flow_receives_the_deal_fields_it_dates_the_cancel_from( orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase ) -> None: # Arrange @@ -286,7 +287,15 @@ def test_the_abandon_flow_receives_the_deal_id_and_outcome_from_the_message( # Assert assert orchestrator.calls == [ - ("abandon_job", DealAbandonment(deal_id=DEAL_ID, outcome="no answer")) + ( + "abandon_job", + DealAbandonment( + deal_id=DEAL_ID, + outcome="no answer", + confirmed_survey_date=date(2026, 6, 24), + last_submission_date=date(2026, 7, 1), + ), + ) ] diff --git a/tests/domain/abri/test_abandonment.py b/tests/domain/abri/test_abandonment.py index 4c0cda5f1..2fbeeb3e5 100644 --- a/tests/domain/abri/test_abandonment.py +++ b/tests/domain/abri/test_abandonment.py @@ -1,10 +1,26 @@ +from datetime import date from typing import Optional import pytest -from domain.abri.abandonment import abandon_reason_for_outcome +from domain.abri.abandonment import abandon_reason_for_outcome, abandonment_date from domain.abri.models import AbandonReason +CONFIRMED = date(2026, 6, 24) +SUBMITTED = date(2026, 7, 1) + + +def test_the_confirmed_survey_date_dates_the_abandonment() -> None: + assert abandonment_date(CONFIRMED, SUBMITTED) == CONFIRMED + + +def test_the_last_submission_date_stands_in_without_a_confirmed_survey_date() -> None: + assert abandonment_date(None, SUBMITTED) == SUBMITTED + + +def test_neither_date_leaves_the_abandonment_undated() -> None: + assert abandonment_date(None, None) is None + @pytest.mark.parametrize( "outcome", diff --git a/tests/orchestration/test_abri_orchestrator_abandon_job.py b/tests/orchestration/test_abri_orchestrator_abandon_job.py index c12f4681b..6243bcb9f 100644 --- a/tests/orchestration/test_abri_orchestrator_abandon_job.py +++ b/tests/orchestration/test_abri_orchestrator_abandon_job.py @@ -12,6 +12,7 @@ from infrastructure.abri.config import AbriConfig from infrastructure.hubspot.deal_contacts_client import HubspotDealContactsClient from infrastructure.hubspot.deal_properties_client import HubspotDealPropertiesClient from orchestration.abri_orchestrator import ( + AbandonmentDateUnknownError, AbriOrchestrator, DealAbandonment, JobNoNotYetRecordedError, @@ -21,7 +22,8 @@ FIXTURE_DIR = Path(__file__).parents[1] / "abri" ENDPOINT_URL = "https://relay.example.test/api/DomnaRelay?code=test-function-key" DEAL_ID = "9876543210" JOB_NO = "AC0439951" -TODAY = date(2026, 7, 7) +CONFIRMED_SURVEY_DATE = date(2026, 6, 24) +LAST_SUBMISSION_DATE = date(2026, 7, 1) CONFIG = AbriConfig( endpoint_url=ENDPOINT_URL, @@ -30,7 +32,12 @@ CONFIG = AbriConfig( default_resource="NAULKH", ) -ABANDONMENT = DealAbandonment(deal_id=DEAL_ID, outcome="no answer") +ABANDONMENT = DealAbandonment( + deal_id=DEAL_ID, + outcome="no answer", + confirmed_survey_date=CONFIRMED_SURVEY_DATE, + last_submission_date=LAST_SUBMISSION_DATE, +) def _load_fixture(name: str) -> bytes: @@ -78,14 +85,21 @@ def orchestrator( deal_contacts=HubspotDealContactsClient(sdk_client=sdk_client), deal_properties=HubspotDealPropertiesClient(sdk_client=sdk_client), deal_database=deal_database, - clock=lambda: TODAY, ) -# --- outbound request: canceljob carries the job, reason and today's date --- +# --- outbound request: canceljob dates the abandonment to the survey date --- -def test_abandon_job_sends_a_canceljob_envelope_for_the_deals_recorded_job( +def _sent_parameters(mock_session: MagicMock) -> Dict[Optional[str], Optional[str]]: + sent_body: bytes = mock_session.post.call_args.kwargs["data"] + return { + parameter.get("attribute"): parameter.get("attribute_value") + for parameter in ET.fromstring(sent_body).findall("Body/Request/Parameters") + } + + +def test_abandon_job_sends_a_canceljob_dated_to_the_confirmed_survey_date( orchestrator: AbriOrchestrator, mock_session: MagicMock, deal_database: FakeDealDatabase, @@ -101,19 +115,66 @@ def test_abandon_job_sends_a_canceljob_envelope_for_the_deals_recorded_job( # Assert (url,) = mock_session.post.call_args.args - sent_body: bytes = mock_session.post.call_args.kwargs["data"] - sent_parameters = { - parameter.get("attribute"): parameter.get("attribute_value") - for parameter in ET.fromstring(sent_body).findall("Body/Request/Parameters") - } assert url == ENDPOINT_URL - assert sent_parameters == { + assert _sent_parameters(mock_session) == { "job_no": JOB_NO, "abandon_reason": "CARDED", - "date_abandoned": "07/07/2026", + "date_abandoned": "24/06/2026", } +# --- fallback: no confirmed survey date, so the last submission date dates it --- + + +def test_abandon_job_falls_back_to_the_last_submission_date( + orchestrator: AbriOrchestrator, + mock_session: MagicMock, + deal_database: FakeDealDatabase, +) -> None: + # Arrange + deal_database.job_nos[DEAL_ID] = JOB_NO + mock_session.post.return_value.content = _load_fixture( + "abri_relay_canceljob_success_response.xml" + ) + + # Act + orchestrator.abandon_job( + DealAbandonment( + deal_id=DEAL_ID, + outcome="no answer", + confirmed_survey_date=None, + last_submission_date=LAST_SUBMISSION_DATE, + ) + ) + + # Assert + assert _sent_parameters(mock_session)["date_abandoned"] == "01/07/2026" + + +# --- neither date present: nothing to date the abandonment to --- + + +def test_abandon_job_raises_when_no_date_can_be_resolved( + orchestrator: AbriOrchestrator, + mock_session: MagicMock, + deal_database: FakeDealDatabase, +) -> None: + # Arrange + deal_database.job_nos[DEAL_ID] = JOB_NO + + # Act / Assert + with pytest.raises(AbandonmentDateUnknownError): + orchestrator.abandon_job( + DealAbandonment( + deal_id=DEAL_ID, + outcome="no answer", + confirmed_survey_date=None, + last_submission_date=None, + ) + ) + assert mock_session.post.call_count == 0 + + # --- happy path: the cancelled job is confirmed back ---