nodejs-express-starter 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.dockerignore +3 -0
  2. package/.editorconfig +9 -0
  3. package/.env.example +22 -0
  4. package/.eslintignore +2 -0
  5. package/.eslintrc.json +32 -0
  6. package/.gitignore +14 -0
  7. package/.prettierignore +3 -0
  8. package/.prettierrc.json +4 -0
  9. package/Dockerfile +15 -0
  10. package/LICENSE +21 -0
  11. package/README.md +440 -0
  12. package/bin/createNodejsApp.js +105 -0
  13. package/docker-compose.dev.yml +4 -0
  14. package/docker-compose.prod.yml +4 -0
  15. package/docker-compose.test.yml +4 -0
  16. package/docker-compose.yml +30 -0
  17. package/jest.config.js +9 -0
  18. package/package.json +117 -0
  19. package/src/app.js +82 -0
  20. package/src/config/config.js +64 -0
  21. package/src/config/logger.js +26 -0
  22. package/src/config/morgan.js +25 -0
  23. package/src/config/passport.js +30 -0
  24. package/src/config/roles.js +12 -0
  25. package/src/config/tokens.js +10 -0
  26. package/src/controllers/auth.controller.js +59 -0
  27. package/src/controllers/index.js +2 -0
  28. package/src/controllers/user.controller.js +43 -0
  29. package/src/docs/components.yml +92 -0
  30. package/src/docs/swaggerDef.js +21 -0
  31. package/src/index.js +57 -0
  32. package/src/middlewares/auth.js +33 -0
  33. package/src/middlewares/error.js +47 -0
  34. package/src/middlewares/rateLimiter.js +11 -0
  35. package/src/middlewares/requestId.js +14 -0
  36. package/src/middlewares/validate.js +21 -0
  37. package/src/models/index.js +2 -0
  38. package/src/models/plugins/index.js +2 -0
  39. package/src/models/plugins/paginate.plugin.js +70 -0
  40. package/src/models/plugins/toJSON.plugin.js +43 -0
  41. package/src/models/token.model.js +44 -0
  42. package/src/models/user.model.js +91 -0
  43. package/src/routes/v1/auth.route.js +291 -0
  44. package/src/routes/v1/docs.route.js +21 -0
  45. package/src/routes/v1/health.route.js +43 -0
  46. package/src/routes/v1/index.js +39 -0
  47. package/src/routes/v1/user.route.js +252 -0
  48. package/src/services/auth.service.js +99 -0
  49. package/src/services/email.service.js +63 -0
  50. package/src/services/index.js +4 -0
  51. package/src/services/token.service.js +123 -0
  52. package/src/services/user.service.js +89 -0
  53. package/src/utils/ApiError.js +14 -0
  54. package/src/utils/catchAsync.js +5 -0
  55. package/src/utils/pick.js +17 -0
  56. package/src/validations/auth.validation.js +60 -0
  57. package/src/validations/custom.validation.js +21 -0
  58. package/src/validations/index.js +2 -0
  59. package/src/validations/user.validation.js +54 -0
  60. package/tests/fixtures/token.fixture.js +14 -0
  61. package/tests/fixtures/user.fixture.js +46 -0
  62. package/tests/integration/auth.test.js +587 -0
  63. package/tests/integration/docs.test.js +14 -0
  64. package/tests/integration/health.test.js +32 -0
  65. package/tests/integration/user.test.js +625 -0
  66. package/tests/unit/middlewares/error.test.js +168 -0
  67. package/tests/unit/models/plugins/paginate.plugin.test.js +61 -0
  68. package/tests/unit/models/plugins/toJSON.plugin.test.js +89 -0
  69. package/tests/unit/models/user.model.test.js +57 -0
  70. package/tests/utils/setupTestDB.js +18 -0
@@ -0,0 +1,168 @@
1
+ const mongoose = require('mongoose');
2
+ const httpStatus = require('http-status');
3
+ const httpMocks = require('node-mocks-http');
4
+ const { errorConverter, errorHandler } = require('../../../src/middlewares/error');
5
+ const ApiError = require('../../../src/utils/ApiError');
6
+ const config = require('../../../src/config/config');
7
+ const logger = require('../../../src/config/logger');
8
+
9
+ describe('Error middlewares', () => {
10
+ describe('Error converter', () => {
11
+ test('should return the same ApiError object it was called with', () => {
12
+ const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error');
13
+ const next = jest.fn();
14
+
15
+ errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next);
16
+
17
+ expect(next).toHaveBeenCalledWith(error);
18
+ });
19
+
20
+ test('should convert an Error to ApiError and preserve its status and message', () => {
21
+ const error = new Error('Any error');
22
+ error.statusCode = httpStatus.BAD_REQUEST;
23
+ const next = jest.fn();
24
+
25
+ errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next);
26
+
27
+ expect(next).toHaveBeenCalledWith(expect.any(ApiError));
28
+ expect(next).toHaveBeenCalledWith(
29
+ expect.objectContaining({
30
+ statusCode: error.statusCode,
31
+ message: error.message,
32
+ isOperational: false,
33
+ }),
34
+ );
35
+ });
36
+
37
+ test('should convert an Error without status to ApiError with status 500', () => {
38
+ const error = new Error('Any error');
39
+ const next = jest.fn();
40
+
41
+ errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next);
42
+
43
+ expect(next).toHaveBeenCalledWith(expect.any(ApiError));
44
+ expect(next).toHaveBeenCalledWith(
45
+ expect.objectContaining({
46
+ statusCode: httpStatus.INTERNAL_SERVER_ERROR,
47
+ message: error.message,
48
+ isOperational: false,
49
+ }),
50
+ );
51
+ });
52
+
53
+ test('should convert an Error without message to ApiError with default message of that http status', () => {
54
+ const error = new Error();
55
+ error.statusCode = httpStatus.BAD_REQUEST;
56
+ const next = jest.fn();
57
+
58
+ errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next);
59
+
60
+ expect(next).toHaveBeenCalledWith(expect.any(ApiError));
61
+ expect(next).toHaveBeenCalledWith(
62
+ expect.objectContaining({
63
+ statusCode: error.statusCode,
64
+ message: httpStatus[error.statusCode],
65
+ isOperational: false,
66
+ }),
67
+ );
68
+ });
69
+
70
+ test('should convert a Mongoose error to ApiError with status 400 and preserve its message', () => {
71
+ const error = new mongoose.Error('Any mongoose error');
72
+ const next = jest.fn();
73
+
74
+ errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next);
75
+
76
+ expect(next).toHaveBeenCalledWith(expect.any(ApiError));
77
+ expect(next).toHaveBeenCalledWith(
78
+ expect.objectContaining({
79
+ statusCode: httpStatus.BAD_REQUEST,
80
+ message: error.message,
81
+ isOperational: false,
82
+ }),
83
+ );
84
+ });
85
+
86
+ test('should convert any other object to ApiError with status 500 and its message', () => {
87
+ const error = {};
88
+ const next = jest.fn();
89
+
90
+ errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next);
91
+
92
+ expect(next).toHaveBeenCalledWith(expect.any(ApiError));
93
+ expect(next).toHaveBeenCalledWith(
94
+ expect.objectContaining({
95
+ statusCode: httpStatus.INTERNAL_SERVER_ERROR,
96
+ message: httpStatus[httpStatus.INTERNAL_SERVER_ERROR],
97
+ isOperational: false,
98
+ }),
99
+ );
100
+ });
101
+ });
102
+
103
+ describe('Error handler', () => {
104
+ beforeEach(() => {
105
+ jest.spyOn(logger, 'error').mockImplementation(() => {});
106
+ });
107
+
108
+ test('should send proper error response and put the error message in res.locals', () => {
109
+ const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error');
110
+ const res = httpMocks.createResponse();
111
+ const sendSpy = jest.spyOn(res, 'send');
112
+
113
+ errorHandler(error, httpMocks.createRequest(), res);
114
+
115
+ expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ code: error.statusCode, message: error.message }));
116
+ expect(res.locals.errorMessage).toBe(error.message);
117
+ });
118
+
119
+ test('should put the error stack in the response if in development mode', () => {
120
+ config.env = 'development';
121
+ const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error');
122
+ const res = httpMocks.createResponse();
123
+ const sendSpy = jest.spyOn(res, 'send');
124
+
125
+ errorHandler(error, httpMocks.createRequest(), res);
126
+
127
+ expect(sendSpy).toHaveBeenCalledWith(
128
+ expect.objectContaining({ code: error.statusCode, message: error.message, stack: error.stack }),
129
+ );
130
+ config.env = process.env.NODE_ENV;
131
+ });
132
+
133
+ test('should send internal server error status and message if in production mode and error is not operational', () => {
134
+ config.env = 'production';
135
+ const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error', false);
136
+ const res = httpMocks.createResponse();
137
+ const sendSpy = jest.spyOn(res, 'send');
138
+
139
+ errorHandler(error, httpMocks.createRequest(), res);
140
+
141
+ expect(sendSpy).toHaveBeenCalledWith(
142
+ expect.objectContaining({
143
+ code: httpStatus.INTERNAL_SERVER_ERROR,
144
+ message: httpStatus[httpStatus.INTERNAL_SERVER_ERROR],
145
+ }),
146
+ );
147
+ expect(res.locals.errorMessage).toBe(error.message);
148
+ config.env = process.env.NODE_ENV;
149
+ });
150
+
151
+ test('should preserve original error status and message if in production mode and error is operational', () => {
152
+ config.env = 'production';
153
+ const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error');
154
+ const res = httpMocks.createResponse();
155
+ const sendSpy = jest.spyOn(res, 'send');
156
+
157
+ errorHandler(error, httpMocks.createRequest(), res);
158
+
159
+ expect(sendSpy).toHaveBeenCalledWith(
160
+ expect.objectContaining({
161
+ code: error.statusCode,
162
+ message: error.message,
163
+ }),
164
+ );
165
+ config.env = process.env.NODE_ENV;
166
+ });
167
+ });
168
+ });
@@ -0,0 +1,61 @@
1
+ const mongoose = require('mongoose');
2
+ const setupTestDB = require('../../../utils/setupTestDB');
3
+ const paginate = require('../../../../src/models/plugins/paginate.plugin');
4
+
5
+ const projectSchema = mongoose.Schema({
6
+ name: {
7
+ type: String,
8
+ required: true,
9
+ },
10
+ });
11
+
12
+ projectSchema.virtual('tasks', {
13
+ ref: 'Task',
14
+ localField: '_id',
15
+ foreignField: 'project',
16
+ });
17
+
18
+ projectSchema.plugin(paginate);
19
+ const Project = mongoose.model('Project', projectSchema);
20
+
21
+ const taskSchema = mongoose.Schema({
22
+ name: {
23
+ type: String,
24
+ required: true,
25
+ },
26
+ project: {
27
+ type: mongoose.SchemaTypes.ObjectId,
28
+ ref: 'Project',
29
+ required: true,
30
+ },
31
+ });
32
+
33
+ taskSchema.plugin(paginate);
34
+ const Task = mongoose.model('Task', taskSchema);
35
+
36
+ setupTestDB();
37
+
38
+ describe('paginate plugin', () => {
39
+ describe('populate option', () => {
40
+ test('should populate the specified data fields', async () => {
41
+ const project = await Project.create({ name: 'Project One' });
42
+ const task = await Task.create({ name: 'Task One', project: project._id });
43
+
44
+ const taskPages = await Task.paginate({ _id: task._id }, { populate: 'project' });
45
+
46
+ expect(taskPages.results[0].project).toHaveProperty('_id', project._id);
47
+ });
48
+
49
+ test('should populate nested fields', async () => {
50
+ const project = await Project.create({ name: 'Project One' });
51
+ const task = await Task.create({ name: 'Task One', project: project._id });
52
+
53
+ const projectPages = await Project.paginate({ _id: project._id }, { populate: 'tasks.project' });
54
+ const { tasks } = projectPages.results[0];
55
+
56
+ expect(tasks).toHaveLength(1);
57
+ expect(tasks[0]).toHaveProperty('_id', task._id);
58
+ expect(tasks[0].project).toHaveProperty('_id', project._id);
59
+ });
60
+ });
61
+ });
@@ -0,0 +1,89 @@
1
+ const mongoose = require('mongoose');
2
+ const { toJSON } = require('../../../../src/models/plugins');
3
+
4
+ describe('toJSON plugin', () => {
5
+ let connection;
6
+
7
+ beforeEach(() => {
8
+ connection = mongoose.createConnection();
9
+ });
10
+
11
+ it('should replace _id with id', () => {
12
+ const schema = mongoose.Schema();
13
+ schema.plugin(toJSON);
14
+ const Model = connection.model('Model', schema);
15
+ const doc = new Model();
16
+ expect(doc.toJSON()).not.toHaveProperty('_id');
17
+ expect(doc.toJSON()).toHaveProperty('id', doc._id.toString());
18
+ });
19
+
20
+ it('should remove __v', () => {
21
+ const schema = mongoose.Schema();
22
+ schema.plugin(toJSON);
23
+ const Model = connection.model('Model', schema);
24
+ const doc = new Model();
25
+ expect(doc.toJSON()).not.toHaveProperty('__v');
26
+ });
27
+
28
+ it('should remove createdAt and updatedAt', () => {
29
+ const schema = mongoose.Schema({}, { timestamps: true });
30
+ schema.plugin(toJSON);
31
+ const Model = connection.model('Model', schema);
32
+ const doc = new Model();
33
+ expect(doc.toJSON()).not.toHaveProperty('createdAt');
34
+ expect(doc.toJSON()).not.toHaveProperty('updatedAt');
35
+ });
36
+
37
+ it('should remove any path set as private', () => {
38
+ const schema = mongoose.Schema({
39
+ public: { type: String },
40
+ private: { type: String, private: true },
41
+ });
42
+ schema.plugin(toJSON);
43
+ const Model = connection.model('Model', schema);
44
+ const doc = new Model({ public: 'some public value', private: 'some private value' });
45
+ expect(doc.toJSON()).not.toHaveProperty('private');
46
+ expect(doc.toJSON()).toHaveProperty('public');
47
+ });
48
+
49
+ it('should remove any nested paths set as private', () => {
50
+ const schema = mongoose.Schema({
51
+ public: { type: String },
52
+ nested: {
53
+ private: { type: String, private: true },
54
+ },
55
+ });
56
+ schema.plugin(toJSON);
57
+ const Model = connection.model('Model', schema);
58
+ const doc = new Model({
59
+ public: 'some public value',
60
+ nested: {
61
+ private: 'some nested private value',
62
+ },
63
+ });
64
+ expect(doc.toJSON()).not.toHaveProperty('nested.private');
65
+ expect(doc.toJSON()).toHaveProperty('public');
66
+ });
67
+
68
+ it('should also call the schema toJSON transform function', () => {
69
+ const schema = mongoose.Schema(
70
+ {
71
+ public: { type: String },
72
+ private: { type: String },
73
+ },
74
+ {
75
+ toJSON: {
76
+ transform: (doc, ret) => {
77
+ // eslint-disable-next-line no-param-reassign
78
+ delete ret.private;
79
+ },
80
+ },
81
+ },
82
+ );
83
+ schema.plugin(toJSON);
84
+ const Model = connection.model('Model', schema);
85
+ const doc = new Model({ public: 'some public value', private: 'some private value' });
86
+ expect(doc.toJSON()).not.toHaveProperty('private');
87
+ expect(doc.toJSON()).toHaveProperty('public');
88
+ });
89
+ });
@@ -0,0 +1,57 @@
1
+ const { faker } = require('@faker-js/faker');
2
+ const { User } = require('../../../src/models');
3
+
4
+ describe('User model', () => {
5
+ describe('User validation', () => {
6
+ let newUser;
7
+ beforeEach(() => {
8
+ newUser = {
9
+ name: faker.person.fullName(),
10
+ email: faker.internet.email().toLowerCase(),
11
+ password: 'password1',
12
+ role: 'user',
13
+ };
14
+ });
15
+
16
+ test('should correctly validate a valid user', async () => {
17
+ await expect(new User(newUser).validate()).resolves.toBeUndefined();
18
+ });
19
+
20
+ test('should throw a validation error if email is invalid', async () => {
21
+ newUser.email = 'invalidEmail';
22
+ await expect(new User(newUser).validate()).rejects.toThrow();
23
+ });
24
+
25
+ test('should throw a validation error if password length is less than 8 characters', async () => {
26
+ newUser.password = 'passwo1';
27
+ await expect(new User(newUser).validate()).rejects.toThrow();
28
+ });
29
+
30
+ test('should throw a validation error if password does not contain numbers', async () => {
31
+ newUser.password = 'password';
32
+ await expect(new User(newUser).validate()).rejects.toThrow();
33
+ });
34
+
35
+ test('should throw a validation error if password does not contain letters', async () => {
36
+ newUser.password = '11111111';
37
+ await expect(new User(newUser).validate()).rejects.toThrow();
38
+ });
39
+
40
+ test('should throw a validation error if role is unknown', async () => {
41
+ newUser.role = 'invalid';
42
+ await expect(new User(newUser).validate()).rejects.toThrow();
43
+ });
44
+ });
45
+
46
+ describe('User toJSON()', () => {
47
+ test('should not return user password when toJSON is called', () => {
48
+ const newUser = {
49
+ name: faker.person.fullName(),
50
+ email: faker.internet.email().toLowerCase(),
51
+ password: 'password1',
52
+ role: 'user',
53
+ };
54
+ expect(new User(newUser).toJSON()).not.toHaveProperty('password');
55
+ });
56
+ });
57
+ });
@@ -0,0 +1,18 @@
1
+ const mongoose = require('mongoose');
2
+ const config = require('../../src/config/config');
3
+
4
+ const setupTestDB = () => {
5
+ beforeAll(async () => {
6
+ await mongoose.connect(config.mongoose.url, config.mongoose.options);
7
+ });
8
+
9
+ beforeEach(async () => {
10
+ await Promise.all(Object.values(mongoose.connection.collections).map(async (collection) => collection.deleteMany()));
11
+ });
12
+
13
+ afterAll(async () => {
14
+ await mongoose.disconnect();
15
+ });
16
+ };
17
+
18
+ module.exports = setupTestDB;