Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ [STMT-169] 스터디 완료 기능 구현 #122

Merged
merged 3 commits into from
May 20, 2024
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
Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<Void>> finish(
@AuthenticationPrincipal LoginMember member,
@PathVariable Long studyId
) {
studyFinishUseCase.finish(studyId, member.getMember().getId());

return new ResponseEntity<>(
ApiResponse.success(SuccessCode.UPDATE_SUCCESS),
HttpStatus.OK);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.stumeet.server.study.application.port.in;

public interface StudyFinishUseCase {

void finish(Long studyId, Long memberId);
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
16 changes: 16 additions & 0 deletions src/main/java/com/stumeet/server/study/domain/Study.java
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -84,6 +86,20 @@ public boolean isStudyTagChanged(List<String> 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();
}
Expand Down
30 changes: 28 additions & 2 deletions src/main/java/com/stumeet/server/study/domain/StudyPeriod.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
20 changes: 20 additions & 0 deletions src/test/java/com/stumeet/server/stub/StudyStub.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ public static Long getInvalidStudyId() {
return 0L;
}

public static Long getFutureStudyId() {
return 2L;
}

public static StudyCreateCommand getStudyCreateCommand() {
return new StudyCreateCommand(
"어학",
Expand Down Expand Up @@ -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(
"어학",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("응답 메시지")
)));
}
}
}
Original file line number Diff line number Diff line change
@@ -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("응답 메시지")
)));
}
}
13 changes: 12 additions & 1 deletion src/test/resources/db/setup.sql
Original file line number Diff line number Diff line change
@@ -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- 제시간에 제출하기!',
Expand Down Expand Up @@ -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);
'서울', 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);
Loading