Model/tests/applications/abri/test_dispatch.py
Daniel Roth ecdcf12050 Dispatch the abandon flow last for a fired deal-change 🟥
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:45:07 +00:00

352 lines
10 KiB
Python

from datetime import date
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 domain.abri.models import (
AbriRequestRejected,
AppointmentAmended,
JobAbandoned,
PlaceRef,
)
from orchestration.abri_orchestrator import (
AbandonJobOrchestrationResult,
AmendJobOrchestrationResult,
AppointmentChange,
ConfirmedSurveyBooking,
DealAbandonment,
JobNoNotYetRecordedError,
LogJobOrchestrationResult,
LogJobSummary,
LogJobWriteBackError,
TenantDataSyncResult,
TenantDataSyncSummary,
)
from utilities.aws_lambda.task_handler import NonRetriableTaskError
DEAL_ID = "9876543210"
def _request(flows: List[str]) -> AbriTriggerRequest:
return AbriTriggerRequest.model_validate(
{
"hubspot_deal_id": DEAL_ID,
"flows": flows,
"place_ref": "1007165",
"deal_name": "49 Admers Crescent",
"confirmed_survey_date": "2026-06-24",
"confirmed_survey_time": "14:30",
"outcome": "no answer",
}
)
class FakeOrchestrator:
"""Records flow invocations in order; outcomes are programmable."""
def __init__(self) -> None:
self.calls: List[Any] = []
self.amend_outcome: AmendJobOrchestrationResult = AppointmentAmended(
job_no="AC0439951",
appointment_date="24/06/2026",
appointment_time="PM",
)
self.log_outcome: LogJobOrchestrationResult = LogJobSummary(
deal_id=DEAL_ID, job_no="AD0226519"
)
self.log_error: Optional[Exception] = None
self.sync_outcome: TenantDataSyncResult = TenantDataSyncSummary(
tenancy_reference="TEN0001",
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))
return self.amend_outcome
def log_job(self, booking: ConfirmedSurveyBooking) -> LogJobOrchestrationResult:
self.calls.append(("log_job", booking))
if self.log_error is not None:
raise self.log_error
return self.log_outcome
def sync_tenant_data(
self, place_ref: PlaceRef, deal_id: str
) -> TenantDataSyncResult:
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."""
def __init__(self) -> None:
self.job_nos: Dict[str, str] = {}
def record_job_no(self, deal_id: str, job_no: str) -> None:
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 orchestrator() -> FakeOrchestrator:
return FakeOrchestrator()
@pytest.fixture()
def deal_database() -> FakeDealDatabase:
return FakeDealDatabase()
# --- fixed dispatch order: amend -> log -> tenant sync ---
def test_fired_flows_run_in_the_fixed_order_regardless_of_message_order(
orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase
) -> None:
# Arrange
request = _request(["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",
]
def test_flows_the_message_does_not_name_do_not_run(
orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase
) -> None:
# Arrange
request = _request(["sync_tenant_data"])
# Act
dispatch_abri_flows(request, orchestrator, deal_database)
# Assert
assert orchestrator.calls == [
("sync_tenant_data", PlaceRef("1007165"), DEAL_ID)
]
# --- the flows receive the deal fields from the message ---
def test_flows_receive_the_deal_fields_carried_by_the_message(
orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase
) -> None:
# Arrange
request = _request(["amend_job", "log_job"])
# Act
dispatch_abri_flows(request, orchestrator, deal_database)
# Assert
amend_call, log_call = orchestrator.calls
assert amend_call == (
"amend_job",
AppointmentChange(
deal_id=DEAL_ID,
confirmed_survey_date=date(2026, 6, 24),
confirmed_survey_time="14:30",
),
)
assert log_call == (
"log_job",
ConfirmedSurveyBooking(
deal_id=DEAL_ID,
place_ref=PlaceRef("1007165"),
deal_name="49 Admers Crescent",
confirmed_survey_date=date(2026, 6, 24),
confirmed_survey_time="14:30",
),
)
# --- idempotency guard: a job is never logged twice for the same deal ---
def test_the_log_flow_is_skipped_when_the_deal_already_has_a_job_no(
orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase
) -> None:
# Arrange
request = _request(["log_job", "sync_tenant_data"])
deal_database.job_nos[DEAL_ID] = "AD0226519"
# Act
dispatch_abri_flows(request, orchestrator, deal_database)
# Assert
assert [call[0] for call in orchestrator.calls] == ["sync_tenant_data"]
def test_the_log_flow_runs_when_the_deal_has_no_job_no(
orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase
) -> None:
# Arrange
request = _request(["log_job"])
# Act
dispatch_abri_flows(request, orchestrator, deal_database)
# Assert
assert [call[0] for call in orchestrator.calls] == ["log_job"]
# --- rejections raise, so the task fails and the DLQ is the ops inbox ---
def test_an_openhousing_rejection_raises_and_stops_the_dispatch(
orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase
) -> None:
# Arrange
request = _request(["amend_job", "sync_tenant_data"])
orchestrator.amend_outcome = AbriRequestRejected(
code="RelayError", message="Job_no 1019905 is invalid. Job not found."
)
# Act / Assert
with pytest.raises(AbriFlowRejectedError) as exc_info:
dispatch_abri_flows(request, orchestrator, deal_database)
assert "RelayError" in str(exc_info.value)
assert "Job_no 1019905 is invalid" in str(exc_info.value)
assert [call[0] for call in orchestrator.calls] == ["amend_job"]
# --- write-back failure: failed task, but the message is never requeued ---
def test_a_log_write_back_failure_raises_a_non_retriable_error(
orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase
) -> None:
# Arrange
request = _request(["log_job", "sync_tenant_data"])
orchestrator.log_error = LogJobWriteBackError(
message="HubSpot update failed", deal_id=DEAL_ID, job_no="AD0226519"
)
# Act / Assert: the orphaned job_no stays visible in the failure record,
# and the tenant sync never runs on this delivery.
with pytest.raises(NonRetriableTaskError) as exc_info:
dispatch_abri_flows(request, orchestrator, deal_database)
assert "AD0226519" in str(exc_info.value)
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 ---
def test_dispatch_returns_a_pii_free_summary_of_what_ran(
orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase
) -> None:
# Arrange
request = _request(["amend_job", "log_job", "sync_tenant_data"])
deal_database.job_nos[DEAL_ID] = "AC0439951"
# Act
summary = dispatch_abri_flows(request, orchestrator, deal_database)
# Assert
assert summary == {
"amend_job": "appointment amended to 24/06/2026 PM (job AC0439951)",
"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"}