diff --git a/BE/school/src/main/java/thisisus/school/common/exception/ExceptionCode.java b/BE/school/src/main/java/thisisus/school/common/exception/ExceptionCode.java index 8a1090d..e077bea 100644 --- a/BE/school/src/main/java/thisisus/school/common/exception/ExceptionCode.java +++ b/BE/school/src/main/java/thisisus/school/common/exception/ExceptionCode.java @@ -22,6 +22,8 @@ public enum ExceptionCode { INCORRECT_TOKEN(UNAUTHORIZED, "올바르지 않는 토큰입니다."), ALREADY_REGISTERED_EMAIL(CONFLICT, "이미 등록된 이메일입니다."), NOT_FOUND_POST(NOT_FOUND, "게시물을 찾을 수 없습니다."), + ALREADY_EXIST_POSTLIKE(CONFLICT, "이미 좋아요를 누른 게시물입니다."), + NOT_EXIST_POSTLIKE(NOT_FOUND,"해당 게시물에 좋아요 기록이 없습니다."), NOT_CORRECT_USER(UNAUTHORIZED, "작성자가 일치하지 않습니다."), PUBLIC_KEY_GENERATION_FAILED(INTERNAL_SERVER_ERROR ,"공개 키 생성에 실패했습니다."); ; diff --git a/BE/school/src/main/java/thisisus/school/post/controller/PostLikeController.java b/BE/school/src/main/java/thisisus/school/post/controller/PostLikeController.java new file mode 100644 index 0000000..d0bb351 --- /dev/null +++ b/BE/school/src/main/java/thisisus/school/post/controller/PostLikeController.java @@ -0,0 +1,29 @@ +package thisisus.school.post.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import thisisus.school.auth.config.AuthenticatedMemberId; +import thisisus.school.common.response.SuccessResonse; +import thisisus.school.post.service.PostLikeService; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/post") +public class PostLikeController { + + private final PostLikeService postLikeService; + + @PostMapping("/{postId}/like") + public SuccessResonse likePost(@PathVariable("postId") final Long postId, + @AuthenticatedMemberId final Long memberId) { + postLikeService.likePost(postId, memberId); + return SuccessResonse.of(); + } + + @DeleteMapping("{postId}/like") + public SuccessResonse disLikePost(@PathVariable("postId") final Long postId, + @AuthenticatedMemberId final Long memberId) { + postLikeService.disLikePost(postId, memberId); + return SuccessResonse.of(); + } +} diff --git a/BE/school/src/main/java/thisisus/school/post/domain/Post.java b/BE/school/src/main/java/thisisus/school/post/domain/Post.java index 8bac01b..ff5c885 100644 --- a/BE/school/src/main/java/thisisus/school/post/domain/Post.java +++ b/BE/school/src/main/java/thisisus/school/post/domain/Post.java @@ -16,6 +16,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; import thisisus.school.common.BaseTimeEntity; import thisisus.school.member.domain.Member; import thisisus.school.post.exception.NotCorrectUserException; @@ -26,47 +27,57 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor public class Post extends BaseTimeEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "post_id") - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "post_id") + private Long id; - @Column - private String title; + @Column + private String title; - @Column - private String content; + @Column + private String content; - @Column - @Enumerated(EnumType.STRING) - private PostCategory category; + @Column + @Enumerated(EnumType.STRING) + private PostCategory category; - @Column - private int likeCount; + @Column + @ColumnDefault("0") + private Integer likeCount; - @Column - private int viewCount; + @Column + @ColumnDefault("0") + private Integer viewCount; - @Column - private boolean isDelete; + @Column + private boolean isDelete; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "member_id") - private Member member; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; - public void delete() { - this.isDelete = true; - } + public void delete() { + this.isDelete = true; + } - public void update(String title, String content, PostCategory category) { - this.title = title; - this.content = content; - this.category = category; - } + public void update(String title, String content, PostCategory category) { + this.title = title; + this.content = content; + this.category = category; + } - public void checkWriter(Post post, Long memberId) { - if (post.getMember().getId() != memberId) { - throw new NotCorrectUserException(); - } - } + public void checkWriter(Post post, Long memberId) { + if (post.getMember().getId() != memberId) { + throw new NotCorrectUserException(); + } + } + + public void increaseLikeCount() { + this.likeCount = getLikeCount() + 1; + } + + public void decreaseLikeCount() { + this.likeCount = getLikeCount() - 1; + } } diff --git a/BE/school/src/main/java/thisisus/school/post/exception/AlreadyExistPostLikeException.java b/BE/school/src/main/java/thisisus/school/post/exception/AlreadyExistPostLikeException.java new file mode 100644 index 0000000..52d01f0 --- /dev/null +++ b/BE/school/src/main/java/thisisus/school/post/exception/AlreadyExistPostLikeException.java @@ -0,0 +1,10 @@ +package thisisus.school.post.exception; + +import thisisus.school.common.exception.CustomException; +import thisisus.school.common.exception.ExceptionCode; + +public class AlreadyExistPostLikeException extends CustomException { + public AlreadyExistPostLikeException() { + super(ExceptionCode.ALREADY_EXIST_POSTLIKE); + } +} diff --git a/BE/school/src/main/java/thisisus/school/post/exception/NotExistPostLikeException.java b/BE/school/src/main/java/thisisus/school/post/exception/NotExistPostLikeException.java new file mode 100644 index 0000000..ab6abc5 --- /dev/null +++ b/BE/school/src/main/java/thisisus/school/post/exception/NotExistPostLikeException.java @@ -0,0 +1,10 @@ +package thisisus.school.post.exception; + +import thisisus.school.common.exception.CustomException; +import thisisus.school.common.exception.ExceptionCode; + +public class NotExistPostLikeException extends CustomException { + public NotExistPostLikeException() { + super(ExceptionCode.NOT_EXIST_POSTLIKE); + } +} diff --git a/BE/school/src/main/java/thisisus/school/post/repository/PostLikeRepository.java b/BE/school/src/main/java/thisisus/school/post/repository/PostLikeRepository.java new file mode 100644 index 0000000..5fc3d8c --- /dev/null +++ b/BE/school/src/main/java/thisisus/school/post/repository/PostLikeRepository.java @@ -0,0 +1,16 @@ +package thisisus.school.post.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import thisisus.school.member.domain.Member; +import thisisus.school.post.domain.Post; +import thisisus.school.post.domain.PostLike; + +import java.util.Optional; + +@Repository +public interface PostLikeRepository extends JpaRepository { + boolean existsByMemberAndPost(Member member, Post post); + + PostLike findByMemberAndPost(Member member, Post post); +} diff --git a/BE/school/src/main/java/thisisus/school/post/repository/PostRepository.java b/BE/school/src/main/java/thisisus/school/post/repository/PostRepository.java index 2d46578..46d069a 100644 --- a/BE/school/src/main/java/thisisus/school/post/repository/PostRepository.java +++ b/BE/school/src/main/java/thisisus/school/post/repository/PostRepository.java @@ -1,10 +1,16 @@ package thisisus.school.post.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import thisisus.school.post.domain.Post; +import javax.persistence.LockModeType; import java.util.List; +import java.util.Optional; public interface PostRepository extends JpaRepository { List findAllByMemberId(Long memberId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + Optional findWithLockById(Long id); } diff --git a/BE/school/src/main/java/thisisus/school/post/service/PostLikeService.java b/BE/school/src/main/java/thisisus/school/post/service/PostLikeService.java new file mode 100644 index 0000000..8e9b8d2 --- /dev/null +++ b/BE/school/src/main/java/thisisus/school/post/service/PostLikeService.java @@ -0,0 +1,6 @@ +package thisisus.school.post.service; + +public interface PostLikeService { + void likePost(Long postId, Long memberId); + void disLikePost(Long postId, Long memberId); +} diff --git a/BE/school/src/main/java/thisisus/school/post/service/PostLikeServiceImpl.java b/BE/school/src/main/java/thisisus/school/post/service/PostLikeServiceImpl.java new file mode 100644 index 0000000..bf0cdd3 --- /dev/null +++ b/BE/school/src/main/java/thisisus/school/post/service/PostLikeServiceImpl.java @@ -0,0 +1,59 @@ +package thisisus.school.post.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import thisisus.school.member.domain.Member; +import thisisus.school.member.exception.NotFoundMemberException; +import thisisus.school.member.repository.MemberRepository; +import thisisus.school.post.domain.Post; +import thisisus.school.post.domain.PostLike; +import thisisus.school.post.exception.AlreadyExistPostLikeException; +import thisisus.school.post.exception.NotExistPostLikeException; +import thisisus.school.post.exception.NotFoundPostException; +import thisisus.school.post.repository.PostLikeRepository; +import thisisus.school.post.repository.PostRepository; + +@Service +@RequiredArgsConstructor +public class PostLikeServiceImpl implements PostLikeService { + private final PostLikeRepository postLikeRepository; + private final PostRepository postRepository; + private final MemberRepository memberRepository; + + @Override + @Transactional + public void likePost(Long postId, Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(NotFoundMemberException::new); + Post post = postRepository.findWithLockById(postId) + .orElseThrow(NotFoundPostException::new); + if (postLikeRepository.existsByMemberAndPost(member, post)) { + throw new AlreadyExistPostLikeException(); + } + post.increaseLikeCount(); + + PostLike postLike = PostLike.builder() + .post(post) + .member(member) + .build(); + + postLikeRepository.save(postLike); + } + + @Override + @Transactional + public void disLikePost(Long postId, Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(NotFoundMemberException::new); + Post post = postRepository.findWithLockById(postId) + .orElseThrow(NotFoundPostException::new); + if (!postLikeRepository.existsByMemberAndPost(member, post)) { + throw new NotExistPostLikeException(); + } + post.decreaseLikeCount(); + + PostLike postLike = postLikeRepository.findByMemberAndPost(member, post); + postLikeRepository.delete(postLike); + } +} diff --git a/BE/school/src/test/java/thisisus/school/post/service/PostLikeServiceImplTest.java b/BE/school/src/test/java/thisisus/school/post/service/PostLikeServiceImplTest.java new file mode 100644 index 0000000..164531a --- /dev/null +++ b/BE/school/src/test/java/thisisus/school/post/service/PostLikeServiceImplTest.java @@ -0,0 +1,167 @@ +package thisisus.school.post.service; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import thisisus.school.member.domain.Member; +import thisisus.school.member.domain.MemberStatus; +import thisisus.school.member.domain.Role; +import thisisus.school.member.exception.NotFoundMemberException; +import thisisus.school.member.repository.MemberRepository; +import thisisus.school.post.domain.Post; +import thisisus.school.post.domain.PostCategory; +import thisisus.school.post.domain.PostLike; +import thisisus.school.post.exception.AlreadyExistPostLikeException; +import thisisus.school.post.exception.NotExistPostLikeException; +import thisisus.school.post.exception.NotFoundPostException; +import thisisus.school.post.repository.PostLikeRepository; +import thisisus.school.post.repository.PostRepository; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PostLikeServiceImplTest { + + @Mock + PostLikeRepository postLikeRepository; + + @Mock + PostRepository postRepository; + + @Mock + MemberRepository memberRepository; + + @InjectMocks + PostLikeServiceImpl postLikeService; + + private Member member; + private Post post; + private PostLike postLike; + + @BeforeEach + void setUp() { + member = new Member(1L, "장창현", + "abcdef@naver.com", "장첸", Role.STUDENT, MemberStatus.ACTIVE, + "tempRefresh"); + post = new Post(1L, "테스트용 제목", "테스트용 내용", + PostCategory.소통해요, 0, 0, false, member); + postLike = PostLike.builder().member(member).post(post).build(); + } + + @Test + @DisplayName("좋아요 insert - 성공") + void insertPostLike() { + //given + when(memberRepository.findById(1L)).thenReturn(Optional.of(member)); + when(postRepository.findById(1L)).thenReturn(Optional.of(post)); + when(postLikeRepository.existsByMemberAndPost(member, post)).thenReturn(false); + when(postLikeRepository.save(any(PostLike.class))).thenReturn(postLike); + + //when + postLikeService.likePost(1L, 1L); + + //then + verify(memberRepository).findById(1L); + verify(postRepository).findById(1L); + verify(postLikeRepository).existsByMemberAndPost(member, post); + verify(postLikeRepository).save(any(PostLike.class)); + Assertions.assertEquals(1, post.getLikeCount()); + } + + @Test + @DisplayName("좋아요 insert - 잘못된 postId로 인한 실패") + void failedInsertPostLikeByWrongPostId() { + // given + when(memberRepository.findById(1L)).thenReturn(Optional.of(member)); + when(postRepository.findById(1L)).thenReturn(Optional.empty()); + + // then + assertThrows(NotFoundPostException.class, () -> postLikeService.likePost(1L, 1L)); + } + + @Test + @DisplayName("좋아요 insert - 잘못된 memberId로 인한 실패") + void failedInsertPostLikeByWrongMemberId() { + // given + when(memberRepository.findById(1L)).thenReturn(Optional.empty()); + + // then + assertThrows(NotFoundMemberException.class, () -> postLikeService.likePost(1L, 1L)); + } + + @Test + @DisplayName("좋아요 insert - 이미 좋아요를 누른 post인 경우") + void failedInsertPostLikeByAlreadyExistPostLike() { + // given + when(memberRepository.findById(1L)).thenReturn(Optional.of(member)); + when(postRepository.findById(1L)).thenReturn(Optional.of(post)); + when(postLikeRepository.existsByMemberAndPost(member, post)).thenReturn(true); + + // then + assertThrows(AlreadyExistPostLikeException.class, () -> postLikeService.likePost(1L, 1L)); + } + + @Test + @DisplayName("좋아요 delete - 성공") + void deletePostLike() { + //given + when(memberRepository.findById(1L)).thenReturn(Optional.of(member)); + when(postRepository.findWithLockById(1L)).thenReturn(Optional.of(post)); + when(postLikeRepository.existsByMemberAndPost(member, post)).thenReturn(true); + when(postRepository.save(any(Post.class))).thenReturn(post); + when(postLikeRepository.findByMemberAndPost(member, post)).thenReturn(postLike); + doNothing().when(postLikeRepository).delete(any(PostLike.class)); + + //when + postLikeService.disLikePost(1L, 1L); + + //then + verify(memberRepository).findById(1L); + verify(postRepository).findWithLockById(1L); + verify(postLikeRepository).existsByMemberAndPost(member, post); + verify(postRepository).save(any(Post.class)); + verify(postLikeRepository).delete(any(PostLike.class)); + } + + @Test + @DisplayName("좋아요 delete - 잘못된 postId로 인한 실패") + void failedDeletePostLikeByWrongPostId() { + // given + when(memberRepository.findById(1L)).thenReturn(Optional.of(member)); + + // when + assertThrows(NotFoundPostException.class, () -> postLikeService.disLikePost(1L, 1L)); + } + + @Test + @DisplayName("좋아요 delete - 잘못된 memberId로 인한 실패") + void failedDeletePostLikeByWrongMemberId() { + // given + when(memberRepository.findById(1L)).thenReturn(Optional.empty()); + + // when + assertThrows(NotFoundMemberException.class, () -> postLikeService.disLikePost(1L, 1L)); + } + + @Test + @DisplayName("좋아요 delete - 좋아요를 누르지 않은 경우") + void failedDeletePostLikeByNotExistPostLike() { + // given + when(memberRepository.findById(1L)).thenReturn(Optional.of(member)); + when(postRepository.findWithLockById(1L)).thenReturn(Optional.of(post)); + when(postLikeRepository.existsByMemberAndPost(member, post)).thenReturn(false); + + // then + assertThrows(NotExistPostLikeException.class, () -> postLikeService.disLikePost(1L, 1L)); + } + +} \ No newline at end of file diff --git a/BE/school/src/test/java/thisisus/school/post/service/PostLikeSynchronousTest.java b/BE/school/src/test/java/thisisus/school/post/service/PostLikeSynchronousTest.java new file mode 100644 index 0000000..cae4897 --- /dev/null +++ b/BE/school/src/test/java/thisisus/school/post/service/PostLikeSynchronousTest.java @@ -0,0 +1,94 @@ +package thisisus.school.post.service; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import thisisus.school.member.domain.Member; +import thisisus.school.member.domain.MemberStatus; +import thisisus.school.member.domain.Role; +import thisisus.school.member.repository.MemberRepository; +import thisisus.school.post.domain.Post; +import thisisus.school.post.domain.PostCategory; +import thisisus.school.post.repository.PostRepository; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +public class PostLikeSynchronousTest { + @Autowired + MemberRepository memberRepository; + + @Autowired + PostRepository postRepository; + + @Autowired + PostLikeServiceImpl postLikeService; + + private Member member; + private Member member2; + private Post post; + + @BeforeEach + void setUp() { + member = new Member(1L, "장창현", + "abcdef@naver.com", "장첸", Role.STUDENT, MemberStatus.ACTIVE, + "tempRefresh"); + member2 = new Member(2L, "윤후중", + "sdfsdf@naver.com", "윤후", Role.STUDENT, MemberStatus.ACTIVE, + "tempRefresh2"); + memberRepository.save(member); + memberRepository.save(member2); + post = new Post(1L, "테스트용 제목", "테스트용 내용", + PostCategory.소통해요, 0, 0, false, member); + postRepository.save(post); + } + + @Test + @DisplayName("좋아요 기능에 대한 동시성 테스트2") + void db를_이용한_동시성_테스트() throws InterruptedException { + ExecutorService executorService = Executors.newFixedThreadPool(2); + CountDownLatch latch = new CountDownLatch(2); + executorService.execute(() -> { + postLikeService.likePost(1L, member.getId()); + latch.countDown(); + }); + + executorService.execute(() -> { + postLikeService.likePost(1L, member2.getId()); + latch.countDown(); + }); + latch.await(); + Post post1 = postRepository.findById(1L).orElseThrow(RuntimeException::new); + Assertions.assertEquals(2, post1.getLikeCount()); + } + + // PostLike에 insert를 안하고 post의 likecount에 대해서만 update를 수행할때 +// @Test +// @DisplayName("좋아요 기능에 대한 동시성 테스트3") +// void db를_이용한_동시성_테스트2() throws InterruptedException{ +// int numberOfThreads = 100; +// ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads); +// CountDownLatch latch = new CountDownLatch(numberOfThreads); +// +// for (int i = 0; i < numberOfThreads; i++) { +// executorService.submit(() -> { +// try { +// postLikeService.insertLike(1L, 1L); +// } finally { +// latch.countDown(); +// } +// }); +// +// } +// latch.await(); +// Post post1 = postRepository.findById(1L).orElseThrow(RuntimeException::new); +// assertEquals(numberOfThreads, post1.getLikeCount()); +// } +}