diff --git a/.env.example b/.env.example index 4d31704..b2e3a88 100644 --- a/.env.example +++ b/.env.example @@ -2,12 +2,18 @@ GITHUB_AUTH_CLIENT_ID= GITHUB_AUTH_CLIENT_SECRET= +# oauth for event badging +GITHUB_AUTH_CLIENT_ID_EVENT= +GITHUB_AUTH_CLIENT_SECRET_EVENT= # github app configs GITHUB_APP_ID= GITHUB_APP_CLIENT_ID= GITHUB_APP_CLIENT_SECRET= GITHUB_APP_WEBHOOK_SECRET= GITHUB_APP_PRIVATE_KEY= +GITHUB_APP_INSTALLATION_ID= +GITHUB_APP_INSTALLATION_TOKEN= + # gitlab OAuth app configs GITLAB_APP_CLIENT_ID= @@ -19,18 +25,26 @@ EMAIL_HOST= # Default is Gmail EMAIL_ADDRESS= # Your email address EMAIL_PASSWORD= # Not your email password. To get a unique Gmail app password, follow this link -> https://myaccount.google.com/apppasswords -PORT= +PORT=3001 # augur configs AUGUR_APP_CLIENT_SECRET= # To get client secret, create an app via https://ai.chaoss.io/account/login -# database configs DB_DIALECT= # Default is 'mysql' DB_HOST= # default is 'localhost' DB_NAME= +TEST_DB_NAME= DB_USER= DB_PASSWORD= +DB_PORT= + +# repository used for event badging +# REPOSITORY_NAME=event-diversity-and-inclusion +# REPOSITORY_OWNER=badging -# smeeClient URL for testing +# smeeClient URL for testing SMEE_CLIENT_URL= +WEBHOOK_PATH= + + diff --git a/.gitignore b/.gitignore index 16b926b..36a075c 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,11 @@ report* deploy.tests.yml /tmp/mysql/* + +# ignore .vscode +.vscode/ +# ignore all.pem files +*.pem + + + diff --git a/.husky/pre-commit b/.husky/pre-commit index 6545f01..f90a789 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -9,4 +9,4 @@ npm run typecheck || exit 1 echo "✨ Running lint-staged..." npx lint-staged || exit 1 -echo "✅ Pre-commit checks passed!" +echo " Pre-commit checks passed!" diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 90fe0bf..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "sqltools.connections": [ - { - "mysqlOptions": { - "authProtocol": "default", - "enableSsl": "Disabled" - }, - "previewLimit": 50, - "server": "localhost", - "port": 3306, - "driver": "MariaDB", - "name": "Project Badging", - "database": "project_badging", - "username": "kaxada", - "askForPassword": true - } - ] -} diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..a419d38 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,13 @@ +import type { Config } from 'jest'; + +const config: Config = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/tests/**/*.test.ts'], + "transform": { + "^.+\\.tsx?$": ["ts-jest", { "tsconfig": "tsconfig.json" }] +}, +setupFilesAfterEnv: ['/jest.setup.ts'], +}; + +export default config; \ No newline at end of file diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 0000000..6645405 --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1,7 @@ +import 'reflect-metadata'; +import * as dotenv from 'dotenv'; +import path from 'path'; + +// Point to your .env file +dotenv.config({ path: path.resolve(__dirname, './.env') }); + diff --git a/package-lock.json b/package-lock.json index 59f7ac4..4fb5452 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,16 +26,19 @@ "sequelize": "^6.35.2", "sequelize-typescript": "^2.1.6", "turndown": "^7.1.2", - "typedi": "^0.10.0" + "typedi": "^0.10.0", + "winston": "^3.19.0" }, "devDependencies": { "@inquirer/prompts": "^7.2.0", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", - "@types/jest": "^29.5.11", - "@types/node": "^20.10.5", + "@types/jest": "^29.5.14", + "@types/node": "^20.19.37", "@types/nodemailer": "^6.4.14", + "@types/supertest": "^7.2.0", "@types/turndown": "^5.0.4", + "@types/winston": "^2.4.4", "@typescript-eslint/eslint-plugin": "^6.15.0", "@typescript-eslint/parser": "^6.15.0", "eslint": "^8.56.0", @@ -46,10 +49,11 @@ "lint-staged": "^15.5.2", "prettier": "^3.1.1", "smee-client": "^2.0.1", - "ts-jest": "^29.1.1", + "supertest": "^7.2.2", + "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", - "typescript": "^5.3.3" + "typescript": "^5.9.3" } }, "node_modules/@babel/code-frame": { @@ -568,6 +572,14 @@ "dev": true, "license": "MIT" }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -592,6 +604,16 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1697,6 +1719,18 @@ "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", "license": "BSD-2-Clause" }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2180,6 +2214,15 @@ "integrity": "sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw==", "license": "MIT" }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2230,6 +2273,15 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -2336,6 +2388,12 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -2430,7 +2488,6 @@ "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", "dev": true, - "license": "MIT", "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" @@ -2453,6 +2510,12 @@ "@types/node": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -2467,10 +2530,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.30", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", - "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", - "license": "MIT", + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "dependencies": { "undici-types": "~6.21.0" } @@ -2560,6 +2622,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==" + }, "node_modules/@types/turndown": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz", @@ -2573,6 +2662,16 @@ "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "license": "MIT" }, + "node_modules/@types/winston": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/winston/-/winston-2.4.4.tgz", + "integrity": "sha512-BVGCztsypW8EYwJ+Hq+QNYiT/MUyCif0ouBH+flrY66O5W+KIXAMML6E/0fJpm7VjIzgangahl5S03bJJQGrZw==", + "deprecated": "This is a stub types definition. winston provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "winston": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -2978,6 +3077,17 @@ "node": ">=8" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3711,6 +3821,18 @@ "dev": true, "license": "MIT" }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3729,6 +3851,44 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-string/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "engines": { + "node": ">=12.20" + } + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -3758,6 +3918,15 @@ "node": ">=18" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3823,6 +3992,12 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true + }, "node_modules/cookies": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", @@ -4027,6 +4202,16 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -4162,6 +4347,11 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -4744,6 +4934,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -4764,6 +4960,11 @@ "bser": "2.1.1" } }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -4862,6 +5063,11 @@ "dev": true, "license": "ISC" }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -4914,6 +5120,23 @@ "node": ">= 6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5645,7 +5868,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5758,7 +5980,6 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, - "license": "MIT", "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -6414,7 +6635,6 @@ "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", - "license": "MIT", "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", @@ -6605,6 +6825,11 @@ "node": ">= 0.6" } }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -7172,6 +7397,22 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -7639,6 +7880,14 @@ "wrappy": "1" } }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "dependencies": { + "fn.name": "1.x.x" + } + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -8644,6 +8893,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -9085,6 +9342,14 @@ "node": ">= 0.6" } }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "engines": { + "node": "*" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -9131,7 +9396,6 @@ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "license": "MIT", - "optional": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -9140,8 +9404,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/string-argv": { "version": "0.3.2", @@ -9254,6 +9517,61 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -9341,6 +9659,11 @@ "node": "*" } }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -9393,6 +9716,14 @@ "tree-kill": "cli.js" } }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -9411,7 +9742,6 @@ "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", "dev": true, - "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", @@ -9477,7 +9807,6 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, - "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -9697,7 +10026,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9815,8 +10143,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/utils-merge": { "version": "1.0.1", @@ -9901,6 +10228,66 @@ "node": ">= 8" } }, + "node_modules/winston": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/winston/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/wkx": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", diff --git a/package.json b/package.json index fb00573..a23f590 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "lint": "eslint src/**/*.ts", "lint:fix": "eslint src/**/*.ts --fix", "typecheck": "tsc --noEmit", - "test": "jest", + "test": "jest --runInBand", "test:watch": "jest --watch", "test:coverage": "jest --coverage" }, @@ -43,16 +43,19 @@ "sequelize": "^6.35.2", "sequelize-typescript": "^2.1.6", "turndown": "^7.1.2", - "typedi": "^0.10.0" + "typedi": "^0.10.0", + "winston": "^3.19.0" }, "devDependencies": { "@inquirer/prompts": "^7.2.0", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", - "@types/jest": "^29.5.11", - "@types/node": "^20.10.5", + "@types/jest": "^29.5.14", + "@types/node": "^20.19.37", "@types/nodemailer": "^6.4.14", + "@types/supertest": "^7.2.0", "@types/turndown": "^5.0.4", + "@types/winston": "^2.4.4", "@typescript-eslint/eslint-plugin": "^6.15.0", "@typescript-eslint/parser": "^6.15.0", "eslint": "^8.56.0", @@ -63,10 +66,11 @@ "lint-staged": "^15.5.2", "prettier": "^3.1.1", "smee-client": "^2.0.1", - "ts-jest": "^29.1.1", + "supertest": "^7.2.2", + "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", - "typescript": "^5.3.3" + "typescript": "^5.9.3" }, "lint-staged": { "src/**/*.ts": [ @@ -74,4 +78,4 @@ "prettier --write" ] } -} \ No newline at end of file +} diff --git a/src/app.ts b/src/app.ts index 8e59091..fa2f2a9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,68 +1,35 @@ import 'reflect-metadata'; import { createExpressServer, useContainer } from 'routing-controllers'; import { Container } from 'typedi'; -import express, { Express, Request, Response } from 'express'; +import express, { Express } from 'express'; import path from 'path'; -// Import controllers import { AuthController } from './auth/controllers'; import { ProjectBadgingController } from './project-badging/controllers'; import { EventBadgingController } from './event-badging/controllers'; +import { SystemController } from './shared/controllers'; + +import { requestLogger, globalErrorHandler} from './middleware' -// Set container for routing-controllers useContainer(Container); -/** - * Create and configure Express application - */ + + export function createApp(): Express { - // Create express server with routing-controllers + const app: Express = createExpressServer({ - controllers: [AuthController, ProjectBadgingController, EventBadgingController], + controllers: [AuthController, ProjectBadgingController, EventBadgingController, SystemController], cors: true, - defaultErrorHandler: true, + defaultErrorHandler: false, routePrefix: '', }) as Express; - // Additional middleware - app.use(express.json()); - app.use(express.urlencoded({ extended: true })); - - // Serve static files - app.use('/assets', express.static(path.join(__dirname, '../assets'))); +app.use(requestLogger); - // Health check endpoint - app.get('/health', (_req: Request, res: Response) => { - res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() }); - }); +app.use('/assets', express.static(path.join(__dirname, '../assets'))); - // API base endpoint (for compatibility) - app.get('/api', (_req: Request, res: Response) => { - res.json({ message: 'Project Badging server up and running' }); - }); - // Root endpoint - app.get('/', (_req: Request, res: Response) => { - res.status(200).json({ - name: 'DEI Badging API', - version: '1.0.0', - endpoints: { - health: '/health', - auth: { - github: '/api/callback', - gitlab: '/api/gitlab/callback', - }, - projectBadging: { - badgedRepos: '/api/badgedRepos', - reposToBadge: '/api/repos-to-badge', - }, - eventBadging: { - webhook: '/api/event_badging', - badgedEvents: '/api/badged_events', - }, - }, - }); - }); + app.use(globalErrorHandler); return app; } diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index 4eefb50..33d7a48 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -5,267 +5,197 @@ import { QueryParam, Body, Res, - Redirect, - Req, } from 'routing-controllers'; +import { Response } from 'express'; import { Service } from 'typedi'; -import { Request, Response } from 'express'; + import { AuthService } from '../services/auth.service'; + import { Provider } from '../../shared/types'; -import { isDevelopment, isProduction } from '../../shared/config/environment'; + +import { isProduction } from '../../shared/config/environment'; +import { renderDevRepoForm } from '../../dev/render-dev-form'; +import { AppError } from '../../shared/errors'; @JsonController('/api') @Service() export class AuthController { constructor(private authService: AuthService) {} - /** - * GET /api/login - * Redirects to the appropriate OAuth provider login page - */ + // ------------------------- + // LOGIN ENTRY + // ------------------------- + @Get('/login') - login(@QueryParam('provider') provider: Provider, @Res() response: Response): Response | void { + // @Redirect(':url') + login(@QueryParam('provider') provider: Provider, @Res() res: Response) { if (!provider) { - return response.status(400).json({ error: 'Provider is required' }); + throw new AppError('Provider is required', 400); } - const result = this.authService.getLoginUrl(provider); + const { data } = this.authService.getLoginUrl(provider); - if (result.errors.length > 0 || !result.data) { - return response.status(500).json({ error: result.errors.join(', ') }); + if (!data) { + throw new AppError('Failed to generate login URL', 500); } - return response.redirect(result.data) as unknown as Response; + //return { url: data }; + res.redirect(data); + return; } - /** - * GET /api/auth/github - * Redirect to GitHub OAuth - */ + // ------------------------- + // PROVIDER LOGIN + // ------------------------- + @Get('/auth/github') - @Redirect('') - authGitHub(): string { - const result = this.authService.getLoginUrl('github'); - if (result.errors.length > 0 || !result.data) { - throw new Error(result.errors.join(', ')); + authGitHub(@Res() res: Response) { + const { data } = this.authService.getLoginUrl('github'); + + if (!data) { + throw new AppError('GitHub auth URL generation failed', 500); + } + + return res.redirect(data); + } + + @Get('/auth/gitlab') + authGitLab(@Res() res: Response) { + const { data } = this.authService.getLoginUrl('gitlab'); + + if (!data) { + throw new AppError('GitLab auth URL generation failed', 500); } - return result.data; + + res.redirect(data); + return; } - /** - * POST /api/auth/github - * Handle event badging GitHub auth (returns authorization link) - */ + // ------------------------- + // EVENT BADGING AUTH + // ------------------------- + @Post('/auth/github') authGitHubEventBadging( - @Body() body: { type?: string; title?: string; body?: string }, - @Res() response: Response - ): Response | void { - if (body.type === 'event-badging') { - if (!body.title || !body.body) { - return response - .status(400) - .json({ error: 'Title and body are required for event badging' }); - } - - const result = this.authService.getEventBadgingAuthUrl(body.title, body.body); + @Body() body: { type?: string; title?: string; body?: string } + ) { + if (body.type !== 'event-badging') { + const { data } = this.authService.getLoginUrl('github'); - if (result.errors.length > 0 || !result.data) { - return response.status(500).json({ error: result.errors.join(', ') }); + if (!data) { + throw new AppError('GitHub auth generation failed', 500); } - return response.json({ authorizationLink: result.data }); + return { redirectUrl: data }; } - // For non-event-badging, redirect to GitHub OAuth - const result = this.authService.getLoginUrl('github'); - if (result.errors.length > 0 || !result.data) { - return response.status(500).json({ error: result.errors.join(', ') }); + if (!body.title || !body.body) { + throw new AppError('Title and body are required', 400); } - return response.redirect(result.data) as unknown as Response; - } + const { data } = this.authService.getEventBadgingAuthUrl( + body.title, + body.body + ); - /** - * GET /api/auth/gitlab - * Redirect to GitLab OAuth - */ - @Get('/auth/gitlab') - @Redirect('') - authGitLab(): string { - const result = this.authService.getLoginUrl('gitlab'); - if (result.errors.length > 0 || !result.data) { - throw new Error(result.errors.join(', ')); + if (!data) { + throw new AppError('Failed to generate event badging auth URL', 500); } - return result.data; + + return { authorizationLink: data }; } - /** - * GET /api/callback/github - * Handle GitHub OAuth callback - */ + // ------------------------- + // CALLBACKS + // ------------------------- + @Get('/callback/github') async callbackGitHubGet( @QueryParam('code') code: string, - @Req() request: Request, - @Res() response: Response - ): Promise { - const state = request.query.state as string | undefined; - return this.handleGitHubCallback(code, state, response); + @QueryParam('state') state: string, + @Res() res: Response + ) { + return this.handleGitHubCallback(code, state, res); } - /** - * POST /api/callback/github - * Handle GitHub OAuth callback (POST variant) - */ @Post('/callback/github') async callbackGitHubPost( @Body() body: { code?: string }, - @QueryParam('code') queryCode: string | undefined, - @Req() request: Request, - @Res() response: Response - ): Promise { - const state = request.query.state as string | undefined; - const code = body.code || queryCode; - if (!code) { - return response.status(400).json({ error: 'Code is required' }); - } - return this.handleGitHubCallback(code, state, response); + @QueryParam('code') queryCode: string, + @QueryParam('state') state: string, + @Res() res: Response + ) { + const code = body?.code || queryCode; + return this.handleGitHubCallback(code, state, res); } - /** - * GET /api/callback/gitlab - * Handle GitLab OAuth callback - */ @Get('/callback/gitlab') async callbackGitLabGet( @QueryParam('code') code: string, - @Res() response: Response - ): Promise { - return this.handleGitLabCallback(code, response); + @Res() res: Response + ) { + return this.handleGitLabCallback(code, res); } - /** - * POST /api/callback/gitlab - * Handle GitLab OAuth callback (POST variant) - */ @Post('/callback/gitlab') async callbackGitLabPost( @Body() body: { code?: string }, - @QueryParam('code') queryCode: string | undefined, - @Res() response: Response - ): Promise { - const code = body.code || queryCode; - if (!code) { - return response.status(400).json({ error: 'Code is required' }); - } - return this.handleGitLabCallback(code, response); + @QueryParam('code') queryCode: string, + @Res() res: Response + ) { + const code = body?.code || queryCode; + return this.handleGitLabCallback(code, res); } - /** - * Common handler for GitHub OAuth callback - */ + // ------------------------- + // HANDLERS + // ------------------------- + private async handleGitHubCallback( code: string, state: string | undefined, - response: Response - ): Promise { + res: Response + ) { if (!code) { - return response.status(400).json({ error: 'Code is required' }); + throw new AppError('Code is required', 400); } const result = await this.authService.handleGitHubCallback(code, state); - if (result.errors.length > 0 || !result.data) { - return response.status(500).json({ error: result.errors.join(', ') }); + if (!result.data) { + throw new AppError('GitHub callback failed', 500); } - // Event badging callback - redirect to issue URL if ('issueUrl' in result.data) { - return response.redirect(result.data.issueUrl) as unknown as Response; + return res.redirect(result.data.issueUrl); } - // Project badging callback - return user data if (isProduction()) { - return response.status(200).json(result.data); - } - - // Development mode - render HTML form - if (isDevelopment()) { - const data = result.data; - return response.status(200).send(this.renderDevRepoForm(data, 'github')); + return res.status(200).json(result.data); } - return response.status(500).json({ error: 'Unknown process mode' }); + return res + .status(200) + .send(renderDevRepoForm(result.data, 'github')); } - /** - * Common handler for GitLab OAuth callback - */ - private async handleGitLabCallback(code: string, response: Response): Promise { + private async handleGitLabCallback(code: string, res: Response) { if (!code) { - return response.status(400).json({ error: 'Code is required' }); + throw new AppError('Code is required', 400); } const result = await this.authService.handleGitLabCallback(code); - if (result.errors.length > 0 || !result.data) { - return response.status(500).json({ error: result.errors.join(', ') }); + if (!result.data) { + throw new AppError('GitLab callback failed', 500); } if (isProduction()) { - return response.status(200).json(result.data); + return res.status(200).json(result.data); } - // Development mode - render HTML form - if (isDevelopment()) { - return response.status(200).send(this.renderDevRepoForm(result.data, 'gitlab')); - } - - return response.status(500).json({ error: 'Unknown process mode' }); - } - - /** - * Render development HTML form for repository selection - */ - private renderDevRepoForm( - data: { - userId: number; - name: string; - username: string; - email: string; - repos: Array<{ id: number; fullName: string }>; - }, - provider: string - ): string { - return ` - - - Repo List - - -

Welcome ${data.name}

-

Username: ${data.username}

-

Email: ${data.email}

-
- - -

Select Repositories:

- ${data.repos - .map( - (repo) => ` -
- - -
- ` - ) - .join('')} -
- -
- - - `; + return res + .status(200) + .send(renderDevRepoForm(result.data, 'gitlab')); } -} +} \ No newline at end of file diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index ad63b66..90e785f 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -1,19 +1,30 @@ import { Service } from 'typedi'; import { Octokit } from '@octokit/rest'; + import { GitHubAuthService, + GitHubAppService, GitHubApiService, GitLabAuthService, GitLabApiService, UserRepository, CryptoService, + getEnvVar } from '../../shared'; -import { Provider, IAuthCallbackResult, IOperationResult } from '../../shared/types'; + +import { + Provider, + IAuthCallbackResult, + IOperationResult +} from '../../shared/types'; + +import { AppError } from '../../shared/errors'; @Service() export class AuthService { constructor( private githubAuthService: GitHubAuthService, + private githubAppService: GitHubAppService, private githubApiService: GitHubApiService, private gitlabAuthService: GitLabAuthService, private gitlabApiService: GitLabApiService, @@ -21,17 +32,16 @@ export class AuthService { private cryptoService: CryptoService ) {} - /** - * Get login redirect URL for a provider - */ + // ------------------------- + // LOGIN URLS + // ------------------------- + getLoginUrl(provider: Provider): IOperationResult { if (provider === 'github') { if (!this.githubAuthService.isConfigured()) { - return { - data: null, - errors: ['GitHub provider is not configured'], - }; + throw new AppError('GitHub provider is not configured', 500); } + return { data: this.githubAuthService.getProjectBadgingAuthUrl(), errors: [], @@ -40,133 +50,154 @@ export class AuthService { if (provider === 'gitlab') { if (!this.gitlabAuthService.isConfigured()) { - return { - data: null, - errors: ['GitLab provider is not configured'], - }; + throw new AppError('GitLab provider is not configured', 500); } + return { data: this.gitlabAuthService.getAuthUrl(), errors: [], }; } - return { - data: null, - errors: [`Unknown provider: ${provider as string}`], - }; + throw new AppError(`Unknown provider: ${provider}`, 400); } - /** - * Get event badging authorization URL with encrypted form data - */ + // ------------------------- + // EVENT BADGING URL + // ------------------------- + getEventBadgingAuthUrl(title: string, body: string): IOperationResult { if (!this.githubAuthService.isEventBadgingConfigured()) { - return { - data: null, - errors: ['GitHub event badging provider is not configured'], - }; + throw new AppError('GitHub event badging provider is not configured', 500); } - const formData = JSON.stringify({ title, body, type: 'event-badging' }); + const formData = JSON.stringify({ + title, + body, + type: 'event-badging' + }); + const encryptedState = this.cryptoService.encrypt(formData); - const url = this.githubAuthService.getEventBadgingAuthUrl(encryptedState); return { - data: url, + data: this.githubAuthService.getEventBadgingAuthUrl(encryptedState), errors: [], }; } - /** - * Handle GitHub OAuth callback - */ + // ------------------------- + // GITHUB CALLBACK + // ------------------------- + async handleGitHubCallback( code: string, state?: string ): Promise> { + const isEventBadging = !!state; - // Exchange code for access token - const { access_token: accessToken, errors: tokenErrors } = - await this.githubAuthService.requestAccessToken(code, isEventBadging); + const { + access_token: accessToken, + errors: tokenErrors + } = await this.githubAuthService.requestAccessToken( + code, + isEventBadging + ); - if (tokenErrors.length > 0 || !accessToken) { - return { - data: null, - errors: tokenErrors.length > 0 ? tokenErrors : ['Failed to get access token'], - }; + if (tokenErrors.length || !accessToken) { + throw new AppError( + tokenErrors.join(', ') || 'Failed to get access token', + 401 + ); } - const octokit = this.githubAuthService.createOctokit(accessToken); + const userOctokit = + this.githubAuthService.createUserOctokit(accessToken); + + const { data: user } = await userOctokit.request('GET /user'); - // Handle event badging flow - create issue and return URL if (state) { - return this.handleEventBadgingCallback(octokit, state); + const appOctokit = + await this.githubAppService.getInstallationOctokit( + Number(getEnvVar('GITHUB_APP_INSTALLATION_ID')) + ); + + return this.handleEventBadgingCallback(appOctokit, state, user); } - // Handle project badging flow - return user data and repos - return this.handleProjectBadgingCallback(octokit); + return this.handleProjectBadgingCallback(userOctokit); } - /** - * Handle event badging OAuth callback - creates issue - */ + // ------------------------- + // EVENT BADGING CALLBACK + // ------------------------- + private async handleEventBadgingCallback( - octokit: Octokit, - encryptedState: string + octokit: any, + encryptedState: string, + user: { login: string; html_url: string } ): Promise> { - try { - const formData = this.cryptoService.decrypt(encryptedState); - const parsedFormData = JSON.parse(formData) as { title: string; body: string }; - const markdown = this.cryptoService.convertToMarkdown(parsedFormData.body); - - const result = await this.githubApiService.createIssue( - octokit, - 'adeyinkaoresanya', - 'sandbox-event-dei', - parsedFormData.title, - markdown - ); - if (result.errors.length > 0 || !result.data) { - return { - data: null, - errors: result.errors, - }; - } + const formData = this.cryptoService.decrypt(encryptedState); + const parsed = JSON.parse(formData) as { + title: string; + body: string; + }; - return { - data: { issueUrl: result.data.url }, - errors: [], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - return { - data: null, - errors: [errorMessage], - }; + const baseMarkdown = + this.cryptoService.convertToMarkdown(parsed.body); + + const markdown = [ + '### Submitted by', + `- GitHub: @${user.login}`, + `- Profile: ${user.html_url}`, + '', + '---', + '', + baseMarkdown + ].join('\n'); + + const result = await this.githubApiService.createIssue( + octokit, + getEnvVar('REPOSITORY_OWNER'), + getEnvVar('REPOSITORY_NAME'), + parsed.title, + markdown + ); + + if (result.errors.length || !result.data) { + throw new AppError( + result.errors.join(', ') || 'Failed to create GitHub issue', + 500 + ); } + + return { + data: { issueUrl: result.data.url }, + errors: [], + }; } - /** - * Handle project badging OAuth callback - returns user and repos - */ + // ------------------------- + // PROJECT BADGING CALLBACK + // ------------------------- + private async handleProjectBadgingCallback( octokit: Octokit ): Promise> { - // Get user info - const { data: userInfo, errors: userInfoErrors } = - await this.githubApiService.getUserInfo(octokit); - if (userInfoErrors.length > 0 || !userInfo) { - return { - data: null, - errors: userInfoErrors, - }; + const { + data: userInfo, + errors: userInfoErrors + } = await this.githubApiService.getUserInfo(octokit); + + if (userInfoErrors.length || !userInfo) { + throw new AppError( + userInfoErrors.join(', ') || 'Failed to retrieve GitHub user info', + 500 + ); } - // Save user to database const savedUser = await this.userRepository.saveUser( userInfo.login, userInfo.name, @@ -176,21 +207,19 @@ export class AuthService { ); if (!savedUser) { - return { - data: null, - errors: ['Error saving user info'], - }; + throw new AppError('Failed to save GitHub user', 500); } - // Get user repositories - const { data: repositories, errors: repoErrors } = - await this.githubApiService.getUserRepositories(octokit); + const { + data: repositories, + errors: repoErrors + } = await this.githubApiService.getUserRepositories(octokit); - if (repoErrors.length > 0 || !repositories) { - return { - data: null, - errors: repoErrors, - }; + if (repoErrors.length || !repositories) { + throw new AppError( + repoErrors.join(', ') || 'Failed to retrieve repositories', + 500 + ); } return { @@ -206,33 +235,38 @@ export class AuthService { }; } - /** - * Handle GitLab OAuth callback - */ - async handleGitLabCallback(code: string): Promise> { - // Exchange code for access token - const { access_token: accessToken, errors: tokenErrors } = - await this.gitlabAuthService.requestAccessToken(code); + // ------------------------- + // GITLAB CALLBACK + // ------------------------- - if (tokenErrors.length > 0 || !accessToken) { - return { - data: null, - errors: tokenErrors.length > 0 ? tokenErrors : ['Failed to get access token'], - }; + async handleGitLabCallback( + code: string + ): Promise> { + + const { + access_token: accessToken, + errors: tokenErrors + } = await this.gitlabAuthService.requestAccessToken(code); + + if (tokenErrors.length || !accessToken) { + throw new AppError( + tokenErrors.join(', ') || 'Failed to get GitLab access token', + 401 + ); } - // Get user info - const { data: userInfo, errors: userInfoErrors } = - await this.gitlabApiService.getUserInfo(accessToken); + const { + data: userInfo, + errors: userInfoErrors + } = await this.gitlabApiService.getUserInfo(accessToken); - if (userInfoErrors.length > 0 || !userInfo) { - return { - data: null, - errors: userInfoErrors, - }; + if (userInfoErrors.length || !userInfo) { + throw new AppError( + userInfoErrors.join(', ') || 'Failed to retrieve GitLab user info', + 500 + ); } - // Save user to database const savedUser = await this.userRepository.saveUser( userInfo.login, userInfo.name, @@ -242,21 +276,19 @@ export class AuthService { ); if (!savedUser) { - return { - data: null, - errors: ['Error saving user info'], - }; + throw new AppError('Failed to save GitLab user', 500); } - // Get user repositories - const { data: repositories, errors: repoErrors } = - await this.gitlabApiService.getUserRepositories(accessToken); + const { + data: repositories, + errors: repoErrors + } = await this.gitlabApiService.getUserRepositories(accessToken); - if (repoErrors.length > 0 || !repositories) { - return { - data: null, - errors: repoErrors, - }; + if (repoErrors.length || !repositories) { + throw new AppError( + repoErrors.join(', ') || 'Failed to retrieve GitLab repositories', + 500 + ); } return { @@ -271,4 +303,4 @@ export class AuthService { errors: [], }; } -} +} \ No newline at end of file diff --git a/src/dev/render-dev-form.ts b/src/dev/render-dev-form.ts new file mode 100644 index 0000000..fcc71e5 --- /dev/null +++ b/src/dev/render-dev-form.ts @@ -0,0 +1,99 @@ + /** + * DEV HTML RENDERER + */ + + export function renderDevRepoForm( + data: { + userId: number; + name: string; + username: string; + email: string; + repos: Array<{ id: number; fullName: string }>; + }, + provider: string + ): string { + return ` + + + Select Repositories + + + +

Welcome ${data.name}

+

Username: ${data.username}

+

Email: ${data.email}

+ +
+ + + +

Select Repositories:

+ ${data.repos + .map( + (repo) => ` +
+ + +
+ ` + ) + .join('')} + + +
+ + + + + `; + } \ No newline at end of file diff --git a/src/dev/smee.ts b/src/dev/smee.ts new file mode 100644 index 0000000..aad7d1f --- /dev/null +++ b/src/dev/smee.ts @@ -0,0 +1,26 @@ +import SmeeClient from 'smee-client'; +import {logger} from '../shared/logger'; +import { getEnvVar } from '../shared/config/environment'; + +export function startSmee(appUrl: string, webhookPath: string): () => void { + const SMEE_CLIENT_URL = getEnvVar('SMEE_CLIENT_URL', ''); + + if (!SMEE_CLIENT_URL) return () => {}; + + const smee = new SmeeClient({ + source: SMEE_CLIENT_URL, + target: `${appUrl}${webhookPath}`, + logger, + }); + + const events = smee.start(); + logger.info('Smee forwarding enabled', { + source: SMEE_CLIENT_URL, + target: webhookPath, + }); + + return () => { + events.close(); + logger.info('Smee forwarding stopped'); + }; +} diff --git a/src/event-badging/controllers/event-badging.controller.ts b/src/event-badging/controllers/event-badging.controller.ts index bd9648d..2cb3cff 100644 --- a/src/event-badging/controllers/event-badging.controller.ts +++ b/src/event-badging/controllers/event-badging.controller.ts @@ -1,10 +1,23 @@ -import { JsonController, Post, Body, HeaderParam, Get, Res } from 'routing-controllers'; +import { + JsonController, + Post, + Body, + HeaderParam, + Get, + Res, +} from 'routing-controllers'; import { Service } from 'typedi'; import { Response } from 'express'; + import { EventBadgingService } from '../services/event-badging.service'; import { EventRepository } from '../../shared/data-access/repositories/event.repository'; import { IGitHubWebhookPayload } from '../../shared/types'; +import { AppError } from '../../shared/errors'; +import { logger } from '../../shared/logger'; + + + @JsonController('/api') @Service() export class EventBadgingController { @@ -15,7 +28,6 @@ export class EventBadgingController { /** * POST /api/event_badging - * Handle GitHub webhook events for event badging */ @Post('/event_badging') async handleWebhook( @@ -23,31 +35,33 @@ export class EventBadgingController { @HeaderParam('x-github-event') eventName: string, @Res() response: Response ): Promise { - try { - console.info(`Received ${eventName} event from GitHub`); - - // Process webhook asynchronously - await this.eventBadgingService.processWebhook(eventName, payload); - return response.send('ok'); - } catch (error) { - console.error('Error processing webhook:', error); - return response.status(500).send('Error processing webhook'); + if (!eventName) { + throw new AppError('GitHub event header missing', 400); } + + logger.info('Received GitHub webhook event', { + eventName, + action: payload?.action, + }); + + await this.eventBadgingService.processWebhook(eventName, payload); + + return response.send('ok'); } /** * GET /api/badged_events - * Get all badged events */ @Get('/badged_events') async getBadgedEvents(@Res() response: Response): Promise { - try { - const events = await this.eventRepository.findAll(); - return response.status(200).json(events); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - return response.status(500).json({ error: errorMessage }); - } + + const events = await this.eventRepository.findAll(); + + logger.info('Fetched badged events', { + count: events.length, + }); + + return response.status(200).json(events); } -} +} \ No newline at end of file diff --git a/src/event-badging/services/checklist.service.ts b/src/event-badging/services/checklist.service.ts index 97ef6a5..bb5ab13 100644 --- a/src/event-badging/services/checklist.service.ts +++ b/src/event-badging/services/checklist.service.ts @@ -1,6 +1,8 @@ import { Service } from 'typedi'; import { Octokit } from 'octokit'; import { GitHubApiService } from '../../shared/providers/github/github-api.service'; +import { AppError } from '../../shared/errors'; +import { logger } from '../../shared/logger'; type ChecklistType = 'virtual' | 'in-person'; @@ -15,9 +17,6 @@ interface ChecklistSection { export class ChecklistService { constructor(private githubApiService: GitHubApiService) {} - /** - * Determine checklist type from issue title - */ getChecklistType(issueTitle: string): ChecklistType { if (issueTitle.substring(0, 15) === '[Virtual Event]') { return 'virtual'; @@ -25,16 +24,12 @@ export class ChecklistService { return 'in-person'; } - /** - * Get checklist path based on type - */ getChecklistPath(type: ChecklistType): string { - return type === 'virtual' ? '.github/checklist-virtual.md' : '.github/checklist.md'; + return type === 'virtual' + ? '.github/checklist-virtual.md' + : '.github/checklist.md'; } - /** - * Generate reviewer checklist by combining issue content with checklist template - */ async generateReviewerChecklist( octokit: Octokit, owner: string, @@ -44,32 +39,26 @@ export class ChecklistService { ): Promise { const checklistPath = this.getChecklistPath(checklistType); - // Fetch checklist template - const { data: checklistContent, errors } = await this.githubApiService.getRepoContent( - octokit as unknown as import('@octokit/rest').Octokit, - owner, - repo, - checklistPath - ); + const { data: checklistContent, errors } = + await this.githubApiService.getRepoContent( + octokit as unknown as import('@octokit/rest').Octokit, + owner, + repo, + checklistPath + ); if (errors.length > 0 || !checklistContent) { - throw new Error(`Failed to fetch checklist: ${errors.join(', ')}`); + logger.error('Failed to fetch checklist', { errors }); + throw new AppError('Failed to fetch checklist template', 500); } - // Remove existing checks from issue body const cleanedBody = issueBody.replace(/- \[x\]|- \[ \]/g, ''); - // Combine issue content with checklist based on type - if (checklistType === 'virtual') { - return this.combineVirtualChecklist(cleanedBody, checklistContent); - } - - return this.combineInPersonChecklist(cleanedBody, checklistContent); + return checklistType === 'virtual' + ? this.combineVirtualChecklist(cleanedBody, checklistContent) + : this.combineInPersonChecklist(cleanedBody, checklistContent); } - /** - * Combine virtual event checklist - */ private combineVirtualChecklist(issueBody: string, checklist: string): string { const sections: ChecklistSection[] = [ { @@ -106,7 +95,6 @@ export class ChecklistService { let combined = ''; - // Add initial content combined += issueBody.slice(0, issueBody.indexOf('## Event Demographics')) + '\n' + @@ -116,7 +104,6 @@ export class ChecklistService { ) + '\n\n'; - // Add sections for (const section of sections) { combined += issueBody.slice( @@ -131,7 +118,6 @@ export class ChecklistService { '\n\n'; } - // Add final section combined += issueBody.slice(issueBody.indexOf('## Event Accessibility')) + '\n' + @@ -141,9 +127,6 @@ export class ChecklistService { return combined; } - /** - * Combine in-person event checklist - */ private combineInPersonChecklist(issueBody: string, checklist: string): string { const sections: ChecklistSection[] = [ { @@ -192,7 +175,6 @@ export class ChecklistService { let combined = ''; - // Add initial content combined += issueBody.slice(0, issueBody.indexOf('## Event Demographics')) + '\n' + @@ -202,7 +184,6 @@ export class ChecklistService { ) + '\n\n'; - // Add sections for (const section of sections) { combined += issueBody.slice( @@ -217,7 +198,6 @@ export class ChecklistService { '\n\n'; } - // Add final section combined += issueBody.slice(issueBody.indexOf('## Public Health and Safety')) + '\n' + @@ -227,45 +207,40 @@ export class ChecklistService { return combined; } - /** - * Get reviewer welcome message - */ async getReviewerWelcome(octokit: Octokit, owner: string, repo: string): Promise { - const { data: content, errors } = await this.githubApiService.getRepoContent( - octokit as unknown as import('@octokit/rest').Octokit, - owner, - repo, - '.github/reviewer-welcome.md' - ); + const { data: content, errors } = + await this.githubApiService.getRepoContent( + octokit as unknown as import('@octokit/rest').Octokit, + owner, + repo, + '.github/reviewer-welcome.md' + ); if (errors.length > 0 || !content) { - throw new Error(`Failed to fetch reviewer welcome: ${errors.join(', ')}`); + logger.error('Failed to fetch reviewer welcome', { errors }); + throw new AppError('Failed to fetch reviewer welcome message', 500); } return content; } - /** - * Get applicant welcome message - */ async getApplicantWelcome(octokit: Octokit, owner: string, repo: string): Promise { - const { data: content, errors } = await this.githubApiService.getRepoContent( - octokit as unknown as import('@octokit/rest').Octokit, - owner, - repo, - '.github/applicant-welcome.md' - ); + const { data: content, errors } = + await this.githubApiService.getRepoContent( + octokit as unknown as import('@octokit/rest').Octokit, + owner, + repo, + '.github/applicant-welcome.md' + ); if (errors.length > 0 || !content) { - throw new Error(`Failed to fetch applicant welcome: ${errors.join(', ')}`); + logger.error('Failed to fetch applicant welcome', { errors }); + throw new AppError('Failed to fetch applicant welcome message', 500); } return content; } - /** - * Check if a user is a moderator - */ async checkModerator( octokit: Octokit, owner: string, @@ -273,19 +248,19 @@ export class ChecklistService { username: string ): Promise { try { - const { data: moderatorsContent, errors } = await this.githubApiService.getRepoContent( - octokit as unknown as import('@octokit/rest').Octokit, - owner, - repo, - '.github/moderators.md' - ); + const { data: moderatorsContent, errors } = + await this.githubApiService.getRepoContent( + octokit as unknown as import('@octokit/rest').Octokit, + owner, + repo, + '.github/moderators.md' + ); if (errors.length > 0 || !moderatorsContent) { - console.error('Failed to fetch moderators list:', errors.join(', ')); + logger.warn('Failed to fetch moderators list', { errors }); return false; } - // Parse moderators list - each line starting with "- " is a moderator const moderatorList = moderatorsContent .split('\n') .filter((line) => line.startsWith('- ')) @@ -293,8 +268,10 @@ export class ChecklistService { return moderatorList.includes(username); } catch (error) { - console.error('Error checking moderator status:', error); + logger.error('Error checking moderator status', { + error: error instanceof Error ? error.message : error, + }); return false; } } -} +} \ No newline at end of file diff --git a/src/event-badging/services/event-badging.service.ts b/src/event-badging/services/event-badging.service.ts index 68b718f..a59da1d 100644 --- a/src/event-badging/services/event-badging.service.ts +++ b/src/event-badging/services/event-badging.service.ts @@ -12,6 +12,9 @@ import { IEventApplication, } from '../../shared/types'; +import { AppError } from '../../shared/errors'; +import { logger } from '../../shared/logger'; + @Service() export class EventBadgingService { constructor( @@ -22,22 +25,19 @@ export class EventBadgingService { private scoringService: ScoringService ) {} - /** - * Process incoming webhook event - */ async processWebhook(eventName: string, payload: IGitHubWebhookPayload): Promise { - // Only process events related to event badging issues if (!payload.issue?.title.match(/event/i)) { - // Handle non-event related webhooks if (eventName === 'installation' && payload.action === 'new_permissions_accepted') { - console.info('New permissions accepted'); + logger.info('New permissions accepted'); } else if (eventName === '*') { - console.info(`Webhook: ${eventName}.${payload.action} not yet automated or needed`); + logger.info(`Webhook: ${eventName}.${payload.action} not yet automated or needed`); } return; } - const octokit = await this.githubAppService.getInstallationOctokit(payload.installation.id); + const octokit = await this.githubAppService.getInstallationOctokit( + payload.installation.id + ); const owner = payload.repository.owner.login; const repo = payload.repository.name; @@ -49,13 +49,10 @@ export class EventBadgingService { await this.handleCommentEvent(octokit, payload, owner, repo); break; default: - console.info(`Unhandled event: ${eventName}.${payload.action}`); + logger.info(`Unhandled event: ${eventName}.${payload.action}`); } } - /** - * Handle issue events (opened, assigned, closed) - */ private async handleIssueEvent( octokit: Octokit, payload: IGitHubWebhookPayload, @@ -77,9 +74,6 @@ export class EventBadgingService { } } - /** - * Handle comment events - */ private async handleCommentEvent( octokit: Octokit, payload: IGitHubWebhookPayload, @@ -90,20 +84,15 @@ export class EventBadgingService { const commentBody = payload.comment.body; - // Handle /result command if (commentBody.match('/result')) { await this.handleResultCommand(octokit, payload, owner, repo); } - // Handle /end command if (commentBody.match('/end')) { await this.handleEndCommand(octokit, payload, owner, repo); } } - /** - * Welcome applicant when issue is opened - */ private async handleIssueOpened( octokit: Octokit, payload: IGitHubWebhookPayload, @@ -113,24 +102,27 @@ export class EventBadgingService { if (!payload.issue) return; try { - const welcomeMessage = await this.checklistService.getApplicantWelcome(octokit, owner, repo); + const welcomeMessage = + await this.checklistService.getApplicantWelcome(octokit, owner, repo); await this.githubApiService.createIssueComment( - octokit as unknown as import('@octokit/rest').Octokit, + octokit as any, owner, repo, payload.issue.number, welcomeMessage ); - console.info('Welcome message posted'); + + logger.info('Welcome message posted'); } catch (error) { - console.error('Error posting welcome message:', error); + logger.error('Error posting welcome message', { + error: error instanceof Error ? error.message : error, + }); + + throw new AppError('Failed to post applicant welcome message', 500); } } - /** - * Assign checklist when reviewer is assigned - */ private async handleIssueAssigned( octokit: Octokit, payload: IGitHubWebhookPayload, @@ -140,10 +132,9 @@ export class EventBadgingService { if (!payload.issue || !payload.assignee) return; try { - // Determine checklist type - const checklistType = this.checklistService.getChecklistType(payload.issue.title); + const checklistType = + this.checklistService.getChecklistType(payload.issue.title); - // Generate checklist const checklist = await this.checklistService.generateReviewerChecklist( octokit, owner, @@ -152,42 +143,43 @@ export class EventBadgingService { checklistType ); - // Get reviewer welcome message - const reviewerWelcome = await this.checklistService.getReviewerWelcome(octokit, owner, repo); + const reviewerWelcome = + await this.checklistService.getReviewerWelcome(octokit, owner, repo); - // Create heading and message const heading = `# Checklist for @${payload.assignee.login}`; - const reviewerMessage = `@${payload.assignee.login} ${reviewerWelcome}${checklist}`; + const reviewerMessage = + `@${payload.assignee.login} ${reviewerWelcome}${checklist}`; - // Post checklist comment await this.githubApiService.createIssueComment( - octokit as unknown as import('@octokit/rest').Octokit, + octokit as any, owner, repo, payload.issue.number, `${heading}\n${reviewerMessage}` ); - console.info('Checklist posted for reviewer'); - // Add review-begin label if two reviewers are assigned + logger.info('Checklist posted for reviewer'); + if (payload.issue.assignees.length === 2) { await this.githubApiService.addLabels( - octokit as unknown as import('@octokit/rest').Octokit, + octokit as any, owner, repo, payload.issue.number, ['review-begin'] ); - console.info('review-begin label added'); + + logger.info('review-begin label added'); } } catch (error) { - console.error('Error assigning checklist:', error); + logger.error('Error assigning checklist', { + error: error instanceof Error ? error.message : error, + }); + + throw new AppError('Failed to assign reviewer checklist', 500); } } - /** - * Save event when issue is closed - */ private async handleIssueClosed( octokit: Octokit, payload: IGitHubWebhookPayload, @@ -197,7 +189,6 @@ export class EventBadgingService { if (!payload.issue) return; try { - // Calculate badge const badgeResult = await this.scoringService.calculateBadge( octokit, owner, @@ -207,48 +198,34 @@ export class EventBadgingService { repo ); - // Extract event name from title const eventName = payload.issue.title.replace(/\[(.*?)\] /gi, ''); - // Extract event URL from body let eventUrl = ''; - if (payload.issue.title.includes('[Virtual Event]')) { - const startMarker = '- Link to the Event Website: '; - const endMarker = '- Provide verification that you are an event organizer: '; - const startIdx = payload.issue.body.indexOf(startMarker); - const endIdx = payload.issue.body.indexOf(endMarker); - if (startIdx !== -1 && endIdx !== -1) { - eventUrl = payload.issue.body.slice(startIdx, endIdx - 2).replace(startMarker, ''); - } - } else if (payload.issue.title.includes('[In-Person Event]')) { - const startMarker = '- Link to the Event Website: '; - const endMarker = '- Are you an organizer '; - const startIdx = payload.issue.body.indexOf(startMarker); - const endIdx = payload.issue.body.indexOf(endMarker); - if (startIdx !== -1 && endIdx !== -1) { - eventUrl = payload.issue.body.slice(startIdx, endIdx - 2).replace(startMarker, ''); - } + + const body = payload.issue.body || ''; + const match = body.match(/Link to the Event Website:\s*(.+)$/mi); + + logger.info(`Regex match result: ${JSON.stringify(match)}`); + + if (match) { + eventUrl = match[1]; } - // Create badge object const badge: IEventBadge = { name: badgeResult.assigned_badge, badgeURL: badgeResult.badge_URL, }; - // Create reviewers array - const reviewers: IEventReviewer[] = payload.issue.assignees.map((assignee) => ({ - name: assignee.login, - github_profile_link: assignee.html_url, + const reviewers: IEventReviewer[] = payload.issue.assignees.map((a) => ({ + name: a.login, + github_profile_link: a.html_url, })); - // Create application object const application: IEventApplication = { app_no: payload.issue.number, app_URL: payload.issue.html_url, }; - // Save event to database await this.eventRepository.createEvent({ event_name: eventName, event_URL: eventUrl, @@ -257,15 +234,16 @@ export class EventBadgingService { application, }); - console.info('Event saved to database'); + logger.info('Event saved to database', { eventUrl }); } catch (error) { - console.error('Error saving event:', error); + logger.error('Error saving event', { + error: error instanceof Error ? error.message : error, + }); + + throw new AppError('Failed to persist event after closure', 500); } } - /** - * Handle /result command - post review results - */ private async handleResultCommand( octokit: Octokit, payload: IGitHubWebhookPayload, @@ -289,21 +267,23 @@ export class EventBadgingService { `\nNumber of reviewers: ${badgeResult.reviewerCount}\n`; await this.githubApiService.createIssueComment( - octokit as unknown as import('@octokit/rest').Octokit, + octokit as any, owner, repo, payload.issue.number, message ); - console.info('Results posted'); + + logger.info('Results posted'); } catch (error) { - console.error('Error posting results:', error); + logger.error('Error posting results', { + error: error instanceof Error ? error.message : error, + }); + + throw new AppError('Failed to post review results', 500); } } - /** - * Handle /end command - end review and assign badge - */ private async handleEndCommand( octokit: Octokit, payload: IGitHubWebhookPayload, @@ -326,44 +306,50 @@ export class EventBadgingService { `\n**Markdown Badge Link:**\n\`\`\`\n${badgeResult.markdownBadgeImage}\n\`\`\`` + `\n**HTML Badge Link:**\n\`\`\`\n${badgeResult.htmlBadgeImage}\n\`\`\``; - // Remove review-begin label await this.githubApiService.removeLabel( - octokit as unknown as import('@octokit/rest').Octokit, + octokit as any, owner, repo, payload.issue.number, 'review-begin' ); - // Add review-end label await this.githubApiService.addLabels( - octokit as unknown as import('@octokit/rest').Octokit, + octokit as any, owner, repo, payload.issue.number, ['review-end'] ); - // Post badge comment await this.githubApiService.createIssueComment( - octokit as unknown as import('@octokit/rest').Octokit, + octokit as any, owner, repo, payload.issue.number, `${badgeResult.markdownBadgeImage}${message}` ); - // Close issue await this.githubApiService.closeIssue( - octokit as unknown as import('@octokit/rest').Octokit, + octokit as any, owner, repo, payload.issue.number ); - console.info('Review ended and issue closed'); + logger.info('Review ended and issue closed'); } catch (error) { - console.error('Error ending review:', error); + logger.error('Error ending review', { + error: error instanceof Error ? error.message : error, + }); + + throw new AppError('Failed to complete review end flow', 500); } } } + + + + + + diff --git a/src/event-badging/services/scoring.service.ts b/src/event-badging/services/scoring.service.ts index 5b6e4b1..82fe19f 100644 --- a/src/event-badging/services/scoring.service.ts +++ b/src/event-badging/services/scoring.service.ts @@ -1,7 +1,10 @@ import { Service } from 'typedi'; import { Octokit } from 'octokit'; +import { getEnvVar, isDevelopment } from '../../shared/config/environment'; import { GitHubApiService } from '../../shared/providers/github/github-api.service'; import { BadgeLevel, IBadgeCalculationResult } from '../../shared/types'; +import { AppError } from '../../shared/errors'; +import { logger } from '../../shared/logger'; @Service() export class ScoringService { @@ -18,59 +21,75 @@ export class ScoringService { issueUrl: string, repoName: string ): Promise { - // Determine initial check count based on repository + const targetRepoName = isDevelopment() + ? 'sandbox-event-dei' + : getEnvVar('REPOSITORY_NAME'); + let initialCheckCount = 6; - if (repoName === 'event-diversity-and-inclusion') { + if (repoName === targetRepoName) { initialCheckCount = 4; } - // Get all comments on the issue - const { data: comments, errors } = await this.githubApiService.listIssueComments( - octokit as unknown as import('@octokit/rest').Octokit, - owner, - repo, - issueNumber - ); + const { data: comments, errors } = + await this.githubApiService.listIssueComments( + octokit as unknown as import('@octokit/rest').Octokit, + owner, + repo, + issueNumber + ); if (errors.length > 0 || !comments) { - throw new Error(`Failed to get comments: ${errors.join(', ')}`); + logger.error('Failed to fetch issue comments for badge calculation', { + owner, + repo, + issueNumber, + errors, + }); + + throw new AppError( + `Failed to get comments: ${errors.join(', ')}`, + 502, + true + ); } - // Filter comments that are checklists (from bots, starting with "# Checklist for") const checklists = comments.filter( (comment) => - comment.user.type === 'Bot' && comment.body.substring(0, 15) === '# Checklist for' + comment.user.type === 'Bot' && + comment.body.substring(0, 15) === '# Checklist for' ); if (checklists.length === 0) { return this.createBadgeResult(0, 0, issueUrl); } - // Calculate total check count for each checklist const totalCheckCounts = checklists.map((checklist) => { const checked = (checklist.body.match(/\[x\]/g) || []).length; const unchecked = (checklist.body.match(/\[ \]/g) || []).length; return checked + unchecked - initialCheckCount; }); - // Calculate positive check count for each checklist const positiveCheckCounts = checklists.map((checklist) => { const checked = (checklist.body.match(/\[x\]/g) || []).length - initialCheckCount; return checked <= 0 ? 0 : checked; }); - // Calculate percentages const percentages = positiveCheckCounts.map((positive, index) => { const total = totalCheckCounts[index]; if (total <= 0) return 0; return Math.floor((positive / total) * 100); }); - // Calculate average review result const reviewerCount = percentages.length; const reviewResult = percentages.reduce((sum, percentage) => sum + percentage, 0) / reviewerCount; + logger.info('Badge calculated successfully', { + issueUrl, + reviewerCount, + reviewResult, + }); + return this.createBadgeResult(reviewResult, reviewerCount, issueUrl); } @@ -82,7 +101,6 @@ export class ScoringService { reviewerCount: number, issueUrl: string ): IBadgeCalculationResult { - // Determine badge level let badgeLevel: BadgeLevel; let badgeCode: string; @@ -103,7 +121,6 @@ export class ScoringService { badgeCode = 'D%26I-Pending-red'; } - // Build badge URL with CHAOSS logo const logoBase64 = 'PHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDI1MCAyNTAiPgo8cGF0aCBmaWxsPSIjMUM5QkQ2IiBkPSJNOTcuMSw0OS4zYzE4LTYuNywzNy44LTYuOCw1NS45LTAuMmwxNy41LTMwLjJjLTI5LTEyLjMtNjEuOC0xMi4yLTkwLjgsMC4zTDk3LjEsNDkuM3oiLz4KPHBhdGggZmlsbD0iIzZBQzdCOSIgZD0iTTE5NC42LDMyLjhMMTc3LjIsNjNjMTQuOCwxMi4zLDI0LjcsMjkuNSwyNy45LDQ4LjVoMzQuOUMyMzYuMiw4MC4yLDIxOS45LDUxLjcsMTk0LjYsMzIuOHoiLz4KPHBhdGggZmlsbD0iI0JGOUNDOSIgZD0iTTIwNC45LDEzOS40Yy03LjksNDMuOS00OS45LDczLTkzLjgsNjUuMWMtMTMuOC0yLjUtMjYuOC04LjYtMzcuNS0xNy42bC0yNi44LDIyLjQKCWM0Ni42LDQzLjQsMTE5LjUsNDAuOSwxNjIuOS01LjdjMTYuNS0xNy43LDI3LTQwLjIsMzAuMS02NC4ySDIwNC45eiIvPgo8cGF0aCBmaWxsPSIjRDYxRDVGIiBkPSJNNTUuNiwxNjUuNkMzNS45LDEzMS44LDQzLjMsODguOCw3My4xLDYzLjVMNTUuNywzMy4yQzcuNSw2OS44LTQuMiwxMzcuNCwyOC44LDE4OEw1NS42LDE2NS42eiIvPgo8L3N2Zz4K'; @@ -123,4 +140,4 @@ export class ScoringService { badge_URL: badgeUrl, }; } -} +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 947d8c8..5be7373 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,65 +1,44 @@ import 'reflect-metadata'; import 'dotenv/config'; -import SmeeClient from 'smee-client'; import { createApp } from './app'; -import { Database } from './shared/data-access/database'; -import { Container } from 'typedi'; +import { initializeDatabase, disconnectDatabase } from './shared/data-access/database.bootstrap'; +import { startSmee } from './dev/smee'; import { getEnvVar, isDevelopment } from './shared/config/environment'; +import {logger} from './shared/logger'; async function bootstrap(): Promise { const PORT = parseInt(getEnvVar('PORT', '5000'), 10); const APP_URL = getEnvVar('APP_URL', `http://localhost:${PORT}`); + const WEBHOOK_PATH = getEnvVar('WEBHOOK_PATH', '/api/event_badging'); - try { - // Initialize database - console.log('Connecting to database...'); - const database = Container.get(Database); - await database.connect(); - console.log('Database connected successfully'); + await initializeDatabase(); - // Create and configure Express app - const app = createApp(); + const app = createApp(); - // Setup Smee.io for webhook forwarding in development - if (isDevelopment()) { - const SMEE_WEBHOOK_URL = getEnvVar('SMEE_WEBHOOK_URL', ''); - if (SMEE_WEBHOOK_URL) { - const smee = new SmeeClient({ - source: SMEE_WEBHOOK_URL, - target: `${APP_URL}/api/event_badging`, - logger: console, - }); - - const events = smee.start(); - console.log(`Smee client forwarding from ${SMEE_WEBHOOK_URL}`); - - // Handle cleanup on exit - process.on('SIGINT', () => { - events.close(); - process.exit(0); - }); - - process.on('SIGTERM', () => { - events.close(); - process.exit(0); - }); - } - } + let stopSmee = () => {}; + if (isDevelopment()) { + stopSmee = startSmee(APP_URL, WEBHOOK_PATH); + } - // Start server - app.listen(PORT, () => { - console.log(`DEI Badging API server running on port ${PORT}`); - console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); - console.log(`App URL: ${APP_URL}`); + const server = app.listen(PORT, () => { + logger.info('Server started at', { url: APP_URL }); + }); + + const shutdown = () => { + stopSmee(); + server.close(async () => { + + await disconnectDatabase(); + process.exit(0); + }); - } catch (error) { - console.error('Failed to start server:', error); - process.exit(1); - } + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); } -// Start the application bootstrap().catch((error) => { - console.error('Bootstrap error:', error); + logger.error('Fatal startup error', error); process.exit(1); }); diff --git a/src/middleware/globalErrorHandler.ts b/src/middleware/globalErrorHandler.ts new file mode 100644 index 0000000..d690a62 --- /dev/null +++ b/src/middleware/globalErrorHandler.ts @@ -0,0 +1,48 @@ +import { Request, Response, NextFunction } from 'express'; +import { logger } from '../shared/logger'; +import { AppError } from '../shared/errors'; + +export function globalErrorHandler( + err: any, + req: Request, + res: Response, + next: NextFunction +) { + const statusCode = err?.statusCode || 500; + + const isAppError = err instanceof AppError; + + + const cause = + err?.cause instanceof Error + ? { + name: err.cause.name, + message: err.cause.message, + stack: err.cause.stack, + } + : err?.cause; + + logger.error(isAppError ? 'API Error' : 'System Error', { + name: err?.name, + message: err?.message, + stack: err?.stack, + path: req.path, + method: req.method, + statusCode, + context: err?.context ?? null, + cause: cause ?? null, + }); + + if (res.headersSent) { + return next(err); + } + + return res.status(statusCode).json({ + status: 'error', + message: + statusCode === 500 + ? 'Internal server error' + : err?.message || 'Unknown error', + context: err?.context ?? null, + }); +} \ No newline at end of file diff --git a/src/middleware/index.ts b/src/middleware/index.ts new file mode 100644 index 0000000..3fef4b3 --- /dev/null +++ b/src/middleware/index.ts @@ -0,0 +1,2 @@ +export { globalErrorHandler } from './globalErrorHandler'; +export { requestLogger } from './requestLogger'; \ No newline at end of file diff --git a/src/middleware/requestLogger.ts b/src/middleware/requestLogger.ts new file mode 100644 index 0000000..d16b8d3 --- /dev/null +++ b/src/middleware/requestLogger.ts @@ -0,0 +1,20 @@ +import { Request, Response, NextFunction } from 'express'; +import { logger } from '../shared/logger'; + +export function requestLogger(req: Request, res: Response, next: NextFunction) { + const start = Date.now(); + + res.on('finish', () => { + const duration = Date.now() - start; + + logger.info('HTTP Request', { + method: req.method, + path: req.path, + statusCode: res.statusCode, + durationMs: duration, + userAgent: req.headers['user-agent'], + }); + }); + + next(); +} \ No newline at end of file diff --git a/src/project-badging/controllers/project-badging.controller.ts b/src/project-badging/controllers/project-badging.controller.ts index d8607e2..66775ee 100644 --- a/src/project-badging/controllers/project-badging.controller.ts +++ b/src/project-badging/controllers/project-badging.controller.ts @@ -1,16 +1,32 @@ -import { JsonController, Get, Post, Body, Res } from 'routing-controllers'; +import { + JsonController, + Get, + Post, + Body, + Res, + QueryParam, + ContentType, +} from 'routing-controllers'; import { Service } from 'typedi'; import { Response } from 'express'; + import { ProjectBadgingService } from '../services/project-badging.service'; import { UserRepository } from '../../shared'; import { Provider } from '../../shared/types'; +import { AppError } from '../../shared/errors'; +import { logger } from '../../shared/logger'; + + interface RepoToBadgeRequest { userId: number; provider: Provider; repos: Array<{ id: number; fullName?: string }>; } + + + @JsonController('/api') @Service() export class ProjectBadgingController { @@ -19,25 +35,17 @@ export class ProjectBadgingController { private userRepository: UserRepository ) {} - /** - * GET /api/badgedRepos - * Get all badged repositories - */ @Get('/badgedRepos') async getBadgedRepos(@Res() response: Response): Promise { - try { - const repos = await this.projectBadgingService.getBadgedRepos(); - return response.json(repos); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - return response.status(500).json({ message: 'Error retrieving repos', error: errorMessage }); - } + const repos = await this.projectBadgingService.getBadgedRepos(); + + logger.info('Fetched badged repos', { + count: repos.length, + }); + + return response.json(repos); } - /** - * POST /api/repos-to-badge - * Scan selected repositories and award badges - */ @Post('/repos-to-badge') async reposToBadge( @Body() body: RepoToBadgeRequest, @@ -45,54 +53,73 @@ export class ProjectBadgingController { ): Promise { const { userId, provider, repos: selectedRepos = [] } = body; - // Validate provider + // ------------------------- + // VALIDATION (business errors) + // ------------------------- if (!provider) { - return response.status(400).json({ error: 'provider missing' }); + throw new AppError('Provider is required', 400); } if (provider !== 'github' && provider !== 'gitlab') { - return response.status(400).json({ error: `Unknown provider: ${provider as string}` }); + throw new AppError('Invalid provider', 400); } - // Validate userId if (!userId) { - return response.status(400).json({ error: 'userId missing' }); + throw new AppError('UserId is required', 400); } - // Find user - let user; - try { - user = await this.userRepository.findById(userId); - if (!user) { - return response.status(404).json({ error: 'User not found' }); - } - } catch (error) { - return response.status(500).json({ error: 'Error fetching user data' }); + if (!selectedRepos.length) { + throw new AppError('No repositories selected', 400); } - // Extract repository IDs - const repositoryIds = selectedRepos.map((repo) => repo.id); + // ------------------------- + // USER FETCH (NO try/catch) + // ------------------------- + const user = await this.userRepository.findById(userId); - if (repositoryIds.length === 0) { - return response.status(400).json({ error: 'No repositories selected' }); + if (!user) { + throw new AppError('User not found', 404); } - try { - // Scan repositories and award badges - const results = await this.projectBadgingService.scanRepositories( - user.id, - user.name, - user.email, - repositoryIds, - provider - ); - - return response.status(200).json({ results }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - return response - .status(500) - .json({ error: 'Error processing repositories', details: errorMessage }); - } + // ------------------------- + // BUSINESS LOGIC + // ------------------------- + const repositoryIds = selectedRepos.map((repo) => repo.id); + + logger.info('Starting repo badging process', { + userId, + provider, + repoCount: repositoryIds.length, + }); + + const results = await this.projectBadgingService.scanRepositories( + user.id, + user.name, + user.email, + repositoryIds, + provider + ); + + return response.status(200).json({ results }); + } + + @Get('/project-badging-successful') + @ContentType('text/html') + projectBadgingSuccessful( + @QueryParam('provider') provider: string, + @QueryParam('userId') userId: string, + @QueryParam('name') name: string, + @QueryParam('email') email: string + ) { + return ` + + +

Badge Scan Successful!

+

User: ${name} (${email})

+

Provider: ${provider}

+

User ID: ${userId}

+ + + `; } } diff --git a/src/project-badging/services/badge-award.service.ts b/src/project-badging/services/badge-award.service.ts index 5a204b1..25dd2a3 100644 --- a/src/project-badging/services/badge-award.service.ts +++ b/src/project-badging/services/badge-award.service.ts @@ -1,5 +1,12 @@ import { Service } from 'typedi'; -import { RepoRepository, MailerService, AugurApiService } from '../../shared'; +import { + RepoRepository, + MailerService, + AugurApiService, +} from '../../shared'; + +import { AppError } from '../../shared/errors'; +import { logger } from '../../shared/logger'; const BRONZE_BADGE_URL = 'https://raw.githubusercontent.com/badging/badging/main/src/assets/images/badges/bronze-badge.svg'; @@ -12,9 +19,6 @@ export class BadgeAwardService { private augurApiService: AugurApiService ) {} - /** - * Award bronze badge to a repository - */ async awardBronzeBadge( userId: number, userName: string, @@ -25,14 +29,20 @@ export class BadgeAwardService { deiCommitSHA: string ): Promise { try { - // Generate badge links const markdownLink = `![Bronze Badge](${BRONZE_BADGE_URL})`; - const htmlLink = `<img src="${BRONZE_BADGE_URL}" alt="DEI Badging Bronze Badge" />`; + const htmlLink = `DEI Badging Bronze Badge`; - // Send success email - await this.mailerService.sendBadgingEmail(email, userName, 'Bronze', markdownLink, htmlLink); + await this.mailerService.sendBadgingEmail( + email, + userName, + 'Bronze', + markdownLink, + htmlLink + ); + // console.log( + // `Sending badging email to ${email} with badge links and username: ${userName}, ${markdownLink}, ${htmlLink}` + // ); - // Save repo to database const repoId = await this.repoRepository.saveRepo( githubRepoId, gitlabRepoId, @@ -43,52 +53,111 @@ export class BadgeAwardService { userId ); - if (repoId) { - // Register with Augur API - const augurResponse = await this.augurApiService.registerBadgedRepo( + if (!repoId) { + logger.error('Failed to save repo during badge award', { + userId, + repoUrl, + }); + + return false; + } + + const augurResponse = + await this.augurApiService.registerBadgedRepo( repoId, 'bronze', repoUrl ); - console.log('Augur API response:', augurResponse); - return true; - } - return false; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - console.error('Error awarding bronze badge:', errorMessage); - return false; + logger.info('Augur API response', { + repoId, + response: augurResponse, + }); + + return true; + } catch (error: unknown) { + logger.error('Badge award failed', { + error: error instanceof Error ? error.message : error, + userId, + repoUrl, + }); + + throw new AppError( + 'Badge awarding process failed', + 500, + error, + { + userId, + repoUrl, + } + ); } } - /** - * Send failure notification for missing DEI sections - */ async sendFailureNotification( email: string, userName: string, missingSections: string[] ): Promise { - const results = missingSections.map((section) => `${section} is missing`).join('\n'); - await this.mailerService.sendBadgingEmail(email, userName, 'Bronze', null, null, results); + try { + const results = missingSections + .map((section) => `${section} is missing`) + .join('\n'); + + await this.mailerService.sendBadgingEmail( + email, + userName, + 'Bronze', + null, + null, + results + ); + // console.log( + // `Sending failure notification email to ${email} and ${userName} with missing sections: ${results}` + // ); + } catch (error: unknown) { + logger.error('Failure notification email failed', { + error: error instanceof Error ? error.message : error, + email, + }); + + throw new AppError( + 'Failed to send failure notification', + 500, + error + ); + } } - /** - * Send generic error notification - */ async sendErrorNotification( email: string, userName: string, errorMessages: string[] ): Promise { - await this.mailerService.sendBadgingEmail( - email, - userName, - 'Bronze', - null, - null, - errorMessages.join('\n') - ); + try { + // console.log( + // `Sending error notification email to ${email} and ${userName} with error messages: ${errorMessages.join(', ')}` + // ); + + await this.mailerService.sendBadgingEmail( + email, + userName, + 'Bronze', + null, + null, + errorMessages.join('\n') + ); + } catch (error: unknown) { + logger.error('Error notification email failed', { + error: error instanceof Error ? error.message : error, + email, + }); + + throw new AppError( + 'Failed to send error notification', + 500, + error + ); + } } -} +} \ No newline at end of file diff --git a/src/project-badging/services/dei-scanner.service.ts b/src/project-badging/services/dei-scanner.service.ts index fa278d0..88d7a2c 100644 --- a/src/project-badging/services/dei-scanner.service.ts +++ b/src/project-badging/services/dei-scanner.service.ts @@ -1,5 +1,6 @@ import { Service } from 'typedi'; import { DEI_REQUIRED_SECTIONS, DEISection } from '../../shared/types'; +import { logger } from '../../shared/logger'; export interface ILocalDEIScanResult { isValid: boolean; @@ -44,7 +45,7 @@ export class DEIScannerService { ); return Buffer.from(response.data.content, 'base64').toString(); } catch (error) { - console.error('Error fetching DEI template:', error); + logger.error('Error fetching DEI template:', error); return null; } } diff --git a/src/project-badging/services/project-badging.service.ts b/src/project-badging/services/project-badging.service.ts index 909aaab..c72d1c8 100644 --- a/src/project-badging/services/project-badging.service.ts +++ b/src/project-badging/services/project-badging.service.ts @@ -4,6 +4,7 @@ import { GitHubApiService, GitLabApiService, RepoRepository } from '../../shared import { DEIScannerService, ILocalDEIScanResult } from './dei-scanner.service'; import { BadgeAwardService } from './badge-award.service'; import { Provider } from '../../shared/types'; +import { AppError } from '../../shared/errors'; export interface IScanResult { repoUrl: string; @@ -21,9 +22,6 @@ export class ProjectBadgingService { private badgeAwardService: BadgeAwardService ) {} - /** - * Scan repositories and award badges - */ async scanRepositories( userId: number, userName: string, @@ -33,21 +31,45 @@ export class ProjectBadgingService { ): Promise { const results: IScanResult[] = []; - // Get DEI template for comparison const template = await this.deiScannerService.getDEITemplate(); for (const repoId of repositoryIds) { - if (provider === 'github') { - const result = await this.scanGitHubRepository(userId, userName, email, repoId, template); - results.push(result); - } else if (provider === 'gitlab') { - const result = await this.scanGitLabRepository(userId, userName, email, repoId, template); - results.push(result); + try { + if (provider === 'github') { + const result = await this.scanGitHubRepository( + userId, + userName, + email, + repoId, + template + ); + + results.push(result); + } else if (provider === 'gitlab') { + const result = await this.scanGitLabRepository( + userId, + userName, + email, + repoId, + template + ); + + results.push(result); + } + } catch (error) { + results.push({ + repoUrl: `Repository ID: ${repoId}`, + success: false, + message: + error instanceof AppError + ? error.message + : 'Unexpected error scanning repository', + }); } } - // Send error notification for repositories that couldn't be badged const errorResults = results.filter((r) => !r.success); + if (errorResults.length > 0) { await this.badgeAwardService.sendErrorNotification( email, @@ -59,9 +81,6 @@ export class ProjectBadgingService { return results; } - /** - * Scan a single GitHub repository - */ private async scanGitHubRepository( userId: number, userName: string, @@ -71,33 +90,28 @@ export class ProjectBadgingService { ): Promise { const octokit = new Octokit(); - // Get repository info - const { data: repoInfo, errors: infoErrors } = await this.githubApiService.getRepositoryInfo( - octokit, - repositoryId - ); + const { data: repoInfo, errors: infoErrors } = + await this.githubApiService.getRepositoryInfo(octokit, repositoryId); if (infoErrors.length > 0 || !repoInfo) { - return { - repoUrl: `Repository ID: ${repositoryId}`, - success: false, - message: infoErrors.join(', ') || 'Failed to get repository info', - }; + throw new AppError( + infoErrors.join(', ') || 'Failed to get repository info', + 404 + ); } - // Get DEI.md file - const { data: file, errors: fileErrors } = await this.githubApiService.getFileContentAndSHA( - octokit, - repoInfo.fullName!, - 'DEI.md' - ); + const { data: file, errors: fileErrors } = + await this.githubApiService.getFileContentAndSHA( + octokit, + repoInfo.fullName!, + 'DEI.md' + ); if (fileErrors.length > 0 || !file) { - return { - repoUrl: repoInfo.url, - success: false, - message: `${repoInfo.url} does not have a DEI.md file`, - }; + throw new AppError( + `${repoInfo.url} does not have a DEI.md file`, + 400 + ); } return this.processRepository( @@ -114,9 +128,6 @@ export class ProjectBadgingService { ); } - /** - * Scan a single GitLab repository - */ private async scanGitLabRepository( userId: number, userName: string, @@ -124,31 +135,28 @@ export class ProjectBadgingService { repositoryId: number, template: string | null ): Promise { - // Get repository info const { data: repoInfo, errors: infoErrors } = await this.gitlabApiService.getRepositoryInfo(repositoryId); if (infoErrors.length > 0 || !repoInfo) { - return { - repoUrl: `Repository ID: ${repositoryId}`, - success: false, - message: infoErrors.join(', ') || 'Failed to get repository info', - }; + throw new AppError( + infoErrors.join(', ') || 'Failed to get repository info', + 404 + ); } - // Get DEI.md file - const { data: file, errors: fileErrors } = await this.gitlabApiService.getFileContentAndSHA( - repositoryId, - 'DEI.md', - repoInfo.defaultBranch! - ); + const { data: file, errors: fileErrors } = + await this.gitlabApiService.getFileContentAndSHA( + repositoryId, + 'DEI.md', + repoInfo.defaultBranch! + ); if (fileErrors.length > 0 || !file) { - return { - repoUrl: repoInfo.url, - success: false, - message: `${repoInfo.url} does not have a DEI.md file`, - }; + throw new AppError( + `${repoInfo.url} does not have a DEI.md file`, + 400 + ); } return this.processRepository( @@ -165,9 +173,6 @@ export class ProjectBadgingService { ); } - /** - * Process a repository for badging - */ private async processRepository( userId: number, userName: string, @@ -180,79 +185,71 @@ export class ProjectBadgingService { template: string | null, _provider: Provider ): Promise { - try { - // Check if already badged with same SHA - let existingRepo = null; - if (githubRepoId) { - existingRepo = await this.repoRepository.findByGithubRepoAndSHA(githubRepoId, deiCommitSHA); - } else if (gitlabRepoId) { - existingRepo = await this.repoRepository.findByGitlabRepoAndSHA(gitlabRepoId, deiCommitSHA); - } - - if (existingRepo) { - return { - repoUrl, - success: false, - message: `${repoUrl} was already badged`, - }; - } + let existingRepo = null; - // Check if content is just the template - if (template && this.deiScannerService.isTemplateContent(deiContent, template)) { - return { - repoUrl, - success: false, - message: `Please provide DEI information specific to ${repoUrl} by editing the template`, - }; - } + if (githubRepoId) { + existingRepo = await this.repoRepository.findByGithubRepoAndSHA( + githubRepoId, + deiCommitSHA + ); + } else if (gitlabRepoId) { + existingRepo = await this.repoRepository.findByGitlabRepoAndSHA( + gitlabRepoId, + deiCommitSHA + ); + } - // Scan DEI content for required sections - const scanResult: ILocalDEIScanResult = this.deiScannerService.scanDEIContent(deiContent); + if (existingRepo) { + throw new AppError(`${repoUrl} was already badged`, 409); + } - if (!scanResult.isValid) { - // Send failure notification with missing sections - await this.badgeAwardService.sendFailureNotification( - email, - userName, - scanResult.missingSections - ); + if ( + template && + this.deiScannerService.isTemplateContent(deiContent, template) + ) { + throw new AppError( + `Please provide DEI information specific to ${repoUrl} by editing the template`, + 400 + ); + } - return { - repoUrl, - success: false, - message: `Missing sections: ${scanResult.missingSections.join(', ')}`, - }; - } + const scanResult: ILocalDEIScanResult = + this.deiScannerService.scanDEIContent(deiContent); - // Award badge - const badgeAwarded = await this.badgeAwardService.awardBronzeBadge( - userId, - userName, + if (!scanResult.isValid) { + await this.badgeAwardService.sendFailureNotification( email, - githubRepoId, - gitlabRepoId, - repoUrl, - deiCommitSHA + userName, + scanResult.missingSections + ); + + throw new AppError( + `Missing sections: ${scanResult.missingSections.join(', ')}`, + 400 ); + } + + const badgeAwarded = await this.badgeAwardService.awardBronzeBadge( + userId, + userName, + email, + githubRepoId, + gitlabRepoId, + repoUrl, + deiCommitSHA + ); - return { - repoUrl, - success: badgeAwarded, - message: badgeAwarded ? 'Badge awarded successfully' : 'Failed to award badge', - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - return { - repoUrl, - success: false, - message: errorMessage, - }; + if (!badgeAwarded) { + throw new AppError('Failed to award badge', 500); } + + return { + repoUrl, + success: true, + message: 'Badge awarded successfully', + }; } - /** - * Get all badged repositories - */ async getBadgedRepos(): Promise< Array<{ id: number; @@ -267,6 +264,7 @@ export class ProjectBadgingService { }> > { const repos = await this.repoRepository.getAllBadgedRepos(); + return repos.map((repo) => ({ id: repo.id, githubRepoId: repo.githubRepoId, @@ -279,4 +277,4 @@ export class ProjectBadgingService { userId: repo.userId, })); } -} +} \ No newline at end of file diff --git a/src/scripts/configure.ts b/src/scripts/configure.ts index 99ee7f0..715f05b 100644 --- a/src/scripts/configure.ts +++ b/src/scripts/configure.ts @@ -1,12 +1,13 @@ import { input, password } from '@inquirer/prompts'; import * as fs from 'fs'; import * as path from 'path'; +import { logger } from '../shared/logger'; interface ConfigValues { // Database configuration db_name: string; db_user: string; - db_password: string; + db_password?: string; db_host: string; db_port: string; @@ -45,24 +46,30 @@ interface ConfigValues { encrypt_key: string; // Smee (development) - smee_webhook_url: string; + smee_client_url: string; + webhook_path: string; + + // Repository for event badging + repository_name: string; + repository_owner: string; + } async function configure(): Promise { const envPath = path.resolve(__dirname, '../../.env'); if (fs.existsSync(envPath)) { - console.info('.env file already exists. Delete it first if you want to reconfigure.'); + logger.info('.env file already exists. Delete it first if you want to reconfigure.'); return; } - console.info('Please input the fields below to configure your project locally\n'); + logger.info('Please input the fields below to configure your project locally\n'); const values: ConfigValues = { // Database configuration db_name: await input({ message: 'Database name:', default: 'badging' }), db_user: await input({ message: 'Database user name:', default: 'postgres' }), - db_password: await password({ message: 'Database password:', mask: true }), + db_password: await password({ message: 'Database password (optional):', mask: true }) || undefined, db_host: await input({ message: 'Database host address:', default: 'localhost' }), db_port: await input({ message: 'Database port:', default: '5432' }), @@ -107,10 +114,18 @@ async function configure(): Promise { }), // Smee (development) - smee_webhook_url: await input({ + smee_client_url: await input({ message: 'Smee.io webhook URL (for development):', default: '', }), + + webhook_path: await input({message: 'webhook path:'}), + + + + // Repository for event badging + repository_name: await input({ message: 'Repository name for event badging:' }), + repository_owner: await input({ message: 'Repository owner for event badging:' }), }; const envFile = `# Server Configuration @@ -123,7 +138,8 @@ DB_HOST=${values.db_host} DB_PORT=${values.db_port} DB_NAME=${values.db_name} DB_USER=${values.db_user} -DB_PASSWORD=${values.db_password} +${values.db_password ? `DB_PASSWORD=${values.db_password}` : ''} +//DB_PASSWORD=${values.db_password} # GitHub OAuth (for project badging) GITHUB_CLIENT_ID=${values.github_auth_client_id} @@ -155,14 +171,19 @@ NODEMAILER_ADDRESS=${values.nodemailer_address} ENCRYPT_KEY=${values.encrypt_key} # Smee (development webhook forwarding) -SMEE_WEBHOOK_URL=${values.smee_webhook_url} +SMEE_CLIENT_URL=${values.smee_client_url} +WEBHOOK_PATH=${values.webhook_path} + +# Repository for event badging +REPOSITORY_NAME=${values.repository_name} +REPOSITORY_OWNER=${values.repository_owner} `; fs.writeFileSync(envPath, envFile); - console.info('\n✅ Configuration file (.env) created successfully at project root.'); + logger.info('\n Configuration file (.env) created successfully at project root.'); } configure().catch((error) => { - console.error('Configuration failed:', error); + logger.error('Configuration failed:', error); process.exit(1); }); diff --git a/src/shared/config/environment.ts b/src/shared/config/environment.ts index 95166e4..95b2dd5 100644 --- a/src/shared/config/environment.ts +++ b/src/shared/config/environment.ts @@ -10,9 +10,9 @@ export interface IEnvironmentConfig { DB_HOST: string; DB_NAME: string; DB_USER: string; - DB_PASSWORD: string; + DB_PASSWORD?: string; DB_DIALECT: 'mysql' | 'postgres' | 'sqlite' | 'mariadb'; - + TEST_DB_NAME?: string; // GitHub OAuth (Project Badging) GITHUB_AUTH_CLIENT_ID: string; GITHUB_AUTH_CLIENT_SECRET: string; @@ -27,12 +27,19 @@ export interface IEnvironmentConfig { GITHUB_APP_CLIENT_ID: string; GITHUB_APP_CLIENT_SECRET: string; GITHUB_APP_WEBHOOK_SECRET: string; + GITHUB_APP_INSTALLATION_ID: string; + GITHUB_APP_INSTALLATION_TOKEN: string; // GitLab OAuth GITLAB_APP_CLIENT_ID: string; GITLAB_APP_CLIENT_SECRET: string; GITLAB_APP_REDIRECT_URI: string; + //repository for event badging + REPOSITORY_NAME: string; + REPOSITORY_OWNER: string; + + // Email EMAIL_HOST: string; EMAIL_ADDRESS: string; diff --git a/src/shared/controllers/index.ts b/src/shared/controllers/index.ts new file mode 100644 index 0000000..b264e7f --- /dev/null +++ b/src/shared/controllers/index.ts @@ -0,0 +1 @@ +export { SystemController } from './system.controller'; \ No newline at end of file diff --git a/src/shared/controllers/system.controller.ts b/src/shared/controllers/system.controller.ts new file mode 100644 index 0000000..062616e --- /dev/null +++ b/src/shared/controllers/system.controller.ts @@ -0,0 +1,31 @@ +import { JsonController, Get } from 'routing-controllers'; +import { Service } from 'typedi'; + +@JsonController() +@Service() +export class SystemController { + + @Get('/health') + healthCheck() { + return { + status: 'ok', + timestamp: new Date().toISOString() + }; + } + + @Get('/') + root() { + return { + name: 'DEI Badging API', + version: '1.0.0', + endpoints: { + health: '/health', + auth: { + github: '/api/callback', + gitlab: '/api/gitlab/callback', + }, + // ... rest of your metadata + }, + }; + } +} \ No newline at end of file diff --git a/src/shared/data-access/database.bootstrap.ts b/src/shared/data-access/database.bootstrap.ts new file mode 100644 index 0000000..7520558 --- /dev/null +++ b/src/shared/data-access/database.bootstrap.ts @@ -0,0 +1,18 @@ +import { Container } from 'typedi'; +import { Database } from './database'; +import {logger} from '../logger'; + +export async function initializeDatabase(): Promise { + logger.info('Connecting to database...'); + const database = Container.get(Database); + await database.connect(); + logger.info('Database connected'); +} + + +export async function disconnectDatabase(): Promise { + logger.info('Disconnecting from database...'); + const database = Container.get(Database); + await database.disconnect(); + logger.info('Database disconnected'); +} diff --git a/src/shared/data-access/database.ts b/src/shared/data-access/database.ts index b834889..f26c9bc 100644 --- a/src/shared/data-access/database.ts +++ b/src/shared/data-access/database.ts @@ -4,6 +4,7 @@ import { getEnvVar, isDevelopment } from '../config/environment'; import { User } from './models/user.model'; import { Repo } from './models/repo.model'; import { Event } from './models/event.model'; +import { logger } from '../logger'; @Service() export class Database { @@ -13,31 +14,33 @@ export class Database { this.sequelize = new Sequelize({ database: getEnvVar('DB_NAME'), username: getEnvVar('DB_USER'), - password: getEnvVar('DB_PASSWORD'), + password: isDevelopment() ? getEnvVar('DB_PASSWORD', false) : getEnvVar('DB_PASSWORD', true), host: getEnvVar('DB_HOST'), dialect: getEnvVar('DB_DIALECT') as 'mysql' | 'postgres' | 'sqlite' | 'mariadb', + port: parseInt(getEnvVar('DB_PORT', '3306'), 10), models: [User, Repo, Event], - logging: isDevelopment() ? console.log : false, + logging: isDevelopment() + ? (msg) => logger.debug('SQL Query', { query: msg }) + : false, }); } async connect(): Promise { try { await this.sequelize.authenticate(); - console.log('Database connection established successfully.'); + logger.info('Database connection established successfully.'); // In development, force sync to recreate tables // In production, just sync without dropping if (isDevelopment()) { await this.sequelize.sync({ force: true }); - console.log('Database synchronized (force mode).'); + logger.warn('Database synchronized (force mode) for dev only.'); } else { await this.sequelize.sync(); - console.log('Database synchronized.'); + logger.info('Database synchronized.'); } - } catch (error) { - console.error('Unable to connect to the database:', error); - throw error; + } catch (error: unknown) { + throw error; } } diff --git a/src/shared/data-access/index.ts b/src/shared/data-access/index.ts index 4c33c79..c1a4861 100644 --- a/src/shared/data-access/index.ts +++ b/src/shared/data-access/index.ts @@ -1,3 +1,4 @@ export { Database } from './database'; +export { initializeDatabase, disconnectDatabase } from './database.bootstrap'; export { User, Repo, Event } from './models'; export { UserRepository, RepoRepository, EventRepository } from './repositories'; diff --git a/src/shared/data-access/repositories/event.repository.ts b/src/shared/data-access/repositories/event.repository.ts index 3d3d3d3..1875d6b 100644 --- a/src/shared/data-access/repositories/event.repository.ts +++ b/src/shared/data-access/repositories/event.repository.ts @@ -1,6 +1,7 @@ import { Service } from 'typedi'; import { Event } from '../models/event.model'; import { IEventCreationAttributes } from '../../types'; +import { logger } from '../../logger' @Service() export class EventRepository { @@ -32,10 +33,10 @@ export class EventRepository { async createEvent(eventData: IEventCreationAttributes): Promise { try { const newEvent = await this.create(eventData); - console.log(`Event ${newEvent.event_name} created successfully`); + logger.info(`Event ${newEvent.event_name} created successfully`); return newEvent; } catch (error) { - console.error('Error creating event:', error); + logger.error('Error creating event:', error); return null; } } diff --git a/src/shared/data-access/repositories/repo.repository.ts b/src/shared/data-access/repositories/repo.repository.ts index 6755096..ffca9fc 100644 --- a/src/shared/data-access/repositories/repo.repository.ts +++ b/src/shared/data-access/repositories/repo.repository.ts @@ -2,6 +2,7 @@ import { Service } from 'typedi'; import { Repo } from '../models/repo.model'; import { User } from '../models/user.model'; import { IRepoCreationAttributes } from '../../types'; +import { logger } from '../../logger' @Service() export class RepoRepository { @@ -70,7 +71,7 @@ export class RepoRepository { ): Promise { // Validate that exactly one of githubRepoId or gitlabRepoId is provided if ((githubRepoId && gitlabRepoId) || (!githubRepoId && !gitlabRepoId)) { - console.error('Error creating repo: provide either githubRepoId or gitlabRepoId'); + logger.error('Error creating repo: provide either githubRepoId or gitlabRepoId'); return null; } @@ -95,7 +96,7 @@ export class RepoRepository { return repo.id; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - console.error('Error saving repo:', errorMessage); + logger.error('Error saving repo:', errorMessage); return null; } } diff --git a/src/shared/data-access/repositories/user.repository.ts b/src/shared/data-access/repositories/user.repository.ts index 708f779..138faf5 100644 --- a/src/shared/data-access/repositories/user.repository.ts +++ b/src/shared/data-access/repositories/user.repository.ts @@ -1,6 +1,7 @@ import { Service } from 'typedi'; import { User } from '../models/user.model'; import { IUserCreationAttributes } from '../../types'; +import { logger } from '../../logger' @Service() export class UserRepository { @@ -53,7 +54,7 @@ export class UserRepository { ): Promise { // Validate that exactly one of githubId or gitlabId is provided if ((githubId && gitlabId) || (!githubId && !gitlabId)) { - console.error('Error creating user: provide either githubId or gitlabId'); + logger.error('Error creating user: provide either githubId or gitlabId'); return null; } @@ -76,7 +77,7 @@ export class UserRepository { githubId, gitlabId, }); - console.log(`New user created: ${user.login}`); + logger.info(`New user created: ${user.login}`); return user; } @@ -95,15 +96,15 @@ export class UserRepository { if (Object.keys(updates).length > 0) { await this.update(user, updates); - console.log(`User ${user.login} updated: ${Object.keys(updates).join(', ')}`); + logger.info(`User ${user.login} updated: ${Object.keys(updates).join(', ')}`); } else { - console.log('User already exists'); + logger.info('User already exists'); } return user; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - console.error(`Error saving user: ${errorMessage}`); + logger.error(`Error saving user: ${errorMessage}`); return null; } } diff --git a/src/shared/errors/appError.ts b/src/shared/errors/appError.ts new file mode 100644 index 0000000..f549465 --- /dev/null +++ b/src/shared/errors/appError.ts @@ -0,0 +1,22 @@ +export class AppError extends Error { + public statusCode: number; + public context?: Record; + + constructor( + message: string, + statusCode = 500, + cause?: unknown, + context?: Record + ) { + super(message, { cause }); + + this.statusCode = statusCode; + this.context = context; + + // fixes prototype chain (important for instanceof checks) + Object.setPrototypeOf(this, new.target.prototype); + + // ensures proper stack trace + Error.captureStackTrace(this, this.constructor); + } +} \ No newline at end of file diff --git a/src/shared/errors/index.ts b/src/shared/errors/index.ts new file mode 100644 index 0000000..117e0de --- /dev/null +++ b/src/shared/errors/index.ts @@ -0,0 +1 @@ +export { AppError } from './appError'; \ No newline at end of file diff --git a/src/shared/index.ts b/src/shared/index.ts index a06c1e4..94d3012 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -4,3 +4,5 @@ export * from './config'; export * from './data-access'; export * from './services'; export * from './providers'; +export * from './logger'; +export * from './errors'; \ No newline at end of file diff --git a/src/shared/logger/index.ts b/src/shared/logger/index.ts new file mode 100644 index 0000000..d8325f6 --- /dev/null +++ b/src/shared/logger/index.ts @@ -0,0 +1 @@ +export {logger} from "./logger"; diff --git a/src/shared/logger/logger.ts b/src/shared/logger/logger.ts new file mode 100644 index 0000000..9e45c50 --- /dev/null +++ b/src/shared/logger/logger.ts @@ -0,0 +1,32 @@ +import { createLogger, format, transports } from 'winston'; +import { isDevelopment } from '../../shared/config/environment'; + +const devFormat = format.printf( + ({ timestamp, level, message, stack, ...meta }) => { + const metaString = + Object.keys(meta).length > 0 + ? `\n${JSON.stringify(meta, null, 2)}` + : ''; + + return `${timestamp} ${level}: ${stack || message}${metaString}`; + } +); + + +export const logger = createLogger({ + level: process.env.LOG_LEVEL || 'info', + + format: format.combine( + format.timestamp({ + format: 'YYYY-MM-DD HH:mm:ss', + }), + + format.errors({ stack: true }), + + isDevelopment() + ? format.combine(format.colorize(), devFormat) + : format.json() + ), + + transports: [new transports.Console()], +}); diff --git a/src/shared/providers/github/github-api.service.ts b/src/shared/providers/github/github-api.service.ts index a54caaa..f7f5975 100644 --- a/src/shared/providers/github/github-api.service.ts +++ b/src/shared/providers/github/github-api.service.ts @@ -7,6 +7,7 @@ import { IRepositoryDetails, IFileContent, } from '../../types'; +import { logger } from '../../logger'; @Service() export class GitHubApiService { @@ -23,7 +24,7 @@ export class GitHubApiService { return { data: { login, - name: name || login, // Fallback to login if name is null + name: name || login, email: email || '', id, }, @@ -31,6 +32,9 @@ export class GitHubApiService { }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + logger.error('Failed to fetch GitHub user info', { error: errorMessage }); + return { data: null, errors: [errorMessage], @@ -73,6 +77,10 @@ export class GitHubApiService { errors: [], }; } catch (error) { + logger.error('Failed to fetch GitHub repositories', { + error: error instanceof Error ? error.message : error, + }); + return { data: null, errors: ['GitHub API returning no repository(ies).'], @@ -91,6 +99,7 @@ export class GitHubApiService { const response = await octokit.request('GET /repositories/{repositoryId}', { repositoryId, }); + const { id, html_url, full_name } = response.data as { id: number; html_url: string; @@ -107,6 +116,12 @@ export class GitHubApiService { }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + logger.error('Failed to fetch repository info', { + repositoryId, + error: errorMessage, + }); + return { data: null, errors: [errorMessage], @@ -114,16 +129,6 @@ export class GitHubApiService { } } - /** - * Get repository info (unauthenticated - by ID) - */ - async getRepositoryInfoPublic( - repositoryId: number - ): Promise> { - const octokit = new Octokit(); - return this.getRepositoryInfo(octokit, repositoryId); - } - /** * Get file content and SHA from a repository */ @@ -141,7 +146,6 @@ export class GitHubApiService { path: filePath, }); - // Type guard - ensure we got a file, not an array of files if (Array.isArray(data) || data.type !== 'file' || !('content' in data)) { return { data: null, @@ -157,25 +161,19 @@ export class GitHubApiService { errors: [], }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Failed to fetch file content from GitHub', { + repositoryFullName, + filePath, + error: error instanceof Error ? error.message : error, + }); + return { data: null, - errors: [errorMessage], + errors: [error instanceof Error ? error.message : 'Unknown error'], }; } } - /** - * Get file content (unauthenticated) - */ - async getFileContentPublic( - repositoryFullName: string, - filePath: string - ): Promise> { - const octokit = new Octokit(); - return this.getFileContentAndSHA(octokit, repositoryFullName, filePath); - } - /** * Create an issue in a repository */ @@ -202,10 +200,16 @@ export class GitHubApiService { errors: [], }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Failed to create GitHub issue', { + owner, + repo, + title, + error: error instanceof Error ? error.message : error, + }); + return { data: null, - errors: [errorMessage], + errors: [error instanceof Error ? error.message : 'Unknown error'], }; } } @@ -233,10 +237,16 @@ export class GitHubApiService { errors: [], }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Failed to create issue comment', { + owner, + repo, + issueNumber, + error: error instanceof Error ? error.message : error, + }); + return { data: null, - errors: [errorMessage], + errors: [error instanceof Error ? error.message : 'Unknown error'], }; } } @@ -271,10 +281,16 @@ export class GitHubApiService { errors: [], }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Failed to list issue comments', { + owner, + repo, + issueNumber, + error: error instanceof Error ? error.message : error, + }); + return { data: null, - errors: [errorMessage], + errors: [error instanceof Error ? error.message : 'Unknown error'], }; } } @@ -299,8 +315,15 @@ export class GitHubApiService { return { data: true, errors: [] }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - return { data: null, errors: [errorMessage] }; + logger.error('Failed to add labels', { + owner, + repo, + issueNumber, + labels, + error: error instanceof Error ? error.message : error, + }); + + return { data: null, errors: [error instanceof Error ? error.message : 'Unknown error'] }; } } @@ -324,8 +347,15 @@ export class GitHubApiService { return { data: true, errors: [] }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - return { data: null, errors: [errorMessage] }; + logger.error('Failed to remove label', { + owner, + repo, + issueNumber, + label, + error: error instanceof Error ? error.message : error, + }); + + return { data: null, errors: [error instanceof Error ? error.message : 'Unknown error'] }; } } @@ -348,8 +378,14 @@ export class GitHubApiService { return { data: true, errors: [] }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - return { data: null, errors: [errorMessage] }; + logger.error('Failed to close issue', { + owner, + repo, + issueNumber, + error: error instanceof Error ? error.message : error, + }); + + return { data: null, errors: [error instanceof Error ? error.message : 'Unknown error'] }; } } @@ -379,8 +415,14 @@ export class GitHubApiService { const content = Buffer.from(data.content, 'base64').toString(); return { data: content, errors: [] }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - return { data: null, errors: [errorMessage] }; + logger.error('Failed to fetch repo content', { + owner, + repo, + path, + error: error instanceof Error ? error.message : error, + }); + + return { data: null, errors: [error instanceof Error ? error.message : 'Unknown error'] }; } } } diff --git a/src/shared/providers/github/github-app.service.ts b/src/shared/providers/github/github-app.service.ts index 2b31f1b..43dbeba 100644 --- a/src/shared/providers/github/github-app.service.ts +++ b/src/shared/providers/github/github-app.service.ts @@ -1,6 +1,7 @@ import { Service } from 'typedi'; import { App, Octokit } from 'octokit'; import { getEnvVar } from '../../config/environment'; +import { logger } from '../../logger'; @Service() export class GitHubAppService { @@ -19,7 +20,7 @@ export class GitHubAppService { const webhookSecret = getEnvVar('GITHUB_APP_WEBHOOK_SECRET', false); if (!appId || !privateKey || !clientId || !clientSecret || !webhookSecret) { - console.warn('GitHub App is not fully configured - webhook handling will be disabled'); + logger.warn('GitHub App is not fully configured - webhook handling will be disabled'); return; } @@ -33,7 +34,7 @@ export class GitHubAppService { webhooks: { secret: webhookSecret }, }); } catch (error) { - console.error('Failed to initialize GitHub App:', error); + throw new Error('GitHub App initialization failed'); } } diff --git a/src/shared/providers/github/github-auth.service.ts b/src/shared/providers/github/github-auth.service.ts index 7d9fa4e..e433580 100644 --- a/src/shared/providers/github/github-auth.service.ts +++ b/src/shared/providers/github/github-auth.service.ts @@ -2,6 +2,7 @@ import { Service } from 'typedi'; import { Octokit } from '@octokit/rest'; import axios from 'axios'; import { getEnvVar } from '../../config/environment'; +import { logger } from '../../logger'; import { IOAuthTokenResponse } from '../../types'; export interface IGitHubAuthConfig { @@ -17,7 +18,7 @@ export class GitHubAuthService { */ getProjectBadgingAuthUrl(): string { const clientId = getEnvVar('GITHUB_AUTH_CLIENT_ID'); - const scopes = ['read:user', 'user:email', 'public_repo']; + const scopes = ['read:user', 'user:email']; return `https://github.com/login/oauth/authorize?client_id=${clientId}&scope=${scopes.join(',')}`; } @@ -27,7 +28,7 @@ export class GitHubAuthService { */ getEventBadgingAuthUrl(encryptedState: string): string { const clientId = getEnvVar('GITHUB_AUTH_CLIENT_ID_EVENT'); - const scopes = ['public_repo']; + const scopes = ['read:user', 'user:email']; return `https://github.com/login/oauth/authorize?client_id=${clientId}&scope=${scopes.join(',')}&state=${encryptedState}`; } @@ -66,7 +67,13 @@ export class GitHubAuthService { errors: [], }; } catch (error) { + logger.error('Failed to exchange GitHub OAuth code', { + error: error instanceof Error ? error.message : 'Unknown error', + isEventBadging, + }); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { access_token: '', errors: [errorMessage], @@ -77,7 +84,7 @@ export class GitHubAuthService { /** * Create an Octokit instance with the given access token */ - createOctokit(accessToken: string): Octokit { + createUserOctokit(accessToken: string): Octokit { return new Octokit({ auth: accessToken }); } diff --git a/src/shared/services/augur-api.service.ts b/src/shared/services/augur-api.service.ts index 1965ab7..407779e 100644 --- a/src/shared/services/augur-api.service.ts +++ b/src/shared/services/augur-api.service.ts @@ -1,6 +1,7 @@ import { Service } from 'typedi'; import axios from 'axios'; import { getEnvVar } from '../config/environment'; +import { logger } from '../logger'; @Service() export class AugurApiService { @@ -14,12 +15,12 @@ export class AugurApiService { const clientSecret = getEnvVar('AUGUR_APP_CLIENT_SECRET', false); if (!clientSecret) { - console.error('AUGUR_APP_CLIENT_SECRET not provided'); + logger.error('AUGUR_APP_CLIENT_SECRET not provided'); return null; } if (!apiKey) { - console.error('AUGUR_API_KEY not provided'); + logger.error('AUGUR_API_KEY not provided'); return null; } @@ -37,7 +38,7 @@ export class AugurApiService { return response.data; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - console.error('Error calling Augur API:', errorMessage); + logger.error('Error calling Augur API:', { error: errorMessage }); return null; } } diff --git a/src/shared/services/crypto.service.ts b/src/shared/services/crypto.service.ts index d7ce62c..433ecf5 100644 --- a/src/shared/services/crypto.service.ts +++ b/src/shared/services/crypto.service.ts @@ -12,6 +12,7 @@ export class CryptoService { constructor() { const encryptionKey = getEnvVar('ENCRYPTION_SECRET_KEY', false); + this.secretKey = crypto .createHash('sha256') .update(String(encryptionKey)) diff --git a/src/shared/services/mailer.service.ts b/src/shared/services/mailer.service.ts index 66c31b0..5e127bf 100644 --- a/src/shared/services/mailer.service.ts +++ b/src/shared/services/mailer.service.ts @@ -5,6 +5,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { getEnvVar } from '../config/environment'; import { IEmailOptions } from '../types'; +import { logger } from '../logger'; @Service() export class MailerService { @@ -22,7 +23,7 @@ export class MailerService { const emailPassword = getEnvVar('EMAIL_PASSWORD', false); if (!emailHost || !emailAddress || !emailPassword) { - console.warn('Email service is not configured - emails will not be sent'); + logger.warn('Email service is not configured - emails will not be sent'); return; } @@ -40,7 +41,7 @@ export class MailerService { */ async sendSuccessEmail(options: IEmailOptions): Promise { if (!this.transporter) { - console.error('Email service is not configured'); + logger.error('Email service is not configured'); return false; } @@ -62,10 +63,10 @@ export class MailerService { }; const info = await this.transporter.sendMail(mailOptions); - console.log('Email sent:', info.response); + logger.info('Email sent:', { response: info.response }); return true; } catch (error) { - console.error('Error sending success email:', error); + logger.error('Error sending success email:', { error }); return false; } } @@ -75,7 +76,7 @@ export class MailerService { */ async sendFailureEmail(options: IEmailOptions): Promise { if (!this.transporter) { - console.error('Email service is not configured'); + logger.error('Email service is not configured'); return false; } @@ -96,10 +97,10 @@ export class MailerService { }; const info = await this.transporter.sendMail(mailOptions); - console.log('Email sent:', info.response); + logger.info('Email sent:', { response: info.response }); return true; } catch (error) { - console.error('Error sending failure email:', error); + logger.error('Error sending failure email:', { error }); return false; } } diff --git a/src/shared/templates/email/success.html b/src/shared/templates/email/success.html index 6fea9d6..98083b9 100644 --- a/src/shared/templates/email/success.html +++ b/src/shared/templates/email/success.html @@ -69,7 +69,7 @@

Congratulations on Your DEI Badge!

Once Again, congratulations on this well-deserved Badge!

- To poudly showcase your accomplishment, we have provided a Markdown Badge link that can be + To proudly showcase your accomplishment, we have provided a Markdown Badge link that can be seamlessly incorporated into your project's documentation, README, website or any other online platform.
diff --git a/src/types/smee-client.d.ts b/src/types/smee-client.d.ts deleted file mode 100644 index a631e1d..0000000 --- a/src/types/smee-client.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -declare module 'smee-client' { - interface SmeeClientOptions { - source: string; - target: string; - logger?: Console; - } - - interface SmeeEvents { - close(): void; - } - - class SmeeClient { - constructor(options: SmeeClientOptions); - start(): SmeeEvents; - } - - export default SmeeClient; -} diff --git a/tests/e2e/event-badging.test.ts b/tests/e2e/event-badging.test.ts new file mode 100644 index 0000000..a4daf46 --- /dev/null +++ b/tests/e2e/event-badging.test.ts @@ -0,0 +1,328 @@ +import request from 'supertest'; +import { Container } from 'typedi'; +import { useContainer } from 'routing-controllers'; +import type { Express } from 'express'; + +import { createApp } from '../../src/app'; +import { setupTestDB, teardownTestDB } from '../mocks/database.mock'; +import { createMockOctokit } from '../mocks/octokit.mock'; + +import { Event } from '../../src/shared/data-access/models/event.model'; +import { EventRepository } from '../../src/shared/data-access/repositories/event.repository'; +import { + GitHubApiService, + GitHubAppService, +} from '../../src/shared/providers/github'; +import { + ChecklistService, + ScoringService, +} from '../../src/event-badging/services'; + +describe('Event Badging E2E', () => { + let app: Express; + let eventRepo: EventRepository; + + const mockGitHubApiService = { + createIssueComment: jest.fn().mockResolvedValue(undefined), + addLabels: jest.fn().mockResolvedValue(undefined), + removeLabel: jest.fn().mockResolvedValue(undefined), + closeIssue: jest.fn().mockResolvedValue(undefined), + }; + + const mockGitHubAppService = { + getInstallationOctokit: jest.fn(), + }; + + const mockChecklistService = { + getApplicantWelcome: jest.fn().mockResolvedValue('Welcome, applicant!'), + getChecklistType: jest.fn().mockReturnValue('virtual'), + generateReviewerChecklist: jest + .fn() + .mockResolvedValue('- [ ] Review item one\n- [ ] Review item two'), + getReviewerWelcome: jest.fn().mockResolvedValue('Welcome, reviewer!'), + }; + + const mockScoringService = { + calculateBadge: jest.fn(), + }; + + beforeAll(async () => { + useContainer(Container); + await setupTestDB(); + + Container.set(GitHubApiService, mockGitHubApiService as any); + Container.set(GitHubAppService, mockGitHubAppService as any); + Container.set(ChecklistService, mockChecklistService as any); + Container.set(ScoringService, mockScoringService as any); + + eventRepo = new EventRepository(); + Container.set(EventRepository, eventRepo); + + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'info').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + + app = createApp(); + }); + + afterAll(async () => { + await teardownTestDB(); + Container.reset(); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + mockGitHubAppService.getInstallationOctokit.mockResolvedValue( + createMockOctokit() + ); + mockChecklistService.getApplicantWelcome.mockResolvedValue( + 'Welcome, applicant!' + ); + mockChecklistService.getChecklistType.mockReturnValue('virtual'); + mockChecklistService.generateReviewerChecklist.mockResolvedValue( + '- [ ] Review item one\n- [ ] Review item two' + ); + mockChecklistService.getReviewerWelcome.mockResolvedValue( + 'Welcome, reviewer!' + ); + await Event.destroy({ where: {} }); + }); + + describe('POST /api/event_badging', () => { + it('returns 400 when the x-github-event header is missing', async () => { + const res = await request(app) + .post('/api/event_badging') + .send({ + action: 'opened', + issue: { title: '[Virtual Event] Some Conf', number: 1 }, + installation: { id: 1 }, + repository: { name: 'badging', owner: { login: 'chaoss' } }, + }); + + expect(res.status).toBe(400); + expect(res.body.message).toContain('GitHub event header missing'); + }); + + it('posts an applicant welcome comment when an event issue is opened', async () => { + const payload = { + action: 'opened', + issue: { title: '[Virtual Event] CHAOSS Con 2026', number: 1 }, + installation: { id: 123 }, + repository: { name: 'badging', owner: { login: 'chaoss' } }, + }; + + const res = await request(app) + .post('/api/event_badging') + .set('x-github-event', 'issues') + .send(payload); + + expect(res.status).toBe(200); + expect(res.text).toBe('ok'); + expect(mockGitHubAppService.getInstallationOctokit).toHaveBeenCalledWith( + 123 + ); + expect(mockGitHubApiService.createIssueComment).toHaveBeenCalledWith( + expect.anything(), + 'chaoss', + 'badging', + 1, + 'Welcome, applicant!' + ); + }); + + it('ignores non-event-related issue webhooks (e.g. plain bug reports)', async () => { + const payload = { + action: 'opened', + issue: { title: 'Just a bug report', number: 2 }, + installation: { id: 1 }, + repository: { name: 'badging', owner: { login: 'chaoss' } }, + }; + + const res = await request(app) + .post('/api/event_badging') + .set('x-github-event', 'issues') + .send(payload); + + expect(res.status).toBe(200); + expect(res.text).toBe('ok'); + expect(mockGitHubAppService.getInstallationOctokit).not.toHaveBeenCalled(); + expect(mockGitHubApiService.createIssueComment).not.toHaveBeenCalled(); + }); + + it('persists a Gold-badged event when the issue is closed and scoring succeeds', async () => { + mockScoringService.calculateBadge.mockResolvedValue({ + assigned_badge: 'Gold', + badge_URL: 'https://badges.example.com/gold.svg', + reviewResult: 100, + reviewerCount: 2, + }); + + const payload = { + action: 'closed', + issue: { + title: '[In-Person Event] CHAOSS Con 2026', + number: 5, + html_url: 'https://github.com/chaoss/badging/issues/5', + assignees: [ + { + login: 'reviewer1', + html_url: 'https://github.com/reviewer1', + }, + { + login: 'reviewer2', + html_url: 'https://github.com/reviewer2', + }, + ], + body: '- Link to the Event Website: https://chaoss.community\n- Are you an organizer', + }, + installation: { id: 123 }, + repository: { + name: 'sandbox-event-dei', + owner: { login: 'adeyinkaoresanya' }, + }, + }; + + const res = await request(app) + .post('/api/event_badging') + .set('x-github-event', 'issues') + .send(payload); + + expect(res.status).toBe(200); + expect(res.text).toBe('ok'); + + const saved = await eventRepo.findAll(); + expect(saved.length).toBeGreaterThan(0); + + const event = saved[saved.length - 1]; + expect(event.event_name).toContain('CHAOSS Con'); + + const badge = + typeof event.badge === 'string' + ? JSON.parse(event.badge as unknown as string) + : event.badge; + expect(badge).toEqual({ + name: 'Gold', + badgeURL: 'https://badges.example.com/gold.svg', + }); + }); + + it('extracts the Event URL correctly for Virtual events from the issue body', async () => { + mockScoringService.calculateBadge.mockResolvedValue({ + assigned_badge: 'Silver', + badge_URL: 'https://badges.example.com/silver.svg', + reviewResult: 75, + reviewerCount: 2, + }); + + const payload = { + action: 'closed', + issue: { + title: '[Virtual Event] Remote Summit', + number: 7, + html_url: 'https://github.com/chaoss/badging/issues/7', + assignees: [ + { + login: 'reviewer1', + html_url: 'https://github.com/reviewer1', + }, + { + login: 'reviewer2', + html_url: 'https://github.com/reviewer2', + }, + ], + body: + '- Link to the Event Website: https://virtual.example.com\n' + + '- Provide verification that you are an event organizer: ', + }, + installation: { id: 1 }, + repository: { name: 'badging', owner: { login: 'chaoss' } }, + }; + + const res = await request(app) + .post('/api/event_badging') + .set('x-github-event', 'issues') + .send(payload); + + expect(res.status).toBe(200); + + const events = await eventRepo.findAll(); + const last = events[events.length - 1]; + expect(last.event_URL).toBe('https://virtual.example.com'); + }); + + it('returns 500 when an internal dependency throws (e.g. GitHub auth failure)', async () => { + mockGitHubAppService.getInstallationOctokit.mockRejectedValue( + new Error('Auth Failed') + ); + + const res = await request(app) + .post('/api/event_badging') + .set('x-github-event', 'issues') + .send({ + action: 'opened', + issue: { title: '[Virtual Event] Crashy', number: 9 }, + installation: { id: 1 }, + repository: { name: 'badging', owner: { login: 'chaoss' } }, + }); + + expect(res.status).toBe(500); + expect(res.body.message).toBe('Internal server error'); + }); + }); + + describe('GET /api/badged_events', () => { + it('returns an empty array when no events have been badged', async () => { + const res = await request(app).get('/api/badged_events'); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body).toHaveLength(0); + }); + + it('returns badged events end-to-end after a closed-issue webhook is processed', async () => { + mockScoringService.calculateBadge.mockResolvedValue({ + assigned_badge: 'Gold', + badge_URL: 'https://badges.example.com/gold.svg', + reviewResult: 100, + reviewerCount: 2, + }); + + const payload = { + action: 'closed', + issue: { + title: '[In-Person Event] DevFest 2026', + number: 11, + html_url: 'https://github.com/chaoss/badging/issues/11', + assignees: [ + { + login: 'reviewer1', + html_url: 'https://github.com/reviewer1', + }, + { + login: 'reviewer2', + html_url: 'https://github.com/reviewer2', + }, + ], + body: '- Link to the Event Website: https://devfest.example.com\n- Are you an organizer', + }, + installation: { id: 42 }, + repository: { name: 'badging', owner: { login: 'chaoss' } }, + }; + + await request(app) + .post('/api/event_badging') + .set('x-github-event', 'issues') + .send(payload); + + const res = await request(app).get('/api/badged_events'); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThan(0); + expect( + res.body.some((e: any) => String(e.event_name).includes('DevFest')) + ).toBe(true); + }); + }); +}); diff --git a/tests/e2e/project-badging.test.ts b/tests/e2e/project-badging.test.ts new file mode 100644 index 0000000..3458942 --- /dev/null +++ b/tests/e2e/project-badging.test.ts @@ -0,0 +1,377 @@ +import request from 'supertest'; +import { Container } from 'typedi'; +import { useContainer } from 'routing-controllers'; +import type { Express } from 'express'; + +import { createApp } from '../../src/app'; +import { + setupTestDB, + teardownTestDB, + seedUser, +} from '../mocks/database.mock'; + +import { Repo } from '../../src/shared/data-access/models/repo.model'; +import { + GitHubApiService, + GitLabApiService, + RepoRepository, + MailerService, + AugurApiService, +} from '../../src/shared'; +import { + DEIScannerService, + BadgeAwardService, +} from '../../src/project-badging/services'; + +describe('Project Badging E2E', () => { + let app: Express; + + const mockGitHubApiService = { + getRepositoryInfo: jest.fn(), + getFileContentAndSHA: jest.fn(), + }; + + const mockGitLabApiService = { + getRepositoryInfo: jest.fn(), + getFileContentAndSHA: jest.fn(), + }; + + const mockDEIScannerService = { + getDEITemplate: jest.fn(), + scanDEIContent: jest.fn(), + isTemplateContent: jest.fn(), + }; + + const mockMailerService = { + sendBadgingEmail: jest.fn().mockResolvedValue(true), + sendErrorNotification: jest.fn().mockResolvedValue(true), + }; + + const mockAugurApiService = { + registerBadgedRepo: jest.fn().mockResolvedValue({ success: true }), + }; + + beforeAll(async () => { + useContainer(Container); + await setupTestDB(); + + Container.set(GitHubApiService, mockGitHubApiService as any); + Container.set(GitLabApiService, mockGitLabApiService as any); + Container.set(DEIScannerService, mockDEIScannerService as any); + Container.set(MailerService, mockMailerService as any); + Container.set(AugurApiService, mockAugurApiService as any); + + Container.set(RepoRepository, new RepoRepository()); + + const badgeAwardService = new BadgeAwardService( + Container.get(RepoRepository), + Container.get(MailerService), + Container.get(AugurApiService) + ); + Container.set(BadgeAwardService, badgeAwardService); + + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'info').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + app = createApp(); + }); + + afterAll(async () => { + await teardownTestDB(); + Container.reset(); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + mockMailerService.sendBadgingEmail.mockResolvedValue(true); + mockMailerService.sendErrorNotification.mockResolvedValue(true); + mockAugurApiService.registerBadgedRepo.mockResolvedValue({ success: true }); + await Repo.destroy({ where: {} }); + }); + + describe('POST /api/repos-to-badge', () => { + it('runs the full GitHub badging flow end-to-end and persists a Bronze badge', async () => { + const user = await seedUser(); + + mockGitHubApiService.getRepositoryInfo.mockResolvedValue({ + data: { id: 101, fullName: 'org/repo-101', url: 'https://github.com/org/repo-101' }, + errors: [], + }); + mockGitHubApiService.getFileContentAndSHA.mockResolvedValue({ + data: { content: 'valid DEI content', sha: 'sha-101' }, + errors: [], + }); + mockDEIScannerService.getDEITemplate.mockResolvedValue('template'); + mockDEIScannerService.isTemplateContent.mockReturnValue(false); + mockDEIScannerService.scanDEIContent.mockReturnValue({ + isValid: true, + missingSections: [], + }); + + const res = await request(app) + .post('/api/repos-to-badge') + .send({ + userId: user.id, + provider: 'github', + repos: [{ id: 101 }], + }); + + expect(res.status).toBe(200); + expect(res.body.results).toHaveLength(1); + expect(res.body.results[0].success).toBe(true); + expect(res.body.results[0].message).toBe('Badge awarded successfully'); + + expect(mockMailerService.sendBadgingEmail).toHaveBeenCalledTimes(1); + expect(mockMailerService.sendBadgingEmail).toHaveBeenCalledWith( + user.email, + user.name, + 'Bronze', + expect.stringContaining('![Bronze Badge]'), + expect.stringContaining(' { + const user = await seedUser({ email: 'gitlab@example.com', login: 'gluser' }); + + mockGitLabApiService.getRepositoryInfo.mockResolvedValue({ + data: { + id: 202, + fullName: 'group/repo-202', + url: 'https://gitlab.com/group/repo-202', + defaultBranch: 'main', + }, + errors: [], + }); + mockGitLabApiService.getFileContentAndSHA.mockResolvedValue({ + data: { content: 'valid DEI content', sha: 'sha-202' }, + errors: [], + }); + mockDEIScannerService.getDEITemplate.mockResolvedValue('template'); + mockDEIScannerService.isTemplateContent.mockReturnValue(false); + mockDEIScannerService.scanDEIContent.mockReturnValue({ + isValid: true, + missingSections: [], + }); + + const res = await request(app) + .post('/api/repos-to-badge') + .send({ + userId: user.id, + provider: 'gitlab', + repos: [{ id: 202 }], + }); + + expect(res.status).toBe(200); + expect(res.body.results[0].success).toBe(true); + + const saved = await Repo.findOne({ + where: { gitlabRepoId: 202, DEICommitSHA: 'sha-202' }, + }); + expect(saved).not.toBeNull(); + expect(saved!.badgeType).toBe('Bronze'); + }); + + it('does not re-badge the same repo + commit SHA twice', async () => { + const user = await seedUser(); + + mockGitHubApiService.getRepositoryInfo.mockResolvedValue({ + data: { id: 303, fullName: 'org/repo-303', url: 'https://github.com/org/repo-303' }, + errors: [], + }); + mockGitHubApiService.getFileContentAndSHA.mockResolvedValue({ + data: { content: 'valid', sha: 'sha-303' }, + errors: [], + }); + mockDEIScannerService.getDEITemplate.mockResolvedValue('template'); + mockDEIScannerService.isTemplateContent.mockReturnValue(false); + mockDEIScannerService.scanDEIContent.mockReturnValue({ + isValid: true, + missingSections: [], + }); + + const first = await request(app).post('/api/repos-to-badge').send({ + userId: user.id, + provider: 'github', + repos: [{ id: 303 }], + }); + expect(first.status).toBe(200); + expect(first.body.results[0].success).toBe(true); + + const second = await request(app).post('/api/repos-to-badge').send({ + userId: user.id, + provider: 'github', + repos: [{ id: 303 }], + }); + expect(second.status).toBe(200); + expect(second.body.results[0].success).toBe(false); + expect(second.body.results[0].message).toContain('was already badged'); + + const repos = await Repo.findAll({ where: { githubRepoId: 303 } }); + expect(repos).toHaveLength(1); + }); + + it('handles mixed success and failure across multiple repos', async () => { + const user = await seedUser(); + + mockGitHubApiService.getRepositoryInfo + .mockResolvedValueOnce({ + data: { id: 401, fullName: 'org/repo-401', url: 'https://github.com/org/repo-401' }, + errors: [], + }) + .mockResolvedValueOnce({ + data: { id: 402, fullName: 'org/repo-402', url: 'https://github.com/org/repo-402' }, + errors: [], + }); + + mockGitHubApiService.getFileContentAndSHA + .mockResolvedValueOnce({ + data: { content: 'valid', sha: 'sha-401' }, + errors: [], + }) + .mockResolvedValueOnce({ + data: null, + errors: ['DEI.md not found'], + }); + + mockDEIScannerService.getDEITemplate.mockResolvedValue('template'); + mockDEIScannerService.isTemplateContent.mockReturnValue(false); + mockDEIScannerService.scanDEIContent.mockReturnValue({ + isValid: true, + missingSections: [], + }); + + const res = await request(app) + .post('/api/repos-to-badge') + .send({ + userId: user.id, + provider: 'github', + repos: [{ id: 401 }, { id: 402 }], + }); + + expect(res.status).toBe(200); + expect(res.body.results).toHaveLength(2); + expect(res.body.results[0].success).toBe(true); + expect(res.body.results[1].success).toBe(false); + expect(res.body.results[1].message).toContain('does not have a DEI.md file'); + }); + + it('returns 400 when provider is missing', async () => { + const user = await seedUser(); + + const res = await request(app).post('/api/repos-to-badge').send({ + userId: user.id, + repos: [{ id: 1 }], + }); + + expect(res.status).toBe(400); + expect(res.body.message).toContain('Provider is required'); + }); + + it('returns 400 for an unsupported provider', async () => { + const user = await seedUser(); + + const res = await request(app).post('/api/repos-to-badge').send({ + userId: user.id, + provider: 'bitbucket', + repos: [{ id: 1 }], + }); + + expect(res.status).toBe(400); + expect(res.body.message).toContain('Invalid provider'); + }); + + it('returns 400 when userId is missing', async () => { + const res = await request(app).post('/api/repos-to-badge').send({ + provider: 'github', + repos: [{ id: 1 }], + }); + + expect(res.status).toBe(400); + expect(res.body.message).toContain('UserId is required'); + }); + + it('returns 400 when no repos are selected', async () => { + const user = await seedUser(); + + const res = await request(app).post('/api/repos-to-badge').send({ + userId: user.id, + provider: 'github', + repos: [], + }); + + expect(res.status).toBe(400); + expect(res.body.message).toContain('No repositories selected'); + }); + + it('returns 404 when the user does not exist', async () => { + const res = await request(app).post('/api/repos-to-badge').send({ + userId: 999999, + provider: 'github', + repos: [{ id: 1 }], + }); + + expect(res.status).toBe(404); + expect(res.body.message).toContain('User not found'); + }); + }); + + describe('GET /api/badgedRepos', () => { + it('returns an empty list when no repos have been badged', async () => { + const res = await request(app).get('/api/badgedRepos'); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body).toHaveLength(0); + }); + + it('returns all badged repos after a successful badging flow', async () => { + const user = await seedUser(); + + await Repo.create({ + githubRepoId: 555, + DEICommitSHA: 'sha-555', + repoLink: 'https://github.com/org/repo-555', + badgeType: 'Bronze', + attachment: '', + userId: user.id, + } as any); + + const res = await request(app).get('/api/badgedRepos'); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThan(0); + }); + }); + + describe('GET /api/project-badging-successful', () => { + it('renders the success HTML page with query params', async () => { + const res = await request(app) + .get('/api/project-badging-successful') + .query({ + provider: 'github', + userId: '42', + name: 'Jane Doe', + email: 'jane@example.com', + }); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('text/html'); + expect(res.text).toContain('Badge Scan Successful!'); + expect(res.text).toContain('Jane Doe'); + expect(res.text).toContain('jane@example.com'); + expect(res.text).toContain('github'); + expect(res.text).toContain('42'); + }); + }); +}); diff --git a/tests/integration/event-badging.test.ts b/tests/integration/event-badging.test.ts new file mode 100644 index 0000000..47b8638 --- /dev/null +++ b/tests/integration/event-badging.test.ts @@ -0,0 +1,177 @@ +import request from 'supertest'; +import { Container } from 'typedi'; +import express from 'express'; +import { useExpressServer, useContainer } from 'routing-controllers'; +import { setupTestDB, teardownTestDB } from '../mocks/database.mock'; +import { EventBadgingController } from '../../src/event-badging/controllers/event-badging.controller'; +import { GitHubApiService } from '../../src/shared/providers/github/github-api.service'; +import { GitHubAppService } from '../../src/shared/providers/github/github-app.service'; +import { ChecklistService, ScoringService } from '../../src/event-badging/services'; +import { EventRepository } from '../../src/shared/data-access/repositories/event.repository'; + +import { createMockOctokit } from '../mocks/octokit.mock'; +import { globalErrorHandler } from '../../src/middleware'; +const mockGitHubApiService = { + createIssueComment: jest.fn(), + addLabels: jest.fn(), + removeLabel: jest.fn(), + closeIssue: jest.fn(), +}; + +const mockGitHubAppService = { + getInstallationOctokit: jest.fn().mockResolvedValue({}), +}; + +const mockChecklistService = { + getApplicantWelcome: jest.fn().mockResolvedValue('Welcome!'), + getChecklistType: jest.fn().mockReturnValue('virtual'), + generateReviewerChecklist: jest.fn().mockResolvedValue('- [ ] Checklist Item'), + getReviewerWelcome: jest.fn().mockResolvedValue('Reviewer Welcome!'), +}; + +const mockScoringService = { + calculateBadge: jest.fn(), +}; + +describe('EventBadgingController Integration', () => { + let app: express.Express; + let eventRepo: EventRepository; + + beforeAll(async () => { + useContainer(Container); + await setupTestDB(); + + Container.set(GitHubApiService, mockGitHubApiService as any); + Container.set(GitHubAppService, mockGitHubAppService as any); + Container.set(ChecklistService, mockChecklistService as any); + Container.set(ScoringService, mockScoringService as any); + + + eventRepo = new EventRepository(); + Container.set(EventRepository, eventRepo); + + app = express(); + app.use(express.json()); + useExpressServer(app, { controllers: [EventBadgingController], defaultErrorHandler: false }); + app.use(globalErrorHandler); + + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'info').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); +}); + + afterAll(async () => { + await teardownTestDB(); + Container.reset(); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + mockGitHubAppService.getInstallationOctokit.mockResolvedValue(createMockOctokit()); + }); + + + it('should post a welcome message when an event issue is opened', async () => { + const payload = { + action: 'opened', + issue: { title: '[Virtual Event] CHAOSS Con', number: 1 }, + installation: { id: 123 }, + repository: { name: 'badging', owner: { login: 'chaoss' } } + }; + + const res = await request(app) + .post('/api/event_badging') + .set('x-github-event', 'issues') + .send(payload); + + expect(res.status).toBe(200); + expect(mockGitHubApiService.createIssueComment).toHaveBeenCalledWith( + expect.anything(), 'chaoss', 'badging', 1, 'Welcome!' + ); + }); + + it('should save event to DB and assign badges when issue is closed', async () => { + mockScoringService.calculateBadge.mockResolvedValue({ + assigned_badge: 'Gold', + badge_URL: 'gold.svg', + reviewResult: 100, + reviewerCount: 2 + }); + + const payload = { + action: 'closed', + issue: { + title: '[In-Person Event] CHAOSS Con', + number: 5, + html_url: 'https://github.com/5', + assignees: [{ login: 'reviewer1', html_url: 'https://github.com/reviewer1' }, { login: 'reviewer2', html_url: 'https://github.com/reviewer2' }], + body: '- Link to the Event Website: https://chaoss.community\n- Are you an organizer' + }, + installation: { id: 123 }, + repository: { name: 'sandbox-event-dei', owner: { login: 'adeyinkaoresanya' } } + }; + + await request(app) + .post('/api/event_badging') + .set('x-github-event', 'issues') + .send(payload); + + const savedEvent = await eventRepo.findAll(); + const badge = typeof savedEvent[0].badge === 'string' + ? JSON.parse(savedEvent[0].badge) + : savedEvent[0].badge; + + expect(savedEvent.length).toBeGreaterThan(0); + expect(savedEvent[0].event_name).toContain('CHAOSS Con'); + expect(badge).toEqual({ name: 'Gold', badgeURL: 'gold.svg' });; + }); + + it('should extract Event URL correctly for Virtual events with markers', async () => { + mockScoringService.calculateBadge.mockResolvedValue({ assigned_badge: 'Silver' }); + const payload = { + action: 'closed', + issue: { + title: '[Virtual Event] Test', + html_url: 'https://github.com/5', + assignees: [{ login: 'reviewer1', html_url: 'https://github.com/reviewer1' }, { login: 'reviewer2', html_url: 'https://github.com/reviewer2' }], + body: '- Link to the Event Website: https://virtual.com\n- Provide verification that you are an event organizer: ', + }, + installation: { id: 1 }, + repository: { name: 'b', owner: { login: 'o' } } + }; + + await request(app).post('/api/event_badging').set('x-github-event', 'issues').send(payload); + + const events = await eventRepo.findAll(); + const lastEvent = events[events.length - 1]; + expect(lastEvent.event_URL).toBe('https://virtual.com'); + }); + + it('should return 500 if the internal service crashes', async () => { + mockGitHubAppService.getInstallationOctokit.mockRejectedValue(new Error('Auth Failed')); + + const res = await request(app) + .post('/api/event_badging') + .set('x-github-event', 'issues') + .send({ installation: { id: 1 }, issue: { title: 'event' } }); + + expect(res.status).toBe(500); + expect(res.body.message).toBe('Internal server error'); + }); + + it('should ignore webhooks that are not event-related', async () => { + const payload = { + action: 'opened', + issue: { title: 'Just a bug report' }, + installation: { id: 1 } + }; + + const res = await request(app) + .post('/api/event_badging') + .set('x-github-event', 'issues') + .send(payload); + + expect(res.status).toBe(200); + expect(mockGitHubAppService.getInstallationOctokit).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/integration/project-badging.test.ts b/tests/integration/project-badging.test.ts new file mode 100644 index 0000000..f0c9111 --- /dev/null +++ b/tests/integration/project-badging.test.ts @@ -0,0 +1,254 @@ +import request from 'supertest'; +import { Container } from 'typedi'; +import express from 'express'; +import { useExpressServer, useContainer } from 'routing-controllers'; + +import { + setupTestDB, + teardownTestDB, + seedUser, +} from '../mocks/database.mock'; + +import { Repo } from '../../src/shared/data-access/models/repo.model'; +import { ProjectBadgingController } from '../../src/project-badging/controllers/project-badging.controller'; +import { GitHubApiService, GitLabApiService, RepoRepository, MailerService, AugurApiService } from '../../src/shared'; +import { DEIScannerService, BadgeAwardService } from '../../src/project-badging/services'; +import { globalErrorHandler } from '../../src/middleware'; +describe('ProjectBadgingController Integration', () => { + let app: express.Express; + + const mockGitHubApiService = { + getRepositoryInfo: jest.fn(), + getFileContentAndSHA: jest.fn(), + }; + + const mockGitLabApiService = { + getRepositoryInfo: jest.fn(), + getFileContentAndSHA: jest.fn(), + }; + + const mockDEIScannerService = { + getDEITemplate: jest.fn(), + scanDEIContent: jest.fn(), + isTemplateContent: jest.fn(), + }; + + const mockMailerService = { + sendBadgingEmail: jest.fn().mockResolvedValue(true), + }; + + const mockAugurApiService = { + registerBadgedRepo: jest.fn().mockResolvedValue({ success: true }), + }; + + + beforeAll(async () => { + + useContainer(Container); + await setupTestDB(); + + Container.set(GitHubApiService, mockGitHubApiService as any); + Container.set(GitLabApiService, mockGitLabApiService as any); + Container.set(DEIScannerService, mockDEIScannerService as any); + Container.set(MailerService, mockMailerService as any); + Container.set(AugurApiService, mockAugurApiService as any); + + Container.set(RepoRepository, new RepoRepository()); + + const badgeAwardService = new BadgeAwardService( + Container.get(RepoRepository), + Container.get(MailerService), + Container.get(AugurApiService) + ); + Container.set(BadgeAwardService, badgeAwardService); + + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'info').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + + app = express(); + app.use(express.json()); + + useExpressServer(app, { + controllers: [ProjectBadgingController], + defaultErrorHandler: false, + }); + app.use(globalErrorHandler); +}); + + afterAll(async () => { + await teardownTestDB(); + Container.reset(); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + + await Repo.destroy({ where: {} }); + }); + + it('should badge a repo successfully', async () => { + const user = await seedUser(); + + const githubService = Container.get(GitHubApiService) as any; + const deiService = Container.get(DEIScannerService) as any; + + githubService.getRepositoryInfo.mockResolvedValue({ + data: { id: 1, fullName: 'repo', url: 'url' }, + errors: [], + }); + githubService.getFileContentAndSHA.mockResolvedValue({ + data: { content: 'valid DEI content', sha: 'sha-1' }, + errors: [], + }); + deiService.getDEITemplate.mockResolvedValue('template'); + deiService.isTemplateContent.mockReturnValue(false); + deiService.scanDEIContent.mockReturnValue({ isValid: true, missingSections: [] }); + + const res = await request(app) + .post('/api/repos-to-badge') + .send({ + userId: user.id, + provider: 'github', + repos: [{ id: 1 }], + }); + + expect(res.status).toBe(200); + expect(res.body.results[0].success).toBe(true); + expect(res.body.results[0].message).toBe('Badge awarded successfully'); + expect(mockMailerService.sendBadgingEmail).toHaveBeenCalledTimes(1); + expect(mockMailerService.sendBadgingEmail).toHaveBeenCalledWith( + user.email, + user.name, + 'Bronze', + expect.stringContaining('![Bronze Badge]'), + expect.stringContaining(' { + const user = await seedUser(); + + const githubService = Container.get(GitHubApiService) as any; + const deiService = Container.get(DEIScannerService) as any; + + githubService.getRepositoryInfo.mockResolvedValue({ + data: { id: 2, fullName: 'repo2', url: 'url2' }, + errors: [], + }); + githubService.getFileContentAndSHA.mockResolvedValue({ + data: { content: 'valid', sha: 'sha-dup' }, + errors: [], + }); + deiService.getDEITemplate.mockResolvedValue('template'); + deiService.isTemplateContent.mockReturnValue(false); + deiService.scanDEIContent.mockReturnValue({ isValid: true, missingSections: [] }); + + await request(app).post('/api/repos-to-badge').send({ + userId: user.id, + provider: 'github', + repos: [{ id: 2 }], + }); + + const res2 = await request(app).post('/api/repos-to-badge').send({ + userId: user.id, + provider: 'github', + repos: [{ id: 2 }], + }); + + expect(res2.status).toBe(200); + expect(res2.body.results[0].success).toBe(false); + expect(res2.body.results[0].message).toContain('was already badged'); + }); + + + it('should return 400 for invalid provider', async () => { + const user = await seedUser(); + const res = await request(app).post('/api/repos-to-badge').send({ + userId: user.id, + provider: 'bitbucket', + repos: [{ id: 1 }], + }); + + expect(res.status).toBe(400); + expect(res.body.message).toContain('Invalid provider'); + }); + + it('should return 404 for non-existent user', async () => { + const res = await request(app).post('/api/repos-to-badge').send({ + userId: 9999, + provider: 'github', + repos: [{ id: 1 }], + }); + + expect(res.status).toBe(404); + expect(res.body.message).toContain('User not found'); + }); + + it('should handle partial success/failure', async () => { + const user = await seedUser(); + + const githubService = Container.get(GitHubApiService) as any; + const deiService = Container.get(DEIScannerService) as any; + + githubService.getRepositoryInfo.mockResolvedValueOnce({ + data: { id: 10, fullName: 'repo10', url: 'url10' }, + errors: [], + }); + githubService.getFileContentAndSHA.mockResolvedValueOnce({ + data: { content: 'valid', sha: 'sha10' }, + errors: [], + }); + + githubService.getRepositoryInfo.mockResolvedValueOnce({ + data: { id: 20, fullName: 'repo20', url: 'url20' }, + errors: [], + }); + githubService.getFileContentAndSHA.mockResolvedValueOnce({ + data: null, + errors: ['missing'], + }); + + deiService.getDEITemplate.mockResolvedValue('template'); + deiService.isTemplateContent.mockReturnValue(false); + deiService.scanDEIContent.mockReturnValue({ isValid: true, missingSections: [] }); + + const res = await request(app).post('/api/repos-to-badge').send({ + userId: user.id, + provider: 'github', + repos: [{ id: 10 }, { id: 20 }], + }); + + expect(res.status).toBe(200); + expect(res.body.results).toHaveLength(2); + expect(res.body.results[0].success).toBe(true); + expect(res.body.results[1].success).toBe(false); + expect(res.body.results[1].message).toContain('does not have a DEI.md file'); + }); + + it('should return all badged repos', async () => { + const user = await seedUser(); + + await Repo.create({ + githubRepoId: 123, + DEICommitSHA: 'sha123', + repoLink: 'url123', + badgeType: 'Bronze', + attachment: '', + userId: user.id, + }); + + const res = await request(app).get('/api/badgedRepos'); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThan(0); + }); + + +}); + diff --git a/tests/mocks/crypto.mock.ts b/tests/mocks/crypto.mock.ts new file mode 100644 index 0000000..16cf37c --- /dev/null +++ b/tests/mocks/crypto.mock.ts @@ -0,0 +1,5 @@ +export const createMockCryptoService = () => ({ + encrypt: jest.fn(), + decrypt: jest.fn(), + convertToMarkdown: jest.fn(), +}); \ No newline at end of file diff --git a/tests/mocks/database.mock.ts b/tests/mocks/database.mock.ts new file mode 100644 index 0000000..c44396a --- /dev/null +++ b/tests/mocks/database.mock.ts @@ -0,0 +1,134 @@ +import { Sequelize } from 'sequelize-typescript'; +import type { Database } from '../../src/shared/data-access/database'; +import { User } from '../../src/shared/data-access/models/user.model'; +import { Repo } from '../../src/shared/data-access/models/repo.model'; +import { Event } from '../../src/shared/data-access/models/event.model'; +import { getEnvVar, isTest } from '../../src/shared/config/environment'; +import { RepoRepository } from '../../src/shared'; + + +// Mocked Sequelize instance +export const mockSequelize = { + authenticate: jest.fn().mockResolvedValue(undefined), + sync: jest.fn().mockResolvedValue(undefined), + close: jest.fn().mockResolvedValue(undefined), +} as Partial; + + +// Mocked Database instance +export const mockDatabase: Partial = { + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn().mockResolvedValue(undefined), + getSequelize: jest.fn().mockReturnValue(mockSequelize), +}; + +const decorator = () => () => {}; +export const sequelizeModuleMock = { + Sequelize: jest.fn(() => mockSequelize), + Model: class {}, + Table: decorator, + Column: decorator, + HasMany: decorator, + BelongsTo: decorator, + ForeignKey: decorator, + CreatedAt: decorator, + UpdatedAt: decorator, + DataType: { + INTEGER: 'INTEGER', + STRING: 'STRING', + DATE: 'DATE', + JSON: 'JSON', + NOW: 'NOW', + }, +}; + + + +// for integration + +export let sequelize: Sequelize; +export let repoRepository: RepoRepository; + +export const setupTestDB = async () => { + + const host = getEnvVar('DB_HOST'); + const port = parseInt(getEnvVar('DB_PORT', '3306'), 10); + const username = getEnvVar('DB_USER'); + const password = isTest()? getEnvVar('DB_PASSWORD', false) : getEnvVar('DB_PASSWORD'); + const database = getEnvVar('TEST_DB_NAME'); + const dialect = getEnvVar('DB_DIALECT') as 'mysql' | 'postgres' | 'sqlite' | 'mariadb'; + + sequelize = new Sequelize({ + dialect, + host, + port, + username, + password, + database, + models: [User, Repo, Event], + logging: false, + }); + + await sequelize.authenticate(); + await sequelize.sync({ force: true }); + + repoRepository = new RepoRepository(); + + return sequelize; +}; + +export const teardownTestDB = async () => { + if (sequelize) await sequelize.close(); +}; + +// ----------------------------- +// Helpers to seed test data +// ----------------------------- +export const seedUser = async (overrides?: Partial) => { + return User.create({ + name: 'Test User', + email: 'test@example.com', + login: 'testuser', + githubId: 123, + gitlabId: null, + ...overrides, + }); +}; + +export const seedRepo = async (userId: number, overrides?: Partial) => { + return Repo.create({ + githubRepoId: 456, + DEICommitSHA: 'abc123', + repoLink: 'https://github.com/test/repo', + badgeType: 'bronze', + attachment: '', + userId, + ...overrides, + }); +}; + +export const seedEvent = async (overrides?: Partial) => { + return Event.create({ + event_name: 'Global Conf 2026', + event_URL: 'https://event.com', + badge: { + name: 'Gold', + badgeURL: 'https://badges.com/gold.png', + }, + reviewers: [ + { name: 'reviewer1', github_profile_link: 'https://github.com/reviewer1' }, + { name: 'reviewer2', github_profile_link: 'https://github.com/reviewer2' }, + ], + application: { + app_no: 1, + app_URL: 'http://github.com/issue/1', + }, + ...overrides, // override any field for custom tests + }); +}; + + + + + + \ No newline at end of file diff --git a/tests/mocks/github.mock.ts b/tests/mocks/github.mock.ts new file mode 100644 index 0000000..3c540ad --- /dev/null +++ b/tests/mocks/github.mock.ts @@ -0,0 +1,28 @@ +export const createMockGitHubAuthService = () => ({ + isConfigured: jest.fn(), + getProjectBadgingAuthUrl: jest.fn(), + requestAccessToken: jest.fn(), + createUserOctokit: jest.fn(), + createPublicOctokit: jest.fn(), + getEventBadgingAuthUrl: jest.fn(), + isEventBadgingConfigured: jest.fn(), +}); + +export const createMockGitHubApiService = () => ({ + createIssue: jest.fn(), + listIssueComments: jest.fn(), + getUserInfo: jest.fn(), + getUserRepositories: jest.fn(), + getRepoContent: jest.fn(), +}); + +export const createMockGitHubAppService = () => ({ + isConfigured: jest.fn(), + getApp: jest.fn(), + getInstallationOctokit: jest.fn(), +}); + +export const createMockGitHubAppInstance = () => ({ + getInstallationOctokit: jest.fn().mockResolvedValue({}), +}); + diff --git a/tests/mocks/gitlab.mock.ts b/tests/mocks/gitlab.mock.ts new file mode 100644 index 0000000..c130455 --- /dev/null +++ b/tests/mocks/gitlab.mock.ts @@ -0,0 +1,10 @@ +export const createMockGitLabAuthService = () => ({ + isConfigured: jest.fn(), + getAuthUrl: jest.fn(), + requestAccessToken: jest.fn(), +}); + +export const createMockGitLabApiService = () => ({ + getUserInfo: jest.fn(), + getUserRepositories: jest.fn(), +}); \ No newline at end of file diff --git a/tests/mocks/octokit.mock.ts b/tests/mocks/octokit.mock.ts new file mode 100644 index 0000000..9147083 --- /dev/null +++ b/tests/mocks/octokit.mock.ts @@ -0,0 +1,27 @@ +import { Octokit } from '@octokit/rest'; + +export const mockOctokit = {} as unknown as Octokit; + +export const createMockOctokit = () => ({ + users: { + getAuthenticated: jest.fn(), + }, + repos: { + listForAuthenticatedUser: jest.fn(), + getContent: jest.fn(), + }, + rest: { + issues: { + create: jest.fn(), + createComment: jest.fn(), + listComments: jest.fn(), + addLabels: jest.fn(), + removeLabel: jest.fn(), + update: jest.fn(), + }, + repos: { + getContent: jest.fn(), + } + }, + request: jest.fn(), +}); \ No newline at end of file diff --git a/tests/mocks/repository.mock.ts b/tests/mocks/repository.mock.ts new file mode 100644 index 0000000..1b43b1b --- /dev/null +++ b/tests/mocks/repository.mock.ts @@ -0,0 +1,3 @@ +export const createMockUserRepository = () => ({ + saveUser: jest.fn(), +}); \ No newline at end of file diff --git a/tests/unit/auth/auth.service.test.ts b/tests/unit/auth/auth.service.test.ts new file mode 100644 index 0000000..8e613c9 --- /dev/null +++ b/tests/unit/auth/auth.service.test.ts @@ -0,0 +1,275 @@ +import { AuthService } from '../../../src/auth/services/auth.service'; +import * as envConfig from '../../../src/shared/config/environment'; +import { + createMockGitHubAuthService, + createMockGitHubApiService, + createMockGitHubAppService, +} from '../../mocks/github.mock'; + +import { + createMockGitLabAuthService, + createMockGitLabApiService, +} from '../../mocks/gitlab.mock'; + +import { createMockUserRepository } from '../../mocks/repository.mock'; +import { createMockCryptoService } from '../../mocks/crypto.mock'; + +describe('AuthService', () => { + let authService: AuthService; + let mockGitHubAuthService: ReturnType; + let mockGitHubApiService: ReturnType; + let mockGitHubAppService: ReturnType; + let mockGitLabAuthService: ReturnType; + let mockGitLabApiService: ReturnType; + let mockUserRepository: ReturnType; + let mockCryptoService: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + + mockGitHubAuthService = createMockGitHubAuthService(); + mockGitHubApiService = createMockGitHubApiService(); + mockGitHubAppService = createMockGitHubAppService(); + mockGitLabAuthService = createMockGitLabAuthService(); + mockGitLabApiService = createMockGitLabApiService(); + mockUserRepository = createMockUserRepository(); + mockCryptoService = createMockCryptoService(); + + authService = new AuthService( + mockGitHubAuthService as any, + mockGitHubAppService as any, + mockGitHubApiService as any, + mockGitLabAuthService as any, + mockGitLabApiService as any, + mockUserRepository as any, + mockCryptoService as any + ); + }); + + describe('getLoginUrl', () => { + it('should return GitHub login URL if configured', () => { + mockGitHubAuthService.isConfigured.mockReturnValue(true); + mockGitHubAuthService.getProjectBadgingAuthUrl.mockReturnValue('https://github.com/login/oauth'); + + const result = authService.getLoginUrl('github'); + expect(result.data).toBe('https://github.com/login/oauth'); + expect(result.errors).toHaveLength(0); + }); + + it('should fail if GitHub is not configured', () => { + mockGitHubAuthService.isConfigured.mockReturnValue(false); + + expect(() => authService.getLoginUrl('github')).toThrow( + 'GitHub provider is not configured' + ); + }); + + it('should return GitLab login URL if configured', () => { + mockGitLabAuthService.isConfigured.mockReturnValue(true); + mockGitLabAuthService.getAuthUrl.mockReturnValue('https://gitlab.com/oauth/authorize'); + + const result = authService.getLoginUrl('gitlab'); + expect(result.data).toBe('https://gitlab.com/oauth/authorize'); + expect(result.errors).toHaveLength(0); + }); + + it('should fail if GitLab is not configured', () => { + mockGitLabAuthService.isConfigured.mockReturnValue(false); + + expect(() => authService.getLoginUrl('gitlab')).toThrow( + 'GitLab provider is not configured' + ); + }); + + it('should return error for unknown provider', () => { + expect(() => authService.getLoginUrl('bitbucket' as any)).toThrow( + 'Unknown provider: bitbucket' + ); + }); + }); + + + describe('getEventBadgingAuthUrl', () => { + it('should return error if event badging is not configured', () => { + mockGitHubAuthService.isEventBadgingConfigured.mockReturnValue(false); + + expect(() => authService.getEventBadgingAuthUrl('Test Event', 'Test body')).toThrow( + 'GitHub event badging provider is not configured' + ); + }); + + it('should return event badging auth URL when configured', () => { + mockGitHubAuthService.isEventBadgingConfigured.mockReturnValue(true); + + mockCryptoService.encrypt.mockReturnValue('encrypted-state'); + + mockGitHubAuthService.getEventBadgingAuthUrl.mockReturnValue( + 'https://github.com/oauth/event' + ); + + const result = authService.getEventBadgingAuthUrl('Test Event', 'Test body'); + + expect(mockCryptoService.encrypt).toHaveBeenCalled(); + expect(mockGitHubAuthService.getEventBadgingAuthUrl).toHaveBeenCalledWith( + 'encrypted-state' + ); + + expect(result.data).toBe('https://github.com/oauth/event'); + expect(result.errors).toHaveLength(0); + }); + }); + + + describe('handleGitHubCallback', () => { + it('should return error if token exchange fails', async () => { + mockGitHubAuthService.requestAccessToken.mockResolvedValue({ + access_token: null, + errors: ['token failed'], + }); + + await expect(authService.handleGitHubCallback('code123')).rejects.toThrow('token failed'); + }); + + it('should return user and repositories for project badging flow', async () => { + mockGitHubAuthService.requestAccessToken.mockResolvedValue({ + access_token: 'mock-token', + errors: [], + }); + + mockGitHubAuthService.createUserOctokit.mockReturnValue({ + request: jest.fn().mockResolvedValue({ + data: { login: 'aj', html_url: 'https://github.com/aj' }, + }), + } as any); + + mockGitHubApiService.getUserInfo.mockResolvedValue({ + data: { + login: 'aj', + name: 'AJ', + email: 'aj@test.com', + id: 123, + }, + errors: [], + }); + + mockUserRepository.saveUser.mockResolvedValue({ + id: 1, + login: 'aj', + name: 'AJ', + email: 'aj@test.com', + }); + + mockGitHubApiService.getUserRepositories.mockResolvedValue({ + data: [{ name: 'repo1' }, { name: 'repo2' }], + errors: [], + }); + + const result = await authService.handleGitHubCallback('code123'); + + expect(result.errors).toHaveLength(0); + + if (result.data && 'provider' in result.data) { + expect(result.data.provider).toBe('github'); + expect(result.data.repos.length).toBe(2); + } else { + fail('Expected project badging result'); + } + }); + + it('should create issue for event badging callback', async () => { + mockGitHubAuthService.requestAccessToken.mockResolvedValue({ + access_token: 'mock-token', + errors: [], + }); + + mockGitHubAuthService.createUserOctokit.mockReturnValue({ + request: jest.fn().mockResolvedValue({ + data: { login: 'aj', html_url: 'https://github.com/aj' }, + }), + } as any); + + mockGitHubAppService.getInstallationOctokit.mockResolvedValue({} as any); + + jest.spyOn(envConfig, 'getEnvVar').mockImplementation((key: unknown) => { + const k = key as string; + if (k === 'GITHUB_APP_INSTALLATION_ID') return '12345'; + if (k === 'REPOSITORY_OWNER') return 'badging'; + if (k === 'REPOSITORY_NAME') return 'badging'; + return 'value'; + }); + + mockCryptoService.decrypt.mockReturnValue( + JSON.stringify({ + title: 'Event Title', + body: 'Event Body', + }) + ); + + mockCryptoService.convertToMarkdown.mockReturnValue('markdown body'); + + mockGitHubApiService.createIssue.mockResolvedValue({ + data: { url: 'https://github.com/issue/1' }, + errors: [], + }); + + const result = await authService.handleGitHubCallback( + 'code123', + 'encrypted-state' + ); + + expect(result.errors).toHaveLength(0); + + expect(result.data).toEqual({ + issueUrl: 'https://github.com/issue/1', + }); + }); + }); + + describe('handleGitLabCallback', () => { + it('should return error if token exchange fails', async () => { + mockGitLabAuthService.requestAccessToken.mockResolvedValue({ + access_token: null, + errors: ['token failed'], + }); + + await expect(authService.handleGitLabCallback('code')).rejects.toThrow('token failed'); + }); + + it('should return user and repositories for GitLab login', async () => { + mockGitLabAuthService.requestAccessToken.mockResolvedValue({ + access_token: 'token', + errors: [], + }); + + mockGitLabApiService.getUserInfo.mockResolvedValue({ + data: { + login: 'gitlabuser', + name: 'GitLab User', + email: 'user@gitlab.com', + id: 999, + }, + errors: [], + }); + + mockUserRepository.saveUser.mockResolvedValue({ + id: 2, + login: 'gitlabuser', + name: 'GitLab User', + email: 'user@gitlab.com', + }); + + mockGitLabApiService.getUserRepositories.mockResolvedValue({ + data: [{ name: 'repoA' }], + errors: [], + }); + + const result = await authService.handleGitLabCallback('code'); + + expect(result.errors).toHaveLength(0); + + expect(result.data?.provider).toBe('gitlab'); + + expect(result.data?.repos.length).toBe(1); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/database/database.test.ts b/tests/unit/database/database.test.ts new file mode 100644 index 0000000..8874366 --- /dev/null +++ b/tests/unit/database/database.test.ts @@ -0,0 +1,171 @@ +const mockAuthenticate = jest.fn(); +const mockSync = jest.fn(); +const mockClose = jest.fn(); + +const mockSequelizeInstance = { + authenticate: mockAuthenticate, + sync: mockSync, + close: mockClose, +}; + +const MockSequelize = jest.fn().mockImplementation(() => mockSequelizeInstance); + +jest.mock('sequelize-typescript', () => ({ + Sequelize: MockSequelize, +})); + +jest.mock('../../../src/shared/config/environment', () => ({ + getEnvVar: jest.fn((key: string, _required?: boolean | string) => { + const values: Record = { + DB_NAME: 'badging_test', + DB_USER: 'test_user', + DB_PASSWORD: 'test_password', + DB_HOST: 'localhost', + DB_DIALECT: 'mysql', + DB_PORT: '3306', + }; + return values[key] ?? ''; + }), + isDevelopment: jest.fn(() => false), +})); + + +jest.mock('../../../src/shared/data-access/models/user.model', () => ({ User: class User {} })); +jest.mock('../../../src/shared/data-access/models/repo.model', () => ({ Repo: class Repo {} })); +jest.mock('../../../src/shared/data-access/models/event.model', () => ({ Event: class Event {} })); + +jest.mock('../../../src/shared/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +import { Database } from '../../../src/shared/data-access/database'; +import { User } from '../../../src/shared/data-access/models/user.model'; +import { Repo } from '../../../src/shared/data-access/models/repo.model'; +import { Event } from '../../../src/shared/data-access/models/event.model'; +import * as envConfig from '../../../src/shared/config/environment'; + +describe('Database (real class, mocked Sequelize)', () => { + beforeEach(() => { + MockSequelize.mockClear(); + mockAuthenticate.mockReset(); + mockSync.mockReset(); + mockClose.mockReset(); + (envConfig.isDevelopment as jest.Mock).mockReturnValue(false); + }); + + describe('constructor', () => { + test('should construct Sequelize with the options derived from env vars', () => { + new Database(); + + expect(MockSequelize).toHaveBeenCalledTimes(1); + expect(MockSequelize).toHaveBeenCalledWith( + expect.objectContaining({ + database: 'badging_test', + username: 'test_user', + password: 'test_password', + host: 'localhost', + dialect: 'mysql', + port: 3306, + models: [User, Repo, Event], + }), + ); + }); + + test('should parse DB_PORT as an integer', () => { + new Database(); + + const passedOptions = MockSequelize.mock.calls[0][0] as { port: number }; + expect(passedOptions.port).toBe(3306); + expect(typeof passedOptions.port).toBe('number'); + }); + + test('should disable SQL logging in production', () => { + (envConfig.isDevelopment as jest.Mock).mockReturnValue(false); + + new Database(); + + const passedOptions = MockSequelize.mock.calls[0][0] as { logging: unknown }; + expect(passedOptions.logging).toBe(false); + }); + + test('should enable SQL logging in development', () => { + (envConfig.isDevelopment as jest.Mock).mockReturnValue(true); + + new Database(); + + const passedOptions = MockSequelize.mock.calls[0][0] as { logging: unknown }; + expect(typeof passedOptions.logging).toBe('function'); + }); + }); + + describe('connect()', () => { + test('should call authenticate() and sync() (without force) in production', async () => { + (envConfig.isDevelopment as jest.Mock).mockReturnValue(false); + mockAuthenticate.mockResolvedValueOnce(undefined); + mockSync.mockResolvedValueOnce(undefined); + + const db = new Database(); + await db.connect(); + + expect(mockAuthenticate).toHaveBeenCalledTimes(1); + expect(mockSync).toHaveBeenCalledTimes(1); + expect(mockSync).toHaveBeenCalledWith(); + }); + + test('should call sync({ force: true }) in development', async () => { + (envConfig.isDevelopment as jest.Mock).mockReturnValue(true); + mockAuthenticate.mockResolvedValueOnce(undefined); + mockSync.mockResolvedValueOnce(undefined); + + const db = new Database(); + await db.connect(); + + expect(mockAuthenticate).toHaveBeenCalledTimes(1); + expect(mockSync).toHaveBeenCalledWith({ force: true }); + }); + + test('should rethrow if authenticate() rejects', async () => { + mockAuthenticate.mockRejectedValueOnce(new Error('ECONNREFUSED')); + + const db = new Database(); + + await expect(db.connect()).rejects.toThrow('ECONNREFUSED'); + expect(mockAuthenticate).toHaveBeenCalledTimes(1); + expect(mockSync).not.toHaveBeenCalled(); + }); + }); + + describe('getSequelize()', () => { + test('should return the underlying Sequelize instance', () => { + const db = new Database(); + + expect(db.getSequelize()).toBe(mockSequelizeInstance); + }); + }); + + describe('disconnect()', () => { + test('should call close() on the Sequelize instance', async () => { + mockClose.mockResolvedValueOnce(undefined); + + const db = new Database(); + await db.disconnect(); + + expect(mockClose).toHaveBeenCalledTimes(1); + }); + + test('should call close() each time it is invoked', async () => { + mockClose.mockResolvedValue(undefined); + + const db = new Database(); + await db.disconnect(); + await db.disconnect(); + + expect(mockClose).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/tests/unit/event-badging/checklist.service.test.ts b/tests/unit/event-badging/checklist.service.test.ts new file mode 100644 index 0000000..ee32fd6 --- /dev/null +++ b/tests/unit/event-badging/checklist.service.test.ts @@ -0,0 +1,69 @@ +import { ChecklistService } from '../../../src/event-badging/services/checklist.service'; +import { createMockGitHubApiService } from '../../mocks/github.mock'; +import { logger } from '../../../src/shared/logger'; + +describe('ChecklistService', () => { + let service: ChecklistService; + let mockGitHubApiService: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + mockGitHubApiService = createMockGitHubApiService(); + service = new ChecklistService(mockGitHubApiService as any); + }); + + test('getChecklistType() should identify virtual vs in-person events', () => { + expect(service.getChecklistType('[Virtual Event] My Conf')).toBe('virtual'); + expect(service.getChecklistType('[In-Person Event] My Conf')).toBe('in-person'); + }); + + test('checkModerator() should return true if user is in the moderator list', async () => { + mockGitHubApiService.getRepoContent.mockResolvedValue({ + data: '# Moderators\n- user1\n- user2\n- tyler', + errors: [] + }); + + const isMod = await service.checkModerator({} as any, 'o', 'r', 'tyler'); + expect(isMod).toBe(true); + }); + + test('generateReviewerChecklist() should throw error if template fetch fails', async () => { + mockGitHubApiService.getRepoContent.mockResolvedValue({ + data: null, + errors: ['404 Not Found'] + }); + + await expect(service.generateReviewerChecklist({} as any, 'o', 'r', 'body', 'virtual')) + .rejects.toThrow('Failed to fetch checklist template'); + }); + + test('checkModerator() should return false and log warning if file is missing', async () => { + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => logger); + mockGitHubApiService.getRepoContent.mockResolvedValue({ data: null, errors: ['File missing'] }); + + const result = await service.checkModerator({} as any, 'o', 'r', 'user'); + + expect(result).toBe(false); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + test('generateReviewerChecklist() should remove existing checkmarks from body', async () => { + const mockTemplate = '## Initial checks\n## Metric based checks\n### Event Demographics\n### Inclusive Experience at Event\n### Time Inclusion for Virtual Events\n### Code of Conduct at Event\n### Diversity Access tickets\n### Event Accessibility'; + + mockGitHubApiService.getRepoContent.mockResolvedValue({ data: mockTemplate, errors: [] }); + + const issueBody = '## Event Demographics\n- [x] Done\n- [ ] Pending\n## Inclusive Experience at Event'; + const result = await service.generateReviewerChecklist({} as any, 'o', 'r', issueBody, 'virtual'); + + expect(result).not.toContain('[x]'); + expect(result).not.toContain('[ ]'); + }); + + test('combineVirtualChecklist() should throw if expected headers are missing', async () => { + mockGitHubApiService.getRepoContent.mockResolvedValue({ data: 'template', errors: [] }); + const brokenBody = 'Just some text without headers'; + const result = await service.generateReviewerChecklist({} as any, 'o', 'r', brokenBody, 'virtual'); + expect(result).toBeDefined(); + }); +}); \ No newline at end of file diff --git a/tests/unit/event-badging/event-badging.service.test.ts b/tests/unit/event-badging/event-badging.service.test.ts new file mode 100644 index 0000000..39c97fc --- /dev/null +++ b/tests/unit/event-badging/event-badging.service.test.ts @@ -0,0 +1,223 @@ +import { EventBadgingService } from '../../../src/event-badging/services/event-badging.service'; +import { GitHubApiService } from '../../../src/shared/providers/github/github-api.service'; +import { GitHubAppService } from '../../../src/shared/providers/github/github-app.service'; +import { EventRepository } from '../../../src/shared/data-access/repositories/event.repository'; +import { ChecklistService } from '../../../src/event-badging/services/checklist.service'; +import { ScoringService } from '../../../src/event-badging/services/scoring.service'; +import { IGitHubWebhookPayload } from '../../../src/shared/types'; +import { createMockOctokit } from '../../mocks/octokit.mock'; + +describe('EventBadgingService', () => { + let service: EventBadgingService; + let githubApiService: jest.Mocked; + let githubAppService: jest.Mocked; + let eventRepository: jest.Mocked; + let checklistService: jest.Mocked; + let scoringService: jest.Mocked; + let mockOctokit: ReturnType; + + beforeEach(() => { + jest.resetAllMocks(); + jest.spyOn(console, 'info').mockImplementation(() => {}); + + + mockOctokit = createMockOctokit(); + + githubApiService = { + createIssueComment: jest.fn(), + addLabels: jest.fn(), + removeLabel: jest.fn(), + closeIssue: jest.fn(), + } as any; + + githubAppService = { + getInstallationOctokit: jest.fn().mockResolvedValue(mockOctokit), + } as any; + + eventRepository = { + createEvent: jest.fn(), + } as any; + + checklistService = { + getApplicantWelcome: jest.fn(), + getChecklistType: jest.fn(), + generateReviewerChecklist: jest.fn(), + getReviewerWelcome: jest.fn(), + } as any; + + scoringService = { + calculateBadge: jest.fn(), + } as any; + + service = new EventBadgingService( + githubApiService, + githubAppService, + eventRepository, + checklistService, + scoringService + ); + }); + + const createPayload = (title: string, action: string): IGitHubWebhookPayload => ({ + action, + installation: { id: 123 }, + repository: { name: 'sandbox-event-dei', owner: { login: 'owner' } }, + issue: { + number: 1, + title, + body: "# Virtual Event Submission ## Requirements - Event Name: teststs - Link to the Event Website: https://event.com - Provide verification that you are an event organizer: Yes ## Event Demographics - \[ \] This event commits to Event Diversity and Inclusion. - `Q` Detail the process for measuring Event Demographics. - `A` - `Q` Provide an example of an opt-out option on the Event registration page if available. - `A` - `Q` Provide an example of a demographics text input box on the Event registration page if available. - `A` ## Inclusive Experience at Event - \[ \] This event commits to the Code of Conduct at Event. - `Q` Provide an example of the Event Feedback page if available. - `A` - `Q` Is the event team using feedback from previous event's attendees, speakers, and volunteers to improve DEI at this event? - `A` - `Q` Does the event team plan to use feedback from this event's attendees, speakers, and volunteers to improve DEI at future events? - `A` - `Q` How can attendees learn more about accessibility at the event? - `A` - `Q` Does the event platform allow attendees to suggest future accomodations for the event? - `A` - `Q` Will the event platform be accessible to attendees and speakers after the event? - `A` ## Time Inclusion for Virtual Events - \[ \] This event commits to Time Inclusion for Virtual Events. - `Q` Are speakers able to pre-record their presentations, as opposed to presenting them live? - `A` null - `Q` Can attendees change video quality on the Event platform while viewing a presentation? - `A` null ## Code of Conduct at Event - \[ \] This event commits to the Code of Conduct at Event. - `Q` Is the code of conduct posted at Event venue? - `A` null - `Q` Provide a link for the Event Code of Conduct. - `A` ## Diversity Access Tickets - \[ \] This event commits to the Diversity Access Tickets. - `Q` How many different types of diversity access tickets are available for the event? - `A` - `Q` What are the criteria for qualifying for a diversity access ticket? - `A` - `Q` Provide a link to the page containing information about Diversity Access Tickets. - `A` ## Event Accessibility - \[ \] This event commits to Event Accessibility. - `Q` Closed captioning is provided for this event. - `A` - `Q` Other accommodations are provided, or will be provided upon request. - `A` - `Q` Provide a link to the page containing information about Event Accessibility. - `A`", + html_url: 'https://github.com/owner/sandbox-event-dei/issues/1', + assignees: [], + } + } as any); + + test('should post welcome message when an event issue is opened', async () => { + const payload = createPayload('[Virtual Event] test', 'opened'); + checklistService.getApplicantWelcome.mockResolvedValue('Thanks for applying to CHAOSS Badging'); + + await service.processWebhook('issues', payload); + + expect(githubAppService.getInstallationOctokit).toHaveBeenCalledWith(123); + + expect(githubApiService.createIssueComment).toHaveBeenCalledWith( + mockOctokit, + 'owner', + 'sandbox-event-dei', + 1, + 'Thanks for applying to CHAOSS Badging' + ); + }); + + test('should post checklist and add label when second reviewer is assigned', async () => { + const payload = createPayload('[Virtual Event] test', 'assigned'); + payload.issue!.assignees = [ + { login: 'reviewer1' }, + { login: 'reviewer2' } + ] as any; + + payload.assignee = { login: 'reviewer2' } as any; + + checklistService.getChecklistType.mockReturnValue('virtual' as any); + checklistService + .generateReviewerChecklist + .mockResolvedValue(`## Time Inclusion for Virtual Events + - [ ] Task 1 + - [ ] Task 2 + `); + checklistService.getReviewerWelcome.mockResolvedValue('Thank you for becoming a part of this Event Badging review.'); + + await service.processWebhook('issues', payload); + + expect(githubApiService.createIssueComment).toHaveBeenCalledWith( + mockOctokit, + 'owner', + 'sandbox-event-dei', + 1, + expect.stringContaining('## Time Inclusion for Virtual Events') + ); + + expect(githubApiService.addLabels).toHaveBeenCalledWith( + mockOctokit, + 'owner', + 'sandbox-event-dei', + 1, + ['review-begin'] + ); + }); + + test('should calculate score and save to DB when issue is closed', async () => { + const payload = createPayload('[In-Person Event] Global Conf', 'closed'); + + payload.issue!.body = "# In-Person Event Submission ## Requirements - Event Name: test2 - Link to the Event Website: https://event.com\n - Are you an organizer of this event? null ## Event Demographics - \[ \] This event commits to Event Diversity and Inclusion. - `Q` Detail the process for measuring Event Demographics. - `A` - `Q` Provide an example of an opt-out option on the Event registration page if available. - `A` - `Q` Provide an example of a demographics text input box on the Event registration page if available. - `A` ## Inclusive Experience at Event - \[ \] This event commits to the Code of Conduct at Event. - `Q` Provide an example of the Event Feedback page if available. - `A` - `Q` Is the event team using feedback from previous event's attendees, speakers, and volunteers to improve DEI at this event? - `A` - `Q` Does the event team plan to use feedback from this event's attendees, speakers, and volunteers to improve DEI at future events? - `A` - `Q` How can attendees learn more about accessibility at the event? - `A` - `Q` Does the event platform allow attendees to suggest future accomodations for the event? - `A` - `Q` Will the event platform be accessible to attendees and speakers after the event? - `A` ## Code of Conduct at Event - \[ \] This event commits to the Code of Conduct at Event. - `Q` Is the code of conduct posted at Event venue? - `A` null - `Q` Provide a link for the Event Code of Conduct. - `A` ## Diversity Access Tickets - \[ \] This event commits to the Diversity Access Tickets. - `Q` How many different types of diversity access tickets are available for the event? - `A` - `Q` What are the criteria for qualifying for a diversity access ticket? - `A` - `Q` Provide a link to the page containing information about Diversity Access Tickets. - `A` ## Family Friendliness - \[ \] This event commits to Family Friendliness. - `Q` Does the Event provide childcare facilities for its attendees and speakers? - `A` - `Q` What are the other ways that a family-friendly environment is being created in the Event? - `A` - `Q` Provide relevant links related to family friendliness at the Event. - `A` ## Event Accessibility - \[ \] This event commits to Event Accessibility. - `Q` Is the Event in a wheelchair-accessible venue? - `A` - `Q` Are speakers given guidance about creating slides that are colorblind accessible? - `A` - `Q` Is signage at the event and the event website colorblind accessible? - `A` - `Q` Does the Event provide other accessibility accommodations, or will upon request? - `A` - `Q` Provide relevant links related to event accessibility at the Event. - `A` ## Event Location Inclusivity - \[ \] This event commits to Event Location Inclusivity. - `Q` Has the Event's location been checked against lists of places of concern for the following demographics: sexual and gender minorities, people with disabilities, racial and ethnic minorities, women, or religious minorities? - `A` - `Q` Have the Event's dates been checked for other events happening in the same location at the same time that could potentially bring harm to a subset of any attendees? - `A` - `Q` The Event website addresses or acknowledges any cause for concern for marginalized attendees. - `A` ## Public Health and Safety - \[ \] This event commits to Public Health and Safety. - `Q` Have you participated in the Public Health Pledge Badging Program? - `A` null - `Q` Provide a link to the PHPledge Badges received. - `A` undefined - `Q` Provide a link to publicly available information about public health and safety on the event website. - `A`", + + payload.issue!.assignees = [ + { login: 'reviewer1', id: 1, html_url: 'https://github.com/reviewer1', type: 'User' }, + { login: 'reviewer2', id: 2, html_url: 'https://github.com/reviewer2', type: 'User' } + ] as any; + + scoringService.calculateBadge.mockResolvedValue({ + markdownBadgeImage: '![Badge](badge-url)', + htmlBadgeImage: 'Badge', + reviewResult: 90, + reviewerCount: 2, + assigned_badge: 'Gold', + badge_URL: 'gold-url', + } as any); + + await service.processWebhook('issues', payload); + + expect(eventRepository.createEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: 'Global Conf', + event_URL: 'https://event.com', + badge: { name: 'Gold', badgeURL: 'gold-url' }, + reviewers: [ + { name: 'reviewer1', github_profile_link: 'https://github.com/reviewer1' }, + { name: 'reviewer2', github_profile_link: 'https://github.com/reviewer2' } + ], + application: { app_no: 1, app_URL: 'https://github.com/owner/sandbox-event-dei/issues/1' } + }) + ); + }); + + test('should ignore non-event issues', async () => { + const payload = createPayload('General Bug Report', 'opened'); + const infoSpy = jest.spyOn(console, 'info').mockImplementation(); + + await service.processWebhook('issues', payload); + + expect(githubAppService.getInstallationOctokit).not.toHaveBeenCalled(); + expect(githubApiService.createIssueComment).not.toHaveBeenCalled(); + + infoSpy.mockRestore(); + }); + + test('should handle and log errors during comment creation', async () => { + const payload = createPayload('[Event]', 'opened'); + + githubApiService.createIssueComment.mockRejectedValue(new Error('GitHub Down')); + + await expect(service.processWebhook('issues', payload)).rejects.toThrow( + 'Failed to post applicant welcome message' + ); + }); + + test('should process /result command from a comment', async () => { + const payload = createPayload('[Virtual Event]', 'created'); + payload.comment = { body: '/result' } as any; + + scoringService.calculateBadge.mockResolvedValue({ + reviewResult: 85, + reviewerCount: 2 + } as any); + + await service.processWebhook('issue_comment', payload); + + expect(githubApiService.createIssueComment).toHaveBeenCalledWith( + mockOctokit, + 'owner', + 'sandbox-event-dei', + 1, + expect.stringContaining('85') + ); + }); + + test('should parse Event URL correctly for Virtual Events', async () => { + const payload = createPayload('[Virtual Event] Online Meetup', 'closed'); + + payload.issue!.body = + '- Link to the Event Website: https://virtual.io\n - Provide verification that you are an event organizer: Yes'; + + scoringService.calculateBadge.mockResolvedValue({ + assigned_badge: 'Silver' + } as any); + + await service.processWebhook('issues', payload); + + expect(eventRepository.createEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_URL: 'https://virtual.io' + }) + ); + }); +}); \ No newline at end of file diff --git a/tests/unit/event-badging/scoring.service.test.ts b/tests/unit/event-badging/scoring.service.test.ts new file mode 100644 index 0000000..c44ec13 --- /dev/null +++ b/tests/unit/event-badging/scoring.service.test.ts @@ -0,0 +1,134 @@ +import { ScoringService } from '../../../src/event-badging/services/scoring.service'; +import { createMockGitHubApiService } from '../../mocks/github.mock'; +import { createMockOctokit } from '../../mocks/octokit.mock'; +import * as envConfig from '../../../src/shared/config/environment'; + + +describe('ScoringService', () => { + let service: ScoringService; + let githubApiServiceMock: ReturnType; + let mockOctokit: any; + + beforeEach(() => { + githubApiServiceMock = createMockGitHubApiService(); + mockOctokit = createMockOctokit(); + + + service = new ScoringService(githubApiServiceMock as any); + + jest.spyOn(envConfig, 'isDevelopment').mockReturnValue(false); + jest.spyOn(envConfig, 'getEnvVar').mockImplementation((key) => { + if (key === 'REPOSITORY_NAME') return 'chaoss-main-repo'; + return ''; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should calculate a GOLD badge (100%) correctly', async () => { + const mockComments = [{ + user: { type: 'Bot' }, + body: '# Checklist for Reviewer 1\n- [x] 1\n- [x] 2\n- [x] 3\n- [x] 4\n- [x] 5\n- [x] 6\n- [x] 7\n- [x] 8' + }]; + + githubApiServiceMock.listIssueComments.mockResolvedValue({ data: mockComments as any, errors: [] }); + + const result = await service.calculateBadge(mockOctokit, 'org', 'repo', 1, 'url', 'other-repo'); + + // Math: (8 total - 6 initial) = 2. (8 checked - 6 initial) = 2. 2/2 = 100%. + expect(result.assigned_badge).toBe('Gold'); + expect(result.reviewResult).toBe(100); + expect(result.reviewerCount).toBe(1); + }); + + it('should calculate SILVER badge for multiple reviewers with varying scores', async () => { + const mockComments = [ + { + user: { type: 'Bot' }, + body: '# Checklist for A\n- [x] 1\n- [x] 2\n- [x] 3\n- [x] 4\n- [x] 5\n- [x] 6\n- [x] 7\n- [x] 8' // 100% + }, + { + user: { type: 'Bot' }, + body: '# Checklist for B\n- [x] 1\n- [x] 2\n- [x] 3\n- [x] 4\n- [x] 5\n- [x] 6\n- [x] 7\n- [ ] 8' // 50% + } + ]; + + githubApiServiceMock.listIssueComments.mockResolvedValue({ data: mockComments as any, errors: [] }); + + const result = await service.calculateBadge(mockOctokit, 'org', 'repo', 1, 'url', 'other-repo'); + + // Avg: (100 + 50) / 2 = 75% + expect(result.assigned_badge).toBe('Silver'); + expect(result.reviewResult).toBe(75); + }); + + it('should use initialCheckCount of 4 when repo matches targetRepoName', async () => { + const repoName = 'chaoss-main-repo'; + const mockComments = [{ + user: { type: 'Bot' }, + body: '# Checklist for\n- [x] 1\n- [x] 2\n- [x] 3\n- [x] 4\n- [x] 5' + }]; + + githubApiServiceMock.listIssueComments.mockResolvedValue({ data: mockComments as any, errors: [] }); + + const result = await service.calculateBadge(mockOctokit, 'o', 'r', 1, 'url', repoName); + + // Math: (5 items - 4 initial) = 1. (5 checked - 4 initial) = 1. Score 100%. + expect(result.reviewResult).toBe(100); + }); + + it('should throw an error if the GitHub API returns errors', async () => { + githubApiServiceMock.listIssueComments.mockResolvedValue({ + data: null, + errors: ['API Rate Limit' as any] + }); + + await expect(service.calculateBadge(mockOctokit, 'o', 'r', 1, 'url', 'repo')) + .rejects.toThrow('Failed to get comments: API Rate Limit'); + }); + + it('should return a Pending badge with 0 score if no checklists are found', async () => { + const mockComments = [ + { user: { type: 'User' }, body: '# Checklist for Human' }, // Type User, not Bot + { user: { type: 'Bot' }, body: 'Hello world' } // No checklist header + ]; + + githubApiServiceMock.listIssueComments.mockResolvedValue({ data: mockComments as any, errors: [] }); + + const result = await service.calculateBadge(mockOctokit, 'o', 'r', 1, 'url', 'repo'); + + expect(result.reviewResult).toBe(0); + expect(result.assigned_badge).toBe('Pending'); + expect(result.reviewerCount).toBe(0); + }); + + it('should handle scores below 0 by capping positive checks at 0', async () => { + const mockComments = [{ + user: { type: 'Bot' }, + body: '# Checklist for\n- [x] 1\n- [ ] 2' // Only 1 checked, initial count is 6 + }]; + + githubApiServiceMock.listIssueComments.mockResolvedValue({ data: mockComments as any, errors: [] }); + + const result = await service.calculateBadge(mockOctokit, 'o', 'r', 1, 'url', 'repo'); + + // positiveCheckCount becomes Math.max(0, 1 - 6) = 0. + expect(result.reviewResult).toBe(0); + expect(result.assigned_badge).toBe('Pending'); + }); + + it('should return 0% percentage if total items minus initial count is 0 or less', async () => { + const mockComments = [{ + user: { type: 'Bot' }, + body: '# Checklist for\n- [x] 1\n- [x] 2\n- [x] 3\n- [x] 4\n- [x] 5\n- [x] 6' + // 6 items, 6 initial. Total = 0. + }]; + + githubApiServiceMock.listIssueComments.mockResolvedValue({ data: mockComments as any, errors: [] }); + + const result = await service.calculateBadge(mockOctokit, 'o', 'r', 1, 'url', 'repo'); + expect(result.reviewResult).toBe(0); + }); +}); \ No newline at end of file diff --git a/tests/unit/project-badging/badge-award.service.test.ts b/tests/unit/project-badging/badge-award.service.test.ts new file mode 100644 index 0000000..d77dd42 --- /dev/null +++ b/tests/unit/project-badging/badge-award.service.test.ts @@ -0,0 +1,127 @@ +import { BadgeAwardService } from '../../../src/project-badging/services/badge-award.service'; +import { RepoRepository, MailerService, AugurApiService } from '../../../src/shared'; + +describe('BadgeAwardService', () => { + let service: BadgeAwardService; + let repoRepository: jest.Mocked; + let mailerService: jest.Mocked; + let augurApiService: jest.Mocked; + + const mockUser = { + id: 1, + name: 'Jane Doe', + email: 'jane@chaoss.org' + }; + + const mockRepoDetails = { + githubId: 12345, + gitlabId: null, + url: 'https://github.com/org/repo', + sha: 'dei-sha-789' + }; + + beforeEach(() => { + repoRepository = { saveRepo: jest.fn() } as any; + mailerService = { sendBadgingEmail: jest.fn() } as any; + augurApiService = { registerBadgedRepo: jest.fn() } as any; + + service = new BadgeAwardService( + repoRepository, + mailerService, + augurApiService + ); + + jest.spyOn(console, 'log').mockImplementation(() => {}); + + jest.clearAllMocks(); + }); + + it('should successfully award a bronze badge and register with Augur', async () => { + repoRepository.saveRepo.mockResolvedValue(101); // Returns a DB ID + mailerService.sendBadgingEmail.mockResolvedValue(true); + augurApiService.registerBadgedRepo.mockResolvedValue({ status: 'success' } as any); + + const result = await service.awardBronzeBadge( + mockUser.id, + mockUser.name, + mockUser.email, + mockRepoDetails.githubId, + mockRepoDetails.gitlabId, + mockRepoDetails.url, + mockRepoDetails.sha + ); + + expect(result).toBe(true); + expect(mailerService.sendBadgingEmail).toHaveBeenCalledWith( + mockUser.email, mockUser.name, 'Bronze', expect.any(String), expect.any(String) + ); + expect(repoRepository.saveRepo).toHaveBeenCalledWith( + mockRepoDetails.githubId, null, mockRepoDetails.sha, mockRepoDetails.url, 'Bronze', expect.any(String), mockUser.id + ); + // Verify Augur Registration + expect(augurApiService.registerBadgedRepo).toHaveBeenCalledWith(101, 'bronze', mockRepoDetails.url); + }); + + it('should send failure notification with correctly formatted missing sections', async () => { + const missing = ['Project License', 'Code of Conduct']; + + await service.sendFailureNotification(mockUser.email, mockUser.name, missing); + + const expectedResults = 'Project License is missing\nCode of Conduct is missing'; + expect(mailerService.sendBadgingEmail).toHaveBeenCalledWith( + mockUser.email, + mockUser.name, + 'Bronze', + null, + null, + expectedResults + ); + }); + + it('should return false if repoRepository fails to save the record', async () => { + repoRepository.saveRepo.mockResolvedValue(null); + + const result = await service.awardBronzeBadge( + mockUser.id, mockUser.name, mockUser.email, 1, null, 'url', 'sha' + ); + + expect(result).toBe(false); + expect(augurApiService.registerBadgedRepo).not.toHaveBeenCalled(); + }); + + it('should throw AppError if mailerService throws', async () => { + mailerService.sendBadgingEmail.mockRejectedValue(new Error('SMTP Down')); + + await expect( + service.awardBronzeBadge( + mockUser.id, mockUser.name, mockUser.email, 1, null, 'url', 'sha' + ) + ).rejects.toThrow('Badge awarding process failed'); + }); + + it('should handle GitLab repository IDs correctly when GitHub ID is null', async () => { + repoRepository.saveRepo.mockResolvedValue(202); + + await service.awardBronzeBadge( + mockUser.id, mockUser.name, mockUser.email, null, 999, 'url', 'sha' + ); + + expect(repoRepository.saveRepo).toHaveBeenCalledWith( + null, 999, 'sha', 'url', 'Bronze', expect.any(String), mockUser.id + ); + }); + + it('should format generic error messages correctly in sendErrorNotification', async () => { + const errors = ['API Error', 'Timeout']; + await service.sendErrorNotification(mockUser.email, mockUser.name, errors); + + expect(mailerService.sendBadgingEmail).toHaveBeenCalledWith( + mockUser.email, + mockUser.name, + 'Bronze', + null, + null, + 'API Error\nTimeout' + ); + }); +}); \ No newline at end of file diff --git a/tests/unit/project-badging/dei-scanner.service.test.ts b/tests/unit/project-badging/dei-scanner.service.test.ts new file mode 100644 index 0000000..d72caf7 --- /dev/null +++ b/tests/unit/project-badging/dei-scanner.service.test.ts @@ -0,0 +1,92 @@ +import { DEIScannerService } from '../../../src/project-badging/services/dei-scanner.service'; +import { DEI_REQUIRED_SECTIONS } from '../../../src/shared/types'; +import { logger } from '../../../src/shared/logger'; + +jest.mock('axios'); + +import axios from 'axios'; + +const mockAxiosGet = axios.get as jest.Mock; + +describe('DEIScannerService', () => { + let service: DEIScannerService; + + beforeEach(() => { + service = new DEIScannerService(); + jest.clearAllMocks(); + }); + + + + test('scanDEIContent() should return isValid true when all sections are present', () => { + const validContent = DEI_REQUIRED_SECTIONS.join('\n'); + + const result = service.scanDEIContent(validContent); + + expect(result.isValid).toBe(true); + expect(result.missingSections).toHaveLength(0); + }); + + test('isTemplateContent() should return true for matching content ignoring whitespace', () => { + const template = ' # Template Content '; + const userContent = '# Template Content'; + + const result = service.isTemplateContent(userContent, template); + + expect(result).toBe(true); + }); + + test('getDEITemplate() should fetch and decode the template from GitHub', async () => { + const base64Template = Buffer.from('# DEI Template').toString('base64'); + mockAxiosGet.mockResolvedValueOnce({ + data: { content: base64Template } + }); + + const result = await service.getDEITemplate(); + + expect(mockAxiosGet).toHaveBeenCalledWith( + expect.stringContaining('https://api.github.com/repos/badging/badging/contents/Template.DEI.md') + ); + expect(result).toBe('# DEI Template'); + }); + + test('scanDEIContent() should identify missing sections correctly', () => { + const incompleteContent = DEI_REQUIRED_SECTIONS.slice(2).join('\n'); + + const result = service.scanDEIContent(incompleteContent); + + expect(result.isValid).toBe(false); + expect(result.missingSections).toContain(DEI_REQUIRED_SECTIONS[0]); + expect(result.missingSections).toContain(DEI_REQUIRED_SECTIONS[1]); + }); + + test('getDEITemplate() should return null and log error on API failure', async () => { + mockAxiosGet.mockRejectedValueOnce(new Error('GitHub API Limit')); + const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => logger); + + const result = await service.getDEITemplate(); + + expect(result).toBeNull(); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + test('scanDEIContent() should be case-sensitive (or insensitive based on current logic)', () => { + // Note: This current implementation uses .includes(section) which is case-sensitive. + // If DEI_REQUIRED_SECTIONS has "Project Access" and content has "project access", it will fail. + const lowercaseContent = DEI_REQUIRED_SECTIONS[0].toLowerCase(); + + const result = service.scanDEIContent(lowercaseContent); + + expect(result.missingSections).toContain(DEI_REQUIRED_SECTIONS[0]); + }); + + test('isTemplateContent() should return false if content is slightly modified', () => { + const template = '# Template'; + const userContent = '# Template - Updated by Me'; + + const result = service.isTemplateContent(userContent, template); + + expect(result).toBe(false); + }); +}); \ No newline at end of file diff --git a/tests/unit/project-badging/project-badging.service.test.ts b/tests/unit/project-badging/project-badging.service.test.ts new file mode 100644 index 0000000..93b6cd9 --- /dev/null +++ b/tests/unit/project-badging/project-badging.service.test.ts @@ -0,0 +1,134 @@ +import { ProjectBadgingService } from '../../../src/project-badging/services/project-badging.service'; +import { GitHubApiService, GitLabApiService, RepoRepository } from '../../../src/shared'; +import { DEIScannerService } from '../../../src/project-badging/services/dei-scanner.service'; +import { BadgeAwardService } from '../../../src/project-badging/services/badge-award.service'; + +describe('ProjectBadgingService', () => { + let service: ProjectBadgingService; + let githubApiService: jest.Mocked; + let gitlabApiService: jest.Mocked; + let repoRepository: jest.Mocked; + let deiScannerService: jest.Mocked; + let badgeAwardService: jest.Mocked; + + const mockUser = { id: 1, name: 'Dev', email: 'dev@test.com' }; + + beforeEach(() => { + githubApiService = { getRepositoryInfo: jest.fn(), getFileContentAndSHA: jest.fn() } as any; + gitlabApiService = { getRepositoryInfo: jest.fn(), getFileContentAndSHA: jest.fn() } as any; + repoRepository = { + findByGithubRepoAndSHA: jest.fn(), + findByGitlabRepoAndSHA: jest.fn(), + getAllBadgedRepos: jest.fn() + } as any; + deiScannerService = { + getDEITemplate: jest.fn().mockResolvedValue('# Template'), + isTemplateContent: jest.fn().mockReturnValue(false), + scanDEIContent: jest.fn().mockReturnValue({ isValid: true, missingSections: [] }) + } as any; + badgeAwardService = { awardBronzeBadge: jest.fn(), sendErrorNotification: jest.fn(), sendFailureNotification: jest.fn() } as any; + + service = new ProjectBadgingService( + githubApiService, + gitlabApiService, + repoRepository, + deiScannerService, + badgeAwardService + ); + }); + + + + test('scanRepositories() should badge a GitHub repo successfully', async () => { + // 1. Mock API Info + githubApiService.getRepositoryInfo.mockResolvedValue({ + data: { id: 123, fullName: 'org/repo', url: 'http://gh.com/repo' }, errors: [] + }); + // 2. Mock File Content + githubApiService.getFileContentAndSHA.mockResolvedValue({ + data: { content: 'DEI Content', sha: 'sha1' }, errors: [] + }); + // 3. Not already badged + repoRepository.findByGithubRepoAndSHA.mockResolvedValue(null); + // 4. Award success + badgeAwardService.awardBronzeBadge.mockResolvedValue(true); + + const results = await service.scanRepositories(mockUser.id, mockUser.name, mockUser.email, [123], 'github'); + + expect(results[0].success).toBe(true); + expect(badgeAwardService.awardBronzeBadge).toHaveBeenCalled(); + }); + + test('scanRepositories() should badge a GitLab repo successfully', async () => { + gitlabApiService.getRepositoryInfo.mockResolvedValue({ + data: { id: 456, url: 'http://gl.com/repo', defaultBranch: 'main' }, errors: [] + }); + gitlabApiService.getFileContentAndSHA.mockResolvedValue({ + data: { content: 'DEI Content', sha: 'sha2' }, errors: [] + }); + repoRepository.findByGitlabRepoAndSHA.mockResolvedValue(null); + badgeAwardService.awardBronzeBadge.mockResolvedValue(true); + + const results = await service.scanRepositories(mockUser.id, mockUser.name, mockUser.email, [456], 'gitlab'); + + expect(results[0].success).toBe(true); + expect(gitlabApiService.getRepositoryInfo).toHaveBeenCalledWith(456); + }); + + // ## --- Failure Paths & Logic Gates --- ## + + test('should return failure if DEI.md is missing', async () => { + githubApiService.getRepositoryInfo.mockResolvedValue({ data: { url: 'url' } as any, errors: [] }); + githubApiService.getFileContentAndSHA.mockResolvedValue({ data: null, errors: ['Not found'] }); + + const results = await service.scanRepositories(mockUser.id, mockUser.name, mockUser.email, [123], 'github'); + + expect(results[0].success).toBe(false); + expect(results[0].message).toContain('does not have a DEI.md file'); + }); + + test('should fail if user provides exact template content', async () => { + githubApiService.getRepositoryInfo.mockResolvedValue({ data: { id: 1 } as any, errors: [] }); + githubApiService.getFileContentAndSHA.mockResolvedValue({ data: { content: 'template' } as any, errors: [] }); + deiScannerService.isTemplateContent.mockReturnValue(true); // User didn't edit the template + + const results = await service.scanRepositories(mockUser.id, mockUser.name, mockUser.email, [1], 'github'); + + expect(results[0].success).toBe(false); + expect(results[0].message).toContain('Please provide DEI information specific to'); + }); + + test('should fail if repo is already badged with same SHA', async () => { + githubApiService.getRepositoryInfo.mockResolvedValue({ data: { id: 1 } as any, errors: [] }); + githubApiService.getFileContentAndSHA.mockResolvedValue({ data: { sha: 'existing-sha' } as any, errors: [] }); + repoRepository.findByGithubRepoAndSHA.mockResolvedValue({ id: 99 } as any); // Found existing record + + const results = await service.scanRepositories(mockUser.id, mockUser.name, mockUser.email, [1], 'github'); + + expect(results[0].success).toBe(false); + expect(results[0].message).toContain('was already badged'); + }); + + test('should trigger error notification email if any repo fails', async () => { + githubApiService.getRepositoryInfo.mockResolvedValue({ data: null, errors: ['API Error'] }); + + await service.scanRepositories(mockUser.id, mockUser.name, mockUser.email, [1], 'github'); + + expect(badgeAwardService.sendErrorNotification).toHaveBeenCalledWith( + mockUser.email, mockUser.name, ['API Error'] + ); + }); + + + + test('should handle invalid scan result with missing sections', async () => { + githubApiService.getRepositoryInfo.mockResolvedValue({ data: { id: 1 } as any, errors: [] }); + githubApiService.getFileContentAndSHA.mockResolvedValue({ data: { content: 'bad' } as any, errors: [] }); + deiScannerService.scanDEIContent.mockReturnValue({ isValid: false, missingSections: ['Project Access'] }); + + const results = await service.scanRepositories(mockUser.id, mockUser.name, mockUser.email, [1], 'github'); + + expect(results[0].success).toBe(false); + expect(badgeAwardService.sendFailureNotification).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/tests/unit/shared/providers/github/github-api.service.test.ts b/tests/unit/shared/providers/github/github-api.service.test.ts new file mode 100644 index 0000000..c82f81e --- /dev/null +++ b/tests/unit/shared/providers/github/github-api.service.test.ts @@ -0,0 +1,113 @@ +import { GitHubApiService } from '../../../../../src/shared/providers/github/github-api.service'; +import { createMockOctokit } from '../../../../mocks/octokit.mock'; + +describe('GitHubApiService', () => { + let service: GitHubApiService; + let mockOctokit: any; + + beforeEach(() => { + service = new GitHubApiService(); + mockOctokit = createMockOctokit(); + }); + + describe('getUserInfo', () => { + it('should return user info on success', async () => { + mockOctokit.users.getAuthenticated.mockResolvedValue({ + data: { login: 'tester', name: 'Test User', email: 'test@test.com', id: 1 }, + }); + + const result = await service.getUserInfo(mockOctokit); + + expect(result.data?.login).toBe('tester'); + expect(result.errors).toHaveLength(0); + }); + + it('should fallback to login if name is null', async () => { + mockOctokit.users.getAuthenticated.mockResolvedValue({ + data: { login: 'tester', name: null, email: null, id: 1 }, + }); + + const result = await service.getUserInfo(mockOctokit); + expect(result.data?.name).toBe('tester'); + }); + + it('should return error message on failure', async () => { + mockOctokit.users.getAuthenticated.mockRejectedValue(new Error('Bad Credentials')); + + const result = await service.getUserInfo(mockOctokit); + expect(result.data).toBeNull(); + expect(result.errors).toContain('Bad Credentials'); + }); + }); + + describe('getUserRepositories (Pagination)', () => { + it('should fetch all pages of repositories', async () => { + // First call returns 1 repo, second call returns 0 (ends loop) + mockOctokit.repos.listForAuthenticatedUser + .mockResolvedValueOnce({ data: [{ id: 1, full_name: 'repo/1' }] }) + .mockResolvedValueOnce({ data: [] }); + + const result = await service.getUserRepositories(mockOctokit); + + expect(result.data).toHaveLength(1); + expect(mockOctokit.repos.listForAuthenticatedUser).toHaveBeenCalledTimes(2); + }); + + it('should return specific error message on failure', async () => { + mockOctokit.repos.listForAuthenticatedUser.mockRejectedValue(new Error('API Down')); + + const result = await service.getUserRepositories(mockOctokit); + expect(result.errors).toContain('GitHub API returning no repository(ies).'); + }); + }); + + describe('getFileContentAndSHA', () => { + it('should decode base64 content correctly', async () => { + const base64Content = Buffer.from('hello world').toString('base64'); + mockOctokit.repos.getContent.mockResolvedValue({ + data: { type: 'file', content: base64Content, sha: 'abc123' }, + }); + + const result = await service.getFileContentAndSHA(mockOctokit, 'owner/repo', 'file.md'); + + expect(result.data?.content).toBe('hello world'); + expect(result.data?.sha).toBe('abc123'); + }); + + it('should return error if path is a directory (array response)', async () => { + mockOctokit.repos.getContent.mockResolvedValue({ data: [{ name: 'file1.md' }] }); + + const result = await service.getFileContentAndSHA(mockOctokit, 'owner/repo', 'folder/'); + expect(result.errors).toContain('Path does not point to a file'); + }); + }); + + describe('Issue Management', () => { + it('should close an issue by calling update with state closed', async () => { + mockOctokit.rest.issues.update.mockResolvedValue({ data: {} }); + + const result = await service.closeIssue(mockOctokit, 'owner', 'repo', 1); + + expect(mockOctokit.rest.issues.update).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + issue_number: 1, + state: 'closed', + }); + expect(result.data).toBe(true); + }); + + it('should return mapped comments with user info', async () => { + mockOctokit.rest.issues.listComments.mockResolvedValue({ + data: [ + { id: 1, body: 'test body', user: { type: 'Bot', login: 'chaoss-bot' } } + ] + }); + + const result = await service.listIssueComments(mockOctokit, 'o', 'r', 1); + + expect(result.data![0].user.type).toBe('Bot'); + expect(result.data![0].body).toBe('test body'); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/shared/providers/github/github-app.service.test.ts b/tests/unit/shared/providers/github/github-app.service.test.ts new file mode 100644 index 0000000..015b459 --- /dev/null +++ b/tests/unit/shared/providers/github/github-app.service.test.ts @@ -0,0 +1,108 @@ +jest.mock('octokit', () => ({ + App: jest.fn(), +})); + +import { GitHubAppService } from '../../../../../src/shared/providers/github/github-app.service'; +import * as envConfig from '../../../../../src/shared/config/environment'; +import { App } from 'octokit'; +import { createMockGitHubAppInstance } from '../../../../mocks/github.mock'; +import { logger } from '../../../../../src/shared/logger'; + +const MockedApp = App as unknown as jest.Mock; + +describe('GitHubAppService', () => { + let service: GitHubAppService; + let mockGitHubAppInstance: ReturnType; + + const mockValidEnv = () => { + jest.spyOn(envConfig, 'getEnvVar').mockImplementation((key: string) => { + const vars: Record = { + GITHUB_APP_ID: '123', + GITHUB_APP_PRIVATE_KEY: '-----BEGIN RSA PRIVATE KEY-----\\nkey\\n-----END...', + GITHUB_APP_CLIENT_ID: 'client_id', + GITHUB_APP_CLIENT_SECRET: 'secret', + GITHUB_APP_WEBHOOK_SECRET: 'webhook_secret', + }; + return vars[key]; + }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + + mockGitHubAppInstance = createMockGitHubAppInstance(); + MockedApp.mockReturnValue(mockGitHubAppInstance); + }); + + afterEach(() => { + jest.restoreAllMocks(); +}); + + test('should initialize successfully when all env vars are present', () => { + mockValidEnv(); + + service = new GitHubAppService(); + + expect(service.isConfigured()).toBe(true); + + expect(service.getApp()).toBe(mockGitHubAppInstance); + + expect(App).toHaveBeenCalledWith( + expect.objectContaining({ + privateKey: expect.stringContaining('\n'), + }) + ); + }); + + test('getInstallationOctokit() should return an octokit instance', async () => { + mockValidEnv(); + + const mockOctokit = { request: jest.fn() }; + mockGitHubAppInstance.getInstallationOctokit.mockResolvedValue(mockOctokit); + + service = new GitHubAppService(); + + const result = await service.getInstallationOctokit(12345); + + expect(mockGitHubAppInstance.getInstallationOctokit).toHaveBeenCalledWith(12345); + expect(result).toBe(mockOctokit); + }); + + test('should fail to initialize if an env var is missing', () => { + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => logger); + + jest.spyOn(envConfig, 'getEnvVar').mockImplementation((key) => + key === 'GITHUB_APP_ID' ? '' : 'some-value' + ); + + service = new GitHubAppService(); + + expect(service.isConfigured()).toBe(false); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('not fully configured') + ); + + warnSpy.mockRestore(); + }); + + test('getInstallationOctokit() should throw error if app is not configured', async () => { + jest.spyOn(envConfig, 'getEnvVar').mockReturnValue(''); + + service = new GitHubAppService(); + + await expect(service.getInstallationOctokit(123)) + .rejects.toThrow('GitHub App is not configured'); + }); + + test('should catch and log errors during App instantiation', () => { + mockValidEnv(); + + MockedApp.mockImplementationOnce(() => { + throw new Error('Invalid Private Key Format'); + }); + + expect(() => new GitHubAppService()).toThrow('GitHub App initialization failed'); + }); +}); \ No newline at end of file diff --git a/tests/unit/shared/providers/github/github-auth.service.test.ts b/tests/unit/shared/providers/github/github-auth.service.test.ts new file mode 100644 index 0000000..c9c290c --- /dev/null +++ b/tests/unit/shared/providers/github/github-auth.service.test.ts @@ -0,0 +1,103 @@ +import axios from 'axios'; +import { GitHubAuthService } from '../../../../../src/shared/providers/github/github-auth.service'; +import * as envConfig from '../../../../../src/shared/config/environment'; +import { Octokit } from '@octokit/rest'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('GitHubAuthService', () => { + let service: GitHubAuthService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new GitHubAuthService(); + }); + + describe('URL Generation', () => { + test('getProjectBadgingAuthUrl() should build valid URL with project scopes', () => { + jest.spyOn(envConfig, 'getEnvVar').mockReturnValue('project_id'); + + const url = service.getProjectBadgingAuthUrl(); + + expect(url).toContain('client_id=project_id'); + expect(url).toContain('scope=read:user,user:email'); + }); + + test('getEventBadgingAuthUrl() should include encrypted state and event client id', () => { + jest.spyOn(envConfig, 'getEnvVar').mockReturnValue('event_id'); + + const url = service.getEventBadgingAuthUrl('my-secret-state'); + + expect(url).toContain('client_id=event_id'); + expect(url).toContain('state=my-secret-state'); + expect(url).toContain('scope=read:user,user:email'); + }); + }); + + + describe('requestAccessToken', () => { + test('should exchange code for token successfully', async () => { + jest.spyOn(envConfig, 'getEnvVar').mockReturnValue('secret_val'); + mockedAxios.post.mockResolvedValue({ + data: { access_token: 'gho_12345' } + }); + + const result = await service.requestAccessToken('auth_code'); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ code: 'auth_code' }), + expect.any(Object) + ); + expect(result.access_token).toBe('gho_12345'); + expect(result.errors).toHaveLength(0); + }); + + test('should use Event credentials when isEventBadging is true', async () => { + const envSpy = jest.spyOn(envConfig, 'getEnvVar').mockReturnValue('val'); + mockedAxios.post.mockResolvedValue({ data: { access_token: 't' } }); + + await service.requestAccessToken('code', true); + + expect(envSpy).toHaveBeenCalledWith('GITHUB_AUTH_CLIENT_ID_EVENT'); + expect(envSpy).toHaveBeenCalledWith('GITHUB_AUTH_CLIENT_SECRET_EVENT'); + }); + + test('should catch axios errors and return error message', async () => { + mockedAxios.post.mockRejectedValue(new Error('Network Error')); + + const result = await service.requestAccessToken('code'); + + expect(result.access_token).toBe(''); + expect(result.errors).toContain('Network Error'); + }); + }); + + + describe('Configuration State', () => { + test('isConfigured() should return true if env var exists', () => { + jest.spyOn(envConfig, 'getEnvVar').mockReturnValue('exists'); + expect(service.isConfigured()).toBe(true); + }); + + test('isConfigured() should return false if getEnvVar throws', () => { + jest.spyOn(envConfig, 'getEnvVar').mockImplementation(() => { + throw new Error('Not found'); + }); + expect(service.isConfigured()).toBe(false); + }); + }); + + describe('Octokit Factory', () => { + test('createUserOctokit() should return instance with auth header', () => { + const octokit = service.createUserOctokit('my_token'); + expect(octokit).toBeInstanceOf(Octokit); + }); + + test('createPublicOctokit() should return instance', () => { + const octokit = service.createPublicOctokit(); + expect(octokit).toBeInstanceOf(Octokit); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/shared/providers/gitlab/gitlab-api.service.test.ts b/tests/unit/shared/providers/gitlab/gitlab-api.service.test.ts new file mode 100644 index 0000000..532eecd --- /dev/null +++ b/tests/unit/shared/providers/gitlab/gitlab-api.service.test.ts @@ -0,0 +1,126 @@ +import axios from 'axios'; +import { GitLabApiService } from '../../../../../src/shared/providers/gitlab/gitlab-api.service'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('GitLabApiService', () => { + let service: GitLabApiService; + const mockToken = 'glpat-12345'; + + beforeEach(() => { + service = new GitLabApiService(); + jest.clearAllMocks(); + }); + + + + test('getUserInfo() should map GitLab user data to internal IUserInfo', async () => { + const gitLabUser = { + username: 'johndoe', + name: 'John Doe', + email: 'john@example.com', + id: 99, + }; + + mockedAxios.get.mockResolvedValueOnce({ data: gitLabUser }); + + const result = await service.getUserInfo(mockToken); + + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.stringContaining('/user'), + expect.objectContaining({ + headers: { Authorization: `Bearer ${mockToken}`, Accept: 'application/json' } + }) + ); + expect(result.data).toEqual({ + login: 'johndoe', + name: 'John Doe', + email: 'john@example.com', + id: 99, + }); + expect(result.errors).toHaveLength(0); + }); + + test('getUserRepositories() should handle multi-page pagination', async () => { + mockedAxios.get.mockResolvedValueOnce({ + data: [{ id: 1, path_with_namespace: 'org/repo1' }], + headers: { 'x-next-page': '2' }, + }); + + mockedAxios.get.mockResolvedValueOnce({ + data: [{ id: 2, path_with_namespace: 'org/repo2' }], + headers: {}, + }); + + const result = await service.getUserRepositories(mockToken); + + expect(mockedAxios.get).toHaveBeenCalledTimes(2); + expect(result.data).toHaveLength(2); + expect(result.data![1].fullName).toBe('org/repo2'); + }); + + test('getFileContentAndSHA() should decode base64 content correctly', async () => { + const base64Content = Buffer.from('Hello GitLab').toString('base64'); + mockedAxios.get.mockResolvedValueOnce({ + data: { + last_commit_id: 'sha-123', + content: base64Content, + }, + }); + + const result = await service.getFileContentAndSHA(123, 'README.md', 'main'); + + expect(result.data?.content).toBe('Hello GitLab'); + expect(result.data?.sha).toBe('sha-123'); + }); + + + + test('getUserInfo() should return error message on API failure', async () => { + mockedAxios.get.mockRejectedValueOnce(new Error('Unauthorized')); + + const result = await service.getUserInfo(mockToken); + + expect(result.data).toBeNull(); + expect(result.errors).toContain('Unauthorized'); + }); + + test('getUserRepositories() should return partial data if an error occurs during pagination', async () => { + mockedAxios.get.mockResolvedValueOnce({ + data: [{ id: 1, path_with_namespace: 'org/repo1' }], + headers: { 'x-next-page': '2' }, + }); + + mockedAxios.get.mockRejectedValueOnce(new Error('Network Timeout')); + + const result = await service.getUserRepositories(mockToken); + + expect(result.data).toHaveLength(1); + expect(result.errors).toContain('Network Timeout'); + }); + + + + test('getUserInfo() should fallback to username if name is missing', async () => { + mockedAxios.get.mockResolvedValueOnce({ + data: { username: 'ghost', id: 1, email: null } + }); + + const result = await service.getUserInfo(mockToken); + + expect(result.data?.name).toBe('ghost'); + expect(result.data?.email).toBe(''); + }); + + test('getRepositoryInfo() should handle repository search by ID', async () => { + mockedAxios.get.mockResolvedValueOnce({ + data: { id: 500, web_url: 'http://gitlab.com/repo', default_branch: 'develop' } + }); + + const result = await service.getRepositoryInfo(500); + + expect(result.data?.defaultBranch).toBe('develop'); + expect(mockedAxios.get).toHaveBeenCalledWith(expect.stringContaining('/projects/500'), expect.any(Object)); + }); +}); \ No newline at end of file diff --git a/tests/unit/shared/providers/gitlab/gitlab-auth.service.test.ts b/tests/unit/shared/providers/gitlab/gitlab-auth.service.test.ts new file mode 100644 index 0000000..afaba3c --- /dev/null +++ b/tests/unit/shared/providers/gitlab/gitlab-auth.service.test.ts @@ -0,0 +1,91 @@ +import axios from 'axios'; +import { GitLabAuthService } from '../../../../../src/shared/providers/gitlab/gitlab-auth.service'; +import * as envConfig from '../../../../../src/shared/config/environment'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('GitLabAuthService', () => { + let service: GitLabAuthService; + + const mockEnv: { [key: string]: string } = { + GITLAB_APP_CLIENT_ID: 'client-123', + GITLAB_APP_CLIENT_SECRET: 'secret-456', + GITLAB_APP_REDIRECT_URI: 'http://localhost/callback', + }; + + beforeEach(() => { + service = new GitLabAuthService(); + jest.clearAllMocks(); + + jest.spyOn(envConfig, 'getEnvVar').mockImplementation((key: string) => { + const value = mockEnv[key]; + if (value) return value; + throw new Error(`Env var ${key} not set`); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + + test('getAuthUrl() should construct the correct GitLab OAuth URL', () => { + const url = service.getAuthUrl(); + + expect(url).toBe( + 'https://gitlab.com/oauth/authorize?client_id=client-123&response_type=code&state=STATE&scope=read_api&redirect_uri=http://localhost/callback' + ); + }); + + test('requestAccessToken() should exchange code for token successfully', async () => { + mockedAxios.post.mockResolvedValueOnce({ + data: { access_token: 'glpat-valid-token' } + }); + + const result = await service.requestAccessToken('valid-code'); + + expect(mockedAxios.post).toHaveBeenCalledWith( + 'https://gitlab.com/oauth/token', + { + client_id: 'client-123', + client_secret: 'secret-456', + code: 'valid-code', + grant_type: 'authorization_code', + redirect_uri: 'http://localhost/callback', + }, + { headers: { Accept: 'application/json' } } + ); + expect(result.access_token).toBe('glpat-valid-token'); + expect(result.errors).toHaveLength(0); + }); + + test('isConfigured() should return true when client ID is available', () => { + expect(service.isConfigured()).toBe(true); + }); + + test('requestAccessToken() should return errors on API failure', async () => { + mockedAxios.post.mockRejectedValueOnce(new Error('Internal Server Error')); + + const result = await service.requestAccessToken('any-code'); + + expect(result.access_token).toBe(''); + expect(result.errors).toContain('Internal Server Error'); + }); + + test('isConfigured() should return false if getEnvVar throws', () => { + jest.spyOn(envConfig, 'getEnvVar').mockImplementation(() => { + throw new Error('Missing Config'); + }); + + expect(service.isConfigured()).toBe(false); + }); + + test('requestAccessToken() should handle non-standard error objects', async () => { + mockedAxios.post.mockRejectedValueOnce('Unexpected Error String'); + + const result = await service.requestAccessToken('code'); + + expect(result.errors).toContain('Unknown error'); + }); +}); \ No newline at end of file diff --git a/tests/unit/shared/services/crypto.service.test.ts b/tests/unit/shared/services/crypto.service.test.ts new file mode 100644 index 0000000..e47743c --- /dev/null +++ b/tests/unit/shared/services/crypto.service.test.ts @@ -0,0 +1,73 @@ +import { CryptoService } from '../../../../src/shared/services/crypto.service'; +import * as envConfig from '../../../../src/shared/config/environment'; + +describe('CryptoService', () => { + let service: CryptoService; + const mockSecret = 'super-secret-key-123'; + + beforeEach(() => { + // Mock the environment variable before the service constructor runs + jest.spyOn(envConfig, 'getEnvVar').mockReturnValue(mockSecret); + service = new CryptoService(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should encrypt and decrypt a string back to its original value', () => { + const originalText = 'Hello, CHAOSS! This is a secret message.'; + + const encrypted = service.encrypt(originalText); + const decrypted = service.decrypt(encrypted); + + expect(encrypted).not.toBe(originalText); + expect(encrypted).toContain(':'); + expect(decrypted).toBe(originalText); + }); + + test('should produce different ciphertexts for the same input (using random IV)', () => { + const text = 'Same message'; + + const encrypted1 = service.encrypt(text); + const encrypted2 = service.encrypt(text); + + expect(encrypted1).not.toBe(encrypted2); + }); + + + test('convertToMarkdown() should convert HTML tags to Markdown', () => { + const html = '

Title

Body with bold text.

'; + const expected = '# Title\n\nBody with **bold** text.'; + + const result = service.convertToMarkdown(html); + expect(result.trim()).toBe(expected); + expect(result).toContain('# Title'); + expect(result).toContain('**bold**'); + }); + + + test('decrypt() should throw an error if the hash is malformed', () => { + const malformedHash = 'not-a-valid-hash'; + + expect(() => service.decrypt(malformedHash)).toThrow(); + }); + + test('should handle very large strings (testing compression/decompression)', () => { + const largeText = 'A'.repeat(10000); + + const encrypted = service.encrypt(largeText); + const decrypted = service.decrypt(encrypted); + + expect(decrypted).toBe(largeText); + expect(decrypted.length).toBe(10000); + }); + + test('should handle empty strings', () => { + const empty = ''; + const encrypted = service.encrypt(empty); + const decrypted = service.decrypt(encrypted); + + expect(decrypted).toBe(empty); + }); +}); \ No newline at end of file diff --git a/tests/unit/shared/services/mailer.service.test.ts b/tests/unit/shared/services/mailer.service.test.ts new file mode 100644 index 0000000..31d4d32 --- /dev/null +++ b/tests/unit/shared/services/mailer.service.test.ts @@ -0,0 +1,106 @@ +import nodemailer from 'nodemailer'; +import * as fs from 'fs'; +import { MailerService } from '../../../../src/shared/services/mailer.service'; +import * as envConfig from '../../../../src/shared/config/environment'; +import { logger } from '../../../../src/shared/logger'; + +jest.mock('nodemailer'); +jest.mock('fs'); + +describe('MailerService', () => { + let service: MailerService; + let mockTransporter: any; + + const mockEnv: { [key: string]: string } = { + EMAIL_HOST: 'smtp.gmail.com', + EMAIL_ADDRESS: 'test@chaoss.org', + EMAIL_PASSWORD: 'password123', + }; + + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.clearAllMocks(); + + jest.spyOn(envConfig, 'getEnvVar').mockImplementation((key: string) => mockEnv[key] || ''); + + mockTransporter = { + sendMail: jest.fn().mockResolvedValue({ response: '250 OK' }), + }; + (nodemailer.createTransport as jest.Mock).mockReturnValue(mockTransporter); + + service = new MailerService(); + }); + + + test('sendSuccessEmail() should read template and replace placeholders', async () => { + const rawTemplate = 'Hello {{recipientName}}, you got {{badgeName}}! {{markdownLink}}'; + (fs.readFileSync as jest.Mock).mockReturnValue(rawTemplate); + + const options = { + to: 'applicant@example.com', + recipientName: 'Alice', + badgeName: 'Gold', + markdownLink: '[badge](link)', + }; + + const result = await service.sendSuccessEmail(options); + + expect(result).toBe(true); + expect(fs.readFileSync).toHaveBeenCalled(); + expect(mockTransporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({ + to: 'applicant@example.com', + html: 'Hello Alice, you got Gold! [badge](link)', + })); + }); + + test('sendBadgingEmail() should route to success when no results/errors are provided', async () => { + const successSpy = jest.spyOn(service as any, 'sendSuccessEmail').mockResolvedValue(true); + + await service.sendBadgingEmail('a@b.com', 'Alice', 'Gold', 'md', 'html', null); + + expect(successSpy).toHaveBeenCalled(); + }); + + + + test('should return false and log warning if SMTP is not configured', () => { + + jest.spyOn(envConfig, 'getEnvVar').mockReturnValue(''); + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => logger); + + const unconfiguredService = new MailerService(); + + expect(unconfiguredService['transporter']).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Email service is not configured')); + warnSpy.mockRestore(); + }); + + test('sendSuccessEmail() should return false if nodemailer fails', async () => { + (fs.readFileSync as jest.Mock).mockReturnValue('template'); + mockTransporter.sendMail.mockRejectedValue(new Error('SMTP Error')); + const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => logger); + + const result = await service.sendSuccessEmail({ to: 'test@test.com' } as any); + + expect(result).toBe(false); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + + + test('sendFailureEmail() should handle missing optional results string', async () => { + (fs.readFileSync as jest.Mock).mockReturnValue('Reasons: {{results}}'); + + await service.sendFailureEmail({ + to: 'test@test.com', + recipientName: 'Bob', + badgeName: 'None', + results: undefined + } as any); + + expect(mockTransporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({ + html: 'Reasons: ' + })); + }); +}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 346d30f..6981ad9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "commonjs", "lib": ["ES2022"], - "types": ["node"], + "types": ["node", "jest"], "outDir": "./dist", "rootDir": "./src", "strict": true, @@ -23,7 +23,7 @@ "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, - "baseUrl": "./src", + "baseUrl": "./", "paths": { "@shared/*": ["shared/*"], "@auth/*": ["auth/*"], @@ -31,6 +31,6 @@ "@event-badging/*": ["event-badging/*"] } }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "tests/**/*.ts"], "exclude": ["node_modules", "dist"] } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..f4bea20 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", // include everything from project root + "outDir": "./dist-tests", // compiled test files (optional) + "types": ["jest", "node"] // Jest & Node globals + }, + "include": ["src", "tests"] // include source + tests +} \ No newline at end of file