From efb9708a57638ca31cd03a9520cb8b6b9eece287 Mon Sep 17 00:00:00 2001 From: nYeonG4001 <2371324@hansung.ac.kr> Date: Sun, 10 May 2026 14:30:38 +0900 Subject: [PATCH 1/3] =?UTF-8?q?DP-474:=20=EC=B1=84=EC=9A=A9=20=ED=99=9C?= =?UTF-8?q?=EB=8F=99=20=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=E2=80=94=20=EB=B6=81=EB=A7=88=ED=81=AC(+5pt),=20?= =?UTF-8?q?=EB=AA=A8=EC=9D=98=EB=A9=B4=EC=A0=91=20=EC=99=84=EB=A3=8C(+20pt?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/job/service/JobService.java | 11 ++++++++ .../job/service/MockInterviewService.java | 17 +++++++++++ .../domain/point/entity/PointAction.java | 4 ++- .../domain/point/service/PointService.java | 3 +- .../report/dto/ActivityItemResponse.java | 6 +++- .../report/dto/HistoryItemResponse.java | 28 +++++++++++++------ .../report/repository/HistoryRepository.java | 12 ++++---- .../controller/HistoryControllerTest.java | 10 +++---- 8 files changed, 69 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/devpick/domain/job/service/JobService.java b/src/main/java/com/devpick/domain/job/service/JobService.java index 45cd7965..4de1ff9c 100644 --- a/src/main/java/com/devpick/domain/job/service/JobService.java +++ b/src/main/java/com/devpick/domain/job/service/JobService.java @@ -23,6 +23,8 @@ import com.devpick.domain.job.repository.JobBookmarkRepository; import com.devpick.domain.job.repository.JobPostingRepository; import com.devpick.domain.job.repository.JobPostingSpecifications; +import com.devpick.domain.point.entity.PointAction; +import com.devpick.domain.point.service.PointService; import com.devpick.domain.report.entity.History; import com.devpick.domain.report.repository.HistoryRepository; import com.devpick.domain.resume.entity.MasterResume; @@ -79,6 +81,7 @@ public class JobService { private final JobAiClient jobAiClient; private final ObjectMapper objectMapper; private final HistoryRepository historyRepository; + private final PointService pointService; @Transactional(readOnly = true) public List listTechTagFacets(Integer limit) { @@ -308,6 +311,14 @@ public void bookmark(UUID userId, UUID jobId) { .userId(userId) .jobPosting(p) .build()); + var user = userRepository.findByIdAndIsActiveTrue(userId) + .orElseThrow(() -> new DevpickException(ErrorCode.USER_NOT_FOUND)); + historyRepository.save(History.builder() + .user(user) + .actionType("job_bookmarked") + .jobPosting(p) + .build()); + pointService.earn(user, PointAction.JOB_BOOKMARK, jobId); } @Transactional diff --git a/src/main/java/com/devpick/domain/job/service/MockInterviewService.java b/src/main/java/com/devpick/domain/job/service/MockInterviewService.java index 6885cf54..3797ce4b 100644 --- a/src/main/java/com/devpick/domain/job/service/MockInterviewService.java +++ b/src/main/java/com/devpick/domain/job/service/MockInterviewService.java @@ -22,8 +22,13 @@ import com.devpick.domain.job.entity.MockInterviewTurnType; import com.devpick.domain.job.repository.JobPostingRepository; import com.devpick.domain.job.repository.MockInterviewSessionRepository; +import com.devpick.domain.point.entity.PointAction; +import com.devpick.domain.point.service.PointService; +import com.devpick.domain.report.entity.History; +import com.devpick.domain.report.repository.HistoryRepository; import com.devpick.domain.resume.repository.MasterResumeRepository; import com.devpick.domain.resume.service.ResumeCryptoService; +import com.devpick.domain.user.repository.UserRepository; import com.devpick.global.common.exception.DevpickException; import com.devpick.global.common.exception.ErrorCode; import com.fasterxml.jackson.core.type.TypeReference; @@ -58,6 +63,9 @@ public class MockInterviewService { private final MockInterviewModelRegistry modelRegistry; private final JobAiClient jobAiClient; private final ObjectMapper objectMapper; + private final HistoryRepository historyRepository; + private final UserRepository userRepository; + private final PointService pointService; @Transactional(readOnly = true) public HistoryListResponse listForUser(UUID userId) { @@ -383,6 +391,15 @@ private void finalizeSession(MockInterviewSession session, QuestionPlanResponse finalResult.put("totalQuestions", MockInterviewPlanner.TOTAL_QUESTIONS); finalResult.put("coverageFactor", coverageFactor(session.getAnsweredCount(), early)); session.setResultJson(writeJson(finalResult)); + + userRepository.findByIdAndIsActiveTrue(session.getUserId()).ifPresent(user -> { + historyRepository.save(History.builder() + .user(user) + .actionType("mock_interview_completed") + .jobPosting(session.getJobPosting()) + .build()); + pointService.earn(user, PointAction.MOCK_INTERVIEW_COMPLETE, session.getId()); + }); } private double coverageFactor(int answered, boolean early) { diff --git a/src/main/java/com/devpick/domain/point/entity/PointAction.java b/src/main/java/com/devpick/domain/point/entity/PointAction.java index 40ee796f..d33c6d9e 100644 --- a/src/main/java/com/devpick/domain/point/entity/PointAction.java +++ b/src/main/java/com/devpick/domain/point/entity/PointAction.java @@ -16,7 +16,9 @@ public enum PointAction { QUESTION_WRITE(10), ANSWER_WRITE(15), ANSWER_ADOPTED(30), - DAILY_LOGIN(1); + DAILY_LOGIN(1), + JOB_BOOKMARK(5), + MOCK_INTERVIEW_COMPLETE(20); private final int points; } diff --git a/src/main/java/com/devpick/domain/point/service/PointService.java b/src/main/java/com/devpick/domain/point/service/PointService.java index 403b8f63..f2816917 100644 --- a/src/main/java/com/devpick/domain/point/service/PointService.java +++ b/src/main/java/com/devpick/domain/point/service/PointService.java @@ -144,7 +144,8 @@ public void refundAnswerPoints(User user, boolean wasAdopted) { private boolean isDuplicate(UUID userId, PointAction action, UUID referenceId) { return switch (action) { - case CONTENT_SCRAP, CONTENT_LIKE, AI_QUIZ_PASS -> + case CONTENT_SCRAP, CONTENT_LIKE, AI_QUIZ_PASS, + JOB_BOOKMARK, MOCK_INTERVIEW_COMPLETE -> referenceId != null && pointLogRepository.existsByUser_IdAndActionAndReferenceId(userId, action, referenceId); case DAILY_LOGIN -> { diff --git a/src/main/java/com/devpick/domain/report/dto/ActivityItemResponse.java b/src/main/java/com/devpick/domain/report/dto/ActivityItemResponse.java index 80e5e3a5..2a6d8c09 100644 --- a/src/main/java/com/devpick/domain/report/dto/ActivityItemResponse.java +++ b/src/main/java/com/devpick/domain/report/dto/ActivityItemResponse.java @@ -4,25 +4,29 @@ import java.util.UUID; /** - * {@code GET /history/activity} — 활동 피드 (content_liked 포함). points 필드는 제외. + * {@code GET /history/activity} — 활동 피드 (content_liked 포함). */ public record ActivityItemResponse( UUID id, String actionType, + Integer points, HistoryItemResponse.ContentInfo content, HistoryItemResponse.PostInfo post, HistoryItemResponse.AnswerInfo answer, HistoryItemResponse.CommentInfo comment, + HistoryItemResponse.JobPostingInfo jobPosting, Instant createdAt ) { public static ActivityItemResponse from(HistoryItemResponse h) { return new ActivityItemResponse( h.id(), h.actionType(), + h.points(), h.content(), h.post(), h.answer(), h.comment(), + h.jobPosting(), h.createdAt() ); } diff --git a/src/main/java/com/devpick/domain/report/dto/HistoryItemResponse.java b/src/main/java/com/devpick/domain/report/dto/HistoryItemResponse.java index 3ddf12c9..09fbee84 100644 --- a/src/main/java/com/devpick/domain/report/dto/HistoryItemResponse.java +++ b/src/main/java/com/devpick/domain/report/dto/HistoryItemResponse.java @@ -17,12 +17,14 @@ public record HistoryItemResponse( PostInfo post, AnswerInfo answer, CommentInfo comment, + JobPostingInfo jobPosting, Instant createdAt ) { public record ContentInfo(UUID id, String title, String translatedTitle, String preview) {} public record PostInfo(UUID id, String title) {} public record AnswerInfo(UUID id, String preview) {} public record CommentInfo(UUID id, String preview) {} + public record JobPostingInfo(UUID id, String title, String companyName) {} public static HistoryItemResponse of(History history) { return of(history, Map.of()); @@ -54,15 +56,24 @@ public static HistoryItemResponse of(History history, Map summaryM truncate(history.getComment().getContent(), 100)) : null; + JobPostingInfo jobPostingInfo = history.getJobPosting() != null + ? new JobPostingInfo( + history.getJobPosting().getId(), + history.getJobPosting().getTitle(), + history.getJobPosting().getCompanyName()) + : null; + Integer points = switch (history.getActionType()) { - case "scrapped" -> PointAction.CONTENT_SCRAP.getPoints(); - case "content_liked" -> PointAction.CONTENT_LIKE.getPoints(); - case "question_created" -> PointAction.QUESTION_WRITE.getPoints(); - case "answer_written" -> PointAction.ANSWER_WRITE.getPoints(); - case "answer_adopted" -> PointAction.ANSWER_ADOPTED.getPoints(); - case "daily_login" -> PointAction.DAILY_LOGIN.getPoints(); - case "ai_quiz_completed" -> PointAction.AI_QUIZ_PASS.getPoints(); - default -> null; + case "scrapped" -> PointAction.CONTENT_SCRAP.getPoints(); + case "content_liked" -> PointAction.CONTENT_LIKE.getPoints(); + case "question_created" -> PointAction.QUESTION_WRITE.getPoints(); + case "answer_written" -> PointAction.ANSWER_WRITE.getPoints(); + case "answer_adopted" -> PointAction.ANSWER_ADOPTED.getPoints(); + case "daily_login" -> PointAction.DAILY_LOGIN.getPoints(); + case "ai_quiz_completed" -> PointAction.AI_QUIZ_PASS.getPoints(); + case "job_bookmarked" -> PointAction.JOB_BOOKMARK.getPoints(); + case "mock_interview_completed" -> PointAction.MOCK_INTERVIEW_COMPLETE.getPoints(); + default -> null; }; return new HistoryItemResponse( @@ -73,6 +84,7 @@ public static HistoryItemResponse of(History history, Map summaryM postInfo, answerInfo, commentInfo, + jobPostingInfo, history.getCreatedAt() != null ? history.getCreatedAt().toInstant(ZoneOffset.UTC) : null ); } diff --git a/src/main/java/com/devpick/domain/report/repository/HistoryRepository.java b/src/main/java/com/devpick/domain/report/repository/HistoryRepository.java index b9544196..c6276113 100644 --- a/src/main/java/com/devpick/domain/report/repository/HistoryRepository.java +++ b/src/main/java/com/devpick/domain/report/repository/HistoryRepository.java @@ -188,28 +188,28 @@ Page findAllHistoryIds( @Param("userId") UUID userId, Pageable pageable); - /** 학습 히스토리: content_liked 제외, 날짜 필터 없음 */ + /** 학습 히스토리: 활동 전용 타입 제외, 날짜 필터 없음 */ @Query(value = "SELECT h.id FROM History h " + "WHERE h.user.id = :userId " + - "AND h.actionType <> 'content_liked' " + + "AND h.actionType NOT IN ('content_liked', 'job_bookmarked', 'mock_interview_completed') " + "ORDER BY h.createdAt DESC", countQuery = "SELECT COUNT(h) FROM History h " + "WHERE h.user.id = :userId " + - "AND h.actionType <> 'content_liked'") + "AND h.actionType NOT IN ('content_liked', 'job_bookmarked', 'mock_interview_completed')") Page findHistoryIdsExcludingContentLiked( @Param("userId") UUID userId, Pageable pageable); - /** 학습 히스토리: content_liked 제외, 날짜 필터 있음 */ + /** 학습 히스토리: 활동 전용 타입 제외, 날짜 필터 있음 */ @Query(value = "SELECT h.id FROM History h " + "WHERE h.user.id = :userId " + - "AND h.actionType <> 'content_liked' " + + "AND h.actionType NOT IN ('content_liked', 'job_bookmarked', 'mock_interview_completed') " + "AND h.createdAt >= :startDate " + "AND h.createdAt <= :endDate " + "ORDER BY h.createdAt DESC", countQuery = "SELECT COUNT(h) FROM History h " + "WHERE h.user.id = :userId " + - "AND h.actionType <> 'content_liked' " + + "AND h.actionType NOT IN ('content_liked', 'job_bookmarked', 'mock_interview_completed') " + "AND h.createdAt >= :startDate " + "AND h.createdAt <= :endDate") Page findHistoryIdsByDateRangeExcludingContentLiked( diff --git a/src/test/java/com/devpick/domain/report/controller/HistoryControllerTest.java b/src/test/java/com/devpick/domain/report/controller/HistoryControllerTest.java index 46b363e4..d75875f2 100644 --- a/src/test/java/com/devpick/domain/report/controller/HistoryControllerTest.java +++ b/src/test/java/com/devpick/domain/report/controller/HistoryControllerTest.java @@ -76,7 +76,7 @@ void setUp() { HistoryItemResponse item = new HistoryItemResponse( UUID.randomUUID(), "content_opened", null, new HistoryItemResponse.ContentInfo(UUID.randomUUID(), "React useEffect 완전 정복", null, "미리보기"), - null, null, null, + null, null, null, null, Instant.now() ); historyPageResponse = new HistoryPageResponse(List.of(item), 0, 20, 1L, 1); @@ -149,7 +149,7 @@ void getLearningHistory_scrappedAction_returnsPoints5() throws Exception { HistoryItemResponse item = new HistoryItemResponse( UUID.randomUUID(), "scrapped", 5, new HistoryItemResponse.ContentInfo(UUID.randomUUID(), "제목", null, "미리보기"), - null, null, null, Instant.now() + null, null, null, null, Instant.now() ); given(historyService.getHistory(any(UUID.class), any(), any(), any(), any())) .willReturn(new HistoryPageResponse(List.of(item), 0, 20, 1L, 1)); @@ -165,7 +165,7 @@ void getLearningHistory_aiSummaryViewedAction_returnsPoints3() throws Exception HistoryItemResponse item = new HistoryItemResponse( UUID.randomUUID(), "ai_summary_viewed", null, new HistoryItemResponse.ContentInfo(UUID.randomUUID(), "제목", null, "미리보기"), - null, null, null, Instant.now() + null, null, null, null, Instant.now() ); given(historyService.getHistory(any(UUID.class), any(), any(), any(), any())) .willReturn(new HistoryPageResponse(List.of(item), 0, 20, 1L, 1)); @@ -182,7 +182,7 @@ void getLearningHistory_questionCreatedAction_returnsPoints10() throws Exception UUID.randomUUID(), "question_created", 10, null, new HistoryItemResponse.PostInfo(UUID.randomUUID(), "질문 제목"), - null, null, Instant.now() + null, null, null, Instant.now() ); given(historyService.getHistory(any(UUID.class), any(), any(), any(), any())) .willReturn(new HistoryPageResponse(List.of(item), 0, 20, 1L, 1)); @@ -198,7 +198,7 @@ void getLearningHistory_aiQuizCompletedAction_returnsPoints5() throws Exception HistoryItemResponse item = new HistoryItemResponse( UUID.randomUUID(), "ai_quiz_completed", 5, new HistoryItemResponse.ContentInfo(UUID.randomUUID(), "React 퀴즈", null, "미리보기"), - null, null, null, Instant.now() + null, null, null, null, Instant.now() ); given(historyService.getHistory(any(UUID.class), any(), any(), any(), any())) .willReturn(new HistoryPageResponse(List.of(item), 0, 20, 1L, 1)); From 6e108d2c422db0800bd3996dc383ab10a8ccb0ad Mon Sep 17 00:00:00 2001 From: nYeonG4001 <2371324@hansung.ac.kr> Date: Sun, 10 May 2026 14:41:59 +0900 Subject: [PATCH 2/3] =?UTF-8?q?DP-474:=20JobService,=20MockInterviewServic?= =?UTF-8?q?e,=20HistoryItemResponse=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 --- .../domain/job/service/JobServiceTest.java | 106 ++++++++++++++ .../job/service/MockInterviewServiceTest.java | 136 ++++++++++++++++++ .../report/dto/HistoryItemResponseTest.java | 58 ++++++++ 3 files changed, 300 insertions(+) create mode 100644 src/test/java/com/devpick/domain/job/service/JobServiceTest.java create mode 100644 src/test/java/com/devpick/domain/job/service/MockInterviewServiceTest.java create mode 100644 src/test/java/com/devpick/domain/report/dto/HistoryItemResponseTest.java diff --git a/src/test/java/com/devpick/domain/job/service/JobServiceTest.java b/src/test/java/com/devpick/domain/job/service/JobServiceTest.java new file mode 100644 index 00000000..a8102b9b --- /dev/null +++ b/src/test/java/com/devpick/domain/job/service/JobServiceTest.java @@ -0,0 +1,106 @@ +package com.devpick.domain.job.service; + +import com.devpick.domain.job.entity.JobPosting; +import com.devpick.domain.job.repository.JobBookmarkRepository; +import com.devpick.domain.job.repository.JobPostingRepository; +import com.devpick.domain.point.entity.PointAction; +import com.devpick.domain.point.service.PointService; +import com.devpick.domain.report.repository.HistoryRepository; +import com.devpick.domain.user.entity.User; +import com.devpick.domain.user.repository.UserRepository; +import com.devpick.global.common.exception.DevpickException; +import com.devpick.global.common.exception.ErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class JobServiceTest { + + @InjectMocks private JobService jobService; + @Mock private JobPostingRepository jobPostingRepository; + @Mock private JobBookmarkRepository jobBookmarkRepository; + @Mock private com.devpick.domain.resume.repository.MasterResumeRepository masterResumeRepository; + @Mock private com.devpick.domain.resume.service.ResumeCryptoService resumeCryptoService; + @Mock private UserRepository userRepository; + @Mock private com.devpick.domain.user.repository.TagRepository tagRepository; + @Mock private com.devpick.domain.content.repository.ContentRepository contentRepository; + @Mock private com.devpick.domain.job.client.JobAiClient jobAiClient; + @Mock private ObjectMapper objectMapper; + @Mock private HistoryRepository historyRepository; + @Mock private PointService pointService; + + @Test + @DisplayName("채용공고 북마크 시 activity 히스토리 및 포인트가 기록된다") + void bookmark_newBookmark_recordsHistoryAndPoint() { + UUID userId = UUID.randomUUID(); + UUID jobId = UUID.randomUUID(); + + JobPosting job = mock(JobPosting.class); + given(job.getTitle()).willReturn("백엔드 개발자"); + given(job.getCompanyName()).willReturn("카카오"); + given(jobPostingRepository.findById(jobId)).willReturn(Optional.of(job)); + given(jobBookmarkRepository.existsByUserIdAndJobPosting_Id(userId, jobId)).willReturn(false); + + User user = mock(User.class); + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); + + jobService.bookmark(userId, jobId); + + then(historyRepository).should().save(argThat(h -> + "job_bookmarked".equals(h.getActionType()) && h.getJobPosting() == job)); + then(pointService).should().earn(user, PointAction.JOB_BOOKMARK, jobId); + } + + @Test + @DisplayName("이미 북마크한 공고는 히스토리 및 포인트가 기록되지 않는다") + void bookmark_alreadyBookmarked_doesNotRecordHistoryOrPoint() { + UUID userId = UUID.randomUUID(); + UUID jobId = UUID.randomUUID(); + + JobPosting job = mock(JobPosting.class); + given(job.getTitle()).willReturn("백엔드 개발자"); + given(job.getCompanyName()).willReturn("카카오"); + given(jobPostingRepository.findById(jobId)).willReturn(Optional.of(job)); + given(jobBookmarkRepository.existsByUserIdAndJobPosting_Id(userId, jobId)).willReturn(true); + + jobService.bookmark(userId, jobId); + + then(historyRepository).should(never()).save(any()); + then(pointService).should(never()).earn(any(), any(), any()); + } + + @Test + @DisplayName("존재하지 않는 사용자 북마크 시 예외가 발생한다") + void bookmark_userNotFound_throwsException() { + UUID userId = UUID.randomUUID(); + UUID jobId = UUID.randomUUID(); + + JobPosting job = mock(JobPosting.class); + given(job.getTitle()).willReturn("백엔드 개발자"); + given(job.getCompanyName()).willReturn("카카오"); + given(jobPostingRepository.findById(jobId)).willReturn(Optional.of(job)); + given(jobBookmarkRepository.existsByUserIdAndJobPosting_Id(userId, jobId)).willReturn(false); + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> jobService.bookmark(userId, jobId)) + .isInstanceOf(DevpickException.class) + .satisfies(e -> org.assertj.core.api.Assertions.assertThat( + ((DevpickException) e).getErrorCode()).isEqualTo(ErrorCode.USER_NOT_FOUND)); + } +} diff --git a/src/test/java/com/devpick/domain/job/service/MockInterviewServiceTest.java b/src/test/java/com/devpick/domain/job/service/MockInterviewServiceTest.java new file mode 100644 index 00000000..cdaf8dd2 --- /dev/null +++ b/src/test/java/com/devpick/domain/job/service/MockInterviewServiceTest.java @@ -0,0 +1,136 @@ +package com.devpick.domain.job.service; + +import com.devpick.domain.job.entity.MockInterviewMode; +import com.devpick.domain.job.entity.MockInterviewPhase; +import com.devpick.domain.job.entity.MockInterviewSession; +import com.devpick.domain.job.entity.MockInterviewStatus; +import com.devpick.domain.job.repository.JobPostingRepository; +import com.devpick.domain.job.repository.MockInterviewSessionRepository; +import com.devpick.domain.point.entity.PointAction; +import com.devpick.domain.point.service.PointService; +import com.devpick.domain.report.repository.HistoryRepository; +import com.devpick.domain.resume.repository.MasterResumeRepository; +import com.devpick.domain.resume.service.ResumeCryptoService; +import com.devpick.domain.user.entity.User; +import com.devpick.domain.user.repository.UserRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.ArrayList; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +class MockInterviewServiceTest { + + @InjectMocks private MockInterviewService mockInterviewService; + @Mock private MockInterviewSessionRepository sessionRepository; + @Mock private JobPostingRepository jobPostingRepository; + @Mock private MasterResumeRepository masterResumeRepository; + @Mock private ResumeCryptoService resumeCryptoService; + @Mock private MockInterviewPlanner planner; + @Mock private MockInterviewModelRegistry modelRegistry; + @Mock private com.devpick.domain.job.client.JobAiClient jobAiClient; + @Spy private ObjectMapper objectMapper = new ObjectMapper(); + @Mock private HistoryRepository historyRepository; + @Mock private UserRepository userRepository; + @Mock private PointService pointService; + + private static final String MINIMAL_PLAN_JSON = """ + { + "questions": [], + "coreCsTopics": [], + "extendedCsTopics": [], + "jdGapKeywords": [], + "domainLabel": "Backend" + } + """; + + @Test + @DisplayName("모의면접 조기 종료 시 activity 히스토리 및 포인트가 기록된다") + void finishEarly_recordsHistoryAndPoint() { + UUID userId = UUID.randomUUID(); + UUID sessionId = UUID.randomUUID(); + User user = mock(User.class); + + MockInterviewSession session = mock(MockInterviewSession.class); + given(session.getId()).willReturn(sessionId); + given(session.getUserId()).willReturn(userId); + given(session.getStatus()).willReturn(MockInterviewStatus.IN_PROGRESS); + given(session.getPlanJson()).willReturn(MINIMAL_PLAN_JSON); + given(session.getJobPosting()).willReturn(null); + given(session.getCompanyName()).willReturn("카카오"); + given(session.getJobTitle()).willReturn("백엔드 개발자"); + given(session.getJobCategory()).willReturn("BACKEND"); + given(session.getRawJdText()).willReturn(""); + given(session.getMode()).willReturn(MockInterviewMode.FULL); + given(session.getModelKey()).willReturn("default"); + given(session.getPhase()).willReturn(MockInterviewPhase.WARM_UP); + given(session.getAnsweredCount()).willReturn(3); + given(session.getCurrentQuestionIndex()).willReturn(2); + given(session.getTurns()).willReturn(new ArrayList<>()); + given(session.getResultJson()).willReturn(null); + given(session.getCreatedAt()).willReturn(null); + given(session.getUpdatedAt()).willReturn(null); + + given(sessionRepository.findByIdAndUserId(sessionId, userId)).willReturn(Optional.of(session)); + given(jobAiClient.finalizeMockInterview(any())).willThrow(new RuntimeException("AI unavailable")); + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); + given(sessionRepository.save(session)).willReturn(session); + + mockInterviewService.finishEarly(userId, sessionId); + + then(historyRepository).should().save(argThat(h -> + "mock_interview_completed".equals(h.getActionType()))); + then(pointService).should().earn(user, PointAction.MOCK_INTERVIEW_COMPLETE, sessionId); + } + + @Test + @DisplayName("사용자가 없으면 모의면접 완료 히스토리가 기록되지 않는다") + void finishEarly_userNotFound_doesNotRecordHistory() { + UUID userId = UUID.randomUUID(); + UUID sessionId = UUID.randomUUID(); + + MockInterviewSession session = mock(MockInterviewSession.class); + given(session.getId()).willReturn(sessionId); + given(session.getUserId()).willReturn(userId); + given(session.getStatus()).willReturn(MockInterviewStatus.IN_PROGRESS); + given(session.getPlanJson()).willReturn(MINIMAL_PLAN_JSON); + given(session.getJobPosting()).willReturn(null); + given(session.getCompanyName()).willReturn("카카오"); + given(session.getJobTitle()).willReturn("백엔드 개발자"); + given(session.getJobCategory()).willReturn("BACKEND"); + given(session.getRawJdText()).willReturn(""); + given(session.getMode()).willReturn(MockInterviewMode.FULL); + given(session.getModelKey()).willReturn("default"); + given(session.getPhase()).willReturn(MockInterviewPhase.WARM_UP); + given(session.getAnsweredCount()).willReturn(3); + given(session.getCurrentQuestionIndex()).willReturn(2); + given(session.getTurns()).willReturn(new ArrayList<>()); + given(session.getResultJson()).willReturn(null); + given(session.getCreatedAt()).willReturn(null); + given(session.getUpdatedAt()).willReturn(null); + + given(sessionRepository.findByIdAndUserId(sessionId, userId)).willReturn(Optional.of(session)); + given(jobAiClient.finalizeMockInterview(any())).willThrow(new RuntimeException("AI unavailable")); + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.empty()); + given(sessionRepository.save(session)).willReturn(session); + + mockInterviewService.finishEarly(userId, sessionId); + + then(historyRepository).should(org.mockito.Mockito.never()).save(any()); + then(pointService).should(org.mockito.Mockito.never()).earn(any(), any(), any()); + } +} diff --git a/src/test/java/com/devpick/domain/report/dto/HistoryItemResponseTest.java b/src/test/java/com/devpick/domain/report/dto/HistoryItemResponseTest.java new file mode 100644 index 00000000..4be166a2 --- /dev/null +++ b/src/test/java/com/devpick/domain/report/dto/HistoryItemResponseTest.java @@ -0,0 +1,58 @@ +package com.devpick.domain.report.dto; + +import com.devpick.domain.job.entity.JobPosting; +import com.devpick.domain.point.entity.PointAction; +import com.devpick.domain.report.entity.History; +import com.devpick.domain.user.entity.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +class HistoryItemResponseTest { + + @Test + @DisplayName("job_bookmarked 액션은 points 5를 반환하고 jobPosting 정보를 포함한다") + void of_jobBookmarked_returnsPointsAndJobPostingInfo() { + UUID jobId = UUID.randomUUID(); + JobPosting jobPosting = mock(JobPosting.class); + given(jobPosting.getId()).willReturn(jobId); + given(jobPosting.getTitle()).willReturn("백엔드 개발자"); + given(jobPosting.getCompanyName()).willReturn("카카오"); + + History history = History.builder() + .user(mock(User.class)) + .actionType("job_bookmarked") + .jobPosting(jobPosting) + .build(); + + HistoryItemResponse result = HistoryItemResponse.of(history, Map.of()); + + assertThat(result.actionType()).isEqualTo("job_bookmarked"); + assertThat(result.points()).isEqualTo(PointAction.JOB_BOOKMARK.getPoints()); + assertThat(result.jobPosting()).isNotNull(); + assertThat(result.jobPosting().id()).isEqualTo(jobId); + assertThat(result.jobPosting().title()).isEqualTo("백엔드 개발자"); + assertThat(result.jobPosting().companyName()).isEqualTo("카카오"); + } + + @Test + @DisplayName("mock_interview_completed 액션은 points 20을 반환한다") + void of_mockInterviewCompleted_returnsPoints20() { + History history = History.builder() + .user(mock(User.class)) + .actionType("mock_interview_completed") + .build(); + + HistoryItemResponse result = HistoryItemResponse.of(history, Map.of()); + + assertThat(result.actionType()).isEqualTo("mock_interview_completed"); + assertThat(result.points()).isEqualTo(PointAction.MOCK_INTERVIEW_COMPLETE.getPoints()); + assertThat(result.jobPosting()).isNull(); + } +} From 5067d4f5f53aae53a93385aafc0f102eb5d15eeb Mon Sep 17 00:00:00 2001 From: nYeonG4001 <2371324@hansung.ac.kr> Date: Sun, 10 May 2026 14:51:58 +0900 Subject: [PATCH 3/3] =?UTF-8?q?DP-474:=20INTERVIEW=5FMASTER=20=EB=B0=B0?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80=20=E2=80=94=20=EB=AA=A8=EC=9D=98?= =?UTF-8?q?=EB=A9=B4=EC=A0=91=205=ED=9A=8C=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/point/entity/BadgeSeeder.java | 3 +- .../domain/point/service/BadgeService.java | 6 +++ .../point/service/BadgeServiceTest.java | 48 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/devpick/domain/point/entity/BadgeSeeder.java b/src/main/java/com/devpick/domain/point/entity/BadgeSeeder.java index 80f6ca47..d97037cf 100644 --- a/src/main/java/com/devpick/domain/point/entity/BadgeSeeder.java +++ b/src/main/java/com/devpick/domain/point/entity/BadgeSeeder.java @@ -29,7 +29,8 @@ public void run(ApplicationArguments args) { Badge.builder().id("POINT_100").name("새싹 개발자").description("누적 포인트 100p 달성").sortOrder(4).build(), Badge.builder().id("POINT_500").name("성장 중").description("누적 포인트 500p 달성").sortOrder(5).build(), Badge.builder().id("POINT_1000").name("시니어 픽커").description("누적 포인트 1000p 달성").sortOrder(6).build(), - Badge.builder().id("STREAK_7").name("7일 연속").description("연속 로그인 7일 달성").sortOrder(7).build() + Badge.builder().id("STREAK_7").name("7일 연속").description("연속 로그인 7일 달성").sortOrder(7).build(), + Badge.builder().id("INTERVIEW_MASTER").name("면접 마스터").description("모의면접 5회 완료").sortOrder(8).build() ); for (Badge seed : seeds) { diff --git a/src/main/java/com/devpick/domain/point/service/BadgeService.java b/src/main/java/com/devpick/domain/point/service/BadgeService.java index 4877db81..9e3f27cd 100644 --- a/src/main/java/com/devpick/domain/point/service/BadgeService.java +++ b/src/main/java/com/devpick/domain/point/service/BadgeService.java @@ -50,6 +50,7 @@ public void checkAndUnlock(User user, PointAction justEarned) { checkAnswerMaster(user); checkPointBadges(user); checkStreak7(user, justEarned); + checkInterviewMaster(user); } @Transactional(readOnly = true) @@ -107,6 +108,11 @@ private void checkStreak7(User user, PointAction justEarned) { unlockIfConditionMet(user, "STREAK_7", calculateStreak(user.getId(), justEarned) >= 7); } + private void checkInterviewMaster(User user) { + unlockIfConditionMet(user, "INTERVIEW_MASTER", + pointLogRepository.countByUser_IdAndAction(user.getId(), PointAction.MOCK_INTERVIEW_COMPLETE) >= 5); + } + private void unlockIfConditionMet(User user, String badgeId, boolean condition) { if (!condition) return; if (userBadgeRepository.existsByUser_IdAndBadge_Id(user.getId(), badgeId)) return; diff --git a/src/test/java/com/devpick/domain/point/service/BadgeServiceTest.java b/src/test/java/com/devpick/domain/point/service/BadgeServiceTest.java index 2432bac7..a9ba760d 100644 --- a/src/test/java/com/devpick/domain/point/service/BadgeServiceTest.java +++ b/src/test/java/com/devpick/domain/point/service/BadgeServiceTest.java @@ -284,4 +284,52 @@ void getRepresentativeBadge_noBadge_returnsEmpty() { assertThat(result).isEmpty(); } + + // ── checkAndUnlock — INTERVIEW_MASTER ────────────────────────── + + @Test + @DisplayName("checkAndUnlock — 모의면접 5회 완료 시 INTERVIEW_MASTER 배지 잠금 해제") + void checkAndUnlock_interviewMaster_5Completions_unlocks() { + Badge badge = Badge.builder().id("INTERVIEW_MASTER").name("면접 마스터").sortOrder(8).build(); + given(pointLogRepository.existsByUser_IdAndAction(userId, PointAction.CONTENT_SCRAP)).willReturn(false); + given(pointLogRepository.existsByUser_IdAndAction(userId, PointAction.QUESTION_WRITE)).willReturn(false); + given(pointLogRepository.countByUser_IdAndAction(userId, PointAction.ANSWER_ADOPTED)).willReturn(0L); + given(pointLogRepository.findDailyLoginsByUserIdOrderByEarnedAtDesc(userId)).willReturn(List.of()); + given(pointLogRepository.countByUser_IdAndAction(userId, PointAction.MOCK_INTERVIEW_COMPLETE)).willReturn(5L); + given(userBadgeRepository.existsByUser_IdAndBadge_Id(userId, "INTERVIEW_MASTER")).willReturn(false); + given(badgeRepository.findById("INTERVIEW_MASTER")).willReturn(Optional.of(badge)); + + badgeService.checkAndUnlock(user, PointAction.MOCK_INTERVIEW_COMPLETE); + + verify(userBadgeRepository).save(any(UserBadge.class)); + } + + @Test + @DisplayName("checkAndUnlock — 모의면접 4회 완료 시 INTERVIEW_MASTER 배지 잠금 해제 안 함") + void checkAndUnlock_interviewMaster_4Completions_doesNotUnlock() { + given(pointLogRepository.existsByUser_IdAndAction(userId, PointAction.CONTENT_SCRAP)).willReturn(false); + given(pointLogRepository.existsByUser_IdAndAction(userId, PointAction.QUESTION_WRITE)).willReturn(false); + given(pointLogRepository.countByUser_IdAndAction(userId, PointAction.ANSWER_ADOPTED)).willReturn(0L); + given(pointLogRepository.findDailyLoginsByUserIdOrderByEarnedAtDesc(userId)).willReturn(List.of()); + given(pointLogRepository.countByUser_IdAndAction(userId, PointAction.MOCK_INTERVIEW_COMPLETE)).willReturn(4L); + + badgeService.checkAndUnlock(user, PointAction.MOCK_INTERVIEW_COMPLETE); + + verify(userBadgeRepository, never()).save(any()); + } + + @Test + @DisplayName("checkAndUnlock — INTERVIEW_MASTER 이미 획득한 경우 중복 발급 안 함") + void checkAndUnlock_interviewMaster_alreadyAcquired_skips() { + given(pointLogRepository.existsByUser_IdAndAction(userId, PointAction.CONTENT_SCRAP)).willReturn(false); + given(pointLogRepository.existsByUser_IdAndAction(userId, PointAction.QUESTION_WRITE)).willReturn(false); + given(pointLogRepository.countByUser_IdAndAction(userId, PointAction.ANSWER_ADOPTED)).willReturn(0L); + given(pointLogRepository.findDailyLoginsByUserIdOrderByEarnedAtDesc(userId)).willReturn(List.of()); + given(pointLogRepository.countByUser_IdAndAction(userId, PointAction.MOCK_INTERVIEW_COMPLETE)).willReturn(5L); + given(userBadgeRepository.existsByUser_IdAndBadge_Id(userId, "INTERVIEW_MASTER")).willReturn(true); + + badgeService.checkAndUnlock(user, PointAction.MOCK_INTERVIEW_COMPLETE); + + verify(userBadgeRepository, never()).save(any()); + } } \ No newline at end of file