Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions src/__tests__/admin.users.list.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
110 changes: 110 additions & 0 deletions src/__tests__/project.create.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
22 changes: 15 additions & 7 deletions src/controllers/admin.users.controller.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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' } },
];
}

Expand All @@ -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);

Expand All @@ -228,7 +236,7 @@ const listUsers = async (req, res, next) => {
{
data: users,
total,
page: parseInt(page),
page,
totalPages: Math.ceil(total / limit),
},
200,
Expand All @@ -248,4 +256,4 @@ module.exports = {
listUsers,
updateUserStatus,
updateUserRole,
};
};
25 changes: 22 additions & 3 deletions src/controllers/project.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
Expand Down Expand Up @@ -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 };
2 changes: 1 addition & 1 deletion src/routes/project.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@ router.get('/:id', authenticateOptional, getProjectDetails);
*/
router.post('/:id/documents', authenticate, handleUpload, uploadDocuments);

module.exports = router;
module.exports = router;
20 changes: 20 additions & 0 deletions src/validators/project.validators.js
Original file line number Diff line number Diff line change
@@ -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,
};
Loading