diff --git a/backend/src/acceptanceTest/java/wooteco/prolog/steps/KeywordRecommendedPostStepDefinitions.java b/backend/src/acceptanceTest/java/wooteco/prolog/steps/KeywordRecommendedPostStepDefinitions.java new file mode 100644 index 000000000..2dfafcb0f --- /dev/null +++ b/backend/src/acceptanceTest/java/wooteco/prolog/steps/KeywordRecommendedPostStepDefinitions.java @@ -0,0 +1,58 @@ +package wooteco.prolog.steps; + +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import org.springframework.http.HttpStatus; +import wooteco.prolog.AcceptanceSteps; +import wooteco.prolog.roadmap.application.dto.RecommendedRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static wooteco.prolog.fixtures.KeywordAcceptanceFixture.KEYWORD_REQUEST; + +public class KeywordRecommendedPostStepDefinitions extends AcceptanceSteps { + + @Given("{int}번 키워드에 대해 추천 포스트 {string}를 작성하고") + @When("{int}번 키워드에 대해 추천 포스트 {string}를 작성하면") + public void 추천_포스트를_추가하면(int keywordId, String url) { + context.invokeHttpPost( + "/keywords/"+keywordId+"/recommended-posts", + new RecommendedRequest(url) + ); + } + + @When("{int}번 키워드에 대한 {int}번 추천 포스트를 {string}로 수정하면") + public void 추천_포스트를_수정하면(int keywordId, int recommendedId, String url) { + context.invokeHttpPut( + "/keywords/"+keywordId+"/recommended-posts/"+recommendedId, + new RecommendedRequest(url)); + } + + @When("{int}번 키워드에 대한 {int}번 추천 포스트를 삭제하면") + public void 추천_포스트를_삭제하면(int keywordId, int recommendedId) { + context.invokeHttpDelete( + "/keywords/" + keywordId + "/recommended-posts/" + recommendedId + ); + } + + @Then("추천 포스트가 생성된다") + public void 추천_포스트가_생성된다() { + int statusCode = context.response.statusCode(); + + assertThat(statusCode).isEqualTo(HttpStatus.CREATED.value()); + } + + @Then("추천 포스트가 수정된다") + public void 추천_포스트가_수정된다() { + int statusCode = context.response.statusCode(); + + assertThat(statusCode).isEqualTo(HttpStatus.OK.value()); + } + + @Then("추천 포스트가 삭제된다") + public void 추천_포스트가_삭제된다() { + int statusCode = context.response.statusCode(); + + assertThat(statusCode).isEqualTo(HttpStatus.NO_CONTENT.value()); + } +} diff --git a/backend/src/acceptanceTest/java/wooteco/prolog/steps/KeywordStepDefinitions.java b/backend/src/acceptanceTest/java/wooteco/prolog/steps/KeywordStepDefinitions.java index 4aff5b7f7..d6d5ef276 100644 --- a/backend/src/acceptanceTest/java/wooteco/prolog/steps/KeywordStepDefinitions.java +++ b/backend/src/acceptanceTest/java/wooteco/prolog/steps/KeywordStepDefinitions.java @@ -1,8 +1,5 @@ package wooteco.prolog.steps; -import static org.assertj.core.api.Assertions.assertThat; -import static wooteco.prolog.fixtures.KeywordAcceptanceFixture.KEYWORD_REQUEST; - import io.cucumber.java.en.Given; import io.cucumber.java.en.Then; import io.cucumber.java.en.When; @@ -10,6 +7,9 @@ import wooteco.prolog.AcceptanceSteps; import wooteco.prolog.session.application.dto.SessionRequest; +import static org.assertj.core.api.Assertions.assertThat; +import static wooteco.prolog.fixtures.KeywordAcceptanceFixture.KEYWORD_REQUEST; + public class KeywordStepDefinitions extends AcceptanceSteps { /** diff --git a/backend/src/acceptanceTest/resources/wooteco/prolog/keyword-recommended-post.feature b/backend/src/acceptanceTest/resources/wooteco/prolog/keyword-recommended-post.feature new file mode 100644 index 000000000..9be2ee3e6 --- /dev/null +++ b/backend/src/acceptanceTest/resources/wooteco/prolog/keyword-recommended-post.feature @@ -0,0 +1,20 @@ +@api +Feature: 로드맵 키워드 추천 포스트 관련 기능 + + Background: 사전 작업 + Given "2022 백엔드 레벨1" 세션을 생성하고 - 1번 세션 + And 1번 세션에 "자바"라는 키워드를 순서 1, 중요도 2로 작성하고 + + Scenario: 키워드 추천 포스트 생성하기 + When 1번 키워드에 대해 추천 포스트 "https://javajavajava"를 작성하면 + Then 추천 포스트가 생성된다 + + Scenario: 키워드 추천 포스트 수정하기 + Given 1번 키워드에 대해 추천 포스트 "https://javajavajava"를 작성하고 + When 1번 키워드에 대한 1번 추천 포스트를 "https://java2java2"로 수정하면 + Then 추천 포스트가 수정된다 + + Scenario: 키워드 추천 포스트 삭제하기 + Given 1번 키워드에 대해 추천 포스트 "https://javajavajava"를 작성하고 + When 1번 키워드에 대한 1번 추천 포스트를 삭제하면 + Then 추천 포스트가 삭제된다 diff --git a/backend/src/documentation/java/wooteco/prolog/docu/KeywordDocumentation.java b/backend/src/documentation/java/wooteco/prolog/docu/KeywordDocumentation.java index dd51f1199..f98318924 100644 --- a/backend/src/documentation/java/wooteco/prolog/docu/KeywordDocumentation.java +++ b/backend/src/documentation/java/wooteco/prolog/docu/KeywordDocumentation.java @@ -1,12 +1,5 @@ package wooteco.prolog.docu; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.doNothing; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; - -import java.util.Arrays; -import java.util.HashSet; import org.elasticsearch.common.collect.List; import org.junit.jupiter.api.Test; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -21,6 +14,14 @@ import wooteco.prolog.roadmap.application.dto.KeywordsResponse; import wooteco.prolog.roadmap.ui.KeywordController; +import java.util.Arrays; +import java.util.HashSet; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; + @WebMvcTest(controllers = KeywordController.class) public class KeywordDocumentation extends NewDocumentation { @@ -108,6 +109,7 @@ public class KeywordDocumentation extends NewDocumentation { 1, 1, null, + null, null ); @@ -133,6 +135,7 @@ public class KeywordDocumentation extends NewDocumentation { 1, 1, null, + null, new HashSet<>( Arrays.asList( new KeywordResponse( @@ -142,6 +145,7 @@ public class KeywordDocumentation extends NewDocumentation { 1, 1, 1L, + null, null ), new KeywordResponse( @@ -151,6 +155,7 @@ public class KeywordDocumentation extends NewDocumentation { 2, 1, 1L, + null, null )) ) diff --git a/backend/src/main/java/wooteco/prolog/common/exception/BadRequestCode.java b/backend/src/main/java/wooteco/prolog/common/exception/BadRequestCode.java index 96b2d71f7..430ab9861 100644 --- a/backend/src/main/java/wooteco/prolog/common/exception/BadRequestCode.java +++ b/backend/src/main/java/wooteco/prolog/common/exception/BadRequestCode.java @@ -2,6 +2,7 @@ import lombok.AllArgsConstructor; import lombok.Getter; +import wooteco.prolog.roadmap.domain.RecommendedPost; import wooteco.prolog.session.domain.Mission; import wooteco.prolog.session.domain.Session; import wooteco.prolog.studylog.domain.TagName; @@ -70,6 +71,10 @@ public enum BadRequestCode { NOT_EMPTY_ESSAY_ANSWER_EXCEPTION(8013, "답변은 공백일 수 없습니다."), ESSAY_ANSWER_NOT_VALID_USER(8014, "본인이 작성한 답변만 수정할 수 있습니다."), + ROADMAP_RECOMMENDED_POST_NOT_FOUND(8101, "해당 추천 포스트가 존재하지 않습니다."), + ROADMAP_RECOMMENDED_POST_INVALID_URL_LENGTH(8102, String.format( + "해당 추천 포스트의 URL 길이는 1 ~ %d여야 합니다.", RecommendedPost.URL_LENGTH_UPPER_BOUND)), + FILE_NAME_EMPTY_EXCEPTION(9001, "파일 이름이 존재하지 않습니다."), UNSUPPORTED_FILE_EXTENSION_EXCEPTION(9002, "지원하지 않는 파일 확장자입니다."), FILE_UPLOAD_FAIL_EXCEPTION(9003, "파일 업로드에 실패했습니다."), diff --git a/backend/src/main/java/wooteco/prolog/roadmap/application/KeywordService.java b/backend/src/main/java/wooteco/prolog/roadmap/application/KeywordService.java index 5d0578e48..87ea445b4 100644 --- a/backend/src/main/java/wooteco/prolog/roadmap/application/KeywordService.java +++ b/backend/src/main/java/wooteco/prolog/roadmap/application/KeywordService.java @@ -1,9 +1,5 @@ package wooteco.prolog.roadmap.application; -import static wooteco.prolog.common.exception.BadRequestCode.ROADMAP_KEYWORD_NOT_FOUND_EXCEPTION; -import static wooteco.prolog.common.exception.BadRequestCode.ROADMAP_SESSION_NOT_FOUND_EXCEPTION; - -import java.util.List; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import wooteco.prolog.common.exception.BadRequestException; @@ -15,6 +11,11 @@ import wooteco.prolog.roadmap.domain.repository.KeywordRepository; import wooteco.prolog.session.domain.repository.SessionRepository; +import java.util.List; + +import static wooteco.prolog.common.exception.BadRequestCode.ROADMAP_KEYWORD_NOT_FOUND_EXCEPTION; +import static wooteco.prolog.common.exception.BadRequestCode.ROADMAP_SESSION_NOT_FOUND_EXCEPTION; + @Transactional @Service public class KeywordService { @@ -55,7 +56,7 @@ public KeywordResponse findKeywordWithAllChild(final Long sessionId, final Long existSession(sessionId); existKeyword(keywordId); - Keyword keyword = keywordRepository.findFetchById(keywordId); + Keyword keyword = keywordRepository.findFetchByIdOrderBySeq(keywordId); return KeywordResponse.createWithAllChildResponse(keyword); } @@ -82,7 +83,7 @@ public void updateKeyword(final Long sessionId, final Long keywordId, public void deleteKeyword(final Long sessionId, final Long keywordId) { existSession(sessionId); - Keyword keyword = keywordRepository.findFetchById(keywordId); + Keyword keyword = keywordRepository.findFetchByIdOrderBySeq(keywordId); keywordRepository.delete(keyword); } diff --git a/backend/src/main/java/wooteco/prolog/roadmap/application/RecommendedPostService.java b/backend/src/main/java/wooteco/prolog/roadmap/application/RecommendedPostService.java new file mode 100644 index 000000000..6d3c00cc1 --- /dev/null +++ b/backend/src/main/java/wooteco/prolog/roadmap/application/RecommendedPostService.java @@ -0,0 +1,59 @@ +package wooteco.prolog.roadmap.application; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import wooteco.prolog.common.exception.BadRequestException; +import wooteco.prolog.roadmap.application.dto.RecommendedRequest; +import wooteco.prolog.roadmap.application.dto.RecommendedUpdateRequest; +import wooteco.prolog.roadmap.domain.Keyword; +import wooteco.prolog.roadmap.domain.RecommendedPost; +import wooteco.prolog.roadmap.domain.repository.KeywordRepository; +import wooteco.prolog.roadmap.domain.repository.RecommendedPostRepository; + +import static wooteco.prolog.common.exception.BadRequestCode.ROADMAP_KEYWORD_NOT_FOUND_EXCEPTION; +import static wooteco.prolog.common.exception.BadRequestCode.ROADMAP_RECOMMENDED_POST_NOT_FOUND; + +@Transactional(readOnly = true) +@Service +public class RecommendedPostService { + + private final RecommendedPostRepository recommendedPostRepository; + private final KeywordRepository keywordRepository; + + public RecommendedPostService(final RecommendedPostRepository recommendedPostRepository, + final KeywordRepository keywordRepository) { + this.recommendedPostRepository = recommendedPostRepository; + this.keywordRepository = keywordRepository; + } + + @Transactional + public Long create(final Long keywordId, final RecommendedRequest request) { + final Keyword keyword = findKeywordOrThrow(keywordId); + final RecommendedPost post = new RecommendedPost(request.getUrl(), keyword); + + return recommendedPostRepository.save(post).getId(); + } + + private Keyword findKeywordOrThrow(final Long keywordId) { + return keywordRepository.findById(keywordId) + .orElseThrow(() -> new BadRequestException(ROADMAP_KEYWORD_NOT_FOUND_EXCEPTION)); + } + + @Transactional + public void update(final Long recommendedId, final RecommendedUpdateRequest request) { + final RecommendedPost post = findPostOrThrow(recommendedId); + + post.updateUrl(request.getUrl()); + } + + private RecommendedPost findPostOrThrow(final Long recommendedId) { + return recommendedPostRepository.findById(recommendedId) + .orElseThrow(() -> new BadRequestException(ROADMAP_RECOMMENDED_POST_NOT_FOUND)); + } + + @Transactional + public void delete(final Long recommendedId) { + final RecommendedPost recommendedPost = findPostOrThrow(recommendedId); + recommendedPost.remove(); + } +} diff --git a/backend/src/main/java/wooteco/prolog/roadmap/application/dto/KeywordResponse.java b/backend/src/main/java/wooteco/prolog/roadmap/application/dto/KeywordResponse.java index 68069a1d9..0e8d105fc 100644 --- a/backend/src/main/java/wooteco/prolog/roadmap/application/dto/KeywordResponse.java +++ b/backend/src/main/java/wooteco/prolog/roadmap/application/dto/KeywordResponse.java @@ -1,12 +1,15 @@ package wooteco.prolog.roadmap.application.dto; -import java.util.HashSet; -import java.util.Set; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import wooteco.prolog.roadmap.domain.Keyword; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class KeywordResponse { @@ -17,11 +20,13 @@ public class KeywordResponse { private int order; private int importance; private Long parentKeywordId; + private List recommendedPosts; private Set childrenKeywords; public KeywordResponse(final Long keywordId, final String name, final String description, final int order, final int importance, final Long parentKeywordId, + final List recommendedPosts, final Set childrenKeywords) { this.keywordId = keywordId; this.name = name; @@ -29,6 +34,7 @@ public KeywordResponse(final Long keywordId, final String name, final String des this.order = order; this.importance = importance; this.parentKeywordId = parentKeywordId; + this.recommendedPosts = recommendedPosts; this.childrenKeywords = childrenKeywords; } @@ -40,9 +46,16 @@ public static KeywordResponse createResponse(final Keyword keyword) { keyword.getSeq(), keyword.getImportance(), keyword.getParentIdOrNull(), + createRecommendedPostResponses(keyword), null); } + private static List createRecommendedPostResponses(final Keyword keyword) { + return keyword.getRecommendedPosts().stream() + .map(RecommendedPostResponse::from) + .collect(Collectors.toList()); + } + public static KeywordResponse createWithAllChildResponse(final Keyword keyword) { return new KeywordResponse( keyword.getId(), @@ -51,6 +64,7 @@ public static KeywordResponse createWithAllChildResponse(final Keyword keyword) keyword.getSeq(), keyword.getImportance(), keyword.getParentIdOrNull(), + createRecommendedPostResponses(keyword), createKeywordChild(keyword.getChildren())); } diff --git a/backend/src/main/java/wooteco/prolog/roadmap/application/dto/RecommendedPostResponse.java b/backend/src/main/java/wooteco/prolog/roadmap/application/dto/RecommendedPostResponse.java new file mode 100644 index 000000000..75816135e --- /dev/null +++ b/backend/src/main/java/wooteco/prolog/roadmap/application/dto/RecommendedPostResponse.java @@ -0,0 +1,17 @@ +package wooteco.prolog.roadmap.application.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import wooteco.prolog.roadmap.domain.RecommendedPost; + +@AllArgsConstructor +@Getter +public class RecommendedPostResponse { + + private final Long id; + private final String url; + + public static RecommendedPostResponse from(final RecommendedPost recommendedPost) { + return new RecommendedPostResponse(recommendedPost.getId(), recommendedPost.getUrl()); + } +} diff --git a/backend/src/main/java/wooteco/prolog/roadmap/application/dto/RecommendedRequest.java b/backend/src/main/java/wooteco/prolog/roadmap/application/dto/RecommendedRequest.java new file mode 100644 index 000000000..a515dfb65 --- /dev/null +++ b/backend/src/main/java/wooteco/prolog/roadmap/application/dto/RecommendedRequest.java @@ -0,0 +1,14 @@ +package wooteco.prolog.roadmap.application.dto; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class RecommendedRequest { + + private String url; +} diff --git a/backend/src/main/java/wooteco/prolog/roadmap/application/dto/RecommendedUpdateRequest.java b/backend/src/main/java/wooteco/prolog/roadmap/application/dto/RecommendedUpdateRequest.java new file mode 100644 index 000000000..44b2db78f --- /dev/null +++ b/backend/src/main/java/wooteco/prolog/roadmap/application/dto/RecommendedUpdateRequest.java @@ -0,0 +1,14 @@ +package wooteco.prolog.roadmap.application.dto; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class RecommendedUpdateRequest { + + private String url; +} diff --git a/backend/src/main/java/wooteco/prolog/roadmap/domain/Keyword.java b/backend/src/main/java/wooteco/prolog/roadmap/domain/Keyword.java index e4ffb3222..9fc136b14 100644 --- a/backend/src/main/java/wooteco/prolog/roadmap/domain/Keyword.java +++ b/backend/src/main/java/wooteco/prolog/roadmap/domain/Keyword.java @@ -1,23 +1,6 @@ package wooteco.prolog.roadmap.domain; -import static wooteco.prolog.common.exception.BadRequestCode.ROADMAP_KEYWORD_AND_KEYWORD_PARENT_SAME_EXCEPTION; -import static wooteco.prolog.common.exception.BadRequestCode.ROADMAP_KEYWORD_SEQUENCE_EXCEPTION; - -import java.util.HashSet; -import java.util.Objects; -import java.util.Set; -import javax.persistence.CascadeType; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.FetchType; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.JoinColumn; -import javax.persistence.ManyToOne; -import javax.persistence.OneToMany; - import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -26,21 +9,11 @@ import javax.persistence.*; import java.util.HashSet; -import java.util.List; -import java.util.Objects; -import java.util.Set; - -import javax.persistence.*; -import java.util.HashSet; -import java.util.List; import java.util.Objects; import java.util.Set; -import javax.persistence.*; -import java.util.HashSet; -import java.util.List; -import java.util.Objects; -import java.util.Set; +import static wooteco.prolog.common.exception.BadRequestCode.ROADMAP_KEYWORD_AND_KEYWORD_PARENT_SAME_EXCEPTION; +import static wooteco.prolog.common.exception.BadRequestCode.ROADMAP_KEYWORD_SEQUENCE_EXCEPTION; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -65,10 +38,8 @@ public class Keyword { @Column(name = "session_id", nullable = false) private Long sessionId; - @ElementCollection - @CollectionTable(name = "keyword_reference") - @Column(name = "url") - private List references; + @OneToMany(mappedBy = "keyword", cascade = CascadeType.ALL, orphanRemoval = true) + private Set recommendedPosts = new HashSet<>(); @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parent_id") diff --git a/backend/src/main/java/wooteco/prolog/roadmap/domain/RecommendedPost.java b/backend/src/main/java/wooteco/prolog/roadmap/domain/RecommendedPost.java new file mode 100644 index 000000000..727dca236 --- /dev/null +++ b/backend/src/main/java/wooteco/prolog/roadmap/domain/RecommendedPost.java @@ -0,0 +1,87 @@ +package wooteco.prolog.roadmap.domain; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import wooteco.prolog.common.exception.BadRequestException; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import java.util.Objects; + +import static io.micrometer.core.instrument.util.StringUtils.isBlank; +import static java.util.Objects.hash; +import static java.util.Objects.isNull; +import static wooteco.prolog.common.exception.BadRequestCode.ROADMAP_KEYWORD_NOT_FOUND_EXCEPTION; +import static wooteco.prolog.common.exception.BadRequestCode.ROADMAP_RECOMMENDED_POST_INVALID_URL_LENGTH; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class RecommendedPost { + + public static final int URL_LENGTH_UPPER_BOUND = 512; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String url; + + @ManyToOne + @JoinColumn(nullable = false) + private Keyword keyword; + + public RecommendedPost(final Long id, final String url, final Keyword keyword) { + validate(url, keyword); + + this.id = id; + this.url = url.trim(); + this.keyword = keyword; + } + + private void validate(final String url, final Keyword keyword) { + if (isNull(keyword)) { + throw new BadRequestException(ROADMAP_KEYWORD_NOT_FOUND_EXCEPTION); + } + if (isBlank(url) || url.trim().length() > URL_LENGTH_UPPER_BOUND) { + throw new BadRequestException(ROADMAP_RECOMMENDED_POST_INVALID_URL_LENGTH); + } + } + + public RecommendedPost(final String url, final Keyword keyword) { + this(null, url, keyword); + } + + public void updateUrl(final String url) { + this.url = url; + } + + public void remove() { + keyword.getRecommendedPosts().remove(this); + } + + public void addKeyword(final Keyword keyword) { + this.keyword = keyword; + keyword.getRecommendedPosts().add(this); + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (!(o instanceof RecommendedPost)) return false; + final RecommendedPost post = (RecommendedPost) o; + return Objects.equals(id, post.id); + } + + @Override + public int hashCode() { + return hash(id); + } +} diff --git a/backend/src/main/java/wooteco/prolog/roadmap/domain/repository/KeywordRepository.java b/backend/src/main/java/wooteco/prolog/roadmap/domain/repository/KeywordRepository.java index d15d4e7ae..fc0237937 100644 --- a/backend/src/main/java/wooteco/prolog/roadmap/domain/repository/KeywordRepository.java +++ b/backend/src/main/java/wooteco/prolog/roadmap/domain/repository/KeywordRepository.java @@ -1,21 +1,39 @@ package wooteco.prolog.roadmap.domain.repository; -import java.util.List; -import java.util.Set; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import wooteco.prolog.roadmap.domain.Keyword; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.springframework.data.jpa.repository.EntityGraph.EntityGraphType.FETCH; + public interface KeywordRepository extends JpaRepository { + @EntityGraph(attributePaths = "recommendedPosts", type = FETCH) + Optional findById(final Long id); + + @EntityGraph(attributePaths = "recommendedPosts", type = FETCH) + List findAll(); + @Query("SELECT k FROM Keyword k " + "LEFT JOIN FETCH k.children c " + + "LEFT JOIN FETCH k.recommendedPosts " + "LEFT JOIN FETCH k.parent p " - + "LEFT JOIN FETCH c.children lc WHERE k.id = :keywordId ORDER BY k.seq") - Keyword findFetchById(@Param("keywordId") Long keywordId); + + "LEFT JOIN FETCH p.recommendedPosts " + + "LEFT JOIN FETCH c.recommendedPosts " + + "LEFT JOIN FETCH c.children lc " + + "LEFT JOIN FETCH lc.recommendedPosts " + + "LEFT JOIN FETCH lc.children " + + "WHERE k.id = :keywordId ORDER BY k.seq") + Keyword findFetchByIdOrderBySeq(@Param("keywordId") Long keywordId); @Query("SELECT k FROM Keyword k " + + "LEFT JOIN FETCH k.recommendedPosts " + "WHERE k.sessionId = :sessionId AND k.parent IS NULL") List findBySessionIdAndParentIsNull(@Param("sessionId") Long sessionId); diff --git a/backend/src/main/java/wooteco/prolog/roadmap/domain/repository/RecommendedPostRepository.java b/backend/src/main/java/wooteco/prolog/roadmap/domain/repository/RecommendedPostRepository.java new file mode 100644 index 000000000..44540d069 --- /dev/null +++ b/backend/src/main/java/wooteco/prolog/roadmap/domain/repository/RecommendedPostRepository.java @@ -0,0 +1,7 @@ +package wooteco.prolog.roadmap.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import wooteco.prolog.roadmap.domain.RecommendedPost; + +public interface RecommendedPostRepository extends JpaRepository { +} diff --git a/backend/src/main/java/wooteco/prolog/roadmap/ui/RecommendedController.java b/backend/src/main/java/wooteco/prolog/roadmap/ui/RecommendedController.java new file mode 100644 index 000000000..aa83c816d --- /dev/null +++ b/backend/src/main/java/wooteco/prolog/roadmap/ui/RecommendedController.java @@ -0,0 +1,47 @@ +package wooteco.prolog.roadmap.ui; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import wooteco.prolog.roadmap.application.RecommendedPostService; +import wooteco.prolog.roadmap.application.dto.RecommendedRequest; +import wooteco.prolog.roadmap.application.dto.RecommendedUpdateRequest; + +@RestController +@RequestMapping("/keywords/{keywordId}/recommended-posts") +public class RecommendedController { + + private final RecommendedPostService recommendedPostService; + + public RecommendedController(final RecommendedPostService recommendedPostService) { + this.recommendedPostService = recommendedPostService; + } + + @PostMapping + public ResponseEntity createRecommendedPost(@PathVariable("keywordId") final Long keywordId, + @RequestBody final RecommendedRequest request) { + recommendedPostService.create(keywordId, request); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PutMapping("/{recommendedId}") + public ResponseEntity updateRecommendedPost(@PathVariable("keywordId") final Long keywordId, + @PathVariable("recommendedId") final Long recommendedId, + @RequestBody final RecommendedUpdateRequest request) { + recommendedPostService.update(recommendedId, request); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{recommendedId}") + public ResponseEntity deleteRecommendedPost(@PathVariable("keywordId") final Long keywordId, + @PathVariable("recommendedId") final Long recommendedId) { + recommendedPostService.delete(recommendedId); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/resources/db/migration/prod/V4__alter_table_keyword_reference.sql b/backend/src/main/resources/db/migration/prod/V4__alter_table_keyword_reference.sql new file mode 100644 index 000000000..99090b659 --- /dev/null +++ b/backend/src/main/resources/db/migration/prod/V4__alter_table_keyword_reference.sql @@ -0,0 +1,10 @@ +drop table prolog.keyword_reference; + +create table if not exists prolog.recommended_post +( + id bigint auto_increment primary key, + url varchar(512) not null, + keyword_id bigint not null, + constraint FK_RECOMMENDED_POST_PARENT_KEYWORD_ID + foreign key (keyword_id) references prolog.keyword (id) +); diff --git a/backend/src/test/java/wooteco/prolog/roadmap/application/KeywordServiceTest.java b/backend/src/test/java/wooteco/prolog/roadmap/application/KeywordServiceTest.java index 36477238a..2bb5f86ec 100644 --- a/backend/src/test/java/wooteco/prolog/roadmap/application/KeywordServiceTest.java +++ b/backend/src/test/java/wooteco/prolog/roadmap/application/KeywordServiceTest.java @@ -1,14 +1,5 @@ package wooteco.prolog.roadmap.application; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static wooteco.prolog.common.exception.BadRequestCode.ROADMAP_KEYWORD_NOT_FOUND_EXCEPTION; - -import java.util.Collections; -import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -22,6 +13,13 @@ import wooteco.prolog.roadmap.domain.repository.KeywordRepository; import wooteco.prolog.session.domain.repository.SessionRepository; +import java.util.Collections; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; +import static wooteco.prolog.common.exception.BadRequestCode.ROADMAP_KEYWORD_NOT_FOUND_EXCEPTION; + @ExtendWith(MockitoExtension.class) class KeywordServiceTest { @@ -105,13 +103,13 @@ void findKeywordWithAllChild() { when(keywordRepository.existsById(any())).thenReturn(true); Keyword keyword = new Keyword(1L, "", "", 1, 1, 1L, null, Collections.emptySet()); - when(keywordRepository.findFetchById(1L)).thenReturn(keyword); + when(keywordRepository.findFetchByIdOrderBySeq(1L)).thenReturn(keyword); //when keywordService.findKeywordWithAllChild(1L, 1L); //then - verify(keywordRepository, times(1)).findFetchById(any()); + verify(keywordRepository, times(1)).findFetchByIdOrderBySeq(any()); } @DisplayName("sessionId로 최상위 키워드들을 찾을 수 있다") @@ -178,7 +176,7 @@ void deleteKeyword() { keywordService.deleteKeyword(1L, 1L); //then - verify(keywordRepository, times(1)).findFetchById(any()); + verify(keywordRepository, times(1)).findFetchByIdOrderBySeq(any()); verify(keywordRepository, times(1)).delete(any()); } } diff --git a/backend/src/test/java/wooteco/prolog/roadmap/application/RecommendedPostServiceTest.java b/backend/src/test/java/wooteco/prolog/roadmap/application/RecommendedPostServiceTest.java new file mode 100644 index 000000000..5f3932c58 --- /dev/null +++ b/backend/src/test/java/wooteco/prolog/roadmap/application/RecommendedPostServiceTest.java @@ -0,0 +1,99 @@ +package wooteco.prolog.roadmap.application; + +import org.junit.jupiter.api.AfterEach; +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 wooteco.prolog.common.DataInitializer; +import wooteco.prolog.roadmap.application.dto.RecommendedRequest; +import wooteco.prolog.roadmap.application.dto.RecommendedUpdateRequest; +import wooteco.prolog.roadmap.domain.Keyword; +import wooteco.prolog.roadmap.domain.RecommendedPost; +import wooteco.prolog.roadmap.domain.repository.KeywordRepository; +import wooteco.prolog.roadmap.domain.repository.RecommendedPostRepository; +import wooteco.prolog.session.domain.Session; +import wooteco.prolog.session.domain.repository.SessionRepository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +@SpringBootTest +class RecommendedPostServiceTest { + + @Autowired + private RecommendedPostService recommendedPostService; + @Autowired + private RecommendedPostRepository recommendedPostRepository; + @Autowired + private KeywordRepository keywordRepository; + @Autowired + private SessionRepository sessionRepository; + + @Autowired + private DataInitializer dataInitializer; + + private Keyword keyword; + + @BeforeEach + public void init() { + final Session session = sessionRepository.save(new Session("레벨 1")); + this.keyword = keywordRepository.save(Keyword.createKeyword("이름", "설명", 1, 1, session.getId(), null)); + } + + @AfterEach + public void removeAll() { + dataInitializer.execute(); + } + + @Test + @DisplayName("추천 포스트 생성 테스트") + void create() { + //given + final RecommendedRequest request = new RecommendedRequest("https://example.com"); + + //when + recommendedPostService.create(keyword.getId(), request); + + //then + assertThat(recommendedPostRepository.findAll()).hasSize(1); + } + + @Test + @DisplayName("추천 포스트 수정 테스트") + void update() { + //given + final Long recommendedPostId = recommendedPostService.create( + keyword.getId(), + new RecommendedRequest("https://example.com")); + + final String newUrl = "https://new.com"; + final RecommendedUpdateRequest updateRequest = new RecommendedUpdateRequest(newUrl); + + //when + recommendedPostService.update(recommendedPostId, updateRequest); + + //then + final RecommendedPost post = recommendedPostRepository.findById(recommendedPostId).get(); + assertThat(post.getUrl()).isEqualTo(newUrl); + } + + @Test + @DisplayName("추천 포스트 삭제 테스트") + void delete() { + //given + final RecommendedRequest request = new RecommendedRequest("https://example.com"); + final Long recommendedPostId = recommendedPostService.create(keyword.getId(), request); + + //when + recommendedPostService.delete(recommendedPostId); + + //then + assertSoftly(softAssertions -> { + assertThat(recommendedPostRepository.findAll()).hasSize(0); + assertThat(keywordRepository.findById(keyword.getId()).get().getRecommendedPosts()) + .isEmpty(); + }); + } +} diff --git a/backend/src/test/java/wooteco/prolog/roadmap/domain/RecommendedPostTest.java b/backend/src/test/java/wooteco/prolog/roadmap/domain/RecommendedPostTest.java new file mode 100644 index 000000000..b9c234d9b --- /dev/null +++ b/backend/src/test/java/wooteco/prolog/roadmap/domain/RecommendedPostTest.java @@ -0,0 +1,106 @@ +package wooteco.prolog.roadmap.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import wooteco.prolog.common.exception.BadRequestException; + +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static wooteco.prolog.common.exception.BadRequestCode.ROADMAP_KEYWORD_NOT_FOUND_EXCEPTION; +import static wooteco.prolog.common.exception.BadRequestCode.ROADMAP_RECOMMENDED_POST_INVALID_URL_LENGTH; + +class RecommendedPostTest { + + @Test + @DisplayName("추천 포스트 생성 시 키워드가 null이면 예외가 발생한다") + void construct_fail1() { + assertThatThrownBy(() -> new RecommendedPost("https://example.com", null)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ROADMAP_KEYWORD_NOT_FOUND_EXCEPTION.getMessage()); + } + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("추천 포스트 생성 시 url이 null이면 예외가 발생한다") + void construct_fail2(final String url) { + assertThatThrownBy(() -> new RecommendedPost(url, null)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ROADMAP_KEYWORD_NOT_FOUND_EXCEPTION.getMessage()); + } + + @Test + @DisplayName("추천 포스트 생성 시 url의 길이가 공백 제외 0이면 예외가 발생한다") + void construct_fail3() { + //given + final String url = " "; + + //when, then + assertThatThrownBy(() -> new RecommendedPost(url, null)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ROADMAP_KEYWORD_NOT_FOUND_EXCEPTION.getMessage()); + } + + @Test + @DisplayName("추천 포스트 생성 시 url의 길이가 공백 제외 512보다 크면 예외가 발생한다") + void construct_fail4() { + //given + final Keyword keyword = Keyword.createKeyword("name", "description", 1, 1, 1L, null); + final String url = Stream.generate(() -> "a") + .limit(513) + .collect(Collectors.joining()); + + //when, then + assertThatThrownBy(() -> new RecommendedPost(url, keyword)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ROADMAP_RECOMMENDED_POST_INVALID_URL_LENGTH.getMessage()); + } + + @Test + @DisplayName("추천 포스트 생성 테스트") + void construct() { + //given + final Keyword keyword = Keyword.createKeyword("name", "description", 1, 1, 1L, null); + final String url = "http://www.salmon"; + + //when, then + assertDoesNotThrow(() -> new RecommendedPost(url, keyword)); + } + + @Test + @DisplayName("삭제 기능 테스트") + void remove() { + //given + final Keyword keyword = Keyword.createKeyword("이름", "설명", 1, 1, 1L, null); + final RecommendedPost recommendedPost = new RecommendedPost("https://example.com", keyword); + + //when + recommendedPost.remove(); + + //then + assertThat(keyword.getRecommendedPosts()).isEmpty(); + } + + @Test + @DisplayName("소속 키워드를 추가한다") + void addKeyword() { + //given + final Keyword keyword = Keyword.createKeyword("name", "description", 1, 1, 1L, null); + final RecommendedPost post = new RecommendedPost("http://연어", keyword); + + //when + post.addKeyword(keyword); + + //then + assertSoftly(soft -> { + assertThat(post.getKeyword()).isEqualTo(keyword); + assertThat(keyword.getRecommendedPosts()).containsExactly(post); + }); + } +} diff --git a/backend/src/test/java/wooteco/prolog/roadmap/repository/KeywordRepositoryTest.java b/backend/src/test/java/wooteco/prolog/roadmap/repository/KeywordRepositoryTest.java index 70255a8ba..1b4236b27 100644 --- a/backend/src/test/java/wooteco/prolog/roadmap/repository/KeywordRepositoryTest.java +++ b/backend/src/test/java/wooteco/prolog/roadmap/repository/KeywordRepositoryTest.java @@ -47,7 +47,7 @@ class KeywordRepositoryTest { em.clear(); // when - Keyword extract = keywordRepository.findFetchById(keywordParentId); + Keyword extract = keywordRepository.findFetchByIdOrderBySeq(keywordParentId); // then assertAll( @@ -79,7 +79,7 @@ class KeywordRepositoryTest { em.clear(); // when - Keyword extract = keywordRepository.findFetchById(keywordParentId); + Keyword extract = keywordRepository.findFetchByIdOrderBySeq(keywordParentId); // then assertAll(