From 34e4770a08b1b30714716575c28ac62f57c29935 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Mon, 10 Aug 2026 07:41:12 +1000 Subject: [PATCH] PM-5758: honor design challenge submission limits What was broken Community challenge details treated serialized submission-limit metadata as a number, so configured limits displayed as Unlimited. Members could also attempt another submission after reaching a configured limit without the requested guidance. Root cause The sidebar expected a legacy scalar metadata value, while current challenges store a JSON string. Submission entry points did not read that metadata or verify the member's existing submissions. What was changed Added safe parsing for current and legacy submission-limit metadata, displayed the configured count in the challenge sidebar, and guarded both the challenge submit action and the final submission boundary. Members at the limit now see the requested Submission Limit Reached message and must delete an existing submission before replacing it. Any added/updated tests Added parser and message tests, header action coverage, and submission-boundary tests for unlimited, below-limit, reached-limit, and lookup-failure cases. --- .../challenge-detail/Header/index.jsx | 99 ++++++++++- .../shared/containers/SubmissionPage.jsx | 157 ++++++++++++++++++ .../challenge-detail/submission-limit.test.js | 75 +++++++++ .../challenge-detail/Header/index.jsx | 19 ++- .../Specification/SideBar/index.jsx | 12 +- src/shared/containers/SubmissionPage.jsx | 52 +++++- .../challenge-detail/submission-limit.js | 115 +++++++++++++ 7 files changed, 516 insertions(+), 13 deletions(-) create mode 100644 __tests__/shared/containers/SubmissionPage.jsx create mode 100644 __tests__/shared/utils/challenge-detail/submission-limit.test.js create mode 100644 src/shared/utils/challenge-detail/submission-limit.js diff --git a/__tests__/shared/components/challenge-detail/Header/index.jsx b/__tests__/shared/components/challenge-detail/Header/index.jsx index 9a51b14d5..5270b4962 100644 --- a/__tests__/shared/components/challenge-detail/Header/index.jsx +++ b/__tests__/shared/components/challenge-detail/Header/index.jsx @@ -1,9 +1,33 @@ import React from 'react'; import Renderer from 'react-test-renderer/shallow'; +import { errors as mockedErrors } from 'topcoder-react-lib'; import Header from 'components/challenge-detail/Header'; import TabSelector from 'components/challenge-detail/Header/TabSelector'; +jest.mock('topcoder-react-lib', () => ({ + challenge: { + filter: {}, + }, + errors: { + fireErrorMessage: jest.fn(), + }, + services: { + api: {}, + }, + tc: { + CHALLENGE_STATUS: { + ACTIVE: 'ACTIVE', + COMPLETED: 'COMPLETED', + }, + OLD_COMPETITION_TRACKS: {}, + }, +})); + +jest.mock('topcoder-react-ui-kit', () => ({ + PrimaryButton: () => null, +})); + jest.mock('react-responsive', () => ({ useMediaQuery: () => true, })); @@ -21,7 +45,28 @@ function collectText(node) { .reduce((acc, child) => acc.concat(collectText(child)), []); } -function renderHeader(challengeOverrides = {}) { +function findSubmitAction(node) { + if (!React.isValidElement(node)) { + return null; + } + + if ((node.props.to || node.props.onClick) + && collectText(node).includes('Submit a solution')) { + return node; + } + + const children = React.Children.toArray(node.props.children); + for (let index = 0; index < children.length; index += 1) { + const match = findSubmitAction(children[index]); + if (match) { + return match; + } + } + + return null; +} + +function renderHeader(challengeOverrides = {}, propOverrides = {}) { const renderer = new Renderer(); renderer.render(
, ); @@ -89,6 +135,10 @@ function renderHeader(challengeOverrides = {}) { } describe('Challenge detail header actions', () => { + beforeEach(() => { + mockedErrors.fireErrorMessage.mockClear(); + }); + test('hides registration and submission actions for classic task challenges', () => { const output = renderHeader({ type: 'Task', @@ -129,6 +179,53 @@ describe('Challenge detail header actions', () => { expect(collectText(output)).toContain('Register'); expect(collectText(output)).toContain('Submit a solution'); }); + + test('shows the limit-reached message instead of opening the submission page', () => { + const output = renderHeader({ + metadata: [{ + name: 'submissionLimit', + value: JSON.stringify({ + count: '1', + limit: 'true', + unlimited: 'false', + }), + }], + }, { + hasRegistered: true, + mySubmissions: [{ id: 'submission-id' }], + }); + const submitAction = findSubmitAction(output); + + expect(submitAction.props.to).toBeUndefined(); + submitAction.props.onClick(); + + expect(mockedErrors.fireErrorMessage).toHaveBeenCalledWith( + 'Submission Limit Reached', + 'This challenge allows only one submission, and you\'ve already submitted.' + + ' To replace it, delete your existing submission first.', + ); + }); + + test('keeps the submission page available while slots remain', () => { + const output = renderHeader({ + metadata: [{ + name: 'submissionLimit', + value: JSON.stringify({ + count: '2', + limit: 'true', + unlimited: 'false', + }), + }], + }, { + hasRegistered: true, + mySubmissions: [{ id: 'submission-id' }], + }); + const submitAction = findSubmitAction(output); + + expect(submitAction.props.to).toBe('/challenges/challenge-id/submit'); + expect(submitAction.props.onClick).toBeUndefined(); + expect(mockedErrors.fireErrorMessage).not.toHaveBeenCalled(); + }); }); describe('Challenge detail tab counts', () => { diff --git a/__tests__/shared/containers/SubmissionPage.jsx b/__tests__/shared/containers/SubmissionPage.jsx new file mode 100644 index 000000000..9d8541e1b --- /dev/null +++ b/__tests__/shared/containers/SubmissionPage.jsx @@ -0,0 +1,157 @@ +import { errors as mockedErrors } from 'topcoder-react-lib'; +import { getChallengeSubmissions as mockedGetChallengeSubmissions } from 'services/submissions'; + +import { SubmissionsPageContainer } from 'containers/SubmissionPage'; + +jest.mock('topcoder-react-lib', () => ({ + actions: { + challenge: {}, + }, + errors: { + fireErrorMessage: jest.fn(), + }, +})); + +jest.mock('services/submissions', () => ({ + getChallengeSubmissions: jest.fn(), +})); + +jest.mock('actions/page/submission', () => ({ + page: { + submission: {}, + }, +})); +jest.mock('actions/page/challenge-details', () => ({ + page: { + challengeDetails: {}, + }, +})); +jest.mock('actions/tc-communities', () => ({ + tcCommunity: {}, +})); +jest.mock('components/SubmissionPage', () => () => null); +jest.mock('components/tc-communities/AccessDenied', () => ({ + __esModule: true, + CAUSE: { + NOT_AUTHORIZED: 'NOT_AUTHORIZED', + }, + default: () => null, +})); +jest.mock('components/LoadingIndicator', () => () => null); +jest.mock('topcoder-react-ui-kit', () => ({ + PrimaryButton: () => null, +})); + +function createContainerProps(overrides = {}) { + return { + challenge: {}, + challengeId: 'challenge-id', + metadata: [], + submit: jest.fn(), + tokenV2: 'token-v2', + tokenV3: 'token-v3', + track: 'Design', + userId: 'member-id', + ...overrides, + }; +} + +describe('SubmissionsPageContainer submission limits', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('skips the limit lookup for unlimited challenges', async () => { + const props = createContainerProps(); + const container = new SubmissionsPageContainer(props); + const body = {}; + + await container.handleSubmit(body); + + expect(mockedGetChallengeSubmissions).not.toHaveBeenCalled(); + expect(props.submit).toHaveBeenCalledWith( + 'token-v3', + 'token-v2', + 'challenge-id', + body, + 'Design', + ); + }); + + test('submits while a limited challenge still has an available slot', async () => { + mockedGetChallengeSubmissions.mockResolvedValue({ + data: [{ id: 'submission-1' }], + }); + const props = createContainerProps({ + metadata: [{ + name: 'submissionLimit', + value: JSON.stringify({ + count: '2', + limit: 'true', + unlimited: 'false', + }), + }], + }); + const container = new SubmissionsPageContainer(props); + const body = {}; + + await container.handleSubmit(body); + + expect(mockedGetChallengeSubmissions).toHaveBeenCalledWith( + 'token-v3', + 'challenge-id', + { memberId: 'member-id' }, + ); + expect(props.submit).toHaveBeenCalled(); + expect(mockedErrors.fireErrorMessage).not.toHaveBeenCalled(); + }); + + test('shows the limit message and does not submit when the limit is reached', async () => { + mockedGetChallengeSubmissions.mockResolvedValue({ + data: [{ id: 'submission-1' }], + }); + const props = createContainerProps({ + metadata: [{ + name: 'submissionLimit', + value: JSON.stringify({ + count: '1', + limit: 'true', + unlimited: 'false', + }), + }], + }); + const container = new SubmissionsPageContainer(props); + + await container.handleSubmit({}); + + expect(props.submit).not.toHaveBeenCalled(); + expect(mockedErrors.fireErrorMessage).toHaveBeenCalledWith( + 'Submission Limit Reached', + 'This challenge allows only one submission, and you\'ve already submitted.' + + ' To replace it, delete your existing submission first.', + ); + }); + + test('does not submit when the existing-submission lookup fails', async () => { + mockedGetChallengeSubmissions.mockRejectedValue(new Error('network error')); + const props = createContainerProps({ + metadata: [{ + name: 'submissionLimit', + value: JSON.stringify({ + count: '1', + limit: 'true', + unlimited: 'false', + }), + }], + }); + const container = new SubmissionsPageContainer(props); + + await container.handleSubmit({}); + + expect(props.submit).not.toHaveBeenCalled(); + expect(mockedErrors.fireErrorMessage).toHaveBeenCalledWith( + 'Unable to Verify Submission Limit', + 'We could not verify your existing submissions. Please try again.', + ); + }); +}); diff --git a/__tests__/shared/utils/challenge-detail/submission-limit.test.js b/__tests__/shared/utils/challenge-detail/submission-limit.test.js new file mode 100644 index 000000000..80281bf86 --- /dev/null +++ b/__tests__/shared/utils/challenge-detail/submission-limit.test.js @@ -0,0 +1,75 @@ +/* eslint-env jest */ +import { + getSubmissionLimit, + getSubmissionLimitReachedMessage, +} from '../../../../src/shared/utils/challenge-detail/submission-limit'; + +describe('getSubmissionLimit', () => { + test('returns null when submission-limit metadata is missing', () => { + expect(getSubmissionLimit([])).toBeNull(); + }); + + test('returns null for the current unlimited payload', () => { + expect(getSubmissionLimit([{ + name: 'submissionLimit', + value: JSON.stringify({ + count: '', + limit: 'false', + unlimited: 'true', + }), + }])).toBeNull(); + }); + + test('returns the count for the current limited payload', () => { + expect(getSubmissionLimit([{ + name: 'submissionLimit', + value: JSON.stringify({ + count: '3', + limit: 'true', + unlimited: 'false', + }), + }])).toBe(3); + }); + + test('supports legacy numeric values', () => { + expect(getSubmissionLimit([{ + name: 'submissionLimit', + value: 1, + }])).toBe(1); + expect(getSubmissionLimit([{ + name: 'submissionLimit', + value: '2', + }])).toBe(2); + }); + + test('returns null for malformed and invalid counts', () => { + expect(getSubmissionLimit([{ + name: 'submissionLimit', + value: '{invalid', + }])).toBeNull(); + expect(getSubmissionLimit([{ + name: 'submissionLimit', + value: JSON.stringify({ + count: '0', + limit: 'true', + unlimited: 'false', + }), + }])).toBeNull(); + }); +}); + +describe('getSubmissionLimitReachedMessage', () => { + test('uses the requested singular limit message', () => { + expect(getSubmissionLimitReachedMessage(1)).toBe( + 'This challenge allows only one submission, and you\'ve already submitted.' + + ' To replace it, delete your existing submission first.', + ); + }); + + test('uses a plural message for larger limits', () => { + expect(getSubmissionLimitReachedMessage(3)).toBe( + 'This challenge allows only 3 submissions, and you\'ve already reached that limit.' + + ' To replace one, delete an existing submission first.', + ); + }); +}); diff --git a/src/shared/components/challenge-detail/Header/index.jsx b/src/shared/components/challenge-detail/Header/index.jsx index 345ed44c1..9f6786431 100644 --- a/src/shared/components/challenge-detail/Header/index.jsx +++ b/src/shared/components/challenge-detail/Header/index.jsx @@ -8,6 +8,7 @@ import _ from 'lodash'; import moment from 'moment'; import 'moment-duration-format'; +import { errors } from 'topcoder-react-lib'; import { isMM, getTrackName, getTypeName } from 'utils/challenge'; import PT from 'prop-types'; @@ -20,6 +21,10 @@ import { getTimeLeft, isRegistrationPhase, } from 'utils/challenge-detail/helper'; +import { + getSubmissionLimit, + getSubmissionLimitReachedMessage, +} from 'utils/challenge-detail/submission-limit'; import LeftArrow from 'assets/images/arrow-prev-blue.svg'; import IconsOpenInNew from 'assets/images/open_in_new.svg'; @@ -38,6 +43,7 @@ import style from './style.scss'; /* Holds day and hour range in ms. */ const HOUR_MS = 60 * 60 * 1000; const DAY_MS = 24 * HOUR_MS; +const { fireErrorMessage } = errors; export default function ChallengeHeader(props) { const { @@ -101,6 +107,9 @@ export default function ChallengeHeader(props) { } const showDeadlineDetail = showDeadlineDetailProp; const isActivedChallenge = `${status}`.indexOf(CHALLENGE_STATUS.ACTIVE) >= 0; + const submissionLimit = getSubmissionLimit(metadata); + const isSubmissionLimitReached = submissionLimit !== null + && mySubmissions.length >= submissionLimit; const allPhases = _.filter(challenge.phases || [], p => p.name !== 'Post-Mortem'); const sortedAllPhases = _.cloneDeep(allPhases) @@ -360,7 +369,15 @@ export default function ChallengeHeader(props) { fireErrorMessage( + 'Submission Limit Reached', + getSubmissionLimitReachedMessage(submissionLimit), + ) + : undefined} + to={isSubmissionLimitReached + ? undefined + : `${challengesUrl}/${challengeId}/submit`} forceA > diff --git a/src/shared/components/challenge-detail/Specification/SideBar/index.jsx b/src/shared/components/challenge-detail/Specification/SideBar/index.jsx index fef4ed762..67e6bf90c 100644 --- a/src/shared/components/challenge-detail/Specification/SideBar/index.jsx +++ b/src/shared/components/challenge-detail/Specification/SideBar/index.jsx @@ -9,6 +9,7 @@ import { Link } from 'react-router-dom'; import { config } from 'topcoder-react-utils'; import TooltipIcon from 'assets/images/tooltip-info.svg'; +import { getSubmissionLimit } from 'utils/challenge-detail/submission-limit'; import EligibleEvents from './EligibleEvents'; // import ShareSocial from './ShareSocial'; @@ -38,14 +39,13 @@ export default function SideBar({ const faqURL = config.URL.INFO.DESIGN_CHALLENGE_SUBMISSION; let submissionLimitDisplay = 'Unlimited'; const submissionLimit = _.find(metadata, { name: 'submissionLimit' }); + const submissionLimitCount = getSubmissionLimit(metadata); const fileTypes = _.find(metadata, { name: 'fileTypes' }); - if (submissionLimit) { - if (submissionLimit.value === 1) { - submissionLimitDisplay = '1 submission'; - } else if (submissionLimit.value > 1) { - submissionLimitDisplay = `${submissionLimit.value} submissions`; - } + if (submissionLimitCount === 1) { + submissionLimitDisplay = '1 submission'; + } else if (submissionLimitCount > 1) { + submissionLimitDisplay = `${submissionLimitCount} submissions`; } const reviewTypeTitle = reviewType === 'PEER' ? 'Peer Review' : 'Community Review Board'; diff --git a/src/shared/containers/SubmissionPage.jsx b/src/shared/containers/SubmissionPage.jsx index 5f5d6f196..98183c11e 100644 --- a/src/shared/containers/SubmissionPage.jsx +++ b/src/shared/containers/SubmissionPage.jsx @@ -8,8 +8,12 @@ */ import actions from 'actions/page/submission'; import challengeDetailsActions from 'actions/page/challenge-details'; -import { actions as api } from 'topcoder-react-lib'; +import { actions as api, errors } from 'topcoder-react-lib'; import { isMM } from 'utils/challenge'; +import { + getSubmissionLimit, + getSubmissionLimitReachedMessage, +} from 'utils/challenge-detail/submission-limit'; import communityActions from 'actions/tc-communities'; import { PrimaryButton } from 'topcoder-react-ui-kit'; import shortId from 'shortid'; @@ -19,11 +23,14 @@ import { connect } from 'react-redux'; import SubmissionsPage from 'components/SubmissionPage'; import AccessDenied, { CAUSE as ACCESS_DENIED_REASON } from 'components/tc-communities/AccessDenied'; import LoadingIndicator from 'components/LoadingIndicator'; +import { getChallengeSubmissions } from 'services/submissions'; + +const { fireErrorMessage } = errors; /** * SubmissionsPage Container */ -class SubmissionsPageContainer extends React.Component { +export class SubmissionsPageContainer extends React.Component { constructor(props) { super(props); this.handleSubmit = this.handleSubmit.bind(this); @@ -54,9 +61,17 @@ class SubmissionsPageContainer extends React.Component { } } - /* A child component has called their submitForm() prop, prepare the passed - form data for submission and create a submit action */ - handleSubmit(body) { + /** + * Verifies the member has an available slot before creating a submission. + * + * Unlimited challenges submit immediately. Limited challenges load the member's complete + * submission history so direct navigation to this page cannot bypass the header guard. + * + * @param {FormData} body Prepared submission form data. + * @return {Promise} Resolves after submission starts or the member is shown an error. + * @throws Does not throw; limit-check failures are reported to the member. + */ + async handleSubmit(body) { const { tokenV2, tokenV3, @@ -64,8 +79,35 @@ class SubmissionsPageContainer extends React.Component { challengeId, challenge, track, + metadata, + userId, } = this.props; + const submissionLimit = getSubmissionLimit(metadata); + if (submissionLimit !== null) { + try { + const existingSubmissions = await getChallengeSubmissions( + tokenV3, + challengeId, + { memberId: userId }, + ); + + if (existingSubmissions.data.length >= submissionLimit) { + fireErrorMessage( + 'Submission Limit Reached', + getSubmissionLimitReachedMessage(submissionLimit), + ); + return; + } + } catch (error) { + fireErrorMessage( + 'Unable to Verify Submission Limit', + 'We could not verify your existing submissions. Please try again.', + ); + return; + } + } + submit(tokenV3, tokenV2, challengeId, body, isMM(challenge) ? 'DEVELOP' : track); } diff --git a/src/shared/utils/challenge-detail/submission-limit.js b/src/shared/utils/challenge-detail/submission-limit.js new file mode 100644 index 000000000..eb27f27ea --- /dev/null +++ b/src/shared/utils/challenge-detail/submission-limit.js @@ -0,0 +1,115 @@ +const SUBMISSION_LIMIT_METADATA_NAME = 'submissionLimit'; + +/** + * Converts a metadata value to a positive integer submission limit. + * + * @param {*} value Raw count value. + * @return {?Number} A positive integer, or null when the value is not a valid limit. + */ +function toPositiveInteger(value) { + const numericValue = Number(value); + + if (!Number.isInteger(numericValue) || numericValue < 1) { + return null; + } + + return numericValue; +} + +/** + * Checks whether a legacy metadata flag is explicitly enabled. + * + * @param {*} value Raw flag value. + * @return {Boolean} Whether the value represents true. + */ +function isTrue(value) { + return value === true || value === 'true'; +} + +/** + * Extracts a limited count from parsed submission-limit metadata. + * + * Explicit unlimited and disabled-limit payloads remain unlimited. A count with no legacy flags + * is accepted for compatibility with older metadata shapes. + * + * @param {*} value Parsed metadata value. + * @return {?Number} The configured submission limit, or null for unlimited/invalid metadata. + */ +function extractSubmissionLimit(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return toPositiveInteger(value); + } + + const count = toPositiveInteger(value.count); + const hasLimitFlag = Object.prototype.hasOwnProperty.call(value, 'limit'); + const hasUnlimitedFlag = Object.prototype.hasOwnProperty.call(value, 'unlimited'); + + if (isTrue(value.limit)) { + return count; + } + + if (isTrue(value.unlimited) || hasLimitFlag || hasUnlimitedFlag) { + return null; + } + + return count; +} + +/** + * Reads the configured submission limit from challenge metadata. + * + * Supports the current JSON-string contract and older numeric values. Missing, malformed, and + * unlimited metadata resolve to null so callers can use the Unlimited display/behavior. + * + * @param {Array} metadata Challenge metadata entries. + * @return {?Number} The positive submission limit, or null when submissions are unlimited. + */ +export function getSubmissionLimit(metadata) { + if (!Array.isArray(metadata)) { + return null; + } + + const submissionLimit = metadata.find(entry => ( + entry && entry.name === SUBMISSION_LIMIT_METADATA_NAME + )); + + if (!submissionLimit) { + return null; + } + + const rawValue = submissionLimit.value; + + if (typeof rawValue !== 'string') { + return extractSubmissionLimit(rawValue); + } + + const normalizedValue = rawValue.trim(); + + if (!normalizedValue) { + return null; + } + + try { + return extractSubmissionLimit(JSON.parse(normalizedValue)); + } catch (error) { + return toPositiveInteger(normalizedValue); + } +} + +/** + * Builds the message shown when a member has no remaining submission slots. + * + * @param {Number} submissionLimit Configured active submission limit. + * @return {String} Message explaining how the member can replace a submission. + */ +export function getSubmissionLimitReachedMessage(submissionLimit) { + if (submissionLimit === 1) { + return 'This challenge allows only one submission, and you\'ve already submitted.' + + ' To replace it, delete your existing submission first.'; + } + + return `This challenge allows only ${submissionLimit} submissions, and you've already reached that limit.` + + ' To replace one, delete an existing submission first.'; +} + +export default getSubmissionLimit;