게시판 만들기 - 게시글 작성기능, 댓글 구현

정영찬·2022년 9월 2일
0

프로젝트 실습

목록 보기
42/60

게시글 작성 기능

인증 관련 디펜던시 추가

spring-security 관련 디펜던시를 추가했다.

testImplementation 'org.springframework.security:spring-security-test'

게시글 삭제 생성 버튼 구현

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

form 데이터 전달 테스트용 인코더 추가

form data는 api와는 다른 형태로 직렬화하여 사용해야 한다.
객체를 규약에 맞게 form data 포캣의 문자열로 바꿔주는 유틸리티를 테스트 전용으로 만들고, 테스트도 함께 작성한다.
이게 없으면 post().param()형태로 값을 넣을 수도 있으나 표현이 실제 post request 전송과 꼭 밪지 않는 듯하고, 객체가 아니라 파라미터 단위로 넣어줘야 하므로 불편할수 있다.

src test 경로에 util이라는 패키지를 만들고 FormDataEncoder.java 와 그 테스트 파일을 생상했다.

FormDataEncoder.java

package com.jycproject.bulletinboard.util;


import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.test.context.TestComponent;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.util.UriComponentsBuilder;

import java.util.Map;
@TestComponent
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();
    }
}

FormDataEncoderTest.java

package com.jycproject.bulletinboard.util;

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 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
        System.out.println(result);
        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
    }
}

게시글 작성 페이지 디자인 구현

<!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="10" 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>

사용자가 새로운 게시글을 만들기 위한 페이지 디자인을 작성함.

게시판, 게시글 뷰의 기능에 대응하는 자바 코드 구현

서비스 테스트

  • 게시글 ID 조회시 댓글 달린 게시글 반환 여부 테스트

    	//when 의 내용을 수정했다. 사용하는 dto와 articleId값에 따른 게시글 dto를 가져오는 메서드를 새로 작성함
  • 댓글 달린 게시글이 없으면 예외를 던지는 테스트

    	이전 테스트에서 사용한 메서드로 내용을 수정함
  • 게시글 조회시 게시글 반환 여부 테스트

    	게시글만 반환하는 것이 효율적이므로 dto를 ArticleDto로 변경, articleId에 따른 게시글 반환 메서드 새로 작성
  • 게시글정보 입력시 게시글 생성 여부 테스트

    	//then 에서 userAccountRepository가 dto.userAccountDto().userId()에 따른 getReferenceById를 호출하는지를 테스트하는 내용도 추가함
  • 게시글, 혹은 없는 게시글의 수정정보 입력시 그에 맞는 결과가 나오는지 여부 테스트

    	//when에서 updateArticle 메소드의 파라미터를 추가함 (dto.id(),dto)
  • createArticle

    	리턴하는 article에 내용을 추가했다.(id)

서비스

  • ArticleWithCommentsDto 의 메소드 변경

    	getArticleWithComments로 변경. 또한 전에 작성된 getArticles를 ArticleDto를 사용해서 다시 작성함
  • updateArticle 파라미터 추가

    	Long ArticleId 추가함
  • saveArticle 에 userAccount를 추가함

    	userAccountRepository.getReferenceById메소드에 dto.userAccountDto().userId()를 파라미터로 넣어 리턴된 값을 userAccount로 선언함.

컨트롤러 테스트

새로운 게시글을 작성하는 페이지와 게시글 수정을 위한 페이지의 호출 테스트와, 생성, 수정, 삭제 기능을 테스트하기위한 코드를 작성한다.

@DisplayName("[view][GET] 새 게시글 작성 페이지")
    @Test
    public void givenNothing_whenRequesting_thenReturnsNewArticlePage() throws Exception {
        // Given
        String hashtag = "#java";

        // When & Then
        mvc.perform(get("/articles/form"))
                .andExpect(status().isOk()) // 정상 호출
                .andExpect(result -> content().contentType(MediaType.TEXT_HTML)) // 데이터 확인
                .andExpect(view().name("articles/form")) // 뷰의 존재여부 검사
                .andExpect(model().attribute("formStatus", FormStatus.CREATE));
    }

    @DisplayName("[view][POST] 새 게시글 등록 - 정상 호출")
    @Test
    public void givenNewArticleInfo_whenRequesting_thenSaveNewArticle() 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")
                        .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
    public void givenNothing_whenRequesting_thenReturnsUpdateArticlePage() 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((ResultMatcher) 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
    public 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
    public void givenArticleIdToDelete_whenRequesting_thenDeletesNewArticle() throws Exception {
        // Given
        long articleId = 1L;
        willDoNothing().given(articleService).deleteArticle(articleId);

        // When & Then
        mvc.perform(
                        post("/articles/" + articleId+ "/form")
                                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                                .with(csrf())
                )
                .andExpect(status().is3xxRedirection())
                .andExpect(view().name("redirect:/articles"))
                .andExpect(redirectedUrl("/articles"));
        then(articleService).should().deleteArticle(articleId);

    }

테스트 진행을 위해 추가한 코드

  • createArticleDto 메소드
private ArticleDto createArticleDto() {
        return ArticleDto.of(
                createUserAccountDto(),
                "title",
                "content",
                "#java"
        );
    }
  • 도메인 항목에 추가한 FormStatus.java. 저장과 수정 요청여부를 판단하기 위해 추가함
package com.jycproject.bulletinboard.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;
    }
}
  • ArticleRequest : 사용자가 작성한 게시글 관련 데이터만 모은 Dto
    dto/request/ArticleRequest
package com.jycproject.bulletinboard.dto.request;

import com.jycproject.bulletinboard.dto.ArticleDto;
import com.jycproject.bulletinboard.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
        );
    }
}
  • 사용자가 입력한 여러 개의 파라미터들을 연결하여 URL 형태로 만들어주기 위한 인코더와 그 테스트 이것을 사용하면 일일이 다 addAttribute를 할필요없이 파라미터를 전달 할수 있다
    test/java/com/jycproject/bulletinboard/util/FromDataEncoder
package com.jycproject.bulletinboard.util;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.test.context.TestComponent;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.util.UriComponentsBuilder;

import java.util.Map;

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

test/java/com/jycproject/bulletinboard/util/FromDataEncoderTest

package com.jycproject.bulletinboard.util;

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 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
        System.out.println(result);
        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
    }
}

컨트롤러

새 게시글 생성 페이지의 뷰와 연결, 이때 사용자 정보를 통해 로그인 된 사람만 crud작업을 할수 있도록 나중에 인증 정보를 넣어줘야한다.


    @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(
                "jyc","asdf1234","jyc@mail.com","Jyc","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(
                "jyc","asdf1234","jyc@mail.com","Jyc","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";
    }

뷰에 기능 부여

이제 게시글 생성 페이지에 타임리프 디커플드 로직 파일을 사용해서 기능을 부여한다.

index.html에 생성한 글쓰기 버튼을 클릭하면 게시글 생성페이지로 이동해주는 기능을 부여

<attr sel="#write-article" th:href="@{/articles/form}"></attr>

게시글 페이지에서 생성한 수정,삭제 버튼에도 기능을 부여한다.

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

마지막으로 게시글 작성 페이지에 기능을 부여한다.

<?xml version="1.0"?>
<thlogic xmlns:th="http://www.thymeleaf.org">
    <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>

게시글 작성 기능 구현 성공

게시글 댓글 작성 기능

컨트롤러 테스트

댓글의 작성과 삭제에 대해서만 테스트를 작서앻ㅆ다.

package com.jycproject.bulletinboard.controller;

import com.jycproject.bulletinboard.config.SecurityConfig;
import com.jycproject.bulletinboard.domain.ArticleComment;
import com.jycproject.bulletinboard.dto.ArticleCommentDto;
import com.jycproject.bulletinboard.dto.ArticleDto;
import com.jycproject.bulletinboard.dto.request.ArticleCommentRequest;
import com.jycproject.bulletinboard.dto.request.ArticleRequest;
import com.jycproject.bulletinboard.service.ArticleCommentService;
import com.jycproject.bulletinboard.util.FormDataEncoder;
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.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.then;
import static org.mockito.BDDMockito.willDoNothing;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@DisplayName("View 컨트롤러 - 댓글")
@Import({SecurityConfig.class, FormDataEncoder.class})
@WebMvcTest(ArticleCommentController.class)
class ArticleCommentControllerTest {

    private final MockMvc mvc;
    private final FormDataEncoder formDataEncoder;

    @MockBean
    private ArticleCommentService articleCommentService;

    public ArticleCommentControllerTest(
            @Autowired MockMvc mvc,
            @Autowired FormDataEncoder formDataEncoder
    ) {
        this.mvc = mvc;
        this.formDataEncoder = formDataEncoder;
    }

    @DisplayName("[view][POST] 댓글 등록 -  호출")
    @Test
    void givenNewArticleCommentInfo_whenRequesting_thenSaveNewArticleComment() 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][POST] 댓글 삭제 - 정상 호출")
    @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/new")
                                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                                .with(csrf())
                )
                .andExpect(status().is3xxRedirection())
                .andExpect(view().name("redirect:/articles" + articleId))
                .andExpect(redirectedUrl("/articles"));
        then(articleCommentService).should().deleteArticleComment(articleId);

    }
}

아직 컨트롤러 기능의 구현은 완료되지 않아서 통과하지 않는다. 컨트롤러 기능을 구현해야한다.

컨트롤러

@PostMapping("/new")
    public String postNewArticleComment(ArticleCommentRequest articleCommentRequest, Long articleId) {
            //TODO: 인증 정보를 넣어줘야한다
        articleCommentService.saveArticleComment(articleCommentRequest.toDto(UserAccountDto.of("jyc","pw","jyc@mail.com",null,null)));
        return "redirect:/articles/" + articleCommentRequest.articleId();
    }

    @PostMapping("/{commentId}/delete")
    public String deleteArticleComment(@PathVariable Long commentId, Long articleId){
        //TODO: 인증 정보를 넣어줘야한다
        articleCommentService.deleteArticleComment(commentId);



        return "redirect:/articles/" + articleId ;
    }

이전에 구현한 saveArticleComment 와 deleteArticleComment를 호출해서 리다이렉트 경로를 리턴한다.

서비스 테스트

이전에 구현한 서비스 테스트에서 내용을 더 추가한다. @Mock 어노테이션으로 UserAccountRepository를 불러오고, 댓글을 입력시 저장여부 테스트의 내용을 변경할 필요가 있다. userAccountRepository의 getReferenceById를 통해 createUserAccount로 만들어진 유저 정보를 가져오는지 여부를 테스트한다.

  @DisplayName("댓글 정보를 입력하면, 해당하는 댓글을 저장한다.")
    @Test
    void givenArticleCommentInfo_whenSavingArticleComment_thenSavesArticleComment(){
        // Given
        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);
        // 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));
    }

댓글 저장시 게시글이 없을 경우 userAccountRepository에 아무런 일을 하지 않았는지의 여부를 테스트 해볼 수 있다.

   @DisplayName("댓글 저장을 시도했는데 맞는 게시글이 없으면, 경고 로그를 찍고 아무것도 안한다.")
    @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();
    }

이상태로 검사를 돌리면 댓글 저장 테스트에 오류가 나타나는데, 서비스 saveArticleComment 메소드에 내용을 추가해보자.

게시글의 id와 댓글 작성자의 id값을 불러오고 이를 파라미터로 해서 save메소드를 호출하면 된다.

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

이때 이전에 ArticleCommentDto에 있는 toEntity 메소드의 파라미터에는 userAccount가 없었으므로 파라미터를 추가해준다.

  public ArticleComment toEntity(Article article, UserAccount userAccount){
        return ArticleComment.of(
                article,
                userAccount,
                content
        );
    }

서비스에서 받아온 article과 userAccount를 통해서 content 까지 불러오는 것이다

이제 테스트를 다시 불러오면

이제 컨트롤러 까지 가져온 내용을 뷰에 표현해주면 된다.

뷰 표현

댓글 관력 작업은 게시글 페이지에서 나타난다. 따라서 detail.html에 존재하는 댓글 컴포넌트에 기능을 부여한다.

댓글 옆에 삭제 버튼이 나타나게 버튼 컴포넌트를 추가했다.

<div class="row">
                            <div class="row col-md-10 col-lg-9">
                                <strong>Jyc</strong>
                                <small>
                                    <time>2022-01-01</time>
                                </small>
                                <p>
                                    Lorem ipsum dolor sit amet, consectetur adipiscing elit.<br>
                                    Lorem ipsum dolor sit amet
                                </p>
                            </div>
                            <div class="col-2 mb-3">
                                <button type="submit" class="btn btn-outline-danger" id="delete-comment"/>
                            </div>
                        </div>

기능 부여를 수월하게 하기 위해서 댓글 작성하는 곳과 댓글이 표시되는 컴포넌트를 수정했다.

  • comment-form id 값을 form 에 추가.
  • 댓글 작성하는 공간의 id 값을 comment-textbox로 변경
  • 댓글 작성공간과 댓글 표시 공간의 hidden input의 class 값을 article-id 로 지정해서 디커플드 로직을 통해 article.id값을 부여할 것이다.
  <section>
            <form class="row g-3" id="comment-form">
                <input type="hidden" class="article-id">
                <div class="col-md-9 col-lg-8">
                    <label for="comment-textbox" hidden>댓글</label>
                    <textarea class="form-control" id="comment-textbox" placeholder="댓글 쓰기.." rows="3"
                              required></textarea>
                </div>
                <div class="col-md-3 col-lg-4">
                    <label for="comment-submit" hidden>댓글 쓰기</label>
                    <button class="btn btn-primary" id="comment-submit" type="submit">쓰기</button>
                </div>

            </form>

            <ul id="article-comments" class="row col-md-10 col-lg-8 pt-3">
                <li>
                    <form class="comment-form">
                        <input type="hidden"  class="article-id">
                        <div class="row">
                            <div class="row col-md-10 col-lg-9">
                                <strong>Jyc</strong>
                                <small>
                                    <time>2022-01-01</time>
                                </small>
                                <p>
                                    Lorem ipsum dolor sit amet, consectetur adipiscing elit.<br>
                                    Lorem ipsum dolor sit amet
                                </p>
                            </div>
                            <div class="col-2 mb-3">
                                <button type="submit" class="btn btn-outline-danger" id="delete-comment">삭제</button>
                            </div>
                        </div>
                    </form>
                </li>
                <li>
                    <form>
                        <input hidden class="article-id">
                        <div class="row">
                            <div class="row col-md-10 col-lg-9">
                                <strong>Jyc</strong>
                                <small>
                                    <time>2022-01-01</time>
                                </small>
                                <p>
                                    Lorem ipsum dolor sit amet, consectetur adipiscing elit.<br>
                                    Lorem ipsum dolor sit amet
                                </p>
                            </div>
                        </div>
                    </form>
                </li>
            </ul>

        </section>

디커플드 로직 파일에 내용을 작성한다.

  <attr sel=".article-id" th:name="articleId" th:value="*{id}"/>
        <attr sel="#comment-form" th:action="@{/comments/new}" th:method="post">
            <attr sel="#comment-textbox" th:name="content"/>
        </attr>



        <attr sel="#article-comments" th:remove="all-but-first">
            <attr sel="li[0]" th:each="articleComment : ${articleComments}">
                <attr sel="form" th:action="'/comments/' + ${articleComment.id} + '/delete'" th:method="post">
                <attr sel="div/strong" th:text="${articleComment.nickname}"/>
                <attr sel="div/small/time" th:datetime="${articleComment.createdAt}"
                      th:text="${#temporals.format(articleComment.createdAt, 'yyyy-MM-dd HH:mm:ss')}"/>
                <attr sel="div/p" th:text="${articleComment.content}"/>
                </attr>
            </attr>
        </attr>


댓글 구현이 완료 되었다.

profile
개발자 꿈나무

0개의 댓글