diff --git a/src/__tests__/project.routes.test.js b/src/__tests__/project.routes.test.js new file mode 100644 index 0000000..48a3f2c --- /dev/null +++ b/src/__tests__/project.routes.test.js @@ -0,0 +1,146 @@ +const jwt = require('jsonwebtoken'); +const request = require('supertest'); + +jest.mock('../models/Project.model', () => ({ + findById: jest.fn(), +})); + +jest.mock('../models/User.model', () => ({ + findById: jest.fn(), +})); + +const Project = require('../models/Project.model'); +const User = require('../models/User.model'); +const app = require('../app'); + +describe('GET /api/projects/:id', () => { + const projectId = '507f1f77bcf86cd799439066'; + const ownerId = '507f1f77bcf86cd799439055'; + const adminId = '507f1f77bcf86cd799439099'; + + const projectDoc = { + _id: projectId, + title: 'Campaign Test', + description: 'A valid active campaign', + owner: { _id: ownerId, fullName: 'Owner Name' }, + isActive: true, + documents: [], + toObject: function () { + return { + _id: this._id, + title: this.title, + description: this.description, + owner: this.owner, + isActive: this.isActive, + documents: this.documents, + }; + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + User.findById.mockImplementation((id) => { + if (id === ownerId) { + return Promise.resolve({ _id: ownerId, role: 'user' }); + } + if (id === adminId) { + return Promise.resolve({ _id: adminId, role: 'admin' }); + } + return Promise.resolve(null); + }); + }); + + it('returns full project details for a valid active project id without auth', async () => { + Project.findById.mockReturnValueOnce({ + populate: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValueOnce(projectDoc), + }); + + const response = await request(app).get(`/api/projects/${projectId}`).expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.message).toBe('Project retrieved successfully'); + expect(response.body.data.project).toMatchObject({ + _id: projectId, + title: 'Campaign Test', + description: 'A valid active campaign', + owner: { fullName: 'Owner Name' }, + isActive: true, + }); + }); + + it('returns 404 for a non-existent project id', async () => { + Project.findById.mockReturnValueOnce({ + populate: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValueOnce(null), + }); + + const response = await request(app).get(`/api/projects/${projectId}`).expect(404); + + expect(response.body.success).toBe(false); + expect(response.body.message).toBe('Project not found'); + }); + + it('returns 404 for an inactive project if requester is not owner/admin', async () => { + Project.findById.mockReturnValueOnce({ + populate: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValueOnce({ + ...projectDoc, + isActive: false, + }), + }); + + const response = await request(app).get(`/api/projects/${projectId}`).expect(404); + + expect(response.body.success).toBe(false); + expect(response.body.message).toBe('Project not found'); + }); + + it('returns inactive project details to owner', async () => { + const token = jwt.sign( + { sub: ownerId, type: 'access' }, + process.env.JWT_SECRET || 'test-secret', + { expiresIn: '1h' } + ); + + Project.findById.mockReturnValueOnce({ + populate: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValueOnce({ + ...projectDoc, + isActive: false, + }), + }); + + const response = await request(app) + .get(`/api/projects/${projectId}`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.project.owner).toEqual({ fullName: 'Owner Name' }); + }); + + it('returns inactive project details to admin', async () => { + const token = jwt.sign( + { sub: adminId, type: 'access' }, + process.env.JWT_SECRET || 'test-secret', + { expiresIn: '1h' } + ); + + Project.findById.mockReturnValueOnce({ + populate: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValueOnce({ + ...projectDoc, + isActive: false, + }), + }); + + const response = await request(app) + .get(`/api/projects/${projectId}`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.project.owner).toEqual({ fullName: 'Owner Name' }); + }); +}); diff --git a/src/controllers/project.controller.js b/src/controllers/project.controller.js index 76078a8..1d2bf61 100644 --- a/src/controllers/project.controller.js +++ b/src/controllers/project.controller.js @@ -17,6 +17,11 @@ const getProjectDetails = async (req, res, next) => { return next(error); } + const ownerId = project.owner && project.owner._id ? project.owner._id.toString() : project.owner?.toString(); + const isOwner = req.userId && ownerId === req.userId; + const isAdmin = req.user?.role === 'admin'; + + if (project.isActive === false && !isOwner && !isAdmin) { const isActive = project.status !== 'inactive'; const isOwner = req.userId && project.owner && project.owner._id?.toString() === req.userId; const isAdmin = req.user && req.user.role === 'admin'; @@ -28,6 +33,12 @@ const getProjectDetails = async (req, res, next) => { return next(error); } + const responseProject = project.toObject(); + if (responseProject.owner && responseProject.owner.fullName) { + responseProject.owner = { fullName: responseProject.owner.fullName }; + } + + return sendSuccess(res, { project: responseProject }, 200, 'Project retrieved successfully'); const projectData = project.toObject({ getters: true }); projectData.owner = project.owner ? { fullName: project.owner.fullName } : null; @@ -105,4 +116,5 @@ const uploadDocuments = async (req, res, next) => { } }; -module.exports = { getProjectDetails, uploadDocuments }; \ No newline at end of file +module.exports = { getProjectById, uploadDocuments }; +module.exports = { getProjectDetails, uploadDocuments }; diff --git a/src/middlewares/auth.js b/src/middlewares/auth.js index aedf172..5a1d8a3 100644 --- a/src/middlewares/auth.js +++ b/src/middlewares/auth.js @@ -56,4 +56,35 @@ const authenticate = async (req, res, next) => { } }; +const optionalAuthenticate = async (req, res, next) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return next(); + } + + const token = authHeader.substring(7); + if (!token) { + return next(); + } + + const decoded = jwt.verify(token, process.env.JWT_SECRET); + if (decoded.type !== 'access') { + return next(); + } + + const user = await User.findById(decoded.sub); + if (!user) { + return next(); + } + + req.user = user; + req.userId = user._id.toString(); + next(); + } catch (_error) { + next(); + } +}; + module.exports = authenticate; +module.exports.optionalAuthenticate = optionalAuthenticate; diff --git a/src/models/Project.model.js b/src/models/Project.model.js index f6d70fe..73c9ecf 100644 --- a/src/models/Project.model.js +++ b/src/models/Project.model.js @@ -17,6 +17,7 @@ const projectSchema = new mongoose.Schema( title: { type: String, required: true, trim: true }, description: { type: String, trim: true }, owner: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + isActive: { type: Boolean, default: true }, documents: { type: [documentSchema], default: [] }, status: { type: String, enum: ['active', 'inactive'], default: 'active' }, // ... add your other project fields here diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js index 304289d..4516c18 100644 --- a/src/routes/project.routes.js +++ b/src/routes/project.routes.js @@ -1,6 +1,15 @@ const express = require('express'); const router = express.Router(); const authenticate = require('../middlewares/auth'); +const { optionalAuthenticate } = require('../middlewares/auth'); +const { handleUpload } = require('../middlewares/upload.middleware'); +const { getProjectById, uploadDocuments } = require('../controllers/project.controller'); + +/** + * GET /api/projects/:id + * Retrieve a single project by id + */ +router.get('/:id', optionalAuthenticate, getProjectById); const authenticateOptional = require('../middlewares/auth.optional'); const { handleUpload } = require('../middlewares/upload.middleware'); const { uploadDocuments, getProjectDetails } = require('../controllers/project.controller');