diff --git a/build.gradle b/build.gradle index 2002a34e4..40711ab88 100644 --- a/build.gradle +++ b/build.gradle @@ -25,9 +25,14 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'com.mysql:mysql-connector-j' - compileOnly 'org.projectlombok:lombok' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'com.mysql:mysql-connector-j' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.testcontainers:testcontainers:1.19.3' testImplementation 'org.testcontainers:junit-jupiter:1.19.3' diff --git a/src/main/java/in/koreatech/koin/auth/JwtProvider.java b/src/main/java/in/koreatech/koin/auth/JwtProvider.java new file mode 100644 index 000000000..9f679dfe7 --- /dev/null +++ b/src/main/java/in/koreatech/koin/auth/JwtProvider.java @@ -0,0 +1,44 @@ +package in.koreatech.koin.auth; + +import in.koreatech.koin.domain.user.User; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import javax.crypto.SecretKey; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class JwtProvider { + + @Value("${jwt.secret-key}") + private String secretKey; + + @Value("${jwt.access-token.expiration-time}") + private Long expirationTime; + + public String createToken(User user) { + if (user == null) { + throw new IllegalArgumentException("존재하지 않는 사용자입니다."); + } + + Key key = getSecretKey(); + return Jwts.builder() + .signWith(key) + .header() + .add("typ", "JWT") + .add("alg", key.getAlgorithm()) + .and() + .claim("id", user.getId()) + .expiration(new Date(Instant.now().toEpochMilli() + expirationTime)) + .compact(); + } + + private SecretKey getSecretKey() { + String encoded = Base64.getEncoder().encodeToString(secretKey.getBytes()); + return Keys.hmacShaKeyFor(encoded.getBytes()); + } +} diff --git a/src/main/java/in/koreatech/koin/controller/GlobalExceptionHandler.java b/src/main/java/in/koreatech/koin/controller/GlobalExceptionHandler.java new file mode 100644 index 000000000..9d3288582 --- /dev/null +++ b/src/main/java/in/koreatech/koin/controller/GlobalExceptionHandler.java @@ -0,0 +1,25 @@ +package in.koreatech.koin.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + log.warn(e.getMessage()); + return ResponseEntity.badRequest().body(e.getMessage()); + } + + @ExceptionHandler + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) { + log.warn(e.getMessage()); + return ResponseEntity.badRequest().body(e.getMessage()); + } +} diff --git a/src/main/java/in/koreatech/koin/controller/UserController.java b/src/main/java/in/koreatech/koin/controller/UserController.java new file mode 100644 index 000000000..4fc251d22 --- /dev/null +++ b/src/main/java/in/koreatech/koin/controller/UserController.java @@ -0,0 +1,26 @@ +package in.koreatech.koin.controller; + +import in.koreatech.koin.dto.UserLoginRequest; +import in.koreatech.koin.dto.UserLoginResponse; +import in.koreatech.koin.service.UserService; +import jakarta.validation.Valid; +import java.net.URI; +import lombok.RequiredArgsConstructor; +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.RestController; + +@RestController +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @PostMapping("/user/login") + public ResponseEntity login(@RequestBody @Valid UserLoginRequest request) { + UserLoginResponse response = userService.login(request); + return ResponseEntity.created(URI.create("/")) + .body(response); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/Member.java b/src/main/java/in/koreatech/koin/domain/Member.java index ee9c76d9b..542fb0cad 100644 --- a/src/main/java/in/koreatech/koin/domain/Member.java +++ b/src/main/java/in/koreatech/koin/domain/Member.java @@ -22,7 +22,7 @@ public class Member extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Integer id; + private Long id; @Size(max = 50) @NotNull @@ -55,8 +55,8 @@ public class Member extends BaseEntity { private Boolean isDeleted = false; @Builder - public Member(String name, String studentNumber, Long trackId, String position, String email, String imageUrl, - Boolean isDeleted) { + private Member(String name, String studentNumber, Long trackId, String position, String email, String imageUrl, + Boolean isDeleted) { this.name = name; this.studentNumber = studentNumber; this.trackId = trackId; diff --git a/src/main/java/in/koreatech/koin/domain/user/Student.java b/src/main/java/in/koreatech/koin/domain/user/Student.java new file mode 100644 index 000000000..a64029c93 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/Student.java @@ -0,0 +1,38 @@ +package in.koreatech.koin.domain.user; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +@Getter +@Entity +@Table(name = "students") +public class Student { + + @Id + private Long userId; + + @Size(max = 255) + @Column(name = "anonymous_nickname") + private String anonymousNickname = "익명_" + System.currentTimeMillis(); + + @Size(max = 20) + @Column(name = "student_number", length = 20) + private String studentNumber; + + @Size(max = 50) + @Column(name = "major", length = 50) + private String department; + + @Column(name = "identity") + @Enumerated(EnumType.STRING) + private UserIdentity userIdentity; + + @Column(name = "is_graduated") + private Boolean isGraduated; +} diff --git a/src/main/java/in/koreatech/koin/domain/user/User.java b/src/main/java/in/koreatech/koin/domain/user/User.java new file mode 100644 index 000000000..d7ce56cdf --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/User.java @@ -0,0 +1,123 @@ +package in.koreatech.koin.domain.user; + +import in.koreatech.koin.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "users") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + @Lob + @Column(name = "password", nullable = false) + private String password; + + @Size(max = 50) + @Column(name = "nickname", length = 50) + private String nickname; + + @Size(max = 50) + @Column(name = "name", length = 50) + private String name; + + @Size(max = 20) + @Column(name = "phone_number", length = 20) + private String phoneNumber; + + @NotNull + @Enumerated(EnumType.STRING) + @Column(name = "user_type", nullable = false, length = 20) + private UserType userType; + + @Size(max = 100) + @NotNull + @Column(name = "email", nullable = false, length = 100) + private String email; + + @Column(name = "gender") + @Enumerated(value = EnumType.ORDINAL) + private UserGender gender; + + @NotNull + @Column(name = "is_authed", nullable = false) + private Boolean isAuthed = false; + + @Column(name = "last_logged_at") + private LocalDateTime lastLoggedAt; + + @Size(max = 255) + @Column(name = "profile_image_url") + private String profileImageUrl; + + @NotNull + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted = false; + + @Size(max = 255) + @Column(name = "auth_token") + private String authToken; + + @Size(max = 255) + @Column(name = "auth_expired_at") + private String authExpiredAt; + + @Size(max = 255) + @Column(name = "reset_token") + private String resetToken; + + @Size(max = 255) + @Column(name = "reset_expired_at") + private String resetExpiredAt; + + @Builder + public User(String password, String nickname, String name, String phoneNumber, UserType userType, + String email, + UserGender gender, Boolean isAuthed, LocalDateTime lastLoggedAt, String profileImageUrl, + Boolean isDeleted, + String authToken, String authExpiredAt, String resetToken, String resetExpiredAt) { + this.password = password; + this.nickname = nickname; + this.name = name; + this.phoneNumber = phoneNumber; + this.userType = userType; + this.email = email; + this.gender = gender; + this.isAuthed = isAuthed; + this.lastLoggedAt = lastLoggedAt; + this.profileImageUrl = profileImageUrl; + this.isDeleted = isDeleted; + this.authToken = authToken; + this.authExpiredAt = authExpiredAt; + this.resetToken = resetToken; + this.resetExpiredAt = resetExpiredAt; + } + + public boolean isSamePassword(String password) { + return this.password.equals(password); + } + + public void updateLastLoggedTime(LocalDateTime lastLoggedTime) { + lastLoggedAt = lastLoggedTime; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/UserGender.java b/src/main/java/in/koreatech/koin/domain/user/UserGender.java new file mode 100644 index 000000000..52b8372f0 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/UserGender.java @@ -0,0 +1,7 @@ +package in.koreatech.koin.domain.user; + +public enum UserGender { + MAN, + WOMAN, + ; +} diff --git a/src/main/java/in/koreatech/koin/domain/user/UserIdentity.java b/src/main/java/in/koreatech/koin/domain/user/UserIdentity.java new file mode 100644 index 000000000..b11f349c9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/UserIdentity.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.domain.user; + +import lombok.Getter; + +/** + * 신원 (0: 학생, 1: 대학원생, 2: 교수, 3: 교직원, 4: 졸업생, 5: 점주) + */ +@Getter +public enum UserIdentity { + UNDERGRADUATE("학부생"), + GRADUATE("대학원생"), + PROFESSOR("교수"), + STAFF("교직원"), + ALUMNI("졸업생"), + OWNER("점주"), + ; + + private final String value; + + UserIdentity(String value) { + this.value = value; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/UserToken.java b/src/main/java/in/koreatech/koin/domain/user/UserToken.java new file mode 100644 index 000000000..efe335cae --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/UserToken.java @@ -0,0 +1,32 @@ +package in.koreatech.koin.domain.user; + +import java.util.concurrent.TimeUnit; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +@Getter +@RedisHash("refreshToken") +public class UserToken { + + private static final long REFRESH_TOKEN_EXPIRE_DAY = 14L; + + @Id + private Long id; + + private final String refreshToken; + + @TimeToLive(unit = TimeUnit.DAYS) + private final Long expiration; + + private UserToken(Long id, String refreshToken, Long expiration) { + this.id = id; + this.refreshToken = refreshToken; + this.expiration = expiration; + } + + public static UserToken create(Long userId, String refreshToken) { + return new UserToken(userId, refreshToken, REFRESH_TOKEN_EXPIRE_DAY); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/user/UserType.java b/src/main/java/in/koreatech/koin/domain/user/UserType.java new file mode 100644 index 000000000..9eb5abe62 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/user/UserType.java @@ -0,0 +1,18 @@ +package in.koreatech.koin.domain.user; + +import lombok.Getter; + +@Getter +public enum UserType { + STUDENT("STUDENT", "학생"), + USER("USER", "사용자"), + ; + + private final String value; + private final String description; + + UserType(String value, String description) { + this.value = value; + this.description = description; + } +} diff --git a/src/main/java/in/koreatech/koin/dto/TrackSingleResponse.java b/src/main/java/in/koreatech/koin/dto/TrackSingleResponse.java index 61cfa9abf..555886df0 100644 --- a/src/main/java/in/koreatech/koin/dto/TrackSingleResponse.java +++ b/src/main/java/in/koreatech/koin/dto/TrackSingleResponse.java @@ -73,7 +73,7 @@ public static InnerTechStackResponse from(TechStack techStack) { @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) public static class InnerMemberResponse { - private Integer id; + private Long id; private String name; private String studentNumber; private String position; diff --git a/src/main/java/in/koreatech/koin/dto/UserLoginRequest.java b/src/main/java/in/koreatech/koin/dto/UserLoginRequest.java new file mode 100644 index 000000000..25ea66541 --- /dev/null +++ b/src/main/java/in/koreatech/koin/dto/UserLoginRequest.java @@ -0,0 +1,16 @@ +package in.koreatech.koin.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; + +@Getter +public class UserLoginRequest { + + @Email(message = "이메일 형식을 지켜주세요.") + @NotBlank(message = "이메일을 입력해주세요.") + private String email; + + @NotBlank(message = "비밀번호를 입력해주세요.") + private String password; +} diff --git a/src/main/java/in/koreatech/koin/dto/UserLoginResponse.java b/src/main/java/in/koreatech/koin/dto/UserLoginResponse.java new file mode 100644 index 000000000..d8e74d5c8 --- /dev/null +++ b/src/main/java/in/koreatech/koin/dto/UserLoginResponse.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = lombok.AccessLevel.PRIVATE) +public class UserLoginResponse { + + @JsonProperty("token") + private String accessToken; + + @JsonProperty("refresh_token") + private String refreshToken; + + @JsonProperty("user_type") + private String userType; + + public static UserLoginResponse of(String token, String refreshToken, String userType) { + return new UserLoginResponse(token, refreshToken, userType); + } +} diff --git a/src/main/java/in/koreatech/koin/repository/UserRepository.java b/src/main/java/in/koreatech/koin/repository/UserRepository.java new file mode 100644 index 000000000..d38ad52af --- /dev/null +++ b/src/main/java/in/koreatech/koin/repository/UserRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.repository; + +import in.koreatech.koin.domain.user.User; +import java.util.Optional; +import org.springframework.data.repository.Repository; + +public interface UserRepository extends Repository { + + User save(User user); + + Optional findByEmail(String email); + + Optional findById(Long id); +} diff --git a/src/main/java/in/koreatech/koin/repository/UserTokenRepository.java b/src/main/java/in/koreatech/koin/repository/UserTokenRepository.java new file mode 100644 index 000000000..dff4366e9 --- /dev/null +++ b/src/main/java/in/koreatech/koin/repository/UserTokenRepository.java @@ -0,0 +1,12 @@ +package in.koreatech.koin.repository; + +import in.koreatech.koin.domain.user.UserToken; +import java.util.Optional; +import org.springframework.data.repository.Repository; + +public interface UserTokenRepository extends Repository { + + UserToken save(UserToken userToken); + + Optional findById(Long userId); +} diff --git a/src/main/java/in/koreatech/koin/service/UserService.java b/src/main/java/in/koreatech/koin/service/UserService.java new file mode 100644 index 000000000..1244793ad --- /dev/null +++ b/src/main/java/in/koreatech/koin/service/UserService.java @@ -0,0 +1,42 @@ +package in.koreatech.koin.service; + +import in.koreatech.koin.auth.JwtProvider; +import in.koreatech.koin.domain.user.User; +import in.koreatech.koin.domain.user.UserToken; +import in.koreatech.koin.dto.UserLoginRequest; +import in.koreatech.koin.dto.UserLoginResponse; +import in.koreatech.koin.repository.UserRepository; +import in.koreatech.koin.repository.UserTokenRepository; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final JwtProvider jwtProvider; + private final UserRepository userRepository; + private final UserTokenRepository userTokenRepository; + + @Transactional + public UserLoginResponse login(UserLoginRequest request) { + User user = userRepository.findByEmail(request.getEmail()) + .orElseThrow(() -> new IllegalArgumentException("잘못된 로그인 정보입니다.")); + + if (!user.isSamePassword(request.getPassword())) { + throw new IllegalArgumentException("잘못된 로그인 정보입니다."); + } + + String accessToken = jwtProvider.createToken(user); + String refreshToken = String.format("%s%d", UUID.randomUUID(), user.getId()); + UserToken savedToken = userTokenRepository.save(UserToken.create(user.getId(), refreshToken)); + user.updateLastLoggedTime(LocalDateTime.now()); + User saved = userRepository.save(user); + + return UserLoginResponse.of(accessToken, savedToken.getRefreshToken(), saved.getUserType().getValue()); + } +} diff --git a/src/main/resources/application-example.yml b/src/main/resources/application-example.yml deleted file mode 100644 index 8b1378917..000000000 --- a/src/main/resources/application-example.yml +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 7212038ca..02fe552fa 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -1,4 +1,21 @@ +jwt: + secret-key: EXAMPLE7A3E4F37B3DAD9CD8KEY6AA4B1AF7123!@# + access-token: + expiration-time: 600000 # (ms) = 10 minutes + spring: jpa: + properties: + hibernate: + show_sql: true + format_sql: true hibernate: ddl-auto: create + +logging: + level: + org: + hibernate: + type: + descriptor: + sql: trace diff --git a/src/test/java/in/koreatech/koin/AcceptanceTest.java b/src/test/java/in/koreatech/koin/AcceptanceTest.java index e57d21854..01acc0509 100644 --- a/src/test/java/in/koreatech/koin/AcceptanceTest.java +++ b/src/test/java/in/koreatech/koin/AcceptanceTest.java @@ -12,8 +12,10 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.MySQLContainer; import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.utility.DockerImageName; @SpringBootTest(webEnvironment = RANDOM_PORT) @Import(DBInitializer.class) @@ -30,22 +32,33 @@ public abstract class AcceptanceTest { private DBInitializer dataInitializer; @Container - protected static MySQLContainer container; + protected static MySQLContainer mySqlContainer; + + @Container + protected static GenericContainer redisContainer; @DynamicPropertySource private static void configureProperties(final DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", container::getJdbcUrl); + registry.add("spring.datasource.url", mySqlContainer::getJdbcUrl); registry.add("spring.datasource.username", () -> ROOT); registry.add("spring.datasource.password", () -> ROOT_PASSWORD); + registry.add("spring.data.redis.host", redisContainer::getHost); + registry.add("spring.data.redis.port", () -> redisContainer.getMappedPort(6379).toString()); } static { - container = (MySQLContainer) new MySQLContainer("mysql:5.7.34") + mySqlContainer = (MySQLContainer) new MySQLContainer("mysql:5.7.34") .withDatabaseName("test") .withUsername(ROOT) .withPassword(ROOT_PASSWORD) .withCommand("--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"); - container.start(); + + redisContainer = new GenericContainer<>( + DockerImageName.parse("redis:4.0.10")) + .withExposedPorts(6379); + + mySqlContainer.start(); + redisContainer.start(); } @BeforeEach diff --git a/src/test/java/in/koreatech/koin/KoinApplicationTest.java b/src/test/java/in/koreatech/koin/KoinApplicationTest.java index e49beb282..c4a59d21a 100644 --- a/src/test/java/in/koreatech/koin/KoinApplicationTest.java +++ b/src/test/java/in/koreatech/koin/KoinApplicationTest.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class KoinApplicationTest { @Test diff --git a/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java b/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java new file mode 100644 index 000000000..06c59555d --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/UserApiTest.java @@ -0,0 +1,77 @@ +package in.koreatech.koin.acceptance; + + +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import in.koreatech.koin.AcceptanceTest; +import in.koreatech.koin.domain.user.User; +import in.koreatech.koin.domain.user.UserToken; +import in.koreatech.koin.domain.user.UserType; +import in.koreatech.koin.repository.UserRepository; +import in.koreatech.koin.repository.UserTokenRepository; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; + +class UserApiTest extends AcceptanceTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserTokenRepository tokenRepository; + + @Test + @DisplayName("사용자가 로그인을 수행한다") + void userLoginSuccess() { + User user = User.builder() + .password("1234") + .nickname("주노") + .name("최준호") + .phoneNumber("010-1234-5678") + .userType(UserType.STUDENT) + .email("test@koreatech.ac.kr") + .isAuthed(true) + .isDeleted(false) + .build(); + + userRepository.save(user); + + ExtractableResponse response = RestAssured + .given() + .log().all() + .body(""" + { + "email": "test@koreatech.ac.kr", + "password": "1234" + } + """) + .contentType(ContentType.JSON) + .when() + .log().all() + .post("/user/login") + .then() + .log().all() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + + User userResult = userRepository.findById(user.getId()).get(); + UserToken token = tokenRepository.findById(userResult.getId()).get(); + + assertSoftly( + softly -> { + softly.assertThat(response.jsonPath().getString("token")).isNotNull(); + softly.assertThat(response.jsonPath().getString("refresh_token")).isNotNull(); + softly.assertThat(response.jsonPath().getString("refresh_token")) + .isEqualTo(token.getRefreshToken()); + softly.assertThat(response.jsonPath().getString("user_type")).isEqualTo("STUDENT"); + softly.assertThat(userResult.getLastLoggedAt()).isNotNull(); + } + ); + } +}