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 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..aad9202 --- /dev/null +++ b/e2e/promo-code-advance-from-ticket-step.spec.js @@ -0,0 +1,123 @@ +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. + +// 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([])) }) + ); + 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/e2e/promo-code-apply-without-ticket.spec.js b/e2e/promo-code-apply-without-ticket.spec.js new file mode 100644 index 0000000..81d7008 --- /dev/null +++ b/e2e/promo-code-apply-without-ticket.spec.js @@ -0,0 +1,136 @@ +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 }), +]; + +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 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 a Remove affordance + await expect(page.locator('input[placeholder="Enter your promo code"][readonly]')).toBeVisible(); + await expect(page.locator('button:has-text("Remove")')).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 }) => { + 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('button:has-text("Remove")')).toBeVisible(); + await expect(promoApplied(page)).toHaveCount(0); + expect(validationCalls).toBe(0); + + // 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(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/e2e/promo-code-discovery.spec.js b/e2e/promo-code-discovery.spec.js index 364dca3..bfa49ed 100644 --- a/e2e/promo-code-discovery.spec.js +++ b/e2e/promo-code-discovery.spec.js @@ -292,6 +292,35 @@ 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, 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); + 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..fd24e83 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'; @@ -74,17 +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, preserve current promo state - if (res && res.statusCode === 429) { - dispatch(createAction(VALIDATE_PROMO_CODE_RATE_LIMITED)({})); - return; - } + // 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/promocode-input/index.js b/src/components/promocode-input/index.js index 36bb93a..7986c2b 100644 --- a/src/components/promocode-input/index.js +++ b/src/components/promocode-input/index.js @@ -27,9 +27,11 @@ 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.UNVERIFIED + || promoStatus === PROMO_STATUS.INVALID; const inputValue = useMemo(() => { if (promoCode) return promoCode; @@ -39,11 +41,11 @@ const PromoCodeInput = ({ promoStatus, promoCode, suggestedCode, isAutoApplied, const label = useMemo(() => { switch (promoStatus) { - case PROMO_STATUS.VALID: + 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.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,9 +93,9 @@ const PromoCodeInput = ({ promoStatus, promoCode, suggestedCode, isAutoApplied, }} readOnly={isLocked} /> - {(promoStatus === PROMO_STATUS.VALIDATING || promoStatus === PROMO_STATUS.APPLYING) && } - {promoStatus === PROMO_STATUS.VALID && } - {promoStatus === PROMO_STATUS.INVALID && } + {promoStatus === PROMO_STATUS.PROCESSING && } + {promoStatus === PROMO_STATUS.APPLIED && } + {promoStatus === PROMO_STATUS.INVALID && }
{isLocked ? 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 882b9bf..1a6f3ec 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, @@ -162,9 +162,6 @@ const RegistrationFormContent = ( showCompanyInputDefaultOptions, companyDDLOptions2Show, promoCode, - promoCodeVerified, - promoCodeValidating, - promoCodeAllowsReassign, discoveredPromoCodes, hasDiscount, getTicketDiscount, @@ -267,8 +264,6 @@ const RegistrationFormContent = ( const promo = usePromoCode({ discoveredPromoCodes, promoCode, - promoCodeVerified, - promoCodeValidating, applyPromoCode, removePromoCode, validatePromoCode, @@ -293,11 +288,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,20 +347,23 @@ 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; } - // 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 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); @@ -449,7 +447,7 @@ const RegistrationFormContent = ( promo={promo} validationError={ticketStepError} promoCode={promoCode} - promoCodeAllowsReassign={promoCodeAllowsReassign} + promoCodeAllowsReassign={promoState.allowsReassign} changeForm={mergeFormValues} trackViewItem={trackViewItem} showMultipleTicketTexts={showMultipleTicketTexts} @@ -494,7 +492,7 @@ const RegistrationFormContent = ( companyDDLPlaceholder={companyDDLPlaceholder} showCompanyInputDefaultOptions={showCompanyInputDefaultOptions} companyDDLOptions2Show={companyDDLOptions2Show} - promoCodeAllowsReassign={promoCodeAllowsReassign} + promoCodeAllowsReassign={promoState.allowsReassign} /> @@ -571,9 +569,6 @@ const mapStateToProps = ({ registrationLiteState }) => ({ passwordlessCodeSent: registrationLiteState.passwordless.code_sent, 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 dc8d867..8c1c89c 100644 --- a/src/hooks/__tests__/usePromoCode.test.js +++ b/src/hooks/__tests__/usePromoCode.test.js @@ -32,8 +32,6 @@ const mockTicketNonQualifying = { id: 99, sub_type: 'Regular' }; const createDefaultProps = (overrides = {}) => ({ discoveredPromoCodes: [], promoCode: '', - promoCodeVerified: null, - promoCodeValidating: false, applyPromoCode: jest.fn(() => Promise.resolve()), removePromoCode: jest.fn(), validatePromoCode: jest.fn(() => Promise.resolve()), @@ -41,6 +39,209 @@ const createDefaultProps = (overrides = {}) => ({ ...overrides, }); +// 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({ + 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); + }); + 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. +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 }; +}; + +// ── 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 + // 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); + }); + + 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 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 }); + expect(view.result.current.state.allowsReassign).toBe(false); + + view.rerender({ promoCode: '' }); + + expect(view.result.current.state.allowsReassign).toBe(true); + }); +}); + +// ── 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); + }); +}); + +// ── 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); + }); + + 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 ── describe('discovery selection', () => { @@ -51,7 +252,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 })) @@ -75,34 +276,262 @@ 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 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 } = 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({ response: {} }); }); + expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); }); - it('returns VALIDATING when code applied and promoCodeValidating is true', () => { + 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({ + 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); }); + expect(result.current.state.status).toBe(PROMO_STATUS.PROCESSING); + expect(result.current.state.validationError).toBeNull(); + }); + + 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. + 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), + })) + ); + + 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('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', + ticketDataLoaded: true, + hasTickets: true, + validatePromoCode, + })) ); - expect(result.current.state.status).toBe(PROMO_STATUS.VALIDATING); + + // 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({ response: {} }); + }); + 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', + 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({ response: {} }); }); + expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); + }); + + it('returns APPLIED when code verified for the selected ticket', async () => { + const { result } = await renderVerified(); + expect(result.current.state.status).toBe(PROMO_STATUS.APPLIED); + }); + + 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', + promoCodeVerified: null, + ticketDataLoaded: true, + 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 answer no longer + // covers the current selection, so it must not render as success. + 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 () => { + 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 VALID when code applied and promoCodeVerified is true', () => { + 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({ response: {} })); + const { result } = renderHook(() => - usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: true })) + usePromoCode(createDefaultProps({ + promoCode: 'CODE', + ticketDataLoaded: true, + hasTickets: true, + validatePromoCode, + })) ); - expect(result.current.state.status).toBe(PROMO_STATUS.VALID); + + 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', () => { + 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({ response: {} })); const { result } = renderHook(() => - usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: false })) + usePromoCode(createDefaultProps({ promoCode: 'CODE', 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({ response: {} }); + canAdvance = await firstAttempt; + }); + expect(canAdvance).toBe(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 }] }] })) @@ -137,42 +566,99 @@ describe('derived values', () => { expect(result.current.state.isReady).toBe(true); }); - it('isReady true for VALID', () => { + it('isReady true for verified code', async () => { + const { result } = await renderVerified(); + expect(result.current.state.isReady).toBe(true); + }); + + 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: true })) + usePromoCode(createDefaultProps({ + promoCode: 'CODE', + promoCodeVerified: null, + ticketDataLoaded: true, + hasTickets: true, + })) ); expect(result.current.state.isReady).toBe(true); }); - it('isReady false for APPLYING', () => { + 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 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); }); - it('isReady false for INVALID', () => { + 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); + }); + + it('isReady stays true after a request error so the user can retry', async () => { + // 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. const { result } = renderHook(() => - usePromoCode(createDefaultProps({ promoCode: 'CODE', promoCodeVerified: false })) + 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(false); + 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(true); + // The failure is still reported, it just doesn't disable the gate. + expect(result.current.state.validationError).toBe('Too many requests'); }); - it('perAccountLimit from active discovered code when valid', () => { + it('onRevalidate refuses to advance when the request fails', async () => { + // The gate that replaced isReady's error check. const { result } = renderHook(() => usePromoCode(createDefaultProps({ - discoveredPromoCodes: mockDiscoveredCodes, - promoCode: 'AUTO1', - promoCodeVerified: true, + 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', async () => { + const { result } = await renderVerified({ discoveredPromoCodes: mockDiscoveredCodes, promoCode: 'AUTO1' }); expect(result.current.state.perAccountLimit).toBe(4); }); @@ -379,7 +865,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({ @@ -640,62 +1126,16 @@ 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', () => { - 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, @@ -704,13 +1144,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); }); @@ -733,7 +1167,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, @@ -742,17 +1176,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, @@ -761,17 +1189,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, @@ -780,17 +1202,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, @@ -799,13 +1215,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(); }); }); @@ -813,23 +1223,26 @@ describe('maxQuantityFromPromo', () => { // ── onRevalidate ── describe('onRevalidate', () => { - it('returns true on successful validation', async () => { - const validatePromoCode = jest.fn(() => Promise.resolve()); + 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({ response: {} })); const { result } = renderHook(() => - usePromoCode(createDefaultProps({ validatePromoCode })) + usePromoCode(createDefaultProps({ promoCode: 'CODE', validatePromoCode })) ); - let valid; + let canAdvance; await act(async () => { - valid = await result.current.actions.onRevalidate(mockTicketQualifying, 3); + canAdvance = await result.current.actions.onRevalidate(mockTicketQualifying, 3); }); - expect(valid).toBe(true); + expect(canAdvance).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('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.'] } } })); @@ -837,11 +1250,11 @@ describe('onRevalidate', () => { usePromoCode(createDefaultProps({ validatePromoCode })) ); - let valid; + let canAdvance; await act(async () => { - valid = await result.current.actions.onRevalidate(mockTicketQualifying, 5); + canAdvance = await result.current.actions.onRevalidate(mockTicketQualifying, 5); }); - expect(valid).toBe(false); + expect(canAdvance).toBe(false); expect(result.current.state.validationError).toBe('Promo code X can not be applied more than 3 times.'); }); @@ -992,7 +1405,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 +1414,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.UNVERIFIED); + 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..ae4ff1d 100644 --- a/src/hooks/usePromoCode.js +++ b/src/hooks/usePromoCode.js @@ -1,13 +1,18 @@ -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'; +// 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, - promoCodeValidating, // Redux dispatchers applyPromoCode, @@ -25,11 +30,28 @@ 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); + // 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); + + // 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. + // + // 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(() => { @@ -40,32 +62,57 @@ 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 + // answer to show in the meantime. + const isBusy = applyingCode || validatingCode + || (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; + // 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 an accepted validation 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; - }, [isApplied, promoCodeVerified, promoCodeValidating, suggestionActive, suggestionDismissed, applyingCode, ticketDataLoaded, hasTickets]); + }, [isBusy, isInvalid, isApplied, isSuggested, promoCodeVerified, apiError]); - // 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. - // Consumers may layer their own warning on top before display. - const validationError = manualError - ?? (status === PROMO_STATUS.INVALID ? T.translate('promo_code.invalid_code') : null); + // 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 --- const suggestedCode = discoveredPromoCode?.code || null; - const activeDiscoveredCode = (status === PROMO_STATUS.VALID && 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 @@ -84,11 +131,14 @@ const usePromoCode = ({ return caps.length > 0 ? Math.min(...caps) : null; }, [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; + // 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 --- @@ -109,22 +159,75 @@ 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 --- + // A ticket switch can leave an earlier validation in flight. Only the most + // 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 + // 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 recorded answer has to come through here. + const abandonValidation = useCallback(() => { + latestValidation.current += 1; + setValidatingCode(false); + setLastValidation(null); + setApiError(null); + }, []); + + + // 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) => { - setManualError(null); + 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 { - await validatePromoCode({ id: ticket.id, ticketQuantity: quantity, sub_type: ticket.sub_type }); + 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; + 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 answer, or + // the absence of one, stands and the error is surfaced instead. + 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); 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]); @@ -144,16 +247,13 @@ const usePromoCode = ({ const tryAutoApply = useCallback(async (ticket) => { setIsAutoApplied(true); setApplyingCode(true); + abandonValidation(); try { await applyPromoCode(discoveredPromoCode.code); - if (ticket) { - const valid = await onRevalidate(ticket, 1); - if (!valid) { - setIsAutoApplied(false); - return false; - } - } - 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); @@ -173,7 +273,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) { @@ -189,8 +289,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; } @@ -217,8 +316,12 @@ const usePromoCode = ({ }, [userRemovedAutoApply, ticketDataLoaded, discoveredPromoCode, discoveredPromoCodes, isApplied, tryAutoApply]); const onApply = useCallback(async (code, ticket, quantity) => { - setManualError(null); setApplyingCode(true); + // A different application of a code, even the same string, has not been + // 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) { @@ -226,17 +329,22 @@ 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(() => { if (isAutoApplied || isDiscoveredCode) setUserRemovedAutoApply(true); setIsAutoApplied(false); - setManualError(null); + abandonValidation(); setSuggestionDismissed(false); if (discoveredPromoCode) setSuggestionActive(true); @@ -246,16 +354,18 @@ const usePromoCode = ({ }, [isAutoApplied, isDiscoveredCode, discoveredPromoCode, removePromoCode, setFormPromoCode]); const onInputChange = useCallback((value) => { - setManualError(null); + setApiError(null); setSuggestionDismissed(value !== discoveredPromoCode?.code); setFormPromoCode(value); }, [discoveredPromoCode, setFormPromoCode]); 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 @@ -263,8 +373,11 @@ 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 - isDiscoveredCode, isAutoApplied, // Discovery / suggestion diff --git a/src/reducer.js b/src/reducer.js index b6549f5..a41a32d 100644 --- a/src/reducer.js +++ b/src/reducer.js @@ -34,10 +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, - VALIDATE_PROMO_CODE_RATE_LIMITED, DISCOVER_PROMO_CODES_SUCCESS, } from './actions'; @@ -68,9 +64,6 @@ const DEFAULT_STATE = { userProfile: null, }, promoCode: '', - promoCodeVerified: null, - promoCodeValidating: false, - promoCodeAllowsReassign: true, discoveredPromoCodes: [], }; @@ -102,8 +95,6 @@ const RegistrationLiteReducer = (state = DEFAULT_STATE, action) => { taxTypes: [], invitation: null, promoCode: '', - promoCodeVerified: null, - promoCodeAllowsReassign: true, discoveredPromoCodes: [], passwordless: { ...DEFAULT_STATE.passwordless }, settings: { @@ -152,10 +143,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: '', 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: '', discoveredPromoCodes: [] }; } case GET_MY_INVITATION: { return { ...state, invitation: payload.response }; @@ -164,24 +155,11 @@ 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: '' } } case SET_CURRENT_PROMO_CODE:{ const { currentPromoCode } = payload; - return { ...state, promoCode: currentPromoCode, promoCodeVerified: null, promoCodeValidating: false, promoCodeAllowsReassign: true } - } - case VALIDATE_PROMO_CODE: { - return { ...state, promoCodeValidating: true } - } - case VALIDATE_PROMO_CODE_SUCCESS: { - const { allows_to_reassign } = payload.response; - return { ...state, promoCodeVerified: true, promoCodeValidating: false, 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, promoCode: currentPromoCode } } case DISCOVER_PROMO_CODES_SUCCESS: { return { ...state, discoveredPromoCodes: payload.response?.data || [] } diff --git a/src/utils/constants.js b/src/utils/constants.js index 5d3abd4..05dacc0 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -47,13 +47,16 @@ 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', + UNVERIFIED: 'unverified', + APPLIED: 'applied', INVALID: 'invalid', };