create-listablelabs-api 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/README.md +142 -0
- package/bin/cli.js +37 -0
- package/bin/commands/add.js +460 -0
- package/bin/commands/create.js +481 -0
- package/package.json +39 -0
- package/templates/base/.dockerignore +19 -0
- package/templates/base/.env.example +18 -0
- package/templates/base/.eslintrc.js +31 -0
- package/templates/base/Dockerfile +48 -0
- package/templates/base/README.md +295 -0
- package/templates/base/docker-compose.yml +55 -0
- package/templates/base/jest.config.js +24 -0
- package/templates/base/package.json +41 -0
- package/templates/base/src/app.js +103 -0
- package/templates/base/src/config/index.js +36 -0
- package/templates/base/src/controllers/exampleController.js +148 -0
- package/templates/base/src/database/baseModel.js +160 -0
- package/templates/base/src/database/index.js +108 -0
- package/templates/base/src/middlewares/errorHandler.js +155 -0
- package/templates/base/src/middlewares/index.js +49 -0
- package/templates/base/src/middlewares/rateLimiter.js +85 -0
- package/templates/base/src/middlewares/requestLogger.js +50 -0
- package/templates/base/src/middlewares/validator.js +107 -0
- package/templates/base/src/models/example.js +117 -0
- package/templates/base/src/models/index.js +6 -0
- package/templates/base/src/routes/v1/exampleRoutes.js +89 -0
- package/templates/base/src/routes/v1/index.js +19 -0
- package/templates/base/src/server.js +80 -0
- package/templates/base/src/utils/logger.js +61 -0
- package/templates/base/src/utils/response.js +117 -0
- package/templates/base/tests/app.test.js +215 -0
- package/templates/base/tests/setup.js +33 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const { createModel } = require('../database/baseModel');
|
|
2
|
+
const { z } = require('zod');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Zod schema for validation
|
|
6
|
+
* Use this in routes for request validation
|
|
7
|
+
*/
|
|
8
|
+
const exampleZodSchema = {
|
|
9
|
+
create: z.object({
|
|
10
|
+
name: z.string().min(1).max(255),
|
|
11
|
+
description: z.string().max(1000).optional(),
|
|
12
|
+
status: z.enum(['active', 'inactive', 'pending']).default('pending'),
|
|
13
|
+
tags: z.array(z.string()).max(10).optional(),
|
|
14
|
+
metadata: z.record(z.any()).optional(),
|
|
15
|
+
}),
|
|
16
|
+
|
|
17
|
+
update: z.object({
|
|
18
|
+
name: z.string().min(1).max(255).optional(),
|
|
19
|
+
description: z.string().max(1000).optional(),
|
|
20
|
+
status: z.enum(['active', 'inactive', 'pending']).optional(),
|
|
21
|
+
tags: z.array(z.string()).max(10).optional(),
|
|
22
|
+
metadata: z.record(z.any()).optional(),
|
|
23
|
+
}),
|
|
24
|
+
|
|
25
|
+
query: z.object({
|
|
26
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
27
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
28
|
+
status: z.enum(['active', 'inactive', 'pending']).optional(),
|
|
29
|
+
search: z.string().optional(),
|
|
30
|
+
sortBy: z.enum(['createdAt', 'updatedAt', 'name']).default('createdAt'),
|
|
31
|
+
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Mongoose model
|
|
37
|
+
*/
|
|
38
|
+
const Example = createModel(
|
|
39
|
+
'Example',
|
|
40
|
+
{
|
|
41
|
+
name: {
|
|
42
|
+
type: String,
|
|
43
|
+
required: true,
|
|
44
|
+
trim: true,
|
|
45
|
+
maxlength: 255,
|
|
46
|
+
},
|
|
47
|
+
description: {
|
|
48
|
+
type: String,
|
|
49
|
+
trim: true,
|
|
50
|
+
maxlength: 1000,
|
|
51
|
+
},
|
|
52
|
+
status: {
|
|
53
|
+
type: String,
|
|
54
|
+
enum: ['active', 'inactive', 'pending'],
|
|
55
|
+
default: 'pending',
|
|
56
|
+
},
|
|
57
|
+
tags: {
|
|
58
|
+
type: [String],
|
|
59
|
+
default: [],
|
|
60
|
+
},
|
|
61
|
+
metadata: {
|
|
62
|
+
type: Map,
|
|
63
|
+
of: String,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
// Enable soft delete
|
|
68
|
+
softDelete: true,
|
|
69
|
+
|
|
70
|
+
// Add indexes
|
|
71
|
+
indexes: [
|
|
72
|
+
{ fields: { name: 'text', description: 'text' } },
|
|
73
|
+
{ fields: { status: 1 } },
|
|
74
|
+
{ fields: { tags: 1 } },
|
|
75
|
+
{ fields: { createdAt: -1 } },
|
|
76
|
+
],
|
|
77
|
+
|
|
78
|
+
// Custom static methods
|
|
79
|
+
statics: {
|
|
80
|
+
async findByStatus(status) {
|
|
81
|
+
return this.find({ status });
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
async search(query, options = {}) {
|
|
85
|
+
const filter = {};
|
|
86
|
+
|
|
87
|
+
if (query.search) {
|
|
88
|
+
filter.$text = { $search: query.search };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (query.status) {
|
|
92
|
+
filter.status = query.status;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return this.paginate(filter, options);
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
// Custom instance methods
|
|
100
|
+
methods: {
|
|
101
|
+
activate() {
|
|
102
|
+
this.status = 'active';
|
|
103
|
+
return this.save();
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
deactivate() {
|
|
107
|
+
this.status = 'inactive';
|
|
108
|
+
return this.save();
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
Example,
|
|
116
|
+
exampleZodSchema,
|
|
117
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const { Router } = require('express');
|
|
2
|
+
const exampleController = require('../../controllers/exampleController');
|
|
3
|
+
const { validate, z } = require('../../middlewares');
|
|
4
|
+
const { exampleZodSchema } = require('../../models');
|
|
5
|
+
|
|
6
|
+
const router = Router();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Validation schemas
|
|
10
|
+
*/
|
|
11
|
+
const schemas = {
|
|
12
|
+
getById: {
|
|
13
|
+
params: z.object({
|
|
14
|
+
id: z.string().regex(/^[0-9a-fA-F]{24}$/, 'Invalid ID format'),
|
|
15
|
+
}),
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
getAll: {
|
|
19
|
+
query: exampleZodSchema.query,
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
create: {
|
|
23
|
+
body: exampleZodSchema.create,
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
update: {
|
|
27
|
+
params: z.object({
|
|
28
|
+
id: z.string().regex(/^[0-9a-fA-F]{24}$/, 'Invalid ID format'),
|
|
29
|
+
}),
|
|
30
|
+
body: exampleZodSchema.update,
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
delete: {
|
|
34
|
+
params: z.object({
|
|
35
|
+
id: z.string().regex(/^[0-9a-fA-F]{24}$/, 'Invalid ID format'),
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @route GET /api/v1/examples
|
|
42
|
+
* @desc Get all examples with pagination
|
|
43
|
+
* @access Public
|
|
44
|
+
*/
|
|
45
|
+
router.get('/', validate(schemas.getAll), exampleController.getAll);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @route GET /api/v1/examples/:id
|
|
49
|
+
* @desc Get example by ID
|
|
50
|
+
* @access Public
|
|
51
|
+
*/
|
|
52
|
+
router.get('/:id', validate(schemas.getById), exampleController.getById);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @route POST /api/v1/examples
|
|
56
|
+
* @desc Create new example
|
|
57
|
+
* @access Public
|
|
58
|
+
*/
|
|
59
|
+
router.post('/', validate(schemas.create), exampleController.create);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @route PUT /api/v1/examples/:id
|
|
63
|
+
* @desc Update example
|
|
64
|
+
* @access Public
|
|
65
|
+
*/
|
|
66
|
+
router.put('/:id', validate(schemas.update), exampleController.update);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @route DELETE /api/v1/examples/:id
|
|
70
|
+
* @desc Delete example (soft delete)
|
|
71
|
+
* @access Public
|
|
72
|
+
*/
|
|
73
|
+
router.delete('/:id', validate(schemas.delete), exampleController.remove);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @route PATCH /api/v1/examples/:id/activate
|
|
77
|
+
* @desc Activate example
|
|
78
|
+
* @access Public
|
|
79
|
+
*/
|
|
80
|
+
router.patch('/:id/activate', validate(schemas.getById), exampleController.activate);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @route PATCH /api/v1/examples/:id/deactivate
|
|
84
|
+
* @desc Deactivate example
|
|
85
|
+
* @access Public
|
|
86
|
+
*/
|
|
87
|
+
router.patch('/:id/deactivate', validate(schemas.getById), exampleController.deactivate);
|
|
88
|
+
|
|
89
|
+
module.exports = router;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const { Router } = require('express');
|
|
2
|
+
const exampleRoutes = require('./exampleRoutes');
|
|
3
|
+
|
|
4
|
+
const router = Router();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Register all route modules here
|
|
8
|
+
* Each module handles its own sub-routes
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Example routes - replace with your actual routes
|
|
12
|
+
router.use('/examples', exampleRoutes);
|
|
13
|
+
|
|
14
|
+
// Add more route modules as needed:
|
|
15
|
+
// router.use('/users', userRoutes);
|
|
16
|
+
// router.use('/products', productRoutes);
|
|
17
|
+
// router.use('/orders', orderRoutes);
|
|
18
|
+
|
|
19
|
+
module.exports = router;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const app = require('./app');
|
|
2
|
+
const config = require('./config');
|
|
3
|
+
const { logger } = require('./utils/logger');
|
|
4
|
+
const { connectDB, disconnectDB } = require('./database');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Start the server
|
|
8
|
+
*/
|
|
9
|
+
const startServer = async () => {
|
|
10
|
+
try {
|
|
11
|
+
// Connect to MongoDB Atlas
|
|
12
|
+
await connectDB();
|
|
13
|
+
|
|
14
|
+
// Start Express server
|
|
15
|
+
const server = app.listen(config.port, () => {
|
|
16
|
+
logger.info({
|
|
17
|
+
port: config.port,
|
|
18
|
+
env: config.env,
|
|
19
|
+
service: config.serviceName,
|
|
20
|
+
}, `🚀 ${config.serviceName} is running on port ${config.port}`);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Graceful shutdown handling
|
|
25
|
+
*/
|
|
26
|
+
const gracefulShutdown = async (signal) => {
|
|
27
|
+
logger.info({ signal }, 'Received shutdown signal, starting graceful shutdown...');
|
|
28
|
+
|
|
29
|
+
// Stop accepting new requests
|
|
30
|
+
server.close(async (err) => {
|
|
31
|
+
if (err) {
|
|
32
|
+
logger.error({ err }, 'Error during server close');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
logger.info('Server closed successfully');
|
|
37
|
+
|
|
38
|
+
// Close database connection
|
|
39
|
+
await disconnectDB();
|
|
40
|
+
|
|
41
|
+
logger.info('All connections closed, exiting process');
|
|
42
|
+
process.exit(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Force shutdown after timeout
|
|
46
|
+
setTimeout(() => {
|
|
47
|
+
logger.error('Forced shutdown due to timeout');
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}, 30000); // 30 seconds timeout
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Handle shutdown signals
|
|
53
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
54
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
55
|
+
|
|
56
|
+
return server;
|
|
57
|
+
} catch (err) {
|
|
58
|
+
logger.fatal({ err }, 'Failed to start server');
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Handle uncaught exceptions
|
|
65
|
+
*/
|
|
66
|
+
process.on('uncaughtException', (err) => {
|
|
67
|
+
logger.fatal({ err }, 'Uncaught exception');
|
|
68
|
+
process.exit(1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Handle unhandled promise rejections
|
|
73
|
+
*/
|
|
74
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
75
|
+
logger.fatal({ reason, promise }, 'Unhandled promise rejection');
|
|
76
|
+
process.exit(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Start the server
|
|
80
|
+
startServer();
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const pino = require('pino');
|
|
2
|
+
const config = require('../config');
|
|
3
|
+
|
|
4
|
+
// Base logger instance
|
|
5
|
+
const logger = pino({
|
|
6
|
+
name: config.serviceName,
|
|
7
|
+
level: config.logLevel,
|
|
8
|
+
|
|
9
|
+
// Custom serializers for consistent log format
|
|
10
|
+
serializers: {
|
|
11
|
+
req: (req) => ({
|
|
12
|
+
method: req.method,
|
|
13
|
+
url: req.url,
|
|
14
|
+
path: req.path,
|
|
15
|
+
params: req.params,
|
|
16
|
+
query: req.query,
|
|
17
|
+
headers: {
|
|
18
|
+
'user-agent': req.headers['user-agent'],
|
|
19
|
+
'content-type': req.headers['content-type'],
|
|
20
|
+
'x-request-id': req.headers['x-request-id'],
|
|
21
|
+
},
|
|
22
|
+
}),
|
|
23
|
+
res: (res) => ({
|
|
24
|
+
statusCode: res.statusCode,
|
|
25
|
+
}),
|
|
26
|
+
err: pino.stdSerializers.err,
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
// Add base context to all logs
|
|
30
|
+
base: {
|
|
31
|
+
service: config.serviceName,
|
|
32
|
+
env: config.env,
|
|
33
|
+
pid: process.pid,
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
// Timestamp format
|
|
37
|
+
timestamp: pino.stdTimeFunctions.isoTime,
|
|
38
|
+
|
|
39
|
+
// Pretty print in development
|
|
40
|
+
transport:
|
|
41
|
+
config.env === 'development'
|
|
42
|
+
? {
|
|
43
|
+
target: 'pino-pretty',
|
|
44
|
+
options: {
|
|
45
|
+
colorize: true,
|
|
46
|
+
translateTime: 'SYS:standard',
|
|
47
|
+
ignore: 'pid,hostname',
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
: undefined,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Create child logger with additional context
|
|
54
|
+
const createChildLogger = (context) => {
|
|
55
|
+
return logger.child(context);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
module.exports = {
|
|
59
|
+
logger,
|
|
60
|
+
createChildLogger,
|
|
61
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const { StatusCodes } = require('http-status-codes');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Standard API response formatter
|
|
5
|
+
* Ensures all responses follow the same structure
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Send success response
|
|
10
|
+
* @param {Object} res - Express response object
|
|
11
|
+
* @param {Object} data - Response data
|
|
12
|
+
* @param {number} statusCode - HTTP status code (default: 200)
|
|
13
|
+
* @param {string} message - Optional success message
|
|
14
|
+
*/
|
|
15
|
+
const sendSuccess = (res, data = null, statusCode = StatusCodes.OK, message = null) => {
|
|
16
|
+
const response = {
|
|
17
|
+
success: true,
|
|
18
|
+
...(message && { message }),
|
|
19
|
+
...(data !== null && { data }),
|
|
20
|
+
timestamp: new Date().toISOString(),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return res.status(statusCode).json(response);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Send created response (201)
|
|
28
|
+
* @param {Object} res - Express response object
|
|
29
|
+
* @param {Object} data - Created resource data
|
|
30
|
+
* @param {string} message - Optional message
|
|
31
|
+
*/
|
|
32
|
+
const sendCreated = (res, data, message = 'Resource created successfully') => {
|
|
33
|
+
return sendSuccess(res, data, StatusCodes.CREATED, message);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Send no content response (204)
|
|
38
|
+
* @param {Object} res - Express response object
|
|
39
|
+
*/
|
|
40
|
+
const sendNoContent = (res) => {
|
|
41
|
+
return res.status(StatusCodes.NO_CONTENT).send();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Send paginated response
|
|
46
|
+
* @param {Object} res - Express response object
|
|
47
|
+
* @param {Array} data - Array of items
|
|
48
|
+
* @param {Object} pagination - Pagination info
|
|
49
|
+
* @param {number} pagination.page - Current page
|
|
50
|
+
* @param {number} pagination.limit - Items per page
|
|
51
|
+
* @param {number} pagination.total - Total items
|
|
52
|
+
*/
|
|
53
|
+
const sendPaginated = (res, data, { page, limit, total }) => {
|
|
54
|
+
const totalPages = Math.ceil(total / limit);
|
|
55
|
+
|
|
56
|
+
const response = {
|
|
57
|
+
success: true,
|
|
58
|
+
data,
|
|
59
|
+
pagination: {
|
|
60
|
+
page,
|
|
61
|
+
limit,
|
|
62
|
+
total,
|
|
63
|
+
totalPages,
|
|
64
|
+
hasNextPage: page < totalPages,
|
|
65
|
+
hasPrevPage: page > 1,
|
|
66
|
+
},
|
|
67
|
+
timestamp: new Date().toISOString(),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return res.status(StatusCodes.OK).json(response);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Response builder for complex responses
|
|
75
|
+
* Usage: responseBuilder(res).data(users).message('Users fetched').status(200).send()
|
|
76
|
+
*/
|
|
77
|
+
const responseBuilder = (res) => {
|
|
78
|
+
const response = {
|
|
79
|
+
success: true,
|
|
80
|
+
timestamp: new Date().toISOString(),
|
|
81
|
+
};
|
|
82
|
+
let statusCode = StatusCodes.OK;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
data(data) {
|
|
86
|
+
response.data = data;
|
|
87
|
+
return this;
|
|
88
|
+
},
|
|
89
|
+
message(msg) {
|
|
90
|
+
response.message = msg;
|
|
91
|
+
return this;
|
|
92
|
+
},
|
|
93
|
+
status(code) {
|
|
94
|
+
statusCode = code;
|
|
95
|
+
return this;
|
|
96
|
+
},
|
|
97
|
+
meta(meta) {
|
|
98
|
+
response.meta = meta;
|
|
99
|
+
return this;
|
|
100
|
+
},
|
|
101
|
+
pagination(paginationData) {
|
|
102
|
+
response.pagination = paginationData;
|
|
103
|
+
return this;
|
|
104
|
+
},
|
|
105
|
+
send() {
|
|
106
|
+
return res.status(statusCode).json(response);
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
module.exports = {
|
|
112
|
+
sendSuccess,
|
|
113
|
+
sendCreated,
|
|
114
|
+
sendNoContent,
|
|
115
|
+
sendPaginated,
|
|
116
|
+
responseBuilder,
|
|
117
|
+
};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
const request = require('supertest');
|
|
2
|
+
const app = require('../src/app');
|
|
3
|
+
const { Example } = require('../src/models');
|
|
4
|
+
|
|
5
|
+
describe('Health Endpoints', () => {
|
|
6
|
+
describe('GET /health', () => {
|
|
7
|
+
it('should return healthy status', async () => {
|
|
8
|
+
const res = await request(app).get('/health');
|
|
9
|
+
|
|
10
|
+
expect(res.statusCode).toBe(200);
|
|
11
|
+
expect(res.body).toHaveProperty('status', 'healthy');
|
|
12
|
+
expect(res.body).toHaveProperty('service');
|
|
13
|
+
expect(res.body).toHaveProperty('timestamp');
|
|
14
|
+
expect(res.body).toHaveProperty('uptime');
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('GET /ready', () => {
|
|
19
|
+
it('should return ready status when DB is connected', async () => {
|
|
20
|
+
const res = await request(app).get('/ready');
|
|
21
|
+
|
|
22
|
+
expect(res.statusCode).toBe(200);
|
|
23
|
+
expect(res.body).toHaveProperty('status', 'ready');
|
|
24
|
+
expect(res.body.checks).toHaveProperty('database');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('Example API Endpoints', () => {
|
|
30
|
+
describe('GET /api/v1/examples', () => {
|
|
31
|
+
it('should return empty array when no examples exist', async () => {
|
|
32
|
+
const res = await request(app).get('/api/v1/examples');
|
|
33
|
+
|
|
34
|
+
expect(res.statusCode).toBe(200);
|
|
35
|
+
expect(res.body).toHaveProperty('success', true);
|
|
36
|
+
expect(res.body.data).toEqual([]);
|
|
37
|
+
expect(res.body.pagination.total).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should return paginated examples', async () => {
|
|
41
|
+
// Create test data
|
|
42
|
+
await Example.create([
|
|
43
|
+
{ name: 'Test 1' },
|
|
44
|
+
{ name: 'Test 2' },
|
|
45
|
+
{ name: 'Test 3' },
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const res = await request(app)
|
|
49
|
+
.get('/api/v1/examples')
|
|
50
|
+
.query({ page: 1, limit: 2 });
|
|
51
|
+
|
|
52
|
+
expect(res.statusCode).toBe(200);
|
|
53
|
+
expect(res.body.data).toHaveLength(2);
|
|
54
|
+
expect(res.body.pagination.page).toBe(1);
|
|
55
|
+
expect(res.body.pagination.limit).toBe(2);
|
|
56
|
+
expect(res.body.pagination.total).toBe(3);
|
|
57
|
+
expect(res.body.pagination.totalPages).toBe(2);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should filter by status', async () => {
|
|
61
|
+
await Example.create([
|
|
62
|
+
{ name: 'Active 1', status: 'active' },
|
|
63
|
+
{ name: 'Active 2', status: 'active' },
|
|
64
|
+
{ name: 'Inactive', status: 'inactive' },
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
const res = await request(app)
|
|
68
|
+
.get('/api/v1/examples')
|
|
69
|
+
.query({ status: 'active' });
|
|
70
|
+
|
|
71
|
+
expect(res.statusCode).toBe(200);
|
|
72
|
+
expect(res.body.data).toHaveLength(2);
|
|
73
|
+
expect(res.body.data.every((item) => item.status === 'active')).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('GET /api/v1/examples/:id', () => {
|
|
78
|
+
it('should return example by ID', async () => {
|
|
79
|
+
const example = await Example.create({ name: 'Test Example' });
|
|
80
|
+
|
|
81
|
+
const res = await request(app).get(`/api/v1/examples/${example._id}`);
|
|
82
|
+
|
|
83
|
+
expect(res.statusCode).toBe(200);
|
|
84
|
+
expect(res.body.success).toBe(true);
|
|
85
|
+
expect(res.body.data.name).toBe('Test Example');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should return 404 for non-existent ID', async () => {
|
|
89
|
+
const fakeId = '507f1f77bcf86cd799439011';
|
|
90
|
+
const res = await request(app).get(`/api/v1/examples/${fakeId}`);
|
|
91
|
+
|
|
92
|
+
expect(res.statusCode).toBe(404);
|
|
93
|
+
expect(res.body.success).toBe(false);
|
|
94
|
+
expect(res.body.error.code).toBe('NOT_FOUND');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should return 422 for invalid ID format', async () => {
|
|
98
|
+
const res = await request(app).get('/api/v1/examples/invalid-id');
|
|
99
|
+
|
|
100
|
+
expect(res.statusCode).toBe(422);
|
|
101
|
+
expect(res.body.success).toBe(false);
|
|
102
|
+
expect(res.body.error.code).toBe('VALIDATION_ERROR');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('POST /api/v1/examples', () => {
|
|
107
|
+
it('should create new example', async () => {
|
|
108
|
+
const res = await request(app)
|
|
109
|
+
.post('/api/v1/examples')
|
|
110
|
+
.send({ name: 'New Example', description: 'Test description' });
|
|
111
|
+
|
|
112
|
+
expect(res.statusCode).toBe(201);
|
|
113
|
+
expect(res.body.success).toBe(true);
|
|
114
|
+
expect(res.body.data.name).toBe('New Example');
|
|
115
|
+
expect(res.body.data.status).toBe('pending'); // default value
|
|
116
|
+
|
|
117
|
+
// Verify it's in the database
|
|
118
|
+
const example = await Example.findById(res.body.data.id);
|
|
119
|
+
expect(example).not.toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should return validation error for missing name', async () => {
|
|
123
|
+
const res = await request(app)
|
|
124
|
+
.post('/api/v1/examples')
|
|
125
|
+
.send({ description: 'No name provided' });
|
|
126
|
+
|
|
127
|
+
expect(res.statusCode).toBe(422);
|
|
128
|
+
expect(res.body.success).toBe(false);
|
|
129
|
+
expect(res.body.error.code).toBe('VALIDATION_ERROR');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should validate status enum', async () => {
|
|
133
|
+
const res = await request(app)
|
|
134
|
+
.post('/api/v1/examples')
|
|
135
|
+
.send({ name: 'Test', status: 'invalid-status' });
|
|
136
|
+
|
|
137
|
+
expect(res.statusCode).toBe(422);
|
|
138
|
+
expect(res.body.error.code).toBe('VALIDATION_ERROR');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('PUT /api/v1/examples/:id', () => {
|
|
143
|
+
it('should update example', async () => {
|
|
144
|
+
const example = await Example.create({ name: 'Original' });
|
|
145
|
+
|
|
146
|
+
const res = await request(app)
|
|
147
|
+
.put(`/api/v1/examples/${example._id}`)
|
|
148
|
+
.send({ name: 'Updated' });
|
|
149
|
+
|
|
150
|
+
expect(res.statusCode).toBe(200);
|
|
151
|
+
expect(res.body.success).toBe(true);
|
|
152
|
+
expect(res.body.data.name).toBe('Updated');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should return 404 for non-existent ID', async () => {
|
|
156
|
+
const fakeId = '507f1f77bcf86cd799439011';
|
|
157
|
+
const res = await request(app)
|
|
158
|
+
.put(`/api/v1/examples/${fakeId}`)
|
|
159
|
+
.send({ name: 'Updated' });
|
|
160
|
+
|
|
161
|
+
expect(res.statusCode).toBe(404);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('DELETE /api/v1/examples/:id', () => {
|
|
166
|
+
it('should soft delete example', async () => {
|
|
167
|
+
const example = await Example.create({ name: 'To Delete' });
|
|
168
|
+
|
|
169
|
+
const res = await request(app).delete(`/api/v1/examples/${example._id}`);
|
|
170
|
+
|
|
171
|
+
expect(res.statusCode).toBe(204);
|
|
172
|
+
|
|
173
|
+
// Verify soft delete (not actually removed)
|
|
174
|
+
const deleted = await Example.findOne({
|
|
175
|
+
_id: example._id,
|
|
176
|
+
includeDeleted: true,
|
|
177
|
+
});
|
|
178
|
+
expect(deleted).not.toBeNull();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('PATCH /api/v1/examples/:id/activate', () => {
|
|
183
|
+
it('should activate example', async () => {
|
|
184
|
+
const example = await Example.create({ name: 'Test', status: 'pending' });
|
|
185
|
+
|
|
186
|
+
const res = await request(app).patch(`/api/v1/examples/${example._id}/activate`);
|
|
187
|
+
|
|
188
|
+
expect(res.statusCode).toBe(200);
|
|
189
|
+
expect(res.body.data.status).toBe('active');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('PATCH /api/v1/examples/:id/deactivate', () => {
|
|
194
|
+
it('should deactivate example', async () => {
|
|
195
|
+
const example = await Example.create({ name: 'Test', status: 'active' });
|
|
196
|
+
|
|
197
|
+
const res = await request(app).patch(`/api/v1/examples/${example._id}/deactivate`);
|
|
198
|
+
|
|
199
|
+
expect(res.statusCode).toBe(200);
|
|
200
|
+
expect(res.body.data.status).toBe('inactive');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('Error Handling', () => {
|
|
206
|
+
describe('404 Not Found', () => {
|
|
207
|
+
it('should return 404 for undefined routes', async () => {
|
|
208
|
+
const res = await request(app).get('/api/v1/nonexistent');
|
|
209
|
+
|
|
210
|
+
expect(res.statusCode).toBe(404);
|
|
211
|
+
expect(res.body.success).toBe(false);
|
|
212
|
+
expect(res.body.error.code).toBe('NOT_FOUND');
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
});
|