A local smoke drives HubSpot-to-write-back LogJob with only the Abri edge stubbed 🟩

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-07 11:47:03 +00:00
parent 481c01098d
commit 55baddfd9e

View file

@ -0,0 +1,268 @@
"""Manual smoke test for the scraper->dispatch LogJob wiring (issue #1480).
The closest run to end-to-end available before Abri send API credentials:
every seam is real except the Abri HTTP edge, which is stubbed to return a
canned LogJob success carrying a fake job_no. Concretely, the script
1. fetches the deal, listing and project from the real HubSpot portal
(exactly what the scraper lambda sees),
2. reads the deal's row from the real (dev) deal database,
3. runs the real differ predicates via build_abri_trigger_message,
4. validates the real message contract (AbriTriggerRequest),
5. runs the real dispatch through the real AbriOrchestrator, so the fake
job_no is written to the deal's client_booking_reference in HubSpot
first, then to the job_no column in the database,
6. reads both back and prints them.
NOTE: the LogJob trigger fires when the CONFIRMED SURVEY DATE is first set
on an Abri deal. (Setting the *expected commencement date* fires the
tenant-data flow instead that flow is skipped here so the stub never
mints junk contacts; smoke_test_tenant_contacts.py covers it.)
Usage:
1. Ensure HUBSPOT_API_KEY and the postgres_* connection values are in
backend/.env (they are read tolerantly; the strict Settings .env
parsing is bypassed).
2. Pick a test deal on the "Abri Condition" project that the scraper has
already ingested (its row must exist in hubspot_deal_data). Paste its
id into DEAL_ID below.
3. In the HubSpot UI, set the deal's confirmed survey date (and
optionally a time). If the dev scraper lambda syncs the change into
the database before you run this, the differ will see no change set
RESET_DB_ROW_FIRST = True to null the row's survey date and job_no so
the trigger fires deterministically.
4. Run: python scripts/smoke_test_abri_logjob_flow.py
5. Check the printed read-backs, and the deal in the HubSpot UI: the
fake job_no (SMOKE...) should sit in client_booking_reference.
6. Run it again unchanged: the dispatch should print
"skipped: job SMOKE... already logged" the idempotency guard.
7. Clean up: set CLEANUP = True and re-run to clear the fake job_no from
both HubSpot and the database, then unset the deal's survey date.
"""
import os
import xml.etree.ElementTree as ET
from datetime import datetime
from pathlib import Path
from typing import Any, List, Optional, cast
from dotenv import dotenv_values
DEAL_ID = "EDIT-ME"
# Null the DB row's confirmed_survey_date + job_no before evaluating the
# differ, so the trigger fires even if the dev scraper already synced it.
RESET_DB_ROW_FIRST = False
# Cleanup mode: clear the fake job_no from HubSpot and the database.
CLEANUP = False
FAKE_JOB_NO = "SMOKE" + datetime.now().strftime("%d%H%M")
JOB_NO_DEAL_PROPERTY = "client_booking_reference"
def _bootstrap_env() -> None:
"""Export backend/.env tolerantly and bypass strict Settings parsing.
backend/.env carries keys the strict Settings model rejects; exporting
them ourselves and pointing ENVIRONMENT away from "local" lets the
legacy HubspotClient (and PostgresConfig.from_env) run unchanged.
"""
env_file = Path(__file__).parents[1] / "backend" / ".env"
for key, value in dotenv_values(env_file).items():
if value is not None:
os.environ.setdefault(key.upper(), value)
os.environ["ENVIRONMENT"] = "smoke-test"
class _CannedLogJobResponse:
content = (
f'<Root><Jobs><Job_logged job_no="{FAKE_JOB_NO}" '
f'logged_info="Job logged. Appointment booked." /></Jobs></Root>'
).encode()
def raise_for_status(self) -> None:
pass
class _StubAbriSession:
"""Stands in for requests.Session; records envelopes, returns the canned
LogJob success. Anything but a logjob request is a wiring bug."""
def __init__(self) -> None:
self.sent_envelopes: List[bytes] = []
def post(self, url: str, data: bytes) -> _CannedLogJobResponse:
request_type = (
ET.fromstring(data).find("Body/Request").get("request_type") # type: ignore[reportOptionalMemberAccess]
)
if request_type != "logjob":
raise AssertionError(
f"stub received a {request_type!r} request; only logjob is smoke-tested"
)
self.sent_envelopes.append(data)
return _CannedLogJobResponse()
def _print_sent_parameters(envelope: bytes) -> None:
parameters = {
parameter.get("attribute"): parameter.get("attribute_value")
for parameter in ET.fromstring(envelope).findall("Body/Request/Parameters")
}
print(" Envelope sent to (stubbed) Abri relay:")
for name, value in parameters.items():
print(f" {name} = {value}")
def main() -> None:
if DEAL_ID == "EDIT-ME":
raise SystemExit("Edit DEAL_ID before running")
_bootstrap_env()
# Imports that read settings/env must come after the bootstrap.
from hubspot.client import Client # type: ignore[reportMissingTypeStubs]
from sqlalchemy.pool import NullPool
from sqlmodel import Session, select
from applications.abri.abri_trigger_request import AbriTriggerRequest
from applications.abri.dispatch import dispatch_abri_flows
from backend.app.db.models.hubspot_deal_data import HubspotDealData
from etl.hubspot.abri_flow_triggers import build_abri_trigger_message
from etl.hubspot.hubspotClient import HubspotClient
from infrastructure.abri.abri_client import AbriClient
from infrastructure.abri.config import AbriConfig
from infrastructure.hubspot.deal_contacts_client import HubspotDealContactsClient
from infrastructure.hubspot.deal_properties_client import (
HubspotDealPropertiesClient,
)
from infrastructure.postgres.config import PostgresConfig
from infrastructure.postgres.engine import make_engine
from orchestration.abri_orchestrator import AbriOrchestrator
from repositories.hubspot_deals.deal_database_postgres_gateway import (
DealDatabasePostgresGateway,
)
engine = make_engine(PostgresConfig.from_env(dict(os.environ)), poolclass=NullPool)
sdk_client: Client = Client.create( # type: ignore[reportUnknownMemberType]
access_token=os.environ["HUBSPOT_API_KEY"]
)
deal_properties = HubspotDealPropertiesClient(sdk_client=sdk_client)
with Session(engine) as session:
deal_row = session.exec(
select(HubspotDealData).where(HubspotDealData.deal_id == DEAL_ID)
).first()
if deal_row is None:
raise SystemExit(
f"Deal {DEAL_ID} has no hubspot_deal_data row — let the scraper "
"ingest it first (the gateway write-back needs the row)."
)
if CLEANUP:
deal_properties.write_deal_property(
deal_id=DEAL_ID, property_name=JOB_NO_DEAL_PROPERTY, value=""
)
deal_row.job_no = None
session.add(deal_row)
session.commit()
print(
f"Cleaned up: cleared {JOB_NO_DEAL_PROPERTY} in HubSpot and "
"job_no in the database. Unset the survey date in the UI too."
)
return
if RESET_DB_ROW_FIRST:
deal_row.confirmed_survey_date = None
deal_row.job_no = None
session.add(deal_row)
session.commit()
session.refresh(deal_row)
print("Reset DB row: confirmed_survey_date and job_no nulled.")
print(f"Fetching deal {DEAL_ID} from HubSpot...")
hubspot_deal, _company, listing, project = HubspotClient(
).get_deal_and_company_and_listing_and_project(DEAL_ID)
print(
f" dealname={hubspot_deal.get('dealname')!r} "
f"project_code={hubspot_deal.get('project_code')!r} "
f"confirmed_survey_date={hubspot_deal.get('confirmed_survey_date')!r} "
f"confirmed_survey_time={hubspot_deal.get('confirmed_survey_time')!r}"
)
print(
f" DB row: confirmed_survey_date={deal_row.confirmed_survey_date} "
f"job_no={deal_row.job_no}"
)
message = build_abri_trigger_message(
hubspot_deal_id=DEAL_ID,
new_deal=hubspot_deal,
new_project=project,
new_listing=listing,
old_deal=deal_row,
)
if message is None:
raise SystemExit(
"No Abri trigger fired. For LogJob, set the CONFIRMED SURVEY "
"DATE on the deal (expected commencement date fires the "
"tenant-data flow, not job logging). If the dev scraper "
"already synced your change, set RESET_DB_ROW_FIRST = True."
)
print(f"Trigger message built: flows={message['flows']}")
request = AbriTriggerRequest.model_validate(message)
if "log_job" not in request.flows:
raise SystemExit(
f"Fired flows {request.flows} do not include log_job — set the "
"confirmed survey date (not the expected commencement date)."
)
if request.flows != ["log_job"]:
skipped = [flow for flow in request.flows if flow != "log_job"]
print(f" Restricting to log_job for this smoke; skipping {skipped}.")
request = request.model_copy(update={"flows": ["log_job"]})
stub_session = _StubAbriSession()
abri_client = AbriClient(
config=AbriConfig(
endpoint_url="https://stubbed.invalid/relay",
username="smoke-test",
password="",
default_resource="SMOKE",
)
)
abri_client._session = cast( # pyright: ignore[reportPrivateUsage]
Any, stub_session
)
gateway = DealDatabasePostgresGateway(session=session)
orchestrator = AbriOrchestrator(
abri_client=abri_client,
deal_contacts=HubspotDealContactsClient(sdk_client=sdk_client),
deal_properties=deal_properties,
deal_database=gateway,
)
print("Dispatching...")
summary = dispatch_abri_flows(request, orchestrator, gateway)
for envelope in stub_session.sent_envelopes:
_print_sent_parameters(envelope)
print(f"Dispatch summary: {summary}")
# Read both write-backs back through the real interfaces.
deal = sdk_client.crm.deals.basic_api.get_by_id( # type: ignore[reportUnknownMemberType]
DEAL_ID, properties=[JOB_NO_DEAL_PROPERTY]
)
hubspot_value = cast(
Optional[str],
cast(Any, deal).properties.get(JOB_NO_DEAL_PROPERTY),
)
print(f"HubSpot {JOB_NO_DEAL_PROPERTY} is now: {hubspot_value!r}")
print(f"Database job_no is now: {gateway.job_no_for_deal(DEAL_ID)!r}")
print(
"Re-run to see the idempotency guard skip the log; "
"set CLEANUP = True to undo."
)
if __name__ == "__main__":
main()