Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ node_modules
coverage
*.log
.idea/
.env
.env
test-results
playwright-report
123 changes: 123 additions & 0 deletions e2e/promo-code-advance-from-ticket-step.spec.js
Original file line number Diff line number Diff line change
@@ -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();
});
136 changes: 136 additions & 0 deletions e2e/promo-code-apply-without-ticket.spec.js
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert exactly one deferred validation request.

Line 94 accepts duplicate validation requests. The test title requires one request after ticket selection. Change the assertion to toBe(1).

Proposed fix
-        await expect.poll(() => validationCalls).toBeGreaterThan(0);
+        await expect.poll(() => validationCalls).toBe(1);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await expect.poll(() => validationCalls).toBeGreaterThan(0);
await expect.poll(() => validationCalls).toBe(1);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/promo-code-apply-without-ticket.spec.js` at line 94, Update the
validationCalls assertion in the ticket-selection test to require exactly one
deferred validation request by using an equality check against 1 instead of only
checking that the count is positive.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gcutrini Not changing this — the suggested assertion doesn't do what it claims. expect.poll re-evaluates until the assertion passes and then returns, so toBe(1) stops the moment the counter hits 1 and is just as blind to a later duplicate as toBeGreaterThan(0). Asserting "exactly one" would need a settle-then-check (e.g. await the ✓, then a bare expect(validationCalls).toBe(1) outside poll).

The count is also one by construction: discovery is stubbed empty, so discoveredPromoCode is null and onTicketSelected takes the isApplied && !isDiscoveredCode branch at src/hooks/usePromoCode.js:191-194, which calls onRevalidate once and returns. The ticket-sync effect at src/components/ticket-type/index.js:108-115 takes the updatedCurrentTicket path once a ticket is set and never revalidates.

This thread can be resolved.

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);
});
});
29 changes: 29 additions & 0 deletions e2e/promo-code-discovery.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()],
Expand Down
17 changes: 5 additions & 12 deletions src/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);
};

Expand Down
20 changes: 11 additions & 9 deletions src/components/promocode-input/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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:
Expand Down Expand Up @@ -91,9 +93,9 @@ const PromoCodeInput = ({ promoStatus, promoCode, suggestedCode, isAutoApplied,
}}
readOnly={isLocked} />

{(promoStatus === PROMO_STATUS.VALIDATING || promoStatus === PROMO_STATUS.APPLYING) && <span className={`${styles.statusIcon} ${styles.spinner}`} />}
{promoStatus === PROMO_STATUS.VALID && <span className={`${styles.statusIcon} ${styles.valid}`}>✓</span>}
{promoStatus === PROMO_STATUS.INVALID && <span className={`${styles.statusIcon} ${styles.invalid}`}>✕</span>}
{promoStatus === PROMO_STATUS.PROCESSING && <span data-testid="promo-spinner" className={`${styles.statusIcon} ${styles.spinner}`} />}
{promoStatus === PROMO_STATUS.APPLIED && <span data-testid="promo-applied" className={`${styles.statusIcon} ${styles.valid}`}>✓</span>}
{promoStatus === PROMO_STATUS.INVALID && <span data-testid="promo-invalid" className={`${styles.statusIcon} ${styles.invalid}`}>✕</span>}
<div className={`${styles.codeButtonWrapper} ${inputValue ? '' : styles.noCode}`}>
{isLocked ?
<button onClick={onRemove}>Remove</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,6 @@ const defaultReduxState = {
},
promoCode: '',
promoCodeVerified: null,
promoCodeValidating: false,
promoCodeAllowsReassign: true,
discoveredPromoCodes: [],
requestedTicketTypes: false,
Expand Down
Loading
Loading