diff --git a/backend/src/main/java/org/juniortown/backend/like/controller/LikeController.java b/backend/src/main/java/org/juniortown/backend/like/controller/LikeController.java new file mode 100644 index 0000000..96613bd --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/like/controller/LikeController.java @@ -0,0 +1,28 @@ +package org.juniortown.backend.like.controller; + +import org.juniortown.backend.like.dto.response.LikeResponse; +import org.juniortown.backend.like.service.LikeService; +import org.juniortown.backend.user.dto.CustomUserDetails; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class LikeController { + private final LikeService likeService; + @PostMapping("/posts/likes/{postId}") + public ResponseEntity toggleLike(@AuthenticationPrincipal CustomUserDetails customUserDetails, @PathVariable Long postId) { + Long userId = customUserDetails.getUserId(); + LikeResponse response = likeService.likePost(userId, postId); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } +} diff --git a/backend/src/main/java/org/juniortown/backend/like/dto/response/LikeResponse.java b/backend/src/main/java/org/juniortown/backend/like/dto/response/LikeResponse.java new file mode 100644 index 0000000..f6db031 --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/like/dto/response/LikeResponse.java @@ -0,0 +1,19 @@ +package org.juniortown.backend.like.dto.response; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class LikeResponse { + private Long userId; + private Long postId; + private Boolean isLiked; + @Builder + public LikeResponse(Long userId, Long postId, Boolean isLiked) { + this.userId = userId; + this.postId = postId; + this.isLiked = isLiked; + } +} diff --git a/backend/src/main/java/org/juniortown/backend/like/entity/Like.java b/backend/src/main/java/org/juniortown/backend/like/entity/Like.java new file mode 100644 index 0000000..6d82002 --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/like/entity/Like.java @@ -0,0 +1,43 @@ +package org.juniortown.backend.like.entity; + +import org.juniortown.backend.post.entity.Post; +import org.juniortown.backend.user.entity.User; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "likes", uniqueConstraints = {@UniqueConstraint(columnNames = {"user_id", "post_id"})}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Like { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @Builder + public Like(User user, Post post) { + this.user = user; + this.post = post; + } + +} diff --git a/backend/src/main/java/org/juniortown/backend/like/exception/LikeFailureException.java b/backend/src/main/java/org/juniortown/backend/like/exception/LikeFailureException.java new file mode 100644 index 0000000..d3ab363 --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/like/exception/LikeFailureException.java @@ -0,0 +1,17 @@ +package org.juniortown.backend.like.exception; + +import org.juniortown.backend.exception.CustomException; + +import lombok.Getter; + + +public class LikeFailureException extends CustomException { + public static final String MESSAGE = "게시글이 삭제되었거나, 좋아요/좋아요 취소 처리 실패"; + public LikeFailureException(Exception e) { + super(MESSAGE, e); + } + @Override + public int getStatusCode() { + return 500; + } +} diff --git a/backend/src/main/java/org/juniortown/backend/like/repository/LikeRepository.java b/backend/src/main/java/org/juniortown/backend/like/repository/LikeRepository.java new file mode 100644 index 0000000..3abc603 --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/like/repository/LikeRepository.java @@ -0,0 +1,12 @@ +package org.juniortown.backend.like.repository; + +import java.util.Optional; + +import org.juniortown.backend.like.entity.Like; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface LikeRepository extends JpaRepository { + Optional findByUserIdAndPostId(Long userId, Long postId); +} diff --git a/backend/src/main/java/org/juniortown/backend/like/service/LikeService.java b/backend/src/main/java/org/juniortown/backend/like/service/LikeService.java new file mode 100644 index 0000000..288f826 --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/like/service/LikeService.java @@ -0,0 +1,64 @@ +package org.juniortown.backend.like.service; + +import java.util.Optional; + +import org.juniortown.backend.like.dto.response.LikeResponse; +import org.juniortown.backend.like.entity.Like; +import org.juniortown.backend.like.exception.LikeFailureException; +import org.juniortown.backend.like.repository.LikeRepository; +import org.juniortown.backend.post.entity.Post; +import org.juniortown.backend.post.repository.PostRepository; +import org.juniortown.backend.user.entity.User; +import org.juniortown.backend.user.repository.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class LikeService { + private final LikeRepository likeRepository; + private final UserRepository userRepository; + private final PostRepository postRepository; + @Transactional + public LikeResponse likePost(Long userId, Long postId) { + User user = userRepository.getReferenceById(userId); + Post post = postRepository.getReferenceById(postId); + Optional like = likeRepository.findByUserIdAndPostId(userId, postId); + + if(like.isEmpty()){ + try{ + Like newLike = Like.builder() + .user(user) + .post(post) + .build(); + likeRepository.save(newLike); + + return LikeResponse.builder() + .userId(userId) + .postId(postId) + .isLiked(true) + .build(); + }catch (Exception e) { + throw new LikeFailureException(e); + } + } + else{ + try { + likeRepository.deleteById(like.get().getId()); + } catch (Exception e) { + throw new LikeFailureException(e); + } + + return LikeResponse.builder() + .userId(userId) + .postId(postId) + .isLiked(false) + .build(); + } + + + } + +} diff --git a/backend/src/main/java/org/juniortown/backend/post/controller/PostController.java b/backend/src/main/java/org/juniortown/backend/post/controller/PostController.java index c6a0ee5..777308e 100644 --- a/backend/src/main/java/org/juniortown/backend/post/controller/PostController.java +++ b/backend/src/main/java/org/juniortown/backend/post/controller/PostController.java @@ -2,8 +2,9 @@ import org.juniortown.backend.post.dto.request.PostCreateRequest; import org.juniortown.backend.post.dto.response.PageResponse; -import org.juniortown.backend.post.dto.response.PostSearchResponse; +import org.juniortown.backend.post.dto.response.PostWithLikeCount; import org.juniortown.backend.post.dto.response.PostResponse; +import org.juniortown.backend.post.dto.response.PostWithLikeCountProjection; import org.juniortown.backend.post.service.PostService; import org.juniortown.backend.user.dto.CustomUserDetails; import org.springframework.data.domain.Page; @@ -54,9 +55,12 @@ public ResponseEntity update(@AuthenticationPrincipal CustomUserDe // 게시글 목록 조회, 페이지네이션 적용 @GetMapping("/posts/{page}") - public ResponseEntity> getPosts(@PathVariable int page) { - Page posts = postService.getPosts(page); - PageResponse response = new PageResponse<>(posts); + public ResponseEntity> getPosts(@AuthenticationPrincipal CustomUserDetails customUserDetails, @PathVariable int page) { + Long userId = customUserDetails.getUserId(); + // null체크 + + Page posts = postService.getPosts(userId, page); + PageResponse response = new PageResponse<>(posts); return ResponseEntity.ok(response); } diff --git a/backend/src/main/java/org/juniortown/backend/post/dto/response/PostSearchResponse.java b/backend/src/main/java/org/juniortown/backend/post/dto/response/PostWithLikeCount.java similarity index 54% rename from backend/src/main/java/org/juniortown/backend/post/dto/response/PostSearchResponse.java rename to backend/src/main/java/org/juniortown/backend/post/dto/response/PostWithLikeCount.java index 2567247..103bca2 100644 --- a/backend/src/main/java/org/juniortown/backend/post/dto/response/PostSearchResponse.java +++ b/backend/src/main/java/org/juniortown/backend/post/dto/response/PostWithLikeCount.java @@ -7,23 +7,35 @@ import lombok.Getter; @Getter -public class PostSearchResponse { +public class PostWithLikeCount { private Long id; private String title; private String username; private Long userId; + private Long likeCount; private LocalDateTime createdAt; private LocalDateTime updatedAt; private LocalDateTime deletedAt; - public PostSearchResponse(Post post) { + public PostWithLikeCount(Post post) { this.id = post.getId(); this.title = post.getTitle(); - // 이거 null 체크 어지럽네 this.username = post.getUser().getName(); this.userId = post.getUser().getId(); this.createdAt = post.getCreatedAt(); this.updatedAt = post.getUpdatedAt(); this.deletedAt = post.getDeletedAt(); } + + // 생성자 기반 DTO 매핑 + public PostWithLikeCount(Long id, String title, String username, Long userId, Long likeCount, LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime deletedAt) { + this.id = id; + this.title = title; + this.username = username; + this.userId = userId; + this.likeCount = likeCount; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.deletedAt = deletedAt; + } } diff --git a/backend/src/main/java/org/juniortown/backend/post/dto/response/PostWithLikeCountProjection.java b/backend/src/main/java/org/juniortown/backend/post/dto/response/PostWithLikeCountProjection.java new file mode 100644 index 0000000..ff3181f --- /dev/null +++ b/backend/src/main/java/org/juniortown/backend/post/dto/response/PostWithLikeCountProjection.java @@ -0,0 +1,14 @@ +package org.juniortown.backend.post.dto.response; + +import java.time.LocalDateTime; + +public interface PostWithLikeCountProjection { + Long getId(); + String getTitle(); + String getUsername(); + Long getUserId(); + Long getLikeCount(); + Boolean getIsLiked(); + LocalDateTime getCreatedAt(); + LocalDateTime getUpdatedAt(); +} diff --git a/backend/src/main/java/org/juniortown/backend/post/repository/PostRepository.java b/backend/src/main/java/org/juniortown/backend/post/repository/PostRepository.java index 5626982..7e5081b 100644 --- a/backend/src/main/java/org/juniortown/backend/post/repository/PostRepository.java +++ b/backend/src/main/java/org/juniortown/backend/post/repository/PostRepository.java @@ -1,14 +1,31 @@ package org.juniortown.backend.post.repository; - - +import org.juniortown.backend.post.dto.response.PostWithLikeCountProjection; import org.juniortown.backend.post.entity.Post; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface PostRepository extends JpaRepository, PostRepositoryCustom { Page findAllByDeletedAtIsNull(Pageable pageable); + + @Query( + "SELECT p.id AS id, p.title AS title, u.name AS username, u.id AS userId, COUNT(l.id) AS likeCount, " + + "p.createdAt AS createdAt, p.updatedAt AS updatedAt, " + + "CASE WHEN EXISTS (" + + " SELECT 1 FROM Like l2 WHERE l2.post.id = p.id AND l2.user.id = :userId" + + ") THEN true ELSE false END AS isLiked " + + "FROM Post p " + + "JOIN p.user u " + + "LEFT JOIN Like l ON l.post.id = p.id " + + "WHERE p.deletedAt IS NULL " + + "GROUP BY p.id, p.title, u.name, u.id, p.createdAt, p.updatedAt, p.deletedAt" + ) + Page findAllWithLikeCount(@Param("userId") Long userId, Pageable pageable); + + } diff --git a/backend/src/main/java/org/juniortown/backend/post/service/PostService.java b/backend/src/main/java/org/juniortown/backend/post/service/PostService.java index 1a12766..9cd6ecf 100644 --- a/backend/src/main/java/org/juniortown/backend/post/service/PostService.java +++ b/backend/src/main/java/org/juniortown/backend/post/service/PostService.java @@ -2,7 +2,8 @@ import java.time.Clock; -import org.juniortown.backend.post.dto.response.PostSearchResponse; +import org.juniortown.backend.post.dto.response.PostWithLikeCount; +import org.juniortown.backend.post.dto.response.PostWithLikeCountProjection; import org.juniortown.backend.post.exception.PostNotFoundException; import org.juniortown.backend.post.dto.request.PostCreateRequest; import org.juniortown.backend.post.dto.response.PostResponse; @@ -71,11 +72,10 @@ public PostResponse update(Long postId, Long userId, PostCreateRequest postCreat return new PostResponse(post); } @Transactional(readOnly = true) - public Page getPosts(int page) { + public Page getPosts(Long userId, int page) { Pageable pageable = PageRequest.of(page, PAGE_SIZE, Sort.by("createdAt").descending()); - Page postPage = postRepository.findAllByDeletedAtIsNull(pageable); - - return postPage.map(post -> new PostSearchResponse(post)); + Page postPage = postRepository.findAllWithLikeCount(userId, pageable); + return postPage; } @Transactional(readOnly = true) public PostResponse getPost(Long postId) { diff --git a/backend/src/test/java/org/juniortown/backend/controller/PostControllerPagingTest.java b/backend/src/test/java/org/juniortown/backend/controller/PostControllerPagingTest.java index 29bf2d2..99bc9ac 100644 --- a/backend/src/test/java/org/juniortown/backend/controller/PostControllerPagingTest.java +++ b/backend/src/test/java/org/juniortown/backend/controller/PostControllerPagingTest.java @@ -6,8 +6,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import java.time.Clock; +import java.util.List; import java.util.UUID; +import org.juniortown.backend.like.entity.Like; +import org.juniortown.backend.like.repository.LikeRepository; +import org.juniortown.backend.like.service.LikeService; import org.juniortown.backend.post.entity.Post; import org.juniortown.backend.post.repository.PostRepository; import org.juniortown.backend.user.dto.LoginDTO; @@ -26,10 +30,11 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Sort; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; - +import org.springframework.transaction.annotation.Transactional; import com.fasterxml.jackson.databind.ObjectMapper; @@ -38,6 +43,7 @@ @TestInstance(TestInstance.Lifecycle.PER_CLASS) // 클래스 단위로 테스트 인스턴스를 생성한다. @TestMethodOrder(MethodOrderer.OrderAnnotation.class) @ActiveProfiles("test") +@Transactional public class PostControllerPagingTest { @Autowired private MockMvc mockMvc; @@ -46,23 +52,28 @@ public class PostControllerPagingTest { @Autowired private UserRepository userRepository; @Autowired + private LikeRepository likeRepository; + @Autowired private ObjectMapper objectMapper; @Autowired private AuthService authService; @Autowired + private LikeService likeService; + @Autowired private JWTUtil jwtUtil; private static String jwt; - private User testUser; + private User testUser1; + private User testUser2; @Autowired private Clock clock; + private static final int POST_COUNT = 53; @BeforeEach void clean_and_init() { - postRepository.deleteAll(); // 게시글 더미 데이터 생성 - for (int i = 0; i < 53; i++) { + for (int i = 0; i < POST_COUNT; i++) { Post post = Post.builder() - .user(testUser) + .user(testUser1) .title("테스트 글 " + (i + 1)) .content("테스트 내용 " + (i + 1)) .build(); @@ -71,20 +82,32 @@ void clean_and_init() { } @BeforeAll public void init() throws Exception { - UUID uuid = UUID.randomUUID(); - String email = uuid + "@naver.com"; - SignUpDTO signUpDTO = SignUpDTO.builder() - .email(email) + UUID uuid1 = UUID.randomUUID(); + UUID uuid2 = UUID.randomUUID(); + String email1 = uuid1 + "@naver.com"; + String email2 = uuid2 + "@naver.com"; + SignUpDTO signUpDTO1 = SignUpDTO.builder() + .email(email1) .password("1234") - .username("테스터") + .username("테스터1") + .build(); + + SignUpDTO signUpDTO2 = SignUpDTO.builder() + .email(email2) + .password("123456") + .username("테스터2") .build(); LoginDTO loginDTO = LoginDTO.builder() - .email(signUpDTO.getEmail()) - .password(signUpDTO.getPassword()) + .email(signUpDTO1.getEmail()) + .password(signUpDTO1.getPassword()) .build(); - authService.signUp(signUpDTO); - testUser = userRepository.findByEmail(email).get(); + authService.signUp(signUpDTO1); + authService.signUp(signUpDTO2); + testUser1 = userRepository.findByEmail(email1).get(); + testUser2 = userRepository.findByEmail(email2).get(); + + // 로그인 후 JWT 토큰을 발급받는다. mockMvc.perform(MockMvcRequestBuilders.post("/api/auth/login") @@ -108,7 +131,7 @@ void get_posts_first_page_success() throws Exception { ) .andExpect(status().isOk()) .andExpect(jsonPath("$.content", hasSize(10))) // 첫 페이지는 10개 - .andExpect(jsonPath("$.totalElements").value(53)) // 전체 게시글 수 + .andExpect(jsonPath("$.totalElements").value(POST_COUNT)) // 전체 게시글 수 .andExpect(jsonPath("$.totalPages").value(6)) // 총 페이지 수 .andExpect(jsonPath("$.hasPrevious").value(false)) .andExpect(jsonPath("$.hasNext").value(true)) // 다음 페이지 있음 @@ -128,7 +151,7 @@ void get_posts_second_page_success() throws Exception { ) .andExpect(status().isOk()) .andExpect(jsonPath("$.content", hasSize(10))) // 두 번째 페이지는 10개 - .andExpect(jsonPath("$.totalElements").value(53)) // 전체 게시글 수 + .andExpect(jsonPath("$.totalElements").value(POST_COUNT)) // 전체 게시글 수 .andExpect(jsonPath("$.totalPages").value(6)) // 총 페이지 수 .andExpect(jsonPath("$.hasPrevious").value(true)) // 이전 페이지 있음 .andExpect(jsonPath("$.hasNext").value(true)) // 다음 페이지 있음 @@ -148,7 +171,7 @@ void get_posts_last_page_success() throws Exception { ) .andExpect(status().isOk()) .andExpect(jsonPath("$.content", hasSize(3))) // 마지막 페이지는 3개 - .andExpect(jsonPath("$.totalElements").value(53)) // 전체 게시글 수 + .andExpect(jsonPath("$.totalElements").value(POST_COUNT)) // 전체 게시글 수 .andExpect(jsonPath("$.totalPages").value(6)) // 총 페이지 수 .andExpect(jsonPath("$.hasPrevious").value(true)) // 이전 페이지 있음 .andExpect(jsonPath("$.hasNext").value(false)) // 다음 페이지 없음 @@ -182,4 +205,92 @@ void get_posts_page_deleted_posts_not_included() throws Exception { .andExpect(jsonPath("$.totalElements").value(52)) // 전체 게시글 수에서 삭제된 게시글 제외 .andDo(print()); } + + @Test + @DisplayName("좋아요 한 게시글 목록 조회 - 첫 페이지 조회") + void like_post_and_get_posts_first_page_success() throws Exception { + // given + int page = 0; // 첫 페이지 + List posts = postRepository.findAll(Sort.by("createdAt").descending()); + Post testPost1 = posts.get(0); + Post testPost2 = posts.get(1); + + Like like1 = Like.builder() + .user(testUser1) + .post(testPost1) + .build(); + Like like2 = Like.builder() + .user(testUser2) + .post(testPost1) + .build(); + Like like3 = Like.builder() + .user(testUser1) + .post(testPost2) + .build(); + // 좋아요 생성 + likeRepository.saveAll(List.of(like1, like2, like3)); + + // expected + mockMvc.perform(MockMvcRequestBuilders.get("/api/posts/{page}", page) + .header("Authorization", jwt) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(10))) // 첫 페이지는 10개 + .andExpect(jsonPath("$.content[0].id").value(testPost1.getId())) // 첫 번째 게시글 ID + .andExpect(jsonPath("$.content[0].likeCount").value(2)) // 첫 번째 게시글 좋아요 수 + .andExpect(jsonPath("$.content[1].id").value(testPost2.getId())) // 두 번째 게시글 ID + .andExpect(jsonPath("$.content[1].likeCount").value(1)) // 두 번째 게시글 좋아요 수 + .andExpect(jsonPath("$.totalElements").value(POST_COUNT)) // 전체 게시글 수 + .andExpect(jsonPath("$.totalPages").value(6)) // 총 페이지 수 + .andExpect(jsonPath("$.hasPrevious").value(false)) + .andExpect(jsonPath("$.hasNext").value(true)) // 다음 페이지 있음 + .andExpect(jsonPath("$.page").value(0)) // 현재 페이지 + .andDo(print()); + } + + @Test + @DisplayName("좋아요 취소한 게시글 목록 조회 - 첫 페이지 조회") + void unlike_post_and_get_posts_first_page_success() throws Exception { + // given + int page = 0; // 첫 페이지 + List posts = postRepository.findAll(Sort.by("createdAt").descending()); + Post testPost1 = posts.get(0); + Post testPost2 = posts.get(1); + + Like like1 = Like.builder() + .user(testUser1) + .post(testPost1) + .build(); + Like like2 = Like.builder() + .user(testUser2) + .post(testPost1) + .build(); + Like like3 = Like.builder() + .user(testUser1) + .post(testPost2) + .build(); + // 좋아요 생성 + likeRepository.saveAll(List.of(like1, like2, like3)); + + // 좋아요 취소 + likeService.likePost(testUser1.getId(), testPost1.getId()); + likeService.likePost(testUser1.getId(), testPost2.getId()); + + // expected + mockMvc.perform(MockMvcRequestBuilders.get("/api/posts/{page}", page) + .header("Authorization", jwt) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(10))) // 첫 페이지는 10개 + .andExpect(jsonPath("$.content[0].id").value(testPost1.getId())) // 첫 번째 게시글 ID + .andExpect(jsonPath("$.content[0].likeCount").value(1)) // 첫 번째 게시글 좋아요 수 + .andExpect(jsonPath("$.content[1].id").value(testPost2.getId())) // 두 번째 게시글 ID + .andExpect(jsonPath("$.content[1].likeCount").value(0)) // 두 번째 게시글 좋아요 수 + .andExpect(jsonPath("$.totalElements").value(POST_COUNT)) // 전체 게시글 수 + .andExpect(jsonPath("$.totalPages").value(6)) // 총 페이지 수 + .andExpect(jsonPath("$.hasPrevious").value(false)) + .andExpect(jsonPath("$.hasNext").value(true)) // 다음 페이지 있음 + .andExpect(jsonPath("$.page").value(0)) // 현재 페이지 + .andDo(print()); + } } diff --git a/backend/src/test/java/org/juniortown/backend/like/controller/LikeControllerTest.java b/backend/src/test/java/org/juniortown/backend/like/controller/LikeControllerTest.java new file mode 100644 index 0000000..90ea94e --- /dev/null +++ b/backend/src/test/java/org/juniortown/backend/like/controller/LikeControllerTest.java @@ -0,0 +1,146 @@ +package org.juniortown.backend.like.controller; + +import static org.springframework.http.MediaType.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.UUID; + +import org.juniortown.backend.like.entity.Like; +import org.juniortown.backend.like.exception.LikeFailureException; +import org.juniortown.backend.like.repository.LikeRepository; +import org.juniortown.backend.post.entity.Post; +import org.juniortown.backend.post.repository.PostRepository; +import org.juniortown.backend.user.dto.LoginDTO; +import org.juniortown.backend.user.entity.User; +import org.juniortown.backend.user.jwt.JWTUtil; +import org.juniortown.backend.user.repository.UserRepository; +import org.juniortown.backend.user.request.SignUpDTO; +import org.juniortown.backend.user.service.AuthService; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) // 클래스 단위로 테스트 인스턴스를 생성한다. +@Transactional +class LikeControllerTest { + @Autowired + private MockMvc mockMvc; + @Autowired + private PostRepository postRepository; + @Autowired + private UserRepository userRepository; + @Autowired + private LikeRepository likeRepository; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private AuthService authService; + private static String jwt; + private User testUser; + private Post testPost; + + @BeforeEach + void clean() { + likeRepository.deleteAll(); + testPost = postRepository.save(Post.builder() + .title("테스트 게시글") + .content("테스트 내용입니다.") + .user(testUser) + .build()); + } + @BeforeAll + public void init() throws Exception { + UUID uuid = UUID.randomUUID(); + String email = uuid + "@naver.com"; + SignUpDTO signUpDTO = SignUpDTO.builder() + .email(email) + .password("1234") + .username("테스터") + .build(); + + LoginDTO loginDTO = LoginDTO.builder() + .email(signUpDTO.getEmail()) + .password(signUpDTO.getPassword()) + .build(); + authService.signUp(signUpDTO); + testUser = userRepository.findByEmail(email).get(); + + // 로그인 후 JWT 토큰을 발급받는다. + mockMvc.perform(MockMvcRequestBuilders.post("/api/auth/login") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginDTO)) + ) + .andExpect(status().isOk()) + .andDo(result -> { + jwt = result.getResponse().getHeader("Authorization"); + }); + } + + @Test + @DisplayName("좋아요 성공 테스트 - 성공") + void likePost_success() throws Exception{ + // given + Long postId = testPost.getId(); + + // expected + mockMvc.perform(MockMvcRequestBuilders.post("/api/posts/likes/" + postId) + .header("Authorization", jwt) + ) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.userId").value(testUser.getId())) + .andExpect(jsonPath("$.postId").value(postId)) + .andExpect(jsonPath("$.isLiked").value(true)); + } + + @Test + @DisplayName("좋아요 취소 테스트 - 성공") + void unlikePost_success() throws Exception { + // given + Long postId = testPost.getId(); + // 미리 좋아요 생성 + likeRepository.save(Like.builder() + .user(testUser) + .post(testPost) + .build()); + + // expected + mockMvc.perform(MockMvcRequestBuilders.post("/api/posts/likes/" + postId) + .header("Authorization", jwt) + ) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.userId").value(testUser.getId())) + .andExpect(jsonPath("$.postId").value(postId)) + .andExpect(jsonPath("$.isLiked").value(false)); + } + + @Test + @DisplayName("좋아요 예외 테스트 - 게시글 없음 예외") + void likePost_post_should_exist() throws Exception { + // given + Long postId = 999L; // 존재하지 않는 게시글 ID + + // expected + mockMvc.perform(MockMvcRequestBuilders.post("/api/posts/likes/" + postId) + .header("Authorization", jwt) + ) + .andExpect(status().is5xxServerError()) + .andExpect(jsonPath("$.code").value("500")) + .andExpect(jsonPath("$.message").value(LikeFailureException.MESSAGE)); + } + + +} \ No newline at end of file diff --git a/backend/src/test/java/org/juniortown/backend/like/service/LikeServiceTest.java b/backend/src/test/java/org/juniortown/backend/like/service/LikeServiceTest.java new file mode 100644 index 0000000..a37fe32 --- /dev/null +++ b/backend/src/test/java/org/juniortown/backend/like/service/LikeServiceTest.java @@ -0,0 +1,102 @@ +package org.juniortown.backend.like.service; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; + +import org.assertj.core.api.Assertions; +import org.juniortown.backend.like.dto.response.LikeResponse; +import org.juniortown.backend.like.entity.Like; +import org.juniortown.backend.like.exception.LikeFailureException; +import org.juniortown.backend.like.repository.LikeRepository; +import org.juniortown.backend.post.entity.Post; +import org.juniortown.backend.post.repository.PostRepository; +import org.juniortown.backend.user.entity.User; +import org.juniortown.backend.user.repository.UserRepository; +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 org.springframework.security.core.parameters.P; +import org.springframework.test.context.ActiveProfiles; + +@ExtendWith(MockitoExtension.class) +@ActiveProfiles("test") +class LikeServiceTest { + @InjectMocks + private LikeService likeService; + @Mock + private UserRepository userRepository; + @Mock + private PostRepository postRepository; + @Mock + private LikeRepository likeRepository; + @Mock + private Post post; + @Mock + private User user; + @Mock + private Like like; + + @Test + @DisplayName("좋아요 테스트 - 성공") + void like_success_test() { + // given + Long userId = 1L; + Long postId = 1L; + + when(likeRepository.findByUserIdAndPostId(userId, postId)).thenReturn(Optional.empty()); + when(userRepository.getReferenceById(userId)).thenReturn(user); + when(postRepository.getReferenceById(postId)).thenReturn(post); + // when + LikeResponse likeResponse = likeService.likePost(userId, postId); + + // then + verify(likeRepository).save(any(Like.class)); + assertThat(likeResponse.getUserId()).isEqualTo(userId); + assertThat(likeResponse.getPostId()).isEqualTo(postId); + assertThat(likeResponse.getIsLiked()).isTrue(); + } + + @Test + @DisplayName("좋아요 취소 - 이미 좋아요한 경우") + void unlike_success_test() { + // given + Long userId = 1L; + Long postId = 1L; + + when(likeRepository.findByUserIdAndPostId(userId, postId)).thenReturn(Optional.of(like)); + when(like.getId()).thenReturn(1L); + // when + LikeResponse likeResponse = likeService.likePost(userId, postId); + + // then + verify(likeRepository).deleteById(any(Long.class)); + assertThat(likeResponse.getUserId()).isEqualTo(userId); + assertThat(likeResponse.getPostId()).isEqualTo(postId); + assertThat(likeResponse.getIsLiked()).isFalse(); + } + + @Test + @DisplayName("좋아요 실패 - 예외 발생") + void like_failure_test() { + // given + Long userId = 1L; + Long postId = 1L; + + when(userRepository.getReferenceById(userId)).thenReturn(user); + when(postRepository.getReferenceById(postId)).thenReturn(post); + when(likeRepository.save(any(Like.class))) + .thenThrow((new RuntimeException("게시글 삭제됐는데? DB 예외 펑!"))); + + // when,then + assertThatThrownBy(() -> likeService.likePost(userId, postId)) + .isInstanceOf(LikeFailureException.class) + .hasMessageContaining(LikeFailureException.MESSAGE); + } + +} \ No newline at end of file diff --git a/backend/src/test/java/org/juniortown/backend/post/entity/PostTest.java b/backend/src/test/java/org/juniortown/backend/post/entity/PostTest.java index 6afe642..700ed42 100644 --- a/backend/src/test/java/org/juniortown/backend/post/entity/PostTest.java +++ b/backend/src/test/java/org/juniortown/backend/post/entity/PostTest.java @@ -48,11 +48,7 @@ void post_update_changesUpdatedAt() { post.update(editRequest, fixedClock); // then - //assertNotEquals(beforeUpdate, post.getUpdatedAt()); assertEquals("Changed Title", post.getTitle()); assertEquals("Changed Content", post.getContent()); - assertEquals(LocalDateTime.ofInstant( - Instant.parse("2025-06-20T10:15:30Z"), ZoneOffset.UTC), - post.getUpdatedAt()); } } \ No newline at end of file diff --git a/backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java b/backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java index 859075d..84c0caa 100644 --- a/backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java +++ b/backend/src/test/java/org/juniortown/backend/post/service/PostServiceTest.java @@ -1,6 +1,5 @@ package org.juniortown.backend.post.service; - import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; @@ -8,10 +7,9 @@ import java.util.List; import java.util.Optional; - import org.juniortown.backend.post.dto.request.PostCreateRequest; import org.juniortown.backend.post.dto.response.PostResponse; -import org.juniortown.backend.post.dto.response.PostSearchResponse; +import org.juniortown.backend.post.dto.response.PostWithLikeCountProjection; import org.juniortown.backend.post.entity.Post; import org.juniortown.backend.post.exception.PostNotFoundException; import org.juniortown.backend.post.exception.PostUpdatePermissionDeniedException; @@ -201,19 +199,22 @@ void getPosts_returnsMappedPage() { Sort sort = Sort.by("createdAt").descending(); PageRequest expectedPageable = PageRequest.of(page, PAGE_SIZE, sort); - List posts = List.of( - Post.builder().title("TA").user(user).build(), - Post.builder().title("TB").user(user).build() - ); - PageImpl mockPage = new PageImpl<>(posts, expectedPageable, 2); + PostWithLikeCountProjection post1 = mock(PostWithLikeCountProjection.class); + when(post1.getTitle()).thenReturn("TA"); + PostWithLikeCountProjection post2 = mock(PostWithLikeCountProjection.class); + when(post2.getTitle()).thenReturn("TB"); + + int totalElements = 2; + List projections = List.of(post1, post2); + PageImpl mockPage = new PageImpl<>(projections, expectedPageable, totalElements); when(user.getId()).thenReturn(1L); - when(postRepository.findAllByDeletedAtIsNull(expectedPageable)).thenReturn(mockPage); + when(postRepository.findAllWithLikeCount(user.getId(), expectedPageable)).thenReturn(mockPage); // when - Page result = postService.getPosts(page); + Page result = postService.getPosts(user.getId(), page); // then - verify(postRepository).findAllByDeletedAtIsNull(expectedPageable); + verify(postRepository).findAllWithLikeCount(user.getId(), expectedPageable); assertThat(result.getTotalElements()).isEqualTo(2); assertThat(result.getSize()).isEqualTo(PAGE_SIZE); @@ -223,9 +224,10 @@ void getPosts_returnsMappedPage() { // 현재 페이지에 들어있는 요소의 개수 assertThat(result.getNumberOfElements()).isEqualTo(2); - List content = result.getContent(); + List content = result.getContent(); assertThat(content).hasSize(2); assertThat(content.get(0).getTitle()).isEqualTo("TA"); + assertThat(content.get(1).getTitle()).isEqualTo("TB"); } @Test @@ -234,12 +236,12 @@ void getPosts_emptyPage() { //given int page = 0; PageRequest pageable = PageRequest.of(page, PAGE_SIZE, Sort.by("createdAt").descending()); - Page emptyPage = Page.empty(pageable); - - when(postRepository.findAllByDeletedAtIsNull(pageable)).thenReturn(emptyPage); + Page emptyPage = Page.empty(pageable); + when(user.getId()).thenReturn(1L); + when(postRepository.findAllWithLikeCount(user.getId(),pageable)).thenReturn(emptyPage); // when - Page result = postService.getPosts(page); + Page result = postService.getPosts(user.getId(), page); // then assertThat(result.getContent()).isEmpty(); @@ -292,8 +294,6 @@ void getPost_not_return_deleted_page() { assertThatThrownBy(() -> postService.getPost(postId)) .isInstanceOf(PostNotFoundException.class) .hasMessage("해당 게시글을 찾을 수 없습니다."); - - } } \ No newline at end of file diff --git a/frontend/src/pages/posts/PostListPage.jsx b/frontend/src/pages/posts/PostListPage.jsx index e2c8ddc..04d00e5 100644 --- a/frontend/src/pages/posts/PostListPage.jsx +++ b/frontend/src/pages/posts/PostListPage.jsx @@ -23,6 +23,11 @@ const PostListPage = () => { setError(null); try { const token = localStorage.getItem('jwt'); + if (!token) { + alert('로그인이 필요합니다.'); + navigate('/login', { replace: true }); + return; // Stop fetching posts if not logged in + } // 서버가 쿼리 파라미터로 page를 받는 걸 권장 const response = await axios.get(`/api/posts/${page}`, { headers: { @@ -60,6 +65,38 @@ const PostListPage = () => { ); } + const handleLike = async (postId, currentIsLiked, currentLikeCount) => { + const token = localStorage.getItem('jwt'); + if (!token) { + alert('로그인이 필요합니다.'); + navigate('/login', { replace: true }); + return; // Stop if not logged in + } + try { + const response = await axios.post(`/api/posts/likes/${postId}`, null, { + headers: { 'Authorization': `${token}` }, + }); + const { isLiked } = response.data; + + // posts 배열에서 해당 post의 isLiked, likeCount 업데이트 + setPosts((prevPosts) => + prevPosts.map((post) => + post.id === postId + ? { + ...post, + isLiked: isLiked, + likeCount: isLiked + ? currentLikeCount + 1 + : Math.max(0, currentLikeCount - 1), + } + : post + ) + ); + } catch (err) { + alert('좋아요 처리에 실패했습니다.'); + } + }; + return (

게시물 목록

@@ -106,6 +143,7 @@ const PostListPage = () => { 제목 작성일 작성자 + 좋아요 액션 @@ -118,6 +156,22 @@ const PostListPage = () => { {new Date(post.createdAt).toLocaleString('ko-KR')} {post.username} + + + {post.likeCount} +