mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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>
361 lines
11 KiB
Python
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"}
|