From 7ac8b66f7fc801eea3d83836fa0fa7ac8cad4495 Mon Sep 17 00:00:00 2001 From: issuejong Date: Mon, 17 Aug 2026 21:04:51 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/OnboardingController.java | 19 +++ .../onboarding/dto/OnboardingRequestDTO.java | 15 +++ .../onboarding/dto/OnboardingResponseDTO.java | 2 +- .../command/OnboardingCommandService.java | 12 ++ .../command/OnboardingCommandServiceImpl.java | 43 +++++++ .../repository/RoutineGroupRepository.java | 2 + .../controller/OnboardingControllerTest.java | 54 +++++++++ .../OnboardingCommandServiceImplTest.java | 111 ++++++++++++++++++ 8 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/moru/server/domain/onboarding/dto/OnboardingRequestDTO.java create mode 100644 src/main/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandService.java create mode 100644 src/main/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImpl.java create mode 100644 src/test/java/com/moru/server/domain/onboarding/controller/OnboardingControllerTest.java create mode 100644 src/test/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImplTest.java 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..19e527b --- /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)); + + if (Boolean.TRUE.equals(member.getOnboardingCompleted())) { + return new OnboardingResponseDTO.StatusResponse(true); + } + + boolean ownsRoutineGroup = routineGroupRepository + .existsByIdAndMember_Id(request.routineGroupId(), memberId); + if (!ownsRoutineGroup) { + throw new BusinessException(ErrorStatus.ROUTINE_GROUP_NOT_FOUND); + } + + 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..56d7f64 --- /dev/null +++ b/src/test/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImplTest.java @@ -0,0 +1,111 @@ +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.never; +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)); + + OnboardingResponseDTO.StatusResponse response = + onboardingCommandService.completeOnboarding(MEMBER_ID, createRequest()); + + assertThat(response.onboardingCompleted()).isTrue(); + verify(routineGroupRepository, never()) + .existsByIdAndMember_Id(ROUTINE_GROUP_ID, MEMBER_ID); + } + + @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(); + } +} From 8abb0567ec2f8c92a8a320c2481c8338fd864f2d Mon Sep 17 00:00:00 2001 From: issuejong Date: Mon, 17 Aug 2026 21:29:58 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=8B=9C=20=EB=A3=A8=ED=8B=B4=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=EC=86=8C=EC=9C=A0=EA=B6=8C=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/OnboardingCommandServiceImpl.java | 8 ++++---- .../OnboardingCommandServiceImplTest.java | 19 +++++++++++++++++-- 2 files changed, 21 insertions(+), 6 deletions(-) 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 index 19e527b..91cdebd 100644 --- 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 @@ -27,16 +27,16 @@ public OnboardingResponseDTO.StatusResponse completeOnboarding( Member member = memberRepository.findByIdForUpdate(memberId) .orElseThrow(() -> new BusinessException(ErrorStatus.MEMBER_NOT_FOUND)); - if (Boolean.TRUE.equals(member.getOnboardingCompleted())) { - return new OnboardingResponseDTO.StatusResponse(true); - } - 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/test/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImplTest.java b/src/test/java/com/moru/server/domain/onboarding/service/command/OnboardingCommandServiceImplTest.java index 56d7f64..3c363c4 100644 --- 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 @@ -2,7 +2,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -58,15 +57,31 @@ void completesOnboardingWhenMemberOwnsRoutineGroup() { 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, never()) + 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);