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
36 changes: 36 additions & 0 deletions src/components/ticket-type/__tests__/per-order-notice.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from 'react';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import T from 'i18n-react';
import TicketTypeComponent from '..';

T.setTexts(require('../../../i18n/en.json'));

const unlimitedStockTicket = {
id: 1,
name: 'General Admission',
currency: 'USD',
currency_symbol: '$',
quantity_2_sell: 0, // API semantics: 0 = unlimited stock
quantity_sold: 10,
max_quantity_per_order: 4, // real, binding per-order cap
};

it('shows the per-order limit notice when stock is unlimited (quantity_2_sell 0)', () => {
const { getByText } = render(
<TicketTypeComponent
isActive
allowedTicketTypes={[unlimitedStockTicket]}
originalTicketTypes={[unlimitedStockTicket]}
taxTypes={[]}
changeForm={jest.fn()}
trackViewItem={jest.fn()}
allowPromoCodes={false}
reservation={{ tickets: [{ ticket_type_id: 1 }] }}
/>
);

// The stepper caps at 4 (getTicketMaxQuantity treats quantity_2_sell 0 as
// unlimited stock), so the notice explaining that cap must be shown.
expect(getByText('This ticket type is limited to 4 per order.')).toBeInTheDocument();
});
2 changes: 1 addition & 1 deletion src/components/ticket-type/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ const TicketTypeComponent = ({
const ticketPerOrderLimit = useMemo(() => {
if (!ticket) return null;
const cap = ticket.max_quantity_per_order;
const inventory = (ticket.quantity_2_sell ?? Number.MAX_SAFE_INTEGER) - (ticket.quantity_sold ?? 0);
const inventory = (ticket.quantity_2_sell || Number.MAX_SAFE_INTEGER) - (ticket.quantity_sold ?? 0);
return cap != null && cap > 0 && cap < inventory ? cap : null;
}, [ticket]);

Expand Down
54 changes: 54 additions & 0 deletions src/helpers/__tests__/getTicketMaxQuantity.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { getTicketMaxQuantity } from '../getTicketMaxQuantity';
import { TICKET_TYPE_SUBTYPE_PREPAID } from '../../utils/constants';

describe('getTicketMaxQuantity', () => {
it('returns 0 when no ticket is given', () => {
expect(getTicketMaxQuantity(null)).toBe(0);
});

it('caps at the remaining stock when there is a real per-order limit', () => {
// 100 to sell, 90 sold, up to 5 per order -> per-order limit wins
const ticket = { quantity_2_sell: 100, quantity_sold: 90, max_quantity_per_order: 5 };
expect(getTicketMaxQuantity(ticket)).toBe(5);
});

it('caps at remaining stock when stock is the tighter limit', () => {
// 100 to sell, 97 sold, up to 5 per order -> only 3 left
const ticket = { quantity_2_sell: 100, quantity_sold: 97, max_quantity_per_order: 5 };
expect(getTicketMaxQuantity(ticket)).toBe(3);
});

it('treats max_quantity_per_order of 0 as unlimited (the sold-out bug)', () => {
// ticket 212 from prod: 2900 to sell, 2832 sold, per-order limit 0 (API = no limit)
// 68 tickets remain, so it must NOT read as sold out
const ticket = { quantity_2_sell: 2900, quantity_sold: 2832, max_quantity_per_order: 0 };
expect(getTicketMaxQuantity(ticket)).toBe(68);
});

it('treats quantity_2_sell of 0 as unlimited stock', () => {
// 0 to sell = no cap on stock; per-order limit of 4 is the only bound
const ticket = { quantity_2_sell: 0, quantity_sold: 10, max_quantity_per_order: 4 };
expect(getTicketMaxQuantity(ticket)).toBe(4);

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 The quantity_sold guard the PR description advertises ("guards quantity_sold when absent") has no test pinning it: every case in this file supplies quantity_sold, so reverting the ?? 0 in the helper (NaN propagates through Math.min, making ticketSelectionValid permanently false and blocking the Next button) fails nothing in the suite.

Why it matters: both API serializers currently always emit quantity_sold as an int, so this is contract-pinning rather than a reachable production bug — but an unpinned guard is the first thing a future refactor silently drops.

Suggested test (passes on this branch, fails with Expected: 5, Received: NaN when the ?? 0 guard is removed — verified locally both ways):

it('defaults quantity_sold to 0 when the API omits it', () => {
    const ticket = { quantity_2_sell: 100, max_quantity_per_order: 5 };
    expect(getTicketMaxQuantity(ticket)).toBe(5);
});

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added your suggested test. Confirmed it fails with NaN when the ?? 0 guard is removed and passes with it.

});

it('defaults quantity_sold to 0 when the API omits it', () => {
const ticket = { quantity_2_sell: 100, max_quantity_per_order: 5 };
expect(getTicketMaxQuantity(ticket)).toBe(5);
});

it('still returns <= 0 for a genuinely sold-out ticket', () => {
// real cap reached: 100 to sell, 100 sold
const ticket = { quantity_2_sell: 100, quantity_sold: 100, max_quantity_per_order: 5 };
expect(getTicketMaxQuantity(ticket)).toBeLessThan(1);
});

it('applies the remaining-per-account cap when it is the tightest', () => {
const ticket = { quantity_2_sell: 100, quantity_sold: 10, max_quantity_per_order: 10 };
expect(getTicketMaxQuantity(ticket, 2)).toBe(2);
});

it('always returns 1 for prepaid ticket types', () => {
const ticket = { sub_type: TICKET_TYPE_SUBTYPE_PREPAID, quantity_2_sell: 0, quantity_sold: 0, max_quantity_per_order: 0 };
expect(getTicketMaxQuantity(ticket)).toBe(1);
});
});
5 changes: 4 additions & 1 deletion src/helpers/getTicketMaxQuantity.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { isPrePaidTicketType } from '../utils/utils';
export const getTicketMaxQuantity = (ticket, remainingQuantityPerAccount) => {
if(!ticket) return 0;
if(isPrePaidTicketType(ticket)) return 1;
let max = Math.min((ticket.quantity_2_sell ?? Number.MAX_SAFE_INTEGER) - ticket.quantity_sold, (ticket.max_quantity_per_order ?? Number.MAX_SAFE_INTEGER));
// The API treats 0 as "no limit" for both fields; only a positive value is a real cap.
const quantityToSell = ticket.quantity_2_sell || Number.MAX_SAFE_INTEGER;

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 The 0-means-unlimited semantics this line establishes are still missing in one sibling consumer of the same fields: the ticketPerOrderLimit memo in src/components/ticket-type/index.js (lines 132-136) computes inventory = (ticket.quantity_2_sell ?? Number.MAX_SAFE_INTEGER) - (ticket.quantity_sold ?? 0), so a ticket with quantity_2_sell: 0 yields inventory <= 0 and cap < inventory is always false — the "limited to N per order" notice is never shown.

Why it matters: quantity_2_sell defaults to 0 in the API model (SummitTicketType.php constructor), so unlimited-stock tickets with a real per-order cap are a common configuration. For those tickets the stepper now correctly stops at the cap (thanks to this PR's fix in getTicketMaxQuantity), but the notice explaining that cap is silently suppressed — the user sees the + button disable with no explanation. The memo's own comment says the cap should surface when it is "the binding constraint on the stepper", which is exactly this case.

Suggested fix (one line, same semantics as this helper):

const inventory = (ticket.quantity_2_sell || Number.MAX_SAFE_INTEGER) - (ticket.quantity_sold ?? 0);

Red test that verifies it (fails on this branch with "Unable to find an element with the text: This ticket type is limited to 4 per order.", passes with the one-line fix — verified locally both ways). Suggested location: src/components/ticket-type/__tests__/per-order-notice.test.js:

import React from 'react';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import T from 'i18n-react';
import TicketTypeComponent from '..';

T.setTexts(require('../../../i18n/en.json'));

const unlimitedStockTicket = {
    id: 1,
    name: 'General Admission',
    currency: 'USD',
    currency_symbol: '$',
    quantity_2_sell: 0,          // API semantics: 0 = unlimited stock
    quantity_sold: 10,
    max_quantity_per_order: 4,   // real, binding per-order cap
};

it('shows the per-order limit notice when stock is unlimited (quantity_2_sell 0)', () => {
    const { getByText } = render(
        <TicketTypeComponent
            isActive
            allowedTicketTypes={[unlimitedStockTicket]}
            originalTicketTypes={[unlimitedStockTicket]}
            taxTypes={[]}
            changeForm={jest.fn()}
            trackViewItem={jest.fn()}
            allowPromoCodes={false}
            reservation={{ tickets: [{ ticket_type_id: 1 }] }}
        />
    );

    // The stepper caps at 4 (getTicketMaxQuantity treats quantity_2_sell 0 as
    // unlimited stock), so the notice explaining that cap must be shown.
    expect(getByText('This ticket type is limited to 4 per order.')).toBeInTheDocument();
});

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch, fixed. ticketPerOrderLimit had the same quantity_2_sell ?? MAX, so with 0 stock the inventory went negative and the notice was suppressed. Changed to || MAX and added your per-order-notice test. Verified red before, green after.

const maxPerOrder = ticket.max_quantity_per_order || Number.MAX_SAFE_INTEGER;
let max = Math.min(quantityToSell - (ticket.quantity_sold ?? 0), maxPerOrder);
if (remainingQuantityPerAccount != null) {
max = Math.min(max, remainingQuantityPerAccount);
}
Expand Down
23 changes: 19 additions & 4 deletions src/hooks/__tests__/usePromoCode.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1180,17 +1180,32 @@ describe('maxQuantityFromPromo', () => {
expect(result.current.state.maxQuantityFromPromo).toBe(3);
});

it('caps at 0 when quantity_available is 0 (sold out)', async () => {
it('treats quantity_available of 0 as unlimited, falling back to the per-account cap', async () => {
// The API treats quantity_available 0 as "no limit" (hasQuantityAvailable),
// so it must not zero the stepper; the per-account remaining is the only cap.
const codes = [{
code: 'SOLDOUT',
code: 'UNLIMITEDUSES',
auto_apply: true,
allowed_ticket_types: [],
quantity_per_account: 5,
remaining_quantity_per_account: 3,
quantity_available: 0,
}];
const { result } = await renderVerified({ discoveredPromoCodes: codes, promoCode: 'SOLDOUT' });
expect(result.current.state.maxQuantityFromPromo).toBe(0);
const { result } = await renderVerified({ discoveredPromoCodes: codes, promoCode: 'UNLIMITEDUSES' });
expect(result.current.state.maxQuantityFromPromo).toBe(3);
});

it('null when quantity_available is 0 and there is no per-account cap', async () => {
const codes = [{
code: 'FULLYUNLIMITED',
auto_apply: true,
allowed_ticket_types: [],
quantity_per_account: 0,
remaining_quantity_per_account: null,
quantity_available: 0,
}];
const { result } = await renderVerified({ discoveredPromoCodes: codes, promoCode: 'FULLYUNLIMITED' });
expect(result.current.state.maxQuantityFromPromo).toBeNull();
});

it('uses only quantity_available when remaining_quantity_per_account is null', async () => {
Expand Down
8 changes: 5 additions & 3 deletions src/hooks/usePromoCode.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,16 @@ const usePromoCode = ({
? activeDiscoveredCode.remaining_quantity_per_account : null;

// Tightest promo-code-level quantity cap for the stepper (discovered codes only).
// Both cap sources use `!= null` so a value of 0 (sold-out / no remaining) caps the
// stepper at 0 instead of being silently ignored.
// remaining_quantity_per_account is a real per-account count: null means no limit,
// and the API drops a code once the account exhausts it, so it is never 0 here.
// quantity_available is a total-use cap where 0 means "no limit" (matches the API's
// hasQuantityAvailable), so only a positive value is a real cap on the stepper.
const maxQuantityFromPromo = useMemo(() => {
if (!activeDiscoveredCode) return null;
const caps = [];
if (activeDiscoveredCode.remaining_quantity_per_account != null)
caps.push(activeDiscoveredCode.remaining_quantity_per_account);
if (activeDiscoveredCode.quantity_available != null)
if (activeDiscoveredCode.quantity_available)
caps.push(activeDiscoveredCode.quantity_available);
return caps.length > 0 ? Math.min(...caps) : null;
}, [activeDiscoveredCode]);
Expand Down
Loading