diff --git a/build.gradle b/build.gradle index 930cfce..5aef1cc 100644 --- a/build.gradle +++ b/build.gradle @@ -59,6 +59,10 @@ dependencies { // Spring REST Docs asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor' testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + + // test lombok + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' } tasks.named('test') { diff --git a/src/docs/asciidoc/api/category.adoc b/src/docs/asciidoc/api/category.adoc index 6756319..0662993 100644 --- a/src/docs/asciidoc/api/category.adoc +++ b/src/docs/asciidoc/api/category.adoc @@ -3,33 +3,30 @@ === 카테고리 목록 조회 .HTTP Request -include::{snippets}/category/getCategoryList/success/http-request.adoc[] +include::{snippets}/category-controller-test/get_list/http-request.adoc[] .HTTP Response -include::{snippets}/category/getCategoryList/success/http-response.adoc[] +include::{snippets}/category-controller-test/get_list/http-response.adoc[] .Response Fields -include::{snippets}/category/getCategoryList/success/response-fields.adoc[] +include::{snippets}/category-controller-test/get_list/response-fields.adoc[] === 카테고리 개별 조회 - 성공 -include::{snippets}/category/getCategory/success/path-parameters.adoc[] +include::{snippets}/category-controller-test/get_success/path-parameters.adoc[] .HTTP Request -include::{snippets}/category/getCategory/success/http-request.adoc[] +include::{snippets}/category-controller-test/get_success/http-request.adoc[] .HTTP Response -include::{snippets}/category/getCategory/success/http-response.adoc[] -include::{snippets}/category/getCategory/success/response-fields.adoc[] +include::{snippets}/category-controller-test/get_success/http-response.adoc[] === 카테고리 개별 조회 - 실패 .HTTP Request -include::{snippets}/category/getCategory/fail-404/http-request.adoc[] +include::{snippets}/category-controller-test/get_fail/http-request.adoc[] .HTTP Response -include::{snippets}/category/getCategory/fail-404/http-response.adoc[] +include::{snippets}/category-controller-test/get_fail/http-response.adoc[] -.Error Response -include::{snippets}/category/getCategory/fail-404/response-fields.adoc[] diff --git a/src/docs/asciidoc/api/meeting.adoc b/src/docs/asciidoc/api/meeting.adoc index c153d80..29e2372 100644 --- a/src/docs/asciidoc/api/meeting.adoc +++ b/src/docs/asciidoc/api/meeting.adoc @@ -3,95 +3,52 @@ === 모임 생성 - 성공 .HTTP Request -include::{snippets}/meeting/create/success/http-request.adoc[] +include::{snippets}/meeting-controller-test/create_success/http-request.adoc[] .Request Fields -include::{snippets}/meeting/create/success/request-fields.adoc[] +include::{snippets}/meeting-controller-test/create_success/request-fields.adoc[] .HTTP Response -include::{snippets}/meeting/create/success/http-response.adoc[] +include::{snippets}/meeting-controller-test/create_success/http-response.adoc[] === 모임 생성 - 실패 .HTTP Request -include::{snippets}/meeting/create/fail/http-request.adoc[] +include::{snippets}/meeting-controller-test/create_fail_invalid/http-request.adoc[] .HTTP Response -include::{snippets}/meeting/create/fail/http-response.adoc[] +include::{snippets}/meeting-controller-test/create_fail_invalid/http-response.adoc[] .Error Response -include::{snippets}/meeting/create/fail/response-fields.adoc[] +include::{snippets}/meeting-controller-test/create_fail_invalid/response-fields.adoc[] -=== 모임 전체 조회 +=== 모임 조회 (UUID) -.Response Fields -include::{snippets}/meeting/getList/success/response-fields.adoc[] +include::{snippets}/meeting-controller-test/get_by-uuid/path-parameters.adoc[] .HTTP Request -include::{snippets}/meeting/getList/success/http-request.adoc[] +include::{snippets}/meeting-controller-test/get_by-uuid/http-request.adoc[] .HTTP Response -include::{snippets}/meeting/getList/success/http-response.adoc[] +include::{snippets}/meeting-controller-test/get_by-uuid/http-response.adoc[] -=== 모임 개별 조회 - 성공 +include::{snippets}/meeting-controller-test/get_by-uuid/response-fields.adoc[] -include::{snippets}/meeting/get/success/path-parameters.adoc[] +=== 모임 확정 일정 수정 .HTTP Request -include::{snippets}/meeting/get/success/http-request.adoc[] +include::{snippets}/meeting-controller-test/update_confirmed-schedule/http-request.adoc[] +include::{snippets}/meeting-controller-test/update_confirmed-schedule/request-fields.adoc[] .HTTP Response -include::{snippets}/meeting/get/success/http-response.adoc[] +include::{snippets}/meeting-controller-test/update_confirmed-schedule/http-response.adoc[] -=== 모임 개별 조회 - 실패 - -include::{snippets}/meeting/get/fail/path-parameters.adoc[] - -.HTTP Request -include::{snippets}/meeting/get/fail/http-request.adoc[] - -include::{snippets}/meeting/get/fail/http-response.adoc[] - -=== 모임 수정 - 성공 - -include::{snippets}/meeting/update/success/path-parameters.adoc[] - -.HTTP Request -include::{snippets}/meeting/update/success/http-request.adoc[] - - -.Request Fields -include::{snippets}/meeting/update/success/request-fields.adoc[] - - - -.HTTP Response -include::{snippets}/meeting/update/success/http-response.adoc[] - -=== 모임 수정 - 실패 - -.HTTP Request -include::{snippets}/meeting/update/fail/http-request.adoc[] - -.HTTP Response -include::{snippets}/meeting/update/fail/http-response.adoc[] - -=== 모임 삭제 - 성공 - -include::{snippets}/meeting/delete/success/path-parameters.adoc[] -.HTTP Request -include::{snippets}/meeting/delete/success/http-request.adoc[] - -.HTTP Response -include::{snippets}/meeting/delete/success/http-response.adoc[] - -=== 모임 삭제 - 실패 - -include::{snippets}/meeting/delete/fail/path-parameters.adoc[] +=== 모임 삭제 .HTTP Request -include::{snippets}/meeting/delete/fail/http-request.adoc[] +include::{snippets}/meeting-controller-test/delete_success/path-parameters.adoc[] +include::{snippets}/meeting-controller-test/delete_success/http-request.adoc[] .HTTP Response -include::{snippets}/meeting/delete/fail/http-response.adoc[] +include::{snippets}/meeting-controller-test/delete_success/http-response.adoc[] diff --git a/src/main/java/com/dnd/jjakkak/domain/jwt/exception/AccessTokenExpiredException.java b/src/main/java/com/dnd/jjakkak/domain/jwt/exception/AccessTokenExpiredException.java new file mode 100644 index 0000000..4dd34fb --- /dev/null +++ b/src/main/java/com/dnd/jjakkak/domain/jwt/exception/AccessTokenExpiredException.java @@ -0,0 +1,16 @@ +package com.dnd.jjakkak.domain.jwt.exception; + +import com.dnd.jjakkak.global.exception.GeneralException; + +public class AccessTokenExpiredException extends GeneralException { + private static final String MESSAGE = "엑세스 토큰이 만료됨."; + + public AccessTokenExpiredException() { + super(MESSAGE); + } + + @Override + public int getStatusCode() { + return 401; + } +} diff --git a/src/main/java/com/dnd/jjakkak/domain/jwt/exception/MalformedTokenException.java b/src/main/java/com/dnd/jjakkak/domain/jwt/exception/MalformedTokenException.java new file mode 100644 index 0000000..759aa7a --- /dev/null +++ b/src/main/java/com/dnd/jjakkak/domain/jwt/exception/MalformedTokenException.java @@ -0,0 +1,16 @@ +package com.dnd.jjakkak.domain.jwt.exception; + +import com.dnd.jjakkak.global.exception.GeneralException; + +public class MalformedTokenException extends GeneralException { + private static final String MESSAGE = "손상된 토큰."; + + public MalformedTokenException() { + super(MESSAGE); + } + + @Override + public int getStatusCode() { + return 401; + } +} diff --git a/src/main/java/com/dnd/jjakkak/domain/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/com/dnd/jjakkak/domain/jwt/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..9f22a78 --- /dev/null +++ b/src/main/java/com/dnd/jjakkak/domain/jwt/filter/JwtAuthenticationFilter.java @@ -0,0 +1,103 @@ +package com.dnd.jjakkak.domain.jwt.filter; + +import com.dnd.jjakkak.domain.jwt.exception.AccessTokenExpiredException; +import com.dnd.jjakkak.domain.jwt.exception.MalformedTokenException; +import com.dnd.jjakkak.domain.jwt.provider.JwtProvider; +import com.dnd.jjakkak.domain.member.entity.Member; +import com.dnd.jjakkak.domain.member.entity.Role; +import com.dnd.jjakkak.domain.member.exception.MemberNotFoundException; +import com.dnd.jjakkak.domain.member.repository.MemberRepository; +import com.dnd.jjakkak.global.config.security.SecurityConfig; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.PatternMatchUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * 검증을 실행하는 필터입니다. + * + * @author 류태웅 + * @version 2024. 08. 03. + */ + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtProvider jwtProvider; + private final MemberRepository memberRepository; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String path = request.getRequestURI(); + if(PatternMatchUtils.simpleMatch(SecurityConfig.WHITE_LIST, path)){ + log.debug("path: {} -> passed token filter", path); + filterChain.doFilter(request, response); + } + String token = parseBearerToken(request); + log.debug("도착한 토큰: {}", token); + if (token == null) { // Bearer 인증 방식이 아니거나 빈 값일 경우 진행하지 말고 다음 필터로 바로 넘김 + filterChain.doFilter(request, response); + return; + } + String kakaoId; + try { + kakaoId = jwtProvider.validate(token); + log.debug("검증된 카카오 ID: {}", kakaoId); + } catch (ExpiredJwtException e) { + log.error("엑세스 토큰이 만료됨", e); + throw new AccessTokenExpiredException(); + } catch (MalformedJwtException e) { + log.error("손상된 토큰", e); + throw new MalformedTokenException(); + } + + if (kakaoId == null) { + filterChain.doFilter(request, response); + return; + } + + Member member = memberRepository.findByKakaoId(Long.parseLong(kakaoId)) + .orElseThrow(MemberNotFoundException::new); + Role role = member.getRole(); + + List authorities = new ArrayList<>(); + authorities.add(new SimpleGrantedAuthority(role.toString())); + + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + AbstractAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(member, null, authorities); + authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + securityContext.setAuthentication(authenticationToken); + SecurityContextHolder.setContext(securityContext); + + filterChain.doFilter(request, response); + } + + private String parseBearerToken(HttpServletRequest request) { + String authorization = request.getHeader("Authorization"); + if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) { + return authorization.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/dnd/jjakkak/domain/jwt/handler/OAuth2FailureHandler.java b/src/main/java/com/dnd/jjakkak/domain/jwt/handler/OAuth2FailureHandler.java new file mode 100644 index 0000000..516c1e3 --- /dev/null +++ b/src/main/java/com/dnd/jjakkak/domain/jwt/handler/OAuth2FailureHandler.java @@ -0,0 +1,38 @@ +package com.dnd.jjakkak.domain.jwt.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * 검증 실패 시 예외 처리 링크로 이동하는 핸들러입니다. + * + * @author 류태웅 + * @version 2024. 08. 02. + */ + +@Component +public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler { + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { + String errorMessage; + if(e instanceof UsernameNotFoundException){ + errorMessage="존재하지 않는 아이디 입니다."; + } + else{ + errorMessage="알 수 없는 이유로 로그인이 안되고 있습니다."; + } + + errorMessage= URLEncoder.encode(errorMessage, StandardCharsets.UTF_8);//한글 인코딩 깨지는 문제 방지 + response.sendRedirect("http://localhost:8080/auth/oauth-response/error="+errorMessage); + } +} diff --git a/src/main/java/com/dnd/jjakkak/domain/jwt/handler/OAuth2LogoutHandler.java b/src/main/java/com/dnd/jjakkak/domain/jwt/handler/OAuth2LogoutHandler.java new file mode 100644 index 0000000..43a0040 --- /dev/null +++ b/src/main/java/com/dnd/jjakkak/domain/jwt/handler/OAuth2LogoutHandler.java @@ -0,0 +1,67 @@ +package com.dnd.jjakkak.domain.jwt.handler; + +import com.dnd.jjakkak.domain.member.service.BlacklistService; +import com.dnd.jjakkak.domain.member.service.RefreshTokenService; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +/** + * 로그아웃 시 토큰에 빈 값을 넣어 전송 하는 방식으로 토큰을 삭제하는 핸들러입니다. + * @author 류태웅 + * @version 2024. 08. 03. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2LogoutHandler implements LogoutHandler { + private final RefreshTokenService refreshTokenService; + private final BlacklistService blacklistService; + + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + String refreshToken = null; + + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if ("refresh_token".equals(cookie.getName())) { + refreshToken = cookie.getValue(); + break; + } + } + } + + log.debug("logout 시작"); + + if (refreshToken != null) { + log.debug("logout refreshToken: {}", refreshToken); + + if (refreshTokenService.validateRefreshToken(refreshToken)) { + try { + refreshTokenService.deleteRefreshToken(refreshToken); + LocalDateTime expirationDate = LocalDateTime.now().plusWeeks(1); + blacklistService.blacklistToken(refreshToken, expirationDate); + log.debug("logout 성공"); + response.setStatus(HttpServletResponse.SC_OK); + } catch (Exception e) { + log.error("서버 에러", e); + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + } else { + log.error("Refresh Token 인증 오류"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } + } else { + log.error("헤더 인증 오류"); + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + } + } +} diff --git a/src/main/java/com/dnd/jjakkak/domain/member/jwt/handler/OAuth2SuccessHandler.java b/src/main/java/com/dnd/jjakkak/domain/jwt/handler/OAuth2SuccessHandler.java similarity index 58% rename from src/main/java/com/dnd/jjakkak/domain/member/jwt/handler/OAuth2SuccessHandler.java rename to src/main/java/com/dnd/jjakkak/domain/jwt/handler/OAuth2SuccessHandler.java index 6beff03..dc74bca 100644 --- a/src/main/java/com/dnd/jjakkak/domain/member/jwt/handler/OAuth2SuccessHandler.java +++ b/src/main/java/com/dnd/jjakkak/domain/jwt/handler/OAuth2SuccessHandler.java @@ -1,9 +1,10 @@ -package com.dnd.jjakkak.domain.member.jwt.handler; +package com.dnd.jjakkak.domain.jwt.handler; +import com.dnd.jjakkak.domain.jwt.provider.JwtProvider; import com.dnd.jjakkak.domain.member.entity.Member; -import com.dnd.jjakkak.domain.member.jwt.provider.JwtProvider; import com.dnd.jjakkak.domain.member.service.RefreshTokenService; import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -16,11 +17,9 @@ /** * 검증 성공 시 토큰이 저장된 링크로 이동하는 핸들러입니다. - * * @author 류태웅 - * @version 2024. 07. 27. + * @version 2024. 08. 02. */ - @Slf4j @Component @RequiredArgsConstructor @@ -29,34 +28,33 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler private final JwtProvider jwtProvider; private final RefreshTokenService refreshTokenService; - /** - * 해당 메소드로 성공 시 링크로 이동합니다. - * 여기서 jwtProvider의 create를 수행합니다. - * - * @param request HttpServletRequest - * @param response HttpServletResponse - * @param authentication Authentication - * @throws IOException IOException - * @throws ServletException ServletException - */ - @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { Member oauth2User = (Member) authentication.getPrincipal(); String kakaoId = Long.toString(oauth2User.getKakaoId()); // Access Token 생성 - String token = jwtProvider.createAccessToken(kakaoId); + String accessToken = jwtProvider.createAccessToken(kakaoId); // Refresh Token 생성 및 저장 String refreshToken = jwtProvider.createRefreshToken(kakaoId); refreshTokenService.createRefreshToken(oauth2User.getMemberId(), refreshToken); - // 토큰을 응답 헤더에 추가 - response.setHeader("Authorization", "Bearer " + token); - response.setHeader("RefreshToken", refreshToken); - - // 로그인 성공 시 리다이렉트되는 URL은 추후 수정 필요 - response.sendRedirect("http://localhost:8080/auth/oauth-response/"); + log.debug("access token: " + accessToken); + log.debug("refresh token: " + refreshToken); + + // Refresh Token 쿠키 설정 + Cookie refreshTokenCookie = new Cookie("refresh_token", refreshToken); + refreshTokenCookie.setSecure(true); + refreshTokenCookie.setHttpOnly(true); + refreshTokenCookie.setPath("/"); // 모든 경로에서 접근 가능하도록 설정 + refreshTokenCookie.setMaxAge(60 * 60 * 24 * 7); // 1주일 + + // 쿠키 추가 + response.addCookie(refreshTokenCookie); + // Access Token을 헤더에 추가 + response.setHeader("Authorization", "Bearer " + accessToken); + // 리다이렉트할 URL 설정 (프론트엔드 페이지로 리다이렉트) + response.sendRedirect("http://localhost:3000/"); } } diff --git a/src/main/java/com/dnd/jjakkak/domain/member/jwt/provider/JwtProvider.java b/src/main/java/com/dnd/jjakkak/domain/jwt/provider/JwtProvider.java similarity index 74% rename from src/main/java/com/dnd/jjakkak/domain/member/jwt/provider/JwtProvider.java rename to src/main/java/com/dnd/jjakkak/domain/jwt/provider/JwtProvider.java index 26bdaed..37c19c1 100644 --- a/src/main/java/com/dnd/jjakkak/domain/member/jwt/provider/JwtProvider.java +++ b/src/main/java/com/dnd/jjakkak/domain/jwt/provider/JwtProvider.java @@ -1,9 +1,6 @@ -package com.dnd.jjakkak.domain.member.jwt.provider; +package com.dnd.jjakkak.domain.jwt.provider; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.ExpiredJwtException; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -19,7 +16,7 @@ * JWT를 만들고 검증하는 Provider입니다. * * @author 류태웅 - * @version 2024. 07. 27. + * @version 2024. 08. 02. */ @Slf4j @@ -78,26 +75,14 @@ public String createRefreshToken(String kakaoId) { * @return subject */ - public String validate(String jwt) { - String subject; - try { - Claims claims = Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(jwt) - .getBody(); - subject = claims.getSubject(); - log.info("subject, {}", subject); - } catch (ExpiredJwtException e) { - log.error("JWT 만료", e); - return null; - } catch (Exception e) { - log.error("JWT 검증 실패", e); - return null; - } - return subject; + public String validate(String jwt) throws ExpiredJwtException, MalformedJwtException, JwtException { + Claims claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(jwt) + .getBody(); + return claims.getSubject(); } - /** * RefreshToken에서 subject를 추출하는 메소드 * diff --git a/src/main/java/com/dnd/jjakkak/domain/meeting/controller/MeetingController.java b/src/main/java/com/dnd/jjakkak/domain/meeting/controller/MeetingController.java index 6906c58..3ed54bf 100644 --- a/src/main/java/com/dnd/jjakkak/domain/meeting/controller/MeetingController.java +++ b/src/main/java/com/dnd/jjakkak/domain/meeting/controller/MeetingController.java @@ -2,13 +2,16 @@ import com.dnd.jjakkak.domain.meeting.dto.request.MeetingConfirmRequestDto; import com.dnd.jjakkak.domain.meeting.dto.request.MeetingCreateRequestDto; -import com.dnd.jjakkak.domain.meeting.dto.request.MeetingUpdateRequestDto; +import com.dnd.jjakkak.domain.meeting.dto.response.MeetingCreateResponseDto; import com.dnd.jjakkak.domain.meeting.dto.response.MeetingResponseDto; import com.dnd.jjakkak.domain.meeting.service.MeetingService; +import com.dnd.jjakkak.domain.member.dto.response.MemberResponseDto; +import com.dnd.jjakkak.domain.member.entity.Member; import jakarta.validation.Valid; 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.*; import java.util.List; @@ -29,49 +32,41 @@ public class MeetingController { /** * 모임을 생성하는 메서드입니다. * + * @param member 로그인한 회원 정보 * @param requestDto 모임 생성 요청 DTO - * @return 201 (CREATED) + * @return 201 (CREATED), body: 모임 생성 응답 DTO (UUID) */ @PostMapping - public ResponseEntity createGroup(@Valid @RequestBody MeetingCreateRequestDto requestDto) { - meetingService.createMeeting(requestDto); - return ResponseEntity.status(HttpStatus.CREATED).build(); - } + public ResponseEntity createGroup(@AuthenticationPrincipal Member member, + @Valid @RequestBody MeetingCreateRequestDto requestDto) { - /** - * 전체 모임을 조회하는 메서드입니다. - * - * @return 200 (OK), body: 모임 응답 DTO 리스트 - */ - @GetMapping - public ResponseEntity> getMeetingList() { - return ResponseEntity.ok(meetingService.getMeetingList()); + MeetingCreateResponseDto response = meetingService.createMeeting(member.getMemberId(), requestDto); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(response); } /** - * 특정 모임을 조회하는 메서드입니다. + * 모임의 UUID로 모임을 조회하는 메서드입니다. * - * @param id 조회할 모임 ID + * @param uuid 조회할 모임 UUID * @return 200 (OK), body: 모임 응답 DTO */ - @GetMapping("/{meetingId}") - public ResponseEntity getMeeting(@PathVariable("meetingId") Long id) { - return ResponseEntity.ok(meetingService.getMeeting(id)); + @GetMapping("/{meetingUuid}") + public ResponseEntity getMeetingByUuid(@PathVariable("meetingUuid") String uuid) { + return ResponseEntity.ok(meetingService.getMeetingByUuid(uuid)); } /** - * 모임을 수정하는 메서드입니다. + * 모임에 속한 회원 조회 * - * @param id 모임 ID - * @param requestDto 수정된 모임 정보 DTO - * @return 200 (OK) + * @param id 조회할 모임 ID + * @return 200 (OK), body: 회원 응답 DTO */ - @PatchMapping("/{meetingId}") - public ResponseEntity updateMeeting(@PathVariable("meetingId") Long id, - @Valid @RequestBody MeetingUpdateRequestDto requestDto) { - - meetingService.updateMeeting(id, requestDto); - return ResponseEntity.ok().build(); + @GetMapping("/{meetingId}/memberList") + public ResponseEntity> getMemberListByMemberId(@PathVariable("meetingId") Long id) { + return ResponseEntity.ok(meetingService.getMeetingListByMeetingId(id)); } /** @@ -88,15 +83,19 @@ public ResponseEntity confirmMeeting(@PathVariable("meetingId") Long id, return ResponseEntity.ok().build(); } + /** * 모임을 삭제하는 메서드입니다. * - * @param id 삭제할 모임 ID + * @param member 로그인한 회원 정보 + * @param id 삭제할 모임 ID * @return 200 (OK) */ @DeleteMapping("/{meetingId}") - public ResponseEntity deleteMeeting(@PathVariable("meetingId") Long id) { - meetingService.deleteMeeting(id); + public ResponseEntity deleteMeeting(@AuthenticationPrincipal Member member, + @PathVariable("meetingId") Long id) { + + meetingService.deleteMeeting(member.getMemberId(), id); return ResponseEntity.ok().build(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/dnd/jjakkak/domain/meeting/dto/request/MeetingCreateRequestDto.java b/src/main/java/com/dnd/jjakkak/domain/meeting/dto/request/MeetingCreateRequestDto.java index f752d11..ad337f4 100644 --- a/src/main/java/com/dnd/jjakkak/domain/meeting/dto/request/MeetingCreateRequestDto.java +++ b/src/main/java/com/dnd/jjakkak/domain/meeting/dto/request/MeetingCreateRequestDto.java @@ -24,11 +24,11 @@ @ToString public class MeetingCreateRequestDto { - @Size(min = 1, max = 8, message = "카테고리는 최소 1개 이상 8개 이하로 선택해주세요.") + @Size(min = 1, max = 3, message = "카테고리는 최소 1개 이상 3개 이하로 선택해주세요.") private final List categoryIds = new ArrayList<>(); @NotBlank(message = "모임명은 필수 값입니다.") - @Size(max = 30, message = "모임명을 30자 이내로 입력해주세요.") + @Size(max = 20, message = "모임명을 20자 이내로 입력해주세요.") private String meetingName; @NotNull(message = "모임 일정 시작일은 필수 값입니다.") @@ -41,8 +41,6 @@ public class MeetingCreateRequestDto { @Max(value = 10, message = "인원수는 10명 이하로 입력해주세요.") private Integer numberOfPeople; - private Boolean isOnline; - @NotNull(message = "익명 여부는 필수 값입니다.") private Boolean isAnonymous; diff --git a/src/main/java/com/dnd/jjakkak/domain/meeting/dto/request/MeetingUpdateRequestDto.java b/src/main/java/com/dnd/jjakkak/domain/meeting/dto/request/MeetingUpdateRequestDto.java deleted file mode 100644 index 6377514..0000000 --- a/src/main/java/com/dnd/jjakkak/domain/meeting/dto/request/MeetingUpdateRequestDto.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.dnd.jjakkak.domain.meeting.dto.request; - -import com.dnd.jjakkak.domain.meeting.exception.InvalidMeetingDateException; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import lombok.Getter; -import lombok.ToString; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.List; - -/** - * 모임 수정 요청 DTO 클래스입니다. - * - * @author 정승조 - * @version 2024. 07. 28. - */ -@Getter -@ToString -public class MeetingUpdateRequestDto { - - @Size(min = 1, max = 8, message = "카테고리는 최소 1개 이상 8개 이하로 선택해주세요.") - private final List categoryIds = new ArrayList<>(); - - @NotBlank(message = "모임명은 필수 값입니다.") - @Size(max = 30, message = "모임명을 30자 이내로 입력해주세요.") - private String meetingName; - - @NotNull(message = "모임 일정 시작일은 필수 값입니다.") - private LocalDate meetingStartDate; - - @NotNull(message = "모임 일정 종료일은 필수 값입니다.") - private LocalDate meetingEndDate; - - @NotNull(message = "인원수는 필수 값입니다.") - @Max(value = 10, message = "인원수는 10명 이하로 입력해주세요.") - private Integer numberOfPeople; - - private Boolean isOnline; - - @NotNull(message = "익명 여부는 필수 값입니다.") - private Boolean isAnonymous; - - @NotNull(message = "투표 종료일은 필수 값입니다.") - private LocalDateTime voteEndDate; - - /** - * 모임 일정을 검증하는 메서드입니다. - * - * @throws InvalidMeetingDateException 모임 일정이 유효하지 않을 경우 발생합니다. - */ - public void checkMeetingDate() { - InvalidMeetingDateException invalidMeetingDateException = new InvalidMeetingDateException(); - - if (meetingStartDate.isAfter(meetingEndDate)) { - invalidMeetingDateException.addValidation( - "meetingStartDate", - "모임 시작일은 종료일 이전으로 설정해주세요."); - } - - if (voteEndDate.isAfter(meetingStartDate.atStartOfDay())) { - invalidMeetingDateException.addValidation( - "voteEndDate", - "투표 종료일은 모임 시작일 이전으로 설정해주세요."); - } - - if (ChronoUnit.DAYS.between(meetingEndDate, meetingStartDate) >= 14) { - invalidMeetingDateException.addValidation( - "meetingStartDate, meetingEndDate", - "모임 시작일과 종료일은 최대 14일까지 설정 가능합니다."); - } - - if (!invalidMeetingDateException.getValidation().isEmpty()) { - throw invalidMeetingDateException; - } - } -} diff --git a/src/main/java/com/dnd/jjakkak/domain/meeting/dto/response/MeetingCreateResponseDto.java b/src/main/java/com/dnd/jjakkak/domain/meeting/dto/response/MeetingCreateResponseDto.java new file mode 100644 index 0000000..65a9a84 --- /dev/null +++ b/src/main/java/com/dnd/jjakkak/domain/meeting/dto/response/MeetingCreateResponseDto.java @@ -0,0 +1,21 @@ +package com.dnd.jjakkak.domain.meeting.dto.response; + +import lombok.Builder; +import lombok.Getter; + +/** + * 모임이 생성된 후 UUID를 반환하는 응답 DTO 클래스입니다. + * + * @author 정승조 + * @version 2024. 08. 01. + */ +@Getter +public class MeetingCreateResponseDto { + + private final String meetingUuid; + + @Builder + public MeetingCreateResponseDto(String meetingUuid) { + this.meetingUuid = meetingUuid; + } +} diff --git a/src/main/java/com/dnd/jjakkak/domain/meeting/dto/response/MeetingResponseDto.java b/src/main/java/com/dnd/jjakkak/domain/meeting/dto/response/MeetingResponseDto.java index a2102c9..4a1b73a 100644 --- a/src/main/java/com/dnd/jjakkak/domain/meeting/dto/response/MeetingResponseDto.java +++ b/src/main/java/com/dnd/jjakkak/domain/meeting/dto/response/MeetingResponseDto.java @@ -1,6 +1,6 @@ package com.dnd.jjakkak.domain.meeting.dto.response; -import lombok.AllArgsConstructor; +import com.dnd.jjakkak.domain.meeting.entity.Meeting; import lombok.Builder; import lombok.Getter; @@ -10,12 +10,10 @@ /** * 모임 응답 DTO 클래스입니다. * - * @author 정승조 - * @version 2024. 07. 25. + * @author 정승조, 류태웅 + * @version 2024. 07. 30. */ @Getter -@AllArgsConstructor -@Builder public class MeetingResponseDto { private final Long meetingId; @@ -23,7 +21,23 @@ public class MeetingResponseDto { private final LocalDate meetingStartDate; private final LocalDate meetingEndDate; private final Integer numberOfPeople; - private final Boolean isOnline; private final Boolean isAnonymous; private final LocalDateTime voteEndDate; + private final LocalDateTime confirmedSchedule; + private final Long meetingLeaderId; + private final String meetingUuid; + + @Builder + public MeetingResponseDto(Meeting meeting) { + this.meetingId = meeting.getMeetingId(); + this.meetingName = meeting.getMeetingName(); + this.meetingStartDate = meeting.getMeetingStartDate(); + this.meetingEndDate = meeting.getMeetingEndDate(); + this.numberOfPeople = meeting.getNumberOfPeople(); + this.isAnonymous = meeting.getIsAnonymous(); + this.voteEndDate = meeting.getVoteEndDate(); + this.confirmedSchedule = meeting.getConfirmedSchedule(); + this.meetingLeaderId = meeting.getMeetingLeaderId(); + this.meetingUuid = meeting.getMeetingUuid(); + } } diff --git a/src/main/java/com/dnd/jjakkak/domain/meeting/entity/Meeting.java b/src/main/java/com/dnd/jjakkak/domain/meeting/entity/Meeting.java index b5516da..e03750e 100644 --- a/src/main/java/com/dnd/jjakkak/domain/meeting/entity/Meeting.java +++ b/src/main/java/com/dnd/jjakkak/domain/meeting/entity/Meeting.java @@ -1,7 +1,6 @@ package com.dnd.jjakkak.domain.meeting.entity; import com.dnd.jjakkak.domain.meeting.dto.request.MeetingConfirmRequestDto; -import com.dnd.jjakkak.domain.meeting.dto.request.MeetingUpdateRequestDto; import com.dnd.jjakkak.domain.meeting.exception.InvalidMeetingDateException; import jakarta.persistence.*; import lombok.*; @@ -16,6 +15,9 @@ * @version 2024. 07. 23. */ @Entity +@Table(indexes = { + @Index(name = "idx_meeting_uuid", columnList = "meeting_uuid", unique = true) +}) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @ToString @@ -38,9 +40,6 @@ public class Meeting { @Column(nullable = false, name = "number_of_people") private Integer numberOfPeople; - @Column(name = "is_online") - private Boolean isOnline; - @Column(name = "is_anonymous") private Boolean isAnonymous; @@ -50,16 +49,24 @@ public class Meeting { @Column(name = "confirmed_schedule") private LocalDateTime confirmedSchedule; + @Column(nullable = false, name = "meeting_leader_id") + private Long meetingLeaderId; + + @Column(nullable = false, name = "meeting_uuid", length = 8) + private String meetingUuid; + @Builder public Meeting(String meetingName, LocalDate meetingStartDate, LocalDate meetingEndDate, - Integer numberOfPeople, Boolean isOnline, Boolean isAnonymous, LocalDateTime voteEndDate) { + Integer numberOfPeople, Boolean isAnonymous, + LocalDateTime voteEndDate, Long meetingLeaderId, String meetingUuid) { this.meetingName = meetingName; this.meetingStartDate = meetingStartDate; this.meetingEndDate = meetingEndDate; this.numberOfPeople = numberOfPeople; - this.isOnline = isOnline; this.isAnonymous = isAnonymous; this.voteEndDate = voteEndDate; + this.meetingLeaderId = meetingLeaderId; + this.meetingUuid = meetingUuid; } @@ -78,19 +85,4 @@ public void updateConfirmedSchedule(MeetingConfirmRequestDto requestDto) { this.confirmedSchedule = requestDto.getConfirmedSchedule(); } - - /** - * 모임 정보를 수정합니다. - * - * @param requestDto 수정 요청 DTO - */ - public void updateMeeting(MeetingUpdateRequestDto requestDto) { - this.meetingName = requestDto.getMeetingName(); - this.meetingStartDate = requestDto.getMeetingStartDate(); - this.meetingEndDate = requestDto.getMeetingEndDate(); - this.numberOfPeople = requestDto.getNumberOfPeople(); - this.isOnline = requestDto.getIsOnline(); - this.isAnonymous = requestDto.getIsAnonymous(); - this.voteEndDate = requestDto.getVoteEndDate(); - } } diff --git a/src/main/java/com/dnd/jjakkak/domain/meeting/exception/MeetingUnauthorizedException.java b/src/main/java/com/dnd/jjakkak/domain/meeting/exception/MeetingUnauthorizedException.java new file mode 100644 index 0000000..ae98807 --- /dev/null +++ b/src/main/java/com/dnd/jjakkak/domain/meeting/exception/MeetingUnauthorizedException.java @@ -0,0 +1,23 @@ +package com.dnd.jjakkak.domain.meeting.exception; + +import com.dnd.jjakkak.global.exception.GeneralException; + +/** + * 모임에 대한 권한이 없을 때 발생하는 예외입니다. + * + * @author 정승조 + * @version 2024. 08. 01. + */ +public class MeetingUnauthorizedException extends GeneralException { + + private static final String MESSAGE = "모임에 대한 권한이 없습니다."; + + public MeetingUnauthorizedException() { + super(MESSAGE); + } + + @Override + public int getStatusCode() { + return 401; + } +} diff --git a/src/main/java/com/dnd/jjakkak/domain/meeting/repository/MeetingRepository.java b/src/main/java/com/dnd/jjakkak/domain/meeting/repository/MeetingRepository.java index 986051f..76cafe7 100644 --- a/src/main/java/com/dnd/jjakkak/domain/meeting/repository/MeetingRepository.java +++ b/src/main/java/com/dnd/jjakkak/domain/meeting/repository/MeetingRepository.java @@ -3,6 +3,8 @@ import com.dnd.jjakkak.domain.meeting.entity.Meeting; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + /** * 모임 JPA 레포지토리입니다. * @@ -10,4 +12,20 @@ * @version 2024. 07. 25. */ public interface MeetingRepository extends JpaRepository { + + /** + * 모임 uuid로 모임이 존재하는지 확인합니다. + * + * @param uuid 모임 uuid + * @return 모임이 존재하는지 여부 + */ + boolean existsByMeetingUuid(String uuid); + + /** + * 모임 uuid로 모임을 조회합니다. + * + * @param uuid 모임 uuid + * @return 모임 + */ + Optional findByMeetingUuid(String uuid); } diff --git a/src/main/java/com/dnd/jjakkak/domain/meeting/service/MeetingService.java b/src/main/java/com/dnd/jjakkak/domain/meeting/service/MeetingService.java index 403db45..608da8a 100644 --- a/src/main/java/com/dnd/jjakkak/domain/meeting/service/MeetingService.java +++ b/src/main/java/com/dnd/jjakkak/domain/meeting/service/MeetingService.java @@ -5,25 +5,29 @@ import com.dnd.jjakkak.domain.category.repository.CategoryRepository; import com.dnd.jjakkak.domain.meeting.dto.request.MeetingConfirmRequestDto; import com.dnd.jjakkak.domain.meeting.dto.request.MeetingCreateRequestDto; -import com.dnd.jjakkak.domain.meeting.dto.request.MeetingUpdateRequestDto; +import com.dnd.jjakkak.domain.meeting.dto.response.MeetingCreateResponseDto; import com.dnd.jjakkak.domain.meeting.dto.response.MeetingResponseDto; import com.dnd.jjakkak.domain.meeting.entity.Meeting; import com.dnd.jjakkak.domain.meeting.exception.MeetingNotFoundException; +import com.dnd.jjakkak.domain.meeting.exception.MeetingUnauthorizedException; import com.dnd.jjakkak.domain.meeting.repository.MeetingRepository; import com.dnd.jjakkak.domain.meetingcategory.entity.MeetingCategory; import com.dnd.jjakkak.domain.meetingcategory.repository.MeetingCategoryRepository; +import com.dnd.jjakkak.domain.meetingmember.repository.MeetingMemberRepository; +import com.dnd.jjakkak.domain.member.dto.response.MemberResponseDto; +import com.dnd.jjakkak.domain.member.entity.Member; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; import java.util.List; +import java.util.UUID; /** * 모임 서비스 클래스입니다. * - * @author 정승조 - * @version 2024. 07. 25. + * @author 류태웅 + * @version 2024. 07. 30. */ @Service @RequiredArgsConstructor @@ -32,22 +36,33 @@ public class MeetingService { private final MeetingRepository meetingRepository; private final MeetingCategoryRepository meetingCategoryRepository; private final CategoryRepository categoryRepository; + private final MeetingMemberRepository meetingMemberRepository; + /** + * 모임을 생성하는 메서드입니다. + * + * @param memberId 모임을 생성하는 회원 ID (리더 ID) + * @param requestDto 모임 생성 요청 DTO + * @return 모임 생성 응답 DTO (UUID) + */ @Transactional - public void createMeeting(MeetingCreateRequestDto requestDto) { + public MeetingCreateResponseDto createMeeting(Long memberId, MeetingCreateRequestDto requestDto) { // checkMeetingDate 메서드를 호출하여 유효성 검사를 진행합니다. requestDto.checkMeetingDate(); + String uuid = generateUuid(); + // 모임 생성 로직 Meeting meeting = Meeting.builder() .meetingName(requestDto.getMeetingName()) .meetingStartDate(requestDto.getMeetingStartDate()) .meetingEndDate(requestDto.getMeetingEndDate()) .numberOfPeople(requestDto.getNumberOfPeople()) - .isOnline(requestDto.getIsOnline()) .isAnonymous(requestDto.getIsAnonymous()) .voteEndDate(requestDto.getVoteEndDate()) + .meetingLeaderId(memberId) + .meetingUuid(uuid) .build(); meetingRepository.save(meeting); @@ -66,127 +81,93 @@ public void createMeeting(MeetingCreateRequestDto requestDto) { meetingCategoryRepository.save(meetingCategory); } + + return MeetingCreateResponseDto.builder() + .meetingUuid(uuid) + .build(); } /** - * 전체 모임 목록을 조회하는 메서드입니다. + * 모임에 속한 회원 조회 * - * @return 모임 목록 + * @param id 조회할 모임 ID + * @return 회원 응답 DTO */ @Transactional(readOnly = true) - public List getMeetingList() { - - List meetingResponseDtoList = new ArrayList<>(); - List meetingList = meetingRepository.findAll(); - for (Meeting meeting : meetingList) { - MeetingResponseDto meetingResponseDto = MeetingResponseDto.builder() - .meetingId(meeting.getMeetingId()) - .meetingName(meeting.getMeetingName()) - .meetingStartDate(meeting.getMeetingStartDate()) - .meetingEndDate(meeting.getMeetingEndDate()) - .numberOfPeople(meeting.getNumberOfPeople()) - .isOnline(meeting.getIsOnline()) - .isAnonymous(meeting.getIsAnonymous()) - .voteEndDate(meeting.getVoteEndDate()) - .build(); - - meetingResponseDtoList.add(meetingResponseDto); - } - - return meetingResponseDtoList; + public List getMeetingListByMeetingId(Long id) { + List memberList = meetingMemberRepository.findByMeetingId(id); + return memberList.stream() + .map(MemberResponseDto::new) + .toList(); } /** - * 모임을 조회하는 메서드입니다. + * 모임의 확정된 일자를 설정하는 메서드입니다. * - * @param id 조회할 모임 ID - * @return 모임 응답 DTO + * @param id 모임 ID + * @param requestDto 모임 확정 요청 DTO */ - @Transactional(readOnly = true) - public MeetingResponseDto getMeeting(Long id) { + @Transactional + public void confirmMeeting(Long id, MeetingConfirmRequestDto requestDto) { Meeting meeting = meetingRepository.findById(id) .orElseThrow(MeetingNotFoundException::new); - return MeetingResponseDto.builder() - .meetingId(meeting.getMeetingId()) - .meetingName(meeting.getMeetingName()) - .meetingStartDate(meeting.getMeetingStartDate()) - .meetingEndDate(meeting.getMeetingEndDate()) - .numberOfPeople(meeting.getNumberOfPeople()) - .isOnline(meeting.getIsOnline()) - .isAnonymous(meeting.getIsAnonymous()) - .voteEndDate(meeting.getVoteEndDate()) - .build(); + meeting.updateConfirmedSchedule(requestDto); } + /** - * 모임을 수정하는 메서드입니다. + * 모임을 삭제하는 메서드입니다. * - * @param id 모임 ID - * @param requestDto 수정된 모임 정보 DTO + * @param memberId 회원 ID + * @param id 모임 ID */ @Transactional - public void updateMeeting(Long id, MeetingUpdateRequestDto requestDto) { + public void deleteMeeting(Long memberId, Long id) { + Meeting meeting = meetingRepository.findById(id) .orElseThrow(MeetingNotFoundException::new); - // checkMeetingDate 메서드를 호출하여 유효성 검사를 진행합니다. - requestDto.checkMeetingDate(); - - // dirty checking - 모임 정보를 수정합니다. - meeting.updateMeeting(requestDto); - - // 모임의 카테고리 값도 수정 필요 - // 기존 모임과 연결된 카테고리 정보를 삭제합니다. - meetingCategoryRepository.deleteByMeetingId(meeting.getMeetingId()); - - for (Long categoryId : requestDto.getCategoryIds()) { - Category category = categoryRepository.findById(categoryId) - .orElseThrow(CategoryNotFoundException::new); - - MeetingCategory.Pk pk = new MeetingCategory.Pk(meeting.getMeetingId(), category.getCategoryId()); - MeetingCategory meetingCategory = MeetingCategory.builder() - .pk(pk) - .meeting(meeting) - .category(category) - .build(); - - meetingCategoryRepository.save(meetingCategory); + // 요청한 회원이 모임의 리더가 아닌 경우 예외 처리 + if (!meeting.getMeetingLeaderId().equals(memberId)) { + throw new MeetingUnauthorizedException(); } + + meetingRepository.deleteById(id); + meetingCategoryRepository.deleteByMeetingId(id); } /** - * 모임의 확정된 일자를 설정하는 메서드입니다. + * UUID로 모임을 조회하는 메서드입니다. * - * @param id 모임 ID - * @param requestDto 모임 확정 요청 DTO + * @param uuid 조회할 모임 UUID + * @return 모임 응답 DTO */ - @Transactional - public void confirmMeeting(Long id, MeetingConfirmRequestDto requestDto) { + @Transactional(readOnly = true) + public MeetingResponseDto getMeetingByUuid(String uuid) { - Meeting meeting = meetingRepository.findById(id) + Meeting meeting = meetingRepository.findByMeetingUuid(uuid) .orElseThrow(MeetingNotFoundException::new); - meeting.updateConfirmedSchedule(requestDto); + return MeetingResponseDto.builder() + .meeting(meeting) + .build(); } /** - * 모임을 삭제하는 메서드입니다. + * UUID를 생성하는 메서드입니다. * - * @param id 삭제할 모임 ID + * @return UUID */ - @Transactional - public void deleteMeeting(Long id) { + private String generateUuid() { + String uuid; - // TODO 1: 모임을 생성한 리더가 맞는지 검증 확인 필요 + do { + uuid = UUID.randomUUID().toString().substring(0, 8); + } while (meetingRepository.existsByMeetingUuid(uuid)); - // TODO 2: 모임과 엮인 모임 일정 테이블 삭제 로직 추가 필요 - if (!meetingRepository.existsById(id)) { - throw new MeetingNotFoundException(); - } - - meetingRepository.deleteById(id); + return uuid; } } diff --git a/src/main/java/com/dnd/jjakkak/domain/meetingmember/entity/MeetingMember.java b/src/main/java/com/dnd/jjakkak/domain/meetingmember/entity/MeetingMember.java new file mode 100644 index 0000000..af5610f --- /dev/null +++ b/src/main/java/com/dnd/jjakkak/domain/meetingmember/entity/MeetingMember.java @@ -0,0 +1,54 @@ +package com.dnd.jjakkak.domain.meetingmember.entity; + +import com.dnd.jjakkak.domain.meeting.entity.Meeting; +import com.dnd.jjakkak.domain.member.entity.Member; +import jakarta.persistence.*; +import lombok.*; + +import java.io.Serializable; + +/** + * 회원 모임 엔티티 클래스입니다. + * + * @author 류태웅 + * @version 2024. 07. 30. + */ + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MeetingMember { + @EmbeddedId + private Pk pk; + + @MapsId("meetingId") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "meeting_id") + private Meeting meeting; + + @MapsId("memberId") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @Builder + public MeetingMember(Pk pk, Meeting meeting, Member member) { + this.pk = pk; + this.meeting = meeting; + this.member = member; + } + + @Embeddable + @Getter + @NoArgsConstructor + @AllArgsConstructor + @EqualsAndHashCode + public static class Pk implements Serializable { + + @Column(name = "meeting_id") + private Long meetingId; + + @Column(name = "member_id") + private Long memberId; + } +} diff --git a/src/main/java/com/dnd/jjakkak/domain/meetingmember/repository/MeetingMemberRepository.java b/src/main/java/com/dnd/jjakkak/domain/meetingmember/repository/MeetingMemberRepository.java new file mode 100644 index 0000000..2f0366d --- /dev/null +++ b/src/main/java/com/dnd/jjakkak/domain/meetingmember/repository/MeetingMemberRepository.java @@ -0,0 +1,15 @@ +package com.dnd.jjakkak.domain.meetingmember.repository; + +import com.dnd.jjakkak.domain.meetingmember.entity.MeetingMember; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * 회원 모임 JPA 레포지토리입니다. + * + * @author 류태웅 + * @version 2024. 07. 30. + */ + +public interface MeetingMemberRepository + extends JpaRepository, MeetingMemberRepositoryCustom { +} diff --git a/src/main/java/com/dnd/jjakkak/domain/meetingmember/repository/MeetingMemberRepositoryCustom.java b/src/main/java/com/dnd/jjakkak/domain/meetingmember/repository/MeetingMemberRepositoryCustom.java new file mode 100644 index 0000000..643a326 --- /dev/null +++ b/src/main/java/com/dnd/jjakkak/domain/meetingmember/repository/MeetingMemberRepositoryCustom.java @@ -0,0 +1,31 @@ +package com.dnd.jjakkak.domain.meetingmember.repository; + +import com.dnd.jjakkak.domain.meeting.entity.Meeting; +import com.dnd.jjakkak.domain.member.entity.Member; +import org.springframework.data.repository.NoRepositoryBean; + +import java.util.List; + +/** + * 회원 모임 Querydsl 메서드 정의 인터페이스입니다. + * + * @author 류태웅 + * @version 2024. 07. 30. + */ + +@NoRepositoryBean +public interface MeetingMemberRepositoryCustom { + /** + * 회원 ID로 회원에 속한 모든 모임 찾기 + * + * @param memberId 회원 ID + */ + List findByMemberId(Long memberId); + + /** + * 모임 ID로 모임에 속한 모든 회원 찾기 + * + * @param meetingId 모임 ID + */ + List findByMeetingId(Long meetingId); +} diff --git a/src/main/java/com/dnd/jjakkak/domain/meetingmember/repository/MeetingMemberRepositoryImpl.java b/src/main/java/com/dnd/jjakkak/domain/meetingmember/repository/MeetingMemberRepositoryImpl.java new file mode 100644 index 0000000..05e4e34 --- /dev/null +++ b/src/main/java/com/dnd/jjakkak/domain/meetingmember/repository/MeetingMemberRepositoryImpl.java @@ -0,0 +1,45 @@ +package com.dnd.jjakkak.domain.meetingmember.repository; + +import com.dnd.jjakkak.domain.meeting.entity.Meeting; +import com.dnd.jjakkak.domain.meetingmember.entity.MeetingMember; +import com.dnd.jjakkak.domain.meetingmember.entity.QMeetingMember; +import com.dnd.jjakkak.domain.member.entity.Member; +import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport; + +import java.util.List; + +/** + * 회원 모임 Querydsl 레포지토리 구현 클래스입니다. + * + * @author 류태웅 + * @version 2024. 07. 30. + */ + +public class MeetingMemberRepositoryImpl extends QuerydslRepositorySupport implements MeetingMemberRepositoryCustom{ + + public MeetingMemberRepositoryImpl() {super(MeetingMember.class);} + + /** + * {@inheritDoc} + */ + @Override + public List findByMemberId(Long memberId) { + QMeetingMember meetingMember = QMeetingMember.meetingMember; + return from(meetingMember) + .select(meetingMember.meeting) + .where(meetingMember.pk.memberId.eq(memberId)) + .fetch(); + } + + /** + * {@inheritDoc} + */ + @Override + public List findByMeetingId(Long meetingId) { + QMeetingMember meetingMember = QMeetingMember.meetingMember; + return from(meetingMember) + .select(meetingMember.member) + .where(meetingMember.pk.meetingId.eq(meetingId)) + .fetch(); + } +} diff --git a/src/main/java/com/dnd/jjakkak/domain/member/controller/AuthController.java b/src/main/java/com/dnd/jjakkak/domain/member/controller/AuthController.java index e0e59ad..ad6cf40 100644 --- a/src/main/java/com/dnd/jjakkak/domain/member/controller/AuthController.java +++ b/src/main/java/com/dnd/jjakkak/domain/member/controller/AuthController.java @@ -1,87 +1,53 @@ package com.dnd.jjakkak.domain.member.controller; -import com.dnd.jjakkak.domain.member.service.BlacklistService; -import com.dnd.jjakkak.domain.member.service.RefreshTokenService; -import com.dnd.jjakkak.global.exception.ErrorResponse; +import com.dnd.jjakkak.domain.jwt.provider.JwtProvider; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.time.LocalDateTime; +import java.util.Collections; /** - * 로그아웃 시 사용하는 컨트롤러입니다. + * 현재 Member의 로그인 여부를 확인하는 컨트롤러입니다. * * @author 류태웅 - * @version 2024. 07. 27. + * @version 2024. 08. 05. + * */ - @Slf4j @RestController +@RequestMapping("/api/v1") @RequiredArgsConstructor public class AuthController { - private final RefreshTokenService refreshTokenService; - private final BlacklistService blacklistService; + + private final JwtProvider jwtProvider; /** - * 로그아웃을 할 때 사용합니다. - * 헤더에 Authorization : Bearer {refresh_token}을 입력한 다음 호출 - * 그렇게 되면 DB에 refresh_token이 삭제되고 블랙리스트에 추가됨 + * 프론트 측에서 보내는 access_token을 확인 후 + * 프론트엔드에게 로그인 또는 비로그인 상태의 메시지를 보냄 * - * @param refreshToken String - * @return ResponseEntity + * @param request HttpServletRequest + * @return message ResponseEntity */ - @PostMapping("/api/v1/logout") - public ResponseEntity logout(@RequestHeader(value = "Authorization", required = false) String refreshToken) { - - // TODO: LoginHandler로 수정해보기 - - log.info("logout 시작"); - - if (refreshToken != null && refreshToken.startsWith("Bearer ")) { - refreshToken = refreshToken.substring(7); - log.info("logout refreshToken: {}", refreshToken); - - if (refreshTokenService.validateRefreshToken(refreshToken)) { - try { - refreshTokenService.deleteRefreshToken(refreshToken); - LocalDateTime expirationDate = LocalDateTime.now().plusWeeks(1); // 토큰의 실제 만료 시간으로 설정 - blacklistService.blacklistToken(refreshToken, expirationDate); - log.info("logout 성공"); - return ResponseEntity.ok().build(); - } catch (Exception e) { - log.error("서버 에러", e); - ErrorResponse response = ErrorResponse.builder() - .code("500") - .message("Server Error") - .build(); - - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); - } - } else { - log.error("Refresh Token 인증 오류"); - - ErrorResponse response = ErrorResponse.builder() - .code("401") - .message("Invalid Refresh Token") - .build(); - - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response); + @GetMapping("/check-auth") + public ResponseEntity checkAuth(HttpServletRequest request) { + String authorizationHeader = request.getHeader("Authorization"); + log.debug(authorizationHeader); + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + String token = authorizationHeader.substring(7); + String subject = jwtProvider.validate(token); + + if (subject != null && !subject.isEmpty()) { + log.debug("isAuthenticated"); + return ResponseEntity.ok().body(Collections.singletonMap("isAuthenticated", true)); } } - - log.error("헤더 인증 오류"); - - ErrorResponse response = ErrorResponse.builder() - .code("400") - .message("Invalid Header Error") - .build(); - - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + log.debug("isNotAuthenticated"); + return ResponseEntity.ok().body(Collections.singletonMap("isAuthenticated", false)); } } diff --git a/src/main/java/com/dnd/jjakkak/domain/member/controller/MemberController.java b/src/main/java/com/dnd/jjakkak/domain/member/controller/MemberController.java index fb52cea..b3a3b90 100644 --- a/src/main/java/com/dnd/jjakkak/domain/member/controller/MemberController.java +++ b/src/main/java/com/dnd/jjakkak/domain/member/controller/MemberController.java @@ -1,5 +1,6 @@ package com.dnd.jjakkak.domain.member.controller; +import com.dnd.jjakkak.domain.meeting.dto.response.MeetingResponseDto; import com.dnd.jjakkak.domain.member.dto.request.MemberUpdateNicknameRequestDto; import com.dnd.jjakkak.domain.member.dto.request.MemberUpdateProfileRequestDto; import com.dnd.jjakkak.domain.member.service.MemberService; @@ -8,6 +9,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.List; + /** * Member의 CRUD에 사용하는 컨트롤러입니다. * @@ -21,6 +24,17 @@ public class MemberController { private final MemberService memberService; + /** + * 회원이 속한 모임 조회 + * + * @param id 조회할 멤버 ID + * @return 200 (OK), body: 모임 응답 DTO + */ + @GetMapping("/{memberId}/meetingList") + public ResponseEntity> getMemberListByMemberId(@PathVariable("memberId") Long id) { + return ResponseEntity.ok(memberService.getMeetingListByMemberId(id)); + } + @PatchMapping("/{memberId}/nickname") public ResponseEntity updateNickname( @PathVariable("memberId") Long id, diff --git a/src/main/java/com/dnd/jjakkak/domain/member/dto/response/MemberResponseDto.java b/src/main/java/com/dnd/jjakkak/domain/member/dto/response/MemberResponseDto.java new file mode 100644 index 0000000..5b60664 --- /dev/null +++ b/src/main/java/com/dnd/jjakkak/domain/member/dto/response/MemberResponseDto.java @@ -0,0 +1,27 @@ +package com.dnd.jjakkak.domain.member.dto.response; + +import com.dnd.jjakkak.domain.member.entity.Member; +import lombok.Builder; +import lombok.Getter; + +/** + * 회원 응답 DTO 클래스입니다. + * + * @author 류태웅 + * @version 2024. 07. 30. + */ +@Getter +public class MemberResponseDto { + private final long memberId; + private final String memberNickname; + private final long kakaoId; + private final String memberProfile; + + @Builder + public MemberResponseDto(Member member) { + this.memberId = member.getMemberId(); + this.memberNickname = member.getMemberNickname(); + this.kakaoId = member.getKakaoId(); + this.memberProfile = member.getMemberProfile(); + } +} diff --git a/src/main/java/com/dnd/jjakkak/domain/member/entity/Member.java b/src/main/java/com/dnd/jjakkak/domain/member/entity/Member.java index 1710470..ac172f4 100644 --- a/src/main/java/com/dnd/jjakkak/domain/member/entity/Member.java +++ b/src/main/java/com/dnd/jjakkak/domain/member/entity/Member.java @@ -44,7 +44,7 @@ public class Member implements OAuth2User { private String memberProfile; @Enumerated(EnumType.STRING) - @Column(nullable = false, name="role") + @Column(nullable = false, name="role", columnDefinition = "varchar(20)") private Role role; /** diff --git a/src/main/java/com/dnd/jjakkak/domain/member/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/com/dnd/jjakkak/domain/member/jwt/filter/JwtAuthenticationFilter.java deleted file mode 100644 index 376ccbf..0000000 --- a/src/main/java/com/dnd/jjakkak/domain/member/jwt/filter/JwtAuthenticationFilter.java +++ /dev/null @@ -1,151 +0,0 @@ -package com.dnd.jjakkak.domain.member.jwt.filter; - -import com.dnd.jjakkak.domain.member.entity.Member; -import com.dnd.jjakkak.domain.member.entity.Role; -import com.dnd.jjakkak.domain.member.exception.MemberNotFoundException; -import com.dnd.jjakkak.domain.member.jwt.provider.JwtProvider; -import com.dnd.jjakkak.domain.member.repository.MemberRepository; -import com.dnd.jjakkak.domain.member.service.RefreshTokenService; -import io.jsonwebtoken.ExpiredJwtException; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -/** - * 권한과 인증 방식을 검사하는 필터입니다. - * - * @author 류태웅 - * @version 2024. 07. 27. - */ - -@Slf4j -@Component -@RequiredArgsConstructor -public class JwtAuthenticationFilter extends OncePerRequestFilter { - private final JwtProvider jwtProvider; - private final MemberRepository memberRepository; - private final RefreshTokenService refreshTokenService; - - /** - * 검증을 수행하는 메소드입니다. - * 여기서 jwtProvider의 validate를 수행합니다. - * - * @param request HttpServletRequest - * @param response HttpServletResponse - * @param filterChain FilterChain - * @throws ServletException ServletException - * @throws IOException IOException - */ - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - log.info("도착한 요청: {}", request.getPathInfo()); - String token = parseBearerToken(request); - log.info("도착한 토큰: {}", token); - if(token == null){ // Bearer 인증 방식이 아니거나 빈 값일 경우 진행하지 말고 다음 필터로 바로 넘김 - filterChain.doFilter(request, response); - return; - } - String kakaoId; - try { - kakaoId = jwtProvider.validate(token); - log.info("검증된 카카오 ID: {}", kakaoId); - } catch (ExpiredJwtException e) { - log.error("엑세스 토큰이 만료됨", e); - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json"); - response.getWriter().write("{\"error\": \"Access Token expired\"}"); - response.getWriter().flush(); - return; - } - if (kakaoId == null) { // AccessToken 검증 실패 시 - String refreshToken = parseRefreshToken(request); - log.info("도착한 리프레시 토큰: {}", refreshToken); - if (refreshToken != null && refreshTokenService.validateRefreshToken(refreshToken)) { - // RefreshToken이 유효한 경우 새로운 AccessToken 발급 - kakaoId = jwtProvider.getSubjectFromRefreshToken(refreshToken); - token = jwtProvider.createAccessToken(kakaoId); - response.setHeader("Authorization", "Bearer " + token); - log.info("새로 발급된 엑세스 토큰: {}", token); - } else { - // RefreshToken도 유효하지 않으면 로그아웃 처리 및 프론트엔드로 메시지 전송 - log.error("엑세스 토큰이 만료되었고 해당되는 리프레시 토큰이 존재하지 않습니다."); - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json"); - response.getWriter().write("{\"error\": \"Invalid Refresh Token\"}"); - response.getWriter().flush(); - return; - } - } - Member member = memberRepository.findByKakaoId(Long.parseLong(kakaoId)) - .orElseThrow(MemberNotFoundException::new); - Role role = member.getRole(); // ROLE_USER, ROLE_ADMIN - - // 예시 : ROLE_MASTER, ROLE_DEVELOPER - List authorities = new ArrayList<>(); - authorities.add(new SimpleGrantedAuthority(role.toString())); - - SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); - AbstractAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(kakaoId, null, authorities); // pwd는 토큰에 추가X -> null - authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - - securityContext.setAuthentication(authenticationToken); - SecurityContextHolder.setContext(securityContext); - - filterChain.doFilter(request, response); - } - - /** - * 헤더를 검증하는 메소드입니다. - * @param request HttpServletRequest - * @return authorization String - */ - - private String parseBearerToken(HttpServletRequest request) { - String authorization = request.getHeader("Authorization"); // 응답의 헤더에서 뽑아옴 - - boolean hasAuthorization = StringUtils.hasText(authorization); - if(!hasAuthorization) return null; // null이거나 문자가 아닌 경우 null - - boolean isBearer = authorization.startsWith("Bearer "); - if(!isBearer) return null; // 아닐 경우 Bearer 인증 방신이 아님 -> null - - String token = authorization.substring(7); - if ("undefined".equalsIgnoreCase(token)) { - return null; - } - - return token; - } - - /** - * 헤더에서 RefreshToken을 검증하는 메소드입니다. - * @param request HttpServletRequest - * @return refreshToken String - */ - private String parseRefreshToken(HttpServletRequest request) { - String refreshToken = request.getHeader("RefreshToken"); - - boolean hasRefreshToken = StringUtils.hasText(refreshToken); - if (!hasRefreshToken) return null; - - return refreshToken; - } -} \ No newline at end of file diff --git a/src/main/java/com/dnd/jjakkak/domain/member/service/MemberService.java b/src/main/java/com/dnd/jjakkak/domain/member/service/MemberService.java index c1ea3c7..0eff2b4 100644 --- a/src/main/java/com/dnd/jjakkak/domain/member/service/MemberService.java +++ b/src/main/java/com/dnd/jjakkak/domain/member/service/MemberService.java @@ -1,5 +1,8 @@ package com.dnd.jjakkak.domain.member.service; +import com.dnd.jjakkak.domain.meeting.dto.response.MeetingResponseDto; +import com.dnd.jjakkak.domain.meeting.entity.Meeting; +import com.dnd.jjakkak.domain.meetingmember.repository.MeetingMemberRepository; import com.dnd.jjakkak.domain.member.dto.request.MemberUpdateNicknameRequestDto; import com.dnd.jjakkak.domain.member.dto.request.MemberUpdateProfileRequestDto; import com.dnd.jjakkak.domain.member.entity.Member; @@ -10,6 +13,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + /** * Member의 CRUD Service입니다. * @@ -21,6 +26,19 @@ @RequiredArgsConstructor public class MemberService { private final MemberRepository memberRepository; + private final MeetingMemberRepository meetingMemberRepository; + + /** + * 해당 회원이 속한 모임 출력 + * + * @param id MemberId + * @return List + */ + @Transactional(readOnly = true) + public List getMeetingListByMemberId(Long id){ + List meetingList = meetingMemberRepository.findByMemberId(id); + return meetingList.stream().map(MeetingResponseDto::new).toList(); + } /** * 닉네임 수정 @@ -66,4 +84,6 @@ public void deleteMember(Long id){ public void deletedMemberAllDeleted(){ memberRepository.deleteAllByIsDeleteTrue(); } + + } diff --git a/src/main/java/com/dnd/jjakkak/global/config/security/SecurityConfig.java b/src/main/java/com/dnd/jjakkak/global/config/security/SecurityConfig.java index 309a541..4f48453 100644 --- a/src/main/java/com/dnd/jjakkak/global/config/security/SecurityConfig.java +++ b/src/main/java/com/dnd/jjakkak/global/config/security/SecurityConfig.java @@ -1,7 +1,10 @@ package com.dnd.jjakkak.global.config.security; -import com.dnd.jjakkak.domain.member.jwt.filter.JwtAuthenticationFilter; -import com.dnd.jjakkak.domain.member.jwt.handler.OAuth2SuccessHandler; +import com.dnd.jjakkak.domain.jwt.filter.JwtAuthenticationFilter; +import com.dnd.jjakkak.domain.jwt.handler.OAuth2FailureHandler; +import com.dnd.jjakkak.domain.jwt.handler.OAuth2LogoutHandler; +import com.dnd.jjakkak.domain.jwt.handler.OAuth2SuccessHandler; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Configurable; import org.springframework.context.annotation.Bean; @@ -18,11 +21,13 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import java.util.Arrays; + /** * Spring Security Configuration Class. * * @author 류태웅 - * @version 2024. 07. 27. + * @version 2024. 08. 03. */ @Configurable @@ -34,6 +39,23 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final DefaultOAuth2UserService oAuth2UserService; private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final OAuth2FailureHandler oAuth2FailureHandler; + private final OAuth2LogoutHandler oAuth2LogoutHandler; + + public static final String[] WHITE_LIST = { + "/api/v1/auth/oauth/**", + "/api/v1/check-auth", + "/api/v1/meeting" + }; + + public static final String[] USER_LIST = { + "/api/v1/categories", + "/api/v1/member/**" + }; + + public static final String[] ADMIN_LIST = { + + }; /** * Security Bean 등록. @@ -41,7 +63,7 @@ public class SecurityConfig { *
  • CSRF 비활성화
  • *
  • CORS 비활성화 -> corsConfigurationSource()로 설정
  • *
  • Form Login 비활성화
  • - *
  • 모든 요청 허용 -> 추후에 변경 필요 -> USER와 ADMIN에 따라 결정해야 할 듯? (류태웅)
  • + *
  • 비회원도 접근 가능한 whiteList, 회원만 접근 가능한 userList, 관리자만 접근 가능한 adminList
  • * *
  • httpBasic 비활성화
  • *
  • 세션 비활성화
  • @@ -59,7 +81,10 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { ) .formLogin(AbstractHttpConfigurer::disable) .authorizeHttpRequests(authorize -> authorize - .anyRequest().permitAll() + .requestMatchers(WHITE_LIST).permitAll() + .requestMatchers(USER_LIST).hasRole("USER") + .requestMatchers(ADMIN_LIST).hasRole("ADMIN") + .anyRequest().authenticated() ) .sessionManagement(sessionManagement -> sessionManagement .sessionCreationPolicy(SessionCreationPolicy.STATELESS) @@ -69,6 +94,13 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .redirectionEndpoint(endpoint -> endpoint.baseUri("/oauth2/callback/*")) .userInfoEndpoint(endpoint -> endpoint.userService(oAuth2UserService)) .successHandler(oAuth2SuccessHandler) + .failureHandler(oAuth2FailureHandler) + ) + .logout(logout -> logout + .addLogoutHandler(oAuth2LogoutHandler) + .logoutUrl("/api/v1/logout") + .deleteCookies("refresh_token") + .logoutSuccessHandler((request, response, authentication) -> response.setStatus(HttpServletResponse.SC_OK)) ) .exceptionHandling(exceptionHandling -> exceptionHandling // 실패 시 해당 메시지 반환 .authenticationEntryPoint(new FailedAuthenticationEntryPoint()) @@ -81,7 +113,7 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { /** * CORS 설정 용 메소드 추가 * - *
  • 추후 수정 필요
  • + *
  • credentials를 허용하기 위해 특정 도메인 추가
  • * * @return CorsConfigurationSource */ @@ -89,9 +121,11 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @Bean protected CorsConfigurationSource corsConfigurationSource() { // 추후 CORS 수정 필요 CorsConfiguration config = new CorsConfiguration(); - config.addAllowedOrigin("*"); + config.setAllowedOrigins(Arrays.asList("http://localhost:3000")); // 허용할 도메인 명시 config.addAllowedMethod("*"); - config.addAllowedHeader("*"); + config.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "Access-Control-Allow-Headers", "Access-Control-Expose-Headers")); + config.addExposedHeader("Authorization"); //프론트에서 해당 헤더를 읽을 수 있게 + config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); @@ -109,6 +143,4 @@ WebSecurityCustomizer webSecurityCustomizer() { return web -> web.ignoring() .requestMatchers("/favicon"); } - - } diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 7407a21..2025c5a 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -573,7 +573,7 @@

    1.1. 카테고리 목록 조회

    [ { "categoryId" : 1, - "categoryName" : "기타" + "categoryName" : "학교" }, { "categoryId" : 2, "categoryName" : "학교" @@ -660,7 +660,7 @@

    1.2. 카테고리 개별 조회 - { "categoryId" : 1, - "categoryName" : "기타" + "categoryName" : "학교" } @@ -763,731 +763,129 @@

    2. 모임

    2.1. 모임 생성 - 성공

    -
    +
    HTTP Request
    -
    -
    POST /api/v1/meeting HTTP/1.1
    -Content-Type: application/json;charset=UTF-8
    -Content-Length: 256
    -Host: 43.202.65.170.nip.io
    -
    -{
    -  "categoryIds" : [ 47, 49, 48 ],
    -  "meetingName" : "세븐일레븐",
    -  "meetingStartDate" : "2024-07-27",
    -  "meetingEndDate" : "2024-07-29",
    -  "numberOfPeople" : 6,
    -  "isOnline" : true,
    -  "isAnonymous" : false,
    -  "voteEndDate" : "2024-07-26T23:59:59"
    -}
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/create/success/http-request.adoc[]

    +
    +
    Request Fields
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/create/success/request-fields.adoc[]

    - - ------- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Table 4. Request Fields
    PathTypeDescriptionOptionalConstraint

    meetingName

    String

    모임 이름

    모임 이름은 1자 이상 10자 이하로 입력해주세요.

    meetingStartDate

    String

    모임 시작 날짜

    모임 시작일은 종료일 이전이어야 합니다.

    meetingEndDate

    String

    모임 종료 날짜

    모임 종료일은 시작일 이후이어야 합니다.

    numberOfPeople

    Number

    모임 인원

    모임 인원은 2명 이상 10명 이하로 설정해주세요.

    isOnline

    Boolean

    온라인 여부

    true

    default = null

    isAnonymous

    Boolean

    익명 여부

    default = false (실명)

    voteEndDate

    String

    투표 종료 날짜

    투표 종료일은 모임 시작일 이전이어야 합니다.

    categoryIds

    Array

    카테고리 아이디 목록

    1개 이상의 카테고리를 선택해주세요.

    -
    +
    HTTP Response
    -
    -
    HTTP/1.1 201 Created
    -Vary: Origin
    -Vary: Access-Control-Request-Method
    -Vary: Access-Control-Request-Headers
    -X-Content-Type-Options: nosniff
    -X-XSS-Protection: 0
    -Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    -Pragma: no-cache
    -Expires: 0
    -X-Frame-Options: DENY
    -
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/create/success/http-response.adoc[]

    2.2. 모임 생성 - 실패

    -
    +
    HTTP Request
    -
    -
    POST /api/v1/meeting HTTP/1.1
    -Content-Type: application/json;charset=UTF-8
    -Content-Length: 159
    -Host: 43.202.65.170.nip.io
    -
    -{"categoryIds":[],"meetingName":null,"meetingStartDate":null,"meetingEndDate":null,"numberOfPeople":null,"isOnline":null,"isAnonymous":null,"voteEndDate":null}
    -
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/create/fail/http-request.adoc[]

    -
    +
    HTTP Response
    -
    -
    HTTP/1.1 400 Bad Request
    -Vary: Origin
    -Vary: Access-Control-Request-Method
    -Vary: Access-Control-Request-Headers
    -Content-Type: application/json
    -X-Content-Type-Options: nosniff
    -X-XSS-Protection: 0
    -Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    -Pragma: no-cache
    -Expires: 0
    -X-Frame-Options: DENY
    -Content-Length: 572
    -
    -{
    -  "code" : "400",
    -  "message" : "잘못된 요청입니다.",
    -  "validation" : {
    -    "categoryIds" : "카테고리는 최소 1개 이상 8개 이하로 선택해주세요.",
    -    "meetingEndDate" : "모임 일정 종료일은 필수 값입니다.",
    -    "isAnonymous" : "익명 여부는 필수 값입니다.",
    -    "meetingName" : "모임명은 필수 값입니다.",
    -    "numberOfPeople" : "인원수는 필수 값입니다.",
    -    "meetingStartDate" : "모임 일정 시작일은 필수 값입니다.",
    -    "voteEndDate" : "투표 종료일은 필수 값입니다."
    -  }
    -}
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/create/fail/http-response.adoc[]

    +
    +
    Error Response
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/create/fail/response-fields.adoc[]

    - - ----- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Table 5. Error Response
    PathTypeDescription

    code

    String

    상태 코드

    message

    String

    에러 메시지

    validation

    Object

    유효성 검사 오류 목록

    validation.meetingName

    String

    모임명은 필수 값입니다.

    validation.meetingStartDate

    String

    모임 일정 시작일은 필수 값입니다.

    validation.meetingEndDate

    String

    모임 일정 종료일은 필수 값입니다.

    validation.numberOfPeople

    String

    인원수는 필수 값입니다.

    validation.isAnonymous

    String

    익명 여부는 필수 값입니다.

    validation.voteEndDate

    String

    투표 종료일은 필수 값입니다.

    validation.categoryIds

    String

    카테고리는 최소 1개 이상 8개 이하로 선택해주세요.

    2.3. 모임 전체 조회

    - - ----- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Table 6. Response Fields
    PathTypeDescription

    [].meetingId

    Number

    모임 아이디

    [].meetingName

    String

    모임 이름

    [].meetingStartDate

    String

    모임 시작 날짜

    [].meetingEndDate

    String

    모임 종료 날짜

    [].numberOfPeople

    Number

    모임 인원

    [].isOnline

    Boolean

    온라인 여부

    [].isAnonymous

    Boolean

    익명 여부

    [].voteEndDate

    String

    투표 종료 날짜

    -
    -
    HTTP Request
    -
    -
    GET /api/v1/meeting HTTP/1.1
    -Content-Type: application/json;charset=UTF-8
    -Host: 43.202.65.170.nip.io
    +
    +
    Response Fields
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/getList/success/response-fields.adoc[]

    +
    +
    HTTP Request
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/getList/success/http-request.adoc[]

    -
    +
    HTTP Response
    -
    -
    HTTP/1.1 200 OK
    -Vary: Origin
    -Vary: Access-Control-Request-Method
    -Vary: Access-Control-Request-Headers
    -Content-Type: application/json
    -X-Content-Type-Options: nosniff
    -X-XSS-Protection: 0
    -Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    -Pragma: no-cache
    -Expires: 0
    -X-Frame-Options: DENY
    -Content-Length: 730
    -
    -[ {
    -  "meetingId" : 2,
    -  "meetingName" : "세븐일레븐",
    -  "meetingStartDate" : "2024-07-27",
    -  "meetingEndDate" : "2024-07-29",
    -  "numberOfPeople" : 6,
    -  "isOnline" : true,
    -  "isAnonymous" : false,
    -  "voteEndDate" : "2024-07-26T23:59:59"
    -}, {
    -  "meetingId" : 3,
    -  "meetingName" : "DND 7조 회의",
    -  "meetingStartDate" : "2024-07-27",
    -  "meetingEndDate" : "2024-07-29",
    -  "numberOfPeople" : 6,
    -  "isOnline" : true,
    -  "isAnonymous" : false,
    -  "voteEndDate" : "2024-07-26T23:59:59"
    -}, {
    -  "meetingId" : 4,
    -  "meetingName" : "Java 스터디",
    -  "meetingStartDate" : "2024-08-01",
    -  "meetingEndDate" : "2024-08-05",
    -  "numberOfPeople" : 4,
    -  "isOnline" : true,
    -  "isAnonymous" : false,
    -  "voteEndDate" : "2024-07-30T23:59:59"
    -} ]
    -
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/getList/success/http-response.adoc[]

    2.4. 모임 개별 조회 - 성공

    - - ---- - - - - - - - - - - - - -
    Table 7. /api/v1/meeting/{meetingId}
    ParameterDescription

    meetingId

    모임 아이디

    -
    -
    HTTP Request
    -
    -
    GET /api/v1/meeting/1 HTTP/1.1
    -Content-Type: application/json;charset=UTF-8
    -Host: 43.202.65.170.nip.io
    +
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/get/success/path-parameters.adoc[]

    +
    +
    HTTP Request
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/get/success/http-request.adoc[]

    -
    +
    HTTP Response
    -
    -
    HTTP/1.1 200 OK
    -Vary: Origin
    -Vary: Access-Control-Request-Method
    -Vary: Access-Control-Request-Headers
    -Content-Type: application/json
    -X-Content-Type-Options: nosniff
    -X-XSS-Protection: 0
    -Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    -Pragma: no-cache
    -Expires: 0
    -X-Frame-Options: DENY
    -Content-Length: 241
    -
    -{
    -  "meetingId" : 1,
    -  "meetingName" : "DND 7조 회의",
    -  "meetingStartDate" : "2024-07-27",
    -  "meetingEndDate" : "2024-07-29",
    -  "numberOfPeople" : 6,
    -  "isOnline" : true,
    -  "isAnonymous" : false,
    -  "voteEndDate" : "2024-07-26T23:59:59"
    -}
    -
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/get/success/http-response.adoc[]

    2.5. 모임 개별 조회 - 실패

    - - ---- - - - - - - - - - - - - -
    Table 8. /api/v1/meeting/{meetingId}
    ParameterDescription

    meetingId

    모임 아이디

    -
    -
    HTTP Request
    -
    -
    GET /api/v1/meeting/9223372036854775807 HTTP/1.1
    -Content-Type: application/json;charset=UTF-8
    -Host: 43.202.65.170.nip.io
    -
    +
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/get/fail/path-parameters.adoc[]

    -
    -
    -
    HTTP/1.1 404 Not Found
    -Vary: Origin
    -Vary: Access-Control-Request-Method
    -Vary: Access-Control-Request-Headers
    -Content-Type: application/json
    -X-Content-Type-Options: nosniff
    -X-XSS-Protection: 0
    -Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    -Pragma: no-cache
    -Expires: 0
    -X-Frame-Options: DENY
    -Content-Length: 94
    -
    -{
    -  "code" : "404",
    -  "message" : "모임을 찾을 수 없습니다.",
    -  "validation" : { }
    -}
    +
    +
    HTTP Request
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/get/fail/http-request.adoc[]

    +
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/get/fail/http-response.adoc[]

    2.6. 모임 수정 - 성공

    - - ---- - - - - - - - - - - - - -
    Table 9. /api/v1/meeting/{meetingId}
    ParameterDescription

    meetingId

    모임 아이디

    -
    +
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/update/success/path-parameters.adoc[]

    +
    +
    HTTP Request
    -
    -
    PATCH /api/v1/meeting/5 HTTP/1.1
    -Content-Type: application/json;charset=UTF-8
    -Content-Length: 251
    -Host: 43.202.65.170.nip.io
    -
    -{
    -  "categoryIds" : [ 64 ],
    -  "meetingName" : "DND 11기 모임",
    -  "meetingStartDate" : "2024-08-11",
    -  "meetingEndDate" : "2024-08-12",
    -  "numberOfPeople" : 10,
    -  "isOnline" : false,
    -  "isAnonymous" : false,
    -  "voteEndDate" : "2024-08-04T23:59:59"
    -}
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/update/success/http-request.adoc[]

    +
    +
    Request Fields
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/update/success/request-fields.adoc[]

    - - ------- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Table 10. Request Fields
    PathTypeDescriptionOptionalConstraint

    meetingName

    String

    모임 이름

    모임 이름은 1자 이상 10자 이하로 입력해주세요.

    meetingStartDate

    String

    모임 시작 날짜

    모임 시작일은 종료일 이전이어야 합니다.

    meetingEndDate

    String

    모임 종료 날짜

    모임 종료일은 시작일 이후이어야 합니다.

    numberOfPeople

    Number

    모임 인원

    모임 인원은 2명 이상 10명 이하로 설정해주세요.

    isOnline

    Boolean

    온라인 여부

    true

    default = null

    isAnonymous

    Boolean

    익명 여부

    default = false (실명)

    voteEndDate

    String

    투표 종료 날짜

    투표 종료일은 모임 시작일 이전이어야 합니다.

    categoryIds

    Array

    카테고리 아이디 목록

    1개 이상의 카테고리를 선택해주세요.

    -
    +
    HTTP Response
    -
    -
    HTTP/1.1 200 OK
    -Vary: Origin
    -Vary: Access-Control-Request-Method
    -Vary: Access-Control-Request-Headers
    -X-Content-Type-Options: nosniff
    -X-XSS-Protection: 0
    -Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    -Pragma: no-cache
    -Expires: 0
    -X-Frame-Options: DENY
    -
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/update/success/http-response.adoc[]

    2.7. 모임 수정 - 실패

    -
    +
    HTTP Request
    -
    -
    PATCH /api/v1/meeting/9223372036854775807 HTTP/1.1
    -Content-Type: application/json;charset=UTF-8
    -Content-Length: 253
    -Host: 43.202.65.170.nip.io
    -
    -{
    -  "categoryIds" : [ 1, 2 ],
    -  "meetingName" : "DND 11기 모임",
    -  "meetingStartDate" : "2024-08-11",
    -  "meetingEndDate" : "2024-08-12",
    -  "numberOfPeople" : 10,
    -  "isOnline" : false,
    -  "isAnonymous" : false,
    -  "voteEndDate" : "2024-08-04T23:59:59"
    -}
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/update/fail/http-request.adoc[]

    -
    -
    +
    HTTP Response
    -
    -
    HTTP/1.1 404 Not Found
    -Vary: Origin
    -Vary: Access-Control-Request-Method
    -Vary: Access-Control-Request-Headers
    -Content-Type: application/json
    -X-Content-Type-Options: nosniff
    -X-XSS-Protection: 0
    -Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    -Pragma: no-cache
    -Expires: 0
    -X-Frame-Options: DENY
    -Content-Length: 94
    -
    -{
    -  "code" : "404",
    -  "message" : "모임을 찾을 수 없습니다.",
    -  "validation" : { }
    -}
    -
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/update/fail/http-response.adoc[]

    2.8. 모임 삭제 - 성공

    - - ---- - - - - - - - - - - - - -
    Table 11. /api/v1/meeting/{meetingId}
    ParameterDescription

    meetingId

    모임 아이디

    -
    -
    HTTP Request
    -
    -
    DELETE /api/v1/meeting/1 HTTP/1.1
    -Content-Type: application/json;charset=UTF-8
    -Host: 43.202.65.170.nip.io
    -
    +
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/delete/success/path-parameters.adoc[] +.HTTP Request +Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/delete/success/http-request.adoc[]

    -
    +
    HTTP Response
    -
    -
    HTTP/1.1 200 OK
    -Vary: Origin
    -Vary: Access-Control-Request-Method
    -Vary: Access-Control-Request-Headers
    -X-Content-Type-Options: nosniff
    -X-XSS-Protection: 0
    -Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    -Pragma: no-cache
    -Expires: 0
    -X-Frame-Options: DENY
    -
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/delete/success/http-response.adoc[]

    2.9. 모임 삭제 - 실패

    - - ---- - - - - - - - - - - - - -
    Table 12. /api/v1/meeting/{meetingId}
    ParameterDescription

    meetingId

    모임 아이디

    -
    -
    HTTP Request
    -
    -
    DELETE /api/v1/meeting/9223372036854775807 HTTP/1.1
    -Content-Type: application/json;charset=UTF-8
    -Host: 43.202.65.170.nip.io
    +
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/delete/fail/path-parameters.adoc[]

    +
    +
    HTTP Request
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/delete/fail/http-request.adoc[]

    -
    +
    HTTP Response
    -
    -
    HTTP/1.1 404 Not Found
    -Vary: Origin
    -Vary: Access-Control-Request-Method
    -Vary: Access-Control-Request-Headers
    -Content-Type: application/json
    -X-Content-Type-Options: nosniff
    -X-XSS-Protection: 0
    -Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    -Pragma: no-cache
    -Expires: 0
    -X-Frame-Options: DENY
    -Content-Length: 94
    -
    -{
    -  "code" : "404",
    -  "message" : "모임을 찾을 수 없습니다.",
    -  "validation" : { }
    -}
    -
    +

    Unresolved directive in api/meeting.adoc - include::/Users/seungjo/development/dnd-11th-7-backend/build/generated-snippets/meeting/delete/fail/http-response.adoc[]

    @@ -1496,7 +894,7 @@

    2.9. 모임 삭제 - 실패

    diff --git a/src/test/java/com/dnd/jjakkak/config/AbstractRestDocsTest.java b/src/test/java/com/dnd/jjakkak/config/AbstractRestDocsTest.java new file mode 100644 index 0000000..91369aa --- /dev/null +++ b/src/test/java/com/dnd/jjakkak/config/AbstractRestDocsTest.java @@ -0,0 +1,67 @@ +package com.dnd.jjakkak.config; + +import com.dnd.jjakkak.domain.jwt.provider.JwtProvider; +import com.dnd.jjakkak.domain.member.repository.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.result.MockMvcResultHandlers; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.filter.CharacterEncodingFilter; + +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +/** + * REST Docs 설정을 적용한 테스트 클래스입니다. (REST Docs 최상위 클래스) + * + * @author 정승조 + * @version 2024. 08. 05. + */ +@Import(RestDocsConfiguration.class) +@ExtendWith(RestDocumentationExtension.class) +public abstract class AbstractRestDocsTest { + + /** + * jwtAuthentication Filter 내부에서 사용하는 Bean 등록 + */ + @MockBean + protected JwtProvider jwtProvider; + + @MockBean + protected MemberRepository memberRepository; + + + @Autowired + protected RestDocumentationResultHandler restDocs; + + @Autowired + protected MockMvc mockMvc; + + @BeforeEach + void setUp(WebApplicationContext context, RestDocumentationContextProvider restDocumentation) { + + + this.mockMvc = MockMvcBuilders + .webAppContextSetup(context) + .apply(documentationConfiguration(restDocumentation)) + .alwaysDo(MockMvcResultHandlers.print()) + .alwaysDo(restDocs) + .addFilters(new CharacterEncodingFilter("UTF-8", true)) + .apply(springSecurity()) + .defaultRequest(post("/**").with(csrf())) + .defaultRequest(get("/**").with(csrf())) + .defaultRequest(patch("/**").with(csrf())) + .defaultRequest(delete("/**").with(csrf())) + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/com/dnd/jjakkak/config/JjakkakMockSecurityContext.java b/src/test/java/com/dnd/jjakkak/config/JjakkakMockSecurityContext.java new file mode 100644 index 0000000..0b95241 --- /dev/null +++ b/src/test/java/com/dnd/jjakkak/config/JjakkakMockSecurityContext.java @@ -0,0 +1,44 @@ +package com.dnd.jjakkak.config; + +import com.dnd.jjakkak.domain.member.entity.Member; +import com.dnd.jjakkak.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.test.context.support.WithSecurityContextFactory; + +import java.util.List; + +/** + * 테스트에서 사용할 MockUser 어노테이션에 SecurityContext 값을 설정하는 클래스입니다. + * + * @author 정승조 + * @version 2024. 08. 05. + */ +@RequiredArgsConstructor +public class JjakkakMockSecurityContext implements WithSecurityContextFactory { + + private final MemberRepository memberRepository; + + + @Override + public SecurityContext createSecurityContext(JjakkakMockUser annotation) { + + Member member = Member.builder() + .memberNickname(annotation.nickname()) + .kakaoId(annotation.kakaoId()) + .build(); + + memberRepository.save(member); + + SimpleGrantedAuthority role = new SimpleGrantedAuthority("ROLE_USER"); + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(member, null, List.of(role)); + + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authToken); + + return context; + } +} diff --git a/src/test/java/com/dnd/jjakkak/config/JjakkakMockUser.java b/src/test/java/com/dnd/jjakkak/config/JjakkakMockUser.java new file mode 100644 index 0000000..928322d --- /dev/null +++ b/src/test/java/com/dnd/jjakkak/config/JjakkakMockUser.java @@ -0,0 +1,21 @@ +package com.dnd.jjakkak.config; + +import org.springframework.security.test.context.support.WithSecurityContext; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * MockUser 어노테이션입니다. + * + * @author 정승조 + * @version 2024. 08. 05. + */ +@Retention(RetentionPolicy.RUNTIME) +@WithSecurityContext(factory = JjakkakMockSecurityContext.class) +public @interface JjakkakMockUser { + + String nickname() default "seungjo"; + + long kakaoId() default 1234567890; +} diff --git a/src/test/java/com/dnd/jjakkak/config/RestDocsConfiguration.java b/src/test/java/com/dnd/jjakkak/config/RestDocsConfiguration.java new file mode 100644 index 0000000..0583ecd --- /dev/null +++ b/src/test/java/com/dnd/jjakkak/config/RestDocsConfiguration.java @@ -0,0 +1,26 @@ +package com.dnd.jjakkak.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.operation.preprocess.Preprocessors; + +/** + * REST Docs 설정 클래스입니다. + * + * @author 정승조 + * @version 2024. 08. 05. + */ +@TestConfiguration +public class RestDocsConfiguration { + + @Bean + public RestDocumentationResultHandler write() { + return MockMvcRestDocumentation.document( + "{class-name}/{method-name}", + Preprocessors.preprocessRequest(Preprocessors.prettyPrint()), + Preprocessors.preprocessResponse(Preprocessors.prettyPrint()) + ); + } +} diff --git a/src/test/java/com/dnd/jjakkak/domain/category/CategoryDummy.java b/src/test/java/com/dnd/jjakkak/domain/category/CategoryDummy.java new file mode 100644 index 0000000..ae10ab1 --- /dev/null +++ b/src/test/java/com/dnd/jjakkak/domain/category/CategoryDummy.java @@ -0,0 +1,66 @@ +package com.dnd.jjakkak.domain.category; + +import com.dnd.jjakkak.domain.category.dto.response.CategoryResponseDto; + +import java.util.List; + +/** + * 카테고리 더미 데이터 클래스입니다. + * + * @author 정승조 + * @version 2024. 08. 06. + */ +public class CategoryDummy { + + public static List getCategoryResponseDtoList() { + + return List.of( + CategoryResponseDto.builder() + .categoryId(1L) + .categoryName("학교") + .build(), + + CategoryResponseDto.builder() + .categoryId(2L) + .categoryName("친구") + .build(), + + CategoryResponseDto.builder() + .categoryId(3L) + .categoryName("팀플") + .build(), + + CategoryResponseDto.builder() + .categoryId(4L) + .categoryName("회의") + .build(), + + CategoryResponseDto.builder() + .categoryId(5L) + .categoryName("스터디") + .build(), + + CategoryResponseDto.builder() + .categoryId(6L) + .categoryName("취미") + .build(), + + CategoryResponseDto.builder() + .categoryId(7L) + .categoryName("봉사") + .build(), + + CategoryResponseDto.builder() + .categoryId(8L) + .categoryName("기타") + .build() + ); + } + + public static CategoryResponseDto getCategoryResponseDto() { + return CategoryResponseDto.builder() + .categoryId(1L) + .categoryName("학교") + .build(); + } +} diff --git a/src/test/java/com/dnd/jjakkak/domain/category/controller/CategoryControllerTest.java b/src/test/java/com/dnd/jjakkak/domain/category/controller/CategoryControllerTest.java index 64fe7cd..019456b 100644 --- a/src/test/java/com/dnd/jjakkak/domain/category/controller/CategoryControllerTest.java +++ b/src/test/java/com/dnd/jjakkak/domain/category/controller/CategoryControllerTest.java @@ -1,28 +1,25 @@ package com.dnd.jjakkak.domain.category.controller; -import com.dnd.jjakkak.domain.category.entity.Category; -import com.dnd.jjakkak.domain.category.repository.CategoryRepository; +import com.dnd.jjakkak.config.AbstractRestDocsTest; +import com.dnd.jjakkak.config.JjakkakMockUser; +import com.dnd.jjakkak.domain.category.CategoryDummy; +import com.dnd.jjakkak.domain.category.exception.CategoryNotFoundException; import com.dnd.jjakkak.domain.category.service.CategoryService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; -import java.util.List; - -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; -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.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** * 카테고리 컨트롤러 테스트 클래스입니다. @@ -30,80 +27,60 @@ * @author 정승조 * @version 2024. 07. 24. */ -@ActiveProfiles("test") -@SpringBootTest +@WebMvcTest(CategoryController.class) @AutoConfigureRestDocs(uriHost = "43.202.65.170.nip.io", uriPort = 80) -@AutoConfigureMockMvc -class CategoryControllerTest { - - @Autowired - MockMvc mockMvc; +class CategoryControllerTest extends AbstractRestDocsTest { - @Autowired + @MockBean CategoryService categoryService; - @Autowired - CategoryRepository categoryRepository; - @Test @DisplayName("카테고리 전체 목록 조회") - void testGetCategoryList() throws Exception { + @JjakkakMockUser + void get_list() throws Exception { // given - Category school = Category.builder() - .categoryName("학교") - .build(); - - Category friend = Category.builder() - .categoryName("친구") - .build(); - - Category meeting = Category.builder() - .categoryName("회의") - .build(); - - categoryRepository.saveAll(List.of(school, friend, meeting)); + when(categoryService.getCategoryList()).thenReturn(CategoryDummy.getCategoryResponseDtoList()); // expected mockMvc.perform(get("/api/v1/categories")) .andExpectAll( status().isOk(), - content().contentType(MediaType.APPLICATION_JSON), jsonPath("$[?(@.categoryName == '학교')]").exists(), jsonPath("$[?(@.categoryName == '친구')]").exists(), - jsonPath("$[?(@.categoryName == '회의')]").exists() + jsonPath("$[?(@.categoryName == '팀플')]").exists(), + jsonPath("$[?(@.categoryName == '회의')]").exists(), + jsonPath("$[?(@.categoryName == '스터디')]").exists(), + jsonPath("$[?(@.categoryName == '취미')]").exists(), + jsonPath("$[?(@.categoryName == '봉사')]").exists(), + jsonPath("$[?(@.categoryName == '기타')]").exists() ) - .andDo(document("category/getCategoryList/success", - preprocessResponse(prettyPrint()), + .andDo(restDocs.document( responseFields( fieldWithPath("[].categoryId").description("카테고리 아이디"), fieldWithPath("[].categoryName").description("카테고리 이름") - ) - )); + )) + ); } @Test @DisplayName("카테고리 단건 조회 - 성공") - void testGetCategory_Success() throws Exception { + @JjakkakMockUser + void get_success() throws Exception { - // given - Category school = Category.builder() - .categoryName("기타") - .build(); + // given (1L, "학교") + when(categoryService.getCategory(anyLong())).thenReturn(CategoryDummy.getCategoryResponseDto()); - categoryRepository.save(school); // expected - mockMvc.perform(get("/api/v1/categories/{id}", school.getCategoryId())) + mockMvc.perform(get("/api/v1/categories/{id}", 1L)) .andExpectAll( status().isOk(), - jsonPath("$.categoryId").value(school.getCategoryId()), - jsonPath("$.categoryName").value("기타") + jsonPath("$.categoryId").value(1), + jsonPath("$.categoryName").value("학교") ) - .andDo(document("category/getCategory/success", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), + .andDo(restDocs.document( pathParameters( parameterWithName("id").description("카테고리 아이디")), responseFields( @@ -115,14 +92,17 @@ void testGetCategory_Success() throws Exception { @Test @DisplayName("카테고리 단건 조회 - 실패 (404)") - void testGetCategory_Fail() throws Exception { + @JjakkakMockUser + void get_fail() throws Exception { + + // given + when(categoryService.getCategory(anyLong())) + .thenThrow(new CategoryNotFoundException()); // expected - mockMvc.perform(get("/api/v1/categories/{id}", Long.MAX_VALUE)) + mockMvc.perform(get("/api/v1/categories/{id}", 100L)) .andExpect(status().isNotFound()) - .andDo(document("category/getCategory/fail-404", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), + .andDo(restDocs.document( pathParameters( parameterWithName("id").description("카테고리 아이디")), responseFields( diff --git a/src/test/java/com/dnd/jjakkak/domain/meeting/MeetingDummy.java b/src/test/java/com/dnd/jjakkak/domain/meeting/MeetingDummy.java index 2c72503..33d71d9 100644 --- a/src/test/java/com/dnd/jjakkak/domain/meeting/MeetingDummy.java +++ b/src/test/java/com/dnd/jjakkak/domain/meeting/MeetingDummy.java @@ -1,7 +1,6 @@ package com.dnd.jjakkak.domain.meeting; import com.dnd.jjakkak.domain.meeting.dto.request.MeetingCreateRequestDto; -import com.dnd.jjakkak.domain.meeting.dto.request.MeetingUpdateRequestDto; import com.dnd.jjakkak.domain.meeting.dto.response.MeetingResponseDto; import com.dnd.jjakkak.domain.meeting.entity.Meeting; import org.springframework.test.util.ReflectionTestUtils; @@ -32,7 +31,6 @@ public static MeetingCreateRequestDto createRequestDto(List categoryIds) { ReflectionTestUtils.setField(requestDto, "meetingStartDate", LocalDate.of(2024, 7, 27)); ReflectionTestUtils.setField(requestDto, "meetingEndDate", LocalDate.of(2024, 7, 29)); ReflectionTestUtils.setField(requestDto, "numberOfPeople", 6); - ReflectionTestUtils.setField(requestDto, "isOnline", true); ReflectionTestUtils.setField(requestDto, "isAnonymous", false); ReflectionTestUtils.setField(requestDto, "voteEndDate", LocalDateTime.of(2024, 7, 26, 23, 59, 59)); ReflectionTestUtils.setField(requestDto, "categoryIds", categoryIds); @@ -56,60 +54,22 @@ public static MeetingCreateRequestDto createInvalidRequestDto() { */ public static MeetingResponseDto createResponseDto() { - return new MeetingResponseDto( - 1L, - "세븐일레븐", - LocalDate.of(2024, 7, 27), - LocalDate.of(2024, 7, 29), - 6, - true, - false, - LocalDateTime.of(2024, 7, 26, 23, 59, 59) - ); - } - - /** - * Meeting 엔티티를 생성하여 반환합니다. - * - * @return Meeting 엔티티 리스트 - */ - public static List createMeetingList() { - Meeting meeting = Meeting.builder() - .meetingName("DND 7조 회의") + .meetingName("세븐일레븐") .meetingStartDate(LocalDate.of(2024, 7, 27)) .meetingEndDate(LocalDate.of(2024, 7, 29)) .numberOfPeople(6) - .isOnline(true) .isAnonymous(false) .voteEndDate(LocalDateTime.of(2024, 7, 26, 23, 59, 59)) + .meetingLeaderId(1L) + .meetingUuid("1234ABCD") .build(); - Meeting study = Meeting.builder() - .meetingName("Java 스터디") - .meetingStartDate(LocalDate.of(2024, 8, 1)) - .meetingEndDate(LocalDate.of(2024, 8, 5)) - .numberOfPeople(4) - .isOnline(true) - .isAnonymous(false) - .voteEndDate(LocalDateTime.of(2024, 7, 30, 23, 59, 59)) - .build(); - - return List.of(meeting, study); - } + ReflectionTestUtils.setField(meeting, "meetingId", 1L); - public static MeetingUpdateRequestDto updateRequestDto(Long... categoryIds) { - MeetingUpdateRequestDto requestDto = new MeetingUpdateRequestDto(); - ReflectionTestUtils.setField(requestDto, "meetingName", "DND 11기 모임"); - ReflectionTestUtils.setField(requestDto, "meetingStartDate", LocalDate.of(2024, 8, 11)); - ReflectionTestUtils.setField(requestDto, "meetingEndDate", LocalDate.of(2024, 8, 12)); - ReflectionTestUtils.setField(requestDto, "numberOfPeople", 10); - ReflectionTestUtils.setField(requestDto, "isOnline", false); - ReflectionTestUtils.setField(requestDto, "isAnonymous", false); - ReflectionTestUtils.setField(requestDto, "voteEndDate", LocalDateTime.of(2024, 8, 4, 23, 59, 59)); - ReflectionTestUtils.setField(requestDto, "categoryIds", List.of(categoryIds)); - - return requestDto; + return MeetingResponseDto.builder() + .meeting(meeting) + .build(); } } diff --git a/src/test/java/com/dnd/jjakkak/domain/meeting/controller/MeetingControllerTest.java b/src/test/java/com/dnd/jjakkak/domain/meeting/controller/MeetingControllerTest.java index c1b1833..bbc9370 100644 --- a/src/test/java/com/dnd/jjakkak/domain/meeting/controller/MeetingControllerTest.java +++ b/src/test/java/com/dnd/jjakkak/domain/meeting/controller/MeetingControllerTest.java @@ -1,130 +1,69 @@ package com.dnd.jjakkak.domain.meeting.controller; -import com.dnd.jjakkak.domain.category.entity.Category; -import com.dnd.jjakkak.domain.category.repository.CategoryRepository; +import com.dnd.jjakkak.config.AbstractRestDocsTest; +import com.dnd.jjakkak.config.JjakkakMockUser; import com.dnd.jjakkak.domain.meeting.MeetingDummy; +import com.dnd.jjakkak.domain.meeting.dto.request.MeetingConfirmRequestDto; import com.dnd.jjakkak.domain.meeting.dto.request.MeetingCreateRequestDto; -import com.dnd.jjakkak.domain.meeting.dto.request.MeetingUpdateRequestDto; -import com.dnd.jjakkak.domain.meeting.entity.Meeting; -import com.dnd.jjakkak.domain.meeting.repository.MeetingRepository; +import com.dnd.jjakkak.domain.meeting.exception.MeetingNotFoundException; import com.dnd.jjakkak.domain.meeting.service.MeetingService; import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; -import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; -import org.springframework.test.context.ActiveProfiles; import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.test.web.servlet.MockMvc; -import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; import static org.springframework.restdocs.payload.PayloadDocumentation.*; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; import static org.springframework.restdocs.snippet.Attributes.key; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** - * 모임 컨트롤러 테스트 클래스입니다. + * 모임 컨트롤러 테스트입니다. * * @author 정승조 - * @version 2024. 07. 25. + * @version 2024. 08. 05. */ -@ActiveProfiles("test") -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@WebMvcTest(MeetingController.class) @AutoConfigureRestDocs(uriHost = "43.202.65.170.nip.io", uriPort = 80) -@AutoConfigureMockMvc -class MeetingControllerTest { +class MeetingControllerTest extends AbstractRestDocsTest { - @Autowired - MockMvc mockMvc; - - @Autowired - MeetingService groupService; - - @Autowired - MeetingRepository meetingRepository; - - @Autowired - CategoryRepository categoryRepository; + @MockBean + MeetingService meetingService; @Autowired - ObjectMapper objectMapper; - - Category school, friend, teamProject, session, study, hobby, volunteer, etc; - - - @BeforeEach - void setUp() { - - // 1.학교, 2.친구, 3.팀플, 4.회의, 5.스터디, 6.취미, 7.봉사, 8.기타 - school = Category.builder() - .categoryName("학교") - .build(); - - friend = Category.builder() - .categoryName("친구") - .build(); - - teamProject = Category.builder() - .categoryName("팀플") - .build(); - - session = Category.builder() - .categoryName("회의") - .build(); - - study = Category.builder() - .categoryName("스터디") - .build(); - - hobby = Category.builder() - .categoryName("취미") - .build(); - - volunteer = Category.builder() - .categoryName("봉사") - .build(); - - etc = Category.builder() - .categoryName("기타") - .build(); - - categoryRepository.saveAll(List.of(school, friend, teamProject, session, study, hobby, volunteer, etc)); - } + ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); @Test @DisplayName("모임 생성 테스트 - 성공") - void testCreateMeeting_Success() throws Exception { - - // given - MeetingCreateRequestDto requestDto = MeetingDummy.createRequestDto( - List.of(teamProject.getCategoryId(), study.getCategoryId(), session.getCategoryId())); + @JjakkakMockUser + void create_success() throws Exception { + MeetingCreateRequestDto requestDto = MeetingDummy.createRequestDto(List.of(1L, 2L)); String json = objectMapper.writeValueAsString(requestDto); - // expected + String token = "Bearer access_token"; + mockMvc.perform(post("/api/v1/meeting") + .header("Authorization", token) .contentType(MediaType.APPLICATION_JSON) .content(json)) - .andExpectAll( - status().isCreated()) - .andDo(document("meeting/create/success", - preprocessRequest(prettyPrint()), + .andExpect(status().isCreated()) + .andDo(restDocs.document( requestFields( fieldWithPath("meetingName").description("모임 이름") .attributes(key("constraint").value("모임 이름은 1자 이상 10자 이하로 입력해주세요.")), @@ -134,9 +73,6 @@ void testCreateMeeting_Success() throws Exception { .attributes(key("constraint").value("모임 종료일은 시작일 이후이어야 합니다.")), fieldWithPath("numberOfPeople").description("모임 인원") .attributes(key("constraint").value("모임 인원은 2명 이상 10명 이하로 설정해주세요.")), - fieldWithPath("isOnline").description("온라인 여부") - .optional() - .attributes(key("constraint").value("default = null")), fieldWithPath("isAnonymous").description("익명 여부") .attributes(key("constraint").value("default = false (실명)")), fieldWithPath("voteEndDate").description("투표 종료 날짜") @@ -147,20 +83,20 @@ void testCreateMeeting_Success() throws Exception { } @Test - @DisplayName("모임 생성 테스트 - 실패") - void testCreateGroup_Fail() throws Exception { - - // given + @DisplayName("모임 생성 테스트 - 실패 (Invalid)") + @JjakkakMockUser + void create_fail_invalid() throws Exception { MeetingCreateRequestDto requestDto = MeetingDummy.createInvalidRequestDto(); String json = objectMapper.writeValueAsString(requestDto); - // expected + String token = "Bearer access_token"; + mockMvc.perform(post("/api/v1/meeting") + .header("Authorization", token) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isBadRequest()) - .andDo(document("meeting/create/fail", - preprocessResponse(prettyPrint()), + .andDo(restDocs.document( responseFields( fieldWithPath("code").description("상태 코드"), fieldWithPath("message").description("에러 메시지"), @@ -172,221 +108,117 @@ void testCreateGroup_Fail() throws Exception { fieldWithPath("validation.isAnonymous").description("익명 여부는 필수 값입니다."), fieldWithPath("validation.voteEndDate").description("투표 종료일은 필수 값입니다."), fieldWithPath("validation.categoryIds").description("카테고리는 최소 1개 이상 8개 이하로 선택해주세요.") - )) - ); - } - - @Test - @DisplayName("모임 조회 테스트 - 전체 조회") - void testGetMeetingList() throws Exception { - // given - List meetingList = MeetingDummy.createMeetingList(); - meetingRepository.saveAll(meetingList); - - // expected - mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/meeting") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andDo(document("meeting/getList/success", - preprocessResponse(prettyPrint()), - responseFields( - fieldWithPath("[].meetingId").description("모임 아이디"), - fieldWithPath("[].meetingName").description("모임 이름"), - fieldWithPath("[].meetingStartDate").description("모임 시작 날짜"), - fieldWithPath("[].meetingEndDate").description("모임 종료 날짜"), - fieldWithPath("[].numberOfPeople").description("모임 인원"), - fieldWithPath("[].isOnline").description("온라인 여부"), - fieldWithPath("[].isAnonymous").description("익명 여부"), - fieldWithPath("[].voteEndDate").description("투표 종료 날짜") ))); + } @Test - @DisplayName("모임 조회 테스트 - 단건 조회 (성공)") - void testGetMeeting_Success() throws Exception { - // given - Meeting meeting = Meeting.builder() - .meetingName("DND 7조 회의") - .meetingStartDate(LocalDate.of(2024, 7, 27)) - .meetingEndDate(LocalDate.of(2024, 7, 29)) - .numberOfPeople(6) - .isOnline(true) - .isAnonymous(false) - .voteEndDate(LocalDateTime.of(2024, 7, 26, 23, 59, 59)) - .build(); + @DisplayName("모임 조회 테스트") + @JjakkakMockUser + void get_byUuid() throws Exception { - meetingRepository.save(meeting); + // given + String uuid = "1234ABCD"; + when(meetingService.getMeetingByUuid(anyString())).thenReturn(MeetingDummy.createResponseDto()); // expected - mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/meeting/{meetingId}", meeting.getMeetingId()) + + mockMvc.perform(get("/api/v1/meeting/{meetingUuid}", uuid) .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) .andExpectAll( - jsonPath("$.meetingId").value(meeting.getMeetingId()), - jsonPath("$.meetingName").value(meeting.getMeetingName()), - jsonPath("$.meetingStartDate").value(meeting.getMeetingStartDate().toString()), - jsonPath("$.meetingEndDate").value(meeting.getMeetingEndDate().toString()), - jsonPath("$.numberOfPeople").value(meeting.getNumberOfPeople()), - jsonPath("$.isOnline").value(meeting.getIsOnline()), - jsonPath("$.isAnonymous").value(meeting.getIsAnonymous()), - jsonPath("$.voteEndDate").value(meeting.getVoteEndDate().toString()) + status().isOk(), + jsonPath("$.meetingId").value(1L), + jsonPath("$.meetingName").value("세븐일레븐"), + jsonPath("$.meetingStartDate").value("2024-07-27"), + jsonPath("$.meetingEndDate").value("2024-07-29"), + jsonPath("$.numberOfPeople").value(6), + jsonPath("$.isAnonymous").value(false), + jsonPath("$.voteEndDate").value("2024-07-26T23:59:59"), + jsonPath("$.confirmedSchedule").doesNotExist(), // null + jsonPath("$.meetingLeaderId").value(1L), + jsonPath("$.meetingUuid").value("1234ABCD") ) - .andDo(document("meeting/get/success", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), + .andDo(restDocs.document( pathParameters( - parameterWithName("meetingId").description("모임 아이디")), + parameterWithName("meetingUuid").description("모임 UUID")), responseFields( - fieldWithPath("meetingId").description("모임 아이디"), + fieldWithPath("meetingId").description("모임 ID"), fieldWithPath("meetingName").description("모임 이름"), fieldWithPath("meetingStartDate").description("모임 시작 날짜"), fieldWithPath("meetingEndDate").description("모임 종료 날짜"), fieldWithPath("numberOfPeople").description("모임 인원"), - fieldWithPath("isOnline").description("온라인 여부"), fieldWithPath("isAnonymous").description("익명 여부"), - fieldWithPath("voteEndDate").description("투표 종료 날짜") + fieldWithPath("voteEndDate").description("투표 종료 날짜"), + fieldWithPath("confirmedSchedule").description("확정된 일정"), + fieldWithPath("meetingLeaderId").description("모임 리더 ID"), + fieldWithPath("meetingUuid").description("모임 UUID") ))); } @Test - @DisplayName("모임 조회 테스트 - 단건 조회 (실패)") - void testGetMeeting_Fail() throws Exception { + @DisplayName("모임 조회 테스트 - 실패") + @JjakkakMockUser + void get_byUuid_fail() throws Exception { + + // given + when(meetingService.getMeetingByUuid(anyString())) + .thenThrow(new MeetingNotFoundException()); // expected - mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/meeting/{meetingId}", Long.MAX_VALUE) + mockMvc.perform(get("/api/v1/meeting/{meetingUuid}", "ABCD1234") .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isNotFound()) - .andDo(document("meeting/get/fail", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), + .andExpectAll( + status().isNotFound(), + jsonPath("$.code").value(404), + jsonPath("$.message").value("모임을 찾을 수 없습니다.") + ) + .andDo(restDocs.document( pathParameters( - parameterWithName("meetingId").description("모임 아이디")), + parameterWithName("meetingUuid").description("모임 UUID")), responseFields( - fieldWithPath("code").description("상태 코드"), + fieldWithPath("code").description("에러 코드"), fieldWithPath("message").description("에러 메시지"), fieldWithPath("validation").description("유효성 검사 오류 목록") ))); } @Test - @DisplayName("모임 수정 테스트 - 성공") - void testUpdateMeeting_Success() throws Exception { - // given - Meeting meeting = Meeting.builder() - .meetingName("DND 7조 회의") - .meetingStartDate(LocalDate.of(2024, 7, 27)) - .meetingEndDate(LocalDate.of(2024, 7, 29)) - .numberOfPeople(6) - .isOnline(true) - .isAnonymous(false) - .voteEndDate(LocalDateTime.of(2024, 7, 26, 23, 59, 59)) - .build(); + @JjakkakMockUser + @DisplayName("모임 확정 일정 수정 테스트") + void update_confirmedSchedule() throws Exception { - Meeting saved = meetingRepository.save(meeting); + // given + MeetingConfirmRequestDto requestDto = new MeetingConfirmRequestDto(); + ReflectionTestUtils.setField(requestDto, "confirmedSchedule", LocalDateTime.of(2024, 7, 28, 12, 30)); - MeetingUpdateRequestDto requestDto = MeetingDummy.updateRequestDto(session.getCategoryId()); String json = objectMapper.writeValueAsString(requestDto); // expected - mockMvc.perform(RestDocumentationRequestBuilders.patch("/api/v1/meeting/{meetingId}", saved.getMeetingId()) + mockMvc.perform(patch("/api/v1/meeting/{meetingId}/confirm", 1L) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isOk()) - .andDo(document("meeting/update/success", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), + .andDo(restDocs.document( pathParameters( - parameterWithName("meetingId").description("모임 아이디")), + parameterWithName("meetingId").description("모임 ID")), requestFields( - fieldWithPath("meetingName").description("모임 이름") - .attributes(key("constraint").value("모임 이름은 1자 이상 10자 이하로 입력해주세요.")), - fieldWithPath("meetingStartDate").description("모임 시작 날짜") - .attributes(key("constraint").value("모임 시작일은 종료일 이전이어야 합니다.")), - fieldWithPath("meetingEndDate").description("모임 종료 날짜") - .attributes(key("constraint").value("모임 종료일은 시작일 이후이어야 합니다.")), - fieldWithPath("numberOfPeople").description("모임 인원") - .attributes(key("constraint").value("모임 인원은 2명 이상 10명 이하로 설정해주세요.")), - fieldWithPath("isOnline").description("온라인 여부") - .optional() - .attributes(key("constraint").value("default = null")), - fieldWithPath("isAnonymous").description("익명 여부") - .attributes(key("constraint").value("default = false (실명)")), - fieldWithPath("voteEndDate").description("투표 종료 날짜") - .attributes(key("constraint").value("투표 종료일은 모임 시작일 이전이어야 합니다.")), - fieldWithPath("categoryIds").description("카테고리 아이디 목록") - .attributes(key("constraint").value("1개 이상의 카테고리를 선택해주세요.")) - ))); - } - - @Test - @DisplayName("모임 수정 테스트 - 실패 (존재하지 않는 모임)") - void testUpdateMeeting_Fail() throws Exception { - - // given - MeetingUpdateRequestDto requestDto = MeetingDummy.updateRequestDto(1L, 2L); - String json = objectMapper.writeValueAsString(requestDto); - - // expected - mockMvc.perform(RestDocumentationRequestBuilders.patch("/api/v1/meeting/{meetingId}", Long.MAX_VALUE) - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isNotFound()) - .andDo(print()) - .andDo(document("meeting/update/fail", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - responseFields( - fieldWithPath("code").description("상태 코드"), - fieldWithPath("message").description("에러 메시지"), - fieldWithPath("validation").description("유효성 검사 오류 목록") - ))); + fieldWithPath("confirmedSchedule").description("확정된 일정") + .attributes(key("constraint").value("확정된 일정은 시작일과 종료일 사이의 날짜여야 합니다."))) + )); } @Test - @DisplayName("모임 삭제 테스트 - 성공") - void testDeleteMeeting_Success() throws Exception { - // given - Meeting meeting = Meeting.builder() - .meetingName("DND 7조 회의") - .meetingStartDate(LocalDate.of(2024, 7, 27)) - .meetingEndDate(LocalDate.of(2024, 7, 29)) - .numberOfPeople(6) - .isOnline(true) - .isAnonymous(false) - .voteEndDate(LocalDateTime.of(2024, 7, 26, 23, 59, 59)) - .build(); - - ReflectionTestUtils.setField(meeting, "meetingId", 1L); - meetingRepository.save(meeting); + @DisplayName("모임 삭제 테스트") + @JjakkakMockUser + void delete_success() throws Exception { // expected - mockMvc.perform(RestDocumentationRequestBuilders.delete("/api/v1/meeting/{meetingId}", 1L) + mockMvc.perform(delete("/api/v1/meeting/{meetingId}", 1L) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andDo(document("meeting/delete/success", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), + .andDo(restDocs.document( pathParameters( - parameterWithName("meetingId").description("모임 아이디")))); - } - - @Test - @DisplayName("모임 삭제 테스트 - 실패 (존재하지 않는 모임)") - void testDeleteMeeting_Fail() throws Exception { - - // expected - mockMvc.perform(RestDocumentationRequestBuilders.delete("/api/v1/meeting/{meetingId}", Long.MAX_VALUE) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isNotFound()) - .andDo(document("meeting/delete/fail", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - pathParameters( - parameterWithName("meetingId").description("모임 아이디")), - responseFields( - fieldWithPath("code").description("상태 코드"), - fieldWithPath("message").description("에러 메시지"), - fieldWithPath("validation").description("유효성 검사 오류 목록")) + parameterWithName("meetingId").description("모임 ID")) )); } } \ No newline at end of file diff --git a/src/test/java/com/dnd/jjakkak/domain/meeting/service/MeetingServiceTest.java b/src/test/java/com/dnd/jjakkak/domain/meeting/service/MeetingServiceTest.java index 9a64381..73bfda4 100644 --- a/src/test/java/com/dnd/jjakkak/domain/meeting/service/MeetingServiceTest.java +++ b/src/test/java/com/dnd/jjakkak/domain/meeting/service/MeetingServiceTest.java @@ -1,11 +1,9 @@ package com.dnd.jjakkak.domain.meeting.service; import com.dnd.jjakkak.domain.category.entity.Category; -import com.dnd.jjakkak.domain.category.exception.CategoryNotFoundException; import com.dnd.jjakkak.domain.category.repository.CategoryRepository; import com.dnd.jjakkak.domain.meeting.MeetingDummy; import com.dnd.jjakkak.domain.meeting.dto.request.MeetingCreateRequestDto; -import com.dnd.jjakkak.domain.meeting.dto.request.MeetingUpdateRequestDto; import com.dnd.jjakkak.domain.meeting.dto.response.MeetingResponseDto; import com.dnd.jjakkak.domain.meeting.entity.Meeting; import com.dnd.jjakkak.domain.meeting.exception.MeetingNotFoundException; @@ -17,7 +15,6 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDate; import java.time.LocalDateTime; @@ -66,7 +63,7 @@ void testCreateMeeting() { when(categoryRepository.findById(2L)).thenReturn(Optional.of(meeting)); // when - meetingService.createMeeting(actual); + meetingService.createMeeting(1L, actual); // then verify(meetingRepository, times(1)).save(any()); @@ -75,53 +72,6 @@ void testCreateMeeting() { } - @Test - @DisplayName("모임 생성 테스트 - 실패 (카테고리 없음)") - void testCreateMeeting_Fail() { - - // given - MeetingCreateRequestDto actual = MeetingDummy.createRequestDto(List.of(1L, 2L)); - Category teamProject = Category.builder() - .categoryName("팀플") - .build(); - - when(categoryRepository.findById(1L)).thenReturn(Optional.of(teamProject)); - when(categoryRepository.findById(2L)).thenReturn(Optional.empty()); - - // expected - assertThrows(CategoryNotFoundException.class, - () -> meetingService.createMeeting(actual)); - } - - @Test - @DisplayName("모임 조회 테스트 - 전체") - void testGetMeetingList() { - - // given - List meetingList = MeetingDummy.createMeetingList(); - - when(meetingRepository.findAll()).thenReturn(meetingList); - - // when - List actual = meetingService.getMeetingList(); - - // then - assertAll( - () -> assertEquals(actual.size(), meetingList.size()), - () -> assertEquals(actual.get(0).getMeetingId(), meetingList.get(0).getMeetingId()), - () -> assertEquals(actual.get(0).getMeetingName(), meetingList.get(0).getMeetingName()), - () -> assertEquals(actual.get(0).getMeetingStartDate(), meetingList.get(0).getMeetingStartDate()), - () -> assertEquals(actual.get(0).getMeetingEndDate(), meetingList.get(0).getMeetingEndDate()), - () -> assertEquals(actual.get(0).getNumberOfPeople(), meetingList.get(0).getNumberOfPeople()), - () -> assertEquals(actual.get(0).getIsOnline(), meetingList.get(0).getIsOnline()), - () -> assertEquals(actual.get(0).getIsAnonymous(), meetingList.get(0).getIsAnonymous()), - () -> assertEquals(actual.get(0).getVoteEndDate(), meetingList.get(0).getVoteEndDate()) - ); - - verify(meetingRepository, times(1)).findAll(); - } - - @Test @DisplayName("모임 조회 테스트 - 단건 (성공)") void testGetMeeting_Success() { @@ -132,15 +82,16 @@ void testGetMeeting_Success() { .meetingStartDate(LocalDate.of(2024, 7, 27)) .meetingEndDate(LocalDate.of(2024, 7, 29)) .numberOfPeople(6) - .isOnline(true) .isAnonymous(false) .voteEndDate(LocalDateTime.of(2024, 7, 26, 23, 59, 59)) + .meetingLeaderId(1L) + .meetingUuid("1234abcd") .build(); - when(meetingRepository.findById(anyLong())).thenReturn(Optional.of(meeting)); + when(meetingRepository.findByMeetingUuid(anyString())).thenReturn(Optional.of(meeting)); // when - MeetingResponseDto actual = meetingService.getMeeting(1L); + MeetingResponseDto actual = meetingService.getMeetingByUuid("1234abcd"); // then assertAll( @@ -149,97 +100,54 @@ void testGetMeeting_Success() { () -> assertEquals(actual.getMeetingStartDate(), meeting.getMeetingStartDate()), () -> assertEquals(actual.getMeetingEndDate(), meeting.getMeetingEndDate()), () -> assertEquals(actual.getNumberOfPeople(), meeting.getNumberOfPeople()), - () -> assertEquals(actual.getIsOnline(), meeting.getIsOnline()), () -> assertEquals(actual.getIsAnonymous(), meeting.getIsAnonymous()), - () -> assertEquals(actual.getVoteEndDate(), meeting.getVoteEndDate()) + () -> assertEquals(actual.getVoteEndDate(), meeting.getVoteEndDate()), + () -> assertEquals(actual.getMeetingLeaderId(), meeting.getMeetingLeaderId()), + () -> assertEquals(actual.getMeetingUuid(), meeting.getMeetingUuid()) ); - verify(meetingRepository, times(1)).findById(1L); + verify(meetingRepository, times(1)).findByMeetingUuid(anyString()); } @Test @DisplayName("모임 조회 테스트 - 단건 (실패)") void testGetMeeting_Fail() { // given - when(meetingRepository.findById(anyLong())).thenReturn(Optional.empty()); + when(meetingRepository.findByMeetingUuid(anyString())).thenReturn(Optional.empty()); // expected assertThrows(MeetingNotFoundException.class, - () -> meetingService.getMeeting(1L)); + () -> meetingService.getMeetingByUuid("1234abcd")); - verify(meetingRepository, times(1)).findById(1L); + verify(meetingRepository, times(1)).findByMeetingUuid(anyString()); } - @Test - @DisplayName("모임 수정 테스트 - 성공") - void testUpdateMeeting_Success() { - - // given - Meeting meeting = Meeting.builder() - .meetingName("DND 7조 회의") - .meetingStartDate(LocalDate.of(2024, 7, 27)) - .meetingEndDate(LocalDate.of(2024, 7, 29)) - .numberOfPeople(6) - .isOnline(true) - .isAnonymous(false) - .voteEndDate(LocalDateTime.of(2024, 7, 26, 23, 59, 59)) - .build(); - - ReflectionTestUtils.setField(meeting, "meetingId", 1L); - - when(meetingRepository.findById(1L)).thenReturn(Optional.of(meeting)); - - Category hobby = Category.builder() - .categoryName("취미") - .build(); - - when(categoryRepository.findById(1L)).thenReturn(Optional.of(hobby)); - - // when - meetingService.updateMeeting(1L, MeetingDummy.updateRequestDto(1L)); - - // then - verify(meetingRepository, times(1)).findById(1L); - verify(categoryRepository, times(1)).findById(1L); - verify(meetingCategoryRepository, times(1)).deleteByMeetingId(1L); - verify(meetingCategoryRepository, times(1)).save(any()); - } - - @Test - @DisplayName("모임 수정 테스트 - 실패 (모임 없음)") - void testUpdateMeeting_Fail() { - - // given - MeetingUpdateRequestDto meetingUpdateRequestDto = MeetingDummy.updateRequestDto(1L); - when(meetingRepository.findById(anyLong())).thenReturn(Optional.empty()); - - // expected - assertThrows(MeetingNotFoundException.class, - () -> meetingService.updateMeeting(1L, meetingUpdateRequestDto)); - } @Test @DisplayName("모임 삭제 테스트 - 성공") void testDeleteMeeting_Success() { // given - when(meetingRepository.existsById(anyLong())).thenReturn(true); + + Meeting meeting = Meeting.builder() + .meetingLeaderId(1L) + .build(); + + when(meetingRepository.findById(anyLong())).thenReturn(Optional.of(meeting)); // when - meetingService.deleteMeeting(1L); + meetingService.deleteMeeting(1L, 1L); // then - verify(meetingRepository, times(1)).existsById(1L); verify(meetingRepository, times(1)).deleteById(1L); + verify(meetingCategoryRepository, times(1)).deleteByMeetingId(1L); } @Test @DisplayName("모임 삭제 테스트 - 실패 (존재하지 않는 모임)") void testDeleteMeeting_Fail() { - // given - when(meetingRepository.existsById(anyLong())).thenReturn(false); // expected assertThrows(MeetingNotFoundException.class, - () -> meetingService.deleteMeeting(1L)); + () -> meetingService.deleteMeeting(1L, 1L)); } } \ No newline at end of file diff --git a/src/test/java/com/dnd/jjakkak/domain/meetingcategory/repository/MeetingCategoryRepositoryTest.java b/src/test/java/com/dnd/jjakkak/domain/meetingcategory/repository/MeetingCategoryRepositoryTest.java index 92db3cd..ad45776 100644 --- a/src/test/java/com/dnd/jjakkak/domain/meetingcategory/repository/MeetingCategoryRepositoryTest.java +++ b/src/test/java/com/dnd/jjakkak/domain/meetingcategory/repository/MeetingCategoryRepositoryTest.java @@ -45,9 +45,10 @@ void testDeleteByMeetingId() { .meetingStartDate(LocalDate.of(2024, 7, 27)) .meetingEndDate(LocalDate.of(2024, 7, 29)) .numberOfPeople(6) - .isOnline(true) .isAnonymous(false) .voteEndDate(LocalDateTime.of(2024, 7, 26, 23, 59, 59)) + .meetingLeaderId(1L) + .meetingUuid("12345678") .build(); entityManager.persist(meeting); diff --git a/src/test/java/com/dnd/jjakkak/domain/member/controller/AuthControllerTest.java b/src/test/java/com/dnd/jjakkak/domain/member/controller/AuthControllerTest.java index 0f14614..c168470 100644 --- a/src/test/java/com/dnd/jjakkak/domain/member/controller/AuthControllerTest.java +++ b/src/test/java/com/dnd/jjakkak/domain/member/controller/AuthControllerTest.java @@ -1,141 +1,71 @@ package com.dnd.jjakkak.domain.member.controller; -import com.dnd.jjakkak.domain.member.entity.RefreshToken; -import com.dnd.jjakkak.domain.member.repository.BlacklistedTokenRepository; -import com.dnd.jjakkak.domain.member.repository.RefreshTokenRepository; -import com.dnd.jjakkak.domain.member.service.BlacklistService; -import com.dnd.jjakkak.domain.member.service.RefreshTokenService; -import jakarta.persistence.EntityManager; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Disabled; +import com.dnd.jjakkak.config.AbstractRestDocsTest; +import com.dnd.jjakkak.config.JjakkakMockUser; +import com.dnd.jjakkak.domain.jwt.filter.JwtAuthenticationFilter; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; -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.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** * AuthController 테스트 클래스입니다. * * @author 류태웅 - * @version 2024. 07. 27. + * @version 2024. 08. 06 */ -@ActiveProfiles("test") -@SpringBootTest +@WebMvcTest(AuthController.class) @AutoConfigureRestDocs(uriHost = "43.202.65.170.nip.io", uriPort = 80) -@AutoConfigureMockMvc -class AuthControllerTest { +class AuthControllerTest extends AbstractRestDocsTest { - @Autowired - MockMvc mockMvc; - - @Autowired - RefreshTokenService refreshTokenService; - - @Autowired - BlacklistService blacklistService; - - @Autowired - EntityManager entityManager; - - @Autowired - RefreshTokenRepository refreshTokenRepository; - - @Autowired - BlacklistedTokenRepository blacklistedTokenRepository; - - @AfterEach - void clear() { - refreshTokenRepository.deleteAll(); - blacklistedTokenRepository.deleteAll(); - } + @MockBean + JwtAuthenticationFilter jwtAuthenticationFilter; @Test - @Disabled - @DisplayName("로그아웃 테스트 - 성공") - void testLogoutSuccess() throws Exception { - - // given - RefreshToken token = new RefreshToken("valid_refresh_token", 1L); - refreshTokenRepository.save(token); - - String refreshToken = "Bearer valid_refresh_token"; - - // expected - mockMvc.perform(post("/api/v1/logout") - .header("Authorization", refreshToken)) - .andExpectAll( - status().isOk(), - content().string("Logout successful") - ) - .andDo(document("auth/logout/success", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - responseFields( - fieldWithPath("status").description("상태 코드"), - fieldWithPath("message").description("응답 메시지") - ) - )); + @DisplayName("로그인 상태 확인 - 확인됨") + @JjakkakMockUser + void testCheckAuthAuthenticated() throws Exception { + + // JwtProvider의 validate 메소드가 "user"를 반환하도록 설정 + when(jwtProvider.validate(anyString())).thenReturn("user"); + + mockMvc.perform(get("/api/v1/check-auth") + .header("Authorization", "Bearer valid-token") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isAuthenticated").value(true)); } @Test - @Disabled - @DisplayName("로그아웃 테스트 - 실패 (유효하지 않은 토큰)") - void testLogoutFailInvalidToken() throws Exception { - - // given - String refreshToken = "Bearer invalid_refresh_token"; - - // expected - mockMvc.perform(post("/api/v1/logout") - .header("Authorization", refreshToken)) - .andExpectAll( - status().isUnauthorized(), - jsonPath("$.code").value(401), - jsonPath("$.message").value("Invalid Refresh Token") - ) - .andDo(document("auth/logout/fail-invalid-token", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - responseFields( - fieldWithPath("code").description("상태 코드"), - fieldWithPath("message").description("응답 메시지"), - fieldWithPath("validation").description("유효성 검사") - ) - )); + @DisplayName("로그인 상태 확인 - 토큰이 인증되질 않음") + @JjakkakMockUser + void testCheckAuthNotAuthenticated() throws Exception { + + // JwtProvider의 validate 메소드가 null을 반환하도록 설정 + when(jwtProvider.validate(anyString())).thenReturn(null); + + mockMvc.perform(get("/api/v1/check-auth") + .header("Authorization", "Bearer invalid-token") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isAuthenticated").value(false)); } @Test - @DisplayName("로그아웃 테스트 - 실패 (헤더 없음)") - void testLogoutFailNoHeader() throws Exception { - - // expected - mockMvc.perform(post("/api/v1/logout") - .contentType(MediaType.APPLICATION_JSON)) - .andExpectAll( - status().isBadRequest(), - jsonPath("$.code").value(400), - jsonPath("$.message").value("Invalid Header Error") - ) - .andDo(document("auth/logout/fail-no-header", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - responseFields( - fieldWithPath("code").description("상태 코드"), - fieldWithPath("message").description("응답 메시지"), - fieldWithPath("validation").description("유효성 검사") - ) - )); + @DisplayName("로그인 상태 확인 - 토큰 없음") + @JjakkakMockUser + void testCheckAuthNoToken() throws Exception { + mockMvc.perform(get("/api/v1/check-auth") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isAuthenticated").value(false)); } } diff --git a/src/test/java/com/dnd/jjakkak/domain/member/controller/MemberControllerTest.java b/src/test/java/com/dnd/jjakkak/domain/member/controller/MemberControllerTest.java new file mode 100644 index 0000000..3b12344 --- /dev/null +++ b/src/test/java/com/dnd/jjakkak/domain/member/controller/MemberControllerTest.java @@ -0,0 +1,112 @@ +package com.dnd.jjakkak.domain.member.controller; + +import com.dnd.jjakkak.config.AbstractRestDocsTest; +import com.dnd.jjakkak.domain.meeting.dto.response.MeetingResponseDto; +import com.dnd.jjakkak.domain.meeting.entity.Meeting; +import com.dnd.jjakkak.domain.member.dto.request.MemberUpdateNicknameRequestDto; +import com.dnd.jjakkak.domain.member.dto.request.MemberUpdateProfileRequestDto; +import com.dnd.jjakkak.domain.member.service.MemberService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Collections; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * MemberController 테스트 클래스입니다. + * + * @author 류태웅 + * @version 2024. 08. 06 + */ +@WebMvcTest(MemberController.class) +@AutoConfigureRestDocs(uriHost = "43.202.65.170.nip.io", uriPort = 80) +class MemberControllerTest extends AbstractRestDocsTest { + + @MockBean + MemberService memberService; + + @Test + @WithMockUser + @DisplayName("회원 모임 리스트 조회") + void testGetMemberListByMemberId() throws Exception { + Meeting meeting = Meeting.builder() + .meetingName("Test Meeting") + .meetingStartDate(LocalDate.now()) + .meetingEndDate(LocalDate.now().plusDays(1)) + .numberOfPeople(10) + .isAnonymous(false) + .voteEndDate(LocalDateTime.now().plusDays(1)) + .meetingLeaderId(1L) + .meetingUuid("uuid") + .build(); + + MeetingResponseDto meetingResponseDto = MeetingResponseDto.builder() + .meeting(meeting) + .build(); + + when(memberService.getMeetingListByMemberId(anyLong())).thenReturn(Collections.singletonList(meetingResponseDto)); + + mockMvc.perform(get("/api/v1/member/{memberId}/meetingList", 1L) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].meetingName").value("Test Meeting")) + .andExpect(jsonPath("$[0].meetingStartDate").exists()) + .andExpect(jsonPath("$[0].meetingEndDate").exists()) + .andExpect(jsonPath("$[0].numberOfPeople").value(10)) + .andExpect(jsonPath("$[0].isAnonymous").value(false)) + .andExpect(jsonPath("$[0].voteEndDate").exists()) + .andExpect(jsonPath("$[0].meetingLeaderId").value(1L)) + .andExpect(jsonPath("$[0].meetingUuid").value("uuid")); + } + + @Test + @WithMockUser + @DisplayName("회원 닉네임 업데이트") + void testUpdateNickname() throws Exception { + doNothing().when(memberService).updateNickname(anyLong(), any(MemberUpdateNicknameRequestDto.class)); + + mockMvc.perform(patch("/api/v1/member/{memberId}/nickname", 9L) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"memberNickname\": \"newName\"}") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser + @DisplayName("회원 프로필 업데이트") + void testUpdateProfile() throws Exception { + doNothing().when(memberService).updateProfile(anyLong(), any(MemberUpdateProfileRequestDto.class)); + + mockMvc.perform(patch("/api/v1/member/{memberId}/profile", 9L) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"memberProfile\": \"http://newProfile\"}") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser + @DisplayName("회원 삭제") + void testDeleteMember() throws Exception { + doNothing().when(memberService).deleteMember(anyLong()); + + mockMvc.perform(delete("/api/v1/member/{memberId}", 9L) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/com/dnd/jjakkak/domain/member/repository/BlacklistedTokenRepositoryTest.java b/src/test/java/com/dnd/jjakkak/domain/member/repository/BlacklistedTokenRepositoryTest.java index 5d4b02c..42466bd 100644 --- a/src/test/java/com/dnd/jjakkak/domain/member/repository/BlacklistedTokenRepositoryTest.java +++ b/src/test/java/com/dnd/jjakkak/domain/member/repository/BlacklistedTokenRepositoryTest.java @@ -30,7 +30,6 @@ void testSaveAndFindBlacklistedToken() { .token("test_token") .expirationDate(LocalDateTime.now().plusDays(1)) .build(); - blacklistedTokenRepository.save(token); // when diff --git a/src/test/java/com/dnd/jjakkak/domain/member/service/BlacklistServiceTest.java b/src/test/java/com/dnd/jjakkak/domain/member/service/BlacklistServiceTest.java index 0007557..74b9798 100644 --- a/src/test/java/com/dnd/jjakkak/domain/member/service/BlacklistServiceTest.java +++ b/src/test/java/com/dnd/jjakkak/domain/member/service/BlacklistServiceTest.java @@ -39,7 +39,6 @@ void testIsTokenBlacklisted() { BlacklistedToken blacklistedToken = BlacklistedToken.builder() .token(token) .build(); - Mockito.when(blacklistedTokenRepository.findByToken(token)) .thenReturn(Optional.of(blacklistedToken));