들어가기에 앞서
실제 프로젝트 진행했던 코드 내용 및 세부 내용은 일부만 업로드하였습니다
<body>
<header class="p-3 bg-dark text-white">
<div class="container">
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
<li><a id="home" href="#" class="nav-link px-2 text-secondary">Home</a></li>
<li><a id="hashtag" href="#" class="nav-link px-2 text-secondary">Hashtags</a></li>
</ul>
<?xml version="1.0"?>
<thlogic>
<attr sel="#home" th:href="@{/}" />
<attr sel="#hashtag" th:href="@{/articles/search-hashtag}" />
</thlogic>
import com.fastcampus.projectboard.domain.UserAccount;
import java.time.LocalDateTime;
public record UserAccountDto(
String userId,
String userPassword,
String email,
String nickname,
String memo,
LocalDateTime createdAt,
String createdBy,
LocalDateTime modifiedAt,
String modifiedBy
) {
// 리팩토링하면서 추가
public static UserAccountDto of(String userId, String userPassword, String email, String nickname, String memo) {
return new UserAccountDto(userId, userPassword, email, nickname, memo, null, null, null, null);
}
public static UserAccountDto of(String userId, String userPassword, String email, String nickname, String memo, LocalDateTime createdAt, String createdBy, LocalDateTime modifiedAt, String modifiedBy) {
return new UserAccountDto(userId, userPassword, email, nickname, memo, createdAt, createdBy, modifiedAt, modifiedBy);
}
public static UserAccountDto from(UserAccount entity) {
return new UserAccountDto(
entity.getUserId(),
entity.getUserPassword(),
entity.getEmail(),
entity.getNickname(),
entity.getMemo(),
entity.getCreatedAt(),
entity.getCreatedBy(),
entity.getModifiedAt(),
entity.getModifiedBy()
);
}
public UserAccount toEntity() {
return UserAccount.of(
userId,
userPassword,
email,
nickname,
memo
);
}
}
testImplementation 'org.springframework.security:spring-security-test'
@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(
"uno", "asdf1234", "uno@mail.com", "Uno", "memo", null, null, null, null
)));
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(
"uno", "asdf1234", "uno@mail.com", "Uno", "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";
}
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;
}
}
import com.fastcampus.projectboard.domain.Article;
import com.fastcampus.projectboard.domain.UserAccount;
import java.time.LocalDateTime;
public record ArticleDto(
// Entity로 받아올 데이터
Long id,
UserAccountDto userAccountDto,
String title,
String content,
String hashtag,
LocalDateTime createdAt,
String createdBy,
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);
}
// Entity -> dto로 변환
public static ArticleDto from(Article entity) {
return new ArticleDto(
entity.getId(),
UserAccountDto.from(entity.getUserAccount()),
entity.getTitle(),
entity.getContent(),
entity.getHashtag(),
entity.getCreatedAt(),
entity.getCreatedBy(),
entity.getModifiedAt(),
entity.getModifiedBy()
);
}
// dto -> Entity로 변환
public Article toEntity(UserAccount userAccount) { // userAccount를 받게 수정
return Article.of(
userAccount, // userAccountDto 를 Entity로 변환한 것에서 userAccount를 직접 리턴하는 것으로 변경
title,
content,
hashtag
);
}
}
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
);
}
}
@Slf4j
@RequiredArgsConstructor
@Transactional
@Service
public class ArticleService {
private final ArticleRepository articleRepository;
private final UserAccountRepository userAccountRepository;
@Transactional(readOnly = true)
public Page<ArticleDto> searchArticles(SearchType searchType, String searchKeyword, Pageable pageable) {
if (searchKeyword == null || searchKeyword.isBlank()) { // 검색 키워드가 없을 경우
return articleRepository.findAll(pageable).map(ArticleDto::from);
// ArticleDto 클래스의 from 메소드 값, 즉 Entity -> Dto화한 것을 매핑
}
return switch(searchType) {
case TITLE -> articleRepository.findByTitleContaining(searchKeyword, pageable).map(ArticleDto::from);
case CONTENT -> articleRepository.findByContentContaining(searchKeyword, pageable).map(ArticleDto::from);
case ID -> articleRepository.findByUserAccount_UserIdContaining(searchKeyword, pageable).map(ArticleDto::from);
case NICKNAME -> articleRepository.findByUserAccount_NicknameContaining(searchKeyword, pageable).map(ArticleDto::from);
case HASHTAG -> articleRepository.findByHashtag(searchKeyword, pageable).map(ArticleDto::from);
};
}
@Transactional(readOnly = true)
public ArticleWithCommentsDto getArticleWithComments(Long articleId) {
return articleRepository.findById(articleId)
.map(ArticleWithCommentsDto::from)
.orElseThrow(() -> new EntityNotFoundException("게시글이 없습니다 - articleId: " + articleId));
}
@Transactional(readOnly = true)
public ArticleDto getArticle(Long articleId) {
return articleRepository.findById(articleId)
.map(ArticleDto::from)
.orElseThrow(() -> new EntityNotFoundException("게시글이 없습니다 - articleId: " + articleId));
}
public void saveArticle(ArticleDto dto) {
UserAccount userAccount = userAccountRepository.getReferenceById(dto.userAccountDto().userId());
articleRepository.save(dto.toEntity(userAccount)); // dto -> Entity로 변환 후 저장
}
public void updateArticle(Long articleId, ArticleDto dto) {
try {
Article article = articleRepository.getReferenceById(articleId);
if (dto.title() != null) { article.setTitle(dto.title());} // record 타입에 getter/setter
if (dto.content() != null) { article.setContent(dto.content()); } // record 타입에 getter/setter
article.setHashtag(dto.hashtag());
// articleRepository.save(article); save 필요 없음 (Transactional)
} catch (EntityNotFoundException e) {
log.warn("게시글 업데이트 실패. 게시글을 찾을 수 없습니다 - dto: {}", dto); // @Slf4j
}
}
public void deleteArticle(Long articleId) {
articleRepository.deleteById(articleId);
}
public long getArticleCount() {
return articleRepository.count();
}
@Transactional(readOnly = true)
public Page<ArticleDto> searchArticlesViaHashtag(String hashtag, Pageable pageable) {
if (hashtag == null || hashtag.isBlank()) {
return Page.empty(pageable);
}
return articleRepository.findByHashtag(hashtag, pageable).map(ArticleDto::from);
}
public List<String> getHashtags() {
return articleRepository.findAllDistinctHashtags();
}
}
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.Map;
public class FormDataEncoder {
private final ObjectMapper mapper;
public FormDataEncoder(ObjectMapper mapper) {
this.mapper = mapper;
}
public String encode(Object obj) {
Map<String, String> fieldMap = mapper.convertValue(obj, new TypeReference<>() {});
MultiValueMap<String, String> valueMap = new LinkedMultiValueMap<>();
valueMap.setAll(fieldMap);
return UriComponentsBuilder.newInstance()
.queryParams(valueMap)
.encode()
.build()
.getQuery();
}
}
import com.fasterxml.jackson.databind.ObjectMapper;
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.context.annotation.Import;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
@DisplayName("테스트 도구 - Form 데이터 인코더")
@Import({FormDataEncoder.class, ObjectMapper.class})
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = Void.class)
class FormDataEncoderTest {
private final FormDataEncoder formDataEncoder;
public FormDataEncoderTest(@Autowired FormDataEncoder formDataEncoder) {
this.formDataEncoder = formDataEncoder;
}
@DisplayName("객체를 넣으면, url encoding 된 form body data 형식의 문자열을 돌려준다.")
@Test
void givenObject_whenEncoding_thenReturnsFormEncodedString() {
// Given
TestObject obj = new TestObject(
"This 'is' \"test\" string.",
List.of("hello", "my", "friend").toString().replace(" ", ""),
String.join(",", "hello", "my", "friend"),
null,
1234,
3.14,
false,
BigDecimal.TEN,
TestEnum.THREE
);
// When
String result = formDataEncoder.encode(obj);
// Then
assertThat(result).isEqualTo(
"str=This%20'is'%20%22test%22%20string." +
"&listStr1=%5Bhello,my,friend%5D" +
"&listStr2=hello,my,friend" +
"&nullStr" +
"&number=1234" +
"&floatingNumber=3.14" +
"&bool=false" +
"&bigDecimal=10" +
"&testEnum=THREE"
);
}
record TestObject(
String str,
String listStr1,
String listStr2,
String nullStr,
Integer number,
Double floatingNumber,
Boolean bool,
BigDecimal bigDecimal,
TestEnum testEnum
) {}
enum TestEnum {
ONE, TWO, THREE
}
}
import com.fastcampus.projectboard.domain.UserAccount;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserAccountRepository extends JpaRepository<UserAccount, String> {
}
@Import({SecurityConfig.class, FormDataEncoder.class})
@DisplayName("View 컨트롤러 - 게시글")
@Import({SecurityConfig.class, FormDataEncoder.class})
@WebMvcTest(ArticleController.class)
class ArticleControllerTest {
private final MockMvc mvc;
private final FormDataEncoder formDataEncoder;
@MockBean // mockito의 mock과 동일, @Autowired 불가, 필드에만 주입
private ArticleService articleService;
@MockBean
private PaginationService paginationService;
public ArticleControllerTest(
@Autowired MockMvc mvc,
@Autowired FormDataEncoder formDataEncoder
) {
this.mvc = mvc;
this.formDataEncoder = formDataEncoder;
}
@DisplayName("[VIEW][GET] 새 게시글 작성 페이지")
@Test
void givenNothing_whenRequesting_thenReturnsNewArticlePage() throws Exception {
// Given
// When & Then
mvc.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
mvc.perform(
post("/articles/form") // post : MockMvcRequestBuilders.post
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.content(formDataEncoder.encode(articleRequest))
.with(csrf()) // csrf : SecurityMockMvcRequestPostProcessors.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
mvc.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
mvc.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
mvc.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);
}
@Mock private UserAccountRepository userAccountRepository
추가; @DisplayName("READ - 게시글 ID로 조회하면, 댓글 달긴 게시글을 반환한다.")
@Test
void givenArticleId_whenSearchingArticleWithComments_thenReturnsArticleWithComments() {
// Given
Long articleId = 1L;
Article article = createArticle();
given(articleRepository.findById(articleId)).willReturn(Optional.of(article));
// When
ArticleWithCommentsDto dto = sut.getArticleWithComments(articleId);
// Then
assertThat(dto)
.hasFieldOrPropertyWithValue("title", article.getTitle())
.hasFieldOrPropertyWithValue("content", article.getContent())
.hasFieldOrPropertyWithValue("hashtag", article.getHashtag());
then(articleRepository).should().findById(articleId);
}
@DisplayName("READ - 댓글 달린 게시글이 없으면, 예외를 던진다.")
@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("CREATE - 게시글 정보 입력 시 게시글을 생성")
@Test
void givenArticleInfo_whenSavingArticle_thenSavesArticle() {
// Given
// static import [ given = BDDMockito.given, any = ArgumentMatchers.any ]
ArticleDto dto = createArticleDto();
given(userAccountRepository.getReferenceById(dto.userAccountDto().userId())).willReturn(createUserAccount()); // 생성자 id 정보
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));
}
private Article createArticle() {
Article article = Article.of(
createUserAccount(),
"title",
"content",
"#java"
);
ReflectionTestUtils.setField(article, "id", 1L);
return article;
}
<div class="row g-5" id="article-buttons">
<form id="delete-article-form">
<div class="pb-5 d-grid gap-2 d-md-block">
<a class="btn btn-success me-md-2" role="button" id="update-article">수정</a>
<button class="btn btn-danger me-md-2" type="submit">삭제</button>
</div>
</form>
</div>
<attr sel="#article-buttons">
<attr sel="#delete-article-form" th:action="'/articles/' + *{id} + '/delete'" th:method="post">
<attr sel="#update-article" th:href="'/articles/' + *{id} + '/form'" />
</attr>
</attr>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<meta name="author" content="Uno Kim">
<title>새 게시글 등록</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-0evHe/X+R7YkIZDRvuzKMRqM+OrBnVFBL6DOitfPri4tjfHxaWutUpFmBp4vmVor" crossorigin="anonymous">
</head>
<body>
<header id="header">
헤더 삽입부
<hr>
</header>
<div class="container">
<header id="article-form-header" class="py-5 text-center">
<h1>게시글 작성</h1>
</header>
<form id="article-form">
<div class="row mb-3 justify-content-md-center">
<label for="title" class="col-sm-2 col-lg-1 col-form-label text-sm-end">제목</label>
<div class="col-sm-8 col-lg-9">
<input type="text" class="form-control" id="title" name="title" required>
</div>
</div>
<div class="row mb-3 justify-content-md-center">
<label for="content" class="col-sm-2 col-lg-1 col-form-label text-sm-end">본문</label>
<div class="col-sm-8 col-lg-9">
<textarea class="form-control" id="content" name="content" rows="5" required></textarea>
</div>
</div>
<div class="row mb-4 justify-content-md-center">
<label for="hashtag" class="col-sm-2 col-lg-1 col-form-label text-sm-end">해시태그</label>
<div class="col-sm-8 col-lg-9">
<input type="text" class="form-control" id="hashtag" name="hashtag">
</div>
</div>
<div class="row mb-5 justify-content-md-center">
<div class="col-sm-10 d-grid gap-2 d-sm-flex justify-content-sm-end">
<button type="submit" class="btn btn-primary" id="submit-button">저장</button>
<button type="button" class="btn btn-secondary" id="cancel-button">취소</button>
</div>
</div>
</form>
</div>
<footer id="footer">
<hr>
푸터 삽입부
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/js/bootstrap.bundle.min.js" integrity="sha384-pprn3073KE6tl6bjs2QrFaJGz5/SUsLqktiwsUTF55Jfv3qYSDhgCecCxMW52nD2" crossorigin="anonymous"></script>
</body>
</html>
<?xml version="1.0"?>
<thlogic>
<attr sel="#header" th:replace="header :: header" />
<attr sel="#footer" th:replace="footer :: footer" />
<attr sel="#article-form-header/h1" th:text="${formStatus} ? '게시글 ' + ${formStatus.description} : _" />
<attr sel="#article-form" th:action="${formStatus?.update} ? '/articles/' + ${article.id} + '/form' : '/articles/form'" th:method="post">
<attr sel="#title" th:value="${article?.title} ?: _" />
<attr sel="#content" th:text="${article?.content} ?: _" />
<attr sel="#hashtag" th:value="${article?.hashtag} ?: _" />
<attr sel="#submit-button" th:text="${formStatus?.description} ?: _" />
<attr sel="#cancel-button" th:onclick="'history.back()'" />
</attr>
</thlogic>
@RequiredArgsConstructor
@RequestMapping("/comments")
@Controller
public class ArticleCommentController {
private final ArticleCommentService articleCommentService;
@PostMapping ("/new")
public String postNewArticleComment(ArticleCommentRequest articleCommentRequest) {
return "redirect:/articles/" + articleCommentRequest.articleId();
}
@PostMapping ("/{commentId}/delete")
public String deleteArticleComment(@PathVariable Long commentId, Long articleId) {
return "redirect:/articles/" + articleId;
}
}
public record ArticleCommentRequest(
Long articleId,
String content
) {
public static ArticleCommentRequest of(Long aritcleId, String content) {
return new ArticleCommentRequest(aritcleId, content);
}
public ArticleCommentDto toDto(UserAccountDto userAccountDto) {
return ArticleCommentDto.of(
articleId,
userAccountDto,
content
);
}
}
public record ArticleCommentDto(
Long id,
Long articleId,
UserAccountDto userAccountDto,
String content,
LocalDateTime createdAt,
String createdBy,
LocalDateTime modifiedAt,
String modifiedBy
) {
public static ArticleCommentDto of(Long articleId, UserAccountDto userAccountDto, String content) {
return new ArticleCommentDto(null, articleId, userAccountDto, content, null, null, null, null);
} // Request를 위한 새 생성자
public static ArticleCommentDto of(Long id, Long articleId, UserAccountDto userAccountDto, String content, LocalDateTime createdAt, String createdBy, LocalDateTime modifiedAt, String modifiedBy) {
return new ArticleCommentDto(id, articleId, userAccountDto, content, createdAt, createdBy, modifiedAt, modifiedBy);
}
// 추가
public static ArticleCommentDto from(ArticleComment entity) {
return new ArticleCommentDto(
entity.getId(),
entity.getArticle().getId(),
UserAccountDto.from(entity.getUserAccount()),
entity.getContent(),
entity.getCreatedAt(),
entity.getCreatedBy(),
entity.getModifiedAt(),
entity.getModifiedBy()
);
}
public ArticleComment toEntity(Article entity) {
return ArticleComment.of(
entity,
userAccountDto.toEntity(),
content
);
}
}
@DisplayName("View 컨트롤러 - 댓글")
@Import({SecurityConfig.class, FormDataEncoder.class})
@WebMvcTest(ArticleCommentController.class)
class ArticleCommentControllerTest {
private final MockMvc mvc;
private final FormDataEncoder formDataEncoder;
@MockBean // mockito의 mock과 동일, @Autowired 불가, 필드에만 주입
private ArticleCommentService articleCommentService;
public ArticleCommentControllerTest(
@Autowired MockMvc mvc,
@Autowired FormDataEncoder formDataEncoder
) {
this.mvc = mvc;
this.formDataEncoder = formDataEncoder;
}
@DisplayName("[view][POST] 댓글 등록 - 정상 호출")
@Test
void givenArticleCommentInfo_whenRequesting_thenSavesNewArticleComment() throws Exception {
// Given
long articleId = 1L;
ArticleCommentRequest request = ArticleCommentRequest.of(articleId, "test comment");
willDoNothing().given(articleCommentService).saveArticleComment(any(ArticleCommentDto.class));
// When & Then
mvc.perform(
post("/comments/new")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.content(formDataEncoder.encode(request))
.with(csrf())
)
.andExpect(status().is3xxRedirection())
.andExpect(view().name("redirect:/articles/" + articleId))
.andExpect(redirectedUrl("/articles/" + articleId));
then(articleCommentService).should().saveArticleComment(any(ArticleCommentDto.class));
}
@DisplayName("[view][GET] 댓글 삭제 - 정상 호출")
@Test
void givenArticleCommentIdToDelete_whenRequesting_thenDeletesArticleComment() throws Exception {
// Given
long articleId = 1L;
long articleCommentId = 1L;
willDoNothing().given(articleCommentService).deleteArticleComment(articleCommentId);
// When & Then
mvc.perform(
post("/comments/" + articleCommentId + "/delete")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.content(formDataEncoder.encode(Map.of("articleId", articleId)))
.with(csrf())
)
.andExpect(status().is3xxRedirection())
.andExpect(view().name("redirect:/articles/" + articleId))
.andExpect(redirectedUrl("/articles/" + articleId));
then(articleCommentService).should().deleteArticleComment(articleCommentId);
}
}
@PostMapping ("/new")
public String postNewArticleComment(ArticleCommentRequest articleCommentRequest) {
// TODO :: 인증 정보를 넣어줘야 함
articleCommentService.saveArticleComment(articleCommentRequest.toDto(UserAccountDto.of("mrcocoball", "password", "cocoball@mail.com", "코코볼", null
)));
return "redirect:/articles/" + articleCommentRequest.articleId();
}
@PostMapping ("/{commentId}/delete")
public String deleteArticleComment(@PathVariable Long commentId, Long articleId) {
articleCommentService.deleteArticleComment(commentId);
return "redirect:/articles/" + articleId;
}
}
@DisplayName("CREATE - 댓글 정보 입력 시 댓글 저장")
@Test
void givenArticleCommentInfo_whenSavingComment_thenSavesComment() {
// Given
// static import [ given = BDDMockito.given, any = ArgumentMatchers.any ]
ArticleCommentDto dto = createArticleCommentDto("댓글");
given(articleRepository.getReferenceById(dto.articleId())).willReturn(createArticle());
given(userAccountRepository.getReferenceById(dto.userAccountDto().userId())).willReturn(createUserAccount());
given(articleCommentRepository.save(any(ArticleComment.class))).willReturn(null);
// articleCommentRepository에 ArticleComment.class의 아무거나 저장하고 null을 리턴
// When
sut.saveArticleComment(dto);
// Then
then(articleRepository).should().getReferenceById(dto.articleId());
then(userAccountRepository).should().getReferenceById(dto.userAccountDto().userId());
then(articleCommentRepository).should().save(any(ArticleComment.class)); // articleRepository의 save가 호출되어야 한다!
}
@DisplayName("CREATE - 댓글 저장을 시도했는데 맞는 게시글이 없으면, 경고 로그를 찍고 아무것도 안 함")
@Test
void givenNonexistentArticle_whenSavingArticleComment_thenLogsSituationAndDoesNothing() {
// Given
ArticleCommentDto dto = createArticleCommentDto("댓글");
given(articleRepository.getReferenceById(dto.articleId())).willThrow(EntityNotFoundException.class);
// When
sut.saveArticleComment(dto);
// Then
then(articleRepository).should().getReferenceById(dto.articleId());
then(userAccountRepository).shouldHaveNoInteractions();
then(articleCommentRepository).shouldHaveNoInteractions();
}
@Slf4j
@RequiredArgsConstructor
@Transactional
@Service
public class ArticleCommentService {
private final ArticleCommentRepository articleCommentRepository;
private final ArticleRepository articleRepository;
private final UserAccountRepository userAccountRepository;
@Transactional(readOnly = true)
public List<ArticleCommentDto> searchArticleComment(Long articleId) {
return articleCommentRepository.findByArticle_Id(articleId)
.stream()
.map(ArticleCommentDto::from)
.toList();
}
public void saveArticleComment(ArticleCommentDto dto) {
try {
Article article = articleRepository.getReferenceById(dto.articleId());
UserAccount userAccount = userAccountRepository.getReferenceById(dto.userAccountDto().userId());
articleCommentRepository.save(dto.toEntity(article, userAccount));
} catch (EntityNotFoundException e) {
log.warn("댓글 저장 실패. 댓글 작성에 필요한 정보를 찾을 수 없습니다 - {}", e.getLocalizedMessage());
}
}
public void updateArticleComment(ArticleCommentDto dto) {
try {
ArticleComment articleComment = articleCommentRepository.getReferenceById(dto.id());
if (dto.content() != null) { articleComment.setContent(dto.content()); }
} catch (EntityNotFoundException e) {
log.warn("댓글 업데이트 실패. 댓글을 찾을 수 없습니다 - dto: {}", dto);
}
}
public void deleteArticleComment(Long articleCommentId) {
articleCommentRepository.deleteById(articleCommentId);
}
@Bean // Spring Security 검사 영역에서 제외, static resource, css, js 등에 적용 (권한 체크가 필요없는 영역)
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
import com.fastcampus.projectboard.dto.UserAccountDto;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.Set;
import java.util.stream.Collectors;
public record BoardPrincipal(
String username,
String password,
Collection<? extends GrantedAuthority> authorities,
String email,
String nickname,
String memo
) implements UserDetails {
// 인증과 권한 부여, canonical
public static BoardPrincipal of(String username, String password, String email, String nickname, String memo) {
Set<RoleType> roleTypes = Set.of(RoleType.USER);
return new BoardPrincipal(
username,
password,
roleTypes.stream()
.map(RoleType::getName)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toUnmodifiableSet())
,
email,
nickname,
memo
);
}
// userAccountDto로부터 인증과 권한 가져오기
public static BoardPrincipal from(UserAccountDto dto) {
return BoardPrincipal.of(
dto.userId(),
dto.userPassword(),
dto.email(),
dto.nickname(),
dto.memo()
);
}
// Pricipal -> dto로 변환
public UserAccountDto toDto() {
return UserAccountDto.of(
username,
password,
email,
nickname,
memo
);
}
@Override public String getPassword() {
return password;
}
@Override public String getUsername() {
return username;
}
// 권한 기능, 현재 해당 프로젝트에는 권한 구분이 따로 되어 있지 않음
@Override public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
// 아래 내용은 해당 프로젝트에서는 필요하지 않아 전부 true 처리
@Override public boolean isAccountNonExpired() {
return true;
}
@Override public boolean isAccountNonLocked() {
return true;
}
@Override public boolean isCredentialsNonExpired() {
return true;
}
@Override public boolean isEnabled() {
return true;
}
// 차후 확장을 위한 RoleType ENUM
public enum RoleType {
USER("ROLE_USER"); // ROLE_### 형식으로 작성
@Getter
private final String name;
RoleType(String name) {
this.name = name;
}
}
}
@Bean
public UserDetailsService userDetailsService(UserAccountRepository userAccountRepository) {
return username -> userAccountRepository
.findById(username)
.map(UserAccountDto::from)
.map(BoardPrincipal::from)
.orElseThrow(() -> new UsernameNotFoundException("유저를 찾을 수 없습니다 - username: " + username));
} // 실제 DB에 저장하게 됨
// password encoder
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean // Spring Security에 등록하여 권한 등을 체크
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // 아래에서의 권한 체크 필요 없는 영역 처리
.mvcMatchers(
HttpMethod.GET,
"/",
"/articles",
"/articles/search-hashtag"
).permitAll() // GET 메소드, 루트 페이지, 게시판, 해시태그 검색페이지에 permitAll 적용
)
.formLogin().and()
.logout()
.logoutSuccessUrl("/")
.and()
.build();
}
import com.fastcampus.projectboard.dto.security.BoardPrincipal;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.Optional;
@EnableJpaAuditing
@Configuration
public class JpaConfig {
@Bean
public AuditorAware<String> auditorAware() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext())
.map(SecurityContext::getAuthentication) // Authentication 정보 가져오기
.filter(Authentication::isAuthenticated) // 필터링 (Authenticated 되었는지)
.map(Authentication::getPrincipal) // 보편적 Principal 가져오기
.map(BoardPrincipal.class::cast) // Principal 중 BoardPrincipal 캐스팅
.map(BoardPrincipal::getUsername); // BoardPrincipal에서 username 가져오기
}
}
import com.fastcampus.projectboard.domain.UserAccount;
import com.fastcampus.projectboard.repository.UserAccountRepository;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.event.annotation.BeforeTestMethod;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
@Import(SecurityConfig.class)
public class TestSecurityConfig {
@MockBean private UserAccountRepository userAccountRepository;
// 인증과 관련된 테스트용 계정 생성 메소드
@BeforeTestMethod // 각 테스트 메소드 실행되기 전에!
public void securitySetUp() {
given(userAccountRepository.findById(anyString())).willReturn((Optional.of(
UserAccount.of(
"cocoballTest",
"pw",
"cocoball-test@mail.com",
"cocoball-test",
"test memo"
)
)));
}
}
// 해당 테스트 시에만 Auditing 시 강제 인증되게끔
@EnableJpaAuditing
@TestConfiguration
public static class TestJpaConfig {
@Bean
public AuditorAware<String> audtiorAware() {
return () -> Optional.of("hahaha");
}
}
import com.fastcampus.projectboard.domain.UserAccount;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.rest.webmvc.config.RepositoryRestConfigurer;
@Configuration
public class DataRestConfig {
@Bean
public RepositoryRestConfigurer repositoryRestConfigurer() {
return RepositoryRestConfigurer.withConfig((config, cors) ->
config.exposeIdsFor(UserAccount.class)
);
}
}