diff --git a/src/main/java/com/yam/app/account/domain/Account.java b/src/main/java/com/yam/app/account/domain/Account.java index 27a8d44..9a3dd60 100644 --- a/src/main/java/com/yam/app/account/domain/Account.java +++ b/src/main/java/com/yam/app/account/domain/Account.java @@ -3,13 +3,14 @@ package com.yam.app.account.domain; import com.yam.app.common.EntityStatus; import java.time.LocalDateTime; import java.util.UUID; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.ToString; @Getter @ToString(exclude = "password") -@NoArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) public final class Account { private Long id; diff --git a/src/main/java/com/yam/app/article/application/ArticleFacade.java b/src/main/java/com/yam/app/article/application/ArticleFacade.java new file mode 100644 index 0000000..c078fd3 --- /dev/null +++ b/src/main/java/com/yam/app/article/application/ArticleFacade.java @@ -0,0 +1,23 @@ +package com.yam.app.article.application; + +import com.yam.app.article.domain.WriteArticleProcessor; +import com.yam.app.article.presentation.WriteArticleCommand; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ArticleFacade { + + private final WriteArticleProcessor writeArticleProcessor; + + public ArticleFacade(WriteArticleProcessor writeArticleProcessor) { + this.writeArticleProcessor = writeArticleProcessor; + } + + @Transactional + public void write(Long memberId, WriteArticleCommand command) { + writeArticleProcessor.write(memberId, + command.getTitle(), command.getContent(), + command.getImage(), command.getTags()); + } +} diff --git a/src/main/java/com/yam/app/article/domain/Article.java b/src/main/java/com/yam/app/article/domain/Article.java index 924cf8e..fbb6e78 100644 --- a/src/main/java/com/yam/app/article/domain/Article.java +++ b/src/main/java/com/yam/app/article/domain/Article.java @@ -6,6 +6,7 @@ import com.yam.app.common.EntityStatus; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import lombok.AccessLevel; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; @@ -13,7 +14,7 @@ import lombok.ToString; @Getter @ToString -@NoArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) @EqualsAndHashCode(of = "id") public final class Article { diff --git a/src/main/java/com/yam/app/article/domain/ArticleTag.java b/src/main/java/com/yam/app/article/domain/ArticleTag.java index c40cf77..60703bb 100644 --- a/src/main/java/com/yam/app/article/domain/ArticleTag.java +++ b/src/main/java/com/yam/app/article/domain/ArticleTag.java @@ -1,12 +1,13 @@ package com.yam.app.article.domain; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.ToString; @Getter @ToString -@NoArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) public final class ArticleTag { private Long id; diff --git a/src/main/java/com/yam/app/article/domain/Tag.java b/src/main/java/com/yam/app/article/domain/Tag.java index 8a10d75..cf78ac7 100644 --- a/src/main/java/com/yam/app/article/domain/Tag.java +++ b/src/main/java/com/yam/app/article/domain/Tag.java @@ -1,12 +1,13 @@ package com.yam.app.article.domain; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.ToString; @Getter @ToString -@NoArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) public final class Tag { private Long id; diff --git a/src/main/java/com/yam/app/article/presentation/ArticleCommandApi.java b/src/main/java/com/yam/app/article/presentation/ArticleCommandApi.java new file mode 100644 index 0000000..bec87ae --- /dev/null +++ b/src/main/java/com/yam/app/article/presentation/ArticleCommandApi.java @@ -0,0 +1,35 @@ +package com.yam.app.article.presentation; + +import com.yam.app.article.application.ArticleFacade; +import com.yam.app.common.Authentication; +import com.yam.app.common.AuthenticationPrincipal; +import javax.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +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 ArticleCommandApi { + + private final ArticleFacade articleFacade; + + public ArticleCommandApi(ArticleFacade articleFacade) { + this.articleFacade = articleFacade; + } + + @PostMapping("/api/articles/write") + public ResponseEntity writeArticle( + @RequestBody @Valid WriteArticleCommand command, + @AuthenticationPrincipal Authentication authentication) { + articleFacade.write(authentication.getMemberId(), command); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } +} diff --git a/src/main/java/com/yam/app/article/presentation/WriteArticleCommand.java b/src/main/java/com/yam/app/article/presentation/WriteArticleCommand.java new file mode 100644 index 0000000..c614461 --- /dev/null +++ b/src/main/java/com/yam/app/article/presentation/WriteArticleCommand.java @@ -0,0 +1,19 @@ +package com.yam.app.article.presentation; + +import java.util.List; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; +import lombok.Data; + +@Data +public final class WriteArticleCommand { + + @NotBlank + private String title; + @NotBlank + private String content; + @NotBlank + private String image; + @Size(max = 3) + private List tags; +} diff --git a/src/main/java/com/yam/app/member/domain/Member.java b/src/main/java/com/yam/app/member/domain/Member.java index bcfb242..27e448f 100644 --- a/src/main/java/com/yam/app/member/domain/Member.java +++ b/src/main/java/com/yam/app/member/domain/Member.java @@ -1,13 +1,14 @@ package com.yam.app.member.domain; import com.yam.app.common.EntityStatus; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.ToString; @Getter @ToString -@NoArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) public final class Member { private Long id; diff --git a/src/main/resources/mapper/xml/ArticleQueryMapper.xml b/src/main/resources/mapper/xml/ArticleQueryMapper.xml index d5863d8..aec9b0a 100644 --- a/src/main/resources/mapper/xml/ArticleQueryMapper.xml +++ b/src/main/resources/mapper/xml/ArticleQueryMapper.xml @@ -22,8 +22,8 @@ atg.id AS article_tag_id, atg.article_id AS article_tag_article_id, t.id AS tag_id, t.name AS tag_name FROM article a - LEFT OUTER JOIN article_tag atg ON atg.article_id = a.id - LEFT OUTER JOIN tag t ON t.id = atg.tag_id + INNER JOIN article_tag atg ON atg.article_id = a.id + INNER JOIN tag t ON t.id = atg.tag_id WHERE a.id = #{articleId}; diff --git a/src/test/java/com/yam/app/article/presentation/ArticleApiUri.java b/src/test/java/com/yam/app/article/presentation/ArticleApiUri.java new file mode 100644 index 0000000..e8ae578 --- /dev/null +++ b/src/test/java/com/yam/app/article/presentation/ArticleApiUri.java @@ -0,0 +1,8 @@ +package com.yam.app.article.presentation; + +public final class ArticleApiUri { + + public static final String WRITE_ARTICLE = "/api/articles/write"; + + private ArticleApiUri() {} +} diff --git a/src/test/java/com/yam/app/article/presentation/ArticleCommandApiTests.java b/src/test/java/com/yam/app/article/presentation/ArticleCommandApiTests.java new file mode 100644 index 0000000..2894a45 --- /dev/null +++ b/src/test/java/com/yam/app/article/presentation/ArticleCommandApiTests.java @@ -0,0 +1,122 @@ +package com.yam.app.article.presentation; + +import static com.yam.app.article.presentation.ArticleApiUri.WRITE_ARTICLE; +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.article.application.ArticleFacade; +import java.util.List; +import org.javaunit.autoparams.AutoSource; +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; +import org.springframework.test.web.servlet.ResultActions; + +@DisplayName("Article Command HTTP API") +@WebMvcTest(ArticleCommandApi.class) +final class ArticleCommandApiTests { + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockBean + private ArticleFacade articleFacade; + + private void assertThatInvalidArgumentError(ResultActions actions) throws Exception { + actions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.data").doesNotExist()) + .andExpect(jsonPath("$.message").value("Invalid argument")); + } + + @Nested + @DisplayName("게시글 작성 HTTP API") + class WriteArticleApi { + + @ParameterizedTest + @AutoSource + @DisplayName("인증되지 않은 사용자가 게시글을 작성하려고 시도하면 401 에러를 반환한다.") + void not_authentication_user_write_article_fail( + String args, List list) throws Exception { + var command = new WriteArticleCommand(); + command.setTitle(args); + command.setContent(args); + command.setImage(args); + command.setTags(list); + + //Act + final var actions = mockMvc.perform(post(WRITE_ARTICLE) + .accept(MediaType.APPLICATION_JSON) + .contentType(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("인증된 사용자의 요청 Body가 null이거나 empty인 경우 400 에러를 반환한다.") + void authentication_user_write_article_http_body_null_or_empty_fail( + String args) throws Exception { + // Arrange + var session = new MockHttpSession(); + var command = new WriteArticleCommand(); + command.setTitle(args); + command.setContent(args); + command.setImage(args); + + // Act + final var actions = mockMvc.perform(post(WRITE_ARTICLE) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(command)) + .session(session) + ); + + // Assert + assertThatInvalidArgumentError(actions); + } + + @ParameterizedTest + @AutoSource + @DisplayName("인증된 사용자의 요청 Body중 Tag의 개수가 3개 이상일 경우 400 에러를 반환한다.") + void authentication_user_write_article_http_body_tag_size_over_fail( + String args) throws Exception { + // Arrange + var session = new MockHttpSession(); + var command = new WriteArticleCommand(); + command.setTitle(args); + command.setContent(args); + command.setImage(args); + command.setTags(List.of("good", "job", "sam", "kim")); + + // Act + final var actions = mockMvc.perform(post(WRITE_ARTICLE) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(command)) + .session(session) + ); + + // Assert + assertThatInvalidArgumentError(actions); + } + } +} diff --git a/src/test/java/com/yam/app/integration/AbstractIntegrationTests.java b/src/test/java/com/yam/app/integration/AbstractIntegrationTests.java new file mode 100644 index 0000000..0ed699a --- /dev/null +++ b/src/test/java/com/yam/app/integration/AbstractIntegrationTests.java @@ -0,0 +1,35 @@ +package com.yam.app.integration; + +import static org.springframework.test.web.servlet.setup.SharedHttpSessionConfigurer.sharedHttpSession; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +abstract class AbstractIntegrationTests { + + @Autowired + protected MockMvc mockMvc; + @Autowired + protected ObjectMapper objectMapper; + + @Autowired + private WebApplicationContext wac; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders + .webAppContextSetup(wac) + .apply(sharedHttpSession()) + .build(); + } +} diff --git a/src/test/java/com/yam/app/account/integration/AccountIntegrationTests.java b/src/test/java/com/yam/app/integration/AccountIntegrationTests.java similarity index 85% rename from src/test/java/com/yam/app/account/integration/AccountIntegrationTests.java rename to src/test/java/com/yam/app/integration/AccountIntegrationTests.java index d4cc2e2..19dc509 100644 --- a/src/test/java/com/yam/app/account/integration/AccountIntegrationTests.java +++ b/src/test/java/com/yam/app/integration/AccountIntegrationTests.java @@ -1,4 +1,4 @@ -package com.yam.app.account.integration; +package com.yam.app.integration; import static com.yam.app.account.presentation.AccountApiUri.EMAIL_CONFIRM; import static com.yam.app.account.presentation.AccountApiUri.FIND_INFO; @@ -13,48 +13,19 @@ import static org.springframework.test.web.servlet.result.MockMvcResultHandlers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import static org.springframework.test.web.servlet.setup.SharedHttpSessionConfigurer.sharedHttpSession; -import com.fasterxml.jackson.databind.ObjectMapper; import com.yam.app.account.presentation.LoginAccountCommand; import com.yam.app.account.presentation.RegisterAccountCommand; import com.yam.app.account.presentation.UpdateAccountCommand; -import org.junit.jupiter.api.BeforeEach; 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.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.web.context.WebApplicationContext; -@SpringBootTest -@AutoConfigureMockMvc -@DisplayName("회원가입 통합 인수 테스트") -@ActiveProfiles("test") -final class AccountIntegrationTests { +@DisplayName("회원 모듈 통합 인수 테스트") +final class AccountIntegrationTests extends AbstractIntegrationTests { private static final String EMAIL_CONFIRM_SUCCESS_REDIRECT_URI = "http://localhost:3000/login"; - @Autowired - private MockMvc mockMvc; - @Autowired - private ObjectMapper objectMapper; - - @Autowired - private WebApplicationContext wac; - - @BeforeEach - void setUp() { - mockMvc = MockMvcBuilders - .webAppContextSetup(wac) - .apply(sharedHttpSession()) - .build(); - } - @Test @DisplayName("새로운 계정 등록에 적절한 파라미터가 입력되고, 계정이 성공적으로 등록된다.") void new_account_request_in_register_correctly() throws Exception { diff --git a/src/test/java/com/yam/app/integration/ArticleIntegrationTests.java b/src/test/java/com/yam/app/integration/ArticleIntegrationTests.java new file mode 100644 index 0000000..efd067c --- /dev/null +++ b/src/test/java/com/yam/app/integration/ArticleIntegrationTests.java @@ -0,0 +1,55 @@ +package com.yam.app.integration; + +import static com.yam.app.account.presentation.AccountApiUri.LOGIN; +import static com.yam.app.article.presentation.ArticleApiUri.WRITE_ARTICLE; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.yam.app.account.presentation.LoginAccountCommand; +import com.yam.app.article.presentation.WriteArticleCommand; +import java.util.List; +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 ArticleIntegrationTests extends AbstractIntegrationTests { + + @ParameterizedTest + @AutoSource + @DisplayName("로그인에 적절한 파라미터를 입력하여, 성공하고 " + + "인증된 사용자가 게시글 한 건을 작성하는 시나리오 테스트.") + void login_success_and_authentication_member_logout_scenarios(String title, String content, + String image, List tags) throws Exception { + //Arrange + var loginCommand = new LoginAccountCommand(); + loginCommand.setEmail("loginCheck@gmail.com"); + loginCommand.setPassword("password!"); + + var writeArticleCommand = new WriteArticleCommand(); + writeArticleCommand.setTitle(title); + writeArticleCommand.setContent(content); + writeArticleCommand.setImage(image); + writeArticleCommand.setTags(tags); + + // 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(WRITE_ARTICLE) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(writeArticleCommand)) + ); + + actions + .andExpect(status().isCreated()); + }); + } +}