From 125ba35d9da72d02aa7f87aab5a8678792c2fcad Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 4 Aug 2026 16:38:12 +1000 Subject: [PATCH 1/6] PM-5803: Show provisional score during system tests What was broken The My Submissions tab replaced a completed Marathon Match provisional score with a dash while system tests were still running. Root cause The row hid the provisional score for every in-progress test status without distinguishing an in-progress provisional run from a later system run. What was changed The My Submissions row now keeps the completed provisional score visible when the current test process is system, matching the existing public submissions behavior. Any added/updated tests Added focused My Submissions row tests covering both an in-progress system run and an in-progress provisional run. --- .../MySubmissions/SubmissionsList/index.jsx | 66 ++++++++++++++++++- .../MySubmissions/SubmissionsList/index.jsx | 3 +- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/__tests__/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx b/__tests__/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx index bb70ec89f..a11964f50 100644 --- a/__tests__/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx +++ b/__tests__/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx @@ -1,9 +1,63 @@ -import { +import { shallow } from 'enzyme'; +import React from 'react'; + +import SubmissionsListView, { getDisplayedScores, isActiveTestStatus, getSubmissionTestProgress, } from '../../../../../../src/shared/components/challenge-detail/MySubmissions/SubmissionsList'; +/** + * Renders a My Submissions row and returns its visible provisional score. + * Tests use this to compare score display behavior across scorer processes. + * + * @param {String} testProcess Review API test process metadata. + * @returns {String} provisional score displayed in the submission row. + * @throws {Error} Propagates errors raised while shallow-rendering SubmissionsListView. + */ +function renderProvisionalScore(testProcess) { + const wrapper = shallow( + , + ); + const scoreColumn = wrapper.find('div') + .filterWhere((node) => { + const firstChild = node.children().at(0); + return firstChild.type() === 'div' + && firstChild.text() === 'Provisional Score'; + }) + .first(); + + return scoreColumn.find('span').last().text(); +} + describe('getDisplayedScores', () => { test('shows final scores when a system review already produced one before review completes', () => { expect(getDisplayedScores( @@ -77,3 +131,13 @@ describe('isActiveTestStatus', () => { expect(isActiveTestStatus('FAILED')).toBe(false); }); }); + +describe('Marathon Match provisional score display', () => { + it('keeps the completed provisional score visible while system tests are running', () => { + expect(renderProvisionalScore('system')).toBe('75.82'); + }); + + it('keeps the provisional score hidden while provisional tests are running', () => { + expect(renderProvisionalScore('provisional')).toBe('-'); + }); +}); diff --git a/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx b/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx index 512e20fed..d01f6f08c 100644 --- a/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx +++ b/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx @@ -646,7 +646,8 @@ class SubmissionsListView extends React.Component { sortedSubmissions.map((mySubmission) => { let { finalScore, provisionalScore } = getDisplayedScores(mySubmission); const testProgress = getSubmissionTestProgress(mySubmission); - const hideProvisionalScore = isActiveTestStatus(testProgress.status); + const hideProvisionalScore = testProgress.process !== 'system' + && isActiveTestStatus(testProgress.status); if (_.isNumber(finalScore)) { if (finalScore > 0) { finalScore = finalScore.toFixed(2); From 1e5ead5921229126d5b616c5c2a7d25bedd2e339 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 4 Aug 2026 16:54:13 +1000 Subject: [PATCH 2/6] PM-5780: Keep My Submissions count stable What was broken The My Submissions badge showed the full attempt count on its own tab, then dropped to the latest-only count after visiting the Submissions tab. Root cause Both tabs replace the same Marathon Match submissions state. The badge read the loaded attempts array length, even though the latest-only response carries the member's total submissionCount separately. What was changed Derive a dedicated My Submissions count from submissionCount with a loaded-attempt fallback, pass it through the challenge header, and use it for desktop and mobile badges. Any added/updated tests Added regression coverage for deriving the total from a latest-only response and rendering that total independently of the loaded attempts array. --- .../challenge-detail/Header/index.jsx | 44 +++++++++++++- .../containers/challenge-detail/index.jsx | 59 +++++++++++++++++++ .../Header/TabSelector/index.jsx | 10 +++- .../challenge-detail/Header/index.jsx | 4 ++ .../containers/challenge-detail/index.jsx | 13 ++++ 5 files changed, 127 insertions(+), 3 deletions(-) diff --git a/__tests__/shared/components/challenge-detail/Header/index.jsx b/__tests__/shared/components/challenge-detail/Header/index.jsx index 8ffdb6648..9a51b14d5 100644 --- a/__tests__/shared/components/challenge-detail/Header/index.jsx +++ b/__tests__/shared/components/challenge-detail/Header/index.jsx @@ -2,9 +2,14 @@ import React from 'react'; import Renderer from 'react-test-renderer/shallow'; import Header from 'components/challenge-detail/Header'; +import TabSelector from 'components/challenge-detail/Header/TabSelector'; + +jest.mock('react-responsive', () => ({ + useMediaQuery: () => true, +})); function collectText(node) { - if (typeof node === 'string') { + if (typeof node === 'string' || typeof node === 'number') { return [node]; } @@ -125,3 +130,40 @@ describe('Challenge detail header actions', () => { expect(collectText(output)).toContain('Submit a solution'); }); }); + +describe('Challenge detail tab counts', () => { + test('renders the MM submission total independently of loaded attempts', () => { + const renderer = new Renderer(); + renderer.render( + , + ); + + const text = collectText(renderer.getRenderOutput()); + const mySubmissionsLabelIndex = text.indexOf('My Submissions'); + + expect(text[mySubmissionsLabelIndex + 1]).toBe(3); + }); +}); diff --git a/__tests__/shared/containers/challenge-detail/index.jsx b/__tests__/shared/containers/challenge-detail/index.jsx index 5fef65aa9..59b7497c4 100644 --- a/__tests__/shared/containers/challenge-detail/index.jsx +++ b/__tests__/shared/containers/challenge-detail/index.jsx @@ -4,6 +4,7 @@ import { isGroupedChallenge, isGroupedChallengeAccessError, isWiproRegistrationBlocked, + mapStateToProps, shouldLoginForGroupedChallenge, shouldLoginForGroupedChallengeError, } from 'containers/challenge-detail'; @@ -76,6 +77,64 @@ describe('Challenge detail winners filter', () => { }); }); +describe('Challenge detail My Submissions count', () => { + test('uses the total attempt count when only the latest MM submission is loaded', () => { + const state = { + auth: { + user: { + handle: 'member', + userId: '123', + }, + }, + challenge: { + checkpoints: {}, + details: { + id: 'challenge-id', + registrants: [{ memberHandle: 'member', memberId: '123' }], + submissions: [], + }, + mmSubmissions: { + challengeId: 'challenge-id', + data: [{ + member: 'member', + memberId: '123', + submissionCount: 3, + submissions: [{ submissionId: 'latest-submission' }], + }], + }, + reviewSummations: [], + statisticsData: [], + }, + challengeListing: {}, + domain: {}, + lookup: { + allCountries: [], + reviewTypes: [], + }, + page: { + challengeDetails: { + feedbackOpen: {}, + }, + }, + tcCommunities: { + list: {}, + }, + terms: {}, + topcoderHeader: {}, + }; + + const props = mapStateToProps(state, { + challengesUrl: '/challenges', + match: { + params: { challengeId: 'challenge-id' }, + }, + }); + + expect(props.mySubmissions).toHaveLength(1); + expect(props.mySubmissionsCount).toBe(3); + }); +}); + describe('Challenge detail grouped challenge login guard', () => { beforeEach(() => { document.cookie = 'tc_utm=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'; diff --git a/src/shared/components/challenge-detail/Header/TabSelector/index.jsx b/src/shared/components/challenge-detail/Header/TabSelector/index.jsx index a02f2177b..6916a4ac9 100644 --- a/src/shared/components/challenge-detail/Header/TabSelector/index.jsx +++ b/src/shared/components/challenge-detail/Header/TabSelector/index.jsx @@ -39,6 +39,7 @@ export default function ChallengeViewSelector(props) { trackLower, hasRegistered, mySubmissions, + mySubmissionsCount, onSort, viewAsTable, } = props; @@ -133,6 +134,9 @@ export default function ChallengeViewSelector(props) { } const numOfSub = numOfSubmissions + (numOfCheckpointSubmissions || 0); + const mySubmissionsBadgeCount = _.isFinite(mySubmissionsCount) + ? mySubmissionsCount + : mySubmissions.length; const forumId = _.get(challenge, 'legacy.forumId') || 0; const discuss = _.get(challenge, 'discussions', []).filter(d => ( _.toLower(d.type) === 'challenge' && !_.isEmpty(d.url) @@ -247,7 +251,7 @@ export default function ChallengeViewSelector(props) { styleName={getSelectorStyle(selectedView, DETAIL_TABS.MY_SUBMISSIONS)} > My Submissions - {mySubmissions.length} + {mySubmissionsBadgeCount} ) : null } @@ -358,7 +362,7 @@ export default function ChallengeViewSelector(props) { { currentSelected === DETAIL_TABS.MY_SUBMISSIONS && hasRegistered && isMM && mySubmissions && ( - {mySubmissions.length} + {mySubmissionsBadgeCount} ) } { @@ -447,6 +451,7 @@ ChallengeViewSelector.defaultProps = { numOfRegistrants: 0, numOfCheckpointSubmissions: 0, numOfSubmissions: 0, + mySubmissionsCount: null, }; ChallengeViewSelector.propTypes = { @@ -477,6 +482,7 @@ ChallengeViewSelector.propTypes = { trackLower: PT.string.isRequired, hasRegistered: PT.bool.isRequired, mySubmissions: PT.arrayOf(PT.shape()).isRequired, + mySubmissionsCount: PT.number, onSort: PT.func.isRequired, viewAsTable: PT.bool.isRequired, }; diff --git a/src/shared/components/challenge-detail/Header/index.jsx b/src/shared/components/challenge-detail/Header/index.jsx index 77c945f40..345ed44c1 100644 --- a/src/shared/components/challenge-detail/Header/index.jsx +++ b/src/shared/components/challenge-detail/Header/index.jsx @@ -63,6 +63,7 @@ export default function ChallengeHeader(props) { isMenuOpened, submissionEnded, mySubmissions, + mySubmissionsCount, openForRegistrationChallenges, onSort, viewAsTable, @@ -571,6 +572,7 @@ export default function ChallengeHeader(props) { hasRegistered={hasRegistered} checkpointCount={checkpointCount} mySubmissions={mySubmissions} + mySubmissionsCount={mySubmissionsCount} onSort={onSort} viewAsTable={viewAsTable} /> @@ -585,6 +587,7 @@ ChallengeHeader.defaultProps = { isMenuOpened: false, hasThriveArticles: false, hasRecommendedChallenges: false, + mySubmissionsCount: null, }; ChallengeHeader.propTypes = { @@ -639,6 +642,7 @@ ChallengeHeader.propTypes = { hasFirstPlacement: PT.bool.isRequired, isMenuOpened: PT.bool, mySubmissions: PT.arrayOf(PT.shape()).isRequired, + mySubmissionsCount: PT.number, openForRegistrationChallenges: PT.shape().isRequired, onSort: PT.func.isRequired, viewAsTable: PT.bool.isRequired, diff --git a/src/shared/containers/challenge-detail/index.jsx b/src/shared/containers/challenge-detail/index.jsx index dce96908b..8cc7594ed 100644 --- a/src/shared/containers/challenge-detail/index.jsx +++ b/src/shared/containers/challenge-detail/index.jsx @@ -564,6 +564,7 @@ class ChallengeDetailPageContainer extends React.Component { // expandedTags, // expandTag, mySubmissions, + mySubmissionsCount, reviewTypes, openForRegistrationChallenges, statisticsData, @@ -710,6 +711,7 @@ class ChallengeDetailPageContainer extends React.Component { isMenuOpened={isMenuOpened} submissionEnded={submissionEnded} mySubmissions={challenge.isRegistered ? mySubmissions : []} + mySubmissionsCount={challenge.isRegistered ? mySubmissionsCount : 0} openForRegistrationChallenges={openForRegistrationChallenges} viewAsTable={viewAsTable && isMM} onSort={(currenctSelected, sort) => { @@ -941,6 +943,7 @@ ChallengeDetailPageContainer.defaultProps = { loadingMMSubmissionsForChallengeId: null, mmSubmissions: [], mySubmissions: [], + mySubmissionsCount: 0, isLoadingSubmissionInformation: false, submissionInformation: null, // prizeMode: 'money-usd', @@ -993,6 +996,7 @@ ChallengeDetailPageContainer.propTypes = { reviewTypes: PT.arrayOf(PT.shape()), reviewSummations: PT.arrayOf(PT.shape()).isRequired, mySubmissions: PT.arrayOf(PT.shape()), + mySubmissionsCount: PT.number, toggleCheckpointFeedback: PT.func.isRequired, unregisterFromChallenge: PT.func.isRequired, unregistering: PT.bool.isRequired, @@ -1269,6 +1273,7 @@ export function mapStateToProps(state, props) { ? buildReviewSummationLookup(reviewSummations) : null; let mySubmissions = []; + let mySubmissionsCount = 0; if (challenge.registrants) { challenge.registrants = challenge.registrants.map(registrant => ({ ...registrant, @@ -1516,6 +1521,12 @@ export function mapStateToProps(state, props) { ...attempt, id: normalizedAttempts.length - index, })); + const submissionCount = _.isNil(submission.submissionCount) + ? null + : Number(submission.submissionCount); + mySubmissionsCount = Number.isFinite(submissionCount) + ? submissionCount + : mySubmissions.length; } return ({ @@ -1528,6 +1539,7 @@ export function mapStateToProps(state, props) { }); } else if (loggedInUserId) { mySubmissions = _.filter(challenge.submissions, s => (`${s.memberId}` === `${loggedInUserId}`)); + mySubmissionsCount = mySubmissions.length; } } const { page: { challengeDetails: { feedbackOpen } } } = state; @@ -1582,6 +1594,7 @@ export function mapStateToProps(state, props) { reviewSummations, allCountries: state.lookup.allCountries, mySubmissions, + mySubmissionsCount, reviewTypes, openForRegistrationChallenges: state.challengeListing.openForRegistrationChallenges, statisticsData, From 34e4770a08b1b30714716575c28ac62f57c29935 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Mon, 10 Aug 2026 07:41:12 +1000 Subject: [PATCH 3/6] 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; From 036afbb98fb63001534ec24a427e0835831b4aef Mon Sep 17 00:00:00 2001 From: jmgasper Date: Mon, 10 Aug 2026 09:10:40 +1000 Subject: [PATCH 4/6] PM-5826: Show N/A during system tests What was broken The community app submissions tab displayed a placeholder final score of 0 while Marathon Match system tests were still in progress. Root cause The public submission row treated every finite final score as displayable when final results were enabled. In-progress system summations include a placeholder score and status metadata, but final score rendering did not consult the status. What was changed The row now displays N/A for final scores while that submission's system test is active. Completed zero scores remain visible. Any added/updated tests Added focused row tests verifying an in-progress system test renders N/A and a successful system test preserves a zero score. The focused tests, full test suite, lint, and production build pass. --- .../Submissions/SubmissionRow/index.jsx | 31 ++++++++++++++----- .../Submissions/SubmissionRow/index.jsx | 4 ++- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/__tests__/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx b/__tests__/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx index 688e3b884..15f744a3d 100644 --- a/__tests__/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx +++ b/__tests__/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx @@ -53,15 +53,17 @@ function collectText(node) { } /** - * Shallow-renders an MM row and returns its provisional score column text. + * Shallow-renders an MM row and returns the selected score column text. * Tests use this to compare score display behavior across scorer process states. * + * @param {String} scoreHeader Header for the score column to inspect. * @param {String} testProcess Review API test process metadata. * @param {String} testStatus Review API test status metadata. - * @returns {Array} Provisional score column label and displayed value. + * @param {Number|null} finalScore Final score supplied by Review API. + * @returns {Array} Score column label and displayed value. * @throws {Error} Propagates errors raised while shallow-rendering SubmissionRow. */ -function renderProvisionalScore(testProcess, testStatus) { +function renderScore(scoreHeader, testProcess, testStatus, finalScore = null) { const renderer = new Renderer(); renderer.render( , ); - const column = findColumnByHeader(renderer.getRenderOutput(), 'PROVISIONAL SCORE'); + const column = findColumnByHeader(renderer.getRenderOutput(), scoreHeader); return collectText(column); } describe('Marathon Match provisional score', () => { it('shows N/A while provisional tests are still running', () => { - expect(renderProvisionalScore('provisional', 'IN PROGRESS')) + expect(renderScore('PROVISIONAL SCORE', 'provisional', 'IN PROGRESS')) .toEqual(['PROVISIONAL SCORE', 'N/A']); }); it('keeps a completed zero provisional score visible', () => { - expect(renderProvisionalScore('provisional', 'SUCCESS')) + expect(renderScore('PROVISIONAL SCORE', 'provisional', 'SUCCESS')) .toEqual(['PROVISIONAL SCORE', 0]); }); it('keeps the provisional score visible while system tests are running', () => { - expect(renderProvisionalScore('system', 'IN PROGRESS')) + expect(renderScore('PROVISIONAL SCORE', 'system', 'IN PROGRESS')) .toEqual(['PROVISIONAL SCORE', 0]); }); }); + +describe('Marathon Match final score', () => { + it('shows N/A while system tests are still running', () => { + expect(renderScore('FINAL SCORE', 'system', 'IN PROGRESS', 0)) + .toEqual(['FINAL SCORE', 'N/A']); + }); + + it('keeps a completed zero final score visible', () => { + expect(renderScore('FINAL SCORE', 'system', 'SUCCESS', 0)) + .toEqual(['FINAL SCORE', 0]); + }); +}); diff --git a/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx b/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx index 2268de0f9..d73d35177 100644 --- a/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx +++ b/src/shared/components/challenge-detail/Submissions/SubmissionRow/index.jsx @@ -57,6 +57,8 @@ export default function SubmissionRow({ const testProgress = getSubmissionTestProgress(latestSubmission); const hideProvisionalScore = testProgress.process !== 'system' && isActiveTestStatus(testProgress.status); + const hideFinalScore = testProgress.process === 'system' + && isActiveTestStatus(testProgress.status); const getInitialReviewResult = () => { if (status === 'failed') { @@ -81,7 +83,7 @@ export default function SubmissionRow({ }; const getFinalReviewResult = () => { - if (!showFinalResults || _.isNil(finalScore)) { + if (hideFinalScore || !showFinalResults || _.isNil(finalScore)) { return 'N/A'; } if (finalScore < 0) { From 9d911c8696602a4416fecbdbb2b081b2d3b05117 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 11 Aug 2026 14:10:23 +1000 Subject: [PATCH 5/6] PM-5831: Fix My Submissions download redirect What was broken Downloading a submission from My Submissions failed after Review API began returning a signed storage redirect. Root cause Community App fetched the redirecting endpoint as a blob, so Firefox followed the authenticated request to S3 and triggered a rejected CORS preflight. What was changed Request the browser-safe /download-url endpoint with the member token, validate the signed URL, and start the existing browser-managed download directly from that URL. Any added/updated tests Added service coverage for the authenticated, URL-encoded download-url request, signed URL parsing, and missing URL rejection. --- __tests__/shared/services/submissions.js | 35 ++++++++++++++++++- .../containers/SubmissionManagement/index.jsx | 17 ++++----- src/shared/services/submissions.js | 31 ++++++++++++++++ 3 files changed, 74 insertions(+), 9 deletions(-) diff --git a/__tests__/shared/services/submissions.js b/__tests__/shared/services/submissions.js index 9e2060b22..bacffb1db 100644 --- a/__tests__/shared/services/submissions.js +++ b/__tests__/shared/services/submissions.js @@ -1,6 +1,9 @@ /* eslint-env jest */ import { config } from 'topcoder-react-utils'; -import { getChallengeSubmissions } from '../../../src/shared/services/submissions'; +import { + getChallengeSubmissions, + getSubmissionDownloadUrl, +} from '../../../src/shared/services/submissions'; const baseUrl = `${config.API.V6}/submissions`; @@ -35,6 +38,36 @@ describe('submissions service', () => { global.fetch = jest.fn(); }); + it('returns a signed submission URL without following the storage redirect', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + url: ' https://storage.example.test/signed-submission ', + }), + }); + + const result = await getSubmissionDownloadUrl('token-v3', 'submission/id'); + + expect(result).toBe('https://storage.example.test/signed-submission'); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith( + `${baseUrl}/submission%2Fid/download-url`, + expect.objectContaining({ method: 'GET' }), + ); + expect(global.fetch.mock.calls[0][1].headers.get('Authorization')) + .toBe('Bearer token-v3'); + }); + + it('rejects a submission download response without a signed URL', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + }); + + await expect(getSubmissionDownloadUrl('token-v3', 'submission-id')) + .rejects.toThrow('Submission download URL is missing'); + }); + it('loads every submissions page reported by metadata', async () => { global.fetch .mockResolvedValueOnce({ diff --git a/src/shared/containers/SubmissionManagement/index.jsx b/src/shared/containers/SubmissionManagement/index.jsx index 91fe8117e..047637e43 100644 --- a/src/shared/containers/SubmissionManagement/index.jsx +++ b/src/shared/containers/SubmissionManagement/index.jsx @@ -15,15 +15,18 @@ import { safeForDownload } from 'utils/tc'; import { connect } from 'react-redux'; import { Modal, PrimaryButton } from 'topcoder-react-ui-kit'; import { config } from 'topcoder-react-utils'; -import { actions, services } from 'topcoder-react-lib'; +import { actions } from 'topcoder-react-lib'; import getReviewSummationsService from 'services/reviewSummations'; -import { getSubmissionArtifacts, downloadSubmissions } from 'services/submissions'; +import { + downloadSubmissions, + getSubmissionArtifacts, + getSubmissionDownloadUrl, +} from 'services/submissions'; import style from './styles.scss'; import smpActions from '../../actions/page/submission_management'; -const { getService } = services.submissions; const SUMMATION_TYPE_PRIORITY = { example: 0, provisional: 1, @@ -401,12 +404,10 @@ class SubmissionManagementPageContainer extends React.Component { onShowDetails, onDelete: onSubmissionDelete, onDownload: (challengeType, submissionId) => { - const submissionsService = getService(authTokens.tokenV3); - submissionsService.downloadSubmission(submissionId) - .then((blob) => { - const url = window.URL.createObjectURL(new Blob([blob])); + getSubmissionDownloadUrl(authTokens.tokenV3, submissionId) + .then((downloadUrl) => { const link = document.createElement('a'); - link.href = url; + link.href = downloadUrl; link.setAttribute('download', `submission-${challengeType}-${submissionId}.zip`); document.body.appendChild(link); link.click(); diff --git a/src/shared/services/submissions.js b/src/shared/services/submissions.js index dfd588899..545277cb6 100644 --- a/src/shared/services/submissions.js +++ b/src/shared/services/submissions.js @@ -25,6 +25,37 @@ function getHeaders(tokenV3) { return headers; } +/** + * Requests a short-lived URL for downloading a submission directly from storage. + * + * @param {String} tokenV3 Topcoder auth token v3 used to authorize the download. + * @param {String|Number} submissionId Submission identifier used by the Review API. + * @return {Promise} Signed submission download URL. + * @throws {Error} Throws when the submission id is empty, the request fails, or no URL is returned. + */ +export async function getSubmissionDownloadUrl(tokenV3, submissionId) { + const normalizedSubmissionId = String(submissionId).trim(); + if (!normalizedSubmissionId) { + throw new Error('Submission id is required'); + } + + const response = await fetch(`${v6ApiUrl}/submissions/${encodeURIComponent(normalizedSubmissionId)}/download-url`, { + method: 'GET', + headers: getHeaders(tokenV3), + }); + + if (!response.ok) { + throw new Error(`Failed to get submission download URL: ${response.status} ${response.statusText}`); + } + + const payload = await response.json(); + if (!payload || typeof payload.url !== 'string' || !payload.url.trim()) { + throw new Error('Submission download URL is missing'); + } + + return payload.url.trim(); +} + async function fetchChallengeSubmissionsPage({ tokenV3, challengeId, From cf438a7006edde1b31735c05c57d750013e0e8d0 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 11 Aug 2026 17:04:38 +1000 Subject: [PATCH 6/6] Further fix for PM-5831 --- .../MySubmissions/SubmissionsList/index.jsx | 60 +++++++++++++++++++ .../MySubmissions/SubmissionsList/index.jsx | 16 ++--- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/__tests__/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx b/__tests__/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx index a11964f50..6f39e5a31 100644 --- a/__tests__/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx +++ b/__tests__/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx @@ -1,5 +1,6 @@ import { shallow } from 'enzyme'; import React from 'react'; +import { getSubmissionDownloadUrl as mockedGetSubmissionDownloadUrl } from 'services/submissions'; import SubmissionsListView, { getDisplayedScores, @@ -7,6 +8,10 @@ import SubmissionsListView, { getSubmissionTestProgress, } from '../../../../../../src/shared/components/challenge-detail/MySubmissions/SubmissionsList'; +jest.mock('services/submissions', () => ({ + getSubmissionDownloadUrl: jest.fn(), +})); + /** * Renders a My Submissions row and returns its visible provisional score. * Tests use this to compare score display behavior across scorer processes. @@ -141,3 +146,58 @@ describe('Marathon Match provisional score display', () => { expect(renderProvisionalScore('provisional')).toBe('-'); }); }); + +describe('Marathon Match submission download', () => { + let originalCreateObjectURL; + + beforeEach(() => { + originalCreateObjectURL = window.URL.createObjectURL; + }); + + afterEach(() => { + window.URL.createObjectURL = originalCreateObjectURL; + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('opens the browser-safe signed URL for an opaque submission id', async () => { + const submissionId = 'BKzPfVv24EcINT'; + const downloadUrl = 'https://storage.example.test/signed-mm-submission'; + const link = document.createElement('a'); + link.click = jest.fn(); + const createObjectURL = jest.fn(); + window.URL.createObjectURL = createObjectURL; + jest.spyOn(document, 'createElement').mockReturnValue(link); + mockedGetSubmissionDownloadUrl.mockResolvedValue(downloadUrl); + + const wrapper = shallow( + , + ); + + wrapper.find('button[aria-label="Download submission"]').prop('onClick')(); + await Promise.resolve(); + + expect(mockedGetSubmissionDownloadUrl).toHaveBeenCalledWith('token-v3', submissionId); + expect(link.href).toBe(downloadUrl); + expect(link.getAttribute('download')).toBe(`submission-${submissionId}.zip`); + expect(link.click).toHaveBeenCalledTimes(1); + expect(document.body.contains(link)).toBe(false); + expect(createObjectURL).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx b/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx index d01f6f08c..647c82f7c 100644 --- a/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx +++ b/src/shared/components/challenge-detail/MySubmissions/SubmissionsList/index.jsx @@ -8,9 +8,9 @@ import _ from 'lodash'; import moment from 'moment'; import { PrimaryButton, Modal } from 'topcoder-react-ui-kit'; import PT from 'prop-types'; -import { services } from 'topcoder-react-lib'; import sortList from 'utils/challenge-detail/sort'; import { getSubmissionStatus } from 'utils/challenge-detail/submission-status'; +import { getSubmissionDownloadUrl } from 'services/submissions'; import IconClose from 'assets/images/icon-close-green.svg'; import DateSortIcon from 'assets/images/icon-date-sort.svg'; @@ -26,8 +26,6 @@ import ArtifactsDownloadIcon from '../../../SubmissionManagement/Icons/IconDownl // import SearchIcon from '../../../SubmissionManagement/Icons/IconSearch.svg'; import style from './styles.scss'; -const { getService } = services.submissions; - const collectReviewSummations = (submission) => { const summations = []; if (!submission) { @@ -764,14 +762,16 @@ class SubmissionsListView extends React.Component { ? (
Download Submission
}>