diff --git a/domain/abri/models.py b/domain/abri/models.py index 0444ec1cc..ceb03c34f 100644 --- a/domain/abri/models.py +++ b/domain/abri/models.py @@ -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] diff --git a/etl/hubspot/deal_contacts_client.py b/etl/hubspot/deal_contacts_client.py new file mode 100644 index 000000000..df8a8f0ec --- /dev/null +++ b/etl/hubspot/deal_contacts_client.py @@ -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 diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py index 3c95c2766..6c9cc883d 100644 --- a/infrastructure/abri/abri_client.py +++ b/infrastructure/abri/abri_client.py @@ -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]: diff --git a/orchestration/tenant_data_sync_orchestrator.py b/orchestration/tenant_data_sync_orchestrator.py new file mode 100644 index 000000000..18790f9d1 --- /dev/null +++ b/orchestration/tenant_data_sync_orchestrator.py @@ -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 diff --git a/tests/orchestration/test_tenant_data_sync_orchestrator.py b/tests/orchestration/test_tenant_data_sync_orchestrator.py new file mode 100644 index 000000000..89e188b35 --- /dev/null +++ b/tests/orchestration/test_tenant_data_sync_orchestrator.py @@ -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'' + f"{tenancy_reference}{tenants_xml}" + ).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( + '136847309853460810' + '02473757484' + ) + + # 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 + ) + ], + )