Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.jobdri.jobdri_api.domain.analysis.controller;

import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionCandidateCreateRequest;
import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionAnswerSaveRequest;
import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionSelectionSaveRequest;
import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnswerResponse;
Expand All @@ -16,6 +17,7 @@
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
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;
Expand Down Expand Up @@ -43,6 +45,19 @@ public ApiResponse<List<QuestionCandidateResponse>> getQuestionCandidates(
);
}

@Operation(summary = "직접 추가 문항 후보 생성", description = "직접 입력한 문항을 선택 후보 목록에 추가합니다. 선택 문항으로 확정 저장되지는 않습니다.")
@PostMapping("/candidates")
public ApiResponse<QuestionCandidateResponse> addCustomQuestionCandidate(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@PathVariable Long mockApplyId,
@Valid @RequestBody QuestionCandidateCreateRequest request
) {
return ApiResponse.onSuccess(
"직접 추가 문항 후보가 생성되었습니다.",
questionService.addCustomQuestionCandidate(userDetails.getUser(), mockApplyId, request)
);
}

@Operation(summary = "선택 문항 조회", description = "현재 모의 서류 지원에 저장된 선택 문항 목록을 조회합니다.")
@GetMapping
public ApiResponse<QuestionSelectionResponse> getSelectedQuestions(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.jobdri.jobdri_api.domain.analysis.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;

public record QuestionCandidateCreateRequest(
@NotBlank(message = "문항 내용은 필수입니다.")
String content,

@Positive(message = "글자수 제한은 1 이상이어야 합니다.")
Integer charLimit
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ public record QuestionCandidateResponse(
Long questionId,
String content,
int charLimit,
boolean selected
boolean selected,
boolean custom
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.jobdri.jobdri_api.domain.analysis.entity;

import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply;
import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder(access = AccessLevel.PRIVATE)
@Table(
name = "custom_question_candidates",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_custom_question_candidates_mock_apply_content",
columnNames = {"mock_apply_id", "content"}
)
}
)
public class CustomQuestionCandidate {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "mock_apply_id", nullable = false)
private MockApply mockApply;

@Column(nullable = false, columnDefinition = "TEXT")
private String content;

@Column(name = "char_limit", nullable = false)
private int limit;

@Column(nullable = false)
private LocalDateTime createdAt;

public static CustomQuestionCandidate create(MockApply mockApply, String content, int limit) {
return CustomQuestionCandidate.builder()
.mockApply(mockApply)
.content(content)
.limit(limit)
.createdAt(LocalDateTime.now())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.jobdri.jobdri_api.domain.analysis.repository;

import com.jobdri.jobdri_api.domain.analysis.entity.CustomQuestionCandidate;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.Optional;

public interface CustomQuestionCandidateRepository extends JpaRepository<CustomQuestionCandidate, Long> {
List<CustomQuestionCandidate> findAllByMockApplyIdOrderByIdAsc(Long mockApplyId);
Optional<CustomQuestionCandidate> findByMockApplyIdAndContent(Long mockApplyId, String content);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
public interface QuestionRepository extends JpaRepository<Question, Long> {
List<Question> findAllByMockApplyId(Long mockApplyId);
List<Question> findAllByMockApplyIdOrderByIdAsc(Long mockApplyId);
boolean existsByMockApplyIdAndContent(Long mockApplyId, String content);
void deleteAllByMockApplyId(Long mockApplyId);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package com.jobdri.jobdri_api.domain.analysis.service;

import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionCandidateCreateRequest;
import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionAnswerSaveRequest;
import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionSelectionSaveRequest;
import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnswerResponse;
import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse;
import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionResponse;
import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionSelectionResponse;
import com.jobdri.jobdri_api.domain.analysis.entity.CustomQuestionCandidate;
import com.jobdri.jobdri_api.domain.analysis.entity.Question;
import com.jobdri.jobdri_api.domain.analysis.repository.CustomQuestionCandidateRepository;
import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository;
import com.jobdri.jobdri_api.domain.mockapply.entity.MockApply;
import com.jobdri.jobdri_api.domain.mockapply.entity.MockApplyStatus;
Expand All @@ -15,10 +18,12 @@
import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode;
import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
Expand All @@ -44,21 +49,86 @@ public class QuestionService {

private final MockApplyRepository mockApplyRepository;
private final QuestionRepository questionRepository;
private final CustomQuestionCandidateRepository customQuestionCandidateRepository;

public List<QuestionCandidateResponse> getQuestionCandidates(User user, Long mockApplyId) {
MockApply mockApply = getOwnedMockApply(user, mockApplyId);
Set<String> selectedContents = questionRepository.findAllByMockApplyId(mockApply.getId()).stream()
List<Question> selectedQuestions = questionRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId());
Set<String> selectedContents = selectedQuestions.stream()
.map(Question::getContent)
.collect(Collectors.toSet());

return DEFAULT_CANDIDATES.stream()
List<QuestionCandidateResponse> candidates = new ArrayList<>(DEFAULT_CANDIDATES.stream()
.map(candidate -> new QuestionCandidateResponse(
candidate.id(),
candidate.content(),
candidate.charLimit(),
selectedContents.contains(candidate.content())
selectedContents.contains(candidate.content()),
false
))
.toList();
.toList());

customQuestionCandidateRepository.findAllByMockApplyIdOrderByIdAsc(mockApply.getId()).stream()
.map(candidate -> new QuestionCandidateResponse(
candidate.getId(),
candidate.getContent(),
candidate.getLimit(),
selectedContents.contains(candidate.getContent()),
true
))
.forEach(candidates::add);

return candidates;
}

@Transactional
public QuestionCandidateResponse addCustomQuestionCandidate(
User user,
Long mockApplyId,
QuestionCandidateCreateRequest request
) {
MockApply mockApply = getOwnedMockApply(user, mockApplyId);
String content = request.content().trim();
validateCustomCandidate(content);

CustomQuestionCandidate candidate = findOrCreateCustomCandidate(mockApply, content, request.charLimit());
boolean selected = questionRepository.existsByMockApplyIdAndContent(mockApply.getId(), candidate.getContent());

return new QuestionCandidateResponse(
candidate.getId(),
candidate.getContent(),
candidate.getLimit(),
selected,
true
);
}

private CustomQuestionCandidate findOrCreateCustomCandidate(
MockApply mockApply,
String content,
Integer charLimit
) {
return customQuestionCandidateRepository
.findByMockApplyIdAndContent(mockApply.getId(), content)
.orElseGet(() -> saveCustomCandidate(mockApply, content, charLimit));
}

private CustomQuestionCandidate saveCustomCandidate(
MockApply mockApply,
String content,
Integer charLimit
) {
try {
return customQuestionCandidateRepository.saveAndFlush(CustomQuestionCandidate.create(
mockApply,
content,
resolveCharLimit(charLimit)
));
} catch (DataIntegrityViolationException e) {
return customQuestionCandidateRepository
.findByMockApplyIdAndContent(mockApply.getId(), content)
.orElseThrow(() -> e);
}
}

public QuestionSelectionResponse getSelectedQuestions(User user, Long mockApplyId) {
Expand Down Expand Up @@ -159,6 +229,14 @@ private int resolveCharLimit(Integer charLimit) {
return charLimit;
}

private void validateCustomCandidate(String content) {
boolean existsInDefault = DEFAULT_CANDIDATES.stream()
.anyMatch(candidate -> candidate.content().equals(content));
if (existsInDefault) {
throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "이미 기본 후보에 존재하는 문항입니다.");
}
}

private String normalizeAnswer(String answer) {
if (StringUtils.hasText(answer)) {
return answer;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.jobdri.jobdri_api.domain.analysis.service;

import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionAnswerSaveRequest;
import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionCandidateCreateRequest;
import com.jobdri.jobdri_api.domain.analysis.dto.request.QuestionSelectionSaveRequest;
import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionAnswerResponse;
import com.jobdri.jobdri_api.domain.analysis.dto.response.QuestionCandidateResponse;
Expand Down Expand Up @@ -110,6 +111,108 @@ void saveSelectedQuestionsReplacesExistingQuestions() {
.containsExactly("새 문항");
}

@Test
@DisplayName("직접 추가 문항은 선택 문항으로 저장하지 않고 후보 목록에 추가한다")
void addCustomQuestionCandidate() {
User user = saveUser("question-custom-candidate@example.com");
MockApply mockApply = saveMockApply(user);

QuestionCandidateResponse response = questionService.addCustomQuestionCandidate(
user,
mockApply.getId(),
new QuestionCandidateCreateRequest("직접 추가한 후보 문항입니다.", 500)
);
List<QuestionCandidateResponse> candidates = questionService.getQuestionCandidates(user, mockApply.getId());

assertThat(response.content()).isEqualTo("직접 추가한 후보 문항입니다.");
assertThat(response.charLimit()).isEqualTo(500);
assertThat(response.selected()).isFalse();
assertThat(response.custom()).isTrue();
assertThat(questionRepository.findAllByMockApplyId(mockApply.getId())).isEmpty();
assertThat(candidates).hasSize(6);
assertThat(candidates.get(5).questionId()).isEqualTo(response.questionId());
assertThat(candidates.get(5).content()).isEqualTo("직접 추가한 후보 문항입니다.");
assertThat(candidates.get(5).selected()).isFalse();
assertThat(candidates.get(5).custom()).isTrue();
}

@Test
@DisplayName("기본 후보와 같은 내용은 직접 추가 문항 후보로 등록할 수 없다")
void addCustomQuestionCandidateThrowsWhenContentExistsInDefaultCandidate() {
User user = saveUser("question-custom-default-duplicate@example.com");
MockApply mockApply = saveMockApply(user);

assertThatThrownBy(() -> questionService.addCustomQuestionCandidate(
user,
mockApply.getId(),
new QuestionCandidateCreateRequest("지원 동기와 입사 후 목표를 작성해주세요.", 700)
))
.isInstanceOf(GeneralException.class)
.extracting("code")
.isEqualTo(GeneralErrorCode.INVALID_PARAMETER);
assertThat(questionRepository.findAllByMockApplyId(mockApply.getId())).isEmpty();
assertThat(questionService.getQuestionCandidates(user, mockApply.getId())).hasSize(5);
}

@Test
@DisplayName("같은 직접 추가 문항 후보를 여러 번 요청하면 기존 후보를 반환한다")
void addCustomQuestionCandidateReturnsExistingCandidateWhenDuplicated() {
User user = saveUser("question-custom-duplicate@example.com");
MockApply mockApply = saveMockApply(user);

QuestionCandidateResponse first = questionService.addCustomQuestionCandidate(
user,
mockApply.getId(),
new QuestionCandidateCreateRequest("중복 직접 추가 문항입니다.", 500)
);
QuestionCandidateResponse second = questionService.addCustomQuestionCandidate(
user,
mockApply.getId(),
new QuestionCandidateCreateRequest("중복 직접 추가 문항입니다.", 800)
);
List<QuestionCandidateResponse> candidates = questionService.getQuestionCandidates(user, mockApply.getId());

assertThat(second.questionId()).isEqualTo(first.questionId());
assertThat(second.content()).isEqualTo(first.content());
assertThat(second.charLimit()).isEqualTo(first.charLimit());
assertThat(second.selected()).isFalse();
assertThat(second.custom()).isTrue();
assertThat(questionRepository.findAllByMockApplyId(mockApply.getId())).isEmpty();
assertThat(candidates).hasSize(6);
assertThat(candidates.stream()
.filter(QuestionCandidateResponse::custom)
.map(QuestionCandidateResponse::content)
.toList())
.containsExactly("중복 직접 추가 문항입니다.");
}

@Test
@DisplayName("이미 선택된 직접 추가 후보를 다시 요청하면 선택 상태로 반환한다")
void addCustomQuestionCandidateReturnsSelectedStateWhenAlreadySelected() {
User user = saveUser("question-custom-selected@example.com");
MockApply mockApply = saveMockApply(user);
QuestionCandidateResponse first = questionService.addCustomQuestionCandidate(
user,
mockApply.getId(),
new QuestionCandidateCreateRequest("이미 선택된 직접 추가 문항입니다.", 500)
);
questionService.saveSelectedQuestions(user, mockApply.getId(), new QuestionSelectionSaveRequest(List.of(
new QuestionSelectionSaveRequest.QuestionItem("이미 선택된 직접 추가 문항입니다.", 500, true)
)));

QuestionCandidateResponse second = questionService.addCustomQuestionCandidate(
user,
mockApply.getId(),
new QuestionCandidateCreateRequest("이미 선택된 직접 추가 문항입니다.", 800)
);

assertThat(second.questionId()).isEqualTo(first.questionId());
assertThat(second.content()).isEqualTo(first.content());
assertThat(second.charLimit()).isEqualTo(first.charLimit());
assertThat(second.selected()).isTrue();
assertThat(second.custom()).isTrue();
}

@Test
@DisplayName("문항 후보 목록은 이미 저장된 기본 문항을 선택 상태로 반환한다")
void getQuestionCandidatesMarksSelectedQuestion() {
Expand All @@ -125,6 +228,9 @@ void getQuestionCandidatesMarksSelectedQuestion() {
assertThat(candidates)
.extracting(QuestionCandidateResponse::questionId)
.containsExactly(1L, 2L, 3L, 4L, 5L);
assertThat(candidates)
.extracting(QuestionCandidateResponse::custom)
.containsOnly(false);
assertThat(candidates.get(0).selected()).isTrue();
assertThat(candidates.get(1).selected()).isFalse();
}
Expand Down
Loading