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
7 changes: 5 additions & 2 deletions src/apps/opportunities/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -695,8 +695,11 @@ challenge track, while authored external-URL rows omit that file action. The
browser requests a short-lived URL from
`GET /v6/submissions/:submissionId/download-url` instead of using submission
list URLs; Review API remains authoritative for access and signs only clean
storage, so pending DMZ and quarantined files cannot be downloaded. Work
Manager's case-insensitive `submission_type=url` challenge
storage, so pending DMZ and quarantined files cannot be downloaded. TopGear
challenges use the URL experience whenever the app is served on the TopGear host
or the challenge belongs to the configured TopGear group, including when opened
through `?tab=mine`. This takes precedence over missing or ZIP submission metadata.
For other challenges, Work Manager's case-insensitive `submission_type=url`
metadata selects the URL experience; every other value, including missing or
malformed metadata, retains the standard ZIP experience. URL submissions
reuse community-app's Topgear flow and exact Wipro instructions, submission
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
const mockRecordAnalyticsEvent = jest.fn()

jest.mock('~/config', () => ({
AppSubdomain: { topgear: 'topgear' },
EnvironmentConfig: {
TOPGEAR_ALLOWED_SUBMISSIONS_DOMAINS: [
'wipro365.sharepoint.com',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
import '@testing-library/jest-dom'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { SWRConfig } from 'swr'

import { ChallengeOpportunity } from '../models'
import {
createChallengeSubmission,
createChallengeUrlSubmission,
getChallengeOpportunity,
getChallengeRegistration,
getChallengeSubmissions,
} from '../services'

import { ChallengeDetailsPage } from './ChallengeDetailsPage'

let mockSubdomain = 'www'

jest.mock('~/config', () => ({
AppSubdomain: { topgear: 'topgear' },
EnvironmentConfig: {
REVIEW_APP_URL: 'https://review.topcoder-dev.com',
get SUBDOMAIN(): string { return mockSubdomain },
TOPGEAR: { GROUP_ID: 'topgear-group' },
TOPGEAR_ALLOWED_SUBMISSIONS_DOMAINS: ['wipro365.sharepoint.com'],
URLS: {
TERMS_OF_USE: 'https://www.topcoder-dev.com/terms',
TOPGEAR_TERMS: 'https://topgear.topcoder-dev.com/challenges/terms/detail/topgear-terms',
},
},
}), { virtual: true })
jest.mock('~/libs/core', () => ({
recordAnalyticsEvent: jest.fn(),
useProfileContext: () => ({ initialized: true, profile: { handle: 'coder', userId: 123 } }),
}), { virtual: true })
jest.mock('~/libs/cms', () => ({}), { virtual: true })
jest.mock('~/libs/ui', () => {
/** Renders a placeholder icon without loading the shared UI library; never throws. */
const Icon = (): JSX.Element => <svg />
return {
ConfirmModal: (): JSX.Element => <></>,
IconOutline: new Proxy({}, { get: () => Icon }),
LoadingSpinner: (): JSX.Element => <span>Loading</span>,
}
}, { virtual: true })
jest.mock('react-toastify', () => ({
toast: { error: jest.fn(), success: jest.fn() },
}))
jest.mock('../components', () => ({
ChallengeDescription: (): JSX.Element => <div>Requirements</div>,
ChallengeDetailHeader: jest.requireActual('../components/ChallengeDetailHeader').ChallengeDetailHeader,
ChallengeSidebar: (): JSX.Element => <aside />,
ChallengeSubmissionUpload: jest.requireActual('../components/ChallengeSubmissionUpload').ChallengeSubmissionUpload,
ChallengeTermsModal: (): JSX.Element => <></>,
extractTableOfContents: (): [] => [],
isHtmlDescriptionFormat: (): boolean => false,
OpportunityTabLoading: (): JSX.Element => <span>Loading submissions</span>,
ReportIssueModal: (): JSX.Element => <></>,
}))
jest.mock('../services', () => ({
createChallengeSubmission: jest.fn(),
createChallengeUrlSubmission: jest.fn(),
getChallengeAiReviewConfig: jest.fn(),
getChallengeForumTopics: jest.fn(),
getChallengeMemberResource: jest.fn(),
getChallengeOpportunity: jest.fn(),
getChallengeRegistration: jest.fn(),
getChallengeSubmissions: jest.fn(),
}))

const mockGetChallenge = getChallengeOpportunity as jest.MockedFunction<typeof getChallengeOpportunity>
const mockGetRegistration = getChallengeRegistration as jest.MockedFunction<typeof getChallengeRegistration>
const mockGetSubmissions = getChallengeSubmissions as jest.MockedFunction<typeof getChallengeSubmissions>
const mockCreateUrlSubmission = createChallengeUrlSubmission as jest.MockedFunction<typeof createChallengeUrlSubmission>

describe('Topgear submissions from the My Submissions deep link', () => {
beforeEach(() => {
jest.clearAllMocks()
jest.spyOn(window, 'scrollTo')
.mockImplementation(() => undefined)
mockCreateUrlSubmission.mockResolvedValue({ id: 'url-submission-id' })
mockGetRegistration.mockResolvedValue({ id: 'registration-id', memberId: '123' })
mockGetSubmissions.mockResolvedValue({ items: [], page: 1, perPage: 10, total: 0, totalPages: 0 })
})

afterEach(() => jest.restoreAllMocks())

it.each([
{ groups: [], metadata: undefined, subdomain: 'topgear' },
{ groups: ['topgear-group'], metadata: undefined, subdomain: 'www' },
{ groups: [], metadata: [{ name: 'submission_type', value: 'zip' }], subdomain: 'topgear' },
{ groups: ['topgear-group'], metadata: [{ name: 'submission_type', value: 'zip' }], subdomain: 'www' },
])('submits a URL for a Topgear challenge: %j', async ({ groups, metadata, subdomain }: {
groups: string[]
metadata: ChallengeOpportunity['metadata']
subdomain: string
}) => {
mockSubdomain = subdomain
mockGetChallenge.mockResolvedValue({
currentPhaseNames: ['Submission'],
groups,
id: 'challenge-id',
metadata,
name: 'Topgear challenge',
phases: [{ isOpen: true, name: 'Submission' }],
status: 'ACTIVE',
track: 'Development',
type: 'Challenge',
})

render(
<SWRConfig value={{ dedupingInterval: 0, provider: () => new Map() }}>
<MemoryRouter initialEntries={['/opportunities/challenge/challenge-id?tab=mine']}>
<Routes>
<Route element={<ChallengeDetailsPage />} path='/opportunities/challenge/:challengeId' />
</Routes>
</MemoryRouter>
</SWRConfig>,
)

await screen.findByText('You have no submissions yet')
expect(screen.getByRole('tab', { name: /^My Submissions/ }))
.toHaveAttribute('aria-selected', 'true')
fireEvent.click(within(screen.getByRole('tabpanel'))
.getByRole('button', { name: 'Submit a solution' }))

const urlInput = await screen.findByLabelText(/Submission URL/)
expect(screen.queryByLabelText(/Upload File/)).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'TopGear terms and conditions' }))
.toHaveAttribute('href', 'https://topgear.topcoder-dev.com/challenges/terms/detail/topgear-terms')
fireEvent.change(urlInput, { target: { value: 'https://wipro365.sharepoint.com/sites/project/deliverable' } })
fireEvent.click(screen.getByRole('button', { name: 'Set URL' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'I understand and agree' }))
fireEvent.click(screen.getByRole('button', { name: 'Submit' }))

await waitFor(() => expect(createChallengeUrlSubmission)
.toHaveBeenCalledWith(
'challenge-id',
'123',
'CONTEST_SUBMISSION',
'https://wipro365.sharepoint.com/sites/project/deliverable',
expect.any(AbortSignal),
))
expect(createChallengeSubmission).not.toHaveBeenCalled()
expect(await screen.findByText('url-submission-id'))
.toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from './challenge-detail.utils'

jest.mock('~/config', () => ({
AppSubdomain: { topgear: 'topgear' },
EnvironmentConfig: {
ADMIN: { ONLINE_REVIEW_URL: 'https://software.topcoder-dev.com/review' },
REVIEW_APP_URL: 'https://review.topcoder-dev.com',
Expand Down
14 changes: 9 additions & 5 deletions src/apps/opportunities/src/utils/challenge-detail.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import {
ChallengeOpportunity,
} from '../models'

import { isTopgearChallenge } from './topgear.utils'

export interface ChallengeSidebarLink {
label: string
url: string
}

/** Submission experiences selected by Work Manager challenge metadata. */
/** Submission experiences selected by TopGear context or Work Manager metadata. */
export type ChallengeSubmissionMode = 'url' | 'zip'

/**
Expand Down Expand Up @@ -93,15 +95,17 @@ export function challengeMetadataValue(
/**
* Resolves whether a challenge accepts a URL or the standard ZIP archive.
*
* Work Manager persists this choice as `submission_type` metadata. URL mode
* is deliberately opt-in: missing, malformed, and unsupported values retain
* the established ZIP flow.
* TopGear challenges always use the URL flow, identified by the community host
* or configured challenge group. Other challenges opt in through Work Manager's
* `submission_type=url` metadata; all other values retain the ZIP flow.
*
* @param challenge raw Challenge API detail response.
* @returns `url` only for an exact case-insensitive metadata value; otherwise `zip`.
* @returns `url` for TopGear or an exact case-insensitive metadata value; otherwise `zip`.
* @throws Does not throw.
*/
export function challengeSubmissionMode(challenge: ChallengeOpportunity): ChallengeSubmissionMode {
if (isTopgearChallenge(challenge)) return 'url'

const value = challengeMetadataValue(challenge.metadata, 'submission_type')
return typeof value === 'string' && value.trim()
.toLowerCase() === 'url'
Expand Down
Loading