diff --git a/build.gradle b/build.gradle index c08ea6f..0ecbe28 100644 --- a/build.gradle +++ b/build.gradle @@ -46,6 +46,11 @@ dependencies { // swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' + + // mapstruct + implementation 'org.mapstruct:mapstruct:1.5.5.Final' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final' + annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0' } tasks.named('test') { diff --git a/src/main/java/run/backend/domain/crew/repository/JoinCrewRepository.java b/src/main/java/run/backend/domain/crew/repository/JoinCrewRepository.java index 28561dc..a971cc8 100644 --- a/src/main/java/run/backend/domain/crew/repository/JoinCrewRepository.java +++ b/src/main/java/run/backend/domain/crew/repository/JoinCrewRepository.java @@ -17,16 +17,16 @@ public interface JoinCrewRepository extends JpaRepository { boolean existsByMemberAndJoinStatus(Member member, JoinStatus joinStatus); @Query(""" - SELECT jc.member - FROM JoinCrew jc - JOIN Crew c ON jc.crew = c - WHERE jc.role = :role - """) + SELECT jc.member + FROM JoinCrew jc + JOIN Crew c ON jc.crew = c + WHERE jc.role = :role + """) Member findCrewLeader(@Param("role") Role role, Crew crew); @Query("SELECT jc FROM JoinCrew jc WHERE jc.member.id = :memberId AND jc.joinStatus = :status") Optional findByMemberIdAndJoinStatus(@Param("memberId") Long memberId, - @Param("status") JoinStatus status); + @Param("status") JoinStatus status); @Query(""" SELECT new run.backend.domain.event.dto.response.EventCreationValidationDto( @@ -41,8 +41,21 @@ Optional findByMemberIdAndJoinStatus(@Param("memberId") Long memberId, AND captainJoin.joinStatus = :status """) Optional validateEventCreation( - @Param("requesterId") Long requesterId, + @Param("requesterId") Long requesterId, + @Param("runningCaptainId") Long runningCaptainId, + @Param("status") JoinStatus status + ); + + @Query(""" + SELECT captainJoin.member + FROM JoinCrew captainJoin + WHERE captainJoin.member.id = :runningCaptainId + AND captainJoin.crew.id = :crewId + AND captainJoin.joinStatus = :status + """) + Optional findCrewMemberById( @Param("runningCaptainId") Long runningCaptainId, + @Param("crewId") Long crewId, @Param("status") JoinStatus status ); } diff --git a/src/main/java/run/backend/domain/event/controller/EventController.java b/src/main/java/run/backend/domain/event/controller/EventController.java index 9391ae3..660f7a9 100644 --- a/src/main/java/run/backend/domain/event/controller/EventController.java +++ b/src/main/java/run/backend/domain/event/controller/EventController.java @@ -4,12 +4,14 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; +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.RestController; import run.backend.domain.event.dto.request.EventInfoRequest; -import run.backend.domain.event.service.EventServiceImpl; +import run.backend.domain.event.service.EventService; import run.backend.domain.member.entity.Member; import run.backend.global.annotation.member.Login; import run.backend.global.common.response.CommonResponse; @@ -19,18 +21,31 @@ @RequestMapping("/api/v1/events") @Tag(name = "Events", description = "일정 관련 API") public class EventController { - - private final EventServiceImpl eventService; + + private final EventService eventService; @PostMapping @PreAuthorize("hasRole('MANAGER') or hasRole('LEADER')") - @Operation(summary = "일정 생성", description = "러닝 일정를 생성합니다. LEADER 또는 MANAGER 권한이 필요합니다.") + @Operation(summary = "일정 생성", description = "러닝 일정을 생성합니다. LEADER 또는 MANAGER 권한이 필요합니다.") public CommonResponse createEvent( - @RequestBody EventInfoRequest eventInfoRequest, - @Login Member member + @RequestBody EventInfoRequest eventInfoRequest, + @Login Member member ) { - + eventService.createEvent(eventInfoRequest, member); - return new CommonResponse<>("일정 생성 성공"); + return new CommonResponse<>("러닝 일정 생성 성공"); + } + + @PatchMapping("/{eventId}") + @PreAuthorize("hasRole('MANAGER') or hasRole('LEADER')") + @Operation(summary = "일정 수정", description = "러닝 일정을 수정합니다. LEADER 또는 MANAGER 권한이 필요합니다.") + public CommonResponse updateEvent( + @PathVariable Long eventId, + @RequestBody EventInfoRequest eventUpdateRequest, + @Login Member member + ) { + + eventService.updateEvent(eventId, eventUpdateRequest, member); + return new CommonResponse<>("러닝 일정 수정 성공"); } } diff --git a/src/main/java/run/backend/domain/event/dto/request/EventInfoRequest.java b/src/main/java/run/backend/domain/event/dto/request/EventInfoRequest.java index b00b751..40bebd9 100644 --- a/src/main/java/run/backend/domain/event/dto/request/EventInfoRequest.java +++ b/src/main/java/run/backend/domain/event/dto/request/EventInfoRequest.java @@ -7,15 +7,27 @@ import run.backend.domain.event.enums.WeekDay; public record EventInfoRequest( + @Schema(description = "일정 제목") String title, + + @Schema(description = "일정 날짜") LocalDate baseDate, + @Schema(description = "반복 주기", example = "NONE / WEEKLY") RepeatCycle repeatCycle, + @Schema(description = "반복 요일", example = "MONDAY / TUESDAY / WEDNESDAY / THURSDAY / FRIDAY / SATURDAY / SUNDAY", nullable = true) WeekDay repeatDays, + + @Schema(description = "시작 시간") LocalTime startTime, + + @Schema(description = "종료 시간") LocalTime endTime, + + @Schema(description = "장소") String place, + @Schema(description = "러닝캡틴 ID", example = "1") Long runningCaptainId ) { diff --git a/src/main/java/run/backend/domain/event/entity/Event.java b/src/main/java/run/backend/domain/event/entity/Event.java index af07741..5173bbe 100644 --- a/src/main/java/run/backend/domain/event/entity/Event.java +++ b/src/main/java/run/backend/domain/event/entity/Event.java @@ -56,14 +56,14 @@ public class Event extends BaseEntity { @Builder public Event( - String title, - LocalDate date, - LocalTime startTime, - LocalTime endTime, - String place, - Crew crew, - CrewRecord record, - Member member + String title, + LocalDate date, + LocalTime startTime, + LocalTime endTime, + String place, + Crew crew, + CrewRecord record, + Member member ) { this.title = title; this.date = date; @@ -84,4 +84,32 @@ public void incrementExpectedParticipants() { public void incrementActualParticipants() { this.actualParticipants++; } + + public void updateEvent( + String title, + LocalDate date, + LocalTime startTime, + LocalTime endTime, + String place, + Member runningCaptain + ) { + if (title != null) { + this.title = title; + } + if (date != null) { + this.date = date; + } + if (startTime != null) { + this.startTime = startTime; + } + if (endTime != null) { + this.endTime = endTime; + } + if (place != null) { + this.place = place; + } + if (runningCaptain != null) { + this.member = runningCaptain; + } + } } diff --git a/src/main/java/run/backend/domain/event/entity/PeriodicEvent.java b/src/main/java/run/backend/domain/event/entity/PeriodicEvent.java index 07b02d3..79e995b 100644 --- a/src/main/java/run/backend/domain/event/entity/PeriodicEvent.java +++ b/src/main/java/run/backend/domain/event/entity/PeriodicEvent.java @@ -55,15 +55,15 @@ public class PeriodicEvent extends BaseEntity { @Builder public PeriodicEvent( - String title, - LocalDate baseDate, - RepeatCycle repeatCycle, - WeekDay repeatDays, - LocalTime startTime, - LocalTime endTime, - String place, - Crew crew, - Member member + String title, + LocalDate baseDate, + RepeatCycle repeatCycle, + WeekDay repeatDays, + LocalTime startTime, + LocalTime endTime, + String place, + Crew crew, + Member member ) { this.title = title; this.baseDate = baseDate; @@ -75,4 +75,40 @@ public PeriodicEvent( this.crew = crew; this.member = member; } + + public void updatePeriodicEvent( + String title, + LocalDate baseDate, + RepeatCycle repeatCycle, + WeekDay repeatDays, + LocalTime startTime, + LocalTime endTime, + String place, + Member runningCaptain + ) { + if (title != null) { + this.title = title; + } + if (baseDate != null) { + this.baseDate = baseDate; + } + if (repeatCycle != null) { + this.repeatCycle = repeatCycle; + } + if (repeatDays != null) { + this.repeatDays = repeatDays; + } + if (startTime != null) { + this.startTime = startTime; + } + if (endTime != null) { + this.endTime = endTime; + } + if (place != null) { + this.place = place; + } + if (runningCaptain != null) { + this.member = runningCaptain; + } + } } diff --git a/src/main/java/run/backend/domain/event/exception/EventErrorCode.java b/src/main/java/run/backend/domain/event/exception/EventErrorCode.java index ebbd7c2..cd3f607 100644 --- a/src/main/java/run/backend/domain/event/exception/EventErrorCode.java +++ b/src/main/java/run/backend/domain/event/exception/EventErrorCode.java @@ -8,7 +8,8 @@ @AllArgsConstructor public enum EventErrorCode implements ErrorCode { - RUNNING_CAPTAIN_NOT_CREW_MEMBER(6001, "러닝캡이 크루원이 아닙니다."); + RUNNING_CAPTAIN_NOT_CREW_MEMBER(6001, "러닝캡이 크루원이 아닙니다."), + EVENT_NOT_FOUND(6002, "일정을 찾을 수 없습니다."); private final int errorCode; private final String errorMessage; diff --git a/src/main/java/run/backend/domain/event/exception/EventException.java b/src/main/java/run/backend/domain/event/exception/EventException.java index 8b12138..179f26d 100644 --- a/src/main/java/run/backend/domain/event/exception/EventException.java +++ b/src/main/java/run/backend/domain/event/exception/EventException.java @@ -13,4 +13,10 @@ public InvalidEventCreationRequest() { super(EventErrorCode.RUNNING_CAPTAIN_NOT_CREW_MEMBER); } } + + public static class EventNotFound extends EventException { + public EventNotFound() { + super(EventErrorCode.EVENT_NOT_FOUND); + } + } } diff --git a/src/main/java/run/backend/domain/event/mapper/EventMapper.java b/src/main/java/run/backend/domain/event/mapper/EventMapper.java index 53e84b2..29a7f3b 100644 --- a/src/main/java/run/backend/domain/event/mapper/EventMapper.java +++ b/src/main/java/run/backend/domain/event/mapper/EventMapper.java @@ -1,29 +1,51 @@ package run.backend.domain.event.mapper; -import org.springframework.stereotype.Component; +import java.util.List; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; import run.backend.domain.crew.dto.response.EventProfileResponse; +import run.backend.domain.crew.entity.Crew; +import run.backend.domain.event.dto.request.EventInfoRequest; import run.backend.domain.event.entity.Event; +import run.backend.domain.event.entity.JoinEvent; +import run.backend.domain.event.entity.PeriodicEvent; +import run.backend.domain.member.entity.Member; -import java.util.List; -import java.util.stream.Collectors; +@Mapper( + componentModel = "spring", + unmappedTargetPolicy = ReportingPolicy.IGNORE +) +public interface EventMapper { -@Component -public class EventMapper { + @Mapping(target = "date", source = "request.baseDate") + @Mapping(target = "member", source = "runningCaptain") + Event toEvent(EventInfoRequest request, Crew crew, Member runningCaptain); - public List toEventProfileList(List events) { - return events.stream() - .map(this::toEventProfile) - .collect(Collectors.toList()); - } + @Mapping(target = "member", source = "runningCaptain") + PeriodicEvent toPeriodicEvent(EventInfoRequest request, Crew crew, Member runningCaptain); + + @Mapping(target = "member", source = "runningCaptain") + @Mapping(target = "event", source = "event") + JoinEvent toJoinEvent(Event event, Member runningCaptain); + + @Mapping(target = "eventId", source = "id") + @Mapping(target = "participants", source = "expectedParticipants") + EventProfileResponse toEventProfile(Event event); + + List toEventProfileList(List events); - public EventProfileResponse toEventProfile(Event event) { - return EventProfileResponse.builder() - .eventId(event.getId()) - .title(event.getTitle()) - .date(event.getDate()) - .startTime(event.getStartTime()) - .endTime(event.getEndTime()) - .participants(event.getExpectedParticipants()) - .build(); + default EventInfoRequest toEventInfoRequest(EventInfoRequest updateRequest, Event event) { + return new EventInfoRequest( + updateRequest.title() != null ? updateRequest.title() : event.getTitle(), + updateRequest.baseDate() != null ? updateRequest.baseDate() : event.getDate(), + updateRequest.repeatCycle(), + updateRequest.repeatDays(), + updateRequest.startTime() != null ? updateRequest.startTime() : event.getStartTime(), + updateRequest.endTime() != null ? updateRequest.endTime() : event.getEndTime(), + updateRequest.place() != null ? updateRequest.place() : event.getPlace(), + updateRequest.runningCaptainId() != null ? updateRequest.runningCaptainId() + : event.getMember().getId() + ); } } diff --git a/src/main/java/run/backend/domain/event/repository/JoinEventRepository.java b/src/main/java/run/backend/domain/event/repository/JoinEventRepository.java index bb974ea..2977acf 100644 --- a/src/main/java/run/backend/domain/event/repository/JoinEventRepository.java +++ b/src/main/java/run/backend/domain/event/repository/JoinEventRepository.java @@ -1,8 +1,12 @@ package run.backend.domain.event.repository; import org.springframework.data.jpa.repository.JpaRepository; +import run.backend.domain.event.entity.Event; import run.backend.domain.event.entity.JoinEvent; +import run.backend.domain.member.entity.Member; public interface JoinEventRepository extends JpaRepository { - + + void deleteByEventAndMember(Event event, Member member); + } diff --git a/src/main/java/run/backend/domain/event/repository/PeriodicEventRepository.java b/src/main/java/run/backend/domain/event/repository/PeriodicEventRepository.java index b3c0e23..f908f09 100644 --- a/src/main/java/run/backend/domain/event/repository/PeriodicEventRepository.java +++ b/src/main/java/run/backend/domain/event/repository/PeriodicEventRepository.java @@ -1,7 +1,28 @@ package run.backend.domain.event.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import run.backend.domain.crew.entity.Crew; import run.backend.domain.event.entity.PeriodicEvent; +import java.time.LocalTime; +import java.util.Optional; + public interface PeriodicEventRepository extends JpaRepository { + + @Query(""" + SELECT pe + FROM PeriodicEvent pe + WHERE pe.crew = :crew + AND pe.title = :title + AND pe.startTime = :startTime + AND pe.endTime = :endTime + """) + Optional findByCrewAndTitleAndTime( + @Param("crew") Crew crew, + @Param("title") String title, + @Param("startTime") LocalTime startTime, + @Param("endTime") LocalTime endTime + ); } diff --git a/src/main/java/run/backend/domain/event/service/EventService.java b/src/main/java/run/backend/domain/event/service/EventService.java index 78ca968..6c5ffbb 100644 --- a/src/main/java/run/backend/domain/event/service/EventService.java +++ b/src/main/java/run/backend/domain/event/service/EventService.java @@ -1,17 +1,153 @@ package run.backend.domain.event.service; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import run.backend.domain.crew.entity.Crew; +import run.backend.domain.crew.enums.JoinStatus; +import run.backend.domain.crew.repository.JoinCrewRepository; import run.backend.domain.event.dto.request.EventInfoRequest; +import run.backend.domain.event.dto.response.EventCreationValidationDto; +import run.backend.domain.event.entity.Event; +import run.backend.domain.event.entity.JoinEvent; +import run.backend.domain.event.entity.PeriodicEvent; +import run.backend.domain.event.enums.RepeatCycle; +import run.backend.domain.event.exception.EventException.EventNotFound; +import run.backend.domain.event.exception.EventException.InvalidEventCreationRequest; +import run.backend.domain.event.mapper.EventMapper; +import run.backend.domain.event.repository.EventRepository; +import run.backend.domain.event.repository.JoinEventRepository; +import run.backend.domain.event.repository.PeriodicEventRepository; import run.backend.domain.member.entity.Member; +import run.backend.global.annotation.global.Logging; -public interface EventService { +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class EventService { - void createEvent(EventInfoRequest eventInfoRequest, Member member); + private final EventRepository eventRepository; + private final PeriodicEventRepository periodicEventRepository; + private final JoinCrewRepository joinCrewRepository; + private final JoinEventRepository joinEventRepository; + private final EventMapper eventMapper; -// void updateEvent(EventInfoRequest eventInfoRequest); -// -// void joinEvent(Long eventId, Long memberId); -// -// void deleteEvent(Long eventId); -// -// EventInfoResponse getEventDetails(Long eventId); + @Transactional + @Logging + public void createEvent(EventInfoRequest eventInfoRequest, Member member) { + EventCreationValidationDto validation = joinCrewRepository + .validateEventCreation( + member.getId(), + eventInfoRequest.runningCaptainId(), + JoinStatus.APPROVED + ) + .orElseThrow(InvalidEventCreationRequest::new); + + Crew crew = validation.crew(); + Member runningCaptain = validation.runningCaptain(); + + if (eventInfoRequest.repeatCycle() != RepeatCycle.NONE) { + createPeriodicEvent(eventInfoRequest, crew, runningCaptain); + } + createSingleEvent(eventInfoRequest, crew, runningCaptain); + } + + @Transactional + @Logging + public void updateEvent(Long eventId, EventInfoRequest eventUpdateRequest, Member member) { + Event event = eventRepository.findById(eventId) + .orElseThrow(EventNotFound::new); + + Member newRunningCaptain = validateNewRunningCaptain(eventUpdateRequest, event); + + if (newRunningCaptain != null && !event.getMember().getId() + .equals(newRunningCaptain.getId())) { + updateRunningCaptain(event, newRunningCaptain); + } + + handlePeriodicEventUpdate(event, eventUpdateRequest, newRunningCaptain); + + event.updateEvent( + eventUpdateRequest.title(), + eventUpdateRequest.baseDate(), + eventUpdateRequest.startTime(), + eventUpdateRequest.endTime(), + eventUpdateRequest.place(), + newRunningCaptain + ); + } + + private void createPeriodicEvent(EventInfoRequest request, Crew crew, Member runningCaptain) { + PeriodicEvent periodicEvent = eventMapper.toPeriodicEvent(request, crew, runningCaptain); + periodicEventRepository.save(periodicEvent); + } + + private void createSingleEvent(EventInfoRequest request, Crew crew, Member runningCaptain) { + Event event = eventMapper.toEvent(request, crew, runningCaptain); + Event savedEvent = eventRepository.save(event); + + JoinEvent joinEvent = eventMapper.toJoinEvent(savedEvent, runningCaptain); + joinEventRepository.save(joinEvent); + } + + private Member validateNewRunningCaptain(EventInfoRequest request, Event event) { + if (request.runningCaptainId() == null) { + return null; + } + + return joinCrewRepository + .findCrewMemberById( + request.runningCaptainId(), + event.getCrew().getId(), + JoinStatus.APPROVED + ) + .orElseThrow(InvalidEventCreationRequest::new); + } + + private void updateRunningCaptain(Event event, Member newRunningCaptain) { + joinEventRepository.deleteByEventAndMember(event, event.getMember()); + + JoinEvent newJoinEvent = eventMapper.toJoinEvent(event, newRunningCaptain); + joinEventRepository.save(newJoinEvent); + } + + private void handlePeriodicEventUpdate(Event event, EventInfoRequest request, + Member newRunningCaptain) { + Optional existingPeriodicEvent = periodicEventRepository + .findByCrewAndTitleAndTime( + event.getCrew(), + event.getTitle(), + event.getStartTime(), + event.getEndTime() + ); + + RepeatCycle requestedRepeatCycle = request.repeatCycle(); + + if (requestedRepeatCycle == null || requestedRepeatCycle == RepeatCycle.NONE) { + existingPeriodicEvent.ifPresent(periodicEventRepository::delete); + } else { + if (existingPeriodicEvent.isPresent()) { + PeriodicEvent periodicEvent = existingPeriodicEvent.get(); + periodicEvent.updatePeriodicEvent( + request.title(), + request.baseDate(), + requestedRepeatCycle, + request.repeatDays(), + request.startTime(), + request.endTime(), + request.place(), + newRunningCaptain + ); + } else { + EventInfoRequest eventInfoRequest = eventMapper.toEventInfoRequest(request, event); + PeriodicEvent newPeriodicEvent = eventMapper.toPeriodicEvent( + eventInfoRequest, + event.getCrew(), + newRunningCaptain != null ? newRunningCaptain : event.getMember() + ); + periodicEventRepository.save(newPeriodicEvent); + } + } + } } diff --git a/src/main/java/run/backend/domain/event/service/EventServiceImpl.java b/src/main/java/run/backend/domain/event/service/EventServiceImpl.java deleted file mode 100644 index 6911817..0000000 --- a/src/main/java/run/backend/domain/event/service/EventServiceImpl.java +++ /dev/null @@ -1,89 +0,0 @@ -package run.backend.domain.event.service; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import run.backend.domain.crew.entity.Crew; -import run.backend.domain.crew.enums.JoinStatus; -import run.backend.domain.crew.repository.JoinCrewRepository; -import run.backend.domain.event.dto.request.EventInfoRequest; -import run.backend.domain.event.dto.response.EventCreationValidationDto; -import run.backend.domain.event.entity.Event; -import run.backend.domain.event.entity.JoinEvent; -import run.backend.domain.event.entity.PeriodicEvent; -import run.backend.domain.event.enums.RepeatCycle; -import run.backend.domain.event.exception.EventException.InvalidEventCreationRequest; -import run.backend.domain.event.repository.EventRepository; -import run.backend.domain.event.repository.JoinEventRepository; -import run.backend.domain.event.repository.PeriodicEventRepository; -import run.backend.domain.member.entity.Member; -import run.backend.global.annotation.global.Logging; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class EventServiceImpl implements EventService { - - private final EventRepository eventRepository; - private final PeriodicEventRepository periodicEventRepository; - private final JoinCrewRepository joinCrewRepository; - private final JoinEventRepository joinEventRepository; - - @Override - @Transactional - @Logging - public void createEvent(EventInfoRequest eventInfoRequest, Member member) { - EventCreationValidationDto validation = joinCrewRepository - .validateEventCreation( - member.getId(), - eventInfoRequest.runningCaptainId(), - JoinStatus.APPROVED - ) - .orElseThrow(InvalidEventCreationRequest::new); - - Crew crew = validation.crew(); - Member runningCaptain = validation.runningCaptain(); - - if (eventInfoRequest.repeatCycle() != RepeatCycle.NONE) { - createPeriodicEvent(eventInfoRequest, crew, runningCaptain); - } - createSingleEvent(eventInfoRequest, crew, runningCaptain); - } - - private void createPeriodicEvent(EventInfoRequest request, Crew crew, Member runningCaptain) { - PeriodicEvent periodicEvent = PeriodicEvent.builder() - .title(request.title()) - .baseDate(request.baseDate()) - .repeatCycle(request.repeatCycle()) - .repeatDays(request.repeatDays()) - .startTime(request.startTime()) - .endTime(request.endTime()) - .place(request.place()) - .crew(crew) - .member(runningCaptain) - .build(); - - periodicEventRepository.save(periodicEvent); - } - - private void createSingleEvent(EventInfoRequest request, Crew crew, Member runningCaptain) { - Event event = Event.builder() - .title(request.title()) - .date(request.baseDate()) - .startTime(request.startTime()) - .endTime(request.endTime()) - .place(request.place()) - .crew(crew) - .member(runningCaptain) - .build(); - - Event savedEvent = eventRepository.save(event); - - JoinEvent joinEvent = JoinEvent.builder() - .event(savedEvent) - .member(runningCaptain) - .build(); - - joinEventRepository.save(joinEvent); - } -} diff --git a/src/main/java/run/backend/domain/member/exception/MemberErrorCode.java b/src/main/java/run/backend/domain/member/exception/MemberErrorCode.java index 87b68ee..93baedd 100644 --- a/src/main/java/run/backend/domain/member/exception/MemberErrorCode.java +++ b/src/main/java/run/backend/domain/member/exception/MemberErrorCode.java @@ -8,7 +8,8 @@ @AllArgsConstructor public enum MemberErrorCode implements ErrorCode { - MEMBER_NOT_JOINED_CREW(5001, "가입한 크루가 없습니다."); + MEMBER_NOT_JOINED_CREW(5001, "가입한 크루가 없습니다."), + MEMBER_NOT_FOUND(5002, "해당 유저를 찾을 수 없습니다."); private final int errorCode; private final String errorMessage; diff --git a/src/main/java/run/backend/domain/member/exception/MemberException.java b/src/main/java/run/backend/domain/member/exception/MemberException.java index fed1333..e107242 100644 --- a/src/main/java/run/backend/domain/member/exception/MemberException.java +++ b/src/main/java/run/backend/domain/member/exception/MemberException.java @@ -13,4 +13,10 @@ public MemberNotJoinedCrew() { super(MemberErrorCode.MEMBER_NOT_JOINED_CREW); } } + + public static class MemberNotFound extends MemberException { + public MemberNotFound() { + super(MemberErrorCode.MEMBER_NOT_FOUND); + } + } } diff --git a/src/main/java/run/backend/global/exception/GlobalExceptionHandler.java b/src/main/java/run/backend/global/exception/GlobalExceptionHandler.java index 0187086..c83fba7 100644 --- a/src/main/java/run/backend/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/run/backend/global/exception/GlobalExceptionHandler.java @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import run.backend.domain.auth.exception.AuthException; import run.backend.domain.crew.exception.CrewException; +import run.backend.domain.event.exception.EventException; import run.backend.domain.file.exception.FileException; import run.backend.domain.member.exception.MemberException; import run.backend.global.common.response.CommonResponse; @@ -19,52 +20,55 @@ public class GlobalExceptionHandler { @ExceptionHandler({ - AuthException.RefreshTokenNotFound.class, - FileException.FileNotFound.class, - MemberException.MemberNotJoinedCrew.class, - CrewException.NotFoundCrew.class + AuthException.RefreshTokenNotFound.class, + FileException.FileNotFound.class, + MemberException.MemberNotJoinedCrew.class, + MemberException.MemberNotFound.class, + CrewException.NotFoundCrew.class, + EventException.EventNotFound.class }) public ResponseEntity> handleNotFound(final CustomException e) { log.warn("[NOT_FOUND_EXCEPTION] {}", e.toString()); return ResponseEntity - .status(HttpStatus.NOT_FOUND) - .body(new CommonResponse<>(e.getErrorCode(), e.getErrorMessage())); + .status(HttpStatus.NOT_FOUND) + .body(new CommonResponse<>(e.getErrorCode(), e.getErrorMessage())); } @ExceptionHandler({ - AuthException.UserAlreadyExists.class, - FileException.FileSizeExceeded.class, - FileException.InvalidFileName.class, - FileException.InvalidFileExtension.class, - FileException.InvalidFileType.class, - CrewException.AlreadyJoinedCrew.class + AuthException.UserAlreadyExists.class, + FileException.FileSizeExceeded.class, + FileException.InvalidFileName.class, + FileException.InvalidFileExtension.class, + FileException.InvalidFileType.class, + CrewException.AlreadyJoinedCrew.class }) public ResponseEntity> handleConflict(final CustomException e) { log.warn("[CONFLICT_EXCEPTION] {}", e.toString()); return ResponseEntity - .status(HttpStatus.CONFLICT) - .body(new CommonResponse<>(e.getErrorCode(), e.getErrorMessage())); + .status(HttpStatus.CONFLICT) + .body(new CommonResponse<>(e.getErrorCode(), e.getErrorMessage())); } @ExceptionHandler({ - AuthException.InvalidSignupToken.class, - AuthException.OauthRequestFailed.class, - AuthException.TokenMissingAuthority.class, - AuthException.InvalidRefreshToken.class, - AuthException.RefreshTokenExpired.class, - FileException.FileUploadFailed.class + AuthException.InvalidSignupToken.class, + AuthException.OauthRequestFailed.class, + AuthException.TokenMissingAuthority.class, + AuthException.InvalidRefreshToken.class, + AuthException.RefreshTokenExpired.class, + FileException.FileUploadFailed.class, + EventException.InvalidEventCreationRequest.class }) public ResponseEntity> handleBadRequest(final CustomException e) { log.warn("[BAD_REQUEST_EXCEPTION] {}", e.toString()); return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body(new CommonResponse<>(e.getErrorCode(), e.getErrorMessage())); + .status(HttpStatus.BAD_REQUEST) + .body(new CommonResponse<>(e.getErrorCode(), e.getErrorMessage())); } @ExceptionHandler(Exception.class) @@ -73,7 +77,8 @@ public ResponseEntity> handleUnknownException(final Excepti log.error("[INTERNAL_SERVER_ERROR]", e); return ResponseEntity - .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new CommonResponse<>(HttpErrorCode.INTERNAL_SERVER_ERROR.getErrorCode(), HttpErrorCode.INTERNAL_SERVER_ERROR.getErrorMessage())); + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new CommonResponse<>(HttpErrorCode.INTERNAL_SERVER_ERROR.getErrorCode(), + HttpErrorCode.INTERNAL_SERVER_ERROR.getErrorMessage())); } } diff --git a/src/test/java/run/backend/domain/event/service/EventServiceImplTest.java b/src/test/java/run/backend/domain/event/service/EventServiceImplTest.java deleted file mode 100644 index 47b8731..0000000 --- a/src/test/java/run/backend/domain/event/service/EventServiceImplTest.java +++ /dev/null @@ -1,213 +0,0 @@ -package run.backend.domain.event.service; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.never; -import static org.mockito.BDDMockito.then; - -import java.time.LocalDate; -import java.time.LocalTime; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import run.backend.domain.crew.entity.Crew; -import run.backend.domain.crew.repository.JoinCrewRepository; -import run.backend.domain.event.dto.request.EventInfoRequest; -import run.backend.domain.event.dto.response.EventCreationValidationDto; -import run.backend.domain.event.entity.Event; -import run.backend.domain.event.entity.JoinEvent; -import run.backend.domain.event.entity.PeriodicEvent; -import run.backend.domain.event.enums.RepeatCycle; -import run.backend.domain.event.enums.WeekDay; -import run.backend.domain.event.exception.EventException.InvalidEventCreationRequest; -import run.backend.domain.event.repository.EventRepository; -import run.backend.domain.event.repository.JoinEventRepository; -import run.backend.domain.event.repository.PeriodicEventRepository; -import run.backend.domain.member.entity.Member; -import run.backend.domain.member.enums.Gender; -import run.backend.domain.member.enums.OAuthType; - -@ExtendWith(MockitoExtension.class) -@DisplayName("EventServiceImpl") -class EventServiceImplTest { - - @Mock - private EventRepository eventRepository; - - @Mock - private PeriodicEventRepository periodicEventRepository; - - @Mock - private JoinCrewRepository joinCrewRepository; - - @Mock - private JoinEventRepository joinEventRepository; - - @InjectMocks - private EventServiceImpl sut; // System Under Test - - private Member requestMember; - private Member runningCaptain; - private Crew crew; - private Event savedEvent; - - @BeforeEach - void setUp() { - requestMember = createMember("요청자"); - runningCaptain = createMember("러닝캡틴"); - crew = createCrew("테스트크루"); - savedEvent = createEvent(); - } - - @Nested - @DisplayName("createEvent 메서드는") - class CreateEventTest { - - @Test - @DisplayName("일반 일정 생성 시 Event와 JoinEvent를 저장한다") - void shouldCreateSingleEventSuccessfully() { - // given - EventInfoRequest request = createSingleEventRequest(); - EventCreationValidationDto validation = new EventCreationValidationDto(crew, runningCaptain); - - given(joinCrewRepository.validateEventCreation(any(), any(), any())) - .willReturn(Optional.of(validation)); - - given(eventRepository.save(any(Event.class))) - .willReturn(savedEvent); - - // when - sut.createEvent(request, requestMember); - - // then - then(eventRepository).should().save(any(Event.class)); - then(joinEventRepository).should().save(any(JoinEvent.class)); - then(periodicEventRepository).should(never()).save(any(PeriodicEvent.class)); - } - - @Test - @DisplayName("주기적 일정 생성 시 PeriodicEvent, Event, JoinEvent를 모두 저장한다") - void shouldCreatePeriodicEventSuccessfully() { - // given - EventInfoRequest request = createPeriodicEventRequest(); - EventCreationValidationDto validation = new EventCreationValidationDto(crew, runningCaptain); - - given(joinCrewRepository.validateEventCreation(any(), any(), any())) - .willReturn(Optional.of(validation)); - - given(eventRepository.save(any(Event.class))) - .willReturn(savedEvent); - - // when - sut.createEvent(request, requestMember); - - // then - then(periodicEventRepository).should().save(any(PeriodicEvent.class)); - then(eventRepository).should().save(any(Event.class)); - then(joinEventRepository).should().save(any(JoinEvent.class)); - } - - @Test - @DisplayName("유효하지 않은 요청 시 InvalidEventCreationRequest 예외를 발생시킨다") - void shouldThrowExceptionWhenValidationFails() { - // given - EventInfoRequest request = createSingleEventRequest(); - - given(joinCrewRepository.validateEventCreation(any(), any(), any())) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> sut.createEvent(request, requestMember)) - .isInstanceOf(InvalidEventCreationRequest.class); - - then(eventRepository).should(never()).save(any(Event.class)); - then(joinEventRepository).should(never()).save(any(JoinEvent.class)); - then(periodicEventRepository).should(never()).save(any(PeriodicEvent.class)); - } - - @Test - @DisplayName("일정 생성 후 러닝캡틴이 자동으로 참가 처리된다") - void shouldAutoJoinRunningCaptainToEvent() { - // given - EventInfoRequest request = createSingleEventRequest(); - EventCreationValidationDto validation = new EventCreationValidationDto(crew, runningCaptain); - - given(joinCrewRepository.validateEventCreation(any(), any(), any())) - .willReturn(Optional.of(validation)); - - given(eventRepository.save(any(Event.class))) - .willReturn(savedEvent); - - // when - sut.createEvent(request, requestMember); - - // then - then(joinEventRepository).should().save(any(JoinEvent.class)); - } - } - - private Member createMember(String nickname) { - return Member.builder() - .username("test_user") - .nickname(nickname) - .gender(Gender.MALE) - .age(25) - .oauthId("oauth_id") - .oauthType(OAuthType.GOOGLE) - .profileImage("profile.jpg") - .build(); - } - - private Crew createCrew(String name) { - return Crew.builder() - .name(name) - .description("테스트 크루 설명") - .image("crew.jpg") - .build(); - } - - private Event createEvent() { - return Event.builder() - .title("테스트 일정") - .date(LocalDate.of(2025, 7, 18)) - .startTime(LocalTime.of(9, 0)) - .endTime(LocalTime.of(10, 0)) - .place("테스트 장소") - .crew(crew) - .member(runningCaptain) - .build(); - } - - private EventInfoRequest createSingleEventRequest() { - return new EventInfoRequest( - "테스트 일정", - LocalDate.of(2025, 7, 18), - RepeatCycle.NONE, - null, - LocalTime.of(9, 0), - LocalTime.of(10, 0), - "테스트 장소", - 1L - ); - } - - private EventInfoRequest createPeriodicEventRequest() { - return new EventInfoRequest( - "주기적 일정", - LocalDate.of(2025, 7, 18), - RepeatCycle.WEEKLY, - WeekDay.MONDAY, - LocalTime.of(9, 0), - LocalTime.of(10, 0), - "테스트 장소", - 1L - ); - } -} diff --git a/src/test/java/run/backend/domain/event/service/EventServiceTest.java b/src/test/java/run/backend/domain/event/service/EventServiceTest.java new file mode 100644 index 0000000..6b2ec09 --- /dev/null +++ b/src/test/java/run/backend/domain/event/service/EventServiceTest.java @@ -0,0 +1,469 @@ +package run.backend.domain.event.service; + +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.BDDMockito.given; +import static org.mockito.BDDMockito.never; +import static org.mockito.BDDMockito.then; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; +import run.backend.domain.crew.entity.Crew; +import run.backend.domain.crew.enums.JoinStatus; +import run.backend.domain.crew.repository.JoinCrewRepository; +import run.backend.domain.event.dto.request.EventInfoRequest; +import run.backend.domain.event.dto.response.EventCreationValidationDto; +import run.backend.domain.event.entity.Event; +import run.backend.domain.event.entity.JoinEvent; +import run.backend.domain.event.entity.PeriodicEvent; +import run.backend.domain.event.enums.RepeatCycle; +import run.backend.domain.event.enums.WeekDay; +import run.backend.domain.event.exception.EventException.EventNotFound; +import run.backend.domain.event.exception.EventException.InvalidEventCreationRequest; +import run.backend.domain.event.mapper.EventMapper; +import run.backend.domain.event.repository.EventRepository; +import run.backend.domain.event.repository.JoinEventRepository; +import run.backend.domain.event.repository.PeriodicEventRepository; +import run.backend.domain.member.entity.Member; +import run.backend.domain.member.enums.Gender; +import run.backend.domain.member.enums.OAuthType; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("EventService") +class EventServiceTest { + + @Mock + private EventRepository eventRepository; + + @Mock + private PeriodicEventRepository periodicEventRepository; + + @Mock + private JoinCrewRepository joinCrewRepository; + + @Mock + private JoinEventRepository joinEventRepository; + + @Mock + private EventMapper eventMapper; + + @InjectMocks + private EventService sut; // System Under Test + + private Member requestMember; + private Member runningCaptain; + private Crew crew; + private Event savedEvent; + private JoinEvent savedJoinEvent; + private PeriodicEvent savedPeriodicEvent; + + @BeforeEach + void setUp() { + requestMember = createMemberWithId(1L, "요청자"); + runningCaptain = createMemberWithId(2L, "러닝캡틴"); + crew = createCrew("테스트크루"); + savedEvent = createEvent(); + savedJoinEvent = createJoinEvent(); + savedPeriodicEvent = createPeriodicEvent(); + } + + @Nested + @DisplayName("createEvent 메서드는") + class CreateEventTest { + + @Test + @DisplayName("일반 일정 생성 시 Event와 JoinEvent를 저장한다") + void shouldCreateSingleEventSuccessfully() { + // given + EventInfoRequest request = createSingleEventRequest(); + EventCreationValidationDto validation = new EventCreationValidationDto(crew, + runningCaptain); + + given(joinCrewRepository.validateEventCreation(any(), any(), any())) + .willReturn(Optional.of(validation)); + + given(eventMapper.toEvent(any(EventInfoRequest.class), any(Crew.class), + any(Member.class))) + .willReturn(savedEvent); + + given(eventMapper.toJoinEvent(any(Event.class), any(Member.class))) + .willReturn(savedJoinEvent); + + given(eventRepository.save(any(Event.class))) + .willReturn(savedEvent); + + given(joinEventRepository.save(any(JoinEvent.class))) + .willReturn(savedJoinEvent); + + // when + sut.createEvent(request, requestMember); + + // then + then(eventRepository).should().save(any(Event.class)); + then(joinEventRepository).should().save(any(JoinEvent.class)); + then(periodicEventRepository).should(never()).save(any(PeriodicEvent.class)); + } + + @Test + @DisplayName("주기적 일정 생성 시 PeriodicEvent, Event, JoinEvent를 모두 저장한다") + void shouldCreatePeriodicEventSuccessfully() { + // given + EventInfoRequest request = createPeriodicEventRequest(); + EventCreationValidationDto validation = new EventCreationValidationDto(crew, + runningCaptain); + + given(joinCrewRepository.validateEventCreation(any(), any(), any())) + .willReturn(Optional.of(validation)); + + given(eventMapper.toPeriodicEvent(any(EventInfoRequest.class), any(Crew.class), + any(Member.class))) + .willReturn(savedPeriodicEvent); + + given(eventMapper.toEvent(any(EventInfoRequest.class), any(Crew.class), + any(Member.class))) + .willReturn(savedEvent); + + given(eventMapper.toJoinEvent(any(Event.class), any(Member.class))) + .willReturn(savedJoinEvent); + + given(periodicEventRepository.save(any(PeriodicEvent.class))) + .willReturn(savedPeriodicEvent); + + given(eventRepository.save(any(Event.class))) + .willReturn(savedEvent); + + given(joinEventRepository.save(any(JoinEvent.class))) + .willReturn(savedJoinEvent); + + // when + sut.createEvent(request, requestMember); + + // then + then(periodicEventRepository).should().save(any(PeriodicEvent.class)); + then(eventRepository).should().save(any(Event.class)); + then(joinEventRepository).should().save(any(JoinEvent.class)); + } + + @Test + @DisplayName("유효하지 않은 요청 시 InvalidEventCreationRequest 예외를 발생시킨다") + void shouldThrowExceptionWhenValidationFails() { + // given + EventInfoRequest request = createSingleEventRequest(); + + given(joinCrewRepository.validateEventCreation(any(), any(), any())) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> sut.createEvent(request, requestMember)) + .isInstanceOf(InvalidEventCreationRequest.class); + + then(eventRepository).should(never()).save(any(Event.class)); + then(joinEventRepository).should(never()).save(any(JoinEvent.class)); + then(periodicEventRepository).should(never()).save(any(PeriodicEvent.class)); + } + + @Test + @DisplayName("일정 생성 후 러닝캡틴이 자동으로 참가 처리된다") + void shouldAutoJoinRunningCaptainToEvent() { + // given + EventInfoRequest request = createSingleEventRequest(); + EventCreationValidationDto validation = new EventCreationValidationDto(crew, + runningCaptain); + + given(joinCrewRepository.validateEventCreation(any(), any(), any())) + .willReturn(Optional.of(validation)); + + given(eventMapper.toEvent(any(EventInfoRequest.class), any(Crew.class), + any(Member.class))) + .willReturn(savedEvent); + + given(eventMapper.toJoinEvent(any(Event.class), any(Member.class))) + .willReturn(savedJoinEvent); + + given(eventRepository.save(any(Event.class))) + .willReturn(savedEvent); + + given(joinEventRepository.save(any(JoinEvent.class))) + .willReturn(savedJoinEvent); + + // when + sut.createEvent(request, requestMember); + + // then + then(joinEventRepository).should().save(any(JoinEvent.class)); + } + } + + @Nested + @DisplayName("updateEvent 메서드는") + class UpdateEventTest { + + @Test + @DisplayName("기본 정보만 수정할 때 성공한다") + void shouldUpdateBasicInfoSuccessfully() { + // given + EventInfoRequest request = createUpdateEventRequest(null, RepeatCycle.NONE, null, "변경된 제목"); + + given(eventRepository.findById(1L)).willReturn(Optional.of(savedEvent)); + given(periodicEventRepository.findByCrewAndTitleAndTime(any(), any(), any(), any())) + .willReturn(Optional.empty()); + + // when + sut.updateEvent(1L, request, requestMember); + + // then + then(eventRepository).should().findById(1L); + then(joinEventRepository).should(never()).deleteByEventAndMember(any(), any()); + } + + @Test + @DisplayName("이벤트 필드가 실제로 업데이트되는지 확인한다") + void shouldActuallyUpdateEventFields() { + // given + EventInfoRequest request = new EventInfoRequest( + "변경된 제목", + LocalDate.of(2025, 7, 20), + RepeatCycle.NONE, + null, + LocalTime.of(14, 0), + LocalTime.of(15, 0), + "변경된 장소", + null + ); + + given(eventRepository.findById(1L)).willReturn(Optional.of(savedEvent)); + given(periodicEventRepository.findByCrewAndTitleAndTime(any(), any(), any(), any())) + .willReturn(Optional.empty()); + + // when + sut.updateEvent(1L, request, requestMember); + + // then + assertThat(savedEvent.getTitle()).isEqualTo("변경된 제목"); + assertThat(savedEvent.getDate()).isEqualTo(LocalDate.of(2025, 7, 20)); + assertThat(savedEvent.getStartTime()).isEqualTo(LocalTime.of(14, 0)); + assertThat(savedEvent.getEndTime()).isEqualTo(LocalTime.of(15, 0)); + assertThat(savedEvent.getPlace()).isEqualTo("변경된 장소"); + } + + @Test + @DisplayName("러닝캡틴 변경 시 JoinEvent를 교체한다") + void shouldChangeRunningCaptainSuccessfully() { + // given + Member newRunningCaptain = createMemberWithId(3L, "새러닝캡틴"); + + EventInfoRequest request = createUpdateEventRequest(3L, RepeatCycle.NONE, null, "변경된 제목"); + + given(eventRepository.findById(1L)).willReturn(Optional.of(savedEvent)); + given(joinCrewRepository.findCrewMemberById(3L, crew.getId(), JoinStatus.APPROVED)) + .willReturn(Optional.of(newRunningCaptain)); + given(eventMapper.toJoinEvent(savedEvent, newRunningCaptain)).willReturn( + savedJoinEvent); + given(periodicEventRepository.findByCrewAndTitleAndTime(any(), any(), any(), any())) + .willReturn(Optional.empty()); + + // when + sut.updateEvent(1L, request, requestMember); + + // then + then(joinEventRepository).should().deleteByEventAndMember(savedEvent, runningCaptain); + then(joinEventRepository).should().save(any(JoinEvent.class)); + } + + @Test + @DisplayName("러닝캡틴이 실제로 변경되는지 확인한다") + void shouldActuallyChangeRunningCaptain() { + // given + Member newCaptain = createMemberWithId(3L, "새 러닝캡틴"); + + EventInfoRequest request = createUpdateEventRequest(3L, RepeatCycle.NONE, null, "변경된 제목"); + + given(eventRepository.findById(1L)).willReturn(Optional.of(savedEvent)); + given(joinCrewRepository.findCrewMemberById(3L, crew.getId(), JoinStatus.APPROVED)) + .willReturn(Optional.of(newCaptain)); + given(eventMapper.toJoinEvent(savedEvent, newCaptain)).willReturn(savedJoinEvent); + given(periodicEventRepository.findByCrewAndTitleAndTime(any(), any(), any(), any())) + .willReturn(Optional.empty()); + + // when + sut.updateEvent(1L, request, requestMember); + + // then + assertThat(savedEvent.getMember()).isEqualTo(newCaptain); + } + + @Test + @DisplayName("반복 설정을 추가할 때 PeriodicEvent를 생성한다") + void shouldAddPeriodicEventSuccessfully() { + // given + EventInfoRequest request = createUpdateEventRequest(null, RepeatCycle.WEEKLY, WeekDay.TUESDAY, "반복 일정으로 변경"); + EventInfoRequest mappedRequest = createPeriodicEventRequest(); + + given(eventRepository.findById(1L)).willReturn(Optional.of(savedEvent)); + given(periodicEventRepository.findByCrewAndTitleAndTime(any(), any(), any(), any())) + .willReturn(Optional.empty()); + given(eventMapper.toEventInfoRequest(request, savedEvent)).willReturn(mappedRequest); + given(eventMapper.toPeriodicEvent(mappedRequest, crew, runningCaptain)).willReturn( + savedPeriodicEvent); + + // when + sut.updateEvent(1L, request, requestMember); + + // then + then(periodicEventRepository).should().save(any(PeriodicEvent.class)); + } + + @Test + @DisplayName("반복 설정을 제거할 때 기존 PeriodicEvent를 삭제한다") + void shouldRemovePeriodicEventSuccessfully() { + // given + EventInfoRequest request = createUpdateEventRequest(null, RepeatCycle.NONE, null, "반복 제거"); + + given(eventRepository.findById(1L)).willReturn(Optional.of(savedEvent)); + given(periodicEventRepository.findByCrewAndTitleAndTime(any(), any(), any(), any())) + .willReturn(Optional.of(savedPeriodicEvent)); + + // when + sut.updateEvent(1L, request, requestMember); + + // then + then(periodicEventRepository).should().delete(savedPeriodicEvent); + } + + @Test + @DisplayName("존재하지 않는 일정 수정 시 EventNotFound 예외를 발생시킨다") + void shouldThrowEventNotFoundWhenEventDoesNotExist() { + // given + EventInfoRequest request = createUpdateEventRequest(null, RepeatCycle.NONE, null, "변경된 제목"); + + given(eventRepository.findById(1L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> sut.updateEvent(1L, request, requestMember)) + .isInstanceOf(EventNotFound.class); + } + + @Test + @DisplayName("새로운 러닝캡틴이 크루원이 아닐 때 InvalidEventCreationRequest 예외를 발생시킨다") + void shouldThrowExceptionWhenNewRunningCaptainIsNotCrewMember() { + // given + EventInfoRequest request = createUpdateEventRequest(3L, RepeatCycle.NONE, null, "변경된 제목"); + + given(eventRepository.findById(1L)).willReturn(Optional.of(savedEvent)); + given(joinCrewRepository.findCrewMemberById(3L, crew.getId(), JoinStatus.APPROVED)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> sut.updateEvent(1L, request, requestMember)) + .isInstanceOf(InvalidEventCreationRequest.class); + } + } + + private Member createMemberWithId(Long id, String nickname) { + Member member = Member.builder() + .username("test_user") + .nickname(nickname) + .gender(Gender.MALE) + .age(25) + .oauthId("oauth_id_" + id) + .oauthType(OAuthType.GOOGLE) + .profileImage("profile.jpg") + .build(); + + ReflectionTestUtils.setField(member, "id", id); + return member; + } + + private Crew createCrew(String name) { + Crew crew = Crew.builder() + .name(name) + .description("테스트 크루 설명") + .image("crew.jpg") + .build(); + + ReflectionTestUtils.setField(crew, "id", 1L); + return crew; + } + + private Event createEvent() { + return Event.builder() + .title("테스트 일정") + .date(LocalDate.of(2025, 7, 18)) + .startTime(LocalTime.of(9, 0)) + .endTime(LocalTime.of(10, 0)) + .place("테스트 장소") + .crew(crew) + .member(runningCaptain) + .build(); + } + + private JoinEvent createJoinEvent() { + return JoinEvent.builder() + .event(savedEvent) + .member(runningCaptain) + .build(); + } + + private PeriodicEvent createPeriodicEvent() { + return PeriodicEvent.builder() + .baseDate(LocalDate.of(2025, 7, 18)) + .repeatCycle(RepeatCycle.WEEKLY) + .repeatDays(WeekDay.MONDAY) + .crew(crew) + .member(runningCaptain) + .build(); + } + + private EventInfoRequest createSingleEventRequest() { + return new EventInfoRequest( + "테스트 일정", + LocalDate.of(2025, 7, 18), + RepeatCycle.NONE, + null, + LocalTime.of(9, 0), + LocalTime.of(10, 0), + "테스트 장소", + 1L + ); + } + + private EventInfoRequest createPeriodicEventRequest() { + return new EventInfoRequest( + "주기적 일정", + LocalDate.of(2025, 7, 18), + RepeatCycle.WEEKLY, + WeekDay.MONDAY, + LocalTime.of(9, 0), + LocalTime.of(10, 0), + "테스트 장소", + 1L + ); + } + + private EventInfoRequest createUpdateEventRequest(Long runningCaptainId, RepeatCycle repeatCycle, WeekDay weekDay, String title) { + return new EventInfoRequest( + title, + LocalDate.of(2025, 7, 19), + repeatCycle, + weekDay, + LocalTime.of(10, 0), + LocalTime.of(11, 0), + "장소", + runningCaptainId + ); + } +} \ No newline at end of file