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
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
"mini-css-extract-plugin": "^2.6.0",
"moment": "^2.22.2",
"moment-timezone": "^0.5.21",
"openstack-uicore-foundation": "4.2.14",
"openstack-uicore-foundation": "4.2.31",
"optimize-css-assets-webpack-plugin": "^6.0.1",
"path": "^0.12.7",
"react": "^16.8.4",
Expand All @@ -104,6 +104,7 @@
"regenerator-runtime": "^0.13.7",
"sass": "^1.77.0",
"sass-loader": "^12.6.0",
"spark-md5": "^3.0.2",
"style-loader": "^3.3.1",
"superagent": "^3.8.1",
"sweetalert2": "^8.15.2",
Expand All @@ -129,7 +130,7 @@
"lodash": "^4.17.14",
"moment": "^2.22.2",
"moment-timezone": "^0.5.21",
"openstack-uicore-foundation": "4.2.9",
"openstack-uicore-foundation": "4.2.31",
"react": "^16.8.4",
"react-bootstrap": "^0.31.5",
"react-datetime": "^2.16.2",
Expand All @@ -140,6 +141,7 @@
"redux": "^4.0.5",
"redux-persist": "^5.9.1",
"redux-thunk": "^2.3.0",
"spark-md5": "^3.0.2",
"superagent": "^3.8.1",
"sweetalert2": "^8.15.2",
"urijs": "^1.19.1",
Expand Down
4 changes: 0 additions & 4 deletions src/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ export const GO_TO_LOGIN = 'GO_TO_LOGIN';
export const GET_MY_INVITATION = 'GET_MY_INVITATION';
export const CLEAR_MY_INVITATION = 'CLEAR_MY_INVITATION';
export const CLEAR_WIDGET_STATE = 'CLEAR_WIDGET_STATE';
export const UPDATE_CLOCK = 'UPDATE_CLOCK';
export const LOAD_PROFILE_DATA = 'LOAD_PROFILE_DATA';

export const SET_CURRENT_PROMO_CODE = 'SET_CURRENT_PROMO_CODE';
Expand Down Expand Up @@ -525,6 +524,3 @@ export const getMyInvitation = (summitId) => async (dispatch, getState, { apiBas
}
}

export const updateClock = (timestamp) => (dispatch) => {
dispatch(createAction(UPDATE_CLOCK)({ timestamp }));
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React from 'react';
import { cleanup, render, act } from '@testing-library/react';
import '@testing-library/jest-dom';

// Settable clock seed (the `mock` prefix is the only identifier jest.mock
// allows the factory to capture via closure).
let mockClockNow = 0;
jest.mock('openstack-uicore-foundation/lib/components/clock-context', () => ({
useClockSelector: (selector) => selector(mockClockNow),
}));

// epochToMomentTimeZone returns an object with .format(). Keep it cheap and
// deterministic so the test doesn't depend on moment / timezone data.
jest.mock('openstack-uicore-foundation/lib/utils/methods', () => ({
epochToMomentTimeZone: jest.fn(() => ({ format: (fmt) => fmt === 'MMMM D' ? 'January 1' : '09:00 AM' })),
}));

jest.mock('openstack-uicore-foundation/lib/components/raw-html', () => (props) => <div>{props.children}</div>);

import PurchaseComplete from '..';

const SUMMIT = {
start_date: 1_700_000_000, // ~Nov 2023
end_date: 1_700_086_400,
time_zone_id: 'UTC',
time_zone_label: 'UTC',
};

const defaultProps = {
checkout: { tickets: [{ owner: { email: 'john@example.com' }, badge: {} }] },
user: { email: 'john@example.com' },
onPurchaseComplete: jest.fn(),
goToExtraQuestions: jest.fn(),
goToEvent: jest.fn(),
goToMyOrders: jest.fn(),
// The component waits on this promise before rendering the branch we want
// to assert, so resolve it synchronously to a deterministic value.
completedExtraQuestions: jest.fn(() => Promise.resolve(false)),
summit: SUMMIT,
clearWidgetState: jest.fn(),
closeWidget: jest.fn(),
supportEmail: 'help@example.com',
hasVirtualAccessLevel: false,
};

afterEach(() => {
cleanup();
jest.clearAllMocks();
mockClockNow = 0;
});

const renderAndFlush = async (props = {}) => {
let utils;
await act(async () => {
utils = render(<PurchaseComplete {...defaultProps} {...props} />);
});
return utils;
};

it('renders the active CTA path when the clock seed falls inside the summit window', async () => {
mockClockNow = SUMMIT.start_date + 1000;
const { queryByText } = await renderAndFlush();

// The "event will start on…" copy belongs to the inactive branch.
expect(queryByText(/event will start on/i)).not.toBeInTheDocument();
// The CTA in the active path with no required extra questions falls through
// to the My Orders/Tickets button.
expect(queryByText('View My Orders/Tickets')).toBeInTheDocument();
});

it('renders the "event will start" copy when the clock seed is outside the summit window', async () => {
mockClockNow = SUMMIT.end_date + 1; // one second past end
const { queryByText } = await renderAndFlush();

expect(queryByText(/The event will start on January 1 at 09:00 AM UTC/)).toBeInTheDocument();
// CTA still renders in the inactive branch (different layout).
expect(queryByText('View My Orders/Tickets')).toBeInTheDocument();
});
13 changes: 10 additions & 3 deletions src/components/purchase-complete/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
* limitations under the License.
**/

import React, { useEffect, useState, useMemo } from 'react';
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import styles from './index.module.scss';
import { epochToMomentTimeZone } from 'openstack-uicore-foundation/lib/utils/methods';
import { useClockSelector } from 'openstack-uicore-foundation/lib/components/clock-context';
import ContentLoader from 'react-content-loader';
import { isEmptyString, ticketHasAccessLevel } from '../../utils/utils';
import { VirtualAccessLevel } from '../../utils/constants';
Expand All @@ -39,7 +40,6 @@ const PurchaseComplete = ({
goToMyOrders,
completedExtraQuestions,
summit,
nowUtc,
clearWidgetState,
closeWidget,
supportEmail,
Expand All @@ -54,7 +54,14 @@ const PurchaseComplete = ({
const [requireExtraQuestions, setRequireExtraQuestions] = useState(null);
const [extraQuestionsLoaded, setExtraQuestonsLoaded] = useState(false);
const isMultiOrder = useMemo(() => checkout?.tickets.length > 1, [checkout]);
const isActive = useMemo(() => summit.start_date <= nowUtc && summit.end_date >= nowUtc, [summit, nowUtc]);
// Re-runs every clock tick but the boolean only changes on summit
// active/inactive transitions (typically zero times per session).
const isActive = useClockSelector(
useCallback(
(nowUtc) => summit.start_date <= nowUtc && summit.end_date >= nowUtc,

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.

No __tests__/ directory exists for PurchaseComplete. The isActive value is now derived from useClockSelector, but neither branch is exercised: the active-summit CTA path (isActive === true) and the "event will start on…" path (isActive === false) are both untested.

Missing: mount PurchaseComplete with a mocked useClockSelector that returns a timestamp inside summit.start_date / summit.end_date and assert the active CTA renders; then one where the timestamp is outside the window and assert the "event will start" copy renders.

[summit.start_date, summit.end_date]
)
);
const currentTicket = useMemo(
() => isMultiOrder ? checkout?.tickets.find(t => t?.owner?.email === user?.email) : checkout?.tickets.find(t => t?.owner),
[user]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,22 @@ import { Provider } from 'react-redux';
import { createStore, combineReducers, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';

// Mock withReduxProvider as identity HOC
jest.mock('../../../utils/withReduxProvider', () => ({
withReduxProvider: (Component) => Component,
// Mock withWidgetProviders as identity HOC
jest.mock('../../../utils/withWidgetProviders', () => ({
withWidgetProviders: (Component) => Component,
__esModule: true,
default: (Component) => Component,
}));

// Stub the uicore clock-context so the selector runs once against a fixed
// timestamp. The seed is settable per test via `mockClockNow` (the `mock`
// prefix is the only identifier jest.mock allows the factory to capture).
let mockClockNow = 1000000;
jest.mock('openstack-uicore-foundation/lib/components/clock-context', () => ({
useClockSelector: (selector) => selector(mockClockNow),
ClockProvider: ({ children }) => children,
}));

// Mock action creators
const mockChangeStep = jest.fn();
const mockClearWidgetState = jest.fn();
Expand All @@ -24,7 +33,6 @@ const mockGetLoginCode = jest.fn();
const mockPasswordlessLogin = jest.fn();
const mockGoToLogin = jest.fn();
const mockGetMyInvitation = jest.fn(() => Promise.resolve());
const mockUpdateClock = jest.fn();
const mockLoadProfileData = jest.fn();
const mockApplyPromoCode = jest.fn();
const mockRemovePromoCode = jest.fn();
Expand Down Expand Up @@ -78,10 +86,6 @@ jest.mock('../../../actions', () => ({
mockGetMyInvitation(...args);
return Promise.resolve();
},
updateClock: (...args) => {
mockUpdateClock(...args);
return { type: 'NOOP' };
},
loadProfileData: (...args) => {
mockLoadProfileData(...args);
return { type: 'NOOP' };
Expand Down Expand Up @@ -117,10 +121,6 @@ jest.mock('openstack-uicore-foundation/lib/components/ajaxloader', () => {
return (props) => <div data-testid="ajax-loader" />;
});

jest.mock('openstack-uicore-foundation/lib/components/clock', () => {
return (props) => <div data-testid="clock" />;
});

jest.mock('openstack-uicore-foundation/lib/security/constants', () => ({
AUTH_ERROR_MISSING_AUTH_INFO: 'Missing Auth info',
AUTH_ERROR_MISSING_REFRESH_TOKEN: 'missing Refresh Token',
Expand All @@ -130,7 +130,11 @@ jest.mock('openstack-uicore-foundation/lib/security/constants', () => ({
jest.mock('../../login', () => () => <div data-testid="login" />);
jest.mock('../../payment', () => () => <div data-testid="payment" />);
jest.mock('../../personal-information', () => () => <div data-testid="personal-info" />);
jest.mock('../../ticket-type', () => () => <div data-testid="ticket-type" />);
const mockTicketTypeProps = { current: null };
jest.mock('../../ticket-type', () => (props) => {
mockTicketTypeProps.current = props;
return <div data-testid="ticket-type" />;
});
jest.mock('../../button-bar', () => () => <div data-testid="button-bar" />);
jest.mock('../../purchase-complete', () => () => <div data-testid="purchase-complete" />);
jest.mock('../../login-passwordless', () => () => <div data-testid="passwordless-login" />);
Expand All @@ -154,7 +158,7 @@ jest.mock('react-use', () => ({
useMeasure: () => [jest.fn(), { height: 100 }],
}));

// Import default (which is withReduxProvider(RegistrationForm) but our mock makes it identity)
// Import default (which is withWidgetProviders(RegistrationForm) but our mock makes it identity)
import RegistrationForm from '..';

const STEP_SELECT_TICKET_TYPE = 0;
Expand All @@ -180,7 +184,6 @@ const defaultReduxState = {
summitId: null,
userProfile: null,
},
nowUtc: 1000000,
promoCode: '',
promoCodeVerified: null,
promoCodeValidating: false,
Expand Down Expand Up @@ -240,6 +243,8 @@ const renderWithStore = (props = {}, stateOverrides = {}) => {
afterEach(() => {
cleanup();
jest.clearAllMocks();
mockClockNow = 1000000;
mockTicketTypeProps.current = null;
});

it('closeHandlerRef is assigned handleCloseClick via useEffect', () => {
Expand Down Expand Up @@ -411,3 +416,51 @@ it('refires ticket-types fetch and promo discovery when summit id changes', asyn
expect(mockDiscoverPromoCodes).toHaveBeenCalledTimes(1);
expect(mockDiscoverPromoCodes).toHaveBeenCalledWith(2);
});

// Exercises isTicketCurrentlyAvailable through the rendered prop, with the
// clock seed deliberately landing inside one ticket's sales window and outside
// the other's. Without this, a regression in the helper would slip through
// because the rest of the suite uses a 1970 seed against which every realistic
// window evaluates to false.
it('allowedTicketTypes contains the ticket whose sales window covers the clock seed', async () => {
mockClockNow = 1700000000; // 2023-11-14, well inside the in-window fixture
const inWindow = {
id: 101,
name: 'In Window',
sub_type: 'Regular',
sales_start_date: 1699000000,
sales_end_date: 1701000000,
};
const expired = {
id: 102,
name: 'Expired',
sub_type: 'Regular',
sales_start_date: 1690000000,
sales_end_date: 1695000000,
};
const alwaysOpen = {
id: 103,
name: 'Always Open',
sub_type: 'Regular',
sales_start_date: null,
sales_end_date: null,
};
const prepaidExpired = {
id: 104,
name: 'Prepaid Past',
sub_type: 'PrePaid',
sales_start_date: 1690000000,
sales_end_date: 1695000000,
};

renderWithStore(
{ ownedTickets: [] },
{ ticketTypes: [inWindow, expired, alwaysOpen, prepaidExpired] },
);
await act(async () => {});

expect(mockTicketTypeProps.current).not.toBeNull();
const ids = mockTicketTypeProps.current.allowedTicketTypes.map((t) => t.id);
expect(ids).toEqual(expect.arrayContaining([101, 103, 104]));
expect(ids).not.toContain(102);
});
Loading
Loading