From 30a92fcf6fe3651b140099f6fbb5dca6acad1c3c Mon Sep 17 00:00:00 2001 From: nYeonG4001 <2371324@hansung.ac.kr> Date: Mon, 13 Apr 2026 16:26:04 +0900 Subject: [PATCH] =?UTF-8?q?DP-323:=20=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20=E2=80=94=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8/=EB=8C=93=EA=B8=80/AI=EC=9A=94?= =?UTF-8?q?=EC=95=BD/=EC=B7=A8=EC=86=8C=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=ED=99=98=EB=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DP-323: 로그인 시 daily_login 히스토리 저장 - DP-324: History에 comment FK + level 컬럼 추가, 댓글 생성/삭제 시 히스토리 연동 - DP-333: AI 요약 자동 히스토리/포인트 제거, POST /contents/{id}/summary/viewed 엔드포인트 신설 - DP-334: ai_summary_viewed 히스토리에 level 필드 추가 - 스크랩/좋아요 취소 시 히스토리 삭제 + 포인트 환불 - 답변/게시글 삭제 시 포인트 환불 (ANSWER_WRITE, ANSWER_ADOPTED, QUESTION_WRITE) --- .../community/service/AnswerService.java | 3 ++ .../community/service/CommentService.java | 2 + .../domain/community/service/PostService.java | 3 ++ .../content/controller/ContentController.java | 19 ++++++++++ .../content/dto/SummaryViewedRequest.java | 3 ++ .../content/service/AiSummaryService.java | 25 +++++-------- .../content/service/ContentService.java | 8 ++++ .../point/repository/PointLogRepository.java | 17 +++++++++ .../domain/point/service/PointService.java | 37 +++++++++++++++++++ .../report/dto/HistoryItemResponse.java | 21 +++++++++-- .../devpick/domain/report/entity/History.java | 8 ++++ .../report/repository/HistoryRepository.java | 12 ++++++ .../com/devpick/domain/user/entity/User.java | 5 +++ .../domain/user/service/AuthService.java | 7 ++++ .../content/service/ContentServiceTest.java | 2 + .../controller/HistoryControllerTest.java | 14 +++---- .../report/service/HistoryServiceTest.java | 2 +- .../domain/user/service/AuthServiceTest.java | 3 ++ 18 files changed, 164 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/devpick/domain/content/dto/SummaryViewedRequest.java diff --git a/src/main/java/com/devpick/domain/community/service/AnswerService.java b/src/main/java/com/devpick/domain/community/service/AnswerService.java index ec8ab97..e97f3ab 100644 --- a/src/main/java/com/devpick/domain/community/service/AnswerService.java +++ b/src/main/java/com/devpick/domain/community/service/AnswerService.java @@ -107,6 +107,9 @@ public void deleteAnswer(UUID userId, UUID postId, UUID answerId) { throw new DevpickException(ErrorCode.COMMUNITY_UNAUTHORIZED_ANSWER_ACTION); } + // 포인트 환불: ANSWER_WRITE (항상), ANSWER_ADOPTED (채택된 경우) + pointService.refundAnswerPoints(answer.getUser(), Boolean.TRUE.equals(answer.getIsAdopted())); + // 자식 레코드 순서대로 삭제 (FK 제약조건 준수) commentRepository.deleteByAnswerId(answerId); answerLikeRepository.deleteByAnswerId(answerId); diff --git a/src/main/java/com/devpick/domain/community/service/CommentService.java b/src/main/java/com/devpick/domain/community/service/CommentService.java index dd64fdb..90f2b72 100644 --- a/src/main/java/com/devpick/domain/community/service/CommentService.java +++ b/src/main/java/com/devpick/domain/community/service/CommentService.java @@ -60,6 +60,7 @@ public CommentResponse createComment(UUID userId, UUID postId, UUID answerId, .actionType("comment_created") .post(answer.getPost()) .answer(answer) + .comment(savedComment) .build()); return CommentResponse.of(savedComment); } @@ -92,6 +93,7 @@ public void deleteComment(UUID userId, UUID postId, UUID answerId, UUID commentI throw new DevpickException(ErrorCode.COMMUNITY_UNAUTHORIZED_COMMENT_ACTION); } + historyRepository.deleteByCommentId(commentId); commentRepository.delete(comment); } } diff --git a/src/main/java/com/devpick/domain/community/service/PostService.java b/src/main/java/com/devpick/domain/community/service/PostService.java index 6e3da5a..7d637e1 100644 --- a/src/main/java/com/devpick/domain/community/service/PostService.java +++ b/src/main/java/com/devpick/domain/community/service/PostService.java @@ -183,6 +183,9 @@ public void deletePost(UUID userId, UUID postId) { throw new DevpickException(ErrorCode.COMMUNITY_UNAUTHORIZED_POST_ACTION); } + // 포인트 환불: QUESTION_WRITE + pointService.refundLatestByAction(post.getUser(), PointAction.QUESTION_WRITE); + // 자식 레코드 순서대로 삭제 (FK 제약조건 준수) commentRepository.deleteByPostId(postId); answerLikeRepository.deleteByPostId(postId); diff --git a/src/main/java/com/devpick/domain/content/controller/ContentController.java b/src/main/java/com/devpick/domain/content/controller/ContentController.java index ff9369f..7f7cf89 100644 --- a/src/main/java/com/devpick/domain/content/controller/ContentController.java +++ b/src/main/java/com/devpick/domain/content/controller/ContentController.java @@ -2,6 +2,8 @@ import com.devpick.domain.content.dto.ContentDetailResponse; import com.devpick.domain.content.dto.ContentListResponse; +import com.devpick.domain.content.dto.SummaryViewedRequest; +import com.devpick.domain.content.service.AiSummaryService; import com.devpick.domain.content.service.ContentService; import com.devpick.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; @@ -17,6 +19,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; @@ -32,6 +35,7 @@ public class ContentController { private final ContentService contentService; + private final AiSummaryService aiSummaryService; @Operation(summary = "개인화 피드 조회", description = "사용자의 기술 태그와 레벨에 맞는 개인화된 콘텐츠 목록을 반환합니다.") @ApiResponses({ @@ -76,6 +80,21 @@ public ApiResponse getDetail( return ApiResponse.ok(contentService.getDetail(userId, contentId)); } + @Operation(summary = "AI 요약 조회 히스토리 기록", description = "AI 요약 섹션 최초 진입 시 1회 호출합니다. 히스토리(ai_summary_viewed)가 기록됩니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "기록 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 필요"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "콘텐츠를 찾을 수 없음") + }) + @PostMapping("/{contentId}/summary/viewed") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void recordSummaryViewed( + @AuthenticationPrincipal UUID userId, + @Parameter(description = "콘텐츠 ID (UUID)", required = true) @PathVariable UUID contentId, + @RequestBody SummaryViewedRequest request) { + aiSummaryService.recordSummaryViewed(userId, contentId, request.level()); + } + @Operation(summary = "원문 확인(학습 기록)", description = "외부 원문 링크를 연 시점에 호출합니다. 학습 히스토리(content_opened)가 기록됩니다.") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "기록 성공"), diff --git a/src/main/java/com/devpick/domain/content/dto/SummaryViewedRequest.java b/src/main/java/com/devpick/domain/content/dto/SummaryViewedRequest.java new file mode 100644 index 0000000..591faf2 --- /dev/null +++ b/src/main/java/com/devpick/domain/content/dto/SummaryViewedRequest.java @@ -0,0 +1,3 @@ +package com.devpick.domain.content.dto; + +public record SummaryViewedRequest(String level) {} diff --git a/src/main/java/com/devpick/domain/content/service/AiSummaryService.java b/src/main/java/com/devpick/domain/content/service/AiSummaryService.java index 4a1d55a..12e03e8 100644 --- a/src/main/java/com/devpick/domain/content/service/AiSummaryService.java +++ b/src/main/java/com/devpick/domain/content/service/AiSummaryService.java @@ -6,11 +6,8 @@ import com.devpick.domain.content.dto.AiSummaryResult; import com.devpick.domain.content.repository.AiSummaryRepository; import com.devpick.domain.content.repository.ContentRepository; -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.user.entity.User; import com.devpick.domain.user.repository.UserRepository; import com.devpick.global.common.exception.DevpickException; import com.devpick.global.common.exception.ErrorCode; @@ -42,7 +39,6 @@ public class AiSummaryService { private final HistoryRepository historyRepository; private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; - private final PointService pointService; private final ContentTagService contentTagService; @Transactional @@ -57,7 +53,6 @@ public AiSummaryResponse getSummary(UUID userId, UUID contentId, String level) { String redisKey = buildRedisKey(contentId, aiLevel); AiSummaryResponse cached = getFromRedis(redisKey); if (cached != null && cached.expiresAt() != null && cached.expiresAt().isAfter(Instant.now())) { - recordHistory(userId, contentId); return cached; } @@ -67,7 +62,6 @@ public AiSummaryResponse getSummary(UUID userId, UUID contentId, String level) { && docOpt.get().getExpiresAt().isAfter(LocalDateTime.now())) { AiSummaryResponse response = AiSummaryResponse.of(docOpt.get()); saveToRedis(redisKey, response); - recordHistory(userId, contentId); return response; } @@ -82,7 +76,6 @@ public AiSummaryResponse getSummary(UUID userId, UUID contentId, String level) { AiSummaryResponse response = AiSummaryResponse.of(doc); saveToRedis(redisKey, response); - recordHistory(userId, contentId); return response; } @@ -163,16 +156,16 @@ public Optional findCachedCoreSummary(UUID contentId, String level) { } } - private void recordHistory(UUID userId, UUID contentId) { + public void recordSummaryViewed(UUID userId, UUID contentId, String level) { userRepository.findByIdAndIsActiveTrue(userId).ifPresent(user -> - contentRepository.findByIdAndIsAvailableTrue(contentId).ifPresent(content -> { - historyRepository.save(History.builder() - .user(user) - .actionType("ai_summary_viewed") - .content(content) - .build()); - pointService.earn(user, PointAction.AI_SUMMARY_VIEW); - }) + contentRepository.findByIdAndIsAvailableTrue(contentId).ifPresent(content -> + historyRepository.save(History.builder() + .user(user) + .actionType("ai_summary_viewed") + .content(content) + .level(level) + .build()) + ) ); } diff --git a/src/main/java/com/devpick/domain/content/service/ContentService.java b/src/main/java/com/devpick/domain/content/service/ContentService.java index cea1114..32defb8 100644 --- a/src/main/java/com/devpick/domain/content/service/ContentService.java +++ b/src/main/java/com/devpick/domain/content/service/ContentService.java @@ -136,7 +136,11 @@ public void addScrap(UUID userId, UUID contentId) { public void removeScrap(UUID userId, UUID contentId) { Scrap scrap = scrapRepository.findByUser_IdAndContent_Id(userId, contentId) .orElseThrow(() -> new DevpickException(ErrorCode.CONTENT_NOT_SCRAPED)); + User user = userRepository.findByIdAndIsActiveTrue(userId) + .orElseThrow(() -> new DevpickException(ErrorCode.USER_NOT_FOUND)); scrapRepository.delete(scrap); + historyRepository.deleteByUserIdAndContentIdAndActionType(userId, contentId, "scrapped"); + pointService.refund(user, PointAction.CONTENT_SCRAP, contentId); } @Transactional @@ -166,7 +170,11 @@ public void addLike(UUID userId, UUID contentId) { public void removeLike(UUID userId, UUID contentId) { Like like = likeRepository.findByUser_IdAndContent_Id(userId, contentId) .orElseThrow(() -> new DevpickException(ErrorCode.CONTENT_NOT_LIKED)); + User user = userRepository.findByIdAndIsActiveTrue(userId) + .orElseThrow(() -> new DevpickException(ErrorCode.USER_NOT_FOUND)); likeRepository.delete(like); + historyRepository.deleteByUserIdAndContentIdAndActionType(userId, contentId, "content_liked"); + pointService.refund(user, PointAction.CONTENT_LIKE, contentId); } @Transactional(readOnly = true) diff --git a/src/main/java/com/devpick/domain/point/repository/PointLogRepository.java b/src/main/java/com/devpick/domain/point/repository/PointLogRepository.java index 508c803..e4fc463 100644 --- a/src/main/java/com/devpick/domain/point/repository/PointLogRepository.java +++ b/src/main/java/com/devpick/domain/point/repository/PointLogRepository.java @@ -5,11 +5,13 @@ 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.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import java.util.UUID; public interface PointLogRepository extends JpaRepository { @@ -36,4 +38,19 @@ int sumPointsByUserIdAndEarnedAtBetween( "WHERE pl.user.id = :userId AND pl.action = 'DAILY_LOGIN' " + "ORDER BY pl.earnedAt DESC") List findDailyLoginsByUserIdOrderByEarnedAtDesc(@Param("userId") UUID userId); + + @Modifying + @Query("DELETE FROM PointLog pl WHERE pl.user.id = :userId AND pl.action = :action AND pl.referenceId = :referenceId") + void deleteByUser_IdAndActionAndReferenceId( + @Param("userId") UUID userId, + @Param("action") PointAction action, + @Param("referenceId") UUID referenceId); + + @Query("SELECT COALESCE(SUM(pl.points), 0) FROM PointLog pl WHERE pl.user.id = :userId AND pl.action = :action AND pl.referenceId = :referenceId") + int sumPointsByUser_IdAndActionAndReferenceId( + @Param("userId") UUID userId, + @Param("action") PointAction action, + @Param("referenceId") UUID referenceId); + + Optional findTopByUser_IdAndActionOrderByEarnedAtDesc(UUID userId, PointAction action); } \ No newline at end of file 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 7e43867..403b8f6 100644 --- a/src/main/java/com/devpick/domain/point/service/PointService.java +++ b/src/main/java/com/devpick/domain/point/service/PointService.java @@ -105,6 +105,43 @@ public PointHistoryResponse getHistory(UUID userId, int page, int size) { // ── 중복 적립 방지 ────────────────────────────────────────────────── + /** + * referenceId 기준으로 적립된 포인트를 환불한다 (스크랩/좋아요 취소 시). + */ + @Transactional + public void refund(User user, PointAction action, UUID referenceId) { + int refundPoints = pointLogRepository.sumPointsByUser_IdAndActionAndReferenceId( + user.getId(), action, referenceId); + if (refundPoints <= 0) return; + pointLogRepository.deleteByUser_IdAndActionAndReferenceId(user.getId(), action, referenceId); + user.subtractPoints(refundPoints); + userRepository.save(user); + } + + /** + * 가장 최근 적립 로그 1건을 환불한다 (답변/게시글 삭제 시). + */ + @Transactional + public void refundLatestByAction(User user, PointAction action) { + pointLogRepository.findTopByUser_IdAndActionOrderByEarnedAtDesc(user.getId(), action) + .ifPresent(log -> { + pointLogRepository.delete(log); + user.subtractPoints(log.getPoints()); + userRepository.save(user); + }); + } + + /** + * 답변 삭제 시 포인트 환불: ANSWER_WRITE (항상), ANSWER_ADOPTED (채택된 경우). + */ + @Transactional + public void refundAnswerPoints(User user, boolean wasAdopted) { + refundLatestByAction(user, PointAction.ANSWER_WRITE); + if (wasAdopted) { + refundLatestByAction(user, PointAction.ANSWER_ADOPTED); + } + } + private boolean isDuplicate(UUID userId, PointAction action, UUID referenceId) { return switch (action) { case CONTENT_SCRAP, CONTENT_LIKE, AI_QUIZ_PASS -> 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 7501474..ef65fa7 100644 --- a/src/main/java/com/devpick/domain/report/dto/HistoryItemResponse.java +++ b/src/main/java/com/devpick/domain/report/dto/HistoryItemResponse.java @@ -14,11 +14,13 @@ public record HistoryItemResponse( ContentInfo content, PostInfo post, AnswerInfo answer, + CommentInfo comment, Instant createdAt ) { public record ContentInfo(UUID id, String title, String preview) {} public record PostInfo(UUID id, String title) {} - public record AnswerInfo(UUID id) {} + public record AnswerInfo(UUID id, String preview) {} + public record CommentInfo(UUID id, String preview) {} public static HistoryItemResponse of(History history) { ContentInfo contentInfo = history.getContent() != null @@ -35,11 +37,18 @@ public static HistoryItemResponse of(History history) { : null; AnswerInfo answerInfo = history.getAnswer() != null - ? new AnswerInfo(history.getAnswer().getId()) + ? new AnswerInfo( + history.getAnswer().getId(), + truncate(history.getAnswer().getContent(), 100)) + : null; + + CommentInfo commentInfo = history.getComment() != null + ? new CommentInfo( + history.getComment().getId(), + truncate(history.getComment().getContent(), 100)) : null; Integer points = switch (history.getActionType()) { - case "ai_summary_viewed" -> PointAction.AI_SUMMARY_VIEW.getPoints(); case "scrapped" -> PointAction.CONTENT_SCRAP.getPoints(); case "content_liked" -> PointAction.CONTENT_LIKE.getPoints(); case "question_created" -> PointAction.QUESTION_WRITE.getPoints(); @@ -57,7 +66,13 @@ public static HistoryItemResponse of(History history) { contentInfo, postInfo, answerInfo, + commentInfo, history.getCreatedAt() != null ? history.getCreatedAt().toInstant(ZoneOffset.UTC) : null ); } + + private static String truncate(String text, int maxLength) { + if (text == null) return null; + return text.length() <= maxLength ? text : text.substring(0, maxLength) + "..."; + } } diff --git a/src/main/java/com/devpick/domain/report/entity/History.java b/src/main/java/com/devpick/domain/report/entity/History.java index c83646a..c28c449 100644 --- a/src/main/java/com/devpick/domain/report/entity/History.java +++ b/src/main/java/com/devpick/domain/report/entity/History.java @@ -2,6 +2,7 @@ import com.devpick.domain.content.entity.Content; import com.devpick.domain.community.entity.Answer; +import com.devpick.domain.community.entity.Comment; import com.devpick.domain.community.entity.Post; import com.devpick.domain.user.entity.User; import com.devpick.global.entity.BaseCreatedEntity; @@ -38,4 +39,11 @@ public class History extends BaseCreatedEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "answer_id") private Answer answer; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id") + private Comment comment; + + @Column(name = "level", length = 20) + private String level; } 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 f88c8b6..f68163c 100644 --- a/src/main/java/com/devpick/domain/report/repository/HistoryRepository.java +++ b/src/main/java/com/devpick/domain/report/repository/HistoryRepository.java @@ -116,6 +116,7 @@ Page findHistoryIdsByDateRangeExcludingContentLiked( "LEFT JOIN FETCH h.content " + "LEFT JOIN FETCH h.post " + "LEFT JOIN FETCH h.answer " + + "LEFT JOIN FETCH h.comment " + "WHERE h.id IN :ids " + "ORDER BY h.createdAt DESC") List findHistoriesWithAssociationsByIds(@Param("ids") List ids); @@ -131,4 +132,15 @@ Page findHistoryIdsByDateRangeExcludingContentLiked( @Modifying @Query("DELETE FROM History h WHERE h.answer.id IN (SELECT a.id FROM Answer a WHERE a.post.id = :postId)") void deleteByAnswerPostId(@Param("postId") UUID postId); + + @Modifying + @Query("DELETE FROM History h WHERE h.comment.id = :commentId") + void deleteByCommentId(@Param("commentId") UUID commentId); + + @Modifying + @Query("DELETE FROM History h WHERE h.user.id = :userId AND h.content.id = :contentId AND h.actionType = :actionType") + void deleteByUserIdAndContentIdAndActionType( + @Param("userId") UUID userId, + @Param("contentId") UUID contentId, + @Param("actionType") String actionType); } diff --git a/src/main/java/com/devpick/domain/user/entity/User.java b/src/main/java/com/devpick/domain/user/entity/User.java index 7396b0b..511a331 100644 --- a/src/main/java/com/devpick/domain/user/entity/User.java +++ b/src/main/java/com/devpick/domain/user/entity/User.java @@ -109,6 +109,11 @@ public void addPoints(int points) { this.totalPoints += points; } + /** 포인트 차감 (환불). */ + public void subtractPoints(int points) { + this.totalPoints = Math.max(0, this.totalPoints - points); + } + /** 프로필 수정 (DP-187). */ public void updateProfile(String nickname, String profileImage, Job job, Level level) { if (nickname != null) this.nickname = nickname; diff --git a/src/main/java/com/devpick/domain/user/service/AuthService.java b/src/main/java/com/devpick/domain/user/service/AuthService.java index 3d07a35..231a2ea 100644 --- a/src/main/java/com/devpick/domain/user/service/AuthService.java +++ b/src/main/java/com/devpick/domain/user/service/AuthService.java @@ -2,6 +2,8 @@ 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.user.dto.LoginRequest; import com.devpick.domain.user.dto.LoginResponse; import com.devpick.domain.user.dto.RecoverRequest; @@ -31,6 +33,7 @@ public class AuthService { private final EmailVerificationRedisService emailVerificationRedisService; private final PointService pointService; private final UserConsentRepository userConsentRepository; + private final HistoryRepository historyRepository; /** * 이메일 회원가입 (DP-177 수정 — 이메일 인증 후 가입 흐름). @@ -93,6 +96,10 @@ public LoginResponse login(LoginRequest request) { } pointService.earn(user, PointAction.DAILY_LOGIN); + historyRepository.save(History.builder() + .user(user) + .actionType("daily_login") + .build()); return tokenService.issueTokenPair(user); } diff --git a/src/test/java/com/devpick/domain/content/service/ContentServiceTest.java b/src/test/java/com/devpick/domain/content/service/ContentServiceTest.java index 6a64cfa..17b17ef 100644 --- a/src/test/java/com/devpick/domain/content/service/ContentServiceTest.java +++ b/src/test/java/com/devpick/domain/content/service/ContentServiceTest.java @@ -259,6 +259,7 @@ void addScrap_alreadyScrapped_throwsException() { void removeScrap_success() { Scrap scrap = Scrap.builder().user(user).content(content).build(); given(scrapRepository.findByUser_IdAndContent_Id(userId, contentId)).willReturn(Optional.of(scrap)); + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); contentService.removeScrap(userId, contentId); @@ -306,6 +307,7 @@ void addLike_alreadyLiked_throwsException() { void removeLike_success() { Like like = Like.builder().user(user).content(content).build(); given(likeRepository.findByUser_IdAndContent_Id(userId, contentId)).willReturn(Optional.of(like)); + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); contentService.removeLike(userId, contentId); 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 3402036..5072351 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, 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, 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)); @@ -163,16 +163,16 @@ void getLearningHistory_scrappedAction_returnsPoints5() throws Exception { @DisplayName("GET /history - ai_summary_viewed 액션은 points 3을 반환한다") void getLearningHistory_aiSummaryViewedAction_returnsPoints3() throws Exception { HistoryItemResponse item = new HistoryItemResponse( - UUID.randomUUID(), "ai_summary_viewed", 3, + UUID.randomUUID(), "ai_summary_viewed", null, new HistoryItemResponse.ContentInfo(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)); mockMvc.perform(get("/history")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.items[0].points").value(3)); + .andExpect(jsonPath("$.data.items[0].points", nullValue())); } @Test @@ -182,7 +182,7 @@ void getLearningHistory_questionCreatedAction_returnsPoints10() throws Exception UUID.randomUUID(), "question_created", 10, null, new HistoryItemResponse.PostInfo(UUID.randomUUID(), "질문 제목"), - null, Instant.now() + 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, 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)); diff --git a/src/test/java/com/devpick/domain/report/service/HistoryServiceTest.java b/src/test/java/com/devpick/domain/report/service/HistoryServiceTest.java index dfa3fda..89cbefb 100644 --- a/src/test/java/com/devpick/domain/report/service/HistoryServiceTest.java +++ b/src/test/java/com/devpick/domain/report/service/HistoryServiceTest.java @@ -201,7 +201,7 @@ void historyItemResponse_of_pointsMappedCorrectly(String actionType, Integer exp static Stream actionTypePointsProvider() { return Stream.of( - Arguments.of("ai_summary_viewed", 3), + Arguments.of("ai_summary_viewed", null), Arguments.of("scrapped", 5), Arguments.of("content_liked", 2), Arguments.of("question_created", 10), diff --git a/src/test/java/com/devpick/domain/user/service/AuthServiceTest.java b/src/test/java/com/devpick/domain/user/service/AuthServiceTest.java index a8a06a6..f1d8195 100644 --- a/src/test/java/com/devpick/domain/user/service/AuthServiceTest.java +++ b/src/test/java/com/devpick/domain/user/service/AuthServiceTest.java @@ -52,6 +52,9 @@ class AuthServiceTest { @Mock private UserConsentRepository userConsentRepository; + @Mock + private com.devpick.domain.report.repository.HistoryRepository historyRepository; + // ── signup ────────────────────────────────────────────────────────── // 프론트 미사용 — 재활성화 시 주석 해제