diff --git a/src/main/java/com/devpick/domain/content/controller/QuizHistoryController.java b/src/main/java/com/devpick/domain/content/controller/QuizHistoryController.java new file mode 100644 index 0000000..059cba8 --- /dev/null +++ b/src/main/java/com/devpick/domain/content/controller/QuizHistoryController.java @@ -0,0 +1,54 @@ +package com.devpick.domain.content.controller; + +import com.devpick.domain.content.dto.QuizHistoryListResponse; +import com.devpick.domain.content.dto.QuizResultResponse; +import com.devpick.domain.content.service.AiQuizService; +import com.devpick.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@Tag(name = "Quiz History", description = "퀴즈 이력 조회") +@RestController +@RequiredArgsConstructor +public class QuizHistoryController { + + private final AiQuizService aiQuizService; + + @Operation(summary = "퀴즈 이력 목록", description = "contentId+level 기준 최신 attempt 중 미완료(점수 < 전체)인 것만 페이징 반환합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 필요") + }) + @GetMapping("/users/me/quiz-history") + public ApiResponse getQuizHistory( + @AuthenticationPrincipal UUID userId, + @Parameter(description = "정렬 순서", example = "newest") @RequestParam(defaultValue = "newest") String sort, + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기", example = "10") @RequestParam(defaultValue = "10") int size) { + return ApiResponse.ok(aiQuizService.getQuizHistory(userId, sort, PageRequest.of(page, size))); + } + + @Operation(summary = "퀴즈 결과 상세", description = "특정 퀴즈 시도의 문제 목록과 내 답안을 반환합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "타인의 이력 접근"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "이력 없음") + }) + @GetMapping("/quiz-history/{attemptId}") + public ApiResponse getQuizResult( + @AuthenticationPrincipal UUID userId, + @Parameter(description = "퀴즈 시도 ID (UUID)", required = true) @PathVariable UUID attemptId) { + return ApiResponse.ok(aiQuizService.getQuizResult(userId, attemptId)); + } +} diff --git a/src/main/java/com/devpick/domain/content/dto/QuizHistoryItemResponse.java b/src/main/java/com/devpick/domain/content/dto/QuizHistoryItemResponse.java new file mode 100644 index 0000000..af90b87 --- /dev/null +++ b/src/main/java/com/devpick/domain/content/dto/QuizHistoryItemResponse.java @@ -0,0 +1,42 @@ +package com.devpick.domain.content.dto; + +import com.devpick.domain.content.entity.QuizAttempt; +import com.devpick.domain.content.service.AiSummaryService; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.UUID; + +public record QuizHistoryItemResponse( + UUID attemptId, + UUID contentId, + String contentTitle, + String thumbnail, + String preview, + String level, + int score, + int totalQuestions, + boolean passed, + Instant attemptedAt +) { + public static QuizHistoryItemResponse of(QuizAttempt attempt, Map previewMap) { + UUID contentId = attempt.getContent().getId(); + String key = contentId + "|" + attempt.getLevel(); + String rawPreview = previewMap.get(key); + String preview = (rawPreview != null && !rawPreview.isBlank()) ? rawPreview : null; + + return new QuizHistoryItemResponse( + attempt.getId(), + contentId, + attempt.getContent().getTitle(), + attempt.getContent().getThumbnailUrl(), + preview, + AiSummaryService.fromAiServerLevel(attempt.getLevel()), + attempt.getScore(), + attempt.getTotalQuestions(), + attempt.isPassed(), + attempt.getCreatedAt().toInstant(ZoneOffset.UTC) + ); + } +} diff --git a/src/main/java/com/devpick/domain/content/dto/QuizHistoryListResponse.java b/src/main/java/com/devpick/domain/content/dto/QuizHistoryListResponse.java new file mode 100644 index 0000000..9c42079 --- /dev/null +++ b/src/main/java/com/devpick/domain/content/dto/QuizHistoryListResponse.java @@ -0,0 +1,11 @@ +package com.devpick.domain.content.dto; + +import java.util.List; + +public record QuizHistoryListResponse( + List content, + int page, + int size, + long totalElements, + int totalPages +) {} diff --git a/src/main/java/com/devpick/domain/content/dto/QuizResultResponse.java b/src/main/java/com/devpick/domain/content/dto/QuizResultResponse.java new file mode 100644 index 0000000..b705b97 --- /dev/null +++ b/src/main/java/com/devpick/domain/content/dto/QuizResultResponse.java @@ -0,0 +1,29 @@ +package com.devpick.domain.content.dto; + +import com.devpick.domain.content.document.AiQuizDocument; + +import java.util.List; +import java.util.UUID; + +public record QuizResultResponse( + UUID attemptId, + UUID contentId, + int score, + int totalQuestions, + boolean passed, + int pointsEarned, + QuizData quiz, + List myAnswers +) { + public record QuizData( + List questions, + int passingCount + ) {} + + public record MyAnswer( + String questionId, + String selectedOptionId, + String answerText, + boolean isCorrect + ) {} +} diff --git a/src/main/java/com/devpick/domain/content/dto/QuizSubmitRequest.java b/src/main/java/com/devpick/domain/content/dto/QuizSubmitRequest.java index 32d623a..36fa0ff 100644 --- a/src/main/java/com/devpick/domain/content/dto/QuizSubmitRequest.java +++ b/src/main/java/com/devpick/domain/content/dto/QuizSubmitRequest.java @@ -1,8 +1,18 @@ package com.devpick.domain.content.dto; +import java.util.List; + public record QuizSubmitRequest( String level, int score, int totalQuestions, - boolean passed -) {} + boolean passed, + List answers +) { + public record AnswerItem( + String questionId, + String selectedOptionId, + String answerText, + boolean isCorrect + ) {} +} diff --git a/src/main/java/com/devpick/domain/content/entity/QuizAttemptAnswer.java b/src/main/java/com/devpick/domain/content/entity/QuizAttemptAnswer.java new file mode 100644 index 0000000..13947bb --- /dev/null +++ b/src/main/java/com/devpick/domain/content/entity/QuizAttemptAnswer.java @@ -0,0 +1,42 @@ +package com.devpick.domain.content.entity; + +import com.devpick.global.entity.BaseCreatedEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "quiz_attempt_answers", indexes = { + @Index(name = "idx_quiz_attempt_answers_attempt_id", columnList = "attempt_id") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@AllArgsConstructor +public class QuizAttemptAnswer extends BaseCreatedEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "attempt_id", nullable = false) + private QuizAttempt attempt; + + @Column(name = "question_id", length = 100, nullable = false) + private String questionId; + + @Column(name = "selected_option_id", length = 100) + private String selectedOptionId; + + @Column(name = "answer_text", columnDefinition = "TEXT") + private String answerText; + + @Column(name = "is_correct", nullable = false) + private boolean correct; +} diff --git a/src/main/java/com/devpick/domain/content/repository/AiQuizRepository.java b/src/main/java/com/devpick/domain/content/repository/AiQuizRepository.java index bedeaa5..7dda2d1 100644 --- a/src/main/java/com/devpick/domain/content/repository/AiQuizRepository.java +++ b/src/main/java/com/devpick/domain/content/repository/AiQuizRepository.java @@ -6,17 +6,26 @@ import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.BatchGetItemEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.ReadBatch; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; @Repository public class AiQuizRepository { private static final String TABLE_NAME = "ai_quizzes"; + private final DynamoDbEnhancedClient enhancedClient; private final DynamoDbTable table; public AiQuizRepository(DynamoDbEnhancedClient enhancedClient) { + this.enhancedClient = enhancedClient; this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(AiQuizDocument.class)); } @@ -40,4 +49,35 @@ public void deleteByContentIdAndLevel(String contentId, String level) { .build(); table.deleteItem(key); } + + /** + * (contentId, level) 쌍 목록에 대해 첫 번째 문제 텍스트를 배치 조회한다. + * @param keys [contentId, level] 배열의 리스트 + * @return "contentId|level" → 첫 번째 문제 텍스트 + */ + public Map batchFindFirstQuestions(List keys) { + if (keys.isEmpty()) return Map.of(); + + ReadBatch.Builder batchBuilder = ReadBatch.builder(AiQuizDocument.class) + .mappedTableResource(table); + + Set seen = new HashSet<>(); + for (String[] key : keys) { + if (seen.add(key[0] + "|" + key[1])) { + batchBuilder.addGetItem(Key.builder() + .partitionValue(key[0]).sortValue(key[1]).build()); + } + } + + Map result = new HashMap<>(); + enhancedClient.batchGetItem( + BatchGetItemEnhancedRequest.builder().readBatches(batchBuilder.build()).build() + ).resultsForTable(table).forEach(doc -> { + if (doc.getQuestions() != null && !doc.getQuestions().isEmpty()) { + result.put(doc.getContentId() + "|" + doc.getLevel(), + doc.getQuestions().getFirst().getQuestion()); + } + }); + return result; + } } diff --git a/src/main/java/com/devpick/domain/content/repository/QuizAttemptAnswerRepository.java b/src/main/java/com/devpick/domain/content/repository/QuizAttemptAnswerRepository.java new file mode 100644 index 0000000..7d652c8 --- /dev/null +++ b/src/main/java/com/devpick/domain/content/repository/QuizAttemptAnswerRepository.java @@ -0,0 +1,12 @@ +package com.devpick.domain.content.repository; + +import com.devpick.domain.content.entity.QuizAttemptAnswer; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface QuizAttemptAnswerRepository extends JpaRepository { + + List findByAttempt_Id(UUID attemptId); +} diff --git a/src/main/java/com/devpick/domain/content/repository/QuizAttemptRepository.java b/src/main/java/com/devpick/domain/content/repository/QuizAttemptRepository.java index 745f42a..2d9adf1 100644 --- a/src/main/java/com/devpick/domain/content/repository/QuizAttemptRepository.java +++ b/src/main/java/com/devpick/domain/content/repository/QuizAttemptRepository.java @@ -1,7 +1,11 @@ package com.devpick.domain.content.repository; import com.devpick.domain.content.entity.QuizAttempt; +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 java.util.Optional; import java.util.UUID; @@ -9,4 +13,18 @@ public interface QuizAttemptRepository extends JpaRepository { Optional findTopByUser_IdAndContent_IdOrderByCreatedAtDesc(UUID userId, UUID contentId); + + @Query(value = "SELECT qa FROM QuizAttempt qa JOIN FETCH qa.content c JOIN FETCH c.source " + + "WHERE qa.user.id = :userId " + + "AND qa.createdAt = (SELECT MAX(qa2.createdAt) FROM QuizAttempt qa2 " + + "WHERE qa2.user.id = :userId AND qa2.content.id = qa.content.id AND qa2.level = qa.level) " + + "AND qa.score < qa.totalQuestions", + countQuery = "SELECT COUNT(qa) FROM QuizAttempt qa " + + "WHERE qa.user.id = :userId " + + "AND qa.createdAt = (SELECT MAX(qa2.createdAt) FROM QuizAttempt qa2 " + + "WHERE qa2.user.id = :userId AND qa2.content.id = qa.content.id AND qa2.level = qa.level) " + + "AND qa.score < qa.totalQuestions") + Page findHistoryByUserId(@Param("userId") UUID userId, Pageable pageable); + + Optional findByIdAndUser_Id(UUID id, UUID userId); } diff --git a/src/main/java/com/devpick/domain/content/service/AiQuizService.java b/src/main/java/com/devpick/domain/content/service/AiQuizService.java index f13dcb9..3a13c6b 100644 --- a/src/main/java/com/devpick/domain/content/service/AiQuizService.java +++ b/src/main/java/com/devpick/domain/content/service/AiQuizService.java @@ -4,14 +4,20 @@ import com.devpick.domain.content.document.AiQuizDocument; import com.devpick.domain.content.dto.AiQuizResponse; import com.devpick.domain.content.dto.AiQuizResult; +import com.devpick.domain.content.dto.QuizHistoryItemResponse; +import com.devpick.domain.content.dto.QuizHistoryListResponse; +import com.devpick.domain.content.dto.QuizResultResponse; import com.devpick.domain.content.dto.QuizSubmitRequest; import com.devpick.domain.content.dto.QuizSubmitResponse; import com.devpick.domain.content.entity.Content; import com.devpick.domain.content.entity.QuizAttempt; +import com.devpick.domain.content.entity.QuizAttemptAnswer; import com.devpick.domain.content.repository.AiQuizRepository; import com.devpick.domain.content.repository.ContentRepository; +import com.devpick.domain.content.repository.QuizAttemptAnswerRepository; import com.devpick.domain.content.repository.QuizAttemptRepository; import com.devpick.domain.point.entity.PointAction; +import com.devpick.domain.point.repository.PointLogRepository; import com.devpick.domain.point.service.PointService; import com.devpick.domain.report.entity.History; import com.devpick.domain.report.repository.HistoryRepository; @@ -23,6 +29,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,6 +40,7 @@ import java.time.Instant; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -50,6 +61,8 @@ public class AiQuizService { private final ObjectMapper objectMapper; private final PointService pointService; private final QuizAttemptRepository quizAttemptRepository; + private final QuizAttemptAnswerRepository quizAttemptAnswerRepository; + private final PointLogRepository pointLogRepository; @Transactional public AiQuizResponse getQuiz(UUID userId, UUID contentId, String level) { @@ -100,7 +113,7 @@ public QuizSubmitResponse submitQuiz(UUID userId, UUID contentId, QuizSubmitRequ if (userOpt.isPresent()) { User user = userOpt.get(); - quizAttemptRepository.save(QuizAttempt.builder() + QuizAttempt savedAttempt = quizAttemptRepository.save(QuizAttempt.builder() .user(user) .content(content) .level(request.level()) @@ -109,6 +122,19 @@ public QuizSubmitResponse submitQuiz(UUID userId, UUID contentId, QuizSubmitRequ .passed(request.passed()) .build()); + if (request.answers() != null && !request.answers().isEmpty()) { + List answers = request.answers().stream() + .map(a -> QuizAttemptAnswer.builder() + .attempt(savedAttempt) + .questionId(a.questionId()) + .selectedOptionId(a.selectedOptionId()) + .answerText(a.answerText()) + .correct(a.isCorrect()) + .build()) + .toList(); + quizAttemptAnswerRepository.saveAll(answers); + } + if (request.passed()) { historyRepository.save(History.builder() .user(user) @@ -130,6 +156,81 @@ public QuizSubmitResponse submitQuiz(UUID userId, UUID contentId, QuizSubmitRequ ); } + @Transactional(readOnly = true) + public QuizHistoryListResponse getQuizHistory(UUID userId, String sort, Pageable pageable) { + Sort jpaSort = "oldest".equalsIgnoreCase(sort) + ? Sort.by(Sort.Direction.ASC, "createdAt") + : Sort.by(Sort.Direction.DESC, "createdAt"); + Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), jpaSort); + + Page page = quizAttemptRepository.findHistoryByUserId(userId, sortedPageable); + + if (page.isEmpty()) { + return new QuizHistoryListResponse(List.of(), page.getNumber(), page.getSize(), 0L, 0); + } + + List keys = page.getContent().stream() + .map(a -> new String[]{a.getContent().getId().toString(), a.getLevel()}) + .toList(); + + Map previewMap; + try { + previewMap = aiQuizRepository.batchFindFirstQuestions(keys); + } catch (Exception e) { + log.warn("퀴즈 preview 배치 조회 실패 — fallback 적용: {}", e.getMessage()); + previewMap = Map.of(); + } + final Map finalPreviewMap = previewMap; + + List items = page.getContent().stream() + .map(a -> QuizHistoryItemResponse.of(a, finalPreviewMap)) + .toList(); + + return new QuizHistoryListResponse(items, page.getNumber(), page.getSize(), + page.getTotalElements(), page.getTotalPages()); + } + + @Transactional(readOnly = true) + public QuizResultResponse getQuizResult(UUID userId, UUID attemptId) { + QuizAttempt attempt = quizAttemptRepository.findById(attemptId) + .orElseThrow(() -> new DevpickException(ErrorCode.QUIZ_ATTEMPT_NOT_FOUND)); + + if (!attempt.getUser().getId().equals(userId)) { + throw new DevpickException(ErrorCode.QUIZ_ATTEMPT_FORBIDDEN); + } + + QuizResultResponse.QuizData quizData = null; + try { + Optional doc = aiQuizRepository.findByContentIdAndLevel( + attempt.getContent().getId().toString(), attempt.getLevel()); + if (doc.isPresent()) { + quizData = new QuizResultResponse.QuizData(doc.get().getQuestions(), doc.get().getPassingCount()); + } + } catch (Exception e) { + log.warn("퀴즈 DynamoDB 조회 실패: {}", e.getMessage()); + } + + List answers = quizAttemptAnswerRepository.findByAttempt_Id(attemptId); + List myAnswers = answers.stream() + .map(a -> new QuizResultResponse.MyAnswer( + a.getQuestionId(), a.getSelectedOptionId(), a.getAnswerText(), a.isCorrect())) + .toList(); + + int pointsEarned = pointLogRepository.sumPointsByUser_IdAndActionAndReferenceId( + userId, PointAction.AI_QUIZ_PASS, attempt.getContent().getId()); + + return new QuizResultResponse( + attempt.getId(), + attempt.getContent().getId(), + attempt.getScore(), + attempt.getTotalQuestions(), + attempt.isPassed(), + pointsEarned, + quizData, + myAnswers + ); + } + private AiQuizResponse mergeWithAttempt(AiQuizResponse cached, QuizAttempt attempt) { boolean hasAttempted = attempt != null; return new AiQuizResponse( 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 fdd2242..d0e1ba8 100644 --- a/src/main/java/com/devpick/domain/content/service/AiSummaryService.java +++ b/src/main/java/com/devpick/domain/content/service/AiSummaryService.java @@ -76,6 +76,13 @@ public static String toAiServerLevel(String level) { }; } + public static String fromAiServerLevel(String aiLevel) { + return switch (aiLevel.toLowerCase()) { + case "mid" -> "MIDDLE"; + default -> aiLevel.toUpperCase(); + }; + } + private String buildRedisKey(UUID contentId, String aiLevel) { return "summary:" + contentId + ":" + aiLevel; } diff --git a/src/main/java/com/devpick/global/common/exception/ErrorCode.java b/src/main/java/com/devpick/global/common/exception/ErrorCode.java index 5571b67..80c9674 100644 --- a/src/main/java/com/devpick/global/common/exception/ErrorCode.java +++ b/src/main/java/com/devpick/global/common/exception/ErrorCode.java @@ -68,6 +68,8 @@ public enum ErrorCode { AI_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "AI_002", "AI 서버 응답 시간이 초과되었습니다."), AI_SUMMARY_NOT_FOUND(HttpStatus.NOT_FOUND, "AI_003", "AI 요약을 찾을 수 없습니다."), AI_QUIZ_NOT_FOUND(HttpStatus.NOT_FOUND, "AI_004", "퀴즈를 찾을 수 없습니다."), + QUIZ_ATTEMPT_NOT_FOUND(HttpStatus.NOT_FOUND, "AI_005", "퀴즈 시도 이력을 찾을 수 없습니다."), + QUIZ_ATTEMPT_FORBIDDEN(HttpStatus.FORBIDDEN, "AI_006", "퀴즈 이력에 접근 권한이 없습니다."), // Community COMMUNITY_POST_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMUNITY_001", "게시글을 찾을 수 없습니다."), diff --git a/src/test/java/com/devpick/domain/content/controller/AiQuizControllerTest.java b/src/test/java/com/devpick/domain/content/controller/AiQuizControllerTest.java index 79227b1..4e6b632 100644 --- a/src/test/java/com/devpick/domain/content/controller/AiQuizControllerTest.java +++ b/src/test/java/com/devpick/domain/content/controller/AiQuizControllerTest.java @@ -162,7 +162,7 @@ void getQuiz_aiServerError_returns500() throws Exception { @Test @DisplayName("POST /contents/{contentId}/quiz/submit - 통과 시 pointsEarned 반환") void submitQuiz_passed_returns200WithPoints() throws Exception { - QuizSubmitRequest request = new QuizSubmitRequest("JUNIOR", 4, 5, true); + QuizSubmitRequest request = new QuizSubmitRequest("JUNIOR", 4, 5, true, null); QuizSubmitResponse submitResponse = new QuizSubmitResponse(true, 4, 5, 5); given(aiQuizService.submitQuiz(eq(userId), eq(contentId), any())).willReturn(submitResponse); @@ -178,7 +178,7 @@ void submitQuiz_passed_returns200WithPoints() throws Exception { @Test @DisplayName("POST /contents/{contentId}/quiz/submit - 실패 시 pointsEarned=0 반환") void submitQuiz_failed_returns200WithZeroPoints() throws Exception { - QuizSubmitRequest request = new QuizSubmitRequest("JUNIOR", 2, 5, false); + QuizSubmitRequest request = new QuizSubmitRequest("JUNIOR", 2, 5, false, null); QuizSubmitResponse submitResponse = new QuizSubmitResponse(false, 2, 5, 0); given(aiQuizService.submitQuiz(eq(userId), eq(contentId), any())).willReturn(submitResponse); @@ -193,7 +193,7 @@ void submitQuiz_failed_returns200WithZeroPoints() throws Exception { @Test @DisplayName("POST /contents/{contentId}/quiz/submit - 콘텐츠 없으면 404 반환") void submitQuiz_contentNotFound_returns404() throws Exception { - QuizSubmitRequest request = new QuizSubmitRequest("JUNIOR", 3, 5, true); + QuizSubmitRequest request = new QuizSubmitRequest("JUNIOR", 3, 5, true, null); given(aiQuizService.submitQuiz(eq(userId), eq(contentId), any())) .willThrow(new DevpickException(ErrorCode.CONTENT_NOT_FOUND)); diff --git a/src/test/java/com/devpick/domain/content/controller/QuizHistoryControllerTest.java b/src/test/java/com/devpick/domain/content/controller/QuizHistoryControllerTest.java new file mode 100644 index 0000000..d792c49 --- /dev/null +++ b/src/test/java/com/devpick/domain/content/controller/QuizHistoryControllerTest.java @@ -0,0 +1,142 @@ +package com.devpick.domain.content.controller; + +import com.devpick.domain.content.dto.QuizHistoryItemResponse; +import com.devpick.domain.content.dto.QuizHistoryListResponse; +import com.devpick.domain.content.dto.QuizResultResponse; +import com.devpick.domain.content.document.AiQuizDocument; +import com.devpick.domain.content.service.AiQuizService; +import com.devpick.global.common.exception.DevpickException; +import com.devpick.global.common.exception.ErrorCode; +import com.devpick.global.common.exception.GlobalExceptionHandler; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class QuizHistoryControllerTest { + + private MockMvc mockMvc; + + @Mock private AiQuizService aiQuizService; + @InjectMocks private QuizHistoryController quizHistoryController; + + private UUID userId; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders + .standaloneSetup(quizHistoryController) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .build(); + + userId = UUID.randomUUID(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + userId, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))) + ); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("GET /users/me/quiz-history - 정상 조회 시 200 반환") + void getQuizHistory_success_returns200() throws Exception { + QuizHistoryItemResponse item = new QuizHistoryItemResponse( + UUID.randomUUID(), UUID.randomUUID(), + "React hooks 완전 정복", "https://thumb.jpg", "첫 번째 문제 텍스트", + "JUNIOR", 2, 3, false, Instant.now() + ); + QuizHistoryListResponse response = new QuizHistoryListResponse(List.of(item), 0, 10, 1L, 1); + given(aiQuizService.getQuizHistory(eq(userId), any(), any())).willReturn(response); + + mockMvc.perform(get("/users/me/quiz-history")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.content[0].level").value("JUNIOR")) + .andExpect(jsonPath("$.data.totalElements").value(1)); + } + + @Test + @DisplayName("GET /users/me/quiz-history - 빈 결과 시 200 반환") + void getQuizHistory_empty_returns200WithEmptyContent() throws Exception { + QuizHistoryListResponse response = new QuizHistoryListResponse(List.of(), 0, 10, 0L, 0); + given(aiQuizService.getQuizHistory(any(), any(), any())).willReturn(response); + + mockMvc.perform(get("/users/me/quiz-history")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content").isEmpty()) + .andExpect(jsonPath("$.data.totalElements").value(0)); + } + + @Test + @DisplayName("GET /quiz-history/{attemptId} - 정상 조회 시 200 반환") + void getQuizResult_success_returns200() throws Exception { + UUID attemptId = UUID.randomUUID(); + UUID contentId = UUID.randomUUID(); + AiQuizDocument.Option opt = AiQuizDocument.Option.builder().id("opt-1").text("선택지1").build(); + AiQuizDocument.Question q = AiQuizDocument.Question.builder() + .id("q-1").type("multiple_choice").question("문제1") + .options(List.of(opt)).correctOptionId("opt-1").explanation("해설1").correctAnswer("").build(); + QuizResultResponse.QuizData quizData = new QuizResultResponse.QuizData(List.of(q), 2); + QuizResultResponse.MyAnswer myAnswer = new QuizResultResponse.MyAnswer("q-1", "opt-1", null, true); + QuizResultResponse response = new QuizResultResponse( + attemptId, contentId, 2, 3, false, 0, quizData, List.of(myAnswer)); + given(aiQuizService.getQuizResult(eq(userId), eq(attemptId))).willReturn(response); + + mockMvc.perform(get("/quiz-history/" + attemptId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.quiz.questions[0].question").value("문제1")) + .andExpect(jsonPath("$.data.myAnswers[0].questionId").value("q-1")); + } + + @Test + @DisplayName("GET /quiz-history/{attemptId} - 타인 attempt → 403 반환") + void getQuizResult_forbidden_returns403() throws Exception { + UUID attemptId = UUID.randomUUID(); + given(aiQuizService.getQuizResult(eq(userId), eq(attemptId))) + .willThrow(new DevpickException(ErrorCode.QUIZ_ATTEMPT_FORBIDDEN)); + + mockMvc.perform(get("/quiz-history/" + attemptId)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.success").value(false)); + } + + @Test + @DisplayName("GET /quiz-history/{attemptId} - 존재하지 않는 attempt → 404 반환") + void getQuizResult_notFound_returns404() throws Exception { + UUID attemptId = UUID.randomUUID(); + given(aiQuizService.getQuizResult(eq(userId), eq(attemptId))) + .willThrow(new DevpickException(ErrorCode.QUIZ_ATTEMPT_NOT_FOUND)); + + mockMvc.perform(get("/quiz-history/" + attemptId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)); + } +} diff --git a/src/test/java/com/devpick/domain/content/service/AiQuizServiceTest.java b/src/test/java/com/devpick/domain/content/service/AiQuizServiceTest.java index ede3766..62a35c8 100644 --- a/src/test/java/com/devpick/domain/content/service/AiQuizServiceTest.java +++ b/src/test/java/com/devpick/domain/content/service/AiQuizServiceTest.java @@ -4,15 +4,20 @@ import com.devpick.domain.content.document.AiQuizDocument; import com.devpick.domain.content.dto.AiQuizResponse; import com.devpick.domain.content.dto.AiQuizResult; +import com.devpick.domain.content.dto.QuizHistoryListResponse; +import com.devpick.domain.content.dto.QuizResultResponse; import com.devpick.domain.content.dto.QuizSubmitRequest; import com.devpick.domain.content.dto.QuizSubmitResponse; import com.devpick.domain.content.entity.Content; import com.devpick.domain.content.entity.ContentSource; import com.devpick.domain.content.entity.QuizAttempt; +import com.devpick.domain.content.entity.QuizAttemptAnswer; import com.devpick.domain.content.repository.AiQuizRepository; import com.devpick.domain.content.repository.ContentRepository; +import com.devpick.domain.content.repository.QuizAttemptAnswerRepository; import com.devpick.domain.content.repository.QuizAttemptRepository; import com.devpick.domain.point.entity.PointAction; +import com.devpick.domain.point.repository.PointLogRepository; import com.devpick.domain.point.service.PointService; import com.devpick.domain.report.repository.HistoryRepository; import com.devpick.domain.user.entity.Job; @@ -27,23 +32,30 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; import java.time.Instant; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; 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.anyLong; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; @@ -67,6 +79,8 @@ class AiQuizServiceTest { @Mock private ValueOperations valueOps; @Mock private PointService pointService; @Mock private QuizAttemptRepository quizAttemptRepository; + @Mock private QuizAttemptAnswerRepository quizAttemptAnswerRepository; + @Mock private PointLogRepository pointLogRepository; private UUID userId; private UUID contentId; @@ -92,10 +106,12 @@ void setUp() throws JsonProcessingException { .canonicalUrl("https://velog.io/@test/spring") .originalContent("Spring Framework 본문 내용입니다.") .build(); + ReflectionTestUtils.setField(content, "id", contentId); user = User.builder() .email("test@devpick.kr").nickname("tester") .job(Job.BACKEND).level(Level.JUNIOR).build(); + ReflectionTestUtils.setField(user, "id", userId); AiQuizDocument.Option opt1 = AiQuizDocument.Option.builder().id("opt-1").text("선택지1").build(); AiQuizDocument.Option opt2 = AiQuizDocument.Option.builder().id("opt-2").text("선택지2").build(); @@ -282,7 +298,7 @@ void getQuiz_aiServerError_throwsException() { @Test @DisplayName("submitQuiz — 통과 시 attempt 저장 + 히스토리 기록 + 포인트 적립") void submitQuiz_passed_savesAttemptAndEarnsPoints() { - QuizSubmitRequest request = new QuizSubmitRequest(level, 4, 5, true); + QuizSubmitRequest request = new QuizSubmitRequest(level, 4, 5, true, null); given(contentRepository.findByIdAndIsAvailableTrue(contentId)).willReturn(Optional.of(content)); given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); given(pointService.earn(eq(user), eq(PointAction.AI_QUIZ_PASS), eq(contentId))).willReturn(true); @@ -300,7 +316,7 @@ void submitQuiz_passed_savesAttemptAndEarnsPoints() { @Test @DisplayName("submitQuiz — 실패 시 attempt 저장, 히스토리/포인트 미기록") void submitQuiz_failed_savesAttemptButNoHistoryOrPoints() { - QuizSubmitRequest request = new QuizSubmitRequest(level, 2, 5, false); + QuizSubmitRequest request = new QuizSubmitRequest(level, 2, 5, false, null); given(contentRepository.findByIdAndIsAvailableTrue(contentId)).willReturn(Optional.of(content)); given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); @@ -316,7 +332,7 @@ void submitQuiz_failed_savesAttemptButNoHistoryOrPoints() { @Test @DisplayName("submitQuiz — 중복 통과 시 pointsEarned=0 반환, attempt는 저장") void submitQuiz_duplicatePass_pointsEarnedZero() { - QuizSubmitRequest request = new QuizSubmitRequest(level, 5, 5, true); + QuizSubmitRequest request = new QuizSubmitRequest(level, 5, 5, true, null); given(contentRepository.findByIdAndIsAvailableTrue(contentId)).willReturn(Optional.of(content)); given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); given(pointService.earn(eq(user), eq(PointAction.AI_QUIZ_PASS), eq(contentId))).willReturn(false); @@ -331,7 +347,7 @@ void submitQuiz_duplicatePass_pointsEarnedZero() { @Test @DisplayName("submitQuiz — 콘텐츠 없으면 CONTENT_NOT_FOUND 예외") void submitQuiz_contentNotFound_throwsException() { - QuizSubmitRequest request = new QuizSubmitRequest(level, 3, 5, true); + QuizSubmitRequest request = new QuizSubmitRequest(level, 3, 5, true, null); given(contentRepository.findByIdAndIsAvailableTrue(contentId)).willReturn(Optional.empty()); assertThatThrownBy(() -> aiQuizService.submitQuiz(userId, contentId, request)) @@ -385,4 +401,185 @@ void getQuiz_responseContainsTypeField() throws JsonProcessingException { assertThat(response.questions()).hasSize(1); assertThat(response.questions().get(0).type()).isEqualTo("multiple_choice"); } + + @Test + @DisplayName("submitQuiz — answers 포함 시 quiz_attempt_answers에 일괄 저장") + void submitQuiz_withAnswers_savesAnswers() { + List answers = List.of( + new QuizSubmitRequest.AnswerItem("q-1", "opt-1", null, true), + new QuizSubmitRequest.AnswerItem("q-2", "opt-2", null, false) + ); + QuizSubmitRequest request = new QuizSubmitRequest(level, 1, 2, false, answers); + QuizAttempt savedAttempt = QuizAttempt.builder() + .user(user).content(content).level(level).score(1).totalQuestions(2).passed(false).build(); + + given(contentRepository.findByIdAndIsAvailableTrue(contentId)).willReturn(Optional.of(content)); + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); + given(quizAttemptRepository.save(any())).willReturn(savedAttempt); + + aiQuizService.submitQuiz(userId, contentId, request); + + verify(quizAttemptAnswerRepository).saveAll(any()); + } + + @Test + @DisplayName("getQuizHistory — 이력 없는 유저 → 빈 리스트 반환") + void getQuizHistory_empty_returnsEmptyList() { + given(quizAttemptRepository.findHistoryByUserId(eq(userId), any())).willReturn(Page.empty()); + + QuizHistoryListResponse result = aiQuizService.getQuizHistory(userId, "newest", Pageable.ofSize(10)); + + assertThat(result.content()).isEmpty(); + assertThat(result.totalElements()).isZero(); + } + + @Test + @DisplayName("getQuizHistory — 이력 있는 유저 → 항목 반환") + void getQuizHistory_withAttempts_returnsItems() { + QuizAttempt attempt = QuizAttempt.builder() + .user(user).content(content).level(aiLevel).score(1).totalQuestions(3).passed(false).build(); + ReflectionTestUtils.setField(attempt, "id", UUID.randomUUID()); + ReflectionTestUtils.setField(attempt, "createdAt", LocalDateTime.now()); + + given(quizAttemptRepository.findHistoryByUserId(eq(userId), any())) + .willReturn(new PageImpl<>(List.of(attempt))); + given(aiQuizRepository.batchFindFirstQuestions(anyList())).willReturn(Map.of()); + + QuizHistoryListResponse result = aiQuizService.getQuizHistory(userId, "newest", Pageable.ofSize(10)); + + assertThat(result.content()).hasSize(1); + assertThat(result.content().getFirst().contentId()).isEqualTo(contentId); + assertThat(result.content().getFirst().level()).isEqualTo("JUNIOR"); + } + + @Test + @DisplayName("getQuizHistory — sort=oldest → createdAt ASC 정렬 적용") + void getQuizHistory_oldestSort_appliesAscSort() { + given(quizAttemptRepository.findHistoryByUserId(eq(userId), any())).willReturn(Page.empty()); + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + + aiQuizService.getQuizHistory(userId, "oldest", Pageable.ofSize(10)); + + verify(quizAttemptRepository).findHistoryByUserId(eq(userId), pageableCaptor.capture()); + Sort.Order order = pageableCaptor.getValue().getSort().getOrderFor("createdAt"); + assertThat(order).isNotNull(); + assertThat(order.getDirection()).isEqualTo(Sort.Direction.ASC); + } + + @Test + @DisplayName("getQuizHistory — DynamoDB에서 첫 번째 문제 텍스트 조회 시 preview에 반영") + void getQuizHistory_withPreview_returnsFirstQuestion() { + QuizAttempt attempt = QuizAttempt.builder() + .user(user).content(content).level(aiLevel).score(1).totalQuestions(3).passed(false).build(); + ReflectionTestUtils.setField(attempt, "id", UUID.randomUUID()); + ReflectionTestUtils.setField(attempt, "createdAt", LocalDateTime.now()); + + given(quizAttemptRepository.findHistoryByUserId(eq(userId), any())) + .willReturn(new PageImpl<>(List.of(attempt))); + given(aiQuizRepository.batchFindFirstQuestions(anyList())) + .willReturn(Map.of(contentId + "|" + aiLevel, "Spring의 DI란 무엇인가요?")); + + QuizHistoryListResponse result = aiQuizService.getQuizHistory(userId, "newest", Pageable.ofSize(10)); + + assertThat(result.content().getFirst().preview()).isEqualTo("Spring의 DI란 무엇인가요?"); + } + + @Test + @DisplayName("getQuizHistory — preview가 blank이면 null 반환") + void getQuizHistory_blankPreview_returnsNull() { + QuizAttempt attempt = QuizAttempt.builder() + .user(user).content(content).level(aiLevel).score(1).totalQuestions(3).passed(false).build(); + ReflectionTestUtils.setField(attempt, "id", UUID.randomUUID()); + ReflectionTestUtils.setField(attempt, "createdAt", LocalDateTime.now()); + + given(quizAttemptRepository.findHistoryByUserId(eq(userId), any())) + .willReturn(new PageImpl<>(List.of(attempt))); + given(aiQuizRepository.batchFindFirstQuestions(anyList())) + .willReturn(Map.of(contentId + "|" + aiLevel, " ")); + + QuizHistoryListResponse result = aiQuizService.getQuizHistory(userId, "newest", Pageable.ofSize(10)); + + assertThat(result.content().getFirst().preview()).isNull(); + } + + @Test + @DisplayName("getQuizHistory — DynamoDB 예외 시 preview null로 fallback") + void getQuizHistory_dynamoDbException_previewFallback() { + QuizAttempt attempt = QuizAttempt.builder() + .user(user).content(content).level(aiLevel).score(1).totalQuestions(3).passed(false).build(); + ReflectionTestUtils.setField(attempt, "id", UUID.randomUUID()); + ReflectionTestUtils.setField(attempt, "createdAt", LocalDateTime.now()); + + given(quizAttemptRepository.findHistoryByUserId(eq(userId), any())) + .willReturn(new PageImpl<>(List.of(attempt))); + given(aiQuizRepository.batchFindFirstQuestions(anyList())) + .willThrow(new RuntimeException("DynamoDB 연결 실패")); + + QuizHistoryListResponse result = aiQuizService.getQuizHistory(userId, "newest", Pageable.ofSize(10)); + + assertThat(result.content()).hasSize(1); + assertThat(result.content().getFirst().preview()).isNull(); + } + + @Test + @DisplayName("getQuizResult — 타인의 attempt 조회 시도 → QUIZ_ATTEMPT_FORBIDDEN 예외") + void getQuizResult_otherUsersAttempt_throwsForbidden() { + UUID otherUserId = UUID.randomUUID(); + User otherUser = User.builder() + .email("other@devpick.kr").nickname("other").job(Job.BACKEND).level(Level.JUNIOR).build(); + ReflectionTestUtils.setField(otherUser, "id", otherUserId); + + UUID attemptId = UUID.randomUUID(); + QuizAttempt attempt = QuizAttempt.builder() + .user(otherUser).content(content).level(aiLevel).score(2).totalQuestions(3).passed(false).build(); + ReflectionTestUtils.setField(attempt, "id", attemptId); + + given(quizAttemptRepository.findById(attemptId)).willReturn(Optional.of(attempt)); + + assertThatThrownBy(() -> aiQuizService.getQuizResult(userId, attemptId)) + .isInstanceOf(DevpickException.class) + .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) + .isEqualTo(ErrorCode.QUIZ_ATTEMPT_FORBIDDEN)); + } + + @Test + @DisplayName("getQuizResult — 존재하지 않는 attempt → QUIZ_ATTEMPT_NOT_FOUND 예외") + void getQuizResult_notFound_throwsException() { + UUID attemptId = UUID.randomUUID(); + given(quizAttemptRepository.findById(attemptId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> aiQuizService.getQuizResult(userId, attemptId)) + .isInstanceOf(DevpickException.class) + .satisfies(e -> assertThat(((DevpickException) e).getErrorCode()) + .isEqualTo(ErrorCode.QUIZ_ATTEMPT_NOT_FOUND)); + } + + @Test + @DisplayName("getQuizResult — 정상 조회 시 quiz + myAnswers 포함") + void getQuizResult_success_returnsQuizAndMyAnswers() { + UUID attemptId = UUID.randomUUID(); + QuizAttempt attempt = QuizAttempt.builder() + .user(user).content(content).level(aiLevel).score(2).totalQuestions(3).passed(false).build(); + ReflectionTestUtils.setField(attempt, "id", attemptId); + ReflectionTestUtils.setField(attempt, "createdAt", LocalDateTime.now()); + + QuizAttemptAnswer answer = QuizAttemptAnswer.builder() + .attempt(attempt).questionId("q-1").selectedOptionId("opt-1").correct(true).build(); + + given(quizAttemptRepository.findById(attemptId)).willReturn(Optional.of(attempt)); + given(aiQuizRepository.findByContentIdAndLevel(contentId.toString(), aiLevel)) + .willReturn(Optional.of(document)); + given(quizAttemptAnswerRepository.findByAttempt_Id(attemptId)).willReturn(List.of(answer)); + given(pointLogRepository.sumPointsByUser_IdAndActionAndReferenceId(userId, PointAction.AI_QUIZ_PASS, contentId)) + .willReturn(0); + + QuizResultResponse result = aiQuizService.getQuizResult(userId, attemptId); + + assertThat(result.attemptId()).isEqualTo(attemptId); + assertThat(result.quiz()).isNotNull(); + assertThat(result.quiz().questions()).hasSize(1); + assertThat(result.myAnswers()).hasSize(1); + assertThat(result.myAnswers().getFirst().questionId()).isEqualTo("q-1"); + assertThat(result.myAnswers().getFirst().isCorrect()).isTrue(); + } }