From b22b4597d66efd79500e61c737137c4b53319fbc Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Thu, 19 Feb 2026 08:59:22 +0100 Subject: [PATCH] test: harden integration suite and error assertions --- jest.config.js | 42 ++-- modules/auth/tests/auth.integration.tests.js | 195 ++++++++++++++++++ modules/core/tests/core.integration.tests.js | 44 +++- modules/core/tests/core.unit.tests.js | 148 +++++++++++-- modules/home/tests/home.integration.tests.js | 108 ++++++++-- .../tasks/tests/tasks.integration.tests.js | 83 +++++++- .../tests/uploads.integration.tests.js | 47 ++++- .../tests/user.account.integration.tests.js | 190 +++++++++++++++++ .../tests/user.admin.integration.tests.js | 127 ++++++++++++ 9 files changed, 924 insertions(+), 60 deletions(-) diff --git a/jest.config.js b/jest.config.js index b8e7c2f8e..57b5da679 100644 --- a/jest.config.js +++ b/jest.config.js @@ -21,13 +21,27 @@ export default { // collectCoverage: true, // An array of glob patterns indicating a set of files for which coverage information should be collected - // collectCoverageFrom: [ - // '/server.js', - // '/config/**/*.js', - // '/modules/*/**/*.js', - // ], + collectCoverageFrom: [ + '/lib/**/*.js', + '/modules/**/*.js', + // Exclude schema/model definitions — no business logic, just DB structure + '!/modules/**/*.model.mongoose.js', + '!/modules/**/*.model.sequelize.js', + // Exclude static config files — just object exports, nothing to test + '!/config/defaults/**/*.js', + // Exclude entry point + '!/server.js', + // Exclude Sequelize service — MySQL support is disabled/commented out in app.js + '!/lib/services/sequelize.js', + // Exclude OAuth strategy configs — no business logic, just passport.use() calls; + // real OAuth credentials required to test, which are not available in CI + '!/modules/auth/config/strategies/apple.js', + '!/modules/auth/config/strategies/google.js', + // Exclude dead code — never imported anywhere in the codebase + '!/modules/users/services/users.data.service.js', + ], // The directory where Jest should output its coverage files - // coverageDirectory: 'coverage', + coverageDirectory: 'coverage', // An array of regexp pattern strings used to skip coverage collection // coveragePathIgnorePatterns: [ @@ -35,15 +49,17 @@ export default { // ], // A list of reporter names that Jest uses when writing coverage reports - // coverageReporters: [ - // 'json', - // // "text", - // 'lcov', - // // "clover" - // ], + coverageReporters: ['json', 'lcov', 'clover', 'text'], // An object that configures minimum threshold enforcement for coverage results - // coverageThreshold: null, + coverageThreshold: { + global: { + statements: 85, + branches: 75, + functions: 85, + lines: 85, + }, + }, // Make calling deprecated APIs throw helpful error messages // errorOnDeprecated: false, diff --git a/modules/auth/tests/auth.integration.tests.js b/modules/auth/tests/auth.integration.tests.js index 9b6502415..3e1cb0a93 100644 --- a/modules/auth/tests/auth.integration.tests.js +++ b/modules/auth/tests/auth.integration.tests.js @@ -4,6 +4,7 @@ import request from 'supertest'; import path from 'path'; import _ from 'lodash'; +import { jest } from '@jest/globals'; import { bootstrap } from '../../../lib/app.js'; import mongooseService from '../../../lib/services/mongoose.js'; @@ -29,6 +30,7 @@ describe('Auth integration tests:', () => { agent = request.agent(init.app); } catch (err) { console.log(err); + expect(err).toBeFalsy(); } }); @@ -235,6 +237,22 @@ describe('Auth integration tests:', () => { } }); + test('should reject login with correct email but wrong password', async () => { + try { + const result = await agent + .post('/api/auth/signin') + .send({ + email: credentials[0].email, + password: 'WrongPassword!123', + }) + .expect(401); + expect(result.error.text).toBe('Unauthorized'); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + }); + test('forgot password request for non-existent email should return 400', async () => { try { const result = await agent @@ -431,12 +449,189 @@ describe('Auth integration tests:', () => { }); }); + describe('OAuth profile and service branches', () => { + let AuthController; + const oauthUsers = []; + + beforeAll(async () => { + AuthController = (await import(path.resolve('./modules/auth/controllers/auth.controller.js'))).default; + // clean up any leftover users from a previously failed run + for (const email of ['noprovider@auth-test.com', 'oauthprofile@test.com', 'oauthfind@test.com']) { + try { + const existing = await UserService.getBrut({ email }); + if (existing) await UserService.remove(existing); + } catch (_) { /* cleanup – ignore errors */ } + } + }); + + test('should create user with default provider when none is specified', async () => { + const result = await UserService.create({ + firstName: 'No', + lastName: 'Provider', + email: 'noprovider@auth-test.com', + password: 'P@ss!W0rd123', + roles: ['user'], + // provider intentionally omitted to trigger the default branch + }); + expect(result.provider).toBe('local'); + oauthUsers.push(result); + }); + + test('should create an OAuth user without password via checkOAuthUserProfile', async () => { + const profil = { + firstName: 'OAuth', + lastName: 'Test', + email: 'oauthprofile@test.com', + avatar: '', + providerData: { id: 'google-fake-id-999' }, + }; + const mockRes = { status() { return this; }, json() {}, cookie() { return this; } }; + const result = await AuthController.checkOAuthUserProfile(profil, 'id', 'google', mockRes); + expect(result).toBeDefined(); + expect(result.id).toBeDefined(); + expect(result.email).toBe(profil.email); + oauthUsers.push(result); + }); + + test('should find an existing OAuth user via checkOAuthUserProfile', async () => { + // Create an OAuth user directly first + const createdUser = await UserService.create({ + firstName: 'OAuth', + lastName: 'Find', + email: 'oauthfind@test.com', + provider: 'google', + providerData: { id: 'google-find-id-777' }, + roles: ['user'], + }); + + const profil = { + firstName: 'OAuth', + lastName: 'Find', + email: 'oauthfind@test.com', + avatar: '', + providerData: { id: 'google-find-id-777' }, + }; + const mockRes = { status() { return this; }, json() {}, cookie() { return this; } }; + // Second call — should find the existing user (search.length === 1 branch) + const found = await AuthController.checkOAuthUserProfile(profil, 'id', 'google', mockRes); + expect(found).toBeDefined(); + + // cleanup + try { + await UserService.remove(createdUser); + } catch (_) { /* cleanup – ignore errors */ } + }); + + afterAll(async () => { + for (const u of oauthUsers) { + try { + await UserService.remove(u); + } catch (_) { /* cleanup – ignore errors */ } + } + }); + }); + + describe('Password reset endpoint', () => { + beforeEach(async () => { + credentials = [ + { + email: 'resetpwd@test.com', + password: 'W@os.jsI$Aw3$0m3', + }, + ]; + _user = { + firstName: 'Reset', + lastName: 'User', + email: credentials[0].email, + password: credentials[0].password, + provider: 'local', + }; + try { + const result = await agent.post('/api/auth/signup').send(_user).expect(200); + user = result.body.user; + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + }); + + test('should return 400 when token or password fields are missing', async () => { + try { + const result = await agent.post('/api/auth/reset').send({ newPassword: 'NewP@ss123' }).expect(400); + expect(result.body.message).toBe('Bad Request'); + expect(result.body.description).toBe('Password or Token fields must not be blank'); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + }); + + test('should return 400 when reset token is invalid or not found', async () => { + try { + const result = await agent.post('/api/auth/reset').send({ token: 'invalid-token-xyz', newPassword: 'NewP@ss!Word123' }).expect(400); + expect(result.body.message).toBe('Bad Request'); + expect(result.body.description).toBe('Password reset token is invalid or has expired.'); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + }); + + test('should successfully reset password with a valid token', async () => { + // Trigger forgot to generate a reset token (email send fails in test env, which is expected) + try { + await agent.post('/api/auth/forgot').send({ email: credentials[0].email }).expect(400); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + + // Fetch the token directly via UserService + let resetToken; + try { + const userWithToken = await UserService.getBrut({ email: credentials[0].email }); + resetToken = userWithToken.resetPasswordToken; + expect(resetToken).toBeDefined(); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + + // Reset password with the valid token + try { + const result = await agent.post('/api/auth/reset').send({ token: resetToken, newPassword: 'NewP@ss!Word123' }).expect(200); + expect(result.body.message).toBe('Password changed successfully'); + expect(result.body.user).toBeDefined(); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + }); + + afterEach(async () => { + try { + await UserService.remove(user); + } catch (err) { + console.log(err); + } + }); + }); + + describe('Error paths', () => { + test('should redirect to invalid when validateResetToken getBrut throws', async () => { + jest.spyOn(UserService, 'getBrut').mockRejectedValueOnce(new Error('DB error')); + const result = await agent.get('/api/auth/reset/sometoken').expect(302); + expect(result.headers.location).toBe('/api/password/reset/invalid'); + }); + }); + // Mongoose disconnect afterAll(async () => { try { await mongooseService.disconnect(); } catch (err) { console.log(err); + expect(err).toBeFalsy(); } }); }); diff --git a/modules/core/tests/core.integration.tests.js b/modules/core/tests/core.integration.tests.js index 809a4400b..cfe87310b 100644 --- a/modules/core/tests/core.integration.tests.js +++ b/modules/core/tests/core.integration.tests.js @@ -3,6 +3,7 @@ */ import path from 'path'; +import { jest } from '@jest/globals'; import config from '../../../config/index.js'; import mongooseService from '../../../lib/services/mongoose.js'; import seed from '../../../lib/services/seed.js'; @@ -25,6 +26,7 @@ describe('Core integration tests:', () => { TaskService = (await import(path.resolve('./modules/tasks/services/tasks.service.js'))).default; } catch (err) { console.log(err); + expect(err).toBeFalsy(); } }); @@ -256,10 +258,44 @@ describe('Core integration tests:', () => { }); }); + describe('Seed service', () => { + it('should log results when logResults option is enabled', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + const result = await seed.start({ logResults: true }, UserService, AuthService, TaskService); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Database Seeding')); + expect(result).toBeInstanceOf(Array); + consoleSpy.mockRestore(); + }); + + it('should seed a single user via seed.user()', async () => { + const seedUser = { + firstName: 'Seed', + lastName: 'Test', + email: 'seedtest@unit.com', + provider: 'local', + roles: ['user'], + }; + + const result = await seed.user(seedUser, UserService, AuthService); + expect(result).toBeInstanceOf(Array); + expect(result).toHaveLength(1); + + // cleanup + try { + await UserService.remove(result[0]); + } catch (_) { /* cleanup – ignore errors */ } + }); + }); + // Mongoose disconnect - afterAll(() => - mongooseService.disconnect().catch((e) => { + afterAll(async () => { + try { + await mongooseService.disconnect(); + } catch (e) { console.log(e); - }), - ); + expect(e).toBeFalsy(); + } + }); }); diff --git a/modules/core/tests/core.unit.tests.js b/modules/core/tests/core.unit.tests.js index ba6bf3abe..18dd77040 100644 --- a/modules/core/tests/core.unit.tests.js +++ b/modules/core/tests/core.unit.tests.js @@ -10,22 +10,13 @@ import config from '../../../config/index.js'; import configHelper from '../../../lib/helpers/config.js'; import logger from '../../../lib/services/logger.js'; import mongooseService from '../../../lib/services/mongoose.js'; +import expressService from '../../../lib/services/express.js'; import errors from '../../../lib/helpers/errors.js'; -import { bootstrap } from '../../../lib/app.js'; - /** * Unit tests */ describe('Core unit tests:', () => { - beforeAll(async () => { - try { - await bootstrap(); - } catch (err) { - console.log(err); - } - }); - let userFromSeedConfig; let adminFromSeedConfig; let tasksFromSeedConfig; @@ -110,12 +101,10 @@ describe('Core unit tests:', () => { describe('Logger', () => { beforeEach(() => { originalLogConfig = _.clone(config.log, true); - // mock(); }); afterEach(() => { config.log = originalLogConfig; - // mock.restore(); }); it('should retrieve the log format from the logger configuration', () => { @@ -161,7 +150,7 @@ describe('Core unit tests:', () => { }); it('should not create a file transport object if critical options are missing: filename', () => { - // manually set the config stream fileName option to an empty string + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); config.log = { format: 'combined', options: { @@ -174,10 +163,11 @@ describe('Core unit tests:', () => { const fileTransport = logger.setupFileLogger(config); expect(fileTransport).toBe(false); + consoleSpy.mockRestore(); }); it('should not create a file transport object if critical options are missing: directory', () => { - // manually set the config stream fileName option to an empty string + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); config.log = { format: 'combined', options: { @@ -190,6 +180,7 @@ describe('Core unit tests:', () => { const fileTransport = logger.setupFileLogger(config); expect(fileTransport).toBe(false); + consoleSpy.mockRestore(); }); }); @@ -232,10 +223,127 @@ describe('Core unit tests:', () => { }); }); - // Mongoose disconnect - afterAll(() => - mongooseService.disconnect().catch((e) => { - console.log(e); - }), - ); + describe('Config helpers', () => { + it('should return URL as-is when globPattern is a URL', async () => { + const result = await configHelper.getGlobbedPaths('http://example.com/resource'); + expect(result).toContain('http://example.com/resource'); + }); + + it('should apply string excludes when provided to getGlobbedPaths', async () => { + const result = await configHelper.getGlobbedPaths('modules/core/tests/*.js', 'modules/core/tests/'); + expect(Array.isArray(result)).toBe(true); + }); + + it('should log a warning and disable ssl when cert files are missing', () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const fakeConfig = { secure: { ssl: true, key: './nonexistent.pem', cert: './nonexistent2.pem' } }; + configHelper.initSecureMode(fakeConfig); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Certificate file')); + expect(fakeConfig.secure.ssl).toBe(false); + consoleSpy.mockRestore(); + }); + + it('should return true early when ssl is not configured', () => { + const result = configHelper.initSecureMode({ secure: { ssl: false } }); + expect(result).toBe(true); + }); + }); + + describe('Express service', () => { + it('should set app.locals.secure when ssl is enabled', () => { + const originalSecure = config.secure; + config.secure = { ssl: true }; + const mockApp = { locals: {}, use: jest.fn() }; + expressService.initLocalVariables(mockApp); + expect(mockApp.locals.secure).toBe(true); + config.secure = originalSecure; + }); + + it('should not set app.locals.secure when ssl is disabled', () => { + const originalSecure = config.secure; + config.secure = { ssl: false }; + const mockApp = { locals: {}, use: jest.fn() }; + expressService.initLocalVariables(mockApp); + expect(mockApp.locals.secure).toBeUndefined(); + config.secure = originalSecure; + }); + + it('should call next() when error middleware receives no error', () => { + const mockApp = { use: jest.fn() }; + expressService.initErrorRoutes(mockApp); + const middleware = mockApp.use.mock.calls[0][0]; + const mockNext = jest.fn(); + const mockRes = { status: jest.fn().mockReturnThis(), send: jest.fn() }; + middleware(null, {}, mockRes, mockNext); + expect(mockNext).toHaveBeenCalled(); + }); + + it('should respond with 500 when error has no status code', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const mockApp = { use: jest.fn() }; + expressService.initErrorRoutes(mockApp); + const middleware = mockApp.use.mock.calls[0][0]; + const mockNext = jest.fn(); + const mockSend = jest.fn(); + const mockStatus = jest.fn().mockReturnValue({ send: mockSend }); + const mockRes = { status: mockStatus }; + const err = new Error('test error'); + middleware(err, {}, mockRes, mockNext); + expect(mockStatus).toHaveBeenCalledWith(500); + consoleSpy.mockRestore(); + }); + + it('should respond with the error status code when provided', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const mockApp = { use: jest.fn() }; + expressService.initErrorRoutes(mockApp); + const middleware = mockApp.use.mock.calls[0][0]; + const mockSend = jest.fn(); + const mockStatus = jest.fn().mockReturnValue({ send: mockSend }); + const mockRes = { status: mockStatus }; + const err = new Error('not found'); + err.status = 404; + middleware(err, {}, mockRes, jest.fn()); + expect(mockStatus).toHaveBeenCalledWith(404); + consoleSpy.mockRestore(); + }); + }); + + describe('Mongoose service', () => { + it('should invoke callback after loading models', async () => { + const callback = jest.fn(); + await mongooseService.loadModels(callback); + expect(callback).toHaveBeenCalled(); + }); + }); + + describe('Auth service', () => { + let AuthService; + + beforeAll(async () => { + AuthService = (await import(path.resolve('./modules/auth/services/auth.service.js'))).default; + }); + + it('should return null when removeSensitive is called with a non-object', () => { + expect(AuthService.removeSensitive(null)).toBeNull(); + expect(AuthService.removeSensitive('string')).toBeNull(); + expect(AuthService.removeSensitive(undefined)).toBeNull(); + }); + + it('should return picked fields when removeSensitive is called with a valid user', () => { + const fakeUser = { id: '1', email: 'a@b.com', password: 'secret', firstName: 'A' }; + const result = AuthService.removeSensitive(fakeUser); + expect(result).toBeDefined(); + expect(result.password).toBeUndefined(); + }); + + it('should throw when checkPassword is called with a weak password', () => { + expect(() => AuthService.checkPassword('password')).toThrow(); + }); + + it('should return the password when checkPassword is called with a strong password', () => { + const strong = 'C0rr3ct!H0rs3B@tt3ry'; + expect(AuthService.checkPassword(strong)).toBe(strong); + }); + }); }); diff --git a/modules/home/tests/home.integration.tests.js b/modules/home/tests/home.integration.tests.js index 21d7b419f..f2b2fdd46 100644 --- a/modules/home/tests/home.integration.tests.js +++ b/modules/home/tests/home.integration.tests.js @@ -1,49 +1,56 @@ /** * Module dependencies. */ +import { jest } from '@jest/globals'; +import axios from 'axios'; +import path from 'path'; import request from 'supertest'; import { bootstrap } from '../../../lib/app.js'; import mongooseService from '../../../lib/services/mongoose.js'; +import config from '../../../config/index.js'; /** * Unit tests */ describe('Home integration tests:', () => { let agent; + let HomeService; // init beforeAll(async () => { + // Mock GitHub API calls to avoid real network requests in tests + jest.spyOn(axios, 'get').mockImplementation(async (url) => { + if (url.includes('/releases')) { + return { data: [{ name: 'v1.0.0', prerelease: false, published_at: '2024-01-01T00:00:00Z' }] }; + } + if (url.includes('/contents/')) { + return { data: { content: Buffer.from('# Changelog\n## v1.0.0\n- First release').toString('base64') } }; + } + throw new Error(`Unexpected GitHub API URL: ${url}`); + }); try { const init = await bootstrap(); + HomeService = (await import(path.resolve('./modules/home/services/home.service.js'))).default; agent = request.agent(init.app); } catch (err) { console.log(err); + expect(err).toBeFalsy(); } }); describe('Logout', () => { test('should be able to get releases', async () => { - try { - const result = await agent.get('/api/home/releases').expect(200); - expect(result.body.type).toBe('success'); - expect(result.body.message).toBe('releases'); - expect(result.body.data).toBeInstanceOf(Array); - } catch (err) { - // expect(err).toBeFalsy(); depends of chain api calls without key - console.log(err); - } + const result = await agent.get('/api/home/releases').expect(200); + expect(result.body.type).toBe('success'); + expect(result.body.message).toBe('releases'); + expect(result.body.data).toBeInstanceOf(Array); }); test('should be able to get changelogs', async () => { - try { - const result = await agent.get('/api/home/changelogs').expect(200); - expect(result.body.type).toBe('success'); - expect(result.body.message).toBe('changelogs'); - expect(result.body.data).toBeInstanceOf(Array); - } catch (err) { - // expect(err).toBeFalsy(); depends of chain api calls without key - console.log(err); - } + const result = await agent.get('/api/home/changelogs').expect(200); + expect(result.body.type).toBe('success'); + expect(result.body.message).toBe('changelogs'); + expect(result.body.data).toBeInstanceOf(Array); }); test('should be able to get team members', async () => { @@ -83,14 +90,79 @@ describe('Home integration tests:', () => { expect(err).toBeFalsy(); } }); + + test('should return 422 when GitHub API fails for releases', async () => { + axios.get.mockRejectedValueOnce(new Error('GitHub API unavailable')); + const result = await agent.get('/api/home/releases').expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('GitHub API unavailable.'); + }); + + test('should return 422 when GitHub API fails for changelogs', async () => { + axios.get.mockRejectedValueOnce(new Error('GitHub API unavailable')); + const result = await agent.get('/api/home/changelogs').expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('GitHub API unavailable.'); + }); + + test('should use Authorization header when a token is configured for releases', async () => { + const originalRepos = config.repos; + axios.get.mockClear(); + // Temporarily set a fake token to cover the token-truthy branch in home.service releases() + config.repos = originalRepos.map((repo) => ({ ...repo, token: 'fake-test-token' })); + const result = await agent.get('/api/home/releases').expect(200); + expect(result.body.type).toBe('success'); + const releaseCalls = axios.get.mock.calls.filter(([url]) => url.includes('/releases')); + expect(releaseCalls.length).toBeGreaterThan(0); + releaseCalls.forEach(([, options]) => { + expect(options.headers.Authorization).toBe('token fake-test-token'); + }); + config.repos = originalRepos; + }); + + test('should use Authorization header when a token is configured for changelogs', async () => { + const originalRepos = config.repos; + axios.get.mockClear(); + config.repos = originalRepos.map((repo) => ({ ...repo, token: 'fake-test-token' })); + const result = await agent.get('/api/home/changelogs').expect(200); + expect(result.body.type).toBe('success'); + const changelogCalls = axios.get.mock.calls.filter(([url]) => url.includes('/contents/')); + expect(changelogCalls.length).toBeGreaterThan(0); + changelogCalls.forEach(([, options]) => { + expect(options.headers.Authorization).toBe('token fake-test-token'); + }); + config.repos = originalRepos; + }); + }); + + describe('Errors', () => { + test('should return 422 when team service fails', async () => { + jest.spyOn(HomeService, 'team').mockRejectedValueOnce(new Error('DB error')); + const result = await agent.get('/api/home/team').expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('DB error.'); + }); + + test('should handle error in pageByName when page service fails', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(HomeService, 'page').mockRejectedValueOnce(new Error('DB error')); + const result = await agent.get('/api/home/pages/terms').expect(500); + expect(result.body.message).toBe('DB error'); + consoleSpy.mockRestore(); + }); }); // Mongoose disconnect afterAll(async () => { + jest.restoreAllMocks(); try { await mongooseService.disconnect(); } catch (err) { console.log(err); + expect(err).toBeFalsy(); } }); }); diff --git a/modules/tasks/tests/tasks.integration.tests.js b/modules/tasks/tests/tasks.integration.tests.js index 3c4fcddcd..72545090f 100644 --- a/modules/tasks/tests/tasks.integration.tests.js +++ b/modules/tasks/tests/tasks.integration.tests.js @@ -4,7 +4,7 @@ import request from 'supertest'; import path from 'path'; -import { afterAll, beforeAll } from '@jest/globals'; +import { jest, afterAll, beforeAll } from '@jest/globals'; import { bootstrap } from '../../../lib/app.js'; import mongooseService from '../../../lib/services/mongoose.js'; @@ -13,6 +13,7 @@ import mongooseService from '../../../lib/services/mongoose.js'; */ describe('Tasks integration tests:', () => { let UserService; + let TasksService; let TasksDataService; let agent; let user; @@ -26,10 +27,12 @@ describe('Tasks integration tests:', () => { try { const init = await bootstrap(); UserService = (await import(path.resolve('./modules/users/services/users.service.js'))).default; + TasksService = (await import(path.resolve('./modules/tasks/services/tasks.service.js'))).default; TasksDataService = (await import(path.resolve('./modules/tasks/services/tasks.data.service.js'))).default; agent = request.agent(init.app); } catch (err) { console.log(err); + expect(err).toBeFalsy(); } }); @@ -380,12 +383,90 @@ describe('Tasks integration tests:', () => { }); }); + describe('Errors', () => { + let errorUser; + let errorTask; + + beforeAll(async () => { + try { + const result = await agent.post('/api/auth/signup').send({ + firstName: 'Error', + lastName: 'Task', + email: 'taskerror@test.com', + password: 'W@os.jsI$Aw3$0m3', + provider: 'local', + }).expect(200); + errorUser = result.body.user; + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + try { + const taskResult = await agent.post('/api/tasks').send({ title: 'errtask', description: 'err desc' }).expect(200); + errorTask = taskResult.body.data; + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + }); + + test('should return 422 when list fails', async () => { + jest.spyOn(TasksService, 'list').mockRejectedValueOnce(new Error('DB error')); + const result = await agent.get('/api/tasks').expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('DB error.'); + }); + + test('should return 422 when create fails', async () => { + jest.spyOn(TasksService, 'create').mockRejectedValueOnce(new Error('DB error')); + const result = await agent.post('/api/tasks').send({ title: 'test', description: 'desc' }).expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('DB error.'); + }); + + test('should return 422 when update fails', async () => { + jest.spyOn(TasksService, 'update').mockRejectedValueOnce(new Error('DB error')); + const result = await agent.put(`/api/tasks/${errorTask.id}`).send({ title: 'upd', description: 'desc' }).expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('DB error.'); + }); + + test('should return 422 when remove fails', async () => { + jest.spyOn(TasksService, 'remove').mockRejectedValueOnce(new Error('DB error')); + const result = await agent.delete(`/api/tasks/${errorTask.id}`).expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('DB error.'); + }); + + test('should return 422 when stats returns an error', async () => { + jest.spyOn(TasksService, 'stats').mockResolvedValueOnce({ err: new Error('DB error') }); + const result = await agent.get('/api/tasks/stats').expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('DB error.'); + }); + + afterAll(async () => { + try { + await agent.delete(`/api/tasks/${errorTask.id}`); + } catch (_) { /* cleanup – ignore errors */ } + try { + await UserService.remove(errorUser); + } catch (_) { /* cleanup – ignore errors */ } + }); + }); + // Mongoose disconnect afterAll(async () => { try { await mongooseService.disconnect(); } catch (err) { console.log(err); + expect(err).toBeFalsy(); } }); }); diff --git a/modules/uploads/tests/uploads.integration.tests.js b/modules/uploads/tests/uploads.integration.tests.js index 8e376cbd3..e9fec508f 100644 --- a/modules/uploads/tests/uploads.integration.tests.js +++ b/modules/uploads/tests/uploads.integration.tests.js @@ -4,7 +4,7 @@ import request from 'supertest'; import path from 'path'; -import { beforeAll } from '@jest/globals'; +import { jest, beforeAll } from '@jest/globals'; import { bootstrap } from '../../../lib/app.js'; import mongooseService from '../../../lib/services/mongoose.js'; @@ -13,6 +13,7 @@ import mongooseService from '../../../lib/services/mongoose.js'; */ describe('Uploads integration tests:', () => { let UserService; + let UploadsService; let UploadsDataService; let UploadRepository; let agent; @@ -26,11 +27,13 @@ describe('Uploads integration tests:', () => { try { const init = await bootstrap(); UserService = (await import(path.resolve('./modules/users/services/users.service.js'))).default; + UploadsService = (await import(path.resolve('./modules/uploads/services/uploads.service.js'))).default; UploadsDataService = (await import(path.resolve('./modules/uploads/services/uploads.data.service.js'))).default; UploadRepository = (await import(path.resolve('./modules/uploads/repositories/uploads.repository.js'))).default; agent = request.agent(init.app); } catch (err) { console.log(err); + expect(err).toBeFalsy(); } }); @@ -239,6 +242,38 @@ describe('Uploads integration tests:', () => { } }); + test('should return 422 when get stream fails', async () => { + jest.spyOn(UploadsService, 'getStream').mockRejectedValueOnce(new Error('DB error')); + const result = await agent.get(`/api/uploads/${upload1}`).expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('DB error.'); + }); + + test('should return 422 when getSharp stream fails', async () => { + jest.spyOn(UploadsService, 'getStream').mockRejectedValueOnce(new Error('DB error')); + const result = await agent.get(`/api/uploads/images/${upload1}`).expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('DB error.'); + }); + + test('should return 422 when remove fails', async () => { + jest.spyOn(UploadsService, 'remove').mockRejectedValueOnce(new Error('DB error')); + const result = await agent.delete(`/api/uploads/${upload1}`).expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('DB error.'); + }); + + test('should return 500 when uploadByName middleware throws', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(UploadsService, 'get').mockRejectedValueOnce(new Error('DB error')); + const result = await agent.get(`/api/uploads/${upload1}`).expect(500); + expect(result.body.message).toBe('DB error'); + consoleSpy.mockRestore(); + }); + afterEach(async () => { try { if (upload1) await agent.delete(`/api/uploads/${upload1}`).expect(200); @@ -320,14 +355,17 @@ describe('Uploads integration tests:', () => { describe('Cron', () => { test('should be able to purge data not linked to another entity', async () => { try { - const _user2 = _user; + const _user2 = { ..._user }; _user2.email = 'upload2@test.com'; const resultUser = await agent.post('/api/auth/signup').send(_user2).expect(200); const user = resultUser.body.user; - await agent.post('/api/users/avatar').attach('img', './modules/users/tests/img/default.jpeg').expect(200); + const uploadResult = await agent.post('/api/users/avatar').attach('img', './modules/users/tests/img/default.jpeg').expect(200); + const orphanAvatar = uploadResult.body.data.avatar; await UserService.remove(user); const result = await UploadRepository.purge('avatar', 'users', 'avatar'); - expect(result.deletedCount).toBe(1); + expect(result.deletedCount).toBeGreaterThanOrEqual(1); + const removedUpload = await UploadRepository.get(orphanAvatar); + expect(removedUpload).toBeFalsy(); } catch (err) { expect(err).toBeFalsy(); console.log(err); @@ -341,6 +379,7 @@ describe('Uploads integration tests:', () => { await mongooseService.disconnect(); } catch (err) { console.log(err); + expect(err).toBeFalsy(); } }); }); diff --git a/modules/users/tests/user.account.integration.tests.js b/modules/users/tests/user.account.integration.tests.js index 9ea69f900..d2f66f927 100644 --- a/modules/users/tests/user.account.integration.tests.js +++ b/modules/users/tests/user.account.integration.tests.js @@ -4,6 +4,7 @@ import request from 'supertest'; import path from 'path'; import _ from 'lodash'; +import { jest } from '@jest/globals'; import { bootstrap } from '../../../lib/app.js'; import mongooseService from '../../../lib/services/mongoose.js'; @@ -29,6 +30,7 @@ describe('User integration tests:', () => { agent = request.agent(init.app); } catch (err) { console.log(err); + expect(err).toBeFalsy(); } }); @@ -174,6 +176,25 @@ describe('User integration tests:', () => { } }); + test('should include terms in user details after signing them', async () => { + // Sign terms first + try { + await agent.get('/api/users/terms').expect(200); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + + // Then get user — terms should be populated + try { + const result = await agent.get('/api/users/me').expect(200); + expect(result.body.data.terms).toBeDefined(); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + }); + test('should be able to get all user data successfully', async () => { try { const result = await agent.get('/api/users/data').expect(200); @@ -385,6 +406,54 @@ describe('User integration tests:', () => { } }); + test('should be able to remove profile avatar when user has no avatar', async () => { + try { + const result = await agent.delete('/api/users/avatar').expect(200); + expect(result.body.type).toBe('success'); + expect(result.body.message).toBe('profile avatar updated'); + expect(result.body.data.avatar).toBeFalsy(); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + }); + + test('should be able to remove profile avatar when user has an existing avatar', async () => { + // First upload an avatar + let avatarFilename; + try { + const uploadResult = await agent.post('/api/users/avatar').attach('img', './modules/users/tests/img/default.jpeg').expect(200); + avatarFilename = uploadResult.body.data.avatar; + expect(avatarFilename).toBeDefined(); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + + // Then remove it + try { + const result = await agent.delete('/api/users/avatar').expect(200); + expect(result.body.type).toBe('success'); + expect(result.body.message).toBe('profile avatar updated'); + expect(result.body.data.avatar).toBeFalsy(); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + }); + + test('should be able to request user data by mail', async () => { + try { + // In test env, mail sending is not configured so it returns 400 + const result = await agent.get('/api/users/data/mail').expect(400); + expect(result.body.message).toBe('Bad Request'); + expect(result.body.description).toBe('Failure sending email'); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + }); + // TOFIX issue on supertest for large file https://github.com/ladjs/supertest/issues/824 // test('should not be able to change profile avatar to too big of a file', async () => { // try { @@ -485,12 +554,133 @@ describe('User integration tests:', () => { }); }); + describe('Errors', () => { + let errorUser; + + beforeAll(async () => { + try { + const result = await agent.post('/api/auth/signup').send({ + firstName: 'Error', + lastName: 'User', + email: 'accounterror@test.com', + password: 'W@os.jsI$Aw3$0m3', + provider: 'local', + }).expect(200); + errorUser = result.body.user; + // set bio and position so me() controller branches are covered + await agent.put('/api/users').send({ + firstName: 'Error', + lastName: 'User', + bio: 'Test bio', + position: 'developer', + }).expect(200); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + }); + + test('should return 422 when terms fails', async () => { + jest.spyOn(UserService, 'terms').mockRejectedValueOnce(new Error('DB error')); + const result = await agent.get('/api/users/terms').expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('DB error.'); + }); + + test('should return 422 when remove fails', async () => { + jest.spyOn(UserService, 'remove').mockRejectedValueOnce(new Error('DB error')); + const result = await agent.delete('/api/users').expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('DB error.'); + }); + + test('should return 422 when stats returns an error', async () => { + jest.spyOn(UserService, 'stats').mockResolvedValueOnce({ err: new Error('DB error') }); + const result = await agent.get('/api/users/stats').expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('DB error.'); + }); + + test('should return 422 when updatePassword fails', async () => { + jest.spyOn(UserService, 'update').mockRejectedValueOnce(new Error('DB error')); + const result = await agent.post('/api/users/password').send({ + currentPassword: 'W@os.jsI$Aw3$0m3', + newPassword: 'NewP@ss!W0rd123', + verifyPassword: 'NewP@ss!W0rd123', + }).expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('DB error.'); + }); + + test('should return 422 when updatePassword has mismatched passwords', async () => { + const result = await agent.post('/api/users/password').send({ + currentPassword: 'W@os.jsI$Aw3$0m3', + newPassword: 'NewP@ss!W0rd123', + verifyPassword: 'DifferentP@ss!W0rd', + }).expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('Passwords do not match'); + }); + + test('should return bio and position in me response', async () => { + const result = await agent.get('/api/users/me').expect(200); + expect(result.body.data.bio).toBe('Test bio'); + expect(result.body.data.position).toBe('developer'); + }); + + test('should return 422 when get user data fails', async () => { + jest.spyOn(UserService, 'get').mockRejectedValueOnce(new Error('DB error')); + const result = await agent.get('/api/users/data').expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('DB error.'); + }); + + test('should return 422 when remove user data fails', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(UserService, 'remove').mockRejectedValueOnce(new Error('DB error')); + const result = await agent.delete('/api/users/data').expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('DB error.'); + consoleSpy.mockRestore(); + }); + + test('should return 422 when getMail data service fails', async () => { + jest.spyOn(UserService, 'get').mockRejectedValueOnce(new Error('DB error')); + const result = await agent.get('/api/users/data/mail').expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('DB error.'); + }); + + test('should return 422 when removeAvatar update fails', async () => { + jest.spyOn(UserService, 'update').mockRejectedValueOnce(new Error('DB error')); + const result = await agent.delete('/api/users/avatar').expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('DB error.'); + }); + + afterAll(async () => { + try { + await UserService.remove(errorUser); + } catch (_) { /* cleanup – ignore errors */ } + }); + }); + // Mongoose disconnect afterAll(async () => { try { await mongooseService.disconnect(); } catch (err) { console.log(err); + expect(err).toBeFalsy(); } }); }); diff --git a/modules/users/tests/user.admin.integration.tests.js b/modules/users/tests/user.admin.integration.tests.js index 0d4bc902b..bc747638e 100644 --- a/modules/users/tests/user.admin.integration.tests.js +++ b/modules/users/tests/user.admin.integration.tests.js @@ -4,6 +4,7 @@ import request from 'supertest'; import path from 'path'; import _ from 'lodash'; +import { jest } from '@jest/globals'; import { bootstrap } from '../../../lib/app.js'; import mongooseService from '../../../lib/services/mongoose.js'; @@ -28,6 +29,7 @@ describe('User admin integration tests:', () => { agent = request.agent(init.app); } catch (err) { console.log(err); + expect(err).toBeFalsy(); } }); @@ -291,6 +293,62 @@ describe('User admin integration tests:', () => { } }); + test('should return 404 when getting a user with a non-existent id if admin', async () => { + _userEdited.roles = ['user', 'admin']; + + try { + const result = await agent.post('/api/auth/signup').send(_userEdited).expect(200); + userEdited = result.body.user; + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + + try { + // Valid ObjectId format but non-existent user + const result = await agent.get('/api/users/000000000000000000000000').expect(404); + expect(result.body.message).toBe('Not Found'); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + + try { + await UserService.remove(userEdited); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + }); + + test('should return 422 when pagination params exceed maximum of 3', async () => { + _userEdited.roles = ['user', 'admin']; + + try { + const result = await agent.post('/api/auth/signup').send(_userEdited).expect(200); + userEdited = result.body.user; + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + + try { + // 4 params separated by & exceeds the allowed max of 3 + const result = await agent.get('/api/users/page/0&10&search&extra').expect(422); + expect(result.body.message).toBeDefined(); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + + try { + await UserService.remove(userEdited); + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + }); + afterEach(async () => { // del user try { @@ -301,12 +359,81 @@ describe('User admin integration tests:', () => { }); }); + describe('Errors', () => { + let adminUser; + let targetUser; + + beforeAll(async () => { + try { + const targetResult = await agent.post('/api/auth/signup').send({ + firstName: 'Target', + lastName: 'Error', + email: 'admintarget@test.com', + password: 'W@os.jsI$Aw3$0m3', + provider: 'local', + }).expect(200); + targetUser = targetResult.body.user; + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + try { + const adminResult = await agent.post('/api/auth/signup').send({ + firstName: 'Admin', + lastName: 'Error', + email: 'adminerror@test.com', + password: 'W@os.jsI$Aw3$0m3', + provider: 'local', + roles: ['user', 'admin'], + }).expect(200); + adminUser = adminResult.body.user; + } catch (err) { + console.log(err); + expect(err).toBeFalsy(); + } + }); + + test('should return 422 when list fails', async () => { + jest.spyOn(UserService, 'list').mockRejectedValueOnce(new Error('DB error')); + const result = await agent.get('/api/users').expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('DB error.'); + }); + + test('should return 422 when admin update fails', async () => { + jest.spyOn(UserService, 'update').mockRejectedValueOnce(new Error('DB error')); + const result = await agent.put(`/api/users/${targetUser._id}`).send({ firstName: 'X' }).expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('DB error.'); + }); + + test('should return 422 when admin remove fails', async () => { + jest.spyOn(UserService, 'remove').mockRejectedValueOnce(new Error('DB error')); + const result = await agent.delete(`/api/users/${targetUser._id}`).expect(422); + expect(result.body.type).toBe('error'); + expect(result.body.message).toBe('Unprocessable Entity'); + expect(result.body.description).toBe('DB error.'); + }); + + afterAll(async () => { + try { + await UserService.remove(targetUser); + } catch (_) { /* cleanup – ignore errors */ } + try { + await UserService.remove(adminUser); + } catch (_) { /* cleanup – ignore errors */ } + }); + }); + // Mongoose disconnect afterAll(async () => { try { await mongooseService.disconnect(); } catch (err) { console.log(err); + expect(err).toBeFalsy(); } }); });