diff --git a/build.gradle b/build.gradle index 9f38a7e..359d131 100644 --- a/build.gradle +++ b/build.gradle @@ -39,11 +39,19 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testCompileOnly 'org.projectlombok:lombok' // 테스트 의존성 추가 + testAnnotationProcessor 'org.projectlombok:lombok' // 테스트 의존성 추가 // JWT implementation 'io.jsonwebtoken:jjwt-api:0.11.5' implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-cache' + implementation('it.ozimov:embedded-redis:0.7.3') { + exclude group: "org.slf4j", module: "slf4j-simple" + } } tasks.named('test') { diff --git a/src/main/java/com/board/BoardApplication.java b/src/main/java/com/BoardApplication.java similarity index 82% rename from src/main/java/com/board/BoardApplication.java rename to src/main/java/com/BoardApplication.java index fc2d3d9..bea8972 100644 --- a/src/main/java/com/board/BoardApplication.java +++ b/src/main/java/com/BoardApplication.java @@ -1,11 +1,13 @@ -package com.board; +package com; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.cache.annotation.EnableCaching; @SpringBootApplication @ConfigurationPropertiesScan +@EnableCaching public class BoardApplication { public static void main(String[] args) { diff --git a/src/main/java/com/board/board/controller/BlogApiController.java b/src/main/java/com/board/board/controller/BlogApiController.java deleted file mode 100644 index 7dc0c3d..0000000 --- a/src/main/java/com/board/board/controller/BlogApiController.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.board.board.controller; - -import com.board.board.dto.request.ArticleCreateRequest; -import com.board.board.dto.request.ArticleUpdateRequest; -import com.board.board.dto.response.ArticleResponse; -import com.board.board.entity.ArticleEntity; -import com.board.board.service.BlogService; -import com.board.config.auth.annotation.AuthenticatedMember; -import jakarta.validation.Valid; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/articles") -public class BlogApiController { - - private final BlogService blogService; - - @PostMapping("") - public ResponseEntity addArticle(@Valid @RequestBody ArticleCreateRequest request, - @AuthenticatedMember Long memberId) { - ArticleEntity savedArticle = blogService.save(request, memberId); - - return ResponseEntity.status(HttpStatus.CREATED) - .body(new ArticleResponse(savedArticle)); - } - - @GetMapping("") - public ResponseEntity> findAllArticles(@RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size) { - Pageable pageable = PageRequest.of(page, size); - - List articles = blogService.findAll(pageable) - .stream() - .map(ArticleResponse::new) - .toList(); - - return ResponseEntity.ok() - .body(articles); - } - - @GetMapping("/{id}") - public ResponseEntity findArticle(@PathVariable long id) { - ArticleEntity article = blogService.findById(id); - - return ResponseEntity.ok() - .body(new ArticleResponse(article)); - } - - @DeleteMapping("/{id}") - public ResponseEntity deleteArticle(@PathVariable long id, - @AuthenticatedMember Long memberId) { - blogService.delete(id, memberId); - - return ResponseEntity.noContent() - .build(); - } - - @PutMapping("/{id}") - public ResponseEntity updateArticle(@PathVariable long id, - @AuthenticatedMember Long memberId, - @Valid @RequestBody ArticleUpdateRequest request) { - ArticleEntity updateArticle = blogService.update(id, memberId, request); - - return ResponseEntity.ok() - .body(new ArticleResponse(updateArticle)); - } -} diff --git a/src/main/java/com/board/board/dto/response/ArticleResponse.java b/src/main/java/com/board/board/dto/response/ArticleResponse.java deleted file mode 100644 index 499c760..0000000 --- a/src/main/java/com/board/board/dto/response/ArticleResponse.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.board.board.dto.response; - -import com.board.board.entity.ArticleEntity; -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -@Getter -public class ArticleResponse { - - private final Long id; - private final String title; - private final String content; - private final Long memberId; - - public ArticleResponse(ArticleEntity article) { - this.id = article.getId(); - this.title = article.getTitle(); - this.content = article.getContent(); - this.memberId = article.getMember().getId(); - } -} diff --git a/src/main/java/com/board/board/repository/BlogRepository.java b/src/main/java/com/board/board/repository/BlogRepository.java deleted file mode 100644 index 7d92f5c..0000000 --- a/src/main/java/com/board/board/repository/BlogRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.board.board.repository; - -import com.board.board.entity.ArticleEntity; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface BlogRepository extends JpaRepository { -} diff --git a/src/main/java/com/board/board/service/BlogService.java b/src/main/java/com/board/board/service/BlogService.java deleted file mode 100644 index 67fd16b..0000000 --- a/src/main/java/com/board/board/service/BlogService.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.board.board.service; - -import com.board.board.dto.request.ArticleCreateRequest; -import com.board.board.dto.request.ArticleUpdateRequest; -import com.board.board.entity.ArticleEntity; -import com.board.board.repository.BlogRepository; -import com.board.exception.custom.MyEntityNotFoundException; -import com.board.member.entity.MemberEntity; -import com.board.member.service.MemberService; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Service -@Transactional(readOnly = true) -public class BlogService { - - private final BlogRepository blogRepository; - private final MemberService memberService; - - @Transactional - public ArticleEntity save(ArticleCreateRequest request, Long memberId) { - return blogRepository.save(ArticleEntity.builder() - .title(request.getTitle()) - .content(request.getContent()) - .member(memberService.findById(memberId)) - .build()); - } - - public Page findAll(Pageable pageable) { - return blogRepository.findAll(pageable); - } - - public ArticleEntity findById(long id) { - return findArticle(id); - } - - @Transactional - public void delete(long id, Long memberId) { - compareAuthors(id, memberService.findById(memberId)); - blogRepository.deleteById(id); - } - - @Transactional - public ArticleEntity update(long id, Long memberId, ArticleUpdateRequest request) { - ArticleEntity article = compareAuthors(id, memberService.findById(memberId)); - article.update(request.getTitle(), request.getContent()); - return article; - } - - private ArticleEntity compareAuthors(long articleId, MemberEntity member) { - ArticleEntity article = findArticle(articleId); - article.validateOwner(member); - return article; - } - - private ArticleEntity findArticle(long id) { - return blogRepository.findById(id) - .orElseThrow(() -> MyEntityNotFoundException.from(id)); - } -} diff --git a/src/main/java/com/board/controller/ArticleController.java b/src/main/java/com/board/controller/ArticleController.java new file mode 100644 index 0000000..64cdb3c --- /dev/null +++ b/src/main/java/com/board/controller/ArticleController.java @@ -0,0 +1,73 @@ +package com.board.controller; + +import com.board.dto.request.ArticleCreateRequest; +import com.board.dto.request.ArticleUpdateRequest; +import com.board.dto.response.ArticleResponse; +import com.board.entity.ArticleEntity; +import com.board.service.ArticleService; +import com.config.auth.annotation.AuthenticatedMember; +import com.util.page.PageResponse; +import com.util.sort.SortUtils; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/articles") +public class ArticleController { + + private final ArticleService articleService; + + @PostMapping("") + public ResponseEntity addArticle(@Valid @RequestBody ArticleCreateRequest request, + @AuthenticatedMember Long memberId) { + ArticleEntity savedArticle = articleService.save(request, memberId); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(new ArticleResponse(savedArticle)); + } + + @GetMapping("") + public ResponseEntity> findAllArticles(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "latest") String sort) { + Pageable pageable = PageRequest.of(page, size, SortUtils.getArticleSort(sort)); + + Page articlePage = articleService.findAll(pageable) + .map(ArticleResponse::withoutContent); + + return ResponseEntity.ok() + .body(PageResponse.from(articlePage)); + } + + @GetMapping("/{id}") + public ResponseEntity findArticle(@PathVariable long id) { + ArticleResponse articleWithViewCount = articleService.getArticleWithViewCount(id); + return ResponseEntity.ok() + .body(articleWithViewCount); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteArticle(@PathVariable long id, + @AuthenticatedMember Long memberId) { + articleService.delete(id, memberId); + return ResponseEntity.noContent() + .build(); + } + + @PutMapping("/{id}") + public ResponseEntity updateArticle(@PathVariable long id, + @AuthenticatedMember Long memberId, + @Valid @RequestBody ArticleUpdateRequest request) { + ArticleEntity updateArticle = articleService.update(id, memberId, request); + + return ResponseEntity.ok() + .body(new ArticleResponse(updateArticle)); + } +} diff --git a/src/main/java/com/board/controller/CommentController.java b/src/main/java/com/board/controller/CommentController.java new file mode 100644 index 0000000..1f0cf7d --- /dev/null +++ b/src/main/java/com/board/controller/CommentController.java @@ -0,0 +1,77 @@ +package com.board.controller; + +import com.board.dto.request.CommentCreateRequest; +import com.board.dto.request.CommentUpdateRequest; +import com.board.dto.response.CommentResponse; +import com.board.entity.CommentEntity; +import com.board.service.CommentService; +import com.config.auth.annotation.AuthenticatedMember; +import com.util.page.PageResponse; +import com.util.sort.SortUtils; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/articles/{articleId}/comments") +public class CommentController { + + private final CommentService commentService; + + @GetMapping + public ResponseEntity> findAllComments(@PathVariable Long articleId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "latest") String sort) { + Pageable pageable = PageRequest.of(page, size, SortUtils.getCommentSort(sort)); + return ResponseEntity.ok(PageResponse.from(commentService.findAllTopLevelComments(articleId, pageable))); + } + + @PostMapping + public ResponseEntity addComment(@PathVariable Long articleId, + @Valid @RequestBody CommentCreateRequest request, + @AuthenticatedMember Long memberId) { + CommentEntity savedComment = commentService.createComment(articleId, request, memberId); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(CommentResponse.of(savedComment, 0)); + } + + @PatchMapping("/{commentId}") + public ResponseEntity updateComment(@PathVariable Long articleId, + @PathVariable Long commentId, + @Valid @RequestBody CommentUpdateRequest request, + @AuthenticatedMember Long memberId) { + CommentEntity updatedComment = commentService.updateComment(articleId, commentId, request, memberId); + int replyCount = commentService.getReplyCount(updatedComment); + + return ResponseEntity.ok() + .body(CommentResponse.of(updatedComment, replyCount)); + } + + @DeleteMapping("/{commentId}") + public ResponseEntity deleteComment(@PathVariable Long articleId, + @PathVariable Long commentId, + @AuthenticatedMember Long memberId) { + commentService.deleteComment(articleId, commentId, memberId); + return ResponseEntity.noContent() + .build(); + } + + @GetMapping("/{commentId}/replies") + public ResponseEntity> findReplies(@PathVariable Long articleId, + @PathVariable Long commentId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "oldest") String sort) { + Pageable pageable = PageRequest.of(page, size, SortUtils.getCommentSort(sort)); + Page replies = commentService.findReplies(commentId, pageable); + return ResponseEntity.ok(PageResponse.from(replies.map(CommentResponse::fromReply))); + } +} diff --git a/src/main/java/com/board/board/dto/request/ArticleCreateRequest.java b/src/main/java/com/board/dto/request/ArticleCreateRequest.java similarity index 93% rename from src/main/java/com/board/board/dto/request/ArticleCreateRequest.java rename to src/main/java/com/board/dto/request/ArticleCreateRequest.java index f12951a..c46365f 100644 --- a/src/main/java/com/board/board/dto/request/ArticleCreateRequest.java +++ b/src/main/java/com/board/dto/request/ArticleCreateRequest.java @@ -1,4 +1,4 @@ -package com.board.board.dto.request; +package com.board.dto.request; import jakarta.validation.constraints.NotBlank; import lombok.Builder; diff --git a/src/main/java/com/board/board/dto/request/ArticleUpdateRequest.java b/src/main/java/com/board/dto/request/ArticleUpdateRequest.java similarity index 91% rename from src/main/java/com/board/board/dto/request/ArticleUpdateRequest.java rename to src/main/java/com/board/dto/request/ArticleUpdateRequest.java index 62a1a2b..8d8b066 100644 --- a/src/main/java/com/board/board/dto/request/ArticleUpdateRequest.java +++ b/src/main/java/com/board/dto/request/ArticleUpdateRequest.java @@ -1,4 +1,4 @@ -package com.board.board.dto.request; +package com.board.dto.request; import jakarta.validation.constraints.NotBlank; import lombok.Getter; diff --git a/src/main/java/com/board/dto/request/CommentCreateRequest.java b/src/main/java/com/board/dto/request/CommentCreateRequest.java new file mode 100644 index 0000000..62f6310 --- /dev/null +++ b/src/main/java/com/board/dto/request/CommentCreateRequest.java @@ -0,0 +1,26 @@ +package com.board.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class CommentCreateRequest { + + @NotBlank(message = "내용을 입력해주세요") + private String content; + + private Long parentId; + + public CommentCreateRequest(String content) { + this.content = content; + } + + @Builder + public CommentCreateRequest(String content, Long parentId) { + this.content = content; + this.parentId = parentId; + } +} diff --git a/src/main/java/com/board/dto/request/CommentUpdateRequest.java b/src/main/java/com/board/dto/request/CommentUpdateRequest.java new file mode 100644 index 0000000..91195f9 --- /dev/null +++ b/src/main/java/com/board/dto/request/CommentUpdateRequest.java @@ -0,0 +1,27 @@ +package com.board.dto.request; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class CommentUpdateRequest { + + @NotBlank(message = "내용을 입력해주세요") + private String content; + @NotNull(message = "게시글 ID는 필수입니다") + @Min(value = 1, message = "코멘트 ID는 1 이상이어야 합니다") + private Long commentId; + + @Builder + public CommentUpdateRequest(String content, Long commentId) { + this.content = content; + this.commentId = commentId; + } +} diff --git a/src/main/java/com/board/dto/response/ArticleResponse.java b/src/main/java/com/board/dto/response/ArticleResponse.java new file mode 100644 index 0000000..3dca41b --- /dev/null +++ b/src/main/java/com/board/dto/response/ArticleResponse.java @@ -0,0 +1,51 @@ +package com.board.dto.response; + +import com.board.entity.ArticleEntity; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public class ArticleResponse { + + private final Long id; + private final String title; + private String content; + private final Long memberId; + private final long viewCount; + + @Builder + public ArticleResponse(Long id, String title, String content, Long memberId, long viewCount) { + this.id = id; + this.title = title; + this.content = content; + this.memberId = memberId; + this.viewCount = viewCount; + } + + public ArticleResponse(ArticleEntity article) { + this.id = article.getId(); + this.title = article.getTitle(); + this.content = article.getContent(); + this.memberId = article.getMember().getId(); + this.viewCount = article.getViewCount(); + } + + public static ArticleResponse withoutContent(ArticleEntity article) { + return new ArticleResponse(article.getId(), + article.getTitle(), + article.getMember().getId(), + article.getViewCount()); + } + + public static ArticleResponse from(ArticleEntity article, long viewCount) { + return new ArticleResponse( + article.getId(), + article.getTitle(), + article.getContent(), + article.getMember().getId(), + viewCount + ); + } +} diff --git a/src/main/java/com/board/dto/response/CommentResponse.java b/src/main/java/com/board/dto/response/CommentResponse.java new file mode 100644 index 0000000..538223c --- /dev/null +++ b/src/main/java/com/board/dto/response/CommentResponse.java @@ -0,0 +1,45 @@ +package com.board.dto.response; + +import com.board.entity.CommentEntity; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.time.LocalDateTime; + +@RequiredArgsConstructor +@Getter +public class CommentResponse { + + private final Long id; + private final String content; + private final Long authorId; + private final String authorName; + private final LocalDateTime createdAt; + private final boolean isDeleted; + private final Integer replyCount; + + public CommentResponse(CommentEntity comment, Integer replyCount) { + this.id = comment.getId(); + this.content = comment.getContent(); + this.authorId = comment.getMember().getId(); + this.authorName = comment.getMember().getNickName(); + this.createdAt = comment.getCreatedAt(); + this.isDeleted = comment.isDeleted(); + this.replyCount = replyCount; + } + + public static CommentResponse fromReply(CommentEntity comment) { + return new CommentResponse(comment, null); + } + + public static CommentResponse fromComment(CommentEntity comment, int replyCount) { + return new CommentResponse(comment, replyCount); + } + + public static CommentResponse of(CommentEntity comment, int replyCount) { + if (comment.isReply()) { + return fromReply(comment); + } + return fromComment(comment, replyCount); + } +} diff --git a/src/main/java/com/board/board/entity/ArticleEntity.java b/src/main/java/com/board/entity/ArticleEntity.java similarity index 72% rename from src/main/java/com/board/board/entity/ArticleEntity.java rename to src/main/java/com/board/entity/ArticleEntity.java index 9a7ea4a..6b7db75 100644 --- a/src/main/java/com/board/board/entity/ArticleEntity.java +++ b/src/main/java/com/board/entity/ArticleEntity.java @@ -1,7 +1,8 @@ -package com.board.board.entity; +package com.board.entity; -import com.board.exception.custom.DifferentOwnerException; -import com.board.member.entity.MemberEntity; +import com.common.entity.SoftDeletedEntity; +import com.exception.custom.DifferentOwnerException; +import com.member.entity.MemberEntity; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; @@ -11,7 +12,8 @@ @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -public class ArticleEntity { +@Table(name = "article") +public class ArticleEntity extends SoftDeletedEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -28,6 +30,8 @@ public class ArticleEntity { @JoinColumn(name = "member_id") private MemberEntity member; + @Column(nullable = false) + private long viewCount = 0; public ArticleEntity(String title, String content, MemberEntity member) { this.title = title; @@ -36,11 +40,16 @@ public ArticleEntity(String title, String content, MemberEntity member) { } @Builder - public ArticleEntity(Long id, String title, String content, MemberEntity member) { + public ArticleEntity(Long id, String title, String content, MemberEntity member, long viewCount) { this.id = id; this.title = title; this.content = content; this.member = member; + this.viewCount = viewCount; + } + + public void increaseViewCount(long viewCount) { + this.viewCount += viewCount; } public void update(String title, String content) { diff --git a/src/main/java/com/board/entity/CommentEntity.java b/src/main/java/com/board/entity/CommentEntity.java new file mode 100644 index 0000000..24b3d7a --- /dev/null +++ b/src/main/java/com/board/entity/CommentEntity.java @@ -0,0 +1,91 @@ +package com.board.entity; + +import com.common.entity.SoftDeletedEntity; +import com.exception.custom.DifferentOwnerException; +import com.exception.custom.NotIncludeBoardException; +import com.member.entity.MemberEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class CommentEntity extends SoftDeletedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", updatable = false) + private Long id; + + @Column(name = "content", nullable = false) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "article_id", nullable = false) + private ArticleEntity article; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private MemberEntity member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private CommentEntity parent; + + @OneToMany(mappedBy = "parent") + private List children = new ArrayList<>(); + + public CommentEntity(String content, ArticleEntity article, MemberEntity member) { + this.content = content; + this.article = article; + this.member = member; + } + + public CommentEntity(Long id, String content, ArticleEntity article, MemberEntity member) { + this.id = id; + this.content = content; + this.article = article; + this.member = member; + } + + @Builder + public CommentEntity(Long id, String content, ArticleEntity article, MemberEntity member, CommentEntity parent) { + this.id = id; + this.content = content; + this.article = article; + this.member = member; + this.parent = parent; + } + + public boolean isReply() { + return parent != null; + } + + public void validateOwner(MemberEntity member) { + if (!this.member.equals(member)) { + throw DifferentOwnerException.from(this.member.getEmail()); + } + } + + public void update(String content, MemberEntity modifier) { + validateOwner(modifier); + this.content = content; + } + + public void softDelete(MemberEntity deleter) { + validateOwner(deleter); + super.softDelete(); + } + + public void validateArticle(Long articleId) { + if (!this.article.getId().equals(articleId)) { + throw NotIncludeBoardException.from(articleId); + } + } +} diff --git a/src/main/java/com/board/member/controller/MemberController.java b/src/main/java/com/board/member/controller/MemberController.java deleted file mode 100644 index 40fae65..0000000 --- a/src/main/java/com/board/member/controller/MemberController.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.board.member.controller; - -import com.board.member.dto.request.LoginRequest; -import com.board.member.dto.request.MemberSignUpRequest; -import com.board.member.dto.response.LoginResponse; -import com.board.member.dto.response.MemberSignUpResponse; -import com.board.member.service.MemberService; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -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; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/members") -public class MemberController { - - private final MemberService memberService; - - @PostMapping("/signup") - public ResponseEntity signUp(@Valid @RequestBody MemberSignUpRequest request) { - MemberSignUpResponse memberSignUpResponse = memberService.signUp(request); - return ResponseEntity.ok(memberSignUpResponse); - } - - @PostMapping("/login") - public ResponseEntity login(@Valid @RequestBody LoginRequest loginRequest, - HttpServletResponse response) { - LoginResponse loginResponse = memberService.login(loginRequest); - - Cookie cookie = new Cookie("token", loginResponse.getAccessToken()); - cookie.setHttpOnly(true); - cookie.setPath("/"); - cookie.setMaxAge((int) loginResponse.getExpirationTime()); - response.addCookie(cookie); - - return ResponseEntity.ok(loginResponse); - } -} diff --git a/src/main/java/com/board/member/dto/response/LoginResponse.java b/src/main/java/com/board/member/dto/response/LoginResponse.java deleted file mode 100644 index ae649c2..0000000 --- a/src/main/java/com/board/member/dto/response/LoginResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.board.member.dto.response; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public class LoginResponse { - - private final String accessToken; - private final long expirationTime; -} diff --git a/src/main/java/com/board/member/service/MemberService.java b/src/main/java/com/board/member/service/MemberService.java deleted file mode 100644 index 5b18938..0000000 --- a/src/main/java/com/board/member/service/MemberService.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.board.member.service; - -import com.board.member.dto.request.LoginRequest; -import com.board.member.dto.request.MemberSignUpRequest; -import com.board.member.dto.response.LoginResponse; -import com.board.member.dto.response.MemberSignUpResponse; -import com.board.member.entity.MemberEntity; - -public interface MemberService { - - MemberSignUpResponse signUp(MemberSignUpRequest request); - - LoginResponse login(LoginRequest request); - - MemberEntity findByEmail(String email); - - MemberEntity findById(Long id); - -} diff --git a/src/main/java/com/board/member/service/MemberServiceImpl.java b/src/main/java/com/board/member/service/MemberServiceImpl.java deleted file mode 100644 index 67e80e1..0000000 --- a/src/main/java/com/board/member/service/MemberServiceImpl.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.board.member.service; - -import com.board.config.jwt.JwtUtil; -import com.board.config.jwt.TokenWithExpiration; -import com.board.exception.custom.EmailNotFoundException; -import com.board.exception.custom.MyEntityNotFoundException; -import com.board.exception.custom.SignUpException; -import com.board.member.domain.Member; -import com.board.member.dto.request.LoginRequest; -import com.board.member.dto.request.MemberSignUpRequest; -import com.board.member.dto.response.LoginResponse; -import com.board.member.dto.response.MemberSignUpResponse; -import com.board.member.entity.MemberEntity; -import com.board.member.message.ErrorMessage; -import com.board.member.repository.MemberRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Service -@Transactional(readOnly = true) -public class MemberServiceImpl implements MemberService { - - private final MemberRepository memberRepository; - private final JwtUtil jwtUtil; - - @Override - @Transactional - public MemberSignUpResponse signUp(MemberSignUpRequest request) { - - validateDuplicate(request); - - MemberEntity member = MemberEntity.builder() - .email(request.getEmail()) - .password(request.getPassword()) // 스프링 시큐리티 추가 안해서 암호화 안함 - .nickName(request.getNickName()) - .build(); - MemberEntity savedMember = memberRepository.save(member); - - return new MemberSignUpResponse(savedMember); - } - - private void validateDuplicate(MemberSignUpRequest request) { - if (memberRepository.findByEmail(request.getEmail()).isPresent()) { - throw SignUpException.from(ErrorMessage.EMAIL_DUPLICATE); - } - if (memberRepository.findByNickName(request.getNickName()).isPresent()) { - throw SignUpException.from(ErrorMessage.NICKNAME_DUPLICATE); - } - } - - @Override - public LoginResponse login(LoginRequest request) { - MemberEntity memberEntity = memberRepository.findByEmail(request.getEmail()) - .orElseThrow(() -> new IllegalArgumentException(ErrorMessage.NOT_CORRECT_LOGIN)); - - Member member = new Member(memberEntity); - member.checkPassword(request.getPassword()); - - TokenWithExpiration tokenWithExpiration = - jwtUtil.generateTokenWithExpiration(member.getEmail()); - return new LoginResponse(tokenWithExpiration.getToken(), tokenWithExpiration.getExpiration()); - } - - @Override - public MemberEntity findByEmail(String email) { - return memberRepository.findByEmail(email) - .orElseThrow(() -> EmailNotFoundException.from(email)); - } - - @Override - public MemberEntity findById(Long id) { - return memberRepository.findById(id) - .orElseThrow(() -> MyEntityNotFoundException.from(id)); - } - - -} diff --git a/src/main/java/com/board/repository/ArticleRepository.java b/src/main/java/com/board/repository/ArticleRepository.java new file mode 100644 index 0000000..f6af7f5 --- /dev/null +++ b/src/main/java/com/board/repository/ArticleRepository.java @@ -0,0 +1,17 @@ +package com.board.repository; + +import com.board.entity.ArticleEntity; +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.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ArticleRepository extends JpaRepository { + Page findAllByIsDeletedFalse(Pageable pageable); + + @Modifying(clearAutomatically = true) // 현재 안씀 + @Query("UPDATE ArticleEntity a SET a.viewCount = a.viewCount + :count WHERE a.id = :id") + void increaseViewCount(@Param("id") Long id, @Param("count") long count); +} diff --git a/src/main/java/com/board/repository/CommentRepository.java b/src/main/java/com/board/repository/CommentRepository.java new file mode 100644 index 0000000..8dc06ce --- /dev/null +++ b/src/main/java/com/board/repository/CommentRepository.java @@ -0,0 +1,20 @@ +package com.board.repository; + +import com.board.entity.ArticleEntity; +import com.board.entity.CommentEntity; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface CommentRepository extends JpaRepository { + + Optional findByIdAndIsDeletedFalse(Long id); + + Page findByArticleAndParentIsNullAndIsDeletedFalse(ArticleEntity article, Pageable pageable); + + Page findByParentAndIsDeletedFalse(CommentEntity parent, Pageable pageable); + + int countByParentAndIsDeletedFalse(CommentEntity parent); +} diff --git a/src/main/java/com/board/scheduler/ArticleViewCountSyncScheduler.java b/src/main/java/com/board/scheduler/ArticleViewCountSyncScheduler.java new file mode 100644 index 0000000..e13952e --- /dev/null +++ b/src/main/java/com/board/scheduler/ArticleViewCountSyncScheduler.java @@ -0,0 +1,36 @@ +package com.board.scheduler; + +import com.board.service.ArticleService; +import com.board.service.cache.ArticleViewCountCacheService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Slf4j +@RequiredArgsConstructor +@Component +public class ArticleViewCountSyncScheduler { + + private final ArticleViewCountCacheService cacheService; + private final ArticleService articleService; + + private static final String PREFIX = "article:viewCount:"; + + @Scheduled(fixedRate = 300000) + public void syncViewCountsToDB() { + Map cachedViewCounts = cacheService.getAllViewCounts(); + + for (Map.Entry entry : cachedViewCounts.entrySet()) { + Long articleId = entry.getKey(); + Long viewCount = entry.getValue(); + + articleService.incrementViewCount(articleId, viewCount); + cacheService.reset(articleId); // 반영 후 캐시 초기화 + } + + log.info("동기화 완료: {}개의 게시글 조회수를 DB에 반영했습니다.", cachedViewCounts.size()); + } +} diff --git a/src/main/java/com/board/service/ArticleService.java b/src/main/java/com/board/service/ArticleService.java new file mode 100644 index 0000000..1ec8078 --- /dev/null +++ b/src/main/java/com/board/service/ArticleService.java @@ -0,0 +1,81 @@ +package com.board.service; + +import com.board.dto.request.ArticleCreateRequest; +import com.board.dto.request.ArticleUpdateRequest; +import com.board.dto.response.ArticleResponse; +import com.board.entity.ArticleEntity; +import com.board.repository.ArticleRepository; +import com.board.service.cache.ArticleViewCountCacheService; +import com.exception.custom.MyEntityNotFoundException; +import com.member.entity.MemberEntity; +import com.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@Transactional(readOnly = true) +public class ArticleService { + + private final ArticleRepository articleRepository; + private final MemberService memberService; + private final ArticleViewCountCacheService viewCountCacheService; + + @Transactional + public ArticleEntity save(ArticleCreateRequest request, Long memberId) { + return articleRepository.save(ArticleEntity.builder() + .title(request.getTitle()) + .content(request.getContent()) + .member(findMemberById(memberId)) + .build()); + } + + public Page findAll(Pageable pageable) { + return articleRepository.findAllByIsDeletedFalse(pageable); + } + + @Transactional + public void delete(Long id, Long memberId) { + getOwnedArticle(id, memberId).softDelete(); + viewCountCacheService.reset(id); + } + + @Transactional + public ArticleEntity update(Long id, Long memberId, ArticleUpdateRequest request) { + ArticleEntity article = getOwnedArticle(id, memberId); + article.update(request.getTitle(), request.getContent()); + return article; + } + + private ArticleEntity getOwnedArticle(Long articleId, Long memberId) { + ArticleEntity article = findById(articleId); + MemberEntity member = findMemberById(memberId); + article.validateOwner(member); + return article; + } + + public ArticleResponse getArticleWithViewCount(Long articleId) { + ArticleEntity article = findById(articleId); + viewCountCacheService.increase(articleId); + long totalViewCount = article.getViewCount() + viewCountCacheService.getViewCount(articleId); + return ArticleResponse.from(article, totalViewCount); + } + + @Transactional + public void incrementViewCount(Long articleId, long viewCount) { + ArticleEntity article = findById(articleId); + article.increaseViewCount(viewCount); + } + + public ArticleEntity findById(Long id) { + return articleRepository.findById(id) + .orElseThrow(() -> MyEntityNotFoundException.from(id)); + } + + private MemberEntity findMemberById(Long memberId) { + return memberService.findById(memberId); + } +} diff --git a/src/main/java/com/board/service/CommentService.java b/src/main/java/com/board/service/CommentService.java new file mode 100644 index 0000000..6bd60e4 --- /dev/null +++ b/src/main/java/com/board/service/CommentService.java @@ -0,0 +1,99 @@ +package com.board.service; + +import com.board.dto.request.CommentCreateRequest; +import com.board.dto.request.CommentUpdateRequest; +import com.board.dto.response.CommentResponse; +import com.board.entity.ArticleEntity; +import com.board.entity.CommentEntity; +import com.board.repository.CommentRepository; +import com.exception.custom.MyEntityNotFoundException; +import com.member.entity.MemberEntity; +import com.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@Transactional(readOnly = true) +public class CommentService { + + private final CommentRepository commentRepository; + private final ArticleService articleService; + private final MemberService memberService; + + public Page findAllTopLevelComments(Long articleId, Pageable pageable) { + ArticleEntity article = articleService.findById(articleId); + Page comments = commentRepository.findByArticleAndParentIsNullAndIsDeletedFalse(article, pageable); + return comments.map(comment -> CommentResponse.of(comment, getReplyCount(comment))); + } + + public int getReplyCount(CommentEntity comment) { + return commentRepository.countByParentAndIsDeletedFalse(comment); + } + + @Transactional + public CommentEntity createComment(Long articleId, CommentCreateRequest request, Long memberId) { + ArticleEntity article = articleService.findById(articleId); + MemberEntity member = findMemberById(memberId); + + if (request.getParentId() != null) { + CommentEntity parent = findComment(request.getParentId()); + + if (parent.isReply()) { + throw new IllegalArgumentException("대댓글에는 대댓글을 달 수 없습니다"); + } + + if (!parent.getArticle().getId().equals(articleId)) { + throw new IllegalArgumentException("부모 댓글이 해당 게시글에 속하지 않습니다"); + } + + CommentEntity comment = CommentEntity.builder() + .content(request.getContent()) + .article(article) + .member(member) + .parent(parent) + .build(); + return commentRepository.save(comment); + } + + CommentEntity comment = new CommentEntity(request.getContent(), article, member); + return commentRepository.save(comment); + } + + @Transactional + public CommentEntity updateComment(Long articleId, Long commentId, CommentUpdateRequest request, Long memberId) { + CommentEntity comment = findComment(commentId); + comment.validateArticle(articleId); + MemberEntity member = findMemberById(memberId); + comment.update(request.getContent(), member); + return comment; + } + + @Transactional + public void deleteComment(Long articleId, Long commentId, Long memberId) { + CommentEntity comment = findComment(commentId); + comment.validateArticle(articleId); + MemberEntity member = findMemberById(memberId); + comment.softDelete(member); + } + + private CommentEntity findComment(Long id) { + return commentRepository.findByIdAndIsDeletedFalse(id) + .orElseThrow(() -> MyEntityNotFoundException.from(id)); + } + + private MemberEntity findMemberById(Long memberId) { + return memberService.findById(memberId); + } + + public Page findReplies(Long parentId, Pageable pageable) { + CommentEntity parent = findComment(parentId); + if (parent.isReply()) { + throw new IllegalArgumentException("대댓글에는 대댓글이 없습니다."); + } + return commentRepository.findByParentAndIsDeletedFalse(parent, pageable); + } +} diff --git a/src/main/java/com/board/service/cache/ArticleViewCountCacheService.java b/src/main/java/com/board/service/cache/ArticleViewCountCacheService.java new file mode 100644 index 0000000..0ea4fd0 --- /dev/null +++ b/src/main/java/com/board/service/cache/ArticleViewCountCacheService.java @@ -0,0 +1,57 @@ +package com.board.service.cache; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +@RequiredArgsConstructor +@Service +public class ArticleViewCountCacheService { + + private static final String PREFIX = "article:viewCount:"; + + private final RedisTemplate redisTemplate; + + public void increase(Long articleId) { + String key = getKey(articleId); + redisTemplate.opsForValue().increment(key); + } + + public long getViewCount(Long articleId) { + String key = getKey(articleId); + String value = redisTemplate.opsForValue().get(key); + if (value == null) { + return 0; + } + return Long.parseLong(value); + } + + public void reset(Long articleId) { + String key = getKey(articleId); + redisTemplate.delete(key); + } + + public Map getAllViewCounts() { + Set keys = redisTemplate.keys(PREFIX + "*"); + Map result = new HashMap<>(); + if (keys.isEmpty()) { + return result; + } + for (String key : keys) { + String value = redisTemplate.opsForValue().get(key); + if (value != null) { + Long articleId = Long.parseLong(key.replace(PREFIX, "")); + result.put(articleId, Long.parseLong(value)); + } + } + return result; + } + + private String getKey(Long articleId) { + return PREFIX + articleId; + } +} diff --git a/src/main/java/com/common/entity/BaseEntity.java b/src/main/java/com/common/entity/BaseEntity.java new file mode 100644 index 0000000..2145abf --- /dev/null +++ b/src/main/java/com/common/entity/BaseEntity.java @@ -0,0 +1,21 @@ +package com.common.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.PrePersist; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +public class BaseEntity { + + @Column(nullable = false) + protected LocalDateTime createdAt; + + @PrePersist + public void setCreatedAtNow() { + this.createdAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/common/entity/SoftDeletedEntity.java b/src/main/java/com/common/entity/SoftDeletedEntity.java new file mode 100644 index 0000000..2e9ab83 --- /dev/null +++ b/src/main/java/com/common/entity/SoftDeletedEntity.java @@ -0,0 +1,17 @@ +package com.common.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +@Getter +@MappedSuperclass +public class SoftDeletedEntity extends BaseEntity { + + @Column(nullable = false) + protected boolean isDeleted = false; + + public void softDelete() { + this.isDeleted = true; + } +} diff --git a/src/main/java/com/board/config/DataInitializer.java b/src/main/java/com/config/DataInitializer.java similarity index 90% rename from src/main/java/com/board/config/DataInitializer.java rename to src/main/java/com/config/DataInitializer.java index 8dc89bf..94ab22f 100644 --- a/src/main/java/com/board/config/DataInitializer.java +++ b/src/main/java/com/config/DataInitializer.java @@ -1,7 +1,7 @@ -package com.board.config; +package com.config; -import com.board.board.repository.BlogRepository; -import com.board.member.repository.MemberRepository; +import com.board.repository.ArticleRepository; +import com.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; @@ -10,7 +10,7 @@ public class DataInitializer { private final MemberRepository memberRepository; - private final BlogRepository blogRepository; + private final ArticleRepository articleRepository; /* 테스트에 방해되서 주석처리 @Bean public CommandLineRunner initData() { diff --git a/src/main/java/com/board/config/WebConfig.java b/src/main/java/com/config/WebConfig.java similarity index 87% rename from src/main/java/com/board/config/WebConfig.java rename to src/main/java/com/config/WebConfig.java index 3b82b18..2b50f5f 100644 --- a/src/main/java/com/board/config/WebConfig.java +++ b/src/main/java/com/config/WebConfig.java @@ -1,13 +1,12 @@ -package com.board.config; +package com.config; -import com.board.config.auth.AuthenticatedMemberArgumentResolver; +import com.config.auth.AuthenticatedMemberArgumentResolver; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; -import java.util.List; - @Configuration @RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { diff --git a/src/main/java/com/board/config/auth/AuthConstants.java b/src/main/java/com/config/auth/AuthConstants.java similarity index 77% rename from src/main/java/com/board/config/auth/AuthConstants.java rename to src/main/java/com/config/auth/AuthConstants.java index f43b2cd..871aeae 100644 --- a/src/main/java/com/board/config/auth/AuthConstants.java +++ b/src/main/java/com/config/auth/AuthConstants.java @@ -1,4 +1,4 @@ -package com.board.config.auth; +package com.config.auth; public class AuthConstants { public static final String AUTHENTICATED_USER = "authenticatedUser"; diff --git a/src/main/java/com/board/config/auth/AuthUtil.java b/src/main/java/com/config/auth/AuthUtil.java similarity index 55% rename from src/main/java/com/board/config/auth/AuthUtil.java rename to src/main/java/com/config/auth/AuthUtil.java index e741637..d2a562f 100644 --- a/src/main/java/com/board/config/auth/AuthUtil.java +++ b/src/main/java/com/config/auth/AuthUtil.java @@ -1,24 +1,24 @@ -package com.board.config.auth; +package com.config.auth; -import static com.board.config.auth.AuthConstants.AUTHENTICATED_USER; - -import com.board.exception.custom.ServerException; +import com.exception.custom.ServerException; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; +import static com.config.auth.AuthConstants.AUTHENTICATED_USER; + @Component public class AuthUtil { - public void saveAuthenticatedMember(String email) { + public void saveAuthenticatedMember(Long memberId) { RequestAttributes requestAttributes = getRequestAttributes(); - requestAttributes.setAttribute(AUTHENTICATED_USER, email, RequestAttributes.SCOPE_REQUEST); + requestAttributes.setAttribute(AUTHENTICATED_USER, memberId, RequestAttributes.SCOPE_REQUEST); } - public String getMemberEmail() { + public Long getMemberId() { RequestAttributes requestAttributes = getRequestAttributes(); - return (String) requestAttributes.getAttribute(AUTHENTICATED_USER, RequestAttributes.SCOPE_REQUEST); + return (Long) requestAttributes.getAttribute(AUTHENTICATED_USER, RequestAttributes.SCOPE_REQUEST); } private RequestAttributes getRequestAttributes() { @@ -28,8 +28,4 @@ private RequestAttributes getRequestAttributes() { } return requestAttributes; } - - public boolean isAuthenticated() { - return getMemberEmail() != null; - } } diff --git a/src/main/java/com/board/config/auth/AuthenticatedMemberArgumentResolver.java b/src/main/java/com/config/auth/AuthenticatedMemberArgumentResolver.java similarity index 75% rename from src/main/java/com/board/config/auth/AuthenticatedMemberArgumentResolver.java rename to src/main/java/com/config/auth/AuthenticatedMemberArgumentResolver.java index 4f68cb6..83120c2 100644 --- a/src/main/java/com/board/config/auth/AuthenticatedMemberArgumentResolver.java +++ b/src/main/java/com/config/auth/AuthenticatedMemberArgumentResolver.java @@ -1,7 +1,7 @@ -package com.board.config.auth; +package com.config.auth; -import com.board.config.auth.annotation.AuthenticatedMember; -import com.board.member.service.MemberService; +import com.config.auth.annotation.AuthenticatedMember; +import com.member.service.MemberService; import lombok.RequiredArgsConstructor; import org.springframework.core.MethodParameter; import org.springframework.stereotype.Component; @@ -19,7 +19,7 @@ public class AuthenticatedMemberArgumentResolver implements HandlerMethodArgumen @Override public boolean supportsParameter(MethodParameter parameter) { - // @AuthenticatedMember 가 붙어있고 MemberEntity 타입이면 처리 + // @AuthenticatedMember 가 붙어있고 Long 타입이면 처리 return parameter.hasParameterAnnotation(AuthenticatedMember.class) && parameter.getParameterType().equals(Long.class); } @@ -29,7 +29,6 @@ public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { - String email = authUtil.getMemberEmail(); - return memberService.findByEmail(email).getId(); // MemberEntity를 반환하여 파라미터에 주입 + return authUtil.getMemberId(); // MemberEntity를 반환하여 파라미터에 주입 } } diff --git a/src/main/java/com/board/config/auth/annotation/AuthenticatedMember.java b/src/main/java/com/config/auth/annotation/AuthenticatedMember.java similarity index 89% rename from src/main/java/com/board/config/auth/annotation/AuthenticatedMember.java rename to src/main/java/com/config/auth/annotation/AuthenticatedMember.java index d29e476..a3a90f7 100644 --- a/src/main/java/com/board/config/auth/annotation/AuthenticatedMember.java +++ b/src/main/java/com/config/auth/annotation/AuthenticatedMember.java @@ -1,4 +1,4 @@ -package com.board.config.auth.annotation; +package com.config.auth.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/src/main/java/com/board/config/filter/FilterConfig.java b/src/main/java/com/config/filter/FilterConfig.java similarity index 74% rename from src/main/java/com/board/config/filter/FilterConfig.java rename to src/main/java/com/config/filter/FilterConfig.java index 3f0c46f..4812fd9 100644 --- a/src/main/java/com/board/config/filter/FilterConfig.java +++ b/src/main/java/com/config/filter/FilterConfig.java @@ -1,8 +1,8 @@ -package com.board.config.filter; +package com.config.filter; -import com.board.config.auth.AuthUtil; -import com.board.config.jwt.JwtAuthFilter; -import com.board.config.jwt.JwtUtil; +import com.config.auth.AuthUtil; +import com.config.jwt.JwtAuthFilter; +import com.config.jwt.JwtUtil; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -19,7 +19,7 @@ public JwtAuthFilter jwtAuthFilter(JwtUtil jwtUtil, AuthUtil authUtil) { public FilterRegistrationBean jwtAuthFilterRegistration(JwtAuthFilter jwtAuthFilter) { FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(jwtAuthFilter); - registrationBean.addUrlPatterns("/articles", "/articles/*"); // 특정 URL 패턴에만 필터 적용 + registrationBean.addUrlPatterns("/*"); // 전체 요청 필터링, JwtAuthFilter 에서 판단 registrationBean.setOrder(1); // 우선순위 설정 (낮을수록 먼저 실행) return registrationBean; } diff --git a/src/main/java/com/config/jwt/AuthRequiredPath.java b/src/main/java/com/config/jwt/AuthRequiredPath.java new file mode 100644 index 0000000..789a47b --- /dev/null +++ b/src/main/java/com/config/jwt/AuthRequiredPath.java @@ -0,0 +1,42 @@ +package com.config.jwt; + +import java.util.List; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpMethod; + +@RequiredArgsConstructor +@Getter +public enum AuthRequiredPath { + CREATE_ARTICLE("/articles", List.of(HttpMethod.POST)), + UPDATE_ARTICLE("/articles/*", List.of(HttpMethod.PUT, HttpMethod.PATCH)), + DELETE_ARTICLE("/articles/*", List.of(HttpMethod.DELETE)), + + CREATE_COMMENT("/articles/*/comments", List.of(HttpMethod.POST)), + UPDATE_COMMENT("/articles/*/comments/*", List.of(HttpMethod.PUT, HttpMethod.PATCH)), + DELETE_COMMENT("/articles/*/comments/*", List.of(HttpMethod.DELETE)), + + CREATE_MEMBER("/members", List.of(HttpMethod.POST)), + DELETE_MEMBER("/members/*", List.of(HttpMethod.DELETE)), + LOGOUT_MEMBER("/members/*", List.of(HttpMethod.POST)); + + private final String pathPattern; + private final List methods; + + public static boolean isAuthRequired(HttpMethod method, String path) { + for (AuthRequiredPath arp : AuthRequiredPath.values()) { + if (pathMatchesPattern(path, arp.getPathPattern()) && arp.getMethods().contains(method)) { + return true; + } + } + return false; + } + + private static boolean pathMatchesPattern(String path, String pattern) { + String regexPattern = pattern + .replace(".", "\\.") + .replace("/*", "/[^/]+") + .replace("/**", "(/[^/]+)*"); + return path.matches("^" + regexPattern + "$"); + } +} diff --git a/src/main/java/com/board/config/jwt/JwtAuthFilter.java b/src/main/java/com/config/jwt/JwtAuthFilter.java similarity index 64% rename from src/main/java/com/board/config/jwt/JwtAuthFilter.java rename to src/main/java/com/config/jwt/JwtAuthFilter.java index 022a605..09e8767 100644 --- a/src/main/java/com/board/config/jwt/JwtAuthFilter.java +++ b/src/main/java/com/config/jwt/JwtAuthFilter.java @@ -1,38 +1,43 @@ -package com.board.config.jwt; +package com.config.jwt; -import com.board.config.auth.AuthUtil; +import com.config.auth.AuthUtil; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpMethod; import org.springframework.web.filter.OncePerRequestFilter; +import java.io.IOException; + @RequiredArgsConstructor public class JwtAuthFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final AuthUtil authUtil; - + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - if (request.getMethod().equalsIgnoreCase("GET")) { + HttpMethod method = HttpMethod.valueOf(request.getMethod().toUpperCase()); + String path = request.getRequestURI(); + + if (!AuthRequiredPath.isAuthRequired(method, path)) { filterChain.doFilter(request, response); return; } - String token = jwtUtil.extractToken(request); + String token = jwtUtil.extractFromHeader(request); if (token != null && jwtUtil.isTokenValid(token)) { - String email = jwtUtil.extractEmail(token); - authUtil.saveAuthenticatedMember(email); + Long memberId = jwtUtil.extractMemberIdFromToken(token); + authUtil.saveAuthenticatedMember(memberId); filterChain.doFilter(request, response); return; } - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized!!!###"); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized - jwt 인증 실패!!!"); } } diff --git a/src/main/java/com/board/config/jwt/JwtProperties.java b/src/main/java/com/config/jwt/JwtProperties.java similarity index 93% rename from src/main/java/com/board/config/jwt/JwtProperties.java rename to src/main/java/com/config/jwt/JwtProperties.java index 93577d9..46bb902 100644 --- a/src/main/java/com/board/config/jwt/JwtProperties.java +++ b/src/main/java/com/config/jwt/JwtProperties.java @@ -1,4 +1,4 @@ -package com.board.config.jwt; +package com.config.jwt; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/com/board/config/jwt/JwtUtil.java b/src/main/java/com/config/jwt/JwtUtil.java similarity index 59% rename from src/main/java/com/board/config/jwt/JwtUtil.java rename to src/main/java/com/config/jwt/JwtUtil.java index e61f443..77228bd 100644 --- a/src/main/java/com/board/config/jwt/JwtUtil.java +++ b/src/main/java/com/config/jwt/JwtUtil.java @@ -1,40 +1,28 @@ -package com.board.config.jwt; +package com.config.jwt; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.Date; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.nio.charset.StandardCharsets; +import java.util.Date; + @RequiredArgsConstructor @Service public class JwtUtil { private static final String AUTHORIZATION_HEADER = "Authorization"; private static final String BEARER_PREFIX = "Bearer "; - private static final String COOKIE_NAME = "token"; private static final long ACCESS_TOKEN_EXPIRATION = 1000 * 60 * 60; // 1시간 + private static final long REFRESH_TOKEN_EXPIRATION = 1000L * 60 * 60 * 24 * 7; // 7일 private final JwtProperties jwtProperties; - public String extractToken(HttpServletRequest request) { - if (request.getCookies() != null) { - return Arrays.stream(request.getCookies()) - .filter(cookie -> COOKIE_NAME.equals(cookie.getName())) - .map(Cookie::getValue) - .findFirst() - .orElseGet(() -> extractFromHeader(request)); // 없으면 헤더에서 추출 - } - return extractFromHeader(request); // 쿠키 자체가 없으면 바로 헤더에서 추출 - } - - private String extractFromHeader(HttpServletRequest request) { + public String extractFromHeader(HttpServletRequest request) { String header = request.getHeader(AUTHORIZATION_HEADER); if (header != null && header.startsWith(BEARER_PREFIX)) { return header.substring(BEARER_PREFIX.length()); @@ -42,27 +30,34 @@ private String extractFromHeader(HttpServletRequest request) { return null; } - public TokenWithExpiration generateTokenWithExpiration(String subject) { - return new TokenWithExpiration(generateToken(subject), ACCESS_TOKEN_EXPIRATION); + public TokenWithExpiration generateAccessToken(Long memberId) { + String token = generateToken(memberId, ACCESS_TOKEN_EXPIRATION); + return new TokenWithExpiration(token, ACCESS_TOKEN_EXPIRATION); + } + + public TokenWithExpiration generateRefreshToken(Long memberId) { + String token = generateToken(memberId, REFRESH_TOKEN_EXPIRATION); + return new TokenWithExpiration(token, REFRESH_TOKEN_EXPIRATION); } - public String generateToken(String email) { + public String generateToken(Long memberId, long expirationTime) { return Jwts.builder() - .setSubject(email) // sub : 이메일(jwt 주인) + .setSubject(String.valueOf(memberId)) // sub : 이메일(jwt 주인) .setIssuer(jwtProperties.getIssuer()) // Issuer 설정 (필수아님) .setIssuedAt(new Date()) // 발급시간 (필수아님) - .setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRATION)) // 유효시간 필수! + .setExpiration(new Date(System.currentTimeMillis() + expirationTime)) // 유효시간 필수! .signWith(Keys.hmacShaKeyFor(getSigningKey()), SignatureAlgorithm.HS256) .compact(); } - public String extractEmail(String token) { - return Jwts.parserBuilder() + public Long extractMemberIdFromToken(String token) { + String subject = Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody() .getSubject(); + return Long.valueOf(subject); } public boolean isTokenValid(String token) { @@ -78,6 +73,7 @@ public boolean isTokenValid(String token) { } private byte[] getSigningKey() { - return jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8); + return jwtProperties.getSecretKey() + .getBytes(StandardCharsets.UTF_8); } } diff --git a/src/main/java/com/board/config/jwt/TokenWithExpiration.java b/src/main/java/com/config/jwt/TokenWithExpiration.java similarity index 86% rename from src/main/java/com/board/config/jwt/TokenWithExpiration.java rename to src/main/java/com/config/jwt/TokenWithExpiration.java index 919c80a..73441e4 100644 --- a/src/main/java/com/board/config/jwt/TokenWithExpiration.java +++ b/src/main/java/com/config/jwt/TokenWithExpiration.java @@ -1,4 +1,4 @@ -package com.board.config.jwt; +package com.config.jwt; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/com/config/jwt/token/RefreshToken.java b/src/main/java/com/config/jwt/token/RefreshToken.java new file mode 100644 index 0000000..6777a35 --- /dev/null +++ b/src/main/java/com/config/jwt/token/RefreshToken.java @@ -0,0 +1,30 @@ +package com.config.jwt.token; + +import com.common.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Table(name = "refreshToken") +@AllArgsConstructor +public class RefreshToken extends BaseEntity { + + @Id + @Column(name = "member_id", updatable = false) + private Long memberId; + + @Column(nullable = false) + private String token; + + public void update(String newToken) { + this.token = newToken; + } +} diff --git a/src/main/java/com/config/jwt/token/RefreshTokenRepository.java b/src/main/java/com/config/jwt/token/RefreshTokenRepository.java new file mode 100644 index 0000000..1410402 --- /dev/null +++ b/src/main/java/com/config/jwt/token/RefreshTokenRepository.java @@ -0,0 +1,6 @@ +package com.config.jwt.token; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RefreshTokenRepository extends JpaRepository { +} diff --git a/src/main/java/com/config/jwt/token/RefreshTokenService.java b/src/main/java/com/config/jwt/token/RefreshTokenService.java new file mode 100644 index 0000000..523ee25 --- /dev/null +++ b/src/main/java/com/config/jwt/token/RefreshTokenService.java @@ -0,0 +1,41 @@ +package com.config.jwt.token; + +import com.exception.custom.MyEntityNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RefreshTokenService { + + private final RefreshTokenRepository refreshTokenRepository; + + @Transactional + public void mergeToken(Long memberId, String token) { + RefreshToken refreshToken = refreshTokenRepository.findById(memberId) + .orElse(null); + if (refreshToken != null) { + refreshToken.update(token); + return; + } + refreshTokenRepository.save(new RefreshToken(memberId, token)); + } + + public boolean isTokenValid(Long memberId, String token) { + return refreshTokenRepository.findById(memberId) + .map(saved -> saved.getToken().equals(token)) + .orElse(false); + } + + @Transactional + public void deleteToken(Long memberId) { + refreshTokenRepository.deleteById(memberId); + } + + public RefreshToken findByMemberId(Long memberId) { + return refreshTokenRepository.findById(memberId) + .orElseThrow(() -> MyEntityNotFoundException.from(memberId)); + } +} diff --git a/src/main/java/com/config/redis/EmbeddedRedisConfig.java b/src/main/java/com/config/redis/EmbeddedRedisConfig.java new file mode 100644 index 0000000..9aeb316 --- /dev/null +++ b/src/main/java/com/config/redis/EmbeddedRedisConfig.java @@ -0,0 +1,30 @@ +package com.config.redis; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import redis.embedded.RedisServer; + +import java.io.IOException; + +@Configuration +@Profile("test") +public class EmbeddedRedisConfig { + + private RedisServer redisServer; + + @PostConstruct + public void startRedis() throws IOException { + redisServer = new RedisServer(6380); + redisServer.start(); + } + + @PreDestroy + public void stopRedis() { + if (redisServer != null) { + redisServer.stop(); + } + } + +} diff --git a/src/main/java/com/config/redis/RedisConfig.java b/src/main/java/com/config/redis/RedisConfig.java new file mode 100644 index 0000000..cf6759e --- /dev/null +++ b/src/main/java/com/config/redis/RedisConfig.java @@ -0,0 +1,23 @@ +package com.config.redis; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(connectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + redisTemplate.afterPropertiesSet(); + return redisTemplate; + } +} diff --git a/src/main/java/com/board/exception/CustomException.java b/src/main/java/com/exception/CustomException.java similarity index 93% rename from src/main/java/com/board/exception/CustomException.java rename to src/main/java/com/exception/CustomException.java index a2b60ae..974cb82 100644 --- a/src/main/java/com/board/exception/CustomException.java +++ b/src/main/java/com/exception/CustomException.java @@ -1,4 +1,4 @@ -package com.board.exception; +package com.exception; import java.util.Map; import lombok.Getter; diff --git a/src/main/java/com/board/exception/ErrorCodeType.java b/src/main/java/com/exception/ErrorCodeType.java similarity index 79% rename from src/main/java/com/board/exception/ErrorCodeType.java rename to src/main/java/com/exception/ErrorCodeType.java index 582dcb8..10a8881 100644 --- a/src/main/java/com/board/exception/ErrorCodeType.java +++ b/src/main/java/com/exception/ErrorCodeType.java @@ -1,4 +1,4 @@ -package com.board.exception; +package com.exception; import lombok.Getter; import org.springframework.http.HttpStatus; @@ -10,7 +10,9 @@ public enum ErrorCodeType implements ErrorType { AUTHENTICATION_ERROR("AUTHENTICATION_ERROR", "인증 실패", HttpStatus.UNAUTHORIZED), AUTHORIZATION_ERROR("AUTHORIZATION_ERROR", "권한 없음", HttpStatus.FORBIDDEN), SYSTEM_ERROR("SYSTEM_ERROR", "시스템 오류", HttpStatus.INTERNAL_SERVER_ERROR), - DUPLICATE("SIGNUP_DUPLICATE", "중복발생", HttpStatus.CONFLICT); + DUPLICATE("SIGNUP_DUPLICATE", "중복발생", HttpStatus.CONFLICT), + MISMATCHED_DATA("MISMATCHED_DATA", "일치하지 않는 데이터", HttpStatus.BAD_REQUEST), + INVALID_REFRESH_TOKEN("INVALID_REFRESH_TOKEN", "유효하지 않은 리프레시 토큰", HttpStatus.UNAUTHORIZED); private final String code; private final String message; diff --git a/src/main/java/com/board/exception/ErrorResponse.java b/src/main/java/com/exception/ErrorResponse.java similarity index 97% rename from src/main/java/com/board/exception/ErrorResponse.java rename to src/main/java/com/exception/ErrorResponse.java index a4b5073..2ad5a93 100644 --- a/src/main/java/com/board/exception/ErrorResponse.java +++ b/src/main/java/com/exception/ErrorResponse.java @@ -1,4 +1,4 @@ -package com.board.exception; +package com.exception; import java.time.LocalDateTime; import java.util.HashMap; diff --git a/src/main/java/com/board/exception/ErrorType.java b/src/main/java/com/exception/ErrorType.java similarity index 84% rename from src/main/java/com/board/exception/ErrorType.java rename to src/main/java/com/exception/ErrorType.java index b024d11..128edc2 100644 --- a/src/main/java/com/board/exception/ErrorType.java +++ b/src/main/java/com/exception/ErrorType.java @@ -1,4 +1,4 @@ -package com.board.exception; +package com.exception; import org.springframework.http.HttpStatus; diff --git a/src/main/java/com/board/exception/GlobalExceptionHandler.java b/src/main/java/com/exception/GlobalExceptionHandler.java similarity index 94% rename from src/main/java/com/board/exception/GlobalExceptionHandler.java rename to src/main/java/com/exception/GlobalExceptionHandler.java index 16bd24b..dc4f9e2 100644 --- a/src/main/java/com/board/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/exception/GlobalExceptionHandler.java @@ -1,6 +1,6 @@ -package com.board.exception; +package com.exception; -import static com.board.exception.ErrorCodeType.VALIDATION_ERROR; +import static com.exception.ErrorCodeType.VALIDATION_ERROR; import java.util.List; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/board/exception/custom/DifferentOwnerException.java b/src/main/java/com/exception/custom/DifferentOwnerException.java similarity index 81% rename from src/main/java/com/board/exception/custom/DifferentOwnerException.java rename to src/main/java/com/exception/custom/DifferentOwnerException.java index fbbadf9..4ae3cec 100644 --- a/src/main/java/com/board/exception/custom/DifferentOwnerException.java +++ b/src/main/java/com/exception/custom/DifferentOwnerException.java @@ -1,7 +1,7 @@ -package com.board.exception.custom; +package com.exception.custom; -import com.board.exception.CustomException; -import com.board.exception.ErrorCodeType; +import com.exception.CustomException; +import com.exception.ErrorCodeType; import java.util.Map; import lombok.Getter; diff --git a/src/main/java/com/board/exception/custom/EmailNotFoundException.java b/src/main/java/com/exception/custom/EmailNotFoundException.java similarity index 80% rename from src/main/java/com/board/exception/custom/EmailNotFoundException.java rename to src/main/java/com/exception/custom/EmailNotFoundException.java index 702c6ed..8308eab 100644 --- a/src/main/java/com/board/exception/custom/EmailNotFoundException.java +++ b/src/main/java/com/exception/custom/EmailNotFoundException.java @@ -1,7 +1,7 @@ -package com.board.exception.custom; +package com.exception.custom; -import com.board.exception.CustomException; -import com.board.exception.ErrorCodeType; +import com.exception.CustomException; +import com.exception.ErrorCodeType; import java.util.Map; import lombok.Getter; diff --git a/src/main/java/com/exception/custom/InvalidToken.java b/src/main/java/com/exception/custom/InvalidToken.java new file mode 100644 index 0000000..497dd68 --- /dev/null +++ b/src/main/java/com/exception/custom/InvalidToken.java @@ -0,0 +1,15 @@ +package com.exception.custom; + +import com.exception.CustomException; +import com.exception.ErrorCodeType; +import com.exception.ErrorType; + +public class InvalidToken extends CustomException { + private InvalidToken(ErrorType exceptionType) { + super(exceptionType); + } + + public static InvalidToken getInstance() { + return new InvalidToken(ErrorCodeType.INVALID_REFRESH_TOKEN); + } +} diff --git a/src/main/java/com/exception/custom/LoginException.java b/src/main/java/com/exception/custom/LoginException.java new file mode 100644 index 0000000..2251a8a --- /dev/null +++ b/src/main/java/com/exception/custom/LoginException.java @@ -0,0 +1,26 @@ +package com.exception.custom; + +import com.exception.CustomException; +import com.exception.ErrorCodeType; +import java.util.Map; +import lombok.Getter; + +@Getter +public class LoginException extends CustomException { + + private final String errorMessage; + + private LoginException(String errorMessage) { + super(ErrorCodeType.ENTITY_NOT_FOUND); + this.errorMessage = errorMessage; + } + + @Override + public Map getAdditionalDetails() { + return Map.of("errorMessage", errorMessage); + } + + public static LoginException from(String errorMessage) { + return new LoginException(errorMessage); + } +} diff --git a/src/main/java/com/board/exception/custom/MyEntityNotFoundException.java b/src/main/java/com/exception/custom/MyEntityNotFoundException.java similarity index 59% rename from src/main/java/com/board/exception/custom/MyEntityNotFoundException.java rename to src/main/java/com/exception/custom/MyEntityNotFoundException.java index 6322f1a..d55679f 100644 --- a/src/main/java/com/board/exception/custom/MyEntityNotFoundException.java +++ b/src/main/java/com/exception/custom/MyEntityNotFoundException.java @@ -1,15 +1,16 @@ -package com.board.exception.custom; +package com.exception.custom; -import com.board.exception.CustomException; -import com.board.exception.ErrorCodeType; -import java.util.Map; +import com.exception.CustomException; +import com.exception.ErrorCodeType; import lombok.Getter; +import java.util.Map; + @Getter public class MyEntityNotFoundException extends CustomException { - private final long entityId; + private final Long entityId; - private MyEntityNotFoundException(long entityId) { + private MyEntityNotFoundException(Long entityId) { super(ErrorCodeType.ENTITY_NOT_FOUND); this.entityId = entityId; } @@ -19,7 +20,7 @@ public Map getAdditionalDetails() { return Map.of("entityId", entityId); } - public static MyEntityNotFoundException from(long entityId) { + public static MyEntityNotFoundException from(Long entityId) { return new MyEntityNotFoundException(entityId); } } diff --git a/src/main/java/com/exception/custom/NotIncludeBoardException.java b/src/main/java/com/exception/custom/NotIncludeBoardException.java new file mode 100644 index 0000000..f1f264a --- /dev/null +++ b/src/main/java/com/exception/custom/NotIncludeBoardException.java @@ -0,0 +1,24 @@ +package com.exception.custom; + +import com.exception.CustomException; +import com.exception.ErrorCodeType; +import java.util.Map; + +public class NotIncludeBoardException extends CustomException { + + private final Long articleId; + + private NotIncludeBoardException(Long articleId) { + super(ErrorCodeType.MISMATCHED_DATA); + this.articleId = articleId; + } + + @Override + public Map getAdditionalDetails() { + return Map.of("errorMessage", "해당 댓글은 게시글 " + articleId + "에 속하지 않습니다."); + } + + public static NotIncludeBoardException from(Long articleId) { + return new NotIncludeBoardException(articleId); + } +} diff --git a/src/main/java/com/board/exception/custom/ServerException.java b/src/main/java/com/exception/custom/ServerException.java similarity index 68% rename from src/main/java/com/board/exception/custom/ServerException.java rename to src/main/java/com/exception/custom/ServerException.java index 7457044..f37f2a5 100644 --- a/src/main/java/com/board/exception/custom/ServerException.java +++ b/src/main/java/com/exception/custom/ServerException.java @@ -1,7 +1,7 @@ -package com.board.exception.custom; +package com.exception.custom; -import com.board.exception.CustomException; -import com.board.exception.ErrorCodeType; +import com.exception.CustomException; +import com.exception.ErrorCodeType; import lombok.Getter; @Getter diff --git a/src/main/java/com/board/exception/custom/SignUpException.java b/src/main/java/com/exception/custom/SignUpException.java similarity index 81% rename from src/main/java/com/board/exception/custom/SignUpException.java rename to src/main/java/com/exception/custom/SignUpException.java index dac9b61..fe5a48e 100644 --- a/src/main/java/com/board/exception/custom/SignUpException.java +++ b/src/main/java/com/exception/custom/SignUpException.java @@ -1,7 +1,7 @@ -package com.board.exception.custom; +package com.exception.custom; -import com.board.exception.CustomException; -import com.board.exception.ErrorCodeType; +import com.exception.CustomException; +import com.exception.ErrorCodeType; import java.util.Map; import lombok.Getter; diff --git a/src/main/java/com/member/controller/MemberController.java b/src/main/java/com/member/controller/MemberController.java new file mode 100644 index 0000000..c086f29 --- /dev/null +++ b/src/main/java/com/member/controller/MemberController.java @@ -0,0 +1,37 @@ +package com.member.controller; + +import com.config.auth.annotation.AuthenticatedMember; +import com.member.dto.response.LoginResponse; +import com.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/members") +public class MemberController { + + private final MemberService memberService; + + @PostMapping("/logout") + public ResponseEntity logout(@AuthenticatedMember Long memberId) { + memberService.logout(memberId); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/withdraw") + public ResponseEntity withdraw(@AuthenticatedMember Long memberId) { + memberService.withdraw(memberId); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/reissue") + public ResponseEntity reissueToken(@AuthenticatedMember Long memberId) { + LoginResponse newAccessToken = memberService.reissueAccessToken(memberId); + return ResponseEntity.ok(newAccessToken); + } +} diff --git a/src/main/java/com/member/controller/PublicMemberController.java b/src/main/java/com/member/controller/PublicMemberController.java new file mode 100644 index 0000000..8ae9cda --- /dev/null +++ b/src/main/java/com/member/controller/PublicMemberController.java @@ -0,0 +1,36 @@ +package com.member.controller; + +import com.member.dto.request.LoginRequest; +import com.member.dto.request.SignUpRequest; +import com.member.dto.response.LoginResponse; +import com.member.dto.response.SignUpResponse; +import com.member.service.MemberService; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/public/members") +public class PublicMemberController { + + private final MemberService memberService; + + @PostMapping("/signup") + public ResponseEntity signUp(@Valid @RequestBody SignUpRequest request) { + SignUpResponse signUpResponse = memberService.signUp(request); + return ResponseEntity.ok(signUpResponse); + } + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest loginRequest, + HttpServletResponse response) { + LoginResponse loginResponse = memberService.login(loginRequest); + return ResponseEntity.ok(loginResponse); + } +} diff --git a/src/main/java/com/board/member/domain/Member.java b/src/main/java/com/member/domain/Member.java similarity index 76% rename from src/main/java/com/board/member/domain/Member.java rename to src/main/java/com/member/domain/Member.java index 82dd3ad..79ebae7 100644 --- a/src/main/java/com/board/member/domain/Member.java +++ b/src/main/java/com/member/domain/Member.java @@ -1,16 +1,18 @@ -package com.board.member.domain; +package com.member.domain; -import com.board.member.entity.MemberEntity; -import com.board.member.message.ErrorMessage; +import com.member.entity.MemberEntity; +import com.member.message.ErrorMessage; import lombok.Getter; @Getter public class Member { + private final Long id; private final String email; private final String password; public Member(MemberEntity memberEntity) { + this.id = memberEntity.getId(); this.email = memberEntity.getEmail(); this.password = memberEntity.getPassword(); } diff --git a/src/main/java/com/board/member/dto/request/LoginRequest.java b/src/main/java/com/member/dto/request/LoginRequest.java similarity index 93% rename from src/main/java/com/board/member/dto/request/LoginRequest.java rename to src/main/java/com/member/dto/request/LoginRequest.java index 17ff91f..555d008 100644 --- a/src/main/java/com/board/member/dto/request/LoginRequest.java +++ b/src/main/java/com/member/dto/request/LoginRequest.java @@ -1,4 +1,4 @@ -package com.board.member.dto.request; +package com.member.dto.request; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/board/member/dto/request/MemberSignUpRequest.java b/src/main/java/com/member/dto/request/SignUpRequest.java similarity index 79% rename from src/main/java/com/board/member/dto/request/MemberSignUpRequest.java rename to src/main/java/com/member/dto/request/SignUpRequest.java index ee26eb2..f0a7e4c 100644 --- a/src/main/java/com/board/member/dto/request/MemberSignUpRequest.java +++ b/src/main/java/com/member/dto/request/SignUpRequest.java @@ -1,4 +1,4 @@ -package com.board.member.dto.request; +package com.member.dto.request; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; @@ -6,7 +6,7 @@ import lombok.Getter; @Getter -public class MemberSignUpRequest { +public class SignUpRequest { @NotBlank(message = "이메일은 필수 입력값이에용") @Email @@ -19,7 +19,7 @@ public class MemberSignUpRequest { private final String password; @Builder - public MemberSignUpRequest(String email, String nickName, String password) { + public SignUpRequest(String email, String nickName, String password) { this.email = email; this.nickName = nickName; this.password = password; diff --git a/src/main/java/com/member/dto/response/LoginResponse.java b/src/main/java/com/member/dto/response/LoginResponse.java new file mode 100644 index 0000000..65fa5bb --- /dev/null +++ b/src/main/java/com/member/dto/response/LoginResponse.java @@ -0,0 +1,17 @@ +package com.member.dto.response; + +import com.config.jwt.TokenWithExpiration; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class LoginResponse { + private final TokenWithExpiration accessToken; + private final TokenWithExpiration refreshToken; + + @Builder + public LoginResponse(TokenWithExpiration accessToken, TokenWithExpiration refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } +} diff --git a/src/main/java/com/board/member/dto/response/MemberSignUpResponse.java b/src/main/java/com/member/dto/response/SignUpResponse.java similarity index 65% rename from src/main/java/com/board/member/dto/response/MemberSignUpResponse.java rename to src/main/java/com/member/dto/response/SignUpResponse.java index a245c16..295b1dc 100644 --- a/src/main/java/com/board/member/dto/response/MemberSignUpResponse.java +++ b/src/main/java/com/member/dto/response/SignUpResponse.java @@ -1,18 +1,18 @@ -package com.board.member.dto.response; +package com.member.dto.response; -import com.board.member.entity.MemberEntity; +import com.member.entity.MemberEntity; import lombok.Getter; import lombok.RequiredArgsConstructor; @Getter @RequiredArgsConstructor -public class MemberSignUpResponse { +public class SignUpResponse { private final Long id; private final String email; private final String nickName; - public MemberSignUpResponse(MemberEntity memberEntity) { + public SignUpResponse(MemberEntity memberEntity) { this.id = memberEntity.getId(); this.email = memberEntity.getEmail(); this.nickName = memberEntity.getNickName(); diff --git a/src/main/java/com/board/member/entity/MemberEntity.java b/src/main/java/com/member/entity/MemberEntity.java similarity index 69% rename from src/main/java/com/board/member/entity/MemberEntity.java rename to src/main/java/com/member/entity/MemberEntity.java index 5cd74d5..1383dfc 100644 --- a/src/main/java/com/board/member/entity/MemberEntity.java +++ b/src/main/java/com/member/entity/MemberEntity.java @@ -1,18 +1,15 @@ -package com.board.member.entity; +package com.member.entity; +import com.common.entity.SoftDeletedEntity; import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; +import lombok.*; @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @Table(name = "member") -public class MemberEntity { +@EqualsAndHashCode(of = "id", callSuper = false) +public class MemberEntity extends SoftDeletedEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -28,9 +25,6 @@ public class MemberEntity { @Column(nullable = false, unique = true) private String nickName; - @Column(nullable = false) - private LocalDateTime createdAt; - @Builder public MemberEntity(String email, String password, String nickName) { this.email = email; @@ -44,9 +38,4 @@ public MemberEntity(Long id, String email, String password, String nickName) { this.password = password; this.nickName = nickName; } - - @PrePersist - public void setCreatedAtNow() { - this.createdAt = LocalDateTime.now(); - } } diff --git a/src/main/java/com/board/member/message/ErrorMessage.java b/src/main/java/com/member/message/ErrorMessage.java similarity index 90% rename from src/main/java/com/board/member/message/ErrorMessage.java rename to src/main/java/com/member/message/ErrorMessage.java index e20e7ee..2f02ec0 100644 --- a/src/main/java/com/board/member/message/ErrorMessage.java +++ b/src/main/java/com/member/message/ErrorMessage.java @@ -1,4 +1,4 @@ -package com.board.member.message; +package com.member.message; public class ErrorMessage { public static final String EMAIL_DUPLICATE = "이미 가입된 이메일입니다."; diff --git a/src/main/java/com/board/member/repository/MemberRepository.java b/src/main/java/com/member/repository/MemberRepository.java similarity index 67% rename from src/main/java/com/board/member/repository/MemberRepository.java rename to src/main/java/com/member/repository/MemberRepository.java index 7d7831b..90a4ad1 100644 --- a/src/main/java/com/board/member/repository/MemberRepository.java +++ b/src/main/java/com/member/repository/MemberRepository.java @@ -1,13 +1,14 @@ -package com.board.member.repository; - -import com.board.member.entity.MemberEntity; -import org.springframework.data.jpa.repository.JpaRepository; +package com.member.repository; +import com.member.entity.MemberEntity; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; public interface MemberRepository extends JpaRepository { Optional findByEmail(String email); Optional findByNickName(String nickName); + + Optional findByEmailAndIsDeletedFalse(String email); } diff --git a/src/main/java/com/member/service/MemberService.java b/src/main/java/com/member/service/MemberService.java new file mode 100644 index 0000000..07599d3 --- /dev/null +++ b/src/main/java/com/member/service/MemberService.java @@ -0,0 +1,25 @@ +package com.member.service; + +import com.member.dto.request.LoginRequest; +import com.member.dto.request.SignUpRequest; +import com.member.dto.response.LoginResponse; +import com.member.dto.response.SignUpResponse; +import com.member.entity.MemberEntity; + +public interface MemberService { + + SignUpResponse signUp(SignUpRequest request); + + LoginResponse login(LoginRequest request); + + MemberEntity findByEmail(String email); + + MemberEntity findById(Long id); + + void logout(Long memberId); + + void withdraw(Long memberId); + + LoginResponse reissueAccessToken(Long refreshToken); + +} diff --git a/src/main/java/com/member/service/MemberServiceImpl.java b/src/main/java/com/member/service/MemberServiceImpl.java new file mode 100644 index 0000000..26c9ba4 --- /dev/null +++ b/src/main/java/com/member/service/MemberServiceImpl.java @@ -0,0 +1,117 @@ +package com.member.service; + +import com.config.jwt.JwtUtil; +import com.config.jwt.TokenWithExpiration; +import com.config.jwt.token.RefreshToken; +import com.config.jwt.token.RefreshTokenService; +import com.exception.custom.EmailNotFoundException; +import com.exception.custom.InvalidToken; +import com.exception.custom.LoginException; +import com.exception.custom.MyEntityNotFoundException; +import com.exception.custom.SignUpException; +import com.member.domain.Member; +import com.member.dto.request.LoginRequest; +import com.member.dto.request.SignUpRequest; +import com.member.dto.response.LoginResponse; +import com.member.dto.response.SignUpResponse; +import com.member.entity.MemberEntity; +import com.member.message.ErrorMessage; +import com.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@Slf4j +@Transactional(readOnly = true) +public class MemberServiceImpl implements MemberService { + + private final MemberRepository memberRepository; + private final RefreshTokenService refreshTokenService; + private final JwtUtil jwtUtil; + + @Override + @Transactional + public SignUpResponse signUp(SignUpRequest request) { + + validateDuplicate(request); + + MemberEntity member = MemberEntity.builder() + .email(request.getEmail()) + .password(request.getPassword()) // 스프링 시큐리티 추가 안해서 암호화 안함 + .nickName(request.getNickName()) + .build(); + MemberEntity savedMember = memberRepository.save(member); + + return new SignUpResponse(savedMember); + } + + private void validateDuplicate(SignUpRequest request) { + if (memberRepository.findByEmail(request.getEmail()).isPresent()) { + throw SignUpException.from(ErrorMessage.EMAIL_DUPLICATE); + } + if (memberRepository.findByNickName(request.getNickName()).isPresent()) { + throw SignUpException.from(ErrorMessage.NICKNAME_DUPLICATE); + } + } + + @Override + @Transactional + public LoginResponse login(LoginRequest request) { + MemberEntity memberEntity = memberRepository.findByEmailAndIsDeletedFalse(request.getEmail()) + .orElseThrow(() -> LoginException.from(ErrorMessage.NOT_CORRECT_LOGIN)); + + Member member = new Member(memberEntity); + member.checkPassword(request.getPassword()); + + TokenWithExpiration accessToken = + jwtUtil.generateAccessToken(member.getId()); + TokenWithExpiration refreshToken = + jwtUtil.generateRefreshToken(member.getId()); + + refreshTokenService.mergeToken(member.getId(), refreshToken.getToken()); + + return new LoginResponse(accessToken, refreshToken); + } + + @Override + public MemberEntity findByEmail(String email) { + return memberRepository.findByEmail(email) + .orElseThrow(() -> EmailNotFoundException.from(email)); + } + + @Override + public MemberEntity findById(Long id) { + return memberRepository.findById(id) + .orElseThrow(() -> MyEntityNotFoundException.from(id)); + } + + @Override + @Transactional + public void logout(Long memberId) { + refreshTokenService.deleteToken(memberId); + log.info("회원 {} 로그아웃함", memberId); + } + + @Transactional + public void withdraw(Long memberId) { + MemberEntity member = findById(memberId); + member.softDelete(); + } + + @Override + public LoginResponse reissueAccessToken(Long memberId) { + RefreshToken savedToken = refreshTokenService.findByMemberId(memberId); + + if (!jwtUtil.isTokenValid(savedToken.getToken())) { + throw InvalidToken.getInstance(); + } + + TokenWithExpiration newAccessToken = jwtUtil.generateAccessToken(memberId); + return LoginResponse.builder() + .accessToken(newAccessToken) + .build(); // 리프래시 토큰은 줄 필요 없음 + } +} diff --git a/src/main/java/com/util/page/PageResponse.java b/src/main/java/com/util/page/PageResponse.java new file mode 100644 index 0000000..34290b6 --- /dev/null +++ b/src/main/java/com/util/page/PageResponse.java @@ -0,0 +1,29 @@ +package com.util.page; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.data.domain.Page; + +import java.util.List; + +@AllArgsConstructor +@Getter +public class PageResponse { + private List content; + private int page; + private int size; + private long totalElements; + private int totalPages; + private boolean isLast; + + public static PageResponse from(Page page) { + return new PageResponse<>( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages(), + page.isLast() + ); + } +} diff --git a/src/main/java/com/util/sort/SortUtils.java b/src/main/java/com/util/sort/SortUtils.java new file mode 100644 index 0000000..9fd98e4 --- /dev/null +++ b/src/main/java/com/util/sort/SortUtils.java @@ -0,0 +1,40 @@ +package com.util.sort; + +import java.util.Map; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Order; + +@Slf4j +public class SortUtils { + + private static final Map SORT_ALIAS_MAP = Map.of( + "latest", Order.desc("createdAt"), + "oldest", Order.asc("createdAt"), + "views", Order.desc("viewCount") + ); + + private static final Set ARTICLE_SORT_FIELDS = Set.of("createdAt", "viewCount"); + private static final Set COMMENT_SORT_FIELDS = Set.of("createdAt"); + + public static Sort getArticleSort(String sortParam) { + return createSort(sortParam, ARTICLE_SORT_FIELDS, Order.desc("createdAt")); + } + + public static Sort getCommentSort(String sortParam) { + return createSort(sortParam, COMMENT_SORT_FIELDS, Order.desc("createdAt")); + } + + private static Sort createSort(String sortParam, Set allowedFields, Order defaultOrder) { + Order mappedOrder = SORT_ALIAS_MAP.getOrDefault(sortParam, defaultOrder); + + if (!allowedFields.contains(mappedOrder.getProperty())) { + log.warn("Unsupported sort field '{}'. Falling back to default '{}'", sortParam, + defaultOrder.getProperty()); + mappedOrder = defaultOrder; + } + + return Sort.by(mappedOrder); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7a43f09..6282d3d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,6 +12,10 @@ spring: hibernate: format_sql: true + redis: + host: localhost + port: 6379 + datasource: url: jdbc:h2:mem:testdb diff --git a/src/test/java/com/board/BoardApplicationTests.java b/src/test/java/com/BoardApplicationTests.java similarity index 90% rename from src/test/java/com/board/BoardApplicationTests.java rename to src/test/java/com/BoardApplicationTests.java index 7d12c37..ff6b29d 100644 --- a/src/test/java/com/board/BoardApplicationTests.java +++ b/src/test/java/com/BoardApplicationTests.java @@ -1,4 +1,4 @@ -package com.board; +package com; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; diff --git a/src/test/java/com/board/board/controller/BlogApiControllerIntegrationTest.java b/src/test/java/com/board/controller/ArticleControllerIntegrationTest.java similarity index 50% rename from src/test/java/com/board/board/controller/BlogApiControllerIntegrationTest.java rename to src/test/java/com/board/controller/ArticleControllerIntegrationTest.java index c4b9508..66a72e1 100644 --- a/src/test/java/com/board/board/controller/BlogApiControllerIntegrationTest.java +++ b/src/test/java/com/board/controller/ArticleControllerIntegrationTest.java @@ -1,47 +1,35 @@ -package com.board.board.controller; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import com.board.board.dto.request.ArticleCreateRequest; -import com.board.board.dto.request.ArticleUpdateRequest; -import com.board.board.entity.ArticleEntity; -import com.board.board.repository.BlogRepository; -import com.board.config.jwt.JwtUtil; -import com.board.member.dto.request.LoginRequest; -import com.board.member.dto.request.MemberSignUpRequest; -import com.board.member.entity.MemberEntity; -import com.board.member.repository.MemberRepository; +package com.board.controller; + +import com.board.dto.request.ArticleCreateRequest; +import com.board.dto.request.ArticleUpdateRequest; +import com.board.entity.ArticleEntity; +import com.board.repository.ArticleRepository; +import com.board.service.cache.ArticleViewCountCacheService; +import com.config.jwt.JwtUtil; import com.fasterxml.jackson.databind.ObjectMapper; import com.jayway.jsonpath.JsonPath; -import jakarta.servlet.http.Cookie; +import com.member.dto.request.LoginRequest; +import com.member.dto.request.SignUpRequest; +import com.member.entity.MemberEntity; +import com.member.repository.MemberRepository; +import com.support.CleanDatabaseBeforeEachTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.ResultActions; -import org.springframework.transaction.annotation.Transactional; -@SpringBootTest -@AutoConfigureMockMvc -@Transactional -@ActiveProfiles("test") -class BlogApiControllerIntegrationTest { +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class ArticleControllerIntegrationTest extends CleanDatabaseBeforeEachTest { @Autowired private MockMvc mockMvc; @@ -50,7 +38,7 @@ class BlogApiControllerIntegrationTest { private ObjectMapper objectMapper; @Autowired - private BlogRepository blogRepository; + private ArticleRepository articleRepository; @Autowired private MemberRepository memberRepository; @@ -58,6 +46,9 @@ class BlogApiControllerIntegrationTest { @Autowired private JwtUtil jwtUtil; + @Autowired + private ArticleViewCountCacheService viewCountCacheService; + @Value("${jwt.secret_key}") private String secretKey; @@ -65,8 +56,7 @@ class BlogApiControllerIntegrationTest { private static final String MEMBER_PASSWORD = "12345"; private static final String MEMBER_NICKNAME = "setupMemberNickname"; private static final int JWT_EXPIRATION_TIME = 3600000; - - private String tokenCookie; + private String accessToken; @BeforeEach void setUp() throws Exception { @@ -79,27 +69,24 @@ void testJwtSecretKey() { } private void signUpAndLogin() throws Exception { - sendPostRequest("/members/signup", new MemberSignUpRequest(MEMBER_EMAIL, MEMBER_NICKNAME, MEMBER_PASSWORD)) + sendPostRequest("/public/members/signup", new SignUpRequest(MEMBER_EMAIL, MEMBER_NICKNAME, MEMBER_PASSWORD)) .andExpect(status().isOk()) .andExpect(jsonPath("$.email").value(MEMBER_EMAIL)) .andExpect(jsonPath("$.nickName").value(MEMBER_NICKNAME)); - MvcResult loginResult = sendPostRequest("/members/login", new LoginRequest(MEMBER_EMAIL, MEMBER_PASSWORD)) + sendPostRequest("/public/members/login", + new LoginRequest(MEMBER_EMAIL, MEMBER_PASSWORD)) .andExpect(status().isOk()) - .andExpect(cookie().exists("token")) - .andExpect(jsonPath("$.accessToken").exists()) - .andExpect(jsonPath("$.accessToken").isNotEmpty()) - .andExpect(jsonPath("$.expirationTime").value(JWT_EXPIRATION_TIME)) + .andExpect(jsonPath("$.accessToken.token").exists()) + .andExpect(jsonPath("$.accessToken.token").isNotEmpty()) + .andExpect(jsonPath("$.accessToken.expiration").value(JWT_EXPIRATION_TIME)) .andDo(result -> { String responseBody = result.getResponse().getContentAsString(); - String token = objectMapper.readTree(responseBody).get("accessToken").asText(); - assertThat(jwtUtil.extractEmail(token)).isEqualTo(MEMBER_EMAIL); - assertThat(jwtUtil.isTokenValid(token)).isTrue(); - }).andReturn(); - - tokenCookie = loginResult.getResponse().getCookie("token").getValue(); - assertThat(jwtUtil.isTokenValid(tokenCookie)).isTrue(); - assertThat(jwtUtil.extractEmail(tokenCookie)).isEqualTo(MEMBER_EMAIL); + this.accessToken = objectMapper.readTree(responseBody).get("accessToken").get("token").asText(); + Long memberId = jwtUtil.extractMemberIdFromToken(accessToken); + assertThat(jwtUtil.extractMemberIdFromToken(accessToken)).isEqualTo(memberId); + assertThat(jwtUtil.isTokenValid(accessToken)).isTrue(); + }); } @Test @@ -115,7 +102,7 @@ private ResultActions sendPostRequest(String url, Object request) throws Excepti return mockMvc.perform(post(url) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) - .cookie(new Cookie("token", tokenCookie))); + .header("Authorization", "Bearer " + accessToken)); } @@ -125,21 +112,42 @@ void findAllArticleTest() throws Exception { MemberEntity member1 = createAndSaveMember("bb@aa.com", "nickname1"); MemberEntity member2 = createAndSaveMember("cc@aa.com", "nickname2"); - blogRepository.save(new ArticleEntity("Title 1", "Content 1", member1)); - blogRepository.save(new ArticleEntity("Title 2", "Content 2", member2)); + articleRepository.save(new ArticleEntity("Title 1", "Content 1", member1)); + articleRepository.save(new ArticleEntity("Title 2", "Content 2", member2)); mockMvc.perform(get("/articles").param("page", "0").param("size", "10")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.size()").value(2)) - .andExpect(jsonPath("$[0].title").value("Title 1")) - .andExpect(jsonPath("$[1].title").value("Title 2")); + .andExpect(jsonPath("$.content.size()").value(2)) + .andExpect(jsonPath("$.content[0].title").value("Title 2")) + .andExpect(jsonPath("$.content[1].title").value("Title 1")); + } + + @Test + @DisplayName("삭제된 게시물을 제외하고 전체 조회 테스트") + void findAllExcludingDeletedArticlesTest() throws Exception { + // Given: 게시물 3개 생성 + MemberEntity member = createAndSaveMember("bb@aa.com", "nickname"); + ArticleEntity article1 = articleRepository.save(new ArticleEntity("Title 1", "Content 1", member)); + ArticleEntity article2 = articleRepository.save(new ArticleEntity("Title 2", "Content 2", member)); + ArticleEntity article3 = articleRepository.save(new ArticleEntity("Title 3", "Content 3", member)); + + // 게시물 1개 삭제 + article3.softDelete(); + articleRepository.save(article3); + + // When: 전체 조회 요청 + mockMvc.perform(get("/articles").param("page", "0").param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.size()").value(2)) // 삭제된 게시물 제외 + .andExpect(jsonPath("$.content[0].title").value("Title 2")) + .andExpect(jsonPath("$.content[1].title").value("Title 1")); } @Test @DisplayName("개별 조회 테스트") void findArticleTest() throws Exception { MemberEntity member = createAndSaveMember("bb@aa.com", "nickname"); - ArticleEntity article = blogRepository.save(new ArticleEntity("Title 1", "Content 1", member)); + ArticleEntity article = articleRepository.save(new ArticleEntity("Title 1", "Content 1", member)); mockMvc.perform(get("/articles/" + article.getId())) .andExpect(status().isOk()) @@ -147,6 +155,22 @@ void findArticleTest() throws Exception { .andExpect(jsonPath("$.content").value("Content 1")); } + @Test + @DisplayName("글 상세조회시 조회수가 정상적으로 상승하면 성공") + void 글_상세조회_조회수_상승() throws Exception { + MemberEntity member = createAndSaveMember("bb@aa.com", "nickname"); + ArticleEntity article = articleRepository.save(new ArticleEntity("Title 1", "Content 1", member)); + + mockMvc.perform(get("/articles/" + article.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("Title 1")) + .andExpect(jsonPath("$.content").value("Content 1")) + .andExpect(jsonPath("$.viewCount").value(1)); + + mockMvc.perform(get("/articles/" + article.getId())) + .andExpect(jsonPath("$.viewCount").value(2)); + } + @Test @DisplayName("로그인 없이 글 삭제 시 401 에러 발생") void notLoginDeleteArticleTest() throws Exception { @@ -155,7 +179,7 @@ void notLoginDeleteArticleTest() throws Exception { mockMvc.perform(delete("/articles/" + article.getId())) .andExpect(status().isUnauthorized()); - assertTrue(blogRepository.findById(article.getId()).isPresent()); + assertTrue(articleRepository.findById(article.getId()).isPresent()); } @Test @@ -163,10 +187,13 @@ void notLoginDeleteArticleTest() throws Exception { void deleteArticleTest() throws Exception { Long articleId = createArticleAndGetId("테스트 제목", "테스트 내용"); - mockMvc.perform(delete("/articles/" + articleId).cookie(new Cookie("token", tokenCookie))) + mockMvc.perform(delete("/articles/" + articleId) + .header("Authorization", "Bearer " + accessToken)) .andExpect(status().isNoContent()); - assertFalse(blogRepository.findById(articleId).isPresent()); + ArticleEntity deletedArticle = articleRepository.findById(articleId) + .orElseThrow(() -> new AssertionError("삭제된 글을 찾을 수 없습니다.")); + assertTrue(deletedArticle.isDeleted()); } @Test @@ -184,7 +211,7 @@ private ResultActions sendPutRequest(String url, Object request) throws Exceptio return mockMvc.perform(put(url) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) - .cookie(new Cookie("token", tokenCookie))); + .header("Authorization", "Bearer " + accessToken)); } private MemberEntity createAndSaveMember(String email, String nickname) { @@ -192,7 +219,7 @@ private MemberEntity createAndSaveMember(String email, String nickname) { } private ArticleEntity createAndSaveArticle(String title, String content) { - return blogRepository.save(new ArticleEntity(title, content, createAndSaveMember("bb@aa.com", "nickname"))); + return articleRepository.save(new ArticleEntity(title, content, createAndSaveMember("bb@aa.com", "nickname"))); } private Long createArticleAndGetId(String title, String content) throws Exception { @@ -204,4 +231,25 @@ private Long createArticleAndGetId(String title, String content) throws Exceptio return JsonPath.parse(mvcResult.getResponse().getContentAsString()).read("$.id", Long.class); } + + @Test + @DisplayName("게시글 조회 시 Redis 캐시의 조회수가 증가한다") + void 게시글_조회시_조회수_캐시_증가() throws Exception { + // given + MemberEntity member = createAndSaveMember("viewer@test.com", "viewer"); + ArticleEntity article = articleRepository.save(new ArticleEntity("조회수 테스트 제목", "조회수 테스트 내용", member)); + Long articleId = article.getId(); + + // when + for (int i = 0; i < 3; i++) { + mockMvc.perform(get("/articles/" + articleId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("조회수 테스트 제목")); + } + + // then + long viewCount = viewCountCacheService.getViewCount(articleId); + assertThat(viewCount).isEqualTo(3L); + } + } diff --git a/src/test/java/com/board/board/controller/BlogApiControllerTest.java b/src/test/java/com/board/controller/ArticleControllerTest.java similarity index 70% rename from src/test/java/com/board/board/controller/BlogApiControllerTest.java rename to src/test/java/com/board/controller/ArticleControllerTest.java index e88e4f3..59cf3bf 100644 --- a/src/test/java/com/board/board/controller/BlogApiControllerTest.java +++ b/src/test/java/com/board/controller/ArticleControllerTest.java @@ -1,15 +1,18 @@ -package com.board.board.controller; - -import com.board.board.dto.request.ArticleCreateRequest; -import com.board.board.dto.request.ArticleUpdateRequest; -import com.board.board.dto.response.ArticleResponse; -import com.board.board.entity.ArticleEntity; -import com.board.board.service.BlogService; -import com.board.config.auth.AuthenticatedMemberArgumentResolver; -import com.board.member.entity.MemberEntity; -import com.board.member.service.MemberService; +package com.board.controller; + +import com.board.dto.request.ArticleCreateRequest; +import com.board.dto.request.ArticleUpdateRequest; +import com.board.dto.response.ArticleResponse; +import com.board.entity.ArticleEntity; +import com.board.service.ArticleService; +import com.board.service.cache.ArticleViewCountCacheService; +import com.config.auth.AuthenticatedMemberArgumentResolver; import com.fasterxml.jackson.databind.ObjectMapper; +import com.member.entity.MemberEntity; +import com.member.service.MemberService; +import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -26,18 +29,21 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@WebMvcTest(BlogApiController.class) -class BlogApiControllerTest { +@WebMvcTest(ArticleController.class) +class ArticleControllerTest { @Autowired private MockMvc mockMvc; @MockitoBean - private BlogService blogService; + private ArticleService articleService; @MockitoBean private MemberService memberService; + @MockitoBean + private ArticleViewCountCacheService viewCountCacheService; + @MockitoBean private AuthenticatedMemberArgumentResolver authenticatedMemberArgumentResolver; @@ -52,6 +58,7 @@ void setup() throws Exception { } @Test + @DisplayName("게시글 생성 성공") void addArticle_Success() throws Exception { // Given ArticleCreateRequest request = new ArticleCreateRequest("Title", "Content"); @@ -69,7 +76,7 @@ void addArticle_Success() throws Exception { .build(); when(memberService.findById(any())).thenReturn(member); - when(blogService.save(any(ArticleCreateRequest.class), any(Long.class))).thenReturn(article); + when(articleService.save(any(ArticleCreateRequest.class), any(Long.class))).thenReturn(article); // When & Then mockMvc.perform(post("/articles") @@ -81,19 +88,21 @@ void addArticle_Success() throws Exception { } @Test - void findAllArticles_Success() throws Exception { + @DisplayName("전체 게시글 조회 성공 - 내용 미포함") + void 전체_게시글_조회_성공() throws Exception { // Given List responses = List.of( - new ArticleResponse(1L, "Title1", "Content1", 1L), - new ArticleResponse(2L, "Title2", "Content2", 1L) + new ArticleResponse(1L, "Title1", null, 1L), + new ArticleResponse(2L, "Title2", null, 1L) ); MemberEntity member = MemberEntity.builder() .email("abc@example.com") .password("abc") .nickName("abc") .build(); - when(blogService.findAll(any())).thenReturn( + when(articleService.findAll(any())).thenReturn( new PageImpl<>(responses.stream().map(response -> ArticleEntity.builder() + .id(response.getId()) .title(response.getTitle()) .content(response.getContent()) .member(member) @@ -102,26 +111,32 @@ void findAllArticles_Success() throws Exception { // When & Then mockMvc.perform(get("/articles")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(2)) - .andExpect(jsonPath("$[0].title").value("Title1")) - .andExpect(jsonPath("$[1].content").value("Content2")); + .andExpect(jsonPath("$.content.length()").value(2)) + .andExpect(jsonPath("$.content[0].title").value("Title1")) + .andExpect(jsonPath("$.content[0].content").value(Matchers.nullValue())) + .andExpect(jsonPath("$.content[1].id").value(2L)); } @Test - void findArticle_Success() throws Exception { + @DisplayName("개별 게시글 조회 성공 - 내용 포함") + void 개별_게시글_조회_성공_내용_포함() throws Exception { // Given - ArticleResponse response = new ArticleResponse(1L, "Title", "Content", 1L); + ArticleResponse response = new ArticleResponse(1L, "Title", "Content", 1L, 0); MemberEntity member = MemberEntity.builder() .email("abc@example.com") .password("abc") .nickName("abc") .build(); - when(blogService.findById(1L)).thenReturn(ArticleEntity.builder() + ArticleEntity article = ArticleEntity.builder() + .id(1L) .title(response.getTitle()) .content(response.getContent()) .member(member) - .build()); + .viewCount(0L) // 초기 조회수 설정 + .build(); + + when(articleService.findById(1L)).thenReturn(article); // When & Then mockMvc.perform(get("/articles/1")) @@ -132,6 +147,7 @@ void findArticle_Success() throws Exception { @Test + @DisplayName("게시글 삭제 성공") void deleteArticle_Success() throws Exception { // When & Then mockMvc.perform(delete("/articles/1")) @@ -139,17 +155,18 @@ void deleteArticle_Success() throws Exception { } @Test + @DisplayName("게시글 수정 성공") void updateArticle_Success() throws Exception { // Given ArticleUpdateRequest request = new ArticleUpdateRequest("Updated Title", "Updated Content"); - ArticleResponse response = new ArticleResponse(1L, "Updated Title", "Updated Content", 1L); + ArticleResponse response = new ArticleResponse(1L, "Updated Title", "Updated Content", 1L, 0); MemberEntity member = MemberEntity.builder() .email("abc@example.com") .password("abc") .nickName("abc") .build(); - when(blogService.update(any(Long.class), any(Long.class), any(ArticleUpdateRequest.class))) + when(articleService.update(any(Long.class), any(Long.class), any(ArticleUpdateRequest.class))) .thenReturn(ArticleEntity.builder() .title(response.getTitle()) .content(response.getContent()) diff --git a/src/test/java/com/board/controller/CommentControllerIntegrationTest.java b/src/test/java/com/board/controller/CommentControllerIntegrationTest.java new file mode 100644 index 0000000..683912e --- /dev/null +++ b/src/test/java/com/board/controller/CommentControllerIntegrationTest.java @@ -0,0 +1,146 @@ +package com.board.controller; + +import com.board.dto.request.CommentCreateRequest; +import com.board.dto.request.CommentUpdateRequest; +import com.board.entity.ArticleEntity; +import com.board.entity.CommentEntity; +import com.board.repository.ArticleRepository; +import com.board.repository.CommentRepository; +import com.config.jwt.JwtUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.member.entity.MemberEntity; +import com.member.repository.MemberRepository; +import com.support.CleanDatabaseBeforeEachTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class CommentControllerIntegrationTest extends CleanDatabaseBeforeEachTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private CommentRepository commentRepository; + + @Autowired + private ArticleRepository articleRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private JwtUtil jwtUtil; + + private MemberEntity member; + private ArticleEntity article; + private String jwtToken; + private static final String AUTHORIZATION_HEADER = "Authorization"; + + @BeforeEach + void setup() { + member = memberRepository.save(new MemberEntity("test@example.com", "password", "nickname")); + article = articleRepository.save(new ArticleEntity("title", "content", member)); + jwtToken = "Bearer " + jwtUtil.generateToken(member.getId(), 1000 * 60 * 60); + } + + @Test + @DisplayName("댓글 생성 성공") + void 댓글_생성_성공() throws Exception { + // Given + CommentCreateRequest request = new CommentCreateRequest("댓글 내용"); + + // When & Then + mockMvc.perform(post("/articles/" + article.getId() + "/comments") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .header(AUTHORIZATION_HEADER, jwtToken)) // 가짜 인증 헤더 + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.content").value("댓글 내용")) + .andExpect(jsonPath("$.authorName").value("nickname")); + } + + @Test + @DisplayName("댓글 조회 성공") + void 댓글_조회_성공() throws Exception { + // Given + CommentEntity comment1 = commentRepository.save(new CommentEntity("댓글 내용1", article, member)); + CommentEntity comment2 = commentRepository.save(new CommentEntity("댓글 내용2", article, member)); + + // When & Then + mockMvc.perform(get("/articles/" + article.getId() + "/comments") + .param("articleId", String.valueOf(article.getId())) + .param("page", "0") + .param("size", "10") + .header(AUTHORIZATION_HEADER, jwtToken)) // JWT 인증 헤더 추가 + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].content").value("댓글 내용2")) + .andExpect(jsonPath("$.content[1].content").value("댓글 내용1")); + } + + @Test + @DisplayName("댓글 수정 성공") + void 댓글_수정_성공() throws Exception { + // Given + CommentEntity comment = commentRepository.save(new CommentEntity("댓글 내용", article, member)); + CommentUpdateRequest request = new CommentUpdateRequest("수정된 댓글 내용", comment.getId()); + + // When & Then + mockMvc.perform(patch("/articles/" + article.getId() + "/comments/" + comment.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .header(AUTHORIZATION_HEADER, jwtToken)) // JWT 인증 헤더 추가 + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("수정된 댓글 내용")) + .andExpect(jsonPath("$.authorName").value(member.getNickName())); + } + + @Test + @DisplayName("댓글 삭제 성공") + void 댓글_삭제_성공() throws Exception { + // Given + CommentEntity comment = commentRepository.save(new CommentEntity("댓글 내용", article, member)); + + // When & Then + mockMvc.perform(delete("/articles/" + article.getId() + "/comments/" + comment.getId()) + .header(AUTHORIZATION_HEADER, jwtToken)) // JWT 인증 헤더 추가 + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("대댓글_목록_조회_성공") + void 대댓글_목록_조회_성공() throws Exception { + // Given + CommentEntity comment = commentRepository.save(new CommentEntity("부모 댓글", article, member)); + CommentEntity childComment1 = commentRepository.save(CommentEntity.builder() + .content("대댓글 1") + .article(article) + .member(member) + .parent(comment) + .build()); + CommentEntity childComment2 = commentRepository.save(CommentEntity.builder() + .content("대댓글 2") + .article(article) + .member(member) + .parent(comment) + .build()); + + // When & Then + mockMvc.perform(get("/articles/{articleId}/comments/{commentId}/replies", article.getId(), comment.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].content").value("대댓글 1")) + .andExpect(jsonPath("$.content[1].content").value("대댓글 2")); + } +} diff --git a/src/test/java/com/board/controller/CommentControllerTest.java b/src/test/java/com/board/controller/CommentControllerTest.java new file mode 100644 index 0000000..2c760b1 --- /dev/null +++ b/src/test/java/com/board/controller/CommentControllerTest.java @@ -0,0 +1,154 @@ +package com.board.controller; + +import com.board.dto.request.CommentCreateRequest; +import com.board.dto.request.CommentUpdateRequest; +import com.board.dto.response.CommentResponse; +import com.board.entity.ArticleEntity; +import com.board.entity.CommentEntity; +import com.board.service.CommentService; +import com.config.auth.AuthenticatedMemberArgumentResolver; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.member.entity.MemberEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(CommentController.class) +class CommentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private CommentService commentService; + + @MockitoBean + private AuthenticatedMemberArgumentResolver authenticatedMemberArgumentResolver; + + @Autowired + private ObjectMapper objectMapper; + + private final Long articleId = 1L; + private final Long commentId = 10L; + private final Long memberId = 100L; + + @BeforeEach + void setup() throws Exception { + when(authenticatedMemberArgumentResolver.supportsParameter(any())).thenReturn(true); + when(authenticatedMemberArgumentResolver.resolveArgument(any(), any(), any(), any())) + .thenReturn(memberId); // @AuthenticatedMember 역할 + } + + @Test + @DisplayName("댓글 생성 성공") + void 댓글_생성() throws Exception { + // given + CommentCreateRequest request = new CommentCreateRequest("댓글 내용"); + MemberEntity member = new MemberEntity("이메일", "패스워드", "닉네임"); + CommentEntity comment = new CommentEntity("댓글 내용", null, member); + + when(commentService.createComment(eq(articleId), any(CommentCreateRequest.class), eq(memberId))) + .thenReturn(comment); + + // when & then + mockMvc.perform(post("/articles/{articleId}/comments", articleId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.content").value("댓글 내용")) + .andExpect(jsonPath("$.authorName").value("닉네임")); + } + + @Test + @DisplayName("댓글 전체 조회 성공") + void 댓글_전체_조회_성공() throws Exception { + // given + MemberEntity member = new MemberEntity("email", "password", "nickName"); + ArticleEntity article = new ArticleEntity("title", "content", member); + List comments = List.of( + new CommentEntity("내용1", article, member), + new CommentEntity("내용2", article, member)); + Page pageResult = new PageImpl<>(comments); + Page response = pageResult.map(comment -> CommentResponse.of(comment, 0)); + + when(commentService.findAllTopLevelComments(eq(articleId), any())).thenReturn(response); + + // when & then + mockMvc.perform(get("/articles/{articleId}/comments", articleId) + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].content").value("내용1")) + .andExpect(jsonPath("$.content[1].authorName").value("nickName")); + } + + @Test + @DisplayName("댓글 수정 성공") + void 댓글_수정_성공() throws Exception { + // given + CommentUpdateRequest request = new CommentUpdateRequest("수정된 댓글 내용", commentId); + MemberEntity member = new MemberEntity("email", "password", "nickName"); + CommentEntity updatedComment = new CommentEntity("수정된 댓글 내용", null, member); + + when(commentService.updateComment(eq(articleId), eq(commentId), any(CommentUpdateRequest.class), eq(memberId))) + .thenReturn(updatedComment); + + // when & then + mockMvc.perform(patch("/articles/{articleId}/comments/{commentId}", articleId, commentId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("수정된 댓글 내용")) + .andExpect(jsonPath("$.authorName").value("nickName")); + } + + @Test + @DisplayName("댓글 삭제 성공") + void 댓글_삭제_성공() throws Exception { + mockMvc.perform(delete("/articles/{articleId}/comments/{commentId}", articleId, commentId)) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("대댓글 목록 조회 성공") + void 대댓글_목록_조회_성공() throws Exception { + // given + MemberEntity member = new MemberEntity("email", "password", "nickname"); + ArticleEntity article = new ArticleEntity("title", "content", member); + + CommentEntity reply1 = new CommentEntity("대댓글1", article, member); + CommentEntity reply2 = new CommentEntity("대댓글2", article, member); + Page replies = new PageImpl<>(List.of(reply1, reply2)); + + when(commentService.findReplies(eq(commentId), any(Pageable.class))) + .thenReturn(replies); + + // when & then + mockMvc.perform(get("/articles/{articleId}/comments/{commentId}/replies", articleId, commentId) + .param("page", "0") + .param("size", "10") + .param("sort", "oldest")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content[0].content").value("대댓글1")) + .andExpect(jsonPath("$.content[1].authorName").value("nickname")); + } +} diff --git a/src/test/java/com/board/member/controller/MemberControllerTest.java b/src/test/java/com/board/member/controller/MemberControllerTest.java deleted file mode 100644 index 7e56e09..0000000 --- a/src/test/java/com/board/member/controller/MemberControllerTest.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.board.member.controller; - -import com.board.config.auth.AuthUtil; -import com.board.member.dto.request.LoginRequest; -import com.board.member.dto.request.MemberSignUpRequest; -import com.board.member.dto.response.LoginResponse; -import com.board.member.dto.response.MemberSignUpResponse; -import com.board.member.service.MemberService; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.http.MediaType; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.MockMvc; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@WebMvcTest(MemberController.class) -class MemberControllerTest { - - @Autowired - private MockMvc mockMvc; - - @MockitoBean - private AuthUtil authUtil; - - @MockitoBean - private MemberService memberService; - - @Test - void signUp_Success() throws Exception { - // Given - MemberSignUpRequest request = new MemberSignUpRequest("test@example.com", "nickname", "password123"); - MemberSignUpResponse response = new MemberSignUpResponse(1L, "test@example.com", "nickname"); - - when(memberService.signUp(any(MemberSignUpRequest.class))).thenReturn(response); - - // When & Then - mockMvc.perform(post("/members/signup") - .contentType(MediaType.APPLICATION_JSON) - .content(new ObjectMapper().writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(1L)) - .andExpect(jsonPath("$.email").value("test@example.com")) - .andExpect(jsonPath("$.nickName").value("nickname")); - } - - @Test - void login_Success() throws Exception { - // Given - LoginRequest loginRequest = new LoginRequest("test@example.com", "password123"); - LoginResponse loginResponse = new LoginResponse("mockAccessToken", 3600L); - - when(memberService.login(any(LoginRequest.class))).thenReturn(loginResponse); - - // When & Then - mockMvc.perform(post("/members/login") - .contentType(MediaType.APPLICATION_JSON) - .content(new ObjectMapper().writeValueAsString(loginRequest))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.accessToken").value("mockAccessToken")); - } -} diff --git a/src/test/java/com/board/member/service/MemberServiceImplTest.java b/src/test/java/com/board/member/service/MemberServiceImplTest.java deleted file mode 100644 index 41efcd7..0000000 --- a/src/test/java/com/board/member/service/MemberServiceImplTest.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.board.member.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; - -import com.board.config.jwt.JwtUtil; -import com.board.config.jwt.TokenWithExpiration; -import com.board.exception.custom.SignUpException; -import com.board.member.dto.request.LoginRequest; -import com.board.member.dto.request.MemberSignUpRequest; -import com.board.member.dto.response.LoginResponse; -import com.board.member.dto.response.MemberSignUpResponse; -import com.board.member.entity.MemberEntity; -import com.board.member.repository.MemberRepository; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -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; - -@ExtendWith(MockitoExtension.class) -class MemberServiceImplTest { - - @InjectMocks - private MemberServiceImpl memberService; - - @Mock - private MemberRepository memberRepository; - - @Mock - private JwtUtil jwtUtil; - - private MemberEntity member; - - @BeforeEach - void setUp() { - member = MemberEntity.builder() - .email("test@example.com") - .password("1234") - .nickName("testUser") - .build(); - } - - @Nested - @DisplayName("회원가입 테스트") - class SignUpTests { - - @Test - @DisplayName("회원가입 성공") - void signUp_Success() { - // Given - MemberSignUpRequest request = new MemberSignUpRequest("test@example.com", "1234", "testUser"); - when(memberRepository.save(any(MemberEntity.class))).thenReturn(member); - - // When - MemberSignUpResponse response = memberService.signUp(request); - - // Then - assertNotNull(response); - assertEquals("test@example.com", response.getEmail()); - assertEquals("testUser", response.getNickName()); - } - - @Test - @DisplayName("회원가입 중복 이메일 에러") - void signUp_DuplicateEmail_ThrowsException() { - // Given - MemberSignUpRequest request = new MemberSignUpRequest("test@example.com", "1234", "testUser"); - when(memberRepository.findByEmail(request.getEmail())).thenReturn(Optional.of(member)); - - // When & Then - assertThrows(SignUpException.class, () -> memberService.signUp(request)); - } - } - - @Nested - @DisplayName("로그인 테스트") - class LoginTests { - @Test - @DisplayName("로그인 성공") - void login_Success() { - // Given - LoginRequest request = new LoginRequest("test@example.com", "1234"); - when(memberRepository.findByEmail(request.getEmail())).thenReturn(Optional.of(member)); - when(jwtUtil.generateTokenWithExpiration(any(String.class))) - .thenReturn(new TokenWithExpiration("token", 3600000L)); - - // When - LoginResponse response = memberService.login(request); - - // Then - assertNotNull(response); - assertEquals("token", response.getAccessToken()); - } - - @Test - @DisplayName("로그인 실패 - 잘못된 비밀번호") - void login_Fail_WrongPassword() { - // Given - LoginRequest request = new LoginRequest("test@example.com", "wrongPassword"); - when(memberRepository.findByEmail(request.getEmail())).thenReturn(Optional.of(member)); - - // When & Then - assertThrows(IllegalArgumentException.class, () -> memberService.login(request)); - } - } - - - @Test - @DisplayName("이메일로 멤버 찾기 성공") - void findByEmail_Success() { - // Given - when(memberRepository.findByEmail("test@example.com")).thenReturn(Optional.of(member)); - - // When - MemberEntity foundMember = memberService.findByEmail("test@example.com"); - - // Then - assertNotNull(foundMember); - assertEquals("test@example.com", foundMember.getEmail()); - } - - @Test - @DisplayName("ID로 멤버 찾기 성공") - void findById_Success() { - // Given - when(memberRepository.findById(1L)).thenReturn(Optional.of(member)); - - // When - MemberEntity foundMember = memberService.findById(1L); - - // Then - assertNotNull(foundMember); - assertEquals("test@example.com", foundMember.getEmail()); - } -} diff --git a/src/test/java/com/board/board/service/BlogServiceTest.java b/src/test/java/com/board/service/ArticleServiceTest.java similarity index 64% rename from src/test/java/com/board/board/service/BlogServiceTest.java rename to src/test/java/com/board/service/ArticleServiceTest.java index 9d1a5bc..10d7683 100644 --- a/src/test/java/com/board/board/service/BlogServiceTest.java +++ b/src/test/java/com/board/service/ArticleServiceTest.java @@ -1,25 +1,12 @@ -package com.board.board.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.board.board.dto.request.ArticleCreateRequest; -import com.board.board.entity.ArticleEntity; -import com.board.board.repository.BlogRepository; -import com.board.exception.custom.DifferentOwnerException; -import com.board.exception.custom.MyEntityNotFoundException; -import com.board.member.entity.MemberEntity; -import com.board.member.service.MemberService; -import java.util.Collections; -import java.util.Optional; +package com.board.service; + +import com.board.dto.request.ArticleCreateRequest; +import com.board.entity.ArticleEntity; +import com.board.repository.ArticleRepository; +import com.exception.custom.DifferentOwnerException; +import com.exception.custom.MyEntityNotFoundException; +import com.member.entity.MemberEntity; +import com.member.service.MemberService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -31,14 +18,22 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import java.util.Collections; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; + @ExtendWith(MockitoExtension.class) -class BlogServiceTest { +class ArticleServiceTest { @InjectMocks - private BlogService blogService; + private ArticleService articleService; @Mock - private BlogRepository blogRepository; + private ArticleRepository articleRepository; @Mock private MemberService memberService; @@ -66,10 +61,10 @@ void saveArticle_Success() { .build(); when(memberService.findById(any(Long.class))).thenReturn(member); - when(blogRepository.save(any(ArticleEntity.class))).thenReturn(article); + when(articleRepository.save(any(ArticleEntity.class))).thenReturn(article); // When - ArticleEntity savedArticle = blogService.save(request, memberId); + ArticleEntity savedArticle = articleService.save(request, memberId); // Then assertNotNull(savedArticle); @@ -85,10 +80,10 @@ void findAllArticles_Success() { Pageable pageable = PageRequest.of(0, 10); Page mockPage = new PageImpl<>(Collections.emptyList()); - when(blogRepository.findAll(pageable)).thenReturn(mockPage); + when(articleRepository.findAllByIsDeletedFalse(pageable)).thenReturn(mockPage); // When - Page result = blogService.findAll(pageable); + Page result = articleService.findAll(pageable); // Then assertNotNull(result); @@ -101,10 +96,10 @@ void findById_ArticleExists() { // Given MemberEntity member = new MemberEntity("test@example.com", "password", "testUser"); ArticleEntity article = new ArticleEntity("title", "content", member); - when(blogRepository.findById(anyLong())).thenReturn(Optional.of(article)); - when(blogRepository.findById(1L)).thenReturn(Optional.of(article)); + when(articleRepository.findById(anyLong())).thenReturn(Optional.of(article)); + when(articleRepository.findById(1L)).thenReturn(Optional.of(article)); // When - ArticleEntity foundArticle = blogService.findById(1L); + ArticleEntity foundArticle = articleService.findById(1L); // Then assertNotNull(foundArticle); @@ -117,10 +112,10 @@ void findById_ArticleExists() { @DisplayName("Serivce - 없는 정보 조회 시 에러 발생") void findById_ArticleNotFound() { // Given - when(blogRepository.findById(1L)).thenReturn(Optional.empty()); + when(articleRepository.findById(1L)).thenReturn(Optional.empty()); // When & Then - assertThrows(MyEntityNotFoundException.class, () -> blogService.findById(1L)); + assertThrows(MyEntityNotFoundException.class, () -> articleService.findById(1L)); } @Test @@ -130,19 +125,17 @@ void deleteArticle_Success() { Long memberId = 3L; Long articleId = 1L; String email = "test@example.com"; - MemberEntity member = new MemberEntity(email, "testUser", "nickName"); // ID 없이 생성 - ArticleEntity article = new ArticleEntity("title", "content", member); // ID 없이 생성 + MemberEntity member = new MemberEntity(email, "testUser", "nickName"); + ArticleEntity article = new ArticleEntity("title", "content", member); when(memberService.findById(any(Long.class))).thenReturn(member); - when(blogRepository.findById(articleId)).thenReturn(Optional.of(article)); - - doNothing().when(blogRepository).deleteById(articleId); + when(articleRepository.findById(articleId)).thenReturn(Optional.of(article)); // When - blogService.delete(articleId, memberId); + articleService.delete(articleId, memberId); // Then - verify(blogRepository, times(1)).deleteById(anyLong()); + assertTrue(article.isDeleted()); // softDelete() 호출 후 상태 확인 } @@ -152,17 +145,17 @@ void deleteArticle_NotAuthor_ThrowsException() { // Given Long memberId = 3L; // 요청한 사용자 ID long articleId = 1L; // 삭제하려는 게시글 ID - MemberEntity requestingMember = new MemberEntity("user@example.com", "1234", "requestingUser"); + MemberEntity requestingMember = new MemberEntity(22L, "user@example.com", "1234", "requestingUser"); MemberEntity articleOwner = new MemberEntity("owner@example.com", "1234", "articleOwner"); - ArticleEntity article = new ArticleEntity("title", "content", articleOwner); + ArticleEntity article = new ArticleEntity(33L, "title", "content", articleOwner, 0); // Mock 설정 when(memberService.findById(memberId)).thenReturn(requestingMember); - when(blogRepository.findById(articleId)).thenReturn(Optional.of(article)); + when(articleRepository.findById(articleId)).thenReturn(Optional.of(article)); // When & Then DifferentOwnerException exception = assertThrows(DifferentOwnerException.class, - () -> blogService.delete(articleId, memberId)); + () -> articleService.delete(articleId, memberId)); assertEquals("권한 없음", exception.getMessage()); } diff --git a/src/test/java/com/board/service/CommentServiceTest.java b/src/test/java/com/board/service/CommentServiceTest.java new file mode 100644 index 0000000..1b889ec --- /dev/null +++ b/src/test/java/com/board/service/CommentServiceTest.java @@ -0,0 +1,360 @@ +package com.board.service; + +import com.board.dto.request.CommentCreateRequest; +import com.board.dto.request.CommentUpdateRequest; +import com.board.dto.response.CommentResponse; +import com.board.entity.ArticleEntity; +import com.board.entity.CommentEntity; +import com.board.repository.CommentRepository; +import com.exception.custom.DifferentOwnerException; +import com.exception.custom.MyEntityNotFoundException; +import com.exception.custom.NotIncludeBoardException; +import com.member.entity.MemberEntity; +import com.member.service.MemberService; +import org.junit.jupiter.api.BeforeEach; +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.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CommentServiceTest { + + @InjectMocks + private CommentService commentService; + @Mock + private CommentRepository commentRepository; + @Mock + private ArticleService articleService; + @Mock + private MemberService memberService; + + private MemberEntity testMember; + private ArticleEntity testArticle; + private CommentEntity testComment; + private Long articleId = 1L; + private Long memberId = 1L; + private Long commentId = 3L; + + @BeforeEach + void setUp() { + testMember = new MemberEntity(memberId, "test@example.com", "password", "nickname"); + testArticle = new ArticleEntity(articleId, "제목", "내용", testMember, 0); + testComment = new CommentEntity(commentId, "기존 댓글 내용", testArticle, testMember); + } + + @Test + @DisplayName("특정 게시글의 댓글 목록을 페이지 단위로 조회한다.") + void 페이지_조회() { + // Given + MemberEntity member2 = new MemberEntity(2L, "member2@example.com", "password", "member2"); + CommentEntity comment2 = new CommentEntity(2L, "댓글내용2", testArticle, member2); + Pageable pageable = PageRequest.of(0, 10); + List comments = List.of(testComment, comment2); + Page commentPage = new PageImpl<>(comments, pageable, comments.size()); + + when(articleService.findById(articleId)).thenReturn(testArticle); + when(commentRepository.findByArticleAndParentIsNullAndIsDeletedFalse(testArticle, pageable)).thenReturn(commentPage); + + // When + Page result = commentService.findAllTopLevelComments(articleId, pageable); + + // Then + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent().get(0).getContent()).isEqualTo(testComment.getContent()); + assertThat(result.getContent().get(0).getAuthorId()).isEqualTo(testMember.getId()); + } + + @Test + @DisplayName("존재하지 않는 게시글 ID로 댓글 목록을 조회하면 예외가 발생한다.") + void 존재하지_않는_게시글_ID로_댓글_목록을_조회하면_예외가_발생() { + // Given + Pageable pageable = PageRequest.of(0, 10); + when(articleService.findById(articleId)).thenThrow(MyEntityNotFoundException.class); + + // When & Then + assertThatThrownBy(() -> commentService.findAllTopLevelComments(articleId, pageable)) + .isInstanceOf(MyEntityNotFoundException.class); + } + + @Test + @DisplayName("새로운 댓글을 생성하고 저장한다.") + void 새로운_댓글을_생성하고_저장() { + // Given + String content = "새로운 댓글 내용"; + CommentCreateRequest request = new CommentCreateRequest(content); + CommentEntity savedComment = new CommentEntity(2L, content, testArticle, testMember); + + when(articleService.findById(articleId)).thenReturn(testArticle); + when(memberService.findById(memberId)).thenReturn(testMember); + when(commentRepository.save(any(CommentEntity.class))).thenReturn(savedComment); + + // When + CommentEntity result = commentService.createComment(articleId, request, memberId); + + // Then + assertThat(result.getContent()).isEqualTo(content); + assertThat(result.getArticle().getId()).isEqualTo(articleId); + assertThat(result.getMember().getId()).isEqualTo(memberId); + } + + @Test + @DisplayName("존재하지 않는 게시글에 댓글을 생성하려고 하면 예외가 발생한다.") + void 존재하지_않는_게시글에_댓글을_생성하려고_하면_예외가_발생() { + // Given + CommentCreateRequest request = new CommentCreateRequest("새로운 댓글 내용"); + when(articleService.findById(articleId)).thenThrow(MyEntityNotFoundException.class); + + // When & Then + assertThatThrownBy(() -> commentService.createComment(articleId, request, memberId)) + .isInstanceOf(MyEntityNotFoundException.class); + } + + @Test + @DisplayName("존재하지 않는 회원으로 댓글을 생성하려고 하면 예외가 발생한다.") + void 존재하지_않는_회원으로_댓글을_생성하려고_하면_예외가_발생() { + // Given + CommentCreateRequest request = new CommentCreateRequest("새로운 댓글 내용"); + when(articleService.findById(articleId)).thenReturn(testArticle); + when(memberService.findById(memberId)).thenThrow(MyEntityNotFoundException.class); + + // When & Then + assertThatThrownBy(() -> commentService.createComment(articleId, request, memberId)) + .isInstanceOf(MyEntityNotFoundException.class); + } + + @Test + @DisplayName("댓글 내용을 수정한다.") + void 댓글_내용을_수정() { + // Given + String updatedContent = "수정된 댓글 내용"; + CommentUpdateRequest request = new CommentUpdateRequest(updatedContent, commentId); + + when(commentRepository.findByIdAndIsDeletedFalse(commentId)).thenReturn(Optional.of(testComment)); + when(memberService.findById(memberId)).thenReturn(testMember); + + // When + CommentEntity result = commentService.updateComment(articleId, commentId, request, memberId); + + // Then + assertThat(result.getContent()).isEqualTo(updatedContent); + } + + @Test + @DisplayName("존재하지 않는 댓글을 수정하려고 하면 예외가 발생한다.") + void 존재하지_않는_댓글을_수정하려고_하면_예외가_발생() { + // Given + CommentUpdateRequest request = new CommentUpdateRequest("수정된 댓글 내용", commentId); + when(commentRepository.findByIdAndIsDeletedFalse(commentId)).thenReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> commentService.updateComment(articleId, commentId, request, memberId)) + .isInstanceOf(MyEntityNotFoundException.class); + } + + @Test + @DisplayName("수정하려는 댓글이 해당 게시글에 속하지 않으면 예외가 발생한다.") + void 수정하려는_댓글이_해당_게시글에_속하지_않으면_예외가_발생() { + + ArticleEntity anotherArticle = new ArticleEntity(999L, "제목", "내용", testMember, 0); + CommentEntity anotherComment = new CommentEntity(commentId, "기존 댓글 내용", anotherArticle, testMember); + + // Given + CommentUpdateRequest request = new CommentUpdateRequest("수정된 댓글 내용", commentId); + + when(commentRepository.findByIdAndIsDeletedFalse(commentId)).thenReturn(Optional.of(anotherComment)); + + // When & Then + assertThatThrownBy(() -> commentService.updateComment(articleId, commentId, request, memberId)) + .isInstanceOf(NotIncludeBoardException.class); + } + + @Test + @DisplayName("댓글을 soft delete 한다.") + void 댓글을_soft_delete_한다() { + // Given + when(commentRepository.findByIdAndIsDeletedFalse(commentId)).thenReturn(Optional.of(testComment)); + when(memberService.findById(memberId)).thenReturn(testMember); + + // When + commentService.deleteComment(articleId, commentId, memberId); + + // Then + assertThatCode(() -> commentService.deleteComment(articleId, commentId, memberId)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("존재하지 않는 댓글을 삭제하려고 하면 예외가 발생한다.") + void 존재하지_않는_댓글을_삭제하려고_하면_예외가_발생() { + // given + when(commentRepository.findByIdAndIsDeletedFalse(commentId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> commentService.deleteComment(articleId, commentId, memberId)) + .isInstanceOf(MyEntityNotFoundException.class); + } + + @Test + @DisplayName("삭제하려는 댓글이 해당 게시글에 속하지 않으면 예외가 발생한다.") + void 삭제하려는_댓글이_해당_게시글에_속하지_않으면_예외가_발생() { + // Given + ArticleEntity anotherArticle = new ArticleEntity(999L, "제목", "내용", testMember, 0); + CommentEntity anotherComment = new CommentEntity(commentId, "기존 댓글 내용", anotherArticle, testMember); + + // when + when(commentRepository.findByIdAndIsDeletedFalse(commentId)).thenReturn(Optional.of(anotherComment)); + + // then + assertThatThrownBy(() -> commentService.deleteComment(articleId, commentId, memberId)) + .isInstanceOf(NotIncludeBoardException.class); + } + + @Test + @DisplayName("삭제 권한이 없는 사용자가 댓글을 삭제하려고 하면 예외가 발생한다.") + void 삭제_권한이_없는_사용자가_댓글을_삭제하려고_하면_예외가_발생() { + // given + MemberEntity anotherMember = new MemberEntity(999L, "not-owner@example.com", "pw", "nick"); + + when(commentRepository.findByIdAndIsDeletedFalse(commentId)).thenReturn(Optional.of(testComment)); + when(memberService.findById(anotherMember.getId())).thenReturn(anotherMember); + + // when & then + assertThatThrownBy(() -> commentService.deleteComment(articleId, commentId, anotherMember.getId())) + .isInstanceOf(DifferentOwnerException.class); + } + + @Test + @DisplayName("수정 권한이 없는 사용자가 댓글을 수정하려고 하면 예외가 발생한다.") + void 수정_권한이_없는_사용자가_댓글을_수정하려고_하면_예외가_발생() { + // given + MemberEntity anotherMember = new MemberEntity(999L, "not-owner@example.com", "pw", "nick"); + + CommentUpdateRequest request = new CommentUpdateRequest("수정된 댓글 내용", commentId); + when(commentRepository.findByIdAndIsDeletedFalse(commentId)).thenReturn(Optional.of(testComment)); + when(memberService.findById(anotherMember.getId())).thenReturn(anotherMember); + + // when & then + assertThatThrownBy(() -> commentService.updateComment(articleId, commentId, request, anotherMember.getId())) + .isInstanceOf(DifferentOwnerException.class); + } + + @Test + @DisplayName("전체 댓글 조회 시 각 댓글의 대댓글 수를 정확히 조회한다.") + void 전체댓글조회시_대댓글수_정확조회() { + // Given + CommentEntity reply1 = new CommentEntity(10L, "대댓글1", testArticle, testMember, testComment); + Pageable pageable = PageRequest.of(0, 10); + List comments = List.of(testComment); + Page commentPage = new PageImpl<>(comments, pageable, comments.size()); + + when(articleService.findById(articleId)).thenReturn(testArticle); + when(commentRepository.findByArticleAndParentIsNullAndIsDeletedFalse(testArticle, pageable)).thenReturn(commentPage); + when(commentRepository.countByParentAndIsDeletedFalse(testComment)).thenReturn(5); + + // When + Page result = commentService.findAllTopLevelComments(articleId, pageable); + + // Then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getReplyCount()).isEqualTo(5); + } + + @Test + @DisplayName("댓글의 대댓글 수를 정확히 조회한다.") + void 대댓글수_정확조회() { + // Given + when(commentRepository.countByParentAndIsDeletedFalse(testComment)).thenReturn(3); + + // When + int replyCount = commentService.getReplyCount(testComment); + + // Then + assertThat(replyCount).isEqualTo(3); + } + + @Test + @DisplayName("대댓글에 대댓글을 작성하려고 하면 예외가 발생한다.") + void 대댓글에_대댓글작성_예외() { + // Given + CommentEntity parentReply = new CommentEntity(99L, "부모 대댓글", testArticle, testMember, testComment); + + CommentCreateRequest request = new CommentCreateRequest("대댓글의 대댓글", parentReply.getId()); + when(articleService.findById(articleId)).thenReturn(testArticle); + when(memberService.findById(memberId)).thenReturn(testMember); + when(commentRepository.findByIdAndIsDeletedFalse(parentReply.getId())).thenReturn(Optional.of(parentReply)); + + // When & Then + assertThatThrownBy(() -> commentService.createComment(articleId, request, memberId)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("댓글에 대댓글을 작성하고, 정상적으로 저장된다.") + void 대댓글_작성_성공() { + // Given + CommentCreateRequest request = new CommentCreateRequest("대댓글 내용", testComment.getId()); + CommentEntity reply = new CommentEntity(20L, "대댓글 내용", testArticle, testMember, testComment); + + when(articleService.findById(articleId)).thenReturn(testArticle); + when(memberService.findById(memberId)).thenReturn(testMember); + when(commentRepository.findByIdAndIsDeletedFalse(testComment.getId())).thenReturn(Optional.of(testComment)); + when(commentRepository.save(any(CommentEntity.class))).thenReturn(reply); + + // When + CommentEntity result = commentService.createComment(articleId, request, memberId); + + // Then + assertThat(result.getContent()).isEqualTo("대댓글 내용"); + assertThat(result.getParent()).isEqualTo(testComment); + } + + @Test + @DisplayName("부모 댓글이 다른 게시글에 속하면 예외가 발생한다.") + void 부모댓글이_다른게시글이면_예외() { + // Given + ArticleEntity otherArticle = new ArticleEntity(2L, "다른 글", "내용", testMember, 0); + CommentEntity otherComment = new CommentEntity(200L, "다른 글의 댓글", otherArticle, testMember); + CommentCreateRequest request = new CommentCreateRequest("잘못된 대댓글", otherComment.getId()); + + when(articleService.findById(articleId)).thenReturn(testArticle); + when(memberService.findById(memberId)).thenReturn(testMember); + when(commentRepository.findByIdAndIsDeletedFalse(otherComment.getId())).thenReturn(Optional.of(otherComment)); + + // When & Then + assertThatThrownBy(() -> commentService.createComment(articleId, request, memberId)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("대댓글 목록을 페이지 단위로 조회한다.") + void 대댓글_리스트_조회() { + // Given + CommentEntity reply1 = new CommentEntity(301L, "대댓글1", testArticle, testMember, testComment); + Pageable pageable = PageRequest.of(0, 10); + Page replyPage = new PageImpl<>(List.of(reply1), pageable, 1); + + when(commentRepository.findByIdAndIsDeletedFalse(testComment.getId())).thenReturn(Optional.of(testComment)); + when(commentRepository.findByParentAndIsDeletedFalse(testComment, pageable)).thenReturn(replyPage); + + // When + Page result = commentService.findReplies(testComment.getId(), pageable); + + // Then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getContent()).isEqualTo("대댓글1"); + } +} diff --git a/src/test/java/com/board/service/cache/ArticleViewCountCacheServiceTest.java b/src/test/java/com/board/service/cache/ArticleViewCountCacheServiceTest.java new file mode 100644 index 0000000..324bb9e --- /dev/null +++ b/src/test/java/com/board/service/cache/ArticleViewCountCacheServiceTest.java @@ -0,0 +1,90 @@ +package com.board.service.cache; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class ArticleViewCountCacheServiceTest { + + @Autowired + private ArticleViewCountCacheService articleViewCountCacheService; + + @Autowired + private RedisTemplate redisTemplate; + + private static final String KEY_PREFIX = "article:viewCount:"; + + @BeforeEach + void setup() { + redisTemplate.getConnectionFactory().getConnection().flushAll(); + } + + @AfterEach + void tearDown() { + redisTemplate.getConnectionFactory().getConnection().flushAll(); + } + + @DisplayName("increase() 호출 시 게시글 조회수가 1 증가한다.") + @Test + void 게시글_조회수가_1_증가() { + // given (준비) + Long articleId = 100L; + String redisKey = KEY_PREFIX + articleId; + + // when (실행) + articleViewCountCacheService.increase(articleId); // 1번째 증가 + articleViewCountCacheService.increase(articleId); // 2번째 증가 + articleViewCountCacheService.increase(articleId); // 3번째 증가 + + // then (검증) + // 1. 서비스 메서드를 통해 반환되는 조회수 검증 + long viewCount = articleViewCountCacheService.getViewCount(articleId); + assertThat(viewCount).isEqualTo(3); + + // 2. Redis에 실제로 저장된 값도 검증 + String storedValueInRedis = redisTemplate.opsForValue().get(redisKey); + assertThat(storedValueInRedis).isEqualTo("3"); + } + + @DisplayName("getViewCount() 호출 시 캐시에 값이 없으면 0을 반환한다.") + @Test + void 캐시에_값이_없으면_0을_반환() { + // given (준비) + Long articleId = 200L; // 아직 Redis에 없는 ID + + // when (실행) + long viewCount = articleViewCountCacheService.getViewCount(articleId); + + // then (검증) + assertThat(viewCount).isEqualTo(0); + } + + @DisplayName("reset() 호출 시 해당 게시글의 조회수 캐시가 삭제된다.") + @Test + void 게시글의_조회수_캐시가_삭제() { + // given (준비) + Long articleId = 300L; + String redisKey = KEY_PREFIX + articleId; + articleViewCountCacheService.increase(articleId); // 조회수 1로 만듦 + assertThat(articleViewCountCacheService.getViewCount(articleId)).isEqualTo(1); + + // when (실행) + articleViewCountCacheService.reset(articleId); // 조회수 초기화 (삭제) + + // then (검증) + // 1. 서비스 메서드를 통해 조회수가 0으로 반환되는지 확인 + long viewCountAfterReset = articleViewCountCacheService.getViewCount(articleId); + assertThat(viewCountAfterReset).isEqualTo(0); + + // 2. Redis에 실제로 해당 키가 없는지 확인 + Boolean keyExists = redisTemplate.hasKey(redisKey); + assertThat(keyExists).isFalse(); + } +} diff --git a/src/test/java/com/config/jwt/token/RefreshTokenServiceTest.java b/src/test/java/com/config/jwt/token/RefreshTokenServiceTest.java new file mode 100644 index 0000000..4fc852f --- /dev/null +++ b/src/test/java/com/config/jwt/token/RefreshTokenServiceTest.java @@ -0,0 +1,135 @@ +package com.config.jwt.token; + +import com.exception.custom.MyEntityNotFoundException; +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 java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RefreshTokenServiceTest { + + @InjectMocks + private RefreshTokenService refreshTokenService; + @Mock + private RefreshTokenRepository refreshTokenRepository; + + private final Long TEST_MEMBER_ID = 1L; + private final String TEST_TOKEN = "testRefreshToken"; + private final RefreshToken TEST_REFRESH_TOKEN = new RefreshToken(TEST_MEMBER_ID, TEST_TOKEN); + + @Test + @DisplayName("mergeToken - 기존 토큰이 없을 때 저장") + void mergeToken_새로운토큰저장() { + // given + given(refreshTokenRepository.findById(TEST_MEMBER_ID)).willReturn(Optional.empty()); + given(refreshTokenRepository.save(any(RefreshToken.class))).willReturn(TEST_REFRESH_TOKEN); + + // when + refreshTokenService.mergeToken(TEST_MEMBER_ID, TEST_TOKEN); + + // then + verify(refreshTokenRepository, times(1)).save(any(RefreshToken.class)); + } + + @Test() + @DisplayName("mergeToken - 기존 토큰이 있을 때 업데이트") + void mergeToken_기존토큰업데이트() { + // given + String newRefreshToken = "newRefreshToken"; + RefreshToken existingRefreshToken = new RefreshToken(TEST_MEMBER_ID, "oldRefreshToken"); + given(refreshTokenRepository.findById(TEST_MEMBER_ID)).willReturn(Optional.of(existingRefreshToken)); + + // when + refreshTokenService.mergeToken(TEST_MEMBER_ID, newRefreshToken); + + // then + assertThat(existingRefreshToken.getToken()).isEqualTo(newRefreshToken); + verify(refreshTokenRepository, times(1)).findById(TEST_MEMBER_ID); + verify(refreshTokenRepository, never()).save(any(RefreshToken.class)); + } + + @Test + @DisplayName("isTokenValid - 토큰이 유효할 때 true 반환") + void isTokenValid_유효한토큰() { + // given + given(refreshTokenRepository.findById(TEST_MEMBER_ID)).willReturn(Optional.of(TEST_REFRESH_TOKEN)); + + // when + boolean isValid = refreshTokenService.isTokenValid(TEST_MEMBER_ID, TEST_TOKEN); + + // then + assertThat(isValid).isTrue(); + } + + @Test + @DisplayName("isTokenValid - 토큰이 유효하지 않을 때 false 반환") + void isTokenValid_유효하지않은토큰() { + // given + given(refreshTokenRepository.findById(TEST_MEMBER_ID)).willReturn(Optional.of(TEST_REFRESH_TOKEN)); + String invalidToken = "invalidRefreshToken"; + + // when + boolean isValid = refreshTokenService.isTokenValid(TEST_MEMBER_ID, invalidToken); + + // then + assertThat(isValid).isFalse(); + } + + @Test + @DisplayName("isTokenValid - 해당 멤버 ID로 저장된 토큰이 없을 때 false 반환") + void isTokenValid_토큰없음() { + // given + given(refreshTokenRepository.findById(TEST_MEMBER_ID)).willReturn(Optional.empty()); + + // when + boolean isValid = refreshTokenService.isTokenValid(TEST_MEMBER_ID, TEST_TOKEN); + + // then + assertThat(isValid).isFalse(); + } + + @Test + @DisplayName("deleteToken - 멤버 ID로 토큰 삭제") + void deleteToken_토큰삭제() { + // when + refreshTokenService.deleteToken(TEST_MEMBER_ID); + + // then + verify(refreshTokenRepository, times(1)).deleteById(TEST_MEMBER_ID); + } + + @Test + @DisplayName("findByMemberId - 멤버 ID로 토큰 찾기 - 존재할 때") + void findByMemberId_토큰존재() { + // given + given(refreshTokenRepository.findById(TEST_MEMBER_ID)).willReturn(Optional.of(TEST_REFRESH_TOKEN)); + + // when + RefreshToken foundToken = refreshTokenService.findByMemberId(TEST_MEMBER_ID); + + // then + assertThat(foundToken).isEqualTo(TEST_REFRESH_TOKEN); + } + + @Test + @DisplayName("findByMemberId - 멤버 ID로 토큰 찾기 - 존재하지 않을 때 MyEntityNotFoundException 발생") + void findByMemberId_토큰없음() { + // given + given(refreshTokenRepository.findById(TEST_MEMBER_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> refreshTokenService.findByMemberId(TEST_MEMBER_ID)) + .isInstanceOf(MyEntityNotFoundException.class); + } +} diff --git a/src/test/java/com/config/redis/RedisConnectionTest.java b/src/test/java/com/config/redis/RedisConnectionTest.java new file mode 100644 index 0000000..db3cb83 --- /dev/null +++ b/src/test/java/com/config/redis/RedisConnectionTest.java @@ -0,0 +1,21 @@ +package com.config.redis; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +class RedisConnectionTest { + @Autowired + private RedisTemplate redisTemplate; + + @Test + void redis_set_and_get() { + redisTemplate.opsForValue().set("testkey", "hello redis"); + String value = (String) redisTemplate.opsForValue().get("testkey"); + assertEquals("hello redis", value); + } +} diff --git a/src/test/java/com/member/controller/MemberControllerIntegrationTest.java b/src/test/java/com/member/controller/MemberControllerIntegrationTest.java new file mode 100644 index 0000000..873c3cb --- /dev/null +++ b/src/test/java/com/member/controller/MemberControllerIntegrationTest.java @@ -0,0 +1,187 @@ +package com.member.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.board.dto.request.ArticleCreateRequest; +import com.config.jwt.JwtUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.member.dto.request.LoginRequest; +import com.member.dto.response.LoginResponse; +import com.member.entity.MemberEntity; +import com.member.repository.MemberRepository; +import com.support.CleanDatabaseBeforeEachTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +class MemberControllerIntegrationTest extends CleanDatabaseBeforeEachTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; // JSON 변환을 위해 사용 + + @Autowired + private MemberRepository memberRepository; + + @Autowired + JwtUtil jwtUtil; + + String setUpMemberEmail = "setupMember@example.com"; + String setUpMemberPassword = "12345"; + String setUpMemberNickname = "setupMemberNickname"; + Long setUpMemberId; + + @BeforeEach + void setUp() { + // 테스트용 회원 데이터 미리 저장 + MemberEntity member = new MemberEntity(setUpMemberEmail, setUpMemberPassword, setUpMemberNickname); + MemberEntity save = memberRepository.save(member); + setUpMemberId = save.getId(); + } + + @Test + @DisplayName("로그아웃_요청시_쿠키가_만료된다") + void 로그아웃_요청시_쿠키가_만료된다() throws Exception { + // Given + String token = getAccessToken(); + + // When: 로그아웃 요청 + mockMvc.perform(post("/members/logout") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("회원탈퇴_요청시_쿠키가_만료되고_재로그인이_불가능하다") + void 회원탈퇴_후_재로그인_불가() throws Exception { + // Given + String token = getAccessToken(); + + // When: 회원 탈퇴 요청 + mockMvc.perform(delete("/members/withdraw") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isNoContent()); + + // Then: 로그인 실패 + LoginRequest loginRequest = new LoginRequest(setUpMemberEmail, setUpMemberPassword); + String loginJson = objectMapper.writeValueAsString(loginRequest); + + mockMvc.perform(post("/public/members/login") + .contentType(MediaType.APPLICATION_JSON) + .content(loginJson)) + .andExpect(jsonPath("$.statusCode").value(404)); + } + + @Test + @DisplayName("로그아웃_후_인증이_필요한_요청시_거부된다") + void 로그아웃_후_인증요청_실패() throws Exception { + // Given + String token = getAccessToken(); + + // 로그아웃 + mockMvc.perform(post("/members/logout") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isNoContent()); + + // 이후 요청 시 → 쿠키 없음 (즉, 인증 실패 유도) + mockMvc.perform(post("/articles") // 인증 필요한 API + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ArticleCreateRequest("제목", "내용")))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("회원탈퇴_후_인증이_필요한_요청시_거부된다") + void 회원탈퇴_후_인증요청_실패() throws Exception { + // Given: 로그인 후 JWT 토큰 발급 + String token = getAccessToken(); + + // When: 회원 탈퇴 요청 + mockMvc.perform(delete("/members/withdraw") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isNoContent()); + + // Then: 인증이 필요한 요청 시 실패 + mockMvc.perform(post("/articles") // 인증 필요한 API + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ArticleCreateRequest("제목", "내용")))) + .andExpect(status().isUnauthorized()); + } + + private String getAccessToken() throws Exception { + LoginRequest loginRequest = new LoginRequest(setUpMemberEmail, setUpMemberPassword); + String loginJson = objectMapper.writeValueAsString(loginRequest); + + MvcResult result = mockMvc.perform(post("/public/members/login") + .contentType(MediaType.APPLICATION_JSON) + .content(loginJson)) + .andExpect(status().isOk()) + .andReturn(); + + String responseBody = result.getResponse().getContentAsString(); + return objectMapper.readTree(responseBody) + .get("accessToken") + .get("token") + .asText(); // JWT 토큰 쿠키 값 반환 + } + + @Test + @DisplayName("만료된_엑세스_토큰으로_요청시_401_응답") + void 만료된_엑세스_토큰으로_요청시_401_응답() throws Exception { + // Given: 3초짜리 만료 토큰 생성 + String expiredSoonToken = jwtUtil.generateToken(setUpMemberId, 1); // 3초 + + // 3초 대기 + Thread.sleep(5); + + // When: 인증 필요한 요청 시도 + mockMvc.perform(post("/articles") + .header("Authorization", "Bearer " + expiredSoonToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ArticleCreateRequest("제목", "내용")))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("만료된_엑세스_토큰으로_재발급_요청시_401_응답") + void 만료된_엑세스_토큰으로_재발급_요청시_401_응답() throws Exception { + // Given + String expiredSoonToken = jwtUtil.generateToken(setUpMemberId, 1); // 3초 뒤 만료되는 토큰 생성 + Thread.sleep(5); + + // When + mockMvc.perform(post("/members/reissue") + .header("Authorization", "Bearer " + expiredSoonToken)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("유효한_엑세스_토큰으로_재발급_요청시_새로운_엑세스_토큰_반환") + void 유효한_엑세스_토큰으로_재발급_요청시_성공() throws Exception { + // Given + String validToken = getAccessToken(); + + // When + MvcResult result = mockMvc.perform(post("/members/reissue") + .header("Authorization", "Bearer " + validToken)) + .andExpect(status().isOk()) + .andReturn(); + + // Then + String responseBody = result.getResponse().getContentAsString(); + LoginResponse loginResponse = objectMapper.readValue(responseBody, LoginResponse.class); + assertThat(loginResponse.getAccessToken()).isNotNull(); + assertThat(loginResponse.getAccessToken().getToken()).isNotBlank(); + assertThat(loginResponse.getAccessToken().getExpiration()).isGreaterThan(0L); + } +} diff --git a/src/test/java/com/member/controller/MemberControllerTest.java b/src/test/java/com/member/controller/MemberControllerTest.java new file mode 100644 index 0000000..9063707 --- /dev/null +++ b/src/test/java/com/member/controller/MemberControllerTest.java @@ -0,0 +1,102 @@ +package com.member.controller; + +import com.config.auth.AuthUtil; +import com.config.auth.AuthenticatedMemberArgumentResolver; +import com.config.jwt.TokenWithExpiration; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.member.dto.response.LoginResponse; +import com.member.service.MemberService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(MemberController.class) +class MemberControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AuthUtil authUtil; + + @MockitoBean + private MemberService memberService; + + @MockitoBean + private AuthenticatedMemberArgumentResolver authenticatedMemberArgumentResolver; + + @Autowired + private ObjectMapper objectMapper; + + @BeforeEach + void setup() throws Exception { + when(authenticatedMemberArgumentResolver.supportsParameter(any())).thenReturn(true); + when(authenticatedMemberArgumentResolver.resolveArgument(any(), any(), any(), any())) + .thenReturn(1L); // Long memberId 주입 + } + + @Test + @DisplayName("로그아웃 테스트") + public void logout_ShouldCallMemberServiceLogout() throws Exception { + // Given + Long memberId = 1L; + + // When: 로그아웃 요청 + mockMvc.perform(post("/members/logout")) + .andExpect(status().isNoContent()); + + // Then: MemberService의 logout 호출 확인 + verify(memberService).logout(memberId); + } + + @Test + @DisplayName("회원탈퇴 테스트") + public void withdraw_ShouldCallMemberServiceWithdraw() throws Exception { + // Given + Long memberId = 1L; + + // When: 회원탈퇴 요청 + mockMvc.perform(delete("/members/withdraw")) + .andExpect(status().isNoContent()); + + // Then: MemberService의 withdraw 호출 확인 + verify(memberService).withdraw(memberId); + } + + @Test + @DisplayName("리프레시 토큰 재발급 잘 되는지 테스트") + public void reissueToken_ShouldCallMemberServiceReissueAccessToken() throws Exception { + // Given + Long memberId = 1L; + TokenWithExpiration newAccessToken = new TokenWithExpiration("newAccessToken", 3600L); + LoginResponse expectedResponse = LoginResponse.builder() + .accessToken(newAccessToken) + .build(); + + when(memberService.reissueAccessToken(memberId)).thenReturn(expectedResponse); + + // When + MvcResult result = mockMvc.perform(post("/members/reissue")) + .andExpect(status().isOk()) + .andReturn(); + + // Then + verify(memberService).reissueAccessToken(memberId); + String responseBody = result.getResponse().getContentAsString(); + LoginResponse actualResponse = objectMapper.readValue(responseBody, LoginResponse.class); + assertThat(actualResponse.getAccessToken().getToken()).isEqualTo("newAccessToken"); + } +} diff --git a/src/test/java/com/board/member/controller/MemberControllerIntegrationTest.java b/src/test/java/com/member/controller/PublicMemberControllerIntegrationTest.java similarity index 68% rename from src/test/java/com/board/member/controller/MemberControllerIntegrationTest.java rename to src/test/java/com/member/controller/PublicMemberControllerIntegrationTest.java index 8c1cab9..b2c9e66 100644 --- a/src/test/java/com/board/member/controller/MemberControllerIntegrationTest.java +++ b/src/test/java/com/member/controller/PublicMemberControllerIntegrationTest.java @@ -1,34 +1,26 @@ -package com.board.member.controller; +package com.member.controller; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.board.config.jwt.JwtUtil; -import com.board.member.dto.request.LoginRequest; -import com.board.member.dto.request.MemberSignUpRequest; -import com.board.member.entity.MemberEntity; -import com.board.member.repository.MemberRepository; +import com.config.jwt.JwtUtil; import com.fasterxml.jackson.databind.ObjectMapper; +import com.member.dto.request.LoginRequest; +import com.member.dto.request.SignUpRequest; +import com.member.entity.MemberEntity; +import com.member.repository.MemberRepository; +import com.support.CleanDatabaseBeforeEachTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; -import org.springframework.transaction.annotation.Transactional; -@SpringBootTest -@AutoConfigureMockMvc -@Transactional -@ActiveProfiles("test") -class MemberControllerIntegrationTest { +class PublicMemberControllerIntegrationTest extends CleanDatabaseBeforeEachTest { @Autowired private MockMvc mockMvc; @@ -45,12 +37,13 @@ class MemberControllerIntegrationTest { String setUpMemberEmail = "setupMember@example.com"; String setUpMemberPassword = "12345"; String setUpMemberNickname = "setupMemberNickname"; + Long setUpMemberId; @BeforeEach void setUp() { // 테스트용 회원 데이터 미리 저장 MemberEntity member = new MemberEntity(setUpMemberEmail, setUpMemberPassword, setUpMemberNickname); - memberRepository.save(member); + setUpMemberId = memberRepository.save(member).getId(); } @Test @@ -59,11 +52,10 @@ void loginTest() throws Exception { LoginRequest loginRequest = new LoginRequest(setUpMemberEmail, setUpMemberPassword); String loginJson = objectMapper.writeValueAsString(loginRequest); - mockMvc.perform(post("/members/login") + mockMvc.perform(post("/public/members/login") .contentType(MediaType.APPLICATION_JSON) .content(loginJson)) .andExpect(status().isOk()) - .andExpect(cookie().exists("token")) // JWT 쿠키 존재 확인 .andExpect(jsonPath("$.accessToken").exists()); // accessToken 존재 확인 } @@ -74,19 +66,18 @@ void loginAndValidateJwtTest() throws Exception { LoginRequest loginRequest = new LoginRequest(setUpMemberEmail, setUpMemberPassword); String loginJson = objectMapper.writeValueAsString(loginRequest); - MvcResult loginResult = mockMvc.perform(post("/members/login") + MvcResult loginResult = mockMvc.perform(post("/public/members/login") .contentType(MediaType.APPLICATION_JSON) .content(loginJson)) .andExpect(status().isOk()) - .andExpect(cookie().exists("token")) - .andExpect(jsonPath("$.accessToken").exists()) - .andExpect(jsonPath("$.accessToken").isNotEmpty()) + .andExpect(jsonPath("$.accessToken.token").exists()) + .andExpect(jsonPath("$.accessToken.token").isNotEmpty()) .andReturn(); // JWT 추출 및 검증 String responseBody = loginResult.getResponse().getContentAsString(); - String token = objectMapper.readTree(responseBody).get("accessToken").asText(); - assertThat(jwtUtil.extractEmail(token)).isEqualTo(setUpMemberEmail); + String token = objectMapper.readTree(responseBody).get("accessToken").get("token").asText(); + assertThat(jwtUtil.extractMemberIdFromToken(token)).isEqualTo(setUpMemberId); assertThat(jwtUtil.isTokenValid(token)).isTrue(); } @@ -97,15 +88,14 @@ void signUpTest() throws Exception { final String testNickName = "test-nickname"; final String testPassword = "password123"; - MemberSignUpRequest signUpRequest = new MemberSignUpRequest(testEmail, testNickName, testPassword); + SignUpRequest signUpRequest = new SignUpRequest(testEmail, testNickName, testPassword); String signUpJson = objectMapper.writeValueAsString(signUpRequest); - mockMvc.perform(post("/members/signup") + mockMvc.perform(post("/public/members/signup") .contentType(MediaType.APPLICATION_JSON) .content(signUpJson)) .andExpect(status().isOk()) .andExpect(jsonPath("$.email").value(testEmail)) .andExpect(jsonPath("$.nickName").value(testNickName)); } - } diff --git a/src/test/java/com/member/controller/PublicMemberControllerTest.java b/src/test/java/com/member/controller/PublicMemberControllerTest.java new file mode 100644 index 0000000..9b64880 --- /dev/null +++ b/src/test/java/com/member/controller/PublicMemberControllerTest.java @@ -0,0 +1,90 @@ +package com.member.controller; + +import com.config.auth.AuthUtil; +import com.config.auth.AuthenticatedMemberArgumentResolver; +import com.config.jwt.TokenWithExpiration; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.member.dto.request.LoginRequest; +import com.member.dto.request.SignUpRequest; +import com.member.dto.response.LoginResponse; +import com.member.dto.response.SignUpResponse; +import com.member.service.MemberService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(PublicMemberController.class) +class PublicMemberControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AuthUtil authUtil; + + @MockitoBean + private MemberService memberService; + + @MockitoBean + private AuthenticatedMemberArgumentResolver authenticatedMemberArgumentResolver; + + @Autowired + private ObjectMapper objectMapper; + + @BeforeEach + void setup() throws Exception { + when(authenticatedMemberArgumentResolver.supportsParameter(any())).thenReturn(true); + when(authenticatedMemberArgumentResolver.resolveArgument(any(), any(), any(), any())) + .thenReturn(1L); // Long memberId 주입 + } + + @Test + void signUp_Success() throws Exception { + // Given + SignUpRequest request = new SignUpRequest("test@example.com", "nickname", "password123"); + SignUpResponse response = new SignUpResponse(1L, "test@example.com", "nickname"); + + when(memberService.signUp(any(SignUpRequest.class))).thenReturn(response); + + // When & Then + mockMvc.perform(post("/public/members/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(new ObjectMapper().writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.email").value("test@example.com")) + .andExpect(jsonPath("$.nickName").value("nickname")); + } + + @Test + void login_Success() throws Exception { + // Given + LoginRequest loginRequest = new LoginRequest("test@example.com", "password123"); + TokenWithExpiration accessToken = new TokenWithExpiration("mockAccessToken", 3600L); + TokenWithExpiration refreshToken = new TokenWithExpiration("mockRefreshToken", 604800L); + LoginResponse loginResponse = new LoginResponse(accessToken, refreshToken); + + + when(memberService.login(any(LoginRequest.class))).thenReturn(loginResponse); + + // When & Then + mockMvc.perform(post("/public/members/login") + .contentType(MediaType.APPLICATION_JSON) + .content(new ObjectMapper().writeValueAsString(loginRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.accessToken.token").value("mockAccessToken")) + .andExpect(jsonPath("$.accessToken.expiration").value(3600)) + .andExpect(jsonPath("$.refreshToken.token").value("mockRefreshToken")) + .andExpect(jsonPath("$.refreshToken.expiration").value(604800)); + } +} diff --git a/src/test/java/com/member/service/MemberServiceImplTest.java b/src/test/java/com/member/service/MemberServiceImplTest.java new file mode 100644 index 0000000..8db5368 --- /dev/null +++ b/src/test/java/com/member/service/MemberServiceImplTest.java @@ -0,0 +1,220 @@ +package com.member.service; + +import com.config.jwt.JwtUtil; +import com.config.jwt.TokenWithExpiration; +import com.config.jwt.token.RefreshToken; +import com.config.jwt.token.RefreshTokenService; +import com.exception.custom.InvalidToken; +import com.exception.custom.LoginException; +import com.exception.custom.MyEntityNotFoundException; +import com.exception.custom.SignUpException; +import com.member.dto.request.LoginRequest; +import com.member.dto.request.SignUpRequest; +import com.member.dto.response.LoginResponse; +import com.member.dto.response.SignUpResponse; +import com.member.entity.MemberEntity; +import com.member.repository.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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 java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberServiceImplTest { + + @InjectMocks + private MemberServiceImpl memberService; + + @Mock + private MemberRepository memberRepository; + + @Mock + private RefreshTokenService refreshTokenService; + + @Mock + private JwtUtil jwtUtil; + + private MemberEntity member; + + @BeforeEach + void setUp() { + member = MemberEntity.builder() + .email("test@example.com") + .password("1234") + .nickName("testUser") + .build(); + } + + @Nested + @DisplayName("회원가입 테스트") + class SignUpTests { + + @Test + @DisplayName("회원가입 성공") + void signUp_Success() { + // Given + SignUpRequest request = new SignUpRequest("test@example.com", "1234", "testUser"); + when(memberRepository.save(any(MemberEntity.class))).thenReturn(member); + + // When + SignUpResponse response = memberService.signUp(request); + + // Then + assertNotNull(response); + assertEquals("test@example.com", response.getEmail()); + assertEquals("testUser", response.getNickName()); + } + + @Test + @DisplayName("회원가입 중복 이메일 에러") + void signUp_DuplicateEmail_ThrowsException() { + // Given + SignUpRequest request = new SignUpRequest("test@example.com", "1234", "testUser"); + when(memberRepository.findByEmail(request.getEmail())).thenReturn(Optional.of(member)); + + // When & Then + assertThrows(SignUpException.class, () -> memberService.signUp(request)); + } + } + + @Nested + @DisplayName("로그인 테스트") + class LoginTests { + @Test + @DisplayName("로그인 성공") + void login_Success() { + // Given + LoginRequest request = new LoginRequest("test@example.com", "1234"); + when(memberRepository.findByEmailAndIsDeletedFalse(request.getEmail())).thenReturn(Optional.of(member)); + + TokenWithExpiration accessToken = new TokenWithExpiration("token", 3600000L); + TokenWithExpiration refreshToken = new TokenWithExpiration("refreshToken", 604800000L); + + when(jwtUtil.generateAccessToken(any())).thenReturn(accessToken); + when(jwtUtil.generateRefreshToken(any())).thenReturn(refreshToken); + + // When + LoginResponse response = memberService.login(request); + + // Then + assertNotNull(response); + assertEquals("token", response.getAccessToken().getToken()); + assertEquals(3600000L, response.getAccessToken().getExpiration()); + assertEquals("refreshToken", response.getRefreshToken().getToken()); + assertEquals(604800000L, response.getRefreshToken().getExpiration()); + } + + @Test + @DisplayName("로그인 실패 - 잘못된 비밀번호") + void login_Fail_WrongPassword() { + // Given + LoginRequest request = new LoginRequest("test@example.com", "wrongPassword"); + when(memberRepository.findByEmailAndIsDeletedFalse(request.getEmail())).thenReturn(Optional.of(member)); + + // When & Then + assertThrows(IllegalArgumentException.class, () -> memberService.login(request)); + } + + @Test + @DisplayName("로그인 실패 - 이메일이 존재하지 않음") + void login_Fail_EmailNotFound() { + // Given + LoginRequest request = new LoginRequest("nonexistent@example.com", "1234"); + when(memberRepository.findByEmailAndIsDeletedFalse(request.getEmail())).thenReturn(Optional.empty()); + + // When & Then + assertThrows(LoginException.class, () -> memberService.login(request)); + } + } + + + @Test + @DisplayName("이메일로 멤버 찾기 성공") + void findByEmail_Success() { + // Given + when(memberRepository.findByEmail("test@example.com")).thenReturn(Optional.of(member)); + + // When + MemberEntity foundMember = memberService.findByEmail("test@example.com"); + + // Then + assertNotNull(foundMember); + assertEquals("test@example.com", foundMember.getEmail()); + } + + @Test + @DisplayName("ID로 멤버 찾기 성공") + void findById_Success() { + // Given + when(memberRepository.findById(1L)).thenReturn(Optional.of(member)); + + // When + MemberEntity foundMember = memberService.findById(1L); + + // Then + assertNotNull(foundMember); + assertEquals("test@example.com", foundMember.getEmail()); + } + + @Test + @DisplayName("유효한 리프래시 토큰으로 새 액세스 토큰 발급") + void reissueAccessToken_유효한리프래시토큰() { + // Given + Long memberId = 1L; + String refreshTokenValue = "validRefreshToken"; + RefreshToken refreshToken = new RefreshToken(memberId, refreshTokenValue); + TokenWithExpiration newAccessToken = new TokenWithExpiration("newAccessToken", 3600L); + + when(refreshTokenService.findByMemberId(memberId)).thenReturn(refreshToken); + when(jwtUtil.isTokenValid(refreshTokenValue)).thenReturn(true); + when(jwtUtil.generateAccessToken(memberId)).thenReturn(newAccessToken); + + // When + LoginResponse response = memberService.reissueAccessToken(memberId); + + // Then + assertThat(response.getAccessToken().getToken()).isEqualTo("newAccessToken"); + } + + @Test + @DisplayName("유효하지 않은 리프래시 토큰으로 예외 발생") + void reissueAccessToken_유효하지않은리프래시토큰() { + // Given + Long memberId = 1L; + String refreshTokenValue = "invalidRefreshToken"; + RefreshToken refreshToken = new RefreshToken(memberId, refreshTokenValue); + + when(refreshTokenService.findByMemberId(memberId)).thenReturn(refreshToken); + when(jwtUtil.isTokenValid(refreshTokenValue)).thenReturn(false); + + // When & Then + assertThatThrownBy(() -> memberService.reissueAccessToken(memberId)) + .isInstanceOf(InvalidToken.class); + } + + @Test + @DisplayName("reissueAccessToken - 멤버 ID로 리프래시 토큰을 찾을 수 없을 때 MyEntityNotFoundException 발생") + void reissueAccessToken_리프래시토큰없음() { + // Given + Long memberId = 1L; + when(refreshTokenService.findByMemberId(memberId)).thenThrow(MyEntityNotFoundException.from(memberId)); + + // When & Then + assertThatThrownBy(() -> memberService.reissueAccessToken(memberId)) + .isInstanceOf(MyEntityNotFoundException.class); + } + + +} diff --git a/src/test/java/com/support/CleanDatabaseBeforeEachTest.java b/src/test/java/com/support/CleanDatabaseBeforeEachTest.java new file mode 100644 index 0000000..8e42cc5 --- /dev/null +++ b/src/test/java/com/support/CleanDatabaseBeforeEachTest.java @@ -0,0 +1,16 @@ +package com.support; + +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; + +@IsolatedTest +public abstract class CleanDatabaseBeforeEachTest { + + @Autowired + private DatabaseCleaner databaseCleaner; + + @BeforeEach + void cleanDatabase() { + databaseCleaner.truncateAll(); + } +} diff --git a/src/test/java/com/support/DatabaseCleaner.java b/src/test/java/com/support/DatabaseCleaner.java new file mode 100644 index 0000000..a0bb427 --- /dev/null +++ b/src/test/java/com/support/DatabaseCleaner.java @@ -0,0 +1,33 @@ +package com.support; + +import jakarta.annotation.PostConstruct; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class DatabaseCleaner { + + private final EntityManager em; + private List tableNames; + + @PostConstruct + public void init() { + tableNames = em.createNativeQuery( + "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'PUBLIC'") + .getResultList(); + } + + @Transactional + public void truncateAll() { + em.flush(); + em.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate(); + for (String table : tableNames) { + em.createNativeQuery("TRUNCATE TABLE " + table).executeUpdate(); + } + em.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate(); + } +} diff --git a/src/test/java/com/support/IntegrationTest.java b/src/test/java/com/support/IntegrationTest.java new file mode 100644 index 0000000..7ba487d --- /dev/null +++ b/src/test/java/com/support/IntegrationTest.java @@ -0,0 +1,20 @@ +package com.support; + +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@ActiveProfiles("test") +public @interface IntegrationTest { +} diff --git a/src/test/java/com/support/IsolatedTest.java b/src/test/java/com/support/IsolatedTest.java new file mode 100644 index 0000000..aacb4cf --- /dev/null +++ b/src/test/java/com/support/IsolatedTest.java @@ -0,0 +1,17 @@ +package com.support; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@interface IsolatedTest { +} diff --git a/src/test/java/com/util/sort/SortUtilsTest.java b/src/test/java/com/util/sort/SortUtilsTest.java new file mode 100644 index 0000000..33056aa --- /dev/null +++ b/src/test/java/com/util/sort/SortUtilsTest.java @@ -0,0 +1,75 @@ +package com.util.sort; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Order; + +class SortUtilsTest { + + @Nested + @DisplayName("게시글 정렬 테스트") + class ArticleSortTest { + + @Test + @DisplayName("정상적인_정렬_파라미터_입력_시_정상_반환") + void 정상적인_정렬_파라미터_입력_시_정상_반환() { + Sort latestSort = SortUtils.getArticleSort("latest"); + Sort viewsSort = SortUtils.getArticleSort("views"); + + assertAll( + () -> { + Order order = latestSort.getOrderFor("createdAt"); + assertThat(order).isNotNull(); + assertThat(order.getDirection()).isEqualTo(Sort.Direction.DESC); + }, + () -> { + Order order = viewsSort.getOrderFor("viewCount"); + assertThat(order).isNotNull(); + assertThat(order.getDirection()).isEqualTo(Sort.Direction.DESC); + } + ); + } + + @Test + @DisplayName("잘못된_정렬_파라미터_입력_시_createdAt_DESC_기본값_반환") + void 잘못된_정렬_파라미터_입력_시_default_반환() { + Sort sort = SortUtils.getArticleSort("invalidKey"); + + Order order = sort.getOrderFor("createdAt"); + + assertThat(order).isNotNull(); + assertThat(order.getDirection()).isEqualTo(Sort.Direction.DESC); + } + } + + @Nested + @DisplayName("댓글 정렬 테스트") + class CommentSortTest { + + @Test + @DisplayName("최신순_정렬_입력_시_createdAt_DESC_반환") + void 최신순_정렬_입력_시_createdAt_DESC_반환() { + Sort sort = SortUtils.getCommentSort("latest"); + Order order = sort.getOrderFor("createdAt"); + + assertThat(order).isNotNull(); + assertThat(order.getDirection()).isEqualTo(Sort.Direction.DESC); + } + + @Test + @DisplayName("지원되지_않는_정렬_입력_시_createdAt_DESC_기본값_반환") + void 지원되지_않는_정렬_입력_시_createdAt_DESC_반환() { + Sort sort = SortUtils.getCommentSort("views"); // 댓글은 viewCount 허용 안됨 + + Order order = sort.getOrderFor("createdAt"); + + assertThat(order).isNotNull(); + assertThat(order.getDirection()).isEqualTo(Sort.Direction.DESC); + } + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 0e8ecc8..b307dab 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -7,11 +7,16 @@ spring: jpa: hibernate: - ddl-auto: create-drop + ddl-auto: create show-sql: true properties: hibernate: format_sql: true + + data: + redis: + host: localhost + port: 6380 #테스트용 다른 포트 jwt: issuer: test-issuer