diff --git a/scripts/smoke_test_abri_logjob_flow.py b/scripts/smoke_test_abri_logjob_flow.py new file mode 100644 index 000000000..0409ef3d1 --- /dev/null +++ b/scripts/smoke_test_abri_logjob_flow.py @@ -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'' + ).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()