diff --git a/.DS_Store b/.DS_Store index 1810fed..60f2f02 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.github/.DS_Store b/.github/.DS_Store index 76802b8..0c9c5de 100644 Binary files a/.github/.DS_Store and b/.github/.DS_Store differ diff --git a/migrations/1773680040687_create-table-comment-likes.js b/migrations/1773680040687_create-table-comment-likes.js new file mode 100644 index 0000000..5f0a4c6 --- /dev/null +++ b/migrations/1773680040687_create-table-comment-likes.js @@ -0,0 +1,39 @@ +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +export const up = (pgm) => { + pgm.createTable('comment_likes', { + id: { + type: 'VARCHAR(50)', + primaryKey: true, + }, + comment_id: { + type: 'VARCHAR(50)', + notNull: true, + references: 'comments(id)', + onDelete: 'CASCADE', + }, + user_id: { + type: 'VARCHAR(50)', + notNull: true, + references: 'users(id)', + onDelete: 'CASCADE', + }, + date: { + type: 'TIMESTAMPTZ', + notNull: true, + default: pgm.func('CURRENT_TIMESTAMP'), + }, + }) +}; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +export const down = (pgm) => { + pgm.dropTable('comment_likes'); +}; diff --git a/src/.DS_Store b/src/.DS_Store index 5a42a16..8d5e092 100644 Binary files a/src/.DS_Store and b/src/.DS_Store differ diff --git a/src/Applications/.DS_Store b/src/Applications/.DS_Store index f102383..9e2e067 100644 Binary files a/src/Applications/.DS_Store and b/src/Applications/.DS_Store differ diff --git a/src/Applications/use_case/LikeCommentUseCase.js b/src/Applications/use_case/LikeCommentUseCase.js new file mode 100644 index 0000000..36df2a7 --- /dev/null +++ b/src/Applications/use_case/LikeCommentUseCase.js @@ -0,0 +1,26 @@ +class LikeCommentUseCase { + constructor({ + likeRepository, + commentRepository, + threadRepository, + }) { + this._likeRepository = likeRepository; + this._commentRepository = commentRepository; + this._threadRepository = threadRepository; + } + + async execute(useCasePayload, userId) { + const { threadId, commentId } = useCasePayload; + await this._threadRepository.verifyThreadExists(threadId); + await this._commentRepository.verifyCommentExistsOnThread(commentId, threadId); + + const isLiked = await this._likeRepository.isLikedComment(commentId, userId); + if (isLiked) { + await this._likeRepository.unlikeComment(commentId, userId); + } else { + await this._likeRepository.likeComment(commentId, userId); + } + } +} + +export default LikeCommentUseCase; \ No newline at end of file diff --git a/src/Applications/use_case/_test/LikeCommentUseCase.test.js b/src/Applications/use_case/_test/LikeCommentUseCase.test.js new file mode 100644 index 0000000..74a6315 --- /dev/null +++ b/src/Applications/use_case/_test/LikeCommentUseCase.test.js @@ -0,0 +1,79 @@ +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'; +import LikeCommentUseCase from '../LikeCommentUseCase.js'; + +describe('LikeCommentUseCase', () => { + it('should orchestrating like comment action properly', async () => { + const useCasePayload = { + threadId: 'thread-123', + commentId: 'comment-123' + }; + const userId = 'user-123'; + const mockThreadRepository = new ThreadRepository(); + const mockCommentRepository = new CommentRepository(); + const mockLikeRepository = new LikeRepository(); + + mockThreadRepository.verifyThreadExists = vi.fn() + .mockImplementation(() => Promise.resolve()); + mockCommentRepository.verifyCommentExistsOnThread = vi.fn() + .mockImplementation(() => Promise.resolve()); + mockLikeRepository.isLikedComment = vi.fn() + .mockImplementation(() => Promise.resolve(false)); + mockLikeRepository.likeComment = vi.fn() + .mockImplementation(() => Promise.resolve()); + mockLikeRepository.unlikeComment = vi.fn() + .mockImplementation(() => Promise.resolve()); + + const likeCommentUseCase = new LikeCommentUseCase({ + likeRepository: mockLikeRepository, + commentRepository: mockCommentRepository, + threadRepository: mockThreadRepository, + }); + + await likeCommentUseCase.execute(useCasePayload, userId); + + 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(); + }); + + it('should orchestrating unlike comment action properly', async () => { + const useCasePayload = { + threadId: 'thread-123', + commentId: 'comment-123' + }; + const userId = 'user-123'; + const mockThreadRepository = new ThreadRepository(); + const mockCommentRepository = new CommentRepository(); + const mockLikeRepository = new LikeRepository(); + + mockThreadRepository.verifyThreadExists = vi.fn() + .mockImplementation(() => Promise.resolve()); + mockCommentRepository.verifyCommentExistsOnThread = vi.fn() + .mockImplementation(() => Promise.resolve()); + mockLikeRepository.isLikedComment = vi.fn() + .mockImplementation(() => Promise.resolve(true)); + mockLikeRepository.unlikeComment = vi.fn() + .mockImplementation(() => Promise.resolve()); + mockLikeRepository.likeComment = vi.fn() + .mockImplementation(() => Promise.resolve()); + + const likeCommentUseCase = new LikeCommentUseCase({ + likeRepository: mockLikeRepository, + commentRepository: mockCommentRepository, + threadRepository: mockThreadRepository, + }); + + await likeCommentUseCase.execute(useCasePayload, userId); + + 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(); + }); +}); \ No newline at end of file diff --git a/src/Domains/.DS_Store b/src/Domains/.DS_Store index dfdf5a8..e378058 100644 Binary files a/src/Domains/.DS_Store and b/src/Domains/.DS_Store differ diff --git a/src/Domains/likes/LikeRepository.js b/src/Domains/likes/LikeRepository.js new file mode 100644 index 0000000..9609ce8 --- /dev/null +++ b/src/Domains/likes/LikeRepository.js @@ -0,0 +1,19 @@ +class LikeRepository { + async likeComment(commentId, userId) { + throw new Error('LIKE_REPOSITORY.METHOD_NOT_IMPLEMENTED'); + } + + async unlikeComment(commentId, userId) { + throw new Error('LIKE_REPOSITORY.METHOD_NOT_IMPLEMENTED'); + } + + async isLikedComment(commentId, userId) { + throw new Error('LIKE_REPOSITORY.METHOD_NOT_IMPLEMENTED'); + } + + async getLikeCountByCommentIds(commentIds) { + throw new Error('LIKE_REPOSITORY.METHOD_NOT_IMPLEMENTED'); + } +} + +export default LikeRepository; \ No newline at end of file diff --git a/src/Domains/likes/_test/LikeRepository.test.js b/src/Domains/likes/_test/LikeRepository.test.js new file mode 100644 index 0000000..fb4bbad --- /dev/null +++ b/src/Domains/likes/_test/LikeRepository.test.js @@ -0,0 +1,12 @@ +import LikeRepository from '../LikeRepository.js'; + +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.isLikedComment('', '')).rejects.toThrowError('LIKE_REPOSITORY.METHOD_NOT_IMPLEMENTED'); + await expect(likeRepository.getLikeCountByCommentIds([])).rejects.toThrowError('LIKE_REPOSITORY.METHOD_NOT_IMPLEMENTED'); + }); +}); \ No newline at end of file diff --git a/src/Infrastructures/.DS_Store b/src/Infrastructures/.DS_Store index b63dfb2..f655050 100644 Binary files a/src/Infrastructures/.DS_Store and b/src/Infrastructures/.DS_Store differ diff --git a/src/Infrastructures/repository/LikeRepositoryPostgres.js b/src/Infrastructures/repository/LikeRepositoryPostgres.js new file mode 100644 index 0000000..8dac2ec --- /dev/null +++ b/src/Infrastructures/repository/LikeRepositoryPostgres.js @@ -0,0 +1,33 @@ +import LikeRepository from '../../Domains/likes/LikeRepository.js'; + +class LikeRepositoryPostgres extends LikeRepository { + constructor(pool, idGenerator) { + super(); + this._pool = pool; + this._idGenerator = idGenerator; + } + + async likeComment(commentId, userId) { + const id = `like-${this._idGenerator()}`; + const query = { + text: ` + INSERT INTO comment_likes (id, comment_id, user_id) + VALUES($1, $2, $3) + `, + values: [id, commentId, userId] + }; + + await this._pool.query(query); + } + + async unlikeComment(commentId, userId) { + const query = { + text: 'DELETE FROM comment_likes WHERE comment_id = $1 AND comment_id = $2', + values: [commentId, userId] + }; + + await this._pool.query(query); + } +} + +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 new file mode 100644 index 0000000..d3bbf3d --- /dev/null +++ b/src/Infrastructures/repository/_test/LikeRepositoryPostgres.test.js @@ -0,0 +1,75 @@ +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'; +import UsersTableTestHelper from '../../../../tests/UsersTableTestHelper.js'; +import ThreadsTableTestHelper from '../../../../tests/ThreadsTableTestHelper.js'; +import CommentsTableTestHelper from '../../../../tests/CommentsTableTestHelper.js'; + +describe('LikeRepositoryPostgres', () => { + beforeEach(async () => { + await UsersTableTestHelper.addUser({ + id: 'user-123', + username: 'dicoding', + }); + + await ThreadsTableTestHelper.addThread({ + id: 'thread-123', + title: 'sebuah thread', + body: 'sebuah body thread', + owner: 'user-123', + }); + + await UsersTableTestHelper.addUser({ + id: 'user-000', + username: 'commentator', + }); + + await CommentsTableTestHelper.addComment({ + id: 'comment-123', + content: 'sebuah komentar', + threadId: 'thread-123', + owner: 'user-000', + }); + }); + + afterAll(async () => { + await pool.end(); + }); + + afterEach(async () => { + await CommentLikesTableTestHelper.cleanTable(); + await CommentsTableTestHelper.cleanTable(); + await ThreadsTableTestHelper.cleanTable(); + await UsersTableTestHelper.cleanTable(); + }); + + describe('likeComment function', () => { + it('should persist comment like', async () => { + const commentId = 'comment-123'; + const userId = 'user-123'; + const fakeIdGenerator = () => '123'; + const likeRepositoryPostgres = new LikeRepositoryPostgres(pool, fakeIdGenerator); + + await likeRepositoryPostgres.likeComment(commentId, userId); + + const commentLikes = await CommentLikesTableTestHelper.findLikesByCommentAndUser(commentId, userId); + expect(commentLikes).toHaveLength(1); + }); + }); + + describe('unlikeComment function', () => { + it('should delete comment like', async () => { + await CommentLikesTableTestHelper.likeComment({ + commentId: 'comment-123', + userId: 'user-123' + }); + const likeRepositoryPostgres = new LikeRepositoryPostgres(pool, {}); + + await likeRepositoryPostgres.unlikeComment('comment-123', 'user-123'); + + const commentLikes = await CommentLikesTableTestHelper.findLikesByCommentAndUser('comment-123', 'user-123'); + expect(commentLikes).toHaveLength(0); + }); + }); +}); \ No newline at end of file diff --git a/tests/CommentLikesTableTestHelper.js b/tests/CommentLikesTableTestHelper.js new file mode 100644 index 0000000..480b4f3 --- /dev/null +++ b/tests/CommentLikesTableTestHelper.js @@ -0,0 +1,35 @@ +import pool from '../src/Infrastructures/database/postgres/pool.js'; + +const CommentLikesTableTestHelper = { + async likeComment({ + id = 'like-123', + commentId = 'comment-123', + userId = 'user-123', + date = new Date().toISOString(), + }) { + const query = { + text: ` + INSERT INTO comment_likes VALUES($1, $2, $3, $4) + `, + values: [id, commentId, userId, date] + }; + + await pool.query(query); + }, + + async findLikesByCommentAndUser(commentId, userId) { + const query = { + text: 'SELECT * FROM comment_likes WHERE comment_id = $1 AND user_id = $2', + values: [commentId, userId] + }; + + const result = await pool.query(query); + return result.rows; + }, + + async cleanTable() { + await pool.query('TRUNCATE TABLE comment_likes CASCADE'); + } +}; + +export default CommentLikesTableTestHelper; \ No newline at end of file