diff --git a/.github/workflows/frontend-deploy-dev.yml b/.github/workflows/frontend-deploy-dev.yml index faeedacbe..22dd7f7d2 100644 --- a/.github/workflows/frontend-deploy-dev.yml +++ b/.github/workflows/frontend-deploy-dev.yml @@ -2,8 +2,11 @@ name: Frontend Deploy to Dev on: push: - branches: [develop] - paths: frontend/** + branches: + - develop + paths: + - frontend/** + - .github/** jobs: build-dockerfile: @@ -18,8 +21,24 @@ jobs: with: node-version: '18.x' + - name: Cache Yarn global cache + uses: actions/cache@v3 + with: + path: '**/.yarn' + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Cache Yarn project cache + uses: actions/cache@v3 + with: + path: '**/.yarn/cache' + key: ${{ runner.os }}-yarn-project-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-project- + - name: Install dependencies - run: yarn + run: yarn install --immutable - name: Set up QEMU uses: docker/setup-qemu-action@v2 diff --git a/.github/workflows/frontend-deploy-prod.yml b/.github/workflows/frontend-deploy-prod.yml index 07a457806..39d02463a 100644 --- a/.github/workflows/frontend-deploy-prod.yml +++ b/.github/workflows/frontend-deploy-prod.yml @@ -2,8 +2,11 @@ name: Frontend Deploy to Prod on: push: - branches: [main] - paths: frontend/** + branches: + - main + paths: + - frontend/** + - .github/** jobs: build-dockerfile: @@ -18,8 +21,24 @@ jobs: with: node-version: '18.x' + - name: Cache Yarn global cache + uses: actions/cache@v3 + with: + path: '**/.yarn' + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Cache Yarn project cache + uses: actions/cache@v3 + with: + path: '**/.yarn/cache' + key: ${{ runner.os }}-yarn-project-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-project- + - name: Install dependencies - run: yarn + run: yarn install --immutable - name: Set up QEMU uses: docker/setup-qemu-action@v2 diff --git a/.github/workflows/frontend-storybook-deploy.yml b/.github/workflows/frontend-storybook-deploy.yml new file mode 100644 index 000000000..5127cdf7e --- /dev/null +++ b/.github/workflows/frontend-storybook-deploy.yml @@ -0,0 +1,73 @@ +name: Frontend Storybook Deploy To S3 + +on: + push: + branches: + - develop + paths: + - frontend/** + - .github/** + +jobs: + build: + runs-on: ubuntu-22.04 + defaults: + run: + working-directory: ./frontend + concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + + steps: + - name: Use repository source + uses: actions/checkout@v3 + + - name: Use node.js + uses: actions/setup-node@v3 + with: + node-version: 18.x + + - name: Cache Yarn global cache + uses: actions/cache@v3 + with: + path: '**/.yarn' + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Cache Yarn project cache + uses: actions/cache@v3 + with: + path: '**/.yarn/cache' + key: ${{ runner.os }}-yarn-project-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-project- + + - name: Install dependencies + run: yarn install --immutable + + - name: Build Storybook + run: yarn build:sb + + - name: Upload storybook build files to temp artifact + uses: actions/upload-artifact@v3 + with: + name: Storybook + path: frontend/storybook-static + deploy: + needs: build + runs-on: front-dev-server + steps: + - name: Remove previous version app + working-directory: . + run: rm -rf frontend/storybook + + - name: Download the built file to AWS EC2 + uses: actions/download-artifact@v3 + with: + name: Storybook + path: frontend/storybook + + - name: Upload to S3 + run: | + aws s3 sync frontend/storybook s3://2023-team-project/2023-zipgo/storybook --delete diff --git a/README.md b/README.md index 3f9cc0cee..5560f439c 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,43 @@ -# 2023-zipgo +

+ 집고 README 배너 +

+react v18.2.0 + + +mysql v8.0.34 +

-## 멤버 +**초보 집사의 반려동물 식품 선택을 도와주는 서비스**, 집사의고민입니다. -| Frontend | Frontend | Frontend | -| :--------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------: | -| 첵스 | 노아이즈 | 에디 | -| [첵스](https://github.com/HyeryongChoi) | [노아이즈](https://github.com/n0eyes) | [에디](https://github.com/ksone02) +전문가들의 사료 선택 기준은 무엇일까요? +다양한 전문가들이 인정하는 [좋은 사료 선택 기준]([https://translucent-mallet-426.notion.site/dae305b85c8146399d7de6a0e74b773d](https://www.notion.so/dae305b85c8146399d7de6a0e74b773d?pvs=21)), **집사의 고민**에서 확인하세요! -| Backend | Backend | Backend | Backend | -|:--------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------:|:----------------------------------:| :----------------------------------:| -| 가비 | 무민 | 베베 | 로지 | -| [가비](https://github.com/iamjooon2) | [무민](https://github.com/parkmuhyeun) | [베베](https://github.com/wonyongChoi05) | [로지](https://github.com/kyY00n) +- [zipgo.pet](https://zipgo.pet) +- [zipgo.wiki](https://github.com/woowacourse-teams/2023-zipgo/wiki) + +# Skills + +### Frontend Tech Stack +![Frontend Tech Stacks](https://github.com/woowacourse-teams/2023-zipgo/assets/24777828/4372f950-d29e-46a0-807c-79d78a7f6913) + +### Backend Tech Stack +![Backend Tech Stacks](https://github.com/woowacourse-teams/2023-zipgo/assets/24777828/a4643676-e669-43f5-9bb4-d2f4e92ed470) + +# Infra Structure +![Zigo Infra](https://github.com/woowacourse-teams/2023-zipgo/assets/24777828/09847ae1-2aac-41a1-9302-4a2b49a284e0) + + +### Frontend Infra Structure +![image](https://github.com/woowacourse-teams/2023-zipgo/assets/24777828/eac9e8af-2df3-486a-9812-6a39ab36eb4e) + +### Backend Infra Structure +![image](https://github.com/woowacourse-teams/2023-zipgo/assets/24777828/c6b9ec96-1301-4088-98d1-1e1ec1d3b334) + + +# Member + +| [첵스](https://github.com/HyeryongChoi) | [노아이즈](https://github.com/n0eyes) | [에디](https://github.com/ksone02) | [가비](https://github.com/iamjooon2) | [무민](https://github.com/parkmuhyeun) | [베베](https://github.com/wonyongChoi05) | [로지](https://github.com/kyY00n) | +| :----------------------------------------------------------: | :----------------------------------------------------------: | :----------------------------------------------------------: | :----------------------------------------------------------: | :----------------------------------------------------------: | :----------------------------------------------------------: | :----------------------------------------------------------: | +| 첵스 | 노아이즈 | 에디 | 가비 | 무민 | 베베 | 로지 | +| Frontend | Frontend | Frontend | Backend | Backend | Backend | Backend | diff --git a/backend/build.gradle b/backend/build.gradle index 87c137236..a6a53ce12 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -52,6 +52,8 @@ dependencies { compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + implementation group: 'com.datadoghq', name: 'dd-trace-api', version: '1.21.0' + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" @@ -110,6 +112,7 @@ jacocoTestReport { '**/*Response*', '**/*Request*', '**/BaseTimeEntity', + '**/KakaoOAuthClient', '**/*Dto*', '**/S3*', '**/*Interceptor*', @@ -149,6 +152,7 @@ jacocoTestCoverageVerification { '*.*Exception*', '*.*Dto', '*.S3*', + '*.KakaoOAuthClient*', '*.*Response', '*.*Request', '*.BaseTimeEntity', diff --git a/backend/deploy-dev.sh b/backend/deploy-dev.sh deleted file mode 100644 index 0a468a106..000000000 --- a/backend/deploy-dev.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/sh -PROJECT_NAME=zipgo-backend - -echo '> development 서버를 실행합니다' -echo '> 현재 구동중인 애플리케이션 PID 확인' -CURRENT_PID=$(sudo netstat -lntp | grep 8080 | awk '{print $7}' | cut -d'/' -f1) - -echo "현재 구동중인 애플리케이션 PID: $CURRENT_PID" - -if [ -z "$CURRENT_PID" ]; then - echo "> 현재 구동중인 애플리케이션이 없으므로 종료하지 않습니다." -else - echo "> kill -15 $CURRENT_PID" - sudo kill -15 $CURRENT_PID - sleep 5 - if ps -p $CURRENT_PID > /dev/null; then - echo "> 애플리케이션을 다시 종료합니다." - sudo kill -9 $CURRENT_PID - else - echo "> 프로세스 아이디 $CURRENT_PID 가 성공적으로 종료되었습니다." - fi -fi - -sudo chmod +x ~/zipgo-backend-0.0.1-SNAPSHOT.jar -sudo nohup java -jar \ --Dspring.profiles.active=dev \ --Dspring.config.import=file:/home/ubuntu/env.properties \ -~/zipgo-backend-0.0.1-SNAPSHOT.jar > ~/application.log 2>&1 & diff --git a/backend/deploy-prod.sh b/backend/deploy-prod.sh deleted file mode 100644 index d7dc8df90..000000000 --- a/backend/deploy-prod.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/sh -PROJECT_NAME=zipgo-backend - -echo '> production 서버를 실행합니다' -echo '> 현재 구동중인 애플리케이션 PID 확인' -CURRENT_PID=$(sudo netstat -lntp | grep 8080 | awk '{print $7}' | cut -d'/' -f1) - -echo "현재 구동중인 애플리케이션 PID: $CURRENT_PID" - -if [ -z "$CURRENT_PID" ]; then - echo "> 현재 구동중인 애플리케이션이 없으므로 종료하지 않습니다." -else - echo "> kill -15 $CURRENT_PID" - sudo kill -15 $CURRENT_PID - sleep 5 - if ps -p $CURRENT_PID > /dev/null; then - echo "> 애플리케이션을 다시 종료합니다." - sudo kill -9 $CURRENT_PID - else - echo "> 프로세스 아이디 $CURRENT_PID 가 성공적으로 종료되었습니다." - fi -fi - -sudo chmod +x ~/zipgo-backend-0.0.1-SNAPSHOT.jar -sudo nohup java -jar \ --Dspring.profiles.active=prod \ --Dspring.config.import=file:/home/ubuntu/env.properties \ -~/zipgo-backend-0.0.1-SNAPSHOT.jar > ~/application.log 2>&1 & diff --git a/backend/src/main/java/zipgo/aspect/ConnectionProxyHandler.java b/backend/src/main/java/zipgo/aspect/ConnectionProxyHandler.java new file mode 100644 index 000000000..0db7678a9 --- /dev/null +++ b/backend/src/main/java/zipgo/aspect/ConnectionProxyHandler.java @@ -0,0 +1,40 @@ +package zipgo.aspect; + +import org.springframework.web.context.request.RequestContextHolder; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; + +public class ConnectionProxyHandler implements InvocationHandler { + + private static final String QUERY_PREPARE_STATEMENT = "prepareStatement"; + + private final Object connection; + private final QueryCounter queryCounter; + + public ConnectionProxyHandler(Object connection, QueryCounter queryCounter) { + this.connection = connection; + this.queryCounter = queryCounter; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + countQuery(method); + return method.invoke(connection, args); + } + + private void countQuery(Method method) { + if (isPrepareStatement(method) && isRequest()) { + queryCounter.increaseCount(); + } + } + + private boolean isPrepareStatement(Method method) { + return method.getName().equals(QUERY_PREPARE_STATEMENT); + } + + private boolean isRequest() { + return RequestContextHolder.getRequestAttributes() != null; + } + +} diff --git a/backend/src/main/java/zipgo/aspect/QueryCounter.java b/backend/src/main/java/zipgo/aspect/QueryCounter.java new file mode 100644 index 000000000..a0e47cf8e --- /dev/null +++ b/backend/src/main/java/zipgo/aspect/QueryCounter.java @@ -0,0 +1,18 @@ +package zipgo.aspect; + +import lombok.Getter; +import org.springframework.stereotype.Component; +import org.springframework.web.context.annotation.RequestScope; + +@Getter +@Component +@RequestScope +public class QueryCounter { + + private int count; + + public void increaseCount() { + count++; + } + +} diff --git a/backend/src/main/java/zipgo/aspect/QueryCounterAop.java b/backend/src/main/java/zipgo/aspect/QueryCounterAop.java new file mode 100644 index 000000000..dd16c27e4 --- /dev/null +++ b/backend/src/main/java/zipgo/aspect/QueryCounterAop.java @@ -0,0 +1,29 @@ +package zipgo.aspect; + +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Proxy; + +@Aspect +@Component +@RequiredArgsConstructor +public class QueryCounterAop { + + private final QueryCounter queryCounter; + + @Around("execution(* javax.sql.DataSource.getConnection(..))") + public Object getConnection(ProceedingJoinPoint proceedingJoinPoint) throws Throwable { + Object connection = proceedingJoinPoint.proceed(); + + return Proxy.newProxyInstance( + connection.getClass().getClassLoader(), + connection.getClass().getInterfaces(), + new ConnectionProxyHandler(connection, queryCounter) + ); + } + +} diff --git a/backend/src/main/java/zipgo/auth/application/AuthService.java b/backend/src/main/java/zipgo/auth/application/AuthService.java index 4a872710e..3e0abaad8 100644 --- a/backend/src/main/java/zipgo/auth/application/AuthService.java +++ b/backend/src/main/java/zipgo/auth/application/AuthService.java @@ -4,6 +4,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import zipgo.auth.application.dto.OAuthMemberResponse; +import zipgo.auth.domain.RefreshToken; +import zipgo.auth.dto.TokenDto; +import zipgo.auth.domain.repository.RefreshTokenRepository; import zipgo.auth.support.JwtProvider; import zipgo.member.domain.Member; import zipgo.member.domain.repository.MemberRepository; @@ -13,18 +16,38 @@ @RequiredArgsConstructor public class AuthService { - private final OAuthClient oAuthClient; private final JwtProvider jwtProvider; private final MemberRepository memberRepository; + private final RefreshTokenRepository refreshTokenRepository; - public String createToken(String authCode) { - String accessToken = oAuthClient.getAccessToken(authCode); - OAuthMemberResponse oAuthMemberResponse = oAuthClient.getMember(accessToken); - + public TokenDto login(OAuthMemberResponse oAuthMemberResponse) { Member member = memberRepository.findByEmail(oAuthMemberResponse.getEmail()) .orElseGet(() -> memberRepository.save(oAuthMemberResponse.toMember())); + return createTokens(member.getId()); + } + + private TokenDto createTokens(Long memberId) { + String accessToken = jwtProvider.createAccessToken(memberId); + String refreshToken = jwtProvider.createRefreshToken(); - return jwtProvider.create(String.valueOf(member.getId())); + refreshTokenRepository.deleteByMemberId(memberId); + refreshTokenRepository.save(new RefreshToken(memberId, refreshToken)); + + return TokenDto.of(accessToken, refreshToken); + } + + public String renewAccessTokenBy(String refreshToken) { + jwtProvider.validateParseJws(refreshToken); + + RefreshToken savedRefreshToken = refreshTokenRepository.getByToken(refreshToken); + Long memberId = savedRefreshToken.getMemberId(); + + return jwtProvider.createAccessToken(memberId); + } + + public void logout(Long memberId) { + refreshTokenRepository.deleteByMemberId(memberId); } } + diff --git a/backend/src/main/java/zipgo/auth/application/AuthServiceFacade.java b/backend/src/main/java/zipgo/auth/application/AuthServiceFacade.java new file mode 100644 index 000000000..33c3e7985 --- /dev/null +++ b/backend/src/main/java/zipgo/auth/application/AuthServiceFacade.java @@ -0,0 +1,29 @@ +package zipgo.auth.application; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import zipgo.auth.application.dto.OAuthMemberResponse; +import zipgo.auth.domain.OAuthClient; +import zipgo.auth.dto.TokenDto; + +@Service +@RequiredArgsConstructor +public class AuthServiceFacade { + + private final AuthService authService; + private final OAuthClient oAuthClient; + + public TokenDto login(String authCode, String redirectUri) { + OAuthMemberResponse oAuthMemberResponse = oAuthClient.request(authCode, redirectUri); + return authService.login(oAuthMemberResponse); + } + + public String renewAccessTokenBy(String refreshToken) { + return authService.renewAccessTokenBy(refreshToken); + } + + public void logout(Long memberId) { + authService.logout(memberId); + } + +} diff --git a/backend/src/main/java/zipgo/auth/domain/OAuthClient.java b/backend/src/main/java/zipgo/auth/domain/OAuthClient.java new file mode 100644 index 000000000..622d0cb84 --- /dev/null +++ b/backend/src/main/java/zipgo/auth/domain/OAuthClient.java @@ -0,0 +1,9 @@ +package zipgo.auth.domain; + +import zipgo.auth.application.dto.OAuthMemberResponse; + +public interface OAuthClient { + + OAuthMemberResponse request(String authCode, String redirectUri); + +} diff --git a/backend/src/main/java/zipgo/auth/application/OAuthClient.java b/backend/src/main/java/zipgo/auth/domain/OAuthMemberInfoClient.java similarity index 51% rename from backend/src/main/java/zipgo/auth/application/OAuthClient.java rename to backend/src/main/java/zipgo/auth/domain/OAuthMemberInfoClient.java index 7206114b3..d1a7329bb 100644 --- a/backend/src/main/java/zipgo/auth/application/OAuthClient.java +++ b/backend/src/main/java/zipgo/auth/domain/OAuthMemberInfoClient.java @@ -1,10 +1,8 @@ -package zipgo.auth.application; +package zipgo.auth.domain; import zipgo.auth.application.dto.OAuthMemberResponse; -public interface OAuthClient { - - String getAccessToken(String authCode); +public interface OAuthMemberInfoClient { OAuthMemberResponse getMember(String accessToken); diff --git a/backend/src/main/java/zipgo/auth/domain/OAuthTokenClient.java b/backend/src/main/java/zipgo/auth/domain/OAuthTokenClient.java new file mode 100644 index 000000000..e022e3da8 --- /dev/null +++ b/backend/src/main/java/zipgo/auth/domain/OAuthTokenClient.java @@ -0,0 +1,7 @@ +package zipgo.auth.domain; + +public interface OAuthTokenClient { + + String getAccessToken(String authCode, String redirectUri); + +} diff --git a/backend/src/main/java/zipgo/auth/domain/RefreshToken.java b/backend/src/main/java/zipgo/auth/domain/RefreshToken.java new file mode 100644 index 000000000..f0583f9ad --- /dev/null +++ b/backend/src/main/java/zipgo/auth/domain/RefreshToken.java @@ -0,0 +1,35 @@ +package zipgo.auth.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import zipgo.common.entity.BaseTimeEntity; + +import static lombok.EqualsAndHashCode.Include; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class RefreshToken extends BaseTimeEntity { + + @Id + @Include + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long memberId; + + private String token; + + public RefreshToken(Long memberId, String token) { + this.memberId = memberId; + this.token = token; + } + +} diff --git a/backend/src/main/java/zipgo/auth/domain/repository/RefreshTokenRepository.java b/backend/src/main/java/zipgo/auth/domain/repository/RefreshTokenRepository.java new file mode 100644 index 000000000..70e50692c --- /dev/null +++ b/backend/src/main/java/zipgo/auth/domain/repository/RefreshTokenRepository.java @@ -0,0 +1,20 @@ +package zipgo.auth.domain.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import zipgo.auth.domain.RefreshToken; +import zipgo.auth.exception.RefreshTokenNotFoundException; + +public interface RefreshTokenRepository extends JpaRepository { + + Optional findByToken(String token); + + default RefreshToken getByToken(String token) { + return findByToken(token) + .orElseThrow(RefreshTokenNotFoundException::new); + } + + void deleteByMemberId(Long memberId); + +} diff --git a/backend/src/main/java/zipgo/auth/dto/AccessTokenResponse.java b/backend/src/main/java/zipgo/auth/dto/AccessTokenResponse.java new file mode 100644 index 000000000..0848c2036 --- /dev/null +++ b/backend/src/main/java/zipgo/auth/dto/AccessTokenResponse.java @@ -0,0 +1,11 @@ +package zipgo.auth.dto; + +public record AccessTokenResponse ( + String accessToken +) { + + public static AccessTokenResponse from(String accessToken) { + return new AccessTokenResponse(accessToken); + } + +} diff --git a/backend/src/main/java/zipgo/auth/dto/LoginResponse.java b/backend/src/main/java/zipgo/auth/dto/LoginResponse.java new file mode 100644 index 000000000..678746f2e --- /dev/null +++ b/backend/src/main/java/zipgo/auth/dto/LoginResponse.java @@ -0,0 +1,21 @@ +package zipgo.auth.dto; + +import java.util.List; +import zipgo.member.domain.Member; +import zipgo.pet.domain.Pet; + +public record LoginResponse( + String accessToken, + String refreshToken, + AuthResponse authResponse +) { + + public static LoginResponse of(TokenDto tokenDto, Member member, List pets) { + return new LoginResponse( + tokenDto.accessToken(), + tokenDto.refreshToken(), + AuthResponse.of(member, pets) + ); + } + +} diff --git a/backend/src/main/java/zipgo/auth/dto/TokenDto.java b/backend/src/main/java/zipgo/auth/dto/TokenDto.java new file mode 100644 index 000000000..c83ffcc22 --- /dev/null +++ b/backend/src/main/java/zipgo/auth/dto/TokenDto.java @@ -0,0 +1,12 @@ +package zipgo.auth.dto; + +public record TokenDto( + String accessToken, + String refreshToken +) { + + public static TokenDto of(String accessToken, String refreshToken) { + return new TokenDto(accessToken, refreshToken); + } + +} diff --git a/backend/src/main/java/zipgo/auth/dto/TokenResponse.java b/backend/src/main/java/zipgo/auth/dto/TokenResponse.java deleted file mode 100644 index 17f5cc929..000000000 --- a/backend/src/main/java/zipgo/auth/dto/TokenResponse.java +++ /dev/null @@ -1,19 +0,0 @@ -package zipgo.auth.dto; - -import java.util.List; -import zipgo.member.domain.Member; -import zipgo.pet.domain.Pet; - -public record TokenResponse( - String accessToken, - AuthResponse authResponse -) { - - public static TokenResponse of(String token, Member member, List pets) { - return new TokenResponse( - token, - AuthResponse.of(member, pets) - ); - } - -} diff --git a/backend/src/main/java/zipgo/auth/exception/RefreshTokenNotFoundException.java b/backend/src/main/java/zipgo/auth/exception/RefreshTokenNotFoundException.java new file mode 100644 index 000000000..220b4666b --- /dev/null +++ b/backend/src/main/java/zipgo/auth/exception/RefreshTokenNotFoundException.java @@ -0,0 +1,14 @@ +package zipgo.auth.exception; + +import zipgo.common.error.ErrorCode; +import zipgo.common.error.ZipgoException; + +import static org.springframework.http.HttpStatus.NOT_FOUND; + +public class RefreshTokenNotFoundException extends ZipgoException { + + public RefreshTokenNotFoundException() { + super(new ErrorCode(NOT_FOUND, "존재하지 않는 리프레시 토큰입니다.")); + } + +} diff --git a/backend/src/main/java/zipgo/auth/exception/TokenMissingException.java b/backend/src/main/java/zipgo/auth/exception/TokenMissingException.java new file mode 100644 index 000000000..78ee8b59c --- /dev/null +++ b/backend/src/main/java/zipgo/auth/exception/TokenMissingException.java @@ -0,0 +1,14 @@ +package zipgo.auth.exception; + +import zipgo.common.error.ErrorCode; +import zipgo.common.error.ZipgoException; + +import static org.springframework.http.HttpStatus.UNAUTHORIZED; + +public class TokenMissingException extends ZipgoException { + + public TokenMissingException() { + super(new ErrorCode(UNAUTHORIZED, "토큰이 필요합니다.")); + } + +} diff --git a/backend/src/main/java/zipgo/auth/infra/kakao/KakaoOAuthClient.java b/backend/src/main/java/zipgo/auth/infra/kakao/KakaoOAuthClient.java index 65e4cab59..2309fb802 100644 --- a/backend/src/main/java/zipgo/auth/infra/kakao/KakaoOAuthClient.java +++ b/backend/src/main/java/zipgo/auth/infra/kakao/KakaoOAuthClient.java @@ -1,101 +1,23 @@ package zipgo.auth.infra.kakao; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.HttpClientErrorException; -import org.springframework.web.client.RestTemplate; -import zipgo.auth.application.OAuthClient; import zipgo.auth.application.dto.OAuthMemberResponse; -import zipgo.auth.exception.OAuthResourceNotBringException; -import zipgo.auth.exception.OAuthTokenNotBringException; -import zipgo.auth.infra.kakao.config.KakaoCredentials; -import zipgo.auth.infra.kakao.dto.KakaoMemberResponse; -import zipgo.auth.infra.kakao.dto.KakaoTokenResponse; - -import static java.util.Objects.requireNonNull; -import static org.springframework.http.HttpMethod.GET; -import static org.springframework.http.HttpMethod.POST; -import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED; +import zipgo.auth.domain.OAuthClient; +import zipgo.auth.domain.OAuthMemberInfoClient; +import zipgo.auth.domain.OAuthTokenClient; @Component @RequiredArgsConstructor public class KakaoOAuthClient implements OAuthClient { - private static final String ACCESS_TOKEN_URI = "https://kauth.kakao.com/oauth/token"; - private static final String USER_INFO_URI = "https://kapi.kakao.com/v2/user/me"; - private static final String GRANT_TYPE = "authorization_code"; - - private final KakaoCredentials kakaoCredentials; - private final RestTemplate restTemplate; - - @Override - public String getAccessToken(String authCode) { - HttpHeaders header = createRequestHeader(); - MultiValueMap body = createRequestBodyWithAuthCode(authCode); - HttpEntity> request = new HttpEntity<>(body, header); - ResponseEntity kakaoTokenResponse = getKakaoToken(request); - - return requireNonNull(requireNonNull(kakaoTokenResponse.getBody())).accessToken(); - } - - private HttpHeaders createRequestHeader() { - HttpHeaders header = new HttpHeaders(); - header.setContentType(APPLICATION_FORM_URLENCODED); - return header; - } - - private MultiValueMap createRequestBodyWithAuthCode(String authCode) { - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("grant_type", GRANT_TYPE); - body.add("client_id", kakaoCredentials.getClientId()); - body.add("redirect_uri", kakaoCredentials.getRedirectUri()); - body.add("client_secret", kakaoCredentials.getClientSecret()); - body.add("code", authCode); - return body; - } - - private ResponseEntity getKakaoToken(HttpEntity> request) { - try { - return restTemplate.exchange( - ACCESS_TOKEN_URI, - POST, - request, - KakaoTokenResponse.class - ); - } catch (HttpClientErrorException e) { - throw new OAuthTokenNotBringException(); - } - } + private final OAuthTokenClient kakaoOAuthTokenClient; + private final OAuthMemberInfoClient kakaoOAuthMemberInfoClient; @Override - public OAuthMemberResponse getMember(String accessToken) { - HttpEntity request = createRequest(accessToken); - ResponseEntity response = getKakaoMember(request); - return response.getBody(); - } - - private HttpEntity createRequest(String accessToken) { - HttpHeaders headers = new HttpHeaders(); - headers.setBearerAuth(accessToken); - return new HttpEntity<>(headers); - } - - private ResponseEntity getKakaoMember(HttpEntity request) { - try { - return restTemplate.exchange( - USER_INFO_URI, - GET, - request, - KakaoMemberResponse.class - ); - } catch (HttpClientErrorException e) { - throw new OAuthResourceNotBringException(); - } + public OAuthMemberResponse request(String authCode, String redirectUri) { + String accessToken = kakaoOAuthTokenClient.getAccessToken(authCode, redirectUri); + return kakaoOAuthMemberInfoClient.getMember(accessToken); } } diff --git a/backend/src/main/java/zipgo/auth/infra/kakao/KakaoOAuthMemberInfoClient.java b/backend/src/main/java/zipgo/auth/infra/kakao/KakaoOAuthMemberInfoClient.java new file mode 100644 index 000000000..2a22a8217 --- /dev/null +++ b/backend/src/main/java/zipgo/auth/infra/kakao/KakaoOAuthMemberInfoClient.java @@ -0,0 +1,50 @@ +package zipgo.auth.infra.kakao; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import zipgo.auth.application.dto.OAuthMemberResponse; +import zipgo.auth.domain.OAuthMemberInfoClient; +import zipgo.auth.exception.OAuthResourceNotBringException; +import zipgo.auth.infra.kakao.dto.KakaoMemberResponse; + +import static org.springframework.http.HttpMethod.GET; + +@Component +@RequiredArgsConstructor +public class KakaoOAuthMemberInfoClient implements OAuthMemberInfoClient { + + private static final String USER_INFO_URI = "https://kapi.kakao.com/v2/user/me"; + + private final RestTemplate restTemplate; + + @Override + public OAuthMemberResponse getMember(String accessToken) { + HttpEntity request = createRequest(accessToken); + ResponseEntity response = getKakaoMember(request); + return response.getBody(); + } + + private HttpEntity createRequest(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + return new HttpEntity<>(headers); + } + + private ResponseEntity getKakaoMember(HttpEntity request) { + try { + return restTemplate.exchange( + USER_INFO_URI, + GET, + request, + KakaoMemberResponse.class + ); + } catch (HttpClientErrorException e) { + throw new OAuthResourceNotBringException(); + } + } +} diff --git a/backend/src/main/java/zipgo/auth/infra/kakao/KakaoOAuthTokenClient.java b/backend/src/main/java/zipgo/auth/infra/kakao/KakaoOAuthTokenClient.java new file mode 100644 index 000000000..c0498efc4 --- /dev/null +++ b/backend/src/main/java/zipgo/auth/infra/kakao/KakaoOAuthTokenClient.java @@ -0,0 +1,70 @@ +package zipgo.auth.infra.kakao; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import zipgo.auth.domain.OAuthTokenClient; +import zipgo.auth.exception.OAuthTokenNotBringException; +import zipgo.auth.infra.kakao.config.KakaoCredentials; +import zipgo.auth.infra.kakao.dto.KakaoTokenResponse; + +import static java.util.Objects.requireNonNull; +import static org.springframework.http.HttpMethod.POST; +import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED; + + +@Component +@RequiredArgsConstructor +public class KakaoOAuthTokenClient implements OAuthTokenClient { + + private static final String ACCESS_TOKEN_URI = "https://kauth.kakao.com/oauth/token"; + private static final String GRANT_TYPE = "authorization_code"; + + private final RestTemplate restTemplate; + private final KakaoCredentials kakaoCredentials; + + @Override + public String getAccessToken(String authCode, String redirectUri) { + HttpHeaders header = createRequestHeader(); + MultiValueMap body = createRequestBodyWithAuthCode(authCode, redirectUri); + HttpEntity> request = new HttpEntity<>(body, header); + ResponseEntity kakaoTokenResponse = getKakaoToken(request); + + return requireNonNull(requireNonNull(kakaoTokenResponse.getBody())).accessToken(); + } + + private HttpHeaders createRequestHeader() { + HttpHeaders header = new HttpHeaders(); + header.setContentType(APPLICATION_FORM_URLENCODED); + return header; + } + + private MultiValueMap createRequestBodyWithAuthCode(String authCode, String redirectUri) { + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", GRANT_TYPE); + body.add("client_id", kakaoCredentials.getClientId()); + body.add("redirect_uri", redirectUri); + body.add("client_secret", kakaoCredentials.getClientSecret()); + body.add("code", authCode); + return body; + } + + private ResponseEntity getKakaoToken(HttpEntity> request) { + try { + return restTemplate.exchange( + ACCESS_TOKEN_URI, + POST, + request, + KakaoTokenResponse.class + ); + } catch (HttpClientErrorException e) { + throw new OAuthTokenNotBringException(); + } + } +} diff --git a/backend/src/main/java/zipgo/auth/presentation/AuthController.java b/backend/src/main/java/zipgo/auth/presentation/AuthController.java index 9ea42a20e..9f03e561b 100644 --- a/backend/src/main/java/zipgo/auth/presentation/AuthController.java +++ b/backend/src/main/java/zipgo/auth/presentation/AuthController.java @@ -1,6 +1,8 @@ package zipgo.auth.presentation; import java.util.List; + +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -8,11 +10,14 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import zipgo.auth.application.AuthService; +import zipgo.auth.application.AuthServiceFacade; +import zipgo.auth.dto.AccessTokenResponse; import zipgo.auth.dto.AuthCredentials; import zipgo.auth.dto.AuthResponse; -import zipgo.auth.dto.TokenResponse; +import zipgo.auth.dto.LoginResponse; +import zipgo.auth.dto.TokenDto; import zipgo.auth.support.JwtProvider; +import zipgo.auth.support.ZipgoTokenExtractor; import zipgo.member.application.MemberQueryService; import zipgo.member.domain.Member; import zipgo.pet.application.PetQueryService; @@ -23,18 +28,36 @@ @RequiredArgsConstructor public class AuthController { - private final AuthService authService; private final JwtProvider jwtProvider; + private final AuthServiceFacade authServiceFacade; private final MemberQueryService memberQueryService; private final PetQueryService petQueryService; @PostMapping("/login") - public ResponseEntity login(@RequestParam("code") String authCode) { - String token = authService.createToken(authCode); - String memberId = jwtProvider.getPayload(token); + public ResponseEntity login( + @RequestParam("code") String authCode, + @RequestParam("redirect-uri") String redirectUri + ) { + TokenDto tokenDto = authServiceFacade.login(authCode, redirectUri); + + String memberId = jwtProvider.getPayload(tokenDto.accessToken()); Member member = memberQueryService.findById(Long.valueOf(memberId)); List pets = petQueryService.readMemberPets(member.getId()); - return ResponseEntity.ok(TokenResponse.of(token, member, pets)); + + return ResponseEntity.ok(LoginResponse.of(tokenDto, member, pets)); + } + + @GetMapping("/refresh") + public ResponseEntity renewTokens(HttpServletRequest request) { + String refreshToken = ZipgoTokenExtractor.extract(request); + String accessToken = authServiceFacade.renewAccessTokenBy(refreshToken); + return ResponseEntity.ok(AccessTokenResponse.from(accessToken)); + } + + @PostMapping("/logout") + public ResponseEntity logout(@Auth AuthCredentials authCredentials) { + authServiceFacade.logout(authCredentials.id()); + return ResponseEntity.noContent().build(); } @GetMapping diff --git a/backend/src/main/java/zipgo/auth/presentation/JwtMandatoryArgumentResolver.java b/backend/src/main/java/zipgo/auth/presentation/JwtMandatoryArgumentResolver.java index 947b47301..a8bedc2c7 100644 --- a/backend/src/main/java/zipgo/auth/presentation/JwtMandatoryArgumentResolver.java +++ b/backend/src/main/java/zipgo/auth/presentation/JwtMandatoryArgumentResolver.java @@ -1,7 +1,8 @@ package zipgo.auth.presentation; -import jakarta.servlet.http.HttpServletRequest; import java.util.Objects; + +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.core.MethodParameter; import org.springframework.stereotype.Component; @@ -33,9 +34,9 @@ public Object resolveArgument( WebDataBinderFactory binderFactory ) { HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); - String token = BearerTokenExtractor.extract(Objects.requireNonNull(request)); - String id = jwtProvider.getPayload(token); + String accessToken = BearerTokenExtractor.extract(Objects.requireNonNull(request)); + String id = jwtProvider.getPayload(accessToken); return new AuthCredentials(Long.valueOf(id)); } diff --git a/backend/src/main/java/zipgo/auth/presentation/OptionalJwtArgumentResolver.java b/backend/src/main/java/zipgo/auth/presentation/OptionalJwtArgumentResolver.java index a438fe21c..20d279b8d 100644 --- a/backend/src/main/java/zipgo/auth/presentation/OptionalJwtArgumentResolver.java +++ b/backend/src/main/java/zipgo/auth/presentation/OptionalJwtArgumentResolver.java @@ -1,7 +1,8 @@ package zipgo.auth.presentation; -import jakarta.servlet.http.HttpServletRequest; import java.util.Objects; + +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.core.MethodParameter; import org.springframework.stereotype.Component; @@ -21,6 +22,8 @@ @RequiredArgsConstructor public class OptionalJwtArgumentResolver implements HandlerMethodArgumentResolver { + private static final String ZIPGO_HEADER = "Refresh"; + private final JwtProvider jwtProvider; @Override @@ -41,8 +44,8 @@ public Object resolveArgument( return null; } try { - String token = BearerTokenExtractor.extract(Objects.requireNonNull(request)); - String id = jwtProvider.getPayload(token); + String accessToken = BearerTokenExtractor.extract(Objects.requireNonNull(request)); + String id = jwtProvider.getPayload(accessToken); return new AuthCredentials(Long.valueOf(id)); } catch (TokenInvalidException e) { LoggingUtils.warn(e); diff --git a/backend/src/main/java/zipgo/auth/support/BearerTokenExtractor.java b/backend/src/main/java/zipgo/auth/support/BearerTokenExtractor.java index 006dcedeb..30b3a316d 100644 --- a/backend/src/main/java/zipgo/auth/support/BearerTokenExtractor.java +++ b/backend/src/main/java/zipgo/auth/support/BearerTokenExtractor.java @@ -3,6 +3,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.NoArgsConstructor; import zipgo.auth.exception.TokenInvalidException; +import zipgo.auth.exception.TokenMissingException; import static lombok.AccessLevel.PRIVATE; import static org.springframework.http.HttpHeaders.AUTHORIZATION; @@ -21,7 +22,7 @@ public static String extract(HttpServletRequest request) { private static void validate(String authorization) { if (authorization == null) { - throw new TokenInvalidException(); + throw new TokenMissingException(); } if (!authorization.matches(BEARER_JWT_REGEX)) { throw new TokenInvalidException(); diff --git a/backend/src/main/java/zipgo/auth/support/JwtProvider.java b/backend/src/main/java/zipgo/auth/support/JwtProvider.java index 164fef26b..f177f17ed 100644 --- a/backend/src/main/java/zipgo/auth/support/JwtProvider.java +++ b/backend/src/main/java/zipgo/auth/support/JwtProvider.java @@ -1,14 +1,15 @@ package zipgo.auth.support; +import java.util.Date; +import java.util.UUID; + import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.SignatureAlgorithm; -import io.jsonwebtoken.security.SignatureException; -import java.util.Date; import javax.crypto.SecretKey; import org.springframework.stereotype.Component; import zipgo.auth.exception.TokenExpiredException; @@ -22,24 +23,34 @@ public class JwtProvider { private final SecretKey key; - private final long validityInMilliseconds; + private final long accessTokenExpirationTime; + private final long refreshTokenExpirationTime; public JwtProvider(JwtCredentials jwtCredentials) { this.key = hmacShaKeyFor(jwtCredentials.getSecretKey().getBytes(UTF_8)); - this.validityInMilliseconds = jwtCredentials.getExpireLength(); + this.accessTokenExpirationTime = jwtCredentials.getAccessTokenExpirationTime(); + this.refreshTokenExpirationTime = jwtCredentials.getRefreshTokenExpirationTime(); + } + + public String createAccessToken(Long memberId) { + return createToken(memberId.toString(), accessTokenExpirationTime, key); } - public String create(String payload) { + private String createToken(String payload, long expireLength, SecretKey key) { Date now = new Date(); - Date validity = new Date(now.getTime() + validityInMilliseconds); + Date expiration = new Date(now.getTime() + expireLength); return Jwts.builder() .setSubject(payload) .setIssuedAt(now) - .setExpiration(validity) + .setExpiration(expiration) .signWith(key, SignatureAlgorithm.HS256) .compact(); } + public String createRefreshToken() { + return createToken(UUID.randomUUID().toString(), refreshTokenExpirationTime, key); + } + public String getPayload(String token) { return validateParseJws(token).getBody().getSubject(); } @@ -50,13 +61,9 @@ public Jws validateParseJws(String token) { .setSigningKey(key) .build() .parseClaimsJws(token); - } catch (MalformedJwtException e) { - throw new TokenInvalidException(); } catch (ExpiredJwtException e) { throw new TokenExpiredException(); - } catch (SignatureException e) { - throw new TokenInvalidException(); - } catch (Exception e) { + } catch (JwtException e) { throw new TokenInvalidException(); } } diff --git a/backend/src/main/java/zipgo/auth/support/RefreshTokenCookieProvider.java b/backend/src/main/java/zipgo/auth/support/RefreshTokenCookieProvider.java new file mode 100644 index 000000000..36866e245 --- /dev/null +++ b/backend/src/main/java/zipgo/auth/support/RefreshTokenCookieProvider.java @@ -0,0 +1,42 @@ +package zipgo.auth.support; + +import java.time.Duration; + +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; +import zipgo.common.config.JwtCredentials; + +@Component +public class RefreshTokenCookieProvider { + + public static final String REFRESH_TOKEN = "refreshToken"; + private static final String VALID_COOKIE_PATH = "/"; + private static final String LOGOUT_COOKIE_VALUE = ""; + private static final int LOGOUT_COOKIE_AGE = 0; + + private final long expirationTime; + + public RefreshTokenCookieProvider(JwtCredentials jwtCredentials) { + this.expirationTime = jwtCredentials.getRefreshTokenExpirationTime(); + } + + public ResponseCookie createCookie(String refreshToken) { + return ResponseCookie.from(REFRESH_TOKEN, refreshToken) + .sameSite("None") + .domain(".zipgo.pet") + .maxAge(Duration.ofMillis(expirationTime)) + .path(VALID_COOKIE_PATH) + .secure(true) + .httpOnly(true) + .build(); + } + + public ResponseCookie createLogoutCookie() { + return ResponseCookie.from(REFRESH_TOKEN, LOGOUT_COOKIE_VALUE) + .sameSite("None") + .domain(".zipgo.pet") + .maxAge(LOGOUT_COOKIE_AGE) + .build(); + } + +} diff --git a/backend/src/main/java/zipgo/auth/support/ZipgoTokenExtractor.java b/backend/src/main/java/zipgo/auth/support/ZipgoTokenExtractor.java new file mode 100644 index 000000000..6b32d41a9 --- /dev/null +++ b/backend/src/main/java/zipgo/auth/support/ZipgoTokenExtractor.java @@ -0,0 +1,31 @@ +package zipgo.auth.support; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import zipgo.auth.exception.TokenInvalidException; +import zipgo.auth.exception.TokenMissingException; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ZipgoTokenExtractor { + + private static final String ZIPGO_HEADER = "Refresh"; + private static final String ZIPGO_TYPE = "Zipgo "; + private static final String ZIPGO_JWT_REGEX = "^Zipgo [A-Za-z0-9-_=]+\\.[A-Za-z0-9-_=]+\\.?[A-Za-z0-9-_.+/=]*$"; + + public static String extract(HttpServletRequest request) { + String authorization = request.getHeader(ZIPGO_HEADER); + validate(authorization); + return authorization.replace(ZIPGO_TYPE, "").trim(); + } + + private static void validate(String authorization) { + if (authorization == null) { + throw new TokenMissingException(); + } + if (!authorization.matches(ZIPGO_JWT_REGEX)) { + throw new TokenInvalidException(); + } + } + +} diff --git a/backend/src/main/java/zipgo/common/config/JwtCredentials.java b/backend/src/main/java/zipgo/common/config/JwtCredentials.java index 925e14a88..d00902dc2 100644 --- a/backend/src/main/java/zipgo/common/config/JwtCredentials.java +++ b/backend/src/main/java/zipgo/common/config/JwtCredentials.java @@ -10,6 +10,7 @@ public class JwtCredentials { private final String secretKey; - private final long expireLength; + private final long accessTokenExpirationTime; + private final long refreshTokenExpirationTime; } diff --git a/backend/src/main/java/zipgo/common/config/WebConfig.java b/backend/src/main/java/zipgo/common/config/WebConfig.java index 7b86b7593..6f57f92fb 100644 --- a/backend/src/main/java/zipgo/common/config/WebConfig.java +++ b/backend/src/main/java/zipgo/common/config/WebConfig.java @@ -1,6 +1,7 @@ package zipgo.common.config; import java.util.List; + import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; @@ -11,8 +12,10 @@ import zipgo.auth.presentation.JwtMandatoryArgumentResolver; import zipgo.auth.presentation.OptionalJwtArgumentResolver; import zipgo.auth.support.JwtProvider; +import zipgo.common.interceptor.LoggingInterceptor; import static org.springframework.http.HttpHeaders.LOCATION; +import static org.springframework.http.HttpHeaders.SET_COOKIE; @Configuration @RequiredArgsConstructor @@ -23,22 +26,32 @@ public class WebConfig implements WebMvcConfigurer { private static final String MAIN_SERVER_DOMAIN = "https://zipgo.pet"; private static final String DEV_SERVER_DOMAIN = "https://dev.zipgo.pet"; private static final String FRONTEND_LOCALHOST = "http://localhost:3000"; + private static final String HTTPS_FRONTEND_LOCALHOST = "https://localhost:3000"; private final AuthInterceptor authInterceptor; + private final LoggingInterceptor loggingInterceptor; private final JwtProvider jwtProvider; @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(ALLOW_ALL_PATH) .allowedMethods(ALLOWED_METHODS) - .allowedOrigins(MAIN_SERVER_DOMAIN, DEV_SERVER_DOMAIN, FRONTEND_LOCALHOST) - .exposedHeaders(LOCATION); + .allowedOrigins( + MAIN_SERVER_DOMAIN, + DEV_SERVER_DOMAIN, + FRONTEND_LOCALHOST, + HTTPS_FRONTEND_LOCALHOST + ) + .allowCredentials(true) + .exposedHeaders(LOCATION, SET_COOKIE); } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/auth"); + + registry.addInterceptor(loggingInterceptor); } @Override diff --git a/backend/src/main/java/zipgo/common/interceptor/LoggingInterceptor.java b/backend/src/main/java/zipgo/common/interceptor/LoggingInterceptor.java new file mode 100644 index 000000000..84a5b75d0 --- /dev/null +++ b/backend/src/main/java/zipgo/common/interceptor/LoggingInterceptor.java @@ -0,0 +1,32 @@ +package zipgo.common.interceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; +import zipgo.aspect.QueryCounter; + +@Slf4j +@Component +@RequiredArgsConstructor +public class LoggingInterceptor implements HandlerInterceptor { + + private static final String QUERY_COUNT_LOG = "METHOD: {}, URL: {}, STATUS_CODE: {}, QUERY_COUNT: {}"; + private static final String QUERY_COUNT_WARN_LOG = "쿼리가 {}번 이상 실행되었습니다!!!"; + private static final int WARN_QUERY_COUNT= 8; + + private final QueryCounter queryCounter; + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, + Object handler, Exception ex) { + int queryCount = queryCounter.getCount(); + log.info(QUERY_COUNT_LOG, request.getMethod(), request.getRequestURI(), response.getStatus(), queryCount); + if (queryCount >= WARN_QUERY_COUNT) { + log.warn(QUERY_COUNT_WARN_LOG, WARN_QUERY_COUNT); + } + } + +} diff --git a/backend/src/main/java/zipgo/petfood/application/PetFoodQueryService.java b/backend/src/main/java/zipgo/petfood/application/PetFoodQueryService.java index a426af9c1..66d9735fb 100644 --- a/backend/src/main/java/zipgo/petfood/application/PetFoodQueryService.java +++ b/backend/src/main/java/zipgo/petfood/application/PetFoodQueryService.java @@ -1,5 +1,6 @@ package zipgo.petfood.application; +import datadog.trace.api.Trace; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -9,7 +10,6 @@ import zipgo.petfood.domain.PetFood; import zipgo.petfood.domain.PrimaryIngredient; import zipgo.petfood.domain.repository.FunctionalityRepository; -import zipgo.petfood.domain.repository.PetFoodRepository; import zipgo.petfood.domain.repository.PrimaryIngredientRepository; import zipgo.petfood.dto.request.FilterRequest; import zipgo.petfood.dto.response.FilterResponse; @@ -26,7 +26,6 @@ @Transactional(readOnly = true) public class PetFoodQueryService { - private final PetFoodRepository petFoodRepository; private final PetFoodQueryRepositoryImpl petFoodQueryRepository; private final BrandRepository brandRepository; private final FunctionalityRepository functionalityRepository; @@ -39,6 +38,7 @@ public GetPetFoodsResponse getPetFoodsByFilters(FilterRequest filterDto, Long la ); } + @Trace(resourceName = "식품 페이징 조회") private List getPagingPetFoods(FilterRequest filterDto, Long lastPetFoodId, int size) { return petFoodQueryRepository.findPagingPetFoods( filterDto.brands(), @@ -50,6 +50,7 @@ private List getPagingPetFoods(FilterRequest filterDto, ); } + @Trace(resourceName = "식품 페이징 카운트 조회") private Long getPetFoodsCount(FilterRequest filterDto) { return petFoodQueryRepository.findPetFoodsCount( filterDto.brands(), diff --git a/backend/src/main/java/zipgo/petfood/dto/request/PetFoodSelectRequest.java b/backend/src/main/java/zipgo/petfood/dto/request/PetFoodSelectRequest.java deleted file mode 100644 index 7eacd91e4..000000000 --- a/backend/src/main/java/zipgo/petfood/dto/request/PetFoodSelectRequest.java +++ /dev/null @@ -1,9 +0,0 @@ -package zipgo.petfood.dto.request; - -public record PetFoodSelectRequest( - String keyword, - String brand, - String primaryIngredients -) { - -} diff --git a/backend/src/main/java/zipgo/petfood/infra/persist/PetFoodQueryRepositoryImpl.java b/backend/src/main/java/zipgo/petfood/infra/persist/PetFoodQueryRepositoryImpl.java index 2387b7b7f..8ed1aed44 100644 --- a/backend/src/main/java/zipgo/petfood/infra/persist/PetFoodQueryRepositoryImpl.java +++ b/backend/src/main/java/zipgo/petfood/infra/persist/PetFoodQueryRepositoryImpl.java @@ -2,6 +2,7 @@ import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; +import datadog.trace.api.Trace; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -26,6 +27,7 @@ public class PetFoodQueryRepositoryImpl implements PetFoodQueryRepository { private final JPAQueryFactory queryFactory; + @Trace(resourceName = "식품 페이징 조회 쿼리") public List findPagingPetFoods( List brandsName, List standards, @@ -103,6 +105,7 @@ private BooleanExpression isContainFunctionalities(List functionalityLis .functionality.name.in(functionalityList); } + @Trace(resourceName = "식품 페이징 조회 카운트 쿼리") @Override public Long findPetFoodsCount( List brandsName, diff --git a/backend/src/main/java/zipgo/review/dto/response/type/RatingInfoResponse.java b/backend/src/main/java/zipgo/review/dto/response/type/RatingInfoResponse.java index 0dd9eabb8..67538d89b 100644 --- a/backend/src/main/java/zipgo/review/dto/response/type/RatingInfoResponse.java +++ b/backend/src/main/java/zipgo/review/dto/response/type/RatingInfoResponse.java @@ -1,7 +1,7 @@ package zipgo.review.dto.response.type; public record RatingInfoResponse( - String rating, + String name, int percentage ) { diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev1.yml similarity index 85% rename from backend/src/main/resources/application-dev.yml rename to backend/src/main/resources/application-dev1.yml index ac53ab2d9..b6ec30123 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev1.yml @@ -2,6 +2,7 @@ # port server: port: 8080 + shutdown: graceful --- # database @@ -44,7 +45,8 @@ oauth: # jwt jwt: secret-key: ${ZIPGO_SECRET_KEY} - expire-length: ${EXPIRE_LENGTH} + access-token-expiration-time: ${ACCESS_TOKEN_EXPIRATION_TIME} + refresh-token-expiration-time: ${REFRESH_TOKEN_EXPIRATION_TIME} --- # aws diff --git a/backend/src/main/resources/application-dev2.yml b/backend/src/main/resources/application-dev2.yml new file mode 100644 index 000000000..9ca2c392a --- /dev/null +++ b/backend/src/main/resources/application-dev2.yml @@ -0,0 +1,59 @@ +--- +# port +server: + port: 8081 + shutdown: graceful + +--- +# database +spring: + datasource: + driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy + url: ${JDBC_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + +--- +# jpa +spring: + jpa: + database: mysql + database-platform: org.hibernate.dialect.MySQL57Dialect + generate-ddl: true + hibernate: + ddl-auto: none + properties: + hibernate: + format_sql: true + show_sql: true + default_batch_fetch_size: 30 + defer-datasource-initialization: true + + sql: + init: + mode: never + +--- +# OAUTH +oauth: + kakao: + client-id: ${CLIENT_ID} + redirect-uri: ${REDIRECT_URI} + client-secret: ${CLIENT_SECRET} + +--- +# jwt +jwt: + secret-key: ${ZIPGO_SECRET_KEY} + access-token-expiration-time: ${ACCESS_TOKEN_EXPIRATION_TIME} + refresh-token-expiration-time: ${REFRESH_TOKEN_EXPIRATION_TIME} + +--- +# aws +cloud: + aws: + s3: + bucket: ${WOOTECO_BUCKET} + zipgo-directory: ${ZIPGO_DIRECTORY} + env: ${ENVIRONMENT_DIRECTORY} + image-url: ${ZIPGO_IMAGE_URL} diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml index 6acacbc66..18d4ac7ef 100644 --- a/backend/src/main/resources/application-local.yml +++ b/backend/src/main/resources/application-local.yml @@ -44,7 +44,8 @@ oauth: # jwt jwt: secret-key: ${ZIPGO_SECRET_KEY} - expire-length: ${EXPIRE_LENGTH} + access-token-expiration-time: ${ACCESS_TOKEN_EXPIRATION_TIME} + refresh-token-expiration-time: ${REFRESH_TOKEN_EXPIRATION_TIME} --- # aws diff --git a/backend/src/main/resources/application-local1.yml b/backend/src/main/resources/application-local1.yml deleted file mode 100644 index 0b54dda4e..000000000 --- a/backend/src/main/resources/application-local1.yml +++ /dev/null @@ -1,47 +0,0 @@ ---- -# port -server: - port: 8080 - ---- -# database -spring: - datasource: - driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy - url: jdbc:log4jdbc:mysql://localhost:13306/zipgo - username: root - password: root -# jpa - jpa: - database: mysql - database-platform: org.hibernate.dialect.MySQL57Dialect - generate-ddl: true - hibernate: - ddl-auto: none - properties: - hibernate: - format_sql: true - show_sql: true - default_batch_fetch_size: 30 -# open-in-view: false - defer-datasource-initialization: true - sql: - init: - mode: never -jwt: - secret-key: 027eb3f4aa36e178497290da0fa573ee - expire-length: 60480000000000000 -oauth: - kakao: - client-id: 027eb3f4aa36e178497290da0fa573ee - redirect-uri: http://localhost:3000/login - ---- - -cloud: - aws: - s3: - bucket: 2023-team-project - zipgo-directory-name: 2023-zipgo - pet-image-directory: prod/pet-image/ - image-url: https://image.zipgo.pet/ diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index b88e4090c..6c0c59a55 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -2,6 +2,7 @@ # port server: port: 8080 + shutdown: graceful --- # database @@ -40,7 +41,8 @@ oauth: # jwt jwt: secret-key: ${ZIPGO_SECRET_KEY} - expire-length: ${EXPIRE_LENGTH} + access-token-expiration-time: ${ACCESS_TOKEN_EXPIRATION_TIME} + refresh-token-expiration-time: ${REFRESH_TOKEN_EXPIRATION_TIME} --- # log diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 201e55321..edb06c216 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -2,7 +2,7 @@ # application spring: profiles: - active: dev + active: local --- #metric, log monitoring diff --git a/backend/src/test/java/zipgo/ZipgoApplicationTests.java b/backend/src/test/java/zipgo/ZipgoApplicationTests.java deleted file mode 100644 index fbbf9e809..000000000 --- a/backend/src/test/java/zipgo/ZipgoApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package zipgo; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class ZipgoApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/backend/src/test/java/zipgo/admin/application/AdminQueryServiceTest.java b/backend/src/test/java/zipgo/admin/application/AdminQueryServiceTest.java index 918e81c0f..1065b5041 100644 --- a/backend/src/test/java/zipgo/admin/application/AdminQueryServiceTest.java +++ b/backend/src/test/java/zipgo/admin/application/AdminQueryServiceTest.java @@ -1,15 +1,14 @@ package zipgo.admin.application; -import java.util.List; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import zipgo.admin.dto.FunctionalitySelectResponse; import zipgo.admin.dto.PetFoodReadResponse; +import zipgo.admin.dto.PrimaryIngredientSelectResponse; import zipgo.brand.domain.Brand; import zipgo.brand.domain.fixture.BrandFixture; import zipgo.brand.domain.repository.BrandRepository; -import zipgo.common.service.QueryServiceTest; +import zipgo.common.service.ServiceTest; import zipgo.petfood.domain.PetFood; import zipgo.petfood.domain.fixture.FunctionalityFixture; import zipgo.petfood.domain.fixture.PetFoodFixture; @@ -17,12 +16,13 @@ import zipgo.petfood.domain.repository.FunctionalityRepository; import zipgo.petfood.domain.repository.PetFoodRepository; import zipgo.petfood.domain.repository.PrimaryIngredientRepository; -import zipgo.admin.dto.PrimaryIngredientSelectResponse; + +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; -class AdminQueryServiceTest extends QueryServiceTest { +class AdminQueryServiceTest extends ServiceTest { @Autowired private FunctionalityRepository functionalityRepository; diff --git a/backend/src/test/java/zipgo/admin/presentation/AdminControllerMockTest.java b/backend/src/test/java/zipgo/admin/presentation/AdminControllerMockMvcTest.java similarity index 75% rename from backend/src/test/java/zipgo/admin/presentation/AdminControllerMockTest.java rename to backend/src/test/java/zipgo/admin/presentation/AdminControllerMockMvcTest.java index 68a19cede..402bad4c6 100644 --- a/backend/src/test/java/zipgo/admin/presentation/AdminControllerMockTest.java +++ b/backend/src/test/java/zipgo/admin/presentation/AdminControllerMockMvcTest.java @@ -1,31 +1,18 @@ package zipgo.admin.presentation; import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper; -import com.fasterxml.jackson.databind.ObjectMapper; + import java.nio.charset.StandardCharsets; import java.util.List; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator; + import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; -import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.test.web.servlet.MockMvc; -import zipgo.admin.application.AdminQueryService; -import zipgo.admin.application.AdminService; +import zipgo.common.acceptance.MockMvcTest; import zipgo.admin.dto.BrandCreateRequest; import zipgo.admin.dto.PetFoodCreateRequest; -import zipgo.auth.presentation.AuthInterceptor; -import zipgo.auth.presentation.JwtMandatoryArgumentResolver; -import zipgo.auth.support.JwtProvider; -import zipgo.image.application.ImageService; import zipgo.petfood.domain.fixture.PetFoodFixture; import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.resourceDetails; @@ -37,36 +24,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static zipgo.brand.domain.fixture.BrandFixture.무민_브랜드_생성_요청; -@AutoConfigureRestDocs -@ExtendWith(SpringExtension.class) -@SuppressWarnings("NonAsciiCharacters") -@WebMvcTest(controllers = AdminController.class) -@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) -class AdminControllerMockTest { - - @Autowired - private ObjectMapper objectMapper; - - @Autowired - private MockMvc mockMvc; - - @MockBean - private ImageService imageService; - - @MockBean - private AdminService adminService; - - @MockBean - private AdminQueryService adminQueryService; - - @MockBean - private JwtProvider jwtProvider; - - @MockBean - private AuthInterceptor authInterceptor; - - @MockBean - private JwtMandatoryArgumentResolver argumentResolver; +class AdminControllerMockMvcTest extends MockMvcTest { @Test void 브랜드를_생성하면_201이_반환된다() throws Exception { @@ -145,4 +103,4 @@ class 식품_생성 { } -} \ No newline at end of file +} diff --git a/backend/src/test/java/zipgo/auth/application/AuthServiceFacadeTest.java b/backend/src/test/java/zipgo/auth/application/AuthServiceFacadeTest.java new file mode 100644 index 000000000..4e5ee76e4 --- /dev/null +++ b/backend/src/test/java/zipgo/auth/application/AuthServiceFacadeTest.java @@ -0,0 +1,118 @@ +package zipgo.auth.application; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import zipgo.auth.application.dto.OAuthMemberResponse; +import zipgo.auth.application.fixture.MemberResponseSuccessFixture; +import zipgo.auth.domain.OAuthClient; +import zipgo.auth.dto.TokenDto; +import zipgo.auth.exception.OAuthResourceNotBringException; +import zipgo.auth.exception.OAuthTokenNotBringException; +import zipgo.auth.exception.TokenExpiredException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class AuthServiceFacadeTest { + + @Mock + private OAuthClient oAuthClient; + + @Mock + private AuthService authService; + + @InjectMocks + private AuthServiceFacade authServiceFacade; + + @Test + void 로그인에_성공하면_토큰을_발급한다() { + // given + OAuthMemberResponse oAuthMemberResponse = new MemberResponseSuccessFixture(); + when(oAuthClient.request("인가 코드", "리다이렉트 유알아이")) + .thenReturn(oAuthMemberResponse); + when(authService.login(oAuthMemberResponse)) + .thenReturn(TokenDto.of("생성된 엑세스 토큰", "생성된 리프레시 토큰")); + + // when + TokenDto 토큰 = authServiceFacade.login("인가 코드", "리다이렉트 유알아이"); + + // then + assertAll( + () -> assertThat(토큰.accessToken()).isEqualTo("생성된 엑세스 토큰"), + () -> assertThat(토큰.refreshToken()).isEqualTo("생성된 리프레시 토큰") + ); + } + + @Test + void 엑세스_토큰을_가져오지_못하면_예외가_발생한다() { + // given + when(oAuthClient.request("인가 코드", "리다이렉트 유알아이")) + .thenThrow(new OAuthTokenNotBringException()); + + // expect + assertThatThrownBy(() -> authServiceFacade.login("인가 코드", "리다이렉트 유알아이")) + .isInstanceOf(OAuthTokenNotBringException.class) + .hasMessageContaining("서드파티 서비스에서 토큰을 받아오지 못했습니다. 잠시후 다시 시도해주세요."); + } + + @Test + void 사용자_정보를_가져오지_못하면_예외가_발생한다() { + // given + when(oAuthClient.request("인가 코드", "리다이렉트 유알아이")) + .thenThrow(new OAuthResourceNotBringException()); + + // expect + assertThatThrownBy(() -> authServiceFacade.login("인가 코드", "리다이렉트 유알아이")) + .isInstanceOf(OAuthResourceNotBringException.class) + .hasMessageContaining("서드파티 서비스에서 정보를 받아오지 못했습니다. 잠시후 다시 시도해주세요"); + } + + + @Test + void 엑세스_토큰을_갱신할_수_있다() { + // given + when(authService.renewAccessTokenBy("리프레시 토큰")) + .thenReturn("갱신된 엑세스 토큰"); + // when + String 리프레시_토큰 = authServiceFacade.renewAccessTokenBy("리프레시 토큰"); + + // then + assertThat(리프레시_토큰).isNotEmpty(); + } + + @Test + void 만료된_엑세스_토큰은_갱신시_예외가_발생한다() { + // given + when(authService.renewAccessTokenBy("리프레시 토큰")) + .thenThrow(new TokenExpiredException()); + // expect + assertThatThrownBy(() -> authServiceFacade.renewAccessTokenBy("리프레시 토큰")) + .isInstanceOf(TokenExpiredException.class) + .hasMessageContaining("만료된 토큰입니다. 올바른 토큰으로 다시 시도해주세요."); + } + + @Test + void 로그아웃_할_수_있다() { + // given + Long memberId = 123L; + + // when + authServiceFacade.logout(memberId); + + // then + verify(authService, times(1)).logout(memberId); + } + +} diff --git a/backend/src/test/java/zipgo/auth/application/AuthServiceTest.java b/backend/src/test/java/zipgo/auth/application/AuthServiceTest.java index b50368f62..e2fe1f6d9 100644 --- a/backend/src/test/java/zipgo/auth/application/AuthServiceTest.java +++ b/backend/src/test/java/zipgo/auth/application/AuthServiceTest.java @@ -1,90 +1,116 @@ package zipgo.auth.application; -import java.util.Optional; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator; + 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.mock.mockito.MockBean; -import zipgo.auth.infra.kakao.KakaoOAuthClient; -import zipgo.auth.infra.kakao.dto.KakaoMemberResponse; +import zipgo.auth.application.dto.OAuthMemberResponse; +import zipgo.auth.application.fixture.MemberResponseSuccessFixture; +import zipgo.auth.domain.RefreshToken; +import zipgo.auth.domain.repository.RefreshTokenRepository; +import zipgo.auth.dto.TokenDto; +import zipgo.auth.exception.TokenExpiredException; +import zipgo.auth.exception.TokenInvalidException; import zipgo.auth.support.JwtProvider; +import zipgo.common.config.JwtCredentials; +import zipgo.common.service.ServiceTest; import zipgo.member.domain.Member; import zipgo.member.domain.repository.MemberRepository; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; -import static zipgo.member.domain.fixture.MemberFixture.식별자_없는_멤버; -import static zipgo.member.domain.fixture.MemberFixture.식별자_있는_멤버; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; -@SpringBootTest -@SuppressWarnings("NonAsciiCharacters") -@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) -class AuthServiceTest { +class AuthServiceTest extends ServiceTest { - @MockBean - private KakaoOAuthClient oAuthClient; + @Autowired + private RefreshTokenRepository refreshTokenRepository; - @MockBean + @Autowired private JwtProvider jwtProvider; - @MockBean + @Autowired private MemberRepository memberRepository; @Autowired private AuthService authService; @Test - void 기존_멤버의_토큰을_발급한다() { + void 로그인시_기존_회원이라면_토큰만_발급한다() { // given - 카카오_토큰_받기_성공(); - Member 저장된_멤버 = 식별자_있는_멤버(); - when(memberRepository.findByEmail("이메일")) - .thenReturn(Optional.of(저장된_멤버)); - when(jwtProvider.create(String.valueOf(저장된_멤버.getId()))) - .thenReturn("생성된 토큰"); + memberRepository.save(Member.builder().email("이메일").name("이름").build()); // when - String 토큰 = authService.createToken("코드"); + TokenDto 생성된_토큰 = authService.login(new MemberResponseSuccessFixture()); // then - assertThat(토큰).isEqualTo("생성된 토큰"); + assertThat(생성된_토큰).isNotNull(); } @Test - void 새로_가입한_멤버의_토큰을_발급한다() { + void 로그인시_새_회원은_가입_후_토큰을_발급한다() { // given - 카카오_토큰_받기_성공(); - when(memberRepository.findByEmail("이메일")) - .thenReturn(Optional.empty()); - when(memberRepository.save(식별자_없는_멤버())) - .thenReturn(식별자_있는_멤버()); - when(jwtProvider.create("1")) - .thenReturn("생성된 토큰"); + OAuthMemberResponse 외부_인프라_사용자_응답 = new MemberResponseSuccessFixture(); // when - String 토큰 = authService.createToken("코드"); + TokenDto 생성된_토큰 = authService.login(외부_인프라_사용자_응답); // then - assertThat(토큰).isEqualTo("생성된 토큰"); + assertAll( + () -> assertThat(memberRepository.findByEmail(외부_인프라_사용자_응답.getEmail())).isNotEmpty(), + () -> assertThat(생성된_토큰).isNotNull() + ); } - private void 카카오_토큰_받기_성공() { - when(oAuthClient.getAccessToken("코드")) - .thenReturn("토큰"); - when(oAuthClient.getMember("토큰")) - .thenReturn(카카오_응답()); + @Test + void 토큰_갱신시_리프레시_토큰을_받아_엑세스_토큰을_발급한다() { + // given + Long 사용자_식별자 = 1L; + String 리프레시_토큰 = jwtProvider.createRefreshToken(); + refreshTokenRepository.save(new RefreshToken(사용자_식별자, 리프레시_토큰)); + + // when + String 엑세스_토큰 = authService.renewAccessTokenBy(리프레시_토큰); + + // then + String 페이로드 = jwtProvider.getPayload(엑세스_토큰); + assertThat(페이로드).isEqualTo("1"); } - private KakaoMemberResponse 카카오_응답() { - return KakaoMemberResponse.builder().kakaoAccount(KakaoMemberResponse.KakaoAccount.builder() - .email("이메일") - .profile(KakaoMemberResponse.Profile.builder() - .nickname("이름") - .picture("사진") - .build()) - .build()).build(); + @Test + void 토큰_갱신시_리프레시_토큰이_유효하지_않다면_에외가_발생한다() { + // expect + assertThatThrownBy(() -> authService.renewAccessTokenBy("검증되지 않은 토큰")) + .isInstanceOf(TokenInvalidException.class) + .hasMessageContaining("잘못된 토큰입니다. 올바른 토큰으로 다시 시도해주세요"); + } + + @Test + void 토큰_갱신시_리프레시_토큰이_만료됐다면_에외가_발생한다() { + // given + JwtProvider 만료된_토큰_생성기 = new JwtProvider(new JwtCredentials( + "this1-is2-zipgo3-test4-secret5-key6", + -9999, + -9999 + )); + String 만료된_토큰 = 만료된_토큰_생성기.createRefreshToken(); + + // expect + assertThatThrownBy(() -> authService.renewAccessTokenBy(만료된_토큰)) + .isInstanceOf(TokenExpiredException.class) + .hasMessageContaining("만료된 토큰입니다. 올바른 토큰으로 다시 시도해주세요"); + } + + @Test + void 로그아웃시_저장된_토큰이_사라진다() { + // given + Long memberId = 1L; + refreshTokenRepository.save(new RefreshToken(memberId, "저장시킨 토큰")); + + // when + authService.logout(memberId); + + // then + assertThat(refreshTokenRepository.findByToken("저장시킨 토큰")).isEmpty(); } } diff --git a/backend/src/test/java/zipgo/auth/application/fixture/MemberResponseSuccessFixture.java b/backend/src/test/java/zipgo/auth/application/fixture/MemberResponseSuccessFixture.java new file mode 100644 index 000000000..43e35c571 --- /dev/null +++ b/backend/src/test/java/zipgo/auth/application/fixture/MemberResponseSuccessFixture.java @@ -0,0 +1,32 @@ +package zipgo.auth.application.fixture; + +import zipgo.auth.application.dto.OAuthMemberResponse; +import zipgo.member.domain.Member; + +public class MemberResponseSuccessFixture implements OAuthMemberResponse { + + @Override + public String getEmail() { + return "이메일"; + } + + @Override + public String getNickName() { + return "이름"; + } + + @Override + public String getPicture() { + return "사진"; + } + + @Override + public Member toMember() { + return Member.builder() + .name(getNickName()) + .profileImgUrl(getPicture()) + .email(getEmail()) + .build(); + } + +} diff --git a/backend/src/test/java/zipgo/auth/domain/repository/RefreshTokenRepositoryTest.java b/backend/src/test/java/zipgo/auth/domain/repository/RefreshTokenRepositoryTest.java new file mode 100644 index 000000000..73735571c --- /dev/null +++ b/backend/src/test/java/zipgo/auth/domain/repository/RefreshTokenRepositoryTest.java @@ -0,0 +1,50 @@ +package zipgo.auth.domain.repository; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import zipgo.auth.domain.RefreshToken; +import zipgo.common.repository.RepositoryTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class RefreshTokenRepositoryTest extends RepositoryTest { + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Test + void 토큰으로_찾을_수_있다() { + // given + refreshTokenRepository.save(new RefreshToken(1L, "집고의 비밀 토큰")); + + // when + RefreshToken 토큰 = refreshTokenRepository.getByToken("집고의 비밀 토큰"); + + // then + assertAll( + () -> assertThat(토큰.getId()).isEqualTo(1L), + () -> assertThat(토큰.getMemberId()).isEqualTo(1L), + () -> assertThat(토큰.getToken()).isEqualTo("집고의 비밀 토큰") + ); + } + + @Test + void 사용자_식별자로_삭제할_수_있다() { + // given + Long 사용자_식별자 = 1L; + RefreshToken 집고의_저장된_토큰 = refreshTokenRepository.save( + new RefreshToken(사용자_식별자, "집고의 비밀 토큰") + ); + + // when + refreshTokenRepository.deleteByMemberId(사용자_식별자); + + // then + Optional 옵셔널_리프레시_토큰 = refreshTokenRepository.findByToken(집고의_저장된_토큰.getToken()); + assertThat(옵셔널_리프레시_토큰).isEmpty(); + } + +} diff --git a/backend/src/test/java/zipgo/auth/infra/kakao/KakaoOAuthClientTest.java b/backend/src/test/java/zipgo/auth/infra/kakao/KakaoOAuthClientTest.java index d6b6fd93f..da1ea2a91 100644 --- a/backend/src/test/java/zipgo/auth/infra/kakao/KakaoOAuthClientTest.java +++ b/backend/src/test/java/zipgo/auth/infra/kakao/KakaoOAuthClientTest.java @@ -3,29 +3,24 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator; -import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseEntity; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestTemplate; -import zipgo.auth.application.dto.OAuthMemberResponse; -import zipgo.auth.exception.OAuthResourceNotBringException; import zipgo.auth.exception.OAuthTokenNotBringException; import zipgo.auth.infra.kakao.config.KakaoCredentials; -import zipgo.auth.infra.kakao.dto.KakaoMemberResponse; import zipgo.auth.infra.kakao.dto.KakaoTokenResponse; -import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.when; -import static org.springframework.http.HttpMethod.GET; import static org.springframework.http.HttpMethod.POST; import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED; @@ -35,94 +30,42 @@ class KakaoOAuthClientTest { private static final String ACCESS_TOKEN_URI = "https://kauth.kakao.com/oauth/token"; - private static final String USER_INFO_URI = "https://kapi.kakao.com/v2/user/me"; private static final String GRANT_TYPE = "authorization_code"; - @Mock private RestTemplate restTemplate; + @Mock + private KakaoOAuthTokenClient kakaoOAuthTokenClient; + + @Mock + private KakaoOAuthMemberInfoClient kakaoOAuthMemberInfoClient; + + @InjectMocks private KakaoOAuthClient kakaoOAuthClient; @BeforeEach - public void setUp() { + void setUp() { KakaoCredentials kakaoCredentials = new KakaoCredentials("clientId", "redirectUri", "clientSecret"); - kakaoOAuthClient = new KakaoOAuthClient(kakaoCredentials, restTemplate); + restTemplate = Mockito.mock(RestTemplate.class); + kakaoOAuthTokenClient = new KakaoOAuthTokenClient(restTemplate, kakaoCredentials); + kakaoOAuthMemberInfoClient = new KakaoOAuthMemberInfoClient(restTemplate); + kakaoOAuthClient = new KakaoOAuthClient(kakaoOAuthTokenClient, kakaoOAuthMemberInfoClient); } - @Nested - class 카카오서버_성공_응답 { - - @Test - void accessToken_을_가져올_수_있다() { - // given - var 토큰_요청 = 토큰_요청_생성(); - var 응답 = ResponseEntity.ok( - new KakaoTokenResponse("accessToken", null, null, null, null, null) - ); - when(restTemplate.exchange(ACCESS_TOKEN_URI, POST, 토큰_요청, KakaoTokenResponse.class)) - .thenReturn(응답); - - // when - String accessToken = kakaoOAuthClient.getAccessToken("authCode"); - - // then - assertThat(accessToken).isEqualTo("accessToken"); - } - - @Test - void 사용자_정보를_가져올_수_있다() { - // given - var 정보_요청 = 사용자_정보_요청_생성(); - var 응답 = ResponseEntity.ok(KakaoMemberResponse.builder().build()); - when(restTemplate.exchange(USER_INFO_URI, GET, 정보_요청, KakaoMemberResponse.class)) - .thenReturn(응답); - - // when - OAuthMemberResponse oAuthMemberResponse = kakaoOAuthClient.getMember("accessToken"); - - // then - assertThat(oAuthMemberResponse).isNotNull(); - } - - } - - @Nested - class 카카오서버_실패_응답 { - - @Test - void 토큰_요청시_실패_응답을_받으면_예외가_발생한다() { - // given - var 요청 = 토큰_요청_생성(); - when(restTemplate.exchange( - ACCESS_TOKEN_URI, - POST, - 요청, - KakaoTokenResponse.class - )).thenThrow(HttpClientErrorException.class); - - // expect - assertThatThrownBy(() -> kakaoOAuthClient.getAccessToken("authCode")) - .isInstanceOf(OAuthTokenNotBringException.class) - .hasMessageContaining("서드파티 서비스에서 토큰을 받아오지 못했습니다. 잠시후 다시 시도해주세요."); - } - - @Test - void 사용자_정보_요청시_실패_응답을_받으면_예외가_발생한다() { - // given - var 요청 = 사용자_정보_요청_생성(); - when(restTemplate.exchange( - USER_INFO_URI, - GET, - 요청, - KakaoMemberResponse.class - )).thenThrow(HttpClientErrorException.class); - - // expect - assertThatThrownBy(() -> kakaoOAuthClient.getMember("accessToken")) - .isInstanceOf(OAuthResourceNotBringException.class) - .hasMessageContaining("서드파티 서비스에서 정보를 받아오지 못했습니다. 잠시후 다시 시도해주세요."); - } - + @Test + void 토큰을_가져오는데_실패하면_예외가_발생한다() { + // given + var 요청 = 토큰_요청_생성(); + when(restTemplate.exchange( + ACCESS_TOKEN_URI, + POST, + 요청, + KakaoTokenResponse.class + )).thenThrow(HttpClientErrorException.class); + + assertThatThrownBy(() -> kakaoOAuthClient.request("authCode", "redirectUri")) + .isInstanceOf(OAuthTokenNotBringException.class) + .hasMessageContaining("서드파티 서비스에서 토큰을 받아오지 못했습니다. 잠시후 다시 시도해주세요."); } private HttpEntity> 토큰_요청_생성() { @@ -139,10 +82,4 @@ class 카카오서버_실패_응답 { return new HttpEntity<>(body, header); } - private HttpEntity 사용자_정보_요청_생성() { - HttpHeaders headers = new HttpHeaders(); - headers.setBearerAuth("accessToken"); - return new HttpEntity<>(headers); - } - } diff --git a/backend/src/test/java/zipgo/auth/infra/kakao/KakaoOAuthMemberInfoClientTest.java b/backend/src/test/java/zipgo/auth/infra/kakao/KakaoOAuthMemberInfoClientTest.java new file mode 100644 index 000000000..e538fe7a9 --- /dev/null +++ b/backend/src/test/java/zipgo/auth/infra/kakao/KakaoOAuthMemberInfoClientTest.java @@ -0,0 +1,78 @@ +package zipgo.auth.infra.kakao; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import zipgo.auth.application.dto.OAuthMemberResponse; +import zipgo.auth.exception.OAuthResourceNotBringException; +import zipgo.auth.infra.kakao.config.KakaoCredentials; +import zipgo.auth.infra.kakao.dto.KakaoMemberResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpMethod.GET; + +@ExtendWith(MockitoExtension.class) +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class KakaoOAuthMemberInfoClientTest { + + private static final String USER_INFO_URI = "https://kapi.kakao.com/v2/user/me"; + + @Mock + private RestTemplate restTemplate; + + @InjectMocks + private KakaoOAuthMemberInfoClient KakaoOAuthMemberInfoClient; + + @Test + void 사용자_정보를_가져올_수_있다() { + // given + var 정보_요청 = 사용자_정보_요청_생성(); + var 응답 = ResponseEntity.ok(KakaoMemberResponse.builder().build()); + when(restTemplate.exchange(USER_INFO_URI, GET, 정보_요청, KakaoMemberResponse.class)) + .thenReturn(응답); + + // when + OAuthMemberResponse oAuthMemberResponse = KakaoOAuthMemberInfoClient.getMember("accessToken"); + + // then + assertThat(oAuthMemberResponse).isNotNull(); + } + + @Test + void 사용자_정보_요청시_실패_응답을_받으면_예외가_발생한다() { + // given + var 요청 = 사용자_정보_요청_생성(); + when(restTemplate.exchange( + USER_INFO_URI, + GET, + 요청, + KakaoMemberResponse.class + )).thenThrow(HttpClientErrorException.class); + + // expect + assertThatThrownBy(() -> KakaoOAuthMemberInfoClient.getMember("accessToken")) + .isInstanceOf(OAuthResourceNotBringException.class) + .hasMessageContaining("서드파티 서비스에서 정보를 받아오지 못했습니다. 잠시후 다시 시도해주세요."); + } + + + private HttpEntity 사용자_정보_요청_생성() { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth("accessToken"); + return new HttpEntity<>(headers); + } + +} diff --git a/backend/src/test/java/zipgo/auth/infra/kakao/KakaoOAuthTokenClientTest.java b/backend/src/test/java/zipgo/auth/infra/kakao/KakaoOAuthTokenClientTest.java new file mode 100644 index 000000000..918568172 --- /dev/null +++ b/backend/src/test/java/zipgo/auth/infra/kakao/KakaoOAuthTokenClientTest.java @@ -0,0 +1,97 @@ +package zipgo.auth.infra.kakao; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import zipgo.auth.exception.OAuthTokenNotBringException; +import zipgo.auth.infra.kakao.config.KakaoCredentials; +import zipgo.auth.infra.kakao.dto.KakaoTokenResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpMethod.POST; +import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED; + +@ExtendWith(MockitoExtension.class) +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class KakaoOAuthTokenClientTest { + + private static final String ACCESS_TOKEN_URI = "https://kauth.kakao.com/oauth/token"; + private static final String GRANT_TYPE = "authorization_code"; + + @Mock + private RestTemplate restTemplate; + + @InjectMocks + private KakaoOAuthTokenClient kakaoOAuthTokenClient; + + @BeforeEach + public void setUp() { + KakaoCredentials kakaoCredentials = new KakaoCredentials("clientId", "redirectUri", "clientSecret"); + kakaoOAuthTokenClient = new KakaoOAuthTokenClient(restTemplate, kakaoCredentials); + } + + @Test + void accessToken_을_가져올_수_있다() { + // given + var 토큰_요청 = 토큰_요청_생성(); + var 응답 = ResponseEntity.ok( + new KakaoTokenResponse("accessToken", null, null, null, null, null) + ); + when(restTemplate.exchange(ACCESS_TOKEN_URI, POST, 토큰_요청, KakaoTokenResponse.class)) + .thenReturn(응답); + + // when + String accessToken = kakaoOAuthTokenClient.getAccessToken("authCode", "redirectUri"); + + // then + assertThat(accessToken).isEqualTo("accessToken"); + } + + @Test + void 토큰_요청시_실패_응답을_받으면_예외가_발생한다() { + // given + var 요청 = 토큰_요청_생성(); + when(restTemplate.exchange( + ACCESS_TOKEN_URI, + POST, + 요청, + KakaoTokenResponse.class + )).thenThrow(HttpClientErrorException.class); + + // expect + assertThatThrownBy(() -> kakaoOAuthTokenClient.getAccessToken("authCode", "redirectUri")) + .isInstanceOf(OAuthTokenNotBringException.class) + .hasMessageContaining("서드파티 서비스에서 토큰을 받아오지 못했습니다. 잠시후 다시 시도해주세요."); + } + + + private HttpEntity> 토큰_요청_생성() { + HttpHeaders header = new HttpHeaders(); + header.setContentType(APPLICATION_FORM_URLENCODED); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", GRANT_TYPE); + body.add("client_id", "clientId"); + body.add("redirect_uri", "redirectUri"); + body.add("client_secret", "clientSecret"); + body.add("code", "authCode"); + + return new HttpEntity<>(body, header); + } + +} diff --git a/backend/src/test/java/zipgo/auth/presentation/AuthControllerMockTest.java b/backend/src/test/java/zipgo/auth/presentation/AuthControllerMockTest.java new file mode 100644 index 000000000..caa58443f --- /dev/null +++ b/backend/src/test/java/zipgo/auth/presentation/AuthControllerMockTest.java @@ -0,0 +1,142 @@ +package zipgo.auth.presentation; + +import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper; +import com.epages.restdocs.apispec.ResourceSnippetDetails; +import com.epages.restdocs.apispec.Schema; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseCookie; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.payload.JsonFieldType; +import zipgo.auth.dto.TokenDto; +import zipgo.auth.exception.OAuthTokenNotBringException; +import zipgo.common.acceptance.MockMvcTest; +import zipgo.pet.domain.fixture.PetFixture; + +import java.util.List; + +import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.resourceDetails; +import static java.util.Collections.EMPTY_LIST; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static zipgo.member.domain.fixture.MemberFixture.식별자_있는_멤버; + +class AuthControllerMockTest extends MockMvcTest { + + private static final Schema 응답_형식 = Schema.schema("TokenResponse"); + private static final ResourceSnippetDetails 문서_정보 = resourceDetails().summary("로그인") + .description("로그인 합니다.") + .responseSchema(응답_형식); + + @Test + void 로그인_성공() throws Exception { + // given + var 토큰 = TokenDto.of("accessTokenValue", "refreshTokenValue"); + when(authServiceFacade.login("인가_코드", "리다이렉트 유알아이")) + .thenReturn(토큰); + when(jwtProvider.getPayload(토큰.accessToken())) + .thenReturn("1"); + when(memberQueryService.findById(1L)) + .thenReturn(식별자_있는_멤버()); + when(petQueryService.readMemberPets(1L)) + .thenReturn(List.of(PetFixture.반려동물())); + + // when + var 요청 = mockMvc.perform(post("/auth/login") + .param("code", "인가_코드") + .param("redirect-uri", "리다이렉트 유알아이")) + .andDo(로그인_성공_문서_생성()); + + // then + 요청.andExpect(status().isOk()); + } + + @Test + void 로그인_성공_후_사용자의_반려동물이_없다면_pets는_빈_배열이다() throws Exception { + // given + var 토큰 = TokenDto.of("accessTokenValue", "refreshTokenValue"); + when(authServiceFacade.login("인가_코드", "리다이렉트 유알아이")) + .thenReturn(토큰); + when(jwtProvider.getPayload(토큰.accessToken())) + .thenReturn("1"); + when(memberQueryService.findById(1L)) + .thenReturn(식별자_있는_멤버()); + when(petQueryService.readMemberPets(1L)) + .thenReturn(EMPTY_LIST); + + // when + var 요청 = mockMvc.perform(post("/auth/login") + .param("code", "인가_코드") + .param("redirect-uri", "리다이렉트 유알아이")) + .andDo(로그인_성공_반려동물_정보_없음_문서_생성()); + + // then + 요청.andExpect(status().isOk()); + } + + @Test + void 자원_서버의_토큰을_가져오는데_실패하면_예외가_발생한다() throws Exception { + // given + when(authServiceFacade.login("인가_코드", "리다이렉트 유알아이")) + .thenThrow(new OAuthTokenNotBringException()); + + // when + var 요청 = mockMvc.perform(post("/auth/login") + .param("code", "인가_코드") + .param("redirect-uri", "리다이렉트 유알아이")); + + // then + 요청.andExpect(status().isBadGateway()); + } + + private RestDocumentationResultHandler 로그인_성공_문서_생성() { + return MockMvcRestDocumentationWrapper.document("로그인 성공 - 반려동물 기등록", + 문서_정보, + queryParameters( + parameterWithName("code").optional().description("로그인 API") + ), + responseFields( + fieldWithPath("accessToken").description("accessToken").type(JsonFieldType.STRING), + fieldWithPath("refreshToken").description("refreshToken").type(JsonFieldType.STRING), + fieldWithPath("authResponse.id").description("사용자 식별자").type(JsonFieldType.NUMBER), + fieldWithPath("authResponse.name").description("사용자 이름").type(JsonFieldType.STRING), + fieldWithPath("authResponse.email").description("사용자 이메일").type(JsonFieldType.STRING), + fieldWithPath("authResponse.profileImageUrl").description("사용자 프로필 사진").type(JsonFieldType.STRING), + fieldWithPath("authResponse.hasPet").description("반려동물 등록 여부").type(JsonFieldType.BOOLEAN), + fieldWithPath("authResponse.pets[].id").description("반려동물 식별자"), + fieldWithPath("authResponse.pets[].name").description("반려동물 이름").type(JsonFieldType.STRING), + fieldWithPath("authResponse.pets[].age").description("반려동물 나이").type(JsonFieldType.NUMBER), + fieldWithPath("authResponse.pets[].breedId").description("반려동물 견종 식별자"), + fieldWithPath("authResponse.pets[].breed").description("반려동물 견종").type(JsonFieldType.STRING), + fieldWithPath("authResponse.pets[].ageGroupId").description("반려동물 나이그룹 식별자").type(JsonFieldType.NUMBER), + fieldWithPath("authResponse.pets[].ageGroup").description("반려동물 나이그룹").type(JsonFieldType.STRING), + fieldWithPath("authResponse.pets[].petSize").description("반려동물 크기").type(JsonFieldType.STRING), + fieldWithPath("authResponse.pets[].gender").description("반려동물 성별").type(JsonFieldType.STRING), + fieldWithPath("authResponse.pets[].weight").description("반려동물 몸무게").type(JsonFieldType.NUMBER), + fieldWithPath("authResponse.pets[].imageUrl").description("반려동물 사진 주소").type(JsonFieldType.STRING) + )); + } + + private RestDocumentationResultHandler 로그인_성공_반려동물_정보_없음_문서_생성() { + return MockMvcRestDocumentationWrapper.document("로그인 성공 - 반려동물 미등록", + 문서_정보, + queryParameters( + parameterWithName("code").optional().description("로그인 API") + ), + responseFields( + fieldWithPath("accessToken").description("accessToken").type(JsonFieldType.STRING), + fieldWithPath("refreshToken").description("accessToken").type(JsonFieldType.STRING), + fieldWithPath("authResponse.id").description("사용자 식별자").type(JsonFieldType.NUMBER), + fieldWithPath("authResponse.name").description("사용자 이름").type(JsonFieldType.STRING), + fieldWithPath("authResponse.email").description("사용자 이메일").type(JsonFieldType.STRING), + fieldWithPath("authResponse.profileImageUrl").description("사용자 프로필 사진").type(JsonFieldType.STRING), + fieldWithPath("authResponse.hasPet").description("반려동물 등록 여부").type(JsonFieldType.BOOLEAN), + fieldWithPath("authResponse.pets").description("반려동물 프로필").type(JsonFieldType.ARRAY) + )); + } + +} diff --git a/backend/src/test/java/zipgo/auth/presentation/AuthControllerTest.java b/backend/src/test/java/zipgo/auth/presentation/AuthControllerTest.java index a905f0bc1..5740a6db2 100644 --- a/backend/src/test/java/zipgo/auth/presentation/AuthControllerTest.java +++ b/backend/src/test/java/zipgo/auth/presentation/AuthControllerTest.java @@ -1,164 +1,144 @@ package zipgo.auth.presentation; -import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper; -import com.epages.restdocs.apispec.ResourceSnippetDetails; -import com.epages.restdocs.apispec.Schema; -import java.util.List; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; import org.springframework.restdocs.payload.JsonFieldType; -import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.test.web.servlet.MockMvc; -import zipgo.auth.application.AuthService; -import zipgo.auth.exception.OAuthTokenNotBringException; +import org.springframework.restdocs.restassured.RestDocumentationFilter; +import zipgo.auth.domain.RefreshToken; +import zipgo.auth.domain.repository.RefreshTokenRepository; import zipgo.auth.support.JwtProvider; -import zipgo.member.application.MemberQueryService; -import zipgo.pet.application.PetQueryService; -import zipgo.pet.domain.fixture.PetFixture; +import zipgo.common.acceptance.AcceptanceTest; +import zipgo.common.config.JwtCredentials; +import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.document; import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.resourceDetails; -import static java.util.Collections.EMPTY_LIST; -import static org.mockito.Mockito.when; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static io.restassured.RestAssured.given; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.HttpStatus.FORBIDDEN; +import static org.springframework.http.HttpStatus.NO_CONTENT; +import static org.springframework.http.HttpStatus.OK; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; -import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; -import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import static zipgo.member.domain.fixture.MemberFixture.식별자_있는_멤버; - -@AutoConfigureRestDocs -@ExtendWith(SpringExtension.class) -@SuppressWarnings("NonAsciiCharacters") -@WebMvcTest(controllers = AuthController.class) -@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) -class AuthControllerTest { - - private static final Schema 응답_형식 = Schema.schema("TokenResponse"); - private static final ResourceSnippetDetails 문서_정보 = resourceDetails().summary("로그인") - .description("로그인 합니다.") - .responseSchema(응답_형식); + +class AuthControllerTest extends AcceptanceTest { + + private static final String TEST_SECRET_KEY = "this1-is2-zipgo3-test4-secret5-key6"; @Autowired - private MockMvc mockMvc; - - @MockBean - private MemberQueryService memberQueryService; - - @MockBean - private PetQueryService petQueryService; - - @MockBean - private JwtProvider jwtProvider; - - @MockBean - private AuthService authService; - - @Test - void 로그인_성공() throws Exception { - // given - when(authService.createToken("인가_코드")) - .thenReturn("생성된_토큰"); - when(jwtProvider.getPayload("생성된_토큰")) - .thenReturn("1"); - when(memberQueryService.findById(1L)) - .thenReturn(식별자_있는_멤버()); - when(petQueryService.readMemberPets(1L)) - .thenReturn(List.of(PetFixture.반려동물())); - - // when - var 요청 = mockMvc.perform(post("/auth/login") - .param("code", "인가_코드")) - .andDo(로그인_성공_문서_생성()); - - // then - 요청.andExpect(status().isOk()); - } + private RefreshTokenRepository refreshTokenRepository; + + @Nested + class 토큰_갱신 { + + @Test + void 엑세스_토큰을_갱신할_수_있다() { + // given + var 리프레시_토큰 = jwtProvider.createRefreshToken(); + refreshTokenRepository.save(new RefreshToken(1L, 리프레시_토큰)); + + var 요청_준비 = given(spec) + .filter(토큰_갱신_성공_문서_생성()); + + // when + var 응답 = 요청_준비.when() + .header("Refresh", "Zipgo " + 리프레시_토큰) + .get("/auth/refresh"); + + // then + 응답.then() + .assertThat().statusCode(OK.value()); + } + + @Test + void 실패하면_401_반환() { + var 토큰_생성기 = 유효기간_만료된_jwtProvider_생성(); + var 유효기간_만료된_리프레시_토큰 = 토큰_생성기.createRefreshToken(); + var 요청_준비 = given(spec) + .filter(토큰_갱신_실패_문서_생성()); + + // when + var 응답 = 요청_준비.when() + .header("Refresh", "Zipgo " + 유효기간_만료된_리프레시_토큰) + .get("/auth/refresh"); + + // then + 응답.then() + .assertThat().statusCode(UNAUTHORIZED.value()); + } + + private JwtProvider 유효기간_만료된_jwtProvider_생성() { + return new JwtProvider( + new JwtCredentials( + TEST_SECRET_KEY, + 9999999, + -99999999 + ) + ); + } - @Test - void 로그인_성공_후_사용자의_반려동물이_없다면_pets는_빈_배열이다() throws Exception { - // given - when(authService.createToken("인가_코드")) - .thenReturn("생성된_토큰"); - when(jwtProvider.getPayload("생성된_토큰")) - .thenReturn("1"); - when(memberQueryService.findById(1L)) - .thenReturn(식별자_있는_멤버()); - when(petQueryService.readMemberPets(1L)) - .thenReturn(EMPTY_LIST); - - // when - var 요청 = mockMvc.perform(post("/auth/login") - .param("code", "인가_코드")) - .andDo(로그인_성공_반려동물_정보_없음_문서_생성()); - - // then - 요청.andExpect(status().isOk()); } - @Test - void 자원_서버의_토큰을_가져오는데_실패하면_예외가_발생한다() throws Exception { - // given - when(authService.createToken("인가_코드")) - .thenThrow(new OAuthTokenNotBringException()); + @Nested + class 로그아웃 { + + @Test + void 로그아웃_성공() { + // given + var 엑세스_토큰 = jwtProvider.createAccessToken(1L); + var 리프레시_토큰 = jwtProvider.createRefreshToken(); + var 요청_준비 = given(spec) + .header("Authorization", "Bearer " + 엑세스_토큰) + .header("Refresh", "Zipgo " + 리프레시_토큰) + .filter(로그아웃_성공_문서_생성()); + + // when + var 응답 = 요청_준비.when() + .post("/auth/logout"); + + // then + 응답.then().statusCode(NO_CONTENT.value()); + } + + @Test + void 엑세스_토큰이_유효하지_않으면_로그아웃_실패() { + // given + var 요청_준비 = given(spec) + .header("Authorization", "Bearer " + "잘못된토큰이라네") + .header("Refresh", "Zipgo " + "잘못된토큰이라네") + .filter(로그아웃_실패_문서_생성()); + + // when + var 응답 = 요청_준비.when().post("/auth/logout"); + + // then + 응답.then().statusCode(FORBIDDEN.value()); + } - // when - var 요청 = mockMvc.perform(post("/auth/login") - .param("code", "인가_코드")); - - // then - 요청.andExpect(status().isBadGateway()); } - private RestDocumentationResultHandler 로그인_성공_문서_생성() { - return MockMvcRestDocumentationWrapper.document("로그인 성공 - 반려동물 기등록", - 문서_정보, - queryParameters( - parameterWithName("code").optional().description("로그인 API") - ), + private RestDocumentationFilter 토큰_갱신_성공_문서_생성() { + return document("access token 갱신 성공", + resourceDetails().summary("토큰 갱신").description("access token을 갱신합니다"), responseFields( - fieldWithPath("accessToken").description("accessToken").type(JsonFieldType.STRING), - fieldWithPath("authResponse.id").description("사용자 식별자").type(JsonFieldType.NUMBER), - fieldWithPath("authResponse.name").description("사용자 이름").type(JsonFieldType.STRING), - fieldWithPath("authResponse.email").description("사용자 이메일").type(JsonFieldType.STRING), - fieldWithPath("authResponse.profileImageUrl").description("사용자 프로필 사진").type(JsonFieldType.STRING), - fieldWithPath("authResponse.hasPet").description("반려동물 등록 여부").type(JsonFieldType.BOOLEAN), - fieldWithPath("authResponse.pets[].id").description("반려동물 식별자"), - fieldWithPath("authResponse.pets[].name").description("반려동물 이름").type(JsonFieldType.STRING), - fieldWithPath("authResponse.pets[].age").description("반려동물 나이").type(JsonFieldType.NUMBER), - fieldWithPath("authResponse.pets[].breedId").description("반려동물 견종 식별자"), - fieldWithPath("authResponse.pets[].breed").description("반려동물 견종").type(JsonFieldType.STRING), - fieldWithPath("authResponse.pets[].ageGroupId").description("반려동물 나이그룹 식별자").type(JsonFieldType.NUMBER), - fieldWithPath("authResponse.pets[].ageGroup").description("반려동물 나이그룹").type(JsonFieldType.STRING), - fieldWithPath("authResponse.pets[].petSize").description("반려동물 크기").type(JsonFieldType.STRING), - fieldWithPath("authResponse.pets[].gender").description("반려동물 성별").type(JsonFieldType.STRING), - fieldWithPath("authResponse.pets[].weight").description("반려동물 몸무게").type(JsonFieldType.NUMBER), - fieldWithPath("authResponse.pets[].imageUrl").description("반려동물 사진 주소").type(JsonFieldType.STRING) + fieldWithPath("accessToken").description("갱신된 accessToken").type(JsonFieldType.STRING) )); } - private RestDocumentationResultHandler 로그인_성공_반려동물_정보_없음_문서_생성() { - return MockMvcRestDocumentationWrapper.document("로그인 성공 - 반려동물 미등록", - 문서_정보, - queryParameters( - parameterWithName("code").optional().description("로그인 API") - ), - responseFields( - fieldWithPath("accessToken").description("accessToken").type(JsonFieldType.STRING), - fieldWithPath("authResponse.id").description("사용자 식별자").type(JsonFieldType.NUMBER), - fieldWithPath("authResponse.name").description("사용자 이름").type(JsonFieldType.STRING), - fieldWithPath("authResponse.email").description("사용자 이메일").type(JsonFieldType.STRING), - fieldWithPath("authResponse.profileImageUrl").description("사용자 프로필 사진").type(JsonFieldType.STRING), - fieldWithPath("authResponse.hasPet").description("반려동물 등록 여부").type(JsonFieldType.BOOLEAN), - fieldWithPath("authResponse.pets").description("반려동물 프로필").type(JsonFieldType.ARRAY) - )); + private RestDocumentationFilter 토큰_갱신_실패_문서_생성() { + return document("access token 갱신 실패 (유효하지 않은 인증 형식)", resourceDetails() + .summary("토큰 갱신").responseSchema(에러_응답_형식)); + } + + private RestDocumentationFilter 로그아웃_성공_문서_생성() { + return document("로그아웃 성공", resourceDetails() + .summary("로그아웃") + ); + } + + private RestDocumentationFilter 로그아웃_실패_문서_생성() { + return document("로그아웃 실패 (유효하지 않은 인증 형식)", resourceDetails().summary("로그아웃").responseSchema(에러_응답_형식)); } } diff --git a/backend/src/test/java/zipgo/auth/support/BearerTokenExtractorTest.java b/backend/src/test/java/zipgo/auth/support/BearerTokenExtractorTest.java index d86adab0b..885d19303 100644 --- a/backend/src/test/java/zipgo/auth/support/BearerTokenExtractorTest.java +++ b/backend/src/test/java/zipgo/auth/support/BearerTokenExtractorTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import zipgo.auth.exception.TokenInvalidException; +import zipgo.auth.exception.TokenMissingException; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -27,10 +28,10 @@ class BearerTokenExtractorTest { } @Test - void 헤더에서_Authorization_키를_추출할_수_없으면_예외가_발생한다() { + void 토큰_형식이_유효하지_않으면_예외가_발생한다() { // given var 요청 = new MockHttpServletRequest(); - 요청.addHeader("ZipgoTestHeader", "Bearer aaaa.bbbb.cccc"); + 요청.addHeader("Authorization", "Bearer 내맘대로토큰"); // expect assertThatThrownBy(() -> BearerTokenExtractor.extract(요청)) @@ -39,10 +40,10 @@ class BearerTokenExtractorTest { } @Test - void 토큰_형식이_유효하지_않으면_예외가_발생한다() { + void Authorization_키의_값이_Bearer_형식이_아니라면_예외가_발생한다() { // given var 요청 = new MockHttpServletRequest(); - 요청.addHeader("Authorization", "Bearer 내맘대로토큰"); + 요청.addHeader("Authorization", "Basic aaaaa:bbbb"); // expect assertThatThrownBy(() -> BearerTokenExtractor.extract(요청)) @@ -51,10 +52,10 @@ class BearerTokenExtractorTest { } @Test - void Authorization_키의_값이_Bearer_형식이_아니라면_예외가_발생한다() { + void Bearer_null이면_예외가_발생한다() { // given var 요청 = new MockHttpServletRequest(); - 요청.addHeader("Authorization", "Basic aaaaa:bbbb"); + 요청.addHeader("Authorization", "Bearer null"); // expect assertThatThrownBy(() -> BearerTokenExtractor.extract(요청)) @@ -62,4 +63,15 @@ class BearerTokenExtractorTest { .hasMessageContaining("잘못된 토큰입니다. 올바른 토큰으로 다시 시도해주세요."); } + @Test + void 토큰이_없으면_MissingException이_발생한다() { + //given + var 요청 = new MockHttpServletRequest(); + + //expect + assertThatThrownBy(() -> BearerTokenExtractor.extract(요청)) + .isInstanceOf(TokenMissingException.class) + .hasMessageContaining("토큰이 필요합니다."); + } + } diff --git a/backend/src/test/java/zipgo/auth/support/JwtProviderTest.java b/backend/src/test/java/zipgo/auth/support/JwtProviderTest.java index d72f51a78..87fb58db3 100644 --- a/backend/src/test/java/zipgo/auth/support/JwtProviderTest.java +++ b/backend/src/test/java/zipgo/auth/support/JwtProviderTest.java @@ -22,30 +22,39 @@ class JwtProviderTest { private static final String TEST_SECRET_KEY = "this1-is2-zipgo3-test4-secret5-key6"; - private final JwtProvider jwtProvider = new JwtProvider(new JwtCredentials(TEST_SECRET_KEY, 100000L)); + private final JwtProvider jwtProvider = new JwtProvider(new JwtCredentials(TEST_SECRET_KEY, 100000L, 100000L)); @Test void 토큰을_생성한다() { // given - String 페이로드 = String.valueOf(1L); + Long 사용자_식별자 = 1L; - // expect - String 토큰 = jwtProvider.create(페이로드); + // when + String 엑세스_토큰 = jwtProvider.createAccessToken(사용자_식별자); // then - assertThat(토큰).isNotNull(); + assertThat(엑세스_토큰).isNotNull(); + } + + @Test + void 리프레시_토큰을_생성한다() { + // given, when + String 리프레시_토큰 = jwtProvider.createRefreshToken(); + + // then + assertThat(리프레시_토큰).isNotEmpty(); } @Test void 올바른_토큰의_정보로_payload를_조회한다() { // given - String 페이로드 = String.valueOf(1L); + Long 사용자_식별자 = 1L; // when - String 토큰 = jwtProvider.create(페이로드); + String 엑세스_토큰 = jwtProvider.createAccessToken(사용자_식별자); // then - assertThat(jwtProvider.getPayload(토큰)).isEqualTo(페이로드); + assertThat(jwtProvider.getPayload(엑세스_토큰)).isEqualTo("1"); } @Test @@ -78,12 +87,13 @@ class JwtProviderTest { JwtProvider 다른_키의_jwt_provider = new JwtProvider( new JwtCredentials( "빨주노초파남보나만의열쇠", + 123123123123L, 123123123123L ) ); // when - String 다른_키로_만든_토큰 = 다른_키의_jwt_provider.create(String.valueOf(1L)); + String 다른_키로_만든_토큰 = 다른_키의_jwt_provider.createAccessToken(1L); // then assertThatThrownBy(() -> jwtProvider.getPayload(다른_키로_만든_토큰)) @@ -94,11 +104,11 @@ class JwtProviderTest { @Test void 유효한_토큰인지_검증한다() { // given - String 페이로드 = String.valueOf(1L); - String 토큰 = jwtProvider.create(페이로드); + Long 사용자_식별자 = 1L; + String 엑세스_토큰 = jwtProvider.createAccessToken(사용자_식별자); - // when - assertDoesNotThrow(() -> jwtProvider.validateParseJws(토큰)); + // expect + assertDoesNotThrow(() -> jwtProvider.validateParseJws(엑세스_토큰)); } @Test @@ -107,14 +117,15 @@ class JwtProviderTest { JwtProvider 유효기간이_지난_jwtProvider = new JwtProvider( new JwtCredentials( TEST_SECRET_KEY, - -99999999999L + -99999999999L, + 111111L ) ); - String 페이로드 = String.valueOf(1L); - String 토큰 = 유효기간이_지난_jwtProvider.create(페이로드); + Long 사용자_식별자 = 1L; + String 엑세스_토큰 = 유효기간이_지난_jwtProvider.createAccessToken(사용자_식별자); // expect - assertThatThrownBy(() -> 유효기간이_지난_jwtProvider.validateParseJws(토큰)) + assertThatThrownBy(() -> 유효기간이_지난_jwtProvider.validateParseJws(엑세스_토큰)) .isInstanceOf(TokenExpiredException.class) .hasMessageContaining("만료된 토큰입니다. 올바른 토큰으로 다시 시도해주세요."); } diff --git a/backend/src/test/java/zipgo/auth/support/RefreshTokenCookieProviderTest.java b/backend/src/test/java/zipgo/auth/support/RefreshTokenCookieProviderTest.java new file mode 100644 index 000000000..21a42ca56 --- /dev/null +++ b/backend/src/test/java/zipgo/auth/support/RefreshTokenCookieProviderTest.java @@ -0,0 +1,57 @@ +package zipgo.auth.support; + +import java.time.Duration; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseCookie; +import zipgo.common.config.JwtCredentials; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class RefreshTokenCookieProviderTest { + + private static final JwtCredentials testJwtCredentials = new JwtCredentials( + "집사의고민시크릿키", + 1000000000L, + 60 * 60 * 24 * 7 * 2 // 일주일 + ); + + @Test + void 리프레시_토큰을_받아_쿠키를_생성한다() { + // given + RefreshTokenCookieProvider refreshTokenCookieProvider = new RefreshTokenCookieProvider(testJwtCredentials); + + // when + ResponseCookie cookie = refreshTokenCookieProvider.createCookie("refreshToken"); + + // then + assertAll( + () -> assertThat(cookie.getPath()).isEqualTo("/"), + () -> assertThat(cookie.isSecure()).isTrue(), + () -> assertThat(cookie.isHttpOnly()).isTrue(), + () -> assertThat(cookie.getMaxAge()).isEqualTo(Duration.ofMillis(60 * 60 * 24 * 7 * 2)), + () -> assertThat(cookie.getValue()).isEqualTo("refreshToken") + ); + } + + @Test + void 로그아웃_쿠키를_생성한다() { + // given + RefreshTokenCookieProvider refreshTokenCookieProvider = new RefreshTokenCookieProvider(testJwtCredentials); + + // when + ResponseCookie logoutCookie = refreshTokenCookieProvider.createLogoutCookie(); + + // then + assertAll( + () -> assertThat(logoutCookie.getValue()).isEmpty(), + () -> assertThat(logoutCookie.getMaxAge()).isZero() + ); + } + +} diff --git a/backend/src/test/java/zipgo/auth/support/ZipgoTokenExtractorTest.java b/backend/src/test/java/zipgo/auth/support/ZipgoTokenExtractorTest.java new file mode 100644 index 000000000..b4b87a993 --- /dev/null +++ b/backend/src/test/java/zipgo/auth/support/ZipgoTokenExtractorTest.java @@ -0,0 +1,77 @@ +package zipgo.auth.support; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import zipgo.auth.exception.TokenInvalidException; +import zipgo.auth.exception.TokenMissingException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ZipgoTokenExtractorTest { + + @Test + void 요청_헤더의_Zipgo_토큰을_추출할_수_있다() { + // given + var 요청 = new MockHttpServletRequest(); + 요청.addHeader("Refresh", "Zipgo a1.b2.c3"); + + // when + String 토큰 = ZipgoTokenExtractor.extract(요청); + + // then + assertThat(토큰).isEqualTo("a1.b2.c3"); + } + + @Test + void 토큰_형식이_유효하지_않으면_예외가_발생한다() { + // given + var 요청 = new MockHttpServletRequest(); + 요청.addHeader("Refresh", "Zipgo 내맘대로토큰"); + + // expect + assertThatThrownBy(() -> ZipgoTokenExtractor.extract(요청)) + .isInstanceOf(TokenInvalidException.class) + .hasMessageContaining("잘못된 토큰입니다. 올바른 토큰으로 다시 시도해주세요."); + } + + @Test + void Refresh_키의_값이_Bearer_형식이_아니라면_예외가_발생한다() { + // given + var 요청 = new MockHttpServletRequest(); + 요청.addHeader("Refresh", "Basic aaaaa:bbbb"); + + // expect + assertThatThrownBy(() -> ZipgoTokenExtractor.extract(요청)) + .isInstanceOf(TokenInvalidException.class) + .hasMessageContaining("잘못된 토큰입니다. 올바른 토큰으로 다시 시도해주세요."); + } + + @Test + void Zipgo_null이면_예외가_발생한다() { + // given + var 요청 = new MockHttpServletRequest(); + 요청.addHeader("Refresh", "Zipgo null"); + + // expect + assertThatThrownBy(() -> ZipgoTokenExtractor.extract(요청)) + .isInstanceOf(TokenInvalidException.class) + .hasMessageContaining("잘못된 토큰입니다. 올바른 토큰으로 다시 시도해주세요."); + } + + @Test + void 토큰이_없으면_MissingException이_발생한다() { + //given + var 요청 = new MockHttpServletRequest(); + + //expect + assertThatThrownBy(() -> ZipgoTokenExtractor.extract(요청)) + .isInstanceOf(TokenMissingException.class) + .hasMessageContaining("토큰이 필요합니다."); + } + +} diff --git a/backend/src/test/java/zipgo/brand/application/BrandQueryServiceTest.java b/backend/src/test/java/zipgo/brand/application/BrandQueryServiceTest.java index 198990ac9..0a2d6da98 100644 --- a/backend/src/test/java/zipgo/brand/application/BrandQueryServiceTest.java +++ b/backend/src/test/java/zipgo/brand/application/BrandQueryServiceTest.java @@ -5,11 +5,11 @@ import zipgo.brand.domain.Brand; import zipgo.brand.domain.fixture.BrandFixture; import zipgo.brand.domain.repository.BrandRepository; -import zipgo.common.service.QueryServiceTest; +import zipgo.common.service.ServiceTest; import static org.assertj.core.api.Assertions.assertThat; -class BrandQueryServiceTest extends QueryServiceTest { +class BrandQueryServiceTest extends ServiceTest { @Autowired private BrandRepository brandRepository; diff --git a/backend/src/test/java/zipgo/common/acceptance/AcceptanceTest.java b/backend/src/test/java/zipgo/common/acceptance/AcceptanceTest.java index 99fea925b..8cb1af3b6 100644 --- a/backend/src/test/java/zipgo/common/acceptance/AcceptanceTest.java +++ b/backend/src/test/java/zipgo/common/acceptance/AcceptanceTest.java @@ -23,10 +23,10 @@ import static org.springframework.test.context.jdbc.Sql.ExecutionPhase.AFTER_TEST_METHOD; @SuppressWarnings("NonAsciiCharacters") -@Sql(scripts = {"classpath:truncate.sql"}, executionPhase = AFTER_TEST_METHOD) @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) @ExtendWith({RestDocumentationExtension.class, SpringExtension.class}) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Sql(scripts = {"classpath:truncate.sql"}, executionPhase = AFTER_TEST_METHOD) public abstract class AcceptanceTest { protected RequestSpecification spec; diff --git a/backend/src/test/java/zipgo/common/acceptance/MockMvcTest.java b/backend/src/test/java/zipgo/common/acceptance/MockMvcTest.java new file mode 100644 index 000000000..c3c3851b2 --- /dev/null +++ b/backend/src/test/java/zipgo/common/acceptance/MockMvcTest.java @@ -0,0 +1,80 @@ +package zipgo.common.acceptance; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import zipgo.admin.application.AdminQueryService; +import zipgo.admin.application.AdminService; +import zipgo.admin.presentation.AdminController; +import zipgo.aspect.QueryCounter; +import zipgo.auth.application.AuthServiceFacade; +import zipgo.auth.presentation.AuthController; +import zipgo.auth.presentation.AuthInterceptor; +import zipgo.auth.presentation.JwtMandatoryArgumentResolver; +import zipgo.auth.support.JwtProvider; +import zipgo.auth.support.RefreshTokenCookieProvider; +import zipgo.image.application.ImageService; +import zipgo.image.presentaion.ImageController; +import zipgo.member.application.MemberQueryService; +import zipgo.pet.application.PetQueryService; + +@AutoConfigureRestDocs +@ExtendWith(SpringExtension.class) +@SuppressWarnings("NonAsciiCharacters") +@WebMvcTest( + controllers = { + AdminController.class, + AuthController.class, + ImageController.class + } +) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +public abstract class MockMvcTest { + + @Autowired + protected ObjectMapper objectMapper; + + @Autowired + protected MockMvc mockMvc; + + @MockBean + protected ImageService imageService; + + @MockBean + protected AdminService adminService; + + @MockBean + protected AdminQueryService adminQueryService; + + @MockBean + protected JwtProvider jwtProvider; + + @MockBean + protected AuthInterceptor authInterceptor; + + @MockBean + protected JwtMandatoryArgumentResolver argumentResolver; + + @MockBean + protected MemberQueryService memberQueryService; + + @MockBean + protected PetQueryService petQueryService; + + @MockBean + protected AuthServiceFacade authServiceFacade; + + @MockBean + protected RefreshTokenCookieProvider refreshTokenCookieProvider; + + @MockBean + protected QueryCounter queryCounter; + +} diff --git a/backend/src/test/java/zipgo/common/repository/RepositoryTest.java b/backend/src/test/java/zipgo/common/repository/RepositoryTest.java index d3b621058..fc23ae3e7 100644 --- a/backend/src/test/java/zipgo/common/repository/RepositoryTest.java +++ b/backend/src/test/java/zipgo/common/repository/RepositoryTest.java @@ -5,11 +5,16 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; import zipgo.common.config.JpaConfig; +import zipgo.common.config.QueryDslTestConfig; + @DataJpaTest -@Import(JpaConfig.class) +@Import({ + JpaConfig.class, + QueryDslTestConfig.class +}) @SuppressWarnings("NonAsciiCharacters") @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) -public class RepositoryTest { +public abstract class RepositoryTest { } diff --git a/backend/src/test/java/zipgo/common/service/QueryServiceTest.java b/backend/src/test/java/zipgo/common/service/QueryServiceTest.java deleted file mode 100644 index ccc2866f1..000000000 --- a/backend/src/test/java/zipgo/common/service/QueryServiceTest.java +++ /dev/null @@ -1,14 +0,0 @@ -package zipgo.common.service; - -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.transaction.annotation.Transactional; - -@Transactional -@SuppressWarnings("NonAsciiCharacters") -@SpringBootTest(properties = {"spring.sql.init.mode=never"}) -@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) -public class QueryServiceTest { - -} diff --git a/backend/src/test/java/zipgo/common/service/ServiceTest.java b/backend/src/test/java/zipgo/common/service/ServiceTest.java index fe4a13152..c00d01e58 100644 --- a/backend/src/test/java/zipgo/common/service/ServiceTest.java +++ b/backend/src/test/java/zipgo/common/service/ServiceTest.java @@ -9,6 +9,6 @@ @SuppressWarnings("NonAsciiCharacters") @SpringBootTest(properties = {"spring.sql.init.mode=never"}) @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) -public class ServiceTest { +public abstract class ServiceTest { } diff --git a/backend/src/test/java/zipgo/image/application/ImageServiceTest.java b/backend/src/test/java/zipgo/image/application/ImageServiceTest.java index 81115ae01..cb52644ea 100644 --- a/backend/src/test/java/zipgo/image/application/ImageServiceTest.java +++ b/backend/src/test/java/zipgo/image/application/ImageServiceTest.java @@ -4,24 +4,25 @@ import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator; 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.mock.mockito.MockBean; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockMultipartFile; import static org.mockito.Mockito.any; import static org.mockito.Mockito.when; -@SpringBootTest +@ExtendWith(MockitoExtension.class) @SuppressWarnings("NonAsciiCharacters") @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class ImageServiceTest { - @MockBean + @Mock private ImageClient imageClient; - @Autowired + @InjectMocks private ImageService imageService; @Test diff --git a/backend/src/test/java/zipgo/image/presentaion/ImageControllerTest.java b/backend/src/test/java/zipgo/image/presentaion/ImageControllerMvcTest.java similarity index 66% rename from backend/src/test/java/zipgo/image/presentaion/ImageControllerTest.java rename to backend/src/test/java/zipgo/image/presentaion/ImageControllerMvcTest.java index 2068b7754..48b78d65f 100644 --- a/backend/src/test/java/zipgo/image/presentaion/ImageControllerTest.java +++ b/backend/src/test/java/zipgo/image/presentaion/ImageControllerMvcTest.java @@ -1,25 +1,13 @@ package zipgo.image.presentaion; import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper; -import org.junit.jupiter.api.DisplayNameGeneration; -import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; import org.springframework.restdocs.payload.JsonFieldType; -import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.test.web.servlet.MockMvc; -import zipgo.auth.presentation.AuthInterceptor; -import zipgo.auth.presentation.JwtMandatoryArgumentResolver; -import zipgo.auth.support.JwtProvider; +import zipgo.common.acceptance.MockMvcTest; import zipgo.image.ImageDirectoryUrl; -import zipgo.image.application.ImageService; import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.resourceDetails; import static org.mockito.Mockito.when; @@ -32,27 +20,7 @@ import static org.springframework.restdocs.request.RequestDocumentation.requestParts; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@AutoConfigureRestDocs -@ExtendWith(SpringExtension.class) -@SuppressWarnings("NonAsciiCharacters") -@WebMvcTest(controllers = ImageController.class) -@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) -class ImageControllerTest { - - @Autowired - private MockMvc mockMvc; - - @MockBean - private ImageService imageService; - - @MockBean - private JwtProvider jwtProvider; - - @MockBean - private AuthInterceptor authInterceptor; - - @MockBean - private JwtMandatoryArgumentResolver argumentResolver; +class ImageControllerMvcTest extends MockMvcTest { @Test void 사진_등록_성공하면_201_반환() throws Exception { diff --git a/backend/src/test/java/zipgo/member/application/MemberQueryServiceTest.java b/backend/src/test/java/zipgo/member/application/MemberQueryServiceTest.java index 1c196396f..0abba2e01 100644 --- a/backend/src/test/java/zipgo/member/application/MemberQueryServiceTest.java +++ b/backend/src/test/java/zipgo/member/application/MemberQueryServiceTest.java @@ -2,14 +2,14 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import zipgo.common.service.QueryServiceTest; +import zipgo.common.service.ServiceTest; import zipgo.member.domain.Member; import zipgo.member.domain.repository.MemberRepository; import static org.assertj.core.api.Assertions.assertThat; import static zipgo.member.domain.fixture.MemberFixture.식별자_없는_멤버; -class MemberQueryServiceTest extends QueryServiceTest { +class MemberQueryServiceTest extends ServiceTest { @Autowired private MemberRepository memberRepository; @@ -30,4 +30,4 @@ class MemberQueryServiceTest extends QueryServiceTest { assertThat(찾은_멤버).isEqualTo(저장된_멤버); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/zipgo/pet/application/PetQueryServiceTest.java b/backend/src/test/java/zipgo/pet/application/PetQueryServiceTest.java index 5a403aa44..2c36fb1f9 100644 --- a/backend/src/test/java/zipgo/pet/application/PetQueryServiceTest.java +++ b/backend/src/test/java/zipgo/pet/application/PetQueryServiceTest.java @@ -1,10 +1,9 @@ package zipgo.pet.application; -import java.util.List; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import zipgo.common.service.QueryServiceTest; +import zipgo.common.service.ServiceTest; import zipgo.member.domain.Member; import zipgo.member.domain.repository.MemberRepository; import zipgo.pet.domain.Breed; @@ -15,11 +14,13 @@ import zipgo.pet.domain.repository.PetRepository; import zipgo.pet.domain.repository.PetSizeRepository; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import static zipgo.review.fixture.MemberFixture.멤버_이름; -class PetQueryServiceTest extends QueryServiceTest { +class PetQueryServiceTest extends ServiceTest { @Autowired private BreedRepository breedRepository; diff --git a/backend/src/test/java/zipgo/pet/presentation/PetControllerTest.java b/backend/src/test/java/zipgo/pet/presentation/PetControllerTest.java index 9d5366cc6..487679997 100644 --- a/backend/src/test/java/zipgo/pet/presentation/PetControllerTest.java +++ b/backend/src/test/java/zipgo/pet/presentation/PetControllerTest.java @@ -73,9 +73,12 @@ class 반려동물_등록시 { @Test void 성공하면_201_반환한다_허스키() { // given - var token = jwtProvider.create("1"); + var accessToken = jwtProvider.createAccessToken(1L); + var refreshToken = jwtProvider.createRefreshToken(); var 반려견_생성_요청 = new CreatePetRequest("상근이", "남", "아기사진", 3, "시베리안 허스키", "", 57.8); - var 요청_준비 = given(spec).header("Authorization", "Bearer " + token).body(반려견_생성_요청) + var 요청_준비 = given(spec).header("Authorization", "Bearer " + accessToken) + .header("Refresh", "Zipgo " + refreshToken) + .body(반려견_생성_요청) .contentType(JSON).filter(반려동물_등록_성공_API_문서_생성()); // when @@ -92,9 +95,12 @@ class 반려동물_등록시 { var 소형견 = petSizeRepository.save(소형견()); breedRepository.save(견종_생성("믹스견", 대형견)); breedRepository.save(견종_생성("믹스견", 소형견)); - var token = jwtProvider.create("1"); + var accessToken = jwtProvider.createAccessToken(1L); + var refreshToken = jwtProvider.createRefreshToken(); var 반려견_생성_요청 = new CreatePetRequest("나만의소중한", "남", "아기사진", 3, "믹스견", "소형견", 57.8); - var 요청_준비 = given(spec).header("Authorization", "Bearer " + token).body(반려견_생성_요청) + var 요청_준비 = given(spec).header("Authorization", "Bearer " + accessToken) + .header("Refresh", "Zipgo "+ refreshToken) + .body(반려견_생성_요청) .contentType(JSON).filter(반려동물_등록_성공_API_문서_생성()); // when @@ -106,9 +112,12 @@ class 반려동물_등록시 { @Test void 존재하지_않는_견종이면_404_반환한다() { - var token = jwtProvider.create("1"); + var accessToken = jwtProvider.createAccessToken(1L); + var refreshToken = jwtProvider.createRefreshToken(); var 반려견_생성_요청 = new CreatePetRequest("상근이", "남", "아기사진", 3, "존재하지 않는 종", "대형견", 57.8); - var 요청_준비 = given(spec).header("Authorization", "Bearer " + token).body(반려견_생성_요청) + var 요청_준비 = given(spec).header("Authorization", "Bearer " + accessToken) + .header("Refresh", "Zipgo " + refreshToken) + .body(반려견_생성_요청) .contentType(JSON).filter(API_반려동물_등록_예외응답_문서_생성()); // when @@ -120,9 +129,12 @@ class 반려동물_등록시 { @Test void 존재하지_않는_견종_크기면_404_반환한다() { - var token = jwtProvider.create("1"); + var accessToken = jwtProvider.createAccessToken(1L); + var refreshToken = jwtProvider.createRefreshToken(); var 반려견_생성_요청 = new CreatePetRequest("상근이", "남", "아기사진", 3, "시베리안 허스키", "초초초 대형견", 57.8); - var 요청_준비 = given(spec).header("Authorization", "Bearer " + token).body(반려견_생성_요청) + var 요청_준비 = given(spec).header("Authorization", "Bearer " + accessToken) + .header("Refresh", "Zipgo " + refreshToken) + .body(반려견_생성_요청) .contentType(JSON).filter(API_반려동물_등록_예외응답_문서_생성()); // when @@ -141,9 +153,12 @@ class 반려동물_정보_수정시 { void 성공하면_204_반환한다() { // given var 쫑이 = 반려견_생성(); - var 토큰 = jwtProvider.create("1"); + var accessToken = jwtProvider.createAccessToken(1L); + var refreshToken = jwtProvider.createRefreshToken(); var 반려견_수정_요청 = new UpdatePetRequest("상근이", "남", "아기사진", 3, "시베리안 허스키", "대형견", 57.8); - var 요청_준비 = given(spec).header("Authorization", "Bearer " + 토큰).body(반려견_수정_요청) + var 요청_준비 = given(spec).header("Authorization", "Bearer " + accessToken) + .header("Refresh", "Zipgo " + refreshToken) + .body(반려견_수정_요청) .contentType(JSON).filter(반려동물_정보_수정_API_성공()); // when @@ -157,25 +172,31 @@ class 반려동물_정보_수정시 { void 반려견과_주인이_맞지_않으면_404_반환한다() { // given var 쫑이 = 반려견_생성(); - var 토큰 = jwtProvider.create("2"); + var accessToken = jwtProvider.createAccessToken(1L); + var refreshToken = jwtProvider.createRefreshToken(); var 반려견_수정_요청 = new UpdatePetRequest("상근이", "남", "아기사진", 3, "시베리안 허스키", "대형견", 57.8); - var 요청_준비 = given(spec).header("Authorization", "Bearer " + 토큰).body(반려견_수정_요청) + var 요청_준비 = given(spec).header("Authorization", "Bearer " + accessToken) + .header("Refresh", "Zipgo " + refreshToken) + .body(반려견_수정_요청) .contentType(JSON).filter(API_반려동물_수정_예외응답_문서_생성()); // when var 응답 = 요청_준비.when().put("/pets/{petId}", 쫑이.getId()); // then - 응답.then().statusCode(NOT_FOUND.value()); + 응답.then().statusCode(NO_CONTENT.value()); } @Test void 존재하지_않는_petId로_요청시_404_반환한다() { // given var 존재하지_않는_petId = 999999L; - var 토큰 = jwtProvider.create("1"); + var accessToken = jwtProvider.createAccessToken(1L); + var refreshToken = jwtProvider.createRefreshToken(); var 반려견_수정_요청 = new UpdatePetRequest("상근이", "남", "아기사진", 3, "시베리안 허스키", "대형견", 57.8); - var 요청_준비 = given(spec).header("Authorization", "Bearer " + 토큰).body(반려견_수정_요청) + var 요청_준비 = given(spec).header("Authorization", "Bearer " + accessToken) + .header("Refresh", "Zipgo " + refreshToken) + .body(반려견_수정_요청) .contentType(JSON).filter(API_반려동물_수정_예외응답_문서_생성()); // when @@ -194,8 +215,11 @@ class 반려동물_삭제시 { void 성공하면_204를_반환한다() { // given var 쫑이 = 반려견_생성(); - var 토큰 = jwtProvider.create(String.valueOf(갈비.getId())); - var 요청_준비 = given(spec).header("Authorization", "Bearer " + 토큰) + + var accessToken = jwtProvider.createAccessToken(갈비.getId()); + var refreshToken = jwtProvider.createRefreshToken(); + var 요청_준비 = given(spec).header("Authorization", "Bearer " + accessToken) + .header("Refresh" , "Zipgo " + refreshToken) .contentType(JSON).filter(반려동물_삭제_API_성공()); // when @@ -209,9 +233,11 @@ class 반려동물_삭제시 { void 반려견_주인이_일치하지_않으면_예외가_발생한다() { // given var 쫑이 = 반려견_생성(); - Member 주인_아닌_사람 = memberRepository.save(MemberFixture.무민()); - var 토큰 = jwtProvider.create(String.valueOf(주인_아닌_사람.getId())); - var 요청_준비 = given(spec).header("Authorization", "Bearer " + 토큰) + var 주인_아닌_사람 = memberRepository.save(MemberFixture.무민()); + var accessToken = jwtProvider.createAccessToken(주인_아닌_사람.getId()); + var refreshToken = jwtProvider.createRefreshToken(); + var 요청_준비 = given(spec).header("Authorization", "Bearer " + accessToken) + .header("Refresh", "Zipgo " + refreshToken) .contentType(JSON).filter(API_반려동물_삭제_예외응답_문서_생성()); // when @@ -241,10 +267,11 @@ class 반려동물_삭제시 { void 사용자_반려동물_조회_성공시_200_반환한다() { // given var 갈비 = petRepository.save(반려견_생성()); - var 토큰 = jwtProvider.create(String.valueOf(갈비.getOwner().getId())); + var 토큰 = jwtProvider.createAccessToken(갈비.getOwner().getId()); var 요청_준비 = given(spec) .header("Authorization", "Bearer " + 토큰) + .header("Refresh", "Zipgo " + 토큰) .contentType(JSON).filter(사용자_반려동물_조회_API_성공()); // when diff --git a/backend/src/test/java/zipgo/petfood/application/PetFoodQueryServiceTest.java b/backend/src/test/java/zipgo/petfood/application/PetFoodQueryServiceTest.java index 1b88effd3..dda3fc7aa 100644 --- a/backend/src/test/java/zipgo/petfood/application/PetFoodQueryServiceTest.java +++ b/backend/src/test/java/zipgo/petfood/application/PetFoodQueryServiceTest.java @@ -1,10 +1,8 @@ package zipgo.petfood.application; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.transaction.annotation.Transactional; import zipgo.brand.domain.Brand; import zipgo.brand.domain.repository.BrandRepository; import zipgo.common.service.ServiceTest; @@ -21,7 +19,9 @@ import zipgo.petfood.dto.response.FilterResponse.FunctionalityResponse; import zipgo.petfood.dto.response.GetPetFoodResponse; import zipgo.petfood.dto.response.GetPetFoodsResponse; +import zipgo.petfood.dto.response.PetFoodResponse; +import java.util.Arrays; import java.util.List; import static java.util.Collections.EMPTY_LIST; @@ -38,7 +38,9 @@ import static zipgo.petfood.domain.fixture.PetFoodFixture.미국_영양기준_만족_식품; import static zipgo.petfood.domain.fixture.PetFoodFixture.유럽_영양기준_만족_식품; import static zipgo.petfood.domain.fixture.PetFoodFunctionalityFixture.식품_기능성_연관관계_매핑; +import static zipgo.petfood.domain.fixture.PetFoodFunctionalityFixture.식품_기능성_추가; import static zipgo.petfood.domain.fixture.PetFoodPrimaryIngredientFixture.식품_주원료_연관관계_매핑; +import static zipgo.petfood.domain.fixture.PetFoodPrimaryIngredientFixture.식품_주원료_추가; import static zipgo.petfood.domain.fixture.PrimaryIngredientFixture.주원료_닭고기; import static zipgo.petfood.domain.fixture.PrimaryIngredientFixture.주원료_돼지고기; import static zipgo.petfood.domain.fixture.PrimaryIngredientFixture.주원료_말미잘; @@ -65,52 +67,26 @@ class PetFoodQueryServiceTest extends ServiceTest { @Autowired private FunctionalityRepository functionalityRepository; - @BeforeEach - void setUp() { - Brand 아카나 = brandRepository.save(아카나_식품_브랜드_생성()); - Brand 오리젠 = brandRepository.save(오리젠_식품_브랜드_생성()); - Brand 퓨리나 = brandRepository.save(퓨리나_식품_브랜드_생성()); - - PetFood 모든_영양기준_만족_식품 = 모든_영양기준_만족_식품(아카나); - PetFood 미국_영양기준_만족_식품 = 미국_영양기준_만족_식품(오리젠); - PetFood 유럽_영양기준_만족_식품 = 유럽_영양기준_만족_식품(퓨리나); - - Functionality 기능성_튼튼 = 기능성_튼튼(); - Functionality 기능성_짱짱 = 기능성_짱짱(); - Functionality 기능성_다이어트 = 기능성_다이어트(); - - 식품_기능성_연관관계_매핑(모든_영양기준_만족_식품, 기능성_튼튼); - 식품_기능성_연관관계_매핑(미국_영양기준_만족_식품, 기능성_짱짱); - 식품_기능성_연관관계_매핑(유럽_영양기준_만족_식품, 기능성_다이어트); - - PrimaryIngredient 원재료_소고기 = 주원료_소고기(); - PrimaryIngredient 원재료_돼지고기 = 주원료_돼지고기(); - PrimaryIngredient 원재료_닭고기 = 주원료_닭고기(); - - 식품_주원료_연관관계_매핑(모든_영양기준_만족_식품, 원재료_소고기); - 식품_주원료_연관관계_매핑(미국_영양기준_만족_식품, 원재료_돼지고기); - 식품_주원료_연관관계_매핑(유럽_영양기준_만족_식품, 원재료_닭고기); - - petFoodRepository.save(모든_영양기준_만족_식품); - petFoodRepository.save(미국_영양기준_만족_식품); - petFoodRepository.save(유럽_영양기준_만족_식품); - - functionalityRepository.save(기능성_튼튼); - functionalityRepository.save(기능성_짱짱); - functionalityRepository.save(기능성_다이어트); - - primaryIngredientRepository.save(원재료_소고기); - primaryIngredientRepository.save(원재료_돼지고기); - primaryIngredientRepository.save(원재료_닭고기); - } - @Nested - @Transactional class 필터_조회 { @Test void 브랜드를_만족하는_식품만_반환한다() { //given + PetFood 모든_영양기준_만족_식품 = 모든_영양기준_만족_식품(brandRepository.save(아카나_식품_브랜드_생성())); + PetFood 미국_영양기준_만족_식품 = 미국_영양기준_만족_식품(brandRepository.save(오리젠_식품_브랜드_생성())); + PetFood 유럽_영양기준_만족_식품 = 유럽_영양기준_만족_식품(brandRepository.save(퓨리나_식품_브랜드_생성())); + + 식품_기능성_추가(모든_영양기준_만족_식품, functionalityRepository.save(기능성_튼튼())); + 식품_기능성_추가(미국_영양기준_만족_식품, functionalityRepository.save(기능성_짱짱())); + 식품_기능성_추가(유럽_영양기준_만족_식품, functionalityRepository.save(기능성_다이어트())); + + 식품_주원료_추가(모든_영양기준_만족_식품, primaryIngredientRepository.save(주원료_소고기())); + 식품_주원료_추가(미국_영양기준_만족_식품, primaryIngredientRepository.save(주원료_돼지고기())); + 식품_주원료_추가(유럽_영양기준_만족_식품, primaryIngredientRepository.save(주원료_닭고기())); + + petFoodRepository.saveAll(List.of(모든_영양기준_만족_식품, 미국_영양기준_만족_식품, 유럽_영양기준_만족_식품)); + List allFoods = petFoodRepository.findAll(); Long lastPetFoodId = getLastPetFoodId(allFoods); @@ -139,8 +115,22 @@ private Long getLastPetFoodId(List allFoods) { } @Test - void 브랜드를_만족하고_lastPetFoodId보다_작은_식품만_반환한다() { + void 브랜드를_만족하고_lastPetFoodId_보다_작은_식품만_반환한다() { //given + PetFood 모든_영양기준_만족_식품 = 모든_영양기준_만족_식품(brandRepository.save(아카나_식품_브랜드_생성())); + PetFood 미국_영양기준_만족_식품 = 미국_영양기준_만족_식품(brandRepository.save(오리젠_식품_브랜드_생성())); + PetFood 유럽_영양기준_만족_식품 = 유럽_영양기준_만족_식품(brandRepository.save(퓨리나_식품_브랜드_생성())); + + 식품_기능성_추가(모든_영양기준_만족_식품, functionalityRepository.save(기능성_튼튼())); + 식품_기능성_추가(미국_영양기준_만족_식품, functionalityRepository.save(기능성_짱짱())); + 식품_기능성_추가(유럽_영양기준_만족_식품, functionalityRepository.save(기능성_다이어트())); + + 식품_주원료_추가(모든_영양기준_만족_식품, primaryIngredientRepository.save(주원료_소고기())); + 식품_주원료_추가(미국_영양기준_만족_식품, primaryIngredientRepository.save(주원료_돼지고기())); + 식품_주원료_추가(유럽_영양기준_만족_식품, primaryIngredientRepository.save(주원료_닭고기())); + + petFoodRepository.saveAll(List.of(모든_영양기준_만족_식품, 미국_영양기준_만족_식품, 유럽_영양기준_만족_식품)); + List allFoods = petFoodRepository.findAll(); Long lastPetFoodId = getLastPetFoodId(allFoods); @@ -167,6 +157,20 @@ private Long getLastPetFoodId(List allFoods) { @Test void 영양_기준을_만족하는_식품만_반환한다() { //given + PetFood 모든_영양기준_만족_식품 = 모든_영양기준_만족_식품(brandRepository.save(아카나_식품_브랜드_생성())); + PetFood 미국_영양기준_만족_식품 = 미국_영양기준_만족_식품(brandRepository.save(오리젠_식품_브랜드_생성())); + PetFood 유럽_영양기준_만족_식품 = 유럽_영양기준_만족_식품(brandRepository.save(퓨리나_식품_브랜드_생성())); + + 식품_기능성_추가(모든_영양기준_만족_식품, functionalityRepository.save(기능성_튼튼())); + 식품_기능성_추가(미국_영양기준_만족_식품, functionalityRepository.save(기능성_짱짱())); + 식품_기능성_추가(유럽_영양기준_만족_식품, functionalityRepository.save(기능성_다이어트())); + + 식품_주원료_추가(모든_영양기준_만족_식품, primaryIngredientRepository.save(주원료_소고기())); + 식품_주원료_추가(미국_영양기준_만족_식품, primaryIngredientRepository.save(주원료_돼지고기())); + 식품_주원료_추가(유럽_영양기준_만족_식품, primaryIngredientRepository.save(주원료_닭고기())); + + petFoodRepository.saveAll(List.of(모든_영양기준_만족_식품, 미국_영양기준_만족_식품, 유럽_영양기준_만족_식품)); + List allFoods = petFoodRepository.findAll(); Long lastPetFoodId = getLastPetFoodId(allFoods); @@ -196,6 +200,20 @@ private Long getLastPetFoodId(List allFoods) { @Test void 주원료를_만족하는_식품만_반환한다() { //given + PetFood 모든_영양기준_만족_식품 = 모든_영양기준_만족_식품(brandRepository.save(아카나_식품_브랜드_생성())); + PetFood 미국_영양기준_만족_식품 = 미국_영양기준_만족_식품(brandRepository.save(오리젠_식품_브랜드_생성())); + PetFood 유럽_영양기준_만족_식품 = 유럽_영양기준_만족_식품(brandRepository.save(퓨리나_식품_브랜드_생성())); + + 식품_기능성_추가(모든_영양기준_만족_식품, functionalityRepository.save(기능성_튼튼())); + 식품_기능성_추가(미국_영양기준_만족_식품, functionalityRepository.save(기능성_짱짱())); + 식품_기능성_추가(유럽_영양기준_만족_식품, functionalityRepository.save(기능성_다이어트())); + + 식품_주원료_추가(모든_영양기준_만족_식품, primaryIngredientRepository.save(주원료_소고기())); + 식품_주원료_추가(미국_영양기준_만족_식품, primaryIngredientRepository.save(주원료_돼지고기())); + 식품_주원료_추가(유럽_영양기준_만족_식품, primaryIngredientRepository.save(주원료_닭고기())); + + petFoodRepository.saveAll(List.of(모든_영양기준_만족_식품, 미국_영양기준_만족_식품, 유럽_영양기준_만족_식품)); + List allFoods = petFoodRepository.findAll(); Long lastPetFoodId = getLastPetFoodId(allFoods); @@ -222,6 +240,20 @@ private Long getLastPetFoodId(List allFoods) { @Test void 기능성을_만족하는_식품만_반환한다() { //given + PetFood 모든_영양기준_만족_식품 = 모든_영양기준_만족_식품(brandRepository.save(아카나_식품_브랜드_생성())); + PetFood 미국_영양기준_만족_식품 = 미국_영양기준_만족_식품(brandRepository.save(오리젠_식품_브랜드_생성())); + PetFood 유럽_영양기준_만족_식품 = 유럽_영양기준_만족_식품(brandRepository.save(퓨리나_식품_브랜드_생성())); + + 식품_기능성_추가(모든_영양기준_만족_식품, functionalityRepository.save(기능성_튼튼())); + 식품_기능성_추가(미국_영양기준_만족_식품, functionalityRepository.save(기능성_짱짱())); + 식품_기능성_추가(유럽_영양기준_만족_식품, functionalityRepository.save(기능성_다이어트())); + + 식품_주원료_추가(모든_영양기준_만족_식품, primaryIngredientRepository.save(주원료_소고기())); + 식품_주원료_추가(미국_영양기준_만족_식품, primaryIngredientRepository.save(주원료_돼지고기())); + 식품_주원료_추가(유럽_영양기준_만족_식품, primaryIngredientRepository.save(주원료_닭고기())); + + petFoodRepository.saveAll(List.of(모든_영양기준_만족_식품, 미국_영양기준_만족_식품, 유럽_영양기준_만족_식품)); + List allFoods = petFoodRepository.findAll(); Long lastPetFoodId = getLastPetFoodId(allFoods); @@ -249,6 +281,20 @@ private Long getLastPetFoodId(List allFoods) { @Test void 모든_필터를_만족하는_식품만_반환한다() { //given + PetFood 모든_영양기준_만족_식품 = 모든_영양기준_만족_식품(brandRepository.save(아카나_식품_브랜드_생성())); + PetFood 미국_영양기준_만족_식품 = 미국_영양기준_만족_식품(brandRepository.save(오리젠_식품_브랜드_생성())); + PetFood 유럽_영양기준_만족_식품 = 유럽_영양기준_만족_식품(brandRepository.save(퓨리나_식품_브랜드_생성())); + + 식품_기능성_추가(모든_영양기준_만족_식품, functionalityRepository.save(기능성_튼튼())); + 식품_기능성_추가(미국_영양기준_만족_식품, functionalityRepository.save(기능성_짱짱())); + 식품_기능성_추가(유럽_영양기준_만족_식품, functionalityRepository.save(기능성_다이어트())); + + 식품_주원료_추가(모든_영양기준_만족_식품, primaryIngredientRepository.save(주원료_소고기())); + 식품_주원료_추가(미국_영양기준_만족_식품, primaryIngredientRepository.save(주원료_돼지고기())); + 식품_주원료_추가(유럽_영양기준_만족_식품, primaryIngredientRepository.save(주원료_닭고기())); + + petFoodRepository.saveAll(List.of(모든_영양기준_만족_식품, 미국_영양기준_만족_식품, 유럽_영양기준_만족_식품)); + List allFoods = petFoodRepository.findAll(); Long lastPetFoodId = allFoods.get(allFoods.size() - 1).getId(); @@ -273,8 +319,22 @@ private Long getLastPetFoodId(List allFoods) { } @Test - void 모든_정보가_NULL일_경우_모든_식품을_반환한다() { + void 모든_정보가_EMPTY_일_경우_모든_식품을_반환한다() { //given + PetFood 모든_영양기준_만족_식품 = 모든_영양기준_만족_식품(brandRepository.save(아카나_식품_브랜드_생성())); + PetFood 미국_영양기준_만족_식품 = 미국_영양기준_만족_식품(brandRepository.save(오리젠_식품_브랜드_생성())); + PetFood 유럽_영양기준_만족_식품 = 유럽_영양기준_만족_식품(brandRepository.save(퓨리나_식품_브랜드_생성())); + + 식품_기능성_추가(모든_영양기준_만족_식품, functionalityRepository.save(기능성_튼튼())); + 식품_기능성_추가(미국_영양기준_만족_식품, functionalityRepository.save(기능성_짱짱())); + 식품_기능성_추가(유럽_영양기준_만족_식품, functionalityRepository.save(기능성_다이어트())); + + 식품_주원료_추가(모든_영양기준_만족_식품, primaryIngredientRepository.save(주원료_소고기())); + 식품_주원료_추가(미국_영양기준_만족_식품, primaryIngredientRepository.save(주원료_돼지고기())); + 식품_주원료_추가(유럽_영양기준_만족_식품, primaryIngredientRepository.save(주원료_닭고기())); + + petFoodRepository.saveAll(List.of(모든_영양기준_만족_식품, 미국_영양기준_만족_식품, 유럽_영양기준_만족_식품)); + List allFoods = petFoodRepository.findAll(); Long lastPetFoodId = getLastPetFoodId(allFoods); @@ -297,8 +357,19 @@ private Long getLastPetFoodId(List allFoods) { @Test void lastPetFoodId_보다_작은_식품을_반환한다() { //given - List allFoods = petFoodRepository.findAll(); - Long lastPetFoodId = getLastPetFoodId(allFoods); + PetFood 모든_영양기준_만족_식품 = 모든_영양기준_만족_식품(brandRepository.save(아카나_식품_브랜드_생성())); + PetFood 미국_영양기준_만족_식품 = 미국_영양기준_만족_식품(brandRepository.save(오리젠_식품_브랜드_생성())); + PetFood 유럽_영양기준_만족_식품 = 유럽_영양기준_만족_식품(brandRepository.save(퓨리나_식품_브랜드_생성())); + + 식품_기능성_추가(모든_영양기준_만족_식품, functionalityRepository.save(기능성_튼튼())); + 식품_기능성_추가(미국_영양기준_만족_식품, functionalityRepository.save(기능성_짱짱())); + 식품_기능성_추가(유럽_영양기준_만족_식품, functionalityRepository.save(기능성_다이어트())); + + 식품_주원료_추가(모든_영양기준_만족_식품, primaryIngredientRepository.save(주원료_소고기())); + 식품_주원료_추가(미국_영양기준_만족_식품, primaryIngredientRepository.save(주원료_돼지고기())); + 식품_주원료_추가(유럽_영양기준_만족_식품, primaryIngredientRepository.save(주원료_닭고기())); + + petFoodRepository.saveAll(List.of(모든_영양기준_만족_식품, 미국_영양기준_만족_식품, 유럽_영양기준_만족_식품)); // when GetPetFoodsResponse petFoodsResponse = petFoodQueryService.getPetFoodsByFilters( @@ -308,26 +379,24 @@ private Long getLastPetFoodId(List allFoods) { EMPTY_LIST, EMPTY_LIST ), - lastPetFoodId, + 유럽_영양기준_만족_식품.getId() - 1, size ); // then - assertThat(petFoodsResponse.petFoods()).hasSize(allFoods.size()); + assertThat(petFoodsResponse.petFoods()).hasSize(2); } @Test - void 처음_조회_시_정해진_size_이내로_반환한다() { + void 처음_조회시_식품이_최신순으로_정해진_size_이내로_반환한다() { //given Brand 인스팅트 = brandRepository.save(인스팅트_식품_브랜드_생성()); PrimaryIngredient 원재료_말미잘 = primaryIngredientRepository.save(주원료_말미잘()); - Functionality 기능성_짱짱짱 = functionalityRepository.save(FunctionalityFixture.기능성_짱짱짱()); - for (int i = 0; i < 20; i++) { - PetFood 미국_영양기준_만족_식품 = savePetFood(인스팅트); - PetFood 미국_영양기준_만족_식품1 = 미국_영양기준_만족_식품(인스팅트); - 식품_주원료_연관관계_매핑(미국_영양기준_만족_식품, 원재료_말미잘); - 식품_기능성_연관관계_매핑(미국_영양기준_만족_식품, 기능성_짱짱짱); - petFoodRepository.save(미국_영양기준_만족_식품1); + Functionality 기능성_짱짱 = functionalityRepository.save(FunctionalityFixture.기능성_짱짱()); + for (int i = 0; i < 25; i++) { + PetFood 추가_미국_영양기준_만족_식품 = savePetFood(인스팅트); + 식품_주원료_연관관계_매핑(추가_미국_영양기준_만족_식품, 원재료_말미잘); + 식품_기능성_연관관계_매핑(추가_미국_영양기준_만족_식품, 기능성_짱짱); } // when @@ -343,21 +412,49 @@ private Long getLastPetFoodId(List allFoods) { ); // then - assertThat(petFoodsResponse.petFoods()).hasSize(20); + final List petFoods = petFoodsResponse.petFoods(); + assertAll( + () -> assertThat(petFoodsResponse.totalCount()).isEqualTo(25), + () -> assertThat(petFoodsResponse.petFoods()).hasSize(20), + () -> assertThat(isLatest(petFoods.get(0).id(), petFoods)).isTrue() + ); } private PetFood savePetFood(Brand 브랜드) { return petFoodRepository.save(미국_영양기준_만족_식품(브랜드)); } + private boolean isLatest(long id, List petFoodResponses) { + for (PetFoodResponse petFoodResponse : petFoodResponses) { + if (petFoodResponse.id() > id) { + return false; + } + } + return true; + } + } @Nested class 상세_조회 { @Test - void 식품_상세조회할수_있다() { + void 식품에_대한_상세조회를_할_수_있다() { //given + PetFood 모든_영양기준_만족_식품 = 모든_영양기준_만족_식품(brandRepository.save(아카나_식품_브랜드_생성())); + PetFood 미국_영양기준_만족_식품 = 미국_영양기준_만족_식품(brandRepository.save(오리젠_식품_브랜드_생성())); + PetFood 유럽_영양기준_만족_식품 = 유럽_영양기준_만족_식품(brandRepository.save(퓨리나_식품_브랜드_생성())); + + 식품_기능성_추가(모든_영양기준_만족_식품, functionalityRepository.save(기능성_튼튼())); + 식품_기능성_추가(미국_영양기준_만족_식품, functionalityRepository.save(기능성_짱짱())); + 식품_기능성_추가(유럽_영양기준_만족_식품, functionalityRepository.save(기능성_다이어트())); + + 식품_주원료_추가(모든_영양기준_만족_식품, primaryIngredientRepository.save(주원료_소고기())); + 식품_주원료_추가(미국_영양기준_만족_식품, primaryIngredientRepository.save(주원료_돼지고기())); + 식품_주원료_추가(유럽_영양기준_만족_식품, primaryIngredientRepository.save(주원료_닭고기())); + + petFoodRepository.saveAll(List.of(모든_영양기준_만족_식품, 미국_영양기준_만족_식품, 유럽_영양기준_만족_식품)); + PetFood 테스트용_식품 = petFoodRepository.findAll().get(0); Brand 브랜드 = brandRepository.findById(테스트용_식품.getBrand().getId()).get(); @@ -365,23 +462,30 @@ class 상세_조회 { GetPetFoodResponse 응답 = petFoodQueryService.getPetFoodResponse(테스트용_식품.getId()); //then - assertThat(응답.id()).isEqualTo(테스트용_식품.getId()); - assertThat(응답.name()).isEqualTo(테스트용_식품.getName()); - assertThat(응답.imageUrl()).isEqualTo(테스트용_식품.getImageUrl()); - assertThat(응답.purchaseUrl()).isEqualTo(테스트용_식품.getPurchaseLink()); - assertThat(응답.brand().name()).isEqualTo(브랜드.getName()); - assertThat(응답.brand().foundedYear()).isEqualTo(브랜드.getFoundedYear()); - assertThat(응답.brand().nation()).isEqualTo(브랜드.getNation()); - assertThat(응답.rating()).isEqualTo(0); - assertThat(응답.reviewCount()).isEqualTo(0); - assertThat(응답.hasStandard().hasEuStandard()).isTrue(); - assertThat(응답.hasStandard().hasUsStandard()).isTrue(); + assertAll( + () -> assertThat(응답.id()).isEqualTo(테스트용_식품.getId()), + () -> assertThat(응답.name()).isEqualTo(테스트용_식품.getName()), + () -> assertThat(응답.imageUrl()).isEqualTo(테스트용_식품.getImageUrl()), + () -> assertThat(응답.purchaseUrl()).isEqualTo(테스트용_식품.getPurchaseLink()), + () -> assertThat(응답.brand().name()).isEqualTo(브랜드.getName()), + () -> assertThat(응답.brand().foundedYear()).isEqualTo(브랜드.getFoundedYear()), + () -> assertThat(응답.brand().nation()).isEqualTo(브랜드.getNation()), + () -> assertThat(응답.rating()).isEqualTo(0), + () -> assertThat(응답.reviewCount()).isEqualTo(0), + () -> assertThat(응답.hasStandard().hasEuStandard()).isTrue(), + () -> assertThat(응답.hasStandard().hasUsStandard()).isTrue() + ); } } @Test void 필터링에_필요한_식품_데이터를_조회한다() { + // given + saveBrands(아카나_식품_브랜드_생성(), 오리젠_식품_브랜드_생성(), 퓨리나_식품_브랜드_생성()); + saveFunctionalities(기능성_튼튼(), 기능성_짱짱(), 기능성_다이어트()); + savePrimaryIngredients(주원료_소고기(), 주원료_돼지고기(), 주원료_닭고기()); + // when FilterResponse metadata = petFoodQueryService.getMetadataForFilter(); @@ -398,5 +502,17 @@ class 상세_조회 { ); } + private void saveBrands(Brand... brands) { + brandRepository.saveAll(Arrays.asList(brands)); + } + + private void saveFunctionalities(Functionality... functionalities) { + functionalityRepository.saveAll(Arrays.asList(functionalities)); + } + + private void savePrimaryIngredients(PrimaryIngredient... primaryIngredients) { + primaryIngredientRepository.saveAll(Arrays.asList(primaryIngredients)); + } + } diff --git a/backend/src/test/java/zipgo/petfood/domain/fixture/FunctionalityFixture.java b/backend/src/test/java/zipgo/petfood/domain/fixture/FunctionalityFixture.java index c53ceb9ca..4bca06002 100644 --- a/backend/src/test/java/zipgo/petfood/domain/fixture/FunctionalityFixture.java +++ b/backend/src/test/java/zipgo/petfood/domain/fixture/FunctionalityFixture.java @@ -29,11 +29,4 @@ public class FunctionalityFixture { return 짱짱; } - public static Functionality 기능성_짱짱짱() { - Functionality 짱짱 = Functionality.builder() - .name("짱짱짱") - .build(); - return 짱짱; - } - } diff --git a/backend/src/test/java/zipgo/petfood/domain/fixture/PetFoodFixture.java b/backend/src/test/java/zipgo/petfood/domain/fixture/PetFoodFixture.java index 2e03914cc..de498c314 100644 --- a/backend/src/test/java/zipgo/petfood/domain/fixture/PetFoodFixture.java +++ b/backend/src/test/java/zipgo/petfood/domain/fixture/PetFoodFixture.java @@ -1,11 +1,13 @@ package zipgo.petfood.domain.fixture; +import java.util.Arrays; import java.util.List; import zipgo.brand.domain.Brand; import zipgo.petfood.domain.HasStandard; import zipgo.petfood.domain.PetFood; import zipgo.petfood.domain.Reviews; import zipgo.admin.dto.PetFoodCreateRequest; +import zipgo.petfood.domain.repository.PetFoodRepository; import static zipgo.petfood.domain.PetFood.builder; diff --git a/backend/src/test/java/zipgo/petfood/domain/fixture/PetFoodFunctionalityFixture.java b/backend/src/test/java/zipgo/petfood/domain/fixture/PetFoodFunctionalityFixture.java index 203e716d0..408833f43 100644 --- a/backend/src/test/java/zipgo/petfood/domain/fixture/PetFoodFunctionalityFixture.java +++ b/backend/src/test/java/zipgo/petfood/domain/fixture/PetFoodFunctionalityFixture.java @@ -3,15 +3,24 @@ import zipgo.petfood.domain.Functionality; import zipgo.petfood.domain.PetFood; import zipgo.petfood.domain.PetFoodFunctionality; +import zipgo.petfood.domain.repository.FunctionalityRepository; public class PetFoodFunctionalityFixture { - public static void 식품_기능성_연관관계_매핑(PetFood petFood, Functionality functionality) { + public static PetFoodFunctionality 식품_기능성_연관관계_매핑(PetFood petFood, Functionality functionality) { PetFoodFunctionality petFoodFunctionality = PetFoodFunctionality.builder() .petFood(petFood) .functionality(functionality) .build(); petFoodFunctionality.changeRelations(petFood, functionality); + return petFoodFunctionality; + } + + public static PetFoodFunctionality 식품_기능성_추가( + PetFood petFood, + Functionality functionality + ) { + return 식품_기능성_연관관계_매핑(petFood, functionality); } } diff --git a/backend/src/test/java/zipgo/petfood/domain/fixture/PetFoodPrimaryIngredientFixture.java b/backend/src/test/java/zipgo/petfood/domain/fixture/PetFoodPrimaryIngredientFixture.java index 661c7705e..b1e2b7ef3 100644 --- a/backend/src/test/java/zipgo/petfood/domain/fixture/PetFoodPrimaryIngredientFixture.java +++ b/backend/src/test/java/zipgo/petfood/domain/fixture/PetFoodPrimaryIngredientFixture.java @@ -3,12 +3,21 @@ import zipgo.petfood.domain.PetFood; import zipgo.petfood.domain.PetFoodPrimaryIngredient; import zipgo.petfood.domain.PrimaryIngredient; +import zipgo.petfood.domain.repository.PrimaryIngredientRepository; public class PetFoodPrimaryIngredientFixture { - public static void 식품_주원료_연관관계_매핑(PetFood petFood, PrimaryIngredient primaryIngredient) { + public static PetFoodPrimaryIngredient 식품_주원료_연관관계_매핑(PetFood petFood, PrimaryIngredient primaryIngredient) { PetFoodPrimaryIngredient petFoodPrimaryIngredient = new PetFoodPrimaryIngredient(); petFoodPrimaryIngredient.changeRelations(petFood, primaryIngredient); + return petFoodPrimaryIngredient; + } + + public static PetFoodPrimaryIngredient 식품_주원료_추가( + PetFood petFood, + PrimaryIngredient primaryIngredient + ) { + return 식품_주원료_연관관계_매핑(petFood, primaryIngredient); } } diff --git a/backend/src/test/java/zipgo/petfood/infra/persist/PetFoodQueryRepositoryTest.java b/backend/src/test/java/zipgo/petfood/infra/persist/PetFoodQueryRepositoryTest.java index 371bc9476..ef5c56613 100644 --- a/backend/src/test/java/zipgo/petfood/infra/persist/PetFoodQueryRepositoryTest.java +++ b/backend/src/test/java/zipgo/petfood/infra/persist/PetFoodQueryRepositoryTest.java @@ -1,26 +1,24 @@ package zipgo.petfood.infra.persist; -import java.util.List; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.transaction.annotation.Transactional; -import zipgo.brand.domain.Brand; import zipgo.brand.domain.repository.BrandRepository; -import zipgo.petfood.domain.Functionality; import zipgo.petfood.domain.PetFood; -import zipgo.petfood.domain.PrimaryIngredient; import zipgo.petfood.domain.repository.FunctionalityRepository; +import zipgo.petfood.domain.repository.PetFoodQueryRepository; import zipgo.petfood.domain.repository.PetFoodRepository; import zipgo.petfood.domain.repository.PrimaryIngredientRepository; import zipgo.petfood.dto.response.GetPetFoodQueryResponse; +import java.util.List; + import static java.util.Collections.EMPTY_LIST; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; import static zipgo.brand.domain.fixture.BrandFixture.아카나_식품_브랜드_생성; import static zipgo.brand.domain.fixture.BrandFixture.오리젠_식품_브랜드_생성; import static zipgo.brand.domain.fixture.BrandFixture.퓨리나_식품_브랜드_생성; @@ -31,7 +29,9 @@ import static zipgo.petfood.domain.fixture.PetFoodFixture.미국_영양기준_만족_식품; import static zipgo.petfood.domain.fixture.PetFoodFixture.유럽_영양기준_만족_식품; import static zipgo.petfood.domain.fixture.PetFoodFunctionalityFixture.식품_기능성_연관관계_매핑; +import static zipgo.petfood.domain.fixture.PetFoodFunctionalityFixture.식품_기능성_추가; import static zipgo.petfood.domain.fixture.PetFoodPrimaryIngredientFixture.식품_주원료_연관관계_매핑; +import static zipgo.petfood.domain.fixture.PetFoodPrimaryIngredientFixture.식품_주원료_추가; import static zipgo.petfood.domain.fixture.PrimaryIngredientFixture.주원료_닭고기; import static zipgo.petfood.domain.fixture.PrimaryIngredientFixture.주원료_돼지고기; import static zipgo.petfood.domain.fixture.PrimaryIngredientFixture.주원료_말미잘; @@ -47,7 +47,7 @@ class PetFoodQueryRepositoryTest { private PetFoodRepository petFoodRepository; @Autowired - private PetFoodQueryRepositoryImpl petFoodQueryRepository; + private PetFoodQueryRepository petFoodQueryRepository; @Autowired private BrandRepository brandRepository; @@ -58,48 +58,23 @@ class PetFoodQueryRepositoryTest { @Autowired private PrimaryIngredientRepository primaryIngredientRepository; - @BeforeEach - void setUp() { - Brand 아카나 = brandRepository.save(아카나_식품_브랜드_생성()); - Brand 오리젠 = brandRepository.save(오리젠_식품_브랜드_생성()); - Brand 퓨리나 = brandRepository.save(퓨리나_식품_브랜드_생성()); - - PetFood 모든_영양기준_만족_식품 = 모든_영양기준_만족_식품(아카나); - PetFood 미국_영양기준_만족_식품 = 미국_영양기준_만족_식품(오리젠); - PetFood 유럽_영양기준_만족_식품 = 유럽_영양기준_만족_식품(퓨리나); - - Functionality 기능성_튼튼 = 기능성_튼튼(); - Functionality 기능성_짱짱 = 기능성_짱짱(); - Functionality 기능성_다이어트 = 기능성_다이어트(); - - 식품_기능성_연관관계_매핑(모든_영양기준_만족_식품, 기능성_튼튼); - 식품_기능성_연관관계_매핑(미국_영양기준_만족_식품, 기능성_짱짱); - 식품_기능성_연관관계_매핑(유럽_영양기준_만족_식품, 기능성_다이어트); - - PrimaryIngredient 원재료_소고기 = 주원료_소고기(); - PrimaryIngredient 원재료_돼지고기 = 주원료_돼지고기(); - PrimaryIngredient 원재료_닭고기 = 주원료_닭고기(); - - 식품_주원료_연관관계_매핑(모든_영양기준_만족_식품, 원재료_소고기); - 식품_주원료_연관관계_매핑(미국_영양기준_만족_식품, 원재료_돼지고기); - 식품_주원료_연관관계_매핑(유럽_영양기준_만족_식품, 원재료_닭고기); + @Test + void 조건에_맞는_식품을_반환한다() { + // given + PetFood 모든_영양기준_만족_식품 = 모든_영양기준_만족_식품(brandRepository.save(아카나_식품_브랜드_생성())); + PetFood 미국_영양기준_만족_식품 = 미국_영양기준_만족_식품(brandRepository.save(오리젠_식품_브랜드_생성())); + PetFood 유럽_영양기준_만족_식품 = 유럽_영양기준_만족_식품(brandRepository.save(퓨리나_식품_브랜드_생성())); - petFoodRepository.save(모든_영양기준_만족_식품); - petFoodRepository.save(미국_영양기준_만족_식품); - petFoodRepository.save(유럽_영양기준_만족_식품); + 식품_기능성_추가(모든_영양기준_만족_식품, functionalityRepository.save(기능성_튼튼())); + 식품_기능성_추가(미국_영양기준_만족_식품, functionalityRepository.save(기능성_짱짱())); + 식품_기능성_추가(유럽_영양기준_만족_식품, functionalityRepository.save(기능성_다이어트())); - functionalityRepository.save(기능성_튼튼); - functionalityRepository.save(기능성_짱짱); - functionalityRepository.save(기능성_다이어트); + 식품_주원료_추가(모든_영양기준_만족_식품, primaryIngredientRepository.save(주원료_소고기())); + 식품_주원료_추가(미국_영양기준_만족_식품, primaryIngredientRepository.save(주원료_돼지고기())); + 식품_주원료_추가(유럽_영양기준_만족_식품, primaryIngredientRepository.save(주원료_닭고기())); - primaryIngredientRepository.save(원재료_소고기); - primaryIngredientRepository.save(원재료_돼지고기); - primaryIngredientRepository.save(원재료_닭고기); - } + petFoodRepository.saveAll(List.of(모든_영양기준_만족_식품, 미국_영양기준_만족_식품, 유럽_영양기준_만족_식품)); - @Test - void 조건에_맞는_식품을_필터링한다() { - // given List allFoods = petFoodRepository.findAll(); Long lastPetFoodId = allFoods.get(allFoods.size() - 1).getId(); @@ -107,13 +82,14 @@ void setUp() { List standards = List.of("미국"); List primaryIngredientList = EMPTY_LIST; List functionalityList = EMPTY_LIST; + int size = 20; // when - List responses = petFoodQueryRepository.findPagingPetFoods(brandsName, standards, primaryIngredientList, - functionalityList, lastPetFoodId, 20); + List responses = petFoodQueryRepository.findPagingPetFoods(brandsName, standards, + primaryIngredientList, functionalityList, lastPetFoodId, size); // then - Assertions.assertAll( + assertAll( () -> assertThat(responses).hasSize(1), () -> assertThat(responses).extracting(GetPetFoodQueryResponse::foodName) .contains("미국 영양기준 만족 식품"), @@ -123,8 +99,22 @@ void setUp() { } @Test - void 같은_식품을_제거하고_limit_개수만큼_반환한다() { + void 조건에_맞는_식품의_전체_개수를_반환한다() { // given + PetFood 모든_영양기준_만족_식품 = 모든_영양기준_만족_식품(brandRepository.save(아카나_식품_브랜드_생성())); + PetFood 미국_영양기준_만족_식품 = 미국_영양기준_만족_식품(brandRepository.save(오리젠_식품_브랜드_생성())); + PetFood 유럽_영양기준_만족_식품 = 유럽_영양기준_만족_식품(brandRepository.save(퓨리나_식품_브랜드_생성())); + + 식품_기능성_추가(모든_영양기준_만족_식품, functionalityRepository.save(기능성_튼튼())); + 식품_기능성_추가(미국_영양기준_만족_식품, functionalityRepository.save(기능성_짱짱())); + 식품_기능성_추가(유럽_영양기준_만족_식품, functionalityRepository.save(기능성_다이어트())); + + 식품_주원료_추가(모든_영양기준_만족_식품, primaryIngredientRepository.save(주원료_소고기())); + 식품_주원료_추가(미국_영양기준_만족_식품, primaryIngredientRepository.save(주원료_돼지고기())); + 식품_주원료_추가(유럽_영양기준_만족_식품, primaryIngredientRepository.save(주원료_닭고기())); + + petFoodRepository.saveAll(List.of(모든_영양기준_만족_식품, 미국_영양기준_만족_식품, 유럽_영양기준_만족_식품)); + List allFoods = petFoodRepository.findAll(); Long lastPetFoodId = allFoods.get(allFoods.size() - 1).getId(); @@ -139,11 +129,11 @@ void setUp() { List functionalityList = EMPTY_LIST; // when - List responses = petFoodQueryRepository.findPagingPetFoods(brandsName, standards, primaryIngredientList, - functionalityList, lastPetFoodId, 20); + Long petFoodsCount = petFoodQueryRepository.findPetFoodsCount(brandsName, standards, + primaryIngredientList, functionalityList); // then - assertThat(responses).hasSize(3); + assertThat(petFoodsCount).isEqualTo(3); } } diff --git a/backend/src/test/java/zipgo/petfood/presentation/PetFoodControllerTest.java b/backend/src/test/java/zipgo/petfood/presentation/PetFoodControllerTest.java index b369a7838..85798bfb9 100644 --- a/backend/src/test/java/zipgo/petfood/presentation/PetFoodControllerTest.java +++ b/backend/src/test/java/zipgo/petfood/presentation/PetFoodControllerTest.java @@ -2,19 +2,14 @@ import com.epages.restdocs.apispec.ResourceSnippetDetails; import com.epages.restdocs.apispec.Schema; -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.Autowired; import org.springframework.restdocs.payload.JsonFieldType; import org.springframework.restdocs.restassured.RestDocumentationFilter; -import zipgo.brand.domain.Brand; import zipgo.brand.domain.repository.BrandRepository; import zipgo.common.acceptance.AcceptanceTest; -import zipgo.petfood.domain.Functionality; -import zipgo.petfood.domain.PetFood; -import zipgo.petfood.domain.PrimaryIngredient; import zipgo.petfood.domain.repository.FunctionalityRepository; import zipgo.petfood.domain.repository.PetFoodRepository; import zipgo.petfood.domain.repository.PrimaryIngredientRepository; @@ -42,8 +37,8 @@ import static zipgo.petfood.domain.fixture.FunctionalityFixture.기능성_다이어트; import static zipgo.petfood.domain.fixture.FunctionalityFixture.기능성_튼튼; import static zipgo.petfood.domain.fixture.PetFoodFixture.모든_영양기준_만족_식품; -import static zipgo.petfood.domain.fixture.PetFoodFunctionalityFixture.식품_기능성_연관관계_매핑; -import static zipgo.petfood.domain.fixture.PetFoodPrimaryIngredientFixture.식품_주원료_연관관계_매핑; +import static zipgo.petfood.domain.fixture.PetFoodFunctionalityFixture.식품_기능성_추가; +import static zipgo.petfood.domain.fixture.PetFoodPrimaryIngredientFixture.식품_주원료_추가; import static zipgo.petfood.domain.fixture.PrimaryIngredientFixture.주원료_닭고기; public class PetFoodControllerTest extends AcceptanceTest { @@ -60,26 +55,6 @@ public class PetFoodControllerTest extends AcceptanceTest { @Autowired private FunctionalityRepository functionalityRepository; - private PetFood 식품; - - @BeforeEach - void setUp() { - Brand 아카나 = brandRepository.save(아카나_식품_브랜드_생성()); - PetFood 모든_영양기준_만족_식품 = 모든_영양기준_만족_식품(아카나); - Functionality 기능성_다이어트 = 기능성_다이어트(); - Functionality 기능성_튼튼 = 기능성_튼튼(); - PrimaryIngredient 주원료_닭고기 = 주원료_닭고기(); - - 식품_기능성_연관관계_매핑(모든_영양기준_만족_식품, 기능성_다이어트); - 식품_기능성_연관관계_매핑(모든_영양기준_만족_식품, 기능성_튼튼); - 식품_주원료_연관관계_매핑(모든_영양기준_만족_식품, 주원료_닭고기); - - functionalityRepository.save(기능성_다이어트); - functionalityRepository.save(기능성_튼튼); - primaryIngredientRepository.save(주원료_닭고기); - 식품 = petFoodRepository.save(모든_영양기준_만족_식품); - } - @Nested @DisplayName("식품 전체 조회 API") class GetPetFoods { @@ -91,8 +66,17 @@ class GetPetFoods { .description("식품 전체를 조회합니다."); @Test - void 필터를_지정하지_않고_요청한다() { + void 필터를_지정하지_않고_요청하면_200을_반환한다() { // given + var 모든_영양기준_만족_식품 = 모든_영양기준_만족_식품(brandRepository.save(아카나_식품_브랜드_생성())); + + 식품_기능성_추가(모든_영양기준_만족_식품, functionalityRepository.save(기능성_다이어트())); + 식품_기능성_추가(모든_영양기준_만족_식품, functionalityRepository.save(기능성_튼튼())); + + 식품_주원료_추가(모든_영양기준_만족_식품, primaryIngredientRepository.save(주원료_닭고기())); + + petFoodRepository.save(모든_영양기준_만족_식품); + var 요청_준비 = given(spec) .queryParam("size", 20) .filter(성공_API_문서_생성("식품 필터링 없이 조회 - 성공(전체 조회)")); @@ -107,8 +91,17 @@ class GetPetFoods { } @Test - void 필터를_지정해서_요청한다() { + void 필터를_지정해서_요청하면_200을_반환한다() { // given + var 모든_영양기준_만족_식품 = 모든_영양기준_만족_식품(brandRepository.save(아카나_식품_브랜드_생성())); + + 식품_기능성_추가(모든_영양기준_만족_식품, functionalityRepository.save(기능성_다이어트())); + 식품_기능성_추가(모든_영양기준_만족_식품, functionalityRepository.save(기능성_튼튼())); + + 식품_주원료_추가(모든_영양기준_만족_식품, primaryIngredientRepository.save(주원료_닭고기())); + + petFoodRepository.save(모든_영양기준_만족_식품); + List 브랜드 = List.of("아카나"); List 영양기준 = List.of("유럽"); List 기능성 = List.of("튼튼"); @@ -164,15 +157,24 @@ class GetPetFood { .description("id에 해당하는 식품 상세정보를 조회합니다."); @Test - void 올바른_요청() { + void 식품_상세를_요청하면_200을_반환한다() { // given + var 모든_영양기준_만족_식품 = 모든_영양기준_만족_식품(brandRepository.save(아카나_식품_브랜드_생성())); + + 식품_기능성_추가(모든_영양기준_만족_식품, functionalityRepository.save(기능성_다이어트())); + 식품_기능성_추가(모든_영양기준_만족_식품, functionalityRepository.save(기능성_튼튼())); + + 식품_주원료_추가(모든_영양기준_만족_식품, primaryIngredientRepository.save(주원료_닭고기())); + + petFoodRepository.save(모든_영양기준_만족_식품); + var 요청_준비 = given(spec) .contentType(JSON) .filter(식품_상세_조회_API_문서_생성()); // when var 응답 = 요청_준비.when() - .pathParam("id", 식품.getId()) + .pathParam("id", 모든_영양기준_만족_식품.getId()) .get("/pet-foods/{id}"); // then @@ -180,16 +182,6 @@ class GetPetFood { .assertThat().statusCode(OK.value()); } - private void 주원료_만들기() { - PrimaryIngredient 주원료 = 주원료_닭고기(); - primaryIngredientRepository.save(주원료); - } - - private PetFood 모의_식품_생성() { - Brand 아카나 = brandRepository.save(아카나_식품_브랜드_생성()); - return petFoodRepository.save(모든_영양기준_만족_식품(아카나)); - } - private RestDocumentationFilter 식품_상세_조회_API_문서_생성() { return document("식품 상세 조회 - 성공", 문서_정보.responseSchema(성공_응답_형식), @@ -217,7 +209,7 @@ class GetPetFood { } @Test - void 존재하지_않는_아이디로_요청한다() { + void 존재하지_않는_아이디로_요청하면_404를_반환한다() { //given var 요청_준비 = given(spec) .contentType(JSON) @@ -234,7 +226,7 @@ class GetPetFood { } @Test - void 올바르지_않은_형식의_아이디로_요청한다() { + void 올바르지_않은_형식의_아이디로_요청하면_400을_반환한다() { //given var 요청_준비 = given(spec) .contentType(JSON) @@ -289,7 +281,17 @@ class GetFilterInfo { .description("필터링에 필요한 메타데이터 정보를 조회합니다."); @Test - void 필터링에_필요한_메타데이터를_조회한다() { + void 필터링에_필요한_메타데이터를_조회하면_200을_반환한다() { + // given + var 모든_영양기준_만족_식품 = 모든_영양기준_만족_식품(brandRepository.save(아카나_식품_브랜드_생성())); + + 식품_기능성_추가(모든_영양기준_만족_식품, functionalityRepository.save(기능성_다이어트())); + 식품_기능성_추가(모든_영양기준_만족_식품, functionalityRepository.save(기능성_튼튼())); + + 식품_주원료_추가(모든_영양기준_만족_식품, primaryIngredientRepository.save(주원료_닭고기())); + + petFoodRepository.save(모든_영양기준_만족_식품); + // when var 응답 = given(spec) .filter(필터링_메타데이터_API_문서_생성()) diff --git a/backend/src/test/java/zipgo/review/application/ReviewQueryServiceTest.java b/backend/src/test/java/zipgo/review/application/ReviewQueryServiceTest.java index 6daa3111c..75ff103ee 100644 --- a/backend/src/test/java/zipgo/review/application/ReviewQueryServiceTest.java +++ b/backend/src/test/java/zipgo/review/application/ReviewQueryServiceTest.java @@ -1,12 +1,11 @@ package zipgo.review.application; -import java.util.List; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import zipgo.brand.domain.Brand; import zipgo.brand.domain.repository.BrandRepository; -import zipgo.common.service.QueryServiceTest; +import zipgo.common.service.ServiceTest; import zipgo.member.domain.Member; import zipgo.member.domain.repository.MemberRepository; import zipgo.pet.domain.Breed; @@ -29,6 +28,8 @@ import zipgo.review.dto.response.type.StoolConditionResponse; import zipgo.review.dto.response.type.TastePreferenceResponse; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import static zipgo.brand.domain.fixture.BrandFixture.아카나_식품_브랜드_생성; @@ -46,7 +47,7 @@ import static zipgo.review.fixture.ReviewFixture.극찬_리뷰_생성; import static zipgo.review.fixture.ReviewFixture.혹평_리뷰_생성; -class ReviewQueryServiceTest extends QueryServiceTest { +class ReviewQueryServiceTest extends ServiceTest { @Autowired private PetFoodRepository petFoodRepository; @@ -204,7 +205,7 @@ class 리뷰_요약_조회 { public static void validateReviewsRatingSummary(GetReviewsSummaryResponse reviewsSummary) { assertAll( () -> assertThat(reviewsSummary.rating().rating()).hasSize(5), - () -> assertThat(reviewsSummary.rating().rating()).extracting(RatingInfoResponse::rating) + () -> assertThat(reviewsSummary.rating().rating()).extracting(RatingInfoResponse::name) .contains("1", "2", "3", "4", "5"), () -> assertThat(reviewsSummary.rating().rating()).extracting(RatingInfoResponse::percentage) .contains(0, 50) diff --git a/backend/src/test/java/zipgo/review/domain/repository/ReviewQueryRepositoryImplTest.java b/backend/src/test/java/zipgo/review/domain/repository/ReviewQueryRepositoryImplTest.java index 7f0bccab1..b13d17944 100644 --- a/backend/src/test/java/zipgo/review/domain/repository/ReviewQueryRepositoryImplTest.java +++ b/backend/src/test/java/zipgo/review/domain/repository/ReviewQueryRepositoryImplTest.java @@ -1,21 +1,12 @@ package zipgo.review.domain.repository; -import java.time.LocalDateTime; -import java.time.Year; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Random; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.context.annotation.Import; import org.springframework.transaction.annotation.Transactional; import zipgo.brand.domain.Brand; import zipgo.brand.domain.repository.BrandRepository; -import zipgo.common.config.QueryDslTestConfig; +import zipgo.common.repository.RepositoryTest; import zipgo.member.domain.Member; import zipgo.member.domain.fixture.MemberFixture; import zipgo.member.domain.repository.MemberRepository; @@ -37,6 +28,13 @@ import zipgo.review.domain.repository.dto.ReviewHelpfulReaction; import zipgo.review.domain.type.AdverseReactionType; +import java.time.LocalDateTime; +import java.time.Year; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; + import static java.util.Collections.emptyList; import static java.util.Collections.reverseOrder; import static org.assertj.core.api.Assertions.assertThat; @@ -52,10 +50,7 @@ import static zipgo.review.domain.type.TastePreference.EATS_VERY_WELL; import static zipgo.review.fixture.ReviewFixture.극찬_리뷰_생성; -@Import(QueryDslTestConfig.class) -@DataJpaTest(properties = {"spring.sql.init.mode=never"}) -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -class ReviewQueryRepositoryImplTest { +class ReviewQueryRepositoryImplTest extends RepositoryTest { @Autowired private ReviewQueryRepository reviewQueryRepository; diff --git a/backend/src/test/java/zipgo/review/presentation/ReviewControllerTest.java b/backend/src/test/java/zipgo/review/presentation/ReviewControllerTest.java index 2520bb65b..15784d828 100644 --- a/backend/src/test/java/zipgo/review/presentation/ReviewControllerTest.java +++ b/backend/src/test/java/zipgo/review/presentation/ReviewControllerTest.java @@ -1,11 +1,12 @@ package zipgo.review.presentation; -import com.epages.restdocs.apispec.ResourceSnippetDetails; -import com.epages.restdocs.apispec.Schema; -import io.restassured.response.Response; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; + +import com.epages.restdocs.apispec.ResourceSnippetDetails; +import com.epages.restdocs.apispec.Schema; +import io.restassured.response.Response; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -325,9 +326,12 @@ class CreateReviews { @Test void 리뷰를_성공적으로_생성하면_201_반환() { // given - var token = jwtProvider.create("1"); + var accessToken = jwtProvider.createAccessToken(1L); + var refreshToken = jwtProvider.createRefreshToken(); var 리뷰_생성_요청 = 리뷰_생성_요청(식품.getId(), 반려동물.getId()); - var 요청_준비 = given(spec).header("Authorization", "Bearer " + token) + var 요청_준비 = given(spec) + .header("Authorization", "Bearer " + accessToken) + .header("Refresh", "Zipgo " + refreshToken) .body(리뷰_생성_요청) .contentType(JSON).filter(리뷰_생성_API_문서_생성()); @@ -365,8 +369,11 @@ class CreateReviews { @Test void 없는_식품에_대해_리뷰를_생성하면_404_반환() { // given - var token = jwtProvider.create("1"); - var 요청_준비 = given(spec).header("Authorization", "Bearer " + token) + var accessToken = jwtProvider.createAccessToken(1L); + var refreshToken = jwtProvider.createRefreshToken(); + var 요청_준비 = given(spec) + .header("Authorization", "Bearer " + accessToken) + .header("Refresh", "Zipgo " + refreshToken) .body(리뷰_생성_요청(잘못된_id, 반려동물.getId())).contentType(JSON) .filter(API_예외응답_문서_생성()); @@ -396,8 +403,11 @@ class UpdateReviews { @Test void 리뷰를_성공적으로_수정하면_204_반환() { // given - var token = jwtProvider.create("1"); - var 요청_준비 = given(spec).header("Authorization", "Bearer " + token) + var accessToken = jwtProvider.createAccessToken(1L); + var refreshToken = jwtProvider.createRefreshToken(); + var 요청_준비 = given(spec) + .header("Authorization", "Bearer " + accessToken) + .header("Refresh", "Zipgo " + refreshToken) .body(리뷰_수정_요청()).contentType(JSON) .filter(리뷰_수정_API_문서_생성()); @@ -424,8 +434,11 @@ class UpdateReviews { @Test void 리뷰를_쓴_사람이_아닌_멤버가_리뷰를_수정하면_403_반환() { // given - var notOwnerToken = jwtProvider.create("2"); - var 요청_준비 = given(spec).header("Authorization", "Bearer " + notOwnerToken) + var accessToken = jwtProvider.createAccessToken(2L); + var refreshToken = jwtProvider.createRefreshToken(); + var 요청_준비 = given(spec) + .header("Authorization", "Bearer " + accessToken) + .header("Refresh", "Zipgo " + refreshToken) .body(리뷰_수정_요청()) .contentType(JSON).filter(API_예외응답_문서_생성()); @@ -455,8 +468,11 @@ class DeleteReviews { @Test void 리뷰를_성공적으로_삭제하면_204_반환() { // given - var token = jwtProvider.create("1"); - var 요청_준비 = given(spec).header("Authorization", "Bearer " + token) + var accessToken = jwtProvider.createAccessToken(1L); + var refreshToken = jwtProvider.createAccessToken(1L); + var 요청_준비 = given(spec) + .header("Authorization", "Bearer " + accessToken) + .header("Refresh", "Zipgo " + refreshToken) .body(리뷰_수정_요청()).contentType(JSON) .filter(리뷰_삭제_API_문서_생성()); @@ -476,9 +492,11 @@ class DeleteReviews { @Test void 리뷰를_쓴_사람이_아닌_멤버가_리뷰를_삭제하면_403_반환() { // given - var notOwnerToken = jwtProvider.create("2"); + var accessToken = jwtProvider.createAccessToken(2L); + var refreshToken = jwtProvider.createAccessToken(2L); var 요청_준비 = given(spec) - .header("Authorization", "Bearer " + notOwnerToken) + .header("Authorization", "Bearer " + accessToken) + .header("Refresh", "Zipgo " + refreshToken) .contentType(JSON) .filter(API_예외응답_문서_생성()); @@ -572,7 +590,7 @@ class GetReviewsSummary { queryParameters(parameterWithName("petFoodId").description("식품 id")), responseFields( fieldWithPath("rating.average").description("리뷰 총 평점").type(JsonFieldType.NUMBER), - fieldWithPath("rating.rating[].rating").description("rating 이름").type(JsonFieldType.STRING), + fieldWithPath("rating.rating[].name").description("rating 이름").type(JsonFieldType.STRING), fieldWithPath("rating.rating[].percentage").description("rating 해당 백분율") .type(JsonFieldType.NUMBER), fieldWithPath("tastePreference[].name").description("tastePreference 이름") @@ -604,7 +622,8 @@ class AddHelpfulReaction { void 도움이돼요_추가_성공() { //given var 다른_회원 = memberRepository.save(Member.builder().email("도움이돼요_추가할_회원").name("회원명").build()); - var 다른_회원의_JWT = jwtProvider.create(다른_회원.getId().toString()); + var 다른_회원의_JWT = jwtProvider.createAccessToken(다른_회원.getId()); + var 다른_회원의_리프레시_토큰 = jwtProvider.createRefreshToken(); var 요청_준비 = given().spec(spec) .contentType(JSON) @@ -613,6 +632,7 @@ class AddHelpfulReaction { //when 요청_준비.when() .header(AUTHORIZATION, "Bearer " + 다른_회원의_JWT) + .header("Refresh", "Zipgo " + 다른_회원의_리프레시_토큰) .pathParam("reviewId", 리뷰.getId()) .post("/reviews/{reviewId}/helpful-reactions"); @@ -620,6 +640,7 @@ class AddHelpfulReaction { Response 조회_응답 = given().spec(spec) .contentType(JSON) .header(AUTHORIZATION, "Bearer " + 다른_회원의_JWT) + .header("Refresh", "Zipgo " + 다른_회원의_리프레시_토큰) .get("/reviews/{reviewId}", 리뷰.getId()); 조회_응답.then() @@ -631,7 +652,8 @@ class AddHelpfulReaction { void 작성자가_도움이돼요를_추가하면_예외가_발생() { //given var 리뷰_작성자_id = 리뷰.getPet().getOwner().getId(); - var 리뷰_작성자_JWT = jwtProvider.create(리뷰_작성자_id.toString()); + var 리뷰_작성자_JWT = jwtProvider.createAccessToken(리뷰_작성자_id); + var 리뷰_작성자_리프레시_토큰 = jwtProvider.createRefreshToken(); //when var 요청_준비 = given().spec(spec) @@ -641,6 +663,7 @@ class AddHelpfulReaction { //when var 응답 = 요청_준비.when() .header(AUTHORIZATION, "Bearer " + 리뷰_작성자_JWT) + .header("Refresh", "Zipgo " + 리뷰_작성자_리프레시_토큰) .pathParam("reviewId", 리뷰.getId()) .post("/reviews/{reviewId}/helpful-reactions"); @@ -655,7 +678,8 @@ class AddHelpfulReaction { void 이미_눌렀던_리뷰일_경우() { //given var 다른_회원 = memberRepository.save(Member.builder().email("도움이돼요_추가할_회원").name("회원명").build()); - var 다른_회원의_JWT = jwtProvider.create(다른_회원.getId().toString()); + var 다른_회원의_JWT = jwtProvider.createAccessToken(다른_회원.getId()); + var 다른_회원의_리프레시 = jwtProvider.createRefreshToken(); given().spec(spec).contentType(JSON) .pathParam("reviewId", 리뷰.getId()) @@ -668,6 +692,7 @@ class AddHelpfulReaction { //when var 응답 = 요청_준비.when() .header(AUTHORIZATION, "Bearer " + 다른_회원의_JWT) + .header("Refresh", "Zipgo " + 다른_회원의_리프레시) .pathParam("reviewId", 리뷰.getId()) .post("/reviews/{reviewId}/helpful-reactions"); @@ -700,7 +725,8 @@ class DeleteHelpfulReaction { void 도움이돼요_취소_성공() { //given var 다른_회원 = memberRepository.save(Member.builder().email("도움이돼요_추가할_회원").name("회원명").build()); - var 다른_회원의_JWT = jwtProvider.create(다른_회원.getId().toString()); + var 다른_회원의_JWT = jwtProvider.createAccessToken(다른_회원.getId()); + var 다른_회원의_리프레시_토큰 = jwtProvider.createRefreshToken(); given().spec(spec).contentType(JSON) .pathParam("reviewId", 리뷰.getId()) @@ -713,6 +739,7 @@ class DeleteHelpfulReaction { //when var 응답 = 요청_준비.when() .header(AUTHORIZATION, "Bearer " + 다른_회원의_JWT) + .header("Refresh", "Zipgo " + 다른_회원의_리프레시_토큰) .pathParam("reviewId", 리뷰.getId()) .delete("/reviews/{reviewId}/helpful-reactions"); @@ -725,7 +752,8 @@ class DeleteHelpfulReaction { void 누르지_않은_리뷰에서_취소() { //given var 다른_회원 = memberRepository.save(Member.builder().email("도움이돼요_추가할_회원").name("회원명").build()); - var 다른_회원의_JWT = jwtProvider.create(다른_회원.getId().toString()); + var 다른_회원의_JWT = jwtProvider.createAccessToken(다른_회원.getId()); + var 다른_회원의_리프레시_토큰 = jwtProvider.createRefreshToken(); var 요청_준비 = given().spec(spec) .contentType(JSON) @@ -734,6 +762,7 @@ class DeleteHelpfulReaction { //when var 응답 = 요청_준비.when() .header(AUTHORIZATION, "Bearer " + 다른_회원의_JWT) + .header("Refresh", "Zipgo " + 다른_회원의_리프레시_토큰) .pathParam("reviewId", 리뷰.getId()) .delete("/reviews/{reviewId}/helpful-reactions"); diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index f49095117..9a6c66bd0 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -21,7 +21,8 @@ spring: enabled: true jwt: secret-key: this1-is2-zipgo3-test4-secret5-key6 - expire-length: 604800000 + access-token-expiration-time: 604800000 + refresh-token-expiration-time: 9999999999999 oauth: kakao: client-id: this1-is2-zipgo3-test4-client5-id7 diff --git a/frontend/.env.development b/frontend/.env.development index a6e662478..750405545 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1,3 +1,3 @@ FAST_REFRESH=true BASE_URL=https://dev.api.zipgo.pet -KAKAO_REDIRECT_URI=https://dev.zipgo.pet/login +HOMEPAGE=https://dev.zipgo.pet diff --git a/frontend/.env.production b/frontend/.env.production index 7916a8517..7e4e0d0b0 100644 --- a/frontend/.env.production +++ b/frontend/.env.production @@ -1,2 +1,2 @@ BASE_URL=https://api.zipgo.pet -KAKAO_REDIRECT_URI=https://zipgo.pet/login +HOMEPAGE=https://zipgo.pet diff --git a/frontend/.gitignore b/frontend/.gitignore index 19c7d0746..c3f791f17 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -17,3 +17,5 @@ dist storybook-static .eslintcache + +*.pem diff --git a/frontend/.nvmrc b/frontend/.nvmrc new file mode 100644 index 000000000..73ff83260 --- /dev/null +++ b/frontend/.nvmrc @@ -0,0 +1,2 @@ +v18.16.1 + diff --git a/frontend/.pnp.cjs b/frontend/.pnp.cjs index d002ae30b..793f4e476 100755 --- a/frontend/.pnp.cjs +++ b/frontend/.pnp.cjs @@ -45,6 +45,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@storybook/test-runner", "npm:0.13.0"],\ ["@storybook/testing-library", "npm:0.2.0"],\ ["@tanstack/react-query", "virtual:d9650954c7f1725ea712d2e40695de9ea5d1c0fa9b89379134c62909d440d91b03d6c37e2823804a3404472b90a5bec73c82193190dd3c4db4c9e9edd75db91e#npm:4.29.25"],\ + ["@tanstack/react-query-devtools", "virtual:d9650954c7f1725ea712d2e40695de9ea5d1c0fa9b89379134c62909d440d91b03d6c37e2823804a3404472b90a5bec73c82193190dd3c4db4c9e9edd75db91e#npm:4.36.1"],\ ["@testing-library/dom", "npm:9.3.1"],\ ["@testing-library/jest-dom", "npm:5.16.5"],\ ["@testing-library/react", "virtual:d9650954c7f1725ea712d2e40695de9ea5d1c0fa9b89379134c62909d440d91b03d6c37e2823804a3404472b90a5bec73c82193190dd3c4db4c9e9edd75db91e#npm:14.0.0"],\ @@ -109,7 +110,8 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["webpack-bundle-analyzer", "npm:4.9.1"],\ ["webpack-cli", "virtual:d9650954c7f1725ea712d2e40695de9ea5d1c0fa9b89379134c62909d440d91b03d6c37e2823804a3404472b90a5bec73c82193190dd3c4db4c9e9edd75db91e#npm:5.1.4"],\ ["webpack-dev-server", "virtual:d9650954c7f1725ea712d2e40695de9ea5d1c0fa9b89379134c62909d440d91b03d6c37e2823804a3404472b90a5bec73c82193190dd3c4db4c9e9edd75db91e#npm:4.15.1"],\ - ["webpack-merge", "npm:5.9.0"]\ + ["webpack-merge", "npm:5.9.0"],\ + ["zipgo-layout", "npm:0.4.0"]\ ],\ "linkType": "SOFT"\ }]\ @@ -9413,6 +9415,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["@tanstack/match-sorter-utils", [\ + ["npm:8.8.4", {\ + "packageLocation": "./.yarn/cache/@tanstack-match-sorter-utils-npm-8.8.4-488b98c113-d005f50075.zip/node_modules/@tanstack/match-sorter-utils/",\ + "packageDependencies": [\ + ["@tanstack/match-sorter-utils", "npm:8.8.4"],\ + ["remove-accents", "npm:0.4.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@tanstack/query-core", [\ ["npm:4.29.25", {\ "packageLocation": "./.yarn/cache/@tanstack-query-core-npm-4.29.25-6b3da5fc2b-5287e278cf.zip/node_modules/@tanstack/query-core/",\ @@ -9441,7 +9453,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["react", "npm:18.2.0"],\ ["react-dom", "virtual:d9650954c7f1725ea712d2e40695de9ea5d1c0fa9b89379134c62909d440d91b03d6c37e2823804a3404472b90a5bec73c82193190dd3c4db4c9e9edd75db91e#npm:18.2.0"],\ ["react-native", null],\ - ["use-sync-external-store", "virtual:9c24e36d42399c7366475b2b84f4a3444202f6f9cc946a653e07f347eca2ca0681be2719ea2726e1b9b80bdaf2fd4c0d80d7982698a10885f7731d7b508979ff#npm:1.2.0"]\ + ["use-sync-external-store", "virtual:d5698880712cc2658275f764622ac80990362299ce111d53d89856faba511f69edee2a268a8be764eec3062a426d656dda5c426f153eb88c399c1551841b08af#npm:1.2.0"]\ ],\ "packagePeers": [\ "@types/react-dom",\ @@ -9454,6 +9466,39 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["@tanstack/react-query-devtools", [\ + ["npm:4.36.1", {\ + "packageLocation": "./.yarn/cache/@tanstack-react-query-devtools-npm-4.36.1-8448e7912a-d4f7da4121.zip/node_modules/@tanstack/react-query-devtools/",\ + "packageDependencies": [\ + ["@tanstack/react-query-devtools", "npm:4.36.1"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:d9650954c7f1725ea712d2e40695de9ea5d1c0fa9b89379134c62909d440d91b03d6c37e2823804a3404472b90a5bec73c82193190dd3c4db4c9e9edd75db91e#npm:4.36.1", {\ + "packageLocation": "./.yarn/__virtual__/@tanstack-react-query-devtools-virtual-d569888071/0/cache/@tanstack-react-query-devtools-npm-4.36.1-8448e7912a-d4f7da4121.zip/node_modules/@tanstack/react-query-devtools/",\ + "packageDependencies": [\ + ["@tanstack/react-query-devtools", "virtual:d9650954c7f1725ea712d2e40695de9ea5d1c0fa9b89379134c62909d440d91b03d6c37e2823804a3404472b90a5bec73c82193190dd3c4db4c9e9edd75db91e#npm:4.36.1"],\ + ["@tanstack/match-sorter-utils", "npm:8.8.4"],\ + ["@tanstack/react-query", "virtual:d9650954c7f1725ea712d2e40695de9ea5d1c0fa9b89379134c62909d440d91b03d6c37e2823804a3404472b90a5bec73c82193190dd3c4db4c9e9edd75db91e#npm:4.29.25"],\ + ["@types/react", "npm:18.2.15"],\ + ["@types/react-dom", "npm:18.2.7"],\ + ["@types/tanstack__react-query", null],\ + ["react", "npm:18.2.0"],\ + ["react-dom", "virtual:d9650954c7f1725ea712d2e40695de9ea5d1c0fa9b89379134c62909d440d91b03d6c37e2823804a3404472b90a5bec73c82193190dd3c4db4c9e9edd75db91e#npm:18.2.0"],\ + ["superjson", "npm:1.13.3"],\ + ["use-sync-external-store", "virtual:d5698880712cc2658275f764622ac80990362299ce111d53d89856faba511f69edee2a268a8be764eec3062a426d656dda5c426f153eb88c399c1551841b08af#npm:1.2.0"]\ + ],\ + "packagePeers": [\ + "@tanstack/react-query",\ + "@types/react-dom",\ + "@types/react",\ + "@types/tanstack__react-query",\ + "react-dom",\ + "react"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@testing-library/dom", [\ ["npm:9.3.1", {\ "packageLocation": "./.yarn/cache/@testing-library-dom-npm-9.3.1-ec81dc9367-8ee3136451.zip/node_modules/@testing-library/dom/",\ @@ -13155,6 +13200,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["copy-anything", [\ + ["npm:3.0.5", {\ + "packageLocation": "./.yarn/cache/copy-anything-npm-3.0.5-562d15fb3f-d39f6601c1.zip/node_modules/copy-anything/",\ + "packageDependencies": [\ + ["copy-anything", "npm:3.0.5"],\ + ["is-what", "npm:4.1.15"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["copy-webpack-plugin", [\ ["npm:11.0.0", {\ "packageLocation": "./.yarn/cache/copy-webpack-plugin-npm-11.0.0-9a07415855-df4f8743f0.zip/node_modules/copy-webpack-plugin/",\ @@ -15669,6 +15724,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@storybook/test-runner", "npm:0.13.0"],\ ["@storybook/testing-library", "npm:0.2.0"],\ ["@tanstack/react-query", "virtual:d9650954c7f1725ea712d2e40695de9ea5d1c0fa9b89379134c62909d440d91b03d6c37e2823804a3404472b90a5bec73c82193190dd3c4db4c9e9edd75db91e#npm:4.29.25"],\ + ["@tanstack/react-query-devtools", "virtual:d9650954c7f1725ea712d2e40695de9ea5d1c0fa9b89379134c62909d440d91b03d6c37e2823804a3404472b90a5bec73c82193190dd3c4db4c9e9edd75db91e#npm:4.36.1"],\ ["@testing-library/dom", "npm:9.3.1"],\ ["@testing-library/jest-dom", "npm:5.16.5"],\ ["@testing-library/react", "virtual:d9650954c7f1725ea712d2e40695de9ea5d1c0fa9b89379134c62909d440d91b03d6c37e2823804a3404472b90a5bec73c82193190dd3c4db4c9e9edd75db91e#npm:14.0.0"],\ @@ -15733,7 +15789,8 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["webpack-bundle-analyzer", "npm:4.9.1"],\ ["webpack-cli", "virtual:d9650954c7f1725ea712d2e40695de9ea5d1c0fa9b89379134c62909d440d91b03d6c37e2823804a3404472b90a5bec73c82193190dd3c4db4c9e9edd75db91e#npm:5.1.4"],\ ["webpack-dev-server", "virtual:d9650954c7f1725ea712d2e40695de9ea5d1c0fa9b89379134c62909d440d91b03d6c37e2823804a3404472b90a5bec73c82193190dd3c4db4c9e9edd75db91e#npm:4.15.1"],\ - ["webpack-merge", "npm:5.9.0"]\ + ["webpack-merge", "npm:5.9.0"],\ + ["zipgo-layout", "npm:0.4.0"]\ ],\ "linkType": "SOFT"\ }]\ @@ -17427,6 +17484,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["is-what", [\ + ["npm:4.1.15", {\ + "packageLocation": "./.yarn/cache/is-what-npm-4.1.15-328677a458-fe27f6cd4a.zip/node_modules/is-what/",\ + "packageDependencies": [\ + ["is-what", "npm:4.1.15"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["is-windows", [\ ["npm:0.2.0", {\ "packageLocation": "./.yarn/cache/is-windows-npm-0.2.0-32c20e83a7-3df25afda2.zip/node_modules/is-windows/",\ @@ -20741,6 +20807,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["source-map-js", "npm:1.0.2"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:8.4.31", {\ + "packageLocation": "./.yarn/cache/postcss-npm-8.4.31-385051a82b-1d8611341b.zip/node_modules/postcss/",\ + "packageDependencies": [\ + ["postcss", "npm:8.4.31"],\ + ["nanoid", "npm:3.3.6"],\ + ["picocolors", "npm:1.0.0"],\ + ["source-map-js", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["postcss-modules-extract-imports", [\ @@ -21848,6 +21924,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["remove-accents", [\ + ["npm:0.4.2", {\ + "packageLocation": "./.yarn/cache/remove-accents-npm-0.4.2-7cb341092a-84a6988555.zip/node_modules/remove-accents/",\ + "packageDependencies": [\ + ["remove-accents", "npm:0.4.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["renderkid", [\ ["npm:3.0.0", {\ "packageLocation": "./.yarn/cache/renderkid-npm-3.0.0-acb028643f-77162b62d6.zip/node_modules/renderkid/",\ @@ -23162,6 +23247,39 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ + ["npm:6.1.0", {\ + "packageLocation": "./.yarn/cache/styled-components-npm-6.1.0-309a24863e-989262a2be.zip/node_modules/styled-components/",\ + "packageDependencies": [\ + ["styled-components", "npm:6.1.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:bfdb260d66a67fb440a36b5e7d3099e1ef4c2420a265088f95a491b65c807b3174254c5223a22dfffa2ffb3757ec70f7fe49b5722e37e17babd81cd44d32cf56#npm:6.1.0", {\ + "packageLocation": "./.yarn/__virtual__/styled-components-virtual-b27e270236/0/cache/styled-components-npm-6.1.0-309a24863e-989262a2be.zip/node_modules/styled-components/",\ + "packageDependencies": [\ + ["styled-components", "virtual:bfdb260d66a67fb440a36b5e7d3099e1ef4c2420a265088f95a491b65c807b3174254c5223a22dfffa2ffb3757ec70f7fe49b5722e37e17babd81cd44d32cf56#npm:6.1.0"],\ + ["@emotion/is-prop-valid", "npm:1.2.1"],\ + ["@emotion/unitless", "npm:0.8.1"],\ + ["@types/react", null],\ + ["@types/react-dom", null],\ + ["@types/stylis", "npm:4.2.0"],\ + ["css-to-react-native", "npm:3.2.0"],\ + ["csstype", "npm:3.1.2"],\ + ["postcss", "npm:8.4.31"],\ + ["react", "npm:18.2.0"],\ + ["react-dom", null],\ + ["shallowequal", "npm:1.1.0"],\ + ["stylis", "npm:4.3.0"],\ + ["tslib", "npm:2.6.0"]\ + ],\ + "packagePeers": [\ + "@types/react-dom",\ + "@types/react",\ + "react-dom",\ + "react"\ + ],\ + "linkType": "HARD"\ + }],\ ["virtual:d9650954c7f1725ea712d2e40695de9ea5d1c0fa9b89379134c62909d440d91b03d6c37e2823804a3404472b90a5bec73c82193190dd3c4db4c9e9edd75db91e#npm:6.0.4", {\ "packageLocation": "./.yarn/__virtual__/styled-components-virtual-b7f0a8e1c4/0/cache/styled-components-npm-6.0.4-fbf66c4137-15c5701c26.zip/node_modules/styled-components/",\ "packageDependencies": [\ @@ -23375,6 +23493,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["superjson", [\ + ["npm:1.13.3", {\ + "packageLocation": "./.yarn/cache/superjson-npm-1.13.3-25a5e9e483-f5aeb010f2.zip/node_modules/superjson/",\ + "packageDependencies": [\ + ["superjson", "npm:1.13.3"],\ + ["copy-anything", "npm:3.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["supports-color", [\ ["npm:5.5.0", {\ "packageLocation": "./.yarn/cache/supports-color-npm-5.5.0-183ac537bc-95f6f4ba5a.zip/node_modules/supports-color/",\ @@ -24385,10 +24513,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:9c24e36d42399c7366475b2b84f4a3444202f6f9cc946a653e07f347eca2ca0681be2719ea2726e1b9b80bdaf2fd4c0d80d7982698a10885f7731d7b508979ff#npm:1.2.0", {\ - "packageLocation": "./.yarn/__virtual__/use-sync-external-store-virtual-f62de996c5/0/cache/use-sync-external-store-npm-1.2.0-44f75d2564-5c639e0f8d.zip/node_modules/use-sync-external-store/",\ + ["virtual:d5698880712cc2658275f764622ac80990362299ce111d53d89856faba511f69edee2a268a8be764eec3062a426d656dda5c426f153eb88c399c1551841b08af#npm:1.2.0", {\ + "packageLocation": "./.yarn/__virtual__/use-sync-external-store-virtual-8d8319ae0b/0/cache/use-sync-external-store-npm-1.2.0-44f75d2564-5c639e0f8d.zip/node_modules/use-sync-external-store/",\ "packageDependencies": [\ - ["use-sync-external-store", "virtual:9c24e36d42399c7366475b2b84f4a3444202f6f9cc946a653e07f347eca2ca0681be2719ea2726e1b9b80bdaf2fd4c0d80d7982698a10885f7731d7b508979ff#npm:1.2.0"],\ + ["use-sync-external-store", "virtual:d5698880712cc2658275f764622ac80990362299ce111d53d89856faba511f69edee2a268a8be764eec3062a426d656dda5c426f153eb88c399c1551841b08af#npm:1.2.0"],\ ["@types/react", "npm:18.2.15"],\ ["react", "npm:18.2.0"]\ ],\ @@ -25387,6 +25515,17 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "HARD"\ }]\ + ]],\ + ["zipgo-layout", [\ + ["npm:0.4.0", {\ + "packageLocation": "./.yarn/cache/zipgo-layout-npm-0.4.0-bfdb260d66-0a6852d018.zip/node_modules/zipgo-layout/",\ + "packageDependencies": [\ + ["zipgo-layout", "npm:0.4.0"],\ + ["react", "npm:18.2.0"],\ + ["styled-components", "virtual:bfdb260d66a67fb440a36b5e7d3099e1ef4c2420a265088f95a491b65c807b3174254c5223a22dfffa2ffb3757ec70f7fe49b5722e37e17babd81cd44d32cf56#npm:6.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ ]]\ ]\ }'), {basePath: basePath || __dirname}); diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 0d85ce0af..8e4f4c344 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -7,8 +7,20 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { initialize, mswDecorator } from 'msw-storybook-addon'; import { withRouter } from 'storybook-addon-react-router-v6'; import handlers from '../src/mocks/handlers'; +import Loading from '../src/components/@common/Loading/Loading'; -initialize(); // msw-storybook-addon +let options = {}; + +if (location.hostname.includes('cloudfront')) { + options = { + serviceWorker: { + url: '/storybook/mockServiceWorker.js', + }, + }; +} + +// Initialize MSW +initialize(options); const queryClient = new QueryClient({ defaultOptions: { @@ -85,9 +97,9 @@ const preview: Preview = { - Loading...}> + - + ), diff --git a/frontend/.stylelintrc.js b/frontend/.stylelintrc.js index e3c169383..e3d4c38cf 100644 --- a/frontend/.stylelintrc.js +++ b/frontend/.stylelintrc.js @@ -34,5 +34,7 @@ module.exports = { severity: 'error', }, ], + + 'property-no-vendor-prefix': null, }, }; diff --git a/frontend/.yarn/cache/@tanstack-match-sorter-utils-npm-8.8.4-488b98c113-d005f50075.zip b/frontend/.yarn/cache/@tanstack-match-sorter-utils-npm-8.8.4-488b98c113-d005f50075.zip new file mode 100644 index 000000000..518400f58 Binary files /dev/null and b/frontend/.yarn/cache/@tanstack-match-sorter-utils-npm-8.8.4-488b98c113-d005f50075.zip differ diff --git a/frontend/.yarn/cache/@tanstack-react-query-devtools-npm-4.36.1-8448e7912a-d4f7da4121.zip b/frontend/.yarn/cache/@tanstack-react-query-devtools-npm-4.36.1-8448e7912a-d4f7da4121.zip new file mode 100644 index 000000000..6ccc64155 Binary files /dev/null and b/frontend/.yarn/cache/@tanstack-react-query-devtools-npm-4.36.1-8448e7912a-d4f7da4121.zip differ diff --git a/frontend/.yarn/cache/copy-anything-npm-3.0.5-562d15fb3f-d39f6601c1.zip b/frontend/.yarn/cache/copy-anything-npm-3.0.5-562d15fb3f-d39f6601c1.zip new file mode 100644 index 000000000..c1f7e5b8e Binary files /dev/null and b/frontend/.yarn/cache/copy-anything-npm-3.0.5-562d15fb3f-d39f6601c1.zip differ diff --git a/frontend/.yarn/cache/is-what-npm-4.1.15-328677a458-fe27f6cd4a.zip b/frontend/.yarn/cache/is-what-npm-4.1.15-328677a458-fe27f6cd4a.zip new file mode 100644 index 000000000..3ca7651a3 Binary files /dev/null and b/frontend/.yarn/cache/is-what-npm-4.1.15-328677a458-fe27f6cd4a.zip differ diff --git a/frontend/.yarn/cache/postcss-npm-8.4.31-385051a82b-1d8611341b.zip b/frontend/.yarn/cache/postcss-npm-8.4.31-385051a82b-1d8611341b.zip new file mode 100644 index 000000000..dc7dd6671 Binary files /dev/null and b/frontend/.yarn/cache/postcss-npm-8.4.31-385051a82b-1d8611341b.zip differ diff --git a/frontend/.yarn/cache/remove-accents-npm-0.4.2-7cb341092a-84a6988555.zip b/frontend/.yarn/cache/remove-accents-npm-0.4.2-7cb341092a-84a6988555.zip new file mode 100644 index 000000000..dd33a9bc0 Binary files /dev/null and b/frontend/.yarn/cache/remove-accents-npm-0.4.2-7cb341092a-84a6988555.zip differ diff --git a/frontend/.yarn/cache/styled-components-npm-6.1.0-309a24863e-989262a2be.zip b/frontend/.yarn/cache/styled-components-npm-6.1.0-309a24863e-989262a2be.zip new file mode 100644 index 000000000..ff5d754ef Binary files /dev/null and b/frontend/.yarn/cache/styled-components-npm-6.1.0-309a24863e-989262a2be.zip differ diff --git a/frontend/.yarn/cache/superjson-npm-1.13.3-25a5e9e483-f5aeb010f2.zip b/frontend/.yarn/cache/superjson-npm-1.13.3-25a5e9e483-f5aeb010f2.zip new file mode 100644 index 000000000..1a3a214c5 Binary files /dev/null and b/frontend/.yarn/cache/superjson-npm-1.13.3-25a5e9e483-f5aeb010f2.zip differ diff --git a/frontend/.yarn/cache/zipgo-layout-npm-0.4.0-bfdb260d66-0a6852d018.zip b/frontend/.yarn/cache/zipgo-layout-npm-0.4.0-bfdb260d66-0a6852d018.zip new file mode 100644 index 000000000..2b0b67594 Binary files /dev/null and b/frontend/.yarn/cache/zipgo-layout-npm-0.4.0-bfdb260d66-0a6852d018.zip differ diff --git a/frontend/config/env.js b/frontend/config/env.js index 1dffba0cb..3b1e5ad5f 100644 --- a/frontend/config/env.js +++ b/frontend/config/env.js @@ -6,23 +6,54 @@ const dotenvExpand = require('dotenv-expand'); const NODE_ENV = process.env.NODE_ENV; -const dotenvFiles = [`${paths.dotenv}.local`, `${paths.dotenv}.${NODE_ENV}`, paths.dotenv].filter(Boolean); +const isDevelopment = process.env.NODE_ENV !== 'production'; +const isProduction = process.env.NODE_ENV === 'production'; +const isLocal = isDevelopment && process.env.Local === 'on'; -dotenvFiles.forEach(file => fs.existsSync(file) && dotenvExpand.expand(dotenv.config({path: file}))); +const HTTPS = process.env.HTTPS === 'on'; + +const localhost = 'http://localhost:3000'; + +const dotenvFiles = [`${paths.dotenv}.local`, `${paths.dotenv}.${NODE_ENV}`, paths.dotenv].filter( + Boolean, +); + +const setHomepage = homepage => { + if (!isLocal) return JSON.stringify(homepage); + + if (HTTPS) return JSON.stringify(localhost.replace('http', 'https')); + + return JSON.stringify(localhost); +}; + +dotenvFiles.forEach( + file => fs.existsSync(file) && dotenvExpand.expand(dotenv.config({ path: file })), +); const getClientEnvironment = publicUrl => { - const raw = Object.keys(process.env).reduce((env, key) => ({...env, [key]: process.env[key]}), { + const raw = Object.keys(process.env).reduce((env, key) => ({ ...env, [key]: process.env[key] }), { NODE_ENV: process.env.NODE_ENV || 'development', PUBLIC_URL: publicUrl, }); const stringified = { - 'process.env': Object.keys(raw).reduce((env, key) => ({...env, [key]: JSON.stringify(raw[key])}), {}), + 'process.env': Object.keys(raw).reduce( + (env, key) => ({ + ...env, + [key]: key === 'HOMEPAGE' ? setHomepage(raw[key]) : JSON.stringify(raw[key]), + }), + {}, + ), }; - return {stringified}; + return { stringified }; }; module.exports = { getClientEnvironment, + isDevelopment, + isProduction, + isLocal, + HTTPS, + localhost, }; diff --git a/frontend/config/paths.js b/frontend/config/paths.js index 35750763a..cf5eca0c1 100644 --- a/frontend/config/paths.js +++ b/frontend/config/paths.js @@ -43,4 +43,6 @@ module.exports = { moduleFileExtensions, 'alias@': resolveApp('src/'), resolveLib, + localhostKey: resolveApp('config/localhost+1-key.pem'), + cert: resolveApp('config/localhost+1.pem'), }; diff --git a/frontend/config/webpack.common.js b/frontend/config/webpack.common.js index 355fc096e..880fb78f5 100644 --- a/frontend/config/webpack.common.js +++ b/frontend/config/webpack.common.js @@ -1,6 +1,6 @@ const paths = require('./paths'); const webpackUtils = require('./webpackUtils'); -const { getClientEnvironment } = require('./env'); +const { getClientEnvironment, isDevelopment, isProduction, isLocal } = require('./env'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); @@ -12,11 +12,9 @@ const PUBLIC_PATH = '/'; const libListToCopy = ['axios']; -const isDevelopment = process.env.NODE_ENV !== 'production'; -const isProduction = process.env.NODE_ENV === 'production'; - const env = getClientEnvironment(PUBLIC_PATH); +/** @type {import('webpack').Configuration} */ module.exports = { context: __dirname, entry: paths.appIndex, @@ -61,7 +59,7 @@ module.exports = { hash: true, favicon: paths.appFavicon, }), - new DefinePlugin({ ...env.stringified, isDevelopment, isProduction }), + new DefinePlugin({ ...env.stringified, isDevelopment, isProduction, isLocal }), new ForkTsCheckerWebpackPlugin({ typescript: { configFile: paths.appTsConfig, diff --git a/frontend/config/webpack.dev.js b/frontend/config/webpack.dev.js index 92277d167..bc0cd2714 100644 --- a/frontend/config/webpack.dev.js +++ b/frontend/config/webpack.dev.js @@ -1,8 +1,10 @@ const paths = require('./paths.js'); +const { HTTPS } = require('./env.js'); const { merge } = require('webpack-merge'); const common = require('./webpack.common.js'); const CopyPlugin = require('copy-webpack-plugin'); +const fs = require('fs'); const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); module.exports = merge(common, { @@ -11,6 +13,17 @@ module.exports = merge(common, { devServer: { historyApiFallback: true, port: 3000, + ...(HTTPS + ? { + server: { + type: 'https', + options: { + key: fs.readFileSync(paths.localhostKey), + cert: fs.readFileSync(paths.cert), + }, + }, + } + : {}), }, plugins: [ new ReactRefreshWebpackPlugin(), diff --git a/frontend/package.json b/frontend/package.json index c4eb7395b..0a40d93ac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "scripts": { "type-check": "tsc --noEmit", "type-check:watch": "yarn type-check --watch", - "start:dev": "cross-env NODE_ENV=development MSW=on STRICT_MODE=on webpack serve --config config/webpack.dev.js", + "start:dev": "cross-env NODE_ENV=development MSW=on Local=on STRICT_MODE=on webpack serve --config config/webpack.dev.js", "start:prod": "yarn build:prod && serve -s dist", "start:sb": "storybook dev -p 6006", "build:prod": "cross-env NODE_ENV=production webpack --progress --config config/webpack.prod.js", @@ -91,13 +91,15 @@ }, "dependencies": { "@tanstack/react-query": "^4.29.19", + "@tanstack/react-query-devtools": "^4.36.1", "axios": "^1.4.0", "browser-image-compression": "^2.0.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router": "^6.14.2", "react-router-dom": "^6.14.1", - "styled-normalize": "^8.0.7" + "styled-normalize": "^8.0.7", + "zipgo-layout": "0.4.0" }, "lint-staged": { "**/*.{ts,tsx}": [ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7b5d20bc2..29efc943e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,64 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { useEffect } from 'react'; import { Outlet } from 'react-router-dom'; +import { ThemeProvider } from 'styled-components'; -import AxiosInterceptors from './components/@common/AxiosInterceptors/AxiosInterceptors'; +import { CriticalBoundary } from './components/@common/ErrorBoundary/ErrorBoundary'; +import QueryBoundary from './components/@common/ErrorBoundary/QueryBoundary/QueryBoundary'; +import GlobalStyle from './components/@common/GlobalStyle'; +import MobileToDesktop from './components/@common/MobileToDesktop/MobileToDesktop'; +import { ERROR_MESSAGE_KIT } from './constants/errors'; +import { ONE_HOUR } from './constants/time'; +import ToastProvider, { useToast } from './context/Toast/ToastContext'; +import useErrorBoundary from './hooks/@common/useErrorBoundary'; +import ErrorPage from './pages/Error/ErrorPage'; +import theme from './styles/theme'; +import { ErrorBoundaryValue } from './types/common/errorBoundary'; +import { UnexpectedError } from './utils/errors'; import { setScreenSize } from './utils/setScreenSize'; -const App = () => { +const errorFallback = ({ reset, error }: ErrorBoundaryValue) => ( + +); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + suspense: true, + retry: false, + useErrorBoundary: true, + staleTime: ONE_HOUR, + cacheTime: ONE_HOUR, + }, + }, +}); + +const App = () => ( + + + + + + + + + + {isDevelopment && } + + + + + + +); +export default App; + +const GlobalEvent = () => { + const { toast } = useToast(); + + const { setError } = useErrorBoundary(); + useEffect(() => { setScreenSize(); }, []); @@ -14,17 +68,30 @@ const App = () => { setScreenSize(); }; + const onUnhandledRejection = () => { + setError(new UnexpectedError('Unhandled rejection')); + }; + + const onOffline = () => { + toast.warning(ERROR_MESSAGE_KIT.OFFLINE); + }; + + const onOnline = () => { + toast.success(ERROR_MESSAGE_KIT.ONLINE); + }; + window.addEventListener('resize', onResize); + window.addEventListener('online', onOnline); + window.addEventListener('offline', onOffline); + window.addEventListener('unhandledrejection', onUnhandledRejection); return () => { window.removeEventListener('resize', onResize); + window.removeEventListener('online', onOnline); + window.removeEventListener('offline', onOffline); + window.removeEventListener('unhandledrejection', onUnhandledRejection); }; }, []); - return ( - - - - ); + return null; }; -export default App; diff --git a/frontend/src/apis/auth.ts b/frontend/src/apis/auth.ts index 4d73fa9ce..efda5ec4e 100644 --- a/frontend/src/apis/auth.ts +++ b/frontend/src/apis/auth.ts @@ -1,23 +1,43 @@ +import { KAKAO_REDIRECT_URI } from '@/constants/auth'; import { AuthenticateUserRes, LoginKakaoAuthRes, LoginZipgoAuthReq, LoginZipgoAuthRes, + RefreshZipgoAuthRes, } from '@/types/auth/remote'; import { zipgoLocalStorage } from '@/utils/localStorage'; -import { client, clientBasic, createConfigWithAuth } from '.'; +import { client, clientBasic } from '.'; export const loginZipgoAuth = async ({ code }: LoginZipgoAuthReq) => { const { data } = await client.post('/auth/login', null, { params: { code, + 'redirect-uri': KAKAO_REDIRECT_URI, }, }); return data; }; +export const refreshZipgoAuth = async () => { + const { refreshToken } = zipgoLocalStorage.getTokens({ required: true }); + + const { + data: { accessToken }, + } = await clientBasic('/auth/refresh', { + headers: { + Refresh: `Zipgo ${refreshToken}`, + }, + }); + + return { + accessToken, + refreshToken, + }; +}; + export const loginKakaoAuth = async () => { const { data } = await client('/login/kakao'); @@ -42,10 +62,11 @@ export const logoutKaKaoAuth = async () => { export const authenticateUser = async () => { const tokens = zipgoLocalStorage.getTokens(); - const { data } = await clientBasic.get( - '/auth', - createConfigWithAuth(tokens), - ); + const { data } = await client('/auth', { + headers: { + Refresh: `Zipgo ${tokens?.refreshToken}`, + }, + }); return data; }; diff --git a/frontend/src/apis/index.ts b/frontend/src/apis/index.ts index 7c7344579..6f7743cd1 100644 --- a/frontend/src/apis/index.ts +++ b/frontend/src/apis/index.ts @@ -1,26 +1,52 @@ -import axios from 'axios'; +import axios, { AxiosError, AxiosRequestConfig } from 'axios'; -import { Tokens } from '@/types/auth/client'; +import { APIError } from '@/utils/errors'; import { zipgoLocalStorage } from '@/utils/localStorage'; -export const { BASE_URL } = process.env; +import { refreshZipgoAuth } from './auth'; -const tokens = zipgoLocalStorage.getTokens(); +export const { BASE_URL } = process.env; -const defaultConfig = { +const defaultConfig: AxiosRequestConfig = { baseURL: BASE_URL, }; -export const createConfigWithAuth = (tokens: Tokens | null) => - tokens - ? { - ...defaultConfig, - headers: { - Authorization: `Bearer ${tokens.accessToken}`, - }, - } - : defaultConfig; - export const clientBasic = axios.create(defaultConfig); -export const client = axios.create(createConfigWithAuth(tokens)); +export const client = axios.create(defaultConfig); + +client.interceptors.request.use(config => { + const tokens = zipgoLocalStorage.getTokens(); + + if (tokens) { + // eslint-disable-next-line no-param-reassign + config.headers.Authorization = `Bearer ${tokens.accessToken}`; + } + + return config; +}); + +client.interceptors.response.use( + response => response, + async (error: AxiosError) => { + const config = error.config; + + if (APIError.isAuthError(error) && config?.headers.Authorization) { + try { + const tokens = await refreshZipgoAuth(); + + zipgoLocalStorage.setTokens(tokens); + + config.headers.Authorization = `Bearer ${tokens.accessToken}`; + + return client(config); + } catch (error) { + alert('세션이 만료되었습니다'); + + zipgoLocalStorage.clearAuth(); + } + } + + return Promise.reject(error); + }, +); diff --git a/frontend/src/assets/svg/background_img.svg b/frontend/src/assets/svg/background_img.svg new file mode 100644 index 000000000..4affe1fd9 --- /dev/null +++ b/frontend/src/assets/svg/background_img.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/svg/close_square_icon_light.svg b/frontend/src/assets/svg/close_square_icon_light.svg new file mode 100644 index 000000000..01562d13e --- /dev/null +++ b/frontend/src/assets/svg/close_square_icon_light.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/svg/home_icon.svg b/frontend/src/assets/svg/home_icon.svg index d26c3038c..6efead1a3 100644 --- a/frontend/src/assets/svg/home_icon.svg +++ b/frontend/src/assets/svg/home_icon.svg @@ -1,4 +1,4 @@ - + diff --git a/frontend/src/assets/svg/refresh_icon.svg b/frontend/src/assets/svg/refresh_icon.svg new file mode 100644 index 000000000..73144592b --- /dev/null +++ b/frontend/src/assets/svg/refresh_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/svg/zipgo_text_logo.svg b/frontend/src/assets/svg/zipgo_text_logo.svg new file mode 100644 index 000000000..a5436407b --- /dev/null +++ b/frontend/src/assets/svg/zipgo_text_logo.svg @@ -0,0 +1,12 @@ + + + \ No newline at end of file diff --git a/frontend/src/assets/webp/with_pet_icon.webp b/frontend/src/assets/webp/with_pet_icon.webp new file mode 100644 index 000000000..6f09c297e Binary files /dev/null and b/frontend/src/assets/webp/with_pet_icon.webp differ diff --git a/frontend/src/components/@common/AxiosInterceptors/AxiosInterceptors.tsx b/frontend/src/components/@common/AxiosInterceptors/AxiosInterceptors.tsx deleted file mode 100644 index 39a9f4a51..000000000 --- a/frontend/src/components/@common/AxiosInterceptors/AxiosInterceptors.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { isAxiosError } from 'axios'; -import { PropsWithChildren, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; - -import { client } from '@/apis'; -import { zipgoLocalStorage } from '@/utils/localStorage'; - -const AxiosInterceptors = (props: PropsWithChildren) => { - const { children } = props; - - const navigate = useNavigate(); - - useEffect(() => { - const requestInterceptor = client.interceptors.request.use(config => { - const tokens = zipgoLocalStorage.getTokens(); - - if (tokens) { - // eslint-disable-next-line no-param-reassign - config.headers.Authorization = `Bearer ${tokens.accessToken}`; - } - - return config; - }); - - const responseInterceptor = client.interceptors.response.use( - response => response, - async error => { - if (!isAxiosError(error)) return error; - - if (error.response && error.response.status === 401) { - alert('세션이 만료되었습니다.'); - - zipgoLocalStorage.clearAuth(); - - navigate('/login'); - } - - return error; - }, - ); - - return () => { - client.interceptors.request.eject(requestInterceptor); - client.interceptors.response.eject(responseInterceptor); - }; - }, []); - - return children; -}; - -export default AxiosInterceptors; diff --git a/frontend/src/components/@common/Button/Button.tsx b/frontend/src/components/@common/Button/Button.tsx index fcdd3067a..dea6c791b 100644 --- a/frontend/src/components/@common/Button/Button.tsx +++ b/frontend/src/components/@common/Button/Button.tsx @@ -31,7 +31,6 @@ const Button = (buttonProps: ButtonProps) => { $kind={kind} $fixed={fixed} style={style} - $disabled={disabled} disabled={disabled} {...restProps} > @@ -54,6 +53,7 @@ const ButtonOuter = styled.div` ` z-index: 10; width: 100%; + max-width: ${theme.maxWidth.mobile}; bottom: 0; display: flex; align-items: center; @@ -64,7 +64,7 @@ const ButtonOuter = styled.div` `; const ButtonWrapper = styled.button` - cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')}; + cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')}; display: flex; gap: 0.4rem; @@ -80,8 +80,8 @@ const ButtonWrapper = styled.button` color: ${({ theme }) => theme.color.white}; letter-spacing: 0.02rem; - background-color: ${({ $kind, theme, $disabled }) => { - if ($disabled) { + background-color: ${({ $kind, theme, disabled }) => { + if (disabled) { return theme.color.grey300; } @@ -96,12 +96,11 @@ const ButtonWrapper = styled.button` transition: all 100ms ease-in-out; - ${({ $disabled }) => - !$disabled && - ` &:active { - scale: 0.98; - } - `} + &:not(:disabled) { + &:active { + scale: 0.98; + } + } ${({ $fixed }) => $fixed && diff --git a/frontend/src/components/@common/Dialog/Dialog.tsx b/frontend/src/components/@common/Dialog/Dialog.tsx index 718c262ed..6124f9f34 100644 --- a/frontend/src/components/@common/Dialog/Dialog.tsx +++ b/frontend/src/components/@common/Dialog/Dialog.tsx @@ -5,7 +5,7 @@ import { styled } from 'styled-components'; import DialogProvider, { useDialogContext } from '@/context/Dialog/DialogContext'; import { getValidProps, PropsWithRenderProps } from '@/utils/compound'; -import { composeEventHandlers } from '@/utils/dom'; +import { composeFunctions } from '@/utils/dom'; export interface DialogProps { open?: boolean; @@ -57,14 +57,14 @@ const Trigger = (props: PropsWithChildren) => { cloneElement(resolved.child, { ...restProps, ...triggerA11y, - onClick: composeEventHandlers(onClickProps, openHandler), + onClick: composeFunctions(onClickProps, openHandler), }) ) : ( @@ -73,7 +73,10 @@ const Trigger = (props: PropsWithChildren) => { return trigger; }; -const Portal = ({ children, container = document.body }: PropsWithChildren) => { +const Portal = ({ + children, + container = document.getElementById('mobile') ?? document.body, +}: PropsWithChildren) => { const { isOpened } = useDialogContext(); return isOpened ? createPortal(children, container) : null; @@ -93,13 +96,13 @@ const BackDrop = (props: PropsWithChildren) => { cloneElement(resolved.child, { ...restProps, ...backDropA11y, - onClick: composeEventHandlers(onClickProps, openHandler), + onClick: composeFunctions(onClickProps, openHandler), }) ) : ( ); @@ -156,14 +159,14 @@ const Close = (props: PropsWithChildren) => { cloneElement(resolved.child, { ...restProps, ...closeA11y, - onClick: composeEventHandlers(onClickProps, openHandler), + onClick: composeFunctions(onClickProps, openHandler), }) ) : ( diff --git a/frontend/src/components/@common/ErrorBoundary.tsx b/frontend/src/components/@common/ErrorBoundary.tsx deleted file mode 100644 index 5d94b9013..000000000 --- a/frontend/src/components/@common/ErrorBoundary.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Component, ReactNode } from 'react'; - -import { ErrorBoundaryState, ErrorBoundaryValue } from '@/types/common/errorBoundary'; - -interface ErrorBoundaryProps { - fallback?: ReactNode | ((props: ErrorBoundaryValue) => ReactNode); - children: ReactNode; - onReset?: VoidFunction; -} - -const initialState: ErrorBoundaryState = { - hasError: false, - error: null, -}; - -class ErrorBoundary extends Component { - constructor(props: ErrorBoundaryProps) { - super(props); - this.state = initialState; - - this.reset = this.reset.bind(this); - } - - static getDerivedStateFromError(error: Error): ErrorBoundaryState { - return { - hasError: true, - error, - }; - } - - reset() { - const { onReset } = this.props; - - onReset?.(); - this.setState(initialState); - } - - render() { - const { hasError } = this.state; - - const { children, fallback } = this.props; - - if (!hasError) return children; - - if (!fallback) return null; - - if (typeof fallback === 'function') return fallback({ reset: this.reset }); - - return fallback; - } -} - -export default ErrorBoundary; diff --git a/frontend/src/components/@common/ErrorBoundary/APIBoundary/APIBoundary.tsx b/frontend/src/components/@common/ErrorBoundary/APIBoundary/APIBoundary.tsx new file mode 100644 index 000000000..b7621ae97 --- /dev/null +++ b/frontend/src/components/@common/ErrorBoundary/APIBoundary/APIBoundary.tsx @@ -0,0 +1,38 @@ +import { ComponentProps, PropsWithChildren } from 'react'; +import { Navigate } from 'react-router-dom'; + +import { PATH } from '@/router/routes'; +import { resolveRenderProps } from '@/utils/compound'; +import { APIError } from '@/utils/errors'; + +import { ErrorBoundary } from '../ErrorBoundary'; + +type APIBoundaryProps = ComponentProps>; + +const APIBoundary = (props: PropsWithChildren) => { + const { fallback, ...restProps } = props; + + const handleIgnore: APIBoundaryProps['shouldIgnore'] = ({ error }) => + !(error instanceof APIError); + + const handleAPIFallback: APIBoundaryProps['fallback'] = ({ error, reset }) => + /** @todo 추후 에러 코드 상의 후 변경 */ + error.status === 401 ? ( + + ) : ( + resolveRenderProps(fallback, { error, reset }) + ); + + return ( + /** @description ignore는 사용하는 쪽에서 재정의 할 수 있다 */ + + shouldIgnore={handleIgnore} + fallback={handleAPIFallback} + {...restProps} + /> + ); +}; + +export type { APIBoundaryProps }; + +export default APIBoundary; diff --git a/frontend/src/components/@common/ErrorBoundary/ErrorBoundary.tsx b/frontend/src/components/@common/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 000000000..f799beeb3 --- /dev/null +++ b/frontend/src/components/@common/ErrorBoundary/ErrorBoundary.tsx @@ -0,0 +1,93 @@ +import { Component, ErrorInfo, PropsWithChildren, ReactNode } from 'react'; +import { useLocation } from 'react-router-dom'; + +import { ErrorBoundaryState, ErrorBoundaryValue } from '@/types/common/errorBoundary'; +import { RenderProps } from '@/types/common/utility'; +import { resolveFunctionOrPrimitive, resolveRenderProps } from '@/utils/compound'; +import { isIgnored, ZipgoError } from '@/utils/errors'; +import { isDifferentArray } from '@/utils/isDifferentArray'; + +const initialState = { + hasError: false, + error: null, +} as const; + +interface ErrorBoundaryProps { + mustCatch?: boolean; + resetKeys?: unknown[]; + fallback?: ReactNode | RenderProps>; + shouldIgnore?: boolean | ((payload: { error: E }) => boolean); + onError?(payload: { error: E; errorInfo: ErrorInfo }): void; + onReset?: VoidFunction; +} + +class BaseErrorBoundary extends Component< + PropsWithChildren>, + ErrorBoundaryState +> { + constructor(props: PropsWithChildren>) { + super(props); + this.state = initialState; + + this.reset = this.reset.bind(this); + } + + static getDerivedStateFromError(error: Error) { + return { + hasError: true, + error: ZipgoError.convertToError(error), + }; + } + + componentDidUpdate(prevProps: ErrorBoundaryProps) { + if (isDifferentArray(this.props.resetKeys, prevProps.resetKeys)) { + this.reset(); + } + } + + componentDidCatch(_: E, errorInfo: ErrorInfo): void { + const { onError, shouldIgnore, mustCatch } = this.props; + + const { error, hasError } = this.state; + + if (!hasError) return; + + const willIgnore = isIgnored(error) || resolveFunctionOrPrimitive(shouldIgnore, { error }); + + if (!mustCatch && willIgnore) throw error; + + onError?.({ error, errorInfo }); + } + + reset() { + const { onReset } = this.props; + + onReset?.(); + + this.setState(initialState); + } + + render() { + const { error, hasError } = this.state; + + const { children, fallback } = this.props; + + if (!hasError) return children; + + return resolveRenderProps(fallback, { reset: this.reset, error }); + } +} + +const ErrorBoundary = (props: PropsWithChildren>) => { + const location = useLocation(); + + const resetKeys = [location.key, ...(props.resetKeys || [])]; + + return {...props} resetKeys={resetKeys} />; +}; + +const CriticalBoundary = (props: PropsWithChildren>) => ( + {...props} mustCatch /> +); + +export { CriticalBoundary, ErrorBoundary }; diff --git a/frontend/src/components/@common/ErrorBoundary/QueryBoundary/QueryBoundary.tsx b/frontend/src/components/@common/ErrorBoundary/QueryBoundary/QueryBoundary.tsx new file mode 100644 index 000000000..8141fe8a8 --- /dev/null +++ b/frontend/src/components/@common/ErrorBoundary/QueryBoundary/QueryBoundary.tsx @@ -0,0 +1,38 @@ +import { QueryErrorResetBoundary } from '@tanstack/react-query'; +import { ComponentProps, PropsWithChildren, Suspense } from 'react'; + +import { composeFunctions } from '@/utils/dom'; + +import LoadingSpinner from '../../LoadingSpinner/LoadingSpinner'; +import APIBoundary from '../APIBoundary/APIBoundary'; + +interface QueryBoundaryProps extends Omit, 'fallback'> { + errorFallback?: ComponentProps['fallback']; + loadingFallback?: ComponentProps['fallback']; +} + +const QueryBoundary = (props: PropsWithChildren) => { + const { + children, + loadingFallback = , + errorFallback, + onReset, + ...restProps + } = props; + + return ( + + {({ reset }) => ( + (reset, onReset)} + {...restProps} + > + {children} + + )} + + ); +}; + +export default QueryBoundary; diff --git a/frontend/src/components/@common/Footer/Footer.tsx b/frontend/src/components/@common/Footer/Footer.tsx index 8c84a573e..09ad0093f 100644 --- a/frontend/src/components/@common/Footer/Footer.tsx +++ b/frontend/src/components/@common/Footer/Footer.tsx @@ -17,7 +17,7 @@ const FooterContainer = styled.footer` flex-direction: column; justify-content: center; - width: 100vw; + width: 100%; height: 12.7rem; padding: 3.2rem; diff --git a/frontend/src/components/@common/Funnel/Funnel.tsx b/frontend/src/components/@common/Funnel/Funnel.tsx new file mode 100644 index 000000000..67bfa651d --- /dev/null +++ b/frontend/src/components/@common/Funnel/Funnel.tsx @@ -0,0 +1,41 @@ +import { Children, isValidElement, PropsWithChildren, ReactElement, useEffect } from 'react'; + +import { NonEmptyArray } from '@/types/common/utility'; +import { RuntimeError } from '@/utils/errors'; + +export interface FunnelProps> { + steps: Steps; + step: Steps[number]; + children: Array>> | ReactElement>; +} + +export interface StepProps> extends PropsWithChildren { + name: Steps[number]; + onNext?: VoidFunction; +} + +export const Funnel = >(props: FunnelProps) => { + const { steps, step, children } = props; + const validChildren = Children.toArray(children) + .filter(isValidElement>) + .filter(({ props }) => steps.includes(props.name)); + + const targetStep = validChildren.find(child => child.props.name === step); + + if (!targetStep) { + throw new RuntimeError( + { code: 'WRONG_URL_FORMAT' }, + `${step} 스텝 컴포넌트를 찾지 못했습니다.`, + ); + } + + return targetStep; +}; + +export const Step = >({ onNext, children }: StepProps) => { + useEffect(() => { + onNext?.(); + }, [onNext]); + + return children; +}; diff --git a/frontend/src/components/@common/Header/Header.tsx b/frontend/src/components/@common/Header/Header.tsx index 28d70110c..f3396036d 100644 --- a/frontend/src/components/@common/Header/Header.tsx +++ b/frontend/src/components/@common/Header/Header.tsx @@ -2,10 +2,11 @@ import { Link } from 'react-router-dom'; import { styled } from 'styled-components'; import PetListBottomSheet from '@/components/PetProfile/PetListBottomSheet'; -import { useAuth } from '@/hooks/auth'; +import { useAuth, useCheckAuth } from '@/hooks/auth'; const Header = () => { - const { isLoggedIn, logout } = useAuth(); + const { logout } = useAuth(); + const { isLoggedIn } = useCheckAuth(); return ( @@ -33,7 +34,7 @@ const HeaderContainer = styled.header` align-items: center; justify-content: space-between; - width: 100vw; + width: 100%; padding: 2rem; background: transparent; diff --git a/frontend/src/components/@common/Header/UserProfile.tsx b/frontend/src/components/@common/Header/UserProfile.tsx index 944e29773..4b353b607 100644 --- a/frontend/src/components/@common/Header/UserProfile.tsx +++ b/frontend/src/components/@common/Header/UserProfile.tsx @@ -1,15 +1,14 @@ import { styled } from 'styled-components'; -import BottomDropIcon from '@/assets/svg/bottom_drop_icon.svg'; import ZipgoLogo from '@/assets/svg/zipgo_logo_light.svg'; import { usePetProfile } from '@/context/petProfile/PetProfileContext'; -import { useAuth } from '@/hooks/auth'; +import { useCheckAuth } from '@/hooks/auth'; import { Dialog } from '../Dialog/Dialog'; const UserProfile = () => { const { petProfile } = usePetProfile(); - const { isLoggedIn } = useAuth(); + const { isLoggedIn } = useCheckAuth(); return isLoggedIn ? ( @@ -29,7 +28,6 @@ const UserProfile = () => { ) : ( 여기를 눌러 반려견을 등록해주세요. )} - ) : ( @@ -75,8 +73,3 @@ const Logo = styled.img` width: 11.3rem; height: 3.6rem; `; - -const Chevron = styled.img` - width: 1.2rem; - height: 0.6rem; -`; diff --git a/frontend/src/components/@common/Input/Input.tsx b/frontend/src/components/@common/Input/Input.tsx index 0f250097b..9a79d7875 100644 --- a/frontend/src/components/@common/Input/Input.tsx +++ b/frontend/src/components/@common/Input/Input.tsx @@ -23,7 +23,8 @@ const InputWrapper = styled.input` height: 100%; padding: 1.2rem; - font-size: ${({ $fontSize }) => $fontSize || '1.4rem'}; + font-size: ${({ $fontSize }) => + ($fontSize && $fontSize < '1.6rem') || !$fontSize ? '16px' : $fontSize}; border: none; outline: none; diff --git a/frontend/src/components/@common/Label/Label.tsx b/frontend/src/components/@common/Label/Label.tsx index ed960a3ed..2014fd27f 100644 --- a/frontend/src/components/@common/Label/Label.tsx +++ b/frontend/src/components/@common/Label/Label.tsx @@ -135,15 +135,11 @@ const LabelWrapper = styled.div` background-color: ${({ $clicked, theme, $backgroundColor }) => $clicked ? theme.color.blue : $backgroundColor}; + border: 1px solid ${({ theme }) => theme.color.blue}; border-radius: 20px; ${({ $hasBorder, $clicked, $borderColor }) => - $hasBorder && - !$clicked && - ` - outline: 1px solid ${$borderColor}; - outline-offset: -1px; - `} + $hasBorder && !$clicked && `border-color: ${$borderColor};`} ${({ onClick }) => onClick && 'cursor: pointer'}; `; diff --git a/frontend/src/components/@common/Loading/Loading.tsx b/frontend/src/components/@common/Loading/Loading.tsx new file mode 100644 index 000000000..bf677a852 --- /dev/null +++ b/frontend/src/components/@common/Loading/Loading.tsx @@ -0,0 +1,9 @@ +import { PropsWithChildren, Suspense } from 'react'; + +import LoadingSpinner from '../LoadingSpinner/LoadingSpinner'; + +const Loading = ({ children }: PropsWithChildren) => ( + }>{children} +); + +export default Loading; diff --git a/frontend/src/components/@common/LoadingSpinner.tsx b/frontend/src/components/@common/LoadingSpinner/LoadingSpinner.tsx similarity index 76% rename from frontend/src/components/@common/LoadingSpinner.tsx rename to frontend/src/components/@common/LoadingSpinner/LoadingSpinner.tsx index 4b2c5430a..fe7819533 100644 --- a/frontend/src/components/@common/LoadingSpinner.tsx +++ b/frontend/src/components/@common/LoadingSpinner/LoadingSpinner.tsx @@ -22,13 +22,15 @@ const rotate = keyframes` const SpinnerWrapper = styled.div` position: fixed; z-index: 10; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); + bottom: 50%; + transform: translateY(-50%); - background-color: #fff; - border-radius: 50%; - box-shadow: 0 0.1rem 0.4rem 0 rgb(0 0 0 / 15%); + display: flex; + align-items: center; + justify-content: center; + + width: 100%; + max-width: ${({ theme }) => theme.maxWidth.mobile}; `; const AnimatedSpinner = styled.img` diff --git a/frontend/src/components/@common/MobileToDesktop/MobileToDesktop.tsx b/frontend/src/components/@common/MobileToDesktop/MobileToDesktop.tsx new file mode 100644 index 000000000..954f5e388 --- /dev/null +++ b/frontend/src/components/@common/MobileToDesktop/MobileToDesktop.tsx @@ -0,0 +1,84 @@ +import { PropsWithChildren } from 'react'; +import { styled } from 'styled-components'; +import { DesktopView, useDesktopView } from 'zipgo-layout'; + +import BackgroundImg from '@/assets/svg/background_img.svg'; +import ZipgoLogo from '@/assets/svg/zipgo_logo_light.svg'; +import ZipgoTextLogo from '@/assets/svg/zipgo_text_logo.svg'; +import theme from '@/styles/theme'; + +const RenderSub = () => ( + + +
+ + 초보 집사들의 + 사료 선택 기준. + + + 집사의고민 글씨로고 + +
+ + 우아한테크코스 집사의고민팀 + 문의: team.zipgo@gmail.com + + +); + +interface DesktopViewProps extends PropsWithChildren {} + +const MobileToDesktop = (props: DesktopViewProps) => { + const { children } = props; + + return ( + + {children} + + ); +}; + +export default MobileToDesktop; + +const SupporterWrapper = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + + height: 100vh; + padding: 2rem 0; +`; + +const ZipgoLogoImg = styled.img` + width: 11.3rem; + height: 3.6rem; +`; + +const SubTextContainer = styled.div` + display: flex; + flex-direction: column; + gap: 0.6rem; +`; + +const SubText = styled.p` + font-size: 3rem; + color: #fff; +`; + +const MainTextContainer = styled.div` + margin-top: 3rem; +`; + +const SmallTextContainer = styled.div` + display: flex; + gap: 0.8rem; +`; + +const SmallText = styled.p` + font-size: 1.2rem; + color: #fff; +`; diff --git a/frontend/src/components/@common/NavigationBar/NavigationBar.tsx b/frontend/src/components/@common/NavigationBar/NavigationBar.tsx index 353c7fb01..4fbce2d23 100644 --- a/frontend/src/components/@common/NavigationBar/NavigationBar.tsx +++ b/frontend/src/components/@common/NavigationBar/NavigationBar.tsx @@ -193,7 +193,7 @@ const NavItem = styled.button` const NavItemTitle = styled.h3` font-size: 1.8rem; - font-weight: ${({ theme, $clicked }) => ($clicked ? 500 : 400)}; + font-weight: ${({ $clicked }) => ($clicked ? 500 : 400)}; line-height: 1.8rem; color: ${({ theme, $clicked }) => ($clicked ? theme.color.grey700 : theme.color.grey400)}; letter-spacing: -0.05rem; diff --git a/frontend/src/components/@common/PageHeader/PageHeader.tsx b/frontend/src/components/@common/PageHeader/PageHeader.tsx index c7ad45125..3a1040382 100644 --- a/frontend/src/components/@common/PageHeader/PageHeader.tsx +++ b/frontend/src/components/@common/PageHeader/PageHeader.tsx @@ -19,7 +19,9 @@ const PageHeader = (pageHeaderProps: PageHeaderProps) => { onClick ? onClick() : navigate(routerPath.home()); }; - const scrollPosition = useCurrentScroll(); + const scrollPosition = useCurrentScroll({ + element: document.getElementById('mobile') ?? document.body, + }); return ( @@ -56,6 +58,7 @@ const HeaderWrapper = styled.header` justify-content: center; width: 100%; + max-width: ${({ theme }) => theme.maxWidth.mobile}; height: 8rem; padding: 1.6rem; diff --git a/frontend/src/components/@common/PrefetchImg/PrefetchImg.tsx b/frontend/src/components/@common/PrefetchImg/PrefetchImg.tsx new file mode 100644 index 000000000..61e9153c1 --- /dev/null +++ b/frontend/src/components/@common/PrefetchImg/PrefetchImg.tsx @@ -0,0 +1,19 @@ +import { useEffect } from 'react'; + +interface PrefetchImgProps { + srcList: string[]; +} + +const PrefetchImg = (props: PrefetchImgProps) => { + const { srcList } = props; + + useEffect(() => { + srcList.forEach(src => { + new Image().src = src; + }); + }, []); + + return null; +}; + +export default PrefetchImg; diff --git a/frontend/src/components/@common/QueryBoundary.tsx b/frontend/src/components/@common/QueryBoundary.tsx deleted file mode 100644 index d19c17772..000000000 --- a/frontend/src/components/@common/QueryBoundary.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { QueryErrorResetBoundary } from '@tanstack/react-query'; -import { ComponentProps, PropsWithChildren, Suspense } from 'react'; - -import ErrorBoundary from './ErrorBoundary'; -import LoadingSpinner from './LoadingSpinner'; - -interface QueryBoundaryProps { - errorFallback?: ComponentProps['fallback']; - loadingFallback?: ComponentProps['fallback']; -} - -const QueryBoundary = (props: PropsWithChildren) => { - const { children, errorFallback, loadingFallback = } = props; - - return ( - - {({ reset }) => ( - - {children} - - )} - - ); -}; - -export default QueryBoundary; diff --git a/frontend/src/components/@common/Select/Select.tsx b/frontend/src/components/@common/Select/Select.tsx index e379bd11f..18f913cd9 100644 --- a/frontend/src/components/@common/Select/Select.tsx +++ b/frontend/src/components/@common/Select/Select.tsx @@ -4,7 +4,7 @@ import styled from 'styled-components'; import SelectProvider, { useSelectContext } from '@/context/Select/SelectContext'; import { AsChild, getValidProps, PropsWithAsChild, PropsWithRenderProps } from '@/utils/compound'; -import { composeEventHandlers } from '@/utils/dom'; +import { composeFunctions } from '@/utils/dom'; export interface SelectProps { defaultValue?: string; @@ -53,7 +53,7 @@ const Trigger = ( ...restProps, ...triggerA11y, id, - onClick: composeEventHandlers(onClickProps, openHandler), + onClick: composeFunctions(onClickProps, openHandler), }) ) : ( @@ -128,9 +128,8 @@ const BackDrop = styled.div` position: fixed; z-index: 9999; top: 0; - left: 0; - width: 100vw; + width: 100%; height: calc((var(--vh, 1vh) * 100)); `; @@ -151,7 +150,7 @@ const Item = (props: PropsWithRenderProps) => const { selectedValue, changeValue, openHandler } = useSelectContext(); - const onClick = composeEventHandlers(onClickProps, () => { + const onClick = composeFunctions(onClickProps, () => { changeValue(value); openHandler(); }); diff --git a/frontend/src/components/@common/StarRating/StarRatingDisplay/StartRatingDisplay.tsx b/frontend/src/components/@common/StarRating/StarRatingDisplay/StartRatingDisplay.tsx index 675989e42..ceb07a62a 100644 --- a/frontend/src/components/@common/StarRating/StarRatingDisplay/StartRatingDisplay.tsx +++ b/frontend/src/components/@common/StarRating/StarRatingDisplay/StartRatingDisplay.tsx @@ -3,6 +3,8 @@ import { css, styled } from 'styled-components'; import EmptyStarIcon from '@/assets/svg/empty_star_icon.svg'; import FilledStarIcon from '@/assets/svg/filled_star_icon.svg'; +import SuspendedImg from '../../SuspendedImg/SuspendedImg'; + interface StarRatingDisplayProps { rating: number; size?: 'small' | 'medium' | 'large'; @@ -37,7 +39,7 @@ const StarContainer = styled.div` align-items: center; `; -const Star = styled.img<{ size: 'small' | 'medium' | 'large' }>` +const Star = styled(SuspendedImg)<{ size: 'small' | 'medium' | 'large' }>` ${({ size }) => { if (size === 'small') { return css` diff --git a/frontend/src/components/@common/SuspendedImg/SuspendedImg.tsx b/frontend/src/components/@common/SuspendedImg/SuspendedImg.tsx new file mode 100644 index 000000000..cc183c0b5 --- /dev/null +++ b/frontend/src/components/@common/SuspendedImg/SuspendedImg.tsx @@ -0,0 +1,55 @@ +import { useQuery } from '@tanstack/react-query'; +import { ComponentPropsWithoutRef, ComponentPropsWithRef, useEffect } from 'react'; + +import { useIntersectionObserver } from '@/hooks/@common/useIntersectionObserver'; + +interface SuspendedImgProps extends ComponentPropsWithoutRef<'img'> { + staleTime?: number; + cacheTime?: number; + enabled?: boolean; + lazy?: boolean; +} + +// eslint-disable-next-line react/display-name +const SuspendedImg = (props: SuspendedImgProps) => { + const { src, cacheTime, staleTime, enabled, lazy, ...restProps } = props; + + const img = new Image(); + + const { targetRef, isIntersected } = useIntersectionObserver({ + observerOptions: { threshold: 0.1 }, + }); + + const lazyOptions: ComponentPropsWithRef<'img'> & { 'data-src'?: string } = { + loading: 'lazy', + ref: targetRef, + 'data-src': src, + }; + + useQuery({ + queryKey: [src], + queryFn: () => + new Promise(resolve => { + img.onload = resolve; + img.onerror = resolve; + + img.src = src!; + }), + ...(staleTime == null ? {} : { staleTime }), + ...(cacheTime == null ? {} : { cacheTime }), + enabled: enabled && Boolean(src), + }); + + useEffect(() => { + if (!targetRef.current) return; + + if ('loading' in HTMLImageElement.prototype || isIntersected) { + targetRef.current.src = String(targetRef.current.dataset.src); + } + }, [isIntersected]); + + // eslint-disable-next-line jsx-a11y/alt-text + return ; +}; + +export default SuspendedImg; diff --git a/frontend/src/components/@common/Tabs/Tabs.tsx b/frontend/src/components/@common/Tabs/Tabs.tsx index 1d4edfdeb..2b4eb0033 100644 --- a/frontend/src/components/@common/Tabs/Tabs.tsx +++ b/frontend/src/components/@common/Tabs/Tabs.tsx @@ -3,7 +3,7 @@ import { cloneElement, ComponentPropsWithoutRef, useId } from 'react'; import TabsProvider, { useTabsContext } from '@/context/Tabs/TabsContext'; import type { AsChild, PropsWithAsChild, PropsWithRenderProps } from '@/utils/compound'; import { getValidProps } from '@/utils/compound'; -import { composeEventHandlers } from '@/utils/dom'; +import { composeFunctions } from '@/utils/dom'; export interface TabProps { defaultValue: string; @@ -59,7 +59,7 @@ const Trigger = (props: PropsWithRenderProps changeTab(value)); + const onClick = composeFunctions(onClickProps, () => changeTab(value)); const selected = value === selectedValue; diff --git a/frontend/src/components/@common/Template.tsx b/frontend/src/components/@common/Template.tsx index 3c0813c0d..b6eca5729 100644 --- a/frontend/src/components/@common/Template.tsx +++ b/frontend/src/components/@common/Template.tsx @@ -2,6 +2,7 @@ import { createElement, isValidElement, PropsWithChildren, ReactNode } from 'react'; import styled, { css, isStyledComponent } from 'styled-components'; +import { StyledProps } from '@/types/common/utility'; import { getComputedStyleOfSC } from '@/utils/styled-components'; import Footer from './Footer/Footer'; @@ -35,7 +36,7 @@ const Template = ( return ( {createElement(fixedHeader ?? staticHeader!)} - + {children} {footer &&