From 4d5420e0d6c5dd1273a09cfda6bd51c7bdb8224a Mon Sep 17 00:00:00 2001 From: jmgasper Date: Mon, 24 Aug 2026 06:06:33 +1000 Subject: [PATCH] PM-5895: refresh review opportunities after auth changes What was broken Group-restricted review opportunities remained missing for admins and eligible group members even after the earlier fix began forwarding bearer tokens. A restricted result already loaded for one caller could also remain cached after the caller logged out. Root cause Community app authentication is populated asynchronously. The Open for Review bucket could therefore complete its first request anonymously before tokenV3 arrived. The review-opportunity rows and all-loaded pagination flag were never invalidated when authentication changed, so the authenticated request was not made and the anonymous result set stayed on screen. What was changed - Added a focused action and reducer path that clears only review-opportunity rows, pagination, loading, and completion state. - Clear that caller-specific cache whenever tokenV3 changes. - Immediately reload page zero with the current token when Open for Review is active; inactive buckets remain lazy-loaded. Any added/updated tests - Added lifecycle coverage for delayed authentication, logout, and auth changes while another bucket is active. - Added reducer coverage proving the review-opportunity cache resets without changing other challenge buckets. - Verified the full test suite, lint, and production build. --- .../Listing.reviewOpportunities.jsx | 89 +++++++++++++++++++ .../reducers/challenge-listing/index.js | 30 +++++++ src/shared/actions/challenge-listing/index.js | 1 + .../challenge-listing/Listing/index.jsx | 12 ++- .../reducers/challenge-listing/index.js | 7 ++ 5 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 __tests__/shared/containers/challenge-listing/Listing.reviewOpportunities.jsx create mode 100644 __tests__/shared/reducers/challenge-listing/index.js 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],