From 069ed35b9661c3f0e08b6ba5da43dcd24d69c754 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Fri, 7 Aug 2026 14:56:14 -0300 Subject: [PATCH 01/16] Rework promo status into canonical signals plus display projection Applying a promo code before selecting a ticket left the input spinner running forever: with no ticket to validate against, no validation ever ran, and the status fell through to APPLYING with nothing in flight. Replace the single status enum with canonical signals (isBusy, isInvalid, isSuggested) whose precedence is encoded once, and derive the display status from them (IDLE / SUGGESTED / PROCESSING / APPLIED / INVALID). The spinner now renders only while a request is genuinely in flight; a code applied without a ticket rests as APPLIED and validates once a ticket is picked. --- e2e/promo-code-apply-without-ticket.spec.js | 98 +++++++++++++++++ src/components/promocode-input/index.js | 16 +-- src/components/registration-form/index.js | 8 +- src/hooks/__tests__/usePromoCode.test.js | 114 ++++++++++++++++++-- src/hooks/usePromoCode.js | 54 ++++++---- src/utils/constants.js | 10 +- 6 files changed, 253 insertions(+), 47 deletions(-) create mode 100644 e2e/promo-code-apply-without-ticket.spec.js diff --git a/e2e/promo-code-apply-without-ticket.spec.js b/e2e/promo-code-apply-without-ticket.spec.js new file mode 100644 index 0000000..3524998 --- /dev/null +++ b/e2e/promo-code-apply-without-ticket.spec.js @@ -0,0 +1,98 @@ +const { test, expect } = require('@playwright/test'); +const { + ticketType, + discoveryResponse, + ticketTypesResponse, + taxTypesResponse, + validationResponse, +} = require('./fixtures'); + +// ── Helpers ── + +const setupRoutes = async (page, { discovery = [], tickets = [], taxes = [], validation = null } = {}) => { + await page.route('**/promo-codes/all/discover*', route => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(discoveryResponse(discovery)) }) + ); + + await page.route('**/ticket-types/allowed*', route => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(ticketTypesResponse(tickets)) }) + ); + + await page.route('**/tax-types*', route => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(taxTypesResponse(taxes)) }) + ); + + if (validation) { + await page.route('**/promo-codes/*/apply*', route => + route.fulfill({ + status: validation.status || 200, + contentType: 'application/json', + body: JSON.stringify(validation.body || validationResponse()), + }) + ); + } +}; + +const selectTicket = async (page, ticketName) => { + await page.locator('[data-testid="ticket-dropdown"]').click(); + await page.locator(`[data-testid="ticket-list"] >> text=${ticketName}`).click(); +}; + +// ── Apply before selecting a ticket ── + +// Two ticket types so the post-apply single-ticket auto-select does not kick +// in — the applied code genuinely rests with no ticket picked. +const twoTickets = [ + ticketType(), + ticketType({ id: 189, name: 'General Admission', cost: 900 }), +]; + +// Scoped to the promo input wrapper: the dev harness renders an unrelated +// payment-section spinner elsewhere on the page. +const promoSpinner = (page) => page.locator('[class*="promoCodeInput"] [class*="spinner"]'); + +test.describe('apply promo code before selecting a ticket', () => { + test('settles on checkmark instead of spinning forever', async ({ page }) => { + await setupRoutes(page, { + tickets: twoTickets, + discovery: [], + validation: { status: 200, body: validationResponse() }, + }); + await page.goto('/'); + + // Apply a code with NO ticket selected + await page.fill('input[placeholder="Enter your promo code"]', 'EARLYCODE'); + await page.click('button:has-text("Apply")'); + + // Input locks with the code accepted + await expect(page.locator('input[placeholder="Enter your promo code"][readonly]')).toBeVisible(); + + // Checkmark shown, no promo spinner stuck on screen + await expect(page.locator('text=✓')).toBeVisible(); + await expect(promoSpinner(page)).toHaveCount(0); + }); + + test('validates the code once a ticket is picked afterwards', async ({ page }) => { + let validationCalls = 0; + await setupRoutes(page, { + tickets: twoTickets, + discovery: [], + }); + await page.route('**/promo-codes/*/apply*', route => { + validationCalls += 1; + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(validationResponse()) }); + }); + await page.goto('/'); + + await page.fill('input[placeholder="Enter your promo code"]', 'EARLYCODE'); + await page.click('button:has-text("Apply")'); + await expect(page.locator('text=✓')).toBeVisible(); + expect(validationCalls).toBe(0); + + // Picking a ticket triggers the deferred promo+ticket validation + await selectTicket(page, 'General Admission'); + await expect.poll(() => validationCalls).toBeGreaterThan(0); + await expect(page.locator('text=✓')).toBeVisible(); + await expect(promoSpinner(page)).toHaveCount(0); + }); +}); diff --git a/src/components/promocode-input/index.js b/src/components/promocode-input/index.js index 36bb93a..1a19458 100644 --- a/src/components/promocode-input/index.js +++ b/src/components/promocode-input/index.js @@ -27,9 +27,10 @@ const PromoCodeInput = ({ promoStatus, promoCode, suggestedCode, isAutoApplied, }, [promoCode]); // Lock the input + show Remove (instead of Apply) whenever a code is in flight - // or has settled (valid or invalid). The user must explicitly Remove to edit again. - const isLocked = promoStatus === PROMO_STATUS.APPLYING || promoStatus === PROMO_STATUS.VALIDATING - || promoStatus === PROMO_STATUS.VALID || promoStatus === PROMO_STATUS.INVALID; + // or has settled (applied or invalid). The user must explicitly Remove to edit again. + const isLocked = promoStatus === PROMO_STATUS.PROCESSING + || promoStatus === PROMO_STATUS.APPLIED + || promoStatus === PROMO_STATUS.INVALID; const inputValue = useMemo(() => { if (promoCode) return promoCode; @@ -39,11 +40,10 @@ const PromoCodeInput = ({ promoStatus, promoCode, suggestedCode, isAutoApplied, const label = useMemo(() => { switch (promoStatus) { - case PROMO_STATUS.VALID: + case PROMO_STATUS.APPLIED: if (isAutoApplied) return T.translate('promo_code.auto_applied_label'); return T.translate('promo_code.applied_label'); - case PROMO_STATUS.APPLYING: - case PROMO_STATUS.VALIDATING: + case PROMO_STATUS.PROCESSING: if (isAutoApplied) return T.translate('promo_code.auto_applied_label'); return T.translate('promo_code.applying_label'); case PROMO_STATUS.INVALID: @@ -91,8 +91,8 @@ const PromoCodeInput = ({ promoStatus, promoCode, suggestedCode, isAutoApplied, }} readOnly={isLocked} /> - {(promoStatus === PROMO_STATUS.VALIDATING || promoStatus === PROMO_STATUS.APPLYING) && } - {promoStatus === PROMO_STATUS.VALID && ✓} + {promoStatus === PROMO_STATUS.PROCESSING && } + {promoStatus === PROMO_STATUS.APPLIED && ✓} {promoStatus === PROMO_STATUS.INVALID && ✕}
{isLocked ? diff --git a/src/components/registration-form/index.js b/src/components/registration-form/index.js index 882b9bf..87c30a6 100644 --- a/src/components/registration-form/index.js +++ b/src/components/registration-form/index.js @@ -66,7 +66,7 @@ import TicketTaxesError from '../ticket-taxes-error'; import T from 'i18n-react'; import { getCurrentUserLanguage } from '../../utils/utils'; import { - ADD_TO_CART, BEGIN_CHECKOUT, PURCHASE_COMPLETE, PROMO_STATUS, + ADD_TO_CART, BEGIN_CHECKOUT, PURCHASE_COMPLETE, STEP_COMPLETE, STEP_PAYMENT, STEP_PERSONAL_INFO, @@ -293,11 +293,11 @@ const RegistrationFormContent = ( useEffect(() => { if (!formValues?.promoCode || promoCode - || promoState.status === PROMO_STATUS.SUGGESTED + || promoState.isSuggested || promoState.validationError) { setUnappliedCodeWarning(null); } - }, [formValues?.promoCode, promoCode, promoState.status, promoState.validationError]) + }, [formValues?.promoCode, promoCode, promoState.isSuggested, promoState.validationError]) const [ref, { height }] = useMeasure(); @@ -352,7 +352,7 @@ const RegistrationFormContent = ( } const handleAdvanceFromTicketStep = async (data) => { - if (formValues?.promoCode && !promoCode && promoState.status !== PROMO_STATUS.SUGGESTED) { + if (formValues?.promoCode && !promoCode && !promoState.isSuggested) { setUnappliedCodeWarning(T.translate('promo_code.unapplied_code_warning')); return; } diff --git a/src/hooks/__tests__/usePromoCode.test.js b/src/hooks/__tests__/usePromoCode.test.js index dc8d867..95578ce 100644 --- a/src/hooks/__tests__/usePromoCode.test.js +++ b/src/hooks/__tests__/usePromoCode.test.js @@ -75,25 +75,55 @@ describe('status derivation', () => { expect(result.current.state.status).toBe(PROMO_STATUS.IDLE); }); - it('returns APPLYING when code applied and promoCodeVerified is null', () => { + it('returns PROCESSING when code applied and ticket data has not loaded yet', () => { const { result } = renderHook(() => usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: null })) ); - expect(result.current.state.status).toBe(PROMO_STATUS.APPLYING); + expect(result.current.state.status).toBe(PROMO_STATUS.PROCESSING); }); - it('returns VALIDATING when code applied and promoCodeValidating is true', () => { + it('returns PROCESSING when validation is in flight', () => { const { result } = renderHook(() => usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeValidating: true })) ); - expect(result.current.state.status).toBe(PROMO_STATUS.VALIDATING); + expect(result.current.state.status).toBe(PROMO_STATUS.PROCESSING); }); - it('returns VALID when code applied and promoCodeVerified is true', () => { + it('returns PROCESSING during re-validation after a failed verdict', () => { + // The reducer keeps promoCodeVerified=false while a re-validation is in + // flight, so both signals coexist. In-flight must win over the stale + // verdict: spinner, not error icon. + const { result } = renderHook(() => + usePromoCode(createDefaultProps({ + promoCode: 'CODE', + promoCodeVerified: false, + promoCodeValidating: true, + })) + ); + expect(result.current.state.status).toBe(PROMO_STATUS.PROCESSING); + expect(result.current.state.validationError).toBeNull(); + }); + + it('returns APPLIED when code verified for the selected ticket', () => { const { result } = renderHook(() => usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: true })) ); - expect(result.current.state.status).toBe(PROMO_STATUS.VALID); + expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); + }); + + it('returns APPLIED when code accepted with tickets available but none picked yet', () => { + // Code was applied without a ticket selected: the code-filtered ticket + // list came back non-empty, nothing is in flight, and no per-ticket + // validation has run. This is a resting state, not a spinner state. + const { result } = renderHook(() => + usePromoCode(createDefaultProps({ + promoCode: 'CODE', + promoCodeVerified: null, + ticketDataLoaded: true, + hasTickets: true, + })) + ); + expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); }); it('returns INVALID when code applied and promoCodeVerified is false', () => { @@ -137,21 +167,34 @@ describe('derived values', () => { expect(result.current.state.isReady).toBe(true); }); - it('isReady true for VALID', () => { + it('isReady true for verified code', () => { const { result } = renderHook(() => usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: true })) ); expect(result.current.state.isReady).toBe(true); }); - it('isReady false for APPLYING', () => { + it('isReady true for applied code awaiting ticket selection', () => { + // Promo layer is settled; ticket selection is enforced by its own gate. + const { result } = renderHook(() => + usePromoCode(createDefaultProps({ + promoCode: 'CODE', + promoCodeVerified: null, + ticketDataLoaded: true, + hasTickets: true, + })) + ); + expect(result.current.state.isReady).toBe(true); + }); + + it('isReady false while ticket data is loading', () => { const { result } = renderHook(() => usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: null })) ); expect(result.current.state.isReady).toBe(false); }); - it('isReady false for VALIDATING', () => { + it('isReady false while validation is in flight', () => { const { result } = renderHook(() => usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeValidating: true })) ); @@ -992,7 +1035,7 @@ describe('status: INVALID without ticket', () => { expect(result.current.state.validationError).toBe(T.translate('promo_code.invalid_code')); }); - it('stays APPLYING while ticket data is still loading', () => { + it('stays PROCESSING while ticket data is still loading', () => { const { result } = renderHook(() => usePromoCode(createDefaultProps({ promoCode: 'PENDING', @@ -1001,6 +1044,55 @@ describe('status: INVALID without ticket', () => { hasTickets: false, })) ); - expect(result.current.state.status).toBe(PROMO_STATUS.APPLYING); + expect(result.current.state.status).toBe(PROMO_STATUS.PROCESSING); + }); +}); + +// ── Applied without ticket: transition out on ticket pick ── + +describe('applied code awaiting ticket selection', () => { + it('fires validatePromoCode when the user then picks a ticket', async () => { + const validatePromoCode = jest.fn(() => Promise.resolve()); + const { result } = renderHook(() => + usePromoCode(createDefaultProps({ + promoCode: 'MANUAL', + promoCodeVerified: null, + ticketDataLoaded: true, + hasTickets: true, + validatePromoCode, + })) + ); + expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); + await act(async () => { + await result.current.actions.onTicketSelected({ id: 1, sub_type: 'Regular' }); + }); + expect(validatePromoCode).toHaveBeenCalledWith({ id: 1, ticketQuantity: 1, sub_type: 'Regular' }); + }); +}); + +// ── isSuggested signal (consumed by registration-form) ── + +describe('isSuggested', () => { + it('true while the suggestion banner is showing', async () => { + const { result } = renderHook(() => + usePromoCode(createDefaultProps({ + discoveredPromoCodes: [{ code: 'S1', auto_apply: false, allowed_ticket_types: [{ id: 1 }] }], + })) + ); + await act(async () => { + await result.current.actions.onTicketSelected(mockTicketQualifying); + }); + expect(result.current.state.isSuggested).toBe(true); + }); + + it('false once a code is applied, even if the suggestion was active', async () => { + const { result } = renderHook(() => + usePromoCode(createDefaultProps({ + discoveredPromoCodes: [{ code: 'S1', auto_apply: false, allowed_ticket_types: [{ id: 1 }] }], + promoCode: 'S1', + promoCodeVerified: true, + })) + ); + expect(result.current.state.isSuggested).toBe(false); }); }); diff --git a/src/hooks/usePromoCode.js b/src/hooks/usePromoCode.js index 7c9ed12..1fc9e67 100644 --- a/src/hooks/usePromoCode.js +++ b/src/hooks/usePromoCode.js @@ -40,32 +40,45 @@ const usePromoCode = ({ const isApplied = !!promoCode; const isDiscoveredCode = isApplied && discoveredPromoCode?.code === promoCode; - // --- Status --- - + // --- Canonical signals --- + // The raw Redux signals can overlap (e.g. a stale promoCodeVerified=false + // persists while a re-validation is in flight), so precedence is encoded + // here, once, rather than in each consumer. + + // Something genuinely in flight: applying the code, validating it against + // a ticket, or waiting on the code-filtered ticket list with no settled + // verdict to show in the meantime. + const isBusy = applyingCode || promoCodeValidating + || (isApplied && promoCodeVerified == null && !ticketDataLoaded); + + // Settled rejection: the backend rejected the code for the selected + // ticket, or the code-filtered ticket list came back empty. + const isInvalid = !isBusy && isApplied + && (promoCodeVerified === false || (promoCodeVerified == null && !hasTickets)); + + const isSuggested = !isApplied && suggestionActive && !suggestionDismissed; + + // --- Display status: a pure projection of the signals, total order. + // Gate rendering on this; gate behavior on the signals above. const status = useMemo(() => { - if (isApplied) { - if (promoCodeValidating) return PROMO_STATUS.VALIDATING; - if (promoCodeVerified === true) return PROMO_STATUS.VALID; - if (promoCodeVerified === false) return PROMO_STATUS.INVALID; - // Applied but no tickets returned and not currently applying: code is invalid - if (!applyingCode && ticketDataLoaded && !hasTickets) return PROMO_STATUS.INVALID; - return PROMO_STATUS.APPLYING; - } - if (suggestionActive && !suggestionDismissed) return PROMO_STATUS.SUGGESTED; + if (isBusy) return PROMO_STATUS.PROCESSING; + if (isInvalid) return PROMO_STATUS.INVALID; + if (isApplied) return PROMO_STATUS.APPLIED; + if (isSuggested) return PROMO_STATUS.SUGGESTED; return PROMO_STATUS.IDLE; - }, [isApplied, promoCodeVerified, promoCodeValidating, suggestionActive, suggestionDismissed, applyingCode, ticketDataLoaded, hasTickets]); + }, [isBusy, isInvalid, isApplied, isSuggested]); // Hook's own validation error. Composed from the in-flight API error (if any) - // and the status-derived "invalid code" message when status is INVALID. + // and the "invalid code" message when the code is rejected. // Consumers may layer their own warning on top before display. const validationError = manualError - ?? (status === PROMO_STATUS.INVALID ? T.translate('promo_code.invalid_code') : null); + ?? (isInvalid ? T.translate('promo_code.invalid_code') : null); // --- Derived values --- const suggestedCode = discoveredPromoCode?.code || null; - const activeDiscoveredCode = (status === PROMO_STATUS.VALID && isDiscoveredCode) + const activeDiscoveredCode = (promoCodeVerified === true && !promoCodeValidating && isDiscoveredCode) ? discoveredPromoCode : null; const perAccountLimit = activeDiscoveredCode?.quantity_per_account > 0 @@ -85,10 +98,9 @@ const usePromoCode = ({ }, [activeDiscoveredCode]); // True when the user can safely advance from the ticket step - // (no in-flight promo apply/validate and no INVALID state to block on). - const isReady = status === PROMO_STATUS.IDLE - || status === PROMO_STATUS.SUGGESTED - || status === PROMO_STATUS.VALID; + // (nothing in flight and no rejection to block on). Ticket selection + // is enforced by its own gate. + const isReady = !isBusy && !isInvalid; // --- Discovery: ticket qualification --- @@ -253,9 +265,11 @@ const usePromoCode = ({ return { state: { - // Status (what's happening with the applied/suggested code) + // Display status (pure projection of the signals below) status, + // Canonical signals isReady, + isSuggested, validationError, // True while applyPromoCode is in flight (covers the window where // promoCode is set but the refreshed ticketTypes haven't landed diff --git a/src/utils/constants.js b/src/utils/constants.js index 5d3abd4..ab1cc30 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -47,13 +47,15 @@ export const ERROR_TYPE_ERROR= 'error_type_error'; export const ERROR_TYPE_VALIDATION = 'error_type_validation'; export const ERROR_TYPE_PAYMENT = 'error_type_payment'; -// PROMO CODE STATUS +// PROMO CODE DISPLAY STATUS +// Derived presentation modes, projected in usePromoCode from the canonical +// signals (isApplied / isBusy / isInvalid / isSuggested). Gate rendering on +// these; gate behavior on the signals. export const PROMO_STATUS = { IDLE: 'idle', SUGGESTED: 'suggested', - APPLYING: 'applying', - VALIDATING: 'validating', - VALID: 'valid', + PROCESSING: 'processing', + APPLIED: 'applied', INVALID: 'invalid', }; From 240e2bfcb7182a2abc0cd5bd2bc33970dec5ccda Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Mon, 10 Aug 2026 13:18:08 -0300 Subject: [PATCH 02/16] Distinguish applied from verified promo codes A code applied without a ticket runs no validation, and the catalog comes back populated even for a code that does not exist, so APPLIED rendered a success mark for codes the API never confirmed. Add UNVERIFIED for applied-without-verdict, keep APPLIED for a confirmed one, and block the advance gate while a request error is unresolved. Status icons gain test ids, plus a spec that asserts the spinner during a slow validation so the absence assertions cannot silently stop matching. --- e2e/promo-code-apply-without-ticket.spec.js | 58 +++++++++++++++++---- src/components/promocode-input/index.js | 8 +-- src/hooks/__tests__/usePromoCode.test.js | 54 +++++++++++++++++-- src/hooks/usePromoCode.js | 42 ++++++++------- src/utils/constants.js | 1 + 5 files changed, 125 insertions(+), 38 deletions(-) diff --git a/e2e/promo-code-apply-without-ticket.spec.js b/e2e/promo-code-apply-without-ticket.spec.js index 3524998..81d7008 100644 --- a/e2e/promo-code-apply-without-ticket.spec.js +++ b/e2e/promo-code-apply-without-ticket.spec.js @@ -47,12 +47,11 @@ const twoTickets = [ ticketType({ id: 189, name: 'General Admission', cost: 900 }), ]; -// Scoped to the promo input wrapper: the dev harness renders an unrelated -// payment-section spinner elsewhere on the page. -const promoSpinner = (page) => page.locator('[class*="promoCodeInput"] [class*="spinner"]'); +const promoSpinner = (page) => page.getByTestId('promo-spinner'); +const promoApplied = (page) => page.getByTestId('promo-applied'); test.describe('apply promo code before selecting a ticket', () => { - test('settles on checkmark instead of spinning forever', async ({ page }) => { + test('settles instead of spinning forever', async ({ page }) => { await setupRoutes(page, { tickets: twoTickets, discovery: [], @@ -64,12 +63,13 @@ test.describe('apply promo code before selecting a ticket', () => { await page.fill('input[placeholder="Enter your promo code"]', 'EARLYCODE'); await page.click('button:has-text("Apply")'); - // Input locks with the code accepted + // Input locks with a Remove affordance await expect(page.locator('input[placeholder="Enter your promo code"][readonly]')).toBeVisible(); + await expect(page.locator('button:has-text("Remove")')).toBeVisible(); - // Checkmark shown, no promo spinner stuck on screen - await expect(page.locator('text=✓')).toBeVisible(); + // No spinner. No success mark either: nothing has verified the code. await expect(promoSpinner(page)).toHaveCount(0); + await expect(promoApplied(page)).toHaveCount(0); }); test('validates the code once a ticket is picked afterwards', async ({ page }) => { @@ -86,13 +86,51 @@ test.describe('apply promo code before selecting a ticket', () => { await page.fill('input[placeholder="Enter your promo code"]', 'EARLYCODE'); await page.click('button:has-text("Apply")'); - await expect(page.locator('text=✓')).toBeVisible(); + await expect(page.locator('button:has-text("Remove")')).toBeVisible(); + await expect(promoApplied(page)).toHaveCount(0); expect(validationCalls).toBe(0); - // Picking a ticket triggers the deferred promo+ticket validation + // Picking a ticket triggers the deferred promo+ticket validation, + // which is what promotes the code to verified. await selectTicket(page, 'General Admission'); await expect.poll(() => validationCalls).toBeGreaterThan(0); - await expect(page.locator('text=✓')).toBeVisible(); + await expect(promoApplied(page)).toBeVisible(); + await expect(promoSpinner(page)).toHaveCount(0); + }); + + test('shows the spinner only while validation is in flight', async ({ page }) => { + // The other assertions in this file check the spinner is absent, which + // would also pass if the locator stopped matching anything. Holding the + // validation open pins the spinner with a positive assertion, so a + // renamed or removed icon fails here instead of passing silently. + await setupRoutes(page, { tickets: twoTickets, discovery: [] }); + + // Slow the validation enough to observe the in-flight state. + await page.route('**/promo-codes/*/apply*', async (route) => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(validationResponse()), + }); + }); + + await page.goto('/'); + await page.fill('input[placeholder="Enter your promo code"]', 'EARLYCODE'); + await page.click('button:has-text("Apply")'); + + // Resting unverified: no validation has been requested yet. + await expect(page.locator('button:has-text("Remove")')).toBeVisible(); + await expect(promoSpinner(page)).toHaveCount(0); + + // Selecting a ticket starts the validation, which is now slow enough + // to catch mid-flight. + await selectTicket(page, 'General Admission'); + await expect(promoSpinner(page)).toBeVisible(); + await expect(promoApplied(page)).toHaveCount(0); + + // And it resolves to verified once the request lands. + await expect(promoApplied(page)).toBeVisible(); await expect(promoSpinner(page)).toHaveCount(0); }); }); diff --git a/src/components/promocode-input/index.js b/src/components/promocode-input/index.js index 1a19458..7986c2b 100644 --- a/src/components/promocode-input/index.js +++ b/src/components/promocode-input/index.js @@ -30,6 +30,7 @@ const PromoCodeInput = ({ promoStatus, promoCode, suggestedCode, isAutoApplied, // or has settled (applied or invalid). The user must explicitly Remove to edit again. const isLocked = promoStatus === PROMO_STATUS.PROCESSING || promoStatus === PROMO_STATUS.APPLIED + || promoStatus === PROMO_STATUS.UNVERIFIED || promoStatus === PROMO_STATUS.INVALID; const inputValue = useMemo(() => { @@ -41,6 +42,7 @@ const PromoCodeInput = ({ promoStatus, promoCode, suggestedCode, isAutoApplied, const label = useMemo(() => { switch (promoStatus) { case PROMO_STATUS.APPLIED: + case PROMO_STATUS.UNVERIFIED: if (isAutoApplied) return T.translate('promo_code.auto_applied_label'); return T.translate('promo_code.applied_label'); case PROMO_STATUS.PROCESSING: @@ -91,9 +93,9 @@ const PromoCodeInput = ({ promoStatus, promoCode, suggestedCode, isAutoApplied, }} readOnly={isLocked} /> - {promoStatus === PROMO_STATUS.PROCESSING && } - {promoStatus === PROMO_STATUS.APPLIED && ✓} - {promoStatus === PROMO_STATUS.INVALID && ✕} + {promoStatus === PROMO_STATUS.PROCESSING && } + {promoStatus === PROMO_STATUS.APPLIED && ✓} + {promoStatus === PROMO_STATUS.INVALID && ✕}
{isLocked ? diff --git a/src/hooks/__tests__/usePromoCode.test.js b/src/hooks/__tests__/usePromoCode.test.js index 95578ce..bdd4748 100644 --- a/src/hooks/__tests__/usePromoCode.test.js +++ b/src/hooks/__tests__/usePromoCode.test.js @@ -111,10 +111,12 @@ describe('status derivation', () => { expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); }); - it('returns APPLIED when code accepted with tickets available but none picked yet', () => { - // Code was applied without a ticket selected: the code-filtered ticket - // list came back non-empty, nothing is in flight, and no per-ticket - // validation has run. This is a resting state, not a spinner state. + it('returns UNVERIFIED when code applied with tickets available but none picked yet', () => { + // Code was applied without a ticket selected: the ticket list came back + // non-empty, nothing is in flight, and no per-ticket validation has run. + // A resting state, but not a verified one: the catalog comes back + // populated even for a code that does not exist, so this must not + // render as success. const { result } = renderHook(() => usePromoCode(createDefaultProps({ promoCode: 'CODE', @@ -123,7 +125,29 @@ describe('status derivation', () => { hasTickets: true, })) ); + expect(result.current.state.status).toBe(PROMO_STATUS.UNVERIFIED); + }); + + it('returns UNVERIFIED when a verified code has an outstanding request error', async () => { + // A rate-limited or timed-out re-validation leaves promoCodeVerified at + // its previous value while setting an error. The last verdict no longer + // covers the current selection, so it must not render as success. + const { result } = renderHook(() => + usePromoCode(createDefaultProps({ + promoCode: 'CODE', + promoCodeVerified: true, + ticketDataLoaded: true, + hasTickets: true, + validatePromoCode: jest.fn(() => Promise.reject({ res: { body: { errors: ['Too many requests'] } } })), + })) + ); expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); + + await act(async () => { + await result.current.actions.onTicketSelected({ id: 1, sub_type: 'Regular' }); + }); + expect(result.current.state.status).toBe(PROMO_STATUS.UNVERIFIED); + expect(result.current.state.validationError).toBe('Too many requests'); }); it('returns INVALID when code applied and promoCodeVerified is false', () => { @@ -208,6 +232,26 @@ describe('derived values', () => { expect(result.current.state.isReady).toBe(false); }); + it('isReady false while a request error is unresolved', async () => { + // A failed validation that leaves no verdict must still block the + // advance gate, otherwise the user proceeds on an unverified code. + const { result } = renderHook(() => + usePromoCode(createDefaultProps({ + promoCode: 'CODE', + promoCodeVerified: null, + ticketDataLoaded: true, + hasTickets: true, + validatePromoCode: jest.fn(() => Promise.reject({ res: { body: { errors: ['Too many requests'] } } })), + })) + ); + expect(result.current.state.isReady).toBe(true); + + await act(async () => { + await result.current.actions.onTicketSelected({ id: 1, sub_type: 'Regular' }); + }); + expect(result.current.state.isReady).toBe(false); + }); + it('perAccountLimit from active discovered code when valid', () => { const { result } = renderHook(() => usePromoCode(createDefaultProps({ @@ -1062,7 +1106,7 @@ describe('applied code awaiting ticket selection', () => { validatePromoCode, })) ); - expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); + expect(result.current.state.status).toBe(PROMO_STATUS.UNVERIFIED); await act(async () => { await result.current.actions.onTicketSelected({ id: 1, sub_type: 'Regular' }); }); diff --git a/src/hooks/usePromoCode.js b/src/hooks/usePromoCode.js index 1fc9e67..0bdb3ce 100644 --- a/src/hooks/usePromoCode.js +++ b/src/hooks/usePromoCode.js @@ -25,10 +25,7 @@ const usePromoCode = ({ const [isAutoApplied, setIsAutoApplied] = useState(false); const [suggestionActive, setSuggestionActive] = useState(false); const [suggestionDismissed, setSuggestionDismissed] = useState(false); - // Error written by handleValidationError (API) or the form (unapplied-code warning). - // The user-facing `validationError` is computed below by merging this with the - // status-derived INVALID message. - const [manualError, setManualError] = useState(null); + const [apiError, setApiError] = useState(null); const [applyingCode, setApplyingCode] = useState(false); // Pick first auto_apply code, or first code if none has auto_apply @@ -63,15 +60,20 @@ const usePromoCode = ({ const status = useMemo(() => { if (isBusy) return PROMO_STATUS.PROCESSING; if (isInvalid) return PROMO_STATUS.INVALID; - if (isApplied) return PROMO_STATUS.APPLIED; + // APPLIED requires a backend verdict for the selected ticket. Applying + // a code without a ticket runs no validation, and the catalog comes + // back populated even for a code that does not exist, so anything + // short of a confirmed verdict rests as UNVERIFIED. + if (isApplied) return (promoCodeVerified === true && apiError == null) + ? PROMO_STATUS.APPLIED + : PROMO_STATUS.UNVERIFIED; if (isSuggested) return PROMO_STATUS.SUGGESTED; return PROMO_STATUS.IDLE; - }, [isBusy, isInvalid, isApplied, isSuggested]); + }, [isBusy, isInvalid, isApplied, isSuggested, promoCodeVerified, apiError]); - // Hook's own validation error. Composed from the in-flight API error (if any) - // and the "invalid code" message when the code is rejected. - // Consumers may layer their own warning on top before display. - const validationError = manualError + // Prefers the message the request returned, falling back to a generic + // invalid-code message when the code was rejected without one. + const validationError = apiError ?? (isInvalid ? T.translate('promo_code.invalid_code') : null); // --- Derived values --- @@ -97,10 +99,10 @@ const usePromoCode = ({ return caps.length > 0 ? Math.min(...caps) : null; }, [activeDiscoveredCode]); - // True when the user can safely advance from the ticket step - // (nothing in flight and no rejection to block on). Ticket selection + // True when the user can safely advance from the ticket step: nothing in + // flight, no rejection, and no unresolved request error. Ticket selection // is enforced by its own gate. - const isReady = !isBusy && !isInvalid; + const isReady = !isBusy && !isInvalid && apiError == null; // --- Discovery: ticket qualification --- @@ -121,16 +123,16 @@ const usePromoCode = ({ const msg = /is not a valid code/i.test(firstStr) ? T.translate('promo_code.invalid_code') : firstStr; - setManualError(msg); + setApiError(msg); } else { - setManualError(T.translate('promo_code.validation_error')); + setApiError(T.translate('promo_code.validation_error')); } }, []); // --- Actions --- const onRevalidate = useCallback(async (ticket, quantity) => { - setManualError(null); + setApiError(null); try { await validatePromoCode({ id: ticket.id, ticketQuantity: quantity, sub_type: ticket.sub_type }); return true; @@ -185,7 +187,7 @@ const usePromoCode = ({ // doesn't surface a stale suggestion. if (qualifies) setSuggestionActive(true); setSuggestionDismissed(false); - setManualError(null); + setApiError(null); // Manual (non-discovered) code is applied: re-validate for new ticket if (isApplied && !isDiscoveredCode) { @@ -229,7 +231,7 @@ const usePromoCode = ({ }, [userRemovedAutoApply, ticketDataLoaded, discoveredPromoCode, discoveredPromoCodes, isApplied, tryAutoApply]); const onApply = useCallback(async (code, ticket, quantity) => { - setManualError(null); + setApiError(null); setApplyingCode(true); try { await applyPromoCode(code); @@ -248,7 +250,7 @@ const usePromoCode = ({ if (isAutoApplied || isDiscoveredCode) setUserRemovedAutoApply(true); setIsAutoApplied(false); - setManualError(null); + setApiError(null); setSuggestionDismissed(false); if (discoveredPromoCode) setSuggestionActive(true); @@ -258,7 +260,7 @@ const usePromoCode = ({ }, [isAutoApplied, isDiscoveredCode, discoveredPromoCode, removePromoCode, setFormPromoCode]); const onInputChange = useCallback((value) => { - setManualError(null); + setApiError(null); setSuggestionDismissed(value !== discoveredPromoCode?.code); setFormPromoCode(value); }, [discoveredPromoCode, setFormPromoCode]); diff --git a/src/utils/constants.js b/src/utils/constants.js index ab1cc30..05dacc0 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -55,6 +55,7 @@ export const PROMO_STATUS = { IDLE: 'idle', SUGGESTED: 'suggested', PROCESSING: 'processing', + UNVERIFIED: 'unverified', APPLIED: 'applied', INVALID: 'invalid', }; From 19e2774e713648f04b2b4982cd03021583e7afcc Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Mon, 10 Aug 2026 14:54:19 -0300 Subject: [PATCH 03/16] Ignore promo validations superseded by a ticket switch Switching tickets leaves the earlier validation in flight, and its rejection was writing an error about a ticket the user already left, blocking Next and hiding the verified mark. Only the latest attempt now reports a result, and it owns clearing the auto-applied flag so callers no longer inspect a return value. --- src/hooks/__tests__/usePromoCode.test.js | 48 ++++++++++++++++++++---- src/hooks/usePromoCode.js | 20 +++++----- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/src/hooks/__tests__/usePromoCode.test.js b/src/hooks/__tests__/usePromoCode.test.js index bdd4748..8e48c25 100644 --- a/src/hooks/__tests__/usePromoCode.test.js +++ b/src/hooks/__tests__/usePromoCode.test.js @@ -150,6 +150,41 @@ describe('status derivation', () => { expect(result.current.state.validationError).toBe('Too many requests'); }); + it('ignores a validation response that a later ticket switch superseded', async () => { + // Ticket A's request stays pending while ticket B's succeeds. When A + // then rejects, its error is about a ticket the user already left, so + // it must not surface or block the advance gate. + let rejectFirst; + const validatePromoCode = jest.fn() + .mockImplementationOnce(() => new Promise((_, reject) => { rejectFirst = reject; })) + .mockImplementationOnce(() => Promise.resolve()); + + const { result } = renderHook(() => + usePromoCode(createDefaultProps({ + promoCode: 'CODE', + promoCodeVerified: true, + ticketDataLoaded: true, + hasTickets: true, + validatePromoCode, + })) + ); + + let firstAttempt; + await act(async () => { + firstAttempt = result.current.actions.onTicketSelected({ id: 1, sub_type: 'Regular' }); + await result.current.actions.onTicketSelected({ id: 2, sub_type: 'Regular' }); + }); + + await act(async () => { + rejectFirst({ res: { body: { errors: ['stale failure'] } } }); + await firstAttempt; + }); + + expect(result.current.state.validationError).toBeNull(); + expect(result.current.state.isReady).toBe(true); + expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); + }); + it('returns INVALID when code applied and promoCodeVerified is false', () => { const { result } = renderHook(() => usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: false })) @@ -900,23 +935,22 @@ describe('maxQuantityFromPromo', () => { // ── onRevalidate ── describe('onRevalidate', () => { - it('returns true on successful validation', async () => { + it('validates the ticket and leaves no error on success', async () => { const validatePromoCode = jest.fn(() => Promise.resolve()); const { result } = renderHook(() => usePromoCode(createDefaultProps({ validatePromoCode })) ); - let valid; await act(async () => { - valid = await result.current.actions.onRevalidate(mockTicketQualifying, 3); + await result.current.actions.onRevalidate(mockTicketQualifying, 3); }); - expect(valid).toBe(true); expect(validatePromoCode).toHaveBeenCalledWith( expect.objectContaining({ id: 1, ticketQuantity: 3, sub_type: 'Regular' }) ); + expect(result.current.state.validationError).toBeNull(); }); - it('returns false and sets validationError on failure', async () => { + it('sets validationError on failure', async () => { const validatePromoCode = jest.fn(() => Promise.reject({ res: { body: { errors: ['Promo code X can not be applied more than 3 times.'] } } })); @@ -924,11 +958,9 @@ describe('onRevalidate', () => { usePromoCode(createDefaultProps({ validatePromoCode })) ); - let valid; await act(async () => { - valid = await result.current.actions.onRevalidate(mockTicketQualifying, 5); + await result.current.actions.onRevalidate(mockTicketQualifying, 5); }); - expect(valid).toBe(false); expect(result.current.state.validationError).toBe('Promo code X can not be applied more than 3 times.'); }); diff --git a/src/hooks/usePromoCode.js b/src/hooks/usePromoCode.js index 0bdb3ce..ce188df 100644 --- a/src/hooks/usePromoCode.js +++ b/src/hooks/usePromoCode.js @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo, useEffect } from 'react'; +import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import T from 'i18n-react'; import { PROMO_STATUS } from '../utils/constants'; @@ -131,14 +131,19 @@ const usePromoCode = ({ // --- Actions --- + // A ticket switch can leave an earlier validation in flight. Only the most + // recent attempt may report a result. + const latestValidation = useRef(0); + const onRevalidate = useCallback(async (ticket, quantity) => { + const attempt = ++latestValidation.current; setApiError(null); try { await validatePromoCode({ id: ticket.id, ticketQuantity: quantity, sub_type: ticket.sub_type }); - return true; } catch (e) { + if (attempt !== latestValidation.current) return; handleValidationError(e); - return false; + setIsAutoApplied(false); } }, [validatePromoCode, handleValidationError]); @@ -161,11 +166,7 @@ const usePromoCode = ({ try { await applyPromoCode(discoveredPromoCode.code); if (ticket) { - const valid = await onRevalidate(ticket, 1); - if (!valid) { - setIsAutoApplied(false); - return false; - } + await onRevalidate(ticket, 1); } return true; } catch (e) { @@ -203,8 +204,7 @@ const usePromoCode = ({ // ticket). Previously we silently removed the code on a non-qualifying // pick, which hid the rejection. if (isDiscoveredCode) { - const valid = await onRevalidate(ticket, 1); - if (!valid) setIsAutoApplied(false); + await onRevalidate(ticket, 1); return; } From 1e830466fc982cb63fc070af301214b625ae7ac2 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Mon, 10 Aug 2026 16:49:52 -0300 Subject: [PATCH 04/16] Restore the advance verdict from onRevalidate registration-form gates changeStep on onRevalidate return value, so dropping it left every manually applied promo code unable to leave the ticket step: Next flashed the loader and did nothing. It now reports whether the caller may advance, false for both a rejection and a superseded attempt, which is all the caller distinguishes. Adds the e2e that was missing for this path, so the regression cannot return silently. --- ...romo-code-advance-from-ticket-step.spec.js | 46 +++++++++++++++++++ src/components/registration-form/index.js | 6 +-- src/hooks/__tests__/usePromoCode.test.js | 37 +++++++++++++-- src/hooks/usePromoCode.js | 6 ++- 4 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 e2e/promo-code-advance-from-ticket-step.spec.js diff --git a/e2e/promo-code-advance-from-ticket-step.spec.js b/e2e/promo-code-advance-from-ticket-step.spec.js new file mode 100644 index 0000000..16a8017 --- /dev/null +++ b/e2e/promo-code-advance-from-ticket-step.spec.js @@ -0,0 +1,46 @@ +const { test, expect } = require('@playwright/test'); +const { + ticketType, + discoveryResponse, + ticketTypesResponse, + taxTypesResponse, + validationResponse, +} = require('./fixtures'); + +// Advancing past the ticket step re-validates an applied manual code and gates +// changeStep on the result (registration-form's handleAdvanceFromTicketStep). +// Nothing else covers that path: the other Next-clicking spec types a code +// without applying it, so it exits on the unapplied-code warning first. + +test('Next advances to personal information with an applied promo code', async ({ page }) => { + await page.route('**/promo-codes/all/discover*', route => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(discoveryResponse([])) }) + ); + await page.route('**/ticket-types/allowed*', route => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(ticketTypesResponse([ticketType()])) }) + ); + await page.route('**/tax-types*', route => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(taxTypesResponse()) }) + ); + await page.route('**/promo-codes/*/apply*', route => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(validationResponse()) }) + ); + + await page.goto('/'); + + await page.locator('[data-testid="ticket-dropdown"]').click(); + await page.locator('[data-testid="ticket-list"] >> text=Early Bird Ticket').click(); + + await page.fill('input[placeholder="Enter your promo code"]', 'EARLYCODE'); + await page.click('button:has-text("Apply")'); + await expect(page.getByTestId('promo-applied')).toBeVisible(); + + await page.click('button:has-text("Next")'); + + // Both only render once the ticket step has been left (button-bar gates the + // Back button and the required-fields note on step !== STEP_SELECT_TICKET_TYPE). + // Purchaser Information is not a usable signal here: it is on screen during + // the ticket step too. + await expect(page.locator('button:has-text("Back")')).toBeVisible(); + await expect(page.locator('text=* Required fields')).toBeVisible(); +}); diff --git a/src/components/registration-form/index.js b/src/components/registration-form/index.js index 87c30a6..9b9773a 100644 --- a/src/components/registration-form/index.js +++ b/src/components/registration-form/index.js @@ -359,13 +359,13 @@ const RegistrationFormContent = ( // Re-validate manual codes with final quantity before advancing if (promoCode && !promoState.isDiscoveredCode) { startWidgetLoading(); - let valid = false; + let canAdvance = false; try { - valid = await promoActions.onRevalidate(formValues.ticketType, data.ticketQuantity); + canAdvance = await promoActions.onRevalidate(formValues.ticketType, data.ticketQuantity); } finally { stopWidgetLoading(); } - if (!valid) return; + if (!canAdvance) return; } trackAddToCart(data); changeStep(STEP_PERSONAL_INFO); diff --git a/src/hooks/__tests__/usePromoCode.test.js b/src/hooks/__tests__/usePromoCode.test.js index 8e48c25..3656130 100644 --- a/src/hooks/__tests__/usePromoCode.test.js +++ b/src/hooks/__tests__/usePromoCode.test.js @@ -185,6 +185,29 @@ describe('status derivation', () => { expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); }); + it('does not let a superseded attempt advance the caller', async () => { + let resolveFirst; + const validatePromoCode = jest.fn() + .mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; })) + .mockImplementationOnce(() => Promise.resolve()); + const { result } = renderHook(() => + usePromoCode(createDefaultProps({ validatePromoCode })) + ); + + let firstAttempt; + await act(async () => { + firstAttempt = result.current.actions.onRevalidate({ id: 1, sub_type: 'Regular' }, 1); + await result.current.actions.onRevalidate({ id: 2, sub_type: 'Regular' }, 1); + }); + + let canAdvance; + await act(async () => { + resolveFirst(); + canAdvance = await firstAttempt; + }); + expect(canAdvance).toBe(false); + }); + it('returns INVALID when code applied and promoCodeVerified is false', () => { const { result } = renderHook(() => usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: false })) @@ -935,22 +958,26 @@ describe('maxQuantityFromPromo', () => { // ── onRevalidate ── describe('onRevalidate', () => { - it('validates the ticket and leaves no error on success', async () => { + it('reports the caller may advance on success', async () => { + // registration-form gates changeStep on this value, so a missing or + // wrong return dead-ends the ticket step. const validatePromoCode = jest.fn(() => Promise.resolve()); const { result } = renderHook(() => usePromoCode(createDefaultProps({ validatePromoCode })) ); + let canAdvance; await act(async () => { - await result.current.actions.onRevalidate(mockTicketQualifying, 3); + canAdvance = await result.current.actions.onRevalidate(mockTicketQualifying, 3); }); + expect(canAdvance).toBe(true); expect(validatePromoCode).toHaveBeenCalledWith( expect.objectContaining({ id: 1, ticketQuantity: 3, sub_type: 'Regular' }) ); expect(result.current.state.validationError).toBeNull(); }); - it('sets validationError on failure', async () => { + it('reports the caller may not advance on failure, and sets validationError', async () => { const validatePromoCode = jest.fn(() => Promise.reject({ res: { body: { errors: ['Promo code X can not be applied more than 3 times.'] } } })); @@ -958,9 +985,11 @@ describe('onRevalidate', () => { usePromoCode(createDefaultProps({ validatePromoCode })) ); + let canAdvance; await act(async () => { - await result.current.actions.onRevalidate(mockTicketQualifying, 5); + canAdvance = await result.current.actions.onRevalidate(mockTicketQualifying, 5); }); + expect(canAdvance).toBe(false); expect(result.current.state.validationError).toBe('Promo code X can not be applied more than 3 times.'); }); diff --git a/src/hooks/usePromoCode.js b/src/hooks/usePromoCode.js index ce188df..8a33ced 100644 --- a/src/hooks/usePromoCode.js +++ b/src/hooks/usePromoCode.js @@ -135,15 +135,19 @@ const usePromoCode = ({ // recent attempt may report a result. const latestValidation = useRef(0); + // Returns whether the caller may advance: true only when the code + // validated and no later attempt has replaced this one. const onRevalidate = useCallback(async (ticket, quantity) => { const attempt = ++latestValidation.current; setApiError(null); try { await validatePromoCode({ id: ticket.id, ticketQuantity: quantity, sub_type: ticket.sub_type }); + return attempt === latestValidation.current; } catch (e) { - if (attempt !== latestValidation.current) return; + if (attempt !== latestValidation.current) return false; handleValidationError(e); setIsAutoApplied(false); + return false; } }, [validatePromoCode, handleValidationError]); From 2463c2e30efb7e6ddf433805faac9035c00ab2aa Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Tue, 11 Aug 2026 08:50:06 -0300 Subject: [PATCH 05/16] Ignore Playwright output directories --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 32e7641..cad4ab9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ node_modules coverage *.log .idea/ -.env \ No newline at end of file +.env +test-results +playwright-report From 34198e09dbc7c7b28bb74b2143c068d6334911ed Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Tue, 11 Aug 2026 10:03:07 -0300 Subject: [PATCH 06/16] Let the promo code hook own its validation in-flight flag promoCodeValidating was cleared only by the two actions that also write a verdict, so a validation ending without one (server error, timeout, dropped connection) left the field spinning for the rest of the session. Move the flag into usePromoCode next to applyingCode. getRequest rejects on every failure, so clearing it in the finally covers every outcome, including the ones no reducer case can see. Nothing outside the hook read it, so it comes out of the store entirely. --- e2e/promo-code-discovery.spec.js | 25 ++++++ src/actions.js | 14 ++-- .../__tests__/registration-form.test.js | 1 - src/components/registration-form/index.js | 3 - src/hooks/__tests__/usePromoCode.test.js | 81 +++++++++++++++++-- src/hooks/usePromoCode.js | 14 +++- src/reducer.js | 19 ++--- 7 files changed, 124 insertions(+), 33 deletions(-) diff --git a/e2e/promo-code-discovery.spec.js b/e2e/promo-code-discovery.spec.js index 364dca3..f192d84 100644 --- a/e2e/promo-code-discovery.spec.js +++ b/e2e/promo-code-discovery.spec.js @@ -292,6 +292,31 @@ test.describe('validation errors', () => { await expect(page.locator('text=Promo code XYZ can not be applied to Ticket Type Early Bird Ticket.')).toBeVisible(); }); + test('a server error leaves the field usable instead of stuck processing', async ({ page }) => { + // A 500 carries no verdict, so it never reaches the reducer. The + // in-flight state has to be cleared by whoever awaited the request, + // otherwise the promo field spins for the rest of the session and the + // user can neither retry nor continue. + await setupRoutes(page, { + tickets: [ticketType()], + discovery: [], + validation: { status: 500, body: { message: 'Server error' } }, + }); + await page.goto('/'); + await selectTicket(page, 'Early Bird Ticket'); + await page.fill('input[placeholder="Enter your promo code"]', 'ANYCODE'); + await page.click('button:has-text("Apply")'); + + // uicore surfaces unhandled statuses in its own modal; dismiss it the + // way a user would before checking the field underneath. + const dismiss = page.locator('.swal2-confirm'); + if (await dismiss.isVisible().catch(() => false)) await dismiss.click(); + + // Settled, not spinning, and still operable. + await expect(page.getByTestId('promo-spinner')).toHaveCount(0); + await expect(page.locator('button:has-text("Remove")')).toBeEnabled(); + }); + test('error clears when user types', async ({ page }) => { await setupRoutes(page, { tickets: [ticketType()], diff --git a/src/actions.js b/src/actions.js index 4779099..76982a2 100644 --- a/src/actions.js +++ b/src/actions.js @@ -54,7 +54,6 @@ export const CLEAR_CURRENT_PROMO_CODE = 'CLEAR_CURRENT_PROMO_CODE'; export const VALIDATE_PROMO_CODE = 'VALIDATE_PROMO_CODE'; export const VALIDATE_PROMO_CODE_SUCCESS = 'VALIDATE_PROMO_CODE_SUCCESS'; export const VALIDATE_PROMO_CODE_ERROR = 'VALIDATE_PROMO_CODE_ERROR'; -export const VALIDATE_PROMO_CODE_RATE_LIMITED = 'VALIDATE_PROMO_CODE_RATE_LIMITED'; export const DISCOVER_PROMO_CODES = 'DISCOVER_PROMO_CODES'; export const DISCOVER_PROMO_CODES_SUCCESS = 'DISCOVER_PROMO_CODES_SUCCESS'; @@ -80,11 +79,14 @@ const promoCodeErrorHandler = (err, res) => (dispatch, state) => { dispatch(createAction(VALIDATE_PROMO_CODE_ERROR)({})); return; } - // 429: rate limited - transient, preserve current promo state - if (res && res.statusCode === 429) { - dispatch(createAction(VALIDATE_PROMO_CODE_RATE_LIMITED)({})); - return; - } + // 429: rate limited. Transient, and it says nothing about the code, so + // leave the promo state alone and don't bother the user with it. + if (res && res.statusCode === 429) return; + + // Anything else (a server error, a timeout, a dropped connection, which + // arrives with no response at all) also decides nothing about the code, so + // no verdict is written here either. The caller's promise rejects and the + // hook clears its in-flight flag from there. return authErrorHandler(err, res)(dispatch, state); }; diff --git a/src/components/registration-form/__tests__/registration-form.test.js b/src/components/registration-form/__tests__/registration-form.test.js index c84a91e..9d7a841 100644 --- a/src/components/registration-form/__tests__/registration-form.test.js +++ b/src/components/registration-form/__tests__/registration-form.test.js @@ -186,7 +186,6 @@ const defaultReduxState = { }, promoCode: '', promoCodeVerified: null, - promoCodeValidating: false, promoCodeAllowsReassign: true, discoveredPromoCodes: [], requestedTicketTypes: false, diff --git a/src/components/registration-form/index.js b/src/components/registration-form/index.js index 9b9773a..740e1e1 100644 --- a/src/components/registration-form/index.js +++ b/src/components/registration-form/index.js @@ -163,7 +163,6 @@ const RegistrationFormContent = ( companyDDLOptions2Show, promoCode, promoCodeVerified, - promoCodeValidating, promoCodeAllowsReassign, discoveredPromoCodes, hasDiscount, @@ -268,7 +267,6 @@ const RegistrationFormContent = ( discoveredPromoCodes, promoCode, promoCodeVerified, - promoCodeValidating, applyPromoCode, removePromoCode, validatePromoCode, @@ -572,7 +570,6 @@ const mapStateToProps = ({ registrationLiteState }) => ({ passwordlessCodeError: registrationLiteState.passwordless.error, promoCode: registrationLiteState.promoCode, promoCodeVerified: registrationLiteState.promoCodeVerified, - promoCodeValidating: registrationLiteState.promoCodeValidating, promoCodeAllowsReassign: registrationLiteState.promoCodeAllowsReassign, discoveredPromoCodes: registrationLiteState.discoveredPromoCodes, }) diff --git a/src/hooks/__tests__/usePromoCode.test.js b/src/hooks/__tests__/usePromoCode.test.js index 3656130..73659f1 100644 --- a/src/hooks/__tests__/usePromoCode.test.js +++ b/src/hooks/__tests__/usePromoCode.test.js @@ -33,7 +33,6 @@ const createDefaultProps = (overrides = {}) => ({ discoveredPromoCodes: [], promoCode: '', promoCodeVerified: null, - promoCodeValidating: false, applyPromoCode: jest.fn(() => Promise.resolve()), removePromoCode: jest.fn(), validatePromoCode: jest.fn(() => Promise.resolve()), @@ -41,6 +40,17 @@ const createDefaultProps = (overrides = {}) => ({ ...overrides, }); +// Lets a test hold a validation open and decide when (and how) it ends, so +// in-flight state is produced by an actual pending request rather than asserted +// from a value handed to the hook. +const deferred = () => { + let settle = {}; + const promise = new Promise((resolve, reject) => { settle = { resolve, reject }; }); + // Nothing here awaits a rejection before it is attached below. + promise.catch(() => {}); + return { promise, ...settle }; +}; + // ── Discovery selection ── describe('discovery selection', () => { @@ -82,28 +92,73 @@ describe('status derivation', () => { expect(result.current.state.status).toBe(PROMO_STATUS.PROCESSING); }); - it('returns PROCESSING when validation is in flight', () => { + it('returns PROCESSING while a validation is in flight, and stops when it ends', async () => { + // ticketDataLoaded/promoCodeVerified are set so the other two inputs to + // isBusy are already false: the only thing that can produce PROCESSING + // here is the pending validation itself. + const pending = deferred(); const { result } = renderHook(() => - usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeValidating: true })) + usePromoCode(createDefaultProps({ + promoCode: 'CODE', + promoCodeVerified: true, + ticketDataLoaded: true, + hasTickets: true, + validatePromoCode: jest.fn(() => pending.promise), + })) ); + expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); + + act(() => { result.current.actions.onRevalidate(mockTicketQualifying, 1); }); expect(result.current.state.status).toBe(PROMO_STATUS.PROCESSING); + + await act(async () => { pending.resolve(); }); + expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); }); it('returns PROCESSING during re-validation after a failed verdict', () => { - // The reducer keeps promoCodeVerified=false while a re-validation is in - // flight, so both signals coexist. In-flight must win over the stale - // verdict: spinner, not error icon. + // A rejected verdict stays in the store while a re-validation runs, so + // both signals coexist. In-flight must win over the stale verdict: + // spinner, not error icon. + const pending = deferred(); const { result } = renderHook(() => usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: false, - promoCodeValidating: true, + ticketDataLoaded: true, + hasTickets: true, + validatePromoCode: jest.fn(() => pending.promise), })) ); + expect(result.current.state.status).toBe(PROMO_STATUS.INVALID); + + act(() => { result.current.actions.onRevalidate(mockTicketQualifying, 1); }); expect(result.current.state.status).toBe(PROMO_STATUS.PROCESSING); expect(result.current.state.validationError).toBeNull(); }); + it('stops PROCESSING when the validation fails without a verdict', async () => { + // A server error, a timeout or a dropped connection never reaches the + // reducer. If the hook does not clear its own flag the field spins for + // the rest of the session. + const pending = deferred(); + const { result } = renderHook(() => + usePromoCode(createDefaultProps({ + promoCode: 'CODE', + promoCodeVerified: true, + ticketDataLoaded: true, + hasTickets: true, + validatePromoCode: jest.fn(() => pending.promise), + })) + ); + + act(() => { result.current.actions.onRevalidate(mockTicketQualifying, 1); }); + expect(result.current.state.status).toBe(PROMO_STATUS.PROCESSING); + + await act(async () => { pending.reject({ res: { body: { message: 'Server error' } } }); }); + expect(result.current.state.status).not.toBe(PROMO_STATUS.PROCESSING); + expect(result.current.state.validationError).toBe('Server error'); + }); + it('returns APPLIED when code verified for the selected ticket', () => { const { result } = renderHook(() => usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: true })) @@ -277,9 +332,19 @@ describe('derived values', () => { }); it('isReady false while validation is in flight', () => { + const pending = deferred(); const { result } = renderHook(() => - usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeValidating: true })) + usePromoCode(createDefaultProps({ + promoCode: 'CODE', + promoCodeVerified: true, + ticketDataLoaded: true, + hasTickets: true, + validatePromoCode: jest.fn(() => pending.promise), + })) ); + expect(result.current.state.isReady).toBe(true); + + act(() => { result.current.actions.onRevalidate(mockTicketQualifying, 1); }); expect(result.current.state.isReady).toBe(false); }); diff --git a/src/hooks/usePromoCode.js b/src/hooks/usePromoCode.js index 8a33ced..28951f8 100644 --- a/src/hooks/usePromoCode.js +++ b/src/hooks/usePromoCode.js @@ -7,7 +7,6 @@ const usePromoCode = ({ discoveredPromoCodes, promoCode, promoCodeVerified, - promoCodeValidating, // Redux dispatchers applyPromoCode, @@ -27,6 +26,10 @@ const usePromoCode = ({ const [suggestionDismissed, setSuggestionDismissed] = useState(false); const [apiError, setApiError] = useState(null); const [applyingCode, setApplyingCode] = useState(false); + // In-flight flags belong to whoever awaits the request. Keeping this next + // to applyingCode means every way a validation can end, including the + // failures that never reach the reducer, clears it in one place. + const [validatingCode, setValidatingCode] = useState(false); // Pick first auto_apply code, or first code if none has auto_apply const discoveredPromoCode = useMemo(() => { @@ -45,7 +48,7 @@ const usePromoCode = ({ // Something genuinely in flight: applying the code, validating it against // a ticket, or waiting on the code-filtered ticket list with no settled // verdict to show in the meantime. - const isBusy = applyingCode || promoCodeValidating + const isBusy = applyingCode || validatingCode || (isApplied && promoCodeVerified == null && !ticketDataLoaded); // Settled rejection: the backend rejected the code for the selected @@ -80,7 +83,7 @@ const usePromoCode = ({ const suggestedCode = discoveredPromoCode?.code || null; - const activeDiscoveredCode = (promoCodeVerified === true && !promoCodeValidating && isDiscoveredCode) + const activeDiscoveredCode = (promoCodeVerified === true && !validatingCode && isDiscoveredCode) ? discoveredPromoCode : null; const perAccountLimit = activeDiscoveredCode?.quantity_per_account > 0 @@ -140,6 +143,7 @@ const usePromoCode = ({ const onRevalidate = useCallback(async (ticket, quantity) => { const attempt = ++latestValidation.current; setApiError(null); + setValidatingCode(true); try { await validatePromoCode({ id: ticket.id, ticketQuantity: quantity, sub_type: ticket.sub_type }); return attempt === latestValidation.current; @@ -148,6 +152,10 @@ const usePromoCode = ({ handleValidationError(e); setIsAutoApplied(false); return false; + } finally { + // A later attempt is still running and owns the flag, so leave it + // set for that one to clear. + if (attempt === latestValidation.current) setValidatingCode(false); } }, [validatePromoCode, handleValidationError]); diff --git a/src/reducer.js b/src/reducer.js index b6549f5..6a1468e 100644 --- a/src/reducer.js +++ b/src/reducer.js @@ -37,7 +37,6 @@ import { VALIDATE_PROMO_CODE, VALIDATE_PROMO_CODE_SUCCESS, VALIDATE_PROMO_CODE_ERROR, - VALIDATE_PROMO_CODE_RATE_LIMITED, DISCOVER_PROMO_CODES_SUCCESS, } from './actions'; @@ -69,7 +68,6 @@ const DEFAULT_STATE = { }, promoCode: '', promoCodeVerified: null, - promoCodeValidating: false, promoCodeAllowsReassign: true, discoveredPromoCodes: [], }; @@ -152,10 +150,10 @@ const RegistrationLiteReducer = (state = DEFAULT_STATE, action) => { return { ...state, reservation: null } } case CLEAR_RESERVATION: { - return { ...state, reservation: null, promoCode: '', promoCodeVerified: null, promoCodeValidating: false, promoCodeAllowsReassign: true, discoveredPromoCodes: [] } + return { ...state, reservation: null, promoCode: '', promoCodeVerified: null, promoCodeAllowsReassign: true, discoveredPromoCodes: [] } } case PAY_RESERVATION: { - return { ...state, checkout: payload.response, reservation: null, userProfile: null, invitation: null, promoCode: '', promoCodeVerified: null, promoCodeValidating: false, promoCodeAllowsReassign: true, discoveredPromoCodes: [] }; + return { ...state, checkout: payload.response, reservation: null, userProfile: null, invitation: null, promoCode: '', promoCodeVerified: null, promoCodeAllowsReassign: true, discoveredPromoCodes: [] }; } case GET_MY_INVITATION: { return { ...state, invitation: payload.response }; @@ -164,24 +162,21 @@ const RegistrationLiteReducer = (state = DEFAULT_STATE, action) => { return { ...state, invitation: null }; } case CLEAR_CURRENT_PROMO_CODE: { - return { ...state, promoCode: '', promoCodeVerified: null, promoCodeValidating: false, promoCodeAllowsReassign: true } + return { ...state, promoCode: '', promoCodeVerified: null, promoCodeAllowsReassign: true } } case SET_CURRENT_PROMO_CODE:{ const { currentPromoCode } = payload; - return { ...state, promoCode: currentPromoCode, promoCodeVerified: null, promoCodeValidating: false, promoCodeAllowsReassign: true } + return { ...state, promoCode: currentPromoCode, promoCodeVerified: null, promoCodeAllowsReassign: true } } case VALIDATE_PROMO_CODE: { - return { ...state, promoCodeValidating: true } + return state } case VALIDATE_PROMO_CODE_SUCCESS: { const { allows_to_reassign } = payload.response; - return { ...state, promoCodeVerified: true, promoCodeValidating: false, promoCodeAllowsReassign: allows_to_reassign ?? true } + return { ...state, promoCodeVerified: true, promoCodeAllowsReassign: allows_to_reassign ?? true } } case VALIDATE_PROMO_CODE_ERROR: { - return { ...state, promoCodeVerified: false, promoCodeValidating: false, promoCodeAllowsReassign: true } - } - case VALIDATE_PROMO_CODE_RATE_LIMITED: { - return { ...state, promoCodeValidating: false } + return { ...state, promoCodeVerified: false, promoCodeAllowsReassign: true } } case DISCOVER_PROMO_CODES_SUCCESS: { return { ...state, discoveredPromoCodes: payload.response?.data || [] } From be094dbc2492fc0185f195e83a1184b70dc2158e Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Tue, 11 Aug 2026 10:54:53 -0300 Subject: [PATCH 07/16] Let the user retry a promo code validation that failed A failed request fed isReady, which disables the Next button, so a server error or timeout left the button dead: the user could neither retry the validation nor continue, with only a Remove to fall back on. Drop it from isReady and re-validate on advance instead, refusing to move on unless it succeeds. The user cannot proceed on an unverified code, and pressing Next is now the retry. Advancing re-validates discovered codes too. Their quantity caps make them as quantity-dependent as a manually entered one, and skipping them left the only codes that can be auto-applied with no way back from a failure. --- ...romo-code-advance-from-ticket-step.spec.js | 77 +++++++++++++++++++ src/components/registration-form/index.js | 7 +- src/hooks/__tests__/usePromoCode.test.js | 31 +++++++- src/hooks/usePromoCode.js | 12 ++- 4 files changed, 117 insertions(+), 10 deletions(-) diff --git a/e2e/promo-code-advance-from-ticket-step.spec.js b/e2e/promo-code-advance-from-ticket-step.spec.js index 16a8017..aad9202 100644 --- a/e2e/promo-code-advance-from-ticket-step.spec.js +++ b/e2e/promo-code-advance-from-ticket-step.spec.js @@ -12,6 +12,83 @@ const { // Nothing else covers that path: the other Next-clicking spec types a code // without applying it, so it exits on the unapplied-code warning first. +// Routes everything the ticket step needs, letting the caller decide how each +// successive validation call responds. +const setup = async (page, validationResponses) => { + let call = 0; + await page.route('**/promo-codes/all/discover*', route => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(discoveryResponse([])) }) + ); + await page.route('**/ticket-types/allowed*', route => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(ticketTypesResponse([ticketType()])) }) + ); + await page.route('**/tax-types*', route => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(taxTypesResponse()) }) + ); + await page.route('**/promo-codes/*/apply*', route => { + // Last entry repeats, so a single-entry list means "always this". + const next = validationResponses[Math.min(call++, validationResponses.length - 1)]; + route.fulfill({ + status: next.status, + contentType: 'application/json', + body: JSON.stringify(next.body ?? validationResponse()), + }); + }); +}; + +// uicore raises its own modal for statuses it does not recognise. It overlays +// the form and swallows clicks, so wait for it and clear it the way a user +// would before touching anything underneath. Waiting rather than sampling +// matters: the modal renders a beat after the response lands, and an +// instantaneous check can miss it and leave it covering the next click. +const dismissServerErrorModal = async (page) => { + const confirm = page.locator('.swal2-confirm'); + await confirm.waitFor({ state: 'visible' }); + await confirm.click(); + await expect(page.locator('.swal2-container')).toHaveCount(0); +}; + +const applyCode = async (page, code) => { + await page.locator('[data-testid="ticket-dropdown"]').click(); + await page.locator('[data-testid="ticket-list"] >> text=Early Bird Ticket').click(); + await page.fill('input[placeholder="Enter your promo code"]', code); + await page.click('button:has-text("Apply")'); +}; + +test('Next retries a validation that failed transiently, and advances once it succeeds', async ({ page }) => { + // A server error decides nothing about the code, so it must not strand the + // user: pressing Next again has to re-run the validation rather than sit on + // a dead button. + await setup(page, [{ status: 500, body: { message: 'Server error' } }, { status: 200 }]); + await page.goto('/'); + + await applyCode(page, 'EARLYCODE'); + await dismissServerErrorModal(page); + + const next = page.locator('button:has-text("Next")'); + await expect(next).toBeEnabled(); + await next.click(); + + await expect(page.locator('button:has-text("Back")')).toBeVisible(); + await expect(page.locator('text=* Required fields')).toBeVisible(); +}); + +test('Next does not advance while validation keeps failing', async ({ page }) => { + // The retry must not become a way through on an unverified code. + await setup(page, [{ status: 500, body: { message: 'Server error' } }]); + await page.goto('/'); + + await applyCode(page, 'EARLYCODE'); + await dismissServerErrorModal(page); + + await page.locator('button:has-text("Next")').click(); + await dismissServerErrorModal(page); + + // Still on the ticket step: neither of these renders until it is left. + await expect(page.locator('button:has-text("Back")')).toHaveCount(0); + await expect(page.locator('text=* Required fields')).toHaveCount(0); +}); + test('Next advances to personal information with an applied promo code', async ({ page }) => { await page.route('**/promo-codes/all/discover*', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(discoveryResponse([])) }) diff --git a/src/components/registration-form/index.js b/src/components/registration-form/index.js index 740e1e1..92dc434 100644 --- a/src/components/registration-form/index.js +++ b/src/components/registration-form/index.js @@ -354,8 +354,11 @@ const RegistrationFormContent = ( setUnappliedCodeWarning(T.translate('promo_code.unapplied_code_warning')); return; } - // Re-validate manual codes with final quantity before advancing - if (promoCode && !promoState.isDiscoveredCode) { + // Re-validate the applied code against the final quantity before + // advancing. This is also the retry for a validation that failed + // earlier, so it covers discovered codes too: their quantity caps make + // them just as quantity-dependent as a manually entered one. + if (promoCode) { startWidgetLoading(); let canAdvance = false; try { diff --git a/src/hooks/__tests__/usePromoCode.test.js b/src/hooks/__tests__/usePromoCode.test.js index 73659f1..f376e1c 100644 --- a/src/hooks/__tests__/usePromoCode.test.js +++ b/src/hooks/__tests__/usePromoCode.test.js @@ -355,9 +355,11 @@ describe('derived values', () => { expect(result.current.state.isReady).toBe(false); }); - it('isReady false while a request error is unresolved', async () => { - // A failed validation that leaves no verdict must still block the - // advance gate, otherwise the user proceeds on an unverified code. + it('isReady stays true after a request error so the user can retry', async () => { + // A failed request leaves no verdict, so it must not latch the gate + // shut: the user has to be able to try again. Not proceeding on an + // unverified code is enforced by re-validating on advance and refusing + // to move on unless it succeeds, which is covered end to end. const { result } = renderHook(() => usePromoCode(createDefaultProps({ promoCode: 'CODE', @@ -372,7 +374,28 @@ describe('derived values', () => { await act(async () => { await result.current.actions.onTicketSelected({ id: 1, sub_type: 'Regular' }); }); - expect(result.current.state.isReady).toBe(false); + expect(result.current.state.isReady).toBe(true); + // The failure is still reported, it just doesn't disable the gate. + expect(result.current.state.validationError).toBe('Too many requests'); + }); + + it('onRevalidate refuses to advance when the request fails', async () => { + // The gate that replaced isReady's error check. + const { result } = renderHook(() => + usePromoCode(createDefaultProps({ + promoCode: 'CODE', + promoCodeVerified: null, + ticketDataLoaded: true, + hasTickets: true, + validatePromoCode: jest.fn(() => Promise.reject({ res: { body: { message: 'Server error' } } })), + })) + ); + + let canAdvance; + await act(async () => { + canAdvance = await result.current.actions.onRevalidate({ id: 1, sub_type: 'Regular' }, 1); + }); + expect(canAdvance).toBe(false); }); it('perAccountLimit from active discovered code when valid', () => { diff --git a/src/hooks/usePromoCode.js b/src/hooks/usePromoCode.js index 28951f8..6f469ae 100644 --- a/src/hooks/usePromoCode.js +++ b/src/hooks/usePromoCode.js @@ -102,10 +102,14 @@ const usePromoCode = ({ return caps.length > 0 ? Math.min(...caps) : null; }, [activeDiscoveredCode]); - // True when the user can safely advance from the ticket step: nothing in - // flight, no rejection, and no unresolved request error. Ticket selection - // is enforced by its own gate. - const isReady = !isBusy && !isInvalid && apiError == null; + // True when the user may attempt to advance from the ticket step: nothing + // in flight and no rejection. Ticket selection is enforced by its own gate. + // + // A failed request deliberately does not block here. It says nothing about + // the code, and leaving the gate shut would stop the user retrying the very + // thing that failed. Advancing re-validates and refuses to move on unless + // that succeeds, so an unverified code still cannot get through. + const isReady = !isBusy && !isInvalid; // --- Discovery: ticket qualification --- From 1dc156d3382c42f37656a4607acbe8c221b13192 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Tue, 11 Aug 2026 10:55:00 -0300 Subject: [PATCH 08/16] Wait for the error modal in the promo code spec instead of sampling for it The modal renders a beat after the response lands, so an instantaneous visibility check could miss it and leave it overlaying the form, where it swallowed the next click and timed the test out under load. --- e2e/promo-code-discovery.spec.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/e2e/promo-code-discovery.spec.js b/e2e/promo-code-discovery.spec.js index f192d84..bfa49ed 100644 --- a/e2e/promo-code-discovery.spec.js +++ b/e2e/promo-code-discovery.spec.js @@ -307,10 +307,14 @@ test.describe('validation errors', () => { await page.fill('input[placeholder="Enter your promo code"]', 'ANYCODE'); await page.click('button:has-text("Apply")'); - // uicore surfaces unhandled statuses in its own modal; dismiss it the - // way a user would before checking the field underneath. - const dismiss = page.locator('.swal2-confirm'); - if (await dismiss.isVisible().catch(() => false)) await dismiss.click(); + // uicore surfaces unhandled statuses in its own modal, which overlays + // the form. Wait for it rather than sampling: it renders a beat after + // the response lands, and dismissing it is what a user would do before + // looking at the field underneath. + const confirm = page.locator('.swal2-confirm'); + await confirm.waitFor({ state: 'visible' }); + await confirm.click(); + await expect(page.locator('.swal2-container')).toHaveCount(0); // Settled, not spinning, and still operable. await expect(page.getByTestId('promo-spinner')).toHaveCount(0); From 521ac077b330954def37326975eda2c8aeb8f636 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Tue, 11 Aug 2026 10:57:53 -0300 Subject: [PATCH 09/16] Report auto-apply failure from tryAutoApply onRevalidate signals a failed validation by returning false rather than throwing, so tryAutoApply returned true whether or not the code it had just applied verified, contradicting its documented contract. --- src/hooks/usePromoCode.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/hooks/usePromoCode.js b/src/hooks/usePromoCode.js index 6f469ae..f8694b6 100644 --- a/src/hooks/usePromoCode.js +++ b/src/hooks/usePromoCode.js @@ -181,10 +181,10 @@ const usePromoCode = ({ setApplyingCode(true); try { await applyPromoCode(discoveredPromoCode.code); - if (ticket) { - await onRevalidate(ticket, 1); - } - return true; + // onRevalidate reports a failed validation by returning false + // rather than throwing, so its result has to be passed on or this + // reports success for a code that was never verified. + return ticket ? await onRevalidate(ticket, 1) : true; } catch (e) { setIsAutoApplied(false); handleValidationError(e); From 9ad75dfad7cd33cb29c76090b4192862e023d632 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Tue, 11 Aug 2026 11:00:38 -0300 Subject: [PATCH 10/16] Stop exposing isDiscoveredCode from usePromoCode Its last consumer went away when advancing started re-validating every applied code. It stays as an internal value; only the export and the tests reading it through the returned state go, so it no longer reads as covered API that nothing calls. --- src/hooks/__tests__/usePromoCode.test.js | 40 ------------------------ src/hooks/usePromoCode.js | 1 - 2 files changed, 41 deletions(-) diff --git a/src/hooks/__tests__/usePromoCode.test.js b/src/hooks/__tests__/usePromoCode.test.js index f376e1c..c6ebecc 100644 --- a/src/hooks/__tests__/usePromoCode.test.js +++ b/src/hooks/__tests__/usePromoCode.test.js @@ -873,46 +873,6 @@ describe('validationError', () => { }); -// ── isDiscoveredCode ── - -describe('isDiscoveredCode', () => { - it('true when applied code matches discovered code', () => { - const { result } = renderHook(() => - usePromoCode(createDefaultProps({ - discoveredPromoCodes: mockDiscoveredCodes, - promoCode: 'AUTO1', - promoCodeVerified: true, - })) - ); - expect(result.current.state.isDiscoveredCode).toBe(true); - }); - - it('false when applied code does not match discovered code', () => { - const { result } = renderHook(() => - usePromoCode(createDefaultProps({ - discoveredPromoCodes: mockDiscoveredCodes, - promoCode: 'MANUAL', - promoCodeVerified: true, - })) - ); - expect(result.current.state.isDiscoveredCode).toBe(false); - }); - - it('false when no code applied', () => { - const { result } = renderHook(() => - usePromoCode(createDefaultProps({ discoveredPromoCodes: mockDiscoveredCodes })) - ); - expect(result.current.state.isDiscoveredCode).toBe(false); - }); - - it('false when no discovered codes', () => { - const { result } = renderHook(() => - usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: true })) - ); - expect(result.current.state.isDiscoveredCode).toBe(false); - }); -}); - // ── maxQuantityFromPromo ── describe('maxQuantityFromPromo', () => { diff --git a/src/hooks/usePromoCode.js b/src/hooks/usePromoCode.js index f8694b6..5d7cad6 100644 --- a/src/hooks/usePromoCode.js +++ b/src/hooks/usePromoCode.js @@ -296,7 +296,6 @@ const usePromoCode = ({ applyingCode, // Applied code origin - isDiscoveredCode, isAutoApplied, // Discovery / suggestion From 5e7b8c82e86a75b3deabfd8a6525e6dc7dee74e3 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Tue, 11 Aug 2026 11:39:55 -0300 Subject: [PATCH 11/16] Stop the promo code apply flag hanging on an aborted request onApply cleared applyingCode only after awaiting revalidation. uicore aborts an in-flight request as soon as a newer one targets the same URL, and superagent never invokes the callback for an aborted request, so that promise neither resolves nor rejects and the flag was never cleared: the field spun forever and Next stayed disabled until a reload. Re-picking the already-selected ticket during an apply is enough to trigger it. The flag covers the apply request, which has already resolved by then, so clear it without waiting on revalidation, which tracks itself. Start the revalidation first so the two busy windows overlap; React 16 does not batch these, and clearing first renders a frame with neither set. --- src/hooks/__tests__/usePromoCode.test.js | 68 ++++++++++++++++++++++++ src/hooks/usePromoCode.js | 11 ++-- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/hooks/__tests__/usePromoCode.test.js b/src/hooks/__tests__/usePromoCode.test.js index c6ebecc..b3c471c 100644 --- a/src/hooks/__tests__/usePromoCode.test.js +++ b/src/hooks/__tests__/usePromoCode.test.js @@ -159,6 +159,74 @@ describe('status derivation', () => { expect(result.current.state.validationError).toBe('Server error'); }); + it('does not strand the applying flag when a validation never settles', async () => { + // uicore aborts an in-flight request as soon as a newer one targets the + // same URL, and superagent does not invoke the callback for an aborted + // request, so that promise neither resolves nor rejects. Any flag + // cleared only after awaiting it stays set for the rest of the session. + // Recovery comes from the newer request, which is the one that caused + // the abort, so nothing may depend on the abandoned one finishing. + const abandoned = new Promise(() => {}); + const newer = deferred(); + let call = 0; + const validatePromoCode = jest.fn(() => (call++ === 0 ? abandoned : newer.promise)); + + const { result } = renderHook(() => + usePromoCode(createDefaultProps({ + promoCode: 'CODE', + promoCodeVerified: true, + ticketDataLoaded: true, + hasTickets: true, + validatePromoCode, + })) + ); + + // Deliberately not awaited: this call never returns. + await act(async () => { result.current.actions.onApply('CODE', mockTicketQualifying, 1); }); + expect(result.current.state.status).toBe(PROMO_STATUS.PROCESSING); + + // The newer request lands. It clears its own in-flight flag, and with + // nothing else stuck the field settles. + await act(async () => { + result.current.actions.onRevalidate(mockTicketQualifying, 1); + newer.resolve(); + }); + expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); + }); + + it('stays busy across the handoff from applying to validating', async () => { + // Two flags cover consecutive stretches of one user action. React 16 + // does not batch these updates, so clearing the first before the second + // is set renders a frame with neither: the spinner blinks off and Next + // is briefly clickable mid-apply. + const statuses = []; + const pending = deferred(); + const { result } = renderHook(() => { + const promo = usePromoCode(createDefaultProps({ + promoCode: 'CODE', + promoCodeVerified: true, + ticketDataLoaded: true, + hasTickets: true, + validatePromoCode: jest.fn(() => pending.promise), + })); + statuses.push(promo.state.status); + return promo; + }); + + await act(async () => { result.current.actions.onApply('CODE', mockTicketQualifying, 1); }); + + // From the moment it goes busy until the validation settles, every + // rendered frame must still be busy. + const firstBusy = statuses.indexOf(PROMO_STATUS.PROCESSING); + expect(firstBusy).toBeGreaterThanOrEqual(0); + expect(statuses.slice(firstBusy)).toEqual( + statuses.slice(firstBusy).map(() => PROMO_STATUS.PROCESSING) + ); + + await act(async () => { pending.resolve(); }); + expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); + }); + it('returns APPLIED when code verified for the selected ticket', () => { const { result } = renderHook(() => usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: true })) diff --git a/src/hooks/usePromoCode.js b/src/hooks/usePromoCode.js index 5d7cad6..be1dd49 100644 --- a/src/hooks/usePromoCode.js +++ b/src/hooks/usePromoCode.js @@ -256,10 +256,15 @@ const usePromoCode = ({ setApplyingCode(false); return; } - if (ticket) { - await onRevalidate(ticket, quantity); - } + // This flag covers the apply request; revalidation has its own. Start + // revalidation before clearing this one so the two overlap: cleared + // first, there is a render with neither set and the field drops out of + // its busy state and back in. Clearing it only after awaiting the + // revalidation is worse still, because an aborted request never settles + // and the flag would never clear at all. + const revalidating = ticket ? onRevalidate(ticket, quantity) : null; setApplyingCode(false); + await revalidating; }, [applyPromoCode, onRevalidate, handleValidationError]); const onRemove = useCallback(() => { From a11fee538d6b0630fc1b77ffd2eb541a2f1a6da3 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Tue, 11 Aug 2026 13:07:49 -0300 Subject: [PATCH 12/16] Let the promo code hook own the validation verdict The reducer wrote whatever answer arrived last, while the hook's attempt counter decided which one the UI should trust, so a validation superseded by a ticket switch could still overwrite the newer answer. Keep the verdict where the request is awaited, so that counter is the only thing that may write one, and applying or removing a code clears it at the point that changes the code. The reducer keeps the applied code and the discovered codes. Advancing no longer counts a validation that never ran as success: with no code applied the request is skipped, and skipping is not a verdict. --- src/actions.js | 19 +- src/components/registration-form/index.js | 9 +- src/hooks/__tests__/usePromoCode.test.js | 207 +++++++++++----------- src/hooks/usePromoCode.js | 42 ++++- src/reducer.js | 25 +-- 5 files changed, 150 insertions(+), 152 deletions(-) diff --git a/src/actions.js b/src/actions.js index 76982a2..fd24e83 100644 --- a/src/actions.js +++ b/src/actions.js @@ -73,20 +73,11 @@ export const clearWidgetState = () => (dispatch) => { } const promoCodeErrorHandler = (err, res) => (dispatch, state) => { - // 404: promo code or ticket type not found - // 412: promo code invalid for this ticket type/qty - if (res && [404, 412].includes(res.statusCode)) { - dispatch(createAction(VALIDATE_PROMO_CODE_ERROR)({})); - return; - } - // 429: rate limited. Transient, and it says nothing about the code, so - // leave the promo state alone and don't bother the user with it. - if (res && res.statusCode === 429) return; - - // Anything else (a server error, a timeout, a dropped connection, which - // arrives with no response at all) also decides nothing about the code, so - // no verdict is written here either. The caller's promise rejects and the - // hook clears its in-flight flag from there. + // 404 and 412 are the API judging the code, and 429 is transient. None of + // them is an auth or session problem, and the caller sees the rejection + // either way, so there is nothing to escalate. + if (res && [404, 412, 429].includes(res.statusCode)) return; + return authErrorHandler(err, res)(dispatch, state); }; diff --git a/src/components/registration-form/index.js b/src/components/registration-form/index.js index 92dc434..1a6f3ec 100644 --- a/src/components/registration-form/index.js +++ b/src/components/registration-form/index.js @@ -162,8 +162,6 @@ const RegistrationFormContent = ( showCompanyInputDefaultOptions, companyDDLOptions2Show, promoCode, - promoCodeVerified, - promoCodeAllowsReassign, discoveredPromoCodes, hasDiscount, getTicketDiscount, @@ -266,7 +264,6 @@ const RegistrationFormContent = ( const promo = usePromoCode({ discoveredPromoCodes, promoCode, - promoCodeVerified, applyPromoCode, removePromoCode, validatePromoCode, @@ -450,7 +447,7 @@ const RegistrationFormContent = ( promo={promo} validationError={ticketStepError} promoCode={promoCode} - promoCodeAllowsReassign={promoCodeAllowsReassign} + promoCodeAllowsReassign={promoState.allowsReassign} changeForm={mergeFormValues} trackViewItem={trackViewItem} showMultipleTicketTexts={showMultipleTicketTexts} @@ -495,7 +492,7 @@ const RegistrationFormContent = ( companyDDLPlaceholder={companyDDLPlaceholder} showCompanyInputDefaultOptions={showCompanyInputDefaultOptions} companyDDLOptions2Show={companyDDLOptions2Show} - promoCodeAllowsReassign={promoCodeAllowsReassign} + promoCodeAllowsReassign={promoState.allowsReassign} /> @@ -572,8 +569,6 @@ const mapStateToProps = ({ registrationLiteState }) => ({ passwordlessCodeSent: registrationLiteState.passwordless.code_sent, passwordlessCodeError: registrationLiteState.passwordless.error, promoCode: registrationLiteState.promoCode, - promoCodeVerified: registrationLiteState.promoCodeVerified, - promoCodeAllowsReassign: registrationLiteState.promoCodeAllowsReassign, discoveredPromoCodes: registrationLiteState.discoveredPromoCodes, }) diff --git a/src/hooks/__tests__/usePromoCode.test.js b/src/hooks/__tests__/usePromoCode.test.js index b3c471c..97a39a8 100644 --- a/src/hooks/__tests__/usePromoCode.test.js +++ b/src/hooks/__tests__/usePromoCode.test.js @@ -32,7 +32,6 @@ const mockTicketNonQualifying = { id: 99, sub_type: 'Regular' }; const createDefaultProps = (overrides = {}) => ({ discoveredPromoCodes: [], promoCode: '', - promoCodeVerified: null, applyPromoCode: jest.fn(() => Promise.resolve()), removePromoCode: jest.fn(), validatePromoCode: jest.fn(() => Promise.resolve()), @@ -40,6 +39,43 @@ const createDefaultProps = (overrides = {}) => ({ ...overrides, }); +// The verdict is the hook's own state, reached only by validating, so a test +// that needs a verified code has to earn one the way the app does. This renders +// the hook and runs one successful validation against it. +const renderVerified = async (overrides = {}, response = {}) => { + const view = renderHook(() => + usePromoCode(createDefaultProps({ + promoCode: 'CODE', + ticketDataLoaded: true, + hasTickets: true, + validatePromoCode: jest.fn(() => Promise.resolve({ response })), + ...overrides, + })) + ); + await act(async () => { + await view.result.current.actions.onRevalidate(mockTicketQualifying, 1); + }); + return view; +}; + +// Same, for a code the API turned down. 412 is the status that means "does not +// apply to this ticket type or quantity". +const renderRejected = async (overrides = {}, body = {}) => { + const view = renderHook(() => + usePromoCode(createDefaultProps({ + promoCode: 'CODE', + ticketDataLoaded: true, + hasTickets: true, + validatePromoCode: jest.fn(() => Promise.reject({ res: { statusCode: 412, body } })), + ...overrides, + })) + ); + await act(async () => { + await view.result.current.actions.onRevalidate(mockTicketQualifying, 1); + }); + return view; +}; + // Lets a test hold a validation open and decide when (and how) it ends, so // in-flight state is produced by an actual pending request rather than asserted // from a value handed to the hook. @@ -61,7 +97,7 @@ describe('discovery selection', () => { expect(result.current.state.suggestedCode).toBe('AUTO1'); }); - it('falls back to first code when none has auto_apply', () => { + it('falls back to first code when none has auto_apply', async () => { const codes = [{ code: 'A', auto_apply: false }, { code: 'B', auto_apply: false }]; const { result } = renderHook(() => usePromoCode(createDefaultProps({ discoveredPromoCodes: codes })) @@ -97,38 +133,30 @@ describe('status derivation', () => { // isBusy are already false: the only thing that can produce PROCESSING // here is the pending validation itself. const pending = deferred(); - const { result } = renderHook(() => - usePromoCode(createDefaultProps({ - promoCode: 'CODE', - promoCodeVerified: true, - ticketDataLoaded: true, - hasTickets: true, - validatePromoCode: jest.fn(() => pending.promise), - })) - ); + const { result } = await renderVerified({ + validatePromoCode: jest.fn() + .mockImplementationOnce(() => Promise.resolve({ response: {} })) + .mockImplementationOnce(() => pending.promise), + }); expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); act(() => { result.current.actions.onRevalidate(mockTicketQualifying, 1); }); expect(result.current.state.status).toBe(PROMO_STATUS.PROCESSING); - await act(async () => { pending.resolve(); }); + await act(async () => { pending.resolve({ response: {} }); }); expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); }); - it('returns PROCESSING during re-validation after a failed verdict', () => { + it('returns PROCESSING during re-validation after a failed verdict', async () => { // A rejected verdict stays in the store while a re-validation runs, so // both signals coexist. In-flight must win over the stale verdict: // spinner, not error icon. const pending = deferred(); - const { result } = renderHook(() => - usePromoCode(createDefaultProps({ - promoCode: 'CODE', - promoCodeVerified: false, - ticketDataLoaded: true, - hasTickets: true, - validatePromoCode: jest.fn(() => pending.promise), - })) - ); + const { result } = await renderRejected({ + validatePromoCode: jest.fn() + .mockImplementationOnce(() => Promise.reject({ res: { statusCode: 412, body: {} } })) + .mockImplementationOnce(() => pending.promise), + }); expect(result.current.state.status).toBe(PROMO_STATUS.INVALID); act(() => { result.current.actions.onRevalidate(mockTicketQualifying, 1); }); @@ -174,7 +202,6 @@ describe('status derivation', () => { const { result } = renderHook(() => usePromoCode(createDefaultProps({ promoCode: 'CODE', - promoCodeVerified: true, ticketDataLoaded: true, hasTickets: true, validatePromoCode, @@ -189,7 +216,7 @@ describe('status derivation', () => { // nothing else stuck the field settles. await act(async () => { result.current.actions.onRevalidate(mockTicketQualifying, 1); - newer.resolve(); + newer.resolve({ response: {} }); }); expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); }); @@ -204,7 +231,6 @@ describe('status derivation', () => { const { result } = renderHook(() => { const promo = usePromoCode(createDefaultProps({ promoCode: 'CODE', - promoCodeVerified: true, ticketDataLoaded: true, hasTickets: true, validatePromoCode: jest.fn(() => pending.promise), @@ -223,14 +249,12 @@ describe('status derivation', () => { statuses.slice(firstBusy).map(() => PROMO_STATUS.PROCESSING) ); - await act(async () => { pending.resolve(); }); + await act(async () => { pending.resolve({ response: {} }); }); expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); }); - it('returns APPLIED when code verified for the selected ticket', () => { - const { result } = renderHook(() => - usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: true })) - ); + it('returns APPLIED when code verified for the selected ticket', async () => { + const { result } = await renderVerified(); expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); }); @@ -255,15 +279,11 @@ describe('status derivation', () => { // A rate-limited or timed-out re-validation leaves promoCodeVerified at // its previous value while setting an error. The last verdict no longer // covers the current selection, so it must not render as success. - const { result } = renderHook(() => - usePromoCode(createDefaultProps({ - promoCode: 'CODE', - promoCodeVerified: true, - ticketDataLoaded: true, - hasTickets: true, - validatePromoCode: jest.fn(() => Promise.reject({ res: { body: { errors: ['Too many requests'] } } })), - })) - ); + const { result } = await renderVerified({ + validatePromoCode: jest.fn() + .mockImplementationOnce(() => Promise.resolve({ response: {} })) + .mockImplementationOnce(() => Promise.reject({ res: { body: { errors: ['Too many requests'] } } })), + }); expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); await act(async () => { @@ -280,12 +300,11 @@ describe('status derivation', () => { let rejectFirst; const validatePromoCode = jest.fn() .mockImplementationOnce(() => new Promise((_, reject) => { rejectFirst = reject; })) - .mockImplementationOnce(() => Promise.resolve()); + .mockImplementationOnce(() => Promise.resolve({ response: {} })); const { result } = renderHook(() => usePromoCode(createDefaultProps({ promoCode: 'CODE', - promoCodeVerified: true, ticketDataLoaded: true, hasTickets: true, validatePromoCode, @@ -331,13 +350,31 @@ describe('status derivation', () => { expect(canAdvance).toBe(false); }); - it('returns INVALID when code applied and promoCodeVerified is false', () => { - const { result } = renderHook(() => - usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: false })) - ); + it('returns INVALID when the API turned the code down', async () => { + const { result } = await renderRejected(); + expect(result.current.state.status).toBe(PROMO_STATUS.INVALID); + }); + + it.each([404, 412])('treats %s as the API rejecting the code', async (statusCode) => { + const { result } = await renderRejected({ + validatePromoCode: jest.fn(() => Promise.reject({ res: { statusCode, body: {} } })), + }); expect(result.current.state.status).toBe(PROMO_STATUS.INVALID); }); + it.each([429, 500, 503, undefined])('does not report the code as rejected on %s', async (statusCode) => { + // These decide nothing about the code. Showing INVALID would tell the + // user their code is bad when nothing ever judged it. undefined stands + // for a request that produced no response at all. + const { result } = await renderRejected({ + validatePromoCode: jest.fn(() => Promise.reject( + statusCode === undefined ? {} : { res: { statusCode, body: {} } } + )), + }); + expect(result.current.state.status).not.toBe(PROMO_STATUS.INVALID); + expect(result.current.state.status).toBe(PROMO_STATUS.UNVERIFIED); + }); + it('returns SUGGESTED after selecting qualifying ticket with discovered codes', async () => { const { result } = renderHook(() => usePromoCode(createDefaultProps({ discoveredPromoCodes: [{ code: 'S1', auto_apply: false, allowed_ticket_types: [{ id: 1 }] }] })) @@ -372,10 +409,8 @@ describe('derived values', () => { expect(result.current.state.isReady).toBe(true); }); - it('isReady true for verified code', () => { - const { result } = renderHook(() => - usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: true })) - ); + it('isReady true for verified code', async () => { + const { result } = await renderVerified(); expect(result.current.state.isReady).toBe(true); }); @@ -466,14 +501,8 @@ describe('derived values', () => { expect(canAdvance).toBe(false); }); - it('perAccountLimit from active discovered code when valid', () => { - const { result } = renderHook(() => - usePromoCode(createDefaultProps({ - discoveredPromoCodes: mockDiscoveredCodes, - promoCode: 'AUTO1', - promoCodeVerified: true, - })) - ); + it('perAccountLimit from active discovered code when valid', async () => { + const { result } = await renderVerified({ discoveredPromoCodes: mockDiscoveredCodes, promoCode: 'AUTO1' }); expect(result.current.state.perAccountLimit).toBe(4); }); @@ -944,19 +973,13 @@ describe('validationError', () => { // ── maxQuantityFromPromo ── describe('maxQuantityFromPromo', () => { - it('returns tightest cap from remaining_quantity_per_account and quantity_available', () => { - const { result } = renderHook(() => - usePromoCode(createDefaultProps({ - discoveredPromoCodes: mockDiscoveredCodes, - promoCode: 'AUTO1', - promoCodeVerified: true, - })) - ); + it('returns tightest cap from remaining_quantity_per_account and quantity_available', async () => { + const { result } = await renderVerified({ discoveredPromoCodes: mockDiscoveredCodes, promoCode: 'AUTO1' }); // remaining_quantity_per_account=4, quantity_available=100 → min is 4 expect(result.current.state.maxQuantityFromPromo).toBe(4); }); - it('uses quantity_available when it is tighter', () => { + it('uses quantity_available when it is tighter', async () => { const codes = [{ code: 'LIMITED', auto_apply: true, @@ -965,13 +988,7 @@ describe('maxQuantityFromPromo', () => { remaining_quantity_per_account: 8, quantity_available: 3, }]; - const { result } = renderHook(() => - usePromoCode(createDefaultProps({ - discoveredPromoCodes: codes, - promoCode: 'LIMITED', - promoCodeVerified: true, - })) - ); + const { result } = await renderVerified({ discoveredPromoCodes: codes, promoCode: 'LIMITED' }); // remaining=8, quantity_available=3 → min is 3 expect(result.current.state.maxQuantityFromPromo).toBe(3); }); @@ -994,7 +1011,7 @@ describe('maxQuantityFromPromo', () => { expect(result.current.state.maxQuantityFromPromo).toBeNull(); }); - it('uses only remaining_quantity_per_account when quantity_available is null (unlimited)', () => { + it('uses only remaining_quantity_per_account when quantity_available is null (unlimited)', async () => { const codes = [{ code: 'UNLIM', auto_apply: true, @@ -1003,17 +1020,11 @@ describe('maxQuantityFromPromo', () => { remaining_quantity_per_account: 3, quantity_available: null, }]; - const { result } = renderHook(() => - usePromoCode(createDefaultProps({ - discoveredPromoCodes: codes, - promoCode: 'UNLIM', - promoCodeVerified: true, - })) - ); + const { result } = await renderVerified({ discoveredPromoCodes: codes, promoCode: 'UNLIM' }); expect(result.current.state.maxQuantityFromPromo).toBe(3); }); - it('caps at 0 when quantity_available is 0 (sold out)', () => { + it('caps at 0 when quantity_available is 0 (sold out)', async () => { const codes = [{ code: 'SOLDOUT', auto_apply: true, @@ -1022,17 +1033,11 @@ describe('maxQuantityFromPromo', () => { remaining_quantity_per_account: 3, quantity_available: 0, }]; - const { result } = renderHook(() => - usePromoCode(createDefaultProps({ - discoveredPromoCodes: codes, - promoCode: 'SOLDOUT', - promoCodeVerified: true, - })) - ); + const { result } = await renderVerified({ discoveredPromoCodes: codes, promoCode: 'SOLDOUT' }); expect(result.current.state.maxQuantityFromPromo).toBe(0); }); - it('uses only quantity_available when remaining_quantity_per_account is null', () => { + it('uses only quantity_available when remaining_quantity_per_account is null', async () => { const codes = [{ code: 'NOACCOUNTLIMIT', auto_apply: true, @@ -1041,17 +1046,11 @@ describe('maxQuantityFromPromo', () => { remaining_quantity_per_account: null, quantity_available: 5, }]; - const { result } = renderHook(() => - usePromoCode(createDefaultProps({ - discoveredPromoCodes: codes, - promoCode: 'NOACCOUNTLIMIT', - promoCodeVerified: true, - })) - ); + const { result } = await renderVerified({ discoveredPromoCodes: codes, promoCode: 'NOACCOUNTLIMIT' }); expect(result.current.state.maxQuantityFromPromo).toBe(5); }); - it('null when both limits are unlimited', () => { + it('null when both limits are unlimited', async () => { const codes = [{ code: 'ALLFREE', auto_apply: true, @@ -1060,13 +1059,7 @@ describe('maxQuantityFromPromo', () => { remaining_quantity_per_account: null, quantity_available: null, }]; - const { result } = renderHook(() => - usePromoCode(createDefaultProps({ - discoveredPromoCodes: codes, - promoCode: 'ALLFREE', - promoCodeVerified: true, - })) - ); + const { result } = await renderVerified({ discoveredPromoCodes: codes, promoCode: 'ALLFREE' }); expect(result.current.state.maxQuantityFromPromo).toBeNull(); }); }); @@ -1077,9 +1070,9 @@ describe('onRevalidate', () => { it('reports the caller may advance on success', async () => { // registration-form gates changeStep on this value, so a missing or // wrong return dead-ends the ticket step. - const validatePromoCode = jest.fn(() => Promise.resolve()); + const validatePromoCode = jest.fn(() => Promise.resolve({ response: {} })); const { result } = renderHook(() => - usePromoCode(createDefaultProps({ validatePromoCode })) + usePromoCode(createDefaultProps({ promoCode: 'CODE', validatePromoCode })) ); let canAdvance; diff --git a/src/hooks/usePromoCode.js b/src/hooks/usePromoCode.js index be1dd49..a18a27c 100644 --- a/src/hooks/usePromoCode.js +++ b/src/hooks/usePromoCode.js @@ -2,11 +2,17 @@ import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import T from 'i18n-react'; import { PROMO_STATUS } from '../utils/constants'; +// 404: the code or the ticket type does not exist. +// 412: the code does not apply to this ticket type or quantity. +// Both are the API judging the code. Anything else -- a server error, a rate +// limit, a timeout, a dropped connection, which arrives with no response at all +// -- says nothing about whether the code is good. +const isRejection = (e) => [404, 412].includes(e?.res?.statusCode); + const usePromoCode = ({ // Redux state discoveredPromoCodes, promoCode, - promoCodeVerified, // Redux dispatchers applyPromoCode, @@ -31,6 +37,18 @@ const usePromoCode = ({ // failures that never reach the reducer, clears it in one place. const [validatingCode, setValidatingCode] = useState(false); + // What the API decided about the applied code, owned here because this is + // what awaits the request and so the only thing that knows which attempt is + // still the current one. Held in the store it took a second record of that + // ordering to stay in step with this one, and the two could disagree. + // + // null means undecided: either nothing has been validated yet, or the last + // attempt failed in a way that decided nothing. + const [verdict, setVerdict] = useState(null); + + const promoCodeVerified = verdict === null ? null : verdict.verified; + const allowsReassign = verdict?.allowsReassign ?? true; + // Pick first auto_apply code, or first code if none has auto_apply const discoveredPromoCode = useMemo(() => { if (!discoveredPromoCodes?.length) return null; @@ -149,10 +167,19 @@ const usePromoCode = ({ setApiError(null); setValidatingCode(true); try { - await validatePromoCode({ id: ticket.id, ticketQuantity: quantity, sub_type: ticket.sub_type }); - return attempt === latestValidation.current; + const result = await validatePromoCode({ id: ticket.id, ticketQuantity: quantity, sub_type: ticket.sub_type }); + if (attempt !== latestValidation.current) return false; + // No code applied means no request went out, so nothing was decided + // and the caller has nothing to advance on. + if (!result) return false; + setVerdict({ verified: true, allowsReassign: result.response?.allows_to_reassign ?? true }); + return true; } catch (e) { if (attempt !== latestValidation.current) return false; + // Only these mean the API judged the code and turned it down. + // Every other failure decided nothing, so the previous verdict, or + // the absence of one, stands and the error is surfaced instead. + if (isRejection(e)) setVerdict({ verified: false, allowsReassign: true }); handleValidationError(e); setIsAutoApplied(false); return false; @@ -179,6 +206,7 @@ const usePromoCode = ({ const tryAutoApply = useCallback(async (ticket) => { setIsAutoApplied(true); setApplyingCode(true); + setVerdict(null); try { await applyPromoCode(discoveredPromoCode.code); // onRevalidate reports a failed validation by returning false @@ -249,6 +277,9 @@ const usePromoCode = ({ const onApply = useCallback(async (code, ticket, quantity) => { setApiError(null); setApplyingCode(true); + // A different application of a code, even the same string, has not been + // judged yet; whatever was decided about the last one does not carry. + setVerdict(null); try { await applyPromoCode(code); } catch (e) { @@ -272,6 +303,7 @@ const usePromoCode = ({ setIsAutoApplied(false); setApiError(null); + setVerdict(null); setSuggestionDismissed(false); if (discoveredPromoCode) setSuggestionActive(true); @@ -300,6 +332,10 @@ const usePromoCode = ({ // until this clears to avoid acting on a stale list. applyingCode, + // False only when the API said so for the applied code, so it + // defaults open while nothing has been decided. + allowsReassign, + // Applied code origin isAutoApplied, diff --git a/src/reducer.js b/src/reducer.js index 6a1468e..a41a32d 100644 --- a/src/reducer.js +++ b/src/reducer.js @@ -34,9 +34,6 @@ import { LOAD_PROFILE_DATA, SET_CURRENT_PROMO_CODE, CLEAR_CURRENT_PROMO_CODE, - VALIDATE_PROMO_CODE, - VALIDATE_PROMO_CODE_SUCCESS, - VALIDATE_PROMO_CODE_ERROR, DISCOVER_PROMO_CODES_SUCCESS, } from './actions'; @@ -67,8 +64,6 @@ const DEFAULT_STATE = { userProfile: null, }, promoCode: '', - promoCodeVerified: null, - promoCodeAllowsReassign: true, discoveredPromoCodes: [], }; @@ -100,8 +95,6 @@ const RegistrationLiteReducer = (state = DEFAULT_STATE, action) => { taxTypes: [], invitation: null, promoCode: '', - promoCodeVerified: null, - promoCodeAllowsReassign: true, discoveredPromoCodes: [], passwordless: { ...DEFAULT_STATE.passwordless }, settings: { @@ -150,10 +143,10 @@ const RegistrationLiteReducer = (state = DEFAULT_STATE, action) => { return { ...state, reservation: null } } case CLEAR_RESERVATION: { - return { ...state, reservation: null, promoCode: '', promoCodeVerified: null, promoCodeAllowsReassign: true, discoveredPromoCodes: [] } + return { ...state, reservation: null, promoCode: '', discoveredPromoCodes: [] } } case PAY_RESERVATION: { - return { ...state, checkout: payload.response, reservation: null, userProfile: null, invitation: null, promoCode: '', promoCodeVerified: null, promoCodeAllowsReassign: true, discoveredPromoCodes: [] }; + return { ...state, checkout: payload.response, reservation: null, userProfile: null, invitation: null, promoCode: '', discoveredPromoCodes: [] }; } case GET_MY_INVITATION: { return { ...state, invitation: payload.response }; @@ -162,21 +155,11 @@ const RegistrationLiteReducer = (state = DEFAULT_STATE, action) => { return { ...state, invitation: null }; } case CLEAR_CURRENT_PROMO_CODE: { - return { ...state, promoCode: '', promoCodeVerified: null, promoCodeAllowsReassign: true } + return { ...state, promoCode: '' } } case SET_CURRENT_PROMO_CODE:{ const { currentPromoCode } = payload; - return { ...state, promoCode: currentPromoCode, promoCodeVerified: null, promoCodeAllowsReassign: true } - } - case VALIDATE_PROMO_CODE: { - return state - } - case VALIDATE_PROMO_CODE_SUCCESS: { - const { allows_to_reassign } = payload.response; - return { ...state, promoCodeVerified: true, promoCodeAllowsReassign: allows_to_reassign ?? true } - } - case VALIDATE_PROMO_CODE_ERROR: { - return { ...state, promoCodeVerified: false, promoCodeAllowsReassign: true } + return { ...state, promoCode: currentPromoCode } } case DISCOVER_PROMO_CODES_SUCCESS: { return { ...state, discoveredPromoCodes: payload.response?.data || [] } From 9fbc16b7de28bcba1cfd1f347c8980c9fcf4cd88 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Tue, 11 Aug 2026 14:29:11 -0300 Subject: [PATCH 13/16] Abandon a promo code validation when its code stops being applied Clearing the verdict was not enough on its own: the request still running kept the field busy and kept the right to write a verdict, so removing a code left the input read-only behind a spinner with Next disabled until that request landed, and its answer was then attributed to whatever the user had done since. Applying a code had the same gap, a whole round trip wide, because only the validation that followed advanced the counter. Withdraw the request instead: advance the attempt counter, clear the in-flight flag and drop the verdict together, wherever the applied code changes. Advancing the counter is what takes away the right to answer, so this has to happen in one place rather than at each site. The store also blanks the code without going through the hook, on checkout, logout and a cleared reservation, so do the same when it does. Two tests were passing for the wrong reason: one resolved a superseded attempt with no body, so the guard against a missing response was killing it rather than the supersede check, and one never reached the state its name claimed. --- src/hooks/__tests__/usePromoCode.test.js | 92 +++++++++++++++++++++--- src/hooks/usePromoCode.js | 34 +++++++-- 2 files changed, 111 insertions(+), 15 deletions(-) diff --git a/src/hooks/__tests__/usePromoCode.test.js b/src/hooks/__tests__/usePromoCode.test.js index 97a39a8..b095fa2 100644 --- a/src/hooks/__tests__/usePromoCode.test.js +++ b/src/hooks/__tests__/usePromoCode.test.js @@ -43,14 +43,15 @@ const createDefaultProps = (overrides = {}) => ({ // that needs a verified code has to earn one the way the app does. This renders // the hook and runs one successful validation against it. const renderVerified = async (overrides = {}, response = {}) => { - const view = renderHook(() => + const view = renderHook((props) => usePromoCode(createDefaultProps({ promoCode: 'CODE', ticketDataLoaded: true, hasTickets: true, validatePromoCode: jest.fn(() => Promise.resolve({ response })), ...overrides, - })) + ...props, + })), { initialProps: {} } ); await act(async () => { await view.result.current.actions.onRevalidate(mockTicketQualifying, 1); @@ -87,6 +88,78 @@ const deferred = () => { return { promise, ...settle }; }; +// ── Abandoning a validation ── + +// A validation is only about the code that was applied when it started. These +// cover what has to happen to one that is still running when the user changes +// that code: it stops owning the field, and it stops being allowed to answer. +describe('abandoning an in-flight validation', () => { + const inFlight = async (extra = {}) => { + const pending = deferred(); + const view = renderHook(() => + usePromoCode(createDefaultProps({ + promoCode: 'CODE', + ticketDataLoaded: true, + hasTickets: true, + validatePromoCode: jest.fn(() => pending.promise), + ...extra, + })) + ); + act(() => { view.result.current.actions.onRevalidate(mockTicketQualifying, 1); }); + expect(view.result.current.state.status).toBe(PROMO_STATUS.PROCESSING); + return { view, pending }; + }; + + it('frees the field when the code is removed mid-validation', async () => { + // Otherwise the input stays read-only behind a spinner and Next stays + // disabled until the abandoned request lands, which for an aborted one + // is never. + const { view } = await inFlight(); + + act(() => { view.result.current.actions.onRemove(); }); + + expect(view.result.current.state.status).not.toBe(PROMO_STATUS.PROCESSING); + expect(view.result.current.state.isReady).toBe(true); + }); + + it('ignores the answer to a validation for a code since removed', async () => { + const { view, pending } = await inFlight(); + + act(() => { view.result.current.actions.onRemove(); }); + await act(async () => { pending.resolve({ response: { allows_to_reassign: false } }); }); + + // The store blanking the code is a separate mechanism, so this asserts + // only what onRemove itself has to guarantee: the abandoned answer + // never becomes a verdict, and its reassignment restriction never + // reaches the rest of the flow. + expect(view.result.current.state.status).not.toBe(PROMO_STATUS.APPLIED); + expect(view.result.current.state.allowsReassign).toBe(true); + }); + + it('ignores the answer to a validation for a code since replaced', async () => { + // The window between applying a code and validating it is a whole + // round trip; the previous code's answer must not fill it. + const { view, pending } = await inFlight(); + + await act(async () => { view.result.current.actions.onApply('OTHER', null, 1); }); + await act(async () => { pending.resolve({ response: { allows_to_reassign: false } }); }); + + expect(view.result.current.state.status).not.toBe(PROMO_STATUS.APPLIED); + expect(view.result.current.state.allowsReassign).toBe(true); + }); + + it('drops the verdict when the applied code is cleared from outside', async () => { + // Checkout, logout and a cleared reservation all blank the code in the + // store without going through this hook. + const view = await renderVerified({}, { allows_to_reassign: false }); + expect(view.result.current.state.allowsReassign).toBe(false); + + view.rerender({ promoCode: '' }); + + expect(view.result.current.state.allowsReassign).toBe(true); + }); +}); + // ── Discovery selection ── describe('discovery selection', () => { @@ -328,12 +401,14 @@ describe('status derivation', () => { }); it('does not let a superseded attempt advance the caller', async () => { + // Resolves with a real response body, so nothing but the supersede + // check can stop the superseded attempt reporting success. let resolveFirst; const validatePromoCode = jest.fn() .mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; })) - .mockImplementationOnce(() => Promise.resolve()); + .mockImplementationOnce(() => Promise.resolve({ response: {} })); const { result } = renderHook(() => - usePromoCode(createDefaultProps({ validatePromoCode })) + usePromoCode(createDefaultProps({ promoCode: 'CODE', validatePromoCode })) ); let firstAttempt; @@ -344,7 +419,7 @@ describe('status derivation', () => { let canAdvance; await act(async () => { - resolveFirst(); + resolveFirst({ response: {} }); canAdvance = await firstAttempt; }); expect(canAdvance).toBe(false); @@ -451,10 +526,9 @@ describe('derived values', () => { expect(result.current.state.isReady).toBe(false); }); - it('isReady false for INVALID', () => { - const { result } = renderHook(() => - usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: false })) - ); + it('isReady false for INVALID', async () => { + const { result } = await renderRejected(); + expect(result.current.state.status).toBe(PROMO_STATUS.INVALID); expect(result.current.state.isReady).toBe(false); }); diff --git a/src/hooks/usePromoCode.js b/src/hooks/usePromoCode.js index a18a27c..ffd952c 100644 --- a/src/hooks/usePromoCode.js +++ b/src/hooks/usePromoCode.js @@ -160,6 +160,28 @@ const usePromoCode = ({ // recent attempt may report a result. const latestValidation = useRef(0); + // Everything a validation in flight still owns: the right to answer, and + // the busy state it put the field into. Both have to go the moment the code + // it was asked about stops being the applied one, or the field stays locked + // behind a request nobody is waiting for and that request's answer lands on + // whatever the user did next. + // + // Advancing the counter is what withdraws the right to answer, so every + // caller that drops the verdict has to come through here. + const abandonValidation = useCallback(() => { + latestValidation.current += 1; + setValidatingCode(false); + setVerdict(null); + setApiError(null); + }, []); + + // The code can also be cleared without this hook being asked: checkout, + // logout, a cleared reservation. Nothing decided about the old code + // survives that either. + useEffect(() => { + if (!promoCode) abandonValidation(); + }, [promoCode, abandonValidation]); + // Returns whether the caller may advance: true only when the code // validated and no later attempt has replaced this one. const onRevalidate = useCallback(async (ticket, quantity) => { @@ -206,7 +228,7 @@ const usePromoCode = ({ const tryAutoApply = useCallback(async (ticket) => { setIsAutoApplied(true); setApplyingCode(true); - setVerdict(null); + abandonValidation(); try { await applyPromoCode(discoveredPromoCode.code); // onRevalidate reports a failed validation by returning false @@ -275,11 +297,12 @@ const usePromoCode = ({ }, [userRemovedAutoApply, ticketDataLoaded, discoveredPromoCode, discoveredPromoCodes, isApplied, tryAutoApply]); const onApply = useCallback(async (code, ticket, quantity) => { - setApiError(null); setApplyingCode(true); // A different application of a code, even the same string, has not been - // judged yet; whatever was decided about the last one does not carry. - setVerdict(null); + // judged yet, and applying takes a whole round trip before any new + // validation starts. Withdrawing the old one now stops it filling that + // window with an answer about the code it replaced. + abandonValidation(); try { await applyPromoCode(code); } catch (e) { @@ -302,8 +325,7 @@ const usePromoCode = ({ if (isAutoApplied || isDiscoveredCode) setUserRemovedAutoApply(true); setIsAutoApplied(false); - setApiError(null); - setVerdict(null); + abandonValidation(); setSuggestionDismissed(false); if (discoveredPromoCode) setSuggestionActive(true); From 391bad0142b39da2a9a39b1185d359c09eae2cc0 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Tue, 11 Aug 2026 15:40:48 -0300 Subject: [PATCH 14/16] Record which promo code each validation answered usePromoCode stored whether the API accepted the code for the selected ticket, but not which code was asked about, so anything that changed the applied code had to clear it. The store clears the code on checkout, logout and when the reservation is dropped, none of which go through the hook. With the code recorded, an answer about a code that is no longer applied stops counting on its own. The code is read when the validation starts rather than when onApply was created: applyPromoCode is a round trip, so that callback still holds the previous code and would file the answer against the one being replaced. --- src/hooks/__tests__/usePromoCode.test.js | 49 +++++++++++++++----- src/hooks/usePromoCode.js | 57 ++++++++++++++---------- 2 files changed, 71 insertions(+), 35 deletions(-) diff --git a/src/hooks/__tests__/usePromoCode.test.js b/src/hooks/__tests__/usePromoCode.test.js index b095fa2..2a50493 100644 --- a/src/hooks/__tests__/usePromoCode.test.js +++ b/src/hooks/__tests__/usePromoCode.test.js @@ -39,9 +39,9 @@ const createDefaultProps = (overrides = {}) => ({ ...overrides, }); -// The verdict is the hook's own state, reached only by validating, so a test -// that needs a verified code has to earn one the way the app does. This renders -// the hook and runs one successful validation against it. +// Whether the code was accepted is the hook's own state, reached only by +// validating, so a test that needs an accepted code has to earn one the way the +// app does: this renders the hook and runs one successful validation against it. const renderVerified = async (overrides = {}, response = {}) => { const view = renderHook((props) => usePromoCode(createDefaultProps({ @@ -130,7 +130,7 @@ describe('abandoning an in-flight validation', () => { // The store blanking the code is a separate mechanism, so this asserts // only what onRemove itself has to guarantee: the abandoned answer - // never becomes a verdict, and its reassignment restriction never + // is never recorded, and its reassignment restriction never // reaches the rest of the flow. expect(view.result.current.state.status).not.toBe(PROMO_STATUS.APPLIED); expect(view.result.current.state.allowsReassign).toBe(true); @@ -148,7 +148,7 @@ describe('abandoning an in-flight validation', () => { expect(view.result.current.state.allowsReassign).toBe(true); }); - it('drops the verdict when the applied code is cleared from outside', async () => { + it('drops the recorded validation when the applied code is cleared from outside', async () => { // Checkout, logout and a cleared reservation all blank the code in the // store without going through this hook. const view = await renderVerified({}, { allows_to_reassign: false }); @@ -160,6 +160,33 @@ describe('abandoning an in-flight validation', () => { }); }); +// ── Applying a code end to end ── + +describe('applying a code', () => { + it('reaches APPLIED once the applied code validates', async () => { + // Applying is a round trip, and the code only arrives as a prop when it + // is over, so the callback that starts the validation was created while + // no code was applied. Anything the validation records about "the + // applied code" has to be read when it runs, not when it was created, + // or the answer is filed against the wrong code and never counts. + let view; + const props = createDefaultProps({ + promoCode: '', + ticketDataLoaded: true, + hasTickets: true, + applyPromoCode: jest.fn(async () => { view.rerender({ promoCode: 'CODE' }); }), + validatePromoCode: jest.fn(() => Promise.resolve({ response: {} })), + }); + view = renderHook((over) => usePromoCode({ ...props, ...over }), { initialProps: {} }); + + await act(async () => { + await view.result.current.actions.onApply('CODE', mockTicketQualifying, 1); + }); + + expect(view.result.current.state.status).toBe(PROMO_STATUS.APPLIED); + }); +}); + // ── Discovery selection ── describe('discovery selection', () => { @@ -220,9 +247,9 @@ describe('status derivation', () => { expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); }); - it('returns PROCESSING during re-validation after a failed verdict', async () => { - // A rejected verdict stays in the store while a re-validation runs, so - // both signals coexist. In-flight must win over the stale verdict: + it('returns PROCESSING while re-validating a code the API turned down', async () => { + // A rejection stays recorded while a re-validation runs, so both signals + // coexist. In-flight must win over the stale rejection: // spinner, not error icon. const pending = deferred(); const { result } = await renderRejected({ @@ -237,7 +264,7 @@ describe('status derivation', () => { expect(result.current.state.validationError).toBeNull(); }); - it('stops PROCESSING when the validation fails without a verdict', async () => { + it('stops PROCESSING when the validation fails without deciding anything', async () => { // A server error, a timeout or a dropped connection never reaches the // reducer. If the hook does not clear its own flag the field spins for // the rest of the session. @@ -350,7 +377,7 @@ describe('status derivation', () => { it('returns UNVERIFIED when a verified code has an outstanding request error', async () => { // A rate-limited or timed-out re-validation leaves promoCodeVerified at - // its previous value while setting an error. The last verdict no longer + // its previous value while setting an error. The last answer no longer // covers the current selection, so it must not render as success. const { result } = await renderVerified({ validatePromoCode: jest.fn() @@ -533,7 +560,7 @@ describe('derived values', () => { }); it('isReady stays true after a request error so the user can retry', async () => { - // A failed request leaves no verdict, so it must not latch the gate + // A failed request decides nothing, so it must not latch the gate // shut: the user has to be able to try again. Not proceeding on an // unverified code is enforced by re-validating on advance and refusing // to move on unless it succeeds, which is covered end to end. diff --git a/src/hooks/usePromoCode.js b/src/hooks/usePromoCode.js index ffd952c..da28633 100644 --- a/src/hooks/usePromoCode.js +++ b/src/hooks/usePromoCode.js @@ -37,17 +37,21 @@ const usePromoCode = ({ // failures that never reach the reducer, clears it in one place. const [validatingCode, setValidatingCode] = useState(false); - // What the API decided about the applied code, owned here because this is - // what awaits the request and so the only thing that knows which attempt is - // still the current one. Held in the store it took a second record of that - // ordering to stay in step with this one, and the two could disagree. + // Whether the API accepted the applied code for the selected ticket, and + // which code it was asked about. Owned here because this is what awaits the + // request, and so the only thing that knows which attempt is still current. // - // null means undecided: either nothing has been validated yet, or the last - // attempt failed in a way that decided nothing. - const [verdict, setVerdict] = useState(null); - - const promoCodeVerified = verdict === null ? null : verdict.verified; - const allowsReassign = verdict?.allowsReassign ?? true; + // Recording the code is what lets an answer stop counting on its own once + // that code is no longer applied, including when the store clears it + // without asking: on checkout, logout or a dropped reservation. + const [lastValidation, setLastValidation] = useState(null); + + // null means nothing has been accepted or turned down for the code now + // applied: nothing validated yet, the last attempt decided nothing, or what + // it decided was about a code since replaced. + const validation = lastValidation?.code === promoCode ? lastValidation : null; + const promoCodeVerified = validation === null ? null : validation.verified; + const allowsReassign = validation?.allowsReassign ?? true; // Pick first auto_apply code, or first code if none has auto_apply const discoveredPromoCode = useMemo(() => { @@ -65,7 +69,7 @@ const usePromoCode = ({ // Something genuinely in flight: applying the code, validating it against // a ticket, or waiting on the code-filtered ticket list with no settled - // verdict to show in the meantime. + // answer to show in the meantime. const isBusy = applyingCode || validatingCode || (isApplied && promoCodeVerified == null && !ticketDataLoaded); @@ -81,10 +85,11 @@ const usePromoCode = ({ const status = useMemo(() => { if (isBusy) return PROMO_STATUS.PROCESSING; if (isInvalid) return PROMO_STATUS.INVALID; - // APPLIED requires a backend verdict for the selected ticket. Applying + // APPLIED requires the API to have accepted the code for the selected + // ticket. Applying // a code without a ticket runs no validation, and the catalog comes // back populated even for a code that does not exist, so anything - // short of a confirmed verdict rests as UNVERIFIED. + // short of an accepted validation rests as UNVERIFIED. if (isApplied) return (promoCodeVerified === true && apiError == null) ? PROMO_STATUS.APPLIED : PROMO_STATUS.UNVERIFIED; @@ -160,6 +165,13 @@ const usePromoCode = ({ // recent attempt may report a result. const latestValidation = useRef(0); + // The applied code as of the latest render. Read when a validation starts + // rather than closed over, because applying a code takes a round trip and + // the callback that starts the validation was created before it: closing + // over the prop would record the answer against the code being replaced. + const appliedCode = useRef(promoCode); + appliedCode.current = promoCode; + // Everything a validation in flight still owns: the right to answer, and // the busy state it put the field into. Both have to go the moment the code // it was asked about stops being the applied one, or the field stays locked @@ -167,25 +179,22 @@ const usePromoCode = ({ // whatever the user did next. // // Advancing the counter is what withdraws the right to answer, so every - // caller that drops the verdict has to come through here. + // caller that drops the recorded answer has to come through here. const abandonValidation = useCallback(() => { latestValidation.current += 1; setValidatingCode(false); - setVerdict(null); + setLastValidation(null); setApiError(null); }, []); - // The code can also be cleared without this hook being asked: checkout, - // logout, a cleared reservation. Nothing decided about the old code - // survives that either. - useEffect(() => { - if (!promoCode) abandonValidation(); - }, [promoCode, abandonValidation]); // Returns whether the caller may advance: true only when the code // validated and no later attempt has replaced this one. const onRevalidate = useCallback(async (ticket, quantity) => { const attempt = ++latestValidation.current; + // Which code this answer will be about, fixed now rather than when it + // lands, so a code applied afterwards cannot inherit it. + const code = appliedCode.current; setApiError(null); setValidatingCode(true); try { @@ -194,14 +203,14 @@ const usePromoCode = ({ // No code applied means no request went out, so nothing was decided // and the caller has nothing to advance on. if (!result) return false; - setVerdict({ verified: true, allowsReassign: result.response?.allows_to_reassign ?? true }); + setLastValidation({ code, verified: true, allowsReassign: result.response?.allows_to_reassign ?? true }); return true; } catch (e) { if (attempt !== latestValidation.current) return false; // Only these mean the API judged the code and turned it down. - // Every other failure decided nothing, so the previous verdict, or + // Every other failure decided nothing, so the previous answer, or // the absence of one, stands and the error is surfaced instead. - if (isRejection(e)) setVerdict({ verified: false, allowsReassign: true }); + if (isRejection(e)) setLastValidation({ code, verified: false, allowsReassign: true }); handleValidationError(e); setIsAutoApplied(false); return false; From 7a00e9130c0a1f52d7fbf57b40c154276f64a7b6 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Tue, 11 Aug 2026 15:41:25 -0300 Subject: [PATCH 15/16] Keep a code auto-applied when validation fails without deciding A server error, a timeout or a rate limit says nothing about the code, but it still cleared isAutoApplied, so the notice changed from "automatically applied" to plain "applied" and a later successful retry did not change it back. Only a 404 or 412 means the API turned the code down. --- src/hooks/__tests__/usePromoCode.test.js | 39 +++++++++++++++++++++++- src/hooks/usePromoCode.js | 8 +++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/hooks/__tests__/usePromoCode.test.js b/src/hooks/__tests__/usePromoCode.test.js index 2a50493..3eca9a7 100644 --- a/src/hooks/__tests__/usePromoCode.test.js +++ b/src/hooks/__tests__/usePromoCode.test.js @@ -187,6 +187,43 @@ describe('applying a code', () => { }); }); +// ── What a failure that decided nothing may change ── + +describe('a validation that decided nothing', () => { + const failing = (statusCode) => jest.fn() + .mockImplementationOnce(() => Promise.resolve({ response: { allows_to_reassign: true } })) + .mockImplementationOnce(() => Promise.reject({ res: { statusCode, body: {} } })); + + it('does not downgrade an auto-applied code to a manual one', async () => { + // The label says the widget applied this code on the user's behalf. + // A server error says nothing about the code, so it cannot quietly + // rewrite how the code came to be applied. + // Built once: createDefaultProps mints new mocks per call, and unstable + // identities restart the auto-apply effect on every render. + const props = createDefaultProps({ + discoveredPromoCodes: [mockDiscoveredCodes[1]], + promoCode: '', + ticketDataLoaded: true, + hasTickets: true, + validatePromoCode: jest.fn(() => Promise.reject({ res: { statusCode: 500, body: {} } })), + }); + const view = renderHook((over) => usePromoCode({ ...props, ...over }), { initialProps: {} }); + + // Let the early auto-apply run, which is the only thing that marks a + // code as applied on the user's behalf. + await act(async () => {}); + expect(view.result.current.state.isAutoApplied).toBe(true); + + view.rerender({ promoCode: 'AUTO1' }); + + await act(async () => { + await view.result.current.actions.onRevalidate(mockTicketQualifying, 1); + }); + expect(view.result.current.state.isAutoApplied).toBe(true); + }); + +}); + // ── Discovery selection ── describe('discovery selection', () => { @@ -810,7 +847,7 @@ describe('onTicketSelected', () => { const singleCode = [mockDiscoveredCodes[1]]; // AUTO1 only const applyPromoCode = jest.fn(() => Promise.resolve()); const validatePromoCode = jest.fn(() => Promise.reject({ - res: { body: { errors: ['Quantity exceeded'] } } + res: { statusCode: 412, body: { errors: ['Quantity exceeded'] } } })); const { result } = renderHook(() => usePromoCode(createDefaultProps({ diff --git a/src/hooks/usePromoCode.js b/src/hooks/usePromoCode.js index da28633..b2562cd 100644 --- a/src/hooks/usePromoCode.js +++ b/src/hooks/usePromoCode.js @@ -210,9 +210,13 @@ const usePromoCode = ({ // Only these mean the API judged the code and turned it down. // Every other failure decided nothing, so the previous answer, or // the absence of one, stands and the error is surfaced instead. - if (isRejection(e)) setLastValidation({ code, verified: false, allowsReassign: true }); + if (isRejection(e)) { + setLastValidation({ code, verified: false, allowsReassign: true }); + // How the code came to be applied is only worth revising when + // the API actually turned it down. + setIsAutoApplied(false); + } handleValidationError(e); - setIsAutoApplied(false); return false; } finally { // A later attempt is still running and owns the flag, so leave it From ae8b6af1c4c2054d90b94a02ccd079d2be882d83 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Tue, 11 Aug 2026 15:43:06 -0300 Subject: [PATCH 16/16] Stop applying promo code quantity caps after an undecided validation The stepper kept honouring a discovered code's per-account and available quantity caps while an outstanding request error meant the last answer no longer covered the current selection, which is the state the field already shows as unverified. The caps now survive a re-validation in flight instead of dropping out and briefly uncapping the stepper for a code that is still applied. --- src/hooks/__tests__/usePromoCode.test.js | 18 ++++++++++++++++++ src/hooks/usePromoCode.js | 8 +++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/hooks/__tests__/usePromoCode.test.js b/src/hooks/__tests__/usePromoCode.test.js index 3eca9a7..8c1c89c 100644 --- a/src/hooks/__tests__/usePromoCode.test.js +++ b/src/hooks/__tests__/usePromoCode.test.js @@ -222,6 +222,24 @@ describe('a validation that decided nothing', () => { expect(view.result.current.state.isAutoApplied).toBe(true); }); + it('stops applying the code quantity caps', async () => { + // status drops to UNVERIFIED after this, so the caps a verified code + // imposes must drop with it rather than keep acting on an answer that + // no longer covers the selection. + const view = await renderVerified({ + discoveredPromoCodes: [mockDiscoveredCodes[1]], + promoCode: 'AUTO1', + validatePromoCode: failing(500), + }); + expect(view.result.current.state.maxQuantityFromPromo).toBe(4); + + await act(async () => { + await view.result.current.actions.onRevalidate(mockTicketQualifying, 1); + }); + expect(view.result.current.state.status).toBe(PROMO_STATUS.UNVERIFIED); + expect(view.result.current.state.maxQuantityFromPromo).toBeNull(); + expect(view.result.current.state.perAccountLimit).toBeNull(); + }); }); // ── Discovery selection ── diff --git a/src/hooks/usePromoCode.js b/src/hooks/usePromoCode.js index b2562cd..ae4ff1d 100644 --- a/src/hooks/usePromoCode.js +++ b/src/hooks/usePromoCode.js @@ -106,7 +106,13 @@ const usePromoCode = ({ const suggestedCode = discoveredPromoCode?.code || null; - const activeDiscoveredCode = (promoCodeVerified === true && !validatingCode && isDiscoveredCode) + // The caps a discovered code carries apply while it is the applied code and + // the last thing heard about it was that it was good. An outstanding error + // means that answer no longer covers the current selection, which is what + // status reflects too, so the caps go with it. They deliberately survive a + // re-validation in flight: dropping them there would briefly uncap the + // stepper for a code that is still applied. + const activeDiscoveredCode = (isDiscoveredCode && promoCodeVerified === true && apiError == null) ? discoveredPromoCode : null; const perAccountLimit = activeDiscoveredCode?.quantity_per_account > 0