diff --git a/src/main/java/com/techfork/domain/activity/repository/ReadPostRepository.java b/src/main/java/com/techfork/domain/activity/repository/ReadPostRepository.java index 00ecbf7..c58f7b0 100644 --- a/src/main/java/com/techfork/domain/activity/repository/ReadPostRepository.java +++ b/src/main/java/com/techfork/domain/activity/repository/ReadPostRepository.java @@ -16,10 +16,10 @@ public interface ReadPostRepository extends JpaRepository { @Query(""" SELECT rp FROM ReadPost rp - JOIN FETCH rp.post - WHERE rp.user = :user + JOIN FETCH rp.post + WHERE rp.user.id = :userId AND (rp.readDurationSeconds IS NULL OR rp.readDurationSeconds > 10) ORDER BY rp.readAt DESC """) - List findRecentReadPostsByUserWithMinDuration(@Param("user") User user, Pageable pageable); + List findRecentReadPostsByUserIdWithMinDuration(@Param("userId") Long userId, Pageable pageable); } diff --git a/src/main/java/com/techfork/domain/activity/repository/ScrabPostRepository.java b/src/main/java/com/techfork/domain/activity/repository/ScrabPostRepository.java index 9619651..8e97338 100644 --- a/src/main/java/com/techfork/domain/activity/repository/ScrabPostRepository.java +++ b/src/main/java/com/techfork/domain/activity/repository/ScrabPostRepository.java @@ -35,6 +35,6 @@ List findBookmarksWithCursor( Optional findByUserAndPost(User user, Post post); - @Query("SELECT sp FROM ScrabPost sp JOIN FETCH sp.post WHERE sp.user = :user ORDER BY sp.scrappedAt DESC") - List findRecentScrapPostsByUser(@Param("user") User user, Pageable pageable); + @Query("SELECT sp FROM ScrabPost sp JOIN FETCH sp.post WHERE sp.user.id = :userId ORDER BY sp.scrappedAt DESC") + List findRecentScrapPostsByUserId(@Param("userId") Long userId, Pageable pageable); } diff --git a/src/main/java/com/techfork/domain/activity/repository/SearchHistoryRepository.java b/src/main/java/com/techfork/domain/activity/repository/SearchHistoryRepository.java index 7cf145a..18bd3cb 100644 --- a/src/main/java/com/techfork/domain/activity/repository/SearchHistoryRepository.java +++ b/src/main/java/com/techfork/domain/activity/repository/SearchHistoryRepository.java @@ -1,7 +1,6 @@ package com.techfork.domain.activity.repository; import com.techfork.domain.activity.entity.SearchHistory; -import com.techfork.domain.user.entity.User; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -11,6 +10,6 @@ public interface SearchHistoryRepository extends JpaRepository { - @Query("SELECT sh FROM SearchHistory sh WHERE sh.user = :user ORDER BY sh.searchedAt DESC") - List findRecentSearchHistoriesByUser(@Param("user") User user, Pageable pageable); + @Query("SELECT sh FROM SearchHistory sh WHERE sh.user.id = :userId ORDER BY sh.searchedAt DESC") + List findRecentSearchHistoriesByUserId(@Param("userId") Long userId, Pageable pageable); } diff --git a/src/main/java/com/techfork/domain/post/entity/Post.java b/src/main/java/com/techfork/domain/post/entity/Post.java index 5bd3115..a1db33c 100644 --- a/src/main/java/com/techfork/domain/post/entity/Post.java +++ b/src/main/java/com/techfork/domain/post/entity/Post.java @@ -8,6 +8,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; import java.time.LocalDateTime; import java.util.ArrayList; @@ -57,6 +58,7 @@ public class Post extends BaseEntity { private TechBlog techBlog; @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + @BatchSize(size = 100) private List keywords = new ArrayList<>(); @Builder diff --git a/src/main/java/com/techfork/domain/post/repository/PostKeywordRepository.java b/src/main/java/com/techfork/domain/post/repository/PostKeywordRepository.java index befb94c..fb4fc7f 100644 --- a/src/main/java/com/techfork/domain/post/repository/PostKeywordRepository.java +++ b/src/main/java/com/techfork/domain/post/repository/PostKeywordRepository.java @@ -1,6 +1,5 @@ package com.techfork.domain.post.repository; -import com.techfork.domain.post.entity.Post; import com.techfork.domain.post.entity.PostKeyword; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -9,11 +8,6 @@ import java.util.List; public interface PostKeywordRepository extends JpaRepository { - - List findByPost(Post post); - - void deleteByPost(Post post); - @Query("SELECT pk FROM PostKeyword pk WHERE pk.post.id IN :postIds") List findByPostIdIn(@Param("postIds") List postIds); } diff --git a/src/main/java/com/techfork/domain/post/repository/PostRepository.java b/src/main/java/com/techfork/domain/post/repository/PostRepository.java index afeca71..47c8708 100644 --- a/src/main/java/com/techfork/domain/post/repository/PostRepository.java +++ b/src/main/java/com/techfork/domain/post/repository/PostRepository.java @@ -20,7 +20,7 @@ public interface PostRepository extends JpaRepository { Set findExistingUrls(@Param("urls") List urls); @Query(""" - SELECT p FROM Post p + SELECT DISTINCT p FROM Post p LEFT JOIN FETCH p.keywords WHERE p.summary IS NULL OR p.summary = '' """) diff --git a/src/main/java/com/techfork/domain/recommendation/service/LlmRecommendationService.java b/src/main/java/com/techfork/domain/recommendation/service/LlmRecommendationService.java index ef05e77..05ca546 100644 --- a/src/main/java/com/techfork/domain/recommendation/service/LlmRecommendationService.java +++ b/src/main/java/com/techfork/domain/recommendation/service/LlmRecommendationService.java @@ -264,7 +264,7 @@ private List searchCandidatesWithCustomReadHistory( */ private List searchCandidates(float[] userProfileVector, User user) throws IOException { // 이미 읽은 글 ID 목록 - Set readPostIds = readPostRepository.findRecentReadPostsByUserWithMinDuration(user, PageRequest.of(0, 1000)) + Set readPostIds = readPostRepository.findRecentReadPostsByUserIdWithMinDuration(user.getId(), PageRequest.of(0, 1000)) .stream() .map(readPost -> readPost.getPost().getId()) .collect(Collectors.toSet()); diff --git a/src/main/java/com/techfork/domain/user/controller/OnboardingController.java b/src/main/java/com/techfork/domain/user/controller/OnboardingController.java index 077976e..2038a29 100644 --- a/src/main/java/com/techfork/domain/user/controller/OnboardingController.java +++ b/src/main/java/com/techfork/domain/user/controller/OnboardingController.java @@ -1,9 +1,9 @@ package com.techfork.domain.user.controller; import com.techfork.domain.user.dto.InterestListResponse; -import com.techfork.domain.user.dto.SaveInterestRequest; -import com.techfork.domain.user.service.InterestCommandService; +import com.techfork.domain.user.dto.OnboardingRequest; import com.techfork.domain.user.service.InterestQueryService; +import com.techfork.domain.user.service.UserCommandService; import com.techfork.global.common.code.SuccessCode; import com.techfork.global.response.BaseResponse; import io.swagger.v3.oas.annotations.Operation; @@ -22,7 +22,7 @@ public class OnboardingController { private final InterestQueryService interestQueryService; - private final InterestCommandService interestCommandService; + private final UserCommandService userCommandService; @Operation( summary = "관심사 목록 조회", @@ -35,17 +35,15 @@ public ResponseEntity> getInterests() { } @Operation( - summary = "내 관심사 저장", - description = "온보딩 시 사용자가 선택한 관심사를 저장합니다. 카테고리별로 세부 키워드를 선택할 수 있습니다." + summary = "내 정보 및 관심사 저장", + description = "온보딩 시 사용자의 정보와 선택한 관심사를 저장합니다. 카테고리별로 세부 키워드를 선택할 수 있습니다." ) - @PostMapping("/interests") - public ResponseEntity> saveInterests( - @Valid @RequestBody SaveInterestRequest request + @PostMapping("/complete") + public ResponseEntity> completeOnboarding( + @RequestHeader(value = "X-User-Id", required = false, defaultValue = "1") Long userId, + @Valid @RequestBody OnboardingRequest request ) { - // TODO: userId Auth 인증 기반으로 추출 - Long userId = 1L; - - interestCommandService.saveUserInterests(userId, request); + userCommandService.completeOnboarding(userId, request); return BaseResponse.of(SuccessCode.CREATED); } } diff --git a/src/main/java/com/techfork/domain/user/dto/OnboardingRequest.java b/src/main/java/com/techfork/domain/user/dto/OnboardingRequest.java new file mode 100644 index 0000000..2fa753d --- /dev/null +++ b/src/main/java/com/techfork/domain/user/dto/OnboardingRequest.java @@ -0,0 +1,23 @@ +package com.techfork.domain.user.dto; + +import jakarta.validation.constraints.*; + +import java.util.List; + +public record OnboardingRequest( + @NotBlank(message = "닉네임은 필수입니다.") + @Size(min = 2, max = 20, message = "닉네임은 2-20자여야 합니다.") + String nickname, + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + String email, + + @Size(max = 100, message = "한줄소개는 100자 이하여야 합니다.") + String description, + + @NotNull(message = "관심사 목록은 필수입니다.") + @NotEmpty(message = "관심사를 최소 1개 이상 선택해주세요.") + List interests +) { +} diff --git a/src/main/java/com/techfork/domain/user/entity/User.java b/src/main/java/com/techfork/domain/user/entity/User.java index 10b4a0a..bc5e595 100644 --- a/src/main/java/com/techfork/domain/user/entity/User.java +++ b/src/main/java/com/techfork/domain/user/entity/User.java @@ -1,25 +1,48 @@ package com.techfork.domain.user.entity; import com.techfork.global.common.BaseTimeEntity; -import jakarta.persistence.Entity; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.OneToMany; -import jakarta.persistence.Table; +import jakarta.persistence.*; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.springframework.data.annotation.PersistenceCreator; +import java.util.ArrayList; import java.util.List; @Entity @Table(name = "users") @Getter -@NoArgsConstructor(access = AccessLevel.PUBLIC) +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class User extends BaseTimeEntity { - @OneToMany - @JoinColumn(name = "user_id") - private List interestCategories; + private String nickName; + + @Column(unique = true) + private String email; + + private String description; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List interestCategories = new ArrayList<>(); + + @PersistenceCreator + @Builder + private User(String nickName, String email, String description) { + this.nickName = nickName; + this.email = email; + this.description = description; + } + + public static User create() { + return User.builder() + .build(); + } + + public void updateUser(String nickName, String email, String description) { + this.nickName = nickName; + this.email = email; + this.description = description; + } } diff --git a/src/main/java/com/techfork/domain/user/repository/UserInterestCategoryRepository.java b/src/main/java/com/techfork/domain/user/repository/UserInterestCategoryRepository.java index b016c43..68b1a3c 100644 --- a/src/main/java/com/techfork/domain/user/repository/UserInterestCategoryRepository.java +++ b/src/main/java/com/techfork/domain/user/repository/UserInterestCategoryRepository.java @@ -1,6 +1,5 @@ package com.techfork.domain.user.repository; -import com.techfork.domain.user.entity.User; import com.techfork.domain.user.entity.UserInterestCategory; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -9,15 +8,11 @@ import java.util.List; public interface UserInterestCategoryRepository extends JpaRepository { - - void deleteByUser(User user); - @Query(""" - SELECT uic - FROM UserInterestCategory uic + SELECT DISTINCT uic FROM UserInterestCategory uic LEFT JOIN FETCH uic.keywords - WHERE uic.user = :user + WHERE uic.user.id = :userId """) - List findByUserWithKeywords(@Param("user") User user); + List findByUserIdWithKeywords(@Param("userId") Long userId); } diff --git a/src/main/java/com/techfork/domain/user/repository/UserRepository.java b/src/main/java/com/techfork/domain/user/repository/UserRepository.java index 2a1501c..24691b9 100644 --- a/src/main/java/com/techfork/domain/user/repository/UserRepository.java +++ b/src/main/java/com/techfork/domain/user/repository/UserRepository.java @@ -7,9 +7,17 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; public interface UserRepository extends JpaRepository { + @Query(""" + SELECT DISTINCT u FROM User u + LEFT JOIN FETCH u.interestCategories + WHERE u.id = :userId + """) + Optional findByIdWithInterestCategories(@Param("userId") Long userId); + /** * 최근 특정 시간 이후 활동한 사용자 조회 * (읽은 포스트, 스크랩, 검색 기록 중 하나라도 있으면 활성 사용자) diff --git a/src/main/java/com/techfork/domain/user/service/InterestCommandService.java b/src/main/java/com/techfork/domain/user/service/InterestCommandService.java index 4adb84f..7896739 100644 --- a/src/main/java/com/techfork/domain/user/service/InterestCommandService.java +++ b/src/main/java/com/techfork/domain/user/service/InterestCommandService.java @@ -8,7 +8,6 @@ import com.techfork.domain.user.enums.EInterestCategory; import com.techfork.domain.user.enums.EInterestKeyword; import com.techfork.domain.user.exception.UserErrorCode; -import com.techfork.domain.user.repository.UserInterestCategoryRepository; import com.techfork.domain.user.repository.UserRepository; import com.techfork.global.exception.GeneralException; import lombok.RequiredArgsConstructor; @@ -25,26 +24,23 @@ public class InterestCommandService { private final UserRepository userRepository; - private final UserInterestCategoryRepository userInterestCategoryRepository; private final UserProfileService userProfileService; public void updateUserInterests(Long userId, SaveInterestRequest request) { - saveUserInterests(userId, request); - } - - public void saveUserInterests(Long userId, SaveInterestRequest request) { - User user = userRepository.findById(userId) + User user = userRepository.findByIdWithInterestCategories(userId) .orElseThrow(() -> new GeneralException(UserErrorCode.USER_NOT_FOUND)); - userInterestCategoryRepository.deleteByUser(user); + saveUserInterests(user, request); + } + void saveUserInterests(User user, SaveInterestRequest request) { + user.getInterestCategories().clear(); List categories = createCategoriesFromRequest(user, request); - userInterestCategoryRepository.saveAll(categories); + user.getInterestCategories().addAll(categories); - log.info("Saved {} interest categories for user {}", categories.size(), userId); + log.info("Saved {} interest categories for user {}", categories.size(), user.getId()); - // 관심사 저장/수정 시 사용자 프로필 재생성 - userProfileService.generateUserProfile(userId); + userProfileService.generateUserProfile(user.getId()); } private List createCategoriesFromRequest(User user, SaveInterestRequest request) { diff --git a/src/main/java/com/techfork/domain/user/service/InterestQueryService.java b/src/main/java/com/techfork/domain/user/service/InterestQueryService.java index 8c366f9..84aa540 100644 --- a/src/main/java/com/techfork/domain/user/service/InterestQueryService.java +++ b/src/main/java/com/techfork/domain/user/service/InterestQueryService.java @@ -37,7 +37,7 @@ public UserInterestResponse getUserInterests(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new GeneralException(UserErrorCode.USER_NOT_FOUND)); - List categories = userInterestCategoryRepository.findByUserWithKeywords(user); + List categories = userInterestCategoryRepository.findByUserIdWithKeywords(user.getId()); List userInterestDtos = interestConverter.toUserInterestDtoList(categories); return UserInterestResponse.builder() diff --git a/src/main/java/com/techfork/domain/user/service/UserCommandService.java b/src/main/java/com/techfork/domain/user/service/UserCommandService.java new file mode 100644 index 0000000..0a3399b --- /dev/null +++ b/src/main/java/com/techfork/domain/user/service/UserCommandService.java @@ -0,0 +1,33 @@ +package com.techfork.domain.user.service; + +import com.techfork.domain.user.dto.OnboardingRequest; +import com.techfork.domain.user.dto.SaveInterestRequest; +import com.techfork.domain.user.entity.User; +import com.techfork.domain.user.exception.UserErrorCode; +import com.techfork.domain.user.repository.UserRepository; +import com.techfork.global.exception.GeneralException; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@Slf4j +@Service +@RequiredArgsConstructor +public class UserCommandService { + + private final InterestCommandService interestCommandService; + + private final UserRepository userRepository; + + public void completeOnboarding(Long userId, @Valid OnboardingRequest request) { + User user = userRepository.findByIdWithInterestCategories(userId) + .orElseThrow(() -> new GeneralException(UserErrorCode.USER_NOT_FOUND)); + + user.updateUser(request.nickname(), request.email(), request.description()); + + interestCommandService.saveUserInterests(user, new SaveInterestRequest(request.interests())); + } +} diff --git a/src/main/java/com/techfork/domain/user/service/UserProfileService.java b/src/main/java/com/techfork/domain/user/service/UserProfileService.java index 4bc1ac8..aafd1a7 100644 --- a/src/main/java/com/techfork/domain/user/service/UserProfileService.java +++ b/src/main/java/com/techfork/domain/user/service/UserProfileService.java @@ -8,13 +8,9 @@ import com.techfork.domain.activity.repository.SearchHistoryRepository; import com.techfork.domain.post.entity.PostKeyword; import com.techfork.domain.user.document.UserProfileDocument; -import com.techfork.domain.user.entity.User; import com.techfork.domain.user.entity.UserInterestCategory; -import com.techfork.domain.user.exception.UserErrorCode; import com.techfork.domain.user.repository.UserInterestCategoryRepository; import com.techfork.domain.user.repository.UserProfileDocumentRepository; -import com.techfork.domain.user.repository.UserRepository; -import com.techfork.global.exception.GeneralException; import com.techfork.global.llm.EmbeddingClient; import com.techfork.global.llm.LlmClient; import lombok.RequiredArgsConstructor; @@ -32,7 +28,6 @@ @RequiredArgsConstructor public class UserProfileService { - private final UserRepository userRepository; private final UserInterestCategoryRepository userInterestCategoryRepository; private final ReadPostRepository readPostRepository; private final ScrabPostRepository scrabPostRepository; @@ -54,10 +49,7 @@ public void generateUserProfile(Long userId) { @Transactional public void generateUserProfileSync(Long userId) { try { - User user = userRepository.findById(userId) - .orElseThrow(() -> new GeneralException(UserErrorCode.USER_NOT_FOUND)); - - UserActivityData activityData = collectUserActivityData(user); + UserActivityData activityData = collectUserActivityData(userId); String profileText = generateProfileTextWithLLM(activityData); float[] profileVector = generateEmbeddingVector(profileText); @@ -77,14 +69,14 @@ public void generateUserProfileSync(Long userId) { } } - private UserActivityData collectUserActivityData(User user) { - List categories = userInterestCategoryRepository.findByUserWithKeywords(user); + private UserActivityData collectUserActivityData(Long userId) { + List categories = userInterestCategoryRepository.findByUserIdWithKeywords(userId); List interests = categories.stream() .flatMap(c -> c.getKeywords().stream()) .map(k -> k.getKeyword().getDisplayName()) .toList(); - List readPosts = readPostRepository.findRecentReadPostsByUserWithMinDuration(user, PageRequest.of(0, 20)); + List readPosts = readPostRepository.findRecentReadPostsByUserIdWithMinDuration(userId, PageRequest.of(0, 20)); List readPostData = readPosts.stream() .map(rp -> new PostData( rp.getPost().getTitle(), @@ -95,7 +87,7 @@ private UserActivityData collectUserActivityData(User user) { )) .toList(); - List scrapPosts = scrabPostRepository.findRecentScrapPostsByUser(user, PageRequest.of(0, 20)); + List scrapPosts = scrabPostRepository.findRecentScrapPostsByUserId(userId, PageRequest.of(0, 20)); List scrapPostData = scrapPosts.stream() .map(sp -> new PostData( sp.getPost().getTitle(), @@ -106,7 +98,7 @@ private UserActivityData collectUserActivityData(User user) { )) .toList(); - List searchHistories = searchHistoryRepository.findRecentSearchHistoriesByUser(user, PageRequest.of(0, 30)); + List searchHistories = searchHistoryRepository.findRecentSearchHistoriesByUserId(userId, PageRequest.of(0, 30)); List searchWords = searchHistories.stream() .map(SearchHistory::getSearchWord) .toList(); diff --git a/src/test/java/com/techfork/domain/recommendation/TestDataGenerator.java b/src/test/java/com/techfork/domain/recommendation/TestDataGenerator.java index 75e7de0..72b85c1 100644 --- a/src/test/java/com/techfork/domain/recommendation/TestDataGenerator.java +++ b/src/test/java/com/techfork/domain/recommendation/TestDataGenerator.java @@ -53,7 +53,7 @@ public class TestDataGenerator { @Transactional public User createTestUser(List interestCategories, int readPostCount) { // 사용자 생성 - User user = new User(); + User user = User.create(); user = userRepository.save(user); log.info("테스트 사용자 생성: ID: {}", user.getId()); @@ -167,7 +167,7 @@ public List findPostsRelatedToInterests(List interests, */ @Transactional(readOnly = true) public RecommendationTestCase generateTestCase(User user, List interests) { - List readPostIds = readPostRepository.findRecentReadPostsByUserWithMinDuration(user, PageRequest.of(0, 1000)).stream() + List readPostIds = readPostRepository.findRecentReadPostsByUserIdWithMinDuration(user.getId(), PageRequest.of(0, 1000)).stream() .map(rp -> rp.getPost().getId()) .toList(); @@ -308,7 +308,7 @@ public ImprovedRecommendationTestCase generateImprovedTestCase( // 읽은 글 이력 조회 (시간순) List readPostIds = readPostRepository - .findRecentReadPostsByUserWithMinDuration(user, PageRequest.of(0, 1000)) + .findRecentReadPostsByUserIdWithMinDuration(user.getId(), PageRequest.of(0, 1000)) .stream() .map(rp -> rp.getPost().getId()) .toList(); diff --git a/src/test/java/com/techfork/domain/search/UserProfileServiceTest.java b/src/test/java/com/techfork/domain/search/UserProfileServiceTest.java index 43a845e..a7e69eb 100644 --- a/src/test/java/com/techfork/domain/search/UserProfileServiceTest.java +++ b/src/test/java/com/techfork/domain/search/UserProfileServiceTest.java @@ -50,7 +50,7 @@ void generateTestUserProfiles() { ); IntStream.range(0, 10).forEach(i -> { - User user = new User(); + User user = User.create(); userRepository.save(user); diff --git a/src/test/java/com/techfork/domain/user/controller/OnboardingControllerIntegrationTest.java b/src/test/java/com/techfork/domain/user/controller/OnboardingControllerIntegrationTest.java new file mode 100644 index 0000000..fc41235 --- /dev/null +++ b/src/test/java/com/techfork/domain/user/controller/OnboardingControllerIntegrationTest.java @@ -0,0 +1,339 @@ +package com.techfork.domain.user.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.techfork.domain.user.dto.OnboardingRequest; +import com.techfork.domain.user.dto.UserInterestDto; +import com.techfork.domain.user.entity.User; +import com.techfork.domain.user.repository.UserRepository; +import com.techfork.global.configuration.ElasticsearchTestConfig; +import com.techfork.global.configuration.MySQLTestConfig; +import com.techfork.global.llm.EmbeddingClient; +import com.techfork.global.llm.LlmClient; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * OnboardingController 통합 테스트 + */ +@SpringBootTest +@AutoConfigureMockMvc +@Import({MySQLTestConfig.class, ElasticsearchTestConfig.class}) +@ActiveProfiles("integrationtest") +class OnboardingControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private LlmClient llmClient; + + @MockitoBean + private EmbeddingClient embeddingClient; + + private User testUser; + + @BeforeEach + void setUp() { + testUser = User.create(); + testUser = userRepository.save(testUser); + + // LLM 클라이언트 모킹 - 테스트용 더미 응답 반환 + when(llmClient.call(anyString(), anyString())) + .thenReturn("테스트용 사용자 프로필입니다. 백엔드 개발에 관심이 많습니다."); + + // Embedding 클라이언트 모킹 - 테스트용 더미 벡터 반환 + when(embeddingClient.embed(anyString())) + .thenReturn(Collections.nCopies(3072, 0.1f)); + } + + @AfterEach + void tearDown() { + userRepository.deleteAll(); + } + + @Test + @DisplayName("관심사 목록 조회 - 성공") + void getInterests_Success() throws Exception { + // When & Then + mockMvc.perform(get("/api/v1/onboarding/interests")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.data.categories").isArray()) + .andExpect(jsonPath("$.data.categories").isNotEmpty()); + } + + @Test + @DisplayName("온보딩 완료 - 정상 케이스") + void completeOnboarding_Success() throws Exception { + // Given + List interests = List.of( + UserInterestDto.builder() + .category("BACKEND") + .keywords(List.of("JAVA", "SPRING")) + .build(), + UserInterestDto.builder() + .category("DATABASE") + .keywords(List.of("MYSQL", "REDIS")) + .build() + ); + + OnboardingRequest request = new OnboardingRequest( + "테크포크유저", + "user@techfork.com", + "백엔드 개발자입니다", + interests + ); + + String requestBody = objectMapper.writeValueAsString(request); + + // When & Then + mockMvc.perform(post("/api/v1/onboarding/complete") + .header("X-User-Id", testUser.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.isSuccess").value(true)); + + // 데이터베이스 검증 + User savedUser = userRepository.findByIdWithInterestCategories(testUser.getId()).orElseThrow(); + assertThat(savedUser.getNickName()).isEqualTo("테크포크유저"); + assertThat(savedUser.getEmail()).isEqualTo("user@techfork.com"); + assertThat(savedUser.getDescription()).isEqualTo("백엔드 개발자입니다"); + assertThat(savedUser.getInterestCategories()).hasSize(2); + } + + @Test + @DisplayName("온보딩 완료 - description null 허용") + void completeOnboarding_NullDescription_Success() throws Exception { + // Given + OnboardingRequest request = new OnboardingRequest( + "테크포크유저", + "user@techfork.com", + null, + List.of( + UserInterestDto.builder() + .category("FRONTEND") + .keywords(List.of("REACT")) + .build() + ) + ); + + String requestBody = objectMapper.writeValueAsString(request); + + // When & Then + mockMvc.perform(post("/api/v1/onboarding/complete") + .header("X-User-Id", testUser.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.isSuccess").value(true)); + + User savedUser = userRepository.findById(testUser.getId()).orElseThrow(); + assertThat(savedUser.getDescription()).isNull(); + } + + @Test + @DisplayName("온보딩 완료 - 닉네임 필수 검증") + void completeOnboarding_BlankNickname_BadRequest() throws Exception { + // Given + OnboardingRequest request = new OnboardingRequest( + "", + "user@techfork.com", + null, + List.of( + UserInterestDto.builder() + .category("BACKEND") + .keywords(List.of("JAVA")) + .build() + ) + ); + + String requestBody = objectMapper.writeValueAsString(request); + + // When & Then + mockMvc.perform(post("/api/v1/onboarding/complete") + .header("X-User-Id", testUser.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("온보딩 완료 - 닉네임 길이 검증 (2-20자)") + void completeOnboarding_NicknameTooShort_BadRequest() throws Exception { + // Given + OnboardingRequest request = new OnboardingRequest( + "a", // 1자 (최소 2자 필요) + "user@techfork.com", + null, + List.of( + UserInterestDto.builder() + .category("BACKEND") + .keywords(List.of("JAVA")) + .build() + ) + ); + + String requestBody = objectMapper.writeValueAsString(request); + + // When & Then + mockMvc.perform(post("/api/v1/onboarding/complete") + .header("X-User-Id", testUser.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("온보딩 완료 - 이메일 형식 검증") + void completeOnboarding_InvalidEmail_BadRequest() throws Exception { + // Given + OnboardingRequest request = new OnboardingRequest( + "테크포크유저", + "invalid-email", // 잘못된 이메일 형식 + null, + List.of( + UserInterestDto.builder() + .category("BACKEND") + .keywords(List.of("JAVA")) + .build() + ) + ); + + String requestBody = objectMapper.writeValueAsString(request); + + // When & Then + mockMvc.perform(post("/api/v1/onboarding/complete") + .header("X-User-Id", testUser.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("온보딩 완료 - 관심사 필수 검증") + void completeOnboarding_EmptyInterests_BadRequest() throws Exception { + // Given + OnboardingRequest request = new OnboardingRequest( + "테크포크유저", + "user@techfork.com", + null, + List.of() // 빈 관심사 목록 + ); + + String requestBody = objectMapper.writeValueAsString(request); + + // When & Then + mockMvc.perform(post("/api/v1/onboarding/complete") + .header("X-User-Id", testUser.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("온보딩 완료 - description 길이 검증 (100자 이하)") + void completeOnboarding_DescriptionTooLong_BadRequest() throws Exception { + // Given + String longDescription = "a".repeat(101); // 101자 + + OnboardingRequest request = new OnboardingRequest( + "테크포크유저", + "user@techfork.com", + longDescription, + List.of( + UserInterestDto.builder() + .category("BACKEND") + .keywords(List.of("JAVA")) + .build() + ) + ); + + String requestBody = objectMapper.writeValueAsString(request); + + // When & Then + mockMvc.perform(post("/api/v1/onboarding/complete") + .header("X-User-Id", testUser.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("온보딩 완료 - 여러 카테고리와 키워드 조합") + void completeOnboarding_MultipleCategories_Success() throws Exception { + // Given + List interests = List.of( + UserInterestDto.builder() + .category("BACKEND") + .keywords(List.of("JAVA", "SPRING", "PYTHON")) + .build(), + UserInterestDto.builder() + .category("DEVOPS") + .keywords(List.of("DOCKER", "KUBERNETES")) + .build(), + UserInterestDto.builder() + .category("DATABASE") + .keywords(List.of("MYSQL", "POSTGRESQL", "REDIS")) + .build() + ); + + OnboardingRequest request = new OnboardingRequest( + "풀스택개발자", + "fullstack@techfork.com", + "백엔드와 인프라를 다룹니다", + interests + ); + + String requestBody = objectMapper.writeValueAsString(request); + + // When & Then + mockMvc.perform(post("/api/v1/onboarding/complete") + .header("X-User-Id", testUser.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.isSuccess").value(true)); + + User savedUser = userRepository.findByIdWithInterestCategories(testUser.getId()).orElseThrow(); + assertThat(savedUser.getInterestCategories()).hasSize(3); + } +} diff --git a/src/test/java/com/techfork/domain/user/repository/UserInterestCategoryRepositoryTest.java b/src/test/java/com/techfork/domain/user/repository/UserInterestCategoryRepositoryTest.java new file mode 100644 index 0000000..bcba65a --- /dev/null +++ b/src/test/java/com/techfork/domain/user/repository/UserInterestCategoryRepositoryTest.java @@ -0,0 +1,206 @@ +package com.techfork.domain.user.repository; + +import com.techfork.domain.user.entity.User; +import com.techfork.domain.user.entity.UserInterestCategory; +import com.techfork.domain.user.entity.UserInterestKeyword; +import com.techfork.domain.user.enums.EInterestCategory; +import com.techfork.domain.user.enums.EInterestKeyword; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * UserInterestCategoryRepository 테스트 + */ +@DataJpaTest +@ActiveProfiles("test") +class UserInterestCategoryRepositoryTest { + + @Autowired + private UserInterestCategoryRepository userInterestCategoryRepository; + + @Autowired + private UserRepository userRepository; + + private User testUser; + + @BeforeEach + void setUp() { + testUser = User.create(); + testUser = userRepository.save(testUser); + } + + @Test + @DisplayName("findByUserIdWithKeywords - 키워드와 함께 조회 성공") + void findByUserIdWithKeywords_Success() { + // Given: 카테고리와 키워드 생성 + UserInterestCategory backendCategory = UserInterestCategory.create(testUser, EInterestCategory.BACKEND); + UserInterestKeyword javaKeyword = UserInterestKeyword.create(backendCategory, EInterestKeyword.JAVA); + UserInterestKeyword springKeyword = UserInterestKeyword.create(backendCategory, EInterestKeyword.SPRING); + backendCategory.addKeyword(javaKeyword); + backendCategory.addKeyword(springKeyword); + + UserInterestCategory databaseCategory = UserInterestCategory.create(testUser, EInterestCategory.DATABASE); + UserInterestKeyword mysqlKeyword = UserInterestKeyword.create(databaseCategory, EInterestKeyword.MYSQL); + databaseCategory.addKeyword(mysqlKeyword); + + userInterestCategoryRepository.saveAll(List.of(backendCategory, databaseCategory)); + + // When: fetch join으로 키워드와 함께 조회 + List categories = userInterestCategoryRepository.findByUserIdWithKeywords(testUser.getId()); + + // Then + assertThat(categories).hasSize(2); + + UserInterestCategory backend = categories.stream() + .filter(c -> c.getCategory() == EInterestCategory.BACKEND) + .findFirst() + .orElseThrow(); + assertThat(backend.getKeywords()).hasSize(2); + assertThat(backend.getKeywords()) + .extracting(UserInterestKeyword::getKeyword) + .containsExactlyInAnyOrder(EInterestKeyword.JAVA, EInterestKeyword.SPRING); + + UserInterestCategory database = categories.stream() + .filter(c -> c.getCategory() == EInterestCategory.DATABASE) + .findFirst() + .orElseThrow(); + assertThat(database.getKeywords()).hasSize(1); + assertThat(database.getKeywords().get(0).getKeyword()).isEqualTo(EInterestKeyword.MYSQL); + } + + @Test + @DisplayName("findByUserIdWithKeywords - 키워드 없는 카테고리도 조회") + void findByUserIdWithKeywords_CategoryWithoutKeywords_Success() { + // Given: 키워드 없이 카테고리만 생성 + UserInterestCategory aiCategory = UserInterestCategory.create(testUser, EInterestCategory.AI_ML); + userInterestCategoryRepository.save(aiCategory); + + // When + List categories = userInterestCategoryRepository.findByUserIdWithKeywords(testUser.getId()); + + // Then + assertThat(categories).hasSize(1); + assertThat(categories.get(0).getCategory()).isEqualTo(EInterestCategory.AI_ML); + assertThat(categories.get(0).getKeywords()).isEmpty(); + } + + @Test + @DisplayName("findByUserIdWithKeywords - 관심사가 없으면 빈 리스트 반환") + void findByUserIdWithKeywords_NoInterests_ReturnsEmpty() { + // When: 관심사가 없는 유저 조회 + List categories = userInterestCategoryRepository.findByUserIdWithKeywords(testUser.getId()); + + // Then + assertThat(categories).isEmpty(); + } + + @Test + @DisplayName("findByUserIdWithKeywords - N+1 문제 없이 조회 (fetch join)") + void findByUserIdWithKeywords_NoNPlusOne() { + // Given: 여러 카테고리와 키워드 생성 + UserInterestCategory backendCategory = UserInterestCategory.create(testUser, EInterestCategory.BACKEND); + backendCategory.addKeyword(UserInterestKeyword.create(backendCategory, EInterestKeyword.JAVA)); + backendCategory.addKeyword(UserInterestKeyword.create(backendCategory, EInterestKeyword.SPRING)); + backendCategory.addKeyword(UserInterestKeyword.create(backendCategory, EInterestKeyword.PYTHON)); + + UserInterestCategory frontendCategory = UserInterestCategory.create(testUser, EInterestCategory.FRONTEND); + frontendCategory.addKeyword(UserInterestKeyword.create(frontendCategory, EInterestKeyword.REACT)); + frontendCategory.addKeyword(UserInterestKeyword.create(frontendCategory, EInterestKeyword.TYPESCRIPT)); + + userInterestCategoryRepository.saveAll(List.of(backendCategory, frontendCategory)); + + // When: fetch join으로 한 번에 조회 + List categories = userInterestCategoryRepository.findByUserIdWithKeywords(testUser.getId()); + + // Then: 추가 쿼리 없이 키워드 접근 가능 (N+1 없음) + assertThat(categories).hasSize(2); + categories.forEach(category -> { + // keywords 컬렉션이 이미 초기화되어 있어야 함 + assertThat(category.getKeywords()).isNotNull(); + }); + + long totalKeywords = categories.stream() + .mapToLong(c -> c.getKeywords().size()) + .sum(); + assertThat(totalKeywords).isEqualTo(5); + } + + @Test + @DisplayName("findByUserIdWithKeywords - DISTINCT로 중복 제거") + void findByUserIdWithKeywords_DistinctCategories() { + // Given: 키워드가 여러 개인 카테고리 + UserInterestCategory category = UserInterestCategory.create(testUser, EInterestCategory.DEVOPS); + category.addKeyword(UserInterestKeyword.create(category, EInterestKeyword.DOCKER)); + category.addKeyword(UserInterestKeyword.create(category, EInterestKeyword.KUBERNETES)); + category.addKeyword(UserInterestKeyword.create(category, EInterestKeyword.CI_CD)); + userInterestCategoryRepository.save(category); + + // When + List categories = userInterestCategoryRepository.findByUserIdWithKeywords(testUser.getId()); + + // Then: 카테고리는 1개만 (DISTINCT 효과) + assertThat(categories).hasSize(1); + assertThat(categories.get(0).getKeywords()).hasSize(3); + } + + @Test + @DisplayName("findByUserIdWithKeywords - 다른 유저의 관심사는 조회되지 않음") + void findByUserIdWithKeywords_OnlyOwnInterests() { + // Given: 두 번째 유저와 관심사 생성 + User anotherUser = User.create(); + anotherUser = userRepository.save(anotherUser); + + UserInterestCategory testUserCategory = UserInterestCategory.create(testUser, EInterestCategory.BACKEND); + UserInterestCategory anotherUserCategory = UserInterestCategory.create(anotherUser, EInterestCategory.FRONTEND); + userInterestCategoryRepository.saveAll(List.of(testUserCategory, anotherUserCategory)); + + // When: testUser의 관심사만 조회 + List categories = userInterestCategoryRepository.findByUserIdWithKeywords(testUser.getId()); + + // Then + assertThat(categories).hasSize(1); + assertThat(categories.get(0).getCategory()).isEqualTo(EInterestCategory.BACKEND); + } + + @Test + @DisplayName("cascade 테스트 - 카테고리 삭제 시 키워드도 함께 삭제") + void cascade_DeleteCategory_DeletesKeywords() { + // Given + UserInterestCategory category = UserInterestCategory.create(testUser, EInterestCategory.BACKEND); + category.addKeyword(UserInterestKeyword.create(category, EInterestKeyword.JAVA)); + category.addKeyword(UserInterestKeyword.create(category, EInterestKeyword.SPRING)); + category = userInterestCategoryRepository.save(category); + + Long categoryId = category.getId(); + + // When: 카테고리 삭제 + userInterestCategoryRepository.delete(category); + userInterestCategoryRepository.flush(); + + // Then: 카테고리와 키워드 모두 삭제됨 + assertThat(userInterestCategoryRepository.findById(categoryId)).isEmpty(); + } + + @Test + @DisplayName("양방향 매핑 - 키워드 추가 시 양쪽 관계 설정") + void bidirectionalMapping_AddKeyword_SetsBothSides() { + // Given + UserInterestCategory category = UserInterestCategory.create(testUser, EInterestCategory.BACKEND); + UserInterestKeyword keyword = UserInterestKeyword.create(category, EInterestKeyword.JAVA); + + // When: addKeyword 메서드 사용 + category.addKeyword(keyword); + + // Then: 양방향 관계 설정됨 + assertThat(category.getKeywords()).contains(keyword); + assertThat(keyword.getUserInterestCategory()).isEqualTo(category); + } +} diff --git a/src/test/java/com/techfork/domain/user/repository/UserRepositoryTest.java b/src/test/java/com/techfork/domain/user/repository/UserRepositoryTest.java new file mode 100644 index 0000000..a0f0f14 --- /dev/null +++ b/src/test/java/com/techfork/domain/user/repository/UserRepositoryTest.java @@ -0,0 +1,258 @@ +package com.techfork.domain.user.repository; + +import com.techfork.domain.activity.entity.ReadPost; +import com.techfork.domain.activity.entity.ScrabPost; +import com.techfork.domain.activity.entity.SearchHistory; +import com.techfork.domain.activity.repository.ReadPostRepository; +import com.techfork.domain.activity.repository.ScrabPostRepository; +import com.techfork.domain.activity.repository.SearchHistoryRepository; +import com.techfork.domain.post.entity.Post; +import com.techfork.domain.post.repository.PostRepository; +import com.techfork.domain.source.entity.TechBlog; +import com.techfork.domain.source.repository.TechBlogRepository; +import com.techfork.domain.user.entity.User; +import com.techfork.domain.user.entity.UserInterestCategory; +import com.techfork.domain.user.enums.EInterestCategory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * UserRepository 테스트 + */ +@DataJpaTest +@ActiveProfiles("test") +class UserRepositoryTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private ReadPostRepository readPostRepository; + + @Autowired + private ScrabPostRepository scrabPostRepository; + + @Autowired + private SearchHistoryRepository searchHistoryRepository; + + @Autowired + private PostRepository postRepository; + + @Autowired + private TechBlogRepository techBlogRepository; + + private User testUser; + private TechBlog testTechBlog; + + @BeforeEach + void setUp() { + testUser = User.create(); + testUser = userRepository.save(testUser); + + testTechBlog = TechBlog.builder() + .companyName("테스트 회사") + .blogUrl("https://test.com") + .rssUrl("https://test.com/rss") + .build(); + testTechBlog = techBlogRepository.save(testTechBlog); + } + + @Test + @DisplayName("findByIdWithInterestCategories - 관심사 카테고리와 함께 조회") + void findByIdWithInterestCategories_Success() { + // Given: 관심사 카테고리 추가 + UserInterestCategory category1 = UserInterestCategory.create(testUser, EInterestCategory.BACKEND); + UserInterestCategory category2 = UserInterestCategory.create(testUser, EInterestCategory.DATABASE); + testUser.getInterestCategories().add(category1); + testUser.getInterestCategories().add(category2); + userRepository.save(testUser); + + // When: fetch join으로 조회 + Optional result = userRepository.findByIdWithInterestCategories(testUser.getId()); + + // Then + assertThat(result).isPresent(); + User user = result.get(); + assertThat(user.getInterestCategories()).hasSize(2); + assertThat(user.getInterestCategories()) + .extracting(UserInterestCategory::getCategory) + .containsExactlyInAnyOrder(EInterestCategory.BACKEND, EInterestCategory.DATABASE); + } + + @Test + @DisplayName("findByIdWithInterestCategories - 관심사가 없어도 조회 성공") + void findByIdWithInterestCategories_NoInterests_Success() { + // When: 관심사 없는 유저 조회 + Optional result = userRepository.findByIdWithInterestCategories(testUser.getId()); + + // Then + assertThat(result).isPresent(); + assertThat(result.get().getInterestCategories()).isEmpty(); + } + + @Test + @DisplayName("findByIdWithInterestCategories - 존재하지 않는 유저는 Empty 반환") + void findByIdWithInterestCategories_NotFound_ReturnsEmpty() { + // When + Optional result = userRepository.findByIdWithInterestCategories(99999L); + + // Then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("findActiveUsersSince - 최근 읽은 포스트가 있는 유저 조회") + void findActiveUsersSince_WithReadPost_ReturnsActiveUsers() { + // Given + LocalDateTime since = LocalDateTime.now().minusDays(7); + + Post post = createPost(); + ReadPost readPost = ReadPost.create(testUser, post, LocalDateTime.now(), 100); + readPostRepository.save(readPost); + + // When: 최근 7일 이내 활동한 유저 조회 + List activeUsers = userRepository.findActiveUsersSince(since); + + // Then + assertThat(activeUsers).hasSize(1); + assertThat(activeUsers.get(0).getId()).isEqualTo(testUser.getId()); + } + + @Test + @DisplayName("findActiveUsersSince - 최근 스크랩한 포스트가 있는 유저 조회") + void findActiveUsersSince_WithScrapPost_ReturnsActiveUsers() { + // Given + LocalDateTime since = LocalDateTime.now().minusDays(7); + + Post post = createPost(); + ScrabPost scrabPost = ScrabPost.create(testUser, post, LocalDateTime.now()); + scrabPostRepository.save(scrabPost); + + // When + List activeUsers = userRepository.findActiveUsersSince(since); + + // Then + assertThat(activeUsers).hasSize(1); + assertThat(activeUsers.get(0).getId()).isEqualTo(testUser.getId()); + } + + @Test + @DisplayName("findActiveUsersSince - 최근 검색 기록이 있는 유저 조회") + void findActiveUsersSince_WithSearchHistory_ReturnsActiveUsers() { + // Given + LocalDateTime since = LocalDateTime.now().minusDays(7); + + SearchHistory searchHistory = SearchHistory.create(testUser, "테스트 검색", LocalDateTime.now()); + searchHistoryRepository.save(searchHistory); + + // When + List activeUsers = userRepository.findActiveUsersSince(since); + + // Then + assertThat(activeUsers).hasSize(1); + assertThat(activeUsers.get(0).getId()).isEqualTo(testUser.getId()); + } + + @Test + @DisplayName("findActiveUsersSince - 오래된 활동만 있으면 조회되지 않음") + void findActiveUsersSince_OldActivity_ReturnsEmpty() { + // Given + LocalDateTime since = LocalDateTime.now().minusDays(7); + LocalDateTime oldDate = LocalDateTime.now().minusDays(30); // 30일 전 + + Post post = createPost(); + ReadPost readPost = ReadPost.create(testUser, post, oldDate, 100); + readPostRepository.save(readPost); + + // When: 최근 7일 이내 활동한 유저만 조회 + List activeUsers = userRepository.findActiveUsersSince(since); + + // Then + assertThat(activeUsers).isEmpty(); + } + + @Test + @DisplayName("findActiveUsersSince - 여러 활동이 있으면 DISTINCT로 중복 제거") + void findActiveUsersSince_MultipleActivities_ReturnsDistinct() { + // Given: 같은 유저가 여러 활동 + LocalDateTime since = LocalDateTime.now().minusDays(7); + + Post post1 = createPost(); + Post post2 = createPost(); + + ReadPost readPost = ReadPost.create(testUser, post1, LocalDateTime.now(), 100); + ScrabPost scrabPost = ScrabPost.create(testUser, post2, LocalDateTime.now()); + SearchHistory searchHistory = SearchHistory.create(testUser, "검색", LocalDateTime.now()); + + readPostRepository.save(readPost); + scrabPostRepository.save(scrabPost); + searchHistoryRepository.save(searchHistory); + + // When + List activeUsers = userRepository.findActiveUsersSince(since); + + // Then: 중복 없이 1명만 조회 + assertThat(activeUsers).hasSize(1); + assertThat(activeUsers.get(0).getId()).isEqualTo(testUser.getId()); + } + + @Test + @DisplayName("findAllWithInterestCategoriesByIds - 여러 유저를 관심사와 함께 조회") + void findAllWithInterestCategoriesByIds_Success() { + // Given: 두 번째 유저 생성 + User user2 = User.create(); + user2 = userRepository.save(user2); + + UserInterestCategory category1 = UserInterestCategory.create(testUser, EInterestCategory.BACKEND); + UserInterestCategory category2 = UserInterestCategory.create(user2, EInterestCategory.FRONTEND); + testUser.getInterestCategories().add(category1); + user2.getInterestCategories().add(category2); + userRepository.saveAll(List.of(testUser, user2)); + + // When + List users = userRepository.findAllWithInterestCategoriesByIds( + List.of(testUser.getId(), user2.getId()) + ); + + // Then + assertThat(users).hasSize(2); + assertThat(users.get(0).getInterestCategories()).isNotEmpty(); + assertThat(users.get(1).getInterestCategories()).isNotEmpty(); + } + + @Test + @DisplayName("findAllWithInterestCategoriesByIds - 존재하지 않는 ID는 제외") + void findAllWithInterestCategoriesByIds_InvalidIds_FiltersOut() { + // When + List users = userRepository.findAllWithInterestCategoriesByIds( + List.of(testUser.getId(), 99999L) + ); + + // Then: 존재하는 유저만 조회 + assertThat(users).hasSize(1); + assertThat(users.get(0).getId()).isEqualTo(testUser.getId()); + } + + private Post createPost() { + return postRepository.save(Post.builder() + .title("테스트 포스트") + .fullContent("내용") + .plainContent("내용") + .company("테스트 회사") + .url("https://test.com/post/" + System.currentTimeMillis()) + .publishedAt(LocalDateTime.now()) + .crawledAt(LocalDateTime.now()) + .techBlog(testTechBlog) + .build()); + } +} diff --git a/src/test/java/com/techfork/domain/user/service/InterestCommandServiceTest.java b/src/test/java/com/techfork/domain/user/service/InterestCommandServiceTest.java new file mode 100644 index 0000000..ba91b80 --- /dev/null +++ b/src/test/java/com/techfork/domain/user/service/InterestCommandServiceTest.java @@ -0,0 +1,233 @@ +package com.techfork.domain.user.service; + +import com.techfork.domain.user.dto.SaveInterestRequest; +import com.techfork.domain.user.dto.UserInterestDto; +import com.techfork.domain.user.entity.User; +import com.techfork.domain.user.entity.UserInterestCategory; +import com.techfork.domain.user.exception.UserErrorCode; +import com.techfork.domain.user.repository.UserRepository; +import com.techfork.global.exception.GeneralException; +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.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +/** + * InterestCommandService 단위 테스트 + */ +@ExtendWith(MockitoExtension.class) +class InterestCommandServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private UserProfileService userProfileService; + + @InjectMocks + private InterestCommandService interestCommandService; + + @Test + @DisplayName("관심사 저장 - 정상 케이스") + void saveUserInterests_Success() { + // Given + User user = User.create(); + + List interests = List.of( + UserInterestDto.builder() + .category("BACKEND") + .keywords(List.of("JAVA", "SPRING")) + .build() + ); + + SaveInterestRequest request = new SaveInterestRequest(interests); + + // When + interestCommandService.saveUserInterests(user, request); + + // Then + assertThat(user.getInterestCategories()).hasSize(1); + assertThat(user.getInterestCategories().get(0).getKeywords()).hasSize(2); + + verify(userProfileService, times(1)).generateUserProfile(user.getId()); + } + + @Test + @DisplayName("관심사 저장 - 키워드 없이 카테고리만 저장") + void saveUserInterests_CategoryOnly_Success() { + // Given + User user = User.create(); + + List interests = List.of( + UserInterestDto.builder() + .category("AI_ML") + .keywords(null) + .build() + ); + + SaveInterestRequest request = new SaveInterestRequest(interests); + + // When + interestCommandService.saveUserInterests(user, request); + + // Then + assertThat(user.getInterestCategories()).hasSize(1); + assertThat(user.getInterestCategories().get(0).getKeywords()).isEmpty(); + + verify(userProfileService, times(1)).generateUserProfile(user.getId()); + } + + @Test + @DisplayName("관심사 저장 - 여러 카테고리와 키워드") + void saveUserInterests_MultipleCategories_Success() { + // Given + User user = User.create(); + + List interests = List.of( + UserInterestDto.builder() + .category("BACKEND") + .keywords(List.of("JAVA", "SPRING", "PYTHON")) + .build(), + UserInterestDto.builder() + .category("DATABASE") + .keywords(List.of("MYSQL", "REDIS")) + .build(), + UserInterestDto.builder() + .category("DEVOPS") + .keywords(List.of("DOCKER", "KUBERNETES", "CI_CD")) + .build() + ); + + SaveInterestRequest request = new SaveInterestRequest(interests); + + // When + interestCommandService.saveUserInterests(user, request); + + // Then + assertThat(user.getInterestCategories()).hasSize(3); + assertThat(user.getInterestCategories().get(0).getKeywords()).hasSize(3); + assertThat(user.getInterestCategories().get(1).getKeywords()).hasSize(2); + assertThat(user.getInterestCategories().get(2).getKeywords()).hasSize(3); + + verify(userProfileService, times(1)).generateUserProfile(user.getId()); + } + + @Test + @DisplayName("관심사 저장 - 기존 관심사를 clear하고 새로 저장") + void saveUserInterests_ClearExistingInterests_Success() { + // Given + User user = User.create(); + + // 기존 관심사 추가 + user.getInterestCategories().add(mock(UserInterestCategory.class)); + user.getInterestCategories().add(mock(UserInterestCategory.class)); + assertThat(user.getInterestCategories()).hasSize(2); + + List interests = List.of( + UserInterestDto.builder() + .category("FRONTEND") + .keywords(List.of("REACT")) + .build() + ); + + SaveInterestRequest request = new SaveInterestRequest(interests); + + // When + interestCommandService.saveUserInterests(user, request); + + // Then + assertThat(user.getInterestCategories()).hasSize(1); + + verify(userProfileService, times(1)).generateUserProfile(user.getId()); + } + + @Test + @DisplayName("관심사 저장 - 잘못된 카테고리와 키워드 조합이면 예외 발생") + void saveUserInterests_InvalidKeywordCategory_ThrowsException() { + // Given + User user = User.create(); + + // BACKEND 카테고리에 FRONTEND 키워드를 넣으려고 시도 + List interests = List.of( + UserInterestDto.builder() + .category("BACKEND") + .keywords(List.of("REACT")) // REACT는 FRONTEND 키워드 + .build() + ); + + SaveInterestRequest request = new SaveInterestRequest(interests); + + // When & Then + assertThatThrownBy(() -> interestCommandService.saveUserInterests(user, request)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("code", UserErrorCode.INVALID_INTEREST_KEYWORD); + + verify(userProfileService, never()).generateUserProfile(any()); + } + + @Test + @DisplayName("관심사 업데이트 - 정상 케이스") + void updateUserInterests_Success() { + // Given + Long userId = 1L; + User mockUser = User.create(); + ReflectionTestUtils.setField(mockUser, "id", userId); + + List interests = List.of( + UserInterestDto.builder() + .category("AI_ML") + .keywords(List.of("TENSORFLOW", "PYTORCH")) + .build() + ); + + SaveInterestRequest request = new SaveInterestRequest(interests); + + given(userRepository.findByIdWithInterestCategories(userId)) + .willReturn(Optional.of(mockUser)); + + // When + interestCommandService.updateUserInterests(userId, request); + + // Then + assertThat(mockUser.getInterestCategories()).hasSize(1); + + verify(userRepository, times(1)).findByIdWithInterestCategories(userId); + verify(userProfileService, times(1)).generateUserProfile(userId); + } + + @Test + @DisplayName("관심사 업데이트 - 사용자가 존재하지 않으면 예외 발생") + void updateUserInterests_UserNotFound_ThrowsException() { + // Given + Long userId = 999L; + SaveInterestRequest request = new SaveInterestRequest( + List.of( + UserInterestDto.builder() + .category("BACKEND") + .keywords(List.of("JAVA")) + .build() + ) + ); + + given(userRepository.findByIdWithInterestCategories(userId)) + .willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> interestCommandService.updateUserInterests(userId, request)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("code", UserErrorCode.USER_NOT_FOUND); + + verify(userProfileService, never()).generateUserProfile(any()); + } +} diff --git a/src/test/java/com/techfork/domain/user/service/UserCommandServiceTest.java b/src/test/java/com/techfork/domain/user/service/UserCommandServiceTest.java new file mode 100644 index 0000000..11fbb6a --- /dev/null +++ b/src/test/java/com/techfork/domain/user/service/UserCommandServiceTest.java @@ -0,0 +1,182 @@ +package com.techfork.domain.user.service; + +import com.techfork.domain.user.dto.OnboardingRequest; +import com.techfork.domain.user.dto.SaveInterestRequest; +import com.techfork.domain.user.dto.UserInterestDto; +import com.techfork.domain.user.entity.User; +import com.techfork.domain.user.exception.UserErrorCode; +import com.techfork.domain.user.repository.UserRepository; +import com.techfork.global.exception.GeneralException; +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 java.util.List; +import java.util.Optional; + +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.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +/** + * UserCommandService 단위 테스트 + */ +@ExtendWith(MockitoExtension.class) +class UserCommandServiceTest { + + @Mock + private InterestCommandService interestCommandService; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private UserCommandService userCommandService; + + @Test + @DisplayName("온보딩 완료 - 정상 케이스") + void completeOnboarding_Success() { + // Given + Long userId = 1L; + User mockUser = User.create(); + + List interests = List.of( + UserInterestDto.builder() + .category("BACKEND") + .keywords(List.of("JAVA", "SPRING")) + .build(), + UserInterestDto.builder() + .category("DATABASE") + .keywords(List.of("MYSQL", "REDIS")) + .build() + ); + + OnboardingRequest request = new OnboardingRequest( + "테크포크유저", + "user@techfork.com", + "백엔드 개발자입니다", + interests + ); + + given(userRepository.findByIdWithInterestCategories(userId)) + .willReturn(Optional.of(mockUser)); + + // When + userCommandService.completeOnboarding(userId, request); + + // Then + assertThat(mockUser.getNickName()).isEqualTo("테크포크유저"); + assertThat(mockUser.getEmail()).isEqualTo("user@techfork.com"); + assertThat(mockUser.getDescription()).isEqualTo("백엔드 개발자입니다"); + + verify(userRepository, times(1)).findByIdWithInterestCategories(userId); + verify(interestCommandService, times(1)).saveUserInterests(eq(mockUser), any(SaveInterestRequest.class)); + } + + @Test + @DisplayName("온보딩 완료 - 사용자가 존재하지 않으면 예외 발생") + void completeOnboarding_UserNotFound_ThrowsException() { + // Given + Long userId = 999L; + OnboardingRequest request = new OnboardingRequest( + "테크포크유저", + "user@techfork.com", + null, + List.of( + UserInterestDto.builder() + .category("BACKEND") + .keywords(List.of("JAVA")) + .build() + ) + ); + + given(userRepository.findByIdWithInterestCategories(userId)) + .willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> userCommandService.completeOnboarding(userId, request)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("code", UserErrorCode.USER_NOT_FOUND); + + verify(interestCommandService, never()).saveUserInterests(any(), any()); + } + + @Test + @DisplayName("온보딩 완료 - description이 null이어도 정상 처리") + void completeOnboarding_NullDescription_Success() { + // Given + Long userId = 1L; + User mockUser = User.create(); + + OnboardingRequest request = new OnboardingRequest( + "테크포크유저", + "user@techfork.com", + null, + List.of( + UserInterestDto.builder() + .category("FRONTEND") + .keywords(List.of("REACT", "TYPESCRIPT")) + .build() + ) + ); + + given(userRepository.findByIdWithInterestCategories(userId)) + .willReturn(Optional.of(mockUser)); + + // When + userCommandService.completeOnboarding(userId, request); + + // Then + assertThat(mockUser.getNickName()).isEqualTo("테크포크유저"); + assertThat(mockUser.getEmail()).isEqualTo("user@techfork.com"); + assertThat(mockUser.getDescription()).isNull(); + + verify(interestCommandService, times(1)).saveUserInterests(eq(mockUser), any(SaveInterestRequest.class)); + } + + @Test + @DisplayName("온보딩 완료 - 여러 카테고리와 키워드 조합") + void completeOnboarding_MultipleCategories_Success() { + // Given + Long userId = 1L; + User mockUser = User.create(); + + List interests = List.of( + UserInterestDto.builder() + .category("BACKEND") + .keywords(List.of("JAVA", "SPRING", "PYTHON")) + .build(), + UserInterestDto.builder() + .category("DEVOPS") + .keywords(List.of("DOCKER", "KUBERNETES")) + .build(), + UserInterestDto.builder() + .category("DATABASE") + .keywords(List.of("MYSQL", "POSTGRESQL", "REDIS")) + .build() + ); + + OnboardingRequest request = new OnboardingRequest( + "풀스택개발자", + "fullstack@techfork.com", + "백엔드와 인프라를 다룹니다", + interests + ); + + given(userRepository.findByIdWithInterestCategories(userId)) + .willReturn(Optional.of(mockUser)); + + // When + userCommandService.completeOnboarding(userId, request); + + // Then + assertThat(mockUser.getNickName()).isEqualTo("풀스택개발자"); + verify(interestCommandService, times(1)).saveUserInterests(eq(mockUser), any(SaveInterestRequest.class)); + } +}