├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── codeql.yml │ └── release.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc ├── .scannerwork ├── .sonar_lock └── report-task.txt ├── CLAUDE.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── eslint.config.js ├── jest.config.js ├── package.json ├── pnpm-lock.yaml ├── sonar-project.properties ├── src ├── __tests__ │ ├── additional-coverage.test.ts │ ├── advanced-index.test.ts │ ├── auth-methods.test.ts │ ├── boolean-string-transform.test.ts │ ├── dependency-injection.test.ts │ ├── direct-handlers.test.ts │ ├── direct-lambdas.test.ts │ ├── direct-schema-validation.test.ts │ ├── environment-validation.test.ts │ ├── error-handling.test.ts │ ├── function-tests.test.ts │ ├── handlers.test.ts │ ├── handlers.test.ts.skip │ ├── index.test.ts │ ├── lambda-functions.test.ts │ ├── lambda-handlers.test.ts.skip │ ├── logger.test.ts │ ├── mapping-functions.test.ts │ ├── mocked-environment.test.ts │ ├── null-to-undefined.test.ts │ ├── parameter-transformations-advanced.test.ts │ ├── parameter-transformations.test.ts │ ├── quality-gates.test.ts │ ├── schema-parameter-transforms.test.ts │ ├── schema-transformation-mocks.test.ts │ ├── schema-transforms.test.ts │ ├── schema-validators.test.ts │ ├── sonarqube.test.ts │ ├── source-code.test.ts │ ├── standalone-handlers.test.ts │ ├── string-to-number-transform.test.ts │ ├── tool-handler-lambdas.test.ts │ ├── tool-handlers.test.ts │ ├── tool-registration-schema.test.ts │ ├── tool-registration-transforms.test.ts │ ├── transformation-util.test.ts │ ├── zod-boolean-transform.test.ts │ ├── zod-schema-transforms.test.ts │ └── zod-transforms.test.ts ├── index.ts ├── sonarqube.ts └── utils │ └── logger.ts ├── tmp └── .gitkeep └── tsconfig.json /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs CI checks for the project 2 | 3 | name: CI 4 | 5 | on: 6 | push: 7 | branches: [main] 8 | pull_request: 9 | branches: [main] 10 | 11 | jobs: 12 | 13 | build: 14 | permissions: 15 | contents: write 16 | 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | 25 | - name: Setup Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: '20' 29 | 30 | - name: Setup pnpm 31 | uses: pnpm/action-setup@v3 32 | with: 33 | version: 10.7.1 34 | 35 | - name: Install dependencies 36 | run: pnpm install --frozen-lockfile 37 | 38 | - name: Run CI scripts 39 | run: pnpm run ci 40 | 41 | - name: SonarQube Scan 42 | uses: SonarSource/sonarqube-scan-action@v5 43 | env: 44 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 45 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL Advanced" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | branches: [ "main" ] 19 | schedule: 20 | - cron: '33 15 * * 1' 21 | 22 | jobs: 23 | analyze: 24 | name: Analyze (${{ matrix.language }}) 25 | # Runner size impacts CodeQL analysis time. To learn more, please see: 26 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 27 | # - https://gh.io/supported-runners-and-hardware-resources 28 | # - https://gh.io/using-larger-runners (GitHub.com only) 29 | # Consider using larger runners or machines with greater resources for possible analysis time improvements. 30 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 31 | permissions: 32 | # required for all workflows 33 | security-events: write 34 | 35 | # required to fetch internal or private CodeQL packs 36 | packages: read 37 | 38 | # only required for workflows in private repositories 39 | actions: read 40 | contents: read 41 | 42 | strategy: 43 | fail-fast: false 44 | matrix: 45 | include: 46 | - language: actions 47 | build-mode: none 48 | - language: javascript-typescript 49 | build-mode: none 50 | # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' 51 | # Use `c-cpp` to analyze code written in C, C++ or both 52 | # Use 'java-kotlin' to analyze code written in Java, Kotlin or both 53 | # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 54 | # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, 55 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. 56 | # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how 57 | # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages 58 | steps: 59 | - name: Checkout repository 60 | uses: actions/checkout@v4 61 | 62 | # Add any setup steps before running the `github/codeql-action/init` action. 63 | # This includes steps like installing compilers or runtimes (`actions/setup-node` 64 | # or others). This is typically only required for manual builds. 65 | # - name: Setup runtime (example) 66 | # uses: actions/setup-example@v1 67 | 68 | # Initializes the CodeQL tools for scanning. 69 | - name: Initialize CodeQL 70 | uses: github/codeql-action/init@v3 71 | with: 72 | languages: ${{ matrix.language }} 73 | build-mode: ${{ matrix.build-mode }} 74 | # If you wish to specify custom queries, you can do so here or in a config file. 75 | # By default, queries listed here will override any specified in a config file. 76 | # Prefix the list here with "+" to use these queries and those in the config file. 77 | 78 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 79 | # queries: security-extended,security-and-quality 80 | 81 | # If the analyze step fails for one of the languages you are analyzing with 82 | # "We were unable to automatically build your code", modify the matrix above 83 | # to set the build mode to "manual" for that language. Then modify this step 84 | # to build your code. 85 | # ℹ️ Command-line programs to run using the OS shell. 86 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 87 | - if: matrix.build-mode == 'manual' 88 | shell: bash 89 | run: | 90 | echo 'If you are using a "manual" build mode for one or more of the' \ 91 | 'languages you are analyzing, replace this with the commands to build' \ 92 | 'your code, for example:' 93 | echo ' make bootstrap' 94 | echo ' make release' 95 | exit 1 96 | 97 | - name: Perform CodeQL Analysis 98 | uses: github/codeql-action/analyze@v3 99 | with: 100 | category: "/language:${{matrix.language}}" 101 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | ci: 9 | name: CI 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '20' 20 | 21 | - name: Setup pnpm 22 | uses: pnpm/action-setup@v3 23 | with: 24 | version: 10.7.1 25 | 26 | - name: Install dependencies 27 | run: pnpm install --frozen-lockfile 28 | 29 | - name: Build 30 | run: pnpm run ci 31 | 32 | docker-release: 33 | name: Docker Release 34 | runs-on: ubuntu-latest 35 | needs: 36 | - ci 37 | permissions: 38 | contents: write 39 | steps: 40 | - name: Checkout code 41 | uses: actions/checkout@v4 42 | with: 43 | fetch-depth: 0 44 | 45 | - name: Configure Git 46 | run: | 47 | git config --global user.name "GitHub Actions" 48 | git config --global user.email "actions@github.com" 49 | git checkout -B main 50 | 51 | - name: Set up QEMU 52 | uses: docker/setup-qemu-action@v3 53 | with: 54 | platforms: 'arm64,amd64' 55 | 56 | - name: Set up Docker Buildx 57 | uses: docker/setup-buildx-action@v3 58 | 59 | - name: Extract metadata (tags, labels) for Docker 60 | id: meta 61 | uses: docker/metadata-action@v5 62 | with: 63 | images: ${{ github.repository_owner }}/sonarqube-mcp-server 64 | tags: | 65 | type=semver,pattern={{version}} 66 | type=semver,pattern={{major}}.{{minor}} 67 | type=ref,event=branch 68 | type=sha 69 | latest 70 | 71 | - name: Login to DockerHub 72 | uses: docker/login-action@v3 73 | with: 74 | username: ${{ secrets.DOCKERHUB_USERNAME }} 75 | password: ${{ secrets.DOCKERHUB_TOKEN }} 76 | 77 | - name: Build and push Docker image 78 | uses: docker/build-push-action@v5 79 | with: 80 | context: . 81 | platforms: linux/amd64,linux/arm64 82 | push: true 83 | tags: ${{ steps.meta.outputs.tags }} 84 | labels: ${{ steps.meta.outputs.labels }} 85 | cache-from: type=gha 86 | cache-to: type=gha,mode=max 87 | 88 | npm-release: 89 | name: NPM Release 90 | runs-on: ubuntu-latest 91 | permissions: 92 | contents: write 93 | id-token: write 94 | needs: 95 | - ci 96 | steps: 97 | - name: Checkout code 98 | uses: actions/checkout@v4 99 | with: 100 | fetch-depth: 0 101 | 102 | - name: Configure Git 103 | run: | 104 | git config --global user.name "GitHub Actions" 105 | git config --global user.email "actions@github.com" 106 | git checkout -B main 107 | 108 | - name: Setup Node.js 109 | uses: actions/setup-node@v4 110 | with: 111 | node-version: '20.x' 112 | registry-url: 'https://registry.npmjs.org' 113 | 114 | - name: Setup pnpm 115 | uses: pnpm/action-setup@v4 116 | with: 117 | version: 10.7.1 118 | 119 | - name: Install dependencies 120 | run: pnpm install --frozen-lockfile 121 | 122 | - name: Build 123 | run: pnpm run build 124 | 125 | - name: Publish 126 | run: pnpm publish --provenance --access public 127 | env: 128 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | .pnpm-store/ 4 | 5 | # Build output 6 | dist/ 7 | coverage/ 8 | 9 | # IDE and editor files 10 | .vscode/ 11 | .idea/ 12 | *.swp 13 | *.swo 14 | 15 | # Logs 16 | *.log 17 | npm-debug.log* 18 | pnpm-debug.log* 19 | 20 | # Environment variables 21 | .env 22 | .env.local 23 | .env.*.local 24 | 25 | # OS files 26 | .DS_Store 27 | Thumbs.db 28 | **/.claude/settings.local.json 29 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm run ci || exit 1 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | coverage/ 4 | pnpm-lock.yaml 5 | package-lock.json 6 | *.d.ts -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "tabWidth": 2, 7 | "useTabs": false, 8 | "endOfLine": "lf" 9 | } -------------------------------------------------------------------------------- /.scannerwork/.sonar_lock: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sapientpants/sonarqube-mcp-server/42eb927079fa9b5a026dd3ea447fe66c506c5a53/.scannerwork/.sonar_lock -------------------------------------------------------------------------------- /.scannerwork/report-task.txt: -------------------------------------------------------------------------------- 1 | projectKey=sonarqube-mcp-server 2 | serverUrl=http://localhost:9000 3 | serverVersion=25.4.0.105899 4 | dashboardUrl=http://localhost:9000/dashboard?id=sonarqube-mcp-server 5 | ceTaskId=40cbeea4-68cf-4724-823c-e186d960c109 6 | ceTaskUrl=http://localhost:9000/api/ce/task?id=40cbeea4-68cf-4724-823c-e186d960c109 7 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Build and Test Commands 6 | 7 | ```bash 8 | # Install dependencies 9 | pnpm install 10 | 11 | # Build the project 12 | pnpm build 13 | 14 | # Run in development mode with auto-reload 15 | pnpm dev 16 | 17 | # Run tests 18 | pnpm test 19 | 20 | # Run a specific test file 21 | NODE_ENV=test NODE_OPTIONS='--experimental-vm-modules --no-warnings' jest src/__tests__/file-name.test.ts 22 | 23 | # Run tests with coverage 24 | pnpm test:coverage 25 | 26 | # Lint the code 27 | pnpm lint 28 | 29 | # Fix linting issues 30 | pnpm lint:fix 31 | 32 | # Check types without emitting files 33 | pnpm check-types 34 | 35 | # Format code with prettier 36 | pnpm format 37 | 38 | # Check code formatting 39 | pnpm format:check 40 | 41 | # Run all checks (format, lint, types, tests) 42 | pnpm validate 43 | 44 | # Inspect MCP tool schema 45 | pnpm inspect 46 | ``` 47 | 48 | ## Git Guidelines 49 | 50 | NEVER use `--no-verify` when committing code. This bypasses pre-commit hooks which run important validation checks. 51 | 52 | ## Architecture Overview 53 | 54 | This project is a Model Context Protocol (MCP) server for SonarQube, which allows AI assistants to interact with SonarQube instances through the MCP protocol. 55 | 56 | ### Key Components 57 | 58 | 1. **API Module (`api.ts`)** - Handles raw HTTP requests to the SonarQube API. 59 | 60 | 2. **SonarQube Client (`sonarqube.ts`)** - Main client implementation that: 61 | - Provides methods for interacting with SonarQube API endpoints 62 | - Handles parameter transformation and request formatting 63 | - Uses the API module for actual HTTP requests 64 | 65 | 3. **MCP Server (`index.ts`)** - Main entry point that: 66 | - Initializes the MCP server 67 | - Registers tools for SonarQube operations (projects, issues, metrics, etc.) 68 | - Maps incoming MCP tool requests to SonarQube client methods 69 | 70 | ### Data Flow 71 | 72 | 1. MCP clients make requests to the server through registered tools 73 | 2. Tool handlers transform parameters to SonarQube-compatible format 74 | 3. SonarQube client methods are called with transformed parameters 75 | 4. API module executes HTTP requests to SonarQube 76 | 5. Responses are formatted and returned to the client 77 | 78 | ### Testing Considerations 79 | 80 | - Tests use `nock` to mock HTTP responses for SonarQube API endpoints 81 | - The `axios` library is used for HTTP requests but is mocked in tests 82 | - Environment variables control SonarQube connection settings: 83 | - `SONARQUBE_TOKEN` (required) 84 | - `SONARQUBE_URL` (defaults to https://sonarcloud.io) 85 | - `SONARQUBE_ORGANIZATION` (optional) -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to SonarQube MCP Server 2 | 3 | Thank you for your interest in contributing to the SonarQube MCP Server! This document provides guidelines and instructions for contributing to the project. 4 | 5 | ## Code of Conduct 6 | 7 | By participating in this project, you agree to abide by our code of conduct: be respectful, inclusive, and constructive in all interactions. 8 | 9 | ## How to Contribute 10 | 11 | ### Reporting Issues 12 | 13 | 1. **Check existing issues** first to avoid duplicates 14 | 2. **Use issue templates** when available 15 | 3. **Provide detailed information**: 16 | - Steps to reproduce 17 | - Expected behavior 18 | - Actual behavior 19 | - Environment details (OS, Node version, etc.) 20 | - Error messages and logs 21 | 22 | ### Submitting Pull Requests 23 | 24 | 1. **Fork the repository** and create your branch from `main` 25 | 2. **Install dependencies** with `pnpm install` 26 | 3. **Make your changes**: 27 | - Write clear, concise commit messages 28 | - Follow the existing code style 29 | - Add tests for new features 30 | - Update documentation as needed 31 | 4. **Test your changes**: 32 | - Run `pnpm test` to ensure all tests pass 33 | - Run `pnpm lint` to check code style 34 | - Run `pnpm check-types` for TypeScript validation 35 | - Run `pnpm validate` to run all checks 36 | 5. **Submit the pull request**: 37 | - Provide a clear description of the changes 38 | - Reference any related issues 39 | - Ensure CI checks pass 40 | 41 | ## Development Setup 42 | 43 | ### Prerequisites 44 | 45 | - Node.js 20 or higher 46 | - pnpm 10.7.0 or higher 47 | - Git 48 | 49 | ### Setup Steps 50 | 51 | ```bash 52 | # Clone your fork 53 | git clone https://github.com/your-username/sonarqube-mcp-server.git 54 | cd sonarqube-mcp-server 55 | 56 | # Install dependencies 57 | pnpm install 58 | 59 | # Build the project 60 | pnpm build 61 | 62 | # Run tests 63 | pnpm test 64 | 65 | # Start development mode 66 | pnpm dev 67 | ``` 68 | 69 | ### Development Commands 70 | 71 | ```bash 72 | # Build the project 73 | pnpm build 74 | 75 | # Run in development mode with watch 76 | pnpm dev 77 | 78 | # Run all tests 79 | pnpm test 80 | 81 | # Run tests with coverage 82 | pnpm test:coverage 83 | 84 | # Run a specific test file 85 | NODE_ENV=test NODE_OPTIONS='--experimental-vm-modules --no-warnings' jest src/__tests__/file-name.test.ts 86 | 87 | # Lint code 88 | pnpm lint 89 | 90 | # Fix linting issues 91 | pnpm lint:fix 92 | 93 | # Check TypeScript types 94 | pnpm check-types 95 | 96 | # Format code 97 | pnpm format 98 | 99 | # Check formatting 100 | pnpm format:check 101 | 102 | # Run all validations 103 | pnpm validate 104 | 105 | # Inspect MCP schema 106 | pnpm inspect 107 | ``` 108 | 109 | ## Coding Standards 110 | 111 | ### TypeScript 112 | 113 | - Use TypeScript for all new code 114 | - Provide proper type definitions 115 | - Avoid using `any` type 116 | - Use interfaces for object shapes 117 | - Export types that might be used by consumers 118 | 119 | ### Code Style 120 | 121 | - Follow the existing code style 122 | - Use ESLint and Prettier configurations 123 | - Write self-documenting code 124 | - Add comments for complex logic 125 | - Keep functions small and focused 126 | 127 | ### Testing 128 | 129 | - Write tests for all new features 130 | - Maintain or improve code coverage 131 | - Use descriptive test names 132 | - Mock external dependencies 133 | - Test error cases and edge conditions 134 | 135 | ### Git Commit Messages 136 | 137 | Follow conventional commit format: 138 | 139 | ``` 140 | type(scope): subject 141 | 142 | body 143 | 144 | footer 145 | ``` 146 | 147 | Types: 148 | - `feat`: New feature 149 | - `fix`: Bug fix 150 | - `docs`: Documentation changes 151 | - `style`: Code style changes (formatting, etc.) 152 | - `refactor`: Code refactoring 153 | - `test`: Test additions or changes 154 | - `chore`: Build process or auxiliary tool changes 155 | 156 | Example: 157 | ``` 158 | feat(api): add support for branch filtering 159 | 160 | Add branch parameter to issues endpoint to allow filtering 161 | issues by specific branch 162 | 163 | Closes #123 164 | ``` 165 | 166 | ## Project Structure 167 | 168 | ``` 169 | sonarqube-mcp-server/ 170 | ├── src/ 171 | │ ├── __tests__/ # Test files 172 | │ ├── api.ts # API module for HTTP requests 173 | │ ├── index.ts # MCP server entry point 174 | │ └── sonarqube.ts # SonarQube client implementation 175 | ├── dist/ # Compiled output 176 | ├── package.json # Project configuration 177 | ├── tsconfig.json # TypeScript configuration 178 | ├── jest.config.js # Jest test configuration 179 | └── eslint.config.js # ESLint configuration 180 | ``` 181 | 182 | ## Testing Guidelines 183 | 184 | ### Unit Tests 185 | 186 | - Test individual functions and methods 187 | - Mock external dependencies 188 | - Cover edge cases and error scenarios 189 | - Use descriptive test names 190 | 191 | ### Integration Tests 192 | 193 | - Test API endpoints with mocked HTTP responses 194 | - Verify parameter transformation 195 | - Test error handling and retries 196 | 197 | ### Test Structure 198 | 199 | ```typescript 200 | describe('ComponentName', () => { 201 | describe('methodName', () => { 202 | it('should handle normal case', () => { 203 | // Test implementation 204 | }); 205 | 206 | it('should handle error case', () => { 207 | // Test implementation 208 | }); 209 | }); 210 | }); 211 | ``` 212 | 213 | ## Documentation 214 | 215 | - Update README.md for user-facing changes 216 | - Add JSDoc comments for public APIs 217 | - Include examples for new features 218 | - Keep documentation clear and concise 219 | 220 | ## Release Process 221 | 222 | 1. Ensure all tests pass 223 | 2. Update version in package.json 224 | 3. Update CHANGELOG.md 225 | 4. Create a pull request 226 | 5. After merge, create a release tag 227 | 6. Publish to npm 228 | 229 | ## Need Help? 230 | 231 | - Check the [README](README.md) for general information 232 | - Look at existing code for examples 233 | - Ask questions in GitHub issues 234 | - Review closed PRs for similar changes 235 | 236 | ## Recognition 237 | 238 | Contributors will be recognized in the project documentation. Thank you for helping improve the SonarQube MCP Server! -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | WORKDIR /app 4 | 5 | # Copy package files 6 | COPY package.json pnpm-lock.yaml ./ 7 | 8 | # Install pnpm 9 | RUN npm install -g pnpm@10.7.1 10 | 11 | # Disable Husky during Docker build 12 | ENV SKIP_HUSKY=1 13 | ENV NODE_ENV=production 14 | 15 | # Install dependencies 16 | RUN pnpm install --frozen-lockfile --ignore-scripts 17 | 18 | # Copy source code 19 | COPY . . 20 | 21 | # Build TypeScript code 22 | RUN pnpm run build 23 | 24 | # Clean up dev dependencies and install production dependencies 25 | RUN rm -rf node_modules && \ 26 | pnpm install --frozen-lockfile --prod --ignore-scripts 27 | 28 | # Expose the port the app runs on 29 | EXPOSE 3000 30 | 31 | # Start the server 32 | CMD ["node", "--experimental-specifier-resolution=node", "dist/index.js"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 sapientpants 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 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import tseslint from '@typescript-eslint/eslint-plugin'; 2 | import tsparser from '@typescript-eslint/parser'; 3 | 4 | export default [ 5 | { 6 | ignores: ['dist/**', 'node_modules/**', 'coverage/**', 'jest.config.js'] 7 | }, 8 | { 9 | files: ['**/*.ts'], 10 | languageOptions: { 11 | parser: tsparser, 12 | parserOptions: { 13 | ecmaVersion: 'latest', 14 | sourceType: 'module', 15 | }, 16 | }, 17 | plugins: { 18 | '@typescript-eslint': tseslint, 19 | }, 20 | rules: { 21 | ...tseslint.configs.recommended.rules, 22 | }, 23 | }, { 24 | plugins: { 25 | '@typescript-eslint': tseslint, 26 | }, 27 | rules: { 28 | ...tseslint.configs.recommended.rules 29 | }, 30 | }, 31 | ]; -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('jest').Config} */ 2 | export default { 3 | preset: 'ts-jest/presets/default-esm', 4 | testEnvironment: 'node', 5 | testMatch: ['**/__tests__/**/*.test.ts'], 6 | moduleNameMapper: { 7 | '^(\\.{1,2}/.*)\\.js$': '$1', 8 | '^(\\.{1,2}/.*)\\.ts$': '$1', 9 | }, 10 | transform: { 11 | '^.+\\.tsx?$': [ 12 | 'ts-jest', 13 | { 14 | useESM: true, 15 | tsconfig: { 16 | moduleResolution: "NodeNext" 17 | } 18 | }, 19 | ], 20 | }, 21 | extensionsToTreatAsEsm: ['.ts'], 22 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 23 | transformIgnorePatterns: [ 24 | 'node_modules/(?!(@modelcontextprotocol)/)' 25 | ], 26 | collectCoverageFrom: [ 27 | 'src/**/*.ts', 28 | '!src/**/*.d.ts', 29 | '!src/**/*.test.ts', 30 | '!src/__tests__/mocks/**/*.ts', 31 | ], 32 | coverageReporters: ['text', 'lcov'], 33 | testPathIgnorePatterns: [ 34 | '/node_modules/', 35 | '/src/__tests__/lambda-functions.test.ts', 36 | '/src/__tests__/handlers.test.ts', 37 | '/src/__tests__/tool-handlers.test.ts', 38 | '/src/__tests__/mocked-environment.test.ts', 39 | '/src/__tests__/direct-lambdas.test.ts' 40 | ], 41 | // Focusing on total coverage, with sonarqube.ts at 100% 42 | coverageThreshold: { 43 | "src/sonarqube.ts": { 44 | statements: 81, 45 | branches: 60, 46 | functions: 100, 47 | lines: 81 48 | }, 49 | global: { 50 | statements: 68, 51 | branches: 8, 52 | functions: 40, 53 | lines: 68 54 | } 55 | }, 56 | bail: 0 // Run all tests regardless of failures 57 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sonarqube-mcp-server", 3 | "version": "1.3.2", 4 | "description": "Model Context Protocol server for SonarQube", 5 | "type": "module", 6 | "main": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "bin": { 9 | "sonarqube-mcp-server": "./dist/index.js" 10 | }, 11 | "exports": { 12 | ".": { 13 | "import": "./dist/index.js", 14 | "types": "./dist/index.d.ts" 15 | } 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/sapientpants/sonarqube-mcp-server.git" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/sapientpants/sonarqube-mcp-server/issues" 23 | }, 24 | "scripts": { 25 | "build": "tsc", 26 | "ci": "pnpm run format:check && pnpm run lint && pnpm run check-types && pnpm run build && pnpm run test:coverage", 27 | "start": "node --experimental-specifier-resolution=node dist/index.js", 28 | "dev": "ts-node-dev --respawn --transpile-only src/index.ts", 29 | "watch": "tsc -w", 30 | "test": "NODE_ENV=test NODE_OPTIONS='--experimental-vm-modules --no-warnings' jest --detectOpenHandles --forceExit", 31 | "test:watch": "NODE_ENV=test NODE_OPTIONS='--experimental-vm-modules --no-warnings' jest --watch --detectOpenHandles --forceExit", 32 | "test:coverage": "NODE_ENV=test NODE_OPTIONS='--experimental-vm-modules --no-warnings' jest --coverage --detectOpenHandles --forceExit", 33 | "lint": "eslint . --ext .ts", 34 | "lint:fix": "eslint . --ext .ts --fix", 35 | "format": "prettier --write \"src/**/*.ts\"", 36 | "format:check": "prettier --check \"src/**/*.ts\"", 37 | "check-types": "tsc --noEmit", 38 | "prepare": "husky", 39 | "validate": "pnpm run check-types && pnpm run lint && pnpm run test", 40 | "inspect": "npx @modelcontextprotocol/inspector@latest node dist/index.js", 41 | "clean": "rm -rf dist" 42 | }, 43 | "lint-staged": { 44 | "*.{ts,tsx}": [ 45 | "prettier --check", 46 | "eslint" 47 | ] 48 | }, 49 | "keywords": [ 50 | "sonarqube", 51 | "mcp", 52 | "model-context-protocol" 53 | ], 54 | "author": "Marc Tremblay ", 55 | "homepage": "https://github.com/sapientpants/sonarqube-mcp-server", 56 | "license": "MIT", 57 | "packageManager": "pnpm@10.7.1", 58 | "dependencies": { 59 | "@modelcontextprotocol/sdk": "^1.12.1", 60 | "cors": "^2.8.5", 61 | "express": "^5.1.0", 62 | "sonarqube-web-api-client": "0.10.1", 63 | "zod": "^3.25.49" 64 | }, 65 | "devDependencies": { 66 | "@eslint/js": "^9.28.0", 67 | "@jest/globals": "^29.7.0", 68 | "@types/cors": "^2.8.18", 69 | "@types/express": "^5.0.2", 70 | "@types/jest": "^29.5.14", 71 | "@types/node": "^22.15.29", 72 | "@types/supertest": "^6.0.3", 73 | "@typescript-eslint/eslint-plugin": "^8.33.1", 74 | "@typescript-eslint/parser": "^8.33.1", 75 | "eslint": "^9.28.0", 76 | "eslint-config-prettier": "^10.1.5", 77 | "eslint-plugin-prettier": "^5.4.1", 78 | "husky": "^9.1.7", 79 | "jest": "^29.7.0", 80 | "lint-staged": "^16.1.0", 81 | "nock": "^14.0.5", 82 | "prettier": "^3.5.3", 83 | "supertest": "^7.1.1", 84 | "ts-jest": "^29.3.4", 85 | "ts-node": "^10.9.2", 86 | "ts-node-dev": "^2.0.0", 87 | "typescript": "^5.8.3" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=sonarqube-mcp-server 2 | sonar.organization=sapientpants 3 | sonar.sources=src 4 | sonar.exclusions=node_modules/**,**/__tests__/** 5 | sonar.tests=src 6 | sonar.test.inclusions=**/__tests__/*.test.ts 7 | sonar.typescript.lcov.reportPaths=coverage/lcov.info 8 | -------------------------------------------------------------------------------- /src/__tests__/additional-coverage.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; 8 | import nock from 'nock'; 9 | 10 | // Mock environment variables 11 | process.env.SONARQUBE_TOKEN = 'test-token'; 12 | process.env.SONARQUBE_URL = 'http://localhost:9000'; 13 | 14 | describe('Lambda Handlers Coverage Tests', () => { 15 | beforeEach(() => { 16 | jest.resetModules(); 17 | 18 | // Setup nock to mock SonarQube API responses 19 | nock('http://localhost:9000') 20 | .persist() 21 | .get('/api/metrics/search') 22 | .query(true) 23 | .reply(200, { 24 | metrics: [ 25 | { 26 | key: 'test-metric', 27 | name: 'Test Metric', 28 | description: 'Test metric description', 29 | domain: 'test', 30 | type: 'INT', 31 | }, 32 | ], 33 | paging: { 34 | pageIndex: 1, 35 | pageSize: 10, 36 | total: 1, 37 | }, 38 | }); 39 | 40 | nock('http://localhost:9000') 41 | .persist() 42 | .get('/api/measures/component') 43 | .query(true) 44 | .reply(200, { 45 | component: { 46 | key: 'test-component', 47 | name: 'Test Component', 48 | qualifier: 'TRK', 49 | measures: [ 50 | { 51 | metric: 'coverage', 52 | value: '85.4', 53 | }, 54 | ], 55 | }, 56 | metrics: [ 57 | { 58 | key: 'coverage', 59 | name: 'Coverage', 60 | description: 'Test coverage', 61 | domain: 'Coverage', 62 | type: 'PERCENT', 63 | }, 64 | ], 65 | }); 66 | 67 | nock('http://localhost:9000') 68 | .persist() 69 | .get('/api/measures/components') 70 | .query(true) 71 | .reply(200, { 72 | components: [ 73 | { 74 | key: 'test-component-1', 75 | name: 'Test Component 1', 76 | qualifier: 'TRK', 77 | measures: [ 78 | { 79 | metric: 'coverage', 80 | value: '85.4', 81 | }, 82 | ], 83 | }, 84 | ], 85 | metrics: [ 86 | { 87 | key: 'coverage', 88 | name: 'Coverage', 89 | description: 'Test coverage', 90 | domain: 'Coverage', 91 | type: 'PERCENT', 92 | }, 93 | ], 94 | paging: { 95 | pageIndex: 1, 96 | pageSize: 100, 97 | total: 1, 98 | }, 99 | }); 100 | 101 | nock('http://localhost:9000') 102 | .persist() 103 | .get('/api/measures/search_history') 104 | .query(true) 105 | .reply(200, { 106 | measures: [ 107 | { 108 | metric: 'coverage', 109 | history: [ 110 | { 111 | date: '2023-01-01T00:00:00+0000', 112 | value: '80.0', 113 | }, 114 | ], 115 | }, 116 | ], 117 | paging: { 118 | pageIndex: 1, 119 | pageSize: 100, 120 | total: 1, 121 | }, 122 | }); 123 | 124 | // No need for this now since we're importing directly in each test 125 | }); 126 | 127 | afterEach(() => { 128 | nock.cleanAll(); 129 | }); 130 | 131 | // Import the module directly in each test to ensure it's available 132 | it('should call metricsHandler', async () => { 133 | const module = await import('../index.js'); 134 | const result = await module.metricsHandler({ page: '1', page_size: '10' }); 135 | expect(result).toBeDefined(); 136 | expect(result.content).toBeDefined(); 137 | expect(result.content[0].text).toBeDefined(); 138 | }); 139 | 140 | it('should call componentMeasuresHandler', async () => { 141 | const module = await import('../index.js'); 142 | const result = await module.componentMeasuresHandler({ 143 | component: 'test-component', 144 | metric_keys: ['coverage'], 145 | additional_fields: ['periods'], 146 | branch: 'main', 147 | pull_request: 'pr-123', 148 | period: '1', 149 | }); 150 | expect(result).toBeDefined(); 151 | expect(result.content).toBeDefined(); 152 | expect(result.content[0].text).toBeDefined(); 153 | }); 154 | 155 | it('should call componentsMeasuresHandler', async () => { 156 | const module = await import('../index.js'); 157 | const result = await module.componentsMeasuresHandler({ 158 | component_keys: ['component1', 'component2'], 159 | metric_keys: ['coverage', 'bugs'], 160 | additional_fields: ['metrics'], 161 | branch: 'develop', 162 | pull_request: 'pr-456', 163 | period: '2', 164 | page: '1', 165 | page_size: '20', 166 | }); 167 | expect(result).toBeDefined(); 168 | expect(result.content).toBeDefined(); 169 | expect(result.content[0].text).toBeDefined(); 170 | }); 171 | 172 | it('should call measuresHistoryHandler', async () => { 173 | const module = await import('../index.js'); 174 | const result = await module.measuresHistoryHandler({ 175 | component: 'test-component', 176 | metrics: ['coverage', 'bugs'], 177 | from: '2023-01-01', 178 | to: '2023-12-31', 179 | branch: 'feature', 180 | pull_request: 'pr-789', 181 | page: '1', 182 | page_size: '30', 183 | }); 184 | expect(result).toBeDefined(); 185 | expect(result.content).toBeDefined(); 186 | expect(result.content[0].text).toBeDefined(); 187 | }); 188 | }); 189 | -------------------------------------------------------------------------------- /src/__tests__/advanced-index.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect, beforeEach, afterEach, beforeAll, jest } from '@jest/globals'; 8 | import nock from 'nock'; 9 | import { z } from 'zod'; 10 | 11 | // Mock environment variables 12 | process.env.SONARQUBE_TOKEN = 'test-token'; 13 | process.env.SONARQUBE_URL = 'http://localhost:9000'; 14 | process.env.SONARQUBE_ORGANIZATION = 'test-org'; 15 | 16 | // Save environment variables 17 | const originalEnv = process.env; 18 | 19 | beforeAll(() => { 20 | nock.cleanAll(); 21 | // Common mocks for all tests 22 | nock('http://localhost:9000') 23 | .persist() 24 | .get('/api/projects/search') 25 | .query(true) 26 | .reply(200, { 27 | components: [ 28 | { 29 | key: 'test-project', 30 | name: 'Test Project', 31 | qualifier: 'TRK', 32 | visibility: 'public', 33 | }, 34 | ], 35 | paging: { 36 | pageIndex: 1, 37 | pageSize: 10, 38 | total: 1, 39 | }, 40 | }); 41 | }); 42 | 43 | afterAll(() => { 44 | nock.cleanAll(); 45 | }); 46 | 47 | /* eslint-disable @typescript-eslint/no-explicit-any */ 48 | let nullToUndefined: any; 49 | let mapToSonarQubeParams: any; 50 | /* eslint-enable @typescript-eslint/no-explicit-any */ 51 | 52 | // No need to mock axios anymore since we're using sonarqube-web-api-client 53 | 54 | describe('Advanced MCP Server Tests', () => { 55 | beforeAll(async () => { 56 | // Import functions we need to test 57 | const module = await import('../index.js'); 58 | nullToUndefined = module.nullToUndefined; 59 | mapToSonarQubeParams = module.mapToSonarQubeParams; 60 | }); 61 | 62 | beforeEach(() => { 63 | jest.resetModules(); 64 | process.env = { ...originalEnv }; 65 | }); 66 | 67 | afterEach(() => { 68 | process.env = originalEnv; 69 | jest.clearAllMocks(); 70 | nock.cleanAll(); 71 | }); 72 | 73 | describe('Schema Transformation Tests', () => { 74 | it('should transform page parameters correctly', () => { 75 | // Create a schema that matches the one in the tool registration 76 | const pageSchema = z 77 | .string() 78 | .optional() 79 | .transform((val) => (val ? parseInt(val, 10) || null : null)); 80 | 81 | // Test valid inputs 82 | expect(pageSchema.parse('10')).toBe(10); 83 | expect(pageSchema.parse('100')).toBe(100); 84 | 85 | // Test invalid or empty inputs 86 | expect(pageSchema.parse('')).toBe(null); 87 | expect(pageSchema.parse('abc')).toBe(null); 88 | expect(pageSchema.parse(undefined)).toBe(null); 89 | }); 90 | 91 | it('should transform boolean parameters correctly', () => { 92 | const booleanSchema = z 93 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 94 | .nullable() 95 | .optional(); 96 | 97 | // Test string values 98 | expect(booleanSchema.parse('true')).toBe(true); 99 | expect(booleanSchema.parse('false')).toBe(false); 100 | 101 | // Test boolean values 102 | expect(booleanSchema.parse(true)).toBe(true); 103 | expect(booleanSchema.parse(false)).toBe(false); 104 | 105 | // Test null/undefined values 106 | expect(booleanSchema.parse(null)).toBe(null); 107 | expect(booleanSchema.parse(undefined)).toBe(undefined); 108 | }); 109 | }); 110 | 111 | describe('nullToUndefined Tests', () => { 112 | it('should convert null to undefined', () => { 113 | expect(nullToUndefined(null)).toBeUndefined(); 114 | }); 115 | 116 | it('should pass through other values', () => { 117 | expect(nullToUndefined(123)).toBe(123); 118 | expect(nullToUndefined('string')).toBe('string'); 119 | expect(nullToUndefined(false)).toBe(false); 120 | expect(nullToUndefined({})).toEqual({}); 121 | expect(nullToUndefined([])).toEqual([]); 122 | expect(nullToUndefined(undefined)).toBeUndefined(); 123 | }); 124 | }); 125 | 126 | describe('mapToSonarQubeParams Tests', () => { 127 | it('should map MCP parameters to SonarQube parameters', () => { 128 | const mcpParams = { 129 | project_key: 'test-project', 130 | severity: 'MAJOR', 131 | page: 1, 132 | page_size: 10, 133 | }; 134 | 135 | const sonarQubeParams = mapToSonarQubeParams(mcpParams); 136 | 137 | expect(sonarQubeParams.projectKey).toBe('test-project'); 138 | expect(sonarQubeParams.severity).toBe('MAJOR'); 139 | expect(sonarQubeParams.page).toBe(1); 140 | expect(sonarQubeParams.pageSize).toBe(10); 141 | }); 142 | 143 | it('should handle empty optional parameters', () => { 144 | const mcpParams = { 145 | project_key: 'test-project', 146 | }; 147 | 148 | const sonarQubeParams = mapToSonarQubeParams(mcpParams); 149 | 150 | expect(sonarQubeParams.projectKey).toBe('test-project'); 151 | expect(sonarQubeParams.severity).toBeUndefined(); 152 | expect(sonarQubeParams.page).toBeUndefined(); 153 | expect(sonarQubeParams.pageSize).toBeUndefined(); 154 | }); 155 | 156 | it('should handle array parameters', () => { 157 | const mcpParams = { 158 | project_key: 'test-project', 159 | statuses: ['OPEN', 'CONFIRMED'], 160 | types: ['BUG', 'VULNERABILITY'], 161 | }; 162 | 163 | const sonarQubeParams = mapToSonarQubeParams(mcpParams); 164 | 165 | expect(sonarQubeParams.projectKey).toBe('test-project'); 166 | expect(sonarQubeParams.statuses).toEqual(['OPEN', 'CONFIRMED']); 167 | expect(sonarQubeParams.types).toEqual(['BUG', 'VULNERABILITY']); 168 | }); 169 | 170 | it('should handle boolean parameters', () => { 171 | const mcpParams = { 172 | project_key: 'test-project', 173 | resolved: true, 174 | on_component_only: false, 175 | }; 176 | 177 | const sonarQubeParams = mapToSonarQubeParams(mcpParams); 178 | 179 | expect(sonarQubeParams.projectKey).toBe('test-project'); 180 | expect(sonarQubeParams.resolved).toBe(true); 181 | expect(sonarQubeParams.onComponentOnly).toBe(false); 182 | }); 183 | }); 184 | 185 | describe('Environment Handling', () => { 186 | it('should correctly retrieve environment variables', () => { 187 | expect(process.env.SONARQUBE_TOKEN).toBe('test-token'); 188 | expect(process.env.SONARQUBE_URL).toBe('http://localhost:9000'); 189 | expect(process.env.SONARQUBE_ORGANIZATION).toBe('test-org'); 190 | }); 191 | }); 192 | }); 193 | -------------------------------------------------------------------------------- /src/__tests__/auth-methods.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; 2 | import { 3 | createSonarQubeClient, 4 | createSonarQubeClientWithBasicAuth, 5 | createSonarQubeClientWithPasscode, 6 | createSonarQubeClientFromEnv, 7 | SonarQubeClient, 8 | } from '../sonarqube.js'; 9 | 10 | describe('Authentication Methods', () => { 11 | // Save original env vars 12 | const originalEnv = process.env; 13 | 14 | beforeEach(() => { 15 | // Clear environment variables 16 | process.env = { ...originalEnv }; 17 | delete process.env.SONARQUBE_TOKEN; 18 | delete process.env.SONARQUBE_USERNAME; 19 | delete process.env.SONARQUBE_PASSWORD; 20 | delete process.env.SONARQUBE_PASSCODE; 21 | delete process.env.SONARQUBE_URL; 22 | delete process.env.SONARQUBE_ORGANIZATION; 23 | }); 24 | 25 | afterEach(() => { 26 | // Restore original env vars 27 | process.env = originalEnv; 28 | }); 29 | 30 | describe('createSonarQubeClient', () => { 31 | it('should create a client with token authentication', () => { 32 | const client = createSonarQubeClient('test-token', 'https://sonarqube.example.com', 'org1'); 33 | expect(client).toBeDefined(); 34 | expect(client).toBeInstanceOf(SonarQubeClient); 35 | }); 36 | 37 | it('should use default URL when not provided', () => { 38 | const client = createSonarQubeClient('test-token'); 39 | expect(client).toBeDefined(); 40 | }); 41 | }); 42 | 43 | describe('createSonarQubeClientWithBasicAuth', () => { 44 | it('should create a client with basic authentication', () => { 45 | const client = createSonarQubeClientWithBasicAuth( 46 | 'username', 47 | 'password', 48 | 'https://sonarqube.example.com', 49 | 'org1' 50 | ); 51 | expect(client).toBeDefined(); 52 | expect(client).toBeInstanceOf(SonarQubeClient); 53 | }); 54 | 55 | it('should use default URL when not provided', () => { 56 | const client = createSonarQubeClientWithBasicAuth('username', 'password'); 57 | expect(client).toBeDefined(); 58 | }); 59 | }); 60 | 61 | describe('createSonarQubeClientWithPasscode', () => { 62 | it('should create a client with passcode authentication', () => { 63 | const client = createSonarQubeClientWithPasscode( 64 | 'test-passcode', 65 | 'https://sonarqube.example.com', 66 | 'org1' 67 | ); 68 | expect(client).toBeDefined(); 69 | expect(client).toBeInstanceOf(SonarQubeClient); 70 | }); 71 | 72 | it('should use default URL when not provided', () => { 73 | const client = createSonarQubeClientWithPasscode('test-passcode'); 74 | expect(client).toBeDefined(); 75 | }); 76 | }); 77 | 78 | describe('createSonarQubeClientFromEnv', () => { 79 | it('should create a client with token from environment', () => { 80 | process.env.SONARQUBE_TOKEN = 'env-token'; 81 | process.env.SONARQUBE_URL = 'https://sonarqube.example.com'; 82 | process.env.SONARQUBE_ORGANIZATION = 'org1'; 83 | 84 | const client = createSonarQubeClientFromEnv(); 85 | expect(client).toBeDefined(); 86 | expect(client).toBeInstanceOf(SonarQubeClient); 87 | }); 88 | 89 | it('should create a client with basic auth from environment', () => { 90 | process.env.SONARQUBE_USERNAME = 'env-user'; 91 | process.env.SONARQUBE_PASSWORD = 'env-pass'; 92 | process.env.SONARQUBE_URL = 'https://sonarqube.example.com'; 93 | 94 | const client = createSonarQubeClientFromEnv(); 95 | expect(client).toBeDefined(); 96 | expect(client).toBeInstanceOf(SonarQubeClient); 97 | }); 98 | 99 | it('should create a client with passcode from environment', () => { 100 | process.env.SONARQUBE_PASSCODE = 'env-passcode'; 101 | process.env.SONARQUBE_URL = 'https://sonarqube.example.com'; 102 | 103 | const client = createSonarQubeClientFromEnv(); 104 | expect(client).toBeDefined(); 105 | expect(client).toBeInstanceOf(SonarQubeClient); 106 | }); 107 | 108 | it('should prioritize token auth when multiple methods are available', () => { 109 | process.env.SONARQUBE_TOKEN = 'env-token'; 110 | process.env.SONARQUBE_USERNAME = 'env-user'; 111 | process.env.SONARQUBE_PASSWORD = 'env-pass'; 112 | process.env.SONARQUBE_PASSCODE = 'env-passcode'; 113 | 114 | // Should not throw and use token auth 115 | const client = createSonarQubeClientFromEnv(); 116 | expect(client).toBeDefined(); 117 | }); 118 | 119 | it('should use default URL when not provided', () => { 120 | process.env.SONARQUBE_TOKEN = 'env-token'; 121 | 122 | const client = createSonarQubeClientFromEnv(); 123 | expect(client).toBeDefined(); 124 | }); 125 | 126 | it('should throw error when no authentication is configured', () => { 127 | expect(() => createSonarQubeClientFromEnv()).toThrow( 128 | 'No SonarQube authentication configured' 129 | ); 130 | }); 131 | 132 | it('should create client with basic auth when only username is provided (legacy token auth)', () => { 133 | process.env.SONARQUBE_USERNAME = 'env-user'; 134 | 135 | const client = createSonarQubeClientFromEnv(); 136 | expect(client).toBeDefined(); 137 | }); 138 | 139 | it('should throw error when only password is provided', () => { 140 | process.env.SONARQUBE_PASSWORD = 'env-pass'; 141 | 142 | expect(() => createSonarQubeClientFromEnv()).toThrow( 143 | 'No SonarQube authentication configured' 144 | ); 145 | }); 146 | }); 147 | 148 | describe('SonarQubeClient static factory methods', () => { 149 | it('should create client with withBasicAuth', () => { 150 | const client = SonarQubeClient.withBasicAuth( 151 | 'username', 152 | 'password', 153 | 'https://sonarqube.example.com', 154 | 'org1' 155 | ); 156 | expect(client).toBeDefined(); 157 | expect(client).toBeInstanceOf(SonarQubeClient); 158 | }); 159 | 160 | it('should create client with withPasscode', () => { 161 | const client = SonarQubeClient.withPasscode( 162 | 'passcode', 163 | 'https://sonarqube.example.com', 164 | 'org1' 165 | ); 166 | expect(client).toBeDefined(); 167 | expect(client).toBeInstanceOf(SonarQubeClient); 168 | }); 169 | 170 | it('should use default URL in static methods', () => { 171 | const basicClient = SonarQubeClient.withBasicAuth('username', 'password'); 172 | expect(basicClient).toBeDefined(); 173 | 174 | const passcodeClient = SonarQubeClient.withPasscode('passcode'); 175 | expect(passcodeClient).toBeDefined(); 176 | }); 177 | }); 178 | }); 179 | -------------------------------------------------------------------------------- /src/__tests__/boolean-string-transform.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect } from '@jest/globals'; 8 | import { z } from 'zod'; 9 | 10 | describe('Boolean string transform', () => { 11 | // Test the boolean transform that's used in the tool registrations 12 | const booleanStringTransform = (val: string) => val === 'true'; 13 | 14 | // Create a schema that matches the one in index.ts 15 | const booleanSchema = z 16 | .union([z.boolean(), z.string().transform(booleanStringTransform)]) 17 | .nullable() 18 | .optional(); 19 | 20 | describe('direct transform function', () => { 21 | it('should transform "true" to true', () => { 22 | expect(booleanStringTransform('true')).toBe(true); 23 | }); 24 | 25 | it('should transform anything else to false', () => { 26 | expect(booleanStringTransform('false')).toBe(false); 27 | expect(booleanStringTransform('True')).toBe(false); 28 | expect(booleanStringTransform('1')).toBe(false); 29 | expect(booleanStringTransform('')).toBe(false); 30 | }); 31 | }); 32 | 33 | describe('zod schema with boolean transform', () => { 34 | it('should accept and pass through boolean values', () => { 35 | expect(booleanSchema.parse(true)).toBe(true); 36 | expect(booleanSchema.parse(false)).toBe(false); 37 | }); 38 | 39 | it('should transform string "true" to boolean true', () => { 40 | expect(booleanSchema.parse('true')).toBe(true); 41 | }); 42 | 43 | it('should transform other string values to boolean false', () => { 44 | expect(booleanSchema.parse('false')).toBe(false); 45 | expect(booleanSchema.parse('1')).toBe(false); 46 | expect(booleanSchema.parse('')).toBe(false); 47 | }); 48 | 49 | it('should pass through null and undefined', () => { 50 | expect(booleanSchema.parse(null)).toBeNull(); 51 | expect(booleanSchema.parse(undefined)).toBeUndefined(); 52 | }); 53 | }); 54 | 55 | // Test multiple boolean schema transformations in the same schema 56 | describe('multiple boolean transforms in schema', () => { 57 | // Create a schema with multiple boolean transforms 58 | const complexSchema = z.object({ 59 | resolved: z 60 | .union([z.boolean(), z.string().transform(booleanStringTransform)]) 61 | .nullable() 62 | .optional(), 63 | on_component_only: z 64 | .union([z.boolean(), z.string().transform(booleanStringTransform)]) 65 | .nullable() 66 | .optional(), 67 | since_leak_period: z 68 | .union([z.boolean(), z.string().transform(booleanStringTransform)]) 69 | .nullable() 70 | .optional(), 71 | in_new_code_period: z 72 | .union([z.boolean(), z.string().transform(booleanStringTransform)]) 73 | .nullable() 74 | .optional(), 75 | }); 76 | 77 | it('should transform multiple boolean string values', () => { 78 | const result = complexSchema.parse({ 79 | resolved: 'true', 80 | on_component_only: 'false', 81 | since_leak_period: true, 82 | in_new_code_period: 'true', 83 | }); 84 | 85 | expect(result).toEqual({ 86 | resolved: true, 87 | on_component_only: false, 88 | since_leak_period: true, 89 | in_new_code_period: true, 90 | }); 91 | }); 92 | 93 | it('should handle mix of boolean, string, null and undefined values', () => { 94 | const result = complexSchema.parse({ 95 | resolved: true, 96 | on_component_only: 'true', 97 | since_leak_period: null, 98 | }); 99 | 100 | expect(result).toEqual({ 101 | resolved: true, 102 | on_component_only: true, 103 | since_leak_period: null, 104 | }); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/__tests__/dependency-injection.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; 8 | import { createDefaultClient } from '../index.js'; 9 | import { createSonarQubeClient } from '../sonarqube.js'; 10 | 11 | // Save original environment variables 12 | const originalEnv = process.env; 13 | 14 | describe('Client Configuration Tests', () => { 15 | beforeEach(() => { 16 | jest.resetModules(); 17 | process.env = { ...originalEnv }; 18 | process.env.SONARQUBE_TOKEN = 'test-token'; 19 | process.env.SONARQUBE_URL = 'http://localhost:9000'; 20 | process.env.SONARQUBE_ORGANIZATION = 'test-org'; 21 | }); 22 | 23 | afterEach(() => { 24 | process.env = originalEnv; 25 | jest.clearAllMocks(); 26 | }); 27 | 28 | describe('Client Factory Functions', () => { 29 | it('should create a client with default parameters', () => { 30 | // Test the factory function 31 | const client = createSonarQubeClient('test-token'); 32 | expect(client).toBeDefined(); 33 | }); 34 | 35 | it('should create a default client using environment variables', () => { 36 | // Test the default client creation function 37 | const client = createDefaultClient(); 38 | expect(client).toBeDefined(); 39 | }); 40 | 41 | it('should create a client with custom base URL and organization', () => { 42 | const customUrl = 'https://custom-sonar.example.com'; 43 | const customOrg = 'custom-org'; 44 | 45 | const client = createSonarQubeClient('test-token', customUrl, customOrg); 46 | expect(client).toBeDefined(); 47 | }); 48 | 49 | it('should handle null organization parameter', () => { 50 | const client = createSonarQubeClient('test-token', undefined, null); 51 | expect(client).toBeDefined(); 52 | }); 53 | }); 54 | 55 | describe('Environment Variable Configuration', () => { 56 | it('should use environment variables for default client creation', () => { 57 | process.env.SONARQUBE_TOKEN = 'env-token'; 58 | process.env.SONARQUBE_URL = 'https://env-sonar.example.com'; 59 | process.env.SONARQUBE_ORGANIZATION = 'env-org'; 60 | 61 | const client = createDefaultClient(); 62 | expect(client).toBeDefined(); 63 | }); 64 | 65 | it('should handle missing optional environment variables', () => { 66 | process.env.SONARQUBE_TOKEN = 'env-token'; 67 | delete process.env.SONARQUBE_URL; 68 | delete process.env.SONARQUBE_ORGANIZATION; 69 | 70 | const client = createDefaultClient(); 71 | expect(client).toBeDefined(); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/__tests__/direct-schema-validation.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { describe, it, expect } from '@jest/globals'; 6 | import { z } from 'zod'; 7 | 8 | describe('Schema Validation by Direct Testing', () => { 9 | // Test specific schema transformation functions 10 | describe('Transformation Functions', () => { 11 | it('should transform string numbers to integers or null', () => { 12 | // Create a transformation function similar to the ones in index.ts 13 | const transformFn = (val?: string) => (val ? parseInt(val, 10) || null : null); 14 | 15 | // Valid number 16 | expect(transformFn('10')).toBe(10); 17 | 18 | // Empty string should return null 19 | expect(transformFn('')).toBe(null); 20 | 21 | // Invalid number should return null 22 | expect(transformFn('abc')).toBe(null); 23 | 24 | // Undefined should return null 25 | expect(transformFn(undefined)).toBe(null); 26 | }); 27 | 28 | it('should transform string booleans to boolean values', () => { 29 | // Create a schema with boolean transformation 30 | const booleanSchema = z 31 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 32 | .nullable() 33 | .optional(); 34 | 35 | // Test the transformation 36 | expect(booleanSchema.parse('true')).toBe(true); 37 | expect(booleanSchema.parse('false')).toBe(false); 38 | expect(booleanSchema.parse(true)).toBe(true); 39 | expect(booleanSchema.parse(false)).toBe(false); 40 | expect(booleanSchema.parse(null)).toBe(null); 41 | expect(booleanSchema.parse(undefined)).toBe(undefined); 42 | }); 43 | 44 | it('should validate enum values', () => { 45 | // Create a schema with enum validation 46 | const severitySchema = z 47 | .enum(['INFO', 'MINOR', 'MAJOR', 'CRITICAL', 'BLOCKER']) 48 | .nullable() 49 | .optional(); 50 | 51 | // Test the validation 52 | expect(severitySchema.parse('MAJOR')).toBe('MAJOR'); 53 | expect(severitySchema.parse('CRITICAL')).toBe('CRITICAL'); 54 | expect(severitySchema.parse(null)).toBe(null); 55 | expect(severitySchema.parse(undefined)).toBe(undefined); 56 | 57 | // Invalid value should throw 58 | expect(() => severitySchema.parse('INVALID')).toThrow(); 59 | }); 60 | }); 61 | 62 | // Test complex schema objects 63 | describe('Complex Schema Objects', () => { 64 | it('should validate issues schema parameters', () => { 65 | // Create a schema similar to issues schema in index.ts 66 | const statusEnumSchema = z.enum([ 67 | 'OPEN', 68 | 'CONFIRMED', 69 | 'REOPENED', 70 | 'RESOLVED', 71 | 'CLOSED', 72 | 'TO_REVIEW', 73 | 'IN_REVIEW', 74 | 'REVIEWED', 75 | ]); 76 | const statusSchema = z.array(statusEnumSchema).nullable().optional(); 77 | 78 | const resolutionEnumSchema = z.enum(['FALSE-POSITIVE', 'WONTFIX', 'FIXED', 'REMOVED']); 79 | const resolutionSchema = z.array(resolutionEnumSchema).nullable().optional(); 80 | 81 | const typeEnumSchema = z.enum(['CODE_SMELL', 'BUG', 'VULNERABILITY', 'SECURITY_HOTSPOT']); 82 | const typeSchema = z.array(typeEnumSchema).nullable().optional(); 83 | 84 | const issuesSchema = z.object({ 85 | project_key: z.string(), 86 | severity: z.enum(['INFO', 'MINOR', 'MAJOR', 'CRITICAL', 'BLOCKER']).nullable().optional(), 87 | page: z 88 | .string() 89 | .optional() 90 | .transform((val) => (val ? parseInt(val, 10) || null : null)), 91 | page_size: z 92 | .string() 93 | .optional() 94 | .transform((val) => (val ? parseInt(val, 10) || null : null)), 95 | statuses: statusSchema, 96 | resolutions: resolutionSchema, 97 | resolved: z 98 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 99 | .nullable() 100 | .optional(), 101 | types: typeSchema, 102 | rules: z.array(z.string()).nullable().optional(), 103 | tags: z.array(z.string()).nullable().optional(), 104 | created_after: z.string().nullable().optional(), 105 | created_before: z.string().nullable().optional(), 106 | created_at: z.string().nullable().optional(), 107 | created_in_last: z.string().nullable().optional(), 108 | assignees: z.array(z.string()).nullable().optional(), 109 | authors: z.array(z.string()).nullable().optional(), 110 | cwe: z.array(z.string()).nullable().optional(), 111 | languages: z.array(z.string()).nullable().optional(), 112 | owasp_top10: z.array(z.string()).nullable().optional(), 113 | sans_top25: z.array(z.string()).nullable().optional(), 114 | sonarsource_security: z.array(z.string()).nullable().optional(), 115 | on_component_only: z 116 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 117 | .nullable() 118 | .optional(), 119 | facets: z.array(z.string()).nullable().optional(), 120 | since_leak_period: z 121 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 122 | .nullable() 123 | .optional(), 124 | in_new_code_period: z 125 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 126 | .nullable() 127 | .optional(), 128 | }); 129 | 130 | // Test with various parameter types 131 | const result = issuesSchema.parse({ 132 | project_key: 'test-project', 133 | severity: 'MAJOR', 134 | page: '2', 135 | page_size: '10', 136 | statuses: ['OPEN', 'CONFIRMED'], 137 | resolved: 'true', 138 | types: ['BUG', 'VULNERABILITY'], 139 | rules: ['rule1', 'rule2'], 140 | tags: ['tag1', 'tag2'], 141 | created_after: '2023-01-01', 142 | on_component_only: 'true', 143 | since_leak_period: 'true', 144 | in_new_code_period: 'true', 145 | }); 146 | 147 | // Check the transformations 148 | expect(result.project_key).toBe('test-project'); 149 | expect(result.severity).toBe('MAJOR'); 150 | expect(result.page).toBe(2); 151 | expect(result.page_size).toBe(10); 152 | expect(result.statuses).toEqual(['OPEN', 'CONFIRMED']); 153 | expect(result.resolved).toBe(true); 154 | expect(result.types).toEqual(['BUG', 'VULNERABILITY']); 155 | expect(result.on_component_only).toBe(true); 156 | expect(result.since_leak_period).toBe(true); 157 | expect(result.in_new_code_period).toBe(true); 158 | }); 159 | 160 | it('should validate component measures schema parameters', () => { 161 | // Create a schema similar to component measures schema in index.ts 162 | const measuresComponentSchema = z.object({ 163 | component: z.string(), 164 | metric_keys: z.array(z.string()), 165 | branch: z.string().optional(), 166 | pull_request: z.string().optional(), 167 | additional_fields: z.array(z.string()).optional(), 168 | period: z.string().optional(), 169 | page: z 170 | .string() 171 | .optional() 172 | .transform((val) => (val ? parseInt(val, 10) || null : null)), 173 | page_size: z 174 | .string() 175 | .optional() 176 | .transform((val) => (val ? parseInt(val, 10) || null : null)), 177 | }); 178 | 179 | // Test with valid parameters 180 | const result = measuresComponentSchema.parse({ 181 | component: 'test-component', 182 | metric_keys: ['complexity', 'coverage'], 183 | branch: 'main', 184 | additional_fields: ['metrics'], 185 | page: '2', 186 | page_size: '20', 187 | }); 188 | 189 | // Check the transformations 190 | expect(result.component).toBe('test-component'); 191 | expect(result.metric_keys).toEqual(['complexity', 'coverage']); 192 | expect(result.branch).toBe('main'); 193 | expect(result.page).toBe(2); 194 | expect(result.page_size).toBe(20); 195 | 196 | // Test with invalid page values 197 | const result2 = measuresComponentSchema.parse({ 198 | component: 'test-component', 199 | metric_keys: ['complexity', 'coverage'], 200 | page: 'invalid', 201 | page_size: 'invalid', 202 | }); 203 | 204 | expect(result2.page).toBe(null); 205 | expect(result2.page_size).toBe(null); 206 | }); 207 | 208 | it('should validate components measures schema parameters', () => { 209 | // Create a schema similar to components measures schema in index.ts 210 | const measuresComponentsSchema = z.object({ 211 | component_keys: z.array(z.string()), 212 | metric_keys: z.array(z.string()), 213 | branch: z.string().optional(), 214 | pull_request: z.string().optional(), 215 | additional_fields: z.array(z.string()).optional(), 216 | period: z.string().optional(), 217 | page: z 218 | .string() 219 | .optional() 220 | .transform((val) => (val ? parseInt(val, 10) || null : null)), 221 | page_size: z 222 | .string() 223 | .optional() 224 | .transform((val) => (val ? parseInt(val, 10) || null : null)), 225 | }); 226 | 227 | // Test with valid parameters 228 | const result = measuresComponentsSchema.parse({ 229 | component_keys: ['comp-1', 'comp-2'], 230 | metric_keys: ['complexity', 'coverage'], 231 | branch: 'main', 232 | page: '2', 233 | page_size: '20', 234 | }); 235 | 236 | // Check the transformations 237 | expect(result.component_keys).toEqual(['comp-1', 'comp-2']); 238 | expect(result.metric_keys).toEqual(['complexity', 'coverage']); 239 | expect(result.page).toBe(2); 240 | expect(result.page_size).toBe(20); 241 | }); 242 | 243 | it('should validate measures history schema parameters', () => { 244 | // Create a schema similar to measures history schema in index.ts 245 | const measuresHistorySchema = z.object({ 246 | component: z.string(), 247 | metrics: z.array(z.string()), 248 | from: z.string().optional(), 249 | to: z.string().optional(), 250 | branch: z.string().optional(), 251 | pull_request: z.string().optional(), 252 | page: z 253 | .string() 254 | .optional() 255 | .transform((val) => (val ? parseInt(val, 10) || null : null)), 256 | page_size: z 257 | .string() 258 | .optional() 259 | .transform((val) => (val ? parseInt(val, 10) || null : null)), 260 | }); 261 | 262 | // Test with valid parameters 263 | const result = measuresHistorySchema.parse({ 264 | component: 'test-component', 265 | metrics: ['complexity', 'coverage'], 266 | from: '2023-01-01', 267 | to: '2023-12-31', 268 | page: '3', 269 | page_size: '15', 270 | }); 271 | 272 | // Check the transformations 273 | expect(result.component).toBe('test-component'); 274 | expect(result.metrics).toEqual(['complexity', 'coverage']); 275 | expect(result.from).toBe('2023-01-01'); 276 | expect(result.to).toBe('2023-12-31'); 277 | expect(result.page).toBe(3); 278 | expect(result.page_size).toBe(15); 279 | }); 280 | }); 281 | }); 282 | -------------------------------------------------------------------------------- /src/__tests__/environment-validation.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; 2 | import { createDefaultClient } from '../index.js'; 3 | 4 | // Mock the sonarqube module 5 | jest.mock('../sonarqube.js', () => ({ 6 | createSonarQubeClientFromEnv: jest.fn(() => ({ 7 | // Mock client implementation 8 | listProjects: jest.fn(), 9 | getIssues: jest.fn(), 10 | })), 11 | })); 12 | 13 | describe('Environment Validation', () => { 14 | // Save original env vars 15 | const originalEnv = process.env; 16 | 17 | beforeEach(() => { 18 | // Clear environment variables 19 | process.env = { ...originalEnv }; 20 | delete process.env.SONARQUBE_TOKEN; 21 | delete process.env.SONARQUBE_USERNAME; 22 | delete process.env.SONARQUBE_PASSWORD; 23 | delete process.env.SONARQUBE_PASSCODE; 24 | delete process.env.SONARQUBE_URL; 25 | delete process.env.SONARQUBE_ORGANIZATION; 26 | }); 27 | 28 | afterEach(() => { 29 | // Restore original env vars 30 | process.env = originalEnv; 31 | }); 32 | 33 | describe('createDefaultClient', () => { 34 | it('should create client with token authentication', () => { 35 | process.env.SONARQUBE_TOKEN = 'test-token'; 36 | 37 | const client = createDefaultClient(); 38 | expect(client).toBeDefined(); 39 | }); 40 | 41 | it('should create client with basic authentication', () => { 42 | process.env.SONARQUBE_USERNAME = 'test-user'; 43 | process.env.SONARQUBE_PASSWORD = 'test-pass'; 44 | 45 | const client = createDefaultClient(); 46 | expect(client).toBeDefined(); 47 | }); 48 | 49 | it('should create client with passcode authentication', () => { 50 | process.env.SONARQUBE_PASSCODE = 'test-passcode'; 51 | 52 | const client = createDefaultClient(); 53 | expect(client).toBeDefined(); 54 | }); 55 | 56 | it('should throw error when no authentication is provided', () => { 57 | expect(() => createDefaultClient()).toThrow('No SonarQube authentication configured'); 58 | }); 59 | 60 | it('should throw error with invalid URL', () => { 61 | process.env.SONARQUBE_TOKEN = 'test-token'; 62 | process.env.SONARQUBE_URL = 'not-a-valid-url'; 63 | 64 | expect(() => createDefaultClient()).toThrow('Invalid SONARQUBE_URL'); 65 | }); 66 | 67 | it('should accept valid URL', () => { 68 | process.env.SONARQUBE_TOKEN = 'test-token'; 69 | process.env.SONARQUBE_URL = 'https://sonarqube.example.com'; 70 | 71 | const client = createDefaultClient(); 72 | expect(client).toBeDefined(); 73 | }); 74 | 75 | it('should accept organization parameter', () => { 76 | process.env.SONARQUBE_TOKEN = 'test-token'; 77 | process.env.SONARQUBE_ORGANIZATION = 'my-org'; 78 | 79 | const client = createDefaultClient(); 80 | expect(client).toBeDefined(); 81 | }); 82 | 83 | it('should prioritize token over other auth methods', () => { 84 | process.env.SONARQUBE_TOKEN = 'test-token'; 85 | process.env.SONARQUBE_USERNAME = 'test-user'; 86 | process.env.SONARQUBE_PASSWORD = 'test-pass'; 87 | process.env.SONARQUBE_PASSCODE = 'test-passcode'; 88 | 89 | // Should not throw - uses token auth 90 | const client = createDefaultClient(); 91 | expect(client).toBeDefined(); 92 | }); 93 | 94 | it('should create client when only username is provided (legacy token auth)', () => { 95 | process.env.SONARQUBE_USERNAME = 'test-user'; 96 | 97 | const client = createDefaultClient(); 98 | expect(client).toBeDefined(); 99 | }); 100 | 101 | it('should throw error when only password is provided', () => { 102 | process.env.SONARQUBE_PASSWORD = 'test-pass'; 103 | 104 | expect(() => createDefaultClient()).toThrow('No SonarQube authentication configured'); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/__tests__/error-handling.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect, beforeEach, afterEach, beforeAll, jest } from '@jest/globals'; 8 | import nock from 'nock'; 9 | 10 | // Mock environment variables 11 | process.env.SONARQUBE_TOKEN = 'test-token'; 12 | process.env.SONARQUBE_URL = 'http://localhost:9000'; 13 | 14 | // Save environment variables 15 | const originalEnv = process.env; 16 | 17 | beforeAll(() => { 18 | nock.cleanAll(); 19 | }); 20 | 21 | afterAll(() => { 22 | nock.cleanAll(); 23 | }); 24 | 25 | /* eslint-disable @typescript-eslint/no-explicit-any */ 26 | let nullToUndefined: any; 27 | /* eslint-enable @typescript-eslint/no-explicit-any */ 28 | 29 | // No need to mock axios anymore since we're using sonarqube-web-api-client 30 | 31 | describe('Error Handling', () => { 32 | beforeAll(async () => { 33 | const module = await import('../index.js'); 34 | nullToUndefined = module.nullToUndefined; 35 | }); 36 | 37 | beforeEach(() => { 38 | jest.resetModules(); 39 | process.env = { ...originalEnv }; 40 | nock.cleanAll(); 41 | }); 42 | 43 | afterEach(() => { 44 | process.env = originalEnv; 45 | jest.restoreAllMocks(); 46 | nock.cleanAll(); 47 | }); 48 | 49 | describe('nullToUndefined function', () => { 50 | it('should handle various input types correctly', () => { 51 | // Test nulls 52 | expect(nullToUndefined(null)).toBeUndefined(); 53 | 54 | // Test undefined 55 | expect(nullToUndefined(undefined)).toBeUndefined(); 56 | 57 | // Test various other types 58 | expect(nullToUndefined(0)).toBe(0); 59 | expect(nullToUndefined('')).toBe(''); 60 | expect(nullToUndefined('test')).toBe('test'); 61 | expect(nullToUndefined(false)).toBe(false); 62 | expect(nullToUndefined(true)).toBe(true); 63 | 64 | // Test objects and arrays 65 | const obj = { test: 1 }; 66 | const arr = [1, 2, 3]; 67 | expect(nullToUndefined(obj)).toBe(obj); 68 | expect(nullToUndefined(arr)).toBe(arr); 69 | }); 70 | }); 71 | 72 | describe('mapToSonarQubeParams', () => { 73 | it('should handle all parameters', async () => { 74 | const module = await import('../index.js'); 75 | const mapToSonarQubeParams = module.mapToSonarQubeParams; 76 | 77 | const params = mapToSonarQubeParams({ 78 | project_key: 'test-project', 79 | severity: 'MAJOR', 80 | page: 1, 81 | page_size: 10, 82 | statuses: ['OPEN', 'CONFIRMED'], 83 | resolutions: ['FALSE-POSITIVE', 'FIXED'], 84 | resolved: true, 85 | types: ['BUG', 'VULNERABILITY'], 86 | rules: ['rule1', 'rule2'], 87 | tags: ['tag1', 'tag2'], 88 | created_after: '2023-01-01', 89 | created_before: '2023-12-31', 90 | created_at: '2023-06-15', 91 | created_in_last: '7d', 92 | assignees: ['user1', 'user2'], 93 | authors: ['author1', 'author2'], 94 | cwe: ['cwe1', 'cwe2'], 95 | languages: ['java', 'typescript'], 96 | owasp_top10: ['a1', 'a2'], 97 | sans_top25: ['sans1', 'sans2'], 98 | sonarsource_security: ['sec1', 'sec2'], 99 | on_component_only: true, 100 | facets: ['facet1', 'facet2'], 101 | since_leak_period: true, 102 | in_new_code_period: true, 103 | }); 104 | 105 | expect(params.projectKey).toBe('test-project'); 106 | expect(params.severity).toBe('MAJOR'); 107 | expect(params.page).toBe(1); 108 | expect(params.pageSize).toBe(10); 109 | expect(params.statuses).toEqual(['OPEN', 'CONFIRMED']); 110 | expect(params.resolutions).toEqual(['FALSE-POSITIVE', 'FIXED']); 111 | expect(params.resolved).toBe(true); 112 | expect(params.types).toEqual(['BUG', 'VULNERABILITY']); 113 | expect(params.rules).toEqual(['rule1', 'rule2']); 114 | expect(params.tags).toEqual(['tag1', 'tag2']); 115 | expect(params.createdAfter).toBe('2023-01-01'); 116 | expect(params.createdBefore).toBe('2023-12-31'); 117 | expect(params.createdAt).toBe('2023-06-15'); 118 | expect(params.createdInLast).toBe('7d'); 119 | expect(params.assignees).toEqual(['user1', 'user2']); 120 | expect(params.authors).toEqual(['author1', 'author2']); 121 | expect(params.cwe).toEqual(['cwe1', 'cwe2']); 122 | expect(params.languages).toEqual(['java', 'typescript']); 123 | expect(params.owaspTop10).toEqual(['a1', 'a2']); 124 | expect(params.sansTop25).toEqual(['sans1', 'sans2']); 125 | expect(params.sonarsourceSecurity).toEqual(['sec1', 'sec2']); 126 | expect(params.onComponentOnly).toBe(true); 127 | expect(params.facets).toEqual(['facet1', 'facet2']); 128 | expect(params.sinceLeakPeriod).toBe(true); 129 | expect(params.inNewCodePeriod).toBe(true); 130 | }); 131 | 132 | it('should handle empty parameters', async () => { 133 | const module = await import('../index.js'); 134 | const mapToSonarQubeParams = module.mapToSonarQubeParams; 135 | 136 | const params = mapToSonarQubeParams({ project_key: 'test-project' }); 137 | 138 | expect(params.projectKey).toBe('test-project'); 139 | expect(params.severity).toBeUndefined(); 140 | expect(params.statuses).toBeUndefined(); 141 | expect(params.resolutions).toBeUndefined(); 142 | expect(params.resolved).toBeUndefined(); 143 | expect(params.types).toBeUndefined(); 144 | expect(params.rules).toBeUndefined(); 145 | }); 146 | }); 147 | 148 | describe('Error handling utility functions', () => { 149 | it('should properly handle null parameters', () => { 150 | expect(nullToUndefined(null)).toBeUndefined(); 151 | }); 152 | 153 | it('should pass through non-null values', () => { 154 | expect(nullToUndefined('value')).toBe('value'); 155 | expect(nullToUndefined(123)).toBe(123); 156 | expect(nullToUndefined(true)).toBe(true); 157 | expect(nullToUndefined(false)).toBe(false); 158 | expect(nullToUndefined([])).toEqual([]); 159 | expect(nullToUndefined({})).toEqual({}); 160 | }); 161 | 162 | it('should handle undefined parameters', () => { 163 | expect(nullToUndefined(undefined)).toBeUndefined(); 164 | }); 165 | }); 166 | }); 167 | -------------------------------------------------------------------------------- /src/__tests__/function-tests.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect, jest } from '@jest/globals'; 8 | import { nullToUndefined, mapToSonarQubeParams } from '../index.js'; 9 | 10 | jest.mock('../sonarqube.js'); 11 | 12 | describe('Utility Function Tests', () => { 13 | describe('nullToUndefined function', () => { 14 | it('should convert null to undefined but preserve other values', () => { 15 | expect(nullToUndefined(null)).toBeUndefined(); 16 | expect(nullToUndefined(undefined)).toBeUndefined(); 17 | 18 | // Other values should remain the same 19 | expect(nullToUndefined(0)).toBe(0); 20 | expect(nullToUndefined('')).toBe(''); 21 | expect(nullToUndefined('test')).toBe('test'); 22 | expect(nullToUndefined(123)).toBe(123); 23 | expect(nullToUndefined(false)).toBe(false); 24 | expect(nullToUndefined(true)).toBe(true); 25 | 26 | // Objects and arrays should be passed through 27 | const obj = { test: 'value' }; 28 | const arr = [1, 2, 3]; 29 | expect(nullToUndefined(obj)).toBe(obj); 30 | expect(nullToUndefined(arr)).toBe(arr); 31 | }); 32 | }); 33 | 34 | describe('mapToSonarQubeParams function', () => { 35 | it('should map MCP tool parameters to SonarQube client parameters', () => { 36 | const result = mapToSonarQubeParams({ 37 | project_key: 'my-project', 38 | severity: 'MAJOR', 39 | page: '10', 40 | page_size: '25', 41 | statuses: ['OPEN', 'CONFIRMED'], 42 | resolutions: ['FALSE-POSITIVE'], 43 | resolved: 'true', 44 | types: ['BUG', 'VULNERABILITY'], 45 | rules: ['rule1', 'rule2'], 46 | tags: ['tag1', 'tag2'], 47 | created_after: '2023-01-01', 48 | created_before: '2023-12-31', 49 | created_at: '2023-06-15', 50 | created_in_last: '30d', 51 | assignees: ['user1', 'user2'], 52 | authors: ['author1', 'author2'], 53 | cwe: ['cwe1', 'cwe2'], 54 | languages: ['java', 'js'], 55 | owasp_top10: ['a1', 'a2'], 56 | sans_top25: ['sans1', 'sans2'], 57 | sonarsource_security: ['ss1', 'ss2'], 58 | on_component_only: 'true', 59 | facets: ['facet1', 'facet2'], 60 | since_leak_period: 'true', 61 | in_new_code_period: 'true', 62 | }); 63 | 64 | // Check key mappings 65 | expect(result.projectKey).toBe('my-project'); 66 | expect(result.severity).toBe('MAJOR'); 67 | expect(result.page).toBe('10'); 68 | expect(result.pageSize).toBe('25'); 69 | expect(result.statuses).toEqual(['OPEN', 'CONFIRMED']); 70 | expect(result.resolutions).toEqual(['FALSE-POSITIVE']); 71 | expect(result.resolved).toBe('true'); 72 | expect(result.types).toEqual(['BUG', 'VULNERABILITY']); 73 | expect(result.rules).toEqual(['rule1', 'rule2']); 74 | expect(result.tags).toEqual(['tag1', 'tag2']); 75 | expect(result.createdAfter).toBe('2023-01-01'); 76 | expect(result.createdBefore).toBe('2023-12-31'); 77 | expect(result.createdAt).toBe('2023-06-15'); 78 | expect(result.createdInLast).toBe('30d'); 79 | expect(result.assignees).toEqual(['user1', 'user2']); 80 | expect(result.authors).toEqual(['author1', 'author2']); 81 | expect(result.cwe).toEqual(['cwe1', 'cwe2']); 82 | expect(result.languages).toEqual(['java', 'js']); 83 | expect(result.owaspTop10).toEqual(['a1', 'a2']); 84 | expect(result.sansTop25).toEqual(['sans1', 'sans2']); 85 | expect(result.sonarsourceSecurity).toEqual(['ss1', 'ss2']); 86 | expect(result.onComponentOnly).toBe('true'); 87 | expect(result.facets).toEqual(['facet1', 'facet2']); 88 | expect(result.sinceLeakPeriod).toBe('true'); 89 | expect(result.inNewCodePeriod).toBe('true'); 90 | }); 91 | 92 | it('should handle null and undefined values correctly', () => { 93 | const result = mapToSonarQubeParams({ 94 | project_key: 'my-project', 95 | severity: null, 96 | statuses: null, 97 | resolved: null, 98 | }); 99 | 100 | expect(result.projectKey).toBe('my-project'); 101 | expect(result.severity).toBeUndefined(); 102 | expect(result.statuses).toBeUndefined(); 103 | expect(result.resolved).toBeUndefined(); 104 | }); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /src/__tests__/handlers.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect, beforeEach, afterEach, beforeAll, jest } from '@jest/globals'; 8 | 9 | // Mock environment variables 10 | process.env.SONARQUBE_TOKEN = 'test-token'; 11 | process.env.SONARQUBE_URL = 'http://localhost:9000'; 12 | process.env.SONARQUBE_ORGANIZATION = 'test-org'; 13 | 14 | // Save environment variables 15 | const originalEnv = process.env; 16 | 17 | /* eslint-disable @typescript-eslint/no-explicit-any */ 18 | let handleSonarQubeProjects: any; 19 | let handleSonarQubeGetIssues: any; 20 | let handleSonarQubeGetMetrics: any; 21 | let handleSonarQubeGetHealth: any; 22 | let handleSonarQubeGetStatus: any; 23 | let handleSonarQubePing: any; 24 | let handleSonarQubeComponentMeasures: any; 25 | let handleSonarQubeComponentsMeasures: any; 26 | let handleSonarQubeMeasuresHistory: any; 27 | /* eslint-enable @typescript-eslint/no-explicit-any */ 28 | 29 | // No need to mock axios anymore since we're using sonarqube-web-api-client 30 | 31 | describe('Handler Functions', () => { 32 | beforeAll(async () => { 33 | const module = await import('../index.js'); 34 | handleSonarQubeProjects = module.handleSonarQubeProjects; 35 | handleSonarQubeGetIssues = module.handleSonarQubeGetIssues; 36 | handleSonarQubeGetMetrics = module.handleSonarQubeGetMetrics; 37 | handleSonarQubeGetHealth = module.handleSonarQubeGetHealth; 38 | handleSonarQubeGetStatus = module.handleSonarQubeGetStatus; 39 | handleSonarQubePing = module.handleSonarQubePing; 40 | handleSonarQubeComponentMeasures = module.handleSonarQubeComponentMeasures; 41 | handleSonarQubeComponentsMeasures = module.handleSonarQubeComponentsMeasures; 42 | handleSonarQubeMeasuresHistory = module.handleSonarQubeMeasuresHistory; 43 | }); 44 | 45 | beforeEach(() => { 46 | jest.resetModules(); 47 | process.env = { ...originalEnv }; 48 | }); 49 | 50 | afterEach(() => { 51 | process.env = originalEnv; 52 | jest.clearAllMocks(); 53 | }); 54 | 55 | describe('handleSonarQubeProjects', () => { 56 | it('should handle projects correctly', async () => { 57 | const result = await handleSonarQubeProjects({}); 58 | const data = JSON.parse(result.content[0].text); 59 | 60 | expect(data.projects).toBeDefined(); 61 | expect(data.projects).toHaveLength(1); 62 | expect(data.projects[0].key).toBe('test-project'); 63 | expect(data.paging).toBeDefined(); 64 | }); 65 | 66 | it('should handle pagination parameters', async () => { 67 | const result = await handleSonarQubeProjects({ page: 2, page_size: 10 }); 68 | const data = JSON.parse(result.content[0].text); 69 | 70 | expect(data.projects).toBeDefined(); 71 | expect(data.paging).toBeDefined(); 72 | }); 73 | }); 74 | 75 | describe('handleSonarQubeGetIssues', () => { 76 | it('should handle issues correctly', async () => { 77 | const result = await handleSonarQubeGetIssues({ projectKey: 'test-project' }); 78 | const data = JSON.parse(result.content[0].text); 79 | 80 | expect(data.issues).toBeDefined(); 81 | expect(data.issues).toHaveLength(1); 82 | expect(data.issues[0].severity).toBe('MAJOR'); 83 | expect(data.paging).toBeDefined(); 84 | }); 85 | }); 86 | 87 | describe('handleSonarQubeGetMetrics', () => { 88 | it('should handle metrics correctly', async () => { 89 | const result = await handleSonarQubeGetMetrics({}); 90 | const data = JSON.parse(result.content[0].text); 91 | 92 | expect(data.metrics).toBeDefined(); 93 | expect(data.metrics).toHaveLength(1); 94 | expect(data.metrics[0].key).toBe('coverage'); 95 | expect(data.paging).toBeDefined(); 96 | }); 97 | }); 98 | 99 | describe('System API Handlers', () => { 100 | it('should handle health correctly', async () => { 101 | const result = await handleSonarQubeGetHealth(); 102 | const data = JSON.parse(result.content[0].text); 103 | 104 | expect(data.health).toBe('GREEN'); 105 | expect(data.causes).toEqual([]); 106 | }); 107 | 108 | it('should handle status correctly', async () => { 109 | const result = await handleSonarQubeGetStatus(); 110 | const data = JSON.parse(result.content[0].text); 111 | 112 | expect(data.id).toBe('test-id'); 113 | expect(data.version).toBe('10.3.0.82913'); 114 | expect(data.status).toBe('UP'); 115 | }); 116 | 117 | it('should handle ping correctly', async () => { 118 | const result = await handleSonarQubePing(); 119 | expect(result.content[0].text).toBe('pong'); 120 | }); 121 | }); 122 | 123 | describe('Measures API Handlers', () => { 124 | it('should handle component measures correctly', async () => { 125 | const result = await handleSonarQubeComponentMeasures({ 126 | component: 'test-component', 127 | metricKeys: ['coverage'], 128 | }); 129 | const data = JSON.parse(result.content[0].text); 130 | 131 | expect(data.component).toBeDefined(); 132 | expect(data.component.key).toBe('test-component'); 133 | expect(data.component.measures).toHaveLength(1); 134 | expect(data.component.measures[0].metric).toBe('coverage'); 135 | expect(data.metrics).toBeDefined(); 136 | }); 137 | 138 | it('should handle components measures correctly', async () => { 139 | const result = await handleSonarQubeComponentsMeasures({ 140 | componentKeys: ['test-component-1'], 141 | metricKeys: ['coverage'], 142 | }); 143 | const data = JSON.parse(result.content[0].text); 144 | 145 | expect(data.components).toBeDefined(); 146 | expect(data.components).toHaveLength(1); 147 | expect(data.components[0].key).toBe('test-component-1'); 148 | expect(data.metrics).toBeDefined(); 149 | expect(data.paging).toBeDefined(); 150 | }); 151 | 152 | it('should handle measures history correctly', async () => { 153 | const result = await handleSonarQubeMeasuresHistory({ 154 | component: 'test-component', 155 | metrics: ['coverage'], 156 | }); 157 | const data = JSON.parse(result.content[0].text); 158 | 159 | expect(data.measures).toBeDefined(); 160 | expect(data.measures).toHaveLength(1); 161 | expect(data.measures[0].metric).toBe('coverage'); 162 | expect(data.measures[0].history).toHaveLength(1); 163 | expect(data.paging).toBeDefined(); 164 | }); 165 | }); 166 | }); 167 | -------------------------------------------------------------------------------- /src/__tests__/handlers.test.ts.skip: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect, jest, beforeEach } from '@jest/globals'; 8 | 9 | // Set environment variables for testing 10 | process.env.SONARQUBE_TOKEN = 'test-token'; 11 | process.env.SONARQUBE_URL = 'http://localhost:9000'; 12 | 13 | // Mock axios directly 14 | jest.mock('axios', () => ({ 15 | get: jest.fn().mockImplementation(() => 16 | Promise.resolve({ 17 | data: { 18 | metrics: [], 19 | paging: { pageIndex: 1, pageSize: 10, total: 0 }, 20 | issues: [], 21 | component: {}, 22 | components: [], 23 | measures: [], 24 | }, 25 | }) 26 | ), 27 | post: jest.fn().mockImplementation(() => 28 | Promise.resolve({ 29 | data: { 30 | result: 'success', 31 | }, 32 | }) 33 | ), 34 | })); 35 | 36 | // Import the function we want to test 37 | import { nullToUndefined } from '../index.js'; 38 | 39 | // Now import the handlers - these will use our mocked handlers 40 | import { 41 | metricsHandler, 42 | issuesHandler, 43 | componentMeasuresHandler, 44 | componentsMeasuresHandler, 45 | measuresHistoryHandler, 46 | } from '../index.js'; 47 | 48 | // Simple tests that don't require HTTP calls 49 | describe('Utility Function Tests', () => { 50 | describe('nullToUndefined', () => { 51 | it('should convert null to undefined', () => { 52 | expect(nullToUndefined(null)).toBeUndefined(); 53 | }); 54 | 55 | it('should pass through non-null values', () => { 56 | expect(nullToUndefined('value')).toBe('value'); 57 | expect(nullToUndefined(123)).toBe(123); 58 | expect(nullToUndefined(0)).toBe(0); 59 | expect(nullToUndefined(false)).toBe(false); 60 | expect(nullToUndefined(undefined)).toBeUndefined(); 61 | }); 62 | }); 63 | }); 64 | 65 | // Lambda handler tests 66 | describe('Handlers', () => { 67 | beforeEach(() => { 68 | jest.clearAllMocks(); 69 | }); 70 | 71 | // Test metricsHandler 72 | it('metricsHandler should transform parameters correctly', async () => { 73 | const params = { page: 5, page_size: 20 }; 74 | const result = await metricsHandler(params); 75 | 76 | expect(result).toBeDefined(); 77 | expect(result.content).toBeDefined(); 78 | expect(result.content).toHaveLength(1); 79 | expect(result.content[0].type).toBe('text'); 80 | }); 81 | 82 | // Test issuesHandler 83 | it('issuesHandler should handle parameters correctly', async () => { 84 | const params = { 85 | project_key: 'test-project', 86 | severity: 'MAJOR', 87 | statuses: ['OPEN', 'CONFIRMED'], 88 | }; 89 | 90 | const result = await issuesHandler(params); 91 | 92 | expect(result).toBeDefined(); 93 | expect(result.content).toBeDefined(); 94 | expect(result.content).toHaveLength(1); 95 | expect(result.content[0].type).toBe('text'); 96 | }); 97 | 98 | // Test componentMeasuresHandler 99 | it('componentMeasuresHandler should transform string metric_keys to array', async () => { 100 | const params = { 101 | component: 'test-component', 102 | metric_keys: 'coverage', 103 | }; 104 | 105 | const result = await componentMeasuresHandler(params); 106 | 107 | expect(result).toBeDefined(); 108 | expect(result.content).toBeDefined(); 109 | expect(result.content).toHaveLength(1); 110 | expect(result.content[0].type).toBe('text'); 111 | }); 112 | 113 | // Test componentsMeasuresHandler 114 | it('componentsMeasuresHandler should transform string parameters', async () => { 115 | const params = { 116 | component_keys: 'test-component', 117 | metric_keys: 'coverage', 118 | page: 2, 119 | page_size: 20, 120 | }; 121 | 122 | const result = await componentsMeasuresHandler(params); 123 | 124 | expect(result).toBeDefined(); 125 | expect(result.content).toBeDefined(); 126 | expect(result.content).toHaveLength(1); 127 | expect(result.content[0].type).toBe('text'); 128 | }); 129 | 130 | // Test measuresHistoryHandler 131 | it('measuresHistoryHandler should transform string parameters', async () => { 132 | const params = { 133 | component: 'test-component', 134 | metrics: 'coverage', 135 | from: '2023-01-01', 136 | to: '2023-12-31', 137 | }; 138 | 139 | const result = await measuresHistoryHandler(params); 140 | 141 | expect(result).toBeDefined(); 142 | expect(result.content).toBeDefined(); 143 | expect(result.content).toHaveLength(1); 144 | expect(result.content[0].type).toBe('text'); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /src/__tests__/logger.test.ts: -------------------------------------------------------------------------------- 1 | import { Logger, LogLevel, createLogger } from '../utils/logger.js'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import os from 'os'; 5 | 6 | describe('Logger', () => { 7 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'logger-test-')); 8 | const logFile = path.join(tempDir, 'test.log'); 9 | const originalEnv = process.env; 10 | 11 | beforeEach(() => { 12 | process.env = { ...originalEnv }; 13 | // Clean up any existing log file 14 | if (fs.existsSync(logFile)) { 15 | fs.unlinkSync(logFile); 16 | } 17 | }); 18 | 19 | afterEach(() => { 20 | process.env = originalEnv; 21 | }); 22 | 23 | afterAll(() => { 24 | // Clean up temp directory 25 | if (fs.existsSync(tempDir)) { 26 | fs.rmSync(tempDir, { recursive: true }); 27 | } 28 | }); 29 | 30 | describe('Logger initialization', () => { 31 | it('should create logger with context', () => { 32 | const logger = new Logger('TestContext'); 33 | expect(logger).toBeDefined(); 34 | }); 35 | 36 | it('should create logger without context', () => { 37 | const logger = new Logger(); 38 | expect(logger).toBeDefined(); 39 | }); 40 | 41 | it('should create logger using createLogger helper', () => { 42 | const logger = createLogger('TestContext'); 43 | expect(logger).toBeDefined(); 44 | }); 45 | }); 46 | 47 | describe('Log file initialization', () => { 48 | it('should create log file when LOG_FILE is set', () => { 49 | process.env.LOG_FILE = logFile; 50 | process.env.LOG_LEVEL = 'DEBUG'; 51 | 52 | const logger = new Logger(); 53 | logger.debug('test message'); 54 | 55 | expect(fs.existsSync(logFile)).toBe(true); 56 | }); 57 | 58 | it('should handle nested directories', () => { 59 | // Create a nested directory manually to test path parsing 60 | const nestedDir = path.join(tempDir, 'nested', 'dir'); 61 | fs.mkdirSync(nestedDir, { recursive: true }); 62 | 63 | const nestedLogFile = path.join(nestedDir, 'test.log'); 64 | process.env.LOG_FILE = nestedLogFile; 65 | process.env.LOG_LEVEL = 'DEBUG'; 66 | 67 | const logger = new Logger(); 68 | logger.debug('test message'); 69 | 70 | // The logger should work with existing nested directories 71 | expect(fs.existsSync(nestedLogFile)).toBe(true); 72 | 73 | // Clean up 74 | if (fs.existsSync(path.join(tempDir, 'nested'))) { 75 | fs.rmSync(path.join(tempDir, 'nested'), { recursive: true }); 76 | } 77 | }); 78 | }); 79 | 80 | describe('Log level filtering', () => { 81 | beforeEach(() => { 82 | process.env.LOG_FILE = logFile; 83 | }); 84 | 85 | it('should log DEBUG messages when LOG_LEVEL is DEBUG', () => { 86 | process.env.LOG_LEVEL = 'DEBUG'; 87 | const logger = new Logger(); 88 | 89 | logger.debug('debug message'); 90 | 91 | const content = fs.readFileSync(logFile, 'utf8'); 92 | expect(content).toContain('DEBUG'); 93 | expect(content).toContain('debug message'); 94 | }); 95 | 96 | it('should not log DEBUG messages when LOG_LEVEL is INFO', () => { 97 | process.env.LOG_LEVEL = 'INFO'; 98 | const logger = new Logger(); 99 | 100 | logger.debug('debug message'); 101 | 102 | expect(fs.existsSync(logFile)).toBe(false); 103 | }); 104 | 105 | it('should log INFO messages when LOG_LEVEL is INFO', () => { 106 | process.env.LOG_LEVEL = 'INFO'; 107 | const logger = new Logger(); 108 | 109 | logger.info('info message'); 110 | 111 | const content = fs.readFileSync(logFile, 'utf8'); 112 | expect(content).toContain('INFO'); 113 | expect(content).toContain('info message'); 114 | }); 115 | 116 | it('should not log INFO messages when LOG_LEVEL is WARN', () => { 117 | process.env.LOG_LEVEL = 'WARN'; 118 | const logger = new Logger(); 119 | 120 | logger.info('info message'); 121 | 122 | expect(fs.existsSync(logFile)).toBe(false); 123 | }); 124 | 125 | it('should log WARN messages when LOG_LEVEL is WARN', () => { 126 | process.env.LOG_LEVEL = 'WARN'; 127 | const logger = new Logger(); 128 | 129 | logger.warn('warn message'); 130 | 131 | const content = fs.readFileSync(logFile, 'utf8'); 132 | expect(content).toContain('WARN'); 133 | expect(content).toContain('warn message'); 134 | }); 135 | 136 | it('should not log WARN messages when LOG_LEVEL is ERROR', () => { 137 | process.env.LOG_LEVEL = 'ERROR'; 138 | const logger = new Logger(); 139 | 140 | logger.warn('warn message'); 141 | 142 | expect(fs.existsSync(logFile)).toBe(false); 143 | }); 144 | 145 | it('should log ERROR messages when LOG_LEVEL is ERROR', () => { 146 | process.env.LOG_LEVEL = 'ERROR'; 147 | const logger = new Logger(); 148 | 149 | logger.error('error message'); 150 | 151 | const content = fs.readFileSync(logFile, 'utf8'); 152 | expect(content).toContain('ERROR'); 153 | expect(content).toContain('error message'); 154 | }); 155 | 156 | it('should default to DEBUG level when LOG_LEVEL is not set', () => { 157 | delete process.env.LOG_LEVEL; 158 | const logger = new Logger(); 159 | 160 | logger.debug('debug message'); 161 | 162 | const content = fs.readFileSync(logFile, 'utf8'); 163 | expect(content).toContain('DEBUG'); 164 | expect(content).toContain('debug message'); 165 | }); 166 | }); 167 | 168 | describe('Log message formatting', () => { 169 | beforeEach(() => { 170 | process.env.LOG_FILE = logFile; 171 | process.env.LOG_LEVEL = 'DEBUG'; 172 | }); 173 | 174 | it('should format log message with timestamp, level, and context', () => { 175 | const logger = new Logger('TestContext'); 176 | 177 | logger.debug('test message'); 178 | 179 | const content = fs.readFileSync(logFile, 'utf8'); 180 | expect(content).toMatch( 181 | /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z DEBUG \[TestContext\] test message/ 182 | ); 183 | }); 184 | 185 | it('should format log message without context', () => { 186 | const logger = new Logger(); 187 | 188 | logger.info('test message'); 189 | 190 | const content = fs.readFileSync(logFile, 'utf8'); 191 | expect(content).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z INFO test message/); 192 | }); 193 | 194 | it('should include data in log message', () => { 195 | const logger = new Logger(); 196 | const data = { key: 'value', number: 42 }; 197 | 198 | logger.debug('test message', data); 199 | 200 | const content = fs.readFileSync(logFile, 'utf8'); 201 | expect(content).toContain('test message'); 202 | expect(content).toContain('"key": "value"'); 203 | expect(content).toContain('"number": 42'); 204 | }); 205 | 206 | it('should handle Error objects specially', () => { 207 | const logger = new Logger(); 208 | const error = new Error('Test error'); 209 | 210 | logger.error('An error occurred', error); 211 | 212 | const content = fs.readFileSync(logFile, 'utf8'); 213 | expect(content).toContain('ERROR'); 214 | expect(content).toContain('An error occurred'); 215 | expect(content).toContain('Error: Test error'); 216 | }); 217 | 218 | it('should handle errors without stack traces', () => { 219 | const logger = new Logger(); 220 | const error = new Error('Test error'); 221 | delete error.stack; 222 | 223 | logger.error('An error occurred', error); 224 | 225 | const content = fs.readFileSync(logFile, 'utf8'); 226 | expect(content).toContain('Error: Test error'); 227 | }); 228 | 229 | it('should handle non-Error objects in error logging', () => { 230 | const logger = new Logger(); 231 | const errorData = { code: 'ERR_001', message: 'Something went wrong' }; 232 | 233 | logger.error('An error occurred', errorData); 234 | 235 | const content = fs.readFileSync(logFile, 'utf8'); 236 | expect(content).toContain('"code": "ERR_001"'); 237 | expect(content).toContain('"message": "Something went wrong"'); 238 | }); 239 | 240 | it('should handle circular references in error data', () => { 241 | const logger = new Logger(); 242 | const obj: Record = { a: 1 }; 243 | obj.circular = obj; 244 | 245 | logger.error('An error occurred', obj); 246 | 247 | const content = fs.readFileSync(logFile, 'utf8'); 248 | expect(content).toContain('[object Object]'); 249 | }); 250 | }); 251 | 252 | describe('No logging when LOG_FILE not set', () => { 253 | beforeEach(() => { 254 | delete process.env.LOG_FILE; 255 | process.env.LOG_LEVEL = 'DEBUG'; 256 | }); 257 | 258 | it('should not create log file when LOG_FILE is not set', () => { 259 | const logger = new Logger(); 260 | 261 | logger.debug('debug message'); 262 | logger.info('info message'); 263 | logger.warn('warn message'); 264 | logger.error('error message'); 265 | 266 | expect(fs.existsSync(logFile)).toBe(false); 267 | }); 268 | }); 269 | 270 | describe('Multiple log entries', () => { 271 | it('should append multiple log entries', () => { 272 | process.env.LOG_FILE = logFile; 273 | process.env.LOG_LEVEL = 'DEBUG'; 274 | 275 | const logger = new Logger(); 276 | 277 | logger.debug('first message'); 278 | logger.info('second message'); 279 | logger.warn('third message'); 280 | logger.error('fourth message'); 281 | 282 | const content = fs.readFileSync(logFile, 'utf8'); 283 | const lines = content.trim().split('\n'); 284 | 285 | expect(lines).toHaveLength(4); 286 | expect(lines[0]).toContain('DEBUG'); 287 | expect(lines[0]).toContain('first message'); 288 | expect(lines[1]).toContain('INFO'); 289 | expect(lines[1]).toContain('second message'); 290 | expect(lines[2]).toContain('WARN'); 291 | expect(lines[2]).toContain('third message'); 292 | expect(lines[3]).toContain('ERROR'); 293 | expect(lines[3]).toContain('fourth message'); 294 | }); 295 | }); 296 | }); 297 | 298 | describe('LogLevel enum', () => { 299 | it('should have correct log levels', () => { 300 | expect(LogLevel.DEBUG).toBe('DEBUG'); 301 | expect(LogLevel.INFO).toBe('INFO'); 302 | expect(LogLevel.WARN).toBe('WARN'); 303 | expect(LogLevel.ERROR).toBe('ERROR'); 304 | }); 305 | }); 306 | 307 | describe('Default logger export', () => { 308 | it('should export a default logger with SonarQubeMCP context', async () => { 309 | // Import the default export 310 | const module = await import('../utils/logger.js'); 311 | const defaultLogger = module.default; 312 | expect(defaultLogger).toBeDefined(); 313 | expect(defaultLogger).toBeInstanceOf(Logger); 314 | }); 315 | }); 316 | -------------------------------------------------------------------------------- /src/__tests__/mapping-functions.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect, beforeEach } from '@jest/globals'; 8 | 9 | describe('Mapping Functions', () => { 10 | let mapToSonarQubeParams; 11 | 12 | beforeEach(async () => { 13 | // Import the function fresh for each test 14 | const module = await import('../index.js'); 15 | mapToSonarQubeParams = module.mapToSonarQubeParams; 16 | }); 17 | 18 | it('should properly map basic required parameters', () => { 19 | const params = mapToSonarQubeParams({ project_key: 'my-project' }); 20 | 21 | expect(params.projectKey).toBe('my-project'); 22 | expect(params.severity).toBeUndefined(); 23 | expect(params.statuses).toBeUndefined(); 24 | }); 25 | 26 | it('should map pagination parameters', () => { 27 | const params = mapToSonarQubeParams({ 28 | project_key: 'my-project', 29 | page: 2, 30 | page_size: 20, 31 | }); 32 | 33 | expect(params.projectKey).toBe('my-project'); 34 | expect(params.page).toBe(2); 35 | expect(params.pageSize).toBe(20); 36 | }); 37 | 38 | it('should map severity parameter', () => { 39 | const params = mapToSonarQubeParams({ 40 | project_key: 'my-project', 41 | severity: 'MAJOR', 42 | }); 43 | 44 | expect(params.projectKey).toBe('my-project'); 45 | expect(params.severity).toBe('MAJOR'); 46 | }); 47 | 48 | it('should map array parameters', () => { 49 | const params = mapToSonarQubeParams({ 50 | project_key: 'my-project', 51 | statuses: ['OPEN', 'CONFIRMED'], 52 | types: ['BUG', 'VULNERABILITY'], 53 | rules: ['rule1', 'rule2'], 54 | tags: ['tag1', 'tag2'], 55 | }); 56 | 57 | expect(params.projectKey).toBe('my-project'); 58 | expect(params.statuses).toEqual(['OPEN', 'CONFIRMED']); 59 | expect(params.types).toEqual(['BUG', 'VULNERABILITY']); 60 | expect(params.rules).toEqual(['rule1', 'rule2']); 61 | expect(params.tags).toEqual(['tag1', 'tag2']); 62 | }); 63 | 64 | it('should map boolean parameters', () => { 65 | const params = mapToSonarQubeParams({ 66 | project_key: 'my-project', 67 | resolved: true, 68 | on_component_only: false, 69 | since_leak_period: true, 70 | in_new_code_period: false, 71 | }); 72 | 73 | expect(params.projectKey).toBe('my-project'); 74 | expect(params.resolved).toBe(true); 75 | expect(params.onComponentOnly).toBe(false); 76 | expect(params.sinceLeakPeriod).toBe(true); 77 | expect(params.inNewCodePeriod).toBe(false); 78 | }); 79 | 80 | it('should map date parameters', () => { 81 | const params = mapToSonarQubeParams({ 82 | project_key: 'my-project', 83 | created_after: '2023-01-01', 84 | created_before: '2023-12-31', 85 | created_at: '2023-06-15', 86 | created_in_last: '7d', 87 | }); 88 | 89 | expect(params.projectKey).toBe('my-project'); 90 | expect(params.createdAfter).toBe('2023-01-01'); 91 | expect(params.createdBefore).toBe('2023-12-31'); 92 | expect(params.createdAt).toBe('2023-06-15'); 93 | expect(params.createdInLast).toBe('7d'); 94 | }); 95 | 96 | it('should map assignees and authors', () => { 97 | const params = mapToSonarQubeParams({ 98 | project_key: 'my-project', 99 | assignees: ['user1', 'user2'], 100 | authors: ['author1', 'author2'], 101 | }); 102 | 103 | expect(params.projectKey).toBe('my-project'); 104 | expect(params.assignees).toEqual(['user1', 'user2']); 105 | expect(params.authors).toEqual(['author1', 'author2']); 106 | }); 107 | 108 | it('should map security-related parameters', () => { 109 | const params = mapToSonarQubeParams({ 110 | project_key: 'my-project', 111 | cwe: ['cwe1', 'cwe2'], 112 | languages: ['java', 'typescript'], 113 | owasp_top10: ['a1', 'a2'], 114 | sans_top25: ['sans1', 'sans2'], 115 | sonarsource_security: ['sec1', 'sec2'], 116 | }); 117 | 118 | expect(params.projectKey).toBe('my-project'); 119 | expect(params.cwe).toEqual(['cwe1', 'cwe2']); 120 | expect(params.languages).toEqual(['java', 'typescript']); 121 | expect(params.owaspTop10).toEqual(['a1', 'a2']); 122 | expect(params.sansTop25).toEqual(['sans1', 'sans2']); 123 | expect(params.sonarsourceSecurity).toEqual(['sec1', 'sec2']); 124 | }); 125 | 126 | it('should map facets parameter', () => { 127 | const params = mapToSonarQubeParams({ 128 | project_key: 'my-project', 129 | facets: ['facet1', 'facet2'], 130 | }); 131 | 132 | expect(params.projectKey).toBe('my-project'); 133 | expect(params.facets).toEqual(['facet1', 'facet2']); 134 | }); 135 | 136 | it('should correctly handle null values', () => { 137 | const params = mapToSonarQubeParams({ 138 | project_key: 'my-project', 139 | severity: null, 140 | statuses: null, 141 | rules: null, 142 | }); 143 | 144 | expect(params.projectKey).toBe('my-project'); 145 | expect(params.severity).toBeUndefined(); 146 | expect(params.statuses).toBeUndefined(); 147 | expect(params.rules).toBeUndefined(); 148 | }); 149 | 150 | it('should handle a mix of parameter types', () => { 151 | const params = mapToSonarQubeParams({ 152 | project_key: 'my-project', 153 | severity: 'MAJOR', 154 | page: 2, 155 | statuses: ['OPEN'], 156 | resolved: true, 157 | created_after: '2023-01-01', 158 | assignees: ['user1'], 159 | cwe: ['cwe1'], 160 | facets: ['facet1'], 161 | }); 162 | 163 | expect(params.projectKey).toBe('my-project'); 164 | expect(params.severity).toBe('MAJOR'); 165 | expect(params.page).toBe(2); 166 | expect(params.statuses).toEqual(['OPEN']); 167 | expect(params.resolved).toBe(true); 168 | expect(params.createdAfter).toBe('2023-01-01'); 169 | expect(params.assignees).toEqual(['user1']); 170 | expect(params.cwe).toEqual(['cwe1']); 171 | expect(params.facets).toEqual(['facet1']); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /src/__tests__/mocked-environment.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; 8 | 9 | // Mock all dependencies 10 | jest.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({ 11 | McpServer: jest.fn(() => ({ 12 | name: 'sonarqube-mcp-server', 13 | version: '1.1.0', 14 | tool: jest.fn(), 15 | connect: jest.fn().mockResolvedValue(undefined), 16 | server: { use: jest.fn() }, 17 | })), 18 | })); 19 | 20 | jest.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({ 21 | StdioServerTransport: jest.fn(() => ({ 22 | connect: jest.fn().mockResolvedValue(undefined), 23 | })), 24 | })); 25 | 26 | // Save original environment variables 27 | const originalEnv = process.env; 28 | 29 | // Set environment variables for testing 30 | process.env.SONARQUBE_TOKEN = 'test-token'; 31 | process.env.SONARQUBE_URL = 'http://localhost:9000'; 32 | process.env.SONARQUBE_ORGANIZATION = 'test-organization'; 33 | 34 | describe('Mocked Environment Tests', () => { 35 | beforeEach(() => { 36 | jest.resetModules(); 37 | process.env = { ...originalEnv }; 38 | process.env.SONARQUBE_TOKEN = 'test-token'; 39 | process.env.SONARQUBE_URL = 'http://localhost:9000'; 40 | process.env.SONARQUBE_ORGANIZATION = 'test-organization'; 41 | }); 42 | 43 | afterEach(() => { 44 | process.env = originalEnv; 45 | jest.clearAllMocks(); 46 | }); 47 | 48 | describe('Server Initialization', () => { 49 | it('should initialize the MCP server with correct configuration', async () => { 50 | const { mcpServer } = await import('../index.js'); 51 | expect(mcpServer).toBeDefined(); 52 | expect(mcpServer.name).toBe('sonarqube-mcp-server'); 53 | expect(mcpServer.version).toBe('1.1.0'); 54 | }); 55 | 56 | it('should register tools on the server', async () => { 57 | const { mcpServer } = await import('../index.js'); 58 | expect(mcpServer.tool).toBeDefined(); 59 | expect(mcpServer.tool).toHaveBeenCalled(); 60 | // Check number of tool registrations (9 tools total) 61 | expect(mcpServer.tool).toHaveBeenCalledTimes(9); 62 | }); 63 | 64 | it('should not connect to transport in test mode', async () => { 65 | process.env.NODE_ENV = 'test'; 66 | const { mcpServer } = await import('../index.js'); 67 | expect(mcpServer.connect).not.toHaveBeenCalled(); 68 | }); 69 | 70 | it('should connect to transport in non-test mode', async () => { 71 | process.env.NODE_ENV = 'development'; 72 | 73 | // Special mock for this specific test that simulates a clean import 74 | jest.resetModules(); 75 | 76 | // Import the module with development environment 77 | await import('../index.js'); 78 | 79 | // Since we're not directly importing mcpServer here, we check connection indirectly 80 | // We've mocked the StdioServerTransport so its connect method should have been called 81 | const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js'); 82 | expect(StdioServerTransport).toHaveBeenCalled(); 83 | 84 | // Reset to test mode 85 | process.env.NODE_ENV = 'test'; 86 | }); 87 | }); 88 | 89 | describe('Environment Variables', () => { 90 | it('should use environment variables to configure SonarQube client', async () => { 91 | // Set specific test environment variables 92 | process.env.SONARQUBE_TOKEN = 'specific-test-token'; 93 | process.env.SONARQUBE_URL = 'https://specific-test-url.com'; 94 | process.env.SONARQUBE_ORGANIZATION = 'specific-test-org'; 95 | 96 | // Mock the SonarQubeClient constructor to verify params 97 | const mockClientConstructor = jest.fn(); 98 | jest.mock('../sonarqube.js', () => ({ 99 | SonarQubeClient: mockClientConstructor.mockImplementation(() => ({ 100 | listProjects: jest.fn().mockResolvedValue({ projects: [], paging: {} }), 101 | getIssues: jest.fn().mockResolvedValue({ issues: [], paging: {} }), 102 | getMetrics: jest.fn().mockResolvedValue({ metrics: [], paging: {} }), 103 | getHealth: jest.fn().mockResolvedValue({}), 104 | getStatus: jest.fn().mockResolvedValue({}), 105 | ping: jest.fn().mockResolvedValue(''), 106 | getComponentMeasures: jest.fn().mockResolvedValue({}), 107 | getComponentsMeasures: jest.fn().mockResolvedValue({}), 108 | getMeasuresHistory: jest.fn().mockResolvedValue({}), 109 | })), 110 | })); 111 | 112 | // Import the module to create the client with our environment variables 113 | await import('../index.js'); 114 | 115 | // Verify client was created with the correct parameters 116 | expect(mockClientConstructor).toHaveBeenCalledWith( 117 | 'specific-test-token', 118 | 'https://specific-test-url.com', 119 | 'specific-test-org' 120 | ); 121 | }); 122 | }); 123 | 124 | describe('Tool Registration Complete', () => { 125 | it('should register all expected tools', async () => { 126 | const { mcpServer } = await import('../index.js'); 127 | 128 | // Verify all tools are registered 129 | const toolNames = mcpServer.tool.mock.calls.map((call) => call[0]); 130 | 131 | expect(toolNames).toContain('projects'); 132 | expect(toolNames).toContain('metrics'); 133 | expect(toolNames).toContain('issues'); 134 | expect(toolNames).toContain('system_health'); 135 | expect(toolNames).toContain('system_status'); 136 | expect(toolNames).toContain('system_ping'); 137 | expect(toolNames).toContain('measures_component'); 138 | expect(toolNames).toContain('measures_components'); 139 | expect(toolNames).toContain('measures_history'); 140 | }); 141 | 142 | it('should register tools with correct descriptions', async () => { 143 | const { mcpServer } = await import('../index.js'); 144 | 145 | // Map of tool names to their descriptions from the mcpServer.tool mock calls 146 | const toolDescriptions = new Map(mcpServer.tool.mock.calls.map((call) => [call[0], call[1]])); 147 | 148 | expect(toolDescriptions.get('projects')).toBe('List all SonarQube projects'); 149 | expect(toolDescriptions.get('metrics')).toBe('Get available metrics from SonarQube'); 150 | expect(toolDescriptions.get('issues')).toBe('Get issues for a SonarQube project'); 151 | expect(toolDescriptions.get('system_health')).toBe( 152 | 'Get the health status of the SonarQube instance' 153 | ); 154 | expect(toolDescriptions.get('system_status')).toBe( 155 | 'Get the status of the SonarQube instance' 156 | ); 157 | expect(toolDescriptions.get('system_ping')).toBe( 158 | 'Ping the SonarQube instance to check if it is up' 159 | ); 160 | expect(toolDescriptions.get('measures_component')).toBe( 161 | 'Get measures for a specific component' 162 | ); 163 | expect(toolDescriptions.get('measures_components')).toBe( 164 | 'Get measures for multiple components' 165 | ); 166 | expect(toolDescriptions.get('measures_history')).toBe('Get measures history for a component'); 167 | }); 168 | 169 | it('should register tools with valid schemas', async () => { 170 | const { mcpServer } = await import('../index.js'); 171 | 172 | // Extract schemas from the mcpServer.tool mock calls 173 | const toolSchemas = new Map(mcpServer.tool.mock.calls.map((call) => [call[0], call[2]])); 174 | 175 | // Check if each tool has a schema defined 176 | for (const [, schema] of toolSchemas.entries()) { 177 | expect(schema).toBeDefined(); 178 | } 179 | 180 | // Check specific schemas for required tools 181 | expect(toolSchemas.get('projects')).toHaveProperty('page'); 182 | expect(toolSchemas.get('projects')).toHaveProperty('page_size'); 183 | 184 | expect(toolSchemas.get('issues')).toHaveProperty('project_key'); 185 | expect(toolSchemas.get('issues')).toHaveProperty('severity'); 186 | 187 | expect(toolSchemas.get('measures_component')).toHaveProperty('component'); 188 | expect(toolSchemas.get('measures_component')).toHaveProperty('metric_keys'); 189 | 190 | expect(toolSchemas.get('measures_components')).toHaveProperty('component_keys'); 191 | expect(toolSchemas.get('measures_components')).toHaveProperty('metric_keys'); 192 | 193 | expect(toolSchemas.get('measures_history')).toHaveProperty('component'); 194 | expect(toolSchemas.get('measures_history')).toHaveProperty('metrics'); 195 | }); 196 | 197 | it('should register tools with valid handlers', async () => { 198 | const { mcpServer } = await import('../index.js'); 199 | 200 | // Extract handlers from the mcpServer.tool mock calls 201 | const toolHandlers = new Map(mcpServer.tool.mock.calls.map((call) => [call[0], call[3]])); 202 | 203 | // Check if each tool has a handler defined and it's a function 204 | for (const [, handler] of toolHandlers.entries()) { 205 | expect(handler).toBeDefined(); 206 | expect(typeof handler).toBe('function'); 207 | } 208 | }); 209 | }); 210 | }); 211 | -------------------------------------------------------------------------------- /src/__tests__/null-to-undefined.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect } from '@jest/globals'; 8 | import { nullToUndefined } from '../index.js'; 9 | 10 | describe('nullToUndefined', () => { 11 | it('should convert null to undefined', () => { 12 | expect(nullToUndefined(null)).toBeUndefined(); 13 | }); 14 | 15 | it('should pass through non-null values', () => { 16 | expect(nullToUndefined('value')).toBe('value'); 17 | expect(nullToUndefined(123)).toBe(123); 18 | expect(nullToUndefined(0)).toBe(0); 19 | expect(nullToUndefined(false)).toBe(false); 20 | expect(nullToUndefined(undefined)).toBeUndefined(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/__tests__/parameter-transformations-advanced.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect } from '@jest/globals'; 8 | import { z } from 'zod'; 9 | 10 | describe('Parameter Transformations', () => { 11 | describe('Page and PageSize Transformations', () => { 12 | it('should transform valid and invalid page values', () => { 13 | // Create schema that matches what's used in index.ts for page transformation 14 | const pageSchema = z 15 | .string() 16 | .optional() 17 | .transform((val) => (val ? parseInt(val, 10) || null : null)); 18 | 19 | // Test with valid number strings 20 | expect(pageSchema.parse('10')).toBe(10); 21 | expect(pageSchema.parse('20')).toBe(20); 22 | 23 | // In the actual implementation, '0' returns 0 or null depending on the parseInt result 24 | // Our implementation here returns null for '0' since parseInt('0', 10) is 0, which is falsy 25 | expect(pageSchema.parse('0')).toBe(null); 26 | 27 | // Test with invalid number strings - should return null 28 | expect(pageSchema.parse('invalid')).toBe(null); 29 | expect(pageSchema.parse('abc123')).toBe(null); 30 | expect(pageSchema.parse('true')).toBe(null); 31 | 32 | // Test with empty/undefined values - should return null 33 | expect(pageSchema.parse(undefined)).toBe(null); 34 | expect(pageSchema.parse('')).toBe(null); 35 | }); 36 | }); 37 | 38 | describe('Boolean Transformations', () => { 39 | it('should transform boolean string values', () => { 40 | // Create schema that matches what's used in index.ts for boolean transformation 41 | const booleanSchema = z 42 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 43 | .nullable() 44 | .optional(); 45 | 46 | // Test with string values 47 | expect(booleanSchema.parse('true')).toBe(true); 48 | expect(booleanSchema.parse('false')).toBe(false); 49 | expect(booleanSchema.parse('anything-else')).toBe(false); 50 | 51 | // Test with actual boolean values 52 | expect(booleanSchema.parse(true)).toBe(true); 53 | expect(booleanSchema.parse(false)).toBe(false); 54 | 55 | // Test with null/undefined values 56 | expect(booleanSchema.parse(null)).toBe(null); 57 | expect(booleanSchema.parse(undefined)).toBe(undefined); 58 | }); 59 | }); 60 | 61 | describe('Status Schema', () => { 62 | it('should validate correct status values', () => { 63 | // Create schema that matches what's used in index.ts for status validation 64 | const statusSchema = z 65 | .array( 66 | z.enum([ 67 | 'OPEN', 68 | 'CONFIRMED', 69 | 'REOPENED', 70 | 'RESOLVED', 71 | 'CLOSED', 72 | 'TO_REVIEW', 73 | 'IN_REVIEW', 74 | 'REVIEWED', 75 | ]) 76 | ) 77 | .nullable() 78 | .optional(); 79 | 80 | // Test with valid status arrays 81 | expect(statusSchema.parse(['OPEN', 'CONFIRMED'])).toEqual(['OPEN', 'CONFIRMED']); 82 | expect(statusSchema.parse(['RESOLVED', 'CLOSED'])).toEqual(['RESOLVED', 'CLOSED']); 83 | expect(statusSchema.parse(['TO_REVIEW', 'IN_REVIEW', 'REVIEWED'])).toEqual([ 84 | 'TO_REVIEW', 85 | 'IN_REVIEW', 86 | 'REVIEWED', 87 | ]); 88 | 89 | // Test with null/undefined values 90 | expect(statusSchema.parse(null)).toBe(null); 91 | expect(statusSchema.parse(undefined)).toBe(undefined); 92 | 93 | // Should throw on invalid values 94 | expect(() => statusSchema.parse(['INVALID'])).toThrow(); 95 | expect(() => statusSchema.parse(['open'])).toThrow(); // case sensitive 96 | }); 97 | }); 98 | 99 | describe('Resolution Schema', () => { 100 | it('should validate correct resolution values', () => { 101 | // Create schema that matches what's used in index.ts for resolution validation 102 | const resolutionSchema = z 103 | .array(z.enum(['FALSE-POSITIVE', 'WONTFIX', 'FIXED', 'REMOVED'])) 104 | .nullable() 105 | .optional(); 106 | 107 | // Test with valid resolution arrays 108 | expect(resolutionSchema.parse(['FALSE-POSITIVE', 'WONTFIX'])).toEqual([ 109 | 'FALSE-POSITIVE', 110 | 'WONTFIX', 111 | ]); 112 | expect(resolutionSchema.parse(['FIXED', 'REMOVED'])).toEqual(['FIXED', 'REMOVED']); 113 | 114 | // Test with null/undefined values 115 | expect(resolutionSchema.parse(null)).toBe(null); 116 | expect(resolutionSchema.parse(undefined)).toBe(undefined); 117 | 118 | // Should throw on invalid values 119 | expect(() => resolutionSchema.parse(['INVALID'])).toThrow(); 120 | }); 121 | }); 122 | 123 | describe('Type Schema', () => { 124 | it('should validate correct type values', () => { 125 | // Create schema that matches what's used in index.ts for type validation 126 | const typeSchema = z 127 | .array(z.enum(['CODE_SMELL', 'BUG', 'VULNERABILITY', 'SECURITY_HOTSPOT'])) 128 | .nullable() 129 | .optional(); 130 | 131 | // Test with valid type arrays 132 | expect(typeSchema.parse(['CODE_SMELL', 'BUG'])).toEqual(['CODE_SMELL', 'BUG']); 133 | expect(typeSchema.parse(['VULNERABILITY', 'SECURITY_HOTSPOT'])).toEqual([ 134 | 'VULNERABILITY', 135 | 'SECURITY_HOTSPOT', 136 | ]); 137 | 138 | // Test with null/undefined values 139 | expect(typeSchema.parse(null)).toBe(null); 140 | expect(typeSchema.parse(undefined)).toBe(undefined); 141 | 142 | // Should throw on invalid values 143 | expect(() => typeSchema.parse(['INVALID'])).toThrow(); 144 | }); 145 | }); 146 | 147 | describe('Severity Schema', () => { 148 | it('should validate correct severity values', () => { 149 | // Create schema that matches what's used in index.ts for severity validation 150 | const severitySchema = z 151 | .enum(['INFO', 'MINOR', 'MAJOR', 'CRITICAL', 'BLOCKER']) 152 | .nullable() 153 | .optional(); 154 | 155 | // Test with valid severities 156 | expect(severitySchema.parse('INFO')).toBe('INFO'); 157 | expect(severitySchema.parse('MINOR')).toBe('MINOR'); 158 | expect(severitySchema.parse('MAJOR')).toBe('MAJOR'); 159 | expect(severitySchema.parse('CRITICAL')).toBe('CRITICAL'); 160 | expect(severitySchema.parse('BLOCKER')).toBe('BLOCKER'); 161 | 162 | // Test with null/undefined values 163 | expect(severitySchema.parse(null)).toBe(null); 164 | expect(severitySchema.parse(undefined)).toBe(undefined); 165 | 166 | // Should throw on invalid values 167 | expect(() => severitySchema.parse('INVALID')).toThrow(); 168 | expect(() => severitySchema.parse('minor')).toThrow(); // case sensitive 169 | }); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /src/__tests__/parameter-transformations.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect } from '@jest/globals'; 8 | import { mapToSonarQubeParams, nullToUndefined } from '../index.js'; 9 | import { z } from 'zod'; 10 | 11 | describe('Parameter Transformation Functions', () => { 12 | describe('nullToUndefined', () => { 13 | it('should convert null to undefined', () => { 14 | expect(nullToUndefined(null)).toBeUndefined(); 15 | }); 16 | 17 | it('should return the original value for non-null inputs', () => { 18 | expect(nullToUndefined(0)).toBe(0); 19 | expect(nullToUndefined('')).toBe(''); 20 | expect(nullToUndefined('test')).toBe('test'); 21 | expect(nullToUndefined(undefined)).toBeUndefined(); 22 | expect(nullToUndefined(123)).toBe(123); 23 | expect(nullToUndefined(false)).toBe(false); 24 | expect(nullToUndefined(true)).toBe(true); 25 | 26 | const obj = { test: 'value' }; 27 | const arr = [1, 2, 3]; 28 | expect(nullToUndefined(obj)).toBe(obj); 29 | expect(nullToUndefined(arr)).toBe(arr); 30 | }); 31 | }); 32 | 33 | describe('mapToSonarQubeParams', () => { 34 | it('should map MCP tool parameters to SonarQube client parameters', () => { 35 | const result = mapToSonarQubeParams({ 36 | project_key: 'my-project', 37 | severity: 'MAJOR', 38 | page: '10', 39 | page_size: '25', 40 | statuses: ['OPEN', 'CONFIRMED'], 41 | resolutions: ['FALSE-POSITIVE'], 42 | resolved: 'true', 43 | types: ['BUG', 'VULNERABILITY'], 44 | rules: ['rule1', 'rule2'], 45 | tags: ['tag1', 'tag2'], 46 | created_after: '2023-01-01', 47 | created_before: '2023-12-31', 48 | created_at: '2023-06-15', 49 | created_in_last: '30d', 50 | assignees: ['user1', 'user2'], 51 | authors: ['author1', 'author2'], 52 | cwe: ['cwe1', 'cwe2'], 53 | languages: ['java', 'js'], 54 | owasp_top10: ['a1', 'a2'], 55 | sans_top25: ['sans1', 'sans2'], 56 | sonarsource_security: ['ss1', 'ss2'], 57 | on_component_only: 'true', 58 | facets: ['facet1', 'facet2'], 59 | since_leak_period: 'true', 60 | in_new_code_period: 'true', 61 | }); 62 | 63 | // Check key mappings 64 | expect(result.projectKey).toBe('my-project'); 65 | expect(result.severity).toBe('MAJOR'); 66 | expect(result.page).toBe('10'); 67 | expect(result.pageSize).toBe('25'); 68 | expect(result.statuses).toEqual(['OPEN', 'CONFIRMED']); 69 | expect(result.resolutions).toEqual(['FALSE-POSITIVE']); 70 | expect(result.resolved).toBe('true'); 71 | expect(result.types).toEqual(['BUG', 'VULNERABILITY']); 72 | expect(result.rules).toEqual(['rule1', 'rule2']); 73 | expect(result.tags).toEqual(['tag1', 'tag2']); 74 | expect(result.createdAfter).toBe('2023-01-01'); 75 | expect(result.createdBefore).toBe('2023-12-31'); 76 | expect(result.createdAt).toBe('2023-06-15'); 77 | expect(result.createdInLast).toBe('30d'); 78 | expect(result.assignees).toEqual(['user1', 'user2']); 79 | expect(result.authors).toEqual(['author1', 'author2']); 80 | expect(result.cwe).toEqual(['cwe1', 'cwe2']); 81 | expect(result.languages).toEqual(['java', 'js']); 82 | expect(result.owaspTop10).toEqual(['a1', 'a2']); 83 | expect(result.sansTop25).toEqual(['sans1', 'sans2']); 84 | expect(result.sonarsourceSecurity).toEqual(['ss1', 'ss2']); 85 | expect(result.onComponentOnly).toBe('true'); 86 | expect(result.facets).toEqual(['facet1', 'facet2']); 87 | expect(result.sinceLeakPeriod).toBe('true'); 88 | expect(result.inNewCodePeriod).toBe('true'); 89 | }); 90 | 91 | it('should handle null and undefined values correctly', () => { 92 | const result = mapToSonarQubeParams({ 93 | project_key: 'my-project', 94 | severity: null, 95 | statuses: null, 96 | resolved: null, 97 | }); 98 | 99 | expect(result.projectKey).toBe('my-project'); 100 | expect(result.severity).toBeUndefined(); 101 | expect(result.statuses).toBeUndefined(); 102 | expect(result.resolved).toBeUndefined(); 103 | }); 104 | 105 | it('should handle minimal parameters', () => { 106 | const result = mapToSonarQubeParams({ 107 | project_key: 'my-project', 108 | }); 109 | 110 | expect(result.projectKey).toBe('my-project'); 111 | expect(result.severity).toBeUndefined(); 112 | expect(result.page).toBeUndefined(); 113 | expect(result.pageSize).toBeUndefined(); 114 | }); 115 | 116 | it('should handle empty parameters', () => { 117 | const result = mapToSonarQubeParams({ 118 | project_key: 'my-project', 119 | statuses: [], 120 | resolutions: [], 121 | types: [], 122 | rules: [], 123 | }); 124 | 125 | expect(result.projectKey).toBe('my-project'); 126 | expect(result.statuses).toEqual([]); 127 | expect(result.resolutions).toEqual([]); 128 | expect(result.types).toEqual([]); 129 | expect(result.rules).toEqual([]); 130 | }); 131 | }); 132 | 133 | describe('Array parameter handling', () => { 134 | it('should handle array handling for issues parameters', () => { 135 | // Test with arrays 136 | const result1 = mapToSonarQubeParams({ 137 | project_key: 'project1', 138 | statuses: ['OPEN', 'CONFIRMED'], 139 | types: ['BUG', 'VULNERABILITY'], 140 | }); 141 | 142 | expect(result1.statuses).toEqual(['OPEN', 'CONFIRMED']); 143 | expect(result1.types).toEqual(['BUG', 'VULNERABILITY']); 144 | 145 | // Test with null 146 | const result2 = mapToSonarQubeParams({ 147 | project_key: 'project1', 148 | statuses: null, 149 | types: null, 150 | }); 151 | 152 | expect(result2.statuses).toBeUndefined(); 153 | expect(result2.types).toBeUndefined(); 154 | }); 155 | }); 156 | 157 | describe('Schema Transformations', () => { 158 | describe('Page Parameter Transformation', () => { 159 | it('should transform string values to numbers or null', () => { 160 | const pageSchema = z 161 | .string() 162 | .optional() 163 | .transform((val) => (val ? parseInt(val, 10) || null : null)); 164 | 165 | // Test valid numeric strings 166 | expect(pageSchema.parse('1')).toBe(1); 167 | expect(pageSchema.parse('100')).toBe(100); 168 | 169 | // Test invalid values 170 | expect(pageSchema.parse('invalid')).toBe(null); 171 | expect(pageSchema.parse('')).toBe(null); 172 | expect(pageSchema.parse(undefined)).toBe(null); 173 | }); 174 | }); 175 | 176 | describe('Boolean Parameter Transformation', () => { 177 | it('should transform string "true"/"false" to boolean values', () => { 178 | const booleanSchema = z 179 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 180 | .nullable() 181 | .optional(); 182 | 183 | // String values 184 | expect(booleanSchema.parse('true')).toBe(true); 185 | expect(booleanSchema.parse('false')).toBe(false); 186 | 187 | // Boolean values should pass through 188 | expect(booleanSchema.parse(true)).toBe(true); 189 | expect(booleanSchema.parse(false)).toBe(false); 190 | 191 | // Null/undefined values 192 | expect(booleanSchema.parse(null)).toBe(null); 193 | expect(booleanSchema.parse(undefined)).toBe(undefined); 194 | }); 195 | }); 196 | 197 | describe('Enum Arrays Parameter Transformation', () => { 198 | it('should validate enum arrays correctly', () => { 199 | const statusSchema = z 200 | .array( 201 | z.enum([ 202 | 'OPEN', 203 | 'CONFIRMED', 204 | 'REOPENED', 205 | 'RESOLVED', 206 | 'CLOSED', 207 | 'TO_REVIEW', 208 | 'IN_REVIEW', 209 | 'REVIEWED', 210 | ]) 211 | ) 212 | .nullable() 213 | .optional(); 214 | 215 | // Valid values 216 | expect(statusSchema.parse(['OPEN', 'CONFIRMED'])).toEqual(['OPEN', 'CONFIRMED']); 217 | 218 | // Null/undefined values 219 | expect(statusSchema.parse(null)).toBe(null); 220 | expect(statusSchema.parse(undefined)).toBe(undefined); 221 | 222 | // Invalid values should throw 223 | expect(() => statusSchema.parse(['INVALID'])).toThrow(); 224 | }); 225 | 226 | it('should validate resolution enums', () => { 227 | const resolutionSchema = z 228 | .array(z.enum(['FALSE-POSITIVE', 'WONTFIX', 'FIXED', 'REMOVED'])) 229 | .nullable() 230 | .optional(); 231 | 232 | // Valid values 233 | expect(resolutionSchema.parse(['FALSE-POSITIVE', 'WONTFIX'])).toEqual([ 234 | 'FALSE-POSITIVE', 235 | 'WONTFIX', 236 | ]); 237 | 238 | // Null/undefined values 239 | expect(resolutionSchema.parse(null)).toBe(null); 240 | expect(resolutionSchema.parse(undefined)).toBe(undefined); 241 | 242 | // Invalid values should throw 243 | expect(() => resolutionSchema.parse(['INVALID'])).toThrow(); 244 | }); 245 | 246 | it('should validate issue type enums', () => { 247 | const typeSchema = z 248 | .array(z.enum(['CODE_SMELL', 'BUG', 'VULNERABILITY', 'SECURITY_HOTSPOT'])) 249 | .nullable() 250 | .optional(); 251 | 252 | // Valid values 253 | expect(typeSchema.parse(['CODE_SMELL', 'BUG'])).toEqual(['CODE_SMELL', 'BUG']); 254 | 255 | // Null/undefined values 256 | expect(typeSchema.parse(null)).toBe(null); 257 | expect(typeSchema.parse(undefined)).toBe(undefined); 258 | 259 | // Invalid values should throw 260 | expect(() => typeSchema.parse(['INVALID'])).toThrow(); 261 | }); 262 | }); 263 | }); 264 | }); 265 | -------------------------------------------------------------------------------- /src/__tests__/quality-gates.test.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock'; 2 | import { createSonarQubeClient, SonarQubeClient, ProjectQualityGateParams } from '../sonarqube.js'; 3 | import { 4 | handleSonarQubeListQualityGates, 5 | handleSonarQubeGetQualityGate, 6 | handleSonarQubeQualityGateStatus, 7 | } from '../index.js'; 8 | 9 | describe('SonarQube Quality Gates API', () => { 10 | const baseUrl = 'https://sonarcloud.io'; 11 | const token = 'fake-token'; 12 | let client: SonarQubeClient; 13 | 14 | beforeEach(() => { 15 | client = createSonarQubeClient(token, baseUrl) as SonarQubeClient; 16 | nock.disableNetConnect(); 17 | }); 18 | 19 | afterEach(() => { 20 | nock.cleanAll(); 21 | nock.enableNetConnect(); 22 | }); 23 | 24 | describe('listQualityGates', () => { 25 | it('should return a list of quality gates', async () => { 26 | const mockResponse = { 27 | qualitygates: [ 28 | { 29 | id: '1', 30 | name: 'Sonar way', 31 | isDefault: true, 32 | isBuiltIn: true, 33 | }, 34 | { 35 | id: '2', 36 | name: 'Custom Quality Gate', 37 | isDefault: false, 38 | isBuiltIn: false, 39 | }, 40 | ], 41 | default: '1', 42 | actions: { 43 | create: true, 44 | }, 45 | }; 46 | 47 | nock(baseUrl) 48 | .get('/api/qualitygates/list') 49 | .query(() => true) 50 | .reply(200, mockResponse); 51 | 52 | const result = await client.listQualityGates(); 53 | expect(result).toEqual(mockResponse); 54 | }); 55 | 56 | it('handler should return quality gates in the expected format', async () => { 57 | const mockResponse = { 58 | qualitygates: [ 59 | { 60 | id: '1', 61 | name: 'Sonar way', 62 | isDefault: true, 63 | isBuiltIn: true, 64 | }, 65 | ], 66 | default: '1', 67 | }; 68 | 69 | nock(baseUrl) 70 | .get('/api/qualitygates/list') 71 | .query(() => true) 72 | .reply(200, mockResponse); 73 | 74 | const response = await handleSonarQubeListQualityGates(client); 75 | expect(response).toHaveProperty('content'); 76 | expect(response.content).toHaveLength(1); 77 | expect(response.content[0].type).toBe('text'); 78 | 79 | const parsedContent = JSON.parse(response.content[0].text); 80 | expect(parsedContent).toEqual(mockResponse); 81 | }); 82 | }); 83 | 84 | describe('getQualityGate', () => { 85 | it('should return quality gate details including conditions', async () => { 86 | const gateId = '1'; 87 | const mockResponse = { 88 | id: '1', 89 | name: 'Sonar way', 90 | isDefault: true, 91 | isBuiltIn: true, 92 | conditions: [ 93 | { 94 | id: '3', 95 | metric: 'new_coverage', 96 | op: 'LT', 97 | error: '80', 98 | }, 99 | { 100 | id: '4', 101 | metric: 'new_bugs', 102 | op: 'GT', 103 | error: '0', 104 | }, 105 | ], 106 | }; 107 | 108 | nock(baseUrl).get('/api/qualitygates/show').query({ id: gateId }).reply(200, mockResponse); 109 | 110 | const result = await client.getQualityGate(gateId); 111 | expect(result).toEqual(mockResponse); 112 | }); 113 | 114 | it('handler should return quality gate details in the expected format', async () => { 115 | const gateId = '1'; 116 | const mockResponse = { 117 | id: '1', 118 | name: 'Sonar way', 119 | conditions: [ 120 | { 121 | id: '3', 122 | metric: 'new_coverage', 123 | op: 'LT', 124 | error: '80', 125 | }, 126 | ], 127 | }; 128 | 129 | nock(baseUrl).get('/api/qualitygates/show').query({ id: gateId }).reply(200, mockResponse); 130 | 131 | const response = await handleSonarQubeGetQualityGate({ id: gateId }, client); 132 | expect(response).toHaveProperty('content'); 133 | expect(response.content).toHaveLength(1); 134 | expect(response.content[0].type).toBe('text'); 135 | 136 | const parsedContent = JSON.parse(response.content[0].text); 137 | expect(parsedContent).toEqual(mockResponse); 138 | }); 139 | }); 140 | 141 | describe('getProjectQualityGateStatus', () => { 142 | it('should return the quality gate status for a project', async () => { 143 | const params: ProjectQualityGateParams = { 144 | projectKey: 'my-project', 145 | }; 146 | 147 | const mockResponse = { 148 | projectStatus: { 149 | status: 'OK', 150 | conditions: [ 151 | { 152 | status: 'OK', 153 | metricKey: 'new_reliability_rating', 154 | comparator: 'GT', 155 | errorThreshold: '1', 156 | actualValue: '1', 157 | }, 158 | { 159 | status: 'ERROR', 160 | metricKey: 'new_security_rating', 161 | comparator: 'GT', 162 | errorThreshold: '1', 163 | actualValue: '2', 164 | }, 165 | ], 166 | periods: [ 167 | { 168 | index: 1, 169 | mode: 'previous_version', 170 | date: '2020-01-01T00:00:00+0000', 171 | }, 172 | ], 173 | ignoredConditions: false, 174 | }, 175 | }; 176 | 177 | nock(baseUrl) 178 | .get('/api/qualitygates/project_status') 179 | .query({ projectKey: params.projectKey }) 180 | .reply(200, mockResponse); 181 | 182 | const result = await client.getProjectQualityGateStatus(params); 183 | expect(result).toEqual(mockResponse); 184 | }); 185 | 186 | it('should include branch parameter if provided', async () => { 187 | const params: ProjectQualityGateParams = { 188 | projectKey: 'my-project', 189 | branch: 'feature/branch', 190 | }; 191 | 192 | const mockResponse = { 193 | projectStatus: { 194 | status: 'OK', 195 | conditions: [], 196 | ignoredConditions: false, 197 | }, 198 | }; 199 | 200 | const scope = nock(baseUrl) 201 | .get('/api/qualitygates/project_status') 202 | .query({ projectKey: params.projectKey, branch: params.branch }) 203 | .reply(200, mockResponse); 204 | 205 | const result = await client.getProjectQualityGateStatus(params); 206 | expect(result).toEqual(mockResponse); 207 | expect(scope.isDone()).toBe(true); 208 | }); 209 | 210 | it('handler should return project quality gate status in the expected format', async () => { 211 | const params: ProjectQualityGateParams = { 212 | projectKey: 'my-project', 213 | }; 214 | 215 | const mockResponse = { 216 | projectStatus: { 217 | status: 'OK', 218 | conditions: [], 219 | ignoredConditions: false, 220 | }, 221 | }; 222 | 223 | nock(baseUrl) 224 | .get('/api/qualitygates/project_status') 225 | .query({ projectKey: params.projectKey }) 226 | .reply(200, mockResponse); 227 | 228 | const response = await handleSonarQubeQualityGateStatus(params, client); 229 | expect(response).toHaveProperty('content'); 230 | expect(response.content).toHaveLength(1); 231 | expect(response.content[0].type).toBe('text'); 232 | 233 | const parsedContent = JSON.parse(response.content[0].text); 234 | expect(parsedContent).toEqual(mockResponse); 235 | }); 236 | }); 237 | }); 238 | -------------------------------------------------------------------------------- /src/__tests__/schema-parameter-transforms.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { describe, it, expect, jest } from '@jest/globals'; 6 | import { z } from 'zod'; 7 | import * as indexModule from '../index.js'; 8 | import { ISonarQubeClient } from '../sonarqube.js'; 9 | 10 | // Create a custom mock implementation of the handlers 11 | const nullToUndefined = indexModule.nullToUndefined; 12 | 13 | // Create a mock client 14 | const mockClient: Partial = { 15 | getMetrics: jest.fn().mockResolvedValue({ 16 | metrics: [{ id: '1', key: 'test', name: 'Test Metric' }], 17 | paging: { pageIndex: 2, pageSize: 5, total: 10 }, 18 | }), 19 | getIssues: jest.fn().mockResolvedValue({ 20 | issues: [{ key: 'issue-1', rule: 'rule-1', severity: 'MAJOR' }], 21 | paging: { pageIndex: 1, pageSize: 10, total: 1 }, 22 | }), 23 | getComponentMeasures: jest.fn().mockResolvedValue({ 24 | component: { key: 'comp-1', measures: [{ metric: 'coverage', value: '75.0' }] }, 25 | metrics: [{ key: 'coverage', name: 'Coverage' }], 26 | }), 27 | getComponentsMeasures: jest.fn().mockResolvedValue({ 28 | components: [ 29 | { key: 'comp-1', measures: [{ metric: 'coverage', value: '75.0' }] }, 30 | { key: 'comp-2', measures: [{ metric: 'coverage', value: '85.0' }] }, 31 | ], 32 | metrics: [{ key: 'coverage', name: 'Coverage' }], 33 | paging: { pageIndex: 1, pageSize: 10, total: 2 }, 34 | }), 35 | getMeasuresHistory: jest.fn().mockResolvedValue({ 36 | measures: [ 37 | { 38 | metric: 'coverage', 39 | history: [ 40 | { date: '2023-01-01', value: '70.0' }, 41 | { date: '2023-02-01', value: '75.0' }, 42 | { date: '2023-03-01', value: '80.0' }, 43 | ], 44 | }, 45 | ], 46 | paging: { pageIndex: 1, pageSize: 10, total: 1 }, 47 | }), 48 | }; 49 | 50 | // Mock handlers that don't actually call the HTTP methods 51 | const mockMetricsHandler = async (params: { page: number | null; page_size: number | null }) => { 52 | const mockResult = await (mockClient as ISonarQubeClient).getMetrics({ 53 | page: nullToUndefined(params.page), 54 | pageSize: nullToUndefined(params.page_size), 55 | }); 56 | 57 | return { 58 | content: [ 59 | { 60 | type: 'text' as const, 61 | text: JSON.stringify(mockResult, null, 2), 62 | }, 63 | ], 64 | }; 65 | }; 66 | 67 | const mockIssuesHandler = async (params: Record) => { 68 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 69 | const mockResult = await (mockClient as ISonarQubeClient).getIssues(params as any); 70 | 71 | return { 72 | content: [ 73 | { 74 | type: 'text' as const, 75 | text: JSON.stringify(mockResult, null, 2), 76 | }, 77 | ], 78 | }; 79 | }; 80 | 81 | const mockComponentMeasuresHandler = async (params: Record) => { 82 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 83 | const mockResult = await (mockClient as ISonarQubeClient).getComponentMeasures(params as any); 84 | 85 | return { 86 | content: [ 87 | { 88 | type: 'text' as const, 89 | text: JSON.stringify(mockResult, null, 2), 90 | }, 91 | ], 92 | }; 93 | }; 94 | 95 | const mockComponentsMeasuresHandler = async (params: Record) => { 96 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 97 | const mockResult = await (mockClient as ISonarQubeClient).getComponentsMeasures(params as any); 98 | 99 | return { 100 | content: [ 101 | { 102 | type: 'text' as const, 103 | text: JSON.stringify(mockResult, null, 2), 104 | }, 105 | ], 106 | }; 107 | }; 108 | 109 | const mockMeasuresHistoryHandler = async (params: Record) => { 110 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 111 | const mockResult = await (mockClient as ISonarQubeClient).getMeasuresHistory(params as any); 112 | 113 | return { 114 | content: [ 115 | { 116 | type: 'text' as const, 117 | text: JSON.stringify(mockResult, null, 2), 118 | }, 119 | ], 120 | }; 121 | }; 122 | 123 | // Helper function to test string to number parameter transformations (not used directly) 124 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 125 | function testNumberTransform(transformFn: (val: string | undefined) => number | null | undefined) { 126 | // Valid number 127 | expect(transformFn('10')).toBe(10); 128 | 129 | // Empty string should return null 130 | expect(transformFn('')).toBe(null); 131 | 132 | // Invalid number should return null 133 | expect(transformFn('abc')).toBe(null); 134 | 135 | // Undefined should return undefined 136 | expect(transformFn(undefined)).toBe(undefined); 137 | } 138 | 139 | describe('Schema Parameter Transformations', () => { 140 | describe('Number Transformations', () => { 141 | it('should transform string numbers to integers or null', () => { 142 | // Create a schema with number transformation 143 | const schema = z 144 | .string() 145 | .optional() 146 | .transform((val) => (val ? parseInt(val, 10) || null : null)); 147 | 148 | // Test the transformation 149 | expect(schema.parse('10')).toBe(10); 150 | expect(schema.parse('')).toBe(null); 151 | expect(schema.parse('abc')).toBe(null); 152 | expect(schema.parse(undefined)).toBe(null); 153 | }); 154 | }); 155 | 156 | describe('Boolean Transformations', () => { 157 | it('should transform string booleans to boolean values', () => { 158 | // Create a schema with boolean transformation 159 | const schema = z 160 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 161 | .nullable() 162 | .optional(); 163 | 164 | // Test the transformation 165 | expect(schema.parse('true')).toBe(true); 166 | expect(schema.parse('false')).toBe(false); 167 | expect(schema.parse(true)).toBe(true); 168 | expect(schema.parse(false)).toBe(false); 169 | expect(schema.parse(null)).toBe(null); 170 | expect(schema.parse(undefined)).toBe(undefined); 171 | }); 172 | }); 173 | 174 | describe('Parameter Transformations for Lambda Functions', () => { 175 | it('should handle nullToUndefined utility function', () => { 176 | expect(nullToUndefined(null)).toBeUndefined(); 177 | expect(nullToUndefined(undefined)).toBeUndefined(); 178 | expect(nullToUndefined(0)).toBe(0); 179 | expect(nullToUndefined('')).toBe(''); 180 | expect(nullToUndefined('test')).toBe('test'); 181 | expect(nullToUndefined(10)).toBe(10); 182 | expect(nullToUndefined(false)).toBe(false); 183 | expect(nullToUndefined(true)).toBe(true); 184 | }); 185 | 186 | it('should handle metrics handler with string parameters', async () => { 187 | const result = await mockMetricsHandler({ page: null, page_size: null }); 188 | 189 | // Verify the result structure 190 | expect(result).toHaveProperty('content'); 191 | expect(result.content[0]).toHaveProperty('type', 'text'); 192 | expect(result.content[0]).toHaveProperty('text'); 193 | 194 | // Verify the result content 195 | const data = JSON.parse(result.content[0].text); 196 | expect(data).toHaveProperty('metrics'); 197 | expect(data.metrics[0]).toHaveProperty('key', 'test'); 198 | }); 199 | 200 | it('should handle issues with complex parameters', async () => { 201 | const result = await mockIssuesHandler({ 202 | project_key: 'test-project', 203 | severity: 'MAJOR', 204 | page: '1', 205 | page_size: '10', 206 | statuses: ['OPEN', 'CONFIRMED'], 207 | resolved: 'true', 208 | types: ['BUG', 'VULNERABILITY'], 209 | rules: ['rule1', 'rule2'], 210 | tags: ['tag1', 'tag2'], 211 | created_after: '2023-01-01', 212 | on_component_only: 'true', 213 | since_leak_period: 'true', 214 | in_new_code_period: 'true', 215 | }); 216 | 217 | // Verify the result structure 218 | expect(result).toHaveProperty('content'); 219 | expect(result.content[0]).toHaveProperty('type', 'text'); 220 | expect(result.content[0]).toHaveProperty('text'); 221 | 222 | // Verify the result content 223 | const data = JSON.parse(result.content[0].text); 224 | expect(data).toHaveProperty('issues'); 225 | expect(data.issues[0]).toHaveProperty('key', 'issue-1'); 226 | }); 227 | 228 | it('should handle component measures with parameters', async () => { 229 | const result = await mockComponentMeasuresHandler({ 230 | component: 'comp-1', 231 | metric_keys: ['coverage'], 232 | branch: 'main', 233 | period: '1', 234 | additional_fields: ['metrics'], 235 | }); 236 | 237 | // Verify the result structure 238 | expect(result).toHaveProperty('content'); 239 | expect(result.content[0]).toHaveProperty('type', 'text'); 240 | expect(result.content[0]).toHaveProperty('text'); 241 | 242 | // Verify the result content 243 | const data = JSON.parse(result.content[0].text); 244 | expect(data).toHaveProperty('component'); 245 | expect(data.component).toHaveProperty('key', 'comp-1'); 246 | }); 247 | 248 | it('should handle components measures with parameters', async () => { 249 | const result = await mockComponentsMeasuresHandler({ 250 | component_keys: ['comp-1', 'comp-2'], 251 | metric_keys: ['coverage'], 252 | branch: 'main', 253 | page: '1', 254 | page_size: '10', 255 | additional_fields: ['metrics'], 256 | }); 257 | 258 | // Verify the result structure 259 | expect(result).toHaveProperty('content'); 260 | expect(result.content[0]).toHaveProperty('type', 'text'); 261 | expect(result.content[0]).toHaveProperty('text'); 262 | 263 | // Verify the result content 264 | const data = JSON.parse(result.content[0].text); 265 | expect(data).toHaveProperty('components'); 266 | expect(data.components).toHaveLength(2); 267 | expect(data.components[0]).toHaveProperty('key', 'comp-1'); 268 | }); 269 | 270 | it('should handle measures history with parameters', async () => { 271 | const result = await mockMeasuresHistoryHandler({ 272 | component: 'comp-1', 273 | metrics: ['coverage'], 274 | from: '2023-01-01', 275 | to: '2023-03-01', 276 | page: '1', 277 | page_size: '10', 278 | }); 279 | 280 | // Verify the result structure 281 | expect(result).toHaveProperty('content'); 282 | expect(result.content[0]).toHaveProperty('type', 'text'); 283 | expect(result.content[0]).toHaveProperty('text'); 284 | 285 | // Verify the result content 286 | const data = JSON.parse(result.content[0].text); 287 | expect(data).toHaveProperty('measures'); 288 | expect(data.measures[0]).toHaveProperty('metric', 'coverage'); 289 | expect(data.measures[0].history).toHaveLength(3); 290 | }); 291 | }); 292 | }); 293 | -------------------------------------------------------------------------------- /src/__tests__/schema-transformation-mocks.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect } from '@jest/globals'; 8 | 9 | // These tests mock the transformations used in the tool registrations in index.ts 10 | 11 | describe('Schema Transformation Mocks', () => { 12 | describe('Page and PageSize Transformations in Tool Registrations', () => { 13 | it('should test page schema transformation - projects tool', () => { 14 | const pageTransform = (val) => (val ? parseInt(val, 10) || null : null); 15 | 16 | expect(pageTransform('10')).toBe(10); 17 | expect(pageTransform('invalid')).toBe(null); 18 | expect(pageTransform(undefined)).toBe(null); 19 | expect(pageTransform('')).toBe(null); 20 | }); 21 | 22 | it('should test page_size schema transformation - projects tool', () => { 23 | const pageSizeTransform = (val) => (val ? parseInt(val, 10) || null : null); 24 | 25 | expect(pageSizeTransform('20')).toBe(20); 26 | expect(pageSizeTransform('invalid')).toBe(null); 27 | expect(pageSizeTransform(undefined)).toBe(null); 28 | expect(pageSizeTransform('')).toBe(null); 29 | }); 30 | 31 | it('should test page schema transformation - metrics tool', () => { 32 | const pageTransform = (val) => (val ? parseInt(val, 10) || null : null); 33 | 34 | expect(pageTransform('10')).toBe(10); 35 | expect(pageTransform('invalid')).toBe(null); 36 | expect(pageTransform(undefined)).toBe(null); 37 | expect(pageTransform('')).toBe(null); 38 | }); 39 | 40 | it('should test page_size schema transformation - metrics tool', () => { 41 | const pageSizeTransform = (val) => (val ? parseInt(val, 10) || null : null); 42 | 43 | expect(pageSizeTransform('20')).toBe(20); 44 | expect(pageSizeTransform('invalid')).toBe(null); 45 | expect(pageSizeTransform(undefined)).toBe(null); 46 | expect(pageSizeTransform('')).toBe(null); 47 | }); 48 | 49 | it('should test page schema transformation - issues tool', () => { 50 | const pageTransform = (val) => (val ? parseInt(val, 10) || null : null); 51 | 52 | expect(pageTransform('10')).toBe(10); 53 | expect(pageTransform('invalid')).toBe(null); 54 | expect(pageTransform(undefined)).toBe(null); 55 | expect(pageTransform('')).toBe(null); 56 | }); 57 | 58 | it('should test page_size schema transformation - issues tool', () => { 59 | const pageSizeTransform = (val) => (val ? parseInt(val, 10) || null : null); 60 | 61 | expect(pageSizeTransform('20')).toBe(20); 62 | expect(pageSizeTransform('invalid')).toBe(null); 63 | expect(pageSizeTransform(undefined)).toBe(null); 64 | expect(pageSizeTransform('')).toBe(null); 65 | }); 66 | 67 | it('should test page schema transformation - measures_components tool', () => { 68 | const pageTransform = (val) => (val ? parseInt(val, 10) || null : null); 69 | 70 | expect(pageTransform('10')).toBe(10); 71 | expect(pageTransform('invalid')).toBe(null); 72 | expect(pageTransform(undefined)).toBe(null); 73 | expect(pageTransform('')).toBe(null); 74 | }); 75 | 76 | it('should test page_size schema transformation - measures_components tool', () => { 77 | const pageSizeTransform = (val) => (val ? parseInt(val, 10) || null : null); 78 | 79 | expect(pageSizeTransform('20')).toBe(20); 80 | expect(pageSizeTransform('invalid')).toBe(null); 81 | expect(pageSizeTransform(undefined)).toBe(null); 82 | expect(pageSizeTransform('')).toBe(null); 83 | }); 84 | 85 | it('should test page schema transformation - measures_history tool', () => { 86 | const pageTransform = (val) => (val ? parseInt(val, 10) || null : null); 87 | 88 | expect(pageTransform('10')).toBe(10); 89 | expect(pageTransform('invalid')).toBe(null); 90 | expect(pageTransform(undefined)).toBe(null); 91 | expect(pageTransform('')).toBe(null); 92 | }); 93 | 94 | it('should test page_size schema transformation - measures_history tool', () => { 95 | const pageSizeTransform = (val) => (val ? parseInt(val, 10) || null : null); 96 | 97 | expect(pageSizeTransform('20')).toBe(20); 98 | expect(pageSizeTransform('invalid')).toBe(null); 99 | expect(pageSizeTransform(undefined)).toBe(null); 100 | expect(pageSizeTransform('')).toBe(null); 101 | }); 102 | }); 103 | 104 | describe('Boolean Parameter Transformations in Issues Tool Registration', () => { 105 | it('should test resolved parameter transformation', () => { 106 | const boolTransform = (val) => val === 'true'; 107 | 108 | expect(boolTransform('true')).toBe(true); 109 | expect(boolTransform('false')).toBe(false); 110 | expect(boolTransform('something')).toBe(false); 111 | }); 112 | 113 | it('should test on_component_only parameter transformation', () => { 114 | const boolTransform = (val) => val === 'true'; 115 | 116 | expect(boolTransform('true')).toBe(true); 117 | expect(boolTransform('false')).toBe(false); 118 | expect(boolTransform('something')).toBe(false); 119 | }); 120 | 121 | it('should test since_leak_period parameter transformation', () => { 122 | const boolTransform = (val) => val === 'true'; 123 | 124 | expect(boolTransform('true')).toBe(true); 125 | expect(boolTransform('false')).toBe(false); 126 | expect(boolTransform('something')).toBe(false); 127 | }); 128 | 129 | it('should test in_new_code_period parameter transformation', () => { 130 | const boolTransform = (val) => val === 'true'; 131 | 132 | expect(boolTransform('true')).toBe(true); 133 | expect(boolTransform('false')).toBe(false); 134 | expect(boolTransform('something')).toBe(false); 135 | }); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /src/__tests__/schema-transforms.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect } from '@jest/globals'; 8 | import { z } from 'zod'; 9 | import { nullToUndefined } from '../index.js'; 10 | 11 | describe('Schema Transformations', () => { 12 | describe('nullToUndefined function', () => { 13 | it('should convert null to undefined', () => { 14 | expect(nullToUndefined(null)).toBeUndefined(); 15 | }); 16 | 17 | it('should keep undefined as undefined', () => { 18 | expect(nullToUndefined(undefined)).toBeUndefined(); 19 | }); 20 | 21 | it('should pass through non-null values', () => { 22 | expect(nullToUndefined('test')).toBe('test'); 23 | expect(nullToUndefined(42)).toBe(42); 24 | expect(nullToUndefined(true)).toBe(true); 25 | expect(nullToUndefined(false)).toBe(false); 26 | expect(nullToUndefined(0)).toBe(0); 27 | expect(nullToUndefined('')).toBe(''); 28 | 29 | const obj = { test: 'value' }; 30 | expect(nullToUndefined(obj)).toBe(obj); 31 | 32 | const arr = [1, 2, 3]; 33 | expect(nullToUndefined(arr)).toBe(arr); 34 | }); 35 | }); 36 | 37 | describe('Common Zod Schemas', () => { 38 | it('should transform page parameters correctly', () => { 39 | const pageSchema = z 40 | .string() 41 | .optional() 42 | .transform((val) => (val ? parseInt(val, 10) || null : null)); 43 | 44 | // Valid numbers 45 | expect(pageSchema.parse('1')).toBe(1); 46 | expect(pageSchema.parse('10')).toBe(10); 47 | expect(pageSchema.parse('100')).toBe(100); 48 | 49 | // Invalid or empty values 50 | expect(pageSchema.parse('abc')).toBe(null); 51 | expect(pageSchema.parse('')).toBe(null); 52 | expect(pageSchema.parse(undefined)).toBe(null); 53 | }); 54 | 55 | it('should transform page_size parameters correctly', () => { 56 | const pageSizeSchema = z 57 | .string() 58 | .optional() 59 | .transform((val) => (val ? parseInt(val, 10) || null : null)); 60 | 61 | // Valid numbers 62 | expect(pageSizeSchema.parse('10')).toBe(10); 63 | expect(pageSizeSchema.parse('50')).toBe(50); 64 | expect(pageSizeSchema.parse('100')).toBe(100); 65 | 66 | // Invalid or empty values 67 | expect(pageSizeSchema.parse('abc')).toBe(null); 68 | expect(pageSizeSchema.parse('')).toBe(null); 69 | expect(pageSizeSchema.parse(undefined)).toBe(null); 70 | }); 71 | 72 | it('should validate severity values correctly', () => { 73 | const severitySchema = z 74 | .enum(['INFO', 'MINOR', 'MAJOR', 'CRITICAL', 'BLOCKER']) 75 | .nullable() 76 | .optional(); 77 | 78 | // Valid severities 79 | expect(severitySchema.parse('INFO')).toBe('INFO'); 80 | expect(severitySchema.parse('MINOR')).toBe('MINOR'); 81 | expect(severitySchema.parse('MAJOR')).toBe('MAJOR'); 82 | expect(severitySchema.parse('CRITICAL')).toBe('CRITICAL'); 83 | expect(severitySchema.parse('BLOCKER')).toBe('BLOCKER'); 84 | 85 | // Null/undefined 86 | expect(severitySchema.parse(null)).toBe(null); 87 | expect(severitySchema.parse(undefined)).toBe(undefined); 88 | 89 | // Invalid 90 | expect(() => severitySchema.parse('INVALID')).toThrow(); 91 | }); 92 | 93 | it('should validate status values correctly', () => { 94 | const statusSchema = z 95 | .array( 96 | z.enum([ 97 | 'OPEN', 98 | 'CONFIRMED', 99 | 'REOPENED', 100 | 'RESOLVED', 101 | 'CLOSED', 102 | 'TO_REVIEW', 103 | 'IN_REVIEW', 104 | 'REVIEWED', 105 | ]) 106 | ) 107 | .nullable() 108 | .optional(); 109 | 110 | // Valid statuses 111 | expect(statusSchema.parse(['OPEN'])).toEqual(['OPEN']); 112 | expect(statusSchema.parse(['CONFIRMED', 'REOPENED'])).toEqual(['CONFIRMED', 'REOPENED']); 113 | expect(statusSchema.parse(['RESOLVED', 'CLOSED'])).toEqual(['RESOLVED', 'CLOSED']); 114 | expect(statusSchema.parse(['TO_REVIEW', 'IN_REVIEW', 'REVIEWED'])).toEqual([ 115 | 'TO_REVIEW', 116 | 'IN_REVIEW', 117 | 'REVIEWED', 118 | ]); 119 | 120 | // Null/undefined 121 | expect(statusSchema.parse(null)).toBe(null); 122 | expect(statusSchema.parse(undefined)).toBe(undefined); 123 | 124 | // Invalid 125 | expect(() => statusSchema.parse(['INVALID'])).toThrow(); 126 | expect(() => statusSchema.parse(['open'])).toThrow(); // case sensitivity 127 | }); 128 | 129 | it('should validate resolution values correctly', () => { 130 | const resolutionSchema = z 131 | .array(z.enum(['FALSE-POSITIVE', 'WONTFIX', 'FIXED', 'REMOVED'])) 132 | .nullable() 133 | .optional(); 134 | 135 | // Valid resolutions 136 | expect(resolutionSchema.parse(['FALSE-POSITIVE'])).toEqual(['FALSE-POSITIVE']); 137 | expect(resolutionSchema.parse(['WONTFIX', 'FIXED'])).toEqual(['WONTFIX', 'FIXED']); 138 | expect(resolutionSchema.parse(['REMOVED'])).toEqual(['REMOVED']); 139 | expect(resolutionSchema.parse(['FALSE-POSITIVE', 'WONTFIX', 'FIXED', 'REMOVED'])).toEqual([ 140 | 'FALSE-POSITIVE', 141 | 'WONTFIX', 142 | 'FIXED', 143 | 'REMOVED', 144 | ]); 145 | 146 | // Null/undefined 147 | expect(resolutionSchema.parse(null)).toBe(null); 148 | expect(resolutionSchema.parse(undefined)).toBe(undefined); 149 | 150 | // Invalid 151 | expect(() => resolutionSchema.parse(['INVALID'])).toThrow(); 152 | }); 153 | 154 | it('should validate type values correctly', () => { 155 | const typeSchema = z 156 | .array(z.enum(['CODE_SMELL', 'BUG', 'VULNERABILITY', 'SECURITY_HOTSPOT'])) 157 | .nullable() 158 | .optional(); 159 | 160 | // Valid types 161 | expect(typeSchema.parse(['CODE_SMELL'])).toEqual(['CODE_SMELL']); 162 | expect(typeSchema.parse(['BUG', 'VULNERABILITY'])).toEqual(['BUG', 'VULNERABILITY']); 163 | expect(typeSchema.parse(['SECURITY_HOTSPOT'])).toEqual(['SECURITY_HOTSPOT']); 164 | expect(typeSchema.parse(['CODE_SMELL', 'BUG', 'VULNERABILITY', 'SECURITY_HOTSPOT'])).toEqual([ 165 | 'CODE_SMELL', 166 | 'BUG', 167 | 'VULNERABILITY', 168 | 'SECURITY_HOTSPOT', 169 | ]); 170 | 171 | // Null/undefined 172 | expect(typeSchema.parse(null)).toBe(null); 173 | expect(typeSchema.parse(undefined)).toBe(undefined); 174 | 175 | // Invalid 176 | expect(() => typeSchema.parse(['INVALID'])).toThrow(); 177 | }); 178 | 179 | it('should transform boolean values correctly', () => { 180 | const booleanSchema = z 181 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 182 | .nullable() 183 | .optional(); 184 | 185 | // String values 186 | expect(booleanSchema.parse('true')).toBe(true); 187 | expect(booleanSchema.parse('false')).toBe(false); 188 | 189 | // Boolean values 190 | expect(booleanSchema.parse(true)).toBe(true); 191 | expect(booleanSchema.parse(false)).toBe(false); 192 | 193 | // Null/undefined 194 | expect(booleanSchema.parse(null)).toBe(null); 195 | expect(booleanSchema.parse(undefined)).toBe(undefined); 196 | }); 197 | 198 | it('should validate string arrays correctly', () => { 199 | const stringArraySchema = z.array(z.string()).nullable().optional(); 200 | 201 | // Valid arrays 202 | expect(stringArraySchema.parse(['test'])).toEqual(['test']); 203 | expect(stringArraySchema.parse(['one', 'two', 'three'])).toEqual(['one', 'two', 'three']); 204 | expect(stringArraySchema.parse([])).toEqual([]); 205 | 206 | // Null/undefined 207 | expect(stringArraySchema.parse(null)).toBe(null); 208 | expect(stringArraySchema.parse(undefined)).toBe(undefined); 209 | 210 | // Invalid 211 | expect(() => stringArraySchema.parse('not-an-array')).toThrow(); 212 | expect(() => stringArraySchema.parse([1, 2, 3])).toThrow(); 213 | }); 214 | 215 | it('should validate and transform string or array unions', () => { 216 | const unionSchema = z.union([z.string(), z.array(z.string())]); 217 | 218 | // Single string 219 | expect(unionSchema.parse('test')).toBe('test'); 220 | 221 | // String array 222 | expect(unionSchema.parse(['one', 'two'])).toEqual(['one', 'two']); 223 | 224 | // Invalid 225 | expect(() => unionSchema.parse(123)).toThrow(); 226 | expect(() => unionSchema.parse([1, 2, 3])).toThrow(); 227 | }); 228 | }); 229 | }); 230 | -------------------------------------------------------------------------------- /src/__tests__/schema-validators.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect } from '@jest/globals'; 8 | import { z } from 'zod'; 9 | 10 | describe('Schema Validators and Transformers', () => { 11 | it('should transform page string to number or null', () => { 12 | const pageSchema = z 13 | .string() 14 | .optional() 15 | .transform((val) => (val ? parseInt(val, 10) || null : null)); 16 | 17 | expect(pageSchema.parse('10')).toBe(10); 18 | expect(pageSchema.parse('invalid')).toBe(null); 19 | expect(pageSchema.parse('')).toBe(null); 20 | expect(pageSchema.parse(undefined)).toBe(null); 21 | }); 22 | 23 | it('should transform string to boolean', () => { 24 | const booleanSchema = z 25 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 26 | .nullable() 27 | .optional(); 28 | 29 | expect(booleanSchema.parse('true')).toBe(true); 30 | expect(booleanSchema.parse('false')).toBe(false); 31 | expect(booleanSchema.parse(true)).toBe(true); 32 | expect(booleanSchema.parse(false)).toBe(false); 33 | expect(booleanSchema.parse(null)).toBe(null); 34 | expect(booleanSchema.parse(undefined)).toBe(undefined); 35 | }); 36 | 37 | it('should validate severity enum', () => { 38 | const severitySchema = z 39 | .enum(['INFO', 'MINOR', 'MAJOR', 'CRITICAL', 'BLOCKER']) 40 | .nullable() 41 | .optional(); 42 | 43 | expect(severitySchema.parse('INFO')).toBe('INFO'); 44 | expect(severitySchema.parse('MINOR')).toBe('MINOR'); 45 | expect(severitySchema.parse('MAJOR')).toBe('MAJOR'); 46 | expect(severitySchema.parse('CRITICAL')).toBe('CRITICAL'); 47 | expect(severitySchema.parse('BLOCKER')).toBe('BLOCKER'); 48 | expect(severitySchema.parse(null)).toBe(null); 49 | expect(severitySchema.parse(undefined)).toBe(undefined); 50 | expect(() => severitySchema.parse('INVALID')).toThrow(); 51 | }); 52 | 53 | it('should validate status array enum', () => { 54 | const statusSchema = z 55 | .array( 56 | z.enum([ 57 | 'OPEN', 58 | 'CONFIRMED', 59 | 'REOPENED', 60 | 'RESOLVED', 61 | 'CLOSED', 62 | 'TO_REVIEW', 63 | 'IN_REVIEW', 64 | 'REVIEWED', 65 | ]) 66 | ) 67 | .nullable() 68 | .optional(); 69 | 70 | expect(statusSchema.parse(['OPEN', 'CONFIRMED'])).toEqual(['OPEN', 'CONFIRMED']); 71 | expect(statusSchema.parse(null)).toBe(null); 72 | expect(statusSchema.parse(undefined)).toBe(undefined); 73 | expect(() => statusSchema.parse(['INVALID'])).toThrow(); 74 | }); 75 | 76 | it('should validate resolution array enum', () => { 77 | const resolutionSchema = z 78 | .array(z.enum(['FALSE-POSITIVE', 'WONTFIX', 'FIXED', 'REMOVED'])) 79 | .nullable() 80 | .optional(); 81 | 82 | expect(resolutionSchema.parse(['FALSE-POSITIVE', 'WONTFIX'])).toEqual([ 83 | 'FALSE-POSITIVE', 84 | 'WONTFIX', 85 | ]); 86 | expect(resolutionSchema.parse(null)).toBe(null); 87 | expect(resolutionSchema.parse(undefined)).toBe(undefined); 88 | expect(() => resolutionSchema.parse(['INVALID'])).toThrow(); 89 | }); 90 | 91 | it('should validate type array enum', () => { 92 | const typeSchema = z 93 | .array(z.enum(['CODE_SMELL', 'BUG', 'VULNERABILITY', 'SECURITY_HOTSPOT'])) 94 | .nullable() 95 | .optional(); 96 | 97 | expect(typeSchema.parse(['CODE_SMELL', 'BUG'])).toEqual(['CODE_SMELL', 'BUG']); 98 | expect(typeSchema.parse(null)).toBe(null); 99 | expect(typeSchema.parse(undefined)).toBe(undefined); 100 | expect(() => typeSchema.parse(['INVALID'])).toThrow(); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /src/__tests__/string-to-number-transform.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect } from '@jest/globals'; 8 | import { nullToUndefined, stringToNumberTransform } from '../index.js'; 9 | 10 | describe('String to Number Transform', () => { 11 | describe('nullToUndefined', () => { 12 | it('should transform null to undefined', () => { 13 | expect(nullToUndefined(null)).toBeUndefined(); 14 | }); 15 | 16 | it('should not transform undefined', () => { 17 | expect(nullToUndefined(undefined)).toBeUndefined(); 18 | }); 19 | 20 | it('should not transform non-null values', () => { 21 | expect(nullToUndefined('test')).toBe('test'); 22 | expect(nullToUndefined(123)).toBe(123); 23 | expect(nullToUndefined(true)).toBe(true); 24 | expect(nullToUndefined(false)).toBe(false); 25 | expect(nullToUndefined(0)).toBe(0); 26 | expect(nullToUndefined('')).toBe(''); 27 | }); 28 | }); 29 | 30 | describe('stringToNumberTransform', () => { 31 | it('should transform valid string numbers to integers', () => { 32 | expect(stringToNumberTransform('123')).toBe(123); 33 | expect(stringToNumberTransform('0')).toBe(0); 34 | expect(stringToNumberTransform('-10')).toBe(-10); 35 | }); 36 | 37 | it('should return null for invalid number strings', () => { 38 | expect(stringToNumberTransform('abc')).toBeNull(); 39 | expect(stringToNumberTransform('')).toBeNull(); 40 | expect(stringToNumberTransform('123abc')).toBe(123); // parseInt behavior 41 | }); 42 | 43 | it('should pass through null and undefined values', () => { 44 | expect(stringToNumberTransform(null)).toBeNull(); 45 | expect(stringToNumberTransform(undefined)).toBeUndefined(); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/__tests__/tool-handler-lambdas.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect } from '@jest/globals'; 8 | import { z } from 'zod'; 9 | 10 | // Since we can't easily mock the MCP server after it's already been created in index.js, 11 | // we'll directly test the transformation functions used in the schemas 12 | 13 | describe('Tool Schema Transformations', () => { 14 | describe('Page Parameter Transformation', () => { 15 | it('should properly transform page string values to numbers or null', () => { 16 | // Create a transform function matching what's in the code 17 | const transformPageValue = (val: string | null | undefined) => { 18 | return val ? parseInt(val, 10) || null : null; 19 | }; 20 | 21 | // Test valid numeric strings 22 | expect(transformPageValue('1')).toBe(1); 23 | expect(transformPageValue('100')).toBe(100); 24 | 25 | // Test strings that parseInt can't fully convert 26 | expect(transformPageValue('123abc')).toBe(123); // Still parses first part 27 | expect(transformPageValue('abc123')).toBe(null); // Can't parse, returns null 28 | 29 | // Test non-numeric strings 30 | expect(transformPageValue('invalid')).toBe(null); 31 | expect(transformPageValue('null')).toBe(null); 32 | expect(transformPageValue('undefined')).toBe(null); 33 | 34 | // Test edge cases 35 | expect(transformPageValue('')).toBe(null); 36 | expect(transformPageValue(null)).toBe(null); 37 | expect(transformPageValue(undefined)).toBe(null); 38 | }); 39 | }); 40 | 41 | describe('Boolean Parameter Transformation', () => { 42 | it('should properly transform string values to booleans', () => { 43 | // Create a schema similar to what's in the code 44 | const booleanTransform = z 45 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 46 | .nullable() 47 | .optional(); 48 | 49 | // Test string values 50 | expect(booleanTransform.parse('true')).toBe(true); 51 | expect(booleanTransform.parse('false')).toBe(false); 52 | expect(booleanTransform.parse('True')).toBe(false); // Case sensitive 53 | expect(booleanTransform.parse('1')).toBe(false); 54 | expect(booleanTransform.parse('0')).toBe(false); 55 | expect(booleanTransform.parse('yes')).toBe(false); 56 | expect(booleanTransform.parse('no')).toBe(false); 57 | 58 | // Test boolean values (pass through) 59 | expect(booleanTransform.parse(true)).toBe(true); 60 | expect(booleanTransform.parse(false)).toBe(false); 61 | 62 | // Test null/undefined handling 63 | expect(booleanTransform.parse(null)).toBe(null); 64 | expect(booleanTransform.parse(undefined)).toBe(undefined); 65 | }); 66 | }); 67 | 68 | describe('String Array Parameter Validation', () => { 69 | it('should properly validate string arrays', () => { 70 | // Create a schema similar to what's in the code 71 | const stringArraySchema = z.array(z.string()).nullable().optional(); 72 | 73 | // Test valid arrays 74 | expect(stringArraySchema.parse(['test1', 'test2'])).toEqual(['test1', 'test2']); 75 | expect(stringArraySchema.parse([''])).toEqual(['']); 76 | expect(stringArraySchema.parse([])).toEqual([]); 77 | 78 | // Test null/undefined handling 79 | expect(stringArraySchema.parse(null)).toBe(null); 80 | expect(stringArraySchema.parse(undefined)).toBe(undefined); 81 | }); 82 | }); 83 | 84 | describe('Enum Parameter Validation', () => { 85 | it('should properly validate status enum values', () => { 86 | // Create a schema similar to what's in the code 87 | const statusSchema = z 88 | .array( 89 | z.enum([ 90 | 'OPEN', 91 | 'CONFIRMED', 92 | 'REOPENED', 93 | 'RESOLVED', 94 | 'CLOSED', 95 | 'TO_REVIEW', 96 | 'IN_REVIEW', 97 | 'REVIEWED', 98 | ]) 99 | ) 100 | .nullable() 101 | .optional(); 102 | 103 | // Test valid status arrays 104 | expect(statusSchema.parse(['OPEN', 'CONFIRMED'])).toEqual(['OPEN', 'CONFIRMED']); 105 | expect(statusSchema.parse(['RESOLVED'])).toEqual(['RESOLVED']); 106 | 107 | // Test null/undefined handling 108 | expect(statusSchema.parse(null)).toBe(null); 109 | expect(statusSchema.parse(undefined)).toBe(undefined); 110 | 111 | // Test invalid values 112 | expect(() => statusSchema.parse(['INVALID'])).toThrow(); 113 | expect(() => statusSchema.parse(['open'])).toThrow(); // Case sensitive 114 | }); 115 | 116 | it('should properly validate resolution enum values', () => { 117 | // Create a schema similar to what's in the code 118 | const resolutionSchema = z 119 | .array(z.enum(['FALSE-POSITIVE', 'WONTFIX', 'FIXED', 'REMOVED'])) 120 | .nullable() 121 | .optional(); 122 | 123 | // Test valid resolution arrays 124 | expect(resolutionSchema.parse(['FALSE-POSITIVE', 'WONTFIX'])).toEqual([ 125 | 'FALSE-POSITIVE', 126 | 'WONTFIX', 127 | ]); 128 | expect(resolutionSchema.parse(['FIXED', 'REMOVED'])).toEqual(['FIXED', 'REMOVED']); 129 | 130 | // Test null/undefined handling 131 | expect(resolutionSchema.parse(null)).toBe(null); 132 | expect(resolutionSchema.parse(undefined)).toBe(undefined); 133 | 134 | // Test invalid values 135 | expect(() => resolutionSchema.parse(['INVALID'])).toThrow(); 136 | }); 137 | 138 | it('should properly validate type enum values', () => { 139 | // Create a schema similar to what's in the code 140 | const typeSchema = z 141 | .array(z.enum(['CODE_SMELL', 'BUG', 'VULNERABILITY', 'SECURITY_HOTSPOT'])) 142 | .nullable() 143 | .optional(); 144 | 145 | // Test valid type arrays 146 | expect(typeSchema.parse(['CODE_SMELL', 'BUG'])).toEqual(['CODE_SMELL', 'BUG']); 147 | expect(typeSchema.parse(['VULNERABILITY', 'SECURITY_HOTSPOT'])).toEqual([ 148 | 'VULNERABILITY', 149 | 'SECURITY_HOTSPOT', 150 | ]); 151 | 152 | // Test null/undefined handling 153 | expect(typeSchema.parse(null)).toBe(null); 154 | expect(typeSchema.parse(undefined)).toBe(undefined); 155 | 156 | // Test invalid values 157 | expect(() => typeSchema.parse(['INVALID'])).toThrow(); 158 | }); 159 | 160 | it('should properly validate severity enum value', () => { 161 | // Create a schema similar to what's in the code 162 | const severitySchema = z 163 | .enum(['INFO', 'MINOR', 'MAJOR', 'CRITICAL', 'BLOCKER']) 164 | .nullable() 165 | .optional(); 166 | 167 | // Test valid severities 168 | expect(severitySchema.parse('INFO')).toBe('INFO'); 169 | expect(severitySchema.parse('MINOR')).toBe('MINOR'); 170 | expect(severitySchema.parse('MAJOR')).toBe('MAJOR'); 171 | expect(severitySchema.parse('CRITICAL')).toBe('CRITICAL'); 172 | expect(severitySchema.parse('BLOCKER')).toBe('BLOCKER'); 173 | 174 | // Test null/undefined handling 175 | expect(severitySchema.parse(null)).toBe(null); 176 | expect(severitySchema.parse(undefined)).toBe(undefined); 177 | 178 | // Test invalid values 179 | expect(() => severitySchema.parse('INVALID')).toThrow(); 180 | expect(() => severitySchema.parse('info')).toThrow(); // Case sensitive 181 | }); 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /src/__tests__/tool-registration-schema.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect } from '@jest/globals'; 8 | import { z } from 'zod'; 9 | 10 | describe('Tool Registration Schemas', () => { 11 | describe('Page Transformations', () => { 12 | // Test page and page_size transformations 13 | it('should transform string to number in page parameters', () => { 14 | // Define a schema similar to what's used in the MCP tool registrations 15 | const pageSchema = z 16 | .string() 17 | .optional() 18 | .transform((val) => (val ? parseInt(val, 10) || null : null)); 19 | 20 | // Valid number 21 | expect(pageSchema.parse('10')).toBe(10); 22 | 23 | // Invalid number should return null 24 | expect(pageSchema.parse('not-a-number')).toBe(null); 25 | 26 | // Empty string should return null 27 | expect(pageSchema.parse('')).toBe(null); 28 | 29 | // Undefined should return null 30 | expect(pageSchema.parse(undefined)).toBe(null); 31 | }); 32 | }); 33 | 34 | describe('Boolean Transformations', () => { 35 | // Test boolean transformations 36 | it('should transform string to boolean in boolean parameters', () => { 37 | // Define a schema similar to what's used in the MCP tool registrations 38 | const booleanSchema = z 39 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 40 | .nullable() 41 | .optional(); 42 | 43 | // String 'true' should become boolean true 44 | expect(booleanSchema.parse('true')).toBe(true); 45 | 46 | // String 'false' should become boolean false 47 | expect(booleanSchema.parse('false')).toBe(false); 48 | 49 | // Boolean true should remain true 50 | expect(booleanSchema.parse(true)).toBe(true); 51 | 52 | // Boolean false should remain false 53 | expect(booleanSchema.parse(false)).toBe(false); 54 | 55 | // Null should remain null 56 | expect(booleanSchema.parse(null)).toBe(null); 57 | 58 | // Undefined should remain undefined 59 | expect(booleanSchema.parse(undefined)).toBe(undefined); 60 | }); 61 | }); 62 | 63 | describe('Enumeration Validations', () => { 64 | // Test severity enum validations 65 | it('should validate severity enum values', () => { 66 | // Define a schema similar to what's used in the MCP tool registrations 67 | const severitySchema = z 68 | .enum(['INFO', 'MINOR', 'MAJOR', 'CRITICAL', 'BLOCKER']) 69 | .nullable() 70 | .optional(); 71 | 72 | // Valid values should pass through 73 | expect(severitySchema.parse('INFO')).toBe('INFO'); 74 | expect(severitySchema.parse('MINOR')).toBe('MINOR'); 75 | expect(severitySchema.parse('MAJOR')).toBe('MAJOR'); 76 | expect(severitySchema.parse('CRITICAL')).toBe('CRITICAL'); 77 | expect(severitySchema.parse('BLOCKER')).toBe('BLOCKER'); 78 | 79 | // Null should remain null 80 | expect(severitySchema.parse(null)).toBe(null); 81 | 82 | // Undefined should remain undefined 83 | expect(severitySchema.parse(undefined)).toBe(undefined); 84 | 85 | // Invalid values should throw 86 | expect(() => severitySchema.parse('INVALID')).toThrow(); 87 | }); 88 | 89 | // Test status enum array validations 90 | it('should validate status enum arrays', () => { 91 | // Define a schema similar to what's used in the MCP tool registrations 92 | const statusSchema = z 93 | .array( 94 | z.enum([ 95 | 'OPEN', 96 | 'CONFIRMED', 97 | 'REOPENED', 98 | 'RESOLVED', 99 | 'CLOSED', 100 | 'TO_REVIEW', 101 | 'IN_REVIEW', 102 | 'REVIEWED', 103 | ]) 104 | ) 105 | .nullable() 106 | .optional(); 107 | 108 | // Valid array should pass through 109 | expect(statusSchema.parse(['OPEN', 'CONFIRMED'])).toEqual(['OPEN', 'CONFIRMED']); 110 | 111 | // Null should remain null 112 | expect(statusSchema.parse(null)).toBe(null); 113 | 114 | // Undefined should remain undefined 115 | expect(statusSchema.parse(undefined)).toBe(undefined); 116 | 117 | // Invalid values should throw 118 | expect(() => statusSchema.parse(['INVALID'])).toThrow(); 119 | }); 120 | 121 | // Test complete projects tool schema 122 | it('should correctly parse and transform projects tool parameters', () => { 123 | // Define a schema similar to the projects tool schema 124 | const projectsSchema = z.object({ 125 | page: z 126 | .string() 127 | .optional() 128 | .transform((val) => (val ? parseInt(val, 10) || null : null)), 129 | page_size: z 130 | .string() 131 | .optional() 132 | .transform((val) => (val ? parseInt(val, 10) || null : null)), 133 | }); 134 | 135 | // Test with valid parameters 136 | const result = projectsSchema.parse({ 137 | page: '2', 138 | page_size: '20', 139 | }); 140 | 141 | expect(result.page).toBe(2); 142 | expect(result.page_size).toBe(20); 143 | }); 144 | 145 | // Test complete issues tool schema 146 | it('should correctly parse and transform issues tool parameters', () => { 147 | // Define schemas similar to the issues tool schema 148 | const severitySchema = z 149 | .enum(['INFO', 'MINOR', 'MAJOR', 'CRITICAL', 'BLOCKER']) 150 | .nullable() 151 | .optional(); 152 | 153 | const pageSchema = z 154 | .string() 155 | .optional() 156 | .transform((val) => (val ? parseInt(val, 10) || null : null)); 157 | 158 | const booleanSchema = z 159 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 160 | .nullable() 161 | .optional(); 162 | 163 | const stringArraySchema = z.array(z.string()).nullable().optional(); 164 | 165 | // Create the schema 166 | const issuesSchema = z.object({ 167 | project_key: z.string(), 168 | severity: severitySchema, 169 | page: pageSchema, 170 | page_size: pageSchema, 171 | resolved: booleanSchema, 172 | rules: stringArraySchema, 173 | }); 174 | 175 | // Test with valid parameters 176 | const result = issuesSchema.parse({ 177 | project_key: 'my-project', 178 | severity: 'MAJOR', 179 | page: '5', 180 | page_size: '25', 181 | resolved: 'true', 182 | rules: ['rule1', 'rule2'], 183 | }); 184 | 185 | expect(result.project_key).toBe('my-project'); 186 | expect(result.severity).toBe('MAJOR'); 187 | expect(result.page).toBe(5); 188 | expect(result.page_size).toBe(25); 189 | expect(result.resolved).toBe(true); 190 | expect(result.rules).toEqual(['rule1', 'rule2']); 191 | }); 192 | 193 | // Test union schema for component_keys and metric_keys 194 | it('should handle union schema for string or array inputs', () => { 195 | // Define a schema similar to the component_keys and metric_keys parameters 196 | const unionSchema = z.union([z.string(), z.array(z.string())]); 197 | 198 | // Test with string 199 | expect(unionSchema.parse('single-value')).toBe('single-value'); 200 | 201 | // Test with array 202 | expect(unionSchema.parse(['value1', 'value2'])).toEqual(['value1', 'value2']); 203 | }); 204 | }); 205 | }); 206 | -------------------------------------------------------------------------------- /src/__tests__/tool-registration-transforms.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect } from '@jest/globals'; 8 | import { z } from 'zod'; 9 | 10 | describe('Tool Registration Schema Transforms', () => { 11 | describe('Pagination parameters', () => { 12 | it('should transform page string to number or null', () => { 13 | const pageSchema = z 14 | .string() 15 | .optional() 16 | .transform((val) => (val ? parseInt(val, 10) || null : null)); 17 | 18 | expect(pageSchema.parse('10')).toBe(10); 19 | expect(pageSchema.parse('invalid')).toBe(null); 20 | expect(pageSchema.parse('')).toBe(null); 21 | expect(pageSchema.parse(undefined)).toBe(null); 22 | }); 23 | }); 24 | 25 | describe('Boolean parameters', () => { 26 | it('should transform string to boolean', () => { 27 | const booleanSchema = z 28 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 29 | .nullable() 30 | .optional(); 31 | 32 | expect(booleanSchema.parse('true')).toBe(true); 33 | expect(booleanSchema.parse('false')).toBe(false); 34 | expect(booleanSchema.parse(true)).toBe(true); 35 | expect(booleanSchema.parse(false)).toBe(false); 36 | expect(booleanSchema.parse(null)).toBe(null); 37 | expect(booleanSchema.parse(undefined)).toBe(undefined); 38 | }); 39 | }); 40 | 41 | describe('Array union with string', () => { 42 | it('should handle both string and array inputs', () => { 43 | const schema = z.union([z.string(), z.array(z.string())]); 44 | 45 | // Test with string input 46 | expect(schema.parse('test')).toBe('test'); 47 | 48 | // Test with array input 49 | expect(schema.parse(['test1', 'test2'])).toEqual(['test1', 'test2']); 50 | }); 51 | }); 52 | 53 | describe('Union schemas for tool parameters', () => { 54 | it('should validate both array and string metrics parameters', () => { 55 | // Similar to how the metrics_keys parameter is defined 56 | const metricsSchema = z.union([z.string(), z.array(z.string())]); 57 | 58 | expect(metricsSchema.parse('coverage')).toBe('coverage'); 59 | expect(metricsSchema.parse(['coverage', 'bugs'])).toEqual(['coverage', 'bugs']); 60 | }); 61 | 62 | it('should validate both array and string component keys parameters', () => { 63 | // Similar to how the component_keys parameter is defined 64 | const componentKeysSchema = z.union([z.string(), z.array(z.string())]); 65 | 66 | expect(componentKeysSchema.parse('component1')).toBe('component1'); 67 | expect(componentKeysSchema.parse(['component1', 'component2'])).toEqual([ 68 | 'component1', 69 | 'component2', 70 | ]); 71 | }); 72 | }); 73 | 74 | describe('Enumeration schemas', () => { 75 | it('should validate severity enum value', () => { 76 | const severitySchema = z 77 | .enum(['INFO', 'MINOR', 'MAJOR', 'CRITICAL', 'BLOCKER']) 78 | .nullable() 79 | .optional(); 80 | 81 | expect(severitySchema.parse('INFO')).toBe('INFO'); 82 | expect(severitySchema.parse('MINOR')).toBe('MINOR'); 83 | expect(severitySchema.parse('MAJOR')).toBe('MAJOR'); 84 | expect(severitySchema.parse('CRITICAL')).toBe('CRITICAL'); 85 | expect(severitySchema.parse('BLOCKER')).toBe('BLOCKER'); 86 | expect(severitySchema.parse(null)).toBe(null); 87 | expect(severitySchema.parse(undefined)).toBe(undefined); 88 | expect(() => severitySchema.parse('INVALID')).toThrow(); 89 | }); 90 | 91 | it('should validate status array enum values', () => { 92 | const statusSchema = z 93 | .array( 94 | z.enum([ 95 | 'OPEN', 96 | 'CONFIRMED', 97 | 'REOPENED', 98 | 'RESOLVED', 99 | 'CLOSED', 100 | 'TO_REVIEW', 101 | 'IN_REVIEW', 102 | 'REVIEWED', 103 | ]) 104 | ) 105 | .nullable() 106 | .optional(); 107 | 108 | expect(statusSchema.parse(['OPEN', 'CONFIRMED'])).toEqual(['OPEN', 'CONFIRMED']); 109 | expect(statusSchema.parse(null)).toBe(null); 110 | expect(statusSchema.parse(undefined)).toBe(undefined); 111 | expect(() => statusSchema.parse(['INVALID'])).toThrow(); 112 | }); 113 | 114 | it('should validate resolution array enum values', () => { 115 | const resolutionSchema = z 116 | .array(z.enum(['FALSE-POSITIVE', 'WONTFIX', 'FIXED', 'REMOVED'])) 117 | .nullable() 118 | .optional(); 119 | 120 | expect(resolutionSchema.parse(['FALSE-POSITIVE', 'WONTFIX'])).toEqual([ 121 | 'FALSE-POSITIVE', 122 | 'WONTFIX', 123 | ]); 124 | expect(resolutionSchema.parse(null)).toBe(null); 125 | expect(resolutionSchema.parse(undefined)).toBe(undefined); 126 | expect(() => resolutionSchema.parse(['INVALID'])).toThrow(); 127 | }); 128 | 129 | it('should validate type array enum values', () => { 130 | const typeSchema = z 131 | .array(z.enum(['CODE_SMELL', 'BUG', 'VULNERABILITY', 'SECURITY_HOTSPOT'])) 132 | .nullable() 133 | .optional(); 134 | 135 | expect(typeSchema.parse(['CODE_SMELL', 'BUG'])).toEqual(['CODE_SMELL', 'BUG']); 136 | expect(typeSchema.parse(null)).toBe(null); 137 | expect(typeSchema.parse(undefined)).toBe(undefined); 138 | expect(() => typeSchema.parse(['INVALID'])).toThrow(); 139 | }); 140 | }); 141 | 142 | describe('Complete registration schema', () => { 143 | it('should validate and transform a complete issues tool schema', () => { 144 | // Create schemas similar to what's in the tool registration 145 | const pageSchema = z 146 | .string() 147 | .optional() 148 | .transform((val) => (val ? parseInt(val, 10) || null : null)); 149 | 150 | const booleanSchema = z 151 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 152 | .nullable() 153 | .optional(); 154 | 155 | const severitySchema = z 156 | .enum(['INFO', 'MINOR', 'MAJOR', 'CRITICAL', 'BLOCKER']) 157 | .nullable() 158 | .optional(); 159 | 160 | const statusSchema = z 161 | .array( 162 | z.enum([ 163 | 'OPEN', 164 | 'CONFIRMED', 165 | 'REOPENED', 166 | 'RESOLVED', 167 | 'CLOSED', 168 | 'TO_REVIEW', 169 | 'IN_REVIEW', 170 | 'REVIEWED', 171 | ]) 172 | ) 173 | .nullable() 174 | .optional(); 175 | 176 | const resolutionSchema = z 177 | .array(z.enum(['FALSE-POSITIVE', 'WONTFIX', 'FIXED', 'REMOVED'])) 178 | .nullable() 179 | .optional(); 180 | 181 | const typeSchema = z 182 | .array(z.enum(['CODE_SMELL', 'BUG', 'VULNERABILITY', 'SECURITY_HOTSPOT'])) 183 | .nullable() 184 | .optional(); 185 | 186 | const stringArraySchema = z.array(z.string()).nullable().optional(); 187 | 188 | // Create the complete schema 189 | const schema = z.object({ 190 | project_key: z.string(), 191 | severity: severitySchema, 192 | page: pageSchema, 193 | page_size: pageSchema, 194 | statuses: statusSchema, 195 | resolutions: resolutionSchema, 196 | resolved: booleanSchema, 197 | types: typeSchema, 198 | rules: stringArraySchema, 199 | tags: stringArraySchema, 200 | }); 201 | 202 | // Test with valid data 203 | const validData = { 204 | project_key: 'test-project', 205 | severity: 'MAJOR', 206 | page: '10', 207 | page_size: '20', 208 | statuses: ['OPEN', 'CONFIRMED'], 209 | resolutions: ['FALSE-POSITIVE', 'WONTFIX'], 210 | resolved: 'true', 211 | types: ['CODE_SMELL', 'BUG'], 212 | rules: ['rule1', 'rule2'], 213 | tags: ['tag1', 'tag2'], 214 | }; 215 | 216 | const result = schema.parse(validData); 217 | 218 | // Check that transformations worked correctly 219 | expect(result.project_key).toBe('test-project'); 220 | expect(result.severity).toBe('MAJOR'); 221 | expect(result.page).toBe(10); // Transformed from string to number 222 | expect(result.page_size).toBe(20); // Transformed from string to number 223 | expect(result.statuses).toEqual(['OPEN', 'CONFIRMED']); 224 | expect(result.resolutions).toEqual(['FALSE-POSITIVE', 'WONTFIX']); 225 | expect(result.resolved).toBe(true); // Transformed from string to boolean 226 | expect(result.types).toEqual(['CODE_SMELL', 'BUG']); 227 | expect(result.rules).toEqual(['rule1', 'rule2']); 228 | expect(result.tags).toEqual(['tag1', 'tag2']); 229 | }); 230 | }); 231 | }); 232 | -------------------------------------------------------------------------------- /src/__tests__/transformation-util.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect } from '@jest/globals'; 8 | 9 | describe('Field transformation utilities', () => { 10 | it('should transform array parameters correctly', () => { 11 | // Simulate the transformation logic in the tool registration 12 | function transformToArray(value: unknown): string[] { 13 | return Array.isArray(value) ? value : [value as string]; 14 | } 15 | 16 | // Test with string input 17 | expect(transformToArray('single')).toEqual(['single']); 18 | 19 | // Test with array input 20 | expect(transformToArray(['one', 'two'])).toEqual(['one', 'two']); 21 | 22 | // Test with empty array 23 | expect(transformToArray([])).toEqual([]); 24 | }); 25 | 26 | it('should transform page parameters correctly', () => { 27 | // Simulate the page transform logic 28 | function transformPage(val: string | undefined | null): number | null | undefined { 29 | return val ? parseInt(val, 10) || null : null; 30 | } 31 | 32 | // Valid number 33 | expect(transformPage('10')).toBe(10); 34 | 35 | // Invalid number 36 | expect(transformPage('not-a-number')).toBe(null); 37 | 38 | // Empty string 39 | expect(transformPage('')).toBe(null); 40 | 41 | // Undefined or null 42 | expect(transformPage(undefined)).toBe(null); 43 | expect(transformPage(null)).toBe(null); 44 | }); 45 | 46 | it('should correctly transform page and page_size in tool handlers', () => { 47 | // Simulate the transform in tool handler 48 | function transformPageParams(params: Record): { 49 | page?: number; 50 | pageSize?: number; 51 | } { 52 | function nullToUndefined(value: T | null | undefined): T | undefined { 53 | return value === null ? undefined : value; 54 | } 55 | 56 | return { 57 | page: nullToUndefined(params.page) as number | undefined, 58 | pageSize: nullToUndefined(params.page_size) as number | undefined, 59 | }; 60 | } 61 | 62 | // Test with numbers 63 | expect(transformPageParams({ page: 5, page_size: 20 })).toEqual({ page: 5, pageSize: 20 }); 64 | 65 | // Test with strings 66 | expect(transformPageParams({ page: '5', page_size: '20' })).toEqual({ 67 | page: '5', 68 | pageSize: '20', 69 | }); 70 | 71 | // Test with null 72 | expect(transformPageParams({ page: null, page_size: null })).toEqual({ 73 | page: undefined, 74 | pageSize: undefined, 75 | }); 76 | 77 | // Test with mixed 78 | expect(transformPageParams({ page: 5, page_size: null })).toEqual({ 79 | page: 5, 80 | pageSize: undefined, 81 | }); 82 | 83 | // Test with undefined 84 | expect(transformPageParams({ page: undefined, page_size: undefined })).toEqual({ 85 | page: undefined, 86 | pageSize: undefined, 87 | }); 88 | 89 | // Test with empty object 90 | expect(transformPageParams({})).toEqual({ page: undefined, pageSize: undefined }); 91 | }); 92 | 93 | it('should handle component key transformation correctly', () => { 94 | // Simulate the component key transformation in the getComponentsMeasures handler 95 | function transformComponentKeys(componentKeys: string | string[]): string { 96 | return Array.isArray(componentKeys) ? componentKeys.join(',') : componentKeys; 97 | } 98 | 99 | // Test with string 100 | expect(transformComponentKeys('single-component')).toBe('single-component'); 101 | 102 | // Test with array 103 | expect(transformComponentKeys(['component1', 'component2'])).toBe('component1,component2'); 104 | 105 | // Test with single item array 106 | expect(transformComponentKeys(['component1'])).toBe('component1'); 107 | 108 | // Test with empty array 109 | expect(transformComponentKeys([])).toBe(''); 110 | }); 111 | 112 | it('should handle metric keys transformation correctly', () => { 113 | // Simulate the metric keys transformation in the getComponentMeasures handler 114 | function transformMetricKeys(metricKeys: string | string[]): string { 115 | return Array.isArray(metricKeys) ? metricKeys.join(',') : metricKeys; 116 | } 117 | 118 | // Test with string 119 | expect(transformMetricKeys('single-metric')).toBe('single-metric'); 120 | 121 | // Test with array 122 | expect(transformMetricKeys(['metric1', 'metric2'])).toBe('metric1,metric2'); 123 | 124 | // Test with single item array 125 | expect(transformMetricKeys(['metric1'])).toBe('metric1'); 126 | 127 | // Test with empty array 128 | expect(transformMetricKeys([])).toBe(''); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /src/__tests__/zod-boolean-transform.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect } from '@jest/globals'; 8 | import { z } from 'zod'; 9 | 10 | describe('Zod Boolean Transform Coverage', () => { 11 | // This explicitly tests the transform used in index.ts for boolean parameters 12 | // We're covering lines 705-731 in index.ts 13 | 14 | describe('resolved parameter transform', () => { 15 | // Recreate the exact schema used in index.ts 16 | const resolvedSchema = z 17 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 18 | .nullable() 19 | .optional(); 20 | 21 | it('should handle boolean true value', () => { 22 | expect(resolvedSchema.parse(true)).toBe(true); 23 | }); 24 | 25 | it('should handle boolean false value', () => { 26 | expect(resolvedSchema.parse(false)).toBe(false); 27 | }); 28 | 29 | it('should transform string "true" to boolean true', () => { 30 | expect(resolvedSchema.parse('true')).toBe(true); 31 | }); 32 | 33 | it('should transform string "false" to boolean false', () => { 34 | expect(resolvedSchema.parse('false')).toBe(false); 35 | }); 36 | 37 | it('should pass null and undefined through', () => { 38 | expect(resolvedSchema.parse(null)).toBeNull(); 39 | expect(resolvedSchema.parse(undefined)).toBeUndefined(); 40 | }); 41 | }); 42 | 43 | describe('on_component_only parameter transform', () => { 44 | // Recreate the exact schema used in index.ts 45 | const onComponentOnlySchema = z 46 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 47 | .nullable() 48 | .optional(); 49 | 50 | it('should transform valid values correctly', () => { 51 | expect(onComponentOnlySchema.parse(true)).toBe(true); 52 | expect(onComponentOnlySchema.parse('true')).toBe(true); 53 | expect(onComponentOnlySchema.parse(false)).toBe(false); 54 | expect(onComponentOnlySchema.parse('false')).toBe(false); 55 | }); 56 | }); 57 | 58 | describe('since_leak_period parameter transform', () => { 59 | // Recreate the exact schema used in index.ts 60 | const sinceLeakPeriodSchema = z 61 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 62 | .nullable() 63 | .optional(); 64 | 65 | it('should transform valid values correctly', () => { 66 | expect(sinceLeakPeriodSchema.parse(true)).toBe(true); 67 | expect(sinceLeakPeriodSchema.parse('true')).toBe(true); 68 | expect(sinceLeakPeriodSchema.parse(false)).toBe(false); 69 | expect(sinceLeakPeriodSchema.parse('false')).toBe(false); 70 | }); 71 | }); 72 | 73 | describe('in_new_code_period parameter transform', () => { 74 | // Recreate the exact schema used in index.ts 75 | const inNewCodePeriodSchema = z 76 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 77 | .nullable() 78 | .optional(); 79 | 80 | it('should transform valid values correctly', () => { 81 | expect(inNewCodePeriodSchema.parse(true)).toBe(true); 82 | expect(inNewCodePeriodSchema.parse('true')).toBe(true); 83 | expect(inNewCodePeriodSchema.parse(false)).toBe(false); 84 | expect(inNewCodePeriodSchema.parse('false')).toBe(false); 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/__tests__/zod-transforms.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * @jest-environment node 5 | */ 6 | 7 | import { describe, it, expect } from '@jest/globals'; 8 | import { z } from 'zod'; 9 | 10 | describe('Zod Schema Transformations', () => { 11 | describe('Page and PageSize Transformations', () => { 12 | it('should transform page parameter from string to number', () => { 13 | const pageSchema = z 14 | .string() 15 | .optional() 16 | .transform((val) => (val ? parseInt(val, 10) || null : null)); 17 | 18 | expect(pageSchema.parse('10')).toBe(10); 19 | expect(pageSchema.parse('invalid')).toBe(null); 20 | expect(pageSchema.parse(undefined)).toBe(null); 21 | expect(pageSchema.parse('')).toBe(null); 22 | }); 23 | 24 | it('should transform page_size parameter from string to number', () => { 25 | const pageSizeSchema = z 26 | .string() 27 | .optional() 28 | .transform((val) => (val ? parseInt(val, 10) || null : null)); 29 | 30 | expect(pageSizeSchema.parse('20')).toBe(20); 31 | expect(pageSizeSchema.parse('invalid')).toBe(null); 32 | expect(pageSizeSchema.parse(undefined)).toBe(null); 33 | expect(pageSizeSchema.parse('')).toBe(null); 34 | }); 35 | }); 36 | 37 | describe('Boolean Parameter Transformations', () => { 38 | it('should transform resolved parameter from string to boolean', () => { 39 | const resolvedSchema = z 40 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 41 | .nullable() 42 | .optional(); 43 | 44 | expect(resolvedSchema.parse('true')).toBe(true); 45 | expect(resolvedSchema.parse('false')).toBe(false); 46 | expect(resolvedSchema.parse(true)).toBe(true); 47 | expect(resolvedSchema.parse(false)).toBe(false); 48 | expect(resolvedSchema.parse(null)).toBe(null); 49 | expect(resolvedSchema.parse(undefined)).toBe(undefined); 50 | }); 51 | 52 | it('should transform on_component_only parameter from string to boolean', () => { 53 | const onComponentOnlySchema = z 54 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 55 | .nullable() 56 | .optional(); 57 | 58 | expect(onComponentOnlySchema.parse('true')).toBe(true); 59 | expect(onComponentOnlySchema.parse('false')).toBe(false); 60 | expect(onComponentOnlySchema.parse(true)).toBe(true); 61 | expect(onComponentOnlySchema.parse(false)).toBe(false); 62 | expect(onComponentOnlySchema.parse(null)).toBe(null); 63 | expect(onComponentOnlySchema.parse(undefined)).toBe(undefined); 64 | }); 65 | 66 | it('should transform since_leak_period parameter from string to boolean', () => { 67 | const sinceLeakPeriodSchema = z 68 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 69 | .nullable() 70 | .optional(); 71 | 72 | expect(sinceLeakPeriodSchema.parse('true')).toBe(true); 73 | expect(sinceLeakPeriodSchema.parse('false')).toBe(false); 74 | expect(sinceLeakPeriodSchema.parse(true)).toBe(true); 75 | expect(sinceLeakPeriodSchema.parse(false)).toBe(false); 76 | expect(sinceLeakPeriodSchema.parse(null)).toBe(null); 77 | expect(sinceLeakPeriodSchema.parse(undefined)).toBe(undefined); 78 | }); 79 | 80 | it('should transform in_new_code_period parameter from string to boolean', () => { 81 | const inNewCodePeriodSchema = z 82 | .union([z.boolean(), z.string().transform((val) => val === 'true')]) 83 | .nullable() 84 | .optional(); 85 | 86 | expect(inNewCodePeriodSchema.parse('true')).toBe(true); 87 | expect(inNewCodePeriodSchema.parse('false')).toBe(false); 88 | expect(inNewCodePeriodSchema.parse(true)).toBe(true); 89 | expect(inNewCodePeriodSchema.parse(false)).toBe(false); 90 | expect(inNewCodePeriodSchema.parse(null)).toBe(null); 91 | expect(inNewCodePeriodSchema.parse(undefined)).toBe(undefined); 92 | }); 93 | }); 94 | 95 | describe('Enum Parameter Transformations', () => { 96 | it('should validate severity enum values', () => { 97 | const severitySchema = z 98 | .enum(['INFO', 'MINOR', 'MAJOR', 'CRITICAL', 'BLOCKER']) 99 | .nullable() 100 | .optional(); 101 | 102 | expect(severitySchema.parse('INFO')).toBe('INFO'); 103 | expect(severitySchema.parse('MINOR')).toBe('MINOR'); 104 | expect(severitySchema.parse('MAJOR')).toBe('MAJOR'); 105 | expect(severitySchema.parse('CRITICAL')).toBe('CRITICAL'); 106 | expect(severitySchema.parse('BLOCKER')).toBe('BLOCKER'); 107 | expect(severitySchema.parse(null)).toBe(null); 108 | expect(severitySchema.parse(undefined)).toBe(undefined); 109 | expect(() => severitySchema.parse('INVALID')).toThrow(); 110 | }); 111 | 112 | it('should validate statuses enum values', () => { 113 | const statusSchema = z 114 | .array( 115 | z.enum([ 116 | 'OPEN', 117 | 'CONFIRMED', 118 | 'REOPENED', 119 | 'RESOLVED', 120 | 'CLOSED', 121 | 'TO_REVIEW', 122 | 'IN_REVIEW', 123 | 'REVIEWED', 124 | ]) 125 | ) 126 | .nullable() 127 | .optional(); 128 | 129 | expect(statusSchema.parse(['OPEN', 'CONFIRMED'])).toEqual(['OPEN', 'CONFIRMED']); 130 | expect(statusSchema.parse(['REOPENED', 'RESOLVED'])).toEqual(['REOPENED', 'RESOLVED']); 131 | expect(statusSchema.parse(null)).toBe(null); 132 | expect(statusSchema.parse(undefined)).toBe(undefined); 133 | expect(() => statusSchema.parse(['INVALID'])).toThrow(); 134 | }); 135 | 136 | it('should validate resolutions enum values', () => { 137 | const resolutionSchema = z 138 | .array(z.enum(['FALSE-POSITIVE', 'WONTFIX', 'FIXED', 'REMOVED'])) 139 | .nullable() 140 | .optional(); 141 | 142 | expect(resolutionSchema.parse(['FALSE-POSITIVE', 'WONTFIX'])).toEqual([ 143 | 'FALSE-POSITIVE', 144 | 'WONTFIX', 145 | ]); 146 | expect(resolutionSchema.parse(['FIXED', 'REMOVED'])).toEqual(['FIXED', 'REMOVED']); 147 | expect(resolutionSchema.parse(null)).toBe(null); 148 | expect(resolutionSchema.parse(undefined)).toBe(undefined); 149 | expect(() => resolutionSchema.parse(['INVALID'])).toThrow(); 150 | }); 151 | 152 | it('should validate types enum values', () => { 153 | const typeSchema = z 154 | .array(z.enum(['CODE_SMELL', 'BUG', 'VULNERABILITY', 'SECURITY_HOTSPOT'])) 155 | .nullable() 156 | .optional(); 157 | 158 | expect(typeSchema.parse(['CODE_SMELL', 'BUG'])).toEqual(['CODE_SMELL', 'BUG']); 159 | expect(typeSchema.parse(['VULNERABILITY', 'SECURITY_HOTSPOT'])).toEqual([ 160 | 'VULNERABILITY', 161 | 'SECURITY_HOTSPOT', 162 | ]); 163 | expect(typeSchema.parse(null)).toBe(null); 164 | expect(typeSchema.parse(undefined)).toBe(undefined); 165 | expect(() => typeSchema.parse(['INVALID'])).toThrow(); 166 | }); 167 | }); 168 | 169 | describe('Array Parameter Transformations', () => { 170 | it('should validate array of strings', () => { 171 | const stringArraySchema = z.array(z.string()).nullable().optional(); 172 | 173 | expect(stringArraySchema.parse(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']); 174 | expect(stringArraySchema.parse([])).toEqual([]); 175 | expect(stringArraySchema.parse(null)).toBe(null); 176 | expect(stringArraySchema.parse(undefined)).toBe(undefined); 177 | }); 178 | }); 179 | }); 180 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Simple logging service for the application. 3 | * This provides a centralized place for all logging functionality. 4 | * 5 | * Configuration: 6 | * - LOG_LEVEL: Sets the minimum log level (DEBUG, INFO, WARN, ERROR). Defaults to DEBUG. 7 | * - LOG_FILE: Path to the log file. If not set, no logs will be written. 8 | * 9 | * Note: Since MCP servers use stdout for protocol communication, logs are written 10 | * to a file instead of stdout/stderr to avoid interference. 11 | */ 12 | 13 | import { writeFileSync, appendFileSync, existsSync, mkdirSync } from 'fs'; 14 | import { dirname } from 'path'; 15 | 16 | /** 17 | * Log levels for the application 18 | * @enum {string} 19 | */ 20 | export enum LogLevel { 21 | DEBUG = 'DEBUG', 22 | INFO = 'INFO', 23 | WARN = 'WARN', 24 | ERROR = 'ERROR', 25 | } 26 | 27 | /** 28 | * Environment-aware logging configuration 29 | */ 30 | const LOG_LEVELS_PRIORITY: Record = { 31 | [LogLevel.DEBUG]: 0, 32 | [LogLevel.INFO]: 1, 33 | [LogLevel.WARN]: 2, 34 | [LogLevel.ERROR]: 3, 35 | }; 36 | 37 | /** 38 | * Get the log file path from environment 39 | * @returns {string | null} The log file path or null if not configured 40 | * @private 41 | */ 42 | function getLogFilePath(): string | null { 43 | return process.env.LOG_FILE ?? null; 44 | } 45 | 46 | let logFileInitialized = false; 47 | 48 | /** 49 | * Initialize the log file if needed by creating the directory and file 50 | * Only initializes once per process to avoid redundant file operations 51 | * @private 52 | * @returns {void} 53 | */ 54 | function initializeLogFile(): void { 55 | const logFile = getLogFilePath(); 56 | if (logFile && !logFileInitialized) { 57 | try { 58 | // Create directory if it doesn't exist 59 | const dir = dirname(logFile); 60 | if (!existsSync(dir)) { 61 | mkdirSync(dir, { recursive: true }); 62 | } 63 | // Create or truncate the log file 64 | writeFileSync(logFile, ''); 65 | logFileInitialized = true; 66 | } catch { 67 | // Fail silently if we can't create the log file 68 | logFileInitialized = true; // Don't retry 69 | } 70 | } 71 | } 72 | 73 | /** 74 | * Formats an error for logging 75 | * @param error The error to format 76 | * @returns Formatted error string 77 | */ 78 | function formatError(error: unknown): string { 79 | if (error === undefined) { 80 | return ''; 81 | } 82 | 83 | if (error instanceof Error) { 84 | const stack = error.stack ? `\n${error.stack}` : ''; 85 | return `${error.name}: ${error.message}${stack}`; 86 | } 87 | 88 | try { 89 | return JSON.stringify(error, null, 2); 90 | } catch { 91 | // Fallback to string representation if JSON.stringify fails 92 | return String(error); 93 | } 94 | } 95 | 96 | /** 97 | * Write a log message to file 98 | * @param message The formatted log message to write 99 | * @private 100 | */ 101 | function writeToLogFile(message: string): void { 102 | const logFile = getLogFilePath(); 103 | if (logFile) { 104 | try { 105 | if (!logFileInitialized) { 106 | initializeLogFile(); 107 | } 108 | appendFileSync(logFile, `${message}\n`); 109 | } catch { 110 | // Fail silently if we can't write to the log file 111 | } 112 | } 113 | } 114 | 115 | /** 116 | * Check if a log level should be displayed based on the environment configuration 117 | * @param level The log level to check 118 | * @returns {boolean} True if the log level should be displayed 119 | * @private 120 | */ 121 | function shouldLog(level: LogLevel): boolean { 122 | const configuredLevel = (process.env.LOG_LEVEL ?? 'DEBUG') as LogLevel; 123 | return LOG_LEVELS_PRIORITY[level] >= LOG_LEVELS_PRIORITY[configuredLevel]; 124 | } 125 | 126 | /** 127 | * Format a log message with timestamp, level, and context information 128 | * @param level The log level of the message 129 | * @param message The log message content 130 | * @param context Optional context identifier 131 | * @returns {string} Formatted log message 132 | * @private 133 | */ 134 | function formatLogMessage(level: LogLevel, message: string, context?: string): string { 135 | const timestamp = new Date().toISOString(); 136 | const contextStr = context ? `[${context}] ` : ''; 137 | return `${timestamp} ${level} ${contextStr}${message}`; 138 | } 139 | 140 | /** 141 | * Logger service for consistent logging throughout the application 142 | */ 143 | export class Logger { 144 | private readonly context?: string; 145 | 146 | /** 147 | * Create a new logger instance, optionally with a context 148 | * @param context Optional context name to identify the log source 149 | */ 150 | constructor(context?: string) { 151 | this.context = context; 152 | } 153 | 154 | /** 155 | * Log a debug message 156 | * @param message The message to log 157 | * @param data Optional data to include in the log 158 | */ 159 | debug(message: string, data?: unknown): void { 160 | if (shouldLog(LogLevel.DEBUG) && getLogFilePath()) { 161 | const formattedMessage = formatLogMessage(LogLevel.DEBUG, message, this.context); 162 | const fullMessage = 163 | data !== undefined 164 | ? `${formattedMessage} ${JSON.stringify(data, null, 2)}` 165 | : formattedMessage; 166 | writeToLogFile(fullMessage); 167 | } 168 | } 169 | 170 | /** 171 | * Log an info message 172 | * @param message The message to log 173 | * @param data Optional data to include in the log 174 | */ 175 | info(message: string, data?: unknown): void { 176 | if (shouldLog(LogLevel.INFO) && getLogFilePath()) { 177 | const formattedMessage = formatLogMessage(LogLevel.INFO, message, this.context); 178 | const fullMessage = 179 | data !== undefined 180 | ? `${formattedMessage} ${JSON.stringify(data, null, 2)}` 181 | : formattedMessage; 182 | writeToLogFile(fullMessage); 183 | } 184 | } 185 | 186 | /** 187 | * Log a warning message 188 | * @param message The message to log 189 | * @param data Optional data to include in the log 190 | */ 191 | warn(message: string, data?: unknown): void { 192 | if (shouldLog(LogLevel.WARN) && getLogFilePath()) { 193 | const formattedMessage = formatLogMessage(LogLevel.WARN, message, this.context); 194 | const fullMessage = 195 | data !== undefined 196 | ? `${formattedMessage} ${JSON.stringify(data, null, 2)}` 197 | : formattedMessage; 198 | writeToLogFile(fullMessage); 199 | } 200 | } 201 | 202 | /** 203 | * Log an error message with improved error formatting 204 | * @param message The message to log 205 | * @param error Optional error to include in the log. The error will be formatted for better readability: 206 | * - Error objects will include name, message and stack trace 207 | * - Objects will be stringified with proper indentation 208 | * - Other values will be converted to strings 209 | */ 210 | error(message: string, error?: unknown): void { 211 | if (!shouldLog(LogLevel.ERROR) || !getLogFilePath()) { 212 | return; 213 | } 214 | 215 | const formattedMessage = formatLogMessage(LogLevel.ERROR, message, this.context); 216 | const errorOutput = formatError(error); 217 | const fullMessage = errorOutput ? `${formattedMessage} ${errorOutput}` : formattedMessage; 218 | writeToLogFile(fullMessage); 219 | } 220 | } 221 | 222 | /** 223 | * Default logger instance for the application 224 | * Pre-configured with the 'SonarQubeMCP' context for quick imports 225 | * @const {Logger} 226 | */ 227 | export const defaultLogger = new Logger('SonarQubeMCP'); 228 | 229 | /** 230 | * Helper function to create a logger with a specific context 231 | * @param context The context to use for the logger 232 | * @returns A new logger instance with the specified context 233 | */ 234 | export function createLogger(context: string): Logger { 235 | return new Logger(context); 236 | } 237 | 238 | /** 239 | * Default export for simpler imports 240 | */ 241 | export default defaultLogger; 242 | -------------------------------------------------------------------------------- /tmp/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sapientpants/sonarqube-mcp-server/42eb927079fa9b5a026dd3ea447fe66c506c5a53/tmp/.gitkeep -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "declaration": true, 13 | "sourceMap": true, 14 | "isolatedModules": true 15 | }, 16 | "include": ["src/**/*"], 17 | "exclude": ["node_modules", "dist", "**/*.test.ts"] 18 | } --------------------------------------------------------------------------------