mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Merge pull request #1471 from Hestia-Homes/feature/abri-api-integration
Abri OpenHousing DomnaRelay — GetTenantData vertical (tenancy signatories to HubSpot)
This commit is contained in:
commit
6335796d6e
16 changed files with 1319 additions and 38 deletions
|
|
@ -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]
|
||||
|
|
|
|||
64
domain/abri/phone_selection.py
Normal file
64
domain/abri/phone_selection.py
Normal file
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
0
infrastructure/hubspot/__init__.py
Normal file
0
infrastructure/hubspot/__init__.py
Normal file
89
infrastructure/hubspot/deal_contacts_client.py
Normal file
89
infrastructure/hubspot/deal_contacts_client.py
Normal file
|
|
@ -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
|
||||
25
infrastructure/hubspot/errors.py
Normal file
25
infrastructure/hubspot/errors.py
Normal file
|
|
@ -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})"
|
||||
)
|
||||
51
infrastructure/hubspot/retry.py
Normal file
51
infrastructure/hubspot/retry.py
Normal file
|
|
@ -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
|
||||
136
orchestration/abri_tenant_data_sync_orchestrator.py
Normal file
136
orchestration/abri_tenant_data_sync_orchestrator.py
Normal file
|
|
@ -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
|
||||
),
|
||||
)
|
||||
142
scripts/smoke_test_tenant_contacts.py
Normal file
142
scripts/smoke_test_tenant_contacts.py
Normal file
|
|
@ -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"""<Root>
|
||||
<Tenancy end_date="" start_date="15/12/2008">SMOKETEST0001<Tenant forenames="Daniel" main-contact="yes" person_title="Mr" surname="Roth">9000001<Mobile priority="10">07700900123</Mobile>
|
||||
<Mobile priority="20">07700900456</Mobile>
|
||||
<Telephone priority="5">01632960123</Telephone>
|
||||
<Telephone priority="30">01632960789</Telephone>
|
||||
<Vulnerability>Blindness</Vulnerability>
|
||||
<Vulnerability>Hard of hearing</Vulnerability>
|
||||
</Tenant>
|
||||
<Tenant forenames="Joe" main-contact="no" person_title="Mr" surname="Bloggs">9000002</Tenant>
|
||||
</Tenancy>
|
||||
</Root>"""
|
||||
|
||||
|
||||
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()
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<Root>
|
||||
<Tenancy end_date="" start_date="05/12/2015">10071650064015<Tenant forenames="Ann" main-contact="no" person_title="Mrs" surname="Bloggs">4007216<Mobile priority="1">07700900456</Mobile>
|
||||
</Tenant>
|
||||
<Tenant forenames="Beth" main-contact="yes" person_title="Miss" surname="Sample">4007215<Mobile priority="10">07700900789</Mobile>
|
||||
<Telephone priority="20">01632960456</Telephone>
|
||||
</Tenant>
|
||||
</Tenancy>
|
||||
</Root>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<response>
|
||||
<success>false</success>
|
||||
<code>RelayError</code>
|
||||
<message>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</message>
|
||||
</response>
|
||||
11
tests/abri/abri_relay_getscstenantdata_request_example.xml
Normal file
11
tests/abri/abri_relay_getscstenantdata_request_example.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" ?>
|
||||
<message>
|
||||
<Header>
|
||||
<Security username="DomnaWeb" password="" />
|
||||
</Header>
|
||||
<Body>
|
||||
<Request request_type="getSCSTenantData">
|
||||
<Parameters attribute="place_ref" attribute_value="1004202A" />
|
||||
</Request>
|
||||
</Body>
|
||||
</message>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<Root>
|
||||
<Tenancy end_date="" start_date="15/12/2008">10042020051017<Tenant forenames="Jane" main-contact="yes" person_title="Mrs" surname="Doe">1368473<Mobile priority="10">07700900123</Mobile>
|
||||
<Telephone priority="101">01632960123</Telephone>
|
||||
<Vulnerability>Blindness</Vulnerability>
|
||||
</Tenant>
|
||||
</Tenancy>
|
||||
</Root>
|
||||
140
tests/domain/abri/test_phone_selection.py
Normal file
140
tests/domain/abri/test_phone_selection.py
Normal file
|
|
@ -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"
|
||||
528
tests/orchestration/test_abri_tenant_data_sync_orchestrator.py
Normal file
528
tests/orchestration/test_abri_tenant_data_sync_orchestrator.py
Normal file
|
|
@ -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'<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
|
||||
) -> 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(
|
||||
'<Tenant forenames="Jane" main-contact="yes" person_title="Mrs"'
|
||||
' surname="Doe">1368473<Mobile priority="10">07700900123</Mobile>'
|
||||
'<Telephone priority="101">01632960123</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": "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(
|
||||
'<Tenant forenames="Jane" main-contact="yes" person_title="Mrs"'
|
||||
' surname="Doe">1368473'
|
||||
"<Mobile>07000000099</Mobile>" # no priority: sorts last
|
||||
'<Mobile priority="1"> </Mobile>' # blank number: ignored
|
||||
'<Mobile priority="20">07000000020</Mobile>'
|
||||
'<Mobile priority="5">07000000005</Mobile>' # lowest priority: wins
|
||||
'<Telephone priority="abc">01000000001</Telephone>' # unusable: sorts last
|
||||
'<Telephone priority="7">01000000007</Telephone>' # tie, first in document
|
||||
'<Telephone priority="7">01000000008</Telephone>'
|
||||
"</Tenant>"
|
||||
)
|
||||
|
||||
# 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(
|
||||
'<Tenant forenames="Jane" main-contact="yes" person_title="Mrs"'
|
||||
' surname="Doe">1368473'
|
||||
'<Mobile priority="5">07000000005</Mobile>' # best mobile
|
||||
'<Mobile priority="20">07000000020</Mobile>'
|
||||
'<Telephone priority="7">01000000007</Telephone>' # best leftover
|
||||
'<Telephone priority="3">01000000003</Telephone>' # best landline
|
||||
"</Tenant>"
|
||||
)
|
||||
|
||||
# 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(
|
||||
'<Tenant forenames="Ann" main-contact="no" person_title="Mrs"'
|
||||
' surname="Bloggs">4007216'
|
||||
"<Vulnerability>Blindness</Vulnerability>"
|
||||
"<Vulnerability>Hard of hearing</Vulnerability>"
|
||||
"<Vulnerability>BLINDNESS</Vulnerability>" # within-tenant duplicate
|
||||
"</Tenant>"
|
||||
'<Tenant forenames="Beth" main-contact="yes" person_title="Miss"'
|
||||
' surname="Sample">4007215</Tenant>'
|
||||
)
|
||||
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(
|
||||
'<Tenant forenames="" main-contact="no" person_title="" surname="">4007216'
|
||||
'<Mobile priority="1"></Mobile></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={"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"<Root></Root>", # no Tenancy element
|
||||
b"<Root><Tenancy>A1</Tenancy><Tenancy>B2</Tenancy></Root>", # multiple
|
||||
b'<Root><Tenancy end_date="" start_date="01/01/2020"></Tenancy></Root>',
|
||||
b"<unexpected />",
|
||||
],
|
||||
)
|
||||
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(
|
||||
'<Tenant forenames="Jane" main-contact="yes" person_title="Mrs"'
|
||||
' surname="Doe">1368473<Mobile priority="10">07700900123</Mobile></Tenant>'
|
||||
)
|
||||
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(
|
||||
'<Tenant forenames="Jane" main-contact="yes" person_title="Mrs"'
|
||||
' surname="Doe">1368473</Tenant>'
|
||||
)
|
||||
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
|
||||
Loading…
Add table
Reference in a new issue