From 8bfa9965fb203ef92f64f2617b7f21e0ed424f6c Mon Sep 17 00:00:00 2001 From: rootTiket Date: Mon, 1 Sep 2025 11:47:57 +0900 Subject: [PATCH 01/21] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EB=AA=A8=EB=93=88=20=EC=84=B8=ED=8C=85=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- settings.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/settings.gradle b/settings.gradle index b477cbe2..fa87b7b1 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,7 +1,6 @@ rootProject.name = 'pinback-server' include 'domain' -include 'core' include 'infrastructure' include 'shared' include 'application' From cfd76263abb67ffdc60b77e68b89bd55ff2d7f0a Mon Sep 17 00:00:00 2001 From: rootTiket Date: Mon, 1 Sep 2025 14:43:28 +0900 Subject: [PATCH 02/21] =?UTF-8?q?refactor:=20=ED=98=84=EC=9E=AC=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=EC=9D=84=20=EC=99=B8=EB=B6=80=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=9E=85=EB=A0=A5=EB=B0=9B=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/dto/request/ArticleUpdateRequest.java | 13 +++++++++---- .../article/dto/command/ArticleUpdateCommand.java | 1 + .../usecase/command/UpdateArticleUsecaseTest.java | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/api/src/main/java/com/pinback/api/article/dto/request/ArticleUpdateRequest.java b/api/src/main/java/com/pinback/api/article/dto/request/ArticleUpdateRequest.java index c3d7c0a9..1411fe22 100644 --- a/api/src/main/java/com/pinback/api/article/dto/request/ArticleUpdateRequest.java +++ b/api/src/main/java/com/pinback/api/article/dto/request/ArticleUpdateRequest.java @@ -15,15 +15,20 @@ public record ArticleUpdateRequest( @NotNull(message = "카테고리 ID는 필수입니다") @Positive(message = "카테고리 ID는 양수여야 합니다") Long categoryId, - + @Schema(description = "메모", example = "수정된 메모입니다") @Size(max = 500, message = "메모는 500자 이하로 입력해주세요") String memo, - - @Schema(description = "리마인더 시간", example = "2025-12-31T23:59:00") + + @Schema(description = "현재 시간", example = "2025-12-31T23:59:00") + @NotNull(message = "현재시간은 필수입니다") + LocalDateTime now, + + @Schema(description = "리마인드 시간", example = "2025-12-31T23:59:00") LocalDateTime remindTime + ) { public ArticleUpdateCommand toCommand() { - return new ArticleUpdateCommand(categoryId, memo, remindTime); + return new ArticleUpdateCommand(categoryId, memo, now, remindTime); } } diff --git a/application/src/main/java/com/pinback/application/article/dto/command/ArticleUpdateCommand.java b/application/src/main/java/com/pinback/application/article/dto/command/ArticleUpdateCommand.java index 9707a65d..a2e82c98 100644 --- a/application/src/main/java/com/pinback/application/article/dto/command/ArticleUpdateCommand.java +++ b/application/src/main/java/com/pinback/application/article/dto/command/ArticleUpdateCommand.java @@ -5,6 +5,7 @@ public record ArticleUpdateCommand( Long categoryId, String memo, + LocalDateTime now, LocalDateTime remindTime ) { } diff --git a/application/src/test/java/com/pinback/application/article/usecase/command/UpdateArticleUsecaseTest.java b/application/src/test/java/com/pinback/application/article/usecase/command/UpdateArticleUsecaseTest.java index 931e1ab1..852dbb10 100644 --- a/application/src/test/java/com/pinback/application/article/usecase/command/UpdateArticleUsecaseTest.java +++ b/application/src/test/java/com/pinback/application/article/usecase/command/UpdateArticleUsecaseTest.java @@ -55,6 +55,7 @@ void setUp() { newCategory = categoryWithName(user, "새 카테고리"); ReflectionTestUtils.setField(newCategory, "id", 2L); article = articleWithDate(user, "https://test.com", originalCategory, LocalDateTime.of(2025, 8, 20, 15, 0)); + ReflectionTestUtils.setField(article, "id", 1L); pushSubscription = pushSubscription(user); } @@ -64,9 +65,11 @@ void updateArticle_Success() { // given Long articleId = 1L; LocalDateTime newRemindTime = LocalDateTime.of(2025, 9, 1, 14, 0); + LocalDateTime now = LocalDateTime.of(2025, 9, 1, 13, 0); ArticleUpdateCommand command = new ArticleUpdateCommand( newCategory.getId(), "업데이트된 메모", + now, newRemindTime ); @@ -91,9 +94,11 @@ void updateArticle_MemoTooLong_ThrowsException() { // given Long articleId = 1L; String longMemo = "a".repeat(501); + LocalDateTime now = LocalDateTime.of(2025, 9, 1, 13, 0); ArticleUpdateCommand command = new ArticleUpdateCommand( originalCategory.getId(), longMemo, + now, LocalDateTime.of(2025, 9, 1, 14, 0) ); @@ -110,9 +115,11 @@ void updateArticle_SameRemindTime_NoScheduling() { // given Long articleId = 1L; LocalDateTime originalRemindTime = LocalDateTime.of(2025, 8, 20, 15, 0); + LocalDateTime now = LocalDateTime.of(2025, 9, 1, 13, 0); ArticleUpdateCommand command = new ArticleUpdateCommand( originalCategory.getId(), "업데이트된 메모", + now, originalRemindTime ); @@ -133,9 +140,11 @@ void updateArticle_SameRemindTime_NoScheduling() { void updateArticle_RemindTimeToNull_CancelReminder() { // given Long articleId = 1L; + LocalDateTime now = LocalDateTime.of(2025, 9, 1, 13, 0); ArticleUpdateCommand command = new ArticleUpdateCommand( originalCategory.getId(), "업데이트된 메모", + now, null ); @@ -159,9 +168,11 @@ void updateArticle_PastRemindTime_NoSchedule() { // given Long articleId = 1L; LocalDateTime pastRemindTime = LocalDateTime.of(2020, 1, 1, 12, 0); + LocalDateTime now = LocalDateTime.of(2025, 9, 1, 13, 0); ArticleUpdateCommand command = new ArticleUpdateCommand( originalCategory.getId(), "업데이트된 메모", + now, pastRemindTime ); @@ -185,9 +196,11 @@ void updateArticle_CategoryChangeOnly_NoNotification() { // given Long articleId = 1L; Article articleWithNullReminder = articleWithDate(user, "https://test.com", originalCategory, null); + LocalDateTime now = LocalDateTime.of(2025, 9, 1, 13, 0); ArticleUpdateCommand command = new ArticleUpdateCommand( newCategory.getId(), "업데이트된 메모", + now, null ); @@ -210,9 +223,11 @@ void updateArticle_NewFutureRemindTime_ScheduleNotification() { Long articleId = 1L; Article articleWithNullReminder = articleWithDate(user, "https://test.com", originalCategory, null); LocalDateTime futureRemindTime = LocalDateTime.of(2025, 12, 31, 23, 59); + LocalDateTime now = LocalDateTime.of(2025, 9, 1, 13, 0); ArticleUpdateCommand command = new ArticleUpdateCommand( originalCategory.getId(), "업데이트된 메모", + now, futureRemindTime ); From aa7e28f7563dbedc456c6b01d90c88d40e49902a Mon Sep 17 00:00:00 2001 From: rootTiket Date: Mon, 1 Sep 2025 14:45:11 +0900 Subject: [PATCH 03/21] =?UTF-8?q?refactor:=20=ED=98=84=EC=9E=AC=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=EC=9D=84=20=EC=99=B8=EB=B6=80=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=9E=85=EB=A0=A5=EB=B0=9B=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/usecase/command/UpdateArticleUsecase.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/application/src/main/java/com/pinback/application/article/usecase/command/UpdateArticleUsecase.java b/application/src/main/java/com/pinback/application/article/usecase/command/UpdateArticleUsecase.java index f5e1b799..5dc38cfb 100644 --- a/application/src/main/java/com/pinback/application/article/usecase/command/UpdateArticleUsecase.java +++ b/application/src/main/java/com/pinback/application/article/usecase/command/UpdateArticleUsecase.java @@ -44,7 +44,7 @@ public void updateArticle(User user, long articleId, ArticleUpdateCommand comman Category category = getCategoryPort.getCategoryAndUser(command.categoryId(), user); article.update(command.memo(), category, command.remindTime()); - handleReminderUpdate(article, user, command.remindTime(), remindTimeChanged, articleId); + handleReminderUpdate(article, user, command.now(), command.remindTime(), remindTimeChanged, articleId); } private void validateMemoLength(String memo) { @@ -53,12 +53,12 @@ private void validateMemoLength(String memo) { } } - private void handleReminderUpdate(Article article, User user, LocalDateTime remindTime, + private void handleReminderUpdate(Article article, User user, LocalDateTime now, LocalDateTime remindTime, boolean remindTimeChanged, long articleId) { if (remindTimeChanged) { manageArticleReminderPort.cancelArticleReminder(articleId, user.getId()); - if (remindTime != null && !remindTime.isBefore(LocalDateTime.now())) { + if (remindTime != null && !remindTime.isBefore(now)) { PushSubscription subscriptionInfo = getPushSubscription.findPushSubscription(user); manageArticleReminderPort.scheduleArticleReminder(article, user, subscriptionInfo.getToken()); } From acf0b4eb5aa040e9e8c5a7cc7daf2c4e759a93ed Mon Sep 17 00:00:00 2001 From: rootTiket Date: Mon, 1 Sep 2025 15:12:21 +0900 Subject: [PATCH 04/21] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=EC=97=90=20=EC=83=89=EC=83=81=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../port/out/CategoryColorServicePort.java | 11 +++++++++ .../domain/category/entity/Category.java | 10 +++++++- .../domain/category/enums/CategoryColor.java | 14 +++++++++++ .../service/CategoryColorService.java | 24 +++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 application/src/main/java/com/pinback/application/category/port/out/CategoryColorServicePort.java create mode 100644 domain/src/main/java/com/pinback/domain/category/enums/CategoryColor.java create mode 100644 infrastructure/src/main/java/com/pinback/infrastructure/category/service/CategoryColorService.java diff --git a/application/src/main/java/com/pinback/application/category/port/out/CategoryColorServicePort.java b/application/src/main/java/com/pinback/application/category/port/out/CategoryColorServicePort.java new file mode 100644 index 00000000..7f1d95d2 --- /dev/null +++ b/application/src/main/java/com/pinback/application/category/port/out/CategoryColorServicePort.java @@ -0,0 +1,11 @@ +package com.pinback.application.category.port.out; + +import java.util.Set; + +import com.pinback.domain.category.enums.CategoryColor; +import com.pinback.domain.user.entity.User; + +public interface CategoryColorServicePort { + + Set getUsedColorsByUser(User user); +} diff --git a/domain/src/main/java/com/pinback/domain/category/entity/Category.java b/domain/src/main/java/com/pinback/domain/category/entity/Category.java index 2ce67ecc..2082e116 100644 --- a/domain/src/main/java/com/pinback/domain/category/entity/Category.java +++ b/domain/src/main/java/com/pinback/domain/category/entity/Category.java @@ -1,10 +1,13 @@ package com.pinback.domain.category.entity; +import com.pinback.domain.category.enums.CategoryColor; import com.pinback.domain.common.BaseEntity; import com.pinback.domain.user.entity.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -38,10 +41,15 @@ public class Category extends BaseEntity { @JoinColumn(name = "user_id", nullable = false) private User user; - public static Category create(String name, User user) { + @Column(name = "color") + @Enumerated(EnumType.STRING) + private CategoryColor color; + + public static Category create(String name, User user, CategoryColor color) { return Category.builder() .name(name) .user(user) + .color(color) .build(); } diff --git a/domain/src/main/java/com/pinback/domain/category/enums/CategoryColor.java b/domain/src/main/java/com/pinback/domain/category/enums/CategoryColor.java new file mode 100644 index 00000000..af73e316 --- /dev/null +++ b/domain/src/main/java/com/pinback/domain/category/enums/CategoryColor.java @@ -0,0 +1,14 @@ +package com.pinback.domain.category.enums; + +public enum CategoryColor { + COLOR1, + COLOR2, + COLOR3, + COLOR4, + COLOR5, + COLOR6, + COLOR7, + COLOR8, + COLOR9, + COLOR10 +} diff --git a/infrastructure/src/main/java/com/pinback/infrastructure/category/service/CategoryColorService.java b/infrastructure/src/main/java/com/pinback/infrastructure/category/service/CategoryColorService.java new file mode 100644 index 00000000..2af0b959 --- /dev/null +++ b/infrastructure/src/main/java/com/pinback/infrastructure/category/service/CategoryColorService.java @@ -0,0 +1,24 @@ +package com.pinback.infrastructure.category.service; + +import java.util.Set; + +import org.springframework.stereotype.Service; + +import com.pinback.application.category.port.out.CategoryColorServicePort; +import com.pinback.domain.category.enums.CategoryColor; +import com.pinback.domain.user.entity.User; +import com.pinback.infrastructure.category.repository.CategoryRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CategoryColorService implements CategoryColorServicePort { + + private final CategoryRepository categoryRepository; + + @Override + public Set getUsedColorsByUser(User user) { + return categoryRepository.findColorsByUser(user); + } +} From e9b339fb59bf75902bbda8b7860f02f7a027730f Mon Sep 17 00:00:00 2001 From: rootTiket Date: Mon, 1 Sep 2025 15:13:06 +0900 Subject: [PATCH 05/21] =?UTF-8?q?refactor:=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/pinback/application/TestFixture.java | 5 +- .../domain/article/ArticleDomainTest.java | 4 +- .../pinback/domain/fixture/TestFixture.java | 5 +- .../java/com/pinback/fixture/TestFixture.java | 53 ------------------- .../repository/CategoryRepository.java | 6 +++ .../infrastructure/fixture/TestFixture.java | 3 +- 6 files changed, 16 insertions(+), 60 deletions(-) delete mode 100644 domain/src/test/java/com/pinback/fixture/TestFixture.java diff --git a/application/src/test/java/com/pinback/application/TestFixture.java b/application/src/test/java/com/pinback/application/TestFixture.java index 5803275b..4bcbde43 100644 --- a/application/src/test/java/com/pinback/application/TestFixture.java +++ b/application/src/test/java/com/pinback/application/TestFixture.java @@ -5,6 +5,7 @@ import com.pinback.domain.article.entity.Article; import com.pinback.domain.category.entity.Category; +import com.pinback.domain.category.enums.CategoryColor; import com.pinback.domain.notification.entity.PushSubscription; import com.pinback.domain.user.entity.User; @@ -19,11 +20,11 @@ public static User userWithEmail(String email) { } public static Category category(User user) { - return Category.create("테스트카테고리", user); + return Category.create("테스트카테고리", user, CategoryColor.COLOR1); } public static Category categoryWithName(User user, String name) { - return Category.create(name, user); + return Category.create(name, user, CategoryColor.COLOR1); } public static Article article(User user) { diff --git a/domain/src/test/java/com/pinback/domain/article/ArticleDomainTest.java b/domain/src/test/java/com/pinback/domain/article/ArticleDomainTest.java index da49515f..d3b50dc6 100644 --- a/domain/src/test/java/com/pinback/domain/article/ArticleDomainTest.java +++ b/domain/src/test/java/com/pinback/domain/article/ArticleDomainTest.java @@ -1,6 +1,6 @@ package com.pinback.domain.article; -import static com.pinback.fixture.TestFixture.*; +import static com.pinback.domain.fixture.TestFixture.*; import static org.assertj.core.api.Assertions.*; import java.time.LocalDateTime; @@ -10,8 +10,8 @@ import com.pinback.domain.article.entity.Article; import com.pinback.domain.category.entity.Category; +import com.pinback.domain.fixture.TestFixture; import com.pinback.domain.user.entity.User; -import com.pinback.fixture.TestFixture; class ArticleDomainTest { diff --git a/domain/src/test/java/com/pinback/domain/fixture/TestFixture.java b/domain/src/test/java/com/pinback/domain/fixture/TestFixture.java index 744ae9d4..e3e758e5 100644 --- a/domain/src/test/java/com/pinback/domain/fixture/TestFixture.java +++ b/domain/src/test/java/com/pinback/domain/fixture/TestFixture.java @@ -5,6 +5,7 @@ import com.pinback.domain.article.entity.Article; import com.pinback.domain.category.entity.Category; +import com.pinback.domain.category.enums.CategoryColor; import com.pinback.domain.notification.entity.PushSubscription; import com.pinback.domain.user.entity.User; @@ -19,11 +20,11 @@ public static User userWithEmail(String email) { } public static Category category(User user) { - return Category.create("테스트카테고리", user); + return Category.create("테스트카테고리", user, CategoryColor.COLOR1); } public static Category categoryWithName(User user, String name) { - return Category.create(name, user); + return Category.create(name, user, CategoryColor.COLOR1); } public static Article article(User user) { diff --git a/domain/src/test/java/com/pinback/fixture/TestFixture.java b/domain/src/test/java/com/pinback/fixture/TestFixture.java deleted file mode 100644 index ce69d7c7..00000000 --- a/domain/src/test/java/com/pinback/fixture/TestFixture.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.pinback.fixture; - -import java.time.LocalDateTime; -import java.time.LocalTime; - -import com.pinback.domain.article.entity.Article; -import com.pinback.domain.category.entity.Category; -import com.pinback.domain.notification.entity.PushSubscription; -import com.pinback.domain.user.entity.User; - -public class TestFixture { - - public static User user() { - return User.create("testUser@gmail.com", LocalTime.of(12, 0, 0)); - } - - public static User userWithEmail(String email) { - return User.create(email, LocalTime.of(12, 0, 0)); - } - - public static Category category(User user) { - return Category.create("테스트카테고리", user); - } - - public static Article article(User user) { - Category category = category(user); - return Article.create("test", "testmemo", user, category, LocalDateTime.of(2025, 7, 7, 12, 0, 0)); - } - - public static Article articleWithCategory(User user, Category category) { - return Article.create("test", "testmemo", user, category, LocalDateTime.of(2025, 7, 7, 12, 0, 0)); - } - - public static Article articleWithDate(User user, String url, Category category, LocalDateTime remindAt) { - return Article.create(url, "testmemo", user, category, remindAt); - } - - public static Article article(User user, String url, Category category) { - return Article.create(url, "testmemo", user, category, LocalDateTime.of(2025, 7, 7, 12, 0, 0)); - } - - public static Article readArticle(User user, String url, Category category) { - Article article = Article.create(url, "testmemo", user, category, LocalDateTime.of(2025, 7, 7, 12, 0, 0)); - article.markAsRead(); - return article; - } - - public static PushSubscription pushSubscription(User user) { - return PushSubscription.create( - user, "testToken" - ); - } -} diff --git a/infrastructure/src/main/java/com/pinback/infrastructure/category/repository/CategoryRepository.java b/infrastructure/src/main/java/com/pinback/infrastructure/category/repository/CategoryRepository.java index 5c35002c..7546310d 100644 --- a/infrastructure/src/main/java/com/pinback/infrastructure/category/repository/CategoryRepository.java +++ b/infrastructure/src/main/java/com/pinback/infrastructure/category/repository/CategoryRepository.java @@ -1,10 +1,13 @@ package com.pinback.infrastructure.category.repository; import java.util.Optional; +import java.util.Set; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import com.pinback.domain.category.entity.Category; +import com.pinback.domain.category.enums.CategoryColor; import com.pinback.domain.user.entity.User; public interface CategoryRepository extends JpaRepository, CategoryRepositoryCustom { @@ -14,4 +17,7 @@ public interface CategoryRepository extends JpaRepository, Categ boolean existsByNameAndUser(String categoryName, User user); long countByUser(User user); + + @Query("SELECT c.color FROM Category c WHERE c.user = :user") + Set findColorsByUser(User user); } diff --git a/infrastructure/src/test/java/com/pinback/infrastructure/fixture/TestFixture.java b/infrastructure/src/test/java/com/pinback/infrastructure/fixture/TestFixture.java index 4c30c78a..005c8040 100644 --- a/infrastructure/src/test/java/com/pinback/infrastructure/fixture/TestFixture.java +++ b/infrastructure/src/test/java/com/pinback/infrastructure/fixture/TestFixture.java @@ -5,6 +5,7 @@ import com.pinback.domain.article.entity.Article; import com.pinback.domain.category.entity.Category; +import com.pinback.domain.category.enums.CategoryColor; import com.pinback.domain.notification.entity.PushSubscription; import com.pinback.domain.user.entity.User; @@ -19,7 +20,7 @@ public static User userWithEmail(String email) { } public static Category category(User user) { - return Category.create("테스트카테고리", user); + return Category.create("테스트카테고리", user, CategoryColor.COLOR1); } public static Article article(User user) { From 020ef7a7c0030b29e62f1d7330452f8584e92c77 Mon Sep 17 00:00:00 2001 From: rootTiket Date: Mon, 1 Sep 2025 15:14:03 +0900 Subject: [PATCH 06/21] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=83=9D=EC=84=B1=EC=8B=9C=20=EC=83=89=EC=9D=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=EB=90=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/CreateCategoryResponse.java | 8 ++++---- .../usecase/command/CreateCategoryUsecase.java | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/application/src/main/java/com/pinback/application/category/dto/response/CreateCategoryResponse.java b/application/src/main/java/com/pinback/application/category/dto/response/CreateCategoryResponse.java index a0822eb3..5503f4d2 100644 --- a/application/src/main/java/com/pinback/application/category/dto/response/CreateCategoryResponse.java +++ b/application/src/main/java/com/pinback/application/category/dto/response/CreateCategoryResponse.java @@ -1,10 +1,10 @@ package com.pinback.application.category.dto.response; public record CreateCategoryResponse( - Long id, - String name + Long categoryId, + String categoryName ) { - public static CreateCategoryResponse of(Long id, String name) { - return new CreateCategoryResponse(id, name); + public static CreateCategoryResponse of(Long categoryId, String categoryName) { + return new CreateCategoryResponse(categoryId, categoryName); } } diff --git a/application/src/main/java/com/pinback/application/category/usecase/command/CreateCategoryUsecase.java b/application/src/main/java/com/pinback/application/category/usecase/command/CreateCategoryUsecase.java index fb3a5f11..4ff7a486 100644 --- a/application/src/main/java/com/pinback/application/category/usecase/command/CreateCategoryUsecase.java +++ b/application/src/main/java/com/pinback/application/category/usecase/command/CreateCategoryUsecase.java @@ -1,16 +1,21 @@ package com.pinback.application.category.usecase.command; +import java.util.Arrays; +import java.util.Set; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.pinback.application.category.dto.command.CreateCategoryCommand; import com.pinback.application.category.dto.response.CreateCategoryResponse; import com.pinback.application.category.port.in.CreateCategoryPort; +import com.pinback.application.category.port.out.CategoryColorServicePort; import com.pinback.application.category.port.out.CategoryGetServicePort; import com.pinback.application.category.port.out.CategorySaveServicePort; import com.pinback.application.common.exception.CategoryAlreadyExistException; import com.pinback.application.common.exception.CategoryLimitOverException; import com.pinback.domain.category.entity.Category; +import com.pinback.domain.category.enums.CategoryColor; import com.pinback.domain.user.entity.User; import lombok.RequiredArgsConstructor; @@ -24,12 +29,14 @@ public class CreateCategoryUsecase implements CreateCategoryPort { private final CategorySaveServicePort categorySaveService; private final CategoryGetServicePort categoryGetService; + private final CategoryColorServicePort categoryColorService; @Override public CreateCategoryResponse createCategory(User user, CreateCategoryCommand command) { validateCategoryCreation(user, command); - Category category = Category.create(command.categoryName(), user); + CategoryColor availableColor = getNextAvailableColor(user); + Category category = Category.create(command.categoryName(), user, availableColor); Category savedCategory = categorySaveService.save(category); return CreateCategoryResponse.of(savedCategory.getId(), savedCategory.getName()); @@ -45,4 +52,13 @@ private void validateCategoryCreation(User user, CreateCategoryCommand command) throw new CategoryAlreadyExistException(); } } + + private CategoryColor getNextAvailableColor(User user) { + Set usedColors = categoryColorService.getUsedColorsByUser(user); + + return Arrays.stream(CategoryColor.values()) + .filter(color -> !usedColors.contains(color)) + .findFirst() + .orElse(CategoryColor.COLOR1); + } } From f856c7cd049ba1832ac7ce002fc8baf0894e2e7a Mon Sep 17 00:00:00 2001 From: rootTiket Date: Mon, 1 Sep 2025 15:14:18 +0900 Subject: [PATCH 07/21] =?UTF-8?q?refactor:=20=ED=95=84=EB=93=9C=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/article/service/ArticleGetServiceTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/infrastructure/src/test/java/com/pinback/infrastructure/article/service/ArticleGetServiceTest.java b/infrastructure/src/test/java/com/pinback/infrastructure/article/service/ArticleGetServiceTest.java index 9118bf23..6ef9d0c6 100644 --- a/infrastructure/src/test/java/com/pinback/infrastructure/article/service/ArticleGetServiceTest.java +++ b/infrastructure/src/test/java/com/pinback/infrastructure/article/service/ArticleGetServiceTest.java @@ -1,5 +1,6 @@ package com.pinback.infrastructure.article.service; +import static com.pinback.domain.category.enums.CategoryColor.*; import static com.pinback.infrastructure.fixture.TestFixture.*; import static org.assertj.core.api.Assertions.*; @@ -122,7 +123,7 @@ void findAllByCategoryTest() { //given User user = userRepository.save(user()); Category category1 = categoryRepository.save(category(user)); - Category category2 = categoryRepository.save(Category.create("다른카테고리", user)); + Category category2 = categoryRepository.save(Category.create("다른카테고리", user, COLOR2)); articleRepository.save(article(user, "url1", category1)); articleRepository.save(article(user, "url2", category2)); From 5d52389675b112c2827753918cff115683f17b52 Mon Sep 17 00:00:00 2001 From: rootTiket Date: Mon, 1 Sep 2025 15:16:44 +0900 Subject: [PATCH 08/21] =?UTF-8?q?refactor:=20=EC=83=9D=EC=84=B1=EC=8B=9C?= =?UTF-8?q?=20=EC=83=89=EC=83=81=EB=8F=84=20=ED=95=A8=EA=BB=98=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../category/dto/response/CreateCategoryResponse.java | 9 ++++++--- .../category/usecase/command/CreateCategoryUsecase.java | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/application/src/main/java/com/pinback/application/category/dto/response/CreateCategoryResponse.java b/application/src/main/java/com/pinback/application/category/dto/response/CreateCategoryResponse.java index 5503f4d2..651c7689 100644 --- a/application/src/main/java/com/pinback/application/category/dto/response/CreateCategoryResponse.java +++ b/application/src/main/java/com/pinback/application/category/dto/response/CreateCategoryResponse.java @@ -1,10 +1,13 @@ package com.pinback.application.category.dto.response; +import com.pinback.domain.category.enums.CategoryColor; + public record CreateCategoryResponse( Long categoryId, - String categoryName + String categoryName, + CategoryColor categoryColor ) { - public static CreateCategoryResponse of(Long categoryId, String categoryName) { - return new CreateCategoryResponse(categoryId, categoryName); + public static CreateCategoryResponse of(Long categoryId, String categoryName, CategoryColor categoryColor) { + return new CreateCategoryResponse(categoryId, categoryName, categoryColor); } } diff --git a/application/src/main/java/com/pinback/application/category/usecase/command/CreateCategoryUsecase.java b/application/src/main/java/com/pinback/application/category/usecase/command/CreateCategoryUsecase.java index 4ff7a486..8aa5c7e0 100644 --- a/application/src/main/java/com/pinback/application/category/usecase/command/CreateCategoryUsecase.java +++ b/application/src/main/java/com/pinback/application/category/usecase/command/CreateCategoryUsecase.java @@ -39,7 +39,7 @@ public CreateCategoryResponse createCategory(User user, CreateCategoryCommand co Category category = Category.create(command.categoryName(), user, availableColor); Category savedCategory = categorySaveService.save(category); - return CreateCategoryResponse.of(savedCategory.getId(), savedCategory.getName()); + return CreateCategoryResponse.of(savedCategory.getId(), savedCategory.getName(), savedCategory.getColor()); } private void validateCategoryCreation(User user, CreateCategoryCommand command) { @@ -55,7 +55,7 @@ private void validateCategoryCreation(User user, CreateCategoryCommand command) private CategoryColor getNextAvailableColor(User user) { Set usedColors = categoryColorService.getUsedColorsByUser(user); - + return Arrays.stream(CategoryColor.values()) .filter(color -> !usedColors.contains(color)) .findFirst() From 99b4332d97879089abb798cd2b200e8098fbcf4b Mon Sep 17 00:00:00 2001 From: rootTiket Date: Mon, 1 Sep 2025 23:35:37 +0900 Subject: [PATCH 09/21] =?UTF-8?q?refactor:=20user=EC=99=80=20color?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=B5=ED=95=A9=20=EC=9C=A0=EB=8B=88=ED=81=AC=20?= =?UTF-8?q?=ED=82=A4=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/category/entity/Category.java | 10 +- .../repository/CategoryRepository.java | 4 - .../repository/CategoryRepositoryCustom.java | 5 + .../CategoryRepositoryCustomImpl.java | 15 ++ .../service/CategoryColorServiceTest.java | 173 ++++++++++++++++++ .../service/CategorySaveServiceTest.java | 128 +++++++++++++ .../fixture/CustomRepository.java | 2 + 7 files changed, 331 insertions(+), 6 deletions(-) create mode 100644 infrastructure/src/test/java/com/pinback/infrastructure/category/service/CategoryColorServiceTest.java create mode 100644 infrastructure/src/test/java/com/pinback/infrastructure/category/service/CategorySaveServiceTest.java diff --git a/domain/src/main/java/com/pinback/domain/category/entity/Category.java b/domain/src/main/java/com/pinback/domain/category/entity/Category.java index 2082e116..5a556996 100644 --- a/domain/src/main/java/com/pinback/domain/category/entity/Category.java +++ b/domain/src/main/java/com/pinback/domain/category/entity/Category.java @@ -15,6 +15,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -23,7 +24,12 @@ @Getter @Entity -@Table(name = "category") +@Table(name = "category", + uniqueConstraints = @UniqueConstraint( + name = "uk_category_user_color", + columnNames = {"user_id", "color"} + ) +) @Builder(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @@ -41,7 +47,7 @@ public class Category extends BaseEntity { @JoinColumn(name = "user_id", nullable = false) private User user; - @Column(name = "color") + @Column(name = "color", nullable = false) @Enumerated(EnumType.STRING) private CategoryColor color; diff --git a/infrastructure/src/main/java/com/pinback/infrastructure/category/repository/CategoryRepository.java b/infrastructure/src/main/java/com/pinback/infrastructure/category/repository/CategoryRepository.java index 7546310d..15c7d57e 100644 --- a/infrastructure/src/main/java/com/pinback/infrastructure/category/repository/CategoryRepository.java +++ b/infrastructure/src/main/java/com/pinback/infrastructure/category/repository/CategoryRepository.java @@ -4,7 +4,6 @@ import java.util.Set; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; import com.pinback.domain.category.entity.Category; import com.pinback.domain.category.enums.CategoryColor; @@ -17,7 +16,4 @@ public interface CategoryRepository extends JpaRepository, Categ boolean existsByNameAndUser(String categoryName, User user); long countByUser(User user); - - @Query("SELECT c.color FROM Category c WHERE c.user = :user") - Set findColorsByUser(User user); } diff --git a/infrastructure/src/main/java/com/pinback/infrastructure/category/repository/CategoryRepositoryCustom.java b/infrastructure/src/main/java/com/pinback/infrastructure/category/repository/CategoryRepositoryCustom.java index ea5c044c..a26c0007 100644 --- a/infrastructure/src/main/java/com/pinback/infrastructure/category/repository/CategoryRepositoryCustom.java +++ b/infrastructure/src/main/java/com/pinback/infrastructure/category/repository/CategoryRepositoryCustom.java @@ -1,13 +1,18 @@ package com.pinback.infrastructure.category.repository; import java.util.List; +import java.util.Set; import java.util.UUID; import com.pinback.domain.category.entity.Category; +import com.pinback.domain.category.enums.CategoryColor; +import com.pinback.domain.user.entity.User; import com.pinback.infrastructure.category.repository.dto.CategoriesForDashboard; public interface CategoryRepositoryCustom { List findAllForExtension(UUID userId); CategoriesForDashboard findAllForDashboard(UUID userId); + + Set findColorsByUser(User user); } diff --git a/infrastructure/src/main/java/com/pinback/infrastructure/category/repository/CategoryRepositoryCustomImpl.java b/infrastructure/src/main/java/com/pinback/infrastructure/category/repository/CategoryRepositoryCustomImpl.java index e5b6a0d1..110e8b49 100644 --- a/infrastructure/src/main/java/com/pinback/infrastructure/category/repository/CategoryRepositoryCustomImpl.java +++ b/infrastructure/src/main/java/com/pinback/infrastructure/category/repository/CategoryRepositoryCustomImpl.java @@ -3,12 +3,16 @@ import static com.pinback.domain.article.entity.QArticle.*; import static com.pinback.domain.category.entity.QCategory.*; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.UUID; import org.springframework.stereotype.Repository; import com.pinback.domain.category.entity.Category; +import com.pinback.domain.category.enums.CategoryColor; +import com.pinback.domain.user.entity.User; import com.pinback.infrastructure.category.repository.dto.CategoriesForDashboard; import com.pinback.infrastructure.category.repository.dto.CategoryForDashboard; import com.pinback.infrastructure.category.repository.dto.QCategoryForDashboard; @@ -50,4 +54,15 @@ public CategoriesForDashboard findAllForDashboard(UUID userId) { return new CategoriesForDashboard(categories); } + + @Override + public Set findColorsByUser(User user) { + List colors = queryFactory + .select(category.color) + .from(category) + .where(category.user.eq(user)) + .fetch(); + + return new HashSet<>(colors); + } } diff --git a/infrastructure/src/test/java/com/pinback/infrastructure/category/service/CategoryColorServiceTest.java b/infrastructure/src/test/java/com/pinback/infrastructure/category/service/CategoryColorServiceTest.java new file mode 100644 index 00000000..c0f908d6 --- /dev/null +++ b/infrastructure/src/test/java/com/pinback/infrastructure/category/service/CategoryColorServiceTest.java @@ -0,0 +1,173 @@ +package com.pinback.infrastructure.category.service; + +import static com.pinback.infrastructure.fixture.TestFixture.*; +import static org.assertj.core.api.Assertions.*; + +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.transaction.annotation.Transactional; + +import com.pinback.domain.category.entity.Category; +import com.pinback.domain.category.enums.CategoryColor; +import com.pinback.domain.user.entity.User; +import com.pinback.infrastructure.ServiceTest; +import com.pinback.infrastructure.category.repository.CategoryRepository; +import com.pinback.infrastructure.user.repository.UserRepository; + +@Import(CategoryColorService.class) +@Transactional +class CategoryColorServiceTest extends ServiceTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private CategoryRepository categoryRepository; + + @Autowired + private CategoryColorService categoryColorService; + + @DisplayName("사용자가 사용한 색상 목록을 조회할 수 있다") + @Test + void getUsedColorsByUser_Success() { + // given + User user = userRepository.save(user()); + categoryRepository.save(Category.create("카테고리1", user, CategoryColor.COLOR1)); + categoryRepository.save(Category.create("카테고리2", user, CategoryColor.COLOR3)); + categoryRepository.save(Category.create("카테고리3", user, CategoryColor.COLOR5)); + + // when + Set usedColors = categoryColorService.getUsedColorsByUser(user); + + // then + assertThat(usedColors).hasSize(3); + assertThat(usedColors).contains(CategoryColor.COLOR1, CategoryColor.COLOR3, CategoryColor.COLOR5); + assertThat(usedColors).doesNotContain(CategoryColor.COLOR2, CategoryColor.COLOR4); + } + + @DisplayName("사용자가 카테고리를 하나도 생성하지 않으면 빈 Set을 반환한다") + @Test + void getUsedColorsByUser_NoCategories() { + // given + User user = userRepository.save(user()); + + // when + Set usedColors = categoryColorService.getUsedColorsByUser(user); + + // then + assertThat(usedColors).isEmpty(); + } + + @DisplayName("한 사용자는 각 색상을 한 번만 사용할 수 있다") + @Test + void getUsedColorsByUser_UniqueColorsPerUser() { + // given + User user = userRepository.save(user()); + categoryRepository.save(Category.create("카테고리1", user, CategoryColor.COLOR1)); + categoryRepository.save(Category.create("카테고리2", user, CategoryColor.COLOR2)); + categoryRepository.save(Category.create("카테고리3", user, CategoryColor.COLOR3)); + + // when + Set usedColors = categoryColorService.getUsedColorsByUser(user); + + // then + assertThat(usedColors).hasSize(3); + assertThat(usedColors).contains(CategoryColor.COLOR1, CategoryColor.COLOR2, CategoryColor.COLOR3); + } + + @DisplayName("다른 사용자의 카테고리 색상은 조회되지 않는다") + @Test + void getUsedColorsByUser_DifferentUsers() { + // given + User user1 = userRepository.save(user()); + User user2 = userRepository.save(userWithEmail("user2@test.com")); + + categoryRepository.save(Category.create("카테고리1", user1, CategoryColor.COLOR1)); + categoryRepository.save(Category.create("카테고리2", user1, CategoryColor.COLOR2)); + categoryRepository.save(Category.create("카테고리3", user2, CategoryColor.COLOR3)); + categoryRepository.save(Category.create("카테고리4", user2, CategoryColor.COLOR4)); + + // when + Set user1Colors = categoryColorService.getUsedColorsByUser(user1); + Set user2Colors = categoryColorService.getUsedColorsByUser(user2); + + // then + assertThat(user1Colors).hasSize(2); + assertThat(user1Colors).contains(CategoryColor.COLOR1, CategoryColor.COLOR2); + assertThat(user1Colors).doesNotContain(CategoryColor.COLOR3, CategoryColor.COLOR4); + + assertThat(user2Colors).hasSize(2); + assertThat(user2Colors).contains(CategoryColor.COLOR3, CategoryColor.COLOR4); + assertThat(user2Colors).doesNotContain(CategoryColor.COLOR1, CategoryColor.COLOR2); + } + + @DisplayName("모든 색상을 사용한 경우 모든 색상이 반환된다") + @Test + void getUsedColorsByUser_AllColors() { + // given + User user = userRepository.save(user()); + CategoryColor[] allColors = CategoryColor.values(); + + for (int i = 0; i < allColors.length; i++) { + categoryRepository.save(Category.create("카테고리" + (i + 1), user, allColors[i])); + } + + // when + Set usedColors = categoryColorService.getUsedColorsByUser(user); + + // then + assertThat(usedColors).hasSize(allColors.length); + assertThat(usedColors).contains(allColors); + } + + @DisplayName("사용자별로 독립적인 색상 목록을 관리한다") + @Test + void getUsedColorsByUser_IndependentColorManagement() { + // given + User user1 = userRepository.save(user()); + User user2 = userRepository.save(userWithEmail("user2@test.com")); + + // user1은 COLOR1~COLOR5 사용 + for (int i = 1; i <= 5; i++) { + CategoryColor color = CategoryColor.valueOf("COLOR" + i); + categoryRepository.save(Category.create("user1카테고리" + i, user1, color)); + } + + // user2는 COLOR6~COLOR10 사용 + for (int i = 6; i <= 10; i++) { + CategoryColor color = CategoryColor.valueOf("COLOR" + i); + categoryRepository.save(Category.create("user2카테고리" + i, user2, color)); + } + + // when + Set user1Colors = categoryColorService.getUsedColorsByUser(user1); + Set user2Colors = categoryColorService.getUsedColorsByUser(user2); + + // then + assertThat(user1Colors).hasSize(5); + assertThat(user1Colors).contains( + CategoryColor.COLOR1, CategoryColor.COLOR2, CategoryColor.COLOR3, + CategoryColor.COLOR4, CategoryColor.COLOR5 + ); + + assertThat(user2Colors).hasSize(5); + assertThat(user2Colors).contains( + CategoryColor.COLOR6, CategoryColor.COLOR7, CategoryColor.COLOR8, + CategoryColor.COLOR9, CategoryColor.COLOR10 + ); + + assertThat(user1Colors).doesNotContain( + CategoryColor.COLOR6, CategoryColor.COLOR7, CategoryColor.COLOR8, + CategoryColor.COLOR9, CategoryColor.COLOR10 + ); + + assertThat(user2Colors).doesNotContain( + CategoryColor.COLOR1, CategoryColor.COLOR2, CategoryColor.COLOR3, + CategoryColor.COLOR4, CategoryColor.COLOR5 + ); + } +} diff --git a/infrastructure/src/test/java/com/pinback/infrastructure/category/service/CategorySaveServiceTest.java b/infrastructure/src/test/java/com/pinback/infrastructure/category/service/CategorySaveServiceTest.java new file mode 100644 index 00000000..b4c24c44 --- /dev/null +++ b/infrastructure/src/test/java/com/pinback/infrastructure/category/service/CategorySaveServiceTest.java @@ -0,0 +1,128 @@ +package com.pinback.infrastructure.category.service; + +import static com.pinback.infrastructure.fixture.TestFixture.*; +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.transaction.annotation.Transactional; + +import com.pinback.domain.category.entity.Category; +import com.pinback.domain.category.enums.CategoryColor; +import com.pinback.domain.user.entity.User; +import com.pinback.infrastructure.ServiceTest; +import com.pinback.infrastructure.category.repository.CategoryRepository; +import com.pinback.infrastructure.user.repository.UserRepository; + +@Import(CategorySaveService.class) +@Transactional +class CategorySaveServiceTest extends ServiceTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private CategoryRepository categoryRepository; + + @Autowired + private CategorySaveService categorySaveService; + + @DisplayName("카테고리를 데이터베이스에 저장할 수 있다") + @Test + void saveSuccess() { + // given + User user = userRepository.save(user()); + Category category = Category.create("테스트카테고리", user, CategoryColor.COLOR1); + + // when + Category savedCategory = categorySaveService.save(category); + + // then + assertThat(savedCategory.getId()).isNotNull(); + assertThat(savedCategory.getName()).isEqualTo("테스트카테고리"); + assertThat(savedCategory.getUser()).isEqualTo(user); + assertThat(savedCategory.getColor()).isEqualTo(CategoryColor.COLOR1); + + Category foundCategory = categoryRepository.findById(savedCategory.getId()).orElse(null); + assertThat(foundCategory).isNotNull(); + assertThat(foundCategory.getName()).isEqualTo("테스트카테고리"); + assertThat(foundCategory.getColor()).isEqualTo(CategoryColor.COLOR1); + } + + @DisplayName("여러 카테고리를 서로 다른 색상으로 저장할 수 있다") + @Test + void saveMultipleCategories_DifferentColors() { + // given + User user = userRepository.save(user()); + Category category1 = Category.create("카테고리1", user, CategoryColor.COLOR1); + Category category2 = Category.create("카테고리2", user, CategoryColor.COLOR2); + Category category3 = Category.create("카테고리3", user, CategoryColor.COLOR3); + + // when + Category savedCategory1 = categorySaveService.save(category1); + Category savedCategory2 = categorySaveService.save(category2); + Category savedCategory3 = categorySaveService.save(category3); + + // then + assertThat(savedCategory1.getColor()).isEqualTo(CategoryColor.COLOR1); + assertThat(savedCategory2.getColor()).isEqualTo(CategoryColor.COLOR2); + assertThat(savedCategory3.getColor()).isEqualTo(CategoryColor.COLOR3); + + Category found1 = categoryRepository.findById(savedCategory1.getId()).orElse(null); + Category found2 = categoryRepository.findById(savedCategory2.getId()).orElse(null); + Category found3 = categoryRepository.findById(savedCategory3.getId()).orElse(null); + + assertThat(found1.getColor()).isEqualTo(CategoryColor.COLOR1); + assertThat(found2.getColor()).isEqualTo(CategoryColor.COLOR2); + assertThat(found3.getColor()).isEqualTo(CategoryColor.COLOR3); + } + + @DisplayName("다른 사용자는 같은 색상의 카테고리를 저장할 수 있다") + @Test + void saveSameColorCategories_DifferentUsers() { + // given + User user1 = userRepository.save(user()); + User user2 = userRepository.save(userWithEmail("user2@test.com")); + Category category1 = Category.create("카테고리1", user1, CategoryColor.COLOR1); + Category category2 = Category.create("카테고리2", user2, CategoryColor.COLOR1); + + // when + Category savedCategory1 = categorySaveService.save(category1); + Category savedCategory2 = categorySaveService.save(category2); + + // then + assertThat(savedCategory1.getColor()).isEqualTo(CategoryColor.COLOR1); + assertThat(savedCategory2.getColor()).isEqualTo(CategoryColor.COLOR1); + assertThat(savedCategory1.getUser()).isEqualTo(user1); + assertThat(savedCategory2.getUser()).isEqualTo(user2); + } + + @DisplayName("같은 사용자가 같은 색상의 카테고리를 저장하려고 하면 제약조건 위반으로 예외가 발생한다") + @Test + void saveSameColorCategories_SameUser_ThrowsException() { + // given + User user = userRepository.save(user()); + Category category1 = Category.create("카테고리1", user, CategoryColor.COLOR1); + categorySaveService.save(category1); + + Category category2 = Category.create("카테고리2", user, CategoryColor.COLOR1); + + // when & then + assertThatThrownBy(() -> categorySaveService.save(category2)) + .isInstanceOf(Exception.class); + } + + @DisplayName("색상이 null이면 저장할 수 없다") + @Test + void save_NullColor_ThrowsException() { + // given + User user = userRepository.save(user()); + Category category = Category.create("테스트카테고리", user, null); + + // when & then + assertThatThrownBy(() -> categorySaveService.save(category)) + .isInstanceOf(Exception.class); + } +} diff --git a/infrastructure/src/test/java/com/pinback/infrastructure/fixture/CustomRepository.java b/infrastructure/src/test/java/com/pinback/infrastructure/fixture/CustomRepository.java index 8851c357..1ed3c963 100644 --- a/infrastructure/src/test/java/com/pinback/infrastructure/fixture/CustomRepository.java +++ b/infrastructure/src/test/java/com/pinback/infrastructure/fixture/CustomRepository.java @@ -13,6 +13,8 @@ public class CustomRepository { @Transactional public void clearAndReset() { + entityManager.clear(); + entityManager.createNativeQuery("DELETE FROM article").executeUpdate(); entityManager.createNativeQuery("DELETE FROM push_subscription").executeUpdate(); entityManager.createNativeQuery("DELETE FROM category").executeUpdate(); From 1bc095d517cbb6b4c7563b1d2fc65634f1ba7610 Mon Sep 17 00:00:00 2001 From: rootTiket Date: Wed, 3 Sep 2025 02:02:35 +0900 Subject: [PATCH 10/21] =?UTF-8?q?refactor:=20=EA=B8=80=EC=9E=90=20?= =?UTF-8?q?=EC=88=98=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=EC=97=90=EC=84=9C=20=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/CreateCategoryResponse.java | 4 ++-- .../command/CreateCategoryUsecase.java | 20 ++++++++----------- .../command/UpdateCategoryUsecase.java | 11 +++++----- .../domain/category/entity/Category.java | 13 +++++++++++- .../CategoryNameLengthOverException.java | 11 ++++++++++ 5 files changed, 38 insertions(+), 21 deletions(-) create mode 100644 domain/src/main/java/com/pinback/domain/category/exception/CategoryNameLengthOverException.java diff --git a/application/src/main/java/com/pinback/application/category/dto/response/CreateCategoryResponse.java b/application/src/main/java/com/pinback/application/category/dto/response/CreateCategoryResponse.java index 651c7689..3a7b2fa7 100644 --- a/application/src/main/java/com/pinback/application/category/dto/response/CreateCategoryResponse.java +++ b/application/src/main/java/com/pinback/application/category/dto/response/CreateCategoryResponse.java @@ -5,9 +5,9 @@ public record CreateCategoryResponse( Long categoryId, String categoryName, - CategoryColor categoryColor + String categoryColor ) { public static CreateCategoryResponse of(Long categoryId, String categoryName, CategoryColor categoryColor) { - return new CreateCategoryResponse(categoryId, categoryName, categoryColor); + return new CreateCategoryResponse(categoryId, categoryName, categoryColor.toString()); } } diff --git a/application/src/main/java/com/pinback/application/category/usecase/command/CreateCategoryUsecase.java b/application/src/main/java/com/pinback/application/category/usecase/command/CreateCategoryUsecase.java index 8aa5c7e0..97e506e9 100644 --- a/application/src/main/java/com/pinback/application/category/usecase/command/CreateCategoryUsecase.java +++ b/application/src/main/java/com/pinback/application/category/usecase/command/CreateCategoryUsecase.java @@ -13,9 +13,9 @@ import com.pinback.application.category.port.out.CategoryGetServicePort; import com.pinback.application.category.port.out.CategorySaveServicePort; import com.pinback.application.common.exception.CategoryAlreadyExistException; -import com.pinback.application.common.exception.CategoryLimitOverException; import com.pinback.domain.category.entity.Category; import com.pinback.domain.category.enums.CategoryColor; +import com.pinback.domain.category.exception.CategoryNameLengthOverException; import com.pinback.domain.user.entity.User; import lombok.RequiredArgsConstructor; @@ -25,8 +25,6 @@ @Transactional public class CreateCategoryUsecase implements CreateCategoryPort { - private static final int CATEGORY_LIMIT = 10; - private final CategorySaveServicePort categorySaveService; private final CategoryGetServicePort categoryGetService; private final CategoryColorServicePort categoryColorService; @@ -36,18 +34,16 @@ public CreateCategoryResponse createCategory(User user, CreateCategoryCommand co validateCategoryCreation(user, command); CategoryColor availableColor = getNextAvailableColor(user); - Category category = Category.create(command.categoryName(), user, availableColor); - Category savedCategory = categorySaveService.save(category); - - return CreateCategoryResponse.of(savedCategory.getId(), savedCategory.getName(), savedCategory.getColor()); + try { + Category category = Category.create(command.categoryName(), user, availableColor); + Category savedCategory = categorySaveService.save(category); + return CreateCategoryResponse.of(savedCategory.getId(), savedCategory.getName(), savedCategory.getColor()); + } catch (CategoryNameLengthOverException e) { + throw new CategoryNameLengthOverException(); + } } private void validateCategoryCreation(User user, CreateCategoryCommand command) { - long existingCategoryCnt = categoryGetService.countCategoriesByUser(user); - if (existingCategoryCnt >= CATEGORY_LIMIT) { - throw new CategoryLimitOverException(); - } - if (categoryGetService.checkExistsByCategoryNameAndUser(command.categoryName(), user)) { throw new CategoryAlreadyExistException(); } diff --git a/application/src/main/java/com/pinback/application/category/usecase/command/UpdateCategoryUsecase.java b/application/src/main/java/com/pinback/application/category/usecase/command/UpdateCategoryUsecase.java index db35062d..3e09c31d 100644 --- a/application/src/main/java/com/pinback/application/category/usecase/command/UpdateCategoryUsecase.java +++ b/application/src/main/java/com/pinback/application/category/usecase/command/UpdateCategoryUsecase.java @@ -7,8 +7,8 @@ import com.pinback.application.category.dto.response.UpdateCategoryResponse; import com.pinback.application.category.port.in.UpdateCategoryPort; import com.pinback.application.category.port.out.CategoryGetServicePort; -import com.pinback.application.common.exception.CategoryAlreadyExistException; import com.pinback.domain.category.entity.Category; +import com.pinback.domain.category.exception.CategoryNameLengthOverException; import com.pinback.domain.user.entity.User; import lombok.RequiredArgsConstructor; @@ -23,13 +23,12 @@ public class UpdateCategoryUsecase implements UpdateCategoryPort { @Override public UpdateCategoryResponse updateCategory(User user, Long categoryId, UpdateCategoryCommand command) { Category category = categoryGetService.getCategoryAndUser(categoryId, user); - - if (categoryGetService.checkExistsByCategoryNameAndUser(command.categoryName(), user)) { - throw new CategoryAlreadyExistException(); + try { + category.updateName(command.categoryName()); + } catch (CategoryNameLengthOverException e) { + throw new CategoryNameLengthOverException(); } - category.updateName(command.categoryName()); - return UpdateCategoryResponse.from(category); } } diff --git a/domain/src/main/java/com/pinback/domain/category/entity/Category.java b/domain/src/main/java/com/pinback/domain/category/entity/Category.java index 5a556996..02b7bec4 100644 --- a/domain/src/main/java/com/pinback/domain/category/entity/Category.java +++ b/domain/src/main/java/com/pinback/domain/category/entity/Category.java @@ -1,8 +1,10 @@ package com.pinback.domain.category.entity; import com.pinback.domain.category.enums.CategoryColor; +import com.pinback.domain.category.exception.CategoryNameLengthOverException; import com.pinback.domain.common.BaseEntity; import com.pinback.domain.user.entity.User; +import com.pinback.shared.util.TextUtil; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -24,7 +26,7 @@ @Getter @Entity -@Table(name = "category", +@Table(name = "category", uniqueConstraints = @UniqueConstraint( name = "uk_category_user_color", columnNames = {"user_id", "color"} @@ -52,6 +54,7 @@ public class Category extends BaseEntity { private CategoryColor color; public static Category create(String name, User user, CategoryColor color) { + validateName(name); return Category.builder() .name(name) .user(user) @@ -59,7 +62,15 @@ public static Category create(String name, User user, CategoryColor color) { .build(); } + private static void validateName(String name) { + int characterCount = TextUtil.countGraphemeClusters(name); + if (characterCount > 300) { + throw new CategoryNameLengthOverException(); + } + } + public void updateName(String name) { + validateName(name); this.name = name; } diff --git a/domain/src/main/java/com/pinback/domain/category/exception/CategoryNameLengthOverException.java b/domain/src/main/java/com/pinback/domain/category/exception/CategoryNameLengthOverException.java new file mode 100644 index 00000000..7e1864e7 --- /dev/null +++ b/domain/src/main/java/com/pinback/domain/category/exception/CategoryNameLengthOverException.java @@ -0,0 +1,11 @@ +package com.pinback.domain.category.exception; + +import static com.pinback.shared.constant.ExceptionCode.*; + +import com.pinback.shared.exception.ApplicationException; + +public class CategoryNameLengthOverException extends ApplicationException { + public CategoryNameLengthOverException() { + super(TEXT_LENGTH_OVER); + } +} From 8eb5de1c60e9dc882b39b37ebb7bb3be1d56181a Mon Sep 17 00:00:00 2001 From: rootTiket Date: Wed, 3 Sep 2025 02:17:07 +0900 Subject: [PATCH 11/21] =?UTF-8?q?fix:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=9C=EC=88=98=20=EC=98=88=EC=99=B8=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../category/usecase/command/CreateCategoryUsecase.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/application/src/main/java/com/pinback/application/category/usecase/command/CreateCategoryUsecase.java b/application/src/main/java/com/pinback/application/category/usecase/command/CreateCategoryUsecase.java index 97e506e9..2898a7f2 100644 --- a/application/src/main/java/com/pinback/application/category/usecase/command/CreateCategoryUsecase.java +++ b/application/src/main/java/com/pinback/application/category/usecase/command/CreateCategoryUsecase.java @@ -13,6 +13,7 @@ import com.pinback.application.category.port.out.CategoryGetServicePort; import com.pinback.application.category.port.out.CategorySaveServicePort; import com.pinback.application.common.exception.CategoryAlreadyExistException; +import com.pinback.application.common.exception.CategoryLimitOverException; import com.pinback.domain.category.entity.Category; import com.pinback.domain.category.enums.CategoryColor; import com.pinback.domain.category.exception.CategoryNameLengthOverException; @@ -44,6 +45,9 @@ public CreateCategoryResponse createCategory(User user, CreateCategoryCommand co } private void validateCategoryCreation(User user, CreateCategoryCommand command) { + if (categoryGetService.countCategoriesByUser(user) >= 10) { + throw new CategoryLimitOverException(); + } if (categoryGetService.checkExistsByCategoryNameAndUser(command.categoryName(), user)) { throw new CategoryAlreadyExistException(); } From 9606f9c5b9e14a447b8cc8cc0b9d77552ff253ed Mon Sep 17 00:00:00 2001 From: rootTiket Date: Wed, 3 Sep 2025 02:17:33 +0900 Subject: [PATCH 12/21] =?UTF-8?q?test:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20usecase=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/CreateCategoryUsecaseTest.java | 135 +++++++++++++ .../command/DeleteCategoryUsecaseTest.java | 141 ++++++++++++++ .../command/UpdateCategoryUsecaseTest.java | 77 ++++++++ .../usecase/query/GetCategoryUsecaseTest.java | 179 ++++++++++++++++++ 4 files changed, 532 insertions(+) create mode 100644 application/src/test/java/com/pinback/application/category/usecase/command/CreateCategoryUsecaseTest.java create mode 100644 application/src/test/java/com/pinback/application/category/usecase/command/DeleteCategoryUsecaseTest.java create mode 100644 application/src/test/java/com/pinback/application/category/usecase/command/UpdateCategoryUsecaseTest.java create mode 100644 application/src/test/java/com/pinback/application/category/usecase/query/GetCategoryUsecaseTest.java diff --git a/application/src/test/java/com/pinback/application/category/usecase/command/CreateCategoryUsecaseTest.java b/application/src/test/java/com/pinback/application/category/usecase/command/CreateCategoryUsecaseTest.java new file mode 100644 index 00000000..fe426572 --- /dev/null +++ b/application/src/test/java/com/pinback/application/category/usecase/command/CreateCategoryUsecaseTest.java @@ -0,0 +1,135 @@ +package com.pinback.application.category.usecase.command; + +import static com.pinback.application.TestFixture.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; + +import com.pinback.application.ApplicationTestBase; +import com.pinback.application.category.dto.command.CreateCategoryCommand; +import com.pinback.application.category.dto.response.CreateCategoryResponse; +import com.pinback.application.category.port.out.CategoryColorServicePort; +import com.pinback.application.category.port.out.CategoryGetServicePort; +import com.pinback.application.category.port.out.CategorySaveServicePort; +import com.pinback.application.common.exception.CategoryAlreadyExistException; +import com.pinback.application.common.exception.CategoryLimitOverException; +import com.pinback.domain.category.entity.Category; +import com.pinback.domain.category.enums.CategoryColor; +import com.pinback.domain.user.entity.User; + +class CreateCategoryUsecaseTest extends ApplicationTestBase { + + @Mock + private CategorySaveServicePort categorySaveService; + + @Mock + private CategoryGetServicePort categoryGetService; + + @Mock + private CategoryColorServicePort categoryColorService; + + @InjectMocks + private CreateCategoryUsecase createCategoryUsecase; + + private User user; + private Category savedCategory; + + @BeforeEach + void setUp() { + user = user(); + ReflectionTestUtils.setField(user, "id", java.util.UUID.randomUUID()); + savedCategory = categoryWithName(user, "테스트카테고리"); + ReflectionTestUtils.setField(savedCategory, "id", 1L); + } + + @DisplayName("사용자는 카테고리를 생성할 수 있다") + @Test + void createCategorySuccess() { + // given + CreateCategoryCommand command = new CreateCategoryCommand("새로운카테고리"); + + when(categoryGetService.checkExistsByCategoryNameAndUser("새로운카테고리", user)).thenReturn(false); + when(categoryColorService.getUsedColorsByUser(user)).thenReturn( + Set.of(CategoryColor.COLOR1, CategoryColor.COLOR2)); + when(categorySaveService.save(any(Category.class))).thenReturn(savedCategory); + + // when + CreateCategoryResponse response = createCategoryUsecase.createCategory(user, command); + + // then + assertThat(response.categoryId()).isEqualTo(1L); + assertThat(response.categoryName()).isEqualTo("테스트카테고리"); + assertThat(response.categoryColor()).isEqualTo("COLOR1"); + verify(categoryGetService).checkExistsByCategoryNameAndUser("새로운카테고리", user); + verify(categoryColorService).getUsedColorsByUser(user); + verify(categorySaveService).save(argThat(category -> + category.getName().equals("새로운카테고리") && + category.getUser().equals(user) && + category.getColor().equals(CategoryColor.COLOR3) + )); + } + + @DisplayName("카테고리가 10개 이상이면 생성할 수 없다") + @Test + void createCategoryExceedsLimitThrowsException() { + // given + CreateCategoryCommand command = new CreateCategoryCommand("새로운카테고리"); + + when(categoryGetService.countCategoriesByUser(user)).thenReturn(10L); + + // when & then + assertThatThrownBy(() -> createCategoryUsecase.createCategory(user, command)) + .isInstanceOf(CategoryLimitOverException.class); + + verify(categoryGetService).countCategoriesByUser(user); + verifyNoMoreInteractions(categoryGetService, categoryColorService, categorySaveService); + } + + @DisplayName("동일한 이름의 카테고리가 이미 존재하면 생성할 수 없다") + @Test + void createCategoryDuplicateNameThrowsException() { + // given + CreateCategoryCommand command = new CreateCategoryCommand("중복카테고리"); + + when(categoryGetService.countCategoriesByUser(user)).thenReturn(5L); + when(categoryGetService.checkExistsByCategoryNameAndUser("중복카테고리", user)).thenReturn(true); + + // when & then + assertThatThrownBy(() -> createCategoryUsecase.createCategory(user, command)) + .isInstanceOf(CategoryAlreadyExistException.class); + + verify(categoryGetService).countCategoriesByUser(user); + verify(categoryGetService).checkExistsByCategoryNameAndUser("중복카테고리", user); + verifyNoMoreInteractions(categoryGetService, categoryColorService, categorySaveService); + } + + @DisplayName("사용 중이지 않은 첫 번째 색상을 자동으로 할당한다") + @Test + void createCategoryAutoAssignFirstAvailableColor() { + // given + CreateCategoryCommand command = new CreateCategoryCommand("새로운카테고리"); + + when(categoryGetService.countCategoriesByUser(user)).thenReturn(3L); + when(categoryGetService.checkExistsByCategoryNameAndUser("새로운카테고리", user)).thenReturn(false); + when(categoryColorService.getUsedColorsByUser(user)).thenReturn( + Set.of(CategoryColor.COLOR1, CategoryColor.COLOR3)); + when(categorySaveService.save(any(Category.class))).thenReturn(savedCategory); + + // when + createCategoryUsecase.createCategory(user, command); + + // then + verify(categorySaveService).save(argThat(category -> + category.getColor().equals(CategoryColor.COLOR2) + )); + } +} diff --git a/application/src/test/java/com/pinback/application/category/usecase/command/DeleteCategoryUsecaseTest.java b/application/src/test/java/com/pinback/application/category/usecase/command/DeleteCategoryUsecaseTest.java new file mode 100644 index 00000000..c4a6ab72 --- /dev/null +++ b/application/src/test/java/com/pinback/application/category/usecase/command/DeleteCategoryUsecaseTest.java @@ -0,0 +1,141 @@ +package com.pinback.application.category.usecase.command; + +import static com.pinback.application.TestFixture.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; + +import com.pinback.application.ApplicationTestBase; +import com.pinback.application.article.port.out.ArticleDeleteServicePort; +import com.pinback.application.category.port.out.CategoryDeleteServicePort; +import com.pinback.application.category.port.out.CategoryGetServicePort; +import com.pinback.application.common.exception.CategoryNotOwnedException; +import com.pinback.domain.category.entity.Category; +import com.pinback.domain.user.entity.User; + +class DeleteCategoryUsecaseTest extends ApplicationTestBase { + + @Mock + private CategoryGetServicePort categoryGetService; + + @Mock + private CategoryDeleteServicePort categoryDeleteService; + + @Mock + private ArticleDeleteServicePort articleDeleteService; + + @InjectMocks + private DeleteCategoryUsecase deleteCategoryUsecase; + + private User user; + private User otherUser; + private Category category; + + @BeforeEach + void setUp() { + user = user(); + ReflectionTestUtils.setField(user, "id", java.util.UUID.randomUUID()); + + otherUser = userWithEmail("other@test.com"); + ReflectionTestUtils.setField(otherUser, "id", java.util.UUID.randomUUID()); + + category = categoryWithName(user, "테스트카테고리"); + ReflectionTestUtils.setField(category, "id", 1L); + } + + @DisplayName("사용자는 자신의 카테고리를 삭제할 수 있다") + @Test + void deleteCategory_Success() { + // given + Long categoryId = 1L; + + when(categoryGetService.findById(categoryId)).thenReturn(category); + + // when + deleteCategoryUsecase.deleteCategory(user, categoryId); + + // then + verify(categoryGetService).findById(categoryId); + verify(articleDeleteService).deleteByCategory(user, categoryId); + verify(categoryDeleteService).delete(category); + } + + @DisplayName("다른 사용자의 카테고리를 삭제하려고 하면 예외가 발생한다") + @Test + void deleteCategory_NotOwner_ThrowsException() { + // given + Long categoryId = 1L; + + when(categoryGetService.findById(categoryId)).thenReturn(category); + + // when & then + assertThatThrownBy(() -> deleteCategoryUsecase.deleteCategory(otherUser, categoryId)) + .isInstanceOf(CategoryNotOwnedException.class); + + verify(categoryGetService).findById(categoryId); + verifyNoInteractions(articleDeleteService, categoryDeleteService); + } + + @DisplayName("카테고리 삭제 시 해당 카테고리의 모든 아티클도 함께 삭제된다") + @Test + void deleteCategory_DeletesAssociatedArticles() { + // given + Long categoryId = 1L; + + when(categoryGetService.findById(categoryId)).thenReturn(category); + + // when + deleteCategoryUsecase.deleteCategory(user, categoryId); + + // then + verify(articleDeleteService).deleteByCategory(user, categoryId); + verify(categoryDeleteService).delete(category); + + // 아티클 삭제가 카테고리 삭제보다 먼저 호출되는지 확인 + var inOrder = inOrder(articleDeleteService, categoryDeleteService); + inOrder.verify(articleDeleteService).deleteByCategory(user, categoryId); + inOrder.verify(categoryDeleteService).delete(category); + } + + @DisplayName("존재하지 않는 카테고리 ID로 삭제를 시도하면 예외가 전파된다") + @Test + void deleteCategory_CategoryNotFound_ThrowsException() { + // given + Long nonExistentCategoryId = 999L; + + when(categoryGetService.findById(nonExistentCategoryId)) + .thenThrow(new RuntimeException("Category not found")); + + // when & then + assertThatThrownBy(() -> deleteCategoryUsecase.deleteCategory(user, nonExistentCategoryId)) + .isInstanceOf(RuntimeException.class) + .hasMessage("Category not found"); + + verify(categoryGetService).findById(nonExistentCategoryId); + verifyNoInteractions(articleDeleteService, categoryDeleteService); + } + + @DisplayName("카테고리 소유자 검증이 올바르게 작동한다") + @Test + void deleteCategory_OwnershipValidation() { + // given + Category otherUserCategory = categoryWithName(otherUser, "다른사용자카테고리"); + ReflectionTestUtils.setField(otherUserCategory, "id", 2L); + Long categoryId = 2L; + + when(categoryGetService.findById(categoryId)).thenReturn(otherUserCategory); + + // when & then + assertThatThrownBy(() -> deleteCategoryUsecase.deleteCategory(user, categoryId)) + .isInstanceOf(CategoryNotOwnedException.class); + + verify(categoryGetService).findById(categoryId); + verifyNoInteractions(articleDeleteService, categoryDeleteService); + } +} diff --git a/application/src/test/java/com/pinback/application/category/usecase/command/UpdateCategoryUsecaseTest.java b/application/src/test/java/com/pinback/application/category/usecase/command/UpdateCategoryUsecaseTest.java new file mode 100644 index 00000000..280f33ec --- /dev/null +++ b/application/src/test/java/com/pinback/application/category/usecase/command/UpdateCategoryUsecaseTest.java @@ -0,0 +1,77 @@ +package com.pinback.application.category.usecase.command; + +import static com.pinback.application.TestFixture.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; + +import com.pinback.application.ApplicationTestBase; +import com.pinback.application.category.dto.command.UpdateCategoryCommand; +import com.pinback.application.category.dto.response.UpdateCategoryResponse; +import com.pinback.application.category.port.out.CategoryGetServicePort; +import com.pinback.domain.category.entity.Category; +import com.pinback.domain.user.entity.User; + +class UpdateCategoryUsecaseTest extends ApplicationTestBase { + + @Mock + private CategoryGetServicePort categoryGetService; + + @InjectMocks + private UpdateCategoryUsecase updateCategoryUsecase; + + private User user; + private Category category; + + @BeforeEach + void setUp() { + user = user(); + ReflectionTestUtils.setField(user, "id", java.util.UUID.randomUUID()); + category = categoryWithName(user, "원본카테고리"); + ReflectionTestUtils.setField(category, "id", 1L); + } + + @DisplayName("사용자는 카테고리 이름을 수정할 수 있다") + @Test + void updateCategory_Success() { + // given + Long categoryId = 1L; + UpdateCategoryCommand command = new UpdateCategoryCommand("수정된카테고리"); + + when(categoryGetService.getCategoryAndUser(categoryId, user)).thenReturn(category); + + // when + UpdateCategoryResponse response = updateCategoryUsecase.updateCategory(user, categoryId, command); + + // then + assertThat(response.categoryId()).isEqualTo(1L); + assertThat(response.categoryName()).isEqualTo("수정된카테고리"); + assertThat(category.getName()).isEqualTo("수정된카테고리"); + verify(categoryGetService).getCategoryAndUser(categoryId, user); + } + + @DisplayName("같은 이름으로 수정하려고 하면 성공한다") + @Test + void updateCategory_SameName_Success() { + // given + Long categoryId = 1L; + String originalName = "원본카테고리"; + UpdateCategoryCommand command = new UpdateCategoryCommand(originalName); + + when(categoryGetService.getCategoryAndUser(categoryId, user)).thenReturn(category); + + // when + UpdateCategoryResponse response = updateCategoryUsecase.updateCategory(user, categoryId, command); + + // then + assertThat(response.categoryId()).isEqualTo(1L); + assertThat(response.categoryName()).isEqualTo(originalName); + verify(categoryGetService).getCategoryAndUser(categoryId, user); + } +} diff --git a/application/src/test/java/com/pinback/application/category/usecase/query/GetCategoryUsecaseTest.java b/application/src/test/java/com/pinback/application/category/usecase/query/GetCategoryUsecaseTest.java new file mode 100644 index 00000000..5cc82549 --- /dev/null +++ b/application/src/test/java/com/pinback/application/category/usecase/query/GetCategoryUsecaseTest.java @@ -0,0 +1,179 @@ +package com.pinback.application.category.usecase.query; + +import static com.pinback.application.TestFixture.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; + +import com.pinback.application.ApplicationTestBase; +import com.pinback.application.article.port.out.ArticleGetServicePort; +import com.pinback.application.category.dto.CategoriesForDashboardDto; +import com.pinback.application.category.dto.CategoryForDashboardDto; +import com.pinback.application.category.dto.response.CategoriesForDashboardResponse; +import com.pinback.application.category.dto.response.CategoriesForExtensionResponse; +import com.pinback.application.category.port.out.CategoryGetServicePort; +import com.pinback.application.common.exception.CategoryNotFoundException; +import com.pinback.domain.article.entity.Article; +import com.pinback.domain.category.entity.Category; +import com.pinback.domain.user.entity.User; + +class GetCategoryUsecaseTest extends ApplicationTestBase { + + @Mock + private CategoryGetServicePort categoryGetServicePort; + + @Mock + private ArticleGetServicePort articleGetServicePort; + + @InjectMocks + private GetCategoryUsecase getCategoryUsecase; + + private User user; + private Category category1; + private Category category2; + private Article recentArticle; + + @BeforeEach + void setUp() { + user = user(); + ReflectionTestUtils.setField(user, "id", java.util.UUID.randomUUID()); + + category1 = categoryWithName(user, "카테고리1"); + ReflectionTestUtils.setField(category1, "id", 1L); + + category2 = categoryWithName(user, "카테고리2"); + ReflectionTestUtils.setField(category2, "id", 2L); + + recentArticle = articleWithCategory(user, category1); + ReflectionTestUtils.setField(recentArticle, "id", 1L); + } + + @DisplayName("익스텐션용 카테고리 목록을 조회할 수 있다") + @Test + void getAllCategoriesForExtension_Success() { + // given + List categories = List.of(category1, category2); + + when(articleGetServicePort.findRecentByUser(user)).thenReturn(Optional.of(recentArticle)); + when(categoryGetServicePort.findAllForExtension(user.getId())).thenReturn(categories); + + // when + CategoriesForExtensionResponse response = getCategoryUsecase.getAllCategoriesForExtension(user); + + // then + assertThat(response.recentSaved()).isEqualTo("카테고리1"); + assertThat(response.categories()).hasSize(2); + assertThat(response.categories().get(0).categoryName()).isEqualTo("카테고리1"); + assertThat(response.categories().get(1).categoryName()).isEqualTo("카테고리2"); + + verify(articleGetServicePort).findRecentByUser(user); + verify(categoryGetServicePort).findAllForExtension(user.getId()); + } + + @DisplayName("최근 저장된 아티클이 없으면 recentSaved가 null이다") + @Test + void getAllCategoriesForExtension_NoRecentArticle() { + // given + List categories = List.of(category1, category2); + + when(articleGetServicePort.findRecentByUser(user)).thenReturn(Optional.empty()); + when(categoryGetServicePort.findAllForExtension(user.getId())).thenReturn(categories); + + // when + CategoriesForExtensionResponse response = getCategoryUsecase.getAllCategoriesForExtension(user); + + // then + assertThat(response.recentSaved()).isNull(); + assertThat(response.categories()).hasSize(2); + + verify(articleGetServicePort).findRecentByUser(user); + verify(categoryGetServicePort).findAllForExtension(user.getId()); + } + + @DisplayName("대시보드용 카테고리 목록을 조회할 수 있다") + @Test + void getAllCategoriesForDashboard_Success() { + // given + List categories = List.of( + new CategoryForDashboardDto(1L, "카테고리1", 3L), + new CategoryForDashboardDto(2L, "카테고리2", 1L) + ); + CategoriesForDashboardDto dashboardDto = new CategoriesForDashboardDto(categories); + + when(categoryGetServicePort.findAllForDashboard(user.getId())).thenReturn(dashboardDto); + + // when + CategoriesForDashboardResponse response = getCategoryUsecase.getAllCategoriesForDashboard(user); + + // then + assertThat(response.categories()).hasSize(2); + assertThat(response.categories().get(0).id()).isEqualTo(1L); + assertThat(response.categories().get(0).name()).isEqualTo("카테고리1"); + assertThat(response.categories().get(0).unreadCount()).isEqualTo(3L); + assertThat(response.categories().get(1).id()).isEqualTo(2L); + assertThat(response.categories().get(1).name()).isEqualTo("카테고리2"); + assertThat(response.categories().get(1).unreadCount()).isEqualTo(1L); + + verify(categoryGetServicePort).findAllForDashboard(user.getId()); + } + + @DisplayName("빈 카테고리 목록도 올바르게 처리한다") + @Test + void getAllCategoriesForDashboard_EmptyList() { + // given + CategoriesForDashboardDto dashboardDto = new CategoriesForDashboardDto(List.of()); + + when(categoryGetServicePort.findAllForDashboard(user.getId())).thenReturn(dashboardDto); + + // when + CategoriesForDashboardResponse response = getCategoryUsecase.getAllCategoriesForDashboard(user); + + // then + assertThat(response.categories()).isEmpty(); + verify(categoryGetServicePort).findAllForDashboard(user.getId()); + } + + @DisplayName("특정 카테고리를 소유자와 함께 조회할 수 있다") + @Test + void getCategoryAndUser_Success() { + // given + Long categoryId = 1L; + + when(categoryGetServicePort.getCategoryAndUser(categoryId, user)).thenReturn(category1); + + // when + Category result = getCategoryUsecase.getCategoryAndUser(categoryId, user); + + // then + assertThat(result).isEqualTo(category1); + assertThat(result.getName()).isEqualTo("카테고리1"); + assertThat(result.getUser()).isEqualTo(user); + + verify(categoryGetServicePort).getCategoryAndUser(categoryId, user); + } + + @DisplayName("존재하지 않는 카테고리를 조회하면 예외가 전파된다") + @Test + void getCategoryAndUser_CategoryNotFound() { + // given + Long nonExistentCategoryId = 999L; + + when(categoryGetServicePort.getCategoryAndUser(nonExistentCategoryId, user)) + .thenThrow(new CategoryNotFoundException()); + + // when & then + assertThatThrownBy(() -> getCategoryUsecase.getCategoryAndUser(nonExistentCategoryId, user)) + .isInstanceOf(CategoryNotFoundException.class); + + verify(categoryGetServicePort).getCategoryAndUser(nonExistentCategoryId, user); + } +} \ No newline at end of file From 9a5a26dab4b15c89c28234eef9ccde28d4069482 Mon Sep 17 00:00:00 2001 From: rootTiket Date: Wed, 3 Sep 2025 02:52:36 +0900 Subject: [PATCH 13/21] =?UTF-8?q?feat:=20=EC=95=84=ED=8B=B0=ED=81=B4=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=A1=B0=ED=9A=8C=EC=8B=9C=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=A0=95=EB=B3=B4=EB=8F=84=20?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=EC=A0=84=EB=8B=AC=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/article/dto/response/ArticleResponse.java | 7 +++++-- .../category/dto/response/CategoryResponse.java | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/application/src/main/java/com/pinback/application/article/dto/response/ArticleResponse.java b/application/src/main/java/com/pinback/application/article/dto/response/ArticleResponse.java index 299b38d5..27cdf218 100644 --- a/application/src/main/java/com/pinback/application/article/dto/response/ArticleResponse.java +++ b/application/src/main/java/com/pinback/application/article/dto/response/ArticleResponse.java @@ -2,6 +2,7 @@ import java.time.LocalDateTime; +import com.pinback.application.category.dto.response.CategoryResponse; import com.pinback.domain.article.entity.Article; public record ArticleResponse( @@ -9,7 +10,8 @@ public record ArticleResponse( String url, String memo, LocalDateTime createdAt, - boolean isRead + boolean isRead, + CategoryResponse category ) { public static ArticleResponse from(Article article) { return new ArticleResponse( @@ -17,7 +19,8 @@ public static ArticleResponse from(Article article) { article.getUrl(), article.getMemo(), article.getCreatedAt(), - article.isRead() + article.isRead(), + CategoryResponse.from(article.getCategory()) ); } } diff --git a/application/src/main/java/com/pinback/application/category/dto/response/CategoryResponse.java b/application/src/main/java/com/pinback/application/category/dto/response/CategoryResponse.java index 9770c832..751d6ea5 100644 --- a/application/src/main/java/com/pinback/application/category/dto/response/CategoryResponse.java +++ b/application/src/main/java/com/pinback/application/category/dto/response/CategoryResponse.java @@ -4,9 +4,10 @@ public record CategoryResponse( long categoryId, - String categoryName + String categoryName, + String categoryColor ) { public static CategoryResponse from(Category category) { - return new CategoryResponse(category.getId(), category.getName()); + return new CategoryResponse(category.getId(), category.getName(), category.getColor().toString()); } } From 3fdba82bd2cdebb3a61c7b3ced3dc74a642d0d19 Mon Sep 17 00:00:00 2001 From: rootTiket Date: Wed, 3 Sep 2025 02:52:49 +0900 Subject: [PATCH 14/21] =?UTF-8?q?fix:=20=EA=B8=B0=EC=A1=B4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EA=B3=BC=20=EB=8B=A4=EB=A5=B8=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pinback/api/auth/controller/AuthController.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/api/src/main/java/com/pinback/api/auth/controller/AuthController.java b/api/src/main/java/com/pinback/api/auth/controller/AuthController.java index 1af50321..84422bb0 100644 --- a/api/src/main/java/com/pinback/api/auth/controller/AuthController.java +++ b/api/src/main/java/com/pinback/api/auth/controller/AuthController.java @@ -1,8 +1,10 @@ package com.pinback.api.auth.controller; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.pinback.api.auth.dto.request.SignUpRequest; @@ -33,12 +35,12 @@ public ResponseDto signUp( return ResponseDto.created(response); } - @Operation(summary = "로그인", description = "이메일로 로그인합니다") - @PostMapping("/signin") + @Operation(summary = "토큰 재발급", description = "이메일로 토큰을 발급합니다.") + @GetMapping("/token") public ResponseDto signIn( - @Valid @RequestBody SignUpRequest request + @Valid @RequestParam String email ) { - TokenResponse response = authUsecase.getToken(request.email()); + TokenResponse response = authUsecase.getToken(email); return ResponseDto.ok(response); } } From 2aae76627e39062cd8034de5a9153c93bf38725a Mon Sep 17 00:00:00 2001 From: rootTiket Date: Wed, 3 Sep 2025 02:58:55 +0900 Subject: [PATCH 15/21] =?UTF-8?q?fix:=20JPA=20Auditing=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/main/java/com/pinback/api/PinbackApiApplication.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/src/main/java/com/pinback/api/PinbackApiApplication.java b/api/src/main/java/com/pinback/api/PinbackApiApplication.java index 264068a9..1312d220 100644 --- a/api/src/main/java/com/pinback/api/PinbackApiApplication.java +++ b/api/src/main/java/com/pinback/api/PinbackApiApplication.java @@ -3,6 +3,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @SpringBootApplication(scanBasePackages = { @@ -13,6 +14,7 @@ }) @EntityScan("com.pinback.domain") @EnableJpaRepositories("com.pinback.infrastructure") +@EnableJpaAuditing public class PinbackApiApplication { public static void main(String[] args) { SpringApplication.run(PinbackApiApplication.class, args); From 45d11219ce684c1146fa64a791f1f1d95b6a415d Mon Sep 17 00:00:00 2001 From: rootTiket Date: Wed, 3 Sep 2025 03:03:42 +0900 Subject: [PATCH 16/21] =?UTF-8?q?feat:=20=EB=B3=91=EB=A0=AC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8,=20=EB=B9=8C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev.yml | 6 +++++- build.gradle | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 0b2e6344..ec2a98af 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -7,6 +7,10 @@ on: jobs: build: runs-on: ubuntu-latest + strategy: + matrix: + java-version: ['21'] + fail-fast: false services: redis: image: redis:7 @@ -39,7 +43,7 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- - name: Build with Gradle Wrapper - run: ./gradlew clean build --no-daemon --build-cache + run: ./gradlew clean build --no-daemon --build-cache --parallel --max-workers=4 - name: Publish Test Report uses: mikepenz/action-junit-report@v5 diff --git a/build.gradle b/build.gradle index be271ffe..0cd349ed 100644 --- a/build.gradle +++ b/build.gradle @@ -55,6 +55,18 @@ subprojects { useJUnitPlatform() inputs.property("java.vendor", System.getProperty("java.vendor")) inputs.property("java.version", System.getProperty("java.version")) + + // 병렬 테스트 실행 + maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1 + + // 테스트 성능 최적화 + jvmArgs = ['-XX:+UseG1GC', '-Xmx1g'] + + // 테스트 보고서 설정 + testLogging { + events "passed", "skipped", "failed" + exceptionFormat "full" + } } } From 99439841866e2a7ef40d6500c7381f9e81083b00 Mon Sep 17 00:00:00 2001 From: rootTiket Date: Wed, 3 Sep 2025 03:07:13 +0900 Subject: [PATCH 17/21] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=84=9C=EB=A8=B8=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/build.gradle b/build.gradle index 0cd349ed..d29cfbfb 100644 --- a/build.gradle +++ b/build.gradle @@ -66,6 +66,20 @@ subprojects { testLogging { events "passed", "skipped", "failed" exceptionFormat "full" + showStandardStreams = false + showCauses = true + showExceptions = true + } + + // 테스트 완료 후 결과 출력 + afterSuite { desc, result -> + if (!desc.parent) { + println "\n${project.name} Test Results:" + println " Tests: ${result.testCount}" + println " Passed: ${result.successfulTestCount}" + println " Failed: ${result.failedTestCount}" + println " Skipped: ${result.skippedTestCount}" + } } } } From 699bcd797a7068290932a5bdc774b4a5e797ab0f Mon Sep 17 00:00:00 2001 From: rootTiket Date: Wed, 3 Sep 2025 19:05:51 +0900 Subject: [PATCH 18/21] =?UTF-8?q?refactor:=20=EB=A6=AC=EB=A7=88=EC=9D=B8?= =?UTF-8?q?=EB=93=9C=20=EC=95=84=ED=8B=B0=ED=81=B4=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=8B=9C,=20=EC=9D=BD=EC=9D=8C=20=EC=83=81=ED=83=9C=EB=A5=BC?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=EB=B0=9B=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/dto/response/RemindArticlesResponse.java | 3 ++- .../application/article/port/out/ArticleGetServicePort.java | 2 +- .../article/usecase/query/GetArticleUsecase.java | 2 +- .../article/usecase/query/GetArticleUsecaseTest.java | 4 ++-- .../article/repository/ArticleRepositoryCustom.java | 2 +- .../article/repository/ArticleRepositoryCustomImpl.java | 6 +++++- .../infrastructure/article/service/ArticleGetService.java | 4 ++-- .../article/service/ArticleGetServiceTest.java | 2 +- 8 files changed, 15 insertions(+), 10 deletions(-) diff --git a/application/src/main/java/com/pinback/application/article/dto/response/RemindArticlesResponse.java b/application/src/main/java/com/pinback/application/article/dto/response/RemindArticlesResponse.java index c8020798..522716a4 100644 --- a/application/src/main/java/com/pinback/application/article/dto/response/RemindArticlesResponse.java +++ b/application/src/main/java/com/pinback/application/article/dto/response/RemindArticlesResponse.java @@ -8,7 +8,8 @@ public record RemindArticlesResponse( LocalDateTime nextRemindDate, List articles ) { - public static RemindArticlesResponse of(long totalElements, LocalDateTime nextRemindDate, List articles) { + public static RemindArticlesResponse of(long totalElements, LocalDateTime nextRemindDate, + List articles) { return new RemindArticlesResponse(totalElements, nextRemindDate, articles); } } diff --git a/application/src/main/java/com/pinback/application/article/port/out/ArticleGetServicePort.java b/application/src/main/java/com/pinback/application/article/port/out/ArticleGetServicePort.java index 2d61cc5c..323153ae 100644 --- a/application/src/main/java/com/pinback/application/article/port/out/ArticleGetServicePort.java +++ b/application/src/main/java/com/pinback/application/article/port/out/ArticleGetServicePort.java @@ -29,5 +29,5 @@ public interface ArticleGetServicePort { ArticlesWithUnreadCountDto findUnreadArticles(User user, PageRequest pageRequest); - Page
findTodayRemind(User user, LocalDateTime remindDateTime, Pageable pageable); + Page
findTodayRemind(User user, LocalDateTime remindDateTime, Pageable pageable, Boolean isRead); } diff --git a/application/src/main/java/com/pinback/application/article/usecase/query/GetArticleUsecase.java b/application/src/main/java/com/pinback/application/article/usecase/query/GetArticleUsecase.java index c71c2128..1f0f47de 100644 --- a/application/src/main/java/com/pinback/application/article/usecase/query/GetArticleUsecase.java +++ b/application/src/main/java/com/pinback/application/article/usecase/query/GetArticleUsecase.java @@ -100,7 +100,7 @@ public RemindArticlesResponse getRemindArticles(User user, LocalDateTime now, Pa LocalDateTime remindDateTime = getRemindDateTime(now, user.getRemindDefault()); Page
articles = articleGetServicePort.findTodayRemind( - user, remindDateTime, PageRequest.of(query.pageNumber(), query.pageSize())); + user, remindDateTime, PageRequest.of(query.pageNumber(), query.pageSize()), null); List articleResponses = articles.stream() .map(ArticleResponse::from) diff --git a/application/src/test/java/com/pinback/application/article/usecase/query/GetArticleUsecaseTest.java b/application/src/test/java/com/pinback/application/article/usecase/query/GetArticleUsecaseTest.java index 60aea67a..8c6eb63e 100644 --- a/application/src/test/java/com/pinback/application/article/usecase/query/GetArticleUsecaseTest.java +++ b/application/src/test/java/com/pinback/application/article/usecase/query/GetArticleUsecaseTest.java @@ -185,7 +185,7 @@ void getRemindArticles_Success() { List
articles = List.of(article, article); Page
articlePage = new PageImpl<>(articles, pageRequest, 2); - when(articleGetServicePort.findTodayRemind(userWithRemindTime, todayRemindTime, pageRequest)).thenReturn( + when(articleGetServicePort.findTodayRemind(userWithRemindTime, todayRemindTime, pageRequest, null)).thenReturn( articlePage); // when @@ -193,6 +193,6 @@ void getRemindArticles_Success() { // then assertThat(response.articles()).hasSize(2); - verify(articleGetServicePort).findTodayRemind(userWithRemindTime, todayRemindTime, pageRequest); + verify(articleGetServicePort).findTodayRemind(userWithRemindTime, todayRemindTime, pageRequest, null); } } diff --git a/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustom.java b/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustom.java index ba0c70b5..81ec9b22 100644 --- a/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustom.java +++ b/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustom.java @@ -14,7 +14,7 @@ public interface ArticleRepositoryCustom { ArticlesWithUnreadCount findAllByCategory(UUID userId, long articleId, Pageable pageable); - Page
findTodayRemind(UUID userId, Pageable pageable, LocalDateTime startAt, LocalDateTime endAt); + Page
findTodayRemind(UUID userId, Pageable pageable, LocalDateTime startAt, LocalDateTime endAt, Boolean isRead); ArticlesWithUnreadCount findAllByIsReadFalse(UUID userId, Pageable pageable); diff --git a/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustomImpl.java b/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustomImpl.java index d2650d86..442f9841 100644 --- a/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustomImpl.java +++ b/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustomImpl.java @@ -87,10 +87,14 @@ public ArticlesWithUnreadCount findAllByCategory(UUID userId, long categoryId, P @Override public Page
findTodayRemind(UUID userId, Pageable pageable, LocalDateTime startAt, - LocalDateTime endAt) { + LocalDateTime endAt, Boolean isRead) { BooleanExpression conditions = article.user.id.eq(userId) .and(article.remindAt.goe(startAt).and(article.remindAt.loe(endAt))); + if (isRead != null) { + conditions = conditions.and(article.isRead.eq(isRead)); + } + List
articles = queryFactory .selectFrom(article) .join(article.user, user).fetchJoin() diff --git a/infrastructure/src/main/java/com/pinback/infrastructure/article/service/ArticleGetService.java b/infrastructure/src/main/java/com/pinback/infrastructure/article/service/ArticleGetService.java index b344a149..4083256f 100644 --- a/infrastructure/src/main/java/com/pinback/infrastructure/article/service/ArticleGetService.java +++ b/infrastructure/src/main/java/com/pinback/infrastructure/article/service/ArticleGetService.java @@ -70,8 +70,8 @@ public Article findByUserAndId(User user, long articleId) { } @Override - public Page
findTodayRemind(User user, LocalDateTime remindDateTime, Pageable pageable) { - return articleRepository.findTodayRemind(user.getId(), pageable, remindDateTime, remindDateTime.plusDays(1)); + public Page
findTodayRemind(User user, LocalDateTime remindDateTime, Pageable pageable, Boolean isRead) { + return articleRepository.findTodayRemind(user.getId(), pageable, remindDateTime, remindDateTime.plusDays(1), isRead); } private ArticlesWithUnreadCountDto convertToDto(ArticlesWithUnreadCount infraResult) { diff --git a/infrastructure/src/test/java/com/pinback/infrastructure/article/service/ArticleGetServiceTest.java b/infrastructure/src/test/java/com/pinback/infrastructure/article/service/ArticleGetServiceTest.java index 6ef9d0c6..afdc0c71 100644 --- a/infrastructure/src/test/java/com/pinback/infrastructure/article/service/ArticleGetServiceTest.java +++ b/infrastructure/src/test/java/com/pinback/infrastructure/article/service/ArticleGetServiceTest.java @@ -151,7 +151,7 @@ void findTodayRemindTest() { PageRequest pageRequest = PageRequest.of(0, 10); //when - Page
result = articleGetService.findTodayRemind(user, today, pageRequest); + Page
result = articleGetService.findTodayRemind(user, today, pageRequest, null); //then assertThat(result.getContent()).hasSize(1); From d78e8076da8f45d8884983bca7f08e5fd1fc85ee Mon Sep 17 00:00:00 2001 From: rootTiket Date: Wed, 3 Sep 2025 19:24:31 +0900 Subject: [PATCH 19/21] =?UTF-8?q?feat:=20=EB=A6=AC=EB=A7=88=EC=9D=B8?= =?UTF-8?q?=EB=93=9C=20=EC=95=84=ED=8B=B0=ED=81=B4=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=8B=9C=20=EC=9D=BD=EC=9D=80=20=EC=95=84=ED=8B=B0=ED=81=B4=20?= =?UTF-8?q?=EC=88=98=EC=99=80=20=EC=9D=BD=EC=A7=80=20=EC=95=8A=EC=9D=80=20?= =?UTF-8?q?=EC=95=84=ED=8B=B0=ED=81=B4=20=EC=88=98=EB=A5=BC=20=ED=95=A8?= =?UTF-8?q?=EA=BB=98=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/RemindArticlesWithCountDto.java | 12 ++++++ .../dto/response/RemindArticleResponse.java | 28 +++++++++++++ .../dto/response/TodayRemindResponse.java | 13 ++++++ .../article/port/in/GetArticlePort.java | 3 +- .../port/out/ArticleGetServicePort.java | 3 ++ .../usecase/query/GetArticleUsecase.java | 21 +++++----- .../usecase/query/GetArticleUsecaseTest.java | 4 +- .../repository/ArticleRepositoryCustom.java | 3 ++ .../ArticleRepositoryCustomImpl.java | 42 +++++++++++++++++++ .../dto/RemindArticlesWithCount.java | 12 ++++++ .../article/service/ArticleGetService.java | 12 ++++++ 11 files changed, 140 insertions(+), 13 deletions(-) create mode 100644 application/src/main/java/com/pinback/application/article/dto/RemindArticlesWithCountDto.java create mode 100644 application/src/main/java/com/pinback/application/article/dto/response/RemindArticleResponse.java create mode 100644 application/src/main/java/com/pinback/application/article/dto/response/TodayRemindResponse.java create mode 100644 infrastructure/src/main/java/com/pinback/infrastructure/article/repository/dto/RemindArticlesWithCount.java diff --git a/application/src/main/java/com/pinback/application/article/dto/RemindArticlesWithCountDto.java b/application/src/main/java/com/pinback/application/article/dto/RemindArticlesWithCountDto.java new file mode 100644 index 00000000..d4f7a729 --- /dev/null +++ b/application/src/main/java/com/pinback/application/article/dto/RemindArticlesWithCountDto.java @@ -0,0 +1,12 @@ +package com.pinback.application.article.dto; + +import org.springframework.data.domain.Page; + +import com.pinback.domain.article.entity.Article; + +public record RemindArticlesWithCountDto( + long readCount, + long unreadCount, + Page
articles +) { +} \ No newline at end of file diff --git a/application/src/main/java/com/pinback/application/article/dto/response/RemindArticleResponse.java b/application/src/main/java/com/pinback/application/article/dto/response/RemindArticleResponse.java new file mode 100644 index 00000000..fe9a59e7 --- /dev/null +++ b/application/src/main/java/com/pinback/application/article/dto/response/RemindArticleResponse.java @@ -0,0 +1,28 @@ +package com.pinback.application.article.dto.response; + +import java.time.LocalDateTime; + +import com.pinback.application.category.dto.response.CategoryResponse; +import com.pinback.domain.article.entity.Article; + +public record RemindArticleResponse( + long articleId, + String url, + String memo, + LocalDateTime createdAt, + boolean isRead, + LocalDateTime remindAt, + CategoryResponse category +) { + public static RemindArticleResponse from(Article article) { + return new RemindArticleResponse( + article.getId(), + article.getUrl(), + article.getMemo(), + article.getCreatedAt(), + article.isRead(), + article.getRemindAt(), + CategoryResponse.from(article.getCategory()) + ); + } +} \ No newline at end of file diff --git a/application/src/main/java/com/pinback/application/article/dto/response/TodayRemindResponse.java b/application/src/main/java/com/pinback/application/article/dto/response/TodayRemindResponse.java new file mode 100644 index 00000000..29effbde --- /dev/null +++ b/application/src/main/java/com/pinback/application/article/dto/response/TodayRemindResponse.java @@ -0,0 +1,13 @@ +package com.pinback.application.article.dto.response; + +import java.util.List; + +public record TodayRemindResponse( + long readArticleCount, + long unreadArticleCount, + List articles +) { + public static TodayRemindResponse of(long readArticleCount, long unreadArticleCount, List articles) { + return new TodayRemindResponse(readArticleCount, unreadArticleCount, articles); + } +} \ No newline at end of file diff --git a/application/src/main/java/com/pinback/application/article/port/in/GetArticlePort.java b/application/src/main/java/com/pinback/application/article/port/in/GetArticlePort.java index 3cccaf90..d3f9fa0a 100644 --- a/application/src/main/java/com/pinback/application/article/port/in/GetArticlePort.java +++ b/application/src/main/java/com/pinback/application/article/port/in/GetArticlePort.java @@ -6,6 +6,7 @@ import com.pinback.application.article.dto.response.ArticleDetailResponse; import com.pinback.application.article.dto.response.ArticlesPageResponse; import com.pinback.application.article.dto.response.RemindArticlesResponse; +import com.pinback.application.article.dto.response.TodayRemindResponse; import com.pinback.domain.user.entity.User; public interface GetArticlePort { @@ -19,5 +20,5 @@ public interface GetArticlePort { ArticlesPageResponse getUnreadArticles(User user, PageQuery query); - RemindArticlesResponse getRemindArticles(User user, LocalDateTime now, PageQuery query); + TodayRemindResponse getRemindArticles(User user, LocalDateTime now, boolean readStatus, PageQuery query); } diff --git a/application/src/main/java/com/pinback/application/article/port/out/ArticleGetServicePort.java b/application/src/main/java/com/pinback/application/article/port/out/ArticleGetServicePort.java index 323153ae..cfa05e68 100644 --- a/application/src/main/java/com/pinback/application/article/port/out/ArticleGetServicePort.java +++ b/application/src/main/java/com/pinback/application/article/port/out/ArticleGetServicePort.java @@ -8,6 +8,7 @@ import org.springframework.data.domain.Pageable; import com.pinback.application.article.dto.ArticlesWithUnreadCountDto; +import com.pinback.application.article.dto.RemindArticlesWithCountDto; import com.pinback.domain.article.entity.Article; import com.pinback.domain.category.entity.Category; import com.pinback.domain.user.entity.User; @@ -30,4 +31,6 @@ public interface ArticleGetServicePort { ArticlesWithUnreadCountDto findUnreadArticles(User user, PageRequest pageRequest); Page
findTodayRemind(User user, LocalDateTime remindDateTime, Pageable pageable, Boolean isRead); + + RemindArticlesWithCountDto findTodayRemindWithCount(User user, LocalDateTime remindDateTime, Pageable pageable, Boolean isRead); } diff --git a/application/src/main/java/com/pinback/application/article/usecase/query/GetArticleUsecase.java b/application/src/main/java/com/pinback/application/article/usecase/query/GetArticleUsecase.java index 1f0f47de..226485a8 100644 --- a/application/src/main/java/com/pinback/application/article/usecase/query/GetArticleUsecase.java +++ b/application/src/main/java/com/pinback/application/article/usecase/query/GetArticleUsecase.java @@ -5,17 +5,18 @@ import java.util.List; import java.util.Optional; -import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.pinback.application.article.dto.ArticlesWithUnreadCountDto; +import com.pinback.application.article.dto.RemindArticlesWithCountDto; import com.pinback.application.article.dto.query.PageQuery; import com.pinback.application.article.dto.response.ArticleDetailResponse; import com.pinback.application.article.dto.response.ArticleResponse; import com.pinback.application.article.dto.response.ArticlesPageResponse; -import com.pinback.application.article.dto.response.RemindArticlesResponse; +import com.pinback.application.article.dto.response.RemindArticleResponse; +import com.pinback.application.article.dto.response.TodayRemindResponse; import com.pinback.application.article.port.in.GetArticlePort; import com.pinback.application.article.port.out.ArticleGetServicePort; import com.pinback.application.category.port.in.GetCategoryPort; @@ -96,19 +97,19 @@ public ArticlesPageResponse getUnreadArticles(User user, PageQuery query) { } @Override - public RemindArticlesResponse getRemindArticles(User user, LocalDateTime now, PageQuery query) { + public TodayRemindResponse getRemindArticles(User user, LocalDateTime now, boolean readStatus, PageQuery query) { LocalDateTime remindDateTime = getRemindDateTime(now, user.getRemindDefault()); - Page
articles = articleGetServicePort.findTodayRemind( - user, remindDateTime, PageRequest.of(query.pageNumber(), query.pageSize()), null); + RemindArticlesWithCountDto result = articleGetServicePort.findTodayRemindWithCount( + user, remindDateTime, PageRequest.of(query.pageNumber(), query.pageSize()), readStatus); - List articleResponses = articles.stream() - .map(ArticleResponse::from) + List articleResponses = result.articles().stream() + .map(RemindArticleResponse::from) .toList(); - return RemindArticlesResponse.of( - articles.getTotalElements(), - remindDateTime.plusDays(1), + return TodayRemindResponse.of( + result.readCount(), + result.unreadCount(), articleResponses ); } diff --git a/application/src/test/java/com/pinback/application/article/usecase/query/GetArticleUsecaseTest.java b/application/src/test/java/com/pinback/application/article/usecase/query/GetArticleUsecaseTest.java index 8c6eb63e..b62aac0e 100644 --- a/application/src/test/java/com/pinback/application/article/usecase/query/GetArticleUsecaseTest.java +++ b/application/src/test/java/com/pinback/application/article/usecase/query/GetArticleUsecaseTest.java @@ -24,7 +24,7 @@ import com.pinback.application.article.dto.query.PageQuery; import com.pinback.application.article.dto.response.ArticleDetailResponse; import com.pinback.application.article.dto.response.ArticlesPageResponse; -import com.pinback.application.article.dto.response.RemindArticlesResponse; +import com.pinback.application.article.dto.response.TodayRemindResponse; import com.pinback.application.article.port.out.ArticleGetServicePort; import com.pinback.application.category.port.in.GetCategoryPort; import com.pinback.domain.article.entity.Article; @@ -189,7 +189,7 @@ void getRemindArticles_Success() { articlePage); // when - RemindArticlesResponse response = getArticleUsecase.getRemindArticles(userWithRemindTime, now, pageQuery); + TodayRemindResponse response = getArticleUsecase.getRemindArticles(userWithRemindTime, now, true, pageQuery); // then assertThat(response.articles()).hasSize(2); diff --git a/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustom.java b/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustom.java index 81ec9b22..fafedce0 100644 --- a/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustom.java +++ b/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustom.java @@ -8,6 +8,7 @@ import com.pinback.domain.article.entity.Article; import com.pinback.infrastructure.article.repository.dto.ArticlesWithUnreadCount; +import com.pinback.infrastructure.article.repository.dto.RemindArticlesWithCount; public interface ArticleRepositoryCustom { ArticlesWithUnreadCount findAllCustom(UUID userId, Pageable pageable); @@ -15,6 +16,8 @@ public interface ArticleRepositoryCustom { ArticlesWithUnreadCount findAllByCategory(UUID userId, long articleId, Pageable pageable); Page
findTodayRemind(UUID userId, Pageable pageable, LocalDateTime startAt, LocalDateTime endAt, Boolean isRead); + + RemindArticlesWithCount findTodayRemindWithCount(UUID userId, Pageable pageable, LocalDateTime startAt, LocalDateTime endAt, Boolean isRead); ArticlesWithUnreadCount findAllByIsReadFalse(UUID userId, Pageable pageable); diff --git a/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustomImpl.java b/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustomImpl.java index 442f9841..a1dc72d7 100644 --- a/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustomImpl.java +++ b/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/ArticleRepositoryCustomImpl.java @@ -15,6 +15,7 @@ import com.pinback.domain.article.entity.Article; import com.pinback.infrastructure.article.repository.dto.ArticlesWithUnreadCount; +import com.pinback.infrastructure.article.repository.dto.RemindArticlesWithCount; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -140,6 +141,47 @@ public ArticlesWithUnreadCount findAllByIsReadFalse(UUID userId, Pageable pageab PageableExecutionUtils.getPage(articles, pageable, countQuery::fetchOne)); } + @Override + public RemindArticlesWithCount findTodayRemindWithCount(UUID userId, Pageable pageable, LocalDateTime startAt, + LocalDateTime endAt, Boolean isRead) { + BooleanExpression baseConditions = article.user.id.eq(userId) + .and(article.remindAt.goe(startAt).and(article.remindAt.loe(endAt))); + + BooleanExpression conditions = baseConditions.and(article.isRead.eq(isRead)); + + List
articles = queryFactory + .selectFrom(article) + .join(article.user, user).fetchJoin() + .where(conditions) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(article.createdAt.desc()) + .fetch(); + + JPAQuery countQuery = queryFactory + .select(article.count()) + .from(article) + .where(conditions); + + Long readCount = queryFactory + .select(article.count()) + .from(article) + .where(baseConditions.and(article.isRead.isTrue())) + .fetchOne(); + + Long unreadCount = queryFactory + .select(article.count()) + .from(article) + .where(baseConditions.and(article.isRead.isFalse())) + .fetchOne(); + + return new RemindArticlesWithCount( + readCount, + unreadCount, + PageableExecutionUtils.getPage(articles, pageable, countQuery::fetchOne) + ); + } + @Override public void deleteArticlesByUserIdAndCategoryId(UUID userId, long categoryId) { BooleanExpression conditions = article.user.id.eq(userId).and(article.category.id.eq(categoryId)); diff --git a/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/dto/RemindArticlesWithCount.java b/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/dto/RemindArticlesWithCount.java new file mode 100644 index 00000000..5d800469 --- /dev/null +++ b/infrastructure/src/main/java/com/pinback/infrastructure/article/repository/dto/RemindArticlesWithCount.java @@ -0,0 +1,12 @@ +package com.pinback.infrastructure.article.repository.dto; + +import org.springframework.data.domain.Page; + +import com.pinback.domain.article.entity.Article; + +public record RemindArticlesWithCount( + long readCount, + long unreadCount, + Page
articles +) { +} \ No newline at end of file diff --git a/infrastructure/src/main/java/com/pinback/infrastructure/article/service/ArticleGetService.java b/infrastructure/src/main/java/com/pinback/infrastructure/article/service/ArticleGetService.java index 4083256f..508b445e 100644 --- a/infrastructure/src/main/java/com/pinback/infrastructure/article/service/ArticleGetService.java +++ b/infrastructure/src/main/java/com/pinback/infrastructure/article/service/ArticleGetService.java @@ -9,6 +9,7 @@ import org.springframework.stereotype.Service; import com.pinback.application.article.dto.ArticlesWithUnreadCountDto; +import com.pinback.application.article.dto.RemindArticlesWithCountDto; import com.pinback.application.article.port.out.ArticleGetServicePort; import com.pinback.application.common.exception.ArticleNotFoundException; import com.pinback.domain.article.entity.Article; @@ -16,6 +17,7 @@ import com.pinback.domain.user.entity.User; import com.pinback.infrastructure.article.repository.ArticleRepository; import com.pinback.infrastructure.article.repository.dto.ArticlesWithUnreadCount; +import com.pinback.infrastructure.article.repository.dto.RemindArticlesWithCount; import lombok.RequiredArgsConstructor; @@ -74,6 +76,16 @@ public Page
findTodayRemind(User user, LocalDateTime remindDateTime, Pa return articleRepository.findTodayRemind(user.getId(), pageable, remindDateTime, remindDateTime.plusDays(1), isRead); } + @Override + public RemindArticlesWithCountDto findTodayRemindWithCount(User user, LocalDateTime remindDateTime, Pageable pageable, Boolean isRead) { + RemindArticlesWithCount infraResult = articleRepository.findTodayRemindWithCount(user.getId(), pageable, remindDateTime, remindDateTime.plusDays(1), isRead); + return new RemindArticlesWithCountDto( + infraResult.readCount(), + infraResult.unreadCount(), + infraResult.articles() + ); + } + private ArticlesWithUnreadCountDto convertToDto(ArticlesWithUnreadCount infraResult) { return new ArticlesWithUnreadCountDto( infraResult.unReadCount(), From 06d8bfdb35c6509f5ca7612379e0c64a3d7ad11b Mon Sep 17 00:00:00 2001 From: rootTiket Date: Wed, 3 Sep 2025 19:29:20 +0900 Subject: [PATCH 20/21] =?UTF-8?q?test:=20=EB=A6=AC=EB=A7=88=EC=9D=B8?= =?UTF-8?q?=EB=93=9C=20=EC=95=84=ED=8B=B0=ED=81=B4=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/query/GetArticleUsecaseTest.java | 41 ++++++++-- .../service/ArticleGetServiceTest.java | 76 +++++++++++++++++++ .../infrastructure/fixture/TestFixture.java | 6 ++ 3 files changed, 118 insertions(+), 5 deletions(-) diff --git a/application/src/test/java/com/pinback/application/article/usecase/query/GetArticleUsecaseTest.java b/application/src/test/java/com/pinback/application/article/usecase/query/GetArticleUsecaseTest.java index b62aac0e..e0bbbcab 100644 --- a/application/src/test/java/com/pinback/application/article/usecase/query/GetArticleUsecaseTest.java +++ b/application/src/test/java/com/pinback/application/article/usecase/query/GetArticleUsecaseTest.java @@ -21,6 +21,7 @@ import com.pinback.application.ApplicationTestBase; import com.pinback.application.article.dto.ArticlesWithUnreadCountDto; +import com.pinback.application.article.dto.RemindArticlesWithCountDto; import com.pinback.application.article.dto.query.PageQuery; import com.pinback.application.article.dto.response.ArticleDetailResponse; import com.pinback.application.article.dto.response.ArticlesPageResponse; @@ -170,9 +171,9 @@ void getUnreadArticles_Success() { verify(articleGetServicePort).findUnreadArticles(user, pageRequest); } - @DisplayName("오늘 리마인드할 아티클을 조회할 수 있다") + @DisplayName("오늘 리마인드할 읽은 아티클을 조회할 수 있다") @Test - void getRemindArticles_Success() { + void getRemindArticles_ReadStatus_Success() { // given User userWithRemindTime = User.create("test@example.com", LocalTime.of(14, 0)); ReflectionTestUtils.setField(userWithRemindTime, "id", java.util.UUID.randomUUID()); @@ -184,15 +185,45 @@ void getRemindArticles_Success() { List
articles = List.of(article, article); Page
articlePage = new PageImpl<>(articles, pageRequest, 2); + RemindArticlesWithCountDto dto = new RemindArticlesWithCountDto(2L, 3L, articlePage); - when(articleGetServicePort.findTodayRemind(userWithRemindTime, todayRemindTime, pageRequest, null)).thenReturn( - articlePage); + when(articleGetServicePort.findTodayRemindWithCount(userWithRemindTime, todayRemindTime, pageRequest, true)).thenReturn(dto); // when TodayRemindResponse response = getArticleUsecase.getRemindArticles(userWithRemindTime, now, true, pageQuery); // then assertThat(response.articles()).hasSize(2); - verify(articleGetServicePort).findTodayRemind(userWithRemindTime, todayRemindTime, pageRequest, null); + assertThat(response.readArticleCount()).isEqualTo(2L); + assertThat(response.unreadArticleCount()).isEqualTo(3L); + verify(articleGetServicePort).findTodayRemindWithCount(userWithRemindTime, todayRemindTime, pageRequest, true); + } + + @DisplayName("오늘 리마인드할 안읽은 아티클을 조회할 수 있다") + @Test + void getRemindArticles_UnreadStatus_Success() { + // given + User userWithRemindTime = User.create("test@example.com", LocalTime.of(14, 0)); + ReflectionTestUtils.setField(userWithRemindTime, "id", java.util.UUID.randomUUID()); + + LocalDateTime now = LocalDateTime.of(2025, 8, 20, 15, 0); + LocalDateTime todayRemindTime = LocalDateTime.of(2025, 8, 20, 14, 0); + PageQuery pageQuery = new PageQuery(0, 10); + PageRequest pageRequest = PageRequest.of(0, 10); + + List
articles = List.of(article, article, article); + Page
articlePage = new PageImpl<>(articles, pageRequest, 3); + RemindArticlesWithCountDto dto = new RemindArticlesWithCountDto(2L, 3L, articlePage); + + when(articleGetServicePort.findTodayRemindWithCount(userWithRemindTime, todayRemindTime, pageRequest, false)).thenReturn(dto); + + // when + TodayRemindResponse response = getArticleUsecase.getRemindArticles(userWithRemindTime, now, false, pageQuery); + + // then + assertThat(response.articles()).hasSize(3); + assertThat(response.readArticleCount()).isEqualTo(2L); + assertThat(response.unreadArticleCount()).isEqualTo(3L); + verify(articleGetServicePort).findTodayRemindWithCount(userWithRemindTime, todayRemindTime, pageRequest, false); } } diff --git a/infrastructure/src/test/java/com/pinback/infrastructure/article/service/ArticleGetServiceTest.java b/infrastructure/src/test/java/com/pinback/infrastructure/article/service/ArticleGetServiceTest.java index afdc0c71..97ab973b 100644 --- a/infrastructure/src/test/java/com/pinback/infrastructure/article/service/ArticleGetServiceTest.java +++ b/infrastructure/src/test/java/com/pinback/infrastructure/article/service/ArticleGetServiceTest.java @@ -16,6 +16,7 @@ import org.springframework.transaction.annotation.Transactional; import com.pinback.application.article.dto.ArticlesWithUnreadCountDto; +import com.pinback.application.article.dto.RemindArticlesWithCountDto; import com.pinback.application.common.exception.ArticleNotFoundException; import com.pinback.domain.article.entity.Article; import com.pinback.domain.category.entity.Category; @@ -239,4 +240,79 @@ void findUnreadArticlesTest() { assertThat(result.article().getContent()).hasSize(1); assertThat(result.article().getContent().get(0).isRead()).isFalse(); } + + @DisplayName("오늘 리마인드할 읽은 아티클을 조회하면 읽은 아티클과 전체 카운트를 반환한다.") + @Test + void findTodayRemindWithCount_ReadStatus_Test() { + //given + User user = userRepository.save(user()); + Category category = categoryRepository.save(category(user)); + LocalDateTime today = LocalDateTime.of(2025, 8, 18, 12, 0, 0); + LocalDateTime yesterday = today.minusDays(1); + + // 오늘 리마인드할 아티클들 (읽음/안읽음) + articleRepository.save(readArticleWithDate(user, "read-url1", category, today)); + articleRepository.save(readArticleWithDate(user, "read-url2", category, today)); + articleRepository.save(articleWithDate(user, "unread-url1", category, today)); + articleRepository.save(articleWithDate(user, "unread-url2", category, today)); + articleRepository.save(articleWithDate(user, "unread-url3", category, today)); + + // 어제 리마인드 (카운트에 포함되지 않음) + articleRepository.save(articleWithDate(user, "yesterday-url", category, yesterday)); + + PageRequest pageRequest = PageRequest.of(0, 10); + + //when + RemindArticlesWithCountDto result = articleGetService.findTodayRemindWithCount(user, today, pageRequest, true); + + //then + assertThat(result.articles().getContent()).hasSize(2); // 읽은 아티클 2개 + assertThat(result.readCount()).isEqualTo(2L); + assertThat(result.unreadCount()).isEqualTo(3L); + assertThat(result.articles().getContent()).allMatch(Article::isRead); + } + + @DisplayName("오늘 리마인드할 안읽은 아티클을 조회하면 안읽은 아티클과 전체 카운트를 반환한다.") + @Test + void findTodayRemindWithCount_UnreadStatus_Test() { + //given + User user = userRepository.save(user()); + Category category = categoryRepository.save(category(user)); + LocalDateTime today = LocalDateTime.of(2025, 8, 18, 12, 0, 0); + + // 오늘 리마인드할 아티클들 + articleRepository.save(readArticleWithDate(user, "read-url1", category, today)); + articleRepository.save(readArticleWithDate(user, "read-url2", category, today)); + articleRepository.save(articleWithDate(user, "unread-url1", category, today)); + articleRepository.save(articleWithDate(user, "unread-url2", category, today)); + articleRepository.save(articleWithDate(user, "unread-url3", category, today)); + + PageRequest pageRequest = PageRequest.of(0, 10); + + //when + RemindArticlesWithCountDto result = articleGetService.findTodayRemindWithCount(user, today, pageRequest, false); + + //then + assertThat(result.articles().getContent()).hasSize(3); // 안읽은 아티클 3개 + assertThat(result.readCount()).isEqualTo(2L); + assertThat(result.unreadCount()).isEqualTo(3L); + assertThat(result.articles().getContent()).allMatch(article -> !article.isRead()); + } + + @DisplayName("오늘 리마인드할 아티클이 없으면 카운트는 0을 반환한다.") + @Test + void findTodayRemindWithCount_NoArticles_Test() { + //given + User user = userRepository.save(user()); + LocalDateTime today = LocalDateTime.of(2025, 8, 18, 12, 0, 0); + PageRequest pageRequest = PageRequest.of(0, 10); + + //when + RemindArticlesWithCountDto result = articleGetService.findTodayRemindWithCount(user, today, pageRequest, true); + + //then + assertThat(result.articles().getContent()).isEmpty(); + assertThat(result.readCount()).isEqualTo(0L); + assertThat(result.unreadCount()).isEqualTo(0L); + } } diff --git a/infrastructure/src/test/java/com/pinback/infrastructure/fixture/TestFixture.java b/infrastructure/src/test/java/com/pinback/infrastructure/fixture/TestFixture.java index 005c8040..f149fa80 100644 --- a/infrastructure/src/test/java/com/pinback/infrastructure/fixture/TestFixture.java +++ b/infrastructure/src/test/java/com/pinback/infrastructure/fixture/TestFixture.java @@ -46,6 +46,12 @@ public static Article readArticle(User user, String url, Category category) { return article; } + public static Article readArticleWithDate(User user, String url, Category category, LocalDateTime remindAt) { + Article article = Article.create(url, "testmemo", user, category, remindAt); + article.markAsRead(); + return article; + } + public static PushSubscription pushSubscription(User user) { return PushSubscription.create( user, "testToken" From db74d5e3feb3b29ef093727c434c57ad2d4dfa1f Mon Sep 17 00:00:00 2001 From: rootTiket Date: Wed, 3 Sep 2025 19:38:30 +0900 Subject: [PATCH 21/21] =?UTF-8?q?feat:=20=EB=A6=AC=EB=A7=88=EC=9D=B8?= =?UTF-8?q?=EB=93=9C=20=EC=95=84=ED=8B=B0=ED=81=B4=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article/controller/ArticleController.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/api/src/main/java/com/pinback/api/article/controller/ArticleController.java b/api/src/main/java/com/pinback/api/article/controller/ArticleController.java index 8ce0007b..176aa4ce 100644 --- a/api/src/main/java/com/pinback/api/article/controller/ArticleController.java +++ b/api/src/main/java/com/pinback/api/article/controller/ArticleController.java @@ -1,5 +1,7 @@ package com.pinback.api.article.controller; +import java.time.LocalDateTime; + import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -16,6 +18,7 @@ import com.pinback.application.article.dto.response.ArticleDetailResponse; import com.pinback.application.article.dto.response.ArticlesPageResponse; import com.pinback.application.article.dto.response.ReadArticleResponse; +import com.pinback.application.article.dto.response.TodayRemindResponse; import com.pinback.application.article.port.in.CreateArticlePort; import com.pinback.application.article.port.in.DeleteArticlePort; import com.pinback.application.article.port.in.GetArticlePort; @@ -130,6 +133,20 @@ public ResponseDto updateArticleStatus( return ResponseDto.ok(response); } + @Operation(summary = "리마인드 아티클 조회", description = "오늘 리마인드할 아티클을 읽음/안읽음 상태별로 조회합니다") + @GetMapping("/remind") + public ResponseDto getRemindArticles( + @Parameter(hidden = true) @CurrentUser User user, + @Parameter(description = "현재 시간", example = "2025-09-03T10:00:00") @RequestParam LocalDateTime now, + @Parameter(description = "읽음 상태 (true: 읽음, false: 안읽음)", example = "true") @RequestParam(name = "read-status") boolean readStatus, + @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "8") int size + ) { + PageQuery query = new PageQuery(page, size); + TodayRemindResponse response = getArticlePort.getRemindArticles(user, now, readStatus, query); + return ResponseDto.ok(response); + } + @Operation(summary = "아티클 삭제", description = "아티클을 삭제합니다") @DeleteMapping("/{articleId}") public ResponseDto deleteArticle(