From 7928742f3fd897764b28c06852702d9ec8130507 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Thu, 12 Feb 2026 15:27:51 +0100 Subject: [PATCH 01/45] untrack local vscode settings meant for local development Signed-off-by: AdeyinkaOresanya --- .gitignore | 6 ++++++ .vscode/settings.json | 18 ------------------ 2 files changed, 6 insertions(+), 18 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.gitignore b/.gitignore index 16b926b..3dfac4a 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,9 @@ report* deploy.tests.yml /tmp/mysql/* + +#ignore .vscode +.vscode/ + + + 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 - } - ] -} From 36db609b5edfb720e2d664bb020eba91827d25fd Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Thu, 12 Feb 2026 15:29:18 +0100 Subject: [PATCH 02/45] refactor: modify code to not require password for database during development Signed-off-by: AdeyinkaOresanya --- src/scripts/configure.ts | 7 ++++--- src/shared/config/environment.ts | 2 +- src/shared/data-access/database.ts | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/scripts/configure.ts b/src/scripts/configure.ts index 99ee7f0..e3c13c6 100644 --- a/src/scripts/configure.ts +++ b/src/scripts/configure.ts @@ -6,7 +6,7 @@ interface ConfigValues { // Database configuration db_name: string; db_user: string; - db_password: string; + db_password?: string; db_host: string; db_port: string; @@ -62,7 +62,7 @@ async function configure(): Promise { // 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' }), @@ -123,7 +123,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} diff --git a/src/shared/config/environment.ts b/src/shared/config/environment.ts index 95166e4..b4b1b2b 100644 --- a/src/shared/config/environment.ts +++ b/src/shared/config/environment.ts @@ -10,7 +10,7 @@ export interface IEnvironmentConfig { DB_HOST: string; DB_NAME: string; DB_USER: string; - DB_PASSWORD: string; + DB_PASSWORD?: string; DB_DIALECT: 'mysql' | 'postgres' | 'sqlite' | 'mariadb'; // GitHub OAuth (Project Badging) diff --git a/src/shared/data-access/database.ts b/src/shared/data-access/database.ts index b834889..798f995 100644 --- a/src/shared/data-access/database.ts +++ b/src/shared/data-access/database.ts @@ -13,7 +13,7 @@ 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', models: [User, Repo, Event], From d5e840d0c87f43d7a71a16cf49e9ff311a3ea50a Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Thu, 12 Feb 2026 16:25:03 +0100 Subject: [PATCH 03/45] add winston library for logging functionality Signed-off-by: AdeyinkaOresanya --- package-lock.json | 242 ++++++++++++++++++++++++++++++++++-- package.json | 6 +- src/shared/logger/index.ts | 1 + src/shared/logger/logger.ts | 16 +++ 4 files changed, 256 insertions(+), 9 deletions(-) create mode 100644 src/shared/logger/index.ts create mode 100644 src/shared/logger/logger.ts diff --git a/package-lock.json b/package-lock.json index 59f7ac4..8debd3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,8 @@ "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", @@ -36,6 +37,7 @@ "@types/node": "^20.10.5", "@types/nodemailer": "^6.4.14", "@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", @@ -568,6 +570,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 +602,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", @@ -2230,6 +2250,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", @@ -2560,6 +2589,11 @@ "dev": true, "license": "MIT" }, + "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 +2607,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 +3022,11 @@ "node": ">=8" } }, + "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 +3760,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 +3790,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", @@ -4162,6 +4261,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", @@ -4764,6 +4868,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 +4971,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", @@ -5645,7 +5759,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" @@ -6605,6 +6718,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 +7290,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 +7773,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 +8786,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 +9235,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 +9289,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 +9297,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", @@ -9341,6 +9497,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 +9554,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", @@ -9815,8 +9984,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 +10069,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..d112ace 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "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", @@ -53,6 +54,7 @@ "@types/node": "^20.10.5", "@types/nodemailer": "^6.4.14", "@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", @@ -74,4 +76,4 @@ "prettier --write" ] } -} \ 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..345f2f8 --- /dev/null +++ b/src/shared/logger/logger.ts @@ -0,0 +1,16 @@ +import { createLogger, format, transports } from 'winston'; + +export const logger = createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: format.combine( + format.timestamp(), + format.colorize(), + format.printf(({ timestamp, level, message, ...meta }) => { + const metaString = Object.keys(meta).length ? JSON.stringify(meta) : ''; + return `[${timestamp}] ${level}: ${message} ${metaString}`; + }) + ), + transports: [new transports.Console()], +}); + +//export default logger; From 8b335d7071b10f63babd1ce0f23f7bf7ac205976 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Thu, 12 Feb 2026 16:26:54 +0100 Subject: [PATCH 04/45] refactor index.js for modularity and proper orchestration Signed-off-by: AdeyinkaOresanya --- src/dev/smee.ts | 25 ++++ src/index.ts | 145 ++++++++++++------- src/shared/data-access/database.bootstrap.ts | 10 ++ src/shared/index.ts | 1 + src/types/smee-client.d.ts | 35 +++-- 5 files changed, 151 insertions(+), 65 deletions(-) create mode 100644 src/dev/smee.ts create mode 100644 src/shared/data-access/database.bootstrap.ts diff --git a/src/dev/smee.ts b/src/dev/smee.ts new file mode 100644 index 0000000..672e999 --- /dev/null +++ b/src/dev/smee.ts @@ -0,0 +1,25 @@ +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_WEBHOOK_URL = getEnvVar('SMEE_WEBHOOK_URL', ''); + if (!SMEE_WEBHOOK_URL) return () => {}; + + const smee = new SmeeClient({ + source: SMEE_WEBHOOK_URL, + target: `${appUrl}${webhookPath}`, + logger, + }); + + const events = smee.start(); + logger.info('Smee forwarding enabled', { + source: SMEE_WEBHOOK_URL, + target: webhookPath, + }); + + return () => { + events.close(); + logger.info('Smee forwarding stopped'); + }; +} diff --git a/src/index.ts b/src/index.ts index 947d8c8..1790cef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,65 +1,108 @@ +// 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 { getEnvVar, isDevelopment } from './shared/config/environment'; + +// async function bootstrap(): Promise { +// const PORT = parseInt(getEnvVar('PORT', '5000'), 10); +// const APP_URL = getEnvVar('APP_URL', `http://localhost:${PORT}`); + +// try { +// // Initialize database +// console.log('Connecting to database...'); +// const database = Container.get(Database); +// await database.connect(); +// console.log('Database connected successfully'); + +// // Create and configure Express app +// 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); +// }); +// } +// } + +// // 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}`); +// }); +// } catch (error) { +// console.error('Failed to start server:', error); +// process.exit(1); +// } +// } + +// // Start the application +// bootstrap().catch((error) => { +// console.error('Bootstrap error:', error); +// process.exit(1); +// }); + + + + 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 } 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'); + + await initializeDatabase(); - try { - // Initialize database - console.log('Connecting to database...'); - const database = Container.get(Database); - await database.connect(); - console.log('Database connected successfully'); - - // Create and configure Express app - 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); - }); - } - } - - // 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}`); - }); - } catch (error) { - console.error('Failed to start server:', error); - process.exit(1); + const app = createApp(); + + let stopSmee = () => {}; + if (isDevelopment()) { + stopSmee = startSmee(APP_URL, WEBHOOK_PATH); } + + const server = app.listen(PORT, () => { + logger.info('Server started at', { url: APP_URL }); + }); + + const shutdown = () => { + stopSmee(); + server.close(() => process.exit(0)); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); } -// Start the application -bootstrap().catch((error) => { - console.error('Bootstrap error:', error); +bootstrap().catch((err) => { + logger.error('Fatal startup error', { err }); process.exit(1); }); diff --git a/src/shared/data-access/database.bootstrap.ts b/src/shared/data-access/database.bootstrap.ts new file mode 100644 index 0000000..82445b7 --- /dev/null +++ b/src/shared/data-access/database.bootstrap.ts @@ -0,0 +1,10 @@ +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'); +} diff --git a/src/shared/index.ts b/src/shared/index.ts index a06c1e4..259a6cc 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -4,3 +4,4 @@ export * from './config'; export * from './data-access'; export * from './services'; export * from './providers'; +export * from './logger'; diff --git a/src/types/smee-client.d.ts b/src/types/smee-client.d.ts index a631e1d..4abc2d3 100644 --- a/src/types/smee-client.d.ts +++ b/src/types/smee-client.d.ts @@ -1,18 +1,25 @@ -declare module 'smee-client' { - interface SmeeClientOptions { - source: string; - target: string; - logger?: Console; - } +import SmeeClient from 'smee-client'; +import logger from '../shared/logger'; +import { getEnvVar } from '../shared/config/environment'; - interface SmeeEvents { - close(): void; - } +export function startSmee(appUrl: string, webhookPath: string): () => void { + const SMEE_WEBHOOK_URL = getEnvVar('SMEE_WEBHOOK_URL', ''); + if (!SMEE_WEBHOOK_URL) return () => {}; - class SmeeClient { - constructor(options: SmeeClientOptions); - start(): SmeeEvents; - } + const smee = new SmeeClient({ + source: SMEE_WEBHOOK_URL, + target: `${appUrl}${webhookPath}`, + logger, + }); - export default SmeeClient; + const events = smee.start(); + logger.info('Smee forwarding enabled', { + source: SMEE_WEBHOOK_URL, + target: webhookPath, + }); + + return () => { + events.close(); + logger.info('Smee forwarding stopped'); + }; } From 6734f65d0adbb11d0401e97697a57b1c069291d0 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Tue, 10 Mar 2026 11:08:44 +0100 Subject: [PATCH 05/45] feat: add repository name to environment configuration and ensure port is parsed as number in database setup Signed-off-by: AdeyinkaOresanya --- src/shared/config/environment.ts | 3 +++ src/shared/data-access/database.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/src/shared/config/environment.ts b/src/shared/config/environment.ts index b4b1b2b..65241c2 100644 --- a/src/shared/config/environment.ts +++ b/src/shared/config/environment.ts @@ -33,6 +33,9 @@ export interface IEnvironmentConfig { GITLAB_APP_CLIENT_SECRET: string; GITLAB_APP_REDIRECT_URI: string; + //repository for event badging + REPOSITORY_NAME: string; + // Email EMAIL_HOST: string; EMAIL_ADDRESS: string; diff --git a/src/shared/data-access/database.ts b/src/shared/data-access/database.ts index 798f995..8b20460 100644 --- a/src/shared/data-access/database.ts +++ b/src/shared/data-access/database.ts @@ -16,6 +16,7 @@ export class Database { 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, }); From 00905f0d9afbe0393a4d8505f60f364e2deef604 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Tue, 10 Mar 2026 11:10:00 +0100 Subject: [PATCH 06/45] feat: add repository name to environment configuration and update scoring service to use it for badge calculation Signed-off-by: AdeyinkaOresanya --- .env.example | 2 ++ src/event-badging/services/scoring.service.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 4d31704..be2c66f 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,8 @@ EMAIL_PASSWORD= # Not your email password. To get a unique Gmail app password, f PORT= +REPOSITORY_NAME= # repository used for event badging + # augur configs AUGUR_APP_CLIENT_SECRET= # To get client secret, create an app via https://ai.chaoss.io/account/login diff --git a/src/event-badging/services/scoring.service.ts b/src/event-badging/services/scoring.service.ts index 5b6e4b1..795a135 100644 --- a/src/event-badging/services/scoring.service.ts +++ b/src/event-badging/services/scoring.service.ts @@ -1,5 +1,6 @@ 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'; @@ -19,8 +20,9 @@ export class ScoringService { 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; } From faa308e76074682e50e98bfebd515a93e7694e66 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 10 Apr 2026 15:36:35 +0100 Subject: [PATCH 07/45] chore: remove commented out code from index.ts Signed-off-by: AdeyinkaOresanya --- src/index.ts | 69 ---------------------------------------------------- 1 file changed, 69 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1790cef..cabb2f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,72 +1,3 @@ -// 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 { getEnvVar, isDevelopment } from './shared/config/environment'; - -// async function bootstrap(): Promise { -// const PORT = parseInt(getEnvVar('PORT', '5000'), 10); -// const APP_URL = getEnvVar('APP_URL', `http://localhost:${PORT}`); - -// try { -// // Initialize database -// console.log('Connecting to database...'); -// const database = Container.get(Database); -// await database.connect(); -// console.log('Database connected successfully'); - -// // Create and configure Express app -// 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); -// }); -// } -// } - -// // 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}`); -// }); -// } catch (error) { -// console.error('Failed to start server:', error); -// process.exit(1); -// } -// } - -// // Start the application -// bootstrap().catch((error) => { -// console.error('Bootstrap error:', error); -// process.exit(1); -// }); - - - - import 'reflect-metadata'; import 'dotenv/config'; import { createApp } from './app'; From ff121a71da48eb74ebf68bc43d645ab8912a3831 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 10 Apr 2026 16:19:56 +0100 Subject: [PATCH 08/45] chore: add missing DB_PORT and WEBHOOK_PATH entries to .env.example Signed-off-by: AdeyinkaOresanya --- .env.example | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index be2c66f..632903d 100644 --- a/.env.example +++ b/.env.example @@ -32,7 +32,8 @@ DB_HOST= # default is 'localhost' DB_NAME= DB_USER= DB_PASSWORD= +DB_PORT= # smeeClient URL for testing - SMEE_CLIENT_URL= +WEBHOOK_PATH= From 69a8a7db62e0ddca5543accba1b84390f853ca42 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 10 Apr 2026 16:23:56 +0100 Subject: [PATCH 09/45] fix: update SmeeClient source variable to use SMEE_CLIENT_URL Signed-off-by: AdeyinkaOresanya --- src/dev/smee.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/dev/smee.ts b/src/dev/smee.ts index 672e999..aad7d1f 100644 --- a/src/dev/smee.ts +++ b/src/dev/smee.ts @@ -3,18 +3,19 @@ import {logger} from '../shared/logger'; import { getEnvVar } from '../shared/config/environment'; export function startSmee(appUrl: string, webhookPath: string): () => void { - const SMEE_WEBHOOK_URL = getEnvVar('SMEE_WEBHOOK_URL', ''); - if (!SMEE_WEBHOOK_URL) return () => {}; + const SMEE_CLIENT_URL = getEnvVar('SMEE_CLIENT_URL', ''); + + if (!SMEE_CLIENT_URL) return () => {}; const smee = new SmeeClient({ - source: SMEE_WEBHOOK_URL, + source: SMEE_CLIENT_URL, target: `${appUrl}${webhookPath}`, logger, }); const events = smee.start(); logger.info('Smee forwarding enabled', { - source: SMEE_WEBHOOK_URL, + source: SMEE_CLIENT_URL, target: webhookPath, }); From 79fa5b39ea7074619dd32a94d002d1ea4c8ce49e Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 10 Apr 2026 16:30:56 +0100 Subject: [PATCH 10/45] refactor: improve error handling and response structure in AuthController methods Signed-off-by: AdeyinkaOresanya --- src/auth/controllers/auth.controller.ts | 375 ++++++++++++++---------- 1 file changed, 214 insertions(+), 161 deletions(-) diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index 4eefb50..c6fd052 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -4,15 +4,17 @@ import { Post, QueryParam, Body, - Res, Redirect, - Req, + BadRequestError, + InternalServerError, + ContentType, + Res, } 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'; @JsonController('/api') @Service() @@ -20,252 +22,303 @@ 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 { - if (!provider) { - return response.status(400).json({ error: 'Provider is required' }); - } + @Redirect(':url') + login(@QueryParam('provider') provider: Provider) { + if (!provider) throw new BadRequestError('Provider is required'); const result = this.authService.getLoginUrl(provider); - if (result.errors.length > 0 || !result.data) { - return response.status(500).json({ error: result.errors.join(', ') }); + if (result.errors.length || !result.data) { + throw new InternalServerError(result.errors.join(', ')); } - return response.redirect(result.data) as unknown as Response; + return { url: result.data }; } /** - * 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(', ')); + + +@Get('/auth/github') +// @Redirect('') +authGitHub(@Res() res: Response): void { + const result = this.authService.getLoginUrl('github'); + + if (result.errors.length || !result.data) { + throw new InternalServerError(result.errors.join(', ')); + } + res.redirect(result.data); + return; + + // return { + // url: result.data, + // statusCode: 302 + // }; + +} + + @Get('/auth/gitlab') + @Redirect(':url') + authGitLab() { + const result = this.authService.getLoginUrl('gitlab'); + + if (result.errors.length || !result.data) { + throw new InternalServerError(result.errors.join(', ')); } - return result.data; + + return { url: result.data }; } /** - * 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 { + @Body() body: { type?: string; title?: string; body?: string } + ) { if (body.type === 'event-badging') { if (!body.title || !body.body) { - return response - .status(400) - .json({ error: 'Title and body are required for event badging' }); + throw new BadRequestError('Title and body are required'); } - const result = this.authService.getEventBadgingAuthUrl(body.title, body.body); + const result = this.authService.getEventBadgingAuthUrl( + body.title, + body.body + ); - if (result.errors.length > 0 || !result.data) { - return response.status(500).json({ error: result.errors.join(', ') }); + if (result.errors.length || !result.data) { + throw new InternalServerError(result.errors.join(', ')); } - return response.json({ authorizationLink: result.data }); + return { authorizationLink: result.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 (result.errors.length || !result.data) { + throw new InternalServerError(result.errors.join(', ')); } - return response.redirect(result.data) as unknown as Response; + return { redirectUrl: result.data }; } /** - * GET /api/auth/gitlab - * Redirect to GitLab OAuth + * CALLBACKS */ - @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(', ')); - } - return result.data; - } - /** + /** * GET /api/callback/github - * Handle GitHub OAuth callback + * Ensure we pass @Res() and return Promise */ @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() response: Response // Inject the response object + ): Promise { + await this.handleGitHubCallback(code, state, response); + return; // Explicitly return nothing so the framework stops here } /** * POST /api/callback/github - * Handle GitHub OAuth callback (POST variant) + * Consistency is key: use the same Res pattern */ @Post('/callback/github') async callbackGitHubPost( @Body() body: { code?: string }, - @QueryParam('code') queryCode: string | undefined, - @Req() request: Request, + @QueryParam('code') queryCode: string, + @QueryParam('state') state: string, @Res() response: Response - ): Promise { - const state = request.query.state as string | undefined; + ): Promise { const code = body.code || queryCode; - if (!code) { - return response.status(400).json({ error: 'Code is required' }); - } - return this.handleGitHubCallback(code, state, response); + await this.handleGitHubCallback(code, state, response); + return; } - /** - * 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); + async callbackGitLabGet(@QueryParam('code') code: string) { + return this.handleGitLabCallback(code); } - /** - * 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 { + @QueryParam('code') queryCode: string + ) { const code = body.code || queryCode; - if (!code) { - return response.status(400).json({ error: 'Code is required' }); - } - return this.handleGitLabCallback(code, response); + return this.handleGitLabCallback(code); } /** - * Common handler for GitHub OAuth callback + * HANDLERS */ - private async handleGitHubCallback( - code: string, - state: string | undefined, - response: Response - ): Promise { - if (!code) { - return response.status(400).json({ error: 'Code is required' }); - } + private async handleGitHubCallback(code: string, state: string | undefined, res: Response): Promise { + if (!code) { + res.status(400).json({ error: 'Code is required' }); + return; // STOP HERE + } + const result = await this.authService.handleGitHubCallback(code, state); - + if (result.errors.length > 0 || !result.data) { - return response.status(500).json({ error: result.errors.join(', ') }); + res.status(500).json({ error: result.errors.join(', ') }); + return; } - // Event badging callback - redirect to issue URL if ('issueUrl' in result.data) { - return response.redirect(result.data.issueUrl) as unknown as Response; + res.redirect(result.data.issueUrl); + return; // Stop execution here } - // Project badging callback - return user data + // For JSON or HTML, send it and return void if (isProduction()) { - return response.status(200).json(result.data); + res.status(200).json(result.data); + return; } - // Development mode - render HTML form - if (isDevelopment()) { - const data = result.data; - return response.status(200).send(this.renderDevRepoForm(data, 'github')); - } - - return response.status(500).json({ error: 'Unknown process mode' }); + res.status(200).send(this.renderDevRepoForm(result.data, 'github')); + return; } - /** - * Common handler for GitLab OAuth callback - */ - private async handleGitLabCallback(code: string, response: Response): Promise { - if (!code) { - return response.status(400).json({ error: 'Code is required' }); - } + private async handleGitLabCallback(code: string) { + if (!code) throw new BadRequestError('Code is required'); 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.errors.length || !result.data) { + throw new InternalServerError(result.errors.join(', ')); } if (isProduction()) { - return response.status(200).json(result.data); - } - - // Development mode - render HTML form - if (isDevelopment()) { - return response.status(200).send(this.renderDevRepoForm(result.data, 'gitlab')); + return result.data; } - return response.status(500).json({ error: 'Unknown process mode' }); + return this.renderDevRepoForm(result.data, 'gitlab'); } /** - * Render development HTML form for repository selection + * DEV HTML RENDERER */ - private renderDevRepoForm( - data: { - userId: number; - name: string; - username: string; - email: string; - repos: Array<{ id: number; fullName: string }>; - }, - provider: string - ): string { - return ` - - - Repo List - + +@ContentType('text/html') +private 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('')} + + +
+ + + + + `; +} + + +@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 ` + -

Welcome ${data.name}

-

Username: ${data.username}

-

Email: ${data.email}

-
- - -

Select Repositories:

- ${data.repos - .map( - (repo) => ` -
- - -
- ` - ) - .join('')} -
- -
+

Badge Scan Successful!

+

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

+

Provider: ${provider}

+

User ID: ${userId}

- `; - } + `; } +} \ No newline at end of file From 588d24928118d6fdb8131bf89f91be54cd1295f0 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 10 Apr 2026 16:35:21 +0100 Subject: [PATCH 11/45] fix: refactor event URL extraction logic for virtual and in-person events Signed-off-by: AdeyinkaOresanya --- .../services/event-badging.service.ts | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/event-badging/services/event-badging.service.ts b/src/event-badging/services/event-badging.service.ts index 68b718f..a03c28b 100644 --- a/src/event-badging/services/event-badging.service.ts +++ b/src/event-badging/services/event-badging.service.ts @@ -212,23 +212,35 @@ export class EventBadgingService { // 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, ''); - } + 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) { + // slice after startMarker, up to endIdx + eventUrl = payload.issue.body.slice( + startIdx + startMarker.length, + endIdx + ).trim(); + } + } 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 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) { + // Slice after startMarker up to endIdx + eventUrl = payload.issue.body.slice( + startIdx + startMarker.length, + endIdx + ).trim(); + } +} + // Create badge object const badge: IEventBadge = { From 0947e9b5fc1294fde57d6095546daa54c538c59a Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 10 Apr 2026 16:38:04 +0100 Subject: [PATCH 12/45] refactor: introduce SystemController with health check and root endpoint Signed-off-by: AdeyinkaOresanya --- src/app.ts | 44 ++------------------- src/shared/controllers/index.ts | 1 + src/shared/controllers/system.controller.ts | 31 +++++++++++++++ 3 files changed, 36 insertions(+), 40 deletions(-) create mode 100644 src/shared/controllers/index.ts create mode 100644 src/shared/controllers/system.controller.ts diff --git a/src/app.ts b/src/app.ts index 8e59091..7d36e1c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,13 +1,14 @@ 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'; // Set container for routing-controllers useContainer(Container); @@ -18,51 +19,14 @@ useContainer(Container); 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'))); - // Health check endpoint - app.get('/health', (_req: Request, res: Response) => { - res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() }); - }); - - // 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', - }, - }, - }); - }); - return app; } 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 From d313be485c7e4db8d0203ecc55accd84b8fedcf6 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 10 Apr 2026 16:40:40 +0100 Subject: [PATCH 13/45] fix: update TypeScript configuration to include Jest types and adjust baseUrl Signed-off-by: AdeyinkaOresanya --- tsconfig.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index 346d30f..9c64eb9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,9 +3,9 @@ "target": "ES2022", "module": "commonjs", "lib": ["ES2022"], - "types": ["node"], + "types": ["node", "jest"], "outDir": "./dist", - "rootDir": "./src", + //"rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, @@ -23,7 +23,8 @@ "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, - "baseUrl": "./src", + "ignoreDeprecations": "6.0", + "baseUrl": "./", "paths": { "@shared/*": ["shared/*"], "@auth/*": ["auth/*"], @@ -31,6 +32,6 @@ "@event-badging/*": ["event-badging/*"] } }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "tests/**/*.ts"], "exclude": ["node_modules", "dist"] } From 6b381766741d91da6d3ae9c52f8b5e027703790a Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 10 Apr 2026 16:44:21 +0100 Subject: [PATCH 14/45] feat: add Jest configuration and setup files for testing Signed-off-by: AdeyinkaOresanya --- jest.config.ts | 13 ++++ jest.setup.ts | 7 ++ package-lock.json | 186 +++++++++++++++++++++++++++++++++++++++++---- package.json | 12 +-- tsconfig.test.json | 9 +++ 5 files changed, 209 insertions(+), 18 deletions(-) create mode 100644 jest.config.ts create mode 100644 jest.setup.ts create mode 100644 tsconfig.test.json 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 8debd3e..5ace257 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,9 +33,10 @@ "@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", @@ -48,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": { @@ -1717,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", @@ -2200,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", @@ -2365,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", @@ -2459,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" @@ -2482,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", @@ -2496,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" } @@ -2589,6 +2622,28 @@ "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", @@ -3022,6 +3077,12 @@ "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", @@ -3857,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", @@ -3922,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", @@ -4126,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", @@ -4848,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", @@ -5028,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", @@ -5871,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", @@ -9410,6 +9518,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", @@ -9580,7 +9743,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", @@ -9646,7 +9808,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", @@ -9866,7 +10027,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" diff --git a/package.json b/package.json index d112ace..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" }, @@ -50,9 +50,10 @@ "@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", @@ -65,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": [ 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 From 01a540b8940221c62baffeaa268cde6cb22f1ec3 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 10 Apr 2026 16:48:36 +0100 Subject: [PATCH 15/45] feat: add mock services for Crypto, Database, GitHub, GitLab, and Octokit Signed-off-by: AdeyinkaOresanya --- tests/mocks/crypto.mock.ts | 5 ++ tests/mocks/database.mock.ts | 134 +++++++++++++++++++++++++++++++++++ tests/mocks/github.mock.ts | 21 ++++++ tests/mocks/gitlab.mock.ts | 11 +++ tests/mocks/octokit.mock.ts | 27 +++++++ 5 files changed, 198 insertions(+) create mode 100644 tests/mocks/crypto.mock.ts create mode 100644 tests/mocks/database.mock.ts create mode 100644 tests/mocks/github.mock.ts create mode 100644 tests/mocks/gitlab.mock.ts create mode 100644 tests/mocks/octokit.mock.ts diff --git a/tests/mocks/crypto.mock.ts b/tests/mocks/crypto.mock.ts new file mode 100644 index 0000000..b0ef847 --- /dev/null +++ b/tests/mocks/crypto.mock.ts @@ -0,0 +1,5 @@ +export const mockCryptoService = { + 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..c590534 --- /dev/null +++ b/tests/mocks/github.mock.ts @@ -0,0 +1,21 @@ +export const mockGitHubAuthService = { + isConfigured: jest.fn(), + getProjectBadgingAuthUrl: jest.fn(), + requestAccessToken: jest.fn(), + createOctokit: jest.fn(), + getEventBadgingAuthUrl: jest.fn(), + isEventBadgingConfigured: jest.fn(), +}; + +export const mockGitHubApiService = { + createIssue: jest.fn(), + listIssueComments: jest.fn(), + getUserInfo: jest.fn(), + getUserRepositories: jest.fn(), + getRepoContent: jest.fn(), +}; + +export const mockGitHubAppInstance = { + getInstallationOctokit: jest.fn().mockResolvedValue({}), +}; + diff --git a/tests/mocks/gitlab.mock.ts b/tests/mocks/gitlab.mock.ts new file mode 100644 index 0000000..5cb4521 --- /dev/null +++ b/tests/mocks/gitlab.mock.ts @@ -0,0 +1,11 @@ + +export const mockGitLabAuthService = { + isConfigured: jest.fn(), + getAuthUrl: jest.fn(), + requestAccessToken: jest.fn(), +}; + +export const mockGitLabApiService = { + 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 From 836aa33e76f060d44417e8ab034b680cafa91aba Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 10 Apr 2026 16:49:27 +0100 Subject: [PATCH 16/45] fix: add optional TEST_DB_NAME to environment configuration for testing purposes Signed-off-by: AdeyinkaOresanya --- src/shared/config/environment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/config/environment.ts b/src/shared/config/environment.ts index 65241c2..313e131 100644 --- a/src/shared/config/environment.ts +++ b/src/shared/config/environment.ts @@ -12,7 +12,7 @@ export interface IEnvironmentConfig { DB_USER: 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; From d62677f60c869c8dcaa5401fdbdbd9ff7bddfa3e Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 10 Apr 2026 16:53:45 +0100 Subject: [PATCH 17/45] feat: add mockUserRepository with saveUser function for testing Signed-off-by: AdeyinkaOresanya --- tests/mocks/repository.mock.ts | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tests/mocks/repository.mock.ts diff --git a/tests/mocks/repository.mock.ts b/tests/mocks/repository.mock.ts new file mode 100644 index 0000000..725989e --- /dev/null +++ b/tests/mocks/repository.mock.ts @@ -0,0 +1,3 @@ + export const mockUserRepository = { + saveUser: jest.fn(), + }; \ No newline at end of file From 4bc8663a39bbe520b4c52f3b866b6735660910c7 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 10 Apr 2026 16:59:29 +0100 Subject: [PATCH 18/45] feat: add unit tests for CryptoService and MailerService Signed-off-by: AdeyinkaOresanya --- .../shared/services/crypto.service.test.ts | 74 ++++++++++++ .../shared/services/mailer.service.test.ts | 105 ++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 tests/unit/shared/services/crypto.service.test.ts create mode 100644 tests/unit/shared/services/mailer.service.test.ts 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..aeafffa --- /dev/null +++ b/tests/unit/shared/services/crypto.service.test.ts @@ -0,0 +1,74 @@ +import 'reflect-metadata'; +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..29ffd89 --- /dev/null +++ b/tests/unit/shared/services/mailer.service.test.ts @@ -0,0 +1,105 @@ +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'; + +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(console, 'warn').mockImplementation(); + + 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(console, 'error').mockImplementation(); + + 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 From 995aa3b31af05205d7b07b3cc92d54b62b53fa79 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 10 Apr 2026 17:09:10 +0100 Subject: [PATCH 19/45] feat: add unit tests for Database Signed-off-by: AdeyinkaOresanya --- tests/unit/database/database.test.ts | 55 ++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 tests/unit/database/database.test.ts diff --git a/tests/unit/database/database.test.ts b/tests/unit/database/database.test.ts new file mode 100644 index 0000000..0c51ba3 --- /dev/null +++ b/tests/unit/database/database.test.ts @@ -0,0 +1,55 @@ +import { Container } from 'typedi'; +import { mockDatabase, mockSequelize } from '../../mocks/database.mock'; +import { Database } from '../../../src/shared/data-access'; + +describe('Database (unit tests with mocks)', () => { + let db: Database; + + beforeEach(() => { + Container.reset(); + Container.set(Database, mockDatabase as any); + db = Container.get(Database); + }); + + afterEach(() => { + jest.clearAllMocks(); + Container.reset(); + }); + + test('connect() should be called', async () => { + await db.connect(); + expect(mockDatabase.connect).toHaveBeenCalled(); + }); + + test('disconnect() should be called', async () => { + await db.disconnect(); + expect(mockDatabase.disconnect).toHaveBeenCalled(); + }); + + test('getSequelize() should return mocked sequelize', () => { + const sequelize = db.getSequelize(); + expect(sequelize).toBe(mockSequelize); + expect(mockDatabase.getSequelize).toHaveBeenCalled(); + }); + + + test('connect() should throw an error if the connection is refused', async () => { + (mockDatabase.connect as jest.Mock).mockRejectedValueOnce(new Error('ECONNREFUSED')); + + await expect(db.connect()).rejects.toThrow('ECONNREFUSED'); + + expect(mockDatabase.connect).toHaveBeenCalled(); + }); + + test('disconnect() should handle being called multiple times gracefully', async () => { + await db.disconnect(); + await db.disconnect(); + + expect(mockDatabase.disconnect).toHaveBeenCalledTimes(2); + }); + + +}) + + + From 18623c2999b301db72cee615e252edc53fef6160 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 10 Apr 2026 17:09:46 +0100 Subject: [PATCH 20/45] feat: add unit tests for GitHub and GitLab services Signed-off-by: AdeyinkaOresanya --- .../github/github-api.service.test.ts | 113 ++++++++++++++++ .../github/github-app.service.test.ts | 116 ++++++++++++++++ .../github/github-auth.service.test.ts | 103 ++++++++++++++ .../gitlab/gitlab-api.service.test.ts | 126 ++++++++++++++++++ .../gitlab/gitlab-auth.service.test.ts | 91 +++++++++++++ 5 files changed, 549 insertions(+) create mode 100644 tests/unit/shared/providers/github/github-api.service.test.ts create mode 100644 tests/unit/shared/providers/github/github-app.service.test.ts create mode 100644 tests/unit/shared/providers/github/github-auth.service.test.ts create mode 100644 tests/unit/shared/providers/gitlab/gitlab-api.service.test.ts create mode 100644 tests/unit/shared/providers/gitlab/gitlab-auth.service.test.ts 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..e87a039 --- /dev/null +++ b/tests/unit/shared/providers/github/github-app.service.test.ts @@ -0,0 +1,116 @@ +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 { mockGitHubAppInstance } from '../../../../mocks/github.mock'; + +const MockedApp = App as unknown as jest.Mock; + +describe('GitHubAppService', () => { + let service: GitHubAppService; + + 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(() => {}); + + + 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 consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + jest.spyOn(envConfig, 'getEnvVar').mockImplementation((key) => + key === 'GITHUB_APP_ID' ? '' : 'some-value' + ); + + service = new GitHubAppService(); + + expect(service.isConfigured()).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('not fully configured') + ); + + consoleSpy.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', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + mockValidEnv(); + + MockedApp.mockImplementationOnce(() => { + throw new Error('Invalid Private Key Format'); + }); + + service = new GitHubAppService(); + + expect(service.isConfigured()).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to initialize GitHub App:', + expect.any(Error) + ); + + consoleSpy.mockRestore(); + }); +}); \ 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..63ced0e --- /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,public_repo'); + }); + + 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=public_repo'); + }); + }); + + + 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('createOctokit() should return instance with auth header', () => { + const octokit = service.createOctokit('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 From 8de07240122c6373cf8c2fa326943744e680be5b Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 10 Apr 2026 17:28:08 +0100 Subject: [PATCH 21/45] Add unit tests for event and project badging services Signed-off-by: AdeyinkaOresanya --- tests/unit/auth/auth.service.test.ts | 289 ++++++++++++++++++ .../event-badging/checklist.service.test.ts | 66 ++++ .../event-badging.service.test.ts | 230 ++++++++++++++ .../event-badging/scoring.service.test.ts | 135 ++++++++ .../badge-award.service.test.ts | 130 ++++++++ .../dei-scanner.service.test.ts | 91 ++++++ .../project-badging.service.test.ts | 134 ++++++++ 7 files changed, 1075 insertions(+) create mode 100644 tests/unit/auth/auth.service.test.ts create mode 100644 tests/unit/event-badging/checklist.service.test.ts create mode 100644 tests/unit/event-badging/event-badging.service.test.ts create mode 100644 tests/unit/event-badging/scoring.service.test.ts create mode 100644 tests/unit/project-badging/badge-award.service.test.ts create mode 100644 tests/unit/project-badging/dei-scanner.service.test.ts create mode 100644 tests/unit/project-badging/project-badging.service.test.ts diff --git a/tests/unit/auth/auth.service.test.ts b/tests/unit/auth/auth.service.test.ts new file mode 100644 index 0000000..e549e35 --- /dev/null +++ b/tests/unit/auth/auth.service.test.ts @@ -0,0 +1,289 @@ +import { AuthService } from '../../../src/auth/services/auth.service'; +import { + mockGitHubAuthService, + mockGitHubApiService, +} from '../../mocks/github.mock'; + +import { + mockGitLabAuthService, + mockGitLabApiService, +} from '../../mocks/gitlab.mock'; + +import { mockUserRepository } from '../../mocks/repository.mock'; +import { mockCryptoService } from '../../mocks/crypto.mock'; + +describe('AuthService', () => { + let authService: AuthService; + + beforeEach(() => { + jest.clearAllMocks(); + + authService = new AuthService( + mockGitHubAuthService 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); + + const result = authService.getLoginUrl('github'); + expect(result.data).toBeNull(); + expect(result.errors).toContain('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); + + const result = authService.getLoginUrl('gitlab'); + expect(result.data).toBeNull(); + expect(result.errors).toContain('GitLab provider is not configured'); + }); + + it('should return error for unknown provider', () => { + const result = authService.getLoginUrl('bitbucket' as any); + expect(result.data).toBeNull(); + expect(result.errors).toContain('Unknown provider: bitbucket'); + }); + }); + + + describe('getEventBadgingAuthUrl', () => { + it('should return error if event badging is not configured', () => { + mockGitHubAuthService.isEventBadgingConfigured.mockReturnValue(false); + + const result = authService.getEventBadgingAuthUrl('Test Event', 'Test body'); + + expect(result.data).toBeNull(); + expect(result.errors).toContain('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'], + }); + + const result = await authService.handleGitHubCallback('code123'); + + expect(result.data).toBeNull(); + expect(result.errors).toContain('token failed'); + }); +}); + +it('should return user and repositories for project badging flow', async () => { + mockGitHubAuthService.requestAccessToken.mockResolvedValue({ + access_token: 'mock-token', + errors: [], + }); + + mockGitHubAuthService.createOctokit.mockReturnValue({} 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 return user and repositories for project badging flow', async () => { + mockGitHubAuthService.requestAccessToken.mockResolvedValue({ + access_token: 'mock-token', + errors: [], + }); + + mockGitHubAuthService.createOctokit.mockReturnValue({} 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.createOctokit.mockReturnValue({} as any); + + 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'], + }); + + const result = await authService.handleGitLabCallback('code'); + + expect(result.data).toBeNull(); + expect(result.errors).toContain('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/event-badging/checklist.service.test.ts b/tests/unit/event-badging/checklist.service.test.ts new file mode 100644 index 0000000..7add638 --- /dev/null +++ b/tests/unit/event-badging/checklist.service.test.ts @@ -0,0 +1,66 @@ +import { ChecklistService } from '../../../src/event-badging/services/checklist.service'; +import { mockGitHubApiService } from '../../mocks/github.mock'; + +describe('ChecklistService', () => { + let service: ChecklistService; + + beforeEach(() => { + jest.clearAllMocks(); + 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: 404 Not Found'); + }); + + test('checkModerator() should return false and log error if file is missing', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + mockGitHubApiService.getRepoContent.mockResolvedValue({ data: null, errors: ['File missing'] }); + + const result = await service.checkModerator({} as any, 'o', 'r', 'user'); + + expect(result).toBe(false); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.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..3d05ad2 --- /dev/null +++ b/tests/unit/event-badging/event-badging.service.test.ts @@ -0,0 +1,230 @@ +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 - 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'); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(); + + githubApiService.createIssueComment.mockRejectedValue(new Error('GitHub Down')); + + await service.processWebhook('issues', payload); + await Promise.resolve(); + + expect(errorSpy).toHaveBeenCalledWith( + 'Error posting welcome message:', + expect.any(Error) + ); + + errorSpy.mockRestore(); + }); + + 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 - 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..ba49d79 --- /dev/null +++ b/tests/unit/event-badging/scoring.service.test.ts @@ -0,0 +1,135 @@ +import { ScoringService } from '../../../src/event-badging/services/scoring.service'; +import { mockGitHubApiService } 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: any; + let mockOctokit: any; + + beforeEach(() => { + mockGitHubApiService.listIssueComments.mockReset(); + githubApiServiceMock = mockGitHubApiService; + mockOctokit = createMockOctokit(); + + + service = new ScoringService(githubApiServiceMock); + + 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..50ecb53 --- /dev/null +++ b/tests/unit/project-badging/badge-award.service.test.ts @@ -0,0 +1,130 @@ +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 catch errors and return false if mailerService throws', async () => { + mailerService.sendBadgingEmail.mockRejectedValue(new Error('SMTP Down')); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const result = await service.awardBronzeBadge( + mockUser.id, mockUser.name, mockUser.email, 1, null, 'url', 'sha' + ); + + expect(result).toBe(false); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Error awarding bronze badge'), 'SMTP Down'); + errorSpy.mockRestore(); + }); + + 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..37fe260 --- /dev/null +++ b/tests/unit/project-badging/dei-scanner.service.test.ts @@ -0,0 +1,91 @@ +import { DEIScannerService } from '../../../src/project-badging/services/dei-scanner.service'; +import { DEI_REQUIRED_SECTIONS } from '../../../src/shared/types'; + +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(console, 'error').mockImplementation(); + + 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 From 2df27f7d467337c08f908616b4270b93689ac5d4 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 10 Apr 2026 17:29:56 +0100 Subject: [PATCH 22/45] fix: remove checkmarks from filesfix: remove checkmarks from files Signed-off-by: AdeyinkaOresanya --- .husky/pre-commit | 2 +- src/scripts/configure.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/src/scripts/configure.ts b/src/scripts/configure.ts index e3c13c6..209ccf4 100644 --- a/src/scripts/configure.ts +++ b/src/scripts/configure.ts @@ -160,7 +160,7 @@ SMEE_WEBHOOK_URL=${values.smee_webhook_url} `; fs.writeFileSync(envPath, envFile); - console.info('\n✅ Configuration file (.env) created successfully at project root.'); + console.info('\n Configuration file (.env) created successfully at project root.'); } configure().catch((error) => { From fde224eab55bfff84876138e88771268dd2747ce Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 10 Apr 2026 17:37:53 +0100 Subject: [PATCH 23/45] feat: add integration tests for EventBadgingController and ProjectBadgingController Signed-off-by: AdeyinkaOresanya --- tests/integration/event-badging.test.ts | 174 +++++++++++++++ tests/integration/project-badging.test.ts | 253 ++++++++++++++++++++++ 2 files changed, 427 insertions(+) create mode 100644 tests/integration/event-badging.test.ts create mode 100644 tests/integration/project-badging.test.ts diff --git a/tests/integration/event-badging.test.ts b/tests/integration/event-badging.test.ts new file mode 100644 index 0000000..0a6b547 --- /dev/null +++ b/tests/integration/event-badging.test.ts @@ -0,0 +1,174 @@ +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'; +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] }); + + 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', + body: '- Link to the Event Website: https://virtual.com\n- Provide verification that you are an event organizer: ', + assignees: [] + }, + 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.text).toBe('Error processing webhook'); + }); + + 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..8700e88 --- /dev/null +++ b/tests/integration/project-badging.test.ts @@ -0,0 +1,253 @@ +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'; +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: true, + }); +}); + + 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 }], + }); + + console.log('Response:', res.body); + 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('<img') + ); + + const saved = await Repo.findOne({ where: { githubRepoId: 1, DEICommitSHA: 'sha-1' } }); + expect(saved).not.toBeNull(); + expect(saved!.badgeType).toBe('Bronze'); + }); + + it('should not badge the same repo/commit twice', async () => { + 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.error).toContain('Unknown 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.error).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); + }); + + +}); + From c3c18eddff07e859a6906f61fa46af36cbdf9c38 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Mon, 13 Apr 2026 19:12:39 +0100 Subject: [PATCH 24/45] fix: remove deprecated option from tsconfig.json Signed-off-by: AdeyinkaOresanya --- tsconfig.json | 1 - 1 file changed, 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 9c64eb9..152e95f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,7 +23,6 @@ "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, - "ignoreDeprecations": "6.0", "baseUrl": "./", "paths": { "@shared/*": ["shared/*"], From b7cec6872de843b1cc71aeeaffc02d921c895be6 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Mon, 13 Apr 2026 19:16:47 +0100 Subject: [PATCH 25/45] fix: update configuration for event badging repository details Signed-off-by: AdeyinkaOresanya --- src/auth/services/auth.service.ts | 5 +++-- src/scripts/configure.ts | 25 ++++++++++++++++++++++--- src/shared/config/environment.ts | 2 ++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index ad63b66..615a119 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -7,6 +7,7 @@ import { GitLabApiService, UserRepository, CryptoService, + getEnvVar } from '../../shared'; import { Provider, IAuthCallbackResult, IOperationResult } from '../../shared/types'; @@ -123,8 +124,8 @@ export class AuthService { const result = await this.githubApiService.createIssue( octokit, - 'adeyinkaoresanya', - 'sandbox-event-dei', + getEnvVar('REPOSITORY_OWNER'), + getEnvVar('REPOSITORY_NAME'), parsedFormData.title, markdown ); diff --git a/src/scripts/configure.ts b/src/scripts/configure.ts index 209ccf4..2f4641e 100644 --- a/src/scripts/configure.ts +++ b/src/scripts/configure.ts @@ -45,7 +45,13 @@ 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 { @@ -107,10 +113,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 @@ -156,7 +170,12 @@ 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); diff --git a/src/shared/config/environment.ts b/src/shared/config/environment.ts index 313e131..f7c7af2 100644 --- a/src/shared/config/environment.ts +++ b/src/shared/config/environment.ts @@ -35,6 +35,8 @@ export interface IEnvironmentConfig { //repository for event badging REPOSITORY_NAME: string; + REPOSITORY_OWNER: string; + // Email EMAIL_HOST: string; From c20785f70890226a68a0cc42868b6b4316fefa01 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Mon, 13 Apr 2026 19:17:24 +0100 Subject: [PATCH 26/45] fix: refactor event URL extraction logic in handleIssueOpened method Signed-off-by: AdeyinkaOresanya --- .../services/event-badging.service.ts | 92 +++++++++++++------ 1 file changed, 64 insertions(+), 28 deletions(-) diff --git a/src/event-badging/services/event-badging.service.ts b/src/event-badging/services/event-badging.service.ts index a03c28b..19d7cc9 100644 --- a/src/event-badging/services/event-badging.service.ts +++ b/src/event-badging/services/event-badging.service.ts @@ -211,36 +211,71 @@ export class EventBadgingService { 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) { - // slice after startMarker, up to endIdx - eventUrl = payload.issue.body.slice( - startIdx + startMarker.length, - endIdx - ).trim(); - } +// 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) { +// // slice after startMarker, up to endIdx +// eventUrl = payload.issue.body.slice( +// startIdx + startMarker.length, +// endIdx +// ).trim(); +// } + +// } 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) { +// // Slice after startMarker up to endIdx +// eventUrl = payload.issue.body.slice( +// startIdx + startMarker.length, +// endIdx +// ).trim(); +// } +// } + +// 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, ''); +// } +// } + + + + +let eventUrl = ''; - } 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) { - // Slice after startMarker up to endIdx - eventUrl = payload.issue.body.slice( - startIdx + startMarker.length, - endIdx - ).trim(); +const body = payload.issue.body || ''; +const match = body.match( + /Link to the Event Website:\s*(.+)$/mi + ); + console.info(`Regex match result: ${match}`); + + if (match) { + eventUrl = match[1]; } -} - + // Create badge object const badge: IEventBadge = { @@ -270,6 +305,7 @@ export class EventBadgingService { }); console.info('Event saved to database'); + console.info(`Event URL: ${eventUrl}`); } catch (error) { console.error('Error saving event:', error); } From 937a1fa073824b5554901b507cafafe74c8fc299 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Mon, 13 Apr 2026 19:18:04 +0100 Subject: [PATCH 27/45] fix: update GitLab authentication methods to handle responses and errors correctly Signed-off-by: AdeyinkaOresanya --- src/auth/controllers/auth.controller.ts | 37 +++++++++++++++++-------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index c6fd052..beae928 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -62,15 +62,18 @@ authGitHub(@Res() res: Response): void { } @Get('/auth/gitlab') - @Redirect(':url') - authGitLab() { + // @Redirect(':url') + authGitLab(@Res() res: Response): void { const result = this.authService.getLoginUrl('gitlab'); if (result.errors.length || !result.data) { + console.error('GitLab auth error:', result.errors); throw new InternalServerError(result.errors.join(', ')); } + res.redirect(result.data); + return; - return { url: result.data }; + // return { url: result.data }; } /** @@ -142,17 +145,22 @@ authGitHub(@Res() res: Response): void { } @Get('/callback/gitlab') - async callbackGitLabGet(@QueryParam('code') code: string) { - return this.handleGitLabCallback(code); + async callbackGitLabGet( + @QueryParam('code') code: string, + @Res() response: Response + ): Promise { + await this.handleGitLabCallback(code, response); + return; } @Post('/callback/gitlab') async callbackGitLabPost( @Body() body: { code?: string }, - @QueryParam('code') queryCode: string - ) { + @QueryParam('code') queryCode: string, + @Res() response: Response + ): Promise { const code = body.code || queryCode; - return this.handleGitLabCallback(code); + return this.handleGitLabCallback(code, response); } /** @@ -187,20 +195,25 @@ authGitHub(@Res() res: Response): void { return; } - private async handleGitLabCallback(code: string) { - if (!code) throw new BadRequestError('Code is required'); + private async handleGitLabCallback(code: string, res: Response): Promise { + if (!code) { + res.status(400).json({ error: 'Code is required' }); + return; + } const result = await this.authService.handleGitLabCallback(code); if (result.errors.length || !result.data) { + console.error('GitLab auth error:', result.errors); throw new InternalServerError(result.errors.join(', ')); } if (isProduction()) { - return result.data; + res.status(200).json(result.data); + return; } - return this.renderDevRepoForm(result.data, 'gitlab'); + res.status(200).send(this.renderDevRepoForm(result.data, 'gitlab')); } /** From e5971f0d4fca5e413e5cce3565b0235396e92266 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Mon, 13 Apr 2026 19:18:28 +0100 Subject: [PATCH 28/45] fix: correct typo in success email template Signed-off-by: AdeyinkaOresanya --- src/shared/templates/email/success.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.
From 1bfe37bd30bdaa79c49202d379a138e78a9ba0ec Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Mon, 13 Apr 2026 19:18:59 +0100 Subject: [PATCH 29/45] fix: update issue payload structure to include assignees and format body correctly Signed-off-by: AdeyinkaOresanya --- tests/integration/event-badging.test.ts | 3 ++- tests/unit/event-badging/event-badging.service.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/integration/event-badging.test.ts b/tests/integration/event-badging.test.ts index 0a6b547..bee6323 100644 --- a/tests/integration/event-badging.test.ts +++ b/tests/integration/event-badging.test.ts @@ -130,8 +130,9 @@ describe('EventBadgingController Integration', () => { 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: ', - assignees: [] }, installation: { id: 1 }, repository: { name: 'b', owner: { login: 'o' } } diff --git a/tests/unit/event-badging/event-badging.service.test.ts b/tests/unit/event-badging/event-badging.service.test.ts index 3d05ad2..aa0df5b 100644 --- a/tests/unit/event-badging/event-badging.service.test.ts +++ b/tests/unit/event-badging/event-badging.service.test.ts @@ -128,7 +128,7 @@ describe('EventBadgingService', () => { 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 - 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!.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' }, @@ -213,7 +213,7 @@ describe('EventBadgingService', () => { const payload = createPayload('[Virtual Event] Online Meetup', 'closed'); payload.issue!.body = - '- Link to the Event Website: https://virtual.io - Provide verification that you are an event organizer: Yes'; + '- Link to the Event Website: https://virtual.io\n - Provide verification that you are an event organizer: Yes'; scoringService.calculateBadge.mockResolvedValue({ assigned_badge: 'Silver' From 87afa612c2e53d1f386a21679a8ec8f761f244bf Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 22 May 2026 16:44:46 +0100 Subject: [PATCH 30/45] fix: update .env.example to include GitHub app installation ID and token Signed-off-by: AdeyinkaOresanya --- .env.example | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 632903d..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,21 +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= - -REPOSITORY_NAME= # repository used for event badging +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 SMEE_CLIENT_URL= WEBHOOK_PATH= + + From e9196bf4d869c2a277e0f6082a15624c187555d4 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 22 May 2026 16:49:12 +0100 Subject: [PATCH 31/45] fix: clean up app.ts by removing comments and organizing imports Signed-off-by: AdeyinkaOresanya --- src/app.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/app.ts b/src/app.ts index 7d36e1c..fa2f2a9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,20 +4,19 @@ import { Container } from 'typedi'; 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'; -// Set container for routing-controllers +import { requestLogger, globalErrorHandler} from './middleware' + 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, SystemController], cors: true, @@ -25,8 +24,12 @@ export function createApp(): Express { routePrefix: '', }) as Express; - // Serve static files - app.use('/assets', express.static(path.join(__dirname, '../assets'))); +app.use(requestLogger); + +app.use('/assets', express.static(path.join(__dirname, '../assets'))); + + + app.use(globalErrorHandler); return app; } From 093e32a55143d3c5ee411e5af0685d3bb534fe15 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 22 May 2026 16:50:58 +0100 Subject: [PATCH 32/45] fix: create database disconnection handling during server shutdown Signed-off-by: AdeyinkaOresanya --- src/index.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index cabb2f2..5be7373 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import 'reflect-metadata'; import 'dotenv/config'; import { createApp } from './app'; -import { initializeDatabase } from './shared/data-access/database.bootstrap'; +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'; @@ -23,17 +23,22 @@ async function bootstrap(): Promise { const server = app.listen(PORT, () => { logger.info('Server started at', { url: APP_URL }); }); - + const shutdown = () => { stopSmee(); - server.close(() => process.exit(0)); + server.close(async () => { + + await disconnectDatabase(); + process.exit(0); + + }); }; process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); } -bootstrap().catch((err) => { - logger.error('Fatal startup error', { err }); +bootstrap().catch((error) => { + logger.error('Fatal startup error', error); process.exit(1); }); From fc6387028be4ee15c5d02125f45185f59309a4f1 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 22 May 2026 16:52:35 +0100 Subject: [PATCH 33/45] refactor: Refactor AuthController and AuthService for improved error handling and code clarity Signed-off-by: AdeyinkaOresanya --- src/auth/controllers/auth.controller.ts | 334 +++----- src/auth/services/auth.service.ts | 1030 ++++++++++++++++++++--- 2 files changed, 996 insertions(+), 368 deletions(-) diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index beae928..33d7a48 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -4,334 +4,198 @@ import { Post, QueryParam, Body, - Redirect, - BadRequestError, - InternalServerError, - ContentType, Res, } from 'routing-controllers'; import { Response } from 'express'; import { Service } from 'typedi'; + import { AuthService } from '../services/auth.service'; + import { Provider } from '../../shared/types'; + 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) {} - /** - * LOGIN ENTRY - */ + // ------------------------- + // LOGIN ENTRY + // ------------------------- + @Get('/login') - @Redirect(':url') - login(@QueryParam('provider') provider: Provider) { - if (!provider) throw new BadRequestError('Provider is required'); + // @Redirect(':url') + login(@QueryParam('provider') provider: Provider, @Res() res: Response) { + if (!provider) { + throw new AppError('Provider is required', 400); + } - const result = this.authService.getLoginUrl(provider); + const { data } = this.authService.getLoginUrl(provider); - if (result.errors.length || !result.data) { - throw new InternalServerError(result.errors.join(', ')); + if (!data) { + throw new AppError('Failed to generate login URL', 500); } - return { url: result.data }; + //return { url: data }; + res.redirect(data); + return; } - /** - * PROVIDER LOGIN - */ + // ------------------------- + // PROVIDER LOGIN + // ------------------------- + @Get('/auth/github') + authGitHub(@Res() res: Response) { + const { data } = this.authService.getLoginUrl('github'); -@Get('/auth/github') -// @Redirect('') -authGitHub(@Res() res: Response): void { - const result = this.authService.getLoginUrl('github'); + if (!data) { + throw new AppError('GitHub auth URL generation failed', 500); + } - if (result.errors.length || !result.data) { - throw new InternalServerError(result.errors.join(', ')); + return res.redirect(data); } - res.redirect(result.data); - return; - - // return { - // url: result.data, - // statusCode: 302 - // }; - -} @Get('/auth/gitlab') - // @Redirect(':url') - authGitLab(@Res() res: Response): void { - const result = this.authService.getLoginUrl('gitlab'); + authGitLab(@Res() res: Response) { + const { data } = this.authService.getLoginUrl('gitlab'); - if (result.errors.length || !result.data) { - console.error('GitLab auth error:', result.errors); - throw new InternalServerError(result.errors.join(', ')); + if (!data) { + throw new AppError('GitLab auth URL generation failed', 500); } - res.redirect(result.data); - return; - // return { url: result.data }; + res.redirect(data); + return; } - /** - * EVENT BADGING AUTH - */ + // ------------------------- + // EVENT BADGING AUTH + // ------------------------- @Post('/auth/github') authGitHubEventBadging( @Body() body: { type?: string; title?: string; body?: string } ) { - if (body.type === 'event-badging') { - if (!body.title || !body.body) { - throw new BadRequestError('Title and body are required'); - } + if (body.type !== 'event-badging') { + const { data } = this.authService.getLoginUrl('github'); - const result = this.authService.getEventBadgingAuthUrl( - body.title, - body.body - ); - - if (result.errors.length || !result.data) { - throw new InternalServerError(result.errors.join(', ')); + if (!data) { + throw new AppError('GitHub auth generation failed', 500); } - return { authorizationLink: result.data }; + return { redirectUrl: data }; + } + + if (!body.title || !body.body) { + throw new AppError('Title and body are required', 400); } - const result = this.authService.getLoginUrl('github'); + const { data } = this.authService.getEventBadgingAuthUrl( + body.title, + body.body + ); - if (result.errors.length || !result.data) { - throw new InternalServerError(result.errors.join(', ')); + if (!data) { + throw new AppError('Failed to generate event badging auth URL', 500); } - return { redirectUrl: result.data }; + return { authorizationLink: data }; } - /** - * CALLBACKS - */ + // ------------------------- + // CALLBACKS + // ------------------------- - /** - * GET /api/callback/github - * Ensure we pass @Res() and return Promise - */ @Get('/callback/github') async callbackGitHubGet( @QueryParam('code') code: string, @QueryParam('state') state: string, - @Res() response: Response // Inject the response object - ): Promise { - await this.handleGitHubCallback(code, state, response); - return; // Explicitly return nothing so the framework stops here + @Res() res: Response + ) { + return this.handleGitHubCallback(code, state, res); } - /** - * POST /api/callback/github - * Consistency is key: use the same Res pattern - */ @Post('/callback/github') async callbackGitHubPost( @Body() body: { code?: string }, @QueryParam('code') queryCode: string, @QueryParam('state') state: string, - @Res() response: Response - ): Promise { - const code = body.code || queryCode; - await this.handleGitHubCallback(code, state, response); - return; + @Res() res: Response + ) { + const code = body?.code || queryCode; + return this.handleGitHubCallback(code, state, res); } @Get('/callback/gitlab') async callbackGitLabGet( @QueryParam('code') code: string, - @Res() response: Response - ): Promise { - await this.handleGitLabCallback(code, response); - return; + @Res() res: Response + ) { + return this.handleGitLabCallback(code, res); } @Post('/callback/gitlab') async callbackGitLabPost( @Body() body: { code?: string }, @QueryParam('code') queryCode: string, - @Res() response: Response - ): Promise { - const code = body.code || queryCode; - return this.handleGitLabCallback(code, response); + @Res() res: Response + ) { + const code = body?.code || queryCode; + return this.handleGitLabCallback(code, res); } - /** - * HANDLERS - */ + // ------------------------- + // HANDLERS + // ------------------------- - private async handleGitHubCallback(code: string, state: string | undefined, res: Response): Promise { + private async handleGitHubCallback( + code: string, + state: string | undefined, + res: Response + ) { if (!code) { - res.status(400).json({ error: 'Code is required' }); - return; // STOP HERE - } - + throw new AppError('Code is required', 400); + } + const result = await this.authService.handleGitHubCallback(code, state); - - if (result.errors.length > 0 || !result.data) { - res.status(500).json({ error: result.errors.join(', ') }); - return; + + if (!result.data) { + throw new AppError('GitHub callback failed', 500); } if ('issueUrl' in result.data) { - res.redirect(result.data.issueUrl); - return; // Stop execution here + return res.redirect(result.data.issueUrl); } - // For JSON or HTML, send it and return void if (isProduction()) { - res.status(200).json(result.data); - return; + return res.status(200).json(result.data); } - res.status(200).send(this.renderDevRepoForm(result.data, 'github')); - return; + return res + .status(200) + .send(renderDevRepoForm(result.data, 'github')); } - private async handleGitLabCallback(code: string, res: Response): Promise { + private async handleGitLabCallback(code: string, res: Response) { if (!code) { - res.status(400).json({ error: 'Code is required' }); - return; + throw new AppError('Code is required', 400); } const result = await this.authService.handleGitLabCallback(code); - if (result.errors.length || !result.data) { - console.error('GitLab auth error:', result.errors); - throw new InternalServerError(result.errors.join(', ')); + if (!result.data) { + throw new AppError('GitLab callback failed', 500); } if (isProduction()) { - res.status(200).json(result.data); - return; + return res.status(200).json(result.data); } - res.status(200).send(this.renderDevRepoForm(result.data, 'gitlab')); + return res + .status(200) + .send(renderDevRepoForm(result.data, 'gitlab')); } - - /** - * DEV HTML RENDERER - */ - -@ContentType('text/html') -private 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('')} - - -
- - - - - `; -} - - -@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}

- - - `; -} } \ No newline at end of file diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 615a119..805fb53 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -1,7 +1,742 @@ +// 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'; + +// @Service() +// export class AuthService { +// constructor( +// private githubAuthService: GitHubAuthService, +// private githubAppService: GitHubAppService, +// private githubApiService: GitHubApiService, +// private gitlabAuthService: GitLabAuthService, +// private gitlabApiService: GitLabApiService, +// private userRepository: UserRepository, +// private cryptoService: CryptoService +// ) {} + +// /** +// * Get login redirect URL for a provider +// */ +// getLoginUrl(provider: Provider): IOperationResult { +// if (provider === 'github') { +// if (!this.githubAuthService.isConfigured()) { +// return { +// data: null, +// errors: ['GitHub provider is not configured'], +// }; +// } +// return { +// data: this.githubAuthService.getProjectBadgingAuthUrl(), +// errors: [], +// }; +// } + +// if (provider === 'gitlab') { +// if (!this.gitlabAuthService.isConfigured()) { +// return { +// data: null, +// errors: ['GitLab provider is not configured'], +// }; +// } +// return { +// data: this.gitlabAuthService.getAuthUrl(), +// errors: [], +// }; +// } + +// return { +// data: null, +// errors: [`Unknown provider: ${provider as string}`], +// }; +// } + +// /** +// * Get event badging authorization URL with encrypted form data +// */ +// getEventBadgingAuthUrl(title: string, body: string): IOperationResult { +// if (!this.githubAuthService.isEventBadgingConfigured()) { +// return { +// data: null, +// errors: ['GitHub event badging provider is not configured'], +// }; +// } + +// const formData = JSON.stringify({ title, body, type: 'event-badging' }); +// const encryptedState = this.cryptoService.encrypt(formData); +// const url = this.githubAuthService.getEventBadgingAuthUrl(encryptedState); + +// return { +// data: url, +// errors: [], +// }; +// } + +// /** +// * Handle GitHub OAuth 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); + +// if (tokenErrors.length > 0 || !accessToken) { +// return { +// data: null, +// errors: tokenErrors.length > 0 ? tokenErrors : ['Failed to get access token'], +// }; +// } + +// 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) { +// const appOctokit = await this.githubAppService.getInstallationOctokit(Number(getEnvVar('GITHUB_APP_INSTALLATION_ID'))); +// //return this.handleEventBadgingCallback(octokit, state); +// return this.handleEventBadgingCallback(appOctokit, state, user); +// } + +// // Handle project badging flow - return user data and repos +// //const octokit = this.githubAuthService.createOctokit(accessToken); +// return this.handleProjectBadgingCallback(userOctokit); +// } + +// /** +// * Handle event badging OAuth callback - creates issue +// */ +// private async handleEventBadgingCallback( +// 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 baseMarkdown = this.cryptoService.convertToMarkdown(parsedFormData.body); + +// // const markdown = `### 👤 Submitted by +// // - GitHub: @${user.login} +// // - Profile: ${user.html_url} +// // --- +// // ${baseMarkdown}`.trim(); + +// 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'), +// parsedFormData.title, +// markdown +// ); + +// if (result.errors.length > 0 || !result.data) { +// return { +// data: null, +// errors: result.errors, +// }; +// } + +// return { +// data: { issueUrl: result.data.url }, +// errors: [], +// }; +// } catch (error) { +// const errorMessage = error instanceof Error ? error.message : 'Unknown error'; +// return { +// data: null, +// errors: [errorMessage], +// }; +// } +// } + +// /** +// * Handle project badging OAuth callback - returns user and repos +// */ +// 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, +// }; +// } + +// // Save user to database +// const savedUser = await this.userRepository.saveUser( +// userInfo.login, +// userInfo.name, +// userInfo.email, +// userInfo.id, +// null +// ); + +// if (!savedUser) { +// return { +// data: null, +// errors: ['Error saving user info'], +// }; +// } + +// // Get user repositories +// const { data: repositories, errors: repoErrors } = +// await this.githubApiService.getUserRepositories(octokit); + +// if (repoErrors.length > 0 || !repositories) { +// return { +// data: null, +// errors: repoErrors, +// }; +// } + +// return { +// data: { +// userId: savedUser.id, +// name: savedUser.name, +// username: savedUser.login, +// email: savedUser.email, +// repos: repositories, +// provider: 'github', +// }, +// errors: [], +// }; +// } + +// /** +// * 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); + +// if (tokenErrors.length > 0 || !accessToken) { +// return { +// data: null, +// errors: tokenErrors.length > 0 ? tokenErrors : ['Failed to get access token'], +// }; +// } + +// // Get user info +// const { data: userInfo, errors: userInfoErrors } = +// await this.gitlabApiService.getUserInfo(accessToken); + +// if (userInfoErrors.length > 0 || !userInfo) { +// return { +// data: null, +// errors: userInfoErrors, +// }; +// } + +// // Save user to database +// const savedUser = await this.userRepository.saveUser( +// userInfo.login, +// userInfo.name, +// userInfo.email, +// null, +// userInfo.id +// ); + +// if (!savedUser) { +// return { +// data: null, +// errors: ['Error saving user info'], +// }; +// } + +// // Get user repositories +// const { data: repositories, errors: repoErrors } = +// await this.gitlabApiService.getUserRepositories(accessToken); + +// if (repoErrors.length > 0 || !repositories) { +// return { +// data: null, +// errors: repoErrors, +// }; +// } + +// return { +// data: { +// userId: savedUser.id, +// name: savedUser.name, +// username: savedUser.login, +// email: savedUser.email, +// repos: repositories, +// provider: 'gitlab', +// }, +// errors: [], +// }; +// } +// } + + + + + + + + +// 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 { AppError } from '../../shared/errors'; + +// @Service() +// export class AuthService { +// constructor( +// private githubAuthService: GitHubAuthService, +// private githubAppService: GitHubAppService, +// private githubApiService: GitHubApiService, +// private gitlabAuthService: GitLabAuthService, +// private gitlabApiService: GitLabApiService, +// private userRepository: UserRepository, +// private cryptoService: CryptoService +// ) {} + +// /** +// * Get login redirect URL +// */ +// getLoginUrl(provider: Provider): IOperationResult { + +// if (provider === 'github') { + +// if (!this.githubAuthService.isConfigured()) { +// throw new AppError( +// 'GitHub provider is not configured', +// 500 +// ); +// } + +// return { +// data: this.githubAuthService.getProjectBadgingAuthUrl(), +// errors: [], +// }; +// } + +// if (provider === 'gitlab') { + +// if (!this.gitlabAuthService.isConfigured()) { +// throw new AppError( +// 'GitLab provider is not configured', +// 500 +// ); +// } + +// return { +// data: this.gitlabAuthService.getAuthUrl(), +// errors: [], +// }; +// } + +// throw new AppError( +// `Unknown provider: ${provider as string}`, +// 400 +// ); +// } + +// /** +// * Event badging OAuth URL +// */ +// getEventBadgingAuthUrl( +// title: string, +// body: string +// ): IOperationResult { + +// if (!this.githubAuthService.isEventBadgingConfigured()) { +// throw new AppError( +// 'GitHub event badging provider is not configured', +// 500 +// ); +// } + +// try { + +// const formData = JSON.stringify({ +// title, +// body, +// type: 'event-badging' +// }); + +// const encryptedState = +// this.cryptoService.encrypt(formData); + +// const url = +// this.githubAuthService.getEventBadgingAuthUrl( +// encryptedState +// ); + +// return { +// data: url, +// errors: [], +// }; + +// } catch (error: unknown) { + +// throw new AppError( +// 'Failed to generate event badging authorization URL', +// 500, +// true, +// error +// ); +// } +// } + +// /** +// * GitHub OAuth callback +// */ +// async handleGitHubCallback( +// code: string, +// state?: string +// ): Promise< +// IOperationResult< +// IAuthCallbackResult | { issueUrl: string } +// > +// > { + +// const isEventBadging = !!state; + +// const { +// access_token: accessToken, +// errors: tokenErrors +// } = await this.githubAuthService.requestAccessToken( +// code, +// isEventBadging +// ); + +// if (tokenErrors.length > 0 || !accessToken) { + +// throw new AppError( +// tokenErrors.join(', ') || 'Failed to get access token', +// 401 +// ); +// } + +// try { + +// const userOctokit = +// this.githubAuthService.createUserOctokit(accessToken); + +// const { data: user } = +// await userOctokit.request('GET /user'); + +// /** +// * EVENT BADGING FLOW +// */ +// if (state) { + +// const appOctokit = +// await this.githubAppService.getInstallationOctokit( +// Number(getEnvVar('GITHUB_APP_INSTALLATION_ID')) +// ); + +// return this.handleEventBadgingCallback( +// appOctokit, +// state, +// user +// ); +// } + +// /** +// * PROJECT BADGING FLOW +// */ +// return this.handleProjectBadgingCallback(userOctokit); + +// } catch (error: unknown) { + +// throw new AppError( +// 'GitHub authentication callback failed', +// 500, +// true, +// error +// ); +// } +// } + +// /** +// * Event badging callback +// */ +// private async handleEventBadgingCallback( +// 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 baseMarkdown = +// this.cryptoService.convertToMarkdown( +// parsedFormData.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'), +// parsedFormData.title, +// markdown +// ); + +// if (result.errors.length > 0 || !result.data) { + +// throw new AppError( +// result.errors.join(', ') || +// 'Failed to create GitHub issue', +// 500 +// ); +// } + +// return { +// data: { +// issueUrl: result.data.url +// }, +// errors: [], +// }; + +// } catch (error: unknown) { +// throw new AppError( +// 'Failed to process event badging callback', +// 500, +// true, +// error +// ); +// } +// } + +// /** +// * Project badging callback +// */ +// private async handleProjectBadgingCallback( +// octokit: Octokit +// ): Promise> { + +// const { +// data: userInfo, +// errors: userInfoErrors +// } = await this.githubApiService.getUserInfo(octokit); + +// if (userInfoErrors.length > 0 || !userInfo) { + +// throw new AppError( +// userInfoErrors.join(', ') || +// 'Failed to retrieve GitHub user info', +// 500 +// ); +// } + +// const savedUser = +// await this.userRepository.saveUser( +// userInfo.login, +// userInfo.name, +// userInfo.email, +// userInfo.id, +// null +// ); + +// if (!savedUser) { + +// throw new AppError( +// 'Failed to save GitHub user', +// 500 +// ); +// } + +// const { +// data: repositories, +// errors: repoErrors +// } = await this.githubApiService.getUserRepositories( +// octokit +// ); + +// if (repoErrors.length > 0 || !repositories) { + +// throw new AppError( +// repoErrors.join(', ') || +// 'Failed to retrieve repositories', +// 500 +// ); +// } + +// return { +// data: { +// userId: savedUser.id, +// name: savedUser.name, +// username: savedUser.login, +// email: savedUser.email, +// repos: repositories, +// provider: 'github', +// }, +// errors: [], +// }; +// } + +// /** +// * GitLab OAuth callback +// */ +// async handleGitLabCallback( +// code: string +// ): Promise> { + +// const { +// access_token: accessToken, +// errors: tokenErrors +// } = await this.gitlabAuthService.requestAccessToken(code); + +// if (tokenErrors.length > 0 || !accessToken) { + +// throw new AppError( +// tokenErrors.join(', ') || +// 'Failed to get GitLab access token', +// 401 +// ); +// } + +// const { +// data: userInfo, +// errors: userInfoErrors +// } = await this.gitlabApiService.getUserInfo(accessToken); + +// if (userInfoErrors.length > 0 || !userInfo) { + +// throw new AppError( +// userInfoErrors.join(', ') || +// 'Failed to retrieve GitLab user info', +// 500 +// ); +// } + +// const savedUser = +// await this.userRepository.saveUser( +// userInfo.login, +// userInfo.name, +// userInfo.email, +// null, +// userInfo.id +// ); + +// if (!savedUser) { + +// throw new AppError( +// 'Failed to save GitLab user', +// 500 +// ); +// } + +// const { +// data: repositories, +// errors: repoErrors +// } = await this.gitlabApiService.getUserRepositories( +// accessToken +// ); + +// if (repoErrors.length > 0 || !repositories) { + +// throw new AppError( +// repoErrors.join(', ') || +// 'Failed to retrieve GitLab repositories', +// 500 +// ); +// } + +// return { +// data: { +// userId: savedUser.id, +// name: savedUser.name, +// username: savedUser.login, +// email: savedUser.email, +// repos: repositories, +// provider: 'gitlab', +// }, +// errors: [], +// }; +// } +// } + + + + + + + + + + + + import { Service } from 'typedi'; import { Octokit } from '@octokit/rest'; + import { GitHubAuthService, + GitHubAppService, GitHubApiService, GitLabAuthService, GitLabApiService, @@ -9,12 +744,20 @@ import { 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, @@ -22,17 +765,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: [], @@ -41,133 +783,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, - getEnvVar('REPOSITORY_OWNER'), - getEnvVar('REPOSITORY_NAME'), - 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, @@ -177,21 +940,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 { @@ -207,33 +968,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, @@ -243,21 +1009,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 { @@ -272,4 +1036,4 @@ export class AuthService { errors: [], }; } -} +} \ No newline at end of file From 0236888de6b9cda76fc58e16c45e2d69986e3b4a Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 22 May 2026 17:19:10 +0100 Subject: [PATCH 34/45] feat: implement global error handler and request logger with enhanced logging capabilities Signed-off-by: AdeyinkaOresanya --- src/middleware/globalErrorHandler.ts | 48 ++++++++++++++++++++++++++++ src/middleware/requestLogger.ts | 20 ++++++++++++ src/shared/errors/appError.ts | 22 +++++++++++++ src/shared/errors/index.ts | 1 + src/shared/logger/logger.ts | 32 ++++++++++++++----- 5 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 src/middleware/globalErrorHandler.ts create mode 100644 src/middleware/requestLogger.ts create mode 100644 src/shared/errors/appError.ts create mode 100644 src/shared/errors/index.ts 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/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/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/logger/logger.ts b/src/shared/logger/logger.ts index 345f2f8..9e45c50 100644 --- a/src/shared/logger/logger.ts +++ b/src/shared/logger/logger.ts @@ -1,16 +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.colorize(), - format.printf(({ timestamp, level, message, ...meta }) => { - const metaString = Object.keys(meta).length ? JSON.stringify(meta) : ''; - return `[${timestamp}] ${level}: ${message} ${metaString}`; - }) + 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()], }); - -//export default logger; From 5612cba4c7e9b6528a827235f90403bfdd89d1a4 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 22 May 2026 17:20:58 +0100 Subject: [PATCH 35/45] feat: add HTML renderer for developer repository selection form Signed-off-by: AdeyinkaOresanya --- src/dev/render-dev-form.ts | 99 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src/dev/render-dev-form.ts 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 From 65235af4a0e511a94fb262ced7b4a3eb8724424e Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 22 May 2026 17:24:44 +0100 Subject: [PATCH 36/45] feat: enhance event handling with improved error logging and webhook processing Signed-off-by: AdeyinkaOresanya --- .../controllers/event-badging.controller.ts | 54 +++-- .../services/checklist.service.ts | 117 ++++------ .../services/event-badging.service.ts | 218 +++++++----------- src/event-badging/services/scoring.service.ts | 54 +++-- src/middleware/index.ts | 2 + 5 files changed, 196 insertions(+), 249 deletions(-) create mode 100644 src/middleware/index.ts 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 19d7cc9..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,95 +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) { -// // slice after startMarker, up to endIdx -// eventUrl = payload.issue.body.slice( -// startIdx + startMarker.length, -// endIdx -// ).trim(); -// } - -// } 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) { -// // Slice after startMarker up to endIdx -// eventUrl = payload.issue.body.slice( -// startIdx + startMarker.length, -// endIdx -// ).trim(); -// } -// } - -// 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, ''); -// } -// } - - - - -let eventUrl = ''; - -const body = payload.issue.body || ''; -const match = body.match( - /Link to the Event Website:\s*(.+)$/mi - ); - console.info(`Regex match result: ${match}`); - - if (match) { - eventUrl = match[1]; - } + let eventUrl = ''; + + 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, @@ -304,16 +234,16 @@ const match = body.match( application, }); - console.info('Event saved to database'); - console.info(`Event URL: ${eventUrl}`); + 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, @@ -337,21 +267,23 @@ const match = body.match( `\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, @@ -374,44 +306,50 @@ const match = body.match( `\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 795a135..1c75cc1 100644 --- a/src/event-badging/services/scoring.service.ts +++ b/src/event-badging/services/scoring.service.ts @@ -3,6 +3,8 @@ 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 { @@ -19,60 +21,76 @@ export class ScoringService { issueUrl: string, repoName: string ): Promise { - // Determine initial check count based on repository - const targetRepoName = isDevelopment() ? 'sandbox-event-dei' : getEnvVar('REPOSITORY_NAME'); + const targetRepoName = isDevelopment() + ? 'sandbox-event-dei' + : getEnvVar('REPOSITORY_NAME'); + let initialCheckCount = 6; 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, + 'GITHUB_COMMENTS_FETCH_FAILED' + ); } - // 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); } @@ -84,7 +102,6 @@ export class ScoringService { reviewerCount: number, issueUrl: string ): IBadgeCalculationResult { - // Determine badge level let badgeLevel: BadgeLevel; let badgeCode: string; @@ -105,7 +122,6 @@ export class ScoringService { badgeCode = 'D%26I-Pending-red'; } - // Build badge URL with CHAOSS logo const logoBase64 = 'PHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDI1MCAyNTAiPgo8cGF0aCBmaWxsPSIjMUM5QkQ2IiBkPSJNOTcuMSw0OS4zYzE4LTYuNywzNy44LTYuOCw1NS45LTAuMmwxNy41LTMwLjJjLTI5LTEyLjMtNjEuOC0xMi4yLTkwLjgsMC4zTDk3LjEsNDkuM3oiLz4KPHBhdGggZmlsbD0iIzZBQzdCOSIgZD0iTTE5NC42LDMyLjhMMTc3LjIsNjNjMTQuOCwxMi4zLDI0LjcsMjkuNSwyNy45LDQ4LjVoMzQuOUMyMzYuMiw4MC4yLDIxOS45LDUxLjcsMTk0LjYsMzIuOHoiLz4KPHBhdGggZmlsbD0iI0JGOUNDOSIgZD0iTTIwNC45LDEzOS40Yy03LjksNDMuOS00OS45LDczLTkzLjgsNjUuMWMtMTMuOC0yLjUtMjYuOC04LjYtMzcuNS0xNy42bC0yNi44LDIyLjQKCWM0Ni42LDQzLjQsMTE5LjUsNDAuOSwxNjIuOS01LjdjMTYuNS0xNy43LDI3LTQwLjIsMzAuMS02NC4ySDIwNC45eiIvPgo8cGF0aCBmaWxsPSIjRDYxRDVGIiBkPSJNNTUuNiwxNjUuNkMzNS45LDEzMS44LDQzLjMsODguOCw3My4xLDYzLjVMNTUuNywzMy4yQzcuNSw2OS44LTQuMiwxMzcuNCwyOC44LDE4OEw1NS42LDE2NS42eiIvPgo8L3N2Zz4K'; @@ -125,4 +141,4 @@ export class ScoringService { badge_URL: badgeUrl, }; } -} +} \ 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 From bd8a031a1691f92a7a0ad112bcca34f8dd4c3f81 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 22 May 2026 17:25:37 +0100 Subject: [PATCH 37/45] feat: enhance project badging process with improved error handling and logging Signed-off-by: AdeyinkaOresanya --- .../controllers/project-badging.controller.ts | 129 ++++++---- .../services/badge-award.service.ts | 143 ++++++++--- .../services/dei-scanner.service.ts | 3 +- .../services/project-badging.service.ts | 240 +++++++++--------- 4 files changed, 305 insertions(+), 210 deletions(-) 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 From 10c54993ade1f996fec391c2960e6862d1b8acdb Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 22 May 2026 17:27:06 +0100 Subject: [PATCH 38/45] feat: integrate logger for improved error handling and logging across services and configuration Signed-off-by: AdeyinkaOresanya --- src/auth/services/auth.service.ts | 733 ------------------ src/scripts/configure.ts | 9 +- src/shared/config/environment.ts | 2 + src/shared/data-access/database.bootstrap.ts | 8 + src/shared/data-access/database.ts | 16 +- src/shared/data-access/index.ts | 1 + .../repositories/event.repository.ts | 5 +- .../repositories/repo.repository.ts | 5 +- .../repositories/user.repository.ts | 11 +- src/shared/index.ts | 1 + tsconfig.json | 2 +- 11 files changed, 39 insertions(+), 754 deletions(-) diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 805fb53..90e785f 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -1,736 +1,3 @@ -// 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'; - -// @Service() -// export class AuthService { -// constructor( -// private githubAuthService: GitHubAuthService, -// private githubAppService: GitHubAppService, -// private githubApiService: GitHubApiService, -// private gitlabAuthService: GitLabAuthService, -// private gitlabApiService: GitLabApiService, -// private userRepository: UserRepository, -// private cryptoService: CryptoService -// ) {} - -// /** -// * Get login redirect URL for a provider -// */ -// getLoginUrl(provider: Provider): IOperationResult { -// if (provider === 'github') { -// if (!this.githubAuthService.isConfigured()) { -// return { -// data: null, -// errors: ['GitHub provider is not configured'], -// }; -// } -// return { -// data: this.githubAuthService.getProjectBadgingAuthUrl(), -// errors: [], -// }; -// } - -// if (provider === 'gitlab') { -// if (!this.gitlabAuthService.isConfigured()) { -// return { -// data: null, -// errors: ['GitLab provider is not configured'], -// }; -// } -// return { -// data: this.gitlabAuthService.getAuthUrl(), -// errors: [], -// }; -// } - -// return { -// data: null, -// errors: [`Unknown provider: ${provider as string}`], -// }; -// } - -// /** -// * Get event badging authorization URL with encrypted form data -// */ -// getEventBadgingAuthUrl(title: string, body: string): IOperationResult { -// if (!this.githubAuthService.isEventBadgingConfigured()) { -// return { -// data: null, -// errors: ['GitHub event badging provider is not configured'], -// }; -// } - -// const formData = JSON.stringify({ title, body, type: 'event-badging' }); -// const encryptedState = this.cryptoService.encrypt(formData); -// const url = this.githubAuthService.getEventBadgingAuthUrl(encryptedState); - -// return { -// data: url, -// errors: [], -// }; -// } - -// /** -// * Handle GitHub OAuth 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); - -// if (tokenErrors.length > 0 || !accessToken) { -// return { -// data: null, -// errors: tokenErrors.length > 0 ? tokenErrors : ['Failed to get access token'], -// }; -// } - -// 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) { -// const appOctokit = await this.githubAppService.getInstallationOctokit(Number(getEnvVar('GITHUB_APP_INSTALLATION_ID'))); -// //return this.handleEventBadgingCallback(octokit, state); -// return this.handleEventBadgingCallback(appOctokit, state, user); -// } - -// // Handle project badging flow - return user data and repos -// //const octokit = this.githubAuthService.createOctokit(accessToken); -// return this.handleProjectBadgingCallback(userOctokit); -// } - -// /** -// * Handle event badging OAuth callback - creates issue -// */ -// private async handleEventBadgingCallback( -// 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 baseMarkdown = this.cryptoService.convertToMarkdown(parsedFormData.body); - -// // const markdown = `### 👤 Submitted by -// // - GitHub: @${user.login} -// // - Profile: ${user.html_url} -// // --- -// // ${baseMarkdown}`.trim(); - -// 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'), -// parsedFormData.title, -// markdown -// ); - -// if (result.errors.length > 0 || !result.data) { -// return { -// data: null, -// errors: result.errors, -// }; -// } - -// return { -// data: { issueUrl: result.data.url }, -// errors: [], -// }; -// } catch (error) { -// const errorMessage = error instanceof Error ? error.message : 'Unknown error'; -// return { -// data: null, -// errors: [errorMessage], -// }; -// } -// } - -// /** -// * Handle project badging OAuth callback - returns user and repos -// */ -// 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, -// }; -// } - -// // Save user to database -// const savedUser = await this.userRepository.saveUser( -// userInfo.login, -// userInfo.name, -// userInfo.email, -// userInfo.id, -// null -// ); - -// if (!savedUser) { -// return { -// data: null, -// errors: ['Error saving user info'], -// }; -// } - -// // Get user repositories -// const { data: repositories, errors: repoErrors } = -// await this.githubApiService.getUserRepositories(octokit); - -// if (repoErrors.length > 0 || !repositories) { -// return { -// data: null, -// errors: repoErrors, -// }; -// } - -// return { -// data: { -// userId: savedUser.id, -// name: savedUser.name, -// username: savedUser.login, -// email: savedUser.email, -// repos: repositories, -// provider: 'github', -// }, -// errors: [], -// }; -// } - -// /** -// * 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); - -// if (tokenErrors.length > 0 || !accessToken) { -// return { -// data: null, -// errors: tokenErrors.length > 0 ? tokenErrors : ['Failed to get access token'], -// }; -// } - -// // Get user info -// const { data: userInfo, errors: userInfoErrors } = -// await this.gitlabApiService.getUserInfo(accessToken); - -// if (userInfoErrors.length > 0 || !userInfo) { -// return { -// data: null, -// errors: userInfoErrors, -// }; -// } - -// // Save user to database -// const savedUser = await this.userRepository.saveUser( -// userInfo.login, -// userInfo.name, -// userInfo.email, -// null, -// userInfo.id -// ); - -// if (!savedUser) { -// return { -// data: null, -// errors: ['Error saving user info'], -// }; -// } - -// // Get user repositories -// const { data: repositories, errors: repoErrors } = -// await this.gitlabApiService.getUserRepositories(accessToken); - -// if (repoErrors.length > 0 || !repositories) { -// return { -// data: null, -// errors: repoErrors, -// }; -// } - -// return { -// data: { -// userId: savedUser.id, -// name: savedUser.name, -// username: savedUser.login, -// email: savedUser.email, -// repos: repositories, -// provider: 'gitlab', -// }, -// errors: [], -// }; -// } -// } - - - - - - - - -// 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 { AppError } from '../../shared/errors'; - -// @Service() -// export class AuthService { -// constructor( -// private githubAuthService: GitHubAuthService, -// private githubAppService: GitHubAppService, -// private githubApiService: GitHubApiService, -// private gitlabAuthService: GitLabAuthService, -// private gitlabApiService: GitLabApiService, -// private userRepository: UserRepository, -// private cryptoService: CryptoService -// ) {} - -// /** -// * Get login redirect URL -// */ -// getLoginUrl(provider: Provider): IOperationResult { - -// if (provider === 'github') { - -// if (!this.githubAuthService.isConfigured()) { -// throw new AppError( -// 'GitHub provider is not configured', -// 500 -// ); -// } - -// return { -// data: this.githubAuthService.getProjectBadgingAuthUrl(), -// errors: [], -// }; -// } - -// if (provider === 'gitlab') { - -// if (!this.gitlabAuthService.isConfigured()) { -// throw new AppError( -// 'GitLab provider is not configured', -// 500 -// ); -// } - -// return { -// data: this.gitlabAuthService.getAuthUrl(), -// errors: [], -// }; -// } - -// throw new AppError( -// `Unknown provider: ${provider as string}`, -// 400 -// ); -// } - -// /** -// * Event badging OAuth URL -// */ -// getEventBadgingAuthUrl( -// title: string, -// body: string -// ): IOperationResult { - -// if (!this.githubAuthService.isEventBadgingConfigured()) { -// throw new AppError( -// 'GitHub event badging provider is not configured', -// 500 -// ); -// } - -// try { - -// const formData = JSON.stringify({ -// title, -// body, -// type: 'event-badging' -// }); - -// const encryptedState = -// this.cryptoService.encrypt(formData); - -// const url = -// this.githubAuthService.getEventBadgingAuthUrl( -// encryptedState -// ); - -// return { -// data: url, -// errors: [], -// }; - -// } catch (error: unknown) { - -// throw new AppError( -// 'Failed to generate event badging authorization URL', -// 500, -// true, -// error -// ); -// } -// } - -// /** -// * GitHub OAuth callback -// */ -// async handleGitHubCallback( -// code: string, -// state?: string -// ): Promise< -// IOperationResult< -// IAuthCallbackResult | { issueUrl: string } -// > -// > { - -// const isEventBadging = !!state; - -// const { -// access_token: accessToken, -// errors: tokenErrors -// } = await this.githubAuthService.requestAccessToken( -// code, -// isEventBadging -// ); - -// if (tokenErrors.length > 0 || !accessToken) { - -// throw new AppError( -// tokenErrors.join(', ') || 'Failed to get access token', -// 401 -// ); -// } - -// try { - -// const userOctokit = -// this.githubAuthService.createUserOctokit(accessToken); - -// const { data: user } = -// await userOctokit.request('GET /user'); - -// /** -// * EVENT BADGING FLOW -// */ -// if (state) { - -// const appOctokit = -// await this.githubAppService.getInstallationOctokit( -// Number(getEnvVar('GITHUB_APP_INSTALLATION_ID')) -// ); - -// return this.handleEventBadgingCallback( -// appOctokit, -// state, -// user -// ); -// } - -// /** -// * PROJECT BADGING FLOW -// */ -// return this.handleProjectBadgingCallback(userOctokit); - -// } catch (error: unknown) { - -// throw new AppError( -// 'GitHub authentication callback failed', -// 500, -// true, -// error -// ); -// } -// } - -// /** -// * Event badging callback -// */ -// private async handleEventBadgingCallback( -// 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 baseMarkdown = -// this.cryptoService.convertToMarkdown( -// parsedFormData.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'), -// parsedFormData.title, -// markdown -// ); - -// if (result.errors.length > 0 || !result.data) { - -// throw new AppError( -// result.errors.join(', ') || -// 'Failed to create GitHub issue', -// 500 -// ); -// } - -// return { -// data: { -// issueUrl: result.data.url -// }, -// errors: [], -// }; - -// } catch (error: unknown) { -// throw new AppError( -// 'Failed to process event badging callback', -// 500, -// true, -// error -// ); -// } -// } - -// /** -// * Project badging callback -// */ -// private async handleProjectBadgingCallback( -// octokit: Octokit -// ): Promise> { - -// const { -// data: userInfo, -// errors: userInfoErrors -// } = await this.githubApiService.getUserInfo(octokit); - -// if (userInfoErrors.length > 0 || !userInfo) { - -// throw new AppError( -// userInfoErrors.join(', ') || -// 'Failed to retrieve GitHub user info', -// 500 -// ); -// } - -// const savedUser = -// await this.userRepository.saveUser( -// userInfo.login, -// userInfo.name, -// userInfo.email, -// userInfo.id, -// null -// ); - -// if (!savedUser) { - -// throw new AppError( -// 'Failed to save GitHub user', -// 500 -// ); -// } - -// const { -// data: repositories, -// errors: repoErrors -// } = await this.githubApiService.getUserRepositories( -// octokit -// ); - -// if (repoErrors.length > 0 || !repositories) { - -// throw new AppError( -// repoErrors.join(', ') || -// 'Failed to retrieve repositories', -// 500 -// ); -// } - -// return { -// data: { -// userId: savedUser.id, -// name: savedUser.name, -// username: savedUser.login, -// email: savedUser.email, -// repos: repositories, -// provider: 'github', -// }, -// errors: [], -// }; -// } - -// /** -// * GitLab OAuth callback -// */ -// async handleGitLabCallback( -// code: string -// ): Promise> { - -// const { -// access_token: accessToken, -// errors: tokenErrors -// } = await this.gitlabAuthService.requestAccessToken(code); - -// if (tokenErrors.length > 0 || !accessToken) { - -// throw new AppError( -// tokenErrors.join(', ') || -// 'Failed to get GitLab access token', -// 401 -// ); -// } - -// const { -// data: userInfo, -// errors: userInfoErrors -// } = await this.gitlabApiService.getUserInfo(accessToken); - -// if (userInfoErrors.length > 0 || !userInfo) { - -// throw new AppError( -// userInfoErrors.join(', ') || -// 'Failed to retrieve GitLab user info', -// 500 -// ); -// } - -// const savedUser = -// await this.userRepository.saveUser( -// userInfo.login, -// userInfo.name, -// userInfo.email, -// null, -// userInfo.id -// ); - -// if (!savedUser) { - -// throw new AppError( -// 'Failed to save GitLab user', -// 500 -// ); -// } - -// const { -// data: repositories, -// errors: repoErrors -// } = await this.gitlabApiService.getUserRepositories( -// accessToken -// ); - -// if (repoErrors.length > 0 || !repositories) { - -// throw new AppError( -// repoErrors.join(', ') || -// 'Failed to retrieve GitLab repositories', -// 500 -// ); -// } - -// return { -// data: { -// userId: savedUser.id, -// name: savedUser.name, -// username: savedUser.login, -// email: savedUser.email, -// repos: repositories, -// provider: 'gitlab', -// }, -// errors: [], -// }; -// } -// } - - - - - - - - - - - - import { Service } from 'typedi'; import { Octokit } from '@octokit/rest'; diff --git a/src/scripts/configure.ts b/src/scripts/configure.ts index 2f4641e..715f05b 100644 --- a/src/scripts/configure.ts +++ b/src/scripts/configure.ts @@ -1,6 +1,7 @@ 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 @@ -58,11 +59,11 @@ 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 @@ -179,10 +180,10 @@ 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 f7c7af2..95b2dd5 100644 --- a/src/shared/config/environment.ts +++ b/src/shared/config/environment.ts @@ -27,6 +27,8 @@ 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; diff --git a/src/shared/data-access/database.bootstrap.ts b/src/shared/data-access/database.bootstrap.ts index 82445b7..7520558 100644 --- a/src/shared/data-access/database.bootstrap.ts +++ b/src/shared/data-access/database.bootstrap.ts @@ -8,3 +8,11 @@ export async function initializeDatabase(): Promise { 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 8b20460..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 { @@ -18,27 +19,28 @@ export class Database { 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/index.ts b/src/shared/index.ts index 259a6cc..94d3012 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -5,3 +5,4 @@ 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/tsconfig.json b/tsconfig.json index 152e95f..6981ad9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "lib": ["ES2022"], "types": ["node", "jest"], "outDir": "./dist", - //"rootDir": "./src", + "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, From 53fd78d4220bc2e53fa79cf878232852b1840ae0 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 22 May 2026 17:28:36 +0100 Subject: [PATCH 39/45] feat: enhance logging across GitHub services and mailer service for better error tracking Signed-off-by: AdeyinkaOresanya --- .gitignore | 4 +- .../providers/github/github-api.service.ts | 120 ++++++++++++------ .../providers/github/github-app.service.ts | 5 +- .../providers/github/github-auth.service.ts | 13 +- src/shared/services/augur-api.service.ts | 7 +- src/shared/services/crypto.service.ts | 1 + src/shared/services/mailer.service.ts | 15 ++- src/types/smee-client.d.ts | 25 ---- 8 files changed, 110 insertions(+), 80 deletions(-) delete mode 100644 src/types/smee-client.d.ts diff --git a/.gitignore b/.gitignore index 3dfac4a..36a075c 100644 --- a/.gitignore +++ b/.gitignore @@ -133,8 +133,10 @@ deploy.tests.yml /tmp/mysql/* -#ignore .vscode +# ignore .vscode .vscode/ +# ignore all.pem files +*.pem 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/types/smee-client.d.ts b/src/types/smee-client.d.ts deleted file mode 100644 index 4abc2d3..0000000 --- a/src/types/smee-client.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -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_WEBHOOK_URL = getEnvVar('SMEE_WEBHOOK_URL', ''); - if (!SMEE_WEBHOOK_URL) return () => {}; - - const smee = new SmeeClient({ - source: SMEE_WEBHOOK_URL, - target: `${appUrl}${webhookPath}`, - logger, - }); - - const events = smee.start(); - logger.info('Smee forwarding enabled', { - source: SMEE_WEBHOOK_URL, - target: webhookPath, - }); - - return () => { - events.close(); - logger.info('Smee forwarding stopped'); - }; -} From 5befba69c7981576f512353ea646278eca2e6f9e Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Fri, 22 May 2026 17:37:48 +0100 Subject: [PATCH 40/45] feat: remove license field from jsonwebtoken dependency in package-lock.json Signed-off-by: AdeyinkaOresanya --- package-lock.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 5ace257..4fb5452 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6635,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", From afe99f9d206b95a7a12204c7907cb9d0cbbf203d Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Tue, 26 May 2026 10:11:30 +0100 Subject: [PATCH 41/45] feat: add end-to-end tests for event and project badging processes Signed-off-by: AdeyinkaOresanya --- tests/e2e/event-badging.test.ts | 329 ++++++++++++++++++++++++++ tests/e2e/project-badging.test.ts | 378 ++++++++++++++++++++++++++++++ 2 files changed, 707 insertions(+) create mode 100644 tests/e2e/event-badging.test.ts create mode 100644 tests/e2e/project-badging.test.ts diff --git a/tests/e2e/event-badging.test.ts b/tests/e2e/event-badging.test.ts new file mode 100644 index 0000000..204c925 --- /dev/null +++ b/tests/e2e/event-badging.test.ts @@ -0,0 +1,329 @@ +import 'reflect-metadata'; +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..54aa7b5 --- /dev/null +++ b/tests/e2e/project-badging.test.ts @@ -0,0 +1,378 @@ +import 'reflect-metadata'; +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'); + }); + }); +}); From 40460436d085e46bccf1a91e7048f89144e669ed Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Tue, 26 May 2026 10:13:32 +0100 Subject: [PATCH 42/45] feat: implement global error handler in event and project badging integration tests Signed-off-by: AdeyinkaOresanya --- tests/integration/event-badging.test.ts | 6 ++++-- tests/integration/project-badging.test.ts | 11 ++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/integration/event-badging.test.ts b/tests/integration/event-badging.test.ts index bee6323..47b8638 100644 --- a/tests/integration/event-badging.test.ts +++ b/tests/integration/event-badging.test.ts @@ -10,6 +10,7 @@ import { ChecklistService, ScoringService } from '../../src/event-badging/servic 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(), @@ -51,7 +52,8 @@ describe('EventBadgingController Integration', () => { app = express(); app.use(express.json()); - useExpressServer(app, { controllers: [EventBadgingController] }); + useExpressServer(app, { controllers: [EventBadgingController], defaultErrorHandler: false }); + app.use(globalErrorHandler); jest.spyOn(console, 'log').mockImplementation(() => {}); jest.spyOn(console, 'info').mockImplementation(() => {}); @@ -154,7 +156,7 @@ describe('EventBadgingController Integration', () => { .send({ installation: { id: 1 }, issue: { title: 'event' } }); expect(res.status).toBe(500); - expect(res.text).toBe('Error processing webhook'); + expect(res.body.message).toBe('Internal server error'); }); it('should ignore webhooks that are not event-related', async () => { diff --git a/tests/integration/project-badging.test.ts b/tests/integration/project-badging.test.ts index 8700e88..f0c9111 100644 --- a/tests/integration/project-badging.test.ts +++ b/tests/integration/project-badging.test.ts @@ -13,6 +13,7 @@ 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; @@ -70,8 +71,9 @@ describe('ProjectBadgingController Integration', () => { useExpressServer(app, { controllers: [ProjectBadgingController], - defaultErrorHandler: true, + defaultErrorHandler: false, }); + app.use(globalErrorHandler); }); afterAll(async () => { @@ -111,7 +113,6 @@ describe('ProjectBadgingController Integration', () => { repos: [{ id: 1 }], }); - console.log('Response:', res.body); expect(res.status).toBe(200); expect(res.body.results[0].success).toBe(true); expect(res.body.results[0].message).toBe('Badge awarded successfully'); @@ -121,7 +122,7 @@ describe('ProjectBadgingController Integration', () => { user.name, 'Bronze', expect.stringContaining('![Bronze Badge]'), - expect.stringContaining('<img') + expect.stringContaining(' { }); expect(res.status).toBe(400); - expect(res.body.error).toContain('Unknown provider'); + expect(res.body.message).toContain('Invalid provider'); }); it('should return 404 for non-existent user', async () => { @@ -185,7 +186,7 @@ describe('ProjectBadgingController Integration', () => { }); expect(res.status).toBe(404); - expect(res.body.error).toContain('User not found'); + expect(res.body.message).toContain('User not found'); }); it('should handle partial success/failure', async () => { From a049bd292ea91910ab889172d8e921d539f4ffd4 Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Tue, 26 May 2026 10:14:45 +0100 Subject: [PATCH 43/45] refactor: update mock services to use factory functions for better test isolation Signed-off-by: AdeyinkaOresanya --- tests/mocks/crypto.mock.ts | 10 +++++----- tests/mocks/github.mock.ts | 21 ++++++++++++++------- tests/mocks/gitlab.mock.ts | 9 ++++----- tests/mocks/repository.mock.ts | 6 +++--- 4 files changed, 26 insertions(+), 20 deletions(-) diff --git a/tests/mocks/crypto.mock.ts b/tests/mocks/crypto.mock.ts index b0ef847..16cf37c 100644 --- a/tests/mocks/crypto.mock.ts +++ b/tests/mocks/crypto.mock.ts @@ -1,5 +1,5 @@ -export const mockCryptoService = { - encrypt: jest.fn(), - decrypt: jest.fn(), - convertToMarkdown: jest.fn(), - }; \ No newline at end of file +export const createMockCryptoService = () => ({ + encrypt: jest.fn(), + decrypt: jest.fn(), + convertToMarkdown: jest.fn(), +}); \ No newline at end of file diff --git a/tests/mocks/github.mock.ts b/tests/mocks/github.mock.ts index c590534..3c540ad 100644 --- a/tests/mocks/github.mock.ts +++ b/tests/mocks/github.mock.ts @@ -1,21 +1,28 @@ -export const mockGitHubAuthService = { +export const createMockGitHubAuthService = () => ({ isConfigured: jest.fn(), getProjectBadgingAuthUrl: jest.fn(), requestAccessToken: jest.fn(), - createOctokit: jest.fn(), + createUserOctokit: jest.fn(), + createPublicOctokit: jest.fn(), getEventBadgingAuthUrl: jest.fn(), isEventBadgingConfigured: jest.fn(), -}; +}); -export const mockGitHubApiService = { +export const createMockGitHubApiService = () => ({ createIssue: jest.fn(), listIssueComments: jest.fn(), getUserInfo: jest.fn(), getUserRepositories: jest.fn(), getRepoContent: jest.fn(), -}; +}); -export const mockGitHubAppInstance = { +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 index 5cb4521..c130455 100644 --- a/tests/mocks/gitlab.mock.ts +++ b/tests/mocks/gitlab.mock.ts @@ -1,11 +1,10 @@ - -export const mockGitLabAuthService = { +export const createMockGitLabAuthService = () => ({ isConfigured: jest.fn(), getAuthUrl: jest.fn(), requestAccessToken: jest.fn(), -}; +}); -export const mockGitLabApiService = { +export const createMockGitLabApiService = () => ({ getUserInfo: jest.fn(), getUserRepositories: jest.fn(), -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/tests/mocks/repository.mock.ts b/tests/mocks/repository.mock.ts index 725989e..1b43b1b 100644 --- a/tests/mocks/repository.mock.ts +++ b/tests/mocks/repository.mock.ts @@ -1,3 +1,3 @@ - export const mockUserRepository = { - saveUser: jest.fn(), - }; \ No newline at end of file +export const createMockUserRepository = () => ({ + saveUser: jest.fn(), +}); \ No newline at end of file From 7c6eb5a5c310c08efd8e58a4203606a1a7ffd58e Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Tue, 26 May 2026 10:17:13 +0100 Subject: [PATCH 44/45] refactor: Refactor tests to improve error handling and logging Signed-off-by: AdeyinkaOresanya --- src/event-badging/services/scoring.service.ts | 3 +- tests/unit/auth/auth.service.test.ts | 382 +++++++++--------- tests/unit/database/database-new.test.ts | 189 +++++++++ tests/unit/database/database.test.ts | 206 ++++++++-- .../event-badging/checklist.service.test.ts | 15 +- .../event-badging.service.test.ts | 11 +- .../event-badging/scoring.service.test.ts | 13 +- .../badge-award.service.test.ts | 15 +- .../dei-scanner.service.test.ts | 3 +- .../github/github-app.service.test.ts | 24 +- .../github/github-auth.service.test.ts | 8 +- .../shared/services/mailer.service.test.ts | 5 +- 12 files changed, 584 insertions(+), 290 deletions(-) create mode 100644 tests/unit/database/database-new.test.ts diff --git a/src/event-badging/services/scoring.service.ts b/src/event-badging/services/scoring.service.ts index 1c75cc1..82fe19f 100644 --- a/src/event-badging/services/scoring.service.ts +++ b/src/event-badging/services/scoring.service.ts @@ -49,8 +49,7 @@ export class ScoringService { throw new AppError( `Failed to get comments: ${errors.join(', ')}`, 502, - true, - 'GITHUB_COMMENTS_FETCH_FAILED' + true ); } diff --git a/tests/unit/auth/auth.service.test.ts b/tests/unit/auth/auth.service.test.ts index e549e35..8e613c9 100644 --- a/tests/unit/auth/auth.service.test.ts +++ b/tests/unit/auth/auth.service.test.ts @@ -1,25 +1,43 @@ import { AuthService } from '../../../src/auth/services/auth.service'; +import * as envConfig from '../../../src/shared/config/environment'; import { - mockGitHubAuthService, - mockGitHubApiService, + createMockGitHubAuthService, + createMockGitHubApiService, + createMockGitHubAppService, } from '../../mocks/github.mock'; import { - mockGitLabAuthService, - mockGitLabApiService, + createMockGitLabAuthService, + createMockGitLabApiService, } from '../../mocks/gitlab.mock'; -import { mockUserRepository } from '../../mocks/repository.mock'; -import { mockCryptoService } from '../../mocks/crypto.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(); + 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, @@ -41,9 +59,9 @@ describe('AuthService', () => { it('should fail if GitHub is not configured', () => { mockGitHubAuthService.isConfigured.mockReturnValue(false); - const result = authService.getLoginUrl('github'); - expect(result.data).toBeNull(); - expect(result.errors).toContain('GitHub provider is not configured'); + expect(() => authService.getLoginUrl('github')).toThrow( + 'GitHub provider is not configured' + ); }); it('should return GitLab login URL if configured', () => { @@ -58,232 +76,200 @@ describe('AuthService', () => { it('should fail if GitLab is not configured', () => { mockGitLabAuthService.isConfigured.mockReturnValue(false); - const result = authService.getLoginUrl('gitlab'); - expect(result.data).toBeNull(); - expect(result.errors).toContain('GitLab provider is not configured'); + expect(() => authService.getLoginUrl('gitlab')).toThrow( + 'GitLab provider is not configured' + ); }); it('should return error for unknown provider', () => { - const result = authService.getLoginUrl('bitbucket' as any); - expect(result.data).toBeNull(); - expect(result.errors).toContain('Unknown provider: bitbucket'); + 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); - - const result = authService.getEventBadgingAuthUrl('Test Event', 'Test body'); - - expect(result.data).toBeNull(); - expect(result.errors).toContain('GitHub event badging provider is not configured'); - }); - - it('should return event badging auth URL when configured', () => { - mockGitHubAuthService.isEventBadgingConfigured.mockReturnValue(true); + it('should return error if event badging is not configured', () => { + mockGitHubAuthService.isEventBadgingConfigured.mockReturnValue(false); - 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'], + expect(() => authService.getEventBadgingAuthUrl('Test Event', 'Test body')).toThrow( + 'GitHub event badging provider is not configured' + ); }); - const result = await authService.handleGitHubCallback('code123'); - - expect(result.data).toBeNull(); - expect(result.errors).toContain('token failed'); - }); -}); - -it('should return user and repositories for project badging flow', async () => { - mockGitHubAuthService.requestAccessToken.mockResolvedValue({ - access_token: 'mock-token', - errors: [], - }); - - mockGitHubAuthService.createOctokit.mockReturnValue({} 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); + it('should return event badging auth URL when configured', () => { + mockGitHubAuthService.isEventBadgingConfigured.mockReturnValue(true); - 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'); -} -}); + mockCryptoService.encrypt.mockReturnValue('encrypted-state'); -it('should return user and repositories for project badging flow', async () => { - mockGitHubAuthService.requestAccessToken.mockResolvedValue({ - access_token: 'mock-token', - errors: [], - }); + mockGitHubAuthService.getEventBadgingAuthUrl.mockReturnValue( + 'https://github.com/oauth/event' + ); - mockGitHubAuthService.createOctokit.mockReturnValue({} as any); + const result = authService.getEventBadgingAuthUrl('Test Event', 'Test body'); - mockGitHubApiService.getUserInfo.mockResolvedValue({ - data: { - login: 'aj', - name: 'AJ', - email: 'aj@test.com', - id: 123, - }, - errors: [], - }); + expect(mockCryptoService.encrypt).toHaveBeenCalled(); + expect(mockGitHubAuthService.getEventBadgingAuthUrl).toHaveBeenCalledWith( + 'encrypted-state' + ); - mockUserRepository.saveUser.mockResolvedValue({ - id: 1, - login: 'aj', - name: 'AJ', - email: 'aj@test.com', - }); - - mockGitHubApiService.getUserRepositories.mockResolvedValue({ - data: [{ name: 'repo1' }, { name: 'repo2' }], - errors: [], + expect(result.data).toBe('https://github.com/oauth/event'); + expect(result.errors).toHaveLength(0); + }); }); - 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: [], - }); + describe('handleGitHubCallback', () => { + it('should return error if token exchange fails', async () => { + mockGitHubAuthService.requestAccessToken.mockResolvedValue({ + access_token: null, + errors: ['token failed'], + }); - mockGitHubAuthService.createOctokit.mockReturnValue({} as any); - - mockCryptoService.decrypt.mockReturnValue( - JSON.stringify({ - title: 'Event Title', - body: 'Event Body', - }) - ); + await expect(authService.handleGitHubCallback('code123')).rejects.toThrow('token failed'); + }); - mockCryptoService.convertToMarkdown.mockReturnValue('markdown body'); + 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'); - mockGitHubApiService.createIssue.mockResolvedValue({ - data: { url: 'https://github.com/issue/1' }, - errors: [], - }); + expect(result.errors).toHaveLength(0); - const result = await authService.handleGitHubCallback( - 'code123', - 'encrypted-state' - ); + 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'); + } + }); - expect(result.errors).toHaveLength(0); + 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.data).toEqual({ - issueUrl: 'https://github.com/issue/1', - }); -}); + expect(result.errors).toHaveLength(0); -describe('handleGitLabCallback', () => { - it('should return error if token exchange fails', async () => { - mockGitLabAuthService.requestAccessToken.mockResolvedValue({ - access_token: null, - errors: ['token failed'], + expect(result.data).toEqual({ + issueUrl: 'https://github.com/issue/1', + }); }); - - const result = await authService.handleGitLabCallback('code'); - - expect(result.data).toBeNull(); - expect(result.errors).toContain('token failed'); }); - it('should return user and repositories for GitLab login', async () => { - mockGitLabAuthService.requestAccessToken.mockResolvedValue({ - access_token: 'token', - errors: [], + 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'); }); - mockGitLabApiService.getUserInfo.mockResolvedValue({ - data: { + 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', - id: 999, - }, - errors: [], - }); - - mockUserRepository.saveUser.mockResolvedValue({ - id: 2, - login: 'gitlabuser', - name: 'GitLab User', - email: 'user@gitlab.com', - }); + }); - mockGitLabApiService.getUserRepositories.mockResolvedValue({ - data: [{ name: 'repoA' }], - errors: [], - }); + mockGitLabApiService.getUserRepositories.mockResolvedValue({ + data: [{ name: 'repoA' }], + errors: [], + }); - const result = await authService.handleGitLabCallback('code'); + const result = await authService.handleGitLabCallback('code'); - expect(result.errors).toHaveLength(0); + expect(result.errors).toHaveLength(0); - expect(result.data?.provider).toBe('gitlab'); + expect(result.data?.provider).toBe('gitlab'); - expect(result.data?.repos.length).toBe(1); + expect(result.data?.repos.length).toBe(1); + }); }); -}); }); \ No newline at end of file diff --git a/tests/unit/database/database-new.test.ts b/tests/unit/database/database-new.test.ts new file mode 100644 index 0000000..088c154 --- /dev/null +++ b/tests/unit/database/database-new.test.ts @@ -0,0 +1,189 @@ +/** + * Unit tests for the Database class. + * + * Unlike the original tests/unit/database/database.test.ts (which swapped the + * `Database` class itself with a mock via the DI container — so it only tested + * the mock), these tests mock the `sequelize-typescript` module so that the + * `Sequelize` constructor is a jest mock. We then `new Database()` directly + * and assert the real class's behaviour: that the constructor receives the + * right options and that `connect()` calls `.authenticate()` (and `.sync()`). + */ + +import 'reflect-metadata'; + +// Mock sequelize-typescript BEFORE importing the Database module so the +// Sequelize constructor inside Database is the jest mock. +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, +})); + +// Mock the env config so the constructor has deterministic inputs. +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), +})); + +// Mock the models so importing Database doesn't drag in real Sequelize decorators. +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 {} })); + +// Silence the logger so tests don't pollute stdout. +jest.mock('../../../src/shared/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +// Now import the class under test and the mocked helpers. +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/database/database.test.ts b/tests/unit/database/database.test.ts index 0c51ba3..088c154 100644 --- a/tests/unit/database/database.test.ts +++ b/tests/unit/database/database.test.ts @@ -1,55 +1,189 @@ -import { Container } from 'typedi'; -import { mockDatabase, mockSequelize } from '../../mocks/database.mock'; -import { Database } from '../../../src/shared/data-access'; - -describe('Database (unit tests with mocks)', () => { - let db: Database; - +/** + * Unit tests for the Database class. + * + * Unlike the original tests/unit/database/database.test.ts (which swapped the + * `Database` class itself with a mock via the DI container — so it only tested + * the mock), these tests mock the `sequelize-typescript` module so that the + * `Sequelize` constructor is a jest mock. We then `new Database()` directly + * and assert the real class's behaviour: that the constructor receives the + * right options and that `connect()` calls `.authenticate()` (and `.sync()`). + */ + +import 'reflect-metadata'; + +// Mock sequelize-typescript BEFORE importing the Database module so the +// Sequelize constructor inside Database is the jest mock. +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, +})); + +// Mock the env config so the constructor has deterministic inputs. +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), +})); + +// Mock the models so importing Database doesn't drag in real Sequelize decorators. +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 {} })); + +// Silence the logger so tests don't pollute stdout. +jest.mock('../../../src/shared/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +// Now import the class under test and the mocked helpers. +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(() => { - Container.reset(); - Container.set(Database, mockDatabase as any); - db = Container.get(Database); + MockSequelize.mockClear(); + mockAuthenticate.mockReset(); + mockSync.mockReset(); + mockClose.mockReset(); + (envConfig.isDevelopment as jest.Mock).mockReturnValue(false); }); - afterEach(() => { - jest.clearAllMocks(); - Container.reset(); + 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'); + }); }); - test('connect() should be called', async () => { - await db.connect(); - expect(mockDatabase.connect).toHaveBeenCalled(); - }); + 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); - test('disconnect() should be called', async () => { - await db.disconnect(); - expect(mockDatabase.disconnect).toHaveBeenCalled(); - }); + const db = new Database(); + await db.connect(); - test('getSequelize() should return mocked sequelize', () => { - const sequelize = db.getSequelize(); - expect(sequelize).toBe(mockSequelize); - expect(mockDatabase.getSequelize).toHaveBeenCalled(); - }); + 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); - test('connect() should throw an error if the connection is refused', async () => { - (mockDatabase.connect as jest.Mock).mockRejectedValueOnce(new Error('ECONNREFUSED')); + const db = new Database(); + await db.connect(); - await expect(db.connect()).rejects.toThrow('ECONNREFUSED'); + expect(mockAuthenticate).toHaveBeenCalledTimes(1); + expect(mockSync).toHaveBeenCalledWith({ force: true }); + }); - expect(mockDatabase.connect).toHaveBeenCalled(); + 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(); + }); }); - test('disconnect() should handle being called multiple times gracefully', async () => { - await db.disconnect(); - await db.disconnect(); + describe('getSequelize()', () => { + test('should return the underlying Sequelize instance', () => { + const db = new Database(); - expect(mockDatabase.disconnect).toHaveBeenCalledTimes(2); + 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 index 7add638..ee32fd6 100644 --- a/tests/unit/event-badging/checklist.service.test.ts +++ b/tests/unit/event-badging/checklist.service.test.ts @@ -1,11 +1,14 @@ import { ChecklistService } from '../../../src/event-badging/services/checklist.service'; -import { mockGitHubApiService } from '../../mocks/github.mock'; +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); }); @@ -31,18 +34,18 @@ describe('ChecklistService', () => { }); await expect(service.generateReviewerChecklist({} as any, 'o', 'r', 'body', 'virtual')) - .rejects.toThrow('Failed to fetch checklist: 404 Not Found'); + .rejects.toThrow('Failed to fetch checklist template'); }); - test('checkModerator() should return false and log error if file is missing', async () => { - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + 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(consoleSpy).toHaveBeenCalled(); - consoleSpy.mockRestore(); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); }); test('generateReviewerChecklist() should remove existing checkmarks from body', async () => { diff --git a/tests/unit/event-badging/event-badging.service.test.ts b/tests/unit/event-badging/event-badging.service.test.ts index aa0df5b..39c97fc 100644 --- a/tests/unit/event-badging/event-badging.service.test.ts +++ b/tests/unit/event-badging/event-badging.service.test.ts @@ -174,19 +174,12 @@ describe('EventBadgingService', () => { test('should handle and log errors during comment creation', async () => { const payload = createPayload('[Event]', 'opened'); - const errorSpy = jest.spyOn(console, 'error').mockImplementation(); githubApiService.createIssueComment.mockRejectedValue(new Error('GitHub Down')); - await service.processWebhook('issues', payload); - await Promise.resolve(); - - expect(errorSpy).toHaveBeenCalledWith( - 'Error posting welcome message:', - expect.any(Error) + await expect(service.processWebhook('issues', payload)).rejects.toThrow( + 'Failed to post applicant welcome message' ); - - errorSpy.mockRestore(); }); test('should process /result command from a comment', async () => { diff --git a/tests/unit/event-badging/scoring.service.test.ts b/tests/unit/event-badging/scoring.service.test.ts index ba49d79..c44ec13 100644 --- a/tests/unit/event-badging/scoring.service.test.ts +++ b/tests/unit/event-badging/scoring.service.test.ts @@ -1,21 +1,20 @@ import { ScoringService } from '../../../src/event-badging/services/scoring.service'; -import { mockGitHubApiService } from '../../mocks/github.mock'; +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: any; - let mockOctokit: any; + let githubApiServiceMock: ReturnType; + let mockOctokit: any; beforeEach(() => { - mockGitHubApiService.listIssueComments.mockReset(); - githubApiServiceMock = mockGitHubApiService; + githubApiServiceMock = createMockGitHubApiService(); mockOctokit = createMockOctokit(); - - service = new ScoringService(githubApiServiceMock); + + service = new ScoringService(githubApiServiceMock as any); jest.spyOn(envConfig, 'isDevelopment').mockReturnValue(false); jest.spyOn(envConfig, 'getEnvVar').mockImplementation((key) => { diff --git a/tests/unit/project-badging/badge-award.service.test.ts b/tests/unit/project-badging/badge-award.service.test.ts index 50ecb53..d77dd42 100644 --- a/tests/unit/project-badging/badge-award.service.test.ts +++ b/tests/unit/project-badging/badge-award.service.test.ts @@ -89,17 +89,14 @@ describe('BadgeAwardService', () => { expect(augurApiService.registerBadgedRepo).not.toHaveBeenCalled(); }); - it('should catch errors and return false if mailerService throws', async () => { + it('should throw AppError if mailerService throws', async () => { mailerService.sendBadgingEmail.mockRejectedValue(new Error('SMTP Down')); - const errorSpy = jest.spyOn(console, 'error').mockImplementation(); - const result = await service.awardBronzeBadge( - mockUser.id, mockUser.name, mockUser.email, 1, null, 'url', 'sha' - ); - - expect(result).toBe(false); - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Error awarding bronze badge'), 'SMTP Down'); - errorSpy.mockRestore(); + 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 () => { diff --git a/tests/unit/project-badging/dei-scanner.service.test.ts b/tests/unit/project-badging/dei-scanner.service.test.ts index 37fe260..d72caf7 100644 --- a/tests/unit/project-badging/dei-scanner.service.test.ts +++ b/tests/unit/project-badging/dei-scanner.service.test.ts @@ -1,5 +1,6 @@ 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'); @@ -61,7 +62,7 @@ describe('DEIScannerService', () => { test('getDEITemplate() should return null and log error on API failure', async () => { mockAxiosGet.mockRejectedValueOnce(new Error('GitHub API Limit')); - const errorSpy = jest.spyOn(console, 'error').mockImplementation(); + const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => logger); const result = await service.getDEITemplate(); diff --git a/tests/unit/shared/providers/github/github-app.service.test.ts b/tests/unit/shared/providers/github/github-app.service.test.ts index e87a039..015b459 100644 --- a/tests/unit/shared/providers/github/github-app.service.test.ts +++ b/tests/unit/shared/providers/github/github-app.service.test.ts @@ -5,12 +5,14 @@ jest.mock('octokit', () => ({ import { GitHubAppService } from '../../../../../src/shared/providers/github/github-app.service'; import * as envConfig from '../../../../../src/shared/config/environment'; import { App } from 'octokit'; -import { mockGitHubAppInstance } from '../../../../mocks/github.mock'; +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) => { @@ -30,7 +32,7 @@ describe('GitHubAppService', () => { jest.spyOn(console, 'warn').mockImplementation(() => {}); jest.spyOn(console, 'error').mockImplementation(() => {}); - + mockGitHubAppInstance = createMockGitHubAppInstance(); MockedApp.mockReturnValue(mockGitHubAppInstance); }); @@ -69,7 +71,7 @@ describe('GitHubAppService', () => { }); test('should fail to initialize if an env var is missing', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => logger); jest.spyOn(envConfig, 'getEnvVar').mockImplementation((key) => key === 'GITHUB_APP_ID' ? '' : 'some-value' @@ -78,11 +80,11 @@ describe('GitHubAppService', () => { service = new GitHubAppService(); expect(service.isConfigured()).toBe(false); - expect(consoleSpy).toHaveBeenCalledWith( + expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('not fully configured') ); - consoleSpy.mockRestore(); + warnSpy.mockRestore(); }); test('getInstallationOctokit() should throw error if app is not configured', async () => { @@ -95,22 +97,12 @@ describe('GitHubAppService', () => { }); test('should catch and log errors during App instantiation', () => { - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - mockValidEnv(); MockedApp.mockImplementationOnce(() => { throw new Error('Invalid Private Key Format'); }); - service = new GitHubAppService(); - - expect(service.isConfigured()).toBe(false); - expect(consoleSpy).toHaveBeenCalledWith( - 'Failed to initialize GitHub App:', - expect.any(Error) - ); - - consoleSpy.mockRestore(); + 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 index 63ced0e..c9c290c 100644 --- a/tests/unit/shared/providers/github/github-auth.service.test.ts +++ b/tests/unit/shared/providers/github/github-auth.service.test.ts @@ -21,7 +21,7 @@ describe('GitHubAuthService', () => { const url = service.getProjectBadgingAuthUrl(); expect(url).toContain('client_id=project_id'); - expect(url).toContain('scope=read:user,user:email,public_repo'); + expect(url).toContain('scope=read:user,user:email'); }); test('getEventBadgingAuthUrl() should include encrypted state and event client id', () => { @@ -31,7 +31,7 @@ describe('GitHubAuthService', () => { expect(url).toContain('client_id=event_id'); expect(url).toContain('state=my-secret-state'); - expect(url).toContain('scope=public_repo'); + expect(url).toContain('scope=read:user,user:email'); }); }); @@ -90,8 +90,8 @@ describe('GitHubAuthService', () => { }); describe('Octokit Factory', () => { - test('createOctokit() should return instance with auth header', () => { - const octokit = service.createOctokit('my_token'); + test('createUserOctokit() should return instance with auth header', () => { + const octokit = service.createUserOctokit('my_token'); expect(octokit).toBeInstanceOf(Octokit); }); diff --git a/tests/unit/shared/services/mailer.service.test.ts b/tests/unit/shared/services/mailer.service.test.ts index 29ffd89..31d4d32 100644 --- a/tests/unit/shared/services/mailer.service.test.ts +++ b/tests/unit/shared/services/mailer.service.test.ts @@ -2,6 +2,7 @@ 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'); @@ -65,7 +66,7 @@ describe('MailerService', () => { test('should return false and log warning if SMTP is not configured', () => { jest.spyOn(envConfig, 'getEnvVar').mockReturnValue(''); - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => logger); const unconfiguredService = new MailerService(); @@ -77,7 +78,7 @@ describe('MailerService', () => { 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(console, 'error').mockImplementation(); + const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => logger); const result = await service.sendSuccessEmail({ to: 'test@test.com' } as any); From cafd97a5c69f8f620b5a45e2dcf22795402ffead Mon Sep 17 00:00:00 2001 From: AdeyinkaOresanya Date: Tue, 26 May 2026 10:41:00 +0100 Subject: [PATCH 45/45] refactor: refactor: remove unnecessary imports from test files Signed-off-by: AdeyinkaOresanya --- tests/e2e/event-badging.test.ts | 1 - tests/e2e/project-badging.test.ts | 1 - tests/unit/database/database-new.test.ts | 189 ------------------ tests/unit/database/database.test.ts | 20 +- .../shared/services/crypto.service.test.ts | 1 - 5 files changed, 1 insertion(+), 211 deletions(-) delete mode 100644 tests/unit/database/database-new.test.ts diff --git a/tests/e2e/event-badging.test.ts b/tests/e2e/event-badging.test.ts index 204c925..a4daf46 100644 --- a/tests/e2e/event-badging.test.ts +++ b/tests/e2e/event-badging.test.ts @@ -1,4 +1,3 @@ -import 'reflect-metadata'; import request from 'supertest'; import { Container } from 'typedi'; import { useContainer } from 'routing-controllers'; diff --git a/tests/e2e/project-badging.test.ts b/tests/e2e/project-badging.test.ts index 54aa7b5..3458942 100644 --- a/tests/e2e/project-badging.test.ts +++ b/tests/e2e/project-badging.test.ts @@ -1,4 +1,3 @@ -import 'reflect-metadata'; import request from 'supertest'; import { Container } from 'typedi'; import { useContainer } from 'routing-controllers'; diff --git a/tests/unit/database/database-new.test.ts b/tests/unit/database/database-new.test.ts deleted file mode 100644 index 088c154..0000000 --- a/tests/unit/database/database-new.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Unit tests for the Database class. - * - * Unlike the original tests/unit/database/database.test.ts (which swapped the - * `Database` class itself with a mock via the DI container — so it only tested - * the mock), these tests mock the `sequelize-typescript` module so that the - * `Sequelize` constructor is a jest mock. We then `new Database()` directly - * and assert the real class's behaviour: that the constructor receives the - * right options and that `connect()` calls `.authenticate()` (and `.sync()`). - */ - -import 'reflect-metadata'; - -// Mock sequelize-typescript BEFORE importing the Database module so the -// Sequelize constructor inside Database is the jest mock. -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, -})); - -// Mock the env config so the constructor has deterministic inputs. -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), -})); - -// Mock the models so importing Database doesn't drag in real Sequelize decorators. -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 {} })); - -// Silence the logger so tests don't pollute stdout. -jest.mock('../../../src/shared/logger', () => ({ - logger: { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - }, -})); - -// Now import the class under test and the mocked helpers. -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/database/database.test.ts b/tests/unit/database/database.test.ts index 088c154..8874366 100644 --- a/tests/unit/database/database.test.ts +++ b/tests/unit/database/database.test.ts @@ -1,18 +1,3 @@ -/** - * Unit tests for the Database class. - * - * Unlike the original tests/unit/database/database.test.ts (which swapped the - * `Database` class itself with a mock via the DI container — so it only tested - * the mock), these tests mock the `sequelize-typescript` module so that the - * `Sequelize` constructor is a jest mock. We then `new Database()` directly - * and assert the real class's behaviour: that the constructor receives the - * right options and that `connect()` calls `.authenticate()` (and `.sync()`). - */ - -import 'reflect-metadata'; - -// Mock sequelize-typescript BEFORE importing the Database module so the -// Sequelize constructor inside Database is the jest mock. const mockAuthenticate = jest.fn(); const mockSync = jest.fn(); const mockClose = jest.fn(); @@ -29,7 +14,6 @@ jest.mock('sequelize-typescript', () => ({ Sequelize: MockSequelize, })); -// Mock the env config so the constructor has deterministic inputs. jest.mock('../../../src/shared/config/environment', () => ({ getEnvVar: jest.fn((key: string, _required?: boolean | string) => { const values: Record = { @@ -45,12 +29,11 @@ jest.mock('../../../src/shared/config/environment', () => ({ isDevelopment: jest.fn(() => false), })); -// Mock the models so importing Database doesn't drag in real Sequelize decorators. + 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 {} })); -// Silence the logger so tests don't pollute stdout. jest.mock('../../../src/shared/logger', () => ({ logger: { info: jest.fn(), @@ -60,7 +43,6 @@ jest.mock('../../../src/shared/logger', () => ({ }, })); -// Now import the class under test and the mocked helpers. 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'; diff --git a/tests/unit/shared/services/crypto.service.test.ts b/tests/unit/shared/services/crypto.service.test.ts index aeafffa..e47743c 100644 --- a/tests/unit/shared/services/crypto.service.test.ts +++ b/tests/unit/shared/services/crypto.service.test.ts @@ -1,4 +1,3 @@ -import 'reflect-metadata'; import { CryptoService } from '../../../../src/shared/services/crypto.service'; import * as envConfig from '../../../../src/shared/config/environment';