diff --git a/build.gradle b/build.gradle index 4a1c7f3e7..1055c459d 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,9 @@ dependencies { implementation 'org.projectlombok:lombok:1.18.24' annotationProcessor 'org.projectlombok:lombok:1.18.24' + // validation + implementation 'org.springframework.boot:spring-boot-starter-validation:2.7.3' + runtimeOnly 'com.h2database:h2' } diff --git a/src/main/java/nextstep/auth/config/AuthConfig.java b/src/main/java/nextstep/auth/config/AuthConfig.java index 92400d875..74773a9c0 100644 --- a/src/main/java/nextstep/auth/config/AuthConfig.java +++ b/src/main/java/nextstep/auth/config/AuthConfig.java @@ -1,22 +1,22 @@ package nextstep.auth.config; +import lombok.RequiredArgsConstructor; import nextstep.auth.principal.AuthenticationPrincipalArgumentResolver; import nextstep.auth.token.JwtTokenProvider; +import nextstep.auth.validation.AuthorizationValidatorGroup; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.util.List; @Configuration +@RequiredArgsConstructor public class AuthConfig implements WebMvcConfigurer { - private JwtTokenProvider jwtTokenProvider; - - public AuthConfig(JwtTokenProvider jwtTokenProvider) { - this.jwtTokenProvider = jwtTokenProvider; - } + private final JwtTokenProvider jwtTokenProvider; + private final AuthorizationValidatorGroup authorizationValidatorGroup; @Override public void addArgumentResolvers(List argumentResolvers) { - argumentResolvers.add(new AuthenticationPrincipalArgumentResolver(jwtTokenProvider)); + argumentResolvers.add(new AuthenticationPrincipalArgumentResolver(jwtTokenProvider, authorizationValidatorGroup)); } } diff --git a/src/main/java/nextstep/auth/principal/AuthenticationPrincipalArgumentResolver.java b/src/main/java/nextstep/auth/principal/AuthenticationPrincipalArgumentResolver.java index 1c0e13be4..2b5cbaf50 100644 --- a/src/main/java/nextstep/auth/principal/AuthenticationPrincipalArgumentResolver.java +++ b/src/main/java/nextstep/auth/principal/AuthenticationPrincipalArgumentResolver.java @@ -1,19 +1,18 @@ package nextstep.auth.principal; -import nextstep.auth.exception.AuthenticationException; +import lombok.RequiredArgsConstructor; import nextstep.auth.token.JwtTokenProvider; +import nextstep.auth.validation.AuthorizationValidatorGroup; import org.springframework.core.MethodParameter; import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; +@RequiredArgsConstructor public class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver { - private JwtTokenProvider jwtTokenProvider; - - public AuthenticationPrincipalArgumentResolver(JwtTokenProvider jwtTokenProvider) { - this.jwtTokenProvider = jwtTokenProvider; - } + private final JwtTokenProvider jwtTokenProvider; + private final AuthorizationValidatorGroup authorizationValidatorGroup; @Override public boolean supportsParameter(MethodParameter parameter) { @@ -23,15 +22,9 @@ public boolean supportsParameter(MethodParameter parameter) { @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { String authorization = webRequest.getHeader("Authorization"); - if (!"bearer".equalsIgnoreCase(authorization.split(" ")[0])) { - throw new AuthenticationException(); - } - String token = authorization.split(" ")[1]; - - if (!jwtTokenProvider.validateToken(token)) { - throw new AuthenticationException(); - } + authorizationValidatorGroup.execute(authorization); + String token = authorization.split(" ")[1]; String username = jwtTokenProvider.getPrincipal(token); String role = jwtTokenProvider.getRoles(token); diff --git a/src/main/java/nextstep/auth/validation/AuthorizationBearerStringValidator.java b/src/main/java/nextstep/auth/validation/AuthorizationBearerStringValidator.java new file mode 100644 index 000000000..de654c90f --- /dev/null +++ b/src/main/java/nextstep/auth/validation/AuthorizationBearerStringValidator.java @@ -0,0 +1,17 @@ +package nextstep.auth.validation; + +import nextstep.auth.exception.AuthenticationException; +import org.springframework.stereotype.Component; + +@Component +public class AuthorizationBearerStringValidator extends AuthorizationValidator { + + @Override + protected void validate(String authorization) { + String authPrefix = authorization.split(" ")[0]; + + if (!"bearer".equalsIgnoreCase(authPrefix)) { + throw new AuthenticationException(); + } + } +} diff --git a/src/main/java/nextstep/auth/validation/AuthorizationNullValidator.java b/src/main/java/nextstep/auth/validation/AuthorizationNullValidator.java new file mode 100644 index 000000000..ec8e72654 --- /dev/null +++ b/src/main/java/nextstep/auth/validation/AuthorizationNullValidator.java @@ -0,0 +1,15 @@ +package nextstep.auth.validation; + +import nextstep.auth.exception.AuthenticationException; +import org.springframework.stereotype.Component; + +@Component +public class AuthorizationNullValidator extends AuthorizationValidator { + + @Override + protected void validate(String authorization) { + if (authorization == null) { + throw new AuthenticationException(); + } + } +} diff --git a/src/main/java/nextstep/auth/validation/AuthorizationTokenValidator.java b/src/main/java/nextstep/auth/validation/AuthorizationTokenValidator.java new file mode 100644 index 000000000..ab9b4adfb --- /dev/null +++ b/src/main/java/nextstep/auth/validation/AuthorizationTokenValidator.java @@ -0,0 +1,22 @@ +package nextstep.auth.validation; + +import lombok.RequiredArgsConstructor; +import nextstep.auth.exception.AuthenticationException; +import nextstep.auth.token.JwtTokenProvider; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class AuthorizationTokenValidator extends AuthorizationValidator { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void validate(String authorization) { + String token = authorization.split(" ")[1]; + + if (!jwtTokenProvider.validateToken(token)) { + throw new AuthenticationException(); + } + } +} diff --git a/src/main/java/nextstep/auth/validation/AuthorizationValidator.java b/src/main/java/nextstep/auth/validation/AuthorizationValidator.java new file mode 100644 index 000000000..aa60585b4 --- /dev/null +++ b/src/main/java/nextstep/auth/validation/AuthorizationValidator.java @@ -0,0 +1,20 @@ +package nextstep.auth.validation; + +public abstract class AuthorizationValidator { + + private AuthorizationValidator nextValidator = null; + + public AuthorizationValidator setNext(AuthorizationValidator validator) { + this.nextValidator = validator; + return validator; + } + + protected abstract void validate(String authorization); + + public void execute(String authorization) { + validate(authorization); + + if (nextValidator != null) + nextValidator.execute(authorization); + } +} diff --git a/src/main/java/nextstep/auth/validation/AuthorizationValidatorGroup.java b/src/main/java/nextstep/auth/validation/AuthorizationValidatorGroup.java new file mode 100644 index 000000000..1e1c0e70e --- /dev/null +++ b/src/main/java/nextstep/auth/validation/AuthorizationValidatorGroup.java @@ -0,0 +1,24 @@ +package nextstep.auth.validation; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AuthorizationValidatorGroup { + + private final AuthorizationNullValidator authorizationNullValidator; + private final AuthorizationBearerStringValidator authorizationBearerStringValidator; + private final AuthorizationTokenValidator authorizationTokenValidator; + + private void chaining() { + authorizationNullValidator + .setNext(authorizationBearerStringValidator) + .setNext(authorizationTokenValidator); + } + + public void execute(String authorization) { + chaining(); + authorizationNullValidator.execute(authorization); + } +} diff --git a/src/main/java/nextstep/member/domain/Member.java b/src/main/java/nextstep/member/domain/Member.java index 6264129ab..6259f62e2 100644 --- a/src/main/java/nextstep/member/domain/Member.java +++ b/src/main/java/nextstep/member/domain/Member.java @@ -31,6 +31,8 @@ public Member(String email, String password, Integer age, String role) { this.role = role; } + + public Long getId() { return id; } diff --git a/src/main/java/nextstep/subway/applicaion/FavoriteService.java b/src/main/java/nextstep/subway/applicaion/FavoriteService.java new file mode 100644 index 000000000..15e3d8c50 --- /dev/null +++ b/src/main/java/nextstep/subway/applicaion/FavoriteService.java @@ -0,0 +1,44 @@ +package nextstep.subway.applicaion; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import nextstep.subway.applicaion.dto.FavoriteRequest; +import nextstep.subway.domain.Favorite; +import nextstep.subway.domain.FavoriteRepository; +import nextstep.subway.domain.Station; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FavoriteService { + + private final FavoriteRepository favoriteRepository; + private final StationService stationService; + private final PathService pathService; + + + public List getFavorites(Long memberId) { + return favoriteRepository.findAllByMemberId(memberId); + } + + public Favorite getFavorite(Long id) { + return favoriteRepository.findById(id) + .orElseThrow(IllegalArgumentException::new); + } + + @Transactional + public Favorite createFavorite(Long memberId, FavoriteRequest request) { + pathService.findPath(request.getSource(), request.getTarget()); + + Station source = stationService.findById(request.getSource()); + Station target = stationService.findById(request.getTarget()); + return favoriteRepository.save(new Favorite(source, target, memberId)); + } + + public void deleteFavorite(Long id) { + favoriteRepository.deleteById(id); + } +} diff --git a/src/main/java/nextstep/subway/applicaion/StationService.java b/src/main/java/nextstep/subway/applicaion/StationService.java index c875db19a..99a1603a6 100644 --- a/src/main/java/nextstep/subway/applicaion/StationService.java +++ b/src/main/java/nextstep/subway/applicaion/StationService.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.stream.Collectors; +import org.springframework.transaction.support.TransactionSynchronizationManager; @Service @Transactional(readOnly = true) diff --git a/src/main/java/nextstep/subway/applicaion/dto/FavoriteRequest.java b/src/main/java/nextstep/subway/applicaion/dto/FavoriteRequest.java new file mode 100644 index 000000000..6a487a2db --- /dev/null +++ b/src/main/java/nextstep/subway/applicaion/dto/FavoriteRequest.java @@ -0,0 +1,17 @@ +package nextstep.subway.applicaion.dto; + +import javax.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class FavoriteRequest { + + @NotNull(message = "출발역은 반드시 입력해야합니다.") + private Long source; + + @NotNull(message = "도착역은 반드시 입력해야합니다.") + private Long target; + +} diff --git a/src/main/java/nextstep/subway/applicaion/dto/FavoriteResponse.java b/src/main/java/nextstep/subway/applicaion/dto/FavoriteResponse.java new file mode 100644 index 000000000..73fa0a5df --- /dev/null +++ b/src/main/java/nextstep/subway/applicaion/dto/FavoriteResponse.java @@ -0,0 +1,27 @@ +package nextstep.subway.applicaion.dto; + +import java.util.List; +import java.util.stream.Collectors; +import lombok.AllArgsConstructor; +import lombok.Getter; +import nextstep.subway.domain.Favorite; + +@Getter +@AllArgsConstructor +public class FavoriteResponse { + private Long id; + private StationResponse source; + private StationResponse target; + + public static FavoriteResponse of(Favorite favorite) { + return new FavoriteResponse(favorite.getId(), + StationResponse.of(favorite.getSource()), + StationResponse.of(favorite.getTarget())); + } + + public static List listOf(List favorites) { + return favorites.stream() + .map(FavoriteResponse::of) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/nextstep/subway/domain/Favorite.java b/src/main/java/nextstep/subway/domain/Favorite.java new file mode 100644 index 000000000..b2161cc97 --- /dev/null +++ b/src/main/java/nextstep/subway/domain/Favorite.java @@ -0,0 +1,40 @@ +package nextstep.subway.domain; + +import javax.persistence.CascadeType; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.OneToOne; +import lombok.Getter; + +@Entity +@Getter +public class Favorite { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(cascade = CascadeType.PERSIST) + @JoinColumn(name = "source_id") + private Station source; + + @OneToOne(cascade = CascadeType.PERSIST) + @JoinColumn(name = "target_id") + private Station target; + + @JoinColumn(name = "member_id") + private Long memberId; + + protected Favorite() { + + } + + public Favorite(Station source, Station target, Long memberId) { + this.source = source; + this.target = target; + this.memberId = memberId; + } +} diff --git a/src/main/java/nextstep/subway/domain/FavoriteRepository.java b/src/main/java/nextstep/subway/domain/FavoriteRepository.java new file mode 100644 index 000000000..1e334ed3e --- /dev/null +++ b/src/main/java/nextstep/subway/domain/FavoriteRepository.java @@ -0,0 +1,10 @@ +package nextstep.subway.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FavoriteRepository extends JpaRepository { + + List findAllByMemberId(Long id); + +} \ No newline at end of file diff --git a/src/main/java/nextstep/subway/domain/Line.java b/src/main/java/nextstep/subway/domain/Line.java index 85bd61c4c..d2f185c2d 100644 --- a/src/main/java/nextstep/subway/domain/Line.java +++ b/src/main/java/nextstep/subway/domain/Line.java @@ -14,7 +14,7 @@ public class Line { @Embedded private Sections sections = new Sections(); - public Line() { + protected Line() { } public Line(String name, String color) { diff --git a/src/main/java/nextstep/subway/domain/Section.java b/src/main/java/nextstep/subway/domain/Section.java index 450af16f0..409784187 100644 --- a/src/main/java/nextstep/subway/domain/Section.java +++ b/src/main/java/nextstep/subway/domain/Section.java @@ -24,7 +24,7 @@ public class Section extends DefaultWeightedEdge { private int distance; - public Section() { + protected Section() { } diff --git a/src/main/java/nextstep/subway/domain/Sections.java b/src/main/java/nextstep/subway/domain/Sections.java index 03b4e1933..4330da30e 100644 --- a/src/main/java/nextstep/subway/domain/Sections.java +++ b/src/main/java/nextstep/subway/domain/Sections.java @@ -14,7 +14,7 @@ public class Sections { @OneToMany(mappedBy = "line", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, orphanRemoval = true) private List
sections = new ArrayList<>(); - public Sections() { + protected Sections() { } public Sections(List
sections) { diff --git a/src/main/java/nextstep/subway/domain/Station.java b/src/main/java/nextstep/subway/domain/Station.java index 79e394179..2df15dbef 100644 --- a/src/main/java/nextstep/subway/domain/Station.java +++ b/src/main/java/nextstep/subway/domain/Station.java @@ -12,7 +12,7 @@ public class Station { private Long id; private String name; - public Station() { + protected Station() { } public Station(String name) { diff --git a/src/main/java/nextstep/subway/domain/SubwayMap.java b/src/main/java/nextstep/subway/domain/SubwayMap.java index 8058964c7..f1c832425 100644 --- a/src/main/java/nextstep/subway/domain/SubwayMap.java +++ b/src/main/java/nextstep/subway/domain/SubwayMap.java @@ -1,5 +1,6 @@ package nextstep.subway.domain; +import java.util.Optional; import org.jgrapht.GraphPath; import org.jgrapht.alg.shortestpath.DijkstraShortestPath; import org.jgrapht.graph.SimpleDirectedWeightedGraph; @@ -45,7 +46,8 @@ public Path findPath(Station source, Station target) { // 다익스트라 최단 경로 찾기 DijkstraShortestPath dijkstraShortestPath = new DijkstraShortestPath<>(graph); - GraphPath result = dijkstraShortestPath.getPath(source, target); + GraphPath result = Optional.ofNullable(dijkstraShortestPath.getPath(source, target)) + .orElseThrow(IllegalArgumentException::new); List
sections = result.getEdgeList().stream() .map(it -> it.getSection()) diff --git a/src/main/java/nextstep/subway/exception/BindingExceptionHandler.java b/src/main/java/nextstep/subway/exception/BindingExceptionHandler.java new file mode 100644 index 000000000..a91a13847 --- /dev/null +++ b/src/main/java/nextstep/subway/exception/BindingExceptionHandler.java @@ -0,0 +1,38 @@ +package nextstep.subway.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindException; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@ControllerAdvice +@Slf4j +public class BindingExceptionHandler { + + // @ModelAttribute 바인딩 파라미터 검증의 경우 BindException 핸들링 + @ExceptionHandler({BindException.class}) + public ResponseEntity errorValid(BindException exception) { + BindingResult bindingResult = exception.getBindingResult(); + + StringBuilder sb = new StringBuilder(); + sb.append("parameter: binding error message"); + for (FieldError fieldError : bindingResult.getFieldErrors()) { + sb.append(fieldError.getField()).append(": "); + sb.append(fieldError.getDefaultMessage()); + sb.append(System.lineSeparator()); + } + log.error(sb.toString()); + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse(HttpStatus.BAD_REQUEST, ServerErrorCode.BAD_PARAMETER.getMessage())); + } + + // @RequestBody 바인딩 파라미터 검증의 경우 MethodArgumentNotValidException 핸들링 + // ... + +} diff --git a/src/main/java/nextstep/subway/exception/ErrorResponse.java b/src/main/java/nextstep/subway/exception/ErrorResponse.java new file mode 100644 index 000000000..ba7a45590 --- /dev/null +++ b/src/main/java/nextstep/subway/exception/ErrorResponse.java @@ -0,0 +1,26 @@ +package nextstep.subway.exception; + +import org.springframework.http.HttpStatus; + +public class ErrorResponse { + + private HttpStatus httpStatus; + private String message; + + public ErrorResponse() { + + } + + public ErrorResponse(HttpStatus httpStatus, String message) { + this.httpStatus = httpStatus; + this.message = message; + } + + public HttpStatus getHttpStatus() { + return httpStatus; + } + + public String getMessage() { + return message; + } +} \ No newline at end of file diff --git a/src/main/java/nextstep/subway/exception/ServerErrorCode.java b/src/main/java/nextstep/subway/exception/ServerErrorCode.java new file mode 100644 index 000000000..015df048b --- /dev/null +++ b/src/main/java/nextstep/subway/exception/ServerErrorCode.java @@ -0,0 +1,17 @@ +package nextstep.subway.exception; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public enum ServerErrorCode { + + BAD_PARAMETER(HttpStatus.BAD_REQUEST, "잘못된 파라미터 요청입니다.") + ; + + private HttpStatus statusCode; + private String message; +} diff --git a/src/main/java/nextstep/subway/ui/FavoriteController.java b/src/main/java/nextstep/subway/ui/FavoriteController.java new file mode 100644 index 000000000..21eff52f6 --- /dev/null +++ b/src/main/java/nextstep/subway/ui/FavoriteController.java @@ -0,0 +1,62 @@ +package nextstep.subway.ui; + +import java.net.URI; +import java.util.List; +import lombok.RequiredArgsConstructor; +import nextstep.auth.principal.AuthenticationPrincipal; +import nextstep.auth.principal.UserPrincipal; +import nextstep.member.application.MemberService; +import nextstep.member.application.dto.MemberResponse; +import nextstep.subway.applicaion.FavoriteService; +import nextstep.subway.applicaion.dto.FavoriteRequest; +import nextstep.subway.applicaion.dto.FavoriteResponse; +import nextstep.subway.domain.Favorite; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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("/favorites") +@RequiredArgsConstructor +public class FavoriteController { + + private final FavoriteService favoriteService; + private final MemberService memberService; + + @GetMapping + public ResponseEntity> getFavorites(@AuthenticationPrincipal UserPrincipal userPrincipal) { + MemberResponse memberResponse = memberService.findMemberByEmail( + userPrincipal.getUsername()); + List favorites = favoriteService.getFavorites(memberResponse.getId()); + return ResponseEntity.ok(FavoriteResponse.listOf(favorites)); + } + + @GetMapping("/{id}") + public ResponseEntity getFavorite(@AuthenticationPrincipal UserPrincipal userPrincipal, + @PathVariable Long id) { + Favorite favorite = favoriteService.getFavorite(id); + return ResponseEntity.ok(FavoriteResponse.of(favorite)); + } + + @PostMapping + public ResponseEntity createFavorite(@AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestBody FavoriteRequest request) { + MemberResponse memberResponse = memberService.findMemberByEmail( + userPrincipal.getUsername()); + Favorite favorite = favoriteService.createFavorite(memberResponse.getId(), request); + return ResponseEntity.created(URI.create("/favorites/" + favorite.getId())).build(); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteFavorite(@AuthenticationPrincipal UserPrincipal userPrincipal, + @PathVariable Long id) { + favoriteService.deleteFavorite(id); + return ResponseEntity.noContent().build(); + } + +} diff --git a/src/test/java/nextstep/utils/GithubFakeController.java b/src/test/java/nextstep/member/external/GithubFakeController.java similarity index 97% rename from src/test/java/nextstep/utils/GithubFakeController.java rename to src/test/java/nextstep/member/external/GithubFakeController.java index c73229363..0838834b2 100644 --- a/src/test/java/nextstep/utils/GithubFakeController.java +++ b/src/test/java/nextstep/member/external/GithubFakeController.java @@ -1,4 +1,4 @@ -package nextstep.utils; +package nextstep.member.external; import lombok.RequiredArgsConstructor; import nextstep.auth.token.oauth2.github.GithubAccessTokenRequest; diff --git a/src/test/java/nextstep/utils/GithubFakeResponse.java b/src/test/java/nextstep/member/external/GithubFakeResponse.java similarity index 97% rename from src/test/java/nextstep/utils/GithubFakeResponse.java rename to src/test/java/nextstep/member/external/GithubFakeResponse.java index 9e9fcdd49..17e72fca9 100644 --- a/src/test/java/nextstep/utils/GithubFakeResponse.java +++ b/src/test/java/nextstep/member/external/GithubFakeResponse.java @@ -1,4 +1,4 @@ -package nextstep.utils; +package nextstep.member.external; import java.util.Arrays; import java.util.Map; diff --git a/src/test/java/nextstep/subway/acceptance/FavoriteAcceptanceTest.java b/src/test/java/nextstep/subway/acceptance/FavoriteAcceptanceTest.java new file mode 100644 index 000000000..9d665f8be --- /dev/null +++ b/src/test/java/nextstep/subway/acceptance/FavoriteAcceptanceTest.java @@ -0,0 +1,269 @@ +package nextstep.subway.acceptance; + +import static nextstep.member.acceptance.MemberSteps.회원_생성_요청; +import static nextstep.member.acceptance.TokenSteps.로그인_요청; +import static nextstep.subway.acceptance.FavoriteSteps.비회원_즐겨찾기_목록_조회_요청; +import static nextstep.subway.acceptance.FavoriteSteps.비회원_즐겨찾기_삭제_요청; +import static nextstep.subway.acceptance.FavoriteSteps.비회원_즐겨찾기_생성_요청; +import static nextstep.subway.acceptance.FavoriteSteps.즐겨찾기_목록_조회_요청; +import static nextstep.subway.acceptance.FavoriteSteps.즐겨찾기_삭제_요청; +import static nextstep.subway.acceptance.FavoriteSteps.즐겨찾기_생성_요청; +import static nextstep.subway.acceptance.FavoriteSteps.즐겨찾기_조회_요청; +import static nextstep.subway.acceptance.LineSteps.지하철_노선_생성_요청; +import static nextstep.subway.acceptance.StationSteps.지하철역_생성_요청; +import static org.assertj.core.api.Assertions.assertThat; + +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import nextstep.auth.token.TokenResponse; +import nextstep.subway.applicaion.dto.FavoriteRequest; +import nextstep.subway.applicaion.dto.FavoriteResponse; +import nextstep.subway.applicaion.dto.StationResponse; +import nextstep.utils.AcceptanceTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +@DisplayName("즐겨찾기 기능") +public class FavoriteAcceptanceTest extends AcceptanceTest { + + private Long 교대역; + private Long 양재역; + + private String 회원_이메일 = "emai@abc.com"; + private String 회원_패스워드 = "password"; + + @BeforeEach + public void setUp() { + 교대역 = 지하철역_생성_요청("교대역").as(StationResponse.class).getId(); + 양재역 = 지하철역_생성_요청("양재역").as(StationResponse.class).getId(); + 지하철_노선_생성_요청(노선_생성_요청_파라미터_생성(교대역, 양재역)); + + 회원_생성_요청(회원_이메일, 회원_패스워드, 20); + } + + + /** + * Given: 로그인 토큰을 발급받는다. + * When: 즐겨찾기를 생성한다. + * Then: 성공(201 Created) 응답을 받는다. + * And: Location URL로 즐겨찾기를 조회한다. + * And: 즐겨찾기 목록을 검증한다. + */ + @Test + @DisplayName("[성공] 회원이 즐겨찾기를 생성한다.") + void 회원이_즐겨찾기를_생성한다() { + // Given + String 로그인_토큰 = 로그인_요청(회원_이메일, 회원_패스워드).as(TokenResponse.class).getAccessToken(); + + // When + ExtractableResponse 즐겨찾기_생성_응답 = 즐겨찾기_생성_요청(로그인_토큰, new FavoriteRequest(교대역, 양재역)); + + // Then + HTTP_응답_상태코드_검증(즐겨찾기_생성_응답.statusCode(), HttpStatus.CREATED); + + ExtractableResponse 즐겨찾기_조회_응답 = 즐겨찾기_조회_요청(로그인_토큰, 즐겨찾기_생성_응답.header("Location")); + 즐겨찾기_조회_검증(즐겨찾기_조회_응답, new SourceTarget(교대역, 양재역)); + } + + /** + * When: 즐겨찾기를 생성한다. + * Then: 실패(401 Unathorized) 응답을 받는다. + */ + @Test + @DisplayName("[실패] 비회원이 즐겨찾기를 생성한다.") + void 비회원이_즐겨찾기를_생성한다() { + // Given + // When + ExtractableResponse 즐겨찾기_생성_응답 = 비회원_즐겨찾기_생성_요청(new FavoriteRequest(교대역, 양재역)); + + // Then + HTTP_응답_상태코드_검증(즐겨찾기_생성_응답.statusCode(), HttpStatus.UNAUTHORIZED); + } + + + /** + * Given: 로그인 토큰을 발급받는다. + * And: 즐겨찾기를 생성한다. + * When: 즐겨찾기를 조회한다. + * Then: 성공(200 OK) 응답을 받는다. + * And: 즐겨찾기 목록을 검증한다. + */ + @Test + @DisplayName("[성공] 회원이 즐겨찾기 목록을 조회한다.") + void 회원이_즐겨찾기를_조회한다() { + // Given + String 로그인_토큰 = 로그인_요청(회원_이메일, 회원_패스워드).as(TokenResponse.class).getAccessToken(); + 즐겨찾기_생성_요청(로그인_토큰, new FavoriteRequest(교대역, 양재역)); + + // When + ExtractableResponse 즐겨찾기_목록_조회_응답 = 즐겨찾기_목록_조회_요청(로그인_토큰); + + // Then + 즐겨찾기_목록_조회_검증(즐겨찾기_목록_조회_응답, List.of(new SourceTarget(교대역, 양재역))); + } + + + /** + * When: 즐겨찾기를 조회한다. + * Then: 실패(401 Unauthorized) 응답을 받는다. + */ + @Test + @DisplayName("[실패] 비회원이 즐겨찾기 목록을 조회한다.") + void 비회원이_즐겨찾기를_조회한다() { + // Given + // When + ExtractableResponse 즐겨찾기_목록_조회_응답 = 비회원_즐겨찾기_목록_조회_요청(); + + // Then + HTTP_응답_상태코드_검증(즐겨찾기_목록_조회_응답.statusCode(), HttpStatus.UNAUTHORIZED); + } + + + /** + * Given: 로그인 토큰을 발급받는다. + * And: 즐겨찾기를 생성한다. + * And: Location URL로 즐겨찾기를 삭제한다. + * Then: 성공(204 No Content) 응답을 받는다. + */ + @Test + @DisplayName("[성공] 회원이 즐겨찾기를 삭제한다.") + void 회원이_즐겨찾기를_삭제한다() { + // Given + String 로그인_토큰 = 로그인_요청(회원_이메일, 회원_패스워드).as(TokenResponse.class).getAccessToken(); + ExtractableResponse 즐겨찾기_생성_응답 = 즐겨찾기_생성_요청(로그인_토큰, new FavoriteRequest(교대역, 양재역)); + + // When + ExtractableResponse 즐겨찾기_삭제_응답 = 즐겨찾기_삭제_요청(로그인_토큰, 즐겨찾기_생성_응답.header("Location")); + + // Then + HTTP_응답_상태코드_검증(즐겨찾기_삭제_응답.statusCode(), HttpStatus.NO_CONTENT); + } + + + /** + * When: 즐겨찾기를 삭제한다. + * Then: 실패(401 Unauthorized) 응답을 받는다. + */ + @Test + @DisplayName("[실패] 비회원이 즐겨찾기를 삭제한다.") + void 비회원이_즐겨찾기를_삭제한다() { + // Given + // When + ExtractableResponse 즐겨찾기_삭제_응답 = 비회원_즐겨찾기_삭제_요청("/favorites/1"); + + // Then + HTTP_응답_상태코드_검증(즐겨찾기_삭제_응답.statusCode(), HttpStatus.UNAUTHORIZED); + } + + + /** + * Given: 로그인 토큰을 발급받는다. + * When: 즐겨찾기를 등록한다. + * Then: 실패(400 Bad Request) 응답을 받는다. + */ + @Test + @DisplayName("[실패] 없는 지하철역을 즐겨찾기로 등록한다.") + void 없는_지하철역을_즐겨찾기로_등록한다() { + // Given + String 로그인_토큰 = 로그인_요청(회원_이메일, 회원_패스워드).as(TokenResponse.class).getAccessToken(); + + // When + ExtractableResponse 즐겨찾기_생성_응답 = 즐겨찾기_생성_요청(로그인_토큰, new FavoriteRequest(교대역, 9999L)); + + // Then + HTTP_응답_상태코드_검증(즐겨찾기_생성_응답.statusCode(), HttpStatus.BAD_REQUEST); + } + + + /** + * Given: 지하철역을 등록한다. + * And: 로그인 토큰을 발급받는다. + * When: 즐겨찾기를 등록한다. + * Then: 실패(400 Bad Request) 응답을 받는다. + */ + @Test + @DisplayName("[실패] 조회가 불가능한 경로를 즐겨찾기로 등록한다.") + void 조회가_불가능한_경로를_즐겨찾기로_등록한다() { + // Given + Long 강남역 = 지하철역_생성_요청("강남역").as(StationResponse.class).getId(); + String 로그인_토큰 = 로그인_요청(회원_이메일, 회원_패스워드).as(TokenResponse.class).getAccessToken(); + + // When + ExtractableResponse 즐겨찾기_생성_응답 = 즐겨찾기_생성_요청(로그인_토큰, new FavoriteRequest(교대역, 강남역)); + + // Then + HTTP_응답_상태코드_검증(즐겨찾기_생성_응답.statusCode(), HttpStatus.BAD_REQUEST); + } + + + private Map 노선_생성_요청_파라미터_생성(Long upStationId, Long downStationId) { + Map lineCreateParams; + lineCreateParams = new HashMap<>(); + lineCreateParams.put("name", "신분당선"); + lineCreateParams.put("color", "bg-orange-600"); + lineCreateParams.put("upStationId", upStationId + ""); + lineCreateParams.put("downStationId", downStationId + ""); + lineCreateParams.put("distance", 10 + ""); + return lineCreateParams; + } + + private void 즐겨찾기_조회_검증(ExtractableResponse response, SourceTarget sourceTarget) { + HTTP_응답_상태코드_검증(response.statusCode(), HttpStatus.OK); + + FavoriteResponse favoriteResponse = response.as(FavoriteResponse.class); + + assertThat(favoriteResponse.getSource().getId()).isEqualTo(sourceTarget.source); + assertThat(favoriteResponse.getTarget().getId()).isEqualTo(sourceTarget.target); + } + + private void 즐겨찾기_목록_조회_검증(ExtractableResponse response, List sourceTargets) { + HTTP_응답_상태코드_검증(response.statusCode(), HttpStatus.OK); + + List respSourceTargets = response.jsonPath() + .getList("", FavoriteResponse.class).stream() + .map(favorite -> new SourceTarget(favorite.getSource().getId(), favorite.getTarget().getId())) + .collect(Collectors.toList()); + + assertThat(respSourceTargets).containsAll(sourceTargets); + } + + void HTTP_응답_상태코드_검증(int actualStatus, HttpStatus expectedStatus) { + assertThat(actualStatus).isEqualTo(expectedStatus.value()); + } + + static class SourceTarget { + Long source; + Long target; + + public SourceTarget(Long source, Long target) { + this.source = source; + this.target = target; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SourceTarget that = (SourceTarget) o; + return Objects.equals(source, that.source) && Objects.equals(target, + that.target); + } + + @Override + public int hashCode() { + return Objects.hash(source, target); + } + } + +} diff --git a/src/test/java/nextstep/subway/acceptance/FavoriteSteps.java b/src/test/java/nextstep/subway/acceptance/FavoriteSteps.java new file mode 100644 index 000000000..78c71be04 --- /dev/null +++ b/src/test/java/nextstep/subway/acceptance/FavoriteSteps.java @@ -0,0 +1,71 @@ +package nextstep.subway.acceptance; + +import io.restassured.RestAssured; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import nextstep.subway.applicaion.dto.FavoriteRequest; +import org.springframework.http.MediaType; + +public class FavoriteSteps { + public static ExtractableResponse 즐겨찾기_생성_요청(String accessToken, FavoriteRequest request) { + return RestAssured + .given().log().all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .auth().oauth2(accessToken) + .body(request) + .when().post("/favorites") + .then().log().all().extract(); + } + + public static ExtractableResponse 비회원_즐겨찾기_생성_요청(FavoriteRequest request) { + return RestAssured + .given().log().all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(request) + .when().post("/favorites") + .then().log().all().extract(); + } + + public static ExtractableResponse 즐겨찾기_조회_요청(String accessToken, String Location) { + return RestAssured + .given().log().all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .auth().oauth2(accessToken) + .when().get(Location) + .then().log().all().extract(); + } + + public static ExtractableResponse 즐겨찾기_목록_조회_요청(String accessToken) { + return RestAssured + .given().log().all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .auth().oauth2(accessToken) + .when().get("/favorites") + .then().log().all().extract(); + } + + public static ExtractableResponse 비회원_즐겨찾기_목록_조회_요청() { + return RestAssured + .given().log().all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .when().get("/favorites") + .then().log().all().extract(); + } + + public static ExtractableResponse 즐겨찾기_삭제_요청(String accessToken, String Location) { + return RestAssured + .given().log().all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .auth().oauth2(accessToken) + .when().delete(Location) + .then().log().all().extract(); + } + + public static ExtractableResponse 비회원_즐겨찾기_삭제_요청(String Location) { + return RestAssured + .given().log().all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .when().delete(Location) + .then().log().all().extract(); + } +} diff --git a/src/test/java/nextstep/subway/acceptance/docs/README.md b/src/test/java/nextstep/subway/acceptance/docs/README.md new file mode 100644 index 000000000..1c50e25eb --- /dev/null +++ b/src/test/java/nextstep/subway/acceptance/docs/README.md @@ -0,0 +1,55 @@ + +# 📚 즐겨찾기 기능 인수 조건 +``` +Feature: 즐겨찾기 생성 기능 + + Senario: 회원이 즐겨찾기를 생성한다. + Given: 로그인 토큰을 발급받는다. + When: 즐겨찾기를 생성한다. + Then: 성공(201 Created) 응답을 받는다. + And: Location URL로 즐겨찾기를 조회한다. + And: 즐겨찾기 목록을 검증한다. + + + Senario: 비회원이 즐겨찾기를 생성한다. + When: 즐겨찾기를 생성한다. + Then: 실패(401 Unauthorized) 응답을 받는다. + + + Senario: 회원이 즐겨찾기 목록을 조회한다. + Given: 로그인 토큰을 발급받는다. + And: 즐겨찾기를 생성한다. + When: 즐겨찾기를 조회한다. + Then: 성공(200 OK) 응답을 받는다. + And: 즐겨찾기 목록을 검증한다. + + + Senario: 비회원이 즐겨찾기 목록을 조회한다. + When: 즐겨찾기를 조회한다. + Then: 실패(401 Unauthorized) 응답을 받는다. + + + Senario: 회원이 즐겨찾기를 삭제한다. + Given: 로그인 토큰을 발급받는다. + And: 즐겨찾기를 생성한다. + And: Location URL로 즐겨찾기를 삭제한다. + Then: 성공(204 No Content) 응답을 받는다. + + + Senario: 비회원이 즐겨찾기를 삭제한다. + When: 즐겨찾기를 삭제한다. + Then: 실패(401 Unauthorized) 응답을 받는다. + + + Senario: 없는 지하철역을 즐겨찾기로 등록한다. + Given: 로그인 토큰을 발급받는다. + When: 즐겨찾기를 등록한다. + Then: 실패(400 Bad Request) 응답을 받는다. + + + Senario: 조회가 불가능한 경로를 즐겨찾기로 등록한다. + Given: 지하철역을 등록한다. + And: 로그인 토큰을 발급받는다. + When: 즐겨찾기를 등록한다. + Then: 실패(400 Bad Request) 응답을 받는다. +``` \ No newline at end of file diff --git a/src/main/resources/application-test.properties b/src/test/resources/application-test.properties similarity index 100% rename from src/main/resources/application-test.properties rename to src/test/resources/application-test.properties