diff --git a/jest.config.js b/jest.config.js index e7a1f95..361df0a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,17 +1,32 @@ module.exports = { + preset: 'ts-jest', testEnvironment: 'node', - collectCoverageFrom: [ - '**/*.js', - '!**/node_modules/**', - '!**/coverage/**', - '!**/public/**', - '!jest.config.js', - '!server.js' // Exclude main server file as it's integration-focused - ], + roots: ['/src', '/tests'], testMatch: [ - '**/tests/**/*.test.js', - '**/tests/**/*.spec.js' + '**/__tests__/**/*.+(ts|tsx|js)', + '**/*.(test|spec).+(ts|tsx|js)' ], - verbose: true, - setupFilesAfterEnv: ['/tests/setup.js'] + transform: { + '^.+\\.(ts|tsx)$': 'ts-jest' + }, + collectCoverageFrom: [ + 'src/**/*.{ts,tsx}', + '!src/**/*.d.ts', + '!src/swagger.ts', // Skip swagger spec file + '!src/types/**/*', // Skip type definitions + '!src/server.ts' // Skip main server as it's integration-focused + ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80 + } + }, + setupFilesAfterEnv: ['/tests/setup.ts'], + testTimeout: 10000, + verbose: true }; diff --git a/package-lock.json b/package-lock.json index 350291c..35aae6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,16 +21,20 @@ "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.3", + "@types/jest": "^30.0.0", "@types/node": "^24.0.10", "@types/node-cron": "^3.0.11", "@types/sqlite3": "^3.1.11", + "@types/supertest": "^6.0.3", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", "concurrently": "^9.2.0", "jest": "^29.7.0", + "jest-environment-node": "^30.0.4", "nodemon": "^3.1.10", "sass": "^1.89.2", "supertest": "^6.3.4", + "ts-jest": "^29.4.0", "ts-node": "^10.9.2", "typescript": "^5.8.3" } @@ -818,6 +822,16 @@ } } }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", @@ -879,6 +893,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/get-type": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.0.1.tgz", + "integrity": "sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/globals": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", @@ -895,6 +919,30 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern/node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/reporters": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", @@ -1600,6 +1648,13 @@ "@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, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -1679,12 +1734,243 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/jest/node_modules/@jest/expect-utils": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.0.4.tgz", + "integrity": "sha512-EgXecHDNfANeqOkcak0DxsoVI4qkDUsR7n/Lr2vtmTBjwLPBnnPOF71S11Q8IObWzxm2QgQoY6f9hzrRD3gHRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/@jest/schemas": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.1.tgz", + "integrity": "sha512-+g/1TKjFuGrf1Hh0QPCv0gISwBxJ+MQSNXmG9zjHy7BmFhtoJ9fdNhWJp3qUKRi93AOZHXtdxZgJ1vAtz6z65w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/@jest/types": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.1.tgz", + "integrity": "sha512-HGwoYRVF0QSKJu1ZQX0o5ZrUrrhj0aOOFA8hXrumD7SIzjouevhawbTjmXdwOmURdGluU9DM/XvGm3NyFoiQjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/@sinclair/typebox": { + "version": "0.34.37", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.37.tgz", + "integrity": "sha512-2TRuQVgQYfy+EzHRTIvkhv2ADEouJ2xNS/Vq+W5EuuewBdOrvATvljZTxHWZSTYr2sTjTHpGvucaGAt67S2akw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jest/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/ci-info": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.2.0.tgz", + "integrity": "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@types/jest/node_modules/expect": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.0.4.tgz", + "integrity": "sha512-dDLGjnP2cKbEppxVICxI/Uf4YemmGMPNy0QytCbfafbpYk9AFQsxb8Uyrxii0RPK7FWgLGlSem+07WirwS3cFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.0.4", + "@jest/get-type": "30.0.1", + "jest-matcher-utils": "30.0.4", + "jest-message-util": "30.0.2", + "jest-mock": "30.0.2", + "jest-util": "30.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-diff": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.0.4.tgz", + "integrity": "sha512-TSjceIf6797jyd+R64NXqicttROD+Qf98fex7CowmlSn7f8+En0da1Dglwr1AXxDtVizoxXYZBlUQwNhoOXkNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.0.1", + "chalk": "^4.1.2", + "pretty-format": "30.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-matcher-utils": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.0.4.tgz", + "integrity": "sha512-ubCewJ54YzeAZ2JeHHGVoU+eDIpQFsfPQs0xURPWoNiO42LGJ+QGgfSf+hFIRplkZDkhH5MOvuxHKXRTUU3dUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1", + "chalk": "^4.1.2", + "jest-diff": "30.0.4", + "pretty-format": "30.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-message-util": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.2.tgz", + "integrity": "sha512-vXywcxmr0SsKXF/bAD7t7nMamRvPuJkras00gqYeB1V0WllxZrbZ0paRr3XqpFU2sYYjD0qAaG2fRyn/CGZ0aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.0.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.0.2", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-mock": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.2.tgz", + "integrity": "sha512-PnZOHmqup/9cT/y+pXIVbbi8ID6U1XHRmbvR7MvUy4SLqhCbwpkmXhLbsWbGewHrV5x/1bF7YDjs+x24/QSvFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.1", + "@types/node": "*", + "jest-util": "30.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-util": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz", + "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", + "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.1", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, + "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, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -1763,6 +2049,30 @@ "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, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/swagger-jsdoc": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz", @@ -2024,6 +2334,13 @@ "dev": true, "license": "MIT" }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2296,6 +2613,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -3024,6 +3354,22 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.179", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.179.tgz", @@ -3358,6 +3704,39 @@ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4208,6 +4587,25 @@ "node": ">=8" } }, + "node_modules/jake": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", + "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", @@ -4362,6 +4760,24 @@ } } }, + "node_modules/jest-config/node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jest-config/node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4422,21 +4838,247 @@ } }, "node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.0.4.tgz", + "integrity": "sha512-p+rLEzC2eThXqiNh9GHHTC0OW5Ca4ZfcURp7scPjYBcmgpR9HG6750716GuUipYf2AcThU3k20B31USuiaaIEg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/environment": "30.0.4", + "@jest/fake-timers": "30.0.4", + "@jest/types": "30.0.1", "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "jest-mock": "30.0.2", + "jest-util": "30.0.2", + "jest-validate": "30.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/@jest/environment": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.0.4.tgz", + "integrity": "sha512-5NT+sr7ZOb8wW7C4r7wOKnRQ8zmRWQT2gW4j73IXAKp5/PX1Z8MCStBLQDYfIG3n1Sw0NRfYGdp0iIPVooBAFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.0.4", + "@jest/types": "30.0.1", + "@types/node": "*", + "jest-mock": "30.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/@jest/fake-timers": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.0.4.tgz", + "integrity": "sha512-qZ7nxOcL5+gwBO6LErvwVy5k06VsX/deqo2XnVUSTV0TNC9lrg8FC3dARbi+5lmrr5VyX5drragK+xLcOjvjYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.1", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.0.2", + "jest-mock": "30.0.2", + "jest-util": "30.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/@jest/schemas": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.1.tgz", + "integrity": "sha512-+g/1TKjFuGrf1Hh0QPCv0gISwBxJ+MQSNXmG9zjHy7BmFhtoJ9fdNhWJp3qUKRi93AOZHXtdxZgJ1vAtz6z65w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/@jest/types": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.1.tgz", + "integrity": "sha512-HGwoYRVF0QSKJu1ZQX0o5ZrUrrhj0aOOFA8hXrumD7SIzjouevhawbTjmXdwOmURdGluU9DM/XvGm3NyFoiQjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/@sinclair/typebox": { + "version": "0.34.37", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.37.tgz", + "integrity": "sha512-2TRuQVgQYfy+EzHRTIvkhv2ADEouJ2xNS/Vq+W5EuuewBdOrvATvljZTxHWZSTYr2sTjTHpGvucaGAt67S2akw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-environment-node/node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/jest-environment-node/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-environment-node/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-environment-node/node_modules/ci-info": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.2.0.tgz", + "integrity": "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-environment-node/node_modules/jest-message-util": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.2.tgz", + "integrity": "sha512-vXywcxmr0SsKXF/bAD7t7nMamRvPuJkras00gqYeB1V0WllxZrbZ0paRr3XqpFU2sYYjD0qAaG2fRyn/CGZ0aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.0.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.0.2", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/jest-mock": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.2.tgz", + "integrity": "sha512-PnZOHmqup/9cT/y+pXIVbbi8ID6U1XHRmbvR7MvUy4SLqhCbwpkmXhLbsWbGewHrV5x/1bF7YDjs+x24/QSvFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.1", + "@types/node": "*", + "jest-util": "30.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/jest-util": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz", + "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.0.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/jest-validate": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.0.2.tgz", + "integrity": "sha512-noOvul+SFER4RIvNAwGn6nmV2fXqBq67j+hKGHKGFCmK4ks/Iy1FSrqQNBLGKlu4ZZIRL6Kg1U72N1nxuRCrGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.0.1", + "@jest/types": "30.0.1", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-environment-node/node_modules/pretty-format": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", + "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.1", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-get-type": { @@ -4637,6 +5279,24 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-runner/node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jest-runtime": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", @@ -4936,6 +5596,13 @@ "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", "license": "MIT" }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.mergewith": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", @@ -6934,6 +7601,72 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-jest": { + "version": "29.4.0", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.0.tgz", + "integrity": "sha512-d423TJMnJGu80/eSgfQ5w/R+0zFJvdtTxwtF9KzFFunOpSeD+79lHJQIiAhluJoyGRbvj9NZJsl9WjCUo0ND7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "ejs": "^3.1.10", + "fast-json-stable-stringify": "^2.1.0", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.2", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", diff --git a/package.json b/package.json index 9dde835..89d4d29 100644 --- a/package.json +++ b/package.json @@ -31,16 +31,20 @@ "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.3", + "@types/jest": "^30.0.0", "@types/node": "^24.0.10", "@types/node-cron": "^3.0.11", "@types/sqlite3": "^3.1.11", + "@types/supertest": "^6.0.3", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", "concurrently": "^9.2.0", "jest": "^29.7.0", + "jest-environment-node": "^30.0.4", "nodemon": "^3.1.10", "sass": "^1.89.2", "supertest": "^6.3.4", + "ts-jest": "^29.4.0", "ts-node": "^10.9.2", "typescript": "^5.8.3" }, diff --git a/tests/integration/routes/public.test.ts b/tests/integration/routes/public.test.ts new file mode 100644 index 0000000..c1be07f --- /dev/null +++ b/tests/integration/routes/public.test.ts @@ -0,0 +1,349 @@ +import request from 'supertest'; +import express, { Application } from 'express'; +import configRoutes from '../../../src/routes/config'; +import locationRoutes from '../../../src/routes/locations'; +import Location from '../../../src/models/Location'; +import { createTestDatabase } from '../../setup'; +import { Database } from 'sqlite3'; + +describe('Public API Routes', () => { + let app: Application; + let db: Database; + let locationModel: Location; + + beforeEach(async () => { + // Setup Express app + app = express(); + app.use(express.json()); + + // Setup test database and models + db = await createTestDatabase(); + locationModel = new Location(db); + + // Mock profanity filter + const mockProfanityFilter = { + analyzeProfanity: jest.fn().mockReturnValue({ + hasProfanity: false, + matches: [], + severity: 'none', + count: 0, + filtered: 'test text' + }) + }; + + // Setup routes + app.use('/api/config', configRoutes()); + app.use('/api/locations', locationRoutes(locationModel, mockProfanityFilter)); + }); + + afterEach((done) => { + db.close(done); + }); + + describe('GET /api/config', () => { + it('should return API configuration', async () => { + const response = await request(app) + .get('/api/config') + .expect(200); + + expect(response.body).toHaveProperty('mapboxAccessToken'); + expect(response.body).toHaveProperty('hasMapbox'); + expect(typeof response.body.hasMapbox).toBe('boolean'); + }); + + it('should return mapbox token when configured', async () => { + process.env.MAPBOX_ACCESS_TOKEN = 'pk.test_token'; + + const response = await request(app) + .get('/api/config') + .expect(200); + + expect(response.body.mapboxAccessToken).toBe('pk.test_token'); + expect(response.body.hasMapbox).toBe(true); + }); + + it('should handle missing mapbox token', async () => { + delete process.env.MAPBOX_ACCESS_TOKEN; + + const response = await request(app) + .get('/api/config') + .expect(200); + + expect(response.body.mapboxAccessToken).toBeNull(); + expect(response.body.hasMapbox).toBe(false); + }); + }); + + describe('GET /api/locations', () => { + beforeEach(async () => { + // Add test locations + await locationModel.create({ + address: 'Test Location 1', + latitude: 42.9634, + longitude: -85.6681, + description: 'Test description 1' + }); + + await locationModel.create({ + address: 'Test Location 2', + latitude: 42.9584, + longitude: -85.6706, + description: 'Test description 2' + }); + }); + + it('should return all active locations', async () => { + const response = await request(app) + .get('/api/locations') + .expect(200); + + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(2); + expect(response.body[0]).toHaveProperty('id'); + expect(response.body[0]).toHaveProperty('address'); + expect(response.body[0]).toHaveProperty('latitude'); + expect(response.body[0]).toHaveProperty('longitude'); + }); + + it('should return empty array when no locations exist', async () => { + // Clear all locations + await locationModel.delete(1); + await locationModel.delete(2); + + const response = await request(app) + .get('/api/locations') + .expect(200); + + expect(response.body).toEqual([]); + }); + + it('should handle database errors gracefully', async () => { + // Create a new app with broken database to simulate error + const brokenApp = express(); + brokenApp.use(express.json()); + + // Create a broken location model that throws errors + const brokenLocationModel = { + getActive: jest.fn().mockRejectedValue(new Error('Database error')) + }; + + const mockProfanityFilter = { + analyzeProfanity: jest.fn().mockReturnValue({ + hasProfanity: false, + matches: [], + severity: 'none', + count: 0, + filtered: 'test text' + }) + }; + + brokenApp.use('/api/locations', locationRoutes(brokenLocationModel as any, mockProfanityFilter)); + + const response = await request(brokenApp) + .get('/api/locations') + .expect(500); + + expect(response.body).toHaveProperty('error'); + expect(response.body.error).toBe('Internal server error'); + }); + }); + + describe('POST /api/locations', () => { + it('should create a new location with valid data', async () => { + const locationData = { + address: 'New Test Location', + latitude: 42.9634, + longitude: -85.6681, + description: 'New test description' + }; + + const response = await request(app) + .post('/api/locations') + .send(locationData) + .expect(200); + + expect(response.body).toHaveProperty('id'); + expect(response.body.address).toBe(locationData.address); + expect(response.body.latitude).toBe(locationData.latitude); + expect(response.body.longitude).toBe(locationData.longitude); + expect(response.body.description).toBe(locationData.description); + expect(response.body).toHaveProperty('created_at'); + }); + + it('should create location with only required address', async () => { + const locationData = { + address: 'Minimal Location' + }; + + const response = await request(app) + .post('/api/locations') + .send(locationData) + .expect(200); + + expect(response.body).toHaveProperty('id'); + expect(response.body.address).toBe(locationData.address); + }); + + it('should reject request without address', async () => { + const locationData = { + latitude: 42.9634, + longitude: -85.6681, + description: 'Missing address' + }; + + const response = await request(app) + .post('/api/locations') + .send(locationData) + .expect(400); + + expect(response.body).toHaveProperty('error'); + expect(response.body.error).toBe('Address is required'); + }); + + it('should reject request with profanity in description', async () => { + // Setup mock to detect profanity + const app2 = express(); + app2.use(express.json()); + + const mockProfanityFilter = { + analyzeProfanity: jest.fn().mockReturnValue({ + hasProfanity: true, + matches: [{ word: 'badword', category: 'general' }], + severity: 'medium', + count: 1, + filtered: '*** text' + }) + }; + + app2.use('/api/locations', locationRoutes(locationModel, mockProfanityFilter)); + + const locationData = { + address: 'Test Location', + description: 'This contains profanity' + }; + + const response = await request(app2) + .post('/api/locations') + .send(locationData) + .expect(400); + + expect(response.body).toHaveProperty('error'); + expect(response.body.error).toBe('Submission rejected'); + expect(response.body).toHaveProperty('message'); + expect(response.body).toHaveProperty('details'); + }); + + it('should handle empty description', async () => { + const locationData = { + address: 'Test Location', + description: '' + }; + + const response = await request(app) + .post('/api/locations') + .send(locationData) + .expect(200); + + expect(response.body).toHaveProperty('id'); + expect(response.body.address).toBe(locationData.address); + }); + + it('should handle missing optional fields', async () => { + const locationData = { + address: 'Test Location' + // No latitude, longitude, or description + }; + + const response = await request(app) + .post('/api/locations') + .send(locationData) + .expect(200); + + expect(response.body).toHaveProperty('id'); + expect(response.body.address).toBe(locationData.address); + }); + + it('should handle profanity filter errors gracefully', async () => { + const app2 = express(); + app2.use(express.json()); + + const mockProfanityFilter = { + analyzeProfanity: jest.fn().mockImplementation(() => { + throw new Error('Filter error'); + }) + }; + + app2.use('/api/locations', locationRoutes(locationModel, mockProfanityFilter)); + + const locationData = { + address: 'Test Location', + description: 'Test description' + }; + + // Should still succeed if profanity filter fails + const response = await request(app2) + .post('/api/locations') + .send(locationData) + .expect(200); + + expect(response.body).toHaveProperty('id'); + }); + }); + + describe('Content-Type validation', () => { + it('should accept JSON content type', async () => { + const response = await request(app) + .post('/api/locations') + .set('Content-Type', 'application/json') + .send({ address: 'Test Location' }) + .expect(200); + + expect(response.body).toHaveProperty('id'); + }); + + it('should handle malformed JSON', async () => { + const response = await request(app) + .post('/api/locations') + .set('Content-Type', 'application/json') + .send('{"address": "Test Location"') // Missing closing brace + .expect(400); + + // Should return bad request for malformed JSON + }); + }); + + describe('Request validation', () => { + it('should handle very long addresses', async () => { + const longAddress = 'A'.repeat(1000); + + const response = await request(app) + .post('/api/locations') + .send({ address: longAddress }) + .expect(200); + + expect(response.body.address).toBe(longAddress); + }); + + it('should handle special characters in address', async () => { + const specialAddress = 'Main St & Oak Ave, Grand Rapids, MI 49503'; + + const response = await request(app) + .post('/api/locations') + .send({ address: specialAddress }) + .expect(200); + + expect(response.body.address).toBe(specialAddress); + }); + + it('should handle unicode characters', async () => { + const unicodeAddress = 'Straße München, Deutschland 🇩🇪'; + + const response = await request(app) + .post('/api/locations') + .send({ address: unicodeAddress }) + .expect(200); + + expect(response.body.address).toBe(unicodeAddress); + }); + }); +}); \ No newline at end of file diff --git a/tests/routes.test.js b/tests/routes.test.js deleted file mode 100644 index 5d58963..0000000 --- a/tests/routes.test.js +++ /dev/null @@ -1,411 +0,0 @@ -const request = require('supertest'); -const express = require('express'); -const sqlite3 = require('sqlite3').verbose(); -const ProfanityFilter = require('../profanity-filter'); -const locationRoutes = require('../routes/locations'); -const adminRoutes = require('../routes/admin'); -const configRoutes = require('../routes/config'); - -describe('API Routes', () => { - let app; - let db; - let profanityFilter; - - beforeEach(async () => { - // Create test app and database - app = express(); - app.use(express.json()); - - db = new sqlite3.Database(':memory:'); - - // Create profanity filter with test database path - profanityFilter = new ProfanityFilter(':memory:'); - - // Wait for profanity filter initialization - await profanityFilter.ready(); - - // Initialize locations table synchronously - await new Promise((resolve, reject) => { - db.serialize(() => { - db.run(`CREATE TABLE IF NOT EXISTS locations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - address TEXT NOT NULL, - latitude REAL, - longitude REAL, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - description TEXT, - persistent INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`, (err) => { - if (err) { - console.error('Error creating locations table:', err); - reject(err); - } else { - resolve(); - } - }); - }); - }); - - // Reload profanity filter patterns after database is ready - await profanityFilter.loadCustomWords(); - - // Setup routes - app.use('/api/locations', locationRoutes(db, profanityFilter)); - app.use('/api/config', configRoutes()); - - // Mock admin authentication for testing - const mockAuth = (req, res, next) => next(); - app.use('/api/admin', adminRoutes(db, profanityFilter, mockAuth)); - }); - - afterEach(async () => { - // Wait longer for all pending operations to complete - await new Promise(resolve => setTimeout(resolve, 200)); - - // Close profanity filter database first - if (profanityFilter) { - profanityFilter.close(); - } - - // Wait a bit more - await new Promise(resolve => setTimeout(resolve, 100)); - - // Close main test database - if (db) { - await new Promise(resolve => { - db.close((err) => { - if (err) { - console.error('Error closing database:', err); - } - resolve(); - }); - }); - } - }); - - describe('Location Routes', () => { - describe('POST /api/locations', () => { - test('should accept clean location submission', async () => { - const response = await request(app) - .post('/api/locations') - .send({ - address: '123 Main St, Grand Rapids, MI', - latitude: 42.9634, - longitude: -85.6681, - description: 'Road is very slippery with black ice' - }); - - expect(response.status).toBe(200); - expect(response.body.address).toBe('123 Main St, Grand Rapids, MI'); - expect(response.body.description).toBe('Road is very slippery with black ice'); - expect(response.body.id).toBeDefined(); - }); - - test('should reject submission with profanity', async () => { - const response = await request(app) - .post('/api/locations') - .send({ - address: '123 Main St, Grand Rapids, MI', - latitude: 42.9634, - longitude: -85.6681, - description: 'This fucking road is terrible' - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('Submission rejected'); - expect(response.body.message).toContain('inappropriate language'); - expect(response.body.details.wordCount).toBeGreaterThan(0); - }); - - test('should reject submission with single profanity word', async () => { - const response = await request(app) - .post('/api/locations') - .send({ - address: '123 Main St, Grand Rapids, MI', - latitude: 42.9634, - longitude: -85.6681, - description: 'Road is shit' - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('Submission rejected'); - expect(response.body.details.wordCount).toBe(1); - }); - - test('should reject submission with leetspeak profanity', async () => { - const response = await request(app) - .post('/api/locations') - .send({ - address: '123 Main St, Grand Rapids, MI', - latitude: 42.9634, - longitude: -85.6681, - description: 'Road is sh1t' - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('Submission rejected'); - }); - - test('should require address field', async () => { - const response = await request(app) - .post('/api/locations') - .send({ - latitude: 42.9634, - longitude: -85.6681, - description: 'Road is slippery' - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('Address is required'); - }); - - test('should accept submission without description', async () => { - const response = await request(app) - .post('/api/locations') - .send({ - address: '123 Main St, Grand Rapids, MI', - latitude: 42.9634, - longitude: -85.6681 - }); - - expect(response.status).toBe(200); - expect(response.body.address).toBe('123 Main St, Grand Rapids, MI'); - }); - - test('should handle empty description', async () => { - const response = await request(app) - .post('/api/locations') - .send({ - address: '123 Main St, Grand Rapids, MI', - latitude: 42.9634, - longitude: -85.6681, - description: '' - }); - - expect(response.status).toBe(200); - }); - }); - - describe('GET /api/locations', () => { - test('should return empty array when no locations', async () => { - const response = await request(app) - .get('/api/locations'); - - expect(response.status).toBe(200); - expect(response.body).toEqual([]); - }); - - test('should return locations after adding them', async () => { - // Add a location first - await request(app) - .post('/api/locations') - .send({ - address: '123 Main St, Grand Rapids, MI', - latitude: 42.9634, - longitude: -85.6681, - description: 'Road is slippery' - }); - - const response = await request(app) - .get('/api/locations'); - - expect(response.status).toBe(200); - expect(response.body).toHaveLength(1); - expect(response.body[0].address).toBe('123 Main St, Grand Rapids, MI'); - }); - }); - }); - - describe('Config Routes', () => { - describe('GET /api/config', () => { - test('should return config with mapbox settings', async () => { - const response = await request(app) - .get('/api/config'); - - expect(response.status).toBe(200); - expect(response.body).toHaveProperty('hasMapbox'); - expect(response.body).toHaveProperty('mapboxAccessToken'); - }); - }); - }); - - describe('Admin Routes', () => { - describe('Profanity Management', () => { - test('should get empty custom words list initially', async () => { - const response = await request(app) - .get('/api/admin/profanity-words'); - - expect(response.status).toBe(200); - expect(response.body).toEqual([]); - }); - - test('should add custom profanity word', async () => { - const response = await request(app) - .post('/api/admin/profanity-words') - .send({ - word: 'custombaword', - severity: 'high', - category: 'test' - }); - - expect(response.status).toBe(200); - expect(response.body.word).toBe('custombaword'); - expect(response.body.severity).toBe('high'); - }); - - test('should reject duplicate custom words', async () => { - // Add first word - await request(app) - .post('/api/admin/profanity-words') - .send({ - word: 'duplicate', - severity: 'medium' - }); - - // Try to add same word again - const response = await request(app) - .post('/api/admin/profanity-words') - .send({ - word: 'duplicate', - severity: 'high' - }); - - expect(response.status).toBe(409); - expect(response.body.error).toContain('already exists'); - }); - - test('should validate custom word input', async () => { - const invalidInputs = [ - { word: '', severity: 'medium' }, - { word: ' ', severity: 'medium' }, - { word: 'test', severity: 'invalid' }, - { severity: 'medium' } - ]; - - for (const input of invalidInputs) { - const response = await request(app) - .post('/api/admin/profanity-words') - .send(input); - - expect(response.status).toBe(400); - } - }); - - test('should test profanity filter', async () => { - const response = await request(app) - .post('/api/admin/test-profanity') - .send({ - text: 'This fucking road is terrible' - }); - - expect(response.status).toBe(200); - expect(response.body.original).toBe('This fucking road is terrible'); - expect(response.body.analysis.hasProfanity).toBe(true); - expect(response.body.filtered).toContain('***'); - }); - }); - - describe('Location Management', () => { - test('should get all locations for admin', async () => { - // Add a location first - await request(app) - .post('/api/locations') - .send({ - address: '123 Main St, Grand Rapids, MI', - latitude: 42.9634, - longitude: -85.6681, - description: 'Road is slippery' - }); - - const response = await request(app) - .get('/api/admin/locations'); - - expect(response.status).toBe(200); - expect(response.body).toHaveLength(1); - expect(response.body[0]).toHaveProperty('isActive'); - }); - }); - }); - - describe('Error Handling', () => { - test('should handle malformed JSON', async () => { - const response = await request(app) - .post('/api/locations') - .set('Content-Type', 'application/json') - .send('{ invalid json }'); - - expect(response.status).toBe(400); - }); - - test('should handle missing content-type', async () => { - const response = await request(app) - .post('/api/locations') - .send('address=test'); - - // Should still work as express.json() is flexible - expect(response.status).toBe(400); // Will fail validation for missing required fields - }); - }); - - describe('Integration Tests', () => { - test('should handle complete workflow with profanity', async () => { - // 1. Try to submit with profanity (should be rejected) - const rejectedResponse = await request(app) - .post('/api/locations') - .send({ - address: '123 Main St, Grand Rapids, MI', - latitude: 42.9634, - longitude: -85.6681, - description: 'This damn road is fucking terrible' - }); - - expect(rejectedResponse.status).toBe(400); - expect(rejectedResponse.body.error).toBe('Submission rejected'); - - // 2. Submit clean version (should be accepted) - const acceptedResponse = await request(app) - .post('/api/locations') - .send({ - address: '123 Main St, Grand Rapids, MI', - latitude: 42.9634, - longitude: -85.6681, - description: 'Road is very slippery and dangerous' - }); - - expect(acceptedResponse.status).toBe(200); - - // 3. Verify it appears in the list - const listResponse = await request(app) - .get('/api/locations'); - - expect(listResponse.status).toBe(200); - expect(listResponse.body).toHaveLength(1); - expect(listResponse.body[0].description).toBe('Road is very slippery and dangerous'); - }); - - test('should handle custom profanity words in submissions', async () => { - // 1. Add custom profanity word - await request(app) - .post('/api/admin/profanity-words') - .send({ - word: 'customoffensive', - severity: 'high', - category: 'test' - }); - - // 2. Try to submit with custom word (should be rejected) - const response = await request(app) - .post('/api/locations') - .send({ - address: '123 Main St, Grand Rapids, MI', - latitude: 42.9634, - longitude: -85.6681, - description: 'This road is customoffensive' - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('Submission rejected'); - }); - }); -}); diff --git a/tests/setup.js b/tests/setup.js deleted file mode 100644 index d91ac6d..0000000 --- a/tests/setup.js +++ /dev/null @@ -1,16 +0,0 @@ -// Test setup file for Jest -// This file runs before each test suite - -// Suppress console.log during tests unless debugging -if (!process.env.DEBUG_TESTS) { - console.log = jest.fn(); - console.warn = jest.fn(); - console.error = jest.fn(); -} - -// Set test environment variables -process.env.NODE_ENV = 'test'; -process.env.ADMIN_PASSWORD = 'test_admin_password'; - -// Global test timeout -jest.setTimeout(30000); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..fc1f0f1 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,110 @@ +import { Database } from 'sqlite3'; +import fs from 'fs'; +import path from 'path'; + +// Setup test environment +process.env.NODE_ENV = 'test'; +process.env.ADMIN_PASSWORD = 'test_admin_password'; +process.env.MAPBOX_ACCESS_TOKEN = 'pk.test_token_here'; + +// Test database paths +export const TEST_DB_PATH = path.join(__dirname, 'test_icewatch.db'); +export const TEST_PROFANITY_DB_PATH = path.join(__dirname, 'test_profanity.db'); + +// Helper function to create test database +export const createTestDatabase = (): Promise => { + return new Promise((resolve, reject) => { + const db = new Database(':memory:', (err) => { + if (err) { + reject(err); + return; + } + + // Create locations table + db.run(` + CREATE TABLE IF NOT EXISTS locations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + address TEXT NOT NULL, + latitude REAL, + longitude REAL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + description TEXT, + persistent INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `, (err) => { + if (err) { + reject(err); + return; + } + resolve(db); + }); + }); + }); +}; + +// Helper function to create test profanity database +export const createTestProfanityDatabase = (): Promise => { + return new Promise((resolve, reject) => { + const db = new Database(':memory:', (err) => { + if (err) { + reject(err); + return; + } + + // Create profanity_words table + db.run(` + CREATE TABLE IF NOT EXISTS profanity_words ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + word TEXT NOT NULL UNIQUE, + severity TEXT NOT NULL, + category TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_by TEXT DEFAULT 'system' + ) + `, (err) => { + if (err) { + reject(err); + return; + } + resolve(db); + }); + }); + }); +}; + +// Cleanup function for tests +export const cleanupTestDatabases = () => { + // Clean up any test database files + [TEST_DB_PATH, TEST_PROFANITY_DB_PATH].forEach(dbPath => { + if (fs.existsSync(dbPath)) { + fs.unlinkSync(dbPath); + } + }); +}; + +// Global test cleanup +afterAll(() => { + cleanupTestDatabases(); +}); + +// Console override for cleaner test output +const originalConsoleLog = console.log; +const originalConsoleError = console.error; +const originalConsoleWarn = console.warn; + +beforeAll(() => { + // Suppress console output during tests unless running in verbose mode + if (!process.env.VERBOSE_TESTS) { + console.log = jest.fn(); + console.error = jest.fn(); + console.warn = jest.fn(); + } +}); + +afterAll(() => { + // Restore console functions + console.log = originalConsoleLog; + console.error = originalConsoleError; + console.warn = originalConsoleWarn; +}); \ No newline at end of file diff --git a/tests/unit/models/Location.test.ts b/tests/unit/models/Location.test.ts new file mode 100644 index 0000000..7ff9d62 --- /dev/null +++ b/tests/unit/models/Location.test.ts @@ -0,0 +1,275 @@ +import Location from '../../../src/models/Location'; +import { createTestDatabase } from '../../setup'; +import { Database } from 'sqlite3'; + +describe('Location Model', () => { + let db: Database; + let locationModel: Location; + + beforeEach(async () => { + db = await createTestDatabase(); + locationModel = new Location(db); + }); + + afterEach((done) => { + db.close(done); + }); + + describe('create', () => { + it('should create a new location with all fields', async () => { + const locationData = { + address: '123 Main St, Grand Rapids, MI', + latitude: 42.9634, + longitude: -85.6681, + description: 'Black ice present' + }; + + const result = await locationModel.create(locationData); + + expect(result).toMatchObject({ + id: 1, + address: locationData.address, + latitude: locationData.latitude, + longitude: locationData.longitude, + description: locationData.description + }); + }); + + it('should create a location with only required address field', async () => { + const locationData = { + address: 'Main St & Oak Ave' + }; + + const result = await locationModel.create(locationData); + + expect(result).toMatchObject({ + id: 1, + address: locationData.address + }); + }); + + it('should handle undefined optional fields', async () => { + const locationData = { + address: 'Test Address', + latitude: undefined, + longitude: undefined, + description: undefined + }; + + const result = await locationModel.create(locationData); + + expect(result).toMatchObject({ + id: 1, + address: locationData.address + }); + }); + }); + + describe('getActive', () => { + beforeEach(async () => { + // Insert test data with different timestamps + const now = new Date(); + const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000); + const fiftyHoursAgo = new Date(now.getTime() - 50 * 60 * 60 * 1000); + + // Active location (within 48 hours) + await new Promise((resolve, reject) => { + db.run( + 'INSERT INTO locations (address, created_at) VALUES (?, ?)', + ['Active Location', twoHoursAgo.toISOString()], + (err) => err ? reject(err) : resolve() + ); + }); + + // Expired location (over 48 hours) + await new Promise((resolve, reject) => { + db.run( + 'INSERT INTO locations (address, created_at) VALUES (?, ?)', + ['Expired Location', fiftyHoursAgo.toISOString()], + (err) => err ? reject(err) : resolve() + ); + }); + + // Persistent location (expired but persistent) + await new Promise((resolve, reject) => { + db.run( + 'INSERT INTO locations (address, created_at, persistent) VALUES (?, ?, ?)', + ['Persistent Location', fiftyHoursAgo.toISOString(), 1], + (err) => err ? reject(err) : resolve() + ); + }); + }); + + it('should return only active and persistent locations', async () => { + const activeLocations = await locationModel.getActive(); + + expect(activeLocations).toHaveLength(2); + expect(activeLocations.map(l => l.address)).toContain('Active Location'); + expect(activeLocations.map(l => l.address)).toContain('Persistent Location'); + expect(activeLocations.map(l => l.address)).not.toContain('Expired Location'); + }); + + it('should respect custom hours threshold', async () => { + const activeLocations = await locationModel.getActive(1); // 1 hour threshold + + expect(activeLocations).toHaveLength(1); + expect(activeLocations[0].address).toBe('Persistent Location'); + }); + }); + + describe('getAll', () => { + beforeEach(async () => { + await locationModel.create({ address: 'Location 1' }); + await locationModel.create({ address: 'Location 2' }); + await locationModel.create({ address: 'Location 3' }); + }); + + it('should return all locations', async () => { + const allLocations = await locationModel.getAll(); + + expect(allLocations).toHaveLength(3); + expect(allLocations.map(l => l.address)).toEqual( + expect.arrayContaining(['Location 1', 'Location 2', 'Location 3']) + ); + }); + + it('should return locations in reverse chronological order', async () => { + const allLocations = await locationModel.getAll(); + + // Check that we have all locations and they're ordered by created_at DESC + expect(allLocations).toHaveLength(3); + + // The query uses ORDER BY created_at DESC, so the most recent should be first + // Since they're created in the same moment, check that ordering is consistent + expect(allLocations[0]).toHaveProperty('id'); + expect(allLocations[1]).toHaveProperty('id'); + expect(allLocations[2]).toHaveProperty('id'); + }); + }); + + describe('update', () => { + let locationId: number; + + beforeEach(async () => { + const location = await locationModel.create({ + address: 'Original Address', + description: 'Original Description' + }); + locationId = location.id; + }); + + it('should update a location successfully', async () => { + const updateData = { + address: 'Updated Address', + latitude: 42.9634, + longitude: -85.6681, + description: 'Updated Description' + }; + + const result = await locationModel.update(locationId, updateData); + + expect(result.changes).toBe(1); + }); + + it('should return 0 changes for non-existent location', async () => { + const updateData = { + address: 'Updated Address' + }; + + const result = await locationModel.update(99999, updateData); + + expect(result.changes).toBe(0); + }); + }); + + describe('togglePersistent', () => { + let locationId: number; + + beforeEach(async () => { + const location = await locationModel.create({ + address: 'Test Location' + }); + locationId = location.id; + }); + + it('should toggle persistent to true', async () => { + const result = await locationModel.togglePersistent(locationId, true); + + expect(result.changes).toBe(1); + }); + + it('should toggle persistent to false', async () => { + const result = await locationModel.togglePersistent(locationId, false); + + expect(result.changes).toBe(1); + }); + + it('should return 0 changes for non-existent location', async () => { + const result = await locationModel.togglePersistent(99999, true); + + expect(result.changes).toBe(0); + }); + }); + + describe('delete', () => { + let locationId: number; + + beforeEach(async () => { + const location = await locationModel.create({ + address: 'To Be Deleted' + }); + locationId = location.id; + }); + + it('should delete a location successfully', async () => { + const result = await locationModel.delete(locationId); + + expect(result.changes).toBe(1); + }); + + it('should return 0 changes for non-existent location', async () => { + const result = await locationModel.delete(99999); + + expect(result.changes).toBe(0); + }); + }); + + describe('cleanupExpired', () => { + beforeEach(async () => { + const now = new Date(); + const fiftyHoursAgo = new Date(now.getTime() - 50 * 60 * 60 * 1000); + + // Expired non-persistent location + await new Promise((resolve, reject) => { + db.run( + 'INSERT INTO locations (address, created_at, persistent) VALUES (?, ?, ?)', + ['Expired Non-Persistent', fiftyHoursAgo.toISOString(), 0], + (err) => err ? reject(err) : resolve() + ); + }); + + // Expired persistent location + await new Promise((resolve, reject) => { + db.run( + 'INSERT INTO locations (address, created_at, persistent) VALUES (?, ?, ?)', + ['Expired Persistent', fiftyHoursAgo.toISOString(), 1], + (err) => err ? reject(err) : resolve() + ); + }); + + // Recent location + await locationModel.create({ address: 'Recent Location' }); + }); + + it('should delete only expired non-persistent locations', async () => { + const result = await locationModel.cleanupExpired(); + + expect(result.changes).toBe(1); + + const remaining = await locationModel.getAll(); + expect(remaining).toHaveLength(2); + expect(remaining.map(l => l.address)).toContain('Expired Persistent'); + expect(remaining.map(l => l.address)).toContain('Recent Location'); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/models/ProfanityWord.test.ts b/tests/unit/models/ProfanityWord.test.ts new file mode 100644 index 0000000..b41d9c9 --- /dev/null +++ b/tests/unit/models/ProfanityWord.test.ts @@ -0,0 +1,193 @@ +import ProfanityWord from '../../../src/models/ProfanityWord'; +import { createTestProfanityDatabase } from '../../setup'; +import { Database } from 'sqlite3'; + +describe('ProfanityWord Model', () => { + let db: Database; + let profanityWordModel: ProfanityWord; + + beforeEach(async () => { + db = await createTestProfanityDatabase(); + profanityWordModel = new ProfanityWord(db); + }); + + afterEach((done) => { + db.close(done); + }); + + describe('create', () => { + it('should create a profanity word with all fields', async () => { + const result = await profanityWordModel.create('badword', 'high', 'offensive', 'test_admin'); + + expect(result).toMatchObject({ + id: 1, + word: 'badword', + severity: 'high', + category: 'offensive' + }); + }); + + it('should create a profanity word with default createdBy', async () => { + const result = await profanityWordModel.create('testword', 'medium', 'general'); + + expect(result).toMatchObject({ + id: 1, + word: 'testword', + severity: 'medium', + category: 'general' + }); + }); + + it('should convert word to lowercase', async () => { + const result = await profanityWordModel.create('UPPERCASE', 'low', 'test'); + + expect(result.word).toBe('uppercase'); + }); + + it('should handle different severity levels', async () => { + const lowResult = await profanityWordModel.create('word1', 'low', 'test'); + const mediumResult = await profanityWordModel.create('word2', 'medium', 'test'); + const highResult = await profanityWordModel.create('word3', 'high', 'test'); + + expect(lowResult.severity).toBe('low'); + expect(mediumResult.severity).toBe('medium'); + expect(highResult.severity).toBe('high'); + }); + }); + + describe('getAll', () => { + beforeEach(async () => { + await profanityWordModel.create('word1', 'low', 'category1', 'admin1'); + await profanityWordModel.create('word2', 'medium', 'category2', 'admin2'); + await profanityWordModel.create('word3', 'high', 'category1', 'admin1'); + }); + + it('should return all profanity words', async () => { + const words = await profanityWordModel.getAll(); + + expect(words).toHaveLength(3); + expect(words.map(w => w.word)).toEqual( + expect.arrayContaining(['word1', 'word2', 'word3']) + ); + }); + + it('should return words in reverse chronological order', async () => { + const words = await profanityWordModel.getAll(); + + // Check that words are returned in consistent order + expect(words).toHaveLength(3); + expect(words.map(w => w.word)).toEqual( + expect.arrayContaining(['word1', 'word2', 'word3']) + ); + }); + + it('should include all required fields', async () => { + const words = await profanityWordModel.getAll(); + + words.forEach(word => { + expect(word).toHaveProperty('id'); + expect(word).toHaveProperty('word'); + expect(word).toHaveProperty('severity'); + expect(word).toHaveProperty('category'); + expect(word).toHaveProperty('created_at'); + expect(word).toHaveProperty('created_by'); + }); + }); + }); + + describe('loadWords', () => { + beforeEach(async () => { + await profanityWordModel.create('loadword1', 'low', 'test'); + await profanityWordModel.create('loadword2', 'high', 'offensive'); + }); + + it('should return words with minimal fields', async () => { + const words = await profanityWordModel.loadWords(); + + expect(words).toHaveLength(2); + words.forEach(word => { + expect(word).toHaveProperty('word'); + expect(word).toHaveProperty('severity'); + expect(word).toHaveProperty('category'); + expect(word).not.toHaveProperty('id'); + expect(word).not.toHaveProperty('created_at'); + }); + }); + }); + + describe('update', () => { + let wordId: number; + + beforeEach(async () => { + const result = await profanityWordModel.create('originalword', 'low', 'original'); + wordId = result.id; + }); + + it('should update a profanity word successfully', async () => { + const result = await profanityWordModel.update(wordId, 'updatedword', 'high', 'updated'); + + expect(result.changes).toBe(1); + }); + + it('should convert updated word to lowercase', async () => { + await profanityWordModel.update(wordId, 'UPDATED', 'medium', 'test'); + + const words = await profanityWordModel.getAll(); + const updatedWord = words.find(w => w.id === wordId); + expect(updatedWord?.word).toBe('updated'); + }); + + it('should return 0 changes for non-existent word', async () => { + const result = await profanityWordModel.update(99999, 'nonexistent', 'low', 'test'); + + expect(result.changes).toBe(0); + }); + }); + + describe('delete', () => { + let wordId: number; + + beforeEach(async () => { + const result = await profanityWordModel.create('tobedeleted', 'medium', 'test'); + wordId = result.id; + }); + + it('should delete a profanity word successfully', async () => { + const result = await profanityWordModel.delete(wordId); + + expect(result.changes).toBe(1); + }); + + it('should return 0 changes for non-existent word', async () => { + const result = await profanityWordModel.delete(99999); + + expect(result.changes).toBe(0); + }); + + it('should actually remove the word from database', async () => { + await profanityWordModel.delete(wordId); + + const words = await profanityWordModel.getAll(); + expect(words.find(w => w.id === wordId)).toBeUndefined(); + }); + }); + + describe('database constraints', () => { + it('should enforce unique constraint on words', async () => { + await profanityWordModel.create('duplicate', 'low', 'test'); + + await expect( + profanityWordModel.create('duplicate', 'high', 'test') + ).rejects.toThrow(); + }); + + it('should handle case insensitive uniqueness', async () => { + await profanityWordModel.create('CaseTest', 'low', 'test'); + + // This should fail because 'casetest' already exists (stored as lowercase) + await expect( + profanityWordModel.create('CASETEST', 'high', 'test') + ).rejects.toThrow(); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/services/ProfanityFilterService.test.ts b/tests/unit/services/ProfanityFilterService.test.ts new file mode 100644 index 0000000..c4b49dc --- /dev/null +++ b/tests/unit/services/ProfanityFilterService.test.ts @@ -0,0 +1,274 @@ +import ProfanityFilterService from '../../../src/services/ProfanityFilterService'; +import ProfanityWord from '../../../src/models/ProfanityWord'; +import { createTestProfanityDatabase } from '../../setup'; +import { Database } from 'sqlite3'; + +describe('ProfanityFilterService', () => { + let db: Database; + let profanityWordModel: ProfanityWord; + let profanityFilter: ProfanityFilterService; + + beforeEach(async () => { + db = await createTestProfanityDatabase(); + profanityWordModel = new ProfanityWord(db); + profanityFilter = new ProfanityFilterService(profanityWordModel); + await profanityFilter.initialize(); + }); + + afterEach((done) => { + db.close(done); + }); + + describe('initialization', () => { + it('should initialize successfully', async () => { + const newFilter = new ProfanityFilterService(profanityWordModel); + await expect(newFilter.initialize()).resolves.not.toThrow(); + }); + + it('should load custom words during initialization', async () => { + await profanityWordModel.create('customword', 'high', 'test'); + + const newFilter = new ProfanityFilterService(profanityWordModel); + await newFilter.initialize(); + + expect(newFilter.containsProfanity('customword')).toBe(true); + }); + }); + + describe('containsProfanity', () => { + it('should detect base profanity words', () => { + expect(profanityFilter.containsProfanity('damn')).toBe(true); + expect(profanityFilter.containsProfanity('hell')).toBe(true); + }); + + it('should not detect clean text', () => { + expect(profanityFilter.containsProfanity('hello world')).toBe(false); + expect(profanityFilter.containsProfanity('this is clean text')).toBe(false); + }); + + it('should be case insensitive', () => { + expect(profanityFilter.containsProfanity('DAMN')).toBe(true); + expect(profanityFilter.containsProfanity('Damn')).toBe(true); + expect(profanityFilter.containsProfanity('DaMn')).toBe(true); + }); + + it('should handle empty or null input', () => { + expect(profanityFilter.containsProfanity('')).toBe(false); + expect(profanityFilter.containsProfanity(null as any)).toBe(false); + expect(profanityFilter.containsProfanity(undefined as any)).toBe(false); + }); + + it('should detect profanity in sentences', () => { + expect(profanityFilter.containsProfanity('This is damn cold outside')).toBe(true); + expect(profanityFilter.containsProfanity('What the hell is happening')).toBe(true); + }); + }); + + describe('analyzeProfanity', () => { + it('should analyze clean text correctly', () => { + const result = profanityFilter.analyzeProfanity('This is clean text'); + + expect(result).toMatchObject({ + hasProfanity: false, + matches: [], + severity: 'none', + count: 0, + filtered: 'This is clean text' + }); + }); + + it('should analyze profane text correctly', () => { + const result = profanityFilter.analyzeProfanity('This is damn bad'); + + expect(result.hasProfanity).toBe(true); + expect(result.matches.length).toBeGreaterThan(0); + expect(result.severity).not.toBe('none'); + expect(result.count).toBeGreaterThan(0); + expect(result.filtered).toContain('*'); + }); + + it('should provide detailed match information', () => { + const result = profanityFilter.analyzeProfanity('damn'); + + expect(result.matches[0]).toHaveProperty('word'); + expect(result.matches[0]).toHaveProperty('found'); + expect(result.matches[0]).toHaveProperty('index'); + expect(result.matches[0]).toHaveProperty('severity'); + expect(result.matches[0]).toHaveProperty('category'); + }); + + it('should determine severity levels correctly', () => { + const lowResult = profanityFilter.analyzeProfanity('damn'); + const mediumResult = profanityFilter.analyzeProfanity('shit'); + + expect(['low', 'medium']).toContain(lowResult.severity); + expect(['medium', 'high']).toContain(mediumResult.severity); + }); + + it('should handle multiple profanity words', () => { + const result = profanityFilter.analyzeProfanity('damn and hell'); + + expect(result.count).toBeGreaterThanOrEqual(2); + expect(result.matches.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('filterProfanity', () => { + it('should filter out profanity with asterisks', () => { + const filtered = profanityFilter.filterProfanity('This is damn bad'); + + expect(filtered).toContain('*'); + expect(filtered).not.toContain('damn'); + }); + + it('should leave clean text unchanged', () => { + const text = 'This is perfectly clean text'; + const filtered = profanityFilter.filterProfanity(text); + + expect(filtered).toBe(text); + }); + + it('should handle empty input', () => { + expect(profanityFilter.filterProfanity('')).toBe(''); + expect(profanityFilter.filterProfanity(null as any)).toBe(''); + }); + }); + + describe('custom word management', () => { + it('should add custom words', async () => { + const result = await profanityFilter.addCustomWord('badword', 'high', 'test'); + + expect(result).toHaveProperty('id'); + expect(result.word).toBe('badword'); + expect(result.severity).toBe('high'); + expect(result.category).toBe('test'); + }); + + it('should detect newly added custom words', async () => { + await profanityFilter.addCustomWord('newbadword', 'medium', 'test'); + + expect(profanityFilter.containsProfanity('newbadword')).toBe(true); + }); + + it('should prevent duplicate words', async () => { + await profanityFilter.addCustomWord('duplicate', 'low', 'test'); + + await expect( + profanityFilter.addCustomWord('duplicate', 'high', 'test') + ).rejects.toThrow('Word already exists in the filter'); + }); + + it('should remove custom words', async () => { + const added = await profanityFilter.addCustomWord('removeme', 'low', 'test'); + + const result = await profanityFilter.removeCustomWord(added.id); + + expect(result.deleted).toBe(true); + expect(result.changes).toBe(1); + expect(profanityFilter.containsProfanity('removeme')).toBe(false); + }); + + it('should handle removing non-existent words', async () => { + await expect( + profanityFilter.removeCustomWord(99999) + ).rejects.toThrow('Word not found'); + }); + + it('should update custom words', async () => { + const added = await profanityFilter.addCustomWord('updateme', 'low', 'test'); + + const result = await profanityFilter.updateCustomWord(added.id, { + word: 'updated', + severity: 'high', + category: 'updated' + }); + + expect(result.updated).toBe(true); + expect(result.changes).toBe(1); + expect(profanityFilter.containsProfanity('updated')).toBe(true); + expect(profanityFilter.containsProfanity('updateme')).toBe(false); + }); + + it('should get all custom words', async () => { + await profanityFilter.addCustomWord('word1', 'low', 'test'); + await profanityFilter.addCustomWord('word2', 'high', 'test'); + + const words = await profanityFilter.getCustomWords(); + + expect(words.length).toBeGreaterThanOrEqual(2); + expect(words.map(w => w.word)).toContain('word1'); + expect(words.map(w => w.word)).toContain('word2'); + }); + }); + + describe('text normalization', () => { + it('should normalize text correctly', () => { + const normalized = profanityFilter.normalizeText('Hello World!!!'); + + expect(typeof normalized).toBe('string'); + expect(normalized.length).toBeGreaterThan(0); + }); + + it('should handle special characters', () => { + const normalized = profanityFilter.normalizeText('h3ll0 w0rld'); + + expect(normalized).toContain('hello world'); + }); + + it('should handle empty input', () => { + expect(profanityFilter.normalizeText('')).toBe(''); + expect(profanityFilter.normalizeText(null as any)).toBe(''); + }); + }); + + describe('severity and category helpers', () => { + it('should get severity for words', () => { + const severity = profanityFilter.getSeverity('damn'); + + expect(['low', 'medium', 'high']).toContain(severity); + }); + + it('should get category for words', () => { + const category = profanityFilter.getCategory('damn'); + + expect(typeof category).toBe('string'); + expect(category.length).toBeGreaterThan(0); + }); + + it('should return default values for unknown words', () => { + const severity = profanityFilter.getSeverity('unknownword'); + const category = profanityFilter.getCategory('unknownword'); + + expect(['low', 'medium', 'high']).toContain(severity); + expect(typeof category).toBe('string'); + }); + }); + + describe('utility methods', () => { + it('should get all words', () => { + const words = profanityFilter.getAllWords(); + + expect(Array.isArray(words)).toBe(true); + expect(words.length).toBeGreaterThan(0); + }); + + it('should get severity level as number', () => { + const level = profanityFilter.getSeverityLevel(); + + expect(typeof level).toBe('number'); + }); + + it('should get severity name', () => { + const name = profanityFilter.getSeverityName(); + + expect(typeof name).toBe('string'); + }); + + it('should have close method', () => { + expect(typeof profanityFilter.close).toBe('function'); + + // Should not throw + profanityFilter.close(); + }); + }); +}); \ No newline at end of file