mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Merge pull request #1492 from Hestia-Homes/feature/abri-api-integration
Abri API integration: abandon job
This commit is contained in:
commit
8b7d32433e
19 changed files with 855 additions and 30 deletions
8
.github/workflows/deploy_terraform.yml
vendored
8
.github/workflows/deploy_terraform.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
28
domain/abri/abandonment.py
Normal file
28
domain/abri/abandonment.py
Normal 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
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" ?>
|
||||
<Root>
|
||||
<CancelJobs />
|
||||
<ErrorDetails code="RelayError" message="Job_no 1019905 is invalid. Job not found." />
|
||||
</Root>
|
||||
6
tests/abri/abri_relay_canceljob_relayerror_response.xml
Normal file
6
tests/abri/abri_relay_canceljob_relayerror_response.xml
Normal 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>
|
||||
13
tests/abri/abri_relay_canceljob_request_example.xml
Normal file
13
tests/abri/abri_relay_canceljob_request_example.xml
Normal 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>
|
||||
6
tests/abri/abri_relay_canceljob_success_response.xml
Normal file
6
tests/abri/abri_relay_canceljob_success_response.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" ?>
|
||||
<Root>
|
||||
<CancelJobs>
|
||||
<JobCancelled job_no="AC0439951" />
|
||||
</CancelJobs>
|
||||
</Root>
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
36
tests/domain/abri/test_abandonment.py
Normal file
36
tests/domain/abri/test_abandonment.py
Normal 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
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
232
tests/orchestration/test_abri_orchestrator_abandon_job.py
Normal file
232
tests/orchestration/test_abri_orchestrator_abandon_job.py
Normal 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")
|
||||
Loading…
Add table
Reference in a new issue