From 259af52b6c1fd1091b7e2ef08fd811930ce8eec8 Mon Sep 17 00:00:00 2001 From: songhyeonpk Date: Wed, 9 Jul 2025 01:13:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=97=AC=ED=96=89=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Trip 모델 구현 * feat: TripCategory enum 구현 * feat: Stamp 모델 구현 * feat: DateUtil 유틸리티 클래스 추가 * feat: 여행 카테고리 목록 조회 기능 구현 * feat: 여행 생성 시 스탬프 함께 생성되도록 기능 구현 * feat: 여행 수정, 스탬프 순서 수정 구현 * feat: 여행 삭제 기능 구현 * feat: 멤버 여행 목록 조회 기능 구현 * feat: 특정 여행 조회 및 해당 스탬프 목록 조회 기능 구현 * test: Fixture 클래스 추가 (TripFixture, CreateTripRequestFixture, UpdateTripRequestFixture, StampFixture, CreateStampRequestFixture) * test: Helper 클래스 추가 (TripTestHelper, TokenTestHelper) * test: TripControllerIntegrationTest - 통합 테스트 추가 * test: TripServiceTest - 단위 테스트 추가 * test: StampServiceTest - 단위 테스트 추가 * test: MemberFixture 클래스 메서드 추가 - createMemberFromKakao(email, nickname), createMemberFromKakoWithId(id) * test: MemberTestHelper 클래스 메서드 추가 - saveMember(email, nickname) --- .../global/common/entity/BaseTimeEntity.java | 2 +- .../global/config/WebSecurityConfig.java | 2 + .../ject/studytrip/global/util/DateUtil.java | 28 + .../stamp/application/dto/StampInfo.java | 26 + .../application/service/StampService.java | 55 ++ .../stamp/domain/error/StampErrorCode.java | 36 + .../stamp/domain/factory/StampFactory.java | 14 + .../studytrip/stamp/domain/model/Stamp.java | 47 ++ .../stamp/domain/policy/StampPolicy.java | 54 ++ .../domain/repository/StampRepository.java | 12 + .../stamp/infra/jpa/StampJpaRepository.java | 11 + .../infra/jpa/StampRepositoryAdapter.java | 28 + .../dto/request/CreateStampRequest.java | 17 + .../dto/response/LoadStampDetailResponse.java | 20 + .../application/dto/TripCategoryInfo.java | 9 + .../trip/application/dto/TripDetail.java | 10 + .../trip/application/dto/TripInfo.java | 39 + .../trip/application/facade/TripFacade.java | 115 +++ .../trip/application/service/TripService.java | 75 ++ .../trip/domain/error/TripErrorCode.java | 41 ++ .../trip/domain/factory/TripFactory.java | 21 + .../studytrip/trip/domain/model/Trip.java | 96 +++ .../trip/domain/model/TripCategory.java | 27 + .../trip/domain/policy/TripPolicy.java | 40 ++ .../repository/TripQueryRepository.java | 9 + .../domain/repository/TripRepository.java | 10 + .../trip/infra/jpa/TripJpaRepository.java | 6 + .../trip/infra/jpa/TripRepositoryAdapter.java | 23 + .../querydsl/TripQueryRepositoryAdapter.java | 38 + .../controller/TripController.java | 114 +++ .../dto/request/CreateTripRequest.java | 25 + .../dto/request/UpdateTripRequest.java | 20 + .../dto/response/CreateTripResponse.java | 9 + .../response/LoadTripCategoryResponse.java | 9 + .../dto/response/LoadTripDetailResponse.java | 38 + .../dto/response/LoadTripsSliceResponse.java | 13 + .../studytrip/StudytripApplicationTests.java | 2 + .../studytrip/auth/fixture/TokenFixture.java | 1 + .../auth/helper/TokenTestHelper.java | 20 + .../member/fixture/MemberFixture.java | 27 + .../member/helper/MemberTestHelper.java | 9 +- .../application/service/StampServiceTest.java | 223 ++++++ .../fixture/CreateStampRequestFixture.java | 30 + .../studytrip/stamp/fixture/StampFixture.java | 14 + .../application/service/TripServiceTest.java | 286 ++++++++ .../fixture/CreateTripRequestFixture.java | 49 ++ .../studytrip/trip/fixture/TripFixture.java | 49 ++ .../fixture/UpdateTripRequestFixture.java | 36 + .../studytrip/trip/helper/TripTestHelper.java | 26 + .../TripControllerIntegrationTest.java | 677 ++++++++++++++++++ 50 files changed, 2585 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/ject/studytrip/global/util/DateUtil.java create mode 100644 src/main/java/com/ject/studytrip/stamp/application/dto/StampInfo.java create mode 100644 src/main/java/com/ject/studytrip/stamp/application/service/StampService.java create mode 100644 src/main/java/com/ject/studytrip/stamp/domain/error/StampErrorCode.java create mode 100644 src/main/java/com/ject/studytrip/stamp/domain/factory/StampFactory.java create mode 100644 src/main/java/com/ject/studytrip/stamp/domain/model/Stamp.java create mode 100644 src/main/java/com/ject/studytrip/stamp/domain/policy/StampPolicy.java create mode 100644 src/main/java/com/ject/studytrip/stamp/domain/repository/StampRepository.java create mode 100644 src/main/java/com/ject/studytrip/stamp/infra/jpa/StampJpaRepository.java create mode 100644 src/main/java/com/ject/studytrip/stamp/infra/jpa/StampRepositoryAdapter.java create mode 100644 src/main/java/com/ject/studytrip/stamp/presentation/dto/request/CreateStampRequest.java create mode 100644 src/main/java/com/ject/studytrip/stamp/presentation/dto/response/LoadStampDetailResponse.java create mode 100644 src/main/java/com/ject/studytrip/trip/application/dto/TripCategoryInfo.java create mode 100644 src/main/java/com/ject/studytrip/trip/application/dto/TripDetail.java create mode 100644 src/main/java/com/ject/studytrip/trip/application/dto/TripInfo.java create mode 100644 src/main/java/com/ject/studytrip/trip/application/facade/TripFacade.java create mode 100644 src/main/java/com/ject/studytrip/trip/application/service/TripService.java create mode 100644 src/main/java/com/ject/studytrip/trip/domain/error/TripErrorCode.java create mode 100644 src/main/java/com/ject/studytrip/trip/domain/factory/TripFactory.java create mode 100644 src/main/java/com/ject/studytrip/trip/domain/model/Trip.java create mode 100644 src/main/java/com/ject/studytrip/trip/domain/model/TripCategory.java create mode 100644 src/main/java/com/ject/studytrip/trip/domain/policy/TripPolicy.java create mode 100644 src/main/java/com/ject/studytrip/trip/domain/repository/TripQueryRepository.java create mode 100644 src/main/java/com/ject/studytrip/trip/domain/repository/TripRepository.java create mode 100644 src/main/java/com/ject/studytrip/trip/infra/jpa/TripJpaRepository.java create mode 100644 src/main/java/com/ject/studytrip/trip/infra/jpa/TripRepositoryAdapter.java create mode 100644 src/main/java/com/ject/studytrip/trip/infra/querydsl/TripQueryRepositoryAdapter.java create mode 100644 src/main/java/com/ject/studytrip/trip/presentation/controller/TripController.java create mode 100644 src/main/java/com/ject/studytrip/trip/presentation/dto/request/CreateTripRequest.java create mode 100644 src/main/java/com/ject/studytrip/trip/presentation/dto/request/UpdateTripRequest.java create mode 100644 src/main/java/com/ject/studytrip/trip/presentation/dto/response/CreateTripResponse.java create mode 100644 src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadTripCategoryResponse.java create mode 100644 src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadTripDetailResponse.java create mode 100644 src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadTripsSliceResponse.java create mode 100644 src/test/java/com/ject/studytrip/auth/helper/TokenTestHelper.java create mode 100644 src/test/java/com/ject/studytrip/stamp/application/service/StampServiceTest.java create mode 100644 src/test/java/com/ject/studytrip/stamp/fixture/CreateStampRequestFixture.java create mode 100644 src/test/java/com/ject/studytrip/stamp/fixture/StampFixture.java create mode 100644 src/test/java/com/ject/studytrip/trip/application/service/TripServiceTest.java create mode 100644 src/test/java/com/ject/studytrip/trip/fixture/CreateTripRequestFixture.java create mode 100644 src/test/java/com/ject/studytrip/trip/fixture/TripFixture.java create mode 100644 src/test/java/com/ject/studytrip/trip/fixture/UpdateTripRequestFixture.java create mode 100644 src/test/java/com/ject/studytrip/trip/helper/TripTestHelper.java create mode 100644 src/test/java/com/ject/studytrip/trip/presentation/controller/TripControllerIntegrationTest.java diff --git a/src/main/java/com/ject/studytrip/global/common/entity/BaseTimeEntity.java b/src/main/java/com/ject/studytrip/global/common/entity/BaseTimeEntity.java index 8177afd..b650e60 100644 --- a/src/main/java/com/ject/studytrip/global/common/entity/BaseTimeEntity.java +++ b/src/main/java/com/ject/studytrip/global/common/entity/BaseTimeEntity.java @@ -20,5 +20,5 @@ public abstract class BaseTimeEntity { @LastModifiedDate private LocalDateTime updatedAt; - private LocalDateTime deletedAt; + protected LocalDateTime deletedAt; } diff --git a/src/main/java/com/ject/studytrip/global/config/WebSecurityConfig.java b/src/main/java/com/ject/studytrip/global/config/WebSecurityConfig.java index 085f565..75d723d 100644 --- a/src/main/java/com/ject/studytrip/global/config/WebSecurityConfig.java +++ b/src/main/java/com/ject/studytrip/global/config/WebSecurityConfig.java @@ -60,6 +60,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .permitAll() // Swagger 경로 .requestMatchers("/api/sample/**", "/api/auth/**") .permitAll() // 샘플 api 경로 + .requestMatchers("/api/trips/categories") + .permitAll() .anyRequest() .authenticated()); // 그 외 요청은 모두 인증 수행 diff --git a/src/main/java/com/ject/studytrip/global/util/DateUtil.java b/src/main/java/com/ject/studytrip/global/util/DateUtil.java new file mode 100644 index 0000000..21aa47c --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/util/DateUtil.java @@ -0,0 +1,28 @@ +package com.ject.studytrip.global.util; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class DateUtil { + + private static final DateTimeFormatter DEFAULT_DATE_FORMATTER = DateTimeFormatter.ISO_DATE; + private static final DateTimeFormatter DEFAULT_DATETIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + public static String formatDate(LocalDate date) { + return date != null ? date.format(DEFAULT_DATE_FORMATTER) : null; + } + + public static LocalDate parseDate(String raw) { + return LocalDate.parse(raw, DEFAULT_DATE_FORMATTER); + } + + public static String formatDateTime(LocalDateTime dateTime) { + return dateTime != null ? dateTime.format(DEFAULT_DATETIME_FORMATTER) : null; + } + + public static LocalDateTime parseDateTime(String raw) { + return LocalDateTime.parse(raw, DEFAULT_DATETIME_FORMATTER); + } +} diff --git a/src/main/java/com/ject/studytrip/stamp/application/dto/StampInfo.java b/src/main/java/com/ject/studytrip/stamp/application/dto/StampInfo.java new file mode 100644 index 0000000..9538dc9 --- /dev/null +++ b/src/main/java/com/ject/studytrip/stamp/application/dto/StampInfo.java @@ -0,0 +1,26 @@ +package com.ject.studytrip.stamp.application.dto; + +import com.ject.studytrip.global.util.DateUtil; +import com.ject.studytrip.stamp.domain.model.Stamp; + +public record StampInfo( + Long stampId, + String stampName, + int stampOrder, + String deadline, + boolean completed, + String createdAt, + String updatedAt, + String deletedAt) { + public static StampInfo from(Stamp stamp) { + return new StampInfo( + stamp.getId(), + stamp.getName(), + stamp.getStampOrder(), + DateUtil.formatDate(stamp.getDeadline()), + stamp.isCompleted(), + DateUtil.formatDateTime(stamp.getCreatedAt()), + DateUtil.formatDateTime(stamp.getUpdatedAt()), + DateUtil.formatDateTime(stamp.getDeletedAt())); + } +} diff --git a/src/main/java/com/ject/studytrip/stamp/application/service/StampService.java b/src/main/java/com/ject/studytrip/stamp/application/service/StampService.java new file mode 100644 index 0000000..b310f43 --- /dev/null +++ b/src/main/java/com/ject/studytrip/stamp/application/service/StampService.java @@ -0,0 +1,55 @@ +package com.ject.studytrip.stamp.application.service; + +import com.ject.studytrip.stamp.domain.factory.StampFactory; +import com.ject.studytrip.stamp.domain.model.Stamp; +import com.ject.studytrip.stamp.domain.policy.StampPolicy; +import com.ject.studytrip.stamp.domain.repository.StampRepository; +import com.ject.studytrip.stamp.presentation.dto.request.CreateStampRequest; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripCategory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class StampService { + + private final StampRepository stampRepository; + + public void createStamps(Trip trip, List requests) { + List stamps = + requests.stream() + .map( + stamp -> + StampFactory.create( + trip, + stamp.name(), + stamp.order(), + stamp.deadline())) + .toList(); + + StampPolicy.validateStampDeadline(trip.getEndDate(), stamps); + StampPolicy.validateStampOrders(trip.getCategory(), stamps); + + stampRepository.saveAll(stamps); + } + + public void updateStampsOrderByTripCategoryChange(Long tripId, TripCategory newCategory) { + List stamps = stampRepository.findAllByTripIdOrderByDeadlineAsc(tripId); + + if (newCategory == TripCategory.EXPLORE) { + stamps.forEach(stamp -> stamp.updateStampOrder(0)); + return; + } + + int order = 1; + for (Stamp stamp : stamps) { + stamp.updateStampOrder(order++); + } + } + + public List getStampsByTripId(Long tripId) { + return stampRepository.findAllByTripId(tripId); + } +} diff --git a/src/main/java/com/ject/studytrip/stamp/domain/error/StampErrorCode.java b/src/main/java/com/ject/studytrip/stamp/domain/error/StampErrorCode.java new file mode 100644 index 0000000..da152da --- /dev/null +++ b/src/main/java/com/ject/studytrip/stamp/domain/error/StampErrorCode.java @@ -0,0 +1,36 @@ +package com.ject.studytrip.stamp.domain.error; + +import com.ject.studytrip.global.exception.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum StampErrorCode implements ErrorCode { + // 400 + STAMP_DEADLINE_CANNOT_BE_IN_PAST(HttpStatus.BAD_REQUEST, "스탬프의 마감일은 과거일 수 없습니다."), + STAMP_DEADLINE_EXCEEDS_TRIP_END_DATE(HttpStatus.BAD_REQUEST, "스탬프의 마감일은 여행 종료일을 초과할 수 없습니다."), + INVALID_STAMP_ORDER_FOR_EXPLORATION_TRIP( + HttpStatus.BAD_REQUEST, "탐험형 여행에서는 스탬프 순서를 지정할 수 없으며, 항상 0이여야 합니다. "), + INVALID_STAMP_ORDER_RANGE_FOR_COURSE_TRIP( + HttpStatus.BAD_REQUEST, "코스형 여행의 스탬프 순서의 범위는 최소 1 이상 또는 최대 총 스탬프 개수여야 합니다."), + DUPLICATE_STAMP_ORDER_FOR_COURSE_TRIP(HttpStatus.BAD_REQUEST, "코스형 여행의 스탬프 순서에 중복된 값이 존재합니다."), + ; + + private final HttpStatus status; + private final String message; + + @Override + public String getName() { + return this.name(); + } + + @Override + public HttpStatus getStatus() { + return this.status; + } + + @Override + public String getMessage() { + return this.message; + } +} diff --git a/src/main/java/com/ject/studytrip/stamp/domain/factory/StampFactory.java b/src/main/java/com/ject/studytrip/stamp/domain/factory/StampFactory.java new file mode 100644 index 0000000..74379c3 --- /dev/null +++ b/src/main/java/com/ject/studytrip/stamp/domain/factory/StampFactory.java @@ -0,0 +1,14 @@ +package com.ject.studytrip.stamp.domain.factory; + +import com.ject.studytrip.stamp.domain.model.Stamp; +import com.ject.studytrip.trip.domain.model.Trip; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class StampFactory { + public static Stamp create(Trip trip, String name, int stampOrder, LocalDate deadline) { + return Stamp.of(trip, name, stampOrder, deadline); + } +} diff --git a/src/main/java/com/ject/studytrip/stamp/domain/model/Stamp.java b/src/main/java/com/ject/studytrip/stamp/domain/model/Stamp.java new file mode 100644 index 0000000..baf74cd --- /dev/null +++ b/src/main/java/com/ject/studytrip/stamp/domain/model/Stamp.java @@ -0,0 +1,47 @@ +package com.ject.studytrip.stamp.domain.model; + +import com.ject.studytrip.global.common.entity.BaseTimeEntity; +import com.ject.studytrip.trip.domain.model.Trip; +import jakarta.persistence.*; +import java.time.LocalDate; +import lombok.*; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class Stamp extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "trip_id", nullable = false) + private Trip trip; + + @Column(nullable = false) + private String name; + + private int stampOrder; + + @Column(nullable = false) + private LocalDate deadline; + + private boolean completed; + + public static Stamp of(Trip trip, String name, int stampOrder, LocalDate deadline) { + return Stamp.builder() + .trip(trip) + .name(name) + .stampOrder(stampOrder) + .deadline(deadline) + .completed(false) + .build(); + } + + public void updateStampOrder(int newOrder) { + this.stampOrder = newOrder; + } +} diff --git a/src/main/java/com/ject/studytrip/stamp/domain/policy/StampPolicy.java b/src/main/java/com/ject/studytrip/stamp/domain/policy/StampPolicy.java new file mode 100644 index 0000000..ec98a47 --- /dev/null +++ b/src/main/java/com/ject/studytrip/stamp/domain/policy/StampPolicy.java @@ -0,0 +1,54 @@ +package com.ject.studytrip.stamp.domain.policy; + +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.stamp.domain.error.StampErrorCode; +import com.ject.studytrip.stamp.domain.model.Stamp; +import com.ject.studytrip.trip.domain.model.TripCategory; +import java.time.LocalDate; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class StampPolicy { + public static void validateStampDeadline(LocalDate tripEndDate, List stamps) { + if (tripEndDate == null || stamps.isEmpty()) return; + + LocalDate today = LocalDate.now(); + for (Stamp stamp : stamps) { + LocalDate stampDeadline = stamp.getDeadline(); + + if (stampDeadline.isBefore(today)) + throw new CustomException(StampErrorCode.STAMP_DEADLINE_CANNOT_BE_IN_PAST); + + if (stampDeadline.isAfter(tripEndDate)) + throw new CustomException(StampErrorCode.STAMP_DEADLINE_EXCEEDS_TRIP_END_DATE); + } + } + + public static void validateStampOrders(TripCategory tripCategory, List stamps) { + int maxOrder = stamps.size(); + Set orderSet = new HashSet<>(); + + for (Stamp stamp : stamps) { + int order = stamp.getStampOrder(); + + // 탐험형 여행이면서 순서가 존재할 경우 + if (tripCategory == TripCategory.EXPLORE && order > 0) + throw new CustomException(StampErrorCode.INVALID_STAMP_ORDER_FOR_EXPLORATION_TRIP); + + if (tripCategory == TripCategory.COURSE) { + // 코스형 여행이면서 순서가 1보다 작거나 총 개수보다 큰 경우 + if (order < 1 || order > maxOrder) + throw new CustomException( + StampErrorCode.INVALID_STAMP_ORDER_RANGE_FOR_COURSE_TRIP); + + // 코스형 여행이면서 중복된 순서일 경우 + if (!orderSet.add(order)) + throw new CustomException(StampErrorCode.DUPLICATE_STAMP_ORDER_FOR_COURSE_TRIP); + } + } + } +} diff --git a/src/main/java/com/ject/studytrip/stamp/domain/repository/StampRepository.java b/src/main/java/com/ject/studytrip/stamp/domain/repository/StampRepository.java new file mode 100644 index 0000000..f24899e --- /dev/null +++ b/src/main/java/com/ject/studytrip/stamp/domain/repository/StampRepository.java @@ -0,0 +1,12 @@ +package com.ject.studytrip.stamp.domain.repository; + +import com.ject.studytrip.stamp.domain.model.Stamp; +import java.util.List; + +public interface StampRepository { + List saveAll(List stamps); + + List findAllByTripId(Long tripId); + + List findAllByTripIdOrderByDeadlineAsc(Long tripId); +} diff --git a/src/main/java/com/ject/studytrip/stamp/infra/jpa/StampJpaRepository.java b/src/main/java/com/ject/studytrip/stamp/infra/jpa/StampJpaRepository.java new file mode 100644 index 0000000..ee437ec --- /dev/null +++ b/src/main/java/com/ject/studytrip/stamp/infra/jpa/StampJpaRepository.java @@ -0,0 +1,11 @@ +package com.ject.studytrip.stamp.infra.jpa; + +import com.ject.studytrip.stamp.domain.model.Stamp; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StampJpaRepository extends JpaRepository { + List findAllByTripId(Long tripId); + + List findAllByTripIdOrderByDeadlineAsc(Long tripId); +} diff --git a/src/main/java/com/ject/studytrip/stamp/infra/jpa/StampRepositoryAdapter.java b/src/main/java/com/ject/studytrip/stamp/infra/jpa/StampRepositoryAdapter.java new file mode 100644 index 0000000..fb0c11f --- /dev/null +++ b/src/main/java/com/ject/studytrip/stamp/infra/jpa/StampRepositoryAdapter.java @@ -0,0 +1,28 @@ +package com.ject.studytrip.stamp.infra.jpa; + +import com.ject.studytrip.stamp.domain.model.Stamp; +import com.ject.studytrip.stamp.domain.repository.StampRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class StampRepositoryAdapter implements StampRepository { + private final StampJpaRepository stampJpaRepository; + + @Override + public List saveAll(List stamps) { + return stampJpaRepository.saveAll(stamps); + } + + @Override + public List findAllByTripId(Long tripId) { + return stampJpaRepository.findAllByTripId(tripId); + } + + @Override + public List findAllByTripIdOrderByDeadlineAsc(Long tripId) { + return stampJpaRepository.findAllByTripIdOrderByDeadlineAsc(tripId); + } +} diff --git a/src/main/java/com/ject/studytrip/stamp/presentation/dto/request/CreateStampRequest.java b/src/main/java/com/ject/studytrip/stamp/presentation/dto/request/CreateStampRequest.java new file mode 100644 index 0000000..432233b --- /dev/null +++ b/src/main/java/com/ject/studytrip/stamp/presentation/dto/request/CreateStampRequest.java @@ -0,0 +1,17 @@ +package com.ject.studytrip.stamp.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; + +public record CreateStampRequest( + @Schema(description = "스탬프 이름") @NotEmpty(message = "스탬프 이름은 필수 요청 값입니다.") String name, + @Schema(description = "스탬프 순서") @Min(value = 0, message = "스탬프 순서는 최소 0 이상이여야 합니다.") + int order, + @Schema(description = "스탬프 마감일") + @NotNull(message = "스탬프 마감일은 필수 요청 값입니다.") + @FutureOrPresent(message = "스탬프 마감일은 현재 날짜보다 과거일 수 없습니다.") + LocalDate deadline) {} diff --git a/src/main/java/com/ject/studytrip/stamp/presentation/dto/response/LoadStampDetailResponse.java b/src/main/java/com/ject/studytrip/stamp/presentation/dto/response/LoadStampDetailResponse.java new file mode 100644 index 0000000..6f3c287 --- /dev/null +++ b/src/main/java/com/ject/studytrip/stamp/presentation/dto/response/LoadStampDetailResponse.java @@ -0,0 +1,20 @@ +package com.ject.studytrip.stamp.presentation.dto.response; + +import com.ject.studytrip.stamp.application.dto.StampInfo; +import io.swagger.v3.oas.annotations.media.Schema; + +public record LoadStampDetailResponse( + @Schema(description = "스탬프 ID") Long stampId, + @Schema(description = "스탬프 이름") String stampName, + @Schema(description = "스탬프 순서") int stampOrder, + @Schema(description = "스탬프 마감일") String stampDeadline, + @Schema(description = "스탬프 완료 여부") boolean completed) { + public static LoadStampDetailResponse of(StampInfo stampInfo) { + return new LoadStampDetailResponse( + stampInfo.stampId(), + stampInfo.stampName(), + stampInfo.stampOrder(), + stampInfo.deadline(), + stampInfo.completed()); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/application/dto/TripCategoryInfo.java b/src/main/java/com/ject/studytrip/trip/application/dto/TripCategoryInfo.java new file mode 100644 index 0000000..6edcb7c --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/application/dto/TripCategoryInfo.java @@ -0,0 +1,9 @@ +package com.ject.studytrip.trip.application.dto; + +import com.ject.studytrip.trip.domain.model.TripCategory; + +public record TripCategoryInfo(String name, String value) { + public static TripCategoryInfo from(TripCategory category) { + return new TripCategoryInfo(category.name(), category.getValue()); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/application/dto/TripDetail.java b/src/main/java/com/ject/studytrip/trip/application/dto/TripDetail.java new file mode 100644 index 0000000..e960b89 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/application/dto/TripDetail.java @@ -0,0 +1,10 @@ +package com.ject.studytrip.trip.application.dto; + +import com.ject.studytrip.stamp.application.dto.StampInfo; +import java.util.List; + +public record TripDetail(TripInfo tripInfo, List stampInfos) { + public static TripDetail from(TripInfo tripInfo, List stampInfos) { + return new TripDetail(tripInfo, stampInfos); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/application/dto/TripInfo.java b/src/main/java/com/ject/studytrip/trip/application/dto/TripInfo.java new file mode 100644 index 0000000..29fa0d1 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/application/dto/TripInfo.java @@ -0,0 +1,39 @@ +package com.ject.studytrip.trip.application.dto; + +import com.ject.studytrip.global.util.DateUtil; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripCategory; + +public record TripInfo( + Long tripId, + String tripName, + String tripMemo, + TripCategory tripCategory, + String startDate, + String endDate, + Integer dDay, + int totalStamps, + int completedStamps, + Integer progress, + boolean completed, + String createdAt, + String updatedAt, + String deletedAt) { + public static TripInfo from(Trip trip, Integer dDay, Integer progress) { + return new TripInfo( + trip.getId(), + trip.getName(), + trip.getMemo(), + trip.getCategory(), + DateUtil.formatDate(trip.getStartDate()), + DateUtil.formatDate(trip.getEndDate()), + dDay, + trip.getTotalStamps(), + trip.getCompletedStamps(), + progress, + trip.isCompleted(), + DateUtil.formatDateTime(trip.getCreatedAt()), + DateUtil.formatDateTime(trip.getUpdatedAt()), + DateUtil.formatDateTime(trip.getDeletedAt())); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/application/facade/TripFacade.java b/src/main/java/com/ject/studytrip/trip/application/facade/TripFacade.java new file mode 100644 index 0000000..f6b2e60 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/application/facade/TripFacade.java @@ -0,0 +1,115 @@ +package com.ject.studytrip.trip.application.facade; + +import com.ject.studytrip.member.application.service.MemberService; +import com.ject.studytrip.member.domain.entity.Member; +import com.ject.studytrip.stamp.application.dto.StampInfo; +import com.ject.studytrip.stamp.application.service.StampService; +import com.ject.studytrip.stamp.domain.model.Stamp; +import com.ject.studytrip.trip.application.dto.TripCategoryInfo; +import com.ject.studytrip.trip.application.dto.TripDetail; +import com.ject.studytrip.trip.application.dto.TripInfo; +import com.ject.studytrip.trip.application.service.TripService; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripCategory; +import com.ject.studytrip.trip.presentation.dto.request.CreateTripRequest; +import com.ject.studytrip.trip.presentation.dto.request.UpdateTripRequest; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class TripFacade { + private final TripService tripService; + private final StampService stampService; + private final MemberService memberService; + + public List loadTripCategories() { + return Arrays.stream(TripCategory.values()).map(TripCategoryInfo::from).toList(); + } + + @Transactional + public TripInfo createTrip(Long memberId, CreateTripRequest request) { + Member member = memberService.getMember(memberId); + Trip trip = tripService.createTrip(member, request); + stampService.createStamps(trip, request.stamps()); + + return TripInfo.from(trip, null, null); + } + + @Transactional + public void updateTrip(Long memberId, Long tripId, UpdateTripRequest request) { + Member member = memberService.getMember(memberId); + Trip trip = tripService.getTrip(tripId); + + tripService.updateTrip(member.getId(), trip, request); + + if (request.category() != null) + stampService.updateStampsOrderByTripCategoryChange( + trip.getId(), TripCategory.from(request.category())); + } + + @Transactional + public void deleteTrip(Long memberId, Long tripId) { + Member member = memberService.getMember(memberId); + Trip trip = tripService.getTrip(tripId); + tripService.deleteTrip(member.getId(), trip); + + // TODO : 추후 엔티티가 생성되면, 여행과 관련된 엔티티를 모두 soft delete 하는 로직 추가 + } + + public Slice getTripsByMember(Long memberId, int page, int size) { + Slice tripSlice = tripService.getTripsSliceByMemberId(memberId, page, size); + + List tripInfos = + tripSlice.getContent().stream() + .map( + trip -> { + Integer dDay = calculateDDay(trip.getEndDate()); + int progress = + calculateProgress( + trip.getTotalStamps(), + trip.getCompletedStamps()); + return TripInfo.from(trip, dDay, progress); + }) + .sorted( + Comparator.comparing( + TripInfo::dDay, + Comparator.nullsLast(Comparator.naturalOrder()))) + .toList(); + + return new SliceImpl<>(tripInfos, tripSlice.getPageable(), tripSlice.hasNext()); + } + + public TripDetail getTrip(Long tripId) { + Trip trip = tripService.getTrip(tripId); + int dDay = calculateDDay(trip.getEndDate()); + int progress = calculateProgress(trip.getTotalStamps(), trip.getCompletedStamps()); + + List stamps = stampService.getStampsByTripId(trip.getId()); + List stampInfos = stamps.stream().map(StampInfo::from).toList(); + + return TripDetail.from(TripInfo.from(trip, dDay, progress), stampInfos); + } + + private Integer calculateDDay(LocalDate endDate) { + if (endDate == null) return null; // NULL 인 경우 무기한 여행 + + LocalDate today = LocalDate.now(); + return (int) ChronoUnit.DAYS.between(today, endDate); + } + + private int calculateProgress(int totalStamps, int completedStamps) { + if (totalStamps == 0) return 0; + + double ratio = (double) completedStamps / totalStamps; + return (int) (ratio * 100); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/application/service/TripService.java b/src/main/java/com/ject/studytrip/trip/application/service/TripService.java new file mode 100644 index 0000000..0bda191 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/application/service/TripService.java @@ -0,0 +1,75 @@ +package com.ject.studytrip.trip.application.service; + +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.member.domain.entity.Member; +import com.ject.studytrip.trip.domain.error.TripErrorCode; +import com.ject.studytrip.trip.domain.factory.TripFactory; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripCategory; +import com.ject.studytrip.trip.domain.policy.TripPolicy; +import com.ject.studytrip.trip.domain.repository.TripQueryRepository; +import com.ject.studytrip.trip.domain.repository.TripRepository; +import com.ject.studytrip.trip.presentation.dto.request.CreateTripRequest; +import com.ject.studytrip.trip.presentation.dto.request.UpdateTripRequest; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class TripService { + private final TripRepository tripRepository; + private final TripQueryRepository tripQueryRepository; + + public Trip createTrip(Member member, CreateTripRequest request) { + TripCategory category = TripCategory.from(request.category()); + + TripPolicy.validateEndDateByCategory(category, request.endDate()); + TripPolicy.validateEndDateIsNotBeforeStartDate(LocalDate.now(), request.endDate()); + TripPolicy.validateMinimumStamps(request); + + Trip trip = + TripFactory.create( + member, + request.name(), + request.memo(), + category, + request.endDate(), + request.stamps().size()); + return tripRepository.save(trip); + } + + public void updateTrip(Long memberId, Trip trip, UpdateTripRequest request) { + TripCategory category = null; + if (request.category() != null) category = TripCategory.from(request.category()); + + TripPolicy.validateOwner(memberId, trip); + TripPolicy.validateEndDateIsNotBeforeStartDate(trip.getStartDate(), request.endDate()); + TripPolicy.validateDeleted(trip); + + trip.update(request.name(), request.memo(), category, request.endDate()); + } + + public void deleteTrip(Long memberId, Trip trip) { + TripPolicy.validateOwner(memberId, trip); + + trip.updateDeletedAt(); + } + + public Trip getTrip(Long tripId) { + Trip trip = + tripRepository + .findById(tripId) + .orElseThrow(() -> new CustomException(TripErrorCode.TRIP_NOT_FOUND)); + + TripPolicy.validateDeleted(trip); + + return trip; + } + + public Slice getTripsSliceByMemberId(Long memberId, int page, int size) { + return tripQueryRepository.findSliceByMemberId(memberId, PageRequest.of(page, size)); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/domain/error/TripErrorCode.java b/src/main/java/com/ject/studytrip/trip/domain/error/TripErrorCode.java new file mode 100644 index 0000000..db877c2 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/domain/error/TripErrorCode.java @@ -0,0 +1,41 @@ +package com.ject.studytrip.trip.domain.error; + +import com.ject.studytrip.global.exception.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum TripErrorCode implements ErrorCode { + // 400 + TRIP_CATEGORY_REQUIRED(HttpStatus.BAD_REQUEST, "여행 카테고리는 필수입니다."), + INVALID_TRIP_CATEGORY(HttpStatus.BAD_REQUEST, "여행 카테고리가 누락되었거나 올바르지 않습니다."), + TRIP_STAMP_REQUIRED(HttpStatus.BAD_REQUEST, "여행을 생성하려면 최소 1개의 스탬프가 필요합니다."), + TRIP_END_DATE_BEFORE_START_DATE(HttpStatus.BAD_REQUEST, "여행 종료일은 시작일보다 이후여야 합니다."), + COURSE_TRIP_END_DATE_REQUIRED(HttpStatus.BAD_REQUEST, "코스형 여행은 종료일이 필수입니다."), + TRIP_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 여행입니다."), + + // 403 + NOT_TRIP_OWNER(HttpStatus.FORBIDDEN, "요청한 여행 정보를 수정/삭제할 권한이 부족합니다."), + + // 404 + TRIP_NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 여행이 존재하지 않습니다."), + ; + + private final HttpStatus status; + private final String message; + + @Override + public String getName() { + return this.name(); + } + + @Override + public HttpStatus getStatus() { + return this.status; + } + + @Override + public String getMessage() { + return this.message; + } +} diff --git a/src/main/java/com/ject/studytrip/trip/domain/factory/TripFactory.java b/src/main/java/com/ject/studytrip/trip/domain/factory/TripFactory.java new file mode 100644 index 0000000..929f7f4 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/domain/factory/TripFactory.java @@ -0,0 +1,21 @@ +package com.ject.studytrip.trip.domain.factory; + +import com.ject.studytrip.member.domain.entity.Member; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripCategory; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class TripFactory { + public static Trip create( + Member member, + String name, + String memo, + TripCategory category, + LocalDate endDate, + int totalStamps) { + return Trip.of(member, name, memo, category, endDate, totalStamps); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/domain/model/Trip.java b/src/main/java/com/ject/studytrip/trip/domain/model/Trip.java new file mode 100644 index 0000000..45d87de --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/domain/model/Trip.java @@ -0,0 +1,96 @@ +package com.ject.studytrip.trip.domain.model; + +import static org.springframework.util.StringUtils.hasText; + +import com.ject.studytrip.global.common.entity.BaseTimeEntity; +import com.ject.studytrip.member.domain.entity.Member; +import jakarta.persistence.*; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Objects; +import lombok.*; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class Trip extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Column(nullable = false) + private String name; + + private String memo; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private TripCategory category; + + @Column(nullable = false) + private LocalDate startDate; + + private LocalDate endDate; + + private int totalStamps; + + private int completedStamps; + + private boolean completed; + + public static Trip of( + Member member, + String name, + String memo, + TripCategory category, + LocalDate endDate, + int totalStamps) { + return Trip.builder() + .member(member) + .name(name) + .memo(memo) + .category(category) + .startDate(LocalDate.now()) + .endDate(endDate) + .totalStamps(totalStamps) + .completedStamps(0) + .completed(false) + .build(); + } + + public void update(String name, String memo, TripCategory category, LocalDate endDate) { + if (hasText(name)) this.name = name; + if (hasText(memo)) this.memo = memo; + if (Objects.nonNull(category)) this.category = category; + if (Objects.nonNull(endDate)) this.endDate = endDate; + } + + // public void updateName(String name) { + // if (hasText(name)) this.name = name; + // } + // + // public void updateMemo(String memo) { if (hasText(memo)) this.memo = memo; } + // + // public void updateCategory(TripCategory category) { + // if (Objects.nonNull(category)) this.category = category; + // } + // + // public void updateDeadline(LocalDate deadline) { + // if (Objects.nonNull(deadline)) this.deadline = deadline; + // } + + public void updateIsComplete(boolean completed) { + this.completed = completed; + } + + public void updateDeletedAt() { + this.deletedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/domain/model/TripCategory.java b/src/main/java/com/ject/studytrip/trip/domain/model/TripCategory.java new file mode 100644 index 0000000..f7f9b02 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/domain/model/TripCategory.java @@ -0,0 +1,27 @@ +package com.ject.studytrip.trip.domain.model; + +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.trip.domain.error.TripErrorCode; +import java.util.Arrays; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TripCategory { + COURSE("코스형"), + EXPLORE("탐험형"), + ; + + private final String value; + + public static TripCategory from(String name) { + if (name == null || name.isBlank()) + throw new CustomException(TripErrorCode.TRIP_CATEGORY_REQUIRED); + + return Arrays.stream(TripCategory.values()) + .filter(category -> category.name().equalsIgnoreCase(name)) + .findFirst() + .orElseThrow(() -> new CustomException(TripErrorCode.INVALID_TRIP_CATEGORY)); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/domain/policy/TripPolicy.java b/src/main/java/com/ject/studytrip/trip/domain/policy/TripPolicy.java new file mode 100644 index 0000000..b4da3eb --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/domain/policy/TripPolicy.java @@ -0,0 +1,40 @@ +package com.ject.studytrip.trip.domain.policy; + +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.trip.domain.error.TripErrorCode; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripCategory; +import com.ject.studytrip.trip.presentation.dto.request.CreateTripRequest; +import java.time.LocalDate; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class TripPolicy { + public static void validateOwner(Long memberId, Trip trip) { + if (!trip.getMember().getId().equals(memberId)) { + throw new CustomException(TripErrorCode.NOT_TRIP_OWNER); + } + } + + public static void validateEndDateByCategory(TripCategory category, LocalDate endDate) { + if (category == TripCategory.EXPLORE) return; + if (endDate == null) throw new CustomException(TripErrorCode.COURSE_TRIP_END_DATE_REQUIRED); + } + + public static void validateEndDateIsNotBeforeStartDate(LocalDate startDate, LocalDate endDate) { + if (startDate == null || endDate == null) return; + if (endDate.isBefore(startDate)) + throw new CustomException(TripErrorCode.TRIP_END_DATE_BEFORE_START_DATE); + } + + public static void validateMinimumStamps(CreateTripRequest request) { + if (request.stamps() == null || request.stamps().size() < 1) + throw new CustomException(TripErrorCode.TRIP_STAMP_REQUIRED); + } + + public static void validateDeleted(Trip trip) { + if (trip.getDeletedAt() != null) + throw new CustomException(TripErrorCode.TRIP_ALREADY_DELETED); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/domain/repository/TripQueryRepository.java b/src/main/java/com/ject/studytrip/trip/domain/repository/TripQueryRepository.java new file mode 100644 index 0000000..d980226 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/domain/repository/TripQueryRepository.java @@ -0,0 +1,9 @@ +package com.ject.studytrip.trip.domain.repository; + +import com.ject.studytrip.trip.domain.model.Trip; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +public interface TripQueryRepository { + Slice findSliceByMemberId(Long memberId, Pageable pageable); +} diff --git a/src/main/java/com/ject/studytrip/trip/domain/repository/TripRepository.java b/src/main/java/com/ject/studytrip/trip/domain/repository/TripRepository.java new file mode 100644 index 0000000..8c25bc2 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/domain/repository/TripRepository.java @@ -0,0 +1,10 @@ +package com.ject.studytrip.trip.domain.repository; + +import com.ject.studytrip.trip.domain.model.Trip; +import java.util.Optional; + +public interface TripRepository { + Optional findById(Long id); + + Trip save(Trip trip); +} diff --git a/src/main/java/com/ject/studytrip/trip/infra/jpa/TripJpaRepository.java b/src/main/java/com/ject/studytrip/trip/infra/jpa/TripJpaRepository.java new file mode 100644 index 0000000..fe7934c --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/infra/jpa/TripJpaRepository.java @@ -0,0 +1,6 @@ +package com.ject.studytrip.trip.infra.jpa; + +import com.ject.studytrip.trip.domain.model.Trip; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TripJpaRepository extends JpaRepository {} diff --git a/src/main/java/com/ject/studytrip/trip/infra/jpa/TripRepositoryAdapter.java b/src/main/java/com/ject/studytrip/trip/infra/jpa/TripRepositoryAdapter.java new file mode 100644 index 0000000..c5aecae --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/infra/jpa/TripRepositoryAdapter.java @@ -0,0 +1,23 @@ +package com.ject.studytrip.trip.infra.jpa; + +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.repository.TripRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class TripRepositoryAdapter implements TripRepository { + private final TripJpaRepository tripJpaRepository; + + @Override + public Optional findById(Long id) { + return tripJpaRepository.findById(id); + } + + @Override + public Trip save(Trip trip) { + return tripJpaRepository.save(trip); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/infra/querydsl/TripQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/trip/infra/querydsl/TripQueryRepositoryAdapter.java new file mode 100644 index 0000000..2a23d8a --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/infra/querydsl/TripQueryRepositoryAdapter.java @@ -0,0 +1,38 @@ +package com.ject.studytrip.trip.infra.querydsl; + +import com.ject.studytrip.trip.domain.model.QTrip; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.repository.TripQueryRepository; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class TripQueryRepositoryAdapter implements TripQueryRepository { + private final JPAQueryFactory queryFactory; + private final QTrip trip = QTrip.trip; + + @Override + public Slice findSliceByMemberId(Long memberId, Pageable pageable) { + List content = + queryFactory + .selectFrom(trip) + .where(trip.member.id.eq(memberId).and(trip.deletedAt.isNull())) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1) + .fetch(); + + List result = content; + boolean hasNext = content.size() > pageable.getPageSize(); + if (hasNext) { + result = content.subList(0, pageable.getPageSize()); + } + + return new SliceImpl<>(result, pageable, hasNext); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/presentation/controller/TripController.java b/src/main/java/com/ject/studytrip/trip/presentation/controller/TripController.java new file mode 100644 index 0000000..5b5015e --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/presentation/controller/TripController.java @@ -0,0 +1,114 @@ +package com.ject.studytrip.trip.presentation.controller; + +import com.ject.studytrip.global.common.response.StandardResponse; +import com.ject.studytrip.trip.application.dto.TripCategoryInfo; +import com.ject.studytrip.trip.application.dto.TripDetail; +import com.ject.studytrip.trip.application.dto.TripInfo; +import com.ject.studytrip.trip.application.facade.TripFacade; +import com.ject.studytrip.trip.presentation.dto.request.CreateTripRequest; +import com.ject.studytrip.trip.presentation.dto.request.UpdateTripRequest; +import com.ject.studytrip.trip.presentation.dto.response.*; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Trip", description = "여행 API") +@RestController +@RequestMapping("/api/trips") +@RequiredArgsConstructor +@Validated +public class TripController { + + private final TripFacade tripFacade; + + @Operation(summary = "여행 카테고리 목록 조회", description = "여행 카테고리 목록을 조회하는 API 입니다.") + @GetMapping("/categories") + public ResponseEntity loadTripCategories() { + List result = tripFacade.loadTripCategories(); + List response = + result.stream().map(LoadTripCategoryResponse::of).toList(); + + return ResponseEntity.status(HttpStatus.OK) + .body(StandardResponse.success(HttpStatus.OK.value(), response)); + } + + @Operation( + summary = "여행 생성", + description = "새로운 여행을 생성하는 API 입니다. 여행을 생성하는 동시에 1개 이상의 스탬프를 함께 생성합니다.") + @PostMapping + public ResponseEntity createTrip( + @AuthenticationPrincipal String memberId, + @RequestBody @Valid CreateTripRequest request) { + TripInfo result = tripFacade.createTrip(Long.valueOf(memberId), request); + + return ResponseEntity.status(HttpStatus.CREATED) + .body( + StandardResponse.success( + HttpStatus.CREATED.value(), + CreateTripResponse.of(result.tripId()))); + } + + @Operation(summary = "여행 수정", description = "여행을 수정하는 API 입니다. PATCH 매핑으로 수정을 원하는 필드만 요청합니다.") + @PatchMapping("/{tripId}") + public ResponseEntity updateTrip( + @AuthenticationPrincipal String memberId, + @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, + @RequestBody @Valid UpdateTripRequest request) { + tripFacade.updateTrip(Long.valueOf(memberId), tripId, request); + + return ResponseEntity.status(HttpStatus.OK) + .body(StandardResponse.success(HttpStatus.OK.value(), null)); + } + + @Operation(summary = "여행 삭제", description = "특정 여행을 삭제하는 API 입니다.") + @DeleteMapping("/{tripId}") + public ResponseEntity deleteTrip( + @AuthenticationPrincipal String memberId, + @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId) { + tripFacade.deleteTrip(Long.valueOf(memberId), tripId); + + return ResponseEntity.status(HttpStatus.OK) + .body(StandardResponse.success(HttpStatus.OK.value(), null)); + } + + @Operation( + summary = "여행 목록 조회", + description = "여행 목록을 조회하는 API 입니다. 무한 스크롤을 위해 슬라이스를 적용하고, D-DAY 정보가 이른 순으로 정렬합니다.") + @GetMapping + public ResponseEntity loadTrips( + @AuthenticationPrincipal String memberId, + @RequestParam(name = "page", defaultValue = "0") @Min(0) int page, + @RequestParam(name = "size", defaultValue = "5") @Min(1) @Max(10) int size) { + Slice result = tripFacade.getTripsByMember(Long.valueOf(memberId), page, size); + + return ResponseEntity.status(HttpStatus.OK) + .body( + StandardResponse.success( + HttpStatus.OK.value(), + LoadTripsSliceResponse.of(result.getContent(), result.hasNext()))); + } + + @Operation(summary = "여행 상세 조회", description = "특정 여행을 조회하는 API 입니다.") + @GetMapping("/{tripId}") + public ResponseEntity loadTripDetail( + @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId) { + TripDetail result = tripFacade.getTrip(tripId); + + return ResponseEntity.status(HttpStatus.OK) + .body( + StandardResponse.success( + HttpStatus.OK.value(), + LoadTripDetailResponse.of(result.tripInfo(), result.stampInfos()))); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/presentation/dto/request/CreateTripRequest.java b/src/main/java/com/ject/studytrip/trip/presentation/dto/request/CreateTripRequest.java new file mode 100644 index 0000000..bf22645 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/presentation/dto/request/CreateTripRequest.java @@ -0,0 +1,25 @@ +package com.ject.studytrip.trip.presentation.dto.request; + +import com.ject.studytrip.stamp.presentation.dto.request.CreateStampRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import java.time.LocalDate; +import java.util.List; + +public record CreateTripRequest( + @Schema(description = "여행 이름") @NotEmpty(message = "여행 이름은 필수 요청 값입니다.") String name, + @Schema(description = "여행 메모") String memo, + @Schema(description = "여행 카테고리") + @NotNull(message = "여행 카테고리는 필수 요청 값입니다.") + @Pattern( + regexp = "^(COURSE|EXPLORE)$", + message = "여행 카테고리는 COURSE, EXPLORE 중 하나여야 합니다.") + String category, + @Schema(description = "여행 종료일") @FutureOrPresent(message = "여행 종료일은 현재 날짜보다 과거일 수 없습니다.") + LocalDate endDate, + @Schema(description = "여행 스탬프 목록") @Valid @NotEmpty(message = "스탬프는 최소 1개 이상 함께 등록해야합니다.") + List stamps) {} diff --git a/src/main/java/com/ject/studytrip/trip/presentation/dto/request/UpdateTripRequest.java b/src/main/java/com/ject/studytrip/trip/presentation/dto/request/UpdateTripRequest.java new file mode 100644 index 0000000..d8b560a --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/presentation/dto/request/UpdateTripRequest.java @@ -0,0 +1,20 @@ +package com.ject.studytrip.trip.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; + +public record UpdateTripRequest( + @Schema(description = "수정할 여행 이름") @Size(min = 1, message = "여행 이름은 최소 1글자 이상이여야 합니다.") + String name, + @Schema(description = "수정할 여행 메모") String memo, + @Schema(description = "수정할 여행 카테고리") + @Pattern( + regexp = "^(COURSE|EXPLORE)$", + message = "여행 카테고리는 COURSE, EXPLORE 중 하나여야 합니다.") + String category, + @Schema(description = "수정할 여행 종료일") + @FutureOrPresent(message = "여행 종료일은 현재 날짜보다 과거일 수 없습니다.") + LocalDate endDate) {} diff --git a/src/main/java/com/ject/studytrip/trip/presentation/dto/response/CreateTripResponse.java b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/CreateTripResponse.java new file mode 100644 index 0000000..09947e8 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/CreateTripResponse.java @@ -0,0 +1,9 @@ +package com.ject.studytrip.trip.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record CreateTripResponse(@Schema(description = "여행 ID") Long tripId) { + public static CreateTripResponse of(Long tripId) { + return new CreateTripResponse(tripId); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadTripCategoryResponse.java b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadTripCategoryResponse.java new file mode 100644 index 0000000..86ebf7f --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadTripCategoryResponse.java @@ -0,0 +1,9 @@ +package com.ject.studytrip.trip.presentation.dto.response; + +import com.ject.studytrip.trip.application.dto.TripCategoryInfo; + +public record LoadTripCategoryResponse(String name, String value) { + public static LoadTripCategoryResponse of(TripCategoryInfo info) { + return new LoadTripCategoryResponse(info.name(), info.value()); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadTripDetailResponse.java b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadTripDetailResponse.java new file mode 100644 index 0000000..e03f119 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadTripDetailResponse.java @@ -0,0 +1,38 @@ +package com.ject.studytrip.trip.presentation.dto.response; + +import com.ject.studytrip.stamp.application.dto.StampInfo; +import com.ject.studytrip.stamp.presentation.dto.response.LoadStampDetailResponse; +import com.ject.studytrip.trip.application.dto.TripInfo; +import com.ject.studytrip.trip.domain.model.TripCategory; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public record LoadTripDetailResponse( + @Schema(description = "여행 ID") Long tripId, + @Schema(description = "여행 이름") String name, + @Schema(description = "여행 메모") String memo, + @Schema(description = "여행 카테고리") TripCategory category, + @Schema(description = "여행 시작일") String startDate, + @Schema(description = "여행 종료일") String endDate, + @Schema(description = "D-DAY") Integer dDay, + @Schema(description = "여행의 총 스탬프 수") int totalStamps, + @Schema(description = "완료된 총 스탬프 수") int completedStamps, + @Schema(description = "진행률") Integer progress, + @Schema(description = "여행 완료 여부") boolean completed, + @Schema(description = "여행에 속한 스탬프 목록") List stamps) { + public static LoadTripDetailResponse of(TripInfo tripInfo, List stampInfos) { + return new LoadTripDetailResponse( + tripInfo.tripId(), + tripInfo.tripName(), + tripInfo.tripMemo(), + tripInfo.tripCategory(), + tripInfo.startDate(), + tripInfo.endDate(), + tripInfo.dDay(), + tripInfo.totalStamps(), + tripInfo.completedStamps(), + tripInfo.progress(), + tripInfo.completed(), + stampInfos.stream().map(LoadStampDetailResponse::of).toList()); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadTripsSliceResponse.java b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadTripsSliceResponse.java new file mode 100644 index 0000000..2bf8936 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/presentation/dto/response/LoadTripsSliceResponse.java @@ -0,0 +1,13 @@ +package com.ject.studytrip.trip.presentation.dto.response; + +import com.ject.studytrip.trip.application.dto.TripInfo; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +public record LoadTripsSliceResponse( + @Schema(description = "여행 목록") List tripInfos, + @Schema(description = "다음 데이터 존재 여부") boolean hasNext) { + public static LoadTripsSliceResponse of(List infos, boolean hasNext) { + return new LoadTripsSliceResponse(infos, hasNext); + } +} diff --git a/src/test/java/com/ject/studytrip/StudytripApplicationTests.java b/src/test/java/com/ject/studytrip/StudytripApplicationTests.java index 4d574f5..c943a9f 100644 --- a/src/test/java/com/ject/studytrip/StudytripApplicationTests.java +++ b/src/test/java/com/ject/studytrip/StudytripApplicationTests.java @@ -4,8 +4,10 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; @ActiveProfiles("test") +@TestPropertySource(locations = "file:.env") @AutoConfigureTestDatabase( replace = AutoConfigureTestDatabase.Replace.NONE) // 테스트 시 내장된 인메모리 DB를 사용하지 않는다는 설정 @SpringBootTest diff --git a/src/test/java/com/ject/studytrip/auth/fixture/TokenFixture.java b/src/test/java/com/ject/studytrip/auth/fixture/TokenFixture.java index e90baff..6a641f6 100644 --- a/src/test/java/com/ject/studytrip/auth/fixture/TokenFixture.java +++ b/src/test/java/com/ject/studytrip/auth/fixture/TokenFixture.java @@ -7,6 +7,7 @@ public class TokenFixture { "this-is-a-test-secret-key-which-is-long-enough-1234567890"; public static final long ACCESS_EXPIRATION_TIME = 7200; public static final long REFRESH_EXPIRATION_TIME = 604800; + public static final String TOKEN_PREFIX = "Bearer "; public static TokenProperties createTokenProperties() { return new TokenProperties(TEST_SECRET, ACCESS_EXPIRATION_TIME, REFRESH_EXPIRATION_TIME); diff --git a/src/test/java/com/ject/studytrip/auth/helper/TokenTestHelper.java b/src/test/java/com/ject/studytrip/auth/helper/TokenTestHelper.java new file mode 100644 index 0000000..2704fad --- /dev/null +++ b/src/test/java/com/ject/studytrip/auth/helper/TokenTestHelper.java @@ -0,0 +1,20 @@ +package com.ject.studytrip.auth.helper; + +import com.ject.studytrip.auth.infra.provider.TokenProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class TokenTestHelper { + + private final TokenProvider tokenProvider; + + @Autowired + public TokenTestHelper(TokenProvider tokenProvider) { + this.tokenProvider = tokenProvider; + } + + public String createAccessToken(String memberId, String role) { + return tokenProvider.createAccessToken(memberId, role); + } +} diff --git a/src/test/java/com/ject/studytrip/member/fixture/MemberFixture.java b/src/test/java/com/ject/studytrip/member/fixture/MemberFixture.java index 64a9788..d4ba52b 100644 --- a/src/test/java/com/ject/studytrip/member/fixture/MemberFixture.java +++ b/src/test/java/com/ject/studytrip/member/fixture/MemberFixture.java @@ -4,6 +4,7 @@ import com.ject.studytrip.member.domain.entity.MemberCategory; import com.ject.studytrip.member.domain.entity.MemberRole; import com.ject.studytrip.member.domain.entity.SocialProvider; +import org.springframework.test.util.ReflectionTestUtils; public class MemberFixture { private static final String NICKNAME = "민우"; @@ -20,6 +21,32 @@ public static Member createMemberFromKakao() { MemberRole.ROLE_USER); } + public static Member createMemberFromKakao(String email, String nickname) { + return Member.of( + SocialProvider.KAKAO, + "12345", + email, + nickname, + "https://kakao.com/profile.jpg", + MEMBER_CATEGORY, + MemberRole.ROLE_USER); + } + + public static Member createMemberFromKakaoWithId(Long id) { + Member member = + Member.of( + SocialProvider.KAKAO, + "12345", + "choi@kakao.com", + NICKNAME, + "https://kakao.com/profile.jpg", + MEMBER_CATEGORY, + MemberRole.ROLE_USER); + ReflectionTestUtils.setField(member, "id", id); + + return member; + } + public static Member createMemberWithoutProfileImageFromKakao() { return Member.of( SocialProvider.KAKAO, diff --git a/src/test/java/com/ject/studytrip/member/helper/MemberTestHelper.java b/src/test/java/com/ject/studytrip/member/helper/MemberTestHelper.java index 0a2b4db..5835a39 100644 --- a/src/test/java/com/ject/studytrip/member/helper/MemberTestHelper.java +++ b/src/test/java/com/ject/studytrip/member/helper/MemberTestHelper.java @@ -15,8 +15,13 @@ public MemberTestHelper(MemberRepository memberRepository) { this.memberRepository = memberRepository; } - public void saveMember() { + public Member saveMember() { Member member = MemberFixture.createMemberFromKakao(); - memberRepository.save(member); + return memberRepository.save(member); + } + + public Member saveMember(String email, String nickname) { + Member member = MemberFixture.createMemberFromKakao(email, nickname); + return memberRepository.save(member); } } diff --git a/src/test/java/com/ject/studytrip/stamp/application/service/StampServiceTest.java b/src/test/java/com/ject/studytrip/stamp/application/service/StampServiceTest.java new file mode 100644 index 0000000..9adda95 --- /dev/null +++ b/src/test/java/com/ject/studytrip/stamp/application/service/StampServiceTest.java @@ -0,0 +1,223 @@ +package com.ject.studytrip.stamp.application.service; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +import com.ject.studytrip.BaseUnitTest; +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.member.domain.entity.Member; +import com.ject.studytrip.member.fixture.MemberFixture; +import com.ject.studytrip.stamp.domain.error.StampErrorCode; +import com.ject.studytrip.stamp.domain.factory.StampFactory; +import com.ject.studytrip.stamp.domain.model.Stamp; +import com.ject.studytrip.stamp.domain.repository.StampRepository; +import com.ject.studytrip.stamp.fixture.CreateStampRequestFixture; +import com.ject.studytrip.stamp.presentation.dto.request.CreateStampRequest; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripCategory; +import com.ject.studytrip.trip.fixture.TripFixture; +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +@DisplayName("StampService 단위 테스트") +public class StampServiceTest extends BaseUnitTest { + private static final String STAMP_NAME = "STAMP NAME"; + private static final LocalDate STAMP_DEAD_LINE = LocalDate.now().plusDays(7); + + @InjectMocks private StampService stampService; + @Mock private StampRepository stampRepository; + + private Trip courseTrip; + private Trip exploreTrip; + + @BeforeEach + void setup() { + Member member = MemberFixture.createMemberFromKakao(); + courseTrip = TripFixture.createTrip(member, TripCategory.COURSE); + exploreTrip = TripFixture.createTrip(member, TripCategory.EXPLORE); + } + + @Nested + @DisplayName("스탬프를 생성한다") + class CreateStamp { + + @Test + @DisplayName("코스형 여행의 유효한 스탬프 리스트를 넘기면 저장된다") + void shouldCreateStampsForCourseTrip() { + // given + List requests = List.of(new CreateStampRequestFixture().build()); + + // when + stampService.createStamps(courseTrip, requests); + + // then + verify(stampRepository).saveAll(anyList()); + } + + @Test + @DisplayName("탐험형 여행의 유효한 스탬프 리스트를 넘기면 저장된다") + void shouldCreateStampsForExploreTrip() { + // given + List requests = + List.of(new CreateStampRequestFixture().withStampOrder(0).build()); + + // when + stampService.createStamps(exploreTrip, requests); + + // then + verify(stampRepository).saveAll(anyList()); + } + + @Test + @DisplayName("스탬프의 마감일이 과거라면 예외가 발생한다") + void shouldThrowExceptionWhenStampDeadlineCannotBeInPast() { + // given + List requests = + List.of( + new CreateStampRequestFixture() + .withDeadline(LocalDate.now().minusDays(1)) + .build()); + + // when & then + assertThatThrownBy(() -> stampService.createStamps(courseTrip, requests)) + .isInstanceOf(CustomException.class) + .hasMessage(StampErrorCode.STAMP_DEADLINE_CANNOT_BE_IN_PAST.getMessage()); + } + + @Test + @DisplayName("스탬프의 마감일이 여행 종료일보다 이후일 경우 예외가 발생한다") + void shouldThrowExceptionWhenStampDeadlineIsAfterTripEndDate() { + // given + List requests = + List.of( + new CreateStampRequestFixture() + .withDeadline(courseTrip.getEndDate().plusDays(1)) + .build()); + + // when & then + assertThatThrownBy(() -> stampService.createStamps(courseTrip, requests)) + .isInstanceOf(CustomException.class) + .hasMessage(StampErrorCode.STAMP_DEADLINE_EXCEEDS_TRIP_END_DATE.getMessage()); + } + + @Test + @DisplayName("탐험형 여행에서 순서가 1 이상이면 예외가 발생한다") + void shouldThrowExceptionWhenOrderExistsInExploreTrip() { + // given + List requests = + List.of(new CreateStampRequestFixture().withStampOrder(1).build()); + + // when & then + assertThatThrownBy(() -> stampService.createStamps(exploreTrip, requests)) + .isInstanceOf(CustomException.class) + .hasMessage( + StampErrorCode.INVALID_STAMP_ORDER_FOR_EXPLORATION_TRIP.getMessage()); + } + + @Test + @DisplayName("코스형 여행에서 순서가 1 미만 또는 총 개수 초과라면 예외가 발생한다") + void shouldThrowExceptionWhenStampOrderIsOutOfRangeForCourseTrip() { + // given + List requests = + List.of(new CreateStampRequestFixture().withStampOrder(2).build()); + + // when & then + assertThatThrownBy(() -> stampService.createStamps(courseTrip, requests)) + .isInstanceOf(CustomException.class) + .hasMessage( + StampErrorCode.INVALID_STAMP_ORDER_RANGE_FOR_COURSE_TRIP.getMessage()); + } + + @Test + @DisplayName("코스형 여행에서 순서가 중복되면 예외가 발생한다") + void shouldThrowExceptionWhenDuplicateOrderInCourseTrip() { + // given + List requests = + List.of( + new CreateStampRequestFixture().withStampOrder(1).build(), + new CreateStampRequestFixture().withStampOrder(1).build()); + + // when & then + assertThatThrownBy(() -> stampService.createStamps(courseTrip, requests)) + .isInstanceOf(CustomException.class) + .hasMessage(StampErrorCode.DUPLICATE_STAMP_ORDER_FOR_COURSE_TRIP.getMessage()); + } + } + + @Nested + @DisplayName("스탬프를 수정한다") + class UpdateStamp { + + @Test + @DisplayName("여행의 카테고리가 탐험형으로 수정되면 소속된 모든 스탬프의 순서를 0으로 수정한다") + void shouldSetAllStampOrdersToZeroWhenCategoryChangesToExplore() { + // given + Stamp stamp = spy(StampFactory.create(courseTrip, STAMP_NAME, 1, STAMP_DEAD_LINE)); + List stamps = List.of(stamp); + + given(stampRepository.findAllByTripIdOrderByDeadlineAsc(courseTrip.getId())) + .willReturn(stamps); + + // when + stampService.updateStampsOrderByTripCategoryChange( + courseTrip.getId(), TripCategory.EXPLORE); + + // then + assertThat(stamp.getStampOrder()).isEqualTo(0); + } + + @Test + @DisplayName("여행의 카테고리가 코스형으로 수정되면 소속된 모든 스탬프의 순서를 마감일이 이른 순으로 1부터 순차적으로 순서를 수정한다") + void shouldSetSequentialStampOrdersWhenCategoryChangesToCourse() { + // given + Stamp stamp1 = + spy( + StampFactory.create( + exploreTrip, STAMP_NAME, 0, STAMP_DEAD_LINE.plusDays(1))); + Stamp stamp2 = spy(StampFactory.create(exploreTrip, STAMP_NAME, 0, STAMP_DEAD_LINE)); + List stamps = + Stream.of(stamp1, stamp2) + .sorted(Comparator.comparing(Stamp::getDeadline)) + .collect(Collectors.toList()); + + given(stampRepository.findAllByTripIdOrderByDeadlineAsc(exploreTrip.getId())) + .willReturn(stamps); + + // when + stampService.updateStampsOrderByTripCategoryChange( + exploreTrip.getId(), TripCategory.COURSE); + + // then + assertThat(stamp2.getStampOrder()).isEqualTo(1); + assertThat(stamp1.getStampOrder()).isEqualTo(2); + } + } + + @Nested + @DisplayName("스탬프 목록을 조회한다") + class ListStamps { + + @Test + @DisplayName("유효한 여행 ID로 스탬프 목록을 조회한다") + void shouldGetStampsByTripId() { + // when + stampService.getStampsByTripId(courseTrip.getId()); + + // then + verify(stampRepository).findAllByTripId(any()); + } + } +} diff --git a/src/test/java/com/ject/studytrip/stamp/fixture/CreateStampRequestFixture.java b/src/test/java/com/ject/studytrip/stamp/fixture/CreateStampRequestFixture.java new file mode 100644 index 0000000..96d1ec9 --- /dev/null +++ b/src/test/java/com/ject/studytrip/stamp/fixture/CreateStampRequestFixture.java @@ -0,0 +1,30 @@ +package com.ject.studytrip.stamp.fixture; + +import com.ject.studytrip.stamp.presentation.dto.request.CreateStampRequest; +import java.time.LocalDate; + +public class CreateStampRequestFixture { + + private String name = "TEST STAMP"; + private int stampOrder = 1; + private LocalDate deadline = LocalDate.now().plusDays(7); + + public CreateStampRequestFixture withName(String name) { + this.name = name; + return this; + } + + public CreateStampRequestFixture withStampOrder(int stampOrder) { + this.stampOrder = stampOrder; + return this; + } + + public CreateStampRequestFixture withDeadline(LocalDate deadline) { + this.deadline = deadline; + return this; + } + + public CreateStampRequest build() { + return new CreateStampRequest(name, stampOrder, deadline); + } +} diff --git a/src/test/java/com/ject/studytrip/stamp/fixture/StampFixture.java b/src/test/java/com/ject/studytrip/stamp/fixture/StampFixture.java new file mode 100644 index 0000000..1226b31 --- /dev/null +++ b/src/test/java/com/ject/studytrip/stamp/fixture/StampFixture.java @@ -0,0 +1,14 @@ +package com.ject.studytrip.stamp.fixture; + +import com.ject.studytrip.stamp.domain.model.Stamp; +import com.ject.studytrip.trip.domain.model.Trip; +import java.time.LocalDate; + +public class StampFixture { + private static final String STAMP_NAME = "TEST STAMP NAME"; + private static final LocalDate STAMP_DEAD_LINE = LocalDate.now().plusDays(7); + + public static Stamp createStamp(Trip trip, int order) { + return Stamp.of(trip, STAMP_NAME, order, STAMP_DEAD_LINE); + } +} diff --git a/src/test/java/com/ject/studytrip/trip/application/service/TripServiceTest.java b/src/test/java/com/ject/studytrip/trip/application/service/TripServiceTest.java new file mode 100644 index 0000000..df83d78 --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/application/service/TripServiceTest.java @@ -0,0 +1,286 @@ +package com.ject.studytrip.trip.application.service; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.*; + +import com.ject.studytrip.BaseUnitTest; +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.member.domain.entity.Member; +import com.ject.studytrip.member.fixture.MemberFixture; +import com.ject.studytrip.trip.domain.error.TripErrorCode; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripCategory; +import com.ject.studytrip.trip.domain.repository.TripQueryRepository; +import com.ject.studytrip.trip.domain.repository.TripRepository; +import com.ject.studytrip.trip.fixture.CreateTripRequestFixture; +import com.ject.studytrip.trip.fixture.TripFixture; +import com.ject.studytrip.trip.fixture.UpdateTripRequestFixture; +import com.ject.studytrip.trip.presentation.dto.request.CreateTripRequest; +import com.ject.studytrip.trip.presentation.dto.request.UpdateTripRequest; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; + +@DisplayName("TripService 단위 테스트") +public class TripServiceTest extends BaseUnitTest { + private static final String TRIP_NAME = "TRIP NAME UPDATED"; + private static final int DEFAULT_PAGE = 0; + private static final int DEFAULT_SIZE = 5; + + @InjectMocks private TripService tripService; + @Mock private TripRepository tripRepository; + @Mock private TripQueryRepository tripQueryRepository; + + private Member member; + private Trip trip; + + @BeforeEach + void setup() { + member = MemberFixture.createMemberFromKakaoWithId(1L); + trip = TripFixture.createTripWithId(1L, member); + } + + @Nested + @DisplayName("여행을 생성한다") + class CreateTrip { + + @Test + @DisplayName("여행 정보를 생성해 DB에 저장하고, 저장된 Trip을 반환한다") + void shouldSaveTripReturnTrip() { + // given + CreateTripRequest request = new CreateTripRequestFixture().build(); + given(tripRepository.save(any())).willReturn(trip); + + // when + Trip saved = tripService.createTrip(member, request); + + // then + assertThat(saved).isNotNull(); + assertThat(saved.getId()).isEqualTo(trip.getId()); + } + + @Test + @DisplayName("여행 카테고리가 누락되었을 경우 예외가 발생한다") + void shouldThrowExceptionWhenMissingTripCategory() { + // given + CreateTripRequest request = new CreateTripRequestFixture().withCategory(null).build(); + + // when & then + assertThatThrownBy(() -> tripService.createTrip(member, request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(TripErrorCode.TRIP_CATEGORY_REQUIRED.getMessage()); + } + + @Test + @DisplayName("여행 카테고리가 유효하지 않은 경우 예외가 발생한다") + void shouldThrowExceptionWhenInvalidTripCategory() { + // given + CreateTripRequest request = new CreateTripRequestFixture().withCategory("test").build(); + + // when & then + assertThatThrownBy(() -> tripService.createTrip(member, request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(TripErrorCode.INVALID_TRIP_CATEGORY.getMessage()); + } + + @Test + @DisplayName("코스형 여행인데 종료일이 null일 경우 예외가 발생한다") + void shouldThrowExceptionWhenCourseTripEndDateIsNull() { + // given + CreateTripRequest request = + new CreateTripRequestFixture() + .withCategory(TripCategory.COURSE.name()) + .withEndDate(null) + .build(); + + // When & Then + assertThatThrownBy(() -> tripService.createTrip(member, request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(TripErrorCode.COURSE_TRIP_END_DATE_REQUIRED.getMessage()); + } + + @Test + @DisplayName("여행 종료일이 시작일보다 이전일 경우 예외가 발생한다") + void shouldThrowExceptionWhenEndDateIsBeforeStartDate() { + // given + CreateTripRequest request = + new CreateTripRequestFixture() + .withEndDate(LocalDate.now().minusDays(7)) + .build(); + + // When & Then + assertThatThrownBy(() -> tripService.createTrip(member, request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining( + TripErrorCode.TRIP_END_DATE_BEFORE_START_DATE.getMessage()); + } + + @Test + @DisplayName("함께 등록할 여행 스탬프 목록이 비어있으면 예외가 발생한다") + void shouldThrowExceptionWhenStampsIsEmpty() { + // given + CreateTripRequest request = + new CreateTripRequestFixture().withStamps(List.of()).build(); + + // When & Then + assertThatThrownBy(() -> tripService.createTrip(member, request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(TripErrorCode.TRIP_STAMP_REQUIRED.getMessage()); + } + } + + @Nested + @DisplayName("여행을 수정한다") + class UpdateTrip { + + @Test + @DisplayName("특정 여행의 정보를 수정하고 DB에 반영한다") + void shouldUpdateTrip() { + // given + UpdateTripRequest request = new UpdateTripRequestFixture().withName(TRIP_NAME).build(); + + // When + tripService.updateTrip(member.getId(), trip, request); + + // Then + assertThat(trip.getName()).isEqualTo(TRIP_NAME); + } + + @Test + @DisplayName("수정할 권한이 없는 사용자일 경우 예외가 발생한다") + void shouldThrowExceptionWhenNoPermission() { + // given + Member newMember = MemberFixture.createMemberFromKakao(); + UpdateTripRequest request = new UpdateTripRequestFixture().build(); + + // When & Then + assertThatThrownBy(() -> tripService.updateTrip(newMember.getId(), trip, request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(TripErrorCode.NOT_TRIP_OWNER.getMessage()); + } + + @Test + @DisplayName("여행 종료일이 시작일보다 이전일 경우 예외가 발생한다") + void shouldThrowExceptionWhenEndDateIsBeforeStartDate() { + // given + UpdateTripRequest request = + new UpdateTripRequestFixture() + .withEndDate(trip.getStartDate().minusDays(1)) + .build(); + + // When & Then + assertThatThrownBy(() -> tripService.updateTrip(member.getId(), trip, request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining( + TripErrorCode.TRIP_END_DATE_BEFORE_START_DATE.getMessage()); + } + + @Test + @DisplayName("이미 삭제된 여행일 경우 예외가 발생한다") + void shouldThrowExceptionWhenAlreadyDeleted() { + // given + Trip deleted = TripFixture.createDeletedTrip(member); + UpdateTripRequest request = new UpdateTripRequestFixture().build(); + + // When & Then + assertThatThrownBy(() -> tripService.updateTrip(member.getId(), deleted, request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(TripErrorCode.TRIP_ALREADY_DELETED.getMessage()); + } + } + + @Nested + @DisplayName("특정 여행을 삭제한다") + class DeleteTrip { + + @Test + @DisplayName("특정 여행을 deletedAt 필드를 현재 시간으로 업데이트한다") + void shouldDeleteTripForUpdateDeletedAt() { + // when + tripService.deleteTrip(member.getId(), trip); + + // then + assertThat(trip.getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("삭제할 권한이 없는 경우 예외가 발생한다") + void shouldThrowExceptionWhenNoPermission() { + // given + Member newMember = MemberFixture.createMemberFromKakao(); + + // when & then + assertThatThrownBy(() -> tripService.deleteTrip(newMember.getId(), trip)) + .isInstanceOf(CustomException.class) + .hasMessage(TripErrorCode.NOT_TRIP_OWNER.getMessage()); + } + } + + @Nested + @DisplayName("특정 여행의 정보를 조회한다") + class GetTrip { + + @Test + @DisplayName("특정 여행 ID로 DB에서 조회한 후 반환한다") + void shouldGetTripByTripIdReturnTrip() { + // given + given(tripRepository.findById(trip.getId())).willReturn(Optional.of(trip)); + + // when + Trip result = tripService.getTrip(trip.getId()); + + // then + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo(trip.getId()); + } + + @Test + @DisplayName("이미 삭제된 여행일 경우 예외가 발생한다") + void shouldThrowExceptionWhenAlreadyTrip() { + // given + Trip deleted = TripFixture.createDeletedTrip(member); + given(tripRepository.findById(any())).willReturn(Optional.of(deleted)); + + // when & then + assertThatThrownBy(() -> tripService.getTrip(trip.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(TripErrorCode.TRIP_ALREADY_DELETED.getMessage()); + } + } + + @Nested + @DisplayName("여행 목록을 조회한다") + class ListTrips { + + @Test + @DisplayName("로그인된 사용자의 여행 목록을 DB에서 조회하고 슬라이스 처리해 반환한다") + void shouldGetTripsReturnSlicePaged() { + // given + List trips = List.of(trip); + Pageable pageable = PageRequest.of(DEFAULT_PAGE, DEFAULT_SIZE); + Slice results = new SliceImpl<>(trips, pageable, false); + given(tripQueryRepository.findSliceByMemberId(member.getId(), pageable)) + .willReturn(results); + + // when + Slice sliceTrips = + tripService.getTripsSliceByMemberId(member.getId(), DEFAULT_PAGE, DEFAULT_SIZE); + + // then + assertThat(sliceTrips.hasContent()).isTrue(); + assertThat(sliceTrips.hasNext()).isFalse(); + } + } +} diff --git a/src/test/java/com/ject/studytrip/trip/fixture/CreateTripRequestFixture.java b/src/test/java/com/ject/studytrip/trip/fixture/CreateTripRequestFixture.java new file mode 100644 index 0000000..d63eb22 --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/fixture/CreateTripRequestFixture.java @@ -0,0 +1,49 @@ +package com.ject.studytrip.trip.fixture; + +import com.ject.studytrip.stamp.fixture.CreateStampRequestFixture; +import com.ject.studytrip.stamp.presentation.dto.request.CreateStampRequest; +import com.ject.studytrip.trip.domain.model.TripCategory; +import com.ject.studytrip.trip.presentation.dto.request.CreateTripRequest; +import java.time.LocalDate; +import java.util.List; + +public class CreateTripRequestFixture { + + private String name = "TEST 여행"; + private String memo = "TEST 여행입니다."; + private String category = TripCategory.COURSE.name(); + private LocalDate endDate = LocalDate.now().plusDays(10); + private List stamps = + List.of( + new CreateStampRequestFixture().build(), + new CreateStampRequestFixture().withStampOrder(2).build()); + + public CreateTripRequestFixture withName(String name) { + this.name = name; + return this; + } + + public CreateTripRequestFixture withMemo(String memo) { + this.memo = memo; + return this; + } + + public CreateTripRequestFixture withEndDate(LocalDate endDate) { + this.endDate = endDate; + return this; + } + + public CreateTripRequestFixture withCategory(String category) { + this.category = category; + return this; + } + + public CreateTripRequestFixture withStamps(List stamps) { + this.stamps = stamps; + return this; + } + + public CreateTripRequest build() { + return new CreateTripRequest(name, memo, category, endDate, stamps); + } +} diff --git a/src/test/java/com/ject/studytrip/trip/fixture/TripFixture.java b/src/test/java/com/ject/studytrip/trip/fixture/TripFixture.java new file mode 100644 index 0000000..30bd278 --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/fixture/TripFixture.java @@ -0,0 +1,49 @@ +package com.ject.studytrip.trip.fixture; + +import com.ject.studytrip.member.domain.entity.Member; +import com.ject.studytrip.trip.domain.factory.TripFactory; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripCategory; +import java.time.LocalDate; +import org.springframework.test.util.ReflectionTestUtils; + +public class TripFixture { + private static final String TRIP_NAME = "TEST TRIP NAME"; + private static final String TRIP_MEMO = "TEST TRIP MEMO"; + private static final TripCategory TRIP_CATEGORY_COURSE = TripCategory.COURSE; + private static final LocalDate TRIP_END_DATE = LocalDate.now().plusDays(7); + private static final int TRIP_TOTAL_STAMPS = 1; + + public static Trip createTrip(Member member, TripCategory category) { + return TripFactory.create( + member, TRIP_NAME, TRIP_MEMO, category, TRIP_END_DATE, TRIP_TOTAL_STAMPS); + } + + public static Trip createTripWithId(Long id, Member member) { + Trip trip = + TripFactory.create( + member, + TRIP_NAME, + TRIP_MEMO, + TRIP_CATEGORY_COURSE, + TRIP_END_DATE, + TRIP_TOTAL_STAMPS); + ReflectionTestUtils.setField(trip, "id", id); + + return trip; + } + + public static Trip createDeletedTrip(Member member) { + Trip deleted = + TripFactory.create( + member, + TRIP_NAME, + TRIP_MEMO, + TRIP_CATEGORY_COURSE, + TRIP_END_DATE, + TRIP_TOTAL_STAMPS); + deleted.updateDeletedAt(); + + return deleted; + } +} diff --git a/src/test/java/com/ject/studytrip/trip/fixture/UpdateTripRequestFixture.java b/src/test/java/com/ject/studytrip/trip/fixture/UpdateTripRequestFixture.java new file mode 100644 index 0000000..daa2045 --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/fixture/UpdateTripRequestFixture.java @@ -0,0 +1,36 @@ +package com.ject.studytrip.trip.fixture; + +import com.ject.studytrip.trip.presentation.dto.request.UpdateTripRequest; +import java.time.LocalDate; + +public class UpdateTripRequestFixture { + + private String name = null; + private String memo = null; + private String category = null; + private LocalDate endDate = null; + + public UpdateTripRequestFixture withName(String name) { + this.name = name; + return this; + } + + public UpdateTripRequestFixture withMemo(String memo) { + this.memo = memo; + return this; + } + + public UpdateTripRequestFixture withEndDate(LocalDate endDate) { + this.endDate = endDate; + return this; + } + + public UpdateTripRequestFixture withCategory(String category) { + this.category = category; + return this; + } + + public UpdateTripRequest build() { + return new UpdateTripRequest(name, memo, category, endDate); + } +} diff --git a/src/test/java/com/ject/studytrip/trip/helper/TripTestHelper.java b/src/test/java/com/ject/studytrip/trip/helper/TripTestHelper.java new file mode 100644 index 0000000..ac8fabf --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/helper/TripTestHelper.java @@ -0,0 +1,26 @@ +package com.ject.studytrip.trip.helper; + +import com.ject.studytrip.member.domain.entity.Member; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripCategory; +import com.ject.studytrip.trip.domain.repository.TripRepository; +import com.ject.studytrip.trip.fixture.TripFixture; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class TripTestHelper { + + @Autowired private TripRepository tripRepository; + + public Trip saveTrip(Member member, TripCategory category) { + Trip trip = TripFixture.createTrip(member, category); + return tripRepository.save(trip); + } + + public Trip saveDeletedTrip(Member member, TripCategory category) { + Trip trip = TripFixture.createTrip(member, category); + trip.updateDeletedAt(); + return tripRepository.save(trip); + } +} diff --git a/src/test/java/com/ject/studytrip/trip/presentation/controller/TripControllerIntegrationTest.java b/src/test/java/com/ject/studytrip/trip/presentation/controller/TripControllerIntegrationTest.java new file mode 100644 index 0000000..8b925c8 --- /dev/null +++ b/src/test/java/com/ject/studytrip/trip/presentation/controller/TripControllerIntegrationTest.java @@ -0,0 +1,677 @@ +package com.ject.studytrip.trip.presentation.controller; + +import static com.ject.studytrip.auth.fixture.TokenFixture.TOKEN_PREFIX; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.ject.studytrip.BaseIntegrationTest; +import com.ject.studytrip.auth.domain.error.AuthErrorCode; +import com.ject.studytrip.auth.helper.TokenTestHelper; +import com.ject.studytrip.global.common.response.StandardResponse; +import com.ject.studytrip.global.exception.error.CommonErrorCode; +import com.ject.studytrip.member.domain.entity.Member; +import com.ject.studytrip.member.helper.MemberTestHelper; +import com.ject.studytrip.stamp.domain.error.StampErrorCode; +import com.ject.studytrip.stamp.fixture.CreateStampRequestFixture; +import com.ject.studytrip.stamp.presentation.dto.request.CreateStampRequest; +import com.ject.studytrip.trip.domain.error.TripErrorCode; +import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripCategory; +import com.ject.studytrip.trip.fixture.CreateTripRequestFixture; +import com.ject.studytrip.trip.fixture.UpdateTripRequestFixture; +import com.ject.studytrip.trip.helper.TripTestHelper; +import com.ject.studytrip.trip.presentation.dto.request.CreateTripRequest; +import com.ject.studytrip.trip.presentation.dto.request.UpdateTripRequest; +import com.ject.studytrip.trip.presentation.dto.response.LoadTripCategoryResponse; +import com.ject.studytrip.trip.presentation.dto.response.LoadTripDetailResponse; +import com.ject.studytrip.trip.presentation.dto.response.LoadTripsSliceResponse; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.ResultActions; + +@DisplayName("TripController 통합 테스트") +public class TripControllerIntegrationTest extends BaseIntegrationTest { + private static final String TRIP_CATEGORY_COURSE = TripCategory.COURSE.name(); + private static final String TRIP_CATEGORY_EXPLORE = TripCategory.EXPLORE.name(); + + @Autowired private TokenTestHelper tokenTestHelper; + @Autowired private MemberTestHelper memberTestHelper; + @Autowired private TripTestHelper tripTestHelper; + + private Member member; + private Trip trip; + private String token; + + @BeforeEach + void setup() { + member = memberTestHelper.saveMember(); + token = + tokenTestHelper.createAccessToken( + member.getId().toString(), member.getRole().name()); + trip = tripTestHelper.saveTrip(member, TripCategory.COURSE); + } + + @Nested + @DisplayName("여행 카테고리 목록 조회 API") + class GetTripCategory { + + private ResultActions getResultActions() throws Exception { + return mockMvc.perform(get("/api/trips/categories")); + } + + @Test + @DisplayName("여행 카테고리 종류를 조회한다") + void shouldGetTripCategories() throws Exception { + // when + ResultActions resultActions = getResultActions(); + + // then + resultActions.andExpect(status().isOk()); + + StandardResponse response = parseResponse(resultActions, StandardResponse.class); + Object rawData = response.data(); + List categoryResponses = + objectMapper.convertValue(rawData, List.class); + + assertThat(categoryResponses.isEmpty()).isFalse(); + assertThat(categoryResponses.size()).isEqualTo(2); + } + } + + @Nested + @DisplayName("여행 생성 API") + class CreateTrip { + + private ResultActions getResultActions(String token, CreateTripRequest request) + throws Exception { + return mockMvc.perform( + post("/api/trips") + .header(HttpHeaders.AUTHORIZATION, TOKEN_PREFIX + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } + + @Test + @DisplayName("유효한 요청으로 여행 정보를 생성한다") + void shouldCreateTrip() throws Exception { + // given + CreateTripRequest request = new CreateTripRequestFixture().build(); + + // when + ResultActions resultActions = getResultActions(token, request); + + // then + resultActions.andExpect(status().isCreated()); + } + + @Test + @DisplayName("인증되지 않은 사용자일 경우 401 예외가 발생한다") + void shouldThrowExceptionWhenUnauthenticated() throws Exception { + // given + CreateTripRequest request = new CreateTripRequestFixture().build(); + + // when + ResultActions resultActions = getResultActions("", request); + + // then + resultActions.andExpect(status().is(AuthErrorCode.UNAUTHENTICATED.getStatus().value())); + } + + @Test + @DisplayName("여행을 생성하는데 필요한 필수 요청 값이 누락되거나 유효하지 않으면 400 예외가 발생한다") + void shouldThrowExceptionWhenInvalidRequiredFields() throws Exception { + // given + CreateTripRequest nonTripNameRequest = + new CreateTripRequestFixture() + .withName("") + .withCategory("test") + .withEndDate(null) + .build(); + + // when + ResultActions resultActions = getResultActions(token, nonTripNameRequest); + + // then + resultActions.andExpect( + status().is(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.getStatus().value())); + } + + @Test + @DisplayName("여행 종료일이 과거일 경우 400 예외가 발생한다") + void shouldThrowExceptionWhenEndDateIsInThePast() throws Exception { + // given + CreateTripRequest invalidDateRequest = + new CreateTripRequestFixture() + .withEndDate(LocalDate.now().minusDays(10)) + .build(); + + // when + ResultActions resultActions = getResultActions(token, invalidDateRequest); + + // then + resultActions.andExpect( + status().is(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.getStatus().value())); + } + + @Test + @DisplayName("여행의 카테고리가 코스형이고 종료일이 존재하지 않으면 400 예외가 발생한다") + void shouldThrowExceptionWhenCourseTripHasNoEndDate() throws Exception { + // given + CreateTripRequest request = + new CreateTripRequestFixture() + .withCategory(TRIP_CATEGORY_COURSE) + .withEndDate(null) + .build(); + + // when + ResultActions resultActions = getResultActions(token, request); + + // then + resultActions.andExpect( + status().is(TripErrorCode.COURSE_TRIP_END_DATE_REQUIRED.getStatus().value())); + } + + @Test + @DisplayName("함께 요청한 여행의 스탬프 목록이 없으면 400 예외가 발생한다") + void shouldThrowExceptionWhenRequestStampsIsEmpty() throws Exception { + // given + CreateTripRequest request = + new CreateTripRequestFixture().withStamps(List.of()).build(); + + // when + ResultActions resultActions = getResultActions(token, request); + + // when & then + resultActions.andExpect( + status().is(TripErrorCode.TRIP_STAMP_REQUIRED.getStatus().value())); + } + + @Test + @DisplayName("스탬프 마감일이 과거일 경우 400 예외가 발생한다") + void shouldThrowExceptionWhenStampDeadlineCannotBeInPast() throws Exception { + // given + List stampRequests = + List.of( + new CreateStampRequestFixture() + .withDeadline(LocalDate.now().minusDays(1)) + .build()); + CreateTripRequest request = + new CreateTripRequestFixture().withStamps(stampRequests).build(); + + // when + ResultActions resultActions = getResultActions(token, request); + + // then + resultActions.andExpect( + status().is( + StampErrorCode.STAMP_DEADLINE_CANNOT_BE_IN_PAST + .getStatus() + .value())); + } + + @Test + @DisplayName("스탬프 마감일이 여행의 종료일보다 이후일 경우 400 예외가 발생한다") + void shouldThrowExceptionWhenStampDeadlineIsAfterTripEndDate() throws Exception { + // given + LocalDate tripEndDate = LocalDate.now().plusDays(1); + List stampRequests = + List.of( + new CreateStampRequestFixture() + .withDeadline(tripEndDate.plusDays(1)) + .build()); + CreateTripRequest request = + new CreateTripRequestFixture() + .withEndDate(tripEndDate) + .withStamps(stampRequests) + .build(); + + // when + ResultActions resultActions = getResultActions(token, request); + + // then + resultActions.andExpect( + status().is( + StampErrorCode.STAMP_DEADLINE_EXCEEDS_TRIP_END_DATE + .getStatus() + .value())); + } + + @Test + @DisplayName("탐험형 여행을 선택하고 스탬프 순서가 존재할 경우 400 예외가 발생한다") + void shouldThrowExceptionWhenStampOrderExistsInExplorationTrip() throws Exception { + // given + List stampRequests = + List.of(new CreateStampRequestFixture().build()); + CreateTripRequest request = + new CreateTripRequestFixture() + .withCategory(TRIP_CATEGORY_EXPLORE) + .withStamps(stampRequests) + .build(); + + // when + ResultActions resultActions = getResultActions(token, request); + + // when & then + resultActions.andExpect( + status().is( + StampErrorCode.INVALID_STAMP_ORDER_FOR_EXPLORATION_TRIP + .getStatus() + .value())); + } + + @Test + @DisplayName("코스형 여행을 선택하고 스탬프 순서가 1 미만 또는 최대 총 개수를 초과하면 400 예외가 발생한다") + void shouldThrowExceptionWhenStampOrderOutOfRangeInCourseTrip() throws Exception { + // given + List stampRequests = + List.of(new CreateStampRequestFixture().withStampOrder(2).build()); + CreateTripRequest request = + new CreateTripRequestFixture().withStamps(stampRequests).build(); + + // when + ResultActions resultActions = getResultActions(token, request); + + // when & then + resultActions.andExpect( + status().is( + StampErrorCode.INVALID_STAMP_ORDER_RANGE_FOR_COURSE_TRIP + .getStatus() + .value())); + } + + @Test + @DisplayName("코스형 여행을 선택하고 스탬프 순서에 중복이 존재할 경우 400 예외가 발생한다") + void shouldThrowExceptionWhenDuplicateStampOrderInCourseTrip() throws Exception { + // given + List stampRequests = + List.of( + new CreateStampRequestFixture().withStampOrder(1).build(), + new CreateStampRequestFixture().withStampOrder(1).build()); + CreateTripRequest request = + new CreateTripRequestFixture().withStamps(stampRequests).build(); + + // when + ResultActions resultActions = getResultActions(token, request); + + // when & then + resultActions.andExpect( + status().is( + StampErrorCode.DUPLICATE_STAMP_ORDER_FOR_COURSE_TRIP + .getStatus() + .value())); + } + } + + @Nested + @DisplayName("여행 수정 API") + class UpdateTrip { + + private ResultActions getResultActions( + String token, Object tripId, UpdateTripRequest request) throws Exception { + return mockMvc.perform( + patch("/api/trips/{tripId}", tripId) + .header(HttpHeaders.AUTHORIZATION, TOKEN_PREFIX + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } + + @Test + @DisplayName("특정 여행 정보를 수정한다") + void shouldUpdateTrip() throws Exception { + // given + UpdateTripRequest request = new UpdateTripRequestFixture().withName("여행 이름 수정").build(); + + // when + ResultActions resultActions = getResultActions(token, trip.getId(), request); + + // then + resultActions.andExpect(status().isOk()); + } + + @Test + @DisplayName("인증되지 않은 사용자일 경우 401 예외가 발생한다") + void shouldThrowExceptionWhenUnauthenticated() throws Exception { + // given + UpdateTripRequest request = new UpdateTripRequestFixture().build(); + + // when + ResultActions resultActions = getResultActions("", trip.getId(), request); + + // then + resultActions.andExpect(status().is(AuthErrorCode.UNAUTHENTICATED.getStatus().value())); + } + + @Test + @DisplayName("PathVariable 여행 ID 타입이 올바르지 않으면 400 예외가 발생한다") + void shouldThrowExceptionWhenTripIdTypeMismatch() throws Exception { + // given + String tripId = "abc"; + UpdateTripRequest request = + new UpdateTripRequestFixture() + .withEndDate(LocalDate.now().minusDays(7)) + .build(); + + // when + ResultActions resultActions = getResultActions(token, tripId, request); + + // then + resultActions.andExpect( + status().is(CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH.getStatus().value())); + } + + @Test + @DisplayName("수정 요청한 여행 종료일이 과거일 경우 400 예외가 발생한다") + void shouldThrowExceptionWhenEndDateIsInThePast() throws Exception { + // given + UpdateTripRequest request = + new UpdateTripRequestFixture() + .withEndDate(LocalDate.now().minusDays(7)) + .build(); + + // when + ResultActions resultActions = getResultActions(token, trip.getId(), request); + + // then + resultActions.andExpect( + status().is(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.getStatus().value())); + } + + @Test + @DisplayName("유효하지 않은 여행 ID 라면 404 예외가 발생한다") + void shouldThrowExceptionWhenInvalidTripId() throws Exception { + // given + Long tripId = 10000L; + UpdateTripRequest request = new UpdateTripRequestFixture().build(); + + // when + ResultActions resultActions = getResultActions(token, tripId, request); + + // when & then + resultActions.andExpect(status().is(TripErrorCode.TRIP_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("수정할 권한이 없으면 403 예외가 발생한다") + void shouldThrowExceptionWhenUpdatingTripWithoutPermission() throws Exception { + // given + Member newMember = memberTestHelper.saveMember("test@gmail.com", "test"); + Trip newTrip = tripTestHelper.saveTrip(newMember, TripCategory.COURSE); + UpdateTripRequest request = new UpdateTripRequestFixture().build(); + + // when + ResultActions resultActions = getResultActions(token, newTrip.getId(), request); + + // then + resultActions.andExpect(status().is(TripErrorCode.NOT_TRIP_OWNER.getStatus().value())); + } + + @Test + @DisplayName("수정 요청한 여행 정보가 이미 삭제된 여행이라면 400 예외가 발생한다") + void shouldThrowExceptionWhenAlreadyDeletedTrip() throws Exception { + // given + Trip deletedTrip = tripTestHelper.saveDeletedTrip(member, TripCategory.COURSE); + UpdateTripRequest request = new UpdateTripRequestFixture().build(); + + // when + ResultActions resultActions = getResultActions(token, deletedTrip.getId(), request); + + // then + resultActions.andExpect( + status().is(TripErrorCode.TRIP_ALREADY_DELETED.getStatus().value())); + } + + @Test + @DisplayName("종료 날짜가 시작 날짜보다 이전인 경우 400 예외가 발생한다") + void shouldThrowExceptionWhenUpdatingTripWithInvalidEndDate() throws Exception { + // given + UpdateTripRequest request = + new UpdateTripRequestFixture() + .withEndDate(trip.getStartDate().minusDays(1)) + .build(); + + // when + ResultActions resultActions = getResultActions(token, trip.getId(), request); + + // when & then + resultActions.andExpect( + status().is(TripErrorCode.TRIP_END_DATE_BEFORE_START_DATE.getStatus().value())); + } + } + + @Nested + @DisplayName("여행 삭제 API") + class DeleteTrip { + + private ResultActions getResultActions(String token, Object tripId) throws Exception { + return mockMvc.perform( + delete("/api/trips/{tripId}", tripId) + .header(HttpHeaders.AUTHORIZATION, TOKEN_PREFIX + token)); + } + + @Test + @DisplayName("특정 여행 정보를 삭제한다") + void shouldDeleteTrip() throws Exception { + // when + ResultActions resultActions = getResultActions(token, trip.getId()); + + // then + resultActions.andExpect(status().isOk()); + } + + @Test + @DisplayName("인증되지 않은 사용자일 경우 401 예외가 발생한다") + void shouldThrowExceptionWhenUnauthenticated() throws Exception { + // when + ResultActions resultActions = getResultActions("", trip.getId()); + + // then + resultActions.andExpect(status().is(AuthErrorCode.UNAUTHENTICATED.getStatus().value())); + } + + @Test + @DisplayName("PathVariable 여행 ID 타입이 올바르지 않으면 400 예외가 발생한다") + void shouldThrowExceptionWhenTripIdTypeMismatch() throws Exception { + // given + String tripId = "abc"; + + // when + ResultActions resultActions = getResultActions(token, tripId); + + // then + resultActions.andExpect( + status().is(CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH.getStatus().value())); + } + + @Test + @DisplayName("유효하지 않은 여행 ID 라면 404 예외가 발생한다") + void shouldThrowExceptionWhenInvalidTripId() throws Exception { + // given + Long tripId = 10000L; + + // when + ResultActions resultActions = getResultActions(token, tripId); + + // when & then + resultActions.andExpect(status().is(TripErrorCode.TRIP_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("여행을 삭제할 권한이 없으면 403 예외가 발생한다") + void shouldThrowExceptionWhenNoPermission() throws Exception { + // given + Member newMember = memberTestHelper.saveMember("test@gmail.com", "test"); + Trip newTrip = tripTestHelper.saveTrip(newMember, TripCategory.COURSE); + + // when + ResultActions resultActions = getResultActions(token, newTrip.getId()); + + // when & then + resultActions.andExpect(status().is(TripErrorCode.NOT_TRIP_OWNER.getStatus().value())); + } + } + + @Nested + @DisplayName("여행 상세 조회 API") + class GetTrip { + private ResultActions getResultActions(String token, Object tripId) throws Exception { + return mockMvc.perform( + get("/api/trips/{tripId}", tripId) + .header(HttpHeaders.AUTHORIZATION, TOKEN_PREFIX + token)); + } + + @Test + @DisplayName("여행 ID로 특정 여행을 상세 조회한다") + void shouldLoadTripByTripId() throws Exception { + // When + ResultActions resultActions = getResultActions(token, trip.getId()); + + // Then + resultActions.andExpect(status().isOk()); + + StandardResponse response = parseResponse(resultActions, StandardResponse.class); + Object rawData = response.data(); + LoadTripDetailResponse detailResponse = + objectMapper.convertValue(rawData, LoadTripDetailResponse.class); + + assertThat(detailResponse.tripId()).isEqualTo(trip.getId()); + } + + @Test + @DisplayName("인증되지 않은 사용자일 경우 401 예외가 발생한다") + void shouldThrowExceptionWhenUnauthenticated() throws Exception { + // When + ResultActions resultActions = getResultActions("", trip.getId()); + + // Then + resultActions.andExpect(status().is(AuthErrorCode.UNAUTHENTICATED.getStatus().value())); + } + + @Test + @DisplayName("PathVariable 여행 ID 값의 타입이 올바르지 않으면 400 예외가 발생한다") + void shouldThrowExceptionWhenTripIdTypeMissMatch() throws Exception { + // given + String tripId = "abc"; + + // when + ResultActions resultActions = getResultActions(token, tripId); + + // then + resultActions.andExpect( + status().is(CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH.getStatus().value())); + } + + @Test + @DisplayName("유효하지 않은 여행 ID 라면 404 예외가 발생한다") + void shouldThrowExceptionWhenInvalidTripId() throws Exception { + // given + Long tripId = 10000L; + + // when + ResultActions resultActions = getResultActions(token, tripId); + + // when & then + resultActions.andExpect(status().is(TripErrorCode.TRIP_NOT_FOUND.getStatus().value())); + } + + @Test + @DisplayName("이미 삭제된 여행일 경우 400 예외가 발생한다") + void shouldThrowExceptionWhenAlreadyDeleted() throws Exception { + // given + Trip deleted = tripTestHelper.saveDeletedTrip(member, TripCategory.COURSE); + + // when + ResultActions resultActions = getResultActions(token, deleted.getId()); + + // when & then + resultActions.andExpect( + status().is(TripErrorCode.TRIP_ALREADY_DELETED.getStatus().value())); + } + } + + @Nested + @DisplayName("여행 목록 조회 API") + class ListTrips { + private ResultActions getResultActions(String token, String page, String size) + throws Exception { + return mockMvc.perform( + get("/api/trips") + .param("page", page) + .param("size", size) + .header(HttpHeaders.AUTHORIZATION, TOKEN_PREFIX + token)); + } + + @Test + @DisplayName("로그인한 사용자의 여행 목록을 조회하고 슬라이스 처리한다") + void shouldLoadTripsWithSlicePaging() throws Exception { + // Given + String page = "0"; + String size = "5"; + + // When + ResultActions resultActions = getResultActions(token, page, size); + + // Then + resultActions.andExpect(status().isOk()); + + StandardResponse response = parseResponse(resultActions, StandardResponse.class); + Object rawData = response.data(); + LoadTripsSliceResponse sliceResponse = + objectMapper.convertValue(rawData, LoadTripsSliceResponse.class); + + assertThat(sliceResponse.tripInfos().size()).isEqualTo(1); + assertThat(sliceResponse.hasNext()).isFalse(); + } + + @Test + @DisplayName("인증되지 않은 사용자일 경우 401 예외가 발생한다") + void shouldThrowExceptionWhenUnauthenticated() throws Exception { + // Given + String page = "0"; + String size = "5"; + + // when + ResultActions resultActions = getResultActions("", page, size); + + // Then + resultActions.andExpect(status().is(AuthErrorCode.UNAUTHENTICATED.getStatus().value())); + } + + @Test + @DisplayName("페이징 파라미터 타입이 올바르지 않으면 400 예외가 발생한다") + void shouldThrowExceptionWhenPagingParameterTypeMismatch() throws Exception { + // Given + String page = "test"; + String size = "test"; + + // when + ResultActions resultActions = getResultActions(token, page, size); + + // Then + resultActions.andExpect( + status().is(CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH.getStatus().value())); + } + + @Test + @DisplayName("페이징 파라미터가 유효하지 않으면 400 예외가 발생한다") + void shouldThrowExceptionWhenPagingParameterIsInvalid() throws Exception { + // Given + String page = "-1"; + String size = "100"; + + // when + ResultActions resultActions = getResultActions(token, page, size); + + // Then + resultActions.andExpect( + status().is(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.getStatus().value())); + } + } +}