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-193] 스터디 탈퇴 기능 구현 #108

Merged
merged 4 commits into from
Mar 30, 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
26 changes: 26 additions & 0 deletions src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,29 @@ include::{snippets}/get-study-members/fail/not-joined-member/response-fields.ado
===== 응답 실패 (404)
include::{snippets}/get-study-members/fail/not-exist-study/response-body.adoc[]
include::{snippets}/get-study-members/fail/not-exist-study/response-fields.adoc[]

=== 스터디 멤버 탈퇴

전달받은 스터디에 대해 탈퇴하는 API입니다.

==== DELETE /v1/api/studies/{studyId}/members/me

===== 요청
include::{snippets}/leave-study/success/http-request.adoc[]
include::{snippets}/leave-study/success/request-headers.adoc[]
include::{snippets}/leave-study/success/path-parameters.adoc[]

===== 응답 성공 (200)
.스터디 탈퇴 성공
include::{snippets}/leave-study/success/response-body.adoc[]
include::{snippets}/leave-study/success/response-fields.adoc[]

===== 응답 실패 (403)
.스터디에 가입한 멤버가 아닌 경우
include::{snippets}/leave-study/fail/not-joined-member/response-body.adoc[]
include::{snippets}/leave-study/fail/not-joined-member/response-fields.adoc[]

===== 응답 실패 (404)
.전달한 스터디가 존재하지 않는 경우
include::{snippets}/leave-study/fail/not-exist-study/response-body.adoc[]
include::{snippets}/leave-study/fail/not-exist-study/response-fields.adoc[]
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ public static <T> ApiResponse<T> success(SuccessCode code, T data) {
return new ApiResponse<>(code.getHttpStatusCode(), code.getMessage(), data);
}

public static <T> ApiResponse<T> success(SuccessCode code) {
return new ApiResponse<>(code.getHttpStatusCode(), code.getMessage(), null);
}

public static ApiResponse<Void> fail(ErrorCode errorCode) {
return new ApiResponse<>(
errorCode.getHttpStatusCode(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ public enum SuccessCode {
GET_SUCCESS(HttpStatus.OK, "조회에 성공했습니다."),
POST_SUCCESS(HttpStatus.CREATED, "생성에 성공했습니다."),
SIGN_UP_SUCCESS(HttpStatus.CREATED, "회원가입에 성공했습니다."),
FILE_UPLOAD_SUCCESS(HttpStatus.CREATED, "파일 업로드에 성공했습니다.")
;
FILE_UPLOAD_SUCCESS(HttpStatus.CREATED, "파일 업로드에 성공했습니다."),
STUDY_LEAVE_SUCCESS(HttpStatus.OK, "스터디 탈퇴에 성공했습니다.");

private final HttpStatus httpStatus;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.stumeet.server.studymember.adapter.in.web;

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.studymember.application.port.in.StudyMemberLeaveUseCase;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;

@WebAdapter
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class StudyMemberLeaveApi {

private final StudyMemberLeaveUseCase studyMemberLeaveUseCase;

@DeleteMapping("/studies/{studyId}/members/me")
public ResponseEntity<ApiResponse<Void>> leave(
@PathVariable Long studyId,
@AuthenticationPrincipal LoginMember member
) {
studyMemberLeaveUseCase.leave(studyId, member.getMember().getId());
return new ResponseEntity<>(ApiResponse.success(SuccessCode.STUDY_LEAVE_SUCCESS), HttpStatus.OK);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@

public interface JpaStudyMemberRepository extends JpaRepository<StudyMemberJpaEntity, Long>, JpaStudyMemberRepositoryCustom {
long countByStudyId(Long studyId);

void deleteByStudyIdAndMemberId(Long studyId, Long memberId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.stumeet.server.studymember.adapter.out.persistence;

import com.stumeet.server.common.annotation.PersistenceAdapter;
import com.stumeet.server.studymember.application.port.out.StudyMemberLeavePort;
import lombok.RequiredArgsConstructor;

@PersistenceAdapter
@RequiredArgsConstructor
public class StudyMemberLeavePersistenceAdapter implements StudyMemberLeavePort {
private final JpaStudyMemberRepository jpaStudyMemberRepository;

@Override
public void leave(Long studyId, Long memberId) {
jpaStudyMemberRepository.deleteByStudyIdAndMemberId(studyId, memberId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.stumeet.server.studymember.application.port.in;

public interface StudyMemberLeaveUseCase {
void leave(Long studyId, Long memberId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.stumeet.server.studymember.application.port.out;

public interface StudyMemberLeavePort {
void leave(Long studyId, Long memberId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.stumeet.server.studymember.application.service;

import com.stumeet.server.common.annotation.UseCase;
import com.stumeet.server.study.application.port.in.StudyValidationUseCase;
import com.stumeet.server.studymember.application.port.in.StudyMemberLeaveUseCase;
import com.stumeet.server.studymember.application.port.in.StudyMemberValidationUseCase;
import com.stumeet.server.studymember.application.port.out.StudyMemberLeavePort;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;

@UseCase
@Transactional
@RequiredArgsConstructor
public class StudyMemberLeaveService implements StudyMemberLeaveUseCase {
private final StudyValidationUseCase studyValidationUseCase;
private final StudyMemberValidationUseCase studyMemberValidationUseCase;
private final StudyMemberLeavePort studyMemberLeavePort;


@Override
public void leave(Long studyId, Long memberId) {
studyValidationUseCase.checkById(studyId);
studyMemberValidationUseCase.checkStudyJoinMember(studyId, memberId);

studyMemberLeavePort.leave(studyId, memberId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.stumeet.server.studymember.adapter.in.web;

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;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

class StudyMemberLeaveApiTest extends ApiTest {

@Nested
@DisplayName("스터디 멤버 탈퇴 API")
class Leave {

private final String path = "/api/v1/studies/{studyId}/members/me";

@Test
@WithMockMember
@DisplayName("[성공] 스터디 멤버 탈퇴에 성공한다.")
void successTest() throws Exception {
Long studyId = StudyStub.getStudyId();

mockMvc.perform(delete(path, studyId)
.header(AuthenticationHeader.ACCESS_TOKEN.getName(), TokenStub.getMockAccessToken()))
.andExpect(status().isOk())
.andDo(document("leave-study/success",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
requestHeaders(headerWithName(AuthenticationHeader.ACCESS_TOKEN.getName()).description("서버로부터 전달받은 액세스 토큰")),
pathParameters(
parameterWithName("studyId").description("스터디 ID")
),
responseFields(
fieldWithPath("code").description("응답 코드"),
fieldWithPath("message").description("응답 메시지")
)));
}

@Test
@WithMockMember
@DisplayName("[실패] 스터디가 존재하지 않는 경우 스터디 멤버 탈퇴에 실패한다.")
void notExistStudyTest() throws Exception {
Long invalidStudyId = StudyStub.getInvalidStudyId();

mockMvc.perform(delete(path, invalidStudyId)
.header(AuthenticationHeader.ACCESS_TOKEN.getName(), TokenStub.getMockAccessToken()))
.andExpect(status().isNotFound())
.andDo(document("leave-study/fail/not-exist-study",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
requestHeaders(headerWithName(AuthenticationHeader.ACCESS_TOKEN.getName()).description("서버로부터 전달받은 액세스 토큰")),
pathParameters(
parameterWithName("studyId").description("스터디 ID")
),
responseFields(
fieldWithPath("code").description("응답 코드"),
fieldWithPath("message").description("응답 메시지")
)));
}

@Test
@WithMockMember(id = 0L)
@DisplayName("[실패] 스터디에 가입된 멤버가 아닌 경우 스터디 멤버 탈퇴에 실패한다.")
void notStudyJoinMemberTest() throws Exception {
Long studyId = StudyStub.getStudyId();

mockMvc.perform(delete(path, studyId)
.header(AuthenticationHeader.ACCESS_TOKEN.getName(), TokenStub.getMockAccessToken()))
.andExpect(status().isForbidden())
.andDo(document("leave-study/fail/not-joined-member",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
requestHeaders(headerWithName(AuthenticationHeader.ACCESS_TOKEN.getName()).description("서버로부터 전달받은 액세스 토큰")),
pathParameters(
parameterWithName("studyId").description("스터디 ID")
),
responseFields(
fieldWithPath("code").description("응답 코드"),
fieldWithPath("message").description("응답 메시지")
)));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.stumeet.server.studymember.application.service;

import com.stumeet.server.stub.MemberStub;
import com.stumeet.server.stub.StudyStub;
import com.stumeet.server.study.application.port.in.StudyValidationUseCase;
import com.stumeet.server.study.domain.exception.StudyNotExistsException;
import com.stumeet.server.studymember.application.port.in.StudyMemberValidationUseCase;
import com.stumeet.server.studymember.application.port.out.StudyMemberLeavePort;
import com.stumeet.server.studymember.domain.exception.StudyMemberNotJoinedException;
import com.stumeet.server.template.UnitTest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;

import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.then;
import static org.mockito.BDDMockito.willThrow;

class StudyMemberLeaveServiceTest extends UnitTest {

@InjectMocks
private StudyMemberLeaveService studyMemberLeaveService;

@Mock
private StudyValidationUseCase studyValidationUseCase;

@Mock
private StudyMemberValidationUseCase studyMemberValidationUseCase;

@Mock
private StudyMemberLeavePort studyMemberLeavePort;

@Nested
@DisplayName("스터디 멤버 탈퇴")
class Leave {

@Test
@DisplayName("[성공] 스터디 멤버 탈퇴에 성공한다.")
void successTest() {
Long studyId = StudyStub.getStudyId();
Long memberId = MemberStub.getMemberId();

studyMemberLeaveService.leave(studyId, memberId);

then(studyMemberLeavePort).should().leave(any(), any());
}

@Test
@DisplayName("[실패] 스터디가 존재하지 않는 경우 예외가 발생한다.")
void studyNotFoundTest() {
Long studyId = StudyStub.getInvalidStudyId();
Long memberId = MemberStub.getMemberId();

willThrow(StudyNotExistsException.class)
.given(studyValidationUseCase).checkById(studyId);

assertThatCode(() -> studyMemberLeaveService.leave(studyId, memberId))
.isInstanceOf(StudyNotExistsException.class);
}

@Test
@DisplayName("[실패] 해당 스터디에 가입한 회원이 아닌 경우 예외가 발생한다.")
void notJoinMemberTest() {
Long studyId = StudyStub.getStudyId();
Long memberId = MemberStub.getMemberId();

willThrow(StudyMemberNotJoinedException.class)
.given(studyMemberValidationUseCase).checkStudyJoinMember(studyId, memberId);

assertThatCode(() -> studyMemberLeaveService.leave(studyId, memberId))
.isInstanceOf(StudyMemberNotJoinedException.class);
}
}
}
Loading