#16 board : article add, update, delete impl

This commit is contained in:
haerong22
2022-08-21 20:53:30 +09:00
parent 76caed8f49
commit dff43b265e
10 changed files with 308 additions and 43 deletions

View File

@@ -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";
}
}

View File

@@ -28,6 +28,7 @@ public class UserAccount extends AuditingFields {
@Setter
@Column(length = 100)
private String email;
@Setter
@Column(length = 100)
private String nickname;

View File

@@ -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;
}
}

View File

@@ -1,4 +1,4 @@
package com.example.board.domain.type;
package com.example.board.domain.constant;
import lombok.Getter;

View File

@@ -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

View File

@@ -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
);
}
}

View File

@@ -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<UserAccount, Long> {
public interface UserAccountRepository extends JpaRepository<UserAccount, String> {
}

View File

@@ -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<ArticleDto> 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<String> getHashtags() {
return articleRepository.findAllDistinctHashtags();
}
public long getArticleCount() {
return articleRepository.count();
}
}

View File

@@ -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,

View File

@@ -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,