updating unit and integration tests

This commit is contained in:
jackiettran
2025-12-20 14:59:09 -05:00
parent 4e0a4ef019
commit bd1bd5014c
14 changed files with 2424 additions and 100 deletions

View File

@@ -0,0 +1,813 @@
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);
});
});
});