Skip to content

Commit

Permalink
✨ [STMT-193] 스터디 탈퇴 기능 구현 (#108)
Browse files Browse the repository at this point in the history
* ♻️ [STMT-193] 공통 응답 객체에 성공시 데이터가 전달되지 않는 경우에 대한 코드 추가

* ♻️ [STMT-193] 스터디 탈퇴시에 대한 성공 코드 추가

* ✨ [STMT-193] 스터디 탈퇴 기능 구현

* ✅ [STMT-193] 스터디 탈퇴 기능에 대한 테스트 코드 추가
  • Loading branch information
zxcv9203 authored Mar 30, 2024
1 parent c7c69e4 commit b7bf2b5
Show file tree
Hide file tree
Showing 11 changed files with 292 additions and 2 deletions.
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);
}
}
}

0 comments on commit b7bf2b5

Please sign in to comment.