diff --git a/src/main/java/com/stumeet/server/common/response/ErrorCode.java b/src/main/java/com/stumeet/server/common/response/ErrorCode.java index 71a43009..d857d714 100644 --- a/src/main/java/com/stumeet/server/common/response/ErrorCode.java +++ b/src/main/java/com/stumeet/server/common/response/ErrorCode.java @@ -28,7 +28,11 @@ public enum ErrorCode { INVALID_FILE_NAME_EXCEPTION(HttpStatus.BAD_REQUEST, "유효하지 않은 파일 이름입니다."), INVALID_FILE_CONTENT_TYPE_EXCEPTION(HttpStatus.BAD_REQUEST, "유효하지 않은 파일 컨텐트 타입 입니다."), INVALID_FILE_EXTENSION_EXCEPTION(HttpStatus.BAD_REQUEST, "유효하지 않은 파일 확장자입니다."), + INVALID_ACTIVITY_CATEGORY_EXCEPTION(HttpStatus.BAD_REQUEST, "유효하지 않은 활동 카테고리입니다."), + INVALID_STUDY_PERIOD_EXCEPTION(HttpStatus.BAD_REQUEST, "종료일이 시작일보다 빠릅니다."), + START_DATE_NOT_YET_EXCEPTION(HttpStatus.BAD_REQUEST, "시작일 전에 스터디를 완료할 수 없습니다."), + /* 401 - UNAUTHORIZED */ diff --git a/src/main/java/com/stumeet/server/study/adapter/in/web/StudyFinishApi.java b/src/main/java/com/stumeet/server/study/adapter/in/web/StudyFinishApi.java new file mode 100644 index 00000000..c8182c72 --- /dev/null +++ b/src/main/java/com/stumeet/server/study/adapter/in/web/StudyFinishApi.java @@ -0,0 +1,36 @@ +package com.stumeet.server.study.adapter.in.web; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; + +import com.stumeet.server.common.annotation.WebAdapter; +import com.stumeet.server.common.auth.model.LoginMember; +import com.stumeet.server.common.model.ApiResponse; +import com.stumeet.server.common.response.SuccessCode; +import com.stumeet.server.study.application.port.in.StudyFinishUseCase; + +import lombok.RequiredArgsConstructor; + +@WebAdapter +@RequestMapping("/api/v1/studies") +@RequiredArgsConstructor +public class StudyFinishApi { + + private final StudyFinishUseCase studyFinishUseCase; + + @PatchMapping("/{studyId}/finish") + public ResponseEntity> finish( + @AuthenticationPrincipal LoginMember member, + @PathVariable Long studyId + ) { + studyFinishUseCase.finish(studyId, member.getMember().getId()); + + return new ResponseEntity<>( + ApiResponse.success(SuccessCode.UPDATE_SUCCESS), + HttpStatus.OK); + } +} diff --git a/src/main/java/com/stumeet/server/study/application/port/in/StudyFinishUseCase.java b/src/main/java/com/stumeet/server/study/application/port/in/StudyFinishUseCase.java new file mode 100644 index 00000000..0f2de9f1 --- /dev/null +++ b/src/main/java/com/stumeet/server/study/application/port/in/StudyFinishUseCase.java @@ -0,0 +1,6 @@ +package com.stumeet.server.study.application.port.in; + +public interface StudyFinishUseCase { + + void finish(Long studyId, Long memberId); +} diff --git a/src/main/java/com/stumeet/server/study/application/service/StudyFinishService.java b/src/main/java/com/stumeet/server/study/application/service/StudyFinishService.java new file mode 100644 index 00000000..2598d94a --- /dev/null +++ b/src/main/java/com/stumeet/server/study/application/service/StudyFinishService.java @@ -0,0 +1,33 @@ +package com.stumeet.server.study.application.service; + +import org.springframework.transaction.annotation.Transactional; + +import com.stumeet.server.common.annotation.UseCase; +import com.stumeet.server.study.application.port.in.StudyFinishUseCase; +import com.stumeet.server.study.application.port.out.StudyCommandPort; +import com.stumeet.server.study.application.port.out.StudyQueryPort; +import com.stumeet.server.study.domain.Study; +import com.stumeet.server.studymember.application.port.in.StudyMemberValidationUseCase; + +import lombok.RequiredArgsConstructor; + +@UseCase +@RequiredArgsConstructor +@Transactional +public class StudyFinishService implements StudyFinishUseCase { + + private final StudyQueryPort studyQueryPort; + private final StudyCommandPort studyCommandPort; + private final StudyMemberValidationUseCase studyMemberValidationUseCase; + + @Override + public void finish(Long studyId, Long memberId) { + Study study = studyQueryPort.getById(studyId); + studyMemberValidationUseCase.checkStudyJoinMember(studyId, memberId); + studyMemberValidationUseCase.checkAdmin(studyId, memberId); + + study.finish(); + + studyCommandPort.save(study); + } +} diff --git a/src/main/java/com/stumeet/server/study/domain/Study.java b/src/main/java/com/stumeet/server/study/domain/Study.java index 37d05538..f3bd86f6 100644 --- a/src/main/java/com/stumeet/server/study/domain/Study.java +++ b/src/main/java/com/stumeet/server/study/domain/Study.java @@ -1,5 +1,7 @@ package com.stumeet.server.study.domain; +import com.stumeet.server.common.exception.model.InvalidStateException; +import com.stumeet.server.common.response.ErrorCode; import com.stumeet.server.study.application.port.in.command.StudyCreateCommand; import com.stumeet.server.study.application.port.in.command.StudyUpdateCommand; @@ -84,6 +86,20 @@ public boolean isStudyTagChanged(List studyTags) { return !getStudyTags().equals(studyTags); } + public void finish() { + LocalDate today = LocalDate.now(); + validateFinishPossible(today); + + period.determineEndDate(today); + this.isFinished = true; + } + + private void validateFinishPossible(LocalDate today) { + if (period.isBeforeStartDate(today)) { + throw new InvalidStateException(ErrorCode.START_DATE_NOT_YET_EXCEPTION); + } + } + public String getStudyFieldName() { return studyDomain.getStudyFieldName(); } diff --git a/src/main/java/com/stumeet/server/study/domain/StudyPeriod.java b/src/main/java/com/stumeet/server/study/domain/StudyPeriod.java index b032c6bc..be9fb327 100644 --- a/src/main/java/com/stumeet/server/study/domain/StudyPeriod.java +++ b/src/main/java/com/stumeet/server/study/domain/StudyPeriod.java @@ -2,14 +2,40 @@ import java.time.LocalDate; -import lombok.AllArgsConstructor; +import com.stumeet.server.common.exception.model.BadRequestException; +import com.stumeet.server.common.response.ErrorCode; + import lombok.Getter; -@AllArgsConstructor(staticName = "of") @Getter public class StudyPeriod { private LocalDate startDate; private LocalDate endDate; + + private StudyPeriod(LocalDate startDate, LocalDate endDate) { + validate(startDate, endDate); + + this.startDate = startDate; + this.endDate = endDate; + } + + private void validate(LocalDate startDate, LocalDate endDate) { + if (startDate.isAfter(endDate) || startDate.isEqual(endDate)) { + throw new BadRequestException(ErrorCode.INVALID_STUDY_PERIOD_EXCEPTION); + } + } + + public static StudyPeriod of(LocalDate startDate, LocalDate endDate) { + return new StudyPeriod(startDate, endDate); + } + + public boolean isBeforeStartDate(LocalDate date) { + return date.isBefore(startDate); + } + + protected void determineEndDate(LocalDate date) { + this.endDate = date; + } } diff --git a/src/test/java/com/stumeet/server/stub/StudyStub.java b/src/test/java/com/stumeet/server/stub/StudyStub.java index 3d3de35b..e0802f84 100644 --- a/src/test/java/com/stumeet/server/stub/StudyStub.java +++ b/src/test/java/com/stumeet/server/stub/StudyStub.java @@ -23,6 +23,10 @@ public static Long getInvalidStudyId() { return 0L; } + public static Long getFutureStudyId() { + return 2L; + } + public static StudyCreateCommand getStudyCreateCommand() { return new StudyCreateCommand( "어학", @@ -87,6 +91,22 @@ public static StudyCreateCommand getInvalidMeetingScheduleStudyCreateCommand() { ); } + public static StudyCreateCommand getInvalidStudyPeriodStudyCreateCommand() { + return new StudyCreateCommand( + "어학", + "영어 회화 클럽", + "매주 영어로 대화하며 언어 실력을 향상시키는 스터디 그룹입니다.", + "부산", + "주 1회 대면 미팅 및 온라인 토론", + LocalDate.parse("2999-12-31"), + LocalDate.parse("2024-01-01"), + LocalTime.parse("18:30:00"), + RepetitionType.valueOf("WEEKLY"), + List.of(), + List.of("영어", "회화", "언어 교환") + ); + } + public static StudyUpdateCommand getStudyUpdateCommand() { return new StudyUpdateCommand( "어학", diff --git a/src/test/java/com/stumeet/server/study/adapter/in/web/StudyCreateApiTest.java b/src/test/java/com/stumeet/server/study/adapter/in/web/StudyCreateApiTest.java index bcb5fcf5..804c605c 100644 --- a/src/test/java/com/stumeet/server/study/adapter/in/web/StudyCreateApiTest.java +++ b/src/test/java/com/stumeet/server/study/adapter/in/web/StudyCreateApiTest.java @@ -145,5 +145,28 @@ void failWithInvalidStudyMeetingSchedule() throws Exception { fieldWithPath("message").description("응답 메시지") ))); } + + @Test + @WithMockMember + @DisplayName("[실패] 유효하지 않은 스터디 기간으로 요청한 경우 스터디 생성을 실패한다.") + void fail_create_study_when_study_period_invalid() throws Exception { + StudyCreateCommand request = StudyStub.getInvalidStudyPeriodStudyCreateCommand(); + + mockMvc.perform(multipart(path) + .file(studyMainImage) + .part(new MockPart("request", "", objectMapper.writeValueAsBytes(request), MediaType.APPLICATION_JSON)) + .header(AuthenticationHeader.ACCESS_TOKEN.getName(), TokenStub.getMockAccessToken()) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andDo(document("create-study/fail/invalid-study-period", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders(headerWithName(AuthenticationHeader.ACCESS_TOKEN.getName()).description( + "서버로부터 전달받은 액세스 토큰")), + responseFields( + fieldWithPath("code").description("응답 상태"), + fieldWithPath("message").description("응답 메시지") + ))); + } } } \ No newline at end of file diff --git a/src/test/java/com/stumeet/server/study/adapter/in/web/StudyFinishApiTest.java b/src/test/java/com/stumeet/server/study/adapter/in/web/StudyFinishApiTest.java new file mode 100644 index 00000000..77c22e7d --- /dev/null +++ b/src/test/java/com/stumeet/server/study/adapter/in/web/StudyFinishApiTest.java @@ -0,0 +1,115 @@ +package com.stumeet.server.study.adapter.in.web; + +import static org.springframework.restdocs.headers.HeaderDocumentation.*; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.*; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.stumeet.server.common.auth.model.AuthenticationHeader; +import com.stumeet.server.helper.WithMockMember; +import com.stumeet.server.stub.StudyStub; +import com.stumeet.server.stub.TokenStub; +import com.stumeet.server.template.ApiTest; + +class StudyFinishApiTest extends ApiTest { + + @Nested + @DisplayName("스터디 완료 API") + class FinishStudy { + + @Test + @WithMockMember + @DisplayName("[성공] 스터디 완료를 성공한다.") + void success_finish_study() throws Exception { + mockMvc.perform(patch("/api/v1/studies/{id}/finish", StudyStub.getStudyId()) + .header(AuthenticationHeader.ACCESS_TOKEN.getName(), TokenStub.getMockAccessToken())) + .andExpect(status().isOk()) + .andDo(document("finish-study/success", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AuthenticationHeader.ACCESS_TOKEN.getName()).description("서버로부터 전달받은 액세스 토큰") + ), + pathParameters( + parameterWithName("id").description("스터디 ID") + ), + responseFields( + fieldWithPath("code").description("응답 상태"), + fieldWithPath("message").description("응답 메시지") + ))); + } + } + + @Test + @WithMockMember(id = 3L) + @DisplayName("[실패] 완료를 시도하는 멤버가 스터디 멤버가 아닌 경우 스터디 완료를 실패한다.") + void fail_to_finish_study_when_member_not_joined_study() throws Exception { + mockMvc.perform(patch("/api/v1/studies/{id}/finish", StudyStub.getStudyId()) + .header(AuthenticationHeader.ACCESS_TOKEN.getName(), TokenStub.getMockAccessToken())) + .andExpect(status().isForbidden()) + .andDo(document("finish-study/fail/member-not-admin", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AuthenticationHeader.ACCESS_TOKEN.getName()).description("서버로부터 전달받은 액세스 토큰") + ), + pathParameters( + parameterWithName("id").description("스터디 ID") + ), + responseFields( + fieldWithPath("code").description("응답 상태"), + fieldWithPath("message").description("응답 메시지") + ))); + } + + @Test + @WithMockMember(id = 2L) + @DisplayName("[실패] 완료를 시도하는 멤버가 관리자가 아닌 경우 스터디 완료를 실패한다.") + void fail_to_finish_study_when_study_member_is_not_admin() throws Exception { + mockMvc.perform(patch("/api/v1/studies/{id}/finish", StudyStub.getStudyId()) + .header(AuthenticationHeader.ACCESS_TOKEN.getName(), TokenStub.getMockAccessToken())) + .andExpect(status().isForbidden()) + .andDo(document("finish-study/fail/member-not-joined_study", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AuthenticationHeader.ACCESS_TOKEN.getName()).description("서버로부터 전달받은 액세스 토큰") + ), + pathParameters( + parameterWithName("id").description("스터디 ID") + ), + responseFields( + fieldWithPath("code").description("응답 상태"), + fieldWithPath("message").description("응답 메시지") + ))); + } + + @Test + @WithMockMember + @DisplayName("[실패] 현재 날짜가 시작일보다 이전이면 스터디 완료를 실패한다.") + void fail_finish_study_when_today_before_start_date() throws Exception { + mockMvc.perform(patch("/api/v1/studies/{id}/finish", StudyStub.getFutureStudyId()) + .header(AuthenticationHeader.ACCESS_TOKEN.getName(), TokenStub.getMockAccessToken())) + .andExpect(status().isBadRequest()) + .andDo(document("finish-study/fail/start-date-not-yet", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName(AuthenticationHeader.ACCESS_TOKEN.getName()).description("서버로부터 전달받은 액세스 토큰") + ), + pathParameters( + parameterWithName("id").description("스터디 ID") + ), + responseFields( + fieldWithPath("code").description("응답 상태"), + fieldWithPath("message").description("응답 메시지") + ))); + } +} \ No newline at end of file diff --git a/src/test/resources/db/setup.sql b/src/test/resources/db/setup.sql index 8c2286ba..916e0ad1 100644 --- a/src/test/resources/db/setup.sql +++ b/src/test/resources/db/setup.sql @@ -1,3 +1,4 @@ +-- study 1: 유효한 스터디 INSERT INTO study (id, study_field, name, region, intro, rule, image, meeting_time, meeting_repetition, start_date, end_date) VALUES (1, 'PROGRAMMING', 'effective java 스터디', '서울', 'java 스터디 입니다.', '- 장소: 태릉입구역\n- 제시간에 제출하기!', @@ -30,4 +31,14 @@ VALUES (2, 1, false, false); -- member id 3: study id 1의 외부자 INSERT INTO member (id, name, image, region, profession_id, role, auth_type, tier, experience, is_deleted, deleted_at) VALUES (3, 'test3', 'http://localhost:4572/user/1/profile/2024030416531039839905-b7e8-4ad3-9552-7d9cbc01cb14-test.jpg', - '서울', 1, 'MEMBER', 'OAUTH', 'SEED', 0.0, false, null); \ No newline at end of file + '서울', 1, 'MEMBER', 'OAUTH', 'SEED', 0.0, false, null); + +-- study 2: 미래 시작 스터디 +INSERT INTO study (id, study_field, name, region, intro, rule, image, + meeting_time, meeting_repetition, start_date, end_date) +VALUES (2, 'PROGRAMMING', 'future study', '서울', 'java 스터디 입니다.', '- 장소: 태릉입구역\n- 제시간에 제출하기!', + 'https://stumeet.s3.ap-northeast-2.amazonaws.com/study/1/image/2023062711172178420.png', + '18:00:00', 'WEEKLY;월;수;목;', '2999-04-01', '2999-05-01'); + +INSERT INTO study_member(member_id, study_id, is_admin, is_sent_grape) +VALUES (1, 2, true, false); \ No newline at end of file