diff --git a/.github/workflows/deploy_terraform.yml b/.github/workflows/deploy_terraform.yml index 228b1fcea..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: diff --git a/applications/abri/abri_trigger_request.py b/applications/abri/abri_trigger_request.py index 0a9346ca9..92ec5b8c0 100644 --- a/applications/abri/abri_trigger_request.py +++ b/applications/abri/abri_trigger_request.py @@ -11,13 +11,18 @@ 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 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"), "sync_tenant_data": ("place_ref",), + "abandon_job": (), } @@ -30,6 +35,8 @@ 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") def _each_fired_flow_has_its_fields(self) -> "AbriTriggerRequest": diff --git a/applications/abri/dispatch.py b/applications/abri/dispatch.py index f69981162..6187c6ea3 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,23 @@ 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, + confirmed_survey_date=request.confirmed_survey_date, + last_submission_date=request.last_submission_date, + ) + ) + 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") diff --git a/domain/abri/abandonment.py b/domain/abri/abandonment.py new file mode 100644 index 000000000..abf6e638e --- /dev/null +++ b/domain/abri/abandonment.py @@ -0,0 +1,28 @@ +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. + + 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. + """ + return AbandonReason.CARDED diff --git a/domain/abri/models.py b/domain/abri/models.py index d96d27fda..a17cf82d5 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 - enum based on Abri codes needs creating and mapping from hubspot + """ + + 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/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index e5b32ce29..eb48fd464 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -210,11 +210,16 @@ 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 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, @@ -227,6 +232,16 @@ 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"), } @staticmethod diff --git a/etl/hubspot/tests/test_abri_flow_triggers.py b/etl/hubspot/tests/test_abri_flow_triggers.py index c6868b70b..0e7eb320b 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, @@ -62,6 +62,8 @@ 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, } @@ -76,7 +78,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 +100,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 +133,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 +157,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 +175,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, @@ -185,16 +187,20 @@ 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 ) - 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 = 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, @@ -202,8 +208,12 @@ def test_abandonment_alone_stays_unwired_and_builds_no_message() -> None: old_deal=old_deal, ) - # Assert - assert message is None + # 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" # ========================== @@ -218,7 +228,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/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py index 80f9cde77..7c9c018a9 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,21 @@ class AbriClient: return self._parse_appointment_amended(outcome) + def abandon_job(self, request: AbandonJobRequest) -> AbandonJobResult: + 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_abandon_result(outcome) + def get_tenant_data(self, place_ref: PlaceRef) -> GetTenantDataResult: outcome = self._exchange( request_type="getSCSTenantData", @@ -191,6 +209,41 @@ class AbriClient: appointment_time=appointment_time, ) + @staticmethod + 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) + + 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( + "canceljob ErrorDetails missing code or message" + ) + + return AbriRequestRejected(code=code, message=message) + @staticmethod def _parse_tenancy_data(root: ET.Element) -> TenancyData: tenancies = root.findall("Tenancy") diff --git a/orchestration/abri_orchestrator.py b/orchestration/abri_orchestrator.py index d4b61b8ba..c0aba0c93 100644 --- a/orchestration/abri_orchestrator.py +++ b/orchestration/abri_orchestrator.py @@ -2,11 +2,14 @@ from dataclasses import dataclass from datetime import date from typing import Dict, List, Optional, Protocol, Tuple, Union +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, AbriRequestRejected, AmendJobRequest, AppointmentAmended, + JobAbandoned, LogJobRequest, PlaceRef, Tenant, @@ -140,6 +143,35 @@ 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] + 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. @@ -254,6 +286,27 @@ class AbriOrchestrator: ) ) + def abandon_job( + self, abandonment: DealAbandonment + ) -> AbandonJobOrchestrationResult: + job_no = self._deal_database.job_no_for_deal(abandonment.deal_id) + 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=date_abandoned, + ) + ) + def log_job(self, booking: ConfirmedSurveyBooking) -> LogJobOrchestrationResult: descriptions = build_job_descriptions(booking.deal_name) outcome = self._abri.log_job( 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, 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/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/applications/abri/test_abri_trigger_request.py b/tests/applications/abri/test_abri_trigger_request.py index 9e9672a2b..5d723e3f0 100644 --- a/tests/applications/abri/test_abri_trigger_request.py +++ b/tests/applications/abri/test_abri_trigger_request.py @@ -83,6 +83,46 @@ 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_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 b41aa4d4b..4b7090ae2 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,8 @@ 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", } ) @@ -53,6 +63,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 +84,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 +255,78 @@ 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_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 --- @@ -252,3 +346,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"} diff --git a/tests/domain/abri/test_abandonment.py b/tests/domain/abri/test_abandonment.py new file mode 100644 index 000000000..2fbeeb3e5 --- /dev/null +++ b/tests/domain/abri/test_abandonment.py @@ -0,0 +1,36 @@ +from datetime import date +from typing import Optional + +import pytest + +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", + ["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 diff --git a/tests/infrastructure/abri/test_abri_client.py b/tests/infrastructure/abri/test_abri_client.py index 6ab6c7698..fff0a3865 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() @@ -118,6 +129,119 @@ 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.", + ) + + +# --- 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 --- @@ -348,3 +472,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 + ) 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..6243bcb9f --- /dev/null +++ b/tests/orchestration/test_abri_orchestrator_abandon_job.py @@ -0,0 +1,232 @@ +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 ( + AbandonmentDateUnknownError, + 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" +CONFIRMED_SURVEY_DATE = date(2026, 6, 24) +LAST_SUBMISSION_DATE = date(2026, 7, 1) + +CONFIG = AbriConfig( + endpoint_url=ENDPOINT_URL, + username="DomnaWeb", + password="", + default_resource="NAULKH", +) + +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: + 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, + ) + + +# --- outbound request: canceljob dates the abandonment to the survey date --- + + +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, +) -> 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 + assert url == ENDPOINT_URL + assert _sent_parameters(mock_session) == { + "job_no": JOB_NO, + "abandon_reason": "CARDED", + "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 --- + + +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")