From df16d2e5fb7ac4d93df96851136f3950aa99d5ab Mon Sep 17 00:00:00 2001 From: Dora Choo Date: Tue, 6 Aug 2024 18:22:08 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#177)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: password 일방향 암호화 기능 구현 * feat: cookie 생산-소비 기능 구현 * chore: jwt 관련 의존성 추가 * feat: 토큰 생성 기능 구현 * feat: 로그인 API 구현 * test: 로그인 API 테스트 * feat: 회원가입 API 구현 * test: 회원가입 API 테스트 * feat: 닉네임 생성 기능 구현 * test: 닉네임 생성 기능 테스트 * fix: postconstruct 여러 개라 발생한 에러 해결 * feat: 회원가입 응답값에 랜덤생성한 닉네임 추가 * feat: MemberArgumentResolver 구현 * feat: MemberArgumentResolver 일부 적용 * test: 바뀐 스펙에 맞게 변경 * test: TestConfig 설정해 빈충돌 오류 해결 * test: 공모 작성 API로 MemberArgumentResolver 사용 * feat: 토큰 재발급 API 구현 * test: 토큰 재발급 API 테스트 * test: 토큰 재발급 API 에러 테스트 * feat: MemberArgumentResolver commant에 적용 * feat: MemberArgumentResolver offering에 적용 * feat: MemberArgumentResolver participant에 적용 * refactor: ci값이 일치하지 않을경우 오류메시지 문구 변경 * refactor: 클래스명 일관적으로 변경 * refactor: 직관적인 명명으로 enum 네이밍 변경 * refactor: Custom Exception 적용 * refactor: 컨트롤러 메서드에 접근제어자 명시 * fix: 중복된 enum 값 제거 * test: 바뀐 API 스펙에 맞게 변경 --------- Co-authored-by: fromitive --- backend/build.gradle | 2 + backend/http/auth.http | 23 ++ backend/http/comment.http | 2 +- backend/http/offering.http | 20 +- .../auth/config/AuthWebMvcConfig.java | 22 ++ .../auth/config/MemberArgumentResolver.java | 34 +++ .../auth/controller/AuthController.java | 51 ++++ .../auth/controller/CookieConsumer.java | 37 +++ .../auth/controller/CookieProducer.java | 28 ++ .../auth/exception/AuthErrorCode.java | 25 ++ .../chongdae/auth/service/AuthService.java | 55 ++++ .../auth/service/JwtTokenProvider.java | 80 ++++++ .../auth/service/PasswordEncoder.java | 6 + .../auth/service/SHA256PasswordEncoder.java | 25 ++ .../auth/service/dto/LoginRequest.java | 6 + .../auth/service/dto/RefreshRequest.java | 4 + .../auth/service/dto/SignupRequest.java | 6 + .../auth/service/dto/SignupResponse.java | 10 + .../chongdae/auth/service/dto/TokenDto.java | 4 + .../comment/controller/CommentController.java | 15 +- .../comment/service/CommentService.java | 25 +- .../service/dto/CommentSaveRequest.java | 3 - .../chongdae/global/config/ClockConfig.java | 15 ++ .../member/exception/MemberErrorCode.java | 5 +- .../member/repository/MemberRepository.java | 6 + .../repository/entity/MemberEntity.java | 7 +- .../member/service/NicknameGenerator.java | 35 +++ .../service/NicknameWordInitializer.java | 35 +++ .../member/service/NicknameWordPicker.java | 8 + .../member/service/NicknameWordReader.java | 26 ++ .../service/RandomNicknameWordPicker.java | 16 ++ .../controller/OfferingController.java | 19 +- .../offering/exception/OfferingErrorCode.java | 3 +- .../offering/service/OfferingService.java | 18 +- .../service/dto/OfferingSaveRequest.java | 5 +- .../controller/OfferingMemberController.java | 6 +- .../service/OfferingMemberService.java | 7 +- .../service/dto/ParticipationRequest.java | 3 - backend/src/main/resources/application.yml | 15 ++ backend/src/main/resources/data.sql | 16 +- .../resources/static/nickname/adjectives.txt | 1 + .../main/resources/static/nickname/nouns.txt | 1 + .../chongdae/ChongdaeApplicationTests.java | 13 - .../auth/integration/AuthIntegrationTest.java | 239 ++++++++++++++++++ .../integration/CommentIntegrationTest.java | 66 +---- .../global/config/TestClockConfig.java | 18 ++ .../chongdae/global/config/TestConfig.java | 11 + .../chongdae/global/domain/MemberFixture.java | 8 +- .../global/helper/CookieProvider.java | 29 +++ .../global/integration/IntegrationTest.java | 22 +- .../config/TestNicknameWordPickerConfig.java | 17 ++ .../service/FixedNicknameWordPicker.java | 14 + .../member/service/NicknameGeneratorTest.java | 29 +++ .../integration/OfferingIntegrationTest.java | 69 +---- .../OfferingMemberIntegrationTest.java | 27 +- .../src/test/resources/application-test.yml | 7 + .../resources/static/nickname/adjectives.txt | 1 + .../test/resources/static/nickname/nouns.txt | 1 + 58 files changed, 1069 insertions(+), 232 deletions(-) create mode 100644 backend/http/auth.http create mode 100644 backend/src/main/java/com/zzang/chongdae/auth/config/AuthWebMvcConfig.java create mode 100644 backend/src/main/java/com/zzang/chongdae/auth/config/MemberArgumentResolver.java create mode 100644 backend/src/main/java/com/zzang/chongdae/auth/controller/AuthController.java create mode 100644 backend/src/main/java/com/zzang/chongdae/auth/controller/CookieConsumer.java create mode 100644 backend/src/main/java/com/zzang/chongdae/auth/controller/CookieProducer.java create mode 100644 backend/src/main/java/com/zzang/chongdae/auth/exception/AuthErrorCode.java create mode 100644 backend/src/main/java/com/zzang/chongdae/auth/service/AuthService.java create mode 100644 backend/src/main/java/com/zzang/chongdae/auth/service/JwtTokenProvider.java create mode 100644 backend/src/main/java/com/zzang/chongdae/auth/service/PasswordEncoder.java create mode 100644 backend/src/main/java/com/zzang/chongdae/auth/service/SHA256PasswordEncoder.java create mode 100644 backend/src/main/java/com/zzang/chongdae/auth/service/dto/LoginRequest.java create mode 100644 backend/src/main/java/com/zzang/chongdae/auth/service/dto/RefreshRequest.java create mode 100644 backend/src/main/java/com/zzang/chongdae/auth/service/dto/SignupRequest.java create mode 100644 backend/src/main/java/com/zzang/chongdae/auth/service/dto/SignupResponse.java create mode 100644 backend/src/main/java/com/zzang/chongdae/auth/service/dto/TokenDto.java create mode 100644 backend/src/main/java/com/zzang/chongdae/global/config/ClockConfig.java create mode 100644 backend/src/main/java/com/zzang/chongdae/member/service/NicknameGenerator.java create mode 100644 backend/src/main/java/com/zzang/chongdae/member/service/NicknameWordInitializer.java create mode 100644 backend/src/main/java/com/zzang/chongdae/member/service/NicknameWordPicker.java create mode 100644 backend/src/main/java/com/zzang/chongdae/member/service/NicknameWordReader.java create mode 100644 backend/src/main/java/com/zzang/chongdae/member/service/RandomNicknameWordPicker.java create mode 100644 backend/src/main/resources/static/nickname/adjectives.txt create mode 100644 backend/src/main/resources/static/nickname/nouns.txt delete mode 100644 backend/src/test/java/com/zzang/chongdae/ChongdaeApplicationTests.java create mode 100644 backend/src/test/java/com/zzang/chongdae/auth/integration/AuthIntegrationTest.java create mode 100644 backend/src/test/java/com/zzang/chongdae/global/config/TestClockConfig.java create mode 100644 backend/src/test/java/com/zzang/chongdae/global/config/TestConfig.java create mode 100644 backend/src/test/java/com/zzang/chongdae/global/helper/CookieProvider.java create mode 100644 backend/src/test/java/com/zzang/chongdae/member/config/TestNicknameWordPickerConfig.java create mode 100644 backend/src/test/java/com/zzang/chongdae/member/service/FixedNicknameWordPicker.java create mode 100644 backend/src/test/java/com/zzang/chongdae/member/service/NicknameGeneratorTest.java create mode 100644 backend/src/test/resources/static/nickname/adjectives.txt create mode 100644 backend/src/test/resources/static/nickname/nouns.txt diff --git a/backend/build.gradle b/backend/build.gradle index bc49055e5..792fd55a6 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -31,6 +31,8 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' implementation 'com.amazonaws:aws-java-sdk-s3:1.12.765' implementation 'org.jsoup:jsoup:1.18.1' + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'javax.xml.bind:jaxb-api:2.3.1' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/backend/http/auth.http b/backend/http/auth.http new file mode 100644 index 000000000..dcf1b5003 --- /dev/null +++ b/backend/http/auth.http @@ -0,0 +1,23 @@ +### 로그인 API +POST {{base-url}}/auth/login +Content-Type: application/json + +{ + "ci": "dora1234" +} + +### 회원가입 API +POST {{base-url}}/auth/signup +Content-Type: application/json + +{ + "ci": "poke12345678" +} + +### 토큰 재발급 API +POST {{base-url}}/auth/refresh +Content-Type: application/json + +{ + "refreshToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzI0MTMwODY0fQ.BoN2p--HbWn1zbed2NSZtZm4RumWtVm338sAtxXwEC8" +} diff --git a/backend/http/comment.http b/backend/http/comment.http index fb8ce6147..58531f284 100644 --- a/backend/http/comment.http +++ b/backend/http/comment.http @@ -1,2 +1,2 @@ ### 댓글방 목록 조회 API -GET {{base-url}}/comments?member-id=1 +GET {{base-url}}/comments diff --git a/backend/http/offering.http b/backend/http/offering.http index 3071b9fb9..d10292837 100644 --- a/backend/http/offering.http +++ b/backend/http/offering.http @@ -1,8 +1,26 @@ ### 공모 상세 조회 API -GET {{base-url}}/offerings/1?member-id=1 +GET {{base-url}}/offerings/1 ### 공모 목록 조회 API GET {{base-url}}/offerings?filter=RECENT&search=&last-id=99&page-size=2 ### 공모 필터 목록 조회 API GET {{base-url}}/offerings/filters + +### 공모 작성 API +POST {{base-url}}/offerings +Content-Type: application/json + +{ + "title": "공모 제목", + "productUrl": "www.naver.com", + "thumbnailUrl": "www.naver.com/favicon.ico", + "totalCount": 5, + "totalPrice": 10000, + "eachPrice": 2000, + "meetingAddress": "서울특별시 광진구 구의강변로 3길 11", + "meetingAddressDetail": "상세주소아파트", + "meetingAddressDong": "구의동", + "deadline": "2024-10-11T10:00:00", + "description": "내용입니다." +} diff --git a/backend/src/main/java/com/zzang/chongdae/auth/config/AuthWebMvcConfig.java b/backend/src/main/java/com/zzang/chongdae/auth/config/AuthWebMvcConfig.java new file mode 100644 index 000000000..e16e2d8c3 --- /dev/null +++ b/backend/src/main/java/com/zzang/chongdae/auth/config/AuthWebMvcConfig.java @@ -0,0 +1,22 @@ +package com.zzang.chongdae.auth.config; + +import com.zzang.chongdae.auth.controller.CookieConsumer; +import com.zzang.chongdae.auth.service.AuthService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@RequiredArgsConstructor +@Configuration +public class AuthWebMvcConfig implements WebMvcConfigurer { + + private final AuthService authService; + private final CookieConsumer cookieConsumer; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new MemberArgumentResolver(authService, cookieConsumer)); + } +} diff --git a/backend/src/main/java/com/zzang/chongdae/auth/config/MemberArgumentResolver.java b/backend/src/main/java/com/zzang/chongdae/auth/config/MemberArgumentResolver.java new file mode 100644 index 000000000..ba4ee282f --- /dev/null +++ b/backend/src/main/java/com/zzang/chongdae/auth/config/MemberArgumentResolver.java @@ -0,0 +1,34 @@ +package com.zzang.chongdae.auth.config; + +import com.zzang.chongdae.auth.controller.CookieConsumer; +import com.zzang.chongdae.auth.service.AuthService; +import com.zzang.chongdae.member.repository.entity.MemberEntity; +import jakarta.servlet.http.HttpServletRequest; +import lombok.AllArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@AllArgsConstructor +public class MemberArgumentResolver implements HandlerMethodArgumentResolver { + + private final AuthService authService; + private final CookieConsumer cookieConsumer; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(MemberEntity.class); + } + + @Override + public MemberEntity resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + String token = cookieConsumer.getAccessToken(request.getCookies()); + return authService.findMemberByAccessToken(token); + } +} diff --git a/backend/src/main/java/com/zzang/chongdae/auth/controller/AuthController.java b/backend/src/main/java/com/zzang/chongdae/auth/controller/AuthController.java new file mode 100644 index 000000000..91e18e84d --- /dev/null +++ b/backend/src/main/java/com/zzang/chongdae/auth/controller/AuthController.java @@ -0,0 +1,51 @@ +package com.zzang.chongdae.auth.controller; + +import com.zzang.chongdae.auth.service.AuthService; +import com.zzang.chongdae.auth.service.dto.LoginRequest; +import com.zzang.chongdae.auth.service.dto.RefreshRequest; +import com.zzang.chongdae.auth.service.dto.SignupRequest; +import com.zzang.chongdae.auth.service.dto.SignupResponse; +import com.zzang.chongdae.auth.service.dto.TokenDto; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +public class AuthController { + + private final AuthService authService; + private final CookieProducer cookieExtractor; + private final CookieConsumer cookieConsumer; + + @PostMapping("/auth/login") + public ResponseEntity login( + @RequestBody @Valid LoginRequest request, HttpServletResponse servletResponse) { + TokenDto tokenDto = authService.login(request); + List cookies = cookieExtractor.extractAuthCookies(tokenDto); + cookieConsumer.addCookies(servletResponse, cookies); + return ResponseEntity.ok().build(); + } + + @PostMapping("/auth/signup") + public ResponseEntity signup( + @RequestBody SignupRequest request) { + SignupResponse response = authService.signup(request); + return ResponseEntity.ok(response); + } + + @PostMapping("/auth/refresh") + public ResponseEntity refresh( + @RequestBody RefreshRequest request, HttpServletResponse servletResponse) { + TokenDto tokenDto = authService.refresh(request); + List cookies = cookieExtractor.extractAuthCookies(tokenDto); + cookieConsumer.addCookies(servletResponse, cookies); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/zzang/chongdae/auth/controller/CookieConsumer.java b/backend/src/main/java/com/zzang/chongdae/auth/controller/CookieConsumer.java new file mode 100644 index 000000000..3debb9907 --- /dev/null +++ b/backend/src/main/java/com/zzang/chongdae/auth/controller/CookieConsumer.java @@ -0,0 +1,37 @@ +package com.zzang.chongdae.auth.controller; + +import com.zzang.chongdae.auth.exception.AuthErrorCode; +import com.zzang.chongdae.global.exception.MarketException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Arrays; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class CookieConsumer { + + private static final String ACCESS_TOKEN_COOKIE_NAME = "access_token"; + private static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token"; + + public void addCookies(HttpServletResponse servletResponse, List cookies) { + for (Cookie cookie : cookies) { + servletResponse.addCookie(cookie); + } + } + + public String getAccessToken(Cookie[] cookies) { + return getTokenByCookieName(ACCESS_TOKEN_COOKIE_NAME, cookies); + } + + private String getTokenByCookieName(String cookieName, Cookie[] cookies) { + if (cookies == null) { + throw new MarketException(AuthErrorCode.INVALID_TOKEN); + } + return Arrays.stream(cookies) + .filter(cookie -> cookie.getName().equals(cookieName)) + .findFirst() + .map(Cookie::getValue) + .orElseThrow(() -> new MarketException(AuthErrorCode.INVALID_TOKEN)); + } +} diff --git a/backend/src/main/java/com/zzang/chongdae/auth/controller/CookieProducer.java b/backend/src/main/java/com/zzang/chongdae/auth/controller/CookieProducer.java new file mode 100644 index 000000000..0b9517766 --- /dev/null +++ b/backend/src/main/java/com/zzang/chongdae/auth/controller/CookieProducer.java @@ -0,0 +1,28 @@ +package com.zzang.chongdae.auth.controller; + +import com.zzang.chongdae.auth.service.dto.TokenDto; +import jakarta.servlet.http.Cookie; +import java.util.ArrayList; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class CookieProducer { + + private static final String ACCESS_TOKEN_COOKIE_NAME = "access_token"; + private static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token"; + + public List extractAuthCookies(TokenDto tokenDto) { + List cookies = new ArrayList<>(); + cookies.add(createCookie(ACCESS_TOKEN_COOKIE_NAME, tokenDto.accessToken())); + cookies.add(createCookie(REFRESH_TOKEN_COOKIE_NAME, tokenDto.refreshToken())); + return cookies; + } + + private Cookie createCookie(String tokenName, String token) { + Cookie cookie = new Cookie(tokenName, token); + cookie.setHttpOnly(true); + cookie.setPath("/"); + return cookie; + } +} diff --git a/backend/src/main/java/com/zzang/chongdae/auth/exception/AuthErrorCode.java b/backend/src/main/java/com/zzang/chongdae/auth/exception/AuthErrorCode.java new file mode 100644 index 000000000..b0472cfdd --- /dev/null +++ b/backend/src/main/java/com/zzang/chongdae/auth/exception/AuthErrorCode.java @@ -0,0 +1,25 @@ +package com.zzang.chongdae.auth.exception; + +import com.zzang.chongdae.global.exception.ErrorMessage; +import com.zzang.chongdae.global.exception.ErrorResponse; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum AuthErrorCode implements ErrorResponse { + + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), + INVALID_PASSWORD(HttpStatus.NOT_FOUND, "가입하지 않은 회원입니다."), + DUPLICATED_MEMBER(HttpStatus.CONFLICT, "이미 가입한 회원입니다."); + + private final HttpStatus status; + private final String message; + + @Override + public ErrorMessage getErrorMessage() { + return new ErrorMessage(this.message); + } +} diff --git a/backend/src/main/java/com/zzang/chongdae/auth/service/AuthService.java b/backend/src/main/java/com/zzang/chongdae/auth/service/AuthService.java new file mode 100644 index 000000000..5f39bfabc --- /dev/null +++ b/backend/src/main/java/com/zzang/chongdae/auth/service/AuthService.java @@ -0,0 +1,55 @@ +package com.zzang.chongdae.auth.service; + +import com.zzang.chongdae.auth.exception.AuthErrorCode; +import com.zzang.chongdae.auth.service.dto.LoginRequest; +import com.zzang.chongdae.auth.service.dto.RefreshRequest; +import com.zzang.chongdae.auth.service.dto.SignupRequest; +import com.zzang.chongdae.auth.service.dto.SignupResponse; +import com.zzang.chongdae.auth.service.dto.TokenDto; +import com.zzang.chongdae.global.exception.MarketException; +import com.zzang.chongdae.member.exception.MemberErrorCode; +import com.zzang.chongdae.member.repository.MemberRepository; +import com.zzang.chongdae.member.repository.entity.MemberEntity; +import com.zzang.chongdae.member.service.NicknameGenerator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class AuthService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + private final NicknameGenerator nickNameGenerator; + + public TokenDto login(LoginRequest request) { + String password = passwordEncoder.encode(request.ci()); + MemberEntity member = memberRepository.findByPassword(password) + .orElseThrow(() -> new MarketException(AuthErrorCode.INVALID_PASSWORD)); + return jwtTokenProvider.createAuthToken(member.getId().toString()); + } + + @Transactional + public SignupResponse signup(SignupRequest request) { + String password = passwordEncoder.encode(request.ci()); + if (memberRepository.existsByPassword(password)) { + throw new MarketException(AuthErrorCode.DUPLICATED_MEMBER); + } + MemberEntity member = new MemberEntity(nickNameGenerator.generate(), password); + MemberEntity savedMember = memberRepository.save(member); + return new SignupResponse(savedMember); + } + + public TokenDto refresh(RefreshRequest request) { + Long memberId = jwtTokenProvider.getMemberIdByRefreshToken(request.refreshToken()); + return jwtTokenProvider.createAuthToken(memberId.toString()); + } + + public MemberEntity findMemberByAccessToken(String token) { + Long memberId = jwtTokenProvider.getMemberIdByAccessToken(token); + return memberRepository.findById(memberId) + .orElseThrow(() -> new MarketException(MemberErrorCode.NOT_FOUND)); + } +} diff --git a/backend/src/main/java/com/zzang/chongdae/auth/service/JwtTokenProvider.java b/backend/src/main/java/com/zzang/chongdae/auth/service/JwtTokenProvider.java new file mode 100644 index 000000000..0acc746ce --- /dev/null +++ b/backend/src/main/java/com/zzang/chongdae/auth/service/JwtTokenProvider.java @@ -0,0 +1,80 @@ +package com.zzang.chongdae.auth.service; + +import com.zzang.chongdae.auth.exception.AuthErrorCode; +import com.zzang.chongdae.auth.service.dto.TokenDto; +import com.zzang.chongdae.global.exception.MarketException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.time.Clock; +import java.time.Duration; +import java.util.Base64; +import java.util.Date; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class JwtTokenProvider { + + private final String accessSecretKey; + private final String refreshSecretKey; + private final Duration accessTokenExpired; + private final Duration refreshTokenExpired; + private final Clock clock; + + public JwtTokenProvider(@Value("${security.jwt.token.access-secret-key}") String accessSecretKey, + @Value("${security.jwt.token.refresh-secret-key}") String refreshSecretKey, + @Value("${security.jwt.token.access-token-expired}") Duration accessTokenExpired, + @Value("${security.jwt.token.refresh-token-expired}") Duration refreshTokenExpired, + Clock clock) { + this.accessSecretKey = Base64.getEncoder().encodeToString(accessSecretKey.getBytes()); + this.refreshSecretKey = Base64.getEncoder().encodeToString(refreshSecretKey.getBytes()); + this.accessTokenExpired = accessTokenExpired; + this.refreshTokenExpired = refreshTokenExpired; + this.clock = clock; + } + + public TokenDto createAuthToken(String payload) { + return new TokenDto(createToken(payload, accessSecretKey, accessTokenExpired), + createToken(payload, refreshSecretKey, refreshTokenExpired)); + } + + private String createToken(String payload, String secretKey, Duration expired) { + return Jwts.builder() + .setSubject(payload) + .setExpiration(calculateExpiredAt(expired)) + .signWith(SignatureAlgorithm.HS256, secretKey) + .compact(); + } + + private Date calculateExpiredAt(Duration expired) { + Date now = Date.from(clock.instant()); + return new Date(now.getTime() + expired.toMillis()); + } + + public Long getMemberIdByAccessToken(String token) { + String memberId = getClaims(token, accessSecretKey).getSubject(); + return Long.valueOf(memberId); + } + + public Long getMemberIdByRefreshToken(String token) { + String memberId = getClaims(token, refreshSecretKey).getSubject(); + return Long.valueOf(memberId); + } + + private Claims getClaims(String token, String key) { + try { + return Jwts.parser() + .setSigningKey(key) + .setClock(() -> Date.from(clock.instant())) + .parseClaimsJws(token) + .getBody(); + } catch (ExpiredJwtException e) { + throw new MarketException(AuthErrorCode.EXPIRED_TOKEN); + } catch (JwtException | IllegalArgumentException e) { + throw new MarketException(AuthErrorCode.INVALID_TOKEN); + } + } +} diff --git a/backend/src/main/java/com/zzang/chongdae/auth/service/PasswordEncoder.java b/backend/src/main/java/com/zzang/chongdae/auth/service/PasswordEncoder.java new file mode 100644 index 000000000..18817e365 --- /dev/null +++ b/backend/src/main/java/com/zzang/chongdae/auth/service/PasswordEncoder.java @@ -0,0 +1,6 @@ +package com.zzang.chongdae.auth.service; + +public interface PasswordEncoder { + + String encode(String rawPassword); +} diff --git a/backend/src/main/java/com/zzang/chongdae/auth/service/SHA256PasswordEncoder.java b/backend/src/main/java/com/zzang/chongdae/auth/service/SHA256PasswordEncoder.java new file mode 100644 index 000000000..9b412a32d --- /dev/null +++ b/backend/src/main/java/com/zzang/chongdae/auth/service/SHA256PasswordEncoder.java @@ -0,0 +1,25 @@ +package com.zzang.chongdae.auth.service; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import org.springframework.stereotype.Component; + +@Component +public class SHA256PasswordEncoder implements PasswordEncoder { + + @Override + public String encode(String rawPassword) { + return hashPassword(rawPassword); + } + + private String hashPassword(String password) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] hashedPassword = md.digest(password.getBytes()); + return Base64.getEncoder().encodeToString(hashedPassword); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(); + } + } +} diff --git a/backend/src/main/java/com/zzang/chongdae/auth/service/dto/LoginRequest.java b/backend/src/main/java/com/zzang/chongdae/auth/service/dto/LoginRequest.java new file mode 100644 index 000000000..b48937ab0 --- /dev/null +++ b/backend/src/main/java/com/zzang/chongdae/auth/service/dto/LoginRequest.java @@ -0,0 +1,6 @@ +package com.zzang.chongdae.auth.service.dto; + +import jakarta.validation.constraints.NotNull; + +public record LoginRequest(@NotNull String ci) { +} diff --git a/backend/src/main/java/com/zzang/chongdae/auth/service/dto/RefreshRequest.java b/backend/src/main/java/com/zzang/chongdae/auth/service/dto/RefreshRequest.java new file mode 100644 index 000000000..1b7f64356 --- /dev/null +++ b/backend/src/main/java/com/zzang/chongdae/auth/service/dto/RefreshRequest.java @@ -0,0 +1,4 @@ +package com.zzang.chongdae.auth.service.dto; + +public record RefreshRequest(String refreshToken) { +} diff --git a/backend/src/main/java/com/zzang/chongdae/auth/service/dto/SignupRequest.java b/backend/src/main/java/com/zzang/chongdae/auth/service/dto/SignupRequest.java new file mode 100644 index 000000000..e4b03e1fa --- /dev/null +++ b/backend/src/main/java/com/zzang/chongdae/auth/service/dto/SignupRequest.java @@ -0,0 +1,6 @@ +package com.zzang.chongdae.auth.service.dto; + +import jakarta.validation.constraints.NotNull; + +public record SignupRequest(@NotNull String ci) { +} diff --git a/backend/src/main/java/com/zzang/chongdae/auth/service/dto/SignupResponse.java b/backend/src/main/java/com/zzang/chongdae/auth/service/dto/SignupResponse.java new file mode 100644 index 000000000..69637e901 --- /dev/null +++ b/backend/src/main/java/com/zzang/chongdae/auth/service/dto/SignupResponse.java @@ -0,0 +1,10 @@ +package com.zzang.chongdae.auth.service.dto; + +import com.zzang.chongdae.member.repository.entity.MemberEntity; + +public record SignupResponse(Long memberId, String nickname) { + + public SignupResponse(MemberEntity member) { + this(member.getId(), member.getNickname()); + } +} diff --git a/backend/src/main/java/com/zzang/chongdae/auth/service/dto/TokenDto.java b/backend/src/main/java/com/zzang/chongdae/auth/service/dto/TokenDto.java new file mode 100644 index 000000000..6018ce119 --- /dev/null +++ b/backend/src/main/java/com/zzang/chongdae/auth/service/dto/TokenDto.java @@ -0,0 +1,4 @@ +package com.zzang.chongdae.auth.service.dto; + +public record TokenDto(String accessToken, String refreshToken) { +} diff --git a/backend/src/main/java/com/zzang/chongdae/comment/controller/CommentController.java b/backend/src/main/java/com/zzang/chongdae/comment/controller/CommentController.java index 7a3cfbab2..74bf024b6 100644 --- a/backend/src/main/java/com/zzang/chongdae/comment/controller/CommentController.java +++ b/backend/src/main/java/com/zzang/chongdae/comment/controller/CommentController.java @@ -4,6 +4,7 @@ import com.zzang.chongdae.comment.service.dto.CommentAllResponse; import com.zzang.chongdae.comment.service.dto.CommentRoomAllResponse; import com.zzang.chongdae.comment.service.dto.CommentSaveRequest; +import com.zzang.chongdae.member.repository.entity.MemberEntity; import jakarta.validation.Valid; import java.net.URI; import lombok.RequiredArgsConstructor; @@ -12,7 +13,6 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor @@ -23,23 +23,24 @@ public class CommentController { @PostMapping("/comments") public ResponseEntity saveComment( - @RequestBody @Valid CommentSaveRequest request) { - Long commentId = commentService.saveComment(request); + @RequestBody @Valid CommentSaveRequest request, + MemberEntity member) { + Long commentId = commentService.saveComment(request, member); return ResponseEntity.created(URI.create("/comments/" + commentId)).build(); } @GetMapping("/comments") public ResponseEntity getAllCommentRoom( - @RequestParam(value = "member-id") Long loginMemberId) { - CommentRoomAllResponse response = commentService.getAllCommentRoom(loginMemberId); + MemberEntity member) { + CommentRoomAllResponse response = commentService.getAllCommentRoom(member); return ResponseEntity.ok(response); } @GetMapping("/comments/{offering-id}") public ResponseEntity getAllComment( @PathVariable(value = "offering-id") Long offeringId, - @RequestParam(value = "member-id") Long loginMemberId) { - CommentAllResponse response = commentService.getAllComment(offeringId, loginMemberId); + MemberEntity member) { + CommentAllResponse response = commentService.getAllComment(offeringId, member); return ResponseEntity.ok(response); } } diff --git a/backend/src/main/java/com/zzang/chongdae/comment/service/CommentService.java b/backend/src/main/java/com/zzang/chongdae/comment/service/CommentService.java index 1cbd9a299..f61e44d6e 100644 --- a/backend/src/main/java/com/zzang/chongdae/comment/service/CommentService.java +++ b/backend/src/main/java/com/zzang/chongdae/comment/service/CommentService.java @@ -11,7 +11,6 @@ import com.zzang.chongdae.comment.service.dto.CommentRoomAllResponseItem; import com.zzang.chongdae.comment.service.dto.CommentSaveRequest; import com.zzang.chongdae.global.exception.MarketException; -import com.zzang.chongdae.member.exception.MemberErrorCode; import com.zzang.chongdae.member.repository.MemberRepository; import com.zzang.chongdae.member.repository.entity.MemberEntity; import com.zzang.chongdae.offering.domain.OfferingWithRole; @@ -32,22 +31,17 @@ public class CommentService { private final MemberRepository memberRepository; private final OfferingRepository offeringRepository; - public Long saveComment(CommentSaveRequest request) { - MemberEntity loginMember = memberRepository.findById(request.memberId()) - .orElseThrow(() -> new MarketException(MemberErrorCode.NOT_FOUND)); + public Long saveComment(CommentSaveRequest request, MemberEntity member) { OfferingEntity offering = offeringRepository.findById(request.offeringId()) .orElseThrow(() -> new MarketException(OfferingErrorCode.NOT_FOUND)); - CommentEntity comment = new CommentEntity(loginMember, offering, request.content()); + CommentEntity comment = new CommentEntity(member, offering, request.content()); CommentEntity savedComment = commentRepository.save(comment); return savedComment.getId(); } - public CommentRoomAllResponse getAllCommentRoom(Long loginMemberId) { - MemberEntity loginMember = memberRepository.findById(loginMemberId) - .orElseThrow(() -> new MarketException(MemberErrorCode.NOT_FOUND)); - - List offeringsWithRole = offeringRepository.findAllWithRoleByMember(loginMember); + public CommentRoomAllResponse getAllCommentRoom(MemberEntity member) { + List offeringsWithRole = offeringRepository.findAllWithRoleByMember(member); List responseItems = offeringsWithRole.stream() .filter(offeringWithRole -> offeringWithRole.getOffering().hasParticipant()) .map(this::toCommentRoomAllResponseItem) @@ -72,24 +66,17 @@ private CommentLatestResponse toCommentLatestResponse(OfferingEntity offering) { .orElseGet(() -> new CommentLatestResponse(null, null)); } - public CommentAllResponse getAllComment(Long offeringId, Long loginMemberId) { - validateMemberExistence(loginMemberId); + public CommentAllResponse getAllComment(Long offeringId, MemberEntity member) { OfferingEntity offering = offeringRepository.findById(offeringId) .orElseThrow(() -> new MarketException(OfferingErrorCode.NOT_FOUND)); List commentsWithRole = commentRepository.findAllWithRoleByOffering(offering); List responseItems = commentsWithRole.stream() - .map(commentWithRole -> toCommentAllResponseItem(commentWithRole, loginMemberId)) + .map(commentWithRole -> toCommentAllResponseItem(commentWithRole, member.getId())) .toList(); return new CommentAllResponse(responseItems); } - private void validateMemberExistence(Long memberId) { - if (!memberRepository.existsById(memberId)) { - throw new MarketException(MemberErrorCode.NOT_FOUND); - } - } - private CommentAllResponseItem toCommentAllResponseItem(CommentWithRole commentWithRole, long loginMemberId) { CommentEntity comment = commentWithRole.getComment(); OfferingMemberRole role = commentWithRole.getRole(); diff --git a/backend/src/main/java/com/zzang/chongdae/comment/service/dto/CommentSaveRequest.java b/backend/src/main/java/com/zzang/chongdae/comment/service/dto/CommentSaveRequest.java index 48af53c2d..f18abffd2 100644 --- a/backend/src/main/java/com/zzang/chongdae/comment/service/dto/CommentSaveRequest.java +++ b/backend/src/main/java/com/zzang/chongdae/comment/service/dto/CommentSaveRequest.java @@ -5,9 +5,6 @@ import jakarta.validation.constraints.Size; public record CommentSaveRequest(@NotNull - Long memberId, - - @NotNull Long offeringId, @NotBlank(message = "댓글 내용을 입력해주세요.") diff --git a/backend/src/main/java/com/zzang/chongdae/global/config/ClockConfig.java b/backend/src/main/java/com/zzang/chongdae/global/config/ClockConfig.java new file mode 100644 index 000000000..eda3b7a30 --- /dev/null +++ b/backend/src/main/java/com/zzang/chongdae/global/config/ClockConfig.java @@ -0,0 +1,15 @@ +package com.zzang.chongdae.global.config; + +import java.time.Clock; +import java.time.ZoneId; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ClockConfig { + + @Bean + Clock clock() { + return Clock.system(ZoneId.of("Asia/Seoul")); + } +} diff --git a/backend/src/main/java/com/zzang/chongdae/member/exception/MemberErrorCode.java b/backend/src/main/java/com/zzang/chongdae/member/exception/MemberErrorCode.java index b972a8f32..e9d247f62 100644 --- a/backend/src/main/java/com/zzang/chongdae/member/exception/MemberErrorCode.java +++ b/backend/src/main/java/com/zzang/chongdae/member/exception/MemberErrorCode.java @@ -1,6 +1,7 @@ package com.zzang.chongdae.member.exception; import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; import com.zzang.chongdae.global.exception.ErrorMessage; import com.zzang.chongdae.global.exception.ErrorResponse; @@ -12,7 +13,9 @@ @AllArgsConstructor public enum MemberErrorCode implements ErrorResponse { - NOT_FOUND(BAD_REQUEST, "해당 사용자가 존재하지 않습니다."); + NOT_FOUND(BAD_REQUEST, "해당 사용자가 존재하지 않습니다."), + MAX_TRY_EXCEEDED(INTERNAL_SERVER_ERROR, "닉네임 생성에 실패했습니다."), + NICK_NAME_READ_FAIL(INTERNAL_SERVER_ERROR, "닉네임 데이터 읽기를 실패했습닏."); private final HttpStatus status; private final String message; diff --git a/backend/src/main/java/com/zzang/chongdae/member/repository/MemberRepository.java b/backend/src/main/java/com/zzang/chongdae/member/repository/MemberRepository.java index 8d8bc238e..34afc9c85 100644 --- a/backend/src/main/java/com/zzang/chongdae/member/repository/MemberRepository.java +++ b/backend/src/main/java/com/zzang/chongdae/member/repository/MemberRepository.java @@ -1,7 +1,13 @@ package com.zzang.chongdae.member.repository; import com.zzang.chongdae.member.repository.entity.MemberEntity; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface MemberRepository extends JpaRepository { + Optional findByPassword(String password); + + boolean existsByPassword(String password); + + boolean existsByNickname(String nickname); } diff --git a/backend/src/main/java/com/zzang/chongdae/member/repository/entity/MemberEntity.java b/backend/src/main/java/com/zzang/chongdae/member/repository/entity/MemberEntity.java index a796e17f3..ff03b74ce 100644 --- a/backend/src/main/java/com/zzang/chongdae/member/repository/entity/MemberEntity.java +++ b/backend/src/main/java/com/zzang/chongdae/member/repository/entity/MemberEntity.java @@ -30,8 +30,11 @@ public class MemberEntity extends BaseTimeEntity { @Column(unique = true, length = 10) private String nickname; - public MemberEntity(String nickname) { - this(null, nickname); + @NotNull + private String password; + + public MemberEntity(String nickname, String password) { + this(null, nickname, password); } public boolean isSameMember(Long memberId) { diff --git a/backend/src/main/java/com/zzang/chongdae/member/service/NicknameGenerator.java b/backend/src/main/java/com/zzang/chongdae/member/service/NicknameGenerator.java new file mode 100644 index 000000000..59329bcff --- /dev/null +++ b/backend/src/main/java/com/zzang/chongdae/member/service/NicknameGenerator.java @@ -0,0 +1,35 @@ +package com.zzang.chongdae.member.service; + +import com.zzang.chongdae.global.exception.MarketException; +import com.zzang.chongdae.member.exception.MemberErrorCode; +import com.zzang.chongdae.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class NicknameGenerator { + + private static final int MAX_TRY_COUNT = 3; + + private final MemberRepository memberRepository; + private final NicknameWordInitializer nicknameWordInitializer; + + public String generate() { + int tryCount = 0; + String nickname = tryGenerate(tryCount); + while (memberRepository.existsByNickname(nickname)) { + nickname = tryGenerate(tryCount++); + } + return nickname; + } + + private String tryGenerate(int tryCount) { + if (tryCount == MAX_TRY_COUNT) { + throw new MarketException(MemberErrorCode.MAX_TRY_EXCEEDED); + } + String adjective = nicknameWordInitializer.pickAdjective(); + String noun = nicknameWordInitializer.pickNoun(); + return adjective + noun; + } +} diff --git a/backend/src/main/java/com/zzang/chongdae/member/service/NicknameWordInitializer.java b/backend/src/main/java/com/zzang/chongdae/member/service/NicknameWordInitializer.java new file mode 100644 index 000000000..a06682c81 --- /dev/null +++ b/backend/src/main/java/com/zzang/chongdae/member/service/NicknameWordInitializer.java @@ -0,0 +1,35 @@ +package com.zzang.chongdae.member.service; + +import jakarta.annotation.PostConstruct; +import java.util.List; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Getter +@RequiredArgsConstructor +@Component +public class NicknameWordInitializer { + + private static final String ADJECTIVE_FILE_PATH = "src/test/resources/static/nickname/adjectives.txt"; + private static final String NOUNS_FILE_PATH = "src/test/resources/static/nickname/nouns.txt"; + + private final NicknameWordPicker nicknameWordPicker; + private final NicknameWordReader nickNameWordReader; + private List adjectives; + private List nouns; + + @PostConstruct + public void init() { + adjectives = nickNameWordReader.read(ADJECTIVE_FILE_PATH); + nouns = nickNameWordReader.read(NOUNS_FILE_PATH); + } + + public String pickAdjective() { + return nicknameWordPicker.pick(adjectives); + } + + public String pickNoun() { + return nicknameWordPicker.pick(nouns); + } +} diff --git a/backend/src/main/java/com/zzang/chongdae/member/service/NicknameWordPicker.java b/backend/src/main/java/com/zzang/chongdae/member/service/NicknameWordPicker.java new file mode 100644 index 000000000..a937e5a67 --- /dev/null +++ b/backend/src/main/java/com/zzang/chongdae/member/service/NicknameWordPicker.java @@ -0,0 +1,8 @@ +package com.zzang.chongdae.member.service; + +import java.util.List; + +public interface NicknameWordPicker { + + String pick(List words); +} diff --git a/backend/src/main/java/com/zzang/chongdae/member/service/NicknameWordReader.java b/backend/src/main/java/com/zzang/chongdae/member/service/NicknameWordReader.java new file mode 100644 index 000000000..51605ca0d --- /dev/null +++ b/backend/src/main/java/com/zzang/chongdae/member/service/NicknameWordReader.java @@ -0,0 +1,26 @@ +package com.zzang.chongdae.member.service; + +import com.zzang.chongdae.global.exception.MarketException; +import com.zzang.chongdae.member.exception.MemberErrorCode; +import java.io.BufferedReader; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.Arrays; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class NicknameWordReader { + + public List read(String path) { + try (FileInputStream fileInputStream = new FileInputStream(path); + InputStreamReader inputStreamReader = new InputStreamReader(fileInputStream); + BufferedReader br = new BufferedReader(inputStreamReader)) { + String[] line = br.readLine().split(","); + return Arrays.asList(line); + } catch (IOException e) { + throw new MarketException(MemberErrorCode.NICK_NAME_READ_FAIL); + } + } +} diff --git a/backend/src/main/java/com/zzang/chongdae/member/service/RandomNicknameWordPicker.java b/backend/src/main/java/com/zzang/chongdae/member/service/RandomNicknameWordPicker.java new file mode 100644 index 000000000..04a1ddf74 --- /dev/null +++ b/backend/src/main/java/com/zzang/chongdae/member/service/RandomNicknameWordPicker.java @@ -0,0 +1,16 @@ +package com.zzang.chongdae.member.service; + +import java.util.List; +import java.util.Random; +import org.springframework.stereotype.Component; + +@Component +public class RandomNicknameWordPicker implements NicknameWordPicker { + + private final Random random = new Random(); + + @Override + public String pick(List words) { + return words.get(random.nextInt(words.size())); + } +} diff --git a/backend/src/main/java/com/zzang/chongdae/offering/controller/OfferingController.java b/backend/src/main/java/com/zzang/chongdae/offering/controller/OfferingController.java index b3265ba13..5a5367122 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/controller/OfferingController.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/controller/OfferingController.java @@ -1,6 +1,7 @@ package com.zzang.chongdae.offering.controller; import com.zzang.chongdae.comment.service.dto.CommentRoomStatusResponse; +import com.zzang.chongdae.member.repository.entity.MemberEntity; import com.zzang.chongdae.offering.service.OfferingService; import com.zzang.chongdae.offering.service.dto.OfferingAllResponse; import com.zzang.chongdae.offering.service.dto.OfferingDetailResponse; @@ -32,8 +33,8 @@ public class OfferingController { @GetMapping("/offerings/{offering-id}") public ResponseEntity getOfferingDetail( @PathVariable(value = "offering-id") Long offeringId, - @RequestParam(value = "member-id") Long memberId) { - OfferingDetailResponse response = offeringService.getOfferingDetail(offeringId, memberId); + MemberEntity member) { + OfferingDetailResponse response = offeringService.getOfferingDetail(offeringId, member); return ResponseEntity.ok(response); } @@ -55,15 +56,17 @@ public ResponseEntity getAllOfferingFilter() { @GetMapping("/offerings/{offering-id}/meetings") public ResponseEntity getOfferingMeeting( - @PathVariable(value = "offering-id") Long offeringId) { - OfferingMeetingResponse response = offeringService.getOfferingMeeting(offeringId); + @PathVariable(value = "offering-id") Long offeringId, + MemberEntity member) { + OfferingMeetingResponse response = offeringService.getOfferingMeeting(offeringId, member); return ResponseEntity.ok(response); } @PostMapping("/offerings") public ResponseEntity saveOffering( - @RequestBody @Valid OfferingSaveRequest request) { - Long offeringId = offeringService.saveOffering(request); + @RequestBody @Valid OfferingSaveRequest request, + MemberEntity member) { + Long offeringId = offeringService.saveOffering(request, member); return ResponseEntity.created(URI.create("/offerings/" + offeringId)).build(); } @@ -77,8 +80,8 @@ public ResponseEntity getOfferingStatus( @PatchMapping("/offerings/{offering-id}/status") public ResponseEntity updateCommentRoomStatus( @PathVariable(value = "offering-id") Long offeringId, - @RequestParam(value = "member-id") Long loginMemberId) { - CommentRoomStatusResponse response = offeringService.updateCommentRoomStatus(offeringId, loginMemberId); + MemberEntity member) { + CommentRoomStatusResponse response = offeringService.updateCommentRoomStatus(offeringId, member); return ResponseEntity.ok(response); } diff --git a/backend/src/main/java/com/zzang/chongdae/offering/exception/OfferingErrorCode.java b/backend/src/main/java/com/zzang/chongdae/offering/exception/OfferingErrorCode.java index 3d3a6b24a..30eea52e7 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/exception/OfferingErrorCode.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/exception/OfferingErrorCode.java @@ -17,7 +17,8 @@ public enum OfferingErrorCode implements ErrorResponse { NOT_SUPPORTED_FILTER(BAD_REQUEST, "현재는 지원하지 않는 필터입니다."), PARTICIPANT_FULL(BAD_REQUEST, "해당 공모에 참여 가능한 인원수를 초과하였습니다."), CANNOT_PARTICIPATE(BAD_REQUEST, "참여할 수 없는 공모입니다."), - INVALID_CONDITION(BAD_REQUEST, "유효하지 않은 공모 상태입니다"); + INVALID_CONDITION(BAD_REQUEST, "유효하지 않은 공모 상태입니다"), + NOT_PARTICIPATE_MEMBER(BAD_REQUEST, "해당 공모의 참여자가 아닙니다."); private final HttpStatus status; private final String message; diff --git a/backend/src/main/java/com/zzang/chongdae/offering/service/OfferingService.java b/backend/src/main/java/com/zzang/chongdae/offering/service/OfferingService.java index 80180dec5..3dbbd6f10 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/service/OfferingService.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/service/OfferingService.java @@ -2,8 +2,6 @@ import com.zzang.chongdae.comment.service.dto.CommentRoomStatusResponse; import com.zzang.chongdae.global.exception.MarketException; -import com.zzang.chongdae.member.exception.MemberErrorCode; -import com.zzang.chongdae.member.repository.MemberRepository; import com.zzang.chongdae.member.repository.entity.MemberEntity; import com.zzang.chongdae.offering.domain.OfferingFilter; import com.zzang.chongdae.offering.domain.OfferingPrice; @@ -39,20 +37,17 @@ public class OfferingService { private final OfferingRepository offeringRepository; private final OfferingMemberRepository offeringMemberRepository; - private final MemberRepository memberRepository; private final StorageService storageService; private final ProductImageExtractor extractor; private final OfferingFetcher offeringFetcher; - public OfferingDetailResponse getOfferingDetail(Long offeringId, Long memberId) { + public OfferingDetailResponse getOfferingDetail(Long offeringId, MemberEntity member) { OfferingEntity offering = offeringRepository.findById(offeringId) .orElseThrow(() -> new MarketException(OfferingErrorCode.NOT_FOUND)); OfferingPrice offeringPrice = offering.toOfferingPrice(); OfferingStatus offeringStatus = offering.toOfferingStatus(); - MemberEntity member = memberRepository.findById(memberId) - .orElseThrow(() -> new MarketException(MemberErrorCode.NOT_FOUND)); Boolean isParticipated = offeringMemberRepository.existsByOfferingAndMember(offering, member); return new OfferingDetailResponse(offering, offeringPrice, offeringStatus, isParticipated); @@ -79,15 +74,16 @@ public OfferingFilterAllResponse getAllOfferingFilter() { return new OfferingFilterAllResponse(filters); } - public OfferingMeetingResponse getOfferingMeeting(Long offeringId) { + public OfferingMeetingResponse getOfferingMeeting(Long offeringId, MemberEntity member) { OfferingEntity offering = offeringRepository.findById(offeringId) .orElseThrow(() -> new MarketException(OfferingErrorCode.NOT_FOUND)); + if (!offeringMemberRepository.existsByOfferingAndMember(offering, member)) { + throw new MarketException(OfferingErrorCode.NOT_PARTICIPATE_MEMBER); + } return new OfferingMeetingResponse(offering.toOfferingMeeting()); } - public Long saveOffering(OfferingSaveRequest request) { - MemberEntity member = memberRepository.findById(request.memberId()) - .orElseThrow(() -> new MarketException(MemberErrorCode.NOT_FOUND)); + public Long saveOffering(OfferingSaveRequest request, MemberEntity member) { OfferingEntity offering = request.toEntity(member); OfferingEntity savedOffering = offeringRepository.save(offering); return savedOffering.getId(); @@ -103,7 +99,7 @@ public OfferingStatusResponse getOfferingStatus(Long offeringId) { } @Transactional - public CommentRoomStatusResponse updateCommentRoomStatus(Long offeringId, Long loginMemberId) { + public CommentRoomStatusResponse updateCommentRoomStatus(Long offeringId, MemberEntity member) { // TODO: loginMember 가 총대 권한을 가지고 있는지 확인 OfferingEntity offering = offeringRepository.findById(offeringId) .orElseThrow(() -> new MarketException(OfferingErrorCode.NOT_FOUND)); diff --git a/backend/src/main/java/com/zzang/chongdae/offering/service/dto/OfferingSaveRequest.java b/backend/src/main/java/com/zzang/chongdae/offering/service/dto/OfferingSaveRequest.java index ba585a879..b61bfca4b 100644 --- a/backend/src/main/java/com/zzang/chongdae/offering/service/dto/OfferingSaveRequest.java +++ b/backend/src/main/java/com/zzang/chongdae/offering/service/dto/OfferingSaveRequest.java @@ -9,10 +9,7 @@ import jakarta.validation.constraints.NotNull; import java.time.LocalDateTime; -public record OfferingSaveRequest(@NotNull - Long memberId, - - @NotBlank +public record OfferingSaveRequest(@NotBlank String title, String productUrl, diff --git a/backend/src/main/java/com/zzang/chongdae/offeringmember/controller/OfferingMemberController.java b/backend/src/main/java/com/zzang/chongdae/offeringmember/controller/OfferingMemberController.java index 7dfa67967..486c993a3 100644 --- a/backend/src/main/java/com/zzang/chongdae/offeringmember/controller/OfferingMemberController.java +++ b/backend/src/main/java/com/zzang/chongdae/offeringmember/controller/OfferingMemberController.java @@ -1,5 +1,6 @@ package com.zzang.chongdae.offeringmember.controller; +import com.zzang.chongdae.member.repository.entity.MemberEntity; import com.zzang.chongdae.offeringmember.service.OfferingMemberService; import com.zzang.chongdae.offeringmember.service.dto.ParticipationRequest; import jakarta.validation.Valid; @@ -18,8 +19,9 @@ public class OfferingMemberController { @PostMapping("/participations") ResponseEntity participate( - @RequestBody @Valid ParticipationRequest request) { - Long id = offeringMemberService.participate(request); + @RequestBody @Valid ParticipationRequest request, + MemberEntity member) { + Long id = offeringMemberService.participate(request, member); return ResponseEntity.created(URI.create("/participations/" + id)).build(); } } diff --git a/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java b/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java index c7cb31bae..7e423f073 100644 --- a/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java +++ b/backend/src/main/java/com/zzang/chongdae/offeringmember/service/OfferingMemberService.java @@ -1,8 +1,6 @@ package com.zzang.chongdae.offeringmember.service; import com.zzang.chongdae.global.exception.MarketException; -import com.zzang.chongdae.member.exception.MemberErrorCode; -import com.zzang.chongdae.member.repository.MemberRepository; import com.zzang.chongdae.member.repository.entity.MemberEntity; import com.zzang.chongdae.offering.domain.OfferingStatus; import com.zzang.chongdae.offering.exception.OfferingErrorCode; @@ -22,13 +20,10 @@ public class OfferingMemberService { private final OfferingMemberRepository offeringMemberRepository; - private final MemberRepository memberRepository; private final OfferingRepository offeringRepository; @Transactional - public Long participate(ParticipationRequest request) { - MemberEntity member = memberRepository.findById(request.memberId()) - .orElseThrow(() -> new MarketException(MemberErrorCode.NOT_FOUND)); + public Long participate(ParticipationRequest request, MemberEntity member) { OfferingEntity offering = offeringRepository.findById(request.offeringId()) .orElseThrow(() -> new MarketException(OfferingErrorCode.NOT_FOUND)); validateParticipate(offering, member); diff --git a/backend/src/main/java/com/zzang/chongdae/offeringmember/service/dto/ParticipationRequest.java b/backend/src/main/java/com/zzang/chongdae/offeringmember/service/dto/ParticipationRequest.java index 8d4d98d7e..cd11ed14e 100644 --- a/backend/src/main/java/com/zzang/chongdae/offeringmember/service/dto/ParticipationRequest.java +++ b/backend/src/main/java/com/zzang/chongdae/offeringmember/service/dto/ParticipationRequest.java @@ -3,8 +3,5 @@ import jakarta.validation.constraints.NotNull; public record ParticipationRequest(@NotNull - Long memberId, - - @NotNull Long offeringId) { } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index e7f1d045f..f7b8c2ad7 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -31,3 +31,18 @@ amazon: bucket: techcourse-project-2024 cloudfront: redirectUrl: d3a5rfnjdz82qu.cloudfront.net + +security: + jwt: + token: + access-secret-key: testAccessSecretKeytestAccessSecretKeytestAccessSecretKeytestAccessSecretKeytest + refresh-secret-key: testRefreshSecretKeytestRefreshSecretKeytestRefreshSecretKeytestRefreshSecretKeytest + access-token-expired: 30m + refresh-token-expired: 14d + +components: + securitySchemes: + cookieAuth: + type: apiKey + in: cookie + name: JSESSIONID diff --git a/backend/src/main/resources/data.sql b/backend/src/main/resources/data.sql index 4d9356db1..36baaf27e 100644 --- a/backend/src/main/resources/data.sql +++ b/backend/src/main/resources/data.sql @@ -1,11 +1,11 @@ -INSERT INTO MEMBER (NICKNAME, CREATED_AT, UPDATED_AT) -VALUES ('dora', '2024-07-15 00:00:00', '2024-07-15 00:00:00'), - ('poke', '2024-07-15 00:00:00', '2024-07-15 00:00:00'), - ('mason', '2024-07-15 00:00:00', '2024-07-15 00:00:00'), - ('ever', '2024-07-15 00:00:00', '2024-07-15 00:00:00'), - ('alsong', '2024-07-15 00:00:00', '2024-07-15 00:00:00'), - ('seogi', '2024-07-15 00:00:00', '2024-07-15 00:00:00'), - ('chaechae', '2024-07-15 00:00:00', '2024-07-15 00:00:00'); +INSERT INTO MEMBER (NICKNAME, CREATED_AT, UPDATED_AT, PASSWORD) +VALUES ('dora', '2024-07-15 00:00:00', '2024-07-15 00:00:00', 'PtgtJCnn307FyCBvRprsy+42rX7dg00qVLWkPbl2Ag0='), + ('poke', '2024-07-15 00:00:00', '2024-07-15 00:00:00', 'XimRQY0Y2avPH6KxGK4ZOXB4+MT3Sfb605ZEPidVNpQ='), + ('mason', '2024-07-15 00:00:00', '2024-07-15 00:00:00', 'WBmuFn7FSb5jG03SKBB0K7MNk0mNg9FLPoHyTbi4Tl0='), + ('ever', '2024-07-15 00:00:00', '2024-07-15 00:00:00', 'UhN2JlsRhvNn7XY2WYlfDwI9/d/XoRvr8Ls7tbeYZWg='), + ('alsong', '2024-07-15 00:00:00', '2024-07-15 00:00:00', '+qY3Pnqyjj9amVGZ1Bu63iJX6cpon7kQiIvqAG0ExkE='), + ('seogi', '2024-07-15 00:00:00', '2024-07-15 00:00:00', '0CWUdyVQ1TP+GGlI9W2d5Gao/5HgT0MSeIwald0Qcsw='), + ('chaechae', '2024-07-15 00:00:00', '2024-07-15 00:00:00', 'WCkwnMjy/yW6odwkADguEIcHjFVELq+JLy+WeojvJ88='); INSERT INTO OFFERING (IS_MANUAL_CONFIRMED, TOTAL_COUNT, TOTAL_PRICE, EACH_PRICE, CURRENT_COUNT, CREATED_AT, UPDATED_AT, MEMBER_ID, DEADLINE, DESCRIPTION, MEETING_ADDRESS, MEETING_ADDRESS_DETAIL, PRODUCT_URL, diff --git a/backend/src/main/resources/static/nickname/adjectives.txt b/backend/src/main/resources/static/nickname/adjectives.txt new file mode 100644 index 000000000..af4b79ff0 --- /dev/null +++ b/backend/src/main/resources/static/nickname/adjectives.txt @@ -0,0 +1 @@ +춤추는,달리는,노래하는,사냥하는,지키는,전사,용감한,지혜로운,강한,빠른,조용한,날아다니는,헤엄치는,뛰어다니는,웃는,슬퍼하는,생각하는,꿈꾸는,사랑하는,기도하는,멋진,아름다운,자랑스러운,소중한,힘찬,빛나는,어두운,화려한,단단한,부드러운,귀여운,강렬한,순수한,고요한,신비한,용맹한,차가운,따뜻한,반짝이는,흐르는,가벼운,무거운,흔들리는,날렵한,느린,신속한,강인한,다정한,예민한,온화한,재빠른,굳건한,우직한,유쾌한,의연한,담담한,근엄한,차분한,겸손한,헌신적인,대담한,기민한,예리한,능숙한,익살스러운,창의적인,도전적인,정직한,희망찬,용서하는,배려하는,진실된,정열적인,활기찬,우아한,열정적인,사려깊은,독창적인,성실한,신중한,침착한,냉철한,열렬한,엄격한,고집스러운,단호한,느긋한,천진난만한,호기심많은,탐구하는,분석적인,혁신적인,서있는,앉아있는,누워있는,달콤한,쌉싸름한,매콤한,향기로운,상쾌한,청량한,푸근한,촉촉한,뽀송뽀송한,포근한,찬란한,황홀한,짜릿한,아릿한,씩씩한,산뜻한,선명한,생생한,활발한,용기있는,모험적인,신비로운,영롱한,눈부신,고독한,슬픈,기쁜,행복한,즐거운,설레는,기대하는,뿌듯한,흐뭇한,만족스러운,부지런한,당당한,자신있는,평화로운,만족한,흥미로운,매혹적인,기분좋은,상냥한,긍정적인,의심하는,신뢰하는,믿음직한,든든한,편안한,안정적인,평온한,당찬,과감한,확고한,결단력있는,책임감있는,신뢰할수있는,참을성있는,인내하는,예의바른,배려깊은,이해심있는,너그러운,친절한,애정있는,자비로운,은혜로운,강직한,꼼꼼한,기품있는,밝은,고운,자상한,정다운,사근한,아늑한,따사로운,생기있는,호탕한,소박한,맑은,깨끗한,명랑한,존경하는,인내심있는,신뢰할수있는,격려하는,이끄는,희생적인,자부심있는,상상력있는,직관적인,날카로운,재치있는,명석한,영리한,현명한,이성적인,통찰력있는,탐구적인,지적인,학구적인,학문적인,박식한,전문적인,기술적인,창조적인,예술적인,감성적인,음악적인,문학적인,철학적인,사색적인,협력적인,친화적인,공감하는,존중하는,포용적인,개방적인,유연한,적응력있는,민첩한,다재다능한,진취적인,존재하지않는,섹시한,졸린,화난,과식하는,욕망의,뜨거운,어여쁜,재미있는,기억이안나는,돈이많은,우등생,공부하는,밥을먹는,커피를마시는,문제아,홍차를좋아하는,날아가는,발냄새나는,숱이많은,만두를먹는,앙증맞은,거대한,향기나는,미세한,운동을잘하는,독서광,게임을좋아하는,게임을잘하는,더운,추운,시원한,적당한,네모난,둥글둥글한,순간이동하는,날렵한,야생의,똑똑한,반짝거리는,제멋대로구는,성공한,출세한,이타적인,커피를좋아하는,야심찬,이기적인,엉뚱한,세련된,발라드를좋아하는,장난스러운,짓궂은,힙합을좋아하는,진지한,눈이침침한,말이없는,파인애플피자를좋아하는,명령하는,잠자는숲속의,기타치는,피시방에자주가는,친구가없는,이유식을먹는,턱받이를한,끊임없이먹는,소설쓰는,휴가간 diff --git a/backend/src/main/resources/static/nickname/nouns.txt b/backend/src/main/resources/static/nickname/nouns.txt new file mode 100644 index 000000000..92a8fbf55 --- /dev/null +++ b/backend/src/main/resources/static/nickname/nouns.txt @@ -0,0 +1 @@ +해,달,강,산,나무,바람,구름,별,불,물,꽃,새,호랑이,용,사자,고래,독수리,늑대,여우,곰,사슴,토끼,부엉이,까마귀,참새,매,황소,말,개,고양이,돼지,소,양,닭,거북이,두더지,원숭이,고릴라,코끼리,코뿔소,하마,바다,강아지,올빼미,두루미,까치,앵무새,나비,벌,개미,거미,나무늘보,고슴도치,오소리,공룡,피라미,상어,연어,새우,가재,붕어,잉어,돌고래,참치,연꽃,백합,장미,튤립,국화,해바라기,민들레,무궁화,진달래,철쭉,수선화,제비꽃,나팔꽃,달맞이꽃,제비,학,봉황,비둘기,갈매기,파랑새,물총새,갈색곰,팬더,자이언트팬더,미어캣,여우원숭이,플라밍고,백조,매미,방울새,강산,초원,사막,폭포,숲,눈,우주,천둥,번개,저녁,아침,새벽,황혼,새벽녘,보름달,은하수,해돋이,해질녘,태양,소나기,땅,언덕,계곡,늪,목초지,사파리,정글,밀림,산맥,협곡,절벽,해안,해변,모래사장,바위,암석,산호,해조류,유령,신령,선녀,도깨비,요정,천사,악마,영혼,망령,정령,초록,파랑,빨강,노랑,주황,보라,분홍,회색,흰색,검정,금색,은색,청록,연두,다홍,진홍,남색,청색,미색,담홍,담청,옥색,주황색,갈색,하늘색,청명,무지개,해골,드래곤,유니콘,피닉스,세이렌,메두사,페가수스,히드라,키메라,켄타우로스,하피,그리핀,미노타우르스,드라큘라,늑대인간,프랑켄슈타인,좀비,스켈레톤,레이스,벤시,오로라,자작나무,붉은노을,파도,용암,황금빛,아지랑이,서리,이슬,메아리,흙,잎사귀,뿌리,가시,씨앗,모래,산들바람,비,우박,눈보라,폭풍,폭우,장마,노을,여명,적막,어둠,맑음,흐림,안개,연무,먼지,태풍,허리케인,모래바람,진눈깨비,미풍,강풍,돌풍,눈발,일몰,일출,청둥오리,원앙,왜가리,황새,갈대,억새,연못,호수,시냇물,개천,웅덩이,동굴,바위산,평원,사바나,초지,숲속,정원,공원,대나무숲,잔디밭,유채꽃,벚꽃,라일락,수국,작약,모란,천리향,매화,목련,감나무,배나무,사과나무,복숭아나무,포도나무,오렌지나무,레몬나무,밤나무,호두나무,은행나무,소나무,참나무,쿼카,악어,기린,오리,오리너구리,너구리,휴먼,침팬지,홍학,가마우지,카멜레온,달팽이,구렁이,이무기,얼룩말,불사조,디멘터,하이에나,맘모스,티라노사우르스,랩터,브라키오사우르스,햄스터,치타,익룡,멧돼지,산돼지,피글렛,캥거루,산토끼,쥐,기니피그,시골쥐,도시쥐,패럿,수달,북극곰,반달가슴곰,펭귄,남극곰,밍크,족제비,뱀,코브라,아나콘다,킹코브라,담비,타조,북극여우,알바트로스,오랑우탄,물범,코알라,하프물범,북극토끼,칠면조,코뿔바다오리,직박구리,황제펭귄,물개,판다,랫서판다,범고래,식인고래,개구리,물소,맹꽁이,우파루파,이구아나,염소,노새,당나귀,올챙이,병아리,살모사,하늘다람쥐,도롱뇽,퓨마,아르마딜로,다람쥐,알파카,일본자이언트날다람쥐,포메라니안,진돗개,웰시코기,말티즈,시베리안허스키,닥스훈트,리트리버,노르웨이숲,푸들,낙타,스피츠,삽살개,먼치킨,페르시안고양이,래그돌,스코티쉬폴드,러시안블루,공작새,쥐며느리,키위새,장수하늘소,흰긴수염고래,죠스,식인상어,아기상어,황금들창코원숭이,긴꼬리원숭이,안경원숭이,자라,표범,청설모,바비루사,빅풋,예티,알락꼬리여우원숭이,메추라기,스라소니,삵,카피바라,라마,딱따구리,기러기,스컹크,해태,구미호,인면조,개미핥기,대왕오징어,갑오징어,두억시니,샐러맨더,바실리스크,와이번,다오,마리드,배찌,디지니,우니,에띠,케피,로두마니,모스,마리오,루이지,피치,로젤리나,쿠파,키노피오,와리오,사일러스,헤카림,진,가렌,갈리오,갱플랭크,그라가스,그레이브즈,나르,나미,나서스,노틸러스,녹턴,누누,니달리,다리우스,다이애나,드레이븐,라이즈,라칸,람머스,럭스,럼블,레넥톤,레오나,렉사이,렐,그웬,렝가,루시안,룰루,르블랑,리신,리븐,바드,미스포츈,문도박사,마스터이,마오카이,말파이트,볼리베어,브라움,모르가나,모데카이저,브랜드,블리츠크랭크,비에고,빅토르,사미라,사이온,샤코,세나,세라핀,세주아니,세트,아리,아무무,신드라,시비르,신짜오,스카너,아이번,아지르,소라카,소나,쉔,애니비아,겐지,둠피스트,리퍼,맥크리,메이,바스티온,솜브라,시메트라,애쉬,에코,위도우메이커,정크랫,토르비욘,트레이서,파라,한조,라인하르트,레킹볼,로드호그,시그마,오리사,윈스턴,자리야,루시우,메르시,모이라,바티스트,브리기테,아나,젠야타,방갈로르,미라지,옥테인,레버넌트,호라이즌,퓨즈,블러드하운드,패스파인더,크립토,발키리,라이프라인,로바,지브롤터,코스틱,왓슨,램파트,소닉,테일즈,스랄,제이나,가로쉬,데스윙,발리라,마이에브,우서,렉사르,실바나스,말퓨리온,굴단,느조스,메디브,안두인,일리단,하이머딩거,피즈,피오라,피들스틱,판테온,파이크,티모,트위치,트위스티드페이트,트린다미어,트리스타나,트런들,고든,탐켄치,탈리야,탈론,타릭,킨드레드,키아나,클레드,퀸,코르키,코그모,케일,케인,케이틀린,케넨,칼리스타,카타리나,카직스,카이사,카시오페아,카서스,카사딘,카밀,카르마,초가스,징크스,질리언,직스,조이,제이스,제라스,제드,잭스,잔나,자크,자르반,일라오이,이즈리얼,이블린,이렐리아,유미,워윅,우르곳,우디르,요릭,요네,올라프,오른,오리아나,오공,엘리스,야스오,애니,알리스타,아펠리오스,아우렐리온솔,아트록스,아칼리,아크샨,마린,파이어뱃,벌쳐,시즈탱크,배틀크루져,저글링,히드라리스크,울트라리스크,럴커,메딕,고스트,하이템플러,다크템플러,리버,옵저버,스카웃,캐리어,질럿,아칸,다크아칸,드라군,커세어,뮤탈리스크,스커지,디바우러,가디언,공허충,말자하,카즈야,헤이하치,에디,알리사,간류,니나,안나,드라구노프,리리,리로이젠킨스,브라이언,스티브폭스,샤오유,아머킹,요시미츠,자피나,쿠니미츠,클라우디오,화랑,아이작,피터파커,토니스타크,하워드스타크,모건스타크,해피호건,페퍼포츠,오베디아스탠,쟈비스,프라이데이,윌리엄리바,쿠엔틴벡,호인센,크리스틴에버하트,이반반코,토르오딘손,나타샤로마노프,페기카터,제인포스터,클린트바튼,베티로스,릭메이슨,로라바튼,행크핌,캐시랭,루이스,메이파커,네드,리즈,베티브랜트,비전,제임스로즈,샘윌슨,완다막시모프,스콧랭,트촬라,버키반즈,피에트로막시모프,호프밴다인,캐럴댄버스,옥토옥타비우스,멜린다메이,슈리,은죠부,오코예,나키아,음바쿠,쥬리,에인션트원,케실리우스,스티븐스트레인지,웡,칼모르도,도르마무,맷머독,제시카존스,프랭크캐슬,빌리루소,알렉산더피어스,울트론,로키오딘슨,헬라,오딘보르슨,헤임달,피터퀼,가모라,드랙스,로켓라쿤,그루트,맨티스,네뷸라,타노스,길가메쉬,스타폭스,킨고,에이잭,치타우리,에고,노웨어,닉퓨리,필콜슨,브루스배너,아르님졸라,마리아힐,알드리치킬리언,마야한센,샹치,만다린,드루이그,데이지존슨,리오피츠,젬마시몬스,정복자캉,트촤카,율리시스클로,욘두우돈타,로난,파이리,꼬부기,피카츄,라이츄,버터플,이상해씨,리자몽,거북왕,캐터피,독침붕,피죤,꼬렛,구구,깨비드릴조,아보,모래두지,고지,니드퀸,니드킹,식스테일,나인테일,픽시,삐삐,주뱃,푸린,뚜벅쵸,디그다,고라파덕,성원숭,윈디,발챙이,슈륙챙이,케이시,윤겔라,알통몬,우츠동,모다피,왕눈해,꼬마돌,롱스톤,야돈,코일,파오리,두두,쥬쥬,질퍽이,파르셀,고오스,팬텀,슬리퍼,크랩,킹크랩,찌리리공,붐볼,아라리,나시,탕구리,홍수몬,시라소몬,내루미,또가스,코뿌리,럭키,쏘드라,콘치,별가사리,아쿠스타,마임맨,스라크,루주라,마그마,잉어킹,갸라도스,라프라스,메타몽,이브이,쥬피썬더,폴리곤,부스터,투구푸스,프테라,잠만보,프리져,썬더,미뇽,망나뇽,뮤츠,뮤,치코리타,토게피,세레비,칠색조,뿔카노,에레브,쁘사이저,켄타로스,샤미드,암나이트,신뇽,질뻐기 diff --git a/backend/src/test/java/com/zzang/chongdae/ChongdaeApplicationTests.java b/backend/src/test/java/com/zzang/chongdae/ChongdaeApplicationTests.java deleted file mode 100644 index 8d089d3a6..000000000 --- a/backend/src/test/java/com/zzang/chongdae/ChongdaeApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.zzang.chongdae; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class ChongdaeApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/backend/src/test/java/com/zzang/chongdae/auth/integration/AuthIntegrationTest.java b/backend/src/test/java/com/zzang/chongdae/auth/integration/AuthIntegrationTest.java new file mode 100644 index 000000000..9e5c77bbd --- /dev/null +++ b/backend/src/test/java/com/zzang/chongdae/auth/integration/AuthIntegrationTest.java @@ -0,0 +1,239 @@ +package com.zzang.chongdae.auth.integration; + +import static com.epages.restdocs.apispec.ResourceDocumentation.headerWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static com.epages.restdocs.apispec.ResourceSnippetParameters.builder; +import static com.epages.restdocs.apispec.Schema.schema; +import static io.restassured.RestAssured.given; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.document; + +import com.epages.restdocs.apispec.HeaderDescriptorWithType; +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.zzang.chongdae.auth.service.dto.LoginRequest; +import com.zzang.chongdae.auth.service.dto.RefreshRequest; +import com.zzang.chongdae.auth.service.dto.SignupRequest; +import com.zzang.chongdae.global.integration.IntegrationTest; +import com.zzang.chongdae.member.repository.entity.MemberEntity; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.restassured.http.ContentType; +import java.time.Duration; +import java.util.Date; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.restdocs.payload.FieldDescriptor; + +class AuthIntegrationTest extends IntegrationTest { + + @DisplayName("로그인") + @Nested + class Login { + + List requestDescriptors = List.of( + fieldWithPath("ci").description("회원 식별자 인증 정보") + ); + List responseHeaderDescriptors = List.of( + headerWithName("Set-Cookie").description(""" + access_token=a.b.c; Path=/; HttpOnly \n + refresh_token=a.b.c; Path=/; HttpOnly + """) + ); + ResourceSnippetParameters successSnippets = builder() + .summary("회원 로그인") + .description("회원 식별자 인증 정보로 로그인 합니다.") + .requestFields(requestDescriptors) + .requestSchema(schema("LonginRequest")) + .responseHeaders(responseHeaderDescriptors) + .build(); + + MemberEntity member; + + @BeforeEach + void setUp() { + member = memberFixture.createMember(); + } + + @DisplayName("회원 식별자 인증 정보로 로그인한다.") + @Test + void should_loginSuccess_when_givenMemberCI() { + LoginRequest request = new LoginRequest( + "dora1234" + ); + + given(spec).log().all() + .filter(document("login-success", resource(successSnippets))) + .contentType(ContentType.JSON) + .body(request) + .when().post("/auth/login") + .then().log().all() + .statusCode(200); + } + } + + @DisplayName("회원가입") + @Nested + class Signup { + List requestDescriptors = List.of( + fieldWithPath("ci").description("회원 식별자 인증 정보") + ); + List responseDescriptors = List.of( + fieldWithPath("memberId").description("회원 id"), + fieldWithPath("nickname").description("닉네임") + ); + ResourceSnippetParameters successSnippets = builder() + .summary("회원 가입") + .description("회원 식별자 인증 정보로 가입합니다.") + .requestFields(requestDescriptors) + .responseFields(responseDescriptors) + .requestSchema(schema("SignupRequest")) + .responseSchema(schema("SignupResponse")) + .build(); + ResourceSnippetParameters failSnippets = builder() + .responseFields(failResponseDescriptors) + .requestSchema(schema("SignupFailRequest")) + .responseSchema(schema("SignupFailResponse")) + .build(); + + MemberEntity member; + + @BeforeEach + void setUp() { + member = memberFixture.createMember(); + } + + @DisplayName("회원 식별자 인증 정보로 회원가입 한다.") + @Test + void should_signupSuccess_when_givenMemberCI() { + SignupRequest request = new SignupRequest( + "poke1234" + ); + + given(spec).log().all() + .filter(document("signup-success", resource(successSnippets))) + .contentType(ContentType.JSON) + .body(request) + .when().post("/auth/signup") + .then().log().all() + .statusCode(200); + } + + @DisplayName("이미 가입된 회원이 있으면 예외가 발생한다.") + @Test + void should_throwException_when_givenAlreadyExistMember() { + SignupRequest request = new SignupRequest( + "dora1234" + ); + + given(spec).log().all() + .filter(document("signup-fail-duplicated-member", resource(failSnippets))) + .contentType(ContentType.JSON) + .body(request) + .when().post("/auth/signup") + .then().log().all() + .statusCode(409); + } + } + + @DisplayName("토큰 재발급") + @Nested + class Refresh { + + List requestDescriptors = List.of( + fieldWithPath("refreshToken").description("재발급에 필요한 토큰") + ); + List responseHeaderDescriptors = List.of( + headerWithName("Set-Cookie").description(""" + access_token=a.b.c; Path=/; HttpOnly \n + refresh_token=a.b.c; Path=/; HttpOnly + """) + ); + ResourceSnippetParameters successSnippets = builder() + .summary("토큰 재발급") + .description("토큰을 재발급합니다.") + .requestFields(requestDescriptors) + .responseHeaders(responseHeaderDescriptors) + .requestSchema(schema("RefreshRequest")) + .build(); + ResourceSnippetParameters failedSnippets = builder() + .requestFields(requestDescriptors) + .requestSchema(schema("RefreshFailRequest")) + .build(); + + @Value("${security.jwt.token.refresh-secret-key}") + String refreshSecretKey; + + @Value("${security.jwt.token.refresh-token-expired}") + Duration refreshTokenExpired; + + MemberEntity member; + Date now; + + @BeforeEach + void setUp() { + member = memberFixture.createMember(); + now = Date.from(clock.instant()); + } + + @DisplayName("refreshToken으로 accessToken과 refreshToken을 재발급 한다.") + @Test + void should_refreshSuccess_when_givenRefreshToken() { + RefreshRequest request = new RefreshRequest( + cookieProvider.createCookies().getValue("refresh_token") + ); + + given(spec).log().all() + .filter(document("refresh-success", resource(successSnippets))) + .contentType(ContentType.JSON) + .body(request) + .when().post("/auth/refresh") + .then().log().all() + .statusCode(200); + } + + @DisplayName("유효하지 않은 refeshToken인 경우 예외가 발생한다.") + @Test + void should_throwException_when_givenInvalidRefreshToken() { + + RefreshRequest request = new RefreshRequest( + "invalidRefreshToken" + ); + + given(spec).log().all() + .filter(document("refresh-fail-invalid-token", resource(failedSnippets))) + .contentType(ContentType.JSON) + .body(request) + .when().post("/auth/refresh") + .then().log().all() + .statusCode(401); + } + + @DisplayName("만료된 refeshToken인 경우 예외가 발생한다.") + @Test + void should_throwException_when_givenExpiredRefreshToken() { + Date alreadyExpiredAt = new Date(now.getTime() - refreshTokenExpired.toMillis()); + + String expiredToken = Jwts.builder() + .setSubject(member.getId().toString()) + .setExpiration(alreadyExpiredAt) + .signWith(SignatureAlgorithm.HS256, refreshSecretKey) + .compact(); + + RefreshRequest request = new RefreshRequest( + expiredToken + ); + + given(spec).log().all() + .filter(document("refresh-fail-expired-token", resource(failedSnippets))) + .contentType(ContentType.JSON) + .body(request) + .when().post("/auth/refresh") + .then().log().all() + .statusCode(401); + } + } +} diff --git a/backend/src/test/java/com/zzang/chongdae/comment/integration/CommentIntegrationTest.java b/backend/src/test/java/com/zzang/chongdae/comment/integration/CommentIntegrationTest.java index 889b4a623..ea4f23b6c 100644 --- a/backend/src/test/java/com/zzang/chongdae/comment/integration/CommentIntegrationTest.java +++ b/backend/src/test/java/com/zzang/chongdae/comment/integration/CommentIntegrationTest.java @@ -29,7 +29,6 @@ public class CommentIntegrationTest extends IntegrationTest { class SaveComment { List requestDescriptors = List.of( - fieldWithPath("memberId").description("회원 id (필수)"), fieldWithPath("offeringId").description("공모 id (필수)"), fieldWithPath("content").description("내용 (필수)") ); @@ -40,12 +39,8 @@ class SaveComment { .requestSchema(schema("CommentSaveRequest")) .build(); ResourceSnippetParameters failSnippets = builder() - .summary("댓글 작성") - .description("댓글을 작성합니다.") - .requestFields(requestDescriptors) .responseFields(failResponseDescriptors) - .requestSchema(schema("CommentSaveRequest")) - .responseSchema(schema("CommentSaveFailResponse")) + .responseSchema(schema("CommentSaveFailResponse")) // TODO: 중복되는 값 제거 .build(); MemberEntity member; @@ -61,13 +56,13 @@ void setUp() { @Test void should_saveCommentSuccess_when_givenCommentSaveRequest() { CommentSaveRequest request = new CommentSaveRequest( - member.getId(), offering.getId(), "댓글 내용" ); RestAssured.given(spec).log().all() .filter(document("save-comment-success", resource(successSnippets))) + .cookies(cookieProvider.createCookies()) .contentType(ContentType.JSON) .body(request) .when().post("/comments") @@ -79,13 +74,13 @@ void should_saveCommentSuccess_when_givenCommentSaveRequest() { @Test void should_throwException_when_emptyValue() { CommentSaveRequest request = new CommentSaveRequest( - null, null, "" ); RestAssured.given(spec).log().all() .filter(document("save-comment-fail-request-with-null", resource(failSnippets))) + .cookies(cookieProvider.createCookies()) .contentType(ContentType.JSON) .body(request) .when().post("/comments") @@ -97,31 +92,13 @@ void should_throwException_when_emptyValue() { @Test void should_throwException_when_longContent() { CommentSaveRequest request = new CommentSaveRequest( - member.getId(), offering.getId(), "123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890" ); RestAssured.given(spec).log().all() .filter(document("save-comment-fail-request-with-long-content", resource(failSnippets))) - .contentType(ContentType.JSON) - .body(request) - .when().post("/comments") - .then().log().all() - .statusCode(400); - } - - @DisplayName("유효하지 않은 사용자에 대해 작성을 시도하는 경우 예외가 발생한다.") - @Test - void should_throwException_when_invalidMember() { - CommentSaveRequest request = new CommentSaveRequest( - member.getId() + 100, - offering.getId(), - "댓글 내용" - ); - - RestAssured.given(spec).log().all() - .filter(document("save-comment-fail-invalid_member", resource(failSnippets))) + .cookies(cookieProvider.createCookies()) .contentType(ContentType.JSON) .body(request) .when().post("/comments") @@ -133,13 +110,13 @@ void should_throwException_when_invalidMember() { @Test void should_throwException_when_invalidOffering() { CommentSaveRequest request = new CommentSaveRequest( - member.getId(), offering.getId() + 100, "댓글 내용" ); RestAssured.given(spec).log().all() .filter(document("save-comment-fail-invalid-offering", resource(failSnippets))) + .cookies(cookieProvider.createCookies()) .contentType(ContentType.JSON) .body(request) .when().post("/comments") @@ -152,9 +129,6 @@ void should_throwException_when_invalidOffering() { @Nested class GetAllCommentRoom { - List queryParameterDescriptors = List.of( - parameterWithName("member-id").description("회원 id (필수)") - ); List successResponseDescriptors = List.of( fieldWithPath("offerings[].offeringId").description("공모 id"), fieldWithPath("offerings[].offeringTitle").description("공모 제목"), @@ -165,17 +139,9 @@ class GetAllCommentRoom { ResourceSnippetParameters successSnippets = builder() .summary("댓글방 목록 조회") .description("댓글방 목록을 조회합니다.") - .queryParameters(queryParameterDescriptors) .responseFields(successResponseDescriptors) .responseSchema(schema("CommentRoomAllSuccessResponse")) .build(); - ResourceSnippetParameters failSnippets = builder() - .summary("댓글 목록 조회") - .description("댓글 목록을 조회합니다.") - .queryParameters(queryParameterDescriptors) - .responseFields(failResponseDescriptors) - .responseSchema(schema("CommentRoomAllFailResponse")) - .build(); MemberEntity member; OfferingEntity offering; @@ -195,22 +161,11 @@ void setUp() { void should_responseAllCommentRoom_when_givenMemberId() { RestAssured.given(spec).log().all() .filter(document("get-all-comment-room-success", resource(successSnippets))) - .queryParam("member-id", member.getId()) + .cookies(cookieProvider.createCookies()) .when().get("/comments") .then().log().all() .statusCode(200); } - - @DisplayName("유효하지 않는 사용자가 댓글방을 조회 할 경우 예외가 발생한다.") - @Test - void should_throwException_when_invalidMember() { - RestAssured.given(spec).log().all() - .filter(document("get-all-comment-room-fail-invalid-member", resource(failSnippets))) - .queryParam("member-id", member.getId() + 100) - .when().get("/comments") - .then().log().all() - .statusCode(400); - } } @DisplayName("댓글 목록 조회") @@ -220,9 +175,6 @@ class GetAllComment { List pathParameterDescriptors = List.of( parameterWithName("offering-id").description("공모 id (필수)") ); - List queryParameterDescriptors = List.of( - parameterWithName("member-id").description("회원 id (필수)") - ); List successResponseDescriptors = List.of( fieldWithPath("comments[].commentId").description("댓글 id"), fieldWithPath("comments[].createdAt.date").description("작성 날짜"), @@ -236,7 +188,6 @@ class GetAllComment { .summary("댓글 목록 조회") .description("댓글 목록을 조회합니다.") .pathParameters(pathParameterDescriptors) - .queryParameters(queryParameterDescriptors) .responseFields(successResponseDescriptors) .responseSchema(schema("CommentAllSuccessResponse")) .build(); @@ -244,7 +195,6 @@ class GetAllComment { .summary("댓글 목록 조회") .description("댓글 목록을 조회합니다.") .pathParameters(pathParameterDescriptors) - .queryParameters(queryParameterDescriptors) .responseFields(failResponseDescriptors) .responseSchema(schema("CommentAllFailResponse")) .build(); @@ -264,8 +214,8 @@ void setUp() { void should_responseAllComment_when_givenOfferingIdAndMemberId() { RestAssured.given(spec).log().all() .filter(document("get-all-comment-success", resource(successSnippets))) + .cookies(cookieProvider.createCookies()) .pathParam("offering-id", offering.getId()) - .queryParam("member-id", member.getId()) .when().get("/comments/{offering-id}") .then().log().all() .statusCode(200); @@ -276,8 +226,8 @@ void should_responseAllComment_when_givenOfferingIdAndMemberId() { void should_throwException_when_invalidOffering() { RestAssured.given(spec).log().all() .filter(document("get-all-comment-fail-invalid-offering", resource(failSnippets))) + .cookies(cookieProvider.createCookies()) .pathParam("offering-id", offering.getId() + 100) - .queryParam("member-id", member.getId()) .when().get("/comments/{offering-id}") .then().log().all() .statusCode(400); diff --git a/backend/src/test/java/com/zzang/chongdae/global/config/TestClockConfig.java b/backend/src/test/java/com/zzang/chongdae/global/config/TestClockConfig.java new file mode 100644 index 000000000..0e32c6f64 --- /dev/null +++ b/backend/src/test/java/com/zzang/chongdae/global/config/TestClockConfig.java @@ -0,0 +1,18 @@ +package com.zzang.chongdae.global.config; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +@TestConfiguration +public class TestClockConfig { + + @Bean + @Primary + public Clock testClock() { + return Clock.fixed(Instant.parse("2000-04-07T02:00:00Z"), ZoneOffset.UTC); + } +} diff --git a/backend/src/test/java/com/zzang/chongdae/global/config/TestConfig.java b/backend/src/test/java/com/zzang/chongdae/global/config/TestConfig.java new file mode 100644 index 000000000..479fc9257 --- /dev/null +++ b/backend/src/test/java/com/zzang/chongdae/global/config/TestConfig.java @@ -0,0 +1,11 @@ +package com.zzang.chongdae.global.config; + +import com.zzang.chongdae.member.config.TestNicknameWordPickerConfig; +import com.zzang.chongdae.offering.config.TestCrawlerConfig; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Import; + +@Import({TestCrawlerConfig.class, TestNicknameWordPickerConfig.class, TestClockConfig.class}) +@TestConfiguration +public class TestConfig { +} diff --git a/backend/src/test/java/com/zzang/chongdae/global/domain/MemberFixture.java b/backend/src/test/java/com/zzang/chongdae/global/domain/MemberFixture.java index b10e91963..6f28b88e3 100644 --- a/backend/src/test/java/com/zzang/chongdae/global/domain/MemberFixture.java +++ b/backend/src/test/java/com/zzang/chongdae/global/domain/MemberFixture.java @@ -1,5 +1,6 @@ package com.zzang.chongdae.global.domain; +import com.zzang.chongdae.auth.service.PasswordEncoder; import com.zzang.chongdae.member.repository.MemberRepository; import com.zzang.chongdae.member.repository.entity.MemberEntity; import org.springframework.beans.factory.annotation.Autowired; @@ -11,13 +12,16 @@ public class MemberFixture { @Autowired private MemberRepository memberRepository; + @Autowired + private PasswordEncoder passwordEncoder; + public MemberEntity createMember() { - MemberEntity member = new MemberEntity("dora"); + MemberEntity member = new MemberEntity("dora", passwordEncoder.encode("dora1234")); return memberRepository.save(member); } public MemberEntity createMember(String nickname) { - MemberEntity member = new MemberEntity(nickname); + MemberEntity member = new MemberEntity(nickname, passwordEncoder.encode(nickname + "5678")); return memberRepository.save(member); } } diff --git a/backend/src/test/java/com/zzang/chongdae/global/helper/CookieProvider.java b/backend/src/test/java/com/zzang/chongdae/global/helper/CookieProvider.java new file mode 100644 index 000000000..9a9c3e815 --- /dev/null +++ b/backend/src/test/java/com/zzang/chongdae/global/helper/CookieProvider.java @@ -0,0 +1,29 @@ +package com.zzang.chongdae.global.helper; + +import com.zzang.chongdae.auth.service.dto.LoginRequest; +import io.restassured.RestAssured; +import io.restassured.http.Cookies; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +@Component +public class CookieProvider { + + public Cookies createCookies() { + return RestAssured.given().log().all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(new LoginRequest("dora1234")) + .when().post("/auth/login") + .then().log().all() + .extract().detailedCookies(); + } + + public Cookies createCookiesWithCi(String ci) { + return RestAssured.given().log().all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(new LoginRequest(ci)) + .when().post("/auth/login") + .then().log().all() + .extract().detailedCookies(); + } +} diff --git a/backend/src/test/java/com/zzang/chongdae/global/integration/IntegrationTest.java b/backend/src/test/java/com/zzang/chongdae/global/integration/IntegrationTest.java index 17f5e671b..0409a6417 100644 --- a/backend/src/test/java/com/zzang/chongdae/global/integration/IntegrationTest.java +++ b/backend/src/test/java/com/zzang/chongdae/global/integration/IntegrationTest.java @@ -1,16 +1,20 @@ package com.zzang.chongdae.global.integration; +import static org.springframework.restdocs.cookies.CookieDocumentation.cookieWithName; +import static org.springframework.restdocs.cookies.CookieDocumentation.requestCookies; import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyHeaders; import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.documentationConfiguration; +import com.zzang.chongdae.global.config.TestConfig; import com.zzang.chongdae.global.domain.DomainSupplier; +import com.zzang.chongdae.global.helper.CookieProvider; import com.zzang.chongdae.global.helper.DatabaseCleaner; -import com.zzang.chongdae.offering.config.TestCrawlerConfig; import io.restassured.RestAssured; import io.restassured.builder.RequestSpecBuilder; import io.restassured.specification.RequestSpecification; +import java.time.Clock; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; @@ -22,10 +26,11 @@ import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.restdocs.RestDocumentationContextProvider; import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.restdocs.cookies.RequestCookiesSnippet; import org.springframework.restdocs.payload.FieldDescriptor; import org.springframework.test.context.ActiveProfiles; -@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, classes = {TestCrawlerConfig.class}) +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, classes = {TestConfig.class}) @ActiveProfiles("test") @ExtendWith(RestDocumentationExtension.class) public abstract class IntegrationTest extends DomainSupplier { @@ -33,9 +38,22 @@ public abstract class IntegrationTest extends DomainSupplier { protected final List failResponseDescriptors = List.of( fieldWithPath("message").description("오류 내용") ); + + protected final RequestCookiesSnippet requestCookiesSnippet = requestCookies( + cookieWithName("access_token").description("인증 토큰") + ); + protected RequestSpecification spec; + @Autowired protected DatabaseCleaner databaseCleaner; + + @Autowired + protected CookieProvider cookieProvider; + + @Autowired + protected Clock clock; + @LocalServerPort private int port; diff --git a/backend/src/test/java/com/zzang/chongdae/member/config/TestNicknameWordPickerConfig.java b/backend/src/test/java/com/zzang/chongdae/member/config/TestNicknameWordPickerConfig.java new file mode 100644 index 000000000..9d65a017f --- /dev/null +++ b/backend/src/test/java/com/zzang/chongdae/member/config/TestNicknameWordPickerConfig.java @@ -0,0 +1,17 @@ +package com.zzang.chongdae.member.config; + +import com.zzang.chongdae.member.service.FixedNicknameWordPicker; +import com.zzang.chongdae.member.service.NicknameWordPicker; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +@TestConfiguration +public class TestNicknameWordPickerConfig { + + @Bean + @Primary + NicknameWordPicker testNicknameWordPicker() { + return new FixedNicknameWordPicker(); + } +} diff --git a/backend/src/test/java/com/zzang/chongdae/member/service/FixedNicknameWordPicker.java b/backend/src/test/java/com/zzang/chongdae/member/service/FixedNicknameWordPicker.java new file mode 100644 index 000000000..7679b79fd --- /dev/null +++ b/backend/src/test/java/com/zzang/chongdae/member/service/FixedNicknameWordPicker.java @@ -0,0 +1,14 @@ +package com.zzang.chongdae.member.service; + + +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class FixedNicknameWordPicker implements NicknameWordPicker { + + @Override + public String pick(List words) { + return words.get(0); + } +} diff --git a/backend/src/test/java/com/zzang/chongdae/member/service/NicknameGeneratorTest.java b/backend/src/test/java/com/zzang/chongdae/member/service/NicknameGeneratorTest.java new file mode 100644 index 000000000..c438a99c0 --- /dev/null +++ b/backend/src/test/java/com/zzang/chongdae/member/service/NicknameGeneratorTest.java @@ -0,0 +1,29 @@ +package com.zzang.chongdae.member.service; + +import com.zzang.chongdae.member.config.TestNicknameWordPickerConfig; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; + +@SpringBootTest(webEnvironment = WebEnvironment.NONE, classes = {TestNicknameWordPickerConfig.class}) +public class NicknameGeneratorTest { + + @Autowired + NicknameGenerator nickNameGenerator; + + @DisplayName("닉네임 생성에 성공한다.") + @Test + void should_returnNickname_when_generateNickName() { + // given + String expected = "춤추는해"; + + // when + String actual = nickNameGenerator.generate(); + + // then + Assertions.assertEquals(actual, expected); + } +} diff --git a/backend/src/test/java/com/zzang/chongdae/offering/integration/OfferingIntegrationTest.java b/backend/src/test/java/com/zzang/chongdae/offering/integration/OfferingIntegrationTest.java index 78c00baf1..876543154 100644 --- a/backend/src/test/java/com/zzang/chongdae/offering/integration/OfferingIntegrationTest.java +++ b/backend/src/test/java/com/zzang/chongdae/offering/integration/OfferingIntegrationTest.java @@ -15,9 +15,9 @@ import com.zzang.chongdae.global.integration.IntegrationTest; import com.zzang.chongdae.member.repository.entity.MemberEntity; import com.zzang.chongdae.offering.domain.OfferingCondition; -import com.zzang.chongdae.offering.repository.entity.OfferingEntity; import com.zzang.chongdae.offering.domain.OfferingFilter; import com.zzang.chongdae.offering.domain.OfferingFilterType; +import com.zzang.chongdae.offering.repository.entity.OfferingEntity; import com.zzang.chongdae.offering.service.dto.OfferingProductImageRequest; import com.zzang.chongdae.offering.service.dto.OfferingSaveRequest; import com.zzang.chongdae.storage.service.StorageService; @@ -48,9 +48,6 @@ class GetOfferingDetail { List pathParameterDescriptors = List.of( parameterWithName("offering-id").description("공모 id (필수)") ); - List queryParameterDescriptors = List.of( - parameterWithName("member-id").description("회원 id (필수)") - ); List successResponseDescriptors = List.of( fieldWithPath("id").description("공모 id"), fieldWithPath("title").description("제목"), @@ -74,7 +71,6 @@ class GetOfferingDetail { .summary("공모 상세 조회") .description("공모 id를 통해 공모의 상세 정보를 조회합니다.") .pathParameters(pathParameterDescriptors) - .queryParameters(queryParameterDescriptors) .responseFields(successResponseDescriptors) .responseSchema(schema("OfferingDetailSuccessResponse")) .build(); @@ -82,7 +78,6 @@ class GetOfferingDetail { .summary("공모 상세 조회") .description("공모 id를 통해 공모의 상세 정보를 조회합니다.") .pathParameters(pathParameterDescriptors) - .queryParameters(queryParameterDescriptors) .responseFields(failResponseDescriptors) .responseSchema(schema("OfferingDetailFailResponse")) .build(); @@ -98,8 +93,8 @@ void setUp() { void should_responseOfferingDetail_when_givenOfferingId() { given(spec).log().all() .filter(document("get-offering-detail-success", resource(successSnippets))) + .cookies(cookieProvider.createCookies()) .pathParam("offering-id", 1) - .queryParam("member-id", 1) .when().get("/offerings/{offering-id}") .then().log().all() .statusCode(200); @@ -110,20 +105,8 @@ void should_responseOfferingDetail_when_givenOfferingId() { void should_throwException_when_invalidOffering() { given(spec).log().all() .filter(document("get-offering-detail-fail-invalid-offering", resource(failSnippets))) + .cookies(cookieProvider.createCookies()) .pathParam("offering-id", 100) - .queryParam("member-id", 1) - .when().get("/offerings/{offering-id}") - .then().log().all() - .statusCode(400); - } - - @DisplayName("유효하지 않은 사용자가 공모를 조회할 경우 예외가 발생한다.") - @Test - void should_throwException_when_invalidMember() { - given(spec).log().all() - .filter(document("get-offering-detail-fail-invalid-member", resource(failSnippets))) - .pathParam("offering-id", 1) - .queryParam("member-id", 100) .when().get("/offerings/{offering-id}") .then().log().all() .statusCode(400); @@ -214,7 +197,8 @@ class GetOfferingMeeting { @BeforeEach void setUp() { MemberEntity member = memberFixture.createMember(); - offeringFixture.createOffering(member); + OfferingEntity offering = offeringFixture.createOffering(member); + offeringMemberFixture.createProposer(member, offering); } @DisplayName("공모 id로 공모 일정 정보를 조회할 수 있다") @@ -222,6 +206,7 @@ void setUp() { void should_responseOfferingMeeting_when_givenOfferingId() { given(spec).log().all() .filter(document("get-offering-meeting-success", resource(successSnippets))) + .cookies(cookieProvider.createCookies()) .pathParam("offering-id", 1) .when().get("/offerings/{offering-id}/meetings") .then().log().all() @@ -233,6 +218,7 @@ void should_responseOfferingMeeting_when_givenOfferingId() { void should_throwException_when_invalidOffering() { given(spec).log().all() .filter(document("get-offering-meeting-fail-invalid-offering", resource(failSnippets))) + .cookies(cookieProvider.createCookies()) .pathParam("offering-id", 100) .when().get("/offerings/{offering-id}/meetings") .then().log().all() @@ -274,7 +260,6 @@ void should_responseOfferingFilter_when_givenOfferingId() { class CreateOffering { List requestDescriptors = List.of( - fieldWithPath("memberId").description("회원 id (필수)"), fieldWithPath("title").description("제목 (필수)"), fieldWithPath("productUrl").description("물품 구매 링크"), fieldWithPath("thumbnailUrl").description("사진 링크"), @@ -313,7 +298,6 @@ void setUp() { @Test void should_createOffering_when_givenOfferingCreateRequest() { OfferingSaveRequest request = new OfferingSaveRequest( - member.getId(), "공모 제목", "www.naver.com", "www.naver.com/favicon.ico", @@ -329,6 +313,7 @@ void should_createOffering_when_givenOfferingCreateRequest() { given(spec).log().all() .filter(document("create-offering-success", resource(successSnippets))) + .cookies(cookieProvider.createCookies()) .contentType(ContentType.JSON) .body(request) .when().post("/offerings") @@ -336,33 +321,6 @@ void should_createOffering_when_givenOfferingCreateRequest() { .statusCode(201); } - @DisplayName("유효하지 않은 사용자가 공모를 작성할 경우 예외가 발생한다.") - @Test - void should_throwException_when_invalidMember() { - OfferingSaveRequest request = new OfferingSaveRequest( - member.getId() + 100, - "공모 제목", - "www.naver.com", - "www.naver.com/favicon.ico", - 5, - 10000, - 2000, - "서울특별시 광진구 구의강변로 3길 11", - "상세주소아파트", - "구의동", - LocalDateTime.parse("2024-10-11T10:00:00"), - "내용입니다." - ); - - given(spec).log().all() - .filter(document("create-offering-fail-invalid-member", resource(failSnippets))) - .contentType(ContentType.JSON) - .body(request) - .when().post("/offerings") - .then().log().all() - .statusCode(400); - } - @DisplayName("요청 값에 빈값이 들어오는 경우 예외가 발생한다.") @Test void should_throwException_when_emptyValue() { @@ -377,12 +335,12 @@ void should_throwException_when_emptyValue() { null, null, null, - null, null ); given(spec).log().all() .filter(document("create-offering-fail-request-with-null", resource(failSnippets))) + .cookies(cookieProvider.createCookies()) .contentType(ContentType.JSON) .body(request) .when().post("/offerings") @@ -453,9 +411,6 @@ class UpdateCommentRoomStatus { List pathParameterDescriptors = List.of( parameterWithName("offering-id").description("공모 id (필수)") ); - List queryParameterDescriptors = List.of( - parameterWithName("member-id").description("회원 id (필수)") - ); List successResponseDescriptors = List.of( fieldWithPath("updatedStatus").description("변경된 상태") ); @@ -463,7 +418,6 @@ class UpdateCommentRoomStatus { .summary("댓글방 상태 변경") .description("댓글방의 상태를 변경합니다.") .pathParameters(pathParameterDescriptors) - .queryParameters(queryParameterDescriptors) .responseFields(successResponseDescriptors) .responseSchema(schema("CommentRoomStatusUpdateSuccessResponse")) .build(); @@ -471,7 +425,6 @@ class UpdateCommentRoomStatus { .summary("댓글방 상태 변경") .description("댓글방의 상태를 변경합니다.") .pathParameters(pathParameterDescriptors) - .queryParameters(queryParameterDescriptors) .responseFields(failResponseDescriptors) .responseSchema(schema("CommentRoomStatusUpdateFailResponse")) .build(); @@ -491,8 +444,8 @@ void setUp() { void should_updateStatus_when_givenOfferingIdAndMemberId() { RestAssured.given(spec).log().all() .filter(document("update-comment-room-status-success", resource(successSnippets))) + .cookies(cookieProvider.createCookies()) .pathParam("offering-id", offering.getId()) - .queryParam("member-id", member.getId()) .when().patch("/offerings/{offering-id}/status") .then().log().all() .statusCode(200); @@ -510,8 +463,8 @@ void should_updateStatus_when_givenOfferingIdAndMemberId() { void should_throwException_when_invalidOffering() { RestAssured.given(spec).log().all() .filter(document("update-comment-room-status-fail-invalid-offering", resource(failSnippets))) + .cookies(cookieProvider.createCookies()) .pathParam("offering-id", offering.getId() + 100) - .queryParam("member-id", member.getId()) .when().patch("/offerings/{offering-id}/status") .then().log().all() .statusCode(400); diff --git a/backend/src/test/java/com/zzang/chongdae/offeringmember/integration/OfferingMemberIntegrationTest.java b/backend/src/test/java/com/zzang/chongdae/offeringmember/integration/OfferingMemberIntegrationTest.java index 15312e51c..dd741aa0c 100644 --- a/backend/src/test/java/com/zzang/chongdae/offeringmember/integration/OfferingMemberIntegrationTest.java +++ b/backend/src/test/java/com/zzang/chongdae/offeringmember/integration/OfferingMemberIntegrationTest.java @@ -26,7 +26,6 @@ public class OfferingMemberIntegrationTest extends IntegrationTest { class Participate { List requestDescriptors = List.of( - fieldWithPath("memberId").description("회원 id (필수)"), fieldWithPath("offeringId").description("공모 id (필수)") ); ResourceSnippetParameters successSnippets = ResourceSnippetParameters.builder() @@ -49,7 +48,7 @@ class Participate { @BeforeEach void setUp() { - proposer = memberFixture.createMember("dora"); + proposer = memberFixture.createMember(); participant = memberFixture.createMember("poke"); offering = offeringFixture.createOffering(proposer); offeringMemberFixture.createProposer(proposer, offering); @@ -59,11 +58,11 @@ void setUp() { @Test void should_participateSuccess() { ParticipationRequest request = new ParticipationRequest( - participant.getId(), offering.getId() ); RestAssured.given(spec).log().all() .filter(document("participate-success", resource(successSnippets))) + .cookies(cookieProvider.createCookiesWithCi("poke5678")) .contentType(ContentType.JSON) .body(request) .when().post("/participations") @@ -75,27 +74,11 @@ void should_participateSuccess() { @Test void should_throwException_when_givenProposerParticipate() { ParticipationRequest request = new ParticipationRequest( - proposer.getId(), offering.getId() ); RestAssured.given(spec).log().all() .filter(document("participate-fail-my-offering", resource(failSnippets))) - .contentType(ContentType.JSON) - .body(request) - .when().post("/participations") - .then().log().all() - .statusCode(400); - } - - @DisplayName("유효하지 않은 사용자가 공모에 참여할 경우 예외가 발생한다.") - @Test - void should_throwException_when_invalidMember() { - ParticipationRequest request = new ParticipationRequest( - participant.getId() + 100, - offering.getId() - ); - RestAssured.given(spec).log().all() - .filter(document("participate-fail-invalid-member", resource(failSnippets))) + .cookies(cookieProvider.createCookies()) .contentType(ContentType.JSON) .body(request) .when().post("/participations") @@ -107,11 +90,11 @@ void should_throwException_when_invalidMember() { @Test void should_throwException_when_invalidOffering() { ParticipationRequest request = new ParticipationRequest( - participant.getId(), offering.getId() + 100 ); RestAssured.given(spec).log().all() .filter(document("participate-fail-invalid-offering", resource(failSnippets))) + .cookies(cookieProvider.createCookiesWithCi("poke5678")) .contentType(ContentType.JSON) .body(request) .when().post("/participations") @@ -123,11 +106,11 @@ void should_throwException_when_invalidOffering() { @Test void should_throwException_when_emptyValue() { ParticipationRequest request = new ParticipationRequest( - null, null ); RestAssured.given(spec).log().all() .filter(document("participate-fail-request-with-null", resource(failSnippets))) + .cookies(cookieProvider.createCookiesWithCi("poke5678")) .contentType(ContentType.JSON) .body(request) .when().post("/participations") diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml index 9f2ba8838..a39820fd9 100644 --- a/backend/src/test/resources/application-test.yml +++ b/backend/src/test/resources/application-test.yml @@ -11,3 +11,10 @@ spring: properties: hibernate: format_sql: true +security: + jwt: + token: + access-secret-key: accessSecretKey + refresh-secret-key: refreshSecretKey + access-token-expired: 30m + refresh-token-expired: 14d diff --git a/backend/src/test/resources/static/nickname/adjectives.txt b/backend/src/test/resources/static/nickname/adjectives.txt new file mode 100644 index 000000000..af4b79ff0 --- /dev/null +++ b/backend/src/test/resources/static/nickname/adjectives.txt @@ -0,0 +1 @@ +춤추는,달리는,노래하는,사냥하는,지키는,전사,용감한,지혜로운,강한,빠른,조용한,날아다니는,헤엄치는,뛰어다니는,웃는,슬퍼하는,생각하는,꿈꾸는,사랑하는,기도하는,멋진,아름다운,자랑스러운,소중한,힘찬,빛나는,어두운,화려한,단단한,부드러운,귀여운,강렬한,순수한,고요한,신비한,용맹한,차가운,따뜻한,반짝이는,흐르는,가벼운,무거운,흔들리는,날렵한,느린,신속한,강인한,다정한,예민한,온화한,재빠른,굳건한,우직한,유쾌한,의연한,담담한,근엄한,차분한,겸손한,헌신적인,대담한,기민한,예리한,능숙한,익살스러운,창의적인,도전적인,정직한,희망찬,용서하는,배려하는,진실된,정열적인,활기찬,우아한,열정적인,사려깊은,독창적인,성실한,신중한,침착한,냉철한,열렬한,엄격한,고집스러운,단호한,느긋한,천진난만한,호기심많은,탐구하는,분석적인,혁신적인,서있는,앉아있는,누워있는,달콤한,쌉싸름한,매콤한,향기로운,상쾌한,청량한,푸근한,촉촉한,뽀송뽀송한,포근한,찬란한,황홀한,짜릿한,아릿한,씩씩한,산뜻한,선명한,생생한,활발한,용기있는,모험적인,신비로운,영롱한,눈부신,고독한,슬픈,기쁜,행복한,즐거운,설레는,기대하는,뿌듯한,흐뭇한,만족스러운,부지런한,당당한,자신있는,평화로운,만족한,흥미로운,매혹적인,기분좋은,상냥한,긍정적인,의심하는,신뢰하는,믿음직한,든든한,편안한,안정적인,평온한,당찬,과감한,확고한,결단력있는,책임감있는,신뢰할수있는,참을성있는,인내하는,예의바른,배려깊은,이해심있는,너그러운,친절한,애정있는,자비로운,은혜로운,강직한,꼼꼼한,기품있는,밝은,고운,자상한,정다운,사근한,아늑한,따사로운,생기있는,호탕한,소박한,맑은,깨끗한,명랑한,존경하는,인내심있는,신뢰할수있는,격려하는,이끄는,희생적인,자부심있는,상상력있는,직관적인,날카로운,재치있는,명석한,영리한,현명한,이성적인,통찰력있는,탐구적인,지적인,학구적인,학문적인,박식한,전문적인,기술적인,창조적인,예술적인,감성적인,음악적인,문학적인,철학적인,사색적인,협력적인,친화적인,공감하는,존중하는,포용적인,개방적인,유연한,적응력있는,민첩한,다재다능한,진취적인,존재하지않는,섹시한,졸린,화난,과식하는,욕망의,뜨거운,어여쁜,재미있는,기억이안나는,돈이많은,우등생,공부하는,밥을먹는,커피를마시는,문제아,홍차를좋아하는,날아가는,발냄새나는,숱이많은,만두를먹는,앙증맞은,거대한,향기나는,미세한,운동을잘하는,독서광,게임을좋아하는,게임을잘하는,더운,추운,시원한,적당한,네모난,둥글둥글한,순간이동하는,날렵한,야생의,똑똑한,반짝거리는,제멋대로구는,성공한,출세한,이타적인,커피를좋아하는,야심찬,이기적인,엉뚱한,세련된,발라드를좋아하는,장난스러운,짓궂은,힙합을좋아하는,진지한,눈이침침한,말이없는,파인애플피자를좋아하는,명령하는,잠자는숲속의,기타치는,피시방에자주가는,친구가없는,이유식을먹는,턱받이를한,끊임없이먹는,소설쓰는,휴가간 diff --git a/backend/src/test/resources/static/nickname/nouns.txt b/backend/src/test/resources/static/nickname/nouns.txt new file mode 100644 index 000000000..92a8fbf55 --- /dev/null +++ b/backend/src/test/resources/static/nickname/nouns.txt @@ -0,0 +1 @@ +해,달,강,산,나무,바람,구름,별,불,물,꽃,새,호랑이,용,사자,고래,독수리,늑대,여우,곰,사슴,토끼,부엉이,까마귀,참새,매,황소,말,개,고양이,돼지,소,양,닭,거북이,두더지,원숭이,고릴라,코끼리,코뿔소,하마,바다,강아지,올빼미,두루미,까치,앵무새,나비,벌,개미,거미,나무늘보,고슴도치,오소리,공룡,피라미,상어,연어,새우,가재,붕어,잉어,돌고래,참치,연꽃,백합,장미,튤립,국화,해바라기,민들레,무궁화,진달래,철쭉,수선화,제비꽃,나팔꽃,달맞이꽃,제비,학,봉황,비둘기,갈매기,파랑새,물총새,갈색곰,팬더,자이언트팬더,미어캣,여우원숭이,플라밍고,백조,매미,방울새,강산,초원,사막,폭포,숲,눈,우주,천둥,번개,저녁,아침,새벽,황혼,새벽녘,보름달,은하수,해돋이,해질녘,태양,소나기,땅,언덕,계곡,늪,목초지,사파리,정글,밀림,산맥,협곡,절벽,해안,해변,모래사장,바위,암석,산호,해조류,유령,신령,선녀,도깨비,요정,천사,악마,영혼,망령,정령,초록,파랑,빨강,노랑,주황,보라,분홍,회색,흰색,검정,금색,은색,청록,연두,다홍,진홍,남색,청색,미색,담홍,담청,옥색,주황색,갈색,하늘색,청명,무지개,해골,드래곤,유니콘,피닉스,세이렌,메두사,페가수스,히드라,키메라,켄타우로스,하피,그리핀,미노타우르스,드라큘라,늑대인간,프랑켄슈타인,좀비,스켈레톤,레이스,벤시,오로라,자작나무,붉은노을,파도,용암,황금빛,아지랑이,서리,이슬,메아리,흙,잎사귀,뿌리,가시,씨앗,모래,산들바람,비,우박,눈보라,폭풍,폭우,장마,노을,여명,적막,어둠,맑음,흐림,안개,연무,먼지,태풍,허리케인,모래바람,진눈깨비,미풍,강풍,돌풍,눈발,일몰,일출,청둥오리,원앙,왜가리,황새,갈대,억새,연못,호수,시냇물,개천,웅덩이,동굴,바위산,평원,사바나,초지,숲속,정원,공원,대나무숲,잔디밭,유채꽃,벚꽃,라일락,수국,작약,모란,천리향,매화,목련,감나무,배나무,사과나무,복숭아나무,포도나무,오렌지나무,레몬나무,밤나무,호두나무,은행나무,소나무,참나무,쿼카,악어,기린,오리,오리너구리,너구리,휴먼,침팬지,홍학,가마우지,카멜레온,달팽이,구렁이,이무기,얼룩말,불사조,디멘터,하이에나,맘모스,티라노사우르스,랩터,브라키오사우르스,햄스터,치타,익룡,멧돼지,산돼지,피글렛,캥거루,산토끼,쥐,기니피그,시골쥐,도시쥐,패럿,수달,북극곰,반달가슴곰,펭귄,남극곰,밍크,족제비,뱀,코브라,아나콘다,킹코브라,담비,타조,북극여우,알바트로스,오랑우탄,물범,코알라,하프물범,북극토끼,칠면조,코뿔바다오리,직박구리,황제펭귄,물개,판다,랫서판다,범고래,식인고래,개구리,물소,맹꽁이,우파루파,이구아나,염소,노새,당나귀,올챙이,병아리,살모사,하늘다람쥐,도롱뇽,퓨마,아르마딜로,다람쥐,알파카,일본자이언트날다람쥐,포메라니안,진돗개,웰시코기,말티즈,시베리안허스키,닥스훈트,리트리버,노르웨이숲,푸들,낙타,스피츠,삽살개,먼치킨,페르시안고양이,래그돌,스코티쉬폴드,러시안블루,공작새,쥐며느리,키위새,장수하늘소,흰긴수염고래,죠스,식인상어,아기상어,황금들창코원숭이,긴꼬리원숭이,안경원숭이,자라,표범,청설모,바비루사,빅풋,예티,알락꼬리여우원숭이,메추라기,스라소니,삵,카피바라,라마,딱따구리,기러기,스컹크,해태,구미호,인면조,개미핥기,대왕오징어,갑오징어,두억시니,샐러맨더,바실리스크,와이번,다오,마리드,배찌,디지니,우니,에띠,케피,로두마니,모스,마리오,루이지,피치,로젤리나,쿠파,키노피오,와리오,사일러스,헤카림,진,가렌,갈리오,갱플랭크,그라가스,그레이브즈,나르,나미,나서스,노틸러스,녹턴,누누,니달리,다리우스,다이애나,드레이븐,라이즈,라칸,람머스,럭스,럼블,레넥톤,레오나,렉사이,렐,그웬,렝가,루시안,룰루,르블랑,리신,리븐,바드,미스포츈,문도박사,마스터이,마오카이,말파이트,볼리베어,브라움,모르가나,모데카이저,브랜드,블리츠크랭크,비에고,빅토르,사미라,사이온,샤코,세나,세라핀,세주아니,세트,아리,아무무,신드라,시비르,신짜오,스카너,아이번,아지르,소라카,소나,쉔,애니비아,겐지,둠피스트,리퍼,맥크리,메이,바스티온,솜브라,시메트라,애쉬,에코,위도우메이커,정크랫,토르비욘,트레이서,파라,한조,라인하르트,레킹볼,로드호그,시그마,오리사,윈스턴,자리야,루시우,메르시,모이라,바티스트,브리기테,아나,젠야타,방갈로르,미라지,옥테인,레버넌트,호라이즌,퓨즈,블러드하운드,패스파인더,크립토,발키리,라이프라인,로바,지브롤터,코스틱,왓슨,램파트,소닉,테일즈,스랄,제이나,가로쉬,데스윙,발리라,마이에브,우서,렉사르,실바나스,말퓨리온,굴단,느조스,메디브,안두인,일리단,하이머딩거,피즈,피오라,피들스틱,판테온,파이크,티모,트위치,트위스티드페이트,트린다미어,트리스타나,트런들,고든,탐켄치,탈리야,탈론,타릭,킨드레드,키아나,클레드,퀸,코르키,코그모,케일,케인,케이틀린,케넨,칼리스타,카타리나,카직스,카이사,카시오페아,카서스,카사딘,카밀,카르마,초가스,징크스,질리언,직스,조이,제이스,제라스,제드,잭스,잔나,자크,자르반,일라오이,이즈리얼,이블린,이렐리아,유미,워윅,우르곳,우디르,요릭,요네,올라프,오른,오리아나,오공,엘리스,야스오,애니,알리스타,아펠리오스,아우렐리온솔,아트록스,아칼리,아크샨,마린,파이어뱃,벌쳐,시즈탱크,배틀크루져,저글링,히드라리스크,울트라리스크,럴커,메딕,고스트,하이템플러,다크템플러,리버,옵저버,스카웃,캐리어,질럿,아칸,다크아칸,드라군,커세어,뮤탈리스크,스커지,디바우러,가디언,공허충,말자하,카즈야,헤이하치,에디,알리사,간류,니나,안나,드라구노프,리리,리로이젠킨스,브라이언,스티브폭스,샤오유,아머킹,요시미츠,자피나,쿠니미츠,클라우디오,화랑,아이작,피터파커,토니스타크,하워드스타크,모건스타크,해피호건,페퍼포츠,오베디아스탠,쟈비스,프라이데이,윌리엄리바,쿠엔틴벡,호인센,크리스틴에버하트,이반반코,토르오딘손,나타샤로마노프,페기카터,제인포스터,클린트바튼,베티로스,릭메이슨,로라바튼,행크핌,캐시랭,루이스,메이파커,네드,리즈,베티브랜트,비전,제임스로즈,샘윌슨,완다막시모프,스콧랭,트촬라,버키반즈,피에트로막시모프,호프밴다인,캐럴댄버스,옥토옥타비우스,멜린다메이,슈리,은죠부,오코예,나키아,음바쿠,쥬리,에인션트원,케실리우스,스티븐스트레인지,웡,칼모르도,도르마무,맷머독,제시카존스,프랭크캐슬,빌리루소,알렉산더피어스,울트론,로키오딘슨,헬라,오딘보르슨,헤임달,피터퀼,가모라,드랙스,로켓라쿤,그루트,맨티스,네뷸라,타노스,길가메쉬,스타폭스,킨고,에이잭,치타우리,에고,노웨어,닉퓨리,필콜슨,브루스배너,아르님졸라,마리아힐,알드리치킬리언,마야한센,샹치,만다린,드루이그,데이지존슨,리오피츠,젬마시몬스,정복자캉,트촤카,율리시스클로,욘두우돈타,로난,파이리,꼬부기,피카츄,라이츄,버터플,이상해씨,리자몽,거북왕,캐터피,독침붕,피죤,꼬렛,구구,깨비드릴조,아보,모래두지,고지,니드퀸,니드킹,식스테일,나인테일,픽시,삐삐,주뱃,푸린,뚜벅쵸,디그다,고라파덕,성원숭,윈디,발챙이,슈륙챙이,케이시,윤겔라,알통몬,우츠동,모다피,왕눈해,꼬마돌,롱스톤,야돈,코일,파오리,두두,쥬쥬,질퍽이,파르셀,고오스,팬텀,슬리퍼,크랩,킹크랩,찌리리공,붐볼,아라리,나시,탕구리,홍수몬,시라소몬,내루미,또가스,코뿌리,럭키,쏘드라,콘치,별가사리,아쿠스타,마임맨,스라크,루주라,마그마,잉어킹,갸라도스,라프라스,메타몽,이브이,쥬피썬더,폴리곤,부스터,투구푸스,프테라,잠만보,프리져,썬더,미뇽,망나뇽,뮤츠,뮤,치코리타,토게피,세레비,칠색조,뿔카노,에레브,쁘사이저,켄타로스,샤미드,암나이트,신뇽,질뻐기