From 61641b908bebfccf2671128568218faf0afe228e Mon Sep 17 00:00:00 2001 From: ryuwldnjs Date: Sat, 14 Feb 2026 15:50:01 +0900 Subject: [PATCH 1/4] recommendation: split God Class into 5 services + remove legacy files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RecommendationService (9 deps): CRUD + 조회 - ScheduledRecommendationService: 배치 오케스트레이션 - RecommendationEmailService: 이메일 발송 - RecommendationCreator: 공통 생성 로직 (package-private) - MissionCyclePolicy: 미션 사이클 정책 (package-private) 레거시 정리: TeamRecommendation, RecommendationStatus 등 5개 삭제 스케줄러 이동: infrastructure/scheduler/ → recommendation/scheduler/ --- .../RecommendationController.java | 17 +- .../recommendation/RecommendationService.java | 415 ------------------ .../domain/team/RecommendationStatus.java | 13 - .../domain/team/TeamRecommendation.java | 102 ----- .../team/TeamRecommendationProblem.java | 45 -- .../RecommendationDetailResponse.java | 66 +++ .../TeamRecommendationDetailResponse.java | 60 --- .../RecommendationMailBuilder.java | 2 +- .../TeamRecommendationProblemRepository.java | 25 -- .../TeamRecommendationRepository.java | 74 ---- .../scheduler/EmailSendScheduler.java | 30 +- .../ProblemRecommendationScheduler.java | 29 +- .../service/MissionCyclePolicy.java | 22 + .../service/RecommendationCreator.java | 97 ++++ .../service/RecommendationEmailService.java | 92 ++++ .../service/RecommendationService.java | 151 +++++++ .../ScheduledRecommendationService.java | 77 ++++ .../com/ryu/studyhelper/team/domain/Team.java | 5 - .../studyhelper/team/service/TeamService.java | 2 +- .../RecommendationServiceTest.java | 333 ++++---------- .../ScheduledRecommendationServiceTest.java | 177 ++++++++ 21 files changed, 817 insertions(+), 1017 deletions(-) delete mode 100644 src/main/java/com/ryu/studyhelper/recommendation/RecommendationService.java delete mode 100644 src/main/java/com/ryu/studyhelper/recommendation/domain/team/RecommendationStatus.java delete mode 100644 src/main/java/com/ryu/studyhelper/recommendation/domain/team/TeamRecommendation.java delete mode 100644 src/main/java/com/ryu/studyhelper/recommendation/domain/team/TeamRecommendationProblem.java create mode 100644 src/main/java/com/ryu/studyhelper/recommendation/dto/response/RecommendationDetailResponse.java delete mode 100644 src/main/java/com/ryu/studyhelper/recommendation/dto/response/TeamRecommendationDetailResponse.java rename src/main/java/com/ryu/studyhelper/recommendation/{mail => mailbuilder}/RecommendationMailBuilder.java (99%) delete mode 100644 src/main/java/com/ryu/studyhelper/recommendation/repository/TeamRecommendationProblemRepository.java delete mode 100644 src/main/java/com/ryu/studyhelper/recommendation/repository/TeamRecommendationRepository.java rename src/main/java/com/ryu/studyhelper/{infrastructure => recommendation}/scheduler/EmailSendScheduler.java (53%) rename src/main/java/com/ryu/studyhelper/{infrastructure => recommendation}/scheduler/ProblemRecommendationScheduler.java (51%) create mode 100644 src/main/java/com/ryu/studyhelper/recommendation/service/MissionCyclePolicy.java create mode 100644 src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationCreator.java create mode 100644 src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationEmailService.java create mode 100644 src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationService.java create mode 100644 src/main/java/com/ryu/studyhelper/recommendation/service/ScheduledRecommendationService.java rename src/test/java/com/ryu/studyhelper/recommendation/{ => service}/RecommendationServiceTest.java (67%) create mode 100644 src/test/java/com/ryu/studyhelper/recommendation/service/ScheduledRecommendationServiceTest.java diff --git a/src/main/java/com/ryu/studyhelper/recommendation/RecommendationController.java b/src/main/java/com/ryu/studyhelper/recommendation/RecommendationController.java index 587831c..45a94c3 100644 --- a/src/main/java/com/ryu/studyhelper/recommendation/RecommendationController.java +++ b/src/main/java/com/ryu/studyhelper/recommendation/RecommendationController.java @@ -6,13 +6,12 @@ import com.ryu.studyhelper.infrastructure.ratelimit.RateLimit; import com.ryu.studyhelper.infrastructure.ratelimit.RateLimitType; import com.ryu.studyhelper.recommendation.dto.response.MyTodayProblemsResponse; -import com.ryu.studyhelper.recommendation.dto.response.TeamRecommendationDetailResponse; +import com.ryu.studyhelper.recommendation.dto.response.RecommendationDetailResponse; import com.ryu.studyhelper.recommendation.dto.response.TodayProblemResponse; +import com.ryu.studyhelper.recommendation.service.RecommendationService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; @@ -40,23 +39,21 @@ public class RecommendationController { description = """ 팀장이 수동으로 문제 추천을 생성합니다. 추천 생성 즉시 팀원들에게 이메일이 발송됩니다. - count 파라미터가 없으면 팀 추천 설정의 problemCount 값을 사용합니다. + 팀 추천 설정의 problemCount 값을 사용합니다. """ ) @RateLimit(type = RateLimitType.SOLVED_AC) @PostMapping("/team/{teamId}/manual") @PreAuthorize("@teamService.isTeamLeader(#teamId, authentication.principal.memberId)") - public ResponseEntity> createManualRecommendation( + public ResponseEntity> createManualRecommendation( @Parameter(description = "팀 ID", example = "1") @PathVariable Long teamId, - @Parameter(description = "추천 문제 개수 (1~10, 미지정 시 팀 설정값 사용)", example = "3") - @RequestParam(required = false) @Min(1) @Max(10) Integer count, @AuthenticationPrincipal PrincipalDetails principalDetails) { - log.info("팀장 {}가 팀 {}에 수동 추천 생성 (count={})", principalDetails.getMemberId(), teamId, count); + log.info("팀장 {}가 팀 {}에 수동 추천 생성", principalDetails.getMemberId(), teamId); - TeamRecommendationDetailResponse response = - recommendationService.createManualRecommendation(teamId, count); + RecommendationDetailResponse response = + recommendationService.createManualRecommendation(teamId); return ResponseEntity.ok(ApiResponse.createSuccess(response, CustomResponseStatus.SUCCESS)); } diff --git a/src/main/java/com/ryu/studyhelper/recommendation/RecommendationService.java b/src/main/java/com/ryu/studyhelper/recommendation/RecommendationService.java deleted file mode 100644 index 3241eeb..0000000 --- a/src/main/java/com/ryu/studyhelper/recommendation/RecommendationService.java +++ /dev/null @@ -1,415 +0,0 @@ -package com.ryu.studyhelper.recommendation; - -import com.ryu.studyhelper.common.enums.CustomResponseStatus; -import com.ryu.studyhelper.common.exception.CustomException; -import com.ryu.studyhelper.infrastructure.mail.sender.MailSender; -import com.ryu.studyhelper.member.domain.Member; -import com.ryu.studyhelper.recommendation.mail.RecommendationMailBuilder; -import com.ryu.studyhelper.problem.domain.Problem; -import com.ryu.studyhelper.problem.dto.projection.ProblemTagProjection; -import com.ryu.studyhelper.problem.repository.ProblemTagRepository; -import com.ryu.studyhelper.problem.service.ProblemService; -import com.ryu.studyhelper.problem.service.ProblemSyncService; -import com.ryu.studyhelper.team.repository.TeamIncludeTagRepository; -import com.ryu.studyhelper.recommendation.domain.Recommendation; -import com.ryu.studyhelper.recommendation.domain.RecommendationProblem; -import com.ryu.studyhelper.recommendation.domain.member.EmailSendStatus; -import com.ryu.studyhelper.recommendation.domain.member.MemberRecommendation; -import com.ryu.studyhelper.recommendation.domain.team.TeamRecommendation; -import com.ryu.studyhelper.recommendation.domain.team.TeamRecommendationProblem; -import com.ryu.studyhelper.recommendation.dto.projection.ProblemWithSolvedStatusProjection; -import com.ryu.studyhelper.recommendation.dto.response.MyTodayProblemsResponse; -import com.ryu.studyhelper.recommendation.dto.response.TeamRecommendationDetailResponse; -import com.ryu.studyhelper.recommendation.dto.response.TodayProblemResponse; -import com.ryu.studyhelper.team.domain.TeamMember; -import com.ryu.studyhelper.recommendation.repository.MemberRecommendationRepository; -import com.ryu.studyhelper.recommendation.repository.RecommendationProblemRepository; -import com.ryu.studyhelper.recommendation.repository.RecommendationRepository; -import com.ryu.studyhelper.infrastructure.solvedac.dto.ProblemInfo; -import com.ryu.studyhelper.team.repository.TeamMemberRepository; -import com.ryu.studyhelper.team.repository.TeamRepository; -import com.ryu.studyhelper.team.domain.Team; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.Clock; -import java.time.DayOfWeek; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.util.List; -import java.util.Optional; - -/** - * 추천 시스템 비즈니스 로직을 담당하는 서비스 - */ -@Service -@RequiredArgsConstructor -@Transactional -@Slf4j -public class RecommendationService { - - // 시간 관련 상수 - private static final LocalTime MISSION_RESET_TIME = LocalTime.of(6, 0); // 미션 사이클 시작 (오전 6시) - private static final LocalTime BLOCKED_START_TIME = LocalTime.of(5, 0); // 수동 추천 금지 시작 (오전 5시) - private static final LocalTime BLOCKED_END_TIME = LocalTime.of(7, 0); // 수동 추천 금지 종료 (오전 7시) - - // 테스트 용이성을 위한 Clock 주입 (단위 테스트에서 시간 제어 가능) - private final Clock clock; - - private final TeamRepository teamRepository; - private final TeamMemberRepository teamMemberRepository; - private final TeamIncludeTagRepository teamIncludeTagRepository; - private final ProblemTagRepository problemTagRepository; - private final ProblemService problemService; - private final ProblemSyncService problemSyncService; - private final MailSender mailSender; - private final RecommendationMailBuilder recommendationMailBuilder; - - // 신규 추천 시스템 - private final RecommendationRepository recommendationRepository; - private final RecommendationProblemRepository recommendationProblemRepository; - private final MemberRecommendationRepository memberRecommendationRepository; - - - - /** - * 수동 추천 생성 (팀장 요청) - * 즉시 이메일 발송 - * 다음 2가지 경우에만 추천 가능: - * 1. 첫 팀 생성 후 아직 추천받은 적이 없는 경우 - * 2. 오늘 날짜 기준 추천이 발행되지 않은 경우 - * - * @param teamId 팀 ID - * @param count (미사용) 팀 설정값 사용, API 호환성을 위해 유지 - */ - public TeamRecommendationDetailResponse createManualRecommendation(Long teamId, Integer count) { - Team team = teamRepository.findById(teamId) - .orElseThrow(() -> new CustomException(CustomResponseStatus.TEAM_NOT_FOUND)); - - // 오늘 이미 추천이 존재하는지 검증 (SCHEDULED, MANUAL 모두 포함) - validateNoRecommendationToday(teamId); - - // 1. Recommendation 생성 (MANUAL 타입) - Recommendation recommendation = Recommendation.createManualRecommendation(teamId); - recommendationRepository.save(recommendation); - - // 2. 문제 추천 및 RecommendationProblem 추가 - List recommendedProblems = recommendProblemsForTeam(team); - for (Problem problem : recommendedProblems) { - RecommendationProblem rp = RecommendationProblem.create(problem); - recommendation.addProblem(rp); - recommendationProblemRepository.save(rp); - } - - // 3. 팀원별 MemberRecommendation 생성 및 즉시 이메일 발송 - List teamMembers = teamMemberRepository.findMembersByTeamId(team.getId()); - - for (Member member : teamMembers) { - MemberRecommendation memberRecommendation = MemberRecommendation.create(member, recommendation, team); - memberRecommendationRepository.save(memberRecommendation); - - // 4. 수동 추천은 항상 즉시 이메일 발송 - try { - String memberEmail = member.getEmail(); - if (memberEmail == null || memberEmail.isBlank()) { - memberRecommendation.markEmailAsFailed(); - memberRecommendationRepository.save(memberRecommendation); - log.warn("회원 ID {}에 이메일이 없습니다", member.getId()); - continue; - } - - mailSender.send(recommendationMailBuilder.build(memberRecommendation)); - - memberRecommendation.markEmailAsSent(); - memberRecommendationRepository.save(memberRecommendation); - } catch (Exception e) { - memberRecommendation.markEmailAsFailed(); - memberRecommendationRepository.save(memberRecommendation); - log.error("회원 ID {} 수동 추천 이메일 발송 실패", member.getId(), e); - } - } - - log.info("팀 '{}' 수동 추천 생성 완료 - 팀원: {}명, 문제: {}개", - team.getName(), teamMembers.size(), recommendedProblems.size()); - - // 6. 레거시 응답 형식 유지 (호환성) - // TODO: 추후 응답 형식을 신규 스키마 기반으로 변경 - TeamRecommendation legacyResponse = TeamRecommendation.createManualRecommendation(team); - for (int i = 0; i < recommendedProblems.size(); i++) { - TeamRecommendationProblem trp = TeamRecommendationProblem.create(recommendedProblems.get(i), i + 1); - legacyResponse.addProblem(trp); - } - legacyResponse.markAsSent(); - return TeamRecommendationDetailResponse.from(legacyResponse); - } - - /** - * 특정 팀의 오늘 추천 조회 (사용자별 해결 여부 포함) - * @param teamId 팀 ID - * @param memberId 회원 ID (nullable - 비로그인 시 null) - * @throws CustomException 추천이 없는 경우 RECOMMENDATION_NOT_FOUND - */ - @Transactional(readOnly = true) - public TodayProblemResponse getTodayRecommendation(Long teamId, Long memberId) { - return findTodayRecommendation(teamId, memberId) - .orElseThrow(() -> new CustomException(CustomResponseStatus.RECOMMENDATION_NOT_FOUND)); - } - - /** - * 특정 팀의 오늘 추천 조회 (Optional 반환) - * @param teamId 팀 ID - * @param memberId 회원 ID (nullable - 비로그인 시 null) - * @return 추천이 있으면 Optional.of(response), 없으면 Optional.empty() - */ - @Transactional(readOnly = true) - public Optional findTodayRecommendation(Long teamId, Long memberId) { - LocalDateTime missionCycleStart = getMissionCycleStart(); - - return recommendationRepository.findFirstByTeamIdOrderByCreatedAtDesc(teamId) - .filter(recommendation -> !recommendation.getCreatedAt().isBefore(missionCycleStart)) - .map(recommendation -> { - // 문제 + 해결 상태 조회 - List problemsWithStatus = recommendationProblemRepository - .findProblemsWithSolvedStatus(recommendation.getId(), memberId); - - // 문제별 태그 조회 - List problemIds = problemsWithStatus.stream() - .map(ProblemWithSolvedStatusProjection::getProblemId) - .toList(); - List tagProjections = problemIds.isEmpty() - ? List.of() - : problemTagRepository.findTagsByProblemIds(problemIds); - - return TodayProblemResponse.from(recommendation, problemsWithStatus, tagProjections); - }); - } - - /** - * 로그인한 유저가 속한 모든 팀의 오늘 추천 문제 조회 - * TeamMember 기반으로 현재 속한 팀만 조회 (중간 합류/탈퇴/해산 자동 반영) - * - * @param memberId 회원 ID - * @return 팀별 오늘의 문제 목록 - */ - @Transactional(readOnly = true) - public MyTodayProblemsResponse getMyTodayProblems(Long memberId) { - List teamMemberships = teamMemberRepository.findByMemberId(memberId); - - List teamProblems = teamMemberships.stream() - .map(tm -> { - Team team = tm.getTeam(); - return findTodayRecommendation(team.getId(), memberId) - .map(todayProblem -> MyTodayProblemsResponse.TeamTodayProblems.from( - team.getId(), - team.getName(), - todayProblem - )); - }) - .filter(Optional::isPresent) - .map(Optional::get) - .toList(); - - return MyTodayProblemsResponse.from(teamProblems); - } - - /** - * 문제 추천만 수행 (이메일 발송 X) - 오전 스케줄러용 - * 미션 사이클 기준(06:00~06:00)으로 중복 체크 - */ - public void prepareDailyRecommendations() { - LocalDateTime now = LocalDateTime.now(clock); - LocalDateTime missionCycleStart = getMissionCycleStart(); - log.info("문제 추천 준비 시작: {} (미션 사이클: {} 06:00 ~)", now.toLocalDate(), missionCycleStart.toLocalDate()); - - List activeTeams = getActiveTeams(now.toLocalDate()); - int successCount = 0; - int failCount = 0; - - for (Team team : activeTeams) { - try { - // 현재 미션 사이클 내 이미 추천이 있는지 체크 (SCHEDULED, MANUAL 모두 포함) - if (recommendationRepository.findFirstByTeamIdAndCreatedAtBetweenOrderById( - team.getId(), missionCycleStart, now - ).isPresent()) { - log.debug("팀 '{}'에 대해 현재 미션 사이클({})에 이미 추천 존재 - 스킵", team.getName(), missionCycleStart); - continue; - } - - prepareDailyRecommendation(team); - successCount++; - log.info("팀 '{}' 문제 추천 완료", team.getName()); - - } catch (Exception e) { - failCount++; - log.error("팀 '{}' 문제 추천 실패", team.getName(), e); - } - } - - log.info("문제 추천 배치 완료 - 대상: {}개, 성공: {}개, 실패: {}개", - activeTeams.size(), successCount, failCount); - } - - /** - * 특정 팀에 대한 문제 추천 준비 (이메일 발송 X) - * 신규 스키마 사용: Recommendation → RecommendationProblem, MemberRecommendation - */ - private void prepareDailyRecommendation(Team team) { - // 1. Recommendation 생성 - Recommendation recommendation = Recommendation.createScheduledRecommendation(team.getId()); - recommendationRepository.save(recommendation); - - // 2. 문제 추천 및 RecommendationProblem 추가 - List recommendedProblems = recommendProblemsForTeam(team); - for (Problem problem : recommendedProblems) { - RecommendationProblem rp = RecommendationProblem.create(problem); - recommendation.addProblem(rp); - recommendationProblemRepository.save(rp); - } - - // 3. 팀원별 MemberRecommendation 생성 - List teamMembers = teamMemberRepository.findMembersByTeamId(team.getId()); - for (Member member : teamMembers) { - MemberRecommendation memberRecommendation = MemberRecommendation.create(member, recommendation, team); - memberRecommendationRepository.save(memberRecommendation); - } - - log.info("팀 '{}' 신규 스키마 추천 생성 완료 - 팀원: {}명, 문제: {}개", - team.getName(), teamMembers.size(), recommendedProblems.size()); - } - - /** - * PENDING 상태의 추천들에 대해 이메일 발송 - 오전 스케줄러용 - * 신규 스키마 사용: MemberRecommendation 개별 발송 - * 미션 사이클 기준(06:00~06:00)으로 조회 - */ - public void sendPendingRecommendationEmails() { - LocalDateTime now = LocalDateTime.now(clock); - LocalDateTime missionCycleStart = getMissionCycleStart(); - log.info("이메일 발송 배치 시작: {} (미션 사이클: {} 06:00 ~)", now.toLocalDate(), missionCycleStart.toLocalDate()); - - // 현재 미션 사이클의 PENDING 상태 개인 추천 조회 - List pendingRecommendations = memberRecommendationRepository - .findPendingRecommendationsByCreatedAtBetween(missionCycleStart, now, EmailSendStatus.PENDING); - - int successCount = 0; - int failCount = 0; - - for (MemberRecommendation memberRecommendation : pendingRecommendations) { - try { - String memberEmail = memberRecommendation.getMember().getEmail(); - - if (memberEmail == null || memberEmail.isBlank()) { - memberRecommendation.markEmailAsFailed(); - memberRecommendationRepository.save(memberRecommendation); - log.warn("회원 ID {}에 이메일이 없습니다", memberRecommendation.getMember().getId()); - failCount++; - continue; - } - - // 개별 회원에게 이메일 발송 - mailSender.send(recommendationMailBuilder.build(memberRecommendation)); - - memberRecommendation.markEmailAsSent(); - memberRecommendationRepository.save(memberRecommendation); - - successCount++; - log.debug("회원 '{}' 이메일 발송 완료", memberRecommendation.getMember().getHandle()); - - } catch (Exception e) { - memberRecommendation.markEmailAsFailed(); - memberRecommendationRepository.save(memberRecommendation); - - failCount++; - log.error("회원 ID {} 이메일 발송 실패", - memberRecommendation.getMember().getId(), e); - } - } - - log.info("이메일 발송 배치 완료 - 대상: {}개, 성공: {}개, 실패: {}개", - pendingRecommendations.size(), successCount, failCount); - } - - /** - * 팀에 문제 추천 (신규 스키마용) - * - 팀 설정(난이도, 문제 수, 포함 태그)을 기반으로 추천 - * - 추천된 문제의 메타데이터와 태그를 DB에 동기화 - */ - private List recommendProblemsForTeam(Team team) { - List handles = teamMemberRepository.findHandlesByTeamId(team.getId()); - if (handles.isEmpty()) { - log.warn("팀 '{}'에 인증된 핸들이 없습니다", team.getName()); - throw new IllegalStateException("인증된 핸들이 없습니다"); - } - - // 팀의 포함 태그 목록 조회 - List tagKeys = teamIncludeTagRepository.findTagKeysByTeamId(team.getId()); - - // solved.ac API로 문제 추천 (태그 필터 포함) - List problemInfos = problemService.recommend( - handles, - team.getProblemCount(), - team.getEffectiveMinProblemLevel(), - team.getEffectiveMaxProblemLevel(), - tagKeys - ); - - // 문제 메타데이터 + 태그 동기화 후 반환 - return problemSyncService.syncProblems(problemInfos); - } - - /** - * 활성 팀 조회 (팀원이 있고, 추천이 활성화되어 있으며, 오늘이 추천 요일인 팀) - */ - private List getActiveTeams(LocalDate date) { - DayOfWeek dayOfWeek = date.getDayOfWeek(); - return teamRepository.findAll().stream() - .filter(team -> !team.getTeamMembers().isEmpty()) - .filter(Team::isRecommendationActive) - .filter(team -> team.isRecommendationDay(dayOfWeek)) - .toList(); - } - - /** - * 수동 추천 생성 가능 여부 검증 - * 1. 오전 5시~7시 사이: 생성 금지 (스케줄러 전환 구간) - * 2. 현재 미션 사이클 내 추천이 있으면: 생성 금지 - * - * @param teamId 팀 ID - * @throws CustomException 생성 불가 시 - */ - private void validateNoRecommendationToday(Long teamId) { - LocalTime now = LocalTime.now(clock); - - // 오전 5시~7시 사이는 수동 추천 금지 - if (!now.isBefore(BLOCKED_START_TIME) && now.isBefore(BLOCKED_END_TIME)) { - throw new CustomException(CustomResponseStatus.RECOMMENDATION_BLOCKED_TIME); - } - - // 현재 미션 사이클 시작 시간 이후(포함)에 생성된 추천이 있으면 금지 - // !isBefore 사용: 정확히 6시에 생성된 추천도 현재 사이클에 포함 - LocalDateTime missionCycleStart = getMissionCycleStart(); - recommendationRepository.findFirstByTeamIdOrderByCreatedAtDesc(teamId) - .filter(recommendation -> !recommendation.getCreatedAt().isBefore(missionCycleStart)) - .ifPresent(recommendation -> { - throw new CustomException(CustomResponseStatus.RECOMMENDATION_ALREADY_EXISTS_TODAY); - }); - } - - /** - * 현재 미션 사이클 시작 시간 계산 - * - 오전 6시 이후: 오늘 오전 6시 - * - 오전 6시 이전: 어제 오전 6시 - */ - private LocalDateTime getMissionCycleStart() { - LocalDateTime now = LocalDateTime.now(clock); - - if (now.toLocalTime().isBefore(MISSION_RESET_TIME)) { - return now.toLocalDate().minusDays(1).atTime(MISSION_RESET_TIME); - } - return now.toLocalDate().atTime(MISSION_RESET_TIME); - } - -} \ No newline at end of file diff --git a/src/main/java/com/ryu/studyhelper/recommendation/domain/team/RecommendationStatus.java b/src/main/java/com/ryu/studyhelper/recommendation/domain/team/RecommendationStatus.java deleted file mode 100644 index 8492ffb..0000000 --- a/src/main/java/com/ryu/studyhelper/recommendation/domain/team/RecommendationStatus.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.ryu.studyhelper.recommendation.domain.team; - -/** - * 추천 상태를 나타내는 열거형 - * PENDING: 추천 생성됨, 이메일 발송 대기 중 - * SENT: 이메일 발송 완료 - * FAILED: 이메일 발송 실패 - */ -public enum RecommendationStatus { - PENDING, // 발송 대기 중 - SENT, // 발송 완료 - FAILED // 발송 실패 -} \ No newline at end of file diff --git a/src/main/java/com/ryu/studyhelper/recommendation/domain/team/TeamRecommendation.java b/src/main/java/com/ryu/studyhelper/recommendation/domain/team/TeamRecommendation.java deleted file mode 100644 index 5cba2f7..0000000 --- a/src/main/java/com/ryu/studyhelper/recommendation/domain/team/TeamRecommendation.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.ryu.studyhelper.recommendation.domain.team; - -import com.ryu.studyhelper.common.entity.BaseEntity; -import com.ryu.studyhelper.recommendation.domain.RecommendationType; -import com.ryu.studyhelper.team.domain.Team; -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - -/** - * 팀별 문제 추천 이력을 저장하는 엔티티 - * 매일 자동 추천 또는 수동 추천 결과를 기록 - */ -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -@Table(name = "team_recommendation", - uniqueConstraints = @UniqueConstraint( - name = "uk_team_recommendation_date", - columnNames = {"team_id", "recommendation_date", "type"} - )) -public class TeamRecommendation extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "team_id", nullable = false) - private Team team; - - @Enumerated(EnumType.STRING) - @Column(length=16, nullable = false) - private RecommendationType type; - - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private RecommendationStatus status; - - @Column(name = "recommendation_date", nullable = false) - private LocalDate recommendationDate; - - @Column(name = "sent_at") - private LocalDateTime sentAt; - - @OneToMany(mappedBy = "teamRecommendation", cascade = CascadeType.ALL, orphanRemoval = true) - @Builder.Default - private List problems = new ArrayList<>(); - - /** - * 팀별 스케줄 추천 생성을 위한 팩토리 메서드 - */ - public static TeamRecommendation createScheduledRecommendation(Team team, LocalDate date) { - return TeamRecommendation.builder() - .team(team) - .type(RecommendationType.SCHEDULED) - .status(RecommendationStatus.PENDING) - .recommendationDate(date) - .build(); - } - - /** - * 수동 추천 생성을 위한 팩토리 메서드 - */ - public static TeamRecommendation createManualRecommendation(Team team) { - return TeamRecommendation.builder() - .team(team) - .type(RecommendationType.MANUAL) - .status(RecommendationStatus.PENDING) - .recommendationDate(LocalDate.now()) - .build(); - } - - /** - * 추천 문제 추가 - */ - public void addProblem(TeamRecommendationProblem problem) { - problems.add(problem); - problem.setTeamRecommendation(this); - } - - /** - * 이메일 발송 완료 처리 - */ - public void markAsSent() { - this.status = RecommendationStatus.SENT; - this.sentAt = LocalDateTime.now(); - } - - /** - * 이메일 발송 실패 처리 - */ - public void markAsFailed() { - this.status = RecommendationStatus.FAILED; - } -} \ No newline at end of file diff --git a/src/main/java/com/ryu/studyhelper/recommendation/domain/team/TeamRecommendationProblem.java b/src/main/java/com/ryu/studyhelper/recommendation/domain/team/TeamRecommendationProblem.java deleted file mode 100644 index 97cf6ba..0000000 --- a/src/main/java/com/ryu/studyhelper/recommendation/domain/team/TeamRecommendationProblem.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.ryu.studyhelper.recommendation.domain.team; - -import com.ryu.studyhelper.common.entity.BaseEntity; -import com.ryu.studyhelper.problem.domain.Problem; -import jakarta.persistence.*; -import lombok.*; - -/** - * 팀 추천에 포함된 문제들을 저장하는 엔티티 - * TeamRecommendation과 Problem 간의 다대다 관계를 중간 테이블로 구현 - */ -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -@Table(name = "team_recommendation_problem") -public class TeamRecommendationProblem extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "team_recommendation_id", nullable = false) - @Setter - private TeamRecommendation teamRecommendation; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "problem_id", nullable = false) - private Problem problem; - - @Column(name = "recommendation_order") - private Integer recommendationOrder; - - /** - * 추천 문제 생성을 위한 팩토리 메서드 - */ - public static TeamRecommendationProblem create(Problem problem, Integer order) { - return TeamRecommendationProblem.builder() - .problem(problem) - .recommendationOrder(order) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/ryu/studyhelper/recommendation/dto/response/RecommendationDetailResponse.java b/src/main/java/com/ryu/studyhelper/recommendation/dto/response/RecommendationDetailResponse.java new file mode 100644 index 0000000..a3717d7 --- /dev/null +++ b/src/main/java/com/ryu/studyhelper/recommendation/dto/response/RecommendationDetailResponse.java @@ -0,0 +1,66 @@ +package com.ryu.studyhelper.recommendation.dto.response; + +import com.ryu.studyhelper.recommendation.domain.Recommendation; +import com.ryu.studyhelper.recommendation.domain.RecommendationProblem; +import com.ryu.studyhelper.recommendation.domain.RecommendationType; +import com.ryu.studyhelper.team.domain.Team; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import com.ryu.studyhelper.problem.domain.Problem; + +import java.util.List; +import java.util.stream.IntStream; + +/** + * 추천 상세 조회용 응답 DTO + */ +public record RecommendationDetailResponse( + Long id, + String teamName, + LocalDate recommendationDate, + RecommendationType type, + String status, + LocalDateTime sentAt, + List problems +) { + /** + * 추천된 문제 상세 정보 + */ + public record RecommendedProblemDetail( + Long problemId, + String title, + String titleKo, + Integer level, + String url, + Integer recommendationOrder, + Integer acceptedUserCount, + Double averageTries + ) {} + + public static RecommendationDetailResponse from(Recommendation recommendation, Team team, List problems) { + return new RecommendationDetailResponse( + recommendation.getId(), + team.getName(), + recommendation.getCreatedAt().toLocalDate(), + recommendation.getType(), + "SENT", + LocalDateTime.now(), + IntStream.range(0, problems.size()) + .mapToObj(i -> { + Problem p = problems.get(i); + return new RecommendedProblemDetail( + p.getId(), + p.getTitle(), + p.getTitleKo(), + p.getLevel(), + p.getUrl(), + i + 1, + p.getAcceptedUserCount(), + p.getAverageTries() + ); + }) + .toList() + ); + } +} diff --git a/src/main/java/com/ryu/studyhelper/recommendation/dto/response/TeamRecommendationDetailResponse.java b/src/main/java/com/ryu/studyhelper/recommendation/dto/response/TeamRecommendationDetailResponse.java deleted file mode 100644 index 48ef30d..0000000 --- a/src/main/java/com/ryu/studyhelper/recommendation/dto/response/TeamRecommendationDetailResponse.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.ryu.studyhelper.recommendation.dto.response; - -import com.ryu.studyhelper.recommendation.domain.team.TeamRecommendation; -import com.ryu.studyhelper.recommendation.domain.RecommendationType; -import com.ryu.studyhelper.recommendation.domain.team.RecommendationStatus; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; - -/** - * 팀 추천 상세 조회용 응답 DTO - */ -public record TeamRecommendationDetailResponse( - Long id, - String teamName, - LocalDate recommendationDate, - RecommendationType type, - RecommendationStatus status, - LocalDateTime sentAt, - List problems -) { - /** - * 추천된 문제 상세 정보 - */ - public record RecommendedProblemDetail( - Long problemId, - String title, - String titleKo, - Integer level, - String url, - Integer recommendationOrder, - Integer acceptedUserCount, - Double averageTries - ) {} - - public static TeamRecommendationDetailResponse from(TeamRecommendation recommendation) { - return new TeamRecommendationDetailResponse( - recommendation.getId(), - recommendation.getTeam().getName(), - recommendation.getRecommendationDate(), - recommendation.getType(), - recommendation.getStatus(), - recommendation.getSentAt(), - recommendation.getProblems().stream() - .map(trp -> new RecommendedProblemDetail( - trp.getProblem().getId(), - trp.getProblem().getTitle(), - trp.getProblem().getTitleKo(), - trp.getProblem().getLevel(), - trp.getProblem().getUrl(), - trp.getRecommendationOrder(), - trp.getProblem().getAcceptedUserCount(), - trp.getProblem().getAverageTries() - )) - .sorted((a, b) -> Integer.compare(a.recommendationOrder(), b.recommendationOrder())) - .toList() - ); - } -} diff --git a/src/main/java/com/ryu/studyhelper/recommendation/mail/RecommendationMailBuilder.java b/src/main/java/com/ryu/studyhelper/recommendation/mailbuilder/RecommendationMailBuilder.java similarity index 99% rename from src/main/java/com/ryu/studyhelper/recommendation/mail/RecommendationMailBuilder.java rename to src/main/java/com/ryu/studyhelper/recommendation/mailbuilder/RecommendationMailBuilder.java index cc24744..17b2cdb 100644 --- a/src/main/java/com/ryu/studyhelper/recommendation/mail/RecommendationMailBuilder.java +++ b/src/main/java/com/ryu/studyhelper/recommendation/mailbuilder/RecommendationMailBuilder.java @@ -1,4 +1,4 @@ -package com.ryu.studyhelper.recommendation.mail; +package com.ryu.studyhelper.recommendation.mailbuilder; import com.ryu.studyhelper.infrastructure.mail.sender.MailMessage; import com.ryu.studyhelper.infrastructure.mail.support.CssInliner; diff --git a/src/main/java/com/ryu/studyhelper/recommendation/repository/TeamRecommendationProblemRepository.java b/src/main/java/com/ryu/studyhelper/recommendation/repository/TeamRecommendationProblemRepository.java deleted file mode 100644 index 7c3222b..0000000 --- a/src/main/java/com/ryu/studyhelper/recommendation/repository/TeamRecommendationProblemRepository.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.ryu.studyhelper.recommendation.repository; - -import com.ryu.studyhelper.recommendation.domain.team.TeamRecommendationProblem; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.util.List; - -/** - * 팀 추천 문제 데이터 접근을 위한 Repository - */ -@Repository -public interface TeamRecommendationProblemRepository extends JpaRepository { - - /** - * 특정 추천에 포함된 문제들을 순서대로 조회 - */ - @Query("SELECT trp FROM TeamRecommendationProblem trp " + - "WHERE trp.teamRecommendation.id = :recommendationId " + - "ORDER BY trp.recommendationOrder ASC") - List findByTeamRecommendationIdOrderByOrder( - @Param("recommendationId") Long recommendationId); -} \ No newline at end of file diff --git a/src/main/java/com/ryu/studyhelper/recommendation/repository/TeamRecommendationRepository.java b/src/main/java/com/ryu/studyhelper/recommendation/repository/TeamRecommendationRepository.java deleted file mode 100644 index 8080241..0000000 --- a/src/main/java/com/ryu/studyhelper/recommendation/repository/TeamRecommendationRepository.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.ryu.studyhelper.recommendation.repository; - -import com.ryu.studyhelper.recommendation.domain.team.TeamRecommendation; -import com.ryu.studyhelper.recommendation.domain.RecommendationType; -import com.ryu.studyhelper.team.domain.Team; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; - -/** - * 팀 추천 데이터 접근을 위한 Repository - */ -@Repository -public interface TeamRecommendationRepository extends JpaRepository { - - /** - * 특정 팀의 특정 날짜에 이미 추천이 존재하는지 확인 - */ - boolean existsByTeamAndRecommendationDate(Team team, LocalDate date); - - /** - * 특정 팀의 특정 날짜와 타입에 해당하는 추천 조회 - */ - Optional findByTeamAndRecommendationDateAndType( - Team team, LocalDate date, RecommendationType type); - - /** - * 특정 팀의 추천 이력을 최신순으로 페이징 조회 - */ - @Query("SELECT tr FROM TeamRecommendation tr " + - "WHERE tr.team.id = :teamId " + - "ORDER BY tr.recommendationDate DESC, tr.createdAt DESC") - Page findByTeamIdOrderByRecommendationDateDesc( - @Param("teamId") Long teamId, Pageable pageable); - - /** - * 특정 사용자가 속한 팀들의 오늘 추천 현황 조회 - */ - @Query("SELECT tr FROM TeamRecommendation tr " + - "JOIN tr.team.teamMembers tm " + - "WHERE tm.member.id = :memberId " + - "AND tr.recommendationDate = :date " + - "ORDER BY tr.team.name") - List findTodayRecommendationsByMemberId( - @Param("memberId") Long memberId, @Param("date") LocalDate date); - - /** - * 특정 날짜의 모든 팀 추천 조회 (배치 처리용) - */ - @Query("SELECT tr FROM TeamRecommendation tr " + - "WHERE tr.recommendationDate = :date " + - "AND tr.type = :type") - List findByRecommendationDateAndType( - @Param("date") LocalDate date, @Param("type") RecommendationType type); - - /** - * 특정 팀의 오늘 추천 조회 (문제 정보 포함) - */ - @Query("SELECT tr FROM TeamRecommendation tr " + - "LEFT JOIN FETCH tr.problems trp " + - "LEFT JOIN FETCH trp.problem " + - "WHERE tr.team.id = :teamId " + - "AND tr.recommendationDate = :date " + - "ORDER BY tr.createdAt DESC") - List findByTeamIdAndRecommendationDateWithProblems( - @Param("teamId") Long teamId, @Param("date") LocalDate date); -} \ No newline at end of file diff --git a/src/main/java/com/ryu/studyhelper/infrastructure/scheduler/EmailSendScheduler.java b/src/main/java/com/ryu/studyhelper/recommendation/scheduler/EmailSendScheduler.java similarity index 53% rename from src/main/java/com/ryu/studyhelper/infrastructure/scheduler/EmailSendScheduler.java rename to src/main/java/com/ryu/studyhelper/recommendation/scheduler/EmailSendScheduler.java index 263048c..8f8218c 100644 --- a/src/main/java/com/ryu/studyhelper/infrastructure/scheduler/EmailSendScheduler.java +++ b/src/main/java/com/ryu/studyhelper/recommendation/scheduler/EmailSendScheduler.java @@ -1,6 +1,6 @@ -package com.ryu.studyhelper.infrastructure.scheduler; +package com.ryu.studyhelper.recommendation.scheduler; -import com.ryu.studyhelper.recommendation.RecommendationService; +import com.ryu.studyhelper.recommendation.service.RecommendationEmailService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -15,7 +15,7 @@ @Slf4j public class EmailSendScheduler { - private final RecommendationService recommendationService; + private final RecommendationEmailService recommendationEmailService; /** * 매일 오전 9시에 이메일 발송 @@ -28,7 +28,27 @@ public void sendPendingEmails() { long startTime = System.currentTimeMillis(); try { - recommendationService.sendPendingRecommendationEmails(); + recommendationEmailService.sendAll(); + + long endTime = System.currentTimeMillis(); + log.info("=== 이메일 발송 배치 작업 완료 === (소요시간: {}ms)", endTime - startTime); + + } catch (Exception e) { + long endTime = System.currentTimeMillis(); + log.error("=== 이메일 발송 배치 작업 실패 === (소요시간: {}ms)", endTime - startTime, e); + } + } + + + // 테스트용 첫 실행후 10초뒤에 딱 한번 문제 추천 배치 작업 시작 +// @Scheduled(initialDelay = 10000, fixedDelay = Long.MAX_VALUE) + public void testsendPendingEmails() { + log.info("=== 이메일 발송 배치 작업 시작 ==="); + + long startTime = System.currentTimeMillis(); + + try { + recommendationEmailService.sendAll(); long endTime = System.currentTimeMillis(); log.info("=== 이메일 발송 배치 작업 완료 === (소요시간: {}ms)", endTime - startTime); @@ -38,4 +58,4 @@ public void sendPendingEmails() { log.error("=== 이메일 발송 배치 작업 실패 === (소요시간: {}ms)", endTime - startTime, e); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/ryu/studyhelper/infrastructure/scheduler/ProblemRecommendationScheduler.java b/src/main/java/com/ryu/studyhelper/recommendation/scheduler/ProblemRecommendationScheduler.java similarity index 51% rename from src/main/java/com/ryu/studyhelper/infrastructure/scheduler/ProblemRecommendationScheduler.java rename to src/main/java/com/ryu/studyhelper/recommendation/scheduler/ProblemRecommendationScheduler.java index 8291035..dfd779b 100644 --- a/src/main/java/com/ryu/studyhelper/infrastructure/scheduler/ProblemRecommendationScheduler.java +++ b/src/main/java/com/ryu/studyhelper/recommendation/scheduler/ProblemRecommendationScheduler.java @@ -1,6 +1,6 @@ -package com.ryu.studyhelper.infrastructure.scheduler; +package com.ryu.studyhelper.recommendation.scheduler; -import com.ryu.studyhelper.recommendation.RecommendationService; +import com.ryu.studyhelper.recommendation.service.ScheduledRecommendationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -15,7 +15,7 @@ @Slf4j public class ProblemRecommendationScheduler { - private final RecommendationService recommendationService; + private final ScheduledRecommendationService scheduledRecommendationService; /** * 매일 오전 6시에 문제 추천 준비 @@ -28,7 +28,7 @@ public void prepareDailyRecommendations() { long startTime = System.currentTimeMillis(); try { - recommendationService.prepareDailyRecommendations(); + scheduledRecommendationService.prepareDailyRecommendations(); long endTime = System.currentTimeMillis(); log.info("=== 문제 추천 배치 작업 완료 === (소요시간: {}ms)", endTime - startTime); @@ -38,4 +38,23 @@ public void prepareDailyRecommendations() { log.error("=== 문제 추천 배치 작업 실패 === (소요시간: {}ms)", endTime - startTime, e); } } -} \ No newline at end of file + + // 테스트용 첫 실행후 1초뒤에 딱 한번 문제 추천 배치 작업 시작 +// @Scheduled(initialDelay = 1000, fixedDelay = Long.MAX_VALUE) + public void testPrepareRecommendations() { + log.info("=== [테스트] 문제 추천 배치 작업 시작 ==="); + + long startTime = System.currentTimeMillis(); + + try { + scheduledRecommendationService.prepareDailyRecommendations(); + + long endTime = System.currentTimeMillis(); + log.info("=== [테스트] 문제 추천 배치 작업 완료 === (소요시간: {}ms)", endTime - startTime); + + } catch (Exception e) { + long endTime = System.currentTimeMillis(); + log.error("=== [테스트] 문제 추천 배치 작업 실패 === (소요시간: {}ms)", endTime - startTime, e); + } + } +} diff --git a/src/main/java/com/ryu/studyhelper/recommendation/service/MissionCyclePolicy.java b/src/main/java/com/ryu/studyhelper/recommendation/service/MissionCyclePolicy.java new file mode 100644 index 0000000..21509d3 --- /dev/null +++ b/src/main/java/com/ryu/studyhelper/recommendation/service/MissionCyclePolicy.java @@ -0,0 +1,22 @@ +package com.ryu.studyhelper.recommendation.service; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.LocalTime; + +/** + * 미션 사이클 도메인 정책 + * 매일 오전 6시를 기준으로 미션 사이클이 갱신된다. + */ +class MissionCyclePolicy { + + static final LocalTime MISSION_RESET_TIME = LocalTime.of(6, 0); + + static LocalDateTime getMissionCycleStart(Clock clock) { + LocalDateTime now = LocalDateTime.now(clock); + if (now.toLocalTime().isBefore(MISSION_RESET_TIME)) { + return now.toLocalDate().minusDays(1).atTime(MISSION_RESET_TIME); + } + return now.toLocalDate().atTime(MISSION_RESET_TIME); + } +} diff --git a/src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationCreator.java b/src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationCreator.java new file mode 100644 index 0000000..1a87ca9 --- /dev/null +++ b/src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationCreator.java @@ -0,0 +1,97 @@ +package com.ryu.studyhelper.recommendation.service; + +import com.ryu.studyhelper.infrastructure.solvedac.dto.ProblemInfo; +import com.ryu.studyhelper.member.domain.Member; +import com.ryu.studyhelper.problem.domain.Problem; +import com.ryu.studyhelper.problem.service.ProblemService; +import com.ryu.studyhelper.problem.service.ProblemSyncService; +import com.ryu.studyhelper.recommendation.domain.Recommendation; +import com.ryu.studyhelper.recommendation.domain.RecommendationProblem; +import com.ryu.studyhelper.recommendation.domain.RecommendationType; +import com.ryu.studyhelper.recommendation.domain.member.MemberRecommendation; +import com.ryu.studyhelper.recommendation.repository.MemberRecommendationRepository; +import com.ryu.studyhelper.recommendation.repository.RecommendationProblemRepository; +import com.ryu.studyhelper.recommendation.repository.RecommendationRepository; +import com.ryu.studyhelper.team.domain.Team; +import com.ryu.studyhelper.team.repository.TeamIncludeTagRepository; +import com.ryu.studyhelper.team.repository.TeamMemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 팀 1개에 대한 추천 생성 공통 로직 + * RecommendationService(수동)와 ScheduledRecommendationService(배치) 모두 사용 + */ +@Service +@RequiredArgsConstructor +@Slf4j +class RecommendationCreator { + + private final TeamMemberRepository teamMemberRepository; + private final TeamIncludeTagRepository teamIncludeTagRepository; + private final ProblemService problemService; + private final ProblemSyncService problemSyncService; + private final RecommendationRepository recommendationRepository; + private final RecommendationProblemRepository recommendationProblemRepository; + private final MemberRecommendationRepository memberRecommendationRepository; + + Recommendation create(Team team, RecommendationType type) { + Recommendation recommendation = createRecommendation(team, type); + List problems = createRecommendationProblems(recommendation, team); + createMemberRecommendations(recommendation, team); + + log.info("팀 '{}' 추천 생성 완료 - 타입: {}, 문제: {}개", + team.getName(), type, problems.size()); + + return recommendation; + } + + private Recommendation createRecommendation(Team team, RecommendationType type) { + + Recommendation recommendation = (type == RecommendationType.MANUAL) + ? Recommendation.createManualRecommendation(team.getId()) + : Recommendation.createScheduledRecommendation(team.getId()); + return recommendationRepository.save(recommendation); + } + + private List createRecommendationProblems(Recommendation recommendation, Team team) { + List problems = recommendProblemsForTeam(team); + for (Problem problem : problems) { + RecommendationProblem rp = RecommendationProblem.create(problem); + recommendation.addProblem(rp); + recommendationProblemRepository.save(rp); + } + return problems; + } + + private void createMemberRecommendations(Recommendation recommendation, Team team) { + List teamMembers = teamMemberRepository.findMembersByTeamId(team.getId()); + for (Member member : teamMembers) { + MemberRecommendation mr = MemberRecommendation.create(member, recommendation, team); + memberRecommendationRepository.save(mr); + } + } + + private List recommendProblemsForTeam(Team team) { + List handles = teamMemberRepository.findHandlesByTeamId(team.getId()); + if (handles.isEmpty()) { + log.warn("팀 '{}'에 인증된 핸들이 없습니다", team.getName()); + throw new IllegalStateException("인증된 핸들이 없습니다"); + } + + List tagKeys = teamIncludeTagRepository.findTagKeysByTeamId(team.getId()); + + List problemInfos = problemService.recommend( + handles, + team.getProblemCount(), + team.getEffectiveMinProblemLevel(), + team.getEffectiveMaxProblemLevel(), + tagKeys + ); + + return problemSyncService.syncProblems(problemInfos); + } +} diff --git a/src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationEmailService.java b/src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationEmailService.java new file mode 100644 index 0000000..601fbab --- /dev/null +++ b/src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationEmailService.java @@ -0,0 +1,92 @@ + package com.ryu.studyhelper.recommendation.service; + + import com.ryu.studyhelper.infrastructure.mail.sender.MailSender; + import com.ryu.studyhelper.recommendation.domain.member.EmailSendStatus; + import com.ryu.studyhelper.recommendation.domain.member.MemberRecommendation; + import com.ryu.studyhelper.recommendation.mailbuilder.RecommendationMailBuilder; + import com.ryu.studyhelper.recommendation.repository.MemberRecommendationRepository; + import lombok.RequiredArgsConstructor; + import lombok.extern.slf4j.Slf4j; + import org.springframework.stereotype.Service; + import org.springframework.transaction.annotation.Transactional; + + import java.time.Clock; + import java.time.LocalDateTime; + import java.util.List; + + /** + * 추천 이메일 발송 + * 배치(sendAll)와 수동(send) 모두 담당 + */ + @Service + @RequiredArgsConstructor + @Transactional + @Slf4j + public class RecommendationEmailService { + + private final Clock clock; + private final MailSender mailSender; + private final RecommendationMailBuilder recommendationMailBuilder; + private final MemberRecommendationRepository memberRecommendationRepository; + + /** + * 배치: PENDING 상태의 추천들에 대해 이메일 발송 + * 미션 사이클 기준(06:00~06:00)으로 조회 + */ + public void sendAll() { + LocalDateTime now = LocalDateTime.now(clock); + LocalDateTime missionCycleStart = MissionCyclePolicy.getMissionCycleStart(clock); + log.info("이메일 발송 배치 시작: {} (미션 사이클: {} 06:00 ~)", now.toLocalDate(), missionCycleStart.toLocalDate()); + + List pendingRecommendations = memberRecommendationRepository + .findPendingRecommendationsByCreatedAtBetween(missionCycleStart, now, EmailSendStatus.PENDING); + + int successCount = 0; + int failCount = 0; + + for (MemberRecommendation mr : pendingRecommendations) { + if (sendEmail(mr)) { + successCount++; + } else { + failCount++; + } + } + + log.info("이메일 발송 배치 완료 - 대상: {}개, 성공: {}개, 실패: {}개", + pendingRecommendations.size(), successCount, failCount); + } + + /** + * 수동 추천: 해당 추천의 팀원들에게 이메일 즉시 발송 + */ + public void send(List memberRecommendations) { + for (MemberRecommendation mr : memberRecommendations) { + sendEmail(mr); + } + } + + private boolean sendEmail(MemberRecommendation mr) { + try { + String email = mr.getMember().getEmail(); + if (email == null || email.isBlank()) { + mr.markEmailAsFailed(); + memberRecommendationRepository.save(mr); + log.warn("회원 ID {}에 이메일이 없습니다", mr.getMember().getId()); + return false; + } + + mailSender.send(recommendationMailBuilder.build(mr)); + + mr.markEmailAsSent(); + memberRecommendationRepository.save(mr); + log.debug("회원 '{}' 이메일 발송 완료", mr.getMember().getHandle()); + return true; + + } catch (Exception e) { + mr.markEmailAsFailed(); + memberRecommendationRepository.save(mr); + log.error("회원 ID {} 이메일 발송 실패", mr.getMember().getId(), e); + return false; + } + } + } diff --git a/src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationService.java b/src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationService.java new file mode 100644 index 0000000..f2d171e --- /dev/null +++ b/src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationService.java @@ -0,0 +1,151 @@ +package com.ryu.studyhelper.recommendation.service; + +import com.ryu.studyhelper.common.enums.CustomResponseStatus; +import com.ryu.studyhelper.common.exception.CustomException; +import com.ryu.studyhelper.problem.dto.projection.ProblemTagProjection; +import com.ryu.studyhelper.problem.domain.Problem; +import com.ryu.studyhelper.recommendation.domain.Recommendation; +import com.ryu.studyhelper.recommendation.domain.RecommendationProblem; +import com.ryu.studyhelper.recommendation.domain.RecommendationType; +import com.ryu.studyhelper.recommendation.domain.member.MemberRecommendation; +import com.ryu.studyhelper.recommendation.dto.projection.ProblemWithSolvedStatusProjection; +import com.ryu.studyhelper.recommendation.dto.response.MyTodayProblemsResponse; +import com.ryu.studyhelper.recommendation.dto.response.RecommendationDetailResponse; +import com.ryu.studyhelper.recommendation.dto.response.TodayProblemResponse; +import com.ryu.studyhelper.recommendation.repository.MemberRecommendationRepository; +import com.ryu.studyhelper.recommendation.repository.RecommendationProblemRepository; +import com.ryu.studyhelper.recommendation.repository.RecommendationRepository; +import com.ryu.studyhelper.problem.repository.ProblemTagRepository; +import com.ryu.studyhelper.team.domain.Team; +import com.ryu.studyhelper.team.domain.TeamMember; +import com.ryu.studyhelper.team.repository.TeamMemberRepository; +import com.ryu.studyhelper.team.repository.TeamRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import java.util.Optional; + +/** + * 추천 CRUD + 조회 + * 수동 추천 생성, 오늘의 추천 조회 등 컨트롤러/다른 도메인이 직접 호출하는 메서드 + */ +@Service +@RequiredArgsConstructor +@Transactional +@Slf4j +public class RecommendationService { + + private static final LocalTime BLOCKED_START_TIME = LocalTime.of(5, 0); + private static final LocalTime BLOCKED_END_TIME = LocalTime.of(7, 0); + + private final Clock clock; + private final TeamRepository teamRepository; + private final TeamMemberRepository teamMemberRepository; + private final ProblemTagRepository problemTagRepository; + private final RecommendationRepository recommendationRepository; + private final RecommendationProblemRepository recommendationProblemRepository; + private final MemberRecommendationRepository memberRecommendationRepository; + private final RecommendationCreator recommendationCreator; + private final RecommendationEmailService recommendationEmailService; + + /** + * 수동 추천 생성 (팀장 요청) + * 즉시 이메일 발송 + */ + public RecommendationDetailResponse createManualRecommendation(Long teamId) { + Team team = teamRepository.findById(teamId) + .orElseThrow(() -> new CustomException(CustomResponseStatus.TEAM_NOT_FOUND)); + + validateNoRecommendationToday(teamId); + + Recommendation recommendation = recommendationCreator.create(team, RecommendationType.MANUAL); + + // 즉시 이메일 발송 + List memberRecommendations = + memberRecommendationRepository.findByRecommendationId(recommendation.getId()); + recommendationEmailService.send(memberRecommendations); + + List problems = recommendation.getProblems().stream() + .map(RecommendationProblem::getProblem) + .toList(); + return RecommendationDetailResponse.from(recommendation, team, problems); + } + + /** + * 특정 팀의 오늘 추천 조회 (사용자별 해결 여부 포함) + */ + @Transactional(readOnly = true) + public TodayProblemResponse getTodayRecommendation(Long teamId, Long memberId) { + return findTodayRecommendation(teamId, memberId) + .orElseThrow(() -> new CustomException(CustomResponseStatus.RECOMMENDATION_NOT_FOUND)); + } + + /** + * 특정 팀의 오늘 추천 조회 (Optional 반환) + */ + @Transactional(readOnly = true) + public Optional findTodayRecommendation(Long teamId, Long memberId) { + LocalDateTime missionCycleStart = MissionCyclePolicy.getMissionCycleStart(clock); + + return recommendationRepository.findFirstByTeamIdOrderByCreatedAtDesc(teamId) + .filter(recommendation -> !recommendation.getCreatedAt().isBefore(missionCycleStart)) + .map(recommendation -> { + List problemsWithStatus = recommendationProblemRepository + .findProblemsWithSolvedStatus(recommendation.getId(), memberId); + + List problemIds = problemsWithStatus.stream() + .map(ProblemWithSolvedStatusProjection::getProblemId) + .toList(); + List tagProjections = problemIds.isEmpty() + ? List.of() + : problemTagRepository.findTagsByProblemIds(problemIds); + + return TodayProblemResponse.from(recommendation, problemsWithStatus, tagProjections); + }); + } + + /** + * 로그인한 유저가 속한 모든 팀의 오늘 추천 문제 조회 + */ + @Transactional(readOnly = true) + public MyTodayProblemsResponse getMyTodayProblems(Long memberId) { + List teamMemberships = teamMemberRepository.findByMemberId(memberId); + + List teamProblems = teamMemberships.stream() + .map(tm -> { + Team team = tm.getTeam(); + return findTodayRecommendation(team.getId(), memberId) + .map(todayProblem -> MyTodayProblemsResponse.TeamTodayProblems.from( + team.getId(), + team.getName(), + todayProblem + )); + }) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + + return MyTodayProblemsResponse.from(teamProblems); + } + + private void validateNoRecommendationToday(Long teamId) { + LocalTime now = LocalTime.now(clock); + + if (!now.isBefore(BLOCKED_START_TIME) && now.isBefore(BLOCKED_END_TIME)) { + throw new CustomException(CustomResponseStatus.RECOMMENDATION_BLOCKED_TIME); + } + + LocalDateTime missionCycleStart = MissionCyclePolicy.getMissionCycleStart(clock); + recommendationRepository.findFirstByTeamIdOrderByCreatedAtDesc(teamId) + .filter(recommendation -> !recommendation.getCreatedAt().isBefore(missionCycleStart)) + .ifPresent(recommendation -> { + throw new CustomException(CustomResponseStatus.RECOMMENDATION_ALREADY_EXISTS_TODAY); + }); + } +} diff --git a/src/main/java/com/ryu/studyhelper/recommendation/service/ScheduledRecommendationService.java b/src/main/java/com/ryu/studyhelper/recommendation/service/ScheduledRecommendationService.java new file mode 100644 index 0000000..c15c4ec --- /dev/null +++ b/src/main/java/com/ryu/studyhelper/recommendation/service/ScheduledRecommendationService.java @@ -0,0 +1,77 @@ +package com.ryu.studyhelper.recommendation.service; + +import com.ryu.studyhelper.recommendation.domain.RecommendationType; +import com.ryu.studyhelper.recommendation.repository.RecommendationRepository; +import com.ryu.studyhelper.team.domain.Team; +import com.ryu.studyhelper.team.repository.TeamRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 배치 문제 추천 오케스트레이션 + * 매일 새벽 6시 스케줄러가 호출 + */ +@Service +@RequiredArgsConstructor +@Transactional +@Slf4j +public class ScheduledRecommendationService { + + private final Clock clock; + private final TeamRepository teamRepository; + private final RecommendationRepository recommendationRepository; + private final RecommendationCreator recommendationCreator; + + /** + * 문제 추천만 수행 (이메일 발송 X) + * 미션 사이클 기준(06:00~06:00)으로 중복 체크 + */ + public void prepareDailyRecommendations() { + LocalDateTime now = LocalDateTime.now(clock); + LocalDateTime missionCycleStart = MissionCyclePolicy.getMissionCycleStart(clock); + log.info("문제 추천 준비 시작: {} (미션 사이클: {} 06:00 ~)", now.toLocalDate(), missionCycleStart.toLocalDate()); + + List activeTeams = getActiveTeams(now.toLocalDate()); + int successCount = 0; + int failCount = 0; + + for (Team team : activeTeams) { + try { + if (recommendationRepository.findFirstByTeamIdAndCreatedAtBetweenOrderById( + team.getId(), missionCycleStart, now + ).isPresent()) { + log.debug("팀 '{}'에 대해 현재 미션 사이클({})에 이미 추천 존재 - 스킵", team.getName(), missionCycleStart); + continue; + } + + recommendationCreator.create(team, RecommendationType.SCHEDULED); + successCount++; + log.info("팀 '{}' 문제 추천 완료", team.getName()); + + } catch (Exception e) { + failCount++; + log.error("팀 '{}' 문제 추천 실패", team.getName(), e); + } + } + + log.info("문제 추천 배치 완료 - 대상: {}개, 성공: {}개, 실패: {}개", + activeTeams.size(), successCount, failCount); + } + + private List getActiveTeams(LocalDate date) { + DayOfWeek dayOfWeek = date.getDayOfWeek(); + return teamRepository.findAll().stream() + .filter(team -> !team.getTeamMembers().isEmpty()) + .filter(Team::isRecommendationActive) + .filter(team -> team.isRecommendationDay(dayOfWeek)) + .toList(); + } +} diff --git a/src/main/java/com/ryu/studyhelper/team/domain/Team.java b/src/main/java/com/ryu/studyhelper/team/domain/Team.java index f25379b..d618ee5 100644 --- a/src/main/java/com/ryu/studyhelper/team/domain/Team.java +++ b/src/main/java/com/ryu/studyhelper/team/domain/Team.java @@ -1,7 +1,6 @@ package com.ryu.studyhelper.team.domain; import com.ryu.studyhelper.common.entity.BaseEntity; -import com.ryu.studyhelper.recommendation.domain.team.TeamRecommendation; import jakarta.persistence.*; import lombok.*; @@ -66,10 +65,6 @@ public class Team extends BaseEntity { @Builder.Default private List teamMembers = new ArrayList<>(); - @OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true) - @Builder.Default - private List teamRecommendations = new ArrayList<>(); - /** * 팀 생성을 위한 팩토리 메서드 (최초 생성시 추천 비활성화) * @param name 팀 이름 diff --git a/src/main/java/com/ryu/studyhelper/team/service/TeamService.java b/src/main/java/com/ryu/studyhelper/team/service/TeamService.java index d709ee7..a4b6a1a 100644 --- a/src/main/java/com/ryu/studyhelper/team/service/TeamService.java +++ b/src/main/java/com/ryu/studyhelper/team/service/TeamService.java @@ -15,7 +15,7 @@ import com.ryu.studyhelper.team.domain.TeamIncludeTag; import com.ryu.studyhelper.team.domain.TeamMember; import com.ryu.studyhelper.team.domain.TeamRole; -import com.ryu.studyhelper.recommendation.RecommendationService; +import com.ryu.studyhelper.recommendation.service.RecommendationService; import com.ryu.studyhelper.recommendation.dto.response.TodayProblemResponse; import com.ryu.studyhelper.team.dto.request.CreateTeamRequest; import com.ryu.studyhelper.team.dto.request.InviteMemberRequest; diff --git a/src/test/java/com/ryu/studyhelper/recommendation/RecommendationServiceTest.java b/src/test/java/com/ryu/studyhelper/recommendation/service/RecommendationServiceTest.java similarity index 67% rename from src/test/java/com/ryu/studyhelper/recommendation/RecommendationServiceTest.java rename to src/test/java/com/ryu/studyhelper/recommendation/service/RecommendationServiceTest.java index 1b1a927..e5171e1 100644 --- a/src/test/java/com/ryu/studyhelper/recommendation/RecommendationServiceTest.java +++ b/src/test/java/com/ryu/studyhelper/recommendation/service/RecommendationServiceTest.java @@ -1,37 +1,30 @@ -package com.ryu.studyhelper.recommendation; +package com.ryu.studyhelper.recommendation.service; import com.ryu.studyhelper.common.enums.CustomResponseStatus; import com.ryu.studyhelper.common.exception.CustomException; -import com.ryu.studyhelper.infrastructure.mail.sender.MailSender; -import com.ryu.studyhelper.recommendation.mail.RecommendationMailBuilder; +import com.ryu.studyhelper.member.domain.Member; import com.ryu.studyhelper.problem.repository.ProblemTagRepository; -import com.ryu.studyhelper.problem.service.ProblemService; -import com.ryu.studyhelper.problem.service.ProblemSyncService; import com.ryu.studyhelper.recommendation.domain.Recommendation; -import com.ryu.studyhelper.team.repository.TeamIncludeTagRepository; +import com.ryu.studyhelper.recommendation.domain.RecommendationType; +import com.ryu.studyhelper.recommendation.dto.response.MyTodayProblemsResponse; import com.ryu.studyhelper.recommendation.repository.MemberRecommendationRepository; import com.ryu.studyhelper.recommendation.repository.RecommendationProblemRepository; import com.ryu.studyhelper.recommendation.repository.RecommendationRepository; +import com.ryu.studyhelper.team.domain.Team; +import com.ryu.studyhelper.team.domain.TeamMember; +import com.ryu.studyhelper.team.domain.TeamRole; import com.ryu.studyhelper.team.repository.TeamMemberRepository; import com.ryu.studyhelper.team.repository.TeamRepository; -import com.ryu.studyhelper.team.domain.Team; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; -import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.ryu.studyhelper.member.domain.Member; -import com.ryu.studyhelper.recommendation.dto.response.MyTodayProblemsResponse; -import com.ryu.studyhelper.team.domain.TeamMember; -import com.ryu.studyhelper.team.domain.TeamRole; - import java.time.Clock; -import java.time.DayOfWeek; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; @@ -46,10 +39,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -/** - * RecommendationService 테스트 - * Clock 주입을 통해 시간 의존적 로직을 테스트합니다. - */ @ExtendWith(MockitoExtension.class) @DisplayName("RecommendationService 테스트") class RecommendationServiceTest { @@ -60,41 +49,29 @@ class RecommendationServiceTest { @Mock private TeamMemberRepository teamMemberRepository; - @Mock - private TeamIncludeTagRepository teamIncludeTagRepository; - @Mock private ProblemTagRepository problemTagRepository; @Mock - private ProblemService problemService; - - @Mock - private ProblemSyncService problemSyncService; - - @Mock - private MailSender mailSender; + private RecommendationRepository recommendationRepository; @Mock - private RecommendationMailBuilder recommendationMailBuilder; + private RecommendationProblemRepository recommendationProblemRepository; @Mock - private RecommendationRepository recommendationRepository; + private MemberRecommendationRepository memberRecommendationRepository; @Mock - private RecommendationProblemRepository recommendationProblemRepository; + private RecommendationCreator recommendationCreator; @Mock - private MemberRecommendationRepository memberRecommendationRepository; + private RecommendationEmailService recommendationEmailService; private RecommendationService recommendationService; private static final ZoneId ZONE_ID = ZoneId.of("Asia/Seoul"); private static final Long TEAM_ID = 1L; - /** - * 특정 시간으로 고정된 Clock 생성 - */ private Clock fixedClock(String dateTime) { LocalDateTime ldt = LocalDateTime.parse(dateTime); Instant instant = ldt.atZone(ZONE_ID).toInstant(); @@ -106,15 +83,12 @@ private void setupServiceWithClock(Clock clock) { clock, teamRepository, teamMemberRepository, - teamIncludeTagRepository, problemTagRepository, - problemService, - problemSyncService, - mailSender, - recommendationMailBuilder, recommendationRepository, recommendationProblemRepository, - memberRecommendationRepository + memberRecommendationRepository, + recommendationCreator, + recommendationEmailService ); } @@ -138,7 +112,7 @@ void blockedTime_throwsException(String dateTime) { when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team)); // when & then - assertThatThrownBy(() -> recommendationService.createManualRecommendation(TEAM_ID, 3)) + assertThatThrownBy(() -> recommendationService.createManualRecommendation(TEAM_ID)) .isInstanceOf(CustomException.class) .satisfies(ex -> { CustomException customEx = (CustomException) ex; @@ -164,13 +138,14 @@ void allowedTime_passesTimeValidation(String dateTime) { when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team)); when(recommendationRepository.findFirstByTeamIdOrderByCreatedAtDesc(TEAM_ID)) .thenReturn(Optional.empty()); - when(teamMemberRepository.findHandlesByTeamId(TEAM_ID)) - .thenReturn(java.util.List.of()); + when(recommendationCreator.create(any(), any())) + .thenReturn(createRecommendationWithCreatedAt(TEAM_ID, LocalDateTime.now())); + + // when: 시간 검증 통과하여 정상 실행 + recommendationService.createManualRecommendation(TEAM_ID); - // when & then: 시간 검증 통과, 핸들 없어서 실패 - assertThatThrownBy(() -> recommendationService.createManualRecommendation(TEAM_ID, 3)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("인증된 핸들이 없습니다"); + // then: Creator가 호출되었음을 검증 (시간 검증 통과) + verify(recommendationCreator).create(any(), eq(RecommendationType.MANUAL)); } } @@ -196,7 +171,7 @@ void existingRecommendationInCycle_throwsException() { .thenReturn(Optional.of(existingRecommendation)); // when & then - assertThatThrownBy(() -> recommendationService.createManualRecommendation(TEAM_ID, 3)) + assertThatThrownBy(() -> recommendationService.createManualRecommendation(TEAM_ID)) .isInstanceOf(CustomException.class) .satisfies(ex -> { CustomException customEx = (CustomException) ex; @@ -221,13 +196,14 @@ void previousCycleRecommendation_allowsNewRecommendation() { ); when(recommendationRepository.findFirstByTeamIdOrderByCreatedAtDesc(TEAM_ID)) .thenReturn(Optional.of(oldRecommendation)); - when(teamMemberRepository.findHandlesByTeamId(TEAM_ID)) - .thenReturn(java.util.List.of()); + when(recommendationCreator.create(any(), any())) + .thenReturn(createRecommendationWithCreatedAt(TEAM_ID, LocalDateTime.now())); - // when & then: 사이클 검증 통과, 핸들 없어서 실패 - assertThatThrownBy(() -> recommendationService.createManualRecommendation(TEAM_ID, 3)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("인증된 핸들이 없습니다"); + // when: 사이클 검증 통과하여 정상 실행 + recommendationService.createManualRecommendation(TEAM_ID); + + // then + verify(recommendationCreator).create(any(), eq(RecommendationType.MANUAL)); } @Test @@ -250,7 +226,7 @@ void before6AM_usesYesterdayCycleStart() { .thenReturn(Optional.of(existingRecommendation)); // when & then - assertThatThrownBy(() -> recommendationService.createManualRecommendation(TEAM_ID, 3)) + assertThatThrownBy(() -> recommendationService.createManualRecommendation(TEAM_ID)) .isInstanceOf(CustomException.class) .satisfies(ex -> { CustomException customEx = (CustomException) ex; @@ -277,7 +253,7 @@ void exactlyAt6AM_isIncludedInCurrentCycle() { .thenReturn(Optional.of(existingRecommendation)); // when & then - assertThatThrownBy(() -> recommendationService.createManualRecommendation(TEAM_ID, 3)) + assertThatThrownBy(() -> recommendationService.createManualRecommendation(TEAM_ID)) .isInstanceOf(CustomException.class) .satisfies(ex -> { CustomException customEx = (CustomException) ex; @@ -321,8 +297,8 @@ void emailSendableTime_correctBehavior(String dateTime, boolean shouldSendImmedi class ManualRecommendationProblemCountValidation { @Test - @DisplayName("항상 팀 설정의 problemCount를 사용한다 (count 파라미터 무시)") - void alwaysUsesTeamProblemCount() { + @DisplayName("팀 설정의 problemCount를 사용하여 추천을 생성한다") + void usesTeamProblemCount() { // given: 오전 10시 (금지 시간대 외) Clock clock = fixedClock("2025-01-15T10:00:00"); setupServiceWithClock(clock); @@ -332,205 +308,14 @@ void alwaysUsesTeamProblemCount() { when(teamRepository.findById(TEAM_ID)).thenReturn(Optional.of(team)); when(recommendationRepository.findFirstByTeamIdOrderByCreatedAtDesc(TEAM_ID)) .thenReturn(Optional.empty()); - when(teamMemberRepository.findHandlesByTeamId(TEAM_ID)) - .thenReturn(List.of("handle1")); - when(teamIncludeTagRepository.findTagKeysByTeamId(TEAM_ID)) - .thenReturn(List.of()); - when(problemService.recommend(any(), eq(5), any(), any(), any())) - .thenReturn(List.of()); - when(problemSyncService.syncProblems(any())) - .thenReturn(List.of()); - when(teamMemberRepository.findMembersByTeamId(TEAM_ID)) - .thenReturn(List.of()); - - // when: count 파라미터로 7을 전달해도 팀 설정값 5가 사용됨 - recommendationService.createManualRecommendation(TEAM_ID, 7); - - // then: 팀 설정값 5개의 문제가 요청되었는지 검증 (count 파라미터 무시) - verify(problemService).recommend(any(), eq(5), any(), any(), any()); - } - } - - @Nested - @DisplayName("스케줄러 미션 사이클 기반 추천 검증") - class SchedulerMissionCycleValidation { - - @Test - @DisplayName("prepareDailyRecommendations()는 미션 사이클 시작(06:00)부터 현재까지의 범위로 조회한다") - void prepareDailyRecommendations_usesMissionCycleRange() { - // given: 오전 6시 스케줄러 실행 (수요일) - Clock clock = fixedClock("2025-01-15T06:00:00"); - setupServiceWithClock(clock); - - // 수요일에 추천 활성화된 팀 설정 - Team team = createTeamWithIdAndRecommendationDay(TEAM_ID, DayOfWeek.WEDNESDAY); - when(teamRepository.findAll()).thenReturn(List.of(team)); - - // 현재 미션 사이클 내 추천 없음 - when(recommendationRepository.findFirstByTeamIdAndCreatedAtBetweenOrderById( - eq(TEAM_ID), any(LocalDateTime.class), any(LocalDateTime.class) - )).thenReturn(Optional.empty()); - - // 핸들 없어서 추천 생성 실패하도록 설정 (범위 검증이 목적) - when(teamMemberRepository.findHandlesByTeamId(TEAM_ID)).thenReturn(List.of()); - - // when - recommendationService.prepareDailyRecommendations(); - - // then: 미션 사이클 범위(06:00 ~ now)로 조회했는지 검증 - ArgumentCaptor fromCaptor = ArgumentCaptor.forClass(LocalDateTime.class); - ArgumentCaptor toCaptor = ArgumentCaptor.forClass(LocalDateTime.class); - - verify(recommendationRepository).findFirstByTeamIdAndCreatedAtBetweenOrderById( - eq(TEAM_ID), fromCaptor.capture(), toCaptor.capture() - ); - - LocalDateTime capturedFrom = fromCaptor.getValue(); - LocalDateTime capturedTo = toCaptor.getValue(); - - // 미션 사이클 시작: 2025-01-15 06:00 (0시가 아님!) - assertThat(capturedFrom).isEqualTo(LocalDateTime.parse("2025-01-15T06:00:00")); - assertThat(capturedTo).isEqualTo(LocalDateTime.parse("2025-01-15T06:00:00")); - } - - @Test - @DisplayName("0시~6시 사이 수동 추천은 이전 미션 사이클로 간주되어 6시 스케줄러에서 조회되지 않는다") - void manualAt0AM_notFoundBySchedulerAt6AM() { - // given: 오전 6시 스케줄러 실행 (수요일) - Clock clock = fixedClock("2025-01-15T06:00:00"); - setupServiceWithClock(clock); - - Team team = createTeamWithIdAndRecommendationDay(TEAM_ID, DayOfWeek.WEDNESDAY); - when(teamRepository.findAll()).thenReturn(List.of(team)); - - // 0:30에 생성된 수동 추천 - 이전 미션 사이클(01-14 06:00 ~ 01-15 06:00)에 속함 - // 스케줄러가 조회하는 범위(01-15 06:00~)에 포함되지 않음 - when(recommendationRepository.findFirstByTeamIdAndCreatedAtBetweenOrderById( - eq(TEAM_ID), - eq(LocalDateTime.parse("2025-01-15T06:00:00")), - eq(LocalDateTime.parse("2025-01-15T06:00:00")) - )).thenReturn(Optional.empty()); // 0:30 추천은 범위 밖이므로 조회 안됨 - - when(teamMemberRepository.findHandlesByTeamId(TEAM_ID)).thenReturn(List.of()); - - // when - recommendationService.prepareDailyRecommendations(); - - // then: 미션 사이클 범위로 조회 확인 (0시가 아닌 6시부터) - verify(recommendationRepository).findFirstByTeamIdAndCreatedAtBetweenOrderById( - eq(TEAM_ID), - eq(LocalDateTime.parse("2025-01-15T06:00:00")), - eq(LocalDateTime.parse("2025-01-15T06:00:00")) - ); - } - - @Test - @DisplayName("캘린더 날짜(0시)가 아닌 미션 사이클(6시) 기준으로 조회해야 한다") - void shouldUseMissionCycleNotCalendarDate() { - // given: 6시 30분 스케줄러 실행 - Clock clock = fixedClock("2025-01-15T06:30:00"); - setupServiceWithClock(clock); - - Team team = createTeamWithIdAndRecommendationDay(TEAM_ID, DayOfWeek.WEDNESDAY); - when(teamRepository.findAll()).thenReturn(List.of(team)); - when(recommendationRepository.findFirstByTeamIdAndCreatedAtBetweenOrderById( - any(), any(), any() - )).thenReturn(Optional.empty()); - when(teamMemberRepository.findHandlesByTeamId(TEAM_ID)).thenReturn(List.of()); + when(recommendationCreator.create(any(), any())) + .thenReturn(createRecommendationWithCreatedAt(TEAM_ID, LocalDateTime.now())); // when - recommendationService.prepareDailyRecommendations(); - - // then - ArgumentCaptor fromCaptor = ArgumentCaptor.forClass(LocalDateTime.class); - verify(recommendationRepository).findFirstByTeamIdAndCreatedAtBetweenOrderById( - eq(TEAM_ID), fromCaptor.capture(), any() - ); - - // 핵심: 0시가 아닌 6시부터 조회해야 함 - LocalDateTime from = fromCaptor.getValue(); - assertThat(from.getHour()).isEqualTo(6); - assertThat(from.getMinute()).isEqualTo(0); - } - } - - /** - * 추천 요일이 설정된 Team 생성 (테스트용) - * - getActiveTeams() 필터를 통과하려면 teamMembers가 비어있지 않아야 함 - */ - private Team createTeamWithIdAndRecommendationDay(Long id, DayOfWeek dayOfWeek) { - Team team = Team.create("테스트팀", "설명", false); - try { - java.lang.reflect.Field idField = team.getClass().getDeclaredField("id"); - idField.setAccessible(true); - idField.set(team, id); - - // 추천 활성화 및 요일 설정 - com.ryu.studyhelper.team.domain.RecommendationDayOfWeek recommendationDay = - com.ryu.studyhelper.team.domain.RecommendationDayOfWeek.from(dayOfWeek); - team.updateRecommendationDays(List.of(recommendationDay)); - - // 팀원 목록에 더미 데이터 추가 (getActiveTeams() 필터 통과용) - java.lang.reflect.Field teamMembersField = team.getClass().getDeclaredField("teamMembers"); - teamMembersField.setAccessible(true); - @SuppressWarnings("unchecked") - java.util.List teamMembers = (java.util.List) teamMembersField.get(team); - teamMembers.add(new Object()); // 더미 객체 추가 - } catch (Exception e) { - throw new RuntimeException("Team 설정 실패", e); - } - return team; - } - - /** - * ID가 설정된 Team 생성 (테스트용) - */ - private Team createTeamWithId(Long id) { - Team team = Team.create("테스트팀", "설명", false); - try { - java.lang.reflect.Field idField = team.getClass().getDeclaredField("id"); - idField.setAccessible(true); - idField.set(team, id); - } catch (Exception e) { - throw new RuntimeException("id 설정 실패", e); - } - return team; - } - - /** - * ID와 problemCount가 설정된 Team 생성 (테스트용) - */ - private Team createTeamWithIdAndProblemCount(Long id, int problemCount) { - Team team = createTeamWithId(id); - team.updateProblemCount(problemCount); - return team; - } + recommendationService.createManualRecommendation(TEAM_ID); - /** - * createdAt이 설정된 스케줄 Recommendation 생성 (테스트용) - */ - private Recommendation createRecommendationWithCreatedAt(Long teamId, LocalDateTime createdAt) { - Recommendation recommendation = Recommendation.createScheduledRecommendation(teamId); - setCreatedAt(recommendation, createdAt); - return recommendation; - } - - /** - * createdAt이 설정된 수동 Recommendation 생성 (테스트용) - */ - private Recommendation createManualRecommendationWithCreatedAt(Long teamId, LocalDateTime createdAt) { - Recommendation recommendation = Recommendation.createManualRecommendation(teamId); - setCreatedAt(recommendation, createdAt); - return recommendation; - } - - private void setCreatedAt(Recommendation recommendation, LocalDateTime createdAt) { - try { - java.lang.reflect.Field createdAtField = recommendation.getClass().getSuperclass().getDeclaredField("createdAt"); - createdAtField.setAccessible(true); - createdAtField.set(recommendation, createdAt); - } catch (Exception e) { - throw new RuntimeException("createdAt 설정 실패", e); + // then: Creator가 team 객체(problemCount=5)와 MANUAL 타입으로 호출됨 + verify(recommendationCreator).create(team, RecommendationType.MANUAL); } } @@ -720,4 +505,40 @@ private void setRecommendationId(Recommendation recommendation, Long id) { } } } -} \ No newline at end of file + + // === Helper Methods === + + private Team createTeamWithId(Long id) { + Team team = Team.create("테스트팀", "설명", false); + try { + java.lang.reflect.Field idField = team.getClass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(team, id); + } catch (Exception e) { + throw new RuntimeException("id 설정 실패", e); + } + return team; + } + + private Team createTeamWithIdAndProblemCount(Long id, int problemCount) { + Team team = createTeamWithId(id); + team.updateProblemCount(problemCount); + return team; + } + + private Recommendation createRecommendationWithCreatedAt(Long teamId, LocalDateTime createdAt) { + Recommendation recommendation = Recommendation.createScheduledRecommendation(teamId); + setCreatedAt(recommendation, createdAt); + return recommendation; + } + + private void setCreatedAt(Recommendation recommendation, LocalDateTime createdAt) { + try { + java.lang.reflect.Field createdAtField = recommendation.getClass().getSuperclass().getDeclaredField("createdAt"); + createdAtField.setAccessible(true); + createdAtField.set(recommendation, createdAt); + } catch (Exception e) { + throw new RuntimeException("createdAt 설정 실패", e); + } + } +} diff --git a/src/test/java/com/ryu/studyhelper/recommendation/service/ScheduledRecommendationServiceTest.java b/src/test/java/com/ryu/studyhelper/recommendation/service/ScheduledRecommendationServiceTest.java new file mode 100644 index 0000000..39f9028 --- /dev/null +++ b/src/test/java/com/ryu/studyhelper/recommendation/service/ScheduledRecommendationServiceTest.java @@ -0,0 +1,177 @@ +package com.ryu.studyhelper.recommendation.service; + +import com.ryu.studyhelper.recommendation.repository.RecommendationRepository; +import com.ryu.studyhelper.team.domain.Team; +import com.ryu.studyhelper.team.repository.TeamRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Clock; +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ScheduledRecommendationService 테스트") +class ScheduledRecommendationServiceTest { + + @Mock + private TeamRepository teamRepository; + + @Mock + private RecommendationRepository recommendationRepository; + + @Mock + private RecommendationCreator recommendationCreator; + + private ScheduledRecommendationService scheduledRecommendationService; + + private static final ZoneId ZONE_ID = ZoneId.of("Asia/Seoul"); + private static final Long TEAM_ID = 1L; + + private Clock fixedClock(String dateTime) { + LocalDateTime ldt = LocalDateTime.parse(dateTime); + Instant instant = ldt.atZone(ZONE_ID).toInstant(); + return Clock.fixed(instant, ZONE_ID); + } + + private void setupServiceWithClock(Clock clock) { + scheduledRecommendationService = new ScheduledRecommendationService( + clock, + teamRepository, + recommendationRepository, + recommendationCreator + ); + } + + @Nested + @DisplayName("스케줄러 미션 사이클 기반 추천 검증") + class SchedulerMissionCycleValidation { + + @Test + @DisplayName("prepareDailyRecommendations()는 미션 사이클 시작(06:00)부터 현재까지의 범위로 조회한다") + void prepareDailyRecommendations_usesMissionCycleRange() { + // given: 오전 6시 스케줄러 실행 (수요일) + Clock clock = fixedClock("2025-01-15T06:00:00"); + setupServiceWithClock(clock); + + Team team = createTeamWithIdAndRecommendationDay(TEAM_ID, DayOfWeek.WEDNESDAY); + when(teamRepository.findAll()).thenReturn(List.of(team)); + + when(recommendationRepository.findFirstByTeamIdAndCreatedAtBetweenOrderById( + eq(TEAM_ID), any(LocalDateTime.class), any(LocalDateTime.class) + )).thenReturn(Optional.empty()); + + // when + scheduledRecommendationService.prepareDailyRecommendations(); + + // then: 미션 사이클 범위(06:00 ~ now)로 조회했는지 검증 + ArgumentCaptor fromCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor toCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + + verify(recommendationRepository).findFirstByTeamIdAndCreatedAtBetweenOrderById( + eq(TEAM_ID), fromCaptor.capture(), toCaptor.capture() + ); + + LocalDateTime capturedFrom = fromCaptor.getValue(); + LocalDateTime capturedTo = toCaptor.getValue(); + + // 미션 사이클 시작: 2025-01-15 06:00 (0시가 아님!) + assertThat(capturedFrom).isEqualTo(LocalDateTime.parse("2025-01-15T06:00:00")); + assertThat(capturedTo).isEqualTo(LocalDateTime.parse("2025-01-15T06:00:00")); + } + + @Test + @DisplayName("0시~6시 사이 수동 추천은 이전 미션 사이클로 간주되어 6시 스케줄러에서 조회되지 않는다") + void manualAt0AM_notFoundBySchedulerAt6AM() { + // given: 오전 6시 스케줄러 실행 (수요일) + Clock clock = fixedClock("2025-01-15T06:00:00"); + setupServiceWithClock(clock); + + Team team = createTeamWithIdAndRecommendationDay(TEAM_ID, DayOfWeek.WEDNESDAY); + when(teamRepository.findAll()).thenReturn(List.of(team)); + + when(recommendationRepository.findFirstByTeamIdAndCreatedAtBetweenOrderById( + eq(TEAM_ID), + eq(LocalDateTime.parse("2025-01-15T06:00:00")), + eq(LocalDateTime.parse("2025-01-15T06:00:00")) + )).thenReturn(Optional.empty()); + + // when + scheduledRecommendationService.prepareDailyRecommendations(); + + // then: 미션 사이클 범위로 조회 확인 (0시가 아닌 6시부터) + verify(recommendationRepository).findFirstByTeamIdAndCreatedAtBetweenOrderById( + eq(TEAM_ID), + eq(LocalDateTime.parse("2025-01-15T06:00:00")), + eq(LocalDateTime.parse("2025-01-15T06:00:00")) + ); + } + + @Test + @DisplayName("캘린더 날짜(0시)가 아닌 미션 사이클(6시) 기준으로 조회해야 한다") + void shouldUseMissionCycleNotCalendarDate() { + // given: 6시 30분 스케줄러 실행 + Clock clock = fixedClock("2025-01-15T06:30:00"); + setupServiceWithClock(clock); + + Team team = createTeamWithIdAndRecommendationDay(TEAM_ID, DayOfWeek.WEDNESDAY); + when(teamRepository.findAll()).thenReturn(List.of(team)); + when(recommendationRepository.findFirstByTeamIdAndCreatedAtBetweenOrderById( + any(), any(), any() + )).thenReturn(Optional.empty()); + + // when + scheduledRecommendationService.prepareDailyRecommendations(); + + // then + ArgumentCaptor fromCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(recommendationRepository).findFirstByTeamIdAndCreatedAtBetweenOrderById( + eq(TEAM_ID), fromCaptor.capture(), any() + ); + + // 핵심: 0시가 아닌 6시부터 조회해야 함 + LocalDateTime from = fromCaptor.getValue(); + assertThat(from.getHour()).isEqualTo(6); + assertThat(from.getMinute()).isEqualTo(0); + } + } + + // === Helper Methods === + + private Team createTeamWithIdAndRecommendationDay(Long id, DayOfWeek dayOfWeek) { + Team team = Team.create("테스트팀", "설명", false); + try { + java.lang.reflect.Field idField = team.getClass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(team, id); + + com.ryu.studyhelper.team.domain.RecommendationDayOfWeek recommendationDay = + com.ryu.studyhelper.team.domain.RecommendationDayOfWeek.from(dayOfWeek); + team.updateRecommendationDays(List.of(recommendationDay)); + + java.lang.reflect.Field teamMembersField = team.getClass().getDeclaredField("teamMembers"); + teamMembersField.setAccessible(true); + @SuppressWarnings("unchecked") + java.util.List teamMembers = (java.util.List) teamMembersField.get(team); + teamMembers.add(new Object()); + } catch (Exception e) { + throw new RuntimeException("Team 설정 실패", e); + } + return team; + } +} From 4681c3a841d3218e4701ef8bf908ebf83d24fe46 Mon Sep 17 00:00:00 2001 From: ryuwldnjs Date: Sat, 14 Feb 2026 16:03:38 +0900 Subject: [PATCH 2/4] =?UTF-8?q?recommendation:=20RecommendationServiceTest?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 죽은 테스트 EmailSendableTimeValidation 제거 - 내부 helper를 클래스 레벨로 승격 (createTeamMember, setRecommendationId 등) - reflection helper를 setFieldValue로 통합 - MEMBER_ID 클래스 상수로 승격, 중복 TEAM_ID_1 제거 --- .../service/RecommendationServiceTest.java | 154 ++++++------------ 1 file changed, 48 insertions(+), 106 deletions(-) diff --git a/src/test/java/com/ryu/studyhelper/recommendation/service/RecommendationServiceTest.java b/src/test/java/com/ryu/studyhelper/recommendation/service/RecommendationServiceTest.java index e5171e1..e17dbb2 100644 --- a/src/test/java/com/ryu/studyhelper/recommendation/service/RecommendationServiceTest.java +++ b/src/test/java/com/ryu/studyhelper/recommendation/service/RecommendationServiceTest.java @@ -71,6 +71,7 @@ class RecommendationServiceTest { private static final ZoneId ZONE_ID = ZoneId.of("Asia/Seoul"); private static final Long TEAM_ID = 1L; + private static final Long MEMBER_ID = 100L; private Clock fixedClock(String dateTime) { LocalDateTime ldt = LocalDateTime.parse(dateTime); @@ -262,36 +263,6 @@ void exactlyAt6AM_isIncludedInCurrentCycle() { } } - @Nested - @DisplayName("이메일 즉시 발송 시간 검증") - class EmailSendableTimeValidation { - - @ParameterizedTest - @CsvSource({ - "2025-01-15T09:00:00, true", - "2025-01-15T12:00:00, true", - "2025-01-15T23:59:59, true", - "2025-01-15T00:00:00, true", - "2025-01-15T00:59:59, true", - "2025-01-15T02:00:00, false", - "2025-01-15T05:00:00, false", - "2025-01-15T08:59:59, false" - }) - @DisplayName("시간대별 이메일 즉시 발송 여부 로직 검증") - void emailSendableTime_correctBehavior(String dateTime, boolean shouldSendImmediately) { - // isEmailSendableTime() 로직 검증 - LocalDateTime time = LocalDateTime.parse(dateTime); - java.time.LocalTime localTime = time.toLocalTime(); - - java.time.LocalTime emailStart = java.time.LocalTime.of(9, 0); - java.time.LocalTime blockedStart = java.time.LocalTime.of(1, 0); - - boolean result = !localTime.isBefore(emailStart) || localTime.isBefore(blockedStart); - - assertThat(result).isEqualTo(shouldSendImmediately); - } - } - @Nested @DisplayName("수동 추천 problemCount 검증") class ManualRecommendationProblemCountValidation { @@ -323,10 +294,6 @@ void usesTeamProblemCount() { @DisplayName("내 오늘의 문제 전체 조회 (getMyTodayProblems)") class GetMyTodayProblemsValidation { - private static final Long MEMBER_ID = 100L; - private static final Long TEAM_ID_1 = 1L; - private static final Long TEAM_ID_2 = 2L; - @Test @DisplayName("팀에 속하지 않은 유저는 빈 목록을 반환한다") void noTeams_returnsEmptyList() { @@ -352,11 +319,11 @@ void teamsWithNoRecommendation_returnsEmptyList() { Clock clock = fixedClock("2025-01-15T10:00:00"); setupServiceWithClock(clock); - Team team = createTeamWithId(TEAM_ID_1); + Team team = createTeamWithId(TEAM_ID); TeamMember teamMember = createTeamMember(team, MEMBER_ID); when(teamMemberRepository.findByMemberId(MEMBER_ID)).thenReturn(List.of(teamMember)); - when(recommendationRepository.findFirstByTeamIdOrderByCreatedAtDesc(TEAM_ID_1)) + when(recommendationRepository.findFirstByTeamIdOrderByCreatedAtDesc(TEAM_ID)) .thenReturn(Optional.empty()); // when @@ -373,17 +340,17 @@ void teamWithRecommendation_returnsProblems() { Clock clock = fixedClock("2025-01-15T10:00:00"); setupServiceWithClock(clock); - Team team = createTeamWithIdAndName(TEAM_ID_1, "알고리즘 스터디"); + Team team = createTeamWithIdAndName(TEAM_ID, "알고리즘 스터디"); TeamMember teamMember = createTeamMember(team, MEMBER_ID); Recommendation recommendation = createRecommendationWithCreatedAt( - TEAM_ID_1, + TEAM_ID, LocalDateTime.parse("2025-01-15T06:00:00") ); setRecommendationId(recommendation, 42L); when(teamMemberRepository.findByMemberId(MEMBER_ID)).thenReturn(List.of(teamMember)); - when(recommendationRepository.findFirstByTeamIdOrderByCreatedAtDesc(TEAM_ID_1)) + when(recommendationRepository.findFirstByTeamIdOrderByCreatedAtDesc(TEAM_ID)) .thenReturn(Optional.of(recommendation)); lenient().when(recommendationProblemRepository.findProblemsWithSolvedStatus(42L, MEMBER_ID)) .thenReturn(List.of()); @@ -394,7 +361,7 @@ void teamWithRecommendation_returnsProblems() { // then assertThat(response.teams()).hasSize(1); - assertThat(response.teams().get(0).teamId()).isEqualTo(TEAM_ID_1); + assertThat(response.teams().get(0).teamId()).isEqualTo(TEAM_ID); assertThat(response.teams().get(0).teamName()).isEqualTo("알고리즘 스터디"); assertThat(response.teams().get(0).recommendationId()).isEqualTo(42L); } @@ -406,24 +373,24 @@ void multipleTeams_returnsAllTeamsProblems() { Clock clock = fixedClock("2025-01-15T10:00:00"); setupServiceWithClock(clock); - Team team1 = createTeamWithIdAndName(TEAM_ID_1, "팀1"); - Team team2 = createTeamWithIdAndName(TEAM_ID_2, "팀2"); + Team team1 = createTeamWithIdAndName(TEAM_ID, "팀1"); + Team team2 = createTeamWithIdAndName(2L, "팀2"); TeamMember teamMember1 = createTeamMember(team1, MEMBER_ID); TeamMember teamMember2 = createTeamMember(team2, MEMBER_ID); Recommendation recommendation1 = createRecommendationWithCreatedAt( - TEAM_ID_1, LocalDateTime.parse("2025-01-15T06:00:00")); + TEAM_ID, LocalDateTime.parse("2025-01-15T06:00:00")); setRecommendationId(recommendation1, 42L); Recommendation recommendation2 = createRecommendationWithCreatedAt( - TEAM_ID_2, LocalDateTime.parse("2025-01-15T06:00:00")); + 2L, LocalDateTime.parse("2025-01-15T06:00:00")); setRecommendationId(recommendation2, 43L); when(teamMemberRepository.findByMemberId(MEMBER_ID)) .thenReturn(List.of(teamMember1, teamMember2)); - when(recommendationRepository.findFirstByTeamIdOrderByCreatedAtDesc(TEAM_ID_1)) + when(recommendationRepository.findFirstByTeamIdOrderByCreatedAtDesc(TEAM_ID)) .thenReturn(Optional.of(recommendation1)); - when(recommendationRepository.findFirstByTeamIdOrderByCreatedAtDesc(TEAM_ID_2)) + when(recommendationRepository.findFirstByTeamIdOrderByCreatedAtDesc(2L)) .thenReturn(Optional.of(recommendation2)); lenient().when(recommendationProblemRepository.findProblemsWithSolvedStatus(any(), eq(MEMBER_ID))) .thenReturn(List.of()); @@ -443,15 +410,15 @@ void previousCycleRecommendation_notIncluded() { Clock clock = fixedClock("2025-01-15T10:00:00"); setupServiceWithClock(clock); - Team team = createTeamWithId(TEAM_ID_1); + Team team = createTeamWithId(TEAM_ID); TeamMember teamMember = createTeamMember(team, MEMBER_ID); // 어제 추천 (이전 미션 사이클) Recommendation oldRecommendation = createRecommendationWithCreatedAt( - TEAM_ID_1, LocalDateTime.parse("2025-01-14T10:00:00")); + TEAM_ID, LocalDateTime.parse("2025-01-14T10:00:00")); when(teamMemberRepository.findByMemberId(MEMBER_ID)).thenReturn(List.of(teamMember)); - when(recommendationRepository.findFirstByTeamIdOrderByCreatedAtDesc(TEAM_ID_1)) + when(recommendationRepository.findFirstByTeamIdOrderByCreatedAtDesc(TEAM_ID)) .thenReturn(Optional.of(oldRecommendation)); // when @@ -461,62 +428,17 @@ void previousCycleRecommendation_notIncluded() { assertThat(response.teams()).isEmpty(); } - private TeamMember createTeamMember(Team team, Long memberId) { - Member member = createMemberWithId(memberId); - return TeamMember.create(team, member, TeamRole.MEMBER); - } - - private Member createMemberWithId(Long id) { - try { - Member member = Member.builder() - .email("test@test.com") - .provider("google") - .providerId("test-provider-id") - .isVerified(false) - .build(); - java.lang.reflect.Field idField = member.getClass().getDeclaredField("id"); - idField.setAccessible(true); - idField.set(member, id); - return member; - } catch (Exception e) { - throw new RuntimeException("Member 생성 실패", e); - } - } - - private Team createTeamWithIdAndName(Long id, String name) { - Team team = Team.create(name, "설명", false); - try { - java.lang.reflect.Field idField = team.getClass().getDeclaredField("id"); - idField.setAccessible(true); - idField.set(team, id); - } catch (Exception e) { - throw new RuntimeException("id 설정 실패", e); - } - return team; - } - - private void setRecommendationId(Recommendation recommendation, Long id) { - try { - java.lang.reflect.Field idField = recommendation.getClass().getDeclaredField("id"); - idField.setAccessible(true); - idField.set(recommendation, id); - } catch (Exception e) { - throw new RuntimeException("id 설정 실패", e); - } - } } // === Helper Methods === private Team createTeamWithId(Long id) { - Team team = Team.create("테스트팀", "설명", false); - try { - java.lang.reflect.Field idField = team.getClass().getDeclaredField("id"); - idField.setAccessible(true); - idField.set(team, id); - } catch (Exception e) { - throw new RuntimeException("id 설정 실패", e); - } + return createTeamWithIdAndName(id, "테스트팀"); + } + + private Team createTeamWithIdAndName(Long id, String name) { + Team team = Team.create(name, "설명", false); + setFieldValue(team, "id", id); return team; } @@ -526,19 +448,39 @@ private Team createTeamWithIdAndProblemCount(Long id, int problemCount) { return team; } + private TeamMember createTeamMember(Team team, Long memberId) { + Member member = Member.builder() + .email("test@test.com") + .provider("google") + .providerId("test-provider-id") + .isVerified(false) + .build(); + setFieldValue(member, "id", memberId); + return TeamMember.create(team, member, TeamRole.MEMBER); + } + private Recommendation createRecommendationWithCreatedAt(Long teamId, LocalDateTime createdAt) { Recommendation recommendation = Recommendation.createScheduledRecommendation(teamId); - setCreatedAt(recommendation, createdAt); + setFieldValue(recommendation, "createdAt", createdAt, true); return recommendation; } - private void setCreatedAt(Recommendation recommendation, LocalDateTime createdAt) { + private void setRecommendationId(Recommendation recommendation, Long id) { + setFieldValue(recommendation, "id", id); + } + + private void setFieldValue(Object target, String fieldName, Object value) { + setFieldValue(target, fieldName, value, false); + } + + private void setFieldValue(Object target, String fieldName, Object value, boolean superClass) { try { - java.lang.reflect.Field createdAtField = recommendation.getClass().getSuperclass().getDeclaredField("createdAt"); - createdAtField.setAccessible(true); - createdAtField.set(recommendation, createdAt); + Class clazz = superClass ? target.getClass().getSuperclass() : target.getClass(); + java.lang.reflect.Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); } catch (Exception e) { - throw new RuntimeException("createdAt 설정 실패", e); + throw new RuntimeException(fieldName + " 설정 실패", e); } } } From 1cc5177a600161f588d99e7f27b04bec4c03fc95 Mon Sep 17 00:00:00 2001 From: ryuwldnjs Date: Sat, 14 Feb 2026 16:33:40 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix=20:=20=EB=93=A4=EC=97=AC=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../recommendation/scheduler/EmailSendScheduler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/ryu/studyhelper/recommendation/scheduler/EmailSendScheduler.java b/src/main/java/com/ryu/studyhelper/recommendation/scheduler/EmailSendScheduler.java index 8f8218c..b8df577 100644 --- a/src/main/java/com/ryu/studyhelper/recommendation/scheduler/EmailSendScheduler.java +++ b/src/main/java/com/ryu/studyhelper/recommendation/scheduler/EmailSendScheduler.java @@ -35,7 +35,7 @@ public void sendPendingEmails() { } catch (Exception e) { long endTime = System.currentTimeMillis(); - log.error("=== 이메일 발송 배치 작업 실패 === (소요시간: {}ms)", endTime - startTime, e); + log.error("=== 이메일 발송 배치 작업 실패 === (소요시간: {}ms)", endTime - startTime, e); } } From ea7e5ea5e1ea4783fa286e5b45eec562ec2847ae Mon Sep 17 00:00:00 2001 From: ryuwldnjs Date: Sun, 15 Feb 2026 01:22:15 +0900 Subject: [PATCH 4/4] =?UTF-8?q?recommendation:=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81=20=ED=9B=84=EC=86=8D=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EB=88=84=EB=9D=BD=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 스케줄러 테스트용 dead method 제거 - RecommendationCreator 예외를 CustomException으로 통일 - deprecated GET /team/{teamId}/today-problem 엔드포인트 제거 - 미사용 getTodayRecommendation 서비스 메서드 제거 - RecommendationCreatorTest, RecommendationEmailServiceTest 추가 --- .../common/enums/CustomResponseStatus.java | 3 +- .../RecommendationController.java | 32 --- .../scheduler/EmailSendScheduler.java | 20 -- .../ProblemRecommendationScheduler.java | 19 -- .../service/RecommendationCreator.java | 4 +- .../service/RecommendationService.java | 9 - .../service/RecommendationCreatorTest.java | 210 +++++++++++++++++ .../RecommendationEmailServiceTest.java | 223 ++++++++++++++++++ 8 files changed, 438 insertions(+), 82 deletions(-) create mode 100644 src/test/java/com/ryu/studyhelper/recommendation/service/RecommendationCreatorTest.java create mode 100644 src/test/java/com/ryu/studyhelper/recommendation/service/RecommendationEmailServiceTest.java diff --git a/src/main/java/com/ryu/studyhelper/common/enums/CustomResponseStatus.java b/src/main/java/com/ryu/studyhelper/common/enums/CustomResponseStatus.java index f7e37e7..85d6a8c 100644 --- a/src/main/java/com/ryu/studyhelper/common/enums/CustomResponseStatus.java +++ b/src/main/java/com/ryu/studyhelper/common/enums/CustomResponseStatus.java @@ -90,7 +90,8 @@ public enum CustomResponseStatus { */ INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "6000", "내부 서버 오류입니다."), ASYNC_COMPLETION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "6001", "비동기 작업에서 오류가 발생하였습니다."), - SOLVED_AC_API_ERROR(HttpStatus.BAD_GATEWAY.value(), "6002", "solved.ac API 호출에 실패했습니다."); + SOLVED_AC_API_ERROR(HttpStatus.BAD_GATEWAY.value(), "6002", "solved.ac API 호출에 실패했습니다."), + NO_VERIFIED_HANDLE(HttpStatus.BAD_REQUEST.value(), "6003", "팀에 인증된 핸들이 없어 추천을 생성할 수 없습니다."); private final int httpStatusCode; diff --git a/src/main/java/com/ryu/studyhelper/recommendation/RecommendationController.java b/src/main/java/com/ryu/studyhelper/recommendation/RecommendationController.java index 45a94c3..a02ed3f 100644 --- a/src/main/java/com/ryu/studyhelper/recommendation/RecommendationController.java +++ b/src/main/java/com/ryu/studyhelper/recommendation/RecommendationController.java @@ -7,7 +7,6 @@ import com.ryu.studyhelper.infrastructure.ratelimit.RateLimitType; import com.ryu.studyhelper.recommendation.dto.response.MyTodayProblemsResponse; import com.ryu.studyhelper.recommendation.dto.response.RecommendationDetailResponse; -import com.ryu.studyhelper.recommendation.dto.response.TodayProblemResponse; import com.ryu.studyhelper.recommendation.service.RecommendationService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -58,37 +57,6 @@ public ResponseEntity> createManualRec return ResponseEntity.ok(ApiResponse.createSuccess(response, CustomResponseStatus.SUCCESS)); } - @Deprecated - @Operation( - summary = "오늘의 문제 조회 (Deprecated)", - description = """ - **Deprecated**: `/api/recommendation/my/today-problems` 사용 권장 - - 특정 팀의 오늘 추천된 문제를 조회합니다. - 가장 최근 추천(수동 추천 우선)을 반환합니다. - 로그인한 사용자의 경우 해결 여부(isSolved)가 포함됩니다. - 비로그인 시 isSolved는 null입니다. - """, - deprecated = true - ) - @GetMapping("/team/{teamId}/today-problem") - public ResponseEntity> getTodayRecommendation( - @Parameter(description = "팀 ID", example = "1") - @PathVariable Long teamId, - @AuthenticationPrincipal PrincipalDetails principalDetails) { - - Long memberId = principalDetails != null ? principalDetails.getMemberId() : null; - - if (memberId != null) { - log.info("사용자 {}가 팀 {}의 오늘의 문제 조회", memberId, teamId); - } else { - log.info("비로그인 사용자가 팀 {}의 오늘의 문제 조회", teamId); - } - - TodayProblemResponse response = recommendationService.getTodayRecommendation(teamId, memberId); - return ResponseEntity.ok(ApiResponse.createSuccess(response, CustomResponseStatus.SUCCESS)); - } - @Operation( summary = "내 오늘의 문제 전체 조회", description = """ diff --git a/src/main/java/com/ryu/studyhelper/recommendation/scheduler/EmailSendScheduler.java b/src/main/java/com/ryu/studyhelper/recommendation/scheduler/EmailSendScheduler.java index b8df577..2342dbc 100644 --- a/src/main/java/com/ryu/studyhelper/recommendation/scheduler/EmailSendScheduler.java +++ b/src/main/java/com/ryu/studyhelper/recommendation/scheduler/EmailSendScheduler.java @@ -38,24 +38,4 @@ public void sendPendingEmails() { log.error("=== 이메일 발송 배치 작업 실패 === (소요시간: {}ms)", endTime - startTime, e); } } - - - // 테스트용 첫 실행후 10초뒤에 딱 한번 문제 추천 배치 작업 시작 -// @Scheduled(initialDelay = 10000, fixedDelay = Long.MAX_VALUE) - public void testsendPendingEmails() { - log.info("=== 이메일 발송 배치 작업 시작 ==="); - - long startTime = System.currentTimeMillis(); - - try { - recommendationEmailService.sendAll(); - - long endTime = System.currentTimeMillis(); - log.info("=== 이메일 발송 배치 작업 완료 === (소요시간: {}ms)", endTime - startTime); - - } catch (Exception e) { - long endTime = System.currentTimeMillis(); - log.error("=== 이메일 발송 배치 작업 실패 === (소요시간: {}ms)", endTime - startTime, e); - } - } } diff --git a/src/main/java/com/ryu/studyhelper/recommendation/scheduler/ProblemRecommendationScheduler.java b/src/main/java/com/ryu/studyhelper/recommendation/scheduler/ProblemRecommendationScheduler.java index dfd779b..b2d70bb 100644 --- a/src/main/java/com/ryu/studyhelper/recommendation/scheduler/ProblemRecommendationScheduler.java +++ b/src/main/java/com/ryu/studyhelper/recommendation/scheduler/ProblemRecommendationScheduler.java @@ -38,23 +38,4 @@ public void prepareDailyRecommendations() { log.error("=== 문제 추천 배치 작업 실패 === (소요시간: {}ms)", endTime - startTime, e); } } - - // 테스트용 첫 실행후 1초뒤에 딱 한번 문제 추천 배치 작업 시작 -// @Scheduled(initialDelay = 1000, fixedDelay = Long.MAX_VALUE) - public void testPrepareRecommendations() { - log.info("=== [테스트] 문제 추천 배치 작업 시작 ==="); - - long startTime = System.currentTimeMillis(); - - try { - scheduledRecommendationService.prepareDailyRecommendations(); - - long endTime = System.currentTimeMillis(); - log.info("=== [테스트] 문제 추천 배치 작업 완료 === (소요시간: {}ms)", endTime - startTime); - - } catch (Exception e) { - long endTime = System.currentTimeMillis(); - log.error("=== [테스트] 문제 추천 배치 작업 실패 === (소요시간: {}ms)", endTime - startTime, e); - } - } } diff --git a/src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationCreator.java b/src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationCreator.java index 1a87ca9..5d78e95 100644 --- a/src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationCreator.java +++ b/src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationCreator.java @@ -1,5 +1,7 @@ package com.ryu.studyhelper.recommendation.service; +import com.ryu.studyhelper.common.enums.CustomResponseStatus; +import com.ryu.studyhelper.common.exception.CustomException; import com.ryu.studyhelper.infrastructure.solvedac.dto.ProblemInfo; import com.ryu.studyhelper.member.domain.Member; import com.ryu.studyhelper.problem.domain.Problem; @@ -79,7 +81,7 @@ private List recommendProblemsForTeam(Team team) { List handles = teamMemberRepository.findHandlesByTeamId(team.getId()); if (handles.isEmpty()) { log.warn("팀 '{}'에 인증된 핸들이 없습니다", team.getName()); - throw new IllegalStateException("인증된 핸들이 없습니다"); + throw new CustomException(CustomResponseStatus.NO_VERIFIED_HANDLE); } List tagKeys = teamIncludeTagRepository.findTagKeysByTeamId(team.getId()); diff --git a/src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationService.java b/src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationService.java index f2d171e..48788dd 100644 --- a/src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationService.java +++ b/src/main/java/com/ryu/studyhelper/recommendation/service/RecommendationService.java @@ -77,15 +77,6 @@ public RecommendationDetailResponse createManualRecommendation(Long teamId) { return RecommendationDetailResponse.from(recommendation, team, problems); } - /** - * 특정 팀의 오늘 추천 조회 (사용자별 해결 여부 포함) - */ - @Transactional(readOnly = true) - public TodayProblemResponse getTodayRecommendation(Long teamId, Long memberId) { - return findTodayRecommendation(teamId, memberId) - .orElseThrow(() -> new CustomException(CustomResponseStatus.RECOMMENDATION_NOT_FOUND)); - } - /** * 특정 팀의 오늘 추천 조회 (Optional 반환) */ diff --git a/src/test/java/com/ryu/studyhelper/recommendation/service/RecommendationCreatorTest.java b/src/test/java/com/ryu/studyhelper/recommendation/service/RecommendationCreatorTest.java new file mode 100644 index 0000000..e819f03 --- /dev/null +++ b/src/test/java/com/ryu/studyhelper/recommendation/service/RecommendationCreatorTest.java @@ -0,0 +1,210 @@ +package com.ryu.studyhelper.recommendation.service; + +import com.ryu.studyhelper.common.enums.CustomResponseStatus; +import com.ryu.studyhelper.common.exception.CustomException; +import com.ryu.studyhelper.infrastructure.solvedac.dto.ProblemInfo; +import com.ryu.studyhelper.member.domain.Member; +import com.ryu.studyhelper.problem.domain.Problem; +import com.ryu.studyhelper.problem.service.ProblemService; +import com.ryu.studyhelper.problem.service.ProblemSyncService; +import com.ryu.studyhelper.recommendation.domain.Recommendation; +import com.ryu.studyhelper.recommendation.domain.RecommendationType; +import com.ryu.studyhelper.recommendation.repository.MemberRecommendationRepository; +import com.ryu.studyhelper.recommendation.repository.RecommendationProblemRepository; +import com.ryu.studyhelper.recommendation.repository.RecommendationRepository; +import com.ryu.studyhelper.team.domain.Team; +import com.ryu.studyhelper.team.repository.TeamIncludeTagRepository; +import com.ryu.studyhelper.team.repository.TeamMemberRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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 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.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RecommendationCreator 테스트") +class RecommendationCreatorTest { + + @Mock + private TeamMemberRepository teamMemberRepository; + + @Mock + private TeamIncludeTagRepository teamIncludeTagRepository; + + @Mock + private ProblemService problemService; + + @Mock + private ProblemSyncService problemSyncService; + + @Mock + private RecommendationRepository recommendationRepository; + + @Mock + private RecommendationProblemRepository recommendationProblemRepository; + + @Mock + private MemberRecommendationRepository memberRecommendationRepository; + + @InjectMocks + private RecommendationCreator recommendationCreator; + + private static final Long TEAM_ID = 1L; + + @Nested + @DisplayName("추천 생성 성공") + class CreateSuccess { + + @Test + @DisplayName("MANUAL 타입으로 추천을 생성한다") + void createManualRecommendation() { + // given + Team team = createTeamWithId(TEAM_ID); + List problems = List.of(createProblem(1000L), createProblem(1001L)); + Member member = createMember(100L); + + when(teamMemberRepository.findHandlesByTeamId(TEAM_ID)).thenReturn(List.of("handle1")); + when(teamIncludeTagRepository.findTagKeysByTeamId(TEAM_ID)).thenReturn(List.of()); + when(problemService.recommend(anyList(), anyInt(), anyInt(), anyInt(), anyList())) + .thenReturn(List.of(mock(ProblemInfo.class))); + when(problemSyncService.syncProblems(anyList())).thenReturn(problems); + when(recommendationRepository.save(any(Recommendation.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(teamMemberRepository.findMembersByTeamId(TEAM_ID)).thenReturn(List.of(member)); + + // when + Recommendation result = recommendationCreator.create(team, RecommendationType.MANUAL); + + // then + assertThat(result).isNotNull(); + assertThat(result.getType()).isEqualTo(RecommendationType.MANUAL); + assertThat(result.getProblems()).hasSize(2); + verify(recommendationProblemRepository, times(2)).save(any()); + verify(memberRecommendationRepository, times(1)).save(any()); + } + + @Test + @DisplayName("SCHEDULED 타입으로 추천을 생성한다") + void createScheduledRecommendation() { + // given + Team team = createTeamWithId(TEAM_ID); + List problems = List.of(createProblem(1000L)); + Member member = createMember(100L); + + when(teamMemberRepository.findHandlesByTeamId(TEAM_ID)).thenReturn(List.of("handle1")); + when(teamIncludeTagRepository.findTagKeysByTeamId(TEAM_ID)).thenReturn(List.of()); + when(problemService.recommend(anyList(), anyInt(), anyInt(), anyInt(), anyList())) + .thenReturn(List.of(mock(ProblemInfo.class))); + when(problemSyncService.syncProblems(anyList())).thenReturn(problems); + when(recommendationRepository.save(any(Recommendation.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(teamMemberRepository.findMembersByTeamId(TEAM_ID)).thenReturn(List.of(member)); + + // when + Recommendation result = recommendationCreator.create(team, RecommendationType.SCHEDULED); + + // then + assertThat(result.getType()).isEqualTo(RecommendationType.SCHEDULED); + } + + @Test + @DisplayName("팀원 수만큼 MemberRecommendation을 생성한다") + void createsMemberRecommendationsForAllMembers() { + // given + Team team = createTeamWithId(TEAM_ID); + List problems = List.of(createProblem(1000L)); + List members = List.of(createMember(100L), createMember(101L), createMember(102L)); + + when(teamMemberRepository.findHandlesByTeamId(TEAM_ID)).thenReturn(List.of("handle1")); + when(teamIncludeTagRepository.findTagKeysByTeamId(TEAM_ID)).thenReturn(List.of()); + when(problemService.recommend(anyList(), anyInt(), anyInt(), anyInt(), anyList())) + .thenReturn(List.of(mock(ProblemInfo.class))); + when(problemSyncService.syncProblems(anyList())).thenReturn(problems); + when(recommendationRepository.save(any(Recommendation.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(teamMemberRepository.findMembersByTeamId(TEAM_ID)).thenReturn(members); + + // when + recommendationCreator.create(team, RecommendationType.MANUAL); + + // then + verify(memberRecommendationRepository, times(3)).save(any()); + } + } + + @Nested + @DisplayName("추천 생성 실패") + class CreateFailure { + + @Test + @DisplayName("인증된 핸들이 없으면 CustomException을 던진다") + void noVerifiedHandle_throwsException() { + // given + Team team = createTeamWithId(TEAM_ID); + + when(teamMemberRepository.findHandlesByTeamId(TEAM_ID)).thenReturn(List.of()); + when(recommendationRepository.save(any(Recommendation.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when & then + assertThatThrownBy(() -> recommendationCreator.create(team, RecommendationType.MANUAL)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getStatus()).isEqualTo(CustomResponseStatus.NO_VERIFIED_HANDLE); + }); + } + } + + // === Helper Methods === + + private Team createTeamWithId(Long id) { + Team team = Team.create("테스트팀", "설명", false); + setFieldValue(team, "id", id); + return team; + } + + private Problem createProblem(Long id) { + Problem problem = Problem.builder() + .title("Problem " + id) + .titleKo("문제 " + id) + .level(10) + .acceptedUserCount(100) + .averageTries(2.5) + .build(); + setFieldValue(problem, "id", id); + return problem; + } + + private Member createMember(Long id) { + Member member = Member.builder() + .email("test" + id + "@test.com") + .provider("google") + .providerId("provider-" + id) + .isVerified(false) + .build(); + setFieldValue(member, "id", id); + return member; + } + + private void setFieldValue(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException(fieldName + " 설정 실패", e); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/ryu/studyhelper/recommendation/service/RecommendationEmailServiceTest.java b/src/test/java/com/ryu/studyhelper/recommendation/service/RecommendationEmailServiceTest.java new file mode 100644 index 0000000..6c99ef4 --- /dev/null +++ b/src/test/java/com/ryu/studyhelper/recommendation/service/RecommendationEmailServiceTest.java @@ -0,0 +1,223 @@ +package com.ryu.studyhelper.recommendation.service; + +import com.ryu.studyhelper.infrastructure.mail.sender.MailMessage; +import com.ryu.studyhelper.infrastructure.mail.sender.MailSender; +import com.ryu.studyhelper.member.domain.Member; +import com.ryu.studyhelper.recommendation.domain.Recommendation; +import com.ryu.studyhelper.recommendation.domain.member.EmailSendStatus; +import com.ryu.studyhelper.recommendation.domain.member.MemberRecommendation; +import com.ryu.studyhelper.recommendation.mailbuilder.RecommendationMailBuilder; +import com.ryu.studyhelper.recommendation.repository.MemberRecommendationRepository; +import com.ryu.studyhelper.team.domain.Team; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RecommendationEmailService 테스트") +class RecommendationEmailServiceTest { + + @Mock + private Clock clock; + + @Mock + private MailSender mailSender; + + @Mock + private RecommendationMailBuilder recommendationMailBuilder; + + @Mock + private MemberRecommendationRepository memberRecommendationRepository; + + @InjectMocks + private RecommendationEmailService recommendationEmailService; + + private static final ZoneId ZONE_ID = ZoneId.of("Asia/Seoul"); + + private void setupClock(String dateTime) { + LocalDateTime ldt = LocalDateTime.parse(dateTime); + Instant instant = ldt.atZone(ZONE_ID).toInstant(); + lenient().when(clock.instant()).thenReturn(instant); + lenient().when(clock.getZone()).thenReturn(ZONE_ID); + } + + @Nested + @DisplayName("sendAll - 배치 이메일 발송") + class SendAll { + + @Test + @DisplayName("PENDING 상태의 추천들에 이메일을 발송한다") + void sendsPendingEmails() { + // given + setupClock("2025-01-15T09:00:00"); + + MemberRecommendation mr = createMemberRecommendation(1L, "user@test.com"); + when(memberRecommendationRepository.findPendingRecommendationsByCreatedAtBetween( + any(), any(), eq(EmailSendStatus.PENDING))) + .thenReturn(List.of(mr)); + when(recommendationMailBuilder.build(mr)) + .thenReturn(new MailMessage("user@test.com", "제목", "")); + + // when + recommendationEmailService.sendAll(); + + // then + verify(mailSender).send(any(MailMessage.class)); + verify(memberRecommendationRepository).save(mr); + assertThat(mr.getEmailSendStatus()).isEqualTo(EmailSendStatus.SENT); + } + + @Test + @DisplayName("PENDING 추천이 없으면 이메일을 발송하지 않는다") + void noPending_sendsNothing() { + // given + setupClock("2025-01-15T09:00:00"); + + when(memberRecommendationRepository.findPendingRecommendationsByCreatedAtBetween( + any(), any(), eq(EmailSendStatus.PENDING))) + .thenReturn(List.of()); + + // when + recommendationEmailService.sendAll(); + + // then + verify(mailSender, never()).send(any()); + } + + @Test + @DisplayName("이메일 발송 실패 시 FAILED로 마킹하고 나머지는 계속 처리한다") + void partialFailure_continuesProcessing() { + // given + setupClock("2025-01-15T09:00:00"); + + MemberRecommendation mr1 = createMemberRecommendation(1L, "fail@test.com"); + MemberRecommendation mr2 = createMemberRecommendation(2L, "success@test.com"); + + when(memberRecommendationRepository.findPendingRecommendationsByCreatedAtBetween( + any(), any(), eq(EmailSendStatus.PENDING))) + .thenReturn(List.of(mr1, mr2)); + + MailMessage msg1 = new MailMessage("fail@test.com", "제목", ""); + MailMessage msg2 = new MailMessage("success@test.com", "제목", ""); + when(recommendationMailBuilder.build(mr1)).thenReturn(msg1); + when(recommendationMailBuilder.build(mr2)).thenReturn(msg2); + + doThrow(new RuntimeException("SMTP 오류")).when(mailSender).send(msg1); + doNothing().when(mailSender).send(msg2); + + // when + recommendationEmailService.sendAll(); + + // then + assertThat(mr1.getEmailSendStatus()).isEqualTo(EmailSendStatus.FAILED); + assertThat(mr2.getEmailSendStatus()).isEqualTo(EmailSendStatus.SENT); + verify(memberRecommendationRepository, times(2)).save(any()); + } + } + + @Nested + @DisplayName("send - 수동 추천 이메일 발송") + class Send { + + @Test + @DisplayName("전달된 MemberRecommendation 목록에 이메일을 발송한다") + void sendsToAllMembers() { + // given + MemberRecommendation mr1 = createMemberRecommendation(1L, "a@test.com"); + MemberRecommendation mr2 = createMemberRecommendation(2L, "b@test.com"); + + when(recommendationMailBuilder.build(any())) + .thenReturn(new MailMessage("to", "제목", "")); + + // when + recommendationEmailService.send(List.of(mr1, mr2)); + + // then + verify(mailSender, times(2)).send(any(MailMessage.class)); + } + } + + @Nested + @DisplayName("이메일 없는 회원 처리") + class NoEmail { + + @Test + @DisplayName("이메일이 null이면 FAILED 처리한다") + void nullEmail_marksFailed() { + // given + MemberRecommendation mr = createMemberRecommendation(1L, null); + + // when + recommendationEmailService.send(List.of(mr)); + + // then + assertThat(mr.getEmailSendStatus()).isEqualTo(EmailSendStatus.FAILED); + verify(mailSender, never()).send(any()); + } + + @Test + @DisplayName("이메일이 빈 문자열이면 FAILED 처리한다") + void blankEmail_marksFailed() { + // given + MemberRecommendation mr = createMemberRecommendation(1L, " "); + + // when + recommendationEmailService.send(List.of(mr)); + + // then + assertThat(mr.getEmailSendStatus()).isEqualTo(EmailSendStatus.FAILED); + verify(mailSender, never()).send(any()); + } + } + + // === Helper Methods === + + private MemberRecommendation createMemberRecommendation(Long id, String email) { + Member member = Member.builder() + .email(email) + .provider("google") + .providerId("provider-" + id) + .isVerified(false) + .build(); + setFieldValue(member, "id", id); + + Team team = Team.create("테스트팀", "설명", false); + setFieldValue(team, "id", 1L); + + Recommendation recommendation = Recommendation.createScheduledRecommendation(1L); + setFieldValue(recommendation, "createdAt", LocalDateTime.now(), true); + + MemberRecommendation mr = MemberRecommendation.create(member, recommendation, team); + setFieldValue(mr, "id", id); + return mr; + } + + private void setFieldValue(Object target, String fieldName, Object value) { + setFieldValue(target, fieldName, value, false); + } + + private void setFieldValue(Object target, String fieldName, Object value, boolean superClass) { + try { + Class clazz = superClass ? target.getClass().getSuperclass() : target.getClass(); + java.lang.reflect.Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException(fieldName + " 설정 실패", e); + } + } +} \ No newline at end of file