Abri tenancy signatory becomes an associated HubSpot deal contact 🟥

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-06 13:22:52 +00:00
parent d1b7e15d64
commit 78fcba45e5
5 changed files with 205 additions and 1 deletions

View file

@ -1,6 +1,6 @@
from dataclasses import dataclass
from datetime import date
from typing import Literal, NewType, Optional, Union
from typing import Literal, NewType, Optional, Tuple, Union
PlaceRef = NewType("PlaceRef", str)
@ -48,3 +48,31 @@ class AppointmentAmended:
AmendJobResult = Union[AppointmentAmended, AbriRequestRejected]
@dataclass(frozen=True)
class Tenant:
"""A tenancy signatory, minimised to the fields the HubSpot sync needs.
Person title, per-person references, the main-contact flag and tenancy
dates are deliberately dropped at parse time (data minimisation); mobile
and telephone are the single best number of each kind, pre-selected by
priority.
"""
forenames: str
surname: str
mobile: Optional[str]
telephone: Optional[str]
vulnerabilities: Tuple[str, ...]
@dataclass(frozen=True)
class TenancyData:
"""Tenancy signatories for a place; the reference is PII-safe."""
tenancy_reference: str
tenants: Tuple[Tenant, ...]
GetTenantDataResult = Union[TenancyData, AbriRequestRejected]

View file

@ -0,0 +1,25 @@
from typing import Dict
from hubspot.client import Client # type: ignore[reportMissingTypeStubs]
# HubSpot-defined deal -> contact association ("deal_to_contact" in v3 terms).
DEAL_TO_CONTACT_ASSOCIATION_TYPE_ID = 3
class HubspotDealContactsClient:
"""Focused HubSpot client for the tenant-data sync flow.
Deliberately separate from the legacy HubspotClient: the SDK client is
constructor-injected, and SDK errors are re-raised scrubbed of response
bodies (HubSpot validation errors echo submitted property values, which
here are tenant PII).
"""
def __init__(self, sdk_client: Client) -> None:
self._client: Client = sdk_client
def create_contact(self, properties: Dict[str, str]) -> str:
raise NotImplementedError
def associate_contact_to_deal(self, deal_id: str, contact_id: str) -> None:
raise NotImplementedError

View file

@ -9,9 +9,11 @@ from domain.abri.models import (
AmendJobRequest,
AmendJobResult,
AppointmentAmended,
GetTenantDataResult,
JobLogged,
LogJobRequest,
LogJobResult,
PlaceRef,
)
from infrastructure.abri.config import AbriConfig
from infrastructure.abri.envelope import serialise_relay_request
@ -73,6 +75,9 @@ class AbriClient:
return self._parse_appointment_amended(outcome)
def get_tenant_data(self, place_ref: PlaceRef) -> GetTenantDataResult:
raise NotImplementedError
def _exchange(
self, request_type: str, parameters: List[Tuple[str, str]]
) -> Union[ET.Element, AbriRequestRejected]:

View file

@ -0,0 +1,37 @@
from dataclasses import dataclass
from typing import Tuple, Union
from domain.abri.models import AbriRequestRejected, PlaceRef
from etl.hubspot.deal_contacts_client import HubspotDealContactsClient
from infrastructure.abri.abri_client import AbriClient
@dataclass(frozen=True)
class TenantDataSyncSummary:
"""PII-free record of what a sync run did: safe to log and persist."""
tenancy_reference: str
contact_ids: Tuple[str, ...]
vulnerable_contact_count: int
TenantDataSyncResult = Union[TenantDataSyncSummary, AbriRequestRejected]
class TenantDataSyncOrchestrator:
"""Syncs an Abri tenancy's signatories to a HubSpot deal.
Tenant data flows straight from Abri to HubSpot; nothing is persisted
in Domna's database or logs.
"""
def __init__(
self,
abri_client: AbriClient,
hubspot: HubspotDealContactsClient,
) -> None:
self._abri = abri_client
self._hubspot = hubspot
def run(self, place_ref: PlaceRef, deal_id: str) -> TenantDataSyncResult:
raise NotImplementedError

View file

@ -0,0 +1,109 @@
from unittest.mock import MagicMock, patch
import pytest
from hubspot.crm.associations.v4 import AssociationSpec # type: ignore[reportMissingTypeStubs]
from hubspot.crm.contacts import SimplePublicObjectInputForCreate # type: ignore[reportMissingTypeStubs]
from domain.abri.models import PlaceRef
from etl.hubspot.deal_contacts_client import HubspotDealContactsClient
from infrastructure.abri.abri_client import AbriClient
from infrastructure.abri.config import AbriConfig
from orchestration.tenant_data_sync_orchestrator import (
TenantDataSyncOrchestrator,
TenantDataSyncSummary,
)
ENDPOINT_URL = "https://relay.example.test/api/DomnaRelay?code=test-function-key"
PLACE_REF = PlaceRef("1004202A")
DEAL_ID = "9876543210"
CONFIG = AbriConfig(
endpoint_url=ENDPOINT_URL,
username="DomnaWeb",
password="",
default_resource="NAULKH",
)
def _tenancy_response(
tenants_xml: str, tenancy_reference: str = "10042020051017"
) -> bytes:
return (
f'<Root><Tenancy end_date="" start_date="15/12/2008">'
f"{tenancy_reference}{tenants_xml}</Tenancy></Root>"
).encode()
@pytest.fixture()
def mock_session() -> MagicMock:
return MagicMock()
@pytest.fixture()
def sdk_client() -> MagicMock:
sdk = MagicMock()
sdk.crm.contacts.basic_api.create.return_value.id = "60123"
return sdk
@pytest.fixture()
def orchestrator(
mock_session: MagicMock, sdk_client: MagicMock
) -> TenantDataSyncOrchestrator:
with patch(
"infrastructure.abri.abri_client.requests.Session",
return_value=mock_session,
):
abri_client = AbriClient(config=CONFIG)
return TenantDataSyncOrchestrator(
abri_client=abri_client,
hubspot=HubspotDealContactsClient(sdk_client=sdk_client),
)
# --- happy path: signatories become associated deal contacts ---
def test_run_creates_an_associated_deal_contact_per_signatory_and_returns_a_summary(
orchestrator: TenantDataSyncOrchestrator,
mock_session: MagicMock,
sdk_client: MagicMock,
) -> None:
# Arrange
mock_session.post.return_value.content = _tenancy_response(
'<Tenant forenames="Amanjeet" main-contact="yes" person_title="Mrs"'
' surname="Okello">1368473<Mobile priority="10">09853460810</Mobile>'
'<Telephone priority="101">02473757484</Telephone></Tenant>'
)
# Act
result = orchestrator.run(PLACE_REF, deal_id=DEAL_ID)
# Assert
assert result == TenantDataSyncSummary(
tenancy_reference="10042020051017",
contact_ids=("60123",),
vulnerable_contact_count=0,
)
sdk_client.crm.contacts.basic_api.create.assert_called_once_with(
simple_public_object_input_for_create=SimplePublicObjectInputForCreate(
properties={
"firstname": "Amanjeet",
"lastname": "Okello",
"mobilephone": "09853460810",
"phone": "02473757484",
"is_vulnerable": "false",
}
)
)
sdk_client.crm.associations.v4.basic_api.create.assert_called_once_with(
object_type="deals",
object_id=DEAL_ID,
to_object_type="contacts",
to_object_id="60123",
association_spec=[
AssociationSpec(
association_category="HUBSPOT_DEFINED", association_type_id=3
)
],
)