mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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:
parent
481c01098d
commit
55baddfd9e
1 changed files with 268 additions and 0 deletions
268
scripts/smoke_test_abri_logjob_flow.py
Normal file
268
scripts/smoke_test_abri_logjob_flow.py
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue