const request = require('supertest'); const express = require('express'); // Mock dependencies before requiring the route jest.mock('../../../models', () => ({ ForumPost: { findAndCountAll: jest.fn(), findByPk: jest.fn(), findOne: jest.fn(), findAll: jest.fn(), create: jest.fn(), }, ForumComment: { findAll: jest.fn(), findByPk: jest.fn(), create: jest.fn(), count: jest.fn(), destroy: jest.fn(), }, PostTag: { findAll: jest.fn(), findOrCreate: jest.fn(), create: jest.fn(), destroy: jest.fn(), }, User: { findByPk: jest.fn(), }, sequelize: { transaction: jest.fn(() => ({ commit: jest.fn(), rollback: jest.fn(), })), }, })); jest.mock('sequelize', () => ({ Op: { or: Symbol('or'), iLike: Symbol('iLike'), in: Symbol('in'), ne: Symbol('ne'), }, fn: jest.fn((name, col) => ({ fn: name, col })), col: jest.fn((name) => ({ col: name })), })); jest.mock('../../../middleware/auth', () => ({ authenticateToken: (req, res, next) => { req.user = { id: 'user-123', role: 'user', isVerified: true }; next(); }, requireAdmin: (req, res, next) => { if (req.user && req.user.role === 'admin') { next(); } else { res.status(403).json({ error: 'Admin access required' }); } }, optionalAuth: (req, res, next) => next(), })); jest.mock('../../../utils/logger', () => ({ info: jest.fn(), error: jest.fn(), warn: jest.fn(), withRequestId: jest.fn(() => ({ info: jest.fn(), error: jest.fn(), warn: jest.fn(), })), })); jest.mock('../../../services/email', () => ({ forum: { sendNewPostNotification: jest.fn().mockResolvedValue(), sendNewCommentNotification: jest.fn().mockResolvedValue(), sendAnswerAcceptedNotification: jest.fn().mockResolvedValue(), sendReplyNotification: jest.fn().mockResolvedValue(), }, })); jest.mock('../../../services/googleMapsService', () => ({ geocodeAddress: jest.fn().mockResolvedValue({ lat: 40.7128, lng: -74.006 }), })); jest.mock('../../../services/locationService', () => ({ getOrCreateLocation: jest.fn().mockResolvedValue({ id: 'loc-123' }), })); jest.mock('../../../utils/s3KeyValidator', () => ({ validateS3Keys: jest.fn().mockReturnValue({ valid: true }), })); jest.mock('../../../config/imageLimits', () => ({ IMAGE_LIMITS: { forum: 10 }, })); const { ForumPost, ForumComment, PostTag, User } = require('../../../models'); const forumRoutes = require('../../../routes/forum'); const app = express(); app.use(express.json()); app.use('/forum', forumRoutes); // Error handler app.use((err, req, res, next) => { res.status(500).json({ error: err.message }); }); describe('Forum Routes', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('GET /forum/posts', () => { it('should return paginated posts', async () => { const mockPosts = [ { id: 'post-1', title: 'Test Post', content: 'Test content', category: 'question', status: 'open', commentCount: 5, viewCount: 100, author: { id: 'user-1', firstName: 'John', lastName: 'Doe' }, tags: [{ id: 'tag-1', name: 'javascript' }], toJSON: function() { return this; } }, ]; ForumPost.findAndCountAll.mockResolvedValue({ count: 1, rows: mockPosts, }); const response = await request(app) .get('/forum/posts') .query({ page: 1, limit: 20 }); expect(response.status).toBe(200); expect(response.body.posts).toHaveLength(1); expect(response.body.posts[0].title).toBe('Test Post'); expect(response.body.totalPages).toBe(1); expect(response.body.currentPage).toBe(1); expect(response.body.totalPosts).toBe(1); }); it('should filter posts by category', async () => { ForumPost.findAndCountAll.mockResolvedValue({ count: 0, rows: [], }); const response = await request(app) .get('/forum/posts') .query({ category: 'question' }); expect(response.status).toBe(200); expect(ForumPost.findAndCountAll).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ category: 'question', }), }) ); }); it('should search posts by title and content', async () => { ForumPost.findAndCountAll.mockResolvedValue({ count: 0, rows: [], }); const response = await request(app) .get('/forum/posts') .query({ search: 'javascript' }); expect(response.status).toBe(200); expect(ForumPost.findAndCountAll).toHaveBeenCalled(); }); it('should sort posts by different criteria', async () => { ForumPost.findAndCountAll.mockResolvedValue({ count: 0, rows: [], }); const response = await request(app) .get('/forum/posts') .query({ sort: 'comments' }); expect(response.status).toBe(200); expect(ForumPost.findAndCountAll).toHaveBeenCalledWith( expect.objectContaining({ order: expect.arrayContaining([ ['commentCount', 'DESC'], ]), }) ); }); it('should handle database errors', async () => { ForumPost.findAndCountAll.mockRejectedValue(new Error('Database error')); const response = await request(app) .get('/forum/posts'); expect(response.status).toBe(500); }); }); describe('GET /forum/posts/:id', () => { it('should return a single post with comments', async () => { const mockPost = { id: 'post-1', title: 'Test Post', content: 'Test content', viewCount: 10, isDeleted: false, comments: [], increment: jest.fn().mockResolvedValue(), toJSON: function() { const { increment, toJSON, ...rest } = this; return rest; }, author: { id: 'user-1', firstName: 'John', lastName: 'Doe', role: 'user' }, tags: [], }; ForumPost.findByPk.mockResolvedValue(mockPost); const response = await request(app) .get('/forum/posts/post-1'); expect(response.status).toBe(200); expect(response.body.title).toBe('Test Post'); expect(mockPost.increment).toHaveBeenCalledWith('viewCount', { silent: true }); }); it('should return 404 for non-existent post', async () => { ForumPost.findByPk.mockResolvedValue(null); const response = await request(app) .get('/forum/posts/non-existent'); expect(response.status).toBe(404); expect(response.body.error).toBe('Post not found'); }); it('should return 404 for deleted post (non-admin)', async () => { const mockPost = { id: 'post-1', isDeleted: true, }; ForumPost.findByPk.mockResolvedValue(mockPost); const response = await request(app) .get('/forum/posts/post-1'); expect(response.status).toBe(404); expect(response.body.error).toBe('Post not found'); }); }); describe('POST /forum/posts', () => { const validPostData = { title: 'New Forum Post', content: 'This is the content of the post', category: 'question', tags: ['javascript', 'react'], }; it('should create a new post successfully', async () => { const mockCreatedPost = { id: 'new-post-id', title: 'New Forum Post', content: 'This is the content of the post', category: 'question', authorId: 'user-123', status: 'open', }; const mockPostWithDetails = { ...mockCreatedPost, author: { id: 'user-123', firstName: 'John', lastName: 'Doe' }, tags: [{ id: 'tag-1', tagName: 'javascript' }], toJSON: function() { return this; }, }; ForumPost.create.mockResolvedValue(mockCreatedPost); // After create, findByPk is called to get post with details ForumPost.findByPk.mockResolvedValue(mockPostWithDetails); const response = await request(app) .post('/forum/posts') .send(validPostData); expect(response.status).toBe(201); expect(ForumPost.create).toHaveBeenCalledWith( expect.objectContaining({ title: 'New Forum Post', content: 'This is the content of the post', category: 'question', authorId: 'user-123', }) ); }); it('should handle Sequelize validation error for missing title', async () => { const validationError = new Error('Validation error'); validationError.name = 'SequelizeValidationError'; ForumPost.create.mockRejectedValue(validationError); const response = await request(app) .post('/forum/posts') .send({ content: 'Content without title', category: 'question' }); expect(response.status).toBe(500); }); it('should handle Sequelize validation error for missing content', async () => { const validationError = new Error('Validation error'); validationError.name = 'SequelizeValidationError'; ForumPost.create.mockRejectedValue(validationError); const response = await request(app) .post('/forum/posts') .send({ title: 'Title without content', category: 'question' }); expect(response.status).toBe(500); }); it('should handle Sequelize validation error for missing category', async () => { const validationError = new Error('Validation error'); validationError.name = 'SequelizeValidationError'; ForumPost.create.mockRejectedValue(validationError); const response = await request(app) .post('/forum/posts') .send({ title: 'Title', content: 'Content' }); expect(response.status).toBe(500); }); it('should handle Sequelize validation error for invalid category', async () => { const validationError = new Error('Validation error'); validationError.name = 'SequelizeValidationError'; ForumPost.create.mockRejectedValue(validationError); const response = await request(app) .post('/forum/posts') .send({ title: 'Title', content: 'Content', category: 'invalid' }); expect(response.status).toBe(500); }); it('should handle Sequelize validation error for title too short', async () => { const validationError = new Error('Validation error'); validationError.name = 'SequelizeValidationError'; ForumPost.create.mockRejectedValue(validationError); const response = await request(app) .post('/forum/posts') .send({ title: 'Hi', content: 'Content', category: 'question' }); expect(response.status).toBe(500); }); }); describe('PUT /forum/posts/:id', () => { it('should update own post successfully', async () => { const mockPost = { id: 'post-1', authorId: 'user-123', title: 'Original Title', content: 'Original content', isDeleted: false, setTags: jest.fn().mockResolvedValue(), update: jest.fn().mockResolvedValue(), reload: jest.fn().mockResolvedValue(), toJSON: function() { return this; }, }; ForumPost.findByPk.mockResolvedValue(mockPost); PostTag.findOrCreate.mockResolvedValue([{ id: 'tag-1', name: 'updated' }]); const response = await request(app) .put('/forum/posts/post-1') .send({ title: 'Updated Title', content: 'Updated content' }); expect(response.status).toBe(200); expect(mockPost.update).toHaveBeenCalledWith( expect.objectContaining({ title: 'Updated Title', content: 'Updated content', }) ); }); it('should return 404 for non-existent post', async () => { ForumPost.findByPk.mockResolvedValue(null); const response = await request(app) .put('/forum/posts/non-existent') .send({ title: 'Updated' }); expect(response.status).toBe(404); }); it('should return 403 when updating other users post', async () => { const mockPost = { id: 'post-1', authorId: 'other-user', isDeleted: false, }; ForumPost.findByPk.mockResolvedValue(mockPost); const response = await request(app) .put('/forum/posts/post-1') .send({ title: 'Updated' }); expect(response.status).toBe(403); expect(response.body.error).toBe('Unauthorized'); }); }); describe('DELETE /forum/posts/:id', () => { it('should hard delete own post', async () => { const mockPost = { id: 'post-1', authorId: 'user-123', isDeleted: false, destroy: jest.fn().mockResolvedValue(), }; ForumPost.findByPk.mockResolvedValue(mockPost); const response = await request(app) .delete('/forum/posts/post-1'); expect(response.status).toBe(204); expect(mockPost.destroy).toHaveBeenCalled(); }); it('should return 404 for non-existent post', async () => { ForumPost.findByPk.mockResolvedValue(null); const response = await request(app) .delete('/forum/posts/non-existent'); expect(response.status).toBe(404); }); it('should return 403 when deleting other users post', async () => { const mockPost = { id: 'post-1', authorId: 'other-user', isDeleted: false, }; ForumPost.findByPk.mockResolvedValue(mockPost); const response = await request(app) .delete('/forum/posts/post-1'); expect(response.status).toBe(403); }); }); describe('POST /forum/posts/:id/comments', () => { it('should add a comment to a post', async () => { const mockPost = { id: 'post-1', authorId: 'post-author', isDeleted: false, status: 'open', increment: jest.fn().mockResolvedValue(), update: jest.fn().mockResolvedValue(), }; const mockCreatedComment = { id: 'comment-1', content: 'Great post!', authorId: 'user-123', postId: 'post-1', }; const mockCommentWithDetails = { ...mockCreatedComment, author: { id: 'user-123', firstName: 'John', lastName: 'Doe', email: 'john@example.com' }, toJSON: function() { return this; }, }; ForumPost.findByPk.mockResolvedValue(mockPost); ForumComment.create.mockResolvedValue(mockCreatedComment); // After create, findByPk is called to get comment with details ForumComment.findByPk.mockResolvedValue(mockCommentWithDetails); const response = await request(app) .post('/forum/posts/post-1/comments') .send({ content: 'Great post!' }); expect(response.status).toBe(201); expect(ForumComment.create).toHaveBeenCalledWith( expect.objectContaining({ content: 'Great post!', authorId: 'user-123', postId: 'post-1', }) ); expect(mockPost.increment).toHaveBeenCalledWith('commentCount'); }); it('should handle Sequelize validation error for missing content', async () => { const mockPost = { id: 'post-1', status: 'open', }; ForumPost.findByPk.mockResolvedValue(mockPost); const validationError = new Error('Validation error'); validationError.name = 'SequelizeValidationError'; ForumComment.create.mockRejectedValue(validationError); const response = await request(app) .post('/forum/posts/post-1/comments') .send({}); expect(response.status).toBe(500); }); it('should return 404 for non-existent post', async () => { ForumPost.findByPk.mockResolvedValue(null); const response = await request(app) .post('/forum/posts/non-existent/comments') .send({ content: 'Comment' }); expect(response.status).toBe(404); }); it('should return 403 when commenting on closed post', async () => { const mockPost = { id: 'post-1', isDeleted: false, status: 'closed', }; ForumPost.findByPk.mockResolvedValue(mockPost); const response = await request(app) .post('/forum/posts/post-1/comments') .send({ content: 'Comment' }); expect(response.status).toBe(403); expect(response.body.error).toContain('closed'); }); it('should support replying to another comment', async () => { const mockPost = { id: 'post-1', authorId: 'post-author', isDeleted: false, status: 'open', increment: jest.fn().mockResolvedValue(), update: jest.fn().mockResolvedValue(), }; const mockParentComment = { id: 'parent-comment', postId: 'post-1', authorId: 'other-user', isDeleted: false, }; const mockCreatedReply = { id: 'reply-1', content: 'Reply to comment', parentCommentId: 'parent-comment', authorId: 'user-123', postId: 'post-1', }; const mockReplyWithDetails = { ...mockCreatedReply, author: { id: 'user-123', firstName: 'John', lastName: 'Doe', email: 'john@example.com' }, toJSON: function() { return this; }, }; ForumPost.findByPk.mockResolvedValue(mockPost); // First findByPk call checks parent comment, second gets created comment with details ForumComment.findByPk .mockResolvedValueOnce(mockParentComment) .mockResolvedValueOnce(mockReplyWithDetails); ForumComment.create.mockResolvedValue(mockCreatedReply); const response = await request(app) .post('/forum/posts/post-1/comments') .send({ content: 'Reply to comment', parentCommentId: 'parent-comment' }); expect(response.status).toBe(201); expect(ForumComment.create).toHaveBeenCalledWith( expect.objectContaining({ parentCommentId: 'parent-comment', }) ); }); }); describe('GET /forum/my-posts', () => { it('should return authenticated users posts', async () => { const mockPosts = [ { id: 'post-1', title: 'My Post', authorId: 'user-123', toJSON: function() { return this; }, }, ]; ForumPost.findAll.mockResolvedValue(mockPosts); const response = await request(app) .get('/forum/my-posts'); expect(response.status).toBe(200); expect(response.body).toHaveLength(1); expect(ForumPost.findAll).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ authorId: 'user-123', }), }) ); }); }); describe('GET /forum/tags', () => { it('should return all tags', async () => { const mockTags = [ { tagName: 'javascript', count: 10 }, { tagName: 'react', count: 5 }, ]; PostTag.findAll.mockResolvedValue(mockTags); const response = await request(app) .get('/forum/tags'); expect(response.status).toBe(200); expect(response.body).toHaveLength(2); expect(response.body[0].tagName).toBe('javascript'); }); }); describe('PATCH /forum/posts/:id/status', () => { it('should update post status by author', async () => { const mockPost = { id: 'post-1', authorId: 'user-123', status: 'open', isDeleted: false, update: jest.fn().mockResolvedValue(), reload: jest.fn().mockResolvedValue(), toJSON: function() { return this; }, }; ForumPost.findByPk.mockResolvedValue(mockPost); const response = await request(app) .patch('/forum/posts/post-1/status') .send({ status: 'answered' }); expect(response.status).toBe(200); expect(mockPost.update).toHaveBeenCalledWith({ status: 'answered', closedBy: null, closedAt: null, }); }); it('should reject invalid status', async () => { const mockPost = { id: 'post-1', authorId: 'user-123', isDeleted: false, }; ForumPost.findByPk.mockResolvedValue(mockPost); const response = await request(app) .patch('/forum/posts/post-1/status') .send({ status: 'invalid-status' }); expect(response.status).toBe(400); expect(response.body.error).toBe('Invalid status value'); }); }); describe('PUT /forum/comments/:id', () => { it('should update own comment', async () => { const mockComment = { id: 'comment-1', authorId: 'user-123', postId: 'post-1', content: 'Original', isDeleted: false, post: { id: 'post-1', isDeleted: false }, update: jest.fn().mockResolvedValue(), reload: jest.fn().mockResolvedValue(), toJSON: function() { return this; }, }; ForumComment.findByPk.mockResolvedValue(mockComment); const response = await request(app) .put('/forum/comments/comment-1') .send({ content: 'Updated content' }); expect(response.status).toBe(200); expect(mockComment.update).toHaveBeenCalledWith( expect.objectContaining({ content: 'Updated content', }) ); }); it('should return 403 when editing other users comment', async () => { const mockComment = { id: 'comment-1', authorId: 'other-user', isDeleted: false, post: { id: 'post-1', isDeleted: false }, }; ForumComment.findByPk.mockResolvedValue(mockComment); const response = await request(app) .put('/forum/comments/comment-1') .send({ content: 'Updated' }); expect(response.status).toBe(403); }); it('should return 404 for non-existent comment', async () => { ForumComment.findByPk.mockResolvedValue(null); const response = await request(app) .put('/forum/comments/non-existent') .send({ content: 'Updated' }); expect(response.status).toBe(404); }); }); describe('DELETE /forum/comments/:id', () => { it('should soft delete own comment', async () => { const mockComment = { id: 'comment-1', authorId: 'user-123', postId: 'post-1', isDeleted: false, update: jest.fn().mockResolvedValue(), }; const mockPost = { id: 'post-1', commentCount: 5, decrement: jest.fn().mockResolvedValue(), }; ForumComment.findByPk.mockResolvedValue(mockComment); ForumPost.findByPk.mockResolvedValue(mockPost); const response = await request(app) .delete('/forum/comments/comment-1'); // Returns 204 No Content on successful delete expect(response.status).toBe(204); expect(mockComment.update).toHaveBeenCalledWith({ isDeleted: true }); expect(mockPost.decrement).toHaveBeenCalledWith('commentCount'); }); it('should return 404 for non-existent comment', async () => { ForumComment.findByPk.mockResolvedValue(null); const response = await request(app) .delete('/forum/comments/non-existent'); expect(response.status).toBe(404); }); it('should return 403 when deleting other users comment', async () => { const mockComment = { id: 'comment-1', authorId: 'other-user', isDeleted: false, }; ForumComment.findByPk.mockResolvedValue(mockComment); const response = await request(app) .delete('/forum/comments/comment-1'); expect(response.status).toBe(403); }); }); });