"""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 abri_message_if_triggered, 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 property in HubSpot first, then to the same-named 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'' ).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 ( check_for_abri_triggers_and_construct_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.client_booking_reference = 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.client_booking_reference = 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.client_booking_reference}" ) message = check_for_abri_triggers_and_construct_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()