From 370e8d6b1431041f1a8da3fdd161f89fa99d96bb Mon Sep 17 00:00:00 2001 From: Donghoon Lee Date: Mon, 7 Oct 2024 14:32:33 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[BE]=20refactor:=20Answer=20=EC=B6=94?= =?UTF-8?q?=EC=83=81=ED=99=94=201=EB=8B=A8=EA=B3=84=20(#766)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 마이그레이션 대비 새로운 테이블 생성 * feat: 테이블 대응 Repository 추가 (복사) * feat: Mapper, validator 추가 * chore: auto_increment 언급 * fix: equalsandhashcode * fix: Checkbox 소문자 수정 * fix: sql 세미콜론 * chore: 예외 메시지 수정 * fix: remove trailing commas * fix: set mysql dialect, add alter table for initialization * style: 코드 스타일 적용 * test: TextAnswer, CheckboxAnswer, Review 테스트 추가 * refactor: 예외 구체화 및 테스트 작성 * test: TextAnswer, CheckboxAnswer 매퍼 테스트 작성 * test: 리뷰 매퍼 클래스 수정 및 테스트 추가 (TODO: 삭제해야 하는 더미 코드 추가) * test: 리뷰 검증 클래스 테스트 추가 * chore: displayname 삭제 * chore: 오타 수정 * style: 개행 추가 * style: 개행 두개 제거 --- .../review/domain/abstraction/Answer.java | 33 ++++ .../domain/abstraction/NewCheckboxAnswer.java | 40 ++++ .../NewCheckboxAnswerRepository.java | 6 + .../NewCheckboxAnswerSelectedOption.java | 34 ++++ .../review/domain/abstraction/NewReview.java | 74 ++++++++ .../abstraction/NewReviewRepository.java | 40 ++++ .../domain/abstraction/NewTextAnswer.java | 33 ++++ .../abstraction/NewTextAnswerRepository.java | 6 + .../review/service/ReviewRegisterService.java | 18 ++ .../mapper/AnswerMapperFactory.java | 20 ++ .../abstraction/mapper/NewAnswerMapper.java | 12 ++ .../mapper/NewCheckboxAnswerMapper.java | 24 +++ .../abstraction/mapper/NewReviewMapper.java | 77 ++++++++ .../mapper/NewTextAnswerMapper.java | 27 +++ .../UnsupportedQuestionTypeException.java | 14 ++ .../validator/AnswerValidatorFactory.java | 21 +++ .../validator/NewAnswerValidator.java | 10 + .../validator/NewCheckboxAnswerValidator.java | 82 ++++++++ .../validator/NewReviewValidator.java | 82 ++++++++ .../validator/NewTextAnswerValidator.java | 48 +++++ .../UnsupportedAnswerTypeException.java | 13 ++ .../db/migration/V2__answer_abstraction.sql | 54 ++++++ .../abstraction/NewCheckboxAnswerTest.java | 22 +++ .../domain/abstraction/NewReviewTest.java | 57 ++++++ .../domain/abstraction/NewTextAnswerTest.java | 21 +++ .../mapper/AnswerMapperFactoryTest.java | 54 ++++++ .../mapper/NewCheckboxAnswerMapperTest.java | 43 +++++ .../mapper/NewReviewMapperTest.java | 176 ++++++++++++++++++ .../mapper/NewTextAnswerMapperTest.java | 46 +++++ .../validator/AnswerValidatorFactoryTest.java | 47 +++++ .../NewCheckboxAnswerValidatorTest.java | 110 +++++++++++ .../validator/NewReviewValidatorTest.java | 153 +++++++++++++++ .../validator/NewTextAnswerValidatorTest.java | 74 ++++++++ .../validator/ReviewValidatorTest.java | 4 +- 34 files changed, 1573 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/reviewme/review/domain/abstraction/Answer.java create mode 100644 backend/src/main/java/reviewme/review/domain/abstraction/NewCheckboxAnswer.java create mode 100644 backend/src/main/java/reviewme/review/domain/abstraction/NewCheckboxAnswerRepository.java create mode 100644 backend/src/main/java/reviewme/review/domain/abstraction/NewCheckboxAnswerSelectedOption.java create mode 100644 backend/src/main/java/reviewme/review/domain/abstraction/NewReview.java create mode 100644 backend/src/main/java/reviewme/review/domain/abstraction/NewReviewRepository.java create mode 100644 backend/src/main/java/reviewme/review/domain/abstraction/NewTextAnswer.java create mode 100644 backend/src/main/java/reviewme/review/domain/abstraction/NewTextAnswerRepository.java create mode 100644 backend/src/main/java/reviewme/review/service/abstraction/mapper/AnswerMapperFactory.java create mode 100644 backend/src/main/java/reviewme/review/service/abstraction/mapper/NewAnswerMapper.java create mode 100644 backend/src/main/java/reviewme/review/service/abstraction/mapper/NewCheckboxAnswerMapper.java create mode 100644 backend/src/main/java/reviewme/review/service/abstraction/mapper/NewReviewMapper.java create mode 100644 backend/src/main/java/reviewme/review/service/abstraction/mapper/NewTextAnswerMapper.java create mode 100644 backend/src/main/java/reviewme/review/service/abstraction/mapper/UnsupportedQuestionTypeException.java create mode 100644 backend/src/main/java/reviewme/review/service/abstraction/validator/AnswerValidatorFactory.java create mode 100644 backend/src/main/java/reviewme/review/service/abstraction/validator/NewAnswerValidator.java create mode 100644 backend/src/main/java/reviewme/review/service/abstraction/validator/NewCheckboxAnswerValidator.java create mode 100644 backend/src/main/java/reviewme/review/service/abstraction/validator/NewReviewValidator.java create mode 100644 backend/src/main/java/reviewme/review/service/abstraction/validator/NewTextAnswerValidator.java create mode 100644 backend/src/main/java/reviewme/review/service/abstraction/validator/UnsupportedAnswerTypeException.java create mode 100644 backend/src/main/resources/db/migration/V2__answer_abstraction.sql create mode 100644 backend/src/test/java/reviewme/review/domain/abstraction/NewCheckboxAnswerTest.java create mode 100644 backend/src/test/java/reviewme/review/domain/abstraction/NewReviewTest.java create mode 100644 backend/src/test/java/reviewme/review/domain/abstraction/NewTextAnswerTest.java create mode 100644 backend/src/test/java/reviewme/review/service/abstraction/mapper/AnswerMapperFactoryTest.java create mode 100644 backend/src/test/java/reviewme/review/service/abstraction/mapper/NewCheckboxAnswerMapperTest.java create mode 100644 backend/src/test/java/reviewme/review/service/abstraction/mapper/NewReviewMapperTest.java create mode 100644 backend/src/test/java/reviewme/review/service/abstraction/mapper/NewTextAnswerMapperTest.java create mode 100644 backend/src/test/java/reviewme/review/service/abstraction/validator/AnswerValidatorFactoryTest.java create mode 100644 backend/src/test/java/reviewme/review/service/abstraction/validator/NewCheckboxAnswerValidatorTest.java create mode 100644 backend/src/test/java/reviewme/review/service/abstraction/validator/NewReviewValidatorTest.java create mode 100644 backend/src/test/java/reviewme/review/service/abstraction/validator/NewTextAnswerValidatorTest.java diff --git a/backend/src/main/java/reviewme/review/domain/abstraction/Answer.java b/backend/src/main/java/reviewme/review/domain/abstraction/Answer.java new file mode 100644 index 000000000..4118c3757 --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/abstraction/Answer.java @@ -0,0 +1,33 @@ +package reviewme.review.domain.abstraction; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "answer") +@Inheritance(strategy = InheritanceType.JOINED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public abstract class Answer { + + @Id + @GeneratedValue(strategy = GenerationType.TABLE) + protected Long id; + + @Column(name = "question_id", nullable = false) + protected long questionId; + + @Column(name = "review_id", nullable = false, insertable = false, updatable = false) + private long reviewId; +} diff --git a/backend/src/main/java/reviewme/review/domain/abstraction/NewCheckboxAnswer.java b/backend/src/main/java/reviewme/review/domain/abstraction/NewCheckboxAnswer.java new file mode 100644 index 000000000..af175ff56 --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/abstraction/NewCheckboxAnswer.java @@ -0,0 +1,40 @@ +package reviewme.review.domain.abstraction; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.List; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import reviewme.review.domain.exception.QuestionNotAnsweredException; + +@Entity +@Table(name = "new_checkbox_answer") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(callSuper = true) +@Getter +public class NewCheckboxAnswer extends Answer { + + @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "checkbox_answer_id", nullable = false, updatable = false) + private List selectedOptionIds; + + public NewCheckboxAnswer(long questionId, List selectedOptionIds) { + validateSelectedOptionIds(questionId, selectedOptionIds); + this.questionId = questionId; + this.selectedOptionIds = selectedOptionIds.stream() + .map(NewCheckboxAnswerSelectedOption::new) + .toList(); + } + + private void validateSelectedOptionIds(long questionId, List selectedOptionIds) { + if (selectedOptionIds == null || selectedOptionIds.isEmpty()) { + throw new QuestionNotAnsweredException(questionId); + } + } +} diff --git a/backend/src/main/java/reviewme/review/domain/abstraction/NewCheckboxAnswerRepository.java b/backend/src/main/java/reviewme/review/domain/abstraction/NewCheckboxAnswerRepository.java new file mode 100644 index 000000000..0387f18d2 --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/abstraction/NewCheckboxAnswerRepository.java @@ -0,0 +1,6 @@ +package reviewme.review.domain.abstraction; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NewCheckboxAnswerRepository extends JpaRepository { +} diff --git a/backend/src/main/java/reviewme/review/domain/abstraction/NewCheckboxAnswerSelectedOption.java b/backend/src/main/java/reviewme/review/domain/abstraction/NewCheckboxAnswerSelectedOption.java new file mode 100644 index 000000000..4fb64f01a --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/abstraction/NewCheckboxAnswerSelectedOption.java @@ -0,0 +1,34 @@ +package reviewme.review.domain.abstraction; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "new_checkbox_answer_selected_option") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public class NewCheckboxAnswerSelectedOption { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "checkbox_answer_id", nullable = false, insertable = false, updatable = false) + private long checkboxAnswerId; + + @Column(name = "selected_option_id", nullable = false) + private long selectedOptionId; + + public NewCheckboxAnswerSelectedOption(long selectedOptionId) { + this.selectedOptionId = selectedOptionId; + } +} diff --git a/backend/src/main/java/reviewme/review/domain/abstraction/NewReview.java b/backend/src/main/java/reviewme/review/domain/abstraction/NewReview.java new file mode 100644 index 000000000..281852795 --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/abstraction/NewReview.java @@ -0,0 +1,74 @@ +package reviewme.review.domain.abstraction; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "new_review") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public class NewReview { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "template_id", nullable = false) + private long templateId; + + @Column(name = "review_group_id", nullable = false) + private long reviewGroupId; + + @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.PERSIST) + @JoinColumn(name = "review_id", nullable = false, updatable = false) + private List answers; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + public NewReview(long templateId, long reviewGroupId, List answers) { + this.templateId = templateId; + this.reviewGroupId = reviewGroupId; + this.answers = answers; + this.createdAt = LocalDateTime.now(); + } + + public Set getAnsweredQuestionIds() { + return answers.stream() + .map(Answer::getQuestionId) + .collect(Collectors.toSet()); + } + + public boolean hasAnsweredQuestion(long questionId) { + return getAnsweredQuestionIds().contains(questionId); + } + + public List getAnswersByType(Class clazz) { + return answers.stream() + .filter(clazz::isInstance) + .map(clazz::cast) + .toList(); + } + + public LocalDate getCreatedDate() { + return createdAt.toLocalDate(); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/abstraction/NewReviewRepository.java b/backend/src/main/java/reviewme/review/domain/abstraction/NewReviewRepository.java new file mode 100644 index 000000000..8f933a7af --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/abstraction/NewReviewRepository.java @@ -0,0 +1,40 @@ +package reviewme.review.domain.abstraction; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface NewReviewRepository extends JpaRepository { + + @Query(value = """ + SELECT r.* FROM new_review r + WHERE r.review_group_id = :reviewGroupId + ORDER BY r.created_at DESC + """, nativeQuery = true) + List findAllByGroupId(long reviewGroupId); + + @Query(value = """ + SELECT r.* FROM new_review r + WHERE r.review_group_id = :reviewGroupId + AND (:lastReviewId IS NULL OR r.id < :lastReviewId) + ORDER BY r.created_at DESC, r.id DESC + LIMIT :limit + """, nativeQuery = true) + List findByReviewGroupIdWithLimit(long reviewGroupId, Long lastReviewId, int limit); + + Optional findByIdAndReviewGroupId(long reviewId, long reviewGroupId); + + @Query(value = """ + SELECT COUNT(r.id) FROM new_review r + WHERE r.review_group_id = :reviewGroupId + AND r.id < :reviewId + AND CAST(r.created_at AS DATE) <= :createdDate + """, nativeQuery = true) + Long existsOlderReviewInGroupInLong(long reviewGroupId, long reviewId, LocalDate createdDate); + + default boolean existsOlderReviewInGroup(long reviewGroupId, long reviewId, LocalDate createdDate) { + return existsOlderReviewInGroupInLong(reviewGroupId, reviewId, createdDate) > 0; + } +} diff --git a/backend/src/main/java/reviewme/review/domain/abstraction/NewTextAnswer.java b/backend/src/main/java/reviewme/review/domain/abstraction/NewTextAnswer.java new file mode 100644 index 000000000..1f0b494be --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/abstraction/NewTextAnswer.java @@ -0,0 +1,33 @@ +package reviewme.review.domain.abstraction; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import reviewme.review.domain.exception.QuestionNotAnsweredException; + +@Entity +@Table(name = "new_text_answer") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(callSuper = true) +@Getter +public class NewTextAnswer extends Answer { + + @Column(name = "content", nullable = false, length = 5000) + private String content; + + public NewTextAnswer(long questionId, String content) { + validateContent(questionId, content); + this.questionId = questionId; + this.content = content; + } + + private void validateContent(long questionId, String content) { + if (content == null || content.isEmpty()) { + throw new QuestionNotAnsweredException(questionId); + } + } +} diff --git a/backend/src/main/java/reviewme/review/domain/abstraction/NewTextAnswerRepository.java b/backend/src/main/java/reviewme/review/domain/abstraction/NewTextAnswerRepository.java new file mode 100644 index 000000000..f4257101a --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/abstraction/NewTextAnswerRepository.java @@ -0,0 +1,6 @@ +package reviewme.review.domain.abstraction; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NewTextAnswerRepository extends JpaRepository { +} diff --git a/backend/src/main/java/reviewme/review/service/ReviewRegisterService.java b/backend/src/main/java/reviewme/review/service/ReviewRegisterService.java index 5b3d8291e..64cc334ad 100644 --- a/backend/src/main/java/reviewme/review/service/ReviewRegisterService.java +++ b/backend/src/main/java/reviewme/review/service/ReviewRegisterService.java @@ -1,10 +1,16 @@ package reviewme.review.service; import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import reviewme.review.domain.Review; +import reviewme.review.domain.abstraction.NewReview; +import reviewme.review.domain.abstraction.NewReviewRepository; import reviewme.review.repository.ReviewRepository; +import reviewme.review.service.abstraction.mapper.NewReviewMapper; +import reviewme.review.service.abstraction.validator.NewReviewValidator; import reviewme.review.service.dto.request.ReviewRegisterRequest; import reviewme.review.service.mapper.ReviewMapper; import reviewme.review.service.validator.ReviewValidator; @@ -13,16 +19,28 @@ @RequiredArgsConstructor public class ReviewRegisterService { + private static final Logger log = LoggerFactory.getLogger(ReviewRegisterService.class); private final ReviewMapper reviewMapper; private final ReviewValidator reviewValidator; private final ReviewRepository reviewRepository; + // 리뷰 추상화, 같은 Transactional에 넣어 처리 + private final NewReviewMapper newReviewMapper; + private final NewReviewValidator newReviewValidator; + private final NewReviewRepository newReviewRepository; + @Transactional public long registerReview(ReviewRegisterRequest request) { Review review = reviewMapper.mapToReview(request); reviewValidator.validate(review); Review registeredReview = reviewRepository.save(review); + + // 새로운 테이블에 중복해서 저장 + NewReview newReview = newReviewMapper.mapToReview(request); + newReviewValidator.validate(newReview); + newReviewRepository.save(newReview); + return registeredReview.getId(); } } diff --git a/backend/src/main/java/reviewme/review/service/abstraction/mapper/AnswerMapperFactory.java b/backend/src/main/java/reviewme/review/service/abstraction/mapper/AnswerMapperFactory.java new file mode 100644 index 000000000..f123d7988 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/abstraction/mapper/AnswerMapperFactory.java @@ -0,0 +1,20 @@ +package reviewme.review.service.abstraction.mapper; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reviewme.question.domain.QuestionType; + +@Component +@RequiredArgsConstructor +public class AnswerMapperFactory { + + private final List answerMappers; + + public NewAnswerMapper getAnswerMapper(QuestionType questionType) { + return answerMappers.stream() + .filter(answerMapper -> answerMapper.supports(questionType)) + .findFirst() + .orElseThrow(() -> new UnsupportedQuestionTypeException(questionType)); + } +} diff --git a/backend/src/main/java/reviewme/review/service/abstraction/mapper/NewAnswerMapper.java b/backend/src/main/java/reviewme/review/service/abstraction/mapper/NewAnswerMapper.java new file mode 100644 index 000000000..c8a48675f --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/abstraction/mapper/NewAnswerMapper.java @@ -0,0 +1,12 @@ +package reviewme.review.service.abstraction.mapper; + +import reviewme.question.domain.QuestionType; +import reviewme.review.domain.abstraction.Answer; +import reviewme.review.service.dto.request.ReviewAnswerRequest; + +public interface NewAnswerMapper { + + boolean supports(QuestionType questionType); + + Answer mapToAnswer(ReviewAnswerRequest answerRequest); +} diff --git a/backend/src/main/java/reviewme/review/service/abstraction/mapper/NewCheckboxAnswerMapper.java b/backend/src/main/java/reviewme/review/service/abstraction/mapper/NewCheckboxAnswerMapper.java new file mode 100644 index 000000000..515573af5 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/abstraction/mapper/NewCheckboxAnswerMapper.java @@ -0,0 +1,24 @@ +package reviewme.review.service.abstraction.mapper; + +import org.springframework.stereotype.Component; +import reviewme.question.domain.QuestionType; +import reviewme.review.domain.abstraction.NewCheckboxAnswer; +import reviewme.review.service.dto.request.ReviewAnswerRequest; +import reviewme.review.service.exception.CheckBoxAnswerIncludedTextException; + +@Component +public class NewCheckboxAnswerMapper implements NewAnswerMapper { + + @Override + public boolean supports(QuestionType questionType) { + return questionType == QuestionType.CHECKBOX; + } + + @Override + public NewCheckboxAnswer mapToAnswer(ReviewAnswerRequest answerRequest) { + if (answerRequest.text() != null) { + throw new CheckBoxAnswerIncludedTextException(answerRequest.questionId()); + } + return new NewCheckboxAnswer(answerRequest.questionId(), answerRequest.selectedOptionIds()); + } +} diff --git a/backend/src/main/java/reviewme/review/service/abstraction/mapper/NewReviewMapper.java b/backend/src/main/java/reviewme/review/service/abstraction/mapper/NewReviewMapper.java new file mode 100644 index 000000000..f29e3e576 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/abstraction/mapper/NewReviewMapper.java @@ -0,0 +1,77 @@ +package reviewme.review.service.abstraction.mapper; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reviewme.question.domain.Question; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.abstraction.Answer; +import reviewme.review.domain.abstraction.NewReview; +import reviewme.review.service.dto.request.ReviewAnswerRequest; +import reviewme.review.service.dto.request.ReviewRegisterRequest; +import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.template.domain.Template; +import reviewme.template.repository.TemplateRepository; +import reviewme.template.service.exception.TemplateNotFoundByReviewGroupException; + +@Component +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public class NewReviewMapper { + + private final AnswerMapperFactory answerMapperFactory; + private final ReviewGroupRepository reviewGroupRepository; + private final QuestionRepository questionRepository; + private final TemplateRepository templateRepository; + + public NewReview mapToReview(ReviewRegisterRequest request) { + ReviewGroup reviewGroup = reviewGroupRepository.findByReviewRequestCode(request.reviewRequestCode()) + .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(request.reviewRequestCode())); + Template template = templateRepository.findById(reviewGroup.getTemplateId()) + .orElseThrow(() -> new TemplateNotFoundByReviewGroupException( + reviewGroup.getId(), reviewGroup.getTemplateId() + )); + + List answers = getAnswersByQuestionType(request); + return new NewReview(template.getId(), reviewGroup.getId(), answers); + } + + private List getAnswersByQuestionType(ReviewRegisterRequest request) { + List questionIds = request.answers() + .stream() + .map(ReviewAnswerRequest::questionId) + .toList(); + + Map questionMap = questionRepository.findAllById(questionIds) + .stream() + .collect(Collectors.toMap(Question::getId, Function.identity())); + + return request.answers() + .stream() + .map(answerRequest -> mapRequestToAnswer(questionMap, answerRequest)) + .filter(Objects::nonNull) + .toList(); + } + + private Answer mapRequestToAnswer(Map questions, ReviewAnswerRequest answerRequest) { + Question question = questions.get(answerRequest.questionId()); + + // TODO: 아래 코드를 삭제해야 한다 + if (question.isSelectable() && answerRequest.selectedOptionIds() != null && answerRequest.selectedOptionIds().isEmpty()) { + return null; + } + if (!question.isSelectable() && answerRequest.text() != null && answerRequest.text().isEmpty()) { + return null; + } + // END + + NewAnswerMapper answerMapper = answerMapperFactory.getAnswerMapper(question.getQuestionType()); + return answerMapper.mapToAnswer(answerRequest); + } +} diff --git a/backend/src/main/java/reviewme/review/service/abstraction/mapper/NewTextAnswerMapper.java b/backend/src/main/java/reviewme/review/service/abstraction/mapper/NewTextAnswerMapper.java new file mode 100644 index 000000000..4b018c989 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/abstraction/mapper/NewTextAnswerMapper.java @@ -0,0 +1,27 @@ +package reviewme.review.service.abstraction.mapper; + +import org.springframework.stereotype.Component; +import reviewme.question.domain.QuestionType; +import reviewme.review.domain.abstraction.NewTextAnswer; +import reviewme.review.service.dto.request.ReviewAnswerRequest; +import reviewme.review.service.exception.TextAnswerIncludedOptionItemException; + +@Component +public class NewTextAnswerMapper implements NewAnswerMapper { + + @Override + public boolean supports(QuestionType questionType) { + return questionType == QuestionType.TEXT; + } + + @Override + public NewTextAnswer mapToAnswer(ReviewAnswerRequest answerRequest) { + if (!answerRequest.hasTextAnswer()) { + return null; + } + if (answerRequest.selectedOptionIds() != null) { + throw new TextAnswerIncludedOptionItemException(answerRequest.questionId()); + } + return new NewTextAnswer(answerRequest.questionId(), answerRequest.text()); + } +} diff --git a/backend/src/main/java/reviewme/review/service/abstraction/mapper/UnsupportedQuestionTypeException.java b/backend/src/main/java/reviewme/review/service/abstraction/mapper/UnsupportedQuestionTypeException.java new file mode 100644 index 000000000..a3e83d660 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/abstraction/mapper/UnsupportedQuestionTypeException.java @@ -0,0 +1,14 @@ +package reviewme.review.service.abstraction.mapper; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.DataInconsistencyException; +import reviewme.question.domain.QuestionType; + +@Slf4j +public class UnsupportedQuestionTypeException extends DataInconsistencyException { + + public UnsupportedQuestionTypeException(QuestionType questionType) { + super("서버 내부 오류입니다. 관리자에게 문의해주세요."); + log.error("Unsupported question type for mapping - questionType: {}", questionType); + } +} diff --git a/backend/src/main/java/reviewme/review/service/abstraction/validator/AnswerValidatorFactory.java b/backend/src/main/java/reviewme/review/service/abstraction/validator/AnswerValidatorFactory.java new file mode 100644 index 000000000..98c2ac5c9 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/abstraction/validator/AnswerValidatorFactory.java @@ -0,0 +1,21 @@ +package reviewme.review.service.abstraction.validator; + +import java.util.List; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reviewme.review.domain.abstraction.Answer; + +@Component +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public class AnswerValidatorFactory { + + private final List answerValidators; + + public NewAnswerValidator getAnswerValidator(Class answerClass) { + return answerValidators.stream() + .filter(validator -> validator.supports(answerClass)) + .findFirst() + .orElseThrow(() -> new UnsupportedAnswerTypeException(answerClass)); + } +} diff --git a/backend/src/main/java/reviewme/review/service/abstraction/validator/NewAnswerValidator.java b/backend/src/main/java/reviewme/review/service/abstraction/validator/NewAnswerValidator.java new file mode 100644 index 000000000..05e36e1e7 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/abstraction/validator/NewAnswerValidator.java @@ -0,0 +1,10 @@ +package reviewme.review.service.abstraction.validator; + +import reviewme.review.domain.abstraction.Answer; + +public interface NewAnswerValidator { + + boolean supports(Class answerClass); + + void validate(Answer answer); +} diff --git a/backend/src/main/java/reviewme/review/service/abstraction/validator/NewCheckboxAnswerValidator.java b/backend/src/main/java/reviewme/review/service/abstraction/validator/NewCheckboxAnswerValidator.java new file mode 100644 index 000000000..6afdc754b --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/abstraction/validator/NewCheckboxAnswerValidator.java @@ -0,0 +1,82 @@ +package reviewme.review.service.abstraction.validator; + +import java.util.HashSet; +import java.util.List; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.Question; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.abstraction.Answer; +import reviewme.review.domain.abstraction.NewCheckboxAnswerSelectedOption; +import reviewme.review.domain.abstraction.NewCheckboxAnswer; +import reviewme.review.service.exception.CheckBoxAnswerIncludedNotProvidedOptionItemException; +import reviewme.review.service.exception.OptionGroupNotFoundByQuestionIdException; +import reviewme.review.service.exception.SelectedOptionItemCountOutOfRangeException; +import reviewme.review.service.exception.SubmittedQuestionNotFoundException; + +@Component +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public class NewCheckboxAnswerValidator implements NewAnswerValidator { + + private final QuestionRepository questionRepository; + private final OptionGroupRepository optionGroupRepository; + private final OptionItemRepository optionItemRepository; + + @Override + public boolean supports(Class answerClass) { + return NewCheckboxAnswer.class.isAssignableFrom(answerClass); + } + + @Override + public void validate(Answer answer) { + NewCheckboxAnswer checkboxAnswer = (NewCheckboxAnswer) answer; + Question question = questionRepository.findById(checkboxAnswer.getQuestionId()) + .orElseThrow(() -> new SubmittedQuestionNotFoundException(checkboxAnswer.getQuestionId())); + + OptionGroup optionGroup = optionGroupRepository.findByQuestionId(question.getId()) + .orElseThrow(() -> new OptionGroupNotFoundByQuestionIdException(question.getId())); + + validateOnlyIncludingProvidedOptionItem(checkboxAnswer, optionGroup); + validateCheckedOptionItemCount(checkboxAnswer, optionGroup); + } + + private void validateOnlyIncludingProvidedOptionItem(NewCheckboxAnswer checkboxAnswer, OptionGroup optionGroup) { + List providedOptionItemIds = optionItemRepository.findAllByOptionGroupId(optionGroup.getId()) + .stream() + .map(OptionItem::getId) + .toList(); + List answeredOptionItemIds = extractAnsweredOptionItemIds(checkboxAnswer); + + if (!new HashSet<>(providedOptionItemIds).containsAll(answeredOptionItemIds)) { + throw new CheckBoxAnswerIncludedNotProvidedOptionItemException( + checkboxAnswer.getQuestionId(), providedOptionItemIds, answeredOptionItemIds + ); + } + } + + private void validateCheckedOptionItemCount(NewCheckboxAnswer checkboxAnswer, OptionGroup optionGroup) { + int answeredOptionItemCount = extractAnsweredOptionItemIds(checkboxAnswer).size(); + + if (answeredOptionItemCount < optionGroup.getMinSelectionCount() + || answeredOptionItemCount > optionGroup.getMaxSelectionCount()) { + throw new SelectedOptionItemCountOutOfRangeException( + checkboxAnswer.getQuestionId(), + answeredOptionItemCount, + optionGroup.getMinSelectionCount(), + optionGroup.getMaxSelectionCount() + ); + } + } + + private List extractAnsweredOptionItemIds(NewCheckboxAnswer checkboxAnswer) { + return checkboxAnswer.getSelectedOptionIds() + .stream() + .map(NewCheckboxAnswerSelectedOption::getSelectedOptionId) + .toList(); + } +} diff --git a/backend/src/main/java/reviewme/review/service/abstraction/validator/NewReviewValidator.java b/backend/src/main/java/reviewme/review/service/abstraction/validator/NewReviewValidator.java new file mode 100644 index 000000000..cad44d493 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/abstraction/validator/NewReviewValidator.java @@ -0,0 +1,82 @@ +package reviewme.review.service.abstraction.validator; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reviewme.question.domain.Question; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.abstraction.Answer; +import reviewme.review.domain.abstraction.NewCheckboxAnswerSelectedOption; +import reviewme.review.domain.abstraction.NewCheckboxAnswer; +import reviewme.review.domain.abstraction.NewReview; +import reviewme.review.service.exception.MissingRequiredQuestionException; +import reviewme.review.service.exception.SubmittedQuestionAndProvidedQuestionMismatchException; +import reviewme.template.domain.Section; +import reviewme.template.domain.SectionQuestion; +import reviewme.template.repository.SectionRepository; + +@Component +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public class NewReviewValidator { + + private final AnswerValidatorFactory answerValidatorFactory; + + private final SectionRepository sectionRepository; + private final QuestionRepository questionRepository; + + public void validate(NewReview review) { + validateAnswer(review.getAnswers()); + validateAllAnswersContainedInTemplate(review); + validateAllRequiredQuestionsAnswered(review); + } + + private void validateAnswer(List answers) { + for (Answer answer : answers) { + NewAnswerValidator validator = answerValidatorFactory.getAnswerValidator(answer.getClass()); + validator.validate(answer); + } + } + + private void validateAllAnswersContainedInTemplate(NewReview review) { + Set providedQuestionIds = questionRepository.findAllQuestionIdByTemplateId(review.getTemplateId()); + Set reviewedQuestionIds = review.getAnsweredQuestionIds(); + if (!providedQuestionIds.containsAll(reviewedQuestionIds)) { + throw new SubmittedQuestionAndProvidedQuestionMismatchException(reviewedQuestionIds, providedQuestionIds); + } + } + + private void validateAllRequiredQuestionsAnswered(NewReview review) { + Set displayedQuestionIds = extractDisplayedQuestionIds(review); + Set requiredQuestionIds = questionRepository.findAllById(displayedQuestionIds) + .stream() + .filter(Question::isRequired) + .map(Question::getId) + .collect(Collectors.toSet()); + + Set reviewedQuestionIds = review.getAnsweredQuestionIds(); + if (!reviewedQuestionIds.containsAll(requiredQuestionIds)) { + List missingRequiredQuestionIds = new ArrayList<>(requiredQuestionIds); + missingRequiredQuestionIds.removeAll(reviewedQuestionIds); + throw new MissingRequiredQuestionException(missingRequiredQuestionIds); + } + } + + private Set extractDisplayedQuestionIds(NewReview review) { + Set selectedOptionIds = review.getAnswersByType(NewCheckboxAnswer.class) + .stream() + .flatMap(answer -> answer.getSelectedOptionIds().stream()) + .map(NewCheckboxAnswerSelectedOption::getSelectedOptionId) + .collect(Collectors.toSet()); + List
sections = sectionRepository.findAllByTemplateId(review.getTemplateId()); + + return sections.stream() + .filter(section -> section.isVisibleBySelectedOptionIds(selectedOptionIds)) + .flatMap(section -> section.getQuestionIds().stream()) + .map(SectionQuestion::getQuestionId) + .collect(Collectors.toSet()); + } +} diff --git a/backend/src/main/java/reviewme/review/service/abstraction/validator/NewTextAnswerValidator.java b/backend/src/main/java/reviewme/review/service/abstraction/validator/NewTextAnswerValidator.java new file mode 100644 index 000000000..2cf49b42c --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/abstraction/validator/NewTextAnswerValidator.java @@ -0,0 +1,48 @@ +package reviewme.review.service.abstraction.validator; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reviewme.question.domain.Question; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.abstraction.Answer; +import reviewme.review.domain.abstraction.NewTextAnswer; +import reviewme.review.service.exception.InvalidTextAnswerLengthException; +import reviewme.review.service.exception.SubmittedQuestionNotFoundException; + +@Component +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public class NewTextAnswerValidator implements NewAnswerValidator { + + private static final int ZERO_LENGTH = 0; + private static final int MIN_LENGTH = 20; + private static final int MAX_LENGTH = 1_000; + + private final QuestionRepository questionRepository; + + @Override + public boolean supports(Class answerClass) { + return NewTextAnswer.class.isAssignableFrom(answerClass); + } + + @Override + public void validate(Answer answer) { + NewTextAnswer textAnswer = (NewTextAnswer) answer; + Question question = questionRepository.findById(textAnswer.getQuestionId()) + .orElseThrow(() -> new SubmittedQuestionNotFoundException(textAnswer.getQuestionId())); + + validateLength(textAnswer, question); + } + + private void validateLength(NewTextAnswer textAnswer, Question question) { + int answerLength = textAnswer.getContent().length(); + + if (question.isRequired() && (answerLength < MIN_LENGTH || answerLength > MAX_LENGTH)) { + throw new InvalidTextAnswerLengthException(question.getId(), answerLength, MIN_LENGTH, MAX_LENGTH); + } + + if (!question.isRequired() && answerLength > MAX_LENGTH) { + throw new InvalidTextAnswerLengthException(question.getId(), answerLength, MAX_LENGTH); + } + } +} diff --git a/backend/src/main/java/reviewme/review/service/abstraction/validator/UnsupportedAnswerTypeException.java b/backend/src/main/java/reviewme/review/service/abstraction/validator/UnsupportedAnswerTypeException.java new file mode 100644 index 000000000..ca545a999 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/abstraction/validator/UnsupportedAnswerTypeException.java @@ -0,0 +1,13 @@ +package reviewme.review.service.abstraction.validator; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.DataInconsistencyException; + +@Slf4j +public class UnsupportedAnswerTypeException extends DataInconsistencyException { + + public UnsupportedAnswerTypeException(Class answerClass) { + super("서버 내부 오류입니다. 관리자에게 문의해주세요."); + log.error("Unsupported answer class for validation - answerClass: {}", answerClass); + } +} diff --git a/backend/src/main/resources/db/migration/V2__answer_abstraction.sql b/backend/src/main/resources/db/migration/V2__answer_abstraction.sql new file mode 100644 index 000000000..f28e19828 --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__answer_abstraction.sql @@ -0,0 +1,54 @@ +-- answer 테이블을 추상화합니다. 아래와 같은 5개의 마이그레이션 테이블을 생성합니다. +-- ALTER TABLE로 dev, prod에 auto_increment를 특정 수로 초기화해야 합니다. + +CREATE TABLE new_review +( + id BIGINT NOT NULL AUTO_INCREMENT, + created_at TIMESTAMP(6) NOT NULL, + review_group_id BIGINT NOT NULL, + template_id BIGINT NOT NULL, + PRIMARY KEY (id) +); + +CREATE TABLE answer +( + id BIGINT NOT NULL AUTO_INCREMENT, + question_id BIGINT NOT NULL, + review_id BIGINT NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (review_id) REFERENCES new_review (id) +); + +CREATE TABLE new_checkbox_answer +( + id BIGINT NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (id) REFERENCES answer (id) +); + +CREATE TABLE new_text_answer +( + id BIGINT NOT NULL, + content varchar(5000) NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (id) REFERENCES answer (id) +); + +CREATE TABLE new_checkbox_answer_selected_option +( + id BIGINT NOT NULL AUTO_INCREMENT, + checkbox_answer_id BIGINT NOT NULL, + selected_option_id BIGINT NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (checkbox_answer_id) REFERENCES new_checkbox_answer (id) +); + +-- MYSQL에서는 아래와 같이 초기화합니다. +ALTER TABLE new_review + AUTO_INCREMENT = 1500000; +ALTER TABLE new_checkbox_answer + AUTO_INCREMENT = 1500000; +ALTER TABLE new_text_answer + AUTO_INCREMENT = 1500000; +ALTER TABLE new_checkbox_answer_selected_option + AUTO_INCREMENT = 1500000; diff --git a/backend/src/test/java/reviewme/review/domain/abstraction/NewCheckboxAnswerTest.java b/backend/src/test/java/reviewme/review/domain/abstraction/NewCheckboxAnswerTest.java new file mode 100644 index 000000000..891448e6e --- /dev/null +++ b/backend/src/test/java/reviewme/review/domain/abstraction/NewCheckboxAnswerTest.java @@ -0,0 +1,22 @@ +package reviewme.review.domain.abstraction; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.util.List; +import org.junit.jupiter.api.Test; +import reviewme.review.domain.exception.QuestionNotAnsweredException; + +class NewCheckboxAnswerTest { + + @Test + void 답변이_없는_경우_예외를_발생한다() { + // given, when, then + assertAll( + () -> assertThatThrownBy(() -> new NewCheckboxAnswer(1L, null)) + .isInstanceOf(QuestionNotAnsweredException.class), + () -> assertThatThrownBy(() -> new NewCheckboxAnswer(1L, List.of())) + .isInstanceOf(QuestionNotAnsweredException.class) + ); + } +} diff --git a/backend/src/test/java/reviewme/review/domain/abstraction/NewReviewTest.java b/backend/src/test/java/reviewme/review/domain/abstraction/NewReviewTest.java new file mode 100644 index 000000000..3bda91a4c --- /dev/null +++ b/backend/src/test/java/reviewme/review/domain/abstraction/NewReviewTest.java @@ -0,0 +1,57 @@ +package reviewme.review.domain.abstraction; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class NewReviewTest { + + @Test + void 리뷰에_등록된_답변의_모든_질문들을_반환한다() { + // given + NewTextAnswer textAnswer = new NewTextAnswer(1L, "답변"); + NewCheckboxAnswer checkboxAnswer = new NewCheckboxAnswer(2L, List.of(1L)); + NewReview review = new NewReview(1L, 1L, List.of(textAnswer, checkboxAnswer)); + + // when + Set allQuestionIdsFromAnswers = review.getAnsweredQuestionIds(); + + // then + assertThat(allQuestionIdsFromAnswers).containsAll(List.of(1L, 2L)); + } + + @Test + void 리뷰에_등록된_타입에_따라_답변을_반환한다() { + // given + NewCheckboxAnswer checkboxAnswer1 = new NewCheckboxAnswer(1L, List.of(1L, 2L)); + NewCheckboxAnswer checkboxAnswer2 = new NewCheckboxAnswer(1L, List.of(3L, 4L)); + NewTextAnswer textAnswer = new NewTextAnswer(1L, "답변"); + NewReview review = new NewReview(1L, 1L, List.of(checkboxAnswer1, checkboxAnswer2, textAnswer)); + + // when + List allQuestionIdsFromAnswers = review.getAnswersByType(NewCheckboxAnswer.class); + + // then + assertThat(allQuestionIdsFromAnswers).containsAll(List.of(checkboxAnswer1, checkboxAnswer2)); + } + + @Test + void 리뷰에_특정_질문에_대한_답변이_있는지_여부를_반환한다() { + // given + long textQuestionId = 1L; + long checkBoxQuestionId = 2L; + + NewTextAnswer textAnswer = new NewTextAnswer(textQuestionId, "답변"); + NewCheckboxAnswer checkboxAnswer = new NewCheckboxAnswer(checkBoxQuestionId, List.of(1L)); + NewReview review = new NewReview(1L, 1L, List.of(textAnswer, checkboxAnswer)); + + // when, then + assertAll( + () -> assertThat(review.hasAnsweredQuestion(textQuestionId)).isTrue(), + () -> assertThat(review.hasAnsweredQuestion(checkBoxQuestionId)).isTrue() + ); + } +} diff --git a/backend/src/test/java/reviewme/review/domain/abstraction/NewTextAnswerTest.java b/backend/src/test/java/reviewme/review/domain/abstraction/NewTextAnswerTest.java new file mode 100644 index 000000000..084b4c362 --- /dev/null +++ b/backend/src/test/java/reviewme/review/domain/abstraction/NewTextAnswerTest.java @@ -0,0 +1,21 @@ +package reviewme.review.domain.abstraction; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import org.junit.jupiter.api.Test; +import reviewme.review.domain.exception.QuestionNotAnsweredException; + +class NewTextAnswerTest { + + @Test + void 답변이_없는_경우_예외를_발생한다() { + // given, when, then + assertAll( + () -> assertThatThrownBy(() -> new NewTextAnswer(1L, null)) + .isInstanceOf(QuestionNotAnsweredException.class), + () -> assertThatThrownBy(() -> new NewTextAnswer(1L, "")) + .isInstanceOf(QuestionNotAnsweredException.class) + ); + } +} diff --git a/backend/src/test/java/reviewme/review/service/abstraction/mapper/AnswerMapperFactoryTest.java b/backend/src/test/java/reviewme/review/service/abstraction/mapper/AnswerMapperFactoryTest.java new file mode 100644 index 000000000..18ac46e46 --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/abstraction/mapper/AnswerMapperFactoryTest.java @@ -0,0 +1,54 @@ +package reviewme.review.service.abstraction.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import reviewme.question.domain.QuestionType; +import reviewme.review.domain.abstraction.Answer; +import reviewme.review.service.dto.request.ReviewAnswerRequest; + +@ExtendWith(OutputCaptureExtension.class) +class AnswerMapperFactoryTest { + + private final NewAnswerMapper answerMapper = new NewAnswerMapper() { + + @Override + public boolean supports(QuestionType questionType) { + return questionType == QuestionType.CHECKBOX; + } + + @Override + public Answer mapToAnswer(ReviewAnswerRequest answerRequest) { + return null; + } + }; + + @Test + void 지원하는_타입에_따른_매퍼를_가져온다() { + // given + List answerMappers = List.of(answerMapper); + AnswerMapperFactory factory = new AnswerMapperFactory(answerMappers); + + // when + NewAnswerMapper actual = factory.getAnswerMapper(QuestionType.CHECKBOX); + + // then + assertThat(answerMapper).isEqualTo(actual); + } + + @Test + void 지원하지_않는_타입에_대한_매퍼_요청_시_예외가_발생한다(CapturedOutput output) { + // given + AnswerMapperFactory factory = new AnswerMapperFactory(List.of()); + + // when, then + assertThatThrownBy(() -> factory.getAnswerMapper(QuestionType.TEXT)) + .isInstanceOf(UnsupportedQuestionTypeException.class); + assertThat(output).contains("Unsupported", "TEXT"); + } +} diff --git a/backend/src/test/java/reviewme/review/service/abstraction/mapper/NewCheckboxAnswerMapperTest.java b/backend/src/test/java/reviewme/review/service/abstraction/mapper/NewCheckboxAnswerMapperTest.java new file mode 100644 index 000000000..6e9e352fc --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/abstraction/mapper/NewCheckboxAnswerMapperTest.java @@ -0,0 +1,43 @@ +package reviewme.review.service.abstraction.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.Test; +import reviewme.review.domain.abstraction.NewCheckboxAnswer; +import reviewme.review.domain.abstraction.NewCheckboxAnswerSelectedOption; +import reviewme.review.service.dto.request.ReviewAnswerRequest; +import reviewme.review.service.exception.CheckBoxAnswerIncludedTextException; + +class NewCheckboxAnswerMapperTest { + + @Test + void 체크박스_답변을_요청으로부터_매핑한다() { + // given + ReviewAnswerRequest request = new ReviewAnswerRequest(1L, List.of(1L, 2L, 3L), null); + NewCheckboxAnswerMapper mapper = new NewCheckboxAnswerMapper(); + + // when + NewCheckboxAnswer actual = mapper.mapToAnswer(request); + + // then + assertThat(actual.getQuestionId()).isEqualTo(1L); + assertThat(actual.getSelectedOptionIds()) + .extracting(NewCheckboxAnswerSelectedOption::getSelectedOptionId) + .containsExactly(1L, 2L, 3L); + } + + @Test + void 체크박스_답변_요청에_텍스트가_포함되어_있으면_예외를_발생시킨다() { + // given + ReviewAnswerRequest request = new ReviewAnswerRequest(1L, List.of(1L, 2L, 3L), "text"); + + // when + NewCheckboxAnswerMapper mapper = new NewCheckboxAnswerMapper(); + + // then + assertThatThrownBy(() -> mapper.mapToAnswer(request)) + .isInstanceOf(CheckBoxAnswerIncludedTextException.class); + } +} diff --git a/backend/src/test/java/reviewme/review/service/abstraction/mapper/NewReviewMapperTest.java b/backend/src/test/java/reviewme/review/service/abstraction/mapper/NewReviewMapperTest.java new file mode 100644 index 000000000..dc710cbb1 --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/abstraction/mapper/NewReviewMapperTest.java @@ -0,0 +1,176 @@ +package reviewme.review.service.abstraction.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static reviewme.fixture.OptionGroupFixture.선택지_그룹; +import static reviewme.fixture.OptionItemFixture.선택지; +import static reviewme.fixture.QuestionFixture.서술형_옵션_질문; +import static reviewme.fixture.QuestionFixture.서술형_필수_질문; +import static reviewme.fixture.QuestionFixture.선택형_옵션_질문; +import static reviewme.fixture.QuestionFixture.선택형_필수_질문; +import static reviewme.fixture.ReviewGroupFixture.리뷰_그룹; +import static reviewme.fixture.SectionFixture.항상_보이는_섹션; +import static reviewme.fixture.TemplateFixture.템플릿; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.Question; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.abstraction.NewCheckboxAnswer; +import reviewme.review.domain.abstraction.NewReview; +import reviewme.review.domain.abstraction.NewTextAnswer; +import reviewme.review.service.dto.request.ReviewAnswerRequest; +import reviewme.review.service.dto.request.ReviewRegisterRequest; +import reviewme.review.service.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.support.ServiceTest; +import reviewme.template.domain.Section; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; + +@ServiceTest +class NewReviewMapperTest { + + @Autowired + private NewReviewMapper reviewMapper; + + @Autowired + private ReviewGroupRepository reviewGroupRepository; + + @Autowired + private OptionGroupRepository optionGroupRepository; + + @Autowired + private OptionItemRepository optionItemRepository; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private SectionRepository sectionRepository; + + @Autowired + private TemplateRepository templateRepository; + + @Test + void 텍스트가_포함된_리뷰를_생성한다() { + // given + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + + Question question = questionRepository.save(서술형_필수_질문()); + Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); + templateRepository.save(템플릿(List.of(section.getId()))); + + String expectedTextAnswer = "답".repeat(20); + ReviewAnswerRequest reviewAnswerRequest = new ReviewAnswerRequest(question.getId(), null, expectedTextAnswer); + ReviewRegisterRequest reviewRegisterRequest = new ReviewRegisterRequest(reviewGroup.getReviewRequestCode(), + List.of(reviewAnswerRequest)); + + // when + NewReview review = reviewMapper.mapToReview(reviewRegisterRequest); + + // then + assertThat(review.getAnswersByType(NewTextAnswer.class)).hasSize(1); + } + + @Test + void 체크박스가_포함된_리뷰를_생성한다() { + // given + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + + Question question = questionRepository.save(선택형_필수_질문()); + OptionGroup optionGroup = optionGroupRepository.save(선택지_그룹(question.getId())); + OptionItem optionItem1 = optionItemRepository.save(선택지(optionGroup.getId())); + OptionItem optionItem2 = optionItemRepository.save(선택지(optionGroup.getId())); + + Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); + templateRepository.save(템플릿(List.of(section.getId()))); + + ReviewAnswerRequest reviewAnswerRequest = new ReviewAnswerRequest(question.getId(), + List.of(optionItem1.getId()), null); + ReviewRegisterRequest reviewRegisterRequest = new ReviewRegisterRequest(reviewGroup.getReviewRequestCode(), + List.of(reviewAnswerRequest)); + + // when + NewReview review = reviewMapper.mapToReview(reviewRegisterRequest); + + // then + assertThat(review.getAnswersByType(NewCheckboxAnswer.class)).hasSize(1); + } + + @Test + void 필수가_아닌_질문에_답변이_없을_경우_답변을_생성하지_않는다() { + // given + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + + Question requiredTextQuestion = questionRepository.save(서술형_필수_질문()); + Question optionalTextQuestion = questionRepository.save(서술형_옵션_질문()); + + Question requeiredCheckBoxQuestion = questionRepository.save(선택형_필수_질문()); + OptionGroup optionGroup1 = optionGroupRepository.save(선택지_그룹(requeiredCheckBoxQuestion.getId())); + OptionItem optionItem1 = optionItemRepository.save(선택지(optionGroup1.getId())); + OptionItem optionItem2 = optionItemRepository.save(선택지(optionGroup1.getId())); + + Question optionalCheckBoxQuestion = questionRepository.save(선택형_옵션_질문()); + OptionGroup optionGroup2 = optionGroupRepository.save(선택지_그룹(optionalCheckBoxQuestion.getId())); + OptionItem optionItem3 = optionItemRepository.save(선택지(optionGroup2.getId())); + OptionItem optionItem4 = optionItemRepository.save(선택지(optionGroup2.getId())); + + Section section = sectionRepository.save(항상_보이는_섹션( + List.of(requiredTextQuestion.getId(), optionalTextQuestion.getId(), + requeiredCheckBoxQuestion.getId(), optionalCheckBoxQuestion.getId()))); + templateRepository.save(템플릿(List.of(section.getId()))); + + String textAnswer = "답".repeat(20); + ReviewAnswerRequest requiredTextAnswerRequest = new ReviewAnswerRequest( + requiredTextQuestion.getId(), null, textAnswer + ); + ReviewAnswerRequest optionalTextAnswerRequest = new ReviewAnswerRequest( + optionalTextQuestion.getId(), null, "" + ); + ReviewAnswerRequest requiredCheckBoxAnswerRequest = new ReviewAnswerRequest( + requeiredCheckBoxQuestion.getId(), List.of(optionItem1.getId()), null + ); + ReviewAnswerRequest optionalCheckBoxAnswerRequest = new ReviewAnswerRequest( + optionalCheckBoxQuestion.getId(), List.of(), null + ); + ReviewRegisterRequest reviewRegisterRequest = new ReviewRegisterRequest(reviewGroup.getReviewRequestCode(), + List.of(requiredTextAnswerRequest, optionalTextAnswerRequest, + requiredCheckBoxAnswerRequest, optionalCheckBoxAnswerRequest)); + + // when + NewReview review = reviewMapper.mapToReview(reviewRegisterRequest); + + // then + assertAll( + () -> assertThat(review.getAnswersByType(NewTextAnswer.class)) + .extracting(NewTextAnswer::getQuestionId) + .containsExactly(requiredTextQuestion.getId()), + () -> assertThat(review.getAnswersByType(NewCheckboxAnswer.class)) + .extracting(NewCheckboxAnswer::getQuestionId) + .containsExactly(requeiredCheckBoxQuestion.getId()) + ); + } + + @Test + void 잘못된_리뷰_요청_코드로_리뷰를_생성할_경우_예외가_발생한다() { + // given + String reviewRequestCode = "notExistCode"; + Question savedQuestion = questionRepository.save(서술형_필수_질문()); + ReviewAnswerRequest emptyTextReviewRequest = new ReviewAnswerRequest( + savedQuestion.getId(), null, ""); + ReviewRegisterRequest reviewRegisterRequest = new ReviewRegisterRequest( + reviewRequestCode, List.of(emptyTextReviewRequest)); + + // when, then + assertThatThrownBy(() -> reviewMapper.mapToReview(reviewRegisterRequest)) + .isInstanceOf(ReviewGroupNotFoundByReviewRequestCodeException.class); + } +} diff --git a/backend/src/test/java/reviewme/review/service/abstraction/mapper/NewTextAnswerMapperTest.java b/backend/src/test/java/reviewme/review/service/abstraction/mapper/NewTextAnswerMapperTest.java new file mode 100644 index 000000000..5feb97e7b --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/abstraction/mapper/NewTextAnswerMapperTest.java @@ -0,0 +1,46 @@ +package reviewme.review.service.abstraction.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.Test; +import reviewme.review.domain.abstraction.NewTextAnswer; +import reviewme.review.service.dto.request.ReviewAnswerRequest; +import reviewme.review.service.exception.TextAnswerIncludedOptionItemException; + +class NewTextAnswerMapperTest { + + /* + TODO: Request를 추상화해야 할까요? + 떠오르는 방법은 아래와 같습니다. + 1: static factory method를 사용 -> 걷잡을 수 없어지지 않을까요? + 2: 다른 방식으로 추상화 ? + */ + + @Test + void 텍스트_답변을_요청으로부터_매핑한다() { + // given + ReviewAnswerRequest request = new ReviewAnswerRequest(1L, null, "text"); + + // when + NewTextAnswerMapper mapper = new NewTextAnswerMapper(); + NewTextAnswer actual = mapper.mapToAnswer(request); + + // then + assertThat(actual.getContent()).isEqualTo("text"); + } + + @Test + void 텍스트_답변_요청에_옵션이_포함되어_있으면_예외를_발생시킨다() { + // given + ReviewAnswerRequest request = new ReviewAnswerRequest(1L, List.of(1L), "text"); + + // when + NewTextAnswerMapper mapper = new NewTextAnswerMapper(); + + // then + assertThatThrownBy(() -> mapper.mapToAnswer(request)) + .isInstanceOf(TextAnswerIncludedOptionItemException.class); + } +} diff --git a/backend/src/test/java/reviewme/review/service/abstraction/validator/AnswerValidatorFactoryTest.java b/backend/src/test/java/reviewme/review/service/abstraction/validator/AnswerValidatorFactoryTest.java new file mode 100644 index 000000000..678526204 --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/abstraction/validator/AnswerValidatorFactoryTest.java @@ -0,0 +1,47 @@ +package reviewme.review.service.abstraction.validator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.Test; +import reviewme.review.domain.abstraction.Answer; +import reviewme.review.domain.abstraction.NewCheckboxAnswer; + +class AnswerValidatorFactoryTest { + + private final NewAnswerValidator validator = new NewAnswerValidator() { + + @Override + public boolean supports(Class answerClass) { + return answerClass.equals(NewCheckboxAnswer.class); + } + + @Override + public void validate(Answer answer) { + } + }; + + @Test + void 지원하는_타입에_따른_밸리데이터를_가져온다() { + // given + List validators = List.of(validator); + AnswerValidatorFactory factory = new AnswerValidatorFactory(validators); + + // when + NewAnswerValidator actual = factory.getAnswerValidator(NewCheckboxAnswer.class); + + // then + assertThat(actual).isEqualTo(validator); + } + + @Test + void 지원하지_않는_타입에_대한_밸리데이터_요청_시_예외가_발생한다() { + // given + AnswerValidatorFactory factory = new AnswerValidatorFactory(List.of()); + + // when, then + assertThatThrownBy(() -> factory.getAnswerValidator(NewCheckboxAnswer.class)) + .isInstanceOf(UnsupportedAnswerTypeException.class); + } +} diff --git a/backend/src/test/java/reviewme/review/service/abstraction/validator/NewCheckboxAnswerValidatorTest.java b/backend/src/test/java/reviewme/review/service/abstraction/validator/NewCheckboxAnswerValidatorTest.java new file mode 100644 index 000000000..c18ce8723 --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/abstraction/validator/NewCheckboxAnswerValidatorTest.java @@ -0,0 +1,110 @@ +package reviewme.review.service.abstraction.validator; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static reviewme.fixture.OptionGroupFixture.선택지_그룹; +import static reviewme.fixture.OptionItemFixture.선택지; +import static reviewme.fixture.QuestionFixture.선택형_필수_질문; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.Question; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.abstraction.NewCheckboxAnswer; +import reviewme.review.service.exception.CheckBoxAnswerIncludedNotProvidedOptionItemException; +import reviewme.review.service.exception.OptionGroupNotFoundByQuestionIdException; +import reviewme.review.service.exception.SelectedOptionItemCountOutOfRangeException; +import reviewme.review.service.exception.SubmittedQuestionNotFoundException; +import reviewme.support.ServiceTest; + +@ServiceTest +class NewCheckboxAnswerValidatorTest { + + @Autowired + private NewCheckboxAnswerValidator checkBoxAnswerValidator; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private OptionGroupRepository optionGroupRepository; + + @Autowired + private OptionItemRepository optionItemRepository; + + @Test + void 저장되지_않은_질문에_대한_답변이면_예외가_발생한다() { + // given + long notSavedQuestionId = 100L; + NewCheckboxAnswer checkboxAnswer = new NewCheckboxAnswer(notSavedQuestionId, List.of(1L)); + + // when, then + assertThatCode(() -> checkBoxAnswerValidator.validate(checkboxAnswer)) + .isInstanceOf(SubmittedQuestionNotFoundException.class); + } + + @Test + void 옵션_그룹이_지정되지_않은_질문에_대한_답변이면_예외가_발생한다() { + // given + Question savedQuestion = questionRepository.save(선택형_필수_질문()); + NewCheckboxAnswer checkboxAnswer = new NewCheckboxAnswer(savedQuestion.getId(), List.of(1L)); + + // when, then + assertThatCode(() -> checkBoxAnswerValidator.validate(checkboxAnswer)) + .isInstanceOf(OptionGroupNotFoundByQuestionIdException.class); + } + + @Test + void 옵션그룹에서_제공하지_않은_옵션아이템을_응답하면_예외가_발생한다() { + // given + Question savedQuestion = questionRepository.save(선택형_필수_질문()); + OptionGroup savedOptionGroup = optionGroupRepository.save(선택지_그룹(savedQuestion.getId())); + OptionItem savedOptionItem = optionItemRepository.save(선택지(savedOptionGroup.getId())); + + NewCheckboxAnswer checkboxAnswer = new NewCheckboxAnswer(savedQuestion.getId(), + List.of(savedOptionItem.getId() + 1L)); + + // when, then + assertThatCode(() -> checkBoxAnswerValidator.validate(checkboxAnswer)) + .isInstanceOf(CheckBoxAnswerIncludedNotProvidedOptionItemException.class); + } + + @Test + void 옵션그룹에서_정한_최소_선택_수_보다_적게_선택하면_예외가_발생한다() { + // given + Question savedQuestion = questionRepository.save(선택형_필수_질문()); + OptionGroup savedOptionGroup = optionGroupRepository.save( + new OptionGroup(savedQuestion.getId(), 2, 3) + ); + OptionItem savedOptionItem1 = optionItemRepository.save(선택지(savedOptionGroup.getId())); + + NewCheckboxAnswer checkboxAnswer = new NewCheckboxAnswer(savedQuestion.getId(), + List.of(savedOptionItem1.getId())); + + // when, then + assertThatCode(() -> checkBoxAnswerValidator.validate(checkboxAnswer)) + .isInstanceOf(SelectedOptionItemCountOutOfRangeException.class); + } + + @Test + void 옵션그룹에서_정한_최대_선택_수_보다_많이_선택하면_예외가_발생한다() { + // given + Question savedQuestion = questionRepository.save(선택형_필수_질문()); + OptionGroup savedOptionGroup = optionGroupRepository.save( + new OptionGroup(savedQuestion.getId(), 1, 1) + ); + OptionItem savedOptionItem1 = optionItemRepository.save(선택지(savedOptionGroup.getId(), 1)); + OptionItem savedOptionItem2 = optionItemRepository.save(선택지(savedOptionGroup.getId(), 2)); + + NewCheckboxAnswer checkboxAnswer = new NewCheckboxAnswer( + savedQuestion.getId(), List.of(savedOptionItem1.getId(), savedOptionItem2.getId())); + + // when, then + assertThatCode(() -> checkBoxAnswerValidator.validate(checkboxAnswer)) + .isInstanceOf(SelectedOptionItemCountOutOfRangeException.class); + } +} diff --git a/backend/src/test/java/reviewme/review/service/abstraction/validator/NewReviewValidatorTest.java b/backend/src/test/java/reviewme/review/service/abstraction/validator/NewReviewValidatorTest.java new file mode 100644 index 000000000..64d389ce3 --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/abstraction/validator/NewReviewValidatorTest.java @@ -0,0 +1,153 @@ +package reviewme.review.service.abstraction.validator; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static reviewme.fixture.OptionGroupFixture.선택지_그룹; +import static reviewme.fixture.OptionItemFixture.선택지; +import static reviewme.fixture.QuestionFixture.서술형_옵션_질문; +import static reviewme.fixture.QuestionFixture.서술형_필수_질문; +import static reviewme.fixture.QuestionFixture.선택형_필수_질문; +import static reviewme.fixture.ReviewGroupFixture.리뷰_그룹; +import static reviewme.fixture.SectionFixture.조건부로_보이는_섹션; +import static reviewme.fixture.SectionFixture.항상_보이는_섹션; +import static reviewme.fixture.TemplateFixture.템플릿; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.Question; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.abstraction.NewCheckboxAnswer; +import reviewme.review.domain.abstraction.NewReview; +import reviewme.review.domain.abstraction.NewTextAnswer; +import reviewme.review.service.exception.MissingRequiredQuestionException; +import reviewme.review.service.exception.SubmittedQuestionAndProvidedQuestionMismatchException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.support.ServiceTest; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; + +@ServiceTest +class NewReviewValidatorTest { + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private OptionGroupRepository optionGroupRepository; + + @Autowired + private OptionItemRepository optionItemRepository; + + @Autowired + private ReviewGroupRepository reviewGroupRepository; + + @Autowired + private TemplateRepository templateRepository; + + @Autowired + private SectionRepository sectionRepository; + + @Autowired + private NewReviewValidator reviewValidator; + + @Test + void 템플릿에_있는_질문에_대한_답과_필수_질문에_모두_응답하는_경우_예외가_발생하지_않는다() { + // 리뷰 그룹 저장 + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + + // 필수가 아닌 서술형 질문 저장 + Question notRequiredTextQuestion = questionRepository.save(서술형_옵션_질문()); + Section visibleSection1 = sectionRepository.save(항상_보이는_섹션(List.of(notRequiredTextQuestion.getId()), 1)); + + // 필수 선택형 질문, 섹션 저장 + Question requiredCheckQuestion = questionRepository.save(선택형_필수_질문()); + OptionGroup requiredOptionGroup = optionGroupRepository.save(선택지_그룹(requiredCheckQuestion.getId())); + OptionItem requiredOptionItem1 = optionItemRepository.save(선택지(requiredOptionGroup.getId())); + OptionItem requiredOptionItem2 = optionItemRepository.save(선택지(requiredOptionGroup.getId())); + Section visibleSection2 = sectionRepository.save(항상_보이는_섹션(List.of(requiredCheckQuestion.getId()), 2)); + + // optionItem 선택에 따라서 required 가 달라지는 섹션1 저장 + Question conditionalTextQuestion1 = questionRepository.save(서술형_필수_질문()); + Question conditionalCheckQuestion = questionRepository.save(선택형_필수_질문()); + OptionGroup conditionalOptionGroup = optionGroupRepository.save(선택지_그룹(conditionalCheckQuestion.getId())); + OptionItem conditionalOptionItem = optionItemRepository.save(선택지(conditionalOptionGroup.getId())); + Section conditionalSection1 = sectionRepository.save(조건부로_보이는_섹션( + List.of(conditionalTextQuestion1.getId(), conditionalCheckQuestion.getId()), + requiredOptionItem1.getId(), 3) + ); + + // optionItem 선택에 따라서 required 가 달라지는 섹션2 저장 + Question conditionalQuestion2 = questionRepository.save(서술형_필수_질문()); + Section conditionalSection2 = sectionRepository.save(조건부로_보이는_섹션( + List.of(conditionalQuestion2.getId()), requiredOptionItem2.getId(), 3) + ); + + // 템플릿 저장 + Template template = templateRepository.save(템플릿( + List.of(visibleSection1.getId(), visibleSection2.getId(), + conditionalSection1.getId(), conditionalSection2.getId()) + )); + + // 각 질문에 대한 답변 생성 + NewTextAnswer notRequiredTextAnswer = new NewTextAnswer(notRequiredTextQuestion.getId(), "답변".repeat(30)); + NewCheckboxAnswer alwaysRequiredCheckAnswer = new NewCheckboxAnswer(requiredCheckQuestion.getId(), + List.of(requiredOptionItem1.getId())); + NewTextAnswer conditionalTextAnswer1 = new NewTextAnswer(conditionalTextQuestion1.getId(), "답변".repeat(30)); + NewCheckboxAnswer conditionalCheckAnswer1 = new NewCheckboxAnswer(conditionalCheckQuestion.getId(), + List.of(conditionalOptionItem.getId())); + + // 리뷰 생성 + NewReview review = new NewReview(template.getId(), reviewGroup.getId(), + List.of(notRequiredTextAnswer, conditionalTextAnswer1, + alwaysRequiredCheckAnswer, conditionalCheckAnswer1)); + + // when, then + assertThatCode(() -> reviewValidator.validate(review)) + .doesNotThrowAnyException(); + } + + @Test + void 제공된_템플릿에_없는_질문에_대한_답변이_있을_경우_예외가_발생한다() { + // given + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + + Question question1 = questionRepository.save(서술형_필수_질문()); + Question question2 = questionRepository.save(서술형_필수_질문()); + Section section = sectionRepository.save(항상_보이는_섹션(List.of(question1.getId()))); + Template template = templateRepository.save(템플릿(List.of(section.getId()))); + + NewTextAnswer textAnswer = new NewTextAnswer(question2.getId(), "답변".repeat(20)); + NewReview review = new NewReview(template.getId(), reviewGroup.getId(), List.of(textAnswer)); + + // when, then + assertThatThrownBy(() -> reviewValidator.validate(review)) + .isInstanceOf(SubmittedQuestionAndProvidedQuestionMismatchException.class); + } + + @Test + void 필수_질문에_답변하지_않은_경우_예외가_발생한다() { + // given + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + + Question requiredQuestion = questionRepository.save(서술형_필수_질문()); + Question optionalQuestion = questionRepository.save(서술형_옵션_질문()); + Section section = sectionRepository.save( + 항상_보이는_섹션(List.of(requiredQuestion.getId(), optionalQuestion.getId()))); + Template template = templateRepository.save(템플릿(List.of(section.getId()))); + + NewTextAnswer optionalTextAnswer = new NewTextAnswer(optionalQuestion.getId(), "답변".repeat(20)); + NewReview review = new NewReview(template.getId(), reviewGroup.getId(), List.of(optionalTextAnswer)); + + // when, then + assertThatThrownBy(() -> reviewValidator.validate(review)) + .isInstanceOf(MissingRequiredQuestionException.class); + } +} diff --git a/backend/src/test/java/reviewme/review/service/abstraction/validator/NewTextAnswerValidatorTest.java b/backend/src/test/java/reviewme/review/service/abstraction/validator/NewTextAnswerValidatorTest.java new file mode 100644 index 000000000..cea001173 --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/abstraction/validator/NewTextAnswerValidatorTest.java @@ -0,0 +1,74 @@ +package reviewme.review.service.abstraction.validator; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static reviewme.fixture.QuestionFixture.서술형_옵션_질문; +import static reviewme.fixture.QuestionFixture.서술형_필수_질문; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.question.domain.Question; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.abstraction.NewTextAnswer; +import reviewme.review.service.exception.InvalidTextAnswerLengthException; +import reviewme.review.service.exception.SubmittedQuestionNotFoundException; +import reviewme.support.ServiceTest; + +@ServiceTest +class NewTextAnswerValidatorTest { + + @Autowired + private NewTextAnswerValidator textAnswerValidator; + + @Autowired + private QuestionRepository questionRepository; + + @Test + void 저장되지_않은_질문에_대한_대답이면_예외가_발생한다() { + // given + long notSavedQuestionId = 100L; + NewTextAnswer textAnswer = new NewTextAnswer(notSavedQuestionId, "텍스트형 응답"); + + // when, then + assertThatThrownBy(() -> textAnswerValidator.validate(textAnswer)) + .isInstanceOf(SubmittedQuestionNotFoundException.class); + } + + @ParameterizedTest + @ValueSource(ints = {19, 10001}) + void 필수_질문의_답변_길이가_유효하지_않으면_예외가_발생한다(int length) { + // given + String content = "답".repeat(length); + Question savedQuestion = questionRepository.save(서술형_필수_질문()); + NewTextAnswer textAnswer = new NewTextAnswer(savedQuestion.getId(), content); + + // when, then + assertThatThrownBy(() -> textAnswerValidator.validate(textAnswer)) + .isInstanceOf(InvalidTextAnswerLengthException.class); + } + + @Test + void 선택_질문의_답변_길이가_유효하지_않으면_예외가_발생한다() { + // given + String content = "답".repeat(10001); + Question savedQuestion = questionRepository.save(서술형_옵션_질문()); + NewTextAnswer textAnswer = new NewTextAnswer(savedQuestion.getId(), content); + + // when, then + assertThatThrownBy(() -> textAnswerValidator.validate(textAnswer)) + .isInstanceOf(InvalidTextAnswerLengthException.class); + } + + @Test + void 선택_질문은_최소_글자수_제한을_받지_않는다() { + // given + String content = "답".repeat(1); + Question savedQuestion = questionRepository.save(서술형_옵션_질문()); + NewTextAnswer textAnswer = new NewTextAnswer(savedQuestion.getId(), content); + + // when, then + assertDoesNotThrow(() -> textAnswerValidator.validate(textAnswer)); + } +} diff --git a/backend/src/test/java/reviewme/review/service/validator/ReviewValidatorTest.java b/backend/src/test/java/reviewme/review/service/validator/ReviewValidatorTest.java index ef3c54a1b..9680e8067 100644 --- a/backend/src/test/java/reviewme/review/service/validator/ReviewValidatorTest.java +++ b/backend/src/test/java/reviewme/review/service/validator/ReviewValidatorTest.java @@ -98,7 +98,7 @@ class ReviewValidatorTest { )); // 각 질문에 대한 답변 생성 - TextAnswer notRequiredlTextAnswer = new TextAnswer(notRequiredTextQuestion.getId(), "답변".repeat(30)); + TextAnswer notRequiredTextAnswer = new TextAnswer(notRequiredTextQuestion.getId(), "답변".repeat(30)); CheckboxAnswer alwaysRequiredCheckAnswer = new CheckboxAnswer(requiredCheckQuestion.getId(), List.of(requiredOptionItem1.getId())); TextAnswer conditionalTextAnswer1 = new TextAnswer(conditionalTextQuestion1.getId(), "답변".repeat(30)); @@ -107,7 +107,7 @@ class ReviewValidatorTest { // 리뷰 생성 Review review = new Review(template.getId(), reviewGroup.getId(), - List.of(notRequiredlTextAnswer, conditionalTextAnswer1), + List.of(notRequiredTextAnswer, conditionalTextAnswer1), List.of(alwaysRequiredCheckAnswer, conditionalCheckAnswer1)); // when, then From 6101019c1febec4b37c14e3e4c23ae5fa2138c7e Mon Sep 17 00:00:00 2001 From: Donghoon Lee Date: Mon, 7 Oct 2024 15:17:39 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[BE]=20fix:=20ID=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=A0=84=EB=9E=B5=EC=9D=84=20IDENTITY=EB=A1=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20(#778)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/reviewme/review/domain/abstraction/Answer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/reviewme/review/domain/abstraction/Answer.java b/backend/src/main/java/reviewme/review/domain/abstraction/Answer.java index 4118c3757..1e6b86833 100644 --- a/backend/src/main/java/reviewme/review/domain/abstraction/Answer.java +++ b/backend/src/main/java/reviewme/review/domain/abstraction/Answer.java @@ -22,7 +22,7 @@ public abstract class Answer { @Id - @GeneratedValue(strategy = GenerationType.TABLE) + @GeneratedValue(strategy = GenerationType.IDENTITY) protected Long id; @Column(name = "question_id", nullable = false) From b8dd2db304dd8f527539978cf1dab76ccf81fa9a Mon Sep 17 00:00:00 2001 From: Donghoon Lee Date: Mon, 7 Oct 2024 15:35:14 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[BE]=20fix:=20Auto=20Increment=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=88=98=EC=A0=95=20(#779)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V2__answer_abstraction.sql | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/backend/src/main/resources/db/migration/V2__answer_abstraction.sql b/backend/src/main/resources/db/migration/V2__answer_abstraction.sql index f28e19828..87d68dd27 100644 --- a/backend/src/main/resources/db/migration/V2__answer_abstraction.sql +++ b/backend/src/main/resources/db/migration/V2__answer_abstraction.sql @@ -44,11 +44,10 @@ CREATE TABLE new_checkbox_answer_selected_option ); -- MYSQL에서는 아래와 같이 초기화합니다. -ALTER TABLE new_review - AUTO_INCREMENT = 1500000; -ALTER TABLE new_checkbox_answer - AUTO_INCREMENT = 1500000; -ALTER TABLE new_text_answer - AUTO_INCREMENT = 1500000; -ALTER TABLE new_checkbox_answer_selected_option - AUTO_INCREMENT = 1500000; +-- 아래 두 테이블은 Answer의 ID를 따라가므로, 조절할 필요가 없습니다. +-- ALTER TABLE new_checkbox_answer AUTO_INCREMENT = 1500000; +-- ALTER TABLE new_text_answer AUTO_INCREMENT = 1500000; + +ALTER TABLE answer AUTO_INCREMENT = 1500000; +ALTER TABLE new_review AUTO_INCREMENT = 1500000; +ALTER TABLE new_checkbox_answer_selected_option AUTO_INCREMENT = 1500000;