Merge pull request #1492 from Hestia-Homes/feature/abri-api-integration

Abri API integration: abandon job
This commit is contained in:
Daniel Roth 2026-07-08 10:53:21 +01:00 committed by GitHub
commit 8b7d32433e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 855 additions and 30 deletions

View file

@ -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:

View file

@ -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":

View file

@ -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")

View file

@ -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

View file

@ -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]

View file

@ -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

View file

@ -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,

View file

@ -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")

View file

@ -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(

View file

@ -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,

View file

@ -0,0 +1,5 @@
<?xml version="1.0" ?>
<Root>
<CancelJobs />
<ErrorDetails code="RelayError" message="Job_no 1019905 is invalid. Job not found." />
</Root>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<response>
<success>false</success>
<code>RelayError</code>
<message>Job_no 1019905 is invalid. Job not found.</message>
</response>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" ?>
<message>
<Header>
<Security username="DomnaWeb" password="" />
</Header>
<Body>
<Request request_type="canceljob">
<Parameters attribute="job_no" attribute_value="AC0439951" />
<Parameters attribute="abandon_reason" attribute_value="CARDED" />
<Parameters attribute="date_abandoned" attribute_value="07/07/2026" />
</Request>
</Body>
</message>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" ?>
<Root>
<CancelJobs>
<JobCancelled job_no="AC0439951" />
</CancelJobs>
</Root>

View file

@ -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()

View file

@ -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"}

View file

@ -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

View file

@ -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"<Root><CancelJobs /></Root>",
b"<Root></Root>",
# JobCancelled present but missing its job_no.
b"<Root><CancelJobs><JobCancelled /></CancelJobs></Root>",
# ErrorDetails present but missing code or message.
b'<Root><CancelJobs /><ErrorDetails message="x" /></Root>',
b'<Root><CancelJobs /><ErrorDetails code="RelayError" /></Root>',
# The shared envelope, but not a failure document.
b"<response><success>true</success></response>",
b"<unexpected />",
],
)
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"<html>Server Error</html>"
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
)

View file

@ -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")