diff --git a/.gitignore b/.gitignore index 69678cc..53294a9 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ secrets.yml .idea .env src/test/resources/application.yml -/uploads \ No newline at end of file +/uploads +./scripts \ No newline at end of file diff --git a/build.gradle b/build.gradle index 19d6b59..c08ea6f 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-aop' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-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 new file mode 100644 index 0000000..ba72f2a --- /dev/null +++ b/src/main/java/run/backend/domain/crew/repository/JoinCrewRepository.java @@ -0,0 +1,34 @@ +package run.backend.domain.crew.repository; + +import java.util.Optional; +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.JoinCrew; +import run.backend.domain.crew.enums.JoinStatus; +import run.backend.domain.event.dto.response.EventCreationValidationDto; + +public interface JoinCrewRepository extends JpaRepository { + + @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); + + @Query(""" + SELECT new run.backend.domain.event.dto.response.EventCreationValidationDto( + requesterJoin.crew, + captainJoin.member + ) + FROM JoinCrew requesterJoin + INNER JOIN JoinCrew captainJoin ON requesterJoin.crew.id = captainJoin.crew.id + WHERE requesterJoin.member.id = :requesterId + AND requesterJoin.joinStatus = :status + AND captainJoin.member.id = :runningCaptainId + AND captainJoin.joinStatus = :status + """) + Optional validateEventCreation( + @Param("requesterId") Long requesterId, + @Param("runningCaptainId") Long runningCaptainId, + @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 new file mode 100644 index 0000000..9391ae3 --- /dev/null +++ b/src/main/java/run/backend/domain/event/controller/EventController.java @@ -0,0 +1,36 @@ +package run.backend.domain.event.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +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.member.entity.Member; +import run.backend.global.annotation.member.Login; +import run.backend.global.common.response.CommonResponse; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/events") +@Tag(name = "Events", description = "일정 관련 API") +public class EventController { + + private final EventServiceImpl eventService; + + @PostMapping + @PreAuthorize("hasRole('MANAGER') or hasRole('LEADER')") + @Operation(summary = "일정 생성", description = "러닝 일정를 생성합니다. LEADER 또는 MANAGER 권한이 필요합니다.") + public CommonResponse createEvent( + @RequestBody EventInfoRequest eventInfoRequest, + @Login Member member + ) { + + eventService.createEvent(eventInfoRequest, 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 aa15102..b00b751 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 @@ -1,4 +1,22 @@ package run.backend.domain.event.dto.request; -public record EventInfoRequest() { +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.time.LocalTime; +import run.backend.domain.event.enums.RepeatCycle; +import run.backend.domain.event.enums.WeekDay; + +public record EventInfoRequest( + String title, + LocalDate baseDate, + @Schema(description = "반복 주기", example = "NONE / WEEKLY") + RepeatCycle repeatCycle, + @Schema(description = "반복 요일", example = "MONDAY / TUESDAY / WEDNESDAY / THURSDAY / FRIDAY / SATURDAY / SUNDAY", nullable = true) + WeekDay repeatDays, + LocalTime startTime, + LocalTime endTime, + String place, + @Schema(description = "러닝캡틴 ID", example = "1") + Long runningCaptainId +) { } diff --git a/src/main/java/run/backend/domain/event/dto/response/EventCreationValidationDto.java b/src/main/java/run/backend/domain/event/dto/response/EventCreationValidationDto.java new file mode 100644 index 0000000..ba59942 --- /dev/null +++ b/src/main/java/run/backend/domain/event/dto/response/EventCreationValidationDto.java @@ -0,0 +1,8 @@ +package run.backend.domain.event.dto.response; + +import run.backend.domain.crew.entity.Crew; +import run.backend.domain.member.entity.Member; + +public record EventCreationValidationDto(Crew crew, Member runningCaptain) { + +} 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 af17ea1..af07741 100644 --- a/src/main/java/run/backend/domain/event/entity/Event.java +++ b/src/main/java/run/backend/domain/event/entity/Event.java @@ -50,7 +50,7 @@ public class Event extends BaseEntity { @JoinColumn(name = "record_id") private CrewRecord record; - @OneToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "running_captain") private Member member; @@ -70,10 +70,18 @@ public Event( this.startTime = startTime; this.endTime = endTime; this.place = place; - this.expectedParticipants = 1L; + this.expectedParticipants = 0L; this.actualParticipants = 0L; this.crew = crew; this.record = record; this.member = member; } + + public void incrementExpectedParticipants() { + this.expectedParticipants++; + } + + public void incrementActualParticipants() { + this.actualParticipants++; + } } diff --git a/src/main/java/run/backend/domain/event/entity/JoinEvent.java b/src/main/java/run/backend/domain/event/entity/JoinEvent.java index 0633682..48bc5ee 100644 --- a/src/main/java/run/backend/domain/event/entity/JoinEvent.java +++ b/src/main/java/run/backend/domain/event/entity/JoinEvent.java @@ -36,5 +36,6 @@ public JoinEvent( this.isRunning = false; this.member = member; this.event = event; + this.event.incrementExpectedParticipants(); } } 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 5e6c839..07b02d3 100644 --- a/src/main/java/run/backend/domain/event/entity/PeriodicEvent.java +++ b/src/main/java/run/backend/domain/event/entity/PeriodicEvent.java @@ -8,6 +8,7 @@ import run.backend.domain.crew.entity.Crew; import run.backend.domain.event.enums.RepeatCycle; import run.backend.domain.event.enums.WeekDay; +import run.backend.domain.member.entity.Member; import run.backend.global.common.BaseEntity; import java.time.LocalDate; @@ -48,6 +49,10 @@ public class PeriodicEvent extends BaseEntity { @JoinColumn(name = "crew_id") private Crew crew; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "running_captain") + private Member member; + @Builder public PeriodicEvent( String title, @@ -57,7 +62,8 @@ public PeriodicEvent( LocalTime startTime, LocalTime endTime, String place, - Crew crew + Crew crew, + Member member ) { this.title = title; this.baseDate = baseDate; @@ -67,5 +73,6 @@ public PeriodicEvent( this.endTime = endTime; this.place = place; this.crew = crew; + this.member = member; } } diff --git a/src/main/java/run/backend/domain/event/exception/EventErrorCode.java b/src/main/java/run/backend/domain/event/exception/EventErrorCode.java new file mode 100644 index 0000000..ebbd7c2 --- /dev/null +++ b/src/main/java/run/backend/domain/event/exception/EventErrorCode.java @@ -0,0 +1,15 @@ +package run.backend.domain.event.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import run.backend.global.exception.ErrorCode; + +@Getter +@AllArgsConstructor +public enum EventErrorCode implements ErrorCode { + + RUNNING_CAPTAIN_NOT_CREW_MEMBER(6001, "러닝캡이 크루원이 아닙니다."); + + 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 new file mode 100644 index 0000000..8b12138 --- /dev/null +++ b/src/main/java/run/backend/domain/event/exception/EventException.java @@ -0,0 +1,16 @@ +package run.backend.domain.event.exception; + +import run.backend.global.exception.CustomException; + +public class EventException extends CustomException { + + public EventException(final EventErrorCode eventErrorCode) { + super(eventErrorCode); + } + + public static class InvalidEventCreationRequest extends EventException { + public InvalidEventCreationRequest() { + super(EventErrorCode.RUNNING_CAPTAIN_NOT_CREW_MEMBER); + } + } +} diff --git a/src/main/java/run/backend/domain/event/repository/EventRepository.java b/src/main/java/run/backend/domain/event/repository/EventRepository.java new file mode 100644 index 0000000..94b76e6 --- /dev/null +++ b/src/main/java/run/backend/domain/event/repository/EventRepository.java @@ -0,0 +1,8 @@ +package run.backend.domain.event.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import run.backend.domain.event.entity.Event; + +public interface EventRepository extends JpaRepository { + +} diff --git a/src/main/java/run/backend/domain/event/repository/JoinEventRepository.java b/src/main/java/run/backend/domain/event/repository/JoinEventRepository.java new file mode 100644 index 0000000..bb974ea --- /dev/null +++ b/src/main/java/run/backend/domain/event/repository/JoinEventRepository.java @@ -0,0 +1,8 @@ +package run.backend.domain.event.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import run.backend.domain.event.entity.JoinEvent; + +public interface JoinEventRepository extends JpaRepository { + +} diff --git a/src/main/java/run/backend/domain/event/repository/PeriodicEventRepository.java b/src/main/java/run/backend/domain/event/repository/PeriodicEventRepository.java new file mode 100644 index 0000000..b3c0e23 --- /dev/null +++ b/src/main/java/run/backend/domain/event/repository/PeriodicEventRepository.java @@ -0,0 +1,7 @@ +package run.backend.domain.event.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import run.backend.domain.event.entity.PeriodicEvent; + +public interface PeriodicEventRepository extends JpaRepository { +} 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 89a3558..78ca968 100644 --- a/src/main/java/run/backend/domain/event/service/EventService.java +++ b/src/main/java/run/backend/domain/event/service/EventService.java @@ -1,16 +1,17 @@ package run.backend.domain.event.service; - import run.backend.domain.event.dto.request.EventInfoRequest; -import run.backend.domain.event.dto.response.EventInfoResponse; +import run.backend.domain.member.entity.Member; public interface EventService { - void updateEvent(EventInfoRequest eventInfoRequest); - - void joinEvent(Long eventId, Long memberId); - - void deleteEvent(Long eventId); + void createEvent(EventInfoRequest eventInfoRequest, Member member); - EventInfoResponse getEventDetails(Long eventId); +// void updateEvent(EventInfoRequest eventInfoRequest); +// +// void joinEvent(Long eventId, Long memberId); +// +// void deleteEvent(Long eventId); +// +// EventInfoResponse getEventDetails(Long eventId); } diff --git a/src/main/java/run/backend/domain/event/service/EventServiceImpl.java b/src/main/java/run/backend/domain/event/service/EventServiceImpl.java new file mode 100644 index 0000000..6911817 --- /dev/null +++ b/src/main/java/run/backend/domain/event/service/EventServiceImpl.java @@ -0,0 +1,89 @@ +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/entity/Member.java b/src/main/java/run/backend/domain/member/entity/Member.java index a15637f..bf8d670 100644 --- a/src/main/java/run/backend/domain/member/entity/Member.java +++ b/src/main/java/run/backend/domain/member/entity/Member.java @@ -75,4 +75,8 @@ public Member(String username, String nickname, Gender gender, int age, String o this.pushEnabled = true; this.role = Role.NONE; } + + public String toString() { + return "[멤버] 닉네임: " + this.nickname; + } } diff --git a/src/main/java/run/backend/global/annotation/global/Logging.java b/src/main/java/run/backend/global/annotation/global/Logging.java new file mode 100644 index 0000000..73d88ff --- /dev/null +++ b/src/main/java/run/backend/global/annotation/global/Logging.java @@ -0,0 +1,12 @@ +package run.backend.global.annotation.global; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Logging { + String description() default ""; +} diff --git a/src/main/java/run/backend/global/annotation/global/ServiceLoggingAspect.java b/src/main/java/run/backend/global/annotation/global/ServiceLoggingAspect.java new file mode 100644 index 0000000..df00bd2 --- /dev/null +++ b/src/main/java/run/backend/global/annotation/global/ServiceLoggingAspect.java @@ -0,0 +1,46 @@ +package run.backend.global.annotation.global; + +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.stereotype.Component; +import org.springframework.util.StopWatch; +import java.util.Arrays; + +@Aspect +@Component +@Slf4j +public class ServiceLoggingAspect { + + @Around("@annotation(run.backend.global.annotation.global.Logging)") + public Object logServiceMethodExecution(ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + String methodName = signature.getMethod().getName(); + String className = joinPoint.getTarget().getClass().getSimpleName(); + + Logging serviceLog = signature.getMethod().getAnnotation(Logging.class); + String description = serviceLog.description(); + + Object[] args = joinPoint.getArgs(); + String parameterString = ""; + if (args != null && args.length > 0) { + parameterString = " (파라미터: " + Arrays.toString(args) + ")"; + } + + log.info("--- [Logging] {} - {}(){}{} 시작 ---", className, methodName, parameterString, (description.isEmpty() ? "" : " - " + description)); + + StopWatch stopWatch = new StopWatch(); + stopWatch.start(); + + Object result = null; + try { + result = joinPoint.proceed(); + } finally { + stopWatch.stop(); + log.info("--- [Logging] {} - {}() 종료 (소요 시간: {} ms) ---", className, methodName, stopWatch.getTotalTimeMillis()); + } + return result; + } +} \ No newline at end of file diff --git a/src/main/java/run/backend/global/annotation/member/MemberCrew.java b/src/main/java/run/backend/global/annotation/member/MemberCrew.java new file mode 100644 index 0000000..069328f --- /dev/null +++ b/src/main/java/run/backend/global/annotation/member/MemberCrew.java @@ -0,0 +1,11 @@ +package run.backend.global.annotation.member; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface MemberCrew { +} diff --git a/src/main/java/run/backend/global/annotation/member/MemberCrewArgumentResolver.java b/src/main/java/run/backend/global/annotation/member/MemberCrewArgumentResolver.java new file mode 100644 index 0000000..435e7ed --- /dev/null +++ b/src/main/java/run/backend/global/annotation/member/MemberCrewArgumentResolver.java @@ -0,0 +1,48 @@ +package run.backend.global.annotation.member; + +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import run.backend.domain.crew.entity.Crew; +import run.backend.domain.crew.entity.JoinCrew; +import run.backend.domain.crew.enums.JoinStatus; +import run.backend.domain.crew.repository.JoinCrewRepository; + +@Component +@RequiredArgsConstructor +public class MemberCrewArgumentResolver implements HandlerMethodArgumentResolver { + + private final JoinCrewRepository joinCrewRepository; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + boolean hasMemberCrewAnnotation = parameter.hasParameterAnnotation(MemberCrew.class); + boolean isCrewType = parameter.getParameterType().equals(Crew.class); + + return hasMemberCrewAnnotation && isCrewType; + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + + Long memberId = getCurrentMemberId(); + + JoinCrew joinCrew = joinCrewRepository.findByMemberIdAndJoinStatus(memberId, JoinStatus.APPROVED) + .orElseThrow(() -> new IllegalArgumentException("사용자가 속한 크루가 없습니다")); + + return joinCrew.getCrew(); + } + + private Long getCurrentMemberId() { + // Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + // CustomUserDetails principal = (CustomUserDetails) authentication.getPrincipal(); + // return principal.getMember().getId(); + + return 1L; + } +} diff --git a/src/main/java/run/backend/global/config/WebConfig.java b/src/main/java/run/backend/global/config/WebConfig.java index 222e86f..dcf52dc 100644 --- a/src/main/java/run/backend/global/config/WebConfig.java +++ b/src/main/java/run/backend/global/config/WebConfig.java @@ -5,6 +5,7 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import run.backend.global.annotation.member.LoginArgumentResolver; +import run.backend.global.annotation.member.MemberCrewArgumentResolver; import java.util.List; @@ -12,14 +13,18 @@ public class WebConfig implements WebMvcConfigurer { private final LoginArgumentResolver loginArgumentResolver; + private final MemberCrewArgumentResolver memberCrewArgumentResolver; @Autowired - public WebConfig(LoginArgumentResolver loginArgumentResolver) { + public WebConfig(LoginArgumentResolver loginArgumentResolver, + MemberCrewArgumentResolver memberCrewArgumentResolver) { this.loginArgumentResolver = loginArgumentResolver; + this.memberCrewArgumentResolver = memberCrewArgumentResolver; } @Override public void addArgumentResolvers(List resolvers) { resolvers.add(loginArgumentResolver); + resolvers.add(memberCrewArgumentResolver); } } diff --git a/src/main/java/run/backend/global/security/SecurityConfig.java b/src/main/java/run/backend/global/security/SecurityConfig.java index f26d7c2..296c76f 100644 --- a/src/main/java/run/backend/global/security/SecurityConfig.java +++ b/src/main/java/run/backend/global/security/SecurityConfig.java @@ -48,7 +48,8 @@ public class SecurityConfig { private final String[] PermitAllPatterns = { "/api/v1/members/**", - "/api/v1/auth/**" + "/api/v1/auth/**", + "/api/v1/events/**" }; @Bean diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8c40a93..2af2f57 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,7 +17,7 @@ spring: jpa: database: mysql hibernate: - ddl-auto: update + ddl-auto: create properties: hibernate: dialect: org.hibernate.dialect.MySQLDialect diff --git a/src/test/java/run/backend/domain/event/service/EventServiceImplTest.java b/src/test/java/run/backend/domain/event/service/EventServiceImplTest.java new file mode 100644 index 0000000..47b8731 --- /dev/null +++ b/src/test/java/run/backend/domain/event/service/EventServiceImplTest.java @@ -0,0 +1,213 @@ +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 + ); + } +}