From 192840de0c8d25d8c5b5401a2d719228b2fe6916 Mon Sep 17 00:00:00 2001 From: wingyou Date: Wed, 30 Oct 2024 00:11:44 +0900 Subject: [PATCH] feat(blog): implement hashtag search feature --- .../blog/controller/HashtagController.java | 42 +++++++++++++++ .../kr/tgwing/tech/blog/dto/HashtagQuery.java | 14 +++++ .../kr/tgwing/tech/blog/dto/HashtagView.java | 25 +++++++++ .../blog/entity/HashtagSpecifications.java | 27 ++++++++++ .../blog/repository/HashtagRepository.java | 13 +++++ .../tech/blog/service/HashtagService.java | 16 ++++++ .../tech/blog/service/HashtagServiceImpl.java | 36 +++++++++++++ .../tgwing/tech/blog/BlogIntegrationTest.java | 52 ++++++++++++++----- 8 files changed, 213 insertions(+), 12 deletions(-) create mode 100644 src/main/java/kr/tgwing/tech/blog/controller/HashtagController.java create mode 100644 src/main/java/kr/tgwing/tech/blog/dto/HashtagQuery.java create mode 100644 src/main/java/kr/tgwing/tech/blog/dto/HashtagView.java create mode 100644 src/main/java/kr/tgwing/tech/blog/entity/HashtagSpecifications.java create mode 100644 src/main/java/kr/tgwing/tech/blog/repository/HashtagRepository.java create mode 100644 src/main/java/kr/tgwing/tech/blog/service/HashtagService.java create mode 100644 src/main/java/kr/tgwing/tech/blog/service/HashtagServiceImpl.java diff --git a/src/main/java/kr/tgwing/tech/blog/controller/HashtagController.java b/src/main/java/kr/tgwing/tech/blog/controller/HashtagController.java new file mode 100644 index 0000000..904293a --- /dev/null +++ b/src/main/java/kr/tgwing/tech/blog/controller/HashtagController.java @@ -0,0 +1,42 @@ +package kr.tgwing.tech.blog.controller; + +import java.security.Principal; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.AllArgsConstructor; + +import kr.tgwing.tech.blog.dto.HashtagQuery; +import kr.tgwing.tech.blog.dto.HashtagView; +import kr.tgwing.tech.blog.service.HashtagService; + +/** + * HashtagController + */ +@RestController +@RequestMapping("/hashtag") +@AllArgsConstructor +public class HashtagController { + + private final HashtagService hashtagService; + + @GetMapping("") + public Page getHashtags( + @ModelAttribute HashtagQuery query, + @PageableDefault Pageable pageable, + Principal principal + ) { + String studentNumber = null; + if (principal != null) { + studentNumber = principal.getName(); + } + return hashtagService.getHashtags(query, studentNumber, pageable); + } + +} diff --git a/src/main/java/kr/tgwing/tech/blog/dto/HashtagQuery.java b/src/main/java/kr/tgwing/tech/blog/dto/HashtagQuery.java new file mode 100644 index 0000000..1ea22a0 --- /dev/null +++ b/src/main/java/kr/tgwing/tech/blog/dto/HashtagQuery.java @@ -0,0 +1,14 @@ +package kr.tgwing.tech.blog.dto; + +import lombok.Data; + +/** + * HashtagQuery + */ +@Data +public class HashtagQuery { + + private String keyword = ""; + private boolean me = false; + +} diff --git a/src/main/java/kr/tgwing/tech/blog/dto/HashtagView.java b/src/main/java/kr/tgwing/tech/blog/dto/HashtagView.java new file mode 100644 index 0000000..47fc5fd --- /dev/null +++ b/src/main/java/kr/tgwing/tech/blog/dto/HashtagView.java @@ -0,0 +1,25 @@ +package kr.tgwing.tech.blog.dto; + +import lombok.Builder; +import lombok.Data; + +import kr.tgwing.tech.blog.entity.Hashtag; + +/** + * HashtagView + */ +@Data +@Builder +public class HashtagView { + + private Long id; + private String name; + + public static HashtagView of(Hashtag hashtag) { + return HashtagView.builder() + .id(hashtag.getId()) + .name(hashtag.getName()) + .build(); + } + +} diff --git a/src/main/java/kr/tgwing/tech/blog/entity/HashtagSpecifications.java b/src/main/java/kr/tgwing/tech/blog/entity/HashtagSpecifications.java new file mode 100644 index 0000000..dede9e8 --- /dev/null +++ b/src/main/java/kr/tgwing/tech/blog/entity/HashtagSpecifications.java @@ -0,0 +1,27 @@ +package kr.tgwing.tech.blog.entity; + +import jakarta.persistence.criteria.Join; + +import org.springframework.data.jpa.domain.Specification; + +import kr.tgwing.tech.user.entity.User; + +/** + * HashtagSpecifications + */ +public class HashtagSpecifications { + + public static Specification hasNameLike(String keyword) { + return (root, query, cb) -> + cb.like(root.get("name"), "%" + keyword + "%"); + } + + public static Specification hasStudentWithStudentNumber(String studentNumber) { + return (root, query, cb) -> { + Join hashtagPost = root.join("post"); + Join postHashtag = hashtagPost.join("writer"); + return cb.equal(postHashtag.get("studentNumber"), studentNumber); + }; + } +} + diff --git a/src/main/java/kr/tgwing/tech/blog/repository/HashtagRepository.java b/src/main/java/kr/tgwing/tech/blog/repository/HashtagRepository.java new file mode 100644 index 0000000..d21a966 --- /dev/null +++ b/src/main/java/kr/tgwing/tech/blog/repository/HashtagRepository.java @@ -0,0 +1,13 @@ +package kr.tgwing.tech.blog.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +import kr.tgwing.tech.blog.entity.Hashtag; + +/** + * HashtagRepository + */ +public interface HashtagRepository extends JpaRepository, JpaSpecificationExecutor { + +} diff --git a/src/main/java/kr/tgwing/tech/blog/service/HashtagService.java b/src/main/java/kr/tgwing/tech/blog/service/HashtagService.java new file mode 100644 index 0000000..5783550 --- /dev/null +++ b/src/main/java/kr/tgwing/tech/blog/service/HashtagService.java @@ -0,0 +1,16 @@ +package kr.tgwing.tech.blog.service; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import kr.tgwing.tech.blog.dto.HashtagQuery; +import kr.tgwing.tech.blog.dto.HashtagView; + +/** + * HashtagService + */ +public interface HashtagService { + + Page getHashtags(HashtagQuery query, String userStudentNumber, Pageable pageable); + +} diff --git a/src/main/java/kr/tgwing/tech/blog/service/HashtagServiceImpl.java b/src/main/java/kr/tgwing/tech/blog/service/HashtagServiceImpl.java new file mode 100644 index 0000000..48245d7 --- /dev/null +++ b/src/main/java/kr/tgwing/tech/blog/service/HashtagServiceImpl.java @@ -0,0 +1,36 @@ +package kr.tgwing.tech.blog.service; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; + +import lombok.AllArgsConstructor; + +import kr.tgwing.tech.blog.dto.HashtagQuery; +import kr.tgwing.tech.blog.dto.HashtagView; +import kr.tgwing.tech.blog.entity.Hashtag; +import kr.tgwing.tech.blog.entity.HashtagSpecifications; +import kr.tgwing.tech.blog.repository.HashtagRepository; + +/** + * HashtagServiceImpl + */ +@Service +@AllArgsConstructor +public class HashtagServiceImpl implements HashtagService { + + private final HashtagRepository hashtagRepository; + + @Override + public Page getHashtags(HashtagQuery query, String userStudentNumber, Pageable pageable) { + Specification spec = HashtagSpecifications.hasNameLike(query.getKeyword()); + + if (query.isMe()) { + spec = spec.and(HashtagSpecifications.hasStudentWithStudentNumber(userStudentNumber)); + } + Page result = hashtagRepository.findAll(spec, pageable); + return result.map(HashtagView::of); + } + +} diff --git a/src/test/java/kr/tgwing/tech/blog/BlogIntegrationTest.java b/src/test/java/kr/tgwing/tech/blog/BlogIntegrationTest.java index cc29505..29c471b 100644 --- a/src/test/java/kr/tgwing/tech/blog/BlogIntegrationTest.java +++ b/src/test/java/kr/tgwing/tech/blog/BlogIntegrationTest.java @@ -13,6 +13,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import kr.tgwing.tech.annotation.IntegrationTest; import kr.tgwing.tech.blog.entity.Comment; @@ -84,7 +86,7 @@ static void prepare_test_data( .post(post2) .build(); Hashtag tag3 = Hashtag.builder() - .name("tag2") + .name("tag3") .post(post1) .build(); Comment comment1 = Comment.builder() @@ -149,17 +151,9 @@ void get_posts_has_hashtag() throws Exception { @Test void get_my_posts() throws Exception { - mvc.perform(post("/login") - .param("username", "2018000000") - .param("password", "12345678")) + performAsUser(get("/post?me=true")) .andExpect(status().isOk()) - .andExpect(header().exists("Authorization")) - .andDo((result) -> { - String token = result.getResponse().getHeader("Authorization"); - mvc.perform(get("/post?me=true").header("Authorization", token)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.totalElements").value(1)); - }); + .andExpect(jsonPath("$.totalElements").value(1)); } @Test @@ -167,5 +161,39 @@ void throw_when_get_my_posts_but_not_logged_in() throws Exception { mvc.perform(get("/post?me=true")) .andExpect(status().isBadRequest()); } - + + @Test + void get_list_of_all_hashtags() throws Exception { + mvc.perform(get("/hashtag")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(3)); + } + + @Test + void get_hashtags_containing_keword() throws Exception { + final String keyword = "1"; + mvc.perform(get("/hashtag").queryParam("keyword", keyword)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + void get_hashtags_of_post_that_i_wrote() throws Exception { + performAsUser(get("/hashtag").queryParam("me", "true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(2)); + } + + private ResultActions performAsUser(MockHttpServletRequestBuilder builder) throws Exception { + var result = mvc.perform(post("/login") + .param("username", "2018000000") + .param("password", "12345678")) + .andExpect(status().isOk()) + .andExpect(header().exists("Authorization")) + .andReturn(); + + String token = result.getResponse().getHeader("Authorization"); + return mvc.perform(builder.header("Authorization", token)); + } + }