From 3b0a45bada125bb1e7e56452eb0546ace7b2c17e Mon Sep 17 00:00:00 2001 From: Rijal Muhyidin Date: Tue, 17 Mar 2026 12:42:21 +0700 Subject: [PATCH] feat: like comments done --- ...773680040687_create-table-comment-likes.js | 10 +- src/Applications/use_case/GetThreadUseCase.js | 5 + .../use_case/LikeCommentUseCase.js | 4 +- .../use_case/_test/GetThreadUseCase.test.js | 19 +++- .../use_case/_test/LikeCommentUseCase.test.js | 17 ++- src/Domains/likes/LikeRepository.js | 8 +- .../likes/_test/LikeRepository.test.js | 6 +- src/Infrastructures/container.js | 44 +++++++- src/Infrastructures/http/_test/likes.test.js | 105 ++++++++++++++++++ .../http/_test/replies.test.js | 2 +- .../http/_test/threads.test.js | 65 +++++++++++ src/Infrastructures/http/createServer.js | 2 + .../repository/LikeRepositoryPostgres.js | 36 +++++- .../_test/LikeRepositoryPostgres.test.js | 81 ++++++++++++-- src/Interfaces/http/api/likes/handler.js | 31 ++++++ src/Interfaces/http/api/likes/index.js | 7 ++ src/Interfaces/http/api/likes/routes.js | 12 ++ tests/CommentLikesTableTestHelper.js | 18 ++- 18 files changed, 437 insertions(+), 35 deletions(-) create mode 100644 src/Infrastructures/http/_test/likes.test.js create mode 100644 src/Interfaces/http/api/likes/handler.js create mode 100644 src/Interfaces/http/api/likes/index.js create mode 100644 src/Interfaces/http/api/likes/routes.js diff --git a/migrations/1773680040687_create-table-comment-likes.js b/migrations/1773680040687_create-table-comment-likes.js index 5f0a4c6..a23e0a2 100644 --- a/migrations/1773680040687_create-table-comment-likes.js +++ b/migrations/1773680040687_create-table-comment-likes.js @@ -9,13 +9,13 @@ export const up = (pgm) => { type: 'VARCHAR(50)', primaryKey: true, }, - comment_id: { + comment_id: { // eslint-disable-line camelcase type: 'VARCHAR(50)', notNull: true, references: 'comments(id)', onDelete: 'CASCADE', }, - user_id: { + user_id: { // eslint-disable-line camelcase type: 'VARCHAR(50)', notNull: true, references: 'users(id)', @@ -26,7 +26,11 @@ export const up = (pgm) => { notNull: true, default: pgm.func('CURRENT_TIMESTAMP'), }, - }) + }); + + pgm.addConstraint('comment_likes', 'unique_comment_id_and_user_id', { + unique: ['comment_id', 'user_id'] + }); }; /** diff --git a/src/Applications/use_case/GetThreadUseCase.js b/src/Applications/use_case/GetThreadUseCase.js index f195758..524cb3e 100644 --- a/src/Applications/use_case/GetThreadUseCase.js +++ b/src/Applications/use_case/GetThreadUseCase.js @@ -3,10 +3,12 @@ class GetThreadUseCase { threadRepository, commentRepository, replyRepository, + likeRepository, }) { this._threadRepository = threadRepository; this._commentRepository = commentRepository; this._replyRepository = replyRepository; + this._likeRepository = likeRepository; } async execute(useCasePayload) { @@ -18,6 +20,8 @@ class GetThreadUseCase { const commentIds = comments.map((comment) => comment.id); const replies = commentIds.length ? await this._replyRepository.getRepliesByCommentIds(commentIds) : []; + const commentLikes = commentIds.length ? await this._likeRepository.getLikeCountsByCommentIds(commentIds) : {}; + const repliesByCommentId = replies.reduce((acc, reply) => { acc[reply.commentId] = acc[reply.commentId] || []; acc[reply.commentId].push({ @@ -33,6 +37,7 @@ class GetThreadUseCase { const mappedComments = comments.map((comment) => ({ ...comment, content: comment.isDelete ? '**komentar telah dihapus**' : comment.content, + likeCount: commentLikes[comment.id] || 0, replies: (repliesByCommentId[comment.id] || []).map((reply) => ({ ...reply, content: reply.isDelete ? '**balasan telah dihapus**' : reply.content, diff --git a/src/Applications/use_case/LikeCommentUseCase.js b/src/Applications/use_case/LikeCommentUseCase.js index 36df2a7..0a1ccbc 100644 --- a/src/Applications/use_case/LikeCommentUseCase.js +++ b/src/Applications/use_case/LikeCommentUseCase.js @@ -16,9 +16,9 @@ class LikeCommentUseCase { const isLiked = await this._likeRepository.isLikedComment(commentId, userId); if (isLiked) { - await this._likeRepository.unlikeComment(commentId, userId); + await this._likeRepository.deleteLikedComment(commentId, userId); } else { - await this._likeRepository.likeComment(commentId, userId); + await this._likeRepository.addLikeComment(commentId, userId); } } } diff --git a/src/Applications/use_case/_test/GetThreadUseCase.test.js b/src/Applications/use_case/_test/GetThreadUseCase.test.js index a9681a5..48f7711 100644 --- a/src/Applications/use_case/_test/GetThreadUseCase.test.js +++ b/src/Applications/use_case/_test/GetThreadUseCase.test.js @@ -4,6 +4,7 @@ import DetailComment from '../../../Domains/comments/entities/DetailComment.js'; import GetThreadUseCase from '../GetThreadUseCase.js'; import ReplyRepository from '../../../Domains/replies/ReplyRepository.js'; import DetailReply from '../../../Domains/replies/entities/DetailReply.js'; +import LikeRepository from '../../../Domains/likes/LikeRepository.js'; describe('GetThreadUseCase', () => { it('should orchestrating the get detail thread action correctly', async () => { @@ -13,6 +14,7 @@ describe('GetThreadUseCase', () => { const mockCommentRepository = new CommentRepository(); const mockThreadRepository = new ThreadRepository(); const mockReplyRepository = new ReplyRepository(); + const mockLikeRepository = new LikeRepository(); mockThreadRepository.verifyThreadExists = vi.fn() .mockImplementation(() => Promise.resolve()); @@ -52,10 +54,13 @@ describe('GetThreadUseCase', () => { isDelete: true, }), ])); + mockLikeRepository.getLikeCountsByCommentIds = vi.fn() + .mockImplementation(() => Promise.resolve({})); const getThreadUseCase = new GetThreadUseCase({ threadRepository: mockThreadRepository, commentRepository: mockCommentRepository, replyRepository: mockReplyRepository, + likeRepository: mockLikeRepository, }); const thread = await getThreadUseCase.execute(useCasePayload); @@ -64,8 +69,8 @@ describe('GetThreadUseCase', () => { expect(mockThreadRepository.getThreadById).toHaveBeenCalledWith(useCasePayload.threadId); expect(mockThreadRepository.verifyThreadExists).toHaveBeenCalledWith(useCasePayload.threadId); expect(mockCommentRepository.getCommentsByThreadId).toHaveBeenCalledWith(useCasePayload.threadId); + expect(mockLikeRepository.getLikeCountsByCommentIds).toHaveBeenCalledWith(['comment-1', 'comment-2']); - expect(thread.date).toEqual(thread.date); expect(thread.comments).toHaveLength(2); expect(thread.comments[0].content).toEqual('**komentar telah dihapus**'); expect(thread.comments[1].content).toEqual('komentar 2'); @@ -75,6 +80,7 @@ describe('GetThreadUseCase', () => { const mockThreadRepository = new ThreadRepository(); const mockReplyRepository = new ReplyRepository(); const mockCommentRepository = new CommentRepository(); + const mockLikeRepository = new LikeRepository(); mockThreadRepository.verifyThreadExists = vi.fn() .mockImplementation(() => Promise.resolve()); @@ -107,11 +113,14 @@ describe('GetThreadUseCase', () => { isDelete: false, }), ])); + mockLikeRepository.getLikeCountsByCommentIds = vi.fn() + .mockImplementation(() => Promise.resolve({})); const getThreadUseCase = new GetThreadUseCase({ commentRepository: mockCommentRepository, threadRepository: mockThreadRepository, replyRepository: mockReplyRepository, + likeRepository: mockLikeRepository, }); const thread = await getThreadUseCase.execute('thread-123'); @@ -123,6 +132,7 @@ describe('GetThreadUseCase', () => { const mockThreadRepository = new ThreadRepository(); const mockReplyRepository = new ReplyRepository(); const mockCommentRepository = new CommentRepository(); + const mockLikeRepository = new LikeRepository(); mockThreadRepository.verifyThreadExists = vi.fn() .mockImplementation(() => Promise.resolve()); @@ -155,11 +165,14 @@ describe('GetThreadUseCase', () => { isDelete: true, }), ])); + mockLikeRepository.getLikeCountsByCommentIds = vi.fn() + .mockImplementation(() => Promise.resolve({})); const getThreadUseCase = new GetThreadUseCase({ commentRepository: mockCommentRepository, threadRepository: mockThreadRepository, replyRepository: mockReplyRepository, + likeRepository: mockLikeRepository, }); const thread = await getThreadUseCase.execute('thread-123'); @@ -172,6 +185,7 @@ describe('GetThreadUseCase', () => { const mockThreadRepository = new ThreadRepository(); const mockCommentRepository = new CommentRepository(); const mockReplyRepository = new ReplyRepository(); + const mockLikeRepository = new LikeRepository(); mockThreadRepository.verifyThreadExists = vi.fn().mockResolvedValue(); mockThreadRepository.getThreadById = vi.fn().mockResolvedValue({ @@ -181,14 +195,15 @@ describe('GetThreadUseCase', () => { date: '2026-03-14T16:10:20.555Z', username: 'dicoding', }); - mockCommentRepository.getCommentsByThreadId = vi.fn().mockResolvedValue([]); mockReplyRepository.getRepliesByCommentIds = vi.fn(); + mockLikeRepository.getLikeCountsByCommentIds = vi.fn(); const getThreadUseCase = new GetThreadUseCase({ threadRepository: mockThreadRepository, commentRepository: mockCommentRepository, replyRepository: mockReplyRepository, + likeRepository: mockLikeRepository, }); const thread = await getThreadUseCase.execute(useCasePayload); diff --git a/src/Applications/use_case/_test/LikeCommentUseCase.test.js b/src/Applications/use_case/_test/LikeCommentUseCase.test.js index 74a6315..da06900 100644 --- a/src/Applications/use_case/_test/LikeCommentUseCase.test.js +++ b/src/Applications/use_case/_test/LikeCommentUseCase.test.js @@ -1,4 +1,3 @@ -import { describe, expect, vi } from 'vitest'; import ThreadRepository from '../../../Domains/threads/ThreadRepository.js'; import CommentRepository from '../../../Domains/comments/CommentRepository.js'; import LikeRepository from '../../../Domains/likes/LikeRepository.js'; @@ -21,9 +20,9 @@ describe('LikeCommentUseCase', () => { .mockImplementation(() => Promise.resolve()); mockLikeRepository.isLikedComment = vi.fn() .mockImplementation(() => Promise.resolve(false)); - mockLikeRepository.likeComment = vi.fn() + mockLikeRepository.addLikeComment = vi.fn() .mockImplementation(() => Promise.resolve()); - mockLikeRepository.unlikeComment = vi.fn() + mockLikeRepository.deleteLikedComment = vi.fn() .mockImplementation(() => Promise.resolve()); const likeCommentUseCase = new LikeCommentUseCase({ @@ -37,8 +36,8 @@ describe('LikeCommentUseCase', () => { expect(mockThreadRepository.verifyThreadExists).toHaveBeenCalledWith(useCasePayload.threadId); expect(mockCommentRepository.verifyCommentExistsOnThread).toHaveBeenCalledWith(useCasePayload.commentId, useCasePayload.threadId); expect(mockLikeRepository.isLikedComment).toHaveBeenCalledWith(useCasePayload.commentId, userId); - expect(mockLikeRepository.likeComment).toHaveBeenCalledExactlyOnceWith(useCasePayload.commentId, userId); - expect(mockLikeRepository.unlikeComment).not.toHaveBeenCalled(); + expect(mockLikeRepository.addLikeComment).toHaveBeenCalledExactlyOnceWith(useCasePayload.commentId, userId); + expect(mockLikeRepository.deleteLikedComment).not.toHaveBeenCalled(); }); it('should orchestrating unlike comment action properly', async () => { @@ -57,9 +56,9 @@ describe('LikeCommentUseCase', () => { .mockImplementation(() => Promise.resolve()); mockLikeRepository.isLikedComment = vi.fn() .mockImplementation(() => Promise.resolve(true)); - mockLikeRepository.unlikeComment = vi.fn() + mockLikeRepository.deleteLikedComment = vi.fn() .mockImplementation(() => Promise.resolve()); - mockLikeRepository.likeComment = vi.fn() + mockLikeRepository.addLikeComment = vi.fn() .mockImplementation(() => Promise.resolve()); const likeCommentUseCase = new LikeCommentUseCase({ @@ -73,7 +72,7 @@ describe('LikeCommentUseCase', () => { expect(mockThreadRepository.verifyThreadExists).toHaveBeenCalledWith(useCasePayload.threadId); expect(mockCommentRepository.verifyCommentExistsOnThread).toHaveBeenCalledWith(useCasePayload.commentId, useCasePayload.threadId); expect(mockLikeRepository.isLikedComment).toHaveBeenCalledWith(useCasePayload.commentId, userId); - expect(mockLikeRepository.unlikeComment).toHaveBeenCalledExactlyOnceWith(useCasePayload.commentId, userId); - expect(mockLikeRepository.likeComment).not.toHaveBeenCalled(); + expect(mockLikeRepository.deleteLikedComment).toHaveBeenCalledExactlyOnceWith(useCasePayload.commentId, userId); + expect(mockLikeRepository.addLikeComment).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/src/Domains/likes/LikeRepository.js b/src/Domains/likes/LikeRepository.js index 9609ce8..fd9431b 100644 --- a/src/Domains/likes/LikeRepository.js +++ b/src/Domains/likes/LikeRepository.js @@ -1,17 +1,17 @@ class LikeRepository { - async likeComment(commentId, userId) { + async addLikeComment(commentId, userId) { // eslint-disable-line no-unused-vars throw new Error('LIKE_REPOSITORY.METHOD_NOT_IMPLEMENTED'); } - async unlikeComment(commentId, userId) { + async deleteLikedComment(commentId, userId) { // eslint-disable-line no-unused-vars throw new Error('LIKE_REPOSITORY.METHOD_NOT_IMPLEMENTED'); } - async isLikedComment(commentId, userId) { + async isLikedComment(commentId, userId) { // eslint-disable-line no-unused-vars throw new Error('LIKE_REPOSITORY.METHOD_NOT_IMPLEMENTED'); } - async getLikeCountByCommentIds(commentIds) { + async getLikeCountsByCommentIds(commentIds) { // eslint-disable-line no-unused-vars throw new Error('LIKE_REPOSITORY.METHOD_NOT_IMPLEMENTED'); } } diff --git a/src/Domains/likes/_test/LikeRepository.test.js b/src/Domains/likes/_test/LikeRepository.test.js index fb4bbad..036b873 100644 --- a/src/Domains/likes/_test/LikeRepository.test.js +++ b/src/Domains/likes/_test/LikeRepository.test.js @@ -4,9 +4,9 @@ describe('LikeRepository interface', () => { it('should throw error when invoke abstract behavior', async () => { const likeRepository = new LikeRepository(); - await expect(likeRepository.likeComment('', '')).rejects.toThrowError('LIKE_REPOSITORY.METHOD_NOT_IMPLEMENTED'); - await expect(likeRepository.unlikeComment('', '')).rejects.toThrowError('LIKE_REPOSITORY.METHOD_NOT_IMPLEMENTED'); + await expect(likeRepository.addLikeComment('', '')).rejects.toThrowError('LIKE_REPOSITORY.METHOD_NOT_IMPLEMENTED'); + await expect(likeRepository.deleteLikedComment('', '')).rejects.toThrowError('LIKE_REPOSITORY.METHOD_NOT_IMPLEMENTED'); await expect(likeRepository.isLikedComment('', '')).rejects.toThrowError('LIKE_REPOSITORY.METHOD_NOT_IMPLEMENTED'); - await expect(likeRepository.getLikeCountByCommentIds([])).rejects.toThrowError('LIKE_REPOSITORY.METHOD_NOT_IMPLEMENTED'); + await expect(likeRepository.getLikeCountsByCommentIds([])).rejects.toThrowError('LIKE_REPOSITORY.METHOD_NOT_IMPLEMENTED'); }); }); \ No newline at end of file diff --git a/src/Infrastructures/container.js b/src/Infrastructures/container.js index 08cf1f1..9cdbc66 100644 --- a/src/Infrastructures/container.js +++ b/src/Infrastructures/container.js @@ -35,6 +35,9 @@ import AddReplyUseCase from '../Applications/use_case/AddReplyUseCase.js'; import ReplyRepository from '../Domains/replies/ReplyRepository.js'; import ReplyRepositoryPostgres from './repository/ReplyRepositoryPostgres.js'; import DeleteReplyUseCase from '../Applications/use_case/DeleteReplyUseCase.js'; +import LikeRepository from '../Domains/likes/LikeRepository.js'; +import LikeRepositoryPostgres from './repository/LikeRepositoryPostgres.js'; +import LikeCommentUseCase from '../Applications/use_case/LikeCommentUseCase.js'; // creating container const container = createContainer(); @@ -108,6 +111,20 @@ container.register([ ] } }, + { + key: LikeRepository.name, + Class: LikeRepositoryPostgres, + parameter: { + dependencies: [ + { + concrete: pool + }, + { + concrete: nanoid + }, + ] + } + }, { key: PasswordHash.name, Class: BcryptPasswordHash, @@ -270,7 +287,11 @@ container.register([ { name: 'replyRepository', internal: ReplyRepository.name, - } + }, + { + name: 'likeRepository', + internal: LikeRepository.name, + }, ], }, }, @@ -312,6 +333,27 @@ container.register([ ], }, }, + { + key: LikeCommentUseCase.name, + Class: LikeCommentUseCase, + parameter: { + injectType: 'destructuring', + dependencies: [ + { + name: 'likeRepository', + internal: LikeRepository.name, + }, + { + name: 'commentRepository', + internal: CommentRepository.name, + }, + { + name: 'threadRepository', + internal: ThreadRepository.name, + }, + ] + } + }, ]); export default container; diff --git a/src/Infrastructures/http/_test/likes.test.js b/src/Infrastructures/http/_test/likes.test.js new file mode 100644 index 0000000..a6fe237 --- /dev/null +++ b/src/Infrastructures/http/_test/likes.test.js @@ -0,0 +1,105 @@ +import CommentLikesTableTestHelper from '../../../../tests/CommentLikesTableTestHelper.js'; +import CommentsTableTestHelper from '../../../../tests/CommentsTableTestHelper.js'; +import ServerTestHelper from '../../../../tests/ServerTestHelper.js'; +import ThreadsTableTestHelper from '../../../../tests/ThreadsTableTestHelper.js'; +import UsersTableTestHelper from '../../../../tests/UsersTableTestHelper.js'; +import container from '../../container.js'; +import pool from '../../database/postgres/pool.js'; +import createServer from '../createServer.js'; +import request from 'supertest'; + +describe('/threads/:threadId/comments/:commentId/likes endpoint', () => { + afterAll(async () => { + await pool.end(); + }); + + afterEach(async () => { + await CommentLikesTableTestHelper.cleanTable(); + await CommentsTableTestHelper.cleanTable(); + await ThreadsTableTestHelper.cleanTable(); + await UsersTableTestHelper.cleanTable(); + await ServerTestHelper.cleanTable(); + }); + + describe('when PUT /threads/:threadId/comments/:commentId/likes', () => { + it('should response 401 when unauthorized', async () => { + const app = await createServer(container); + await UsersTableTestHelper.addUser({}); + await ThreadsTableTestHelper.addThread({ id: 'thread-123', owner: 'user-123' }); + await CommentsTableTestHelper.addComment({ id: 'comment-123', owner: 'user-123', threadId: 'thread-123' }); + + const response = await request(app) + .put('/threads/thread-123/comments/comment-123/likes'); + + expect(response.statusCode).toEqual(401); + expect(response.body.status).toEqual('fail'); + expect(response.body.message).toBeDefined(); + }); + + it('should response 404 when thread not found', async () => { + const app = await createServer(container); + const accessToken = await ServerTestHelper.getAccessToken({}); + await UsersTableTestHelper.addUser({ id: 'user-000', username: 'commentator' }); + await ThreadsTableTestHelper.addThread({ id: 'thread-123', owner: 'user-123' }); + await CommentsTableTestHelper.addComment({ id: 'comment-123', owner: 'user-000', threadId: 'thread-123' }); + + const response = await request(app) + .put('/threads/thread-234/comments/comment-123/likes') + .set('Authorization', `Bearer ${accessToken}`); + + expect(response.statusCode).toEqual(404); + expect(response.body.status).toEqual('fail'); + expect(response.body.message).toBeDefined(); + }); + + it('should response 404 when comment not found in thread', async () => { + const app = await createServer(container); + const accessToken = await ServerTestHelper.getAccessToken({}); + await UsersTableTestHelper.addUser({ id: 'user-000', username: 'commentator' }); + await ThreadsTableTestHelper.addThread({ id: 'thread-123', owner: 'user-123' }); + + const response = await request(app) + .put('/threads/thread-123/comments/comment-123/likes') + .set('Authorization', `Bearer ${accessToken}`); + + expect(response.statusCode).toEqual(404); + expect(response.body.status).toEqual('fail'); + expect(response.body.message).toBeDefined(); + }); + + it('should response 200 and add like when comment not liked yet', async () => { + const app = await createServer(container); + const accessToken = await ServerTestHelper.getAccessToken({}); + await UsersTableTestHelper.addUser({ id: 'user-000', username: 'commentator' }); + await ThreadsTableTestHelper.addThread({ id: 'thread-123', owner: 'user-123' }); + await CommentsTableTestHelper.addComment({ id: 'comment-123', owner: 'user-000', threadId: 'thread-123' }); + + const response = await request(app) + .put('/threads/thread-123/comments/comment-123/likes') + .set('Authorization', `Bearer ${accessToken}`); + + const likes = await CommentLikesTableTestHelper.findLikesByCommentAndUser('comment-123', 'user-123'); + expect(response.statusCode).toEqual(200); + expect(response.body.status).toEqual('success'); + expect(likes).toHaveLength(1); + }); + + it('should response 200 and unlike when comment already liked', async () => { + const app = await createServer(container); + const accessToken = await ServerTestHelper.getAccessToken({}); + await UsersTableTestHelper.addUser({ id: 'user-000', username: 'commentator' }); + await ThreadsTableTestHelper.addThread({ id: 'thread-123', owner: 'user-123' }); + await CommentsTableTestHelper.addComment({ id: 'comment-123', owner: 'user-000', threadId: 'thread-123' }); + await CommentLikesTableTestHelper.addLikeComment({ commentId: 'comment-123', userId: 'user-123' }); + + const response = await request(app) + .put('/threads/thread-123/comments/comment-123/likes') + .set('Authorization', `Bearer ${accessToken}`); + + const likes = await CommentLikesTableTestHelper.findLikesByCommentAndUser('comment-123', 'user-123'); + expect(response.statusCode).toEqual(200); + expect(response.body.status).toEqual('success'); + expect(likes).toHaveLength(0); + }); + }); +}); \ No newline at end of file diff --git a/src/Infrastructures/http/_test/replies.test.js b/src/Infrastructures/http/_test/replies.test.js index 236807e..302f256 100644 --- a/src/Infrastructures/http/_test/replies.test.js +++ b/src/Infrastructures/http/_test/replies.test.js @@ -95,7 +95,7 @@ describe('/threads/:threadId/comments/:commentId/replies endpoint', () => { expect(response.body.message).toBeDefined(); }); - it('should response 404 when comment not found', async () => { + it('should response 404 when comment not found in thread', async () => { const app = await createServer(container); const accessToken = await ServerTestHelper.getAccessToken({}); await UsersTableTestHelper.addUser({ id: 'user-000', username: 'commentator' }); diff --git a/src/Infrastructures/http/_test/threads.test.js b/src/Infrastructures/http/_test/threads.test.js index e59072f..cf96b24 100644 --- a/src/Infrastructures/http/_test/threads.test.js +++ b/src/Infrastructures/http/_test/threads.test.js @@ -7,6 +7,7 @@ import createServer from '../createServer.js'; import request from 'supertest'; import ServerTestHelper from '../../../../tests/ServerTestHelper.js'; import CommentsTableTestHelper from '../../../../tests/CommentsTableTestHelper.js'; +import CommentLikesTableTestHelper from '../../../../tests/CommentLikesTableTestHelper.js'; describe('/threads endpoint', () => { afterAll(async () => { @@ -14,6 +15,8 @@ describe('/threads endpoint', () => { }); afterEach(async () => { + await CommentLikesTableTestHelper.cleanTable(); + await CommentsTableTestHelper.cleanTable(); await UsersTableTestHelper.cleanTable(); await ThreadsTableTestHelper.cleanTable(); await AuthenticationsTableTestHelper.cleanTable(); @@ -143,5 +146,67 @@ describe('/threads endpoint', () => { expect(response.body.data.thread.comments[1].username).toEqual('commentator'); expect(response.body.data.thread.comments[1].content).toEqual('**komentar telah dihapus**'); }); + + it('should response 200, return corrent likeCount after likes comment', async () => { + const app = await createServer(container); + + await UsersTableTestHelper.addUser({ id: 'user-123', username: 'dicoding' }); + await UsersTableTestHelper.addUser({ id: 'user-000', username: 'commentator' }); + await ThreadsTableTestHelper.addThread({ id: 'thread-123', owner: 'user-123' }); + await CommentsTableTestHelper.addComment({ id: 'comment-123', threadId: 'thread-123', owner: 'user-123' }); + await CommentLikesTableTestHelper.addLikeComment({ userId: 'user-000', commentId: 'comment-123' }); + + const response = await request(app) + .get('/threads/thread-123'); + + expect(response.status).toEqual(200); + expect(response.body.data.thread.id).toEqual('thread-123'); + expect(response.body.data.thread.username).toEqual('dicoding'); + expect(response.body.data.thread.comments).toHaveLength(1); + expect(response.body.data.thread.comments[0].likeCount).toEqual(1); + }); + + it('should response 200, return corrent likeCount after unlikes comment', async () => { + const app = await createServer(container); + + await UsersTableTestHelper.addUser({ id: 'user-123', username: 'dicoding' }); + await UsersTableTestHelper.addUser({ id: 'user-000', username: 'commentator' }); + await ThreadsTableTestHelper.addThread({ id: 'thread-123', owner: 'user-123' }); + await CommentsTableTestHelper.addComment({ id: 'comment-123', threadId: 'thread-123', owner: 'user-123' }); + await CommentLikesTableTestHelper.addLikeComment({ userId: 'user-000', commentId: 'comment-123' }); + await CommentLikesTableTestHelper.deleteLikedComment({ userId: 'user-000', commentId: 'comment-123' }); + + const response = await request(app) + .get('/threads/thread-123'); + + expect(response.status).toEqual(200); + expect(response.body.data.thread.id).toEqual('thread-123'); + expect(response.body.data.thread.username).toEqual('dicoding'); + expect(response.body.data.thread.comments).toHaveLength(1); + expect(response.body.data.thread.comments[0].likeCount).toEqual(0); + }); + + it('should response 200 and return correct likeCount for multiple comments', async () => { + const app = await createServer(container); + + await UsersTableTestHelper.addUser({ id: 'user-123', username: 'dicoding' }); + await UsersTableTestHelper.addUser({ id: 'user-000', username: 'commentator' }); + await ThreadsTableTestHelper.addThread({ id: 'thread-123', owner: 'user-123' }); + + // Comment 123: 2 likes + await CommentsTableTestHelper.addComment({ id: 'comment-123', threadId: 'thread-123' }); + await CommentLikesTableTestHelper.addLikeComment({ id: 'like-1', userId: 'user-123', commentId: 'comment-123' }); + await CommentLikesTableTestHelper.addLikeComment({ id: 'like-2', userId: 'user-000', commentId: 'comment-123' }); + + // Comment 234: 0 likes + await CommentsTableTestHelper.addComment({ id: 'comment-234', threadId: 'thread-123' }); + + const response = await request(app) + .get('/threads/thread-123'); + + expect(response.status).toEqual(200); + expect(response.body.data.thread.comments[0].likeCount).toEqual(2); + expect(response.body.data.thread.comments[1].likeCount).toEqual(0); + }); }); }); \ No newline at end of file diff --git a/src/Infrastructures/http/createServer.js b/src/Infrastructures/http/createServer.js index b9b183b..5692e16 100644 --- a/src/Infrastructures/http/createServer.js +++ b/src/Infrastructures/http/createServer.js @@ -6,6 +6,7 @@ import authentications from '../../Interfaces/http/api/authentications/index.js' import threads from '../../Interfaces/http/api/threads/index.js'; import comments from '../../Interfaces/http/api/comments/index.js'; import replies from '../../Interfaces/http/api/replies/index.js'; +import likes from '../../Interfaces/http/api/likes/index.js'; const createServer = async (container) => { const app = express(); @@ -19,6 +20,7 @@ const createServer = async (container) => { app.use('/', threads(container)); app.use('/', comments(container)); app.use('/', replies(container)); + app.use('/', likes(container)); // Global error handler app.use((error, req, res, next) => { // eslint-disable-line no-unused-vars diff --git a/src/Infrastructures/repository/LikeRepositoryPostgres.js b/src/Infrastructures/repository/LikeRepositoryPostgres.js index 7dfbc67..0be7eee 100644 --- a/src/Infrastructures/repository/LikeRepositoryPostgres.js +++ b/src/Infrastructures/repository/LikeRepositoryPostgres.js @@ -7,7 +7,7 @@ class LikeRepositoryPostgres extends LikeRepository { this._idGenerator = idGenerator; } - async likeComment(commentId, userId) { + async addLikeComment(commentId, userId) { const id = `like-${this._idGenerator()}`; const query = { text: ` @@ -20,7 +20,7 @@ class LikeRepositoryPostgres extends LikeRepository { await this._pool.query(query); } - async unlikeComment(commentId, userId) { + async deleteLikedComment(commentId, userId) { const query = { text: 'DELETE FROM comment_likes WHERE comment_id = $1 AND user_id = $2', values: [commentId, userId] @@ -28,6 +28,38 @@ class LikeRepositoryPostgres extends LikeRepository { await this._pool.query(query); } + + async isLikedComment(commentId, userId) { + const query = { + text: ` + SELECT EXISTS ( + SELECT 1 FROM comment_likes + WHERE comment_id = $1 AND user_id = $2 + ) + `, + values: [commentId, userId] + }; + + const result = await this._pool.query(query); + return result.rows[0].exists; + } + + async getLikeCountsByCommentIds(commentIds) { + if (!commentIds.length) return {}; + + const query = { + text: ` + SELECT comment_id, COUNT(*)::int + FROM comment_likes + WHERE comment_id = ANY($1) + GROUP BY comment_id + `, + values: [commentIds] + }; + + const result = await this._pool.query(query); + return Object.fromEntries(result.rows.map((row) => [row.comment_id, row.count])); + } } export default LikeRepositoryPostgres; \ No newline at end of file diff --git a/src/Infrastructures/repository/_test/LikeRepositoryPostgres.test.js b/src/Infrastructures/repository/_test/LikeRepositoryPostgres.test.js index d3bbf3d..94f7d7a 100644 --- a/src/Infrastructures/repository/_test/LikeRepositoryPostgres.test.js +++ b/src/Infrastructures/repository/_test/LikeRepositoryPostgres.test.js @@ -1,4 +1,3 @@ -import { afterAll, afterEach, beforeEach, describe, expect } from 'vitest'; import LikeRepositoryPostgres from '../LikeRepositoryPostgres.js'; import pool from '../../database/postgres/pool.js'; import CommentLikesTableTestHelper from '../../../../tests/CommentLikesTableTestHelper.js'; @@ -44,32 +43,100 @@ describe('LikeRepositoryPostgres', () => { await UsersTableTestHelper.cleanTable(); }); - describe('likeComment function', () => { - it('should persist comment like', async () => { + describe('addLikeComment function', () => { + it('should persist like on comment', async () => { const commentId = 'comment-123'; const userId = 'user-123'; const fakeIdGenerator = () => '123'; const likeRepositoryPostgres = new LikeRepositoryPostgres(pool, fakeIdGenerator); - await likeRepositoryPostgres.likeComment(commentId, userId); + await likeRepositoryPostgres.addLikeComment(commentId, userId); const commentLikes = await CommentLikesTableTestHelper.findLikesByCommentAndUser(commentId, userId); expect(commentLikes).toHaveLength(1); }); + + it('should throw error if already liked', async () => { + await CommentLikesTableTestHelper.addLikeComment({ + commentId: 'comment-123', + userId: 'user-123' + }); + const commentId = 'comment-123'; + const userId = 'user-123'; + const fakeIdGenerator = () => '123'; + const likeRepositoryPostgres = new LikeRepositoryPostgres(pool, fakeIdGenerator); + + await expect(likeRepositoryPostgres.addLikeComment(commentId, userId)) + .rejects.toThrowError(); + }); }); describe('unlikeComment function', () => { - it('should delete comment like', async () => { - await CommentLikesTableTestHelper.likeComment({ + it('should delete like from comment', async () => { + await CommentLikesTableTestHelper.addLikeComment({ commentId: 'comment-123', userId: 'user-123' }); const likeRepositoryPostgres = new LikeRepositoryPostgres(pool, {}); - await likeRepositoryPostgres.unlikeComment('comment-123', 'user-123'); + await likeRepositoryPostgres.deleteLikedComment('comment-123', 'user-123'); const commentLikes = await CommentLikesTableTestHelper.findLikesByCommentAndUser('comment-123', 'user-123'); expect(commentLikes).toHaveLength(0); }); }); + + describe('isLikedComment function', () => { + it('should return true if user already like comment', async () => { + await CommentLikesTableTestHelper.addLikeComment({ + commentId: 'comment-123', + userId: 'user-123' + }); + const likeRepositoryPostgres = new LikeRepositoryPostgres(pool, {}); + + const isLiked = await likeRepositoryPostgres.isLikedComment('comment-123', 'user-123'); + + expect(isLiked).toEqual(true); + }); + + it('should return false if user not like comment', async () => { + const likeRepositoryPostgres = new LikeRepositoryPostgres(pool, {}); + + const isLiked = await likeRepositoryPostgres.isLikedComment('comment-123', 'user-123'); + + expect(isLiked).toEqual(false); + }); + }); + + describe('getLikeCountByCommentIds function', () => { + it('should return empty object when commentIds empty', async () => { + const likeRepositoryPostgres = new LikeRepositoryPostgres(pool, {}); + + const likeCounts = await likeRepositoryPostgres.getLikeCountsByCommentIds([]); + + expect(likeCounts).toEqual({}); + }); + + it('should return correct like counts for multiple commentIds', async () => { + await CommentsTableTestHelper.addComment({ + id: 'comment-234', + content: 'sebuah komentar', + threadId: 'thread-123', + owner: 'user-123', + }); + + await CommentLikesTableTestHelper.addLikeComment({ id: 'like-1', commentId: 'comment-123', userId: 'user-123' }); + await CommentLikesTableTestHelper.addLikeComment({ id: 'like-2', commentId: 'comment-123', userId: 'user-000' }); + await CommentLikesTableTestHelper.addLikeComment({ id: 'like-3', commentId: 'comment-234', userId: 'user-000' }); + + const likeRepositoryPostgres = new LikeRepositoryPostgres(pool, {}); + + const likeCounts = await likeRepositoryPostgres.getLikeCountsByCommentIds(['comment-123', 'comment-234']); + + expect(likeCounts).toEqual({ + 'comment-123': 2, + 'comment-234': 1 + }); + }); + }); }); \ No newline at end of file diff --git a/src/Interfaces/http/api/likes/handler.js b/src/Interfaces/http/api/likes/handler.js new file mode 100644 index 0000000..3b085d3 --- /dev/null +++ b/src/Interfaces/http/api/likes/handler.js @@ -0,0 +1,31 @@ +import LikeCommentUseCase from '../../../../Applications/use_case/LikeCommentUseCase.js'; + +class LikesHandler { + constructor(container) { + this._container = container; + + this.putCommentLikeHandler = this.putCommentLikeHandler.bind(this); + } + + async putCommentLikeHandler(req, res, next) { + try { + const likeCommentUseCase = this._container.getInstance(LikeCommentUseCase.name); + const { threadId, commentId } = req.params; + const { id: userId } = req.user; + const useCasePayload = { + threadId, + commentId, + }; + + await likeCommentUseCase.execute(useCasePayload, userId); + + res.status(200).json({ + status: 'success' + }); + } catch (error) { + next(error); + } + } +} + +export default LikesHandler; \ No newline at end of file diff --git a/src/Interfaces/http/api/likes/index.js b/src/Interfaces/http/api/likes/index.js new file mode 100644 index 0000000..93bf4f8 --- /dev/null +++ b/src/Interfaces/http/api/likes/index.js @@ -0,0 +1,7 @@ +import LikesHandler from './handler.js'; +import routes from './routes.js'; + +export default (container) => { + const likesHandler = new LikesHandler(container); + return routes(likesHandler, container); +}; \ No newline at end of file diff --git a/src/Interfaces/http/api/likes/routes.js b/src/Interfaces/http/api/likes/routes.js new file mode 100644 index 0000000..e62c154 --- /dev/null +++ b/src/Interfaces/http/api/likes/routes.js @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import authenticate from '../../middlewares/auth.js'; + +const routes = (handler, container) => { + const router = Router(); + + router.put('/threads/:threadId/comments/:commentId/likes', authenticate(container), handler.putCommentLikeHandler); + + return router; +}; + +export default routes; \ No newline at end of file diff --git a/tests/CommentLikesTableTestHelper.js b/tests/CommentLikesTableTestHelper.js index 480b4f3..dd88774 100644 --- a/tests/CommentLikesTableTestHelper.js +++ b/tests/CommentLikesTableTestHelper.js @@ -1,7 +1,8 @@ +/* istanbul ignore file */ import pool from '../src/Infrastructures/database/postgres/pool.js'; const CommentLikesTableTestHelper = { - async likeComment({ + async addLikeComment({ id = 'like-123', commentId = 'comment-123', userId = 'user-123', @@ -17,6 +18,21 @@ const CommentLikesTableTestHelper = { await pool.query(query); }, + async deleteLikedComment({ + commentId = 'comment-123', + userId = 'user-123' + }) { + const query = { + text: ` + DELETE FROM comment_likes + WHERE comment_id = $1 AND user_id = $2 + `, + values: [commentId, userId] + }; + + await pool.query(query); + }, + async findLikesByCommentAndUser(commentId, userId) { const query = { text: 'SELECT * FROM comment_likes WHERE comment_id = $1 AND user_id = $2',