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-31Part 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 groupedActor > Feature(the spec'sdescribe);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.
| Test | Setup (given) | Action (when) | Checks (then) |
|---|---|---|---|
| the saved agent session reaches the My Quotes area logged in | — (file default) | navigate to the My Quotes view | body 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.
| Test | Setup (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.
| Test | Setup (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 getMissingSelectionsProduct | the 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.
| Test | Setup (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 → storeProjectName | DB: name = created then renamed + pipedrive_update_scheduled=1 → 0 + 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.
| Test | Setup (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=1 → 0 + 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.
| Test | Setup (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 deal | select 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.
| Test | Setup (given) | Action (when) | Checks (then) |
|---|---|---|---|
| bumping a line item quantity recomputes and pushes a higher deal value | baseline deal value from the first capture after processSerial | fill the quantity input to 3 (debounced → updatePositionQuantity) | DB: update_scheduled=1 → 0 + 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.
| Test | Setup (given) | Action (when) | Checks (then) |
|---|---|---|---|
| copying a line item raises the deal value | baseline value from capture after processSerial | select the line (.trigger-select-position) then copy it (copyPosition) | DB: update_scheduled=1 → 0 + 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.
| Test | Setup (given) | Action (when) | Checks (then) |
|---|---|---|---|
| deleting a line item lowers the deal value | baseline value from capture after processSerial | select the line then delete it (deletePosition) | DB: update_scheduled=1 → 0 + 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.
| Test | Setup (given) | Action (when) | Checks (then) |
|---|---|---|---|
| marking a line item as an alternative flips the deal's "contains alternatives" field | baseline capture asserts HAS_ALT = "No" (174) | tick the enabled input.is-alt-checkbox → updatePositionIsAltFlag | DB: update_scheduled=1 → 0 + 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.
| Test | Setup (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 → submitOotForm | DB: oot_status='registered' + update_scheduled=1 → 0 + 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.
| Test | Setup (given) | Action (when) | Checks (then) |
|---|---|---|---|
| the owner still gets their own quote HTML | the seeded quote's id | GET /cb-api/bcquote/getQuoteHtml?quoteId=… as the agent | 200; view-bcquote markup + the seeded serial |
| the owner still gets their own positions HTML | the seeded quote's id | GET /cb-api/bcquote/getPositionsHtml?quoteId=… as the agent | 200; view-bcquotepositions markup |
| a quote that is not theirs is denied | an id matching no quote (same denial path as a foreign quote, no real foreign data touched) | GET getQuoteHtml with that id as the agent | 403 — 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.
| Test | Setup (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 processSerial | DB: status open, stage, completion date, comment_pending; update_scheduled=1 → 0 + 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.
| Test | Setup (given) | Action (when) | Checks (then) |
|---|---|---|---|
| a fresh quote (no feedback) opens in the "form" state with a stage pre-selected | runs first, before any submission | open /quote-follow-up/<serial> with no ?state | data-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 chosen | at ?state=form | click Lost, then pick a reason | with no reason submit disabled; selecting "Price" enables it |
| selecting the won-flagged stage wins the quote — native deal status included | at ?state=form | click the won-flagged stage; submit | no 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 fields | at ?state=form (prior test left native "won") | Open + firstStage + bid date + completion date; submit | DB: 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 dates | at ?state=form (prior test set both dates) | Lost + "Lead time"; submit | DB: 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 reason | at ?state=form | Lost → "Other", fill free-form; submit | submit 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 form | a fresh Open+stage submission made in-test | revisit 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 agent | at ?state=expired | click "Request a new link"; capture the requestNewLink POST | json.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 404 | unknown serial DOESNOTEXIST999 | navigate to /quote-follow-up/DOESNOTEXIST999 | HTTP 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 finally | navigate to that quote's follow-up page as the non-owner | HTTP 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.
| Test | Setup (given) | Action (when) | Checks (then) |
|---|---|---|---|
| Lost → reopen to Open restores the stage and clears both lost reasons | first submits Lost + "Price" | revisit ?state=form, reopen to Open on firstStage; submit | after 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 stage | revisit ?state=form, mark Lost + "Lost to competitor"; submit | after 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.
| Test | Setup (given) | Action (when) | Checks (then) |
|---|---|---|---|
| the "other open quotes" section lists another open quote and opens it | as the logged-in agent | open the fresh quote's follow-up page, click the spare's tile | data-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 quote | a fresh NOT-logged-in context (consent.json) + a minted grant token (support.mintFollowupToken) | open the first quote via the token, click the spare's tile | data-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.
| Test | Setup (given) | Action (when) | Checks (then) |
|---|---|---|---|
| confirming "nothing has changed" records the response and resets the nudge clock | quote 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.
| Test | Setup (given) | Action (when) | Checks (then) |
|---|---|---|---|
| the no-change confirmation stamps the follow-up timestamp to ~now | fresh quote, pipedrive_agent_feedback_updated_on empty | open 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.
| Test | Setup (given) | Action (when) | Checks (then) |
|---|---|---|---|
| a stage with no cadence in any bucket is never due (cheap skip) | nudge_days_* left NULL | collectNudgeDueRow(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 computed | setStageCadence(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 overdue | cadence '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 form | collectNudgeDueRow(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.
| Test | Setup (given) | Action (when) | Checks (then) |
|---|---|---|---|
| a due quote ends up on the follow-up nudge sheet ★ | setStageCadence(stage,'0,0,0') → due | run 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/stage | cadence far-future + buildNudgeSheet, then '0,0,0' + buildNudgeSheet | not-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.
| Test | Setup (given) | Action (when) | Checks (then) |
|---|---|---|---|
| a first Open+stage submission records exactly one movement row | stage-sync cleared to a clean slate | submit 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/Open | re-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-mirrored | re-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 chaining | on firstStage | submit 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 reason | on secondStage/Open (2 rows) | mark Lost + "Spec change"; submit | 3 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.
| Test | Setup (given) | Action (when) | Checks (then) |
|---|---|---|---|
a form submission records a row carrying last_feedback_on | stage-sync cleared to a clean slate | submit Open + firstStage via the real form | 1 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 row | the 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.
| Test | Setup (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 deal | set Open + that stage, submit (real UI), then processSerialLive + exportSheet | DB: 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.
| Test | Setup (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=expired | only 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.
| Test | Setup (given) | Action (when) | Checks (then) |
|---|---|---|---|
| anonymous saveQuote is rejected before the handler runs | any quote id | POST /cb-api/bcqmquotedetails/saveQuote with an editedValues payload | 403; 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 rejected | any quote id | GET /cb-api/bcqmquotedetails/display?quoteId=… | 403 |
| anonymous getQuoteHtml leaks nothing | the seeded quote's id when present | GET /cb-api/bcquote/getQuoteHtml?quoteId=… | 403; no view-bcquote markup, serial absent |
| anonymous getPositionsHtml leaks nothing | the seeded quote's id when present | GET /cb-api/bcquote/getPositionsHtml?quoteId=… | 403; no view-bcquotepositions markup |
| the cold-quote re-render still works anonymously, by serial | the 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 serial | — | GET the same task with a serial matching no quote | 404 |
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.
| Test | Setup (given) | Action (when) | Checks (then) |
|---|---|---|---|
| a bad token shows the deny banner with "Request a new link" and "Log in" buttons | shared seeded quote | open the page with an invalid token | deny 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 buttons | a validly-signed token backdated past its lifetime | open with the expired token | identical 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 URL | over the grant set Open + stage + date + comments, submit; then go to My Quotes | DB: 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.
| Test | Setup (given) | Action (when) | Checks (then) |
|---|---|---|---|
| a visit without a token shows the login screen first | no token | open /quote-follow-up/<serial> with no token/grant | no 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 link | invalid token string | open ?token=not-a-valid-token, then click .qlp-request-link | deny 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 lifetime | open with the expired token, then request a new link | same deny screen + same requestNewLink contract |
| a link for a quote that does not exist shows the friendly "not found" screen | non-existent serial DOESNOTEXIST999 | open /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.
| Test | Setup (given) | Action (when) | Checks (then) |
|---|---|---|---|
| a valid token authorizes the page but does NOT log the visitor in | a fresh minted grant token | visit ?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 recap | over the grant, forced ?state=form | choose 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 included | over the grant at ?state=form | click the won-flagged stage, submit | DB: status won, stage=wonStage, native pipedrive_status also won |
| Lost requires a reason and records it over the grant | over the grant at ?state=form | click Lost, select "Price", submit | submit 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.
| Test | Setup (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", submit | DB: 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.md → End-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.
| Test | Setup (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 |
Anon > Link API
The machine Link API that mints a follow-up magic link. See docs/quote-follow-up/link-api-n8n.md.
quote-follow-up-link-api.spec.ts (anon) — Link API
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.
| Test | Setup (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 body | — | GET ?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 missing | unknown serial + a serial-less call | GET NOSUCHSERIAL999, then GET with no serial | unknown → 404 success=false + non-empty errors; missing → 400 success=false |
| is open with no secret, and requires the secret once one is configured | secret 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-Key | open+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.
| Test | Setup (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 secret | URL 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.
| Test | Setup (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 page | no .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 again | group 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 count | a group containing .item-row.account-disabled | expand it, tick Hide accounts that are already disabled, then untick | disabled 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 state | tick the filter, then untick it | group-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 group | one Customer link per row; Joomla links exactly as many as the rows that are not "No login account" |
| both row links open the right record | a row that has a login account | follow the row's Customer and Joomla hrefs in new pages | both 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 all | no 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/orspecs/anon/;describeisActor > 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) — Featuresection — mechanics explicit (what data, how provisioned, the concrete action, the real checks). -
npx playwright test --listshows the test under its describe; every title has exactly one row here (no missing, no stale).