From 908063712f4ff26ae855762190abe79ea97fff16 Mon Sep 17 00:00:00 2001 From: TueBack Date: Sun, 10 Aug 2025 16:23:41 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=81=AC=EB=A3=A8=20=EC=B0=B8=EC=97=AC?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=20=EC=8B=9C=20=EC=A7=88=EB=AC=B8=20?= =?UTF-8?q?=EB=8B=B5=EB=B3=80=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RecruitmentAnswer 엔티티 추가, 참여 요청 시 답변 저장 - 참여 요청자 본인의 지원 현황(답변 포함) 조회 기능 구현 - PENDING 상태의 참여 요청에 대한 답변 수정 기능 구현 - 답변 개수 불일치, 타 크루 질문 등 유효성 검증 로직 및 테스트 코드 추가 --- .../crew/application/in/DemandService.java | 54 +++++- .../request/demand/CreateDemandRequest.java | 24 ++- .../request/demand/UpdateDemandRequest.java | 30 +++ .../response/demand/CreateDemandResponse.java | 5 +- .../in/response/demand/DemandsResponse.java | 36 +++- .../in/response/demand/MyDemandResponse.java | 56 ++++++ .../response/demand/UpdateDemandResponse.java | 24 +++ .../in/usecase/ManageDemandUseCase.java | 5 + .../out/repository/CrewDemandRepository.java | 3 + .../com/retrip/crew/domain/entity/Demand.java | 37 ++++ .../crew/domain/entity/RecruitmentAnswer.java | 55 ++++++ .../domain/exception/common/ErrorCode.java | 7 +- .../domain/vo/RecruitmentAnswerContent.java | 32 ++++ .../presentation/rest/DemandController.java | 21 +++ .../application/in/DemandServiceTest.java | 178 ++++++++++++++++-- 15 files changed, 540 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/retrip/crew/application/in/request/demand/UpdateDemandRequest.java create mode 100644 src/main/java/com/retrip/crew/application/in/response/demand/MyDemandResponse.java create mode 100644 src/main/java/com/retrip/crew/application/in/response/demand/UpdateDemandResponse.java create mode 100644 src/main/java/com/retrip/crew/domain/entity/RecruitmentAnswer.java create mode 100644 src/main/java/com/retrip/crew/domain/vo/RecruitmentAnswerContent.java diff --git a/src/main/java/com/retrip/crew/application/in/DemandService.java b/src/main/java/com/retrip/crew/application/in/DemandService.java index 31432c2..8c62b25 100644 --- a/src/main/java/com/retrip/crew/application/in/DemandService.java +++ b/src/main/java/com/retrip/crew/application/in/DemandService.java @@ -5,15 +5,11 @@ import com.retrip.crew.application.in.request.crew.CrewOrder; import com.retrip.crew.application.in.request.demand.CreateDemandRequest; import com.retrip.crew.application.in.request.demand.DemandOrder; +import com.retrip.crew.application.in.request.demand.UpdateDemandRequest; import com.retrip.crew.application.in.response.CreateRecruitmentQuestionResponse; import com.retrip.crew.application.in.response.RecruitmentQuestionResponse; import com.retrip.crew.application.in.response.UpdateRecruitmentQuestionResponse; -import com.retrip.crew.application.in.response.demand.ApproveDemandResponse; -import com.retrip.crew.application.in.response.demand.ChangeRecruitmentStatusResponse; -import com.retrip.crew.application.in.response.demand.CreateDemandResponse; -import com.retrip.crew.application.in.response.demand.CrewsOfDemandResponse; -import com.retrip.crew.application.in.response.demand.DemandsResponse; -import com.retrip.crew.application.in.response.demand.RejectDemandResponse; +import com.retrip.crew.application.in.response.demand.*; import com.retrip.crew.application.in.usecase.ManageDemandUseCase; import com.retrip.crew.application.in.usecase.ManageRecruitmentQuestionUseCase; import com.retrip.crew.application.in.usecase.UpdateRecruitmentUseCase; @@ -24,14 +20,17 @@ import com.retrip.crew.application.out.repository.RecruitmentQuestionRepository; import com.retrip.crew.domain.entity.Crew; import com.retrip.crew.domain.entity.Demand; +import com.retrip.crew.domain.entity.RecruitmentAnswer; import com.retrip.crew.domain.entity.RecruitmentQuestion; import com.retrip.crew.domain.exception.CrewNotFoundException; import com.retrip.crew.domain.exception.NotCrewLeaderException; import com.retrip.crew.domain.exception.QuestionNotFoundException; import com.retrip.crew.domain.exception.common.BusinessException; import com.retrip.crew.domain.exception.common.EntityNotFoundException; +import com.retrip.crew.domain.exception.common.ErrorCode; import com.retrip.crew.domain.vo.DemandStatus; import com.retrip.crew.infra.util.PaginationUtils; +import com.retrip.crew.domain.exception.common.InvalidValueException; import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; @@ -40,6 +39,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; + @Service @Transactional @RequiredArgsConstructor @@ -67,7 +67,25 @@ public ChangeRecruitmentStatusResponse stopRecruitment(UUID crewId) { @Override public CreateDemandResponse createDemand(UUID crewId, CreateDemandRequest request) { Crew crew = findById(crewId); + + if (crew.getRecruitment().getRecruitmentQuestions().getValues().size() != request.answers().size()) { + throw new InvalidValueException(ErrorCode.MISMATCHED_ANSWER_COUNT); + } + Demand demand = crew.demand(request.memberId()); + + request.answers().forEach(answerRequest -> { + RecruitmentQuestion question = recruitmentQuestionRepository.findById(answerRequest.questionId()) + .orElseThrow(QuestionNotFoundException::new); + + if (!question.getCrew().getId().equals(crewId)) { + throw new InvalidValueException(ErrorCode.INVALID_QUESTION_FOR_CREW); + } + + RecruitmentAnswer answer = RecruitmentAnswer.of(demand, question, answerRequest.content()); + demand.addAnswer(answer); + }); + return CreateDemandResponse.of(crew.getId(), demand); } @@ -162,4 +180,28 @@ private RecruitmentQuestion findRecruitmentQuestionByIdAndCrewId(UUID questionId return recruitmentQuestionRepository.findByIdAndCrewId(questionId, crewId) .orElseThrow(QuestionNotFoundException::new); } + + @Override + @Transactional(readOnly = true) + public MyDemandResponse getMyDemand(UUID crewId, UUID memberId) { + Demand demand = demandRepository.findByCrewIdAndMemberId(crewId, memberId) + .orElseThrow(() -> new EntityNotFoundException(ErrorCode.USER_DEMAND_NOT_FOUND)); + return MyDemandResponse.from(demand); + } + + @Override + public UpdateDemandResponse updateDemand(UUID crewId, UpdateDemandRequest request) { + Demand demand = demandRepository.findByCrewIdAndMemberId(crewId, request.memberId()) + .orElseThrow(() -> new EntityNotFoundException(ErrorCode.USER_DEMAND_NOT_FOUND)); + + List newAnswers = request.answers().stream() + .map(answerRequest -> { + RecruitmentQuestion question = findRecruitmentQuestionByIdAndCrewId(answerRequest.questionId(), crewId); + return RecruitmentAnswer.of(demand, question, answerRequest.content()); + }).toList(); + + demand.updateAnswers(request.memberId(), newAnswers); + + return UpdateDemandResponse.from(demand); + } } diff --git a/src/main/java/com/retrip/crew/application/in/request/demand/CreateDemandRequest.java b/src/main/java/com/retrip/crew/application/in/request/demand/CreateDemandRequest.java index 44f3ec6..c778f33 100644 --- a/src/main/java/com/retrip/crew/application/in/request/demand/CreateDemandRequest.java +++ b/src/main/java/com/retrip/crew/application/in/request/demand/CreateDemandRequest.java @@ -1,11 +1,29 @@ package com.retrip.crew.application.in.request.demand; import io.swagger.v3.oas.annotations.media.Schema; - +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.util.List; import java.util.UUID; @Schema(description = "크루 참여 요청 생성 Request") public record CreateDemandRequest( - UUID memberId + @Schema(description = "참여 요청자 ID") + @NotNull + UUID memberId, + + @Schema(description = "질문 답변 목록") + @Valid + List answers ) { -} + @Schema(description = "질문 답변 Request") + public record AnswerRequest( + @Schema(description = "질문 ID") + @NotNull + UUID questionId, + + @Schema(description = "답변 내용") + @NotNull + String content + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/retrip/crew/application/in/request/demand/UpdateDemandRequest.java b/src/main/java/com/retrip/crew/application/in/request/demand/UpdateDemandRequest.java new file mode 100644 index 0000000..94d80a3 --- /dev/null +++ b/src/main/java/com/retrip/crew/application/in/request/demand/UpdateDemandRequest.java @@ -0,0 +1,30 @@ +package com.retrip.crew.application.in.request.demand; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import java.util.UUID; + +@Schema(description = "크루 참여 요청 수정 Request") +public record UpdateDemandRequest( + @Schema(description = "참여 요청자 ID") + @NotNull + UUID memberId, + + @Schema(description = "수정할 질문 답변 목록") + @Valid + @NotNull + List answers +) { + @Schema(description = "질문 답변 Request") + public record AnswerRequest( + @Schema(description = "질문 ID") + @NotNull + UUID questionId, + + @Schema(description = "새로운 답변 내용") + @NotNull + String content + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/retrip/crew/application/in/response/demand/CreateDemandResponse.java b/src/main/java/com/retrip/crew/application/in/response/demand/CreateDemandResponse.java index 2945e4f..2918f86 100644 --- a/src/main/java/com/retrip/crew/application/in/response/demand/CreateDemandResponse.java +++ b/src/main/java/com/retrip/crew/application/in/response/demand/CreateDemandResponse.java @@ -10,10 +10,13 @@ public record CreateDemandResponse( @Schema(description = "크루 ID") UUID crewId, + @Schema(description = "참여 요청 ID") + UUID demandId, + @Schema(description = "참여 요청자 ID") UUID memberId ) { public static CreateDemandResponse of(UUID crewId, Demand demand) { - return new CreateDemandResponse(crewId, demand.getMemberId()); + return new CreateDemandResponse(crewId, demand.getId(), demand.getMemberId()); } } diff --git a/src/main/java/com/retrip/crew/application/in/response/demand/DemandsResponse.java b/src/main/java/com/retrip/crew/application/in/response/demand/DemandsResponse.java index 651289f..efaad9c 100644 --- a/src/main/java/com/retrip/crew/application/in/response/demand/DemandsResponse.java +++ b/src/main/java/com/retrip/crew/application/in/response/demand/DemandsResponse.java @@ -1,10 +1,13 @@ package com.retrip.crew.application.in.response.demand; import com.retrip.crew.domain.entity.Demand; +import com.retrip.crew.domain.entity.RecruitmentAnswer; import com.retrip.crew.domain.vo.DemandStatus; import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; @Schema(description = "참여 요청 목록 조회 Response") public record DemandsResponse( @@ -18,9 +21,38 @@ public record DemandsResponse( UUID memberId, @Schema(description = "참여 요청 상태") - DemandStatus status + DemandStatus status, + + @Schema(description = "질문 답변 목록") + List answers ) { public static DemandsResponse of(UUID crewId, Demand demand) { - return new DemandsResponse(crewId, demand.getId(), demand.getMemberId(), demand.getStatus()); + return new DemandsResponse( + crewId, + demand.getId(), + demand.getMemberId(), + demand.getStatus(), + demand.getAnswers().stream() + .map(AnswerResponse::of) + .collect(Collectors.toList()) + ); + } + + @Schema(description = "질문 답변 Response") + public record AnswerResponse( + @Schema(description = "질문 ID") + UUID questionId, + @Schema(description = "질문 내용") + String questionContent, + @Schema(description = "답변 내용") + String answerContent + ) { + public static AnswerResponse of(RecruitmentAnswer answer) { + return new AnswerResponse( + answer.getQuestion().getId(), + answer.getQuestion().getContent().getValue(), + answer.getContent().getValue() + ); + } } } diff --git a/src/main/java/com/retrip/crew/application/in/response/demand/MyDemandResponse.java b/src/main/java/com/retrip/crew/application/in/response/demand/MyDemandResponse.java new file mode 100644 index 0000000..f7145f1 --- /dev/null +++ b/src/main/java/com/retrip/crew/application/in/response/demand/MyDemandResponse.java @@ -0,0 +1,56 @@ +package com.retrip.crew.application.in.response.demand; + +import com.retrip.crew.domain.entity.Demand; +import com.retrip.crew.domain.entity.RecruitmentAnswer; +import com.retrip.crew.domain.vo.DemandStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Builder +@Schema(description = "내 참여 요청 조회 Response") +public record MyDemandResponse( + @Schema(description = "크루 ID") + UUID crewId, + + @Schema(description = "참여 요청 ID") + UUID demandId, + + @Schema(description = "참여 요청 상태") + DemandStatus status, + + @Schema(description = "질문 답변 목록") + List answers +) { + public static MyDemandResponse from(Demand demand) { + return MyDemandResponse.builder() + .crewId(demand.getCrew().getId()) + .demandId(demand.getId()) + .status(demand.getStatus()) + .answers(demand.getAnswers().stream() + .map(AnswerResponse::from) + .collect(Collectors.toList())) + .build(); + } + + @Schema(description = "질문 답변 Response") + public record AnswerResponse( + @Schema(description = "질문 ID") + UUID questionId, + @Schema(description = "질문 내용") + String questionContent, + @Schema(description = "답변 내용") + String answerContent + ) { + public static AnswerResponse from(RecruitmentAnswer answer) { + return new AnswerResponse( + answer.getQuestion().getId(), + answer.getQuestion().getContent().getValue(), + answer.getContent().getValue() + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/retrip/crew/application/in/response/demand/UpdateDemandResponse.java b/src/main/java/com/retrip/crew/application/in/response/demand/UpdateDemandResponse.java new file mode 100644 index 0000000..8d4bb86 --- /dev/null +++ b/src/main/java/com/retrip/crew/application/in/response/demand/UpdateDemandResponse.java @@ -0,0 +1,24 @@ +package com.retrip.crew.application.in.response.demand; + +import com.retrip.crew.domain.entity.Demand; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.UUID; + +@Builder +@Schema(description = "크루 참여 요청 수정 Response") +public record UpdateDemandResponse( + @Schema(description = "크루 ID") + UUID crewId, + + @Schema(description = "참여 요청 ID") + UUID demandId +) { + public static UpdateDemandResponse from(Demand demand) { + return UpdateDemandResponse.builder() + .crewId(demand.getCrew().getId()) + .demandId(demand.getId()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/retrip/crew/application/in/usecase/ManageDemandUseCase.java b/src/main/java/com/retrip/crew/application/in/usecase/ManageDemandUseCase.java index 0f0c976..f84aadd 100644 --- a/src/main/java/com/retrip/crew/application/in/usecase/ManageDemandUseCase.java +++ b/src/main/java/com/retrip/crew/application/in/usecase/ManageDemandUseCase.java @@ -3,6 +3,7 @@ import com.retrip.crew.application.in.request.demand.CreateDemandRequest; import com.retrip.crew.application.in.request.crew.CrewOrder; import com.retrip.crew.application.in.request.demand.DemandOrder; +import com.retrip.crew.application.in.request.demand.UpdateDemandRequest; import com.retrip.crew.application.in.response.demand.*; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -22,4 +23,8 @@ Page getDemands( RejectDemandResponse rejectDemand(UUID crewId, UUID demandId, UUID memberId); ApproveDemandResponse approveDemand(UUID crewId, UUID demandId, UUID memberId); + + MyDemandResponse getMyDemand(UUID crewId, UUID memberId); + + UpdateDemandResponse updateDemand(UUID crewId, UpdateDemandRequest request); } diff --git a/src/main/java/com/retrip/crew/application/out/repository/CrewDemandRepository.java b/src/main/java/com/retrip/crew/application/out/repository/CrewDemandRepository.java index b8362fa..01857ec 100644 --- a/src/main/java/com/retrip/crew/application/out/repository/CrewDemandRepository.java +++ b/src/main/java/com/retrip/crew/application/out/repository/CrewDemandRepository.java @@ -15,4 +15,7 @@ public interface CrewDemandRepository extends ReadRepository { @Query("select d, c from Demand d join fetch d.crew c where d.id = :demandId and c.id = :crewId") Optional findCrewByIdAndCrewId(UUID demandId, UUID crewId); + + @Query("select d from Demand d join fetch d.answers where d.crew.id = :crewId and d.memberId = :memberId and d.status <> 'CANCELED'") + Optional findByCrewIdAndMemberId(UUID crewId, UUID memberId); } diff --git a/src/main/java/com/retrip/crew/domain/entity/Demand.java b/src/main/java/com/retrip/crew/domain/entity/Demand.java index 59a2257..3c7102d 100644 --- a/src/main/java/com/retrip/crew/domain/entity/Demand.java +++ b/src/main/java/com/retrip/crew/domain/entity/Demand.java @@ -1,12 +1,20 @@ package com.retrip.crew.domain.entity; +import com.retrip.crew.domain.exception.IllegalDemandStateException; +import com.retrip.crew.domain.exception.common.InvalidAccessException; +import com.retrip.crew.domain.exception.common.InvalidValueException; import com.retrip.crew.domain.vo.DemandStatus; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; import static com.retrip.crew.domain.vo.DemandStatus.*; @@ -29,6 +37,9 @@ public class Demand extends BaseEntity { ) private Crew crew; + @OneToMany(mappedBy = "demand", cascade = CascadeType.ALL, orphanRemoval = true) + private List answers = new ArrayList<>(); + public Demand(UUID memberId, Crew crew) { this.id = UUID.randomUUID(); this.memberId = memberId; @@ -63,4 +74,30 @@ public boolean isEqualTo(UUID memberId) { public void restore() { this.status = PENDING; } + + public void addAnswer(RecruitmentAnswer answer) { + this.answers.add(answer); + } + + public void updateAnswers(UUID memberId, List newAnswers) { + if (!this.memberId.equals(memberId)) { + throw new InvalidAccessException("자신의 참여 요청만 수정할 수 있습니다."); + } + if (this.status != PENDING) { + throw new IllegalDemandStateException("대기중인 참여 요청만 수정할 수 있습니다."); + } + if (this.answers.size() != newAnswers.size()) { + throw new InvalidValueException("수정하려는 답변의 개수가 올바르지 않습니다."); + } + + Map answerMap = this.answers.stream() + .collect(Collectors.toMap(a -> a.getQuestion().getId(), Function.identity())); + + newAnswers.forEach(newAnswer -> { + RecruitmentAnswer existingAnswer = answerMap.get(newAnswer.getQuestion().getId()); + if (existingAnswer != null) { + existingAnswer.updateContent(newAnswer.getContent().getValue()); + } + }); + } } diff --git a/src/main/java/com/retrip/crew/domain/entity/RecruitmentAnswer.java b/src/main/java/com/retrip/crew/domain/entity/RecruitmentAnswer.java new file mode 100644 index 0000000..64fab6b --- /dev/null +++ b/src/main/java/com/retrip/crew/domain/entity/RecruitmentAnswer.java @@ -0,0 +1,55 @@ +package com.retrip.crew.domain.entity; + +import com.retrip.crew.domain.vo.RecruitmentAnswerContent; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true) +@Getter +public class RecruitmentAnswer extends BaseEntity { + + @Id + @Column(columnDefinition = "varbinary(16)") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn( + name = "demand_id", + nullable = false, + columnDefinition = "varbinary(16)", + foreignKey = @ForeignKey(name = "fk_answer_to_demand") + ) + private Demand demand; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn( + name = "question_id", + nullable = false, + columnDefinition = "varbinary(16)", + foreignKey = @ForeignKey(name = "fk_answer_to_question") + ) + private RecruitmentQuestion question; + + @Embedded + private RecruitmentAnswerContent content; + + private RecruitmentAnswer(Demand demand, RecruitmentQuestion question, String content) { + this.id = UUID.randomUUID(); + this.demand = demand; + this.question = question; + this.content = new RecruitmentAnswerContent(content); + } + + public static RecruitmentAnswer of(Demand demand, RecruitmentQuestion question, String content) { + return new RecruitmentAnswer(demand, question, content); + } + + public void updateContent(String content) { + this.content = new RecruitmentAnswerContent(content); + } +} \ No newline at end of file diff --git a/src/main/java/com/retrip/crew/domain/exception/common/ErrorCode.java b/src/main/java/com/retrip/crew/domain/exception/common/ErrorCode.java index 79fca3f..32d8232 100644 --- a/src/main/java/com/retrip/crew/domain/exception/common/ErrorCode.java +++ b/src/main/java/com/retrip/crew/domain/exception/common/ErrorCode.java @@ -29,7 +29,12 @@ public enum ErrorCode { POST_DELETE_FAIL(FORBIDDEN, "Crew-011", "자유 게시글을 삭제할 권한이 없습니다."), QUESTION_UPDATE_FAIL(FORBIDDEN, "Crew-012", "질문 수정 권한이 없습니다."), QUESTION_DELETE_FAIL(FORBIDDEN, "Crew-013", "질문 삭제 권한이 없습니다."), - QUESTION_NOT_FOUND(BAD_REQUEST, "Crew-014", "질문을 찾을 수 없습니다."); + QUESTION_NOT_FOUND(BAD_REQUEST, "Crew-014", "질문을 찾을 수 없습니다."), + + MISMATCHED_ANSWER_COUNT(BAD_REQUEST, "Demand-001", "질문의 개수와 답변의 개수가 일치하지 않습니다."), + INVALID_QUESTION_FOR_CREW(BAD_REQUEST, "Demand-002", "해당 크루의 질문이 아닙니다."), + DEMAND_NOT_FOUND(BAD_REQUEST, "Demand-003", "참여 요청을 찾을 수 없습니다."), + USER_DEMAND_NOT_FOUND(BAD_REQUEST, "Demand-004", "해당 크루에 대한 사용자의 참여 요청을 찾을 수 없습니다."); ; diff --git a/src/main/java/com/retrip/crew/domain/vo/RecruitmentAnswerContent.java b/src/main/java/com/retrip/crew/domain/vo/RecruitmentAnswerContent.java new file mode 100644 index 0000000..500e90f --- /dev/null +++ b/src/main/java/com/retrip/crew/domain/vo/RecruitmentAnswerContent.java @@ -0,0 +1,32 @@ +package com.retrip.crew.domain.vo; + +import com.retrip.crew.domain.exception.common.InvalidValueException; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true) +public class RecruitmentAnswerContent { + + private static final int MAX_LENGTH = 500; + + @Column(name = "content", nullable = false, length = MAX_LENGTH) + private final String value; + + public RecruitmentAnswerContent(String value) { + validate(value); + this.value = value; + } + + private void validate(String value) { + if (value == null || value.trim().isEmpty() || value.length() > MAX_LENGTH) { + throw new InvalidValueException("답변 내용은 1자 이상 " + MAX_LENGTH + "자 이하로 입력해야 합니다."); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/retrip/crew/infra/adapter/in/presentation/rest/DemandController.java b/src/main/java/com/retrip/crew/infra/adapter/in/presentation/rest/DemandController.java index f0ac50a..23b5e5a 100644 --- a/src/main/java/com/retrip/crew/infra/adapter/in/presentation/rest/DemandController.java +++ b/src/main/java/com/retrip/crew/infra/adapter/in/presentation/rest/DemandController.java @@ -5,6 +5,7 @@ import com.retrip.crew.application.in.request.crew.CrewOrder; import com.retrip.crew.application.in.request.demand.CreateDemandRequest; import com.retrip.crew.application.in.request.demand.DemandOrder; +import com.retrip.crew.application.in.request.demand.UpdateDemandRequest; import com.retrip.crew.application.in.response.CreateRecruitmentQuestionResponse; import com.retrip.crew.application.in.response.RecruitmentQuestionResponse; import com.retrip.crew.application.in.response.UpdateRecruitmentQuestionResponse; @@ -16,6 +17,8 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; + +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -153,4 +156,22 @@ public ApiResponse> getRecruitmentQuestions( List response = manageRecruitmentQuestionUseCase.getRecruitmentQuestions(crewId, memberId); return ApiResponse.ok(response); } + + @GetMapping("/{crewId}/demands/my-demand") + @Schema(description = "내 크루 참여 요청 조회") + public ApiResponse getMyDemand( + @PathVariable final UUID crewId, + @RequestParam final UUID memberId) { // TODO: 로그인 구현 시 @AuthenticationPrincipal 등으로 교체 + MyDemandResponse myDemand = manageDemandUseCase.getMyDemand(crewId, memberId); + return ApiResponse.ok(myDemand); + } + + @PutMapping("/{crewId}/demands/my-demand") + @Schema(description = "내 크루 참여 요청 수정") + public ApiResponse updateMyDemand( + @PathVariable final UUID crewId, + @RequestBody @Valid final UpdateDemandRequest request) { + UpdateDemandResponse response = manageDemandUseCase.updateDemand(crewId, request); + return ApiResponse.ok(response); + } } diff --git a/src/test/java/com/retrip/crew/application/in/DemandServiceTest.java b/src/test/java/com/retrip/crew/application/in/DemandServiceTest.java index 7b5d17c..8ec3a5a 100644 --- a/src/test/java/com/retrip/crew/application/in/DemandServiceTest.java +++ b/src/test/java/com/retrip/crew/application/in/DemandServiceTest.java @@ -5,18 +5,22 @@ import com.retrip.crew.application.in.request.crew.CrewOrder; import com.retrip.crew.application.in.request.demand.CreateDemandRequest; import com.retrip.crew.application.in.request.demand.DemandOrder; +import com.retrip.crew.application.in.request.demand.UpdateDemandRequest; import com.retrip.crew.application.in.response.CreateRecruitmentQuestionResponse; import com.retrip.crew.application.in.response.RecruitmentQuestionResponse; import com.retrip.crew.application.in.response.UpdateRecruitmentQuestionResponse; import com.retrip.crew.application.in.response.demand.CreateDemandResponse; import com.retrip.crew.application.in.response.demand.CrewsOfDemandResponse; import com.retrip.crew.application.in.response.demand.DemandsResponse; +import com.retrip.crew.application.in.response.demand.MyDemandResponse; import com.retrip.crew.common.ServiceTest; import com.retrip.crew.domain.entity.Crew; import com.retrip.crew.domain.entity.Demand; import com.retrip.crew.domain.entity.RecruitmentQuestion; import com.retrip.crew.domain.exception.DuplicateDemandException; -import com.retrip.crew.domain.exception.QuestionNotFoundException; +import com.retrip.crew.domain.exception.IllegalDemandStateException; // 수정된 import +import com.retrip.crew.domain.exception.common.EntityNotFoundException; +import com.retrip.crew.domain.exception.common.InvalidValueException; import com.retrip.crew.domain.vo.DemandStatus; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -26,21 +30,30 @@ import org.springframework.data.domain.Pageable; import org.springframework.test.util.ReflectionTestUtils; +import java.util.Collections; import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; import static com.retrip.crew.common.fixture.CrewFixture.*; -import static com.retrip.crew.common.fixture.CrewFixture.LEADER_ID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertAll; class DemandServiceTest extends ServiceTest { + + private List createValidAnswersForCrew(Crew crew, String content) { + return crew.getRecruitment().getRecruitmentQuestions().getValues().stream() + .map(q -> new CreateDemandRequest.AnswerRequest(q.getId(), content)) + .collect(Collectors.toList()); + } + @Test void 크루_참여_요청을_생성한다() { // given Crew crew = crewRepository.save(createCrew(LEADER_ID)); - CreateDemandRequest request = new CreateDemandRequest(MEMBER_ID); + List answerRequests = createValidAnswersForCrew(crew, "열정적으로 참여하겠습니다!"); + CreateDemandRequest request = new CreateDemandRequest(MEMBER_ID, answerRequests); // when CreateDemandResponse response = demandService.createDemand(crew.getId(), request); @@ -48,8 +61,9 @@ class DemandServiceTest extends ServiceTest { // then List demands = crew.getRecruitment().getDemands(); assertAll( - () -> assertThat(demands.size()).isEqualTo(1), - () -> assertThat(response.memberId()).isEqualTo(demands.get(0).getMemberId()) + () -> assertThat(demands).hasSize(1), + () -> assertThat(response.memberId()).isEqualTo(demands.get(0).getMemberId()), + () -> assertThat(demands.get(0).getAnswers()).hasSize(answerRequests.size()) ); } @@ -58,13 +72,13 @@ class DemandServiceTest extends ServiceTest { // given Crew crew = createCrew(LEADER_ID); crew.demand(MEMBER_ID); - crew.demand(UUID.randomUUID()); - crew.demand(UUID.randomUUID()); - Crew save = crewRepository.save(crew); - CreateDemandRequest request = new CreateDemandRequest(MEMBER_ID); + Crew savedCrew = crewRepository.save(crew); + + List answerRequests = createValidAnswersForCrew(savedCrew, "다시 지원합니다!"); + CreateDemandRequest request = new CreateDemandRequest(MEMBER_ID, answerRequests); // when, then - assertThatThrownBy(() -> demandService.createDemand(save.getId(), request)) + assertThatThrownBy(() -> demandService.createDemand(savedCrew.getId(), request)) .isExactlyInstanceOf(DuplicateDemandException.class); } @@ -141,8 +155,6 @@ class DemandServiceTest extends ServiceTest { CreateRecruitmentQuestionResponse response = demandService.createRecruitmentQuestion(LEADER_ID, crew.getId(), request); - // then - // then List questions = crew.getRecruitment().getRecruitmentQuestions().getValues(); assertAll( @@ -211,4 +223,142 @@ class DemandServiceTest extends ServiceTest { "추가로 하고 싶은 말이 있나요?" ); } -} + + @Test + void 답변_개수가_질문_개수와_다르면_예외가_발생한다() { + // given + Crew crew = crewRepository.save(createCrew(LEADER_ID)); + CreateDemandRequest request = new CreateDemandRequest(MEMBER_ID, Collections.emptyList()); + + // when & then + assertThatThrownBy(() -> demandService.createDemand(crew.getId(), request)) + .isInstanceOf(InvalidValueException.class) + .hasMessageContaining("개수가 일치하지 않습니다."); + } + + @Test + void 답변_내용이_너무_길면_예외가_발생한다() { + // given + Crew crew = crewRepository.save(createCrew(LEADER_ID)); + String longAnswer = "a".repeat(501); + List answerRequests = createValidAnswersForCrew(crew, longAnswer); + CreateDemandRequest request = new CreateDemandRequest(MEMBER_ID, answerRequests); + + // when & then + assertThatThrownBy(() -> demandService.createDemand(crew.getId(), request)) + .isInstanceOf(InvalidValueException.class) + .hasMessageContaining("이하로 입력해야 합니다."); + } + + @Test + void 다른_크루의_질문으로_답변하면_예외가_발생한다() { + // given + Crew crewA = crewRepository.save(createCrew(LEADER_ID)); + Crew crewB = crewRepository.save(createCrew(UUID.randomUUID())); + + RecruitmentQuestion questionFromCrewB = crewB.getRecruitment().getRecruitmentQuestions().getValues().get(0); + CreateDemandRequest.AnswerRequest invalidAnswer = new CreateDemandRequest.AnswerRequest(questionFromCrewB.getId(), "잘못된 답변"); + + List answerRequests = crewA.getRecruitment().getRecruitmentQuestions().getValues().stream() + .skip(1) + .map(q -> new CreateDemandRequest.AnswerRequest(q.getId(), "정상 답변")) + .collect(Collectors.toList()); + answerRequests.add(invalidAnswer); + + CreateDemandRequest request = new CreateDemandRequest(MEMBER_ID, answerRequests); + + // when & then + assertThatThrownBy(() -> demandService.createDemand(crewA.getId(), request)) + .isInstanceOf(InvalidValueException.class) + .hasMessageContaining("해당 크루의 질문이 아닙니다."); + } + + @Test + void 내_크루_참여_요청을_조회한다() { + // given + Crew crew = crewRepository.save(createCrew(LEADER_ID)); + List answerRequests = createValidAnswersForCrew(crew, "제 답변입니다."); + demandService.createDemand(crew.getId(), new CreateDemandRequest(MEMBER_ID, answerRequests)); + + // when + MyDemandResponse response = demandService.getMyDemand(crew.getId(), MEMBER_ID); + + // then + assertAll( + () -> assertThat(response.crewId()).isEqualTo(crew.getId()), + () -> assertThat(response.status()).isEqualTo(DemandStatus.PENDING), + () -> assertThat(response.answers()).hasSize(5), + () -> assertThat(response.answers().get(0).answerContent()).isEqualTo("제 답변입니다.") + ); + } + + @Test + void 존재하지_않는_내_참여_요청을_조회하면_예외가_발생한다() { + // given + Crew crew = crewRepository.save(createCrew(LEADER_ID)); + + // when & then + assertThatThrownBy(() -> demandService.getMyDemand(crew.getId(), MEMBER_ID)) + .isInstanceOf(EntityNotFoundException.class) + .hasMessageContaining("사용자의 참여 요청을 찾을 수 없습니다."); + } + + @Test + void 내_크루_참여_요청을_수정한다() { + // given + Crew crew = crewRepository.save(createCrew(LEADER_ID)); + List initialAnswers = createValidAnswersForCrew(crew, "처음 쓴 답변"); + demandService.createDemand(crew.getId(), new CreateDemandRequest(MEMBER_ID, initialAnswers)); + + List updatedAnswers = crew.getRecruitment().getRecruitmentQuestions().getValues().stream() + .map(q -> new UpdateDemandRequest.AnswerRequest(q.getId(), "새롭게 수정한 답변입니다.")) + .toList(); + UpdateDemandRequest updateRequest = new UpdateDemandRequest(MEMBER_ID, updatedAnswers); + + // when + demandService.updateDemand(crew.getId(), updateRequest); + MyDemandResponse result = demandService.getMyDemand(crew.getId(), MEMBER_ID); + + // then + assertThat(result.answers().get(0).answerContent()).isEqualTo("새롭게 수정한 답변입니다."); + } + + @Test + void 대기상태가_아닌_참여_요청을_수정하려하면_예외가_발생한다() { + // given + Crew crew = crewRepository.save(createCrew(LEADER_ID)); + List answerRequests = createValidAnswersForCrew(crew, "답변"); + CreateDemandResponse demandResponse = demandService.createDemand(crew.getId(), new CreateDemandRequest(MEMBER_ID, answerRequests)); + + demandService.approveDemand(crew.getId(), demandResponse.demandId(), LEADER_ID); + + List updatedAnswers = createValidAnswersForCrew(crew, "수정 답변").stream() + .map(a -> new UpdateDemandRequest.AnswerRequest(a.questionId(), a.content())) + .toList(); + UpdateDemandRequest updateRequest = new UpdateDemandRequest(MEMBER_ID, updatedAnswers); + + // when & then + assertThatThrownBy(() -> demandService.updateDemand(crew.getId(), updateRequest)) + .isExactlyInstanceOf(IllegalDemandStateException.class) + .hasMessageContaining("대기중인 참여 요청만 수정할 수 있습니다."); + } + + @Test + void 다른사람의_참여_요청을_수정하려하면_예외가_발생한다() { + // given + Crew crew = crewRepository.save(createCrew(LEADER_ID)); + List answerRequests = createValidAnswersForCrew(crew, "원래 답변"); + demandService.createDemand(crew.getId(), new CreateDemandRequest(MEMBER_ID, answerRequests)); + + UUID anotherMemberId = UUID.randomUUID(); + List updatedAnswers = createValidAnswersForCrew(crew, "수정 답변").stream() + .map(a -> new UpdateDemandRequest.AnswerRequest(a.questionId(), a.content())) + .toList(); + UpdateDemandRequest updateRequest = new UpdateDemandRequest(anotherMemberId, updatedAnswers); + + // when & then + assertThatThrownBy(() -> demandService.updateDemand(crew.getId(), updateRequest)) + .isInstanceOf(EntityNotFoundException.class) + .hasMessageContaining("사용자의 참여 요청을 찾을 수 없습니다."); + } +} \ No newline at end of file