From b9fa94e72a3f0e4c6f25e00cfedf0049a9ca888c Mon Sep 17 00:00:00 2001 From: jmgasper Date: Sat, 1 Aug 2026 10:21:22 +1000 Subject: [PATCH 1/3] PM-5788: Allow Autopilot to sync profile points What was broken Completed point challenges generated finance winnings, but winning member profiles retained an empty challengePoints summary, so Profiles hid the Points section. Root cause Autopilot calls the member challenge-points endpoint with its refresh:member_stats M2M scope. The endpoint accepted only update:user_profiles or all:user_profiles, so automatic synchronization was rejected before point rows could be stored. What was changed Allowed refresh:member_stats on the challenge-points update route while preserving the existing profile scopes. Restricted user-token access to administrators and updated the README and Swagger authorization contract. Any added/updated tests Added a route contract regression test for the accepted M2M scopes and admin-only user access. Focused route and challenge-point persistence tests, lint, and build pass. The full suite reports 205 passing tests and 28 unrelated existing failures caused by missing bus credentials and outdated Joi error-message expectations. --- ReadMe.md | 4 ++++ docs/swagger.yaml | 3 ++- src/routes.ts | 3 ++- test/unit/AppRoutes.test.js | 12 ++++++++++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/ReadMe.md b/ReadMe.md index f6fd456..e888b88 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -174,6 +174,10 @@ Content-Type: application/json } ``` +The endpoint accepts M2M tokens with `refresh:member_stats`, `update:user_profiles`, +or `all:user_profiles`. Autopilot uses the stats refresh scope when synchronizing +completed challenge results. User tokens require the `administrator` or `admin` role. + `GET /v6/members/{handle}` includes a public `challengePoints` object by default: ```json diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 7432b74..460315b 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -182,7 +182,8 @@ paths: Replace the stored point awards for one challenge. This endpoint mirrors point-based challenge prize payouts into member-api so member profiles can return challenge point totals and per-challenge details. Authorization: - - M2M scopes: `update:user_profiles` or `all:user_profiles`. + - M2M scopes: `update:user_profiles`, `refresh:member_stats`, or `all:user_profiles`. + - User roles: `administrator` or `admin`. security: - bearer: [] parameters: diff --git a/src/routes.ts b/src/routes.ts index 4b4ac3e..520cf62 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -73,7 +73,8 @@ module.exports = { controller: 'MemberController', method: 'updateChallengePoints', auth: 'jwt', - scopes: [MEMBERS.UPDATE, MEMBERS.ALL] + scopes: [MEMBERS.UPDATE, STATS_REFRESH, MEMBERS.ALL], + access: constants.ADMIN_ROLES } }, '/members/:handle': { diff --git a/test/unit/AppRoutes.test.js b/test/unit/AppRoutes.test.js index a36b615..b1b83f9 100644 --- a/test/unit/AppRoutes.test.js +++ b/test/unit/AppRoutes.test.js @@ -69,6 +69,18 @@ describe('app routes unit tests', () => { delete require.cache[appRoutesPath] }) + it('challenge points route should accept stats refresh M2M calls and restrict user calls to admins', () => { + const config = require(configPath) + const routes = require(routesPath) + const challengePointsRoute = routes['/members/challenge-points/:challengeId'].put + const scopes = challengePointsRoute.scopes + + scopes.should.include(config.SCOPES.MEMBERS.UPDATE) + scopes.should.include(config.SCOPES.MEMBERS.STATS_REFRESH) + scopes.should.include(config.SCOPES.MEMBERS.ALL) + challengePointsRoute.access.should.deep.equal(['administrator', 'admin']) + }) + it('public routes should skip optional JWT authentication when no authorization header is present', async () => { const originalEntries = { [appRoutesPath]: require.cache[appRoutesPath], From 0fa1038529b8d297dff74314dae0656f0012b7bb Mon Sep 17 00:00:00 2001 From: jmgasper Date: Sat, 1 Aug 2026 10:53:29 +1000 Subject: [PATCH 2/3] PM-5793: Align downloaded profile activity stats What was broken Downloaded profiles showed activity totals that differed from Profiles, including a separate Quality Assurance row and Competitive Programming activity that the UI did not display. Root cause (if identifiable) The download flow recalculated activity from challenge winner and resource rows. That logic used different win, submission, grouping, and Data Science rules from the member stats and history data used by Profiles. What was changed The download now reads member stats and history through StatisticsService and maps the Development, Design, Testing, and SRM-based Competitive Programming rows with the same grouping and history fallbacks used by Profiles. A history lookup failure falls back to aggregate stats so profile generation can continue. Any added/updated tests Added unit coverage for the PM-5793 profile totals, SRM-only Competitive Programming visibility, and overlapping AI Engineering history. The focused tests, lint, and build pass. The full suite has 218 passing tests and the same 17 pre-existing Joi message assertion failures as origin/develop, which has 215 passing tests and those 17 failures. --- src/common/profileStats.ts | 344 +++++++++++++++++++++++++++++++++ src/services/MemberService.ts | 167 +++------------- test/unit/ProfileStats.test.js | 167 ++++++++++++++++ 3 files changed, 535 insertions(+), 143 deletions(-) create mode 100644 src/common/profileStats.ts create mode 100644 test/unit/ProfileStats.test.js diff --git a/src/common/profileStats.ts b/src/common/profileStats.ts new file mode 100644 index 0000000..226d7e3 --- /dev/null +++ b/src/common/profileStats.ts @@ -0,0 +1,344 @@ +/** + * Build the Topcoder activity summary used by downloaded member profiles. + * + * Profiles derives its displayed track totals from member stats and stats history. + * This helper applies the same grouping and history fallbacks so the PDF does not + * invent a second set of counters from challenge registrations or winner rows. + */ + +const TESTING_SUBTRACK_NAMES = new Set([ + 'BUG_HUNT', + 'TEST_SCENARIOS', + 'TEST_SUITES' +]) +const AI_ENGINEERING_TRACK_NAMES = new Set([ + 'AI', + 'AI_ENGINEER', + 'AI_ENGINEERING' +]) + +/** + * Return a finite numeric value without coercing strings or nullish values. + * PDF profile summaries use this to distinguish missing counters from zero. + * @param {*} value candidate numeric value from the member stats response + * @returns {number|undefined} the finite number, or undefined when unavailable + */ +function getFiniteNumber (value) { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +/** + * Normalize a track name for matching Profiles aliases such as AI Engineering. + * @param {*} value raw track or rating-path name + * @returns {string} uppercase underscore-delimited track token + */ +function normalizeTrackName (value) { + return String(value || '').trim().toUpperCase().replace(/[\s-]+/g, '_') +} + +/** + * Determine whether a track or rating-path name represents AI Engineering. + * Profiles groups these aliases into Development activity. + * @param {*} value raw track or rating-path name + * @returns {boolean} true for a supported AI Engineering alias + */ +function isAIEngineeringTrackName (value) { + return AI_ENGINEERING_TRACK_NAMES.has(normalizeTrackName(value)) +} + +/** + * Read an explicit submission count from a member stats subtrack. + * Both legacy numeric values and unified nested submission objects are supported. + * @param {Object} subTrack member stats subtrack + * @returns {number|undefined} explicit submission count when present + */ +function getSubTrackSubmissionCount (subTrack) { + const nestedCount = subTrack && subTrack.submissions && typeof subTrack.submissions === 'object' + ? subTrack.submissions.submissions + : undefined + return getFiniteNumber(nestedCount) ?? getFiniteNumber(subTrack && subTrack.submissions) +} + +/** + * Resolve the submission count displayed by Profiles for a subtrack. + * Positive explicit counts win; otherwise challenge participation is the fallback. + * @param {Object} subTrack member stats subtrack + * @returns {number|undefined} displayable submission count when available + */ +function getSubTrackDisplaySubmissionCount (subTrack) { + const submissionCount = getSubTrackSubmissionCount(subTrack) + if (submissionCount !== undefined && submissionCount > 0) { + return submissionCount + } + + const challengeCount = getFiniteNumber(subTrack && subTrack.challenges) + return challengeCount !== undefined && challengeCount > 0 + ? challengeCount + : submissionCount +} + +/** + * Determine whether a member stats subtrack has activity visible in Profiles. + * @param {Object} subTrack member stats subtrack + * @returns {boolean} true when submissions or challenges are positive + */ +function isActiveSubTrack (subTrack) { + return (getSubTrackDisplaySubmissionCount(subTrack) ?? 0) > 0 || + (getFiniteNumber(subTrack && subTrack.challenges) ?? 0) > 0 +} + +/** + * Load history rows for a stats subtrack from the member history response. + * Unified DEVELOPMENT, DESIGN, and QA history is stored in named subtrack arrays. + * @param {Object|undefined} statsHistory member stats history response + * @param {string} trackName API track key containing the subtrack + * @param {string} subTrackName API subtrack name + * @returns {Array} matching history rows, or an empty array + */ +function getSubTrackHistory (statsHistory, trackName, subTrackName) { + const trackHistory = statsHistory && statsHistory[trackName] + if (!trackHistory) { + return [] + } + + if (Array.isArray(trackHistory.history)) { + return trackHistory.history + } + + if (Array.isArray(trackHistory.subTracks)) { + const matchingSubTrack = trackHistory.subTracks.find(subTrack => subTrack && subTrack.name === subTrackName) + return matchingSubTrack && Array.isArray(matchingSubTrack.history) + ? matchingSubTrack.history + : [] + } + + const keyedHistory = trackHistory[subTrackName] + return keyedHistory && Array.isArray(keyedHistory.history) ? keyedHistory.history : [] +} + +/** + * Pick the rated AI Engineering stats source that Profiles groups under Development. + * A DATA_SCIENCE rating path is preferred over compatible top-level AI payloads. + * @param {Object} stats member stats response for one public group + * @returns {Object|undefined} subtrack plus its API history track key + */ +function getAIEngineeringSource (stats) { + const dataScienceStats: Record = stats.DATA_SCIENCE || {} + const dataScienceCandidates = Object.entries(dataScienceStats) + .filter(([name, value]) => ( + isAIEngineeringTrackName(name) && + value && typeof value === 'object' && + getFiniteNumber(value.rank && value.rank.rating) !== undefined + )) + .map(([name, subTrack]) => ({ + subTrack: { ...subTrack, name }, + trackName: 'DATA_SCIENCE' + })) + .sort((left, right) => ( + (getFiniteNumber(right.subTrack.rank && right.subTrack.rank.rating) ?? 0) - + (getFiniteNumber(left.subTrack.rank && left.subTrack.rank.rating) ?? 0) + )) + + if (dataScienceCandidates.length > 0) { + return dataScienceCandidates[0] + } + + const topLevelName = ['AI_ENGINEERING', 'AI', 'AI_ENGINEER'] + .find(name => stats[name] && typeof stats[name] === 'object') + if (!topLevelName) { + return undefined + } + + return { + subTrack: { ...stats[topLevelName], name: topLevelName }, + trackName: topLevelName + } +} + +/** + * Build the win and submission totals displayed for one Profiles subtrack. + * Placement-bearing history is authoritative for wins, while history length is + * the minimum submission count when aggregate counters are stale or incomplete. + * @param {Object} source subtrack plus the API track key used to find its history + * @param {Object|undefined} statsHistory member stats history response + * @returns {{wins: number, submissions: number}} display-safe subtrack totals + */ +function getSubTrackSummary (source, statsHistory) { + const history = getSubTrackHistory(statsHistory, source.trackName, source.subTrack.name) + const historyWithPlacements = history.filter(row => getFiniteNumber(row && row.placement) !== undefined) + + return { + submissions: Math.max(getSubTrackDisplaySubmissionCount(source.subTrack) ?? 0, history.length), + wins: historyWithPlacements.length > 0 + ? historyWithPlacements.filter(row => row.placement === 1).length + : getFiniteNumber(source.subTrack.wins) ?? 0 + } +} + +/** + * Build the stable challenge identity used by Profiles to de-duplicate history. + * @param {Object} historyRow member stats history row + * @returns {string} composite challenge identity + */ +function getHistoryChallengeKey (historyRow) { + return [ + historyRow && historyRow.challengeId, + historyRow && historyRow.challengeName, + historyRow && (historyRow.ratingDate ?? historyRow.date) + ].map(value => String(value ?? '')).join('::') +} + +/** + * Sum a track without de-duplicating activity across its subtracks. + * Design and Testing detail views use raw challenge totals plus history-aware + * win and submission summaries for each child card. + * @param {Array} sources active subtracks with their API track keys + * @param {Object|undefined} statsHistory member stats history response + * @returns {{challenges: number, wins: number, submissions: number}} track totals + */ +function getStandardTrackSummary (sources, statsHistory) { + return sources.reduce((summary, source) => { + const subTrackSummary = getSubTrackSummary(source, statsHistory) + summary.challenges += getFiniteNumber(source.subTrack.challenges) ?? 0 + summary.wins += subTrackSummary.wins + summary.submissions += subTrackSummary.submissions + return summary + }, { challenges: 0, wins: 0, submissions: 0 }) +} + +/** + * Build Development totals using the Profiles history de-duplication rules. + * This prevents overlapping rating paths from counting the same challenge twice + * while retaining aggregate-only CODE and First2Finish activity. + * @param {Array} sources active Development subtracks + * @param {Object|undefined} statsHistory member stats history response + * @returns {{challenges: number, wins: number, submissions: number}} Development totals + */ +function getDevelopmentTrackSummary (sources, statsHistory) { + const summaries = sources.map(source => ({ + history: getSubTrackHistory(statsHistory, source.trackName, source.subTrack.name), + stats: getSubTrackSummary(source, statsHistory), + subTrack: source.subTrack + })) + const historySummaries = summaries.filter(summary => summary.history.length > 0) + + if (historySummaries.length === 0) { + return getStandardTrackSummary(sources, statsHistory) + } + + const uniqueHistoryByChallenge = new Map() + let hasDuplicateHistory = false + historySummaries.forEach((summary) => { + summary.history.forEach((historyRow) => { + const key = getHistoryChallengeKey(historyRow) + const existingHistory = uniqueHistoryByChallenge.get(key) + if (existingHistory) { + hasDuplicateHistory = true + } + if (!existingHistory || existingHistory.placement !== 1) { + uniqueHistoryByChallenge.set(key, historyRow) + } + }) + }) + + const uniqueHistory = Array.from(uniqueHistoryByChallenge.values()) + const noHistorySources = sources.filter((source) => ( + getSubTrackHistory(statsHistory, source.trackName, source.subTrack.name).length === 0 + )) + const noHistoryStats = getStandardTrackSummary(noHistorySources, statsHistory) + const historyChallengeExtras = historySummaries.reduce((total, summary) => ( + total + Math.max(0, (getFiniteNumber(summary.subTrack.challenges) ?? 0) - summary.history.length) + ), 0) + const historySubmissionExtras = historySummaries.reduce((total, summary) => ( + total + Math.max(0, summary.stats.submissions - summary.history.length) + ), 0) + const uniqueHistoryWins = uniqueHistory.filter(historyRow => historyRow.placement === 1).length + const historyStatsWins = hasDuplicateHistory + ? Math.max(...historySummaries.map(summary => summary.stats.wins)) + : historySummaries.reduce((total, summary) => total + summary.stats.wins, 0) + + return { + challenges: uniqueHistory.length + historyChallengeExtras + noHistoryStats.challenges, + submissions: uniqueHistory.length + historySubmissionExtras + noHistoryStats.submissions, + wins: (uniqueHistoryWins > 0 ? uniqueHistoryWins : historyStatsWins) + noHistoryStats.wins + } +} + +/** + * Convert member stats and history responses into downloaded-profile activity rows. + * The PDF uses this mapper to match the Development, Design, Testing, and + * Competitive Programming values shown by Profiles. Competitive Programming is + * emitted only for active SRM stats; other Data Science activity is not relabeled. + * This function does not mutate its inputs or throw for missing response fields. + * @param {Object|undefined} stats member stats response for one public group + * @param {Object|undefined} statsHistory member stats history response for the same group + * @returns {Array<{trackName: string, wins: number, submissions?: number, challenges?: number, rating?: number, competitions?: number}>} PDF activity rows + */ +function buildProfileActivityStats (stats, statsHistory) { + if (!stats) { + return [] + } + + const developSources = Array.isArray(stats.DEVELOP && stats.DEVELOP.subTracks) + ? stats.DEVELOP.subTracks.map(subTrack => ({ subTrack, trackName: 'DEVELOP' })) + : [] + const developmentSources = developSources.filter(source => ( + !TESTING_SUBTRACK_NAMES.has(source.subTrack.name) && isActiveSubTrack(source.subTrack) + )) + if (!developmentSources.some(source => isAIEngineeringTrackName(source.subTrack.name))) { + const aiEngineeringSource = getAIEngineeringSource(stats) + if (aiEngineeringSource && isActiveSubTrack(aiEngineeringSource.subTrack)) { + developmentSources.push(aiEngineeringSource) + } + } + const designSources = Array.isArray(stats.DESIGN && stats.DESIGN.subTracks) + ? stats.DESIGN.subTracks + .filter(isActiveSubTrack) + .map(subTrack => ({ subTrack, trackName: 'DESIGN' })) + : [] + const qaSources = Array.isArray(stats.QA && stats.QA.subTracks) + ? stats.QA.subTracks + .filter(isActiveSubTrack) + .map(subTrack => ({ subTrack, trackName: 'QA' })) + : [] + const testingSources = developSources + .filter(source => TESTING_SUBTRACK_NAMES.has(source.subTrack.name) && isActiveSubTrack(source.subTrack)) + .concat(qaSources) + const result = [] + + if (developmentSources.length > 0) { + result.push({ + trackName: 'Development', + ...getDevelopmentTrackSummary(developmentSources, statsHistory) + }) + } + if (designSources.length > 0) { + result.push({ + trackName: 'Design', + ...getStandardTrackSummary(designSources, statsHistory) + }) + } + if (testingSources.length > 0) { + result.push({ + trackName: 'Testing', + ...getStandardTrackSummary(testingSources, statsHistory) + }) + } + + const srmStats = stats.DATA_SCIENCE && stats.DATA_SCIENCE.SRM + const competitions = getFiniteNumber(srmStats && srmStats.challenges) ?? 0 + if (competitions > 0) { + result.push({ + trackName: 'Competitive Programming', + rating: getFiniteNumber(srmStats && srmStats.rank && srmStats.rank.rating) ?? 0, + wins: getFiniteNumber(srmStats && srmStats.wins) ?? 0, + competitions + }) + } + + return result +} + +module.exports = { + buildProfileActivityStats +} diff --git a/src/services/MemberService.ts b/src/services/MemberService.ts index 3c9fa93..10de735 100644 --- a/src/services/MemberService.ts +++ b/src/services/MemberService.ts @@ -23,6 +23,7 @@ const fileTypeChecker = require('file-type-checker') const sharp = require('sharp') const { bufferContainsScript } = require('../common/image') const { htmlToText } = require('../common/htmlUtils') +const { buildProfileActivityStats } = require('../common/profileStats') const countryCallingCodes = require('country-calling-code') const prismaHelper = require('../common/prismaHelper') const prismaManager = require('../common/prisma') @@ -1755,126 +1756,34 @@ async function getMemberRoles (userId) { } } -/** Track enum to display name for member PDF activity stats. */ -const TRACK_DISPLAY_NAMES = { - DEVELOPMENT: 'Development', - DESIGN: 'Design', - DATA_SCIENCE: 'Competitive Programming', - QUALITY_ASSURANCE: 'Quality Assurance' -} - /** - * Fetch member stats by challenge track for PDF: wins and submissions from ChallengeWinner, - * registrations (challenges count) from resources schema, grouped by track. - * @param {Number} userId member userId - * @param {Object} challengesPrisma challenges Prisma client - * @param {Object} resourcesPrisma resources Prisma client + * Fetch the member stats and history used by Profiles and map them for the PDF. + * Failures are logged and return no activity rows so profile generation can continue. + * @param {Object} currentUser the user who performs the profile download + * @param {String} handle member handle * @returns {Promise>} */ -async function fetchMemberStatsByTrack (userId, challengesPrisma, resourcesPrisma) { - const trackMap: Record = {} // track enum -> standard counts or competitive programming counts - +async function fetchMemberStatsByTrack (currentUser, handle) { try { - const numUserId = Object.prototype.toString.call(userId) === '[object BigInt]' - ? helper.bigIntToNumber(userId) - : userId - - const winnerRows = await challengesPrisma.ChallengeWinner.findMany({ - where: { - userId: numUserId, - type: { in: ['PLACEMENT', 'PASSED_REVIEW'] } - }, - include: { - challenge: { - include: { track: true } - } - } - }) - - for (const w of winnerRows) { - const trackEnum = _.get(w, 'challenge.track.track') - if (!trackEnum) continue - if (!trackMap[trackEnum]) { - const isCompetitiveProgramming = trackEnum === 'DATA_SCIENCE' - trackMap[trackEnum] = isCompetitiveProgramming - ? { wins: 0, competitions: 0, rating: undefined } - : { wins: 0, submissions: 0, challenges: 0 } - } - const row = trackMap[trackEnum] - if (w.type === 'PLACEMENT') row.wins += 1 - if (w.type === 'PASSED_REVIEW' && row.submissions !== undefined) row.submissions += 1 - } - - // 2) Resources: registrations (distinct challenges) by track - const memberIdStr = String(userId) - const resources = await resourcesPrisma.resource.findMany({ - where: { - memberId: memberIdStr, - resourceRole: { - nameLower: 'submitter' - } - }, - select: { challengeId: true } - }) - const challengeIds: any[] = [...new Set(resources.map(r => r.challengeId).filter(Boolean))] - if (challengeIds.length > 0) { - const challenges = await challengesPrisma.Challenge.findMany({ - where: { id: { in: challengeIds } }, - include: { track: true } - }) - const challengeIdToTrack: Record = {} - for (const c of challenges) { - const trackEnum = _.get(c, 'track.track') - if (trackEnum) challengeIdToTrack[c.id] = trackEnum - } - const challengesPerTrack: Record = {} - for (const cid of challengeIds) { - const trackEnum = challengeIdToTrack[cid] - if (!trackEnum) continue - if (!challengesPerTrack[trackEnum]) challengesPerTrack[trackEnum] = 0 - challengesPerTrack[trackEnum] += 1 - } - for (const [trackEnum, count] of Object.entries(challengesPerTrack)) { - if (!trackMap[trackEnum]) { - const isCompetitiveProgramming = trackEnum === 'DATA_SCIENCE' - trackMap[trackEnum] = isCompetitiveProgramming - ? { wins: 0, competitions: count, rating: undefined } - : { wins: 0, submissions: 0, challenges: count } - } else { - if (trackMap[trackEnum].challenges !== undefined) { - trackMap[trackEnum].challenges = count - } - if (trackMap[trackEnum].competitions !== undefined) { - trackMap[trackEnum].competitions = count - } - } - } + const StatisticsService = require('./StatisticsService') + const [statsOutcome, historyOutcome] = await Promise.allSettled([ + StatisticsService.getMemberStats(currentUser, handle, {}), + StatisticsService.getHistoryStats(currentUser, handle, {}) + ]) + if (statsOutcome.status === 'rejected') { + throw statsOutcome.reason } - - const statsByTrack = [] - for (const [trackEnum, counts] of Object.entries(trackMap)) { - const trackName = TRACK_DISPLAY_NAMES[trackEnum] || trackEnum - const hasAny = Object.values(counts).some(v => typeof v === 'number' && v > 0) - if (!hasAny && (counts.rating == null || counts.rating === 0)) continue - if (trackEnum === 'DATA_SCIENCE') { - statsByTrack.push({ - trackName, - rating: counts.rating == null ? 0 : counts.rating, - wins: counts.wins == null ? 0 : counts.wins, - competitions: counts.competitions == null ? 0 : counts.competitions - }) - } else { - statsByTrack.push({ - trackName, - wins: counts.wins == null ? 0 : counts.wins, - submissions: counts.submissions == null ? 0 : counts.submissions, - challenges: counts.challenges == null ? 0 : counts.challenges - }) - } + if (historyOutcome.status === 'rejected') { + logger.warn(`fetchMemberStatsByTrack history lookup failed for ${handle}: ${historyOutcome.reason.message}`) } - return statsByTrack + const statsResult = statsOutcome.value + const historyResult = historyOutcome.status === 'fulfilled' ? historyOutcome.value : [] + return buildProfileActivityStats( + Array.isArray(statsResult) ? statsResult[0] : undefined, + Array.isArray(historyResult) ? historyResult[0] : undefined + ) } catch (err) { - logger.warn(`fetchMemberStatsByTrack failed for user ${userId}: ${err.message}`) + logger.warn(`fetchMemberStatsByTrack failed for ${handle}: ${err.message}`) return [] } } @@ -1979,36 +1888,8 @@ async function aggregatePDFData (currentUser, handle) { // Fetch gamification achievements const achievements = await fetchGamificationAchievements(userId) - // Fetch member stats by track (wins, submissions, challenges from ChallengeWinner + resources) - let statsByTrack = [] - try { - statsByTrack = await fetchMemberStatsByTrack(userId, challengesPrisma, resourcesPrisma) - } catch (err) { - logger.warn(`aggregatePDFData: statsByTrack failed for ${handle}: ${err.message}`) - } - - // Merge Competitive Programming rating from the stats service into PDF activity data. - try { - const StatisticsService = require('./StatisticsService') - const statsResult = await StatisticsService.getMemberStats(currentUser, handle, {}) - const statsResponse = Array.isArray(statsResult) && statsResult.length > 0 ? statsResult[0] : null - if (statsResponse && statsResponse.DATA_SCIENCE) { - const ds = statsResponse.DATA_SCIENCE - const rating = (ds.SRM && ds.SRM.rank && ds.SRM.rank.rating != null) - ? ds.SRM.rank.rating - : (ds.MARATHON_MATCH && ds.MARATHON_MATCH.rank && ds.MARATHON_MATCH.rank.rating != null) - ? ds.MARATHON_MATCH.rank.rating - : 0 - const cpEntry = statsByTrack.find(entry => entry.trackName === 'Competitive Programming') - if (cpEntry) { - cpEntry.rating = rating - } else if (rating > 0) { - statsByTrack.push({ trackName: 'Competitive Programming', rating, wins: 0, competitions: 0 }) - } - } - } catch (err) { - logger.warn(`aggregatePDFData: getMemberStats for rating failed for ${handle}: ${err.message}`) - } + // Use the same member stats and history sources as the Profiles UI. + const statsByTrack = await fetchMemberStatsByTrack(currentUser, handle) // Fetch certifications and courses const { certifications, courses } = await fetchCertificationsAndCourses(userId) diff --git a/test/unit/ProfileStats.test.js b/test/unit/ProfileStats.test.js new file mode 100644 index 0000000..f992b28 --- /dev/null +++ b/test/unit/ProfileStats.test.js @@ -0,0 +1,167 @@ +/* + * Unit tests for downloaded-profile member stats aggregation. + */ + +const chai = require('chai') + +const { buildProfileActivityStats } = require('../../src/common/profileStats') + +const should = chai.should() + +describe('profile stats helper unit tests', () => { + it('should match Profiles track totals for the PM-5793 member data', () => { + const stats = { + DEVELOP: { + subTracks: [ + { name: 'Task', challenges: 178, wins: 178, submissions: { submissions: 178 } }, + { name: 'BUG_HUNT', challenges: 5, wins: 0, submissions: { submissions: 2 } }, + { name: 'TEST_SUITES', challenges: 6, wins: 2, submissions: { submissions: 4 } }, + { name: 'First2Finish', challenges: 17, wins: 7, submissions: { submissions: 7 } }, + { name: 'CODE', challenges: 29, wins: 7, submissions: { submissions: 21 } }, + { name: 'Challenge', challenges: 22, wins: 8, submissions: { submissions: 22 } } + ] + }, + DESIGN: { + subTracks: [ + { name: 'Challenge', challenges: 6, wins: null } + ] + }, + QA: { + subTracks: [ + { name: 'Challenge', challenges: 5, wins: 2, submissions: { submissions: 5 } }, + { name: 'Task', challenges: 4, wins: 4, submissions: { submissions: 4 } } + ] + }, + DATA_SCIENCE: { + SRM: { challenges: null, wins: null, rank: { rating: 0 } }, + Task: { challenges: 1, wins: 1, submissions: { submissions: 1 } } + } + } + const history = { + DEVELOP: { + subTracks: [ + { + name: 'Task', + history: Array.from({ length: 178 }, (_, index) => ({ + challengeId: `task-${index}`, + challengeName: `Task ${index}`, + placement: 1, + ratingDate: index + })) + }, + { + name: 'Challenge', + history: Array.from({ length: 23 }, (_, index) => ({ + challengeId: `challenge-${index}`, + challengeName: `Challenge ${index}`, + placement: index < 8 ? 1 : index < 22 ? 2 : undefined, + ratingDate: index + })) + } + ] + }, + DESIGN: { + subTracks: [{ + name: 'Challenge', + history: [ + { challengeId: 'design-1', challengeName: 'Design 1', placement: 50, ratingDate: 1 }, + { challengeId: 'design-2', challengeName: 'Design 2', placement: 1, ratingDate: 2 } + ] + }] + }, + QA: { + subTracks: [ + { + name: 'Challenge', + history: Array.from({ length: 5 }, (_, index) => ({ + challengeId: `qa-challenge-${index}`, + challengeName: `QA Challenge ${index}`, + placement: index < 2 ? 1 : 2, + ratingDate: index + })) + }, + { + name: 'Task', + history: Array.from({ length: 4 }, (_, index) => ({ + challengeId: `qa-task-${index}`, + challengeName: `QA Task ${index}`, + placement: 1, + ratingDate: index + })) + } + ] + } + } + + buildProfileActivityStats(stats, history).should.deep.equal([ + { trackName: 'Development', challenges: 247, wins: 200, submissions: 229 }, + { trackName: 'Design', challenges: 6, wins: 1, submissions: 6 }, + { trackName: 'Testing', challenges: 20, wins: 8, submissions: 15 } + ]) + }) + + it('should create Competitive Programming only from active SRM stats', () => { + buildProfileActivityStats({ + DATA_SCIENCE: { + Challenge: { challenges: 5, wins: 2, rank: { rating: 1500 } }, + MARATHON_MATCH: { challenges: 8, wins: 1, rank: { rating: 1800 } }, + SRM: { challenges: 0, wins: 0, rank: { rating: 0 } } + } + }, {}).should.deep.equal([]) + + const activeStats = buildProfileActivityStats({ + DATA_SCIENCE: { + Challenge: { challenges: 5, wins: 2, rank: { rating: 1500 } }, + MARATHON_MATCH: { challenges: 8, wins: 1, rank: { rating: 1800 } }, + SRM: { challenges: 170, wins: 1, rank: { rating: 2741 } } + } + }, {}) + + activeStats.should.deep.equal([ + { trackName: 'Competitive Programming', rating: 2741, wins: 1, competitions: 170 } + ]) + should.equal(activeStats[0].rating, 2741) + }) + + it('should include and de-duplicate rated AI Engineering activity under Development', () => { + const sharedHistory = [ + { challengeId: 'shared-1', challengeName: 'Shared 1', placement: 1, ratingDate: 1 }, + { challengeId: 'shared-2', challengeName: 'Shared 2', placement: 1, ratingDate: 2 } + ] + const aiHistory = [ + { challengeId: 'ai-1', challengeName: 'AI 1', placement: 1, ratingDate: 3 }, + { challengeId: 'ai-2', challengeName: 'AI 2', placement: 1, ratingDate: 4 }, + { challengeId: 'ai-3', challengeName: 'AI 3', placement: 1, ratingDate: 5 }, + { challengeId: 'ai-4', challengeName: 'AI 4', placement: 1, ratingDate: 6 }, + ...sharedHistory + ] + + buildProfileActivityStats({ + DEVELOP: { + subTracks: [{ + name: 'Challenge', + challenges: 2, + wins: 2, + submissions: { submissions: 2 } + }] + }, + DATA_SCIENCE: { + 'AI Engineering': { + challenges: 6, + wins: 6, + submissions: { submissions: 6 }, + rank: { rating: 1200 } + } + } + }, { + DEVELOP: { + subTracks: [{ name: 'Challenge', history: sharedHistory }] + }, + DATA_SCIENCE: { + 'AI Engineering': { history: aiHistory } + } + }).should.deep.equal([ + { trackName: 'Development', challenges: 6, wins: 6, submissions: 6 } + ]) + }) +}) From 78cafaf10c2bac327bc8e2ee0bd8da6cdc06bb50 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Mon, 3 Aug 2026 15:16:28 +1000 Subject: [PATCH 3/3] PM-5793: Restore downloaded profile ratings What was broken Downloaded profiles could omit all Topcoder Activity stats after the first PM-5793 change, and rated Data Science paths were not included even when Profiles displayed them. Root cause (if identifiable) The service used Promise.allSettled while app-bootstrap replaces the global Promise with Bluebird. Bluebird returns PromiseInspection objects rather than native status/value records, so the mapper received no stats. The first mapper also handled only the legacy tracks and SRM. What was changed Resolve required stats and optional history with Bluebird-compatible Promise.all behavior, map native and configured Data Science ratings with Profiles history/count/order rules, and render ratings in PDF activity rows. Any added/updated tests Expanded ProfileStats regression coverage for the supplied totals, Data Science and SRM separation, rating selection and ordering, AI de-duplication/tie-breaks, Bluebird/native promise resolution, optional-history fallback, and PDF rating text. --- src/common/profileStats.ts | 136 +++++++++++++++++++++-- src/common/profileTemplate.ts | 5 +- src/services/MemberService.ts | 23 ++-- test/unit/ProfileStats.test.js | 197 ++++++++++++++++++++++++++++++++- 4 files changed, 329 insertions(+), 32 deletions(-) diff --git a/src/common/profileStats.ts b/src/common/profileStats.ts index 226d7e3..5a61f70 100644 --- a/src/common/profileStats.ts +++ b/src/common/profileStats.ts @@ -16,6 +16,19 @@ const AI_ENGINEERING_TRACK_NAMES = new Set([ 'AI_ENGINEER', 'AI_ENGINEERING' ]) +const NATIVE_DATA_SCIENCE_SUBTRACK_NAMES = [ + 'Challenge', + 'MARATHON_MATCH' +] +const NATIVE_DATA_SCIENCE_STATS_KEYS = new Set([ + ...NATIVE_DATA_SCIENCE_SUBTRACK_NAMES, + 'SRM', + 'challenges', + 'mostRecentEventDate', + 'mostRecentEventName', + 'mostRecentSubmission', + 'wins' +]) /** * Return a finite numeric value without coercing strings or nullish values. @@ -134,13 +147,9 @@ function getAIEngineeringSource (stats) { subTrack: { ...subTrack, name }, trackName: 'DATA_SCIENCE' })) - .sort((left, right) => ( - (getFiniteNumber(right.subTrack.rank && right.subTrack.rank.rating) ?? 0) - - (getFiniteNumber(left.subTrack.rank && left.subTrack.rank.rating) ?? 0) - )) if (dataScienceCandidates.length > 0) { - return dataScienceCandidates[0] + return getDataScienceSummarySource(dataScienceCandidates) } const topLevelName = ['AI_ENGINEERING', 'AI', 'AI_ENGINEER'] @@ -264,11 +273,68 @@ function getDevelopmentTrackSummary (sources, statsHistory) { } } +/** + * Pick the Data Science subtrack whose rating Profiles displays. + * Rating, percentile, and challenge count are descending tie breakers. + * @param {Array} sources active rated Data Science sources + * @returns {Object|undefined} source with the strongest visible rating + */ +function getDataScienceSummarySource (sources) { + return [...sources].sort((left, right) => { + const leftRank = left.subTrack.rank || {} + const rightRank = right.subTrack.rank || {} + + return (getFiniteNumber(rightRank.rating) ?? 0) - (getFiniteNumber(leftRank.rating) ?? 0) || + (getFiniteNumber(rightRank.percentile) ?? 0) - (getFiniteNumber(leftRank.percentile) ?? 0) || + (getFiniteNumber(right.subTrack.challenges) ?? 0) - (getFiniteNumber(left.subTrack.challenges) ?? 0) + })[0] +} + +/** + * Build the independently rated, non-native Data Science rows shown by Profiles. + * Native Challenge, Marathon Match, and SRM rows are handled by their parent + * tracks, while AI Engineering aliases remain grouped under Development. + * @param {Object} stats member stats response for one public group + * @param {Object|undefined} statsHistory member stats history response for the same group + * @returns {Array<{trackName: string, rating: number, wins: number, submissions: number, challenges: number}>} custom rated rows + */ +function getDataScienceRatingPathRows (stats, statsHistory) { + const dataScienceStats: Record = stats.DATA_SCIENCE + if (!dataScienceStats || typeof dataScienceStats !== 'object') { + return [] + } + + return Object.entries(dataScienceStats) + .filter(([name, subTrack]) => ( + !NATIVE_DATA_SCIENCE_STATS_KEYS.has(name) && + !isAIEngineeringTrackName(name) && + subTrack && typeof subTrack === 'object' && + getFiniteNumber(subTrack.rank && subTrack.rank.rating) !== undefined + )) + .sort(([, left], [, right]) => ( + (getFiniteNumber(right.wins) ?? 0) - (getFiniteNumber(left.wins) ?? 0) || + (getSubTrackDisplaySubmissionCount(right) ?? 0) - (getSubTrackDisplaySubmissionCount(left) ?? 0) + )) + .map(([name, subTrack]) => { + const source = { + subTrack: { ...subTrack, name }, + trackName: 'DATA_SCIENCE' + } + + return { + trackName: name, + rating: getFiniteNumber(subTrack.rank && subTrack.rank.rating) ?? 0, + ...getSubTrackSummary(source, statsHistory), + challenges: getFiniteNumber(subTrack.challenges) ?? 0 + } + }) +} + /** * Convert member stats and history responses into downloaded-profile activity rows. - * The PDF uses this mapper to match the Development, Design, Testing, and - * Competitive Programming values shown by Profiles. Competitive Programming is - * emitted only for active SRM stats; other Data Science activity is not relabeled. + * The PDF uses this mapper to match the Development, Design, Testing, Data + * Science, configured rating-path, and Competitive Programming values shown by + * Profiles. Competitive Programming is emitted only for active SRM stats. * This function does not mutate its inputs or throw for missing response fields. * @param {Object|undefined} stats member stats response for one public group * @param {Object|undefined} statsHistory member stats history response for the same group @@ -325,6 +391,26 @@ function buildProfileActivityStats (stats, statsHistory) { }) } + const dataScienceStats: Record = stats.DATA_SCIENCE || {} + const dataScienceSources = NATIVE_DATA_SCIENCE_SUBTRACK_NAMES + .filter(name => ( + dataScienceStats[name] && + typeof dataScienceStats[name] === 'object' && + (getFiniteNumber(dataScienceStats[name].challenges) ?? 0) > 0 + )) + .map(name => ({ + subTrack: { ...dataScienceStats[name], name }, + trackName: 'DATA_SCIENCE' + })) + if (dataScienceSources.length > 0) { + const summarySource = getDataScienceSummarySource(dataScienceSources) + result.push({ + trackName: 'Data Science', + rating: getFiniteNumber(summarySource && summarySource.subTrack.rank && summarySource.subTrack.rank.rating) ?? 0, + ...getStandardTrackSummary(dataScienceSources, statsHistory) + }) + } + const srmStats = stats.DATA_SCIENCE && stats.DATA_SCIENCE.SRM const competitions = getFiniteNumber(srmStats && srmStats.challenges) ?? 0 if (competitions > 0) { @@ -336,9 +422,41 @@ function buildProfileActivityStats (stats, statsHistory) { }) } + result.push(...getDataScienceRatingPathRows(stats, statsHistory)) + return result } +/** + * Resolve stats requests and build downloaded-profile activity rows. + * Member stats are required; history is optional and falls back to aggregate + * counters when its request fails. Promise.all is compatible with the Bluebird + * global used by the application bootstrap. + * @param {Promise>} statsRequest member stats request + * @param {Promise>} historyRequest member stats history request + * @param {Function} [onHistoryFailure] optional history error callback + * @returns {Promise>} PDF activity rows + * @throws {*} when the required member stats request fails + */ +async function buildProfileActivityStatsFromRequests (statsRequest, historyRequest, onHistoryFailure) { + const safeHistoryRequest = Promise.resolve(historyRequest).catch((error) => { + if (typeof onHistoryFailure === 'function') { + onHistoryFailure(error) + } + return [] + }) + const [statsResult, historyResult] = await Promise.all([ + statsRequest, + safeHistoryRequest + ]) + + return buildProfileActivityStats( + Array.isArray(statsResult) ? statsResult[0] : undefined, + Array.isArray(historyResult) ? historyResult[0] : undefined + ) +} + module.exports = { - buildProfileActivityStats + buildProfileActivityStats, + buildProfileActivityStatsFromRequests } diff --git a/src/common/profileTemplate.ts b/src/common/profileTemplate.ts index 20e9141..11b2651 100644 --- a/src/common/profileTemplate.ts +++ b/src/common/profileTemplate.ts @@ -297,7 +297,7 @@ function createCategorySkillsBlock (categoryName, skillNames) { } /** - * Build the PDF template for member profile + * Build the PDF template for a member profile, including rated activity rows. * @param {Object} pdfData the aggregated PDF data * @returns {Object} React element tree */ @@ -515,13 +515,14 @@ function buildProfileTemplate (pdfData) { const statsItems = topcoderActivity.statsByTrack.map((stat, index) => { const isCompetitiveProgramming = stat.trackName === 'Competitive Programming' const rating = stat.rating == null ? 0 : stat.rating + const hasTrackRating = !isCompetitiveProgramming && rating > 0 const wins = stat.wins == null ? 0 : stat.wins const competitions = stat.competitions == null ? 0 : stat.competitions const submissions = stat.submissions == null ? 0 : stat.submissions const challenges = stat.challenges == null ? 0 : stat.challenges const valueText = isCompetitiveProgramming ? `${rating} rating, ${wins} wins, ${competitions} competitions` - : `${wins} ${wins === 1 ? 'win' : 'wins'}, ${submissions} ${submissions === 1 ? 'submission' : 'submissions'}, ${challenges} ${challenges === 1 ? 'challenge' : 'challenges'}` + : `${hasTrackRating ? `${rating} rating, ` : ''}${wins} ${wins === 1 ? 'win' : 'wins'}, ${submissions} ${submissions === 1 ? 'submission' : 'submissions'}, ${challenges} ${challenges === 1 ? 'challenge' : 'challenges'}` return React.createElement( Text, { key: `stats-track-${index}`, style: styles.activityItem }, diff --git a/src/services/MemberService.ts b/src/services/MemberService.ts index 10de735..f0dc0d9 100644 --- a/src/services/MemberService.ts +++ b/src/services/MemberService.ts @@ -23,7 +23,7 @@ const fileTypeChecker = require('file-type-checker') const sharp = require('sharp') const { bufferContainsScript } = require('../common/image') const { htmlToText } = require('../common/htmlUtils') -const { buildProfileActivityStats } = require('../common/profileStats') +const { buildProfileActivityStatsFromRequests } = require('../common/profileStats') const countryCallingCodes = require('country-calling-code') const prismaHelper = require('../common/prismaHelper') const prismaManager = require('../common/prisma') @@ -1758,7 +1758,8 @@ async function getMemberRoles (userId) { /** * Fetch the member stats and history used by Profiles and map them for the PDF. - * Failures are logged and return no activity rows so profile generation can continue. + * Required stats failures are logged and return no activity rows so profile generation + * can continue; optional history failures fall back to aggregate counters. * @param {Object} currentUser the user who performs the profile download * @param {String} handle member handle * @returns {Promise>} @@ -1766,22 +1767,12 @@ async function getMemberRoles (userId) { async function fetchMemberStatsByTrack (currentUser, handle) { try { const StatisticsService = require('./StatisticsService') - const [statsOutcome, historyOutcome] = await Promise.allSettled([ + const statsByTrack = await buildProfileActivityStatsFromRequests( StatisticsService.getMemberStats(currentUser, handle, {}), - StatisticsService.getHistoryStats(currentUser, handle, {}) - ]) - if (statsOutcome.status === 'rejected') { - throw statsOutcome.reason - } - if (historyOutcome.status === 'rejected') { - logger.warn(`fetchMemberStatsByTrack history lookup failed for ${handle}: ${historyOutcome.reason.message}`) - } - const statsResult = statsOutcome.value - const historyResult = historyOutcome.status === 'fulfilled' ? historyOutcome.value : [] - return buildProfileActivityStats( - Array.isArray(statsResult) ? statsResult[0] : undefined, - Array.isArray(historyResult) ? historyResult[0] : undefined + StatisticsService.getHistoryStats(currentUser, handle, {}), + error => logger.warn(`fetchMemberStatsByTrack history lookup failed for ${handle}: ${error.message}`) ) + return statsByTrack } catch (err) { logger.warn(`fetchMemberStatsByTrack failed for ${handle}: ${err.message}`) return [] diff --git a/test/unit/ProfileStats.test.js b/test/unit/ProfileStats.test.js index f992b28..a1a37ad 100644 --- a/test/unit/ProfileStats.test.js +++ b/test/unit/ProfileStats.test.js @@ -2,12 +2,35 @@ * Unit tests for downloaded-profile member stats aggregation. */ +require('../../app-bootstrap') const chai = require('chai') -const { buildProfileActivityStats } = require('../../src/common/profileStats') +const { + buildProfileActivityStats, + buildProfileActivityStatsFromRequests +} = require('../../src/common/profileStats') +const { buildProfileTemplate } = require('../../src/common/profileTemplate') const should = chai.should() +/** + * Flatten text children from a React element tree for template assertions. + * @param {*} node React element, child array, or primitive value + * @returns {string} concatenated visible text + */ +function getTemplateText (node) { + if (node === null || node === undefined || typeof node === 'boolean') { + return '' + } + if (Array.isArray(node)) { + return node.map(getTemplateText).join('') + } + if (typeof node === 'string' || typeof node === 'number') { + return String(node) + } + return getTemplateText(node.props && node.props.children) +} + describe('profile stats helper unit tests', () => { it('should match Profiles track totals for the PM-5793 member data', () => { const stats = { @@ -100,14 +123,19 @@ describe('profile stats helper unit tests', () => { ]) }) - it('should create Competitive Programming only from active SRM stats', () => { - buildProfileActivityStats({ + it('should keep active SRM stats separate from Data Science activity', () => { + const inactiveStats = buildProfileActivityStats({ DATA_SCIENCE: { Challenge: { challenges: 5, wins: 2, rank: { rating: 1500 } }, MARATHON_MATCH: { challenges: 8, wins: 1, rank: { rating: 1800 } }, SRM: { challenges: 0, wins: 0, rank: { rating: 0 } } } - }, {}).should.deep.equal([]) + }, {}) + + inactiveStats.should.deep.equal([ + { trackName: 'Data Science', rating: 1800, challenges: 13, wins: 3, submissions: 13 } + ]) + inactiveStats.map(stat => stat.trackName).should.not.include('Competitive Programming') const activeStats = buildProfileActivityStats({ DATA_SCIENCE: { @@ -118,9 +146,102 @@ describe('profile stats helper unit tests', () => { }, {}) activeStats.should.deep.equal([ + { trackName: 'Data Science', rating: 1800, challenges: 13, wins: 3, submissions: 13 }, { trackName: 'Competitive Programming', rating: 2741, wins: 1, competitions: 170 } ]) - should.equal(activeStats[0].rating, 2741) + should.equal(activeStats[1].rating, 2741) + }) + + it('should include the strongest native Data Science rating and combined activity', () => { + buildProfileActivityStats({ + DATA_SCIENCE: { + Challenge: { + challenges: 2, + wins: 2, + submissions: { submissions: 1 }, + rank: { rating: 1499, percentile: 10 } + }, + MARATHON_MATCH: { + challenges: 1, + wins: 2, + rank: { rating: 763, percentile: 20 } + } + } + }, { + DATA_SCIENCE: { + Challenge: { + history: [ + { challengeId: 1, placement: 1 }, + { challengeId: 2, placement: 2 } + ] + }, + MARATHON_MATCH: { + history: [ + { challengeId: 3, placement: 1 }, + { challengeId: 4, placement: 3 } + ] + } + } + }).should.deep.equal([ + { trackName: 'Data Science', rating: 1499, challenges: 3, wins: 2, submissions: 4 } + ]) + }) + + it('should include history-aware custom ratings after Competitive Programming', () => { + buildProfileActivityStats({ + DATA_SCIENCE: { + 'Java MySQL': { + challenges: 3, + wins: 1, + rank: { rating: 1422, overallPercentile: 12 } + }, + Python: { + challenges: 2, + wins: 2, + rank: { rating: 1500, overallPercentile: 20 } + }, + NO_RATING: { + challenges: 2, + wins: 1, + rank: {} + }, + SRM: { challenges: 1, wins: 0, rank: { rating: 900 } } + } + }, { + DATA_SCIENCE: { + 'Java MySQL': { + history: [ + { challengeId: 1, placement: 2 }, + { challengeId: 2, placement: 1 }, + { challengeId: 3, placement: 3 }, + { challengeId: 4, placement: 2 } + ] + } + } + }).should.deep.equal([ + { trackName: 'Competitive Programming', rating: 900, wins: 0, competitions: 1 }, + { trackName: 'Python', rating: 1500, wins: 2, submissions: 2, challenges: 2 }, + { trackName: 'Java MySQL', rating: 1422, wins: 1, submissions: 4, challenges: 3 } + ]) + }) + + it('should use Profiles tie breakers for AI Engineering rating aliases', () => { + buildProfileActivityStats({ + DATA_SCIENCE: { + AI: { + challenges: 1, + wins: 1, + rank: { rating: 1200, percentile: 10 } + }, + AI_ENGINEERING: { + challenges: 3, + wins: 2, + rank: { rating: 1200, percentile: 20 } + } + } + }, {}).should.deep.equal([ + { trackName: 'Development', challenges: 3, wins: 2, submissions: 3 } + ]) }) it('should include and de-duplicate rated AI Engineering activity under Development', () => { @@ -164,4 +285,70 @@ describe('profile stats helper unit tests', () => { { trackName: 'Development', challenges: 6, wins: 6, submissions: 6 } ]) }) + + it('should resolve activity requests with the application Bluebird Promise', async () => { + const stats = { + DEVELOP: { + subTracks: [ + { name: 'Task', challenges: 2, wins: 1, submissions: { submissions: 2 } } + ] + } + } + + const result = await buildProfileActivityStatsFromRequests( + (async () => [stats])(), + (async () => [{}])() + ) + + result.should.deep.equal([ + { trackName: 'Development', challenges: 2, wins: 1, submissions: 2 } + ]) + }) + + it('should retain aggregate activity when the optional history request fails', async () => { + const historyError = new Error('history unavailable') + let reportedError + + const result = await buildProfileActivityStatsFromRequests( + (async () => [{ + DESIGN: { + subTracks: [ + { name: 'Challenge', challenges: 3, wins: 1 } + ] + } + }])(), + (async () => { throw historyError })(), + error => { reportedError = error } + ) + + result.should.deep.equal([ + { trackName: 'Design', challenges: 3, wins: 1, submissions: 3 } + ]) + should.equal(reportedError, historyError) + }) + + it('should render Data Science ratings in the downloaded-profile activity row', () => { + const template = buildProfileTemplate({ + member: { + generatedOn: 'August 3, 2026', + handle: 'rated-member' + }, + workExperience: [], + education: [], + languages: [], + skills: { principal: { verified: [], notVerified: [] } }, + skillsByCategory: [], + topcoderActivity: { + statsByTrack: [ + { trackName: 'Data Science', rating: 1499, wins: 1, submissions: 2, challenges: 2 } + ] + }, + certifications: [], + courses: [] + }) + + getTemplateText(template).should.include( + 'Data Science: 1499 rating, 1 win, 2 submissions, 2 challenges' + ) + }) })