From 0af8a93331ef816adaa21529093df1830d257236 Mon Sep 17 00:00:00 2001 From: jeongjiho Date: Fri, 24 Apr 2026 19:31:26 +0900 Subject: [PATCH 1/8] =?UTF-8?q?[#223]=20feat:=20travel=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=84=B0=EB=A7=81=20=EA=B5=AC=EC=A1=B0=EB=A5=BC=20use?= =?UTF-8?q?case/reader/command=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../group/application/GroupReader.java | 19 + .../group/application/GroupUseCase.java | 2 +- .../travel/application/TravelCommand.java | 114 ++++++ .../travel/application/TravelReader.java | 64 ++++ .../travel/application/TravelUseCase.java | 144 ++++++++ .../travel/entity/TravelItinerary.java | 51 ++- .../travel/entity/UserTravelItinerary.java | 9 + .../exception/TravelItineraryErrorCode.java | 3 +- .../UserTravelItineraryErrorCode.java | 4 +- .../TravelItineraryController.java | 56 ++- .../dto/req}/TravelItineraryAddUserDto.java | 2 +- .../req}/TravelItinerarySaveRequestDto.java | 2 +- .../req}/TravelItineraryUpdateRequestDto.java | 2 +- .../TravelItineraryCursorResponseDto.java | 2 +- .../res}/TravelItineraryInfoResponseDto.java | 2 +- .../res}/TravelItinerarySaveResponseDto.java | 2 +- .../TravelItineraryJpaRepository.java | 34 +- .../UserTravelItineraryJpaRepository.java | 24 ++ .../TravelItineraryCustomRepository.java | 9 + .../TravelItineraryCustomRepositoryImpl.java | 48 +++ .../service/TravelItineraryService.java | 337 ------------------ .../backend/user/application/UserReader.java | 10 + 22 files changed, 504 insertions(+), 436 deletions(-) create mode 100644 src/main/java/org/triple/backend/travel/application/TravelCommand.java create mode 100644 src/main/java/org/triple/backend/travel/application/TravelReader.java create mode 100644 src/main/java/org/triple/backend/travel/application/TravelUseCase.java rename src/main/java/org/triple/backend/travel/{controller => presentation}/TravelItineraryController.java (58%) rename src/main/java/org/triple/backend/travel/{dto/request => presentation/dto/req}/TravelItineraryAddUserDto.java (54%) rename src/main/java/org/triple/backend/travel/{dto/request => presentation/dto/req}/TravelItinerarySaveRequestDto.java (94%) rename src/main/java/org/triple/backend/travel/{dto/request => presentation/dto/req}/TravelItineraryUpdateRequestDto.java (85%) rename src/main/java/org/triple/backend/travel/{dto/response => presentation/dto/res}/TravelItineraryCursorResponseDto.java (96%) rename src/main/java/org/triple/backend/travel/{dto/response => presentation/dto/res}/TravelItineraryInfoResponseDto.java (95%) rename src/main/java/org/triple/backend/travel/{dto/response => presentation/dto/res}/TravelItinerarySaveResponseDto.java (79%) create mode 100644 src/main/java/org/triple/backend/travel/repository/custom/TravelItineraryCustomRepository.java create mode 100644 src/main/java/org/triple/backend/travel/repository/custom/TravelItineraryCustomRepositoryImpl.java delete mode 100644 src/main/java/org/triple/backend/travel/service/TravelItineraryService.java diff --git a/src/main/java/org/triple/backend/group/application/GroupReader.java b/src/main/java/org/triple/backend/group/application/GroupReader.java index e8439cb8..db25c5ea 100644 --- a/src/main/java/org/triple/backend/group/application/GroupReader.java +++ b/src/main/java/org/triple/backend/group/application/GroupReader.java @@ -25,19 +25,33 @@ public class GroupReader { private final GroupJpaRepository groupJpaRepository; private final UserGroupJpaRepository userGroupJpaRepository; private final JoinApplyJpaRepository joinApplyJpaRepository; + public Group findGroup(Long groupId) { return groupJpaRepository.findById(groupId) .orElseThrow(() -> new BusinessException(GROUP_NOT_FOUND)); } + public Group findGroupForUpdate(Long groupId) { return groupJpaRepository.findByIdForUpdate(groupId) .orElseThrow(() -> new BusinessException(GROUP_NOT_FOUND)); } + public UserGroup findUserGroupByRole(Long groupId, Long userId, Role role) { return userGroupJpaRepository.findByGroupIdAndUserIdAndRoleAndJoinStatus( groupId, userId, role, JoinStatus.JOINED) .orElseThrow(() -> new BusinessException(NOT_AUTHORIZED)); } + + public UserGroup findJoinedUserGroup(Long groupId, Long userId) { + return userGroupJpaRepository.findByGroupIdAndUserIdAndJoinStatus( + groupId, userId, JoinStatus.JOINED) + .orElseThrow(() -> new BusinessException(NOT_GROUP_MEMBER)); + } + + public boolean existsJoinedUserGroup(Long groupId, Long userId) { + return userGroupJpaRepository.existsByGroupIdAndUserIdAndJoinStatus(groupId, userId, JoinStatus.JOINED); + } + public UserGroup findKickTarget(Long groupId, Long ownerId, Long targetUserId) { if (ownerId.equals(targetUserId)) throw new BusinessException(CANNOT_KICK_SELF); @@ -45,12 +59,15 @@ public UserGroup findKickTarget(Long groupId, Long ownerId, Long targetUserId) { return findUserGroupByRole(groupId, targetUserId, Role.MEMBER); } + public UserGroup findLeaveTarget(Long groupId, Long userId) { return findUserGroupByRole(groupId, userId, Role.MEMBER); } + public List readMyGroups(Long cursor, int pageSize, Long userId) { return groupJpaRepository.findMyGroups(userId, pageSize, cursor); } + public GroupMenuRes readMenu(Long userId, Long groupId) { Group group = findGroup(groupId); UserGroup userGroup = userId == null ? null : userGroupJpaRepository @@ -64,6 +81,7 @@ public GroupMenuRes readMenu(Long userId, Long groupId) { Role role = userGroup == null ? Role.GUEST : userGroup.getRole(); return GroupMenuRes.of(group, role); } + public List readGroupUsers(Long groupId) { Group group = findGroup(groupId); if (group.getGroupKind() == GroupKind.PRIVATE) { @@ -72,6 +90,7 @@ public List readGroupUsers(Long groupId) { return userGroupJpaRepository.findAllByGroupIdAndJoinStatus(groupId, JoinStatus.JOINED); } + public JoinApply findJoinApplyByIdAndGroupIdWithUser(Long joinApplyId, Long groupId) { return joinApplyJpaRepository.findByIdAndGroupIdAndJoinApplyStatus(joinApplyId, groupId, JoinApplyStatus.PENDING) .orElseThrow(() -> new BusinessException(JOIN_APPLY_NOT_FOUND)); diff --git a/src/main/java/org/triple/backend/group/application/GroupUseCase.java b/src/main/java/org/triple/backend/group/application/GroupUseCase.java index 6cfa3014..7ce6c8d4 100644 --- a/src/main/java/org/triple/backend/group/application/GroupUseCase.java +++ b/src/main/java/org/triple/backend/group/application/GroupUseCase.java @@ -128,7 +128,7 @@ public GroupDetailRes detail(final Long groupId, final Long userId) { } Pageable detailRecentPage = PageRequest.of(0, DETAIL_RECENT_SIZE); - int travelCount = Math.toIntExact(travelItineraryJpaRepository.countByGroupIdAndIsDeletedFalse(groupId)); + int travelCount = Math.toIntExact(travelItineraryJpaRepository.countByGroupId(groupId)); List recentTravels; if (myRole == Role.GUEST) { recentTravels = List.of(); diff --git a/src/main/java/org/triple/backend/travel/application/TravelCommand.java b/src/main/java/org/triple/backend/travel/application/TravelCommand.java new file mode 100644 index 00000000..9db705b4 --- /dev/null +++ b/src/main/java/org/triple/backend/travel/application/TravelCommand.java @@ -0,0 +1,114 @@ +package org.triple.backend.travel.application; + +import lombok.RequiredArgsConstructor; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.triple.backend.global.error.BusinessException; +import org.triple.backend.group.domain.group.Group; +import org.triple.backend.travel.entity.TravelItinerary; +import org.triple.backend.travel.entity.UserRole; +import org.triple.backend.travel.entity.UserTravelItinerary; +import org.triple.backend.travel.exception.TravelItineraryErrorCode; +import org.triple.backend.travel.exception.UserTravelItineraryErrorCode; +import org.triple.backend.travel.repository.TravelItineraryJpaRepository; +import org.triple.backend.travel.repository.TravelReviewImageJpaRepository; +import org.triple.backend.travel.repository.TravelReviewJpaRepository; +import org.triple.backend.travel.repository.UserTravelItineraryJpaRepository; +import org.triple.backend.user.domain.User; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class TravelCommand { + private final TravelReader travelReader; + private final TravelItineraryJpaRepository travelItineraryJpaRepository; + private final TravelReviewJpaRepository travelReviewJpaRepository; + private final TravelReviewImageJpaRepository travelReviewImageJpaRepository; + private final UserTravelItineraryJpaRepository userTravelItineraryJpaRepository; + + public TravelItinerary saveTravelItinerary( + final User leader, + final Group group, + final String title, + final LocalDateTime startAt, + final LocalDateTime endAt, + final String description, + final List members + ) { + TravelItinerary travelItinerary = TravelItinerary.of(title, startAt, endAt, group, description); + TravelItinerary savedTravelItinerary = travelItineraryJpaRepository.save(travelItinerary); + + userTravelItineraryJpaRepository.save(UserTravelItinerary.of(leader, savedTravelItinerary, UserRole.LEADER)); + + List memberTravelItineraries = new ArrayList<>(); + for (User member : members) { + memberTravelItineraries.add(UserTravelItinerary.of(member, savedTravelItinerary, UserRole.MEMBER)); + } + userTravelItineraryJpaRepository.saveAll(memberTravelItineraries); + + savedTravelItinerary.increaseMemberCount(memberTravelItineraries.size()); + return savedTravelItinerary; + } + + public void addUserTravelItinerary( + final User user, + final TravelItinerary travelItinerary, + final UserRole role + ) { + try { + travelItinerary.increaseMemberCount(1); + userTravelItineraryJpaRepository.save(UserTravelItinerary.of(user, travelItinerary, role)); + travelItineraryJpaRepository.flush(); + } catch (DataIntegrityViolationException e) { + throw new BusinessException(UserTravelItineraryErrorCode.ALREADY_JOINED_TRAVEL); + } catch (OptimisticLockingFailureException e) { + throw new BusinessException(UserTravelItineraryErrorCode.CONCURRENT_TRAVEL_ITINERARY); + } + } + + public void updateTravel(final Long travelItineraryId, final String title, final LocalDateTime startAt, + final LocalDateTime endAt, final String description) { + TravelItinerary travel = travelReader.findTravel(travelItineraryId); + + try { + travel.changeTitle(title); + travel.changeStartAt(startAt); + travel.changeEndAt(endAt); + travel.changeDescription(description); + travelItineraryJpaRepository.flush(); + } catch (OptimisticLockingFailureException e) { + throw new BusinessException(TravelItineraryErrorCode.CONCURRENT_TRAVEL_ITINERARY_UPDATE); + } + } + + public void deleteTravel(final TravelItinerary travelItinerary) { + try { + userTravelItineraryJpaRepository.bulkDeleteByTravelId(travelItinerary.getId()); + travelItineraryJpaRepository.delete(travelItinerary); + travelItineraryJpaRepository.flush(); + } catch (OptimisticLockingFailureException e) { + throw new BusinessException(TravelItineraryErrorCode.CONCURRENT_TRAVEL_ITINERARY_DELETE); + } + } + + public void leaveTravel(final Long travelId, final Long userId) { + UserTravelItinerary userTravelItinerary = userTravelItineraryJpaRepository + .findByUserIdAndTravelItineraryIdAndUserRole(userId, travelId, UserRole.MEMBER) + .orElseThrow(() -> new BusinessException(UserTravelItineraryErrorCode.USER_TRAVEL_ITINERARY_NOT_FOUND)); + + TravelItinerary travelItinerary = userTravelItinerary.getTravelItinerary(); + + try { + travelItinerary.decreaseMemberCount(); + userTravelItineraryJpaRepository.delete(userTravelItinerary); + userTravelItineraryJpaRepository.flush(); + } catch (OptimisticLockingFailureException e) { + throw new BusinessException(UserTravelItineraryErrorCode.CONCURRENT_TRAVEL_ITINERARY_LEAVE); + } + } +} diff --git a/src/main/java/org/triple/backend/travel/application/TravelReader.java b/src/main/java/org/triple/backend/travel/application/TravelReader.java new file mode 100644 index 00000000..452f316c --- /dev/null +++ b/src/main/java/org/triple/backend/travel/application/TravelReader.java @@ -0,0 +1,64 @@ +package org.triple.backend.travel.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.triple.backend.global.error.BusinessException; +import org.triple.backend.global.error.ErrorCode; +import org.triple.backend.travel.entity.TravelItinerary; +import org.triple.backend.travel.entity.UserRole; +import org.triple.backend.travel.entity.UserTravelItinerary; +import org.triple.backend.travel.exception.TravelItineraryErrorCode; +import org.triple.backend.travel.exception.UserTravelItineraryErrorCode; +import org.triple.backend.travel.presentation.dto.res.TravelItineraryInfoResponseDto; +import org.triple.backend.travel.presentation.dto.res.TravelItineraryCursorResponseDto.TravelSummaryDto; +import org.triple.backend.travel.repository.TravelItineraryJpaRepository; +import org.triple.backend.travel.repository.TravelReviewImageJpaRepository; +import org.triple.backend.travel.repository.TravelReviewJpaRepository; +import org.triple.backend.travel.repository.UserTravelItineraryJpaRepository; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class TravelReader { + private final TravelItineraryJpaRepository travelItineraryJpaRepository; + private final TravelReviewJpaRepository travelReviewJpaRepository; + private final TravelReviewImageJpaRepository travelReviewImageJpaRepository; + private final UserTravelItineraryJpaRepository userTravelItineraryJpaRepository; + + public long countGroupTravels(final Long groupId) { + return travelItineraryJpaRepository.countByGroupId(groupId); + } + + public List readGroupTravels(final Long groupId, final int size, final Long cursor) { + return travelItineraryJpaRepository.findGroupTravels(groupId, size, cursor); + } + + public TravelItineraryInfoResponseDto getTravelInfo(final TravelItinerary travelItinerary) { + List members = userTravelItineraryJpaRepository.findAllByTravelItineraryId(travelItinerary.getId()); + return TravelItineraryInfoResponseDto.of(travelItinerary, members); + } + + public TravelItinerary findTravel(final Long travelId) { + return travelItineraryJpaRepository.findById(travelId) + .orElseThrow(() -> new BusinessException(TravelItineraryErrorCode.TRAVEL_NOT_FOUND)); + } + + public UserTravelItinerary findUserTravelByStatus( + final Long userId, + final Long travelId, + final UserRole userRole + ) { + if (userRole == null) { + return userTravelItineraryJpaRepository.findByUserIdAndTravelItineraryId(userId, travelId) + .orElseThrow(() -> new BusinessException(UserTravelItineraryErrorCode.USER_TRAVEL_ITINERARY_NOT_FOUND)); + } + return userTravelItineraryJpaRepository.findByUserIdAndTravelItineraryIdAndUserRole(userId, travelId, userRole) + .orElseThrow(() -> new BusinessException(TravelItineraryErrorCode.USER_TRAVEL_NOT_FOUND)); + } + + public TravelItinerary findTravelForUpdate(Long travelId) { + return travelItineraryJpaRepository.findByIdAndIsDeletedFalseForUpdate(travelId) + .orElseThrow(() -> new BusinessException(TravelItineraryErrorCode.TRAVEL_NOT_FOUND)); + } +} diff --git a/src/main/java/org/triple/backend/travel/application/TravelUseCase.java b/src/main/java/org/triple/backend/travel/application/TravelUseCase.java new file mode 100644 index 00000000..429217ee --- /dev/null +++ b/src/main/java/org/triple/backend/travel/application/TravelUseCase.java @@ -0,0 +1,144 @@ +package org.triple.backend.travel.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.triple.backend.auth.crypto.UserIdentityResolver; +import org.triple.backend.auth.jwt.JwtManager; +import org.triple.backend.global.error.BusinessException; +import org.triple.backend.group.application.GroupReader; +import org.triple.backend.group.domain.group.Group; +import org.triple.backend.travel.entity.TravelItinerary; +import org.triple.backend.travel.entity.UserRole; +import org.triple.backend.travel.exception.UserTravelItineraryErrorCode; +import org.triple.backend.travel.presentation.dto.req.TravelItineraryAddUserDto; +import org.triple.backend.travel.presentation.dto.req.TravelItinerarySaveRequestDto; +import org.triple.backend.travel.presentation.dto.req.TravelItineraryUpdateRequestDto; +import org.triple.backend.travel.presentation.dto.res.TravelItineraryCursorResponseDto; +import org.triple.backend.travel.presentation.dto.res.TravelItineraryCursorResponseDto.TravelSummaryDto; +import org.triple.backend.travel.presentation.dto.res.TravelItineraryInfoResponseDto; +import org.triple.backend.travel.presentation.dto.res.TravelItinerarySaveResponseDto; +import org.triple.backend.user.application.UserReader; +import org.triple.backend.user.domain.User; +import org.triple.backend.user.exception.UserErrorCode; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class TravelUseCase { + private final UserReader userReader; + private final GroupReader groupReader; + private final TravelReader travelReader; + private final TravelCommand travelCommand; + private final UserIdentityResolver userIdentityResolver; + private final JwtManager jwtManager; + + @Transactional + public TravelItinerarySaveResponseDto saveTravelItinerary(final TravelItinerarySaveRequestDto req, final Long userId) { + User leader = userReader.findUserById(userId); + Group group = groupReader.findGroup(req.groupId()); + + groupReader.findJoinedUserGroup(req.groupId(), userId); + + List memberIds = req.memberUuids().stream().map(userIdentityResolver::resolve).toList(); + List members = userReader.findUsersInIds(memberIds).stream() + .filter(member -> !member.getId().equals(leader.getId())).toList(); + + TravelItinerary savedTravelItinerary = travelCommand.saveTravelItinerary(leader, group, req.title(), + req.startAt(), req.endAt(), req.description(), members); + + return TravelItinerarySaveResponseDto.from(savedTravelItinerary.getId()); + } + + @Transactional + public void joinTravel(final Long travelId, final Long userId) { + TravelItinerary travelItinerary = travelReader.findTravel(travelId); + + groupReader.findJoinedUserGroup(travelItinerary.getGroup().getId(), userId); + + User user = userReader.findUserById(userId); + + travelCommand.addUserTravelItinerary(user, travelItinerary, UserRole.MEMBER); + } + + @Transactional + public void addTravelUser(final Long travelId, final Long userId, final TravelItineraryAddUserDto req) { + TravelItinerary travelItinerary = travelReader.findTravel(travelId); + + travelReader.findUserTravelByStatus(userId, travelItinerary.getId(), UserRole.LEADER); + + Long addUserId = resolveMemberUserIdOrThrow(req.userUuid()); + User addUser = userReader.findUserById(addUserId); + + travelCommand.addUserTravelItinerary(addUser, travelItinerary, UserRole.MEMBER); + } + + @Transactional + public void updateTravel(final TravelItineraryUpdateRequestDto req, final Long travelId, final Long userId) { + travelReader.findUserTravelByStatus(userId, travelId, UserRole.LEADER); + + travelCommand.updateTravel(travelId, req.title(), req.startAt(), req.endAt(), req.description()); + } + + @Transactional + public void deleteTravel(final Long travelId, final Long userId) { + TravelItinerary travelItinerary = travelReader.findTravelForUpdate(travelId); + + travelReader.findUserTravelByStatus(userId, travelId, UserRole.LEADER); + + travelCommand.deleteTravel(travelItinerary); + } + + @Transactional(readOnly = true) + public TravelItineraryCursorResponseDto browseTravels(final Long groupId, final Long cursor, final int size, + final Long userId) { + long count = travelReader.countGroupTravels(groupId); + + if (userId == null) { + return TravelItineraryCursorResponseDto.countOnly(count); + } + if (!groupReader.existsJoinedUserGroup(groupId, userId)) { + return TravelItineraryCursorResponseDto.countOnly(count); + } + + int pageSize = Math.min(Math.max(size, 1), 10); + List rows = travelReader.readGroupTravels(groupId, pageSize, cursor); + + boolean hasNext = rows.size() > pageSize; + Long nextCursor = null; + if (hasNext) { + nextCursor = rows.get(rows.size() - 1).id(); + rows = rows.subList(0, pageSize); + } + + return new TravelItineraryCursorResponseDto(rows, nextCursor, hasNext, count); + } + + @Transactional + public void leaveTravel(final Long travelId, final Long userId) { + travelCommand.leaveTravel(travelId, userId); + } + + @Transactional(readOnly = true) + public TravelItineraryInfoResponseDto getTravelInfo(final Long travelId, final Long userId) { + TravelItinerary travelItinerary = travelReader.findTravel(travelId); + groupReader.findJoinedUserGroup(travelItinerary.getGroup().getId(), userId); + return travelReader.getTravelInfo(travelItinerary); + } + + @Transactional(readOnly = true) + public String issueSecondaryToken(final Long travelId, final Long userId) { + travelReader.findTravel(travelId); + travelReader.findUserTravelByStatus(userId, travelId, null); + return jwtManager.createSecondaryToken(userId, travelId); + } + + private Long resolveMemberUserIdOrThrow(final String memberUuid) { + Long memberUserId = userIdentityResolver.resolve(memberUuid); + if (memberUserId == null) { + throw new BusinessException(UserErrorCode.USER_NOT_FOUND); + } + return memberUserId; + } +} diff --git a/src/main/java/org/triple/backend/travel/entity/TravelItinerary.java b/src/main/java/org/triple/backend/travel/entity/TravelItinerary.java index c84f35cb..651182c6 100644 --- a/src/main/java/org/triple/backend/travel/entity/TravelItinerary.java +++ b/src/main/java/org/triple/backend/travel/entity/TravelItinerary.java @@ -4,8 +4,6 @@ import lombok.*; import org.triple.backend.global.common.BaseEntity; import org.triple.backend.group.domain.group.Group; -import org.triple.backend.travel.dto.request.TravelItinerarySaveRequestDto; -import org.triple.backend.travel.dto.request.TravelItineraryUpdateRequestDto; import java.time.LocalDateTime; import java.util.ArrayList; @@ -36,8 +34,6 @@ public class TravelItinerary extends BaseEntity { private int memberCount = 1; - private boolean isDeleted; - @OneToMany(mappedBy = "travelItinerary") private List userTravelItineraries = new ArrayList<>(); @@ -51,8 +47,7 @@ public TravelItinerary( LocalDateTime endAt, Group group, String description, - int memberCount, - boolean isDeleted + int memberCount ) { this.title = validateTitle(title); this.startAt = validateStartAt(startAt); @@ -61,7 +56,6 @@ public TravelItinerary( this.group = validateGroup(group); this.description = description; this.memberCount = memberCount; - this.isDeleted = isDeleted; } public void decreaseMemberCount() { @@ -71,52 +65,47 @@ public void decreaseMemberCount() { this.memberCount--; } - public void updateTravelItinerary(final TravelItineraryUpdateRequestDto travelItineraryUpdateRequestDto) { - updateTitle(travelItineraryUpdateRequestDto.title()); - updateStartAt(travelItineraryUpdateRequestDto.startAt()); - updateEndAt(travelItineraryUpdateRequestDto.endAt()); - validateDateOrder(this.startAt, this.endAt); - updateDescription(travelItineraryUpdateRequestDto.description()); + public void increaseMemberCount(int count) { + this.memberCount += count; } - public void deleteTravelItinerary() { - this.isDeleted = true; - } - - public void increaseMemberCount() { - this.memberCount++; - } - - private void updateTitle(String title) { + public void changeTitle(final String title) { if (title == null) return; this.title = validateTitle(title); } - private void updateStartAt(LocalDateTime startAt) { + public void changeStartAt(final LocalDateTime startAt) { if (startAt == null) return; this.startAt = validateStartAt(startAt); + validateDateOrder(this.startAt, this.endAt); } - private void updateEndAt(LocalDateTime endAt) { + public void changeEndAt(final LocalDateTime endAt) { if (endAt == null) return; this.endAt = validateEndAt(endAt); + validateDateOrder(this.startAt, this.endAt); } - private void updateDescription(String description) { + public void changeDescription(final String description) { if (description == null) return; this.description = description; } //첫 생성 정적 팩터리 메서드 - public static TravelItinerary of(final TravelItinerarySaveRequestDto travelsRequestDto, final Group group) { + public static TravelItinerary of( + final String title, + final LocalDateTime startAt, + final LocalDateTime endAt, + final Group group, + final String description + ) { return TravelItinerary.builder() - .title(travelsRequestDto.title()) - .startAt(travelsRequestDto.startAt()) - .endAt(travelsRequestDto.endAt()) + .title(title) + .startAt(startAt) + .endAt(endAt) .group(group) - .description(travelsRequestDto.description()) + .description(description) .memberCount(1) - .isDeleted(false) .build(); } diff --git a/src/main/java/org/triple/backend/travel/entity/UserTravelItinerary.java b/src/main/java/org/triple/backend/travel/entity/UserTravelItinerary.java index 310c7e08..f26cf8c2 100644 --- a/src/main/java/org/triple/backend/travel/entity/UserTravelItinerary.java +++ b/src/main/java/org/triple/backend/travel/entity/UserTravelItinerary.java @@ -9,6 +9,15 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "user_travel_itinerary", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_user_travel_itinerary_user_id_travel_itinerary_id", + columnNames = {"user_id", "travel_itinerary_id"} + ) + } +) public class UserTravelItinerary { @Id diff --git a/src/main/java/org/triple/backend/travel/exception/TravelItineraryErrorCode.java b/src/main/java/org/triple/backend/travel/exception/TravelItineraryErrorCode.java index 01d18d8a..ce450122 100644 --- a/src/main/java/org/triple/backend/travel/exception/TravelItineraryErrorCode.java +++ b/src/main/java/org/triple/backend/travel/exception/TravelItineraryErrorCode.java @@ -12,7 +12,8 @@ public enum TravelItineraryErrorCode implements ErrorCode { CONCURRENT_TRAVEL_ITINERARY_UPDATE(HttpStatus.CONFLICT, "여행 동시 수정은 불가능합니다. 다시 시도해주세요."), CONCURRENT_TRAVEL_ITINERARY_DELETE(HttpStatus.CONFLICT, "여행이 이미 삭제되었습니다."), CONCURRENT_TRAVEL_ITINERARY_JOIN(HttpStatus.CONFLICT, "여행 참가 처리 중 동시성 충돌이 발생했습니다. 다시 시도해주세요."), - CONCURRENT_TRAVEL_ITINERARY_ADD_USER(HttpStatus.CONFLICT, "여행 멤버 추가 처리 중 동시성 충돌이 발생했습니다. 다시 시도해주세요."); + CONCURRENT_TRAVEL_ITINERARY_ADD_USER(HttpStatus.CONFLICT, "여행 멤버 추가 처리 중 동시성 충돌이 발생했습니다. 다시 시도해주세요."), + USER_TRAVEL_NOT_FOUND(HttpStatus.NOT_FOUND, "여행 멤버가 없습니다."); private final HttpStatus httpStatus; private final String message; diff --git a/src/main/java/org/triple/backend/travel/exception/UserTravelItineraryErrorCode.java b/src/main/java/org/triple/backend/travel/exception/UserTravelItineraryErrorCode.java index f07bd136..fc5588a1 100644 --- a/src/main/java/org/triple/backend/travel/exception/UserTravelItineraryErrorCode.java +++ b/src/main/java/org/triple/backend/travel/exception/UserTravelItineraryErrorCode.java @@ -12,8 +12,8 @@ public enum UserTravelItineraryErrorCode implements ErrorCode { DELETE_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "LEADER만 삭제할 수 있습니다."), LEAVE_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "LEADER는 여행에서 탈퇴할 수 없습니다."), CONCURRENT_TRAVEL_ITINERARY_LEAVE(HttpStatus.CONFLICT, "여행 탈퇴가 동시에 처리되어 실패했습니다. 다시 시도해주세요."), - USER_TRAVEL_FORBIDDEN(HttpStatus.FORBIDDEN, "여행 멤버가 아니므로 권한이 없습니다.") - ; + USER_TRAVEL_FORBIDDEN(HttpStatus.FORBIDDEN, "여행 멤버가 아니므로 권한이 없습니다."), + CONCURRENT_TRAVEL_ITINERARY(HttpStatus.CONFLICT, "동시에 수정되었습니다."); private final HttpStatus httpStatus; private final String message; diff --git a/src/main/java/org/triple/backend/travel/controller/TravelItineraryController.java b/src/main/java/org/triple/backend/travel/presentation/TravelItineraryController.java similarity index 58% rename from src/main/java/org/triple/backend/travel/controller/TravelItineraryController.java rename to src/main/java/org/triple/backend/travel/presentation/TravelItineraryController.java index 7bd2a15f..7a00c5b7 100644 --- a/src/main/java/org/triple/backend/travel/controller/TravelItineraryController.java +++ b/src/main/java/org/triple/backend/travel/presentation/TravelItineraryController.java @@ -1,19 +1,26 @@ -package org.triple.backend.travel.controller; +package org.triple.backend.travel.presentation; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import org.triple.backend.auth.jwt.LoginRequired; import org.triple.backend.auth.jwt.LoginUser; -import org.triple.backend.travel.dto.request.TravelItineraryAddUserDto; -import org.triple.backend.travel.dto.request.TravelItinerarySaveRequestDto; -import org.triple.backend.travel.dto.request.TravelItineraryUpdateRequestDto; -import org.triple.backend.travel.dto.response.TravelDocInitialStateResponseDto; -import org.triple.backend.travel.dto.response.TravelItineraryCursorResponseDto; -import org.triple.backend.travel.dto.response.TravelItineraryInfoResponseDto; -import org.triple.backend.travel.dto.response.TravelItinerarySaveResponseDto; -import org.triple.backend.travel.service.TravelItineraryService; +import org.triple.backend.travel.application.TravelUseCase; +import org.triple.backend.travel.presentation.dto.req.TravelItineraryAddUserDto; +import org.triple.backend.travel.presentation.dto.req.TravelItinerarySaveRequestDto; +import org.triple.backend.travel.presentation.dto.req.TravelItineraryUpdateRequestDto; +import org.triple.backend.travel.presentation.dto.res.TravelItineraryCursorResponseDto; +import org.triple.backend.travel.presentation.dto.res.TravelItineraryInfoResponseDto; +import org.triple.backend.travel.presentation.dto.res.TravelItinerarySaveResponseDto; @RestController @RequestMapping("/travels") @@ -22,7 +29,7 @@ public class TravelItineraryController { private static final String BEARER_PREFIX = "Bearer "; private static final String SECONDARY_HEADER = "Secondary-Authorization"; - private final TravelItineraryService travelItineraryService; + private final TravelUseCase travelUseCase; @LoginRequired @PostMapping @@ -30,7 +37,7 @@ public TravelItinerarySaveResponseDto saveTravelItinerary( @Valid @RequestBody TravelItinerarySaveRequestDto travelItinerarySaveRequestDto, @LoginUser Long userId ) { - return travelItineraryService.saveTravels(travelItinerarySaveRequestDto, userId); + return travelUseCase.saveTravelItinerary(travelItinerarySaveRequestDto, userId); } @LoginRequired @@ -39,7 +46,7 @@ public void joinTravel( @PathVariable Long travelId, @LoginUser Long userId ) { - travelItineraryService.joinTravel(travelId, userId); + travelUseCase.joinTravel(travelId, userId); } @LoginRequired @@ -49,7 +56,7 @@ public void addTravelUser( @LoginUser Long userId, @RequestBody TravelItineraryAddUserDto travelItineraryAddUserDto ) { - travelItineraryService.addTravelUser(travelId, userId, travelItineraryAddUserDto); + travelUseCase.addTravelUser(travelId, userId, travelItineraryAddUserDto); } @LoginRequired @@ -59,7 +66,7 @@ public void updateTravel( @PathVariable Long travelId, @LoginUser Long userId ) { - travelItineraryService.updateTravel(travelItineraryUpdateRequestDto, travelId, userId); + travelUseCase.updateTravel(travelItineraryUpdateRequestDto, travelId, userId); } @LoginRequired @@ -68,7 +75,7 @@ public void deleteTravel( @PathVariable Long travelId, @LoginUser Long userId ) { - travelItineraryService.deleteTravel(travelId, userId); + travelUseCase.deleteTravel(travelId, userId); } @GetMapping("/{groupId}") @@ -78,7 +85,7 @@ public TravelItineraryCursorResponseDto browseTravels( @RequestParam(defaultValue = "10") int size, @LoginUser Long userId ) { - return travelItineraryService.browseTravels(groupId, cursor, size, userId); + return travelUseCase.browseTravels(groupId, cursor, size, userId); } @LoginRequired @@ -87,7 +94,7 @@ public void leaveTravel( @PathVariable Long travelId, @LoginUser Long userId ) { - travelItineraryService.leaveTravel(travelId, userId); + travelUseCase.leaveTravel(travelId, userId); } @LoginRequired @@ -96,16 +103,7 @@ public TravelItineraryInfoResponseDto getTravelInfo( @PathVariable Long travelId, @LoginUser Long userId ) { - return travelItineraryService.getTravelInfo(travelId, userId); - } - - @LoginRequired - @GetMapping("/{travelId}/doc") - public TravelDocInitialStateResponseDto getTravelDocInitialState( - @PathVariable Long travelId, - @LoginUser Long userId - ) { - return travelItineraryService.getTravelDocInitialState(travelId, userId); + return travelUseCase.getTravelInfo(travelId, userId); } @LoginRequired @@ -115,7 +113,7 @@ public void issueSecondaryToken( @LoginUser Long userId, HttpServletResponse response ) { - String secondaryToken = travelItineraryService.issueSecondaryToken(travelId, userId); + String secondaryToken = travelUseCase.issueSecondaryToken(travelId, userId); response.setHeader(SECONDARY_HEADER, BEARER_PREFIX + secondaryToken); } } diff --git a/src/main/java/org/triple/backend/travel/dto/request/TravelItineraryAddUserDto.java b/src/main/java/org/triple/backend/travel/presentation/dto/req/TravelItineraryAddUserDto.java similarity index 54% rename from src/main/java/org/triple/backend/travel/dto/request/TravelItineraryAddUserDto.java rename to src/main/java/org/triple/backend/travel/presentation/dto/req/TravelItineraryAddUserDto.java index 2e5f5ff1..877adfcf 100644 --- a/src/main/java/org/triple/backend/travel/dto/request/TravelItineraryAddUserDto.java +++ b/src/main/java/org/triple/backend/travel/presentation/dto/req/TravelItineraryAddUserDto.java @@ -1,4 +1,4 @@ -package org.triple.backend.travel.dto.request; +package org.triple.backend.travel.presentation.dto.req; public record TravelItineraryAddUserDto( String userUuid diff --git a/src/main/java/org/triple/backend/travel/dto/request/TravelItinerarySaveRequestDto.java b/src/main/java/org/triple/backend/travel/presentation/dto/req/TravelItinerarySaveRequestDto.java similarity index 94% rename from src/main/java/org/triple/backend/travel/dto/request/TravelItinerarySaveRequestDto.java rename to src/main/java/org/triple/backend/travel/presentation/dto/req/TravelItinerarySaveRequestDto.java index deda0122..545c619b 100644 --- a/src/main/java/org/triple/backend/travel/dto/request/TravelItinerarySaveRequestDto.java +++ b/src/main/java/org/triple/backend/travel/presentation/dto/req/TravelItinerarySaveRequestDto.java @@ -1,4 +1,4 @@ -package org.triple.backend.travel.dto.request; +package org.triple.backend.travel.presentation.dto.req; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/org/triple/backend/travel/dto/request/TravelItineraryUpdateRequestDto.java b/src/main/java/org/triple/backend/travel/presentation/dto/req/TravelItineraryUpdateRequestDto.java similarity index 85% rename from src/main/java/org/triple/backend/travel/dto/request/TravelItineraryUpdateRequestDto.java rename to src/main/java/org/triple/backend/travel/presentation/dto/req/TravelItineraryUpdateRequestDto.java index 01de0c55..2eab988d 100644 --- a/src/main/java/org/triple/backend/travel/dto/request/TravelItineraryUpdateRequestDto.java +++ b/src/main/java/org/triple/backend/travel/presentation/dto/req/TravelItineraryUpdateRequestDto.java @@ -1,4 +1,4 @@ -package org.triple.backend.travel.dto.request; +package org.triple.backend.travel.presentation.dto.req; import jakarta.validation.constraints.Size; diff --git a/src/main/java/org/triple/backend/travel/dto/response/TravelItineraryCursorResponseDto.java b/src/main/java/org/triple/backend/travel/presentation/dto/res/TravelItineraryCursorResponseDto.java similarity index 96% rename from src/main/java/org/triple/backend/travel/dto/response/TravelItineraryCursorResponseDto.java rename to src/main/java/org/triple/backend/travel/presentation/dto/res/TravelItineraryCursorResponseDto.java index 6f8f9f64..a1b0c164 100644 --- a/src/main/java/org/triple/backend/travel/dto/response/TravelItineraryCursorResponseDto.java +++ b/src/main/java/org/triple/backend/travel/presentation/dto/res/TravelItineraryCursorResponseDto.java @@ -1,4 +1,4 @@ -package org.triple.backend.travel.dto.response; +package org.triple.backend.travel.presentation.dto.res; import org.triple.backend.travel.entity.TravelItinerary; diff --git a/src/main/java/org/triple/backend/travel/dto/response/TravelItineraryInfoResponseDto.java b/src/main/java/org/triple/backend/travel/presentation/dto/res/TravelItineraryInfoResponseDto.java similarity index 95% rename from src/main/java/org/triple/backend/travel/dto/response/TravelItineraryInfoResponseDto.java rename to src/main/java/org/triple/backend/travel/presentation/dto/res/TravelItineraryInfoResponseDto.java index 56e95606..56dcb182 100644 --- a/src/main/java/org/triple/backend/travel/dto/response/TravelItineraryInfoResponseDto.java +++ b/src/main/java/org/triple/backend/travel/presentation/dto/res/TravelItineraryInfoResponseDto.java @@ -1,4 +1,4 @@ -package org.triple.backend.travel.dto.response; +package org.triple.backend.travel.presentation.dto.res; import org.triple.backend.travel.entity.TravelItinerary; import org.triple.backend.travel.entity.UserRole; diff --git a/src/main/java/org/triple/backend/travel/dto/response/TravelItinerarySaveResponseDto.java b/src/main/java/org/triple/backend/travel/presentation/dto/res/TravelItinerarySaveResponseDto.java similarity index 79% rename from src/main/java/org/triple/backend/travel/dto/response/TravelItinerarySaveResponseDto.java rename to src/main/java/org/triple/backend/travel/presentation/dto/res/TravelItinerarySaveResponseDto.java index 5f594b47..0e031676 100644 --- a/src/main/java/org/triple/backend/travel/dto/response/TravelItinerarySaveResponseDto.java +++ b/src/main/java/org/triple/backend/travel/presentation/dto/res/TravelItinerarySaveResponseDto.java @@ -1,4 +1,4 @@ -package org.triple.backend.travel.dto.response; +package org.triple.backend.travel.presentation.dto.res; public record TravelItinerarySaveResponseDto( diff --git a/src/main/java/org/triple/backend/travel/repository/TravelItineraryJpaRepository.java b/src/main/java/org/triple/backend/travel/repository/TravelItineraryJpaRepository.java index d099c90a..852f0d9d 100644 --- a/src/main/java/org/triple/backend/travel/repository/TravelItineraryJpaRepository.java +++ b/src/main/java/org/triple/backend/travel/repository/TravelItineraryJpaRepository.java @@ -7,52 +7,28 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.triple.backend.travel.entity.TravelItinerary; +import org.triple.backend.travel.repository.custom.TravelItineraryCustomRepository; import java.util.List; import java.util.Optional; -public interface TravelItineraryJpaRepository extends JpaRepository { +public interface TravelItineraryJpaRepository extends JpaRepository, TravelItineraryCustomRepository { Optional findById(Long travelId); - long countByGroupIdAndIsDeletedFalse(Long groupId); - - @Query("select t from TravelItinerary t where t.id = :travelId and t.isDeleted = false") - Optional findByIdAndIsDeletedFalse(@Param("travelId") Long travelId); + long countByGroupId(Long groupId); @Lock(value = LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT t from TravelItinerary t WHERE t.id = :travelItineraryId AND t.group.id = :groupId AND t.isDeleted = false") + @Query("SELECT t from TravelItinerary t WHERE t.id = :travelItineraryId AND t.group.id = :groupId") Optional findByIdAndGroupIdAndIsDeletedFalseForUpdate(Long travelItineraryId, Long groupId); - @Query(""" - SELECT t FROM TravelItinerary t - WHERE t.group.id = :groupId - AND t.isDeleted = false - ORDER BY t.id DESC - """) - List findGroupTravelsFirstPage(@Param("groupId") Long groupId, Pageable pageable); - - @Query(""" - SELECT t FROM TravelItinerary t - WHERE t.group.id = :groupId - AND t.isDeleted = false - AND t.id < :cursor - ORDER BY t.id DESC - """) - List findGroupTravelsNextPage( - @Param("groupId") Long groupId, - @Param("cursor") Long cursor, - Pageable pageable - ); - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("select t from TravelItinerary t where t.id = :travelId and t.isDeleted = false") + @Query("select t from TravelItinerary t where t.id = :travelId") Optional findByIdAndIsDeletedFalseForUpdate(Long travelId); @Query(""" SELECT t FROM TravelItinerary t WHERE t.group.id = :groupId - AND t.isDeleted = false ORDER BY t.id desc """) List findRecentByGroupId(Long groupId, Pageable pageable); diff --git a/src/main/java/org/triple/backend/travel/repository/UserTravelItineraryJpaRepository.java b/src/main/java/org/triple/backend/travel/repository/UserTravelItineraryJpaRepository.java index 3629b0dd..0d5eef0d 100644 --- a/src/main/java/org/triple/backend/travel/repository/UserTravelItineraryJpaRepository.java +++ b/src/main/java/org/triple/backend/travel/repository/UserTravelItineraryJpaRepository.java @@ -1,8 +1,12 @@ package org.triple.backend.travel.repository; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.triple.backend.travel.entity.UserRole; import org.triple.backend.travel.entity.UserTravelItinerary; import java.util.List; @@ -30,4 +34,24 @@ Optional findByUserIdAndTravelItineraryId( List findAllByTravelItineraryId(@Param("travelItineraryId") Long travelItineraryId); boolean existsByUserIdAndTravelItineraryId(Long userId, Long travelItineraryId); + + Optional findByUserIdAndTravelItineraryIdAndUserRole( + Long userId, + Long travelItineraryId, + UserRole userRole + ); + + boolean existsByUserIdAndTravelItineraryIdAndUserRole( + Long userId, + Long travelItineraryId, + UserRole userRole + ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM UserTravelItinerary ut WHERE ut.travelItinerary.id = :travelId") + void bulkDeleteByTravelId(@Param("travelId") Long travelId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT ut FROM UserTravelItinerary ut WHERE ut.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); } diff --git a/src/main/java/org/triple/backend/travel/repository/custom/TravelItineraryCustomRepository.java b/src/main/java/org/triple/backend/travel/repository/custom/TravelItineraryCustomRepository.java new file mode 100644 index 00000000..a980c78b --- /dev/null +++ b/src/main/java/org/triple/backend/travel/repository/custom/TravelItineraryCustomRepository.java @@ -0,0 +1,9 @@ +package org.triple.backend.travel.repository.custom; + +import org.triple.backend.travel.presentation.dto.res.TravelItineraryCursorResponseDto.TravelSummaryDto; + +import java.util.List; + +public interface TravelItineraryCustomRepository { + List findGroupTravels(Long groupId, int size, Long cursor); +} diff --git a/src/main/java/org/triple/backend/travel/repository/custom/TravelItineraryCustomRepositoryImpl.java b/src/main/java/org/triple/backend/travel/repository/custom/TravelItineraryCustomRepositoryImpl.java new file mode 100644 index 00000000..7f3f0df1 --- /dev/null +++ b/src/main/java/org/triple/backend/travel/repository/custom/TravelItineraryCustomRepositoryImpl.java @@ -0,0 +1,48 @@ +package org.triple.backend.travel.repository.custom; + +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.triple.backend.travel.entity.QTravelItinerary; +import org.triple.backend.travel.presentation.dto.res.TravelItineraryCursorResponseDto.TravelSummaryDto; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class TravelItineraryCustomRepositoryImpl implements TravelItineraryCustomRepository { + private static final QTravelItinerary travelItinerary = QTravelItinerary.travelItinerary; + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public List findGroupTravels(final Long groupId, final int size, final Long cursor) { + return jpaQueryFactory + .select(Projections.constructor( + TravelSummaryDto.class, + travelItinerary.id, + travelItinerary.title, + travelItinerary.description, + travelItinerary.startAt, + travelItinerary.endAt, + travelItinerary.memberCount + )) + .from(travelItinerary) + .where( + travelItinerary.group.id.eq(groupId), + ltCursor(cursor) + ) + .orderBy(travelItinerary.id.desc()) + .limit(size + 1) + .fetch(); + } + + private BooleanExpression ltCursor(final Long cursor) { + if (cursor == null) { + return null; + } + return travelItinerary.id.lt(cursor); + } +} diff --git a/src/main/java/org/triple/backend/travel/service/TravelItineraryService.java b/src/main/java/org/triple/backend/travel/service/TravelItineraryService.java deleted file mode 100644 index 455dbb80..00000000 --- a/src/main/java/org/triple/backend/travel/service/TravelItineraryService.java +++ /dev/null @@ -1,337 +0,0 @@ -package org.triple.backend.travel.service; - -import lombok.RequiredArgsConstructor; -import org.springframework.dao.OptimisticLockingFailureException; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.triple.backend.auth.crypto.UserIdentityResolver; -import org.triple.backend.auth.jwt.JwtManager; -import org.triple.backend.global.error.BusinessException; -import org.triple.backend.group.domain.group.Group; -import org.triple.backend.group.domain.userGroup.JoinStatus; -import org.triple.backend.group.exception.GroupErrorCode; -import org.triple.backend.group.repository.GroupJpaRepository; -import org.triple.backend.group.repository.UserGroupJpaRepository; -import org.triple.backend.travel.dto.request.TravelItineraryAddUserDto; -import org.triple.backend.travel.exception.TravelItineraryErrorCode; -import org.triple.backend.travel.dto.request.TravelItinerarySaveRequestDto; -import org.triple.backend.travel.dto.request.TravelItineraryUpdateRequestDto; -import org.triple.backend.travel.dto.response.TravelDocInitialStateResponseDto; -import org.triple.backend.travel.dto.response.TravelItineraryCursorResponseDto; -import org.triple.backend.travel.dto.response.TravelItineraryInfoResponseDto; -import org.triple.backend.travel.dto.response.TravelItinerarySaveResponseDto; -import org.triple.backend.travel.entity.TravelDoc; -import org.triple.backend.travel.entity.TravelItinerary; -import org.triple.backend.travel.entity.UserRole; -import org.triple.backend.travel.entity.UserTravelItinerary; -import org.triple.backend.travel.exception.UserTravelItineraryErrorCode; -import org.triple.backend.travel.repository.TravelDocJpaRepository; -import org.triple.backend.travel.repository.TravelItineraryJpaRepository; -import org.triple.backend.travel.repository.UserTravelItineraryJpaRepository; -import org.triple.backend.user.domain.User; -import org.triple.backend.user.exception.UserErrorCode; -import org.triple.backend.user.persistence.UserJpaRepository; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -@Service -@RequiredArgsConstructor -public class TravelItineraryService { - private static final int MIN_PAGE_SIZE = 1; - private static final int MAX_PAGE_SIZE = 10; - - private final UserJpaRepository userJpaRepository; - private final UserTravelItineraryJpaRepository userTravelItineraryJpaRepository; - private final TravelItineraryJpaRepository travelItineraryJpaRepository; - private final TravelDocJpaRepository travelDocJpaRepository; - private final GroupJpaRepository groupJpaRepository; - private final UserGroupJpaRepository userGroupJpaRepository; - private final UserIdentityResolver userIdentityResolver; - private final JwtManager jwtManager; - - @Transactional - public TravelItinerarySaveResponseDto saveTravels(final TravelItinerarySaveRequestDto travelsRequestDto, final Long userId) { - User user = userJpaRepository.findById(userId) - .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND)); - - Group group = groupJpaRepository.findByIdForRead(travelsRequestDto.groupId()) - .orElseThrow(() -> new BusinessException(GroupErrorCode.GROUP_NOT_FOUND)); - - if (!userGroupJpaRepository.existsByGroupIdAndUserIdAndJoinStatus(group.getId(), userId, JoinStatus.JOINED)) { - throw new BusinessException(TravelItineraryErrorCode.SAVE_FORBIDDEN); - } - - TravelItinerary travelItinerary = TravelItinerary.of(travelsRequestDto, group); - TravelItinerary savedTravelItinerary = travelItineraryJpaRepository.save(travelItinerary); - - UserTravelItinerary userTravelItinerary = UserTravelItinerary.of(user, savedTravelItinerary, - UserRole.LEADER); - userTravelItineraryJpaRepository.save(userTravelItinerary); - addTravelMembers( - travelsRequestDto.memberUuids(), - savedTravelItinerary, - group.getId(), - userId - ); - - return TravelItinerarySaveResponseDto.from(savedTravelItinerary.getId()); - } - - private void addTravelMembers( - final List memberUuids, - final TravelItinerary travelItinerary, - final Long groupId, - final Long leaderUserId - ) { - if (memberUuids == null || memberUuids.isEmpty()) { - return; - } - - Set addedUserIds = new HashSet<>(); - List memberUserIds = new ArrayList<>(); - - for (String memberUuid : memberUuids) { - Long memberUserId = userIdentityResolver.resolve(memberUuid); - if (memberUserId == null) { - throw new BusinessException(UserErrorCode.USER_NOT_FOUND); - } - - if (leaderUserId.equals(memberUserId) || !addedUserIds.add(memberUserId)) { - continue; - } - memberUserIds.add(memberUserId); - } - - if (memberUserIds.isEmpty()) { - return; - } - - List members = userJpaRepository.findAllById(memberUserIds); - if (members.size() != memberUserIds.size()) { - throw new BusinessException(UserErrorCode.USER_NOT_FOUND); - } - - long joinedMemberCount = userGroupJpaRepository.countByGroupIdAndUserIdInAndJoinStatus( - groupId, - memberUserIds, - JoinStatus.JOINED - ); - if (joinedMemberCount != memberUserIds.size()) { - throw new BusinessException(TravelItineraryErrorCode.SAVE_FORBIDDEN); - } - - List memberMappings = members.stream() - .map(member -> UserTravelItinerary.of(member, travelItinerary, UserRole.MEMBER)) - .toList(); - userTravelItineraryJpaRepository.saveAll(memberMappings); - - for (int i = 0; i < memberMappings.size(); i++) { - travelItinerary.increaseMemberCount(); - } - } - - @Transactional - public void joinTravel(final Long travelItineraryId, final Long userId) { - User user = userJpaRepository.findById(userId) - .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND)); - - TravelItinerary travelItinerary = travelItineraryJpaRepository.findByIdAndIsDeletedFalseForUpdate(travelItineraryId) - .orElseThrow(() -> new BusinessException(TravelItineraryErrorCode.TRAVEL_NOT_FOUND)); - - Long groupId = travelItinerary.getGroup().getId(); - if (!userGroupJpaRepository.existsByGroupIdAndUserIdAndJoinStatus(groupId, userId, JoinStatus.JOINED)) { - throw new BusinessException(TravelItineraryErrorCode.JOIN_FORBIDDEN); - } - - if (userTravelItineraryJpaRepository.existsByUserIdAndTravelItineraryId(userId, travelItineraryId)) { - throw new BusinessException(UserTravelItineraryErrorCode.ALREADY_JOINED_TRAVEL); - } - - try { - travelItinerary.increaseMemberCount(); - userTravelItineraryJpaRepository.save(UserTravelItinerary.of(user, travelItinerary, UserRole.MEMBER)); - travelItineraryJpaRepository.flush(); - } catch (OptimisticLockingFailureException e) { - throw new BusinessException(TravelItineraryErrorCode.CONCURRENT_TRAVEL_ITINERARY_JOIN); - } - } - - @Transactional - public void updateTravel(TravelItineraryUpdateRequestDto travelItineraryUpdateRequestDto, Long travelItineraryId, Long userId) { - TravelItinerary travelItinerary = travelItineraryJpaRepository.findByIdAndIsDeletedFalse(travelItineraryId) - .orElseThrow(() -> new BusinessException(TravelItineraryErrorCode.TRAVEL_NOT_FOUND)); - - UserTravelItinerary userTravelItinerary = userTravelItineraryJpaRepository.findByUserIdAndTravelItineraryId(userId, travelItineraryId) - .orElseThrow(() -> new BusinessException(UserTravelItineraryErrorCode.USER_TRAVEL_ITINERARY_NOT_FOUND)); - - if(!userTravelItinerary.getUserRole().equals(UserRole.LEADER)) { - throw new BusinessException(UserTravelItineraryErrorCode.UPDATE_UNAUTHORIZED); - } - - try { - travelItinerary.updateTravelItinerary(travelItineraryUpdateRequestDto); - travelItineraryJpaRepository.flush(); - } catch (OptimisticLockingFailureException e) { - throw new BusinessException(TravelItineraryErrorCode.CONCURRENT_TRAVEL_ITINERARY_UPDATE); - } - } - - @Transactional - public void deleteTravel(Long travelItineraryId, Long userId) { - TravelItinerary travelItinerary = travelItineraryJpaRepository.findByIdAndIsDeletedFalse(travelItineraryId) - .orElseThrow(() -> new BusinessException(TravelItineraryErrorCode.TRAVEL_NOT_FOUND)); - - UserTravelItinerary userTravelItinerary = userTravelItineraryJpaRepository.findByUserIdAndTravelItineraryId(userId, travelItineraryId) - .orElseThrow(() -> new BusinessException(UserTravelItineraryErrorCode.USER_TRAVEL_ITINERARY_NOT_FOUND)); - - if (!userTravelItinerary.getUserRole().equals(UserRole.LEADER)) { - throw new BusinessException(UserTravelItineraryErrorCode.DELETE_UNAUTHORIZED); - } - - try { - travelItinerary.deleteTravelItinerary(); - travelItineraryJpaRepository.flush(); - } catch (OptimisticLockingFailureException e) { - throw new BusinessException(TravelItineraryErrorCode.CONCURRENT_TRAVEL_ITINERARY_DELETE); - } - } - - @Transactional(readOnly = true) - public TravelItineraryCursorResponseDto browseTravels( - final Long groupId, - final Long cursor, - final int size, - final Long userId - ) { - long count = travelItineraryJpaRepository.countByGroupIdAndIsDeletedFalse(groupId); - - if (userId == null) { - return TravelItineraryCursorResponseDto.countOnly(count); - } - - if (!userGroupJpaRepository.existsByGroupIdAndUserIdAndJoinStatus(groupId, userId, JoinStatus.JOINED)) { - return TravelItineraryCursorResponseDto.countOnly(count); - } - - int pageSize = normalizePageSize(size); - Pageable pageRequest = PageRequest.of(0, pageSize + 1); - - List rows = cursor == null - ? travelItineraryJpaRepository.findGroupTravelsFirstPage(groupId, pageRequest) - : travelItineraryJpaRepository.findGroupTravelsNextPage(groupId, cursor, pageRequest); - - boolean hasNext = rows.size() > pageSize; - if (hasNext) { - rows = rows.subList(0, pageSize); - } - - Long nextCursor = hasNext ? rows.get(rows.size() - 1).getId() : null; - return TravelItineraryCursorResponseDto.of(rows, nextCursor, hasNext, count); - } - - private int normalizePageSize(int size) { - return Math.min(Math.max(size, MIN_PAGE_SIZE), MAX_PAGE_SIZE); - } - - @Transactional(readOnly = true) - public TravelItineraryInfoResponseDto getTravelInfo(final Long travelItineraryId, final Long userId) { - TravelItinerary travelItinerary = travelItineraryJpaRepository.findByIdAndIsDeletedFalse(travelItineraryId) - .orElseThrow(() -> new BusinessException(TravelItineraryErrorCode.TRAVEL_NOT_FOUND)); - - if(!userGroupJpaRepository.existsByGroupIdAndUserIdAndJoinStatus(travelItinerary.getGroup().getId(), userId, JoinStatus.JOINED)) - throw new BusinessException(GroupErrorCode.NOT_GROUP_MEMBER); - - List members = userTravelItineraryJpaRepository.findAllByTravelItineraryId(travelItineraryId); - return TravelItineraryInfoResponseDto.of(travelItinerary, members); - } - - @Transactional(readOnly = true) - public TravelDocInitialStateResponseDto getTravelDocInitialState(final Long travelItineraryId, final Long userId) { - travelItineraryJpaRepository.findByIdAndIsDeletedFalse(travelItineraryId) - .orElseThrow(() -> new BusinessException(TravelItineraryErrorCode.TRAVEL_NOT_FOUND)); - - boolean isMember = userTravelItineraryJpaRepository.existsByUserIdAndTravelItineraryId(userId, travelItineraryId); - if (!isMember) { - throw new BusinessException(UserTravelItineraryErrorCode.USER_TRAVEL_ITINERARY_NOT_FOUND); - } - - TravelDoc travelDoc = travelDocJpaRepository.findByTravelItineraryId(travelItineraryId) - .orElse(null); - if (travelDoc == null) { - return TravelDocInitialStateResponseDto.empty(travelItineraryId); - } - return TravelDocInitialStateResponseDto.initialized(travelItineraryId, travelDoc.getState()); - } - - @Transactional(readOnly = true) - public String issueSecondaryToken(final Long travelItineraryId, final Long userId) { - travelItineraryJpaRepository.findByIdAndIsDeletedFalse(travelItineraryId) - .orElseThrow(() -> new BusinessException(TravelItineraryErrorCode.TRAVEL_NOT_FOUND)); - - if (!userTravelItineraryJpaRepository.existsByUserIdAndTravelItineraryId(userId, travelItineraryId)) { - throw new BusinessException(UserTravelItineraryErrorCode.USER_TRAVEL_ITINERARY_NOT_FOUND); - } - - return jwtManager.createSecondaryToken(userId, travelItineraryId); - } - - @Transactional - public void leaveTravel(final Long travelItineraryId, final Long userId) { - UserTravelItinerary userTravelItinerary = userTravelItineraryJpaRepository.findByUserIdAndTravelItineraryId(userId, travelItineraryId) - .orElseThrow(() -> new BusinessException(UserTravelItineraryErrorCode.USER_TRAVEL_ITINERARY_NOT_FOUND)); - - TravelItinerary travelItinerary = userTravelItinerary.getTravelItinerary(); - if(travelItinerary == null) { - throw new BusinessException(TravelItineraryErrorCode.TRAVEL_NOT_FOUND); - } - - if (userTravelItinerary.getUserRole().equals(UserRole.LEADER)) { - throw new BusinessException(UserTravelItineraryErrorCode.LEAVE_UNAUTHORIZED); - } - - try { - travelItinerary.decreaseMemberCount(); - userTravelItineraryJpaRepository.delete(userTravelItinerary); - userTravelItineraryJpaRepository.flush(); - } catch (OptimisticLockingFailureException e) { - throw new BusinessException(UserTravelItineraryErrorCode.CONCURRENT_TRAVEL_ITINERARY_LEAVE); - } - } - - @Transactional - public void addTravelUser(Long travelId, Long userId, TravelItineraryAddUserDto travelItineraryAddUserDto) { - TravelItinerary travelItinerary = travelItineraryJpaRepository.findByIdAndIsDeletedFalse(travelId) - .orElseThrow(() -> new BusinessException(TravelItineraryErrorCode.TRAVEL_NOT_FOUND)); - - List userTravelItineraries = userTravelItineraryJpaRepository.findAllByTravelItineraryId(travelId); - - Long addUserId = userIdentityResolver.resolve(travelItineraryAddUserDto.userUuid()); - if(addUserId == null) throw new BusinessException(UserErrorCode.USER_NOT_FOUND); - - boolean hasAuthorization = false; - boolean isAlreadyMember = false; - for(int i = 0; i < userTravelItineraries.size(); i++) { - Long currId = userTravelItineraries.get(i).getUser().getId(); - if(currId.equals(userId)) hasAuthorization = true; - if(currId.equals(addUserId)) isAlreadyMember = true; - } - - if(!hasAuthorization) throw new BusinessException(UserTravelItineraryErrorCode.USER_TRAVEL_FORBIDDEN); - if(isAlreadyMember) throw new BusinessException(UserTravelItineraryErrorCode.ALREADY_JOINED_TRAVEL); - - try { - User addUser = userJpaRepository.getReferenceById(addUserId); - travelItinerary.increaseMemberCount(); - UserTravelItinerary userTravelItinerary = UserTravelItinerary.of(addUser, travelItinerary, UserRole.MEMBER); - userTravelItineraryJpaRepository.save(userTravelItinerary); - travelItineraryJpaRepository.flush(); - } catch (OptimisticLockingFailureException e) { - throw new BusinessException(TravelItineraryErrorCode.CONCURRENT_TRAVEL_ITINERARY_ADD_USER); - } - } -} diff --git a/src/main/java/org/triple/backend/user/application/UserReader.java b/src/main/java/org/triple/backend/user/application/UserReader.java index a19c2029..0edb8162 100644 --- a/src/main/java/org/triple/backend/user/application/UserReader.java +++ b/src/main/java/org/triple/backend/user/application/UserReader.java @@ -7,6 +7,8 @@ import org.triple.backend.user.exception.UserErrorCode; import org.triple.backend.user.persistence.UserJpaRepository; +import java.util.List; + @Service @RequiredArgsConstructor public class UserReader { @@ -16,4 +18,12 @@ public User findUserById(Long userId) { return userJpaRepository.findById(userId) .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND)); } + + public List findUsersInIds(List memberIds) { + List users = userJpaRepository.findAllById(memberIds); + if(users.isEmpty()) { + throw new BusinessException(UserErrorCode.USER_NOT_FOUND); + } + return users; + } } From 0b9dd667d5cf4b369cf3e7918c02a3006ee37330 Mon Sep 17 00:00:00 2001 From: jeongjiho Date: Fri, 24 Apr 2026 19:31:39 +0900 Subject: [PATCH 2/8] =?UTF-8?q?[#223]=20chore:=20travel=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=83=81=ED=83=9C(TravelDoc)=20=EB=A0=88=EA=B1=B0?= =?UTF-8?q?=EC=8B=9C=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TravelDocInitialStateResponseDto.java | 22 --------- .../backend/travel/entity/TravelDoc.java | 45 ------------------- .../repository/TravelDocJpaRepository.java | 10 ----- 3 files changed, 77 deletions(-) delete mode 100644 src/main/java/org/triple/backend/travel/dto/response/TravelDocInitialStateResponseDto.java delete mode 100644 src/main/java/org/triple/backend/travel/entity/TravelDoc.java delete mode 100644 src/main/java/org/triple/backend/travel/repository/TravelDocJpaRepository.java diff --git a/src/main/java/org/triple/backend/travel/dto/response/TravelDocInitialStateResponseDto.java b/src/main/java/org/triple/backend/travel/dto/response/TravelDocInitialStateResponseDto.java deleted file mode 100644 index 9b8d3b7f..00000000 --- a/src/main/java/org/triple/backend/travel/dto/response/TravelDocInitialStateResponseDto.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.triple.backend.travel.dto.response; - -import java.util.Base64; - -public record TravelDocInitialStateResponseDto( - Long travelItineraryId, - String state, - boolean initialized -) { - - public static TravelDocInitialStateResponseDto initialized( - final Long travelItineraryId, - final byte[] state - ) { - String encodedState = Base64.getEncoder().encodeToString(state); - return new TravelDocInitialStateResponseDto(travelItineraryId, encodedState, true); - } - - public static TravelDocInitialStateResponseDto empty(final Long travelItineraryId) { - return new TravelDocInitialStateResponseDto(travelItineraryId, "", false); - } -} diff --git a/src/main/java/org/triple/backend/travel/entity/TravelDoc.java b/src/main/java/org/triple/backend/travel/entity/TravelDoc.java deleted file mode 100644 index 4fde6c1f..00000000 --- a/src/main/java/org/triple/backend/travel/entity/TravelDoc.java +++ /dev/null @@ -1,45 +0,0 @@ -package org.triple.backend.travel.entity; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Lob; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.hibernate.annotations.UpdateTimestamp; - -import java.time.LocalDateTime; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "travel_doc") -public class TravelDoc { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "travel_doc_id") - private Long id; - - @Column(name = "travel_itinerary_id", nullable = false, unique = true) - private Long travelItineraryId; - - @Lob - @Column(name = "state", nullable = false) - private byte[] state; - - @UpdateTimestamp - @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; - - public static TravelDoc of(final Long travelItineraryId, final byte[] state) { - TravelDoc travelDoc = new TravelDoc(); - travelDoc.travelItineraryId = travelItineraryId; - travelDoc.state = state; - return travelDoc; - } -} diff --git a/src/main/java/org/triple/backend/travel/repository/TravelDocJpaRepository.java b/src/main/java/org/triple/backend/travel/repository/TravelDocJpaRepository.java deleted file mode 100644 index 6ae959ef..00000000 --- a/src/main/java/org/triple/backend/travel/repository/TravelDocJpaRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.triple.backend.travel.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.triple.backend.travel.entity.TravelDoc; - -import java.util.Optional; - -public interface TravelDocJpaRepository extends JpaRepository { - Optional findByTravelItineraryId(Long travelItineraryId); -} From 01f50276f44a69bea922ad59913796aa895b2346 Mon Sep 17 00:00:00 2001 From: jeongjiho Date: Fri, 24 Apr 2026 19:31:52 +0900 Subject: [PATCH 3/8] =?UTF-8?q?[#223]=20test:=20travel=20=EC=95=A0?= =?UTF-8?q?=ED=94=8C=EB=A6=AC=EC=BC=80=EC=9D=B4=EC=85=98=20=EA=B3=84?= =?UTF-8?q?=EC=B8=B5=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../travel/application/TravelCommandTest.java | 222 +++++++++++++++ .../travel/application/TravelReaderTest.java | 221 +++++++++++++++ .../travel/application/TravelUseCaseTest.java | 260 ++++++++++++++++++ 3 files changed, 703 insertions(+) create mode 100644 src/test/java/org/triple/backend/travel/application/TravelCommandTest.java create mode 100644 src/test/java/org/triple/backend/travel/application/TravelReaderTest.java create mode 100644 src/test/java/org/triple/backend/travel/application/TravelUseCaseTest.java diff --git a/src/test/java/org/triple/backend/travel/application/TravelCommandTest.java b/src/test/java/org/triple/backend/travel/application/TravelCommandTest.java new file mode 100644 index 00000000..9db88cea --- /dev/null +++ b/src/test/java/org/triple/backend/travel/application/TravelCommandTest.java @@ -0,0 +1,222 @@ +package org.triple.backend.travel.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.test.util.ReflectionTestUtils; +import org.triple.backend.global.error.BusinessException; +import org.triple.backend.group.domain.group.Group; +import org.triple.backend.group.domain.group.GroupKind; +import org.triple.backend.travel.entity.TravelItinerary; +import org.triple.backend.travel.entity.UserRole; +import org.triple.backend.travel.entity.UserTravelItinerary; +import org.triple.backend.travel.exception.TravelItineraryErrorCode; +import org.triple.backend.travel.exception.UserTravelItineraryErrorCode; +import org.triple.backend.travel.repository.TravelItineraryJpaRepository; +import org.triple.backend.travel.repository.TravelReviewImageJpaRepository; +import org.triple.backend.travel.repository.TravelReviewJpaRepository; +import org.triple.backend.travel.repository.UserTravelItineraryJpaRepository; +import org.triple.backend.user.domain.User; + +@ExtendWith(MockitoExtension.class) +class TravelCommandTest { + + @Mock + private TravelReader travelReader; + + @Mock + private TravelItineraryJpaRepository travelItineraryJpaRepository; + + @Mock + private TravelReviewJpaRepository travelReviewJpaRepository; + + @Mock + private TravelReviewImageJpaRepository travelReviewImageJpaRepository; + + @Mock + private UserTravelItineraryJpaRepository userTravelItineraryJpaRepository; + + @InjectMocks + private TravelCommand travelCommand; + + @Test + @DisplayName("여행 생성 시 여행과 리더/멤버 매핑을 저장하고 멤버 수를 증가시킨다") + void saveTravelItinerary_savesTravelAndMappings() { + User leader = user(1L, "leader"); + User member1 = user(2L, "member1"); + User member2 = user(3L, "member2"); + Group group = group(10L); + + when(travelItineraryJpaRepository.save(any(TravelItinerary.class))).thenAnswer(invocation -> { + TravelItinerary saved = invocation.getArgument(0, TravelItinerary.class); + ReflectionTestUtils.setField(saved, "id", 100L); + return saved; + }); + + TravelItinerary result = travelCommand.saveTravelItinerary( + leader, + group, + "trip", + LocalDateTime.of(2026, 1, 1, 10, 0), + LocalDateTime.of(2026, 1, 2, 10, 0), + "desc", + List.of(member1, member2) + ); + + assertThat(result.getId()).isEqualTo(100L); + assertThat(result.getMemberCount()).isEqualTo(3); + verify(userTravelItineraryJpaRepository).save(any(UserTravelItinerary.class)); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(userTravelItineraryJpaRepository).saveAll(captor.capture()); + assertThat(captor.getValue()).hasSize(2); + } + + @Test + @DisplayName("여행 멤버 추가 성공 시 멤버 수를 증가시키고 flush한다") + void addUserTravelItinerary_addsMemberAndFlushes() { + TravelItinerary travel = travel(100L, group(10L), "trip"); + User user = user(2L, "member"); + + travelCommand.addUserTravelItinerary(user, travel, UserRole.MEMBER); + + assertThat(travel.getMemberCount()).isEqualTo(2); + verify(userTravelItineraryJpaRepository).save(any(UserTravelItinerary.class)); + verify(travelItineraryJpaRepository).flush(); + } + + @Test + @DisplayName("여행 멤버가 이미 존재하면 ALREADY_JOINED_TRAVEL로 변환한다") + void addUserTravelItinerary_convertsDuplicateException() { + TravelItinerary travel = travel(100L, group(10L), "trip"); + User user = user(2L, "member"); + when(userTravelItineraryJpaRepository.save(any(UserTravelItinerary.class))) + .thenThrow(new DataIntegrityViolationException("duplicate")); + + assertThatThrownBy(() -> travelCommand.addUserTravelItinerary(user, travel, UserRole.MEMBER)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException) exception).getErrorCode()) + .isEqualTo(UserTravelItineraryErrorCode.ALREADY_JOINED_TRAVEL)); + } + + @Test + @DisplayName("여행 수정은 엔티티 변경 메서드를 호출하고 flush한다") + void updateTravel_updatesFields() { + Group group = group(10L); + TravelItinerary travel = travel(100L, group, "old-title"); + when(travelReader.findTravel(100L)).thenReturn(travel); + + LocalDateTime startAt = LocalDateTime.of(2026, 1, 1, 11, 0); + LocalDateTime endAt = LocalDateTime.of(2026, 1, 3, 10, 0); + travelCommand.updateTravel(100L, "new-title", startAt, endAt, "new-desc"); + + assertThat(travel.getTitle()).isEqualTo("new-title"); + assertThat(travel.getStartAt()).isEqualTo(startAt); + assertThat(travel.getEndAt()).isEqualTo(endAt); + assertThat(travel.getDescription()).isEqualTo("new-desc"); + verify(travelItineraryJpaRepository).flush(); + } + + @Test + @DisplayName("여행 수정 flush 충돌은 CONCURRENT_TRAVEL_ITINERARY_UPDATE로 변환한다") + void updateTravel_convertsOptimisticLockException() { + TravelItinerary travel = travel(100L, group(10L), "old-title"); + when(travelReader.findTravel(100L)).thenReturn(travel); + doThrow(new OptimisticLockingFailureException("conflict")).when(travelItineraryJpaRepository).flush(); + + assertThatThrownBy(() -> travelCommand.updateTravel( + 100L, + "new-title", + LocalDateTime.of(2026, 1, 1, 11, 0), + LocalDateTime.of(2026, 1, 3, 10, 0), + "new-desc" + )) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException) exception).getErrorCode()) + .isEqualTo(TravelItineraryErrorCode.CONCURRENT_TRAVEL_ITINERARY_UPDATE)); + } + + @Test + @DisplayName("여행 삭제는 멤버 매핑 벌크 삭제 후 여행을 삭제한다") + void deleteTravel_deletesMappingsAndTravel() { + TravelItinerary travel = travel(100L, group(10L), "trip"); + + travelCommand.deleteTravel(travel); + + verify(userTravelItineraryJpaRepository).bulkDeleteByTravelId(100L); + verify(travelItineraryJpaRepository).delete(travel); + verify(travelItineraryJpaRepository).flush(); + } + + @Test + @DisplayName("여행 나가기는 MEMBER 매핑을 삭제하고 멤버 수를 감소시킨다") + void leaveTravel_decreasesMemberCountAndDeletesMapping() { + TravelItinerary travel = travel(100L, group(10L), "trip"); + travel.increaseMemberCount(1); + UserTravelItinerary mapping = UserTravelItinerary.of(user(1L, "member"), travel, UserRole.MEMBER); + when(userTravelItineraryJpaRepository.findByUserIdAndTravelItineraryIdAndUserRole(1L, 100L, UserRole.MEMBER)) + .thenReturn(Optional.of(mapping)); + + travelCommand.leaveTravel(100L, 1L); + + assertThat(travel.getMemberCount()).isEqualTo(1); + verify(userTravelItineraryJpaRepository).delete(mapping); + verify(userTravelItineraryJpaRepository).flush(); + } + + @Test + @DisplayName("여행 멤버 매핑이 없으면 USER_TRAVEL_ITINERARY_NOT_FOUND를 던진다") + void leaveTravel_throwsWhenMemberMappingMissing() { + when(userTravelItineraryJpaRepository.findByUserIdAndTravelItineraryIdAndUserRole(1L, 100L, UserRole.MEMBER)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> travelCommand.leaveTravel(100L, 1L)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException) exception).getErrorCode()) + .isEqualTo(UserTravelItineraryErrorCode.USER_TRAVEL_ITINERARY_NOT_FOUND)); + } + + private Group group(final Long id) { + Group group = Group.create(GroupKind.PUBLIC, "group", "desc", "thumb", 10); + ReflectionTestUtils.setField(group, "id", id); + return group; + } + + private User user(final Long id, final String nickname) { + User user = User.builder() + .nickname(nickname) + .description("desc") + .profileUrl("profile") + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private TravelItinerary travel(final Long id, final Group group, final String title) { + TravelItinerary travelItinerary = TravelItinerary.of( + title, + LocalDateTime.of(2026, 1, 1, 10, 0), + LocalDateTime.of(2026, 1, 2, 10, 0), + group, + "desc" + ); + ReflectionTestUtils.setField(travelItinerary, "id", id); + return travelItinerary; + } +} diff --git a/src/test/java/org/triple/backend/travel/application/TravelReaderTest.java b/src/test/java/org/triple/backend/travel/application/TravelReaderTest.java new file mode 100644 index 00000000..0dc3772e --- /dev/null +++ b/src/test/java/org/triple/backend/travel/application/TravelReaderTest.java @@ -0,0 +1,221 @@ +package org.triple.backend.travel.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.triple.backend.global.error.BusinessException; +import org.triple.backend.group.domain.group.Group; +import org.triple.backend.group.domain.group.GroupKind; +import org.triple.backend.travel.entity.TravelItinerary; +import org.triple.backend.travel.entity.UserRole; +import org.triple.backend.travel.entity.UserTravelItinerary; +import org.triple.backend.travel.exception.TravelItineraryErrorCode; +import org.triple.backend.travel.exception.UserTravelItineraryErrorCode; +import org.triple.backend.travel.presentation.dto.res.TravelItineraryCursorResponseDto.TravelSummaryDto; +import org.triple.backend.travel.presentation.dto.res.TravelItineraryInfoResponseDto; +import org.triple.backend.travel.repository.TravelItineraryJpaRepository; +import org.triple.backend.travel.repository.TravelReviewImageJpaRepository; +import org.triple.backend.travel.repository.TravelReviewJpaRepository; +import org.triple.backend.travel.repository.UserTravelItineraryJpaRepository; +import org.triple.backend.user.domain.User; + +@ExtendWith(MockitoExtension.class) +class TravelReaderTest { + + @Mock + private TravelItineraryJpaRepository travelItineraryJpaRepository; + + @Mock + private TravelReviewJpaRepository travelReviewJpaRepository; + + @Mock + private TravelReviewImageJpaRepository travelReviewImageJpaRepository; + + @Mock + private UserTravelItineraryJpaRepository userTravelItineraryJpaRepository; + + @InjectMocks + private TravelReader travelReader; + + @Test + @DisplayName("그룹 여행 수 조회는 repository에 위임한다") + void countGroupTravels_delegatesRepository() { + when(travelItineraryJpaRepository.countByGroupId(10L)).thenReturn(7L); + + long count = travelReader.countGroupTravels(10L); + + assertThat(count).isEqualTo(7L); + } + + @Test + @DisplayName("그룹 여행 목록 조회는 repository에 위임한다") + void readGroupTravels_delegatesRepository() { + List rows = List.of(summary(1L), summary(2L)); + when(travelItineraryJpaRepository.findGroupTravels(10L, 10, 20L)).thenReturn(rows); + + List result = travelReader.readGroupTravels(10L, 10, 20L); + + assertThat(result).isSameAs(rows); + } + + @Test + @DisplayName("여행이 존재하면 엔티티를 반환한다") + void findTravel_returnsTravel() { + TravelItinerary travel = travel(100L, group(10L), "trip"); + when(travelItineraryJpaRepository.findById(100L)).thenReturn(Optional.of(travel)); + + TravelItinerary result = travelReader.findTravel(100L); + + assertThat(result).isSameAs(travel); + } + + @Test + @DisplayName("여행이 없으면 TRAVEL_NOT_FOUND를 던진다") + void findTravel_throwsWhenMissing() { + when(travelItineraryJpaRepository.findById(100L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> travelReader.findTravel(100L)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException) exception).getErrorCode()) + .isEqualTo(TravelItineraryErrorCode.TRAVEL_NOT_FOUND)); + } + + @Test + @DisplayName("status가 null이면 여행 멤버를 조회한다") + void findUserTravelByStatus_returnsMemberWhenRoleNull() { + TravelItinerary travel = travel(100L, group(10L), "trip"); + UserTravelItinerary member = UserTravelItinerary.of(user(1L, "member"), travel, UserRole.MEMBER); + when(userTravelItineraryJpaRepository.findByUserIdAndTravelItineraryId(1L, 100L)) + .thenReturn(Optional.of(member)); + + UserTravelItinerary result = travelReader.findUserTravelByStatus(1L, 100L, null); + + assertThat(result).isSameAs(member); + } + + @Test + @DisplayName("status가 null인데 멤버가 없으면 USER_TRAVEL_ITINERARY_NOT_FOUND를 던진다") + void findUserTravelByStatus_throwsWhenMemberMissing() { + when(userTravelItineraryJpaRepository.findByUserIdAndTravelItineraryId(1L, 100L)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> travelReader.findUserTravelByStatus(1L, 100L, null)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException) exception).getErrorCode()) + .isEqualTo(UserTravelItineraryErrorCode.USER_TRAVEL_ITINERARY_NOT_FOUND)); + } + + @Test + @DisplayName("status가 있으면 해당 role의 멤버를 조회한다") + void findUserTravelByStatus_returnsByRole() { + TravelItinerary travel = travel(100L, group(10L), "trip"); + UserTravelItinerary leader = UserTravelItinerary.of(user(1L, "leader"), travel, UserRole.LEADER); + when(userTravelItineraryJpaRepository.findByUserIdAndTravelItineraryIdAndUserRole(1L, 100L, UserRole.LEADER)) + .thenReturn(Optional.of(leader)); + + UserTravelItinerary result = travelReader.findUserTravelByStatus(1L, 100L, UserRole.LEADER); + + assertThat(result).isSameAs(leader); + } + + @Test + @DisplayName("role 조건 조회에 실패하면 USER_TRAVEL_NOT_FOUND를 던진다") + void findUserTravelByStatus_throwsWhenRoleMissing() { + when(userTravelItineraryJpaRepository.findByUserIdAndTravelItineraryIdAndUserRole(1L, 100L, UserRole.LEADER)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> travelReader.findUserTravelByStatus(1L, 100L, UserRole.LEADER)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException) exception).getErrorCode()) + .isEqualTo(TravelItineraryErrorCode.USER_TRAVEL_NOT_FOUND)); + } + + @Test + @DisplayName("여행 잠금 조회가 성공하면 엔티티를 반환한다") + void findTravelForUpdate_returnsTravel() { + TravelItinerary travel = travel(100L, group(10L), "trip"); + when(travelItineraryJpaRepository.findByIdAndIsDeletedFalseForUpdate(100L)).thenReturn(Optional.of(travel)); + + TravelItinerary result = travelReader.findTravelForUpdate(100L); + + assertThat(result).isSameAs(travel); + verify(travelItineraryJpaRepository).findByIdAndIsDeletedFalseForUpdate(100L); + } + + @Test + @DisplayName("여행 잠금 조회가 실패하면 TRAVEL_NOT_FOUND를 던진다") + void findTravelForUpdate_throwsWhenMissing() { + when(travelItineraryJpaRepository.findByIdAndIsDeletedFalseForUpdate(100L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> travelReader.findTravelForUpdate(100L)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException) exception).getErrorCode()) + .isEqualTo(TravelItineraryErrorCode.TRAVEL_NOT_FOUND)); + } + + @Test + @DisplayName("여행 정보 조회는 멤버 목록을 응답 DTO로 변환한다") + void getTravelInfo_mapsMembers() { + TravelItinerary travel = travel(100L, group(10L), "trip"); + UserTravelItinerary leader = UserTravelItinerary.of(user(1L, "leader"), travel, UserRole.LEADER); + UserTravelItinerary member = UserTravelItinerary.of(user(2L, "member"), travel, UserRole.MEMBER); + when(userTravelItineraryJpaRepository.findAllByTravelItineraryId(100L)).thenReturn(List.of(leader, member)); + + TravelItineraryInfoResponseDto result = travelReader.getTravelInfo(travel); + + assertThat(result.title()).isEqualTo("trip"); + assertThat(result.members()).hasSize(2); + assertThat(result.members().get(0).nickname()).isEqualTo("leader"); + } + + private Group group(final Long id) { + Group group = Group.create(GroupKind.PUBLIC, "group", "desc", "thumb", 10); + ReflectionTestUtils.setField(group, "id", id); + return group; + } + + private User user(final Long id, final String nickname) { + User user = User.builder() + .nickname(nickname) + .description("desc") + .profileUrl("profile") + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private TravelItinerary travel(final Long id, final Group group, final String title) { + TravelItinerary travelItinerary = TravelItinerary.of( + title, + LocalDateTime.of(2026, 1, 1, 10, 0), + LocalDateTime.of(2026, 1, 2, 10, 0), + group, + "desc" + ); + ReflectionTestUtils.setField(travelItinerary, "id", id); + return travelItinerary; + } + + private TravelSummaryDto summary(final Long id) { + return new TravelSummaryDto( + id, + "trip-" + id, + "desc", + LocalDateTime.of(2026, 1, 1, 10, 0), + LocalDateTime.of(2026, 1, 2, 10, 0), + 2 + ); + } +} diff --git a/src/test/java/org/triple/backend/travel/application/TravelUseCaseTest.java b/src/test/java/org/triple/backend/travel/application/TravelUseCaseTest.java new file mode 100644 index 00000000..31354c7d --- /dev/null +++ b/src/test/java/org/triple/backend/travel/application/TravelUseCaseTest.java @@ -0,0 +1,260 @@ +package org.triple.backend.travel.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.triple.backend.auth.crypto.UserIdentityResolver; +import org.triple.backend.auth.jwt.JwtManager; +import org.triple.backend.global.error.BusinessException; +import org.triple.backend.group.application.GroupReader; +import org.triple.backend.group.domain.group.Group; +import org.triple.backend.group.domain.group.GroupKind; +import org.triple.backend.travel.entity.TravelItinerary; +import org.triple.backend.travel.entity.UserRole; +import org.triple.backend.travel.entity.UserTravelItinerary; +import org.triple.backend.travel.presentation.dto.req.TravelItineraryAddUserDto; +import org.triple.backend.travel.presentation.dto.req.TravelItinerarySaveRequestDto; +import org.triple.backend.travel.presentation.dto.res.TravelItineraryCursorResponseDto; +import org.triple.backend.travel.presentation.dto.res.TravelItinerarySaveResponseDto; +import org.triple.backend.user.application.UserReader; +import org.triple.backend.user.domain.User; +import org.triple.backend.user.exception.UserErrorCode; + +@ExtendWith(MockitoExtension.class) +class TravelUseCaseTest { + + @Mock + private UserReader userReader; + + @Mock + private GroupReader groupReader; + + @Mock + private TravelReader travelReader; + + @Mock + private TravelCommand travelCommand; + + @Mock + private UserIdentityResolver userIdentityResolver; + + @Mock + private JwtManager jwtManager; + + @InjectMocks + private TravelUseCase travelUseCase; + + @Test + @DisplayName("여행 생성 시 리더를 제외한 멤버만 command에 전달한다") + void saveTravelItinerary_filtersLeaderFromMembers() { + User leader = user(1L, "leader"); + User member = user(2L, "member"); + Group group = group(10L); + TravelItinerarySaveRequestDto req = new TravelItinerarySaveRequestDto( + "trip", + LocalDateTime.of(2026, 1, 1, 10, 0), + LocalDateTime.of(2026, 1, 3, 10, 0), + 10L, + "desc", + List.of("leader-uuid", "member-uuid") + ); + TravelItinerary saved = travel(100L, group, "trip"); + + when(userReader.findUserById(1L)).thenReturn(leader); + when(groupReader.findGroup(10L)).thenReturn(group); + when(userIdentityResolver.resolve("leader-uuid")).thenReturn(1L); + when(userIdentityResolver.resolve("member-uuid")).thenReturn(2L); + when(userReader.findUsersInIds(List.of(1L, 2L))).thenReturn(List.of(leader, member)); + when(travelCommand.saveTravelItinerary( + eq(leader), + eq(group), + eq("trip"), + any(LocalDateTime.class), + any(LocalDateTime.class), + eq("desc"), + anyList() + )) + .thenReturn(saved); + + TravelItinerarySaveResponseDto result = travelUseCase.saveTravelItinerary(req, 1L); + + assertThat(result.itineraryId()).isEqualTo(100L); + verify(groupReader).findJoinedUserGroup(10L, 1L); + + ArgumentCaptor> membersCaptor = ArgumentCaptor.forClass(List.class); + verify(travelCommand).saveTravelItinerary(eq(leader), eq(group), eq("trip"), any(), any(), eq("desc"), + membersCaptor.capture()); + assertThat(membersCaptor.getValue()).extracting(User::getId).containsExactly(2L); + } + + @Test + @DisplayName("여행 참여는 그룹 멤버 확인 후 command에 위임한다") + void joinTravel_delegatesAfterGroupMemberCheck() { + User user = user(1L, "member"); + Group group = group(10L); + TravelItinerary travel = travel(100L, group, "trip"); + + when(travelReader.findTravel(100L)).thenReturn(travel); + when(userReader.findUserById(1L)).thenReturn(user); + + travelUseCase.joinTravel(100L, 1L); + + verify(groupReader).findJoinedUserGroup(10L, 1L); + verify(travelCommand).addUserTravelItinerary(user, travel, UserRole.MEMBER); + } + + @Test + @DisplayName("여행 멤버 추가는 리더 검증과 uuid 해석 후 command에 위임한다") + void addTravelUser_resolvesUuidAndDelegates() { + User leader = user(1L, "leader"); + User addUser = user(20L, "member"); + Group group = group(10L); + TravelItinerary travel = travel(100L, group, "trip"); + UserTravelItinerary leaderMapping = UserTravelItinerary.of(leader, travel, UserRole.LEADER); + + when(travelReader.findTravel(100L)).thenReturn(travel); + when(travelReader.findUserTravelByStatus(1L, 100L, UserRole.LEADER)).thenReturn(leaderMapping); + when(userIdentityResolver.resolve("public-uuid")).thenReturn(20L); + when(userReader.findUserById(20L)).thenReturn(addUser); + + travelUseCase.addTravelUser(100L, 1L, new TravelItineraryAddUserDto("public-uuid")); + + verify(travelCommand).addUserTravelItinerary(addUser, travel, UserRole.MEMBER); + } + + @Test + @DisplayName("uuid 해석 결과가 없으면 USER_NOT_FOUND를 던진다") + void addTravelUser_throwsWhenUuidResolveFails() { + Group group = group(10L); + TravelItinerary travel = travel(100L, group, "trip"); + + when(travelReader.findTravel(100L)).thenReturn(travel); + when(travelReader.findUserTravelByStatus(1L, 100L, UserRole.LEADER)) + .thenReturn(UserTravelItinerary.of(user(1L, "leader"), travel, UserRole.LEADER)); + when(userIdentityResolver.resolve("bad-uuid")).thenReturn(null); + + assertThatThrownBy(() -> travelUseCase.addTravelUser(100L, 1L, new TravelItineraryAddUserDto("bad-uuid"))) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException) exception).getErrorCode()) + .isEqualTo(UserErrorCode.USER_NOT_FOUND)); + } + + @Test + @DisplayName("비로그인 조회는 countOnly 응답을 반환한다") + void browseTravels_returnsCountOnlyWhenAnonymous() { + when(travelReader.countGroupTravels(10L)).thenReturn(3L); + + TravelItineraryCursorResponseDto result = travelUseCase.browseTravels(10L, null, 10, null); + + assertThat(result.count()).isEqualTo(3L); + assertThat(result.items()).isEmpty(); + assertThat(result.hasNext()).isFalse(); + verify(groupReader, never()).existsJoinedUserGroup(any(), any()); + verify(travelReader, never()).readGroupTravels(any(), anyInt(), any()); + } + + @Test + @DisplayName("그룹 미가입 사용자는 countOnly 응답을 반환한다") + void browseTravels_returnsCountOnlyWhenNotJoined() { + when(travelReader.countGroupTravels(10L)).thenReturn(4L); + when(groupReader.existsJoinedUserGroup(10L, 1L)).thenReturn(false); + + TravelItineraryCursorResponseDto result = travelUseCase.browseTravels(10L, null, 10, 1L); + + assertThat(result.count()).isEqualTo(4L); + assertThat(result.items()).isEmpty(); + verify(travelReader, never()).readGroupTravels(any(), anyInt(), any()); + } + + @Test + @DisplayName("그룹 가입 사용자는 size+1 결과로 nextCursor와 hasNext를 계산한다") + void browseTravels_calculatesCursorAndHasNext() { + when(travelReader.countGroupTravels(10L)).thenReturn(20L); + when(groupReader.existsJoinedUserGroup(10L, 1L)).thenReturn(true); + when(travelReader.readGroupTravels(10L, 10, null)).thenReturn(List.of( + summary(110L), summary(109L), summary(108L), summary(107L), summary(106L), + summary(105L), summary(104L), summary(103L), summary(102L), summary(101L), summary(100L) + )); + + TravelItineraryCursorResponseDto result = travelUseCase.browseTravels(10L, null, 50, 1L); + + assertThat(result.items()).hasSize(10); + assertThat(result.hasNext()).isTrue(); + assertThat(result.nextCursor()).isEqualTo(100L); + assertThat(result.count()).isEqualTo(20L); + } + + @Test + @DisplayName("2차 토큰 발급은 여행과 멤버 여부를 확인 후 JwtManager에 위임한다") + void issueSecondaryToken_delegatesJwtManager() { + Group group = group(10L); + TravelItinerary travel = travel(100L, group, "trip"); + User user = user(1L, "member"); + UserTravelItinerary member = UserTravelItinerary.of(user, travel, UserRole.MEMBER); + + when(travelReader.findTravel(100L)).thenReturn(travel); + when(travelReader.findUserTravelByStatus(1L, 100L, null)).thenReturn(member); + when(jwtManager.createSecondaryToken(1L, 100L)).thenReturn("secondary-token"); + + String token = travelUseCase.issueSecondaryToken(100L, 1L); + + assertThat(token).isEqualTo("secondary-token"); + verify(jwtManager).createSecondaryToken(1L, 100L); + } + + private Group group(final Long id) { + Group group = Group.create(GroupKind.PUBLIC, "group", "desc", "thumb", 10); + ReflectionTestUtils.setField(group, "id", id); + return group; + } + + private User user(final Long id, final String nickname) { + User user = User.builder() + .nickname(nickname) + .description("desc") + .profileUrl("profile") + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private TravelItinerary travel(final Long id, final Group group, final String title) { + TravelItinerary travelItinerary = TravelItinerary.of( + title, + LocalDateTime.of(2026, 1, 1, 10, 0), + LocalDateTime.of(2026, 1, 2, 10, 0), + group, + "desc" + ); + ReflectionTestUtils.setField(travelItinerary, "id", id); + return travelItinerary; + } + + private TravelItineraryCursorResponseDto.TravelSummaryDto summary(final Long id) { + return new TravelItineraryCursorResponseDto.TravelSummaryDto( + id, + "trip-" + id, + "desc", + LocalDateTime.of(2026, 1, 1, 10, 0), + LocalDateTime.of(2026, 1, 2, 10, 0), + 2 + ); + } +} From 022813afbeec050bf5e8df2b9436e29cbe08ed4f Mon Sep 17 00:00:00 2001 From: jeongjiho Date: Fri, 24 Apr 2026 19:32:16 +0900 Subject: [PATCH 4/8] =?UTF-8?q?[#223]=20test:=20travel=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20REST=20Docs=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TravelItineraryControllerTest.java | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 src/test/java/org/triple/backend/travel/presentation/TravelItineraryControllerTest.java diff --git a/src/test/java/org/triple/backend/travel/presentation/TravelItineraryControllerTest.java b/src/test/java/org/triple/backend/travel/presentation/TravelItineraryControllerTest.java new file mode 100644 index 00000000..647a3327 --- /dev/null +++ b/src/test/java/org/triple/backend/travel/presentation/TravelItineraryControllerTest.java @@ -0,0 +1,305 @@ +package org.triple.backend.travel.presentation; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.triple.backend.common.ControllerTest; +import org.triple.backend.travel.application.TravelUseCase; +import org.triple.backend.travel.entity.UserRole; +import org.triple.backend.travel.presentation.dto.req.TravelItinerarySaveRequestDto; +import org.triple.backend.travel.presentation.dto.res.TravelItineraryCursorResponseDto; +import org.triple.backend.travel.presentation.dto.res.TravelItineraryInfoResponseDto; +import org.triple.backend.travel.presentation.dto.res.TravelItinerarySaveResponseDto; + +@WebMvcTest(TravelItineraryController.class) +class TravelItineraryControllerTest extends ControllerTest { + + @MockitoBean + private TravelUseCase travelUseCase; + + @Test + @DisplayName("여행 생성 API") + void saveTravelItinerary() throws Exception { + whenSaveTravelReturns(); + + mockMvc.perform(post("/travels") + .with(loginJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content(validSaveBody())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.itineraryId").value(100L)) + .andDo(document("travels/create", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields(saveRequestFields()), + responseFields(fieldWithPath("itineraryId").description("생성된 여행 ID")) + )); + + verify(travelUseCase, times(1)).saveTravelItinerary(any(TravelItinerarySaveRequestDto.class), eq(1L)); + } + + @Test + @DisplayName("내 여행 참여 API") + void joinTravel() throws Exception { + mockMvc.perform(post("/travels/{travelId}/users/me", 100L).with(loginJwt())) + .andExpect(status().isOk()) + .andDo(document("travels/join-me", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters(parameterWithName("travelId").description("여행 ID")) + )); + + verify(travelUseCase).joinTravel(100L, 1L); + } + + @Test + @DisplayName("여행 멤버 추가 API") + void addTravelUser() throws Exception { + mockMvc.perform(post("/travels/{travelId}/user", 100L) + .with(loginJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "userUuid": "public-user-uuid" + } + """)) + .andExpect(status().isOk()) + .andDo(document("travels/add-user", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters(parameterWithName("travelId").description("여행 ID")), + requestFields(fieldWithPath("userUuid").description("추가할 유저 public uuid")) + )); + + verify(travelUseCase).addTravelUser(eq(100L), eq(1L), any()); + } + + @Test + @DisplayName("여행 수정 API") + void updateTravel() throws Exception { + mockMvc.perform(patch("/travels/{travelId}", 100L) + .with(loginJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "title": "updated-trip", + "startAt": "2026-03-01T10:00:00", + "endAt": "2026-03-03T10:00:00", + "description": "updated-desc" + } + """)) + .andExpect(status().isOk()) + .andDo(document("travels/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters(parameterWithName("travelId").description("여행 ID")), + requestFields(updateRequestFields()) + )); + + verify(travelUseCase).updateTravel(any(), eq(100L), eq(1L)); + } + + @Test + @DisplayName("여행 삭제 API") + void deleteTravel() throws Exception { + mockMvc.perform(delete("/travels/{travelId}", 100L).with(loginJwt())) + .andExpect(status().isOk()) + .andDo(document("travels/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters(parameterWithName("travelId").description("여행 ID")) + )); + + verify(travelUseCase).deleteTravel(100L, 1L); + } + + @Test + @DisplayName("여행 목록 조회 API") + void browseTravels() throws Exception { + TravelItineraryCursorResponseDto response = new TravelItineraryCursorResponseDto( + List.of(new TravelItineraryCursorResponseDto.TravelSummaryDto( + 100L, + "trip", + "desc", + LocalDateTime.of(2026, 3, 1, 10, 0), + LocalDateTime.of(2026, 3, 3, 10, 0), + 3 + )), + null, + false, + 1L + ); + org.mockito.BDDMockito.given(travelUseCase.browseTravels(10L, null, 10, 1L)).willReturn(response); + + mockMvc.perform(get("/travels/{groupId}", 10L) + .with(loginJwt()) + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].id").value(100L)) + .andDo(document("travels/browse", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters(parameterWithName("groupId").description("그룹 ID")), + queryParameters( + parameterWithName("cursor").description("다음 페이지 커서").optional(), + parameterWithName("size").description("페이지 크기").optional() + ), + responseFields(browseResponseFields()) + )); + } + + @Test + @DisplayName("여행 나가기 API") + void leaveTravel() throws Exception { + mockMvc.perform(delete("/travels/{travelId}/users/me", 100L).with(loginJwt())) + .andExpect(status().isOk()) + .andDo(document("travels/leave", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters(parameterWithName("travelId").description("여행 ID")) + )); + + verify(travelUseCase).leaveTravel(100L, 1L); + } + + @Test + @DisplayName("여행 상세 정보 조회 API") + void getTravelInfo() throws Exception { + TravelItineraryInfoResponseDto response = new TravelItineraryInfoResponseDto( + "trip", + LocalDateTime.of(2026, 3, 1, 10, 0), + LocalDateTime.of(2026, 3, 3, 10, 0), + List.of( + new TravelItineraryInfoResponseDto.TravelMemberDto("leader", "profile1", UserRole.LEADER), + new TravelItineraryInfoResponseDto.TravelMemberDto("member", "profile2", UserRole.MEMBER) + ) + ); + org.mockito.BDDMockito.given(travelUseCase.getTravelInfo(100L, 1L)).willReturn(response); + + mockMvc.perform(get("/travels/{travelId}/info", 100L).with(loginJwt())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("trip")) + .andDo(document("travels/info", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters(parameterWithName("travelId").description("여행 ID")), + responseFields(infoResponseFields()) + )); + } + + @Test + @DisplayName("여행 2차 토큰 발급 API") + void issueSecondaryToken() throws Exception { + org.mockito.BDDMockito.given(travelUseCase.issueSecondaryToken(100L, 1L)).willReturn("secondary-token"); + + mockMvc.perform(post("/travels/{travelId}/secondary-token", 100L).with(loginJwt())) + .andExpect(status().isOk()) + .andExpect(header().string("Secondary-Authorization", "Bearer secondary-token")) + .andDo(document("travels/issue-secondary-token", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters(parameterWithName("travelId").description("여행 ID")), + responseHeaders(headerWithName("Secondary-Authorization").description("2차 인증 Bearer 토큰")) + )); + } + + private void whenSaveTravelReturns() { + org.mockito.BDDMockito.given(travelUseCase.saveTravelItinerary(any(TravelItinerarySaveRequestDto.class), eq(1L))) + .willReturn(TravelItinerarySaveResponseDto.from(100L)); + } + + private List saveRequestFields() { + return List.of( + fieldWithPath("title").description("여행 제목"), + fieldWithPath("startAt").description("여행 시작일시"), + fieldWithPath("endAt").description("여행 종료일시"), + fieldWithPath("groupId").description("그룹 ID"), + fieldWithPath("description").description("여행 설명"), + fieldWithPath("memberUuids").description("추가할 멤버 uuid 목록") + ); + } + + private List updateRequestFields() { + return List.of( + fieldWithPath("title").description("여행 제목").optional(), + fieldWithPath("startAt").description("여행 시작일시").optional(), + fieldWithPath("endAt").description("여행 종료일시").optional(), + fieldWithPath("description").description("여행 설명").optional() + ); + } + + private List browseResponseFields() { + return List.of( + fieldWithPath("items[].id").description("여행 ID"), + fieldWithPath("items[].title").description("여행 제목"), + fieldWithPath("items[].description").description("여행 설명"), + fieldWithPath("items[].startAt").description("시작일시"), + fieldWithPath("items[].endAt").description("종료일시"), + fieldWithPath("items[].memberCount").description("멤버 수"), + fieldWithPath("nextCursor").description("다음 커서").optional(), + fieldWithPath("hasNext").description("다음 페이지 존재 여부"), + fieldWithPath("count").description("총 여행 수") + ); + } + + private List infoResponseFields() { + return List.of( + fieldWithPath("title").description("여행 제목"), + fieldWithPath("startAt").description("여행 시작일시"), + fieldWithPath("endAt").description("여행 종료일시"), + fieldWithPath("members[].nickname").description("멤버 닉네임"), + fieldWithPath("members[].profileUrl").description("멤버 프로필 URL"), + fieldWithPath("members[].userRole").description("멤버 역할") + ); + } + + private String validSaveBody() { + return """ + { + "title": "trip", + "startAt": "2026-03-01T10:00:00", + "endAt": "2026-03-03T10:00:00", + "groupId": 10, + "description": "desc", + "memberUuids": ["u1", "u2"] + } + """; + } + + private RequestPostProcessor loginJwt() { + return request -> { + request.addHeader("Authorization", "Bearer test-token"); + return request; + }; + } +} From 3d4d2cccee68b1943997d7ca503dde33517029a2 Mon Sep 17 00:00:00 2001 From: jeongjiho Date: Fri, 24 Apr 2026 19:32:31 +0900 Subject: [PATCH 5/8] =?UTF-8?q?[#223]=20test:=20travel=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../travel/entity/TravelItineraryTest.java | 111 ++++++++++++++++++ .../entity/UserTravelItineraryTest.java | 60 ++++++++++ 2 files changed, 171 insertions(+) create mode 100644 src/test/java/org/triple/backend/travel/entity/TravelItineraryTest.java create mode 100644 src/test/java/org/triple/backend/travel/entity/UserTravelItineraryTest.java diff --git a/src/test/java/org/triple/backend/travel/entity/TravelItineraryTest.java b/src/test/java/org/triple/backend/travel/entity/TravelItineraryTest.java new file mode 100644 index 00000000..9096bc36 --- /dev/null +++ b/src/test/java/org/triple/backend/travel/entity/TravelItineraryTest.java @@ -0,0 +1,111 @@ +package org.triple.backend.travel.entity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.triple.backend.group.domain.group.Group; +import org.triple.backend.group.domain.group.GroupKind; + +class TravelItineraryTest { + + @Test + @DisplayName("valid values create travel itinerary with default member count 1") + void create() { + TravelItinerary travel = createTravel(); + + assertThat(travel.getTitle()).isEqualTo("Trip"); + assertThat(travel.getDescription()).isEqualTo("desc"); + assertThat(travel.getMemberCount()).isEqualTo(1); + } + + @Test + @DisplayName("invalid create values throw IllegalArgumentException") + void createInvalid() { + Group group = createGroup(); + LocalDateTime startAt = LocalDateTime.of(2026, 1, 1, 10, 0); + LocalDateTime endAt = LocalDateTime.of(2026, 1, 2, 10, 0); + + assertThatThrownBy(() -> TravelItinerary.of(null, startAt, endAt, group, "desc")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> TravelItinerary.of("Trip", null, endAt, group, "desc")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> TravelItinerary.of("Trip", startAt, null, group, "desc")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> TravelItinerary.of("Trip", startAt, startAt, group, "desc")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> TravelItinerary.of("Trip", startAt, endAt, null, "desc")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("change methods update values and null values are ignored") + void changeFields() { + TravelItinerary travel = createTravel(); + LocalDateTime newStart = LocalDateTime.of(2026, 1, 1, 11, 0); + LocalDateTime newEnd = LocalDateTime.of(2026, 1, 3, 10, 0); + + travel.changeTitle("Updated"); + travel.changeStartAt(newStart); + travel.changeEndAt(newEnd); + travel.changeDescription("new-desc"); + + assertThat(travel.getTitle()).isEqualTo("Updated"); + assertThat(travel.getStartAt()).isEqualTo(newStart); + assertThat(travel.getEndAt()).isEqualTo(newEnd); + assertThat(travel.getDescription()).isEqualTo("new-desc"); + + travel.changeTitle(null); + travel.changeDescription(null); + assertThat(travel.getTitle()).isEqualTo("Updated"); + assertThat(travel.getDescription()).isEqualTo("new-desc"); + } + + @Test + @DisplayName("invalid date update throws IllegalArgumentException") + void changeDateInvalid() { + TravelItinerary travel = createTravel(); + + assertThatThrownBy(() -> travel.changeStartAt(LocalDateTime.of(2026, 1, 3, 10, 0))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> travel.changeEndAt(LocalDateTime.of(2025, 12, 31, 23, 59))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("member count increases and decreases in valid range") + void changeMemberCount() { + TravelItinerary travel = createTravel(); + + travel.increaseMemberCount(2); + assertThat(travel.getMemberCount()).isEqualTo(3); + + travel.decreaseMemberCount(); + assertThat(travel.getMemberCount()).isEqualTo(2); + } + + @Test + @DisplayName("cannot decrease member count below 1") + void decreaseMemberCountInvalid() { + TravelItinerary travel = createTravel(); + + assertThatThrownBy(travel::decreaseMemberCount) + .isInstanceOf(IllegalArgumentException.class); + } + + private TravelItinerary createTravel() { + return TravelItinerary.of( + "Trip", + LocalDateTime.of(2026, 1, 1, 10, 0), + LocalDateTime.of(2026, 1, 2, 10, 0), + createGroup(), + "desc" + ); + } + + private Group createGroup() { + return Group.create(GroupKind.PUBLIC, "group", "desc", "thumb", 10); + } +} diff --git a/src/test/java/org/triple/backend/travel/entity/UserTravelItineraryTest.java b/src/test/java/org/triple/backend/travel/entity/UserTravelItineraryTest.java new file mode 100644 index 00000000..b35b24f0 --- /dev/null +++ b/src/test/java/org/triple/backend/travel/entity/UserTravelItineraryTest.java @@ -0,0 +1,60 @@ +package org.triple.backend.travel.entity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.triple.backend.group.domain.group.Group; +import org.triple.backend.group.domain.group.GroupKind; +import org.triple.backend.user.domain.User; + +class UserTravelItineraryTest { + + @Test + @DisplayName("valid values create user-travel mapping") + void create() { + User user = createUser("member"); + TravelItinerary travel = createTravel(); + + UserTravelItinerary mapping = UserTravelItinerary.of(user, travel, UserRole.MEMBER); + + assertThat(mapping.getUser()).isEqualTo(user); + assertThat(mapping.getTravelItinerary()).isEqualTo(travel); + assertThat(mapping.getUserRole()).isEqualTo(UserRole.MEMBER); + } + + @Test + @DisplayName("null values throw IllegalArgumentException") + void createInvalid() { + User user = createUser("member"); + TravelItinerary travel = createTravel(); + + assertThatThrownBy(() -> UserTravelItinerary.of(null, travel, UserRole.MEMBER)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> UserTravelItinerary.of(user, null, UserRole.MEMBER)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> UserTravelItinerary.of(user, travel, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + private User createUser(final String nickname) { + return User.builder() + .nickname(nickname) + .description("desc") + .profileUrl("profile") + .build(); + } + + private TravelItinerary createTravel() { + Group group = Group.create(GroupKind.PUBLIC, "group", "desc", "thumb", 10); + return TravelItinerary.of( + "Trip", + LocalDateTime.of(2026, 1, 1, 10, 0), + LocalDateTime.of(2026, 1, 2, 10, 0), + group, + "desc" + ); + } +} From 6c5d1958419b67e62093aa40a44d9c673ca7efbc Mon Sep 17 00:00:00 2001 From: jeongjiho Date: Fri, 24 Apr 2026 19:32:43 +0900 Subject: [PATCH 6/8] =?UTF-8?q?[#223]=20test:=20travel=20API=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration/TravelIntegrationTest.java | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 src/test/java/org/triple/backend/travel/integration/TravelIntegrationTest.java diff --git a/src/test/java/org/triple/backend/travel/integration/TravelIntegrationTest.java b/src/test/java/org/triple/backend/travel/integration/TravelIntegrationTest.java new file mode 100644 index 00000000..7d87996e --- /dev/null +++ b/src/test/java/org/triple/backend/travel/integration/TravelIntegrationTest.java @@ -0,0 +1,259 @@ +package org.triple.backend.travel.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.triple.backend.auth.crypto.UuidCrypto; +import org.triple.backend.auth.jwt.JwtManager; +import org.triple.backend.auth.oauth.OauthProvider; +import org.triple.backend.common.DbCleaner; +import org.triple.backend.common.annotation.IntegrationTest; +import org.triple.backend.group.domain.group.Group; +import org.triple.backend.group.domain.group.GroupKind; +import org.triple.backend.group.domain.userGroup.JoinStatus; +import org.triple.backend.group.domain.userGroup.Role; +import org.triple.backend.group.domain.userGroup.UserGroup; +import org.triple.backend.group.repository.GroupJpaRepository; +import org.triple.backend.group.repository.UserGroupJpaRepository; +import org.triple.backend.travel.entity.TravelItinerary; +import org.triple.backend.travel.entity.UserRole; +import org.triple.backend.travel.entity.UserTravelItinerary; +import org.triple.backend.travel.repository.TravelItineraryJpaRepository; +import org.triple.backend.travel.repository.UserTravelItineraryJpaRepository; +import org.triple.backend.user.domain.User; +import org.triple.backend.user.persistence.UserJpaRepository; + +@IntegrationTest +class TravelIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private DbCleaner dbCleaner; + + @Autowired + private JwtManager jwtManager; + + @Autowired + private UuidCrypto uuidCrypto; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private GroupJpaRepository groupJpaRepository; + + @Autowired + private UserGroupJpaRepository userGroupJpaRepository; + + @Autowired + private TravelItineraryJpaRepository travelItineraryJpaRepository; + + @Autowired + private UserTravelItineraryJpaRepository userTravelItineraryJpaRepository; + + @BeforeEach + void setUp() { + dbCleaner.clean(); + } + + @Test + @DisplayName("group member creates travel itinerary") + void createTravel() throws Exception { + User leader = saveUser("travel-create-leader"); + User member = saveUser("travel-create-member"); + Group group = saveGroup("travel create group"); + saveUserGroup(leader, group, Role.OWNER); + saveUserGroup(member, group, Role.MEMBER); + + String encryptedMemberUuid = uuidCrypto.encrypt(member.getPublicUuid()); + + mockMvc.perform(post("/travels") + .header("Authorization", authorization(leader)) + .contentType(MediaType.APPLICATION_JSON) + .content(createTravelBody(group.getId(), encryptedMemberUuid))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.itineraryId").isNumber()); + + TravelItinerary savedTravel = travelItineraryJpaRepository.findAll().get(0); + assertThat(savedTravel.getMemberCount()).isEqualTo(2); + assertThat(userTravelItineraryJpaRepository.findAllByTravelItineraryId(savedTravel.getId())) + .hasSize(2); + } + + @Test + @DisplayName("joined group member joins travel") + void joinTravel() throws Exception { + User leader = saveUser("travel-join-leader"); + User joinUser = saveUser("travel-join-member"); + Group group = saveGroup("travel join group"); + saveUserGroup(leader, group, Role.OWNER); + saveUserGroup(joinUser, group, Role.MEMBER); + + TravelItinerary travel = saveTravel(group, "join target"); + saveUserTravel(leader, travel, UserRole.LEADER); + + mockMvc.perform(post("/travels/{travelId}/users/me", travel.getId()) + .header("Authorization", authorization(joinUser))) + .andExpect(status().isOk()); + + assertThat(userTravelItineraryJpaRepository + .findByUserIdAndTravelItineraryIdAndUserRole(joinUser.getId(), travel.getId(), UserRole.MEMBER)) + .isPresent(); + + TravelItinerary updated = travelItineraryJpaRepository.findById(travel.getId()).orElseThrow(); + assertThat(updated.getMemberCount()).isEqualTo(2); + } + + @Test + @DisplayName("joined member can browse and get travel info") + void browseAndInfo() throws Exception { + User leader = saveUser("travel-info-leader"); + User member = saveUser("travel-info-member"); + Group group = saveGroup("travel info group"); + saveUserGroup(leader, group, Role.OWNER); + saveUserGroup(member, group, Role.MEMBER); + + TravelItinerary travel = saveTravel(group, "info trip"); + saveUserTravel(leader, travel, UserRole.LEADER); + saveUserTravel(member, travel, UserRole.MEMBER); + travel.increaseMemberCount(1); + travelItineraryJpaRepository.saveAndFlush(travel); + + mockMvc.perform(get("/travels/{groupId}", group.getId()) + .header("Authorization", authorization(member)) + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items[0].id").value(travel.getId())) + .andExpect(jsonPath("$.count").value(1)); + + mockMvc.perform(get("/travels/{travelId}/info", travel.getId()) + .header("Authorization", authorization(member))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("info trip")) + .andExpect(jsonPath("$.members.length()").value(2)); + } + + @Test + @DisplayName("travel member can issue secondary token") + void issueSecondaryToken() throws Exception { + User leader = saveUser("travel-token-leader"); + User member = saveUser("travel-token-member"); + Group group = saveGroup("travel token group"); + saveUserGroup(leader, group, Role.OWNER); + saveUserGroup(member, group, Role.MEMBER); + + TravelItinerary travel = saveTravel(group, "token trip"); + saveUserTravel(leader, travel, UserRole.LEADER); + saveUserTravel(member, travel, UserRole.MEMBER); + travel.increaseMemberCount(1); + travelItineraryJpaRepository.saveAndFlush(travel); + + mockMvc.perform(post("/travels/{travelId}/secondary-token", travel.getId()) + .header("Authorization", authorization(member))) + .andExpect(status().isOk()) + .andExpect(header().string("Secondary-Authorization", org.hamcrest.Matchers.startsWith("Bearer "))); + } + + @Test + @DisplayName("member can leave travel") + void leaveTravel() throws Exception { + User leader = saveUser("travel-leave-leader"); + User member = saveUser("travel-leave-member"); + Group group = saveGroup("travel leave group"); + saveUserGroup(leader, group, Role.OWNER); + saveUserGroup(member, group, Role.MEMBER); + + TravelItinerary travel = saveTravel(group, "leave trip"); + saveUserTravel(leader, travel, UserRole.LEADER); + saveUserTravel(member, travel, UserRole.MEMBER); + travel.increaseMemberCount(1); + travelItineraryJpaRepository.saveAndFlush(travel); + + mockMvc.perform(delete("/travels/{travelId}/users/me", travel.getId()) + .header("Authorization", authorization(member))) + .andExpect(status().isOk()); + + assertThat(userTravelItineraryJpaRepository + .findByUserIdAndTravelItineraryIdAndUserRole(member.getId(), travel.getId(), UserRole.MEMBER)) + .isEmpty(); + TravelItinerary updated = travelItineraryJpaRepository.findById(travel.getId()).orElseThrow(); + assertThat(updated.getMemberCount()).isEqualTo(1); + } + + private User saveUser(final String providerId) { + return userJpaRepository.saveAndFlush(User.builder() + .provider(OauthProvider.KAKAO) + .providerId(providerId) + .nickname(providerId + "-nick") + .email(providerId + "@test.com") + .description(providerId + "-desc") + .profileUrl("http://img.test/" + providerId) + .build()); + } + + private Group saveGroup(final String name) { + return groupJpaRepository.saveAndFlush(Group.create( + GroupKind.PUBLIC, + name, + name + "-desc", + "http://thumb.test/" + name, + 20 + )); + } + + private UserGroup saveUserGroup(final User user, final Group group, final Role role) { + Group savedGroup = groupJpaRepository.findById(group.getId()).orElseThrow(); + savedGroup.addCurrentMemberCount(); + groupJpaRepository.saveAndFlush(savedGroup); + UserGroup userGroup = UserGroup.create(user, savedGroup, role); + return userGroupJpaRepository.saveAndFlush(userGroup); + } + + private TravelItinerary saveTravel(final Group group, final String title) { + TravelItinerary travel = TravelItinerary.of( + title, + LocalDateTime.of(2026, 4, 1, 10, 0), + LocalDateTime.of(2026, 4, 3, 10, 0), + group, + title + "-desc" + ); + return travelItineraryJpaRepository.saveAndFlush(travel); + } + + private UserTravelItinerary saveUserTravel(final User user, final TravelItinerary travel, final UserRole role) { + return userTravelItineraryJpaRepository.saveAndFlush(UserTravelItinerary.of(user, travel, role)); + } + + private String authorization(final User user) { + return "Bearer " + jwtManager.createAccessToken(user.getId()); + } + + private String createTravelBody(final Long groupId, final String memberUuid) { + return """ + { + "title": "spring trip", + "startAt": "2026-04-01T10:00:00", + "endAt": "2026-04-03T10:00:00", + "groupId": %d, + "description": "travel desc", + "memberUuids": ["%s"] + } + """.formatted(groupId, memberUuid); + } +} From 4f564408ef62175355638a363d34e78c813c75ca Mon Sep 17 00:00:00 2001 From: jeongjiho Date: Fri, 24 Apr 2026 19:32:58 +0900 Subject: [PATCH 7/8] =?UTF-8?q?[#223]=20chore:=20vibe=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EB=AC=B8=EC=84=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vibe/VIBE_PROMPT.md | 71 ---------- vibe/agent/AGENT_TEMPLATE.md | 36 ----- vibe/agent/TEST_AUTOMATION_AGENT.md | 210 ---------------------------- vibe/draft/DRAFT_TEMPLATE.md | 107 -------------- vibe/plan/PLAN_TEMPLATE.md | 72 ---------- 5 files changed, 496 deletions(-) delete mode 100644 vibe/VIBE_PROMPT.md delete mode 100644 vibe/agent/AGENT_TEMPLATE.md delete mode 100644 vibe/agent/TEST_AUTOMATION_AGENT.md delete mode 100644 vibe/draft/DRAFT_TEMPLATE.md delete mode 100644 vibe/plan/PLAN_TEMPLATE.md diff --git a/vibe/VIBE_PROMPT.md b/vibe/VIBE_PROMPT.md deleted file mode 100644 index eccfd17b..00000000 --- a/vibe/VIBE_PROMPT.md +++ /dev/null @@ -1,71 +0,0 @@ -# Spring Clone VIBE Prompt - -아래 규칙을 따르는 백엔드 AI 코딩 에이전트로 동작하라. - -## Mission - -- 프로젝트: Triple 여행 일정 실시간 협업 시스템 -- 목표: - - 실제, 운영될 프로덕트 - - 백엔드 기초 지식 학습(스프링, 네트워크, OS, Java) - - AI 활용 역량 강화 - -## Working Policy - -1. 먼저 JOB을 분석한다. -2. JOB을 구현 가능한 TASK로 분해한다. -3. 분석/분해 결과를 PLAN MD로 저장한다. -4. 사용자가 PLAN을 보고 질문/설계 피드백을 주면 반영한다. -5. 사용자가 명시적으로 `작업 진행`이라고 승인하면 TASK별 DRAFT MD를 작성한다. -6. 사용자가 TASK 구현 파일과 DRAFT를 리뷰한다. -7. 사용자가 명시적으로 `병합!`이라고 승인하면 코드 반영을 시작한다. -8. 병합은 테스트 기반으로 진행한다. -9. 병합 후 커밋 전 사용자 이슈번호를 반드시 확인한다. -10. 테스트 결과와 병합/커밋/산출물 정리(PLAN/DRAFT 삭제) 내용을 보고한다. - -## File Contract - -- PLAN 위치: `vibe/plan/{YYYYMMDD}-{job_slug}-plan.md` -- DRAFT 위치: `vibe/draft/{YYYYMMDD}-{task_id}-{task_slug}-draft.md` -- 템플릿: - - PLAN: `vibe/plan/PLAN_TEMPLATE.md` - - DRAFT: `vibe/draft/DRAFT_TEMPLATE.md` - -## Output Rules - -- PLAN 단계에서는 코드 파일을 직접 수정하지 않는다. -- 모든 요청에서 PLAN 작성은 필수다. -- DRAFT 단계에서는 실제 코드 반영 전, 변경 내용을 MD에 먼저 기록한다. -- 사용자 `작업 진행` 승인 전에는 DRAFT를 작성하지 않는다. -- DRAFT에는 "변경 전 코드"를 포함한다. 신규 파일이면 빈 코드블록(```` ``` ````)으로 표시한다. -- DRAFT의 `변경 파일 목록` 정합성 검증과 누락 파일 처리 규칙은 `AGENTS.md`의 `Merge Gate`를 따른다. -- 사용자 `병합!` 승인 전에는 코드 수정/병합을 진행하지 않는다. -- 병합 중 문제(컴파일/테스트 실패 포함)가 발생하면 PLAN에 이슈 대응 TASK를 신규 추가하고, 해당 TASK의 DRAFT를 신규 작성한다. -- 이슈 대응 TASK의 PLAN/DRAFT에 대한 사용자 확인 전에는 코드 수정/재병합을 진행하지 않는다. -- 병합 단계에서는 변경 파일, 테스트 명령, 테스트 결과를 함께 보고한다. -- 해당 JOB의 마지막 TASK 병합 완료 시점까지 DRAFT 파일을 유지하고, 마지막 TASK 병합 완료 후 일괄 삭제한다. (무조건) -- 병합 후 커밋 전 사용자 이슈번호 확인 없이는 커밋하지 않는다. -- 해당 JOB의 마지막 TASK 커밋 완료 시 PLAN 파일을 즉시 삭제한다. (무조건) -- 모든 TASK는 "완료 조건(Definition of Done)"과 "검증 방법"을 포함한다. -- 커밋은 TASK 단위로 분리하고, 서로 다른 TASK 변경을 한 커밋에 섞지 않는다. -- 커밋 전 사용자에게 이슈번호를 반드시 확인하고, 커밋 메시지 제목은 `[#이슈번호]` 형식으로 시작한다. - -## Policy Reference - -- 병합 게이트 규칙: `AGENTS.md`의 `Merge Gate`를 따른다. -- 코드 스타일 규칙: `AGENTS.md`의 `Code Style`을 따른다. -- 병합 중 사고 대응/롤백 규칙: `AGENTS.md`의 `Merge Incident Rule`을 따른다. - -## Knowledge Gain Rules - -- 각 TASK에 최소 1개 이상의 CS 학습 포인트를 연결한다. -- 예시: Repository 업데이트 위한 조회 시 "비관/낙관 락", 삭제 API 구현 시 Soft Delete 장점 -- PLAN 질문/피드백 단계는 1차 학습 구간으로 간주한다. -- DRAFT 리뷰/구현 확인 단계는 2차 학습 구간으로 간주한다. -- DRAFT마다 "이번 TASK로 얻는 면접 포인트"를 3개 이내로 정리한다. - -## Response Style - -- 짧고 명확하게 작성한다. -- 추상적 표현보다 파일 경로, 클래스명, 테스트 명령처럼 구체 항목을 우선한다. -- 리스크가 있으면 숨기지 말고 선제적으로 명시한다. diff --git a/vibe/agent/AGENT_TEMPLATE.md b/vibe/agent/AGENT_TEMPLATE.md deleted file mode 100644 index e3a17dd3..00000000 --- a/vibe/agent/AGENT_TEMPLATE.md +++ /dev/null @@ -1,36 +0,0 @@ -# AGENT_TEMPLATE - -아래 내용을 각 프로젝트의 `AGENTS.md`로 복사하여 사용한다. - -## 핵심 워크플로우 (Core Workflow) - -1. JOB을 분석한다. -2. JOB을 여러 개의 TASK로 분해한다. -3. PLAN 마크다운을 `vibe/plan/` 디렉토리에 저장한다. -4. 사용자와의 Q&A 및 설계 피드백을 반영한다. -5. 사용자 `작업 진행` 명령 이후에만 TASK DRAFT 마크다운을 `vibe/draft/` 디렉토리에 저장한다. -6. 사용자 리뷰를 기다린다. -7. 사용자 `병합!` 명령이 있기 전까지 코드 반영/병합을 하지 않는다. -8. 병합 전/후 테스트를 실행하고 결과를 보고한다. -9. 병합 후 커밋 전 사용자 이슈번호를 반드시 확인한다. -10. TASK 병합 완료 즉시 DRAFT를 삭제하고, JOB 커밋 완료 후 PLAN을 삭제한다. - -## 파일 정책 (File Policy) - -- PLAN 템플릿: `vibe/plan/PLAN_TEMPLATE.md` -- DRAFT 템플릿: `vibe/draft/DRAFT_TEMPLATE.md` -- 프롬프트 기본 파일: `vibe/VIBE_PROMPT.md` - -## 가드레일 (Guardrails) - -- PLAN 없이 어떤 작업도 진행 금지 -- 사용자 `작업 진행` 명령 없이 DRAFT 작성 금지 -- 사용자 `병합!` 명령 없이 코드 반영/병합 금지 -- 병합 후 커밋 전 이슈번호 확인 없으면 커밋 금지 -- 사용자 승인 없이 병합 금지 -- 테스트 실패 상태에서 병합 금지 - -## 학습 정책 (Learning Policy) - -- 모든 TASK에는 최소 하나 이상의 CS 개념이 포함되어야 한다. -- 모든 DRAFT에는 면접 대비용 학습 포인트를 포함해야 한다. diff --git a/vibe/agent/TEST_AUTOMATION_AGENT.md b/vibe/agent/TEST_AUTOMATION_AGENT.md deleted file mode 100644 index cf191655..00000000 --- a/vibe/agent/TEST_AUTOMATION_AGENT.md +++ /dev/null @@ -1,210 +0,0 @@ -# TEST_AUTOMATION_AGENT - -## 목적 - -이 문서는 코드에 작성된 `TEST:` 주석을 테스트 요구사항으로 해석하는 에이전트 규칙을 정의한다. -에이전트는 주석에 선언된 계약(contract)을 기준으로 테스트 시나리오를 도출하고, 최소한의 중복으로 핵심 회귀를 막는 테스트를 우선 작성한다. - -## 적용 범위 - -- 포함: - - 엔티티 단위 테스트 - - 서비스/커맨드 단위 테스트 - - 컨트롤러 테스트 - - 저장소/쿼리 관련 통합 테스트 -- 제외: - - E2E 테스트 - - 성능 테스트 - - 부하 테스트 - - 단순 문서 주석 자동 변환 - -## 핵심 원칙 - -1. `TEST:` 주석은 구현 설명이 아니라 메서드 계약 선언으로 취급한다. -2. 각 bullet 항목은 독립적인 테스트 시나리오로 해석한다. -3. 예외 규칙, 값 유지 규칙, 정상 반영 규칙 순으로 우선순위를 둔다. -4. 동일 정책을 여러 계층에서 중복 생성하지 않는다. -5. 코드 동작과 주석이 충돌하면 바로 생성하지 않고 사용자 확인 대상으로 올린다. -6. 테스트 코드가 작성 완료되고 계약이 테스트로 이전되면, 해당 `TEST:` 주석은 제거한다. -7. `도메인명 테스트!` 요청이 오면, 해당 도메인 패키지를 읽어 테스트 생성 입력을 수집한다. - -## 도메인 테스트 트리거 규칙 - -- 사용자가 `도메인명 테스트!` 형식으로 요청하면, 에이전트는 해당 도메인 패키지를 읽고 테스트 생성을 시작한다. -- 예: - - `User 테스트!` - - `Travel 테스트!` - - `Group 테스트!` -- 이 규칙은 `TEST:` 주석이 없는 기존 도메인 코드에도 적용된다. - -## 도메인 패키지 탐색 규칙 - -- 기본 탐색 경로는 `src/main/java/.../{domain}/domain` 이다. -- 우선적으로 엔티티, 값 객체, 도메인 서비스, 관련 enum을 읽는다. -- 필요 시 `application`, `presentation`, `persistence`는 보조 맥락 확인 용도로만 읽는다. -- 도메인 테스트 요청에서는 `domain` 패키지의 공개 메서드, 검증 로직, 상태 변경 규칙, 예외 규칙을 우선 추출한다. -- 이미 존재하는 테스트 파일이 있으면 중복 여부를 먼저 확인한다. - -## 테스트 패키지 구조 규칙 - -- `integration` 테스트를 제외한 신규 테스트 파일은 `src/main/java`의 대응 패키지 구조를 따른다. -- 예: - - `src/main/java/org/triple/backend/user/application/UserUseCase.java` - - `src/test/java/org/triple/backend/user/application/UserUseCaseTest.java` - - `src/main/java/org/triple/backend/user/presentation/UserController.java` - - `src/test/java/org/triple/backend/user/presentation/UserControllerTest.java` -- 테스트 파일 생성 시 `unit/controller`, `unit/service` 같은 보조 디렉터리를 임의로 새로 만들지 않는다. -- 이미 기존 저장소에 동일 경로 관례가 있으면 그 구조를 우선 따르되, 신규 기본 규칙은 프로덕션 패키지 대응 경로다. - -## integration 예외 규칙 - -- 통합 테스트만 `src/test/java/.../integration` 디렉터리를 별도로 사용할 수 있다. -- 예: - - `src/test/java/org/triple/backend/user/integration/UserIntegrationTest.java` -- integration 테스트가 아닌 경우에는 `integration` 디렉터리에 배치하지 않는다. - -## 입력 우선순위 규칙 - -- 메서드 위 `TEST:` 주석이 있으면 이를 최우선 계약으로 사용한다. -- `TEST:` 주석이 없으면 코드에 드러난 도메인 규칙을 기준으로 테스트 시나리오를 도출한다. -- 주석과 코드 규칙이 충돌하면 주석을 절대적으로 따르지 않고, 충돌 보고 후 사용자 확인을 받는다. -- 도메인 코드 fallback은 주석이 없는 경우에만 사용한다. - -## 주석 형식 규칙 - -테스트 요구사항 주석은 메서드 바로 위에 아래 형식으로 작성한다. - -```java -// TEST: -// - birth가 null이면 기존 값을 유지한다. -// - description이 null이 아니면 검증 후 반영한다. -// - nickname이 공백이면 예외를 던진다. -public User patchUser(...) { - ... -} -``` - -## 파싱 규칙 - -- `// TEST:` 로 시작하는 연속 주석 블록만 테스트 요구사항으로 인정한다. -- `- ` 로 시작하는 각 줄은 독립된 테스트 시나리오 1개로 해석한다. -- 주석 블록은 바로 아래 메서드의 계약을 설명해야 한다. -- 메서드 본문 중간 주석, 라인 끝 일반 주석, 구현 메모는 기본적으로 테스트 요구사항으로 해석하지 않는다. -- 한 항목에는 하나의 정책만 작성한다. - -## 시나리오 해석 규칙 - -- `null이면 기존 값을 유지한다` - - 부분 수정 규칙으로 해석한다. - - 대상 필드가 유지되는지와 함께 다른 변경 필드가 있다면 그 반영 여부도 같이 본다. -- `null이 아니면 반영한다` - - 정상 변경 시나리오로 해석한다. - - 반영 전 검증, trim, 저장 결과가 있으면 함께 확인한다. -- `공백이면 예외를 던진다` - - 실패 시나리오로 해석한다. - - 예외 타입과 메시지 또는 에러 코드를 검증한다. -- `검증 후 반영한다` - - validation contract가 존재한다고 보고, 가능하면 정상 케이스와 실패 케이스를 함께 만든다. - -## 테스트 생성 규칙 - -- 각 `TEST:` 항목마다 최소 1개의 테스트 후보를 만든다. -- `TEST:` 주석이 없을 때는 공개 메서드, 검증 메서드가 암시하는 규칙, 예외 메시지를 기준으로 테스트 후보를 만든다. -- 다음 우선순위를 따른다. - 1. 예외 발생 규칙 - 2. 값 유지 규칙 - 3. 정상 반영 규칙 -- 여러 항목이 하나의 테스트로 자연스럽게 묶일 수 있으면 합칠 수 있다. -- 단, 어떤 `TEST:` 항목이 어떤 테스트에서 커버되는지 추적 가능해야 한다. -- 한 테스트가 너무 많은 정책을 동시에 검증하지 않도록 한다. - -## 계층 선택 규칙 - -- 엔티티 메서드 위 `TEST:` 주석 - - 엔티티 단위 테스트 우선 -- 서비스/커맨드 메서드 위 `TEST:` 주석 - - 서비스 단위 테스트 우선 -- 컨트롤러 메서드 위 `TEST:` 주석 - - 컨트롤러 테스트 우선 -- Repository/Query 메서드 위 `TEST:` 주석 - - 통합 테스트 우선 -- `도메인명 테스트!` 요청은 기본적으로 엔티티/값 객체 중심의 도메인 단위 테스트를 우선 생성한다. - -## 주석 정리 규칙 - -- 에이전트는 `TEST:` 주석을 테스트 작성 입력으로만 사용한다. -- 주석에 선언된 요구사항이 테스트 코드로 반영되면, 동일 메서드의 `TEST:` 주석은 제거한다. -- 테스트를 아직 작성하지 않았거나, 일부 항목만 반영된 경우에는 주석을 유지한다. -- 주석 삭제 전에는 어떤 항목이 어떤 테스트에서 커버되는지 확인해야 한다. -- 테스트 요구사항 문장 자체는 코드에 영구 중복 보관하지 않는다. - -하위 계층 테스트만으로 계약이 충분히 검증되지 않으면 상위 계층 테스트를 추가할 수 있다. -단, 동일 정책의 계층별 중복 생성은 지양한다. - -## Assertion 규칙 - -- 값 유지 시: - - 대상 필드가 기존 값과 동일한지 검증한다. -- 값 변경 시: - - 대상 필드가 기대값으로 반영되었는지 검증한다. -- 검증 로직 포함 시: - - trim 또는 validation 결과를 검증한다. -- 예외 시: - - 예외 타입 - - 예외 메시지 또는 에러 코드 -- PATCH 성격 메서드 시: - - 변경 필드와 미변경 필드를 함께 검증한다. - -## 테스트 작성 규칙 - -- 테스트 이름은 주석의 의미 또는 코드에서 추출한 규칙을 직접 반영한다. -- 테스트 메서드는 given-when-then 구조를 따른다. -- fixture는 최소화한다. -- 랜덤값, 현재시간 의존값은 통제한다. -- 프레임워크 기본 동작만 재검증하는 테스트는 생성하지 않는다. -- getter/setter 확인용 테스트는 생성하지 않는다. -- `@DisplayName`은 반드시 한글로 작성한다. -- `@DisplayName` 문자열은 UTF-8 기준으로 한글이 깨지지 않도록 유지한다. -- 테스트 생성/수정 과정에서 한글 깨짐이 감지되면 즉시 중단하고 인코딩 문제를 먼저 해결한다. - -## 충돌 처리 규칙 - -`TEST:` 주석과 실제 코드 동작이 충돌하면 에이전트는 즉시 테스트를 생성하지 않는다. -먼저 아래 내용을 사용자에게 보고한다. - -- 현재 코드 기준 동작 -- 주석 기준 기대 동작 -- 차이점 -- 수정 대상 후보: 코드 또는 주석 - -## 예시 - -```java -// TEST: -// - birth가 null이면 기존 값을 유지한다. -// - description이 null이 아니면 검증 후 반영한다. -// - nickname이 공백이면 예외를 던진다. -public User patchUser(Long userId, LocalDate birth, String description, Gender gender, - String nickname, String profileUrl) { - ... -} -``` - -에이전트는 최소 아래 시나리오를 테스트 후보로 만든다. - -- birth가 null일 때 기존 birth 유지 -- description이 정상 입력이면 반영 -- description에 검증 규칙이 있으면 실패 케이스 추가 검토 -- nickname이 공백이면 예외 발생 - -```text -User 테스트! -``` - -에이전트는 최소 아래 순서로 입력을 수집한다. - -1. `org.triple.backend.user.domain` 패키지 확인 -2. `User` 공개 메서드와 검증 규칙 추출 -3. 기존 `UserTest` 존재 여부 확인 -4. `TEST:` 주석 우선 적용 여부 확인 -5. 주석이 없으면 코드 기준으로 테스트 시나리오 생성 diff --git a/vibe/draft/DRAFT_TEMPLATE.md b/vibe/draft/DRAFT_TEMPLATE.md deleted file mode 100644 index c174af93..00000000 --- a/vibe/draft/DRAFT_TEMPLATE.md +++ /dev/null @@ -1,107 +0,0 @@ -# DRAFT: {TASK_ID} - {TASK_NAME} - -## 0. Meta - -- 작성일: {YYYY-MM-DD} -- JOB: {JOB_NAME} -- TASK ID: {TASK_ID} -- 대상 브랜치: {branch_name} -- DRAFT 작성 승인 명령: {사용자 `작업 진행` 확인} -- 코드 반영 승인 명령: {사용자 `병합!` 확인} -- 커밋 전 이슈번호 확인 상태: {대기/완료} - -## 1. TASK 목표 - -- 문제 정의: {무엇이 문제인가} -- 구현 목표: {이번 TASK의 산출물} -- 완료 조건(DoD): {검증 가능한 기준} -- 완료 이후: DRAFT 산출물을 정리한다. - -## 2. 변경 설계 요약 - -- 핵심 아이디어: {한 줄 요약} -- 대안 비교: {왜 이 설계를 선택했는가} -- 영향 범위: {어떤 기능/모듈에 영향이 있는가} - -## 3. 변경 파일 목록 - -- `{path1}`: {변경 요약} -- `{path2}`: {변경 요약} -- `변경 파일 목록`은 실제 병합 대상 파일과 1:1로 맞춘다. -- 아래 `변경 전 코드`와 `코드 제안`에 목록의 모든 파일을 빠짐없이 포함한다. - -## 4. 변경 전 코드 - -- 수정 파일은 변경 전 코드를 반드시 첨부한다. -- 신규 파일은 코드블록을 비워둔다. -- 파일별로 분리해서 작성한다. - -`{path1}` -```java -``` - -`{path2}` -```java -``` - -## 5. 코드 제안 - -`{path1}` -```java -// 핵심 변경 코드 또는 의사코드 -``` - -`{path2}` -```java -// 핵심 변경 코드 또는 의사코드 -``` - -```diff -// 선택: patch 형태 요약 -``` - -## 6. 테스트 계획 - -- 테스트 케이스 1: {시나리오} -- 테스트 케이스 2: {시나리오} -- 실행 명령: - - `./gradlew test` - - `{추가 명령}` -- 기대 결과: {성공 기준} -- 테스트는 `@DisplayName()`으로 한국어로 목적을 명확히 설명한다. -- 테스트 메서드명은 짧고 명확히 한다. ex) 빈_2개_실패 - -## 7. 리뷰 체크리스트 - -- [ ] 요구사항 충족 여부 -- [ ] 기존 기능 회귀 여부 -- [ ] 예외/경계값 처리 -- [ ] 코드 가독성/유지보수성 -- [ ] 3항 연산자 지양 원칙 준수 여부 -- [ ] `변경 파일 목록`의 모든 파일이 `변경 전 코드`에 포함되었는가 -- [ ] `변경 파일 목록`의 모든 파일이 `코드 제안`에 포함되었는가 -- [ ] 테스트 커버리지 타당성 - -## 8. 학습 포인트 (면접 대비) - -- 포인트 1: {이번 TASK에서 배운 CS/스프링 개념} -- 포인트 2: {트레이드오프/설계 이유} -- 포인트 3: {실무 적용 관점} - -## 9. 사용자 피드백 반영 예정 - -- 피드백 #1: {내용} -- 반영 계획: {수정 예정 내용} -- 상태: {대기/진행/완료} -- `병합!` 승인 상태: {대기/완료} - -## 10. Merge Incident Handling - -- Merge issue detected: {발생 여부 / 증상} -- Impact scope: {영향 파일/기능/테스트} -- PLAN/DRAFT update status: {업데이트 완료 여부} -- User confirmation status: {확인 완료 여부} -- Rollback executed: {롤백 여부 / 범위} -- Re-merge condition: {재병합 전 필수 테스트/검증} -- DRAFT 삭제 상태(마지막 TASK 병합 후): {대기/완료} -- PLAN 삭제 조건 확인 상태(커밋 후): {대기/완료} diff --git a/vibe/plan/PLAN_TEMPLATE.md b/vibe/plan/PLAN_TEMPLATE.md deleted file mode 100644 index 2e8a3a7f..00000000 --- a/vibe/plan/PLAN_TEMPLATE.md +++ /dev/null @@ -1,72 +0,0 @@ -# PLAN: {JOB_NAME} - -## 0. Meta - -- 작성일: {YYYY-MM-DD} -- 프로젝트: spring-clone -- 작성자: AI Agent -- 관련 이슈/요청: {요약} -- PLAN 승인 상태: {대기/승인} -- 다음 단계 트리거 확인: {사용자 `작업 진행` 명령 여부} -- 커밋 전 이슈번호 확인 상태: {대기/완료} -- PLAN 삭제 상태(커밋 후): {대기/완료} - -## 1. JOB 분석 - -- JOB 목적: {무엇을 왜 만드는가} -- 기대 결과: {사용자/시스템 관점 결과} -- 범위(In Scope): {이번 작업에 포함} -- 제외 범위(Out of Scope): {이번 작업에서 제외} - -## 2. 현재 상태 진단 - -- 현재 코드 구조: {패키지/모듈 요약} -- 재사용 가능 컴포넌트: {기존 클래스/유틸} -- 제약사항: {시간/구조/기술/호환성} - -## 3. TASK 분해 - -| TASK ID | 작업명 | 목적 | 대상 파일(예상) | CS 학습 포인트 | 완료 조건(DoD) | -|---|---|---|---|---|---| -| T1 | {task_name} | {goal} | `{path}` | {cs_point} | {dod} | -| T2 | {task_name} | {goal} | `{path}` | {cs_point} | {dod} | - -## 4. 구현 전략 - -- 설계 선택지 A: {장점/단점} -- 설계 선택지 B: {장점/단점} -- 최종 선택: {선택 근거} - -## 5. 테스트 전략 - -- 단위 테스트: {대상/케이스} -- 통합 테스트: {대상/케이스} -- 회귀 테스트: {기존 기능 영향} -- 실행 명령: - - `./gradlew test` - - `{추가 명령}` - -## 6. 리스크 및 대응 - -- 리스크 1: {내용} -- 대응 1: {대응} -- 리스크 2: {내용} -- 대응 2: {대응} - -## 7. 질문 유도(학습용) - -- Q1. {스프링/네트워크/OS/Java 관련 질문} -- Q2. {설계 판단 검증 질문} -- Q3. {면접형 질문} - -## 8. 사용자 피드백 반영 로그 - -- 피드백 #1: {내용} -- 반영 방식: {수정 사항} -- 상태: {반영 완료/보류} - -## 9. Merge Incident / Rollback Plan - -- Merge risk candidates: {병합 단계에서 예상되는 리스크} -- During merge issue rule: {문제 발생 시 PLAN/DRAFT 업데이트 후 사용자 확인 절차} -- Post-merge issue rule: {문제 발생 시 롤백 범위와 재병합 조건} From 0d9ca7c63cb47aabeca86da63ee06fdf188237e5 Mon Sep 17 00:00:00 2001 From: jeongjiho Date: Fri, 24 Apr 2026 19:21:06 +0900 Subject: [PATCH 8/8] =?UTF-8?q?[#223]=20fix:=20isDelete=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=82=AD=EC=A0=9C=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20?= =?UTF-8?q?repository=20=EB=A9=94=EC=84=9C=EB=93=9C=EB=AA=85=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/transfer/service/TransferService.java | 2 +- .../backend/travel/application/TravelReader.java | 3 +-- .../repository/TravelItineraryJpaRepository.java | 5 ++--- .../repository/UserTravelItineraryJpaRepository.java | 10 ---------- .../backend/travel/application/TravelReaderTest.java | 6 +++--- 5 files changed, 7 insertions(+), 19 deletions(-) diff --git a/src/main/java/org/triple/backend/transfer/service/TransferService.java b/src/main/java/org/triple/backend/transfer/service/TransferService.java index 082ef229..ccfbf10d 100644 --- a/src/main/java/org/triple/backend/transfer/service/TransferService.java +++ b/src/main/java/org/triple/backend/transfer/service/TransferService.java @@ -63,7 +63,7 @@ public TransferCreateResponseDto create(final Long userId, final TransferCreateR Group group = groupJpaRepository.findById(dto.groupId()).orElseThrow(() -> new BusinessException(GroupErrorCode.GROUP_NOT_FOUND)); - TravelItinerary travelItinerary = travelItineraryJpaRepository.findByIdAndGroupIdAndIsDeletedFalseForUpdate(dto.travelItineraryId(), dto.groupId()).orElseThrow(() -> new BusinessException(TravelItineraryErrorCode.TRAVEL_NOT_FOUND)); + TravelItinerary travelItinerary = travelItineraryJpaRepository.findByIdAndGroupIdForUpdate(dto.travelItineraryId(), dto.groupId()).orElseThrow(() -> new BusinessException(TravelItineraryErrorCode.TRAVEL_NOT_FOUND)); UserTravelItinerary creatorTravelMembership = userTravelItineraryJpaRepository.findByUserIdAndTravelItineraryId(userId, travelItinerary.getId()) .orElseThrow(() -> new BusinessException(TransferErrorCode.USER_TRAVEL_ITINERARY_NOT_FOUND)); diff --git a/src/main/java/org/triple/backend/travel/application/TravelReader.java b/src/main/java/org/triple/backend/travel/application/TravelReader.java index 452f316c..a55abe57 100644 --- a/src/main/java/org/triple/backend/travel/application/TravelReader.java +++ b/src/main/java/org/triple/backend/travel/application/TravelReader.java @@ -3,7 +3,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.triple.backend.global.error.BusinessException; -import org.triple.backend.global.error.ErrorCode; import org.triple.backend.travel.entity.TravelItinerary; import org.triple.backend.travel.entity.UserRole; import org.triple.backend.travel.entity.UserTravelItinerary; @@ -58,7 +57,7 @@ public UserTravelItinerary findUserTravelByStatus( } public TravelItinerary findTravelForUpdate(Long travelId) { - return travelItineraryJpaRepository.findByIdAndIsDeletedFalseForUpdate(travelId) + return travelItineraryJpaRepository.findByIdForUpdate(travelId) .orElseThrow(() -> new BusinessException(TravelItineraryErrorCode.TRAVEL_NOT_FOUND)); } } diff --git a/src/main/java/org/triple/backend/travel/repository/TravelItineraryJpaRepository.java b/src/main/java/org/triple/backend/travel/repository/TravelItineraryJpaRepository.java index 852f0d9d..6bfe6c00 100644 --- a/src/main/java/org/triple/backend/travel/repository/TravelItineraryJpaRepository.java +++ b/src/main/java/org/triple/backend/travel/repository/TravelItineraryJpaRepository.java @@ -5,7 +5,6 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import org.triple.backend.travel.entity.TravelItinerary; import org.triple.backend.travel.repository.custom.TravelItineraryCustomRepository; @@ -19,11 +18,11 @@ public interface TravelItineraryJpaRepository extends JpaRepository findByIdAndGroupIdAndIsDeletedFalseForUpdate(Long travelItineraryId, Long groupId); + Optional findByIdAndGroupIdForUpdate(Long travelItineraryId, Long groupId); @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select t from TravelItinerary t where t.id = :travelId") - Optional findByIdAndIsDeletedFalseForUpdate(Long travelId); + Optional findByIdForUpdate(Long travelId); @Query(""" SELECT t diff --git a/src/main/java/org/triple/backend/travel/repository/UserTravelItineraryJpaRepository.java b/src/main/java/org/triple/backend/travel/repository/UserTravelItineraryJpaRepository.java index 0d5eef0d..ca3fc170 100644 --- a/src/main/java/org/triple/backend/travel/repository/UserTravelItineraryJpaRepository.java +++ b/src/main/java/org/triple/backend/travel/repository/UserTravelItineraryJpaRepository.java @@ -41,17 +41,7 @@ Optional findByUserIdAndTravelItineraryIdAndUserRole( UserRole userRole ); - boolean existsByUserIdAndTravelItineraryIdAndUserRole( - Long userId, - Long travelItineraryId, - UserRole userRole - ); - @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("DELETE FROM UserTravelItinerary ut WHERE ut.travelItinerary.id = :travelId") void bulkDeleteByTravelId(@Param("travelId") Long travelId); - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT ut FROM UserTravelItinerary ut WHERE ut.id = :id") - Optional findByIdForUpdate(@Param("id") Long id); } diff --git a/src/test/java/org/triple/backend/travel/application/TravelReaderTest.java b/src/test/java/org/triple/backend/travel/application/TravelReaderTest.java index 0dc3772e..6b1d340c 100644 --- a/src/test/java/org/triple/backend/travel/application/TravelReaderTest.java +++ b/src/test/java/org/triple/backend/travel/application/TravelReaderTest.java @@ -146,18 +146,18 @@ void findUserTravelByStatus_throwsWhenRoleMissing() { @DisplayName("여행 잠금 조회가 성공하면 엔티티를 반환한다") void findTravelForUpdate_returnsTravel() { TravelItinerary travel = travel(100L, group(10L), "trip"); - when(travelItineraryJpaRepository.findByIdAndIsDeletedFalseForUpdate(100L)).thenReturn(Optional.of(travel)); + when(travelItineraryJpaRepository.findByIdForUpdate(100L)).thenReturn(Optional.of(travel)); TravelItinerary result = travelReader.findTravelForUpdate(100L); assertThat(result).isSameAs(travel); - verify(travelItineraryJpaRepository).findByIdAndIsDeletedFalseForUpdate(100L); + verify(travelItineraryJpaRepository).findByIdForUpdate(100L); } @Test @DisplayName("여행 잠금 조회가 실패하면 TRAVEL_NOT_FOUND를 던진다") void findTravelForUpdate_throwsWhenMissing() { - when(travelItineraryJpaRepository.findByIdAndIsDeletedFalseForUpdate(100L)).thenReturn(Optional.empty()); + when(travelItineraryJpaRepository.findByIdForUpdate(100L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> travelReader.findTravelForUpdate(100L)) .isInstanceOf(BusinessException.class)