diff --git a/domain/abri/models.py b/domain/abri/models.py
index 0444ec1cc..d96d27fda 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,33 @@ 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, and secondary_number is the best of the numbers they left
+ unused.
+ """
+
+ forenames: str
+ surname: str
+ mobile: Optional[str]
+ telephone: Optional[str]
+ secondary_number: 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/domain/abri/phone_selection.py b/domain/abri/phone_selection.py
new file mode 100644
index 000000000..fe3588d5f
--- /dev/null
+++ b/domain/abri/phone_selection.py
@@ -0,0 +1,64 @@
+from dataclasses import dataclass
+from typing import List, Literal, Optional, Sequence, Tuple
+
+PhoneKind = Literal["mobile", "telephone"]
+
+
+@dataclass(frozen=True)
+class PhoneEntry:
+ """One phone number as Abri lists it for a tenant, in document order."""
+
+ kind: PhoneKind
+ number: str
+ priority: Optional[str] = None
+
+
+@dataclass(frozen=True)
+class SelectedPhoneNumbers:
+ """The numbers a contact carries: best of each kind plus the runner-up."""
+
+ mobile: Optional[str]
+ telephone: Optional[str]
+ secondary: Optional[str]
+
+
+# TODO(#1474): confirm the priority ordering with Abri — this assumes the
+# lowest numeric priority marks the preferred number, and that entries with
+# a missing or non-numeric priority rank last (ties keep document order).
+def _priority_sort_key(entry: PhoneEntry) -> Tuple[int, int]:
+ """Sort key: lowest numeric priority first; unusable priorities last."""
+ try:
+ return (0, int((entry.priority or "").strip()))
+ except ValueError:
+ return (1, 0)
+
+
+def _best(entries: List[PhoneEntry]) -> Optional[PhoneEntry]:
+ ranked = sorted(entries, key=_priority_sort_key) # stable: ties keep document order
+ return ranked[0] if ranked else None
+
+
+def _number(entry: Optional[PhoneEntry]) -> Optional[str]:
+ return entry.number.strip() if entry is not None else None
+
+
+def select_phone_numbers(entries: Sequence[PhoneEntry]) -> SelectedPhoneNumbers:
+ """Best mobile, best landline, then the best of the leftovers of both kinds.
+
+ All three picks use the same rule: lowest numeric priority wins, entries
+ without a usable priority sort last, ties break by document order, blank
+ numbers are ignored.
+ """
+ usable = [entry for entry in entries if entry.number.strip()]
+
+ best_mobile = _best([e for e in usable if e.kind == "mobile"])
+ best_telephone = _best([e for e in usable if e.kind == "telephone"])
+ runner_up = _best(
+ [e for e in usable if e is not best_mobile and e is not best_telephone]
+ )
+
+ return SelectedPhoneNumbers(
+ mobile=_number(best_mobile),
+ telephone=_number(best_telephone),
+ secondary=_number(runner_up),
+ )
diff --git a/etl/hubspot/hubspotClient.py b/etl/hubspot/hubspotClient.py
index 918701cac..641695e27 100644
--- a/etl/hubspot/hubspotClient.py
+++ b/etl/hubspot/hubspotClient.py
@@ -1,7 +1,5 @@
import os
-import time
from enum import Enum
-from http import HTTPStatus
from typing import Optional, cast, Callable, Any
from hubspot.client import Client # type: ignore[reportMissingTypeStubs]
@@ -32,6 +30,7 @@ from hubspot.crm.associations.v4.models import ( # type: ignore[reportMissingTy
from backend.app.config import get_settings
from etl.hubspot.company_data import CompanyData
from etl.hubspot.project_data import ProjectData
+from infrastructure.hubspot.retry import call_with_retry
from utils.logger import setup_logger
import mimetypes
@@ -87,40 +86,8 @@ class HubspotClient:
# self.client
def _call_with_retry(self, fn: Callable[[], Any], max_retries: int = 2) -> Any:
- """
- Call fn(), retrying up to max_retries times on 429 rate-limit errors
- or transient 5xx server errors.
- Waits the minimal amount: the remaining interval window reported by HubSpot headers.
- Falls back to the full interval (10s) if headers are absent.
-
- Note: each HubSpot sub-module (deals, companies, etc.) ships its own ApiException
- class with no shared base beyond Exception, so we detect retryable statuses via duck-typing.
- """
- retryable_statuses = {
- HTTPStatus.TOO_MANY_REQUESTS,
- HTTPStatus.INTERNAL_SERVER_ERROR,
- HTTPStatus.BAD_GATEWAY,
- HTTPStatus.SERVICE_UNAVAILABLE,
- HTTPStatus.GATEWAY_TIMEOUT,
- }
- for attempt in range(max_retries + 1):
- try:
- return fn()
- except Exception as e:
- status = getattr(e, "status", None)
- if status not in retryable_statuses or attempt == max_retries:
- raise
- headers = getattr(e, "headers", None) or {}
- interval_ms = int(
- headers.get("x-hubspot-ratelimit-interval-milliseconds", 10000)
- )
- wait_s = interval_ms / 1000.0
- self.logger.warning(
- f"HubSpot {status} (attempt {attempt + 1}/{max_retries}), "
- f"waiting {wait_s:.1f}s before retry."
- )
- time.sleep(wait_s)
- raise RuntimeError("Unreachable") # pragma: no cover
+ """Delegates to the shared infrastructure.hubspot.retry policy."""
+ return call_with_retry(fn, max_retries=max_retries, logger=self.logger)
def get_deal_ids_from_company(self, company_id: str) -> list[str]:
associations_api: AssociationsBasicApi = ( # type: ignore[reportUnknownMemberType]
diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py
index 3c95c2766..80f9cde77 100644
--- a/infrastructure/abri/abri_client.py
+++ b/infrastructure/abri/abri_client.py
@@ -1,6 +1,6 @@
import xml.etree.ElementTree as ET
from datetime import date
-from typing import List, Tuple, Union
+from typing import Dict, List, Tuple, Union
import requests
@@ -9,10 +9,15 @@ from domain.abri.models import (
AmendJobRequest,
AmendJobResult,
AppointmentAmended,
+ GetTenantDataResult,
JobLogged,
LogJobRequest,
LogJobResult,
+ PlaceRef,
+ TenancyData,
+ Tenant,
)
+from domain.abri.phone_selection import PhoneEntry, PhoneKind, select_phone_numbers
from infrastructure.abri.config import AbriConfig
from infrastructure.abri.envelope import serialise_relay_request
from infrastructure.abri.errors import AbriResponseParseError, AbriTransportError
@@ -26,6 +31,22 @@ def _format_appointment_date(appointment_date: date) -> str:
return appointment_date.strftime("%d/%m/%Y")
+# Wire tags -> the domain's phone kinds; selection policy lives in the domain.
+_PHONE_KINDS: Dict[str, PhoneKind] = {"Mobile": "mobile", "Telephone": "telephone"}
+
+
+def _phone_entries(tenant_element: ET.Element) -> List[PhoneEntry]:
+ return [
+ PhoneEntry(
+ kind=_PHONE_KINDS[child.tag],
+ number=child.text or "",
+ priority=child.get("priority"),
+ )
+ for child in tenant_element
+ if child.tag in _PHONE_KINDS
+ ]
+
+
class AbriClient:
def __init__(self, config: AbriConfig) -> None:
self._config = config
@@ -73,6 +94,17 @@ class AbriClient:
return self._parse_appointment_amended(outcome)
+ def get_tenant_data(self, place_ref: PlaceRef) -> GetTenantDataResult:
+ outcome = self._exchange(
+ request_type="getSCSTenantData",
+ parameters=[("place_ref", place_ref)],
+ )
+
+ if isinstance(outcome, AbriRequestRejected):
+ return outcome
+
+ return self._parse_tenancy_data(outcome)
+
def _exchange(
self, request_type: str, parameters: List[Tuple[str, str]]
) -> Union[ET.Element, AbriRequestRejected]:
@@ -159,6 +191,47 @@ class AbriClient:
appointment_time=appointment_time,
)
+ @staticmethod
+ def _parse_tenancy_data(root: ET.Element) -> TenancyData:
+ tenancies = root.findall("Tenancy")
+
+ if len(tenancies) != 1:
+ raise AbriResponseParseError(
+ f"expected exactly one Tenancy element, found {len(tenancies)}"
+ )
+
+ tenancy = tenancies[0]
+ tenancy_reference = (tenancy.text or "").strip()
+
+ if not tenancy_reference:
+ raise AbriResponseParseError("Tenancy element missing its reference")
+
+ return TenancyData(
+ tenancy_reference=tenancy_reference,
+ tenants=tuple(
+ AbriClient._parse_tenant(tenant)
+ for tenant in tenancy.findall("Tenant")
+ ),
+ )
+
+ @staticmethod
+ def _parse_tenant(element: ET.Element) -> Tenant:
+ # Data minimisation: person_title, main-contact and the per-person
+ # reference (the element text) are deliberately not read.
+ numbers = select_phone_numbers(_phone_entries(element))
+ return Tenant(
+ forenames=(element.get("forenames") or "").strip(),
+ surname=(element.get("surname") or "").strip(),
+ mobile=numbers.mobile,
+ telephone=numbers.telephone,
+ secondary_number=numbers.secondary,
+ vulnerabilities=tuple(
+ text
+ for vulnerability in element.findall("Vulnerability")
+ if (text := (vulnerability.text or "").strip())
+ ),
+ )
+
@staticmethod
def _parse_rejection(root: ET.Element) -> AbriRequestRejected:
success = root.findtext("success")
diff --git a/infrastructure/hubspot/__init__.py b/infrastructure/hubspot/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/infrastructure/hubspot/deal_contacts_client.py b/infrastructure/hubspot/deal_contacts_client.py
new file mode 100644
index 000000000..2c0eb0627
--- /dev/null
+++ b/infrastructure/hubspot/deal_contacts_client.py
@@ -0,0 +1,89 @@
+import json
+from typing import Any, Callable, Dict, Optional, cast
+
+from hubspot.client import Client # type: ignore[reportMissingTypeStubs]
+from hubspot.crm.associations.v4 import AssociationSpec # type: ignore[reportMissingTypeStubs]
+from hubspot.crm.contacts import SimplePublicObjectInputForCreate # type: ignore[reportMissingTypeStubs]
+
+from infrastructure.hubspot.errors import HubspotRequestError
+from infrastructure.hubspot.retry import call_with_retry
+
+# HubSpot-defined deal -> contact association ("deal_to_contact" in v3 terms).
+DEAL_TO_CONTACT_ASSOCIATION_TYPE_ID = 3
+
+
+def _scrubbed(error: Exception) -> HubspotRequestError:
+ """Reduce an SDK error to non-PII diagnostics, dropping the body.
+
+ Each HubSpot sub-module ships its own ApiException class with no shared
+ base beyond Exception, so the fields are read by duck-typing.
+ """
+ status = getattr(error, "status", None)
+ status_code = status if isinstance(status, int) else None
+
+ category: Optional[str] = None
+ correlation_id: Optional[str] = None
+ try:
+ parsed: Any = json.loads(getattr(error, "body", None) or "")
+ if isinstance(parsed, dict):
+ details = cast(Dict[str, Any], parsed)
+ raw_category = details.get("category")
+ raw_correlation_id = details.get("correlationId")
+ category = raw_category if isinstance(raw_category, str) else None
+ correlation_id = (
+ raw_correlation_id if isinstance(raw_correlation_id, str) else None
+ )
+ except ValueError:
+ pass
+
+ return HubspotRequestError(
+ status_code=status_code, category=category, correlation_id=correlation_id
+ )
+
+
+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:
+ contact = self._call(
+ lambda: self._client.crm.contacts.basic_api.create( # type: ignore[reportUnknownMemberType]
+ simple_public_object_input_for_create=SimplePublicObjectInputForCreate(
+ properties=properties
+ )
+ )
+ )
+ return str(contact.id) # type: ignore[reportUnknownMemberType]
+
+ def associate_contact_to_deal(self, deal_id: str, contact_id: str) -> None:
+ self._call(
+ lambda: self._client.crm.associations.v4.basic_api.create( # type: ignore[reportUnknownMemberType]
+ object_type="deals",
+ object_id=deal_id,
+ to_object_type="contacts",
+ to_object_id=contact_id,
+ association_spec=[
+ AssociationSpec(
+ association_category="HUBSPOT_DEFINED",
+ association_type_id=DEAL_TO_CONTACT_ASSOCIATION_TYPE_ID,
+ )
+ ],
+ )
+ )
+
+ @staticmethod
+ def _call(fn: Callable[[], Any]) -> Any:
+ """Run one SDK call under the shared retry policy, scrubbed on failure."""
+ try:
+ return call_with_retry(fn)
+ except Exception as error:
+ # `from None` keeps the body-carrying SDK error out of tracebacks.
+ raise _scrubbed(error) from None
diff --git a/infrastructure/hubspot/errors.py b/infrastructure/hubspot/errors.py
new file mode 100644
index 000000000..057e4d968
--- /dev/null
+++ b/infrastructure/hubspot/errors.py
@@ -0,0 +1,25 @@
+from typing import Optional
+
+
+class HubspotRequestError(Exception):
+ """A HubSpot API failure, scrubbed of the response body.
+
+ HubSpot validation errors echo submitted property values back in the
+ body, so carrying (or exception-chaining) the SDK error would leak the
+ submitted PII into tracebacks and logs. Only non-PII diagnostics are
+ kept: status code, HubSpot error category and correlation id.
+ """
+
+ def __init__(
+ self,
+ status_code: Optional[int],
+ category: Optional[str],
+ correlation_id: Optional[str],
+ ) -> None:
+ self.status_code = status_code
+ self.category = category
+ self.correlation_id = correlation_id
+ super().__init__(
+ f"HubSpot request failed (status={status_code}, "
+ f"category={category}, correlation_id={correlation_id})"
+ )
diff --git a/infrastructure/hubspot/retry.py b/infrastructure/hubspot/retry.py
new file mode 100644
index 000000000..7bd50c8a1
--- /dev/null
+++ b/infrastructure/hubspot/retry.py
@@ -0,0 +1,51 @@
+import time
+from http import HTTPStatus
+from logging import Logger
+from typing import Callable, Dict, Optional, TypeVar, cast
+
+from utils.logger import setup_logger
+
+T = TypeVar("T")
+
+RETRYABLE_STATUSES = {
+ HTTPStatus.TOO_MANY_REQUESTS,
+ HTTPStatus.INTERNAL_SERVER_ERROR,
+ HTTPStatus.BAD_GATEWAY,
+ HTTPStatus.SERVICE_UNAVAILABLE,
+ HTTPStatus.GATEWAY_TIMEOUT,
+}
+
+
+def call_with_retry(
+ fn: Callable[[], T],
+ max_retries: int = 2,
+ logger: Optional[Logger] = None,
+) -> T:
+ """
+ Call fn(), retrying up to max_retries times on 429 rate-limit errors
+ or transient 5xx server errors.
+ Waits the minimal amount: the remaining interval window reported by HubSpot headers.
+ Falls back to the full interval (10s) if headers are absent.
+
+ Note: each HubSpot sub-module (deals, companies, etc.) ships its own ApiException
+ class with no shared base beyond Exception, so we detect retryable statuses via duck-typing.
+ """
+ logger = logger or setup_logger()
+ for attempt in range(max_retries + 1):
+ try:
+ return fn()
+ except Exception as e:
+ status = getattr(e, "status", None)
+ if status not in RETRYABLE_STATUSES or attempt == max_retries:
+ raise
+ headers = cast(Dict[str, str], getattr(e, "headers", None) or {})
+ interval_ms = int(
+ headers.get("x-hubspot-ratelimit-interval-milliseconds", 10000)
+ )
+ wait_s = interval_ms / 1000.0
+ logger.warning(
+ f"HubSpot {status} (attempt {attempt + 1}/{max_retries}), "
+ f"waiting {wait_s:.1f}s before retry."
+ )
+ time.sleep(wait_s)
+ raise RuntimeError("Unreachable") # pragma: no cover
diff --git a/orchestration/abri_tenant_data_sync_orchestrator.py b/orchestration/abri_tenant_data_sync_orchestrator.py
new file mode 100644
index 000000000..14ab68e2f
--- /dev/null
+++ b/orchestration/abri_tenant_data_sync_orchestrator.py
@@ -0,0 +1,136 @@
+from dataclasses import dataclass
+from typing import Dict, List, Optional, Tuple, Union
+
+from domain.abri.models import AbriRequestRejected, PlaceRef, Tenant
+from infrastructure.hubspot.deal_contacts_client import HubspotDealContactsClient
+from infrastructure.hubspot.errors import HubspotRequestError
+from infrastructure.abri.abri_client import AbriClient
+
+
+def _vulnerability_description(tenant: Tenant) -> Optional[str]:
+ """The tenant's distinct vulnerabilities, verbatim and newline-joined.
+
+ Case-insensitively distinct within the tenant, keeping the first-seen
+ casing and document order.
+ """
+ distinct: List[str] = []
+ seen: set[str] = set()
+ for vulnerability in tenant.vulnerabilities:
+ if vulnerability.lower() in seen:
+ continue
+ seen.add(vulnerability.lower())
+ distinct.append(vulnerability)
+ return "\n".join(distinct) if distinct else None
+
+
+def _contact_properties(tenant: Tenant) -> Dict[str, str]:
+ candidates: Dict[str, Optional[str]] = {
+ "firstname": tenant.forenames,
+ "lastname": tenant.surname,
+ "mobilephone": tenant.mobile,
+ "phone": tenant.telephone,
+ "secondary_phone_number": tenant.secondary_number,
+ "vulnerability_description": _vulnerability_description(tenant),
+ }
+ properties = {name: value for name, value in candidates.items() if value}
+ # Always written, so "not vulnerable" is distinguishable from "not synced".
+ properties["is_vulnerable"] = "true" if tenant.vulnerabilities else "false"
+ return properties
+
+
+@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 TenantDataSyncError(Exception):
+ """A sync that stopped at the first failed HubSpot write.
+
+ Carries a PII-free progress report so the operator knows the deal's
+ half-state before cleaning up and re-running; contacts created but not
+ associated are orphaned in HubSpot and need manual cleanup.
+ """
+
+ def __init__(
+ self,
+ message: str,
+ deal_id: str,
+ tenant_index: int,
+ contact_ids_created: Tuple[str, ...],
+ contact_ids_associated: Tuple[str, ...],
+ ) -> None:
+ self.deal_id = deal_id
+ self.tenant_index = tenant_index
+ self.contact_ids_created = contact_ids_created
+ self.contact_ids_associated = contact_ids_associated
+ super().__init__(
+ f"{message} (deal: {deal_id}, tenant index: {tenant_index}, "
+ f"contacts created: {list(contact_ids_created)}, "
+ f"associated: {list(contact_ids_associated)})"
+ )
+
+ @property
+ def orphaned_contact_ids(self) -> Tuple[str, ...]:
+ return tuple(
+ contact_id
+ for contact_id in self.contact_ids_created
+ if contact_id not in self.contact_ids_associated
+ )
+
+
+class AbriTenantDataSyncOrchestrator:
+ """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:
+ tenancy = self._abri.get_tenant_data(place_ref)
+
+ if isinstance(tenancy, AbriRequestRejected):
+ return tenancy
+
+ contact_ids: List[str] = []
+ associated_ids: List[str] = []
+ for tenant_index, tenant in enumerate(tenancy.tenants):
+ try:
+ contact_id = self._hubspot.create_contact(_contact_properties(tenant))
+ contact_ids.append(contact_id)
+ self._hubspot.associate_contact_to_deal(
+ deal_id=deal_id, contact_id=contact_id
+ )
+ associated_ids.append(contact_id)
+ except HubspotRequestError as error:
+ # Fail fast at the first failed write; `error` is already
+ # scrubbed, so chaining it keeps tracebacks PII-free.
+ raise TenantDataSyncError(
+ message=str(error),
+ deal_id=deal_id,
+ tenant_index=tenant_index,
+ contact_ids_created=tuple(contact_ids),
+ contact_ids_associated=tuple(associated_ids),
+ ) from error
+
+ return TenantDataSyncSummary(
+ tenancy_reference=tenancy.tenancy_reference,
+ contact_ids=tuple(contact_ids),
+ vulnerable_contact_count=sum(
+ 1 for tenant in tenancy.tenants if tenant.vulnerabilities
+ ),
+ )
diff --git a/scripts/smoke_test_tenant_contacts.py b/scripts/smoke_test_tenant_contacts.py
new file mode 100644
index 000000000..c211c076e
--- /dev/null
+++ b/scripts/smoke_test_tenant_contacts.py
@@ -0,0 +1,142 @@
+"""Manual smoke test for the Abri tenant-data -> HubSpot sync (issue #1467).
+
+Drives the real TenantDataSyncOrchestrator end-to-end with only the Abri HTTP
+edge stubbed (no Abri API key needed): the canned tenancy XML below flows
+through the real parse, phone-priority selection and vulnerability mapping,
+and the resulting contact writes hit the real HubSpot portal.
+
+Usage:
+ 1. Ensure HUBSPOT_API_KEY is set in backend/.env.
+ 2. Create a throwaway test deal in the HubSpot UI and paste its id into
+ DEAL_ID below.
+ 3. Run: python scripts/smoke_test_tenant_contacts.py
+ 4. Check the deal in the UI: two contacts (Daniel Roth, Joe Bloggs), with
+ Daniel carrying mobilephone, phone, secondary_phone_number,
+ is_vulnerable=true and a two-line vulnerability_description, and Joe
+ carrying only a name and is_vulnerable=false.
+ 5. Clean up: paste the printed contact ids into CONTACT_IDS_TO_ARCHIVE,
+ set ARCHIVE = True and re-run, then delete the test deal in the UI.
+
+All tenant details below are fictional (Ofcom-reserved number ranges).
+"""
+
+import os
+from pathlib import Path
+from typing import Any, List, cast
+
+from dotenv import dotenv_values
+from hubspot.client import Client # type: ignore[reportMissingTypeStubs]
+
+from domain.abri.models import AbriRequestRejected, PlaceRef
+from infrastructure.abri.abri_client import AbriClient
+from infrastructure.abri.config import AbriConfig
+from infrastructure.hubspot.deal_contacts_client import HubspotDealContactsClient
+from orchestration.abri_tenant_data_sync_orchestrator import (
+ AbriTenantDataSyncOrchestrator,
+)
+
+DEAL_ID = "485125892321"
+
+# Cleanup mode: set ARCHIVE = True and list the contact ids printed by a
+# previous run to archive them instead of creating new ones.
+ARCHIVE = False
+CONTACT_IDS_TO_ARCHIVE: List[str] = []
+
+PLACE_REF = PlaceRef("SMOKE1")
+
+# Daniel Roth exercises best-of-kind phone selection, the runner-up pick into
+# secondary_phone_number, and the vulnerability fields; Joe Bloggs exercises
+# the always-written is_vulnerable=false with every other property omitted.
+SMOKE_TENANCY_XML = b"""
+ SMOKETEST0001900000107700900123
+ 07700900456
+ 01632960123
+ 01632960789
+ Blindness
+ Hard of hearing
+
+ 9000002
+
+"""
+
+
+class _CannedResponse:
+ content = SMOKE_TENANCY_XML
+
+ def raise_for_status(self) -> None:
+ pass
+
+
+class _StubAbriSession:
+ """Stands in for requests.Session; returns the canned tenancy."""
+
+ def post(self, url: str, data: bytes) -> _CannedResponse:
+ return _CannedResponse()
+
+
+def _hubspot_sdk_client() -> Client:
+ # Read the key directly rather than via backend.app.config.get_settings,
+ # whose strict Settings model rejects unrelated extra keys in backend/.env.
+ env_file = Path(__file__).parents[1] / "backend" / ".env"
+ access_token = os.getenv("HUBSPOT_API_KEY") or dotenv_values(env_file).get(
+ "HUBSPOT_API_KEY"
+ )
+ if not access_token:
+ raise SystemExit("Missing HUBSPOT_API_KEY (env var or backend/.env)")
+ return Client.create(access_token=access_token) # type: ignore[reportUnknownMemberType]
+
+
+def _stubbed_abri_client() -> AbriClient:
+ client = AbriClient(
+ config=AbriConfig(
+ endpoint_url="https://stubbed.invalid/relay",
+ username="smoke-test",
+ password="",
+ default_resource="",
+ )
+ )
+ client._session = cast(
+ Any, _StubAbriSession()
+ ) # pyright: ignore[reportPrivateUsage]
+ return client
+
+
+def _archive_contacts(sdk_client: Client) -> None:
+ if not CONTACT_IDS_TO_ARCHIVE:
+ raise SystemExit("ARCHIVE is set but CONTACT_IDS_TO_ARCHIVE is empty")
+ for contact_id in CONTACT_IDS_TO_ARCHIVE:
+ sdk_client.crm.contacts.basic_api.archive(contact_id=contact_id) # type: ignore[reportUnknownMemberType]
+ print(f"Archived contact {contact_id}")
+
+
+def main() -> None:
+ sdk_client = _hubspot_sdk_client()
+
+ if ARCHIVE:
+ _archive_contacts(sdk_client)
+ return
+
+ if DEAL_ID == "EDIT-ME":
+ raise SystemExit("Set DEAL_ID to a throwaway test deal id first")
+
+ orchestrator = AbriTenantDataSyncOrchestrator(
+ abri_client=_stubbed_abri_client(),
+ hubspot=HubspotDealContactsClient(sdk_client=sdk_client),
+ )
+
+ result = orchestrator.run(PLACE_REF, deal_id=DEAL_ID)
+
+ if isinstance(result, AbriRequestRejected):
+ raise SystemExit(f"Unexpected Abri rejection from stub: {result}")
+
+ print(f"Tenancy reference: {result.tenancy_reference}")
+ print(f"Contacts created: {list(result.contact_ids)}")
+ print(f"Vulnerable contact count: {result.vulnerable_contact_count}")
+ print(
+ "\nTo clean up: paste the ids above into CONTACT_IDS_TO_ARCHIVE, "
+ "set ARCHIVE = True and re-run."
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/abri/abri_relay_getscstenantdata_multitenant_response.xml b/tests/abri/abri_relay_getscstenantdata_multitenant_response.xml
new file mode 100644
index 000000000..7a3b26efa
--- /dev/null
+++ b/tests/abri/abri_relay_getscstenantdata_multitenant_response.xml
@@ -0,0 +1,8 @@
+
+ 10071650064015400721607700900456
+
+ 400721507700900789
+ 01632960456
+
+
+
diff --git a/tests/abri/abri_relay_getscstenantdata_relayerror_response.xml b/tests/abri/abri_relay_getscstenantdata_relayerror_response.xml
new file mode 100644
index 000000000..bc6da7b89
--- /dev/null
+++ b/tests/abri/abri_relay_getscstenantdata_relayerror_response.xml
@@ -0,0 +1,10 @@
+
+
+ false
+ RelayError
+ No Tenancy Found for Query FOR EACH re-tncy-place WHERE re-tncy-place.org-code = '01'
+and re-tncy-place.place-ref = '1004202A'
+and re-tncy-place.start-date LE TODAY
+and (re-tncy-place.end-date GE TODAY OR re-tncy-place.end-date = ?)and re-tncy-place.prime-place NO-LOCK,
+EACH re-tenancy OF re-tncy-place NO-LOCK
+
diff --git a/tests/abri/abri_relay_getscstenantdata_request_example.xml b/tests/abri/abri_relay_getscstenantdata_request_example.xml
new file mode 100644
index 000000000..785710678
--- /dev/null
+++ b/tests/abri/abri_relay_getscstenantdata_request_example.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
diff --git a/tests/abri/abri_relay_getscstenantdata_success_response.xml b/tests/abri/abri_relay_getscstenantdata_success_response.xml
new file mode 100644
index 000000000..9056a8c81
--- /dev/null
+++ b/tests/abri/abri_relay_getscstenantdata_success_response.xml
@@ -0,0 +1,7 @@
+
+ 10042020051017136847307700900123
+ 01632960123
+ Blindness
+
+
+
diff --git a/tests/domain/abri/test_phone_selection.py b/tests/domain/abri/test_phone_selection.py
new file mode 100644
index 000000000..70e2ec5a3
--- /dev/null
+++ b/tests/domain/abri/test_phone_selection.py
@@ -0,0 +1,140 @@
+from typing import Optional
+
+from domain.abri.phone_selection import (
+ PhoneEntry,
+ SelectedPhoneNumbers,
+ select_phone_numbers,
+)
+
+
+def _mobile(number: str, priority: Optional[str] = None) -> PhoneEntry:
+ return PhoneEntry(kind="mobile", number=number, priority=priority)
+
+
+def _telephone(number: str, priority: Optional[str] = None) -> PhoneEntry:
+ return PhoneEntry(kind="telephone", number=number, priority=priority)
+
+
+def test_no_entries_selects_nothing() -> None:
+ assert select_phone_numbers([]) == SelectedPhoneNumbers(
+ mobile=None, telephone=None, secondary=None
+ )
+
+
+def test_lowest_numeric_priority_wins_within_a_kind() -> None:
+ # Arrange
+ entries = [
+ _mobile("07700900020", priority="20"),
+ _mobile("07700900005", priority="5"),
+ _mobile("07700900010", priority="10"),
+ ]
+
+ # Act
+ selected = select_phone_numbers(entries)
+
+ # Assert
+ assert selected.mobile == "07700900005"
+
+
+def test_entries_without_a_usable_priority_sort_last() -> None:
+ # Arrange
+ entries = [
+ _mobile("07700900001"), # missing priority
+ _mobile("07700900002", priority="abc"), # non-numeric priority
+ _mobile("07700900003", priority="99"),
+ ]
+
+ # Act
+ selected = select_phone_numbers(entries)
+
+ # Assert
+ assert selected.mobile == "07700900003"
+
+
+def test_priority_ties_break_by_document_order() -> None:
+ # Arrange
+ entries = [
+ _telephone("01632960001", priority="7"),
+ _telephone("01632960002", priority="7"),
+ ]
+
+ # Act
+ selected = select_phone_numbers(entries)
+
+ # Assert
+ assert selected.telephone == "01632960001"
+
+
+def test_blank_numbers_are_ignored_even_at_best_priority() -> None:
+ # Arrange
+ entries = [
+ _mobile(" ", priority="1"),
+ _mobile("07700900002", priority="2"),
+ ]
+
+ # Act
+ selected = select_phone_numbers(entries)
+
+ # Assert
+ assert selected == SelectedPhoneNumbers(
+ mobile="07700900002", telephone=None, secondary=None
+ )
+
+
+def test_secondary_is_the_best_of_the_pooled_leftovers_of_both_kinds() -> None:
+ # Arrange
+ entries = [
+ _mobile("07700900005", priority="5"), # best mobile
+ _mobile("07700900020", priority="20"), # leftover
+ _telephone("01632960003", priority="3"), # best landline
+ _telephone("01632960007", priority="7"), # best leftover: wins secondary
+ ]
+
+ # Act
+ selected = select_phone_numbers(entries)
+
+ # Assert
+ assert selected == SelectedPhoneNumbers(
+ mobile="07700900005", telephone="01632960003", secondary="01632960007"
+ )
+
+
+def test_no_secondary_when_the_best_picks_use_every_number() -> None:
+ # Arrange
+ entries = [
+ _mobile("07700900005", priority="5"),
+ _telephone("01632960003", priority="3"),
+ ]
+
+ # Act
+ selected = select_phone_numbers(entries)
+
+ # Assert
+ assert selected.secondary is None
+
+
+def test_a_single_kind_still_yields_a_secondary_from_its_own_leftovers() -> None:
+ # Arrange
+ entries = [
+ _telephone("01632960001", priority="1"),
+ _telephone("01632960002", priority="2"),
+ ]
+
+ # Act
+ selected = select_phone_numbers(entries)
+
+ # Assert
+ assert selected == SelectedPhoneNumbers(
+ mobile=None, telephone="01632960001", secondary="01632960002"
+ )
+
+
+def test_selected_numbers_are_stripped_of_surrounding_whitespace() -> None:
+ # Arrange
+ entries = [_mobile(" 07700900123 ", priority="1")]
+
+ # Act
+ selected = select_phone_numbers(entries)
+
+ # Assert
+ assert selected.mobile == "07700900123"
diff --git a/tests/orchestration/test_abri_tenant_data_sync_orchestrator.py b/tests/orchestration/test_abri_tenant_data_sync_orchestrator.py
new file mode 100644
index 000000000..ad7a738b5
--- /dev/null
+++ b/tests/orchestration/test_abri_tenant_data_sync_orchestrator.py
@@ -0,0 +1,528 @@
+import json
+import traceback
+import xml.etree.ElementTree as ET
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+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 domain.abri.models import AbriRequestRejected, PlaceRef
+from infrastructure.hubspot.deal_contacts_client import HubspotDealContactsClient
+from infrastructure.abri.abri_client import AbriClient
+from infrastructure.abri.config import AbriConfig
+from infrastructure.abri.errors import AbriResponseParseError, AbriTransportError
+from orchestration.abri_tenant_data_sync_orchestrator import (
+ TenantDataSyncError,
+ AbriTenantDataSyncOrchestrator,
+ TenantDataSyncSummary,
+)
+
+FIXTURE_DIR = Path(__file__).parents[1] / "abri"
+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 _load_fixture(name: str) -> bytes:
+ return (FIXTURE_DIR / name).read_bytes()
+
+
+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
+) -> AbriTenantDataSyncOrchestrator:
+ with patch(
+ "infrastructure.abri.abri_client.requests.Session",
+ return_value=mock_session,
+ ):
+ abri_client = AbriClient(config=CONFIG)
+ return AbriTenantDataSyncOrchestrator(
+ abri_client=abri_client,
+ hubspot=HubspotDealContactsClient(sdk_client=sdk_client),
+ )
+
+
+# --- outbound request: the spec's recorded envelope ---
+
+
+def test_run_sends_the_recorded_getscstenantdata_envelope_to_the_relay_endpoint(
+ orchestrator: AbriTenantDataSyncOrchestrator, mock_session: MagicMock
+) -> None:
+ # Arrange
+ mock_session.post.return_value.content = _tenancy_response("")
+
+ # Act
+ orchestrator.run(PLACE_REF, deal_id=DEAL_ID)
+
+ # Assert
+ (url,) = mock_session.post.call_args.args
+ sent_body: bytes = mock_session.post.call_args.kwargs["data"]
+ expected_body = _load_fixture("abri_relay_getscstenantdata_request_example.xml")
+ assert url == ENDPOINT_URL
+ assert ET.canonicalize(xml_data=sent_body, strip_text=True) == ET.canonicalize(
+ xml_data=expected_body, strip_text=True
+ )
+
+
+# --- happy path: signatories become associated deal contacts ---
+
+
+def test_run_creates_an_associated_deal_contact_per_signatory_and_returns_a_summary(
+ orchestrator: AbriTenantDataSyncOrchestrator,
+ mock_session: MagicMock,
+ sdk_client: MagicMock,
+) -> None:
+ # Arrange
+ mock_session.post.return_value.content = _tenancy_response(
+ '136847307700900123'
+ '01632960123'
+ )
+
+ # 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": "Jane",
+ "lastname": "Doe",
+ "mobilephone": "07700900123",
+ "phone": "01632960123",
+ "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
+ )
+ ],
+ )
+
+
+def test_run_creates_a_separate_contact_for_each_signatory(
+ orchestrator: AbriTenantDataSyncOrchestrator,
+ mock_session: MagicMock,
+ sdk_client: MagicMock,
+) -> None:
+ # Arrange
+ mock_session.post.return_value.content = _load_fixture(
+ "abri_relay_getscstenantdata_multitenant_response.xml"
+ )
+ sdk_client.crm.contacts.basic_api.create.side_effect = [
+ MagicMock(id="60123"),
+ MagicMock(id="60124"),
+ ]
+
+ # Act
+ result = orchestrator.run(PLACE_REF, deal_id=DEAL_ID)
+
+ # Assert
+ assert result == TenantDataSyncSummary(
+ tenancy_reference="10071650064015",
+ contact_ids=("60123", "60124"),
+ vulnerable_contact_count=0,
+ )
+ created_properties = [
+ call.kwargs["simple_public_object_input_for_create"].properties
+ for call in sdk_client.crm.contacts.basic_api.create.call_args_list
+ ]
+ assert created_properties == [
+ {
+ "firstname": "Ann",
+ "lastname": "Bloggs",
+ "mobilephone": "07700900456",
+ "is_vulnerable": "false",
+ },
+ {
+ "firstname": "Beth",
+ "lastname": "Sample",
+ "mobilephone": "07700900789",
+ "phone": "01632960456",
+ "is_vulnerable": "false",
+ },
+ ]
+ associated_contact_ids = [
+ call.kwargs["to_object_id"]
+ for call in sdk_client.crm.associations.v4.basic_api.create.call_args_list
+ ]
+ assert associated_contact_ids == ["60123", "60124"]
+
+
+def test_run_picks_the_best_priority_number_of_each_kind_for_a_contact(
+ orchestrator: AbriTenantDataSyncOrchestrator,
+ mock_session: MagicMock,
+ sdk_client: MagicMock,
+) -> None:
+ # Arrange
+ mock_session.post.return_value.content = _tenancy_response(
+ '1368473'
+ "07000000099" # no priority: sorts last
+ ' ' # blank number: ignored
+ '07000000020'
+ '07000000005' # lowest priority: wins
+ '01000000001' # unusable: sorts last
+ '01000000007' # tie, first in document
+ '01000000008'
+ ""
+ )
+
+ # Act
+ orchestrator.run(PLACE_REF, deal_id=DEAL_ID)
+
+ # Assert
+ created_properties = sdk_client.crm.contacts.basic_api.create.call_args.kwargs[
+ "simple_public_object_input_for_create"
+ ].properties
+ assert created_properties["mobilephone"] == "07000000005"
+ assert created_properties["phone"] == "01000000007"
+
+
+def test_run_fills_secondary_phone_number_with_the_best_leftover_number(
+ orchestrator: AbriTenantDataSyncOrchestrator,
+ mock_session: MagicMock,
+ sdk_client: MagicMock,
+) -> None:
+ # Arrange
+ mock_session.post.return_value.content = _tenancy_response(
+ '1368473'
+ '07000000005' # best mobile
+ '07000000020'
+ '01000000007' # best leftover
+ '01000000003' # best landline
+ ""
+ )
+
+ # Act
+ orchestrator.run(PLACE_REF, deal_id=DEAL_ID)
+
+ # Assert
+ sdk_client.crm.contacts.basic_api.create.assert_called_once_with(
+ simple_public_object_input_for_create=SimplePublicObjectInputForCreate(
+ properties={
+ "firstname": "Jane",
+ "lastname": "Doe",
+ "mobilephone": "07000000005",
+ "phone": "01000000003",
+ "secondary_phone_number": "01000000007",
+ "is_vulnerable": "false",
+ }
+ )
+ )
+
+
+# --- vulnerabilities: recorded on the tenant's own contact ---
+
+
+def test_run_records_vulnerabilities_on_the_tenants_own_contact(
+ orchestrator: AbriTenantDataSyncOrchestrator,
+ mock_session: MagicMock,
+ sdk_client: MagicMock,
+) -> None:
+ # Arrange
+ mock_session.post.return_value.content = _tenancy_response(
+ '4007216'
+ "Blindness"
+ "Hard of hearing"
+ "BLINDNESS" # within-tenant duplicate
+ ""
+ '4007215'
+ )
+ sdk_client.crm.contacts.basic_api.create.side_effect = [
+ MagicMock(id="60123"),
+ MagicMock(id="60124"),
+ ]
+
+ # Act
+ result = orchestrator.run(PLACE_REF, deal_id=DEAL_ID)
+
+ # Assert
+ assert result == TenantDataSyncSummary(
+ tenancy_reference="10042020051017",
+ contact_ids=("60123", "60124"),
+ vulnerable_contact_count=1,
+ )
+ created_properties = [
+ call.kwargs["simple_public_object_input_for_create"].properties
+ for call in sdk_client.crm.contacts.basic_api.create.call_args_list
+ ]
+ assert created_properties == [
+ {
+ "firstname": "Ann",
+ "lastname": "Bloggs",
+ "is_vulnerable": "true",
+ "vulnerability_description": "Blindness\nHard of hearing",
+ },
+ {
+ "firstname": "Beth",
+ "lastname": "Sample",
+ "is_vulnerable": "false",
+ },
+ ]
+
+
+def test_run_still_creates_a_contact_for_an_all_blank_signatory(
+ orchestrator: AbriTenantDataSyncOrchestrator,
+ mock_session: MagicMock,
+ sdk_client: MagicMock,
+) -> None:
+ # Arrange
+ mock_session.post.return_value.content = _tenancy_response(
+ '4007216'
+ ''
+ )
+
+ # 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={"is_vulnerable": "false"}
+ )
+ )
+
+
+# --- unusual but valid outcomes ---
+
+
+def test_run_treats_a_tenancy_with_no_signatories_as_a_valid_zero_contact_outcome(
+ orchestrator: AbriTenantDataSyncOrchestrator,
+ mock_session: MagicMock,
+ sdk_client: MagicMock,
+) -> None:
+ # Arrange
+ mock_session.post.return_value.content = _tenancy_response("")
+
+ # Act
+ result = orchestrator.run(PLACE_REF, deal_id=DEAL_ID)
+
+ # Assert
+ assert result == TenantDataSyncSummary(
+ tenancy_reference="10042020051017",
+ contact_ids=(),
+ vulnerable_contact_count=0,
+ )
+ assert sdk_client.mock_calls == []
+
+
+# --- rejection passthrough ---
+
+
+def test_run_returns_the_abri_rejection_verbatim_and_makes_no_hubspot_calls(
+ orchestrator: AbriTenantDataSyncOrchestrator,
+ mock_session: MagicMock,
+ sdk_client: MagicMock,
+) -> None:
+ # Arrange
+ mock_session.post.return_value.content = _load_fixture(
+ "abri_relay_getscstenantdata_relayerror_response.xml"
+ )
+
+ # Act
+ result = orchestrator.run(PLACE_REF, deal_id=DEAL_ID)
+
+ # Assert
+ assert isinstance(result, AbriRequestRejected)
+ assert result.code == "RelayError"
+ assert result.message.startswith("No Tenancy Found for Query")
+ assert sdk_client.mock_calls == []
+
+
+# --- ambiguous responses are retriable, never success or rejection ---
+
+
+@pytest.mark.parametrize(
+ "body",
+ [
+ b"not xml at all <<<",
+ b"", # no Tenancy element
+ b"A1B2", # multiple
+ b'',
+ b"",
+ ],
+)
+def test_run_raises_a_retriable_parse_error_on_an_unexpectedly_shaped_response(
+ orchestrator: AbriTenantDataSyncOrchestrator,
+ mock_session: MagicMock,
+ sdk_client: MagicMock,
+ body: bytes,
+) -> None:
+ # Arrange
+ mock_session.post.return_value.content = body
+
+ # Act / Assert
+ with pytest.raises(AbriResponseParseError):
+ orchestrator.run(PLACE_REF, deal_id=DEAL_ID)
+ assert sdk_client.mock_calls == []
+
+
+def test_run_raises_a_transport_error_when_the_relay_is_unreachable(
+ orchestrator: AbriTenantDataSyncOrchestrator,
+ mock_session: MagicMock,
+ sdk_client: MagicMock,
+) -> None:
+ # Arrange
+ mock_session.post.side_effect = requests.ConnectionError("connection refused")
+
+ # Act / Assert
+ with pytest.raises(AbriTransportError):
+ orchestrator.run(PLACE_REF, deal_id=DEAL_ID)
+ assert sdk_client.mock_calls == []
+
+
+# --- HubSpot failures: scrubbed of response bodies, which can echo PII ---
+
+
+def _pii_echoing_api_error() -> ContactsApiException:
+ """A HubSpot validation error whose body echoes the submitted PII."""
+ api_error = ContactsApiException(status=400, reason="Bad Request")
+ api_error.body = json.dumps(
+ {
+ "status": "error",
+ "message": 'Invalid value "07700900123" for mobilephone (Jane Doe)',
+ "correlationId": "corr-abc-123",
+ "category": "VALIDATION_ERROR",
+ }
+ )
+ api_error.headers = {}
+ return api_error
+
+
+def test_run_raises_an_error_scrubbed_of_the_hubspot_response_body(
+ orchestrator: AbriTenantDataSyncOrchestrator,
+ mock_session: MagicMock,
+ sdk_client: MagicMock,
+) -> None:
+ # Arrange
+ mock_session.post.return_value.content = _tenancy_response(
+ '136847307700900123'
+ )
+ sdk_client.crm.contacts.basic_api.create.side_effect = _pii_echoing_api_error()
+
+ # Act
+ with pytest.raises(Exception) as exc_info:
+ orchestrator.run(PLACE_REF, deal_id=DEAL_ID)
+
+ # Assert: non-PII diagnostics survive; the echoed body does not
+ rendered_traceback = "".join(traceback.format_exception(exc_info.value))
+ assert "400" in str(exc_info.value)
+ assert "VALIDATION_ERROR" in str(exc_info.value)
+ assert "corr-abc-123" in str(exc_info.value)
+ assert "Jane" not in rendered_traceback
+ assert "Doe" not in rendered_traceback
+ assert "07700900123" not in rendered_traceback
+
+
+def test_run_fails_fast_when_association_fails_and_reports_the_orphaned_contact(
+ orchestrator: AbriTenantDataSyncOrchestrator,
+ mock_session: MagicMock,
+ sdk_client: MagicMock,
+) -> None:
+ # Arrange
+ mock_session.post.return_value.content = _load_fixture(
+ "abri_relay_getscstenantdata_multitenant_response.xml"
+ )
+ sdk_client.crm.associations.v4.basic_api.create.side_effect = (
+ _pii_echoing_api_error()
+ )
+
+ # Act
+ with pytest.raises(TenantDataSyncError) as exc_info:
+ orchestrator.run(PLACE_REF, deal_id=DEAL_ID)
+
+ # Assert: the flow stopped at the first failed write, with progress reported
+ error = exc_info.value
+ assert error.deal_id == DEAL_ID
+ assert error.tenant_index == 0
+ assert error.contact_ids_created == ("60123",)
+ assert error.contact_ids_associated == ()
+ assert error.orphaned_contact_ids == ("60123",)
+ assert sdk_client.crm.contacts.basic_api.create.call_count == 1 # fail fast
+ rendered_traceback = "".join(traceback.format_exception(error))
+ assert "corr-abc-123" in str(error) # scrubbed diagnostics carried through
+ assert "Bloggs" not in rendered_traceback
+ assert "07700900456" not in rendered_traceback
+
+
+def test_run_retries_a_rate_limited_hubspot_call_before_succeeding(
+ orchestrator: AbriTenantDataSyncOrchestrator,
+ mock_session: MagicMock,
+ sdk_client: MagicMock,
+) -> None:
+ # Arrange
+ mock_session.post.return_value.content = _tenancy_response(
+ '1368473'
+ )
+ rate_limited = ContactsApiException(status=429, reason="Too Many Requests")
+ rate_limited.headers = {"x-hubspot-ratelimit-interval-milliseconds": "0"}
+ sdk_client.crm.contacts.basic_api.create.side_effect = [
+ rate_limited,
+ MagicMock(id="60123"),
+ ]
+
+ # Act
+ result = orchestrator.run(PLACE_REF, deal_id=DEAL_ID)
+
+ # Assert
+ assert result == TenantDataSyncSummary(
+ tenancy_reference="10042020051017",
+ contact_ids=("60123",),
+ vulnerable_contact_count=0,
+ )
+ assert sdk_client.crm.contacts.basic_api.create.call_count == 2