diff --git a/infrastructure/hubspot/deal_properties_client.py b/infrastructure/hubspot/deal_properties_client.py index f97a51448..cf6966fb1 100644 --- a/infrastructure/hubspot/deal_properties_client.py +++ b/infrastructure/hubspot/deal_properties_client.py @@ -1,7 +1,8 @@ -from typing import Any, Dict, Optional, cast +from typing import Dict, Optional, cast from hubspot.client import Client # type: ignore[reportMissingTypeStubs] from hubspot.crm.deals import SimplePublicObjectInput # type: ignore[reportMissingTypeStubs] +from hubspot.crm.objects.models import SimplePublicObject as HubspotObject # type: ignore[reportMissingTypeStubs] from infrastructure.hubspot.scrubbed_calls import scrubbed_call_with_retry @@ -24,12 +25,17 @@ class HubspotDealPropertiesClient: a property whole (HubSpot has no append) would otherwise overwrite edits made since the last scrape. """ - deal: Any = scrubbed_call_with_retry( - lambda: self._client.crm.deals.basic_api.get_by_id( # type: ignore[reportUnknownMemberType] - deal_id, properties=[property_name] + deal: HubspotObject = scrubbed_call_with_retry( + lambda: cast( + HubspotObject, + self._client.crm.deals.basic_api.get_by_id( # type: ignore[reportUnknownMemberType] + deal_id, properties=[property_name] + ), ) ) - properties = cast(Dict[str, Optional[str]], deal.properties) + properties: Dict[str, Optional[str]] = cast( + Dict[str, Optional[str]], deal.properties # type: ignore[reportUnknownMemberType] + ) return properties.get(property_name) def write_deal_property( diff --git a/tests/orchestration/test_abri_orchestrator_tenant_data_sync.py b/tests/orchestration/test_abri_orchestrator_tenant_data_sync.py index a62580bd4..f0b8c8aed 100644 --- a/tests/orchestration/test_abri_orchestrator_tenant_data_sync.py +++ b/tests/orchestration/test_abri_orchestrator_tenant_data_sync.py @@ -10,10 +10,12 @@ import requests from hubspot.crm.associations.v4 import AssociationSpec # type: ignore[reportMissingTypeStubs] from hubspot.crm.contacts import ApiException as ContactsApiException # type: ignore[reportMissingTypeStubs] from hubspot.crm.contacts import SimplePublicObjectInputForCreate # type: ignore[reportMissingTypeStubs] +from hubspot.crm.deals import ApiException as DealsApiException # type: ignore[reportMissingTypeStubs] from hubspot.crm.deals import SimplePublicObjectInput as DealPropertyInput # type: ignore[reportMissingTypeStubs] from domain.abri.models import AbriRequestRejected, PlaceRef from infrastructure.hubspot.deal_contacts_client import HubspotDealContactsClient +from infrastructure.hubspot.errors import HubspotRequestError from infrastructure.hubspot.deal_properties_client import HubspotDealPropertiesClient from infrastructure.abri.abri_client import AbriClient from infrastructure.abri.config import AbriConfig @@ -588,3 +590,72 @@ def test_a_void_property_is_flagged_at_the_front_of_the_deals_booking_informatio properties={"notes_for_surveyor": "Void || Key safe code 1234"} ), ) + + +def test_a_void_property_with_no_existing_booking_information_is_flagged_alone( + orchestrator: AbriOrchestrator, + mock_session: MagicMock, + sdk_client: MagicMock, +) -> None: + # Arrange: HubSpot sends "" for an unset property. + mock_session.post.return_value.content = _load_fixture( + "abri_relay_getscstenantdata_notenancyfound_response.xml" + ) + sdk_client.crm.deals.basic_api.get_by_id.return_value.properties = { + "notes_for_surveyor": "" + } + + # Act + orchestrator.sync_tenant_data(PLACE_REF, deal_id=DEAL_ID) + + # Assert: no dangling separator on an otherwise empty note. + sdk_client.crm.deals.basic_api.update.assert_called_once_with( + DEAL_ID, + simple_public_object_input=DealPropertyInput( + properties={"notes_for_surveyor": "Void ||"} + ), + ) + + +def test_a_deal_already_flagged_void_is_left_alone_so_redelivery_never_stacks_flags( + orchestrator: AbriOrchestrator, + mock_session: MagicMock, + sdk_client: MagicMock, +) -> None: + # Arrange: the state a redelivered message finds. + mock_session.post.return_value.content = _load_fixture( + "abri_relay_getscstenantdata_notenancyfound_response.xml" + ) + sdk_client.crm.deals.basic_api.get_by_id.return_value.properties = { + "notes_for_surveyor": "Void || Key safe code 1234" + } + + # Act + result = orchestrator.sync_tenant_data(PLACE_REF, deal_id=DEAL_ID) + + # Assert + assert result == PropertyReportedVoid(place_ref=PLACE_REF) + assert sdk_client.crm.deals.basic_api.update.mock_calls == [] + + +def test_a_failed_void_flag_write_fails_the_sync_rather_than_reporting_success( + orchestrator: AbriOrchestrator, + mock_session: MagicMock, + sdk_client: MagicMock, +) -> None: + # Arrange: nothing was created for a void property, so the whole flow is + # safe to re-run — the failure must reach the caller and be redelivered. + mock_session.post.return_value.content = _load_fixture( + "abri_relay_getscstenantdata_notenancyfound_response.xml" + ) + sdk_client.crm.deals.basic_api.get_by_id.return_value.properties = { + "notes_for_surveyor": "" + } + api_error = DealsApiException(status=400, reason="Bad Request") + api_error.body = json.dumps({"category": "VALIDATION_ERROR"}) + api_error.headers = {} + sdk_client.crm.deals.basic_api.update.side_effect = api_error + + # Act / Assert + with pytest.raises(HubspotRequestError): + orchestrator.sync_tenant_data(PLACE_REF, deal_id=DEAL_ID)