diff --git a/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java b/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java index 21d68f35..d5720169 100644 --- a/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java +++ b/src/main/java/com/mr/domain/learning/repository/UserLearningProgressRepository.java @@ -14,4 +14,9 @@ public interface UserLearningProgressRepository extends JpaRepository= 90") long countCompletedStepsByUserIdAndLearningId(@Param("userId") Long userId, @Param("learningId") Long learningId); + + // 유저가 완료(점수 90점 이상)한 학습(learning) distinct 개수 조회 (프로필 통계의 completedLearningCount) + @Query("SELECT COUNT(DISTINCT ulp.learning.id) FROM UserLearningProgress ulp " + + "WHERE ulp.user.userId = :userId AND ulp.score >= 90") + long countDistinctCompletedLearningsByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/com/mr/domain/statistics/repository/UserStatisticsRepository.java b/src/main/java/com/mr/domain/statistics/repository/UserStatisticsRepository.java new file mode 100644 index 00000000..4b03e947 --- /dev/null +++ b/src/main/java/com/mr/domain/statistics/repository/UserStatisticsRepository.java @@ -0,0 +1,10 @@ +package com.mr.domain.statistics.repository; + +import com.mr.domain.statistics.entity.UserStatistics; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserStatisticsRepository extends JpaRepository { + + Optional findByUserId(Long userId); +} diff --git a/src/main/java/com/mr/domain/subscriptions/entity/enums/SubscriptionTier.java b/src/main/java/com/mr/domain/subscriptions/entity/enums/SubscriptionTier.java new file mode 100644 index 00000000..b7f365a6 --- /dev/null +++ b/src/main/java/com/mr/domain/subscriptions/entity/enums/SubscriptionTier.java @@ -0,0 +1,7 @@ +package com.mr.domain.subscriptions.entity.enums; + +public enum SubscriptionTier { + FREE, + STANDARD, + PRO +} diff --git a/src/main/java/com/mr/domain/subscriptions/exception/SubscriptionErrorStatus.java b/src/main/java/com/mr/domain/subscriptions/exception/SubscriptionErrorStatus.java index 4b9fb959..567de8af 100644 --- a/src/main/java/com/mr/domain/subscriptions/exception/SubscriptionErrorStatus.java +++ b/src/main/java/com/mr/domain/subscriptions/exception/SubscriptionErrorStatus.java @@ -12,7 +12,8 @@ public enum SubscriptionErrorStatus implements BaseCode { INVALID_SUBSCRIPTION_DATE(HttpStatus.BAD_REQUEST, "SUBSCRIPTION_400_01", "구독 종료일은 시작일보다 이전일 수 없습니다."), INVALID_SUBSCRIPTION_EXTENSION_DATE(HttpStatus.BAD_REQUEST, "SUBSCRIPTION_400_02", "연장할 종료일은 기존 종료일보다 이후여야 합니다."), USER_REQUIRED(HttpStatus.BAD_REQUEST, "SUBSCRIPTION_400_03", "user는 필수입니다."), - TIER_REQUIRED(HttpStatus.BAD_REQUEST, "SUBSCRIPTION_400_04", "구독 등급은 필수입니다."); + TIER_REQUIRED(HttpStatus.BAD_REQUEST, "SUBSCRIPTION_400_04", "구독 등급은 필수입니다."), + ACTIVE_SUBSCRIPTION_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "SUBSCRIPTION_500_01", "유효한 구독 정보가 존재하지 않습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/mr/domain/subscriptions/repository/SubscriptionRepository.java b/src/main/java/com/mr/domain/subscriptions/repository/SubscriptionRepository.java new file mode 100644 index 00000000..62112388 --- /dev/null +++ b/src/main/java/com/mr/domain/subscriptions/repository/SubscriptionRepository.java @@ -0,0 +1,13 @@ +package com.mr.domain.subscriptions.repository; + +import com.mr.domain.subscriptions.entity.Subscription; +import com.mr.domain.user.entity.User; +import java.time.LocalDateTime; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SubscriptionRepository extends JpaRepository { + + Optional findFirstByUserAndStartDateLessThanEqualAndEndDateGreaterThanEqualOrderByStartDateDesc( + User user, LocalDateTime startDateInclusive, LocalDateTime endDateInclusive); +} diff --git a/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java b/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java new file mode 100644 index 00000000..ce4d85e8 --- /dev/null +++ b/src/main/java/com/mr/domain/user/config/InstrumentSeeder.java @@ -0,0 +1,50 @@ +package com.mr.domain.user.config; + +import com.mr.domain.user.entity.Instrument; +import com.mr.domain.user.repository.InstrumentRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.exception.ConstraintViolationException; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class InstrumentSeeder implements ApplicationRunner { + + private static final String PIANO_CODE = "PIANO"; + private static final String PIANO_NAME = "피아노"; + private static final String POSTGRES_UNIQUE_VIOLATION_SQL_STATE = "23505"; + + private final InstrumentRepository instrumentRepository; + + // NOTE: run() 자체엔 @Transactional을 걸지 않음 — PostgreSQL은 트랜잭션 내 한 문장이라도 + // 제약 위반으로 실패하면 그 트랜잭션 전체가 abort 상태가 되어, 이후 catch로 예외를 소비해도 + // 커밋 시점에 다시 실패함. saveAndFlush() 각각이 Spring Data JPA의 자체 트랜잭션으로 독립 처리되도록 + // run() 레벨 트랜잭션을 없애서, 중복 저장 실패가 그 saveAndFlush() 호출 안에서 깔끔하게 끝나게 함. + @Override + public void run(ApplicationArguments args) { + if (instrumentRepository.findByCode(PIANO_CODE).isPresent()) { + return; + } + + try { + instrumentRepository.saveAndFlush(Instrument.create(PIANO_CODE, PIANO_NAME)); + } catch (DataIntegrityViolationException e) { + if (!isDuplicateKeyViolation(e)) { + throw e; + } + // 여러 인스턴스가 동시에 기동해 경쟁 상태로 중복 저장을 시도한 경우 + // code unique 제약으로 한쪽만 성공하므로, 나머지는 무시하고 넘어감 + log.info("PIANO 악기 시드 데이터가 다른 인스턴스에 의해 이미 생성됨"); + } + } + + private boolean isDuplicateKeyViolation(DataIntegrityViolationException e) { + return e.getCause() instanceof ConstraintViolationException constraintViolationException + && POSTGRES_UNIQUE_VIOLATION_SQL_STATE.equals(constraintViolationException.getSQLState()); + } +} diff --git a/src/main/java/com/mr/domain/user/controller/UserProfileController.java b/src/main/java/com/mr/domain/user/controller/UserProfileController.java new file mode 100644 index 00000000..baa33be8 --- /dev/null +++ b/src/main/java/com/mr/domain/user/controller/UserProfileController.java @@ -0,0 +1,61 @@ +package com.mr.domain.user.controller; + +import com.mr.domain.user.dto.req.UserProfileRequestDTO; +import com.mr.domain.user.dto.res.UserProfileResponseDTO; +import com.mr.domain.user.service.UserProfileService; +import com.mr.global.apipayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/users/me/profile") +@Tag(name = "사용자 프로필", description = "프로필 조회/최초 등록(온보딩)/수정 API") +public class UserProfileController { + + private final UserProfileService userProfileService; + + @Operation( + summary = "프로필 조회", + description = "현재 로그인한 사용자의 닉네임, 프로필 이미지, 대표 악기, 화성학 숙련도, 구독 등급, 누적 학습 통계를 조회합니다." + ) + @GetMapping + public ApiResponse getMyProfile() { + return ApiResponse.onSuccess(userProfileService.getMyProfile()); + } + + @Operation( + summary = "프로필 최초 등록 (온보딩)", + description = "닉네임, 화성학 숙련도를 입력받아 Student/StudentInstrument(대표 악기 PIANO)/Subscription을 하나의 트랜잭션으로 생성합니다. " + + "구독 등급은 MVP 기간 동안 결제 없이 항상 PRO로 서버에서 고정됩니다. " + + "이미 온보딩을 완료한 사용자가 다시 호출하면 409(USER_409_02)가 반환됩니다." + ) + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse registerProfile( + @RequestBody UserProfileRequestDTO.OnboardingRequest request + ) { + return ApiResponse.onSuccess(userProfileService.registerProfile(request)); + } + + @Operation( + summary = "프로필 수정", + description = "닉네임과 화성학 숙련도를 부분 수정합니다. 요청에 생략하거나 null로 보낸 필드는 기존 값을 유지합니다. " + + "대표 악기, 프로필 이미지, 구독 등급은 이 API에서 변경되지 않습니다." + ) + @PatchMapping + public ApiResponse updateProfile( + @RequestBody UserProfileRequestDTO.UpdateRequest request + ) { + return ApiResponse.onSuccess(userProfileService.updateProfile(request)); + } +} diff --git a/src/main/java/com/mr/domain/user/dto/req/UserProfileRequestDTO.java b/src/main/java/com/mr/domain/user/dto/req/UserProfileRequestDTO.java new file mode 100644 index 00000000..07327203 --- /dev/null +++ b/src/main/java/com/mr/domain/user/dto/req/UserProfileRequestDTO.java @@ -0,0 +1,25 @@ +package com.mr.domain.user.dto.req; + +import com.mr.domain.user.entity.enums.TheoryLevel; +import io.swagger.v3.oas.annotations.media.Schema; + +public class UserProfileRequestDTO { + + @Schema(description = "프로필 최초 등록(온보딩) 요청") + public record OnboardingRequest( + @Schema(description = "닉네임 (한글/영문/숫자 2~10자)", example = "김뮤즈") + String nickname, + + @Schema(description = "화성학 숙련도", example = "INTERMEDIATE") + TheoryLevel skillLevel + ) {} + + @Schema(description = "프로필 수정 요청 (부분 수정 — 생략하거나 null인 필드는 기존 값 유지)") + public record UpdateRequest( + @Schema(description = "변경할 닉네임 (한글/영문/숫자 2~10자). 생략 시 기존 닉네임 유지", example = "새로운뮤즈") + String nickname, + + @Schema(description = "변경할 화성학 숙련도. 생략 시 기존 숙련도 유지", example = "ADVANCED") + TheoryLevel skillLevel + ) {} +} diff --git a/src/main/java/com/mr/domain/user/dto/res/UserProfileResponseDTO.java b/src/main/java/com/mr/domain/user/dto/res/UserProfileResponseDTO.java new file mode 100644 index 00000000..f09040b5 --- /dev/null +++ b/src/main/java/com/mr/domain/user/dto/res/UserProfileResponseDTO.java @@ -0,0 +1,81 @@ +package com.mr.domain.user.dto.res; + +import com.mr.domain.user.entity.enums.TheoryLevel; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import lombok.Builder; + +public class UserProfileResponseDTO { + + @Builder + @Schema(description = "프로필 조회 응답") + public record ProfileResponse( + @Schema(description = "닉네임", example = "김뮤즈") + String nickname, + + @Schema(description = "프로필 이미지 URL", example = "https://musereview-storage-526426842030-ap-northeast-2.s3.ap-northeast-2.amazonaws.com/profile/default-profile.png") + String profileImgUrl, + + @Schema(description = "대표 악기 (MVP는 PIANO 고정)", example = "PIANO") + String instrumentType, + + @Schema(description = "화성학 숙련도", example = "INTERMEDIATE") + TheoryLevel skillLevel, + + @Schema(description = "구독 등급", example = "PRO") + String subscriptionTier, + + StatisticsResponse statistics + ) {} + + @Builder + @Schema(description = "누적 학습 통계") + public record StatisticsResponse( + @Schema(description = "누적 연주 세션 수", example = "24") + Long practiceSessionCount, + + @Schema(description = "누적 연습 시간(분)", example = "350") + Long totalPracticeMinutes, + + @Schema(description = "완료한 학습 개수 (score 90점 이상)", example = "8") + Long completedLearningCount + ) {} + + @Builder + @Schema(description = "프로필 최초 등록(온보딩) 응답") + public record OnboardingResponse( + @Schema(description = "사용자 ID", example = "1") + Long userId, + + @Schema(description = "닉네임", example = "김뮤즈") + String nickname, + + @Schema(description = "화성학 숙련도", example = "INTERMEDIATE") + TheoryLevel skillLevel, + + @Schema(description = "대표 악기", example = "PIANO") + String instrumentType, + + @Schema(description = "구독 등급", example = "PRO") + String subscriptionTier, + + @Schema(description = "프로필 이미지 URL", example = "https://musereview-storage-526426842030-ap-northeast-2.s3.ap-northeast-2.amazonaws.com/profile/default-profile.png") + String profileImgUrl + ) {} + + @Builder + @Schema(description = "프로필 수정 응답") + public record UpdateResponse( + @Schema(description = "사용자 ID", example = "1") + Long userId, + + @Schema(description = "수정된 닉네임", example = "새로운뮤즈") + String nickname, + + @Schema(description = "수정된 화성학 숙련도", example = "ADVANCED") + TheoryLevel skillLevel, + + @Schema(description = "처리 완료 시각", example = "2026-07-24T01:30:00") + LocalDateTime updatedAt + ) {} +} diff --git a/src/main/java/com/mr/domain/user/exception/InstrumentErrorStatus.java b/src/main/java/com/mr/domain/user/exception/InstrumentErrorStatus.java index b0257eb5..4324b7ba 100644 --- a/src/main/java/com/mr/domain/user/exception/InstrumentErrorStatus.java +++ b/src/main/java/com/mr/domain/user/exception/InstrumentErrorStatus.java @@ -10,7 +10,8 @@ public enum InstrumentErrorStatus implements BaseCode { CODE_REQUIRED(HttpStatus.BAD_REQUEST, "INSTRUMENT_400_01", "code는 필수입니다."), - NAME_REQUIRED(HttpStatus.BAD_REQUEST, "INSTRUMENT_400_02", "name은 필수입니다."); + NAME_REQUIRED(HttpStatus.BAD_REQUEST, "INSTRUMENT_400_02", "name은 필수입니다."), + INSTRUMENT_NOT_SEEDED(HttpStatus.INTERNAL_SERVER_ERROR, "INSTRUMENT_500_01", "PIANO 악기 시드 데이터가 존재하지 않습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/mr/domain/user/exception/StudentErrorStatus.java b/src/main/java/com/mr/domain/user/exception/StudentErrorStatus.java index 55a879ac..4d31a34d 100644 --- a/src/main/java/com/mr/domain/user/exception/StudentErrorStatus.java +++ b/src/main/java/com/mr/domain/user/exception/StudentErrorStatus.java @@ -10,7 +10,8 @@ public enum StudentErrorStatus implements BaseCode { USER_REQUIRED(HttpStatus.BAD_REQUEST, "STUDENT_400_01", "user는 필수입니다."), - THEORY_LEVEL_REQUIRED(HttpStatus.BAD_REQUEST, "STUDENT_400_02", "theoryLevel은 필수입니다."); + THEORY_LEVEL_REQUIRED(HttpStatus.BAD_REQUEST, "STUDENT_400_02", "theoryLevel은 필수입니다."), + STUDENT_NOT_FOUND(HttpStatus.NOT_FOUND, "STUDENT_404_01", "온보딩 정보가 없는 사용자입니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/mr/domain/user/exception/StudentInstrumentErrorStatus.java b/src/main/java/com/mr/domain/user/exception/StudentInstrumentErrorStatus.java index 4814213c..070ccd70 100644 --- a/src/main/java/com/mr/domain/user/exception/StudentInstrumentErrorStatus.java +++ b/src/main/java/com/mr/domain/user/exception/StudentInstrumentErrorStatus.java @@ -10,7 +10,8 @@ public enum StudentInstrumentErrorStatus implements BaseCode { STUDENT_REQUIRED(HttpStatus.BAD_REQUEST, "STUDENT_INSTRUMENT_400_01", "student는 필수입니다."), - INSTRUMENT_REQUIRED(HttpStatus.BAD_REQUEST, "STUDENT_INSTRUMENT_400_02", "instrument는 필수입니다."); + INSTRUMENT_REQUIRED(HttpStatus.BAD_REQUEST, "STUDENT_INSTRUMENT_400_02", "instrument는 필수입니다."), + PRIMARY_INSTRUMENT_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "STUDENT_INSTRUMENT_500_01", "대표 악기 정보가 존재하지 않습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java b/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java index 3cca4c33..6cb770ad 100644 --- a/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java +++ b/src/main/java/com/mr/domain/user/exception/UserErrorStatus.java @@ -12,10 +12,11 @@ public enum UserErrorStatus implements BaseCode { NICKNAME_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_01", "닉네임은 필수입니다."), NICKNAME_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "USER_400_02", "닉네임은 한글, 영어, 숫자 2~10자로 입력해야 합니다."), PROFILE_IMAGE_REQUIRED(HttpStatus.BAD_REQUEST, "USER_400_03", "프로필 이미지 URL은 필수입니다."), - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_01", "존재하지 않는 사용자입니다."); - + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_01", "존재하지 않는 사용자입니다."), + NICKNAME_DUPLICATED(HttpStatus.CONFLICT, "USER_409_01", "이미 사용 중인 닉네임입니다."), + ONBOARDING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "USER_409_02", "이미 온보딩을 완료한 사용자입니다."); private final HttpStatus status; private final String code; private final String message; -} +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/user/repository/InstrumentRepository.java b/src/main/java/com/mr/domain/user/repository/InstrumentRepository.java new file mode 100644 index 00000000..5c111658 --- /dev/null +++ b/src/main/java/com/mr/domain/user/repository/InstrumentRepository.java @@ -0,0 +1,10 @@ +package com.mr.domain.user.repository; + +import com.mr.domain.user.entity.Instrument; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface InstrumentRepository extends JpaRepository { + + Optional findByCode(String code); +} diff --git a/src/main/java/com/mr/domain/user/repository/StudentInstrumentRepository.java b/src/main/java/com/mr/domain/user/repository/StudentInstrumentRepository.java new file mode 100644 index 00000000..3efd3dfa --- /dev/null +++ b/src/main/java/com/mr/domain/user/repository/StudentInstrumentRepository.java @@ -0,0 +1,11 @@ +package com.mr.domain.user.repository; + +import com.mr.domain.user.entity.Student; +import com.mr.domain.user.entity.StudentInstrument; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StudentInstrumentRepository extends JpaRepository { + + Optional findByStudentAndPrimaryTrue(Student student); +} diff --git a/src/main/java/com/mr/domain/user/repository/StudentRepository.java b/src/main/java/com/mr/domain/user/repository/StudentRepository.java new file mode 100644 index 00000000..e6d662b7 --- /dev/null +++ b/src/main/java/com/mr/domain/user/repository/StudentRepository.java @@ -0,0 +1,11 @@ +package com.mr.domain.user.repository; + +import com.mr.domain.user.entity.Student; +import com.mr.domain.user.entity.User; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StudentRepository extends JpaRepository { + + Optional findByUser(User user); +} diff --git a/src/main/java/com/mr/domain/user/repository/UserRepository.java b/src/main/java/com/mr/domain/user/repository/UserRepository.java index 5826f092..13db89ea 100644 --- a/src/main/java/com/mr/domain/user/repository/UserRepository.java +++ b/src/main/java/com/mr/domain/user/repository/UserRepository.java @@ -4,4 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface UserRepository extends JpaRepository { -} + + boolean existsByNicknameAndUserIdNot(String nickname, Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/user/service/UserProfileService.java b/src/main/java/com/mr/domain/user/service/UserProfileService.java new file mode 100644 index 00000000..186a04ac --- /dev/null +++ b/src/main/java/com/mr/domain/user/service/UserProfileService.java @@ -0,0 +1,199 @@ +package com.mr.domain.user.service; + +import com.mr.domain.learning.repository.UserLearningProgressRepository; +import com.mr.domain.statistics.entity.UserStatistics; +import com.mr.domain.statistics.repository.UserStatisticsRepository; +import com.mr.domain.subscriptions.entity.Subscription; +import com.mr.domain.subscriptions.entity.enums.SubscriptionTier; +import com.mr.domain.subscriptions.exception.SubscriptionErrorStatus; +import com.mr.domain.subscriptions.repository.SubscriptionRepository; +import com.mr.domain.user.dto.req.UserProfileRequestDTO; +import com.mr.domain.user.dto.res.UserProfileResponseDTO; +import com.mr.domain.user.entity.Instrument; +import com.mr.domain.user.entity.Student; +import com.mr.domain.user.entity.StudentInstrument; +import com.mr.domain.user.entity.User; +import com.mr.domain.user.exception.InstrumentErrorStatus; +import com.mr.domain.user.exception.StudentErrorStatus; +import com.mr.domain.user.exception.StudentInstrumentErrorStatus; +import com.mr.domain.user.exception.UserErrorStatus; +import com.mr.domain.user.repository.InstrumentRepository; +import com.mr.domain.user.repository.StudentInstrumentRepository; +import com.mr.domain.user.repository.StudentRepository; +import com.mr.domain.user.repository.UserRepository; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.security.SecurityUtil; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.hibernate.exception.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserProfileService { + + private static final String PIANO_CODE = "PIANO"; + private static final int SUBSCRIPTION_PERIOD_DAYS = 30; + private static final String POSTGRES_UNIQUE_VIOLATION_SQL_STATE = "23505"; + + private final UserRepository userRepository; + private final StudentRepository studentRepository; + private final StudentInstrumentRepository studentInstrumentRepository; + private final InstrumentRepository instrumentRepository; + private final SubscriptionRepository subscriptionRepository; + private final UserStatisticsRepository userStatisticsRepository; + private final UserLearningProgressRepository userLearningProgressRepository; + + public UserProfileResponseDTO.ProfileResponse getMyProfile() { + Long userId = SecurityUtil.getCurrentUserId(); + User user = getUser(userId); + Student student = getStudent(user); + + String instrumentType = studentInstrumentRepository.findByStudentAndPrimaryTrue(student) + .map(StudentInstrument::getInstrument) + .map(Instrument::getCode) + .orElseThrow(() -> new GeneralException(StudentInstrumentErrorStatus.PRIMARY_INSTRUMENT_NOT_FOUND)); + + LocalDateTime now = LocalDateTime.now(); + String subscriptionTier = subscriptionRepository + .findFirstByUserAndStartDateLessThanEqualAndEndDateGreaterThanEqualOrderByStartDateDesc(user, now, now) + .map(Subscription::getTier) + .orElseThrow(() -> new GeneralException(SubscriptionErrorStatus.ACTIVE_SUBSCRIPTION_NOT_FOUND)); + + return UserProfileResponseDTO.ProfileResponse.builder() + .nickname(user.getNickname()) + .profileImgUrl(user.getProfileImgUrl()) + .instrumentType(instrumentType) + .skillLevel(student.getTheoryLevel()) + .subscriptionTier(subscriptionTier) + .statistics(buildStatistics(userId)) + .build(); + } + + @Transactional + public UserProfileResponseDTO.OnboardingResponse registerProfile(UserProfileRequestDTO.OnboardingRequest request) { + Long userId = SecurityUtil.getCurrentUserId(); + User user = getUser(userId); + + if (user.isOnboardingCompleted()) { + throw new GeneralException(UserErrorStatus.ONBOARDING_ALREADY_COMPLETED); + } + + String trimmedNickname = trimNickname(request.nickname()); + ensureNicknameNotTaken(user.getUserId(), trimmedNickname); + user.updateNickname(trimmedNickname); + + try { + userRepository.saveAndFlush(user); + } catch (DataIntegrityViolationException e) { + if (isDuplicateKeyViolation(e)) { + // 동시에 들어온 다른 요청이 먼저 같은 닉네임을 선점한 경우(User.nickname unique 제약 위반) + throw new GeneralException(UserErrorStatus.NICKNAME_DUPLICATED); + } + throw e; + } + + Student student; + try { + student = studentRepository.saveAndFlush(Student.create(user, request.skillLevel())); + } catch (DataIntegrityViolationException e) { + if (isDuplicateKeyViolation(e)) { + // 동시에 들어온 다른 요청이 먼저 온보딩을 완료한 경우(Student.user_id unique 제약 위반) + throw new GeneralException(UserErrorStatus.ONBOARDING_ALREADY_COMPLETED); + } + throw e; + } + + Instrument piano = instrumentRepository.findByCode(PIANO_CODE) + .orElseThrow(() -> new GeneralException(InstrumentErrorStatus.INSTRUMENT_NOT_SEEDED)); + studentInstrumentRepository.save(StudentInstrument.createPrimary(student, piano)); + + LocalDateTime now = LocalDateTime.now(); + Subscription subscription = subscriptionRepository.save( + Subscription.create(user, SubscriptionTier.PRO.name(), now, now.plusDays(SUBSCRIPTION_PERIOD_DAYS))); + + return UserProfileResponseDTO.OnboardingResponse.builder() + .userId(user.getUserId()) + .nickname(user.getNickname()) + .skillLevel(student.getTheoryLevel()) + .instrumentType(piano.getCode()) + .subscriptionTier(subscription.getTier()) + .profileImgUrl(user.getProfileImgUrl()) + .build(); + } + + @Transactional + public UserProfileResponseDTO.UpdateResponse updateProfile(UserProfileRequestDTO.UpdateRequest request) { + Long userId = SecurityUtil.getCurrentUserId(); + User user = getUser(userId); + Student student = getStudent(user); + + if (request.nickname() != null) { + String trimmedNickname = trimNickname(request.nickname()); + ensureNicknameNotTaken(user.getUserId(), trimmedNickname); + user.updateNickname(trimmedNickname); + } + if (request.skillLevel() != null) { + student.updateTheoryLevel(request.skillLevel()); + } + + try { + userRepository.saveAndFlush(user); + } catch (DataIntegrityViolationException e) { + if (isDuplicateKeyViolation(e)) { + // 동시에 들어온 다른 요청이 먼저 같은 닉네임을 선점한 경우(User.nickname unique 제약 위반) + throw new GeneralException(UserErrorStatus.NICKNAME_DUPLICATED); + } + throw e; + } + + return UserProfileResponseDTO.UpdateResponse.builder() + .userId(user.getUserId()) + .nickname(user.getNickname()) + .skillLevel(student.getTheoryLevel()) + .updatedAt(LocalDateTime.now()) + .build(); + } + + private User getUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(UserErrorStatus.USER_NOT_FOUND)); + } + + private Student getStudent(User user) { + return studentRepository.findByUser(user) + .orElseThrow(() -> new GeneralException(StudentErrorStatus.STUDENT_NOT_FOUND)); + } + + private boolean isDuplicateKeyViolation(DataIntegrityViolationException e) { + return e.getCause() instanceof ConstraintViolationException constraintViolationException + && POSTGRES_UNIQUE_VIOLATION_SQL_STATE.equals(constraintViolationException.getSQLState()); + } + + private String trimNickname(String nickname) { + return nickname == null ? null : nickname.trim(); + } + + private void ensureNicknameNotTaken(Long userId, String trimmedNickname) { + if (userRepository.existsByNicknameAndUserIdNot(trimmedNickname, userId)) { + throw new GeneralException(UserErrorStatus.NICKNAME_DUPLICATED); + } + } + + private UserProfileResponseDTO.StatisticsResponse buildStatistics(Long userId) { + UserStatistics stats = userStatisticsRepository.findByUserId(userId).orElse(null); + + return UserProfileResponseDTO.StatisticsResponse.builder() + .practiceSessionCount(stats == null ? 0L : stats.getTotalPracticeCount().longValue()) + .totalPracticeMinutes(stats == null ? 0L : stats.getTotalPracticeMinutes().longValue()) + .completedLearningCount(countCompletedLearnings(userId)) + .build(); + } + + private Long countCompletedLearnings(Long userId) { + return userLearningProgressRepository.countDistinctCompletedLearningsByUserId(userId); + } +} diff --git a/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java b/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java new file mode 100644 index 00000000..3e7203c3 --- /dev/null +++ b/src/test/java/com/mr/domain/user/controller/UserProfileControllerTest.java @@ -0,0 +1,232 @@ +package com.mr.domain.user.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mr.domain.user.dto.req.UserProfileRequestDTO; +import com.mr.domain.user.dto.res.UserProfileResponseDTO; +import com.mr.domain.user.entity.enums.TheoryLevel; +import com.mr.domain.user.exception.StudentErrorStatus; +import com.mr.domain.user.exception.UserErrorStatus; +import com.mr.domain.user.service.UserProfileService; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.apipayload.handler.GlobalExceptionHandler; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +@ExtendWith(MockitoExtension.class) +class UserProfileControllerTest { + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + + @Mock + private UserProfileService userProfileService; + + private MockMvc setUp() { + objectMapper = Jackson2ObjectMapperBuilder.json() + .findModulesViaServiceLoader(true) + .build(); + + return MockMvcBuilders.standaloneSetup(new UserProfileController(userProfileService)) + .setControllerAdvice(new GlobalExceptionHandler()) + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .build(); + } + + @Test + @DisplayName("GET /api/users/me/profile - 조회 성공") + void getMyProfile_success() throws Exception { + mockMvc = setUp(); + + UserProfileResponseDTO.ProfileResponse response = UserProfileResponseDTO.ProfileResponse.builder() + .nickname("김뮤즈") + .profileImgUrl("https://cdn.example.com/profile/default-profile.png") + .instrumentType("PIANO") + .skillLevel(TheoryLevel.INTERMEDIATE) + .subscriptionTier("PRO") + .statistics(UserProfileResponseDTO.StatisticsResponse.builder() + .practiceSessionCount(24L) + .totalPracticeMinutes(350L) + .completedLearningCount(0L) + .build()) + .build(); + given(userProfileService.getMyProfile()).willReturn(response); + + mockMvc.perform(get("/api/users/me/profile")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.data.nickname").value("김뮤즈")) + .andExpect(jsonPath("$.data.instrumentType").value("PIANO")) + .andExpect(jsonPath("$.data.subscriptionTier").value("PRO")) + .andExpect(jsonPath("$.data.statistics.practiceSessionCount").value(24)); + } + + @Test + @DisplayName("GET /api/users/me/profile - user 없으면 404") + void getMyProfile_userNotFound() throws Exception { + mockMvc = setUp(); + + given(userProfileService.getMyProfile()) + .willThrow(new GeneralException(UserErrorStatus.USER_NOT_FOUND)); + + mockMvc.perform(get("/api/users/me/profile")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("USER_404_01")); + } + + @Test + @DisplayName("GET /api/users/me/profile - 온보딩 안 된 유저면 404") + void getMyProfile_studentNotFound() throws Exception { + mockMvc = setUp(); + + given(userProfileService.getMyProfile()) + .willThrow(new GeneralException(StudentErrorStatus.STUDENT_NOT_FOUND)); + + mockMvc.perform(get("/api/users/me/profile")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("STUDENT_404_01")); + } + + @Test + @DisplayName("POST /api/users/me/profile - 최초 등록 성공") + void registerProfile_success() throws Exception { + mockMvc = setUp(); + + UserProfileRequestDTO.OnboardingRequest request = new UserProfileRequestDTO.OnboardingRequest( + "김뮤즈", TheoryLevel.INTERMEDIATE + ); + UserProfileResponseDTO.OnboardingResponse response = UserProfileResponseDTO.OnboardingResponse.builder() + .userId(1L) + .nickname("김뮤즈") + .skillLevel(TheoryLevel.INTERMEDIATE) + .instrumentType("PIANO") + .subscriptionTier("PRO") + .profileImgUrl("https://cdn.example.com/profile/default-profile.png") + .build(); + given(userProfileService.registerProfile(any())).willReturn(response); + + mockMvc.perform(post("/api/users/me/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.data.userId").value(1L)) + .andExpect(jsonPath("$.data.nickname").value("김뮤즈")) + .andExpect(jsonPath("$.data.instrumentType").value("PIANO")); + } + + @Test + @DisplayName("POST /api/users/me/profile - 이미 온보딩 완료했으면 409") + void registerProfile_alreadyCompleted() throws Exception { + mockMvc = setUp(); + + UserProfileRequestDTO.OnboardingRequest request = new UserProfileRequestDTO.OnboardingRequest( + "김뮤즈", TheoryLevel.INTERMEDIATE + ); + willThrow(new GeneralException(UserErrorStatus.ONBOARDING_ALREADY_COMPLETED)) + .given(userProfileService).registerProfile(any()); + + mockMvc.perform(post("/api/users/me/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("USER_409_02")); + } + + @Test + @DisplayName("POST /api/users/me/profile - 닉네임 중복이면 409") + void registerProfile_nicknameDuplicated() throws Exception { + mockMvc = setUp(); + + UserProfileRequestDTO.OnboardingRequest request = new UserProfileRequestDTO.OnboardingRequest( + "김뮤즈", TheoryLevel.INTERMEDIATE + ); + willThrow(new GeneralException(UserErrorStatus.NICKNAME_DUPLICATED)) + .given(userProfileService).registerProfile(any()); + + mockMvc.perform(post("/api/users/me/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("USER_409_01")); + } + + @Test + @DisplayName("PATCH /api/users/me/profile - 수정 성공") + void updateProfile_success() throws Exception { + mockMvc = setUp(); + + UserProfileRequestDTO.UpdateRequest request = new UserProfileRequestDTO.UpdateRequest( + "새로운뮤즈", TheoryLevel.ADVANCED + ); + UserProfileResponseDTO.UpdateResponse response = UserProfileResponseDTO.UpdateResponse.builder() + .userId(1L) + .nickname("새로운뮤즈") + .skillLevel(TheoryLevel.ADVANCED) + .updatedAt(LocalDateTime.of(2026, 7, 24, 1, 30)) + .build(); + given(userProfileService.updateProfile(any())).willReturn(response); + + mockMvc.perform(patch("/api/users/me/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.data.nickname").value("새로운뮤즈")) + .andExpect(jsonPath("$.data.skillLevel").value("ADVANCED")); + } + + @Test + @DisplayName("PATCH /api/users/me/profile - 온보딩 안 된 유저면 404") + void updateProfile_studentNotFound() throws Exception { + mockMvc = setUp(); + + UserProfileRequestDTO.UpdateRequest request = new UserProfileRequestDTO.UpdateRequest( + "새로운뮤즈", TheoryLevel.ADVANCED + ); + willThrow(new GeneralException(StudentErrorStatus.STUDENT_NOT_FOUND)) + .given(userProfileService).updateProfile(any()); + + mockMvc.perform(patch("/api/users/me/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("STUDENT_404_01")); + } + + @Test + @DisplayName("PATCH /api/users/me/profile - 닉네임 중복이면 409") + void updateProfile_nicknameDuplicated() throws Exception { + mockMvc = setUp(); + + UserProfileRequestDTO.UpdateRequest request = new UserProfileRequestDTO.UpdateRequest( + "새로운뮤즈", TheoryLevel.ADVANCED + ); + willThrow(new GeneralException(UserErrorStatus.NICKNAME_DUPLICATED)) + .given(userProfileService).updateProfile(any()); + + mockMvc.perform(patch("/api/users/me/profile") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("USER_409_01")); + } +} diff --git a/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java b/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java new file mode 100644 index 00000000..b6c5f4b5 --- /dev/null +++ b/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java @@ -0,0 +1,304 @@ +package com.mr.domain.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.mr.domain.learning.repository.UserLearningProgressRepository; +import com.mr.domain.statistics.repository.UserStatisticsRepository; +import com.mr.domain.subscriptions.entity.Subscription; +import com.mr.domain.subscriptions.repository.SubscriptionRepository; +import com.mr.domain.user.dto.req.UserProfileRequestDTO; +import com.mr.domain.user.dto.res.UserProfileResponseDTO; +import com.mr.domain.user.entity.Instrument; +import com.mr.domain.user.entity.Student; +import com.mr.domain.user.entity.StudentInstrument; +import com.mr.domain.user.entity.User; +import com.mr.domain.user.entity.enums.TheoryLevel; +import com.mr.domain.user.entity.enums.UserRole; +import com.mr.domain.user.exception.InstrumentErrorStatus; +import com.mr.domain.user.exception.StudentErrorStatus; +import com.mr.domain.user.exception.UserErrorStatus; +import com.mr.domain.user.repository.InstrumentRepository; +import com.mr.domain.user.repository.StudentInstrumentRepository; +import com.mr.domain.user.repository.StudentRepository; +import com.mr.domain.user.repository.UserRepository; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.security.principal.CustomUserDetails; +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.Optional; +import org.hibernate.exception.ConstraintViolationException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +@ExtendWith(MockitoExtension.class) +class UserProfileServiceTest { + + private static final Long USER_ID = 1L; + + @Mock + private UserRepository userRepository; + @Mock + private StudentRepository studentRepository; + @Mock + private StudentInstrumentRepository studentInstrumentRepository; + @Mock + private InstrumentRepository instrumentRepository; + @Mock + private SubscriptionRepository subscriptionRepository; + @Mock + private UserStatisticsRepository userStatisticsRepository; + @Mock + private UserLearningProgressRepository userLearningProgressRepository; + + @InjectMocks + private UserProfileService userProfileService; + + private User user; + + @BeforeEach + void setUp() { + user = User.createFromOAuth("https://cdn.example.com/profile/default-profile.png"); + + CustomUserDetails userDetails = new CustomUserDetails(USER_ID, UserRole.ROLE_STUDENT); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities())); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("registerProfile - 정상 온보딩 시 구독 등급은 요청값과 무관하게 항상 PRO로 저장된다") + void registerProfile_alwaysUsesProTier() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNicknameAndUserIdNot(any(), any())).willReturn(false); + + Student student = Student.create(user, TheoryLevel.INTERMEDIATE); + given(studentRepository.saveAndFlush(any(Student.class))).willReturn(student); + + Instrument piano = Instrument.create("PIANO", "피아노"); + given(instrumentRepository.findByCode("PIANO")).willReturn(Optional.of(piano)); + + given(subscriptionRepository.save(any(Subscription.class))).willAnswer(invocation -> invocation.getArgument(0)); + + UserProfileRequestDTO.OnboardingRequest request = + new UserProfileRequestDTO.OnboardingRequest("김뮤즈", TheoryLevel.INTERMEDIATE); + + UserProfileResponseDTO.OnboardingResponse response = userProfileService.registerProfile(request); + + assertThat(response.subscriptionTier()).isEqualTo("PRO"); + assertThat(user.getNickname()).isEqualTo("김뮤즈"); + } + + @Test + @DisplayName("registerProfile - 이미 온보딩을 완료한 유저면 USER_409_02") + void registerProfile_alreadyOnboarded() { + user.updateNickname("이미가입함"); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + + UserProfileRequestDTO.OnboardingRequest request = + new UserProfileRequestDTO.OnboardingRequest("김뮤즈", TheoryLevel.INTERMEDIATE); + + assertThatThrownBy(() -> userProfileService.registerProfile(request)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getCode()) + .isEqualTo(UserErrorStatus.ONBOARDING_ALREADY_COMPLETED); + + verify(studentRepository, never()).saveAndFlush(any()); + } + + @Test + @DisplayName("registerProfile - 동시 요청으로 닉네임이 먼저 선점되면(UNIQUE 위반) USER_409_01(닉네임 중복)로 변환된다") + void registerProfile_nicknameRaceCondition_convertedToNicknameDuplicated() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNicknameAndUserIdNot(any(), any())).willReturn(false); + given(userRepository.saveAndFlush(any(User.class))) + .willThrow(uniqueViolation("users_nickname_key")); + + UserProfileRequestDTO.OnboardingRequest request = + new UserProfileRequestDTO.OnboardingRequest("김뮤즈", TheoryLevel.INTERMEDIATE); + + assertThatThrownBy(() -> userProfileService.registerProfile(request)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getCode()) + .isEqualTo(UserErrorStatus.NICKNAME_DUPLICATED); + + verify(studentRepository, never()).saveAndFlush(any()); + } + + @Test + @DisplayName("registerProfile - 동시 요청으로 온보딩이 먼저 완료되면(Student UNIQUE 위반) USER_409_02로 변환된다") + void registerProfile_studentRaceCondition_convertedToAlreadyCompleted() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNicknameAndUserIdNot(any(), any())).willReturn(false); + given(studentRepository.saveAndFlush(any(Student.class))) + .willThrow(uniqueViolation("student_user_id_key")); + + UserProfileRequestDTO.OnboardingRequest request = + new UserProfileRequestDTO.OnboardingRequest("김뮤즈", TheoryLevel.INTERMEDIATE); + + assertThatThrownBy(() -> userProfileService.registerProfile(request)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getCode()) + .isEqualTo(UserErrorStatus.ONBOARDING_ALREADY_COMPLETED); + } + + @Test + @DisplayName("registerProfile - 닉네임 위반이 아닌 다른 무결성 오류는 그대로 전파된다") + void registerProfile_nonDuplicateIntegrityViolation_propagates() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNicknameAndUserIdNot(any(), any())).willReturn(false); + DataIntegrityViolationException notNullViolation = + new DataIntegrityViolationException("not null violation"); + given(userRepository.saveAndFlush(any(User.class))).willThrow(notNullViolation); + + UserProfileRequestDTO.OnboardingRequest request = + new UserProfileRequestDTO.OnboardingRequest("김뮤즈", TheoryLevel.INTERMEDIATE); + + assertThatThrownBy(() -> userProfileService.registerProfile(request)) + .isSameAs(notNullViolation); + } + + @Test + @DisplayName("registerProfile - PIANO 시드 데이터가 없으면 INSTRUMENT_500_01") + void registerProfile_instrumentNotSeeded() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNicknameAndUserIdNot(any(), any())).willReturn(false); + + Student student = Student.create(user, TheoryLevel.INTERMEDIATE); + given(studentRepository.saveAndFlush(any(Student.class))).willReturn(student); + given(instrumentRepository.findByCode("PIANO")).willReturn(Optional.empty()); + + UserProfileRequestDTO.OnboardingRequest request = + new UserProfileRequestDTO.OnboardingRequest("김뮤즈", TheoryLevel.INTERMEDIATE); + + assertThatThrownBy(() -> userProfileService.registerProfile(request)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getCode()) + .isEqualTo(InstrumentErrorStatus.INSTRUMENT_NOT_SEEDED); + } + + @Test + @DisplayName("getMyProfile - 통계의 completedLearningCount는 UserLearningProgressRepository 집계 결과를 그대로 사용한다") + void getMyProfile_usesLearningProgressAggregationForCompletedLearningCount() { + user.updateNickname("김뮤즈"); + Student student = Student.create(user, TheoryLevel.INTERMEDIATE); + Instrument piano = Instrument.create("PIANO", "피아노"); + StudentInstrument primaryInstrument = StudentInstrument.createPrimary(student, piano); + Subscription subscription = Subscription.create( + user, "PRO", LocalDateTime.now().minusDays(1), LocalDateTime.now().plusDays(29)); + + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(studentRepository.findByUser(user)).willReturn(Optional.of(student)); + given(studentInstrumentRepository.findByStudentAndPrimaryTrue(student)) + .willReturn(Optional.of(primaryInstrument)); + given(subscriptionRepository + .findFirstByUserAndStartDateLessThanEqualAndEndDateGreaterThanEqualOrderByStartDateDesc( + any(), any(), any())) + .willReturn(Optional.of(subscription)); + given(userStatisticsRepository.findByUserId(USER_ID)).willReturn(Optional.empty()); + given(userLearningProgressRepository.countDistinctCompletedLearningsByUserId(USER_ID)).willReturn(8L); + + UserProfileResponseDTO.ProfileResponse response = userProfileService.getMyProfile(); + + assertThat(response.statistics().completedLearningCount()).isEqualTo(8L); + verify(userLearningProgressRepository).countDistinctCompletedLearningsByUserId(USER_ID); + } + + @Test + @DisplayName("updateProfile - 온보딩 정보(student)가 없으면 STUDENT_404_01") + void updateProfile_studentNotFound() { + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(studentRepository.findByUser(user)).willReturn(Optional.empty()); + + UserProfileRequestDTO.UpdateRequest request = + new UserProfileRequestDTO.UpdateRequest("새로운뮤즈", TheoryLevel.ADVANCED); + + assertThatThrownBy(() -> userProfileService.updateProfile(request)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getCode()) + .isEqualTo(StudentErrorStatus.STUDENT_NOT_FOUND); + } + + @Test + @DisplayName("updateProfile - 동시 요청으로 닉네임이 먼저 선점되면(UNIQUE 위반) USER_409_01로 변환된다") + void updateProfile_nicknameRaceCondition_convertedToNicknameDuplicated() { + user.updateNickname("기존닉네임"); + Student student = Student.create(user, TheoryLevel.INTERMEDIATE); + + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(studentRepository.findByUser(user)).willReturn(Optional.of(student)); + given(userRepository.existsByNicknameAndUserIdNot(any(), any())).willReturn(false); + given(userRepository.saveAndFlush(any(User.class))) + .willThrow(uniqueViolation("users_nickname_key")); + + UserProfileRequestDTO.UpdateRequest request = + new UserProfileRequestDTO.UpdateRequest("새로운뮤즈", TheoryLevel.ADVANCED); + + assertThatThrownBy(() -> userProfileService.updateProfile(request)) + .isInstanceOf(GeneralException.class) + .extracting(e -> ((GeneralException) e).getCode()) + .isEqualTo(UserErrorStatus.NICKNAME_DUPLICATED); + } + + @Test + @DisplayName("updateProfile - nickname을 생략(null)하면 기존 닉네임을 유지하고 skillLevel만 변경한다") + void updateProfile_partialUpdate_skillLevelOnly() { + user.updateNickname("기존닉네임"); + Student student = Student.create(user, TheoryLevel.BEGINNER); + + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(studentRepository.findByUser(user)).willReturn(Optional.of(student)); + + UserProfileRequestDTO.UpdateRequest request = + new UserProfileRequestDTO.UpdateRequest(null, TheoryLevel.ADVANCED); + + UserProfileResponseDTO.UpdateResponse response = userProfileService.updateProfile(request); + + assertThat(response.nickname()).isEqualTo("기존닉네임"); + assertThat(response.skillLevel()).isEqualTo(TheoryLevel.ADVANCED); + verify(userRepository, never()).existsByNicknameAndUserIdNot(any(), any()); + } + + @Test + @DisplayName("updateProfile - skillLevel을 생략(null)하면 기존 숙련도를 유지하고 nickname만 변경한다") + void updateProfile_partialUpdate_nicknameOnly() { + user.updateNickname("기존닉네임"); + Student student = Student.create(user, TheoryLevel.BEGINNER); + + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(studentRepository.findByUser(user)).willReturn(Optional.of(student)); + given(userRepository.existsByNicknameAndUserIdNot(any(), any())).willReturn(false); + + UserProfileRequestDTO.UpdateRequest request = + new UserProfileRequestDTO.UpdateRequest("새로운뮤즈", null); + + UserProfileResponseDTO.UpdateResponse response = userProfileService.updateProfile(request); + + assertThat(response.nickname()).isEqualTo("새로운뮤즈"); + assertThat(response.skillLevel()).isEqualTo(TheoryLevel.BEGINNER); + } + + private static DataIntegrityViolationException uniqueViolation(String constraintName) { + SQLException sqlException = new SQLException("duplicate key value violates unique constraint", "23505"); + ConstraintViolationException constraintViolationException = + new ConstraintViolationException("duplicate key", sqlException, constraintName); + return new DataIntegrityViolationException("duplicate key", constraintViolationException); + } +}