diff --git a/board/src/main/java/com/example/board/controller/ArticleController.java b/board/src/main/java/com/example/board/controller/ArticleController.java index 856789b6..231f67e2 100644 --- a/board/src/main/java/com/example/board/controller/ArticleController.java +++ b/board/src/main/java/com/example/board/controller/ArticleController.java @@ -1,6 +1,9 @@ package com.example.board.controller; -import com.example.board.domain.type.SearchType; +import com.example.board.domain.constant.FormStatus; +import com.example.board.domain.constant.SearchType; +import com.example.board.dto.UserAccountDto; +import com.example.board.dto.request.ArticleRequest; import com.example.board.dto.response.ArticleResponse; import com.example.board.dto.response.ArticleWithCommentsResponse; import com.example.board.service.ArticleService; @@ -12,10 +15,7 @@ import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; import org.springframework.stereotype.Controller; import org.springframework.ui.ModelMap; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.*; import java.util.List; @@ -45,14 +45,15 @@ public class ArticleController { @GetMapping("/{articleId}") public String article(@PathVariable Long articleId, ModelMap map) { - ArticleWithCommentsResponse article = ArticleWithCommentsResponse.from(articleService.getArticle(articleId)); + ArticleWithCommentsResponse article = ArticleWithCommentsResponse.from(articleService.getArticleWithComments(articleId)); map.addAttribute("article", article); map.addAttribute("articleComments", article.articleCommentsResponse()); + map.addAttribute("totalCount", articleService.getArticleCount()); return "articles/detail"; } @GetMapping("/search-hashtag") - public String searchHashtag( + public String searchArticleHashtag( @RequestParam(required = false) SearchType searchType, @RequestParam(required = false) String searchValue, @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable, @@ -69,4 +70,49 @@ public class ArticleController { return "articles/search-hashtag"; } + + @GetMapping("/form") + public String articleForm(ModelMap map) { + map.addAttribute("formStatus", FormStatus.CREATE); + + return "articles/form"; + } + + @PostMapping ("/form") + public String postNewArticle(ArticleRequest articleRequest) { + // TODO: 인증 정보를 넣어줘야 한다. + articleService.saveArticle(articleRequest.toDto(UserAccountDto.of( + "bobby", "asdf1234", "bobby@mail.com", "bobby", "memo" + ))); + + return "redirect:/articles"; + } + + @GetMapping("/{articleId}/form") + public String updateArticleForm(@PathVariable Long articleId, ModelMap map) { + ArticleResponse article = ArticleResponse.from(articleService.getArticle(articleId)); + + map.addAttribute("article", article); + map.addAttribute("formStatus", FormStatus.UPDATE); + + return "articles/form"; + } + + @PostMapping ("/{articleId}/form") + public String updateArticle(@PathVariable Long articleId, ArticleRequest articleRequest) { + // TODO: 인증 정보를 넣어줘야 한다. + articleService.updateArticle(articleId, articleRequest.toDto(UserAccountDto.of( + "bobby", "asdf1234", "bobby@mail.com", "bobby", "memo", null, null, null, null + ))); + + return "redirect:/articles/" + articleId; + } + + @PostMapping ("/{articleId}/delete") + public String deleteArticle(@PathVariable Long articleId) { + // TODO: 인증 정보를 넣어줘야 한다. + articleService.deleteArticle(articleId); + + return "redirect:/articles"; + } } diff --git a/board/src/main/java/com/example/board/domain/UserAccount.java b/board/src/main/java/com/example/board/domain/UserAccount.java index 3f94c617..f59b17ff 100644 --- a/board/src/main/java/com/example/board/domain/UserAccount.java +++ b/board/src/main/java/com/example/board/domain/UserAccount.java @@ -28,6 +28,7 @@ public class UserAccount extends AuditingFields { @Setter @Column(length = 100) private String email; + @Setter @Column(length = 100) private String nickname; diff --git a/board/src/main/java/com/example/board/domain/constant/FormStatus.java b/board/src/main/java/com/example/board/domain/constant/FormStatus.java new file mode 100644 index 00000000..9ff0bc4c --- /dev/null +++ b/board/src/main/java/com/example/board/domain/constant/FormStatus.java @@ -0,0 +1,17 @@ +package com.example.board.domain.constant; + +import lombok.Getter; + +public enum FormStatus { + CREATE("저장", false), + UPDATE("수정", true); + + @Getter private final String description; + @Getter private final Boolean update; + + FormStatus(String description, Boolean update) { + this.description = description; + this.update = update; + } + +} \ No newline at end of file diff --git a/board/src/main/java/com/example/board/domain/type/SearchType.java b/board/src/main/java/com/example/board/domain/constant/SearchType.java similarity index 87% rename from board/src/main/java/com/example/board/domain/type/SearchType.java rename to board/src/main/java/com/example/board/domain/constant/SearchType.java index d9a35d13..73a88664 100644 --- a/board/src/main/java/com/example/board/domain/type/SearchType.java +++ b/board/src/main/java/com/example/board/domain/constant/SearchType.java @@ -1,4 +1,4 @@ -package com.example.board.domain.type; +package com.example.board.domain.constant; import lombok.Getter; diff --git a/board/src/main/java/com/example/board/dto/ArticleDto.java b/board/src/main/java/com/example/board/dto/ArticleDto.java index 0437b95b..ed6a38bc 100644 --- a/board/src/main/java/com/example/board/dto/ArticleDto.java +++ b/board/src/main/java/com/example/board/dto/ArticleDto.java @@ -1,6 +1,7 @@ package com.example.board.dto; import com.example.board.domain.Article; +import com.example.board.domain.UserAccount; import java.time.LocalDateTime; @@ -15,6 +16,11 @@ public record ArticleDto( LocalDateTime modifiedAt, String modifiedBy ) { + + public static ArticleDto of(UserAccountDto userAccountDto, String title, String content, String hashtag) { + return new ArticleDto(null, userAccountDto, title, content, hashtag, null, null, null, null); + } + public static ArticleDto of(Long id, UserAccountDto userAccountDto, String title, String content, String hashtag, LocalDateTime createdAt, String createdBy, LocalDateTime modifiedAt, String modifiedBy) { return new ArticleDto(id, userAccountDto, title, content, hashtag, createdAt, createdBy, modifiedAt, modifiedBy); } @@ -33,9 +39,9 @@ public record ArticleDto( ); } - public Article toEntity() { + public Article toEntity(UserAccount userAccount) { return Article.of( - userAccountDto.toEntity(), + userAccount, title, content, hashtag diff --git a/board/src/main/java/com/example/board/dto/request/ArticleRequest.java b/board/src/main/java/com/example/board/dto/request/ArticleRequest.java new file mode 100644 index 00000000..cfbf7a80 --- /dev/null +++ b/board/src/main/java/com/example/board/dto/request/ArticleRequest.java @@ -0,0 +1,25 @@ +package com.example.board.dto.request; + +import com.example.board.dto.ArticleDto; +import com.example.board.dto.UserAccountDto; + +public record ArticleRequest( + String title, + String content, + String hashtag +) { + + public static ArticleRequest of(String title, String content, String hashtag) { + return new ArticleRequest(title, content, hashtag); + } + + public ArticleDto toDto(UserAccountDto userAccountDto) { + return ArticleDto.of( + userAccountDto, + title, + content, + hashtag + ); + } + +} \ No newline at end of file diff --git a/board/src/main/java/com/example/board/repository/UserAccountRepository.java b/board/src/main/java/com/example/board/repository/UserAccountRepository.java index 3ebadb86..d9718587 100644 --- a/board/src/main/java/com/example/board/repository/UserAccountRepository.java +++ b/board/src/main/java/com/example/board/repository/UserAccountRepository.java @@ -3,5 +3,5 @@ package com.example.board.repository; import com.example.board.domain.UserAccount; import org.springframework.data.jpa.repository.JpaRepository; -public interface UserAccountRepository extends JpaRepository { +public interface UserAccountRepository extends JpaRepository { } \ No newline at end of file diff --git a/board/src/main/java/com/example/board/service/ArticleService.java b/board/src/main/java/com/example/board/service/ArticleService.java index de64ceea..ff8b7654 100644 --- a/board/src/main/java/com/example/board/service/ArticleService.java +++ b/board/src/main/java/com/example/board/service/ArticleService.java @@ -1,10 +1,12 @@ package com.example.board.service; import com.example.board.domain.Article; -import com.example.board.domain.type.SearchType; +import com.example.board.domain.UserAccount; +import com.example.board.domain.constant.SearchType; import com.example.board.dto.ArticleDto; import com.example.board.dto.ArticleWithCommentsDto; import com.example.board.repository.ArticleRepository; +import com.example.board.repository.UserAccountRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -22,6 +24,7 @@ import java.util.List; public class ArticleService { private final ArticleRepository articleRepository; + private final UserAccountRepository userAccountRepository; @Transactional(readOnly = true) public Page searchArticles(SearchType searchType, String searchKeyword, Pageable pageable) { @@ -39,28 +42,29 @@ public class ArticleService { } @Transactional(readOnly = true) - public ArticleWithCommentsDto getArticle(Long articleId) { + public ArticleWithCommentsDto getArticleWithComments(Long articleId) { return articleRepository.findById(articleId) .map(ArticleWithCommentsDto::from) .orElseThrow(() -> new EntityNotFoundException("게시글이 없습니다 - articleId: " + articleId)); } - public void saveArticle(ArticleDto dto) { - articleRepository.save(dto.toEntity()); + @Transactional(readOnly = true) + public ArticleDto getArticle(Long articleId) { + return articleRepository.findById(articleId) + .map(ArticleDto::from) + .orElseThrow(() -> new EntityNotFoundException("게시글이 없습니다 - articleId: " + articleId)); } - public void updateArticle(ArticleDto dto) { + public void saveArticle(ArticleDto dto) { + UserAccount userAccount = userAccountRepository.getReferenceById(dto.userAccountDto().userId()); + articleRepository.save(dto.toEntity(userAccount)); + } + + public void updateArticle(Long articleId, ArticleDto dto) { try { - Article article = articleRepository.getReferenceById(dto.id()); - - if (dto.title() != null) { - article.setTitle(dto.title()); - } - - if (dto.content() != null) { - article.setContent(dto.content()); - } - + Article article = articleRepository.getReferenceById(articleId); + if (dto.title() != null) { article.setTitle(dto.title()); } + if (dto.content() != null) { article.setContent(dto.content()); } article.setHashtag(dto.hashtag()); } catch (EntityNotFoundException e) { log.warn("게시글 업데이트 실패. 게시글을 찾을 수 없습니다 - dto: {}", dto); @@ -82,4 +86,8 @@ public class ArticleService { public List getHashtags() { return articleRepository.findAllDistinctHashtags(); } + + public long getArticleCount() { + return articleRepository.count(); + } } diff --git a/board/src/test/java/com/example/board/controller/ArticleControllerTest.java b/board/src/test/java/com/example/board/controller/ArticleControllerTest.java index 2bf5cfb4..6f8f21a3 100644 --- a/board/src/test/java/com/example/board/controller/ArticleControllerTest.java +++ b/board/src/test/java/com/example/board/controller/ArticleControllerTest.java @@ -1,11 +1,16 @@ package com.example.board.controller; import com.example.board.config.SecurityConfig; -import com.example.board.domain.type.SearchType; +import com.example.board.domain.constant.FormStatus; +import com.example.board.domain.constant.SearchType; +import com.example.board.dto.ArticleDto; import com.example.board.dto.ArticleWithCommentsDto; import com.example.board.dto.UserAccountDto; +import com.example.board.dto.request.ArticleRequest; +import com.example.board.dto.response.ArticleResponse; import com.example.board.service.ArticleService; import com.example.board.service.PaginationService; +import com.example.board.util.FormDataEncoder; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -25,15 +30,18 @@ import java.util.List; import java.util.Set; import static org.mockito.BDDMockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 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.result.MockMvcResultMatchers.*; @DisplayName("View 컨트롤러 - 게시글") -@Import(SecurityConfig.class) +@Import({SecurityConfig.class, FormDataEncoder.class}) @WebMvcTest(ArticleController.class) class ArticleControllerTest { private final MockMvc mockMvc; + private final FormDataEncoder formDataEncoder; @MockBean private ArticleService articleService; @@ -41,8 +49,12 @@ class ArticleControllerTest { @MockBean private PaginationService paginationService; - public ArticleControllerTest(@Autowired MockMvc mockMvc) { + public ArticleControllerTest( + @Autowired MockMvc mockMvc, + @Autowired FormDataEncoder formDataEncoder + ) { this.mockMvc = mockMvc; + this.formDataEncoder = formDataEncoder; } @DisplayName("[view][GET] 게시글 리스트(게시판) 페이지 - 정상 호출") @@ -126,8 +138,9 @@ class ArticleControllerTest { public void givenNothing_whenRequestingArticleView_thenReturnsArticleView() throws Exception { // given Long articleId = 1L; + long totalCount = 1L; - given(articleService.getArticle(articleId)).willReturn(createArticleWithCommentDto()); + given(articleService.getArticleWithComments(articleId)).willReturn(createArticleWithCommentDto()); // when & then mockMvc.perform(get("/articles/" + articleId)) @@ -138,7 +151,8 @@ class ArticleControllerTest { .andExpect(model().attributeExists("articleComments")) ; - then(articleService).should().getArticle(articleId); + then(articleService).should().getArticleWithComments(articleId); + then(articleService).should().getArticleCount(); } @Disabled("구현 중") @@ -208,6 +222,107 @@ class ArticleControllerTest { then(paginationService).should().getPaginationBarNumbers(anyInt(), anyInt()); } + @DisplayName("[view][GET] 새 게시글 작성 페이지") + @Test + void givenNothing_whenRequesting_thenReturnsNewArticlePage() throws Exception { + // Given + + // When & Then + mockMvc.perform(get("/articles/form")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)) + .andExpect(view().name("articles/form")) + .andExpect(model().attribute("formStatus", FormStatus.CREATE)); + } + + @DisplayName("[view][POST] 새 게시글 등록 - 정상 호출") + @Test + void givenNewArticleInfo_whenRequesting_thenSavesNewArticle() throws Exception { + // Given + ArticleRequest articleRequest = ArticleRequest.of("new title", "new content", "#new"); + willDoNothing().given(articleService).saveArticle(any(ArticleDto.class)); + + // When & Then + mockMvc.perform( + post("/articles/form") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .content(formDataEncoder.encode(articleRequest)) + .with(csrf()) + ) + .andExpect(status().is3xxRedirection()) + .andExpect(view().name("redirect:/articles")) + .andExpect(redirectedUrl("/articles")); + then(articleService).should().saveArticle(any(ArticleDto.class)); + } + + @DisplayName("[view][GET] 게시글 수정 페이지") + @Test + void givenNothing_whenRequesting_thenReturnsUpdatedArticlePage() throws Exception { + // Given + long articleId = 1L; + ArticleDto dto = createArticleDto(); + given(articleService.getArticle(articleId)).willReturn(dto); + + // When & Then + mockMvc.perform(get("/articles/" + articleId + "/form")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)) + .andExpect(view().name("articles/form")) + .andExpect(model().attribute("article", ArticleResponse.from(dto))) + .andExpect(model().attribute("formStatus", FormStatus.UPDATE)); + then(articleService).should().getArticle(articleId); + } + + @DisplayName("[view][POST] 게시글 수정 - 정상 호출") + @Test + void givenUpdatedArticleInfo_whenRequesting_thenUpdatesNewArticle() throws Exception { + // Given + long articleId = 1L; + ArticleRequest articleRequest = ArticleRequest.of("new title", "new content", "#new"); + willDoNothing().given(articleService).updateArticle(eq(articleId), any(ArticleDto.class)); + + // When & Then + mockMvc.perform( + post("/articles/" + articleId + "/form") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .content(formDataEncoder.encode(articleRequest)) + .with(csrf()) + ) + .andExpect(status().is3xxRedirection()) + .andExpect(view().name("redirect:/articles/" + articleId)) + .andExpect(redirectedUrl("/articles/" + articleId)); + then(articleService).should().updateArticle(eq(articleId), any(ArticleDto.class)); + } + + @DisplayName("[view][POST] 게시글 삭제 - 정상 호출") + @Test + void givenArticleIdToDelete_whenRequesting_thenDeletesArticle() throws Exception { + // Given + long articleId = 1L; + willDoNothing().given(articleService).deleteArticle(articleId); + + // When & Then + mockMvc.perform( + post("/articles/" + articleId + "/delete") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .with(csrf()) + ) + .andExpect(status().is3xxRedirection()) + .andExpect(view().name("redirect:/articles")) + .andExpect(redirectedUrl("/articles")); + then(articleService).should().deleteArticle(articleId); + } + + + private ArticleDto createArticleDto() { + return ArticleDto.of( + createUserAccountDto(), + "title", + "content", + "#java" + ); + } + private ArticleWithCommentsDto createArticleWithCommentDto() { return ArticleWithCommentsDto.of( 1L, diff --git a/board/src/test/java/com/example/board/service/ArticleServiceTest.java b/board/src/test/java/com/example/board/service/ArticleServiceTest.java index 8a2e1cae..8bdebebb 100644 --- a/board/src/test/java/com/example/board/service/ArticleServiceTest.java +++ b/board/src/test/java/com/example/board/service/ArticleServiceTest.java @@ -2,11 +2,12 @@ package com.example.board.service; import com.example.board.domain.Article; import com.example.board.domain.UserAccount; -import com.example.board.domain.type.SearchType; +import com.example.board.domain.constant.SearchType; import com.example.board.dto.ArticleDto; import com.example.board.dto.ArticleWithCommentsDto; import com.example.board.dto.UserAccountDto; import com.example.board.repository.ArticleRepository; +import com.example.board.repository.UserAccountRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -15,6 +16,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; import javax.persistence.EntityNotFoundException; import java.time.LocalDateTime; @@ -35,6 +37,9 @@ class ArticleServiceTest { @Mock private ArticleRepository articleRepository; + @Mock + private UserAccountRepository userAccountRepository; + @DisplayName("검색어 없이 게시글을 검색하면, 게시글 페이지를 반환한다.") @Test void givenNoSearchParameters_whenSearchingArticles_thenReturnsArticlePage() { @@ -97,16 +102,16 @@ class ArticleServiceTest { then(articleRepository).should().findByHashtag(hashtag, pageable); } - @DisplayName("게시글을 조회하면 게시글을 반환한다.") + @DisplayName("게시글 ID로 조회하면, 댓글 달긴 게시글을 반환한다.") @Test - void givenArticleId_whenSearchingArticle_thenReturnArticle() { - // given + void givenArticleId_whenSearchingArticleWithComments_thenReturnsArticleWithComments() { + // Given Long articleId = 1L; Article article = createArticle(); given(articleRepository.findById(articleId)).willReturn(Optional.of(article)); // When - ArticleWithCommentsDto dto = sut.getArticle(articleId); + ArticleWithCommentsDto dto = sut.getArticleWithComments(articleId); // Then assertThat(dto) @@ -116,7 +121,43 @@ class ArticleServiceTest { then(articleRepository).should().findById(articleId); } - @DisplayName("없는 게시글을 조회하면, 예외를 던진다.") + @DisplayName("댓글 달린 게시글이 없으면, 예외를 던진다.") + @Test + void givenNonexistentArticleId_whenSearchingArticleWithComments_thenThrowsException() { + // Given + Long articleId = 0L; + given(articleRepository.findById(articleId)).willReturn(Optional.empty()); + + // When + Throwable t = catchThrowable(() -> sut.getArticleWithComments(articleId)); + + // Then + assertThat(t) + .isInstanceOf(EntityNotFoundException.class) + .hasMessage("게시글이 없습니다 - articleId: " + articleId); + then(articleRepository).should().findById(articleId); + } + + @DisplayName("게시글을 조회하면 게시글을 반환한다.") + @Test + void givenArticleId_whenSearchingArticle_thenReturnArticle() { + // given + Long articleId = 1L; + Article article = createArticle(); + given(articleRepository.findById(articleId)).willReturn(Optional.of(article)); + + // When + ArticleDto dto = sut.getArticle(articleId); + + // Then + assertThat(dto) + .hasFieldOrPropertyWithValue("title", article.getTitle()) + .hasFieldOrPropertyWithValue("content", article.getContent()) + .hasFieldOrPropertyWithValue("hashtag", article.getHashtag()); + then(articleRepository).should().findById(articleId); + } + + @DisplayName("게시글이 없으면, 예외를 던진다.") @Test void givenNonexistentArticleId_whenSearchingArticle_thenThrowsException() { // Given @@ -124,7 +165,7 @@ class ArticleServiceTest { given(articleRepository.findById(articleId)).willReturn(Optional.empty()); // When - Throwable t = catchThrowable(() -> sut.getArticle(articleId)); + Throwable t = catchThrowable(() -> sut.getArticleWithComments(articleId)); // Then assertThat(t) @@ -138,13 +179,15 @@ class ArticleServiceTest { void givenArticleInfo_whenSavingArticle_thenSavesArticle() { // given ArticleDto dto = createArticleDto(); - given(articleRepository.save(any(Article.class))).willReturn(createArticle()); + given(userAccountRepository.getReferenceById(dto.userAccountDto().userId())).willReturn(createUserAccount()); + given(articleRepository.save(any(Article.class))).willReturn(createArticle()); // when sut.saveArticle(dto); // then + then(userAccountRepository).should().getReferenceById(dto.userAccountDto().userId()); then(articleRepository).should().save(any(Article.class)); } @@ -157,7 +200,7 @@ class ArticleServiceTest { given(articleRepository.getReferenceById(dto.id())).willReturn(article); // when - sut.updateArticle(dto); + sut.updateArticle(dto.id(), dto); // then assertThat(article) @@ -175,7 +218,7 @@ class ArticleServiceTest { given(articleRepository.getReferenceById(dto.id())).willThrow(EntityNotFoundException.class); // When - sut.updateArticle(dto); + sut.updateArticle(dto.id(), dto); // Then then(articleRepository).should().getReferenceById(dto.id()); @@ -221,12 +264,15 @@ class ArticleServiceTest { } private Article createArticle() { - return Article.of( + Article article = Article.of( createUserAccount(), "title", "content", "#java" ); + ReflectionTestUtils.setField(article, "id", 1L); + + return article; } private ArticleDto createArticleDto() { @@ -234,7 +280,8 @@ class ArticleServiceTest { } private ArticleDto createArticleDto(String title, String content, String hashtag) { - return ArticleDto.of(1L, + return ArticleDto.of( + 1L, createUserAccountDto(), title, content,