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:
-
Capture mode sends nothing at all (safe by construction — the network call is never made).
-
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 constant6034— production's DEV organisation does not exist in any other account. Blank means no organisation restriction, which is the right setting against a sandbox. -
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:
| Setting | Value |
|---|---|
pipedrive_company_id | the sandbox company id |
| the credential | the sandbox's API token, or the dev app's installation |
pipedrive_org_id_dev | blank (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, andupdatePipedriveDeal()validates it withgetOrganizationDetails()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(andpipedrive_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
| Piece | File | Role |
|---|---|---|
| Write wrappers | system_overrides/BcPipedriveApi.php | addDeal/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. |
BcPipedriveTestResponse | system_overrides/BcPipedriveTestResponse.php | Synthetic SDK response returned for a captured write (->success, ->data->id, ->jsonSerialize()). |
BcPipedrivePolicyException | system_overrides/BcPipedrivePolicyException.php | Thrown when the guard blocks a non-DEV-org write. |
| Test-support logic | system_overrides/BcTestSupport.php | The shared glue: inspect/prepare quote state, run the deal-update flow (returning captures inline), mint follow-up tokens. |
| Test-support HTTP endpoint | controllers/bctestsupport.php | Secret-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 CLI | cli/cb_pipedrive_test_support.php | Thin 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 it | What happens |
|---|---|---|
| captured | env PIPEDRIVE_TEST=capture | Recorded to the capture file; nothing sent; synthetic success returned. (BcPipedriveApi::isCapturing() is true.) |
| sent | default (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=captureon 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 / status → export-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.ts → SEND_MODE) — that decides which
path an opted-in spec takes:
PIPEDRIVE_SEND | Opted-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. |
1 | Send 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",…}}
op ∈ addDeal | 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
supportclient + 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
capturesfield), 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.
| Command | Purpose |
|---|---|
mode | Show 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-keys | The 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-urls | Server-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. |
process | Run 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-captures | No-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-sheet | Run 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-sheet | Run 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:
- Arrange — log in via saved
storageState(see the e2e guide); ensure the acting user belongs to the test branch (org 6034). - Act — perform the real UI action (add a position, change status, submit the OOT form, …).
- Assert (DB, scheduled) —
support.latestQuote(serial)→pipedrive_update_scheduled === "1"and anypipedrive_*columns the action should have changed. - Capture —
support.clearCaptures(), thensupport.processSerial(serial)(runs the deal update in capture mode over HTTP and returns the payloads inline). - Assert (payload) —
capturesForSerial(serial)and assertop(create vs update) and the field values against deal-updates.md. - Assert (DB, cleared) —
support.latestQuote(serial)again →pipedrive_update_scheduled === "0"andpipedrive_date_last_exportnow 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 expectation | Captured-payload expectation |
|---|---|---|
✅ Copy a line item (copy-line-item.spec.ts) | update_scheduled=1 | value rises (a second line item) |
✅ Delete a line item (delete-line-item.spec.ts) | update_scheduled=1 | value falls (line item removed) |
✅ Change quantity (change-line-item-quantity.spec.ts) | update_scheduled=1 | value rises (qty 1→3 on the seeded line item) |
| Edit a line item / add a line item | update_scheduled=1 | value (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 persisted | title (on create) reflects new name |
✅ Create a revision (create-revision.spec.ts) | second revision becomes latest + update_scheduled=1 | latest revision pushes same serial, positive value |
| Copy quote → new quote (duplicate) | new serial, pipedrive_* nulled, scheduled | new quote → addDeal — not 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 set | status=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, scheduled | full 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 reason | status 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), scheduled | a deal write is produced; Is-OOT stays "no" — registration ≠ approval |
| Deal Type derivations | — | name "test"→161; @betacalco.com→160; else 159 — not yet a spec; deterministic only for the name-"test" case |
| Create vs update | new quote vs linked quote | new → op=addDeal, title present, no lost_reason; linked → op=updateDeal, no title — partly asserted in create-and-rename-quote.spec.ts |
✅ Delete a quote that has a deal (delete-quote.spec.ts) | scheduled after delete; deal link cleared | op=deleteDeal — deleteDeal 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, scheduled | real 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 deal | per field table — not yet a spec (quote-maker context) |
Quote Console storePosition | update_scheduled=1 | value recomputed — not yet a spec (admin Quote Console context) |
| Safety: quote on a non-6034 branch, guard mode | — | process 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
devandlive(same login), so the suite can target either — see tests/README.md → Targeting environments (tests/README.md). Its password lives only in git-ignoredtests/.env(E2E_AGENT_USER/E2E_AGENT_PASS);.env.examplecarries 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.tslogs 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; theagentproject 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-seedproject,support/seedQuote.ts): opens the first product in the Decorative category on/families(resolved by the filter'sdata-filter-value-name→ category id, then the product'sdata-product-assignments), answers every applying question for a realistic priced config, then confirms completeness via the server: clicking Add to Quote firesconfiguratorpage/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; thecleanup-seedteardown 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, rsmpipe@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_SERIALto 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 withset-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 thanaddDeal. Assert path-aware: checktitleonly onaddDeal; checkvalue/org_id/serial on both. - Capture-mode
processover 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
processstill writes to the quotes table — it clearsupdate_scheduled, stampspipedrive_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
- Capture (default, recommended):
PIPEDRIVE_TEST=capture. Zero Pipedrive writes; asserts on captured payloads. The everyday CI/local mode. - 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 thelost_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
BcPipedriveApimethod, or it escapes both the guard and capture. (grepfor->getDeals()->etc. should only hitBcPipedriveApi.) processruns the whole queue, not one quote. Isolate by starting from a clean queue and/or filtering captures byquoteSerial.- 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_SECRETset (matching the target environment'stest_support_secret) and network access toBASE_URL. The server-localcb_pipedrive_test_support.phpCLI is for manual use on the box.