From 2e72f32d5244ef02c3bf4da478fe201e2f6bdee1 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Tue, 7 Jul 2026 10:12:41 +0000 Subject: [PATCH] =?UTF-8?q?A=20scrape=20that=20fires=20Abri=20triggers=20b?= =?UTF-8?q?uilds=20one=20message=20naming=20the=20fired=20flows=20?= =?UTF-8?q?=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/abri_flow_triggers.py | 29 +++ etl/hubspot/tests/test_abri_flow_triggers.py | 236 +++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 etl/hubspot/abri_flow_triggers.py create mode 100644 etl/hubspot/tests/test_abri_flow_triggers.py diff --git a/etl/hubspot/abri_flow_triggers.py b/etl/hubspot/abri_flow_triggers.py new file mode 100644 index 000000000..a379acd6f --- /dev/null +++ b/etl/hubspot/abri_flow_triggers.py @@ -0,0 +1,29 @@ +"""Builds the one-per-scrape SQS message for the Abri OpenHousing lambda. + +The scraper decides which flows fire (the differ predicates live in one +tested place); the lambda is a dumb dispatcher. When any predicate fires, +one message carries every fired flow plus the deal fields they need, so +flows for the same deal cannot interleave across concurrent lambda +invocations. + +Abandonment (no-access) detection stays unwired until the CancelJob flow +exists — check_for_abri_deal_abandonment is deliberately not called here. +""" + +from typing import Any, Dict, List, Optional + +from backend.app.db.models.hubspot_deal_data import HubspotDealData +from etl.hubspot.hubspot_deal_differ import HubspotDealDiffer +from etl.hubspot.project_data import ProjectData +from etl.hubspot.utils import parse_hs_date + + +def build_abri_trigger_message( + hubspot_deal_id: str, + new_deal: Dict[str, str], + new_project: Optional[ProjectData], + new_listing: Optional[Dict[str, str]], + old_deal: HubspotDealData, +) -> Optional[Dict[str, Any]]: + """The Abri trigger message for this scrape, or None if no flow fired.""" + raise NotImplementedError diff --git a/etl/hubspot/tests/test_abri_flow_triggers.py b/etl/hubspot/tests/test_abri_flow_triggers.py new file mode 100644 index 000000000..63dbee4f9 --- /dev/null +++ b/etl/hubspot/tests/test_abri_flow_triggers.py @@ -0,0 +1,236 @@ +from datetime import datetime, timezone +from typing import Any, Dict +import uuid + +from backend.app.db.models.hubspot_deal_data import HubspotDealData +from etl.hubspot.abri_flow_triggers import build_abri_trigger_message + +BASE_TIME = datetime(2025, 12, 1, 12, 0, 0) + +DEAL_ID = "9876543210" + +LISTING = {"listing_id": "55", "owner_property_id": "1007165", "national_uprn": "1"} + + +def make_old_deal(**overrides: Any) -> HubspotDealData: + return HubspotDealData( + id=overrides.get("id", uuid.uuid4()), + deal_id=DEAL_ID, + created_at=BASE_TIME, + updated_at=BASE_TIME, + **{k: v for k, v in overrides.items() if k != "id"}, + ) + + +def make_new_deal(**overrides: str) -> Dict[str, str]: + return { + "hs_object_id": DEAL_ID, + "dealname": "49 Admers Crescent", + "project_code": "Abri Condition", + **overrides, + } + + +# ========================== +# SINGLE-FLOW FIRING +# ========================== + + +def test_a_first_confirmed_survey_date_builds_a_log_job_message() -> None: + # Arrange + old_deal = make_old_deal( + project_code="Abri Condition", confirmed_survey_date=None + ) + new_deal = make_new_deal( + confirmed_survey_date="2026-06-24", confirmed_survey_time="14:30" + ) + + # Act + message = build_abri_trigger_message( + hubspot_deal_id=DEAL_ID, + new_deal=new_deal, + new_project=None, + new_listing=LISTING, + old_deal=old_deal, + ) + + # 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", + } + + +def test_a_changed_confirmed_survey_date_builds_an_amend_job_message() -> None: + # Arrange + old_deal = make_old_deal( + project_code="Abri Condition", + confirmed_survey_date=datetime(2026, 6, 18, tzinfo=timezone.utc), + ) + new_deal = make_new_deal( + confirmed_survey_date="2026-06-24", confirmed_survey_time="14:30" + ) + + # Act + message = build_abri_trigger_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["confirmed_survey_date"] == "2026-06-24" + + +def test_a_first_expected_commencement_date_builds_a_tenant_sync_message() -> None: + # Arrange + old_deal = make_old_deal( + project_code="Abri Condition", expected_commencement_date=None + ) + new_deal = make_new_deal(expected_commencement_date="2026-07-01") + + # Act + message = build_abri_trigger_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"] == ["sync_tenant_data"] + assert message["place_ref"] == "1007165" + + +# ========================== +# ONE MESSAGE PER SCRAPE +# ========================== + + +def test_several_flows_firing_in_one_scrape_share_one_message() -> None: + # Arrange: date changed and commencement date first set together. + old_deal = make_old_deal( + project_code="Abri Condition", + confirmed_survey_date=datetime(2026, 6, 18, tzinfo=timezone.utc), + expected_commencement_date=None, + ) + new_deal = make_new_deal( + confirmed_survey_date="2026-06-24", + confirmed_survey_time="14:30", + expected_commencement_date="2026-07-01", + ) + + # Act + message = build_abri_trigger_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", "sync_tenant_data"] + + +# ========================== +# NOTHING FIRES +# ========================== + + +def test_no_message_when_no_abri_trigger_fires() -> None: + # Arrange: something changed, but nothing Abri cares about. + old_deal = make_old_deal(project_code="Abri Condition", outcome=None) + new_deal = make_new_deal(outcome="left voicemail") + + # Act + message = build_abri_trigger_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_no_message_for_a_non_abri_deal() -> None: + # Arrange: a survey date first set, but on another project's deal. + old_deal = make_old_deal(project_code="Other", confirmed_survey_date=None) + new_deal = make_new_deal( + project_code="Other", confirmed_survey_date="2026-06-24" + ) + + # Act + message = build_abri_trigger_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_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. + old_deal = make_old_deal( + project_code="Abri Condition", number_of_attempts="2", outcome=None + ) + new_deal = make_new_deal(number_of_attempts="3", outcome="no answer") + + # Act + message = build_abri_trigger_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 + + +# ========================== +# PAYLOAD ROBUSTNESS +# ========================== + + +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 Condition", confirmed_survey_date=None + ) + new_deal = make_new_deal(confirmed_survey_date="2026-06-24") + + # Act + message = build_abri_trigger_message( + hubspot_deal_id=DEAL_ID, + new_deal=new_deal, + new_project=None, + new_listing=None, + old_deal=old_deal, + ) + + # Assert + assert message is not None + assert message["place_ref"] is None + assert message["confirmed_survey_time"] is None