create-theta-code 1.0.1
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.
- package/bin/create.js +9 -0
- package/package.json +34 -0
- package/src/cli.js +21 -0
- package/src/generators/scaffoldProject.js +46 -0
- package/src/prompts/getProjectName.js +30 -0
- package/src/prompts/getTemplateChoice.js +39 -0
- package/src/utils/logger.js +29 -0
- package/templates/mongo-js/.env +12 -0
- package/templates/mongo-js/.env.example +13 -0
- package/templates/mongo-js/.eslintrc.json +24 -0
- package/templates/mongo-js/.prettierrc +9 -0
- package/templates/mongo-js/README.md +429 -0
- package/templates/mongo-js/_env.example +13 -0
- package/templates/mongo-js/_gitignore +22 -0
- package/templates/mongo-js/package-lock.json +4671 -0
- package/templates/mongo-js/package.json +48 -0
- package/templates/mongo-js/server.js +67 -0
- package/templates/mongo-js/src/config/app.config.js +72 -0
- package/templates/mongo-js/src/config/db.config.js +32 -0
- package/templates/mongo-js/src/config/env.config.js +49 -0
- package/templates/mongo-js/src/config/rateLimiter.config.js +32 -0
- package/templates/mongo-js/src/middlewares/auth.middleware.js +20 -0
- package/templates/mongo-js/src/middlewares/error.middleware.js +61 -0
- package/templates/mongo-js/src/middlewares/notFound.middleware.js +11 -0
- package/templates/mongo-js/src/middlewares/requestId.middleware.js +10 -0
- package/templates/mongo-js/src/middlewares/requireRole.middleware.js +13 -0
- package/templates/mongo-js/src/middlewares/validate.middleware.js +21 -0
- package/templates/mongo-js/src/modules/user/user.controller.js +88 -0
- package/templates/mongo-js/src/modules/user/user.model.js +45 -0
- package/templates/mongo-js/src/modules/user/user.repository.js +47 -0
- package/templates/mongo-js/src/modules/user/user.routes.js +32 -0
- package/templates/mongo-js/src/modules/user/user.service.js +87 -0
- package/templates/mongo-js/src/modules/user/user.validator.js +28 -0
- package/templates/mongo-js/src/utils/AppError.js +15 -0
- package/templates/mongo-js/src/utils/apiResponse.js +23 -0
- package/templates/mongo-js/src/utils/asyncHandler.js +7 -0
- package/templates/mongo-js/src/utils/constants.js +16 -0
- package/templates/mongo-js/src/utils/jwt.utils.js +40 -0
- package/templates/mongo-js/tests/integration/user.routes.test.js +111 -0
- package/templates/mongo-js/tests/unit/user.service.test.js +96 -0
- package/templates/pg-js/.eslintrc.json +24 -0
- package/templates/pg-js/.prettierrc +9 -0
- package/templates/pg-js/_env.example +7 -0
- package/templates/pg-js/_gitignore +20 -0
- package/templates/pg-js/package.json +50 -0
- package/templates/pg-js/prisma/schema.prisma +23 -0
- package/templates/pg-js/server.js +63 -0
- package/templates/pg-js/src/config/app.config.js +48 -0
- package/templates/pg-js/src/config/db.config.js +30 -0
- package/templates/pg-js/src/config/env.config.js +36 -0
- package/templates/pg-js/src/config/rateLimiter.config.js +22 -0
- package/templates/pg-js/src/middlewares/auth.middleware.js +32 -0
- package/templates/pg-js/src/middlewares/error.middleware.js +50 -0
- package/templates/pg-js/src/middlewares/notFound.middleware.js +11 -0
- package/templates/pg-js/src/middlewares/validate.middleware.js +21 -0
- package/templates/pg-js/src/modules/user/user.controller.js +57 -0
- package/templates/pg-js/src/modules/user/user.model.js +20 -0
- package/templates/pg-js/src/modules/user/user.repository.js +105 -0
- package/templates/pg-js/src/modules/user/user.routes.js +27 -0
- package/templates/pg-js/src/modules/user/user.service.js +81 -0
- package/templates/pg-js/src/modules/user/user.validator.js +22 -0
- package/templates/pg-js/src/utils/AppError.js +14 -0
- package/templates/pg-js/src/utils/apiResponse.js +23 -0
- package/templates/pg-js/src/utils/asyncHandler.js +7 -0
- package/templates/pg-js/src/utils/constants.js +24 -0
- package/templates/pg-js/src/utils/jwt.utils.js +39 -0
- package/templates/pg-js/tests/integration/user.routes.test.js +95 -0
- package/templates/pg-js/tests/unit/user.service.test.js +96 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// src/modules/user/user.service.js — Business logic only. Never DB queries, never HTTP.
|
|
2
|
+
|
|
3
|
+
import bcryptjs from 'bcryptjs';
|
|
4
|
+
import { AppError } from '../../utils/AppError.js';
|
|
5
|
+
import { HTTP_STATUS } from '../../utils/constants.js';
|
|
6
|
+
import envConfig from '../../config/env.config.js';
|
|
7
|
+
import { signToken, signRefreshToken } from '../../utils/jwt.utils.js';
|
|
8
|
+
import * as userRepository from './user.repository.js';
|
|
9
|
+
|
|
10
|
+
async function register(email, firstName, lastName, password) {
|
|
11
|
+
// Check if user exists
|
|
12
|
+
const existingUser = await userRepository.findByEmailPublic(email);
|
|
13
|
+
if (existingUser) {
|
|
14
|
+
throw new AppError('Email already in use', HTTP_STATUS.CONFLICT);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Hash password
|
|
18
|
+
const hashedPassword = await bcryptjs.hash(password, envConfig.BCRYPT_SALT_ROUNDS);
|
|
19
|
+
|
|
20
|
+
// Create user
|
|
21
|
+
const user = await userRepository.create({
|
|
22
|
+
email,
|
|
23
|
+
firstName,
|
|
24
|
+
lastName,
|
|
25
|
+
password: hashedPassword,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return user;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function login(email, password) {
|
|
32
|
+
// Find user with password
|
|
33
|
+
const user = await userRepository.findByEmail(email);
|
|
34
|
+
if (!user) {
|
|
35
|
+
throw new AppError('Invalid email or password', HTTP_STATUS.UNAUTHORIZED);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Verify password
|
|
39
|
+
const isPasswordValid = await bcryptjs.compare(password, user.password);
|
|
40
|
+
if (!isPasswordValid) {
|
|
41
|
+
throw new AppError('Invalid email or password', HTTP_STATUS.UNAUTHORIZED);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Generate tokens with role included
|
|
45
|
+
const token = signToken({ id: user._id, email: user.email, role: user.role });
|
|
46
|
+
const refreshToken = signRefreshToken({ id: user._id });
|
|
47
|
+
|
|
48
|
+
// Fetch clean user object - no manual delete needed
|
|
49
|
+
const cleanUser = await userRepository.findById(user._id);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
user: cleanUser,
|
|
53
|
+
token,
|
|
54
|
+
refreshToken,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function getAllUsers(limit = 10, skip = 0) {
|
|
59
|
+
const users = await userRepository.findAll(limit, skip);
|
|
60
|
+
return users;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function getUserById(id) {
|
|
64
|
+
const user = await userRepository.findById(id);
|
|
65
|
+
if (!user) {
|
|
66
|
+
throw new AppError('User not found', HTTP_STATUS.NOT_FOUND);
|
|
67
|
+
}
|
|
68
|
+
return user;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function updateUser(id, updateData) {
|
|
72
|
+
const user = await userRepository.updateById(id, updateData);
|
|
73
|
+
if (!user) {
|
|
74
|
+
throw new AppError('User not found', HTTP_STATUS.NOT_FOUND);
|
|
75
|
+
}
|
|
76
|
+
return user;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function deleteUser(id) {
|
|
80
|
+
const user = await userRepository.deleteById(id);
|
|
81
|
+
if (!user) {
|
|
82
|
+
throw new AppError('User not found', HTTP_STATUS.NOT_FOUND);
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export { register, login, getAllUsers, getUserById, updateUser, deleteUser };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// src/modules/user/user.validator.js — Zod validation schemas. Never validate in controller.
|
|
2
|
+
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
const registerSchema = z.object({
|
|
6
|
+
email: z.string().email('Invalid email format'),
|
|
7
|
+
firstName: z.string().min(2, 'First name must be at least 2 characters').max(50),
|
|
8
|
+
lastName: z.string().min(2, 'Last name must be at least 2 characters').max(50),
|
|
9
|
+
password: z
|
|
10
|
+
.string()
|
|
11
|
+
.min(8, 'Password must be at least 8 characters')
|
|
12
|
+
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
|
13
|
+
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
|
14
|
+
.regex(/[0-9]/, 'Password must contain at least one number')
|
|
15
|
+
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const loginSchema = z.object({
|
|
19
|
+
email: z.string().email('Invalid email format'),
|
|
20
|
+
password: z.string().min(1, 'Password is required'),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const updateUserSchema = z.object({
|
|
24
|
+
firstName: z.string().min(2).max(50).optional(),
|
|
25
|
+
lastName: z.string().min(2).max(50).optional(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export { registerSchema, loginSchema, updateUserSchema };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// src/utils/AppError.js — Custom Error class for operational errors. All errors inherit this.
|
|
2
|
+
|
|
3
|
+
class AppError extends Error {
|
|
4
|
+
constructor(message, statusCode, isOperational = true) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.statusCode = statusCode;
|
|
7
|
+
this.status = String(statusCode).startsWith('4') ? 'fail' : 'error';
|
|
8
|
+
this.isOperational = isOperational;
|
|
9
|
+
this.timestamp = new Date().toISOString();
|
|
10
|
+
|
|
11
|
+
Error.captureStackTrace(this, this.constructor);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export { AppError };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// src/utils/apiResponse.js — Standardized success/error response formatting. Always use these.
|
|
2
|
+
|
|
3
|
+
import { HTTP_STATUS } from './constants.js';
|
|
4
|
+
|
|
5
|
+
function sendSuccess(res, statusCode = HTTP_STATUS.OK, message = 'Success', data = null) {
|
|
6
|
+
return res.status(statusCode).json({
|
|
7
|
+
success: true,
|
|
8
|
+
message,
|
|
9
|
+
data,
|
|
10
|
+
timestamp: new Date().toISOString(),
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sendError(res, statusCode = HTTP_STATUS.INTERNAL_SERVER_ERROR, message = 'Internal Server Error', data = null) {
|
|
15
|
+
return res.status(statusCode).json({
|
|
16
|
+
success: false,
|
|
17
|
+
message,
|
|
18
|
+
data,
|
|
19
|
+
timestamp: new Date().toISOString(),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { sendSuccess, sendError };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// src/utils/constants.js — All app constants. No hardcoded strings anywhere.
|
|
2
|
+
|
|
3
|
+
const HTTP_STATUS = {
|
|
4
|
+
OK: 200,
|
|
5
|
+
CREATED: 201,
|
|
6
|
+
BAD_REQUEST: 400,
|
|
7
|
+
UNAUTHORIZED: 401,
|
|
8
|
+
FORBIDDEN: 403,
|
|
9
|
+
NOT_FOUND: 404,
|
|
10
|
+
CONFLICT: 409,
|
|
11
|
+
INTERNAL_SERVER_ERROR: 500,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const REQUEST_BODY_LIMIT = '10kb';
|
|
15
|
+
|
|
16
|
+
export { HTTP_STATUS, REQUEST_BODY_LIMIT };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// src/utils/jwt.utils.js — JWT token operations only. Never inline JWT logic anywhere.
|
|
2
|
+
|
|
3
|
+
import jwt from 'jsonwebtoken';
|
|
4
|
+
import { AppError } from './AppError.js';
|
|
5
|
+
import { HTTP_STATUS } from './constants.js';
|
|
6
|
+
import envConfig from '../config/env.config.js';
|
|
7
|
+
|
|
8
|
+
function signToken(payload, expiresIn = envConfig.JWT_EXPIRES_IN) {
|
|
9
|
+
const secret = envConfig.JWT_SECRET;
|
|
10
|
+
if (!secret) {
|
|
11
|
+
throw new AppError('JWT_SECRET not configured', HTTP_STATUS.INTERNAL_SERVER_ERROR, false);
|
|
12
|
+
}
|
|
13
|
+
return jwt.sign(payload, secret, { expiresIn });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function signRefreshToken(payload, expiresIn = envConfig.JWT_REFRESH_EXPIRES_IN) {
|
|
17
|
+
const secret = envConfig.JWT_REFRESH_SECRET;
|
|
18
|
+
if (!secret) {
|
|
19
|
+
throw new AppError('JWT_REFRESH_SECRET not configured', HTTP_STATUS.INTERNAL_SERVER_ERROR, false);
|
|
20
|
+
}
|
|
21
|
+
return jwt.sign(payload, secret, { expiresIn });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function verifyToken(token) {
|
|
25
|
+
const secret = envConfig.JWT_SECRET;
|
|
26
|
+
if (!secret) {
|
|
27
|
+
throw new AppError('JWT_SECRET not configured', HTTP_STATUS.INTERNAL_SERVER_ERROR, false);
|
|
28
|
+
}
|
|
29
|
+
return jwt.verify(token, secret);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function verifyRefreshToken(token) {
|
|
33
|
+
const secret = envConfig.JWT_REFRESH_SECRET;
|
|
34
|
+
if (!secret) {
|
|
35
|
+
throw new AppError('JWT_REFRESH_SECRET not configured', HTTP_STATUS.INTERNAL_SERVER_ERROR, false);
|
|
36
|
+
}
|
|
37
|
+
return jwt.verify(token, secret);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export { signToken, signRefreshToken, verifyToken, verifyRefreshToken };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// tests/integration/user.routes.test.js — Integration tests for HTTP layer. Real app, mocked DB.
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
4
|
+
import request from 'supertest';
|
|
5
|
+
import bcryptjs from 'bcryptjs';
|
|
6
|
+
import { createApp } from '../../src/config/app.config.js';
|
|
7
|
+
import * as userRepository from '../../src/modules/user/user.repository.js';
|
|
8
|
+
|
|
9
|
+
vi.mock('../../src/modules/user/user.repository.js');
|
|
10
|
+
vi.mock('bcryptjs');
|
|
11
|
+
|
|
12
|
+
describe('User Routes', () => {
|
|
13
|
+
let app;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
app = createApp();
|
|
17
|
+
vi.clearAllMocks();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('POST /api/v1/users/register', () => {
|
|
21
|
+
it('should register a new user', async () => {
|
|
22
|
+
vi.mocked(userRepository.findByEmailPublic).mockResolvedValueOnce(null);
|
|
23
|
+
vi.mocked(userRepository.create).mockResolvedValueOnce({
|
|
24
|
+
_id: '123',
|
|
25
|
+
email: 'test@example.com',
|
|
26
|
+
firstName: 'John',
|
|
27
|
+
lastName: 'Doe',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const response = await request(app)
|
|
31
|
+
.post('/api/v1/users/register')
|
|
32
|
+
.send({
|
|
33
|
+
email: 'test@example.com',
|
|
34
|
+
firstName: 'John',
|
|
35
|
+
lastName: 'Doe',
|
|
36
|
+
password: 'Password123!',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(response.status).toBe(201);
|
|
40
|
+
expect(response.body).toHaveProperty('success', true);
|
|
41
|
+
expect(response.body).toHaveProperty('data.user');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should return 400 on validation error', async () => {
|
|
45
|
+
const response = await request(app)
|
|
46
|
+
.post('/api/v1/users/register')
|
|
47
|
+
.send({
|
|
48
|
+
email: 'invalid-email',
|
|
49
|
+
firstName: 'John',
|
|
50
|
+
lastName: 'Doe',
|
|
51
|
+
password: 'pass',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(response.status).toBe(400);
|
|
55
|
+
expect(response.body).toHaveProperty('success', false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('POST /api/v1/users/login', () => {
|
|
60
|
+
it('should login successfully', async () => {
|
|
61
|
+
const user = {
|
|
62
|
+
_id: '123',
|
|
63
|
+
email: 'test@example.com',
|
|
64
|
+
password: '$2a$12$abcdefghijklmnopqrstuvwxyz',
|
|
65
|
+
role: 'user',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const cleanUser = {
|
|
69
|
+
_id: '123',
|
|
70
|
+
email: 'test@example.com',
|
|
71
|
+
firstName: 'John',
|
|
72
|
+
lastName: 'Doe',
|
|
73
|
+
role: 'user',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
vi.mocked(userRepository.findByEmail).mockResolvedValueOnce(user);
|
|
77
|
+
vi.mocked(bcryptjs.compare).mockResolvedValueOnce(true);
|
|
78
|
+
vi.mocked(userRepository.findById).mockResolvedValueOnce(cleanUser);
|
|
79
|
+
|
|
80
|
+
const response = await request(app)
|
|
81
|
+
.post('/api/v1/users/login')
|
|
82
|
+
.send({
|
|
83
|
+
email: 'test@example.com',
|
|
84
|
+
password: 'Password123!',
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(response.status).toBe(200);
|
|
88
|
+
expect(response.body).toHaveProperty('success', true);
|
|
89
|
+
expect(response.body.data).toHaveProperty('user');
|
|
90
|
+
expect(response.body.data.user).toHaveProperty('role', 'user');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('GET /health', () => {
|
|
95
|
+
it('should return health status', async () => {
|
|
96
|
+
const response = await request(app).get('/health');
|
|
97
|
+
|
|
98
|
+
expect(response.status).toBe(200);
|
|
99
|
+
expect(response.body).toHaveProperty('status', 'UP');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('404 handler', () => {
|
|
104
|
+
it('should return 404 for unknown routes', async () => {
|
|
105
|
+
const response = await request(app).get('/api/v1/unknown');
|
|
106
|
+
|
|
107
|
+
expect(response.status).toBe(404);
|
|
108
|
+
expect(response.body).toHaveProperty('success', false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// tests/unit/user.service.test.js — Unit tests for business logic. Mocked repository.
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
4
|
+
import * as userService from '../../src/modules/user/user.service.js';
|
|
5
|
+
import * as userRepository from '../../src/modules/user/user.repository.js';
|
|
6
|
+
import { AppError } from '../../src/utils/AppError.js';
|
|
7
|
+
import bcryptjs from 'bcryptjs';
|
|
8
|
+
|
|
9
|
+
vi.mock('../../src/modules/user/user.repository.js');
|
|
10
|
+
vi.mock('bcryptjs');
|
|
11
|
+
|
|
12
|
+
describe('User Service', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('register', () => {
|
|
18
|
+
it('should create a new user successfully', async () => {
|
|
19
|
+
const userData = {
|
|
20
|
+
email: 'test@example.com',
|
|
21
|
+
firstName: 'John',
|
|
22
|
+
lastName: 'Doe',
|
|
23
|
+
password: 'hashedPassword123',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
vi.mocked(userRepository.findByEmailPublic).mockResolvedValueOnce(null);
|
|
27
|
+
vi.mocked(bcryptjs.hash).mockResolvedValueOnce('hashedPassword123');
|
|
28
|
+
vi.mocked(userRepository.create).mockResolvedValueOnce(userData);
|
|
29
|
+
|
|
30
|
+
const result = await userService.register(
|
|
31
|
+
'test@example.com',
|
|
32
|
+
'John',
|
|
33
|
+
'Doe',
|
|
34
|
+
'password123'
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
expect(result).toEqual(userData);
|
|
38
|
+
expect(userRepository.findByEmailPublic).toHaveBeenCalledWith('test@example.com');
|
|
39
|
+
expect(bcryptjs.hash).toHaveBeenCalledWith('password123', expect.any(Number));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should throw error if email exists', async () => {
|
|
43
|
+
vi.mocked(userRepository.findByEmailPublic).mockResolvedValueOnce({ email: 'test@example.com' });
|
|
44
|
+
|
|
45
|
+
await expect(
|
|
46
|
+
userService.register('test@example.com', 'John', 'Doe', 'password123')
|
|
47
|
+
).rejects.toThrow(AppError);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('login', () => {
|
|
52
|
+
it('should return user and tokens on successful login', async () => {
|
|
53
|
+
const user = {
|
|
54
|
+
_id: '123',
|
|
55
|
+
email: 'test@example.com',
|
|
56
|
+
password: 'hashedPassword123',
|
|
57
|
+
toObject: vi.fn().mockReturnValue({ _id: '123', email: 'test@example.com' }),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
vi.mocked(userRepository.findByEmail).mockResolvedValueOnce(user);
|
|
61
|
+
vi.mocked(bcryptjs.compare).mockResolvedValueOnce(true);
|
|
62
|
+
|
|
63
|
+
const result = await userService.login('test@example.com', 'password123');
|
|
64
|
+
|
|
65
|
+
expect(result).toHaveProperty('user');
|
|
66
|
+
expect(result).toHaveProperty('token');
|
|
67
|
+
expect(result).toHaveProperty('refreshToken');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should throw error on invalid credentials', async () => {
|
|
71
|
+
vi.mocked(userRepository.findByEmail).mockResolvedValueOnce(null);
|
|
72
|
+
|
|
73
|
+
await expect(
|
|
74
|
+
userService.login('test@example.com', 'password123')
|
|
75
|
+
).rejects.toThrow(AppError);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('getUserById', () => {
|
|
80
|
+
it('should return user by id', async () => {
|
|
81
|
+
const user = { _id: '123', email: 'test@example.com' };
|
|
82
|
+
vi.mocked(userRepository.findById).mockResolvedValueOnce(user);
|
|
83
|
+
|
|
84
|
+
const result = await userService.getUserById('123');
|
|
85
|
+
|
|
86
|
+
expect(result).toEqual(user);
|
|
87
|
+
expect(userRepository.findById).toHaveBeenCalledWith('123');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should throw error if user not found', async () => {
|
|
91
|
+
vi.mocked(userRepository.findById).mockResolvedValueOnce(null);
|
|
92
|
+
|
|
93
|
+
await expect(userService.getUserById('123')).rejects.toThrow(AppError);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": ["eslint:recommended"],
|
|
3
|
+
"env": {
|
|
4
|
+
"node": true,
|
|
5
|
+
"es2022": true
|
|
6
|
+
},
|
|
7
|
+
"parserOptions": {
|
|
8
|
+
"ecmaVersion": 2022,
|
|
9
|
+
"sourceType": "module"
|
|
10
|
+
},
|
|
11
|
+
"rules": {
|
|
12
|
+
"no-console": "off",
|
|
13
|
+
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
|
|
14
|
+
"prefer-const": "error",
|
|
15
|
+
"no-var": "error",
|
|
16
|
+
"eqeqeq": ["error", "always"],
|
|
17
|
+
"curly": "error",
|
|
18
|
+
"brace-style": ["error", "1tbs"],
|
|
19
|
+
"quotes": ["error", "single", { "avoidEscape": true }],
|
|
20
|
+
"semi": ["error", "always"],
|
|
21
|
+
"comma-dangle": ["error", "always-multiline"],
|
|
22
|
+
"indent": ["error", 2]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
NODE_ENV=development
|
|
2
|
+
PORT=3000
|
|
3
|
+
DATABASE_URL=postgresql://user:password@localhost:5432/theta_app
|
|
4
|
+
JWT_SECRET=your-super-secret-jwt-key-min-32-characters-required
|
|
5
|
+
JWT_REFRESH_SECRET=your-super-secret-refresh-key-min-32-characters-required
|
|
6
|
+
CORS_ORIGIN=http://localhost:3000
|
|
7
|
+
LOG_LEVEL=info
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
.env
|
|
3
|
+
.env.local
|
|
4
|
+
.env.*.local
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
*.log
|
|
8
|
+
npm-debug.log*
|
|
9
|
+
yarn-debug.log*
|
|
10
|
+
yarn-error.log*
|
|
11
|
+
.DS_Store
|
|
12
|
+
.vscode/
|
|
13
|
+
.idea/
|
|
14
|
+
*.swp
|
|
15
|
+
*.swo
|
|
16
|
+
*~
|
|
17
|
+
.cache/
|
|
18
|
+
.env.test
|
|
19
|
+
coverage/
|
|
20
|
+
prisma/migrations/
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "my-awesome-app",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Production-grade Node.js API built with create-theta-code",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "server.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node server.js",
|
|
9
|
+
"dev": "nodemon server.js",
|
|
10
|
+
"test": "vitest",
|
|
11
|
+
"test:coverage": "vitest --coverage",
|
|
12
|
+
"lint": "eslint src/ --fix",
|
|
13
|
+
"format": "prettier --write \"src/**/*.js\"",
|
|
14
|
+
"db:migrate": "prisma migrate dev",
|
|
15
|
+
"db:generate": "prisma generate",
|
|
16
|
+
"db:reset": "prisma migrate reset"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"nodejs",
|
|
20
|
+
"express",
|
|
21
|
+
"postgresql",
|
|
22
|
+
"prisma",
|
|
23
|
+
"api"
|
|
24
|
+
],
|
|
25
|
+
"author": "Your Name",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"express": "^4.18.2",
|
|
29
|
+
"@prisma/client": "^5.7.0",
|
|
30
|
+
"bcryptjs": "^2.4.3",
|
|
31
|
+
"jsonwebtoken": "^9.1.0",
|
|
32
|
+
"dotenv": "^16.3.1",
|
|
33
|
+
"cors": "^2.8.5",
|
|
34
|
+
"helmet": "^7.1.0",
|
|
35
|
+
"express-rate-limit": "^7.1.5",
|
|
36
|
+
"morgan": "^1.10.0",
|
|
37
|
+
"zod": "^3.22.4"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"nodemon": "^3.0.1",
|
|
41
|
+
"eslint": "^8.54.0",
|
|
42
|
+
"prettier": "^3.1.0",
|
|
43
|
+
"vitest": "^0.34.6",
|
|
44
|
+
"supertest": "^6.3.3",
|
|
45
|
+
"prisma": "^5.7.0"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=18.0.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// prisma/schema.prisma — Prisma database schema. PostgreSQL with User model.
|
|
2
|
+
|
|
3
|
+
generator client {
|
|
4
|
+
provider = "prisma-client-js"
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
datasource db {
|
|
8
|
+
provider = "postgresql"
|
|
9
|
+
url = env("DATABASE_URL")
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
model User {
|
|
13
|
+
id Int @id @default(autoincrement())
|
|
14
|
+
email String @unique
|
|
15
|
+
firstName String
|
|
16
|
+
lastName String
|
|
17
|
+
password String
|
|
18
|
+
isActive Boolean @default(true)
|
|
19
|
+
createdAt DateTime @default(now())
|
|
20
|
+
updatedAt DateTime @updatedAt
|
|
21
|
+
|
|
22
|
+
@@map("users")
|
|
23
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// server.js — HTTP server startup and graceful shutdown. Zero other logic.
|
|
2
|
+
|
|
3
|
+
import { createApp } from './src/config/app.config.js';
|
|
4
|
+
import { connectDB, disconnectDB } from './src/config/db.config.js';
|
|
5
|
+
import envConfig from './src/config/env.config.js';
|
|
6
|
+
|
|
7
|
+
let server;
|
|
8
|
+
|
|
9
|
+
async function startServer() {
|
|
10
|
+
try {
|
|
11
|
+
// Connect to database
|
|
12
|
+
await connectDB();
|
|
13
|
+
|
|
14
|
+
// Create Express app
|
|
15
|
+
const app = createApp();
|
|
16
|
+
|
|
17
|
+
// Start HTTP server
|
|
18
|
+
server = app.listen(envConfig.PORT, () => {
|
|
19
|
+
console.log(`✅ Server running on port ${envConfig.PORT}`);
|
|
20
|
+
});
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.error('❌ Failed to start server:', err.message);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Graceful shutdown
|
|
28
|
+
process.on('SIGTERM', async () => {
|
|
29
|
+
console.log('📌 SIGTERM received, shutting down gracefully');
|
|
30
|
+
if (server) {
|
|
31
|
+
server.close(async () => {
|
|
32
|
+
console.log('✅ Server closed');
|
|
33
|
+
await disconnectDB();
|
|
34
|
+
process.exit(0);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
process.on('SIGINT', async () => {
|
|
40
|
+
console.log('📌 SIGINT received, shutting down gracefully');
|
|
41
|
+
if (server) {
|
|
42
|
+
server.close(async () => {
|
|
43
|
+
console.log('✅ Server closed');
|
|
44
|
+
await disconnectDB();
|
|
45
|
+
process.exit(0);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Unhandled promise rejections
|
|
51
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
52
|
+
console.error('❌ Unhandled Rejection at:', promise, 'reason:', reason);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Uncaught exceptions
|
|
57
|
+
process.on('uncaughtException', (err) => {
|
|
58
|
+
console.error('❌ Uncaught Exception:', err);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Start server
|
|
63
|
+
startServer();
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// src/config/app.config.js — Express app factory. Middleware registration. Middleware order sacred.
|
|
2
|
+
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import helmet from 'helmet';
|
|
5
|
+
import cors from 'cors';
|
|
6
|
+
import morgan from 'morgan';
|
|
7
|
+
import envConfig from './env.config.js';
|
|
8
|
+
import { globalRateLimiter } from './rateLimiter.config.js';
|
|
9
|
+
import { notFoundMiddleware } from '../middlewares/notFound.middleware.js';
|
|
10
|
+
import { errorMiddleware } from '../middlewares/error.middleware.js';
|
|
11
|
+
import { APP_CONSTANTS } from '../utils/constants.js';
|
|
12
|
+
import userRoutes from '../modules/user/user.routes.js';
|
|
13
|
+
|
|
14
|
+
function createApp() {
|
|
15
|
+
const app = express();
|
|
16
|
+
|
|
17
|
+
// Security middleware
|
|
18
|
+
app.use(helmet());
|
|
19
|
+
app.use(cors({ origin: envConfig.CORS_ORIGIN }));
|
|
20
|
+
|
|
21
|
+
// Body parser with 10kb limit
|
|
22
|
+
app.use(express.json({ limit: APP_CONSTANTS.REQUEST_BODY_LIMIT }));
|
|
23
|
+
app.use(express.urlencoded({ limit: APP_CONSTANTS.REQUEST_BODY_LIMIT }));
|
|
24
|
+
|
|
25
|
+
// Logging
|
|
26
|
+
app.use(morgan('combined'));
|
|
27
|
+
|
|
28
|
+
// Rate limiter on all /api routes
|
|
29
|
+
app.use('/api/', globalRateLimiter);
|
|
30
|
+
|
|
31
|
+
// Health check
|
|
32
|
+
app.get('/health', (req, res) => {
|
|
33
|
+
res.json({ status: 'UP', timestamp: new Date().toISOString() });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// API routes
|
|
37
|
+
app.use('/api/v1/users', userRoutes);
|
|
38
|
+
|
|
39
|
+
// 404 handler
|
|
40
|
+
app.use(notFoundMiddleware);
|
|
41
|
+
|
|
42
|
+
// Global error handler (must be last)
|
|
43
|
+
app.use(errorMiddleware);
|
|
44
|
+
|
|
45
|
+
return app;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export { createApp };
|