diff --git a/build.gradle b/build.gradle index 27ca41c..e825439 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'org.springframework.boot' version '2.4.4' id 'io.spring.dependency-management' version '1.0.11.RELEASE' id 'java' + id 'jacoco' id "io.freefair.lombok" version "5.3.3.3" id "org.ec4j.editorconfig" version "0.0.3" id "org.sonarqube" version "3.1.1" @@ -23,6 +24,8 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' } +apply from: 'test.gradle' + test { useJUnitPlatform() } diff --git a/lombok.config b/lombok.config index 6aa51d7..c049ae8 100644 --- a/lombok.config +++ b/lombok.config @@ -1,2 +1,4 @@ # This file is generated by the 'io.freefair.lombok' Gradle plugin config.stopBubbling = true +lombok.addLombokGeneratedAnnotation = true +lombok.extern.findbugs.addSuppressFBWarnings = true diff --git a/src/main/java/io/github/raeperd/realworld/application/UserLoginRequestDTO.java b/src/main/java/io/github/raeperd/realworld/application/UserLoginRequestDTO.java new file mode 100644 index 0000000..d955aed --- /dev/null +++ b/src/main/java/io/github/raeperd/realworld/application/UserLoginRequestDTO.java @@ -0,0 +1,16 @@ +package io.github.raeperd.realworld.application; + +import lombok.Getter; + +@Getter +public class UserLoginRequestDTO { + + private final String email; + private final String password; + + public UserLoginRequestDTO(String email, String password) { + this.email = email; + this.password = password; + } + +} diff --git a/src/main/java/io/github/raeperd/realworld/application/UserPostRequestDTO.java b/src/main/java/io/github/raeperd/realworld/application/UserPostRequestDTO.java new file mode 100644 index 0000000..bda81cb --- /dev/null +++ b/src/main/java/io/github/raeperd/realworld/application/UserPostRequestDTO.java @@ -0,0 +1,23 @@ +package io.github.raeperd.realworld.application; + +import io.github.raeperd.realworld.domain.User; +import lombok.Getter; + +@Getter +public class UserPostRequestDTO { + + private final String username; + private final String email; + private final String password; + + public UserPostRequestDTO(String username, String email, String password) { + this.username = username; + this.email = email; + this.password = password; + } + + public User toUser() { + return User.createNewUser(username, email, password); + } + +} diff --git a/src/main/java/io/github/raeperd/realworld/application/UserResponseDTO.java b/src/main/java/io/github/raeperd/realworld/application/UserResponseDTO.java new file mode 100644 index 0000000..cdd9a53 --- /dev/null +++ b/src/main/java/io/github/raeperd/realworld/application/UserResponseDTO.java @@ -0,0 +1,33 @@ +package io.github.raeperd.realworld.application; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.github.raeperd.realworld.domain.User; +import lombok.Getter; + +@JsonTypeName("user") +@JsonTypeInfo(include = As.WRAPPER_OBJECT, use = Id.NAME) +@Getter +public class UserResponseDTO { + + private final String email; + private final String token; + private final String username; + private final String bio; + private final String image; + + public static UserResponseDTO fromUser(User user) { + return new UserResponseDTO(user); + } + + private UserResponseDTO(User user) { + this.email = user.getEmail(); + this.username = user.getUsername(); + this.bio = user.getBio(); + this.image = user.getImage(); + // TODO: Generate token from user somewhere else + this.token = ""; + } +} diff --git a/src/main/java/io/github/raeperd/realworld/application/UserRestController.java b/src/main/java/io/github/raeperd/realworld/application/UserRestController.java new file mode 100644 index 0000000..870a9b2 --- /dev/null +++ b/src/main/java/io/github/raeperd/realworld/application/UserRestController.java @@ -0,0 +1,33 @@ +package io.github.raeperd.realworld.application; + +import io.github.raeperd.realworld.domain.UserService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import static org.springframework.http.HttpStatus.CREATED; +import static org.springframework.http.ResponseEntity.of; + +@RequestMapping("/users") +@RestController +public class UserRestController { + + private final UserService userService; + + public UserRestController(UserService userService) { + this.userService = userService; + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody UserLoginRequestDTO loginRequest) { + return of(userService.login(loginRequest.getEmail(), loginRequest.getPassword()) + .map(UserResponseDTO::fromUser)); + } + + @ResponseStatus(CREATED) + @PostMapping + public UserResponseDTO postUser(@RequestBody UserPostRequestDTO postRequest) { + return UserResponseDTO.fromUser( + userService.createUser(postRequest.toUser())); + } + +} diff --git a/src/main/java/io/github/raeperd/realworld/domain/User.java b/src/main/java/io/github/raeperd/realworld/domain/User.java new file mode 100644 index 0000000..fc82006 --- /dev/null +++ b/src/main/java/io/github/raeperd/realworld/domain/User.java @@ -0,0 +1,46 @@ +package io.github.raeperd.realworld.domain; + +import javax.persistence.Entity; +import javax.persistence.Id; + +@Entity +public class User { + + @Id + private long id; + + private String email; + private String password; + private String username; + private String bio; + private String image; + + public static User createNewUser(String username, String email, String password) { + return new User(username, email, password); + } + + private User(String username, String email, String password) { + this.username = username; + this.email = email; + this.password = password; + } + + protected User() { + } + + public String getEmail() { + return email; + } + + public String getUsername() { + return username; + } + + public String getBio() { + return bio; + } + + public String getImage() { + return image; + } +} diff --git a/src/main/java/io/github/raeperd/realworld/domain/UserRepository.java b/src/main/java/io/github/raeperd/realworld/domain/UserRepository.java new file mode 100644 index 0000000..055d753 --- /dev/null +++ b/src/main/java/io/github/raeperd/realworld/domain/UserRepository.java @@ -0,0 +1,15 @@ +package io.github.raeperd.realworld.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +interface UserRepository extends JpaRepository { + + Optional findFirstByEmailAndPassword(String email, String password); + + boolean existsByEmail(String email); + +} diff --git a/src/main/java/io/github/raeperd/realworld/domain/UserService.java b/src/main/java/io/github/raeperd/realworld/domain/UserService.java new file mode 100644 index 0000000..9f20954 --- /dev/null +++ b/src/main/java/io/github/raeperd/realworld/domain/UserService.java @@ -0,0 +1,27 @@ +package io.github.raeperd.realworld.domain; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +public class UserService { + + private final UserRepository userRepository; + + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Transactional(readOnly = true) + public Optional login(String email, String password) { + return userRepository.findFirstByEmailAndPassword(email, password); + } + + @Transactional + public User createUser(User user) { + return userRepository.save(user); + } + +} diff --git a/src/test/java/io/github/raeperd/realworld/application/UserRestControllerTest.java b/src/test/java/io/github/raeperd/realworld/application/UserRestControllerTest.java new file mode 100644 index 0000000..038e91f --- /dev/null +++ b/src/test/java/io/github/raeperd/realworld/application/UserRestControllerTest.java @@ -0,0 +1,96 @@ +package io.github.raeperd.realworld.application; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.raeperd.realworld.domain.User; +import io.github.raeperd.realworld.domain.UserService; +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.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +import static io.github.raeperd.realworld.domain.User.createNewUser; +import static java.util.Optional.of; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +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; + +@AutoConfigureMockMvc +@WebMvcTest(UserRestController.class) +class UserRestControllerTest { + + @MockBean + private UserService userService; + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + + @Test + void when_post_login_expect_userService_login_called() throws Exception { + final var loginDTO = new UserLoginRequestDTO("email", "password"); + given(userService.login(loginDTO.getEmail(), loginDTO.getPassword())) + .willReturn(of(createNewUser("", loginDTO.getEmail(), loginDTO.getPassword()))); + + mockMvc.perform(post("/users/login") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginDTO))); + + then(userService).should(times(1)) + .login(loginDTO.getEmail(), loginDTO.getPassword()); + } + + @Test + void when_post_login_expect_valid_response() throws Exception { + when(userService.login(anyString(), anyString())) + .thenReturn(of(createNewUser("username", "email", "password"))); + + mockMvc.perform(post("/users/login") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new UserLoginRequestDTO("email", "password")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.user").exists()) + .andExpect(jsonPath("$.user.email").exists()) + .andExpect(jsonPath("$.user.bio").hasJsonPath()) + .andExpect(jsonPath("$.user.image").hasJsonPath()) + .andExpect(jsonPath("$.user.token").hasJsonPath()); + } + + @Test + void when_post_users_expect_userService_createUser_called() throws Exception { + final var postRequestDTO = new UserPostRequestDTO("username", "email", "password"); + given(userService.createUser(any(User.class))).willReturn(postRequestDTO.toUser()); + + mockMvc.perform(post("/users") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(postRequestDTO))); + + then(userService).should(times(1)).createUser(any(User.class)); + } + + @Test + void when_post_users_expect_valid_response() throws Exception { + when(userService.createUser(any(User.class))) + .thenReturn(createNewUser("username", "email", "password")); + + mockMvc.perform(post("/users") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new UserLoginRequestDTO("email", "password")))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.user").exists()) + .andExpect(jsonPath("$.user.email").exists()) + .andExpect(jsonPath("$.user.bio").hasJsonPath()) + .andExpect(jsonPath("$.user.image").hasJsonPath()) + .andExpect(jsonPath("$.user.token").hasJsonPath()); + } + +} \ No newline at end of file diff --git a/src/test/java/io/github/raeperd/realworld/domain/UserServiceTest.java b/src/test/java/io/github/raeperd/realworld/domain/UserServiceTest.java new file mode 100644 index 0000000..0a09bdf --- /dev/null +++ b/src/test/java/io/github/raeperd/realworld/domain/UserServiceTest.java @@ -0,0 +1,43 @@ +package io.github.raeperd.realworld.domain; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + private UserService userService; + + @Mock + private UserRepository userRepository; + + @BeforeEach + void initializeService() { + this.userService = new UserService(userRepository); + } + + @Test + void when_login_expect_userRepository_findFirstByEmailAndPassword_called() { + final var email = "email"; + final var password = "password"; + + userService.login(email, password); + + then(userRepository).should(times(1)) + .findFirstByEmailAndPassword(email, password); + } + + @Test + void when_createUser_expect_userRepository_save_called(@Mock User user) { + userService.createUser(user); + + then(userRepository).should(times(1)).save(user); + } + +} diff --git a/src/test/java/io/github/raeperd/realworld/domain/UserTest.java b/src/test/java/io/github/raeperd/realworld/domain/UserTest.java new file mode 100644 index 0000000..253d5c9 --- /dev/null +++ b/src/test/java/io/github/raeperd/realworld/domain/UserTest.java @@ -0,0 +1,20 @@ +package io.github.raeperd.realworld.domain; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class UserTest { + + @Test + void expect_user_has_protected_no_args_constructor() { + class ChildUser extends User { + public ChildUser() { + super(); + } + } + final var childUser = new ChildUser(); + + assertThat(childUser).isInstanceOf(User.class); + } +} \ No newline at end of file diff --git a/test.gradle b/test.gradle new file mode 100644 index 0000000..0de0a95 --- /dev/null +++ b/test.gradle @@ -0,0 +1,74 @@ +test { + useJUnitPlatform() + finalizedBy 'jacocoTestReport' +} + +jacocoTestReport { + reports { + html.enabled true + xml.enabled true + } + finalizedBy 'jacocoTestCoverageVerification' +} + +jacocoTestCoverageVerification { + violationRules { + rule { + element = 'PACKAGE' + + limit { + counter = 'CLASS' + value = 'COVEREDRATIO' + maximum = 1.00 + } + + excludes = [ + 'io.github.raeperd.realworld', + ] + } + rule { + element = "CLASS" + + limit { + counter = 'METHOD' + value = 'COVEREDRATIO' + minimum = 1.00 + } + + excludes = [ + 'io.github.raeperd.realworld.RealWorldApplication' + ] + } + rule { + element = "METHOD" + + limit { + counter = 'INSTRUCTION' + value = 'COVEREDRATIO' + minimum = 1.00 + } + + excludes = [ + 'io.github.raeperd.realworld.RealWorldApplication.main*', + ] + } + rule { + element = "CLASS" + + limit { + counter = 'LINE' + value = 'TOTALCOUNT' + maximum = 100 + } + } + rule { + element = "METHOD" + + limit { + counter = 'LINE' + value = 'TOTALCOUNT' + maximum = 10 + } + } + } +} \ No newline at end of file