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
154 changes: 154 additions & 0 deletions src/__tests__/project.details.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
38 changes: 37 additions & 1 deletion src/controllers/project.controller.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -69,4 +105,4 @@ const uploadDocuments = async (req, res, next) => {
}
};

module.exports = { uploadDocuments };
module.exports = { getProjectDetails, uploadDocuments };
59 changes: 59 additions & 0 deletions src/middlewares/auth.optional.js
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions src/models/Project.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
7 changes: 3 additions & 4 deletions src/models/User.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
11 changes: 9 additions & 2 deletions src/routes/project.routes.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading