Skip to main content

Test catalog

Audience: developers & AI agents · Scope: every spec in tests/specs/ — one row per test: what data it runs on (Setup), what it drives (Action), and what it asserts (Checks) · Last reviewed: 2026-07-31

Part of Testing; the format is fixed by the e2e test standard. Keep this in sync — when you add, rename, move, or remove a test, update its row here in the same change (every test(...) title has exactly one row). Sections are grouped Actor > Feature (the spec's describe); agent = logged-in project, anon = not-logged-in, backend = Joomla-backend project (logs in per spec); = opt-in live smoke (real Pipedrive/Sheet, E2E_LIVE_SMOKE=1 / PIPEDRIVE_SEND=1, DEV org 6034). For a live list of test titles: npx playwright test --list.

Agent > Session

session.spec.ts (agent) — Session

Setup: the saved agent session (.auth/agent.json) replayed by the agent project — no in-test login.

TestSetup (given)Action (when)Checks (then)
the saved agent session reaches the My Quotes area logged in— (file default)navigate to the My Quotes viewbody has class viewname-bcmyquotes; login #username hidden; a logout affordance attached — an authenticated session, not a login gate

Agent > Quote maker

The My-Quotes / configurator actions an agent takes; each asserts the persisted quote and the captured Pipedrive deal write (capture mode — nothing sent). See docs/pipedrive/deal-updates.md and the operator flow in admin-guide/sales-agent (admin-guide/sales-agent/README.md).

add-configured-product-to-quote.spec.ts (agent) — Quote maker

Setup: a fresh quote driven end-to-end through the real UI (seedDecorativeQuote: /families → configurator → Add to Quote); deleted in afterEach.

TestSetup (given)Action (when)Checks (then)
adding a configured product to a new quote produces a priced line item that flows to the deal value— (file default)support.schedule + processSerial the new quote (capture)DB: quote persisted, is_latest_revision=1, name matches; capture: a deal write for the serial with payload.value > 0 and an org id set (a real price flowed from the configured line item)

configurator-requires-selections.spec.ts (agent) — Quote maker

Setup: a fresh, unanswered configurator for the first Decorative product, opened via /families — no seed, no support-API state.

TestSetup (given)Action (when)Checks (then)
the configurator blocks Add to Quote until every required selection is made— (file default)click "Add to Quote" (.trigger-show-quote-picker) without answering; capture getMissingSelectionsProductthe response is an array; when it lists required questions, each names its question id and the quote picker stays shut (#project-name-field hidden); skips if the product has no required questions

create-and-rename-quote.spec.ts (agent) — Quote maker

Setup: a fresh quote driven through the My Quotes "Create" flow (addQuote); deleted in afterEach.

TestSetup (given)Action (when)Checks (then)
agent creates a quote, renames it, and the captured deal payload reflects the new name— (file default)create via .trigger-add-quote/addQuote, open the view, edit #quote-name → storeProjectNameDB: name = created then renamed + pipedrive_update_scheduled=10 + date_last_export after processSerial; capture: one deal write — addDeal title = new name or updateDeal omits title; value defined, org present

create-revision.spec.ts (agent) — Quote maker

Setup: a fresh Decorative quote seeded through the app (seedDecorativeQuote); all revisions deleted in afterEach.

TestSetup (given)Action (when)Checks (then)
creating a revision clones the quote and schedules a deal update for the new latest revision— (one revision, is_latest_revision=1)click Create Revision (.trigger-create-revision/createRevision)DB: 2nd revision under same serial; original demoted (is_latest_revision=0), clone latest (=1) + update_scheduled=10 + date_last_export after processSerial; capture: addDeal/updateDeal with value > 0

delete-quote.spec.ts (agent) — Quote maker

Setup: a fresh Decorative quote (seedDecorativeQuote), hard-deleted in afterEach; the linked-deal branch is a support-API write (support.setDealId) gated on PIPEDRIVE_TEST_DEAL_ID.

TestSetup (given)Action (when)Checks (then)
deleting a quote schedules a deal update (and deletes the linked deal when one exists)with PIPEDRIVE_TEST_DEAL_ID, setDealId links a real org-6034 dealselect the row and delete it (.trigger-delete-quote/deleteQuote)DB: soft-deleted latest revision has update_scheduled=1; with a fixture deal, after processSerial a deleteDeal capture exists and pipedrive_deal_id is cleared; without one, the delete-deal half is skipped

change-line-item-quantity.spec.ts (agent) — Quote maker

Setup: a fresh realistic quote (seedDecorativeQuote, one configured line item); deleted in afterEach.

TestSetup (given)Action (when)Checks (then)
bumping a line item quantity recomputes and pushes a higher deal valuebaseline deal value from the first capture after processSerialfill the quantity input to 3 (debounced → updatePositionQuantity)DB: update_scheduled=10 + date_last_export after processSerial; capture: new payload.value > baseline

copy-line-item.spec.ts (agent) — Quote maker

Setup: a fresh realistic quote (seedDecorativeQuote, one priced line item); deleted in afterEach.

TestSetup (given)Action (when)Checks (then)
copying a line item raises the deal valuebaseline value from capture after processSerialselect the line (.trigger-select-position) then copy it (copyPosition)DB: update_scheduled=10 + date_last_export; capture: new payload.value > baseline

delete-line-item.spec.ts (agent) — Quote maker

Setup: a fresh realistic quote (seedDecorativeQuote, one priced line item); deleted in afterEach.

TestSetup (given)Action (when)Checks (then)
deleting a line item lowers the deal valuebaseline value from capture after processSerialselect the line then delete it (deletePosition)DB: update_scheduled=10 + date_last_export; capture: new payload.value less than baseline

toggle-alternative-flag.spec.ts (agent) — Quote maker

Setup: a fresh realistic quote (seedDecorativeQuote), then the line copied so the quote has two lines (enables alt-marking); deleted in afterEach.

TestSetup (given)Action (when)Checks (then)
marking a line item as an alternative flips the deal's "contains alternatives" fieldbaseline capture asserts HAS_ALT = "No" (174)tick the enabled input.is-alt-checkbox → updatePositionIsAltFlagDB: update_scheduled=10 + date_last_export; capture: new payload's HAS_ALT = "Yes" (173)

submit-oot-form.spec.ts (agent) — Quote maker

Setup: a fresh realistic quote (seedDecorativeQuote); deleted in afterEach.

TestSetup (given)Action (when)Checks (then)
submitting the OOT form registers the quote and schedules a deal update— (file default)open the OOT modal (.trigger-show-oot-form), fill the seven required fields, submit → submitOotFormDB: oot_status='registered' + update_scheduled=10 + date_last_export; capture: addDeal/updateDeal with value defined (registration is not OOT-approval, so Is-OOT stays "no")

Agent > Quote access control

The owner side of the 2026-07-25 quote-endpoint hardening. See docs/quotes/README.md → "Who may act on a quote".

quote-fragments-owner-access.spec.ts (agent) — Quote access control

Setup: the shared seeded quote (owned by the automation agent); plain request calls against /cb-api/… over the replayed agent session — read-only, nothing to restore.

TestSetup (given)Action (when)Checks (then)
the owner still gets their own quote HTMLthe seeded quote's idGET /cb-api/bcquote/getQuoteHtml?quoteId=… as the agent200; view-bcquote markup + the seeded serial
the owner still gets their own positions HTMLthe seeded quote's idGET /cb-api/bcquote/getPositionsHtml?quoteId=… as the agent200; view-bcquotepositions markup
a quote that is not theirs is deniedan id matching no quote (same denial path as a foreign quote, no real foreign data touched)GET getQuoteHtml with that id as the agent403 — a login grants nothing beyond your own quotes

Agent > Quote Follow-Up

The follow-up landing page as the logged-in agent drives it. See docs/quote-follow-up/landing-page.md.

outbound-deal-payload.spec.ts (agent) — Quote Follow-Up

Setup: a fresh Decorative quote (seedDecorativeQuote, agent → DEV org 6034) + deterministic stages (support.seedStages); processSerial runs the write in capture mode; deleted in afterEach.

TestSetup (given)Action (when)Checks (then)
Quote Follow-Up "Open + stage + date + comment" builds the correct addDeal + addNote payloads— (file default)drive the real form to Open + seeded stage + completion date + comment, submit, then processSerialDB: status open, stage, completion date, comment_pending; update_scheduled=10 + pending comment cleared; capture: addDeal/updateDeal with value>0/currency/org=DEV/owner/person, status open, Deal-Type + hasAlternatives="No", quote-serial + Agent-Feedback-Status keys, stage_id, expected_close_date; a separate addNote carries the comment + serial

quote-follow-up-variations.spec.ts (agent) — Quote Follow-Up

Setup: one shared Decorative quote (seedDecorativeQuote in beforeAll) + deterministic stages; most cases force ?state=form; serial block; never calls processSerial; deleted in afterAll.

TestSetup (given)Action (when)Checks (then)
a fresh quote (no feedback) opens in the "form" state with a stage pre-selectedruns first, before any submissionopen /quote-follow-up/<serial> with no ?statedata-state="form", form visible, exactly one stage pre-selected (=firstStage), no Won button, first stage shows 25%
Lost requires a reason — the submit is gated until one is chosenat ?state=formclick Lost, then pick a reasonwith no reason submit disabled; selecting "Price" enables it
selecting the won-flagged stage wins the quote — native deal status includedat ?state=formclick the won-flagged stage; submitno Won button, status stays Open; DB: agent status "won", stage=wonStage, native pipedrive_status="won"
Open + stage stores the expected bid date and the completion date on separate fieldsat ?state=form (prior test left native "won")Open + firstStage + bid date + completion date; submitDB: status open, stage=firstStage, native back to "open", bid/completion on separate columns; a stage-sync row is_latest_sync=1, source "Follow-up Landing Page", the two dates on expected_bid_date/expected_release_date
Lost submission records the reason and clears stage + both datesat ?state=form (prior test set both dates)Lost + "Lead time"; submitDB: status/native lost, agent+native lost_reason "Lead time", stage null, both dates null
Lost with "Other" requires the free-form reason and stores it on the native lost reasonat ?state=formLost → "Other", fill free-form; submitsubmit gated until filled; DB: status lost, agent reason "Other", native lost_reason = free text; stage-sync lost_reason = free text
after a submission, revisiting shows the "updated" recap and "update again" reveals the forma fresh Open+stage submission made in-testrevisit with no ?state, then click "Update again"data-state="updated" + .qlp-updated visible; after "Update again" the form is visible
the expired screen re-issues the follow-up link to the quote agentat ?state=expiredclick "Request a new link"; capture the requestNewLink POSTjson.success boolean; on success a masked email + .qlp-request-done "on its way", else errors present
an unknown serial shows the friendly "not found" screen with a proper 404unknown serial DOESNOTEXIST999navigate to /quote-follow-up/DOESNOTEXIST999HTTP 404, data-state="notfound", no form, copy "find that quote" + the serial
a signed-in user who does not own the quote sees the same "not found" (no existence leak)a fresh quote reassigned to a foreign owner (support.setQuoteOwner); deleted in finallynavigate to that quote's follow-up page as the non-ownerHTTP 404, data-state="notfound", no form — indistinguishable from missing

quote-follow-up-corrections.spec.ts (agent) — Quote Follow-Up

Setup: one shared Decorative quote (seedDecorativeQuote in beforeAll) + deterministic stages; each test drives its own precondition from ?state=form; asserts DB columns only; deleted in afterAll.

TestSetup (given)Action (when)Checks (then)
Lost → reopen to Open restores the stage and clears both lost reasonsfirst submits Lost + "Price"revisit ?state=form, reopen to Open on firstStage; submitafter Lost: agent+native lost, both lost_reasons "Price", stage null; after reopen: agent open, native "open", stage=firstStage restored, both lost reasons cleared
Won → correct to Lost moves the native deal status off "won" to "lost"first establishes Won via the won-flagged stagerevisit ?state=form, mark Lost + "Lost to competitor"; submitafter Won: agent+native won, stage=wonStage; after correction: agent+native lost, native moved off "won" → "lost", lost_reason "Lost to competitor", stage cleared

quote-follow-up-other-quotes.spec.ts (agent) — Quote Follow-Up

Setup: a fresh Decorative quote (seedDecorativeQuote in beforeAll, gives the agent's cb user id) + an existing feedback-free spare AAE8267 reassigned to the agent (support.setQuoteOwner); spare restored + quote deleted in afterAll; both skip if the spare is unavailable/has feedback.

TestSetup (given)Action (when)Checks (then)
the "other open quotes" section lists another open quote and opens itas the logged-in agentopen the fresh quote's follow-up page, click the spare's tiledata-state="form"; session-only a.qd-myquotes-link and section.qd-other visible; a tile links to the spare; clicking navigates to /quote-follow-up/<spare> and the form loads
a user-scoped grant (NOT logged in) also lists and opens the agent's other open quotea fresh NOT-logged-in context (consent.json) + a minted grant token (support.mintFollowupToken)open the first quote via the token, click the spare's tiledata-state="form"; a.qd-myquotes-link absent (grant ≠ login); section.qd-other shown; the tile has no token (the grant cookie authorises it); clicking loads the spare's form, still no My Quotes link

quote-follow-up-no-change.spec.ts (agent) — Quote Follow-Up

Setup: one shared Decorative quote (seedDecorativeQuote in beforeAll) + deterministic stages; include-internal ON (the agent's @betacalco.com quote); quote put on an Open stage via the real form, that stage's cadence set to '0,0,0'; state read via support.quoteState / collectNudgeDueRow (no sheet, no Pipedrive); gates restored + quote deleted in afterAll.

TestSetup (given)Action (when)Checks (then)
confirming "nothing has changed" records the response and resets the nudge clockquote on the Open seeded stage, due (cadence '0,0,0')re-open the form, click "Nothing has changed"acknowledgement screen shows ("noted" copy); status/stage unchanged; pipedrive_agent_feedback_updated_on advanced; nudge row anchor_source="feedback_updated_on", days_in_stage=0 (the clock restarted)

quote-follow-up-no-change-timestamp.spec.ts (agent) — Quote Follow-Up

Setup: a fresh Decorative quote (seedDecorativeQuote in beforeAll, no follow-up feedback yet) + deterministic stages; read back via support.quoteState (no sheet, no Pipedrive); quote deleted in afterAll.

TestSetup (given)Action (when)Checks (then)
the no-change confirmation stamps the follow-up timestamp to ~nowfresh quote, pipedrive_agent_feedback_updated_on emptyopen the form, click the top "Jump to the bottom" one-liner, then "Nothing has changed".qlp-nochange-jump visible; confirmation shows; pipedrive_agent_feedback_updated_on goes empty → a fresh UTC "now" (within ~2 min of the click)

Agent > Nudge cadence

The "due for a nudge" engine. See docs/quote-follow-up/nudge-cadence.md.

quote-nudge-due.spec.ts (agent) — Nudge cadence

Setup: one shared Decorative quote (seedDecorativeQuote in beforeAll) put on the Open seeded stage via the real form; cadence/gates via support.setStageCadence/setNudgeGates; support.collectNudgeDueRow computes the row + stats (no sheet); gates + quote restored/deleted in afterAll.

TestSetup (given)Action (when)Checks (then)
a stage with no cadence in any bucket is never due (cheap skip)nudge_days_* left NULLcollectNudgeDueRow(serial)due=false; row null; stats.candidates=1, skipped_no_cadence=1
cadence 0 for the bucket → the quote is DUE, with bucket/interval/anchor/link computedsetStageCadence(firstStage,'0,0,0')collectNudgeDueRow(serial)due=true; row: serial, stage=firstStage, value_bucket{small,mid,large}, nudge_interval_days=0, days_in_stage≥0, anchor_source{"stage_changed_on","feedback_updated_on"} (a real stamp, not the created_on fallback), follow_up_link has "token="; stats.due=1
cadence far in the future → NOT due (has not sat in the stage long enough)setStageCadence(firstStage,'9999,9999,9999')collectNudgeDueRow(serial)due=false; row null; stats.skipped_not_due=1
the internal-exclusion gate drops an @betacalco.com (internal) quote even when overduecadence '0,0,0' (overdue) + setNudgeGates{includeInternal:false}collectNudgeDueRow(serial)due=false; row null; stats.skipped_internal=1 (dropped despite overdue)
a Lost quote drops out of the candidate set entirely (native status no longer open)cadence '0,0,0', then marked Lost + "Price" via the real formcollectNudgeDueRow(serial)DB pipedrive_status="lost"; due=false; row null; stats.candidates=0

quote-nudge-sheet-live.spec.ts (agent) — Nudge cadence ★

Setup: ★ opt-in (E2E_LIVE_SMOKE=1) — real Google Sheet write, no Pipedrive. Fresh Decorative quote on a REAL imported non-won stage; cadence edited on that real stage (restored after); support.buildNudgeSheet + latestNudgeSheetRow round-trip the configured sheet; quote deleted + sheet rebuilt in afterAll.

TestSetup (given)Action (when)Checks (then)
a due quote ends up on the follow-up nudge sheet ★setStageCadence(stage,'0,0,0') → duerun support.buildNudgeSheet()build returns a message; the actual sheet row (latestNudgeSheetRow): serial, stage, value_bucket, nudge_interval_days=0, days_in_stage≥0, follow_up_link is an http(s) URL
reconcile: absent while not due, present once due ★same quote/stagecadence far-future + buildNudgeSheet, then '0,0,0' + buildNudgeSheetnot-due → sheet row undefined; once due → reappears with quote_serial=serial

Agent > Stage-sync

The always-on stage-sync recorder + sheet. See docs/quote-follow-up/stage-sync.md.

quote-stage-sync-behaviour.spec.ts (agent) — Stage-sync

Setup: one shared Decorative quote (seedDecorativeQuote in beforeAll) + deterministic stages, taken through a sequence via the real form; rows read with support.stageSyncRows (no sheet, no Pipedrive); deleted in afterAll.

TestSetup (given)Action (when)Checks (then)
a first Open+stage submission records exactly one movement rowstage-sync cleared to a clean slatesubmit Open + firstStage + comment "first pass"1 row: sync_number_per_quote=1, is_latest_sync=1, previous_stage falsy, status Open, stage resolved, source "Follow-up Landing Page", trigger "Follow-up email", updated_by_user_id=agent, comments "first pass", sheet_synced=0
re-submitting the same status+stage is idempotent (no second row)already on firstStage/Openre-submit the same Open + firstStage + "first pass"still 1 row; sync_number_per_quote=1
a comment-only edit amends the pending row in place (still one row)latest row still un-mirroredre-submit same status+stage, comment "amended comment"still 1 row; comments="amended comment"; is_latest_sync=1
an actual stage move appends a second row: numbering, is_latest flip, previous_stage chainingon firstStagesubmit Open + secondStage + "moved along"2 rows: old demoted (is_latest_sync=0), new =1 numbered 2, previous_stage=prior stage, status Open
marking Lost appends a row that carries the stage forward and surfaces the reasonon secondStage/Open (2 rows)mark Lost + "Spec change"; submit3 rows: lost row status Lost, lost_reason "Spec change", is_latest_sync=1, prior demoted, numbered 3; carries last stage + pipeline_id forward

quote-stage-sync-last-feedback.spec.ts (agent) — Stage-sync

Setup: a fresh Decorative quote (seedDecorativeQuote in beforeAll) + deterministic stages, stage-sync cleared to a clean slate; serial mode (the second test builds on the first); rows read with support.stageSyncRows (no sheet, no Pipedrive); relies on rows staying un-mirrored (sheet_synced=0); deleted in afterAll. Covers last_feedback_on (sheet col Z) — see stage-sync.md.

TestSetup (given)Action (when)Checks (then)
a form submission records a row carrying last_feedback_onstage-sync cleared to a clean slatesubmit Open + firstStage via the real form1 row; last_feedback_on truthy and equal to the quote's pipedrive_agent_feedback_updated_on; sheet_synced=0
a "nothing has changed" confirmation advances last_feedback_on without adding a rowthe same un-mirrored row (>1s elapsed)re-open the form, click "Nothing has changed"still 1 row (same id); last_feedback_on strictly later than before and tracking the quote's bumped stamp; status / stage / sync_number_per_quote unchanged

quote-stage-e2e-live.spec.ts (agent) — Stage-sync ★

Setup: ★ opt-in (E2E_LIVE_SMOKE=1) — the only spec with real external writes (real Pipedrive deal + real Sheet). Fresh Decorative quote on REAL imported stages; pinned to a throwaway DEV-org (6034) fixture deal (support.createFixtureDeal); pushed via support.processSerialLive + exportSheet; real deal + local rows deleted in afterAll.

TestSetup (given)Action (when)Checks (then)
editing the project stage lands on the real deal and the real sheet row ★a REAL non-won stage read off the form; quote pinned to a fixture dealset Open + that stage, submit (real UI), then processSerialLive + exportSheetDB: pipedrive_stage_id=chosen, update_scheduled=1; Pipedrive (readDeal): stage_id/pipeline_id=configured, status "open"; sheet (latestSheetRow): serial, stage=name, status Open, is_latest_sync="Y", probability_derived when present

Agent > Doc screenshots

Not a functional spec — an on-demand utility that regenerates the Admin Guide's landing-page images (admin-guide/quote-follow-up/img/). Skipped unless DOC_SCREENSHOTS=1, so a normal run never executes it.

docs-screenshots.spec.ts (agent) — Doc screenshots

Setup: opt-in (DOC_SCREENSHOTS=1); a fresh Decorative quote seeded under a generic project name (no real customer data in a committed image), feedback reset for the pristine form state; deleted (incl. its stage-sync rows) in finally. Images land in tests/doc-screenshots/ (git-ignored) — copy the keepers into the Admin Guide.

TestSetup (given)Action (when)Checks (then)
capture the landing-page states— (file default)render + screenshot: the fresh form, the "Nothing has changed" callout, submit → confirmation card, reload → updated recap, ?state=expiredonly that each state renders (visibility waits); the artifact is the images, not assertions

Anon > Quote access control

The closed side of the 2026-07-25 quote-endpoint hardening (known issues #1/#2): the quote-maker details screen and the quote HTML fragments deny anonymous callers; the cold-quote re-render stays serial-keyed and anonymous by design. See docs/quotes/README.md → "Who may act on a quote".

quote-endpoints-require-auth.spec.ts (anon) — Quote access control

Setup: the shared seeded quote for a real id + serial (the 403 probes hold for any id — rejection is unconditional); plain request calls against /cb-api/… from an empty storage state (truly cookie-less — the replayed consent state is ConfigBox-logged-in, see the guide's gotchas); nothing written, nothing to restore.

TestSetup (given)Action (when)Checks (then)
anonymous saveQuote is rejected before the handler runsany quote idPOST /cb-api/bcqmquotedetails/saveQuote with an editedValues payload403; body has neither the handler's own "No changes are detected." (the pre-fix proof it ran) nor any JSON contract
anonymous quote-details screen (display) is rejectedany quote idGET /cb-api/bcqmquotedetails/display?quoteId=…403
anonymous getQuoteHtml leaks nothingthe seeded quote's id when presentGET /cb-api/bcquote/getQuoteHtml?quoteId=…403; no view-bcquote markup, serial absent
anonymous getPositionsHtml leaks nothingthe seeded quote's id when presentGET /cb-api/bcquote/getPositionsHtml?quoteId=…403; no view-bcquotepositions markup
the cold-quote re-render still works anonymously, by serialthe seeded quote's serial (skipped without one)GET /cb-api/bccoldquote/getQuoteHtml?serial=…200; view-bcquote markup + the requested serial (deliberately anonymous)
the cold-quote re-render 404s an unknown serialGET the same task with a serial matching no quote404

Anon > Quote Follow-Up

The no-login landing page as an emailed link delivers it. See docs/quote-follow-up/landing-page.md.

quote-follow-up-access.spec.ts (anon) — Quote Follow-Up

Setup: the shared seeded quote (readSeededQuote) + minted grant tokens (support.mintFollowupToken); test 3 also does support-API state writes (resetFeedback/seedStages) and leaves the quote as found.

TestSetup (given)Action (when)Checks (then)
a bad token shows the deny banner with "Request a new link" and "Log in" buttonsshared seeded quoteopen the page with an invalid tokendeny screen (no form, data-state=expired, stayed on page) + both recovery buttons; the login button points at the server-resolved login path
an expired token shows the same deny banner and buttonsa validly-signed token backdated past its lifetimeopen with the expired tokenidentical deny screen + both buttons
a valid token lets the agent update, but is NOT a login (My Quotes redirects to the login page)quote left feedback-free + stage picker seeded; grant via token → cookie → clean URLover the grant set Open + stage + date + comments, submit; then go to My QuotesDB: status open, chosen stage, date + comments persisted; no logout link (grant ≠ session); My Quotes redirects to the server-resolved login page (URLs from support.pageUrls()); teardown restores the quote

quote-follow-up-auth.spec.ts (anon) — Quote Follow-Up

Setup: the shared seeded quote (readSeededQuote/SEED_SERIAL), read-only; tokens minted the way an emailed link carries them.

TestSetup (given)Action (when)Checks (then)
a visit without a token shows the login screen firstno tokenopen /quote-follow-up/<serial> with no token/grantno update form; on the Joomla login (URL contains login or a password field present)
a wrong token denies access and offers a new magic login linkinvalid token stringopen ?token=not-a-valid-token, then click .qlp-request-linkdeny screen (no form, data-state=expired); requestNewLink → boolean success; on success a masked *@ email + "on its way", else structured errors
an expired token is treated exactly like a wrong token (deny + new-link offer)a validly-signed token backdated past lifetimeopen with the expired token, then request a new linksame deny screen + same requestNewLink contract
a link for a quote that does not exist shows the friendly "not found" screennon-existent serial DOESNOTEXIST999open /quote-follow-up/DOESNOTEXIST999?token=…HTTP 404; data-state=notfound; no form; no .qlp-request-link; copy "find that quote"

quote-follow-up-token-grant.spec.ts (anon) — Quote Follow-Up

Setup: the shared seeded quote reset feedback-free (resetFeedback) + stages seeded (seedStages) in beforeAll; grant via token → cookie → clean URL; serial block; afterAll clears stages.

TestSetup (given)Action (when)Checks (then)
a valid token authorizes the page but does NOT log the visitor ina fresh minted grant tokenvisit ?token=<token>form visible, data-state=form, token stripped; login-only a.qd-myquotes-link absent; guest login-overlay trigger present + no logout link → no account session
the full update works over the grant: open + stage + date + comments, then the updated recapover the grant, forced ?state=formchoose Open + firstStage, fill date + unique comment, submit; revisit, click "update again"DB: status open, stage, date + comment persisted; revisit data-state=updated; "update again" reveals the form
selecting the won-flagged stage wins the quote over the grant — native status includedover the grant at ?state=formclick the won-flagged stage, submitDB: status won, stage=wonStage, native pipedrive_status also won
Lost requires a reason and records it over the grantover the grant at ?state=formclick Lost, select "Price", submitsubmit disabled until a reason; DB: status lost, lost_reason Price, stage + completion date cleared

quote-follow-up-status.spec.ts (anon) — Quote Follow-Up ★

Setup: the shared seeded quote (readSeededQuote/SEED_SERIAL) or PIPEDRIVE_TEST_SERIAL; page authorised via a minted grant token (not a login); afterEach deletes SEND-mode deal + sync rows.

TestSetup (given)Action (when)Checks (then)
marking "Lost" schedules an update and the change lands on the deal + sheet ★grant token; in SEND mode a DEV-org-6034 fixture deal pinned first (createFixtureDeal)over the grant force ?state=form, click Lost, select "Price", submitDB: update_scheduled=1, status lost, lost_reason Price. Capture: processSerial → addDeal/updateDeal with status=lost (lost_reason=Price on updateDeal), value+org set, flag cleared + date_last_export. SEND (★): poll the real deal → status/lost_reason set, flag cleared, a sync-sheet row with the serial + is_latest_sync=Y

Anon > Follow-up flow

The whole system wired end to end in one spec — the golden path every other follow-up spec proves a slice of: nudge engine finds a quote due & mints its magic link → that emailed link is clicked (not logged in) → the landing-page update is made → the change lands as a stage-sync row (the sync-sheet source of truth) and in the captured Pipedrive deal write. See docs/quote-follow-up/README.mdEnd-to-end flow.

quote-follow-up-nudge-to-deal.spec.ts (anon) — Follow-up flow

Setup: a fresh Decorative quote (seedDecorativeQuote in beforeAll, agent-owned → DEV org 6034) placed on an Open first stage via the real form (makes it nudge-eligible + stamps the days-in-stage clock, and records stage-sync row 1); deterministic stages (support.seedStages); the first stage's cadence flipped to due (setStageCadence '0,0,0') and the internal-exclusion gate off (setNudgeGates, restored in afterAll); the emailed-link click runs in the NOT-logged-in anon page; capture mode (no sheet, no send); quote + stage-sync rows + synthetic stages cleaned up in afterAll; the block is serial.

TestSetup (given)Action (when)Checks (then)
a nudge-emailed link opens the landing page and the update lands as a stage-sync row and a Pipedrive deal write— (file default)collectNudgeDueRow → parse the grant token out of the due row's follow_up_link → open /quote-follow-up/<serial>?token= in the not-logged-in page → over the grant force ?state=form, set Open + a second stage (a real move) + completion date + comment, submit → processSerial (capture)NUDGE: due=true; row quote_serial=serial, stage_id=firstStage, nudge_interval_days=0; follow_up_link carries the serial + a non-trivial token=. LINK: page authorised (data-state form/updated), token stripped, NOT a login (no a.qd-myquotes-link, no logout link). DB: status open, stage=secondStage, completion date + comments + pending comment persisted, pipedrive_update_scheduled=1. STAGE-SYNC: a 2nd, latest movement row — status Open, sync_number_per_quote=2, previous_stage chains firstStage→secondStage, source "Follow-up Landing Page", trigger "Follow-up email", comment recorded, sheet_synced=0. PIPEDRIVE (capture): addDeal/updateDeal with value>0, org=DEV, status open, native stage_id=secondStage, expected_close_date=completion date, quote-serial key; a separate addNote carries the comment + serial; then update_scheduled=0 + date_last_export set + pending comment cleared

The machine Link API that mints a follow-up magic link. See docs/quote-follow-up/link-api-n8n.md.

Setup: the shared seeded quote (agent-owned), reset feedback-free; the API secret cleared to OPEN in beforeAll and restored in afterAll; API calls use the request fixture; the "link works" check opens the URL in page.

TestSetup (given)Action (when)Checks (then)
mints a link that both looks right and actually authorizes the follow-up tool— (file default)GET the Link API with the serial as a path segment, then open the returned URL (no session)HTTP 200; success=true, serial echoed, url = /quote-follow-up/<serial>?token= with a non-trivial token, tokenLifetimeDays a number, expiresAt present; the opened URL shows the form, data-state=form, token stripped, not bounced to login
accepts the serial as a path segment, a query param, and a JSON bodyGET ?serial=<serial> and POST { serial }both HTTP 200; url contains /quote-follow-up/<serial>?token=
returns 404 for an unknown serial and 400 when the serial is missingunknown serial + a serial-less callGET NOSUCHSERIAL999, then GET with no serialunknown → 404 success=false + non-empty errors; missing → 400 success=false
is open with no secret, and requires the secret once one is configuredsecret starts OPEN, then this test sets one (restored in finally)call with no header while open; set a secret, then call with no / wrong / correct X-Bc-Api-Keyopen+no header → 200; with a secret: no header → 401, wrong → 401, correct → 200 success=true

Backend > Admin access

The Joomla backend behind the forced-2FA regime, as the backend automation admin. See guide.md → Test accounts.

backend-admin-2fa-login.spec.ts (backend) — Admin access

Setup: none — a fresh context; the login itself is the subject. Skips when the admin creds or the target's TOTP secret are missing from tests/.env.

TestSetup (given)Action (when)Checks (then)
password + generated TOTP code reaches the admin dashboard— (file default)loginToBackend(page): password form, then the captive challenge answered with a code generated from the stored secretURL is /administrator/index.php with no view=captive/view=methods; the admin sidebar is visible — a real dashboard session, proving automated 2FA access end-to-end

Backend > Branch Cleanup

The Users on Deleted Branches admin page (docs/features/branch-cleanup.md). Read-only coverage on purpose: Reassign and Disable account mutate real user records, and this suite runs against shared environments.

branch-cleanup.spec.ts (backend) — Branch Cleanup

Setup: loginToBackend(page) per test, then the page; readiness is the view-processed class the AMD module stamps on the view. Every test skips itself when the environment has no users on deleted branches, so it is safe on a clean database.

TestSetup (given)Action (when)Checks (then)
page renders with its heading clear of the intro text— (file default)load the page.kenedo-page-title reads "Users on Deleted Branches"; the intro's bounding box starts at/below the heading's bottom edge — custom.css floats the title, so this is checked by geometry, not by trusting the CSS
branch groups start collapsed and nothing is pre-checked— (file default)load the pageno .branch-cleanup-group:not(.collapsed), no visible .group-body, and zero checked .user-check / .check-all — destructive actions are never one click away
a group expands on click and its branch picker becomes searchable— (file default)click the first group's header, then click it againgroup loses collapsed and its body is visible; a Chosen search input is attached (lazy init on first expand); the second click re-collapses it
the "hide disabled" filter removes disabled rows and updates the counta group containing .item-row.account-disabledexpand it, tick Hide accounts that are already disabled, then untickdisabled rows hidden and .user-count drops by exactly the disabled count; unticking restores both the rows and the original count
the filter toggles cleanly with groups collapsed (regression)— groups left in their default collapsed statetick the filter, then untick itgroup-hidden count equals the number of branches with no .active-badge (only fully-disabled branches may vanish, never all of them); unticking clears every group-hidden and no group is lost. Guards the :visible-based bug that hid every group and could not restore them
every row links to the customer record, and to Joomla when an account exists— (file default)expand the first groupone Customer link per row; Joomla links exactly as many as the rows that are not "No login account"
both row links open the right recorda row that has a login accountfollow the row's Customer and Joomla hrefs in new pagesboth return HTTP 200; the customer screen carries an input holding the row's email; the Joomla URL is option=com_users + layout=edit and #jform_email holds the same address — the links resolve to the same person, not just render
the summary counts follow the filter— (file default)tick the filter, then untick it.summary-branch-count equals the groups still shown and .summary-user-count equals the un-filtered rows on screen; unticking returns both to the original totals — the summary never contradicts the list under it
expand all / collapse all drive every group— (file default)click Expand all, then Collapse allno group left collapsed after the first; every group collapsed after the second

Adding or changing a test — checklist

Per the e2e test standard: whenever you add, rename, move, or remove a test, update its documentation in the same change — its per-test Setup/Action/Checks JSDoc and its row here must agree.

  • The spec is under specs/agent/ or specs/anon/; describe is Actor > Feature; the title is a behaviour sentence.
  • File header (@area/@feature/@summary/@preconditions/@covers) + per-test Setup/Action/Checks JSDoc.
  • A row here under the right ### file.spec.ts (project) — Feature section — mechanics explicit (what data, how provisioned, the concrete action, the real checks).
  • npx playwright test --list shows the test under its describe; every title has exactly one row here (no missing, no stale).