Merge pull request #1648 from Hestia-Homes/feature/abri-api-resource-field

Sync third_party_surveyor_identifier deal field from HubSpot
This commit is contained in:
Daniel Roth 2026-07-20 11:02:11 +01:00 committed by GitHub
commit dc96cf5aba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 431 additions and 102 deletions

View file

@ -107,8 +107,6 @@ on:
required: false
TF_VAR_abri_relay_password:
required: false
TF_VAR_abri_relay_default_resource:
required: false
TF_VAR_test_abri_project_code_override:
required: false
jobs:
@ -189,7 +187,6 @@ jobs:
TF_VAR_abri_relay_url: ${{ secrets.TF_VAR_abri_relay_url }}
TF_VAR_abri_relay_username: ${{ secrets.TF_VAR_abri_relay_username }}
TF_VAR_abri_relay_password: ${{ secrets.TF_VAR_abri_relay_password }}
TF_VAR_abri_relay_default_resource: ${{ secrets.TF_VAR_abri_relay_default_resource }}
TF_VAR_test_abri_project_code_override: ${{ secrets.TF_VAR_test_abri_project_code_override }}
run: |
ECR_REPO_URL_VAR=""
@ -247,7 +244,6 @@ jobs:
TF_VAR_abri_relay_url: ${{ secrets.TF_VAR_abri_relay_url }}
TF_VAR_abri_relay_username: ${{ secrets.TF_VAR_abri_relay_username }}
TF_VAR_abri_relay_password: ${{ secrets.TF_VAR_abri_relay_password }}
TF_VAR_abri_relay_default_resource: ${{ secrets.TF_VAR_abri_relay_default_resource }}
run: |
EXTRA_VARS=""
if [[ -n "${{ inputs.ecr_repo }}" ]]; then

View file

@ -740,7 +740,6 @@ jobs:
TF_VAR_abri_relay_url: ${{ secrets.ABRI_RELAY_URL }}
TF_VAR_abri_relay_username: ${{ secrets.ABRI_RELAY_USERNAME }}
TF_VAR_abri_relay_password: ${{ secrets.ABRI_RELAY_PASSWORD }}
TF_VAR_abri_relay_default_resource: ${{ secrets.ABRI_RELAY_DEFAULT_RESOURCE }}
# ============================================================
# Build Audit Generator image

View file

@ -9,7 +9,7 @@ visibility surface.
from datetime import date
from typing import Dict, List, Literal, Optional, Tuple
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
AbriFlow = Literal["amend_job", "log_job", "sync_tenant_data", "abandon_job"]
@ -19,8 +19,13 @@ AbriFlow = Literal["amend_job", "log_job", "sync_tenant_data", "abandon_job"]
# alone would reject valid messages; the orchestrator fails the flow if a deal
# carries neither. Its outcome is optional too, only feeding the reason seam.
_FIELDS_BY_FLOW: Dict[AbriFlow, Tuple[str, ...]] = {
"amend_job": ("confirmed_survey_date",),
"log_job": ("place_ref", "deal_name", "confirmed_survey_date"),
"amend_job": ("confirmed_survey_date", "third_party_surveyor_identifier"),
"log_job": (
"place_ref",
"deal_name",
"confirmed_survey_date",
"third_party_surveyor_identifier",
),
"sync_tenant_data": ("place_ref",),
"abandon_job": (),
}
@ -37,6 +42,18 @@ class AbriTriggerRequest(BaseModel):
confirmed_survey_time: Optional[str] = None
last_submission_date: Optional[date] = None
outcome: Optional[str] = None
# OpenHousing's bookable resource for the survey; required by the log and
# amend flows. Empty string is also rejected by Abri, so not allowed here
third_party_surveyor_identifier: Optional[str] = None
@field_validator("third_party_surveyor_identifier")
@classmethod
def _blank_surveyor_identifier_is_missing(
cls, value: Optional[str]
) -> Optional[str]:
if value is not None and not value.strip():
return None
return value
@model_validator(mode="after")
def _each_fired_flow_has_its_fields(self) -> "AbriTriggerRequest":

View file

@ -81,13 +81,19 @@ def dispatch_abri_flows(
def _run_amend(request: AbriTriggerRequest, flows: AbriFlows) -> str:
if request.confirmed_survey_date is None:
raise ValueError("amend_job fired without a confirmed_survey_date")
if (
request.confirmed_survey_date is None
or request.third_party_surveyor_identifier is None
):
raise ValueError(
"amend_job fired without a confirmed_survey_date/surveyor identifier"
)
outcome = flows.amend_job(
AppointmentChange(
deal_id=request.hubspot_deal_id,
confirmed_survey_date=request.confirmed_survey_date,
confirmed_survey_time=request.confirmed_survey_time,
third_party_surveyor_identifier=request.third_party_surveyor_identifier,
)
)
if isinstance(outcome, AbriRequestRejected):
@ -111,8 +117,12 @@ def _run_log(
request.place_ref is None
or request.deal_name is None
or request.confirmed_survey_date is None
or request.third_party_surveyor_identifier is None
):
raise ValueError("log_job fired without place_ref/deal_name/survey date")
raise ValueError(
"log_job fired without place_ref/deal_name/survey date/"
"surveyor identifier"
)
try:
outcome = flows.log_job(
ConfirmedSurveyBooking(
@ -121,6 +131,9 @@ def _run_log(
deal_name=request.deal_name,
confirmed_survey_date=request.confirmed_survey_date,
confirmed_survey_time=request.confirmed_survey_time,
third_party_surveyor_identifier=(
request.third_party_surveyor_identifier
),
)
)
except LogJobWriteBackError as error:

View file

@ -67,6 +67,7 @@ class HubspotDealData(SQLModel, table=True):
lodgement_date: Optional[datetime] = Field(default=None)
expected_commencement_date: Optional[datetime] = Field(default=None)
surveyor: Optional[str] = Field(default=None)
third_party_surveyor_identifier: Optional[str] = Field(default=None)
confirmed_survey_date: Optional[datetime] = Field(default=None)
confirmed_survey_time: Optional[str] = Field(default=None)
surveyed_date: Optional[datetime] = Field(default=None)

View file

@ -24,10 +24,9 @@ module "lambda" {
# Abri relay credentials are per-stage: the dev/staging stage points at
# Abri's UAT relay endpoint; production values are pending from Abri.
ABRI_RELAY_URL = var.abri_relay_url
ABRI_RELAY_USERNAME = var.abri_relay_username
ABRI_RELAY_PASSWORD = var.abri_relay_password
ABRI_RELAY_DEFAULT_RESOURCE = var.abri_relay_default_resource
ABRI_RELAY_URL = var.abri_relay_url
ABRI_RELAY_USERNAME = var.abri_relay_username
ABRI_RELAY_PASSWORD = var.abri_relay_password
HUBSPOT_API_KEY = var.hubspot_api_key

View file

@ -58,11 +58,6 @@ variable "abri_relay_password" {
sensitive = true
}
variable "abri_relay_default_resource" {
type = string
sensitive = true
}
variable "hubspot_api_key" {
type = string
sensitive = true

View file

@ -0,0 +1,91 @@
# Abri bookings: what your HubSpot changes do
**Who this is for:** operations staff who work Abri deals in HubSpot — booking surveys,
recording visit outcomes, updating deal fields.
**The one-line version:** for deals on the Abri Stock Condition project, certain HubSpot
field changes are picked up automatically and sent to Abri's scheduling system
(OpenHousing). You never talk to OpenHousing directly — the deal fields are the controls.
---
## Which deals does this apply to?
Only deals associated with the **Abri Stock Condition — Privately Funded** project.
Changes to any other deal are ignored by this integration.
## How fast does it happen?
Changes are picked up automatically shortly after you save them in HubSpot — usually
within a few minutes. Nothing fires while you are mid-edit; it's the saved change that
counts.
---
## What your changes do in Abri's system
| When you… | Abri's system… | How you know it worked |
|---|---|---|
| Set **Expected commencement date** for the first time | Sends us the tenancy details for the property | Tenant contact records appear in HubSpot, linked to the deal |
| Set **Confirmed survey date** for the first time | Creates the survey booking (a "job") against the property, assigned to the surveyor on the deal | Abri's job number appears in **Client booking reference** on the deal |
| Change **Confirmed survey date**, **Confirmed survey time** or **Third-party surveyor identifier** on a deal that already has a booking | Updates the existing appointment — new date/time and/or reassigns it to the new surveyor | The deal keeps the same Client booking reference |
| Record a **3rd attempt** (Number of attempts reaches 3) **and** set **Outcome** to an unsuccessful value (see below) | Cancels the booking as abandoned | — (this only fires once per deal) |
**Unsuccessful outcomes** that count towards abandonment — the wording must match exactly:
- `no answer`
- `cancelled / no show`
- `tenant refusal`
- `not viable`
Any other outcome (or fewer than 3 attempts) does **not** cancel anything in Abri's
system.
## The fields, and why they matter
| HubSpot deal field | What it feeds |
|---|---|
| **Expected commencement date** | Setting it the first time triggers the tenant-details fetch |
| **Confirmed survey date** | The appointment date sent to Abri |
| **Confirmed survey time** | The appointment slot: `morning` → AM, `afternoon` → PM, a clock time like `09:30` → AM/PM by whether it's before midday, left blank → all day |
| **Third-party surveyor identifier** | Which surveyor the booking is assigned to in Abri's system. **Required** — a booking or appointment change cannot be sent without it, and it must be one of Abri's valid surveyor codes. Changing it on a deal that already has a booking reassigns that booking to the new surveyor |
| **Number of attempts** + **Outcome** | Together they trigger abandonment (3+ attempts and an unsuccessful outcome) |
| **Client booking reference** | Abri's job number, written back **by the system** after the booking is created |
| **Deal name** | Used as the job description Abri's staff see |
---
## Things that will catch you out
### Clearing the survey date does NOT cancel the booking
If you delete or blank the confirmed survey date, the booking **still exists in Abri's
system** and the surveyor is still expected. There is currently no way to cancel a
booking from HubSpot other than the 3-attempts abandonment route. If a booking needs
cancelling for any other reason, contact the tech team.
### Don't edit Client booking reference
It is written automatically with Abri's job number and is how the system finds the
booking when you later change or abandon it. If you overwrite or clear it, appointment
changes and abandonments will stop working for that deal.
### The surveyor identifier (Third-Party Surveyor Identifier) must be a valid Abri code
Free-typed or misspelt values will be rejected by Abri's system and the booking won't be
made. The tech team holds the current list of valid surveyor codes from Abri — use a
value from that list. Soon this field will be set automatically from the deal
owner.
### Outcome wording is exact
Abandonment only recognises the four unsuccessful outcomes listed above, spelled exactly
that way. A variation like "No Answer - left card" won't trigger it.
### If the job number never appears
The booking didn't go through — most often because the surveyor identifier was missing
or invalid, or the deal isn't on the Abri project. Failures land with the tech team, not
in HubSpot, so you won't see an error message. If **Client booking reference** is still
empty well after you set the survey date, flag it to the tech team rather than re-editing
fields.

View file

@ -44,6 +44,7 @@ class LogJobRequest:
appointment_time: SlotCode
short_description: str
long_description: str
resource: str
@dataclass(frozen=True)
@ -67,7 +68,7 @@ class AmendJobRequest:
job_no: str
appointment_date: date
appointment_time: SlotCode
resource: Optional[str] = None
resource: str
@dataclass(frozen=True)

View file

@ -291,6 +291,7 @@ class HubspotClient:
"lodgement_date",
"expected_commencement_date",
"surveyor",
"third_party_surveyor_identifier",
"confirmed_survey_date",
"confirmed_survey_time",
"client_booking_reference",

View file

@ -270,6 +270,9 @@ class HubspotDataToDb:
deal_data.get("expected_commencement_date")
),
"surveyor": deal_data.get("surveyor"),
"third_party_surveyor_identifier": deal_data.get(
"third_party_surveyor_identifier"
),
"confirmed_survey_date": parse_hs_date(
deal_data.get("confirmed_survey_date")
),
@ -391,6 +394,9 @@ class HubspotDataToDb:
deal_data.get("expected_commencement_date")
),
surveyor=deal_data.get("surveyor"),
third_party_surveyor_identifier=deal_data.get(
"third_party_surveyor_identifier"
),
confirmed_survey_date=parse_hs_date(deal_data.get("confirmed_survey_date")),
confirmed_survey_time=deal_data.get("confirmed_survey_time"),
client_booking_reference=deal_data.get("client_booking_reference"),

View file

@ -100,6 +100,7 @@ class HubspotDealDiffer:
"lodgement_status": "lodgement_status",
"design_type": "design_type",
"surveyor": "surveyor",
"third_party_surveyor_identifier": "third_party_surveyor_identifier",
"confirmed_survey_time": "confirmed_survey_time",
"client_booking_reference": "client_booking_reference",
"survey_type": "survey_type",
@ -243,9 +244,7 @@ class HubspotDealDiffer:
flows.append("abandon_job")
if not flows:
logger.info(
"No Abri flows triggered for HubSpot deal %s", hubspot_deal_id
)
logger.info("No Abri flows triggered for HubSpot deal %s", hubspot_deal_id)
return None
logger.info(
@ -266,6 +265,9 @@ class HubspotDealDiffer:
else None
),
"confirmed_survey_time": new_deal.get("confirmed_survey_time"),
"third_party_surveyor_identifier": new_deal.get(
"third_party_surveyor_identifier"
),
# Carried for the abandon flow: it dates the cancel to the confirmed
# survey date, falling back to the last submission date.
"last_submission_date": (
@ -318,14 +320,24 @@ class HubspotDealDiffer:
new_survey_date = parse_hs_date(new_deal.get("confirmed_survey_date"))
new_survey_time = new_deal.get("confirmed_survey_time")
new_surveyor = HubspotDealDiffer._normalised_surveyor(
new_deal.get("third_party_surveyor_identifier")
)
old_surveyor = HubspotDealDiffer._normalised_surveyor(
old_deal.third_party_surveyor_identifier
)
logger.info(
"Abri job-amendment check: "
"old_confirmed_survey_date=%s new_confirmed_survey_date=%s "
"old_confirmed_survey_time=%s new_confirmed_survey_time=%s",
"old_confirmed_survey_time=%s new_confirmed_survey_time=%s "
"old_third_party_surveyor_identifier=%s "
"new_third_party_surveyor_identifier=%s",
old_deal.confirmed_survey_date,
new_survey_date,
old_deal.confirmed_survey_time,
new_survey_time,
old_surveyor,
new_surveyor,
)
# A first-time survey date logs a new job, not an amendment.
@ -339,7 +351,10 @@ class HubspotDealDiffer:
if old_deal.confirmed_survey_date != new_survey_date:
return True
return old_deal.confirmed_survey_time != new_survey_time
if old_deal.confirmed_survey_time != new_survey_time:
return True
return old_surveyor != new_surveyor
@staticmethod
def check_for_abri_tenant_data_fetch(
@ -405,6 +420,14 @@ class HubspotDealDiffer:
old_outcome = (old_deal.outcome or "").lower()
return new_outcome == "surveyed" and old_outcome != "surveyed"
@staticmethod
def _normalised_surveyor(value: Optional[str]) -> Optional[str]:
# HubSpot sends "" for an unset property and the DB stores whatever
# the scrape wrote, so blank and missing must compare as equal.
if value is None or not value.strip():
return None
return value
@staticmethod
def _is_abandoned(
number_of_attempts: Optional[str], outcome: Optional[str]

View file

@ -66,9 +66,33 @@ def test_a_first_confirmed_survey_date_builds_a_log_job_message() -> None:
"confirmed_survey_time": "14:30",
"last_submission_date": None,
"outcome": None,
"third_party_surveyor_identifier": None,
}
def test_the_message_carries_the_deals_third_party_surveyor_identifier() -> None:
# Arrange
old_deal = make_old_deal(project_code=ABRI_PROJECT_CODE, confirmed_survey_date=None)
new_deal = make_new_deal(
confirmed_survey_date="2026-06-24",
confirmed_survey_time="14:30",
third_party_surveyor_identifier="THIRDPARTY",
)
# Act
message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
new_listing=LISTING,
old_deal=old_deal,
)
# Assert
assert message is not None
assert message["third_party_surveyor_identifier"] == "THIRDPARTY"
def test_a_changed_confirmed_survey_date_builds_an_amend_job_message() -> None:
# Arrange
old_deal = make_old_deal(
@ -94,6 +118,115 @@ def test_a_changed_confirmed_survey_date_builds_an_amend_job_message() -> None:
assert message["confirmed_survey_date"] == "2026-06-24"
def test_a_changed_surveyor_on_a_booked_deal_builds_an_amend_job_message() -> None:
# Arrange: booking exists; only the surveyor changes.
old_deal = make_old_deal(
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date=datetime(2026, 6, 24, tzinfo=timezone.utc),
confirmed_survey_time="14:30",
third_party_surveyor_identifier="OLDSURVEYOR",
)
new_deal = make_new_deal(
confirmed_survey_date="2026-06-24",
confirmed_survey_time="14:30",
third_party_surveyor_identifier="NEWSURVEYOR",
)
# Act
message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
new_listing=LISTING,
old_deal=old_deal,
)
# Assert
assert message is not None
assert message["flows"] == ["amend_job"]
assert message["third_party_surveyor_identifier"] == "NEWSURVEYOR"
def test_a_blank_surveyor_on_a_booked_deal_with_no_stored_surveyor_is_not_a_change() -> None:
# Arrange: booking exists; surveyor unset in the DB, blank in HubSpot.
old_deal = make_old_deal(
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date=datetime(2026, 6, 24, tzinfo=timezone.utc),
confirmed_survey_time="14:30",
third_party_surveyor_identifier=None,
)
new_deal = make_new_deal(
confirmed_survey_date="2026-06-24",
confirmed_survey_time="14:30",
third_party_surveyor_identifier="",
)
# Act
message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
new_listing=LISTING,
old_deal=old_deal,
)
# Assert
assert message is None
def test_a_blank_stored_surveyor_and_a_missing_incoming_one_are_not_a_change() -> None:
# Arrange: booking exists; DB stored "" for the surveyor, HubSpot omits it.
old_deal = make_old_deal(
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date=datetime(2026, 6, 24, tzinfo=timezone.utc),
confirmed_survey_time="14:30",
third_party_surveyor_identifier="",
)
new_deal = make_new_deal(
confirmed_survey_date="2026-06-24",
confirmed_survey_time="14:30",
)
# Act
message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
new_listing=LISTING,
old_deal=old_deal,
)
# Assert
assert message is None
def test_an_unchanged_surveyor_on_a_booked_deal_fires_nothing() -> None:
# Arrange: booking exists; surveyor and appointment are unchanged.
old_deal = make_old_deal(
project_code=ABRI_PROJECT_CODE,
confirmed_survey_date=datetime(2026, 6, 24, tzinfo=timezone.utc),
confirmed_survey_time="14:30",
third_party_surveyor_identifier="SURVEYOR",
)
new_deal = make_new_deal(
confirmed_survey_date="2026-06-24",
confirmed_survey_time="14:30",
third_party_surveyor_identifier="SURVEYOR",
)
# Act
message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
new_listing=LISTING,
old_deal=old_deal,
)
# Assert
assert message is None
def test_a_first_expected_commencement_date_builds_a_tenant_sync_message() -> None:
# Arrange
old_deal = make_old_deal(

View file

@ -67,7 +67,7 @@ class AbriClient:
("client_ref", request.client_ref),
("appointment_date", _format_appointment_date(request.appointment_date)),
("appointment_time", request.appointment_time),
("resource", self._config.default_resource),
("resource", request.resource),
("resource_group", RESOURCE_GROUP),
],
)
@ -78,20 +78,12 @@ class AbriClient:
return self._parse_job_logged(outcome)
def amend_job(self, request: AmendJobRequest) -> AmendJobResult:
# OpenHousing silently drops the appointment unless a bookable resource
# is sent, so fall back to the configured surveyor when the request
# carries no explicit override (mirrors log_job).
resource = (
request.resource
if request.resource is not None
else self._config.default_resource
)
parameters = [
("job_no", request.job_no),
("action", "amend"),
("appointment_date", _format_appointment_date(request.appointment_date)),
("appointment_time", request.appointment_time),
("resource", resource),
("resource", request.resource),
]
outcome = self._exchange(

View file

@ -7,20 +7,11 @@ class AbriConfig:
endpoint_url: str
username: str
password: str
default_resource: str
@classmethod
def from_env(cls, env: Mapping[str, str]) -> "AbriConfig":
# A blank default_resource does not error at the relay — OpenHousing
# logs the job but silently drops the appointment — so reject it here
# rather than let the misconfiguration ship an empty resource.
default_resource = env["ABRI_RELAY_DEFAULT_RESOURCE"]
if not default_resource.strip():
raise ValueError("ABRI_RELAY_DEFAULT_RESOURCE must not be blank")
return cls(
endpoint_url=env["ABRI_RELAY_URL"],
username=env["ABRI_RELAY_USERNAME"],
password=env["ABRI_RELAY_PASSWORD"],
default_resource=default_resource,
)

View file

@ -112,6 +112,9 @@ class ConfirmedSurveyBooking:
deal_name: str
confirmed_survey_date: date
confirmed_survey_time: Optional[str]
# OpenHousing calls it the resource; the HubSpot deal property (and the
# database column) call it the third-party surveyor identifier.
third_party_surveyor_identifier: str
@dataclass(frozen=True)
@ -138,6 +141,9 @@ class AppointmentChange:
deal_id: str
confirmed_survey_date: date
confirmed_survey_time: Optional[str]
# OpenHousing calls it the resource; the HubSpot deal property (and the
# database column) call it the third-party surveyor identifier.
third_party_surveyor_identifier: str
AmendJobOrchestrationResult = Union[AppointmentAmended, AbriRequestRejected]
@ -283,6 +289,7 @@ class AbriOrchestrator:
appointment_time=slot_for_confirmed_time(
change.confirmed_survey_time
),
resource=change.third_party_surveyor_identifier,
)
)
@ -319,6 +326,7 @@ class AbriOrchestrator:
),
short_description=descriptions.short_description,
long_description=descriptions.long_description,
resource=booking.third_party_surveyor_identifier,
)
)

View file

@ -13,7 +13,7 @@ production database.
Usage:
1. Ensure these are set in the environment or backend/.env:
HUBSPOT_API_KEY, ABRI_RELAY_URL (the UAT endpoint),
ABRI_RELAY_USERNAME, ABRI_RELAY_PASSWORD, ABRI_RELAY_DEFAULT_RESOURCE.
ABRI_RELAY_USERNAME, ABRI_RELAY_PASSWORD.
2. Create a throwaway test deal in the HubSpot UI and paste its id into
DEAL_ID below; paste Abri's agreed UAT test place reference into
PLACE_REF.
@ -46,6 +46,9 @@ from orchestration.abri_orchestrator import (
DEAL_ID = "EDIT-ME"
PLACE_REF = PlaceRef("EDIT-ME")
# The bookable OpenHousing resource to book the UAT job against (mandatory:
# in production it comes from the deal's third_party_surveyor_identifier).
SURVEYOR = "EDIT-ME"
# Log for the first weekday at least a week out (a slot Abri UAT will take),
# then amend to the following day's opposite slot.
@ -73,7 +76,6 @@ def _uat_abri_client() -> AbriClient:
endpoint_url=_env("ABRI_RELAY_URL"),
username=_env("ABRI_RELAY_USERNAME"),
password=_env("ABRI_RELAY_PASSWORD"),
default_resource=_env("ABRI_RELAY_DEFAULT_RESOURCE"),
)
)
@ -94,8 +96,8 @@ class InMemoryDealDatabase:
def main() -> None:
if DEAL_ID == "EDIT-ME" or PLACE_REF == PlaceRef("EDIT-ME"):
raise SystemExit("Edit DEAL_ID and PLACE_REF before running")
if "EDIT-ME" in (DEAL_ID, str(PLACE_REF), SURVEYOR):
raise SystemExit("Edit DEAL_ID, PLACE_REF and SURVEYOR before running")
sdk_client = _hubspot_sdk_client()
deal_database = InMemoryDealDatabase()
@ -114,6 +116,7 @@ def main() -> None:
deal_name=f"Domna UAT smoke test {date.today().isoformat()}",
confirmed_survey_date=LOG_DATE,
confirmed_survey_time=LOG_TIME,
third_party_surveyor_identifier=SURVEYOR,
)
)
if isinstance(log_result, AbriRequestRejected):
@ -128,6 +131,7 @@ def main() -> None:
deal_id=DEAL_ID,
confirmed_survey_date=AMEND_DATE,
confirmed_survey_time=AMEND_TIME,
third_party_surveyor_identifier=SURVEYOR,
)
)
if isinstance(amend_result, AbriRequestRejected):

View file

@ -229,7 +229,6 @@ def main() -> None:
endpoint_url="https://stubbed.invalid/relay",
username="smoke-test",
password="",
default_resource="SMOKE",
)
)
abri_client._session = cast( # pyright: ignore[reportPrivateUsage]

View file

@ -91,7 +91,6 @@ def _stubbed_abri_client() -> AbriClient:
endpoint_url="https://stubbed.invalid/relay",
username="smoke-test",
password="",
default_resource="",
)
)
client._session = cast(

View file

@ -15,6 +15,7 @@ def _full_message() -> Dict[str, Any]:
"deal_name": "49 Admers Crescent",
"confirmed_survey_date": "2026-06-24",
"confirmed_survey_time": "14:30",
"third_party_surveyor_identifier": "THIRDPARTY",
}
@ -29,6 +30,7 @@ def test_a_full_message_parses_into_a_typed_request() -> None:
assert request.deal_name == "49 Admers Crescent"
assert request.confirmed_survey_date == date(2026, 6, 24)
assert request.confirmed_survey_time == "14:30"
assert request.third_party_surveyor_identifier == "THIRDPARTY"
@pytest.mark.parametrize(
@ -37,7 +39,9 @@ def test_a_full_message_parses_into_a_typed_request() -> None:
("log_job", "place_ref"),
("log_job", "deal_name"),
("log_job", "confirmed_survey_date"),
("log_job", "third_party_surveyor_identifier"),
("amend_job", "confirmed_survey_date"),
("amend_job", "third_party_surveyor_identifier"),
("sync_tenant_data", "place_ref"),
],
)
@ -97,6 +101,20 @@ def test_a_message_naming_abandon_job_validates_with_only_the_deal_id() -> None:
assert request.flows == ["abandon_job"]
@pytest.mark.parametrize("blank", ["", " "])
def test_a_blank_surveyor_identifier_is_treated_as_missing(blank: str) -> None:
# A blank resource does not error at the relay: OpenHousing logs the job
# but silently drops the appointment. Treat blank as missing so the
# per-flow validation fails loudly instead.
message = _full_message()
message["flows"] = ["log_job"]
message["third_party_surveyor_identifier"] = blank
# Act / Assert
with pytest.raises(ValidationError, match="third_party_surveyor_identifier"):
AbriTriggerRequest.model_validate(message)
def test_a_message_carries_the_outcome_for_the_abandonment_reason_mapping() -> None:
# Arrange
message = _full_message()

View file

@ -40,6 +40,7 @@ def _request(flows: List[str]) -> AbriTriggerRequest:
"confirmed_survey_time": "14:30",
"last_submission_date": "2026-07-01",
"outcome": "no answer",
"third_party_surveyor_identifier": "THIRDPARTY",
}
)
@ -171,6 +172,7 @@ def test_flows_receive_the_deal_fields_carried_by_the_message(
deal_id=DEAL_ID,
confirmed_survey_date=date(2026, 6, 24),
confirmed_survey_time="14:30",
third_party_surveyor_identifier="THIRDPARTY",
),
)
assert log_call == (
@ -181,6 +183,7 @@ def test_flows_receive_the_deal_fields_carried_by_the_message(
deal_name="49 Admers Crescent",
confirmed_survey_date=date(2026, 6, 24),
confirmed_survey_time="14:30",
third_party_surveyor_identifier="THIRDPARTY",
),
)

View file

@ -28,7 +28,6 @@ CONFIG = AbriConfig(
endpoint_url=ENDPOINT_URL,
username="DomnaWeb",
password="",
default_resource="NAULKH",
)
@ -52,6 +51,7 @@ def _spec_example_request() -> LogJobRequest:
appointment_time="PM",
short_description="Christian's SCS External Test.",
long_description="Christian's SCS External Test.",
resource="NAULKH",
)
@ -60,6 +60,7 @@ def _spec_example_amend_request() -> AmendJobRequest:
job_no="AC0439951",
appointment_date=date(2026, 6, 19),
appointment_time="PM",
resource="NAULKH",
)
@ -145,6 +146,38 @@ def test_log_job_sends_the_recorded_logjob_envelope_to_the_relay_endpoint(
)
def test_log_job_books_the_job_against_the_requests_resource(
client: AbriClient, mock_session: MagicMock
) -> None:
# Arrange: the resource is mandatory on the request — the deal's
# third-party surveyor identifier — and is sent verbatim.
mock_session.post.return_value.content = _load_fixture(
"abri_relay_logjob_success_response.xml"
)
# Act
client.log_job(
LogJobRequest(
client_ref="DOM51111",
place_ref=PlaceRef("1007165"),
appointment_date=date(2026, 6, 18),
appointment_time="PM",
short_description="Christian's SCS External Test.",
long_description="Christian's SCS External Test.",
resource="THIRDPARTY",
)
)
# Assert
sent_body: bytes = mock_session.post.call_args.kwargs["data"]
resources = [
parameter.get("attribute_value")
for parameter in ET.fromstring(sent_body).findall(".//Parameters")
if parameter.get("attribute") == "resource"
]
assert resources == ["THIRDPARTY"]
def test_log_job_declares_the_xml_body_content_type_the_relay_requires(
client: AbriClient, mock_session: MagicMock
) -> None:
@ -410,29 +443,6 @@ def test_amend_job_sends_the_recorded_amendoptiappt_envelope_to_the_relay_endpoi
)
def test_amend_job_defaults_the_resource_to_the_configured_surveyor(
client: AbriClient, mock_session: MagicMock
) -> None:
# Arrange: OpenHousing silently drops the appointment unless a bookable
# resource is sent, so an amend without an explicit surveyor falls back to
# the configured default rather than omitting the parameter.
mock_session.post.return_value.content = _load_fixture(
"abri_relay_amendoptiappt_success_response.xml"
)
# Act
client.amend_job(_spec_example_amend_request())
# Assert
sent_body: bytes = mock_session.post.call_args.kwargs["data"]
resources = [
parameter.get("attribute_value")
for parameter in ET.fromstring(sent_body).findall(".//Parameters")
if parameter.get("attribute") == "resource"
]
assert resources == [CONFIG.default_resource]
# --- amend_job: rejection ---

View file

@ -1,15 +1,12 @@
import pytest
from infrastructure.abri.config import AbriConfig
def test_config_hydrates_relay_credentials_and_defaults_from_environment() -> None:
def test_config_hydrates_relay_credentials_from_environment() -> None:
# Arrange
env = {
"ABRI_RELAY_URL": "https://relay.example.test/api/DomnaRelay?code=key",
"ABRI_RELAY_USERNAME": "DomnaWeb",
"ABRI_RELAY_PASSWORD": "secret",
"ABRI_RELAY_DEFAULT_RESOURCE": "CBRYAN",
}
# Act
@ -20,20 +17,4 @@ def test_config_hydrates_relay_credentials_and_defaults_from_environment() -> No
endpoint_url="https://relay.example.test/api/DomnaRelay?code=key",
username="DomnaWeb",
password="secret",
default_resource="CBRYAN",
)
@pytest.mark.parametrize("blank", ["", " "])
def test_config_rejects_a_blank_default_resource(blank: str) -> None:
# A blank resource does not error at the relay: OpenHousing logs the job
# but silently drops the appointment. Fail loudly at config load instead.
env = {
"ABRI_RELAY_URL": "https://relay.example.test/api/DomnaRelay?code=key",
"ABRI_RELAY_USERNAME": "DomnaWeb",
"ABRI_RELAY_PASSWORD": "secret",
"ABRI_RELAY_DEFAULT_RESOURCE": blank,
}
with pytest.raises(ValueError, match="ABRI_RELAY_DEFAULT_RESOURCE"):
AbriConfig.from_env(env)

View file

@ -29,7 +29,6 @@ CONFIG = AbriConfig(
endpoint_url=ENDPOINT_URL,
username="DomnaWeb",
password="",
default_resource="NAULKH",
)
ABANDONMENT = DealAbandonment(

View file

@ -1,4 +1,5 @@
import xml.etree.ElementTree as ET
from dataclasses import replace
from datetime import date
from pathlib import Path
from typing import Dict, List, Optional, Tuple
@ -26,13 +27,13 @@ CONFIG = AbriConfig(
endpoint_url=ENDPOINT_URL,
username="DomnaWeb",
password="",
default_resource="NAULKH",
)
CHANGE = AppointmentChange(
deal_id=DEAL_ID,
confirmed_survey_date=date(2026, 6, 24),
confirmed_survey_time="14:30",
third_party_surveyor_identifier="NAULKH",
)
@ -114,12 +115,38 @@ def test_amend_job_sends_an_amendoptiappt_envelope_for_the_deals_recorded_job(
"action": "amend",
"appointment_date": "24/06/2026",
"appointment_time": "PM",
# OpenHousing drops the appointment without a bookable resource, so the
# amend defaults to the configured surveyor when none is overridden.
"resource": CONFIG.default_resource,
# OpenHousing drops the appointment without a bookable resource, so
# the deal's third-party surveyor identifier is always sent.
"resource": "NAULKH",
}
def test_amend_job_books_the_deals_third_party_surveyor_as_the_resource(
orchestrator: AbriOrchestrator,
mock_session: MagicMock,
deal_database: FakeDealDatabase,
) -> None:
# Arrange: the deal's third-party surveyor identifier is booked as the
# amended appointment's resource.
deal_database.job_nos[DEAL_ID] = JOB_NO
mock_session.post.return_value.content = _load_fixture(
"abri_relay_amendoptiappt_success_response.xml"
)
# Act
orchestrator.amend_job(
replace(CHANGE, third_party_surveyor_identifier="THIRDPARTY")
)
# Assert
sent_body: bytes = mock_session.post.call_args.kwargs["data"]
sent_parameters = {
parameter.get("attribute"): parameter.get("attribute_value")
for parameter in ET.fromstring(sent_body).findall("Body/Request/Parameters")
}
assert sent_parameters["resource"] == "THIRDPARTY"
# --- happy path: the amended appointment is confirmed back ---

View file

@ -1,6 +1,7 @@
import json
import traceback
import xml.etree.ElementTree as ET
from dataclasses import replace
from datetime import date
from pathlib import Path
from typing import List, Optional, Tuple
@ -32,7 +33,6 @@ CONFIG = AbriConfig(
endpoint_url=ENDPOINT_URL,
username="DomnaWeb",
password="",
default_resource="NAULKH",
)
BOOKING = ConfirmedSurveyBooking(
@ -41,6 +41,7 @@ BOOKING = ConfirmedSurveyBooking(
deal_name="49 Admers Crescent",
confirmed_survey_date=date(2026, 6, 18),
confirmed_survey_time="14:30",
third_party_surveyor_identifier="NAULKH",
)
@ -141,6 +142,29 @@ def test_log_job_sends_a_logjob_envelope_built_from_the_deals_fields(
}
def test_log_job_books_the_deals_third_party_surveyor_as_the_resource(
orchestrator: AbriOrchestrator, mock_session: MagicMock
) -> None:
# Arrange: the deal's third-party surveyor identifier is booked as the
# job's resource.
mock_session.post.return_value.content = _load_fixture(
"abri_relay_logjob_success_response.xml"
)
# Act
orchestrator.log_job(
replace(BOOKING, third_party_surveyor_identifier="THIRDPARTY")
)
# Assert
sent_body: bytes = mock_session.post.call_args.kwargs["data"]
sent_parameters = {
parameter.get("attribute"): parameter.get("attribute_value")
for parameter in ET.fromstring(sent_body).findall("Body/Request/Parameters")
}
assert sent_parameters["resource"] == "THIRDPARTY"
# --- happy path: the returned job_no lands in HubSpot, then the database ---

View file

@ -32,7 +32,6 @@ CONFIG = AbriConfig(
endpoint_url=ENDPOINT_URL,
username="DomnaWeb",
password="",
default_resource="NAULKH",
)