From e5e158770ed443066f3effd944dc1f8b10ae4fa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=8F=99=ED=98=B8?= <66300965+kdkdhoho@users.noreply.github.com> Date: Sun, 27 Oct 2024 21:44:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=EC=9A=A9=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=A3=BC=EC=A0=9C(=ED=86=A0=ED=94=BD)=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EC=88=98=EC=A0=95=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#307)=20(#320)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 관리자용 토픽 조회 API 구현 (#307) * feat: 관리자용 요청 주제(토픽) 수정 API 구현 (#307) * test: static import 추가 (#307) --- .../topic/application/domain/Topic.java | 6 + .../application/service/TopicService.java | 15 ++ .../service/dto/TopicFindResponse.java | 69 +++++++++ .../topic/presentation/TopicController.java | 19 +++ .../presentation/dto/TopicUpdateRequest.java | 8 ++ .../repository/CustomTopicRepository.java | 2 + .../repository/CustomTopicRepositoryImpl.java | 22 ++- .../topic/repository/TopicRepository.java | 7 + .../com/listywave/common/IntegrationTest.java | 4 +- .../application/service/TopicServiceTest.java | 133 +++++++++++++++++- 10 files changed, 275 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/listywave/topic/application/service/dto/TopicFindResponse.java create mode 100644 src/main/java/com/listywave/topic/presentation/dto/TopicUpdateRequest.java diff --git a/src/main/java/com/listywave/topic/application/domain/Topic.java b/src/main/java/com/listywave/topic/application/domain/Topic.java index bd2e0f40..e77fdb48 100644 --- a/src/main/java/com/listywave/topic/application/domain/Topic.java +++ b/src/main/java/com/listywave/topic/application/domain/Topic.java @@ -43,4 +43,10 @@ public class Topic extends BaseEntity { @Column(nullable = false) private boolean isExposed; + + public void update(boolean isExposed, CategoryType categoryType, String title) { + this.isExposed = isExposed; + this.category = categoryType; + this.title = new ListTitle(title); + } } diff --git a/src/main/java/com/listywave/topic/application/service/TopicService.java b/src/main/java/com/listywave/topic/application/service/TopicService.java index 72aaea35..68e287f1 100644 --- a/src/main/java/com/listywave/topic/application/service/TopicService.java +++ b/src/main/java/com/listywave/topic/application/service/TopicService.java @@ -1,8 +1,10 @@ package com.listywave.topic.application.service; +import com.listywave.list.application.domain.category.CategoryType; import com.listywave.topic.application.domain.Topic; import com.listywave.topic.application.service.dto.ExposedTopicFindResponse; import com.listywave.topic.application.service.dto.TopicCreateRequest; +import com.listywave.topic.application.service.dto.TopicFindResponse; import com.listywave.topic.repository.TopicRepository; import com.listywave.user.application.domain.User; import com.listywave.user.repository.user.UserRepository; @@ -26,8 +28,21 @@ public void create(TopicCreateRequest request, Long userId) { topicRepository.save(topic); } + @Transactional(readOnly = true) public ExposedTopicFindResponse findAllExposed(@Nullable Long cursorId, int size) { List result = topicRepository.findAllExposed(cursorId, size); return ExposedTopicFindResponse.of(result, size); } + + @Transactional(readOnly = true) + public TopicFindResponse findAll(@Nullable Long cursorId, int size) { + List result = topicRepository.findAll(cursorId, size); + long totalCount = (topicRepository.count() / size) + 1; + return TopicFindResponse.from(result, size, totalCount); + } + + public void update(Long topicId, boolean isExposed, String categoryCode, String title) { + Topic topic = topicRepository.getById(topicId); + topic.update(isExposed, CategoryType.codeOf(categoryCode), title); + } } diff --git a/src/main/java/com/listywave/topic/application/service/dto/TopicFindResponse.java b/src/main/java/com/listywave/topic/application/service/dto/TopicFindResponse.java new file mode 100644 index 00000000..c2ed981b --- /dev/null +++ b/src/main/java/com/listywave/topic/application/service/dto/TopicFindResponse.java @@ -0,0 +1,69 @@ +package com.listywave.topic.application.service.dto; + +import com.listywave.topic.application.domain.Topic; +import java.time.LocalDateTime; +import java.util.List; +import lombok.Builder; + +@Builder +public record TopicFindResponse( + boolean hasNext, + long totalCount, + Long cursorId, + List topics +) { + + public static TopicFindResponse from(List topics, int size, long totalCount) { + if (topics.isEmpty()) { + return new TopicFindResponse(false, totalCount, null, List.of()); + } + + boolean hasNext = false; + if (topics.size() > size) { + hasNext = true; + topics.remove(topics.size() - 1); + } + long cursorId = topics.get(topics.size() - 1).getId(); + + return TopicFindResponse.builder() + .hasNext(hasNext) + .totalCount(totalCount) + .cursorId(cursorId) + .topics(TopicDto.toList(topics)) + .build(); + } + + @Builder + public record TopicDto( + String categoryEngName, + String categoryKorName, + String title, + String description, + LocalDateTime createdDate, + Long ownerId, + String ownerNickname, + boolean isAnonymous, + boolean isExposed + ) { + + public static List toList(List topics) { + return topics.stream() + .map(TopicDto::of) + .toList(); + } + + private static TopicDto of(Topic topic) { + return TopicDto.builder() + .categoryEngName(topic.getCategory().name()) + .categoryKorName(topic.getCategory().getViewName()) + .title(topic.getTitle().getValue()) + .description(topic.getDescription().getValue()) + .createdDate(topic.getCreatedDate()) + .ownerId(topic.getUser().getId()) + .ownerNickname(topic.getUser().getNickname()) + .isAnonymous(topic.isAnonymous()) + .isExposed(topic.isExposed()) + .build(); + } + } +} diff --git a/src/main/java/com/listywave/topic/presentation/TopicController.java b/src/main/java/com/listywave/topic/presentation/TopicController.java index 11a70992..120a6005 100644 --- a/src/main/java/com/listywave/topic/presentation/TopicController.java +++ b/src/main/java/com/listywave/topic/presentation/TopicController.java @@ -4,10 +4,14 @@ import com.listywave.topic.application.service.TopicService; import com.listywave.topic.application.service.dto.ExposedTopicFindResponse; import com.listywave.topic.application.service.dto.TopicCreateRequest; +import com.listywave.topic.application.service.dto.TopicFindResponse; +import com.listywave.topic.presentation.dto.TopicUpdateRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +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.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -32,4 +36,19 @@ ResponseEntity findAllExposed( ExposedTopicFindResponse result = topicService.findAllExposed(cursorId, size); return ResponseEntity.ok(result); } + + @GetMapping("/admin/topics") + ResponseEntity findAll( + @RequestParam(required = false) Long cursorId, + @RequestParam(defaultValue = "5") int size + ) { + TopicFindResponse result = topicService.findAll(cursorId, size); + return ResponseEntity.ok(result); + } + + @PutMapping("/admin/topics/{topicId}") + ResponseEntity update(@PathVariable Long topicId, @RequestBody TopicUpdateRequest request) { + topicService.update(topicId, request.isExposed(), request.categoryCode(), request.title()); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/listywave/topic/presentation/dto/TopicUpdateRequest.java b/src/main/java/com/listywave/topic/presentation/dto/TopicUpdateRequest.java new file mode 100644 index 00000000..133a5763 --- /dev/null +++ b/src/main/java/com/listywave/topic/presentation/dto/TopicUpdateRequest.java @@ -0,0 +1,8 @@ +package com.listywave.topic.presentation.dto; + +public record TopicUpdateRequest( + boolean isExposed, + String categoryCode, + String title +) { +} diff --git a/src/main/java/com/listywave/topic/repository/CustomTopicRepository.java b/src/main/java/com/listywave/topic/repository/CustomTopicRepository.java index f0faaa0f..b1c8a43c 100644 --- a/src/main/java/com/listywave/topic/repository/CustomTopicRepository.java +++ b/src/main/java/com/listywave/topic/repository/CustomTopicRepository.java @@ -6,4 +6,6 @@ public interface CustomTopicRepository { List findAllExposed(Long cursorId, int size); + + List findAll(Long cursorId, int size); } diff --git a/src/main/java/com/listywave/topic/repository/CustomTopicRepositoryImpl.java b/src/main/java/com/listywave/topic/repository/CustomTopicRepositoryImpl.java index f0f200fa..70a6d57f 100644 --- a/src/main/java/com/listywave/topic/repository/CustomTopicRepositoryImpl.java +++ b/src/main/java/com/listywave/topic/repository/CustomTopicRepositoryImpl.java @@ -6,6 +6,7 @@ import com.listywave.topic.application.domain.Topic; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.annotation.Nullable; import java.util.List; import lombok.RequiredArgsConstructor; @@ -15,7 +16,7 @@ public class CustomTopicRepositoryImpl implements CustomTopicRepository { private final JPAQueryFactory queryFactory; @Override - public List findAllExposed(Long cursorId, int size) { + public List findAllExposed(@Nullable Long cursorId, int size) { return queryFactory .selectFrom(topic) .join(user).on(topic.user.id.eq(user.id)) @@ -28,7 +29,24 @@ public List findAllExposed(Long cursorId, int size) { .fetch(); } - private static BooleanExpression cursorIdLowerThan(Long cursorId) { + private BooleanExpression cursorIdLowerThan(Long cursorId) { return cursorId == null ? null : topic.id.lt(cursorId); } + + @Override + public List findAll(@Nullable Long cursorId, int size) { + return queryFactory + .selectFrom(topic) + .join(user).on(topic.user.id.eq(user.id)) + .where( + cursorIdGreaterThan(cursorId) + ) + .limit(size + 1) + .orderBy(topic.id.asc()) + .fetch(); + } + + private BooleanExpression cursorIdGreaterThan(Long cursorId) { + return cursorId == null ? null : topic.id.gt(cursorId); + } } diff --git a/src/main/java/com/listywave/topic/repository/TopicRepository.java b/src/main/java/com/listywave/topic/repository/TopicRepository.java index 0723b390..841aa91d 100644 --- a/src/main/java/com/listywave/topic/repository/TopicRepository.java +++ b/src/main/java/com/listywave/topic/repository/TopicRepository.java @@ -1,7 +1,14 @@ package com.listywave.topic.repository; +import static com.listywave.common.exception.ErrorCode.RESOURCE_NOT_FOUND; + +import com.listywave.common.exception.CustomException; import com.listywave.topic.application.domain.Topic; import org.springframework.data.jpa.repository.JpaRepository; public interface TopicRepository extends JpaRepository, CustomTopicRepository { + + default Topic getById(Long id) { + return findById(id).orElseThrow(() -> new CustomException(RESOURCE_NOT_FOUND)); + } } diff --git a/src/test/java/com/listywave/common/IntegrationTest.java b/src/test/java/com/listywave/common/IntegrationTest.java index 4e2cfa4e..f2f5076f 100644 --- a/src/test/java/com/listywave/common/IntegrationTest.java +++ b/src/test/java/com/listywave/common/IntegrationTest.java @@ -1,5 +1,6 @@ package com.listywave.common; +import static com.listywave.list.fixture.ListFixture.가장_좋아하는_견종_TOP3; import static com.listywave.user.fixture.UserFixture.동호; import static com.listywave.user.fixture.UserFixture.서영; import static com.listywave.user.fixture.UserFixture.유진; @@ -15,7 +16,6 @@ import com.listywave.list.application.domain.list.ListEntity; import com.listywave.list.application.service.CommentService; import com.listywave.list.application.service.ReplyService; -import com.listywave.list.fixture.ListFixture; import com.listywave.list.repository.ItemRepository; import com.listywave.list.repository.comment.CommentRepository; import com.listywave.list.repository.label.LabelRepository; @@ -103,7 +103,7 @@ void setUp() { js = userRepository.save(정수()); ej = userRepository.save(유진()); sy = userRepository.save(서영()); - list = listRepository.save(ListFixture.가장_좋아하는_견종_TOP3(dh, List.of())); + list = listRepository.save(가장_좋아하는_견종_TOP3(dh, List.of())); log.info("=============================테스트 데이터 셋 생성============================="); } } diff --git a/src/test/java/com/listywave/topic/application/service/TopicServiceTest.java b/src/test/java/com/listywave/topic/application/service/TopicServiceTest.java index 7cf2973d..41f72fd0 100644 --- a/src/test/java/com/listywave/topic/application/service/TopicServiceTest.java +++ b/src/test/java/com/listywave/topic/application/service/TopicServiceTest.java @@ -1,6 +1,7 @@ package com.listywave.topic.application.service; import static com.listywave.list.application.domain.category.CategoryType.DAILYLIFE_THOUGHTS; +import static com.listywave.list.application.domain.category.CategoryType.MOVIE_DRAMA; import static com.listywave.list.application.domain.category.CategoryType.MUSIC; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -12,6 +13,7 @@ import com.listywave.topic.application.service.dto.ExposedTopicFindResponse; import com.listywave.topic.application.service.dto.ExposedTopicFindResponse.TopicDto; import com.listywave.topic.application.service.dto.TopicCreateRequest; +import com.listywave.topic.application.service.dto.TopicFindResponse; import java.util.List; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -47,7 +49,7 @@ class 토픽_생성 { } @Nested - class 토픽_조회 { + class 사용자용_토픽_조회 { @Test void 노출이_승인된_토픽만_조회한다() { @@ -124,7 +126,7 @@ class 토픽_조회 { } @Test - void cursorId가_5이고_size가_5일_때_노출이_승인된_토픽을_조회한다() { + void cursorId가_뒤에서_다섯_번째고_size가_5일_때_노출이_승인된_토픽을_조회한다() { // given List topics = List.of( new Topic(dh, MUSIC, new ListTitle("1"), new ListDescription("1"), false, true), @@ -144,20 +146,139 @@ class 토픽_조회 { topicRepository.saveAll(topics); // when - ExposedTopicFindResponse result = topicService.findAllExposed(5L, 5); + long cursorId = topics.get(8).getId(); + ExposedTopicFindResponse result = topicService.findAllExposed(cursorId, 5); // then assertAll( - () -> assertThat(result.hasNext()).isFalse(), + () -> assertThat(result.hasNext()).isTrue(), () -> { List topicDtos = result.topics(); - assertThat(result.cursorId()).isEqualTo(topicDtos.get(topicDtos.size() - 1).id()); + assertThat(result.cursorId()).isEqualTo(topics.get(3).getId()); assertThat(topicDtos).extracting("title") - .isEqualTo(List.of("4", "3", "2", "1")); + .isEqualTo(List.of("8", "7", "6", "5", "4")); } ); } } + + @Nested + class 관리자용_토픽_조회 { + + @Test + void cursorId가_null이고_size가_10일_때_모든_토픽을_조회한다() { + // given + List topics = List.of( + new Topic(dh, MUSIC, new ListTitle("1"), new ListDescription("1"), false, true), // O + new Topic(dh, MUSIC, new ListTitle("2"), new ListDescription("2"), false, false), + new Topic(dh, MUSIC, new ListTitle("3"), new ListDescription("3"), false, true), // O + new Topic(dh, MUSIC, new ListTitle("4"), new ListDescription("4"), false, false), + new Topic(dh, MUSIC, new ListTitle("5"), new ListDescription("5"), false, true), // O + new Topic(dh, MUSIC, new ListTitle("6"), new ListDescription("6"), false, false), + new Topic(dh, MUSIC, new ListTitle("7"), new ListDescription("7"), false, true), // O + new Topic(dh, MUSIC, new ListTitle("8"), new ListDescription("8"), false, false), + new Topic(dh, MUSIC, new ListTitle("9"), new ListDescription("9"), false, true), // O + new Topic(dh, MUSIC, new ListTitle("10"), new ListDescription("10"), false, false), + new Topic(dh, MUSIC, new ListTitle("11"), new ListDescription("11"), false, true), // O + new Topic(dh, MUSIC, new ListTitle("12"), new ListDescription("12"), false, false), + new Topic(dh, MUSIC, new ListTitle("13"), new ListDescription("13"), false, true) // O + ); + topicRepository.saveAll(topics); + + // when + TopicFindResponse result = topicService.findAll(null, 10); + + // then + assertAll( + () -> assertThat(result.hasNext()).isTrue(), + () -> assertThat(result.totalCount()).isEqualTo(2), + () -> assertThat(result.topics()).extracting("title") + .isEqualTo(List.of("1", "2", "3", "4", "5", "6", "7", "8", "9", "10")) + ); + } + + @Test + void cursorId가_열_번째_ID이고_size가_10일_때_모든_토픽을_조회한다() { + // given + List topics = List.of( + new Topic(dh, MUSIC, new ListTitle("1"), new ListDescription("1"), false, true), // O + new Topic(dh, MUSIC, new ListTitle("2"), new ListDescription("2"), false, false), + new Topic(dh, MUSIC, new ListTitle("3"), new ListDescription("3"), false, true), // O + new Topic(dh, MUSIC, new ListTitle("4"), new ListDescription("4"), false, false), + new Topic(dh, MUSIC, new ListTitle("5"), new ListDescription("5"), false, true), // O + new Topic(dh, MUSIC, new ListTitle("6"), new ListDescription("6"), false, false), + new Topic(dh, MUSIC, new ListTitle("7"), new ListDescription("7"), false, true), // O + new Topic(dh, MUSIC, new ListTitle("8"), new ListDescription("8"), false, false), + new Topic(dh, MUSIC, new ListTitle("9"), new ListDescription("9"), false, true), // O + new Topic(dh, MUSIC, new ListTitle("10"), new ListDescription("10"), false, false), + new Topic(dh, MUSIC, new ListTitle("11"), new ListDescription("11"), false, true), // O + new Topic(dh, MUSIC, new ListTitle("12"), new ListDescription("12"), false, false), + new Topic(dh, MUSIC, new ListTitle("13"), new ListDescription("13"), false, true) // O + ); + topicRepository.saveAll(topics); + + // when + TopicFindResponse result = topicService.findAll(topics.get(9).getId(), 10); + + // then + assertAll( + () -> assertThat(result.hasNext()).isFalse(), + () -> assertThat(result.totalCount()).isEqualTo(2), + () -> assertThat(result.topics()).extracting("title") + .isEqualTo(List.of("11", "12", "13")) + ); + } + + @Test + void cursorId가_다섯_번째이고_size가_5일_때_모든_토픽을_조회한다() { + // given + List topics = List.of( + new Topic(dh, MUSIC, new ListTitle("1"), new ListDescription("1"), false, true), // O + new Topic(dh, MUSIC, new ListTitle("2"), new ListDescription("2"), false, false), + new Topic(dh, MUSIC, new ListTitle("3"), new ListDescription("3"), false, true), // O + new Topic(dh, MUSIC, new ListTitle("4"), new ListDescription("4"), false, false), + new Topic(dh, MUSIC, new ListTitle("5"), new ListDescription("5"), false, true), // O + new Topic(dh, MUSIC, new ListTitle("6"), new ListDescription("6"), false, false), + new Topic(dh, MUSIC, new ListTitle("7"), new ListDescription("7"), false, true), // O + new Topic(dh, MUSIC, new ListTitle("8"), new ListDescription("8"), false, false), + new Topic(dh, MUSIC, new ListTitle("9"), new ListDescription("9"), false, true), // O + new Topic(dh, MUSIC, new ListTitle("10"), new ListDescription("10"), false, false), + new Topic(dh, MUSIC, new ListTitle("11"), new ListDescription("11"), false, true), // O + new Topic(dh, MUSIC, new ListTitle("12"), new ListDescription("12"), false, false), + new Topic(dh, MUSIC, new ListTitle("13"), new ListDescription("13"), false, true) // O + ); + topicRepository.saveAll(topics); + + // when + TopicFindResponse result = topicService.findAll(topics.get(4).getId(), 5); + + // then + assertAll( + () -> assertThat(result.hasNext()).isTrue(), + () -> assertThat(result.totalCount()).isEqualTo(3), + () -> assertThat(result.topics()).extracting("title") + .isEqualTo(List.of("6", "7", "8", "9", "10")) + ); + } + } + + @Test + void 노출_여부와_타이틀과_카테고리_수정() { + // given + Topic topic = new Topic(dh, MUSIC, new ListTitle("origin"), new ListDescription("origin"), true, false); + topicRepository.save(topic); + + // when + topicService.update(topic.getId(), true, MOVIE_DRAMA.getCode(), "new"); + + // then + Topic result = topicRepository.getById(topic.getId()); + assertAll( + () -> assertThat(result.isExposed()).isTrue(), + () -> assertThat(result.getCategory()).isEqualTo(MOVIE_DRAMA), + () -> assertThat(result.getTitle()).isEqualTo(new ListTitle("new")) + ); + } }