diff --git a/src/docs/asciidoc/users.adoc b/src/docs/asciidoc/users.adoc index 6580e170..5742b2fd 100644 --- a/src/docs/asciidoc/users.adoc +++ b/src/docs/asciidoc/users.adoc @@ -11,5 +11,10 @@ operation::user-controller-test/find-user-info[snippets='http-request,curl-reque operation::user-controller-test/find-me[snippets='http-request,curl-request,request-headers,http-response,response-fields'] +[[온보딩-단계-완료]] +=== `PATCH` 온보딩 단계 완료 + +operation::user-controller-test/complete-step[snippets='http-request,curl-request,request-headers,request-fields,http-response,response-fields'] + [[본인-정보-조회]] === `GET` 내 정보 수정 (미구현) diff --git a/src/main/java/com/chooz/common/exception/ErrorCode.java b/src/main/java/com/chooz/common/exception/ErrorCode.java index 523e9138..6154c662 100644 --- a/src/main/java/com/chooz/common/exception/ErrorCode.java +++ b/src/main/java/com/chooz/common/exception/ErrorCode.java @@ -46,6 +46,7 @@ public enum ErrorCode { DUPLICATE_POLL_CHOICE("복수 투표의 경우 중복된 선택지가 있으면 안 됨"), NOT_POST_POLL_CHOICE_ID("게시글의 투표 선택지가 아님"), ONLY_SELF_CAN_CLOSE("작성자 마감의 경우, SELF 마감 방식만이 마감 가능합니다."), + INVALID_ONBOARDING_STEP("유효하지 않은 온보딩 단계."), //401 EXPIRED_TOKEN("토큰이 만료됐습니다."), diff --git a/src/main/java/com/chooz/user/application/NicknameGenerator.java b/src/main/java/com/chooz/user/application/NicknameGenerator.java index db353278..158ee26e 100644 --- a/src/main/java/com/chooz/user/application/NicknameGenerator.java +++ b/src/main/java/com/chooz/user/application/NicknameGenerator.java @@ -1,18 +1,49 @@ package com.chooz.user.application; import com.chooz.user.domain.NicknameAdjectiveRepository; +import com.chooz.user.domain.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.math.BigInteger; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; @Component @RequiredArgsConstructor public class NicknameGenerator { private final NicknameAdjectiveRepository nicknameAdjectiveRepository; + private final UserRepository userRepository; public String generate() { - return nicknameAdjectiveRepository.findRandomNicknameAdjective() + String prefix = nicknameAdjectiveRepository.findRandomNicknameAdjective() .map(adjective -> adjective.getAdjective() + " 츄") .orElse("숨겨진 츄"); + return makeNickname(prefix); + } + private String makeNickname(String prefix) { + List nickNames = userRepository.findNicknamesByPrefix(prefix); + Set usedSuffixes = getUsedSuffixes(prefix, nickNames); + return findUsableNickname(prefix, usedSuffixes); + } + private Set getUsedSuffixes(String prefix, List nickNames) { + Set usedSuffixes = new TreeSet<>(BigInteger::compareTo); + for(String nickName : nickNames) { + String suffix = nickName.substring(prefix.length()); + if(suffix.isEmpty()) { + usedSuffixes.add(BigInteger.ZERO); + }else{ + usedSuffixes.add(new BigInteger(suffix)); + } + } + return usedSuffixes; + } + private String findUsableNickname(String prefix, Set usedSuffixes) { + BigInteger suffix = BigInteger.ZERO; + while (usedSuffixes.contains(suffix)) { + suffix = suffix.add(BigInteger.ONE); + } + return suffix.signum() == 0 ? prefix : prefix + suffix; } } diff --git a/src/main/java/com/chooz/user/application/UserService.java b/src/main/java/com/chooz/user/application/UserService.java index 1181a6de..e87845d8 100644 --- a/src/main/java/com/chooz/user/application/UserService.java +++ b/src/main/java/com/chooz/user/application/UserService.java @@ -2,14 +2,17 @@ import com.chooz.common.exception.BadRequestException; import com.chooz.common.exception.ErrorCode; -import com.chooz.user.domain.User; import com.chooz.user.domain.UserRepository; +import com.chooz.user.domain.OnboardingStepRepository; +import com.chooz.user.domain.User; +import com.chooz.user.presentation.dto.OnboardingRequest; import com.chooz.user.presentation.dto.UserInfoResponse; import com.chooz.user.presentation.dto.UserMyInfoResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Map; import java.util.Optional; @Service @@ -19,32 +22,49 @@ public class UserService { private final UserRepository userRepository; private final NicknameGenerator nicknameGenerator; + private final OnboardingStepRepository onboardingStepRepository; @Transactional public Long createUser(String nickname, String profileImageUrl) { - User user = userRepository.save(User.create(getNickname(nickname), getProfileImage(profileImageUrl))); + User user = userRepository.save(User.create(getOrGenerateNickname(nickname), profileImageUrl)); return user.getId(); } - private String getNickname(String nickname) { + private String getOrGenerateNickname(String nickname) { return Optional.ofNullable(nickname) - .orElseGet(() -> nicknameGenerator.generate()); - } - - private String getProfileImage(String profileImageUrl) { - return Optional.ofNullable(profileImageUrl) - .orElse(User.DEFAULT_PROFILE_URL); + .orElseGet(nicknameGenerator::generate); } + @Transactional(readOnly = true) public UserInfoResponse findById(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); return UserInfoResponse.of(user); } + @Transactional(readOnly = true) public UserMyInfoResponse findByMe(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); return UserMyInfoResponse.of(user); } + + @Transactional + public UserInfoResponse completeStep(Long userId, OnboardingRequest onboardingRequest) { + if (onboardingRequest.onboardingStep().values().stream().noneMatch(Boolean.TRUE::equals)) { + throw new BadRequestException(ErrorCode.INVALID_ONBOARDING_STEP); + } + User user = userRepository.findById(userId) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + UpdateOnboardingStep(user, onboardingRequest); + return UserInfoResponse.of(userRepository.findById(userId) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND))); + } + + private void UpdateOnboardingStep(User user, OnboardingRequest onboardingRequest) { + onboardingRequest.onboardingStep().entrySet().stream() + .filter(step -> Boolean.TRUE.equals(step.getValue())) + .map(Map.Entry::getKey) + .forEach(stepType -> stepType.apply(user.getOnboardingStep())); + } } diff --git a/src/main/java/com/chooz/user/domain/OnboardingStep.java b/src/main/java/com/chooz/user/domain/OnboardingStep.java new file mode 100644 index 00000000..e51e61c7 --- /dev/null +++ b/src/main/java/com/chooz/user/domain/OnboardingStep.java @@ -0,0 +1,46 @@ +package com.chooz.user.domain; + +import com.chooz.common.domain.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Getter +@Entity +@Table(name = "onboarding_step") +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +public class OnboardingStep extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private boolean welcomeGuide; + + private boolean firstVote; + + @Builder + public OnboardingStep(Long id, boolean welcomeGuide, boolean firstVote) { + this.id = id; + this.welcomeGuide = welcomeGuide; + this.firstVote = firstVote; + } + + public void completeWelcomeGuide() { + this.welcomeGuide = true; + } + + public void completeFirstVote() { + this.firstVote = true; + } + + public boolean isCompletedAll() { + return welcomeGuide && firstVote; + } +} diff --git a/src/main/java/com/chooz/user/domain/OnboardingStepRepository.java b/src/main/java/com/chooz/user/domain/OnboardingStepRepository.java new file mode 100644 index 00000000..57fe577d --- /dev/null +++ b/src/main/java/com/chooz/user/domain/OnboardingStepRepository.java @@ -0,0 +1,7 @@ +package com.chooz.user.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface OnboardingStepRepository extends JpaRepository {} diff --git a/src/main/java/com/chooz/user/domain/OnboardingStepType.java b/src/main/java/com/chooz/user/domain/OnboardingStepType.java new file mode 100644 index 00000000..c96357f4 --- /dev/null +++ b/src/main/java/com/chooz/user/domain/OnboardingStepType.java @@ -0,0 +1,27 @@ +package com.chooz.user.domain; + + +import java.util.function.Consumer; +import java.util.function.Predicate; + +public enum OnboardingStepType { + + WELCOME_GUIDE(OnboardingStep::completeWelcomeGuide, OnboardingStep::isWelcomeGuide), + FIRST_VOTE(OnboardingStep::completeFirstVote, OnboardingStep::isFirstVote); + + private final Consumer action; + private final Predicate checker; + + OnboardingStepType(Consumer action, Predicate checker) { + this.action = action; + this.checker = checker; + } + + public void apply(OnboardingStep step) { + this.action.accept(step); + } + + public boolean check(OnboardingStep step) { + return this.checker.test(step); + } +} diff --git a/src/main/java/com/chooz/user/domain/User.java b/src/main/java/com/chooz/user/domain/User.java index 35e2f2c4..b1d7cbb7 100644 --- a/src/main/java/com/chooz/user/domain/User.java +++ b/src/main/java/com/chooz/user/domain/User.java @@ -1,23 +1,28 @@ package com.chooz.user.domain; import com.chooz.common.domain.BaseEntity; +import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; -import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.Optional; + @Getter @Entity @Table(name = "users") @NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) public class User extends BaseEntity { - public static final String DEFAULT_PROFILE_URL = "https://image.chooz.site/default_profile.png"; + private static final String DEFAULT_PROFILE_URL = "https://cdn.chooz.site/default_profile.png"; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -27,19 +32,37 @@ public class User extends BaseEntity { private String profileUrl; - @Enumerated(jakarta.persistence.EnumType.STRING) - public Role role; + private boolean notification; + + @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "onboarding_step_id", unique = true) + private OnboardingStep onboardingStep; @Builder - private User(Long id, String nickname, String profileUrl, Role role) { + private User( + Long id, + String nickname, + String profileUrl, + boolean notification, + OnboardingStep onboardingStep + ) { this.id = id; this.nickname = nickname; this.profileUrl = profileUrl; - this.role = role; + this.notification = notification; + this.onboardingStep = onboardingStep; } public static User create(String nickname, String profileUrl) { - return new User(null, nickname, profileUrl, Role.USER); + return new User(null, nickname, getOrDefaultProfileImage(profileUrl), false, new OnboardingStep()); } + private static String getOrDefaultProfileImage(String profileImageUrl) { + return Optional.ofNullable(profileImageUrl) + .orElse(User.DEFAULT_PROFILE_URL); + } + + public boolean hasCompletedOnboarding() { + return onboardingStep != null && onboardingStep.isCompletedAll(); + } } diff --git a/src/main/java/com/chooz/user/domain/UserRepository.java b/src/main/java/com/chooz/user/domain/UserRepository.java index af296786..c6758943 100644 --- a/src/main/java/com/chooz/user/domain/UserRepository.java +++ b/src/main/java/com/chooz/user/domain/UserRepository.java @@ -1,8 +1,20 @@ package com.chooz.user.domain; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface UserRepository extends JpaRepository { + @Query(""" + SELECT u.nickname + FROM User u + WHERE u.nickname + LIKE CONCAT(:prefix, '%') + """) + List findNicknamesByPrefix(@Param("prefix") String prefix); + } diff --git a/src/main/java/com/chooz/user/presentation/UserController.java b/src/main/java/com/chooz/user/presentation/UserController.java index df2731cd..dbe458a7 100644 --- a/src/main/java/com/chooz/user/presentation/UserController.java +++ b/src/main/java/com/chooz/user/presentation/UserController.java @@ -2,14 +2,17 @@ import com.chooz.auth.domain.UserInfo; import com.chooz.user.application.UserService; +import com.chooz.user.presentation.dto.OnboardingRequest; import com.chooz.user.presentation.dto.UserInfoResponse; import com.chooz.user.presentation.dto.UserMyInfoResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -32,4 +35,11 @@ public ResponseEntity findMyInfo( return ResponseEntity.ok(userService.findByMe(userInfo.userId())); } + @PatchMapping("/onboarding") + public ResponseEntity findUserInfo( + @Valid @RequestBody OnboardingRequest request, + @AuthenticationPrincipal UserInfo userInfo + ) { + return ResponseEntity.ok(userService.completeStep(userInfo.userId(), request)); + } } diff --git a/src/main/java/com/chooz/user/presentation/dto/OnboardingRequest.java b/src/main/java/com/chooz/user/presentation/dto/OnboardingRequest.java new file mode 100644 index 00000000..e39c3ae5 --- /dev/null +++ b/src/main/java/com/chooz/user/presentation/dto/OnboardingRequest.java @@ -0,0 +1,13 @@ +package com.chooz.user.presentation.dto; + +import com.chooz.user.domain.OnboardingStepType; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.Map; + +public record OnboardingRequest( + @NotNull + @Size(min = 1) + Map onboardingStep +) {} diff --git a/src/main/java/com/chooz/user/presentation/dto/UserInfoResponse.java b/src/main/java/com/chooz/user/presentation/dto/UserInfoResponse.java index f7cefc44..2f7d4315 100644 --- a/src/main/java/com/chooz/user/presentation/dto/UserInfoResponse.java +++ b/src/main/java/com/chooz/user/presentation/dto/UserInfoResponse.java @@ -1,13 +1,36 @@ package com.chooz.user.presentation.dto; +import com.chooz.user.domain.OnboardingStep; +import com.chooz.user.domain.OnboardingStepType; import com.chooz.user.domain.User; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + public record UserInfoResponse( Long id, String nickname, - String profileUrl + String profileImageUrl, + boolean notification, + Map onboardingStep + ) { public static UserInfoResponse of(User user) { - return new UserInfoResponse(user.getId(), user.getNickname(), user.getProfileUrl()); + return new UserInfoResponse( + user.getId(), + user.getNickname(), + user.getProfileUrl(), + user.isNotification(), + convertStepStatus(user.getOnboardingStep()) + ); + } + + private static Map convertStepStatus(OnboardingStep step) { + return Arrays.stream(OnboardingStepType.values()) + .collect(Collectors.toMap( + Enum::name, + stepType -> step != null && stepType.check(step) + )); } } diff --git a/src/main/java/com/chooz/user/presentation/dto/UserMyInfoResponse.java b/src/main/java/com/chooz/user/presentation/dto/UserMyInfoResponse.java index 8fb8d52e..d3981641 100644 --- a/src/main/java/com/chooz/user/presentation/dto/UserMyInfoResponse.java +++ b/src/main/java/com/chooz/user/presentation/dto/UserMyInfoResponse.java @@ -1,15 +1,36 @@ package com.chooz.user.presentation.dto; -import com.chooz.user.domain.Role; +import com.chooz.user.domain.OnboardingStep; +import com.chooz.user.domain.OnboardingStepType; import com.chooz.user.domain.User; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + public record UserMyInfoResponse( Long id, String nickname, String profileImageUrl, - Role role + boolean notification, + Map onboardingStep + ) { public static UserMyInfoResponse of(User user) { - return new UserMyInfoResponse(user.getId(), user.getNickname(), user.getProfileUrl(), user.getRole()); + return new UserMyInfoResponse( + user.getId(), + user.getNickname(), + user.getProfileUrl(), + user.isNotification(), + convertStepStatus(user.getOnboardingStep()) + ); + } + + private static Map convertStepStatus(OnboardingStep step) { + return Arrays.stream(OnboardingStepType.values()) + .collect(Collectors.toMap( + Enum::name, + stepType -> step != null && stepType.check(step) + )); } } diff --git a/src/test/java/com/chooz/support/fixture/OnboardingStepFixture.java b/src/test/java/com/chooz/support/fixture/OnboardingStepFixture.java new file mode 100644 index 00000000..1023642b --- /dev/null +++ b/src/test/java/com/chooz/support/fixture/OnboardingStepFixture.java @@ -0,0 +1,17 @@ +package com.chooz.support.fixture; + +import com.chooz.user.domain.OnboardingStep; +import com.chooz.user.domain.User; + +public class OnboardingStepFixture { + + public static OnboardingStep createDefaultOnboardingStep() { + return createUserBuilder().build(); + } + + public static OnboardingStep.OnboardingStepBuilder createUserBuilder() { + return OnboardingStep.builder() + .welcomeGuide(false) + .firstVote(false); + } +} diff --git a/src/test/java/com/chooz/support/fixture/UserFixture.java b/src/test/java/com/chooz/support/fixture/UserFixture.java index a3b47c55..16de869c 100644 --- a/src/test/java/com/chooz/support/fixture/UserFixture.java +++ b/src/test/java/com/chooz/support/fixture/UserFixture.java @@ -1,18 +1,23 @@ package com.chooz.support.fixture; -import com.chooz.user.domain.Role; import com.chooz.user.domain.User; +import static com.chooz.support.fixture.OnboardingStepFixture.createDefaultOnboardingStep; + public class UserFixture { public static User createDefaultUser() { return createUserBuilder().build(); } + public static User createUserWithNickname (String nickname) { + return createUserBuilder().nickname(nickname).build(); + } public static User.UserBuilder createUserBuilder() { return User.builder() - .role(Role.USER) .nickname("nickname") - .profileUrl("http://example.com/profile.png"); + .profileUrl("https://cdn.chooz.com/default_profile.png") + .notification(false) + .onboardingStep(createDefaultOnboardingStep()); } } diff --git a/src/test/java/com/chooz/user/application/NicknameGeneratorTest.java b/src/test/java/com/chooz/user/application/NicknameGeneratorTest.java index 9006c251..dac5974e 100644 --- a/src/test/java/com/chooz/user/application/NicknameGeneratorTest.java +++ b/src/test/java/com/chooz/user/application/NicknameGeneratorTest.java @@ -2,7 +2,7 @@ import com.chooz.user.domain.NicknameAdjective; import com.chooz.user.domain.NicknameAdjectiveRepository; -import com.chooz.user.domain.Role; +import com.chooz.user.domain.UserRepository; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -12,8 +12,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.util.Optional; - -import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; @ExtendWith(MockitoExtension.class) @@ -25,6 +23,9 @@ class NicknameGeneratorTest { @Mock NicknameAdjectiveRepository nicknameAdjectiveRepository; + @Mock + UserRepository userRepository; + @Test @DisplayName("닉네임 생성 테스트") void generate() throws Exception { @@ -38,5 +39,4 @@ void generate() throws Exception { //then Assertions.assertThat(nickname).isEqualTo("호기심 많은 츄"); } - } diff --git a/src/test/java/com/chooz/user/application/UserServiceTest.java b/src/test/java/com/chooz/user/application/UserServiceTest.java index 92eee24e..3ae39673 100644 --- a/src/test/java/com/chooz/user/application/UserServiceTest.java +++ b/src/test/java/com/chooz/user/application/UserServiceTest.java @@ -1,18 +1,22 @@ package com.chooz.user.application; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; import com.chooz.support.IntegrationTest; import com.chooz.support.fixture.UserFixture; import com.chooz.user.domain.NicknameAdjective; import com.chooz.user.domain.NicknameAdjectiveRepository; +import com.chooz.user.domain.OnboardingStep; +import com.chooz.user.domain.OnboardingStepType; import com.chooz.user.domain.User; import com.chooz.user.domain.UserRepository; +import com.chooz.user.presentation.dto.OnboardingRequest; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; - -import java.util.Optional; - +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; class UserServiceTest extends IntegrationTest { @@ -23,28 +27,112 @@ class UserServiceTest extends IntegrationTest { @Autowired NicknameAdjectiveRepository nicknameAdjectiveRepository; + @Autowired + NicknameGenerator nicknameGenerator; + @Autowired UserService userService; + private void saveNickNameAdjective(String... adjectives) { + for(String adjective : adjectives){ + nicknameAdjectiveRepository.save(new NicknameAdjective(adjective)); + } + } + private User saveUser(){ + User user = UserFixture.createUserWithNickname(nicknameGenerator.generate()); + return userRepository.save(user); + } @Test + @DisplayName("유저생성 테스트") void createUser() { // given - User user = User.create(null, "https://image.com/1"); + saveNickNameAdjective("호기심 많은", "배려 깊은"); + User user = saveUser(); - for (int i = 0; i < 250; i++) { - nicknameAdjectiveRepository.save(new NicknameAdjective("호기심 많은 츄")); - nicknameAdjectiveRepository.save(new NicknameAdjective("배려 깊은 츄")); - } + // when then + assertAll( + () -> assertThat(user.getNickname()).isNotNull(), + () -> assertThat(user.getNickname()).contains("츄") + ); + } + + @Test + @DisplayName("유저생성 닉넥임 중복 테스트") + void createUser_duplicateNickname() { + // given + saveNickNameAdjective("호기심 많은"); + User user = saveUser(); + User user2 = saveUser(); + + // when then + assertAll( + () -> assertThat(user.getNickname()).isNotNull(), + () -> assertThat(user.getNickname()).contains("츄"), + () -> assertThat(user.getNickname()).isNotEqualTo(user2.getNickname()), + () -> assertThat(user.getNickname()).isEqualTo("호기심 많은 츄"), + () -> assertThat(user2.getNickname()).isEqualTo("호기심 많은 츄1") + ); + } + @Test + @DisplayName("유저생성 닉넥임 사용가능한 가장 작은 suffix 선택 테스트") + void createUser_minSuffix() { + // given + saveNickNameAdjective("호기심 많은"); + User user = saveUser(); + User user1 = saveUser(); + User user2 = saveUser(); + userRepository.delete(user1); // when - Long userId = userService.createUser(user.getNickname(), user.getProfileUrl()); - Optional returnUser = userRepository.findById(userId); + User user3 = saveUser(); // when then assertAll( - () -> assertThat(returnUser.get().getNickname()).isNotNull(), - () -> assertThat(returnUser.get().getNickname()).contains("츄") + () -> assertThat(user.getNickname()).isEqualTo("호기심 많은 츄"), + () -> assertThat(user2.getNickname()).isEqualTo("호기심 많은 츄2"), + () -> assertThat(user3.getNickname()).isEqualTo("호기심 많은 츄1") ); + } + @Test + @DisplayName("온보딩 수행 테스트") + void user_complete_onboarding_step() { + // given + Long userId = saveUser().getId(); + OnboardingRequest onboardingRequest = new OnboardingRequest( + Map.of( + OnboardingStepType.WELCOME_GUIDE, true, + OnboardingStepType.FIRST_VOTE, false + ) + ); + // when + userService.completeStep(userId, onboardingRequest); + OnboardingStep onboardingStep + = userRepository.findById(userId).get().getOnboardingStep(); + // then + assertAll( + () -> assertThat(onboardingStep.isWelcomeGuide()).isTrue(), + () -> assertThat(onboardingStep.isFirstVote()).isFalse() + ); + } + @Test + @DisplayName("온보딩 요청 예외 테스트") + void user_complete_onboarding_step_exception() { + // given + Long userId = saveUser().getId(); + OnboardingRequest onboardingRequest = new OnboardingRequest( + Map.of( + OnboardingStepType.WELCOME_GUIDE, false, + OnboardingStepType.FIRST_VOTE, false + ) + ); + + // when then + assertThatThrownBy( + () -> userService.completeStep(userId, onboardingRequest)) + .isInstanceOf(BadRequestException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_ONBOARDING_STEP + ); } } diff --git a/src/test/java/com/chooz/user/domain/UserTest.java b/src/test/java/com/chooz/user/domain/UserTest.java index 145e7ceb..c132f8cf 100644 --- a/src/test/java/com/chooz/user/domain/UserTest.java +++ b/src/test/java/com/chooz/user/domain/UserTest.java @@ -9,7 +9,7 @@ class UserTest { @Test @DisplayName("user Entity 생성") - void create() throws Exception { + void create() { //given String nickname = "nickname"; diff --git a/src/test/java/com/chooz/user/presentation/UserControllerTest.java b/src/test/java/com/chooz/user/presentation/UserControllerTest.java index 7e26eb11..6d913283 100644 --- a/src/test/java/com/chooz/user/presentation/UserControllerTest.java +++ b/src/test/java/com/chooz/user/presentation/UserControllerTest.java @@ -2,20 +2,24 @@ import com.chooz.support.RestDocsTest; import com.chooz.support.WithMockUserInfo; -import com.chooz.user.domain.Role; +import com.chooz.user.domain.OnboardingStepType; +import com.chooz.user.presentation.dto.OnboardingRequest; import com.chooz.user.presentation.dto.UserInfoResponse; import com.chooz.user.presentation.dto.UserMyInfoResponse; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.payload.JsonFieldType; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; -import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; -import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; -import static org.springframework.restdocs.payload.JsonFieldType.STRING; -import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; @@ -28,10 +32,20 @@ class UserControllerTest extends RestDocsTest { @DisplayName("유저 정보 조회") void findUserInfo() throws Exception { //given - UserInfoResponse response = new UserInfoResponse(1L, "nickname", "https://image.com/profile-image"); + Map onboardingStep = Map.of( + "WELCOME_GUIDE", false, + "FIRST_VOTE", true + ); + UserInfoResponse response = new UserInfoResponse( + 1L, + "nickname", + "https://cdn.chooz.site/default_profile.png", + false, + onboardingStep + ); given(userService.findById(1L)) .willReturn(response); - + System.out.println(objectMapper.writeValueAsString(response)); //when then mockMvc.perform(RestDocumentationRequestBuilders.get("/users/{userId}", "1")) .andExpect(status().isOk()) @@ -41,9 +55,27 @@ void findUserInfo() throws Exception { parameterWithName("userId").description("유저 아이디") ), responseFields( - fieldWithPath("id").description("유저 아이디").type(NUMBER), - fieldWithPath("nickname").description("닉네임").type(STRING), - fieldWithPath("profileUrl").description("프로필 이미지 URL").type(STRING) + fieldWithPath("id") + .description("유저 아이디") + .type(JsonFieldType.NUMBER), + fieldWithPath("nickname") + .description("닉네임") + .type(JsonFieldType.STRING), + fieldWithPath("profileImageUrl") + .description("프로필 이미지 URL") + .type(JsonFieldType.STRING), + fieldWithPath("notification") + .description("알림 설정 여부") + .type(JsonFieldType.BOOLEAN), + fieldWithPath("onboardingStep") + .description("유저 온보딩 단계") + .type(JsonFieldType.OBJECT), + fieldWithPath("onboardingStep.WELCOME_GUIDE") + .description("웰컴 가이드 완료 여부") + .type(JsonFieldType.BOOLEAN), + fieldWithPath("onboardingStep.FIRST_VOTE") + .description("첫 투표 완료 여부") + .type(JsonFieldType.BOOLEAN) ) )); } @@ -53,22 +85,118 @@ void findUserInfo() throws Exception { @DisplayName("본인 정보 조회") void findMe() throws Exception { //given - UserMyInfoResponse response = new UserMyInfoResponse(1L, "nickname", "https://image.com/profile-image", Role.USER); + Map onboardingStep = Map.of( + "WELCOME_GUIDE", false, + "FIRST_VOTE", true + ); + UserMyInfoResponse response = new UserMyInfoResponse( + 1L, + "nickname", + "https://cdn.chooz.site/default_profile.png", + false, + onboardingStep + ); given(userService.findByMe(1L)) .willReturn(response); //when then - mockMvc.perform(RestDocumentationRequestBuilders.get("/users/me") - .header(HttpHeaders.AUTHORIZATION, "Bearer access-token")) + mockMvc.perform(RestDocumentationRequestBuilders.get("/users/me")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + responseFields( + fieldWithPath("id") + .description("유저 아이디") + .type(JsonFieldType.NUMBER), + fieldWithPath("nickname") + .description("닉네임") + .type(JsonFieldType.STRING), + fieldWithPath("profileImageUrl") + .description("프로필 이미지 URL") + .type(JsonFieldType.STRING), + fieldWithPath("notification") + .description("알림 설정 여부") + .type(JsonFieldType.BOOLEAN), + fieldWithPath("onboardingStep") + .description("유저 온보딩 단계") + .type(JsonFieldType.OBJECT), + fieldWithPath("onboardingStep.WELCOME_GUIDE") + .description("웰컴 가이드 완료 여부") + .type(JsonFieldType.BOOLEAN), + fieldWithPath("onboardingStep.FIRST_VOTE") + .description("첫 투표 완료 여부") + .type(JsonFieldType.BOOLEAN) + ) + )); + } + @Test + @WithMockUserInfo + @DisplayName("온보딩 수행") + void completeStep () throws Exception { + // given + Map steps = Map.of( + OnboardingStepType.WELCOME_GUIDE, false, + OnboardingStepType.FIRST_VOTE, true + + ); + OnboardingRequest request = new OnboardingRequest(steps); + + Map responseSteps = Map.of( + "WELCOME_GUIDE", false, + "FIRST_VOTE", true + + ); + UserInfoResponse response = new UserInfoResponse( + 1L, + "nickname", + "https://cdn.chooz.site/default_profile.png", + false, + responseSteps + ); + + given(userService.completeStep(eq(1L), any(OnboardingRequest.class))) + .willReturn(response); + + // when & then + mockMvc.perform(RestDocumentationRequestBuilders.patch("/users/onboarding") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) .andExpect(content().json(objectMapper.writeValueAsString(response))) .andDo(restDocs.document( - requestHeaders(authorizationHeader()), + requestFields( + fieldWithPath("onboardingStep") + .description("온보딩 단계") + .type(JsonFieldType.OBJECT), + fieldWithPath("onboardingStep.WELCOME_GUIDE") + .description("웰컴 가이드 완료 여부") + .type(JsonFieldType.BOOLEAN), + fieldWithPath("onboardingStep.FIRST_VOTE") + .description("첫 투표 완료 여부") + .type(JsonFieldType.BOOLEAN) + ), responseFields( - fieldWithPath("id").description("유저 아이디").type(NUMBER), - fieldWithPath("nickname").description("닉네임").type(STRING), - fieldWithPath("profileImageUrl").description("프로필 이미지 URL").type(STRING), - fieldWithPath("role").description("유저 권한").type(STRING) + fieldWithPath("id") + .description("유저 아이디") + .type(JsonFieldType.NUMBER), + fieldWithPath("nickname") + .description("닉네임") + .type(JsonFieldType.STRING), + fieldWithPath("profileImageUrl") + .description("프로필 이미지 URL") + .type(JsonFieldType.STRING), + fieldWithPath("notification") + .description("알림 설정 여부") + .type(JsonFieldType.BOOLEAN), + fieldWithPath("onboardingStep") + .description("유저 온보딩 단계") + .type(JsonFieldType.OBJECT), + fieldWithPath("onboardingStep.WELCOME_GUIDE") + .description("웰컴 가이드 완료 여부") + .type(JsonFieldType.BOOLEAN), + fieldWithPath("onboardingStep.FIRST_VOTE") + .description("첫 투표 완료 여부") + .type(JsonFieldType.BOOLEAN) ) )); }