From 93d8ed86df6c61d31b53a5455f94b00df7c0d35e Mon Sep 17 00:00:00 2001 From: AbdulSnk Date: Mon, 22 Jun 2026 11:19:30 +0100 Subject: [PATCH 1/2] implement-project-endpoint --- src/__tests__/project.create.test.js | 110 ++++++++++++++++++++++++++ src/controllers/project.controller.js | 30 ++++++- src/models/Project.model.js | 10 ++- src/models/User.model.js | 4 +- src/routes/project.routes.js | 14 +++- src/validators/project.validators.js | 20 +++++ 6 files changed, 179 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/project.create.test.js create mode 100644 src/validators/project.validators.js 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/project.controller.js b/src/controllers/project.controller.js index cce47b8..c493ff6 100644 --- a/src/controllers/project.controller.js +++ b/src/controllers/project.controller.js @@ -1,6 +1,34 @@ 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; + 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'); + } catch (error) { + return next(error); + } +}; + /** * Upload supporting documents to a project * POST /api/projects/:id/documents @@ -69,4 +97,4 @@ const uploadDocuments = async (req, res, next) => { } }; -module.exports = { uploadDocuments }; \ No newline at end of file +module.exports = { createProject, uploadDocuments }; diff --git a/src/models/Project.model.js b/src/models/Project.model.js index 136c53f..ba8e992 100644 --- a/src/models/Project.model.js +++ b/src/models/Project.model.js @@ -15,12 +15,16 @@ const documentSchema = new mongoose.Schema( const projectSchema = new mongoose.Schema( { title: { type: String, required: true, trim: true }, - description: { type: String, trim: true }, + description: { type: String, required: true, trim: true }, owner: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + status: { + type: String, + enum: ['pending', 'approved', 'rejected'], + default: 'pending', + }, documents: { type: [documentSchema], default: [] }, - // ... add your other project fields here }, { timestamps: true } ); -module.exports = mongoose.model('Project', projectSchema); \ No newline at end of file +module.exports = mongoose.model('Project', projectSchema); diff --git a/src/models/User.model.js b/src/models/User.model.js index 1640483..fc92b00 100644 --- a/src/models/User.model.js +++ b/src/models/User.model.js @@ -90,10 +90,10 @@ const userSchema = new mongoose.Schema( type: Date, default: null, }, + }, + { timestamps: true, } -} - ); // Middleware to exclude soft-deleted users from all queries diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js index 2a5ae44..e3b0a64 100644 --- a/src/routes/project.routes.js +++ b/src/routes/project.routes.js @@ -1,8 +1,16 @@ const express = require('express'); const router = express.Router(); -const { authenticate } = require('../middlewares/authenticate'); // your existing auth middleware +const authenticate = require('../middlewares/auth'); const { handleUpload } = require('../middlewares/upload.middleware'); -const { uploadDocuments } = require('../controllers/project.controller'); +const validate = require('../middlewares/validate'); +const { createProjectSchema } = require('../validators/project.validators'); +const { createProject, uploadDocuments } = require('../controllers/project.controller'); + +/** + * POST /api/projects + * Create a project (KYC-approved users only) + */ +router.post('/', authenticate, validate(createProjectSchema), createProject); /** * POST /api/projects/:id/documents @@ -10,4 +18,4 @@ const { uploadDocuments } = require('../controllers/project.controller'); */ 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, +}; From 1a54976ab5c6979ebcd19b5140ed8fa41d203f0d Mon Sep 17 00:00:00 2001 From: AbdulSnk Date: Mon, 22 Jun 2026 12:05:44 +0100 Subject: [PATCH 2/2] implement List Users (Admin) Endpoin --- src/__tests__/admin.users.list.test.js | 115 ++++++++++++++++++++++ src/controllers/admin.users.controller.js | 22 +++-- 2 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/admin.users.list.test.js 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/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 +};