Model/tests/applications/abri/test_dispatch.py
Daniel Roth 84d0e1e0d5 Date an abandonment to the deal's own dates, not now() 🟩
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 <noreply@anthropic.com>
2026-07-08 08:51:39 +00:00

361 lines
11 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",
"last_submission_date": "2026-07-01",
"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_fields_it_dates_the_cancel_from(
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",
confirmed_survey_date=date(2026, 6, 24),
last_submission_date=date(2026, 7, 1),
),
)
]
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"}