ADD create & update Comment HTTP API, Refactor code

- LAST_INSERT_ID() 를 사용하기 위해 H2DB 설정에 mode=MYSQL 추가.
- save()가 Long commentId 를 반환하도록 댓글 도메인 수정.
This commit is contained in:
JiwonDev
2021-10-10 09:41:18 +09:00
committed by Jiwon
parent 1537cb4694
commit 938f5600bd
14 changed files with 519 additions and 23 deletions

View File

@@ -0,0 +1,24 @@
package com.yam.app.comment.application;
import com.yam.app.comment.domain.CommentProcessor;
import com.yam.app.comment.presentation.CreateCommentCommand;
import com.yam.app.comment.presentation.UpdateCommentCommand;
import org.springframework.stereotype.Service;
@Service
public class CommentFacade {
private final CommentProcessor commentProcessor;
public CommentFacade(CommentProcessor commentProcessor) {
this.commentProcessor = commentProcessor;
}
public Long create(CreateCommentCommand request, Long memberId) {
return commentProcessor.create(request.getContent(), request.getArticleId(), memberId);
}
public void update(UpdateCommentCommand request, Long commentId, Long memberId) {
commentProcessor.update(request.getContent(), commentId, memberId);
}
}

View File

@@ -16,12 +16,12 @@ public final class CommentProcessor {
this.articleReader = articleReader;
}
public void create(String content, Long articleId, Long memberId) {
public Long create(String content, Long articleId, Long memberId) {
if (!articleReader.existsById(articleId)) {
throw new ArticleNotFoundException(articleId);
}
commentRepository.save(Comment.of(content, articleId, memberId));
return commentRepository.save(Comment.of(content, articleId, memberId));
}
public void update(String content, Long commentId, Long memberId) {

View File

@@ -2,7 +2,7 @@ package com.yam.app.comment.domain;
public interface CommentRepository {
void save(Comment entity);
Long save(Comment entity);
void update(Comment entity);

View File

@@ -19,12 +19,14 @@ public final class MybatisCommentRepository implements CommentReader, CommentRep
}
@Override
public void save(Comment entity) {
public Long save(Comment entity) {
int result = template.insert(SAVE_FQCN, entity);
if (result != 1) {
throw new RuntimeException(
String.format("There was a problem saving the object : %s", entity));
}
return entity.getId();
}
@Override

View File

@@ -0,0 +1,54 @@
package com.yam.app.comment.presentation;
import com.yam.app.comment.application.CommentFacade;
import com.yam.app.common.Authentication;
import com.yam.app.common.AuthenticationPrincipal;
import java.net.URI;
import javax.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping(
produces = MediaType.APPLICATION_JSON_VALUE,
consumes = MediaType.APPLICATION_JSON_VALUE
)
public final class CommentCommandApi {
private final CommentFacade commentFacade;
public CommentCommandApi(CommentFacade commentFacade) {
this.commentFacade = commentFacade;
}
@PostMapping("/api/comments/create")
public ResponseEntity<Void> createComment(
@RequestBody @Valid CreateCommentCommand request,
@AuthenticationPrincipal Authentication authentication) {
final Long commentId = commentFacade.create(request, authentication.getMemberId());
return ResponseEntity
.created(URI.create("/comments/" + commentId))
.build();
}
@PatchMapping("api/comments/{commentId}")
public ResponseEntity<Void> updateComment(
@PathVariable Long commentId,
@RequestBody @Valid UpdateCommentCommand request,
@AuthenticationPrincipal Authentication authentication) {
commentFacade.update(request, commentId, authentication.getMemberId());
return ResponseEntity.ok().build();
}
}

View File

@@ -0,0 +1,17 @@
package com.yam.app.comment.presentation;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
@Data
public final class CreateCommentCommand {
@NotNull
private Long articleId;
@Length(max = 120)
@NotBlank(message = "댓글 내용은 비어 있을 수 없습니다.")
private String content;
}

View File

@@ -0,0 +1,17 @@
package com.yam.app.comment.presentation;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
@Data
public final class UpdateCommentCommand {
@NotNull
private Long commentId;
@Length(max = 120)
@NotBlank(message = "댓글 내용은 비어 있을 수 없습니다.")
private String content;
}

View File

@@ -16,6 +16,7 @@ public class DatabaseConfiguration {
public DataSource dataSource() {
return new EmbeddedDatabaseBuilder()
.setType(EmbeddedDatabaseType.H2)
.setName("testdb;mode=MySQL")
.addScript("classpath:sql/ddl.sql")
.addScript("classpath:sql/dml.sql")
.build();

View File

@@ -1,25 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yam.app.comment.domain.CommentRepository">
<insert id="save" parameterType="com.yam.app.comment.domain.Comment">
INSERT INTO COMMENT(content, created_at, modified_at, status, article_id, member_id)
VALUES (#{content}, #{createAt}, #{modifiedAt}, #{status}, #{articleId}, #{memberId})
</insert>
<insert id="save" parameterType="com.yam.app.comment.domain.Comment">
INSERT INTO COMMENT(content, created_at, modified_at, status, article_id, member_id)
VALUES (#{content}, #{createdAt}, #{modifiedAt}, #{status}, #{articleId}, #{memberId})
<update id="update" parameterType="com.yam.app.comment.domain.Comment">
UPDATE COMMENT
SET content = #{content},
modified_at = #{modifiedAt}
WHERE id = #{id}
</update>
<selectKey keyProperty="id" resultType="long" order="AFTER">
SELECT LAST_INSERT_ID()
</selectKey>
</insert>
<update id="delete" parameterType="com.yam.app.comment.domain.Comment">
UPDATE COMMNET
SET status = #{status}
WHERE id = #{id}
</update>
<update id="update" parameterType="com.yam.app.comment.domain.Comment">
UPDATE COMMENT
SET content = #{content},
modified_at = #{modifiedAt}
WHERE id = #{id}
</update>
<update id="delete" parameterType="com.yam.app.comment.domain.Comment">
UPDATE COMMNET
SET status = #{status}
WHERE id = #{id}
</update>
</mapper>

View File

@@ -28,5 +28,14 @@ values (1, 1),
(1, 2),
(1, 3);
insert into member(nickname, image, status)
values ('comment', 'temp.png', 'ALIVE');
insert into account(email, email_check_token, email_check_token_generated_at, email_verified,
joined_at, last_modified_at, password, withdraw, role, member_id, status)
values ('comment@gmail.com', 'emailchecktoken1', now(), true, now(), now(),
'$2a$10$EqbMbYB0vcZnuA5CClqa9uiLDnjA6pCjxn208ZchzA2q3ofqnkhcq',
false, 'DEFAULT', 2, 'ALIVE');
INSERT INTO comment(content, created_at, modified_at, status, article_id, member_id)
VALUES ('sample content1', now(), now(), 'ALIVE', 1, 1);
VALUES ('sample content1', now(), now(), 'ALIVE', 1, 2);

View File

@@ -32,9 +32,11 @@ public final class FakeCommentRepository implements CommentRepository, CommentRe
}
@Override
public void save(Comment entity) {
entity.setId(idGenerator.incrementAndGet());
public Long save(Comment entity) {
final Long commentId = idGenerator.incrementAndGet();
entity.setId(commentId);
data.put(entity.getId(), entity);
return commentId;
}
@Override

View File

@@ -0,0 +1,10 @@
package com.yam.app.comment.presentation;
public final class CommentApiUri {
public static final String CREATE_COMMENT = "/api/comments/create";
public static final String UPDATE_COMMENT = "/api/comments/";
private CommentApiUri() {
}
}

View File

@@ -0,0 +1,264 @@
package com.yam.app.comment.presentation;
import static com.yam.app.comment.presentation.CommentApiUri.CREATE_COMMENT;
import static com.yam.app.comment.presentation.CommentApiUri.UPDATE_COMMENT;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yam.app.comment.application.CommentFacade;
import java.util.Random;
import org.javaunit.autoparams.AutoSource;
import org.javaunit.autoparams.customization.Customization;
import org.javaunit.autoparams.customization.SettablePropertyWriter;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
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.http.MediaType;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MockMvc;
@DisplayName("Comment Command HTTP API")
@WebMvcTest(CommentCommandApi.class)
final class CommentCommandApiTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private CommentFacade commentFacade;
private String generatedRandomString(int length) {
Random random = new Random();
return random.ints(48, 123)
.filter(i -> (i <= 57 || i >= 65) && (i <= 90 || i >= 97))
.limit(length)
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
.toString();
}
@Nested
@DisplayName("댓글 작성 HTTP API")
class CreateCommentApi {
@ParameterizedTest
@AutoSource
@Customization(SettablePropertyWriter.class)
@DisplayName("인증되지 않은 사용자가 요청을 보냈다면 401에러를 반환한다.")
void unauthenticated_user_request(CreateCommentCommand command) throws Exception {
//Act
final var actions = mockMvc.perform(post(CREATE_COMMENT)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(command))
);
//Assert
actions
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.data").doesNotExist())
.andExpect(jsonPath("$.message").value("Unauthorized request"));
}
@ParameterizedTest
@NullAndEmptySource
@DisplayName("인증된 사용자의 요청 바디의 content 가 비어있거나 null 이라면 400에러를 반환한다.")
void authenticated_user_request_body_content_is_empty_or_null(String args)
throws Exception {
//Arrange
var session = new MockHttpSession();
var command = new CreateCommentCommand();
command.setContent(args);
command.setArticleId(0L);
//Act
final var actions = mockMvc.perform(post(CREATE_COMMENT)
.session(session)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(command))
);
//Assert
actions
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.data").doesNotExist())
.andExpect(jsonPath("$.message").value("Invalid argument"));
}
@ParameterizedTest
@AutoSource
@DisplayName("인증된 사용자의 요청 바디의 articleId 가 null 이라면 400에러를 반환한다.")
void authenticated_user_request_body_articleId_is_null(String args) throws Exception {
//Arrange
var session = new MockHttpSession();
var command = new CreateCommentCommand();
command.setContent(args);
command.setArticleId(null);
//Act
final var actions = mockMvc.perform(post(CREATE_COMMENT)
.session(session)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(command))
);
//Assert
actions
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.data").doesNotExist())
.andExpect(jsonPath("$.message").value("Invalid argument"));
}
@ParameterizedTest
@AutoSource
@DisplayName("인증된 사용자의 요청 바디의 content 가 최대 Length 를 초과한 경우 400에러를 반환한다.")
void authentication_user_request_body_has_exceeded_content_length_limit(Long args)
throws Exception {
//Arrange
var maxContentLength = 120;
var session = new MockHttpSession();
var exceededLengthCommand = new CreateCommentCommand();
exceededLengthCommand.setContent(generatedRandomString(maxContentLength + 1));
exceededLengthCommand.setArticleId(args);
//Act
final var actions = mockMvc.perform(post(CREATE_COMMENT)
.session(session)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(exceededLengthCommand))
);
//Assert
actions
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.data").doesNotExist())
.andExpect(jsonPath("$.message").value("Invalid argument"));
}
}
@Nested
@DisplayName("댓글 수정 HTTP API")
class UpdateComment {
@ParameterizedTest
@AutoSource
@Customization(SettablePropertyWriter.class)
@DisplayName("인증되지 않은 사용자가 요청을 보냈다면 401에러를 반환한다.")
void unauthenticated_user_request(UpdateCommentCommand command) throws Exception {
//Act
final var actions = mockMvc.perform(patch(UPDATE_COMMENT + "1")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(command))
);
//Assert
actions
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.data").doesNotExist())
.andExpect(jsonPath("$.message").value("Unauthorized request"));
}
@ParameterizedTest
@NullAndEmptySource
@DisplayName("인증된 사용자의 요청 바디의 content 가 비어있거나 null 이라면 400에러를 반환한다.")
void authenticated_user_request_body_content_is_empty_or_null(String args)
throws Exception {
//Arrange
var session = new MockHttpSession();
var command = new UpdateCommentCommand();
command.setContent(args);
command.setCommentId(1L);
//Act
final var actions = mockMvc.perform(patch(UPDATE_COMMENT + "1")
.session(session)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(command))
);
//Assert
actions
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.data").doesNotExist())
.andExpect(jsonPath("$.message").value("Invalid argument"));
}
@ParameterizedTest
@AutoSource
@DisplayName("인증된 사용자의 요청 바디의 articleId 가 null 이라면 400에러를 반환한다.")
void authenticated_user_request_body_articleId_is_null(String args) throws Exception {
//Arrange
var session = new MockHttpSession();
var command = new UpdateCommentCommand();
command.setContent(args);
command.setCommentId(null);
//Act
final var actions = mockMvc.perform(patch(UPDATE_COMMENT + "1")
.session(session)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(command))
);
//Assert
actions
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.data").doesNotExist())
.andExpect(jsonPath("$.message").value("Invalid argument"));
}
@ParameterizedTest
@AutoSource
@DisplayName("인증된 사용자의 요청 바디의 content 가 최대 Length 를 초과한 경우 400에러를 반환한다.")
void authentication_user_request_body_has_exceeded_content_length_limit(Long args)
throws Exception {
//Arrange
var maxContentLength = 120;
var session = new MockHttpSession();
var exceededLengthCommand = new UpdateCommentCommand();
exceededLengthCommand.setContent(generatedRandomString(maxContentLength + 1));
exceededLengthCommand.setCommentId(args);
//Act
final var actions = mockMvc.perform(patch(UPDATE_COMMENT + "1")
.session(session)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(exceededLengthCommand))
);
//Assert
actions
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.data").doesNotExist())
.andExpect(jsonPath("$.message").value("Invalid argument"));
}
}
}

View File

@@ -0,0 +1,92 @@
package com.yam.app.integration;
import static com.yam.app.account.presentation.AccountApiUri.LOGIN;
import static com.yam.app.comment.presentation.CommentApiUri.CREATE_COMMENT;
import static com.yam.app.comment.presentation.CommentApiUri.UPDATE_COMMENT;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.yam.app.account.presentation.LoginAccountCommand;
import com.yam.app.comment.presentation.CreateCommentCommand;
import com.yam.app.comment.presentation.UpdateCommentCommand;
import org.javaunit.autoparams.AutoSource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.springframework.http.MediaType;
@DisplayName("댓글 모듈 통합 인수 테스트")
final class CommentIntegrationTests extends AbstractIntegrationTests {
@ParameterizedTest
@AutoSource
@DisplayName("로그인에 적절한 파라미터를 입력하여 성공하고 인증된 사용자가 댓글을 작성하는 시나리오 테스트")
void login_success_and_create_comment_scenarios(String args) throws Exception {
//Arrange
var loginCommand = new LoginAccountCommand();
loginCommand.setEmail("loginCheck@gmail.com");
loginCommand.setPassword("password!");
var createCommand = new CreateCommentCommand();
createCommand.setArticleId(1L);
createCommand.setContent(args);
//Act & Assert
mockMvc.perform(post(LOGIN)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(loginCommand))
)
.andExpect(status().isOk())
.andDo(
result -> {
final var actions = mockMvc.perform(post(CREATE_COMMENT)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createCommand))
);
actions
.andDo(print())
.andExpect(status().isCreated());
});
}
@ParameterizedTest
@AutoSource
@DisplayName("로그인에 적절한 파라미터를 입력하여 성공하고 인증된 사용자가 댓글을 수정하는 시나리오 테스트")
void login_success_and_update_comment_and_then_delete_it_scenarios(String args)
throws Exception {
//Arrange
var loginCommand = new LoginAccountCommand();
loginCommand.setEmail("comment@gmail.com");
loginCommand.setPassword("password!");
var updateCommentId = 1L;
var updateCommand = new UpdateCommentCommand();
updateCommand.setCommentId(updateCommentId);
updateCommand.setContent(args);
//Act & Assert
mockMvc.perform(post(LOGIN)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(loginCommand))
)
.andExpect(status().isOk())
.andDo(
result -> {
final var actions = mockMvc.perform(patch(UPDATE_COMMENT + updateCommentId)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(updateCommand))
);
actions
.andDo(print())
.andExpect(status().isOk());
});
}
}