From 614abeb196c12da5efb5ec1e3e9ed55f887219af Mon Sep 17 00:00:00 2001 From: soufiane Date: Thu, 27 Nov 2025 11:23:43 +0100 Subject: [PATCH] test: add comprehensive unit and integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend Tests Added: - Unit tests for helpers.js (tokens, validation, pagination) - Unit tests for middleware (auth, errorHandler, validate) - Integration tests for auth endpoints - Integration tests for game endpoints - Integration tests for admin endpoints - Integration tests for employee endpoints - Integration tests for draw endpoints - Integration tests for newsletter/contact endpoints Also added: - cross-env for Windows compatibility - Test scripts update đŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- grafana-http-panel.json | 259 ++++++++++++++++++++++++++++ package-lock.json | 26 +++ package.json | 4 +- prometheus-dev.yml | 21 +++ test/integration/admin.test.js | 160 +++++++++++++++++ test/integration/auth.test.js | 156 +++++++++++++++++ test/integration/draw.test.js | 120 +++++++++++++ test/integration/employee.test.js | 116 +++++++++++++ test/integration/game.test.js | 90 ++++++++++ test/integration/newsletter.test.js | 119 +++++++++++++ test/unit/helpers.test.js | 213 +++++++++++++++++++++++ test/unit/middleware.test.js | 217 +++++++++++++++++++++++ 12 files changed, 1500 insertions(+), 1 deletion(-) create mode 100644 grafana-http-panel.json create mode 100644 prometheus-dev.yml create mode 100644 test/integration/admin.test.js create mode 100644 test/integration/auth.test.js create mode 100644 test/integration/draw.test.js create mode 100644 test/integration/employee.test.js create mode 100644 test/integration/game.test.js create mode 100644 test/integration/newsletter.test.js create mode 100644 test/unit/helpers.test.js create mode 100644 test/unit/middleware.test.js diff --git a/grafana-http-panel.json b/grafana-http-panel.json new file mode 100644 index 00000000..dfab84f1 --- /dev/null +++ b/grafana-http-panel.json @@ -0,0 +1,259 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 15, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + }, + "unit": "reqps" + }, + "overrides": [ + { + "matcher": { "id": "byRegexp", "options": ".*Frontend.*" }, + "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byRegexp", "options": ".*Backend.*" }, + "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] + } + ] + }, + "gridPos": { "h": 10, "w": 24, "x": 0, "y": 0 }, + "id": 1, + "options": { + "legend": { + "calcs": ["mean", "max", "sum"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "expr": "sum(rate(http_requests_total{job=\"backend\"}[1m]))", + "legendFormat": "Backend - Total", + "refId": "A" + }, + { + "expr": "sum(rate(http_requests_total{job=\"frontend\"}[1m]))", + "legendFormat": "Frontend - Total", + "refId": "B" + } + ], + "title": "Historique HTTP - RequĂȘtes/sec", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "normal" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { "id": "byRegexp", "options": ".*2[0-9]{2}.*" }, + "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byRegexp", "options": ".*3[0-9]{2}.*" }, + "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byRegexp", "options": ".*4[0-9]{2}.*" }, + "properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byRegexp", "options": ".*5[0-9]{2}.*" }, + "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] + } + ] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 10 }, + "id": 2, + "options": { + "legend": { + "calcs": ["sum"], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "expr": "sum(increase(http_requests_total{job=\"backend\"}[5m])) by (status_code)", + "legendFormat": "Backend {{status_code}}", + "refId": "A" + } + ], + "title": "Backend - HTTP par Status Code", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "normal" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { "id": "byRegexp", "options": ".*2[0-9]{2}.*" }, + "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byRegexp", "options": ".*3[0-9]{2}.*" }, + "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byRegexp", "options": ".*4[0-9]{2}.*" }, + "properties": [{ "id": "color", "value": { "fixedColor": "yellow", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byRegexp", "options": ".*5[0-9]{2}.*" }, + "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] + } + ] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 10 }, + "id": 3, + "options": { + "legend": { + "calcs": ["sum"], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "expr": "sum(increase(http_requests_total{job=\"frontend\"}[5m])) by (status_code)", + "legendFormat": "Frontend {{status_code}}", + "refId": "A" + } + ], + "title": "Frontend - HTTP par Status Code", + "type": "timeseries" + } + ], + "refresh": "10s", + "schemaVersion": 38, + "style": "dark", + "tags": ["http", "thetiptop"], + "templating": { + "list": [ + { + "current": {}, + "hide": 0, + "includeAll": false, + "label": "Prometheus", + "multi": false, + "name": "DS_PROMETHEUS", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + } + ] + }, + "time": { "from": "now-1h", "to": "now" }, + "timepicker": {}, + "timezone": "browser", + "title": "Historique HTTP - TheTipTop", + "uid": "historique-http-thetiptop", + "version": 1 +} diff --git a/package-lock.json b/package-lock.json index 7eff5698..fe7d18a7 100755 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.0", + "cross-env": "^10.1.0", "eslint": "^9.39.0", "jest": "^30.2.0", "nodemon": "^3.1.10", @@ -567,6 +568,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", @@ -2627,6 +2635,24 @@ "node": ">= 0.10" } }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/package.json b/package.json index 66eb81e7..6d20fe65 100755 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "start": "node index.js", "dev": "nodemon index.js", "lint": "eslint .", - "test": "NODE_OPTIONS=--experimental-vm-modules jest", + "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest", + "test:win": "set NODE_OPTIONS=--experimental-vm-modules && jest", "test:integration": "NODE_OPTIONS=--experimental-vm-modules jest --config jest.integration.config.js", "db:schema": "psql -h 51.75.24.29 -U postgres -d thetiptop_dev -p 5433 -f database/schema.sql", "db:create": "node scripts/create-tables.js", @@ -44,6 +45,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.0", + "cross-env": "^10.1.0", "eslint": "^9.39.0", "jest": "^30.2.0", "nodemon": "^3.1.10", diff --git a/prometheus-dev.yml b/prometheus-dev.yml new file mode 100644 index 00000000..d6fb7637 --- /dev/null +++ b/prometheus-dev.yml @@ -0,0 +1,21 @@ +global: + scrape_interval: 5s + +scrape_configs: + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + - job_name: 'backend-dev' + metrics_path: /metrics + static_configs: + - targets: ['the-tip-top-backend-dev:4000'] + + - job_name: 'frontend-dev' + metrics_path: /api/metrics + static_configs: + - targets: ['the-tip-top-frontend-dev:3000'] + + - job_name: 'node-exporter' + static_configs: + - targets: ['node-exporter:9100'] diff --git a/test/integration/admin.test.js b/test/integration/admin.test.js new file mode 100644 index 00000000..47eb14f1 --- /dev/null +++ b/test/integration/admin.test.js @@ -0,0 +1,160 @@ +/** + * Tests d'intĂ©gration pour les endpoints admin + */ +import request from 'supertest'; +import app from '../../index.js'; + +describe('Admin API', () => { + describe('GET /api/admin/statistics', () => { + it('should reject request without authentication', async () => { + const res = await request(app).get('/api/admin/statistics'); + + expect(res.statusCode).toBe(401); + expect(res.body.success).toBe(false); + }); + + it('should reject request with non-admin token', async () => { + const res = await request(app) + .get('/api/admin/statistics') + .set('Authorization', 'Bearer invalid-token'); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('Prize Management', () => { + describe('GET /api/admin/prizes', () => { + it('should reject request without authentication', async () => { + const res = await request(app).get('/api/admin/prizes'); + + expect(res.statusCode).toBe(401); + expect(res.body.success).toBe(false); + }); + }); + + describe('POST /api/admin/prizes', () => { + it('should reject request without authentication', async () => { + const res = await request(app) + .post('/api/admin/prizes') + .send({ + name: 'Test Prize', + type: 'INFUSEUR', + value: 10, + probability: 0.1, + stock: 100, + }); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('PUT /api/admin/prizes/:id', () => { + it('should reject request without authentication', async () => { + const res = await request(app) + .put('/api/admin/prizes/1') + .send({ name: 'Updated Prize' }); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('DELETE /api/admin/prizes/:id', () => { + it('should reject request without authentication', async () => { + const res = await request(app).delete('/api/admin/prizes/1'); + + expect(res.statusCode).toBe(401); + }); + }); + }); + + describe('User Management', () => { + describe('GET /api/admin/users', () => { + it('should reject request without authentication', async () => { + const res = await request(app).get('/api/admin/users'); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('GET /api/admin/users/:id', () => { + it('should reject request without authentication', async () => { + const res = await request(app).get('/api/admin/users/1'); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('POST /api/admin/employees', () => { + it('should reject request without authentication', async () => { + const res = await request(app) + .post('/api/admin/employees') + .send({ + email: 'employee@example.com', + firstName: 'John', + lastName: 'Doe', + password: 'Password123!', + }); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('PUT /api/admin/users/:id', () => { + it('should reject request without authentication', async () => { + const res = await request(app) + .put('/api/admin/users/1') + .send({ role: 'EMPLOYEE' }); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('DELETE /api/admin/users/:id', () => { + it('should reject request without authentication', async () => { + const res = await request(app).delete('/api/admin/users/1'); + + expect(res.statusCode).toBe(401); + }); + }); + }); + + describe('Ticket Management', () => { + describe('GET /api/admin/tickets', () => { + it('should reject request without authentication', async () => { + const res = await request(app).get('/api/admin/tickets'); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('POST /api/admin/generate-tickets', () => { + it('should reject request without authentication', async () => { + const res = await request(app) + .post('/api/admin/generate-tickets') + .send({ count: 100 }); + + expect(res.statusCode).toBe(401); + }); + }); + }); + + describe('Marketing', () => { + describe('GET /api/admin/marketing/stats', () => { + it('should reject request without authentication', async () => { + const res = await request(app).get('/api/admin/marketing/stats'); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('POST /api/admin/marketing/export', () => { + it('should reject request without authentication', async () => { + const res = await request(app) + .post('/api/admin/marketing/export') + .send({ segment: 'winners' }); + + expect(res.statusCode).toBe(401); + }); + }); + }); +}); diff --git a/test/integration/auth.test.js b/test/integration/auth.test.js new file mode 100644 index 00000000..4ecdce99 --- /dev/null +++ b/test/integration/auth.test.js @@ -0,0 +1,156 @@ +/** + * Tests d'intĂ©gration pour les endpoints d'authentification + */ +import request from 'supertest'; +import app from '../../index.js'; + +describe('Auth API', () => { + describe('POST /api/auth/register', () => { + it('should reject registration with missing fields', async () => { + const res = await request(app) + .post('/api/auth/register') + .send({ + email: 'test@example.com', + }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('should reject registration with invalid email', async () => { + const res = await request(app) + .post('/api/auth/register') + .send({ + email: 'invalid-email', + password: 'Password123!', + firstName: 'John', + lastName: 'Doe', + }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('should reject registration with weak password', async () => { + const res = await request(app) + .post('/api/auth/register') + .send({ + email: 'test@example.com', + password: '123', + firstName: 'John', + lastName: 'Doe', + }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + }); + + describe('POST /api/auth/login', () => { + it('should reject login with missing credentials', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({}); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('should reject login with invalid credentials', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({ + email: 'nonexistent@example.com', + password: 'wrongpassword', + }); + + expect(res.statusCode).toBeGreaterThanOrEqual(400); + expect(res.body.success).toBe(false); + }); + }); + + describe('GET /api/auth/me', () => { + it('should reject request without token', async () => { + const res = await request(app).get('/api/auth/me'); + + expect(res.statusCode).toBe(401); + expect(res.body.success).toBe(false); + }); + + it('should reject request with invalid token', async () => { + const res = await request(app) + .get('/api/auth/me') + .set('Authorization', 'Bearer invalid-token'); + + expect(res.statusCode).toBe(401); + expect(res.body.success).toBe(false); + }); + }); + + describe('POST /api/auth/forgot-password', () => { + it('should reject with invalid email format', async () => { + const res = await request(app) + .post('/api/auth/forgot-password') + .send({ + email: 'invalid-email', + }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + }); + + describe('POST /api/auth/reset-password', () => { + it('should reject with missing token', async () => { + const res = await request(app) + .post('/api/auth/reset-password') + .send({ + password: 'NewPassword123!', + }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('should reject with invalid token', async () => { + const res = await request(app) + .post('/api/auth/reset-password') + .send({ + token: 'invalid-token', + password: 'NewPassword123!', + }); + + expect(res.statusCode).toBeGreaterThanOrEqual(400); + }); + }); + + describe('GET /api/auth/verify-email/:token', () => { + it('should reject with invalid token', async () => { + const res = await request(app).get('/api/auth/verify-email/invalid-token'); + + expect(res.statusCode).toBeGreaterThanOrEqual(400); + }); + }); + + describe('POST /api/auth/google', () => { + it('should reject with missing token', async () => { + const res = await request(app) + .post('/api/auth/google') + .send({}); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + }); + + describe('POST /api/auth/facebook', () => { + it('should reject with missing token', async () => { + const res = await request(app) + .post('/api/auth/facebook') + .send({}); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + }); +}); diff --git a/test/integration/draw.test.js b/test/integration/draw.test.js new file mode 100644 index 00000000..1d517e5d --- /dev/null +++ b/test/integration/draw.test.js @@ -0,0 +1,120 @@ +/** + * Tests d'intĂ©gration pour les endpoints de tirage au sort + */ +import request from 'supertest'; +import app from '../../index.js'; + +describe('Draw API', () => { + describe('GET /api/draw/eligible-participants', () => { + it('should reject request without authentication', async () => { + const res = await request(app).get('/api/draw/eligible-participants'); + + expect(res.statusCode).toBe(401); + expect(res.body.success).toBe(false); + }); + + it('should reject request with non-admin token', async () => { + const res = await request(app) + .get('/api/draw/eligible-participants') + .set('Authorization', 'Bearer invalid-token'); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('GET /api/draw/check-existing', () => { + it('should reject request without authentication', async () => { + const res = await request(app).get('/api/draw/check-existing'); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('POST /api/draw/conduct', () => { + it('should reject request without authentication', async () => { + const res = await request(app) + .post('/api/draw/conduct') + .send({ + criteria: {}, + prizeName: 'Grand Prize', + prizeValue: 1000, + }); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('GET /api/draw/history', () => { + it('should reject request without authentication', async () => { + const res = await request(app).get('/api/draw/history'); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('PUT /api/draw/:id/notify', () => { + it('should reject request without authentication', async () => { + const res = await request(app).put('/api/draw/1/notify'); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('PUT /api/draw/:id/claim', () => { + it('should reject request without authentication', async () => { + const res = await request(app).put('/api/draw/1/claim'); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('GET /api/draw/:id/report', () => { + it('should reject request without authentication', async () => { + const res = await request(app).get('/api/draw/1/report'); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('DELETE /api/draw/:id', () => { + it('should reject request without authentication', async () => { + const res = await request(app).delete('/api/draw/1'); + + expect(res.statusCode).toBe(401); + }); + }); +}); + +describe('Draw Business Logic', () => { + describe('Eligibility Criteria', () => { + it('should define valid eligibility rules', () => { + // A participant is eligible if they have at least one CLAIMED ticket + const eligibilityRules = { + minClaimedTickets: 1, + ticketStatus: 'CLAIMED', + }; + + expect(eligibilityRules.minClaimedTickets).toBeGreaterThan(0); + expect(eligibilityRules.ticketStatus).toBe('CLAIMED'); + }); + }); + + describe('Random Selection', () => { + it('should select one winner from eligible participants', () => { + const participants = [ + { id: 1, name: 'User 1' }, + { id: 2, name: 'User 2' }, + { id: 3, name: 'User 3' }, + ]; + + const selectWinner = (participants) => { + const randomIndex = Math.floor(Math.random() * participants.length); + return participants[randomIndex]; + }; + + const winner = selectWinner(participants); + + expect(participants).toContainEqual(winner); + }); + }); +}); diff --git a/test/integration/employee.test.js b/test/integration/employee.test.js new file mode 100644 index 00000000..35885555 --- /dev/null +++ b/test/integration/employee.test.js @@ -0,0 +1,116 @@ +/** + * Tests d'intĂ©gration pour les endpoints employĂ© + */ +import request from 'supertest'; +import app from '../../index.js'; + +describe('Employee API', () => { + describe('GET /api/employee/pending-tickets', () => { + it('should reject request without authentication', async () => { + const res = await request(app).get('/api/employee/pending-tickets'); + + expect(res.statusCode).toBe(401); + expect(res.body.success).toBe(false); + }); + + it('should reject request with invalid token', async () => { + const res = await request(app) + .get('/api/employee/pending-tickets') + .set('Authorization', 'Bearer invalid-token'); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('GET /api/employee/search-ticket', () => { + it('should reject request without authentication', async () => { + const res = await request(app) + .get('/api/employee/search-ticket') + .query({ code: 'TTP2025ABC' }); + + expect(res.statusCode).toBe(401); + }); + + it('should reject request without search query', async () => { + const res = await request(app) + .get('/api/employee/search-ticket') + .set('Authorization', 'Bearer invalid-token'); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('POST /api/employee/validate-ticket', () => { + it('should reject request without authentication', async () => { + const res = await request(app) + .post('/api/employee/validate-ticket') + .send({ + ticketId: 1, + status: 'CLAIMED', + }); + + expect(res.statusCode).toBe(401); + }); + + it('should require ticketId and status', async () => { + const res = await request(app) + .post('/api/employee/validate-ticket') + .set('Authorization', 'Bearer invalid-token') + .send({}); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('GET /api/employee/stats', () => { + it('should reject request without authentication', async () => { + const res = await request(app).get('/api/employee/stats'); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('GET /api/employee/client-prizes', () => { + it('should reject request without authentication', async () => { + const res = await request(app) + .get('/api/employee/client-prizes') + .query({ email: 'client@example.com' }); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('GET /api/employee/history', () => { + it('should reject request without authentication', async () => { + const res = await request(app).get('/api/employee/history'); + + expect(res.statusCode).toBe(401); + }); + }); +}); + +describe('Employee Ticket Validation', () => { + describe('Status Transitions', () => { + it('should define valid ticket statuses', () => { + const validStatuses = ['PENDING', 'CLAIMED', 'REJECTED']; + + validStatuses.forEach((status) => { + expect(['PENDING', 'CLAIMED', 'REJECTED']).toContain(status); + }); + }); + + it('should have valid status transitions', () => { + // PENDING can go to CLAIMED or REJECTED + const transitions = { + PENDING: ['CLAIMED', 'REJECTED'], + CLAIMED: [], // Final state + REJECTED: [], // Final state + }; + + expect(transitions.PENDING).toContain('CLAIMED'); + expect(transitions.PENDING).toContain('REJECTED'); + expect(transitions.CLAIMED).toHaveLength(0); + expect(transitions.REJECTED).toHaveLength(0); + }); + }); +}); diff --git a/test/integration/game.test.js b/test/integration/game.test.js new file mode 100644 index 00000000..e586c6cb --- /dev/null +++ b/test/integration/game.test.js @@ -0,0 +1,90 @@ +/** + * Tests d'intĂ©gration pour les endpoints du jeu + */ +import request from 'supertest'; +import app from '../../index.js'; + +describe('Game API', () => { + describe('POST /api/game/play', () => { + it('should reject request without authentication', async () => { + const res = await request(app) + .post('/api/game/play') + .send({ ticketCode: 'TTP2025ABC' }); + + expect(res.statusCode).toBe(401); + expect(res.body.success).toBe(false); + }); + + it('should reject request with invalid token', async () => { + const res = await request(app) + .post('/api/game/play') + .set('Authorization', 'Bearer invalid-token') + .send({ ticketCode: 'TTP2025ABC' }); + + expect(res.statusCode).toBe(401); + expect(res.body.success).toBe(false); + }); + + it('should reject request without ticket code', async () => { + const res = await request(app) + .post('/api/game/play') + .set('Authorization', 'Bearer invalid-token') + .send({}); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('GET /api/game/my-tickets', () => { + it('should reject request without authentication', async () => { + const res = await request(app).get('/api/game/my-tickets'); + + expect(res.statusCode).toBe(401); + expect(res.body.success).toBe(false); + }); + + it('should reject request with invalid token', async () => { + const res = await request(app) + .get('/api/game/my-tickets') + .set('Authorization', 'Bearer invalid-token'); + + expect(res.statusCode).toBe(401); + expect(res.body.success).toBe(false); + }); + }); + + describe('GET /api/game/my-tickets/:id', () => { + it('should reject request without authentication', async () => { + const res = await request(app).get('/api/game/my-tickets/1'); + + expect(res.statusCode).toBe(401); + expect(res.body.success).toBe(false); + }); + }); + + describe('GET /api/game/ticket/:code', () => { + it('should reject request without authentication', async () => { + const res = await request(app).get('/api/game/ticket/TTP2025ABC'); + + expect(res.statusCode).toBe(401); + expect(res.body.success).toBe(false); + }); + }); +}); + +describe('Game Validation', () => { + describe('Ticket Code Format', () => { + it('should have valid ticket code format TTP + year + 3 chars', () => { + const validCodes = ['TTP2025ABC', 'TTP2025XYZ', 'TTP2025123']; + const invalidCodes = ['INVALID', 'TT2025ABC', 'TTP25ABC', '']; + + validCodes.forEach((code) => { + expect(code).toMatch(/^TTP\d{4}[A-Z0-9]{3}$/); + }); + + invalidCodes.forEach((code) => { + expect(code).not.toMatch(/^TTP\d{4}[A-Z0-9]{3}$/); + }); + }); + }); +}); diff --git a/test/integration/newsletter.test.js b/test/integration/newsletter.test.js new file mode 100644 index 00000000..d2f24714 --- /dev/null +++ b/test/integration/newsletter.test.js @@ -0,0 +1,119 @@ +/** + * Tests d'intĂ©gration pour les endpoints newsletter et contact + */ +import request from 'supertest'; +import app from '../../index.js'; + +describe('Newsletter API', () => { + describe('POST /api/newsletter/subscribe', () => { + it('should reject subscription with invalid email', async () => { + const res = await request(app) + .post('/api/newsletter/subscribe') + .send({ email: 'invalid-email' }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('should reject subscription with missing email', async () => { + const res = await request(app) + .post('/api/newsletter/subscribe') + .send({}); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + }); + + describe('POST /api/newsletter/unsubscribe', () => { + it('should reject unsubscription with invalid email', async () => { + const res = await request(app) + .post('/api/newsletter/unsubscribe') + .send({ email: 'invalid-email' }); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('should reject unsubscription with missing email', async () => { + const res = await request(app) + .post('/api/newsletter/unsubscribe') + .send({}); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + }); + }); + + describe('GET /api/newsletter/subscribers', () => { + it('should reject request without authentication', async () => { + const res = await request(app).get('/api/newsletter/subscribers'); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('GET /api/newsletter/count', () => { + it('should reject request without authentication', async () => { + const res = await request(app).get('/api/newsletter/count'); + + expect(res.statusCode).toBe(401); + }); + }); +}); + +describe('Contact API', () => { + describe('POST /api/contact', () => { + it('should reject contact form with missing fields', async () => { + const res = await request(app) + .post('/api/contact') + .send({ + name: 'John Doe', + }); + + expect(res.statusCode).toBe(400); + }); + + it('should reject contact form with invalid email', async () => { + const res = await request(app) + .post('/api/contact') + .send({ + name: 'John Doe', + email: 'invalid-email', + subject: 'Test Subject', + message: 'Test message', + }); + + expect(res.statusCode).toBe(400); + }); + }); +}); + +describe('Health Check Endpoints', () => { + describe('GET /', () => { + it('should return 200 with API status', async () => { + const res = await request(app).get('/'); + + expect(res.statusCode).toBe(200); + expect(res.body).toHaveProperty('message'); + }); + }); + + describe('GET /db-check', () => { + it('should return database status', async () => { + const res = await request(app).get('/db-check'); + + // May return 200 or 500 depending on DB connection + expect([200, 500]).toContain(res.statusCode); + }); + }); + + describe('GET /metrics', () => { + it('should return Prometheus metrics', async () => { + const res = await request(app).get('/metrics'); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toContain('text/plain'); + }); + }); +}); diff --git a/test/unit/helpers.test.js b/test/unit/helpers.test.js new file mode 100644 index 00000000..f8cdf44c --- /dev/null +++ b/test/unit/helpers.test.js @@ -0,0 +1,213 @@ +/** + * Tests unitaires pour src/utils/helpers.js + */ +import { + generateToken, + generateJWT, + verifyJWT, + generateTicketCode, + isValidEmail, + isValidPhone, + cleanPhone, + formatDate, + maskEmail, + getTokenExpiry, + isExpired, + generateRandomPassword, + getPagination, + formatPaginatedResponse, +} from '../../src/utils/helpers.js'; + +describe('Helpers - Token Generation', () => { + describe('generateToken', () => { + it('should generate a token of default length 32', () => { + const token = generateToken(); + expect(token).toHaveLength(64); // hex string is 2x the byte length + }); + + it('should generate a token of specified length', () => { + const token = generateToken(16); + expect(token).toHaveLength(32); + }); + + it('should generate unique tokens', () => { + const token1 = generateToken(); + const token2 = generateToken(); + expect(token1).not.toBe(token2); + }); + }); + + describe('generateJWT', () => { + it('should generate a valid JWT token', () => { + const token = generateJWT(1, 'test@example.com', 'CLIENT'); + expect(token).toBeDefined(); + expect(typeof token).toBe('string'); + expect(token.split('.')).toHaveLength(3); + }); + }); + + describe('verifyJWT', () => { + it('should verify a valid JWT token', () => { + const token = generateJWT(1, 'test@example.com', 'CLIENT'); + const decoded = verifyJWT(token); + expect(decoded).toBeDefined(); + expect(decoded.userId).toBe(1); + expect(decoded.email).toBe('test@example.com'); + expect(decoded.role).toBe('CLIENT'); + }); + + it('should return null for invalid token', () => { + const decoded = verifyJWT('invalid-token'); + expect(decoded).toBeNull(); + }); + }); +}); + +describe('Helpers - Ticket Code Generation', () => { + describe('generateTicketCode', () => { + it('should generate a ticket code starting with TTP', () => { + const code = generateTicketCode(); + expect(code).toMatch(/^TTP/); + }); + + it('should generate a 10-character code', () => { + const code = generateTicketCode(); + expect(code).toHaveLength(10); + }); + + it('should include current year', () => { + const code = generateTicketCode(); + const year = new Date().getFullYear().toString(); + expect(code).toContain(year); + }); + + it('should generate unique codes', () => { + const codes = new Set(); + for (let i = 0; i < 100; i++) { + codes.add(generateTicketCode()); + } + expect(codes.size).toBe(100); + }); + }); +}); + +describe('Helpers - Validation', () => { + describe('isValidEmail', () => { + it('should return true for valid emails', () => { + expect(isValidEmail('test@example.com')).toBe(true); + expect(isValidEmail('user.name@domain.fr')).toBe(true); + expect(isValidEmail('user+tag@example.org')).toBe(true); + }); + + it('should return false for invalid emails', () => { + expect(isValidEmail('invalid')).toBe(false); + expect(isValidEmail('invalid@')).toBe(false); + expect(isValidEmail('@domain.com')).toBe(false); + expect(isValidEmail('')).toBe(false); + }); + }); + + describe('isValidPhone', () => { + it('should return true for valid French phone numbers', () => { + expect(isValidPhone('0612345678')).toBe(true); + expect(isValidPhone('06 12 34 56 78')).toBe(true); + expect(isValidPhone('+33612345678')).toBe(true); + }); + + it('should return false for invalid phone numbers', () => { + expect(isValidPhone('123')).toBe(false); + expect(isValidPhone('')).toBe(false); + }); + }); + + describe('cleanPhone', () => { + it('should remove spaces from phone number', () => { + expect(cleanPhone('06 12 34 56 78')).toBe('0612345678'); + }); + + it('should handle already clean numbers', () => { + expect(cleanPhone('0612345678')).toBe('0612345678'); + }); + }); +}); + +describe('Helpers - Date Functions', () => { + describe('formatDate', () => { + it('should format date to French locale', () => { + const date = new Date('2025-01-15'); + const formatted = formatDate(date); + expect(formatted).toContain('2025'); + }); + }); + + describe('getTokenExpiry', () => { + it('should return a future date', () => { + const expiry = getTokenExpiry(24); + expect(new Date(expiry).getTime()).toBeGreaterThan(Date.now()); + }); + }); + + describe('isExpired', () => { + it('should return true for past dates', () => { + const pastDate = new Date('2020-01-01'); + expect(isExpired(pastDate)).toBe(true); + }); + + it('should return false for future dates', () => { + const futureDate = new Date('2030-01-01'); + expect(isExpired(futureDate)).toBe(false); + }); + }); +}); + +describe('Helpers - Security', () => { + describe('maskEmail', () => { + it('should mask the middle of email', () => { + const masked = maskEmail('john.doe@example.com'); + expect(masked).toContain('***'); + expect(masked).not.toBe('john.doe@example.com'); + }); + }); + + describe('generateRandomPassword', () => { + it('should generate password of specified length', () => { + const password = generateRandomPassword(12); + expect(password).toHaveLength(12); + }); + + it('should generate unique passwords', () => { + const p1 = generateRandomPassword(16); + const p2 = generateRandomPassword(16); + expect(p1).not.toBe(p2); + }); + }); +}); + +describe('Helpers - Pagination', () => { + describe('getPagination', () => { + it('should calculate correct offset', () => { + expect(getPagination(1, 10)).toEqual({ offset: 0, limit: 10 }); + expect(getPagination(2, 10)).toEqual({ offset: 10, limit: 10 }); + expect(getPagination(3, 20)).toEqual({ offset: 40, limit: 20 }); + }); + + it('should handle default values', () => { + const result = getPagination(); + expect(result.offset).toBeDefined(); + expect(result.limit).toBeDefined(); + }); + }); + + describe('formatPaginatedResponse', () => { + it('should format paginated response correctly', () => { + const data = [{ id: 1 }, { id: 2 }]; + const response = formatPaginatedResponse(data, 100, 1, 10); + + expect(response.data).toEqual(data); + expect(response.pagination.total).toBe(100); + expect(response.pagination.page).toBe(1); + expect(response.pagination.limit).toBe(10); + expect(response.pagination.totalPages).toBe(10); + }); + }); +}); diff --git a/test/unit/middleware.test.js b/test/unit/middleware.test.js new file mode 100644 index 00000000..c42e70e7 --- /dev/null +++ b/test/unit/middleware.test.js @@ -0,0 +1,217 @@ +/** + * Tests unitaires pour src/middleware/ + */ +import { jest } from '@jest/globals'; + +// Mock du pool de base de donnĂ©es +jest.unstable_mockModule('../../db.js', () => ({ + pool: { + query: jest.fn(), + }, +})); + +const { pool } = await import('../../db.js'); +const { authenticateToken, authorizeRoles, optionalAuth } = await import('../../src/middleware/auth.js'); +const { AppError, errorHandler, asyncHandler } = await import('../../src/middleware/errorHandler.js'); +const { validate } = await import('../../src/middleware/validate.js'); + +describe('Auth Middleware', () => { + let mockReq; + let mockRes; + let mockNext; + + beforeEach(() => { + mockReq = { + headers: {}, + cookies: {}, + }; + mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + mockNext = jest.fn(); + jest.clearAllMocks(); + }); + + describe('authenticateToken', () => { + it('should return 401 if no token provided', async () => { + await authenticateToken(mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + }) + ); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should return 401 for invalid token', async () => { + mockReq.headers.authorization = 'Bearer invalid-token'; + + await authenticateToken(mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); + + describe('authorizeRoles', () => { + it('should call next if user has required role', () => { + mockReq.user = { role: 'ADMIN' }; + const middleware = authorizeRoles('ADMIN', 'EMPLOYEE'); + + middleware(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + }); + + it('should return 403 if user lacks required role', () => { + mockReq.user = { role: 'CLIENT' }; + const middleware = authorizeRoles('ADMIN'); + + middleware(mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(403); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); + + describe('optionalAuth', () => { + it('should call next without user if no token', async () => { + await optionalAuth(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(mockReq.user).toBeUndefined(); + }); + }); +}); + +describe('Error Handler Middleware', () => { + let mockReq; + let mockRes; + let mockNext; + + beforeEach(() => { + mockReq = {}; + mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + mockNext = jest.fn(); + }); + + describe('AppError', () => { + it('should create an operational error', () => { + const error = new AppError('Test error', 400); + + expect(error.message).toBe('Test error'); + expect(error.statusCode).toBe(400); + expect(error.isOperational).toBe(true); + }); + }); + + describe('errorHandler', () => { + it('should handle AppError with correct status', () => { + const error = new AppError('Not found', 404); + + errorHandler(error, mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(404); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + message: 'Not found', + }) + ); + }); + + it('should handle generic errors with 500', () => { + const error = new Error('Something went wrong'); + + errorHandler(error, mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(500); + }); + }); + + describe('asyncHandler', () => { + it('should pass errors to next middleware', async () => { + const error = new Error('Async error'); + const asyncFn = async () => { + throw error; + }; + const wrappedFn = asyncHandler(asyncFn); + + await wrappedFn(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalledWith(error); + }); + + it('should not call next on success', async () => { + const asyncFn = async (req, res) => { + res.json({ success: true }); + }; + const wrappedFn = asyncHandler(asyncFn); + + await wrappedFn(mockReq, mockRes, mockNext); + + expect(mockRes.json).toHaveBeenCalledWith({ success: true }); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); +}); + +describe('Validation Middleware', () => { + let mockReq; + let mockRes; + let mockNext; + + beforeEach(() => { + mockReq = { + body: {}, + query: {}, + params: {}, + }; + mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + mockNext = jest.fn(); + }); + + describe('validate', () => { + it('should call next for valid data', async () => { + const { z } = await import('zod'); + const schema = z.object({ + body: z.object({ + email: z.string().email(), + }), + }); + + mockReq.body = { email: 'test@example.com' }; + const middleware = validate(schema); + + await middleware(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + }); + + it('should return 400 for invalid data', async () => { + const { z } = await import('zod'); + const schema = z.object({ + body: z.object({ + email: z.string().email(), + }), + }); + + mockReq.body = { email: 'invalid-email' }; + const middleware = validate(schema); + + await middleware(mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); +});