diff --git a/src/__tests__/project.details.test.js b/src/__tests__/project.details.test.js new file mode 100644 index 0000000..1f5cd33 --- /dev/null +++ b/src/__tests__/project.details.test.js @@ -0,0 +1,154 @@ +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 = '507f1f77bcf86cd799439000'; + const ownerId = '507f1f77bcf86cd799439001'; + const adminId = '507f1f77bcf86cd799439099'; + let ownerToken; + let adminToken; + + beforeAll(() => { + ownerToken = jwt.sign( + { sub: ownerId, type: 'access' }, + process.env.JWT_SECRET || 'test-secret', + { expiresIn: '1h' } + ); + + adminToken = jwt.sign( + { sub: adminId, type: 'access' }, + process.env.JWT_SECRET || 'test-secret', + { expiresIn: '1h' } + ); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const projectResponse = (data) => ({ + ...data, + toObject() { + return { + ...data, + owner: data.owner, + }; + }, + }); + + it('returns full project details for a valid active project ID without auth', async () => { + const project = projectResponse({ + _id: projectId, + title: 'Campaign One', + description: 'A great initiative', + status: 'active', + owner: { _id: ownerId, fullName: 'Alice Cooper' }, + documents: [], + }); + + Project.findById.mockReturnValue({ + populate: jest.fn().mockResolvedValue(project), + }); + + const response = await request(app).get(`/api/projects/${projectId}`).expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toMatchObject({ + title: 'Campaign One', + description: 'A great initiative', + status: 'active', + owner: { fullName: 'Alice Cooper' }, + }); + }); + + it('returns 404 for an inactive project when not authenticated', async () => { + const project = projectResponse({ + _id: projectId, + title: 'Campaign One', + description: 'A great initiative', + status: 'inactive', + owner: { _id: ownerId, fullName: 'Alice Cooper' }, + documents: [], + }); + + Project.findById.mockReturnValue({ + populate: jest.fn().mockResolvedValue(project), + }); + + 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 full project details for an inactive project when requested by the owner', async () => { + const project = projectResponse({ + _id: projectId, + title: 'Campaign One', + description: 'A great initiative', + status: 'inactive', + owner: { _id: ownerId, fullName: 'Alice Cooper' }, + documents: [], + }); + + Project.findById.mockReturnValue({ + populate: jest.fn().mockResolvedValue(project), + }); + User.findById.mockResolvedValue({ _id: ownerId, role: 'user' }); + + const response = await request(app) + .get(`/api/projects/${projectId}`) + .set('Authorization', `Bearer ${ownerToken}`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.owner).toEqual({ fullName: 'Alice Cooper' }); + }); + + it('returns full project details for an inactive project when requested by an admin', async () => { + const project = projectResponse({ + _id: projectId, + title: 'Campaign One', + description: 'A great initiative', + status: 'inactive', + owner: { _id: ownerId, fullName: 'Alice Cooper' }, + documents: [], + }); + + Project.findById.mockReturnValue({ + populate: jest.fn().mockResolvedValue(project), + }); + User.findById.mockResolvedValue({ _id: adminId, role: 'admin' }); + + const response = await request(app) + .get(`/api/projects/${projectId}`) + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.owner).toEqual({ fullName: 'Alice Cooper' }); + }); + + it('returns 404 when project does not exist', async () => { + Project.findById.mockReturnValue({ + populate: jest.fn().mockResolvedValue(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'); + }); +}); diff --git a/src/controllers/project.controller.js b/src/controllers/project.controller.js index cce47b8..76078a8 100644 --- a/src/controllers/project.controller.js +++ b/src/controllers/project.controller.js @@ -1,6 +1,42 @@ const Project = require('../models/Project.model'); const { sendSuccess } = require('../utils/response'); +/** + * GET /api/projects/:id + * Retrieve campaign details for a single project. + */ +const getProjectDetails = async (req, res, next) => { + try { + const { id } = req.params; + + const project = await Project.findById(id).populate('owner', 'fullName'); + if (!project) { + const error = new Error('Project not found'); + error.statusCode = 404; + error.isOperational = true; + return next(error); + } + + 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'; + + if (!isActive && !isOwner && !isAdmin) { + const error = new Error('Project not found'); + error.statusCode = 404; + error.isOperational = true; + return next(error); + } + + const projectData = project.toObject({ getters: true }); + projectData.owner = project.owner ? { fullName: project.owner.fullName } : null; + + return sendSuccess(res, projectData, 200, 'Project details retrieved successfully'); + } catch (error) { + return next(error); + } +}; + /** * Upload supporting documents to a project * POST /api/projects/:id/documents @@ -69,4 +105,4 @@ const uploadDocuments = async (req, res, next) => { } }; -module.exports = { uploadDocuments }; \ No newline at end of file +module.exports = { getProjectDetails, uploadDocuments }; \ No newline at end of file diff --git a/src/middlewares/auth.optional.js b/src/middlewares/auth.optional.js new file mode 100644 index 0000000..cd4183e --- /dev/null +++ b/src/middlewares/auth.optional.js @@ -0,0 +1,59 @@ +const jwt = require('jsonwebtoken'); +const User = require('../models/User.model'); + +const authenticateOptional = async (req, res, next) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader) { + return next(); + } + + if (!authHeader.startsWith('Bearer ')) { + const error = new Error('Invalid token format'); + error.statusCode = 401; + error.isOperational = true; + return next(error); + } + + const token = authHeader.substring(7); + if (!token) { + const error = new Error('Token missing'); + error.statusCode = 401; + error.isOperational = true; + return next(error); + } + + const decoded = jwt.verify(token, process.env.JWT_SECRET); + if (decoded.type !== 'access') { + const error = new Error('Invalid token type'); + error.statusCode = 401; + error.isOperational = true; + return next(error); + } + + const user = await User.findById(decoded.sub); + if (!user) { + const error = new Error('User not found'); + error.statusCode = 401; + error.isOperational = true; + return next(error); + } + + req.user = user; + req.userId = user._id.toString(); + next(); + } catch (error) { + if (error.name === 'JsonWebTokenError') { + error.message = 'Invalid token'; + error.statusCode = 401; + error.isOperational = true; + } else if (error.name === 'TokenExpiredError') { + error.message = 'Token expired'; + error.statusCode = 401; + error.isOperational = true; + } + next(error); + } +}; + +module.exports = authenticateOptional; diff --git a/src/models/Project.model.js b/src/models/Project.model.js index 136c53f..f6d70fe 100644 --- a/src/models/Project.model.js +++ b/src/models/Project.model.js @@ -18,6 +18,7 @@ const projectSchema = new mongoose.Schema( description: { type: String, trim: true }, owner: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, documents: { type: [documentSchema], default: [] }, + status: { type: String, enum: ['active', 'inactive'], default: 'active' }, // ... add your other project fields here }, { timestamps: true } diff --git a/src/models/User.model.js b/src/models/User.model.js index 1640483..2eb078b 100644 --- a/src/models/User.model.js +++ b/src/models/User.model.js @@ -90,12 +90,11 @@ const userSchema = new mongoose.Schema( type: Date, default: null, }, - timestamps: true, - } -} - + }, + { timestamps: true } ); + // Middleware to exclude soft-deleted users from all queries userSchema.pre('find', function () { this.where({ deletedAt: null }); diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js index 2a5ae44..304289d 100644 --- a/src/routes/project.routes.js +++ b/src/routes/project.routes.js @@ -1,8 +1,15 @@ const express = require('express'); const router = express.Router(); -const { authenticate } = require('../middlewares/authenticate'); // your existing auth middleware +const authenticate = require('../middlewares/auth'); +const authenticateOptional = require('../middlewares/auth.optional'); const { handleUpload } = require('../middlewares/upload.middleware'); -const { uploadDocuments } = require('../controllers/project.controller'); +const { uploadDocuments, getProjectDetails } = require('../controllers/project.controller'); + +/** + * GET /api/projects/:id + * Retrieve campaign details for a single project. + */ +router.get('/:id', authenticateOptional, getProjectDetails); /** * POST /api/projects/:id/documents