diff --git a/src/main/java/com/moru/server/domain/onboarding/controller/OnboardingController.java b/src/main/java/com/moru/server/domain/onboarding/controller/OnboardingController.java index 041d4e7..17bb4be 100644 --- a/src/main/java/com/moru/server/domain/onboarding/controller/OnboardingController.java +++ b/src/main/java/com/moru/server/domain/onboarding/controller/OnboardingController.java @@ -1,6 +1,8 @@ package com.moru.server.domain.onboarding.controller; +import com.moru.server.domain.onboarding.dto.OnboardingRequestDTO; import com.moru.server.domain.onboarding.dto.OnboardingResponseDTO; +import com.moru.server.domain.onboarding.service.command.OnboardingCommandService; import com.moru.server.domain.onboarding.service.query.OnboardingQueryService; import com.moru.server.domain.routine.dto.RoutineGroupResponseDTO; import com.moru.server.domain.routine.entity.enums.RoutineGoalType; @@ -9,9 +11,12 @@ import com.moru.server.global.security.auth.AuthenticatedMember; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; +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; @@ -25,8 +30,22 @@ public class OnboardingController { private final OnboardingQueryService onboardingQueryService; + private final OnboardingCommandService onboardingCommandService; private final RoutineGroupQueryService routineGroupQueryService; + @Operation( + summary = "온보딩 완료", + description = "로그인 후 서버에 동기화된 본인 소유 루틴 그룹을 기준으로 온보딩을 완료합니다." + ) + @PostMapping("/complete") + public ApiResponse completeOnboarding( + @AuthenticationPrincipal AuthenticatedMember member, + @Valid @RequestBody OnboardingRequestDTO.CompleteRequest request + ) { + return ApiResponse.onSuccess( + onboardingCommandService.completeOnboarding(member.memberId(), request)); + } + @Operation(summary = "온보딩 완료 여부 조회", description = "현재 로그인한 사용자의 온보딩 완료 여부를 조회합니다.") @GetMapping("/status") public ApiResponse getOnboardingStatus( diff --git a/src/main/java/com/moru/server/domain/onboarding/dto/OnboardingRequestDTO.java b/src/main/java/com/moru/server/domain/onboarding/dto/OnboardingRequestDTO.java new file mode 100644 index 0000000..d075aff --- /dev/null +++ b/src/main/java/com/moru/server/domain/onboarding/dto/OnboardingRequestDTO.java @@ -0,0 +1,15 @@ +package com.moru.server.domain.onboarding.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record OnboardingRequestDTO() { + + @Schema(description = "온보딩 완료 요청") + public record CompleteRequest( + @NotNull(message = "routineGroupId는 필수입니다.") + @Schema(description = "동기화가 완료된 회원 소유 루틴 그룹 ID", example = "15") + Long routineGroupId + ) { + } +} diff --git a/src/main/java/com/moru/server/domain/onboarding/dto/OnboardingResponseDTO.java b/src/main/java/com/moru/server/domain/onboarding/dto/OnboardingResponseDTO.java index 5ffbe07..5d8cd45 100644 --- a/src/main/java/com/moru/server/domain/onboarding/dto/OnboardingResponseDTO.java +++ b/src/main/java/com/moru/server/domain/onboarding/dto/OnboardingResponseDTO.java @@ -4,7 +4,7 @@ public record OnboardingResponseDTO() { - @Schema(description = "온보딩 완료 여부 조회 응답") + @Schema(description = "온보딩 완료 상태 응답") public record StatusResponse( @Schema(description = "온보딩 완료 여부", example = "false") Boolean onboardingCompleted diff --git a/src/main/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandService.java b/src/main/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandService.java new file mode 100644 index 0000000..2f8009c --- /dev/null +++ b/src/main/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandService.java @@ -0,0 +1,12 @@ +package com.moru.server.domain.onboarding.service.command; + +import com.moru.server.domain.onboarding.dto.OnboardingRequestDTO; +import com.moru.server.domain.onboarding.dto.OnboardingResponseDTO; + +public interface OnboardingCommandService { + + OnboardingResponseDTO.StatusResponse completeOnboarding( + Long memberId, + OnboardingRequestDTO.CompleteRequest request + ); +} diff --git a/src/main/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImpl.java b/src/main/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImpl.java new file mode 100644 index 0000000..91cdebd --- /dev/null +++ b/src/main/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImpl.java @@ -0,0 +1,43 @@ +package com.moru.server.domain.onboarding.service.command; + +import com.moru.server.domain.member.entity.Member; +import com.moru.server.domain.member.repository.MemberRepository; +import com.moru.server.domain.onboarding.dto.OnboardingRequestDTO; +import com.moru.server.domain.onboarding.dto.OnboardingResponseDTO; +import com.moru.server.domain.routine.repository.RoutineGroupRepository; +import com.moru.server.global.exception.BusinessException; +import com.moru.server.global.response.code.status.ErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class OnboardingCommandServiceImpl implements OnboardingCommandService { + + private final MemberRepository memberRepository; + private final RoutineGroupRepository routineGroupRepository; + + @Override + public OnboardingResponseDTO.StatusResponse completeOnboarding( + Long memberId, + OnboardingRequestDTO.CompleteRequest request + ) { + Member member = memberRepository.findByIdForUpdate(memberId) + .orElseThrow(() -> new BusinessException(ErrorStatus.MEMBER_NOT_FOUND)); + + boolean ownsRoutineGroup = routineGroupRepository + .existsByIdAndMember_Id(request.routineGroupId(), memberId); + if (!ownsRoutineGroup) { + throw new BusinessException(ErrorStatus.ROUTINE_GROUP_NOT_FOUND); + } + + if (Boolean.TRUE.equals(member.getOnboardingCompleted())) { + return new OnboardingResponseDTO.StatusResponse(true); + } + + member.completeOnboarding(); + return new OnboardingResponseDTO.StatusResponse(true); + } +} diff --git a/src/main/java/com/moru/server/domain/routine/repository/RoutineGroupRepository.java b/src/main/java/com/moru/server/domain/routine/repository/RoutineGroupRepository.java index bb9b209..3b23c18 100644 --- a/src/main/java/com/moru/server/domain/routine/repository/RoutineGroupRepository.java +++ b/src/main/java/com/moru/server/domain/routine/repository/RoutineGroupRepository.java @@ -13,6 +13,8 @@ public interface RoutineGroupRepository extends JpaRepository { + boolean existsByIdAndMember_Id(Long routineGroupId, Long memberId); + @Query(""" select distinct rg from RoutineGroup rg left join fetch rg.routines diff --git a/src/test/java/com/moru/server/domain/onboarding/controller/OnboardingControllerTest.java b/src/test/java/com/moru/server/domain/onboarding/controller/OnboardingControllerTest.java new file mode 100644 index 0000000..a9306f1 --- /dev/null +++ b/src/test/java/com/moru/server/domain/onboarding/controller/OnboardingControllerTest.java @@ -0,0 +1,54 @@ +package com.moru.server.domain.onboarding.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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 com.moru.server.domain.member.entity.enums.Role; +import com.moru.server.domain.onboarding.dto.OnboardingRequestDTO; +import com.moru.server.domain.onboarding.dto.OnboardingResponseDTO; +import com.moru.server.domain.onboarding.service.command.OnboardingCommandService; +import com.moru.server.domain.onboarding.service.query.OnboardingQueryService; +import com.moru.server.domain.routine.service.query.RoutineGroup.RoutineGroupQueryService; +import com.moru.server.global.response.ApiResponse; +import com.moru.server.global.security.auth.AuthenticatedMember; + +@ExtendWith(MockitoExtension.class) +class OnboardingControllerTest { + + @Mock + private OnboardingQueryService onboardingQueryService; + + @Mock + private OnboardingCommandService onboardingCommandService; + + @Mock + private RoutineGroupQueryService routineGroupQueryService; + + @InjectMocks + private OnboardingController onboardingController; + + @Test + void delegatesOnboardingCompletionToCommandService() { + AuthenticatedMember member = new AuthenticatedMember(1L, Role.MEMBER); + OnboardingRequestDTO.CompleteRequest request = + new OnboardingRequestDTO.CompleteRequest(15L); + OnboardingResponseDTO.StatusResponse serviceResponse = + new OnboardingResponseDTO.StatusResponse(true); + when(onboardingCommandService.completeOnboarding(1L, request)) + .thenReturn(serviceResponse); + + ApiResponse response = + onboardingController.completeOnboarding(member, request); + + assertThat(response.getIsSuccess()).isTrue(); + assertThat(response.getResult().onboardingCompleted()).isTrue(); + verify(onboardingCommandService).completeOnboarding(1L, request); + } +} diff --git a/src/test/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImplTest.java b/src/test/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImplTest.java new file mode 100644 index 0000000..3c363c4 --- /dev/null +++ b/src/test/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImplTest.java @@ -0,0 +1,126 @@ +package com.moru.server.domain.onboarding.service.command; + +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.util.Optional; + +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 com.moru.server.domain.member.entity.Member; +import com.moru.server.domain.member.entity.enums.LoginType; +import com.moru.server.domain.member.entity.enums.Role; +import com.moru.server.domain.member.repository.MemberRepository; +import com.moru.server.domain.onboarding.dto.OnboardingRequestDTO; +import com.moru.server.domain.onboarding.dto.OnboardingResponseDTO; +import com.moru.server.domain.routine.repository.RoutineGroupRepository; +import com.moru.server.global.exception.BusinessException; +import com.moru.server.global.response.code.status.ErrorStatus; + +@ExtendWith(MockitoExtension.class) +class OnboardingCommandServiceImplTest { + + private static final Long MEMBER_ID = 1L; + private static final Long ROUTINE_GROUP_ID = 15L; + + @Mock + private MemberRepository memberRepository; + + @Mock + private RoutineGroupRepository routineGroupRepository; + + @InjectMocks + private OnboardingCommandServiceImpl onboardingCommandService; + + @Test + void completesOnboardingWhenMemberOwnsRoutineGroup() { + Member member = createMember(false); + OnboardingRequestDTO.CompleteRequest request = createRequest(); + when(memberRepository.findByIdForUpdate(MEMBER_ID)).thenReturn(Optional.of(member)); + when(routineGroupRepository.existsByIdAndMember_Id(ROUTINE_GROUP_ID, MEMBER_ID)) + .thenReturn(true); + + OnboardingResponseDTO.StatusResponse response = + onboardingCommandService.completeOnboarding(MEMBER_ID, request); + + assertThat(response.onboardingCompleted()).isTrue(); + assertThat(member.getOnboardingCompleted()).isTrue(); + } + + @Test + void returnsSuccessWhenOnboardingIsAlreadyCompleted() { + Member member = createMember(true); + when(memberRepository.findByIdForUpdate(MEMBER_ID)).thenReturn(Optional.of(member)); + when(routineGroupRepository.existsByIdAndMember_Id(ROUTINE_GROUP_ID, MEMBER_ID)) + .thenReturn(true); + + OnboardingResponseDTO.StatusResponse response = + onboardingCommandService.completeOnboarding(MEMBER_ID, createRequest()); + + assertThat(response.onboardingCompleted()).isTrue(); + verify(routineGroupRepository) + .existsByIdAndMember_Id(ROUTINE_GROUP_ID, MEMBER_ID); + } + + @Test + void rejectsUnownedRoutineGroupEvenWhenOnboardingIsAlreadyCompleted() { + Member member = createMember(true); + when(memberRepository.findByIdForUpdate(MEMBER_ID)).thenReturn(Optional.of(member)); + when(routineGroupRepository.existsByIdAndMember_Id(ROUTINE_GROUP_ID, MEMBER_ID)) + .thenReturn(false); + + assertThatThrownBy(() -> + onboardingCommandService.completeOnboarding(MEMBER_ID, createRequest())) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getBaseCode()) + .isEqualTo(ErrorStatus.ROUTINE_GROUP_NOT_FOUND)); + } + + @Test + void rejectsRoutineGroupNotOwnedByMember() { + Member member = createMember(false); + when(memberRepository.findByIdForUpdate(MEMBER_ID)).thenReturn(Optional.of(member)); + when(routineGroupRepository.existsByIdAndMember_Id(ROUTINE_GROUP_ID, MEMBER_ID)) + .thenReturn(false); + + assertThatThrownBy(() -> + onboardingCommandService.completeOnboarding(MEMBER_ID, createRequest())) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getBaseCode()) + .isEqualTo(ErrorStatus.ROUTINE_GROUP_NOT_FOUND)); + + assertThat(member.getOnboardingCompleted()).isFalse(); + } + + @Test + void rejectsMissingMember() { + when(memberRepository.findByIdForUpdate(MEMBER_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> + onboardingCommandService.completeOnboarding(MEMBER_ID, createRequest())) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getBaseCode()) + .isEqualTo(ErrorStatus.MEMBER_NOT_FOUND)); + } + + private OnboardingRequestDTO.CompleteRequest createRequest() { + return new OnboardingRequestDTO.CompleteRequest(ROUTINE_GROUP_ID); + } + + private Member createMember(boolean onboardingCompleted) { + return Member.builder() + .id(MEMBER_ID) + .oauthId("google-member-id") + .nickname("모루") + .role(Role.MEMBER) + .loginType(LoginType.GOOGLE) + .onboardingCompleted(onboardingCompleted) + .build(); + } +}