├── src ├── schema │ ├── index.ts │ ├── common │ │ └── index.ts │ └── contests.ts ├── config.ts ├── index.ts ├── GQLQueries │ ├── allContests.ts │ ├── languageStats.ts │ ├── recentSubmit.ts │ ├── recentAcSubmit.ts │ ├── userProfileCalendar.ts │ ├── skillStats.ts │ ├── trendingDiscuss.ts │ ├── userQuestionProgress.ts │ ├── contest.ts │ ├── userContestRanking.ts │ ├── problemList.ts │ ├── index.ts │ ├── officialSolution.ts │ ├── getUserProfile.ts │ ├── discussTopic.ts │ ├── discussComments.ts │ ├── selectProblem.ts │ ├── userProfile.ts │ └── dailyProblem.ts ├── FormatUtils │ ├── trendingTopicData.ts │ ├── formatter.ts │ ├── index.ts │ ├── userProfileData.ts │ ├── problemData.ts │ └── userData.ts ├── Controllers │ ├── index.ts │ ├── handleRequest.ts │ ├── fetchDataRawFormat.ts │ ├── fetchDiscussion.ts │ ├── fetchUserProfile.ts │ ├── fetchSingleProblem.ts │ ├── fetchUserDetails.ts │ ├── fetchProblems.ts │ └── fetchContests.ts ├── types.ts └── app.ts ├── public └── demo │ ├── demo1.png │ ├── demo10.png │ ├── demo11.png │ ├── demo12.png │ ├── demo13.png │ ├── demo14.png │ ├── demo15.png │ ├── demo16.png │ ├── demo17.png │ ├── demo18.png │ ├── demo19.png │ ├── demo2.png │ ├── demo20.png │ ├── demo21.png │ ├── demo22.png │ ├── demo23.png │ ├── demo24.png │ ├── demo25.png │ ├── demo26.png │ ├── demo27.png │ ├── demo28.png │ ├── demo29.png │ ├── demo3.png │ ├── demo30.png │ ├── demo31.png │ ├── demo32.png │ ├── demo33.png │ ├── demo34.png │ ├── demo4.png │ ├── demo5.png │ ├── demo6.png │ ├── demo7.png │ ├── demo8.png │ ├── demo9.png │ ├── contribute.png │ ├── mcp-example-1.png │ ├── mcp-example-2.png │ ├── mcp-example-3.png │ ├── mcp-example-4.png │ └── mcp-example-5.png ├── tests ├── msw │ ├── server.ts │ ├── mockData │ │ ├── singleUserContests.json │ │ ├── languageStats.json │ │ ├── allContests.json │ │ ├── userCalendar.json │ │ ├── problems.json │ │ ├── index.ts │ │ ├── skillStats.json │ │ ├── trendingDiscuss.json │ │ ├── discussTopic.json │ │ ├── userQuestionProgress.json │ │ ├── officialSolution.json │ │ ├── discussComments.json │ │ ├── singleUser.json │ │ ├── recentAcSubmissionList.json │ │ └── recentSubmissions.json │ ├── setup.ts │ └── handlers.ts ├── unit │ ├── FormatUtils │ │ ├── trendingTopicData.test.ts │ │ └── formatter.test.ts │ └── Controllers │ │ ├── fetchUserDetails.test.ts │ │ ├── handleRequest.test.ts │ │ ├── fetchSingleProblem.test.ts │ │ ├── fetchDataRawFormat.test.ts │ │ ├── fetchDiscussion.test.ts │ │ ├── fetchContests.test.ts │ │ └── fetchUserProfile.test.ts └── integration │ ├── contest-routes.test.ts │ └── discussion-routes.test.ts ├── .husky ├── pre-commit └── check-package-manager ├── .dockerignore ├── vercel.json ├── .gitignore ├── Dockerfile ├── docker-compose.yml ├── .github └── ISSUE_TEMPLATE │ └── custom.md ├── tsconfig.json ├── mcp ├── runInspector.ts ├── modules │ ├── discussionTools.ts │ ├── problemTools.ts │ └── userTools.ts ├── index.ts ├── types.ts ├── serverUtils.ts └── leetCodeService.ts ├── vitest.config.ts ├── LICENSE ├── biome.json ├── package.json └── CONTRIBUTING.md /src/schema/index.ts: -------------------------------------------------------------------------------- 1 | export { default as userContest, UserContest } from './contests'; 2 | -------------------------------------------------------------------------------- /public/demo/demo1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo1.png -------------------------------------------------------------------------------- /public/demo/demo10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo10.png -------------------------------------------------------------------------------- /public/demo/demo11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo11.png -------------------------------------------------------------------------------- /public/demo/demo12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo12.png -------------------------------------------------------------------------------- /public/demo/demo13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo13.png -------------------------------------------------------------------------------- /public/demo/demo14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo14.png -------------------------------------------------------------------------------- /public/demo/demo15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo15.png -------------------------------------------------------------------------------- /public/demo/demo16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo16.png -------------------------------------------------------------------------------- /public/demo/demo17.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo17.png -------------------------------------------------------------------------------- /public/demo/demo18.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo18.png -------------------------------------------------------------------------------- /public/demo/demo19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo19.png -------------------------------------------------------------------------------- /public/demo/demo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo2.png -------------------------------------------------------------------------------- /public/demo/demo20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo20.png -------------------------------------------------------------------------------- /public/demo/demo21.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo21.png -------------------------------------------------------------------------------- /public/demo/demo22.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo22.png -------------------------------------------------------------------------------- /public/demo/demo23.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo23.png -------------------------------------------------------------------------------- /public/demo/demo24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo24.png -------------------------------------------------------------------------------- /public/demo/demo25.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo25.png -------------------------------------------------------------------------------- /public/demo/demo26.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo26.png -------------------------------------------------------------------------------- /public/demo/demo27.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo27.png -------------------------------------------------------------------------------- /public/demo/demo28.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo28.png -------------------------------------------------------------------------------- /public/demo/demo29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo29.png -------------------------------------------------------------------------------- /public/demo/demo3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo3.png -------------------------------------------------------------------------------- /public/demo/demo30.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo30.png -------------------------------------------------------------------------------- /public/demo/demo31.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo31.png -------------------------------------------------------------------------------- /public/demo/demo32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo32.png -------------------------------------------------------------------------------- /public/demo/demo33.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo33.png -------------------------------------------------------------------------------- /public/demo/demo34.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo34.png -------------------------------------------------------------------------------- /public/demo/demo4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo4.png -------------------------------------------------------------------------------- /public/demo/demo5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo5.png -------------------------------------------------------------------------------- /public/demo/demo6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo6.png -------------------------------------------------------------------------------- /public/demo/demo7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo7.png -------------------------------------------------------------------------------- /public/demo/demo8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo8.png -------------------------------------------------------------------------------- /public/demo/demo9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/demo9.png -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | const config = { 2 | port: process.env.PORT || 3000, 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /public/demo/contribute.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/contribute.png -------------------------------------------------------------------------------- /public/demo/mcp-example-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/mcp-example-1.png -------------------------------------------------------------------------------- /public/demo/mcp-example-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/mcp-example-2.png -------------------------------------------------------------------------------- /public/demo/mcp-example-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/mcp-example-3.png -------------------------------------------------------------------------------- /public/demo/mcp-example-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/mcp-example-4.png -------------------------------------------------------------------------------- /public/demo/mcp-example-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfaarghya/alfa-leetcode-api/main/public/demo/mcp-example-5.png -------------------------------------------------------------------------------- /src/schema/common/index.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod'; 2 | 3 | export const badge = z.object({ 4 | name: z.string(), 5 | icon: z.string().optional(), 6 | }); 7 | -------------------------------------------------------------------------------- /tests/msw/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from 'msw/node'; 2 | import { handlers } from './handlers'; 3 | 4 | export const server = setupServer(...handlers); 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Check package manager (must use npm) 4 | .husky/check-package-manager 5 | 6 | # Lint and format staged files 7 | npx lint-staged 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import app from './app'; 2 | import config from './config'; 3 | 4 | app.listen(config.port, () => { 5 | console.log(`Server is running at => http://localhost:${config.port} ⚙️`); 6 | }); 7 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .git 4 | Dockerfile* 5 | .dockerignore 6 | *.log* 7 | .env* 8 | coverage/ 9 | .vitest/ 10 | .vscode/ 11 | .idea/ 12 | .DS_Store 13 | Thumbs.db 14 | *.tmp 15 | *.swp -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "builds": [ 4 | { 5 | "src": "./index.js", 6 | "use": "@vercel/node" 7 | } 8 | ], 9 | "routes": [ 10 | { 11 | "src": "/(.*)", 12 | "dest": "/" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | leetCode-api.txt 4 | 5 | dist 6 | 7 | # Package manager lock files (we use npm only) 8 | yarn.lock 9 | pnpm-lock.yaml 10 | bun.lockb 11 | 12 | .DS_Store 13 | 14 | # Vitest 15 | coverage/ 16 | .vitest/ 17 | 18 | CLAUDE.md 19 | .claude/ 20 | .specify/ 21 | specs/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:24-alpine 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm install 8 | 9 | COPY . . 10 | 11 | # need to remove when we use dev command 12 | # RUN npm run build 13 | 14 | EXPOSE 3000 15 | 16 | # CMD ["node", "dist/index.js"] 17 | CMD ["npm", "run", "dev"] -------------------------------------------------------------------------------- /src/GQLQueries/allContests.ts: -------------------------------------------------------------------------------- 1 | export const allContestQuery = ` 2 | query allContests { 3 | allContests { 4 | title 5 | titleSlug 6 | startTime 7 | duration 8 | originStartTime 9 | isVirtual 10 | containsPremium 11 | } 12 | } 13 | `; 14 | -------------------------------------------------------------------------------- /src/FormatUtils/trendingTopicData.ts: -------------------------------------------------------------------------------- 1 | import type { TrendingDiscussionObject } from '../types'; 2 | 3 | export const formatTrendingCategoryTopicData = ( 4 | data: TrendingDiscussionObject, 5 | ) => { 6 | // Not suggest reformat this data. as the orginial format are orginised in a way that is easy to understand 7 | return data; 8 | }; 9 | -------------------------------------------------------------------------------- /src/GQLQueries/languageStats.ts: -------------------------------------------------------------------------------- 1 | const query = ` 2 | query languageStats($username: String!) { 3 | matchedUser(username: $username) { 4 | languageProblemCount { 5 | languageName 6 | problemsSolved 7 | } 8 | } 9 | } 10 | `; 11 | 12 | export default query; 13 | -------------------------------------------------------------------------------- /src/GQLQueries/recentSubmit.ts: -------------------------------------------------------------------------------- 1 | const query = `#graphql 2 | query getRecentSubmissions($username: String!, $limit: Int) { 3 | recentSubmissionList(username: $username, limit: $limit) { 4 | title 5 | titleSlug 6 | timestamp 7 | statusDisplay 8 | lang 9 | } 10 | }`; 11 | 12 | export default query; 13 | -------------------------------------------------------------------------------- /src/GQLQueries/recentAcSubmit.ts: -------------------------------------------------------------------------------- 1 | const query = `#graphql 2 | query getACSubmissions ($username: String!, $limit: Int) { 3 | recentAcSubmissionList(username: $username, limit: $limit) { 4 | title 5 | titleSlug 6 | timestamp 7 | statusDisplay 8 | lang 9 | } 10 | }`; 11 | 12 | export default query; 13 | -------------------------------------------------------------------------------- /src/FormatUtils/formatter.ts: -------------------------------------------------------------------------------- 1 | import type { ZodType } from 'zod'; 2 | 3 | export const withSchema = 4 | (schema: ZodType, formatter: (data: T) => U) => 5 | (input: T) => { 6 | const result = schema.safeParse(input); 7 | if (result.success) { 8 | return formatter(result.data); 9 | } 10 | throw new Error(result.error.message); 11 | }; 12 | -------------------------------------------------------------------------------- /src/FormatUtils/index.ts: -------------------------------------------------------------------------------- 1 | import { userContest } from '../schema'; 2 | import { withSchema } from './formatter'; 3 | import { formatContestData as _formatContestData } from './userData'; 4 | 5 | export * from './problemData'; 6 | export * from './trendingTopicData'; 7 | export * from './userData'; 8 | export * from './userProfileData'; 9 | 10 | export const formatContestData = withSchema(userContest, _formatContestData); 11 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | app: 5 | build: . 6 | container_name: alfa-leetcode-api-docker 7 | ports: 8 | - '3000:3000' 9 | restart: always 10 | environment: 11 | - WDS_SOCKET_HOST=127.0.0.1 12 | - CHOKIDAR_USEPOLLING=true 13 | - WATCHPACK_POLLING=true 14 | volumes: 15 | - .:/usr/src/app 16 | - /usr/src/app/node_modules 17 | command: npm run dev 18 | -------------------------------------------------------------------------------- /tests/msw/mockData/singleUserContests.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "userContestRanking": null, 4 | "userContestRankingHistory": [ 5 | { 6 | "attended": false, 7 | "rating": 1500, 8 | "ranking": 0, 9 | "trendDirection": "NONE", 10 | "problemsSolved": 0, 11 | "totalProblems": 3, 12 | "finishTimeInSeconds": 0, 13 | "contest": { 14 | "title": "Biweekly Contest 52", 15 | "startTime": 1621089000 16 | } 17 | } 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/GQLQueries/userProfileCalendar.ts: -------------------------------------------------------------------------------- 1 | export const userProfileCalendarQuery = ` 2 | query UserProfileCalendar($username: String!, $year: Int!) { 3 | matchedUser(username: $username) { 4 | userCalendar(year: $year) { 5 | activeYears 6 | streak 7 | totalActiveDays 8 | dccBadges { 9 | timestamp 10 | badge { 11 | name 12 | icon 13 | } 14 | } 15 | submissionCalendar 16 | } 17 | } 18 | } 19 | `; 20 | -------------------------------------------------------------------------------- /src/Controllers/index.ts: -------------------------------------------------------------------------------- 1 | export { fetchAllContests, fetchUpcomingContests } from './fetchContests'; 2 | export { default as fetchDataRawFormat } from './fetchDataRawFormat'; 3 | export { default as fetchTrendingTopics } from './fetchDiscussion'; 4 | export { default as fetchProblems } from './fetchProblems'; 5 | export { default as fetchSingleProblem } from './fetchSingleProblem'; 6 | export { default as fetchUserDetails } from './fetchUserDetails'; 7 | export { default as fetchUserProfile } from './fetchUserProfile'; 8 | export { default as handleRequest } from './handleRequest'; 9 | -------------------------------------------------------------------------------- /tests/msw/mockData/languageStats.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "matchedUser": { 4 | "languageProblemCount": [ 5 | { 6 | "languageName": "JavaScript", 7 | "problemsSolved": 45 8 | }, 9 | { 10 | "languageName": "Python", 11 | "problemsSolved": 32 12 | }, 13 | { 14 | "languageName": "TypeScript", 15 | "problemsSolved": 28 16 | }, 17 | { 18 | "languageName": "Java", 19 | "problemsSolved": 15 20 | } 21 | ] 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/GQLQueries/skillStats.ts: -------------------------------------------------------------------------------- 1 | export const skillStatsQuery = ` 2 | query skillStats($username: String!) { 3 | matchedUser(username: $username) { 4 | tagProblemCounts { 5 | advanced { 6 | tagName 7 | tagSlug 8 | problemsSolved 9 | } 10 | intermediate { 11 | tagName 12 | tagSlug 13 | problemsSolved 14 | } 15 | fundamental { 16 | tagName 17 | tagSlug 18 | problemsSolved 19 | } 20 | } 21 | } 22 | } 23 | `; 24 | -------------------------------------------------------------------------------- /src/GQLQueries/trendingDiscuss.ts: -------------------------------------------------------------------------------- 1 | const query = ` 2 | query trendingDiscuss($first: Int!) { 3 | cachedTrendingCategoryTopics(first: $first) { 4 | id 5 | title 6 | post { 7 | id 8 | creationDate 9 | contentPreview 10 | author { 11 | username 12 | isActive 13 | profile { 14 | userAvatar 15 | } 16 | } 17 | } 18 | } 19 | } 20 | `; 21 | 22 | export default query; 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | #### Describe the bug/issue 11 | Write down a clear and concise description of what the bug/issue is. 12 | 13 | #### Steps to reproduce : 14 | 1. Go to '...' 15 | 2. Click on '....' 16 | 3. Scroll down to '....' 17 | 4. See error 18 | 19 | #### Expected behavior 20 | A clear and concise description of what you expected to happen. 21 | 22 | #### Screenshots/Clips 23 | If applicable, add screenshots/clips 24 | 25 | #### Additional context 26 | Add any other context about the problem here. 27 | -------------------------------------------------------------------------------- /tests/msw/mockData/allContests.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "allContests": [ 4 | { 5 | "title": "Weekly Contest 300", 6 | "titleSlug": "weekly-contest-300", 7 | "startTime": 1659200400, 8 | "duration": 5400, 9 | "originStartTime": 1659200400, 10 | "isVirtual": false, 11 | "containsPremium": false 12 | }, 13 | { 14 | "title": "Biweekly Contest 82", 15 | "titleSlug": "biweekly-contest-82", 16 | "startTime": 1659286800, 17 | "duration": 5400, 18 | "originStartTime": 1659286800, 19 | "isVirtual": false, 20 | "containsPremium": false 21 | } 22 | ] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/GQLQueries/userQuestionProgress.ts: -------------------------------------------------------------------------------- 1 | export const userQuestionProgressQuery = ` 2 | query userProfileUserQuestionProgressV2($username: String!) { 3 | userProfileUserQuestionProgressV2(userSlug: $username) { 4 | numAcceptedQuestions { 5 | count 6 | difficulty 7 | } 8 | numFailedQuestions { 9 | count 10 | difficulty 11 | } 12 | numUntouchedQuestions { 13 | count 14 | difficulty 15 | } 16 | userSessionBeatsPercentage { 17 | difficulty 18 | percentage 19 | } 20 | } 21 | } 22 | `; 23 | -------------------------------------------------------------------------------- /src/GQLQueries/contest.ts: -------------------------------------------------------------------------------- 1 | const query = `#graphql 2 | query getUserContestRanking ($username: String!) { 3 | userContestRanking(username: $username) { 4 | attendedContestsCount 5 | rating 6 | globalRanking 7 | totalParticipants 8 | topPercentage 9 | badge { 10 | name 11 | } 12 | } 13 | userContestRankingHistory(username: $username) { 14 | attended 15 | rating 16 | ranking 17 | trendDirection 18 | problemsSolved 19 | totalProblems 20 | finishTimeInSeconds 21 | contest { 22 | title 23 | startTime 24 | } 25 | } 26 | }`; 27 | 28 | export default query; 29 | -------------------------------------------------------------------------------- /tests/msw/mockData/userCalendar.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "matchedUser": { 4 | "userCalendar": { 5 | "activeYears": [2023, 2024], 6 | "streak": 5, 7 | "totalActiveDays": 120, 8 | "dccBadges": [ 9 | { 10 | "timestamp": 1704153600, 11 | "badge": { 12 | "name": "Jan LeetCoding Challenge", 13 | "icon": "/static/images/badges/dcc-2024-1.png" 14 | } 15 | } 16 | ], 17 | "submissionCalendar": "{\"1704153600\": 24, \"1704240000\": 9, \"1704326400\": 5, \"1704412800\": 3, \"1705017600\": 1, \"1705104000\": 6, \"1699315200\": 46, \"1701302400\": 6, \"1701648000\": 24, \"1701734400\": 11, \"1702339200\": 29, \"1702425600\": 7}" 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/GQLQueries/userContestRanking.ts: -------------------------------------------------------------------------------- 1 | export const userContestRankingInfoQuery = ` 2 | query userContestRankingInfo($username: String!) { 3 | userContestRanking(username: $username) { 4 | attendedContestsCount 5 | rating 6 | globalRanking 7 | totalParticipants 8 | topPercentage 9 | badge { 10 | name 11 | } 12 | } 13 | userContestRankingHistory(username: $username) { 14 | attended 15 | trendDirection 16 | problemsSolved 17 | totalProblems 18 | finishTimeInSeconds 19 | rating 20 | ranking 21 | contest { 22 | title 23 | startTime 24 | } 25 | } 26 | } 27 | `; 28 | -------------------------------------------------------------------------------- /tests/msw/mockData/problems.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "problemsetQuestionList": { 4 | "total": 3078, 5 | "questions": [ 6 | { 7 | "acRate": 52.09288098160903, 8 | "difficulty": "Easy", 9 | "freqBar": null, 10 | "questionFrontendId": "1", 11 | "isFavor": false, 12 | "isPaidOnly": false, 13 | "status": null, 14 | "title": "Two Sum", 15 | "titleSlug": "two-sum", 16 | "topicTags": [ 17 | { "name": "Array", "id": "VG9waWNUYWdOb2RlOjU=", "slug": "array" }, 18 | { 19 | "name": "Hash Table", 20 | "id": "VG9waWNUYWdOb2RlOjY=", 21 | "slug": "hash-table" 22 | } 23 | ], 24 | "hasSolution": true, 25 | "hasVideoSolution": true 26 | } 27 | ] 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/GQLQueries/problemList.ts: -------------------------------------------------------------------------------- 1 | const query = `#graphql 2 | query getProblems($categorySlug: String, $limit: Int, $skip: Int, $filters: QuestionListFilterInput) { 3 | problemsetQuestionList: questionList( 4 | categorySlug: $categorySlug 5 | limit: $limit 6 | skip: $skip 7 | filters: $filters 8 | ) { 9 | total: totalNum 10 | questions: data { 11 | acRate 12 | difficulty 13 | freqBar 14 | questionFrontendId 15 | isFavor 16 | isPaidOnly 17 | status 18 | title 19 | titleSlug 20 | topicTags { 21 | name 22 | id 23 | slug 24 | } 25 | hasSolution 26 | hasVideoSolution 27 | } 28 | } 29 | }`; 30 | 31 | export default query; 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "lib": ["dom", "es6", "es2017", "esnext.asynciterable"], 6 | "skipLibCheck": true, 7 | "sourceMap": true, 8 | "outDir": "./dist", 9 | "moduleResolution": "node", 10 | "removeComments": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "strictFunctionTypes": true, 14 | "noImplicitThis": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "allowSyntheticDefaultImports": true, 20 | "esModuleInterop": true, 21 | "emitDecoratorMetadata": true, 22 | "experimentalDecorators": true, 23 | "resolveJsonModule": true, 24 | "baseUrl": "." 25 | }, 26 | "exclude": ["node_modules", "src/__tests__", "tests"], 27 | // "include": ["./src/**/*.ts", "./mcp/**/*.ts"], 28 | "include": ["./src/**/*.ts"] 29 | } 30 | -------------------------------------------------------------------------------- /mcp/runInspector.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'node:child_process'; 2 | 3 | const VALID_MODES = new Set(['all', 'users', 'problems', 'discussions']); 4 | 5 | const requestedMode = process.argv[2]?.toLowerCase(); 6 | 7 | if (requestedMode && !VALID_MODES.has(requestedMode)) { 8 | console.error(`Invalid inspector mode: ${requestedMode}`); 9 | console.error('Expected one of: all, users, problems, discussions'); 10 | process.exit(1); 11 | } 12 | 13 | const mode = requestedMode ?? 'all'; 14 | 15 | const commandParts = ['npx', '-y', '@modelcontextprotocol/inspector', 'npx', 'ts-node', 'mcp/index.ts']; 16 | 17 | if (mode !== 'all') { 18 | commandParts.push(mode); 19 | } 20 | 21 | const child = spawn(commandParts.join(' '), { 22 | stdio: 'inherit', 23 | shell: true, 24 | }); 25 | 26 | child.on('exit', (code) => { 27 | process.exit(code ?? 0); 28 | }); 29 | 30 | child.on('error', (error) => { 31 | console.error('Failed to launch MCP Inspector', error); 32 | process.exit(1); 33 | }); 34 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: 'node', 7 | pool: 'forks', // Use process forking for better MSW compatibility 8 | setupFiles: ['./tests/msw/setup.ts'], 9 | coverage: { 10 | provider: 'v8', 11 | reporter: ['text', 'json', 'html'], 12 | include: [ 13 | 'src/Controllers/**/*.ts', 14 | 'src/FormatUtils/**/*.ts', 15 | 'src/app.ts', 16 | 'src/leetCode.ts', 17 | ], 18 | exclude: [ 19 | 'node_modules/', 20 | 'dist/', 21 | 'tests/', 22 | 'src/GQLQueries/**/*.ts', 23 | 'src/schema/**/*.ts', 24 | 'src/__tests__/**/*.ts', 25 | '**/*.spec.ts', 26 | '**/*.test.ts', 27 | ], 28 | thresholds: { 29 | lines: 80, 30 | functions: 80, 31 | branches: 75, 32 | statements: 80, 33 | }, 34 | }, 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /tests/msw/setup.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, afterEach, beforeAll } from 'vitest'; 2 | import { server } from './server'; 3 | 4 | // Start MSW server before all tests 5 | beforeAll(() => { 6 | server.listen({ 7 | onUnhandledRequest: (req) => { 8 | // Allow requests to localhost/127.0.0.1 (your Express app) to pass through 9 | // Only warn on unhandled external requests (not errors to avoid test failures) 10 | const url = new URL(req.url); 11 | if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') { 12 | return; // bypass - don't intercept local requests 13 | } 14 | // Warn instead of error to help debugging 15 | console.warn(`[MSW] Unhandled ${req.method} request to ${req.url}`); 16 | }, 17 | }); 18 | }); 19 | 20 | // Reset handlers after each test to ensure test isolation 21 | afterEach(() => { 22 | server.resetHandlers(); 23 | }); 24 | 25 | // Clean up and close server after all tests 26 | afterAll(() => { 27 | server.close(); 28 | }); 29 | -------------------------------------------------------------------------------- /src/Controllers/handleRequest.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from 'express'; 2 | import type { GraphQLParams } from '../types'; 3 | 4 | const handleRequest = async ( 5 | res: Response, 6 | query: string, 7 | params: GraphQLParams, 8 | ) => { 9 | try { 10 | const response = await fetch('https://leetcode.com/graphql', { 11 | method: 'POST', 12 | headers: { 13 | 'Content-Type': 'application/json', 14 | Referer: 'https://leetcode.com', 15 | }, 16 | body: JSON.stringify({ 17 | query: query, 18 | variables: params, 19 | }), 20 | }); 21 | 22 | const result = await response.json(); 23 | if (!response.ok) { 24 | console.error(`HTTP error! status: ${response.status}`); 25 | } 26 | if (result.errors) { 27 | return res.send(result); 28 | } 29 | 30 | return res.json(result.data); 31 | } catch (err) { 32 | console.error('Error: ', err); 33 | return res.send(err); 34 | } 35 | }; 36 | 37 | export default handleRequest; 38 | -------------------------------------------------------------------------------- /src/Controllers/fetchDataRawFormat.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from 'express'; 2 | 3 | const fetchDataRawFormat = async ( 4 | options: { username: string }, 5 | res: Response, 6 | query: string, 7 | ) => { 8 | try { 9 | const response = await fetch('https://leetcode.com/graphql', { 10 | method: 'POST', 11 | headers: { 12 | 'Content-Type': 'application/json', 13 | Referer: 'https://leetcode.com', 14 | }, 15 | body: JSON.stringify({ 16 | query: query, 17 | variables: { 18 | username: options.username, 19 | }, 20 | }), 21 | }); 22 | 23 | const result = await response.json(); 24 | if (!response.ok) { 25 | console.error(`HTTP error! status: ${response.status}`); 26 | } 27 | if (result.errors) { 28 | return res.send(result); 29 | } 30 | 31 | return res.json(result.data); 32 | } catch (err) { 33 | console.error('Error: ', err); 34 | return res.send(err); 35 | } 36 | }; 37 | 38 | export default fetchDataRawFormat; 39 | -------------------------------------------------------------------------------- /.husky/check-package-manager: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Enforce npm as the only package manager for this project 4 | 5 | # List of unwanted lock files 6 | check_files="yarn.lock pnpm-lock.yaml bun.lockb" 7 | 8 | echo "🔍 Checking package manager..." 9 | 10 | # Check for unwanted lock files in staged changes 11 | for file in $check_files; do 12 | if git diff --cached --name-only | grep -qE "^$file$"; then 13 | echo "" 14 | echo "❌ Error: This project uses npm as the package manager." 15 | echo " Found: $file" 16 | echo "" 17 | echo " Please remove this file and use npm instead:" 18 | echo " $ git rm $file" 19 | echo " $ npm install" 20 | echo "" 21 | exit 1 22 | fi 23 | done 24 | 25 | # Ensure package-lock.json exists 26 | if [ ! -f "package-lock.json" ]; then 27 | echo "" 28 | echo "❌ Error: package-lock.json is missing." 29 | echo " Run 'npm install' to generate it." 30 | echo "" 31 | exit 1 32 | fi 33 | 34 | echo "✅ Package manager check passed (npm)" 35 | -------------------------------------------------------------------------------- /src/Controllers/fetchDiscussion.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from 'express'; 2 | import type { TrendingDiscussionObject } from '../types'; 3 | 4 | const fetchDiscussion = async ( 5 | options: { first: number }, 6 | res: Response, 7 | formatData: (data: TrendingDiscussionObject) => object, 8 | query: string, 9 | ) => { 10 | try { 11 | const response = await fetch('https://leetcode.com/graphql', { 12 | method: 'POST', 13 | headers: { 14 | 'Content-Type': 'application/json', 15 | Referer: 'https://leetcode.com', 16 | }, 17 | body: JSON.stringify({ 18 | query: query, 19 | variables: { 20 | first: options.first || 20, //by default get 20 question 21 | }, 22 | }), 23 | }); 24 | 25 | const result = await response.json(); 26 | 27 | if (result.errors) { 28 | return res.send(result); 29 | } 30 | 31 | return res.json(formatData(result.data)); 32 | } catch (err) { 33 | console.error('Error: ', err); 34 | return res.send(err); 35 | } 36 | }; 37 | 38 | export default fetchDiscussion; 39 | -------------------------------------------------------------------------------- /src/schema/contests.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod'; 2 | import { badge } from './common'; 3 | 4 | const userContestRanking = z.object({ 5 | attendedContestsCount: z.number().nonnegative(), 6 | badge: badge.nullable(), 7 | globalRanking: z.number().nonnegative(), 8 | rating: z.number().nonnegative(), 9 | totalParticipants: z.number().nonnegative(), 10 | topPercentage: z.number().nonnegative(), 11 | }); 12 | 13 | const userContestRankingHistory = z.object({ 14 | attended: z.boolean(), 15 | rating: z.number().nonnegative(), 16 | ranking: z.number().nonnegative(), 17 | trendDirection: z.string(), 18 | problemsSolved: z.number().nonnegative(), 19 | totalProblems: z.number().positive(), 20 | finishTimeInSeconds: z.number().nonnegative(), 21 | contest: z.object({ 22 | title: z.string(), 23 | startTime: z.number(), 24 | }), 25 | }); 26 | 27 | const userContest = z.object({ 28 | userContestRanking: userContestRanking.nullable(), 29 | userContestRankingHistory: z.array(userContestRankingHistory), 30 | }); 31 | 32 | export default userContest; 33 | export type UserContest = z.infer; 34 | -------------------------------------------------------------------------------- /src/Controllers/fetchUserProfile.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from 'express'; 2 | import type { GraphQLParams, UserProfileResponse } from '../types'; 3 | 4 | const fetchUserProfile = async ( 5 | res: Response, 6 | query: string, 7 | params: GraphQLParams, 8 | formatFunction: (data: UserProfileResponse) => unknown, 9 | ) => { 10 | try { 11 | const response = await fetch('https://leetcode.com/graphql', { 12 | method: 'POST', 13 | headers: { 14 | 'Content-Type': 'application/json', 15 | Referer: 'https://leetcode.com', 16 | }, 17 | body: JSON.stringify({ 18 | query: query, 19 | variables: params, 20 | }), 21 | }); 22 | 23 | const result = await response.json(); 24 | if (!response.ok) { 25 | console.error(`HTTP error! status: ${response.status}`); 26 | } 27 | if (result.errors) { 28 | return res.send(result); 29 | } 30 | 31 | return res.json(formatFunction(result.data)); 32 | } catch (err) { 33 | console.error('Error: ', err); 34 | return res.send(err); 35 | } 36 | }; 37 | 38 | export default fetchUserProfile; 39 | -------------------------------------------------------------------------------- /src/FormatUtils/userProfileData.ts: -------------------------------------------------------------------------------- 1 | import type { UserProfileResponse } from '../types'; 2 | 3 | export const formatUserProfileData = (data: UserProfileResponse) => { 4 | return { 5 | totalSolved: data.matchedUser.submitStats.acSubmissionNum[0].count, 6 | totalSubmissions: data.matchedUser.submitStats.totalSubmissionNum, 7 | totalQuestions: data.allQuestionsCount[0].count, 8 | easySolved: data.matchedUser.submitStats.acSubmissionNum[1].count, 9 | totalEasy: data.allQuestionsCount[1].count, 10 | mediumSolved: data.matchedUser.submitStats.acSubmissionNum[2].count, 11 | totalMedium: data.allQuestionsCount[2].count, 12 | hardSolved: data.matchedUser.submitStats.acSubmissionNum[3].count, 13 | totalHard: data.allQuestionsCount[3].count, 14 | ranking: data.matchedUser.profile.ranking, 15 | contributionPoint: data.matchedUser.contributions.points, 16 | reputation: data.matchedUser.profile.reputation, 17 | submissionCalendar: JSON.parse(data.matchedUser.submissionCalendar), 18 | recentSubmissions: data.recentSubmissionList, 19 | matchedUserStats: data.matchedUser.submitStats, 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /tests/msw/mockData/index.ts: -------------------------------------------------------------------------------- 1 | export { default as allContests } from './allContests.json'; 2 | export { default as dailyProblem } from './dailyProblem.json'; 3 | export { default as discussComments } from './discussComments.json'; 4 | export { default as discussTopic } from './discussTopic.json'; 5 | export { default as languageStats } from './languageStats.json'; 6 | export { default as officialSolution } from './officialSolution.json'; 7 | export { default as problems } from './problems.json'; 8 | export { default as recentACSubmissions } from './recentAcSubmissionList.json'; 9 | export { default as recentSubmissions } from './recentSubmissions.json'; 10 | export { default as selectProblem } from './selectProblem.json'; 11 | export { default as singleUser } from './singleUser.json'; 12 | export { default as singleUserContests } from './singleUserContests.json'; 13 | export { default as skillStats } from './skillStats.json'; 14 | export { default as trendingDiscuss } from './trendingDiscuss.json'; 15 | export { default as userCalendar } from './userCalendar.json'; 16 | export { default as userQuestionProgress } from './userQuestionProgress.json'; 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Arghya Das 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/GQLQueries/index.ts: -------------------------------------------------------------------------------- 1 | export { allContestQuery } from './allContests'; 2 | export { default as contestQuery } from './contest'; 3 | export { default as dailyProblemQuery } from './dailyProblem'; 4 | export { discussCommentsQuery } from './discussComments'; 5 | export { discussTopicQuery } from './discussTopic'; 6 | export { getUserProfileQuery } from './getUserProfile'; 7 | export { default as languageStatsQuery } from './languageStats'; 8 | export { officialSolutionQuery } from './officialSolution'; 9 | export { default as problemListQuery } from './problemList'; 10 | export { default as AcSubmissionQuery } from './recentAcSubmit'; 11 | export { default as submissionQuery } from './recentSubmit'; 12 | export { default as selectProblemQuery } from './selectProblem'; 13 | export { skillStatsQuery } from './skillStats'; 14 | export { default as trendingDiscussQuery } from './trendingDiscuss'; 15 | export { userContestRankingInfoQuery } from './userContestRanking'; 16 | export { default as userProfileQuery } from './userProfile'; 17 | export { userProfileCalendarQuery } from './userProfileCalendar'; 18 | export { userQuestionProgressQuery } from './userQuestionProgress'; 19 | -------------------------------------------------------------------------------- /tests/msw/mockData/skillStats.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "matchedUser": { 4 | "tagProblemCounts": { 5 | "advanced": [ 6 | { 7 | "tagName": "Dynamic Programming", 8 | "tagSlug": "dynamic-programming", 9 | "problemsSolved": 12 10 | }, 11 | { 12 | "tagName": "Tree", 13 | "tagSlug": "tree", 14 | "problemsSolved": 8 15 | } 16 | ], 17 | "intermediate": [ 18 | { 19 | "tagName": "Array", 20 | "tagSlug": "array", 21 | "problemsSolved": 45 22 | }, 23 | { 24 | "tagName": "Hash Table", 25 | "tagSlug": "hash-table", 26 | "problemsSolved": 30 27 | } 28 | ], 29 | "fundamental": [ 30 | { 31 | "tagName": "String", 32 | "tagSlug": "string", 33 | "problemsSolved": 55 34 | }, 35 | { 36 | "tagName": "Math", 37 | "tagSlug": "math", 38 | "problemsSolved": 40 39 | } 40 | ] 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Controllers/fetchSingleProblem.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from 'express'; 2 | import type { DailyProblemData, SelectProblemData } from '../types'; 3 | 4 | const fetchSingleProblem = async ( 5 | res: Response, 6 | query: string, 7 | titleSlug: string | null, 8 | formatData?: (data: DailyProblemData & SelectProblemData) => void, 9 | ) => { 10 | try { 11 | const response = await fetch('https://leetcode.com/graphql', { 12 | method: 'POST', 13 | headers: { 14 | 'Content-Type': 'application/json', 15 | Referer: 'https://leetcode.com', 16 | }, 17 | body: JSON.stringify({ 18 | query: query, 19 | variables: { 20 | titleSlug, //search question using titleSlug 21 | }, 22 | }), 23 | }); 24 | 25 | const result = await response.json(); 26 | 27 | if (result.errors) { 28 | return res.send(result); 29 | } 30 | 31 | if (formatData == null) { 32 | return res.json(result.data); 33 | } 34 | 35 | return res.json(formatData(result.data)); 36 | } catch (err) { 37 | console.error('Error: ', err); 38 | return res.send(err); 39 | } 40 | }; 41 | 42 | export default fetchSingleProblem; 43 | -------------------------------------------------------------------------------- /src/GQLQueries/officialSolution.ts: -------------------------------------------------------------------------------- 1 | export const officialSolutionQuery = ` 2 | query OfficialSolution($titleSlug: String!) { 3 | question(titleSlug: $titleSlug) { 4 | solution { 5 | id 6 | title 7 | content 8 | contentTypeId 9 | paidOnly 10 | hasVideoSolution 11 | paidOnlyVideo 12 | canSeeDetail 13 | rating { 14 | count 15 | average 16 | userRating { 17 | score 18 | } 19 | } 20 | topic { 21 | id 22 | commentCount 23 | topLevelCommentCount 24 | viewCount 25 | subscribed 26 | solutionTags { 27 | name 28 | slug 29 | } 30 | post { 31 | id 32 | status 33 | creationDate 34 | author { 35 | username 36 | isActive 37 | profile { 38 | userAvatar 39 | reputation 40 | } 41 | } 42 | } 43 | } 44 | } 45 | } 46 | } 47 | `; 48 | -------------------------------------------------------------------------------- /src/Controllers/fetchUserDetails.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from 'express'; 2 | 3 | const fetchUserDetails = async ( 4 | options: { username: string; limit: number; year: number }, 5 | res: Response, 6 | query: string, 7 | formatData?: (data: T) => U, 8 | ) => { 9 | try { 10 | const response = await fetch('https://leetcode.com/graphql', { 11 | method: 'POST', 12 | headers: { 13 | 'Content-Type': 'application/json', 14 | Referer: 'https://leetcode.com', 15 | }, 16 | body: JSON.stringify({ 17 | query: query, 18 | variables: { 19 | username: options.username, //username required 20 | limit: options.limit, //only for submission 21 | year: options.year, 22 | }, 23 | }), 24 | }); 25 | 26 | const result = await response.json(); 27 | 28 | if (result.errors) { 29 | return res.send(result); 30 | } 31 | 32 | if (formatData == null) { 33 | return res.json(result.data); 34 | } 35 | 36 | return res.json(formatData(result.data)); 37 | } catch (err) { 38 | console.error('Error: ', err); 39 | return res.send(err.message); 40 | } 41 | }; 42 | 43 | export default fetchUserDetails; 44 | -------------------------------------------------------------------------------- /tests/msw/mockData/trendingDiscuss.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "cachedTrendingCategoryTopics": [ 4 | { 5 | "id": "12345", 6 | "title": "How to solve Two Sum efficiently?", 7 | "post": { 8 | "id": "67890", 9 | "creationDate": 1659200400, 10 | "contentPreview": "I found an interesting way to solve the Two Sum problem using hash maps...", 11 | "author": { 12 | "username": "codingmaster", 13 | "isActive": true, 14 | "profile": { 15 | "userAvatar": "https://assets.leetcode.com/users/default_avatar.jpg" 16 | } 17 | } 18 | } 19 | }, 20 | { 21 | "id": "12346", 22 | "title": "Dynamic Programming Tips and Tricks", 23 | "post": { 24 | "id": "67891", 25 | "creationDate": 1659200500, 26 | "contentPreview": "Here are some useful patterns for DP problems...", 27 | "author": { 28 | "username": "algoexpert", 29 | "isActive": true, 30 | "profile": { 31 | "userAvatar": "https://assets.leetcode.com/users/default_avatar.jpg" 32 | } 33 | } 34 | } 35 | } 36 | ] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/msw/mockData/discussTopic.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "topic": { 4 | "id": 12345, 5 | "viewCount": 5432, 6 | "topLevelCommentCount": 45, 7 | "subscribed": false, 8 | "title": "How to solve Two Sum efficiently?", 9 | "pinned": false, 10 | "tags": ["Array", "Hash Table"], 11 | "hideFromTrending": false, 12 | "post": { 13 | "id": "67890", 14 | "voteCount": 120, 15 | "voteStatus": 0, 16 | "content": "# Two Sum Solution\n\nI found an interesting way to solve the Two Sum problem using hash maps...", 17 | "updationDate": 1659200500, 18 | "creationDate": 1659200400, 19 | "status": "PUBLISHED", 20 | "isHidden": false, 21 | "coinRewards": [], 22 | "author": { 23 | "isDiscussAdmin": false, 24 | "isDiscussStaff": false, 25 | "username": "codingmaster", 26 | "nameColor": null, 27 | "activeBadge": null, 28 | "profile": { 29 | "userAvatar": "https://assets.leetcode.com/users/default_avatar.jpg", 30 | "reputation": 1250 31 | }, 32 | "isActive": true 33 | }, 34 | "authorIsModerator": false, 35 | "isOwnPost": false 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/GQLQueries/getUserProfile.ts: -------------------------------------------------------------------------------- 1 | export const getUserProfileQuery = ` 2 | query getUserProfile($username: String!) { 3 | allQuestionsCount { 4 | difficulty 5 | count 6 | } 7 | matchedUser(username: $username) { 8 | contributions { 9 | points 10 | } 11 | profile { 12 | reputation 13 | ranking 14 | } 15 | submissionCalendar 16 | submitStats { 17 | acSubmissionNum { 18 | difficulty 19 | count 20 | submissions 21 | } 22 | totalSubmissionNum { 23 | difficulty 24 | count 25 | submissions 26 | } 27 | } 28 | } 29 | recentSubmissionList(username: $username) { 30 | title 31 | titleSlug 32 | timestamp 33 | statusDisplay 34 | lang 35 | __typename 36 | } 37 | matchedUserStats: matchedUser(username: $username) { 38 | submitStats: submitStatsGlobal { 39 | acSubmissionNum { 40 | difficulty 41 | count 42 | submissions 43 | __typename 44 | } 45 | totalSubmissionNum { 46 | difficulty 47 | count 48 | submissions 49 | __typename 50 | } 51 | __typename 52 | } 53 | } 54 | } 55 | `; 56 | -------------------------------------------------------------------------------- /src/GQLQueries/discussTopic.ts: -------------------------------------------------------------------------------- 1 | export const discussTopicQuery = ` 2 | query DiscussTopic($topicId: Int!) { 3 | topic(id: $topicId) { 4 | id 5 | viewCount 6 | topLevelCommentCount 7 | subscribed 8 | title 9 | pinned 10 | tags 11 | hideFromTrending 12 | post { 13 | ...DiscussPost 14 | } 15 | } 16 | } 17 | 18 | fragment DiscussPost on PostNode { 19 | id 20 | voteCount 21 | voteStatus 22 | content 23 | updationDate 24 | creationDate 25 | status 26 | isHidden 27 | coinRewards { 28 | ...CoinReward 29 | } 30 | author { 31 | isDiscussAdmin 32 | isDiscussStaff 33 | username 34 | nameColor 35 | activeBadge { 36 | displayName 37 | icon 38 | } 39 | profile { 40 | userAvatar 41 | reputation 42 | } 43 | isActive 44 | } 45 | authorIsModerator 46 | isOwnPost 47 | } 48 | 49 | fragment CoinReward on ScoreNode { 50 | id 51 | score 52 | description 53 | date 54 | } 55 | `; 56 | -------------------------------------------------------------------------------- /tests/msw/mockData/userQuestionProgress.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "userProfileUserQuestionProgressV2": { 4 | "numAcceptedQuestions": [ 5 | { 6 | "difficulty": "Easy", 7 | "count": 122 8 | }, 9 | { 10 | "difficulty": "Medium", 11 | "count": 45 12 | }, 13 | { 14 | "difficulty": "Hard", 15 | "count": 8 16 | } 17 | ], 18 | "numFailedQuestions": [ 19 | { 20 | "difficulty": "Easy", 21 | "count": 10 22 | }, 23 | { 24 | "difficulty": "Medium", 25 | "count": 25 26 | }, 27 | { 28 | "difficulty": "Hard", 29 | "count": 15 30 | } 31 | ], 32 | "numUntouchedQuestions": [ 33 | { 34 | "difficulty": "Easy", 35 | "count": 468 36 | }, 37 | { 38 | "difficulty": "Medium", 39 | "count": 1230 40 | }, 41 | { 42 | "difficulty": "Hard", 43 | "count": 577 44 | } 45 | ], 46 | "userSessionBeatsPercentage": [ 47 | { 48 | "difficulty": "Easy", 49 | "percentage": 75.5 50 | }, 51 | { 52 | "difficulty": "Medium", 53 | "percentage": 65.2 54 | }, 55 | { 56 | "difficulty": "Hard", 57 | "percentage": 55.8 58 | } 59 | ] 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/GQLQueries/discussComments.ts: -------------------------------------------------------------------------------- 1 | export const discussCommentsQuery = ` 2 | query discussComments($topicId: Int!, $orderBy: String = "newest_to_oldest", $pageNo: Int = 1, $numPerPage: Int = 10) { 3 | topicComments(topicId: $topicId, orderBy: $orderBy, pageNo: $pageNo, numPerPage: $numPerPage) { 4 | data { 5 | id 6 | pinned 7 | pinnedBy { 8 | username 9 | } 10 | post { 11 | ...DiscussPost 12 | } 13 | numChildren 14 | } 15 | } 16 | } 17 | 18 | fragment DiscussPost on PostNode { 19 | id 20 | voteCount 21 | voteStatus 22 | content 23 | updationDate 24 | creationDate 25 | status 26 | isHidden 27 | coinRewards { 28 | ...CoinReward 29 | } 30 | author { 31 | isDiscussAdmin 32 | isDiscussStaff 33 | username 34 | nameColor 35 | activeBadge { 36 | displayName 37 | icon 38 | } 39 | profile { 40 | userAvatar 41 | reputation 42 | } 43 | isActive 44 | } 45 | authorIsModerator 46 | isOwnPost 47 | } 48 | 49 | fragment CoinReward on ScoreNode { 50 | id 51 | score 52 | description 53 | date 54 | } 55 | `; 56 | -------------------------------------------------------------------------------- /tests/msw/mockData/officialSolution.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "question": { 4 | "solution": { 5 | "id": "12345", 6 | "title": "Two Sum - Official Solution", 7 | "content": "## Approach 1: Brute Force\n\nThe brute force approach is simple...", 8 | "contentTypeId": "1", 9 | "paidOnly": false, 10 | "hasVideoSolution": true, 11 | "paidOnlyVideo": false, 12 | "canSeeDetail": true, 13 | "rating": { 14 | "count": 1245, 15 | "average": 4.5, 16 | "userRating": { 17 | "score": 5 18 | } 19 | }, 20 | "topic": { 21 | "id": "54321", 22 | "commentCount": 156, 23 | "topLevelCommentCount": 45, 24 | "viewCount": 125000, 25 | "subscribed": false, 26 | "solutionTags": [ 27 | { 28 | "name": "Array", 29 | "slug": "array" 30 | }, 31 | { 32 | "name": "Hash Table", 33 | "slug": "hash-table" 34 | } 35 | ], 36 | "post": { 37 | "id": "98765", 38 | "status": "PUBLISHED", 39 | "creationDate": 1577836800, 40 | "author": { 41 | "username": "LeetCode", 42 | "isActive": true, 43 | "profile": { 44 | "userAvatar": "https://assets.leetcode.com/users/default_avatar.jpg", 45 | "reputation": 10000 46 | } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/GQLQueries/selectProblem.ts: -------------------------------------------------------------------------------- 1 | const query = `#graphql 2 | query selectProblem($titleSlug: String!) { 3 | question(titleSlug: $titleSlug) { 4 | questionId 5 | questionFrontendId 6 | boundTopicId 7 | title 8 | titleSlug 9 | content 10 | translatedTitle 11 | translatedContent 12 | isPaidOnly 13 | difficulty 14 | likes 15 | dislikes 16 | isLiked 17 | similarQuestions 18 | exampleTestcases 19 | contributors { 20 | username 21 | profileUrl 22 | avatarUrl 23 | } 24 | topicTags { 25 | name 26 | slug 27 | translatedName 28 | } 29 | companyTagStats 30 | codeSnippets { 31 | lang 32 | langSlug 33 | code 34 | } 35 | stats 36 | hints 37 | solution { 38 | id 39 | canSeeDetail 40 | paidOnly 41 | hasVideoSolution 42 | paidOnlyVideo 43 | } 44 | status 45 | sampleTestCase 46 | metaData 47 | judgerAvailable 48 | judgeType 49 | mysqlSchemas 50 | enableRunCode 51 | enableTestMode 52 | enableDebugger 53 | envInfo 54 | libraryUrl 55 | adminUrl 56 | challengeQuestion { 57 | id 58 | date 59 | incompleteChallengeCount 60 | streakCount 61 | type 62 | } 63 | note 64 | } 65 | }`; 66 | 67 | export default query; 68 | -------------------------------------------------------------------------------- /src/GQLQueries/userProfile.ts: -------------------------------------------------------------------------------- 1 | const query = `#graphql 2 | query getUserProfile($username: String!) { 3 | allQuestionsCount { 4 | difficulty 5 | count 6 | } 7 | matchedUser(username: $username) { 8 | username 9 | githubUrl 10 | twitterUrl 11 | linkedinUrl 12 | contributions { 13 | points 14 | questionCount 15 | testcaseCount 16 | } 17 | profile { 18 | realName 19 | userAvatar 20 | birthday 21 | ranking 22 | reputation 23 | websites 24 | countryName 25 | company 26 | school 27 | skillTags 28 | aboutMe 29 | starRating 30 | } 31 | badges { 32 | id 33 | displayName 34 | icon 35 | creationDate 36 | } 37 | upcomingBadges { 38 | name 39 | icon 40 | } 41 | activeBadge { 42 | id 43 | displayName 44 | icon 45 | creationDate 46 | } 47 | submitStats { 48 | totalSubmissionNum { 49 | difficulty 50 | count 51 | submissions 52 | } 53 | acSubmissionNum { 54 | difficulty 55 | count 56 | submissions 57 | } 58 | } 59 | submissionCalendar 60 | } 61 | recentSubmissionList(username: $username, limit: 20) { 62 | title 63 | titleSlug 64 | timestamp 65 | statusDisplay 66 | lang 67 | } 68 | }`; 69 | 70 | export default query; 71 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", 3 | "files": { 4 | "includes": ["src/**/*.ts", "tests/**/*.ts", "*.ts", "*.js"], 5 | "ignoreUnknown": true 6 | }, 7 | "vcs": { 8 | "enabled": true, 9 | "clientKind": "git", 10 | "useIgnoreFile": true, 11 | "defaultBranch": "main" 12 | }, 13 | "formatter": { 14 | "enabled": true, 15 | "indentStyle": "space", 16 | "indentWidth": 2, 17 | "lineWidth": 80, 18 | "lineEnding": "lf" 19 | }, 20 | "javascript": { 21 | "formatter": { 22 | "quoteStyle": "single", 23 | "trailingCommas": "all", 24 | "semicolons": "always", 25 | "arrowParentheses": "always", 26 | "bracketSpacing": true, 27 | "quoteProperties": "asNeeded" 28 | } 29 | }, 30 | "linter": { 31 | "enabled": true, 32 | "rules": { 33 | "recommended": true, 34 | "suspicious": { 35 | "noExplicitAny": "warn", 36 | "noDoubleEquals": "error", 37 | "noDebugger": "error", 38 | "noConsole": "off" 39 | }, 40 | "complexity": { 41 | "noForEach": "off", 42 | "useLiteralKeys": "warn", 43 | "noExcessiveCognitiveComplexity": { 44 | "level": "warn", 45 | "options": { 46 | "maxAllowedComplexity": 15 47 | } 48 | } 49 | }, 50 | "style": { 51 | "noParameterAssign": "warn", 52 | "noNonNullAssertion": "warn", 53 | "useConst": "error", 54 | "useTemplate": "warn" 55 | }, 56 | "correctness": { 57 | "noUnusedVariables": "error", 58 | "noUnusedImports": "error", 59 | "useExhaustiveDependencies": "warn" 60 | }, 61 | "performance": { 62 | "noDelete": "warn" 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/GQLQueries/dailyProblem.ts: -------------------------------------------------------------------------------- 1 | const query = `#graphql 2 | query getDailyProblem { 3 | activeDailyCodingChallengeQuestion { 4 | date 5 | link 6 | question { 7 | questionId 8 | questionFrontendId 9 | boundTopicId 10 | title 11 | titleSlug 12 | content 13 | translatedTitle 14 | translatedContent 15 | isPaidOnly 16 | difficulty 17 | likes 18 | dislikes 19 | isLiked 20 | similarQuestions 21 | exampleTestcases 22 | contributors { 23 | username 24 | profileUrl 25 | avatarUrl 26 | } 27 | topicTags { 28 | name 29 | slug 30 | translatedName 31 | } 32 | companyTagStats 33 | codeSnippets { 34 | lang 35 | langSlug 36 | code 37 | } 38 | stats 39 | hints 40 | solution { 41 | id 42 | canSeeDetail 43 | paidOnly 44 | hasVideoSolution 45 | paidOnlyVideo 46 | } 47 | status 48 | sampleTestCase 49 | metaData 50 | judgerAvailable 51 | judgeType 52 | mysqlSchemas 53 | enableRunCode 54 | enableTestMode 55 | enableDebugger 56 | envInfo 57 | libraryUrl 58 | adminUrl 59 | challengeQuestion { 60 | id 61 | date 62 | incompleteChallengeCount 63 | streakCount 64 | type 65 | } 66 | note 67 | } 68 | } 69 | }`; 70 | 71 | export default query; 72 | -------------------------------------------------------------------------------- /mcp/modules/discussionTools.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 2 | import { z } from 'zod'; 3 | import { getDiscussComments, getDiscussTopic, getTrendingTopics } from '../leetCodeService'; 4 | import { runTool } from '../serverUtils'; 5 | import { ToolModule } from '../types'; 6 | 7 | export class DiscussionToolsModule implements ToolModule { 8 | // Registers discussion-related tools with the MCP server. 9 | register(server: McpServer): void { 10 | server.registerTool( 11 | 'leetcode_discuss_trending', 12 | { 13 | title: 'Trending Discussions', 14 | description: 'Lists trending discussion topics', 15 | inputSchema: { 16 | first: z.number().int().positive().max(50).optional(), 17 | }, 18 | }, 19 | async ({ first }) => runTool(() => getTrendingTopics(first ?? 20)), 20 | ); 21 | 22 | server.registerTool( 23 | 'leetcode_discuss_topic', 24 | { 25 | title: 'Discussion Topic', 26 | description: 'Retrieves full topic details by topic id', 27 | inputSchema: { 28 | topicId: z.number().int().positive(), 29 | }, 30 | }, 31 | async ({ topicId }) => runTool(() => getDiscussTopic(topicId)), 32 | ); 33 | 34 | server.registerTool( 35 | 'leetcode_discuss_comments', 36 | { 37 | title: 'Discussion Comments', 38 | description: 'Retrieves comments for a discussion topic', 39 | inputSchema: { 40 | topicId: z.number().int().positive(), 41 | orderBy: z.string().optional(), 42 | pageNo: z.number().int().positive().optional(), 43 | numPerPage: z.number().int().positive().max(50).optional(), 44 | }, 45 | }, 46 | async ({ topicId, orderBy, pageNo, numPerPage }) => 47 | runTool(() => getDiscussComments({ topicId, orderBy, pageNo, numPerPage })), 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Controllers/fetchProblems.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from 'express'; 2 | import type { ProblemSetQuestionListData } from '../types'; 3 | 4 | const fetchProblems = async ( 5 | options: { 6 | limit?: number; 7 | skip?: number; 8 | tags?: string; 9 | difficulty?: string; 10 | }, // Mark parameters as optional 11 | res: Response, 12 | formatData: (data: ProblemSetQuestionListData) => object, 13 | query: string, 14 | ) => { 15 | try { 16 | // Set default limit to 1 if only skip is provided 17 | const limit = 18 | options.skip !== undefined && options.limit === undefined 19 | ? 1 20 | : options.limit || 20; 21 | const skip = options.skip || 0; // Default to 0 if not provided 22 | const tags = options.tags ? options.tags.split(' ') : []; // Split tags or default to empty array 23 | const difficulty = options.difficulty || undefined; // difficulty has to be 'EASY', 'MEDIUM' or 'HARD' 24 | 25 | const response = await fetch('https://leetcode.com/graphql', { 26 | method: 'POST', 27 | headers: { 28 | 'Content-Type': 'application/json', 29 | Referer: 'https://leetcode.com', 30 | }, 31 | body: JSON.stringify({ 32 | query: query, 33 | variables: { 34 | categorySlug: '', 35 | skip, 36 | limit, 37 | filters: { 38 | tags, 39 | difficulty, 40 | }, 41 | }, 42 | }), 43 | }); 44 | 45 | const result = await response.json(); 46 | 47 | if (result.errors) { 48 | return res.status(400).json(result.errors); // Return errors with a 400 status code 49 | } 50 | return res.json(formatData(result.data)); 51 | } catch (err) { 52 | console.error('Error: ', err); 53 | return res.status(500).json({ error: 'Internal server error' }); // Return a 500 status code for server errors 54 | } 55 | }; 56 | 57 | export default fetchProblems; 58 | -------------------------------------------------------------------------------- /tests/msw/handlers.ts: -------------------------------------------------------------------------------- 1 | import * as msw from 'msw'; 2 | import { 3 | allContests, 4 | dailyProblem, 5 | discussComments, 6 | discussTopic, 7 | languageStats, 8 | officialSolution, 9 | problems, 10 | recentACSubmissions, 11 | recentSubmissions, 12 | selectProblem, 13 | singleUser, 14 | singleUserContests, 15 | skillStats, 16 | trendingDiscuss, 17 | userCalendar, 18 | userQuestionProgress, 19 | } from './mockData'; 20 | 21 | const queryResponseMap = [ 22 | { identifier: 'getUserProfile', response: singleUser }, 23 | { identifier: 'getUserContestRanking', response: singleUserContests }, 24 | { identifier: 'getRecentSubmissions', response: recentSubmissions }, 25 | { identifier: 'getACSubmissions', response: recentACSubmissions }, 26 | { identifier: 'getDailyProblem', response: dailyProblem }, 27 | { identifier: 'getProblems', response: problems }, 28 | { identifier: 'selectProblem', response: selectProblem }, 29 | { identifier: 'UserProfileCalendar', response: userCalendar }, 30 | { identifier: 'languageStats', response: languageStats }, 31 | { identifier: 'skillStats', response: skillStats }, 32 | { 33 | identifier: 'userProfileUserQuestionProgressV2', 34 | response: userQuestionProgress, 35 | }, 36 | { identifier: 'OfficialSolution', response: officialSolution }, 37 | { identifier: 'allContests', response: allContests }, 38 | { identifier: 'trendingDiscuss', response: trendingDiscuss }, 39 | { identifier: 'DiscussTopic', response: discussTopic }, 40 | { identifier: 'discussComments', response: discussComments }, 41 | ]; 42 | 43 | export const handlers = [ 44 | msw.http.post('https://leetcode.com/graphql', async (ctx) => { 45 | const test = await ctx.request.json(); 46 | const typed = test as { query: string }; 47 | 48 | const matchedQuery = queryResponseMap.find((mapping) => 49 | typed.query.includes(mapping.identifier), 50 | ); 51 | 52 | return msw.HttpResponse.json(matchedQuery?.response ?? {}); 53 | }), 54 | ]; 55 | -------------------------------------------------------------------------------- /src/Controllers/fetchContests.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from 'express'; 2 | import type { Contest } from '../types'; 3 | 4 | export const fetchAllContests = async (res: Response, query: string) => { 5 | try { 6 | const response = await fetch('https://leetcode.com/graphql', { 7 | method: 'POST', 8 | headers: { 9 | 'Content-Type': 'application/json', 10 | Referer: 'https://leetcode.com', 11 | }, 12 | body: JSON.stringify({ 13 | query: query, 14 | }), 15 | }); 16 | 17 | const result = await response.json(); 18 | if (!response.ok) { 19 | console.error(`HTTP error! status: ${response.status}`); 20 | } 21 | if (result.errors) { 22 | return res.send(result); 23 | } 24 | 25 | return res.json(result.data); 26 | } catch (err) { 27 | console.error('Error: ', err); 28 | return res.send(err); 29 | } 30 | }; 31 | 32 | export const fetchUpcomingContests = async (res: Response, query: string) => { 33 | try { 34 | const response = await fetch('https://leetcode.com/graphql', { 35 | method: 'POST', 36 | headers: { 37 | 'Content-Type': 'application/json', 38 | Referer: 'https://leetcode.com', 39 | }, 40 | body: JSON.stringify({ 41 | query: query, 42 | }), 43 | }); 44 | 45 | const result = await response.json(); 46 | if (!response.ok) { 47 | console.error(`HTTP error! status: ${response.status}`); 48 | } 49 | if (result.errors) { 50 | return res.send(result); 51 | } 52 | 53 | const now = Math.floor(Date.now() / 1000); 54 | const allContests = result.data.allContests || []; 55 | 56 | const upcomingContests = allContests.filter( 57 | (contest: Contest) => contest.startTime > now, 58 | ); 59 | 60 | return res.json({ 61 | count: upcomingContests.length, 62 | contests: upcomingContests, 63 | }); 64 | } catch (err) { 65 | console.error('Error: ', err); 66 | return res.send(err); 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /mcp/index.ts: -------------------------------------------------------------------------------- 1 | import { DiscussionToolsModule } from './modules/discussionTools'; 2 | import { ProblemToolsModule } from './modules/problemTools'; 3 | import { UserToolsModule } from './modules/userTools'; 4 | import { SERVER_VERSION, startServer } from './serverUtils'; 5 | import { Mode, ModuleConfig, ToolModule } from './types'; 6 | 7 | const modulesByKey: Record, ToolModule> = { 8 | users: new UserToolsModule(), 9 | problems: new ProblemToolsModule(), 10 | discussions: new DiscussionToolsModule(), 11 | }; 12 | 13 | // Normalizes the input mode string to a valid Mode type. 14 | function normalizeMode(input: string | undefined): Mode { 15 | if (!input) { 16 | return 'all'; 17 | } 18 | 19 | const normalized = input.trim().toLowerCase(); 20 | 21 | if (normalized === 'all' || normalized === 'suite' || normalized === 'default') { 22 | return 'all'; 23 | } 24 | 25 | if (normalized in modulesByKey) { 26 | return normalized as Exclude; 27 | } 28 | 29 | console.error(`Unknown MCP server mode: ${input}`); 30 | console.error('Expected one of: all, users, problems, discussions'); 31 | process.exit(1); 32 | } 33 | 34 | // Resolves the modules and server name based on the selected mode. 35 | function resolveModules(mode: Mode): ModuleConfig { 36 | if (mode === 'all') { 37 | return { 38 | modules: Object.values(modulesByKey), 39 | name: 'alfa-leetcode-suite', 40 | }; 41 | } 42 | 43 | return { 44 | modules: [modulesByKey[mode]], 45 | name: `alfa-leetcode-${mode}`, 46 | }; 47 | } 48 | 49 | // Main entry point for the MCP server. Parses command line arguments, sets up modules, and starts the server. 50 | async function main() { 51 | const modeInput = process.env.MCP_SERVER_MODE ?? process.argv[2]; 52 | const mode = normalizeMode(modeInput); 53 | const { modules, name } = resolveModules(mode); 54 | 55 | await startServer({ name, version: SERVER_VERSION }, modules); 56 | } 57 | 58 | main().catch((error) => { 59 | console.error(error); 60 | process.exit(1); 61 | }); 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alfa-leetcode-api", 3 | "version": "2.0.3", 4 | "description": "It's a leetcode custom api. This API provides endpoints to retrieve details about a user's profile, badges, solved questions, contest details, contest history, submissions, calendar and and also daily questions, selected problem, list of problems.", 5 | "main": "dist/index.js", 6 | "packageManager": "npm@10.9.2", 7 | "scripts": { 8 | "prepare": "husky", 9 | "prestart": "npm run build", 10 | "build": "tsc", 11 | "dev": "nodemon --exec ts-node src/index.ts", 12 | "start": "node dist/index.js", 13 | "test": "vitest run", 14 | "test:watch": "vitest", 15 | "test:coverage": "vitest run --coverage", 16 | "lint": "biome check . ", 17 | "lint:fix": "biome check --write .", 18 | "format": "biome format .", 19 | "format:fix": "biome format --write .", 20 | "check": "biome check . && biome format .", 21 | "check:fix": "biome check --write . && biome format --write .", 22 | "mcp": "ts-node mcp/index.ts", 23 | "mcp:dist": "npm run build && node dist/mcp/index.js", 24 | "mcp:inspect": "ts-node mcp/runInspector.ts" 25 | }, 26 | "keywords": [], 27 | "author": "alfaarghya", 28 | "license": "ISC", 29 | "dependencies": { 30 | "@modelcontextprotocol/sdk": "^1.20.2", 31 | "apicache": "^1.6.3", 32 | "axios": "^1.7.2", 33 | "cors": "^2.8.5", 34 | "express": "^4.18.2", 35 | "express-rate-limit": "^7.1.5", 36 | "zod": "^3.25.76" 37 | }, 38 | "devDependencies": { 39 | "@biomejs/biome": "^2.3.8", 40 | "@types/apicache": "^1.6.6", 41 | "@types/cors": "^2.8.17", 42 | "@types/express": "^4.17.21", 43 | "@types/node": "^20.19.23", 44 | "@types/supertest": "^6.0.2", 45 | "husky": "^9.1.7", 46 | "lint-staged": "^16.2.7", 47 | "msw": "^2.2.3", 48 | "nodemon": "^3.1.0", 49 | "supertest": "^6.3.4", 50 | "ts-node": "^10.9.2", 51 | "typescript": "^5.9.3", 52 | "vite": "^6.0.5", 53 | "vitest": "^4.0.15" 54 | }, 55 | "lint-staged": { 56 | "*.{ts,js}": [ 57 | "biome check --write --no-errors-on-unmatched", 58 | "biome format --write --no-errors-on-unmatched" 59 | ] 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /mcp/types.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 2 | 3 | /** 4 | * Represents the mode for running the MCP server. 5 | * - 'all': Run all modules 6 | * - 'users': Run only user-related tools 7 | * - 'problems': Run only problem-related tools 8 | * - 'discussions': Run only discussion-related tools 9 | */ 10 | export type Mode = 'all' | 'users' | 'problems' | 'discussions'; 11 | 12 | /** 13 | * Configuration for the modules to be loaded in the server. 14 | */ 15 | export type ModuleConfig = { 16 | modules: ToolModule[]; 17 | name: string; 18 | }; 19 | 20 | /** 21 | * Arguments for submission-related functions. 22 | */ 23 | export type SubmissionArgs = { username: string; limit?: number }; 24 | 25 | /** 26 | * Arguments for calendar-related functions. 27 | */ 28 | export type CalendarArgs = { username: string; year: number }; 29 | 30 | /** 31 | * Arguments for problem list functions. 32 | */ 33 | export type ProblemArgs = { limit?: number; skip?: number; tags?: string; difficulty?: string }; 34 | 35 | /** 36 | * Arguments for discussion comments functions. 37 | */ 38 | export type DiscussCommentsArgs = { topicId: number; orderBy?: string; pageNo?: number; numPerPage?: number }; 39 | 40 | /** 41 | * Type for GraphQL variables. 42 | */ 43 | export type Variables = Record; 44 | 45 | /** 46 | * Type for GraphQL parameters. 47 | */ 48 | export type GraphQLParams = Record; 49 | 50 | /** 51 | * Custom error class for GraphQL client errors. 52 | */ 53 | export class GraphQLClientError extends Error { 54 | readonly status: number; 55 | readonly body: unknown; 56 | 57 | constructor(message: string, status: number, body: unknown) { 58 | super(message); 59 | this.status = status; 60 | this.body = body; 61 | } 62 | } 63 | 64 | /** 65 | * Response type for tools. 66 | */ 67 | export type ToolResponse = { content: { type: 'text'; text: string }[] }; 68 | 69 | /** 70 | * Executor function type for tools. 71 | */ 72 | export type ToolExecutor = () => Promise; 73 | 74 | /** 75 | * Interface for tool modules that can register with the MCP server. 76 | */ 77 | export interface ToolModule { 78 | register(server: McpServer): void; 79 | } -------------------------------------------------------------------------------- /tests/msw/mockData/discussComments.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "topicComments": { 4 | "data": [ 5 | { 6 | "id": 11111, 7 | "pinned": false, 8 | "pinnedBy": null, 9 | "post": { 10 | "id": "22222", 11 | "voteCount": 15, 12 | "voteStatus": 0, 13 | "content": "Great explanation! This helped me understand the concept better.", 14 | "updationDate": 1659201000, 15 | "creationDate": 1659200800, 16 | "status": "PUBLISHED", 17 | "isHidden": false, 18 | "coinRewards": [], 19 | "author": { 20 | "isDiscussAdmin": false, 21 | "isDiscussStaff": false, 22 | "username": "learner123", 23 | "nameColor": null, 24 | "activeBadge": null, 25 | "profile": { 26 | "userAvatar": "https://assets.leetcode.com/users/default_avatar.jpg", 27 | "reputation": 450 28 | }, 29 | "isActive": true 30 | }, 31 | "authorIsModerator": false, 32 | "isOwnPost": false 33 | }, 34 | "numChildren": 2 35 | }, 36 | { 37 | "id": 11112, 38 | "pinned": false, 39 | "pinnedBy": null, 40 | "post": { 41 | "id": "22223", 42 | "voteCount": 8, 43 | "voteStatus": 0, 44 | "content": "Thanks for sharing this approach!", 45 | "updationDate": 1659201200, 46 | "creationDate": 1659201100, 47 | "status": "PUBLISHED", 48 | "isHidden": false, 49 | "coinRewards": [], 50 | "author": { 51 | "isDiscussAdmin": false, 52 | "isDiscussStaff": false, 53 | "username": "developer456", 54 | "nameColor": null, 55 | "activeBadge": null, 56 | "profile": { 57 | "userAvatar": "https://assets.leetcode.com/users/default_avatar.jpg", 58 | "reputation": 320 59 | }, 60 | "isActive": true 61 | }, 62 | "authorIsModerator": false, 63 | "isOwnPost": false 64 | }, 65 | "numChildren": 0 66 | } 67 | ] 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tests/unit/FormatUtils/trendingTopicData.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { formatTrendingCategoryTopicData } from '../../../src/FormatUtils/trendingTopicData'; 3 | 4 | describe('trendingTopicData FormatUtils', () => { 5 | describe('formatTrendingCategoryTopicData', () => { 6 | it('should return data unchanged', () => { 7 | const input = { 8 | categoryTopicList: { 9 | edges: [ 10 | { 11 | node: { 12 | id: '1', 13 | title: 'How to solve Two Sum?', 14 | commentCount: 10, 15 | viewCount: 1000, 16 | }, 17 | }, 18 | { 19 | node: { 20 | id: '2', 21 | title: 'Best approach for Binary Search', 22 | commentCount: 5, 23 | viewCount: 500, 24 | }, 25 | }, 26 | ], 27 | }, 28 | }; 29 | 30 | const result = formatTrendingCategoryTopicData(input as never); 31 | 32 | expect(result).toEqual(input); 33 | expect(result).toBe(input); 34 | }); 35 | 36 | it('should handle empty topic list', () => { 37 | const input = { 38 | categoryTopicList: { 39 | edges: [], 40 | }, 41 | }; 42 | 43 | const result = formatTrendingCategoryTopicData(input as never); 44 | 45 | expect(result).toEqual(input); 46 | }); 47 | 48 | it('should not modify the original object', () => { 49 | const input = { 50 | categoryTopicList: { 51 | edges: [ 52 | { 53 | node: { 54 | id: '1', 55 | title: 'Test Topic', 56 | }, 57 | }, 58 | ], 59 | }, 60 | }; 61 | 62 | const original = JSON.parse(JSON.stringify(input)); 63 | const result = formatTrendingCategoryTopicData(input as never); 64 | 65 | expect(result).toEqual(original); 66 | }); 67 | 68 | it('should handle null and undefined values', () => { 69 | const input = { 70 | categoryTopicList: { 71 | edges: [ 72 | { 73 | node: { 74 | id: '1', 75 | title: 'Topic with null values', 76 | commentCount: null, 77 | viewCount: undefined, 78 | }, 79 | }, 80 | ], 81 | }, 82 | }; 83 | 84 | const result = formatTrendingCategoryTopicData(input as never); 85 | 86 | expect(result).toEqual(input); 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/FormatUtils/problemData.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | DailyProblemData, 3 | ProblemSetQuestionListData, 4 | SelectProblemData, 5 | } from '../types'; 6 | 7 | export const formatDailyData = (data: DailyProblemData) => ({ 8 | questionLink: `https://leetcode.com${data.activeDailyCodingChallengeQuestion.link}`, 9 | date: data.activeDailyCodingChallengeQuestion.date, 10 | questionId: data.activeDailyCodingChallengeQuestion.question.questionId, 11 | questionFrontendId: 12 | data.activeDailyCodingChallengeQuestion.question.questionFrontendId, 13 | questionTitle: data.activeDailyCodingChallengeQuestion.question.title, 14 | titleSlug: data.activeDailyCodingChallengeQuestion.question.titleSlug, 15 | difficulty: data.activeDailyCodingChallengeQuestion.question.difficulty, 16 | isPaidOnly: data.activeDailyCodingChallengeQuestion.question.isPaidOnly, 17 | question: data.activeDailyCodingChallengeQuestion.question.content, 18 | exampleTestcases: 19 | data.activeDailyCodingChallengeQuestion.question.exampleTestcases, 20 | topicTags: data.activeDailyCodingChallengeQuestion.question.topicTags, 21 | hints: data.activeDailyCodingChallengeQuestion.question.hints, 22 | solution: data.activeDailyCodingChallengeQuestion.question.solution, 23 | companyTagStats: 24 | data.activeDailyCodingChallengeQuestion.question.companyTagStats, 25 | likes: data.activeDailyCodingChallengeQuestion.question.likes, 26 | dislikes: data.activeDailyCodingChallengeQuestion.question.dislikes, 27 | similarQuestions: 28 | data.activeDailyCodingChallengeQuestion.question.similarQuestions, 29 | }); 30 | 31 | export const formatQuestionData = (data: SelectProblemData) => ({ 32 | link: `https://leetcode.com/problems/${data.question.titleSlug}`, 33 | questionId: data.question.questionId, 34 | questionFrontendId: data.question.questionFrontendId, 35 | questionTitle: data.question.title, 36 | titleSlug: data.question.titleSlug, 37 | difficulty: data.question.difficulty, 38 | isPaidOnly: data.question.isPaidOnly, 39 | question: data.question.content, 40 | exampleTestcases: data.question.exampleTestcases, 41 | topicTags: data.question.topicTags, 42 | hints: data.question.hints, 43 | solution: data.question.solution, 44 | companyTagStats: data.question.companyTagStats, 45 | likes: data.question.likes, 46 | dislikes: data.question.dislikes, 47 | similarQuestions: data.question.similarQuestions, 48 | }); 49 | 50 | export const formatProblemsData = (data: ProblemSetQuestionListData) => ({ 51 | totalQuestions: data.problemsetQuestionList.total, 52 | count: data.problemsetQuestionList.questions.length, 53 | problemsetQuestionList: data.problemsetQuestionList.questions, 54 | }); 55 | -------------------------------------------------------------------------------- /tests/msw/mockData/singleUser.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "allQuestionsCount": [ 4 | { "difficulty": "All", "count": 3273 }, 5 | { "difficulty": "Easy", "count": 823 }, 6 | { "difficulty": "Medium", "count": 1717 }, 7 | { "difficulty": "Hard", "count": 733 } 8 | ], 9 | "matchedUser": { 10 | "submissionCalendar": "{\"1704153600\": 24, \"1704240000\": 9, \"1704326400\": 5, \"1704412800\": 3, \"1705017600\": 1, \"1705104000\": 6, \"1699315200\": 46, \"1701302400\": 6, \"1701648000\": 24, \"1701734400\": 11, \"1702339200\": 29, \"1702425600\": 7}", 11 | "submitStats": { 12 | "totalSubmissionNum": [ 13 | { "difficulty": "All", "count": 158, "submissions": 463 }, 14 | { "difficulty": "Easy", "count": 131, "submissions": 388 }, 15 | { "difficulty": "Medium", "count": 27, "submissions": 75 }, 16 | { "difficulty": "Hard", "count": 0, "submissions": 0 } 17 | ], 18 | "acSubmissionNum": [ 19 | { "difficulty": "All", "count": 133, "submissions": 215 }, 20 | { "difficulty": "Easy", "count": 122, "submissions": 202 }, 21 | { "difficulty": "Medium", "count": 11, "submissions": 13 }, 22 | { "difficulty": "Hard", "count": 0, "submissions": 0 } 23 | ] 24 | }, 25 | "username": "jambobjones", 26 | "githubUrl": "https://github.com/jambobjones", 27 | "twitterUrl": null, 28 | "linkedinUrl": null, 29 | "contributions": { 30 | "points": 115, 31 | "questionCount": 0, 32 | "testcaseCount": 0 33 | }, 34 | "profile": { 35 | "realName": "Jambob Jones", 36 | "userAvatar": "https://assets.leetcode.com/users/jambobjones/avatar_1617850141.png", 37 | "birthday": null, 38 | "ranking": 630800, 39 | "reputation": 0, 40 | "websites": [], 41 | "countryName": null, 42 | "company": null, 43 | "school": null, 44 | "skillTags": [], 45 | "aboutMe": "", 46 | "starRating": 2 47 | }, 48 | "badges": [], 49 | "upcomingBadges": [ 50 | { 51 | "name": "Mar LeetCoding Challenge", 52 | "icon": "/static/images/badges/dcc-2024-3.png" 53 | } 54 | ], 55 | "activeBadge": null 56 | }, 57 | "recentSubmissionList": [ 58 | { 59 | "title": "Two Sum", 60 | "titleSlug": "two-sum", 61 | "timestamp": "1704153600", 62 | "statusDisplay": "Accepted", 63 | "lang": "python3" 64 | }, 65 | { 66 | "title": "Add Two Numbers", 67 | "titleSlug": "add-two-numbers", 68 | "timestamp": "1704067200", 69 | "statusDisplay": "Accepted", 70 | "lang": "javascript" 71 | } 72 | ] 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /mcp/serverUtils.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 2 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 3 | import { GraphQLParams, GraphQLClientError, ToolResponse, ToolExecutor, ToolModule } from './types'; 4 | 5 | const GRAPHQL_ENDPOINT = 'https://leetcode.com/graphql'; 6 | export const SERVER_VERSION = '1.0.0'; 7 | 8 | // Executes a GraphQL query against the LeetCode API. 9 | export async function executeGraphQL(query: string, variables: GraphQLParams = {}): Promise { 10 | const requestInit: RequestInit = { 11 | method: 'POST', 12 | headers: { 13 | 'Content-Type': 'application/json', 14 | Referer: 'https://leetcode.com', 15 | }, 16 | body: JSON.stringify({ query, variables }), 17 | }; 18 | 19 | const response = await fetch(GRAPHQL_ENDPOINT, requestInit); 20 | const payload = await response.json(); 21 | 22 | if (!response.ok) { 23 | throw new GraphQLClientError('HTTP error when calling LeetCode GraphQL', response.status, payload); 24 | } 25 | 26 | if (payload?.errors) { 27 | throw new GraphQLClientError('LeetCode GraphQL responded with errors', response.status, payload); 28 | } 29 | 30 | return payload.data; 31 | } 32 | 33 | // Converts data to tool content format. 34 | export function toToolContent(data: unknown): { type: 'text'; text: string }[] { 35 | return [{ type: 'text', text: JSON.stringify(data, null, 2) }]; 36 | } 37 | 38 | // Creates a tool result from data. 39 | export function createToolResult(data: unknown): { 40 | content: { type: 'text'; text: string }[]; 41 | } { 42 | return { 43 | content: toToolContent(data), 44 | }; 45 | } 46 | 47 | // Creates an error tool result from an error. 48 | export function createErrorResult(error: unknown): { 49 | content: { type: 'text'; text: string }[]; 50 | } { 51 | if (error instanceof GraphQLClientError) { 52 | const payload = { 53 | message: error.message, 54 | status: error.status, 55 | response: error.body, 56 | }; 57 | return createToolResult(payload); 58 | } 59 | 60 | if (error instanceof Error) { 61 | return createToolResult({ message: error.message }); 62 | } 63 | 64 | return createToolResult({ message: 'Unknown error', detail: error }); 65 | } 66 | 67 | // Runs a tool executor and handles errors. 68 | export async function runTool(executor: ToolExecutor): Promise { 69 | try { 70 | const data = await executor(); 71 | return createToolResult(data); 72 | } catch (error) { 73 | return createErrorResult(error); 74 | } 75 | } 76 | 77 | // Starts the MCP server with the given modules. 78 | export async function startServer( 79 | serverInfo: { name: string; version?: string }, 80 | modules: ToolModule[], 81 | ): Promise { 82 | const server = new McpServer({ 83 | name: serverInfo.name, 84 | version: serverInfo.version ?? SERVER_VERSION, 85 | }); 86 | 87 | for (const module of modules) { 88 | module.register(server); 89 | } 90 | 91 | const transport = new StdioServerTransport(); 92 | await server.connect(transport); 93 | } 94 | -------------------------------------------------------------------------------- /mcp/modules/problemTools.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 2 | import { z } from 'zod'; 3 | import { 4 | getDailyProblem, 5 | getDailyProblemLegacy, 6 | getDailyProblemRaw, 7 | getOfficialSolution, 8 | getProblemSet, 9 | getSelectProblem, 10 | getSelectProblemRaw, 11 | } from '../leetCodeService'; 12 | import { runTool } from '../serverUtils'; 13 | import { ToolModule } from '../types'; 14 | 15 | export class ProblemToolsModule implements ToolModule { 16 | // Registers problem-related tools with the MCP server. 17 | register(server: McpServer): void { 18 | server.registerTool( 19 | 'leetcode_problem_daily', 20 | { 21 | title: 'Daily Problem', 22 | description: 'Retrieves the formatted daily challenge', 23 | }, 24 | async () => runTool(() => getDailyProblem()), 25 | ); 26 | 27 | server.registerTool( 28 | 'leetcode_problem_daily_raw', 29 | { 30 | title: 'Daily Problem Raw', 31 | description: 'Retrieves the raw daily challenge payload', 32 | }, 33 | async () => runTool(() => getDailyProblemRaw()), 34 | ); 35 | 36 | server.registerTool( 37 | 'leetcode_problem_select', 38 | { 39 | title: 'Selected Problem', 40 | description: 'Fetches formatted data for a problem by slug', 41 | inputSchema: { 42 | titleSlug: z.string(), 43 | }, 44 | }, 45 | async ({ titleSlug }) => runTool(() => getSelectProblem(titleSlug)), 46 | ); 47 | 48 | server.registerTool( 49 | 'leetcode_problem_select_raw', 50 | { 51 | title: 'Selected Problem Raw', 52 | description: 'Fetches raw data for a problem by slug', 53 | inputSchema: { 54 | titleSlug: z.string(), 55 | }, 56 | }, 57 | async ({ titleSlug }) => runTool(() => getSelectProblemRaw(titleSlug)), 58 | ); 59 | 60 | server.registerTool( 61 | 'leetcode_problem_list', 62 | { 63 | title: 'Problem List', 64 | description: 'Retrieves a filtered set of problems', 65 | inputSchema: { 66 | limit: z.number().int().positive().max(100).optional(), 67 | skip: z.number().int().min(0).optional(), 68 | tags: z.string().optional(), 69 | difficulty: z.enum(['EASY', 'MEDIUM', 'HARD']).optional(), 70 | }, 71 | }, 72 | async ({ limit, skip, tags, difficulty }) => runTool(() => getProblemSet({ limit, skip, tags, difficulty })), 73 | ); 74 | 75 | server.registerTool( 76 | 'leetcode_problem_official_solution', 77 | { 78 | title: 'Official Solution', 79 | description: 'Retrieves the official LeetCode solution for a problem', 80 | inputSchema: { 81 | titleSlug: z.string(), 82 | }, 83 | }, 84 | async ({ titleSlug }) => runTool(() => getOfficialSolution(titleSlug)), 85 | ); 86 | 87 | server.registerTool( 88 | 'leetcode_problem_daily_legacy', 89 | { 90 | title: 'Daily Problem Legacy', 91 | description: 'Retrieves the legacy daily challenge payload', 92 | }, 93 | async () => runTool(() => getDailyProblemLegacy()), 94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/FormatUtils/userData.ts: -------------------------------------------------------------------------------- 1 | import type { UserContest } from '../schema'; 2 | import type { UserData } from '../types'; 3 | 4 | export const formatUserData = (data: UserData) => ({ 5 | username: data.matchedUser.username, 6 | name: data.matchedUser.profile.realName, 7 | birthday: data.matchedUser.profile.birthday, 8 | avatar: data.matchedUser.profile.userAvatar, 9 | ranking: data.matchedUser.profile.ranking, 10 | reputation: data.matchedUser.profile.reputation, 11 | gitHub: data.matchedUser.githubUrl, 12 | twitter: data.matchedUser.twitterUrl, 13 | linkedIN: data.matchedUser.linkedinUrl, 14 | website: data.matchedUser.profile.websites, 15 | country: data.matchedUser.profile.countryName, 16 | company: data.matchedUser.profile.company, 17 | school: data.matchedUser.profile.school, 18 | skillTags: data.matchedUser.profile.skillTags, 19 | about: data.matchedUser.profile.aboutMe, 20 | }); 21 | 22 | export const formatBadgesData = (data: UserData) => ({ 23 | badgesCount: data.matchedUser.badges.length, 24 | badges: data.matchedUser.badges, 25 | upcomingBadges: data.matchedUser.upcomingBadges, 26 | activeBadge: data.matchedUser.activeBadge, 27 | }); 28 | 29 | export const formatContestData = (data: UserContest) => ({ 30 | contestAttend: data.userContestRanking?.attendedContestsCount, 31 | contestRating: data.userContestRanking?.rating, 32 | contestGlobalRanking: data.userContestRanking?.globalRanking, 33 | totalParticipants: data.userContestRanking?.totalParticipants, 34 | contestTopPercentage: data.userContestRanking?.topPercentage, 35 | contestBadges: data.userContestRanking?.badge, 36 | contestParticipation: data.userContestRankingHistory.filter( 37 | (obj) => obj.attended === true, 38 | ), 39 | }); 40 | 41 | export const formatContestHistoryData = (data: UserData) => ({ 42 | count: data.userContestRankingHistory.length, 43 | contestHistory: data.userContestRankingHistory, 44 | }); 45 | 46 | export const formatSolvedProblemsData = (data: UserData) => ({ 47 | solvedProblem: data.matchedUser.submitStats.acSubmissionNum[0].count, 48 | easySolved: data.matchedUser.submitStats.acSubmissionNum[1].count, 49 | mediumSolved: data.matchedUser.submitStats.acSubmissionNum[2].count, 50 | hardSolved: data.matchedUser.submitStats.acSubmissionNum[3].count, 51 | totalSubmissionNum: data.matchedUser.submitStats.totalSubmissionNum, 52 | acSubmissionNum: data.matchedUser.submitStats.acSubmissionNum, 53 | }); 54 | 55 | export const formatSubmissionData = (data: UserData) => ({ 56 | count: data.recentSubmissionList.length, 57 | submission: data.recentSubmissionList, 58 | }); 59 | 60 | export const formatAcSubmissionData = (data: UserData) => ({ 61 | count: data.recentAcSubmissionList.length, 62 | submission: data.recentAcSubmissionList, 63 | }); 64 | 65 | export const formatSubmissionCalendarData = (data: UserData) => ({ 66 | activeYears: data.matchedUser.userCalendar.activeYears, 67 | streak: data.matchedUser.userCalendar.streak, 68 | totalActiveDays: data.matchedUser.userCalendar.totalActiveDays, 69 | dccBadges: data.matchedUser.userCalendar.dccBadge, 70 | submissionCalendar: data.matchedUser.userCalendar.submissionCalendar, 71 | }); 72 | 73 | export const formatSkillStats = (data: UserData) => ({ 74 | fundamental: data.matchedUser.tagProblemCounts.fundamental, 75 | intermediate: data.matchedUser.tagProblemCounts.intermediate, 76 | advanced: data.matchedUser.tagProblemCounts.advanced, 77 | }); 78 | 79 | export const formatLanguageStats = (data: UserData) => ({ 80 | languageProblemCount: data.matchedUser.languageProblemCount, 81 | }); 82 | 83 | export const formatProgressStats = (data: UserData) => ({ 84 | numAcceptedQuestions: data.userProfileUserQuestionProgressV2, 85 | }); 86 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to alfa-leetcode-api 2 | 3 | I'm excited you're interested in contributing to **alfa-leetcode-api**, a custom solution born out of the need for a well-documented and detailed LeetCode API. This project is designed to provide developers with endpoints that offer insights into a user's profile, badges, solved questions, contest details, contest history, submissions, and also daily questions, selected problem, list of problems. 4 | 5 | ## Reporting Bugs 6 | 7 | If you find a bug, please check the Issues to see if it has already been reported. If not, open a new issue with a clear `title` and `description`. Include as much detail as possible to help us reproduce the issue: 8 | 9 | - A clear and concise description of the bug. 10 | - Steps to reproduce the behavior. 11 | - Expected behavior. 12 | - Screenshots or a video if applicable. 13 | 14 | ## Suggesting Enhancements 15 | 16 | All ideas for new features or improvements are welcome. If you have a suggestion, please create a new topic on the [discussions page](https://github.com/alfaarghya/alfa-leetcode-api/discussions). Describe your idea and why you think it would be a good addition to the project. 17 | 18 | ### Working on Issues 19 | 20 | First requirement: use the program. I've seen people wanting to contribute without using it. 21 | 22 | Issues will only be assigned to users when enough discussion about their implementation has taken place. It's important that nobody keeps an issue assigned without making progress, as this prevents others from contributing. So, if you want to write code for an existing issue, start by discussing the issue and your proposed solution first. 23 | 24 | I do think it's fine if you submit a PR for a bugfix you made without prior discussion, as long as you take the time to explain the **why** and the **how**. In that case, the issue won't be assigned to you until the merge is complete. 25 | 26 | ### Generative AI use 27 | 28 | I don't want to go as far as prohibiting anyone from using AI. After all, at this point, _some AI use_ is inevitable. However, **purely vibe-coded PRs are not going to be approved**. 29 | 30 | If you're using AI to generate code, you must make it very clear. And you'll have to own it and maintain it. I will review and ask as many questions as necessary about the code, and I reserve the right to judge whether I think the contribution is worth it or not. 31 | 32 | Also, not properly communicating that you're using generated code in your PR is considered dishonest. If I find out, I'll have to close the PR. 33 | 34 | ## Submitting a Pull Request 35 | 36 | 1. Fork the repository and create your branch from `main`. Call it `feature/xyz-feature` or `bug/xyz-bug`. 37 | 2. Clone your forked repository to your local machine. 38 | 3. Implement your changes. Please ensure your code is: 39 | - well-written 40 | - well formatted (see [Code Quality and Formatting](#code-quality-and-formatting) section below) 41 | 4. Write clear, concise commit messages. 42 | 5. run tests with `npm run test` before pushing 43 | 6. Push your changes to your fork. 44 | 45 | Open a new pull request from your branch to the `main` branch of **alfa-leetcode-api**. 46 | 47 | Provide a clear description of the changes in your pull request. If your PR addresses an existing issue, please reference it. Images and videos are always appreciated, for a quicker understanding of what has been implemented. 48 | 49 | ## Code Quality and Formatting 50 | 51 | This project uses [BiomeJS](https://biomejs.dev/) for code linting and formatting. All contributions must pass linting and formatting checks before being merged. 52 | 53 | ### Before Submitting Your PR 54 | 55 | Run the following command to check and fix any linting or formatting issues: 56 | 57 | ```bash 58 | npm run check:fix 59 | ``` 60 | 61 | This will: 62 | 63 | - Auto-fix linting issues where possible 64 | - Apply consistent code formatting across your changes 65 | 66 | ### Available Scripts 67 | 68 | - `npm run lint` - Check for linting issues without fixing 69 | - `npm run lint:fix` - Auto-fix linting issues 70 | - `npm run format` - Check code formatting without fixing 71 | - `npm run format:fix` - Apply code formatting 72 | - `npm run check` - Run both lint and format checks 73 | - `npm run check:fix` - Fix all issues (recommended before commits) 74 | 75 | ### Pre-commit Hooks 76 | 77 | The project uses Husky and lint-staged to automatically format your code when you commit. Staged files will be automatically linted and formatted before each commit, ensuring consistent code style throughout the project. 78 | 79 | ## Setting up Your Development Environment 80 | 81 | To start contributing, you'll need to set up your local environment. 82 | 83 | Clone the repository: 84 | 85 | ```bash 86 | git clone https://github.com//alfa-leetcode-api.git 87 | cd alfa-leetcode-api # go to the project 88 | npm install # install required modules 89 | npm run dev # run the project 90 | ``` 91 | 92 | Thank you for helping us to improve **alfa-leetcode-api**! 93 | -------------------------------------------------------------------------------- /tests/msw/mockData/recentAcSubmissionList.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "recentAcSubmissionList": [ 4 | { 5 | "title": "Generate a String With Characters That Have Odd Counts", 6 | "titleSlug": "generate-a-string-with-characters-that-have-odd-counts", 7 | "timestamp": "1705161771", 8 | "statusDisplay": "Accepted", 9 | "lang": "javascript" 10 | }, 11 | { 12 | "title": "Count Negative Numbers in a Sorted Matrix", 13 | "titleSlug": "count-negative-numbers-in-a-sorted-matrix", 14 | "timestamp": "1705161438", 15 | "statusDisplay": "Accepted", 16 | "lang": "javascript" 17 | }, 18 | { 19 | "title": "Check If N and Its Double Exist", 20 | "titleSlug": "check-if-n-and-its-double-exist", 21 | "timestamp": "1705161205", 22 | "statusDisplay": "Accepted", 23 | "lang": "javascript" 24 | }, 25 | { 26 | "title": "Determine if String Halves Are Alike", 27 | "titleSlug": "determine-if-string-halves-are-alike", 28 | "timestamp": "1705032551", 29 | "statusDisplay": "Accepted", 30 | "lang": "javascript" 31 | }, 32 | { 33 | "title": "Minimum Number of Operations to Make Array Empty", 34 | "titleSlug": "minimum-number-of-operations-to-make-array-empty", 35 | "timestamp": "1704343666", 36 | "statusDisplay": "Accepted", 37 | "lang": "javascript" 38 | }, 39 | { 40 | "title": "Sort Array By Parity", 41 | "titleSlug": "sort-array-by-parity", 42 | "timestamp": "1704316936", 43 | "statusDisplay": "Accepted", 44 | "lang": "javascript" 45 | }, 46 | { 47 | "title": "Convert an Array Into a 2D Array With Conditions", 48 | "titleSlug": "convert-an-array-into-a-2d-array-with-conditions", 49 | "timestamp": "1704261617", 50 | "statusDisplay": "Accepted", 51 | "lang": "javascript" 52 | }, 53 | { 54 | "title": "Average Value of Even Numbers That Are Divisible by Three", 55 | "titleSlug": "average-value-of-even-numbers-that-are-divisible-by-three", 56 | "timestamp": "1704259967", 57 | "statusDisplay": "Accepted", 58 | "lang": "javascript" 59 | }, 60 | { 61 | "title": "Number of Laser Beams in a Bank", 62 | "titleSlug": "number-of-laser-beams-in-a-bank", 63 | "timestamp": "1704256409", 64 | "statusDisplay": "Accepted", 65 | "lang": "javascript" 66 | }, 67 | { 68 | "title": "Take Gifts From the Richest Pile", 69 | "titleSlug": "take-gifts-from-the-richest-pile", 70 | "timestamp": "1704252901", 71 | "statusDisplay": "Accepted", 72 | "lang": "javascript" 73 | }, 74 | { 75 | "title": "Decompress Run-Length Encoded List", 76 | "titleSlug": "decompress-run-length-encoded-list", 77 | "timestamp": "1704236706", 78 | "statusDisplay": "Accepted", 79 | "lang": "javascript" 80 | }, 81 | { 82 | "title": "Make Array Zero by Subtracting Equal Amounts", 83 | "titleSlug": "make-array-zero-by-subtracting-equal-amounts", 84 | "timestamp": "1704234483", 85 | "statusDisplay": "Accepted", 86 | "lang": "javascript" 87 | }, 88 | { 89 | "title": "Remove One Element to Make the Array Strictly Increasing", 90 | "titleSlug": "remove-one-element-to-make-the-array-strictly-increasing", 91 | "timestamp": "1704228445", 92 | "statusDisplay": "Accepted", 93 | "lang": "javascript" 94 | }, 95 | { 96 | "title": "Check if All A's Appears Before All B's", 97 | "titleSlug": "check-if-all-as-appears-before-all-bs", 98 | "timestamp": "1704223378", 99 | "statusDisplay": "Accepted", 100 | "lang": "javascript" 101 | }, 102 | { 103 | "title": "Contains Duplicate II", 104 | "titleSlug": "contains-duplicate-ii", 105 | "timestamp": "1704222499", 106 | "statusDisplay": "Accepted", 107 | "lang": "javascript" 108 | }, 109 | { 110 | "title": "Pascal's Triangle", 111 | "titleSlug": "pascals-triangle", 112 | "timestamp": "1704219156", 113 | "statusDisplay": "Accepted", 114 | "lang": "javascript" 115 | }, 116 | { 117 | "title": "Make Array Zero by Subtracting Equal Amounts", 118 | "titleSlug": "make-array-zero-by-subtracting-equal-amounts", 119 | "timestamp": "1704216984", 120 | "statusDisplay": "Accepted", 121 | "lang": "javascript" 122 | }, 123 | { 124 | "title": "Apply Transform Over Each Element in Array", 125 | "titleSlug": "apply-transform-over-each-element-in-array", 126 | "timestamp": "1704215794", 127 | "statusDisplay": "Accepted", 128 | "lang": "javascript" 129 | }, 130 | { 131 | "title": "Find the Difference", 132 | "titleSlug": "find-the-difference", 133 | "timestamp": "1702492842", 134 | "statusDisplay": "Accepted", 135 | "lang": "javascript" 136 | }, 137 | { 138 | "title": "Monotonic Array", 139 | "titleSlug": "monotonic-array", 140 | "timestamp": "1702417451", 141 | "statusDisplay": "Accepted", 142 | "lang": "javascript" 143 | } 144 | ] 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Request } from 'express'; 2 | 3 | // User Data 4 | interface UserDataProfile { 5 | aboutMe: string; 6 | company?: string; 7 | countryName?: string; 8 | realName: string; 9 | birthday?: string; 10 | userAvatar: string; 11 | ranking: number; 12 | reputation: number; 13 | school?: string; 14 | skillTags: string[]; 15 | websites: string[]; 16 | } 17 | 18 | interface MatchedUser { 19 | activeBadge: Badge; 20 | badges: Badge[]; 21 | githubUrl: string; 22 | linkedinUrl?: string; 23 | profile: UserDataProfile; 24 | upcomingBadges: Badge[]; 25 | username: string; 26 | twitterUrl?: string; 27 | userCalendar: { 28 | activeYears: number[]; 29 | streak: number; 30 | totalActiveDays: number; 31 | dccBadge: { 32 | timestamp: number; 33 | badge: { 34 | name: string; 35 | icon: string; 36 | }; 37 | }[]; 38 | submissionCalendar: string; 39 | }; 40 | submitStats: { 41 | totalSubmissionNum: { 42 | difficulty: Difficulty; 43 | count: number; 44 | submissions: number; 45 | }[]; 46 | acSubmissionNum: { 47 | difficulty: Difficulty; 48 | count: number; 49 | submissions: number; 50 | }[]; 51 | count: number; 52 | }; 53 | tagProblemCounts: { 54 | fundamental: skillStats[]; 55 | intermediate: skillStats[]; 56 | advanced: skillStats[]; 57 | }; 58 | languageProblemCount: { languageName: string; problemsSolved: number }[]; 59 | } 60 | 61 | export interface UserData { 62 | userContestRanking: null | { 63 | attendedContestsCount: number; 64 | badge: Badge; 65 | globalRanking: number; 66 | rating: number; 67 | totalParticipants: number; 68 | topPercentage: number; 69 | }; 70 | userContestRankingHistory: { 71 | attended: boolean; 72 | rating: number; 73 | ranking: number; 74 | trendDirection: string; 75 | problemsSolved: number; 76 | totalProblems: number; 77 | finishTimeInSeconds: number; 78 | contest: { 79 | title: string; 80 | startTime: string; 81 | }; 82 | }[]; 83 | matchedUser: MatchedUser; 84 | recentAcSubmissionList: object[]; 85 | recentSubmissionList: Submission[]; 86 | userProfileUserQuestionProgressV2: { count: number; difficulty: string }[]; 87 | } 88 | 89 | interface Badge { 90 | name: string; 91 | icon: string; 92 | } 93 | 94 | interface skillStats { 95 | tagName: string; 96 | tagSlug: string; 97 | problemsSolved: number; 98 | } 99 | 100 | type Difficulty = 'All' | 'Easy' | 'Medium' | 'Hard'; 101 | //User Details 102 | export type FetchUserDataRequest = Request< 103 | { username: string }, 104 | object, 105 | { username: string; limit: number; year: number }, 106 | { limit?: string; year?: string } 107 | >; 108 | 109 | export type TransformedUserDataRequest = Request< 110 | object, 111 | object, 112 | { username: string; limit: number; year: number } 113 | >; 114 | 115 | // ProblemData 116 | export interface ProblemSetQuestionListData { 117 | problemsetQuestionList: { 118 | total: number; 119 | questions: object[]; 120 | }; 121 | } 122 | 123 | interface Submission { 124 | title: string; 125 | titleSlug: string; 126 | timestamp: string; 127 | statusDisplay: string; 128 | lang: string; 129 | } 130 | 131 | interface Question { 132 | content: string; 133 | companyTagStats: string[]; 134 | difficulty: Difficulty; 135 | dislikes: number; 136 | exampleTestcases: object[]; 137 | hints: object[]; 138 | isPaidOnly: boolean; 139 | likes: number; 140 | questionId: number; 141 | questionFrontendId: number; 142 | solution: string; 143 | similarQuestions: object[]; 144 | title: string; 145 | titleSlug: string; 146 | topicTags: string[]; 147 | } 148 | 149 | export interface DailyProblemData { 150 | activeDailyCodingChallengeQuestion: { 151 | date: string; 152 | link: string; 153 | question: Question; 154 | }; 155 | } 156 | export interface SelectProblemData { 157 | question: Question; 158 | } 159 | 160 | export interface TrendingDiscussionObject { 161 | data: { 162 | cachedTrendingCategoryTopics: { 163 | id: number; 164 | title: string; 165 | post: { 166 | id: number; 167 | creationDate: number; 168 | contentPreview: string; 169 | author: { 170 | username: string; 171 | isActive: boolean; 172 | profile: { 173 | userAvatar: string; 174 | }; 175 | }; 176 | }; 177 | }[]; 178 | }; 179 | } 180 | 181 | // Contest type matching GraphQL query structure 182 | export interface Contest { 183 | title: string; 184 | titleSlug: string; 185 | startTime: number; 186 | duration: number; 187 | originStartTime: number; 188 | isVirtual: boolean; 189 | containsPremium: boolean; 190 | } 191 | 192 | // Generic GraphQL params (username is most common) 193 | export interface GraphQLParams { 194 | username?: string; 195 | [key: string]: unknown; 196 | } 197 | 198 | // User profile specific GraphQL response 199 | export interface UserProfileResponse { 200 | matchedUser: { 201 | submitStats: { 202 | acSubmissionNum: Array<{ count: number }>; 203 | totalSubmissionNum: unknown; 204 | }; 205 | submissionCalendar: string; 206 | profile: { 207 | ranking: number; 208 | reputation: number; 209 | }; 210 | contributions: { 211 | points: number; 212 | }; 213 | }; 214 | allQuestionsCount: Array<{ count: number }>; 215 | recentSubmissionList: unknown[]; 216 | } 217 | -------------------------------------------------------------------------------- /tests/msw/mockData/recentSubmissions.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "recentSubmissionList": [ 4 | { 5 | "title": "Generate a String With Characters That Have Odd Counts", 6 | "titleSlug": "generate-a-string-with-characters-that-have-odd-counts", 7 | "timestamp": "1705161771", 8 | "statusDisplay": "Accepted", 9 | "lang": "javascript" 10 | }, 11 | { 12 | "title": "Count Negative Numbers in a Sorted Matrix", 13 | "titleSlug": "count-negative-numbers-in-a-sorted-matrix", 14 | "timestamp": "1705161438", 15 | "statusDisplay": "Accepted", 16 | "lang": "javascript" 17 | }, 18 | { 19 | "title": "Count Negative Numbers in a Sorted Matrix", 20 | "titleSlug": "count-negative-numbers-in-a-sorted-matrix", 21 | "timestamp": "1705161424", 22 | "statusDisplay": "Runtime Error", 23 | "lang": "javascript" 24 | }, 25 | { 26 | "title": "Check If N and Its Double Exist", 27 | "titleSlug": "check-if-n-and-its-double-exist", 28 | "timestamp": "1705161205", 29 | "statusDisplay": "Accepted", 30 | "lang": "javascript" 31 | }, 32 | { 33 | "title": "Check If N and Its Double Exist", 34 | "titleSlug": "check-if-n-and-its-double-exist", 35 | "timestamp": "1705161123", 36 | "statusDisplay": "Wrong Answer", 37 | "lang": "javascript" 38 | }, 39 | { 40 | "title": "Minimum Number of Steps to Make Two Strings Anagram", 41 | "titleSlug": "minimum-number-of-steps-to-make-two-strings-anagram", 42 | "timestamp": "1705160304", 43 | "statusDisplay": "Wrong Answer", 44 | "lang": "javascript" 45 | }, 46 | { 47 | "title": "Determine if String Halves Are Alike", 48 | "titleSlug": "determine-if-string-halves-are-alike", 49 | "timestamp": "1705032551", 50 | "statusDisplay": "Accepted", 51 | "lang": "javascript" 52 | }, 53 | { 54 | "title": "Longest Increasing Subsequence", 55 | "titleSlug": "longest-increasing-subsequence", 56 | "timestamp": "1704442672", 57 | "statusDisplay": "Wrong Answer", 58 | "lang": "javascript" 59 | }, 60 | { 61 | "title": "Longest Increasing Subsequence", 62 | "titleSlug": "longest-increasing-subsequence", 63 | "timestamp": "1704440871", 64 | "statusDisplay": "Wrong Answer", 65 | "lang": "javascript" 66 | }, 67 | { 68 | "title": "Longest Increasing Subsequence", 69 | "titleSlug": "longest-increasing-subsequence", 70 | "timestamp": "1704439843", 71 | "statusDisplay": "Wrong Answer", 72 | "lang": "javascript" 73 | }, 74 | { 75 | "title": "Minimum Number of Operations to Make Array Empty", 76 | "titleSlug": "minimum-number-of-operations-to-make-array-empty", 77 | "timestamp": "1704343666", 78 | "statusDisplay": "Accepted", 79 | "lang": "javascript" 80 | }, 81 | { 82 | "title": "Minimum Number of Operations to Make Array Empty", 83 | "titleSlug": "minimum-number-of-operations-to-make-array-empty", 84 | "timestamp": "1704343585", 85 | "statusDisplay": "Wrong Answer", 86 | "lang": "javascript" 87 | }, 88 | { 89 | "title": "Minimum Number of Operations to Make Array Empty", 90 | "titleSlug": "minimum-number-of-operations-to-make-array-empty", 91 | "timestamp": "1704343426", 92 | "statusDisplay": "Wrong Answer", 93 | "lang": "javascript" 94 | }, 95 | { 96 | "title": "Minimum Number of Operations to Make Array Empty", 97 | "titleSlug": "minimum-number-of-operations-to-make-array-empty", 98 | "timestamp": "1704343413", 99 | "statusDisplay": "Runtime Error", 100 | "lang": "javascript" 101 | }, 102 | { 103 | "title": "Minimum Number of Operations to Make Array Empty", 104 | "titleSlug": "minimum-number-of-operations-to-make-array-empty", 105 | "timestamp": "1704343387", 106 | "statusDisplay": "Runtime Error", 107 | "lang": "javascript" 108 | }, 109 | { 110 | "title": "Sort Array By Parity", 111 | "titleSlug": "sort-array-by-parity", 112 | "timestamp": "1704316936", 113 | "statusDisplay": "Accepted", 114 | "lang": "javascript" 115 | }, 116 | { 117 | "title": "Convert an Array Into a 2D Array With Conditions", 118 | "titleSlug": "convert-an-array-into-a-2d-array-with-conditions", 119 | "timestamp": "1704261617", 120 | "statusDisplay": "Accepted", 121 | "lang": "javascript" 122 | }, 123 | { 124 | "title": "Convert an Array Into a 2D Array With Conditions", 125 | "titleSlug": "convert-an-array-into-a-2d-array-with-conditions", 126 | "timestamp": "1704261566", 127 | "statusDisplay": "Accepted", 128 | "lang": "javascript" 129 | }, 130 | { 131 | "title": "Convert an Array Into a 2D Array With Conditions", 132 | "titleSlug": "convert-an-array-into-a-2d-array-with-conditions", 133 | "timestamp": "1704261530", 134 | "statusDisplay": "Accepted", 135 | "lang": "javascript" 136 | }, 137 | { 138 | "title": "Average Value of Even Numbers That Are Divisible by Three", 139 | "titleSlug": "average-value-of-even-numbers-that-are-divisible-by-three", 140 | "timestamp": "1704259967", 141 | "statusDisplay": "Accepted", 142 | "lang": "javascript" 143 | } 144 | ] 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /tests/integration/contest-routes.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; 3 | import app from '../../src/app'; 4 | import type { Contest } from '../../src/types'; 5 | import { server } from '../msw/server'; 6 | 7 | describe('Contest Routes Integration Tests', () => { 8 | beforeAll(() => server.listen()); 9 | afterEach(() => server.resetHandlers()); 10 | afterAll(() => server.close()); 11 | 12 | describe('GET /contests', () => { 13 | it('should return all contests list', async () => { 14 | const response = await request(app).get('/contests'); 15 | 16 | expect(response.status).toBe(200); 17 | expect(response.body).toHaveProperty('allContests'); 18 | }); 19 | 20 | it('should return array of contests', async () => { 21 | const response = await request(app).get('/contests'); 22 | 23 | expect(response.status).toBe(200); 24 | if (response.body.allContests) { 25 | expect(Array.isArray(response.body.allContests)).toBe(true); 26 | } 27 | }); 28 | 29 | it('should include past and future contests', async () => { 30 | const response = await request(app).get('/contests'); 31 | 32 | expect(response.status).toBe(200); 33 | if (response.body.allContests && response.body.allContests.length > 0) { 34 | const contest = response.body.allContests[0]; 35 | expect(contest).toHaveProperty('startTime'); 36 | } 37 | }); 38 | 39 | it('should return contests with title', async () => { 40 | const response = await request(app).get('/contests'); 41 | 42 | expect(response.status).toBe(200); 43 | if (response.body.allContests && response.body.allContests.length > 0) { 44 | const contest = response.body.allContests[0]; 45 | expect(contest).toHaveProperty('title'); 46 | } 47 | }); 48 | }); 49 | 50 | describe('GET /contests/upcoming', () => { 51 | it('should return only upcoming contests', async () => { 52 | const response = await request(app).get('/contests/upcoming'); 53 | 54 | expect(response.status).toBe(200); 55 | expect(response.body).toHaveProperty('count'); 56 | expect(response.body).toHaveProperty('contests'); 57 | expect(Array.isArray(response.body.contests)).toBe(true); 58 | }); 59 | 60 | it('should filter contests with startTime > now', async () => { 61 | const response = await request(app).get('/contests/upcoming'); 62 | 63 | expect(response.status).toBe(200); 64 | const now = Math.floor(Date.now() / 1000); 65 | 66 | if (response.body.contests.length > 0) { 67 | response.body.contests.forEach((contest: Contest) => { 68 | expect(contest.startTime).toBeGreaterThan(now - 86400); 69 | }); 70 | } 71 | }); 72 | 73 | it('should have count matching array length', async () => { 74 | const response = await request(app).get('/contests/upcoming'); 75 | 76 | expect(response.status).toBe(200); 77 | expect(response.body.count).toBe(response.body.contests.length); 78 | }); 79 | 80 | it('should return zero count when no upcoming contests', async () => { 81 | const response = await request(app).get('/contests/upcoming'); 82 | 83 | expect(response.status).toBe(200); 84 | if (response.body.count === 0) { 85 | expect(response.body.contests).toEqual([]); 86 | } 87 | }); 88 | 89 | it('should return valid timestamp for startTime', async () => { 90 | const response = await request(app).get('/contests/upcoming'); 91 | 92 | expect(response.status).toBe(200); 93 | if (response.body.contests.length > 0) { 94 | const contest = response.body.contests[0]; 95 | expect(typeof contest.startTime).toBe('number'); 96 | expect(contest.startTime).toBeGreaterThan(0); 97 | } 98 | }); 99 | }); 100 | 101 | describe('Contest data structure', () => { 102 | it('should include required contest fields', async () => { 103 | const response = await request(app).get('/contests'); 104 | 105 | expect(response.status).toBe(200); 106 | if (response.body.allContests && response.body.allContests.length > 0) { 107 | const contest = response.body.allContests[0]; 108 | expect(contest.title).toBeDefined(); 109 | expect(contest.startTime).toBeDefined(); 110 | } 111 | }); 112 | 113 | it('should handle empty contests list', async () => { 114 | const response = await request(app).get('/contests/upcoming'); 115 | 116 | expect(response.status).toBe(200); 117 | if (response.body.count === 0) { 118 | expect(response.body.contests).toEqual([]); 119 | } 120 | }); 121 | }); 122 | 123 | describe('Response format validation', () => { 124 | it('should return JSON format', async () => { 125 | const response = await request(app).get('/contests'); 126 | 127 | expect(response.status).toBe(200); 128 | expect(response.headers['content-type']).toMatch(/json/); 129 | }); 130 | 131 | it('should return JSON format for upcoming contests', async () => { 132 | const response = await request(app).get('/contests/upcoming'); 133 | 134 | expect(response.status).toBe(200); 135 | expect(response.headers['content-type']).toMatch(/json/); 136 | }); 137 | }); 138 | 139 | describe('Error handling', () => { 140 | it('should handle requests gracefully', async () => { 141 | const response = await request(app).get('/contests'); 142 | 143 | expect(response.status).toBe(200); 144 | }); 145 | 146 | it('should handle upcoming contests request gracefully', async () => { 147 | const response = await request(app).get('/contests/upcoming'); 148 | 149 | expect(response.status).toBe(200); 150 | }); 151 | }); 152 | 153 | describe('Time-based filtering', () => { 154 | it('should correctly calculate current timestamp', async () => { 155 | const response = await request(app).get('/contests/upcoming'); 156 | 157 | expect(response.status).toBe(200); 158 | expect(response.body.count).toBeGreaterThanOrEqual(0); 159 | }); 160 | 161 | it('should exclude past contests from upcoming', async () => { 162 | const allResponse = await request(app).get('/contests'); 163 | const upcomingResponse = await request(app).get('/contests/upcoming'); 164 | 165 | expect(allResponse.status).toBe(200); 166 | expect(upcomingResponse.status).toBe(200); 167 | 168 | if (allResponse.body.allContests) { 169 | const totalContests = allResponse.body.allContests.length || 0; 170 | const upcomingContests = upcomingResponse.body.count; 171 | 172 | expect(upcomingContests).toBeLessThanOrEqual(totalContests); 173 | } 174 | }); 175 | }); 176 | }); 177 | -------------------------------------------------------------------------------- /tests/unit/Controllers/fetchUserDetails.test.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from 'express'; 2 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 3 | import fetchUserDetails from '../../../src/Controllers/fetchUserDetails'; 4 | 5 | describe('fetchUserDetails', () => { 6 | let mockRes: Partial; 7 | let jsonSpy: ReturnType; 8 | let sendSpy: ReturnType; 9 | 10 | beforeEach(() => { 11 | jsonSpy = vi.fn(); 12 | sendSpy = vi.fn(); 13 | mockRes = { 14 | json: jsonSpy, 15 | send: sendSpy, 16 | }; 17 | }); 18 | 19 | it('should fetch user details successfully and return formatted data', async () => { 20 | const mockData = { 21 | data: { 22 | matchedUser: { 23 | username: 'testuser', 24 | profile: { realName: 'Test User' }, 25 | }, 26 | }, 27 | }; 28 | 29 | global.fetch = vi.fn().mockResolvedValue({ 30 | json: vi.fn().mockResolvedValue(mockData), 31 | }); 32 | 33 | const formatData = vi.fn((data: never) => ({ 34 | username: data.matchedUser.username, 35 | name: data.matchedUser.profile.realName, 36 | })); 37 | 38 | await fetchUserDetails( 39 | { username: 'testuser', limit: 20, year: 2024 }, 40 | mockRes as Response, 41 | 'query { matchedUser }', 42 | formatData, 43 | ); 44 | 45 | expect(global.fetch).toHaveBeenCalledWith('https://leetcode.com/graphql', { 46 | method: 'POST', 47 | headers: { 48 | 'Content-Type': 'application/json', 49 | Referer: 'https://leetcode.com', 50 | }, 51 | body: JSON.stringify({ 52 | query: 'query { matchedUser }', 53 | variables: { 54 | username: 'testuser', 55 | limit: 20, 56 | year: 2024, 57 | }, 58 | }), 59 | }); 60 | 61 | expect(formatData).toHaveBeenCalledWith(mockData.data); 62 | expect(jsonSpy).toHaveBeenCalledWith({ 63 | username: 'testuser', 64 | name: 'Test User', 65 | }); 66 | }); 67 | 68 | it('should return raw data when formatData is not provided', async () => { 69 | const mockData = { 70 | data: { 71 | matchedUser: { 72 | username: 'testuser', 73 | }, 74 | }, 75 | }; 76 | 77 | global.fetch = vi.fn().mockResolvedValue({ 78 | json: vi.fn().mockResolvedValue(mockData), 79 | }); 80 | 81 | await fetchUserDetails( 82 | { username: 'testuser', limit: 20, year: 2024 }, 83 | mockRes as Response, 84 | 'query { matchedUser }', 85 | ); 86 | 87 | expect(jsonSpy).toHaveBeenCalledWith(mockData.data); 88 | }); 89 | 90 | it('should handle GraphQL errors from LeetCode API', async () => { 91 | const mockErrorResponse = { 92 | errors: [ 93 | { 94 | message: 'User not found', 95 | locations: [{ line: 1, column: 1 }], 96 | }, 97 | ], 98 | }; 99 | 100 | global.fetch = vi.fn().mockResolvedValue({ 101 | json: vi.fn().mockResolvedValue(mockErrorResponse), 102 | }); 103 | 104 | await fetchUserDetails( 105 | { username: 'nonexistent', limit: 20, year: 2024 }, 106 | mockRes as Response, 107 | 'query { matchedUser }', 108 | ); 109 | 110 | expect(sendSpy).toHaveBeenCalledWith(mockErrorResponse); 111 | expect(jsonSpy).not.toHaveBeenCalled(); 112 | }); 113 | 114 | it('should handle network errors', async () => { 115 | const networkError = new Error('Network error'); 116 | global.fetch = vi.fn().mockRejectedValue(networkError); 117 | 118 | await fetchUserDetails( 119 | { username: 'testuser', limit: 20, year: 2024 }, 120 | mockRes as Response, 121 | 'query { matchedUser }', 122 | ); 123 | 124 | expect(sendSpy).toHaveBeenCalledWith('Network error'); 125 | }); 126 | 127 | it('should handle null formatData explicitly', async () => { 128 | const mockData = { 129 | data: { 130 | matchedUser: { 131 | username: 'testuser', 132 | }, 133 | }, 134 | }; 135 | 136 | global.fetch = vi.fn().mockResolvedValue({ 137 | json: vi.fn().mockResolvedValue(mockData), 138 | }); 139 | 140 | await fetchUserDetails( 141 | { username: 'testuser', limit: 20, year: 2024 }, 142 | mockRes as Response, 143 | 'query { matchedUser }', 144 | undefined, 145 | ); 146 | 147 | expect(jsonSpy).toHaveBeenCalledWith(mockData.data); 148 | }); 149 | 150 | it('should handle empty response data', async () => { 151 | const mockData = { data: {} }; 152 | 153 | global.fetch = vi.fn().mockResolvedValue({ 154 | json: vi.fn().mockResolvedValue(mockData), 155 | }); 156 | 157 | const formatData = vi.fn((data: never) => data); 158 | 159 | await fetchUserDetails( 160 | { username: 'testuser', limit: 20, year: 2024 }, 161 | mockRes as Response, 162 | 'query { matchedUser }', 163 | formatData, 164 | ); 165 | 166 | expect(formatData).toHaveBeenCalledWith({}); 167 | expect(jsonSpy).toHaveBeenCalledWith({}); 168 | }); 169 | 170 | it('should pass correct parameters for submission queries', async () => { 171 | const mockData = { data: { recentSubmissions: [] } }; 172 | 173 | global.fetch = vi.fn().mockResolvedValue({ 174 | json: vi.fn().mockResolvedValue(mockData), 175 | }); 176 | 177 | await fetchUserDetails( 178 | { username: 'testuser', limit: 50, year: 2024 }, 179 | mockRes as Response, 180 | 'query { recentSubmissions }', 181 | ); 182 | 183 | expect(global.fetch).toHaveBeenCalledWith( 184 | 'https://leetcode.com/graphql', 185 | expect.objectContaining({ 186 | body: JSON.stringify({ 187 | query: 'query { recentSubmissions }', 188 | variables: { 189 | username: 'testuser', 190 | limit: 50, 191 | year: 2024, 192 | }, 193 | }), 194 | }), 195 | ); 196 | }); 197 | 198 | it('should pass correct year parameter for calendar queries', async () => { 199 | const mockData = { data: { calendar: {} } }; 200 | 201 | global.fetch = vi.fn().mockResolvedValue({ 202 | json: vi.fn().mockResolvedValue(mockData), 203 | }); 204 | 205 | await fetchUserDetails( 206 | { username: 'testuser', limit: 20, year: 2023 }, 207 | mockRes as Response, 208 | 'query { calendar }', 209 | ); 210 | 211 | expect(global.fetch).toHaveBeenCalledWith( 212 | 'https://leetcode.com/graphql', 213 | expect.objectContaining({ 214 | body: JSON.stringify({ 215 | query: 'query { calendar }', 216 | variables: { 217 | username: 'testuser', 218 | limit: 20, 219 | year: 2023, 220 | }, 221 | }), 222 | }), 223 | ); 224 | }); 225 | }); 226 | -------------------------------------------------------------------------------- /tests/unit/Controllers/handleRequest.test.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from 'express'; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 3 | import handleRequest from '../../../src/Controllers/handleRequest'; 4 | 5 | describe('handleRequest', () => { 6 | let mockRes: Partial; 7 | let jsonSpy: ReturnType; 8 | let sendSpy: ReturnType; 9 | 10 | beforeEach(() => { 11 | jsonSpy = vi.fn(); 12 | sendSpy = vi.fn(); 13 | mockRes = { 14 | json: jsonSpy, 15 | send: sendSpy, 16 | }; 17 | }); 18 | 19 | afterEach(() => { 20 | vi.restoreAllMocks(); 21 | }); 22 | 23 | it('should handle successful GraphQL request', async () => { 24 | const mockData = { 25 | data: { 26 | user: { 27 | username: 'testuser', 28 | profile: { realName: 'Test User' }, 29 | }, 30 | }, 31 | }; 32 | 33 | global.fetch = vi.fn().mockResolvedValue({ 34 | ok: true, 35 | json: vi.fn().mockResolvedValue(mockData), 36 | }); 37 | 38 | const params = { username: 'testuser' }; 39 | 40 | await handleRequest(mockRes as Response, 'query { user }', params); 41 | 42 | expect(global.fetch).toHaveBeenCalledWith('https://leetcode.com/graphql', { 43 | method: 'POST', 44 | headers: { 45 | 'Content-Type': 'application/json', 46 | Referer: 'https://leetcode.com', 47 | }, 48 | body: JSON.stringify({ 49 | query: 'query { user }', 50 | variables: params, 51 | }), 52 | }); 53 | 54 | expect(jsonSpy).toHaveBeenCalledWith(mockData.data); 55 | }); 56 | 57 | it('should handle GraphQL errors from LeetCode API', async () => { 58 | const mockErrorResponse = { 59 | errors: [ 60 | { 61 | message: 'Invalid query', 62 | extensions: { code: 'BAD_REQUEST' }, 63 | }, 64 | ], 65 | }; 66 | 67 | global.fetch = vi.fn().mockResolvedValue({ 68 | ok: true, 69 | json: vi.fn().mockResolvedValue(mockErrorResponse), 70 | }); 71 | 72 | await handleRequest(mockRes as Response, 'query { invalid }', {}); 73 | 74 | expect(sendSpy).toHaveBeenCalledWith(mockErrorResponse); 75 | expect(jsonSpy).not.toHaveBeenCalled(); 76 | }); 77 | 78 | it('should log HTTP errors when response is not ok', async () => { 79 | const mockData = { 80 | data: { 81 | result: 'some data', 82 | }, 83 | }; 84 | 85 | global.fetch = vi.fn().mockResolvedValue({ 86 | ok: false, 87 | status: 500, 88 | json: vi.fn().mockResolvedValue(mockData), 89 | }); 90 | 91 | const consoleErrorSpy = vi 92 | .spyOn(console, 'error') 93 | .mockImplementation(() => {}); 94 | 95 | await handleRequest(mockRes as Response, 'query { data }', {}); 96 | 97 | expect(consoleErrorSpy).toHaveBeenCalledWith('HTTP error! status: 500'); 98 | expect(jsonSpy).toHaveBeenCalledWith(mockData.data); 99 | }); 100 | 101 | it('should handle network errors', async () => { 102 | const networkError = new Error('Network error'); 103 | global.fetch = vi.fn().mockRejectedValue(networkError); 104 | 105 | const consoleErrorSpy = vi 106 | .spyOn(console, 'error') 107 | .mockImplementation(() => {}); 108 | 109 | await handleRequest(mockRes as Response, 'query { data }', {}); 110 | 111 | expect(consoleErrorSpy).toHaveBeenCalledWith('Error: ', networkError); 112 | expect(sendSpy).toHaveBeenCalledWith(networkError); 113 | }); 114 | 115 | it('should handle empty parameters', async () => { 116 | const mockData = { 117 | data: { 118 | globalData: 'some value', 119 | }, 120 | }; 121 | 122 | global.fetch = vi.fn().mockResolvedValue({ 123 | ok: true, 124 | json: vi.fn().mockResolvedValue(mockData), 125 | }); 126 | 127 | await handleRequest(mockRes as Response, 'query { globalData }', {}); 128 | 129 | expect(global.fetch).toHaveBeenCalledWith( 130 | 'https://leetcode.com/graphql', 131 | expect.objectContaining({ 132 | body: JSON.stringify({ 133 | query: 'query { globalData }', 134 | variables: {}, 135 | }), 136 | }), 137 | ); 138 | 139 | expect(jsonSpy).toHaveBeenCalledWith(mockData.data); 140 | }); 141 | 142 | it('should handle null parameters', async () => { 143 | const mockData = { 144 | data: { 145 | result: 'data', 146 | }, 147 | }; 148 | 149 | global.fetch = vi.fn().mockResolvedValue({ 150 | ok: true, 151 | json: vi.fn().mockResolvedValue(mockData), 152 | }); 153 | 154 | await handleRequest(mockRes as Response, 'query { result }', null); 155 | 156 | expect(global.fetch).toHaveBeenCalledWith( 157 | 'https://leetcode.com/graphql', 158 | expect.objectContaining({ 159 | body: JSON.stringify({ 160 | query: 'query { result }', 161 | variables: null, 162 | }), 163 | }), 164 | ); 165 | }); 166 | 167 | it('should handle complex parameters', async () => { 168 | const mockData = { 169 | data: { 170 | problems: [], 171 | }, 172 | }; 173 | 174 | global.fetch = vi.fn().mockResolvedValue({ 175 | ok: true, 176 | json: vi.fn().mockResolvedValue(mockData), 177 | }); 178 | 179 | const complexParams = { 180 | categorySlug: 'algorithms', 181 | skip: 10, 182 | limit: 50, 183 | filters: { 184 | tags: ['array', 'string'], 185 | difficulty: 'MEDIUM', 186 | }, 187 | }; 188 | 189 | await handleRequest( 190 | mockRes as Response, 191 | 'query { problems }', 192 | complexParams, 193 | ); 194 | 195 | expect(global.fetch).toHaveBeenCalledWith( 196 | 'https://leetcode.com/graphql', 197 | expect.objectContaining({ 198 | body: JSON.stringify({ 199 | query: 'query { problems }', 200 | variables: complexParams, 201 | }), 202 | }), 203 | ); 204 | 205 | expect(jsonSpy).toHaveBeenCalledWith(mockData.data); 206 | }); 207 | 208 | it('should handle undefined response data', async () => { 209 | const mockData = { 210 | data: undefined, 211 | }; 212 | 213 | global.fetch = vi.fn().mockResolvedValue({ 214 | ok: true, 215 | json: vi.fn().mockResolvedValue(mockData), 216 | }); 217 | 218 | await handleRequest(mockRes as Response, 'query { data }', {}); 219 | 220 | expect(jsonSpy).toHaveBeenCalledWith(undefined); 221 | }); 222 | 223 | it('should pass correct referer header', async () => { 224 | const mockData = { 225 | data: { test: 'data' }, 226 | }; 227 | 228 | global.fetch = vi.fn().mockResolvedValue({ 229 | ok: true, 230 | json: vi.fn().mockResolvedValue(mockData), 231 | }); 232 | 233 | await handleRequest(mockRes as Response, 'query { test }', {}); 234 | 235 | expect(global.fetch).toHaveBeenCalledWith( 236 | 'https://leetcode.com/graphql', 237 | expect.objectContaining({ 238 | headers: { 239 | 'Content-Type': 'application/json', 240 | Referer: 'https://leetcode.com', 241 | }, 242 | }), 243 | ); 244 | }); 245 | }); 246 | -------------------------------------------------------------------------------- /tests/unit/Controllers/fetchSingleProblem.test.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from 'express'; 2 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 3 | import fetchSingleProblem from '../../../src/Controllers/fetchSingleProblem'; 4 | 5 | describe('fetchSingleProblem', () => { 6 | let mockRes: Partial; 7 | let jsonSpy: ReturnType; 8 | let sendSpy: ReturnType; 9 | 10 | beforeEach(() => { 11 | jsonSpy = vi.fn(); 12 | sendSpy = vi.fn(); 13 | mockRes = { 14 | json: jsonSpy, 15 | send: sendSpy, 16 | }; 17 | }); 18 | 19 | it('should fetch daily problem successfully without formatData', async () => { 20 | const mockData = { 21 | data: { 22 | activeDailyCodingChallengeQuestion: { 23 | question: { 24 | titleSlug: 'two-sum', 25 | title: 'Two Sum', 26 | }, 27 | }, 28 | }, 29 | }; 30 | 31 | global.fetch = vi.fn().mockResolvedValue({ 32 | json: vi.fn().mockResolvedValue(mockData), 33 | }); 34 | 35 | await fetchSingleProblem( 36 | mockRes as Response, 37 | 'query { activeDailyCodingChallengeQuestion }', 38 | null, 39 | ); 40 | 41 | expect(global.fetch).toHaveBeenCalledWith('https://leetcode.com/graphql', { 42 | method: 'POST', 43 | headers: { 44 | 'Content-Type': 'application/json', 45 | Referer: 'https://leetcode.com', 46 | }, 47 | body: JSON.stringify({ 48 | query: 'query { activeDailyCodingChallengeQuestion }', 49 | variables: { 50 | titleSlug: null, 51 | }, 52 | }), 53 | }); 54 | 55 | expect(jsonSpy).toHaveBeenCalledWith(mockData.data); 56 | }); 57 | 58 | it('should fetch selected problem with titleSlug', async () => { 59 | const mockData = { 60 | data: { 61 | question: { 62 | questionId: '1', 63 | titleSlug: 'two-sum', 64 | title: 'Two Sum', 65 | difficulty: 'Easy', 66 | }, 67 | }, 68 | }; 69 | 70 | global.fetch = vi.fn().mockResolvedValue({ 71 | json: vi.fn().mockResolvedValue(mockData), 72 | }); 73 | 74 | await fetchSingleProblem( 75 | mockRes as Response, 76 | 'query { question }', 77 | 'two-sum', 78 | ); 79 | 80 | expect(global.fetch).toHaveBeenCalledWith( 81 | 'https://leetcode.com/graphql', 82 | expect.objectContaining({ 83 | body: JSON.stringify({ 84 | query: 'query { question }', 85 | variables: { 86 | titleSlug: 'two-sum', 87 | }, 88 | }), 89 | }), 90 | ); 91 | 92 | expect(jsonSpy).toHaveBeenCalledWith(mockData.data); 93 | }); 94 | 95 | it('should apply formatData transformation when provided', async () => { 96 | const mockData = { 97 | data: { 98 | question: { 99 | questionId: '1', 100 | titleSlug: 'two-sum', 101 | title: 'Two Sum', 102 | }, 103 | }, 104 | }; 105 | 106 | global.fetch = vi.fn().mockResolvedValue({ 107 | json: vi.fn().mockResolvedValue(mockData), 108 | }); 109 | 110 | const formatData = vi.fn((data: never) => ({ 111 | id: data.question.questionId, 112 | slug: data.question.titleSlug, 113 | })); 114 | 115 | await fetchSingleProblem( 116 | mockRes as Response, 117 | 'query { question }', 118 | 'two-sum', 119 | formatData, 120 | ); 121 | 122 | expect(formatData).toHaveBeenCalledWith(mockData.data); 123 | expect(jsonSpy).toHaveBeenCalledWith({ 124 | id: '1', 125 | slug: 'two-sum', 126 | }); 127 | }); 128 | 129 | it('should handle GraphQL errors from LeetCode API', async () => { 130 | const mockErrorResponse = { 131 | errors: [ 132 | { 133 | message: 'Question not found', 134 | extensions: { code: 'NOT_FOUND' }, 135 | }, 136 | ], 137 | }; 138 | 139 | global.fetch = vi.fn().mockResolvedValue({ 140 | json: vi.fn().mockResolvedValue(mockErrorResponse), 141 | }); 142 | 143 | await fetchSingleProblem( 144 | mockRes as Response, 145 | 'query { question }', 146 | 'non-existent-problem', 147 | ); 148 | 149 | expect(sendSpy).toHaveBeenCalledWith(mockErrorResponse); 150 | expect(jsonSpy).not.toHaveBeenCalled(); 151 | }); 152 | 153 | it('should handle null titleSlug for daily problem', async () => { 154 | const mockData = { 155 | data: { 156 | activeDailyCodingChallengeQuestion: { 157 | question: { 158 | titleSlug: 'daily-problem', 159 | }, 160 | }, 161 | }, 162 | }; 163 | 164 | global.fetch = vi.fn().mockResolvedValue({ 165 | json: vi.fn().mockResolvedValue(mockData), 166 | }); 167 | 168 | await fetchSingleProblem( 169 | mockRes as Response, 170 | 'query { activeDailyCodingChallengeQuestion }', 171 | null, 172 | ); 173 | 174 | expect(global.fetch).toHaveBeenCalledWith( 175 | 'https://leetcode.com/graphql', 176 | expect.objectContaining({ 177 | body: JSON.stringify({ 178 | query: 'query { activeDailyCodingChallengeQuestion }', 179 | variables: { 180 | titleSlug: null, 181 | }, 182 | }), 183 | }), 184 | ); 185 | }); 186 | 187 | it('should handle undefined formatData explicitly', async () => { 188 | const mockData = { 189 | data: { 190 | question: { 191 | titleSlug: 'two-sum', 192 | }, 193 | }, 194 | }; 195 | 196 | global.fetch = vi.fn().mockResolvedValue({ 197 | json: vi.fn().mockResolvedValue(mockData), 198 | }); 199 | 200 | await fetchSingleProblem( 201 | mockRes as Response, 202 | 'query { question }', 203 | 'two-sum', 204 | undefined, 205 | ); 206 | 207 | expect(jsonSpy).toHaveBeenCalledWith(mockData.data); 208 | }); 209 | 210 | it('should handle empty response data', async () => { 211 | const mockData = { data: {} }; 212 | 213 | global.fetch = vi.fn().mockResolvedValue({ 214 | json: vi.fn().mockResolvedValue(mockData), 215 | }); 216 | 217 | await fetchSingleProblem( 218 | mockRes as Response, 219 | 'query { question }', 220 | 'empty-problem', 221 | ); 222 | 223 | expect(jsonSpy).toHaveBeenCalledWith({}); 224 | }); 225 | 226 | it('should handle titleSlug with special characters', async () => { 227 | const mockData = { 228 | data: { 229 | question: { 230 | titleSlug: 'problem-with-special-chars', 231 | }, 232 | }, 233 | }; 234 | 235 | global.fetch = vi.fn().mockResolvedValue({ 236 | json: vi.fn().mockResolvedValue(mockData), 237 | }); 238 | 239 | await fetchSingleProblem( 240 | mockRes as Response, 241 | 'query { question }', 242 | 'problem-with-special-chars', 243 | ); 244 | 245 | expect(global.fetch).toHaveBeenCalledWith( 246 | 'https://leetcode.com/graphql', 247 | expect.objectContaining({ 248 | body: JSON.stringify({ 249 | query: 'query { question }', 250 | variables: { 251 | titleSlug: 'problem-with-special-chars', 252 | }, 253 | }), 254 | }), 255 | ); 256 | }); 257 | }); 258 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import apicache from 'apicache'; 2 | import cors from 'cors'; 3 | import express, { type NextFunction, type Response } from 'express'; 4 | import rateLimit from 'express-rate-limit'; 5 | import * as leetcode from './leetCode'; 6 | import type { FetchUserDataRequest } from './types'; 7 | 8 | const app = express(); 9 | const cache = apicache.middleware; 10 | 11 | const limiter = rateLimit({ 12 | windowMs: 60 * 60 * 1000, // 1 hour 13 | limit: 120, 14 | standardHeaders: 'draft-7', 15 | legacyHeaders: false, 16 | message: 'Too many request from this IP, try again in 1 hour', 17 | }); 18 | 19 | app.use(cache('5 minutes')); 20 | app.use(cors()); //enable all CORS request 21 | app.use(limiter); //limit to all API 22 | app.use((req: express.Request, _res: Response, next: NextFunction) => { 23 | console.log('Requested URL:', req.originalUrl); 24 | next(); 25 | }); 26 | 27 | app.get('/', (_req, res) => { 28 | res.json({ 29 | apiOverview: 30 | 'Welcome to the Alfa-Leetcode-API! Alfa-Leetcode-Api is a custom solution born out of the need for a well-documented and detailed LeetCode API. This project is designed to provide developers with endpoints that offer insights into a user"s profile, badges, solved questions, contest details, contest history, submissions, and also daily questions, selected problem, list of problems.', 31 | apiEndpointsLink: 32 | 'https://github.com/alfaarghya/alfa-leetcode-api?tab=readme-ov-file#endpoints-', 33 | routes: { 34 | userDetails: { 35 | description: 36 | 'Endpoints for retrieving detailed user profile information on Leetcode.', 37 | Method: 'GET', 38 | '/:username': 'Get your leetcode profile Details', 39 | '/:username/profile': 'Get full profile details', 40 | '/:username/badges': 'Get your badges', 41 | '/:username/solved': 'Get total number of question you solved', 42 | '/:username/contest': 'Get your contest details', 43 | '/:username/contest/history': 'Get all contest history', 44 | '/:username/submission': 'Get your last 20 submission', 45 | '/:username/submission?limit=7': 46 | 'Get a specified number of last submissions.', 47 | '/:username/acSubmission': 'Get your last 20 accepted submission', 48 | '/:username/acSubmission?limit=7': 49 | 'Get a specified number of last acSubmissions.', 50 | '/:username/calendar': 'Get your submission calendar', 51 | '/:username/calendar?year=2025': 'Get your year submission calendar', 52 | '/:username/skill': 'Get your skill stats', 53 | '/:username/language': 'Get your language stats', 54 | '/:username/progress': 'Get your progress stats', 55 | }, 56 | discussion: { 57 | description: 'Endpoints for fetching discussion topics and comments.', 58 | Method: 'GET', 59 | '/trendingDiscuss?first=20': 'Get top 20 trending discussions', 60 | '/discussTopic/:topicId': 'Get discussion topic', 61 | '/discussComments/:topicId': 'Get discussion comments', 62 | }, 63 | problems: { 64 | description: 65 | 'Endpoints for fetching problem-related data, including lists, details, and solutions.', 66 | Method: 'GET', 67 | singleProblem: { 68 | '/select?titleSlug=two-sum': 'Get selected Problem', 69 | '/select/raw?titleSlug=two-sum': 'Get raw selected Problem', 70 | '/daily': 'Get daily Problem', 71 | '/daily/raw': 'Get raw daily Problem', 72 | }, 73 | problemList: { 74 | '/problems': 'Get list of 20 problems', 75 | '/problems?limit=50': 'Get list of some problems', 76 | '/problems?tags=array+math': 'Get list problems on selected topics', 77 | '/problems?tags=array+math+string&limit=5': 78 | 'Get list some problems on selected topics', 79 | '/problems?skip=500': 80 | 'Get list after skipping a given amount of problems', 81 | '/problems?difficulty=EASY': 82 | 'Get list of problems having selected difficulty', 83 | '/problems?limit=5&skip=100': 84 | 'Get list of size limit after skipping selected amount', 85 | 'problems?tags=array+maths&limit=5&skip=100': 86 | 'Get list of problems with selected tags having size limit after skipping selected amount', 87 | '/officialSolution?titleSlug=two-sum': 88 | 'Get official solution of selected problem', 89 | }, 90 | }, 91 | }, 92 | }); 93 | }); 94 | 95 | //get trending Discuss 96 | app.get('/trendingDiscuss', leetcode.trendingCategoryTopics); 97 | 98 | //get discuss topic 99 | app.get('/discussTopic/:topicId', leetcode.discussTopic); 100 | 101 | //get discuss comments 102 | app.get('/discussComments/:topicId', leetcode.discussComments); 103 | 104 | //get the daily leetCode problem 105 | app.get('/daily', leetcode.dailyProblem); 106 | app.get('/daily/raw', leetcode.dailyProblemRaw); 107 | 108 | //get the selected question 109 | app.get('/select', leetcode.selectProblem); 110 | app.get('/select/raw', leetcode.selectProblemRaw); 111 | 112 | //get official solution 113 | app.get('/officialSolution', leetcode.officialSolution); 114 | 115 | //get list of problems 116 | app.get('/problems', leetcode.problems); 117 | 118 | //get contests 119 | app.get('/contests', leetcode.allContests); 120 | app.get('/contests/upcoming', leetcode.upcomingContests); 121 | 122 | // Construct options object on all user routes. 123 | app.use( 124 | '/:username*', 125 | (req: FetchUserDataRequest, _res: Response, next: NextFunction) => { 126 | req.body = { 127 | username: req.params.username, 128 | limit: req.query.limit ? parseInt(req.query.limit as string, 10) : 20, 129 | year: req.query.year ? parseInt(req.query.year as string, 10) : 0, 130 | }; 131 | next(); 132 | }, 133 | ); 134 | 135 | //get user profile details 136 | app.get('/:username', leetcode.userData); 137 | app.get('/:username/badges', leetcode.userBadges); 138 | app.get('/:username/solved', leetcode.solvedProblem); 139 | app.get('/:username/contest', leetcode.userContest); 140 | app.get('/:username/contest/history', leetcode.userContestHistory); 141 | app.get('/:username/submission', leetcode.submission); 142 | app.get('/:username/acSubmission', leetcode.acSubmission); 143 | app.get('/:username/calendar', leetcode.calendar); 144 | app.get('/:username/skill/', leetcode.skillStats); 145 | app.get('/:username/profile/', leetcode.userProfile); 146 | app.get('/:username/language', leetcode.languageStats); 147 | app.get('/:username/progress/', leetcode.progress); 148 | 149 | /* ----- Migrated to new routes -> these will be deleted -----*/ 150 | //get user profile calendar 151 | // app.get('/userProfileCalendar', leetcode.userProfileCalendar_); 152 | 153 | //get user profile details 154 | app.get('/userProfile/:id', leetcode.userProfile_); 155 | 156 | //get daily question 157 | app.get('/dailyQuestion', leetcode.dailyQuestion_); 158 | 159 | // get the selection question raw 160 | app.get('/selectQuestion', leetcode.selectProblemRaw); 161 | 162 | //get skill stats 163 | app.get('/skillStats/:username', leetcode.skillStats_); 164 | 165 | //get user profile question progress 166 | app.get( 167 | '/userProfileUserQuestionProgressV2/:userSlug', 168 | leetcode.userProfileUserQuestionProgressV2_, 169 | ); 170 | 171 | app.get('/languageStats', leetcode.languageStats_); 172 | 173 | //get user contest ranking info 174 | app.get('/userContestRankingInfo/:username', leetcode.userContestRankingInfo_); 175 | 176 | export default app; 177 | -------------------------------------------------------------------------------- /tests/unit/Controllers/fetchDataRawFormat.test.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from 'express'; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 3 | import fetchDataRawFormat from '../../../src/Controllers/fetchDataRawFormat'; 4 | 5 | describe('fetchDataRawFormat', () => { 6 | let mockRes: Partial; 7 | let jsonSpy: ReturnType; 8 | let sendSpy: ReturnType; 9 | 10 | beforeEach(() => { 11 | jsonSpy = vi.fn(); 12 | sendSpy = vi.fn(); 13 | mockRes = { 14 | json: jsonSpy, 15 | send: sendSpy, 16 | }; 17 | }); 18 | 19 | afterEach(() => { 20 | vi.restoreAllMocks(); 21 | }); 22 | 23 | it('should fetch data in raw format successfully', async () => { 24 | const mockData = { 25 | data: { 26 | matchedUser: { 27 | username: 'testuser', 28 | profile: { 29 | realName: 'Test User', 30 | ranking: 12345, 31 | }, 32 | submitStats: { 33 | acSubmissionNum: [{ difficulty: 'All', count: 500 }], 34 | }, 35 | }, 36 | }, 37 | }; 38 | 39 | global.fetch = vi.fn().mockResolvedValue({ 40 | ok: true, 41 | json: vi.fn().mockResolvedValue(mockData), 42 | }); 43 | 44 | await fetchDataRawFormat( 45 | { username: 'testuser' }, 46 | mockRes as Response, 47 | 'query { matchedUser }', 48 | ); 49 | 50 | expect(global.fetch).toHaveBeenCalledWith('https://leetcode.com/graphql', { 51 | method: 'POST', 52 | headers: { 53 | 'Content-Type': 'application/json', 54 | Referer: 'https://leetcode.com', 55 | }, 56 | body: JSON.stringify({ 57 | query: 'query { matchedUser }', 58 | variables: { 59 | username: 'testuser', 60 | }, 61 | }), 62 | }); 63 | 64 | expect(jsonSpy).toHaveBeenCalledWith(mockData.data); 65 | }); 66 | 67 | it('should handle GraphQL errors from LeetCode API', async () => { 68 | const mockErrorResponse = { 69 | errors: [ 70 | { 71 | message: 'User not found', 72 | extensions: { code: 'NOT_FOUND' }, 73 | }, 74 | ], 75 | }; 76 | 77 | global.fetch = vi.fn().mockResolvedValue({ 78 | ok: true, 79 | json: vi.fn().mockResolvedValue(mockErrorResponse), 80 | }); 81 | 82 | await fetchDataRawFormat( 83 | { username: 'nonexistent' }, 84 | mockRes as Response, 85 | 'query { matchedUser }', 86 | ); 87 | 88 | expect(sendSpy).toHaveBeenCalledWith(mockErrorResponse); 89 | expect(jsonSpy).not.toHaveBeenCalled(); 90 | }); 91 | 92 | it('should log HTTP errors when response is not ok', async () => { 93 | const mockData = { 94 | data: { 95 | matchedUser: { 96 | username: 'testuser', 97 | }, 98 | }, 99 | }; 100 | 101 | global.fetch = vi.fn().mockResolvedValue({ 102 | ok: false, 103 | status: 500, 104 | json: vi.fn().mockResolvedValue(mockData), 105 | }); 106 | 107 | const consoleErrorSpy = vi 108 | .spyOn(console, 'error') 109 | .mockImplementation(() => {}); 110 | 111 | await fetchDataRawFormat( 112 | { username: 'testuser' }, 113 | mockRes as Response, 114 | 'query { matchedUser }', 115 | ); 116 | 117 | expect(consoleErrorSpy).toHaveBeenCalledWith('HTTP error! status: 500'); 118 | expect(jsonSpy).toHaveBeenCalledWith(mockData.data); 119 | }); 120 | 121 | it('should handle network errors', async () => { 122 | const networkError = new Error('Network error'); 123 | global.fetch = vi.fn().mockRejectedValue(networkError); 124 | 125 | const consoleErrorSpy = vi 126 | .spyOn(console, 'error') 127 | .mockImplementation(() => {}); 128 | 129 | await fetchDataRawFormat( 130 | { username: 'testuser' }, 131 | mockRes as Response, 132 | 'query { matchedUser }', 133 | ); 134 | 135 | expect(consoleErrorSpy).toHaveBeenCalledWith('Error: ', networkError); 136 | expect(sendSpy).toHaveBeenCalledWith(networkError); 137 | }); 138 | 139 | it('should return raw data without any formatting', async () => { 140 | const mockData = { 141 | data: { 142 | complexObject: { 143 | nested: { 144 | deeply: { 145 | value: 'test', 146 | array: [1, 2, 3], 147 | object: { key: 'value' }, 148 | }, 149 | }, 150 | }, 151 | }, 152 | }; 153 | 154 | global.fetch = vi.fn().mockResolvedValue({ 155 | ok: true, 156 | json: vi.fn().mockResolvedValue(mockData), 157 | }); 158 | 159 | await fetchDataRawFormat( 160 | { username: 'testuser' }, 161 | mockRes as Response, 162 | 'query { complexObject }', 163 | ); 164 | 165 | expect(jsonSpy).toHaveBeenCalledWith(mockData.data); 166 | }); 167 | 168 | it('should handle empty response data', async () => { 169 | const mockData = { 170 | data: {}, 171 | }; 172 | 173 | global.fetch = vi.fn().mockResolvedValue({ 174 | ok: true, 175 | json: vi.fn().mockResolvedValue(mockData), 176 | }); 177 | 178 | await fetchDataRawFormat( 179 | { username: 'testuser' }, 180 | mockRes as Response, 181 | 'query { matchedUser }', 182 | ); 183 | 184 | expect(jsonSpy).toHaveBeenCalledWith({}); 185 | }); 186 | 187 | it('should handle username with special characters', async () => { 188 | const mockData = { 189 | data: { 190 | matchedUser: { 191 | username: 'test-user_123', 192 | }, 193 | }, 194 | }; 195 | 196 | global.fetch = vi.fn().mockResolvedValue({ 197 | ok: true, 198 | json: vi.fn().mockResolvedValue(mockData), 199 | }); 200 | 201 | await fetchDataRawFormat( 202 | { username: 'test-user_123' }, 203 | mockRes as Response, 204 | 'query { matchedUser }', 205 | ); 206 | 207 | expect(global.fetch).toHaveBeenCalledWith( 208 | 'https://leetcode.com/graphql', 209 | expect.objectContaining({ 210 | body: JSON.stringify({ 211 | query: 'query { matchedUser }', 212 | variables: { 213 | username: 'test-user_123', 214 | }, 215 | }), 216 | }), 217 | ); 218 | 219 | expect(jsonSpy).toHaveBeenCalledWith(mockData.data); 220 | }); 221 | 222 | it('should pass correct referer header', async () => { 223 | const mockData = { 224 | data: { 225 | matchedUser: {}, 226 | }, 227 | }; 228 | 229 | global.fetch = vi.fn().mockResolvedValue({ 230 | ok: true, 231 | json: vi.fn().mockResolvedValue(mockData), 232 | }); 233 | 234 | await fetchDataRawFormat( 235 | { username: 'testuser' }, 236 | mockRes as Response, 237 | 'query { matchedUser }', 238 | ); 239 | 240 | expect(global.fetch).toHaveBeenCalledWith( 241 | 'https://leetcode.com/graphql', 242 | expect.objectContaining({ 243 | headers: { 244 | 'Content-Type': 'application/json', 245 | Referer: 'https://leetcode.com', 246 | }, 247 | }), 248 | ); 249 | }); 250 | 251 | it('should handle null data in response', async () => { 252 | const mockData = { 253 | data: null, 254 | }; 255 | 256 | global.fetch = vi.fn().mockResolvedValue({ 257 | ok: true, 258 | json: vi.fn().mockResolvedValue(mockData), 259 | }); 260 | 261 | await fetchDataRawFormat( 262 | { username: 'testuser' }, 263 | mockRes as Response, 264 | 'query { matchedUser }', 265 | ); 266 | 267 | expect(jsonSpy).toHaveBeenCalledWith(null); 268 | }); 269 | }); 270 | -------------------------------------------------------------------------------- /tests/integration/discussion-routes.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; 3 | import app from '../../src/app'; 4 | import { server } from '../msw/server'; 5 | 6 | describe('Discussion Routes Integration Tests', () => { 7 | beforeAll(() => server.listen()); 8 | afterEach(() => server.resetHandlers()); 9 | afterAll(() => server.close()); 10 | 11 | describe('GET /trendingDiscuss', () => { 12 | it('should handle first=1 parameter', async () => { 13 | const response = await request(app).get('/trendingDiscuss?first=1'); 14 | 15 | expect(response.status).toBe(200); 16 | }); 17 | 18 | it('should handle large first parameter', async () => { 19 | const response = await request(app).get('/trendingDiscuss?first=50'); 20 | 21 | expect(response.status).toBe(200); 22 | }); 23 | 24 | it('should default to 20 when first=0', async () => { 25 | const response = await request(app).get('/trendingDiscuss?first=0'); 26 | 27 | expect(response.status).toBe(200); 28 | }); 29 | 30 | it('should error message', async () => { 31 | const response = await request(app).get('/trendingDiscuss'); 32 | 33 | expect(response.status).toBe(400); 34 | if (response.body.categoryTopicList) { 35 | expect(response.body.categoryTopicList).toHaveProperty('edges'); 36 | } 37 | }); 38 | 39 | it('should return array of discussion edges', async () => { 40 | const response = await request(app).get('/trendingDiscuss?first=5'); 41 | 42 | expect(response.status).toBe(200); 43 | if (response.body.categoryTopicList?.edges) { 44 | expect(Array.isArray(response.body.categoryTopicList.edges)).toBe(true); 45 | } 46 | }); 47 | }); 48 | 49 | describe('GET /discussTopic/:topicId', () => { 50 | it('should return discussion topic details', async () => { 51 | const response = await request(app).get('/discussTopic/12345'); 52 | 53 | expect(response.status).toBe(200); 54 | }); 55 | 56 | it('should handle numeric topicId', async () => { 57 | const response = await request(app).get('/discussTopic/98765'); 58 | 59 | expect(response.status).toBe(200); 60 | }); 61 | 62 | it('should handle large topicId', async () => { 63 | const response = await request(app).get('/discussTopic/999999999'); 64 | 65 | expect(response.status).toBe(200); 66 | }); 67 | 68 | it('should handle string topicId', async () => { 69 | const response = await request(app).get('/discussTopic/topic-123'); 70 | 71 | expect(response.status).toBe(200); 72 | }); 73 | 74 | it('should return topic data structure', async () => { 75 | const response = await request(app).get('/discussTopic/12345'); 76 | 77 | expect(response.status).toBe(200); 78 | expect(response.body).toBeDefined(); 79 | }); 80 | }); 81 | 82 | describe('GET /discussComments/:topicId', () => { 83 | it('should return discussion comments', async () => { 84 | const response = await request(app).get('/discussComments/12345'); 85 | 86 | expect(response.status).toBe(200); 87 | }); 88 | 89 | it('should handle numeric topicId for comments', async () => { 90 | const response = await request(app).get('/discussComments/54321'); 91 | 92 | expect(response.status).toBe(200); 93 | }); 94 | 95 | it('should handle large topicId for comments', async () => { 96 | const response = await request(app).get('/discussComments/888888888'); 97 | 98 | expect(response.status).toBe(200); 99 | }); 100 | 101 | it('should return comments data structure', async () => { 102 | const response = await request(app).get('/discussComments/12345'); 103 | 104 | expect(response.status).toBe(200); 105 | expect(response.body).toBeDefined(); 106 | }); 107 | }); 108 | 109 | describe('Query parameter validation', () => { 110 | it('should handle missing first parameter', async () => { 111 | const response = await request(app).get('/trendingDiscuss'); 112 | 113 | expect(response.status).toBe(400); 114 | }); 115 | 116 | it('should handle numeric string for first parameter', async () => { 117 | const response = await request(app).get('/trendingDiscuss?first=15'); 118 | 119 | expect(response.status).toBe(200); 120 | }); 121 | 122 | it('should handle invalid first parameter gracefully', async () => { 123 | const response = await request(app).get('/trendingDiscuss?first=abc'); 124 | 125 | expect(response.status).toBe(400); 126 | }); 127 | }); 128 | 129 | describe('Response format', () => { 130 | it('should return JSON format for trending discussions', async () => { 131 | const response = await request(app).get('/trendingDiscuss'); 132 | 133 | expect(response.status).toBe(400); 134 | expect(response.headers['content-type']).toMatch(/json/); 135 | }); 136 | 137 | it('should return JSON format for discussion topic', async () => { 138 | const response = await request(app).get('/discussTopic/12345'); 139 | 140 | expect(response.status).toBe(200); 141 | expect(response.headers['content-type']).toMatch(/json/); 142 | }); 143 | 144 | it('should return JSON format for discussion comments', async () => { 145 | const response = await request(app).get('/discussComments/12345'); 146 | 147 | expect(response.status).toBe(200); 148 | expect(response.headers['content-type']).toMatch(/json/); 149 | }); 150 | }); 151 | 152 | describe('Edge cases', () => { 153 | it('should handle topicId with special characters', async () => { 154 | const response = await request(app).get('/discussTopic/topic-123-test'); 155 | 156 | expect(response.status).toBe(200); 157 | }); 158 | 159 | it('should handle very long topicId', async () => { 160 | const longId = '1'.repeat(50); 161 | const response = await request(app).get(`/discussTopic/${longId}`); 162 | 163 | expect(response.status).toBe(200); 164 | }); 165 | 166 | it('should handle zero as topicId', async () => { 167 | const response = await request(app).get('/discussTopic/0'); 168 | 169 | expect(response.status).toBe(200); 170 | }); 171 | 172 | it('should handle negative topicId', async () => { 173 | const response = await request(app).get('/discussTopic/-123'); 174 | 175 | expect(response.status).toBe(200); 176 | }); 177 | }); 178 | 179 | describe('Multiple requests', () => { 180 | it('should handle multiple trending discussion requests', async () => { 181 | const response1 = await request(app).get('/trendingDiscuss?first=5'); 182 | const response2 = await request(app).get('/trendingDiscuss?first=10'); 183 | 184 | expect(response1.status).toBe(200); 185 | expect(response2.status).toBe(200); 186 | }); 187 | 188 | it('should handle topic and comments for same topicId', async () => { 189 | const topicResponse = await request(app).get('/discussTopic/12345'); 190 | const commentsResponse = await request(app).get('/discussComments/12345'); 191 | 192 | expect(topicResponse.status).toBe(200); 193 | expect(commentsResponse.status).toBe(200); 194 | }); 195 | }); 196 | 197 | describe('Error handling', () => { 198 | it('should handle non-existent topic gracefully', async () => { 199 | const response = await request(app).get('/discussTopic/9999999999'); 200 | 201 | expect(response.status).toBe(200); 202 | }); 203 | 204 | it('should handle non-existent comments gracefully', async () => { 205 | const response = await request(app).get('/discussComments/9999999999'); 206 | 207 | expect(response.status).toBe(200); 208 | }); 209 | }); 210 | }); 211 | -------------------------------------------------------------------------------- /tests/unit/FormatUtils/formatter.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { z } from 'zod'; 3 | import { withSchema } from '../../../src/FormatUtils/formatter'; 4 | 5 | describe('formatter utils', () => { 6 | describe('withSchema', () => { 7 | it('should validate and format data successfully', () => { 8 | const schema = z.object({ 9 | name: z.string(), 10 | age: z.number(), 11 | }); 12 | 13 | const formatter = (data: { name: string; age: number }) => ({ 14 | fullName: data.name, 15 | years: data.age, 16 | }); 17 | 18 | const validate = withSchema(schema, formatter); 19 | 20 | const input = { name: 'John', age: 30 }; 21 | const result = validate(input); 22 | 23 | expect(result).toEqual({ 24 | fullName: 'John', 25 | years: 30, 26 | }); 27 | }); 28 | 29 | it('should throw error for invalid data', () => { 30 | const schema = z.object({ 31 | name: z.string(), 32 | age: z.number(), 33 | }); 34 | 35 | const formatter = (data: { name: string; age: number }) => ({ 36 | fullName: data.name, 37 | years: data.age, 38 | }); 39 | 40 | const validate = withSchema(schema, formatter); 41 | 42 | const input = { name: 'John', age: 'thirty' }; 43 | 44 | expect(() => validate(input as never)).toThrow(); 45 | }); 46 | 47 | it('should work with complex nested schemas', () => { 48 | const schema = z.object({ 49 | user: z.object({ 50 | username: z.string(), 51 | profile: z.object({ 52 | age: z.number(), 53 | country: z.string(), 54 | }), 55 | }), 56 | }); 57 | 58 | const formatter = (data: never) => ({ 59 | username: data.user.username, 60 | age: data.user.profile.age, 61 | country: data.user.profile.country, 62 | }); 63 | 64 | const validate = withSchema(schema, formatter); 65 | 66 | const input = { 67 | user: { 68 | username: 'testuser', 69 | profile: { 70 | age: 25, 71 | country: 'USA', 72 | }, 73 | }, 74 | }; 75 | 76 | const result = validate(input); 77 | 78 | expect(result).toEqual({ 79 | username: 'testuser', 80 | age: 25, 81 | country: 'USA', 82 | }); 83 | }); 84 | 85 | it('should work with array schemas', () => { 86 | const schema = z.array( 87 | z.object({ 88 | id: z.number(), 89 | title: z.string(), 90 | }), 91 | ); 92 | 93 | const formatter = (data: Array<{ id: number; title: string }>) => 94 | data.map((item) => ({ 95 | identifier: item.id, 96 | name: item.title, 97 | })); 98 | 99 | const validate = withSchema(schema, formatter); 100 | 101 | const input = [ 102 | { id: 1, title: 'First' }, 103 | { id: 2, title: 'Second' }, 104 | ]; 105 | 106 | const result = validate(input); 107 | 108 | expect(result).toEqual([ 109 | { identifier: 1, name: 'First' }, 110 | { identifier: 2, name: 'Second' }, 111 | ]); 112 | }); 113 | 114 | it('should handle optional fields correctly', () => { 115 | const schema = z.object({ 116 | name: z.string(), 117 | age: z.number().optional(), 118 | }); 119 | 120 | const formatter = (data: { name: string; age?: number }) => ({ 121 | fullName: data.name, 122 | years: data.age ?? 0, 123 | }); 124 | 125 | const validate = withSchema(schema, formatter); 126 | 127 | const input1 = { name: 'John' }; 128 | const result1 = validate(input1); 129 | 130 | expect(result1).toEqual({ 131 | fullName: 'John', 132 | years: 0, 133 | }); 134 | 135 | const input2 = { name: 'Jane', age: 25 }; 136 | const result2 = validate(input2); 137 | 138 | expect(result2).toEqual({ 139 | fullName: 'Jane', 140 | years: 25, 141 | }); 142 | }); 143 | 144 | it('should provide detailed error message on validation failure', () => { 145 | const schema = z.object({ 146 | name: z.string(), 147 | age: z.number().min(0).max(120), 148 | }); 149 | 150 | const formatter = (data: { name: string; age: number }) => data; 151 | 152 | const validate = withSchema(schema, formatter); 153 | 154 | const input = { name: 'John', age: 150 }; 155 | 156 | expect(() => validate(input)).toThrow(); 157 | }); 158 | 159 | it('should work with string schemas and transformations', () => { 160 | const schema = z.string().email(); 161 | 162 | const formatter = (email: string) => ({ 163 | email, 164 | domain: email.split('@')[1], 165 | }); 166 | 167 | const validate = withSchema(schema, formatter); 168 | 169 | const input = 'test@example.com'; 170 | const result = validate(input); 171 | 172 | expect(result).toEqual({ 173 | email: 'test@example.com', 174 | domain: 'example.com', 175 | }); 176 | }); 177 | 178 | it('should handle empty objects', () => { 179 | const schema = z.object({}); 180 | 181 | const formatter = () => ({ 182 | isEmpty: true, 183 | }); 184 | 185 | const validate = withSchema(schema, formatter); 186 | 187 | const input = {}; 188 | const result = validate(input); 189 | 190 | expect(result).toEqual({ 191 | isEmpty: true, 192 | }); 193 | }); 194 | 195 | it('should work with union types', () => { 196 | const schema = z.union([ 197 | z.object({ type: z.literal('user'), username: z.string() }), 198 | z.object({ type: z.literal('admin'), adminId: z.number() }), 199 | ]); 200 | 201 | const formatter = (data: never) => ({ 202 | accountType: data.type, 203 | identifier: data.username || data.adminId, 204 | }); 205 | 206 | const validate = withSchema(schema, formatter); 207 | 208 | const userInput = { type: 'user' as const, username: 'testuser' }; 209 | const userResult = validate(userInput); 210 | 211 | expect(userResult).toEqual({ 212 | accountType: 'user', 213 | identifier: 'testuser', 214 | }); 215 | 216 | const adminInput = { type: 'admin' as const, adminId: 123 }; 217 | const adminResult = validate(adminInput); 218 | 219 | expect(adminResult).toEqual({ 220 | accountType: 'admin', 221 | identifier: 123, 222 | }); 223 | }); 224 | 225 | it('should handle null values when schema allows', () => { 226 | const schema = z.object({ 227 | name: z.string(), 228 | age: z.number().nullable(), 229 | }); 230 | 231 | const formatter = (data: { name: string; age: number | null }) => ({ 232 | fullName: data.name, 233 | years: data.age ?? 'unknown', 234 | }); 235 | 236 | const validate = withSchema(schema, formatter); 237 | 238 | const input = { name: 'John', age: null }; 239 | const result = validate(input); 240 | 241 | expect(result).toEqual({ 242 | fullName: 'John', 243 | years: 'unknown', 244 | }); 245 | }); 246 | 247 | it('should not call formatter if validation fails', () => { 248 | const schema = z.object({ 249 | name: z.string(), 250 | age: z.number(), 251 | }); 252 | 253 | let formatterCalled = false; 254 | const formatter = (data: { name: string; age: number }) => { 255 | formatterCalled = true; 256 | return data; 257 | }; 258 | 259 | const validate = withSchema(schema, formatter); 260 | 261 | const input = { name: 123, age: 'invalid' }; 262 | 263 | try { 264 | validate(input as never); 265 | } catch (_error) { 266 | expect(formatterCalled).toBe(false); 267 | } 268 | }); 269 | }); 270 | }); 271 | -------------------------------------------------------------------------------- /tests/unit/Controllers/fetchDiscussion.test.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from 'express'; 2 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 3 | import fetchDiscussion from '../../../src/Controllers/fetchDiscussion'; 4 | 5 | describe('fetchDiscussion', () => { 6 | let mockRes: Partial; 7 | let jsonSpy: ReturnType; 8 | let sendSpy: ReturnType; 9 | 10 | beforeEach(() => { 11 | jsonSpy = vi.fn(); 12 | sendSpy = vi.fn(); 13 | mockRes = { 14 | json: jsonSpy, 15 | send: sendSpy, 16 | }; 17 | }); 18 | 19 | it('should fetch discussions with custom first parameter', async () => { 20 | const mockData = { 21 | data: { 22 | categoryTopicList: { 23 | edges: [ 24 | { node: { id: '1', title: 'Discussion 1' } }, 25 | { node: { id: '2', title: 'Discussion 2' } }, 26 | ], 27 | }, 28 | }, 29 | }; 30 | 31 | global.fetch = vi.fn().mockResolvedValue({ 32 | json: vi.fn().mockResolvedValue(mockData), 33 | }); 34 | 35 | const formatData = vi.fn((data: never) => data); 36 | 37 | await fetchDiscussion( 38 | { first: 10 }, 39 | mockRes as Response, 40 | formatData, 41 | 'query { categoryTopicList }', 42 | ); 43 | 44 | expect(global.fetch).toHaveBeenCalledWith('https://leetcode.com/graphql', { 45 | method: 'POST', 46 | headers: { 47 | 'Content-Type': 'application/json', 48 | Referer: 'https://leetcode.com', 49 | }, 50 | body: JSON.stringify({ 51 | query: 'query { categoryTopicList }', 52 | variables: { 53 | first: 10, 54 | }, 55 | }), 56 | }); 57 | 58 | expect(formatData).toHaveBeenCalledWith(mockData.data); 59 | expect(jsonSpy).toHaveBeenCalledWith(mockData.data); 60 | }); 61 | 62 | it('should use default first value of 20 when not provided', async () => { 63 | const mockData = { 64 | data: { 65 | categoryTopicList: { 66 | edges: [], 67 | }, 68 | }, 69 | }; 70 | 71 | global.fetch = vi.fn().mockResolvedValue({ 72 | json: vi.fn().mockResolvedValue(mockData), 73 | }); 74 | 75 | const formatData = vi.fn((data: never) => data); 76 | 77 | await fetchDiscussion( 78 | { first: 0 }, 79 | mockRes as Response, 80 | formatData, 81 | 'query { categoryTopicList }', 82 | ); 83 | 84 | expect(global.fetch).toHaveBeenCalledWith( 85 | 'https://leetcode.com/graphql', 86 | expect.objectContaining({ 87 | body: JSON.stringify({ 88 | query: 'query { categoryTopicList }', 89 | variables: { 90 | first: 20, 91 | }, 92 | }), 93 | }), 94 | ); 95 | }); 96 | 97 | it('should apply formatData transformation to response', async () => { 98 | const mockData = { 99 | data: { 100 | categoryTopicList: { 101 | edges: [ 102 | { 103 | node: { 104 | id: '1', 105 | title: 'How to solve Two Sum?', 106 | post: { content: 'Discussion content' }, 107 | }, 108 | }, 109 | ], 110 | }, 111 | }, 112 | }; 113 | 114 | global.fetch = vi.fn().mockResolvedValue({ 115 | json: vi.fn().mockResolvedValue(mockData), 116 | }); 117 | 118 | const formatData = vi.fn((data: never) => ({ 119 | count: data.categoryTopicList.edges.length, 120 | topics: data.categoryTopicList.edges.map( 121 | (edge: never) => edge.node.title, 122 | ), 123 | })); 124 | 125 | await fetchDiscussion( 126 | { first: 5 }, 127 | mockRes as Response, 128 | formatData, 129 | 'query { categoryTopicList }', 130 | ); 131 | 132 | expect(formatData).toHaveBeenCalledWith(mockData.data); 133 | expect(jsonSpy).toHaveBeenCalledWith({ 134 | count: 1, 135 | topics: ['How to solve Two Sum?'], 136 | }); 137 | }); 138 | 139 | it('should handle GraphQL errors from LeetCode API', async () => { 140 | const mockErrorResponse = { 141 | errors: [ 142 | { 143 | message: 'Unable to fetch discussions', 144 | extensions: { code: 'INTERNAL_ERROR' }, 145 | }, 146 | ], 147 | }; 148 | 149 | global.fetch = vi.fn().mockResolvedValue({ 150 | json: vi.fn().mockResolvedValue(mockErrorResponse), 151 | }); 152 | 153 | const formatData = vi.fn((data: never) => data); 154 | 155 | await fetchDiscussion( 156 | { first: 10 }, 157 | mockRes as Response, 158 | formatData, 159 | 'query { categoryTopicList }', 160 | ); 161 | 162 | expect(sendSpy).toHaveBeenCalledWith(mockErrorResponse); 163 | expect(jsonSpy).not.toHaveBeenCalled(); 164 | }); 165 | 166 | it('should handle network errors', async () => { 167 | const networkError = new Error('Network error'); 168 | global.fetch = vi.fn().mockRejectedValue(networkError); 169 | 170 | const formatData = vi.fn((data: never) => data); 171 | 172 | await fetchDiscussion( 173 | { first: 10 }, 174 | mockRes as Response, 175 | formatData, 176 | 'query { categoryTopicList }', 177 | ); 178 | 179 | expect(sendSpy).toHaveBeenCalledWith(networkError); 180 | }); 181 | 182 | it('should handle empty discussion list', async () => { 183 | const mockData = { 184 | data: { 185 | categoryTopicList: { 186 | edges: [], 187 | }, 188 | }, 189 | }; 190 | 191 | global.fetch = vi.fn().mockResolvedValue({ 192 | json: vi.fn().mockResolvedValue(mockData), 193 | }); 194 | 195 | const formatData = vi.fn((data: never) => ({ 196 | count: data.categoryTopicList.edges.length, 197 | })); 198 | 199 | await fetchDiscussion( 200 | { first: 10 }, 201 | mockRes as Response, 202 | formatData, 203 | 'query { categoryTopicList }', 204 | ); 205 | 206 | expect(jsonSpy).toHaveBeenCalledWith({ count: 0 }); 207 | }); 208 | 209 | it('should handle large first parameter values', async () => { 210 | const mockData = { 211 | data: { 212 | categoryTopicList: { 213 | edges: Array(100) 214 | .fill(null) 215 | .map((_, i) => ({ 216 | node: { id: String(i), title: `Discussion ${i}` }, 217 | })), 218 | }, 219 | }, 220 | }; 221 | 222 | global.fetch = vi.fn().mockResolvedValue({ 223 | json: vi.fn().mockResolvedValue(mockData), 224 | }); 225 | 226 | const formatData = vi.fn((data: never) => data); 227 | 228 | await fetchDiscussion( 229 | { first: 100 }, 230 | mockRes as Response, 231 | formatData, 232 | 'query { categoryTopicList }', 233 | ); 234 | 235 | expect(global.fetch).toHaveBeenCalledWith( 236 | 'https://leetcode.com/graphql', 237 | expect.objectContaining({ 238 | body: JSON.stringify({ 239 | query: 'query { categoryTopicList }', 240 | variables: { 241 | first: 100, 242 | }, 243 | }), 244 | }), 245 | ); 246 | 247 | expect(formatData).toHaveBeenCalledWith(mockData.data); 248 | }); 249 | 250 | it('should handle first parameter as 1', async () => { 251 | const mockData = { 252 | data: { 253 | categoryTopicList: { 254 | edges: [{ node: { id: '1', title: 'Single Discussion' } }], 255 | }, 256 | }, 257 | }; 258 | 259 | global.fetch = vi.fn().mockResolvedValue({ 260 | json: vi.fn().mockResolvedValue(mockData), 261 | }); 262 | 263 | const formatData = vi.fn((data: never) => data); 264 | 265 | await fetchDiscussion( 266 | { first: 1 }, 267 | mockRes as Response, 268 | formatData, 269 | 'query { categoryTopicList }', 270 | ); 271 | 272 | expect(global.fetch).toHaveBeenCalledWith( 273 | 'https://leetcode.com/graphql', 274 | expect.objectContaining({ 275 | body: JSON.stringify({ 276 | query: 'query { categoryTopicList }', 277 | variables: { 278 | first: 1, 279 | }, 280 | }), 281 | }), 282 | ); 283 | }); 284 | }); 285 | -------------------------------------------------------------------------------- /mcp/modules/userTools.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 2 | import { z } from 'zod'; 3 | import { 4 | getLanguageStats, 5 | getLanguageStatsRaw, 6 | getRecentAcSubmission, 7 | getRecentSubmission, 8 | getSkillStats, 9 | getSkillStatsRaw, 10 | getSolvedProblems, 11 | getSubmissionCalendar, 12 | getUserBadges, 13 | getUserContest, 14 | getUserContestHistory, 15 | getUserContestRankingInfo, 16 | getUserProfileAggregate, 17 | getUserProfileCalendarRaw, 18 | getUserProfileRaw, 19 | getUserProfileSummary, 20 | getUserProgress, 21 | getUserProgressRaw, 22 | } from '../leetCodeService'; 23 | import { runTool } from '../serverUtils'; 24 | import { ToolModule } from '../types'; 25 | 26 | const usernameSchema = z.string(); 27 | const limitSchema = z.number().int().positive().max(50).optional(); 28 | 29 | export class UserToolsModule implements ToolModule { 30 | // Registers user-related tools with the MCP server. 31 | register(server: McpServer): void { 32 | server.registerTool( 33 | 'leetcode_user_data', 34 | { 35 | title: 'User Profile Overview', 36 | description: 'Fetches the public profile for a LeetCode user', 37 | inputSchema: { 38 | username: usernameSchema, 39 | }, 40 | }, 41 | async ({ username }) => runTool(() => getUserProfileSummary(username)), 42 | ); 43 | 44 | server.registerTool( 45 | 'leetcode_user_badges', 46 | { 47 | title: 'User Badges', 48 | description: 'Retrieves earned and upcoming badges for a user', 49 | inputSchema: { 50 | username: usernameSchema, 51 | }, 52 | }, 53 | async ({ username }) => runTool(() => getUserBadges(username)), 54 | ); 55 | 56 | server.registerTool( 57 | 'leetcode_user_contest', 58 | { 59 | title: 'Contest Summary', 60 | description: 'Returns contest stats for a user', 61 | inputSchema: { 62 | username: usernameSchema, 63 | }, 64 | }, 65 | async ({ username }) => runTool(() => getUserContest(username)), 66 | ); 67 | 68 | server.registerTool( 69 | 'leetcode_user_contest_history', 70 | { 71 | title: 'Contest History', 72 | description: 'Returns contest participation history for a user', 73 | inputSchema: { 74 | username: usernameSchema, 75 | }, 76 | }, 77 | async ({ username }) => runTool(() => getUserContestHistory(username)), 78 | ); 79 | 80 | server.registerTool( 81 | 'leetcode_user_solved', 82 | { 83 | title: 'Solved Problem Stats', 84 | description: 'Summarizes solved problems for a user', 85 | inputSchema: { 86 | username: usernameSchema, 87 | }, 88 | }, 89 | async ({ username }) => runTool(() => getSolvedProblems(username)), 90 | ); 91 | 92 | server.registerTool( 93 | 'leetcode_user_submissions', 94 | { 95 | title: 'Recent Submissions', 96 | description: 'Lists recent submissions for a user', 97 | inputSchema: { 98 | username: usernameSchema, 99 | limit: limitSchema, 100 | }, 101 | }, 102 | async ({ username, limit }) => runTool(() => getRecentSubmission({ username, limit })), 103 | ); 104 | 105 | server.registerTool( 106 | 'leetcode_user_accepted_submissions', 107 | { 108 | title: 'Recent Accepted Submissions', 109 | description: 'Lists recent accepted submissions for a user', 110 | inputSchema: { 111 | username: usernameSchema, 112 | limit: limitSchema, 113 | }, 114 | }, 115 | async ({ username, limit }) => runTool(() => getRecentAcSubmission({ username, limit })), 116 | ); 117 | 118 | server.registerTool( 119 | 'leetcode_user_calendar', 120 | { 121 | title: 'Submission Calendar', 122 | description: 'Retrieves submission calendar data for a given year', 123 | inputSchema: { 124 | username: usernameSchema, 125 | year: z.number().int(), 126 | }, 127 | }, 128 | async ({ username, year }) => runTool(() => getSubmissionCalendar({ username, year })), 129 | ); 130 | 131 | server.registerTool( 132 | 'leetcode_user_skill_stats', 133 | { 134 | title: 'Skill Distribution', 135 | description: 'Returns skill category breakdown for a user', 136 | inputSchema: { 137 | username: usernameSchema, 138 | }, 139 | }, 140 | async ({ username }) => runTool(() => getSkillStats(username)), 141 | ); 142 | 143 | server.registerTool( 144 | 'leetcode_user_profile_aggregate', 145 | { 146 | title: 'Profile Aggregate', 147 | description: 'Retrieves aggregated profile metrics and submissions', 148 | inputSchema: { 149 | username: usernameSchema, 150 | }, 151 | }, 152 | async ({ username }) => runTool(() => getUserProfileAggregate(username)), 153 | ); 154 | 155 | server.registerTool( 156 | 'leetcode_user_language_stats', 157 | { 158 | title: 'Language Usage', 159 | description: 'Lists problems solved by language for a user', 160 | inputSchema: { 161 | username: usernameSchema, 162 | }, 163 | }, 164 | async ({ username }) => runTool(() => getLanguageStats(username)), 165 | ); 166 | 167 | server.registerTool( 168 | 'leetcode_user_progress', 169 | { 170 | title: 'Question Progress', 171 | description: 'Summarizes accepted, failed, and untouched questions', 172 | inputSchema: { 173 | username: usernameSchema, 174 | }, 175 | }, 176 | async ({ username }) => runTool(() => getUserProgress(username)), 177 | ); 178 | 179 | server.registerTool( 180 | 'leetcode_user_language_stats_raw', 181 | { 182 | title: 'Language Usage Raw', 183 | description: 'Retrieves raw language usage data', 184 | inputSchema: { 185 | username: usernameSchema, 186 | }, 187 | }, 188 | async ({ username }) => runTool(() => getLanguageStatsRaw(username)), 189 | ); 190 | 191 | server.registerTool( 192 | 'leetcode_user_calendar_raw', 193 | { 194 | title: 'Submission Calendar Raw', 195 | description: 'Retrieves raw submission calendar data', 196 | inputSchema: { 197 | username: usernameSchema, 198 | year: z.number().int(), 199 | }, 200 | }, 201 | async ({ username, year }) => runTool(() => getUserProfileCalendarRaw({ username, year })), 202 | ); 203 | 204 | server.registerTool( 205 | 'leetcode_user_profile_raw', 206 | { 207 | title: 'Profile Aggregate Raw', 208 | description: 'Retrieves raw aggregated profile data', 209 | inputSchema: { 210 | username: usernameSchema, 211 | }, 212 | }, 213 | async ({ username }) => runTool(() => getUserProfileRaw(username)), 214 | ); 215 | 216 | server.registerTool( 217 | 'leetcode_user_skill_stats_raw', 218 | { 219 | title: 'Skill Distribution Raw', 220 | description: 'Retrieves raw skill distribution data', 221 | inputSchema: { 222 | username: usernameSchema, 223 | }, 224 | }, 225 | async ({ username }) => runTool(() => getSkillStatsRaw(username)), 226 | ); 227 | 228 | server.registerTool( 229 | 'leetcode_user_contest_ranking_info', 230 | { 231 | title: 'Contest Ranking Info Raw', 232 | description: 'Retrieves contest ranking information', 233 | inputSchema: { 234 | username: usernameSchema, 235 | }, 236 | }, 237 | async ({ username }) => runTool(() => getUserContestRankingInfo(username)), 238 | ); 239 | 240 | server.registerTool( 241 | 'leetcode_user_progress_raw', 242 | { 243 | title: 'Question Progress Raw', 244 | description: 'Retrieves raw question progress data', 245 | inputSchema: { 246 | username: usernameSchema, 247 | }, 248 | }, 249 | async ({ username }) => runTool(() => getUserProgressRaw(username)), 250 | ); 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /tests/unit/Controllers/fetchContests.test.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from 'express'; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 3 | import { 4 | fetchAllContests, 5 | fetchUpcomingContests, 6 | } from '../../../src/Controllers/fetchContests'; 7 | 8 | describe('fetchContests', () => { 9 | let mockRes: Partial; 10 | let jsonSpy: ReturnType; 11 | let sendSpy: ReturnType; 12 | 13 | beforeEach(() => { 14 | jsonSpy = vi.fn(); 15 | sendSpy = vi.fn(); 16 | mockRes = { 17 | json: jsonSpy, 18 | send: sendSpy, 19 | }; 20 | }); 21 | 22 | afterEach(() => { 23 | vi.restoreAllMocks(); 24 | }); 25 | 26 | describe('fetchAllContests', () => { 27 | it('should fetch all contests successfully', async () => { 28 | const mockData = { 29 | data: { 30 | allContests: [ 31 | { title: 'Weekly Contest 1', startTime: 1234567890 }, 32 | { title: 'Weekly Contest 2', startTime: 1234567900 }, 33 | ], 34 | }, 35 | }; 36 | 37 | global.fetch = vi.fn().mockResolvedValue({ 38 | ok: true, 39 | json: vi.fn().mockResolvedValue(mockData), 40 | }); 41 | 42 | await fetchAllContests(mockRes as Response, 'query { allContests }'); 43 | 44 | expect(global.fetch).toHaveBeenCalledWith( 45 | 'https://leetcode.com/graphql', 46 | { 47 | method: 'POST', 48 | headers: { 49 | 'Content-Type': 'application/json', 50 | Referer: 'https://leetcode.com', 51 | }, 52 | body: JSON.stringify({ 53 | query: 'query { allContests }', 54 | }), 55 | }, 56 | ); 57 | 58 | expect(jsonSpy).toHaveBeenCalledWith(mockData.data); 59 | }); 60 | 61 | it('should handle GraphQL errors', async () => { 62 | const mockErrorResponse = { 63 | errors: [ 64 | { 65 | message: 'Unable to fetch contests', 66 | extensions: { code: 'INTERNAL_ERROR' }, 67 | }, 68 | ], 69 | }; 70 | 71 | global.fetch = vi.fn().mockResolvedValue({ 72 | ok: false, 73 | status: 500, 74 | json: vi.fn().mockResolvedValue(mockErrorResponse), 75 | }); 76 | 77 | const consoleErrorSpy = vi 78 | .spyOn(console, 'error') 79 | .mockImplementation(() => {}); 80 | 81 | await fetchAllContests(mockRes as Response, 'query { allContests }'); 82 | 83 | expect(consoleErrorSpy).toHaveBeenCalledWith('HTTP error! status: 500'); 84 | expect(sendSpy).toHaveBeenCalledWith(mockErrorResponse); 85 | }); 86 | 87 | it('should handle network errors', async () => { 88 | const networkError = new Error('Network error'); 89 | global.fetch = vi.fn().mockRejectedValue(networkError); 90 | 91 | const consoleErrorSpy = vi 92 | .spyOn(console, 'error') 93 | .mockImplementation(() => {}); 94 | 95 | await fetchAllContests(mockRes as Response, 'query { allContests }'); 96 | 97 | expect(consoleErrorSpy).toHaveBeenCalledWith('Error: ', networkError); 98 | expect(sendSpy).toHaveBeenCalledWith(networkError); 99 | }); 100 | 101 | it('should handle empty contests array', async () => { 102 | const mockData = { 103 | data: { 104 | allContests: [], 105 | }, 106 | }; 107 | 108 | global.fetch = vi.fn().mockResolvedValue({ 109 | ok: true, 110 | json: vi.fn().mockResolvedValue(mockData), 111 | }); 112 | 113 | await fetchAllContests(mockRes as Response, 'query { allContests }'); 114 | 115 | expect(jsonSpy).toHaveBeenCalledWith(mockData.data); 116 | }); 117 | }); 118 | 119 | describe('fetchUpcomingContests', () => { 120 | it('should filter and return only upcoming contests', async () => { 121 | const futureTime = Math.floor(Date.now() / 1000) + 86400; 122 | const pastTime = Math.floor(Date.now() / 1000) - 86400; 123 | 124 | const mockData = { 125 | data: { 126 | allContests: [ 127 | { title: 'Past Contest', startTime: pastTime }, 128 | { title: 'Future Contest 1', startTime: futureTime }, 129 | { title: 'Future Contest 2', startTime: futureTime + 3600 }, 130 | ], 131 | }, 132 | }; 133 | 134 | global.fetch = vi.fn().mockResolvedValue({ 135 | ok: true, 136 | json: vi.fn().mockResolvedValue(mockData), 137 | }); 138 | 139 | await fetchUpcomingContests(mockRes as Response, 'query { allContests }'); 140 | 141 | expect(jsonSpy).toHaveBeenCalledWith({ 142 | count: 2, 143 | contests: [ 144 | { title: 'Future Contest 1', startTime: futureTime }, 145 | { title: 'Future Contest 2', startTime: futureTime + 3600 }, 146 | ], 147 | }); 148 | }); 149 | 150 | it('should handle no upcoming contests', async () => { 151 | const pastTime = Math.floor(Date.now() / 1000) - 86400; 152 | 153 | const mockData = { 154 | data: { 155 | allContests: [ 156 | { title: 'Past Contest 1', startTime: pastTime }, 157 | { title: 'Past Contest 2', startTime: pastTime - 3600 }, 158 | ], 159 | }, 160 | }; 161 | 162 | global.fetch = vi.fn().mockResolvedValue({ 163 | ok: true, 164 | json: vi.fn().mockResolvedValue(mockData), 165 | }); 166 | 167 | await fetchUpcomingContests(mockRes as Response, 'query { allContests }'); 168 | 169 | expect(jsonSpy).toHaveBeenCalledWith({ 170 | count: 0, 171 | contests: [], 172 | }); 173 | }); 174 | 175 | it('should handle null or undefined allContests', async () => { 176 | const mockData = { 177 | data: {}, 178 | }; 179 | 180 | global.fetch = vi.fn().mockResolvedValue({ 181 | ok: true, 182 | json: vi.fn().mockResolvedValue(mockData), 183 | }); 184 | 185 | await fetchUpcomingContests(mockRes as Response, 'query { allContests }'); 186 | 187 | expect(jsonSpy).toHaveBeenCalledWith({ 188 | count: 0, 189 | contests: [], 190 | }); 191 | }); 192 | 193 | it('should handle GraphQL errors', async () => { 194 | const mockErrorResponse = { 195 | errors: [ 196 | { 197 | message: 'Unable to fetch contests', 198 | extensions: { code: 'INTERNAL_ERROR' }, 199 | }, 200 | ], 201 | }; 202 | 203 | global.fetch = vi.fn().mockResolvedValue({ 204 | ok: false, 205 | status: 500, 206 | json: vi.fn().mockResolvedValue(mockErrorResponse), 207 | }); 208 | 209 | const consoleErrorSpy = vi 210 | .spyOn(console, 'error') 211 | .mockImplementation(() => {}); 212 | 213 | await fetchUpcomingContests(mockRes as Response, 'query { allContests }'); 214 | 215 | expect(consoleErrorSpy).toHaveBeenCalledWith('HTTP error! status: 500'); 216 | expect(sendSpy).toHaveBeenCalledWith(mockErrorResponse); 217 | }); 218 | 219 | it('should handle network errors', async () => { 220 | const networkError = new Error('Network error'); 221 | global.fetch = vi.fn().mockRejectedValue(networkError); 222 | 223 | const consoleErrorSpy = vi 224 | .spyOn(console, 'error') 225 | .mockImplementation(() => {}); 226 | 227 | await fetchUpcomingContests(mockRes as Response, 'query { allContests }'); 228 | 229 | expect(consoleErrorSpy).toHaveBeenCalledWith('Error: ', networkError); 230 | expect(sendSpy).toHaveBeenCalledWith(networkError); 231 | }); 232 | 233 | it('should correctly calculate current timestamp', async () => { 234 | const now = Math.floor(Date.now() / 1000); 235 | const justBeforeNow = now - 1; 236 | const justAfterNow = now + 1; 237 | 238 | const mockData = { 239 | data: { 240 | allContests: [ 241 | { title: 'Just Past', startTime: justBeforeNow }, 242 | { title: 'Just Future', startTime: justAfterNow }, 243 | ], 244 | }, 245 | }; 246 | 247 | global.fetch = vi.fn().mockResolvedValue({ 248 | ok: true, 249 | json: vi.fn().mockResolvedValue(mockData), 250 | }); 251 | 252 | await fetchUpcomingContests(mockRes as Response, 'query { allContests }'); 253 | 254 | const result = jsonSpy.mock.calls[0][0]; 255 | expect(result.count).toBeGreaterThanOrEqual(0); 256 | expect(result.contests).toBeInstanceOf(Array); 257 | }); 258 | }); 259 | }); 260 | -------------------------------------------------------------------------------- /mcp/leetCodeService.ts: -------------------------------------------------------------------------------- 1 | import { 2 | formatAcSubmissionData, 3 | formatBadgesData, 4 | formatContestData, 5 | formatContestHistoryData, 6 | formatDailyData, 7 | formatLanguageStats, 8 | formatProblemsData, 9 | formatProgressStats, 10 | formatQuestionData, 11 | formatSolvedProblemsData, 12 | formatSubmissionCalendarData, 13 | formatSubmissionData, 14 | formatTrendingCategoryTopicData, 15 | formatSkillStats, 16 | formatUserData, 17 | formatUserProfileData, 18 | } from '../src/FormatUtils'; 19 | import { 20 | AcSubmissionQuery, 21 | contestQuery, 22 | dailyProblemQuery, 23 | discussCommentsQuery, 24 | discussTopicQuery, 25 | getUserProfileQuery, 26 | languageStatsQuery, 27 | officialSolutionQuery, 28 | problemListQuery, 29 | selectProblemQuery, 30 | submissionQuery, 31 | trendingDiscussQuery, 32 | userProfileCalendarQuery, 33 | userProfileQuery, 34 | userQuestionProgressQuery, 35 | userContestRankingInfoQuery, 36 | skillStatsQuery, 37 | } from '../src/GQLQueries'; 38 | import type { 39 | DailyProblemData, 40 | ProblemSetQuestionListData, 41 | SelectProblemData, 42 | TrendingDiscussionObject, 43 | UserData, 44 | } from '../src/types'; 45 | import { executeGraphQL } from './serverUtils'; 46 | import { SubmissionArgs, CalendarArgs, ProblemArgs, DiscussCommentsArgs, Variables } from './types'; 47 | 48 | // Builds GraphQL variables by filtering out undefined, null, and NaN values. 49 | function buildVariables(input: Record): Variables { 50 | const result: Variables = {}; 51 | for (const [key, value] of Object.entries(input)) { 52 | if (value !== undefined && value !== null && !(typeof value === 'number' && Number.isNaN(value))) { 53 | result[key] = value; 54 | } 55 | } 56 | return result; 57 | } 58 | 59 | // Retrieves the formatted user profile summary. 60 | export async function getUserProfileSummary(username: string) { 61 | const data = await executeGraphQL(userProfileQuery, { username }); 62 | return formatUserData(data as UserData); 63 | } 64 | 65 | // Retrieves the formatted user badges data. 66 | export async function getUserBadges(username: string) { 67 | const data = await executeGraphQL(userProfileQuery, { username }); 68 | return formatBadgesData(data as UserData); 69 | } 70 | 71 | // Retrieves the formatted user contest data. 72 | export async function getUserContest(username: string) { 73 | const data = await executeGraphQL(contestQuery, { username }); 74 | return formatContestData(data as UserData); 75 | } 76 | 77 | // Retrieves the formatted user contest history. 78 | export async function getUserContestHistory(username: string) { 79 | const data = await executeGraphQL(contestQuery, { username }); 80 | return formatContestHistoryData(data as UserData); 81 | } 82 | 83 | // Retrieves the formatted solved problems statistics. 84 | export async function getSolvedProblems(username: string) { 85 | const data = await executeGraphQL(userProfileQuery, { username }); 86 | return formatSolvedProblemsData(data as UserData); 87 | } 88 | 89 | // Retrieves recent submissions for a user. 90 | export async function getRecentSubmission(args: SubmissionArgs) { 91 | const variables = buildVariables({ username: args.username, limit: args.limit }); 92 | const data = await executeGraphQL(submissionQuery, variables); 93 | return formatSubmissionData(data as UserData); 94 | } 95 | 96 | // Retrieves recent accepted submissions for a user. 97 | export async function getRecentAcSubmission(args: SubmissionArgs) { 98 | const variables = buildVariables({ username: args.username, limit: args.limit }); 99 | const data = await executeGraphQL(AcSubmissionQuery, variables); 100 | return formatAcSubmissionData(data as UserData); 101 | } 102 | 103 | // Retrieves the submission calendar for a user in a given year. 104 | export async function getSubmissionCalendar(args: CalendarArgs) { 105 | const variables = buildVariables({ username: args.username, year: args.year }); 106 | const data = await executeGraphQL(userProfileCalendarQuery, variables); 107 | return formatSubmissionCalendarData(data as UserData); 108 | } 109 | 110 | // Retrieves the aggregated user profile data. 111 | export async function getUserProfileAggregate(username: string) { 112 | const data = await executeGraphQL(getUserProfileQuery, { username }); 113 | return formatUserProfileData(data); 114 | } 115 | 116 | // Retrieves the language statistics for a user. 117 | export async function getLanguageStats(username: string) { 118 | const data = await executeGraphQL(languageStatsQuery, { username }); 119 | return formatLanguageStats(data as UserData); 120 | } 121 | 122 | // Retrieves the skill statistics for a user. 123 | export async function getSkillStats(username: string) { 124 | const data = await executeGraphQL(skillStatsQuery, { username }); 125 | return formatSkillStats(data as UserData); 126 | } 127 | 128 | // Retrieves the daily problem. 129 | export async function getDailyProblem() { 130 | const data = await executeGraphQL(dailyProblemQuery, {}); 131 | return formatDailyData(data as DailyProblemData); 132 | } 133 | 134 | // Retrieves the raw daily problem data. 135 | export async function getDailyProblemRaw() { 136 | return executeGraphQL(dailyProblemQuery, {}); 137 | } 138 | 139 | // Retrieves a selected problem by title slug. 140 | export async function getSelectProblem(titleSlug: string) { 141 | const data = await executeGraphQL(selectProblemQuery, { titleSlug }); 142 | return formatQuestionData(data as SelectProblemData); 143 | } 144 | 145 | // Retrieves the raw data for a selected problem by title slug. 146 | export async function getSelectProblemRaw(titleSlug: string) { 147 | return executeGraphQL(selectProblemQuery, { titleSlug }); 148 | } 149 | 150 | // Retrieves a list of problems based on the given arguments. 151 | export async function getProblemSet(args: ProblemArgs) { 152 | const limit = args.skip !== undefined && args.limit === undefined ? 1 : args.limit ?? 20; 153 | const skip = args.skip ?? 0; 154 | const tags = args.tags ? args.tags.split(' ') : []; 155 | const difficulty = args.difficulty ?? undefined; 156 | const variables = buildVariables({ 157 | categorySlug: '', 158 | limit, 159 | skip, 160 | filters: { 161 | tags, 162 | difficulty, 163 | }, 164 | }); 165 | const data = await executeGraphQL(problemListQuery, variables); 166 | return formatProblemsData(data as ProblemSetQuestionListData); 167 | } 168 | 169 | // Retrieves the official solution for a problem. 170 | export async function getOfficialSolution(titleSlug: string) { 171 | return executeGraphQL(officialSolutionQuery, { titleSlug }); 172 | } 173 | 174 | // Retrieves trending discussion topics. 175 | export async function getTrendingTopics(first: number) { 176 | const data = await executeGraphQL(trendingDiscussQuery, { first }); 177 | return formatTrendingCategoryTopicData(data as TrendingDiscussionObject); 178 | } 179 | 180 | // Retrieves a discussion topic by ID. 181 | export async function getDiscussTopic(topicId: number) { 182 | return executeGraphQL(discussTopicQuery, { topicId }); 183 | } 184 | 185 | // Retrieves comments for a discussion topic. 186 | export async function getDiscussComments(args: DiscussCommentsArgs) { 187 | const variables = buildVariables({ 188 | topicId: args.topicId, 189 | orderBy: args.orderBy ?? 'newest_to_oldest', 190 | pageNo: args.pageNo ?? 1, 191 | numPerPage: args.numPerPage ?? 10, 192 | }); 193 | return executeGraphQL(discussCommentsQuery, variables); 194 | } 195 | 196 | // Retrieves raw language statistics for a user. 197 | export async function getLanguageStatsRaw(username: string) { 198 | return executeGraphQL(languageStatsQuery, { username }); 199 | } 200 | 201 | // Retrieves raw submission calendar data for a user. 202 | export async function getUserProfileCalendarRaw(args: CalendarArgs) { 203 | const variables = buildVariables({ username: args.username, year: args.year }); 204 | return executeGraphQL(userProfileCalendarQuery, variables); 205 | } 206 | 207 | // Retrieves raw user profile data. 208 | export async function getUserProfileRaw(username: string) { 209 | return executeGraphQL(getUserProfileQuery, { username }); 210 | } 211 | 212 | // Retrieves the legacy daily problem data. 213 | export async function getDailyProblemLegacy() { 214 | return executeGraphQL(dailyProblemQuery, {}); 215 | } 216 | 217 | // Retrieves raw skill statistics for a user. 218 | export async function getSkillStatsRaw(username: string) { 219 | return executeGraphQL(skillStatsQuery, { username }); 220 | } 221 | 222 | // Retrieves the question progress for a user. 223 | export async function getUserProgress(username: string) { 224 | const data = await executeGraphQL(userQuestionProgressQuery, { username }); 225 | return formatProgressStats(data as UserData); 226 | } 227 | 228 | // Retrieves the contest ranking information for a user. 229 | export async function getUserContestRankingInfo(username: string) { 230 | return executeGraphQL(userContestRankingInfoQuery, { username }); 231 | } 232 | 233 | // Retrieves raw question progress data for a user. 234 | export async function getUserProgressRaw(username: string) { 235 | return executeGraphQL(userQuestionProgressQuery, { username }); 236 | } 237 | -------------------------------------------------------------------------------- /tests/unit/Controllers/fetchUserProfile.test.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from 'express'; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 3 | import fetchUserProfile from '../../../src/Controllers/fetchUserProfile'; 4 | 5 | describe('fetchUserProfile', () => { 6 | let mockRes: Partial; 7 | let jsonSpy: ReturnType; 8 | let sendSpy: ReturnType; 9 | 10 | beforeEach(() => { 11 | jsonSpy = vi.fn(); 12 | sendSpy = vi.fn(); 13 | mockRes = { 14 | json: jsonSpy, 15 | send: sendSpy, 16 | }; 17 | }); 18 | 19 | afterEach(() => { 20 | vi.restoreAllMocks(); 21 | }); 22 | 23 | it('should fetch user profile and apply format function', async () => { 24 | const mockData = { 25 | data: { 26 | matchedUser: { 27 | username: 'testuser', 28 | profile: { 29 | realName: 'Test User', 30 | ranking: 12345, 31 | }, 32 | submitStats: { 33 | acSubmissionNum: [ 34 | { difficulty: 'All', count: 500 }, 35 | { difficulty: 'Easy', count: 200 }, 36 | ], 37 | }, 38 | }, 39 | }, 40 | }; 41 | 42 | global.fetch = vi.fn().mockResolvedValue({ 43 | ok: true, 44 | json: vi.fn().mockResolvedValue(mockData), 45 | }); 46 | 47 | const formatFunction = vi.fn((data: never) => ({ 48 | username: data.matchedUser.username, 49 | name: data.matchedUser.profile.realName, 50 | rank: data.matchedUser.profile.ranking, 51 | totalSolved: data.matchedUser.submitStats.acSubmissionNum[0].count, 52 | })); 53 | 54 | const params = { username: 'testuser' }; 55 | 56 | await fetchUserProfile( 57 | mockRes as Response, 58 | 'query { matchedUser }', 59 | params, 60 | formatFunction, 61 | ); 62 | 63 | expect(global.fetch).toHaveBeenCalledWith('https://leetcode.com/graphql', { 64 | method: 'POST', 65 | headers: { 66 | 'Content-Type': 'application/json', 67 | Referer: 'https://leetcode.com', 68 | }, 69 | body: JSON.stringify({ 70 | query: 'query { matchedUser }', 71 | variables: params, 72 | }), 73 | }); 74 | 75 | expect(formatFunction).toHaveBeenCalledWith(mockData.data); 76 | expect(jsonSpy).toHaveBeenCalledWith({ 77 | username: 'testuser', 78 | name: 'Test User', 79 | rank: 12345, 80 | totalSolved: 500, 81 | }); 82 | }); 83 | 84 | it('should handle GraphQL errors from LeetCode API', async () => { 85 | const mockErrorResponse = { 86 | errors: [ 87 | { 88 | message: 'User not found', 89 | extensions: { code: 'NOT_FOUND' }, 90 | }, 91 | ], 92 | }; 93 | 94 | global.fetch = vi.fn().mockResolvedValue({ 95 | ok: true, 96 | json: vi.fn().mockResolvedValue(mockErrorResponse), 97 | }); 98 | 99 | const formatFunction = vi.fn((data: never) => data); 100 | 101 | await fetchUserProfile( 102 | mockRes as Response, 103 | 'query { matchedUser }', 104 | { username: 'nonexistent' }, 105 | formatFunction, 106 | ); 107 | 108 | expect(sendSpy).toHaveBeenCalledWith(mockErrorResponse); 109 | expect(jsonSpy).not.toHaveBeenCalled(); 110 | }); 111 | 112 | it('should log HTTP errors when response is not ok', async () => { 113 | const mockData = { 114 | data: { 115 | matchedUser: { 116 | username: 'testuser', 117 | }, 118 | }, 119 | }; 120 | 121 | global.fetch = vi.fn().mockResolvedValue({ 122 | ok: false, 123 | status: 500, 124 | json: vi.fn().mockResolvedValue(mockData), 125 | }); 126 | 127 | const consoleErrorSpy = vi 128 | .spyOn(console, 'error') 129 | .mockImplementation(() => {}); 130 | 131 | const formatFunction = vi.fn((data: never) => data.matchedUser); 132 | 133 | await fetchUserProfile( 134 | mockRes as Response, 135 | 'query { matchedUser }', 136 | { username: 'testuser' }, 137 | formatFunction, 138 | ); 139 | 140 | expect(consoleErrorSpy).toHaveBeenCalledWith('HTTP error! status: 500'); 141 | expect(formatFunction).toHaveBeenCalledWith(mockData.data); 142 | }); 143 | 144 | it('should handle network errors', async () => { 145 | const networkError = new Error('Network error'); 146 | global.fetch = vi.fn().mockRejectedValue(networkError); 147 | 148 | const consoleErrorSpy = vi 149 | .spyOn(console, 'error') 150 | .mockImplementation(() => {}); 151 | 152 | const formatFunction = vi.fn((data: never) => data); 153 | 154 | await fetchUserProfile( 155 | mockRes as Response, 156 | 'query { matchedUser }', 157 | { username: 'testuser' }, 158 | formatFunction, 159 | ); 160 | 161 | expect(consoleErrorSpy).toHaveBeenCalledWith('Error: ', networkError); 162 | expect(sendSpy).toHaveBeenCalledWith(networkError); 163 | }); 164 | 165 | it('should handle complex parameters', async () => { 166 | const mockData = { 167 | data: { 168 | userProfile: { 169 | badges: [], 170 | contributions: 100, 171 | }, 172 | }, 173 | }; 174 | 175 | global.fetch = vi.fn().mockResolvedValue({ 176 | ok: true, 177 | json: vi.fn().mockResolvedValue(mockData), 178 | }); 179 | 180 | const formatFunction = vi.fn((data: never) => data.userProfile); 181 | 182 | const complexParams = { 183 | username: 'testuser', 184 | year: 2024, 185 | limit: 50, 186 | }; 187 | 188 | await fetchUserProfile( 189 | mockRes as Response, 190 | 'query { userProfile }', 191 | complexParams, 192 | formatFunction, 193 | ); 194 | 195 | expect(global.fetch).toHaveBeenCalledWith( 196 | 'https://leetcode.com/graphql', 197 | expect.objectContaining({ 198 | body: JSON.stringify({ 199 | query: 'query { userProfile }', 200 | variables: complexParams, 201 | }), 202 | }), 203 | ); 204 | 205 | expect(jsonSpy).toHaveBeenCalledWith(mockData.data.userProfile); 206 | }); 207 | 208 | it('should handle empty response data', async () => { 209 | const mockData = { 210 | data: {}, 211 | }; 212 | 213 | global.fetch = vi.fn().mockResolvedValue({ 214 | ok: true, 215 | json: vi.fn().mockResolvedValue(mockData), 216 | }); 217 | 218 | const formatFunction = vi.fn(() => ({ empty: true })); 219 | 220 | await fetchUserProfile( 221 | mockRes as Response, 222 | 'query { matchedUser }', 223 | { username: 'testuser' }, 224 | formatFunction, 225 | ); 226 | 227 | expect(formatFunction).toHaveBeenCalledWith({}); 228 | expect(jsonSpy).toHaveBeenCalledWith({ empty: true }); 229 | }); 230 | 231 | it('should handle format function that transforms nested data', async () => { 232 | const mockData = { 233 | data: { 234 | allQuestionsCount: [ 235 | { difficulty: 'Easy', count: 100 }, 236 | { difficulty: 'Medium', count: 200 }, 237 | { difficulty: 'Hard', count: 150 }, 238 | ], 239 | matchedUser: { 240 | submitStats: { 241 | acSubmissionNum: [ 242 | { difficulty: 'Easy', count: 50 }, 243 | { difficulty: 'Medium', count: 80 }, 244 | { difficulty: 'Hard', count: 20 }, 245 | ], 246 | }, 247 | }, 248 | }, 249 | }; 250 | 251 | global.fetch = vi.fn().mockResolvedValue({ 252 | ok: true, 253 | json: vi.fn().mockResolvedValue(mockData), 254 | }); 255 | 256 | const formatFunction = vi.fn((data: never) => ({ 257 | totalQuestions: data.allQuestionsCount.reduce( 258 | (sum: number, item: never) => sum + item.count, 259 | 0, 260 | ), 261 | solvedQuestions: data.matchedUser.submitStats.acSubmissionNum.reduce( 262 | (sum: number, item: never) => sum + item.count, 263 | 0, 264 | ), 265 | })); 266 | 267 | await fetchUserProfile( 268 | mockRes as Response, 269 | 'query { allQuestionsCount matchedUser }', 270 | { username: 'testuser' }, 271 | formatFunction, 272 | ); 273 | 274 | expect(jsonSpy).toHaveBeenCalledWith({ 275 | totalQuestions: 450, 276 | solvedQuestions: 150, 277 | }); 278 | }); 279 | 280 | it('should handle null parameters', async () => { 281 | const mockData = { 282 | data: { 283 | globalData: 'value', 284 | }, 285 | }; 286 | 287 | global.fetch = vi.fn().mockResolvedValue({ 288 | ok: true, 289 | json: vi.fn().mockResolvedValue(mockData), 290 | }); 291 | 292 | const formatFunction = vi.fn((data: never) => data); 293 | 294 | await fetchUserProfile( 295 | mockRes as Response, 296 | 'query { globalData }', 297 | null, 298 | formatFunction, 299 | ); 300 | 301 | expect(global.fetch).toHaveBeenCalledWith( 302 | 'https://leetcode.com/graphql', 303 | expect.objectContaining({ 304 | body: JSON.stringify({ 305 | query: 'query { globalData }', 306 | variables: null, 307 | }), 308 | }), 309 | ); 310 | }); 311 | }); 312 | --------------------------------------------------------------------------------