diff --git a/src/__tests__/admin.users.list.test.js b/src/__tests__/admin.users.list.test.js new file mode 100644 index 0000000..f7897bc --- /dev/null +++ b/src/__tests__/admin.users.list.test.js @@ -0,0 +1,115 @@ +const jwt = require('jsonwebtoken'); +const request = require('supertest'); + +jest.mock('../models/User.model', () => ({ + findById: jest.fn(), + find: jest.fn(), + countDocuments: jest.fn(), +})); + +const User = require('../models/User.model'); +const app = require('../app'); + +describe('GET /api/admin/users', () => { + const adminId = '507f1f77bcf86cd799439010'; + const userId = '507f1f77bcf86cd799439011'; + const secret = process.env.JWT_SECRET || 'test-secret'; + + const adminToken = jwt.sign({ sub: adminId, type: 'access' }, secret, { + expiresIn: '1h', + }); + const userToken = jwt.sign({ sub: userId, type: 'access' }, secret, { + expiresIn: '1h', + }); + + const buildFindChain = (users = []) => { + const chain = { + select: jest.fn().mockReturnThis(), + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockResolvedValue(users), + }; + User.find.mockReturnValue(chain); + return chain; + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns a paginated list of users for admins', async () => { + const users = [ + { + _id: userId, + fullName: 'Jane Doe', + email: 'jane@example.com', + role: 'user', + kycStatus: 'approved', + }, + ]; + const chain = buildFindChain(users); + + User.findById.mockResolvedValue({ _id: adminId, role: 'admin' }); + User.countDocuments.mockResolvedValue(21); + + const response = await request(app) + .get('/api/admin/users?page=2&limit=10') + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + + expect(User.find).toHaveBeenCalledWith({ deletedAt: null }); + expect(chain.skip).toHaveBeenCalledWith(10); + expect(chain.limit).toHaveBeenCalledWith(10); + expect(User.countDocuments).toHaveBeenCalledWith({ deletedAt: null }); + expect(response.body.success).toBe(true); + expect(response.body.data).toEqual({ + data: users, + total: 21, + page: 2, + totalPages: 3, + }); + }); + + it('supports search, role, and KYC status filters', async () => { + const chain = buildFindChain([]); + + User.findById.mockResolvedValue({ _id: adminId, role: 'admin' }); + User.countDocuments.mockResolvedValue(0); + + await request(app) + .get('/api/admin/users?search=jane@example.com&role=user&kycStatus=approved') + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + + const expectedQuery = { + deletedAt: null, + $or: [ + { fullName: { $regex: 'jane@example\\.com', $options: 'i' } }, + { email: { $regex: 'jane@example\\.com', $options: 'i' } }, + ], + role: 'user', + kycStatus: 'approved', + }; + + expect(User.find).toHaveBeenCalledWith(expectedQuery); + expect(User.countDocuments).toHaveBeenCalledWith(expectedQuery); + expect(chain.skip).toHaveBeenCalledWith(0); + expect(chain.limit).toHaveBeenCalledWith(10); + }); + + it('returns 403 for authenticated non-admin users', async () => { + buildFindChain([]); + + User.findById.mockResolvedValue({ _id: userId, role: 'user' }); + + const response = await request(app) + .get('/api/admin/users') + .set('Authorization', `Bearer ${userToken}`) + .expect(403); + + expect(response.body.success).toBe(false); + expect(response.body.message).toBe('Admin access required'); + expect(User.find).not.toHaveBeenCalled(); + expect(User.countDocuments).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/project.create.test.js b/src/__tests__/project.create.test.js new file mode 100644 index 0000000..a1b8bb1 --- /dev/null +++ b/src/__tests__/project.create.test.js @@ -0,0 +1,110 @@ +const jwt = require('jsonwebtoken'); +const request = require('supertest'); + +jest.mock('../models/User.model', () => ({ + findById: jest.fn(), +})); + +jest.mock('../models/Project.model', () => ({ + create: jest.fn(), +})); + +const User = require('../models/User.model'); +const Project = require('../models/Project.model'); +const app = require('../app'); + +describe('POST /api/projects', () => { + const userId = '507f1f77bcf86cd799439011'; + const token = jwt.sign( + { sub: userId, type: 'access' }, + process.env.JWT_SECRET || 'test-secret', + { expiresIn: '1h' } + ); + + const validPayload = { + title: 'Clean Water Campaign', + description: 'Providing clean water access for the community.', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('requires authentication', async () => { + const response = await request(app) + .post('/api/projects') + .send(validPayload) + .expect(401); + + expect(response.body.success).toBe(false); + expect(response.body.message).toBe('Authentication required'); + expect(Project.create).not.toHaveBeenCalled(); + }); + + it('returns 403 when the authenticated user is not KYC approved', async () => { + User.findById.mockResolvedValue({ + _id: userId, + kycStatus: 'pending', + }); + + const response = await request(app) + .post('/api/projects') + .set('Authorization', `Bearer ${token}`) + .send(validPayload) + .expect(403); + + expect(response.body.success).toBe(false); + expect(response.body.message).toBe('KYC approval is required to create a project'); + expect(Project.create).not.toHaveBeenCalled(); + }); + + it('validates required fields', async () => { + User.findById.mockResolvedValue({ + _id: userId, + kycStatus: 'approved', + }); + + const response = await request(app) + .post('/api/projects') + .set('Authorization', `Bearer ${token}`) + .send({ title: 'Hi' }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.message).toContain('Title must be at least 3 characters long'); + expect(response.body.message).toContain('Description is required'); + expect(Project.create).not.toHaveBeenCalled(); + }); + + it('creates a pending project for a KYC-approved user', async () => { + const createdProject = { + _id: '607f1f77bcf86cd799439011', + ...validPayload, + owner: userId, + status: 'pending', + documents: [], + }; + + User.findById.mockResolvedValue({ + _id: userId, + kycStatus: 'approved', + }); + Project.create.mockResolvedValue(createdProject); + + const response = await request(app) + .post('/api/projects') + .set('Authorization', `Bearer ${token}`) + .send(validPayload) + .expect(201); + + expect(Project.create).toHaveBeenCalledWith({ + title: validPayload.title, + description: validPayload.description, + owner: userId, + status: 'pending', + }); + expect(response.body.success).toBe(true); + expect(response.body.message).toBe('Project created successfully'); + expect(response.body.data).toEqual(createdProject); + }); +}); diff --git a/src/controllers/admin.users.controller.js b/src/controllers/admin.users.controller.js index 66384ba..a2ab51f 100644 --- a/src/controllers/admin.users.controller.js +++ b/src/controllers/admin.users.controller.js @@ -1,6 +1,13 @@ const User = require('../models/User.model'); const { sendSuccess, sendError } = require('../utils/response'); +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const parsePositiveInteger = (value, fallback) => { + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +}; + /** * Get a specific user profile (admin only) * @route GET /api/admin/users/:id @@ -191,19 +198,20 @@ const restoreUser = async (req, res, next) => { const listUsers = async (req, res, next) => { try { const { - page = 1, - limit = 10, search, role, kycStatus, } = req.query; + const page = parsePositiveInteger(req.query.page, 1); + const limit = parsePositiveInteger(req.query.limit, 10); const query = { deletedAt: null }; if (search) { + const searchRegex = escapeRegExp(search.trim()); query.$or = [ - { fullName: { $regex: search, $options: 'i' } }, - { email: { $regex: search, $options: 'i' } }, + { fullName: { $regex: searchRegex, $options: 'i' } }, + { email: { $regex: searchRegex, $options: 'i' } }, ]; } @@ -219,7 +227,7 @@ const listUsers = async (req, res, next) => { .select('-password -refreshTokenHash -resetPasswordToken -emailVerificationToken') .sort({ createdAt: -1 }) .skip((page - 1) * limit) - .limit(parseInt(limit)); + .limit(limit); const total = await User.countDocuments(query); @@ -228,7 +236,7 @@ const listUsers = async (req, res, next) => { { data: users, total, - page: parseInt(page), + page, totalPages: Math.ceil(total / limit), }, 200, @@ -248,4 +256,4 @@ module.exports = { listUsers, updateUserStatus, updateUserRole, -}; \ No newline at end of file +}; diff --git a/src/controllers/project.controller.js b/src/controllers/project.controller.js index 1d2bf61..9c54fd7 100644 --- a/src/controllers/project.controller.js +++ b/src/controllers/project.controller.js @@ -2,6 +2,15 @@ const Project = require('../models/Project.model'); const { sendSuccess } = require('../utils/response'); /** + * Create a new project/campaign + * POST /api/projects + */ +const createProject = async (req, res, next) => { + try { + if (req.user.kycStatus !== 'approved') { + const error = new Error('KYC approval is required to create a project'); + error.statusCode = 403; + * GET /api/projects/:id * Retrieve campaign details for a single project. */ @@ -31,7 +40,17 @@ const getProjectDetails = async (req, res, next) => { error.statusCode = 404; error.isOperational = true; return next(error); - } + + const { title, description } = req.body; + + const project = await Project.create({ + title, + description, + owner: req.userId, + status: 'pending', + }); + + return sendSuccess(res, project, 201, 'Project created successfully'); const responseProject = project.toObject(); if (responseProject.owner && responseProject.owner.fullName) { @@ -42,7 +61,7 @@ const getProjectDetails = async (req, res, next) => { const projectData = project.toObject({ getters: true }); projectData.owner = project.owner ? { fullName: project.owner.fullName } : null; - return sendSuccess(res, projectData, 200, 'Project details retrieved successfully'); + return sendSuccess(res, projectData, 200, 'Project details retrieved successfully'); } catch (error) { return next(error); } @@ -114,7 +133,7 @@ const uploadDocuments = async (req, res, next) => { } catch (error) { return next(error); } -}; +module.exports = { createProject, uploadDocuments }; module.exports = { getProjectById, uploadDocuments }; module.exports = { getProjectDetails, uploadDocuments }; diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js index 4516c18..6d51276 100644 --- a/src/routes/project.routes.js +++ b/src/routes/project.routes.js @@ -26,4 +26,4 @@ router.get('/:id', authenticateOptional, getProjectDetails); */ router.post('/:id/documents', authenticate, handleUpload, uploadDocuments); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/src/validators/project.validators.js b/src/validators/project.validators.js new file mode 100644 index 0000000..aea61f5 --- /dev/null +++ b/src/validators/project.validators.js @@ -0,0 +1,20 @@ +const Joi = require('joi'); + +const createProjectSchema = Joi.object({ + title: Joi.string().trim().min(3).max(120).required().messages({ + 'string.empty': 'Title is required', + 'string.min': 'Title must be at least 3 characters long', + 'string.max': 'Title cannot exceed 120 characters', + 'any.required': 'Title is required', + }), + description: Joi.string().trim().min(10).max(5000).required().messages({ + 'string.empty': 'Description is required', + 'string.min': 'Description must be at least 10 characters long', + 'string.max': 'Description cannot exceed 5000 characters', + 'any.required': 'Description is required', + }), +}); + +module.exports = { + createProjectSchema, +};