diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/onboarding/PersistedOnboardingProfileReader.java b/src/main/java/com/hansung/tracktory/domain/recommendation/onboarding/PersistedOnboardingProfileReader.java new file mode 100644 index 0000000..3ffac8f --- /dev/null +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/onboarding/PersistedOnboardingProfileReader.java @@ -0,0 +1,112 @@ +package com.hansung.tracktory.domain.recommendation.onboarding; + +import com.hansung.tracktory.domain.catalog.organization.entity.Department; +import com.hansung.tracktory.domain.profile.entity.UserCompletedSubject; +import com.hansung.tracktory.domain.profile.entity.UserProfile; +import com.hansung.tracktory.domain.profile.repository.UserCompanyTypeRepository; +import com.hansung.tracktory.domain.profile.repository.UserCompletedSubjectRepository; +import com.hansung.tracktory.domain.profile.repository.UserDevFieldRepository; +import com.hansung.tracktory.domain.profile.repository.UserInterestRepository; +import com.hansung.tracktory.domain.profile.repository.UserProfileRepository; +import com.hansung.tracktory.domain.profile.repository.UserTrackRepository; +import com.hansung.tracktory.domain.profile.repository.UserWorkValueRepository; +import com.hansung.tracktory.domain.recommendation.onboarding.OnboardingProfileSnapshot.CompletedCourse; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 저장된 온보딩 프로필을 추천 입력 스냅샷으로 변환한다. + * + *

추천 도메인은 본 reader 를 통해서만 프로필 도메인의 영속 모델을 읽는다. 트랙을 아직 선택하지 않은 사용자도 온보딩 프로필이 있으면 빈 트랙 목록을 그대로 AI + * 입력으로 전달해, "선택 없음" 과 "선택 있음" 을 구분한다. + */ +@Component +@RequiredArgsConstructor +public class PersistedOnboardingProfileReader implements OnboardingProfileReader { + + private static final int DEFAULT_ADMISSION_YEAR_UNTIL_PROFILE_SCHEMA_SUPPORTS_IT = 2023; + + private final UserProfileRepository userProfileRepository; + private final UserTrackRepository userTrackRepository; + private final UserInterestRepository userInterestRepository; + private final UserDevFieldRepository userDevFieldRepository; + private final UserWorkValueRepository userWorkValueRepository; + private final UserCompanyTypeRepository userCompanyTypeRepository; + private final UserCompletedSubjectRepository userCompletedSubjectRepository; + + @Override + @Transactional(readOnly = true) + public Optional read(Long userId) { + return userProfileRepository.findByUserId(userId).map(profile -> toSnapshot(userId, profile)); + } + + private OnboardingProfileSnapshot toSnapshot(Long userId, UserProfile profile) { + Department department = profile.getDepartment(); + return new OnboardingProfileSnapshot( + userId, + DEFAULT_ADMISSION_YEAR_UNTIL_PROFILE_SCHEMA_SUPPORTS_IT, + department.getCollege().getName(), + department.getName(), + currentSemester(profile.getCurrentYear()), + trackCodes(userId), + interestCodes(userId), + devFieldCodes(userId), + workValueCodes(userId), + companyTypeCodes(userId), + List.of(), + completedCourses(userId)); + } + + private Integer currentSemester(Integer currentYear) { + if (currentYear == null) { + return null; + } + return Math.max(1, currentYear * 2 - 1); + } + + private List trackCodes(Long userId) { + return userTrackRepository.findByUserIdOrderByTrackOrderAsc(userId).stream() + .map(userTrack -> userTrack.getTrack().getCode()) + .toList(); + } + + private List interestCodes(Long userId) { + return userInterestRepository.findByUserIdOrderByIdAsc(userId).stream() + .map(userInterest -> userInterest.getInterest().getCode()) + .toList(); + } + + private List devFieldCodes(Long userId) { + return userDevFieldRepository.findByUserIdOrderByIdAsc(userId).stream() + .map(userDevField -> userDevField.getDevField().getCode()) + .toList(); + } + + private List workValueCodes(Long userId) { + return userWorkValueRepository.findByUserIdOrderByIdAsc(userId).stream() + .map(userWorkValue -> userWorkValue.getWorkValue().getCode()) + .toList(); + } + + private List companyTypeCodes(Long userId) { + return userCompanyTypeRepository.findByUserIdOrderByIdAsc(userId).stream() + .map(userCompanyType -> userCompanyType.getCompanyType().getCode()) + .toList(); + } + + private List completedCourses(Long userId) { + return userCompletedSubjectRepository.findByUserIdOrderByYearAscSemesterAsc(userId).stream() + .map(this::completedCourse) + .toList(); + } + + private CompletedCourse completedCourse(UserCompletedSubject completedSubject) { + return new CompletedCourse( + completedSubject.getSubject().getCode(), + completedSubject.getYear(), + completedSubject.getSemester()); + } +} diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/onboarding/SeedOnboardingProfileReader.java b/src/main/java/com/hansung/tracktory/domain/recommendation/onboarding/SeedOnboardingProfileReader.java deleted file mode 100644 index cbcfc55..0000000 --- a/src/main/java/com/hansung/tracktory/domain/recommendation/onboarding/SeedOnboardingProfileReader.java +++ /dev/null @@ -1,147 +0,0 @@ -package com.hansung.tracktory.domain.recommendation.onboarding; - -import com.hansung.tracktory.domain.catalog.classification.entity.CompanyType; -import com.hansung.tracktory.domain.catalog.classification.entity.DevField; -import com.hansung.tracktory.domain.catalog.classification.entity.Interest; -import com.hansung.tracktory.domain.catalog.classification.entity.WorkValue; -import com.hansung.tracktory.domain.catalog.classification.repository.CompanyTypeRepository; -import com.hansung.tracktory.domain.catalog.classification.repository.DevFieldRepository; -import com.hansung.tracktory.domain.catalog.classification.repository.InterestRepository; -import com.hansung.tracktory.domain.catalog.classification.repository.WorkValueRepository; -import com.hansung.tracktory.domain.catalog.curriculum.entity.Subject; -import com.hansung.tracktory.domain.catalog.curriculum.repository.SubjectRepository; -import com.hansung.tracktory.domain.catalog.organization.entity.Department; -import com.hansung.tracktory.domain.catalog.organization.entity.Track; -import com.hansung.tracktory.domain.catalog.organization.repository.TrackRepository; -import com.hansung.tracktory.domain.recommendation.onboarding.OnboardingProfileSnapshot.CompletedCourse; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.function.Function; -import java.util.function.Supplier; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -/** - * 온보딩 영속 도메인이 준비되기 전까지 사용하는 임시 시드 구현. - * - *

적재된 카탈로그 기준 데이터에서 그럴듯한 프로필 한 건을 합성한다. 반환하는 모든 분류·트랙·과목 코드는 실제 카탈로그에 존재하므로 추천 파이프라인의 코드 → 카탈로그 - * 매핑이 성립한다. 트랙 두 개를 합성하지 못할 만큼 카탈로그가 비어 있으면 {@link Optional#empty()} 를 반환해 온보딩 미완료로 처리되게 한다. 온보딩 - * 도메인이 완성되면 본 구현을 영속 기반 reader 로 교체한다. - */ -@Component -@RequiredArgsConstructor -public class SeedOnboardingProfileReader implements OnboardingProfileReader { - - private static final int SEED_ADMISSION_YEAR = 2023; - private static final int SEED_CURRENT_SEMESTER = 3; - - // 이수 과목은 현재 학기(3학기) 이전 학기에만 놓여야 한다. completed=true 인 과목이 현재 학기 이후로 - // 배치되면 (a) 같은 (year,semester) 학기 카드가 로드맵에서 중복되고 (b) completed=true 인데 timing 이 - // FUTURE 가 되는 모순이 발생한다. 1학년 1·2학기 두 칸만 사용한다. - private static final int[][] COMPLETED_SCHEDULE = {{1, 1}, {1, 2}}; - - // MVP 타겟(컴퓨터공학부 빅데이터)을 mirror 한 데모 프로필. 모두 카탈로그에 존재하는 코드이며, - // 1트랙(빅데이터트랙)이 주전공 소속이라 home 단과대/학부가 자연히 IT공과대학/컴퓨터공학부로 잡힌다. - // 코드가 누락된 환경에서는 카탈로그 선두 항목으로 graceful fallback 한다. - private static final List SEED_TRACK_CODES = List.of("빅데이터트랙", "AIㆍ소프트웨어학과"); - private static final List SEED_INTEREST_CODES = List.of("IT/인터넷", "연구개발/설계"); - private static final List SEED_DEV_FIELD_CODES = List.of("데이터"); - private static final List SEED_COMPANY_TYPE_CODES = List.of("대기업"); - private static final List SEED_WORK_VALUE_CODES = List.of("성장성"); - - // 이수 과목은 컴퓨터공학부 기초 과목으로 합성한다(COMPLETED_SCHEDULE 와 순서·개수 정합). - // 카탈로그 선두 4개 과목은 패션(계약학과) 소속이라 데모 프로필의 학과와 무관하므로 코드로 직접 지정한다. - private static final List SEED_COMPLETED_COURSE_CODES = List.of("CTE0001", "W080002"); - - private final TrackRepository trackRepository; - private final SubjectRepository subjectRepository; - private final InterestRepository interestRepository; - private final DevFieldRepository devFieldRepository; - private final CompanyTypeRepository companyTypeRepository; - private final WorkValueRepository workValueRepository; - - @Override - @Transactional(readOnly = true) - public Optional read(Long userId) { - List tracks = resolveTracks(); - List interests = - resolveByCodes( - SEED_INTEREST_CODES, - interestRepository::findByCode, - () -> interestRepository.findAll(PageRequest.of(0, 2)).getContent()); - List devFields = - resolveByCodes( - SEED_DEV_FIELD_CODES, - devFieldRepository::findByCode, - () -> devFieldRepository.findAll(PageRequest.of(0, 1)).getContent()); - List companyTypes = - resolveByCodes( - SEED_COMPANY_TYPE_CODES, - companyTypeRepository::findByCode, - () -> companyTypeRepository.findAll(PageRequest.of(0, 1)).getContent()); - List workValues = - resolveByCodes( - SEED_WORK_VALUE_CODES, - workValueRepository::findByCode, - () -> workValueRepository.findAll(PageRequest.of(0, 1)).getContent()); - List subjects = - resolveByCodes( - SEED_COMPLETED_COURSE_CODES, - subjectRepository::findByCode, - () -> subjectRepository.findAll(PageRequest.of(0, 2)).getContent()); - - // AI 중계 서버는 정확히 0개 또는 2개 트랙을 요구한다. 시드는 2개 모드를 합성한다. - if (tracks.size() < 2 || interests.isEmpty() || devFields.isEmpty() || companyTypes.isEmpty()) { - return Optional.empty(); - } - - Department homeDepartment = tracks.get(0).getDepartment(); - OnboardingProfileSnapshot snapshot = - new OnboardingProfileSnapshot( - userId, - SEED_ADMISSION_YEAR, - homeDepartment.getCollege().getName(), - homeDepartment.getName(), - SEED_CURRENT_SEMESTER, - tracks.stream().map(Track::getCode).toList(), - interests.stream().map(Interest::getCode).toList(), - devFields.stream().map(DevField::getCode).toList(), - workValues.stream().map(WorkValue::getCode).toList(), - companyTypes.stream().map(CompanyType::getCode).toList(), - List.of(), - toCompletedCourses(subjects)); - return Optional.of(snapshot); - } - - private List resolveTracks() { - List resolved = new ArrayList<>(); - for (String code : SEED_TRACK_CODES) { - trackRepository.findByCode(code).ifPresent(resolved::add); - } - if (resolved.size() >= 2) { - return resolved.subList(0, 2); - } - return trackRepository.findAll(PageRequest.of(0, 2)).getContent(); - } - - private List resolveByCodes( - List codes, Function> finder, Supplier> fallback) { - List resolved = new ArrayList<>(); - for (String code : codes) { - finder.apply(code).ifPresent(resolved::add); - } - return resolved.isEmpty() ? fallback.get() : resolved; - } - - private List toCompletedCourses(List subjects) { - List completed = new ArrayList<>(); - for (int i = 0; i < subjects.size() && i < COMPLETED_SCHEDULE.length; i++) { - int[] slot = COMPLETED_SCHEDULE[i]; - completed.add(new CompletedCourse(subjects.get(i).getCode(), slot[0], slot[1])); - } - return completed; - } -} diff --git a/src/test/java/com/hansung/tracktory/domain/recommendation/onboarding/PersistedOnboardingProfileReaderTest.java b/src/test/java/com/hansung/tracktory/domain/recommendation/onboarding/PersistedOnboardingProfileReaderTest.java new file mode 100644 index 0000000..cb6c549 --- /dev/null +++ b/src/test/java/com/hansung/tracktory/domain/recommendation/onboarding/PersistedOnboardingProfileReaderTest.java @@ -0,0 +1,140 @@ +package com.hansung.tracktory.domain.recommendation.onboarding; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.hansung.tracktory.domain.catalog.classification.entity.CompanyType; +import com.hansung.tracktory.domain.catalog.classification.entity.DevField; +import com.hansung.tracktory.domain.catalog.classification.entity.Interest; +import com.hansung.tracktory.domain.catalog.classification.entity.WorkValue; +import com.hansung.tracktory.domain.catalog.curriculum.entity.Subject; +import com.hansung.tracktory.domain.catalog.organization.entity.College; +import com.hansung.tracktory.domain.catalog.organization.entity.Department; +import com.hansung.tracktory.domain.catalog.organization.entity.Track; +import com.hansung.tracktory.domain.profile.entity.UserCompanyType; +import com.hansung.tracktory.domain.profile.entity.UserCompletedSubject; +import com.hansung.tracktory.domain.profile.entity.UserDevField; +import com.hansung.tracktory.domain.profile.entity.UserInterest; +import com.hansung.tracktory.domain.profile.entity.UserProfile; +import com.hansung.tracktory.domain.profile.entity.UserTrack; +import com.hansung.tracktory.domain.profile.entity.UserWorkValue; +import com.hansung.tracktory.domain.profile.repository.UserCompanyTypeRepository; +import com.hansung.tracktory.domain.profile.repository.UserCompletedSubjectRepository; +import com.hansung.tracktory.domain.profile.repository.UserDevFieldRepository; +import com.hansung.tracktory.domain.profile.repository.UserInterestRepository; +import com.hansung.tracktory.domain.profile.repository.UserProfileRepository; +import com.hansung.tracktory.domain.profile.repository.UserTrackRepository; +import com.hansung.tracktory.domain.profile.repository.UserWorkValueRepository; +import com.hansung.tracktory.domain.user.entity.User; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class PersistedOnboardingProfileReaderTest { + + @InjectMocks private PersistedOnboardingProfileReader reader; + + @Mock private UserProfileRepository userProfileRepository; + @Mock private UserTrackRepository userTrackRepository; + @Mock private UserInterestRepository userInterestRepository; + @Mock private UserDevFieldRepository userDevFieldRepository; + @Mock private UserWorkValueRepository userWorkValueRepository; + @Mock private UserCompanyTypeRepository userCompanyTypeRepository; + @Mock private UserCompletedSubjectRepository userCompletedSubjectRepository; + + @Test + void read_notOnboarded_returnsEmptyWithoutReadingCollections() { + given(userProfileRepository.findByUserId(1L)).willReturn(Optional.empty()); + + Optional result = reader.read(1L); + + assertThat(result).isEmpty(); + verify(userTrackRepository, never()).findByUserIdOrderByTrackOrderAsc(1L); + verify(userCompletedSubjectRepository, never()).findByUserIdOrderByYearAscSemesterAsc(1L); + } + + @Test + void read_persistedProfile_mapsUserSpecificSelections() { + User user = sampleUser(); + Department department = sampleDepartment(); + UserProfile profile = UserProfile.of(user, department, 3, "이재원"); + Track primary = Track.builder().code("BIGDATA").department(department).name("빅데이터트랙").build(); + Track secondary = Track.builder().code("WEB").department(department).name("웹공학트랙").build(); + Subject completed = Subject.builder().code("W080001").name("프로그래밍기초").build(); + + given(userProfileRepository.findByUserId(1L)).willReturn(Optional.of(profile)); + given(userTrackRepository.findByUserIdOrderByTrackOrderAsc(1L)) + .willReturn(List.of(UserTrack.of(user, primary, 1), UserTrack.of(user, secondary, 2))); + given(userInterestRepository.findByUserIdOrderByIdAsc(1L)) + .willReturn(List.of(UserInterest.of(user, Interest.builder().code("IT/인터넷").build()))); + given(userDevFieldRepository.findByUserIdOrderByIdAsc(1L)) + .willReturn(List.of(UserDevField.of(user, DevField.builder().code("데이터").build()))); + given(userWorkValueRepository.findByUserIdOrderByIdAsc(1L)) + .willReturn(List.of(UserWorkValue.of(user, WorkValue.builder().code("성장성").build()))); + given(userCompanyTypeRepository.findByUserIdOrderByIdAsc(1L)) + .willReturn(List.of(UserCompanyType.of(user, CompanyType.builder().code("대기업").build()))); + given(userCompletedSubjectRepository.findByUserIdOrderByYearAscSemesterAsc(1L)) + .willReturn(List.of(UserCompletedSubject.of(user, completed, 1, 1))); + + OnboardingProfileSnapshot result = reader.read(1L).orElseThrow(); + + assertThat(result.userId()).isEqualTo(1L); + assertThat(result.admissionYear()).isEqualTo(2023); + assertThat(result.collegeName()).isEqualTo("IT공과대학"); + assertThat(result.departmentName()).isEqualTo("컴퓨터공학부"); + assertThat(result.currentSemester()).isEqualTo(5); + assertThat(result.trackCodes()).containsExactly("BIGDATA", "WEB"); + assertThat(result.interestCodes()).containsExactly("IT/인터넷"); + assertThat(result.devFieldCodes()).containsExactly("데이터"); + assertThat(result.workValueCodes()).containsExactly("성장성"); + assertThat(result.companyTypeCodes()).containsExactly("대기업"); + assertThat(result.completedCourses()) + .singleElement() + .satisfies( + course -> { + assertThat(course.subjectCode()).isEqualTo("W080001"); + assertThat(course.year()).isEqualTo(1); + assertThat(course.semester()).isEqualTo(1); + }); + } + + @Test + void read_profileWithoutTracks_returnsEmptyTrackCodes() { + User user = sampleUser(); + UserProfile profile = UserProfile.of(user, sampleDepartment(), 1, "이재원"); + + given(userProfileRepository.findByUserId(1L)).willReturn(Optional.of(profile)); + given(userTrackRepository.findByUserIdOrderByTrackOrderAsc(1L)).willReturn(List.of()); + given(userInterestRepository.findByUserIdOrderByIdAsc(1L)) + .willReturn(List.of(UserInterest.of(user, Interest.builder().code("IT/인터넷").build()))); + given(userDevFieldRepository.findByUserIdOrderByIdAsc(1L)).willReturn(List.of()); + given(userWorkValueRepository.findByUserIdOrderByIdAsc(1L)) + .willReturn(List.of(UserWorkValue.of(user, WorkValue.builder().code("성장성").build()))); + given(userCompanyTypeRepository.findByUserIdOrderByIdAsc(1L)) + .willReturn(List.of(UserCompanyType.of(user, CompanyType.builder().code("대기업").build()))); + given(userCompletedSubjectRepository.findByUserIdOrderByYearAscSemesterAsc(1L)) + .willReturn(List.of()); + + OnboardingProfileSnapshot result = reader.read(1L).orElseThrow(); + + assertThat(result.currentSemester()).isEqualTo(1); + assertThat(result.trackCodes()).isEmpty(); + assertThat(result.interestCodes()).containsExactly("IT/인터넷"); + } + + private static User sampleUser() { + return User.builder().email("a@b.com").passwordHash("hash").build(); + } + + private static Department sampleDepartment() { + College college = College.builder().name("IT공과대학").build(); + return Department.builder().college(college).name("컴퓨터공학부").build(); + } +} diff --git a/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationServiceTest.java b/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationServiceTest.java index d73f652..5ed7a92 100644 --- a/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationServiceTest.java +++ b/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationServiceTest.java @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import com.hansung.tracktory.domain.catalog.career.entity.Job; @@ -16,6 +17,7 @@ import com.hansung.tracktory.domain.catalog.organization.entity.Track; import com.hansung.tracktory.domain.catalog.organization.repository.TrackRepository; import com.hansung.tracktory.domain.recommendation.ai.AiRecommendClient; +import com.hansung.tracktory.domain.recommendation.ai.AiRecommendRequest; import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse; import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.CourseCoverageContribution; import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.CoverageAnalysis; @@ -244,6 +246,58 @@ void generate_forceRefresh_bypassesReuseAndCallsAi() { .findFirstByUser_IdAndStatusOrderByCreatedAtDesc(any(), any()); } + @Test + void generate_differentUserTracks_sendsDifferentCurrentTracksToAi() { + OnboardingProfileSnapshot bigdataUser = + sampleProfile(1L, List.of("BIGDATA", "WEB"), List.of("W080001")); + OnboardingProfileSnapshot designUser = + sampleProfile(2L, List.of("MEDIA", "VR"), List.of("CTA0014")); + + given(onboardingProfileReader.read(1L)).willReturn(Optional.of(bigdataUser)); + given(onboardingProfileReader.read(2L)).willReturn(Optional.of(designUser)); + given(aiRecommendClient.generate(any())).willReturn(emptyAiResponse()); + given(userRepository.findById(1L)).willReturn(Optional.of(sampleUser())); + given(userRepository.findById(2L)).willReturn(Optional.of(sampleUser())); + given(recommendationRepository.findByUser_IdAndStatus(1L, RecommendationStatus.ACTIVE)) + .willReturn(List.of()); + given(recommendationRepository.findByUser_IdAndStatus(2L, RecommendationStatus.ACTIVE)) + .willReturn(List.of()); + given(recommendationRepository.save(any(Recommendation.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + given(recommendationAssembler.assemble(any(Recommendation.class), any())) + .willReturn(sentinelResponse()); + + recommendationService.generate(1L, true); + recommendationService.generate(2L, true); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AiRecommendRequest.class); + verify(aiRecommendClient, times(2)).generate(captor.capture()); + + assertThat(captor.getAllValues()) + .extracting(AiRecommendRequest::currentTracks) + .containsExactly(List.of("BIGDATA", "WEB"), List.of("MEDIA", "VR")); + } + + @Test + void generate_profileWithoutTracks_sendsEmptyCurrentTracksToAi() { + OnboardingProfileSnapshot profile = sampleProfile(USER_ID, List.of(), List.of("W080001")); + given(onboardingProfileReader.read(USER_ID)).willReturn(Optional.of(profile)); + given(aiRecommendClient.generate(any())).willReturn(emptyAiResponse()); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(sampleUser())); + given(recommendationRepository.findByUser_IdAndStatus(USER_ID, RecommendationStatus.ACTIVE)) + .willReturn(List.of()); + given(recommendationRepository.save(any(Recommendation.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + given(recommendationAssembler.assemble(any(Recommendation.class), eq(profile))) + .willReturn(sentinelResponse()); + + recommendationService.generate(USER_ID, true); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AiRecommendRequest.class); + verify(aiRecommendClient).generate(captor.capture()); + assertThat(captor.getValue().currentTracks()).isEmpty(); + } + @Test void generate_jobsFoldingToSameCatalogCode_dedupedToSingleRecommendedJob() { OnboardingProfileSnapshot profile = sampleProfile(); @@ -375,19 +429,24 @@ void generate_secondaryComboWithMiddleDotVariant_mappedViaNormalizedCode() { } private static OnboardingProfileSnapshot sampleProfile() { + return sampleProfile(USER_ID, List.of("BIGDATA", "WEB"), List.of("W080001")); + } + + private static OnboardingProfileSnapshot sampleProfile( + Long userId, List trackCodes, List completedCourseCodes) { return new OnboardingProfileSnapshot( - USER_ID, + userId, 2023, "IT공과대학", "컴퓨터공학부", 3, - List.of("BIGDATA", "WEB"), + trackCodes, List.of("IT/인터넷"), List.of("앱"), List.of("성장성"), List.of("대기업"), List.of(), - List.of(new CompletedCourse("W080001", 1, 1))); + completedCourseCodes.stream().map(code -> new CompletedCourse(code, 1, 1)).toList()); } private static User sampleUser() { @@ -457,4 +516,8 @@ private static AiRecommendResponse sampleAiResponse() { coverage, explanation); } + + private static AiRecommendResponse emptyAiResponse() { + return new AiRecommendResponse(List.of(), List.of(), List.of(), null, null, null); + } }