Merge pull request #1683 from Hestia-Homes/feature/abri-api-tweaks

Key Abri log/amend/abandon off client_booking_reference, not the survey-date edge
This commit is contained in:
Daniel Roth 2026-07-27 12:10:22 +01:00 committed by GitHub
commit ab7b5e388f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 546 additions and 59 deletions

View file

@ -45,12 +45,15 @@ class AbriTriggerRequest(BaseModel):
# OpenHousing's bookable resource for the survey; required by the log and
# amend flows. Empty string is also rejected by Abri, so not allowed here
third_party_surveyor_identifier: Optional[str] = None
# The Job Number OpenHousing returns on a successful log. Present means the
# job exists (amend/abandon); absent means it was never logged (or the
# reference was lost). Passed through, not required by any flow. A blank
# value carries no Job Number, so it is normalised to missing.
client_booking_reference: Optional[str] = None
@field_validator("third_party_surveyor_identifier")
@field_validator("third_party_surveyor_identifier", "client_booking_reference")
@classmethod
def _blank_surveyor_identifier_is_missing(
cls, value: Optional[str]
) -> Optional[str]:
def _blank_is_missing(cls, value: Optional[str]) -> Optional[str]:
if value is not None and not value.strip():
return None
return value

View file

@ -44,6 +44,24 @@ class AbriFlows(Protocol):
) -> AbandonJobOrchestrationResult: ...
class DealDivergedFromAbriError(NonRetriableTaskError):
"""An amend/abandon on a deal carrying no client_booking_reference.
An absent Job Number is ambiguous the job was never logged, or the
reference OpenHousing returned was lost so the change or cancellation
cannot be synced to Abri. Non-retriable: redelivery cannot conjure a
reference; a human must reconcile the deal against OpenHousing.
"""
def __init__(self, flow: str, deal_id: str) -> None:
self.flow = flow
self.deal_id = deal_id
super().__init__(
f"{flow} cannot run for deal {deal_id}: no client_booking_reference "
"(Job Number) — the deal has diverged from Abri; check OpenHousing"
)
class AbriFlowRejectedError(Exception):
"""An OpenHousing rejection, surfaced as a failure so the task fails.
@ -81,6 +99,10 @@ def dispatch_abri_flows(
def _run_amend(request: AbriTriggerRequest, flows: AbriFlows) -> str:
if request.client_booking_reference is None:
raise DealDivergedFromAbriError(
flow="amend_job", deal_id=request.hubspot_deal_id
)
if (
request.confirmed_survey_date is None
or request.third_party_surveyor_identifier is None
@ -147,8 +169,13 @@ def _run_log(
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.
# tenant sync in the same batch. With a Job Number, a missing DB job_no
# raises the retriable not-yet-recorded error, which propagates so the task
# fails and redelivers; with no Job Number at all, the deal has diverged.
if request.client_booking_reference is None:
raise DealDivergedFromAbriError(
flow="abandon_job", deal_id=request.hubspot_deal_id
)
outcome = flows.abandon_job(
DealAbandonment(
deal_id=request.hubspot_deal_id,

View file

@ -268,6 +268,12 @@ class HubspotDealDiffer:
"third_party_surveyor_identifier": new_deal.get(
"third_party_surveyor_identifier"
),
# The Job Number OpenHousing returned on a successful log, if any.
# The consumer reads it to tell "job exists" (amend/abandon) from
# "never logged" (log).
"client_booking_reference": HubspotDealDiffer._client_booking_reference(
new_deal
),
# Carried for the abandon flow: it dates the cancel to the confirmed
# survey date, falling back to the last submission date.
"last_submission_date": (
@ -295,19 +301,37 @@ class HubspotDealDiffer:
if not HubspotDealDiffer._is_abri_condition_deal(new_deal, new_project):
return False
new_survey_date = parse_hs_date(new_deal.get("confirmed_survey_date"))
logger.info(
"Abri job-logging check: old_confirmed_survey_date=%s "
"new_confirmed_survey_date=%s",
old_deal.confirmed_survey_date,
new_survey_date,
)
# Only a first-time survey date (previously empty) logs a new job.
if old_deal.confirmed_survey_date is not None:
# A deal that already carries a Job Number is logged; never log it
# again (that would duplicate the job in OpenHousing).
if HubspotDealDiffer._client_booking_reference(new_deal) is not None:
return False
return new_survey_date is not None
new_survey_date = parse_hs_date(new_deal.get("confirmed_survey_date"))
new_surveyor = HubspotDealDiffer._normalised_surveyor(
new_deal.get("third_party_surveyor_identifier")
)
old_surveyor = HubspotDealDiffer._normalised_surveyor(
old_deal.third_party_surveyor_identifier
)
pair_complete_now = new_survey_date is not None and new_surveyor is not None
pair_complete_before = (
old_deal.confirmed_survey_date is not None and old_surveyor is not None
)
logger.info(
"Abri job-logging check: old_confirmed_survey_date=%s "
"new_confirmed_survey_date=%s old_surveyor=%s new_surveyor=%s "
"pair_complete_before=%s pair_complete_now=%s",
old_deal.confirmed_survey_date,
new_survey_date,
old_surveyor,
new_surveyor,
pair_complete_before,
pair_complete_now,
)
# Fire exactly once, on the scrape that completes the pair — whichever
# of the survey date or surveyor lands second.
return pair_complete_now and not pair_complete_before
@staticmethod
def check_for_abri_job_amendment(
@ -340,21 +364,31 @@ class HubspotDealDiffer:
new_surveyor,
)
# A first-time survey date logs a new job, not an amendment.
if old_deal.confirmed_survey_date is None:
# Only a complete booking (both date and surveyor set now) can be
# amended; a lone field is nothing to amend, and a pair still forming
# waits for the log flow.
# TODO: a cleared survey date (was a complete booking, now not) no
# longer fires this check; the amendoptiappt route cannot express a
# cancellation, so cancellation semantics (the canceljob route) need a
# deliberate decision (existing differ TODO, issue #1478 out of scope).
if new_survey_date is None or new_surveyor is None:
return False
# TODO: a cleared survey date (was set, now empty) still fires this
# check, but the amendoptiappt route cannot express a cancellation.
# Cancellation semantics (the "delete" action / canceljob route) need
# a deliberate decision before this trigger is wired to a consumer.
if old_deal.confirmed_survey_date != new_survey_date:
return True
change_occurred = (
old_deal.confirmed_survey_date != new_survey_date
or old_deal.confirmed_survey_time != new_survey_time
or old_surveyor != new_surveyor
)
if not change_occurred:
return False
if old_deal.confirmed_survey_time != new_survey_time:
return True
# The scrape that completes the pair logs the job; it never amends one.
if HubspotDealDiffer.check_for_abri_job_logging(
new_deal=new_deal, new_project=new_project, old_deal=old_deal
):
return False
return old_surveyor != new_surveyor
return True
@staticmethod
def check_for_abri_tenant_data_fetch(
@ -421,13 +455,25 @@ class HubspotDealDiffer:
return new_outcome == "surveyed" and old_outcome != "surveyed"
@staticmethod
def _normalised_surveyor(value: Optional[str]) -> Optional[str]:
# HubSpot sends "" for an unset property and the DB stores whatever
# the scrape wrote, so blank and missing must compare as equal.
def _blank_to_none(value: Optional[str]) -> Optional[str]:
# HubSpot sends "" for an unset property and the DB stores whatever the
# scrape wrote, so blank and missing must always compare as equal.
if value is None or not value.strip():
return None
return value
@staticmethod
def _normalised_surveyor(value: Optional[str]) -> Optional[str]:
return HubspotDealDiffer._blank_to_none(value)
@staticmethod
def _client_booking_reference(new_deal: Dict[str, str]) -> Optional[str]:
# The Job Number OpenHousing returns on a successful log; a blank value
# carries no Job Number, so blank and missing both read as "no job".
return HubspotDealDiffer._blank_to_none(
new_deal.get("client_booking_reference")
)
@staticmethod
def _is_abandoned(
number_of_attempts: Optional[str], outcome: Optional[str]

View file

@ -40,9 +40,72 @@ def make_new_deal(**overrides: str) -> Dict[str, str]:
# ==========================
def test_a_first_confirmed_survey_date_builds_a_log_job_message() -> None:
# Arrange
old_deal = make_old_deal(project_code=ABRI_PROJECT_CODE, confirmed_survey_date=None)
def test_a_surveyor_landing_after_the_date_completes_the_pair_and_logs() -> None:
# Arrange: the date was already set; the surveyor lands second, completing
# the pair, so the job is logged (not amended) whichever field lands last.
old_deal = make_old_deal(
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date=datetime(2026, 6, 24, tzinfo=timezone.utc),
confirmed_survey_time="14:30",
third_party_surveyor_identifier=None,
)
new_deal = make_new_deal(
confirmed_survey_date="2026-06-24",
confirmed_survey_time="14:30",
third_party_surveyor_identifier="THIRDPARTY",
)
# Act
message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
new_listing=LISTING,
old_deal=old_deal,
)
# Assert: the completing scrape logs (amend suppression is asserted
# separately); the point here is that a late surveyor logs, not amends.
assert message is not None
assert "log_job" in message["flows"]
def test_a_date_landing_after_the_surveyor_completes_the_pair_and_logs() -> None:
# Arrange: the surveyor was already set; the date lands second, completing
# the pair, so the job is logged — field ordering does not matter.
old_deal = make_old_deal(
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date=None,
third_party_surveyor_identifier="THIRDPARTY",
)
new_deal = make_new_deal(
confirmed_survey_date="2026-06-24",
confirmed_survey_time="14:30",
third_party_surveyor_identifier="THIRDPARTY",
)
# Act
message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
new_listing=LISTING,
old_deal=old_deal,
)
# Assert
assert message is not None
assert message["flows"] == ["log_job"]
def test_a_lone_survey_date_without_a_surveyor_fires_nothing() -> None:
# Arrange: only the date lands; the pair is incomplete, so the job is not
# logged (the resource requirement is kept) and nothing is amended.
old_deal = make_old_deal(
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date=None,
third_party_surveyor_identifier=None,
)
new_deal = make_new_deal(
confirmed_survey_date="2026-06-24", confirmed_survey_time="14:30"
)
@ -57,17 +120,88 @@ def test_a_first_confirmed_survey_date_builds_a_log_job_message() -> None:
)
# Assert
assert message == {
"hubspot_deal_id": DEAL_ID,
"flows": ["log_job"],
"place_ref": "1007165",
"deal_name": "49 Admers Crescent",
"confirmed_survey_date": "2026-06-24",
"confirmed_survey_time": "14:30",
"last_submission_date": None,
"outcome": None,
"third_party_surveyor_identifier": None,
}
assert message is None
def test_a_lone_surveyor_without_a_survey_date_fires_nothing() -> None:
# Arrange: only the surveyor lands; the pair is incomplete, so nothing fires.
old_deal = make_old_deal(
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date=None,
third_party_surveyor_identifier=None,
)
new_deal = make_new_deal(third_party_surveyor_identifier="THIRDPARTY")
# Act
message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
new_listing=LISTING,
old_deal=old_deal,
)
# Assert
assert message is None
def test_a_pair_already_complete_before_does_not_re_log() -> None:
# Arrange: both fields were already set and nothing changed, so the log
# must not fire a second time.
old_deal = make_old_deal(
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date=datetime(2026, 6, 24, tzinfo=timezone.utc),
confirmed_survey_time="14:30",
third_party_surveyor_identifier="THIRDPARTY",
)
new_deal = make_new_deal(
confirmed_survey_date="2026-06-24",
confirmed_survey_time="14:30",
third_party_surveyor_identifier="THIRDPARTY",
)
# Act
message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
new_listing=LISTING,
old_deal=old_deal,
)
# Assert
assert message is None
def test_a_deal_that_already_has_a_client_booking_reference_is_never_logged() -> None:
# Arrange: the pair completes this scrape, but the deal already carries a
# Job Number, so the job exists and must not be logged again.
old_deal = make_old_deal(
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date=datetime(2026, 6, 24, tzinfo=timezone.utc),
confirmed_survey_time="14:30",
third_party_surveyor_identifier=None,
client_booking_reference="AD0226519",
)
new_deal = make_new_deal(
confirmed_survey_date="2026-06-24",
confirmed_survey_time="14:30",
third_party_surveyor_identifier="THIRDPARTY",
client_booking_reference="AD0226519",
)
# Act
message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
new_listing=LISTING,
old_deal=old_deal,
)
# Assert
assert message is not None
assert "log_job" not in message["flows"]
def test_the_message_carries_the_deals_third_party_surveyor_identifier() -> None:
@ -93,14 +227,75 @@ def test_the_message_carries_the_deals_third_party_surveyor_identifier() -> None
assert message["third_party_surveyor_identifier"] == "THIRDPARTY"
def test_a_changed_confirmed_survey_date_builds_an_amend_job_message() -> None:
# Arrange
def test_the_message_carries_the_deals_client_booking_reference() -> None:
# Arrange: an amend on a deal that already carries a Job Number; the
# consumer reads the reference off the message to decide the job exists.
old_deal = make_old_deal(
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date=datetime(2026, 6, 18, tzinfo=timezone.utc),
third_party_surveyor_identifier="THIRDPARTY",
client_booking_reference="AD0226519",
)
new_deal = make_new_deal(
confirmed_survey_date="2026-06-24", confirmed_survey_time="14:30"
confirmed_survey_date="2026-06-24",
confirmed_survey_time="14:30",
third_party_surveyor_identifier="THIRDPARTY",
client_booking_reference="AD0226519",
)
# Act
message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
new_listing=LISTING,
old_deal=old_deal,
)
# Assert
assert message is not None
assert message["flows"] == ["amend_job"]
assert message["client_booking_reference"] == "AD0226519"
def test_the_message_omits_the_client_booking_reference_when_the_deal_has_none() -> None:
# Arrange: a pair-completing log on a deal with no Job Number yet.
old_deal = make_old_deal(
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date=None,
third_party_surveyor_identifier=None,
)
new_deal = make_new_deal(
confirmed_survey_date="2026-06-24",
confirmed_survey_time="14:30",
third_party_surveyor_identifier="THIRDPARTY",
)
# Act
message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
new_listing=LISTING,
old_deal=old_deal,
)
# Assert
assert message is not None
assert message["client_booking_reference"] is None
def test_a_changed_survey_date_on_a_complete_booking_builds_an_amend_job_message() -> None:
# Arrange: a complete booking (date + surveyor) whose date moves.
old_deal = make_old_deal(
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date=datetime(2026, 6, 18, tzinfo=timezone.utc),
third_party_surveyor_identifier="THIRDPARTY",
)
new_deal = make_new_deal(
confirmed_survey_date="2026-06-24",
confirmed_survey_time="14:30",
third_party_surveyor_identifier="THIRDPARTY",
)
# Act
@ -118,6 +313,62 @@ def test_a_changed_confirmed_survey_date_builds_an_amend_job_message() -> None:
assert message["confirmed_survey_date"] == "2026-06-24"
def test_log_takes_precedence_over_amend_on_the_pair_completing_scrape() -> None:
# Arrange: the surveyor lands to complete the pair on a deal with no Job
# Number. The surveyor change would otherwise read as an amend, but the
# pair-completing scrape must log, not amend.
old_deal = make_old_deal(
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date=datetime(2026, 6, 24, tzinfo=timezone.utc),
confirmed_survey_time="14:30",
third_party_surveyor_identifier=None,
)
new_deal = make_new_deal(
confirmed_survey_date="2026-06-24",
confirmed_survey_time="14:30",
third_party_surveyor_identifier="THIRDPARTY",
)
# Act
message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
new_listing=LISTING,
old_deal=old_deal,
)
# Assert
assert message is not None
assert message["flows"] == ["log_job"]
def test_a_date_change_on_an_incomplete_pair_does_not_amend() -> None:
# Arrange: the date moves while no surveyor is set — there is no complete
# booking to amend, so nothing fires until the pair completes.
old_deal = make_old_deal(
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date=datetime(2026, 6, 18, tzinfo=timezone.utc),
third_party_surveyor_identifier=None,
)
new_deal = make_new_deal(
confirmed_survey_date="2026-06-24",
third_party_surveyor_identifier="",
)
# Act
message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
new_listing=LISTING,
old_deal=old_deal,
)
# Assert
assert message is None
def test_a_changed_surveyor_on_a_booked_deal_builds_an_amend_job_message() -> None:
# Arrange: booking exists; only the surveyor changes.
old_deal = make_old_deal(
@ -255,15 +506,18 @@ def test_a_first_expected_commencement_date_builds_a_tenant_sync_message() -> No
def test_several_flows_firing_in_one_scrape_share_one_message() -> None:
# Arrange: date changed and commencement date first set together.
# Arrange: on a complete booking, the date changed (amend) and the
# commencement date was first set (tenant sync) together.
old_deal = make_old_deal(
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date=datetime(2026, 6, 18, tzinfo=timezone.utc),
third_party_surveyor_identifier="THIRDPARTY",
expected_commencement_date=None,
)
new_deal = make_new_deal(
confirmed_survey_date="2026-06-24",
confirmed_survey_time="14:30",
third_party_surveyor_identifier="THIRDPARTY",
expected_commencement_date="2026-07-01",
)
@ -328,9 +582,15 @@ def test_the_test_override_env_var_fires_a_deal_on_its_own_project_code(
# Arrange: a sandbox deal on its own project code, with the test override
# pointing the gate at that code so it fires like a production Abri deal.
monkeypatch.setenv("TEST_ABRI_PROJECT_CODE_OVERRIDE", "TEST-SANDBOX-CODE")
old_deal = make_old_deal(project_code="TEST-SANDBOX-CODE", confirmed_survey_date=None)
old_deal = make_old_deal(
project_code="TEST-SANDBOX-CODE",
confirmed_survey_date=None,
third_party_surveyor_identifier=None,
)
new_deal = make_new_deal(
project_code="TEST-SANDBOX-CODE", confirmed_survey_date="2026-06-24"
project_code="TEST-SANDBOX-CODE",
confirmed_survey_date="2026-06-24",
third_party_surveyor_identifier="THIRDPARTY",
)
# Act
@ -384,8 +644,15 @@ def test_a_deal_crossing_into_abandonment_builds_an_abandon_job_message() -> Non
def test_a_missing_listing_still_builds_the_message_without_a_place_ref() -> None:
# Arrange: validation in the abri lambda is the visibility surface for
# a fired flow that lacks its place_ref; the scraper still sends.
old_deal = make_old_deal(project_code=ABRI_PROJECT_CODE, confirmed_survey_date=None)
new_deal = make_new_deal(confirmed_survey_date="2026-06-24")
old_deal = make_old_deal(
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date=None,
third_party_surveyor_identifier=None,
)
new_deal = make_new_deal(
confirmed_survey_date="2026-06-24",
third_party_surveyor_identifier="THIRDPARTY",
)
# Act
message = HubspotDealDiffer.check_abri_triggers_and_construct_message(

View file

@ -353,19 +353,21 @@ def test_magicplan_trigger__outcome_surveyed_uppercase__returns_true() -> None:
# ==============================
def test_abri_job_logging__date_first_set_on_abri_deal__returns_true() -> None:
def test_abri_job_logging__pair_completed_on_abri_deal__returns_true() -> None:
deal_id = uuid.uuid4()
# Arrange
# Arrange: the surveyor lands to complete the date+surveyor pair.
old_deal = make_old_deal(
id=deal_id,
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date=None,
third_party_surveyor_identifier=None,
)
new_deal = make_new_deal(
deal_id,
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date="2026-07-15",
third_party_surveyor_identifier="THIRDPARTY",
)
# Act
@ -439,11 +441,13 @@ def test_abri_job_logging__project_code_cased_differently__returns_true() -> Non
id=deal_id,
project_code=ABRI_PROJECT_CODE.upper(),
confirmed_survey_date=None,
third_party_surveyor_identifier=None,
)
new_deal = make_new_deal(
deal_id,
project_code=ABRI_PROJECT_CODE.upper(),
confirmed_survey_date="2026-07-15",
third_party_surveyor_identifier="THIRDPARTY",
)
# Act
@ -468,11 +472,13 @@ def test_abri_job_logging__real_portal_project_code__returns_true() -> None:
id=deal_id,
project_code="[Abri Stock Condition - Privately Funded - 062026 - 1247536318670]",
confirmed_survey_date=None,
third_party_surveyor_identifier=None,
)
new_deal = make_new_deal(
deal_id,
project_code="[Abri Stock Condition - Privately Funded - 062026 - 1247536318670]",
confirmed_survey_date="2026-07-15",
third_party_surveyor_identifier="THIRDPARTY",
)
# Act
@ -530,11 +536,13 @@ def test_abri_job_logging__associated_abri_project_id__returns_true() -> None:
id=deal_id,
project_code="Southern Retrofit",
confirmed_survey_date=None,
third_party_surveyor_identifier=None,
)
new_deal = make_new_deal(
deal_id,
project_code="Southern Retrofit",
confirmed_survey_date="2026-07-15",
third_party_surveyor_identifier="THIRDPARTY",
)
new_project = ProjectData(
project_id=HubspotDealDiffer.ABRI_CONDITION_ASSOCIATED_PROJECT_ID,
@ -581,6 +589,39 @@ def test_abri_job_logging__non_abri_project_id_with_abri_code__returns_false() -
assert result is False
def test_abri_job_logging__deal_already_has_client_booking_reference__returns_false() -> (
None
):
deal_id = uuid.uuid4()
# Arrange: the pair completes this scrape, but the deal already carries a
# Job Number, so the job exists and must not be logged again.
old_deal = make_old_deal(
id=deal_id,
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date=None,
third_party_surveyor_identifier=None,
client_booking_reference="AD0226519",
)
new_deal = make_new_deal(
deal_id,
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date="2026-07-15",
third_party_surveyor_identifier="THIRDPARTY",
client_booking_reference="AD0226519",
)
# Act
result = HubspotDealDiffer.check_for_abri_job_logging(
new_deal=new_deal,
new_project=None,
old_deal=old_deal,
)
# Assert
assert result is False
# ================================
# ABRI JOB AMENDMENT TRIGGER TESTS
# ================================
@ -589,16 +630,18 @@ def test_abri_job_logging__non_abri_project_id_with_abri_code__returns_false() -
def test_abri_job_amendment__date_changed_on_abri_deal__returns_true() -> None:
deal_id = uuid.uuid4()
# Arrange
# Arrange: a complete booking (date + surveyor) whose date moves.
old_deal = make_old_deal(
id=deal_id,
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date=datetime(2026, 7, 1, tzinfo=timezone.utc),
third_party_surveyor_identifier="THIRDPARTY",
)
new_deal = make_new_deal(
deal_id,
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date="2026-07-15",
third_party_surveyor_identifier="THIRDPARTY",
)
# Act
@ -669,18 +712,20 @@ def test_abri_job_amendment__date_and_time_unchanged__returns_false() -> None:
def test_abri_job_amendment__time_changed_on_abri_deal__returns_true() -> None:
deal_id = uuid.uuid4()
# Arrange
# Arrange: a complete booking (date + surveyor) whose time moves.
old_deal = make_old_deal(
id=deal_id,
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date=datetime(2026, 7, 15, tzinfo=timezone.utc),
confirmed_survey_time="09:30",
third_party_surveyor_identifier="THIRDPARTY",
)
new_deal = make_new_deal(
deal_id,
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date="2026-07-15",
confirmed_survey_time="14:00",
third_party_surveyor_identifier="THIRDPARTY",
)
# Act

View file

@ -115,6 +115,41 @@ def test_a_blank_surveyor_identifier_is_treated_as_missing(blank: str) -> None:
AbriTriggerRequest.model_validate(message)
def test_a_message_carries_the_client_booking_reference() -> None:
# The Job Number OpenHousing returns on a successful log; the consumer
# reads it to tell "job exists" (amend/abandon) from "never logged" (log).
message = _full_message()
message["client_booking_reference"] = "AD0226519"
# Act
request = AbriTriggerRequest.model_validate(message)
# Assert
assert request.client_booking_reference == "AD0226519"
@pytest.mark.parametrize("blank", ["", " "])
def test_a_blank_client_booking_reference_is_treated_as_missing(blank: str) -> None:
# A blank reference means no Job Number: the consumer must treat it as
# "never logged / reference lost", not "job exists".
message = _full_message()
message["client_booking_reference"] = blank
# Act
request = AbriTriggerRequest.model_validate(message)
# Assert
assert request.client_booking_reference is None
def test_a_message_without_a_client_booking_reference_defaults_to_none() -> None:
# Act
request = AbriTriggerRequest.model_validate(_full_message())
# Assert
assert request.client_booking_reference is None
def test_a_message_carries_the_outcome_for_the_abandonment_reason_mapping() -> None:
# Arrange
message = _full_message()

View file

@ -4,7 +4,11 @@ from typing import Any, Dict, List, Optional
import pytest
from applications.abri.abri_trigger_request import AbriTriggerRequest
from applications.abri.dispatch import AbriFlowRejectedError, dispatch_abri_flows
from applications.abri.dispatch import (
AbriFlowRejectedError,
DealDivergedFromAbriError,
dispatch_abri_flows,
)
from domain.abri.models import (
AbriRequestRejected,
AppointmentAmended,
@ -29,7 +33,9 @@ from utilities.aws_lambda.task_handler import NonRetriableTaskError
DEAL_ID = "9876543210"
def _request(flows: List[str]) -> AbriTriggerRequest:
def _request(
flows: List[str], client_booking_reference: Optional[str] = "AD0226519"
) -> AbriTriggerRequest:
return AbriTriggerRequest.model_validate(
{
"hubspot_deal_id": DEAL_ID,
@ -41,6 +47,7 @@ def _request(flows: List[str]) -> AbriTriggerRequest:
"last_submission_date": "2026-07-01",
"outcome": "no answer",
"third_party_surveyor_identifier": "THIRDPARTY",
"client_booking_reference": client_booking_reference,
}
)
@ -218,6 +225,63 @@ def test_the_log_flow_runs_when_the_deal_has_no_job_no(
assert [call[0] for call in orchestrator.calls] == ["log_job"]
# --- divergence: amend/abandon with no Job Number cannot sync to Abri ---
def test_an_amend_with_no_client_booking_reference_raises_a_divergence_error(
orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase
) -> None:
# Arrange: an amend fires on a deal that was never logged (no Job Number).
request = _request(["amend_job"], client_booking_reference=None)
# Act / Assert: non-retriable, and the amend flow never runs.
with pytest.raises(DealDivergedFromAbriError) as exc_info:
dispatch_abri_flows(request, orchestrator, deal_database)
assert isinstance(exc_info.value, NonRetriableTaskError)
assert DEAL_ID in str(exc_info.value)
assert orchestrator.calls == []
def test_an_abandon_with_no_client_booking_reference_raises_a_divergence_error(
orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase
) -> None:
# Arrange: an abandon fires on a deal with no Job Number — possibly a lost
# reference, so a live Abri job must not be silently left un-cancelled.
request = _request(["abandon_job"], client_booking_reference=None)
# Act / Assert
with pytest.raises(DealDivergedFromAbriError) as exc_info:
dispatch_abri_flows(request, orchestrator, deal_database)
assert isinstance(exc_info.value, NonRetriableTaskError)
assert orchestrator.calls == []
def test_an_amend_with_a_client_booking_reference_proceeds(
orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase
) -> None:
# Arrange: the deal carries a Job Number, so the job exists and is amended.
request = _request(["amend_job"], client_booking_reference="AD0226519")
# Act
dispatch_abri_flows(request, orchestrator, deal_database)
# Assert
assert [call[0] for call in orchestrator.calls] == ["amend_job"]
def test_an_abandon_with_a_client_booking_reference_proceeds(
orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase
) -> None:
# Arrange
request = _request(["abandon_job"], client_booking_reference="AD0226519")
# Act
dispatch_abri_flows(request, orchestrator, deal_database)
# Assert
assert [call[0] for call in orchestrator.calls] == ["abandon_job"]
# --- rejections raise, so the task fails and the DLQ is the ops inbox ---