diff --git a/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java b/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java index 505b90cd..8cd77af0 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java +++ b/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java @@ -90,7 +90,9 @@ public void gameStart(Long roomId, UserPrincipal principal) { timerService.startTimer(room, START_DELAY); messageSender.sendBroadcast( - destination, MessageType.GAME_START, toGameStartResponse(questions)); + destination, + MessageType.GAME_START, + toGameStartResponse(quiz.getQuizType(), questions)); messageSender.sendBroadcast( destination, MessageType.RANK_UPDATE, toRankUpdateResponse(room)); messageSender.sendBroadcast( diff --git a/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java b/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java index 239851d1..25157541 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java +++ b/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java @@ -310,6 +310,9 @@ public void reconnectSendResponse(Long roomId, UserPrincipal principal) { String destination = getDestination(roomId); String userDestination = getUserDestination(); + Long quizId = room.getQuizId(); + Quiz quiz = quizService.findQuizById(quizId); + messageSender.sendBroadcast( destination, MessageType.SYSTEM_NOTICE, @@ -330,14 +333,11 @@ public void reconnectSendResponse(Long roomId, UserPrincipal principal) { messageSender.sendPersonal( userDestination, MessageType.GAME_START, - toGameStartResponse(room.getQuestions()), + toGameStartResponse(quiz.getQuizType(), room.getQuestions()), principal.getName()); } else { RoomSettingResponse roomSettingResponse = toRoomSettingResponse(room); - Long quizId = room.getGameSetting().getQuizId(); - - Quiz quiz = quizService.findQuizById(quizId); Long questionsCount = quizService.getQuestionsCount(quizId); GameSettingResponse gameSettingResponse = diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/response/GameStartResponse.java b/backend/src/main/java/io/f1/backend/domain/game/dto/response/GameStartResponse.java index 9545a8ff..f507d8ad 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/dto/response/GameStartResponse.java +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/response/GameStartResponse.java @@ -1,7 +1,8 @@ package io.f1.backend.domain.game.dto.response; import io.f1.backend.domain.quiz.dto.GameQuestionResponse; +import io.f1.backend.domain.quiz.entity.QuizType; import java.util.List; -public record GameStartResponse(List questions) {} +public record GameStartResponse(QuizType quizType, List questions) {} diff --git a/backend/src/main/java/io/f1/backend/domain/question/api/QuestionController.java b/backend/src/main/java/io/f1/backend/domain/question/api/QuestionController.java index 8f361202..719fba2f 100644 --- a/backend/src/main/java/io/f1/backend/domain/question/api/QuestionController.java +++ b/backend/src/main/java/io/f1/backend/domain/question/api/QuestionController.java @@ -1,26 +1,11 @@ package io.f1.backend.domain.question.api; -import io.f1.backend.domain.question.app.QuestionService; - import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/questions") @RequiredArgsConstructor -public class QuestionController { - - private final QuestionService questionService; - - @DeleteMapping("/{questionId}") - public ResponseEntity deleteQuestion(@PathVariable Long questionId) { - questionService.deleteQuestion(questionId); - - return ResponseEntity.noContent().build(); - } -} +public class QuestionController {} diff --git a/backend/src/main/java/io/f1/backend/domain/question/app/QuestionService.java b/backend/src/main/java/io/f1/backend/domain/question/app/QuestionService.java index 45862ace..e9076ddb 100644 --- a/backend/src/main/java/io/f1/backend/domain/question/app/QuestionService.java +++ b/backend/src/main/java/io/f1/backend/domain/question/app/QuestionService.java @@ -1,72 +1,79 @@ package io.f1.backend.domain.question.app; -import static io.f1.backend.domain.question.mapper.QuestionMapper.questionRequestToQuestion; -import static io.f1.backend.domain.question.mapper.TextQuestionMapper.questionRequestToTextQuestion; -import static io.f1.backend.domain.quiz.app.QuizService.verifyUserAuthority; - +import io.f1.backend.domain.question.dao.ContentQuestionRepository; import io.f1.backend.domain.question.dao.QuestionRepository; -import io.f1.backend.domain.question.dao.TextQuestionRepository; -import io.f1.backend.domain.question.dto.QuestionRequest; -import io.f1.backend.domain.question.dto.QuestionUpdateRequest; +import io.f1.backend.domain.question.dto.ContentQuestionRequest; +import io.f1.backend.domain.question.dto.ContentQuestionUpdateRequest; +import io.f1.backend.domain.question.entity.ContentQuestion; import io.f1.backend.domain.question.entity.Question; -import io.f1.backend.domain.question.entity.TextQuestion; import io.f1.backend.domain.quiz.entity.Quiz; +import io.f1.backend.domain.quiz.entity.QuizType; import io.f1.backend.global.exception.CustomException; import io.f1.backend.global.exception.errorcode.QuestionErrorCode; +import io.f1.backend.global.util.FileManager; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class QuestionService { private final QuestionRepository questionRepository; - private final TextQuestionRepository textQuestionRepository; - - @Transactional - public void saveQuestion(Quiz quiz, QuestionRequest request) { + private final ContentQuestionRepository contentQuestionRepository; - Question question = questionRequestToQuestion(quiz, request); + public void saveContentQuestion(Quiz quiz, ContentQuestionRequest request) { + Question question = new Question(quiz, request.getAnswer()); quiz.addQuestion(question); questionRepository.save(question); - TextQuestion textQuestion = questionRequestToTextQuestion(question, request.getContent()); - textQuestionRepository.save(textQuestion); - question.addTextQuestion(textQuestion); + ContentQuestion contentQuestion = request.toContentQuestion(question); + contentQuestionRepository.save(contentQuestion); + question.addContentQuestion(contentQuestion); } - public void updateQuestions(Quiz quiz, QuestionUpdateRequest request) { - + public void updateContentQuestions(Quiz quiz, ContentQuestionUpdateRequest request) { if (request.getId() == null) { - saveQuestion(quiz, QuestionRequest.of(request)); + saveContentQuestion( + quiz, ContentQuestionRequest.of(request.getContent(), request.getAnswer())); + return; } - Question question = - questionRepository - .findById(request.getId()) - .orElseThrow( - () -> new CustomException(QuestionErrorCode.QUESTION_NOT_FOUND)); + Question question = getQuestionWithContent(request.getId()); + + if (request.getContent() != null) { + ContentQuestion contentQuestion = question.getContentQuestion(); + contentQuestion.changeContent(request.getContent()); + } - TextQuestion textQuestion = question.getTextQuestion(); - textQuestion.changeContent(request.getContent()); question.changeAnswer(request.getAnswer()); } - @Transactional - public void deleteQuestion(Long questionId) { + public void deleteQuestion(Long questionId, QuizType quizType) { + Question question; - Question question = - questionRepository - .findById(questionId) - .orElseThrow( - () -> new CustomException(QuestionErrorCode.QUESTION_NOT_FOUND)); - - verifyUserAuthority(question.getQuiz()); + if (quizType.name().equals("IMAGE")) { + question = getQuestionWithContent(questionId); + String filePath = question.getContentQuestion().getContent(); + FileManager.deleteFile(filePath); + } else { + question = getQuestion(questionId); + } questionRepository.delete(question); } + + private Question getQuestion(Long questionId) { + return questionRepository + .findById(questionId) + .orElseThrow(() -> new CustomException(QuestionErrorCode.QUESTION_NOT_FOUND)); + } + + private Question getQuestionWithContent(Long questionId) { + return questionRepository + .findByIdWithContent(questionId) + .orElseThrow(() -> new CustomException(QuestionErrorCode.QUESTION_NOT_FOUND)); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/question/dao/ContentQuestionRepository.java b/backend/src/main/java/io/f1/backend/domain/question/dao/ContentQuestionRepository.java new file mode 100644 index 00000000..d4af9570 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/question/dao/ContentQuestionRepository.java @@ -0,0 +1,7 @@ +package io.f1.backend.domain.question.dao; + +import io.f1.backend.domain.question.entity.ContentQuestion; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ContentQuestionRepository extends JpaRepository {} diff --git a/backend/src/main/java/io/f1/backend/domain/question/dao/QuestionRepository.java b/backend/src/main/java/io/f1/backend/domain/question/dao/QuestionRepository.java index 7ff72081..fb40c5cc 100644 --- a/backend/src/main/java/io/f1/backend/domain/question/dao/QuestionRepository.java +++ b/backend/src/main/java/io/f1/backend/domain/question/dao/QuestionRepository.java @@ -3,5 +3,12 @@ import io.f1.backend.domain.question.entity.Question; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; -public interface QuestionRepository extends JpaRepository {} +import java.util.Optional; + +public interface QuestionRepository extends JpaRepository { + + @Query("SELECT q FROM Question q JOIN FETCH q.contentQuestion WHERE q.id = :id") + Optional findByIdWithContent(Long id); +} diff --git a/backend/src/main/java/io/f1/backend/domain/question/dao/TextQuestionRepository.java b/backend/src/main/java/io/f1/backend/domain/question/dao/TextQuestionRepository.java deleted file mode 100644 index 39812de0..00000000 --- a/backend/src/main/java/io/f1/backend/domain/question/dao/TextQuestionRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package io.f1.backend.domain.question.dao; - -import io.f1.backend.domain.question.entity.TextQuestion; - -import org.springframework.data.jpa.repository.JpaRepository; - -public interface TextQuestionRepository extends JpaRepository {} diff --git a/backend/src/main/java/io/f1/backend/domain/question/dto/ContentQuestionRequest.java b/backend/src/main/java/io/f1/backend/domain/question/dto/ContentQuestionRequest.java new file mode 100644 index 00000000..534ca877 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/question/dto/ContentQuestionRequest.java @@ -0,0 +1,28 @@ +package io.f1.backend.domain.question.dto; + +import io.f1.backend.domain.question.entity.ContentQuestion; +import io.f1.backend.domain.question.entity.Question; +import io.f1.backend.domain.quiz.entity.Quiz; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ContentQuestionRequest { + private String content; + private String answer; + + public static ContentQuestionRequest of(String content, String answer) { + return new ContentQuestionRequest(content, answer); + } + + public ContentQuestion toContentQuestion(Question question) { + return new ContentQuestion(question, content); + } + + public Question toQuestion(Quiz quiz) { + return new Question(quiz, answer); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/question/dto/ContentQuestionUpdateRequest.java b/backend/src/main/java/io/f1/backend/domain/question/dto/ContentQuestionUpdateRequest.java new file mode 100644 index 00000000..7d649a4c --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/question/dto/ContentQuestionUpdateRequest.java @@ -0,0 +1,17 @@ +package io.f1.backend.domain.question.dto; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ContentQuestionUpdateRequest { + private Long id; + private String content; + private String answer; + + public static ContentQuestionUpdateRequest of(Long id, String content, String answer) { + return new ContentQuestionUpdateRequest(id, content, answer); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/question/dto/ImageQuestionRequest.java b/backend/src/main/java/io/f1/backend/domain/question/dto/ImageQuestionRequest.java new file mode 100644 index 00000000..7cde254c --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/question/dto/ImageQuestionRequest.java @@ -0,0 +1,8 @@ +package io.f1.backend.domain.question.dto; + +import io.f1.backend.global.validation.TrimmedSize; + +import jakarta.validation.constraints.NotBlank; + +public record ImageQuestionRequest( + @TrimmedSize(min = 1, max = 30) @NotBlank(message = "정답을 입력해주세요.") String answer) {} diff --git a/backend/src/main/java/io/f1/backend/domain/question/dto/ImageQuestionUpdateRequest.java b/backend/src/main/java/io/f1/backend/domain/question/dto/ImageQuestionUpdateRequest.java new file mode 100644 index 00000000..01e5edfc --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/question/dto/ImageQuestionUpdateRequest.java @@ -0,0 +1,22 @@ +package io.f1.backend.domain.question.dto; + +import io.f1.backend.global.validation.TrimmedSize; + +import jakarta.validation.constraints.NotBlank; + +import lombok.Getter; + +@Getter +public class ImageQuestionUpdateRequest { + private Long id; + + private boolean imageFile; + + @TrimmedSize(min = 1, max = 30) + @NotBlank(message = "정답을 입력해주세요.") + private String answer; + + public boolean hasImageFile() { + return imageFile; + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionDeleteRequest.java b/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionDeleteRequest.java new file mode 100644 index 00000000..80734a7c --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionDeleteRequest.java @@ -0,0 +1,5 @@ +package io.f1.backend.domain.question.dto; + +import java.util.List; + +public record QuestionDeleteRequest(List questionIds) {} diff --git a/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionRequest.java b/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionRequest.java deleted file mode 100644 index 60274656..00000000 --- a/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionRequest.java +++ /dev/null @@ -1,30 +0,0 @@ -package io.f1.backend.domain.question.dto; - -import io.f1.backend.global.validation.TrimmedSize; - -import jakarta.validation.constraints.NotBlank; - -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class QuestionRequest { - - @TrimmedSize(min = 5, max = 30) - @NotBlank(message = "문제를 입력해주세요.") - private String content; - - @TrimmedSize(min = 1, max = 30) - @NotBlank(message = "정답을 입력해주세요.") - private String answer; - - public static QuestionRequest of(QuestionUpdateRequest request) { - QuestionRequest questionRequest = new QuestionRequest(); - questionRequest.content = request.getContent(); - questionRequest.answer = request.getAnswer(); - - return questionRequest; - } -} diff --git a/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionUpdateRequest.java b/backend/src/main/java/io/f1/backend/domain/question/dto/TextQuestionRequest.java similarity index 85% rename from backend/src/main/java/io/f1/backend/domain/question/dto/QuestionUpdateRequest.java rename to backend/src/main/java/io/f1/backend/domain/question/dto/TextQuestionRequest.java index 7cff28d2..9e6bdd83 100644 --- a/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionUpdateRequest.java +++ b/backend/src/main/java/io/f1/backend/domain/question/dto/TextQuestionRequest.java @@ -5,14 +5,14 @@ import jakarta.validation.constraints.NotBlank; import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @Getter +@AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class QuestionUpdateRequest { - - private Long id; +public class TextQuestionRequest { @TrimmedSize(min = 5, max = 30) @NotBlank(message = "문제를 입력해주세요.") diff --git a/backend/src/main/java/io/f1/backend/domain/question/dto/TextQuestionUpdateRequest.java b/backend/src/main/java/io/f1/backend/domain/question/dto/TextQuestionUpdateRequest.java new file mode 100644 index 00000000..12d83aac --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/question/dto/TextQuestionUpdateRequest.java @@ -0,0 +1,23 @@ +package io.f1.backend.domain.question.dto; + +import io.f1.backend.global.validation.TrimmedSize; + +import jakarta.validation.constraints.NotBlank; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class TextQuestionUpdateRequest { + + private Long id; + + @TrimmedSize(min = 5, max = 30) + @NotBlank(message = "문제를 입력해주세요.") + private String content; + + @TrimmedSize(min = 1, max = 30) + @NotBlank(message = "정답을 입력해주세요.") + private String answer; +} diff --git a/backend/src/main/java/io/f1/backend/domain/question/entity/TextQuestion.java b/backend/src/main/java/io/f1/backend/domain/question/entity/ContentQuestion.java similarity index 90% rename from backend/src/main/java/io/f1/backend/domain/question/entity/TextQuestion.java rename to backend/src/main/java/io/f1/backend/domain/question/entity/ContentQuestion.java index b10be109..b6fc8b6d 100644 --- a/backend/src/main/java/io/f1/backend/domain/question/entity/TextQuestion.java +++ b/backend/src/main/java/io/f1/backend/domain/question/entity/ContentQuestion.java @@ -15,7 +15,7 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class TextQuestion { +public class ContentQuestion { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -28,7 +28,7 @@ public class TextQuestion { @Column(nullable = false) private String content; - public TextQuestion(Question question, String content) { + public ContentQuestion(Question question, String content) { this.question = question; this.content = content; } diff --git a/backend/src/main/java/io/f1/backend/domain/question/entity/Question.java b/backend/src/main/java/io/f1/backend/domain/question/entity/Question.java index fe6d0783..885c7572 100644 --- a/backend/src/main/java/io/f1/backend/domain/question/entity/Question.java +++ b/backend/src/main/java/io/f1/backend/domain/question/entity/Question.java @@ -35,15 +35,15 @@ public class Question extends BaseEntity { private String answer; @OneToOne(mappedBy = "question", cascade = CascadeType.REMOVE) - private TextQuestion textQuestion; + private ContentQuestion contentQuestion; public Question(Quiz quiz, String answer) { this.quiz = quiz; this.answer = answer; } - public void addTextQuestion(TextQuestion textQuestion) { - this.textQuestion = textQuestion; + public void addContentQuestion(ContentQuestion contentQuestion) { + this.contentQuestion = contentQuestion; } public void changeAnswer(String answer) { diff --git a/backend/src/main/java/io/f1/backend/domain/question/mapper/ContentQuestionMapper.java b/backend/src/main/java/io/f1/backend/domain/question/mapper/ContentQuestionMapper.java new file mode 100644 index 00000000..cb5d0781 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/question/mapper/ContentQuestionMapper.java @@ -0,0 +1,12 @@ +package io.f1.backend.domain.question.mapper; + +import io.f1.backend.domain.question.entity.ContentQuestion; +import io.f1.backend.domain.question.entity.Question; + +public class ContentQuestionMapper { + + public static ContentQuestion questionRequestToContentQuestion( + Question question, String content) { + return new ContentQuestion(question, content); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/question/mapper/QuestionMapper.java b/backend/src/main/java/io/f1/backend/domain/question/mapper/QuestionMapper.java index 4228fe8f..3bbe9a30 100644 --- a/backend/src/main/java/io/f1/backend/domain/question/mapper/QuestionMapper.java +++ b/backend/src/main/java/io/f1/backend/domain/question/mapper/QuestionMapper.java @@ -1,12 +1,13 @@ package io.f1.backend.domain.question.mapper; -import io.f1.backend.domain.question.dto.QuestionRequest; +import io.f1.backend.domain.question.dto.ContentQuestionRequest; import io.f1.backend.domain.question.entity.Question; import io.f1.backend.domain.quiz.entity.Quiz; public class QuestionMapper { - public static Question questionRequestToQuestion(Quiz quiz, QuestionRequest questionRequest) { + public static Question questionRequestToQuestion( + Quiz quiz, ContentQuestionRequest questionRequest) { return new Question(quiz, questionRequest.getAnswer()); } } diff --git a/backend/src/main/java/io/f1/backend/domain/question/mapper/TextQuestionMapper.java b/backend/src/main/java/io/f1/backend/domain/question/mapper/TextQuestionMapper.java deleted file mode 100644 index 97e42cab..00000000 --- a/backend/src/main/java/io/f1/backend/domain/question/mapper/TextQuestionMapper.java +++ /dev/null @@ -1,11 +0,0 @@ -package io.f1.backend.domain.question.mapper; - -import io.f1.backend.domain.question.entity.Question; -import io.f1.backend.domain.question.entity.TextQuestion; - -public class TextQuestionMapper { - - public static TextQuestion questionRequestToTextQuestion(Question question, String content) { - return new TextQuestion(question, content); - } -} diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/api/QuizController.java b/backend/src/main/java/io/f1/backend/domain/quiz/api/QuizController.java index 55a62f7d..959d6f58 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/api/QuizController.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/api/QuizController.java @@ -1,11 +1,14 @@ package io.f1.backend.domain.quiz.api; +import io.f1.backend.domain.question.dto.QuestionDeleteRequest; import io.f1.backend.domain.quiz.app.QuizService; -import io.f1.backend.domain.quiz.dto.QuizCreateRequest; +import io.f1.backend.domain.quiz.dto.ImageQuizCreateRequest; +import io.f1.backend.domain.quiz.dto.ImageQuizUpdateRequest; import io.f1.backend.domain.quiz.dto.QuizCreateResponse; import io.f1.backend.domain.quiz.dto.QuizListPageResponse; import io.f1.backend.domain.quiz.dto.QuizQuestionListResponse; -import io.f1.backend.domain.quiz.dto.QuizUpdateRequest; +import io.f1.backend.domain.quiz.dto.TextQuizCreateRequest; +import io.f1.backend.domain.quiz.dto.TextQuizUpdateRequest; import io.f1.backend.global.exception.CustomException; import io.f1.backend.global.exception.errorcode.CommonErrorCode; @@ -24,12 +27,15 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; +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.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import java.util.List; + @RestController @RequestMapping("/quizzes") @RequiredArgsConstructor @@ -37,11 +43,24 @@ public class QuizController { private final QuizService quizService; - @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PostMapping(value = "/text", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity saveQuiz( @RequestPart(required = false) MultipartFile thumbnailFile, - @Valid @RequestPart QuizCreateRequest request) { - QuizCreateResponse response = quizService.saveQuiz(thumbnailFile, request); + @Valid @RequestPart TextQuizCreateRequest request) { + + QuizCreateResponse response = quizService.saveTextQuiz(thumbnailFile, request); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PostMapping(value = "/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity saveImageQuiz( + @RequestPart(required = false) MultipartFile thumbnailFile, + @RequestPart(required = false) List questionImageFiles, + @Valid @RequestPart ImageQuizCreateRequest request) { + + QuizCreateResponse response = + quizService.saveImageQuiz(thumbnailFile, request, questionImageFiles); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @@ -53,17 +72,33 @@ public ResponseEntity deleteQuiz(@PathVariable Long quizId) { return ResponseEntity.noContent().build(); } - @PutMapping("/{quizId}") - public ResponseEntity updateQuiz( + @DeleteMapping("/{quizId}/questions") + public ResponseEntity deleteQuestions( + @PathVariable Long quizId, @RequestBody QuestionDeleteRequest request) { + + quizService.deleteQuestions(quizId, request); + return ResponseEntity.noContent().build(); + } + + @PutMapping("/text/{quizId}") + public ResponseEntity updateTextQuiz( @PathVariable Long quizId, @RequestPart(required = false) MultipartFile thumbnailFile, - @Valid @RequestPart QuizUpdateRequest request) { + @Valid @RequestPart TextQuizUpdateRequest request) { - quizService.updateQuizAndQuestions(quizId, request); + quizService.updateTextQuiz(quizId, request, thumbnailFile); - if (thumbnailFile != null && !thumbnailFile.isEmpty()) { - quizService.updateThumbnail(quizId, thumbnailFile); - } + return ResponseEntity.noContent().build(); + } + + @PutMapping("/image/{quizId}") + public ResponseEntity updateImageQuiz( + @PathVariable Long quizId, + @RequestPart(required = false) MultipartFile thumbnailFile, + @RequestPart(required = false) List questionImageFiles, + @Valid @RequestPart ImageQuizUpdateRequest request) { + + quizService.updateImageQuiz(quizId, request, thumbnailFile, questionImageFiles); return ResponseEntity.noContent().build(); } diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java b/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java index fe5ef37a..3f74f5ae 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java @@ -2,13 +2,18 @@ import static io.f1.backend.domain.quiz.mapper.QuizMapper.*; -import static java.nio.file.Files.deleteIfExists; - import io.f1.backend.domain.question.app.QuestionService; -import io.f1.backend.domain.question.dto.QuestionRequest; -import io.f1.backend.domain.question.dto.QuestionUpdateRequest; +import io.f1.backend.domain.question.dto.ContentQuestionRequest; +import io.f1.backend.domain.question.dto.ContentQuestionUpdateRequest; +import io.f1.backend.domain.question.dto.ImageQuestionRequest; +import io.f1.backend.domain.question.dto.ImageQuestionUpdateRequest; +import io.f1.backend.domain.question.dto.QuestionDeleteRequest; +import io.f1.backend.domain.question.dto.TextQuestionRequest; +import io.f1.backend.domain.question.dto.TextQuestionUpdateRequest; import io.f1.backend.domain.question.entity.Question; import io.f1.backend.domain.quiz.dao.QuizRepository; +import io.f1.backend.domain.quiz.dto.ImageQuizCreateRequest; +import io.f1.backend.domain.quiz.dto.ImageQuizUpdateRequest; import io.f1.backend.domain.quiz.dto.QuizCreateRequest; import io.f1.backend.domain.quiz.dto.QuizCreateResponse; import io.f1.backend.domain.quiz.dto.QuizListPageResponse; @@ -16,15 +21,19 @@ import io.f1.backend.domain.quiz.dto.QuizMinData; import io.f1.backend.domain.quiz.dto.QuizQuestionListResponse; import io.f1.backend.domain.quiz.dto.QuizUpdateRequest; +import io.f1.backend.domain.quiz.dto.TextQuizCreateRequest; +import io.f1.backend.domain.quiz.dto.TextQuizUpdateRequest; import io.f1.backend.domain.quiz.entity.Quiz; import io.f1.backend.domain.user.dao.UserRepository; import io.f1.backend.domain.user.entity.User; import io.f1.backend.global.exception.CustomException; import io.f1.backend.global.exception.errorcode.AuthErrorCode; +import io.f1.backend.global.exception.errorcode.QuestionErrorCode; import io.f1.backend.global.exception.errorcode.QuizErrorCode; import io.f1.backend.global.exception.errorcode.UserErrorCode; import io.f1.backend.global.security.enums.Role; import io.f1.backend.global.security.util.SecurityUtils; +import io.f1.backend.global.util.FileManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -37,13 +46,8 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.List; -import java.util.NoSuchElementException; -import java.util.Objects; -import java.util.UUID; +import java.util.*; +import java.util.ArrayList; @Slf4j @Service @@ -51,93 +55,131 @@ public class QuizService { @Value("${file.thumbnail-path}") - private String uploadPath; + private String thumbnailPath; - @Value("${file.default-thumbnail-url}") - private String defaultThumbnailPath; + @Value("${file.question-path}") + private String questionPath; - private final String DEFAULT = "default"; - private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + @Value("${file.default-thumbnail-file}") + private String defaultThumbnailFile; private final UserRepository userRepository; private final QuestionService questionService; private final QuizRepository quizRepository; @Transactional - public QuizCreateResponse saveQuiz(MultipartFile thumbnailFile, QuizCreateRequest request) { - String thumbnailPath = defaultThumbnailPath; - - if (thumbnailFile != null && !thumbnailFile.isEmpty()) { - validateImageFile(thumbnailFile); - thumbnailPath = convertToThumbnailPath(thumbnailFile); + public QuizCreateResponse saveTextQuiz( + MultipartFile thumbnailFile, TextQuizCreateRequest request) { + Quiz savedQuiz = saveQuiz(thumbnailFile, request); + + for (TextQuestionRequest qRequest : request.getQuestions()) { + questionService.saveContentQuestion( + savedQuiz, + ContentQuestionRequest.of(qRequest.getContent(), qRequest.getAnswer())); } - Long creatorId = SecurityUtils.getCurrentUserId(); - User creator = - userRepository - .findById(creatorId) - .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + return quizToQuizCreateResponse(savedQuiz); + } + + @Transactional + public QuizCreateResponse saveImageQuiz( + MultipartFile thumbnailFile, + ImageQuizCreateRequest request, + List questionImageFiles) { + Quiz savedQuiz = saveQuiz(thumbnailFile, request); + + validateImageQuestions(request.getQuestions(), questionImageFiles); + + Iterator imageIter = questionImageFiles.iterator(); - Quiz quiz = quizCreateRequestToQuiz(request, thumbnailPath, creator); + for (ImageQuestionRequest qRequest : request.getQuestions()) { + MultipartFile imageFile = imageIter.next(); - Quiz savedQuiz = quizRepository.save(quiz); + if (hasFile(imageFile)) { + validateImageFile(imageFile); + } else { + throw new CustomException(QuestionErrorCode.INVALID_IMAGE_QUESTION_FILE); + } - for (QuestionRequest qRequest : request.getQuestions()) { - questionService.saveQuestion(savedQuiz, qRequest); + String imagePath = FileManager.saveMultipartFile(imageFile, questionPath); + questionService.saveContentQuestion( + savedQuiz, ContentQuestionRequest.of(imagePath, qRequest.answer())); } return quizToQuizCreateResponse(savedQuiz); } - private void validateImageFile(MultipartFile thumbnailFile) { + private Quiz saveQuiz(MultipartFile thumbnailFile, QuizCreateRequest request) { + String savedThumbnailPath = resolveThumbnail(thumbnailFile); + User creator = loadCreator(); + Quiz quiz = quizCreateRequestToQuiz(request, savedThumbnailPath, creator); + + return quizRepository.save(quiz); + } + + private String resolveThumbnail(MultipartFile thumbnailFile) { + String path = thumbnailPath + defaultThumbnailFile; + if (hasFile(thumbnailFile)) { + validateImageFile(thumbnailFile); + path = FileManager.saveMultipartFile(thumbnailFile, thumbnailPath); + } + return path; + } + + private User loadCreator() { + Long creatorId = SecurityUtils.getCurrentUserId(); + return userRepository + .findById(creatorId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + } + + private void validateImageQuestions( + List requestQuestions, List questionImageFiles) { + if (requestQuestions.size() != questionImageFiles.size()) { + throw new CustomException(QuestionErrorCode.INVALID_IMAGE_QUESTION_SIZE); + } + } + private boolean hasFile(MultipartFile file) { + return file != null && !file.isEmpty(); + } + + private void validateImageFile(MultipartFile thumbnailFile) { if (!thumbnailFile.getContentType().startsWith("image")) { throw new CustomException(QuizErrorCode.UNSUPPORTED_MEDIA_TYPE); } List allowedExt = List.of("jpg", "jpeg", "png", "webp"); - String ext = getExtension(thumbnailFile.getOriginalFilename()); + String ext = FileManager.getExtension(thumbnailFile.getOriginalFilename()); if (!allowedExt.contains(ext)) { throw new CustomException(QuizErrorCode.UNSUPPORTED_IMAGE_FORMAT); } - - if (thumbnailFile.getSize() > MAX_FILE_SIZE) { - throw new CustomException(QuizErrorCode.FILE_SIZE_TOO_LARGE); - } } - private String convertToThumbnailPath(MultipartFile thumbnailFile) { - String originalFilename = thumbnailFile.getOriginalFilename(); - String ext = getExtension(originalFilename); - String savedFilename = UUID.randomUUID().toString() + "." + ext; - - try { - Path savePath = Paths.get(uploadPath, savedFilename).toAbsolutePath(); - thumbnailFile.transferTo(savePath.toFile()); - } catch (IOException e) { - log.error("썸네일 업로드 중 IOException 발생", e); - throw new CustomException(QuizErrorCode.THUMBNAIL_SAVE_FAILED); - } + @Transactional + public void deleteQuiz(Long quizId) { + Quiz quiz = findQuizWithQuestions(quizId); - return "/images/thumbnail/" + savedFilename; - } + verifyUserAuthority(quiz); - private String getExtension(String filename) { - return filename.substring(filename.lastIndexOf(".") + 1).toLowerCase(); + deleteImageFile(quiz.getThumbnailUrl()); + + for (Question question : quiz.getQuestions()) { + questionService.deleteQuestion(question.getId(), quiz.getQuizType()); + } + + quizRepository.deleteById(quizId); } @Transactional - public void deleteQuiz(Long quizId) { - - Quiz quiz = - quizRepository - .findById(quizId) - .orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND)); + public void deleteQuestions(Long quizId, QuestionDeleteRequest request) { + Quiz quiz = findQuiz(quizId); verifyUserAuthority(quiz); - deleteThumbnailFile(quiz.getThumbnailUrl()); - quizRepository.deleteById(quizId); + for (Long questionId : request.questionIds()) { + questionService.deleteQuestion(questionId, quiz.getQuizType()); + } } public static void verifyUserAuthority(Quiz quiz) { @@ -150,62 +192,81 @@ public static void verifyUserAuthority(Quiz quiz) { } @Transactional - public void updateQuizAndQuestions(Long quizId, QuizUpdateRequest request) { - Quiz quiz = - quizRepository - .findById(quizId) - .orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND)); + public void updateTextQuiz( + Long quizId, TextQuizUpdateRequest request, MultipartFile thumbnailFile) { + Quiz quiz = updateQuiz(quizId, request, thumbnailFile); + + for (TextQuestionUpdateRequest questionReq : request.getQuestions()) { + questionService.updateContentQuestions( + quiz, + ContentQuestionUpdateRequest.of( + questionReq.getId(), + questionReq.getContent(), + questionReq.getAnswer())); + } + } + + @Transactional + public void updateImageQuiz( + Long quizId, + ImageQuizUpdateRequest request, + MultipartFile thumbnailFile, + List questionImageFiles) { + Quiz quiz = updateQuiz(quizId, request, thumbnailFile); + + if (questionImageFiles == null) { + questionImageFiles = new ArrayList<>(); + } + + Iterator fileIter = questionImageFiles.iterator(); + for (ImageQuestionUpdateRequest questionReq : request.getQuestions()) { + String savedImagePath = null; + if (questionReq.hasImageFile() && fileIter.hasNext()) { + MultipartFile imageFile = fileIter.next(); + if (hasFile(imageFile)) { + validateImageFile(imageFile); + } else { + throw new CustomException(QuestionErrorCode.INVALID_IMAGE_QUESTION_FILE); + } + savedImagePath = FileManager.saveMultipartFile(imageFile, questionPath); + } + questionService.updateContentQuestions( + quiz, + ContentQuestionUpdateRequest.of( + questionReq.getId(), savedImagePath, questionReq.getAnswer())); + } + } + + private Quiz updateQuiz(Long quizId, QuizUpdateRequest request, MultipartFile thumbnailFile) { + Quiz quiz = findQuiz(quizId); verifyUserAuthority(quiz); quiz.changeTitle(request.getTitle()); quiz.changeDescription(request.getDescription()); - List questionReqList = request.getQuestions(); + updateThumbnail(quiz, thumbnailFile); - for (QuestionUpdateRequest questionReq : questionReqList) { - questionService.updateQuestions(quiz, questionReq); - } + return quiz; } - @Transactional - public void updateThumbnail(Long quizId, MultipartFile thumbnailFile) { - - Quiz quiz = - quizRepository - .findById(quizId) - .orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND)); - - verifyUserAuthority(quiz); - + private void updateThumbnail(Quiz quiz, MultipartFile thumbnailFile) { + if (!hasFile(thumbnailFile)) return; validateImageFile(thumbnailFile); - String newThumbnailPath = convertToThumbnailPath(thumbnailFile); - deleteThumbnailFile(quiz.getThumbnailUrl()); + String newThumbnailPath = FileManager.saveMultipartFile(thumbnailFile, thumbnailPath); + String oldThumbnailPath = quiz.getThumbnailUrl(); + quiz.changeThumbnailUrl(newThumbnailPath); + deleteImageFile(oldThumbnailPath); } - private void deleteThumbnailFile(String oldFilename) { - if (oldFilename.contains(DEFAULT)) { + private void deleteImageFile(String filePath) { + if (filePath.contains(defaultThumbnailFile)) { return; } - // oldFilename : /images/thumbnail/123asd.jpg - // filename : 123asd.jpg - String filename = oldFilename.substring(oldFilename.lastIndexOf("/") + 1); - Path filePath = Paths.get(uploadPath, filename).toAbsolutePath(); - - try { - boolean deleted = deleteIfExists(filePath); - if (deleted) { - log.info("기존 썸네일 삭제 완료 : {}", filePath); - } else { - log.info("기존 썸네일 존재 X : {}", filePath); - } - } catch (IOException e) { - log.error("기존 썸네일 삭제 중 오류 : {}", filePath); - throw new CustomException(QuizErrorCode.THUMBNAIL_DELETE_FAILED); - } + FileManager.deleteFile(filePath); } @Transactional(readOnly = true) @@ -229,11 +290,7 @@ public QuizListPageResponse getQuizzes(String title, String creator, Pageable pa @Transactional(readOnly = true) public Quiz getQuizWithQuestionsById(Long quizId) { - Quiz quiz = - quizRepository - .findQuizWithQuestionsById(quizId) - .orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND)); - return quiz; + return findQuizWithQuestions(quizId); } @Transactional(readOnly = true) @@ -243,32 +300,37 @@ public QuizMinData getQuizMinData() { @Transactional(readOnly = true) public QuizQuestionListResponse getQuizWithQuestions(Long quizId) { - Quiz quiz = - quizRepository - .findById(quizId) - .orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND)); + Quiz quiz = findQuizWithQuestions(quizId); return quizToQuizQuestionListResponse(quiz); } @Transactional(readOnly = true) public List getRandomQuestionsWithoutAnswer(Long quizId, Integer round) { - quizRepository - .findById(quizId) - .orElseThrow(() -> new NoSuchElementException("존재하지 않는 퀴즈입니다.")); + findQuiz(quizId); return quizRepository.findRandQuestionsByQuizId(quizId, round); } @Transactional(readOnly = true) public Quiz findQuizById(Long quizId) { - return quizRepository - .findById(quizId) - .orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND)); + return findQuiz(quizId); } @Transactional(readOnly = true) public Long getQuestionsCount(Long quizId) { return quizRepository.countQuestionsByQuizId(quizId); } + + private Quiz findQuiz(Long quizId) { + return quizRepository + .findById(quizId) + .orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND)); + } + + private Quiz findQuizWithQuestions(Long quizId) { + return quizRepository + .findQuizWithQuestionsById(quizId) + .orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND)); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/dto/ImageQuizCreateRequest.java b/backend/src/main/java/io/f1/backend/domain/quiz/dto/ImageQuizCreateRequest.java new file mode 100644 index 00000000..e23bb95c --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/quiz/dto/ImageQuizCreateRequest.java @@ -0,0 +1,22 @@ +package io.f1.backend.domain.quiz.dto; + +import io.f1.backend.domain.question.dto.ImageQuestionRequest; +import io.f1.backend.domain.quiz.entity.QuizType; + +import jakarta.validation.constraints.Size; + +import lombok.Getter; + +import java.util.List; + +@Getter +public class ImageQuizCreateRequest extends QuizCreateRequest { + @Size(min = 10, max = 80, message = "문제는 최소 10개, 최대 80개로 정해주세요.") + private List questions; + + public ImageQuizCreateRequest( + String title, String description, List questions) { + super(title, QuizType.IMAGE, description); + this.questions = questions; + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/dto/ImageQuizUpdateRequest.java b/backend/src/main/java/io/f1/backend/domain/quiz/dto/ImageQuizUpdateRequest.java new file mode 100644 index 00000000..e35ba7a8 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/quiz/dto/ImageQuizUpdateRequest.java @@ -0,0 +1,21 @@ +package io.f1.backend.domain.quiz.dto; + +import io.f1.backend.domain.question.dto.ImageQuestionUpdateRequest; + +import jakarta.validation.constraints.Size; + +import lombok.Getter; + +import java.util.List; + +@Getter +public class ImageQuizUpdateRequest extends QuizUpdateRequest { + @Size(min = 10, max = 80, message = "문제는 최소 10개, 최대 80개로 정해주세요.") + private List questions; + + public ImageQuizUpdateRequest( + String title, String description, List questions) { + super(title, description); + this.questions = questions; + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizCreateRequest.java b/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizCreateRequest.java index 313a519a..2dd69c20 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizCreateRequest.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizCreateRequest.java @@ -1,23 +1,17 @@ package io.f1.backend.domain.quiz.dto; -import io.f1.backend.domain.question.dto.QuestionRequest; import io.f1.backend.domain.quiz.entity.QuizType; import io.f1.backend.global.validation.TrimmedSize; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.List; @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class QuizCreateRequest { - +@AllArgsConstructor +public abstract class QuizCreateRequest { @TrimmedSize(min = 2, max = 30) @NotBlank(message = "퀴즈 제목을 설정해주세요.") private String title; @@ -28,7 +22,4 @@ public class QuizCreateRequest { @TrimmedSize(min = 10, max = 50) @NotBlank(message = "퀴즈 설명을 적어주세요.") private String description; - - @Size(min = 10, max = 80, message = "문제는 최소 10개, 최대 80개로 정해주세요.") - private List questions; } diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizListResponse.java b/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizListResponse.java index 8a443fab..aca32c79 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizListResponse.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizListResponse.java @@ -1,9 +1,12 @@ package io.f1.backend.domain.quiz.dto; +import io.f1.backend.domain.quiz.entity.QuizType; + public record QuizListResponse( Long quizId, String title, String description, + QuizType quizType, String creatorNickname, int numberOfQuestion, String thumbnailUrl) {} diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizUpdateRequest.java b/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizUpdateRequest.java index d1de1be7..46c68bcd 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizUpdateRequest.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizUpdateRequest.java @@ -1,21 +1,15 @@ package io.f1.backend.domain.quiz.dto; -import io.f1.backend.domain.question.dto.QuestionUpdateRequest; import io.f1.backend.global.validation.TrimmedSize; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; -import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.List; @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class QuizUpdateRequest { - +@AllArgsConstructor +public abstract class QuizUpdateRequest { @TrimmedSize(min = 2, max = 30) @NotBlank(message = "퀴즈 제목을 설정해주세요.") private String title; @@ -23,7 +17,4 @@ public class QuizUpdateRequest { @TrimmedSize(min = 10, max = 50) @NotBlank(message = "퀴즈 설명을 적어주세요.") private String description; - - @Size(min = 10, max = 80, message = "문제는 최소 10개, 최대 80개로 정해주세요.") - private List questions; } diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/dto/TextQuizCreateRequest.java b/backend/src/main/java/io/f1/backend/domain/quiz/dto/TextQuizCreateRequest.java new file mode 100644 index 00000000..61ee2088 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/quiz/dto/TextQuizCreateRequest.java @@ -0,0 +1,22 @@ +package io.f1.backend.domain.quiz.dto; + +import io.f1.backend.domain.question.dto.TextQuestionRequest; +import io.f1.backend.domain.quiz.entity.QuizType; + +import jakarta.validation.constraints.Size; + +import lombok.Getter; + +import java.util.List; + +@Getter +public class TextQuizCreateRequest extends QuizCreateRequest { + @Size(min = 10, max = 80, message = "문제는 최소 10개, 최대 80개로 정해주세요.") + private List questions; + + public TextQuizCreateRequest( + String title, String description, List questions) { + super(title, QuizType.TEXT, description); + this.questions = questions; + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/dto/TextQuizUpdateRequest.java b/backend/src/main/java/io/f1/backend/domain/quiz/dto/TextQuizUpdateRequest.java new file mode 100644 index 00000000..463096ea --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/quiz/dto/TextQuizUpdateRequest.java @@ -0,0 +1,21 @@ +package io.f1.backend.domain.quiz.dto; + +import io.f1.backend.domain.question.dto.TextQuestionUpdateRequest; + +import jakarta.validation.constraints.Size; + +import lombok.Getter; + +import java.util.List; + +@Getter +public class TextQuizUpdateRequest extends QuizUpdateRequest { + @Size(min = 10, max = 80, message = "문제는 최소 10개, 최대 80개로 정해주세요.") + private List questions; + + public TextQuizUpdateRequest( + String title, String description, List questions) { + super(title, description); + this.questions = questions; + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/mapper/QuizMapper.java b/backend/src/main/java/io/f1/backend/domain/quiz/mapper/QuizMapper.java index 25a16242..0f175599 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/mapper/QuizMapper.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/mapper/QuizMapper.java @@ -10,6 +10,7 @@ import io.f1.backend.domain.quiz.dto.QuizListResponse; import io.f1.backend.domain.quiz.dto.QuizQuestionListResponse; import io.f1.backend.domain.quiz.entity.Quiz; +import io.f1.backend.domain.quiz.entity.QuizType; import io.f1.backend.domain.user.entity.User; import org.springframework.data.domain.Page; @@ -44,6 +45,7 @@ public static QuizListResponse quizToQuizListResponse(Quiz quiz) { quiz.getId(), quiz.getTitle(), quiz.getDescription(), + quiz.getQuizType(), quiz.findCreatorNickname(), quiz.getQuestions().size(), quiz.getThumbnailUrl()); @@ -67,7 +69,7 @@ public static List questionsToQuestionResponses(List question -> new QuestionResponse( question.getId(), - question.getTextQuestion().getContent(), + question.getContentQuestion().getContent(), question.getAnswer())) .toList(); } @@ -88,10 +90,12 @@ public static List toGameQuestionResponseList(List questions) { - return new GameStartResponse(toGameQuestionResponseList(questions)); + public static GameStartResponse toGameStartResponse( + QuizType quizType, List questions) { + return new GameStartResponse(quizType, toGameQuestionResponseList(questions)); } } diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuestionErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuestionErrorCode.java index 7a179950..52308e53 100644 --- a/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuestionErrorCode.java +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuestionErrorCode.java @@ -10,6 +10,8 @@ public enum QuestionErrorCode implements ErrorCode { INVALID_CONTENT_LENGTH("E400011", HttpStatus.BAD_REQUEST, "문제는 5자 이상 30자 이하로 입력해주세요."), INVALID_ANSWER_LENGTH("E400012", HttpStatus.BAD_REQUEST, "정답은 1자 이상 30자 이하로 입력해주세요."), + INVALID_IMAGE_QUESTION_SIZE("E400017", HttpStatus.BAD_REQUEST, "문제 수와 이미지 수가 일치하지 않습니다."), + INVALID_IMAGE_QUESTION_FILE("E400018", HttpStatus.BAD_REQUEST, "문제에 이미지 파일이 없습니다."), QUESTION_NOT_FOUND("E404003", HttpStatus.NOT_FOUND, "존재하지 않는 문제입니다."); private final String code; diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuizErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuizErrorCode.java index fbedd44c..8c131dcd 100644 --- a/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuizErrorCode.java +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuizErrorCode.java @@ -16,8 +16,8 @@ public enum QuizErrorCode implements ErrorCode { UNSUPPORTED_MEDIA_TYPE("E415001", HttpStatus.UNSUPPORTED_MEDIA_TYPE, "지원하지 않는 파일 형식입니다."), INVALID_FILTER("E400007", HttpStatus.BAD_REQUEST, "title 또는 creator 중 하나만 입력 가능합니다."), QUIZ_NOT_FOUND("E404002", HttpStatus.NOT_FOUND, "존재하지 않는 퀴즈입니다."), - THUMBNAIL_SAVE_FAILED("E500002", HttpStatus.INTERNAL_SERVER_ERROR, "썸네일 저장에 실패했습니다."), - THUMBNAIL_DELETE_FAILED("E500003", HttpStatus.INTERNAL_SERVER_ERROR, "썸네일 삭제에 실패했습니다."); + IMAGE_SAVE_FAILED("E500002", HttpStatus.INTERNAL_SERVER_ERROR, "이미지 저장에 실패했습니다."), + IMAGE_DELETE_FAILED("E500003", HttpStatus.INTERNAL_SERVER_ERROR, "이미지 삭제에 실패했습니다."); private final String code; diff --git a/backend/src/main/java/io/f1/backend/global/exception/handler/GlobalExceptionHandler.java b/backend/src/main/java/io/f1/backend/global/exception/handler/GlobalExceptionHandler.java index c6f39a83..344d7cd8 100644 --- a/backend/src/main/java/io/f1/backend/global/exception/handler/GlobalExceptionHandler.java +++ b/backend/src/main/java/io/f1/backend/global/exception/handler/GlobalExceptionHandler.java @@ -31,7 +31,7 @@ public ResponseEntity handleCustomException(CustomException e) { @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception e, HttpServletRequest request) { - log.warn("handleException: {}", e.getMessage()); + log.warn("handleException:", e); if ("text/event-stream".equals(request.getHeader("Accept"))) { return ResponseEntity.noContent().build(); diff --git a/backend/src/main/java/io/f1/backend/global/util/FileManager.java b/backend/src/main/java/io/f1/backend/global/util/FileManager.java new file mode 100644 index 00000000..21eff4b7 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/util/FileManager.java @@ -0,0 +1,56 @@ +package io.f1.backend.global.util; + +import io.f1.backend.global.exception.CustomException; +import io.f1.backend.global.exception.errorcode.QuizErrorCode; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.UUID; + +@Slf4j +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class FileManager { + public static void deleteFile(String filePath) { + Path path = Paths.get(filePath).toAbsolutePath(); + + try { + boolean deleted = Files.deleteIfExists(path); + if (deleted) { + log.info("파일 삭제 완료 : {}", path); + } else { + log.info("기존 파일 존재 X : {}", path); + } + } catch (IOException e) { + log.error("파일 삭제 중 오류 : {}", path); + throw new CustomException(QuizErrorCode.IMAGE_DELETE_FAILED); + } + } + + public static String saveMultipartFile(MultipartFile imageFile, String filePath) { + String originalFilename = imageFile.getOriginalFilename(); + String ext = getExtension(originalFilename); + String savedFilename = UUID.randomUUID().toString() + "." + ext; + + try { + Path savePath = Paths.get(filePath, savedFilename).toAbsolutePath(); + imageFile.transferTo(savePath.toFile()); + } catch (IOException e) { + log.error("파일 업로드 중 IOException 발생", e); + throw new CustomException(QuizErrorCode.IMAGE_SAVE_FAILED); + } + + return filePath + savedFilename; + } + + public static String getExtension(String filename) { + return filename.substring(filename.lastIndexOf(".") + 1).toLowerCase(); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index b730cce1..7123013e 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,7 +1,8 @@ spring: servlet: multipart: - max-file-size: 5MB + max-file-size: 2MB + max-request-size: 162MB config: import: optional:file:.env[.properties] @@ -56,8 +57,9 @@ spring: user-name-attribute: id file: - thumbnail-path: images/thumbnail/ # 이후 배포 환경에서는 바꾸면 될 듯 - default-thumbnail-url: /images/thumbnail/default.png + thumbnail-path: images/thumbnail/ + default-thumbnail-file: default.png + question-path: images/question/ server: port: 8080 @@ -69,6 +71,8 @@ server: secure: true http-only: true timeout: ${SESSION_TIMEOUT} + tomcat: + max-part-count: 100 custom: oauth: diff --git a/backend/src/main/resources/data.sql b/backend/src/main/resources/data.sql index 35015899..0a31505a 100644 --- a/backend/src/main/resources/data.sql +++ b/backend/src/main/resources/data.sql @@ -89,7 +89,7 @@ VALUES (1, '정답9', NOW(), NOW()), (1, '정답10', NOW(), NOW()); -INSERT INTO text_question (question_id, content) +INSERT INTO content_question (question_id, content) VALUES (1, '1번 문제 내용입니다.'), (2, '2번 문제 내용입니다.'), @@ -146,7 +146,7 @@ INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES (2, '리스본', NOW(), NOW()), (2, '서울', NOW(), NOW()); -INSERT INTO text_question (question_id, content) VALUES +INSERT INTO content_question (question_id, content) VALUES (11, '프랑스의 수도는 어디인가요?'), (12, '일본의 수도는 어디인가요?'), (13, '중국의 수도는 어디인가요?'), @@ -178,7 +178,7 @@ INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES (3, '침팬지', NOW(), NOW()), (3, '코알라', NOW(), NOW()); -INSERT INTO text_question (question_id, content) VALUES +INSERT INTO content_question (question_id, content) VALUES (26, '가장 큰 육상 동물은?'), (27, '목이 가장 긴 동물은?'), (28, '물속에서 오래 숨을 참을 수 있는 동물은?'), @@ -205,7 +205,7 @@ INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES (4, '탄소', NOW(), NOW()), (4, '전자', NOW(), NOW()); -INSERT INTO text_question (question_id, content) VALUES +INSERT INTO content_question (question_id, content) VALUES (38, '화학식 H2O는 무엇인가요?'), (39, '지구에 빛과 열을 제공하는 별은?'), (40, '우주에서 가장 빠른 것은?'), @@ -233,7 +233,7 @@ INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES (5, '레오나르도 다 빈치', NOW(), NOW()), (5, '아인슈타인', NOW(), NOW()); -INSERT INTO text_question (question_id, content) VALUES +INSERT INTO content_question (question_id, content) VALUES (48, '한글을 창제한 조선의 왕은?'), (49, '임진왜란 당시 활약한 조선의 장군은?'), (50, '10만 양병설로 유명한 조선의 학자는?'), @@ -262,7 +262,7 @@ INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES (6, '탁구', NOW(), NOW()), (6, '수영', NOW(), NOW()); -INSERT INTO text_question (question_id, content) VALUES +INSERT INTO content_question (question_id, content) VALUES (61, '11명이 한 팀으로 경기하는 스포츠는?'), (62, '홈런이 나오는 스포츠는?'), (63, '슛과 리바운드가 중요한 스포츠는?'), @@ -292,7 +292,7 @@ INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES (7, '박완서', NOW(), NOW()), (7, '루이자 메이 올컷', NOW(), NOW()); -INSERT INTO text_question (question_id, content) VALUES +INSERT INTO content_question (question_id, content) VALUES (72, '『무정』을 쓴 작가는 누구인가요?'), (73, '『진달래꽃』을 지은 시인은 누구인가요?'), (74, '『날개』를 쓴 작가는 누구인가요?'), @@ -321,7 +321,7 @@ INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES (8, '음표', NOW(), NOW()), (8, '템포', NOW(), NOW()); -INSERT INTO text_question (question_id, content) VALUES +INSERT INTO content_question (question_id, content) VALUES (86, '88개의 건반이 있는 대표적인 건반악기는?'), (87, '활을 사용하는 대표적인 현악기는?'), (88, '『운명 교향곡』을 작곡한 사람은?'), @@ -347,7 +347,7 @@ INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES (9, '울릉도', NOW(), NOW()), (9, '독도', NOW(), NOW()); -INSERT INTO text_question (question_id, content) VALUES +INSERT INTO content_question (question_id, content) VALUES (96, '대한민국에서 가장 높은 산은?'), (97, '한반도의 가장 북쪽에 있는 산은?'), (98, '대한민국에서 가장 긴 강은?'), @@ -374,7 +374,7 @@ INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES (10, '포케', NOW(), NOW()), (10, '무사카', NOW(), NOW()); -INSERT INTO text_question (question_id, content) VALUES +INSERT INTO content_question (question_id, content) VALUES (106, '한국을 대표하는 발효 음식은?'), (107, '일본의 생선 초밥을 무엇이라 하나요?'), (108, '이탈리아의 대표적인 면 요리는?'), @@ -404,7 +404,7 @@ INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES (11, '돌다리', NOW(), NOW()), (11, '벼', NOW(), NOW()); -INSERT INTO text_question (question_id, content) VALUES +INSERT INTO content_question (question_id, content) VALUES (118, '가는 말이 고와야 오는 ○○○ 곱다'), (119, '소 잃고 ○○○ 고친다'), (120, '호랑이도 ○○○ 앞에서는 없다'), @@ -433,7 +433,7 @@ INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES (12, '목', NOW(), NOW()), (12, '배', NOW(), NOW()); -INSERT INTO text_question (question_id, content) VALUES +INSERT INTO content_question (question_id, content) VALUES (131, '○이 가볍다 = 말이 많다'), (132, '○에 불이 나다 = 매우 바쁘다'), (133, '○에 장난 치다 = 방해하다'), @@ -462,7 +462,7 @@ INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES (13, '작용 반작용', NOW(), NOW()), (13, '운동량 보존 법칙', NOW(), NOW()); -INSERT INTO text_question (question_id, content) VALUES +INSERT INTO content_question (question_id, content) VALUES (142, '정지한 물체는 계속 정지하고, 운동 중인 물체는 계속 운동하려는 법칙은?'), (143, '힘 = 질량 x 가속도, 어떤 법칙인가요?'), (144, '모든 작용에는 크기가 같고 방향이 반대인 반작용이 따른다. 어떤 법칙인가요?'), @@ -490,7 +490,7 @@ INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES (14, '납', NOW(), NOW()), (14, '우라늄', NOW(), NOW()); -INSERT INTO text_question (question_id, content) VALUES +INSERT INTO content_question (question_id, content) VALUES (154, '가장 가벼운 원소는?'), (155, '풍선에 쓰이며 비활성 기체인 원소는?'), (156, '생명 유지에 꼭 필요한 기체 원소는?'), @@ -519,7 +519,7 @@ INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES (15, '해리포터', NOW(), NOW()), (15, '반지의 제왕', NOW(), NOW()); -INSERT INTO text_question (question_id, content) VALUES +INSERT INTO content_question (question_id, content) VALUES (165, '봉준호 감독의 아카데미 수상작은?'), (166, '꿈 속에서 또 다른 꿈으로 들어가는 영화는?'), (167, '히어로들이 모여서 싸우는 마블 영화는?'), @@ -547,7 +547,7 @@ INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES (16, '이순신', NOW(), NOW()), (16, '세종대왕', NOW(), NOW()); -INSERT INTO text_question (question_id, content) VALUES +INSERT INTO content_question (question_id, content) VALUES (178, '"상상력은 지식보다 중요하다." 라는 명언을 남긴 사람은?'), (179, '"당신이 세상에서 보고 싶은 변화가 되어라." 라고 말한 인물은?'), (180, '"우리는 큰 일을 할 수는 없지만, 작은 일을 큰 사랑으로 할 수 있습니다." 는 누구의 말인가요?'), @@ -577,7 +577,7 @@ INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES (17, '방콕', NOW(), NOW()), (17, '자카르타', NOW(), NOW()); -INSERT INTO text_question (question_id, content) VALUES +INSERT INTO content_question (question_id, content) VALUES (188, '대한민국의 수도는?'), (189, '미국의 수도는?'), (190, '일본의 수도는?'), @@ -609,7 +609,7 @@ INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES (18, '기린', NOW(), NOW()), (18, '안경', NOW(), NOW()); -INSERT INTO text_question (question_id, content) VALUES +INSERT INTO content_question (question_id, content) VALUES (203, '“니가 가라 ○○○”에서 ○○○에 들어갈 말은?'), (204, '유리가 집을 나간 이유는?'), (205, '하늘에서 떨어지는 코는?'), @@ -636,7 +636,7 @@ INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES (19, '크레몰린 궁전', NOW(), NOW()), (19, '앙코르와트', NOW(), NOW()); -INSERT INTO text_question (question_id, content) VALUES +INSERT INTO content_question (question_id, content) VALUES (215, '프랑스 파리의 상징적인 탑은?'), (216, '뉴욕에 있는 유명한 동상은?'), (217, '로마에 있는 고대 원형 경기장은?'), @@ -665,7 +665,7 @@ INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES (20, '치즈', NOW(), NOW()), (20, '와플', NOW(), NOW()); -INSERT INTO text_question (question_id, content) VALUES +INSERT INTO content_question (question_id, content) VALUES (225, '한국을 대표하는 발효 음식은?'), (226, '생선을 얇게 썰어 만든 일본 음식은?'), (227, '이탈리아에서 유래된 빵 위에 토핑을 얹은 음식은?'), @@ -705,7 +705,7 @@ INSERT INTO question (quiz_id, answer, created_at, updated_at) VALUES (21, '문', NOW(), NOW()), (21, '창문', NOW(), NOW()); -INSERT INTO text_question (question_id, content) VALUES +INSERT INTO content_question (question_id, content) VALUES (239, 'apple의 뜻은?'), (240, 'banana의 뜻은?'), (241, 'cat의 뜻은?'), diff --git a/backend/src/main/resources/db/migration/V2__content_question.sql b/backend/src/main/resources/db/migration/V2__content_question.sql new file mode 100644 index 00000000..6cba72e1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__content_question.sql @@ -0,0 +1,12 @@ +-- text_question 테이블을 content_question으로 변경 +-- 1. 기존 제약조건 삭제 +ALTER TABLE text_question DROP CONSTRAINT FK_test_question__question_id; +ALTER TABLE text_question DROP CONSTRAINT UK_text_question__question_id; + +-- 2. 테이블 이름 변경 +RENAME TABLE text_question TO content_question; + +-- 3. 새로운 제약조건 추가 +ALTER TABLE content_question ADD CONSTRAINT UK_content_question__question_id UNIQUE (question_id); +ALTER TABLE content_question ADD CONSTRAINT FK_content_question__question_id + FOREIGN KEY (question_id) REFERENCES question (id); diff --git a/backend/src/test/java/io/f1/backend/domain/quiz/QuizBrowserTest.java b/backend/src/test/java/io/f1/backend/domain/quiz/QuizBrowserTest.java new file mode 100644 index 00000000..41ec4c65 --- /dev/null +++ b/backend/src/test/java/io/f1/backend/domain/quiz/QuizBrowserTest.java @@ -0,0 +1,153 @@ +package io.f1.backend.domain.quiz; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mockStatic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.database.rider.core.api.dataset.DataSet; + +import io.f1.backend.domain.question.dto.ImageQuestionRequest; +import io.f1.backend.domain.question.dto.TextQuestionRequest; +import io.f1.backend.domain.quiz.dto.ImageQuizCreateRequest; +import io.f1.backend.domain.quiz.dto.TextQuizCreateRequest; +import io.f1.backend.domain.user.dao.UserRepository; +import io.f1.backend.domain.user.entity.User; +import io.f1.backend.global.template.BrowserTestTemplate; +import io.f1.backend.global.util.FileManager; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.mock.web.MockPart; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMultipartHttpServletRequestBuilder; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +@WithMockUser +class QuizBrowserTest extends BrowserTestTemplate { + + @Autowired UserRepository userRepository; + @Autowired ObjectMapper objectMapper; + + @Test + @DataSet("datasets/user.yml") + @DisplayName("텍스트 퀴즈를 등록하면 201 응답을 받는다.") + void createTextQuiz() throws Exception { + // given + User user = userRepository.findById(1L).orElseThrow(AssertionError::new); + MockHttpSession session = getMockSession(user, true); + MockMultipartFile thumbnailFile = createMockMultipartFile("thumbnailFile"); + + List questions = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + questions.add(new TextQuestionRequest("test", "test")); + } + TextQuizCreateRequest request = + new TextQuizCreateRequest("test", "test description", questions); + + MockPart requestPart = createJsonMockPart("request", request); + + // when + ResultActions result; + try (MockedStatic mockFileManager = mockStatic(FileManager.class)) { + mockFileManager(mockFileManager); + result = + mockMvc.perform( + multipart("/quizzes/text") + .file(thumbnailFile) + .part(requestPart) + .session(session)); + } + + // then + result.andExpectAll( + status().isCreated(), + jsonPath("$.title").value("test"), + jsonPath("$.quizType").value("TEXT"), + jsonPath("$.description").value("test description"), + jsonPath("$.thumbnailUrl").value("testpath"), + jsonPath("$.creatorId").value(1)); + } + + @Test + @DataSet("datasets/user.yml") + @DisplayName("이미지 퀴즈를 등록하면 201 응답을 받는다.") + void createImageQuiz() throws Exception { + // given + User user = userRepository.findById(1L).orElseThrow(AssertionError::new); + MockHttpSession session = getMockSession(user, true); + MockMultipartFile thumbnailFile = createMockMultipartFile("thumbnailFile"); + List questionImageFiles = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + questionImageFiles.add(createMockMultipartFile("questionImageFiles")); + } + List questions = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + questions.add(new ImageQuestionRequest("test")); + } + ImageQuizCreateRequest request = + new ImageQuizCreateRequest("test", "test description", questions); + MockPart requestPart = createJsonMockPart("request", request); + + // when + MockMultipartHttpServletRequestBuilder mockMvcBuilder = + multipart("/quizzes/image").file(thumbnailFile).part(requestPart); + + for (MockMultipartFile questionImageFile : questionImageFiles) { + mockMvcBuilder.file(questionImageFile); + } + + mockMvcBuilder.session(session); + + ResultActions result; + try (MockedStatic mockFileManager = mockStatic(FileManager.class)) { + mockFileManager(mockFileManager); + result = mockMvc.perform(mockMvcBuilder); + } + + // then + result.andExpectAll( + status().isCreated(), + jsonPath("$.title").value("test"), + jsonPath("$.quizType").value("IMAGE"), + jsonPath("$.description").value("test description"), + jsonPath("$.thumbnailUrl").value("testpath"), + jsonPath("$.creatorId").value(1)); + } + + private MockMultipartFile createMockMultipartFile(String requestPart) { + return new MockMultipartFile( + requestPart, "test-image.jpg", "image/jpeg", "test-image.jpg".getBytes()); + } + + private MockPart createJsonMockPart(String requestPart, Object requestObject) throws Exception { + MockPart part = + new MockPart( + requestPart, + objectMapper + .writeValueAsString(requestObject) + .getBytes(StandardCharsets.UTF_8)); + part.getHeaders().set("Content-Type", MediaType.APPLICATION_JSON_VALUE); + return part; + } + + private void mockFileManager(MockedStatic mockFileManager) { + mockFileManager + .when(() -> FileManager.saveMultipartFile(any(MultipartFile.class), anyString())) + .thenReturn("testpath"); + mockFileManager.when(() -> FileManager.getExtension(anyString())).thenReturn("jpg"); + } +} diff --git a/backend/src/test/java/io/f1/backend/domain/stat/RedisStatBrowserTest.java b/backend/src/test/java/io/f1/backend/domain/stat/RedisStatBrowserTest.java index 1a3f5fe8..a7b62d9d 100644 --- a/backend/src/test/java/io/f1/backend/domain/stat/RedisStatBrowserTest.java +++ b/backend/src/test/java/io/f1/backend/domain/stat/RedisStatBrowserTest.java @@ -1,7 +1,5 @@ package io.f1.backend.domain.stat; -import static io.f1.backend.domain.user.constants.SessionKeys.OAUTH_USER; -import static io.f1.backend.domain.user.constants.SessionKeys.USER; import static io.f1.backend.global.exception.errorcode.RoomErrorCode.PLAYER_NOT_FOUND; import static org.mockito.ArgumentMatchers.any; @@ -28,11 +26,9 @@ import io.f1.backend.domain.stat.dto.StatWithNickname; import io.f1.backend.domain.stat.dto.StatWithUserSummary; import io.f1.backend.domain.user.dao.UserRepository; -import io.f1.backend.domain.user.dto.AuthenticationUser; import io.f1.backend.domain.user.dto.SignupRequest; import io.f1.backend.domain.user.entity.User; import io.f1.backend.global.config.RedisTestContainerConfig; -import io.f1.backend.global.security.util.SecurityUtils; import io.f1.backend.global.template.BrowserTestTemplate; import org.junit.jupiter.api.BeforeEach; @@ -173,18 +169,6 @@ private void verifyNeverUsedJpa() { verify(statJpaRepository, never()).findAllStatsWithUser(any()); } - private MockHttpSession getMockSession(User user, boolean signup) { - MockHttpSession session = new MockHttpSession(); - if (signup) { - session.setAttribute(USER, AuthenticationUser.from(user)); - SecurityUtils.setAuthentication(user); - } else { - session.setAttribute(OAUTH_USER, AuthenticationUser.from(user)); - } - - return session; - } - private void warmingRedisOneUser(User user) { StatWithUserSummary mockStat = new StatWithUserSummary(user.getId(), user.getNickname(), 10, 10, 100); diff --git a/backend/src/test/java/io/f1/backend/global/template/BrowserTestTemplate.java b/backend/src/test/java/io/f1/backend/global/template/BrowserTestTemplate.java index 061ff7ef..4e86a6e3 100644 --- a/backend/src/test/java/io/f1/backend/global/template/BrowserTestTemplate.java +++ b/backend/src/test/java/io/f1/backend/global/template/BrowserTestTemplate.java @@ -1,10 +1,18 @@ package io.f1.backend.global.template; +import static io.f1.backend.domain.user.constants.SessionKeys.OAUTH_USER; +import static io.f1.backend.domain.user.constants.SessionKeys.USER; + import com.github.database.rider.spring.api.DBRider; +import io.f1.backend.domain.user.dto.AuthenticationUser; +import io.f1.backend.domain.user.entity.User; +import io.f1.backend.global.security.util.SecurityUtils; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockHttpSession; import org.springframework.test.web.servlet.MockMvc; @DBRider @@ -12,4 +20,16 @@ @AutoConfigureMockMvc public abstract class BrowserTestTemplate { @Autowired protected MockMvc mockMvc; + + protected MockHttpSession getMockSession(User user, boolean signup) { + MockHttpSession session = new MockHttpSession(); + if (signup) { + session.setAttribute(USER, AuthenticationUser.from(user)); + SecurityUtils.setAuthentication(user); + } else { + session.setAttribute(OAUTH_USER, AuthenticationUser.from(user)); + } + + return session; + } } diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index b103fbec..bba366b5 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -38,5 +38,6 @@ spring: file: thumbnail-path : images/thumbnail/ # 이후 배포 환경에서는 바꾸면 될 듯 - default-thumbnail-url: /images/thumbnail/default.png + default-thumbnail-file: default.png + question-path: images/question/