Skip to main content

Testing the Pipedrive deal-update flow (functional, end-to-end)

Audience: developers & AI agents · Scope: how to functionally test the outbound quote → Pipedrive "deal update" flow with Playwright, intercept the outgoing payload, and assert its integrity — without mutating any production deal · Last reviewed: 2026-08-07

TL;DR: Every outbound write funnels through the BcPipedriveApi write wrappers, which apply an opt-in capture mode (PIPEDRIVE_TEST=capture) that records the outgoing payload and sends nothing, plus an always-on DEV-org guard. A functional test drives a real website action with Playwright, runs the deal-update runner in capture mode, then asserts on both the quote row in the DB and the captured deal payload.

What changed on 2026-08-06: there IS a Pipedrive sandbox now, and an environment can be pointed at it — see Testing against the sandbox. That does not retire capture mode (it is still the fastest, most deterministic way to assert a payload) but it does mean a test that must really send no longer has to send to production.


Why this design

The vendored SDK's base URL is a hard-coded production constant (a private static map in Configuration.php with no setter), so it can never be pointed at a mock server — or, as it turned out, at an app installation's own api_domain. Instead we intercept one layer above it, in our own code, where it's safe to edit and where one choke point already exists: the SDK operation wrappers in BcPipedriveApi (addDeal, updateDeal, deleteDeal, …). Every write goes through them, so guarding + capturing is implemented once, right inside those methods (there is no separate gate class).

Two independent safety layers mean a test can never touch a non-DEV deal:

  1. Capture mode sends nothing at all (safe by construction — the network call is never made).

  2. The DEV-org guard blocks any non-DEV-org deal/person write outside live, even if capture is off. It is always on (regular behaviour, not a selectable mode). This also fixed a real latent bug: the previous inline guard only blocked one narrow serial-collision case, so a dev run could otherwise update a live deal.

    The organisation is now the per-environment setting pipedrive_org_id_dev, not the constant 6034 — production's DEV organisation does not exist in any other account. Blank means no organisation restriction, which is the right setting against a sandbox.

  3. The cross-account guard, added with the credential layer, and stronger than both: every request is checked against the Pipedrive company the environment declares, on reads as well as writes, and refused before anything is sent (oauth-app.md). It is what makes pointing an environment somewhere else a deliberate act rather than an accident.


Testing against the sandbox

A developer sandbox exists (beta-calco-sandbox, company 20731130) and an environment can be pointed at it. Three settings move together — and they must move together, because the guard refuses any disagreement:

SettingValue
pipedrive_company_idthe sandbox company id
the credentialthe sandbox's API token, or the dev app's installation
pipedrive_org_id_devblank (no organisation restriction)

Then re-read the per-account ids — field keys and option ids are per account, so live's mean nothing there:

php docroot/cli/cb_pipedrive_import_fields.php --adopt-legacy=1 # imports + audits

See field-registry.md and provisioning.md.

What the sandbox can and cannot cover. Its custom-field budget is 30 in total, shared across deals, persons, organizations and products — not 30 each; production has 52. That sounds like a blocker and is not. Audited 2026-08-07: the integration touches zero custom fields on persons, organizations and products — it sends org_id/person_id as plain ints, creates a person from name/email/org_id, and reads an organization only to confirm a branch's organisation_id exists. The six custom deal fields are the whole dependency, and the sandbox already has all six.

  • Testable: the whole quote → deal push, stage/pipeline sync, the inbound webhook, deal create/update/delete, activities, and every capture-mode payload assertion.
  • Not realistic: owner assignment — the sandbox has ~1 user, so the fallback RSM fires for every deal — and anything that depends on production's data volume rather than its schema.

⚠️ Fields are not the hard part — the ORGANISATION IDS are

The site's own data references production Pipedrive organisation ids: every branch row carries an organisation_id, and updatePipedriveDeal() validates it with getOrganizationDetails() before building the payload. Point a worktree at the sandbox without touching that data and the existing suite fails on its first real push:

test-support HTTP 500 (process-serial): Pipedrive v2 GET /organizations/6034 returned HTTP 404.

The database is seeded from production, so it is full of ids that mean nothing in the sandbox. Mirroring custom fields does not fix this — organisations are records, not schema, and their ids are assigned by Pipedrive on creation, so they cannot be mirrored either.

To actually run the suite against the sandbox you would have to create the organisations there and repoint #__configbox_external_settings_branches.organisation_id (and pipedrive_org_id_dev) at the new ids. That is a real piece of work and nobody has done it.

So the current, honest position: the suite runs against live with the DEV-org guard and capture mode, exactly as it always has — that combination is what proves a change is safe. The sandbox is proven for credential-layer work (the registry import, the company guard, the watchdog, tools/pd), and becomes a full test target only once the branch organisations exist there.

The prize is tests/specs/agent/quote-stage-e2e-live.spec.ts, which currently creates and deletes a real deal in the production account on every run. Pointed at the sandbox, that whole class of test moves off production.

Assert where you are before you write. The test-support endpoint answers:

php docroot/cli/cb_pipedrive_test_support.php pipedrive-identity
# {"environment":"dev","authMode":"token","expectedCompany":20731130,
# "companyId":20731130,"orgIdDev":null,"capture":false,"problem":null}

A spec that checks this first fails on its first line when TARGET points somewhere aimed at the wrong company — rather than discovering it by writing a deal into production.


The moving parts

PieceFileRole
Write wrapperssystem_overrides/BcPipedriveApi.phpaddDeal/updateDeal/deleteDeal/addPerson (+ setup writes) each enforce the org-6034 guard and, in capture mode, record the payload + return a synthetic response. The single choke point — no separate gate class.
BcPipedriveTestResponsesystem_overrides/BcPipedriveTestResponse.phpSynthetic SDK response returned for a captured write (->success, ->data->id, ->jsonSerialize()).
BcPipedrivePolicyExceptionsystem_overrides/BcPipedrivePolicyException.phpThrown when the guard blocks a non-DEV-org write.
Test-support logicsystem_overrides/BcTestSupport.phpThe shared glue: inspect/prepare quote state, run the deal-update flow (returning captures inline), mint follow-up tokens.
Test-support HTTP endpointcontrollers/bctestsupport.phpSecret-gated (test_support_secret) HTTP wrapper around BcTestSupport, so the suite can drive a remote environment without a local checkout. This is what Playwright calls.
Test-support CLIcli/cb_pipedrive_test_support.phpThin server-local CLI over the same BcTestSupport (for manual use on the box); the suite no longer needs it.

Behaviour (the only toggle is capture)

There is no guard/off mode any more — the org-6034 guard is unconditional. The single env toggle selects whether writes are captured or sent:

Writes are…How you get itWhat happens
capturedenv PIPEDRIVE_TEST=captureRecorded to the capture file; nothing sent; synthetic success returned. (BcPipedriveApi::isCapturing() is true.)
sentdefault (no PIPEDRIVE_TEST)Sent to Pipedrive — but the always-on org-6034 guard still applies: outside live, a non-DEV-org deal/person write throws BcPipedrivePolicyException; on live, any org is allowed.

Reads are never gated or captured (they can't mutate Pipedrive). In capture mode reads still go to live Pipedrive — see Determinism.

Since the API v2 migration, write payloads are translated to the active API version before capture, so the capture file shows the real wire shape. Each capture record carries apiVersion (1 or 2); under v2 custom fields sit nested under custom_fields and an update's deal id is on the record (dealId), not in the payload. tests/support/captures.ts exposes customField() to read a custom field regardless of version.

⚠️ Never set PIPEDRIVE_TEST=capture on the live server: it would silently stop real syncing.

PIPEDRIVE_TEST is the server-side switch. The test harness has its own switch, PIPEDRIVE_SEND, which chooses whether an opted-in spec captures or verifies the real destinations — see Send mode.

Live end-to-end smoke (the only spec that really sends)

tests/specs/agent/quote-stage-e2e-live.spec.ts is the one spec that verifies a stage change all the way through to both real destinations — the actual Pipedrive deal and the actual quote-stage-sync Google Sheet — rather than a captured payload / the local sync table. It is opt-in: it runs only with E2E_LIVE_SMOKE=1 (skipped otherwise), so the everyday suite stays capture-only.

Flow: seed a quote → edit its project stage on the follow-up page (real UI) → create-fixture-deal (a throwaway DEV-org 6034 deal, pinned to the quote) → process-serial with capture=0 (the deal is really pushed → the fixture deal moves to the chosen stage) → read-deal asserts the deal's stage_id / pipeline_id / statusexport-sheet (the real cron export) → read-sheet-rows asserts the sheet row (stage name, probability_derived, status, is_latest_sync, column-aligned). Teardown delete-deal + deletes the quote and local sync rows.

Why the fixture deal: a freshly-seeded serial usually already maps to a real deal on production Pipedrive (dev and live share the serial namespace and the same Pipedrive), and that deal can be in a foreign customer org — which the always-on org-6034 guard rightly refuses to update (the smoke test caught exactly this). Pinning the quote to a DEV-org fixture deal keeps the whole push in org 6034. The appended sheet row is left in place (append-only sheet; each run uses a fresh serial, so runs don't collide).

It uses the real imported pipeline stages (not seed-stages, whose synthetic ids Pipedrive would reject), so the target environment must have the pipeline's stages imported.

Send mode (opt-in real destinations, cron-driven)

Above, PIPEDRIVE_TEST is the server-side switch (BcPipedriveApi captures or sends). Separately, the test harness has a switch — PIPEDRIVE_SEND (support/env.tsSEND_MODE) — that decides which path an opted-in spec takes:

PIPEDRIVE_SENDOpted-in spec does…
unset / 0 (default)Capture path — runs the export itself via process-serial (capture=1) and asserts the intercepted payload. Safe anywhere/CI.
1Send path — turns capture off and verifies the real destinations, cron-driven (below). Real writes, confined to DEV org 6034.

Only specs that read SEND_MODE change; the rest keep capturing. Today that's anon/quote-follow-up-status.spec.ts (below) plus the always-opt-in live smoke above.

anon/quote-follow-up-status.spec.ts in send mode models production faithfully: it doesn't run the export — it assumes the target's crons run ~every minute and just waits. Flow: pin a DEV-org 6034 create-fixture-deal (same guard reason as the live smoke) → mark the quote Lost on the follow-up page (real UI) — which schedules the deal update and records a quote-stage-sync row → then poll: the Pipedrive deal-update cron flips the fixture deal to status=lost (read-deal, incl. lost_reason), and the sheet-export cron mirrors the sync row into the Google Sheet (read-sheet-rows, status=Lost). Waits up to PIPEDRIVE_CRON_WAIT_MS (default 3 min); teardown delete-deal + clear-stage-sync.

So send mode needs a target where both crons — Pipedrive deal-update and quote-stage-sync sheet-export — are scheduled and Pipedrive/Sheets are reachable. On a plain local dev box they usually aren't and the polls time out (with a hint). Two shapes to pick from: the live smoke self-triggers the export (deterministic, no cron needed); send mode waits for the real crons (black-box, production-like).

PIPEDRIVE_SEND=1 npm run test:quote-followup-status # real deal + real sheet — needs the crons running

Capture file format

One JSON object per intercepted write. The endpoint collects these per call and returns them inline (the captures array); the client mirrors them into a local JSONL file (tests/.captures/) for capturesForSerial and the demo runner. Example for a created deal:

{"ts":"2026-06-24T10:00:00+00:00","op":"addDeal","orgId":6034,"dealId":null,"quoteSerial":"Q-12345","payload":{"title":"…","org_id":6034,"value":1234,"currency":"CAD","user_id":,"person_id":,"<serialKey>":"Q-12345","<dealTypeKey>":"159","<hasAltKey>":"174","status":"open",}}

opaddDeal | updateDeal | deleteDeal | addPerson | addDealField | …. quoteSerial is extracted from the payload's configured serial field so tests can filter by quote. payload is the exact array handed to the SDK — this is what you assert for integrity.


The test-support endpoint (and CLI)

The canonical, reusable reference for the endpoint + the support client + every command is ../testing/support-api.md. The table below is the same endpoint seen through the deal-update flow — reach for the reusable reference when writing any new spec.

The suite reaches the support commands over an authenticated HTTP endpoint, so it can run from any machine against any environment — no local PHP checkout, which is what made testing live impractical before. The same commands are also available as a server-local CLI for manual use.

# HTTP (what the suite uses) — secret-gated, returns one JSON object:
GET {BASE_URL}/index.php?option=com_configbox&controller=bctestsupport&task=run&command=<cmd>&arg1=<..>
Header: X-BC-Test-Support-Secret: <the environment's test_support_secret setting>

# CLI (manual, on the server) — prints one JSON line:
php docroot/cli/cb_pipedrive_test_support.php <command> [args]

Enabling it: set the per-environment test_support_secret (Backend → Settings → Functional Test Harness; migration 0.5.54) and put the same value in tests/.env as TEST_SUPPORT_SECRET. A blank setting disables the endpoint (fail-closed, 403) — leave it blank except while running the suite. The endpoint acts as the automation test account, so its mutations stay on that account's bogus-org data.

The endpoint runs the process commands in capture mode and returns the intercepted payloads inline (the captures field), which the client mirrors into the local JSONL file. So there is no capture file on the server, and a remote run needs no filesystem access there.

CommandPurpose
modeShow resolved mode, environment, DEV org id (sanity check the harness).
quote-state <serial>JSON of the pipedrive_* columns (+ id/name/revision/stage/completion-date/comments/oot) for every revision of that serial.
field-keysThe settings-sourced custom-field keys the payload uses (serial, agent-feedback status/lost-reason, project phase, is-OOT), so a spec can assert payload entries keyed by them without hard-coding the environment-specific hashes.
page-urlsServer-resolved SEF URLs for the My Quotes page and the login page (via KLink::getRoute / getPlatformLoginLink), so a spec can assert a redirect / link target without hard-coding or configuring the path (login may be relative, e.g. /jlogin). Read-only.
schedule <serial>Flag the latest revision for a deal update (pipedrive_update_scheduled=1) — drive the runner without the UI.
set-deal-id <serial> <dealId>Link the quote to an existing deal id — set up the update-existing-deal path against a real 6034 fixture deal.
processRun executeScheduledDealUpdates() over the whole scheduled queue, in capture mode; returns captures inline. Slow on a non-empty queue.
process-serial <serial>Run the deal update for just one serial's latest revision (updatePipedriveDeal + markUpdated), in capture mode, returning captures inline. The isolated, fast path most specs use (support.processSerial()).
delete-quote <serial>Hard-delete every revision of a serial and its child rows (test cleanup; never touches Pipedrive).
clear-capturesNo-op server-side (captures return inline); the client empties its local capture file. Kept for CLI parity.
set-quote-owner <serial> <cbUserId>Reassign every revision of a serial to a different owner (cb user id). Used by the Quote Follow-Up non-owner test to make the logged-in agent a non-owner and assert the page hides the quote (shows notfound).
mint-followup-token <serial> [expired]Sign a quote-bound Quote Follow-Up grant token for the serial's agent (via BcQuoteFollowupAuth), so an anon-project spec can open the landing page authorized for that one quote the way an emailed link does (a scoped grant, not a login). Returns {serial,userId,token,lifetimeDays,expired}; the spec builds /quote-follow-up/<serial>?token=<token> against its baseURL. Pass expired to get a validly-signed token backdated past the configured lifetime (for the expired-token test; needs lifetime > 0). See quote-follow-up/authentication.md.
stage-sync-rows <serial>Every quote-stage-sync row recorded for a serial (#__configbox_external_quote_stage_syncs), oldest first — so a spec can assert what the always-on recorder wrote (sync_source, sync_trigger, status, stage, expected_bid_date/expected_release_date) over HTTP, with no DB/CLI. See quote-follow-up/stage-sync.md.
clear-stage-sync <serial>Delete the quote-stage-sync rows for a serial (test cleanup — they have no FK to the quote, so delete-quote leaves them behind).
reset-feedback <serial>Clear the Quote Follow-Up feedback (agent status / lost reason / stage / pipeline / bid + completion dates / comments) on every revision, so the landing page renders the fresh form state again. Use in a beforeAll to make a follow-up spec independent of what a prior spec did to the shared seeded quote.
read-deal <serial>Read the live Pipedrive deal for the serial (id / stage_id / pipeline_id / status / value / expected-close-date), resolved by stored deal id then by serial. Read-only. Used by the live end-to-end smoke to assert the change really landed on the deal (needs the deal pushed first, in send mode).
create-fixture-deal <serial>Create a throwaway real deal in the DEV org (6034) and link the serial to it. Send mode. The live smoke test calls this so its outbound push resolves to a DEV-org deal (guard-safe update path) instead of a colliding live deal in a foreign org.
delete-deal <serial>Delete the real Pipedrive deal linked to a serial (org-6034 guard applies) and clear the link. Live-smoke teardown, so a deal isn't left behind each run.
export-sheetRun the real quote-stage-sync export (ConfigboxModelBcquotestagesync::exportPendingRowsToSheet()) — the same code the cron runs — draining pending rows to the configured Google Sheet. No-op when no sheet is configured.
read-sheet-rows <serial>Read the configured quote-stage-sync Google Sheet back and return the rows for a serial as header-keyed objects (so a spec asserts the actual sheet, column-aligned). Read-only.
set-stage-cadence <stageId> <csv>Set one imported stage's follow-up nudge cadence (nudge_days_small/mid/large; csv = "small,mid,large", empty segment = NULL, "0,0,0" = due immediately). Returns the previous values (previousCsv) so the spec restores them (the only cleanup for cadence on a real stage). See quote-follow-up/nudge-cadence.md.
set-nudge-gates <includeInternal> <includeTest>Set the two nudge deal-type gates (quote_nudge_include_internal / quote_nudge_include_test; both 1 = include, each 0/1, empty = unchanged) so a spec can include an agent-owned (internal, @betacalco.com) quote or positively exercise a gate. Returns the previous values to restore. Writes every environment row.
collect-due-rows <serial>Run the real nudge due computation (ConfigboxModelBcquotenudge::collectDueRows) scoped to one serial, without writing any Google Sheet, returning { due, row (header-keyed), stats }. The capture-safe way to assert whether a quote is "due for a nudge" (bucket / cadence / anchor / gates) — the everyday counterpart to build-nudge-sheet. Read-only. See quote-follow-up/nudge-cadence.md.
build-nudge-sheetRun the real follow-up nudge reconcile (ConfigboxModelBcquotenudge::buildAndReconcileSheet(), the cron code) — rebuild the configured nudge Google Sheet with the current due set. No-op when no nudge sheet is configured. Live (writes a real sheet; opt-in specs only).
read-nudge-sheet-rows <serial>Read the configured nudge Google Sheet back and return the rows for a serial as header-keyed objects (the nudge sheet keys the serial in column A). Read-only.

A test, end to end

Each functional test is the same shape:

  1. Arrange — log in via saved storageState (see the e2e guide); ensure the acting user belongs to the test branch (org 6034).
  2. Act — perform the real UI action (add a position, change status, submit the OOT form, …).
  3. Assert (DB, scheduled)support.latestQuote(serial)pipedrive_update_scheduled === "1" and any pipedrive_* columns the action should have changed.
  4. Capturesupport.clearCaptures(), then support.processSerial(serial) (runs the deal update in capture mode over HTTP and returns the payloads inline).
  5. Assert (payload)capturesForSerial(serial) and assert op (create vs update) and the field values against deal-updates.md.
  6. Assert (DB, cleared)support.latestQuote(serial) again → pipedrive_update_scheduled === "0" and pipedrive_date_last_export now set (the runner marked it done).

Playwright skeleton

The support helper (tests/support/cli.ts) calls the HTTP endpoint; captures are read via capturesForSerial (tests/support/captures.ts). Both are synchronous — no per-spec plumbing.

import { test, expect } from '@playwright/test';
import { support } from '../../support/cli';
import { capturesForSerial } from '../../support/captures';

test('adding a position schedules a deal update with the new value', async ({ page }) => {
const serial = /* serial of the quote under test */ '';

// 2) real UI action
await page.goto('https://betacalco.ddev.site/…');
await page.getByRole('button', { name: 'Add position' }).click();
// …

// 3) DB: scheduled
expect(support.latestQuote(serial)!.pipedrive_update_scheduled).toBe('1');

// 4) capture (HTTP, capture mode — nothing sent to Pipedrive)
support.clearCaptures();
expect(support.processSerial(serial).processed).toBe(true);

// 5) payload integrity
const recs = capturesForSerial(serial);
expect(recs.length).toBeGreaterThan(0);
const deal = recs.at(-1)!;
expect(['addDeal', 'updateDeal']).toContain(deal.op);
expect(deal.orgId === 6034 || deal.payload!.org_id === 6034).toBeTruthy();
// expect(deal.payload!.value).toBe(<expected total>);

// 6) DB: cleared + stamped
const after = support.latestQuote(serial)!;
expect(after.pipedrive_update_scheduled).toBe('0');
expect(after.pipedrive_date_last_export).not.toBeNull();
});

Test matrix (one assertion set per trigger)

Triggers come from deal-updates.md; expected payload comes from the same doc's field table.

Implemented specs are marked ✅.

Action (Playwright)DB expectationCaptured-payload expectation
✅ Copy a line item (copy-line-item.spec.ts)update_scheduled=1value rises (a second line item)
✅ Delete a line item (delete-line-item.spec.ts)update_scheduled=1value falls (line item removed)
✅ Change quantity (change-line-item-quantity.spec.ts)update_scheduled=1value rises (qty 1→3 on the seeded line item)
Edit a line item / add a line itemupdate_scheduled=1value (and has-alternatives field) recomputed — not yet a spec; add covered by seeding, edit/reconfigure not
✅ Toggle a position's alternative flag (toggle-alternative-flag.spec.ts)update_scheduled=1"Deal contains alternatives" field flips 174→173
✅ Set project name (create-and-rename-quote.spec.ts)update_scheduled=1, name persistedtitle (on create) reflects new name
✅ Create a revision (create-revision.spec.ts)second revision becomes latest + update_scheduled=1latest revision pushes same serial, positive value
Copy quote → new quote (duplicate)new serial, pipedrive_* nulled, schedulednew quote → addDealnot yet a spec (duplicate is a quote-maker/opportunity-list UI action)
✅ Agent feedback = lost (quote-follow-up-status.spec.ts)pipedrive_status=lost, lost reason setstatus=lost; lost_reason on update path; agent-feedback fields set
✅ Quote Follow-Up "Open + stage + date + comment" (outbound-deal-payload.spec.ts)agent-feedback status=open, stage/completion_date/comment_pending set, scheduledfull payload asserted: value/currency/org 6034/owner/creator/person, Deal Type + has-alternatives, quote-serial, Agent Feedback Status, stock expected_close_date, Project Phase label; create-vs-update handled path-aware; plus a separate done addActivity whose subject/note carry the serial + comment
✅ Quote Follow-Up won via stage 8 (quote-follow-up-variations.spec.ts)pipedrive_agent_feedback_status=won, stage=po; native pipedrive_status not won— (DB rule; no payload assertion) — a won suggestion must not win the native deal
✅ Quote Follow-Up page variations (quote-follow-up-variations.spec.ts)form/updated/expired states, submit gating (Lost needs a reason), Lost clears stage/date, re-issue-link, 404 for an unknown serial— (page behaviour + DB, not a deal-payload trigger)
Agent feedback = won / open (My Quotes select)pipedrive_agent_feedback_status set; open propagates pipedrive_status=open, clears lost reasonstatus reflects it — not yet a spec; the #pipedrive_agent_feedback_status select only renders when the quote already has a pipedrive_deal_id
✅ OOT form submitted (submit-oot-form.spec.ts)oot_status=registered (NOT approved), scheduleda deal write is produced; Is-OOT stays "no" — registration ≠ approval
Deal Type derivationsname "test"→161; @betacalco.com→160; else 159 — not yet a spec; deterministic only for the name-"test" case
Create vs updatenew quote vs linked quotenew → op=addDeal, title present, no lost_reason; linked → op=updateDeal, no titlepartly asserted in create-and-rename-quote.spec.ts
✅ Delete a quote that has a deal (delete-quote.spec.ts)scheduled after delete; deal link clearedop=deleteDealdeleteDeal half gated behind PIPEDRIVE_TEST_DEAL_ID (a real DEV deal); scheduling asserted unconditionally
Live E2E: stage change → real deal + real sheet (quote-stage-e2e-live.spec.ts, opt-in E2E_LIVE_SMOKE=1)stage saved, scheduledreal send (not capture): the DEV-org fixture deal's stage_id/pipeline_id/status read back from Pipedrive, and the real Google Sheet row (stage/probability_derived/status/is_latest_sync) read back — see Live end-to-end smoke
✅ Quote Follow-Up change of mind (quote-follow-up-corrections.spec.ts)Lost→reopen: pipedrive_status back to open, stage restored, both lost reasons cleared; Won→correct-to-Lost: native status moved off won to lost— (DB outcome of the page's authoritative native-status writes; not a payload trigger)
✅ Quote-stage-sync recorder semantics (quote-stage-sync-behaviour.spec.ts)idempotency (no dup row), soft-field amend-in-place, per-quote sync numbering, is_latest flip, previous_stage chaining, lost stage carry-forward — read back via stage-sync-rows— (local audit table; no sheet, no Pipedrive — the capture-safe counterpart to the live smoke)
✅ Follow-up nudge due logic (quote-nudge-due.spec.ts)no-cadence skip; due at cadence 0 (bucket/interval/anchor/link); not due at high cadence (skipped_not_due); internal gate excludes; Lost drops from candidates — via collect-due-rows— (no Google Sheet; the capture-safe counterpart to the opt-in nudge live smoke)
editQuote / quote-maker saveQuote (conditional)scheduled only if a pipedrive_* field changed and the quote has a linked dealper field table — not yet a spec (quote-maker context)
Quote Console storePositionupdate_scheduled=1value recomputed — not yet a spec (admin Quote Console context)
Safety: quote on a non-6034 branch, guard modeprocess logs/raises BcPipedrivePolicyException; no capture, no send — not yet a spec (needs a non-DEV-branch fixture)

Authenticated specs (the automation test account)

The no-login Quote Follow-Up spec needs no account. The rest of the matrix (My Quotes, the quote-maker quote-details actions, adding positions) is behind login, so the suite acts as one canonical automation account — test-automation-agent@betacalco.com (use it for every new spec):

  • Exists on both dev and live (same login), so the suite can target either — see tests/README.md → Targeting environments (tests/README.md). Its password lives only in git-ignored tests/.env (E2E_AGENT_USER / E2E_AGENT_PASS); .env.example carries the username.
  • In the Agent Joomla group and the "US Agent" ConfigBox customer group, attached to the bogus test rep branch → the bogus test Pipedrive organisation (the DEV org 6034 in the shared account). So quotes it owns resolve to 6034 (the guard passes off-live) and its deals land in the bogus org on every environment.
  • Safe on live. Because its quotes/deals are confined to the bogus org, it is sanctioned for this account to create quotes and even real Pipedrive deals on production (the team is aware). The suite still defaults to capture mode, so it asserts payloads without sending.
  • tests/setup/agent.setup.ts logs in once through the site's own AJAX login (server.makeRequest('loginoverlay','login',…), no CSRF token needed) and saves the session to .auth/agent.json; the agent project replays it.

Fixtures / seeding

Realistic quotes are seeded by driving the real configurator — never hand-built in SQL — so all derived data (pricing, BOM, totals, branch→org) is produced by the app.

  • Auto-seed (setup-seed project, support/seedQuote.ts): opens the first product in the Decorative category on /families (resolved by the filter's data-filter-value-name → category id, then the product's data-product-assignments), answers every applying question for a realistic priced config, then confirms completeness via the server: clicking Add to Quote fires configuratorpage/getMissingSelectionsProduct, whose JSON lists any still-required questions ({id,title,message,…}); the seed answers the reported #question-<id> and re-fires until the array is empty — at which point the button opens the quote picker. It then creates a new quote (#project-name-field + .trigger-create-new-quote) and adds the configured product (.trigger-add-to-quote). The serial lands in .auth/seed-quote.json; the quote-follow-up spec reads it; the cleanup-seed teardown deletes it. Runs in the agent session, so the quote is owned by the agent → org 6034.
  • Test branch: the account sits on the bogus test rep branch (organisation_id = 6034, rsm pipe@betacalco.com — the fallback RSM the export already uses), so its quotes resolve to 6034 and pass the guard even in guarded-live runs. This mapping is already configured on the account (on both dev and live); you don't set it per run.
  • Override: set PIPEDRIVE_TEST_SERIAL to skip seeding and run against a specific existing quote.
  • Update-path fixture (optional): a dev quote's serial often already maps to a real deal, so the capture is an updateDeal (the guard protects that deal in guarded-live mode). To force it, point a quote at a known 6034 deal with set-deal-id.

Determinism, and the "reads are live" caveat

Capture mode blocks writes but lets reads hit live Pipedrive (they can't mutate anything). The flow reads: org validation, RSM user lookup, person lookup, deal-field definitions, and the existing-deal lookup. Consequences:

  • A test needs network and a valid API token in dev settings.
  • Create vs update is not guaranteed: the flow looks the deal up by serial against live Pipedrive. On dev (a copy of live), a freshly created quote's serial often already maps to a real deal, so the capture is an updateDeal (title omitted) rather than addDeal. Assert path-aware: check title only on addDeal; check value/org_id/serial on both.
  • Capture-mode process over a pre-existing dev queue makes many live reads and is slow — drive tests from a clean queue (or filter captures by serial and tolerate the time). Fully-offline tests (stubbed reads from fixtures) are a possible future enhancement; not built yet.

⚠️ Capture-mode process still writes to the quotes table — it clears update_scheduled, stamps pipedrive_date_last_export, and for created deals stores a synthetic deal id (> 900000000). It is a true end-to-end run minus the network send. Run it against a disposable / test database (or be aware it mutates quote rows on dev). It never mutates Pipedrive.


Two ways to run the suite

  1. Capture (default, recommended): PIPEDRIVE_TEST=capture. Zero Pipedrive writes; asserts on captured payloads. The everyday CI/local mode.
  2. Live smoke (occasional): no PIPEDRIVE_TEST, on dev. Writes are really sent, but the always-on org-6034 guard means only org 6034 deals go through (anything else throws). Use sparingly to confirm Pipedrive really accepts the payload (e.g. catches a server-side rejection like the lost_reason-on-create case). Inspect/clean up the created DEV-org deals afterward.

For a watchable, narrated demo (showing stakeholders the real browser drive the site, then the intercepted payload, then the visual report), use cd tests && npm run demo — see the "Presenting the tests" section in tests/README.md.


Gotchas

  • Don't bypass the wrappers. Any new Pipedrive call must go through a BcPipedriveApi method, or it escapes both the guard and capture. (grep for ->getDeals()-> etc. should only hit BcPipedriveApi.)
  • process runs the whole queue, not one quote. Isolate by starting from a clean queue and/or filtering captures by quoteSerial.
  • Guard blocks → the runner records that quote as failed and leaves it flagged (it does not clear the flag). That's intended: a non-DEV quote should never be marked "sent" on dev.
  • Synthetic deal ids (> 900000000) in capture mode are recognizable fakes; don't treat them as real Pipedrive ids.
  • The suite needs no local PHP. It reaches the support commands over the HTTP endpoint, so you only need TEST_SUPPORT_SECRET set (matching the target environment's test_support_secret) and network access to BASE_URL. The server-local cb_pipedrive_test_support.php CLI is for manual use on the box.