diff --git a/build.gradle b/build.gradle index eba4d2f..b161d8c 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,7 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'com.google.code.gson:gson:2.10.1' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' implementation 'org.springframework.boot:spring-boot-starter-validation' testImplementation 'org.springframework.boot:spring-boot-starter-test' @@ -33,6 +34,9 @@ dependencies { compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-cache' } test { diff --git a/src/main/java/org/sopt/domain/article/controller/ArticleController.java b/src/main/java/org/sopt/domain/article/controller/ArticleController.java index 1d97685..2565a6a 100644 --- a/src/main/java/org/sopt/domain/article/controller/ArticleController.java +++ b/src/main/java/org/sopt/domain/article/controller/ArticleController.java @@ -3,19 +3,28 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.sopt.domain.article.dto.request.ArticleCreateRequestDto; -import org.sopt.domain.article.dto.response.ArticleResponseDto; +import org.sopt.domain.article.dto.response.ArticleDetailResponseDto; +import org.sopt.domain.article.dto.response.ArticleListResponseDto; import org.sopt.domain.article.service.ArticleService; -import org.sopt.global.exception.constant.ArticleSuccessCode; +import org.sopt.domain.comment.dto.request.CommentCreateRequestDto; +import org.sopt.domain.comment.dto.response.CommentResponseDto; +import org.sopt.domain.comment.service.CommentService; +import org.sopt.global.exception.SuccessCode.ArticleSuccessCode; +import org.sopt.global.exception.SuccessCode.CommentSuccessCode; import org.sopt.global.response.BaseResponse; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; import org.springframework.web.bind.annotation.*; -import java.util.List; - @RestController @RequiredArgsConstructor @RequestMapping("/articles") public class ArticleController { + private final ArticleService articleService; + private final CommentService commentService; @PostMapping public BaseResponse createArticle( @@ -26,19 +35,27 @@ public BaseResponse createArticle( } @GetMapping("/{articleId}") - public BaseResponse getArticle( + public BaseResponse getArticle( @PathVariable Long articleId ) { - ArticleResponseDto response = articleService.findOne(articleId); - + ArticleDetailResponseDto response = articleService.findOne(articleId); return BaseResponse.ok(ArticleSuccessCode.GET_ARTICLE_SUCCESS.getMsg(), response); } @GetMapping - public BaseResponse> getAllArticles() { - List response = articleService.findAllArticles(); + public BaseResponse> getAllArticles( + @RequestParam(required = false) String keyword, + @ParameterObject @PageableDefault(size = 20) Pageable pageable) { + Page response = articleService.findAllArticles(keyword, pageable); return BaseResponse.ok(ArticleSuccessCode.GET_ALL_ARTICLES_SUCCESS.getMsg(), response); } + + @PostMapping("/{articleId}/comments") + public BaseResponse createComment(@PathVariable Long articleId, + @Valid @RequestBody CommentCreateRequestDto commentCreateRequestDto) { + CommentResponseDto response = commentService.createComment(articleId, commentCreateRequestDto); + return BaseResponse.ok(CommentSuccessCode.CREATE_COMMENT_SUCCESS.getMsg(), response); + } } diff --git a/src/main/java/org/sopt/domain/article/domain/Article.java b/src/main/java/org/sopt/domain/article/domain/Article.java index fefa6fa..f046c33 100644 --- a/src/main/java/org/sopt/domain/article/domain/Article.java +++ b/src/main/java/org/sopt/domain/article/domain/Article.java @@ -11,6 +11,9 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "article", indexes = { + @Index(name = "idx_article_created_at", columnList = "created_at") +}) public class Article extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/org/sopt/domain/article/dto/response/ArticleResponseDto.java b/src/main/java/org/sopt/domain/article/dto/response/ArticleDetailResponseDto.java similarity index 55% rename from src/main/java/org/sopt/domain/article/dto/response/ArticleResponseDto.java rename to src/main/java/org/sopt/domain/article/dto/response/ArticleDetailResponseDto.java index 90407fb..134bdfd 100644 --- a/src/main/java/org/sopt/domain/article/dto/response/ArticleResponseDto.java +++ b/src/main/java/org/sopt/domain/article/dto/response/ArticleDetailResponseDto.java @@ -3,27 +3,35 @@ import lombok.Builder; import org.sopt.domain.article.domain.Article; import org.sopt.domain.article.domain.enums.Tag; +import org.sopt.domain.comment.domain.Comment; +import org.sopt.domain.comment.dto.response.CommentResponseDto; import java.time.LocalDateTime; +import java.util.List; @Builder -public record ArticleResponseDto( +public record ArticleDetailResponseDto( Long id, Long memberId, String memberName, Tag tag, String title, String content, + List comments, LocalDateTime publishedAt) { - public static ArticleResponseDto fromEntity(Article article) { - return ArticleResponseDto.builder() + public static ArticleDetailResponseDto fromEntity(Article article, List comments) { + return ArticleDetailResponseDto.builder() .id(article.getId()) .memberId(article.getMember().getId()) .memberName(article.getMember().getName()) .tag(article.getTag()) .title(article.getTitle()) .content(article.getContent()) + .comments(comments.stream() + .map(CommentResponseDto::fromEntity) + .toList()) + .publishedAt(article.getCreatedAt()) .build(); } } diff --git a/src/main/java/org/sopt/domain/article/dto/response/ArticleListResponseDto.java b/src/main/java/org/sopt/domain/article/dto/response/ArticleListResponseDto.java new file mode 100644 index 0000000..e466a5d --- /dev/null +++ b/src/main/java/org/sopt/domain/article/dto/response/ArticleListResponseDto.java @@ -0,0 +1,29 @@ +package org.sopt.domain.article.dto.response; + +import lombok.*; +import org.sopt.domain.article.domain.Article; +import org.sopt.domain.article.domain.enums.Tag; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +@Builder +public class ArticleListResponseDto { + private Long id; + private String memberName; + private Tag tag; + private String title; + private LocalDateTime createdAt; + + public static ArticleListResponseDto fromEntity(Article article) { + return ArticleListResponseDto.builder() + .id(article.getId()) + .memberName(article.getMember().getName()) + .tag(article.getTag()) + .title(article.getTitle()) + .createdAt(article.getCreatedAt()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/org/sopt/domain/article/repository/ArticleRepository.java b/src/main/java/org/sopt/domain/article/repository/ArticleRepository.java index c6666b8..c7270d5 100644 --- a/src/main/java/org/sopt/domain/article/repository/ArticleRepository.java +++ b/src/main/java/org/sopt/domain/article/repository/ArticleRepository.java @@ -1,14 +1,27 @@ package org.sopt.domain.article.repository; import org.sopt.domain.article.domain.Article; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; -import java.util.List; import java.util.Optional; public interface ArticleRepository extends JpaRepository { - @Query("SELECT a FROM Article a JOIN FETCH a.member") - List
findAllWithMember(); + @Query(value = "SELECT a FROM Article a JOIN FETCH a.member", + countQuery = "SELECT COUNT(a) FROM Article a") + Page
findAllWithMember(Pageable pageable); + + @Query(value = "SELECT a FROM Article a JOIN FETCH a.member m " + + "WHERE a.title LIKE %:keyword% OR m.name LIKE %:keyword%", + countQuery = "SELECT COUNT(a) FROM Article a JOIN a.member m " + + "WHERE a.title LIKE %:keyword% OR m.name LIKE %:keyword%") Page
searchByKeyword(@Param("keyword") String keyword, Pageable pageable); + + @Query("SELECT a FROM Article a JOIN FETCH a.member WHERE a.id = :id") + Optional
findWithMemberById(@Param("id") Long id); + Optional
findByTitle(String title); + } diff --git a/src/main/java/org/sopt/domain/article/service/ArticleService.java b/src/main/java/org/sopt/domain/article/service/ArticleService.java index e0d10fe..87f6b89 100644 --- a/src/main/java/org/sopt/domain/article/service/ArticleService.java +++ b/src/main/java/org/sopt/domain/article/service/ArticleService.java @@ -1,12 +1,13 @@ package org.sopt.domain.article.service; import org.sopt.domain.article.dto.request.ArticleCreateRequestDto; -import org.sopt.domain.article.dto.response.ArticleResponseDto; - -import java.util.List; +import org.sopt.domain.article.dto.response.ArticleDetailResponseDto; +import org.sopt.domain.article.dto.response.ArticleListResponseDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; public interface ArticleService { Long createArticle(ArticleCreateRequestDto request); - ArticleResponseDto findOne(Long articleId); - List findAllArticles(); + ArticleDetailResponseDto findOne(Long articleId); + Page findAllArticles(String keyword, Pageable pageable); } diff --git a/src/main/java/org/sopt/domain/article/service/ArticleServiceImpl.java b/src/main/java/org/sopt/domain/article/service/ArticleServiceImpl.java index 3d52ddc..c1d1104 100644 --- a/src/main/java/org/sopt/domain/article/service/ArticleServiceImpl.java +++ b/src/main/java/org/sopt/domain/article/service/ArticleServiceImpl.java @@ -3,12 +3,19 @@ import lombok.RequiredArgsConstructor; import org.sopt.domain.article.domain.Article; import org.sopt.domain.article.dto.request.ArticleCreateRequestDto; -import org.sopt.domain.article.dto.response.ArticleResponseDto; +import org.sopt.domain.article.dto.response.ArticleDetailResponseDto; +import org.sopt.domain.article.dto.response.ArticleListResponseDto; import org.sopt.domain.article.repository.ArticleRepository; +import org.sopt.domain.comment.domain.Comment; +import org.sopt.domain.comment.repository.CommentRepository; import org.sopt.domain.member.domain.Member; import org.sopt.domain.member.repository.MemberRepository; import org.sopt.global.exception.CustomException; -import org.sopt.global.exception.constant.GlobalErrorCode; +import org.sopt.global.exception.ErrorCode.GlobalErrorCode; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,9 +28,11 @@ public class ArticleServiceImpl implements ArticleService { private final ArticleRepository articleRepository; private final MemberRepository memberRepository; + private final CommentRepository commentRepository; @Override @Transactional + @CacheEvict(value = "articleList", allEntries = true, cacheManager = "cacheManager") public Long createArticle(ArticleCreateRequestDto request) { Member member = memberRepository.findById(request.memberId()) @@ -46,17 +55,26 @@ public Long createArticle(ArticleCreateRequestDto request) { } @Override - public ArticleResponseDto findOne(Long articleId) { - Article article = articleRepository.findById(articleId) + @Cacheable(value = "article", key = "#articleId", cacheManager = "cacheManager") + public ArticleDetailResponseDto findOne(Long articleId) { + Article article = articleRepository.findWithMemberById(articleId) .orElseThrow(() -> new CustomException(GlobalErrorCode.ARTICLE_NOT_FOUND)); - return ArticleResponseDto.fromEntity(article); + List comments = commentRepository.findAllByArticleWithMember(article); + return ArticleDetailResponseDto.fromEntity(article, comments); } @Override - public List findAllArticles() { - return articleRepository.findAllWithMember().stream() - .map(ArticleResponseDto::fromEntity) - .toList(); + @Cacheable(value = "articleList", key = "'all'", cacheManager = "cacheManager") + public Page findAllArticles(String keyword, Pageable pageable) { + Page
articles; + + if (keyword == null || keyword.isEmpty()) { + articles = articleRepository.findAllWithMember(pageable); + } else { + articles = articleRepository.searchByKeyword(keyword, pageable); + } + + return articles.map(ArticleListResponseDto::fromEntity); } } diff --git a/src/main/java/org/sopt/domain/comment/controller/CommentController.java b/src/main/java/org/sopt/domain/comment/controller/CommentController.java new file mode 100644 index 0000000..b7b0a2f --- /dev/null +++ b/src/main/java/org/sopt/domain/comment/controller/CommentController.java @@ -0,0 +1,36 @@ +package org.sopt.domain.comment.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.sopt.domain.comment.dto.request.CommentUpdateRequestDto; +import org.sopt.domain.comment.dto.response.CommentResponseDto; +import org.sopt.domain.comment.service.CommentService; +import org.sopt.global.exception.SuccessCode.CommentSuccessCode; +import org.sopt.global.response.BaseResponse; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/comments/{commentId}") +public class CommentController { + + private final CommentService commentService; + @PutMapping + public BaseResponse updateComment(@PathVariable Long commentId, + @Valid @RequestBody CommentUpdateRequestDto commentUpdateRequestDto) { + CommentResponseDto response = commentService.updateComment(commentId, commentUpdateRequestDto); + return BaseResponse.ok(CommentSuccessCode.UPDATE_COMMENT_SUCCESS.getMsg(), response); + } + + @DeleteMapping + public BaseResponse deleteComment(@PathVariable Long commentId) { + commentService.deleteComment(commentId); + return BaseResponse.ok(CommentSuccessCode.DELETE_COMMENT_SUCCESS.getMsg(), null); + } + + @GetMapping + public BaseResponse getComment(@PathVariable Long commentId){ + CommentResponseDto response = commentService.getComment(commentId); + return BaseResponse.ok(CommentSuccessCode.GET_COMMENT_SUCCESS.getMsg(), response); + } +} diff --git a/src/main/java/org/sopt/domain/comment/domain/Comment.java b/src/main/java/org/sopt/domain/comment/domain/Comment.java new file mode 100644 index 0000000..57a0c27 --- /dev/null +++ b/src/main/java/org/sopt/domain/comment/domain/Comment.java @@ -0,0 +1,57 @@ +package org.sopt.domain.comment.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLRestriction; +import org.sopt.domain.article.domain.Article; +import org.sopt.domain.member.domain.Member; +import org.sopt.global.entity.BaseTimeEntity; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLRestriction("is_deleted = false") +@Table(name = "comment", indexes = { + @Index(name = "idx_comment_article_id", columnList = "article_id") +}) +public class Comment extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 300) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "article_id") + private Article article; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id") + private Member member; + + private boolean isDeleted = false; + + @Builder + private Comment(String content, Article article, Member member) { + this.content = content; + this.article = article; + this.member = member; + } + + public static Comment create(String content, Article article, Member member) { + return new Comment(content, article, member); + } + + public void updateComment(String content) { + this.content = content; + } + + public void softDelete() { + this.isDeleted = true; + } +} diff --git a/src/main/java/org/sopt/domain/comment/dto/request/CommentCreateRequestDto.java b/src/main/java/org/sopt/domain/comment/dto/request/CommentCreateRequestDto.java new file mode 100644 index 0000000..bbed801 --- /dev/null +++ b/src/main/java/org/sopt/domain/comment/dto/request/CommentCreateRequestDto.java @@ -0,0 +1,8 @@ +package org.sopt.domain.comment.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record CommentCreateRequestDto(@NotNull Long memberId, @NotBlank @Size(max = 300) String content) { +} diff --git a/src/main/java/org/sopt/domain/comment/dto/request/CommentUpdateRequestDto.java b/src/main/java/org/sopt/domain/comment/dto/request/CommentUpdateRequestDto.java new file mode 100644 index 0000000..9aec187 --- /dev/null +++ b/src/main/java/org/sopt/domain/comment/dto/request/CommentUpdateRequestDto.java @@ -0,0 +1,8 @@ +package org.sopt.domain.comment.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CommentUpdateRequestDto(@NotBlank @Size(max = 300) String content) { +} + diff --git a/src/main/java/org/sopt/domain/comment/dto/response/CommentResponseDto.java b/src/main/java/org/sopt/domain/comment/dto/response/CommentResponseDto.java new file mode 100644 index 0000000..d616117 --- /dev/null +++ b/src/main/java/org/sopt/domain/comment/dto/response/CommentResponseDto.java @@ -0,0 +1,19 @@ +package org.sopt.domain.comment.dto.response; + +import lombok.Builder; +import org.sopt.domain.comment.domain.Comment; + +import java.time.LocalDateTime; + +@Builder +public record CommentResponseDto(Long id, String memberName, String content, LocalDateTime createdAt, LocalDateTime updatedAt) { + public static CommentResponseDto fromEntity(Comment comment) { + return CommentResponseDto.builder() + .id(comment.getId()) + .memberName(comment.getMember().getName()) + .content(comment.getContent()) + .createdAt(comment.getCreatedAt()) + .updatedAt(comment.getUpdatedAt()) + .build(); + } +} diff --git a/src/main/java/org/sopt/domain/comment/repository/CommentRepository.java b/src/main/java/org/sopt/domain/comment/repository/CommentRepository.java new file mode 100644 index 0000000..e2b26ce --- /dev/null +++ b/src/main/java/org/sopt/domain/comment/repository/CommentRepository.java @@ -0,0 +1,17 @@ +package org.sopt.domain.comment.repository; + +import org.sopt.domain.article.domain.Article; +import org.sopt.domain.comment.domain.Comment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface CommentRepository extends JpaRepository { + @Query("select c from Comment c join fetch c.member where c.article = :article") + List findAllByArticleWithMember(@Param("article") Article article); + @Query("select c from Comment c join fetch c.member where c.id = :id") + Optional findWithMemberById(@Param("id") Long id); +} diff --git a/src/main/java/org/sopt/domain/comment/service/CommentService.java b/src/main/java/org/sopt/domain/comment/service/CommentService.java new file mode 100644 index 0000000..f32f218 --- /dev/null +++ b/src/main/java/org/sopt/domain/comment/service/CommentService.java @@ -0,0 +1,12 @@ +package org.sopt.domain.comment.service; + +import org.sopt.domain.comment.dto.request.CommentCreateRequestDto; +import org.sopt.domain.comment.dto.request.CommentUpdateRequestDto; +import org.sopt.domain.comment.dto.response.CommentResponseDto; + +public interface CommentService { + CommentResponseDto createComment(Long articleId, CommentCreateRequestDto commentCreateRequestDto); + CommentResponseDto updateComment(Long commentId, CommentUpdateRequestDto commentUpdateRequestDto); + void deleteComment(Long commentId); + CommentResponseDto getComment(Long commentId); +} diff --git a/src/main/java/org/sopt/domain/comment/service/CommentServiceImpl.java b/src/main/java/org/sopt/domain/comment/service/CommentServiceImpl.java new file mode 100644 index 0000000..d0a31aa --- /dev/null +++ b/src/main/java/org/sopt/domain/comment/service/CommentServiceImpl.java @@ -0,0 +1,80 @@ +package org.sopt.domain.comment.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.domain.article.domain.Article; +import org.sopt.domain.article.repository.ArticleRepository; +import org.sopt.domain.comment.domain.Comment; +import org.sopt.domain.comment.dto.request.CommentCreateRequestDto; +import org.sopt.domain.comment.dto.request.CommentUpdateRequestDto; +import org.sopt.domain.comment.dto.response.CommentResponseDto; +import org.sopt.domain.comment.repository.CommentRepository; +import org.sopt.domain.member.domain.Member; +import org.sopt.domain.member.repository.MemberRepository; +import org.sopt.global.exception.CustomException; +import org.sopt.global.exception.ErrorCode.GlobalErrorCode; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class CommentServiceImpl implements CommentService { + + private final CommentRepository commentRepository; + private final ArticleRepository articleRepository; + private final MemberRepository memberRepository; + private final CacheManager cacheManager; + + @Override + @Transactional + @CacheEvict(value = "article", key = "#articleId", cacheManager = "cacheManager") + public CommentResponseDto createComment(Long articleId, CommentCreateRequestDto commentCreateRequestDto) { + Article article = articleRepository.findById(articleId) + .orElseThrow(() -> new CustomException(GlobalErrorCode.ARTICLE_NOT_FOUND)); + Member member = memberRepository.findById(commentCreateRequestDto.memberId()) + .orElseThrow(() -> new CustomException(GlobalErrorCode.MEMBER_NOT_FOUND)); + + Comment comment = Comment.create(commentCreateRequestDto.content(), article, member); + commentRepository.save(comment); + return CommentResponseDto.fromEntity(comment); + } + + @Override + @Transactional + public CommentResponseDto updateComment(Long commentId, CommentUpdateRequestDto commentUpdateRequestDto) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(GlobalErrorCode.COMMENT_NOT_FOUND)); + + comment.updateComment(commentUpdateRequestDto.content()); + evictArticleCache(comment.getArticle().getId()); + + return CommentResponseDto.fromEntity(comment); + } + + @Override + @Transactional + public void deleteComment(Long commentId) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(GlobalErrorCode.COMMENT_NOT_FOUND)); + comment.softDelete(); + + evictArticleCache(comment.getArticle().getId()); + } + + @Override + public CommentResponseDto getComment(Long commentId) { + Comment comment = commentRepository.findWithMemberById(commentId) + .orElseThrow(() -> new CustomException(GlobalErrorCode.COMMENT_NOT_FOUND)); + return CommentResponseDto.fromEntity(comment); + } + + private void evictArticleCache(Long articleId) { + Cache cache = cacheManager.getCache("article"); + if (cache != null) { + cache.evict(articleId); + } + } +} diff --git a/src/main/java/org/sopt/domain/member/controller/MemberController.java b/src/main/java/org/sopt/domain/member/controller/MemberController.java index b223835..4dccf9d 100644 --- a/src/main/java/org/sopt/domain/member/controller/MemberController.java +++ b/src/main/java/org/sopt/domain/member/controller/MemberController.java @@ -3,7 +3,7 @@ import jakarta.validation.Valid; import org.sopt.domain.member.dto.request.MemberCreateRequestDto; import org.sopt.domain.member.dto.response.MemberResponseDto; -import org.sopt.global.exception.constant.MemberSuccessCode; +import org.sopt.global.exception.SuccessCode.MemberSuccessCode; import org.sopt.global.response.BaseResponse; import org.sopt.domain.member.service.MemberService; import org.springframework.web.bind.annotation.*; diff --git a/src/main/java/org/sopt/domain/member/domain/Member.java b/src/main/java/org/sopt/domain/member/domain/Member.java index ed5e4a7..bb2384c 100644 --- a/src/main/java/org/sopt/domain/member/domain/Member.java +++ b/src/main/java/org/sopt/domain/member/domain/Member.java @@ -8,7 +8,7 @@ import org.sopt.domain.member.domain.enums.Gender; import org.sopt.global.entity.BaseTimeEntity; import org.sopt.global.exception.CustomException; -import org.sopt.global.exception.constant.GlobalErrorCode; +import org.sopt.global.exception.ErrorCode.GlobalErrorCode; import java.time.LocalDate; import java.time.Period; @@ -57,15 +57,18 @@ private static void validateName(String name) { } private void validateAge(String birth) { + LocalDate birthDate; + try { - LocalDate birthDate = LocalDate.parse(birth, DateTimeFormatter.ofPattern("yyyyMMdd")); - int age = Period.between(birthDate, LocalDate.now()).getYears(); - if (age < 20) { - throw new CustomException(GlobalErrorCode.UNDER_20_CANNOT_JOIN); - } + birthDate = LocalDate.parse(birth, DateTimeFormatter.ofPattern("yyyyMMdd")); } catch (Exception e) { throw new CustomException(GlobalErrorCode.INVALID_BIRTH_FORMAT); } + + int age = Period.between(birthDate, LocalDate.now()).getYears(); + if (age < 20) { + throw new CustomException(GlobalErrorCode.UNDER_20_CANNOT_JOIN); + } } public void delete() { diff --git a/src/main/java/org/sopt/domain/member/domain/enums/Gender.java b/src/main/java/org/sopt/domain/member/domain/enums/Gender.java index 94692bf..a70e524 100644 --- a/src/main/java/org/sopt/domain/member/domain/enums/Gender.java +++ b/src/main/java/org/sopt/domain/member/domain/enums/Gender.java @@ -2,7 +2,7 @@ import lombok.Getter; import org.sopt.global.exception.CustomException; -import org.sopt.global.exception.constant.GlobalErrorCode; +import org.sopt.global.exception.ErrorCode.GlobalErrorCode; @Getter public enum Gender { diff --git a/src/main/java/org/sopt/domain/member/service/MemberServiceImpl.java b/src/main/java/org/sopt/domain/member/service/MemberServiceImpl.java index 2474a28..ad419ce 100644 --- a/src/main/java/org/sopt/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/org/sopt/domain/member/service/MemberServiceImpl.java @@ -5,9 +5,8 @@ import org.sopt.domain.member.dto.request.MemberCreateRequestDto; import org.sopt.domain.member.dto.response.MemberResponseDto; import org.sopt.global.exception.CustomException; -import org.sopt.global.exception.constant.GlobalErrorCode; +import org.sopt.global.exception.ErrorCode.GlobalErrorCode; import org.sopt.domain.member.repository.MemberRepository; -import org.sopt.global.validator.MemberValidator; import org.springframework.stereotype.Service; import java.util.List; diff --git a/src/main/java/org/sopt/global/config/RedisCacheConfig.java b/src/main/java/org/sopt/global/config/RedisCacheConfig.java new file mode 100644 index 0000000..4b9b34c --- /dev/null +++ b/src/main/java/org/sopt/global/config/RedisCacheConfig.java @@ -0,0 +1,40 @@ +package org.sopt.global.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; + +@Configuration +@EnableCaching +public class RedisCacheConfig { + + @Bean + public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper); + + RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(10)) + .disableCachingNullValues() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(serializer)); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(configuration) + .build(); + } +} diff --git a/src/main/java/org/sopt/global/exception/CustomException.java b/src/main/java/org/sopt/global/exception/CustomException.java index 5b520b8..bc41e3b 100644 --- a/src/main/java/org/sopt/global/exception/CustomException.java +++ b/src/main/java/org/sopt/global/exception/CustomException.java @@ -1,6 +1,6 @@ package org.sopt.global.exception; -import org.sopt.global.exception.constant.ErrorCode; +import org.sopt.global.exception.ErrorCode.ErrorCode; public class CustomException extends RuntimeException{ private final ErrorCode errorCode; diff --git a/src/main/java/org/sopt/global/exception/constant/ErrorCode.java b/src/main/java/org/sopt/global/exception/ErrorCode/ErrorCode.java similarity index 68% rename from src/main/java/org/sopt/global/exception/constant/ErrorCode.java rename to src/main/java/org/sopt/global/exception/ErrorCode/ErrorCode.java index 6ade6c2..4a36be4 100644 --- a/src/main/java/org/sopt/global/exception/constant/ErrorCode.java +++ b/src/main/java/org/sopt/global/exception/ErrorCode/ErrorCode.java @@ -1,4 +1,4 @@ -package org.sopt.global.exception.constant; +package org.sopt.global.exception.ErrorCode; public interface ErrorCode { int getHttpStatus(); diff --git a/src/main/java/org/sopt/global/exception/constant/GlobalErrorCode.java b/src/main/java/org/sopt/global/exception/ErrorCode/GlobalErrorCode.java similarity index 93% rename from src/main/java/org/sopt/global/exception/constant/GlobalErrorCode.java rename to src/main/java/org/sopt/global/exception/ErrorCode/GlobalErrorCode.java index eb4bca6..edfeaa8 100644 --- a/src/main/java/org/sopt/global/exception/constant/GlobalErrorCode.java +++ b/src/main/java/org/sopt/global/exception/ErrorCode/GlobalErrorCode.java @@ -1,4 +1,4 @@ -package org.sopt.global.exception.constant; +package org.sopt.global.exception.ErrorCode; import org.springframework.http.HttpStatus; @@ -18,6 +18,9 @@ public enum GlobalErrorCode implements ErrorCode { ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "ARTICLE_NOT_FOUND", "해당 ID의 아티클을 찾을 수 없습니다."), DUPLICATE_ARTICLE_TITLE(HttpStatus.BAD_REQUEST.value(), "DUPLICATE_ARTICLE_TITLE", "이미 등록된 아티클 제목입니다."), + // Comment + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "COMMENT_NOT_FOUND", "해당 ID의 댓글을 찾을 수 없습니다."), + // Global FILE_INIT_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "FILE_INIT_FAILED", "파일 초기화를 실패했습니다."), FILE_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "FILE_UPDATE_FAILED", "데이터 파일 저장에 실패하였습니다."), diff --git a/src/main/java/org/sopt/global/exception/GlobalExceptionHandler.java b/src/main/java/org/sopt/global/exception/GlobalExceptionHandler.java index 162db78..b017b96 100644 --- a/src/main/java/org/sopt/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/sopt/global/exception/GlobalExceptionHandler.java @@ -1,7 +1,8 @@ package org.sopt.global.exception; -import org.sopt.global.exception.constant.ErrorCode; -import org.sopt.global.exception.constant.GlobalErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.sopt.global.exception.ErrorCode.ErrorCode; +import org.sopt.global.exception.ErrorCode.GlobalErrorCode; import org.sopt.global.response.BaseErrorResponse; import org.springframework.beans.TypeMismatchException; import org.springframework.http.ResponseEntity; @@ -9,6 +10,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @@ -16,6 +18,7 @@ public class GlobalExceptionHandler { @ExceptionHandler(CustomException.class) public ResponseEntity handlerCustomException(CustomException ex){ ErrorCode errorCode = ex.getErrorCode(); + log.warn("CustomException: Code: {}, Message: {}", errorCode.getCode(), errorCode.getMsg()); return ResponseEntity.status(errorCode.getHttpStatus()) .body(BaseErrorResponse.of(errorCode)); } @@ -23,6 +26,7 @@ public ResponseEntity handlerCustomException(CustomException // 모든 예외 @ExceptionHandler(Exception.class) public ResponseEntity handlerInternalServerError(Exception ex) { + log.error("Internal Server Error: ", ex); return ResponseEntity.status(GlobalErrorCode.INTERNAL_SERVER_ERROR.getHttpStatus()) .body(BaseErrorResponse.of(GlobalErrorCode.INTERNAL_SERVER_ERROR)); } @@ -30,12 +34,14 @@ public ResponseEntity handlerInternalServerError(Exception ex // 타입 불일치 @ExceptionHandler(TypeMismatchException.class) public BaseErrorResponse handleTypeMismatch(TypeMismatchException ex) { + log.warn("TypeMismatchException Error: ", ex); return BaseErrorResponse.of(GlobalErrorCode.TYPE_MISMATCH); } // JSON 파싱 실패 @ExceptionHandler(HttpMessageNotReadableException.class) public BaseErrorResponse handleHttpMessageNotReadable(HttpMessageNotReadableException ex) { + log.warn("HttpMessageNotReadableException Error: ", ex); return BaseErrorResponse.of(GlobalErrorCode.INVALID_JSON); } } diff --git a/src/main/java/org/sopt/global/exception/constant/ArticleSuccessCode.java b/src/main/java/org/sopt/global/exception/SuccessCode/ArticleSuccessCode.java similarity index 92% rename from src/main/java/org/sopt/global/exception/constant/ArticleSuccessCode.java rename to src/main/java/org/sopt/global/exception/SuccessCode/ArticleSuccessCode.java index 31bb178..3ed5618 100644 --- a/src/main/java/org/sopt/global/exception/constant/ArticleSuccessCode.java +++ b/src/main/java/org/sopt/global/exception/SuccessCode/ArticleSuccessCode.java @@ -1,4 +1,4 @@ -package org.sopt.global.exception.constant; +package org.sopt.global.exception.SuccessCode; import lombok.Getter; import org.springframework.http.HttpStatus; diff --git a/src/main/java/org/sopt/global/exception/SuccessCode/CommentSuccessCode.java b/src/main/java/org/sopt/global/exception/SuccessCode/CommentSuccessCode.java new file mode 100644 index 0000000..c488216 --- /dev/null +++ b/src/main/java/org/sopt/global/exception/SuccessCode/CommentSuccessCode.java @@ -0,0 +1,20 @@ +package org.sopt.global.exception.SuccessCode; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum CommentSuccessCode implements SuccessCode { + CREATE_COMMENT_SUCCESS(HttpStatus.CREATED.value(), "댓글 등록 성공"), + UPDATE_COMMENT_SUCCESS(HttpStatus.CREATED.value(), "댓글 수정 성공"), + GET_COMMENT_SUCCESS(HttpStatus.OK.value(), "댓글 조회 성공"), + DELETE_COMMENT_SUCCESS(HttpStatus.OK.value(), "댓글 삭제 성공"); + + private final int status; + private final String msg; + + CommentSuccessCode(int status, String msg) { + this.status = status; + this.msg = msg; + } +} diff --git a/src/main/java/org/sopt/global/exception/constant/MemberSuccessCode.java b/src/main/java/org/sopt/global/exception/SuccessCode/MemberSuccessCode.java similarity index 92% rename from src/main/java/org/sopt/global/exception/constant/MemberSuccessCode.java rename to src/main/java/org/sopt/global/exception/SuccessCode/MemberSuccessCode.java index 97a747b..80e5867 100644 --- a/src/main/java/org/sopt/global/exception/constant/MemberSuccessCode.java +++ b/src/main/java/org/sopt/global/exception/SuccessCode/MemberSuccessCode.java @@ -1,4 +1,4 @@ -package org.sopt.global.exception.constant; +package org.sopt.global.exception.SuccessCode; import lombok.Getter; import org.springframework.http.HttpStatus; diff --git a/src/main/java/org/sopt/global/exception/constant/SuccessCode.java b/src/main/java/org/sopt/global/exception/SuccessCode/SuccessCode.java similarity index 61% rename from src/main/java/org/sopt/global/exception/constant/SuccessCode.java rename to src/main/java/org/sopt/global/exception/SuccessCode/SuccessCode.java index 801ba82..412dc0f 100644 --- a/src/main/java/org/sopt/global/exception/constant/SuccessCode.java +++ b/src/main/java/org/sopt/global/exception/SuccessCode/SuccessCode.java @@ -1,4 +1,4 @@ -package org.sopt.global.exception.constant; +package org.sopt.global.exception.SuccessCode; public interface SuccessCode { int getStatus(); diff --git a/src/main/java/org/sopt/global/response/BaseErrorResponse.java b/src/main/java/org/sopt/global/response/BaseErrorResponse.java index e79340f..0ee2285 100644 --- a/src/main/java/org/sopt/global/response/BaseErrorResponse.java +++ b/src/main/java/org/sopt/global/response/BaseErrorResponse.java @@ -1,6 +1,6 @@ package org.sopt.global.response; -import org.sopt.global.exception.constant.ErrorCode; +import org.sopt.global.exception.ErrorCode.ErrorCode; public record BaseErrorResponse(int status, String code, String msg) { diff --git a/src/main/java/org/sopt/global/validator/MemberValidator.java b/src/main/java/org/sopt/global/validator/MemberValidator.java deleted file mode 100644 index 37b0a58..0000000 --- a/src/main/java/org/sopt/global/validator/MemberValidator.java +++ /dev/null @@ -1,18 +0,0 @@ -package org.sopt.global.validator; - -import org.sopt.domain.member.domain.enums.Gender; -import org.sopt.global.exception.CustomException; -import org.sopt.global.exception.constant.GlobalErrorCode; - -public class MemberValidator { - - public static Gender validateGender(String gender){ - return Gender.fromDisplayGender(gender); - } - - public static void validateBirth(String birth){ - if (birth == null || birth.isBlank() || !birth.matches("\\d{8}")) { - throw new CustomException(GlobalErrorCode.INVALID_BIRTH_FORMAT); - } - } -} diff --git a/src/test/java/org/sopt/domain/article/domain/ArticleTest.java b/src/test/java/org/sopt/domain/article/domain/ArticleTest.java new file mode 100644 index 0000000..14b7d00 --- /dev/null +++ b/src/test/java/org/sopt/domain/article/domain/ArticleTest.java @@ -0,0 +1,28 @@ +package org.sopt.domain.article.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.sopt.domain.article.domain.enums.Tag; +import org.sopt.domain.member.domain.Member; +import org.sopt.domain.member.domain.enums.Gender; + +import static org.assertj.core.api.Assertions.assertThat; + +class ArticleTest { + + @Test + @DisplayName("아티클 생성 정적 팩토리 메서드가 정상 작동한다.") + void articleCreateSuccess() { + // given + Member member = Member.create("작성자", "test@sopt.org", "20000101", Gender.MALE); + + // when + Article article = Article.create(member, Tag.ETC, "제목", "내용"); + + // then + assertThat(article.getMember()).isEqualTo(member); + assertThat(article.getTitle()).isEqualTo("제목"); + assertThat(article.getTag()).isEqualTo(Tag.ETC); + } + +} \ No newline at end of file diff --git a/src/test/java/org/sopt/domain/article/service/ArticleIndexTest.java b/src/test/java/org/sopt/domain/article/service/ArticleIndexTest.java new file mode 100644 index 0000000..51c0a42 --- /dev/null +++ b/src/test/java/org/sopt/domain/article/service/ArticleIndexTest.java @@ -0,0 +1,77 @@ +package org.sopt.domain.article.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.sopt.domain.article.domain.enums.Tag; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.Rollback; + +import java.util.ArrayList; +import java.util.List; + +@SpringBootTest +public class ArticleIndexTest { + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private ArticleService articleService; + + @Test + @Rollback(false) + @DisplayName("DB에 게시물 데이터를 3만개 삽입한다.") + void insert30kArticles() { + Long memberId = 2L; + Tag validTag = Tag.ETC; + + String sql = "INSERT INTO article (member_id, tag, title, content, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, NOW(), NOW())"; + + List batchArgs = new ArrayList<>(); + + System.out.println("데이터 삽입 시작"); + long startTime = System.currentTimeMillis(); + + for (int i = 1; i <= 30000; i++) { + batchArgs.add(new Object[]{memberId, validTag.name(), "제목이지렁이 " + i, "내용이지렁 " + i}); + + if (i % 1000 == 0) { + jdbcTemplate.batchUpdate(sql, batchArgs); + batchArgs.clear(); + } + } + + long endTime = System.currentTimeMillis(); + System.out.println("소요 시간: " + (endTime - startTime) + "ms"); + } + + @Test + @DisplayName("인덱스 적용 전 소요 시간을 측정한다.") + void measureTimeWithoutIndex() { + Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + long startTime = System.currentTimeMillis(); + + articleService.findAllArticles(null, pageable); + + long endTime = System.currentTimeMillis(); + System.out.println("인덱스 적용 전 소요 시간: " + (endTime - startTime) + "ms"); + } + + @Test + @DisplayName("인덱스 적용 후 소요 시간을 측정한다.") + void measureTimeWithIndex() { + Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + long startTime = System.currentTimeMillis(); + + articleService.findAllArticles(null, pageable); + + long endTime = System.currentTimeMillis(); + System.out.println("인덱스 적용 후 소요 시간: " + (endTime - startTime) + "ms"); + } +} \ No newline at end of file diff --git a/src/test/java/org/sopt/domain/article/service/ArticleServiceImplTest.java b/src/test/java/org/sopt/domain/article/service/ArticleServiceImplTest.java new file mode 100644 index 0000000..90c3a3b --- /dev/null +++ b/src/test/java/org/sopt/domain/article/service/ArticleServiceImplTest.java @@ -0,0 +1,52 @@ +package org.sopt.domain.article.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.domain.article.domain.Article; +import org.sopt.domain.article.domain.enums.Tag; +import org.sopt.domain.article.dto.request.ArticleCreateRequestDto; +import org.sopt.domain.article.repository.ArticleRepository; +import org.sopt.domain.member.domain.Member; +import org.sopt.domain.member.domain.enums.Gender; +import org.sopt.domain.member.repository.MemberRepository; +import org.sopt.global.exception.CustomException; +import org.sopt.global.exception.ErrorCode.GlobalErrorCode; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +class ArticleServiceImplTest { + @InjectMocks + private ArticleServiceImpl articleService; + + @Mock + private ArticleRepository articleRepository; + @Mock + private MemberRepository memberRepository; + + @Test + @DisplayName("아티클 생성 시 중복된 제목이 있으면 예외가 발생한다.") + void createArticleDuplicateTitleFail() { + // given + ArticleCreateRequestDto request = new ArticleCreateRequestDto(1L, Tag.ETC, "제목", "내용"); + Member member = Member.create("이름", "testest@sopt.org", "19900101", Gender.MALE); + + given(memberRepository.findById(anyLong())).willReturn(Optional.of(member)); + given(articleRepository.findByTitle(anyString())).willReturn(Optional.of(mock(Article.class))); + + // when & then + CustomException exception = assertThrows(CustomException.class, () -> articleService.createArticle(request)); + assertThat(exception.getErrorCode()).isEqualTo(GlobalErrorCode.DUPLICATE_ARTICLE_TITLE); + } +} \ No newline at end of file diff --git a/src/test/java/org/sopt/domain/comment/service/CommentServiceImplTest.java b/src/test/java/org/sopt/domain/comment/service/CommentServiceImplTest.java new file mode 100644 index 0000000..4225943 --- /dev/null +++ b/src/test/java/org/sopt/domain/comment/service/CommentServiceImplTest.java @@ -0,0 +1,67 @@ +package org.sopt.domain.comment.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.sopt.domain.article.domain.enums.Tag; +import org.sopt.domain.article.dto.request.ArticleCreateRequestDto; +import org.sopt.domain.article.repository.ArticleRepository; +import org.sopt.domain.article.service.ArticleService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.cache.CacheManager; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.transaction.annotation.Transactional; + +import java.util.stream.IntStream; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@SpringBootTest +@Transactional +class CommentServiceImplTest { + + @Autowired + private ArticleService articleService; + + @SpyBean + private ArticleRepository articleRepository; + + @Autowired + private CacheManager cacheManager; + + private final Pageable pageable = PageRequest.of(0, 20); + + @BeforeEach + void clearCache() { + if (cacheManager.getCache("articleList") != null) { + cacheManager.getCache("articleList").clear(); + } } + + @Test + @DisplayName("목록 조회 시 최초 1회만 DB에 접근하고 이후에는 캐시를 사용한다") + void getArticleListWithCaching() { + // when + IntStream.range(0, 10).forEach(i -> articleService.findAllArticles(null,pageable)); + + // then + verify(articleRepository, times(1)).findAllWithMember(pageable); + } + + @Test + @DisplayName("새 글 작성 시 기존 목록 캐시가 삭제되어 DB를 다시 조회한다") + void getArticleListWithCacheEvict() { + IntStream.range(0, 10).forEach(i -> articleService.findAllArticles(null,pageable)); + + ArticleCreateRequestDto request = new ArticleCreateRequestDto(1L, Tag.ETC, "New Title", "Content"); + articleService.createArticle(request); + + IntStream.range(0, 10).forEach(i -> articleService.findAllArticles(null,pageable)); + + verify(articleRepository, times(2)).findAllWithMember(pageable); + } + +} \ No newline at end of file diff --git a/src/test/java/org/sopt/domain/member/domain/MemberTest.java b/src/test/java/org/sopt/domain/member/domain/MemberTest.java new file mode 100644 index 0000000..ce13c2d --- /dev/null +++ b/src/test/java/org/sopt/domain/member/domain/MemberTest.java @@ -0,0 +1,71 @@ +package org.sopt.domain.member.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.sopt.domain.member.domain.enums.Gender; +import org.sopt.global.exception.CustomException; +import org.sopt.global.exception.ErrorCode.GlobalErrorCode; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MemberTest { + + @Test + @DisplayName("Member 엔티티 생성 테스트") + void createMember() { + // given + String name = "복복복"; + String email = "test@test.com"; + String birth = "20000101"; + + // when + Member member = Member.create(name, email, birth, Gender.FEMALE); + + // then + assertThat(member.getName()).isEqualTo(name); + assertThat(member.getBirth()).isEqualTo(birth); + assertThat(member.getEmail()).isEqualTo(email); + } + + @Test + @DisplayName("이름이 공백일 경우 회원가입에 실패한다") + void failCreateMemberWithBlank() { + assertThatThrownBy(() -> Member.create(" ", "test@test.com", "20000101", Gender.FEMALE)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", GlobalErrorCode.NAME_BLANK); + } + + @Test + @DisplayName("20세 미만일 경우 회원가입에 실패한다.") + void failCreateMemberUnder20() { + assertThatThrownBy(() -> Member.create("급식이", "test@test.com", "20150101", Gender.FEMALE)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", GlobalErrorCode.UNDER_20_CANNOT_JOIN); + } + + @Test + @DisplayName("생년월일 형식이 올바르지 않을 경우 회원가입에 실패한다.") + void failCreateMemberInvalidBrith() { + assertThatThrownBy(() -> Member.create("복복복이", "test@test.com", "2015-01-01", Gender.FEMALE)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", GlobalErrorCode.INVALID_BIRTH_FORMAT); + } + + @Test + @DisplayName("회원 삭제 시 isDeleted 상태가 true가 된다") + void deleteMemberSuccess() { + // given + String name = "복복복"; + String email = "test@test.com"; + String birth = "20000101"; + + Member member = Member.create(name, email, birth, Gender.FEMALE); + + // when + member.delete(); + + // then + assertThat(member.isDeleted()).isTrue(); + } +} \ No newline at end of file diff --git a/src/test/java/org/sopt/domain/member/service/MemberServiceImplTest.java b/src/test/java/org/sopt/domain/member/service/MemberServiceImplTest.java new file mode 100644 index 0000000..ee9a307 --- /dev/null +++ b/src/test/java/org/sopt/domain/member/service/MemberServiceImplTest.java @@ -0,0 +1,90 @@ +package org.sopt.domain.member.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.domain.member.domain.Member; +import org.sopt.domain.member.domain.enums.Gender; +import org.sopt.domain.member.dto.request.MemberCreateRequestDto; +import org.sopt.domain.member.repository.MemberRepository; +import org.sopt.global.exception.CustomException; +import org.sopt.global.exception.ErrorCode.GlobalErrorCode; + +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MemberServiceImplTest { + + @Mock + private MemberRepository memberRepository; + + @InjectMocks + private MemberServiceImpl memberService; + + @Test + @DisplayName("회원가입 성공 - 중복 이메일이 없으면 저장된다") + void joinSuccess() { + // given + MemberCreateRequestDto request = new MemberCreateRequestDto("복복이", "19950101", "test@sopt.org", "여성"); + given(memberRepository.findByEmail(anyString())).willReturn(Optional.empty()); + + // when + memberService.join(request); + + // then + verify(memberRepository, times(1)).save(any(Member.class)); + } + + @Test + @DisplayName("회원가입 실패 - 이메일 중복 시 예외가 발생한다") + void joinFailDuplicateEmail() { + // given + MemberCreateRequestDto request = new MemberCreateRequestDto("복복이", "test@sopt.org", "19950101", "여성"); + given(memberRepository.findByEmail(request.getEmail())).willReturn(Optional.of(mock(Member.class))); + + // when & then + assertThatThrownBy(() -> memberService.join(request)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", GlobalErrorCode.DUPLICATE_EMAIL); + } + + @Test + @DisplayName("회원 삭제 실패 - 이미 삭제된 회원은 예외가 발생한다") + void deleteMemberFailAlreadyDeleted() { + // given + Long memberId = 1L; + Member member = Member.create("복복이", "test@sopt.org", "19950101", Gender.FEMALE); + member.delete(); + + given(memberRepository.findByIncludedDeleted(memberId)).willReturn(Optional.of(member)); + + // when & then + assertThatThrownBy(() -> memberService.deleteMember(memberId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", GlobalErrorCode.ALREADY_DELETED_MEMBER); + } + + @Test + @DisplayName("조회 실패 - 존재하지 않는 회원 ID면 예외가 발생한다") + void findOneFailNotFound() { + // given + Long memberId = 999L; + given(memberRepository.findById(memberId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> memberService.findOne(memberId)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", GlobalErrorCode.MEMBER_NOT_FOUND); + } + + +} \ No newline at end of file