Skip to content

Commit

Permalink
feat: 특정 여행 상세 수정 API 구현 #22 (#62)
Browse files Browse the repository at this point in the history
* build: Docker Compose Setting #27 (#40)

* chore: gitignore 파일 추가

* chore: mysql 디펜던시 추가

* chore: Profile 분리

* feat: Docker 파일 설정

* feat: 여행 상세 생성 API 구현 #18 (#43)

* build: RestAssured 의존성 추가

* test: 여행 상세 생성 인수 테스트 작성

* feat: 임시 MemberIdArgumentResolver 구현

* feat: Lombok 추가

* feat: Database 초기화 구현

* feat: 여행 상세 성공 서비스 구현

* fix: resolveArgument 반환 타입 오류 수정

* feat: 여행 상세 생성 성공 컨트롤러 구현

* feat: 여행 상세 생성 시 필수값 누락 검증 구현

* test: 글자 수 제한 검증 인수 테스트 추가

* refactor: 생성자에 builder 지정

* feat: 시작 날짜와 끝 날짜 도메인 검증 구현

* feat: 시작 날짜와 끝 날짜 예외 처리 테스트 및 구현

* style: 코드 컨벤션 적용

* refactor: parameter명 변경

* feat: transactional 적용

* style: paremeter 형식 통일

* style: parameter 형식 통일

* refactor: display name 오류 수정

* refactor: 불필요한 상수 제거

* refactor: paramterized test로 리팩터링

* style: 개행 제거

* refactor: 인자 변경

* refactor: 공통 예외 클래스명 변경

* feat: 범위 예외 핸들러 추가

* refactor: 서비스, 통합 테스트 보일러 플레이트 코드 제거

* refactor: builder 사용 시 필수 값 누락 제약 추가

* refactor: 도메인으로 변환하는 메서드를 dto에 추가

* build: CD yml 파일 구성 #28 (#53)

* feat: CI/CD 설정

* feat: CI/CD 검증용 트리거 설정

* fix: CI/CD workflow 수정

* fix: CI/CD workflow 재수정

* fix: CI/CD workflow 절대 경로 수정

* chore: DDL 생성 전략 변경

* chore: dev 환경 DDL 생성 전략 변경

* refactor: 검증용 트리거 제거

* fix: 도커 이미지 기반 컨테이너 생성으로 변경

* refactor: 중간 테이블 엔티티 수정 #56 (#57)

* refactor: 중간 테이블명 TravelMember로 변경

* refactor: 중간 테이블 OneToMany 필드 추가

* refactor: Member OneToMany 제거

* refactor: OneToMany List 초기화

* refactor: 연관관계 편의 메서드 사용

* chore: ddl 전략 임시 변경

* chore: ddl 전략 변경

* feat: 비어있는 요청 에러 핸들링 추가

* feat: 특정 여행 상세 수정 서비스 구현

* feat: 특정 여행 상세 수정 컨트롤러 구현

* feat: 비어있는 요청 에러 핸들링 추가

* feat: 특정 여행 상세 수정 서비스 구현

* feat: 특정 여행 상세 수정 컨트롤러 구현

* refactor: DirtiesContext 삭제

* refactor: Transactional 읽기 전용 옵션 구성

* feat: 방문 기록 날짜 검증 로직 추가

* refactor: 메서드 체이닝 적용

* refactor: 수정 작업 테스트 환경 동일하게 유지

---------

Co-authored-by: linirini <[email protected]>
Co-authored-by: devhoya97 <[email protected]>
  • Loading branch information
3 people authored Jul 24, 2024
1 parent f2eb19a commit 8d2f42d
Show file tree
Hide file tree
Showing 8 changed files with 241 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@

import java.time.format.DateTimeParseException;
import java.util.Optional;
import java.util.Set;

import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;

import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
Expand All @@ -33,9 +32,17 @@ public ResponseEntity<ExceptionResponse> handleValidationException(MethodArgumen

@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ExceptionResponse> handleConstraintViolationException(ConstraintViolationException e) {
Set<ConstraintViolation<?>> violations = e.getConstraintViolations();
ConstraintViolation<?> violation = violations.iterator().next();
String errorMessage = violation.getMessage();
String errorMessage = e.getConstraintViolations()
.iterator()
.next()
.getMessage();
return ResponseEntity.badRequest()
.body(new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), errorMessage));
}

@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ExceptionResponse> handleHttpMessageNotReadableException() {
String errorMessage = "요청 본문을 읽을 수 없습니다. 올바른 형식으로 데이터를 제공해주세요.";
return ResponseEntity.badRequest()
.body(new ExceptionResponse(HttpStatus.BAD_REQUEST.toString(), errorMessage));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
import java.net.URI;

import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;

import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
Expand All @@ -19,6 +23,7 @@

import lombok.RequiredArgsConstructor;

@Validated
@RestController
@RequestMapping("/travels")
@RequiredArgsConstructor
Expand All @@ -37,4 +42,13 @@ public ResponseEntity<TravelResponses> readAllTravels(
@RequestParam(value = "year", required = false) Integer year) {
return ResponseEntity.ok(travelService.readAllTravels(memberId, year));
}

@PutMapping("/{travelId}")
public ResponseEntity<Void> updateTravel(
@PathVariable @Min(value = 1L, message = "여행 식별자는 양수로 이루어져야 합니다.") Long travelId,
@Valid @RequestBody TravelRequest travelRequest,
@MemberId Long memberId) {
travelService.updateTravel(travelRequest, travelId);
return ResponseEntity.ok().build();
}
}
23 changes: 23 additions & 0 deletions backend/src/main/java/com/staccato/travel/domain/Travel.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import com.staccato.config.domain.BaseEntity;
import com.staccato.exception.StaccatoException;
import com.staccato.member.domain.Member;
import com.staccato.visit.domain.Visit;

import lombok.AccessLevel;
import lombok.Builder;
Expand Down Expand Up @@ -64,6 +65,28 @@ public void addTravelMember(TravelMember travelMember) {
travelMembers.add(travelMember);
}

public void update(Travel updatedTravel, List<Visit> visits) {
validateDuration(updatedTravel, visits);
this.thumbnailUrl = updatedTravel.getThumbnailUrl();
this.title = updatedTravel.getTitle();
this.description = updatedTravel.getDescription();
this.startAt = updatedTravel.getStartAt();
this.endAt = updatedTravel.getEndAt();
}

private void validateDuration(Travel updatedTravel, List<Visit> visits) {
visits.stream()
.filter(visit -> !updatedTravel.withinDuration(visit.getVisitedAt()))
.findAny()
.ifPresent(visit -> {
throw new StaccatoException("변경하려는 여행 기간이 이미 존재하는 방문 기록을 포함하지 않습니다. 여행 기간을 다시 설정해주세요.");
});
}

public boolean withinDuration(LocalDate date) {
return startAt.isBefore(date) && endAt.isAfter(date);
}

public List<Member> getMates() {
return travelMembers.stream()
.map(TravelMember::getMember)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.staccato.exception.StaccatoException;
import com.staccato.member.domain.Member;
import com.staccato.member.repository.MemberRepository;
import com.staccato.travel.domain.Travel;
Expand All @@ -14,6 +15,8 @@
import com.staccato.travel.repository.TravelRepository;
import com.staccato.travel.service.dto.request.TravelRequest;
import com.staccato.travel.service.dto.response.TravelResponses;
import com.staccato.visit.domain.Visit;
import com.staccato.visit.repository.VisitRepository;

import lombok.RequiredArgsConstructor;

Expand All @@ -23,6 +26,7 @@
public class TravelService {
private final TravelRepository travelRepository;
private final TravelMemberRepository travelMemberRepository;
private final VisitRepository visitRepository;
private final MemberRepository memberRepository;

@Transactional
Expand All @@ -46,6 +50,19 @@ private Member getMemberById(long memberId) {
.orElseThrow(() -> new IllegalArgumentException("Invalid Operation"));
}

@Transactional
public void updateTravel(TravelRequest travelRequest, Long travelId) {
Travel updatedTravel = travelRequest.toTravel();
Travel originTravel = getTravelById(travelId);
List<Visit> visits = visitRepository.findAllByTravelId(travelId);
originTravel.update(updatedTravel, visits);
}

private Travel getTravelById(long travelId) {
return travelRepository.findById(travelId)
.orElseThrow(() -> new StaccatoException("요청하신 여행을 찾을 수 없어요."));
}

public TravelResponses readAllTravels(long memberId, Integer year) {
return Optional.ofNullable(year)
.map(y -> readAllByYear(memberId, y))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.staccato.visit.repository;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;

import com.staccato.visit.domain.Visit;

public interface VisitRepository extends JpaRepository<Visit, Long> {
List<Visit> findAllByTravelId(Long travelId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
Expand Down Expand Up @@ -104,6 +105,69 @@ void failCreateTravel(TravelRequest travelRequest, String expectedMessage) {
.body("status", is(HttpStatus.BAD_REQUEST.toString()));
}

@DisplayName("사용자가 여행 상세 정보를 입력하면, 여행 상세를 수정한다.")
@ParameterizedTest
@MethodSource("travelRequestProvider")
void updateTravel(TravelRequest travelRequest) {
// given
createTravel(travelRequest);
Long travelId = 1L;

// when & then
RestAssured.given().pathParam("travelId", travelId).log().all()
.contentType(ContentType.JSON)
.header(HttpHeaders.AUTHORIZATION, USER_AUTHORIZATION)
.body(travelRequest)
.when().log().all()
.put("/travels/{travelId}")
.then().log().all()
.assertThat().statusCode(HttpStatus.OK.value());
}

@DisplayName("사용자가 잘못된 방식으로 정보를 입력하면, 여행 상세를 수정할 수 없다.")
@ParameterizedTest
@MethodSource("invalidTravelRequestProvider")
void failUpdateTravel(TravelRequest invalidTravelRequest, String expectedMessage) {
// given
TravelRequest travelRequest = new TravelRequest("https://example.com/travels/geumohrm.jpg", "2023 여름 휴가", "친구들과 함께한 여름 휴가 여행", LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10));
Long travelId = 1L;
createTravel(travelRequest);

// when & then
RestAssured.given().pathParam("travelId", travelId).log().all()
.contentType(ContentType.JSON)
.header(HttpHeaders.AUTHORIZATION, USER_AUTHORIZATION)
.body(invalidTravelRequest)
.when().log().all()
.put("/travels/{travelId}")
.then().log().all()
.assertThat().statusCode(HttpStatus.BAD_REQUEST.value())
.body("message", is(expectedMessage))
.body("status", is(HttpStatus.BAD_REQUEST.toString()));
}

@DisplayName("사용자가 잘못된 여행식별자로 접근한다면 예외가 발생한다.")
@ParameterizedTest
@ValueSource(longs = {0, -1})
void failAccessTravel(Long travelId) {
// given
TravelRequest travelRequest = new TravelRequest("https://example.com/travels/geumohrm.jpg", "2023 여름 휴가", "친구들과 함께한 여름 휴가 여행", LocalDate.of(2023, 7, 1), LocalDate.of(2023, 7, 10));
String expectedMessage = "여행 식별자는 양수로 이루어져야 합니다.";
createTravel(travelRequest);

// when & then
RestAssured.given().pathParam("travelId", travelId).log().all()
.contentType(ContentType.JSON)
.header(HttpHeaders.AUTHORIZATION, USER_AUTHORIZATION)
.body(travelRequest)
.when().log().all()
.put("/travels/{travelId}")
.then().log().all()
.assertThat().statusCode(HttpStatus.BAD_REQUEST.value())
.body("message", is(expectedMessage))
.body("status", is(HttpStatus.BAD_REQUEST.toString()));
}

@DisplayName("사용자의 모든 여행 상세 목록을 조회한다.")
@TestFactory
Stream<DynamicTest> findAllTravels() {
Expand Down
49 changes: 49 additions & 0 deletions backend/src/test/java/com/staccato/travel/domain/TravelTest.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
package com.staccato.travel.domain;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode;

import java.time.LocalDate;
import java.util.List;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import com.staccato.exception.StaccatoException;
import com.staccato.pin.domain.Pin;
import com.staccato.visit.domain.Visit;

class TravelTest {
@DisplayName("끝 날짜는 시작 날짜보다 앞설 수 없다.")
Expand All @@ -21,4 +26,48 @@ void validateDate() {
.isInstanceOf(StaccatoException.class)
.hasMessage("끝 날짜가 시작 날짜보다 앞설 수 없어요.");
}

@DisplayName("특정 날짜가 여행 상세 날짜에 속하는지 알 수 있다.")
@Test
void withinDuration() {
// given
Travel travel = Travel.builder()
.title("2023 여름 여행")
.startAt(LocalDate.of(2023, 7, 1))
.endAt(LocalDate.of(2023, 7, 10))
.build();

// when & then
assertThat(travel.withinDuration(LocalDate.of(2023, 7, 11))).isFalse();
}

@DisplayName("여행 상세를 수정 시 기존 방문 기록 날짜를 포함하지 않는 경우 수정에 실패한다.")
@Test
void validateDuration(){
// given
Travel travel = Travel.builder()
.title("2023 여름 여행")
.startAt(LocalDate.of(2023, 7, 1))
.endAt(LocalDate.of(2023, 7, 10))
.build();
Travel updatedTravel = Travel.builder()
.title("2023 여름 여행")
.startAt(LocalDate.of(2023, 7, 20))
.endAt(LocalDate.of(2023, 7, 21))
.build();
Pin pin = Pin.builder()
.place("Sample Place")
.address("Sample Address")
.build();
Visit visit = Visit.builder()
.visitedAt(LocalDate.of(2023, 7, 1))
.pin(pin)
.travel(travel)
.build();

// when & then
assertThatThrownBy(() -> travel.update(updatedTravel, List.of(visit)))
.isInstanceOf(StaccatoException.class)
.hasMessage("변경하려는 여행 기간이 이미 존재하는 방문 기록을 포함하지 않습니다. 여행 기간을 다시 설정해주세요.");
}
}
Loading

0 comments on commit 8d2f42d

Please sign in to comment.