diff --git a/src/main/java/com/pinback/pinback_server/domain/article/application/ArticleManagementUsecase.java b/src/main/java/com/pinback/pinback_server/domain/article/application/ArticleManagementUsecase.java new file mode 100644 index 00000000..a462d4d8 --- /dev/null +++ b/src/main/java/com/pinback/pinback_server/domain/article/application/ArticleManagementUsecase.java @@ -0,0 +1,36 @@ +package com.pinback.pinback_server.domain.article.application; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.pinback.pinback_server.domain.article.application.command.ArticleCreateCommand; +import com.pinback.pinback_server.domain.article.domain.entity.Article; +import com.pinback.pinback_server.domain.article.domain.service.ArticleGetService; +import com.pinback.pinback_server.domain.article.domain.service.ArticleSaveService; +import com.pinback.pinback_server.domain.article.exception.ArticleAlreadyExistException; +import com.pinback.pinback_server.domain.category.domain.entity.Category; +import com.pinback.pinback_server.domain.category.domain.service.CategoryGetService; +import com.pinback.pinback_server.domain.user.domain.entity.User; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ArticleManagementUsecase { + + private final CategoryGetService categoryGetService; + private final ArticleSaveService articleSaveService; + private final ArticleGetService articleGetService; + + //TODO: 리마인드 로직 추가 필요 + @Transactional + public void createArticle(User user, ArticleCreateCommand command) { + if (articleGetService.checkExistsByUserAndUrl(user, command.url())) { + throw new ArticleAlreadyExistException(); + } + Category category = categoryGetService.getCategoryAndUser(command.categoryId(), user); + Article article = Article.create(command.url(), command.memo(), user, category); + articleSaveService.save(article); + } +} diff --git a/src/main/java/com/pinback/pinback_server/domain/article/application/command/ArticleCreateCommand.java b/src/main/java/com/pinback/pinback_server/domain/article/application/command/ArticleCreateCommand.java new file mode 100644 index 00000000..f44ea4f2 --- /dev/null +++ b/src/main/java/com/pinback/pinback_server/domain/article/application/command/ArticleCreateCommand.java @@ -0,0 +1,11 @@ +package com.pinback.pinback_server.domain.article.application.command; + +import java.time.LocalDateTime; + +public record ArticleCreateCommand( + String url, + long categoryId, + String memo, + LocalDateTime remindTime +) { +} diff --git a/src/main/java/com/pinback/pinback_server/domain/article/domain/entity/Article.java b/src/main/java/com/pinback/pinback_server/domain/article/domain/entity/Article.java index 2c3b3472..578534b8 100644 --- a/src/main/java/com/pinback/pinback_server/domain/article/domain/entity/Article.java +++ b/src/main/java/com/pinback/pinback_server/domain/article/domain/entity/Article.java @@ -13,6 +13,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; @@ -21,7 +22,10 @@ @Getter @Entity -@Table(name = "article") +@Table(name = "article", uniqueConstraints = +@UniqueConstraint( + columnNames = {"user_id", "url"} +)) @Builder(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor diff --git a/src/main/java/com/pinback/pinback_server/domain/article/domain/repository/ArticleRepository.java b/src/main/java/com/pinback/pinback_server/domain/article/domain/repository/ArticleRepository.java index 6e777a59..4df86d93 100644 --- a/src/main/java/com/pinback/pinback_server/domain/article/domain/repository/ArticleRepository.java +++ b/src/main/java/com/pinback/pinback_server/domain/article/domain/repository/ArticleRepository.java @@ -4,7 +4,10 @@ import org.springframework.stereotype.Repository; import com.pinback.pinback_server.domain.article.domain.entity.Article; +import com.pinback.pinback_server.domain.user.domain.entity.User; @Repository public interface ArticleRepository extends JpaRepository { + + boolean existsByUserAndUrl(User user, String url); } diff --git a/src/main/java/com/pinback/pinback_server/domain/article/domain/service/ArticleGetService.java b/src/main/java/com/pinback/pinback_server/domain/article/domain/service/ArticleGetService.java new file mode 100644 index 00000000..b79bc453 --- /dev/null +++ b/src/main/java/com/pinback/pinback_server/domain/article/domain/service/ArticleGetService.java @@ -0,0 +1,20 @@ +package com.pinback.pinback_server.domain.article.domain.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.pinback.pinback_server.domain.article.domain.repository.ArticleRepository; +import com.pinback.pinback_server.domain.user.domain.entity.User; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ArticleGetService { + private final ArticleRepository articleRepository; + + public boolean checkExistsByUserAndUrl(User user, String url) { + return articleRepository.existsByUserAndUrl(user, url); + } +} diff --git a/src/main/java/com/pinback/pinback_server/domain/article/domain/service/ArticleSaveService.java b/src/main/java/com/pinback/pinback_server/domain/article/domain/service/ArticleSaveService.java new file mode 100644 index 00000000..6de093d3 --- /dev/null +++ b/src/main/java/com/pinback/pinback_server/domain/article/domain/service/ArticleSaveService.java @@ -0,0 +1,20 @@ +package com.pinback.pinback_server.domain.article.domain.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.pinback.pinback_server.domain.article.domain.entity.Article; +import com.pinback.pinback_server.domain.article.domain.repository.ArticleRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class ArticleSaveService { + private final ArticleRepository articleRepository; + + public void save(Article article) { + articleRepository.save(article); + } +} diff --git a/src/main/java/com/pinback/pinback_server/domain/article/exception/ArticleAlreadyExistException.java b/src/main/java/com/pinback/pinback_server/domain/article/exception/ArticleAlreadyExistException.java new file mode 100644 index 00000000..7cd51830 --- /dev/null +++ b/src/main/java/com/pinback/pinback_server/domain/article/exception/ArticleAlreadyExistException.java @@ -0,0 +1,10 @@ +package com.pinback.pinback_server.domain.article.exception; + +import com.pinback.pinback_server.global.exception.ApplicationException; +import com.pinback.pinback_server.global.exception.constant.ExceptionCode; + +public class ArticleAlreadyExistException extends ApplicationException { + public ArticleAlreadyExistException() { + super(ExceptionCode.ARTICLE_ALREADY_EXIST); + } +} diff --git a/src/main/java/com/pinback/pinback_server/domain/article/presentation/ArticleController.java b/src/main/java/com/pinback/pinback_server/domain/article/presentation/ArticleController.java new file mode 100644 index 00000000..d5e540ff --- /dev/null +++ b/src/main/java/com/pinback/pinback_server/domain/article/presentation/ArticleController.java @@ -0,0 +1,28 @@ +package com.pinback.pinback_server.domain.article.presentation; + +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.RestController; + +import com.pinback.pinback_server.domain.article.application.ArticleManagementUsecase; +import com.pinback.pinback_server.domain.article.presentation.dto.request.ArticleCreateRequest; +import com.pinback.pinback_server.domain.user.domain.entity.User; +import com.pinback.pinback_server.global.common.annotation.CurrentUser; +import com.pinback.pinback_server.global.common.dto.ResponseDto; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/article") +@RequiredArgsConstructor +public class ArticleController { + private final ArticleManagementUsecase articleManagementUsecase; + + @PostMapping + public ResponseDto createArticle(@CurrentUser User user, @Valid @RequestBody ArticleCreateRequest request) { + articleManagementUsecase.createArticle(user, request.toCommand()); + return ResponseDto.created(); + } +} diff --git a/src/main/java/com/pinback/pinback_server/domain/article/presentation/dto/request/ArticleCreateRequest.java b/src/main/java/com/pinback/pinback_server/domain/article/presentation/dto/request/ArticleCreateRequest.java new file mode 100644 index 00000000..455ef9d7 --- /dev/null +++ b/src/main/java/com/pinback/pinback_server/domain/article/presentation/dto/request/ArticleCreateRequest.java @@ -0,0 +1,30 @@ +package com.pinback.pinback_server.domain.article.presentation.dto.request; + +import java.time.LocalDateTime; + +import com.pinback.pinback_server.domain.article.application.command.ArticleCreateCommand; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +public record ArticleCreateRequest( + @NotEmpty(message = "url을 비어있을 수 없습니다.") + String url, + + @NotNull(message = "카테고리 ID는 비어있을 수 없습니다.") + Long categoryId, + + String memo, + + @NotNull(message = "리마인드 날짜는 비어있을 수 없습니다.") + LocalDateTime remindTime +) { + public ArticleCreateCommand toCommand() { + return new ArticleCreateCommand( + url, + categoryId, + memo, + remindTime + ); + } +} diff --git a/src/main/java/com/pinback/pinback_server/domain/category/domain/repository/CategoryRepository.java b/src/main/java/com/pinback/pinback_server/domain/category/domain/repository/CategoryRepository.java index 751a3398..5dd78295 100644 --- a/src/main/java/com/pinback/pinback_server/domain/category/domain/repository/CategoryRepository.java +++ b/src/main/java/com/pinback/pinback_server/domain/category/domain/repository/CategoryRepository.java @@ -1,10 +1,14 @@ package com.pinback.pinback_server.domain.category.domain.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import com.pinback.pinback_server.domain.category.domain.entity.Category; +import com.pinback.pinback_server.domain.user.domain.entity.User; @Repository public interface CategoryRepository extends JpaRepository { + Optional findByIdAndUser(long categoryId, User user); } diff --git a/src/main/java/com/pinback/pinback_server/domain/category/domain/service/CategoryGetService.java b/src/main/java/com/pinback/pinback_server/domain/category/domain/service/CategoryGetService.java new file mode 100644 index 00000000..b5eed25d --- /dev/null +++ b/src/main/java/com/pinback/pinback_server/domain/category/domain/service/CategoryGetService.java @@ -0,0 +1,22 @@ +package com.pinback.pinback_server.domain.category.domain.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.pinback.pinback_server.domain.category.domain.entity.Category; +import com.pinback.pinback_server.domain.category.domain.repository.CategoryRepository; +import com.pinback.pinback_server.domain.category.exception.CategoryNotFoundException; +import com.pinback.pinback_server.domain.user.domain.entity.User; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional +@RequiredArgsConstructor +public class CategoryGetService { + private final CategoryRepository categoryRepository; + + public Category getCategoryAndUser(long categoryId, User user) { + return categoryRepository.findByIdAndUser(categoryId, user).orElseThrow(CategoryNotFoundException::new); + } +} diff --git a/src/main/java/com/pinback/pinback_server/domain/category/exception/CategoryNotFoundException.java b/src/main/java/com/pinback/pinback_server/domain/category/exception/CategoryNotFoundException.java new file mode 100644 index 00000000..b0454584 --- /dev/null +++ b/src/main/java/com/pinback/pinback_server/domain/category/exception/CategoryNotFoundException.java @@ -0,0 +1,10 @@ +package com.pinback.pinback_server.domain.category.exception; + +import com.pinback.pinback_server.global.exception.ApplicationException; +import com.pinback.pinback_server.global.exception.constant.ExceptionCode; + +public class CategoryNotFoundException extends ApplicationException { + public CategoryNotFoundException() { + super(ExceptionCode.CATEGORY_NOT_FOUND); + } +} diff --git a/src/main/java/com/pinback/pinback_server/global/exception/constant/ExceptionCode.java b/src/main/java/com/pinback/pinback_server/global/exception/constant/ExceptionCode.java index 41187dee..f6e4ccfc 100644 --- a/src/main/java/com/pinback/pinback_server/global/exception/constant/ExceptionCode.java +++ b/src/main/java/com/pinback/pinback_server/global/exception/constant/ExceptionCode.java @@ -10,14 +10,15 @@ public enum ExceptionCode { //400 INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "c40000", "잘못된 요청입니다."), - //403 + //401 INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "c40101", "유효하지 않은 토큰입니다."), EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "c40102", "만료된 토큰입니다."), EMPTY_TOKEN(HttpStatus.UNAUTHORIZED, "c40101", "토큰이 비어있습니다."), //404 NOT_FOUND(HttpStatus.NOT_FOUND, "c40400", "리소스가 존재하지 않습니다."), - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "c40400", "사용자가 존재하지 않습니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "c40401", "사용자가 존재하지 않습니다."), + CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "c40402", "카테고리가 존재하지 않습니다."), //405 METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "c40500", "잘못된 HTTP method 요청입니다."), @@ -25,6 +26,7 @@ public enum ExceptionCode { //409 DUPLICATE(HttpStatus.CONFLICT, "c40900", "이미 존재하는 리소스입니다."), USER_ALREADY_EXIST(HttpStatus.CONFLICT, "c40901", "이미 존재하는 사용자입니다."), + ARTICLE_ALREADY_EXIST(HttpStatus.CONFLICT, "c40902", "이미 저장한 url 입니다."), //500 INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "s50000", "서버 내부 오류가 발생했습니다."); diff --git a/src/test/java/com/pinback/pinback_server/domain/ApplicationTest.java b/src/test/java/com/pinback/pinback_server/domain/ApplicationTest.java new file mode 100644 index 00000000..396a8b55 --- /dev/null +++ b/src/test/java/com/pinback/pinback_server/domain/ApplicationTest.java @@ -0,0 +1,21 @@ +package com.pinback.pinback_server.domain; + +import org.junit.jupiter.api.AfterEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import com.pinback.pinback_server.domain.fixture.CustomRepository; + +@SpringBootTest +@ActiveProfiles("test") +public class ApplicationTest { + + @Autowired + CustomRepository customRepository; + + @AfterEach + void tearDown() { + customRepository.clearAndReset(); + } +} diff --git a/src/test/java/com/pinback/pinback_server/domain/article/application/ArticleManagementUsecaseTest.java b/src/test/java/com/pinback/pinback_server/domain/article/application/ArticleManagementUsecaseTest.java new file mode 100644 index 00000000..7cfc708a --- /dev/null +++ b/src/test/java/com/pinback/pinback_server/domain/article/application/ArticleManagementUsecaseTest.java @@ -0,0 +1,72 @@ +package com.pinback.pinback_server.domain.article.application; + +import static com.pinback.pinback_server.domain.fixture.TestFixture.*; +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import com.pinback.pinback_server.domain.ApplicationTest; +import com.pinback.pinback_server.domain.article.application.command.ArticleCreateCommand; +import com.pinback.pinback_server.domain.article.domain.entity.Article; +import com.pinback.pinback_server.domain.article.domain.repository.ArticleRepository; +import com.pinback.pinback_server.domain.article.exception.ArticleAlreadyExistException; +import com.pinback.pinback_server.domain.category.domain.entity.Category; +import com.pinback.pinback_server.domain.category.domain.repository.CategoryRepository; +import com.pinback.pinback_server.domain.user.domain.entity.User; +import com.pinback.pinback_server.domain.user.domain.repository.UserRepository; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class ArticleManagementUsecaseTest extends ApplicationTest { + + @Autowired + private ArticleManagementUsecase articleManagementUsecase; + @Autowired + private UserRepository userRepository; + @Autowired + private CategoryRepository categoryRepository; + @Autowired + private ArticleRepository articleRepository; + + @DisplayName("사용자는 아티클을 생성할 수 있다.") + @Test + void articleSaveService() { + User user = userRepository.save(user()); + Category category = categoryRepository.save(category(user)); + ArticleCreateCommand command = new ArticleCreateCommand("testUrl", category.getId() + , "테스트메모", + LocalDateTime.of(2025, 8, 6, 0, 0, 0)); + //when + articleManagementUsecase.createArticle(user, command); + + //then + Article article = articleRepository.findById(1L).get(); + assertThat(article.getUrl()).isEqualTo(command.url()); + assertThat(article.getMemo()).isEqualTo(command.memo()); + assertThat(article.getCategory()).isEqualTo(category); + assertThat(article.getIsRead()).isFalse(); + } + + @DisplayName("사용자는 중복된 url을 저장할 수 없다.") + @Test + void articleDuplicate() { + User user = userRepository.save(user()); + Category category = categoryRepository.save(category(user)); + Article article = articleRepository.save(articleWithCategory(user, category)); + ArticleCreateCommand command = new ArticleCreateCommand(article.getUrl(), article.getCategory().getId() + , article.getMemo(), + LocalDateTime.of(2025, 8, 6, 0, 0, 0)); + //when & then + assertThatThrownBy(() -> articleManagementUsecase.createArticle(user, command)) + .isInstanceOf(ArticleAlreadyExistException.class); + + } +} diff --git a/src/test/java/com/pinback/pinback_server/domain/category/domain/service/CategoryGetServiceTest.java b/src/test/java/com/pinback/pinback_server/domain/category/domain/service/CategoryGetServiceTest.java new file mode 100644 index 00000000..500a6d56 --- /dev/null +++ b/src/test/java/com/pinback/pinback_server/domain/category/domain/service/CategoryGetServiceTest.java @@ -0,0 +1,47 @@ +package com.pinback.pinback_server.domain.category.domain.service; + +import static com.pinback.pinback_server.domain.fixture.TestFixture.*; + +import 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.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import com.pinback.pinback_server.domain.category.domain.entity.Category; +import com.pinback.pinback_server.domain.category.domain.repository.CategoryRepository; +import com.pinback.pinback_server.domain.category.exception.CategoryNotFoundException; +import com.pinback.pinback_server.domain.user.domain.entity.User; +import com.pinback.pinback_server.domain.user.domain.repository.UserRepository; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class CategoryGetServiceTest { + @Autowired + private UserRepository userRepository; + + @Autowired + private CategoryRepository categoryRepository; + + @Autowired + private CategoryGetService categoryGetService; + + @DisplayName("카테고리 소유자가 아닐경우 예외가 발생한다.") + @Test + void throwExceptionIsNotOwner() { + //given + User user = userRepository.save(user()); + User user1 = userRepository.save(userWithEmail("another@gmail.com")); + Category category = categoryRepository.save(category(user)); + + //when & Then + + Assertions.assertThatThrownBy(() -> categoryGetService.getCategoryAndUser(category.getId(), user1)) + .isInstanceOf(CategoryNotFoundException.class); + + } + +} diff --git a/src/test/java/com/pinback/pinback_server/domain/fixture/CustomRepository.java b/src/test/java/com/pinback/pinback_server/domain/fixture/CustomRepository.java new file mode 100644 index 00000000..4b935714 --- /dev/null +++ b/src/test/java/com/pinback/pinback_server/domain/fixture/CustomRepository.java @@ -0,0 +1,23 @@ +package com.pinback.pinback_server.domain.fixture; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; + +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; + +@Repository +public class CustomRepository { + @Autowired + private EntityManager entityManager; + + @Transactional + public void clearAndReset() { + entityManager.createNativeQuery("DELETE FROM article").executeUpdate(); + entityManager.createNativeQuery("DELETE FROM category").executeUpdate(); + entityManager.createNativeQuery("DELETE FROM users").executeUpdate(); + + entityManager.createNativeQuery("ALTER TABLE article ALTER COLUMN article_id RESTART WITH 1").executeUpdate(); + entityManager.createNativeQuery("ALTER TABLE category ALTER COLUMN category_id RESTART WITH 1").executeUpdate(); + } +} diff --git a/src/test/java/com/pinback/pinback_server/domain/fixture/TestFixture.java b/src/test/java/com/pinback/pinback_server/domain/fixture/TestFixture.java new file mode 100644 index 00000000..1c9a6078 --- /dev/null +++ b/src/test/java/com/pinback/pinback_server/domain/fixture/TestFixture.java @@ -0,0 +1,31 @@ +package com.pinback.pinback_server.domain.fixture; + +import java.time.LocalTime; + +import com.pinback.pinback_server.domain.article.domain.entity.Article; +import com.pinback.pinback_server.domain.category.domain.entity.Category; +import com.pinback.pinback_server.domain.user.domain.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); + } + + public static Article articleWithCategory(User user, Category category) { + return Article.create("test", "testmemo", user, category); + } +}