diff --git a/__tests__/shared/containers/challenge-listing/Listing.reviewOpportunities.jsx b/__tests__/shared/containers/challenge-listing/Listing.reviewOpportunities.jsx new file mode 100644 index 000000000..b48344834 --- /dev/null +++ b/__tests__/shared/containers/challenge-listing/Listing.reviewOpportunities.jsx @@ -0,0 +1,89 @@ +import { ListingContainer } from 'containers/challenge-listing/Listing'; +import { BUCKETS } from 'utils/challenge-listing/buckets'; + +/** + * Creates a challenge-listing container configured for an auth-change test. + * The returned instance is used to invoke the lifecycle method directly, + * without mounting the full connected challenge-listing tree. + * + * @param {String|null} tokenV3 Current Topcoder v3 token. + * @param {String} activeBucket Currently selected challenge-listing bucket. + * @returns {Object} Container instance and its mocked props. + */ +function createListing(tokenV3, activeBucket) { + const props = { + activeBucket, + auth: { + tokenV3, + user: { userId: 123, handle: 'member' }, + }, + communitiesList: { data: [] }, + communityId: null, + dropReviewOpportunities: jest.fn(), + filter: {}, + filterState: { recommended: false }, + getCommunitiesList: jest.fn(), + getReviewOpportunities: jest.fn(), + loading: false, + selectBucketDone: jest.fn(), + setFilter: jest.fn(), + sorts: {}, + }; + const instance = new ListingContainer(props); + instance.getBackendFilter = jest.fn(() => ({ back: {}, front: {} })); + instance.reloadChallenges = jest.fn(); + return { instance, props }; +} + +describe('challenge listing review opportunities authentication', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + test('reloads the first review-opportunity page when authentication arrives', () => { + const { instance, props } = createListing( + 'member-token', + BUCKETS.REVIEW_OPPORTUNITIES, + ); + + instance.componentDidUpdate({ + ...props, + auth: { ...props.auth, tokenV3: null }, + }); + + expect(props.dropReviewOpportunities).toHaveBeenCalledTimes(1); + expect(props.getReviewOpportunities).toHaveBeenCalledWith(0, 'member-token'); + }); + + test('reloads anonymously after logout so restricted rows cannot remain cached', () => { + const { instance, props } = createListing( + null, + BUCKETS.REVIEW_OPPORTUNITIES, + ); + + instance.componentDidUpdate({ + ...props, + auth: { ...props.auth, tokenV3: 'member-token' }, + }); + + expect(props.dropReviewOpportunities).toHaveBeenCalledTimes(1); + expect(props.getReviewOpportunities).toHaveBeenCalledWith(0, null); + }); + + test('clears cached opportunities without preloading an inactive bucket', () => { + const { instance, props } = createListing('member-token', BUCKETS.ALL); + + instance.componentDidUpdate({ + ...props, + auth: { ...props.auth, tokenV3: null }, + }); + + expect(props.dropReviewOpportunities).toHaveBeenCalledTimes(1); + expect(props.getReviewOpportunities).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/shared/reducers/challenge-listing/index.js b/__tests__/shared/reducers/challenge-listing/index.js new file mode 100644 index 000000000..6e1da5634 --- /dev/null +++ b/__tests__/shared/reducers/challenge-listing/index.js @@ -0,0 +1,30 @@ +import actions from 'actions/challenge-listing'; +import reducer from 'reducers/challenge-listing'; + +describe('challenge listing review-opportunity cache', () => { + test('drops caller-specific review opportunities without changing other buckets', () => { + const initialState = reducer(undefined, { type: '@@INIT' }); + const allChallenges = [{ id: 'public-challenge' }]; + const populatedState = { + ...initialState, + allChallenges, + allReviewOpportunitiesLoaded: true, + lastRequestedPageOfReviewOpportunities: 3, + loadingReviewOpportunitiesUUID: 'old-request', + reviewOpportunities: [{ id: 'restricted-opportunity' }], + }; + + const nextState = reducer( + populatedState, + actions.challengeListing.dropReviewOpportunities(), + ); + + expect(nextState).toEqual(expect.objectContaining({ + allReviewOpportunitiesLoaded: false, + lastRequestedPageOfReviewOpportunities: -1, + loadingReviewOpportunitiesUUID: '', + reviewOpportunities: [], + })); + expect(nextState.allChallenges).toBe(allChallenges); + }); +}); diff --git a/src/shared/actions/challenge-listing/index.js b/src/shared/actions/challenge-listing/index.js index 3e138b524..e89e25e7e 100644 --- a/src/shared/actions/challenge-listing/index.js +++ b/src/shared/actions/challenge-listing/index.js @@ -590,6 +590,7 @@ export default createActions({ DROP_PAST_CHALLENGES: _.noop, DROP_MY_PAST_CHALLENGES: _.noop, DROP_RECOMMENDED_CHALLENGES: _.noop, + DROP_REVIEW_OPPORTUNITIES: _.noop, // GET_ALL_ACTIVE_CHALLENGES_INIT: getAllActiveChallengesInit, // GET_ALL_ACTIVE_CHALLENGES_DONE: getAllActiveChallengesDone, diff --git a/src/shared/containers/challenge-listing/Listing/index.jsx b/src/shared/containers/challenge-listing/Listing/index.jsx index bfefdd764..129281055 100644 --- a/src/shared/containers/challenge-listing/Listing/index.jsx +++ b/src/shared/containers/challenge-listing/Listing/index.jsx @@ -109,7 +109,7 @@ export class ListingContainer extends React.Component { componentDidUpdate(prevProps) { const { - // activeBucket, + activeBucket, auth, // dropChallenges, communityId, @@ -134,6 +134,8 @@ export class ListingContainer extends React.Component { dropOpenForRegistrationChallenges, dropPastChallenges, getPastChallenges, + dropReviewOpportunities, + getReviewOpportunities, filterState, loading, setFilter, @@ -145,6 +147,12 @@ export class ListingContainer extends React.Component { if (userId !== oldUserId) { getCommunitiesList(auth); } + if (prevProps.auth.tokenV3 !== auth.tokenV3) { + dropReviewOpportunities(); + if (activeBucket === BUCKETS.REVIEW_OPPORTUNITIES) { + getReviewOpportunities(0, auth.tokenV3); + } + } // console.log(prevProps); // const { profile } = auth; // if (profile) { @@ -794,6 +802,7 @@ ListingContainer.propTypes = { dropOpenForRegistrationChallenges: PT.func.isRequired, dropActiveChallenges: PT.func.isRequired, dropPastChallenges: PT.func.isRequired, + dropReviewOpportunities: PT.func.isRequired, filter: PT.shape().isRequired, hideSrm: PT.bool, // hideTcLinksInSidebarFooter: PT.bool, @@ -997,6 +1006,7 @@ function mapDispatchToProps(dispatch) { dispatch(ca.getListDone(uuid, auth)); }, dropPastChallenges: () => dispatch(a.dropPastChallenges()), + dropReviewOpportunities: () => dispatch(a.dropReviewOpportunities()), getPastChallenges: (page, filter, token, frontFilter) => { const uuid = shortId(); dispatch(a.getPastChallengesInit(uuid, page, frontFilter)); diff --git a/src/shared/reducers/challenge-listing/index.js b/src/shared/reducers/challenge-listing/index.js index 3f7ae44ed..3aa67382a 100644 --- a/src/shared/reducers/challenge-listing/index.js +++ b/src/shared/reducers/challenge-listing/index.js @@ -821,6 +821,13 @@ function create(initialState) { lastRequestedPageOfPastChallenges: -1, loadingPastChallengesUUID: '', }), + [a.dropReviewOpportunities]: state => ({ + ...state, + allReviewOpportunitiesLoaded: false, + reviewOpportunities: [], + lastRequestedPageOfReviewOpportunities: -1, + loadingReviewOpportunitiesUUID: '', + }), [a.expandTag]: (state, { payload }) => ({ ...state, expandedTags: [...state.expandedTags, payload],