diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index d2ae2081..904cf1ac 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -9,3 +9,7 @@ assignees: '' ## 어떤 기능을 구현하나요? - 구현 할 기능을 작성합니다. + +## 일정 + +- 추정 시간 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index db30585e..5911e03c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -13,3 +13,8 @@ ## 🎸 기타 - 특이 사항이 있으면 작성합니다. + +## ⏰ 일정 + +- 추정 시간 : +- 걸린 시간 : diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 00000000..33ec4ac1 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,17 @@ +name-template: 'v$RESOLVED_VERSION' +tag-template: 'v$RESOLVED_VERSION' +template: | + # What's Changed + + $CHANGES + + **Full Changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION + +categories: + - title: '🔑 FE' + labels: + - '🔑 FE' + - title: '🔒 BE' + labels: + - '🔒 BE' +change-template: '- $TITLE @$AUTHOR (#$NUMBER)' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..ef1944d3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,36 @@ +name: 릴리즈 노트 작성 + +on: + push: + branches: + - main + tags: + - v*.*.* + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: 저장소 가져오기 + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: 버전 가져오기 + id: version + run: | + tag=${GITHUB_REF/refs\/tags\//} + version=${tag#v} + major=${version%%.*} + echo "tag=${tag}" >> $GITHUB_OUTPUT + echo "version=${version}" >> $GITHUB_OUTPUT + echo "major=${major}" >> $GITHUB_OUTPUT + + - name: 릴리즈 노트 작성 + uses: release-drafter/release-drafter@master + with: + version: ${{ steps.version.outputs.version }} + publish: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-fe.yml b/.github/workflows/test-fe.yml new file mode 100644 index 00000000..5b3f8843 --- /dev/null +++ b/.github/workflows/test-fe.yml @@ -0,0 +1,76 @@ +name: Frontend PR Test + +on: + pull_request: + branches: + - main + - develop + paths: + - '.github/**' + - 'frontend/**' + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + + permissions: + checks: write + pull-requests: write + + steps: + - name: Repository 체크아웃 + uses: actions/checkout@v3 + + - name: Node 설정 + uses: actions/setup-node@v3 + with: + node-version: '18.16.1' + + - name: node_modules 캐싱 + id: cache + uses: actions/cache@v3 + with: + path: '**/frontend/node_modules' + key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: 의존성 설치 + working-directory: frontend/ + if: steps.cache.outputs.cache-hit != 'true' + run: yarn install --pure-lockfile + + - name: 테스트 실행 + working-directory: frontend/ + run: yarn test + continue-on-error: true + + - name: 테스트 결과 PR에 코멘트 등록 + uses: EnricoMi/publish-unit-test-result-action@v1 + if: always() + with: + files: '**/frontend/test-results/results.xml' + + - name: 테스트 실패 시, 실패한 코드 라인에 Check 코멘트를 등록 + uses: mikepenz/action-junit-report@v3 + if: always() + with: + report_paths: '**/frontend/test-results/results.xml' + token: ${{ github.token }} + + - name: build 실패 시 Slack으로 알립니다 + uses: 8398a7/action-slack@v3 + with: + status: ${{ job.status }} + author_name: 프론트엔드 테스트 실패 알림 + fields: repo, message, commit, author, action, eventName, ref, workflow, job, took + env: + SLACK_CHANNEL: group-dev + SLACK_COLOR: '#FF2D00' + SLACK_USERNAME: 'Github Action' + SLACK_ICON: https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png + SLACK_TITLE: Build Failure - ${{ github.event.pull_request.title }} + SLACK_MESSAGE: PR Url - ${{ github.event.pull_request.url }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + if: failure() diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..ee8d1123 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,76 @@ +name: PR Test + +on: + pull_request: + branches: + - main + - develop + paths: + - '.github/**' + - 'backend/**' + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + + permissions: + checks: write + pull-requests: write + + steps: + - name: Repository 체크아웃 + uses: actions/checkout@v3 + + - name: JDK 11 설정 + uses: actions/setup-java@v3 + with: + java-version: 11 + distribution: temurin + + - name: Gradle 캐싱 + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Gradle 권한 부여 + working-directory: backend/ + run: chmod +x ./gradlew + + - name: 테스트 실행 + working-directory: backend/ + run: ./gradlew --info test + + - name: 테스트 결과 PR에 코멘트 등록 + uses: EnricoMi/publish-unit-test-result-action@v1 + if: always() + with: + files: '**/backend/build/test-results/test/TEST-*.xml' + + - name: 테스트 실패 시, 실패한 코드 라인에 Check 코멘트를 등록 + uses: mikepenz/action-junit-report@v3 + if: always() + with: + report_paths: '**/backend/build/test-results/test/TEST-*.xml' + token: ${{ github.token }} + + - name: build 실패 시 Slack으로 알립니다 + uses: 8398a7/action-slack@v3 + with: + status: ${{ job.status }} + author_name: 백엔드 테스트 실패 알림 + fields: repo, message, commit, author, action, eventName, ref, workflow, job, took + env: + SLACK_CHANNEL: group-dev + SLACK_COLOR: '#FF2D00' + SLACK_USERNAME: 'Github Action' + SLACK_ICON: https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png + SLACK_TITLE: Build Failure - ${{ github.event.pull_request.title }} + SLACK_MESSAGE: PR Url - ${{ github.event.pull_request.url }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + if: failure() diff --git a/backend/build.gradle b/backend/build.gradle index 421fffc8..3850c278 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -1,28 +1,37 @@ plugins { - id 'java' - id 'org.springframework.boot' version '2.7.13' - id 'io.spring.dependency-management' version '1.0.15.RELEASE' + id 'java' + id 'org.springframework.boot' version '2.7.13' + id 'io.spring.dependency-management' version '1.0.15.RELEASE' } group = 'com.funeat' version = '0.0.1-SNAPSHOT' java { - sourceCompatibility = '11' + sourceCompatibility = '11' } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-web' - runtimeOnly 'com.mysql:mysql-connector-j' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'com.h2database:h2' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + runtimeOnly 'com.mysql:mysql-connector-j' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.rest-assured:rest-assured:4.4.0' + testRuntimeOnly 'com.h2database:h2' + + implementation 'org.springdoc:springdoc-openapi-ui:1.7.0' + implementation 'commons-fileupload:commons-fileupload:1.5' + implementation 'commons-io:commons-io:2.11.0' + implementation 'com.github.maricn:logback-slack-appender:1.4.0' + + implementation 'org.springframework.boot:spring-boot-starter-actuator' + runtimeOnly 'io.micrometer:micrometer-registry-prometheus' } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/backend/src/main/java/com/funeat/auth/application/AuthService.java b/backend/src/main/java/com/funeat/auth/application/AuthService.java new file mode 100644 index 00000000..0918aa60 --- /dev/null +++ b/backend/src/main/java/com/funeat/auth/application/AuthService.java @@ -0,0 +1,31 @@ +package com.funeat.auth.application; + +import com.funeat.auth.dto.SignUserDto; +import com.funeat.auth.dto.UserInfoDto; +import com.funeat.auth.util.PlatformUserProvider; +import com.funeat.member.application.MemberService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +public class AuthService { + + private final MemberService memberService; + private final PlatformUserProvider platformUserProvider; + + public AuthService(final MemberService memberService, final PlatformUserProvider platformUserProvider) { + this.memberService = memberService; + this.platformUserProvider = platformUserProvider; + } + + public SignUserDto loginWithKakao(final String code) { + final UserInfoDto userInfoDto = platformUserProvider.getPlatformUser(code); + final SignUserDto signUserDto = memberService.findOrCreateMember(userInfoDto); + return signUserDto; + } + + public String getLoginRedirectUri() { + return platformUserProvider.getRedirectURI(); + } +} diff --git a/backend/src/main/java/com/funeat/auth/dto/KakaoTokenDto.java b/backend/src/main/java/com/funeat/auth/dto/KakaoTokenDto.java new file mode 100644 index 00000000..6b4f0995 --- /dev/null +++ b/backend/src/main/java/com/funeat/auth/dto/KakaoTokenDto.java @@ -0,0 +1,53 @@ +package com.funeat.auth.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class KakaoTokenDto { + + private final String accessToken; + private final String tokenType; + private final String refreshToken; + private final String expiresIn; + private final String scope; + private final String refreshTokenExpiresIn; + + @JsonCreator + public KakaoTokenDto(@JsonProperty("access_token") final String accessToken, + @JsonProperty("token_type") final String tokenType, + @JsonProperty("refresh_token") final String refreshToken, + @JsonProperty("expires_in") final String expiresIn, + @JsonProperty("scope") final String scope, + @JsonProperty("refresh_token_expires_in") final String refreshTokenExpiresIn) { + this.accessToken = accessToken; + this.tokenType = tokenType; + this.refreshToken = refreshToken; + this.expiresIn = expiresIn; + this.scope = scope; + this.refreshTokenExpiresIn = refreshTokenExpiresIn; + } + + public String getAccessToken() { + return accessToken; + } + + public String getTokenType() { + return tokenType; + } + + public String getRefreshToken() { + return refreshToken; + } + + public String getExpiresIn() { + return expiresIn; + } + + public String getScope() { + return scope; + } + + public String getRefreshTokenExpiresIn() { + return refreshTokenExpiresIn; + } +} diff --git a/backend/src/main/java/com/funeat/auth/dto/KakaoUserInfoDto.java b/backend/src/main/java/com/funeat/auth/dto/KakaoUserInfoDto.java new file mode 100644 index 00000000..8b942404 --- /dev/null +++ b/backend/src/main/java/com/funeat/auth/dto/KakaoUserInfoDto.java @@ -0,0 +1,61 @@ +package com.funeat.auth.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class KakaoUserInfoDto { + + private final Long id; + private final KakaoAccount kakaoAccount; + + @JsonCreator + public KakaoUserInfoDto(@JsonProperty("id") final Long id, + @JsonProperty("kakao_account") final KakaoAccount kakaoAccount) { + this.id = id; + this.kakaoAccount = kakaoAccount; + } + + public Long getId() { + return id; + } + + public KakaoAccount getKakaoAccount() { + return kakaoAccount; + } + + public static class KakaoAccount { + + private final KakaoProfile profile; + + @JsonCreator + public KakaoAccount(@JsonProperty("profile") final KakaoProfile profile) { + this.profile = profile; + } + + public KakaoProfile getProfile() { + return profile; + } + } + + public static class KakaoProfile { + + private final String nickname; + private final String profileImageUrl; + + @JsonCreator + public KakaoProfile( + @JsonProperty("nickname") final String nickname, + @JsonProperty("profile_image_url") final String profileImageUrl) { + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + } + + public String getNickname() { + return nickname; + } + + public String getProfileImageUrl() { + return profileImageUrl; + } + } +} diff --git a/backend/src/main/java/com/funeat/auth/dto/LoginInfo.java b/backend/src/main/java/com/funeat/auth/dto/LoginInfo.java new file mode 100644 index 00000000..2987c111 --- /dev/null +++ b/backend/src/main/java/com/funeat/auth/dto/LoginInfo.java @@ -0,0 +1,14 @@ +package com.funeat.auth.dto; + +public class LoginInfo { + + private final Long id; + + public LoginInfo(final Long id) { + this.id = id; + } + + public Long getId() { + return id; + } +} diff --git a/backend/src/main/java/com/funeat/auth/dto/SignUserDto.java b/backend/src/main/java/com/funeat/auth/dto/SignUserDto.java new file mode 100644 index 00000000..2e910ee7 --- /dev/null +++ b/backend/src/main/java/com/funeat/auth/dto/SignUserDto.java @@ -0,0 +1,26 @@ +package com.funeat.auth.dto; + +import com.funeat.member.domain.Member; + +public class SignUserDto { + + private final boolean isSignUp; + private final Member member; + + public SignUserDto(final boolean isSignUp, final Member member) { + this.isSignUp = isSignUp; + this.member = member; + } + + public static SignUserDto of(final boolean isSignUp, final Member member) { + return new SignUserDto(isSignUp, member); + } + + public boolean isSignUp() { + return isSignUp; + } + + public Member getMember() { + return member; + } +} diff --git a/backend/src/main/java/com/funeat/auth/dto/TokenResponse.java b/backend/src/main/java/com/funeat/auth/dto/TokenResponse.java new file mode 100644 index 00000000..842cea22 --- /dev/null +++ b/backend/src/main/java/com/funeat/auth/dto/TokenResponse.java @@ -0,0 +1,14 @@ +package com.funeat.auth.dto; + +public class TokenResponse { + + private String accessToken; + + public TokenResponse(final String accessToken) { + this.accessToken = accessToken; + } + + public String getAccessToken() { + return accessToken; + } +} diff --git a/backend/src/main/java/com/funeat/auth/dto/UserInfoDto.java b/backend/src/main/java/com/funeat/auth/dto/UserInfoDto.java new file mode 100644 index 00000000..3861eac6 --- /dev/null +++ b/backend/src/main/java/com/funeat/auth/dto/UserInfoDto.java @@ -0,0 +1,40 @@ +package com.funeat.auth.dto; + +import com.funeat.member.domain.Member; + +public class UserInfoDto { + + private final Long id; + private final String nickname; + private final String profileImageUrl; + + public UserInfoDto(final Long id, final String nickname, final String profileImageUrl) { + this.id = id; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + } + + public static UserInfoDto from(final KakaoUserInfoDto kakaoUserInfoDto) { + return new UserInfoDto( + kakaoUserInfoDto.getId(), + kakaoUserInfoDto.getKakaoAccount().getProfile().getNickname(), + kakaoUserInfoDto.getKakaoAccount().getProfile().getProfileImageUrl() + ); + } + + public Member toMember() { + return new Member(this.nickname, this.profileImageUrl, this.id.toString()); + } + + public Long getId() { + return id; + } + + public String getNickname() { + return nickname; + } + + public String getProfileImageUrl() { + return profileImageUrl; + } +} diff --git a/backend/src/main/java/com/funeat/auth/exception/LoginException.java b/backend/src/main/java/com/funeat/auth/exception/LoginException.java new file mode 100644 index 00000000..2b3eaca0 --- /dev/null +++ b/backend/src/main/java/com/funeat/auth/exception/LoginException.java @@ -0,0 +1,8 @@ +package com.funeat.auth.exception; + +public class LoginException extends RuntimeException { + + public LoginException(final String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/funeat/auth/presentation/AuthApiController.java b/backend/src/main/java/com/funeat/auth/presentation/AuthApiController.java new file mode 100644 index 00000000..dc7aadf8 --- /dev/null +++ b/backend/src/main/java/com/funeat/auth/presentation/AuthApiController.java @@ -0,0 +1,55 @@ +package com.funeat.auth.presentation; + +import com.funeat.auth.application.AuthService; +import com.funeat.auth.dto.LoginInfo; +import com.funeat.auth.dto.SignUserDto; +import com.funeat.auth.util.AuthenticationPrincipal; +import java.net.URI; +import javax.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class AuthApiController implements AuthController { + + private final AuthService authService; + + public AuthApiController(final AuthService authService) { + this.authService = authService; + } + + @GetMapping("/api/auth/kakao") + public ResponseEntity kakaoLogin() { + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(authService.getLoginRedirectUri())) + .build(); + } + + @GetMapping("/api/login/oauth2/code/kakao") + public ResponseEntity loginAuthorizeUser(@RequestParam("code") final String code, + final HttpServletRequest request) { + final SignUserDto signUserDto = authService.loginWithKakao(code); + final Long memberId = signUserDto.getMember().getId(); + request.getSession().setAttribute("member", memberId); + + if (signUserDto.isSignUp()) { + return ResponseEntity.ok() + .location(URI.create("/profile")) + .build(); + } + return ResponseEntity.ok() + .location(URI.create("/")) + .build(); + } + + @GetMapping("/api/logout") + public ResponseEntity logout(@AuthenticationPrincipal final LoginInfo loginInfo, + final HttpServletRequest request) { + request.getSession().removeAttribute("member"); + + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/funeat/auth/presentation/AuthController.java b/backend/src/main/java/com/funeat/auth/presentation/AuthController.java new file mode 100644 index 00000000..eb65e8be --- /dev/null +++ b/backend/src/main/java/com/funeat/auth/presentation/AuthController.java @@ -0,0 +1,39 @@ +package com.funeat.auth.presentation; + +import com.funeat.auth.dto.LoginInfo; +import com.funeat.auth.util.AuthenticationPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import javax.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "06.Login", description = "로그인 관련 API 입니다.") +public interface AuthController { + + @Operation(summary = "카카오 로그인", description = "카카오 로그인을 한다") + @ApiResponse( + responseCode = "302", + description = "로그인이 성공하여 리다이렉트함" + ) + @GetMapping + ResponseEntity kakaoLogin(); + + @Operation(summary = "유저 인증", description = "유저 인증을 한다") + @ApiResponse( + responseCode = "302", + description = "기존 회원이면 홈으로 이동, 신규 회원이면 마이페이지로 이동." + ) + @GetMapping + ResponseEntity loginAuthorizeUser(@RequestParam("code") String code, HttpServletRequest request); + + @Operation(summary = "로그아웃", description = "로그아웃을 한다") + @ApiResponse( + responseCode = "200", + description = "로그아웃 성공." + ) + @GetMapping + ResponseEntity logout(@AuthenticationPrincipal LoginInfo loginInfo, HttpServletRequest request); +} diff --git a/backend/src/main/java/com/funeat/auth/util/AuthArgumentResolver.java b/backend/src/main/java/com/funeat/auth/util/AuthArgumentResolver.java new file mode 100644 index 00000000..74fb8efb --- /dev/null +++ b/backend/src/main/java/com/funeat/auth/util/AuthArgumentResolver.java @@ -0,0 +1,35 @@ +package com.funeat.auth.util; + +import com.funeat.auth.dto.LoginInfo; +import java.util.Objects; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class AuthArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthenticationPrincipal.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + final HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + System.out.println("request.getSession().getAttribute(\"member\"))"+ request.getSession().getAttribute("member")); + System.out.println("request.getHeader(\"Cookie\")"+ request.getHeader("Cookie")); + System.out.println("request.getRequestURL().toString()"+request.getRequestURL().toString()); + + final HttpSession session = Objects.requireNonNull(request).getSession(); + final String id = String.valueOf(session.getAttribute("member")); + + return new LoginInfo(Long.valueOf(id)); + } +} diff --git a/backend/src/main/java/com/funeat/auth/util/AuthHandlerInterceptor.java b/backend/src/main/java/com/funeat/auth/util/AuthHandlerInterceptor.java new file mode 100644 index 00000000..f32f791e --- /dev/null +++ b/backend/src/main/java/com/funeat/auth/util/AuthHandlerInterceptor.java @@ -0,0 +1,21 @@ +package com.funeat.auth.util; + +import com.funeat.auth.exception.LoginException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class AuthHandlerInterceptor implements HandlerInterceptor { + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + final HttpSession session = request.getSession(); + if (session.getAttribute("member") == null) { + throw new LoginException("login error"); + } + return true; + } +} diff --git a/backend/src/main/java/com/funeat/auth/util/AuthenticationPrincipal.java b/backend/src/main/java/com/funeat/auth/util/AuthenticationPrincipal.java new file mode 100644 index 00000000..a7c0b0cd --- /dev/null +++ b/backend/src/main/java/com/funeat/auth/util/AuthenticationPrincipal.java @@ -0,0 +1,11 @@ +package com.funeat.auth.util; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthenticationPrincipal { +} diff --git a/backend/src/main/java/com/funeat/auth/util/KakaoPlatformUserProvider.java b/backend/src/main/java/com/funeat/auth/util/KakaoPlatformUserProvider.java new file mode 100644 index 00000000..74c3c6f5 --- /dev/null +++ b/backend/src/main/java/com/funeat/auth/util/KakaoPlatformUserProvider.java @@ -0,0 +1,123 @@ +package com.funeat.auth.util; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.funeat.auth.dto.KakaoTokenDto; +import com.funeat.auth.dto.KakaoUserInfoDto; +import com.funeat.auth.dto.UserInfoDto; +import java.util.StringJoiner; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +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.RestTemplate; + +@Component +@Profile("!test") +public class KakaoPlatformUserProvider implements PlatformUserProvider { + + private static final String AUTHORIZATION_BASE_URL = "https://kauth.kakao.com"; + private static final String RESOURCE_BASE_URL = "https://kapi.kakao.com"; + private static final String OAUTH_URI = "/oauth/authorize"; + private static final String ACCESS_TOKEN_URI = "/oauth/token"; + private static final String USER_INFO_URI = "/v2/user/me"; + private static final String AUTHORIZATION_CODE = "authorization_code"; + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final String kakaoRestApiKey; + private final String redirectUri; + + public KakaoPlatformUserProvider(final RestTemplateBuilder restTemplateBuilder, + final ObjectMapper objectMapper, + @Value("${kakao.rest-api-key}") final String kakaoRestApiKey, + @Value("${kakao.redirect-uri}") final String redirectUri) { + this.restTemplate = restTemplateBuilder.build(); + this.objectMapper = objectMapper; + this.kakaoRestApiKey = kakaoRestApiKey; + this.redirectUri = redirectUri; + } + + public UserInfoDto getPlatformUser(final String code) { + final KakaoTokenDto accessTokenDto = findAccessToken(code); + final KakaoUserInfoDto kakaoUserInfoDto = findKakaoUserInfo(accessTokenDto.getAccessToken()); + return UserInfoDto.from(kakaoUserInfoDto); + } + + private KakaoTokenDto findAccessToken(final String code) { + final ResponseEntity response = requestAccessToken(code); + validateResponse(response, HttpStatus.OK); + return convertJsonToKakaoTokenDto(response.getBody()); + } + + private ResponseEntity requestAccessToken(final String code) { + final HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + final MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", AUTHORIZATION_CODE); + body.add("client_id", kakaoRestApiKey); + body.add("redirect_uri", redirectUri); + body.add("code", code); + + final HttpEntity> request = new HttpEntity<>(body, headers); + final ResponseEntity response = restTemplate.postForEntity(AUTHORIZATION_BASE_URL + ACCESS_TOKEN_URI, + request, String.class); + + return response; + } + + private void validateResponse(final ResponseEntity response, final HttpStatus status) { + if (response.getStatusCode() != status) { + throw new IllegalArgumentException(); + } + } + + private KakaoTokenDto convertJsonToKakaoTokenDto(final String responseBody) { + try { + return objectMapper.readValue(responseBody, KakaoTokenDto.class); + } catch (final JsonProcessingException e) { + throw new IllegalArgumentException(); + } + } + + private KakaoUserInfoDto findKakaoUserInfo(final String accessToken) { + final ResponseEntity response = requestKakaoUserInfo(accessToken); + validateResponse(response, HttpStatus.OK); + return convertJsonToKakaoUserDto(response.getBody()); + } + + private ResponseEntity requestKakaoUserInfo(final String accessToken) { + final HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + final HttpEntity> request = new HttpEntity<>(null, headers); + return restTemplate.postForEntity(RESOURCE_BASE_URL + USER_INFO_URI, request, String.class); + } + + private KakaoUserInfoDto convertJsonToKakaoUserDto(final String responseBody) { + try { + return objectMapper.readValue(responseBody, KakaoUserInfoDto.class); + } catch (final JsonProcessingException e) { + throw new IllegalArgumentException(); + } + } + + @Override + public String getRedirectURI() { + final StringJoiner joiner = new StringJoiner("&"); + joiner.add("response_type=code"); + joiner.add("client_id=" + kakaoRestApiKey); + joiner.add("redirect_uri=" + redirectUri); + + return AUTHORIZATION_BASE_URL + OAUTH_URI + "?" + joiner; + } +} diff --git a/backend/src/main/java/com/funeat/auth/util/PlatformUserProvider.java b/backend/src/main/java/com/funeat/auth/util/PlatformUserProvider.java new file mode 100644 index 00000000..2d77b441 --- /dev/null +++ b/backend/src/main/java/com/funeat/auth/util/PlatformUserProvider.java @@ -0,0 +1,10 @@ +package com.funeat.auth.util; + +import com.funeat.auth.dto.UserInfoDto; + +public interface PlatformUserProvider { + + UserInfoDto getPlatformUser(final String code); + + String getRedirectURI(); +} diff --git a/backend/src/main/java/com/funeat/common/CustomPageableHandlerMethodArgumentResolver.java b/backend/src/main/java/com/funeat/common/CustomPageableHandlerMethodArgumentResolver.java new file mode 100644 index 00000000..db3e9622 --- /dev/null +++ b/backend/src/main/java/com/funeat/common/CustomPageableHandlerMethodArgumentResolver.java @@ -0,0 +1,26 @@ +package com.funeat.common; + +import org.springframework.core.MethodParameter; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class CustomPageableHandlerMethodArgumentResolver extends PageableHandlerMethodArgumentResolver { + + @Override + public Pageable resolveArgument(MethodParameter methodParameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + final Pageable pageable = super.resolveArgument(methodParameter, mavContainer, webRequest, binderFactory); + + final Sort lastPrioritySort = Sort.by("id").descending(); + + return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), + pageable.getSort().and(lastPrioritySort)); + } +} diff --git a/backend/src/main/java/com/funeat/common/ImageService.java b/backend/src/main/java/com/funeat/common/ImageService.java new file mode 100644 index 00000000..3e8ad0cc --- /dev/null +++ b/backend/src/main/java/com/funeat/common/ImageService.java @@ -0,0 +1,8 @@ +package com.funeat.common; + +import org.springframework.web.multipart.MultipartFile; + +public interface ImageService { + + void upload(final MultipartFile image); +} diff --git a/backend/src/main/java/com/funeat/common/ImageUploader.java b/backend/src/main/java/com/funeat/common/ImageUploader.java new file mode 100644 index 00000000..98af6796 --- /dev/null +++ b/backend/src/main/java/com/funeat/common/ImageUploader.java @@ -0,0 +1,29 @@ +package com.funeat.common; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +@Component +@Profile("!test") +public class ImageUploader implements ImageService { + + @Value("${image.path}") + private String imagePath; + + @Override + public void upload(final MultipartFile image) { + final String originalImageName = image.getOriginalFilename(); + final Path path = Paths.get(imagePath + originalImageName); + try { + Files.write(path, image.getBytes()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/backend/src/main/java/com/funeat/common/OpenApiConfig.java b/backend/src/main/java/com/funeat/common/OpenApiConfig.java new file mode 100644 index 00000000..ef3b45b2 --- /dev/null +++ b/backend/src/main/java/com/funeat/common/OpenApiConfig.java @@ -0,0 +1,22 @@ +package com.funeat.common; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.context.annotation.Configuration; + +@OpenAPIDefinition( + info = @Info( + title = "펀잇 API 명세서", + description = "펀잇팀 API 명세서입니다.", + version = "v1" + ), + tags = { + @Tag(name = "01.Product", description = "상품 기능"), + @Tag(name = "02.Category", description = "카테고리 기능"), + @Tag(name = "03.Review", description = "리뷰 기능"), + } +) +@Configuration +public class OpenApiConfig { +} diff --git a/backend/src/main/java/com/funeat/common/StringToCategoryTypeConverter.java b/backend/src/main/java/com/funeat/common/StringToCategoryTypeConverter.java new file mode 100644 index 00000000..642c82a8 --- /dev/null +++ b/backend/src/main/java/com/funeat/common/StringToCategoryTypeConverter.java @@ -0,0 +1,12 @@ +package com.funeat.common; + +import com.funeat.product.domain.CategoryType; +import org.springframework.core.convert.converter.Converter; + +public class StringToCategoryTypeConverter implements Converter { + + @Override + public CategoryType convert(final String source) { + return CategoryType.valueOf(source.toUpperCase()); + } +} diff --git a/backend/src/main/java/com/funeat/common/WebConfig.java b/backend/src/main/java/com/funeat/common/WebConfig.java new file mode 100644 index 00000000..8c43fdea --- /dev/null +++ b/backend/src/main/java/com/funeat/common/WebConfig.java @@ -0,0 +1,50 @@ +package com.funeat.common; + +import com.funeat.auth.util.AuthArgumentResolver; +import com.funeat.auth.util.AuthHandlerInterceptor; +import com.funeat.recipe.utill.RecipeHandlerInterceptor; +import java.util.List; +import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final CustomPageableHandlerMethodArgumentResolver customPageableHandlerMethodArgumentResolver; + private final AuthArgumentResolver authArgumentResolver; + private final AuthHandlerInterceptor authHandlerInterceptor; + private final RecipeHandlerInterceptor recipeHandlerInterceptor; + + public WebConfig(final CustomPageableHandlerMethodArgumentResolver customPageableHandlerMethodArgumentResolver, + final AuthArgumentResolver authArgumentResolver, + final AuthHandlerInterceptor authHandlerInterceptor, + final RecipeHandlerInterceptor recipeHandlerInterceptor) { + this.customPageableHandlerMethodArgumentResolver = customPageableHandlerMethodArgumentResolver; + this.authArgumentResolver = authArgumentResolver; + this.authHandlerInterceptor = authHandlerInterceptor; + this.recipeHandlerInterceptor = recipeHandlerInterceptor; + } + + @Override + public void addInterceptors(final InterceptorRegistry registry) { + registry.addInterceptor(authHandlerInterceptor) + .addPathPatterns("/api/products/**/reviews/**") + .addPathPatterns("/api/members"); + registry.addInterceptor(recipeHandlerInterceptor) + .addPathPatterns("/api/recipes"); + } + + @Override + public void addFormatters(final FormatterRegistry registry) { + registry.addConverter(new StringToCategoryTypeConverter()); + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(customPageableHandlerMethodArgumentResolver); + resolvers.add(authArgumentResolver); + } +} diff --git a/backend/src/main/java/com/funeat/exception/presentation/GlobalControllerAdvice.java b/backend/src/main/java/com/funeat/exception/presentation/GlobalControllerAdvice.java new file mode 100644 index 00000000..0ff01c73 --- /dev/null +++ b/backend/src/main/java/com/funeat/exception/presentation/GlobalControllerAdvice.java @@ -0,0 +1,25 @@ +package com.funeat.exception.presentation; + +import com.funeat.auth.exception.LoginException; +import javax.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalControllerAdvice { + + private final Logger log = LoggerFactory.getLogger(this.getClass()); + + @ExceptionHandler(LoginException.class) + public ResponseEntity loginExceptionHandler(final LoginException e, final HttpServletRequest request) { + + log.warn("URI: {}, 쿠키값: {}, 저장된 JSESSIONID 값: {}", request.getRequestURI(), request.getHeader("Cookie"), + request.getSession().getId()); + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage()); + } +} diff --git a/backend/src/main/java/com/funeat/member/application/MemberService.java b/backend/src/main/java/com/funeat/member/application/MemberService.java new file mode 100644 index 00000000..8d34a223 --- /dev/null +++ b/backend/src/main/java/com/funeat/member/application/MemberService.java @@ -0,0 +1,57 @@ +package com.funeat.member.application; + +import static org.springframework.transaction.annotation.Propagation.REQUIRES_NEW; + +import com.funeat.auth.dto.SignUserDto; +import com.funeat.auth.dto.UserInfoDto; +import com.funeat.member.domain.Member; +import com.funeat.member.dto.MemberProfileResponse; +import com.funeat.member.dto.MemberRequest; +import com.funeat.member.persistence.MemberRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +public class MemberService { + + private final MemberRepository memberRepository; + + public MemberService(final MemberRepository memberRepository) { + this.memberRepository = memberRepository; + } + + @Transactional(propagation = REQUIRES_NEW) + public SignUserDto findOrCreateMember(final UserInfoDto userInfoDto) { + final String platformId = userInfoDto.getId().toString(); + + return memberRepository.findByPlatformId(platformId) + .map(member -> SignUserDto.of(false, member)) + .orElseGet(() -> save(userInfoDto)); + } + + private SignUserDto save(final UserInfoDto userInfoDto) { + final Member member = userInfoDto.toMember(); + memberRepository.save(member); + + return SignUserDto.of(true, member); + } + + public MemberProfileResponse getMemberProfile(final Long memberId) { + final Member findMember = memberRepository.findById(memberId) + .orElseThrow(IllegalArgumentException::new); + + return MemberProfileResponse.toResponse(findMember); + } + + @Transactional + public void modify(final Long memberId, final MemberRequest request) { + final Member findMember = memberRepository.findById(memberId) + .orElseThrow(IllegalArgumentException::new); + + final String nickname = request.getNickname(); + final String profileImage = request.getProfileImage(); + + findMember.modifyProfile(nickname, profileImage); + } +} diff --git a/backend/src/main/java/com/funeat/member/domain/Member.java b/backend/src/main/java/com/funeat/member/domain/Member.java new file mode 100644 index 00000000..db0037cf --- /dev/null +++ b/backend/src/main/java/com/funeat/member/domain/Member.java @@ -0,0 +1,69 @@ +package com.funeat.member.domain; + +import com.funeat.member.domain.favorite.RecipeFavorite; +import com.funeat.member.domain.favorite.ReviewFavorite; +import java.util.ArrayList; +import java.util.List; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.OneToMany; + +@Entity +public class Member { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String nickname; + + private String profileImage; + + private String platformId; + + @OneToMany(mappedBy = "member") + private List reviewFavorites = new ArrayList<>(); + + @OneToMany(mappedBy = "member") + private List recipeFavorites; + + protected Member() { + } + + public Member(final String nickname, final String profileImage, final String platformId) { + this.nickname = nickname; + this.profileImage = profileImage; + this.platformId = platformId; + } + + public Long getId() { + return id; + } + + public String getNickname() { + return nickname; + } + + public String getProfileImage() { + return profileImage; + } + + public String getPlatformId() { + return platformId; + } + + public List getReviewFavorites() { + return reviewFavorites; + } + + public List getRecipeFavorites() { + return recipeFavorites; + } + + public void modifyProfile(final String nickname, final String profileImage) { + this.nickname = nickname; + this.profileImage = profileImage; + } +} diff --git a/backend/src/main/java/com/funeat/member/domain/bookmark/ProductBookmark.java b/backend/src/main/java/com/funeat/member/domain/bookmark/ProductBookmark.java new file mode 100644 index 00000000..c18c84b5 --- /dev/null +++ b/backend/src/main/java/com/funeat/member/domain/bookmark/ProductBookmark.java @@ -0,0 +1,28 @@ +package com.funeat.member.domain.bookmark; + +import com.funeat.member.domain.Member; +import com.funeat.product.domain.Product; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; + +@Entity +public class ProductBookmark { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne + @JoinColumn(name = "product_id") + private Product product; + + private Boolean checked; +} diff --git a/backend/src/main/java/com/funeat/member/domain/bookmark/RecipeBookmark.java b/backend/src/main/java/com/funeat/member/domain/bookmark/RecipeBookmark.java new file mode 100644 index 00000000..9dc0b75a --- /dev/null +++ b/backend/src/main/java/com/funeat/member/domain/bookmark/RecipeBookmark.java @@ -0,0 +1,28 @@ +package com.funeat.member.domain.bookmark; + +import com.funeat.member.domain.Member; +import com.funeat.recipe.domain.Recipe; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; + +@Entity +public class RecipeBookmark { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne + @JoinColumn(name = "recipe_id") + private Recipe recipe; + + private Boolean checked; +} diff --git a/backend/src/main/java/com/funeat/member/domain/favorite/RecipeFavorite.java b/backend/src/main/java/com/funeat/member/domain/favorite/RecipeFavorite.java new file mode 100644 index 00000000..46f186dd --- /dev/null +++ b/backend/src/main/java/com/funeat/member/domain/favorite/RecipeFavorite.java @@ -0,0 +1,53 @@ +package com.funeat.member.domain.favorite; + +import com.funeat.member.domain.Member; +import com.funeat.recipe.domain.Recipe; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; + +@Entity +public class RecipeFavorite { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne + @JoinColumn(name = "recipe_id") + private Recipe recipe; + + private Boolean favorite; + + protected RecipeFavorite() { + } + + public RecipeFavorite(final Member member, final Recipe recipe, final Boolean favorite) { + this.member = member; + this.recipe = recipe; + this.favorite = favorite; + } + + public Long getId() { + return id; + } + + public Member getMember() { + return member; + } + + public Recipe getRecipe() { + return recipe; + } + + public Boolean getFavorite() { + return favorite; + } +} diff --git a/backend/src/main/java/com/funeat/member/domain/favorite/ReviewFavorite.java b/backend/src/main/java/com/funeat/member/domain/favorite/ReviewFavorite.java new file mode 100644 index 00000000..2bf37ec1 --- /dev/null +++ b/backend/src/main/java/com/funeat/member/domain/favorite/ReviewFavorite.java @@ -0,0 +1,74 @@ +package com.funeat.member.domain.favorite; + +import com.funeat.member.domain.Member; +import com.funeat.review.domain.Review; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; + +@Entity +public class ReviewFavorite { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne + @JoinColumn(name = "review_id") + private Review review; + + private Boolean favorite; + + protected ReviewFavorite() { + } + + public ReviewFavorite(final Member member, final Review review) { + this.member = member; + this.review = review; + } + + public static ReviewFavorite createReviewFavoriteByMemberAndReview(final Member member, final Review review, + final Boolean favorite) { + final ReviewFavorite reviewFavorite = new ReviewFavorite(member, review); + reviewFavorite.review.getReviewFavorites().add(reviewFavorite); + reviewFavorite.member.getReviewFavorites().add(reviewFavorite); + reviewFavorite.favorite = favorite; + reviewFavorite.review.addFavoriteCount(); + return reviewFavorite; + } + + public void updateChecked(final Boolean favorite) { + if (!this.favorite && favorite) { + this.review.addFavoriteCount(); + this.favorite = favorite; + return; + } + if (this.favorite && !favorite) { + this.review.minusFavoriteCount(); + this.favorite = favorite; + } + } + + public Long getId() { + return id; + } + + public Member getMember() { + return member; + } + + public Review getReview() { + return review; + } + + public Boolean getFavorite() { + return favorite; + } +} diff --git a/backend/src/main/java/com/funeat/member/dto/MemberProfileResponse.java b/backend/src/main/java/com/funeat/member/dto/MemberProfileResponse.java new file mode 100644 index 00000000..84e915eb --- /dev/null +++ b/backend/src/main/java/com/funeat/member/dto/MemberProfileResponse.java @@ -0,0 +1,26 @@ +package com.funeat.member.dto; + +import com.funeat.member.domain.Member; + +public class MemberProfileResponse { + + private final String nickname; + private final String profileImage; + + public MemberProfileResponse(final String nickname, final String profileImage) { + this.nickname = nickname; + this.profileImage = profileImage; + } + + public static MemberProfileResponse toResponse(final Member member) { + return new MemberProfileResponse(member.getNickname(), member.getProfileImage()); + } + + public String getNickname() { + return nickname; + } + + public String getProfileImage() { + return profileImage; + } +} diff --git a/backend/src/main/java/com/funeat/member/dto/MemberRequest.java b/backend/src/main/java/com/funeat/member/dto/MemberRequest.java new file mode 100644 index 00000000..a4800c6e --- /dev/null +++ b/backend/src/main/java/com/funeat/member/dto/MemberRequest.java @@ -0,0 +1,20 @@ +package com.funeat.member.dto; + +public class MemberRequest { + + private final String nickname; + private final String profileImage; + + public MemberRequest(final String nickname, final String profileImage) { + this.nickname = nickname; + this.profileImage = profileImage; + } + + public String getNickname() { + return nickname; + } + + public String getProfileImage() { + return profileImage; + } +} diff --git a/backend/src/main/java/com/funeat/member/persistence/MemberRepository.java b/backend/src/main/java/com/funeat/member/persistence/MemberRepository.java new file mode 100644 index 00000000..832c48a3 --- /dev/null +++ b/backend/src/main/java/com/funeat/member/persistence/MemberRepository.java @@ -0,0 +1,10 @@ +package com.funeat.member.persistence; + +import com.funeat.member.domain.Member; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRepository extends JpaRepository { + + Optional findByPlatformId(final String platformId); +} diff --git a/backend/src/main/java/com/funeat/member/persistence/ProductBookmarkRepository.java b/backend/src/main/java/com/funeat/member/persistence/ProductBookmarkRepository.java new file mode 100644 index 00000000..c7651b59 --- /dev/null +++ b/backend/src/main/java/com/funeat/member/persistence/ProductBookmarkRepository.java @@ -0,0 +1,7 @@ +package com.funeat.member.persistence; + +import com.funeat.member.domain.bookmark.ProductBookmark; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductBookmarkRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/funeat/member/persistence/RecipeBookMarkRepository.java b/backend/src/main/java/com/funeat/member/persistence/RecipeBookMarkRepository.java new file mode 100644 index 00000000..4ed5cce4 --- /dev/null +++ b/backend/src/main/java/com/funeat/member/persistence/RecipeBookMarkRepository.java @@ -0,0 +1,7 @@ +package com.funeat.member.persistence; + +import com.funeat.member.domain.bookmark.RecipeBookmark; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RecipeBookMarkRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/funeat/member/persistence/RecipeFavoriteRepository.java b/backend/src/main/java/com/funeat/member/persistence/RecipeFavoriteRepository.java new file mode 100644 index 00000000..8bc177c8 --- /dev/null +++ b/backend/src/main/java/com/funeat/member/persistence/RecipeFavoriteRepository.java @@ -0,0 +1,11 @@ +package com.funeat.member.persistence; + +import com.funeat.member.domain.Member; +import com.funeat.member.domain.favorite.RecipeFavorite; +import com.funeat.recipe.domain.Recipe; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RecipeFavoriteRepository extends JpaRepository { + + boolean existsByMemberAndRecipeAndFavoriteTrue(final Member member, final Recipe recipe); +} diff --git a/backend/src/main/java/com/funeat/member/persistence/ReviewFavoriteRepository.java b/backend/src/main/java/com/funeat/member/persistence/ReviewFavoriteRepository.java new file mode 100644 index 00000000..2e96e623 --- /dev/null +++ b/backend/src/main/java/com/funeat/member/persistence/ReviewFavoriteRepository.java @@ -0,0 +1,12 @@ +package com.funeat.member.persistence; + +import com.funeat.member.domain.Member; +import com.funeat.member.domain.favorite.ReviewFavorite; +import com.funeat.review.domain.Review; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewFavoriteRepository extends JpaRepository { + + Optional findByMemberAndReview(final Member member, final Review review); +} diff --git a/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java b/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java new file mode 100644 index 00000000..546c5665 --- /dev/null +++ b/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java @@ -0,0 +1,42 @@ +package com.funeat.member.presentation; + +import com.funeat.auth.dto.LoginInfo; +import com.funeat.auth.util.AuthenticationPrincipal; +import com.funeat.member.application.MemberService; +import com.funeat.member.dto.MemberProfileResponse; +import com.funeat.member.dto.MemberRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class MemberApiController implements MemberController { + + private final MemberService memberService; + + public MemberApiController(final MemberService memberService) { + this.memberService = memberService; + } + + @GetMapping("/api/members") + public ResponseEntity getMemberProfile( + @AuthenticationPrincipal final LoginInfo loginInfo) { + final Long memberId = loginInfo.getId(); + + final MemberProfileResponse response = memberService.getMemberProfile(memberId); + + return ResponseEntity.ok(response); + } + + @PutMapping("/api/members") + public ResponseEntity putMemberProfile(@AuthenticationPrincipal final LoginInfo loginInfo, + @RequestBody final MemberRequest request) { + final Long memberId = loginInfo.getId(); + + memberService.modify(memberId, request); + + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/funeat/member/presentation/MemberController.java b/backend/src/main/java/com/funeat/member/presentation/MemberController.java new file mode 100644 index 00000000..dee16f3b --- /dev/null +++ b/backend/src/main/java/com/funeat/member/presentation/MemberController.java @@ -0,0 +1,34 @@ +package com.funeat.member.presentation; + +import com.funeat.auth.dto.LoginInfo; +import com.funeat.auth.util.AuthenticationPrincipal; +import com.funeat.member.dto.MemberProfileResponse; +import com.funeat.member.dto.MemberRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "05.Member", description = "사용자 기능") +public interface MemberController { + + @Operation(summary = "사용자 정보 조회", description = "사용자 닉네임과 프로필 사진을 조회한다.") + @ApiResponse( + responseCode = "200", + description = "사용자 정보 조회 성공." + ) + @GetMapping + ResponseEntity getMemberProfile(@AuthenticationPrincipal LoginInfo loginInfo); + + @Operation(summary = "사용자 정보 수정", description = "사용자 닉네임과 프로필 사진을 수정한다.") + @ApiResponse( + responseCode = "200", + description = "사용자 정보 수정 성공." + ) + @PutMapping + ResponseEntity putMemberProfile(@AuthenticationPrincipal LoginInfo loginInfo, + @RequestBody MemberRequest request); +} diff --git a/backend/src/main/java/com/funeat/product/application/CategoryService.java b/backend/src/main/java/com/funeat/product/application/CategoryService.java new file mode 100644 index 00000000..7b99b6f3 --- /dev/null +++ b/backend/src/main/java/com/funeat/product/application/CategoryService.java @@ -0,0 +1,23 @@ +package com.funeat.product.application; + +import com.funeat.product.domain.Category; +import com.funeat.product.domain.CategoryType; +import com.funeat.product.persistence.CategoryRepository; +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +public class CategoryService { + + private final CategoryRepository categoryRepository; + + public CategoryService(final CategoryRepository categoryRepository) { + this.categoryRepository = categoryRepository; + } + + public List findAllCategoriesByType(final CategoryType type) { + return categoryRepository.findAllByType(type); + } +} diff --git a/backend/src/main/java/com/funeat/product/application/ProductService.java b/backend/src/main/java/com/funeat/product/application/ProductService.java new file mode 100644 index 00000000..f4c864df --- /dev/null +++ b/backend/src/main/java/com/funeat/product/application/ProductService.java @@ -0,0 +1,88 @@ +package com.funeat.product.application; + +import com.funeat.product.domain.Category; +import com.funeat.product.domain.Product; +import com.funeat.product.dto.ProductInCategoryDto; +import com.funeat.product.dto.ProductResponse; +import com.funeat.product.dto.ProductReviewCountDto; +import com.funeat.product.dto.ProductsInCategoryPageDto; +import com.funeat.product.dto.ProductsInCategoryResponse; +import com.funeat.product.dto.RankingProductDto; +import com.funeat.product.dto.RankingProductsResponse; +import com.funeat.product.persistence.CategoryRepository; +import com.funeat.product.persistence.ProductRepository; +import com.funeat.review.persistence.ReviewTagRepository; +import com.funeat.tag.domain.Tag; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +public class ProductService { + + private static final int THREE = 3; + private static final int TOP = 0; + public static final String REVIEW_COUNT = "reviewCount"; + + private final CategoryRepository categoryRepository; + private final ProductRepository productRepository; + private final ReviewTagRepository reviewTagRepository; + + public ProductService(final CategoryRepository categoryRepository, final ProductRepository productRepository, + final ReviewTagRepository reviewTagRepository) { + this.categoryRepository = categoryRepository; + this.productRepository = productRepository; + this.reviewTagRepository = reviewTagRepository; + } + + public ProductsInCategoryResponse getAllProductsInCategory(final Long categoryId, + final Pageable pageable) { + final Category category = categoryRepository.findById(categoryId) + .orElseThrow(IllegalArgumentException::new); + + final Page pages = getAllProductsInCategory(pageable, category); + + final ProductsInCategoryPageDto pageDto = ProductsInCategoryPageDto.toDto(pages); + final List productDtos = pages.getContent(); + + return ProductsInCategoryResponse.toResponse(pageDto, productDtos); + } + + private Page getAllProductsInCategory(final Pageable pageable, final Category category) { + if (pageable.getSort().getOrderFor(REVIEW_COUNT) != null) { + final PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()); + return productRepository.findAllByCategoryOrderByReviewCountDesc(category, pageRequest); + } + return productRepository.findAllByCategory(category, pageable); + } + + public ProductResponse findProductDetail(final Long productId) { + final Product product = productRepository.findById(productId) + .orElseThrow(IllegalArgumentException::new); + + final List tags = reviewTagRepository.findTop3TagsByReviewIn(productId, PageRequest.of(TOP, THREE)); + + return ProductResponse.toResponse(product, tags); + } + + public RankingProductsResponse getTop3Products() { + final List productsAndReviewCounts = productRepository.findAllByAverageRatingGreaterThan3(); + final Comparator rankingScoreComparator = Comparator.comparing( + (ProductReviewCountDto it) -> it.getProduct().calculateRankingScore(it.getReviewCount()) + ).reversed(); + + final List rankingProductDtos = productsAndReviewCounts.stream() + .sorted(rankingScoreComparator) + .limit(3) + .map(it -> RankingProductDto.toDto(it.getProduct())) + .collect(Collectors.toList()); + + return RankingProductsResponse.toResponse(rankingProductDtos); + } +} diff --git a/backend/src/main/java/com/funeat/product/domain/Category.java b/backend/src/main/java/com/funeat/product/domain/Category.java new file mode 100644 index 00000000..03d1d4f5 --- /dev/null +++ b/backend/src/main/java/com/funeat/product/domain/Category.java @@ -0,0 +1,41 @@ +package com.funeat.product.domain; + +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; + +@Entity +public class Category { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + @Enumerated(EnumType.STRING) + private CategoryType type; + + protected Category() { + } + + public Category(final String name, final CategoryType type) { + this.name = name; + this.type = type; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public CategoryType getType() { + return type; + } +} diff --git a/backend/src/main/java/com/funeat/product/domain/CategoryType.java b/backend/src/main/java/com/funeat/product/domain/CategoryType.java new file mode 100644 index 00000000..ee55c80b --- /dev/null +++ b/backend/src/main/java/com/funeat/product/domain/CategoryType.java @@ -0,0 +1,5 @@ +package com.funeat.product.domain; + +public enum CategoryType { + FOOD, STORE +} diff --git a/backend/src/main/java/com/funeat/product/domain/Product.java b/backend/src/main/java/com/funeat/product/domain/Product.java new file mode 100644 index 00000000..33057622 --- /dev/null +++ b/backend/src/main/java/com/funeat/product/domain/Product.java @@ -0,0 +1,104 @@ +package com.funeat.product.domain; + +import com.funeat.member.domain.bookmark.ProductBookmark; +import com.funeat.review.domain.Review; +import java.util.List; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.OneToMany; + +@Entity +public class Product { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + private Long price; + + private String image; + + private String content; + + private Double averageRating = 0.0; + + @ManyToOne + @JoinColumn(name = "category_id") + private Category category; + + @OneToMany(mappedBy = "product") + private List reviews; + + @OneToMany(mappedBy = "product") + private List productRecipes; + + @OneToMany(mappedBy = "product") + private List productBookmarks; + + protected Product() { + } + + public Product(final String name, final Long price, final String image, final String content, + final Category category) { + this.name = name; + this.price = price; + this.image = image; + this.content = content; + this.category = category; + } + + public Product(final String name, final Long price, final String image, final String content, + final Double averageRating, final Category category) { + this.name = name; + this.price = price; + this.image = image; + this.content = content; + this.averageRating = averageRating; + this.category = category; + } + + public void updateAverageRating(final Long rating, final Long count) { + final double calculatedRating = ((count - 1) * averageRating + rating) / count; + this.averageRating = Math.round(calculatedRating * 10.0) / 10.0; + } + + public Double calculateRankingScore(final Long reviewCount) { + final double exponent = -Math.log10(reviewCount + 1); + final double factor = Math.pow(2, exponent); + return averageRating - (averageRating - 3.0) * factor; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Long getPrice() { + return price; + } + + public String getImage() { + return image; + } + + public String getContent() { + return content; + } + + public Double getAverageRating() { + return averageRating; + } + + public Category getCategory() { + return category; + } +} diff --git a/backend/src/main/java/com/funeat/product/domain/ProductRecipe.java b/backend/src/main/java/com/funeat/product/domain/ProductRecipe.java new file mode 100644 index 00000000..dc68ee01 --- /dev/null +++ b/backend/src/main/java/com/funeat/product/domain/ProductRecipe.java @@ -0,0 +1,33 @@ +package com.funeat.product.domain; + +import com.funeat.recipe.domain.Recipe; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; + +@Entity +public class ProductRecipe { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "product_id") + private Product product; + + @ManyToOne + @JoinColumn(name = "recipe_id") + private Recipe recipe; + + protected ProductRecipe() { + } + + public ProductRecipe(final Product product, final Recipe recipe) { + this.product = product; + this.recipe = recipe; + } +} diff --git a/backend/src/main/java/com/funeat/product/dto/CategoryResponse.java b/backend/src/main/java/com/funeat/product/dto/CategoryResponse.java new file mode 100644 index 00000000..ebf70e60 --- /dev/null +++ b/backend/src/main/java/com/funeat/product/dto/CategoryResponse.java @@ -0,0 +1,26 @@ +package com.funeat.product.dto; + +import com.funeat.product.domain.Category; + +public class CategoryResponse { + + private final Long id; + private final String name; + + public CategoryResponse(final Long id, final String name) { + this.id = id; + this.name = name; + } + + public static CategoryResponse toResponse(final Category category) { + return new CategoryResponse(category.getId(), category.getName()); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } +} diff --git a/backend/src/main/java/com/funeat/product/dto/ProductInCategoryDto.java b/backend/src/main/java/com/funeat/product/dto/ProductInCategoryDto.java new file mode 100644 index 00000000..7ab4bf46 --- /dev/null +++ b/backend/src/main/java/com/funeat/product/dto/ProductInCategoryDto.java @@ -0,0 +1,52 @@ +package com.funeat.product.dto; + +import com.funeat.product.domain.Product; + +public class ProductInCategoryDto { + + private final Long id; + private final String name; + private final Long price; + private final String image; + private final Double averageRating; + private final Long reviewCount; + + public ProductInCategoryDto(final Long id, final String name, final Long price, final String image, + final Double averageRating, final Long reviewCount) { + this.id = id; + this.name = name; + this.price = price; + this.image = image; + this.averageRating = averageRating; + this.reviewCount = reviewCount; + } + + public static ProductInCategoryDto toDto(final Product product, final Long reviewCount) { + return new ProductInCategoryDto(product.getId(), product.getName(), product.getPrice(), product.getImage(), + product.getAverageRating(), reviewCount); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Long getPrice() { + return price; + } + + public String getImage() { + return image; + } + + public Double getAverageRating() { + return averageRating; + } + + public Long getReviewCount() { + return reviewCount; + } +} diff --git a/backend/src/main/java/com/funeat/product/dto/ProductResponse.java b/backend/src/main/java/com/funeat/product/dto/ProductResponse.java new file mode 100644 index 00000000..a0400ef0 --- /dev/null +++ b/backend/src/main/java/com/funeat/product/dto/ProductResponse.java @@ -0,0 +1,66 @@ +package com.funeat.product.dto; + +import com.funeat.product.domain.Product; +import com.funeat.tag.domain.Tag; +import com.funeat.tag.dto.TagDto; +import java.util.ArrayList; +import java.util.List; + +public class ProductResponse { + + private final Long id; + private final String name; + private final Long price; + private final String image; + private final String content; + private final Double averageRating; + private final List tags; + + public ProductResponse(final Long id, final String name, final Long price, final String image, + final String content, final Double averageRating, final List tags) { + this.id = id; + this.name = name; + this.price = price; + this.image = image; + this.content = content; + this.averageRating = averageRating; + this.tags = tags; + } + + public static ProductResponse toResponse(final Product product, final List tags) { + List tagDtos = new ArrayList<>(); + for (Tag tag : tags) { + tagDtos.add(TagDto.toDto(tag)); + } + return new ProductResponse(product.getId(), product.getName(), product.getPrice(), product.getImage(), + product.getContent(), product.getAverageRating(), tagDtos); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Long getPrice() { + return price; + } + + public String getImage() { + return image; + } + + public String getContent() { + return content; + } + + public Double getAverageRating() { + return averageRating; + } + + public List getTags() { + return tags; + } +} diff --git a/backend/src/main/java/com/funeat/product/dto/ProductReviewCountDto.java b/backend/src/main/java/com/funeat/product/dto/ProductReviewCountDto.java new file mode 100644 index 00000000..f769b708 --- /dev/null +++ b/backend/src/main/java/com/funeat/product/dto/ProductReviewCountDto.java @@ -0,0 +1,22 @@ +package com.funeat.product.dto; + +import com.funeat.product.domain.Product; + +public class ProductReviewCountDto { + + private final Product product; + private final Long reviewCount; + + public ProductReviewCountDto(final Product product, final Long reviewCount) { + this.product = product; + this.reviewCount = reviewCount; + } + + public Product getProduct() { + return product; + } + + public Long getReviewCount() { + return reviewCount; + } +} diff --git a/backend/src/main/java/com/funeat/product/dto/ProductsInCategoryPageDto.java b/backend/src/main/java/com/funeat/product/dto/ProductsInCategoryPageDto.java new file mode 100644 index 00000000..4bb05d15 --- /dev/null +++ b/backend/src/main/java/com/funeat/product/dto/ProductsInCategoryPageDto.java @@ -0,0 +1,58 @@ +package com.funeat.product.dto; + +import com.funeat.product.domain.Product; +import org.springframework.data.domain.Page; + +public class ProductsInCategoryPageDto { + private final Long totalDataCount; + private final Long totalPages; + private final boolean firstPage; + private final boolean lastPage; + private final Long requestPage; + private final Long requestSize; + + public ProductsInCategoryPageDto(final Long totalDataCount, final Long totalPages, final boolean FirstPage, + final boolean LastPage, final Long requestPage, final Long requestSize) { + this.totalDataCount = totalDataCount; + this.totalPages = totalPages; + this.firstPage = FirstPage; + this.lastPage = LastPage; + this.requestPage = requestPage; + this.requestSize = requestSize; + } + + public static ProductsInCategoryPageDto toDto(final Page page) { + return new ProductsInCategoryPageDto( + page.getTotalElements(), + Long.valueOf(page.getTotalPages()), + page.isFirst(), + page.isLast(), + Long.valueOf(page.getNumber()), + Long.valueOf(page.getSize()) + ); + } + + public Long getTotalDataCount() { + return totalDataCount; + } + + public Long getTotalPages() { + return totalPages; + } + + public boolean isFirstPage() { + return firstPage; + } + + public boolean isLastPage() { + return lastPage; + } + + public Long getRequestPage() { + return requestPage; + } + + public Long getRequestSize() { + return requestSize; + } +} diff --git a/backend/src/main/java/com/funeat/product/dto/ProductsInCategoryResponse.java b/backend/src/main/java/com/funeat/product/dto/ProductsInCategoryResponse.java new file mode 100644 index 00000000..8123b4ab --- /dev/null +++ b/backend/src/main/java/com/funeat/product/dto/ProductsInCategoryResponse.java @@ -0,0 +1,26 @@ +package com.funeat.product.dto; + +import java.util.List; + +public class ProductsInCategoryResponse { + + private final ProductsInCategoryPageDto page; + private final List products; + + public ProductsInCategoryResponse(final ProductsInCategoryPageDto page, final List products) { + this.page = page; + this.products = products; + } + + public static ProductsInCategoryResponse toResponse(final ProductsInCategoryPageDto page, final List products) { + return new ProductsInCategoryResponse(page, products); + } + + public ProductsInCategoryPageDto getPage() { + return page; + } + + public List getProducts() { + return products; + } +} diff --git a/backend/src/main/java/com/funeat/product/dto/RankingProductDto.java b/backend/src/main/java/com/funeat/product/dto/RankingProductDto.java new file mode 100644 index 00000000..060a8dcd --- /dev/null +++ b/backend/src/main/java/com/funeat/product/dto/RankingProductDto.java @@ -0,0 +1,32 @@ +package com.funeat.product.dto; + +import com.funeat.product.domain.Product; + +public class RankingProductDto { + + private final Long id; + private final String name; + private final String image; + + public RankingProductDto(final Long id, final String name, final String image) { + this.id = id; + this.name = name; + this.image = image; + } + + public static RankingProductDto toDto(final Product product) { + return new RankingProductDto(product.getId(), product.getName(), product.getImage()); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getImage() { + return image; + } +} diff --git a/backend/src/main/java/com/funeat/product/dto/RankingProductsResponse.java b/backend/src/main/java/com/funeat/product/dto/RankingProductsResponse.java new file mode 100644 index 00000000..14bfcf15 --- /dev/null +++ b/backend/src/main/java/com/funeat/product/dto/RankingProductsResponse.java @@ -0,0 +1,20 @@ +package com.funeat.product.dto; + +import java.util.List; + +public class RankingProductsResponse { + + private final List products; + + public RankingProductsResponse(final List products) { + this.products = products; + } + + public static RankingProductsResponse toResponse(final List products) { + return new RankingProductsResponse(products); + } + + public List getProducts() { + return products; + } +} diff --git a/backend/src/main/java/com/funeat/product/persistence/CategoryRepository.java b/backend/src/main/java/com/funeat/product/persistence/CategoryRepository.java new file mode 100644 index 00000000..4da06451 --- /dev/null +++ b/backend/src/main/java/com/funeat/product/persistence/CategoryRepository.java @@ -0,0 +1,11 @@ +package com.funeat.product.persistence; + +import com.funeat.product.domain.Category; +import com.funeat.product.domain.CategoryType; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CategoryRepository extends JpaRepository { + + List findAllByType(final CategoryType type); +} diff --git a/backend/src/main/java/com/funeat/product/persistence/ProductRecipeRepository.java b/backend/src/main/java/com/funeat/product/persistence/ProductRecipeRepository.java new file mode 100644 index 00000000..a5c81957 --- /dev/null +++ b/backend/src/main/java/com/funeat/product/persistence/ProductRecipeRepository.java @@ -0,0 +1,14 @@ +package com.funeat.product.persistence; + +import com.funeat.product.domain.Product; +import com.funeat.product.domain.ProductRecipe; +import com.funeat.recipe.domain.Recipe; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface ProductRecipeRepository extends JpaRepository { + + @Query("SELECT pr.product FROM ProductRecipe pr WHERE pr.recipe = :recipe") + List findProductByRecipe(final Recipe recipe); +} diff --git a/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java b/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java new file mode 100644 index 00000000..c6116610 --- /dev/null +++ b/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java @@ -0,0 +1,39 @@ +package com.funeat.product.persistence; + +import com.funeat.product.domain.Category; +import com.funeat.product.domain.Product; +import com.funeat.product.dto.ProductInCategoryDto; +import com.funeat.product.dto.ProductReviewCountDto; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ProductRepository extends JpaRepository { + + @Query(value = "SELECT new com.funeat.product.dto.ProductInCategoryDto(p.id, p.name, p.price, p.image, p.averageRating, COUNT(r)) " + + "FROM Product p " + + "LEFT JOIN p.reviews r " + + "WHERE p.category = :category " + + "GROUP BY p ", + countQuery = "SELECT COUNT(p) FROM Product p WHERE p.category = :category") + Page findAllByCategory(@Param("category") final Category category, final Pageable pageable); + + @Query(value = "SELECT new com.funeat.product.dto.ProductInCategoryDto(p.id, p.name, p.price, p.image, p.averageRating, COUNT(r)) " + + "FROM Product p " + + "LEFT JOIN p.reviews r " + + "WHERE p.category = :category " + + "GROUP BY p " + + "ORDER BY COUNT(r) DESC, p.id DESC ", + countQuery = "SELECT COUNT(p) FROM Product p WHERE p.category = :category") + Page findAllByCategoryOrderByReviewCountDesc(@Param("category") final Category category, final Pageable pageable); + + @Query("SELECT new com.funeat.product.dto.ProductReviewCountDto(p, COUNT(r.id)) " + + "FROM Product p " + + "LEFT JOIN Review r ON r.product.id = p.id " + + "WHERE p.averageRating > 3.0 " + + "GROUP BY p.id") + List findAllByAverageRatingGreaterThan3(); +} diff --git a/backend/src/main/java/com/funeat/product/presentation/CategoryApiController.java b/backend/src/main/java/com/funeat/product/presentation/CategoryApiController.java new file mode 100644 index 00000000..f084fc17 --- /dev/null +++ b/backend/src/main/java/com/funeat/product/presentation/CategoryApiController.java @@ -0,0 +1,31 @@ +package com.funeat.product.presentation; + +import com.funeat.product.application.CategoryService; +import com.funeat.product.domain.CategoryType; +import com.funeat.product.dto.CategoryResponse; +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/categories") +public class CategoryApiController implements CategoryController { + + private final CategoryService categoryService; + + public CategoryApiController(final CategoryService categoryService) { + this.categoryService = categoryService; + } + + @GetMapping + public ResponseEntity> getAllCategoriesByType(@RequestParam final CategoryType type) { + final List responses = categoryService.findAllCategoriesByType(type).stream() + .map(CategoryResponse::toResponse) + .collect(Collectors.toList()); + return ResponseEntity.ok(responses); + } +} diff --git a/backend/src/main/java/com/funeat/product/presentation/CategoryController.java b/backend/src/main/java/com/funeat/product/presentation/CategoryController.java new file mode 100644 index 00000000..53625ffb --- /dev/null +++ b/backend/src/main/java/com/funeat/product/presentation/CategoryController.java @@ -0,0 +1,23 @@ +package com.funeat.product.presentation; + +import com.funeat.product.domain.CategoryType; +import com.funeat.product.dto.CategoryResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "02.Category", description = "카테고리 기능") +public interface CategoryController { + + @Operation(summary = "해당 type 카테고리 전체 조회", description = "FOOD 또는 STORE 를 받아 해당 카테고리를 전체 조회한다.") + @ApiResponse( + responseCode = "200", + description = "해당 type 카테고리 전체 조회 성공." + ) + @GetMapping + ResponseEntity> getAllCategoriesByType(@RequestParam final CategoryType type); +} diff --git a/backend/src/main/java/com/funeat/product/presentation/ProductApiController.java b/backend/src/main/java/com/funeat/product/presentation/ProductApiController.java new file mode 100644 index 00000000..eaa73e3f --- /dev/null +++ b/backend/src/main/java/com/funeat/product/presentation/ProductApiController.java @@ -0,0 +1,45 @@ +package com.funeat.product.presentation; + +import com.funeat.product.application.ProductService; +import com.funeat.product.dto.ProductResponse; +import com.funeat.product.dto.ProductsInCategoryResponse; +import com.funeat.product.dto.RankingProductsResponse; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api") +public class ProductApiController implements ProductController { + + private final ProductService productService; + + public ProductApiController(final ProductService productService) { + this.productService = productService; + } + + @GetMapping("/categories/{categoryId}/products") + public ResponseEntity getAllProductsInCategory( + @PathVariable final Long categoryId, + @PageableDefault Pageable pageable + ) { + final ProductsInCategoryResponse response = productService.getAllProductsInCategory(categoryId, pageable); + return ResponseEntity.ok(response); + } + + @GetMapping("/products/{productId}") + public ResponseEntity getProductDetail(@PathVariable final Long productId) { + final ProductResponse response = productService.findProductDetail(productId); + return ResponseEntity.ok(response); + } + + @GetMapping("/ranks/products") + public ResponseEntity getRankingProducts() { + final RankingProductsResponse response = productService.getTop3Products(); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/com/funeat/product/presentation/ProductController.java b/backend/src/main/java/com/funeat/product/presentation/ProductController.java new file mode 100644 index 00000000..891a9ceb --- /dev/null +++ b/backend/src/main/java/com/funeat/product/presentation/ProductController.java @@ -0,0 +1,43 @@ +package com.funeat.product.presentation; + +import com.funeat.product.dto.ProductResponse; +import com.funeat.product.dto.ProductsInCategoryResponse; +import com.funeat.product.dto.RankingProductsResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +@Tag(name = "01.Product", description = "상품 기능") +public interface ProductController { + + @Operation(summary = "해당 카테고리 상품 조회", description = "해당 카테고리의 상품을 조회한다.") + @ApiResponse( + responseCode = "200", + description = "해당 카테고리 상품 조회 성공." + ) + @GetMapping + ResponseEntity getAllProductsInCategory( + @PathVariable(name = "category_id") final Long categoryId, @PageableDefault Pageable pageable + ); + + @Operation(summary = "해당 상품 상세 조회", description = "해당 상품 상세정보를 조회한다.") + @ApiResponse( + responseCode = "200", + description = "해당 상품 상세 조회 성공." + ) + @GetMapping + ResponseEntity getProductDetail(@PathVariable final Long productId); + + @Operation(summary = "전체 상품 랭킹 조회", description = "전체 상품들 중에서 랭킹 TOP3를 조회한다.") + @ApiResponse( + responseCode = "200", + description = "전체 상품 랭킹 조회 성공." + ) + @GetMapping + ResponseEntity getRankingProducts(); +} diff --git a/backend/src/main/java/com/funeat/recipe/application/RecipeService.java b/backend/src/main/java/com/funeat/recipe/application/RecipeService.java new file mode 100644 index 00000000..2a2f3ba4 --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/application/RecipeService.java @@ -0,0 +1,89 @@ +package com.funeat.recipe.application; + +import com.funeat.common.ImageService; +import com.funeat.member.domain.Member; +import com.funeat.member.persistence.MemberRepository; +import com.funeat.member.persistence.RecipeFavoriteRepository; +import com.funeat.product.domain.Product; +import com.funeat.product.domain.ProductRecipe; +import com.funeat.product.persistence.ProductRecipeRepository; +import com.funeat.product.persistence.ProductRepository; +import com.funeat.recipe.domain.Recipe; +import com.funeat.recipe.domain.RecipeImage; +import com.funeat.recipe.dto.RecipeCreateRequest; +import com.funeat.recipe.dto.RecipeDetailResponse; +import com.funeat.recipe.persistence.RecipeImageRepository; +import com.funeat.recipe.persistence.RecipeRepository; +import java.util.List; +import java.util.Objects; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@Transactional(readOnly = true) +public class RecipeService { + + private final MemberRepository memberRepository; + private final ProductRepository productRepository; + private final ProductRecipeRepository productRecipeRepository; + private final RecipeRepository recipeRepository; + private final RecipeImageRepository recipeImageRepository; + private final RecipeFavoriteRepository recipeFavoriteRepository; + private final ImageService imageService; + + public RecipeService(final MemberRepository memberRepository, final ProductRepository productRepository, + final ProductRecipeRepository productRecipeRepository, final RecipeRepository recipeRepository, + final RecipeImageRepository recipeImageRepository, + final RecipeFavoriteRepository recipeFavoriteRepository, + final ImageService imageService) { + this.memberRepository = memberRepository; + this.productRepository = productRepository; + this.productRecipeRepository = productRecipeRepository; + this.recipeRepository = recipeRepository; + this.recipeImageRepository = recipeImageRepository; + this.recipeFavoriteRepository = recipeFavoriteRepository; + this.imageService = imageService; + } + + @Transactional + public Long create(final Long memberId, final List images, final RecipeCreateRequest request) { + final Member member = memberRepository.findById(memberId) + .orElseThrow(IllegalArgumentException::new); + + final Recipe savedRecipe = recipeRepository.save(new Recipe(request.getTitle(), request.getContent(), member)); + request.getProductIds() + .stream() + .map(it -> productRepository.findById(it) + .orElseThrow(IllegalArgumentException::new)) + .forEach(it -> productRecipeRepository.save(new ProductRecipe(it, savedRecipe))); + + if (Objects.nonNull(images)) { + images.stream() + .peek(it -> recipeImageRepository.save(new RecipeImage(it.getOriginalFilename(), savedRecipe))) + .forEach(imageService::upload); + } + + return savedRecipe.getId(); + } + + public RecipeDetailResponse getRecipeDetail(final Long memberId, final Long recipeId) { + final Recipe recipe = recipeRepository.findById(recipeId) + .orElseThrow(IllegalArgumentException::new); + final List images = recipeImageRepository.findByRecipe(recipe); + final List products = productRecipeRepository.findProductByRecipe(recipe); + final Long totalPrice = products.stream() + .mapToLong(Product::getPrice) + .sum(); + + final Boolean favorite = calculateFavoriteChecked(memberId, recipe); + + return RecipeDetailResponse.toResponse(recipe, images, products, totalPrice, favorite); + } + + private Boolean calculateFavoriteChecked(final Long memberId, final Recipe recipe) { + final Member member = memberRepository.findById(memberId) + .orElseThrow(IllegalArgumentException::new); + return recipeFavoriteRepository.existsByMemberAndRecipeAndFavoriteTrue(member, recipe); + } +} diff --git a/backend/src/main/java/com/funeat/recipe/domain/Recipe.java b/backend/src/main/java/com/funeat/recipe/domain/Recipe.java new file mode 100644 index 00000000..8136a498 --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/domain/Recipe.java @@ -0,0 +1,56 @@ +package com.funeat.recipe.domain; + +import com.funeat.member.domain.Member; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; + +@Entity +public class Recipe { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + private String content; + + @ManyToOne + @JoinColumn(name = "member_id") + private Member member; + + private Long favoriteCount = 0L; + + protected Recipe() { + } + + public Recipe(final String title, final String content, final Member member) { + this.title = title; + this.content = content; + this.member = member; + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public String getContent() { + return content; + } + + public Member getMember() { + return member; + } + + public Long getFavoriteCount() { + return favoriteCount; + } +} diff --git a/backend/src/main/java/com/funeat/recipe/domain/RecipeImage.java b/backend/src/main/java/com/funeat/recipe/domain/RecipeImage.java new file mode 100644 index 00000000..1f0d119e --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/domain/RecipeImage.java @@ -0,0 +1,42 @@ +package com.funeat.recipe.domain; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; + +@Entity +public class RecipeImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String image; + + @ManyToOne + @JoinColumn(name = "recipe_id") + private Recipe recipe; + + protected RecipeImage() { + } + + public RecipeImage(final String image, final Recipe recipe) { + this.image = image; + this.recipe = recipe; + } + + public Long getId() { + return id; + } + + public String getImage() { + return image; + } + + public Recipe getRecipe() { + return recipe; + } +} diff --git a/backend/src/main/java/com/funeat/recipe/dto/ProductRecipeDto.java b/backend/src/main/java/com/funeat/recipe/dto/ProductRecipeDto.java new file mode 100644 index 00000000..60ab88ea --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/dto/ProductRecipeDto.java @@ -0,0 +1,32 @@ +package com.funeat.recipe.dto; + +import com.funeat.product.domain.Product; + +public class ProductRecipeDto { + + private final Long id; + private final String name; + private final Long price; + + private ProductRecipeDto(final Long id, final String name, final Long price) { + this.id = id; + this.name = name; + this.price = price; + } + + public static ProductRecipeDto toDto(final Product product) { + return new ProductRecipeDto(product.getId(), product.getName(), product.getPrice()); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Long getPrice() { + return price; + } +} diff --git a/backend/src/main/java/com/funeat/recipe/dto/RecipeAuthorDto.java b/backend/src/main/java/com/funeat/recipe/dto/RecipeAuthorDto.java new file mode 100644 index 00000000..c32a919c --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/dto/RecipeAuthorDto.java @@ -0,0 +1,26 @@ +package com.funeat.recipe.dto; + +import com.funeat.member.domain.Member; + +public class RecipeAuthorDto { + + private final String profileImage; + private final String nickName; + + private RecipeAuthorDto(final String profileImage, final String nickName) { + this.profileImage = profileImage; + this.nickName = nickName; + } + + public static RecipeAuthorDto toDto(final Member member) { + return new RecipeAuthorDto(member.getProfileImage(), member.getNickname()); + } + + public String getProfileImage() { + return profileImage; + } + + public String getNickName() { + return nickName; + } +} diff --git a/backend/src/main/java/com/funeat/recipe/dto/RecipeCreateRequest.java b/backend/src/main/java/com/funeat/recipe/dto/RecipeCreateRequest.java new file mode 100644 index 00000000..fd24536d --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/dto/RecipeCreateRequest.java @@ -0,0 +1,28 @@ +package com.funeat.recipe.dto; + +import java.util.List; + +public class RecipeCreateRequest { + + private final String title; + private final List productIds; + private final String content; + + public RecipeCreateRequest(final String title, final List productIds, final String content) { + this.title = title; + this.productIds = productIds; + this.content = content; + } + + public String getTitle() { + return title; + } + + public List getProductIds() { + return productIds; + } + + public String getContent() { + return content; + } +} diff --git a/backend/src/main/java/com/funeat/recipe/dto/RecipeDetailResponse.java b/backend/src/main/java/com/funeat/recipe/dto/RecipeDetailResponse.java new file mode 100644 index 00000000..9af6d479 --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/dto/RecipeDetailResponse.java @@ -0,0 +1,84 @@ +package com.funeat.recipe.dto; + +import com.funeat.product.domain.Product; +import com.funeat.recipe.domain.Recipe; +import com.funeat.recipe.domain.RecipeImage; +import java.util.List; +import java.util.stream.Collectors; + +public class RecipeDetailResponse { + + private final Long id; + private final List images; + private final String title; + private final String content; + private final RecipeAuthorDto author; + private final List products; + private final Long totalPrice; + private final Long favoriteCount; + private final Boolean favorite; + + private RecipeDetailResponse(final Long id, final List images, final String title, final String content, + final RecipeAuthorDto author, + final List products, final Long totalPrice, final Long favoriteCount, + final Boolean favorite) { + this.id = id; + this.images = images; + this.title = title; + this.content = content; + this.author = author; + this.products = products; + this.totalPrice = totalPrice; + this.favoriteCount = favoriteCount; + this.favorite = favorite; + } + + public static RecipeDetailResponse toResponse(final Recipe recipe, final List recipeImages, + final List products, final Long totalPrice, final Boolean favorite) { + final RecipeAuthorDto authorDto = RecipeAuthorDto.toDto(recipe.getMember()); + final List productDtos = products.stream() + .map(ProductRecipeDto::toDto) + .collect(Collectors.toList()); + final List images = recipeImages.stream() + .map(RecipeImage::getImage) + .collect(Collectors.toList()); + return new RecipeDetailResponse(recipe.getId(), images, recipe.getTitle(), recipe.getContent(), + authorDto, productDtos, totalPrice, recipe.getFavoriteCount(), favorite); + } + + public Long getId() { + return id; + } + + public List getImages() { + return images; + } + + public String getTitle() { + return title; + } + + public String getContent() { + return content; + } + + public RecipeAuthorDto getAuthor() { + return author; + } + + public List getProducts() { + return products; + } + + public Long getTotalPrice() { + return totalPrice; + } + + public Long getFavoriteCount() { + return favoriteCount; + } + + public Boolean getFavorite() { + return favorite; + } +} diff --git a/backend/src/main/java/com/funeat/recipe/persistence/RecipeImageRepository.java b/backend/src/main/java/com/funeat/recipe/persistence/RecipeImageRepository.java new file mode 100644 index 00000000..04d2ca6a --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/persistence/RecipeImageRepository.java @@ -0,0 +1,11 @@ +package com.funeat.recipe.persistence; + +import com.funeat.recipe.domain.Recipe; +import com.funeat.recipe.domain.RecipeImage; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RecipeImageRepository extends JpaRepository { + + List findByRecipe(final Recipe recipe); +} diff --git a/backend/src/main/java/com/funeat/recipe/persistence/RecipeRepository.java b/backend/src/main/java/com/funeat/recipe/persistence/RecipeRepository.java new file mode 100644 index 00000000..83cb43d5 --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/persistence/RecipeRepository.java @@ -0,0 +1,7 @@ +package com.funeat.recipe.persistence; + +import com.funeat.recipe.domain.Recipe; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RecipeRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/funeat/recipe/presentation/RecipeApiController.java b/backend/src/main/java/com/funeat/recipe/presentation/RecipeApiController.java new file mode 100644 index 00000000..f55cd75f --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/presentation/RecipeApiController.java @@ -0,0 +1,44 @@ +package com.funeat.recipe.presentation; + +import com.funeat.auth.dto.LoginInfo; +import com.funeat.auth.util.AuthenticationPrincipal; +import com.funeat.recipe.application.RecipeService; +import com.funeat.recipe.dto.RecipeCreateRequest; +import com.funeat.recipe.dto.RecipeDetailResponse; +import java.net.URI; +import java.util.List; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +public class RecipeApiController implements RecipeController{ + + private final RecipeService recipeService; + + public RecipeApiController(final RecipeService recipeService) { + this.recipeService = recipeService; + } + + @PostMapping(value = "/api/recipes", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE}) + public ResponseEntity writeRecipe(@AuthenticationPrincipal final LoginInfo loginInfo, + @RequestPart(required = false) final List images, + @RequestPart final RecipeCreateRequest recipeRequest) { + final Long recipeId = recipeService.create(loginInfo.getId(), images, recipeRequest); + + return ResponseEntity.created(URI.create("/api/recipes/" + recipeId)).build(); + } + + @GetMapping(value = "/api/recipes/{recipeId}") + public ResponseEntity getRecipeDetail(@AuthenticationPrincipal final LoginInfo loginInfo, + @PathVariable final Long recipeId) { + final RecipeDetailResponse response = recipeService.getRecipeDetail(loginInfo.getId(), recipeId); + + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/com/funeat/recipe/presentation/RecipeController.java b/backend/src/main/java/com/funeat/recipe/presentation/RecipeController.java new file mode 100644 index 00000000..6cc59d48 --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/presentation/RecipeController.java @@ -0,0 +1,39 @@ +package com.funeat.recipe.presentation; + +import com.funeat.auth.dto.LoginInfo; +import com.funeat.auth.util.AuthenticationPrincipal; +import com.funeat.recipe.dto.RecipeCreateRequest; +import com.funeat.recipe.dto.RecipeDetailResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "07. Recipe", description = "꿀조합 관련 API 입니다.") +public interface RecipeController { + + @Operation(summary = "꿀조합 추가", description = "꿀조합을 작성한다.") + @ApiResponse( + responseCode = "201", + description = "꿀조합 작성 성공." + ) + @PostMapping + ResponseEntity writeRecipe(@AuthenticationPrincipal LoginInfo loginInfo, + @RequestPart List images, + @RequestPart RecipeCreateRequest recipeRequest); + + @Operation(summary = "꿀조합 상세 조회", description = "꿀조합의 상세 정보를 조회한다.") + @ApiResponse( + responseCode = "200", + description = "꿀조합 상세 조회 성공." + ) + @GetMapping + ResponseEntity getRecipeDetail(@AuthenticationPrincipal final LoginInfo loginInfo, + @PathVariable final Long recipeId); +} diff --git a/backend/src/main/java/com/funeat/recipe/utill/RecipeHandlerInterceptor.java b/backend/src/main/java/com/funeat/recipe/utill/RecipeHandlerInterceptor.java new file mode 100644 index 00000000..03f2f712 --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/utill/RecipeHandlerInterceptor.java @@ -0,0 +1,24 @@ +package com.funeat.recipe.utill; + +import com.funeat.auth.exception.LoginException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class RecipeHandlerInterceptor implements HandlerInterceptor { + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if ("GET".equals(request.getMethod())) { + return true; + } + final HttpSession session = request.getSession(); + if (session.getAttribute("member") == null) { + throw new LoginException("login error"); + } + return true; + } +} diff --git a/backend/src/main/java/com/funeat/review/application/ReviewService.java b/backend/src/main/java/com/funeat/review/application/ReviewService.java new file mode 100644 index 00000000..6b9500c2 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/application/ReviewService.java @@ -0,0 +1,135 @@ +package com.funeat.review.application; + +import com.funeat.common.ImageService; +import com.funeat.member.domain.Member; +import com.funeat.member.domain.favorite.ReviewFavorite; +import com.funeat.member.persistence.MemberRepository; +import com.funeat.member.persistence.ReviewFavoriteRepository; +import com.funeat.product.domain.Product; +import com.funeat.product.persistence.ProductRepository; +import com.funeat.review.domain.Review; +import com.funeat.review.domain.ReviewTag; +import com.funeat.review.persistence.ReviewRepository; +import com.funeat.review.persistence.ReviewTagRepository; +import com.funeat.review.presentation.dto.RankingReviewDto; +import com.funeat.review.presentation.dto.RankingReviewsResponse; +import com.funeat.review.presentation.dto.ReviewCreateRequest; +import com.funeat.review.presentation.dto.ReviewFavoriteRequest; +import com.funeat.review.presentation.dto.SortingReviewDto; +import com.funeat.review.presentation.dto.SortingReviewsPageDto; +import com.funeat.review.presentation.dto.SortingReviewsResponse; +import com.funeat.tag.domain.Tag; +import com.funeat.tag.persistence.TagRepository; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@Transactional(readOnly = true) +public class ReviewService { + + private final ReviewRepository reviewRepository; + private final TagRepository tagRepository; + private final ReviewTagRepository reviewTagRepository; + private final MemberRepository memberRepository; + private final ProductRepository productRepository; + private final ReviewFavoriteRepository reviewFavoriteRepository; + private final ImageService imageService; + + public ReviewService(final ReviewRepository reviewRepository, final TagRepository tagRepository, + final ReviewTagRepository reviewTagRepository, final MemberRepository memberRepository, + final ProductRepository productRepository, + final ReviewFavoriteRepository reviewFavoriteRepository, final ImageService imageService) { + this.reviewRepository = reviewRepository; + this.tagRepository = tagRepository; + this.reviewTagRepository = reviewTagRepository; + this.memberRepository = memberRepository; + this.productRepository = productRepository; + this.reviewFavoriteRepository = reviewFavoriteRepository; + this.imageService = imageService; + } + + @Transactional + public void create(final Long productId, final Long memberId, final MultipartFile image, + final ReviewCreateRequest reviewRequest) { + final Member findMember = memberRepository.findById(memberId) + .orElseThrow(IllegalArgumentException::new); + final Product findProduct = productRepository.findById(productId) + .orElseThrow(IllegalArgumentException::new); + + final Review savedReview; + if (Objects.isNull(image)) { + savedReview = reviewRepository.save( + new Review(findMember, findProduct, reviewRequest.getRating(), reviewRequest.getContent(), + reviewRequest.getRebuy())); + } else { + savedReview = reviewRepository.save( + new Review(findMember, findProduct, image.getOriginalFilename(), reviewRequest.getRating(), + reviewRequest.getContent(), reviewRequest.getRebuy())); + imageService.upload(image); + } + + final List findTags = tagRepository.findTagsByIdIn(reviewRequest.getTagIds()); + + final List reviewTags = findTags.stream() + .map(findTag -> ReviewTag.createReviewTag(savedReview, findTag)) + .collect(Collectors.toList()); + + final Long countByProduct = reviewRepository.countByProduct(findProduct); + + findProduct.updateAverageRating(savedReview.getRating(), countByProduct); + reviewTagRepository.saveAll(reviewTags); + } + + @Transactional + public void likeReview(final Long reviewId, final Long memberId, final ReviewFavoriteRequest request) { + final Member findMember = memberRepository.findById(memberId) + .orElseThrow(IllegalArgumentException::new); + final Review findReview = reviewRepository.findById(reviewId) + .orElseThrow(IllegalArgumentException::new); + + final ReviewFavorite savedReviewFavorite = reviewFavoriteRepository.findByMemberAndReview(findMember, + findReview).orElseGet(() -> saveReviewFavorite(findMember, findReview, request.getFavorite())); + + savedReviewFavorite.updateChecked(request.getFavorite()); + } + + private ReviewFavorite saveReviewFavorite(final Member member, final Review review, final Boolean favorite) { + final ReviewFavorite reviewFavorite = ReviewFavorite.createReviewFavoriteByMemberAndReview(member, review, + favorite); + + return reviewFavoriteRepository.save(reviewFavorite); + } + + public SortingReviewsResponse sortingReviews(final Long productId, final Pageable pageable, final Long memberId) { + final Member member = memberRepository.findById(memberId) + .orElseThrow(IllegalArgumentException::new); + + final Product product = productRepository.findById(productId) + .orElseThrow(IllegalArgumentException::new); + + final Page reviewPage = reviewRepository.findReviewsByProduct(pageable, product); + + final SortingReviewsPageDto pageDto = SortingReviewsPageDto.toDto(reviewPage); + final List reviewDtos = reviewPage.stream() + .map(review -> SortingReviewDto.toDto(review, member)) + .collect(Collectors.toList()); + + return SortingReviewsResponse.toResponse(pageDto, reviewDtos); + } + + public RankingReviewsResponse getTopReviews() { + final List rankingReviews = reviewRepository.findTop3ByOrderByFavoriteCountDesc(); + + final List dtos = rankingReviews.stream() + .map(RankingReviewDto::toDto) + .collect(Collectors.toList()); + + return RankingReviewsResponse.toResponse(dtos); + } +} diff --git a/backend/src/main/java/com/funeat/review/domain/Review.java b/backend/src/main/java/com/funeat/review/domain/Review.java new file mode 100644 index 00000000..13b9edce --- /dev/null +++ b/backend/src/main/java/com/funeat/review/domain/Review.java @@ -0,0 +1,132 @@ +package com.funeat.review.domain; + +import com.funeat.member.domain.Member; +import com.funeat.member.domain.favorite.ReviewFavorite; +import com.funeat.product.domain.Product; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.OneToMany; + +@Entity +public class Review { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String image; + + private Long rating; + + private String content; + + private Boolean reBuy; + + @Column(nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + @ManyToOne + @JoinColumn(name = "product_id") + private Product product; + + @ManyToOne + @JoinColumn(name = "member_id") + private Member member; + + @OneToMany(mappedBy = "review") + private List reviewTags = new ArrayList<>(); + + @OneToMany(mappedBy = "review") + private List reviewFavorites = new ArrayList<>(); + + private Long favoriteCount = 0L; + + protected Review() { + } + + public Review(final Member member, final Product findProduct, final Long rating, final String content, + final Boolean reBuy) { + this(member, findProduct, null, rating, content, reBuy); + } + + public Review(final Member member, final Product findProduct, final String image, final Long rating, + final String content, final Boolean reBuy) { + this.member = member; + this.product = findProduct; + this.image = image; + this.rating = rating; + this.content = content; + this.reBuy = reBuy; + } + + public Review(final Member member, final Product findProduct, final String image, final Long rating, + final String content, final Boolean reBuy, final Long favoriteCount) { + this.member = member; + this.product = findProduct; + this.image = image; + this.rating = rating; + this.content = content; + this.reBuy = reBuy; + this.favoriteCount = favoriteCount; + } + + public void addFavoriteCount() { + this.favoriteCount++; + } + + public void minusFavoriteCount() { + this.favoriteCount--; + } + + public Long getId() { + return id; + } + + public String getImage() { + return image; + } + + public Long getRating() { + return rating; + } + + public String getContent() { + return content; + } + + public Boolean getReBuy() { + return reBuy; + } + + public Product getProduct() { + return product; + } + + public Member getMember() { + return member; + } + + public List getReviewFavorites() { + return reviewFavorites; + } + + public Long getFavoriteCount() { + return favoriteCount; + } + + public List getReviewTags() { + return reviewTags; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/funeat/review/domain/ReviewTag.java b/backend/src/main/java/com/funeat/review/domain/ReviewTag.java new file mode 100644 index 00000000..ee4afd02 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/domain/ReviewTag.java @@ -0,0 +1,53 @@ +package com.funeat.review.domain; + +import com.funeat.tag.domain.Tag; + +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; + +@Entity +public class ReviewTag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_id") + private Review review; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tag_id") + private Tag tag; + + protected ReviewTag() { + } + + private ReviewTag(final Review review, final Tag tag) { + this.review = review; + this.tag = tag; + } + + public static ReviewTag createReviewTag(final Review review, final Tag tag) { + final ReviewTag reviewTag = new ReviewTag(review, tag); + review.getReviewTags().add(reviewTag); + return reviewTag; + } + + public Long getId() { + return id; + } + + public Review getReview() { + return review; + } + + public Tag getTag() { + return tag; + } +} diff --git a/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java b/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java new file mode 100644 index 00000000..209f3174 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java @@ -0,0 +1,18 @@ +package com.funeat.review.persistence; + +import com.funeat.product.domain.Product; +import com.funeat.review.domain.Review; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ReviewRepository extends JpaRepository { + + Page findReviewsByProduct(final Pageable pageable, final Product product); + + List findTop3ByOrderByFavoriteCountDesc(); + + Long countByProduct(final Product product); +} diff --git a/backend/src/main/java/com/funeat/review/persistence/ReviewTagRepository.java b/backend/src/main/java/com/funeat/review/persistence/ReviewTagRepository.java new file mode 100644 index 00000000..7129a711 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/persistence/ReviewTagRepository.java @@ -0,0 +1,19 @@ +package com.funeat.review.persistence; + +import com.funeat.review.domain.ReviewTag; +import com.funeat.tag.domain.Tag; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface ReviewTagRepository extends JpaRepository { + + @Query("SELECT rt.tag, COUNT(rt.tag) AS cnt " + + "FROM ReviewTag rt " + + "JOIN Review r ON rt.review.id = r.id " + + "WHERE r.product.id = :productId " + + "GROUP BY rt.tag " + + "ORDER BY cnt DESC") + List findTop3TagsByReviewIn(final Long productId, final Pageable pageable); +} diff --git a/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java new file mode 100644 index 00000000..d5052607 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java @@ -0,0 +1,69 @@ +package com.funeat.review.presentation; + +import com.funeat.auth.dto.LoginInfo; +import com.funeat.auth.util.AuthenticationPrincipal; +import com.funeat.review.application.ReviewService; +import com.funeat.review.presentation.dto.RankingReviewsResponse; +import com.funeat.review.presentation.dto.ReviewCreateRequest; +import com.funeat.review.presentation.dto.ReviewFavoriteRequest; +import com.funeat.review.presentation.dto.SortingReviewsResponse; +import java.net.URI; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +public class ReviewApiController implements ReviewController { + + private final ReviewService reviewService; + + public ReviewApiController(final ReviewService reviewService) { + this.reviewService = reviewService; + } + + @PostMapping(value = "/api/products/{productId}/reviews", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, + MediaType.APPLICATION_JSON_VALUE}) + public ResponseEntity writeReview(@PathVariable final Long productId, + @AuthenticationPrincipal final LoginInfo loginInfo, + @RequestPart(required = false) final MultipartFile image, + @RequestPart final ReviewCreateRequest reviewRequest) { + reviewService.create(productId, loginInfo.getId(), image, reviewRequest); + + return ResponseEntity.created(URI.create("/api/products/" + productId)).build(); + } + + @PatchMapping("/api/products/{productId}/reviews/{reviewId}") + public ResponseEntity toggleLikeReview(@PathVariable Long reviewId, + @AuthenticationPrincipal LoginInfo loginInfo, + @RequestBody ReviewFavoriteRequest request) { + reviewService.likeReview(reviewId, loginInfo.getId(), request); + + return ResponseEntity.noContent().build(); + + } + + @GetMapping("/api/products/{productId}/reviews") + public ResponseEntity getSortingReviews(@AuthenticationPrincipal LoginInfo loginInfo, + @PathVariable Long productId, + @PageableDefault Pageable pageable) { + final SortingReviewsResponse response = reviewService.sortingReviews(productId, pageable, loginInfo.getId()); + + return ResponseEntity.ok(response); + } + + @GetMapping("/api/ranks/reviews") + public ResponseEntity getRankingReviews() { + final RankingReviewsResponse response = reviewService.getTopReviews(); + + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/com/funeat/review/presentation/ReviewController.java b/backend/src/main/java/com/funeat/review/presentation/ReviewController.java new file mode 100644 index 00000000..dce29686 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/presentation/ReviewController.java @@ -0,0 +1,61 @@ +package com.funeat.review.presentation; + +import com.funeat.auth.dto.LoginInfo; +import com.funeat.auth.util.AuthenticationPrincipal; +import com.funeat.review.presentation.dto.RankingReviewsResponse; +import com.funeat.review.presentation.dto.ReviewCreateRequest; +import com.funeat.review.presentation.dto.ReviewFavoriteRequest; +import com.funeat.review.presentation.dto.SortingReviewsResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "03.Review", description = "리뷰관련 API 입니다.") +public interface ReviewController { + + @Operation(summary = "리뷰 추가", description = "리뷰를 작성한다.") + @ApiResponse( + responseCode = "201", + description = "리뷰 작성 성공." + ) + @PostMapping + ResponseEntity writeReview(@PathVariable Long productId, @AuthenticationPrincipal LoginInfo loginInfo, + @RequestPart MultipartFile image, @RequestPart ReviewCreateRequest reviewRequest); + + @Operation(summary = "리뷰 좋아요", description = "리뷰에 좋아요 또는 취소를 한다.") + @ApiResponse( + responseCode = "204", + description = "리뷰 좋아요(취소) 성공." + ) + @PatchMapping + ResponseEntity toggleLikeReview(@PathVariable Long reviewId, @AuthenticationPrincipal LoginInfo loginInfo, + @RequestBody ReviewFavoriteRequest request); + + @Operation(summary = "리뷰를 정렬후 조회", description = "리뷰를 정렬후 조회한다.") + @ApiResponse( + responseCode = "200", + description = "리뷰 정렬후 조회 성공." + ) + @GetMapping + ResponseEntity getSortingReviews(@AuthenticationPrincipal LoginInfo loginInfo, + @PathVariable Long productId, + @PageableDefault Pageable pageable); + + @Operation(summary = "리뷰 랭킹 Top3 조회", description = "리뷰 랭킹 Top3 조회한다.") + @ApiResponse( + responseCode = "200", + description = "리뷰 랭킹 Top3 조회 성공." + ) + @GetMapping + ResponseEntity getRankingReviews(); +} diff --git a/backend/src/main/java/com/funeat/review/presentation/dto/RankingReviewDto.java b/backend/src/main/java/com/funeat/review/presentation/dto/RankingReviewDto.java new file mode 100644 index 00000000..fcbaaed5 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/presentation/dto/RankingReviewDto.java @@ -0,0 +1,62 @@ +package com.funeat.review.presentation.dto; + +import com.funeat.review.domain.Review; + +public class RankingReviewDto { + + private final Long reviewId; + private final Long productId; + private final String productName; + private final String content; + private final Long rating; + private final Long favoriteCount; + + public RankingReviewDto(final Long reviewId, + final Long productId, + final String productName, + final String content, + final Long rating, + final Long favoriteCount) { + this.reviewId = reviewId; + this.productId = productId; + this.productName = productName; + this.content = content; + this.rating = rating; + this.favoriteCount = favoriteCount; + } + + public static RankingReviewDto toDto(final Review review) { + return new RankingReviewDto( + review.getId(), + review.getProduct().getId(), + review.getProduct().getName(), + review.getContent(), + review.getRating(), + review.getFavoriteCount() + ); + } + + public Long getReviewId() { + return reviewId; + } + + public Long getProductId() { + return productId; + } + + public String getProductName() { + return productName; + } + + public String getContent() { + return content; + } + + public Long getRating() { + return rating; + } + + public Long getFavoriteCount() { + return favoriteCount; + } +} diff --git a/backend/src/main/java/com/funeat/review/presentation/dto/RankingReviewsResponse.java b/backend/src/main/java/com/funeat/review/presentation/dto/RankingReviewsResponse.java new file mode 100644 index 00000000..c5971de0 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/presentation/dto/RankingReviewsResponse.java @@ -0,0 +1,20 @@ +package com.funeat.review.presentation.dto; + +import java.util.List; + +public class RankingReviewsResponse { + + private final List reviews; + + public RankingReviewsResponse(final List reviews) { + this.reviews = reviews; + } + + public static RankingReviewsResponse toResponse(final List reviews) { + return new RankingReviewsResponse(reviews); + } + + public List getReviews() { + return reviews; + } +} diff --git a/backend/src/main/java/com/funeat/review/presentation/dto/ReviewCreateRequest.java b/backend/src/main/java/com/funeat/review/presentation/dto/ReviewCreateRequest.java new file mode 100644 index 00000000..127bd347 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/presentation/dto/ReviewCreateRequest.java @@ -0,0 +1,34 @@ +package com.funeat.review.presentation.dto; + +import java.util.List; + +public class ReviewCreateRequest { + + private final Long rating; + private final List tagIds; + private final String content; + private final Boolean rebuy; + + public ReviewCreateRequest(final Long rating, final List tagIds, final String content, final Boolean rebuy) { + this.rating = rating; + this.tagIds = tagIds; + this.content = content; + this.rebuy = rebuy; + } + + public Long getRating() { + return rating; + } + + public String getContent() { + return content; + } + + public Boolean getRebuy() { + return rebuy; + } + + public List getTagIds() { + return tagIds; + } +} diff --git a/backend/src/main/java/com/funeat/review/presentation/dto/ReviewFavoriteRequest.java b/backend/src/main/java/com/funeat/review/presentation/dto/ReviewFavoriteRequest.java new file mode 100644 index 00000000..6dce8407 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/presentation/dto/ReviewFavoriteRequest.java @@ -0,0 +1,18 @@ +package com.funeat.review.presentation.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class ReviewFavoriteRequest { + + private final Boolean favorite; + + @JsonCreator + public ReviewFavoriteRequest(@JsonProperty("favorite") final Boolean favorite) { + this.favorite = favorite; + } + + public Boolean getFavorite() { + return favorite; + } +} diff --git a/backend/src/main/java/com/funeat/review/presentation/dto/SortingReviewDto.java b/backend/src/main/java/com/funeat/review/presentation/dto/SortingReviewDto.java new file mode 100644 index 00000000..60e06c41 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/presentation/dto/SortingReviewDto.java @@ -0,0 +1,119 @@ +package com.funeat.review.presentation.dto; + +import com.funeat.member.domain.Member; +import com.funeat.member.domain.favorite.ReviewFavorite; +import com.funeat.review.domain.Review; +import com.funeat.review.domain.ReviewTag; +import com.funeat.tag.dto.TagDto; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +public class SortingReviewDto { + + private final Long id; + private final String userName; + private final String profileImage; + private final String image; + private final Long rating; + private final List tags; + private final String content; + private final boolean rebuy; + private final Long favoriteCount; + private final boolean favorite; + private final LocalDateTime createdAt; + + public SortingReviewDto(final Long id, final String userName, final String profileImage, final String image, + final Long rating, final List tags, + final String content, final boolean rebuy, final Long favoriteCount, final boolean favorite, + final LocalDateTime createdAt) { + this.id = id; + this.userName = userName; + this.profileImage = profileImage; + this.image = image; + this.rating = rating; + this.tags = tags; + this.content = content; + this.rebuy = rebuy; + this.favoriteCount = favoriteCount; + this.favorite = favorite; + this.createdAt = createdAt; + } + + public static SortingReviewDto toDto(final Review review, final Member member) { + return new SortingReviewDto( + review.getId(), + review.getMember().getNickname(), + review.getMember().getProfileImage(), + review.getImage(), + review.getRating(), + findTagDtos(review), + review.getContent(), + review.getReBuy(), + review.getFavoriteCount(), + findReviewFavoriteChecked(review, member), + review.getCreatedAt() + ); + } + + private static List findTagDtos(final Review review) { + return review.getReviewTags().stream() + .map(ReviewTag::getTag) + .map(TagDto::toDto) + .collect(Collectors.toList()); + } + + private static boolean findReviewFavoriteChecked(final Review review, final Member member) { + return review.getReviewFavorites() + .stream() + .filter(reviewFavorite -> reviewFavorite.getReview().equals(review)) + .filter(reviewFavorite -> reviewFavorite.getMember().equals(member)) + .findFirst() + .map(ReviewFavorite::getFavorite) + .orElse(false); + } + + public Long getId() { + return id; + } + + public String getUserName() { + return userName; + } + + public String getProfileImage() { + return profileImage; + } + + public String getImage() { + return image; + } + + public Long getRating() { + return rating; + } + + public List getTags() { + return tags; + } + + public String getContent() { + return content; + } + + public boolean isRebuy() { + return rebuy; + } + + public Long getFavoriteCount() { + return favoriteCount; + } + + public boolean isFavorite() { + return favorite; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/funeat/review/presentation/dto/SortingReviewsPageDto.java b/backend/src/main/java/com/funeat/review/presentation/dto/SortingReviewsPageDto.java new file mode 100644 index 00000000..37bf3bfd --- /dev/null +++ b/backend/src/main/java/com/funeat/review/presentation/dto/SortingReviewsPageDto.java @@ -0,0 +1,63 @@ +package com.funeat.review.presentation.dto; + +import com.funeat.review.domain.Review; +import org.springframework.data.domain.Page; + +public class SortingReviewsPageDto { + + private final Long totalDataCount; + private final Long totalPages; + private final boolean firstPage; + private final boolean lastPage; + private final Long requestPage; + private final Long requestSize; + + public SortingReviewsPageDto(final Long totalDataCount, + final Long totalPages, + final boolean firstPage, + final boolean lastPage, + final Long requestPage, + final Long requestSize) { + this.totalDataCount = totalDataCount; + this.totalPages = totalPages; + this.firstPage = firstPage; + this.lastPage = lastPage; + this.requestPage = requestPage; + this.requestSize = requestSize; + } + + public static SortingReviewsPageDto toDto(final Page page) { + return new SortingReviewsPageDto( + page.getTotalElements(), + Long.valueOf(page.getTotalPages()), + page.isFirst(), + page.isLast(), + Long.valueOf(page.getNumber()), + Long.valueOf(page.getSize()) + ); + } + + public Long getTotalDataCount() { + return totalDataCount; + } + + public Long getTotalPages() { + return totalPages; + } + + public boolean isFirstPage() { + return firstPage; + } + + public boolean isLastPage() { + return lastPage; + } + + public Long getRequestPage() { + return requestPage; + } + + public Long getRequestSize() { + return requestSize; + } +} diff --git a/backend/src/main/java/com/funeat/review/presentation/dto/SortingReviewsResponse.java b/backend/src/main/java/com/funeat/review/presentation/dto/SortingReviewsResponse.java new file mode 100644 index 00000000..9a9178a9 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/presentation/dto/SortingReviewsResponse.java @@ -0,0 +1,28 @@ +package com.funeat.review.presentation.dto; + +import java.util.List; + +public class SortingReviewsResponse { + + private final SortingReviewsPageDto page; + private final List reviews; + + public SortingReviewsResponse(final SortingReviewsPageDto page, + final List reviews) { + this.page = page; + this.reviews = reviews; + } + + public static SortingReviewsResponse toResponse(final SortingReviewsPageDto page, + final List reviews) { + return new SortingReviewsResponse(page, reviews); + } + + public SortingReviewsPageDto getPage() { + return page; + } + + public List getReviews() { + return reviews; + } +} diff --git a/backend/src/main/java/com/funeat/tag/application/TagService.java b/backend/src/main/java/com/funeat/tag/application/TagService.java new file mode 100644 index 00000000..efb15849 --- /dev/null +++ b/backend/src/main/java/com/funeat/tag/application/TagService.java @@ -0,0 +1,37 @@ +package com.funeat.tag.application; + +import com.funeat.tag.domain.TagType; +import com.funeat.tag.dto.TagDto; +import com.funeat.tag.dto.TagsResponse; +import com.funeat.tag.persistence.TagRepository; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +public class TagService { + + private final TagRepository tagRepository; + + public TagService(final TagRepository tagRepository) { + this.tagRepository = tagRepository; + } + + public List getAllTags() { + final List responses = new ArrayList<>(); + for (final TagType tagType : TagType.values()) { + responses.add(getTagsByTagType(tagType)); + } + return responses; + } + + private TagsResponse getTagsByTagType(final TagType tagType) { + final List tags = tagRepository.findTagsByTagType(tagType).stream() + .map(TagDto::toDto) + .collect(Collectors.toList()); + return TagsResponse.toResponse(tagType.name(), tags); + } +} diff --git a/backend/src/main/java/com/funeat/tag/domain/Tag.java b/backend/src/main/java/com/funeat/tag/domain/Tag.java new file mode 100644 index 00000000..554b8db6 --- /dev/null +++ b/backend/src/main/java/com/funeat/tag/domain/Tag.java @@ -0,0 +1,41 @@ +package com.funeat.tag.domain; + +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; + +@Entity +public class Tag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + @Enumerated(EnumType.STRING) + private TagType tagType; + + protected Tag() { + } + + public Tag(final String name, final TagType tagType) { + this.name = name; + this.tagType = tagType; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public TagType getTagType() { + return tagType; + } +} diff --git a/backend/src/main/java/com/funeat/tag/domain/TagType.java b/backend/src/main/java/com/funeat/tag/domain/TagType.java new file mode 100644 index 00000000..5408099b --- /dev/null +++ b/backend/src/main/java/com/funeat/tag/domain/TagType.java @@ -0,0 +1,6 @@ +package com.funeat.tag.domain; + +public enum TagType { + + TASTE, PRICE, ETC +} diff --git a/backend/src/main/java/com/funeat/tag/dto/TagDto.java b/backend/src/main/java/com/funeat/tag/dto/TagDto.java new file mode 100644 index 00000000..45a09c5f --- /dev/null +++ b/backend/src/main/java/com/funeat/tag/dto/TagDto.java @@ -0,0 +1,33 @@ +package com.funeat.tag.dto; + +import com.funeat.tag.domain.Tag; +import com.funeat.tag.domain.TagType; + +public class TagDto { + + private final Long id; + private final String name; + private final TagType tagType; + + public TagDto(final Long id, final String name, final TagType tagType) { + this.id = id; + this.name = name; + this.tagType = tagType; + } + + public static TagDto toDto(final Tag tag) { + return new TagDto(tag.getId(), tag.getName(), tag.getTagType()); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public TagType getTagType() { + return tagType; + } +} diff --git a/backend/src/main/java/com/funeat/tag/dto/TagsResponse.java b/backend/src/main/java/com/funeat/tag/dto/TagsResponse.java new file mode 100644 index 00000000..e81f29e1 --- /dev/null +++ b/backend/src/main/java/com/funeat/tag/dto/TagsResponse.java @@ -0,0 +1,26 @@ +package com.funeat.tag.dto; + +import java.util.List; + +public class TagsResponse { + + private final String tagType; + private final List tags; + + public TagsResponse(final String tagType, final List tags) { + this.tagType = tagType; + this.tags = tags; + } + + public static TagsResponse toResponse(final String tagType, final List tags) { + return new TagsResponse(tagType, tags); + } + + public String getTagType() { + return tagType; + } + + public List getTags() { + return tags; + } +} diff --git a/backend/src/main/java/com/funeat/tag/persistence/TagRepository.java b/backend/src/main/java/com/funeat/tag/persistence/TagRepository.java new file mode 100644 index 00000000..b74e0197 --- /dev/null +++ b/backend/src/main/java/com/funeat/tag/persistence/TagRepository.java @@ -0,0 +1,13 @@ +package com.funeat.tag.persistence; + +import com.funeat.tag.domain.Tag; +import com.funeat.tag.domain.TagType; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TagRepository extends JpaRepository { + + List findTagsByIdIn(final List tagIds); + + List findTagsByTagType(final TagType tagType); +} diff --git a/backend/src/main/java/com/funeat/tag/presentation/TagApiController.java b/backend/src/main/java/com/funeat/tag/presentation/TagApiController.java new file mode 100644 index 00000000..b8e5c6b6 --- /dev/null +++ b/backend/src/main/java/com/funeat/tag/presentation/TagApiController.java @@ -0,0 +1,24 @@ +package com.funeat.tag.presentation; + +import com.funeat.tag.application.TagService; +import com.funeat.tag.dto.TagsResponse; +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class TagApiController implements TagController { + + private final TagService tagService; + + public TagApiController(final TagService tagService) { + this.tagService = tagService; + } + + @GetMapping("/api/tags") + public ResponseEntity> getAllTags() { + final List responses = tagService.getAllTags(); + return ResponseEntity.ok(responses); + } +} diff --git a/backend/src/main/java/com/funeat/tag/presentation/TagController.java b/backend/src/main/java/com/funeat/tag/presentation/TagController.java new file mode 100644 index 00000000..a0e2377c --- /dev/null +++ b/backend/src/main/java/com/funeat/tag/presentation/TagController.java @@ -0,0 +1,21 @@ +package com.funeat.tag.presentation; + +import com.funeat.tag.dto.TagsResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; + +@Tag(name = "04.Tag", description = "태그 기능") +public interface TagController { + + @Operation(summary = "전체 태그 목록 조회", description = "전체 태그 목록을 태그 타입 별로 조회한다.") + @ApiResponse( + responseCode = "200", + description = "전체 태그 목록 조회 성공." + ) + @GetMapping + ResponseEntity> getAllTags(); +} diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml new file mode 100644 index 00000000..a2987722 --- /dev/null +++ b/backend/src/main/resources/application-dev.yml @@ -0,0 +1,37 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: { DEV_DB_URL } + username: { DEV_DB_USERNAME } + password: { DEV_DB_PASSWORD } + + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + show_sql: true + +image: + path: { DEV_IMAGE_PATH } + +management: + endpoints: + enabled-by-default: false + web: + exposure: + include: health, metrics, prometheus + base-path: { ACTUATOR_BASE_PATH } + jmx: + exposure: + exclude: "*" + endpoint: + health: + enabled: true + metrics: + enabled: true + prometheus: + enabled: true + server: + port: { SERVER_PORT } diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml new file mode 100644 index 00000000..f5942cf3 --- /dev/null +++ b/backend/src/main/resources/application-local.yml @@ -0,0 +1,21 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: + username: + password: + + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + show_sql: true + +logging: + level: + org.hibernate.type.descriptor.sql: trace + +image: + path: diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml new file mode 100644 index 00000000..218f1fa3 --- /dev/null +++ b/backend/src/main/resources/application-prod.yml @@ -0,0 +1,37 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: { PROD_DB_URL } + username: { PROD_DB_USERNAME } + password: { PROD_DB_PASSWORD } + + jpa: + hibernate: + ddl-auto: none + properties: + hibernate: + show_sql: true + +image: + path: { PROD_IMAGE_PATH } + +management: + endpoints: + enabled-by-default: false + web: + exposure: + include: health, metrics, prometheus + base-path: { ACTUATOR_BASE_PATH } + jmx: + exposure: + exclude: "*" + endpoint: + health: + enabled: true + metrics: + enabled: true + prometheus: + enabled: true + server: + port: { SERVER_PORT } + diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties deleted file mode 100644 index 8b137891..00000000 --- a/backend/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ - diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 00000000..b9429977 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,20 @@ +spring: + profiles: + active: { DEPLOY_ACTIVE } + servlet: + multipart: + enabled: true + +springdoc: + swagger-ui: + path: /funeat-api + enabled: true + tags-sorter: alpha + +kakao: + rest-api-key: { REST_API_KEY } + redirect-uri: { REDIRECT_URI } + +logging: + file: + path: { LOG_DIR } diff --git a/backend/src/main/resources/logback-spring-dev.xml b/backend/src/main/resources/logback-spring-dev.xml new file mode 100644 index 00000000..b86839da --- /dev/null +++ b/backend/src/main/resources/logback-spring-dev.xml @@ -0,0 +1,121 @@ + + + + + + + + ${dev_slack_webhook_uri} + + ${log_pattern} + + open-macbook + :face_with_symbols_on_mouth: + true + + + + + + WARN + + + + + + INFO + ACCEPT + DENY + + ${log_dir}/info.log + + + ${log_dir}/info/info.%d{yyyy-MM-dd}_%i.log + + ${dev_file_size} + ${dev_file_max_history} + + + + ${log_pattern} + + true + + + + + + WARN + ACCEPT + DENY + + ${log_dir}/warn.log + + + ${log_dir}/warn/%d{yyyy-MM-dd}_%i.log + + ${dev_file_size} + ${dev_file_max_history} + + + + ${log_pattern} + + true + + + + + + ERROR + ACCEPT + DENY + + ${log_dir}/error.log + + + ${log_dir}/error/%d{yyyy-MM-dd}_%i.log + + ${dev_file_size} + ${dev_file_max_history} + + + + ${log_pattern} + + true + + + + + ${log_dir}/query_log.log + + + ${log_dir}/query/%d{yyyy-MM-dd}_%i.log + + 10kb + 1 + + + + ${log_pattern} + + true + + + + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/logback-spring-prod.xml b/backend/src/main/resources/logback-spring-prod.xml new file mode 100644 index 00000000..101ba8d7 --- /dev/null +++ b/backend/src/main/resources/logback-spring-prod.xml @@ -0,0 +1,97 @@ + + + + + + + + ${prod_slack_webhook_uri} + + ${log_pattern} + + open-macbook + :face_with_symbols_on_mouth: + true + + + + + + ERROR + + + + + + INFO + ACCEPT + DENY + + ${log_dir}/info.log + + + ${log_dir}/info/info.%d{yyyy-MM-dd}_%i.log + + ${prod_file_size} + ${prod_file_max_history} + + + + ${log_pattern} + + true + + + + + + WARN + ACCEPT + DENY + + ${log_dir}/warn.log + + + ${log_dir}/warn/%d{yyyy-MM-dd}_%i.log + + ${prod_file_size} + ${prod_file_max_history} + + + + ${log_pattern} + + true + + + + + + ERROR + ACCEPT + DENY + + ${log_dir}/error.log + + + ${log_dir}/error/%d{yyyy-MM-dd}_%i.log + + ${prod_file_size} + ${prod_file_max_history} + + + + ${log_pattern} + + true + + + + + + + + + + + diff --git a/backend/src/main/resources/logback-spring.xml b/backend/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..ccea7008 --- /dev/null +++ b/backend/src/main/resources/logback-spring.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/backend/src/main/resources/logback-variables.yml b/backend/src/main/resources/logback-variables.yml new file mode 100644 index 00000000..82334293 --- /dev/null +++ b/backend/src/main/resources/logback-variables.yml @@ -0,0 +1,7 @@ +log_pattern: { LOG_PATTERN } +dev_slack_webhook_uri: { DEV_SLACK_WEBHOOK_URI } +dev_file_size: { DEV_FILE_SIZE } +dev_file_max_history: { DEV_FILE_MAX_HISTORY } +prod_slack_webhook_uri: { PROD_SLACK_WEBHOOK_URI } +prod_file_size: { PROD_FILE_SIZE } +prod_file_max_history: { PROD_FILE_MAX_HISTORY } diff --git a/backend/src/test/java/com/funeat/FuneatApplicationTests.java b/backend/src/test/java/com/funeat/FuneatApplicationTests.java index 890bb292..f8bc6d3e 100644 --- a/backend/src/test/java/com/funeat/FuneatApplicationTests.java +++ b/backend/src/test/java/com/funeat/FuneatApplicationTests.java @@ -9,5 +9,4 @@ class FuneatApplicationTests { @Test void contextLoads() { } - } diff --git a/backend/src/test/java/com/funeat/acceptance/auth/AuthAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/auth/AuthAcceptanceTest.java new file mode 100644 index 00000000..509bc543 --- /dev/null +++ b/backend/src/test/java/com/funeat/acceptance/auth/AuthAcceptanceTest.java @@ -0,0 +1,116 @@ +package com.funeat.acceptance.auth; + +import static com.funeat.acceptance.auth.LoginSteps.로그아웃_요청; +import static com.funeat.acceptance.auth.LoginSteps.로그인_시도_요청; +import static com.funeat.acceptance.auth.LoginSteps.로그인_쿠키를_얻는다; +import static com.funeat.acceptance.auth.LoginSteps.카카오_로그인_버튼_클릭; +import static com.funeat.acceptance.common.CommonSteps.LOCATION_헤더에서_리다이렉트_주소_추출; +import static com.funeat.acceptance.common.CommonSteps.REDIRECT_URL을_검증한다; +import static com.funeat.acceptance.common.CommonSteps.STATUS_CODE를_검증한다; +import static com.funeat.acceptance.common.CommonSteps.리다이렉션_영구_이동; +import static com.funeat.acceptance.common.CommonSteps.정상_처리; +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static org.assertj.core.api.Assertions.assertThat; + +import com.funeat.acceptance.common.AcceptanceTest; +import com.funeat.auth.application.AuthService; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; + +@SuppressWarnings("NonAsciiCharacters") +public class AuthAcceptanceTest extends AcceptanceTest { + + @Autowired + private AuthService authService; + + @Nested + class kakaoLogin_성공_테스트 { + + @Test + void 멤버가_카카오_로그인_버튼을_누르면_카카오_로그인_페이지로_리다이렉트할_수_있다() { + // given + final var expected = authService.getLoginRedirectUri(); + + // when + final var response = 카카오_로그인_버튼_클릭(); + + // then + STATUS_CODE를_검증한다(response, 리다이렉션_영구_이동); + REDIRECT_URL을_검증한다(response, expected); + } + } + + @Nested + class loginAuthorizeUser_성공_테스트 { + + @Test + void 신규_유저라면_마이페이지_경로를_헤더에_담아_응답을_보낸다() { + // given + final var code = "member1"; + final var loginCookie = "12345"; + + // when + final var response = 로그인_시도_요청(code, loginCookie); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 헤더에_리다이렉트가_존재하는지_검증한다(response, "/profile"); + } + + @Test + void 기존_유저라면_메인페이지_경로를_헤더에_담아_응답을_보낸다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var code = "member1"; + final var loginCookie = 로그인_쿠키를_얻는다(); + + // when + final var response = 로그인_시도_요청(code, loginCookie); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 헤더에_리다이렉트가_존재하는지_검증한다(response, "/"); + } + } + + @Nested + class logout_성공_테스트 { + + @Test + void 로그아웃을_하다() { + // given + final var loginCookie = 로그인_쿠키를_얻는다(); + + // when + final var response = 로그아웃_요청(loginCookie); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + } + } + + @Nested + class logout_실패_테스트 { + + @Test + void 쿠키가_존재하지_않을_때_로그아웃을_하면_예외가_발생해야하는데_통과하고_있다() { + // given & when + final var response = 로그아웃_요청(null); + + // then + STATUS_CODE를_검증한다(response, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + private void 헤더에_리다이렉트가_존재하는지_검증한다(final ExtractableResponse response, final String expected) { + final var actual = LOCATION_헤더에서_리다이렉트_주소_추출(response); + + assertThat(actual).isEqualTo(expected); + } +} diff --git a/backend/src/test/java/com/funeat/acceptance/auth/LoginSteps.java b/backend/src/test/java/com/funeat/acceptance/auth/LoginSteps.java new file mode 100644 index 00000000..d938cac7 --- /dev/null +++ b/backend/src/test/java/com/funeat/acceptance/auth/LoginSteps.java @@ -0,0 +1,50 @@ +package com.funeat.acceptance.auth; + +import static io.restassured.RestAssured.given; + +import io.restassured.RestAssured; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; + +@SuppressWarnings("NonAsciiCharacters") +public class LoginSteps { + + public static ExtractableResponse 카카오_로그인_버튼_클릭() { + return given() + .redirects().follow(false) + .when() + .get("/api/auth/kakao") + .then() + .extract(); + } + + public static ExtractableResponse 로그인_시도_요청(final String code, final String loginCookie) { + return given() + .cookie("JSESSIONID", loginCookie) + .param("code", code) + .when() + .get("/api/login/oauth2/code/kakao") + .then() + .extract(); + } + + public static ExtractableResponse 로그아웃_요청(final String loginCookie) { + return given() + .cookie("JSESSIONID", loginCookie) + .when() + .get("/api/logout") + .then() + .extract(); + } + + public static String 로그인_쿠키를_얻는다() { + return RestAssured.given() + .queryParam("code", "test") + .when() + .get("/api/login/oauth2/code/kakao") + .then() + .extract() + .response() + .getCookie("JSESSIONID"); + } +} diff --git a/backend/src/test/java/com/funeat/acceptance/common/AcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/common/AcceptanceTest.java new file mode 100644 index 00000000..cdaaf56b --- /dev/null +++ b/backend/src/test/java/com/funeat/acceptance/common/AcceptanceTest.java @@ -0,0 +1,141 @@ +package com.funeat.acceptance.common; + +import com.funeat.common.DataClearExtension; +import com.funeat.member.domain.Member; +import com.funeat.member.domain.favorite.ReviewFavorite; +import com.funeat.member.persistence.MemberRepository; +import com.funeat.member.persistence.ReviewFavoriteRepository; +import com.funeat.product.domain.Category; +import com.funeat.product.domain.Product; +import com.funeat.product.persistence.CategoryRepository; +import com.funeat.product.persistence.ProductRepository; +import com.funeat.recipe.persistence.RecipeImageRepository; +import com.funeat.recipe.persistence.RecipeRepository; +import com.funeat.review.domain.Review; +import com.funeat.review.domain.ReviewTag; +import com.funeat.review.persistence.ReviewRepository; +import com.funeat.review.persistence.ReviewTagRepository; +import com.funeat.tag.domain.Tag; +import com.funeat.tag.persistence.TagRepository; +import io.restassured.RestAssured; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; + +@ExtendWith(DataClearExtension.class) +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +public abstract class AcceptanceTest { + + @LocalServerPort + private int port; + + @Autowired + protected ProductRepository productRepository; + + @Autowired + protected CategoryRepository categoryRepository; + + @Autowired + protected MemberRepository memberRepository; + + @Autowired + protected ReviewRepository reviewRepository; + + @Autowired + protected TagRepository tagRepository; + + @Autowired + protected ReviewTagRepository reviewTagRepository; + + @Autowired + protected ReviewFavoriteRepository reviewFavoriteRepository; + + @Autowired + public RecipeRepository recipeRepository; + + @Autowired + public RecipeImageRepository recipeImageRepository; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + protected Long 단일_상품_저장(final Product product) { + return productRepository.save(product).getId(); + } + + protected void 복수_상품_저장(final Product... productsToSave) { + final var products = List.of(productsToSave); + + productRepository.saveAll(products); + } + + protected Long 단일_카테고리_저장(final Category category) { + return categoryRepository.save(category).getId(); + } + + protected void 복수_카테고리_저장(final Category... categoriesToSave) { + final var categories = List.of(categoriesToSave); + + categoryRepository.saveAll(categories); + } + + protected Long 단일_멤버_저장(final Member member) { + return memberRepository.save(member).getId(); + } + + protected void 복수_멤버_저장(final Member... membersToSave) { + final var members = List.of(membersToSave); + + memberRepository.saveAll(members); + } + + protected Long 단일_리뷰_저장(final Review review) { + return reviewRepository.save(review).getId(); + } + + protected void 복수_리뷰_저장(final Review... reviewsToSave) { + final var reviews = List.of(reviewsToSave); + + reviewRepository.saveAll(reviews); + } + + protected Long 단일_태그_저장(final Tag tag) { + return tagRepository.save(tag).getId(); + } + + protected void 복수_태그_저장(final Tag... tagsToSave) { + final var tags = List.of(tagsToSave); + + tagRepository.saveAll(tags); + } + + protected Long 단일_리뷰_태그_저장(final ReviewTag reviewTag) { + return reviewTagRepository.save(reviewTag).getId(); + } + + protected void 복수_리뷰_태그_저장(final ReviewTag... reviewTagsToSave) { + final var reviewTags = List.of(reviewTagsToSave); + + reviewTagRepository.saveAll(reviewTags); + } + + protected Long 단일_리뷰_좋아요_저장(final ReviewFavorite reviewFavorite) { + return reviewFavoriteRepository.save(reviewFavorite).getId(); + } + + protected void 복수_리뷰_좋아요_저장(final ReviewFavorite... reviewFavoritesToSave) { + final var reviewFavorites = List.of(reviewFavoritesToSave); + + reviewFavoriteRepository.saveAll(reviewFavorites); + } +} diff --git a/backend/src/test/java/com/funeat/acceptance/common/CommonSteps.java b/backend/src/test/java/com/funeat/acceptance/common/CommonSteps.java new file mode 100644 index 00000000..e1617999 --- /dev/null +++ b/backend/src/test/java/com/funeat/acceptance/common/CommonSteps.java @@ -0,0 +1,39 @@ +package com.funeat.acceptance.common; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import org.springframework.http.HttpStatus; + +@SuppressWarnings("NonAsciiCharacters") +public class CommonSteps { + + private static final String LOCATION = "Location"; + public static final HttpStatus 정상_처리 = HttpStatus.OK; + public static final HttpStatus 정상_생성 = HttpStatus.CREATED; + public static final HttpStatus 정상_처리_NO_CONTENT = HttpStatus.NO_CONTENT; + public static final HttpStatus 리다이렉션_영구_이동 = HttpStatus.FOUND; + + public static Long LOCATION_헤더에서_ID_추출(final ExtractableResponse response) { + return Long.parseLong(response.header(LOCATION).split("/")[2]); + } + + public static String LOCATION_헤더에서_리다이렉트_주소_추출(final ExtractableResponse response) { + return response.header(LOCATION); + } + + public static void LOCATION_헤더를_검증한다(final ExtractableResponse response) { + assertThat(response.header("Location")).isNotBlank(); + } + + public static void STATUS_CODE를_검증한다(final ExtractableResponse response, final HttpStatus httpStatus) { + assertThat(response.statusCode()).isEqualTo(httpStatus.value()); + } + + public static void REDIRECT_URL을_검증한다(final ExtractableResponse response, final String expected) { + final var actual = LOCATION_헤더에서_리다이렉트_주소_추출(response); + + assertThat(actual).isEqualTo(expected); + } +} diff --git a/backend/src/test/java/com/funeat/acceptance/common/TestPlatformUserProvider.java b/backend/src/test/java/com/funeat/acceptance/common/TestPlatformUserProvider.java new file mode 100644 index 00000000..02cbeb83 --- /dev/null +++ b/backend/src/test/java/com/funeat/acceptance/common/TestPlatformUserProvider.java @@ -0,0 +1,21 @@ +package com.funeat.acceptance.common; + +import com.funeat.auth.dto.UserInfoDto; +import com.funeat.auth.util.PlatformUserProvider; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +@Component +@Profile("test") +public class TestPlatformUserProvider implements PlatformUserProvider { + + @Override + public UserInfoDto getPlatformUser(final String code) { + return new UserInfoDto(1L, code, String.format("www.%s.com", code)); + } + + @Override + public String getRedirectURI() { + return "www.test.com"; + } +} diff --git a/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java new file mode 100644 index 00000000..642a6c4f --- /dev/null +++ b/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java @@ -0,0 +1,78 @@ +package com.funeat.acceptance.member; + +import static com.funeat.acceptance.auth.LoginSteps.로그인_쿠키를_얻는다; +import static com.funeat.acceptance.common.CommonSteps.STATUS_CODE를_검증한다; +import static com.funeat.acceptance.common.CommonSteps.정상_처리; +import static com.funeat.acceptance.member.MemberSteps.사용자_정보_수정_요청; +import static com.funeat.acceptance.member.MemberSteps.사용자_정보_조회_요청; +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.funeat.acceptance.common.AcceptanceTest; +import com.funeat.member.domain.Member; +import com.funeat.member.dto.MemberProfileResponse; +import com.funeat.member.dto.MemberRequest; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +public class MemberAcceptanceTest extends AcceptanceTest { + + @Nested + class getMemberProfile_성공_테스트 { + + @Test + void 사용자_정보를_확인하다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var loginCookie = 로그인_쿠키를_얻는다(); + + // when + final var response = 사용자_정보_조회_요청(loginCookie); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 사용자_정보_조회를_검증하다(response, member); + } + } + + @Nested + class putMemberProfile_성공_테스트 { + + @Test + void 사용자_정보를_수정하다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var loginCookie = 로그인_쿠키를_얻는다(); + final var request = new MemberRequest("after", "http://www.after.com"); + + // when + final var response = 사용자_정보_수정_요청(loginCookie, request); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + } + } + + private void 사용자_정보_조회를_검증하다(final ExtractableResponse response, final Member member) { + final var expected = MemberProfileResponse.toResponse(member); + final var expectedNickname = expected.getNickname(); + final var expectedProfileImage = expected.getProfileImage(); + + final var actualNickname = response.jsonPath().getString("nickname"); + final var actualProfileImage = response.jsonPath().getString("profileImage"); + + assertSoftly(softAssertions -> { + softAssertions.assertThat(actualNickname) + .isEqualTo(expectedNickname); + softAssertions.assertThat(actualProfileImage) + .isEqualTo(expectedProfileImage); + }); + } +} diff --git a/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java b/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java new file mode 100644 index 00000000..ca8d3fed --- /dev/null +++ b/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java @@ -0,0 +1,31 @@ +package com.funeat.acceptance.member; + +import static io.restassured.RestAssured.given; + +import com.funeat.member.dto.MemberRequest; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; + +@SuppressWarnings("NonAsciiCharacters") +public class MemberSteps { + + public static ExtractableResponse 사용자_정보_조회_요청(final String loginCookie) { + return given() + .cookie("JSESSIONID", loginCookie) + .when() + .get("/api/members") + .then() + .extract(); + } + + public static ExtractableResponse 사용자_정보_수정_요청(final String loginCookie, final MemberRequest request) { + return given() + .cookie("JSESSIONID", loginCookie) + .contentType("application/json") + .body(request) + .when() + .put("/api/members") + .then() + .extract(); + } +} diff --git a/backend/src/test/java/com/funeat/acceptance/product/CategoryAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/product/CategoryAcceptanceTest.java new file mode 100644 index 00000000..56e5b617 --- /dev/null +++ b/backend/src/test/java/com/funeat/acceptance/product/CategoryAcceptanceTest.java @@ -0,0 +1,58 @@ +package com.funeat.acceptance.product; + +import static com.funeat.acceptance.common.CommonSteps.STATUS_CODE를_검증한다; +import static com.funeat.acceptance.common.CommonSteps.정상_처리; +import static com.funeat.acceptance.product.CategorySteps.공통_상품_카테고리_목록_조회_요청; +import static com.funeat.fixture.CategoryFixture.카테고리_CU_생성; +import static com.funeat.fixture.CategoryFixture.카테고리_간편식사_생성; +import static com.funeat.fixture.CategoryFixture.카테고리_과자류_생성; +import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성; +import static org.assertj.core.api.Assertions.assertThat; + +import com.funeat.acceptance.common.AcceptanceTest; +import com.funeat.product.domain.Category; +import com.funeat.product.dto.CategoryResponse; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +public class CategoryAcceptanceTest extends AcceptanceTest { + + + @Nested + class getAllCategoriesByType_성공_테스트 { + + @Test + void 공통_상품_카테고리의_목록을_조회한다() { + // given + final var 간편식사 = 카테고리_간편식사_생성(); + final var 즉석조리 = 카테고리_즉석조리_생성(); + final var 과자류 = 카테고리_과자류_생성(); + final var CU = 카테고리_CU_생성(); + 복수_카테고리_저장(간편식사, 즉석조리, 과자류, CU); + + // when + final var response = 공통_상품_카테고리_목록_조회_요청(); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 공통_상품_카테고리_목록_조회_결과를_검증한다(response, List.of(간편식사, 즉석조리, 과자류)); + } + } + + private void 공통_상품_카테고리_목록_조회_결과를_검증한다(final ExtractableResponse response, + final List categories) { + final var expected = categories.stream() + .map(CategoryResponse::toResponse) + .collect(Collectors.toList()); + final var actual = response.jsonPath() + .getList("", CategoryResponse.class); + + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } +} diff --git a/backend/src/test/java/com/funeat/acceptance/product/CategorySteps.java b/backend/src/test/java/com/funeat/acceptance/product/CategorySteps.java new file mode 100644 index 00000000..9ed5aa8d --- /dev/null +++ b/backend/src/test/java/com/funeat/acceptance/product/CategorySteps.java @@ -0,0 +1,19 @@ +package com.funeat.acceptance.product; + +import static io.restassured.RestAssured.given; + +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; + +@SuppressWarnings("NonAsciiCharacters") +public class CategorySteps { + + public static ExtractableResponse 공통_상품_카테고리_목록_조회_요청() { + return given() + .queryParam("type", "food") + .when() + .get("/api/categories") + .then() + .extract(); + } +} diff --git a/backend/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java new file mode 100644 index 00000000..f46e7e38 --- /dev/null +++ b/backend/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java @@ -0,0 +1,558 @@ +package com.funeat.acceptance.product; + +import static com.funeat.acceptance.auth.LoginSteps.로그인_쿠키를_얻는다; +import static com.funeat.acceptance.common.CommonSteps.STATUS_CODE를_검증한다; +import static com.funeat.acceptance.common.CommonSteps.정상_처리; +import static com.funeat.acceptance.product.ProductSteps.상품_랭킹_조회_요청; +import static com.funeat.acceptance.product.ProductSteps.상품_상세_조회_요청; +import static com.funeat.acceptance.product.ProductSteps.카테고리별_상품_목록_조회_요청; +import static com.funeat.acceptance.review.ReviewSteps.단일_리뷰_요청; +import static com.funeat.acceptance.review.ReviewSteps.리뷰_사진_명세_요청; +import static com.funeat.fixture.CategoryFixture.카테고리_간편식사_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버3_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점1점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점4점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점5점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점1점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점3점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점4점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_평점1점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_평점3점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_평점4점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격4000원_평점2점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격4000원_평점4점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격5000원_평점1점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격5000원_평점3점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격5000원_평점4점_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test1_평점1점_재구매X_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test2_평점2점_재구매X_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매X_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매X_생성; +import static com.funeat.fixture.ReviewFixture.리뷰추가요청_재구매X_생성; +import static com.funeat.fixture.TagFixture.태그_간식_ETC_생성; +import static com.funeat.fixture.TagFixture.태그_단짠단짠_TASTE_생성; +import static com.funeat.fixture.TagFixture.태그_맛있어요_TASTE_생성; +import static org.assertj.core.api.Assertions.assertThat; + +import com.funeat.acceptance.common.AcceptanceTest; +import com.funeat.product.domain.Product; +import com.funeat.product.dto.ProductInCategoryDto; +import com.funeat.product.dto.ProductResponse; +import com.funeat.product.dto.ProductsInCategoryPageDto; +import com.funeat.product.dto.RankingProductDto; +import com.funeat.review.presentation.dto.SortingReviewsPageDto; +import com.funeat.tag.domain.Tag; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +class ProductAcceptanceTest extends AcceptanceTest { + + private static final Long PAGE_SIZE = 10L; + private static final Long FIRST_PAGE = 0L; + + @Nested + class getAllProductsInCategory_성공_테스트 { + + @Nested + class 가격_기준_내림차순으로_카테고리별_상품_목록_조회 { + + @Test + void 상품_가격이_서로_다르면_가격_기준_내림차순으로_정렬할_수_있다() { + // given + final var category = 카테고리_간편식사_생성(); + final var categoryId = 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점5점_생성(category); + final var product2 = 상품_삼각김밥_가격2000원_평점3점_생성(category); + final var product3 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + final var product4 = 상품_삼각김밥_가격4000원_평점4점_생성(category); + final var product5 = 상품_삼각김밥_가격5000원_평점4점_생성(category); + final var product6 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product7 = 상품_삼각김밥_가격4000원_평점2점_생성(category); + final var product8 = 상품_삼각김밥_가격5000원_평점1점_생성(category); + final var product9 = 상품_삼각김밥_가격3000원_평점3점_생성(category); + final var product10 = 상품_삼각김밥_가격3000원_평점1점_생성(category); + final var product11 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + 복수_상품_저장(product1, product2, product3, product4, product5, product6, product7, product8, product9, + product10, product11); + + final var pageDto = new SortingReviewsPageDto(11L, 2L, true, false, FIRST_PAGE, PAGE_SIZE); + + final var expectedProducts = List.of(product8, product5, product7, product4, product10, product9, + product3, product2, product11, product6); + + // when + final var response = 카테고리별_상품_목록_조회_요청(categoryId, "price", "desc", 0); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 페이지를_검증한다(response, pageDto); + 카테고리별_상품_목록_조회_결과를_검증한다(response, expectedProducts); + } + + @Test + void 상품_가격이_서로_같으면_ID_기준_내림차순으로_정렬할_수_있다() { + // given + final var category = 카테고리_간편식사_생성(); + final var categoryId = 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점5점_생성(category); + final var product2 = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var product3 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product4 = 상품_삼각김밥_가격1000원_평점4점_생성(category); + final var product5 = 상품_삼각김밥_가격1000원_평점4점_생성(category); + final var product6 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product7 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product8 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product9 = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var product10 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product11 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + 복수_상품_저장(product1, product2, product3, product4, product5, product6, product7, product8, product9, + product10, product11); + + final var pageDto = new SortingReviewsPageDto(11L, 2L, true, false, FIRST_PAGE, PAGE_SIZE); + + final var expectedProducts = List.of(product11, product10, product9, product8, product7, product6, + product5, product4, product3, product2); + + // when + final var response = 카테고리별_상품_목록_조회_요청(categoryId, "price", "desc", 0); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 페이지를_검증한다(response, pageDto); + 카테고리별_상품_목록_조회_결과를_검증한다(response, expectedProducts); + } + } + + @Nested + class 가격_기준_오름차순으로_카테고리별_상품_목록_조회 { + + @Test + void 상품_가격이_서로_다르면_가격_기준_오름차순으로_정렬할_수_있다() { + // given + final var category = 카테고리_간편식사_생성(); + final var categoryId = 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점5점_생성(category); + final var product2 = 상품_삼각김밥_가격2000원_평점3점_생성(category); + final var product3 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + final var product4 = 상품_삼각김밥_가격4000원_평점4점_생성(category); + final var product5 = 상품_삼각김밥_가격5000원_평점4점_생성(category); + final var product6 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product7 = 상품_삼각김밥_가격4000원_평점2점_생성(category); + final var product8 = 상품_삼각김밥_가격5000원_평점1점_생성(category); + final var product9 = 상품_삼각김밥_가격3000원_평점3점_생성(category); + final var product10 = 상품_삼각김밥_가격3000원_평점1점_생성(category); + final var product11 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + 복수_상품_저장(product1, product2, product3, product4, product5, product6, product7, product8, product9, + product10, product11); + + final var pageDto = new SortingReviewsPageDto(11L, 2L, true, false, FIRST_PAGE, PAGE_SIZE); + + final var expectedProducts = List.of(product11, product6, product1, product3, product2, product10, + product9, product7, product4, product8); + + // when + final var response = 카테고리별_상품_목록_조회_요청(categoryId, "price", "asc", 0); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 페이지를_검증한다(response, pageDto); + 카테고리별_상품_목록_조회_결과를_검증한다(response, expectedProducts); + } + + @Test + void 상품_가격이_서로_같으면_ID_기준_내림차순으로_정렬할_수_있다() { + // given + final var category = 카테고리_간편식사_생성(); + final var categoryId = 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점5점_생성(category); + final var product2 = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var product3 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product4 = 상품_삼각김밥_가격1000원_평점4점_생성(category); + final var product5 = 상품_삼각김밥_가격1000원_평점4점_생성(category); + final var product6 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product7 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product8 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product9 = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var product10 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product11 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + 복수_상품_저장(product1, product2, product3, product4, product5, product6, product7, product8, product9, + product10, product11); + + final var pageDto = new SortingReviewsPageDto(11L, 2L, true, false, FIRST_PAGE, PAGE_SIZE); + + final var expectedProducts = List.of(product11, product10, product9, product8, product7, product6, + product5, product4, product3, product2); + + // when + final var response = 카테고리별_상품_목록_조회_요청(categoryId, "price", "asc", 0); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 페이지를_검증한다(response, pageDto); + 카테고리별_상품_목록_조회_결과를_검증한다(response, expectedProducts); + } + } + + @Nested + class 평점_기준_내림차순으로_카테고리별_상품_목록_조회 { + + @Test + void 상품_평점이_서로_다르면_평점_기준_내림차순으로_정렬할_수_있다() { + // given + final var category = 카테고리_간편식사_생성(); + final var categoryId = 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점5점_생성(category); + final var product2 = 상품_삼각김밥_가격2000원_평점3점_생성(category); + final var product3 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + final var product4 = 상품_삼각김밥_가격1000원_평점4점_생성(category); + final var product5 = 상품_삼각김밥_가격2000원_평점4점_생성(category); + final var product6 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product7 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product8 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product9 = 상품_삼각김밥_가격3000원_평점3점_생성(category); + final var product10 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + final var product11 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + 복수_상품_저장(product1, product2, product3, product4, product5, product6, product7, product8, product9, + product10, product11); + + final var pageDto = new SortingReviewsPageDto(11L, 2L, true, false, FIRST_PAGE, PAGE_SIZE); + + final var expectedProducts = List.of(product1, product5, product4, product9, product2, product7, + product6, product11, product10, product8); + + // when + final var response = 카테고리별_상품_목록_조회_요청(categoryId, "averageRating", "desc", 0); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 페이지를_검증한다(response, pageDto); + 카테고리별_상품_목록_조회_결과를_검증한다(response, expectedProducts); + } + + @Test + void 상품_평점이_서로_같으면_ID_기준_내림차순으로_정렬할_수_있다() { + // given + final var category = 카테고리_간편식사_생성(); + final var categoryId = 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product2 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + final var product3 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + final var product4 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product5 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + final var product6 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product7 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product8 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product9 = 상품_삼각김밥_가격3000원_평점1점_생성(category); + final var product10 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + final var product11 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + 복수_상품_저장(product1, product2, product3, product4, product5, product6, product7, product8, product9, + product10, product11); + + final var pageDto = new SortingReviewsPageDto(11L, 2L, true, false, FIRST_PAGE, PAGE_SIZE); + + final var expectedProducts = List.of(product11, product10, product9, product8, product7, product6, + product5, product4, product3, product2); + + // when + final var response = 카테고리별_상품_목록_조회_요청(categoryId, "averageRating", "desc", 0); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 페이지를_검증한다(response, pageDto); + 카테고리별_상품_목록_조회_결과를_검증한다(response, expectedProducts); + } + } + + @Nested + class 평점_기준_오름차순으로_카테고리별_상품_목록_조회 { + + @Test + void 상품_평점이_서로_다르면_평점_기준_오름차순으로_정렬할_수_있다() { + // given + final var category = 카테고리_간편식사_생성(); + final var categoryId = 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점5점_생성(category); + final var product2 = 상품_삼각김밥_가격2000원_평점3점_생성(category); + final var product3 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + final var product4 = 상품_삼각김밥_가격1000원_평점4점_생성(category); + final var product5 = 상품_삼각김밥_가격2000원_평점4점_생성(category); + final var product6 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product7 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product8 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product9 = 상품_삼각김밥_가격3000원_평점3점_생성(category); + final var product10 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + final var product11 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + 복수_상품_저장(product1, product2, product3, product4, product5, product6, product7, product8, product9, + product10, product11); + + final var pageDto = new SortingReviewsPageDto(11L, 2L, true, false, FIRST_PAGE, PAGE_SIZE); + + final var expectedProducts = List.of(product11, product10, product8, product3, product7, product6, + product9, product2, product5, product4); + + // when + final var response = 카테고리별_상품_목록_조회_요청(categoryId, "averageRating", "asc", 0); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 페이지를_검증한다(response, pageDto); + 카테고리별_상품_목록_조회_결과를_검증한다(response, expectedProducts); + } + + @Test + void 상품_평점이_서로_같으면_ID_기준_내림차순으로_정렬할_수_있다() { + // given + final var category = 카테고리_간편식사_생성(); + final var categoryId = 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product2 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + final var product3 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + final var product4 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product5 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + final var product6 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product7 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product8 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product9 = 상품_삼각김밥_가격3000원_평점1점_생성(category); + final var product10 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + final var product11 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + 복수_상품_저장(product1, product2, product3, product4, product5, product6, product7, product8, product9, + product10, product11); + + final var pageDto = new SortingReviewsPageDto(11L, 2L, true, false, FIRST_PAGE, PAGE_SIZE); + + final var expectedProducts = List.of(product11, product10, product9, product8, product7, product6, + product5, product4, product3, product2); + + // when + final var response = 카테고리별_상품_목록_조회_요청(categoryId, "averageRating", "asc", 0); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 페이지를_검증한다(response, pageDto); + 카테고리별_상품_목록_조회_결과를_검증한다(response, expectedProducts); + } + } + + @Nested + class 리뷰수_기준_내림차순으로_카테고리별_상품_목록_조회 { + + @Test + void 리뷰수가_서로_다르면_리뷰수_기준_내림차순으로_정렬할_수_있다() { + // given + final var category = 카테고리_간편식사_생성(); + final var categoryId = 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점5점_생성(category); + final var product2 = 상품_삼각김밥_가격2000원_평점3점_생성(category); + final var product3 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + 복수_상품_저장(product1, product2, product3); + + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var review1_1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product1, 0L); + final var review1_2 = 리뷰_이미지test3_평점3점_재구매O_생성(member2, product1, 0L); + final var review1_3 = 리뷰_이미지test4_평점4점_재구매O_생성(member3, product1, 0L); + final var review2_1 = 리뷰_이미지test1_평점1점_재구매X_생성(member1, product2, 0L); + final var review2_2 = 리뷰_이미지test1_평점1점_재구매X_생성(member2, product2, 0L); + final var review3_1 = 리뷰_이미지test2_평점2점_재구매X_생성(member1, product3, 0L); + final var review3_2 = 리뷰_이미지test2_평점2점_재구매X_생성(member2, product3, 0L); + final var review3_3 = 리뷰_이미지test2_평점2점_재구매X_생성(member3, product3, 0L); + 복수_리뷰_저장(review1_1, review1_2, review1_3, review2_1, review2_2, review3_1, review3_2, review3_3); + + final var pageDto = new SortingReviewsPageDto(3L, 1L, true, true, FIRST_PAGE, PAGE_SIZE); + + final var expectedProducts = List.of(product3, product1, product2); + + // when + final var response = 카테고리별_상품_목록_조회_요청(categoryId, "reviewCount", "desc", 0); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 페이지를_검증한다(response, pageDto); + 카테고리별_상품_목록_조회_결과를_검증한다(response, expectedProducts); + } + + @Test + void 리뷰수가_서로_같으면_ID_기준_내림차순으로_정렬할_수_있다() { + // given + final var category = 카테고리_간편식사_생성(); + final var categoryId = 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점5점_생성(category); + final var product2 = 상품_삼각김밥_가격5000원_평점3점_생성(category); + final var product3 = 상품_삼각김밥_가격3000원_평점1점_생성(category); + final var product4 = 상품_삼각김밥_가격2000원_평점4점_생성(category); + final var product5 = 상품_삼각김밥_가격3000원_평점4점_생성(category); + 복수_상품_저장(product1, product2, product3, product4, product5); + + final var member1 = 멤버_멤버1_생성(); + 단일_멤버_저장(member1); + + final var review1_1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product1, 0L); + final var review2_1 = 리뷰_이미지test1_평점1점_재구매X_생성(member1, product2, 0L); + final var review3_1 = 리뷰_이미지test2_평점2점_재구매X_생성(member1, product3, 0L); + 복수_리뷰_저장(review1_1, review2_1, review3_1); + + final var pageDto = new SortingReviewsPageDto(5L, 1L, true, true, FIRST_PAGE, PAGE_SIZE); + + final var expectedProducts = List.of(product3, product2, product1, product5, product4); + + // when + final var response = 카테고리별_상품_목록_조회_요청(categoryId, "reviewCount", "desc", 0); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 페이지를_검증한다(response, pageDto); + 카테고리별_상품_목록_조회_결과를_검증한다(response, expectedProducts); + } + } + } + + @Nested + class getProductDetail_성공_테스트 { + + @Test + void 상품_상세_정보를_조회한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_단짠단짠_TASTE_생성(); + final var tag3 = 태그_간식_ETC_생성(); + 복수_태그_저장(tag1, tag2, tag3); + + final var image = 리뷰_사진_명세_요청(); + + final var request1 = 리뷰추가요청_재구매X_생성(4L, 태그_아이디_변환(tag1, tag2, tag3)); + final var request2 = 리뷰추가요청_재구매X_생성(4L, 태그_아이디_변환(tag2, tag3)); + final var request3 = 리뷰추가요청_재구매X_생성(3L, 태그_아이디_변환(tag2)); + + final var loginCookie = 로그인_쿠키를_얻는다(); + + 단일_리뷰_요청(productId, image, request1, loginCookie); + 단일_리뷰_요청(productId, image, request2, loginCookie); + 단일_리뷰_요청(productId, image, request3, loginCookie); + + final var expectedTags = List.of(tag2, tag3, tag1); + + // when + final var response = 상품_상세_조회_요청(productId); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 상품_상세_정보_조회_결과를_검증한다(response, product, expectedTags); + } + } + + @Nested + class getRankingProducts_성공_테스트 { + + @Test + void 전체_상품들_중에서_랭킹_TOP3를_조회할_수_있다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점4점_생성(category); + final var product2 = 상품_삼각김밥_가격1000원_평점5점_생성(category); + final var product3 = 상품_삼각김밥_가격1000원_평점4점_생성(category); + final var product4 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + 복수_상품_저장(product1, product2, product3, product4); + + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var review1_1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product1, 0L); + final var review1_2 = 리뷰_이미지test3_평점3점_재구매O_생성(member2, product1, 0L); + final var review1_3 = 리뷰_이미지test4_평점4점_재구매O_생성(member3, product1, 0L); + final var review2_1 = 리뷰_이미지test4_평점4점_재구매X_생성(member1, product2, 0L); + final var review2_2 = 리뷰_이미지test4_평점4점_재구매X_생성(member2, product2, 0L); + final var review3_1 = 리뷰_이미지test2_평점2점_재구매X_생성(member1, product3, 0L); + final var review3_2 = 리뷰_이미지test5_평점5점_재구매X_생성(member2, product3, 0L); + 복수_리뷰_저장(review1_1, review1_2, review1_3, review2_1, review2_2, review3_1, review3_2); + + final var expectedProduct = List.of(product2, product1, product3); + + // when + final var response = 상품_랭킹_조회_요청(); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 상품_랭킹_조회_결과를_검증한다(response, expectedProduct); + } + } + + private List 태그_아이디_변환(final Tag... tags) { + return Arrays.stream(tags) + .map(Tag::getId) + .collect(Collectors.toList()); + } + + private void 페이지를_검증한다(final ExtractableResponse response, final SortingReviewsPageDto expected) { + final var actual = response.jsonPath().getObject("page", ProductsInCategoryPageDto.class); + + assertThat(actual).usingRecursiveComparison().isEqualTo(expected); + } + + private void 카테고리별_상품_목록_조회_결과를_검증한다(final ExtractableResponse response, final List products) { + final var expected = products.stream() + .map(product -> ProductInCategoryDto.toDto(product, 0L)) + .collect(Collectors.toList()); + final var actual = response.jsonPath() + .getList("products", ProductInCategoryDto.class); + + assertThat(actual).usingRecursiveComparison() + .ignoringFields("reviewCount") + .isEqualTo(expected); + } + + private void 상품_상세_정보_조회_결과를_검증한다(final ExtractableResponse response, final Product product, + final List expectedTags) { + final var expected = ProductResponse.toResponse(product, expectedTags); + final var actual = response.as(ProductResponse.class); + + assertThat(actual).usingRecursiveComparison() + .ignoringFields("averageRating") + .isEqualTo(expected); + } + + private void 상품_랭킹_조회_결과를_검증한다(final ExtractableResponse response, final List products) { + final var expected = products.stream() + .map(RankingProductDto::toDto) + .collect(Collectors.toList()); + final var actual = response.jsonPath() + .getList("products", RankingProductDto.class); + + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } +} diff --git a/backend/src/test/java/com/funeat/acceptance/product/ProductSteps.java b/backend/src/test/java/com/funeat/acceptance/product/ProductSteps.java new file mode 100644 index 00000000..6efed1cf --- /dev/null +++ b/backend/src/test/java/com/funeat/acceptance/product/ProductSteps.java @@ -0,0 +1,37 @@ +package com.funeat.acceptance.product; + +import static io.restassured.RestAssured.given; + +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; + +@SuppressWarnings("NonAsciiCharacters") +public class ProductSteps { + + public static ExtractableResponse 카테고리별_상품_목록_조회_요청(final Long categoryId, final String sortType, + final String sortOrderType, final int page) { + return given() + .queryParam("sort", sortType + "," + sortOrderType) + .queryParam("page", page) + .when() + .get("/api/categories/{category_id}/products", categoryId) + .then() + .extract(); + } + + public static ExtractableResponse 상품_상세_조회_요청(final Long productId) { + return given() + .when() + .get("/api/products/{product_id}", productId) + .then() + .extract(); + } + + public static ExtractableResponse 상품_랭킹_조회_요청() { + return given() + .when() + .get("/api/ranks/products") + .then() + .extract(); + } +} diff --git a/backend/src/test/java/com/funeat/acceptance/recipe/RecipeAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/recipe/RecipeAcceptanceTest.java new file mode 100644 index 00000000..a7a97a9c --- /dev/null +++ b/backend/src/test/java/com/funeat/acceptance/recipe/RecipeAcceptanceTest.java @@ -0,0 +1,113 @@ +package com.funeat.acceptance.recipe; + +import static com.funeat.acceptance.auth.LoginSteps.로그인_쿠키를_얻는다; +import static com.funeat.acceptance.common.CommonSteps.STATUS_CODE를_검증한다; +import static com.funeat.acceptance.common.CommonSteps.정상_생성; +import static com.funeat.acceptance.common.CommonSteps.정상_처리; +import static com.funeat.acceptance.recipe.RecipeSteps.레시피_상세_정보_요청; +import static com.funeat.acceptance.recipe.RecipeSteps.레시피_생성_요청; +import static com.funeat.acceptance.recipe.RecipeSteps.레시피_추가_요청하고_id_반환; +import static com.funeat.acceptance.recipe.RecipeSteps.여러_사진_요청; +import static com.funeat.fixture.CategoryFixture.카테고리_간편식사_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점1점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점1점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_평점1점_생성; +import static com.funeat.fixture.RecipeFixture.레시피추가요청_생성; +import static org.assertj.core.api.Assertions.assertThat; + +import com.funeat.acceptance.common.AcceptanceTest; +import com.funeat.product.domain.Product; +import com.funeat.recipe.dto.RecipeDetailResponse; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +public class RecipeAcceptanceTest extends AcceptanceTest { + + @Nested + class writeRecipe_성공_테스트 { + + @Test + void 레시피를_작성한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product2 = 상품_삼각김밥_가격3000원_평점1점_생성(category); + final var product3 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + 복수_상품_저장(product1, product2, product3); + + final var productIds = 상품_아이디_변환(product1, product2, product3); + final var request = 레시피추가요청_생성(productIds); + + final var images = 여러_사진_요청(3); + + final var loginCookie = 로그인_쿠키를_얻는다(); + + // when + final var response = 레시피_생성_요청(request, images, loginCookie); + + // then + STATUS_CODE를_검증한다(response, 정상_생성); + } + } + + @Nested + class getRecipeDetail_성공_테스트 { + + @Test + void 레시피의_상세_정보를_조회한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product2 = 상품_삼각김밥_가격3000원_평점1점_생성(category); + final var product3 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + final var products = List.of(product1, product2, product3); + 복수_상품_저장(product1, product2, product3); + final var productIds = 상품_아이디_변환(product1, product2, product3); + final var totalPrice = 상품_총가격_계산(product1, product2, product3); + + final var loginCookie = 로그인_쿠키를_얻는다(); + + final var createRequest = 레시피추가요청_생성(productIds); + final var images = 여러_사진_요청(3); + final var recipeId = 레시피_추가_요청하고_id_반환(createRequest, images, loginCookie); + + final var recipe = recipeRepository.findById(recipeId).get(); + final var findImages = recipeImageRepository.findByRecipe(recipe); + + final var expected = RecipeDetailResponse.toResponse(recipe, findImages, products, totalPrice, false); + + // when + final var response = 레시피_상세_정보_요청(loginCookie, recipeId); + final var actual = response.as(RecipeDetailResponse.class); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 레시피_상세_정보_조회_결과를_검증한다(actual, expected); + } + } + + private void 레시피_상세_정보_조회_결과를_검증한다(final RecipeDetailResponse actual, final RecipeDetailResponse expected) { + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + private Long 상품_총가격_계산(final Product... products) { + return Stream.of(products) + .mapToLong(Product::getPrice) + .sum(); + } + + private List 상품_아이디_변환(final Product... products) { + return Stream.of(products) + .map(Product::getId) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/test/java/com/funeat/acceptance/recipe/RecipeSteps.java b/backend/src/test/java/com/funeat/acceptance/recipe/RecipeSteps.java new file mode 100644 index 00000000..c93f5cd1 --- /dev/null +++ b/backend/src/test/java/com/funeat/acceptance/recipe/RecipeSteps.java @@ -0,0 +1,56 @@ +package com.funeat.acceptance.recipe; + +import static io.restassured.RestAssured.given; + +import com.funeat.recipe.dto.RecipeCreateRequest; +import io.restassured.builder.MultiPartSpecBuilder; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import io.restassured.specification.MultiPartSpecification; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +@SuppressWarnings("NonAsciiCharacters") +public class RecipeSteps { + + public static ExtractableResponse 레시피_생성_요청(final RecipeCreateRequest recipeRequest, + final List images, + final String loginCookie) { + final var request = given() + .cookie("JSESSIONID", loginCookie); + images.forEach(request::multiPart); + return request + .multiPart("recipeRequest", recipeRequest, "application/json") + .when() + .post("/api/recipes") + .then() + .extract(); + } + + public static Long 레시피_추가_요청하고_id_반환(final RecipeCreateRequest recipeRequest, + final List imageList, + final String loginCookie) { + final var response = 레시피_생성_요청(recipeRequest, imageList, loginCookie); + return Long.parseLong(response.header("Location").split("/")[3]); + } + + public static ExtractableResponse 레시피_상세_정보_요청(final String loginCookie, final Long recipeId) { + return given() + .cookie("JSESSIONID", loginCookie) + .when() + .get("/api/recipes/{recipeId}", recipeId) + .then() + .extract(); + } + + public static List 여러_사진_요청(final int count) { + return IntStream.range(0, count) + .mapToObj(i -> new MultiPartSpecBuilder("image".getBytes()) + .fileName("testImage.png") + .controlName("images") + .mimeType("image/png") + .build()) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/test/java/com/funeat/acceptance/review/ReviewAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/review/ReviewAcceptanceTest.java new file mode 100644 index 00000000..6f90bf51 --- /dev/null +++ b/backend/src/test/java/com/funeat/acceptance/review/ReviewAcceptanceTest.java @@ -0,0 +1,499 @@ +package com.funeat.acceptance.review; + +import static com.funeat.acceptance.auth.LoginSteps.로그인_쿠키를_얻는다; +import static com.funeat.acceptance.common.CommonSteps.STATUS_CODE를_검증한다; +import static com.funeat.acceptance.common.CommonSteps.정상_생성; +import static com.funeat.acceptance.common.CommonSteps.정상_처리; +import static com.funeat.acceptance.common.CommonSteps.정상_처리_NO_CONTENT; +import static com.funeat.acceptance.review.ReviewSteps.단일_리뷰_요청; +import static com.funeat.acceptance.review.ReviewSteps.리뷰_랭킹_조회_요청; +import static com.funeat.acceptance.review.ReviewSteps.리뷰_사진_명세_요청; +import static com.funeat.acceptance.review.ReviewSteps.리뷰_좋아요_요청; +import static com.funeat.acceptance.review.ReviewSteps.정렬된_리뷰_목록_조회_요청; +import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버3_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점3점_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test1_평점1점_재구매X_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test2_평점2점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매X_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰좋아요요청_false_생성; +import static com.funeat.fixture.ReviewFixture.리뷰좋아요요청_true_생성; +import static com.funeat.fixture.ReviewFixture.리뷰추가요청_재구매O_생성; +import static com.funeat.fixture.TagFixture.태그_맛있어요_TASTE_생성; +import static com.funeat.fixture.TagFixture.태그_푸짐해요_PRICE_생성; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.funeat.acceptance.common.AcceptanceTest; +import com.funeat.member.domain.Member; +import com.funeat.member.domain.favorite.ReviewFavorite; +import com.funeat.product.domain.Category; +import com.funeat.review.domain.Review; +import com.funeat.review.presentation.dto.RankingReviewDto; +import com.funeat.review.presentation.dto.SortingReviewDto; +import com.funeat.review.presentation.dto.SortingReviewsPageDto; +import com.funeat.tag.domain.Tag; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +class ReviewAcceptanceTest extends AcceptanceTest { + + @Nested + class writeReview_성공_테스트 { + + @Test + void 리뷰를_작성한다() { + // given + final var category = 카테고리_즉석조리_생성(); + 카테고리_단일_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_푸짐해요_PRICE_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + + final var image = 리뷰_사진_명세_요청(); + final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); + final var loginCookie = 로그인_쿠키를_얻는다(); + + // when + final var response = 단일_리뷰_요청(productId, image, request, loginCookie); + + // then + STATUS_CODE를_검증한다(response, 정상_생성); + } + } + + @Nested + class toggleLikeReview_성공_테스트 { + + @Test + void 리뷰에_좋아요를_할_수_있다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 카테고리_단일_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_푸짐해요_PRICE_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + + final var image = 리뷰_사진_명세_요청(); + final var reviewRequest = 리뷰추가요청_재구매O_생성(4L, tagIds); + final var loginCookie = 로그인_쿠키를_얻는다(); + 단일_리뷰_요청(productId, image, reviewRequest, loginCookie); + + final var reviewId = reviewRepository.findAll().get(0).getId(); + final var favoriteRequest = 리뷰좋아요요청_true_생성(); + + // when + final var response = 리뷰_좋아요_요청(productId, reviewId, favoriteRequest, loginCookie); + final var actual = reviewFavoriteRepository.findAll().get(0); + + // then + STATUS_CODE를_검증한다(response, 정상_처리_NO_CONTENT); + 리뷰_좋아요_결과를_검증한다(actual, memberId, reviewId, true); + } + + @Test + void 리뷰에_좋아요를_취소할_수_있다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 카테고리_단일_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_푸짐해요_PRICE_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + + final var image = 리뷰_사진_명세_요청(); + final var reviewRequest = 리뷰추가요청_재구매O_생성(4L, tagIds); + final var loginCookie = 로그인_쿠키를_얻는다(); + + 단일_리뷰_요청(productId, image, reviewRequest, loginCookie); + + final var reviewId = reviewRepository.findAll().get(0).getId(); + + final var favoriteRequest = 리뷰좋아요요청_true_생성(); + 리뷰_좋아요_요청(productId, reviewId, favoriteRequest, loginCookie); + + final var favoriteCancelRequest = 리뷰좋아요요청_false_생성(); + + // when + final var response = 리뷰_좋아요_요청(productId, reviewId, favoriteCancelRequest, loginCookie); + final var actual = reviewFavoriteRepository.findAll().get(0); + + // then + STATUS_CODE를_검증한다(response, 정상_처리_NO_CONTENT); + 리뷰_좋아요_결과를_검증한다(actual, memberId, reviewId, false); + } + } + + @Nested + class getSortingReviews_성공_테스트 { + + @Nested + class 좋아요_기준_내림차순으로_리뷰_목록_조회 { + + @Test + void 좋아요_수가_서로_다르면_좋아요_기준_내림차순으로_정렬할_수_있다() { + // given + final var category = 카테고리_즉석조리_생성(); + 카테고리_단일_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var productId = 단일_상품_저장(product); + + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 5L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 351L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + 복수_리뷰_저장(review1, review2, review3); + + final var sortingReviews = List.of(review2, review3, review1); + final var pageDto = new SortingReviewsPageDto(3L, 1L, true, true, 0L, 10L); + + final var loginCookie = 로그인_쿠키를_얻는다(); + + // when + final var response = 정렬된_리뷰_목록_조회_요청(loginCookie, productId, "favoriteCount,desc", 0); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 정렬된_리뷰_목록_조회_결과를_검증한다(response, sortingReviews, pageDto, member1); + } + + @Test + void 좋아요_수가_서로_같으면_ID_기준_내림차순으로_정렬할_수_있다() { + // given + final var category = 카테고리_즉석조리_생성(); + 카테고리_단일_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var productId = 단일_상품_저장(product); + + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 130L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 130L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + 복수_리뷰_저장(review1, review2, review3); + + final var sortingReviews = List.of(review3, review2, review1); + final var pageDto = new SortingReviewsPageDto(3L, 1L, true, true, 0L, 10L); + final var loginCookie = 로그인_쿠키를_얻는다(); + + // when + final var response = 정렬된_리뷰_목록_조회_요청(loginCookie, productId, "favoriteCount,desc", 0); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 정렬된_리뷰_목록_조회_결과를_검증한다(response, sortingReviews, pageDto, member1); + } + } + + @Nested + class 평점_기준_오름차순으로_리뷰_목록을_조회 { + + @Test + void 평점이_서로_다르면_평점_기준_오름차순으로_정렬할_수_있다() { + // given + final var category = 카테고리_즉석조리_생성(); + 카테고리_단일_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var productId = 단일_상품_저장(product); + + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var review1 = 리뷰_이미지test2_평점2점_재구매O_생성(member1, product, 5L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 351L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + 복수_리뷰_저장(review1, review2, review3); + + final var sortingReviews = List.of(review1, review3, review2); + final var loginCookie = 로그인_쿠키를_얻는다(); + + // when + final var response = 정렬된_리뷰_목록_조회_요청(loginCookie, productId, "rating,asc", 0); + final var page = new SortingReviewsPageDto(3L, 1L, true, true, 0L, 10L); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 정렬된_리뷰_목록_조회_결과를_검증한다(response, sortingReviews, page, member1); + } + + @Test + void 평점이_서로_같으면_ID_기준_내림차순으로_정렬할_수_있다() { + // given + final var category = 카테고리_즉석조리_생성(); + 카테고리_단일_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var productId = 단일_상품_저장(product); + + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 5L); + final var review2 = 리뷰_이미지test3_평점3점_재구매O_생성(member2, product, 351L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + 복수_리뷰_저장(review1, review2, review3); + + final var sortingReviews = List.of(review3, review2, review1); + final var page = new SortingReviewsPageDto(3L, 1L, true, true, 0L, 10L); + final var loginCookie = 로그인_쿠키를_얻는다(); + + // when + final var response = 정렬된_리뷰_목록_조회_요청(loginCookie, productId, "rating,asc", 0); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 정렬된_리뷰_목록_조회_결과를_검증한다(response, sortingReviews, page, member1); + } + } + + @Nested + class 평점_기준_내림차순으로_리뷰_목록_조회 { + + @Test + void 평점이_서로_다르면_평점_기준_내림차순으로_정렬할_수_있다() { + // given + final var category = 카테고리_즉석조리_생성(); + 카테고리_단일_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var productId = 단일_상품_저장(product); + + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var review1 = 리뷰_이미지test2_평점2점_재구매O_생성(member1, product, 5L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 351L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + 복수_리뷰_저장(review1, review2, review3); + + final var sortingReviews = List.of(review2, review3, review1); + final var page = new SortingReviewsPageDto(3L, 1L, true, true, 0L, 10L); + final var loginCookie = 로그인_쿠키를_얻는다(); + + // when + final var response = 정렬된_리뷰_목록_조회_요청(loginCookie, productId, "rating,desc", 0); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 정렬된_리뷰_목록_조회_결과를_검증한다(response, sortingReviews, page, member1); + } + + @Test + void 평점이_서로_같으면_ID_기준_내림차순으로_정렬할_수_있다() { + // given + final var category = 카테고리_즉석조리_생성(); + 카테고리_단일_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var productId = 단일_상품_저장(product); + + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 5L); + final var review2 = 리뷰_이미지test3_평점3점_재구매O_생성(member2, product, 351L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + 복수_리뷰_저장(review1, review2, review3); + + final var sortingReviews = List.of(review3, review2, review1); + final var page = new SortingReviewsPageDto(3L, 1L, true, true, 0L, 10L); + final var loginCookie = 로그인_쿠키를_얻는다(); + + // when + final var response = 정렬된_리뷰_목록_조회_요청(loginCookie, productId, "rating,desc", 0); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 정렬된_리뷰_목록_조회_결과를_검증한다(response, sortingReviews, page, member1); + } + } + + @Nested + class 최신순으로_리뷰_목록을_조회 { + + @Test + void 등록_시간이_서로_다르면_최신순으로_정렬할_수_있다() { + // given + final var category = 카테고리_즉석조리_생성(); + 카테고리_단일_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var productId = 단일_상품_저장(product); + + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var review1 = 리뷰_이미지test2_평점2점_재구매O_생성(member1, product, 5L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 351L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + 복수_리뷰_저장(review1, review2, review3); + + final var sortingReviews = List.of(review3, review2, review1); + final var loginCookie = 로그인_쿠키를_얻는다(); + + // when + final var response = 정렬된_리뷰_목록_조회_요청(loginCookie, productId, "createdAt,desc", 0); + final var page = new SortingReviewsPageDto(3L, 1L, true, true, 0L, 10L); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 정렬된_리뷰_목록_조회_결과를_검증한다(response, sortingReviews, page, member1); + } + } + } + + @Nested + class getRankingReviews_성공_테스트 { + + @Test + void 리뷰_랭킹을_조회하다() { + // given + final var category = 카테고리_즉석조리_생성(); + 카테고리_단일_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var product2 = 상품_삼각김밥_가격2000원_평점3점_생성(category); + 복수_상품_저장(product1, product2); + + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var review1_1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product1, 5L); + final var review1_2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product1, 351L); + final var review1_3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product1, 130L); + final var review2_1 = 리뷰_이미지test5_평점5점_재구매O_생성(member1, product2, 247L); + final var review2_2 = 리뷰_이미지test1_평점1점_재구매X_생성(member2, product2, 83L); + 복수_리뷰_저장(review1_1, review1_2, review1_3, review2_1, review2_2); + + final var rankingReviews = List.of(review1_2, review2_1, review1_3); + + // when + final var response = 리뷰_랭킹_조회_요청(); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 리뷰_랭킹_조회_결과를_검증한다(response, rankingReviews); + } + } + + private void 리뷰_좋아요_결과를_검증한다(final ReviewFavorite actual, final Long expectedMemberId, + final Long expectedReviewId, final Boolean expectedFavorite) { + final var actualId = actual.getId(); + final var actualMemberId = actual.getMember().getId(); + final var actualReviewId = actual.getReview().getId(); + final var actualFavorite = actual.getFavorite(); + + assertSoftly(softAssertions -> { + softAssertions.assertThat(actualId) + .isNotNull(); + softAssertions.assertThat(actualReviewId) + .isEqualTo(expectedReviewId); + softAssertions.assertThat(actualMemberId) + .isEqualTo(expectedMemberId); + softAssertions.assertThat(actualFavorite) + .isEqualTo(expectedFavorite); + }); + } + + private Long 카테고리_단일_저장(final Category category) { + return categoryRepository.save(category).getId(); + } + + private List 태그_아이디_변환(final Tag... tags) { + return Stream.of(tags) + .map(Tag::getId) + .collect(Collectors.toList()); + } + + private void 정렬된_리뷰_목록_조회_결과를_검증한다(final ExtractableResponse response, final List reviews, + final SortingReviewsPageDto pageDto, final Member member) { + 페이지를_검증한다(response, pageDto); + 리뷰_목록을_검증한다(response, reviews, member); + } + + private void 페이지를_검증한다(final ExtractableResponse response, final SortingReviewsPageDto expected) { + final var actual = response.jsonPath().getObject("page", SortingReviewsPageDto.class); + + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + private void 리뷰_목록을_검증한다(final ExtractableResponse response, final List reviews, + final Member member) { + final var expected = reviews.stream() + .map(review -> SortingReviewDto.toDto(review, member)) + .collect(Collectors.toList()); + final var actual = response.jsonPath().getList("reviews", SortingReviewDto.class); + + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + private void 리뷰_랭킹_조회_결과를_검증한다(final ExtractableResponse response, final List reviews) { + final var expected = reviews.stream() + .map(RankingReviewDto::toDto) + .collect(Collectors.toList()); + final var actual = response.jsonPath() + .getList("reviews", RankingReviewDto.class); + + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } +} diff --git a/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java b/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java new file mode 100644 index 00000000..0bac5b0d --- /dev/null +++ b/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java @@ -0,0 +1,67 @@ +package com.funeat.acceptance.review; + +import static io.restassured.RestAssured.given; + +import com.funeat.review.presentation.dto.ReviewCreateRequest; +import com.funeat.review.presentation.dto.ReviewFavoriteRequest; +import io.restassured.builder.MultiPartSpecBuilder; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import io.restassured.specification.MultiPartSpecification; + +@SuppressWarnings("NonAsciiCharacters") +public class ReviewSteps { + + public static ExtractableResponse 단일_리뷰_요청(final Long productId, final MultiPartSpecification image, + final ReviewCreateRequest request, final String loginCookie) { + return given() + .cookie("JSESSIONID", loginCookie) + .multiPart(image) + .multiPart("reviewRequest", request, "application/json") + .when() + .post("/api/products/{productId}/reviews", productId) + .then() + .extract(); + } + + public static ExtractableResponse 리뷰_좋아요_요청(final Long productId, final Long reviewId, + final ReviewFavoriteRequest request, + final String loginCookie) { + return given() + .cookie("JSESSIONID", loginCookie) + .contentType("application/json") + .body(request) + .when() + .patch("/api/products/{productId}/reviews/{reviewId}", productId, reviewId) + .then() + .extract(); + } + + public static ExtractableResponse 정렬된_리뷰_목록_조회_요청(final String loginCookie, final Long productId, + final String sort, final Integer page) { + return given() + .cookie("JSESSIONID", loginCookie) + .queryParam("sort", sort) + .queryParam("page", page) + .when() + .get("/api/products/{product_id}/reviews", productId) + .then() + .extract(); + } + + public static ExtractableResponse 리뷰_랭킹_조회_요청() { + return given() + .when() + .get("/api/ranks/reviews") + .then() + .extract(); + } + + public static MultiPartSpecification 리뷰_사진_명세_요청() { + return new MultiPartSpecBuilder("image".getBytes()) + .fileName("testImage.png") + .controlName("image") + .mimeType("image/png") + .build(); + } +} diff --git a/backend/src/test/java/com/funeat/acceptance/tag/TagAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/tag/TagAcceptanceTest.java new file mode 100644 index 00000000..a5be9ab7 --- /dev/null +++ b/backend/src/test/java/com/funeat/acceptance/tag/TagAcceptanceTest.java @@ -0,0 +1,74 @@ +package com.funeat.acceptance.tag; + +import static com.funeat.acceptance.common.CommonSteps.STATUS_CODE를_검증한다; +import static com.funeat.acceptance.common.CommonSteps.정상_처리; +import static com.funeat.acceptance.tag.TagSteps.전체_태그_목록_조회_요청; +import static com.funeat.fixture.TagFixture.태그_간식_ETC_생성; +import static com.funeat.fixture.TagFixture.태그_갓성비_PRICE_생성; +import static com.funeat.fixture.TagFixture.태그_단짠단짠_TASTE_생성; +import static com.funeat.fixture.TagFixture.태그_맛있어요_TASTE_생성; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.funeat.acceptance.common.AcceptanceTest; +import com.funeat.tag.domain.Tag; +import com.funeat.tag.domain.TagType; +import com.funeat.tag.dto.TagDto; +import com.funeat.tag.dto.TagsResponse; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +public class TagAcceptanceTest extends AcceptanceTest { + + @Nested + class getAllTags_성공_테스트 { + + @Test + void 전체_태그_목록을_조회할_수_있다() { + // given + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_단짠단짠_TASTE_생성(); + final var tag3 = 태그_갓성비_PRICE_생성(); + final var tag4 = 태그_간식_ETC_생성(); + 복수_태그_저장(tag1, tag2, tag3, tag4); + + final var expected = List.of(tag1, tag2, tag3, tag4); + + // when + final var response = 전체_태그_목록_조회_요청(); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 전체_태그_목록_조회_결과를_검증한다(response, expected); + } + } + + private void 전체_태그_목록_조회_결과를_검증한다(final ExtractableResponse response, final List expected) { + final var expectedByType = expected.stream() + .collect(Collectors.groupingBy(Tag::getTagType)); + final var actual = response.jsonPath() + .getList("", TagsResponse.class); + + for (final var tagsResponse : actual) { + final var actualTagType = TagType.valueOf(tagsResponse.getTagType()); + final var actualTag = tagsResponse.getTags(); + + final var expectedTagTypes = expectedByType.keySet(); + final var expectedTag = expectedByType.get(actualTagType).stream() + .map(TagDto::toDto) + .collect(Collectors.toList()); + + assertSoftly(softAssertions -> { + softAssertions.assertThat(actualTagType) + .isIn(expectedTagTypes); + softAssertions.assertThat(actualTag) + .usingRecursiveComparison() + .isEqualTo(expectedTag); + }); + } + } +} diff --git a/backend/src/test/java/com/funeat/acceptance/tag/TagSteps.java b/backend/src/test/java/com/funeat/acceptance/tag/TagSteps.java new file mode 100644 index 00000000..53dae95a --- /dev/null +++ b/backend/src/test/java/com/funeat/acceptance/tag/TagSteps.java @@ -0,0 +1,18 @@ +package com.funeat.acceptance.tag; + +import static io.restassured.RestAssured.given; + +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; + +@SuppressWarnings("NonAsciiCharacters") +public class TagSteps { + + public static ExtractableResponse 전체_태그_목록_조회_요청() { + return given() + .when() + .get("/api/tags") + .then() + .extract(); + } +} diff --git a/backend/src/test/java/com/funeat/auth/application/AuthServiceTest.java b/backend/src/test/java/com/funeat/auth/application/AuthServiceTest.java new file mode 100644 index 00000000..e3a11736 --- /dev/null +++ b/backend/src/test/java/com/funeat/auth/application/AuthServiceTest.java @@ -0,0 +1,33 @@ +package com.funeat.auth.application; + +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static org.assertj.core.api.Assertions.assertThat; + +import com.funeat.auth.dto.SignUserDto; +import com.funeat.common.ServiceTest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +public class AuthServiceTest extends ServiceTest { + + @Nested + class loginWithKakao_성공_테스트 { + + @Test + void 카카오_로그인을_하여_멤버_정보를_가져온다() { + // given + final var code = "member1"; + final var member = 멤버_멤버1_생성(); + final var expected = SignUserDto.of(true, member); + + // when + final var actual = authService.loginWithKakao(code); + + // then + assertThat(actual).usingRecursiveComparison() + .ignoringFields("member.id") + .isEqualTo(expected); + } + } +} diff --git a/backend/src/test/java/com/funeat/common/DataCleaner.java b/backend/src/test/java/com/funeat/common/DataCleaner.java new file mode 100644 index 00000000..9cefb2f3 --- /dev/null +++ b/backend/src/test/java/com/funeat/common/DataCleaner.java @@ -0,0 +1,47 @@ +package com.funeat.common; + +import java.util.ArrayList; +import java.util.List; +import javax.annotation.PostConstruct; +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import javax.transaction.Transactional; +import org.springframework.stereotype.Component; + +@Component +public class DataCleaner { + + private static final String FOREIGN_KEY_CHECK_FORMAT = "SET REFERENTIAL_INTEGRITY %s"; + private static final String TRUNCATE_FORMAT = "TRUNCATE TABLE %s"; + private static final String AUTO_INCREMENT_FORMAT = "ALTER TABLE %s ALTER COLUMN id RESTART WITH 1"; + + private final List tableNames = new ArrayList<>(); + + @PersistenceContext + private EntityManager entityManager; + + @SuppressWarnings("unchecked") + @PostConstruct + public void findDatabaseTableNames() { + List tableInfos = entityManager.createNativeQuery("SHOW TABLES").getResultList(); + for (Object[] tableInfo : tableInfos) { + String tableName = (String) tableInfo[0]; + tableNames.add(tableName); + } + } + + @Transactional + public void clear() { + entityManager.clear(); + truncate(); + } + + private void truncate() { + entityManager.createNativeQuery(String.format(FOREIGN_KEY_CHECK_FORMAT, "FALSE")).executeUpdate(); + for (String tableName : tableNames) { + entityManager.createNativeQuery(String.format(TRUNCATE_FORMAT, tableName)).executeUpdate(); + entityManager.createNativeQuery(String.format(AUTO_INCREMENT_FORMAT, tableName)).executeUpdate(); + } + entityManager.createNativeQuery(String.format(FOREIGN_KEY_CHECK_FORMAT, "TRUE")).executeUpdate(); + } +} diff --git a/backend/src/test/java/com/funeat/common/DataClearExtension.java b/backend/src/test/java/com/funeat/common/DataClearExtension.java new file mode 100644 index 00000000..4c06f51f --- /dev/null +++ b/backend/src/test/java/com/funeat/common/DataClearExtension.java @@ -0,0 +1,19 @@ +package com.funeat.common; + +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +public class DataClearExtension implements BeforeEachCallback { + + @Override + public void beforeEach(final ExtensionContext context) { + final DataCleaner dataCleaner = getDataCleaner(context); + dataCleaner.clear(); + } + + private DataCleaner getDataCleaner(final ExtensionContext extensionContext) { + return SpringExtension.getApplicationContext(extensionContext) + .getBean(DataCleaner.class); + } +} diff --git a/backend/src/test/java/com/funeat/common/RepositoryTest.java b/backend/src/test/java/com/funeat/common/RepositoryTest.java new file mode 100644 index 00000000..5cbd4fd9 --- /dev/null +++ b/backend/src/test/java/com/funeat/common/RepositoryTest.java @@ -0,0 +1,171 @@ +package com.funeat.common; + +import com.funeat.member.domain.Member; +import com.funeat.member.domain.favorite.ReviewFavorite; +import com.funeat.member.persistence.MemberRepository; +import com.funeat.member.persistence.ProductBookmarkRepository; +import com.funeat.member.persistence.RecipeBookMarkRepository; +import com.funeat.member.persistence.RecipeFavoriteRepository; +import com.funeat.member.persistence.ReviewFavoriteRepository; +import com.funeat.product.domain.Category; +import com.funeat.product.domain.Product; +import com.funeat.product.domain.ProductRecipe; +import com.funeat.product.persistence.CategoryRepository; +import com.funeat.product.persistence.ProductRecipeRepository; +import com.funeat.product.persistence.ProductRepository; +import com.funeat.recipe.domain.Recipe; +import com.funeat.recipe.domain.RecipeImage; +import com.funeat.recipe.persistence.RecipeImageRepository; +import com.funeat.recipe.persistence.RecipeRepository; +import com.funeat.review.domain.Review; +import com.funeat.review.domain.ReviewTag; +import com.funeat.review.persistence.ReviewRepository; +import com.funeat.review.persistence.ReviewTagRepository; +import com.funeat.tag.domain.Tag; +import com.funeat.tag.persistence.TagRepository; +import java.util.List; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +@DataJpaTest +@Import(DataCleaner.class) +@ExtendWith(DataClearExtension.class) +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +public abstract class RepositoryTest { + + @Autowired + protected MemberRepository memberRepository; + + @Autowired + protected ProductBookmarkRepository productBookmarkRepository; + + @Autowired + protected RecipeBookMarkRepository recipeBookMarkRepository; + + @Autowired + protected RecipeFavoriteRepository recipeFavoriteRepository; + + @Autowired + protected ReviewFavoriteRepository reviewFavoriteRepository; + + @Autowired + protected CategoryRepository categoryRepository; + + @Autowired + protected ProductRecipeRepository productRecipeRepository; + + @Autowired + protected ProductRepository productRepository; + + @Autowired + protected RecipeImageRepository recipeImageRepository; + + @Autowired + protected RecipeRepository recipeRepository; + + @Autowired + protected ReviewRepository reviewRepository; + + @Autowired + protected ReviewTagRepository reviewTagRepository; + + @Autowired + protected TagRepository tagRepository; + + protected Long 단일_상품_저장(final Product product) { + return productRepository.save(product).getId(); + } + + protected void 복수_상품_저장(final Product... productsToSave) { + final var products = List.of(productsToSave); + + productRepository.saveAll(products); + } + + protected Long 단일_카테고리_저장(final Category category) { + return categoryRepository.save(category).getId(); + } + + protected void 복수_카테고리_저장(final Category... categoriesToSave) { + final var categories = List.of(categoriesToSave); + + categoryRepository.saveAll(categories); + } + + protected Long 단일_멤버_저장(final Member member) { + return memberRepository.save(member).getId(); + } + + protected void 복수_멤버_저장(final Member... membersToSave) { + final var members = List.of(membersToSave); + + memberRepository.saveAll(members); + } + + protected Long 단일_리뷰_저장(final Review review) { + return reviewRepository.save(review).getId(); + } + + protected void 복수_리뷰_저장(final Review... reviewsToSave) { + final var reviews = List.of(reviewsToSave); + + reviewRepository.saveAll(reviews); + } + + protected Long 단일_태그_저장(final Tag tag) { + return tagRepository.save(tag).getId(); + } + + protected void 복수_태그_저장(final Tag... tagsToSave) { + final var tags = List.of(tagsToSave); + + tagRepository.saveAll(tags); + } + + protected Long 단일_리뷰_태그_저장(final ReviewTag reviewTag) { + return reviewTagRepository.save(reviewTag).getId(); + } + + protected void 복수_리뷰_태그_저장(final ReviewTag... reviewTagsToSave) { + final var reviewTags = List.of(reviewTagsToSave); + + reviewTagRepository.saveAll(reviewTags); + } + + protected Long 단일_리뷰_좋아요_저장(final ReviewFavorite reviewFavorite) { + return reviewFavoriteRepository.save(reviewFavorite).getId(); + } + + protected void 복수_리뷰_좋아요_저장(final ReviewFavorite... reviewFavoritesToSave) { + final var reviewFavorites = List.of(reviewFavoritesToSave); + + reviewFavoriteRepository.saveAll(reviewFavorites); + } + + protected Long 단일_레시피_저장(final Recipe recipe) { + return recipeRepository.save(recipe).getId(); + } + + protected void 복수_레시피_저장(final Recipe... recipeToSave) { + final var recipes = List.of(recipeToSave); + + recipeRepository.saveAll(recipes); + } + + protected void 복수_레시피_이미지_저장(final RecipeImage... recipeImageToSave) { + final var images = List.of(recipeImageToSave); + + recipeImageRepository.saveAll(images); + } + + protected void 복수_레시피_상품_저장(final ProductRecipe... productRecipeToSave) { + final var productRecipes = List.of(productRecipeToSave); + + productRecipeRepository.saveAll(productRecipes); + } +} diff --git a/backend/src/test/java/com/funeat/common/ServiceTest.java b/backend/src/test/java/com/funeat/common/ServiceTest.java new file mode 100644 index 00000000..49ed980d --- /dev/null +++ b/backend/src/test/java/com/funeat/common/ServiceTest.java @@ -0,0 +1,177 @@ +package com.funeat.common; + +import com.funeat.auth.application.AuthService; +import com.funeat.member.application.TestMemberService; +import com.funeat.member.domain.Member; +import com.funeat.member.domain.favorite.ReviewFavorite; +import com.funeat.member.persistence.MemberRepository; +import com.funeat.member.persistence.ProductBookmarkRepository; +import com.funeat.member.persistence.RecipeBookMarkRepository; +import com.funeat.member.persistence.RecipeFavoriteRepository; +import com.funeat.member.persistence.ReviewFavoriteRepository; +import com.funeat.product.application.CategoryService; +import com.funeat.product.application.ProductService; +import com.funeat.product.domain.Category; +import com.funeat.product.domain.Product; +import com.funeat.product.persistence.CategoryRepository; +import com.funeat.product.persistence.ProductRecipeRepository; +import com.funeat.product.persistence.ProductRepository; +import com.funeat.recipe.application.RecipeService; +import com.funeat.recipe.persistence.RecipeImageRepository; +import com.funeat.recipe.persistence.RecipeRepository; +import com.funeat.review.application.ReviewService; +import com.funeat.review.domain.Review; +import com.funeat.review.domain.ReviewTag; +import com.funeat.review.persistence.ReviewRepository; +import com.funeat.review.persistence.ReviewTagRepository; +import com.funeat.tag.application.TagService; +import com.funeat.tag.domain.Tag; +import com.funeat.tag.persistence.TagRepository; +import java.util.List; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@SpringBootTest +@ExtendWith(DataClearExtension.class) +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +public abstract class ServiceTest { + + @Autowired + protected MemberRepository memberRepository; + + @Autowired + protected ProductBookmarkRepository productBookmarkRepository; + + @Autowired + protected RecipeBookMarkRepository recipeBookMarkRepository; + + @Autowired + protected RecipeFavoriteRepository recipeFavoriteRepository; + + @Autowired + protected ReviewFavoriteRepository reviewFavoriteRepository; + + @Autowired + protected CategoryRepository categoryRepository; + + @Autowired + protected ProductRecipeRepository productRecipeRepository; + + @Autowired + protected ProductRepository productRepository; + + @Autowired + protected RecipeImageRepository recipeImageRepository; + + @Autowired + protected RecipeRepository recipeRepository; + + @Autowired + protected ReviewRepository reviewRepository; + + @Autowired + protected ReviewTagRepository reviewTagRepository; + + @Autowired + protected TagRepository tagRepository; + + @Autowired + protected AuthService authService; + + @Autowired + protected TestMemberService memberService; + + @Autowired + protected CategoryService categoryService; + + @Autowired + protected RecipeService recipeService; + + @Autowired + protected ProductService productService; + + @Autowired + protected ImageService imageService; + + @Autowired + protected ReviewService reviewService; + + @Autowired + protected TagService tagService; + + protected Long 단일_상품_저장(final Product product) { + return productRepository.save(product).getId(); + } + + protected void 복수_상품_저장(final Product... productsToSave) { + final var products = List.of(productsToSave); + + productRepository.saveAll(products); + } + + protected Long 단일_카테고리_저장(final Category category) { + return categoryRepository.save(category).getId(); + } + + protected void 복수_카테고리_저장(final Category... categoriesToSave) { + final var categories = List.of(categoriesToSave); + + categoryRepository.saveAll(categories); + } + + protected Long 단일_멤버_저장(final Member member) { + return memberRepository.save(member).getId(); + } + + protected void 복수_멤버_저장(final Member... membersToSave) { + final var members = List.of(membersToSave); + + memberRepository.saveAll(members); + } + + protected Long 단일_리뷰_저장(final Review review) { + return reviewRepository.save(review).getId(); + } + + protected void 복수_리뷰_저장(final Review... reviewsToSave) { + final var reviews = List.of(reviewsToSave); + + reviewRepository.saveAll(reviews); + } + + protected Long 단일_태그_저장(final Tag tag) { + return tagRepository.save(tag).getId(); + } + + protected void 복수_태그_저장(final Tag... tagsToSave) { + final var tags = List.of(tagsToSave); + + tagRepository.saveAll(tags); + } + + protected Long 단일_리뷰_태그_저장(final ReviewTag reviewTag) { + return reviewTagRepository.save(reviewTag).getId(); + } + + protected void 복수_리뷰_태그_저장(final ReviewTag... reviewTagsToSave) { + final var reviewTags = List.of(reviewTagsToSave); + + reviewTagRepository.saveAll(reviewTags); + } + + protected Long 단일_리뷰_좋아요_저장(final ReviewFavorite reviewFavorite) { + return reviewFavoriteRepository.save(reviewFavorite).getId(); + } + + protected void 복수_리뷰_좋아요_저장(final ReviewFavorite... reviewFavoritesToSave) { + final var reviewFavorites = List.of(reviewFavoritesToSave); + + reviewFavoriteRepository.saveAll(reviewFavorites); + } +} diff --git a/backend/src/test/java/com/funeat/common/TestImageUploader.java b/backend/src/test/java/com/funeat/common/TestImageUploader.java new file mode 100644 index 00000000..c34c6cf9 --- /dev/null +++ b/backend/src/test/java/com/funeat/common/TestImageUploader.java @@ -0,0 +1,41 @@ +package com.funeat.common; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.stream.Stream; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +@Component +@Profile("test") +public class TestImageUploader implements ImageService { + + @Override + public void upload(final MultipartFile image) { + // 실제로 IO 작업을 수행하는 대신, 임시 디렉토리로 복사하도록 수정 + try { + final String temporaryPath = String.valueOf(System.currentTimeMillis()); + final Path tempDirectoryPath = Files.createTempDirectory(temporaryPath); + final Path filePath = tempDirectoryPath.resolve(image.getOriginalFilename()); + Files.copy(image.getInputStream(), filePath); + + deleteDirectory(tempDirectoryPath); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private void deleteDirectory(Path directory) throws IOException { + // 디렉토리 내부 파일 및 디렉토리 삭제 + try (Stream pathStream = Files.walk(directory)) { + pathStream.sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + } + Files.deleteIfExists(directory); + } +} diff --git a/backend/src/test/java/com/funeat/fixture/CategoryFixture.java b/backend/src/test/java/com/funeat/fixture/CategoryFixture.java new file mode 100644 index 00000000..ac50312c --- /dev/null +++ b/backend/src/test/java/com/funeat/fixture/CategoryFixture.java @@ -0,0 +1,46 @@ +package com.funeat.fixture; + +import com.funeat.product.domain.Category; +import com.funeat.product.domain.CategoryType; + +@SuppressWarnings("NonAsciiCharacters") +public class CategoryFixture { + + public static Category 카테고리_간편식사_생성() { + return new Category("간편식사", CategoryType.FOOD); + } + + public static Category 카테고리_즉석조리_생성() { + return new Category("즉석조리", CategoryType.FOOD); + } + + public static Category 카테고리_과자류_생성() { + return new Category("과자류", CategoryType.FOOD); + } + + public static Category 카테고리_아이스크림_생성() { + return new Category("아이스크림", CategoryType.FOOD); + } + + public static Category 카테고리_식품_생성() { + return new Category("식품", CategoryType.FOOD); + } + + public static Category 카테고리_음료_생성() { + return new Category("음료", CategoryType.FOOD); + } + + public static Category 카테고리_CU_생성() { + return new Category("CU", CategoryType.STORE); + } + + public static Category 카테고리_GS25_생성() { + return new Category("GS25", CategoryType.STORE); + } + + public static Category 카테고리_EMART24_생성() { + return new Category("EMART24", CategoryType.STORE); + } + + +} diff --git a/backend/src/test/java/com/funeat/fixture/ImageFixture.java b/backend/src/test/java/com/funeat/fixture/ImageFixture.java new file mode 100644 index 00000000..a4c24bf8 --- /dev/null +++ b/backend/src/test/java/com/funeat/fixture/ImageFixture.java @@ -0,0 +1,12 @@ +package com.funeat.fixture; + +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@SuppressWarnings("NonAsciiCharacters") +public class ImageFixture { + + public static MultipartFile 이미지_생성() { + return new MockMultipartFile("image", "image.jpg", "image/jpeg", new byte[]{1, 2, 3}); + } +} diff --git a/backend/src/test/java/com/funeat/fixture/MemberFixture.java b/backend/src/test/java/com/funeat/fixture/MemberFixture.java new file mode 100644 index 00000000..9337594b --- /dev/null +++ b/backend/src/test/java/com/funeat/fixture/MemberFixture.java @@ -0,0 +1,19 @@ +package com.funeat.fixture; + +import com.funeat.member.domain.Member; + +@SuppressWarnings("NonAsciiCharacters") +public class MemberFixture { + + public static Member 멤버_멤버1_생성() { + return new Member("member1", "www.member1.com", "1"); + } + + public static Member 멤버_멤버2_생성() { + return new Member("member2", "www.member2.com", "2"); + } + + public static Member 멤버_멤버3_생성() { + return new Member("member3", "www.member3.com", "3"); + } +} diff --git a/backend/src/test/java/com/funeat/fixture/PageFixture.java b/backend/src/test/java/com/funeat/fixture/PageFixture.java new file mode 100644 index 00000000..6aab74a6 --- /dev/null +++ b/backend/src/test/java/com/funeat/fixture/PageFixture.java @@ -0,0 +1,78 @@ +package com.funeat.fixture; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; + +@SuppressWarnings("NonAsciiCharacters") +public class PageFixture { + + private static final String 평균_평점 = "averageRating"; + private static final String 가격 = "price"; + private static final String 좋아요 = "favoriteCount"; + private static final String 평점 = "rating"; + private static final String 생성_시간 = "createdAt"; + + public static PageRequest 페이지요청_기본_생성(final int page, final int size) { + return PageRequest.of(page, size); + } + + public static PageRequest 페이지요청_평균_평점_오름차순_생성(final int page, final int size) { + final var sort = Sort.by(평균_평점).ascending(); + + return PageRequest.of(page, size, sort); + } + + public static PageRequest 페이지요청_평균_평점_내림차순_생성(final int page, final int size) { + final var sort = Sort.by(평균_평점).descending(); + + return PageRequest.of(page, size, sort); + } + + public static PageRequest 페이지요청_가격_오름차순_생성(final int page, final int size) { + final var sort = Sort.by(가격).ascending(); + + return PageRequest.of(page, size, sort); + } + + public static PageRequest 페이지요청_가격_내림차순_생성(final int page, final int size) { + final var sort = Sort.by(가격).descending(); + + return PageRequest.of(page, size, sort); + } + + public static PageRequest 페이지요청_좋아요_오름차순_생성(final int page, final int size) { + final var sort = Sort.by(좋아요).ascending(); + + return PageRequest.of(page, size, sort); + } + + public static PageRequest 페이지요청_좋아요_내림차순_생성(final int page, final int size) { + final var sort = Sort.by(좋아요).descending(); + + return PageRequest.of(page, size, sort); + } + + public static PageRequest 페이지요청_평점_오름차순_생성(final int page, final int size) { + final var sort = Sort.by(평점).ascending(); + + return PageRequest.of(page, size, sort); + } + + public static PageRequest 페이지요청_평점_내림차순_생성(final int page, final int size) { + final var sort = Sort.by(평점).descending(); + + return PageRequest.of(page, size, sort); + } + + public static PageRequest 페이지요청_생성_시간_오름차순_생성(final int page, final int size) { + final var sort = Sort.by(생성_시간).ascending(); + + return PageRequest.of(page, size, sort); + } + + public static PageRequest 페이지요청_생성_시간_내림차순_생성(final int page, final int size) { + final var sort = Sort.by(생성_시간).descending(); + + return PageRequest.of(page, size, sort); + } +} diff --git a/backend/src/test/java/com/funeat/fixture/ProductFixture.java b/backend/src/test/java/com/funeat/fixture/ProductFixture.java new file mode 100644 index 00000000..14cef88a --- /dev/null +++ b/backend/src/test/java/com/funeat/fixture/ProductFixture.java @@ -0,0 +1,114 @@ +package com.funeat.fixture; + +import com.funeat.product.domain.Category; +import com.funeat.product.domain.Product; +import com.funeat.product.domain.ProductRecipe; +import com.funeat.recipe.domain.Recipe; + +@SuppressWarnings("NonAsciiCharacters") +public class ProductFixture { + + public static Product 상품_삼각김밥_가격1000원_평점1점_생성(final Category category) { + return new Product("삼각김밥", 1000L, "image.png", "맛있는 삼각김밥", 1.0, category); + } + + public static Product 상품_삼각김밥_가격1000원_평점2점_생성(final Category category) { + return new Product("삼각김밥", 1000L, "image.png", "맛있는 삼각김밥", 2.0, category); + } + + public static Product 상품_삼각김밥_가격1000원_평점3점_생성(final Category category) { + return new Product("삼각김밥", 1000L, "image.png", "맛있는 삼각김밥", 3.0, category); + } + + public static Product 상품_삼각김밥_가격1000원_평점4점_생성(final Category category) { + return new Product("삼각김밥", 1000L, "image.png", "맛있는 삼각김밥", 4.0, category); + } + + public static Product 상품_삼각김밥_가격1000원_평점5점_생성(final Category category) { + return new Product("삼각김밥", 1000L, "image.png", "맛있는 삼각김밥", 5.0, category); + } + + public static Product 상품_삼각김밥_가격2000원_평점1점_생성(final Category category) { + return new Product("삼각김밥", 2000L, "image.png", "맛있는 삼각김밥", 1.0, category); + } + + public static Product 상품_삼각김밥_가격2000원_평점2점_생성(final Category category) { + return new Product("삼각김밥", 2000L, "image.png", "맛있는 삼각김밥", 2.0, category); + } + + public static Product 상품_삼각김밥_가격2000원_평점3점_생성(final Category category) { + return new Product("삼각김밥", 2000L, "image.png", "맛있는 삼각김밥", 3.0, category); + } + + public static Product 상품_삼각김밥_가격2000원_평점4점_생성(final Category category) { + return new Product("삼각김밥", 2000L, "image.png", "맛있는 삼각김밥", 4.0, category); + } + + public static Product 상품_삼각김밥_가격2000원_평점5점_생성(final Category category) { + return new Product("삼각김밥", 2000L, "image.png", "맛있는 삼각김밥", 5.0, category); + } + + public static Product 상품_삼각김밥_가격3000원_평점1점_생성(final Category category) { + return new Product("삼각김밥", 3000L, "image.png", "맛있는 삼각김밥", 1.0, category); + } + + public static Product 상품_삼각김밥_가격3000원_평점2점_생성(final Category category) { + return new Product("삼각김밥", 3000L, "image.png", "맛있는 삼각김밥", 2.0, category); + } + + public static Product 상품_삼각김밥_가격3000원_평점3점_생성(final Category category) { + return new Product("삼각김밥", 3000L, "image.png", "맛있는 삼각김밥", 3.0, category); + } + + public static Product 상품_삼각김밥_가격3000원_평점4점_생성(final Category category) { + return new Product("삼각김밥", 3000L, "image.png", "맛있는 삼각김밥", 4.0, category); + } + + public static Product 상품_삼각김밥_가격3000원_평점5점_생성(final Category category) { + return new Product("삼각김밥", 3000L, "image.png", "맛있는 삼각김밥", 5.0, category); + } + + public static Product 상품_삼각김밥_가격4000원_평점1점_생성(final Category category) { + return new Product("삼각김밥", 4000L, "image.png", "맛있는 삼각김밥", 1.0, category); + } + + public static Product 상품_삼각김밥_가격4000원_평점2점_생성(final Category category) { + return new Product("삼각김밥", 4000L, "image.png", "맛있는 삼각김밥", 2.0, category); + } + + public static Product 상품_삼각김밥_가격4000원_평점3점_생성(final Category category) { + return new Product("삼각김밥", 4000L, "image.png", "맛있는 삼각김밥", 3.0, category); + } + + public static Product 상품_삼각김밥_가격4000원_평점4점_생성(final Category category) { + return new Product("삼각김밥", 4000L, "image.png", "맛있는 삼각김밥", 4.0, category); + } + + public static Product 상품_삼각김밥_가격4000원_평점5점_생성(final Category category) { + return new Product("삼각김밥", 4000L, "image.png", "맛있는 삼각김밥", 5.0, category); + } + + public static Product 상품_삼각김밥_가격5000원_평점1점_생성(final Category category) { + return new Product("삼각김밥", 5000L, "image.png", "맛있는 삼각김밥", 1.0, category); + } + + public static Product 상품_삼각김밥_가격5000원_평점2점_생성(final Category category) { + return new Product("삼각김밥", 5000L, "image.png", "맛있는 삼각김밥", 2.0, category); + } + + public static Product 상품_삼각김밥_가격5000원_평점3점_생성(final Category category) { + return new Product("삼각김밥", 5000L, "image.png", "맛있는 삼각김밥", 3.0, category); + } + + public static Product 상품_삼각김밥_가격5000원_평점4점_생성(final Category category) { + return new Product("삼각김밥", 5000L, "image.png", "맛있는 삼각김밥", 4.0, category); + } + + public static Product 상품_삼각김밥_가격5000원_평점5점_생성(final Category category) { + return new Product("삼각김밥", 5000L, "image.png", "맛있는 삼각김밥", 5.0, category); + } + + public static ProductRecipe 레시피_안에_들어가는_상품_생성(final Product product, final Recipe recipe) { + return new ProductRecipe(product, recipe); + } +} diff --git a/backend/src/test/java/com/funeat/fixture/RecipeFixture.java b/backend/src/test/java/com/funeat/fixture/RecipeFixture.java new file mode 100644 index 00000000..a0c3babe --- /dev/null +++ b/backend/src/test/java/com/funeat/fixture/RecipeFixture.java @@ -0,0 +1,23 @@ +package com.funeat.fixture; + +import com.funeat.member.domain.Member; +import com.funeat.recipe.domain.Recipe; +import com.funeat.recipe.domain.RecipeImage; +import com.funeat.recipe.dto.RecipeCreateRequest; +import java.util.List; + +@SuppressWarnings("NonAsciiCharacters") +public class RecipeFixture { + + public static Recipe 레시피_생성(final Member member) { + return new Recipe("제일로 맛있는 레시피", "밥 추가, 밥 추가, 밥 추가.. 끝!!", member); + } + + public static RecipeCreateRequest 레시피추가요청_생성(final List productIds) { + return new RecipeCreateRequest("제일로 맛있는 레시피", productIds, "밥 추가, 밥 추가, 밥 추가.. 끝!!"); + } + + public static RecipeImage 레시피이미지_생성(final Recipe recipe) { + return new RecipeImage("제일로 맛없는 사진", recipe); + } +} diff --git a/backend/src/test/java/com/funeat/fixture/ReviewFixture.java b/backend/src/test/java/com/funeat/fixture/ReviewFixture.java new file mode 100644 index 00000000..4dbb4a96 --- /dev/null +++ b/backend/src/test/java/com/funeat/fixture/ReviewFixture.java @@ -0,0 +1,68 @@ +package com.funeat.fixture; + +import com.funeat.member.domain.Member; +import com.funeat.product.domain.Product; +import com.funeat.review.domain.Review; +import com.funeat.review.presentation.dto.ReviewCreateRequest; +import com.funeat.review.presentation.dto.ReviewFavoriteRequest; +import java.util.List; + +@SuppressWarnings("NonAsciiCharacters") +public class ReviewFixture { + + public static Review 리뷰_이미지test1_평점1점_재구매O_생성(final Member member, final Product product, final Long count) { + return new Review(member, product, "test1", 1L, "test", true, count); + } + + public static Review 리뷰_이미지test1_평점1점_재구매X_생성(final Member member, final Product product, final Long count) { + return new Review(member, product, "test1", 1L, "test", false, count); + } + + public static Review 리뷰_이미지test2_평점2점_재구매O_생성(final Member member, final Product product, final Long count) { + return new Review(member, product, "test2", 2L, "test", true, count); + } + + public static Review 리뷰_이미지test2_평점2점_재구매X_생성(final Member member, final Product product, final Long count) { + return new Review(member, product, "test2", 2L, "test", false, count); + } + + public static Review 리뷰_이미지test3_평점3점_재구매O_생성(final Member member, final Product product, final Long count) { + return new Review(member, product, "test3", 3L, "test", true, count); + } + + public static Review 리뷰_이미지test3_평점3점_재구매X_생성(final Member member, final Product product, final Long count) { + return new Review(member, product, "test3", 3L, "test", false, count); + } + + public static Review 리뷰_이미지test4_평점4점_재구매O_생성(final Member member, final Product product, final Long count) { + return new Review(member, product, "test4", 4L, "test", true, count); + } + + public static Review 리뷰_이미지test4_평점4점_재구매X_생성(final Member member, final Product product, final Long count) { + return new Review(member, product, "test4", 4L, "test", false, count); + } + + public static Review 리뷰_이미지test5_평점5점_재구매O_생성(final Member member, final Product product, final Long count) { + return new Review(member, product, "test5", 5L, "test", true, count); + } + + public static Review 리뷰_이미지test5_평점5점_재구매X_생성(final Member member, final Product product, final Long count) { + return new Review(member, product, "test5", 5L, "test", false, count); + } + + public static ReviewCreateRequest 리뷰추가요청_재구매O_생성(final Long rating, final List tagIds) { + return new ReviewCreateRequest(rating, tagIds, "test", true); + } + + public static ReviewCreateRequest 리뷰추가요청_재구매X_생성(final Long rating, final List tagIds) { + return new ReviewCreateRequest(rating, tagIds, "test", false); + } + + public static ReviewFavoriteRequest 리뷰좋아요요청_true_생성() { + return new ReviewFavoriteRequest(true); + } + + public static ReviewFavoriteRequest 리뷰좋아요요청_false_생성() { + return new ReviewFavoriteRequest(false); + } +} diff --git a/backend/src/test/java/com/funeat/fixture/TagFixture.java b/backend/src/test/java/com/funeat/fixture/TagFixture.java new file mode 100644 index 00000000..396b9f8e --- /dev/null +++ b/backend/src/test/java/com/funeat/fixture/TagFixture.java @@ -0,0 +1,35 @@ +package com.funeat.fixture; + +import static com.funeat.tag.domain.TagType.ETC; +import static com.funeat.tag.domain.TagType.PRICE; +import static com.funeat.tag.domain.TagType.TASTE; + +import com.funeat.tag.domain.Tag; + +@SuppressWarnings("NonAsciiCharacters") +public class TagFixture { + + public static Tag 태그_맛있어요_TASTE_생성() { + return new Tag("맛있어요", TASTE); + } + + public static Tag 태그_단짠단짠_TASTE_생성() { + return new Tag("단짠딴짠", TASTE); + } + + public static Tag 태그_갓성비_PRICE_생성() { + return new Tag("갓성비", PRICE); + } + + public static Tag 태그_푸짐해요_PRICE_생성() { + return new Tag("푸짐해요", PRICE); + } + + public static Tag 태그_간식_ETC_생성() { + return new Tag("간식", ETC); + } + + public static Tag 태그_아침식사_ETC_생성() { + return new Tag("아침식사", ETC); + } +} diff --git a/backend/src/test/java/com/funeat/member/application/MemberServiceTest.java b/backend/src/test/java/com/funeat/member/application/MemberServiceTest.java new file mode 100644 index 00000000..d9c36eb8 --- /dev/null +++ b/backend/src/test/java/com/funeat/member/application/MemberServiceTest.java @@ -0,0 +1,287 @@ +package com.funeat.member.application; + +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.funeat.auth.dto.UserInfoDto; +import com.funeat.common.ServiceTest; +import com.funeat.member.domain.Member; +import com.funeat.member.dto.MemberProfileResponse; +import com.funeat.member.dto.MemberRequest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +class MemberServiceTest extends ServiceTest { + + @Nested + class findOrCreateMember_성공_테스트 { + + @Test + void 이미_가입된_사용자면_가입하지_않고_반환한다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var userInfoDto = new UserInfoDto(1L, "test", "www.test.com"); + + final var expected = memberRepository.findAll(); + + // when + final var actual = memberService.findOrCreateMember(userInfoDto); + + // then + assertSoftly(softAssertions -> { + softAssertions.assertThat(actual.isSignUp()) + .isFalse(); + softAssertions.assertThat(expected) + .containsExactly(actual.getMember()); + }); + } + + @Test + void 가입되지_않은_사용자면_가입하고_반환하다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var userInfoDto = new UserInfoDto(2L, "test", "www.test.com"); + + final var expected = memberRepository.findAll(); + + // when + final var actual = memberService.findOrCreateMember(userInfoDto); + + // then + assertSoftly(softAssertions -> { + softAssertions.assertThat(actual.isSignUp()) + .isTrue(); + softAssertions.assertThat(expected) + .doesNotContain(actual.getMember()); + }); + } + } + + @Nested + class findOrCreateMember_실패_테스트 { + + @Test + void platformId가_null이면_예외가_발생한다() { + // given + final var userInfoDto = new UserInfoDto(null, "test", "www.test.com"); + + // when & then + assertThatThrownBy(() -> memberService.findOrCreateMember(userInfoDto)) + .isInstanceOf(NullPointerException.class); + } + } + + @Nested + class getMemberProfile_성공_테스트 { + + @Test + void 사용자_정보를_확인하다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var expected = MemberProfileResponse.toResponse(member); + + // when + final var actual = memberService.getMemberProfile(memberId); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } + + @Nested + class getMemberProfile_실패_테스트 { + + @Test + void 존재하지_않는_사용자_정보를_조회하면_예외가_발생한다() { + // given + final var member = 멤버_멤버1_생성(); + final var wrongMemberId = 단일_멤버_저장(member) + 1L; + + // when & then + assertThatThrownBy(() -> memberService.getMemberProfile(wrongMemberId)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + class modify_성공_테스트 { + + @Test + void 닉네임과_프로필_사진이_그대로면_사용자_정보는_바뀌지_않는다() { + // given + final var nickname = "member1"; + final var profileImage = "www.member1.com"; + final var request = new MemberRequest(nickname, profileImage); + + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var expected = memberRepository.findById(memberId).get(); + final var expectedNickname = expected.getNickname(); + final var expectedProfileImage = expected.getProfileImage(); + + // when + memberService.modify(memberId, request); + final var actual = memberRepository.findById(memberId).get(); + final var actualNickname = actual.getNickname(); + final var actualProfileImage = actual.getProfileImage(); + + // then + assertSoftly(softAssertions -> { + softAssertions.assertThat(actualNickname) + .isEqualTo(expectedNickname); + softAssertions.assertThat(actualProfileImage) + .isEqualTo(expectedProfileImage); + }); + } + + @Test + void 닉네임만_바뀌고_프로필_사진은_그대로면_닉네임만_바뀐다() { + // given + final var profileImage = "www.member1.com"; + final var afterNickname = "after"; + final var request = new MemberRequest(afterNickname, profileImage); + + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var expected = memberRepository.findById(memberId).get(); + final var expectedNickname = expected.getNickname(); + final var expectedProfileImage = expected.getProfileImage(); + + // when + memberService.modify(memberId, request); + final var actual = memberRepository.findById(memberId).get(); + final var actualNickname = actual.getNickname(); + final var actualProfileImage = actual.getProfileImage(); + + // then + assertSoftly(softAssertions -> { + softAssertions.assertThat(actualNickname) + .isNotEqualTo(expectedNickname); + softAssertions.assertThat(actualProfileImage) + .isEqualTo(expectedProfileImage); + }); + } + + @Test + void 닉네임은_그대로이고_프로필_사진이_바뀌면_프로필_사진만_바뀐다() { + // given + final var nickname = "member1"; + final var afterProfileImage = "www.after.com"; + + final var request = new MemberRequest(nickname, afterProfileImage); + + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var expected = memberRepository.findById(memberId).get(); + final var expectedNickname = expected.getNickname(); + final var expectedProfileImage = expected.getProfileImage(); + + // when + memberService.modify(memberId, request); + final var actual = memberRepository.findById(memberId).get(); + final var actualNickname = actual.getNickname(); + final var actualProfileImage = actual.getProfileImage(); + + // then + assertSoftly(softAssertions -> { + softAssertions.assertThat(actualNickname) + .isEqualTo(expectedNickname); + softAssertions.assertThat(actualProfileImage) + .isNotEqualTo(expectedProfileImage); + }); + } + + @Test + void 닉네임과_프로필_사진_모두_바뀌면_모두_바뀐다() { + // given + final var afterNickname = "after"; + final var afterProfileImage = "http://www.after.com"; + + final var request = new MemberRequest(afterNickname, afterProfileImage); + + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var expected = memberRepository.findById(memberId).get(); + final var expectedNickname = expected.getNickname(); + final var expectedProfileImage = expected.getProfileImage(); + + // when + memberService.modify(memberId, request); + final var actual = memberRepository.findById(memberId).get(); + final var actualNickname = actual.getNickname(); + final var actualProfileImage = actual.getProfileImage(); + + // then + assertSoftly(softAssertions -> { + softAssertions.assertThat(actualNickname) + .isNotEqualTo(expectedNickname); + softAssertions.assertThat(actualProfileImage) + .isNotEqualTo(expectedProfileImage); + }); + } + } + + @Nested + class modify_실패_테스트 { + + @Test + void 존재하지않는_멤버를_수정하면_예외가_발생한다() { + // given + final var afterNickname = "after"; + final var afterProfileImage = "www.after.com"; + + final var member = 멤버_멤버1_생성(); + final var wrongMemberId = 단일_멤버_저장(member) + 1L; + + final var request = new MemberRequest(afterNickname, afterProfileImage); + + // when + assertThatThrownBy(() -> memberService.modify(wrongMemberId, request)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 닉네임_수정_요청_값을_null로_설정하면_예외가_발생해야_하지만_통과하고_있다() { + // given + final var nickname = "test"; + final var beforeProfileImage = "www.before.com"; + final var afterProfileImage = "www.after.com"; + + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var request = new MemberRequest(null, afterProfileImage); + + // when & then + } + + @Test + void 프로필_이미지_요청_값을_null로_설정하면_예외가_발생해야_하지만_통과하고_있다() { + // given + final var beforeNickname = "before"; + final var afterNickname = "after"; + final var profileImage = "www.test.com"; + + final var member = new Member(beforeNickname, profileImage, "1"); + final var memberId = 단일_멤버_저장(member); + + final var request = new MemberRequest(afterNickname, null); + + // when & then + } + } +} diff --git a/backend/src/test/java/com/funeat/member/application/TestMemberService.java b/backend/src/test/java/com/funeat/member/application/TestMemberService.java new file mode 100644 index 00000000..705b5258 --- /dev/null +++ b/backend/src/test/java/com/funeat/member/application/TestMemberService.java @@ -0,0 +1,21 @@ +package com.funeat.member.application; + +import com.funeat.auth.dto.SignUserDto; +import com.funeat.auth.dto.UserInfoDto; +import com.funeat.member.persistence.MemberRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class TestMemberService extends MemberService { + + public TestMemberService(final MemberRepository memberRepository) { + super(memberRepository); + } + + @Override + @Transactional + public SignUserDto findOrCreateMember(final UserInfoDto userInfoDto) { + return super.findOrCreateMember(userInfoDto); + } +} diff --git a/backend/src/test/java/com/funeat/member/domain/MemberTest.java b/backend/src/test/java/com/funeat/member/domain/MemberTest.java new file mode 100644 index 00000000..8fd04f70 --- /dev/null +++ b/backend/src/test/java/com/funeat/member/domain/MemberTest.java @@ -0,0 +1,73 @@ +package com.funeat.member.domain; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +public class MemberTest { + + @Nested + class modifyProfile_성공_테스트 { + + @Test + void 사용자의_닉네임과_이미지_주소를_변경할_수_있다() { + // given + final var member = new Member("before", "http://www.before.com", "1"); + + final var expectedNickname = "after"; + final var expectedProfileImage = "http://www.after.com"; + + // when + member.modifyProfile(expectedNickname, expectedProfileImage); + final var actualNickname = member.getNickname(); + final var actualProfileImage = member.getProfileImage(); + + // then + assertSoftly(softAssertions -> { + softAssertions.assertThat(actualNickname) + .isEqualTo(expectedNickname); + softAssertions.assertThat(actualProfileImage) + .isEqualTo(expectedProfileImage); + }); + } + } + + @Nested + class modifyProfile_실패_테스트 { + + @Test + void 사용자의_닉네임_변경_값이_null이면_예외를_발생해야_하지만_통과하고_있다() { + // given + final var member = new Member("test", "http://www.before.com", "1"); + + final var expectedProfileImage = "http://www.after.com"; + + // when + member.modifyProfile(null, expectedProfileImage); + final var actualNickname = member.getNickname(); + final var actualProfileImage = member.getProfileImage(); + + // then + } + + @Test + void 사용자의_프로필_이미지_변경_값이_null이면_예외를_발생해야_하지만_통과하고_있다() { + // given + final var member = new Member("test", "http://www.before.com", "1"); + + final var expectedNickname = "after"; + + // when + member.modifyProfile(expectedNickname, null); + final var actualNickname = member.getNickname(); + final var actualProfileImage = member.getProfileImage(); + + // then + } + } +} diff --git a/backend/src/test/java/com/funeat/member/persistence/MemberRepositoryTest.java b/backend/src/test/java/com/funeat/member/persistence/MemberRepositoryTest.java new file mode 100644 index 00000000..21b89013 --- /dev/null +++ b/backend/src/test/java/com/funeat/member/persistence/MemberRepositoryTest.java @@ -0,0 +1,50 @@ +package com.funeat.member.persistence; + +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.funeat.common.RepositoryTest; +import java.util.NoSuchElementException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +public class MemberRepositoryTest extends RepositoryTest { + + @Nested + class findByPlatformId_성공_테스트 { + + @Test + void platformId를_통해_멤버를_반환한다() { + // given + final var platformId = "1"; + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + // when + final var actual = memberRepository.findByPlatformId(platformId).get(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(member); + } + } + + @Nested + class findByPlatformId_실패_테스트 { + + @Test + void platform_id가_잘못된_값으로_멤버를_조회할_때_예외가_발생한다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var wrongPlatformId = "2"; + + // when & then + assertThatThrownBy(() -> memberRepository.findByPlatformId(wrongPlatformId).get()) + .isInstanceOf(NoSuchElementException.class); + } + } +} diff --git a/backend/src/test/java/com/funeat/member/persistence/RecipeFavoriteRepositoryTest.java b/backend/src/test/java/com/funeat/member/persistence/RecipeFavoriteRepositoryTest.java new file mode 100644 index 00000000..3174f077 --- /dev/null +++ b/backend/src/test/java/com/funeat/member/persistence/RecipeFavoriteRepositoryTest.java @@ -0,0 +1,101 @@ +package com.funeat.member.persistence; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.funeat.common.DataCleaner; +import com.funeat.common.DataClearExtension; +import com.funeat.member.domain.Member; +import com.funeat.member.domain.favorite.RecipeFavorite; +import com.funeat.product.domain.Category; +import com.funeat.product.domain.CategoryType; +import com.funeat.product.domain.Product; +import com.funeat.product.domain.ProductRecipe; +import com.funeat.product.persistence.CategoryRepository; +import com.funeat.product.persistence.ProductRecipeRepository; +import com.funeat.product.persistence.ProductRepository; +import com.funeat.recipe.domain.Recipe; +import com.funeat.recipe.persistence.RecipeRepository; +import java.util.List; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +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.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +@DataJpaTest +@Import(DataCleaner.class) +@ExtendWith(DataClearExtension.class) +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class RecipeFavoriteRepositoryTest { + + @Autowired + private CategoryRepository categoryRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RecipeRepository recipeRepository; + + @Autowired + private ProductRecipeRepository productRecipeRepository; + + @Autowired + private RecipeFavoriteRepository recipeFavoriteRepository; + + @Test + void 사용자의_레시피에_대한_좋아요_현황을_알_수_있다() { + // given + final var category = 카테고리_추가_요청(new Category("간편식사", CategoryType.FOOD)); + final var product1 = 상품_추가_요청(new Product("불닭볶음면", 1000L, "image.png", "엄청 매운 불닭", category)); + final var product2 = 상품_추가_요청(new Product("참치 삼김", 2000L, "image.png", "담백한 참치마요 삼김", category)); + final var product3 = 상품_추가_요청(new Product("스트링 치즈", 1500L, "image.png", "고소한 치즈", category)); + final var recipeAuthor = 멤버_추가_요청(new Member("author", "image.png", "1")); + final var products = List.of(product1, product2, product3); + + final var recipe = 레시피_추가_요청(new Recipe("레시피1", "밥 넣고 밥 넣자", recipeAuthor)); + products.forEach(it -> 레시피에_사용된_상품_추가_요청(new ProductRecipe(it, recipe))); + + // when + final var realMember = 멤버_추가_요청(new Member("real", "image.png", "2")); + final var fakeMember = 멤버_추가_요청(new Member("fake", "image.png", "3")); + 레시피_좋아요_요청(new RecipeFavorite(realMember, recipe, true)); + + final var realMemberActual = recipeFavoriteRepository.existsByMemberAndRecipeAndFavoriteTrue(realMember, recipe); + final var fakeMemberActual = recipeFavoriteRepository.existsByMemberAndRecipeAndFavoriteTrue(fakeMember, recipe); + + // then + assertThat(realMemberActual).isTrue(); + assertThat(fakeMemberActual).isFalse(); + } + + private Category 카테고리_추가_요청(final Category category) { + return categoryRepository.save(category); + } + + private Product 상품_추가_요청(final Product product) { + return productRepository.save(product); + } + + private Member 멤버_추가_요청(final Member member) { + return memberRepository.save(member); + } + + private Recipe 레시피_추가_요청(final Recipe recipe) { + return recipeRepository.save(recipe); + } + + private ProductRecipe 레시피에_사용된_상품_추가_요청(final ProductRecipe productRecipe) { + return productRecipeRepository.save(productRecipe); + } + + private RecipeFavorite 레시피_좋아요_요청(final RecipeFavorite recipeFavorite) { + return recipeFavoriteRepository.save(recipeFavorite); + } +} diff --git a/backend/src/test/java/com/funeat/member/persistence/ReviewFavoriteRepositoryTest.java b/backend/src/test/java/com/funeat/member/persistence/ReviewFavoriteRepositoryTest.java new file mode 100644 index 00000000..e0a8fbc1 --- /dev/null +++ b/backend/src/test/java/com/funeat/member/persistence/ReviewFavoriteRepositoryTest.java @@ -0,0 +1,109 @@ +package com.funeat.member.persistence; + +import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매O_생성; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.funeat.common.RepositoryTest; +import com.funeat.member.domain.favorite.ReviewFavorite; +import java.util.NoSuchElementException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +class ReviewFavoriteRepositoryTest extends RepositoryTest { + + @Nested + class findByMemberAndReview_성공_테스트 { + + @Test + void 멤버와_리뷰로_리뷰_좋아요를_조회할_수_있다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + 단일_상품_저장(product); + + final var review = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 0L); + 단일_리뷰_저장(review); + + final var reviewFavorite = ReviewFavorite.createReviewFavoriteByMemberAndReview(member, review, true); + 단일_리뷰_좋아요_저장(reviewFavorite); + + final var expected = ReviewFavorite.createReviewFavoriteByMemberAndReview(member, review, true); + + // when + final var actual = reviewFavoriteRepository.findByMemberAndReview(member, review).get(); + + // then + assertThat(actual).usingRecursiveComparison() + .ignoringExpectedNullFields() + .isEqualTo(expected); + } + } + + @Nested + class findByMemberAndReview_실패_테스트 { + + @Test + void 잘못된_멤버로_좋아요를_조회하면_에러가_발생한다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + 단일_상품_저장(product); + + final var review = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 0L); + 단일_리뷰_저장(review); + + final var wrongMember = 멤버_멤버2_생성(); + 단일_멤버_저장(wrongMember); + + // when & then + assertThatThrownBy(() -> reviewFavoriteRepository.findByMemberAndReview(wrongMember, review).get()) + .isInstanceOf(NoSuchElementException.class); + } + + @Test + void 잘못된_리뷰로_좋아요를_조회하면_에러가_발생한다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var anotherMember = 멤버_멤버2_생성(); + 단일_멤버_저장(anotherMember); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + 단일_상품_저장(product); + + final var review = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 0L); + 단일_리뷰_저장(review); + + final var reviewFavorite = ReviewFavorite.createReviewFavoriteByMemberAndReview(member, review, true); + 단일_리뷰_좋아요_저장(reviewFavorite); + + final var wrongReview = 리뷰_이미지test5_평점5점_재구매O_생성(member, product, 0L); + 단일_리뷰_저장(wrongReview); + + // when & then + assertThatThrownBy(() -> reviewFavoriteRepository.findByMemberAndReview(member, wrongReview).get()) + .isInstanceOf(NoSuchElementException.class); + } + } +} diff --git a/backend/src/test/java/com/funeat/product/application/ProductServiceTest.java b/backend/src/test/java/com/funeat/product/application/ProductServiceTest.java new file mode 100644 index 00000000..5aa812dc --- /dev/null +++ b/backend/src/test/java/com/funeat/product/application/ProductServiceTest.java @@ -0,0 +1,204 @@ +package com.funeat.product.application; + +import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버3_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점4점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점5점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점1점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점4점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점5점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격4000원_평점4점_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test1_평점1점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test2_평점2점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매X_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매X_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매X_생성; +import static org.assertj.core.api.Assertions.assertThat; + +import com.funeat.common.ServiceTest; +import com.funeat.product.dto.RankingProductDto; +import com.funeat.product.dto.RankingProductsResponse; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +class ProductServiceTest extends ServiceTest { + + @Nested + class getTop3Products_성공_테스트 { + + @Nested + class 상품_개수에_대한_테스트 { + + @Test + void 전체_상품이_하나도_없어도_반환값은_있어야한다() { + // given + final var expected = RankingProductsResponse.toResponse(Collections.emptyList()); + + // when + final var actual = productService.getTop3Products(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 전체_상품이_1개_이상_3개_미만이라도_상품이_나와야한다() { + // given + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점4점_생성(category); + final var product2 = 상품_삼각김밥_가격2000원_평점5점_생성(category); + 복수_상품_저장(product1, product2); + + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var review1_1 = 리뷰_이미지test5_평점5점_재구매O_생성(member, product1, 0L); + final var review1_2 = 리뷰_이미지test3_평점3점_재구매O_생성(member, product1, 0L); + final var review1_3 = 리뷰_이미지test3_평점3점_재구매O_생성(member, product1, 0L); + final var review1_4 = 리뷰_이미지test3_평점3점_재구매O_생성(member, product1, 0L); + final var review2_1 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product2, 0L); + final var review2_2 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product2, 0L); + 복수_리뷰_저장(review1_1, review1_2, review1_3, review1_4, review2_1, review2_2); + + final var rankingProductDto1 = RankingProductDto.toDto(product2); + final var rankingProductDto2 = RankingProductDto.toDto(product1); + final var rankingProductDtos = List.of(rankingProductDto1, rankingProductDto2); + final var expected = RankingProductsResponse.toResponse(rankingProductDtos); + + // when + final var actual = productService.getTop3Products(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 전체_상품_중_랭킹이_높은_상위_3개_상품을_구할_수_있다() { + // given + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var product2 = 상품_삼각김밥_가격2000원_평점4점_생성(category); + final var product3 = 상품_삼각김밥_가격1000원_평점5점_생성(category); + final var product4 = 상품_삼각김밥_가격4000원_평점4점_생성(category); + 복수_상품_저장(product1, product2, product3, product4); + + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var review1_1 = 리뷰_이미지test5_평점5점_재구매O_생성(member1, product1, 0L); + final var review1_2 = 리뷰_이미지test3_평점3점_재구매O_생성(member2, product1, 0L); + final var review1_3 = 리뷰_이미지test3_평점3점_재구매O_생성(member3, product1, 0L); + final var review1_4 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product1, 0L); + final var review2_1 = 리뷰_이미지test4_평점4점_재구매O_생성(member1, product2, 0L); + final var review2_2 = 리뷰_이미지test4_평점4점_재구매O_생성(member1, product2, 0L); + final var review3_1 = 리뷰_이미지test5_평점5점_재구매X_생성(member1, product2, 0L); + final var review4_1 = 리뷰_이미지test4_평점4점_재구매X_생성(member1, product2, 0L); + final var review4_2 = 리뷰_이미지test3_평점3점_재구매X_생성(member1, product2, 0L); + final var review4_3 = 리뷰_이미지test5_평점5점_재구매X_생성(member1, product2, 0L); + 복수_리뷰_저장(review1_1, review1_2, review1_3, review1_4, review2_1, review2_2, review3_1, review4_1, + review4_2, review4_3); + + final var rankingProductDto1 = RankingProductDto.toDto(product2); + final var rankingProductDto2 = RankingProductDto.toDto(product3); + final var rankingProductDto3 = RankingProductDto.toDto(product4); + final var rankingProductDtos = List.of(rankingProductDto1, rankingProductDto2, rankingProductDto3); + final var expected = RankingProductsResponse.toResponse(rankingProductDtos); + + // when + final var actual = productService.getTop3Products(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } + + @Nested + class 상품_점수에_대한_테스트 { + + @Test + void 모든_상품의_평점이_3점_미만이면_빈_배열을_반환한다() { + // given + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product2 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + 복수_상품_저장(product1, product2); + + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var review1_1 = 리뷰_이미지test1_평점1점_재구매O_생성(member1, product1, 0L); + final var review1_2 = 리뷰_이미지test2_평점2점_재구매O_생성(member2, product1, 0L); + final var review1_3 = 리뷰_이미지test3_평점3점_재구매O_생성(member3, product1, 0L); + final var review2_1 = 리뷰_이미지test1_평점1점_재구매O_생성(member1, product2, 0L); + final var review2_2 = 리뷰_이미지test2_평점2점_재구매O_생성(member2, product2, 0L); + 복수_리뷰_저장(review1_1, review1_2, review1_3, review2_1, review2_2); + + final var expected = RankingProductsResponse.toResponse(Collections.emptyList()); + + // when + final var actual = productService.getTop3Products(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 일부_상품이_평점_3점_이상이면_일부_상품만_반환한다() { + // given + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product2 = 상품_삼각김밥_가격2000원_평점4점_생성(category); + 복수_상품_저장(product1, product2); + + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var review1_1 = 리뷰_이미지test1_평점1점_재구매O_생성(member1, product1, 0L); + final var review1_2 = 리뷰_이미지test2_평점2점_재구매O_생성(member2, product1, 0L); + final var review1_3 = 리뷰_이미지test3_평점3점_재구매O_생성(member3, product1, 0L); + final var review2_1 = 리뷰_이미지test4_평점4점_재구매X_생성(member3, product2, 0L); + final var review2_2 = 리뷰_이미지test5_평점5점_재구매X_생성(member3, product2, 0L); + 복수_리뷰_저장(review1_1, review1_2, review1_3, review2_1, review2_2); + + final var rankingProductDtos = List.of(RankingProductDto.toDto(product2)); + + final var expected = RankingProductsResponse.toResponse(rankingProductDtos); + + // when + final var actual = productService.getTop3Products(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } + } +} diff --git a/backend/src/test/java/com/funeat/product/domain/ProductTest.java b/backend/src/test/java/com/funeat/product/domain/ProductTest.java new file mode 100644 index 00000000..bee40ecc --- /dev/null +++ b/backend/src/test/java/com/funeat/product/domain/ProductTest.java @@ -0,0 +1,130 @@ +package com.funeat.product.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class ProductTest { + + @Nested + class updateAverageRating_성공_테스트 { + + @Test + void 평균_평점을_업데이트_할_수_있다() { + // given + final var product = new Product("testName", 1000L, "testImage", "testContent", null); + final var reviewRating = 4L; + final var reviewCount = 1L; + + // when + product.updateAverageRating(reviewRating, reviewCount); + final var actual = product.getAverageRating(); + + // then + assertThat(actual).isEqualTo(4.0); + } + + @Test + void 평균_평점을_여러번_업데이트_할_수_있다() { + // given + final var product = new Product("testName", 1000L, "testImage", "testContent", null); + final var reviewRating1 = 4L; + final var reviewRating2 = 2L; + final var reviewCount1 = 1L; + final var reviewCount2 = 2L; + + // when + product.updateAverageRating(reviewRating1, reviewCount1); + final var actual1 = product.getAverageRating(); + + product.updateAverageRating(reviewRating2, reviewCount2); + final var actual2 = product.getAverageRating(); + + // then + assertSoftly(softAssertions -> { + softAssertions.assertThat(actual1) + .isEqualTo(4.0); + softAssertions.assertThat(actual2) + .isEqualTo(3.0); + }); + } + } + + @Nested + class updateAverageRating_실패_테스트 { + + @Test + void 리뷰_평점에_null_값이_들어오면_예외가_발생한다() { + // given + final var product = new Product("testName", 1000L, "testImage", "testContent", null); + final var reviewCount = 1L; + + // when + assertThatThrownBy(() -> product.updateAverageRating(null, reviewCount)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void 리뷰_평점이_0점이라면_예외가_발생해야하는데_관련_로직이_없어_통과하고_있다() { + // given + final var product = new Product("testName", 1000L, "testImage", "testContent", null); + final var reviewRating = 0L; + final var reviewCount = 1L; + + // when + product.updateAverageRating(reviewRating, reviewCount); + } + + @Test + void 리뷰_개수가_0개라면_예외가_발생해야하는데_calculatedRating값이_infinity가_나와_통과하고_있다() { + // given + final var product = new Product("testName", 1000L, "testImage", "testContent", null); + final var reviewRating = 3L; + final var reviewCount = 0L; + + // when + product.updateAverageRating(reviewRating, reviewCount); + } + } + + @Nested + class calculateRankingScore_성공_테스트 { + + @Test + void 평균_평점과_리뷰_수로_해당_상품의_랭킹_점수를_구할_수_있다() { + // given + final var product = new Product("testName", 1000L, "testImage", "testContent", 4.0, null); + final var reviewCount = 9L; + + // when + final var rankingScore = product.calculateRankingScore(reviewCount); + + // then + assertThat(rankingScore).isEqualTo(3.5); + } + } + + @Nested + class calculateRankingScore_실패_테스트 { + + @Test + void 리뷰_수가_마이너스_일로_나온다면_무한대로_나와서_예외가_나와야하는데_통과하고_있다() { + // given + final var product = new Product("testName", 1000L, "testImage", "testContent", 4.0, null); + final var reviewCount = -1L; + + // when + final var rankingScore = product.calculateRankingScore(reviewCount); + + // then + assertThat(rankingScore).isInfinite(); + } + } +} diff --git a/backend/src/test/java/com/funeat/product/domain/favorite/ReviewFavoriteTest.java b/backend/src/test/java/com/funeat/product/domain/favorite/ReviewFavoriteTest.java new file mode 100644 index 00000000..c180cbfe --- /dev/null +++ b/backend/src/test/java/com/funeat/product/domain/favorite/ReviewFavoriteTest.java @@ -0,0 +1,98 @@ +package com.funeat.product.domain.favorite; + +import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점1점_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test1_평점1점_재구매O_생성; +import static org.assertj.core.api.Assertions.assertThat; + +import com.funeat.member.domain.favorite.ReviewFavorite; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +public class ReviewFavoriteTest { + + @Nested + class updateChecked_성공_테스트 { + + @Test + void 좋아요가_있는_상태에서_좋아요_취소한다() { + // given + final var category = 카테고리_즉석조리_생성(); + final var product = 상품_삼각김밥_가격1000원_평점1점_생성(category); + + final var member = 멤버_멤버1_생성(); + final var review = 리뷰_이미지test1_평점1점_재구매O_생성(member, product, 0L); + final var reviewFavorite = ReviewFavorite.createReviewFavoriteByMemberAndReview(member, review, true); + + // when + reviewFavorite.updateChecked(false); + final var actual = reviewFavorite.getFavorite(); + + // then + assertThat(actual).isFalse(); + } + + @Test + void 좋아요가_없는_상태에서_좋아요를_적용한다() { + // given + final var category = 카테고리_즉석조리_생성(); + final var product = 상품_삼각김밥_가격1000원_평점1점_생성(category); + + final var member = 멤버_멤버1_생성(); + final var review = 리뷰_이미지test1_평점1점_재구매O_생성(member, product, 0L); + final var reviewFavorite = ReviewFavorite.createReviewFavoriteByMemberAndReview(member, review, false); + + // when + reviewFavorite.updateChecked(true); + final var actual = reviewFavorite.getFavorite(); + + // then + assertThat(actual).isTrue(); + } + } + + @Nested + class updateChecked_실패_테스트 { + + @Test + void 좋아요가_있는_상태에서_좋아요를_적용하면_예외가_발생해야하는데_통과하고_있다() { + // given + final var category = 카테고리_즉석조리_생성(); + final var product = 상품_삼각김밥_가격1000원_평점1점_생성(category); + + final var member = 멤버_멤버1_생성(); + final var review = 리뷰_이미지test1_평점1점_재구매O_생성(member, product, 0L); + final var reviewFavorite = ReviewFavorite.createReviewFavoriteByMemberAndReview(member, review, true); + + // when + reviewFavorite.updateChecked(true); + final var actual = reviewFavorite.getFavorite(); + + // then + assertThat(actual).isTrue(); + } + + @Test + void 좋아요가_없는_상태에서_좋아요를_취소하면_예외가_발생해야하는데_통과하고_있다() { + // given + final var category = 카테고리_즉석조리_생성(); + final var product = 상품_삼각김밥_가격1000원_평점1점_생성(category); + + final var member = 멤버_멤버1_생성(); + final var review = 리뷰_이미지test1_평점1점_재구매O_생성(member, product, 0L); + final var reviewFavorite = ReviewFavorite.createReviewFavoriteByMemberAndReview(member, review, false); + + // when + reviewFavorite.updateChecked(false); + final var actual = reviewFavorite.getFavorite(); + + // then + assertThat(actual).isFalse(); + } + } +} diff --git a/backend/src/test/java/com/funeat/product/persistence/CategoryRepositoryTest.java b/backend/src/test/java/com/funeat/product/persistence/CategoryRepositoryTest.java new file mode 100644 index 00000000..37ec9676 --- /dev/null +++ b/backend/src/test/java/com/funeat/product/persistence/CategoryRepositoryTest.java @@ -0,0 +1,74 @@ +package com.funeat.product.persistence; + +import static com.funeat.fixture.CategoryFixture.카테고리_CU_생성; +import static com.funeat.fixture.CategoryFixture.카테고리_EMART24_생성; +import static com.funeat.fixture.CategoryFixture.카테고리_GS25_생성; +import static com.funeat.fixture.CategoryFixture.카테고리_간편식사_생성; +import static com.funeat.fixture.CategoryFixture.카테고리_과자류_생성; +import static com.funeat.fixture.CategoryFixture.카테고리_식품_생성; +import static com.funeat.fixture.CategoryFixture.카테고리_아이스크림_생성; +import static com.funeat.fixture.CategoryFixture.카테고리_음료_생성; +import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성; +import static org.assertj.core.api.Assertions.assertThat; + +import com.funeat.common.RepositoryTest; +import com.funeat.product.domain.CategoryType; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +public class CategoryRepositoryTest extends RepositoryTest { + + @Nested + class findAllByType_성공_테스트 { + + @Test + void 카테고리_타입이_FOOD인_모든_카테고리를_조회한다() { + // given + final var 간편식사 = 카테고리_간편식사_생성(); + final var 즉석조리 = 카테고리_즉석조리_생성(); + final var 과자류 = 카테고리_과자류_생성(); + final var 아이스크림 = 카테고리_아이스크림_생성(); + final var 식품 = 카테고리_식품_생성(); + final var 음료 = 카테고리_음료_생성(); + final var CU = 카테고리_CU_생성(); + final var GS25 = 카테고리_GS25_생성(); + final var EMART24 = 카테고리_EMART24_생성(); + 복수_카테고리_저장(간편식사, 즉석조리, 과자류, 아이스크림, 식품, 음료, CU, GS25, EMART24); + + final var expected = List.of(간편식사, 즉석조리, 과자류, 아이스크림, 식품, 음료); + + // when + final var actual = categoryRepository.findAllByType(CategoryType.FOOD); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 카테고리_타입이_STORE인_모든_카테고리를_조회한다() { + // given + final var 간편식사 = 카테고리_간편식사_생성(); + final var 즉석조리 = 카테고리_즉석조리_생성(); + final var 과자류 = 카테고리_과자류_생성(); + final var 아이스크림 = 카테고리_아이스크림_생성(); + final var 식품 = 카테고리_식품_생성(); + final var 음료 = 카테고리_음료_생성(); + final var CU = 카테고리_CU_생성(); + final var GS25 = 카테고리_GS25_생성(); + final var EMART24 = 카테고리_EMART24_생성(); + 복수_카테고리_저장(간편식사, 즉석조리, 과자류, 아이스크림, 식품, 음료, CU, GS25, EMART24); + + final var expected = List.of(CU, GS25, EMART24); + + // when + final var actual = categoryRepository.findAllByType(CategoryType.STORE); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } +} diff --git a/backend/src/test/java/com/funeat/product/persistence/ProductRecipeRepositoryTest.java b/backend/src/test/java/com/funeat/product/persistence/ProductRecipeRepositoryTest.java new file mode 100644 index 00000000..5b926f38 --- /dev/null +++ b/backend/src/test/java/com/funeat/product/persistence/ProductRecipeRepositoryTest.java @@ -0,0 +1,89 @@ +package com.funeat.product.persistence; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.funeat.common.DataCleaner; +import com.funeat.common.DataClearExtension; +import com.funeat.member.domain.Member; +import com.funeat.member.persistence.MemberRepository; +import com.funeat.product.domain.Category; +import com.funeat.product.domain.CategoryType; +import com.funeat.product.domain.Product; +import com.funeat.product.domain.ProductRecipe; +import com.funeat.recipe.domain.Recipe; +import com.funeat.recipe.persistence.RecipeRepository; +import java.util.List; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +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.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +@DataJpaTest +@Import(DataCleaner.class) +@ExtendWith(DataClearExtension.class) +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class ProductRecipeRepositoryTest { + + @Autowired + private CategoryRepository categoryRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RecipeRepository recipeRepository; + + @Autowired + private ProductRecipeRepository productRecipeRepository; + + @Test + void 레시피에_사용된_상품들을_조회할_수_있다() { + // given + final var category = 카테고리_추가_요청(new Category("간편식사", CategoryType.FOOD)); + final var product1 = 상품_추가_요청(new Product("불닭볶음면", 1000L, "image.png", "엄청 매운 불닭", category)); + final var product2 = 상품_추가_요청(new Product("참치 삼김", 2000L, "image.png", "담백한 참치마요 삼김", category)); + final var product3 = 상품_추가_요청(new Product("스트링 치즈", 1500L, "image.png", "고소한 치즈", category)); + final var products = List.of(product1, product2, product3); + final var member = 멤버_추가_요청(new Member("test", "image.png", "1")); + + final var recipe = 레시피_추가_요청(new Recipe("레시피1", "밥 넣고 밥 넣자", member)); + 복수_레시피_상품_추가_요청(products, recipe); + final var expected = products; + + // when + final var actual = productRecipeRepository.findProductByRecipe(recipe); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + private Category 카테고리_추가_요청(final Category category) { + return categoryRepository.save(category); + } + + private Product 상품_추가_요청(final Product product) { + return productRepository.save(product); + } + + private Member 멤버_추가_요청(final Member member) { + return memberRepository.save(member); + } + + private Recipe 레시피_추가_요청(final Recipe recipe) { + return recipeRepository.save(recipe); + } + + private void 복수_레시피_상품_추가_요청(final List products, final Recipe recipe) { + for (Product product : products) { + productRecipeRepository.save(new ProductRecipe(product, recipe)); + } + } +} diff --git a/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java b/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java new file mode 100644 index 00000000..ff47ca00 --- /dev/null +++ b/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java @@ -0,0 +1,243 @@ +package com.funeat.product.persistence; + +import static com.funeat.fixture.CategoryFixture.카테고리_간편식사_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버3_생성; +import static com.funeat.fixture.PageFixture.페이지요청_가격_내림차순_생성; +import static com.funeat.fixture.PageFixture.페이지요청_가격_오름차순_생성; +import static com.funeat.fixture.PageFixture.페이지요청_기본_생성; +import static com.funeat.fixture.PageFixture.페이지요청_평균_평점_내림차순_생성; +import static com.funeat.fixture.PageFixture.페이지요청_평균_평점_오름차순_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점1점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점4점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점5점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점1점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점4점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_평점1점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_평점5점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격4000원_평점1점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격4000원_평점2점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격5000원_평점1점_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test1_평점1점_재구매X_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test2_평점2점_재구매X_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매X_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매O_생성; +import static org.assertj.core.api.Assertions.assertThat; + +import com.funeat.common.RepositoryTest; +import com.funeat.product.dto.ProductInCategoryDto; +import com.funeat.product.dto.ProductReviewCountDto; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +class ProductRepositoryTest extends RepositoryTest { + + @Nested + class findByAllCategory_성공_테스트 { + + @Test + void 카테고리별_상품을_평점이_높은_순으로_정렬한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product2 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product3 = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var product4 = 상품_삼각김밥_가격1000원_평점4점_생성(category); + final var product5 = 상품_삼각김밥_가격1000원_평점5점_생성(category); + 복수_상품_저장(product1, product2, product3, product4, product5); + + final var page = 페이지요청_평균_평점_내림차순_생성(0, 3); + + final var productInCategoryDto1 = ProductInCategoryDto.toDto(product5, 0L); + final var productInCategoryDto2 = ProductInCategoryDto.toDto(product4, 0L); + final var productInCategoryDto3 = ProductInCategoryDto.toDto(product3, 0L); + final var expected = List.of(productInCategoryDto1, productInCategoryDto2, productInCategoryDto3); + + // when + final var actual = productRepository.findAllByCategory(category, page).getContent(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 카테고리별_상품을_평점이_낮은_순으로_정렬한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product2 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product3 = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var product4 = 상품_삼각김밥_가격1000원_평점4점_생성(category); + final var product5 = 상품_삼각김밥_가격1000원_평점5점_생성(category); + 복수_상품_저장(product1, product2, product3, product4, product5); + + final var page = 페이지요청_평균_평점_오름차순_생성(0, 3); + + final var productInCategoryDto1 = ProductInCategoryDto.toDto(product1, 0L); + final var productInCategoryDto2 = ProductInCategoryDto.toDto(product2, 0L); + final var productInCategoryDto3 = ProductInCategoryDto.toDto(product3, 0L); + final var expected = List.of(productInCategoryDto1, productInCategoryDto2, productInCategoryDto3); + + // when + final var actual = productRepository.findAllByCategory(category, page).getContent(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 카테고리별_상품을_가격이_높은_순으로_정렬한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product2 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + final var product3 = 상품_삼각김밥_가격3000원_평점1점_생성(category); + final var product4 = 상품_삼각김밥_가격4000원_평점1점_생성(category); + final var product5 = 상품_삼각김밥_가격5000원_평점1점_생성(category); + 복수_상품_저장(product1, product2, product3, product4, product5); + + final var page = 페이지요청_가격_내림차순_생성(0, 3); + + final var productInCategoryDto1 = ProductInCategoryDto.toDto(product5, 0L); + final var productInCategoryDto2 = ProductInCategoryDto.toDto(product4, 0L); + final var productInCategoryDto3 = ProductInCategoryDto.toDto(product3, 0L); + final var expected = List.of(productInCategoryDto1, productInCategoryDto2, productInCategoryDto3); + + // when + final var actual = productRepository.findAllByCategory(category, page).getContent(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 카테고리별_상품을_가격이_낮은_순으로_정렬한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product2 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + final var product3 = 상품_삼각김밥_가격3000원_평점1점_생성(category); + final var product4 = 상품_삼각김밥_가격4000원_평점1점_생성(category); + final var product5 = 상품_삼각김밥_가격5000원_평점1점_생성(category); + 복수_상품_저장(product1, product2, product3, product4, product5); + + final var page = 페이지요청_가격_오름차순_생성(0, 3); + + final var productInCategoryDto1 = ProductInCategoryDto.toDto(product1, 0L); + final var productInCategoryDto2 = ProductInCategoryDto.toDto(product2, 0L); + final var productInCategoryDto3 = ProductInCategoryDto.toDto(product3, 0L); + final var expected = List.of(productInCategoryDto1, productInCategoryDto2, productInCategoryDto3); + + // when + final var actual = productRepository.findAllByCategory(category, page).getContent(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } + + @Nested + class findAllByCategoryOrderByReviewCountDesc_성공_테스트 { + + @Test + void 카테고리별_상품을_리뷰수가_많은_순으로_정렬한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product2 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + final var product3 = 상품_삼각김밥_가격3000원_평점1점_생성(category); + final var product4 = 상품_삼각김밥_가격4000원_평점1점_생성(category); + 복수_상품_저장(product1, product2, product3, product4); + + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var review1_1 = 리뷰_이미지test1_평점1점_재구매X_생성(member1, product1, 0L); + final var review1_2 = 리뷰_이미지test3_평점3점_재구매O_생성(member2, product1, 0L); + final var review2_1 = 리뷰_이미지test4_평점4점_재구매O_생성(member3, product2, 0L); + final var review2_2 = 리뷰_이미지test2_평점2점_재구매X_생성(member1, product2, 0L); + final var review2_3 = 리뷰_이미지test3_평점3점_재구매O_생성(member2, product2, 0L); + final var review3_1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product3, 0L); + 복수_리뷰_저장(review1_1, review1_2, review2_1, review2_2, review2_3, review3_1); + + final var page = 페이지요청_기본_생성(0, 3); + + final var productInCategoryDto1 = ProductInCategoryDto.toDto(product2, 3L); + final var productInCategoryDto2 = ProductInCategoryDto.toDto(product1, 2L); + final var productInCategoryDto3 = ProductInCategoryDto.toDto(product3, 1L); + final var expected = List.of(productInCategoryDto1, productInCategoryDto2, productInCategoryDto3); + + // when + final var actual = productRepository.findAllByCategoryOrderByReviewCountDesc(category, page) + .getContent(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } + + @Nested + class findAllByAverageRatingGreaterThan3_성공_테스트 { + + @Test + void 평점이_3보다_큰_모든_상품들과_리뷰_수를_조회한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var product2 = 상품_삼각김밥_가격2000원_평점4점_생성(category); + final var product3 = 상품_삼각김밥_가격3000원_평점5점_생성(category); + final var product4 = 상품_삼각김밥_가격4000원_평점2점_생성(category); + 복수_상품_저장(product1, product2, product3, product4); + + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var review1_1 = 리뷰_이미지test1_평점1점_재구매X_생성(member1, product1, 0L); + final var review1_2 = 리뷰_이미지test5_평점5점_재구매O_생성(member2, product1, 0L); + final var review2_1 = 리뷰_이미지test3_평점3점_재구매O_생성(member3, product2, 0L); + final var review2_2 = 리뷰_이미지test4_평점4점_재구매X_생성(member1, product2, 0L); + final var review2_3 = 리뷰_이미지test5_평점5점_재구매O_생성(member2, product2, 0L); + final var review3_1 = 리뷰_이미지test5_평점5점_재구매O_생성(member1, product3, 0L); + 복수_리뷰_저장(review1_1, review1_2, review2_1, review2_2, review2_3, review3_1); + + final var productReviewCountDto1 = new ProductReviewCountDto(product2, 3L); + final var productReviewCountDto2 = new ProductReviewCountDto(product3, 1L); + final var expected = List.of(productReviewCountDto1, productReviewCountDto2); + + // when + final var actual = productRepository.findAllByAverageRatingGreaterThan3(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } +} diff --git a/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java b/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java new file mode 100644 index 00000000..d84cb9df --- /dev/null +++ b/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java @@ -0,0 +1,177 @@ +package com.funeat.recipe.application; + +import static com.funeat.fixture.CategoryFixture.카테고리_간편식사_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점3점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_평점4점_생성; +import static com.funeat.fixture.RecipeFixture.레시피_생성; +import static com.funeat.fixture.RecipeFixture.레시피추가요청_생성; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.funeat.common.ServiceTest; +import com.funeat.member.domain.Member; +import com.funeat.product.domain.Category; +import com.funeat.product.domain.CategoryType; +import com.funeat.product.domain.Product; +import com.funeat.recipe.dto.RecipeCreateRequest; +import com.funeat.recipe.dto.RecipeDetailResponse; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@SuppressWarnings("NonAsciiCharacters") +class RecipeServiceTest extends ServiceTest { + + @Nested + class create_성공_테스트 { + + @Test + void 레시피를_추가할_수_있다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product2 = 상품_삼각김밥_가격3000원_평점4점_생성(category); + final var product3 = 상품_삼각김밥_가격2000원_평점3점_생성(category); + 복수_상품_저장(product1, product2, product3); + final var productIds = 상품_아이디_변환(product1, product2, product3); + + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var image1 = 이미지_생성(); + final var image2 = 이미지_생성(); + final var image3 = 이미지_생성(); + final var images = List.of(image1, image2, image3); + + final var request = 레시피추가요청_생성(productIds); + + final var expected = 레시피_생성(member); + + // when + final var recipeId = recipeService.create(memberId, images, request); + final var actual = recipeRepository.findById(recipeId).get(); + + // then + assertThat(actual).usingRecursiveComparison() + .ignoringFields("id") + .isEqualTo(expected); + } + } + + @Test + void 레시피의_상세_정보를_조회할_수_있다() { + // given + final var category = 카테고리_추가_요청(new Category("간편식사", CategoryType.FOOD)); + final var product1 = new Product("불닭볶음면", 1000L, "image.png", "엄청 매운 불닭", category); + final var product2 = new Product("참치 삼김", 2000L, "image.png", "담백한 참치마요 삼김", category); + final var product3 = new Product("스트링 치즈", 1500L, "image.png", "고소한 치즈", category); + 복수_상품_저장(product1, product2, product3); + final var products = List.of(product1, product2, product3); + final var author = new Member("author", "image.png", "1"); + 단일_멤버_저장(author); + final var authorId = author.getId(); + + final var image1 = new MockMultipartFile("image1", "image1.jpg", "image/jpeg", new byte[]{1, 2, 3}); + final var image2 = new MockMultipartFile("image2", "image2.jpg", "image/jpeg", new byte[]{1, 2, 3}); + final var image3 = new MockMultipartFile("image3", "image3.jpg", "image/jpeg", new byte[]{1, 2, 3}); + + final var productIds = List.of(product1.getId(), product2.getId(), product3.getId()); + final var request = new RecipeCreateRequest("제일로 맛있는 레시피", productIds, + "우선 밥을 넣어요. 그리고 밥을 또 넣어요. 그리고 밥을 또 넣으면.. 끝!!"); + + final var recipeId = recipeService.create(authorId, List.of(image1, image2, image3), request); + + // when + final var actual = recipeService.getRecipeDetail(authorId, recipeId); + + // then + final var recipe = recipeRepository.findById(recipeId).get(); + final var expected = RecipeDetailResponse.toResponse( + recipe, recipeImageRepository.findByRecipe(recipe), + products, 4500L, false); + + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Nested + class create_실패_테스트 { + + @Test + void 존재하지_않는_멤버가_레시피를_추가하면_예외가_발생한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product2 = 상품_삼각김밥_가격3000원_평점4점_생성(category); + final var product3 = 상품_삼각김밥_가격2000원_평점3점_생성(category); + 복수_상품_저장(product1, product2, product3); + final var productIds = 상품_아이디_변환(product1, product2, product3); + + final var member = 멤버_멤버1_생성(); + final var wrongMemberId = 단일_멤버_저장(member) + 1L; + + final var image1 = 이미지_생성(); + final var image2 = 이미지_생성(); + final var image3 = 이미지_생성(); + final var images = List.of(image1, image2, image3); + + final var request = 레시피추가요청_생성(productIds); + + // when & then + assertThatThrownBy(() -> recipeService.create(wrongMemberId, images, request)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 존재하지_않는_상품을_레시피에_추가하면_예외가_발생한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product2 = 상품_삼각김밥_가격3000원_평점4점_생성(category); + final var product3 = 상품_삼각김밥_가격2000원_평점3점_생성(category); + 복수_상품_저장(product1, product2, product3); + final var wrongProductIds = 상품_아이디_변환(product1, product2, product3); + wrongProductIds.add(4L); + + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var image1 = 이미지_생성(); + final var image2 = 이미지_생성(); + final var image3 = 이미지_생성(); + final var images = List.of(image1, image2, image3); + + final var request = 레시피추가요청_생성(wrongProductIds); + + // when & then + assertThatThrownBy(() -> recipeService.create(memberId, images, request)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + private MultipartFile 이미지_생성() { + return new MockMultipartFile("image", "image.jpg", "image/jpeg", new byte[]{1, 2, 3}); + } + + private Category 카테고리_추가_요청(final Category category) { + return categoryRepository.save(category); + } + + private List 상품_아이디_변환(final Product... products) { + return Stream.of(products) + .map(Product::getId) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/test/java/com/funeat/recipe/persistence/RecipeImageRepositoryTest.java b/backend/src/test/java/com/funeat/recipe/persistence/RecipeImageRepositoryTest.java new file mode 100644 index 00000000..bb2c8092 --- /dev/null +++ b/backend/src/test/java/com/funeat/recipe/persistence/RecipeImageRepositoryTest.java @@ -0,0 +1,57 @@ +package com.funeat.recipe.persistence; + +import static com.funeat.fixture.CategoryFixture.카테고리_간편식사_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static com.funeat.fixture.ProductFixture.레시피_안에_들어가는_상품_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점1점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점1점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_평점1점_생성; +import static com.funeat.fixture.RecipeFixture.레시피_생성; +import static com.funeat.fixture.RecipeFixture.레시피이미지_생성; +import static org.assertj.core.api.Assertions.assertThat; + +import com.funeat.common.RepositoryTest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +class RecipeImageRepositoryTest extends RepositoryTest { + + @Nested + class findByRecipe_성공_테스트 { + + @Test + void 레시피에_사용된_이미지들을_조회할_수_있다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var product2 = 상품_삼각김밥_가격3000원_평점1점_생성(category); + final var product3 = 상품_삼각김밥_가격2000원_평점1점_생성(category); + 복수_상품_저장(product1, product2, product3); + + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var recipe = 레시피_생성(member); + 단일_레시피_저장(recipe); + + final var productRecipe1 = 레시피_안에_들어가는_상품_생성(product1, recipe); + final var productRecipe2 = 레시피_안에_들어가는_상품_생성(product2, recipe); + final var productRecipe3 = 레시피_안에_들어가는_상품_생성(product3, recipe); + 복수_레시피_상품_저장(productRecipe1, productRecipe2, productRecipe3); + + final var image1 = 레시피이미지_생성(recipe); + final var image2 = 레시피이미지_생성(recipe); + final var image3 = 레시피이미지_생성(recipe); + 복수_레시피_이미지_저장(image1, image2, image3); + + // when + final var images = recipeImageRepository.findByRecipe(recipe); + + // then + assertThat(images.size()).isEqualTo(3); + } + } +} diff --git a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java new file mode 100644 index 00000000..92849b1b --- /dev/null +++ b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java @@ -0,0 +1,526 @@ +package com.funeat.review.application; + +import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성; +import static com.funeat.fixture.ImageFixture.이미지_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버3_생성; +import static com.funeat.fixture.PageFixture.페이지요청_기본_생성; +import static com.funeat.fixture.PageFixture.페이지요청_생성_시간_내림차순_생성; +import static com.funeat.fixture.PageFixture.페이지요청_좋아요_내림차순_생성; +import static com.funeat.fixture.PageFixture.페이지요청_평점_내림차순_생성; +import static com.funeat.fixture.PageFixture.페이지요청_평점_오름차순_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test2_평점2점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매X_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰좋아요요청_false_생성; +import static com.funeat.fixture.ReviewFixture.리뷰좋아요요청_true_생성; +import static com.funeat.fixture.ReviewFixture.리뷰추가요청_재구매O_생성; +import static com.funeat.fixture.TagFixture.태그_맛있어요_TASTE_생성; +import static com.funeat.fixture.TagFixture.태그_아침식사_ETC_생성; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.funeat.common.ServiceTest; +import com.funeat.review.domain.Review; +import com.funeat.review.presentation.dto.SortingReviewDto; +import com.funeat.tag.domain.Tag; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +class ReviewServiceTest extends ServiceTest { + + @Nested + class create_성공_테스트 { + + @Test + void 이미지가_존재하는_리뷰를_추가할_수_있다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + final var imageFileName = image.getOriginalFilename(); + + final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); + + final var expected = new Review(member, product, imageFileName, 4L, "test", true); + + // when + reviewService.create(productId, memberId, image, request); + final var actual = reviewRepository.findAll().get(0); + + // then + assertThat(actual).usingRecursiveComparison() + .comparingOnlyFields("member", "product", "image", "rating", "content", "reBuy") + .isEqualTo(expected); + } + + @Test + void 이미지가_없는_리뷰를_추가할_수_있다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + + final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); + + final var expected = new Review(member, product, null, 4L, "test", true); + + // when + reviewService.create(productId, memberId, null, request); + final var actual = reviewRepository.findAll().get(0); + + // then + assertThat(actual).usingRecursiveComparison() + .comparingOnlyFields("member", "product", "image", "rating", "content", "reBuy") + .isEqualTo(expected); + } + } + + @Nested + class create_실패_테스트 { + + @Test + void 존재하지_않는_멤버로_상품에_리뷰를_추가하면_예외가_발생한다() { + // given + final var member = 멤버_멤버1_생성(); + final var wrongMemberId = 단일_멤버_저장(member) + 1L; + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + + final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); + + // when & then + assertThatThrownBy(() -> reviewService.create(productId, wrongMemberId, image, request)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 멤버로_존재하지_않는_상품에_리뷰를_추가하면_예외가_발생한다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var wrongProductId = 단일_상품_저장(product) + 1L; + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + + final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); + + // when & then + assertThatThrownBy(() -> reviewService.create(wrongProductId, memberId, image, request)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + class likeReview_성공_테스트 { + + @Test + void 리뷰에_좋아요를_할_수_있다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + final var reviewCreaterequest = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, memberId, image, reviewCreaterequest); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + final var favoriteRequest = 리뷰좋아요요청_true_생성(); + + // when + reviewService.likeReview(reviewId, memberId, favoriteRequest); + + final var actualReview = reviewRepository.findAll().get(0); + final var actualReviewFavorite = reviewFavoriteRepository.findAll().get(0); + + // then + assertSoftly(softAssertions -> { + softAssertions.assertThat(actualReview.getFavoriteCount()) + .isOne(); + softAssertions.assertThat(actualReviewFavorite.getFavorite()) + .isTrue(); + }); + } + + @Test + void 리뷰에_좋아요를_취소_할_수_있다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + final var reviewCreaterequest = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, memberId, image, reviewCreaterequest); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + final var favoriteRequest = 리뷰좋아요요청_true_생성(); + reviewService.likeReview(reviewId, memberId, favoriteRequest); + + // when + final var cancelFavoriteRequest = 리뷰좋아요요청_false_생성(); + reviewService.likeReview(reviewId, memberId, cancelFavoriteRequest); + + final var actualReview = reviewRepository.findAll().get(0); + final var actualReviewFavorite = reviewFavoriteRepository.findAll().get(0); + + // then + assertSoftly(softAssertions -> { + softAssertions.assertThat(actualReview.getFavoriteCount()) + .isZero(); + softAssertions.assertThat(actualReviewFavorite.getFavorite()) + .isFalse(); + }); + } + } + + @Nested + class likeReview_실패_테스트 { + + @Test + void 존재하지_않는_멤버가_리뷰에_좋아요를_하면_예외가_발생한다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + final var wrongMemberId = memberId + 1L; + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + final var reviewCreaterequest = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, memberId, image, reviewCreaterequest); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + final var favoriteRequest = 리뷰좋아요요청_true_생성(); + + // when + assertThatThrownBy(() -> reviewService.likeReview(reviewId, wrongMemberId, favoriteRequest)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 멤버가_존재하지_않는_리뷰에_좋아요를_하면_예외가_발생한다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + final var reviewCreaterequest = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, memberId, image, reviewCreaterequest); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + final var wrongReviewId = reviewId + 1L; + + final var favoriteRequest = 리뷰좋아요요청_true_생성(); + + // when + assertThatThrownBy(() -> reviewService.likeReview(wrongReviewId, memberId, favoriteRequest)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + class sortingReviews_성공_테스트 { + + @Test + void 좋아요_기준으로_내림차순_정렬을_할_수_있다() { + // given + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var productId = 단일_상품_저장(product); + + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 351L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + 복수_리뷰_저장(review1, review2, review3); + + final var page = 페이지요청_좋아요_내림차순_생성(0, 2); + final var member1Id = member1.getId(); + + final var expected = Stream.of(review1, review3) + .map(review -> SortingReviewDto.toDto(review, member1)) + .collect(Collectors.toList()); + + // when + final var actual = reviewService.sortingReviews(productId, page, member1Id).getReviews(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 평점_기준으로_오름차순_정렬을_할_수_있다() { + // given + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var productId = 단일_상품_저장(product); + + final var review1 = 리뷰_이미지test2_평점2점_재구매O_생성(member1, product, 351L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + 복수_리뷰_저장(review1, review2, review3); + + final var page = 페이지요청_평점_오름차순_생성(0, 2); + final var member1Id = member1.getId(); + + final var expected = Stream.of(review1, review3) + .map(review -> SortingReviewDto.toDto(review, member1)) + .collect(Collectors.toList()); + + // when + final var actual = reviewService.sortingReviews(productId, page, member1Id).getReviews(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 평점_기준으로_내림차순_정렬을_할_수_있다() { + // given + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var productId = 단일_상품_저장(product); + + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 351L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + 복수_리뷰_저장(review1, review2, review3); + + final var page = 페이지요청_평점_내림차순_생성(0, 2); + final var member1Id = member1.getId(); + + final var expected = Stream.of(review2, review3) + .map(review -> SortingReviewDto.toDto(review, member1)) + .collect(Collectors.toList()); + + // when + final var actual = reviewService.sortingReviews(productId, page, member1Id).getReviews(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 최신순으로_정렬을_할_수_있다() { + // given + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var productId = 단일_상품_저장(product); + + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 351L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + 복수_리뷰_저장(review1, review2, review3); + + final var page = 페이지요청_생성_시간_내림차순_생성(0, 2); + final var member1Id = member1.getId(); + + final var expected = Stream.of(review3, review2) + .map(review -> SortingReviewDto.toDto(review, member1)) + .collect(Collectors.toList()); + + // when + final var actual = reviewService.sortingReviews(productId, page, member1Id).getReviews(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } + + @Nested + class sortingReviews_실패_테스트 { + + @Test + void 존재하지_않는_멤버가_상품에_있는_리뷰들을_정렬하면_예외가_발생한다() { + // given + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var productId = 단일_상품_저장(product); + + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 351L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + 복수_리뷰_저장(review1, review2, review3); + + final var page = 페이지요청_기본_생성(0, 2); + final var wrongMemberId = member1.getId() + 3L; + + // when & then + assertThatThrownBy(() -> reviewService.sortingReviews(productId, page, wrongMemberId)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 멤버가_존재하지_않는_상품에_있는_리뷰들을_정렬하면_예외가_발생한다() { + // given + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var wrongProductId = 단일_상품_저장(product) + 1L; + + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 351L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + 복수_리뷰_저장(review1, review2, review3); + + final var page = 페이지요청_기본_생성(0, 2); + final var member1Id = member1.getId(); + + // when & then + assertThatThrownBy(() -> reviewService.sortingReviews(wrongProductId, page, member1Id)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + private List 태그_아이디_변환(final Tag... tags) { + return Stream.of(tags) + .map(Tag::getId) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java b/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java new file mode 100644 index 00000000..210da08e --- /dev/null +++ b/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java @@ -0,0 +1,134 @@ +package com.funeat.review.persistence; + +import static com.funeat.fixture.CategoryFixture.카테고리_간편식사_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버3_생성; +import static com.funeat.fixture.PageFixture.페이지요청_좋아요_내림차순_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점3점_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test1_평점1점_재구매X_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매X_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매O_생성; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.funeat.common.RepositoryTest; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +class ReviewRepositoryTest extends RepositoryTest { + + @Nested + class countByProduct_성공_테스트 { + + @Test + void 상품의_리뷰_수를_반환한다() { + // given + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product2 = 상품_삼각김밥_가격2000원_평점3점_생성(category); + 복수_상품_저장(product1, product2); + + final var review1_1 = 리뷰_이미지test4_평점4점_재구매O_생성(member1, product1, 0L); + final var review1_2 = 리뷰_이미지test3_평점3점_재구매X_생성(member2, product1, 0L); + final var review1_3 = 리뷰_이미지test4_평점4점_재구매O_생성(member3, product1, 0L); + final var review2_1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product2, 0L); + 복수_리뷰_저장(review1_1, review1_2, review1_3, review2_1); + + // when + final var actual1 = reviewRepository.countByProduct(product1); + final var actual2 = reviewRepository.countByProduct(product2); + + // then + assertSoftly(softAssertions -> { + softAssertions.assertThat(actual1) + .isEqualTo(3); + softAssertions.assertThat(actual2) + .isEqualTo(1); + }); + } + } + + @Nested + class findReviewsByProduct_성공_테스트 { + + @Test + void 특정_상품에_대한_좋아요_기준_내림차순으로_정렬한다() { + // given + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + 단일_상품_저장(product); + + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 351L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + 복수_리뷰_저장(review1, review2, review3); + + final var page = 페이지요청_좋아요_내림차순_생성(0, 2); + + final var expected = List.of(review1, review3); + + // when + final var actual = reviewRepository.findReviewsByProduct(page, product).getContent(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } + + @Nested + class findTop3ByOrderByFavoriteCountDesc_성공_테스트 { + + @Test + void 전체_리뷰_목록에서_가장_좋아요가_높은_상위_3개의_리뷰를_가져온다() { + // given + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product2 = 상품_삼각김밥_가격2000원_평점3점_생성(category); + 복수_상품_저장(product1, product2); + + final var review1_1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product1, 5L); + final var review1_2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product1, 351L); + final var review1_3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product1, 130L); + final var review2_2 = 리뷰_이미지test5_평점5점_재구매O_생성(member2, product2, 247L); + final var review3_2 = 리뷰_이미지test1_평점1점_재구매X_생성(member3, product2, 83L); + 복수_리뷰_저장(review1_1, review1_2, review1_3, review2_2, review3_2); + + final var expected = List.of(review1_2, review2_2, review1_3); + + // when + final var actual = reviewRepository.findTop3ByOrderByFavoriteCountDesc(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } +} diff --git a/backend/src/test/java/com/funeat/review/persistence/ReviewTagRepositoryTest.java b/backend/src/test/java/com/funeat/review/persistence/ReviewTagRepositoryTest.java new file mode 100644 index 00000000..baab6abd --- /dev/null +++ b/backend/src/test/java/com/funeat/review/persistence/ReviewTagRepositoryTest.java @@ -0,0 +1,78 @@ +package com.funeat.review.persistence; + +import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static com.funeat.fixture.PageFixture.페이지요청_기본_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_평점2점_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매X_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매O_생성; +import static com.funeat.fixture.TagFixture.태그_간식_ETC_생성; +import static com.funeat.fixture.TagFixture.태그_갓성비_PRICE_생성; +import static com.funeat.fixture.TagFixture.태그_단짠단짠_TASTE_생성; +import static com.funeat.fixture.TagFixture.태그_맛있어요_TASTE_생성; +import static org.assertj.core.api.Assertions.assertThat; + +import com.funeat.common.RepositoryTest; +import com.funeat.review.domain.Review; +import com.funeat.review.domain.ReviewTag; +import com.funeat.tag.domain.Tag; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +class ReviewTagRepositoryTest extends RepositoryTest { + + @Nested + class findTop3TagsByReviewIn_성공_테스트 { + + @Test + void 리뷰_목록에서_상위_3개에_해당하는_태그를_조회한다() { + + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격3000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_단짠단짠_TASTE_생성(); + final var tag3 = 태그_갓성비_PRICE_생성(); + final var tag4 = 태그_간식_ETC_생성(); + 복수_태그_저장(tag1, tag2, tag3, tag4); + + final var review1 = 리뷰_이미지test5_평점5점_재구매O_생성(member, product, 0L); + final var review2 = 리뷰_이미지test3_평점3점_재구매X_생성(member, product, 0L); + final var review3 = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 0L); + 복수_리뷰_저장(review1, review2, review3); + + final var reviewTag1_1 = 리뷰_태그_생성(review1, tag1); + final var reviewTag1_2 = 리뷰_태그_생성(review1, tag2); + final var reviewTag2_1 = 리뷰_태그_생성(review2, tag1); + final var reviewTag2_2 = 리뷰_태그_생성(review2, tag2); + final var reviewTag2_3 = 리뷰_태그_생성(review2, tag3); + final var reviewTag3_1 = 리뷰_태그_생성(review3, tag1); + 복수_리뷰_태그_저장(reviewTag1_1, reviewTag1_2, reviewTag2_1, reviewTag2_2, reviewTag2_3, reviewTag3_1); + + final var page = 페이지요청_기본_생성(0, 3); + + final var expected = List.of(tag1, tag2, tag3); + + // when + final var top3Tags = reviewTagRepository.findTop3TagsByReviewIn(productId, page); + + // then + assertThat(top3Tags).usingRecursiveComparison() + .isEqualTo(expected); + } + } + + private ReviewTag 리뷰_태그_생성(final Review review, final Tag tag) { + return ReviewTag.createReviewTag(review, tag); + } +} diff --git a/backend/src/test/java/com/funeat/tag/persistence/TagRepositoryTest.java b/backend/src/test/java/com/funeat/tag/persistence/TagRepositoryTest.java new file mode 100644 index 00000000..2b6dc178 --- /dev/null +++ b/backend/src/test/java/com/funeat/tag/persistence/TagRepositoryTest.java @@ -0,0 +1,68 @@ +package com.funeat.tag.persistence; + +import static com.funeat.fixture.TagFixture.태그_갓성비_PRICE_생성; +import static com.funeat.fixture.TagFixture.태그_맛있어요_TASTE_생성; +import static com.funeat.tag.domain.TagType.TASTE; +import static org.assertj.core.api.Assertions.assertThat; + +import com.funeat.common.RepositoryTest; +import com.funeat.tag.domain.Tag; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +class TagRepositoryTest extends RepositoryTest { + + @Nested + class findTagsByIdIn_성공_테스트 { + + @Test + void 여러_태그_아이디로_태그들을_조회_할_수_있다() { + // given + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_갓성비_PRICE_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + + final var expected = List.of(tag1, tag2); + + // then + final var actual = tagRepository.findTagsByIdIn(tagIds); + + // when + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } + + @Nested + class findTagsByTagType_성공_테스트 { + + @Test + void 태그_타입으로_태그들을_조회할_수_있다() { + // given + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_갓성비_PRICE_생성(); + 복수_태그_저장(tag1, tag2); + + final var expected = List.of(tag1); + + // when + final var actual = tagRepository.findTagsByTagType(TASTE); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } + + private List 태그_아이디_변환(final Tag... tags) { + return Stream.of(tags) + .map(Tag::getId) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml new file mode 100644 index 00000000..0e698721 --- /dev/null +++ b/backend/src/test/resources/application.yml @@ -0,0 +1,20 @@ +spring: + profiles: + active: test + + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:test;MODE=MySQL + username: sa + + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + show_sql: true + +logging: + level: + org.hibernate.type.descriptor.sql: trace diff --git a/frontend/.babelrc.json b/frontend/.babelrc.json new file mode 100644 index 00000000..2c055d07 --- /dev/null +++ b/frontend/.babelrc.json @@ -0,0 +1,16 @@ +{ + "sourceType": "unambiguous", + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "chrome": 100 + } + } + ], + "@babel/preset-typescript", + "@babel/preset-react" + ], + "plugins": ["babel-plugin-styled-components"] +} diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index c192e697..5ea0e3be 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -3,7 +3,13 @@ module.exports = { browser: true, es2021: true, }, - extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react/recommended'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react/recommended', + 'plugin:storybook/recommended', + 'plugin:import/recommended', + ], ignorePatterns: ['*.js'], overrides: [ { @@ -15,6 +21,18 @@ module.exports = { sourceType: 'script', }, }, + { + env: { + jest: true, + }, + files: ['__tests__/**/*.{ts,tsx}'], + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: './tsconfig.json', + tsconfigRootDir: __dirname, + }, + }, ], parser: '@typescript-eslint/parser', parserOptions: { @@ -23,7 +41,7 @@ module.exports = { project: './tsconfig.json', tsconfigRootDir: __dirname, }, - plugins: ['@typescript-eslint', 'react'], + plugins: ['@typescript-eslint', 'react', 'import'], rules: { 'react/react-in-jsx-scope': 'off', '@typescript-eslint/no-var-requires': 0, @@ -47,5 +65,48 @@ module.exports = { html: true, }, ], + 'import/order': [ + 'error', + { + groups: ['builtin', 'external', 'internal', ['parent', 'sibling', 'index'], 'object', 'unknown'], + pathGroups: [ + { + pattern: '@storybook/**', + group: 'external', + }, + { + pattern: '@fun-eat/**', + group: 'external', + }, + { + pattern: '@tanstack/**', + group: 'external', + }, + { + pattern: '@*/**', + group: 'unknown', + }, + { + pattern: '@*', + group: 'unknown', + }, + ], + pathGroupsExcludedImportTypes: ['unknown'], + alphabetize: { + order: 'asc', + caseInsensitive: true, + }, + 'newlines-between': 'always', + }, + ], + 'import/no-unresolved': 'off', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/ban-types': 'off', + }, + settings: { + 'import/resolver': { + typescript: {}, + webpack: {}, + }, }, }; diff --git a/frontend/.gitignore b/frontend/.gitignore index 771d65fe..8a2a134c 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -2,4 +2,8 @@ node_modules dist .DS_Store .AppleDouble -.LSOverride \ No newline at end of file +.LSOverride +.env +coverage +test-results +junit.xml diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts new file mode 100644 index 00000000..8791c62d --- /dev/null +++ b/frontend/.storybook/main.ts @@ -0,0 +1,65 @@ +import type { StorybookConfig } from '@storybook/react-webpack5'; +import path from 'path'; + +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], + addons: [ + '@storybook/addon-links', + '@storybook/addon-essentials', + '@storybook/addon-interactions', + 'msw-storybook-addon', + '@storybook/addon-onboarding', + ], + framework: { + name: '@storybook/react-webpack5', + options: {}, + }, + core: { + builder: { + name: '@storybook/builder-webpack5', + options: { + fsCache: true, + lazyCompilation: true, + }, + }, + }, + webpackFinal: async (config) => { + if (config.resolve) { + config.resolve.alias = { + ...config.resolve.alias, + '@': path.resolve(__dirname, '../src'), + '@apis': path.resolve(__dirname, '../src/apis'), + '@assets': path.resolve(__dirname, '../src/assets'), + '@components': path.resolve(__dirname, '../src/components'), + '@constants': path.resolve(__dirname, '../src/constants'), + '@hooks': path.resolve(__dirname, '../src/hooks'), + '@mocks': path.resolve(__dirname, '../src/mocks'), + '@pages': path.resolve(__dirname, '../src/pages'), + '@router': path.resolve(__dirname, '../src/router'), + '@styles': path.resolve(__dirname, '../src/styles'), + '@utils': path.resolve(__dirname, '../src/utils'), + }; + } + const imageRule = config.module?.rules?.find((rule) => { + const test = (rule as { test: RegExp }).test; + + if (!test) return false; + + return test.test('.svg'); + }) as { [key: string]: any }; + + imageRule.exclude = /\.svg$/; + + config.module?.rules?.push({ + test: /\.svg$/, + use: ['@svgr/webpack'], + }); + + return config; + }, + docs: { + autodocs: true, + }, + staticDirs: ['../public'], +}; +export default config; diff --git a/frontend/.storybook/preview-body.html b/frontend/.storybook/preview-body.html new file mode 100644 index 00000000..0cb3cbaa --- /dev/null +++ b/frontend/.storybook/preview-body.html @@ -0,0 +1,94 @@ + diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx new file mode 100644 index 00000000..6eb59a11 --- /dev/null +++ b/frontend/.storybook/preview.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { FunEatProvider } from '@fun-eat/design-system'; +import type { Preview } from '@storybook/react'; +import { initialize, mswDecorator } from 'msw-storybook-addon'; +import { loginHandlers, productHandlers, rankingHandlers, reviewHandlers } from '../src/mocks/handlers'; +import { BrowserRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const queryClient = new QueryClient(); + +initialize({ + serviceWorker: { + url: '/mockServiceWorker.js', + }, +}); + +export const decorators = [ + (Story) => ( + + + + + + + + ), + mswDecorator, +]; + +const preview: Preview = { + parameters: { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + msw: [...loginHandlers, ...productHandlers, ...reviewHandlers, ...rankingHandlers], + }, +}; + +export default preview; diff --git a/frontend/__tests__/hooks/useStarRating.test.ts b/frontend/__tests__/hooks/useStarRating.test.ts new file mode 100644 index 00000000..66ed60cb --- /dev/null +++ b/frontend/__tests__/hooks/useStarRating.test.ts @@ -0,0 +1,32 @@ +import { useStarRatingHover } from '@/hooks/review'; +import { renderHook, act } from '@testing-library/react'; + +it('handleMouseEnter를 사용하여 마우스 호버된 별점 값을 저장할 수 있다.', () => { + const { result } = renderHook(() => useStarRatingHover()); + + expect(result.current.hovering).toBe(0); + + act(() => { + result.current.handleMouseEnter(3); + }); + + expect(result.current.hovering).toBe(3); +}); + +it('handleMouseLeave를 사용하여 마우스 호버된 별점을 초기화 할 수 있다.', () => { + const { result } = renderHook(() => useStarRatingHover()); + + expect(result.current.hovering).toBe(0); + + act(() => { + result.current.handleMouseEnter(3); + }); + + expect(result.current.hovering).toBe(3); + + act(() => { + result.current.handleMouseLeave(); + }); + + expect(result.current.hovering).toBe(0); +}); diff --git a/frontend/jest.config.js b/frontend/jest.config.js new file mode 100644 index 00000000..82a71339 --- /dev/null +++ b/frontend/jest.config.js @@ -0,0 +1,20 @@ +module.exports = { + testEnvironment: 'jsdom', + transform: { + '^.+\\.(js|ts|tsx)?$': 'ts-jest', + }, + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, + testMatch: ['/__tests__/**/*.test.(js|jsx|ts|tsx)'], + reporters: [ + 'default', + [ + 'jest-junit', + { + outputDirectory: '/test-results', + outputName: 'results.xml', + }, + ], + ], +}; diff --git a/frontend/package.json b/frontend/package.json index 73b4dbb5..5fe84754 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,27 +6,72 @@ "scripts": { "start": "webpack serve --open --config webpack.dev.js", "build": "webpack --config webpack.prod.js", - "build-dev": "webpack --config webpack.dev.js" + "build-dev": "webpack --config webpack.dev.js", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build", + "test": "jest", + "test:coverage": "jest --watchAll --coverage" }, "dependencies": { + "@fun-eat/design-system": "^0.3.11", + "@tanstack/react-query": "^4.32.6", + "@tanstack/react-query-devtools": "^4.32.6", + "browser-image-compression": "^2.0.2", + "dayjs": "^1.11.9", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6.14.2", "styled-components": "^6.0.2" }, "devDependencies": { + "@babel/preset-env": "^7.22.9", + "@babel/preset-react": "^7.22.5", + "@babel/preset-typescript": "^7.22.5", + "@storybook/addon-essentials": "^7.0.27", + "@storybook/addon-interactions": "^7.0.27", + "@storybook/addon-links": "^7.0.27", + "@storybook/addon-onboarding": "^1.0.8", + "@storybook/blocks": "^7.0.27", + "@storybook/react": "^7.0.27", + "@storybook/react-webpack5": "^7.0.27", + "@storybook/testing-library": "^0.0.14-next.2", + "@svgr/webpack": "^8.0.1", + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.4.3", + "@types/jest": "^29.5.3", "@types/react": "^18.2.14", "@types/react-dom": "^18.2.6", "@types/styled-components": "^5.1.26", "@typescript-eslint/eslint-plugin": "^5.60.1", "@typescript-eslint/parser": "^5.60.1", + "babel-plugin-styled-components": "^2.1.4", + "dotenv-webpack": "^8.0.1", "eslint": "^8.44.0", + "eslint-import-resolver-typescript": "^3.5.5", + "eslint-import-resolver-webpack": "^0.13.2", + "eslint-plugin-import": "^2.27.5", "eslint-plugin-react": "^7.32.2", + "eslint-plugin-storybook": "^0.6.12", "html-webpack-plugin": "^5.5.3", + "jest": "^29.6.2", + "jest-environment-jsdom": "^29.6.2", + "jest-junit": "^16.0.0", + "msw": "^1.2.3", + "msw-storybook-addon": "^1.8.0", "prettier": "^2.8.8", + "storybook": "^7.1.1", + "ts-jest": "^29.1.1", "ts-loader": "^9.4.4", "typescript": "^5.1.6", "webpack": "^5.88.1", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1" + }, + "msw": { + "workerDirectory": "public" + }, + "resolutions": { + "jackspeak": "2.1.1" } } diff --git a/frontend/public/assets/apple-icon-180x180.png b/frontend/public/assets/apple-icon-180x180.png new file mode 100644 index 00000000..6534aca9 Binary files /dev/null and b/frontend/public/assets/apple-icon-180x180.png differ diff --git a/frontend/public/assets/favicon-16x16.png b/frontend/public/assets/favicon-16x16.png new file mode 100644 index 00000000..3e04f12c Binary files /dev/null and b/frontend/public/assets/favicon-16x16.png differ diff --git a/frontend/public/assets/favicon-32x32.png b/frontend/public/assets/favicon-32x32.png new file mode 100644 index 00000000..9c20736c Binary files /dev/null and b/frontend/public/assets/favicon-32x32.png differ diff --git a/frontend/public/assets/favicon.ico b/frontend/public/assets/favicon.ico new file mode 100644 index 00000000..41fe60fe Binary files /dev/null and b/frontend/public/assets/favicon.ico differ diff --git a/frontend/public/index.html b/frontend/public/index.html index dae2be05..f0224cee 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -2,8 +2,25 @@ + + + + + + + - Document + + + + + 펀잇
diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 00000000..a1dd331e --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,24 @@ +{ + "short_name": "펀잇", + "name": "펀잇", + "description": "궁금해? 맛있을걸? 먹어봐 🥄", + "theme_color": "#D8EAFF", + "background_color": "#ffffff", + "icons": [ + { + "src": "/favicon-16x16.png", + "sizes": "16x16", + "type": "image/png" + }, + { + "src": "/favicon-32x32.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "/apple-icon-180x180.png", + "sizes": "180x180", + "type": "image/png" + } + ] +} diff --git a/frontend/public/mockServiceWorker.js b/frontend/public/mockServiceWorker.js new file mode 100644 index 00000000..36a99274 --- /dev/null +++ b/frontend/public/mockServiceWorker.js @@ -0,0 +1,303 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (1.2.3). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70' +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: INTEGRITY_CHECKSUM, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + const accept = request.headers.get('accept') || '' + + // Bypass server-sent events. + if (accept.includes('text/event-stream')) { + return + } + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = Math.random().toString(16).slice(2) + + event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === 'NetworkError') { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url, + ) + return + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}`, + ) + }), + ) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const clonedResponse = response.clone() + sendToClient(client, { + type: 'RESPONSE', + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: Object.fromEntries(clonedResponse.headers.entries()), + redirected: clonedResponse.redirected, + }, + }) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + const clonedRequest = request.clone() + + function passthrough() { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const headers = Object.fromEntries(clonedRequest.headers.entries()) + + // Remove MSW-specific request headers so the bypassed requests + // comply with the server's CORS preflight check. + // Operate with the headers as an object because request "Headers" + // are immutable. + delete headers['x-msw-bypass'] + + return fetch(clonedRequest, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + if (request.headers.get('x-msw-bypass') === 'true') { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const clientMessage = await sendToClient(client, { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.text(), + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'MOCK_NOT_FOUND': { + return passthrough() + } + + case 'NETWORK_ERROR': { + const { name, message } = clientMessage.data + const networkError = new Error(message) + networkError.name = name + + // Rejecting a "respondWith" promise emulates a network error. + throw networkError + } + } + + return passthrough() +} + +function sendToClient(client, message) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [channel.port2]) + }) +} + +function sleep(timeMs) { + return new Promise((resolve) => { + setTimeout(resolve, timeMs) + }) +} + +async function respondWithMock(response) { + await sleep(response.delay) + return new Response(response.body, response) +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index 44961626..00000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,5 +0,0 @@ -const App = () => { - return <>; -}; - -export default App; diff --git a/frontend/src/apis/.gitkeep b/frontend/src/apis/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/src/apis/ApiClient.ts b/frontend/src/apis/ApiClient.ts new file mode 100644 index 00000000..bf4e6162 --- /dev/null +++ b/frontend/src/apis/ApiClient.ts @@ -0,0 +1,66 @@ +import { fetchApi } from './fetch'; + +interface RequestOptions { + params?: string; + queries?: string; + credentials?: boolean; +} + +export class ApiClient { + #path: string; + + #headers: HeadersInit; + + constructor(path: string, headers: HeadersInit = {}) { + this.#path = path; + this.#headers = headers; + } + + getUrl(params = '', queries = '') { + return '/api' + this.#path + params + queries; + } + + get({ params, queries, credentials = false }: RequestOptions) { + return fetchApi(this.getUrl(params, queries), { + method: 'GET', + headers: this.#headers, + credentials: credentials ? 'include' : 'omit', + }); + } + + post({ params, queries, credentials = false }: RequestOptions, body?: B) { + return fetchApi(this.getUrl(params, queries), { + method: 'POST', + headers: this.#headers, + body: body ? JSON.stringify(body) : null, + credentials: credentials ? 'include' : 'omit', + }); + } + + postData({ params, queries, credentials = false }: RequestOptions, body?: FormData) { + return fetchApi(this.getUrl(params, queries), { + method: 'POST', + headers: this.#headers, + body: body ? body : null, + credentials: credentials ? 'include' : 'omit', + }); + } + + patch({ params, queries, credentials = false }: RequestOptions, headers: HeadersInit, body?: B) { + return fetchApi(this.getUrl(params, queries), { + method: 'PATCH', + headers: headers, + body: body ? JSON.stringify(body) : null, + credentials: credentials ? 'include' : 'omit', + }); + } + + delete({ params, queries, credentials = false }: RequestOptions, body?: B) { + return fetchApi(this.getUrl(params, queries), { + method: 'DELETE', + headers: this.#headers, + body: body ? JSON.stringify(body) : null, + credentials: credentials ? 'include' : 'omit', + }); + } +} diff --git a/frontend/src/apis/fetch.ts b/frontend/src/apis/fetch.ts new file mode 100644 index 00000000..2c89a0b7 --- /dev/null +++ b/frontend/src/apis/fetch.ts @@ -0,0 +1,13 @@ +export const fetchApi = async (url: string, options: RequestInit) => { + if (!navigator.onLine) { + throw new Error('네트워크 오프라인이 감지되었습니다'); + } + + const response = await fetch(url, options); + + if (!response.ok) { + throw new Error(`에러 발생 상태코드:${response.status}`); + } + + return response; +}; diff --git a/frontend/src/apis/index.ts b/frontend/src/apis/index.ts new file mode 100644 index 00000000..632dceda --- /dev/null +++ b/frontend/src/apis/index.ts @@ -0,0 +1,8 @@ +import { ApiClient } from './ApiClient'; + +export const categoryApi = new ApiClient('/categories'); +export const productApi = new ApiClient('/products'); +export const tagApi = new ApiClient('/tags'); +export const rankApi = new ApiClient('/ranks'); +export const loginApi = new ApiClient('/login'); +export const memberApi = new ApiClient('/members'); diff --git a/frontend/src/assets/.gitkeep b/frontend/src/assets/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/src/assets/characters.svg b/frontend/src/assets/characters.svg new file mode 100644 index 00000000..6580162c --- /dev/null +++ b/frontend/src/assets/characters.svg @@ -0,0 +1,63 @@ + + +펀잇의 캐릭터 + +상품 미리보기 사진입니다. + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/logo.svg b/frontend/src/assets/logo.svg new file mode 100644 index 00000000..26cb9858 --- /dev/null +++ b/frontend/src/assets/logo.svg @@ -0,0 +1,101 @@ + + + 펀잇 로고 + +펀잇 로고 사진입니다. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/samgakgimbab.svg b/frontend/src/assets/samgakgimbab.svg new file mode 100644 index 00000000..a5e9872f --- /dev/null +++ b/frontend/src/assets/samgakgimbab.svg @@ -0,0 +1,87 @@ + + + 펀잇의 캐릭터 + +상품 미리보기 사진입니다. + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/.gitkeep b/frontend/src/components/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/src/components/Common/CategoryMenu/CategoryMenu.stories.tsx b/frontend/src/components/Common/CategoryMenu/CategoryMenu.stories.tsx new file mode 100644 index 00000000..b43814b7 --- /dev/null +++ b/frontend/src/components/Common/CategoryMenu/CategoryMenu.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import CategoryMenu from './CategoryMenu'; + +const meta: Meta = { + title: 'common/CategoryMenu', + component: CategoryMenu, +}; + +export default meta; +type Story = StoryObj; + +export const FoodCategory: Story = { + args: { + menuVariant: 'food', + }, +}; + +export const StoreCategory: Story = { + args: { + menuVariant: 'store', + }, +}; diff --git a/frontend/src/components/Common/CategoryMenu/CategoryMenu.tsx b/frontend/src/components/Common/CategoryMenu/CategoryMenu.tsx new file mode 100644 index 00000000..90a7d500 --- /dev/null +++ b/frontend/src/components/Common/CategoryMenu/CategoryMenu.tsx @@ -0,0 +1,75 @@ +import { Button, theme } from '@fun-eat/design-system'; +import type { CSSProp } from 'styled-components'; +import styled from 'styled-components'; + +import { useCategoryContext } from '@/hooks/context'; +import { useCategoryQuery } from '@/hooks/queries/product'; +import type { CategoryVariant } from '@/types/common'; + +interface CategoryMenuProps { + menuVariant: CategoryVariant; +} + +const CategoryMenu = ({ menuVariant }: CategoryMenuProps) => { + const { data: categoryList } = useCategoryQuery(menuVariant); + const { categoryIds, selectCategory } = useCategoryContext(); + + const currentCategoryId = categoryIds[menuVariant]; + + return ( + + {categoryList?.map((menu) => { + const isSelected = menu.id === currentCategoryId; + return ( +
  • + selectCategory(menuVariant, menu.id)} + aria-pressed={isSelected} + > + {menu.name} + +
  • + ); + })} +
    + ); +}; + +export default CategoryMenu; + +type CategoryMenuStyleProps = Pick; + +const CategoryMenuContainer = styled.ul` + display: flex; + gap: 8px; + white-space: nowrap; + overflow-x: auto; + + &::-webkit-scrollbar { + display: none; + } +`; + +const CategoryButton = styled(Button)<{ isSelected: boolean } & CategoryMenuStyleProps>` + padding: 6px 12px; + ${({ isSelected, menuVariant }) => (isSelected ? selectedCategoryMenuStyles[menuVariant] : '')} +`; + +const selectedCategoryMenuStyles: Record = { + food: ` + background: ${theme.colors.gray5}; + color: ${theme.textColors.white}; + `, + store: ` + background: ${theme.colors.primary}; + color: ${theme.textColors.default}; + `, +}; diff --git a/frontend/src/components/Common/ErrorBoundary/ErrorBoundary.tsx b/frontend/src/components/Common/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 00000000..44ada325 --- /dev/null +++ b/frontend/src/components/Common/ErrorBoundary/ErrorBoundary.tsx @@ -0,0 +1,36 @@ +import type { ComponentType, PropsWithChildren } from 'react'; +import { Component } from 'react'; + +export interface FallbackProps { + message: string; +} + +interface ErrorBoundaryProps { + fallback: ComponentType; +} + +interface ErrorBoundaryState { + error: Error | null; +} + +class ErrorBoundary extends Component, ErrorBoundaryState> { + state: ErrorBoundaryState = { + error: null, + }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + render() { + const { fallback: FallbackComponent } = this.props; + + if (this.state.error) { + return ; + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/frontend/src/components/Common/ErrorComponent/ErrorComponent.stories.tsx b/frontend/src/components/Common/ErrorComponent/ErrorComponent.stories.tsx new file mode 100644 index 00000000..ee51fcea --- /dev/null +++ b/frontend/src/components/Common/ErrorComponent/ErrorComponent.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import ErrorComponent from './ErrorComponent'; + +const meta: Meta = { + title: 'common/ErrorComponent', + component: ErrorComponent, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Common/ErrorComponent/ErrorComponent.tsx b/frontend/src/components/Common/ErrorComponent/ErrorComponent.tsx new file mode 100644 index 00000000..bc66e1f1 --- /dev/null +++ b/frontend/src/components/Common/ErrorComponent/ErrorComponent.tsx @@ -0,0 +1,5 @@ +const ErrorComponent = () => { + return
    에러가 발생했습니다.
    ; +}; + +export default ErrorComponent; diff --git a/frontend/src/components/Common/Header/Header.stories.tsx b/frontend/src/components/Common/Header/Header.stories.tsx new file mode 100644 index 00000000..b88b4258 --- /dev/null +++ b/frontend/src/components/Common/Header/Header.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import Header from './Header'; + +const meta: Meta = { + title: 'common/Header', + component: Header, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Common/Header/Header.tsx b/frontend/src/components/Common/Header/Header.tsx new file mode 100644 index 00000000..e78c715c --- /dev/null +++ b/frontend/src/components/Common/Header/Header.tsx @@ -0,0 +1,26 @@ +import { Link } from '@fun-eat/design-system'; +import { Link as RouterLink } from 'react-router-dom'; +import styled from 'styled-components'; + +import Logo from '@/assets/logo.svg'; +import { PATH } from '@/constants/path'; + +const Header = () => { + return ( + + + + + + ); +}; + +export default Header; + +const HeaderContainer = styled.header` + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 60px; +`; diff --git a/frontend/src/components/Common/Input/Input.stories.tsx b/frontend/src/components/Common/Input/Input.stories.tsx new file mode 100644 index 00000000..0325e14b --- /dev/null +++ b/frontend/src/components/Common/Input/Input.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import Input from './Input'; +import SvgIcon from '../Svg/SvgIcon'; + +const meta: Meta = { + title: 'common/Input', + component: Input, + argTypes: { + rightIcon: { + control: { type: 'boolean' }, + mapping: { false: '', true: }, + }, + }, + args: { + customWidth: '300px', + isError: false, + rightIcon: false, + errorMessage: '', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithPlaceholder: Story = { + args: { + placeholder: '상품 이름을 검색하세요.', + }, +}; + +export const WithIcon: Story = { + args: { + placeholder: '상품 이름을 검색하세요.', + rightIcon: true, + }, +}; + +export const Error: Story = { + args: { + isError: true, + errorMessage: '10글자 이내로 입력해주세요.', + }, +}; diff --git a/frontend/src/components/Common/Input/Input.tsx b/frontend/src/components/Common/Input/Input.tsx new file mode 100644 index 00000000..ef7ecee1 --- /dev/null +++ b/frontend/src/components/Common/Input/Input.tsx @@ -0,0 +1,85 @@ +import { Button, Text, theme } from '@fun-eat/design-system'; +import type { ComponentPropsWithRef, ForwardedRef, ReactNode } from 'react'; +import { forwardRef } from 'react'; +import styled from 'styled-components'; + +interface InputProps extends ComponentPropsWithRef<'input'> { + /** + * Input 컴포넌트의 너비값입니다. + */ + customWidth?: string; + /** + * Input value에 에러가 있는지 여부입니다. + */ + isError?: boolean; + /** + * Input 컴포넌트 오른쪽에 위치할 아이콘입니다. + */ + rightIcon?: ReactNode; + /** + * isError가 true일 때 보여줄 에러 메시지입니다. + */ + errorMessage?: string; +} + +const Input = forwardRef( + ( + { customWidth = '300px', isError = false, rightIcon, errorMessage, ...props }: InputProps, + ref: ForwardedRef + ) => { + return ( + <> + + + {rightIcon && {rightIcon}} + + {isError && {errorMessage}} + + ); + } +); + +Input.displayName = 'Input'; + +export default Input; + +type InputContainerStyleProps = Pick; +type CustomInputStyleProps = Pick; + +const InputContainer = styled.div` + position: relative; + max-width: ${({ customWidth }) => customWidth}; + text-align: center; +`; + +const CustomInput = styled.input` + width: 100%; + height: 40px; + padding: 10px 0 10px 12px; + color: ${({ isError }) => (isError ? theme.colors.error : theme.textColors.default)}; + border: 1px solid ${({ isError }) => (isError ? theme.colors.error : theme.borderColors.default)}; + border-radius: 5px; + + &:focus { + outline: none; + border: 2px solid ${({ isError }) => (isError ? theme.colors.error : theme.borderColors.strong)}; + } + + &::placeholder { + font-size: ${theme.fontSizes.sm}; + color: ${theme.textColors.disabled}; + } +`; + +const IconWrapper = styled(Button)` + position: absolute; + top: 0; + right: 0; + height: 100%; + margin-right: 8px; +`; + +const ErrorMessage = styled(Text)` + font-size: ${theme.fontSizes.xs}; + color: ${theme.colors.error}; +`; diff --git a/frontend/src/components/Common/Loading/Loading.stories.tsx b/frontend/src/components/Common/Loading/Loading.stories.tsx new file mode 100644 index 00000000..3b866175 --- /dev/null +++ b/frontend/src/components/Common/Loading/Loading.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import Loading from './Loading'; + +const meta: Meta = { + title: 'common/Loading', + component: Loading, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Common/Loading/Loading.tsx b/frontend/src/components/Common/Loading/Loading.tsx new file mode 100644 index 00000000..17ce56a8 --- /dev/null +++ b/frontend/src/components/Common/Loading/Loading.tsx @@ -0,0 +1,5 @@ +const Loading = () => { + return
    로딩중..
    ; +}; + +export default Loading; diff --git a/frontend/src/components/Common/MoreButton/MoreButton.stories.tsx b/frontend/src/components/Common/MoreButton/MoreButton.stories.tsx new file mode 100644 index 00000000..292e12c0 --- /dev/null +++ b/frontend/src/components/Common/MoreButton/MoreButton.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import MoreButton from './MoreButton'; + +const meta: Meta = { + title: 'common/MoreButton', + component: MoreButton, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Common/MoreButton/MoreButton.tsx b/frontend/src/components/Common/MoreButton/MoreButton.tsx new file mode 100644 index 00000000..36097f83 --- /dev/null +++ b/frontend/src/components/Common/MoreButton/MoreButton.tsx @@ -0,0 +1,46 @@ +import { Link, Text } from '@fun-eat/design-system'; +import { Link as RouterLink } from 'react-router-dom'; +import styled from 'styled-components'; + +import SvgIcon from '../Svg/SvgIcon'; + +import { PATH } from '@/constants/path'; + +const MoreButton = () => { + return ( + + + + + + + 더보기 + + + + ); +}; + +export default MoreButton; + +const MoreButtonWrapper = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 110px; + height: 110px; + border-radius: 5px; + background: ${({ theme }) => theme.colors.gray1}; +`; + +const PlusIconWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + margin-bottom: 5px; + border-radius: 50%; + background: ${({ theme }) => theme.colors.white}; +`; diff --git a/frontend/src/components/Common/NavigationBar/NavigationBar.stories.tsx b/frontend/src/components/Common/NavigationBar/NavigationBar.stories.tsx new file mode 100644 index 00000000..a9242715 --- /dev/null +++ b/frontend/src/components/Common/NavigationBar/NavigationBar.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import NavigationBar from './NavigationBar'; + +const meta: Meta = { + title: 'common/NavigationBar', + component: NavigationBar, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( +
    + +
    + ), +}; diff --git a/frontend/src/components/Common/NavigationBar/NavigationBar.tsx b/frontend/src/components/Common/NavigationBar/NavigationBar.tsx new file mode 100644 index 00000000..e09ac61b --- /dev/null +++ b/frontend/src/components/Common/NavigationBar/NavigationBar.tsx @@ -0,0 +1,63 @@ +import { Link, Text, theme } from '@fun-eat/design-system'; +import { Link as RouterLink, useLocation } from 'react-router-dom'; +import styled from 'styled-components'; + +import SvgIcon from '../Svg/SvgIcon'; + +import { NAVIGATION_MENU } from '@/constants'; + +const NavigationBar = () => { + const location = useLocation(); + + return ( + + + {NAVIGATION_MENU.map(({ variant, name, path }) => { + const currentPath = location.pathname.split('/')[1]; + const isSelected = currentPath === path.split('/')[1]; + + return ( + + + + + {name} + + + + ); + })} + + + ); +}; + +export default NavigationBar; + +const NavigationBarContainer = styled.nav` + width: 100%; + height: 60px; +`; + +const NavigationBarList = styled.ul` + display: flex; + align-items: center; + justify-content: space-around; + padding-top: 12px; + border: 1px solid ${({ theme }) => theme.borderColors.disabled}; + border-bottom: none; + border-top-right-radius: 20px; + border-top-left-radius: 20px; +`; + +const NavigationItem = styled.li` + height: 50px; +`; + +const NavigationLink = styled(Link)` + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + gap: 4px; +`; diff --git a/frontend/src/components/Common/ScrollButton/ScrollButton.stories.tsx b/frontend/src/components/Common/ScrollButton/ScrollButton.stories.tsx new file mode 100644 index 00000000..50b796d2 --- /dev/null +++ b/frontend/src/components/Common/ScrollButton/ScrollButton.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import ScrollButton from './ScrollButton'; + +const meta: Meta = { + title: 'common/ScrollButton', + component: ScrollButton, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Common/ScrollButton/ScrollButton.tsx b/frontend/src/components/Common/ScrollButton/ScrollButton.tsx new file mode 100644 index 00000000..4bdae819 --- /dev/null +++ b/frontend/src/components/Common/ScrollButton/ScrollButton.tsx @@ -0,0 +1,44 @@ +import { Button } from '@fun-eat/design-system'; +import { useState, useEffect } from 'react'; +import { styled } from 'styled-components'; + +import SvgIcon from '../Svg/SvgIcon'; + +import { useScroll } from '@/hooks/common'; + +const ScrollButton = () => { + const { scrollToTop } = useScroll(); + const [scrollTop, setScrollTop] = useState(false); + + const handleScroll = () => { + setScrollTop(true); + }; + + useEffect(() => { + const mainElement = document.getElementById('main'); + if (mainElement) { + scrollToTop(mainElement); + setScrollTop(false); + } + }, [scrollTop]); + + return ( + + + + ); +}; + +export default ScrollButton; + +const ScrollButtonWrapper = styled(Button)` + position: absolute; + bottom: 90px; + right: 32%; + border-radius: 50%; + + &:hover { + transform: scale(1.1); + transition: all 200ms ease-in-out; + } +`; diff --git a/frontend/src/components/Common/SectionTitle/SectionTitle.stories.tsx b/frontend/src/components/Common/SectionTitle/SectionTitle.stories.tsx new file mode 100644 index 00000000..1fe09019 --- /dev/null +++ b/frontend/src/components/Common/SectionTitle/SectionTitle.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import SectionTitle from './SectionTitleTitle'; + +const meta: Meta = { + title: 'common/SectionTitle', + component: SectionTitle, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + name: '사이다', + bookmark: false, + }, +}; + +export const Bookmarked: Story = { + args: { + name: '사이다', + bookmark: true, + }, +}; diff --git a/frontend/src/components/Common/SectionTitle/SectionTitleTitle.tsx b/frontend/src/components/Common/SectionTitle/SectionTitleTitle.tsx new file mode 100644 index 00000000..c4784145 --- /dev/null +++ b/frontend/src/components/Common/SectionTitle/SectionTitleTitle.tsx @@ -0,0 +1,51 @@ +import { Button, Heading, theme } from '@fun-eat/design-system'; +import styled from 'styled-components'; + +import { SvgIcon } from '@/components/Common'; +import { useRoutePage } from '@/hooks/common'; + +interface SectionTitleProps { + name: string; + bookmark: boolean; +} + +const SectionTitle = ({ name, bookmark }: SectionTitleProps) => { + const { routeBack } = useRoutePage(); + + return ( + + + + + {name} + + + + + ); +}; + +export default SectionTitle; + +const SectionTitleContainer = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +`; + +const SectionTitleWrapper = styled.div` + display: flex; + align-items: center; + column-gap: 16px; + + svg { + padding-top: 2px; + } +`; diff --git a/frontend/src/components/Common/SortButton/SortButton.stories.tsx b/frontend/src/components/Common/SortButton/SortButton.stories.tsx new file mode 100644 index 00000000..004845ef --- /dev/null +++ b/frontend/src/components/Common/SortButton/SortButton.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import SortButton from './SortButton'; + +import { PRODUCT_SORT_OPTIONS } from '@/constants'; + +const meta: Meta = { + title: 'common/SortButton', + component: SortButton, + args: { + option: PRODUCT_SORT_OPTIONS[0], + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Common/SortButton/SortButton.tsx b/frontend/src/components/Common/SortButton/SortButton.tsx new file mode 100644 index 00000000..c3119508 --- /dev/null +++ b/frontend/src/components/Common/SortButton/SortButton.tsx @@ -0,0 +1,32 @@ +import { Button, useTheme } from '@fun-eat/design-system'; +import styled from 'styled-components'; + +import SvgIcon from '../Svg/SvgIcon'; + +import type { SortOption } from '@/types/common'; + +interface SortButtonProps { + option: SortOption; + onClick: () => void; +} + +const SortButton = ({ option, onClick }: SortButtonProps) => { + const theme = useTheme(); + + return ( + + + {option.label} + + ); +}; + +export default SortButton; + +const SortButtonContainer = styled(Button)` + display: flex; + align-items: center; + justify-content: flex-end; + column-gap: 4px; + padding: 0; +`; diff --git a/frontend/src/components/Common/SortOptionList/SortOptionList.stories.tsx b/frontend/src/components/Common/SortOptionList/SortOptionList.stories.tsx new file mode 100644 index 00000000..779e6894 --- /dev/null +++ b/frontend/src/components/Common/SortOptionList/SortOptionList.stories.tsx @@ -0,0 +1,38 @@ +import { BottomSheet, useBottomSheet } from '@fun-eat/design-system'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useEffect } from 'react'; + +import SortOptionList from './SortOptionList'; + +import { PRODUCT_SORT_OPTIONS } from '@/constants'; +import { useSortOption } from '@/hooks/common'; + +const meta: Meta = { + title: 'common/SortOptionList', + component: SortOptionList, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const { ref, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); + const { selectedOption, selectSortOption } = useSortOption(PRODUCT_SORT_OPTIONS[0]); + + useEffect(() => { + handleOpenBottomSheet(); + }, []); + + return ( + + + + ); + }, +}; diff --git a/frontend/src/components/Common/SortOptionList/SortOptionList.tsx b/frontend/src/components/Common/SortOptionList/SortOptionList.tsx new file mode 100644 index 00000000..879f2af5 --- /dev/null +++ b/frontend/src/components/Common/SortOptionList/SortOptionList.tsx @@ -0,0 +1,71 @@ +import { Button } from '@fun-eat/design-system'; +import styled from 'styled-components'; + +import type { SortOption } from '@/types/common'; + +interface SortOptionListProps { + options: readonly SortOption[]; + selectedOption: SortOption; + selectSortOption: (selectedOptionLabel: SortOption) => void; + close: () => void; +} + +const SortOptionList = ({ options, selectedOption, selectSortOption, close }: SortOptionListProps) => { + const handleSelectedOption = (sortOption: SortOption) => { + selectSortOption(sortOption); + close(); + }; + + return ( + + {options.map((sortOption) => { + const isSelected = sortOption.label === selectedOption.label; + return ( +
  • + handleSelectedOption(sortOption)} + > + {sortOption.label} + +
  • + ); + })} +
    + ); +}; + +export default SortOptionList; + +const SortOptionListContainer = styled.ul` + padding: 20px; + + & > li { + height: 60px; + border-bottom: 1px solid ${({ theme }) => theme.dividerColors.disabled}; + line-height: 60px; + } + + & > li:last-of-type { + border: none; + } +`; + +const SortOptionButton = styled(Button)` + padding: 10px 0; + border: none; + outline: transparent; + text-align: left; + + &:hover { + font-weight: ${({ theme }) => theme.fontWeights.bold}; + color: ${({ theme }) => theme.textColors.default}; + transition: all 200ms ease-in; + } +`; diff --git a/frontend/src/components/Common/Svg/SvgIcon.stories.tsx b/frontend/src/components/Common/Svg/SvgIcon.stories.tsx new file mode 100644 index 00000000..87c7f7e0 --- /dev/null +++ b/frontend/src/components/Common/Svg/SvgIcon.stories.tsx @@ -0,0 +1,37 @@ +import { theme } from '@fun-eat/design-system'; +import type { Meta, StoryObj } from '@storybook/react'; + +import SvgIcon, { SVG_ICON_VARIANTS } from './SvgIcon'; + +const meta: Meta = { + title: 'common/SvgIcon', + component: SvgIcon, + argTypes: { + color: { + control: { + type: 'color', + }, + }, + }, + args: { + variant: 'recipe', + color: theme.colors.gray4, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = {}; + +export const SvgIcons: Story = { + render: () => { + return ( + <> + {SVG_ICON_VARIANTS.map((variant) => ( + + ))} + + ); + }, +}; diff --git a/frontend/src/components/Common/Svg/SvgIcon.tsx b/frontend/src/components/Common/Svg/SvgIcon.tsx new file mode 100644 index 00000000..e9b7125d --- /dev/null +++ b/frontend/src/components/Common/Svg/SvgIcon.tsx @@ -0,0 +1,52 @@ +import { theme } from '@fun-eat/design-system'; +import type { ComponentPropsWithoutRef, CSSProperties } from 'react'; + +export const SVG_ICON_VARIANTS = [ + 'recipe', + 'list', + 'profile', + 'search', + 'arrow', + 'bookmark', + 'bookmarkFilled', + 'review', + 'star', + 'favorite', + 'favoriteFilled', + 'home', + 'sort', + 'kakao', + 'close', + 'triangle', + 'plus', +] as const; +export type SvgIconVariant = (typeof SVG_ICON_VARIANTS)[number]; + +interface SvgIconProps extends ComponentPropsWithoutRef<'svg'> { + /** + * SvgSprite 컴포넌트의 symbol id입니다. + */ + variant: SvgIconVariant; + /** + * SvgIcon의 색상입니다. (기본값 gray4) + */ + color?: CSSProperties['color']; + /** + * SvgIcon의 너비입니다. (기본값 24) + */ + width?: number; + /** + * SvgIcon의 높이입니다. (기본값 24) + */ + height?: number; +} + +const SvgIcon = ({ variant, width = 24, height = 24, color = theme.colors.gray4, ...props }: SvgIconProps) => { + return ( + + + + ); +}; + +export default SvgIcon; diff --git a/frontend/src/components/Common/Svg/SvgSprite.tsx b/frontend/src/components/Common/Svg/SvgSprite.tsx new file mode 100644 index 00000000..bbcb69ce --- /dev/null +++ b/frontend/src/components/Common/Svg/SvgSprite.tsx @@ -0,0 +1,75 @@ +const SvgSprite = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default SvgSprite; diff --git a/frontend/src/components/Common/TabMenu/TabMenu.stories.tsx b/frontend/src/components/Common/TabMenu/TabMenu.stories.tsx new file mode 100644 index 00000000..4ba72445 --- /dev/null +++ b/frontend/src/components/Common/TabMenu/TabMenu.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import TabMenu from './TabMenu'; + +const meta: Meta = { + title: 'common/TabMenu', + component: TabMenu, + args: { + tabMenus: ['리뷰 1,200', '꿀조합'], + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: ({ ...args }) => ( +
    + +
    + ), +}; diff --git a/frontend/src/components/Common/TabMenu/TabMenu.tsx b/frontend/src/components/Common/TabMenu/TabMenu.tsx new file mode 100644 index 00000000..0b4af1a1 --- /dev/null +++ b/frontend/src/components/Common/TabMenu/TabMenu.tsx @@ -0,0 +1,58 @@ +import { Button } from '@fun-eat/design-system'; +import type { ForwardedRef } from 'react'; +import { forwardRef, useState } from 'react'; +import styled from 'styled-components'; + +interface TabMenuProps { + tabMenus: string[]; +} + +const TabMenu = ({ tabMenus }: TabMenuProps, ref: ForwardedRef) => { + const [selectedTab, setSelectedTab] = useState(0); + + const selectTabMenu = (selectedIndex: number) => { + setSelectedTab(selectedIndex); + }; + + return ( + + {tabMenus.map((menu, index) => { + const isSelected = selectedTab === index; + return ( + + selectTabMenu(index)} + > + {menu} + + + ); + })} + + ); +}; + +export default forwardRef(TabMenu); + +const TabMenuContainer = styled.ul` + display: flex; +`; + +const TabMenuItem = styled.li<{ isSelected: boolean }>` + flex-grow: 1; + width: 50%; + height: 45px; + border-bottom: 2px solid + ${({ isSelected, theme }) => (isSelected ? theme.borderColors.strong : theme.borderColors.disabled)}; +`; + +const TabMenuButton = styled(Button)` + padding: 0; + line-height: 45px; +`; diff --git a/frontend/src/components/Common/TagList/TagList.stories.tsx b/frontend/src/components/Common/TagList/TagList.stories.tsx new file mode 100644 index 00000000..4ac76785 --- /dev/null +++ b/frontend/src/components/Common/TagList/TagList.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import TagList from './TagList'; + +import productDetails from '@/mocks/data/productDetails.json'; + +const meta: Meta = { + title: 'common/TagList', + component: TagList, + args: { + tags: productDetails[0].tags, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Common/TagList/TagList.tsx b/frontend/src/components/Common/TagList/TagList.tsx new file mode 100644 index 00000000..abf2817e --- /dev/null +++ b/frontend/src/components/Common/TagList/TagList.tsx @@ -0,0 +1,37 @@ +import { Badge } from '@fun-eat/design-system'; +import styled from 'styled-components'; + +import type { Tag } from '@/types/common'; +import { convertTagColor } from '@/utils/convertTagColor'; + +interface TagListProps { + tags: Tag[]; +} + +const TagList = ({ tags }: TagListProps) => { + return ( + + {tags.map((tag) => { + const tagColor = convertTagColor(tag.tagType); + return ( +
  • + + {tag.name} + +
  • + ); + })} +
    + ); +}; + +export default TagList; + +const TagListContainer = styled.ul` + display: flex; + column-gap: 8px; +`; + +const TagBadge = styled(Badge)` + font-weight: bold; +`; diff --git a/frontend/src/components/Common/Title/Title.stories.tsx b/frontend/src/components/Common/Title/Title.stories.tsx new file mode 100644 index 00000000..9f455c1a --- /dev/null +++ b/frontend/src/components/Common/Title/Title.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import Title from './Title'; + +const meta: Meta = { + title: 'common/Title', + component: Title, + args: { + headingTitle: '상품 목록', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Common/Title/Title.tsx b/frontend/src/components/Common/Title/Title.tsx new file mode 100644 index 00000000..b0e9b941 --- /dev/null +++ b/frontend/src/components/Common/Title/Title.tsx @@ -0,0 +1,53 @@ +import { Link, Text, theme } from '@fun-eat/design-system'; +import { Link as RouterLink } from 'react-router-dom'; +import styled from 'styled-components'; + +import SvgIcon from '../Svg/SvgIcon'; + +import { PATH } from '@/constants/path'; + +interface TitleProps { + headingTitle: string; + routeDestination: string; +} + +const Title = ({ headingTitle, routeDestination }: TitleProps) => { + return ( + + + + + + + {headingTitle} + + + + + ); +}; + +export default Title; + +const TitleContainer = styled.div` + display: flex; + flex-direction: row; + justify-content: center; + position: relative; +`; + +const HomeLink = styled(Link)` + position: absolute; + top: 8px; + left: 0; +`; + +const TitleLink = styled(Link)` + display: flex; + align-items: center; + gap: 20px; +`; + +const DropDownIcon = styled(SvgIcon)` + rotate: 270deg; +`; diff --git a/frontend/src/components/Common/index.ts b/frontend/src/components/Common/index.ts new file mode 100644 index 00000000..2932d5b6 --- /dev/null +++ b/frontend/src/components/Common/index.ts @@ -0,0 +1,14 @@ +export { default as CategoryMenu } from './CategoryMenu/CategoryMenu'; +export { default as Header } from './Header/Header'; +export { default as NavigationBar } from './NavigationBar/NavigationBar'; +export { default as SortButton } from './SortButton/SortButton'; +export { default as SortOptionList } from './SortOptionList/SortOptionList'; +export { default as SvgSprite } from './Svg/SvgSprite'; +export { default as SvgIcon } from './Svg/SvgIcon'; +export { default as TabMenu } from './TabMenu/TabMenu'; +export { default as TagList } from './TagList/TagList'; +export { default as Title } from './Title/Title'; +export { default as SectionTitle } from './SectionTitle/SectionTitleTitle'; +export { default as ScrollButton } from './ScrollButton/ScrollButton'; +export { default as Input } from './Input/Input'; +export { default as MoreButton } from './MoreButton/MoreButton'; diff --git a/frontend/src/components/Layout/AuthLayout.tsx b/frontend/src/components/Layout/AuthLayout.tsx new file mode 100644 index 00000000..c5951501 --- /dev/null +++ b/frontend/src/components/Layout/AuthLayout.tsx @@ -0,0 +1,24 @@ +import type { PropsWithChildren } from 'react'; +import styled from 'styled-components'; + +const AuthLayout = ({ children }: PropsWithChildren) => { + return ( + + {children} + + ); +}; + +export default AuthLayout; + +const AuthLayoutContainer = styled.div` + max-width: 600px; + height: 100%; + margin: 0 auto; +`; + +const MainWrapper = styled.main` + height: 100%; + padding: 20px; + overflow-y: auto; +`; diff --git a/frontend/src/components/Layout/DefaultLayout.tsx b/frontend/src/components/Layout/DefaultLayout.tsx new file mode 100644 index 00000000..5fb6bc9b --- /dev/null +++ b/frontend/src/components/Layout/DefaultLayout.tsx @@ -0,0 +1,29 @@ +import type { PropsWithChildren } from 'react'; +import styled from 'styled-components'; + +import Header from '../Common/Header/Header'; +import NavigationBar from '../Common/NavigationBar/NavigationBar'; + +const DefaultLayout = ({ children }: PropsWithChildren) => { + return ( + +
    + {children} + + + ); +}; + +export default DefaultLayout; + +const DefaultLayoutContainer = styled.div` + max-width: 600px; + height: 100%; + margin: 0 auto; +`; + +const MainWrapper = styled.main` + height: calc(100% - 120px); + padding: 20px; + overflow-y: auto; +`; diff --git a/frontend/src/components/Layout/DetailLayout.tsx b/frontend/src/components/Layout/DetailLayout.tsx new file mode 100644 index 00000000..ec493058 --- /dev/null +++ b/frontend/src/components/Layout/DetailLayout.tsx @@ -0,0 +1,27 @@ +import type { PropsWithChildren } from 'react'; +import styled from 'styled-components'; + +import Header from '../Common/Header/Header'; + +const DetailLayout = ({ children }: PropsWithChildren) => { + return ( + +
    + {children} + + ); +}; + +export default DetailLayout; + +const DetailLayoutContainer = styled.div` + max-width: 600px; + height: 100%; + margin: 0 auto; +`; + +const MainWrapper = styled.main` + height: calc(100% - 60px); + padding: 20px; + overflow-y: auto; +`; diff --git a/frontend/src/components/Layout/index.ts b/frontend/src/components/Layout/index.ts new file mode 100644 index 00000000..76e403e6 --- /dev/null +++ b/frontend/src/components/Layout/index.ts @@ -0,0 +1,3 @@ +export { default as DefaultLayout } from './DefaultLayout'; +export { default as AuthLayout } from './AuthLayout'; +export { default as DetailLayout } from './DetailLayout'; diff --git a/frontend/src/components/Product/PBProductItem/PBProductItem.stories.tsx b/frontend/src/components/Product/PBProductItem/PBProductItem.stories.tsx new file mode 100644 index 00000000..175918ec --- /dev/null +++ b/frontend/src/components/Product/PBProductItem/PBProductItem.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import PBProductItem from './PBProductItem'; + +import pbProducts from '@/mocks/data/pbProducts.json'; + +const meta: Meta = { + title: 'product/PBProductItem', + component: PBProductItem, + args: { + pbProduct: pbProducts.products[0], + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Product/PBProductItem/PBProductItem.tsx b/frontend/src/components/Product/PBProductItem/PBProductItem.tsx new file mode 100644 index 00000000..f350c070 --- /dev/null +++ b/frontend/src/components/Product/PBProductItem/PBProductItem.tsx @@ -0,0 +1,78 @@ +import { Text, theme } from '@fun-eat/design-system'; +import styled from 'styled-components'; + +import PBPreviewImage from '@/assets/samgakgimbab.svg'; +import { SvgIcon } from '@/components/Common'; +import type { PBProduct } from '@/types/product'; + +interface PBProductItemProps { + pbProduct: PBProduct; +} + +const PBProductItem = ({ pbProduct }: PBProductItemProps) => { + const { name, price, image, averageRating } = pbProduct; + + return ( + + {image ? ( + + ) : ( + + )} + + {name} + + + + + {averageRating} + + + + {price.toLocaleString('ko-KR')}원 + + + + + ); +}; + +export default PBProductItem; + +const PBProductItemContainer = styled.div` + width: 110px; +`; + +const PBProductImage = styled.img` + object-fit: cover; +`; + +const PBProductInfoWrapper = styled.div` + height: 50%; + margin-top: 10px; +`; + +const PBProductName = styled(Text)` + display: inline-block; + width: 100%; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +`; + +const PBProductReviewWrapper = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + margin: 5px 0; +`; + +const RatingWrapper = styled.div` + display: flex; + align-items: center; + column-gap: 4px; + + & > svg { + padding-bottom: 2px; + } +`; diff --git a/frontend/src/components/Product/PBProductList/PBProductList.stories.tsx b/frontend/src/components/Product/PBProductList/PBProductList.stories.tsx new file mode 100644 index 00000000..dc1804f0 --- /dev/null +++ b/frontend/src/components/Product/PBProductList/PBProductList.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import PBProductList from './PBProductList'; + +const meta: Meta = { + title: 'product/PBProductList', + component: PBProductList, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Product/PBProductList/PBProductList.tsx b/frontend/src/components/Product/PBProductList/PBProductList.tsx new file mode 100644 index 00000000..0f6a983d --- /dev/null +++ b/frontend/src/components/Product/PBProductList/PBProductList.tsx @@ -0,0 +1,51 @@ +import { Link } from '@fun-eat/design-system'; +import { Link as RouterLink } from 'react-router-dom'; +import styled from 'styled-components'; + +import PBProductItem from '../PBProductItem/PBProductItem'; + +import { MoreButton } from '@/components/Common'; +import { PATH } from '@/constants/path'; +import { useCategoryContext } from '@/hooks/context'; +import { useInfiniteProductsQuery } from '@/hooks/queries/product'; +import displaySlice from '@/utils/displaySlice'; + +interface PBProductListProps { + isHome?: boolean; +} + +const PBProductList = ({ isHome }: PBProductListProps) => { + const { categoryIds } = useCategoryContext(); + + const { data: pbProductListResponse } = useInfiniteProductsQuery(categoryIds.store); + const pbProductList = pbProductListResponse?.pages.flatMap((page) => page.products); + const pbProductsToDisplay = displaySlice(isHome, pbProductList, 10); + + return ( + <> + + {pbProductsToDisplay?.map((pbProduct) => ( +
  • + + + +
  • + ))} + +
    + + ); +}; + +export default PBProductList; + +const PBProductListContainer = styled.ul` + display: flex; + overflow-x: auto; + overflow-y: hidden; + gap: 40px; + + &::-webkit-scrollbar { + display: none; + } +`; diff --git a/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.stories.tsx b/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.stories.tsx new file mode 100644 index 00000000..f6c41d9b --- /dev/null +++ b/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import ProductDetailItem from './ProductDetailItem'; + +import productDetails from '@/mocks/data/productDetails.json'; + +const meta: Meta = { + title: 'product/ProductDetailItem', + component: ProductDetailItem, + args: { + productId: productDetails[0].id, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: ({ ...args }) => ( +
    + +
    + ), +}; diff --git a/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.tsx b/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.tsx new file mode 100644 index 00000000..2c962092 --- /dev/null +++ b/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.tsx @@ -0,0 +1,89 @@ +import { Spacing, Text, useTheme } from '@fun-eat/design-system'; +import styled from 'styled-components'; + +import PreviewImage from '@/assets/characters.svg'; +import { SectionTitle, SvgIcon, TagList } from '@/components/Common'; +import { useProductDetailQuery } from '@/hooks/queries/product'; + +interface ProductDetailItemProps { + productId: number; +} + +const ProductDetailItem = ({ productId }: ProductDetailItemProps) => { + const theme = useTheme(); + + const { data: productDetail } = useProductDetailQuery(productId); + + if (!productDetail) { + return null; + } + + const { name, price, image, content, averageRating, tags, bookmark } = productDetail; + + return ( + <> + + + + {image ? {name} : } + + + 가격 + {price.toLocaleString('ko-KR')}원 + + + 상품 설명 + {content} + + + 평균 평점 + + + {averageRating} + + + + + + + ); +}; + +export default ProductDetailItem; + +const ProductDetailContainer = styled.div` + display: flex; + flex-direction: column; + row-gap: 30px; + g & > img, + svg { + align-self: center; + } +`; + +const DetailInfoWrapper = styled.div` + & > div + div { + margin-top: 10px; + } +`; + +const DescriptionWrapper = styled.div` + display: flex; + column-gap: 20px; + + & > p:first-of-type { + flex-shrink: 0; + width: 60px; + } +`; + +const RatingIconWrapper = styled.div` + display: flex; + align-items: center; + column-gap: 4px; + margin-left: -4px; + + & > svg { + padding-bottom: 2px; + } +`; diff --git a/frontend/src/components/Product/ProductItem/ProductItem.stories.tsx b/frontend/src/components/Product/ProductItem/ProductItem.stories.tsx new file mode 100644 index 00000000..6afb4a23 --- /dev/null +++ b/frontend/src/components/Product/ProductItem/ProductItem.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import ProductItem from './ProductItem'; + +import mockProducts from '@/mocks/data/products.json'; + +const meta: Meta = { + title: 'product/ProductItem', + component: ProductItem, + args: { + product: mockProducts.products[0], + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Product/ProductItem/ProductItem.tsx b/frontend/src/components/Product/ProductItem/ProductItem.tsx new file mode 100644 index 00000000..9fef5396 --- /dev/null +++ b/frontend/src/components/Product/ProductItem/ProductItem.tsx @@ -0,0 +1,84 @@ +import { Text, useTheme } from '@fun-eat/design-system'; +import styled from 'styled-components'; + +import PreviewImage from '@/assets/characters.svg'; +import { SvgIcon } from '@/components/Common'; +import type { Product } from '@/types/product'; +interface ProductItemProps { + product: Product; +} + +const ProductItem = ({ product }: ProductItemProps) => { + const theme = useTheme(); + const { name, price, image, averageRating, reviewCount } = product; + + return ( + + {image ? {`${name}사진`} : } + + + {name} + + + {price.toLocaleString('ko-KR')}원 + + + + + + {averageRating} + + + + + + {reviewCount} + + + + + + ); +}; + +export default ProductItem; + +const ProductItemContainer = styled.div` + display: flex; + align-items: center; + padding: 12px 0; +`; + +const ProductInfoWrapper = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; + margin-left: 30px; +`; + +const ProductReviewWrapper = styled.div` + display: flex; + column-gap: 20px; + margin-left: -2px; +`; + +const RatingIconWrapper = styled.div` + display: flex; + align-items: center; + column-gap: 4px; + + & > svg { + padding-bottom: 2px; + } +`; + +const ReviewIconWrapper = styled.div` + display: flex; + align-items: center; + column-gap: 4px; + + & > svg { + padding-top: 2px; + } +`; diff --git a/frontend/src/components/Product/ProductList/ProductList.stories.tsx b/frontend/src/components/Product/ProductList/ProductList.stories.tsx new file mode 100644 index 00000000..de89073b --- /dev/null +++ b/frontend/src/components/Product/ProductList/ProductList.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import ProductList from './ProductList'; + +const meta: Meta = { + title: 'product/ProductList', + component: ProductList, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Product/ProductList/ProductList.tsx b/frontend/src/components/Product/ProductList/ProductList.tsx new file mode 100644 index 00000000..692b3e09 --- /dev/null +++ b/frontend/src/components/Product/ProductList/ProductList.tsx @@ -0,0 +1,58 @@ +import { Link } from '@fun-eat/design-system'; +import { useRef } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import styled from 'styled-components'; + +import ProductItem from '../ProductItem/ProductItem'; + +import { PRODUCT_SORT_OPTIONS } from '@/constants'; +import { PATH } from '@/constants/path'; +import { useIntersectionObserver, useSortOption } from '@/hooks/common'; +import { useCategoryContext } from '@/hooks/context'; +import { useInfiniteProductsQuery } from '@/hooks/queries/product'; +import type { CategoryVariant } from '@/types/common'; +import displaySlice from '@/utils/displaySlice'; + +interface ProductListProps { + category: CategoryVariant; + isHome?: boolean; +} + +const ProductList = ({ category, isHome }: ProductListProps) => { + const scrollRef = useRef(null); + + const { selectedOption } = useSortOption(PRODUCT_SORT_OPTIONS[0]); + + const { categoryIds } = useCategoryContext(); + + const { fetchNextPage, hasNextPage, data } = useInfiniteProductsQuery(categoryIds[category], selectedOption.value); + const productList = data?.pages.flatMap((page) => page.products); + const productsToDisplay = displaySlice(isHome, productList); + + useIntersectionObserver(fetchNextPage, scrollRef, hasNextPage); + + return ( + <> + + {productsToDisplay?.map((product) => ( +
  • + + + +
  • + ))} +
    +
    + + ); +}; +export default ProductList; + +const ProductListContainer = styled.ul` + display: flex; + flex-direction: column; + + & > li { + border-bottom: 1px solid ${({ theme }) => theme.borderColors.disabled}; + } +`; diff --git a/frontend/src/components/Product/ProductOverviewItem/ProductOverviewItem.stories.tsx b/frontend/src/components/Product/ProductOverviewItem/ProductOverviewItem.stories.tsx new file mode 100644 index 00000000..4dc5590b --- /dev/null +++ b/frontend/src/components/Product/ProductOverviewItem/ProductOverviewItem.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import ProductOverviewItem from './ProductOverviewItem'; + +const meta: Meta = { + title: 'product/ProductOverviewItem', + component: ProductOverviewItem, + args: { + image: 'https://t3.ftcdn.net/jpg/06/06/91/70/240_F_606917032_4ujrrMV8nspZDX8nTgGrTpJ69N9JNxOL.jpg', + name: '소금빵', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Ranking: Story = { + args: { + rank: 1, + }, +}; diff --git a/frontend/src/components/Product/ProductOverviewItem/ProductOverviewItem.tsx b/frontend/src/components/Product/ProductOverviewItem/ProductOverviewItem.tsx new file mode 100644 index 00000000..9441439b --- /dev/null +++ b/frontend/src/components/Product/ProductOverviewItem/ProductOverviewItem.tsx @@ -0,0 +1,53 @@ +import { Text } from '@fun-eat/design-system'; +import styled from 'styled-components'; + +import PreviewImage from '@/assets/characters.svg'; + +interface ProductOverviewItemProps { + rank?: number; + name?: string; + image?: string; +} + +const ProductOverviewItem = ({ rank, name, image }: ProductOverviewItemProps) => { + return ( + + + {rank ?? ''} + + {image ? ( + + ) : ( + + )} + + {name} + + + ); +}; + +export default ProductOverviewItem; + +const ProductOverviewContainer = styled.div>` + display: flex; + align-items: center; + height: 50px; + padding-left: 15px; + gap: 15px; + border-radius: ${({ theme }) => theme.borderRadius.xs}; + background: ${({ theme, rank }) => (rank ? theme.colors.gray1 : theme.colors.white)}; +`; + +const ProductOverviewImage = styled.img` + width: 45px; + height: 45px; + border-radius: 50%; +`; + +const ProductPreviewImage = styled(PreviewImage)` + width: 45px; + height: 45px; + border-radius: 50%; + background-color: ${({ theme }) => theme.colors.white}; +`; diff --git a/frontend/src/components/Product/index.ts b/frontend/src/components/Product/index.ts new file mode 100644 index 00000000..6ae0f500 --- /dev/null +++ b/frontend/src/components/Product/index.ts @@ -0,0 +1,5 @@ +export { default as ProductDetailItem } from './ProductDetailItem/ProductDetailItem'; +export { default as ProductItem } from './ProductItem/ProductItem'; +export { default as ProductList } from './ProductList/ProductList'; +export { default as ProductOverviewItem } from './ProductOverviewItem/ProductOverviewItem'; +export { default as PBProductList } from './PBProductList/PBProductList'; diff --git a/frontend/src/components/Rank/ProductRankingList/ProductRankingList.stories.tsx b/frontend/src/components/Rank/ProductRankingList/ProductRankingList.stories.tsx new file mode 100644 index 00000000..68b955e5 --- /dev/null +++ b/frontend/src/components/Rank/ProductRankingList/ProductRankingList.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import ProductRankingList from './ProductRankingList'; + +const meta: Meta = { + title: 'product/ProductRankingList', + component: ProductRankingList, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Rank/ProductRankingList/ProductRankingList.tsx b/frontend/src/components/Rank/ProductRankingList/ProductRankingList.tsx new file mode 100644 index 00000000..aa934d5a --- /dev/null +++ b/frontend/src/components/Rank/ProductRankingList/ProductRankingList.tsx @@ -0,0 +1,27 @@ +import { Spacing } from '@fun-eat/design-system'; + +import { ProductOverviewItem } from '@/components/Product'; +import { useProductRankingQuery } from '@/hooks/queries/rank'; +import displaySlice from '@/utils/displaySlice'; + +interface ProductRankingListProps { + isHome?: boolean; +} + +const ProductRankingList = ({ isHome }: ProductRankingListProps) => { + const { data: productRankings } = useProductRankingQuery(); + const productsToDisplay = displaySlice(isHome, productRankings?.products, 3); + + return ( +
      + {productsToDisplay?.map(({ id, name, image }, index) => ( +
    • + + +
    • + ))} +
    + ); +}; + +export default ProductRankingList; diff --git a/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.stories.tsx b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.stories.tsx new file mode 100644 index 00000000..59f7425f --- /dev/null +++ b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import ReviewRankingItem from './ReviewRankingItem'; + +const meta: Meta = { + title: 'review/ReviewRankingItem', + component: ReviewRankingItem, + args: { + reviewRanking: { + reviewId: 1, + productId: 5, + productName: '구운감자슬림명란마요', + content: + '할머니가 먹을 거 같은 맛입니다. 1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데요 이것보다 긴 리뷰도 잘려 보인답니다', + rating: 4.0, + favoriteCount: 1256, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx new file mode 100644 index 00000000..19da423c --- /dev/null +++ b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx @@ -0,0 +1,79 @@ +import { Spacing, Text, theme } from '@fun-eat/design-system'; +import styled from 'styled-components'; + +import { SvgIcon } from '@/components/Common'; +import type { ReviewRanking } from '@/types/ranking'; + +interface ReviewRankingItemProps { + reviewRanking: ReviewRanking; +} + +const ReviewRankingItem = ({ reviewRanking }: ReviewRankingItemProps) => { + const { productName, content, rating, favoriteCount } = reviewRanking; + + return ( + + + {productName} + + + {content} + + + + + + + {favoriteCount} + + + + + + {rating.toFixed(1)} + + + + + ); +}; + +export default ReviewRankingItem; + +const ReviewRankingItemContainer = styled.div` + display: flex; + flex-direction: column; + padding: 12px; + gap: 4px; + border: 1px solid ${({ theme }) => theme.borderColors.disabled}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; +`; + +const ReviewText = styled(Text)` + display: -webkit-inline-box; + overflow: hidden; + text-overflow: ellipsis; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +`; + +const FavoriteStarWrapper = styled.div` + display: flex; + gap: 4px; +`; + +const FavoriteIconWrapper = styled.div` + display: flex; + align-items: center; + gap: 4px; +`; + +const RatingIconWrapper = styled.div` + display: flex; + align-items: center; + gap: 2px; + + & > svg { + padding-bottom: 2px; + } +`; diff --git a/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.stories.tsx b/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.stories.tsx new file mode 100644 index 00000000..671dd89e --- /dev/null +++ b/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import ReviewRankingList from './ReviewRankingList'; + +const meta: Meta = { + title: 'review/ReviewRankingList', + component: ReviewRankingList, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx b/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx new file mode 100644 index 00000000..79477bab --- /dev/null +++ b/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx @@ -0,0 +1,33 @@ +import styled from 'styled-components'; + +import ReviewRankingItem from '../ReviewRankingItem/ReviewRankingItem'; + +import { useReviewRankingQuery } from '@/hooks/queries/rank'; +import useDisplaySlice from '@/utils/displaySlice'; + +interface ReviewRankingListProps { + isHome?: boolean; +} + +const ReviewRankingList = ({ isHome }: ReviewRankingListProps) => { + const { data: reviewRankings } = useReviewRankingQuery(); + const reviewsToDisplay = useDisplaySlice(isHome, reviewRankings?.reviews); + + return ( + + {reviewsToDisplay?.map((reviewRanking) => ( +
  • + +
  • + ))} +
    + ); +}; + +export default ReviewRankingList; + +const ReviewRankingListContainer = styled.ul` + display: flex; + flex-direction: column; + gap: 20px; +`; diff --git a/frontend/src/components/Rank/index.ts b/frontend/src/components/Rank/index.ts new file mode 100644 index 00000000..c19607dd --- /dev/null +++ b/frontend/src/components/Rank/index.ts @@ -0,0 +1,3 @@ +export { default as ReviewRankingItem } from '../Rank/ReviewRankingItem/ReviewRankingItem'; +export { default as ReviewRankingList } from './ReviewRankingList/ReviewRankingList'; +export { default as ProductRankingList } from './ProductRankingList/ProductRankingList'; diff --git a/frontend/src/components/Review/RebuyCheckbox/RebuyCheckbox.tsx b/frontend/src/components/Review/RebuyCheckbox/RebuyCheckbox.tsx new file mode 100644 index 00000000..9f1a6e95 --- /dev/null +++ b/frontend/src/components/Review/RebuyCheckbox/RebuyCheckbox.tsx @@ -0,0 +1,24 @@ +import { Checkbox } from '@fun-eat/design-system'; +import type { ChangeEventHandler } from 'react'; + +import { useEnterKeyDown } from '@/hooks/common'; +import { useReviewFormActionContext } from '@/hooks/context'; + +const RebuyCheckbox = () => { + const { handleReviewFormValue } = useReviewFormActionContext(); + const { inputRef, labelRef, handleKeydown } = useEnterKeyDown(); + + const handleRebuy: ChangeEventHandler = (event) => { + handleReviewFormValue({ target: 'rebuy', value: event.target.checked }); + }; + + return ( +

    + + 재구매할 생각이 있으신가요? + +

    + ); +}; + +export default RebuyCheckbox; diff --git a/frontend/src/components/Review/ReviewImageUploader/ReviewImageUploader.stories.tsx b/frontend/src/components/Review/ReviewImageUploader/ReviewImageUploader.stories.tsx new file mode 100644 index 00000000..f8f21693 --- /dev/null +++ b/frontend/src/components/Review/ReviewImageUploader/ReviewImageUploader.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import ReviewImageUploader from './ReviewImageUploader'; + +const meta: Meta = { + title: 'review/ReviewImageUploader', + component: ReviewImageUploader, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Review/ReviewImageUploader/ReviewImageUploader.tsx b/frontend/src/components/Review/ReviewImageUploader/ReviewImageUploader.tsx new file mode 100644 index 00000000..1fcc6237 --- /dev/null +++ b/frontend/src/components/Review/ReviewImageUploader/ReviewImageUploader.tsx @@ -0,0 +1,84 @@ +import { Button, Heading, Spacing, Text, useTheme } from '@fun-eat/design-system'; +import type { ChangeEventHandler } from 'react'; +import styled from 'styled-components'; + +import { useEnterKeyDown } from '@/hooks/common'; + +interface ReviewImageUploaderProps { + reviewPreviewImage: string; + uploadReviewImage: ChangeEventHandler; + deleteReviewImage: () => void; +} + +const ReviewImageUploader = ({ + reviewPreviewImage, + uploadReviewImage, + deleteReviewImage, +}: ReviewImageUploaderProps) => { + const { inputRef, handleKeydown } = useEnterKeyDown(); + const theme = useTheme(); + + return ( + + + 구매한 상품 사진이 있다면 올려주세요. + + + + (사진은 5MB 이하, 1장까지 업로드 할 수 있어요.) + + + {reviewPreviewImage ? ( + + 업로드한 리뷰 사진 + + + ) : ( + + + + + + )} + + ); +}; + +export default ReviewImageUploader; + +const ReviewImageUploaderContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; +`; + +const ImageUploadLabel = styled.label` + display: flex; + align-items: center; + justify-content: center; + width: 92px; + height: 95px; + background: ${({ theme }) => theme.colors.gray1}; + border: 1px solid ${({ theme }) => theme.borderColors.disabled}; + border-radius: ${({ theme }) => theme.borderRadius.xs}; + cursor: pointer; + + & > input { + display: none; + } +`; + +const ReviewImageButtonWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; +`; diff --git a/frontend/src/components/Review/ReviewItem/ReviewItem.stories.tsx b/frontend/src/components/Review/ReviewItem/ReviewItem.stories.tsx new file mode 100644 index 00000000..4611ed65 --- /dev/null +++ b/frontend/src/components/Review/ReviewItem/ReviewItem.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import ReviewItem from './ReviewItem'; + +import mockReviews from '@/mocks/data/reviews.json'; + +const meta: Meta = { + title: 'review/ReviewItem', + component: ReviewItem, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( +
    + +
    + ), +}; + +export const RebuyAndFavorite: Story = { + render: () => ( +
    + +
    + ), +}; + +export const NoImageReview: Story = { + render: () => ( +
    + +
    + ), +}; diff --git a/frontend/src/components/Review/ReviewItem/ReviewItem.tsx b/frontend/src/components/Review/ReviewItem/ReviewItem.tsx new file mode 100644 index 00000000..29d508c9 --- /dev/null +++ b/frontend/src/components/Review/ReviewItem/ReviewItem.tsx @@ -0,0 +1,139 @@ +import { Badge, Button, Text, useTheme } from '@fun-eat/design-system'; +import { useState } from 'react'; +import styled from 'styled-components'; + +import { SvgIcon, TagList } from '@/components/Common'; +import { useDebounce } from '@/hooks/common'; +import { useReviewFavoriteMutation } from '@/hooks/queries/review'; +import type { Review } from '@/types/review'; +import { getRelativeDate } from '@/utils/relativeDate'; + +interface ReviewItemProps { + productId: number; + review: Review; +} + +const srcPath = process.env.NODE_ENV === 'development' ? '' : '/images/'; + +const ReviewItem = ({ productId, review }: ReviewItemProps) => { + const { id, userName, profileImage, image, rating, tags, content, createdAt, rebuy, favoriteCount, favorite } = + review; + const [isFavorite, setIsFavorite] = useState(favorite); + const [currentFavoriteCount, setCurrentFavoriteCount] = useState(favoriteCount); + const { mutate } = useReviewFavoriteMutation(productId, id); + + const theme = useTheme(); + + const handleToggleFavorite = async () => { + mutate( + { favorite: !isFavorite }, + { + onSuccess: () => { + setIsFavorite((prev) => !prev); + setCurrentFavoriteCount((prev) => (isFavorite ? prev - 1 : prev + 1)); + }, + onError: () => { + alert('리뷰 좋아요를 다시 시도해주세요.'); + }, + } + ); + }; + + const [debouncedToggleFavorite] = useDebounce(handleToggleFavorite, 200); + + return ( + + + + +
    + {userName} + + {Array.from({ length: 5 }, (_, index) => ( + + ))} + + {getRelativeDate(createdAt)} + + +
    +
    + {rebuy && ( + + 😝 또 살래요 + + )} +
    + {image !== null && } + + {content} + + + + {currentFavoriteCount} + + +
    + ); +}; + +export default ReviewItem; + +const ReviewItemContainer = styled.div` + display: flex; + flex-direction: column; + row-gap: 20px; +`; + +const ReviewerWrapper = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +`; + +const ReviewerInfoWrapper = styled.div` + display: flex; + align-items: center; + column-gap: 10px; +`; + +const RebuyBadge = styled(Badge)` + font-weight: ${({ theme }) => theme.fontWeights.bold}; +`; + +const ReviewerImage = styled.img` + border-radius: 50%; + border: 2px solid ${({ theme }) => theme.colors.primary}; +`; + +const RatingIconWrapper = styled.div` + display: flex; + align-items: center; + margin-left: -2px; + + & > span { + margin-left: 12px; + } +`; + +const ReviewImage = styled.img` + align-self: center; +`; + +const FavoriteButton = styled(Button)` + display: flex; + align-items: center; + column-gap: 8px; + padding: 0; +`; diff --git a/frontend/src/components/Review/ReviewList/ReviewList.tsx b/frontend/src/components/Review/ReviewList/ReviewList.tsx new file mode 100644 index 00000000..dd64b78a --- /dev/null +++ b/frontend/src/components/Review/ReviewList/ReviewList.tsx @@ -0,0 +1,80 @@ +import { Text, Link } from '@fun-eat/design-system'; +import { useRef } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import styled from 'styled-components'; + +import ReviewItem from '../ReviewItem/ReviewItem'; + +import { PATH } from '@/constants/path'; +import { useIntersectionObserver } from '@/hooks/common'; +import { useInfiniteProductReviewsQuery } from '@/hooks/queries/product'; +import type { SortOption } from '@/types/common'; + +const LOGIN_ERROR_MESSAGE = + '로그인 해야 상품 리뷰를 볼 수 있어요.\n펀잇에 가입하고 편의점 상품의 리뷰를 확인해보세요 😊'; + +interface ReviewListProps { + productId: number; + selectedOption: SortOption; +} + +const ReviewList = ({ productId, selectedOption }: ReviewListProps) => { + const scrollRef = useRef(null); + + const { fetchNextPage, hasNextPage, data, isError } = useInfiniteProductReviewsQuery(productId, selectedOption.value); + useIntersectionObserver(fetchNextPage, scrollRef, hasNextPage); + + if (isError) { + return ( + + + {LOGIN_ERROR_MESSAGE} + + + 로그인하러 가기 + + + ); + } + + return ( + <> + + {data?.pages + .flatMap((page) => page.reviews) + .map((review) => ( +
  • + +
  • + ))} +
    +
    + + ); +}; + +export default ReviewList; + +const ReviewListContainer = styled.ul` + display: flex; + flex-direction: column; + row-gap: 60px; +`; + +const ErrorContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; +`; + +const ErrorDescription = styled(Text)` + padding: 40px 0; + white-space: pre-line; + word-break: break-all; +`; + +const LoginLink = styled(Link)` + padding: 16px 24px; + border: 1px solid ${({ theme }) => theme.colors.gray4}; + border-radius: 8px; +`; diff --git a/frontend/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.stories.tsx b/frontend/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.stories.tsx new file mode 100644 index 00000000..9aa84b67 --- /dev/null +++ b/frontend/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import ReviewRegisterForm from './ReviewRegisterForm'; + +import productDetail from '@/mocks/data/productDetail.json'; + +const meta: Meta = { + title: 'review/ReviewRegisterForm', + component: ReviewRegisterForm, + args: { + productId: productDetail.id, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.tsx b/frontend/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.tsx new file mode 100644 index 00000000..97902ab3 --- /dev/null +++ b/frontend/src/components/Review/ReviewRegisterForm/ReviewRegisterForm.tsx @@ -0,0 +1,130 @@ +import { Button, Divider, Heading, Spacing, theme } from '@fun-eat/design-system'; +import type { RefObject } from 'react'; +import styled from 'styled-components'; + +import RebuyCheckbox from '../RebuyCheckbox/RebuyCheckbox'; +import ReviewImageUploader from '../ReviewImageUploader/ReviewImageUploader'; +import ReviewTagList from '../ReviewTagList/ReviewTagList'; +import ReviewTextarea from '../ReviewTextarea/ReviewTextarea'; +import StarRate from '../StarRate/StarRate'; + +import { SvgIcon } from '@/components/Common'; +import { ProductOverviewItem } from '@/components/Product'; +import { useScroll } from '@/hooks/common'; +import { useReviewFormActionContext, useReviewFormValueContext } from '@/hooks/context'; +import { useProductDetailQuery } from '@/hooks/queries/product'; +import { useReviewRegisterFormMutation } from '@/hooks/queries/review'; +import { useReviewImageUploader, useFormData } from '@/hooks/review'; + +const MIN_RATING_SCORE = 0; +const MIN_SELECTED_TAGS_COUNT = 1; +const MIN_CONTENT_LENGTH = 0; + +interface ReviewRegisterFormProps { + productId: number; + targetRef: RefObject; + closeReviewDialog: () => void; +} + +const ReviewRegisterForm = ({ productId, targetRef, closeReviewDialog }: ReviewRegisterFormProps) => { + const { reviewPreviewImage, setReviewPreviewImage, reviewImageFile, uploadReviewImage, deleteReviewImage } = + useReviewImageUploader(); + const reviewFormValue = useReviewFormValueContext(); + const { resetReviewFormValue } = useReviewFormActionContext(); + + const { data: productDetail } = useProductDetailQuery(productId); + const { mutate } = useReviewRegisterFormMutation(productId); + const { scrollToPosition } = useScroll(); + + // TODO: 태그 아이디 개수 조건 수정 + const isValid = + reviewFormValue.rating > MIN_RATING_SCORE && + reviewFormValue.tagIds.length === MIN_SELECTED_TAGS_COUNT && + reviewFormValue.content.length > MIN_CONTENT_LENGTH; + + const formData = useFormData({ + imageKey: 'image', + imageFile: reviewImageFile, + formContentKey: 'reviewRequest', + formContent: reviewFormValue, + }); + + const handleSubmit: React.FormEventHandler = async (event) => { + event.preventDefault(); + + await mutate(formData); + + setReviewPreviewImage(''); + resetReviewFormValue(); + + closeReviewDialog(); + scrollToPosition(targetRef); + }; + + return ( + + 리뷰 작성 + + + + + + + + + + + + + + + + + + + + + {isValid ? '리뷰 등록하기' : '꼭 입력해야 하는 항목이 있어요'} + + + + ); +}; + +export default ReviewRegisterForm; + +const ReviewRegisterFormContainer = styled.div` + position: relative; + height: 100%; +`; + +const ReviewHeading = styled(Heading)` + height: 80px; + text-align: center; + font-size: 2.4rem; + line-height: 80px; +`; + +const CloseButton = styled(Button)` + position: absolute; + top: 24px; + right: 32px; +`; + +const ProductOverviewItemWrapper = styled.div` + margin: 15px 0; +`; + +const RegisterForm = styled.form` + padding: 50px 20px; +`; + +const FormButton = styled(Button)` + background: ${({ theme, disabled }) => (disabled ? theme.colors.gray3 : theme.colors.primary)}; + color: ${({ theme, disabled }) => (disabled ? theme.colors.white : theme.colors.black)}; + cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')}; +`; diff --git a/frontend/src/components/Review/ReviewTagItem/ReviewTagItem.stories.tsx b/frontend/src/components/Review/ReviewTagItem/ReviewTagItem.stories.tsx new file mode 100644 index 00000000..815a1ba8 --- /dev/null +++ b/frontend/src/components/Review/ReviewTagItem/ReviewTagItem.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import ReviewTagItem from './ReviewTagItem'; + +const meta: Meta = { + title: 'review/ReviewTagItem', + component: ReviewTagItem, + args: { + id: 0, + name: '단짠단짠', + isSelected: false, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Selected: Story = { + args: { + isSelected: true, + }, +}; diff --git a/frontend/src/components/Review/ReviewTagItem/ReviewTagItem.tsx b/frontend/src/components/Review/ReviewTagItem/ReviewTagItem.tsx new file mode 100644 index 00000000..cdb5cf82 --- /dev/null +++ b/frontend/src/components/Review/ReviewTagItem/ReviewTagItem.tsx @@ -0,0 +1,66 @@ +import { Badge, Button, useTheme } from '@fun-eat/design-system'; +import type { RuleSet } from 'styled-components'; +import styled, { css } from 'styled-components'; + +import { useReviewFormActionContext } from '@/hooks/context'; + +type TagVariants = 'TASTE' | 'PRICE' | 'ETC'; + +interface ReviewTagItemProps { + id: number; + name: string; + variant: TagVariants; + isSelected: boolean; +} + +const ReviewTagItem = ({ id, name, variant, isSelected }: ReviewTagItemProps) => { + const { handleReviewFormValue } = useReviewFormActionContext(); + const theme = useTheme(); + + const handleReviewTag = () => { + handleReviewFormValue({ target: 'tagIds', value: id, isSelected }); + }; + + return ( + + ); +}; + +export default ReviewTagItem; + +type TagStyleProps = Pick; + +type TagVariantStyles = Record RuleSet>; + +const tagColorStyles: TagVariantStyles = { + TASTE: (isSelected) => css` + border: 2px solid ${({ theme }) => theme.colors.tertiary}; + background: ${({ theme }) => isSelected && theme.colors.tertiary}; + `, + PRICE: (isSelected) => css` + border: 2px solid ${({ theme }) => theme.colors.secondary}; + background: ${({ theme }) => isSelected && theme.colors.secondary}; + `, + ETC: (isSelected) => css` + border: 2px solid ${({ theme }) => theme.colors.primary}; + background: ${({ theme }) => isSelected && theme.colors.primary}; + `, +}; + +const TagBadge = styled(Badge)` + ${({ variant, isSelected }) => tagColorStyles[variant ?? 'ETC'](isSelected)}; + white-space: nowrap; +`; diff --git a/frontend/src/components/Review/ReviewTagList/ReviewTagList.stories.tsx b/frontend/src/components/Review/ReviewTagList/ReviewTagList.stories.tsx new file mode 100644 index 00000000..9847876c --- /dev/null +++ b/frontend/src/components/Review/ReviewTagList/ReviewTagList.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import ReviewTagList from './ReviewTagList'; + +const meta: Meta = { + title: 'review/ReviewTagList', + component: ReviewTagList, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Review/ReviewTagList/ReviewTagList.tsx b/frontend/src/components/Review/ReviewTagList/ReviewTagList.tsx new file mode 100644 index 00000000..763694d0 --- /dev/null +++ b/frontend/src/components/Review/ReviewTagList/ReviewTagList.tsx @@ -0,0 +1,118 @@ +import { Button, Heading, Spacing } from '@fun-eat/design-system'; +import styled from 'styled-components'; + +import ReviewTagItem from '../ReviewTagItem/ReviewTagItem'; + +import { SvgIcon } from '@/components/Common'; +import { MIN_DISPLAYED_TAGS_LENGTH, TAG_TITLE } from '@/constants'; +import { useReviewTagsQuery } from '@/hooks/queries/review'; +import { useDisplayTag } from '@/hooks/review'; + +interface ReviewTagListProps { + selectedTags: number[]; +} + +const ReviewTagList = ({ selectedTags }: ReviewTagListProps) => { + const { data: tagsData } = useReviewTagsQuery(); + const { minDisplayedTags, canShowMore, showMoreTags } = useDisplayTag(tagsData ?? [], MIN_DISPLAYED_TAGS_LENGTH); + + if (!tagsData) { + return null; + } + + return ( + + + 태그를 골라주세요. (3개까지) + * + + + + {tagsData.map(({ tagType, tags }) => { + return ( + + + {TAG_TITLE[tagType]} + + +
      + {tags.slice(0, minDisplayedTags).map(({ id, name }) => ( + <> +
    • + +
    • + + + ))} +
    +
    + ); + })} +
    + + {canShowMore && ( + + )} +
    + ); +}; + +export default ReviewTagList; + +const ReviewTagListContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; +`; + +const RequiredMark = styled.sup` + color: ${({ theme }) => theme.colors.error}; +`; + +const TagListWrapper = styled.div` + display: flex; + column-gap: 20px; + width: 100%; + margin: 0 auto; + overflow-x: auto; + + &::-webkit-scrollbar { + display: none; + } + + & > div { + flex-grow: 1; + } + + @media screen and (min-width: 420px) { + justify-content: center; + + & > div { + flex-grow: 0; + } + } +`; + +const TagItemWrapper = styled.div` + display: flex; + flex-direction: column; +`; + +const TagTitle = styled(Heading)` + text-align: center; +`; + +const SvgWrapper = styled.span` + display: inline-block; + transform: rotate(270deg); +`; diff --git a/frontend/src/components/Review/ReviewTextarea/ReviewTextarea.stories.tsx b/frontend/src/components/Review/ReviewTextarea/ReviewTextarea.stories.tsx new file mode 100644 index 00000000..d51a623b --- /dev/null +++ b/frontend/src/components/Review/ReviewTextarea/ReviewTextarea.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import ReviewTextarea from './ReviewTextarea'; + +const meta: Meta = { + title: 'review/ReviewTextarea', + component: ReviewTextarea, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Review/ReviewTextarea/ReviewTextarea.tsx b/frontend/src/components/Review/ReviewTextarea/ReviewTextarea.tsx new file mode 100644 index 00000000..6349e0d4 --- /dev/null +++ b/frontend/src/components/Review/ReviewTextarea/ReviewTextarea.tsx @@ -0,0 +1,59 @@ +import { Heading, Spacing, Text, Textarea, useTheme } from '@fun-eat/design-system'; +import type { ChangeEventHandler } from 'react'; +import styled from 'styled-components'; + +import { useReviewFormActionContext } from '@/hooks/context'; + +const MAX_LENGTH = 200; + +interface ReviewTextareaProps { + content: string; +} + +const ReviewTextarea = ({ content }: ReviewTextareaProps) => { + const { handleReviewFormValue } = useReviewFormActionContext(); + const theme = useTheme(); + + const handleReviewText: ChangeEventHandler = (event) => { + handleReviewFormValue({ target: 'content', value: event.currentTarget.value }); + }; + + return ( + + + 리뷰를 남겨주세요. + * + + +