From fb64f9687e4b6ae16edc1ee365a7520d08b76a0d Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 24 Jul 2026 15:53:50 +0000 Subject: [PATCH] =?UTF-8?q?Fail=20non-retriably=20when=20amending=20or=20a?= =?UTF-8?q?bandoning=20a=20deal=20that=20never=20logged=20to=20Abri=20?= =?UTF-8?q?=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 (1M context) --- applications/abri/dispatch.py | 18 +++++++ tests/applications/abri/test_dispatch.py | 68 +++++++++++++++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/applications/abri/dispatch.py b/applications/abri/dispatch.py index 7d03f1120..092c0a59c 100644 --- a/applications/abri/dispatch.py +++ b/applications/abri/dispatch.py @@ -44,6 +44,24 @@ class AbriFlows(Protocol): ) -> AbandonJobOrchestrationResult: ... +class DealDivergedFromAbriError(NonRetriableTaskError): + """An amend/abandon on a deal carrying no client_booking_reference. + + An absent Job Number is ambiguous — the job was never logged, or the + reference OpenHousing returned was lost — so the change or cancellation + cannot be synced to Abri. Non-retriable: redelivery cannot conjure a + reference; a human must reconcile the deal against OpenHousing. + """ + + def __init__(self, flow: str, deal_id: str) -> None: + self.flow = flow + self.deal_id = deal_id + super().__init__( + f"{flow} cannot run for deal {deal_id}: no client_booking_reference " + "(Job Number) — the deal has diverged from Abri; check OpenHousing" + ) + + class AbriFlowRejectedError(Exception): """An OpenHousing rejection, surfaced as a failure so the task fails. diff --git a/tests/applications/abri/test_dispatch.py b/tests/applications/abri/test_dispatch.py index 7d898b155..19a988ee3 100644 --- a/tests/applications/abri/test_dispatch.py +++ b/tests/applications/abri/test_dispatch.py @@ -4,7 +4,11 @@ from typing import Any, Dict, List, Optional import pytest from applications.abri.abri_trigger_request import AbriTriggerRequest -from applications.abri.dispatch import AbriFlowRejectedError, dispatch_abri_flows +from applications.abri.dispatch import ( + AbriFlowRejectedError, + DealDivergedFromAbriError, + dispatch_abri_flows, +) from domain.abri.models import ( AbriRequestRejected, AppointmentAmended, @@ -29,7 +33,9 @@ from utilities.aws_lambda.task_handler import NonRetriableTaskError DEAL_ID = "9876543210" -def _request(flows: List[str]) -> AbriTriggerRequest: +def _request( + flows: List[str], client_booking_reference: Optional[str] = "AD0226519" +) -> AbriTriggerRequest: return AbriTriggerRequest.model_validate( { "hubspot_deal_id": DEAL_ID, @@ -41,6 +47,7 @@ def _request(flows: List[str]) -> AbriTriggerRequest: "last_submission_date": "2026-07-01", "outcome": "no answer", "third_party_surveyor_identifier": "THIRDPARTY", + "client_booking_reference": client_booking_reference, } ) @@ -218,6 +225,63 @@ def test_the_log_flow_runs_when_the_deal_has_no_job_no( assert [call[0] for call in orchestrator.calls] == ["log_job"] +# --- divergence: amend/abandon with no Job Number cannot sync to Abri --- + + +def test_an_amend_with_no_client_booking_reference_raises_a_divergence_error( + orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase +) -> None: + # Arrange: an amend fires on a deal that was never logged (no Job Number). + request = _request(["amend_job"], client_booking_reference=None) + + # Act / Assert: non-retriable, and the amend flow never runs. + with pytest.raises(DealDivergedFromAbriError) as exc_info: + dispatch_abri_flows(request, orchestrator, deal_database) + assert isinstance(exc_info.value, NonRetriableTaskError) + assert DEAL_ID in str(exc_info.value) + assert orchestrator.calls == [] + + +def test_an_abandon_with_no_client_booking_reference_raises_a_divergence_error( + orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase +) -> None: + # Arrange: an abandon fires on a deal with no Job Number — possibly a lost + # reference, so a live Abri job must not be silently left un-cancelled. + request = _request(["abandon_job"], client_booking_reference=None) + + # Act / Assert + with pytest.raises(DealDivergedFromAbriError) as exc_info: + dispatch_abri_flows(request, orchestrator, deal_database) + assert isinstance(exc_info.value, NonRetriableTaskError) + assert orchestrator.calls == [] + + +def test_an_amend_with_a_client_booking_reference_proceeds( + orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase +) -> None: + # Arrange: the deal carries a Job Number, so the job exists and is amended. + request = _request(["amend_job"], client_booking_reference="AD0226519") + + # Act + dispatch_abri_flows(request, orchestrator, deal_database) + + # Assert + assert [call[0] for call in orchestrator.calls] == ["amend_job"] + + +def test_an_abandon_with_a_client_booking_reference_proceeds( + orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase +) -> None: + # Arrange + request = _request(["abandon_job"], client_booking_reference="AD0226519") + + # Act + dispatch_abri_flows(request, orchestrator, deal_database) + + # Assert + assert [call[0] for call in orchestrator.calls] == ["abandon_job"] + + # --- rejections raise, so the task fails and the DLQ is the ops inbox ---