diff --git a/.github/ISSUE_TEMPLATE/bug-template.md b/.github/ISSUE_TEMPLATE/bug-template.md index 4fe7cd1a8..66a09964e 100644 --- a/.github/ISSUE_TEMPLATE/bug-template.md +++ b/.github/ISSUE_TEMPLATE/bug-template.md @@ -7,8 +7,8 @@ assignees: '' --- -## 이슈 번호 - ## 상세 내용 +
+ ## 주의사항 diff --git a/.github/ISSUE_TEMPLATE/feature-template.md b/.github/ISSUE_TEMPLATE/feature-template.md index 1e361c6ee..0f9f21f60 100644 --- a/.github/ISSUE_TEMPLATE/feature-template.md +++ b/.github/ISSUE_TEMPLATE/feature-template.md @@ -2,13 +2,17 @@ name: Feature Template about: 구현할 기능을 이슈에 등록한다. title: "[feature/{branch_name}] 기능을 구현한다." -labels: '' +labels: 'feature' assignees: '' --- ## 구현 기능 +
+ ## 작업 내용 +
+ ## 주의사항 diff --git a/.github/ISSUE_TEMPLATE/refactor-template.md b/.github/ISSUE_TEMPLATE/refactor-template.md new file mode 100644 index 000000000..fa03e717e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor-template.md @@ -0,0 +1,14 @@ +--- +name: Refactor Template +about: 리팩토링할 기능을 이슈에 등록한다. +title: "[refactor/{branch_name}] 기능을 리팩토링한다." +labels: 'refactor' +assignees: '' + +--- + +## 작업 내용 + +
+ +## 주의사항 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b7dee6b27..9b39ac021 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,9 @@ ## 상세 내용 +
+ ## 기타 + +
+ +Close #{이슈_번호} diff --git a/backend/pick-git/build.gradle b/backend/pick-git/build.gradle index 9e85e8c48..b4de1cd81 100644 --- a/backend/pick-git/build.gradle +++ b/backend/pick-git/build.gradle @@ -19,6 +19,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.apache.httpcomponents:httpclient:4.5' + implementation 'net.rakugakibox.spring.boot:logback-access-spring-boot-starter:2.7.1' compileOnly 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.12.4' @@ -27,9 +28,15 @@ dependencies { asciidoctor 'org.springframework.restdocs:spring-restdocs-asciidoctor:1.2.6.RELEASE' - testImplementation('org.springframework.restdocs:spring-restdocs-mockmvc') + compileOnly 'org.projectlombok:lombok:1.18.20' + annotationProcessor 'org.projectlombok:lombok:1.18.20' + + testCompileOnly 'org.projectlombok:lombok:1.18.20' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.20' + + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation group: 'io.rest-assured', name: 'rest-assured', version: '4.4.0' + testImplementation 'io.rest-assured:rest-assured:4.4.0' } processResources.dependsOn('copySecurity') @@ -68,4 +75,4 @@ bootJar { test { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/application/JwtTokenProvider.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/application/JwtTokenProvider.java index 3c53aca99..4181f859f 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/application/JwtTokenProvider.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/application/JwtTokenProvider.java @@ -1,7 +1,5 @@ package com.woowacourse.pickgit.authentication.application; -import com.woowacourse.pickgit.user.domain.User; - public interface JwtTokenProvider { String createToken(String payload); diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/application/OAuthService.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/application/OAuthService.java index 83f1dea8e..b076128a1 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/application/OAuthService.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/application/OAuthService.java @@ -1,7 +1,7 @@ package com.woowacourse.pickgit.authentication.application; -import com.woowacourse.pickgit.authentication.application.dto.TokenDto; import com.woowacourse.pickgit.authentication.application.dto.OAuthProfileResponse; +import com.woowacourse.pickgit.authentication.application.dto.TokenDto; import com.woowacourse.pickgit.authentication.dao.OAuthAccessTokenDao; import com.woowacourse.pickgit.authentication.domain.OAuthClient; import com.woowacourse.pickgit.authentication.domain.user.AppUser; @@ -41,7 +41,8 @@ public String getGithubAuthorizationUrl() { public TokenDto createToken(String code) { String githubAccessToken = githubOAuthClient.getAccessToken(code); - OAuthProfileResponse githubProfileResponse = githubOAuthClient.getGithubProfile(githubAccessToken); + OAuthProfileResponse githubProfileResponse = githubOAuthClient + .getGithubProfile(githubAccessToken); updateUserOrCreateUser(githubProfileResponse); diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/application/dto/OAuthProfileResponse.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/application/dto/OAuthProfileResponse.java index cc2fff319..5d8d3ae82 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/application/dto/OAuthProfileResponse.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/application/dto/OAuthProfileResponse.java @@ -3,7 +3,9 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.woowacourse.pickgit.user.domain.profile.BasicProfile; import com.woowacourse.pickgit.user.domain.profile.GithubProfile; +import lombok.Builder; +@Builder public class OAuthProfileResponse { @JsonProperty("login") @@ -43,18 +45,6 @@ public OAuthProfileResponse(String name, String image, String description, this.twitter = twitter; } - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getImage() { - return image; - } - public GithubProfile toGithubProfile() { return new GithubProfile( githubUrl, @@ -73,55 +63,35 @@ public BasicProfile toBasicProfile() { ); } - public void setImage(String image) { - this.image = image; + public String getName() { + return name; } - public String getDescription() { - return description; + public String getImage() { + return image; } - public void setDescription(String description) { - this.description = description; + public String getDescription() { + return description; } public String getGithubUrl() { return githubUrl; } - public void setGithubUrl(String githubUrl) { - this.githubUrl = githubUrl; - } - public String getCompany() { return company; } - public void setCompany(String company) { - this.company = company; - } - public String getLocation() { return location; } - public void setLocation(String location) { - this.location = location; - } - public String getWebsite() { return website; } - public void setWebsite(String website) { - this.website = website; - } - public String getTwitter() { return twitter; } - - public void setTwitter(String twitter) { - this.twitter = twitter; - } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/infrastructure/AuthorizationExtractor.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/infrastructure/AuthorizationExtractor.java index 1ebe1e31f..648c362e3 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/infrastructure/AuthorizationExtractor.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/infrastructure/AuthorizationExtractor.java @@ -4,9 +4,10 @@ import javax.servlet.http.HttpServletRequest; public class AuthorizationExtractor { - public static final String AUTHORIZATION = "Authorization"; - public static final String ACCESS_TOKEN_TYPE = AuthorizationExtractor.class.getSimpleName() + ".ACCESS_TOKEN_TYPE"; - public static String BEARER_TYPE = "Bearer"; + + private static final String AUTHORIZATION = "Authorization"; + private static final String ACCESS_TOKEN_TYPE = AuthorizationExtractor.class.getSimpleName() + ".ACCESS_TOKEN_TYPE"; + private static String BEARER_TYPE = "Bearer"; public static String extract(HttpServletRequest request) { Enumeration headers = request.getHeaders(AUTHORIZATION); @@ -24,6 +25,5 @@ public static String extract(HttpServletRequest request) { } return null; - } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/infrastructure/GithubOAuthClient.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/infrastructure/GithubOAuthClient.java index 118bfa9b3..7d738dfe6 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/infrastructure/GithubOAuthClient.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/infrastructure/GithubOAuthClient.java @@ -4,6 +4,7 @@ import com.woowacourse.pickgit.authentication.domain.OAuthClient; import com.woowacourse.pickgit.authentication.infrastructure.dto.OAuthAccessTokenRequest; import com.woowacourse.pickgit.authentication.infrastructure.dto.OAuthAccessTokenResponse; +import com.woowacourse.pickgit.exception.platform.PlatformHttpErrorException; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -50,7 +51,7 @@ public String getAccessToken(String code) { .getAccessToken(); if (accessToken == null) { - throw new IllegalArgumentException("깃헙 인증 에러"); + throw new PlatformHttpErrorException(); } return accessToken; } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/AuthenticationPrincipalArgumentResolver.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/AuthenticationPrincipalArgumentResolver.java index d27449201..7e34c09ea 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/AuthenticationPrincipalArgumentResolver.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/AuthenticationPrincipalArgumentResolver.java @@ -5,7 +5,6 @@ import com.woowacourse.pickgit.authentication.presentation.interceptor.AuthHeader; import javax.servlet.http.HttpServletRequest; import org.springframework.core.MethodParameter; -import org.springframework.http.HttpHeaders; import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.method.support.HandlerMethodArgumentResolver; diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/interceptor/IgnoreAuthenticationInterceptor.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/interceptor/IgnoreAuthenticationInterceptor.java index 49bead99e..aeb2bbaa2 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/interceptor/IgnoreAuthenticationInterceptor.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/authentication/presentation/interceptor/IgnoreAuthenticationInterceptor.java @@ -20,7 +20,9 @@ public IgnoreAuthenticationInterceptor( } @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, + public boolean preHandle( + HttpServletRequest request, + HttpServletResponse response, Object handler) throws Exception { if (isPreflightRequest(request)) { return true; diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/common/network/RestTemplateClient.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/common/network/RestTemplateClient.java index a915f7b8b..3e4bdea8d 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/common/network/RestTemplateClient.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/common/network/RestTemplateClient.java @@ -1,6 +1,6 @@ package com.woowacourse.pickgit.common.network; -import com.woowacourse.pickgit.post.infrastructure.RestClient; +import com.woowacourse.pickgit.post.domain.util.RestClient; import java.net.URI; import java.util.Map; import java.util.Set; @@ -30,7 +30,9 @@ public class RestTemplateClient implements RestClient { public final RestTemplate restTemplate = createRestTemplate(); private static RestTemplate createRestTemplate() { - var factory = new HttpComponentsClientHttpRequestFactory(); + HttpComponentsClientHttpRequestFactory factory = + new HttpComponentsClientHttpRequestFactory(); + factory.setReadTimeout(READ_TIMEOUT); factory.setConnectionRequestTimeout(CONNECTION_REQUEST_TIMEOUT); diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/config/OAuthConfiguration.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/config/OAuthConfiguration.java index dadcd7e22..0815db8ae 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/config/OAuthConfiguration.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/config/OAuthConfiguration.java @@ -45,19 +45,24 @@ public void addArgumentResolvers(List resolvers) @Override public void addInterceptors(InterceptorRegistry registry) { - HandlerInterceptor authenticationInterceptor = new PathMatchInterceptor(authenticationInterceptor()) + HandlerInterceptor authenticationInterceptor = new PathMatchInterceptor( + authenticationInterceptor()) .addPathPatterns("/api/posts/me", HttpMethod.GET) .addPathPatterns("/api/github/*/repositories", HttpMethod.GET) .addPathPatterns("/api/github/repositories/*/tags/languages", HttpMethod.GET) .addPathPatterns("/api/posts", HttpMethod.POST) - .addPathPatterns("/api/posts/*/likes", HttpMethod.POST, HttpMethod.DELETE) + .addPathPatterns("/api/posts/*/likes", HttpMethod.PUT, HttpMethod.DELETE) .addPathPatterns("/api/posts/*/comments", HttpMethod.POST) - .addPathPatterns("/api/profiles/me", HttpMethod.GET) - .addPathPatterns("/api/profiles/*/followings", HttpMethod.POST, HttpMethod.DELETE); + .addPathPatterns("/api/profiles/me", HttpMethod.GET, HttpMethod.POST) + .addPathPatterns("/api/profiles/*/followings", HttpMethod.POST, HttpMethod.DELETE) + .addPathPatterns("/api/posts/*", HttpMethod.PUT, HttpMethod.DELETE); - HandlerInterceptor ignoreAuthenticationInterceptor = new PathMatchInterceptor(ignoreAuthenticationInterceptor()) + HandlerInterceptor ignoreAuthenticationInterceptor = new PathMatchInterceptor( + ignoreAuthenticationInterceptor()) .addPathPatterns("/api/profiles/*", HttpMethod.GET) + .addPathPatterns("/api/posts", HttpMethod.GET) .addPathPatterns("/api/posts/*", HttpMethod.GET) + .addPathPatterns("/api/search/**", HttpMethod.GET) .excludePatterns("/api/profiles/*/followings", HttpMethod.POST, HttpMethod.DELETE) .excludePatterns("/api/profiles/me", HttpMethod.GET) .excludePatterns("/api/posts/me", HttpMethod.GET); diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/config/PostTestConfiguration.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/config/PostTestConfiguration.java deleted file mode 100644 index 12b0f6a83..000000000 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/config/PostTestConfiguration.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.woowacourse.pickgit.config; - -import static java.util.stream.Collectors.toList; - -import com.woowacourse.pickgit.post.presentation.PickGitStorage; -import java.io.File; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; - -@Configuration -public class PostTestConfiguration { - - @Bean - @Profile("test") - public PickGitStorage pickGitStorage() { - return (files, userName) -> files.stream() - .map(File::getName) - .collect(toList()); - } -} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/GlobalExceptionHandler.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/GlobalExceptionHandler.java index f0e766719..1a8660917 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/GlobalExceptionHandler.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/GlobalExceptionHandler.java @@ -3,6 +3,8 @@ import static java.util.Objects.requireNonNull; import com.woowacourse.pickgit.exception.dto.ApiErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -10,24 +12,62 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice +@Slf4j public class GlobalExceptionHandler { + private static final String LOG_FORMAT = "Class : {}, Code : {}, Message : {}"; + private static final String INTERNAL_SERVER_ERROR_CODE = "S0001"; + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity methodArgumentNotValidException( - MethodArgumentNotValidException e) { - ApiErrorResponse exceptionResponse = - new ApiErrorResponse(requireNonNull(e.getFieldError()).getDefaultMessage()); - + MethodArgumentNotValidException e + ) { + String errorCode = requireNonNull(e.getFieldError()) + .getDefaultMessage(); + ApiErrorResponse exceptionResponse = new ApiErrorResponse(errorCode); + log.warn(LOG_FORMAT, e.getClass().getSimpleName(), errorCode, "@Valid"); return ResponseEntity .status(HttpStatus.BAD_REQUEST.value()) .body(exceptionResponse); } @ExceptionHandler(ApplicationException.class) - public ResponseEntity authenticationException( - ApplicationException e) { + public ResponseEntity applicationException(ApplicationException e) { + String errorCode = e.getErrorCode(); + log.warn( + LOG_FORMAT, + e.getClass().getSimpleName(), + errorCode, + e.getMessage() + ); + return ResponseEntity + .status(e.getHttpStatus()) + .body(new ApiErrorResponse(errorCode)); + } + + @ExceptionHandler(DataAccessException.class) + public ResponseEntity dataAccessException(DataAccessException e) { + log.error( + LOG_FORMAT, + e.getClass().getSimpleName(), + INTERNAL_SERVER_ERROR_CODE, + e.getMessage() + ); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ApiErrorResponse(INTERNAL_SERVER_ERROR_CODE)); + } + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity runtimeException(RuntimeException e) { + log.error( + LOG_FORMAT, + e.getClass().getSimpleName(), + INTERNAL_SERVER_ERROR_CODE, + e.getMessage() + ); return ResponseEntity - .status(e.getHttpStatus().value()) - .body(new ApiErrorResponse(e.getErrorCode())); + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ApiErrorResponse(INTERNAL_SERVER_ERROR_CODE)); } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/CannotUnlikeException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/CannotUnlikeException.java new file mode 100644 index 000000000..1ee48458f --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/CannotUnlikeException.java @@ -0,0 +1,14 @@ +package com.woowacourse.pickgit.exception.post; + +import com.woowacourse.pickgit.exception.ApplicationException; +import org.springframework.http.HttpStatus; + +public class CannotUnlikeException extends ApplicationException { + + private static final String CODE = "P0004"; + private static final String MESSAGE = "좋아요 하지 않은 게시물 좋아요 취소 에러"; + + public CannotUnlikeException() { + super(CODE, HttpStatus.BAD_REQUEST, MESSAGE); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/DuplicatedLikeException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/DuplicatedLikeException.java new file mode 100644 index 000000000..263aa4508 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/DuplicatedLikeException.java @@ -0,0 +1,14 @@ +package com.woowacourse.pickgit.exception.post; + +import com.woowacourse.pickgit.exception.ApplicationException; +import org.springframework.http.HttpStatus; + +public class DuplicatedLikeException extends ApplicationException { + + private static final String CODE = "P0003"; + private static final String MESSAGE = "이미 좋아요한 게시물 중복 좋아요 에러"; + + public DuplicatedLikeException() { + super(CODE, HttpStatus.BAD_REQUEST, MESSAGE); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/PostFormatException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/PostFormatException.java index bb5feb792..45337af17 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/PostFormatException.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/PostFormatException.java @@ -4,8 +4,8 @@ public class PostFormatException extends PostException { - private static final String CODE = "F0001"; - private static final String MESSAGE = "게시물 포맷 에러"; + private static final String CODE = "F0004"; + private static final String MESSAGE = "게시물 길이 에러"; public PostFormatException() { super(CODE, HttpStatus.BAD_REQUEST, MESSAGE); diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/PostNotBelongToUserException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/PostNotBelongToUserException.java new file mode 100644 index 000000000..27083f513 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/PostNotBelongToUserException.java @@ -0,0 +1,14 @@ +package com.woowacourse.pickgit.exception.post; + +import org.springframework.http.HttpStatus; + +public class PostNotBelongToUserException extends PostException{ + + private static final String CODE = "P0005"; + private static final String MESSAGE = "해당하는 사용자의 게시물이 아닙니다."; + + + public PostNotBelongToUserException() { + super(CODE, HttpStatus.UNAUTHORIZED, MESSAGE); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/PostNotFoundException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/PostNotFoundException.java index d20c581ea..c33a60c42 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/PostNotFoundException.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/PostNotFoundException.java @@ -4,8 +4,19 @@ public class PostNotFoundException extends PostException { - public PostNotFoundException(String errorCode, HttpStatus httpStatus, - String message) { + private static final String ERROR_CODE = "P0002"; + private static final HttpStatus HTTP_STATUS = HttpStatus.INTERNAL_SERVER_ERROR; + private static final String MESSAGE = "해당하는 게시물을 찾을 수 없습니다."; + + public PostNotFoundException() { + this(ERROR_CODE, HTTP_STATUS, MESSAGE); + } + + public PostNotFoundException( + String errorCode, + HttpStatus httpStatus, + String message + ) { super(errorCode, httpStatus, message); } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/RepositoryParseException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/RepositoryParseException.java new file mode 100644 index 000000000..01b5a2b97 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/post/RepositoryParseException.java @@ -0,0 +1,20 @@ +package com.woowacourse.pickgit.exception.post; + +import org.springframework.http.HttpStatus; + +public class RepositoryParseException extends PostException { + + private static final String ERROR_CODE = "V0001"; + private static final HttpStatus HTTP_STATUS = HttpStatus.BAD_REQUEST; + private static final String MESSAGE = "레포지토리 목록을 불러올 수 없습니다."; + + public RepositoryParseException() { + this(ERROR_CODE, HTTP_STATUS, MESSAGE); + } + + public RepositoryParseException( + String errorCode, HttpStatus httpStatus, String message + ) { + super(errorCode, httpStatus, message); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/ContributionParseException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/ContributionParseException.java new file mode 100644 index 000000000..1ae5a899b --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/ContributionParseException.java @@ -0,0 +1,12 @@ +package com.woowacourse.pickgit.exception.user; + +import org.springframework.http.HttpStatus; + +public class ContributionParseException extends UserException { + + public ContributionParseException( + String errorCode, HttpStatus httpStatus, String message + ) { + super(errorCode, httpStatus, message); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/UserNotFoundException.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/UserNotFoundException.java index 6d95a7257..c18f6b70c 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/UserNotFoundException.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/exception/user/UserNotFoundException.java @@ -4,8 +4,15 @@ public class UserNotFoundException extends UserException { - public UserNotFoundException(String errorCode, HttpStatus httpStatus, - String message) { + private static final String ERROR_CODE = "U0001"; + private static final HttpStatus HTTP_STATUS = HttpStatus.INTERNAL_SERVER_ERROR; + private static final String MESSAGE = "해당하는 사용자를 찾을 수 없습니다."; + + public UserNotFoundException() { + this(ERROR_CODE, HTTP_STATUS, MESSAGE); + } + + public UserNotFoundException(String errorCode, HttpStatus httpStatus, String message) { super(errorCode, httpStatus, message); } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/PostDtoAssembler.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/PostDtoAssembler.java index ce58a11f8..0fa5ff338 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/PostDtoAssembler.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/PostDtoAssembler.java @@ -1,46 +1,77 @@ package com.woowacourse.pickgit.post.application; -import com.woowacourse.pickgit.authentication.domain.user.AppUser; -import com.woowacourse.pickgit.post.application.dto.CommentResponse; +import static java.util.stream.Collectors.toList; + +import com.woowacourse.pickgit.post.application.dto.response.CommentResponseDto; import com.woowacourse.pickgit.post.application.dto.response.PostResponseDto; import com.woowacourse.pickgit.post.domain.Post; +import com.woowacourse.pickgit.post.domain.comment.Comment; import com.woowacourse.pickgit.tag.domain.Tag; import com.woowacourse.pickgit.user.domain.User; import java.util.List; -import java.util.stream.Collectors; +import java.util.function.Function; public class PostDtoAssembler { private PostDtoAssembler() { } - public static List assembleFrom(AppUser appUser, List posts) { + public static List assembleFrom( + User requestUser, + boolean isGuest, + List posts + ) { return posts.stream() - .map(post -> convertFrom(post, appUser)) - .collect(Collectors.toList()); + .map(post -> convertFrom(requestUser, isGuest, post)) + .collect(toList()); } - private static PostResponseDto convertFrom(Post post, AppUser appUser) { - User postWriter = post.getUser(); - List tags = post.getTags() - .stream() - .map(Tag::getName) - .collect(Collectors.toList()); - List comments = post.getComments() + private static PostResponseDto convertFrom(User requestUser, boolean isGuest, Post post) { + List tags = createTagsFrom(post); + List comments = createCommentResponsesFrom(post); + + return PostResponseDto.builder() + .id(post.getId()) + .imageUrls(post.getImageUrls()) + .githubRepoUrl(post.getGithubRepoUrl()) + .content(post.getContent()) + .authorName(post.getAuthorName()) + .profileImageUrl(post.getAuthorProfileImage()) + .likesCount(post.getLikeCounts()) + .tags(tags) + .createdAt(post.getCreatedAt()) + .updatedAt(post.getUpdatedAt()) + .comments(comments) + .liked(isLikedBy(requestUser, post, isGuest)) + .build(); + } + + private static List createCommentResponsesFrom(Post post) { + return post.getComments() .stream() - .map(CommentResponse::from) - .collect(Collectors.toList()); - - if (appUser.isGuest()) { - return new PostResponseDto(post.getId(), post.getImagaeUrls(), post.getGithubRepoUrl(), - post.getContent(), postWriter.getName(), postWriter.getBasicProfile().getImage(), - post.getLikeCounts(), - tags, post.getCreatedAt(), post.getUpdatedAt(), comments, null); + .map(toCommentResponse()) + .collect(toList()); + } + + private static Function toCommentResponse() { + return comment -> CommentResponseDto.builder() + .id(comment.getId()) + .profileImageUrl(comment.getProfileImageUrl()) + .authorName(comment.getAuthorName()) + .content(comment.getContent()) + .liked(false) + .build(); + } + + private static List createTagsFrom(Post post) { + return post.getTagNames(); + } + + private static Boolean isLikedBy(User requestUser, Post post, boolean isGuest) { + if (isGuest) { + return null; } - return new PostResponseDto(post.getId(), post.getImagaeUrls(), post.getGithubRepoUrl(), - post.getContent(), postWriter.getName(), postWriter.getBasicProfile().getImage(), - post.getLikeCounts(), - tags, post.getCreatedAt(), post.getUpdatedAt(), comments, post.isLikedBy(appUser.getUsername())); + return post.isLikedBy(requestUser); } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/PostFeedService.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/PostFeedService.java new file mode 100644 index 000000000..8386ecfb3 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/PostFeedService.java @@ -0,0 +1,82 @@ +package com.woowacourse.pickgit.post.application; + +import com.woowacourse.pickgit.exception.authentication.UnauthorizedException; +import com.woowacourse.pickgit.exception.user.UserNotFoundException; +import com.woowacourse.pickgit.post.application.dto.request.HomeFeedRequestDto; +import com.woowacourse.pickgit.post.application.dto.response.PostResponseDto; +import com.woowacourse.pickgit.post.domain.Post; +import com.woowacourse.pickgit.post.domain.repository.PostRepository; +import com.woowacourse.pickgit.user.domain.User; +import com.woowacourse.pickgit.user.domain.UserRepository; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +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 PostFeedService { + + private final PostRepository postRepository; + private final UserRepository userRepository; + + public PostFeedService(PostRepository postRepository, UserRepository userRepository) { + this.postRepository = postRepository; + this.userRepository = userRepository; + } + + public List homeFeed(HomeFeedRequestDto homeFeedRequestDto) { + return readFeed(homeFeedRequestDto, Optional.empty()); + } + + public List myFeed(HomeFeedRequestDto homeFeedRequestDto) { + String userName = homeFeedRequestDto.getRequestUserName(); + + if (Objects.isNull(userName)) { + throw new UnauthorizedException(); + } + + return readFeed(homeFeedRequestDto, Optional.of(userName)); + } + + public List userFeed(HomeFeedRequestDto homeFeedRequestDto, String userName) { + return readFeed(homeFeedRequestDto, Optional.of(userName)); + } + + private List readFeed( + HomeFeedRequestDto homeFeedRequestDto, + Optional userName + ) { + int page = homeFeedRequestDto.getPage().intValue(); + int limit = homeFeedRequestDto.getLimit().intValue(); + String requestUserName = homeFeedRequestDto.getRequestUserName(); + boolean isGuest = homeFeedRequestDto.isGuest(); + + Pageable pageable = PageRequest.of(page, limit); + List result = getPostsBy(userName, pageable); + + User requestUser = findUserByName(requestUserName); + + return PostDtoAssembler.assembleFrom(requestUser, isGuest, result); + } + + private List getPostsBy(Optional userName, Pageable pageable) { + return userName + .map(this::findUserByName) + .map(target -> postRepository.findAllPostsByUser(target, pageable)) + .orElse(postRepository.findAllPosts(pageable)); + } + + private User findUserByName(String userName) { + if(Objects.isNull(userName)) { + return null; + } + + return userRepository + .findByBasicProfile_Name(userName) + .orElseThrow(UserNotFoundException::new); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/PostService.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/PostService.java index 46c6beb17..7569c6aac 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/PostService.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/PostService.java @@ -3,41 +3,33 @@ import static java.util.stream.Collectors.toList; import com.woowacourse.pickgit.authentication.domain.user.AppUser; -import com.woowacourse.pickgit.exception.platform.PlatformHttpErrorException; +import com.woowacourse.pickgit.exception.post.PostNotBelongToUserException; import com.woowacourse.pickgit.exception.post.PostNotFoundException; import com.woowacourse.pickgit.exception.user.UserNotFoundException; -import com.woowacourse.pickgit.post.application.dto.CommentResponse; -import com.woowacourse.pickgit.post.application.dto.response.PostResponseDto; +import com.woowacourse.pickgit.post.application.dto.request.CommentRequestDto; +import com.woowacourse.pickgit.post.application.dto.request.PostDeleteRequestDto; import com.woowacourse.pickgit.post.application.dto.request.PostRequestDto; +import com.woowacourse.pickgit.post.application.dto.request.PostUpdateRequestDto; import com.woowacourse.pickgit.post.application.dto.request.RepositoryRequestDto; +import com.woowacourse.pickgit.post.application.dto.response.CommentResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.LikeResponseDto; import com.woowacourse.pickgit.post.application.dto.response.PostImageUrlResponseDto; -import com.woowacourse.pickgit.post.application.dto.response.RepositoriesResponseDto; -import com.woowacourse.pickgit.post.domain.PlatformRepositoryExtractor; +import com.woowacourse.pickgit.post.application.dto.response.PostUpdateResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.RepositoryResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.RepositoryResponseDtos; import com.woowacourse.pickgit.post.domain.Post; -import com.woowacourse.pickgit.post.domain.PostContent; -import com.woowacourse.pickgit.post.domain.PostRepository; import com.woowacourse.pickgit.post.domain.comment.Comment; -import com.woowacourse.pickgit.post.domain.content.Image; -import com.woowacourse.pickgit.post.domain.content.Images; -import com.woowacourse.pickgit.post.domain.dto.RepositoryResponseDto; -import com.woowacourse.pickgit.post.presentation.PickGitStorage; -import com.woowacourse.pickgit.post.presentation.dto.request.CommentRequest; -import com.woowacourse.pickgit.post.presentation.dto.request.HomeFeedRequest; +import com.woowacourse.pickgit.post.domain.repository.PickGitStorage; +import com.woowacourse.pickgit.post.domain.repository.PostRepository; +import com.woowacourse.pickgit.post.domain.util.PlatformRepositoryExtractor; +import com.woowacourse.pickgit.post.domain.util.dto.RepositoryUrlAndName; import com.woowacourse.pickgit.tag.application.TagService; import com.woowacourse.pickgit.tag.application.TagsDto; import com.woowacourse.pickgit.tag.domain.Tag; import com.woowacourse.pickgit.user.domain.User; import com.woowacourse.pickgit.user.domain.UserRepository; -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.List; import java.util.function.Function; -import javax.persistence.EntityManager; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -46,140 +38,168 @@ @Transactional public class PostService { + private final TagService tagService; private final UserRepository userRepository; private final PostRepository postRepository; private final PickGitStorage pickgitStorage; private final PlatformRepositoryExtractor platformRepositoryExtractor; - private final TagService tagService; - private final EntityManager entityManager; - public PostService(UserRepository userRepository, + public PostService( + TagService tagService, + UserRepository userRepository, PostRepository postRepository, PickGitStorage pickgitStorage, - PlatformRepositoryExtractor platformRepositoryExtractor, - TagService tagService, EntityManager entityManager) { + PlatformRepositoryExtractor platformRepositoryExtractor + ) { + this.tagService = tagService; this.userRepository = userRepository; this.postRepository = postRepository; this.pickgitStorage = pickgitStorage; this.platformRepositoryExtractor = platformRepositoryExtractor; - this.tagService = tagService; - this.entityManager = entityManager; } public PostImageUrlResponseDto write(PostRequestDto postRequestDto) { - PostContent postContent = new PostContent(postRequestDto.getContent()); - - User user = findUserByName(postRequestDto.getUsername()); + Post post = createPost(postRequestDto); + Post savedPost = postRepository.save(post); - Post post = new Post(postContent, getImages(postRequestDto), - postRequestDto.getGithubRepoUrl(), user); + return PostImageUrlResponseDto.builder() + .id(savedPost.getId()) + .imageUrls(savedPost.getImageUrls()) + .build(); + } + private Post createPost(PostRequestDto postRequestDto) { + String content = postRequestDto.getContent(); + List files = postRequestDto.getImages(); + String userName = postRequestDto.getUsername(); + String githubRepoUrl = postRequestDto.getGithubRepoUrl(); List tags = tagService.findOrCreateTags(new TagsDto(postRequestDto.getTags())); + + User user = findUserByName(userName); + List imageUrls = pickgitStorage.storeMultipartFile(files, userName); + + Post post = createPost(content, githubRepoUrl, user, imageUrls); post.addTags(tags); - Post findPost = postRepository.save(post); - return new PostImageUrlResponseDto(findPost.getId(), findPost.getImageUrls()); + return post; } - private User findUserByName(String username) { - return userRepository - .findByBasicProfile_Name(username) - .orElseThrow(() -> new UserNotFoundException( - "U0001", - HttpStatus.INTERNAL_SERVER_ERROR, - "해당하는 사용자를 찾을 수 없습니다.")); + private Post createPost(String content, String githubRepoUrl, User user, List imageUrls) { + return Post.builder() + .content(content) + .images(imageUrls) + .githubRepoUrl(githubRepoUrl) + .author(user) + .build(); } - private Images getImages(PostRequestDto postRequestDto) { - List files = filesOf(postRequestDto); + public CommentResponseDto addComment(CommentRequestDto commentRequestDto) { + String userName = commentRequestDto.getUserName(); + Long postId = commentRequestDto.getPostId(); + String content = commentRequestDto.getContent(); + + User user = findUserByName(userName); + Post post = findPostById(postId); - return new Images(getImages(postRequestDto, files)); + Comment comment = new Comment(content, user); + post.addComment(comment); + + return createCommentResponseDto(comment); } - private List getImages(PostRequestDto postRequestDto, List files) { - return pickgitStorage - .store(files, postRequestDto.getUsername()) - .stream() - .map(Image::new) - .collect(toList()); + private CommentResponseDto createCommentResponseDto(Comment comment) { + return CommentResponseDto.builder() + .id(comment.getId()) + .profileImageUrl(comment.getProfileImageUrl()) + .authorName(comment.getAuthorName()) + .content(comment.getContent()) + .liked(false) + .build(); } - private List filesOf(PostRequestDto postRequestDto) { - return postRequestDto.getImages().stream() - .map(toFile()) + @Transactional(readOnly = true) + public RepositoryResponseDtos userRepositories(RepositoryRequestDto repositoryRequestDto) { + String token = repositoryRequestDto.getToken(); + String username = repositoryRequestDto.getUsername(); + + List repositoryUrlAndNames = + platformRepositoryExtractor.extract(token, username); + List repositoryResponseDtos = + createRepositoryResponseDtos(repositoryUrlAndNames); + + return new RepositoryResponseDtos(repositoryResponseDtos); + } + + private List createRepositoryResponseDtos( + List repositoryUrlAndNames + ) { + return repositoryUrlAndNames.stream() + .map(toRepositoryResponseDto()) .collect(toList()); } - private Function toFile() { - return multipartFile -> { - try { - return multipartFile.getResource().getFile(); - } catch (IOException e) { - return tryCreateTempFile(multipartFile); - } - }; + private Function toRepositoryResponseDto() { + return repositoryUrlAndName -> RepositoryResponseDto.builder() + .name(repositoryUrlAndName.getName()) + .url(repositoryUrlAndName.getUrl()) + .build(); } - private File tryCreateTempFile(MultipartFile multipartFile) { - try { - Path tempFile = Files.createTempFile(null, null); - Files.write(tempFile, multipartFile.getBytes()); + public LikeResponseDto like(AppUser user, Long postId) { + User source = findUserByName(user.getUsername()); + Post target = findPostById(postId); - return tempFile.toFile(); - } catch (IOException ioException) { - throw new PlatformHttpErrorException(); - } + target.like(source); + return new LikeResponseDto(target.getLikeCounts(), true); } - public CommentResponse addComment(CommentRequest commentRequest) { - User user = userRepository.findByBasicProfile_Name(commentRequest.getUserName()) - .orElseThrow(() -> new UserNotFoundException( - "U0001", - HttpStatus.INTERNAL_SERVER_ERROR, - "해당하는 사용자를 찾을 수 없습니다." - )); - Post post = postRepository.findById(commentRequest.getPostId()) - .orElseThrow(() -> new PostNotFoundException( - "P0002", - HttpStatus.INTERNAL_SERVER_ERROR, - "해당하는 게시물을 찾을 수 없습니다." - )); - Comment comment = new Comment(commentRequest.getContent()); - user.addComment(post, comment); - entityManager.flush(); - return CommentResponse.from(comment); + public LikeResponseDto unlike(AppUser user, Long postId) { + User source = findUserByName(user.getUsername()); + Post target = findPostById(postId); + + target.unlike(source); + return new LikeResponseDto(target.getLikeCounts(), false); } - @Transactional(readOnly = true) - public RepositoriesResponseDto showRepositories(RepositoryRequestDto repositoryRequestDto) { - List repositories = platformRepositoryExtractor - .extract(repositoryRequestDto.getToken(), repositoryRequestDto.getUsername()); + public PostUpdateResponseDto update(PostUpdateRequestDto updateRequestDto) { + User user = findUserByName(updateRequestDto.getUsername()); + Post post = findPostById(updateRequestDto.getPostId()); - return new RepositoriesResponseDto(repositories); - } + if (!post.isWrittenBy(user)) { + throw new PostNotBelongToUserException(); + } - @Transactional(readOnly = true) - public List readHomeFeed(HomeFeedRequest homeFeedRequest) { - Pageable pageable = PageRequest.of(homeFeedRequest.getPage(), homeFeedRequest.getLimit()); - List result = postRepository.findAllPosts(pageable); - return PostDtoAssembler.assembleFrom(homeFeedRequest.getAppUser(), result); + List tags = tagService.findOrCreateTags(new TagsDto(updateRequestDto.getTags())); + + post.updateContent(updateRequestDto.getContent()); + post.updateTags(tags); + + return PostUpdateResponseDto.builder() + .content(post.getContent()) + .tags(post.getTagNames()) + .build(); } - @Transactional(readOnly = true) - public List readMyFeed(HomeFeedRequest homeFeedRequest) { - return readFeed(homeFeedRequest, homeFeedRequest.getAppUser().getUsername()); + public void delete(PostDeleteRequestDto deleteRequestDto) { + User user = findUserByName(deleteRequestDto.getUsername()); + Post post = findPostById(deleteRequestDto.getPostId()); + + if (!post.isWrittenBy(user)) { + throw new PostNotBelongToUserException(); + } + + user.delete(post); + postRepository.delete(post); } - @Transactional(readOnly = true) - public List readUserFeed(HomeFeedRequest homeFeedRequest, String username) { - return readFeed(homeFeedRequest, username); + private Post findPostById(Long id) { + return postRepository.findById(id) + .orElseThrow(PostNotFoundException::new); } - private List readFeed(HomeFeedRequest homeFeedRequest, String username) { - AppUser appUser = homeFeedRequest.getAppUser(); - User target = findUserByName(username); - Pageable pageable = PageRequest.of(homeFeedRequest.getPage(), homeFeedRequest.getLimit()); - List result = postRepository.findAllPostsByUser(target, pageable); - return PostDtoAssembler.assembleFrom(appUser, result); + private User findUserByName(String username) { + return userRepository + .findByBasicProfile_Name(username) + .orElseThrow(UserNotFoundException::new); } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/CommentResponse.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/CommentResponse.java deleted file mode 100644 index 2b62e6053..000000000 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/CommentResponse.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.woowacourse.pickgit.post.application.dto; - -import com.woowacourse.pickgit.post.domain.comment.Comment; - -public class CommentResponse { - - private Long id; - private String authorName; - private String content; - private Boolean isLiked; - - private CommentResponse() { - } - - public CommentResponse(Long id, String authorName, String content, Boolean isLiked) { - this.id = id; - this.authorName = authorName; - this.content = content; - this.isLiked = isLiked; - } - - public static CommentResponse from(Comment comment) { - return new CommentResponse(comment.getId(), comment.getAuthorName(), - comment.getContent(), false); - } - - public Long getId() { - return id; - } - - public String getAuthorName() { - return authorName; - } - - public String getContent() { - return content; - } - - public Boolean getIsLiked() { - return isLiked; - } -} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/request/CommentRequest.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/request/CommentRequestDto.java similarity index 61% rename from backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/request/CommentRequest.java rename to backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/request/CommentRequestDto.java index b7a7a73dd..831c3636e 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/request/CommentRequest.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/request/CommentRequestDto.java @@ -1,15 +1,18 @@ -package com.woowacourse.pickgit.post.presentation.dto.request; +package com.woowacourse.pickgit.post.application.dto.request; -public class CommentRequest { +import lombok.Builder; + +@Builder +public class CommentRequestDto { private String userName; private String content; private Long postId; - private CommentRequest() { + private CommentRequestDto() { } - public CommentRequest(String userName, String content, Long postId) { + public CommentRequestDto(String userName, String content, Long postId) { this.userName = userName; this.content = content; this.postId = postId; diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/request/HomeFeedRequestDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/request/HomeFeedRequestDto.java new file mode 100644 index 000000000..0464b6ab7 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/request/HomeFeedRequestDto.java @@ -0,0 +1,48 @@ +package com.woowacourse.pickgit.post.application.dto.request; + +import com.woowacourse.pickgit.authentication.domain.user.AppUser; +import lombok.Builder; + +@Builder +public class HomeFeedRequestDto { + + private String requestUserName; + private boolean isGuest; + private Long page; + private Long limit; + + public HomeFeedRequestDto(AppUser appUser, Long page, Long limit) { + if(appUser.isGuest()) { + requestUserName = null; + } else { + requestUserName = appUser.getUsername(); + } + + this.isGuest = appUser.isGuest(); + this.page = page; + this.limit = limit; + } + + public HomeFeedRequestDto(String requestUserName, boolean isGuest, Long page, Long limit) { + this.requestUserName = requestUserName; + this.isGuest = isGuest; + this.page = page; + this.limit = limit; + } + + public String getRequestUserName() { + return requestUserName; + } + + public boolean isGuest() { + return isGuest; + } + + public Long getPage() { + return page; + } + + public Long getLimit() { + return limit; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/request/PostDeleteRequestDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/request/PostDeleteRequestDto.java new file mode 100644 index 000000000..211eace07 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/request/PostDeleteRequestDto.java @@ -0,0 +1,31 @@ +package com.woowacourse.pickgit.post.application.dto.request; + +import com.woowacourse.pickgit.authentication.domain.user.AppUser; +import lombok.Builder; + +@Builder +public class PostDeleteRequestDto { + + private String username; + private Long postId; + + private PostDeleteRequestDto() { + } + + public PostDeleteRequestDto(AppUser user, Long postId) { + this(user.getUsername(), postId); + } + + public PostDeleteRequestDto(String username, Long postId) { + this.username = username; + this.postId = postId; + } + + public String getUsername() { + return username; + } + + public Long getPostId() { + return postId; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/request/PostRequestDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/request/PostRequestDto.java index bc1c40f7d..1a174587c 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/request/PostRequestDto.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/request/PostRequestDto.java @@ -1,8 +1,10 @@ package com.woowacourse.pickgit.post.application.dto.request; import java.util.List; +import lombok.Builder; import org.springframework.web.multipart.MultipartFile; +@Builder public class PostRequestDto { private String token; @@ -15,8 +17,14 @@ public class PostRequestDto { private PostRequestDto() { } - public PostRequestDto(String token, String username, List images, - String githubRepoUrl, List tags, String content) { + public PostRequestDto( + String token, + String username, + List images, + String githubRepoUrl, + List tags, + String content + ) { this.token = token; this.username = username; this.images = images; diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/request/PostUpdateRequestDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/request/PostUpdateRequestDto.java new file mode 100644 index 000000000..dcd37d111 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/request/PostUpdateRequestDto.java @@ -0,0 +1,44 @@ +package com.woowacourse.pickgit.post.application.dto.request; + +import com.woowacourse.pickgit.authentication.domain.user.AppUser; +import java.util.List; +import lombok.Builder; + +@Builder +public class PostUpdateRequestDto { + + private String username; + private Long postId; + private List tags; + private String content; + + private PostUpdateRequestDto() { + } + + public PostUpdateRequestDto(AppUser user, Long postId, List tags, String content) { + this(user.getUsername(), postId, tags, content); + } + + public PostUpdateRequestDto(String username, Long postId, List tags, String content) { + this.username = username; + this.postId = postId; + this.tags = tags; + this.content = content; + } + + public String getUsername() { + return username; + } + + public Long getPostId() { + return postId; + } + + public List getTags() { + return tags; + } + + public String getContent() { + return content; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/CommentResponseDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/CommentResponseDto.java new file mode 100644 index 000000000..bae7a44bb --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/CommentResponseDto.java @@ -0,0 +1,50 @@ +package com.woowacourse.pickgit.post.application.dto.response; + +import lombok.Builder; + +@Builder +public class CommentResponseDto { + + private Long id; + private String profileImageUrl; + private String authorName; + private String content; + private Boolean liked; + + private CommentResponseDto() { + } + + public CommentResponseDto( + Long id, + String profileImageUrl, + String authorName, + String content, + Boolean liked + ) { + this.id = id; + this.profileImageUrl = profileImageUrl; + this.authorName = authorName; + this.content = content; + this.liked = liked; + } + + public Long getId() { + return id; + } + + public String getAuthorName() { + return authorName; + } + + public String getContent() { + return content; + } + + public Boolean getLiked() { + return liked; + } + + public String getProfileImageUrl() { + return profileImageUrl; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/LikeResponseDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/LikeResponseDto.java new file mode 100644 index 000000000..8a6221e67 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/LikeResponseDto.java @@ -0,0 +1,23 @@ +package com.woowacourse.pickgit.post.application.dto.response; + +public class LikeResponseDto { + + private int likesCount; + private Boolean liked; + + private LikeResponseDto() { + } + + public LikeResponseDto(int likesCount, boolean liked) { + this.likesCount = likesCount; + this.liked = liked; + } + + public int getLikesCount() { + return likesCount; + } + + public Boolean getLiked() { + return liked; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/PostImageUrlResponseDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/PostImageUrlResponseDto.java index 0f6ec22b0..426144870 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/PostImageUrlResponseDto.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/PostImageUrlResponseDto.java @@ -1,7 +1,9 @@ package com.woowacourse.pickgit.post.application.dto.response; import java.util.List; +import lombok.Builder; +@Builder public class PostImageUrlResponseDto { private Long id; @@ -11,7 +13,7 @@ private PostImageUrlResponseDto() { } public PostImageUrlResponseDto(Long id) { - this(id, null); + this(id, List.of()); } public PostImageUrlResponseDto(Long id, List imageUrls) { diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/PostResponseDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/PostResponseDto.java index fa3bcfec6..774e3eb6a 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/PostResponseDto.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/PostResponseDto.java @@ -1,9 +1,10 @@ package com.woowacourse.pickgit.post.application.dto.response; -import com.woowacourse.pickgit.post.application.dto.CommentResponse; import java.time.LocalDateTime; import java.util.List; +import lombok.Builder; +@Builder public class PostResponseDto { private Long id; @@ -16,16 +17,26 @@ public class PostResponseDto { private List tags; private LocalDateTime createdAt; private LocalDateTime updatedAt; - private List comments; - private Boolean isLiked; + private List comments; + private Boolean liked; private PostResponseDto() { } - public PostResponseDto(Long id, List imageUrls, String githubRepoUrl, String content, - String authorName, String profileImageUrl, Integer likesCount, - List tags, LocalDateTime createdAt, LocalDateTime updatedAt, - List comments, Boolean isLiked) { + public PostResponseDto( + Long id, + List imageUrls, + String githubRepoUrl, + String content, + String authorName, + String profileImageUrl, + Integer likesCount, + List tags, + LocalDateTime createdAt, + LocalDateTime updatedAt, + List comments, + Boolean liked + ) { this.id = id; this.imageUrls = imageUrls; this.githubRepoUrl = githubRepoUrl; @@ -37,7 +48,7 @@ public PostResponseDto(Long id, List imageUrls, String githubRepoUrl, St this.createdAt = createdAt; this.updatedAt = updatedAt; this.comments = comments; - this.isLiked = isLiked; + this.liked = liked; } public Long getId() { @@ -80,11 +91,11 @@ public LocalDateTime getUpdatedAt() { return updatedAt; } - public List getComments() { + public List getComments() { return comments; } - public Boolean getIsLiked() { - return isLiked; + public Boolean getLiked() { + return liked; } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/PostUpdateResponseDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/PostUpdateResponseDto.java new file mode 100644 index 000000000..ee90b7ff8 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/PostUpdateResponseDto.java @@ -0,0 +1,27 @@ +package com.woowacourse.pickgit.post.application.dto.response; + +import java.util.List; +import lombok.Builder; + +@Builder +public class PostUpdateResponseDto { + + private List tags; + private String content; + + private PostUpdateResponseDto() { + } + + public PostUpdateResponseDto(List tags, String content) { + this.tags = tags; + this.content = content; + } + + public List getTags() { + return tags; + } + + public String getContent() { + return content; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/RepositoriesResponseDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/RepositoriesResponseDto.java deleted file mode 100644 index fff0228d9..000000000 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/RepositoriesResponseDto.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.woowacourse.pickgit.post.application.dto.response; - -import com.woowacourse.pickgit.post.domain.dto.RepositoryResponseDto; -import java.util.List; - -public class RepositoriesResponseDto { - - private List repositories; - - private RepositoriesResponseDto() { - } - - public - RepositoriesResponseDto(List repositories) { - this.repositories = repositories; - } - - public List getRepositories() { - return repositories; - } -} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/dto/RepositoryResponseDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/RepositoryResponseDto.java similarity index 71% rename from backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/dto/RepositoryResponseDto.java rename to backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/RepositoryResponseDto.java index b3cfb14c0..fb9cc44ad 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/dto/RepositoryResponseDto.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/RepositoryResponseDto.java @@ -1,27 +1,29 @@ -package com.woowacourse.pickgit.post.domain.dto; +package com.woowacourse.pickgit.post.application.dto.response; import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +@Builder public class RepositoryResponseDto { - private String name; - @JsonProperty("html_url") private String url; + private String name; + private RepositoryResponseDto() { } - public RepositoryResponseDto(String name, String url) { - this.name = name; + public RepositoryResponseDto(String url, String name) { this.url = url; - } - - public String getName() { - return name; + this.name = name; } public String getUrl() { return url; } + + public String getName() { + return name; + } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/RepositoryResponseDtos.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/RepositoryResponseDtos.java new file mode 100644 index 000000000..98c98d3d9 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/application/dto/response/RepositoryResponseDtos.java @@ -0,0 +1,21 @@ +package com.woowacourse.pickgit.post.application.dto.response; + +import java.util.List; +import lombok.Builder; + +@Builder +public class RepositoryResponseDtos { + + private List repositoryResponseDtos; + + private RepositoryResponseDtos() { + } + + public RepositoryResponseDtos(List repositoryResponseDtos) { + this.repositoryResponseDtos = repositoryResponseDtos; + } + + public List getRepositoryResponseDtos() { + return repositoryResponseDtos; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/PlatformRepositoryExtractor.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/PlatformRepositoryExtractor.java deleted file mode 100644 index 6c0b7302a..000000000 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/PlatformRepositoryExtractor.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.woowacourse.pickgit.post.domain; - -import com.woowacourse.pickgit.post.domain.dto.RepositoryResponseDto; -import java.util.List; - -public interface PlatformRepositoryExtractor { - - List extract(String token, String username); -} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/Post.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/Post.java index c21f700ad..6cbd19352 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/Post.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/Post.java @@ -2,18 +2,21 @@ import static java.util.stream.Collectors.toList; -import com.woowacourse.pickgit.exception.post.CannotAddTagException; +import com.woowacourse.pickgit.exception.post.PostNotBelongToUserException; import com.woowacourse.pickgit.post.domain.comment.Comment; import com.woowacourse.pickgit.post.domain.comment.Comments; +import com.woowacourse.pickgit.post.domain.content.Image; import com.woowacourse.pickgit.post.domain.content.Images; +import com.woowacourse.pickgit.post.domain.content.PostContent; +import com.woowacourse.pickgit.post.domain.like.Like; import com.woowacourse.pickgit.post.domain.like.Likes; +import com.woowacourse.pickgit.post.domain.tag.PostTags; import com.woowacourse.pickgit.tag.domain.Tag; import com.woowacourse.pickgit.user.domain.User; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.Objects; -import javax.persistence.CascadeType; import javax.persistence.Embedded; import javax.persistence.Entity; import javax.persistence.EntityListeners; @@ -23,7 +26,6 @@ import javax.persistence.Id; import javax.persistence.JoinColumn; import javax.persistence.ManyToOne; -import javax.persistence.OneToMany; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @@ -35,107 +37,108 @@ public class Post { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + @Embedded private Images images; @Embedded private PostContent content; - private String githubRepoUrl; - @Embedded private Likes likes; @Embedded private Comments comments; + @Embedded + private PostTags postTags; + + private String githubRepoUrl; + @CreatedDate private LocalDateTime createdAt; @LastModifiedDate private LocalDateTime updatedAt; - @OneToMany(mappedBy = "post", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) - private List postTags = new ArrayList<>(); - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - private User user; - protected Post() { } - public Post(Long id, Images images, PostContent content, String githubRepoUrl, - Likes likes, Comments comments, - List postTags, User user) { + protected Post( + Long id, + User user, + Images images, + PostContent content, + Likes likes, + Comments comments, + PostTags postTags, + String githubRepoUrl, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { this.id = id; + this.user = user; this.images = images; - this.content = content; - this.githubRepoUrl = githubRepoUrl; this.likes = likes; + this.content = content; this.comments = comments; this.postTags = postTags; - this.user = user; + this.githubRepoUrl = githubRepoUrl; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + + images.belongTo(this); } - public Post(PostContent content, Images images, String githubRepoUrl, User user) { - this.content = content; - this.images = images; - this.githubRepoUrl = githubRepoUrl; - this.user = user; - if (!Objects.isNull(images)) { - images.setMapping(this); - } + public static Builder builder() { + return new Builder(); } public void addComment(Comment comment) { - comments.addComment(comment); + comments.addComment(comment, this); } - public Long getId() { - return id; + public void addTags(List tags) { + postTags.addAll(this, tags); } - public List getImageUrls() { - return images.getUrls(); + public void like(User user) { + Like like = new Like(this, user); + likes.add(like); } - public void addTags(List tags) { - List existingTags = getTags(); - for (Tag tag : tags) { - if (existingTags.contains(tag)) { - throw new CannotAddTagException(); - } - PostTag postTag = new PostTag(this, tag); - postTags.add(postTag); - } + public void unlike(User user) { + Like like = new Like(this, user); + likes.remove(like); } - public List getTags() { - return postTags.stream() - .map(PostTag::getTag) - .collect(toList()); + public boolean isLikedBy(User user) { + Like like = new Like(this, user); + return likes.contains(like); } - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - Post post = (Post) o; - return Objects.equals(id, post.id); + public boolean isWrittenBy(User user) { + return this.user.equals(user); } - @Override - public int hashCode() { - return Objects.hash(id); + public void updateContent(String content) { + this.content = new PostContent(content); + } + + public void updateTags(List tags) { + postTags.clear(); + addTags(tags); } - public List getImagaeUrls() { - return images.getImageUrls(); + public Long getId() { + return id; + } + + public List getImageUrls() { + return images.getUrls(); } public String getGithubRepoUrl() { @@ -158,15 +161,127 @@ public String getContent() { return content.getContent(); } - public User getUser() { - return user; + public String getAuthorName() { + return user.getName(); + } + + public String getAuthorProfileImage() { + return user.getImage(); } public List getComments() { return comments.getComments(); } - public boolean isLikedBy(String userName) { - return likes.contains(userName); + public List getTagNames() { + return postTags.getTagNames(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Post post = (Post) o; + return Objects.equals(id, post.getId()); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + public static class Builder { + + private Long id; + private User author; + private Images images = new Images(List.of()); + private PostContent content; + private Likes likes = new Likes(); + private Comments comments = new Comments(); + private PostTags postTags = new PostTags(); + private String githubRepoUrl; + private LocalDateTime createdAt = null; + private LocalDateTime updatedAt = null; + + private List tags = new ArrayList<>(); + + public Builder id(Long id) { + this.id = id; + return this; + } + + public Builder author(User user) { + this.author = user; + return this; + } + + public Builder images(List imageUrls) { + List images = imageUrls.stream() + .map(Image::new) + .collect(toList()); + + this.images = new Images(images); + return this; + } + + public Builder images(Images images) { + this.images = images; + return this; + } + + public Builder content(String content) { + this.content = new PostContent(content); + return this; + } + + public Builder tags(Tag... tags) { + tags(List.of(tags)); + return this; + } + + public Builder tags(List tags) { + this.tags = tags; + return this; + } + + public Builder githubRepoUrl(String githubRepoUrl) { + this.githubRepoUrl = githubRepoUrl; + return this; + } + + @Deprecated + public Builder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + @Deprecated + public Builder updatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + return this; + } + + public Post build() { + Post post = new Post( + id, + author, + images, + content, + likes, + comments, + postTags, + githubRepoUrl, + createdAt, + updatedAt + ); + + post.addTags(tags); + + return post; + } } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/PostContent.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/PostContent.java deleted file mode 100644 index 8bf9dad6d..000000000 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/PostContent.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.woowacourse.pickgit.post.domain; - -import com.woowacourse.pickgit.exception.post.PostFormatException; -import javax.persistence.Embeddable; -import javax.persistence.Lob; - -@Embeddable -public class PostContent { - - @Lob - private String content; - - protected PostContent() { - } - - public PostContent(String content) { - validate(content); - this.content = content; - } - - private void validate(String content) { - if (isOver500(content)) { - throw new PostFormatException(); - } - } - - private boolean isOver500(String content) { - return content.length() > 500; - } - - public String getContent() { - return content; - } -} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/Posts.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/Posts.java index 679356e2e..18bb504be 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/Posts.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/Posts.java @@ -9,13 +9,22 @@ @Embeddable public class Posts { - @OneToMany(mappedBy = "user", fetch = FetchType.LAZY) - private List posts = new ArrayList<>(); + @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, orphanRemoval = true) + private List posts; public Posts() { + this(new ArrayList<>()); } - public int getCounts() { + public Posts(List posts) { + this.posts = posts; + } + + public int count() { return posts.size(); } + + public List getPosts() { + return posts; + } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/comment/Comment.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/comment/Comment.java index 10c164b82..262dcbc9c 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/comment/Comment.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/comment/Comment.java @@ -32,18 +32,34 @@ public class Comment { protected Comment() { } - public Comment(String content) { - this.content = new CommentContent(content); + public Comment(String content, User user) { + this(null, content, user); } - public Comment writeBy(User user) { + public Comment(Long id, String content, User user) { + this.id = id; + this.content = new CommentContent(content); this.user = user; - return this; } - public Comment toPost(Post post) { + public void belongTo(Post post) { this.post = post; - return this; + } + + public Long getId() { + return id; + } + + public String getProfileImageUrl() { + return user.getImage(); + } + + public String getAuthorName() { + return user.getName(); + } + + public String getContent() { + return content.getContent(); } @Override @@ -55,27 +71,11 @@ public boolean equals(Object o) { return false; } Comment comment = (Comment) o; - return Objects.equals(id, comment.id); + return Objects.equals(id, comment.getId()); } @Override public int hashCode() { return Objects.hash(id); } - - public User getUser() { - return user; - } - - public Long getId() { - return id; - } - - public String getAuthorName() { - return user.getName(); - } - - public String getContent() { - return content.getContent(); - } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/comment/CommentContent.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/comment/CommentContent.java index 0db75e351..a94882d77 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/comment/CommentContent.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/comment/CommentContent.java @@ -27,11 +27,28 @@ public CommentContent(String content) { private boolean isNotValidContent(String content) { return Objects.isNull(content) - || content.isEmpty() - || content.length() > MAX_COMMENT_CONTENT_LENGTH; + || content.isBlank() + || content.length() >= MAX_COMMENT_CONTENT_LENGTH; } public String getContent() { return content; } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CommentContent that = (CommentContent) o; + return Objects.equals(content, that.getContent()); + } + + @Override + public int hashCode() { + return Objects.hash(content); + } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/comment/Comments.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/comment/Comments.java index 2768861f5..f529aa132 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/comment/Comments.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/comment/Comments.java @@ -1,5 +1,6 @@ package com.woowacourse.pickgit.post.domain.comment; +import com.woowacourse.pickgit.post.domain.Post; import java.util.ArrayList; import java.util.List; import javax.persistence.CascadeType; @@ -10,13 +11,23 @@ @Embeddable public class Comments { - @OneToMany(mappedBy = "post", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) - private List comments = new ArrayList<>(); + @OneToMany( + mappedBy = "post", + fetch = FetchType.LAZY, + cascade = {CascadeType.PERSIST, CascadeType.REMOVE} + ) + private List comments; public Comments() { + this(new ArrayList<>()); } - public void addComment(Comment comment) { + public Comments(List comments) { + this.comments = comments; + } + + public void addComment(Comment comment, Post targetPost) { + comment.belongTo(targetPost); comments.add(comment); } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/content/Image.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/content/Image.java index 6ce856e1a..d0e883228 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/content/Image.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/content/Image.java @@ -31,13 +31,12 @@ public Image(String url) { this.url = url; } - public String getUrl() { - return url; + public void belongTo(Post post) { + this.post = post; } - public Image toPost(Post post) { - this.post = post; - return this; + public String getUrl() { + return url; } @Override @@ -49,11 +48,11 @@ public boolean equals(Object o) { return false; } Image image = (Image) o; - return Objects.equals(id, image.id); + return Objects.equals(url, image.getUrl()); } @Override public int hashCode() { - return Objects.hash(id); + return Objects.hash(url); } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/content/Images.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/content/Images.java index 3d34b7150..4fd364ed0 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/content/Images.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/content/Images.java @@ -1,11 +1,10 @@ package com.woowacourse.pickgit.post.domain.content; -import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toUnmodifiableList; import com.woowacourse.pickgit.post.domain.Post; import java.util.ArrayList; import java.util.List; -import java.util.stream.Collectors; import javax.persistence.CascadeType; import javax.persistence.Embeddable; import javax.persistence.FetchType; @@ -14,7 +13,11 @@ @Embeddable public class Images { - @OneToMany(mappedBy = "post", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) + @OneToMany( + mappedBy = "post", + fetch = FetchType.LAZY, + cascade = {CascadeType.PERSIST, CascadeType.REMOVE} + ) private List images = new ArrayList<>(); protected Images() { @@ -24,18 +27,20 @@ public Images(List images) { this.images = images; } + public void belongTo(Post post) { + images.forEach(image -> image.belongTo(post)); + } + public List getUrls() { return images.stream() .map(Image::getUrl) - .collect(toList()); + .collect(toUnmodifiableList()); } + public List getImageUrls() { return images.stream() .map(Image::getUrl) - .collect(Collectors.toList()); - } - - public void setMapping(Post post) { - images.forEach(image -> image.toPost(post)); + .collect(toUnmodifiableList()); } } + diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/content/PostContent.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/content/PostContent.java new file mode 100644 index 000000000..eb96a8365 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/content/PostContent.java @@ -0,0 +1,50 @@ +package com.woowacourse.pickgit.post.domain.content; + +import com.woowacourse.pickgit.exception.post.PostFormatException; +import java.util.Objects; +import javax.persistence.Embeddable; +import javax.persistence.Lob; + +@Embeddable +public class PostContent { + + public static final int MAXIMUM_CONTENT_LENGTH = 500; + + @Lob + private String content; + + protected PostContent() { + } + + public PostContent(String content) { + validateLengthIsOverThanMaximumContentLength(content); + this.content = content; + } + + private void validateLengthIsOverThanMaximumContentLength(String content) { + if (content.length() > MAXIMUM_CONTENT_LENGTH) { + throw new PostFormatException(); + } + } + + public String getContent() { + return content; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PostContent that = (PostContent) o; + return Objects.equals(content, that.getContent()); + } + + @Override + public int hashCode() { + return Objects.hash(content); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/like/Like.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/like/Like.java index 42b9f00fa..12b7ff47a 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/like/Like.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/like/Like.java @@ -30,8 +30,22 @@ public class Like { protected Like() { } - public boolean contains(String userName) { - return user.getName().equals(userName); + public Like(Post post, User user) { + this(null, post, user); + } + + public Like(Long id, Post post, User user) { + this.id = id; + this.post = post; + this.user = user; + } + + private Post getPost() { + return post; + } + + private User getUser() { + return user; } @Override @@ -43,11 +57,12 @@ public boolean equals(Object o) { return false; } Like like = (Like) o; - return Objects.equals(id, like.id); + return Objects.equals(post, like.getPost()) && + Objects.equals(user, like.getUser()); } @Override public int hashCode() { - return Objects.hash(id); + return Objects.hash(post, user); } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/like/Likes.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/like/Likes.java index a37f9f327..76af75d9f 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/like/Likes.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/like/Likes.java @@ -1,7 +1,10 @@ package com.woowacourse.pickgit.post.domain.like; +import com.woowacourse.pickgit.exception.post.CannotUnlikeException; +import com.woowacourse.pickgit.exception.post.DuplicatedLikeException; import java.util.ArrayList; import java.util.List; +import javax.persistence.CascadeType; import javax.persistence.Embeddable; import javax.persistence.FetchType; import javax.persistence.OneToMany; @@ -9,18 +12,42 @@ @Embeddable public class Likes { - @OneToMany(mappedBy = "post", fetch = FetchType.LAZY) - private List likes = new ArrayList<>(); + @OneToMany( + mappedBy = "post", + fetch = FetchType.LAZY, + cascade = CascadeType.PERSIST, + orphanRemoval = true + ) + private List likes; - protected Likes() { + public Likes() { + this(new ArrayList<>()); + } + + public Likes(List likes) { + this.likes = likes; } public int getCounts() { return likes.size(); } - public boolean contains(String userName) { + public boolean contains(Like like) { return likes.stream() - .anyMatch(like -> like.contains(userName)); + .anyMatch(like::equals); + } + + public void add(Like like) { + if (likes.contains(like)) { + throw new DuplicatedLikeException(); + } + likes.add(like); + } + + public void remove(Like like) { + if (!likes.contains(like)) { + throw new CannotUnlikeException(); + } + likes.remove(like); } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/repository/PickGitStorage.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/repository/PickGitStorage.java new file mode 100644 index 000000000..633ca2858 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/repository/PickGitStorage.java @@ -0,0 +1,13 @@ +package com.woowacourse.pickgit.post.domain.repository; + +import java.io.File; +import java.util.List; +import java.util.Optional; +import org.springframework.web.multipart.MultipartFile; + +public interface PickGitStorage { + + List store(List files, String userName); + Optional store(File file, String userName); + List storeMultipartFile(List multipartFiles, String userName); +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/PostRepository.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/repository/PostRepository.java similarity index 76% rename from backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/PostRepository.java rename to backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/repository/PostRepository.java index b4e0e386e..85c3bc698 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/PostRepository.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/repository/PostRepository.java @@ -1,5 +1,6 @@ -package com.woowacourse.pickgit.post.domain; +package com.woowacourse.pickgit.post.domain.repository; +import com.woowacourse.pickgit.post.domain.Post; import com.woowacourse.pickgit.user.domain.User; import java.util.List; import java.util.Optional; @@ -10,12 +11,9 @@ public interface PostRepository extends JpaRepository { - Optional findByUser(User user); - @Query("select p from Post p left join fetch p.user order by p.createdAt desc") List findAllPosts(Pageable pageable); - @Query("select p from Post p where p.user = :user " - + "order by p.createdAt desc") + @Query("select p from Post p where p.user = :user order by p.createdAt desc") List findAllPostsByUser(@Param("user") User user, Pageable pageable); } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/PostTag.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/tag/PostTag.java similarity index 70% rename from backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/PostTag.java rename to backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/tag/PostTag.java index dc61840c0..fe3a71261 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/PostTag.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/tag/PostTag.java @@ -1,8 +1,8 @@ -package com.woowacourse.pickgit.post.domain; +package com.woowacourse.pickgit.post.domain.tag; +import com.woowacourse.pickgit.post.domain.Post; import com.woowacourse.pickgit.tag.domain.Tag; import java.util.Objects; -import javax.persistence.CascadeType; import javax.persistence.Entity; import javax.persistence.FetchType; import javax.persistence.GeneratedValue; @@ -18,13 +18,13 @@ public class PostTag { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "post_id") - private Post post; - - @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) - @JoinColumn(name = "tag_id") + @JoinColumn(name = "tag_id", nullable = false) private Tag tag; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + protected PostTag() { } @@ -33,10 +33,18 @@ public PostTag(Post post, Tag tag) { this.tag = tag; } + public boolean hasSameTag(Tag tag) { + return Objects.equals(this.tag, tag); + } + public Tag getTag() { return tag; } + private Long getId() { + return id; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -46,7 +54,7 @@ public boolean equals(Object o) { return false; } PostTag postTag = (PostTag) o; - return Objects.equals(id, postTag.id); + return Objects.equals(id, postTag.getId()); } @Override diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/tag/PostTags.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/tag/PostTags.java new file mode 100644 index 000000000..ab646e2b8 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/tag/PostTags.java @@ -0,0 +1,82 @@ +package com.woowacourse.pickgit.post.domain.tag; + +import static java.util.stream.Collectors.toList; + +import com.woowacourse.pickgit.exception.post.CannotAddTagException; +import com.woowacourse.pickgit.post.domain.Post; +import com.woowacourse.pickgit.tag.domain.Tag; +import java.util.ArrayList; +import java.util.List; +import javax.persistence.CascadeType; +import javax.persistence.Embeddable; +import javax.persistence.FetchType; +import javax.persistence.OneToMany; + +@Embeddable +public class PostTags { + + @OneToMany( + mappedBy = "post", + fetch = FetchType.LAZY, + cascade = CascadeType.PERSIST, + orphanRemoval = true + ) + private List postTags; + + public PostTags() { + this(new ArrayList<>()); + } + + public PostTags(List postTags) { + this.postTags = postTags; + } + + public void add(Post post, Tag tag) { + addAll(post, List.of(tag)); + } + + public void addAll(Post post, List tags) { + validateDuplicateTag(tags); + tags.forEach(this::validateDuplicateTagAlreadyExistsInPost); + + tags.stream() + .map(tag -> new PostTag(post, tag)) + .forEach(postTags::add); + } + + private void validateDuplicateTag(List tags) { + long distinctCountOfNewTags = tags.stream() + .map(Tag::getName) + .distinct() + .count(); + + if (distinctCountOfNewTags != tags.size()) { + throw new CannotAddTagException(); + } + } + + private void validateDuplicateTagAlreadyExistsInPost(Tag tag) { + boolean isDuplicate = postTags.stream() + .anyMatch(postTag -> postTag.hasSameTag(tag)); + + if (isDuplicate) { + throw new CannotAddTagException(); + } + } + + public void clear() { + postTags.clear(); + } + + public List getTags() { + return postTags.stream() + .map(PostTag::getTag) + .collect(toList()); + } + + public List getTagNames() { + return getTags().stream() + .map(Tag::getName) + .collect(toList()); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/PlatformRepositoryApiRequester.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/util/PlatformRepositoryApiRequester.java similarity index 65% rename from backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/PlatformRepositoryApiRequester.java rename to backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/util/PlatformRepositoryApiRequester.java index a0e2a19b8..62d6cbb42 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/PlatformRepositoryApiRequester.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/util/PlatformRepositoryApiRequester.java @@ -1,4 +1,4 @@ -package com.woowacourse.pickgit.post.infrastructure; +package com.woowacourse.pickgit.post.domain.util; public interface PlatformRepositoryApiRequester { diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/util/PlatformRepositoryExtractor.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/util/PlatformRepositoryExtractor.java new file mode 100644 index 000000000..8e074b58d --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/util/PlatformRepositoryExtractor.java @@ -0,0 +1,9 @@ +package com.woowacourse.pickgit.post.domain.util; + +import com.woowacourse.pickgit.post.domain.util.dto.RepositoryUrlAndName; +import java.util.List; + +public interface PlatformRepositoryExtractor { + + List extract(String token, String username); +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/RestClient.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/util/RestClient.java similarity index 67% rename from backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/RestClient.java rename to backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/util/RestClient.java index db128b171..e61bdcca3 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/RestClient.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/util/RestClient.java @@ -1,4 +1,4 @@ -package com.woowacourse.pickgit.post.infrastructure; +package com.woowacourse.pickgit.post.domain.util; import org.springframework.web.client.RestOperations; diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/util/dto/RepositoryUrlAndName.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/util/dto/RepositoryUrlAndName.java new file mode 100644 index 000000000..0f05a33cd --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/domain/util/dto/RepositoryUrlAndName.java @@ -0,0 +1,27 @@ +package com.woowacourse.pickgit.post.domain.util.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class RepositoryUrlAndName { + + @JsonProperty("html_url") + private String url; + + private String name; + + private RepositoryUrlAndName() { + } + + public RepositoryUrlAndName(String name, String url) { + this.name = name; + this.url = url; + } + + public String getName() { + return name; + } + + public String getUrl() { + return url; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/S3Storage.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/S3Storage.java index 418195b57..dafd86a26 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/S3Storage.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/S3Storage.java @@ -1,25 +1,24 @@ package com.woowacourse.pickgit.post.infrastructure; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.sun.xml.bind.v2.model.core.TypeRef; +import static java.util.stream.Collectors.toList; + import com.woowacourse.pickgit.exception.platform.PlatformHttpErrorException; -import com.woowacourse.pickgit.post.presentation.PickGitStorage; +import com.woowacourse.pickgit.post.domain.repository.PickGitStorage; +import com.woowacourse.pickgit.post.domain.util.RestClient; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; -import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.mongo.embedded.EmbeddedMongoProperties.Storage; import org.springframework.context.annotation.Profile; import org.springframework.core.io.FileSystemResource; -import org.springframework.http.HttpMethod; -import org.springframework.http.RequestEntity; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Repository; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestTemplate; +import org.springframework.web.multipart.MultipartFile; @Repository @Profile("!test") @@ -28,12 +27,14 @@ public class S3Storage implements PickGitStorage { private static final String MULTIPART_KEY = "files"; private final RestClient restClient; + private final String s3ProxyUrl; - @Value("${storage.pickgit.s3proxy}") - private String s3ProxyUrl; - - public S3Storage(RestClient restClient) { + public S3Storage( + RestClient restClient, + @Value("${storage.pickgit.s3proxy}") String s3ProxyUrl + ) { this.restClient = restClient; + this.s3ProxyUrl = s3ProxyUrl; } @Override @@ -45,6 +46,16 @@ public List store(List files, String userName) { return response.getUrls(); } + @Override + public Optional store(File file, String userName) { + List imageUrls = restClient + .postForEntity(s3ProxyUrl, createBody(List.of(file), userName), StorageDto.class) + .getBody() + .getUrls(); + + return Optional.ofNullable(imageUrls.get(0)); + } + private MultiValueMap createBody( List files, String userName @@ -62,6 +73,38 @@ private MultiValueMap createMultipartMap(List files) { return body; } + @Override + public List storeMultipartFile(List multipartFiles, String userName) { + return store(toFiles(multipartFiles), userName); + } + + private List toFiles(List files) { + return files.stream() + .map(toFile()) + .collect(toList()); + } + + private Function toFile() { + return multipartFile -> { + try { + return multipartFile.getResource().getFile(); + } catch (IOException e) { + return tryCreateTempFile(multipartFile); + } + }; + } + + private File tryCreateTempFile(MultipartFile multipartFile) { + try { + Path tempFile = Files.createTempFile(null, null); + Files.write(tempFile, multipartFile.getBytes()); + + return tempFile.toFile(); + } catch (IOException ioException) { + throw new PlatformHttpErrorException(); + } + } + public static class StorageDto { private List urls; diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/GithubRepositoryExtractor.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/extractor/GithubRepositoryExtractor.java similarity index 69% rename from backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/GithubRepositoryExtractor.java rename to backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/extractor/GithubRepositoryExtractor.java index 2d25da68e..dfdde54b8 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/GithubRepositoryExtractor.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/extractor/GithubRepositoryExtractor.java @@ -1,10 +1,12 @@ -package com.woowacourse.pickgit.post.infrastructure; +package com.woowacourse.pickgit.post.infrastructure.extractor; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import com.woowacourse.pickgit.post.domain.PlatformRepositoryExtractor; -import com.woowacourse.pickgit.post.domain.dto.RepositoryResponseDto; +import com.woowacourse.pickgit.exception.post.RepositoryParseException; +import com.woowacourse.pickgit.post.domain.util.PlatformRepositoryApiRequester; +import com.woowacourse.pickgit.post.domain.util.PlatformRepositoryExtractor; +import com.woowacourse.pickgit.post.domain.util.dto.RepositoryUrlAndName; import java.util.List; import org.springframework.stereotype.Component; @@ -24,7 +26,7 @@ public GithubRepositoryExtractor( } @Override - public List extract(String token, String username) { + public List extract(String token, String username) { String apiUrl = generateApiUrl(username); String response = platformRepositoryApiRequester.request(token, apiUrl); @@ -35,11 +37,11 @@ private String generateApiUrl(String username) { return String.format(API_URL_FORMAT, username); } - private List parseToRepositories(String response) { + private List parseToRepositories(String response) { try { return objectMapper.readValue(response, new TypeReference<>() {}); } catch (JsonProcessingException e) { - throw new IllegalArgumentException(); + throw new RepositoryParseException(); } } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/GithubRepositoryApiRequester.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/requester/GithubRepositoryApiRequester.java similarity index 57% rename from backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/GithubRepositoryApiRequester.java rename to backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/requester/GithubRepositoryApiRequester.java index 62d291120..cf388b645 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/GithubRepositoryApiRequester.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/infrastructure/requester/GithubRepositoryApiRequester.java @@ -1,13 +1,22 @@ -package com.woowacourse.pickgit.post.infrastructure; +package com.woowacourse.pickgit.post.infrastructure.requester; +import com.woowacourse.pickgit.post.domain.util.PlatformRepositoryApiRequester; +import com.woowacourse.pickgit.post.domain.util.RestClient; +import org.springframework.context.annotation.Profile; import org.springframework.http.HttpHeaders; import org.springframework.http.RequestEntity; import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; @Component +@Profile("!test") public class GithubRepositoryApiRequester implements PlatformRepositoryApiRequester { + private final RestClient restClient; + + public GithubRepositoryApiRequester(RestClient restClient) { + this.restClient = restClient; + } + @Override public String request(String token, String url) { HttpHeaders httpHeaders = new HttpHeaders(); @@ -18,7 +27,7 @@ public String request(String token, String url) { .headers(httpHeaders) .build(); - return new RestTemplate() + return restClient .exchange(requestEntity, String.class) .getBody(); } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/PickGitStorage.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/PickGitStorage.java deleted file mode 100644 index 45c28a7c7..000000000 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/PickGitStorage.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.woowacourse.pickgit.post.presentation; - -import java.io.File; -import java.util.List; - -public interface PickGitStorage { - - List store(List files, String userName); -} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/PostController.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/PostController.java index f0502e443..f5c2abcf9 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/PostController.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/PostController.java @@ -1,36 +1,47 @@ package com.woowacourse.pickgit.post.presentation; +import static java.util.stream.Collectors.toList; + import com.woowacourse.pickgit.authentication.domain.Authenticated; import com.woowacourse.pickgit.authentication.domain.user.AppUser; import com.woowacourse.pickgit.exception.authentication.UnauthorizedException; import com.woowacourse.pickgit.post.application.PostService; -import com.woowacourse.pickgit.post.application.dto.CommentResponse; -import com.woowacourse.pickgit.post.application.dto.response.PostResponseDto; +import com.woowacourse.pickgit.post.application.dto.request.CommentRequestDto; +import com.woowacourse.pickgit.post.application.dto.request.PostDeleteRequestDto; import com.woowacourse.pickgit.post.application.dto.request.PostRequestDto; +import com.woowacourse.pickgit.post.application.dto.request.PostUpdateRequestDto; import com.woowacourse.pickgit.post.application.dto.request.RepositoryRequestDto; +import com.woowacourse.pickgit.post.application.dto.response.CommentResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.LikeResponseDto; import com.woowacourse.pickgit.post.application.dto.response.PostImageUrlResponseDto; -import com.woowacourse.pickgit.post.application.dto.response.RepositoriesResponseDto; -import com.woowacourse.pickgit.post.domain.dto.RepositoryResponseDto; -import com.woowacourse.pickgit.post.presentation.dto.request.CommentRequest; +import com.woowacourse.pickgit.post.application.dto.response.PostUpdateResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.RepositoryResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.RepositoryResponseDtos; import com.woowacourse.pickgit.post.presentation.dto.request.ContentRequest; -import com.woowacourse.pickgit.post.presentation.dto.request.HomeFeedRequest; import com.woowacourse.pickgit.post.presentation.dto.request.PostRequest; +import com.woowacourse.pickgit.post.presentation.dto.request.PostUpdateRequest; +import com.woowacourse.pickgit.post.presentation.dto.response.CommentResponse; +import com.woowacourse.pickgit.post.presentation.dto.response.LikeResponse; +import com.woowacourse.pickgit.post.presentation.dto.response.PostUpdateResponse; +import com.woowacourse.pickgit.post.presentation.dto.response.RepositoryResponse; import java.net.URI; import java.util.List; +import java.util.function.Function; import javax.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.DeleteMapping; 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.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -@RestController -@RequestMapping("/api") @CrossOrigin(value = "*") +@RequestMapping("/api") +@RestController public class PostController { private static final String REDIRECT_URL = "/api/posts/%s/%d"; @@ -41,62 +52,127 @@ public PostController(PostService postService) { this.postService = postService; } - @GetMapping("/posts") - public ResponseEntity> readHomeFeed( - @Authenticated AppUser appUser, - @RequestParam Long page, - @RequestParam Long limit) { - HomeFeedRequest homeFeedRequest = new HomeFeedRequest(appUser, page, limit); - List postResponseDtos = postService.readHomeFeed(homeFeedRequest); - return ResponseEntity.ok(postResponseDtos); - } - - @GetMapping("/posts/me") - public ResponseEntity> readMyFeed(@Authenticated AppUser appUser, - @RequestParam Long page, @RequestParam Long limit) { - HomeFeedRequest homeFeedRequest = new HomeFeedRequest(appUser, page, limit); - List postResponseDtos = postService.readMyFeed(homeFeedRequest); - return ResponseEntity.ok(postResponseDtos); - } - - @GetMapping("/posts/{username}") - public ResponseEntity> readUserFeed(@Authenticated AppUser appUser, - @PathVariable String username, @RequestParam Long page, @RequestParam Long limit) { - HomeFeedRequest homeFeedRequest = new HomeFeedRequest(appUser, page, limit); - List postResponseDtos = postService - .readUserFeed(homeFeedRequest, username); - return ResponseEntity.ok(postResponseDtos); - } - @PostMapping("/posts") public ResponseEntity write( @Authenticated AppUser user, PostRequest request ) { - validateIsGuest(user); - - PostImageUrlResponseDto responseDto = postService.write( - createPostRequestDto(user, request) - ); + PostImageUrlResponseDto postImageUrlResponseDto = + postService.write(createPostRequestDto(user, request)); return ResponseEntity - .created(redirectUrl(user, responseDto)) + .created(redirectUrl(user, postImageUrlResponseDto)) .build(); } + private PostRequestDto createPostRequestDto(AppUser user, PostRequest request) { + return new PostRequestDto( + user.getAccessToken(), + user.getUsername(), + request.getImages(), + request.getGithubRepoUrl(), + request.getTags(), + request.getContent() + ); + } + + private URI redirectUrl(AppUser user, PostImageUrlResponseDto responseDto) { + return URI + .create(String.format("/api/posts/%s/%d", user.getUsername(), responseDto.getId())); + } + @PostMapping("/posts/{postId}/comments") public ResponseEntity addComment( @Authenticated AppUser user, @PathVariable Long postId, @Valid @RequestBody ContentRequest request + ) { + CommentRequestDto commentRequestDto = createCommentRequest(user, postId, request); + CommentResponseDto commentResponseDto = postService.addComment(commentRequestDto); + CommentResponse commentResponse = createCommentResponse(commentResponseDto); + + return ResponseEntity.ok(commentResponse); + } + + private CommentRequestDto createCommentRequest( + AppUser user, + Long postId, + ContentRequest request + ) { + return CommentRequestDto.builder() + .userName(user.getUsername()) + .content(request.getContent()) + .postId(postId) + .build(); + } + + private CommentResponse createCommentResponse(CommentResponseDto commentResponseDto) { + return CommentResponse.builder() + .id(commentResponseDto.getId()) + .profileImageUrl(commentResponseDto.getProfileImageUrl()) + .content(commentResponseDto.getContent()) + .authorName(commentResponseDto.getAuthorName()) + .liked(commentResponseDto.getLiked()) + .build(); + } + + @GetMapping("/github/{username}/repositories") + public ResponseEntity> userRepositories( + @Authenticated AppUser user, + @PathVariable String username + ) { + String token = user.getAccessToken(); + + RepositoryRequestDto repositoryRequestDto = new RepositoryRequestDto(token, username); + RepositoryResponseDtos repositoryResponseDtos = + postService.userRepositories(repositoryRequestDto); + List repositoryResponses = toRepositoryResponses( + repositoryResponseDtos.getRepositoryResponseDtos()); + + return ResponseEntity.ok(repositoryResponses); + } + + private List toRepositoryResponses( + List repositoryResponseDos + ) { + return repositoryResponseDos.stream() + .map(toRepositoryResponse()) + .collect(toList()); + } + + private Function toRepositoryResponse() { + return repositoryResponseDto -> RepositoryResponse.builder() + .name(repositoryResponseDto.getName()) + .url(repositoryResponseDto.getUrl()) + .build(); + } + + @PutMapping("/posts/{postId}/likes") + public ResponseEntity likePost( + @Authenticated AppUser user, + @PathVariable Long postId ) { validateIsGuest(user); + LikeResponseDto likeResponseDto = postService.like(user, postId); - CommentRequest commentRequest = - new CommentRequest(user.getUsername(), request.getContent(), postId); - CommentResponse response = postService.addComment(commentRequest); + LikeResponse likeResponse = + new LikeResponse(likeResponseDto.getLikesCount(), likeResponseDto.getLiked()); - return ResponseEntity.ok(response); + return ResponseEntity.ok(likeResponse); + } + + @DeleteMapping("/posts/{postId}/likes") + public ResponseEntity unlikePost( + @Authenticated AppUser user, + @PathVariable Long postId + ) { + validateIsGuest(user); + LikeResponseDto likeResponseDto = postService.unlike(user, postId); + + LikeResponse likeResponse = + new LikeResponse(likeResponseDto.getLikesCount(), likeResponseDto.getLiked()); + + return ResponseEntity.ok(likeResponse); } private void validateIsGuest(AppUser user) { @@ -105,30 +181,56 @@ private void validateIsGuest(AppUser user) { } } - private PostRequestDto createPostRequestDto(AppUser user, PostRequest request) { - return new PostRequestDto( - user.getAccessToken(), - user.getUsername(), - request.getImages(), - request.getGithubRepoUrl(), - request.getTags(), - request.getContent() + @PutMapping("/posts/{postId}") + public ResponseEntity update( + @Authenticated AppUser user, + @PathVariable Long postId, + @Valid @RequestBody PostUpdateRequest updateRequest + ) { + PostUpdateResponseDto updateResponseDto = postService + .update(createPostUpdateRequestDto(user, postId, updateRequest)); + + return ResponseEntity + .created(redirectUrl(user.getUsername(), postId)) + .body(createPostUpdateResponse(updateResponseDto)); + } + + private PostUpdateRequestDto createPostUpdateRequestDto( + AppUser user, + Long postId, + PostUpdateRequest updateRequest) { + return new PostUpdateRequestDto( + user, + postId, + updateRequest.getTags(), + updateRequest.getContent() ); } - private URI redirectUrl(AppUser user, PostImageUrlResponseDto responseDto) { - return URI.create(String.format(REDIRECT_URL, user.getUsername(), responseDto.getId())); + private PostUpdateResponse createPostUpdateResponse(PostUpdateResponseDto updateResponseDto) { + return PostUpdateResponse.builder() + .tags(updateResponseDto.getTags()) + .content(updateResponseDto.getContent()) + .build(); } - @GetMapping("/github/{username}/repositories") - public ResponseEntity> showRepositories( + private URI redirectUrl(String username, Long postId) { + return URI.create(String.format(REDIRECT_URL, username, postId)); + } + + @DeleteMapping("/posts/{postId}") + public ResponseEntity delete( @Authenticated AppUser user, - @PathVariable String username + @PathVariable Long postId ) { - String token = user.getAccessToken(); - RepositoriesResponseDto responseDto = postService - .showRepositories(new RepositoryRequestDto(token, username)); + postService.delete(createPostDeleteRequestDto(user, postId)); + + return ResponseEntity + .noContent() + .build(); + } - return ResponseEntity.ok(responseDto.getRepositories()); + private PostDeleteRequestDto createPostDeleteRequestDto(AppUser user, Long postId) { + return new PostDeleteRequestDto(user, postId); } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/PostFeedController.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/PostFeedController.java new file mode 100644 index 000000000..2f470c69a --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/PostFeedController.java @@ -0,0 +1,95 @@ +package com.woowacourse.pickgit.post.presentation; + +import static java.util.stream.Collectors.toList; + +import com.woowacourse.pickgit.authentication.domain.Authenticated; +import com.woowacourse.pickgit.authentication.domain.user.AppUser; +import com.woowacourse.pickgit.post.application.PostFeedService; +import com.woowacourse.pickgit.post.application.dto.request.HomeFeedRequestDto; +import com.woowacourse.pickgit.post.application.dto.response.PostResponseDto; +import com.woowacourse.pickgit.post.presentation.dto.response.PostResponse; +import java.util.List; +import java.util.function.Function; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@CrossOrigin(value = "*") +@RequestMapping("/api") +@RestController +public class PostFeedController { + + private final PostFeedService postFeedService; + + public PostFeedController(PostFeedService postFeedService) { + this.postFeedService = postFeedService; + } + + @GetMapping("/posts") + public ResponseEntity> readHomeFeed( + @Authenticated AppUser appUser, + @RequestParam Long page, + @RequestParam Long limit + ) { + HomeFeedRequestDto homeFeedRequestDto = new HomeFeedRequestDto(appUser, page, limit); + List postResponseDtos = postFeedService.homeFeed(homeFeedRequestDto); + List postResponses = createPostResponses(postResponseDtos); + + return ResponseEntity.ok(postResponses); + } + + @GetMapping("/posts/me") + public ResponseEntity> readMyFeed( + @Authenticated AppUser appUser, + @RequestParam Long page, + @RequestParam Long limit + ) { + HomeFeedRequestDto homeFeedRequestDto = new HomeFeedRequestDto(appUser, page, limit); + List postResponseDtos = postFeedService.myFeed(homeFeedRequestDto); + List postResponses = createPostResponses(postResponseDtos); + + return ResponseEntity.ok(postResponses); + } + + @GetMapping("/posts/{username}") + public ResponseEntity> readUserFeed( + @Authenticated AppUser appUser, + @PathVariable String username, + @RequestParam Long page, + @RequestParam Long limit + ) { + HomeFeedRequestDto homeFeedRequestDto = new HomeFeedRequestDto(appUser, page, limit); + List postResponseDtos = + postFeedService.userFeed(homeFeedRequestDto, username); + List postResponses = createPostResponses(postResponseDtos); + + return ResponseEntity.ok(postResponses); + } + + private List createPostResponses(List postResponseDtos) { + return postResponseDtos.stream() + .map(toPostResponseDtoPostResponse()) + .collect(toList()); + } + + private Function toPostResponseDtoPostResponse() { + return postResponseDto -> PostResponse.builder() + .id(postResponseDto.getId()) + .imageUrls(postResponseDto.getImageUrls()) + .githubRepoUrl(postResponseDto.getGithubRepoUrl()) + .content(postResponseDto.getContent()) + .authorName(postResponseDto.getAuthorName()) + .profileImageUrl(postResponseDto.getProfileImageUrl()) + .likesCount(postResponseDto.getLikesCount()) + .tags(postResponseDto.getTags()) + .createdAt(postResponseDto.getCreatedAt()) + .updatedAt(postResponseDto.getUpdatedAt()) + .comments(postResponseDto.getComments()) + .liked(postResponseDto.getLiked()) + .build(); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/request/HomeFeedRequest.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/request/HomeFeedRequest.java index 5537aaf21..547180464 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/request/HomeFeedRequest.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/request/HomeFeedRequest.java @@ -1,7 +1,9 @@ package com.woowacourse.pickgit.post.presentation.dto.request; import com.woowacourse.pickgit.authentication.domain.user.AppUser; +import lombok.Builder; +@Builder public class HomeFeedRequest { private AppUser appUser; diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/request/PostRequest.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/request/PostRequest.java index a7f53c0d1..208795ba2 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/request/PostRequest.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/request/PostRequest.java @@ -1,11 +1,10 @@ package com.woowacourse.pickgit.post.presentation.dto.request; import java.util.List; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.Size; +import lombok.Builder; import org.springframework.web.multipart.MultipartFile; +@Builder public class PostRequest { private List images; diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/request/PostUpdateRequest.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/request/PostUpdateRequest.java new file mode 100644 index 000000000..d885ef60d --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/request/PostUpdateRequest.java @@ -0,0 +1,32 @@ +package com.woowacourse.pickgit.post.presentation.dto.request; + +import java.util.List; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; +import lombok.Builder; + +@Builder +public class PostUpdateRequest { + + private List tags; + + @NotNull(message = "F0001") + @Size(max = 500, message = "F0004") + private String content; + + private PostUpdateRequest() { + } + + public PostUpdateRequest(List tags, String content) { + this.tags = tags; + this.content = content; + } + + public List getTags() { + return tags; + } + + public String getContent() { + return content; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/response/CommentResponse.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/response/CommentResponse.java index 5cb3ee179..6366447b3 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/response/CommentResponse.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/response/CommentResponse.java @@ -1,26 +1,50 @@ package com.woowacourse.pickgit.post.presentation.dto.response; -import com.woowacourse.pickgit.post.domain.comment.Comment; +import lombok.Builder; +@Builder public class CommentResponse { private Long id; + private String profileImageUrl; private String authorName; private String content; - private Boolean isLiked; + private Boolean liked; private CommentResponse() { } - public CommentResponse(Long id, String authorName, String content, Boolean isLiked) { + public CommentResponse( + Long id, + String profileImageUrl, + String authorName, + String content, + Boolean liked + ) { this.id = id; + this.profileImageUrl = profileImageUrl; this.authorName = authorName; this.content = content; - this.isLiked = isLiked; + this.liked = liked; } - public static CommentResponse from(Comment comment) { - return new CommentResponse(comment.getId(), comment.getAuthorName(), - comment.getContent(), false); + public Long getId() { + return id; + } + + public String getAuthorName() { + return authorName; + } + + public String getContent() { + return content; + } + + public Boolean getLiked() { + return liked; + } + + public String getProfileImageUrl() { + return profileImageUrl; } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/response/LikeResponse.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/response/LikeResponse.java new file mode 100644 index 000000000..9d977e724 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/response/LikeResponse.java @@ -0,0 +1,23 @@ +package com.woowacourse.pickgit.post.presentation.dto.response; + +public class LikeResponse { + + private int likesCount; + private Boolean liked; + + private LikeResponse() { + } + + public LikeResponse(int likeCount, boolean liked) { + this.likesCount = likeCount; + this.liked = liked; + } + + public int getLikesCount() { + return likesCount; + } + + public Boolean getLiked() { + return liked; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/response/PostResponse.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/response/PostResponse.java new file mode 100644 index 000000000..5753187b8 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/response/PostResponse.java @@ -0,0 +1,102 @@ +package com.woowacourse.pickgit.post.presentation.dto.response; + +import com.woowacourse.pickgit.post.application.dto.response.CommentResponseDto; +import java.time.LocalDateTime; +import java.util.List; +import lombok.Builder; + +@Builder +public class PostResponse { + + private Long id; + private List imageUrls; + private String githubRepoUrl; + private String content; + private String authorName; + private String profileImageUrl; + private Integer likesCount; + private List tags; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private List comments; + private Boolean liked; + + private PostResponse() { + } + + public PostResponse( + Long id, + List imageUrls, + String githubRepoUrl, + String content, + String authorName, + String profileImageUrl, + Integer likesCount, + List tags, + LocalDateTime createdAt, + LocalDateTime updatedAt, + List comments, + Boolean liked + ) { + this.id = id; + this.imageUrls = imageUrls; + this.githubRepoUrl = githubRepoUrl; + this.content = content; + this.authorName = authorName; + this.profileImageUrl = profileImageUrl; + this.likesCount = likesCount; + this.tags = tags; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.comments = comments; + this.liked = liked; + } + + public Long getId() { + return id; + } + + public List getImageUrls() { + return imageUrls; + } + + public String getGithubRepoUrl() { + return githubRepoUrl; + } + + public String getContent() { + return content; + } + + public String getAuthorName() { + return authorName; + } + + public String getProfileImageUrl() { + return profileImageUrl; + } + + public Integer getLikesCount() { + return likesCount; + } + + public List getTags() { + return tags; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public List getComments() { + return comments; + } + + public Boolean getLiked() { + return liked; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/response/PostUpdateResponse.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/response/PostUpdateResponse.java new file mode 100644 index 000000000..92e560e5c --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/response/PostUpdateResponse.java @@ -0,0 +1,27 @@ +package com.woowacourse.pickgit.post.presentation.dto.response; + +import java.util.List; +import lombok.Builder; + +@Builder +public class PostUpdateResponse { + + private List tags; + private String content; + + private PostUpdateResponse() { + } + + public PostUpdateResponse(List tags, String content) { + this.tags = tags; + this.content = content; + } + + public List getTags() { + return tags; + } + + public String getContent() { + return content; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/response/RepositoryResponse.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/response/RepositoryResponse.java new file mode 100644 index 000000000..e8bbfd171 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/post/presentation/dto/response/RepositoryResponse.java @@ -0,0 +1,29 @@ +package com.woowacourse.pickgit.post.presentation.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +@Builder +public class RepositoryResponse { + + @JsonProperty("html_url") + private String url; + + private String name; + + private RepositoryResponse() { + } + + public RepositoryResponse(String url, String name) { + this.url = url; + this.name = name; + } + + public String getUrl() { + return url; + } + + public String getName() { + return name; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/application/ExtractionRequestDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/application/ExtractionRequestDto.java index 61e7bc441..cc1de1613 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/application/ExtractionRequestDto.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/application/ExtractionRequestDto.java @@ -1,5 +1,8 @@ package com.woowacourse.pickgit.tag.application; +import lombok.Builder; + +@Builder public class ExtractionRequestDto { private String accessToken; diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/application/TagService.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/application/TagService.java index e40da65f5..66a44a397 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/application/TagService.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/application/TagService.java @@ -1,10 +1,14 @@ package com.woowacourse.pickgit.tag.application; +import static java.util.stream.Collectors.toList; + import com.woowacourse.pickgit.tag.domain.PlatformTagExtractor; import com.woowacourse.pickgit.tag.domain.Tag; import com.woowacourse.pickgit.tag.domain.TagRepository; import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Objects; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,17 +31,26 @@ public TagsDto extractTags(ExtractionRequestDto extractionRequestDto) { String userName = extractionRequestDto.getUserName(); String repositoryName = extractionRequestDto.getRepositoryName(); List tags = platformTagExtractor - .extractTags(accessToken, userName, repositoryName); + .extractTags(accessToken, userName, repositoryName) + .stream() + .map(String::toLowerCase) + .collect(toList()); return new TagsDto(tags); } @Transactional(readOnly = true) public List findOrCreateTags(TagsDto tagsDto) { - List tagNames = tagsDto.getTags(); + if (Objects.isNull(tagsDto.getTagNames())) { + return Collections.emptyList(); + } + List tagNames = tagsDto.getTagNames(); List tags = new ArrayList<>(); for (String tagName : tagNames) { - tagRepository.findByName(tagName) - .ifPresentOrElse(tags::add, () -> tags.add(new Tag(tagName))); + Tag tag = new Tag(tagName); + tagRepository.findByName(tag.getName()) + .ifPresentOrElse(tags::add, + () -> tags.add(tagRepository.save(tag)) + ); } return tags; } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/application/TagsDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/application/TagsDto.java index 6cca60615..d0e31f311 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/application/TagsDto.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/application/TagsDto.java @@ -4,16 +4,16 @@ public class TagsDto { - private List tags; + private List tagNames; private TagsDto() { } - public TagsDto(List tags) { - this.tags = tags; + public TagsDto(List tagNames) { + this.tagNames = tagNames; } - public List getTags() { - return tags; + public List getTagNames() { + return tagNames; } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/domain/Tag.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/domain/Tag.java index 9b0ea626b..d63b170f9 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/domain/Tag.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/domain/Tag.java @@ -1,16 +1,12 @@ package com.woowacourse.pickgit.tag.domain; import com.woowacourse.pickgit.exception.post.TagFormatException; -import com.woowacourse.pickgit.post.domain.PostTag; -import java.util.ArrayList; -import java.util.List; import java.util.Objects; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; -import javax.persistence.OneToMany; @Entity public class Tag { @@ -21,12 +17,9 @@ public class Tag { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(nullable = false, unique = true, length = 20) + @Column(nullable = false, unique = true, length = MAX_TAG_LENGTH) private String name; - @OneToMany(mappedBy = "tag") - private List postTags = new ArrayList<>(); - protected Tag() { } @@ -34,15 +27,19 @@ public Tag(String name) { if (isNotValidTag(name)) { throw new TagFormatException(); } - this.name = name; + this.name = name.toLowerCase(); } private boolean isNotValidTag(String name) { return Objects.isNull(name) - || name.isEmpty() + || name.isBlank() || name.length() > MAX_TAG_LENGTH; } + public Long getId() { + return id; + } + public String getName() { return name; } @@ -56,7 +53,7 @@ public boolean equals(Object o) { return false; } Tag tag = (Tag) o; - return Objects.equals(getName(), tag.getName()); + return Objects.equals(name, tag.getName()); } @Override diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/infrastructure/GithubApiRequester.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/infrastructure/GithubApiRequester.java deleted file mode 100644 index 3b19c36ef..000000000 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/infrastructure/GithubApiRequester.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.woowacourse.pickgit.tag.infrastructure; - -import org.springframework.http.HttpHeaders; -import org.springframework.http.RequestEntity; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -@Component -public class GithubApiRequester implements PlatformApiRequester { - - @Override - public String requestTags(String url, String accessToken) { - HttpHeaders httpHeaders = new HttpHeaders(); - httpHeaders.setBearerAuth(accessToken); - RequestEntity requestEntity = RequestEntity.get(url) - .headers(httpHeaders) - .build(); - return new RestTemplate().exchange(requestEntity, String.class) - .getBody(); - } -} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/infrastructure/GithubTagApiRequester.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/infrastructure/GithubTagApiRequester.java new file mode 100644 index 000000000..7581926c1 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/infrastructure/GithubTagApiRequester.java @@ -0,0 +1,29 @@ +package com.woowacourse.pickgit.tag.infrastructure; + +import com.woowacourse.pickgit.common.network.RestTemplateClient; +import com.woowacourse.pickgit.exception.platform.PlatformHttpErrorException; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpHeaders; +import org.springframework.http.RequestEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; + +@Component +@Profile("!test") +public class GithubTagApiRequester implements PlatformTagApiRequester { + + @Override + public String requestTags(String url, String accessToken) { + try { + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.setBearerAuth(accessToken); + RequestEntity requestEntity = RequestEntity.get(url) + .headers(httpHeaders) + .build(); + return new RestTemplateClient().exchange(requestEntity, String.class) + .getBody(); + } catch (HttpClientErrorException e) { + throw new PlatformHttpErrorException(e.getMessage()); + } + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/infrastructure/GithubTagExtractor.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/infrastructure/GithubTagExtractor.java index b573df75a..593bbb8b2 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/infrastructure/GithubTagExtractor.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/infrastructure/GithubTagExtractor.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.woowacourse.pickgit.exception.platform.PlatformHttpErrorException; import com.woowacourse.pickgit.tag.domain.PlatformTagExtractor; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -16,18 +17,18 @@ public class GithubTagExtractor implements PlatformTagExtractor { private static final String GITHUB_TAG_API_FORMAT = "https://api.github.com/repos/%s/%s/languages"; - private final PlatformApiRequester platformApiRequester; + private final PlatformTagApiRequester platformTagApiRequester; private final ObjectMapper objectMapper; - public GithubTagExtractor(PlatformApiRequester platformApiRequester, + public GithubTagExtractor(PlatformTagApiRequester platformTagApiRequester, ObjectMapper objectMapper) { - this.platformApiRequester = platformApiRequester; + this.platformTagApiRequester = platformTagApiRequester; this.objectMapper = objectMapper; } public List extractTags(String accessToken, String userName, String repositoryName) { String url = generateApiUrl(userName, repositoryName); - String response = platformApiRequester.requestTags(url, accessToken); + String response = platformTagApiRequester.requestTags(url, accessToken); return parseResponseIntoLanguageTags(response); } @@ -43,7 +44,7 @@ private List parseResponseIntoLanguageTags(String response) { .keySet(); return new ArrayList<>(tags); } catch (JsonProcessingException e) { - throw new IllegalStateException(); + throw new PlatformHttpErrorException(); } } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/infrastructure/PlatformApiRequester.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/infrastructure/PlatformTagApiRequester.java similarity index 72% rename from backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/infrastructure/PlatformApiRequester.java rename to backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/infrastructure/PlatformTagApiRequester.java index 4ff44cb8b..98820eb1e 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/infrastructure/PlatformApiRequester.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/infrastructure/PlatformTagApiRequester.java @@ -1,6 +1,6 @@ package com.woowacourse.pickgit.tag.infrastructure; -public interface PlatformApiRequester { +public interface PlatformTagApiRequester { String requestTags(String url, String accessToken); } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/presentation/TagController.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/presentation/TagController.java index 2adfb25e8..338d1afc5 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/presentation/TagController.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/tag/presentation/TagController.java @@ -28,9 +28,12 @@ public TagController(TagService tagService) { public ResponseEntity> extractLanguageTags(@Authenticated AppUser appUser, @PathVariable String repositoryName) { String accessToken = appUser.getAccessToken(); - ExtractionRequestDto extractionRequestDto = - new ExtractionRequestDto(accessToken, appUser.getUsername(), repositoryName); + ExtractionRequestDto extractionRequestDto = ExtractionRequestDto.builder() + .accessToken(accessToken) + .userName(appUser.getUsername()) + .repositoryName(repositoryName) + .build(); TagsDto tagsDto = tagService.extractTags(extractionRequestDto); - return ResponseEntity.ok(tagsDto.getTags()); + return ResponseEntity.ok(tagsDto.getTagNames()); } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/UserService.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/UserService.java index 2bdbb3c22..0b035dba2 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/UserService.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/UserService.java @@ -1,92 +1,241 @@ package com.woowacourse.pickgit.user.application; -import com.woowacourse.pickgit.authentication.domain.user.AppUser; -import com.woowacourse.pickgit.user.application.dto.AuthUserServiceDto; -import com.woowacourse.pickgit.user.application.dto.FollowServiceDto; -import com.woowacourse.pickgit.user.application.dto.UserProfileServiceDto; +import static java.util.stream.Collectors.toList; + +import com.woowacourse.pickgit.exception.authentication.UnauthorizedException; +import com.woowacourse.pickgit.exception.platform.PlatformHttpErrorException; +import com.woowacourse.pickgit.exception.user.InvalidUserException; +import com.woowacourse.pickgit.post.domain.repository.PickGitStorage; +import com.woowacourse.pickgit.user.application.dto.request.AuthUserRequestDto; +import com.woowacourse.pickgit.user.application.dto.request.ProfileEditRequestDto; +import com.woowacourse.pickgit.user.application.dto.request.UserSearchRequestDto; +import com.woowacourse.pickgit.user.application.dto.response.ContributionResponseDto; +import com.woowacourse.pickgit.user.application.dto.response.FollowResponseDto; +import com.woowacourse.pickgit.user.application.dto.response.ProfileEditResponseDto; +import com.woowacourse.pickgit.user.application.dto.response.UserProfileResponseDto; +import com.woowacourse.pickgit.user.application.dto.response.UserSearchResponseDto; +import com.woowacourse.pickgit.user.domain.Contribution; +import com.woowacourse.pickgit.user.domain.PlatformContributionCalculator; import com.woowacourse.pickgit.user.domain.User; import com.woowacourse.pickgit.user.domain.UserRepository; -import com.woowacourse.pickgit.exception.user.InvalidUserException; -import com.woowacourse.pickgit.exception.user.SameSourceTargetUserException; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; +import org.springframework.data.domain.PageRequest; +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 public class UserService { private final UserRepository userRepository; - - public UserService(UserRepository userRepository) { + private final PickGitStorage pickGitStorage; + private final PlatformContributionCalculator platformContributionCalculator; + + public UserService( + UserRepository userRepository, + PickGitStorage pickGitStorage, + PlatformContributionCalculator platformContributionCalculator + ) { this.userRepository = userRepository; + this.pickGitStorage = pickGitStorage; + this.platformContributionCalculator = platformContributionCalculator; } @Transactional(readOnly = true) - public UserProfileServiceDto getMyUserProfile(AuthUserServiceDto authUserServiceDto) { - User user = findUserByName(authUserServiceDto.getGithubName()); - - return new UserProfileServiceDto( - user.getName(), user.getImage(), user.getDescription(), - user.getFollowerCount(), user.getFollowingCount(), user.getPostCount(), - user.getGithubUrl(), user.getCompany(), user.getLocation(), - user.getWebsite(), user.getTwitter(), null - ); + public UserProfileResponseDto getMyUserProfile(AuthUserRequestDto requestDto) { + validateIsGuest(requestDto); + User user = findUserByName(requestDto.getUsername()); + return generateUserProfileResponse(user, null); } @Transactional(readOnly = true) - public UserProfileServiceDto getUserProfile(AppUser appUser, String targetUsername) { - User targetUser = findUserByName(targetUsername); - - if (appUser.isGuest()) { - return new UserProfileServiceDto( - targetUser.getName(), targetUser.getImage(), targetUser.getDescription(), - targetUser.getFollowerCount(), targetUser.getFollowingCount(), targetUser.getPostCount(), - targetUser.getGithubUrl(), targetUser.getCompany(), targetUser.getLocation(), - targetUser.getWebsite(), targetUser.getTwitter(), null - ); + public UserProfileResponseDto getUserProfile(AuthUserRequestDto requestDto, String targetName) { + User target = findUserByName(targetName); + if (requestDto.isGuest()) { + return generateUserProfileResponse(target, null); } + User source = findUserByName(requestDto.getUsername()); + return generateUserProfileResponse(target, source.isFollowing(target)); + } - User sourceUser = findUserByName(appUser.getUsername()); + private UserProfileResponseDto generateUserProfileResponse(User user, Boolean following) { + return UserProfileResponseDto.builder() + .name(user.getName()) + .imageUrl(user.getImage()) + .description(user.getDescription()) + .followerCount(user.getFollowerCount()) + .followingCount(user.getFollowingCount()) + .postCount(user.getPostCount()) + .githubUrl(user.getGithubUrl()) + .company(user.getCompany()) + .location(user.getLocation()) + .website(user.getWebsite()) + .twitter(user.getTwitter()) + .following(following) + .build(); + } - return new UserProfileServiceDto( - targetUser.getName(), targetUser.getImage(), targetUser.getDescription(), - targetUser.getFollowerCount(), targetUser.getFollowingCount(), targetUser.getPostCount(), - targetUser.getGithubUrl(), targetUser.getCompany(), targetUser.getLocation(), - targetUser.getWebsite(), targetUser.getTwitter(), sourceUser.isFollowing(targetUser) - ); + public ProfileEditResponseDto editProfile( + AuthUserRequestDto authUserRequestDto, + ProfileEditRequestDto profileEditRequestDto + ) { + validateIsGuest(authUserRequestDto); + User user = findUserByName(authUserRequestDto.getUsername()); + + String userImageUrl = user.getImage(); + if (doesContainProfileImage(profileEditRequestDto.getImage())) { + userImageUrl = saveImageAndGetUrl(profileEditRequestDto.getImage(), user.getName()); + user.updateProfileImage(userImageUrl); + } + + user.updateDescription(profileEditRequestDto.getDecription()); + + return new ProfileEditResponseDto(userImageUrl, profileEditRequestDto.getDecription()); } - public FollowServiceDto followUser(AuthUserServiceDto authUserServiceDto, - String targetUsername) { - User source = findUserByName(authUserServiceDto.getGithubName()); - User target = findUserByName(targetUsername); + private boolean doesContainProfileImage(MultipartFile image) { + if (Objects.isNull(image)) { + return false; + } + return !Objects.requireNonNull(image.getOriginalFilename()).isBlank(); + } - validateDifferentSourceTarget(source, target); - source.follow(target); + private String saveImageAndGetUrl(MultipartFile image, String username) { + File file = fileFrom(image); + + return saveImageAndGetUrl(file, username); + } + + private File fileFrom(MultipartFile image) { + try { + return image.getResource().getFile(); + } catch (IOException e) { + return tryCreateTempFile(image); + } + } - return new FollowServiceDto(target.getFollowerCount(), true); + private File tryCreateTempFile(MultipartFile multipartFile) { + try { + Path tempFile = Files.createTempFile(null, null); + Files.write(tempFile, multipartFile.getBytes()); + + return tempFile.toFile(); + } catch (IOException ioException) { + throw new PlatformHttpErrorException(); + } + } + + private String saveImageAndGetUrl(File file, String username) { + return pickGitStorage + .store(file, username) + .orElseThrow(PlatformHttpErrorException::new); } - public FollowServiceDto unfollowUser(AuthUserServiceDto authUserServiceDto, - String targetUsername) { - User source = findUserByName(authUserServiceDto.getGithubName()); - User target = findUserByName(targetUsername); + public FollowResponseDto followUser(AuthUserRequestDto requestDto, String targetName) { + validateIsGuest(requestDto); + User source = findUserByName(requestDto.getUsername()); + User target = findUserByName(targetName); + source.follow(target); + return generateFollowResponse(target, true); + } - validateDifferentSourceTarget(source, target); + public FollowResponseDto unfollowUser(AuthUserRequestDto requestDto, String targetName) { + validateIsGuest(requestDto); + User source = findUserByName(requestDto.getUsername()); + User target = findUserByName(targetName); source.unfollow(target); + return generateFollowResponse(target, false); + } - return new FollowServiceDto(target.getFollowerCount(), false); + private FollowResponseDto generateFollowResponse(User target, boolean isFollowing) { + return FollowResponseDto.builder() + .followerCount(target.getFollowerCount()) + .isFollowing(isFollowing) + .build(); } - private User findUserByName(String githubName) { - return userRepository - .findByBasicProfile_Name(githubName) - .orElseThrow(InvalidUserException::new); + public ContributionResponseDto calculateContributions(String username) { + User user = findUserByName(username); + + Contribution contribution = platformContributionCalculator.calculate(user.getName()); + + return ContributionResponseDto.builder() + .starsCount(contribution.getStarsCount()) + .commitsCount(contribution.getCommitsCount()) + .prsCount(contribution.getPrsCount()) + .issuesCount(contribution.getIssuesCount()) + .reposCount(contribution.getReposCount()) + .build(); + } + + @Transactional(readOnly = true) + public List searchUser( + AuthUserRequestDto authUserRequestDto, + UserSearchRequestDto userSearchRequestDto + ) { + Pageable pageable = PageRequest.of( + userSearchRequestDto.getPage(), + userSearchRequestDto.getLimit() + ); + List users = userRepository.searchByUsernameLike( + userSearchRequestDto.getKeyword(), + pageable + ); + + if (authUserRequestDto.isGuest()) { + return convertToUserSearchResponseDtoWithoutFollowing(users); + } + + User loginUser = findUserByName(authUserRequestDto.getUsername()); + + return convertToUserSearchResponseDtoWithFollowing(loginUser, users); + } + + private List convertToUserSearchResponseDtoWithoutFollowing( + List users + ) { + return users.stream() + .map(user -> new UserSearchResponseDto( + user.getImage(), + user.getName(), + null)) + .collect(toList()); + } + + private List convertToUserSearchResponseDtoWithFollowing( + User loginUser, + List users + ) { + return users + .stream() + .filter(isLoginUser(loginUser)) + .map(user -> new UserSearchResponseDto( + user.getImage(), + user.getName(), + loginUser.isFollowing(user))) + .collect(toList()); } - private void validateDifferentSourceTarget(User source, User target) { - if (source.getId() == target.getId()) { - throw new SameSourceTargetUserException(); + private Predicate isLoginUser(User loginUser) { + return user -> !user.equals(loginUser); + } + + private void validateIsGuest(AuthUserRequestDto requestDto) { + if (requestDto.isGuest()) { + throw new UnauthorizedException(); } } + + private User findUserByName(String githubName) { + return userRepository.findByBasicProfile_Name(githubName) + .orElseThrow(InvalidUserException::new); + } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/AuthUserServiceDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/AuthUserServiceDto.java deleted file mode 100644 index d7940c074..000000000 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/AuthUserServiceDto.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.woowacourse.pickgit.user.application.dto; - -public class AuthUserServiceDto { - - private String githubName; - - public AuthUserServiceDto(String githubName) { - this.githubName = githubName; - } - - public String getGithubName() { - return githubName; - } -} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/FollowServiceDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/FollowServiceDto.java deleted file mode 100644 index 3b6a9a6c8..000000000 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/FollowServiceDto.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.woowacourse.pickgit.user.application.dto; - -public class FollowServiceDto { - - private boolean isFollowing; - private int followerCount; - - public FollowServiceDto(int followerCount, boolean isFollowing) { - this.followerCount = followerCount; - this.isFollowing = isFollowing; - } - - public int getFollowerCount() { - return followerCount; - } - - public boolean isFollowing() { - return isFollowing; - } -} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/UserProfileServiceDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/UserProfileServiceDto.java deleted file mode 100644 index 95d3da549..000000000 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/UserProfileServiceDto.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.woowacourse.pickgit.user.application.dto; - -public class UserProfileServiceDto { - - private final String name; - private final String image; - private final String description; - - private final int followerCount; - private final int followingCount; - private final int postCount; - - private final String githubUrl; - private final String company; - private final String location; - private final String website; - private final String twitter; - - private final Boolean following; - - public UserProfileServiceDto(String name, String image, String description, - int followerCount, int followingCount, int postCount, String githubUrl, String company, - String location, String website, String twitter, Boolean following) { - this.name = name; - this.image = image; - this.description = description; - this.followerCount = followerCount; - this.followingCount = followingCount; - this.postCount = postCount; - this.githubUrl = githubUrl; - this.company = company; - this.location = location; - this.website = website; - this.twitter = twitter; - this.following = following; - } - - public String getName() { - return name; - } - - public String getImage() { - return image; - } - - public String getDescription() { - return description; - } - - public int getFollowerCount() { - return followerCount; - } - - public int getFollowingCount() { - return followingCount; - } - - public int getPostCount() { - return postCount; - } - - public String getGithubUrl() { - return githubUrl; - } - - public String getCompany() { - return company; - } - - public String getLocation() { - return location; - } - - public String getWebsite() { - return website; - } - - public String getTwitter() { - return twitter; - } - - public Boolean getFollowing() { - return following; - } -} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/request/AuthUserRequestDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/request/AuthUserRequestDto.java new file mode 100644 index 000000000..70b33b1f2 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/request/AuthUserRequestDto.java @@ -0,0 +1,28 @@ +package com.woowacourse.pickgit.user.application.dto.request; + +import com.woowacourse.pickgit.authentication.domain.user.AppUser; +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class AuthUserRequestDto { + + private String username; + private boolean isGuest; + + private AuthUserRequestDto() { + } + + private AuthUserRequestDto(String username, boolean isGuest) { + this.username = username; + this.isGuest = isGuest; + } + + public static AuthUserRequestDto from(AppUser appUser) { + if (appUser.isGuest()) { + return new AuthUserRequestDto(null, true); + } + return new AuthUserRequestDto(appUser.getUsername(), false); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/request/ProfileEditRequestDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/request/ProfileEditRequestDto.java new file mode 100644 index 000000000..3138da00f --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/request/ProfileEditRequestDto.java @@ -0,0 +1,31 @@ +package com.woowacourse.pickgit.user.application.dto.request; + +import lombok.Builder; +import org.springframework.web.multipart.MultipartFile; + +@Builder +public class ProfileEditRequestDto { + + private MultipartFile image; + private String decription; + + private ProfileEditRequestDto() { + } + + public ProfileEditRequestDto(MultipartFile image, String decription) { + this.image = image; + this.decription = decription; + } + + public MultipartFile getImage() { + return image; + } + + public String getImageOriginalFileName() { + return image.getOriginalFilename(); + } + + public String getDecription() { + return decription; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/request/UserSearchRequestDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/request/UserSearchRequestDto.java new file mode 100644 index 000000000..79fb6618c --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/request/UserSearchRequestDto.java @@ -0,0 +1,32 @@ +package com.woowacourse.pickgit.user.application.dto.request; + +import lombok.Builder; + +@Builder +public class UserSearchRequestDto { + + private String keyword; + private Long page; + private Long limit; + + private UserSearchRequestDto() { + } + + public UserSearchRequestDto(String keyword, Long page, Long limit) { + this.keyword = keyword; + this.page = page; + this.limit = limit; + } + + public String getKeyword() { + return keyword; + } + + public int getPage() { + return Math.toIntExact(page); + } + + public int getLimit() { + return Math.toIntExact(limit); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/response/ContributionResponseDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/response/ContributionResponseDto.java new file mode 100644 index 000000000..6766a3842 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/response/ContributionResponseDto.java @@ -0,0 +1,50 @@ +package com.woowacourse.pickgit.user.application.dto.response; + +import lombok.Builder; + +@Builder +public class ContributionResponseDto { + + private int starsCount; + private int commitsCount; + private int prsCount; + private int issuesCount; + private int reposCount; + + private ContributionResponseDto() { + } + + public ContributionResponseDto( + int starsCount, + int commitsCount, + int prsCount, + int issuesCount, + int reposCount + ) { + this.starsCount = starsCount; + this.commitsCount = commitsCount; + this.prsCount = prsCount; + this.issuesCount = issuesCount; + this.reposCount = reposCount; + } + + public int getStarsCount() { + return starsCount; + } + + public int getCommitsCount() { + return commitsCount; + } + + public int getPrsCount() { + return prsCount; + } + + public int getIssuesCount() { + return issuesCount; + } + + public int getReposCount() { + return reposCount; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/response/FollowResponseDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/response/FollowResponseDto.java new file mode 100644 index 000000000..d62265b7d --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/response/FollowResponseDto.java @@ -0,0 +1,20 @@ +package com.woowacourse.pickgit.user.application.dto.response; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class FollowResponseDto { + + private int followerCount; + private boolean isFollowing; + + private FollowResponseDto() { + } + + public FollowResponseDto(int followerCount, boolean isFollowing) { + this.followerCount = followerCount; + this.isFollowing = isFollowing; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/response/ProfileEditResponseDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/response/ProfileEditResponseDto.java new file mode 100644 index 000000000..ffc79e466 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/response/ProfileEditResponseDto.java @@ -0,0 +1,26 @@ +package com.woowacourse.pickgit.user.application.dto.response; + +import lombok.Builder; + +@Builder +public class ProfileEditResponseDto { + + private String imageUrl; + private String description; + + public ProfileEditResponseDto() { + } + + public ProfileEditResponseDto(String imageUrl, String description) { + this.imageUrl = imageUrl; + this.description = description; + } + + public String getImageUrl() { + return imageUrl; + } + + public String getDescription() { + return description; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/response/UserProfileResponseDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/response/UserProfileResponseDto.java new file mode 100644 index 000000000..7bfad37de --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/response/UserProfileResponseDto.java @@ -0,0 +1,53 @@ +package com.woowacourse.pickgit.user.application.dto.response; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class UserProfileResponseDto { + + private String name; + private String imageUrl; + private String description; + private int followerCount; + private int followingCount; + private int postCount; + private String githubUrl; + private String company; + private String location; + private String website; + private String twitter; + private Boolean following; + + private UserProfileResponseDto() { + } + + public UserProfileResponseDto( + String name, + String imageUrl, + String description, + int followerCount, + int followingCount, + int postCount, + String githubUrl, + String company, + String location, + String website, + String twitter, + Boolean following + ) { + this.name = name; + this.imageUrl = imageUrl; + this.description = description; + this.followerCount = followerCount; + this.followingCount = followingCount; + this.postCount = postCount; + this.githubUrl = githubUrl; + this.company = company; + this.location = location; + this.website = website; + this.twitter = twitter; + this.following = following; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/response/UserSearchResponseDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/response/UserSearchResponseDto.java new file mode 100644 index 000000000..738644a05 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/application/dto/response/UserSearchResponseDto.java @@ -0,0 +1,29 @@ +package com.woowacourse.pickgit.user.application.dto.response; + +public class UserSearchResponseDto { + + private String imageUrl; + private String username; + private Boolean following; + + private UserSearchResponseDto() { + } + + public UserSearchResponseDto(String imageUrl, String username, Boolean following) { + this.imageUrl = imageUrl; + this.username = username; + this.following = following; + } + + public String getImageUrl() { + return imageUrl; + } + + public String getUsername() { + return username; + } + + public Boolean getFollowing() { + return following; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/Contribution.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/Contribution.java new file mode 100644 index 000000000..1c167fec2 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/Contribution.java @@ -0,0 +1,47 @@ +package com.woowacourse.pickgit.user.domain; + +public class Contribution { + + private int starsCount; + private int commitsCount; + private int prsCount; + private int issuesCount; + private int reposCount; + + private Contribution() { + } + + public Contribution( + int starsCount, + int commitsCount, + int prsCount, + int issuesCount, + int reposCount) + { + this.starsCount = starsCount; + this.commitsCount = commitsCount; + this.prsCount = prsCount; + this.issuesCount = issuesCount; + this.reposCount = reposCount; + } + + public int getStarsCount() { + return starsCount; + } + + public int getCommitsCount() { + return commitsCount; + } + + public int getPrsCount() { + return prsCount; + } + + public int getIssuesCount() { + return issuesCount; + } + + public int getReposCount() { + return reposCount; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/PlatformContributionCalculator.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/PlatformContributionCalculator.java new file mode 100644 index 000000000..6e480b6f8 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/PlatformContributionCalculator.java @@ -0,0 +1,6 @@ +package com.woowacourse.pickgit.user.domain; + +public interface PlatformContributionCalculator { + + Contribution calculate(String username); +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/PlatformContributionExtractor.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/PlatformContributionExtractor.java new file mode 100644 index 000000000..abe79700a --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/PlatformContributionExtractor.java @@ -0,0 +1,13 @@ +package com.woowacourse.pickgit.user.domain; + +import com.woowacourse.pickgit.user.infrastructure.dto.CountDto; +import com.woowacourse.pickgit.user.infrastructure.dto.ItemDto; +import com.woowacourse.pickgit.user.infrastructure.dto.StarsDto; +import java.util.List; + +public interface PlatformContributionExtractor { + + ItemDto extractStars(String username); + + CountDto extractCount(String restUrl, String username); +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/User.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/User.java index 45f04e8b4..66b2490b4 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/User.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/User.java @@ -2,14 +2,14 @@ import com.woowacourse.pickgit.post.domain.Post; import com.woowacourse.pickgit.post.domain.Posts; -import com.woowacourse.pickgit.user.domain.follow.Follow; import com.woowacourse.pickgit.post.domain.comment.Comment; +import com.woowacourse.pickgit.user.domain.follow.Follow; import com.woowacourse.pickgit.user.domain.follow.Followers; import com.woowacourse.pickgit.user.domain.follow.Followings; import com.woowacourse.pickgit.user.domain.profile.BasicProfile; import com.woowacourse.pickgit.user.domain.profile.GithubProfile; -import com.woowacourse.pickgit.exception.user.DuplicateFollowException; -import com.woowacourse.pickgit.exception.user.InvalidFollowException; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; import javax.persistence.Embedded; import javax.persistence.Entity; @@ -30,56 +30,64 @@ public class User { private GithubProfile githubProfile; @Embedded - private Followers followers = new Followers(); + private Followers followers; @Embedded - private Followings followings = new Followings(); + private Followings followings; @Embedded - private Posts posts = new Posts(); + private Posts posts; protected User() { } - public User(Long id, BasicProfile basicProfile, - GithubProfile githubProfile) { - this.id = id; - this.basicProfile = basicProfile; - this.githubProfile = githubProfile; + public User(BasicProfile basicProfile, GithubProfile githubProfile) { + this(null, basicProfile, githubProfile); } - public User(BasicProfile basicProfile, - GithubProfile githubProfile) { + public User(Long id, BasicProfile basicProfile, GithubProfile githubProfile) { + this( + id, + basicProfile, + githubProfile, + new Followers(new ArrayList<>()), + new Followings(new ArrayList<>()), + new Posts(new ArrayList<>()) + ); + } + + public User( + Long id, + BasicProfile basicProfile, + GithubProfile githubProfile, + Followers followers, + Followings followings, + Posts posts + ) { + this.id = id; this.basicProfile = basicProfile; this.githubProfile = githubProfile; + this.followers = followers; + this.followings = followings; + this.posts = posts; } - public void changeBasicProfile(BasicProfile basicProfile) { - this.basicProfile = basicProfile; + public void updateDescription(String description) { + this.basicProfile.setDescription(description); } - public void changeGithubProfile(GithubProfile githubProfile) { - this.githubProfile = githubProfile; + public void updateProfileImage(String imageUrl) { + this.basicProfile.setImage(imageUrl); } public void follow(User target) { Follow follow = new Follow(this, target); - - if (this.followings.existFollow(follow)) { - throw new DuplicateFollowException(); - } this.followings.add(follow); target.followers.add(follow); } - public void unfollow(User target) { Follow follow = new Follow(this, target); - - if (!this.followings.existFollow(follow)) { - throw new InvalidFollowException(); - } - this.followings.remove(follow); target.followers.remove(follow); } @@ -88,28 +96,33 @@ public Boolean isFollowing(User targetUser) { return this.followings.isFollowing(targetUser); } - public int getFollowerCount() { - return followers.count(); + public void changeGithubProfile(GithubProfile githubProfile) { + this.githubProfile = githubProfile; } - public int getFollowingCount() { - return followings.count(); + public boolean isSameAs(User user) { + return this.id.equals(user.getId()); } - public int getPostCount() { - return posts.getCounts(); + public void delete(Post post) { + List posts = this.posts.getPosts(); + posts.remove(post); } public Long getId() { - return this.id; + return id; + } + + public int getFollowerCount() { + return followers.count(); } - public BasicProfile getBasicProfile() { - return basicProfile; + public int getFollowingCount() { + return followings.count(); } - public GithubProfile getGithubProfile() { - return githubProfile; + public int getPostCount() { + return posts.count(); } public String getName() { @@ -144,12 +157,6 @@ public String getTwitter() { return githubProfile.getTwitter(); } - public void addComment(Post post, Comment comment) { - comment.toPost(post) - .writeBy(this); - post.addComment(comment); - } - @Override public boolean equals(Object o) { if (this == o) { @@ -159,11 +166,11 @@ public boolean equals(Object o) { return false; } User user = (User) o; - return Objects.equals(id, user.id); + return Objects.equals(getId(), user.getId()); } @Override public int hashCode() { - return Objects.hash(id); + return Objects.hash(getId()); } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/UserRepository.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/UserRepository.java index ec0a84138..39bb0e714 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/UserRepository.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/UserRepository.java @@ -1,10 +1,17 @@ package com.woowacourse.pickgit.user.domain; +import java.util.List; import java.util.Optional; +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 UserRepository extends JpaRepository { Optional findByBasicProfile_Name(String name); + + @Query("select u from User u where u.basicProfile.name like %:username%") + List searchByUsernameLike(@Param("username") String username, Pageable pageable); } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/dto/ContributionDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/dto/ContributionDto.java new file mode 100644 index 000000000..7b001ce4d --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/dto/ContributionDto.java @@ -0,0 +1,50 @@ +package com.woowacourse.pickgit.user.domain.dto; + +import lombok.Builder; + +@Builder +public class ContributionDto { + + private int starsCount; + private int commitsCount; + private int prsCount; + private int issuesCount; + private int reposCount; + + private ContributionDto() { + } + + public ContributionDto( + int starsCount, + int commitsCount, + int prsCount, + int issuesCount, + int reposCount) + { + this.starsCount = starsCount; + this.commitsCount = commitsCount; + this.prsCount = prsCount; + this.issuesCount = issuesCount; + this.reposCount = reposCount; + } + + public int getStarsCount() { + return starsCount; + } + + public int getCommitsCount() { + return commitsCount; + } + + public int getPrsCount() { + return prsCount; + } + + public int getIssuesCount() { + return issuesCount; + } + + public int getReposCount() { + return reposCount; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/follow/Follow.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/follow/Follow.java index 7b80b30e9..3232771b3 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/follow/Follow.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/follow/Follow.java @@ -1,5 +1,6 @@ package com.woowacourse.pickgit.user.domain.follow; +import com.woowacourse.pickgit.exception.user.SameSourceTargetUserException; import com.woowacourse.pickgit.user.domain.User; import java.util.Objects; import javax.persistence.Entity; @@ -35,10 +36,21 @@ protected Follow() { } public Follow(User source, User target) { + validateDifferentSourceTarget(source, target); this.source = source; this.target = target; } + private void validateDifferentSourceTarget(User source, User target) { + if (source.equals(target)) { + throw new SameSourceTargetUserException(); + } + } + + public boolean isFollowing(User targetUser) { + return this.target.equals(targetUser); + } + public Long getId() { return id; } @@ -60,12 +72,12 @@ public boolean equals(Object o) { return false; } Follow follow = (Follow) o; - return Objects.equals(source, follow.source) && Objects - .equals(target, follow.target); + return Objects.equals(getSource(), follow.getSource()) + && Objects.equals(getTarget(), follow.getTarget()); } @Override public int hashCode() { - return Objects.hash(source, target); + return Objects.hash(getSource(), getTarget()); } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/follow/Followers.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/follow/Followers.java index bacceca4c..ad2150b4d 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/follow/Followers.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/follow/Followers.java @@ -1,6 +1,7 @@ package com.woowacourse.pickgit.user.domain.follow; -import java.util.ArrayList; +import com.woowacourse.pickgit.exception.user.DuplicateFollowException; +import com.woowacourse.pickgit.exception.user.InvalidFollowException; import java.util.List; import javax.persistence.CascadeType; import javax.persistence.Embeddable; @@ -10,25 +11,36 @@ @Embeddable public class Followers { - @OneToMany(mappedBy = "target", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST, orphanRemoval = true) - private List followers = new ArrayList<>(); + @OneToMany( + mappedBy = "target", + fetch = FetchType.LAZY, + cascade = CascadeType.PERSIST, + orphanRemoval = true + ) + private List followers; - public Followers() { + protected Followers() { } - public int count() { - return followers.size(); + public Followers(List followers) { + this.followers = followers; } public void add(Follow follow) { + if (this.followers.contains(follow)) { + throw new DuplicateFollowException(); + } followers.add(follow); } public void remove(Follow follow) { + if (!this.followers.contains(follow)) { + throw new InvalidFollowException(); + } followers.remove(follow); } - public List getFollowers() { - return followers; + public int count() { + return followers.size(); } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/follow/Followings.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/follow/Followings.java index b920ccdf6..e2fc099da 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/follow/Followings.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/follow/Followings.java @@ -1,7 +1,8 @@ package com.woowacourse.pickgit.user.domain.follow; +import com.woowacourse.pickgit.exception.user.DuplicateFollowException; +import com.woowacourse.pickgit.exception.user.InvalidFollowException; import com.woowacourse.pickgit.user.domain.User; -import java.util.ArrayList; import java.util.List; import javax.persistence.CascadeType; import javax.persistence.Embeddable; @@ -11,32 +12,45 @@ @Embeddable public class Followings { - @OneToMany(mappedBy = "source", fetch = FetchType.LAZY, cascade = CascadeType.PERSIST, orphanRemoval = true) - private List followings = new ArrayList<>(); + @OneToMany( + mappedBy = "source", + fetch = FetchType.LAZY, + cascade = CascadeType.PERSIST, + orphanRemoval = true + ) + private List followings; - public Followings() { + protected Followings() { } - public boolean existFollow(Follow follow) { - return this.followings.contains(follow); - } - - public int count() { - return followings.size(); + public Followings(List followings) { + this.followings = followings; } public void add(Follow follow) { + if (this.followings.contains(follow)) { + throw new DuplicateFollowException(); + } followings.add(follow); } public void remove(Follow follow) { + if (!this.followings.contains(follow)) { + throw new InvalidFollowException(); + } followings.remove(follow); } public Boolean isFollowing(User targetUser) { return followings.stream() - .filter(follow -> follow.getTarget().equals(targetUser)) - .findAny().isPresent(); + .anyMatch(follow -> follow.isFollowing(targetUser)); } -} + public boolean contains(Follow follow) { + return this.followings.contains(follow); + } + + public int count() { + return followings.size(); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/profile/BasicProfile.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/profile/BasicProfile.java index a51c606a6..190e8f4cf 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/profile/BasicProfile.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/profile/BasicProfile.java @@ -26,18 +26,10 @@ public String getName() { return name; } - public void setName(String name) { - this.name = name; - } - public String getImage() { return image; } - public void setImage(String image) { - this.image = image; - } - public String getDescription() { return description; } @@ -45,4 +37,8 @@ public String getDescription() { public void setDescription(String description) { this.description = description; } + + public void setImage(String image) { + this.image = image; + } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/profile/GithubProfile.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/profile/GithubProfile.java index 753a0f977..2ae46747d 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/profile/GithubProfile.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/profile/GithubProfile.java @@ -20,8 +20,13 @@ public class GithubProfile { protected GithubProfile() { } - public GithubProfile(String githubUrl, String company, String location, String website, - String twitter) { + public GithubProfile( + String githubUrl, + String company, + String location, + String website, + String twitter + ) { this.githubUrl = githubUrl; this.company = company; this.location = location; @@ -40,6 +45,7 @@ public String getCompany() { public String getLocation() { return location; } + public String getWebsite() { return website; } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/calculator/GithubContributionCalculator.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/calculator/GithubContributionCalculator.java new file mode 100644 index 000000000..3fd216ad1 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/calculator/GithubContributionCalculator.java @@ -0,0 +1,69 @@ +package com.woowacourse.pickgit.user.infrastructure.calculator; + +import com.woowacourse.pickgit.user.domain.Contribution; +import com.woowacourse.pickgit.user.domain.PlatformContributionCalculator; +import com.woowacourse.pickgit.user.domain.PlatformContributionExtractor; +import com.woowacourse.pickgit.user.infrastructure.dto.CountDto; +import com.woowacourse.pickgit.user.infrastructure.dto.ItemDto; +import com.woowacourse.pickgit.user.infrastructure.dto.StarsDto; +import org.springframework.stereotype.Component; + +@Component +public class GithubContributionCalculator implements PlatformContributionCalculator { + + private final PlatformContributionExtractor platformContributionExtractor; + + public GithubContributionCalculator( + PlatformContributionExtractor platformContributionExtractor + ) { + this.platformContributionExtractor = platformContributionExtractor; + } + + @Override + public Contribution calculate(String username) { + return new Contribution( + calculateStars(username), + calculateCommits(username), + calculatePRs(username), + calculateIssues(username), + calculateRepos(username) + ); + } + + private int calculateStars(String username) { + ItemDto stars = platformContributionExtractor.extractStars(username); + + return stars.getItems() + .stream() + .mapToInt(StarsDto::getStars) + .sum(); + } + + private int calculateCommits(String username) { + CountDto count = platformContributionExtractor + .extractCount("/commits?q=committer:%s", username); + + return count.getCount(); + } + + private int calculatePRs(String username) { + CountDto count = platformContributionExtractor + .extractCount("/issues?q=author:%s type:pr", username); + + return count.getCount(); + } + + private int calculateIssues(String username) { + CountDto count = platformContributionExtractor + .extractCount("/issues?q=author:%s type:issue", username); + + return count.getCount(); + } + + private int calculateRepos(String username) { + CountDto count = platformContributionExtractor + .extractCount("/repositories?q=user:%s is:public", username); + + return count.getCount(); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/dto/CountDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/dto/CountDto.java new file mode 100644 index 000000000..25cb0ff26 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/dto/CountDto.java @@ -0,0 +1,22 @@ +package com.woowacourse.pickgit.user.infrastructure.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +@Builder +public class CountDto { + + @JsonProperty("total_count") + private int count; + + private CountDto() { + } + + public CountDto(int count) { + this.count = count; + } + + public int getCount() { + return count; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/dto/ItemDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/dto/ItemDto.java new file mode 100644 index 000000000..51939080b --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/dto/ItemDto.java @@ -0,0 +1,19 @@ +package com.woowacourse.pickgit.user.infrastructure.dto; + +import java.util.List; + +public class ItemDto { + + private List items; + + private ItemDto() { + } + + public ItemDto(List items) { + this.items = items; + } + + public List getItems() { + return items; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/dto/StarsDto.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/dto/StarsDto.java new file mode 100644 index 000000000..2f03361ee --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/dto/StarsDto.java @@ -0,0 +1,22 @@ +package com.woowacourse.pickgit.user.infrastructure.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +@Builder +public class StarsDto { + + @JsonProperty("stargazers_count") + private int stars; + + private StarsDto() { + } + + public StarsDto(int stars) { + this.stars = stars; + } + + public int getStars() { + return stars; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/extractor/GithubContributionExtractor.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/extractor/GithubContributionExtractor.java new file mode 100644 index 000000000..2ec8b4891 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/extractor/GithubContributionExtractor.java @@ -0,0 +1,84 @@ +package com.woowacourse.pickgit.user.infrastructure.extractor; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.woowacourse.pickgit.exception.user.ContributionParseException; +import com.woowacourse.pickgit.user.domain.PlatformContributionExtractor; +import com.woowacourse.pickgit.user.infrastructure.dto.CountDto; +import com.woowacourse.pickgit.user.infrastructure.dto.ItemDto; +import com.woowacourse.pickgit.user.infrastructure.dto.StarsDto; +import com.woowacourse.pickgit.user.infrastructure.requester.PlatformContributionApiRequester; +import java.util.List; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +@Component +public class GithubContributionExtractor implements PlatformContributionExtractor { + + private final ObjectMapper objectMapper; + private final PlatformContributionApiRequester platformContributionApiRequester; + private final String apiUrlFormatForStar; + private final String apiUrlFormatForCount; + + public GithubContributionExtractor( + ObjectMapper objectMapper, + PlatformContributionApiRequester platformContributionApiRequester, + @Value("${github.contribution.star-url}") String apiUrlFormatForStar, + @Value("${github.contribution.count-url}") String apiUrlFormatForCount + ) { + this.objectMapper = objectMapper; + this.platformContributionApiRequester = platformContributionApiRequester; + this.apiUrlFormatForStar = apiUrlFormatForStar; + this.apiUrlFormatForCount = apiUrlFormatForCount; + } + + @Override + public ItemDto extractStars(String username) { + String apiUrl = generateUrl(username); + String response = platformContributionApiRequester.request(apiUrl); + + return parseToStars(response); + } + + private String generateUrl(String username) { + return String.format(apiUrlFormatForStar, username); + } + + private ItemDto parseToStars(String response) { + try { + return objectMapper.readValue(response, new TypeReference<>() {}); + } catch (JsonProcessingException e) { + throw new ContributionParseException( + "V0001", + HttpStatus.INTERNAL_SERVER_ERROR, + "활동 통계를 조회할 수 없습니다." + ); + } + } + + @Override + public CountDto extractCount(String restUrl, String username) { + String apiUrl = generateUrl(restUrl, username); + String response = platformContributionApiRequester.request(apiUrl); + + return parseToCount(response); + } + + private String generateUrl(String restUrl, String username) { + return apiUrlFormatForCount + String.format(restUrl, username); + } + + private CountDto parseToCount(String response) { + try { + return objectMapper.readValue(response, new TypeReference<>() {}); + } catch (JsonProcessingException e) { + throw new ContributionParseException( + "V0001", + HttpStatus.INTERNAL_SERVER_ERROR, + "활동 통계를 조회할 수 없습니다." + ); + } + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/requester/GithubContributionApiRequester.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/requester/GithubContributionApiRequester.java new file mode 100644 index 000000000..359780228 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/requester/GithubContributionApiRequester.java @@ -0,0 +1,33 @@ +package com.woowacourse.pickgit.user.infrastructure.requester; + +import com.woowacourse.pickgit.exception.platform.PlatformHttpErrorException; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpHeaders; +import org.springframework.http.RequestEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +@Component +@Profile("!test") +public class GithubContributionApiRequester implements PlatformContributionApiRequester { + + @Override + public String request(String url) { + try { + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.set("Accept", "application/vnd.github.cloak-preview"); + + RequestEntity requestEntity = RequestEntity + .get(url) + .headers(httpHeaders) + .build(); + + return new RestTemplate() + .exchange(requestEntity, String.class) + .getBody(); + } catch (HttpClientErrorException e) { + throw new PlatformHttpErrorException(e.getMessage()); + } + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/requester/PlatformContributionApiRequester.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/requester/PlatformContributionApiRequester.java new file mode 100644 index 000000000..3748126b8 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/infrastructure/requester/PlatformContributionApiRequester.java @@ -0,0 +1,6 @@ +package com.woowacourse.pickgit.user.infrastructure.requester; + +public interface PlatformContributionApiRequester { + + String request(String url); +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/UserController.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/UserController.java index b1327db2b..3fde6d7dd 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/UserController.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/UserController.java @@ -3,15 +3,22 @@ import com.woowacourse.pickgit.authentication.domain.Authenticated; import com.woowacourse.pickgit.authentication.domain.user.AppUser; import com.woowacourse.pickgit.user.application.UserService; -import com.woowacourse.pickgit.user.application.dto.AuthUserServiceDto; -import com.woowacourse.pickgit.user.application.dto.FollowServiceDto; -import com.woowacourse.pickgit.user.application.dto.UserProfileServiceDto; -import com.woowacourse.pickgit.user.presentation.dto.FollowResponse; -import com.woowacourse.pickgit.user.presentation.dto.UserProfileResponse; +import com.woowacourse.pickgit.user.application.dto.request.AuthUserRequestDto; +import com.woowacourse.pickgit.user.application.dto.request.ProfileEditRequestDto; +import com.woowacourse.pickgit.user.application.dto.response.ContributionResponseDto; +import com.woowacourse.pickgit.user.application.dto.response.FollowResponseDto; +import com.woowacourse.pickgit.user.application.dto.response.ProfileEditResponseDto; +import com.woowacourse.pickgit.user.application.dto.response.UserProfileResponseDto; +import com.woowacourse.pickgit.user.presentation.dto.request.ProfileEditRequest; +import com.woowacourse.pickgit.user.presentation.dto.response.ContributionResponse; +import com.woowacourse.pickgit.user.presentation.dto.response.FollowResponse; +import com.woowacourse.pickgit.user.presentation.dto.response.ProfileEditResponse; +import com.woowacourse.pickgit.user.presentation.dto.response.UserProfileResponse; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -30,21 +37,43 @@ public UserController(UserService userService) { @GetMapping("/me") public ResponseEntity getAuthenticatedUserProfile( - @Authenticated AppUser appUser) { - UserProfileServiceDto userProfileServiceDto = userService.getMyUserProfile( - new AuthUserServiceDto(appUser.getUsername()) - ); + @Authenticated AppUser appUser + ) { + AuthUserRequestDto authUserRequestDto = AuthUserRequestDto.from(appUser); + UserProfileResponseDto responseDto = userService.getMyUserProfile(authUserRequestDto); - return ResponseEntity.ok(getUserProfileResponseDto(userProfileServiceDto)); + return ResponseEntity.ok(createUserProfileResponse(responseDto)); } @GetMapping("/{username}") public ResponseEntity getUserProfile( @Authenticated AppUser appUser, - @PathVariable String username) { - UserProfileServiceDto userProfileServiceDto = userService.getUserProfile(appUser, username); + @PathVariable String username + ) { + AuthUserRequestDto authUserRequestDto = AuthUserRequestDto.from(appUser); + UserProfileResponseDto responseDto = userService + .getUserProfile(authUserRequestDto, username); - return ResponseEntity.ok(getUserProfileResponseDto(userProfileServiceDto)); + return ResponseEntity.ok(createUserProfileResponse(responseDto)); + } + + private UserProfileResponse createUserProfileResponse( + UserProfileResponseDto userProfileResponseDto + ) { + return UserProfileResponse.builder() + .name(userProfileResponseDto.getName()) + .imageUrl(userProfileResponseDto.getImageUrl()) + .description(userProfileResponseDto.getDescription()) + .followerCount(userProfileResponseDto.getFollowerCount()) + .followingCount(userProfileResponseDto.getFollowingCount()) + .postCount(userProfileResponseDto.getPostCount()) + .githubUrl(userProfileResponseDto.getGithubUrl()) + .company(userProfileResponseDto.getCompany()) + .location(userProfileResponseDto.getLocation()) + .website(userProfileResponseDto.getWebsite()) + .twitter(userProfileResponseDto.getTwitter()) + .following(userProfileResponseDto.getFollowing()) + .build(); } @PostMapping("/{username}/followings") @@ -52,12 +81,11 @@ public ResponseEntity followUser( @Authenticated AppUser appUser, @PathVariable String username ) { - AuthUserServiceDto authUserServiceDto = - new AuthUserServiceDto(appUser.getUsername()); + AuthUserRequestDto authUserRequestDto = AuthUserRequestDto.from(appUser); + FollowResponseDto followResponseDto = + userService.followUser(authUserRequestDto, username); - FollowServiceDto followServiceDto = userService.followUser(authUserServiceDto, username); - - return ResponseEntity.ok(createFollowResponseDto(followServiceDto)); + return ResponseEntity.ok(createFollowResponse(followResponseDto)); } @DeleteMapping("/{username}/followings") @@ -65,29 +93,56 @@ public ResponseEntity unfollowUser( @Authenticated AppUser appUser, @PathVariable String username ) { - AuthUserServiceDto authUserServiceDto = - new AuthUserServiceDto(appUser.getUsername()); + AuthUserRequestDto authUserRequestDto = AuthUserRequestDto.from(appUser); + FollowResponseDto followResponseDto = + userService.unfollowUser(authUserRequestDto, username); - FollowServiceDto followServiceDto = userService.unfollowUser(authUserServiceDto, username); + return ResponseEntity.ok(createFollowResponse(followResponseDto)); + } - return ResponseEntity.ok(createFollowResponseDto(followServiceDto)); + private FollowResponse createFollowResponse(FollowResponseDto followResponseDto) { + return FollowResponse.builder() + .followerCount(followResponseDto.getFollowerCount()) + .following(followResponseDto.isFollowing()) + .build(); } - private UserProfileResponse getUserProfileResponseDto( - UserProfileServiceDto userProfileServiceDto) { - return new UserProfileResponse( - userProfileServiceDto.getName(), userProfileServiceDto.getImage(), - userProfileServiceDto.getDescription(), userProfileServiceDto.getFollowerCount(), - userProfileServiceDto.getFollowingCount(), userProfileServiceDto.getPostCount(), - userProfileServiceDto.getGithubUrl(), userProfileServiceDto.getCompany(), - userProfileServiceDto.getLocation(), userProfileServiceDto.getWebsite(), - userProfileServiceDto.getTwitter(), userProfileServiceDto.getFollowing() + @PostMapping("/me") + public ResponseEntity editProfile( + @Authenticated AppUser appUser, + @ModelAttribute ProfileEditRequest request + ) { + AuthUserRequestDto authUserRequestDto = AuthUserRequestDto.from(appUser); + ProfileEditRequestDto profileEditRequestDto = ProfileEditRequestDto + .builder() + .image(request.getImage()) + .decription(request.getDescription()) + .build(); + ProfileEditResponseDto responseDto = + userService.editProfile(authUserRequestDto, profileEditRequestDto); + + return ResponseEntity.ok( + new ProfileEditResponse( + responseDto.getImageUrl(), + responseDto.getDescription() + ) ); } - private FollowResponse createFollowResponseDto(FollowServiceDto followServiceDto) { - return new FollowResponse( - followServiceDto.getFollowerCount(), - followServiceDto.isFollowing()); + @GetMapping("/{username}/contributions") + public ResponseEntity getContributions(@PathVariable String username) { + ContributionResponseDto responseDto = userService.calculateContributions(username); + + return ResponseEntity.ok(createContributionResponse(responseDto)); + } + + private ContributionResponse createContributionResponse(ContributionResponseDto responseDto) { + return ContributionResponse.builder() + .starsCount(responseDto.getStarsCount()) + .commitsCount(responseDto.getCommitsCount()) + .prsCount(responseDto.getPrsCount()) + .issuesCount(responseDto.getIssuesCount()) + .reposCount(responseDto.getReposCount()) + .build(); } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/UserSearchController.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/UserSearchController.java new file mode 100644 index 000000000..ab613ea28 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/UserSearchController.java @@ -0,0 +1,44 @@ +package com.woowacourse.pickgit.user.presentation; + +import com.woowacourse.pickgit.authentication.domain.Authenticated; +import com.woowacourse.pickgit.authentication.domain.user.AppUser; +import com.woowacourse.pickgit.user.application.UserService; +import com.woowacourse.pickgit.user.application.dto.request.AuthUserRequestDto; +import com.woowacourse.pickgit.user.application.dto.request.UserSearchRequestDto; +import com.woowacourse.pickgit.user.application.dto.response.UserSearchResponseDto; +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +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") +@CrossOrigin(value = "*") +public class UserSearchController { + + private final UserService userService; + + public UserSearchController(UserService userService) { + this.userService = userService; + } + + @GetMapping("/search/users") + public ResponseEntity> searchUser( + @Authenticated AppUser appUser, + @RequestParam String keyword, + @RequestParam Long page, + @RequestParam Long limit + ) { + AuthUserRequestDto authUserRequestDto = AuthUserRequestDto.from(appUser); + UserSearchRequestDto userSearchRequestDto = UserSearchRequestDto.builder() + .keyword(keyword) + .page(page) + .limit(limit) + .build(); + return ResponseEntity + .ok(userService.searchUser(authUserRequestDto, userSearchRequestDto)); + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/AuthUserRequest.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/AuthUserRequest.java deleted file mode 100644 index 581b07081..000000000 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/AuthUserRequest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.woowacourse.pickgit.user.presentation.dto; - -public class AuthUserRequest { - - private String githubName; - - private AuthUserRequest() { - } - - public AuthUserRequest(String githubName) { - this.githubName = githubName; - } - - public String getGithubName() { - return githubName; - } -} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/UserProfileResponse.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/UserProfileResponse.java deleted file mode 100644 index f0c8f466c..000000000 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/UserProfileResponse.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.woowacourse.pickgit.user.presentation.dto; - -public class UserProfileResponse { - - private String name; - private String image; - private String description; - - private int followerCount; - private int followingCount; - private int postCount; - - private String githubUrl; - private String company; - private String location; - private String website; - private String twitter; - - private Boolean following; - - private UserProfileResponse() { - } - - public UserProfileResponse(String name, String image, String description, int followerCount, - int followingCount, int postCount, String githubUrl, String company, String location, - String website, String twitter, Boolean following) { - this.name = name; - this.image = image; - this.description = description; - this.followerCount = followerCount; - this.followingCount = followingCount; - this.postCount = postCount; - this.githubUrl = githubUrl; - this.company = company; - this.location = location; - this.website = website; - this.twitter = twitter; - this.following = following; - } - - public String getName() { - return name; - } - - public String getImage() { - return image; - } - - public String getDescription() { - return description; - } - - public int getFollowerCount() { - return followerCount; - } - - public int getFollowingCount() { - return followingCount; - } - - public int getPostCount() { - return postCount; - } - - public String getGithubUrl() { - return githubUrl; - } - - public String getCompany() { - return company; - } - - public String getLocation() { - return location; - } - - public String getWebsite() { - return website; - } - - public String getTwitter() { - return twitter; - } - - public Boolean getFollowing() { - return following; - } -} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/request/ProfileEditRequest.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/request/ProfileEditRequest.java new file mode 100644 index 000000000..10e1a80c1 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/request/ProfileEditRequest.java @@ -0,0 +1,28 @@ +package com.woowacourse.pickgit.user.presentation.dto.request; + +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; + +public class ProfileEditRequest { + + private MultipartFile image; + private String description; + + private ProfileEditRequest() { + } + + public ProfileEditRequest( + @RequestParam(name = "image", required = false) MultipartFile image, + @RequestParam(name = "description") String description) { + this.image = image; + this.description = description; + } + + public MultipartFile getImage() { + return image; + } + + public String getDescription() { + return description; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/statistics/empty.txt b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/request/empty.txt similarity index 100% rename from backend/pick-git/src/main/java/com/woowacourse/pickgit/user/domain/statistics/empty.txt rename to backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/request/empty.txt diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/response/ContributionResponse.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/response/ContributionResponse.java new file mode 100644 index 000000000..5dd3806cf --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/response/ContributionResponse.java @@ -0,0 +1,50 @@ +package com.woowacourse.pickgit.user.presentation.dto.response; + +import lombok.Builder; + +@Builder +public class ContributionResponse { + + private int starsCount; + private int commitsCount; + private int prsCount; + private int issuesCount; + private int reposCount; + + private ContributionResponse() { + } + + public ContributionResponse( + int starsCount, + int commitsCount, + int prsCount, + int issuesCount, + int reposCount + ) { + this.starsCount = starsCount; + this.commitsCount = commitsCount; + this.prsCount = prsCount; + this.issuesCount = issuesCount; + this.reposCount = reposCount; + } + + public int getStarsCount() { + return starsCount; + } + + public int getCommitsCount() { + return commitsCount; + } + + public int getPrsCount() { + return prsCount; + } + + public int getIssuesCount() { + return issuesCount; + } + + public int getReposCount() { + return reposCount; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/FollowResponse.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/response/FollowResponse.java similarity index 59% rename from backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/FollowResponse.java rename to backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/response/FollowResponse.java index af7b6c429..a11bf99f9 100644 --- a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/FollowResponse.java +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/response/FollowResponse.java @@ -1,5 +1,10 @@ -package com.woowacourse.pickgit.user.presentation.dto; +package com.woowacourse.pickgit.user.presentation.dto.response; +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter public class FollowResponse { private int followerCount; @@ -12,12 +17,4 @@ public FollowResponse(int followerCount, boolean isFollowing) { this.followerCount = followerCount; this.following = isFollowing; } - - public int getFollowerCount() { - return followerCount; - } - - public boolean isFollowing() { - return following; - } } diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/response/ProfileEditResponse.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/response/ProfileEditResponse.java new file mode 100644 index 000000000..a7364ee5b --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/response/ProfileEditResponse.java @@ -0,0 +1,23 @@ +package com.woowacourse.pickgit.user.presentation.dto.response; + +public class ProfileEditResponse { + + private String imageUrl; + private String description; + + private ProfileEditResponse() { + } + + public ProfileEditResponse(String imageUrl, String description) { + this.imageUrl = imageUrl; + this.description = description; + } + + public String getImageUrl() { + return imageUrl; + } + + public String getDescription() { + return description; + } +} diff --git a/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/response/UserProfileResponse.java b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/response/UserProfileResponse.java new file mode 100644 index 000000000..bbea8b046 --- /dev/null +++ b/backend/pick-git/src/main/java/com/woowacourse/pickgit/user/presentation/dto/response/UserProfileResponse.java @@ -0,0 +1,53 @@ +package com.woowacourse.pickgit.user.presentation.dto.response; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class UserProfileResponse { + + private String name; + private String imageUrl; + private String description; + private int followerCount; + private int followingCount; + private int postCount; + private String githubUrl; + private String company; + private String location; + private String website; + private String twitter; + private Boolean following; + + private UserProfileResponse() { + } + + public UserProfileResponse( + String name, + String imageUrl, + String description, + int followerCount, + int followingCount, + int postCount, + String githubUrl, + String company, + String location, + String website, + String twitter, + Boolean following + ) { + this.name = name; + this.imageUrl = imageUrl; + this.description = description; + this.followerCount = followerCount; + this.followingCount = followingCount; + this.postCount = postCount; + this.githubUrl = githubUrl; + this.company = company; + this.location = location; + this.website = website; + this.twitter = twitter; + this.following = following; + } +} diff --git a/backend/pick-git/src/main/resources/application-test.yml b/backend/pick-git/src/main/resources/application-test.yml index cec23b713..2ef0b3134 100644 --- a/backend/pick-git/src/main/resources/application-test.yml +++ b/backend/pick-git/src/main/resources/application-test.yml @@ -1,6 +1,6 @@ spring: datasource: - url: jdbc:h2:~/test;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + url: jdbc:h2:mem:~/test;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE username: sa password: jpa: @@ -13,12 +13,3 @@ spring: generate-ddl: true hibernate: ddl-auto: create-drop - -logging: - level: - org: - hibernate: - type: - descriptor: - sql: - BasicBinder: TRACE diff --git a/backend/pick-git/src/main/resources/application.yml b/backend/pick-git/src/main/resources/application.yml index e57f9d610..9a0c7cc69 100644 --- a/backend/pick-git/src/main/resources/application.yml +++ b/backend/pick-git/src/main/resources/application.yml @@ -1,3 +1,8 @@ spring: profiles: include: security + +github: + contribution: + star-url: https://api.github.com/search/repositories?q=user:%s stars:>=1 + count-url: https://api.github.com/search/ diff --git a/backend/pick-git/src/main/resources/logback-access.xml b/backend/pick-git/src/main/resources/logback-access.xml new file mode 100644 index 000000000..3116ff72c --- /dev/null +++ b/backend/pick-git/src/main/resources/logback-access.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/backend/pick-git/src/main/resources/logback-spring.xml b/backend/pick-git/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..54d56c266 --- /dev/null +++ b/backend/pick-git/src/main/resources/logback-spring.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/pick-git/src/main/resources/logback/access-logger.xml b/backend/pick-git/src/main/resources/logback/access-logger.xml new file mode 100644 index 000000000..ed7d9a222 --- /dev/null +++ b/backend/pick-git/src/main/resources/logback/access-logger.xml @@ -0,0 +1,20 @@ + + + + + ${home}access.log + + ${home}access-%d{yyyyMMdd}-%i.log + + 15MB + + 30 + + + utf8 + + %n%boldGreen(###### HTTP Request ######) %n%fullRequest%boldCyan(###### HTTP Response ######) %n%fullResponse + + + + diff --git a/backend/pick-git/src/main/resources/logback/api-logger.xml b/backend/pick-git/src/main/resources/logback/api-logger.xml new file mode 100644 index 000000000..c27dddb49 --- /dev/null +++ b/backend/pick-git/src/main/resources/logback/api-logger.xml @@ -0,0 +1,3 @@ + + + diff --git a/backend/pick-git/src/main/resources/logback/db-logger.xml b/backend/pick-git/src/main/resources/logback/db-logger.xml new file mode 100644 index 000000000..04fc1c231 --- /dev/null +++ b/backend/pick-git/src/main/resources/logback/db-logger.xml @@ -0,0 +1,20 @@ + + + + + ${home}db.log + + ${home}db-%d{yyyyMMdd}-%i.log + + 15MB + + 30 + + + utf8 + + %d{yyyy-MM-dd HH:mm:ss.SSS} %boldGreen(%-5level) %boldCyan(%t) %boldYellow(%class{36}).%boldMagenta(%M) %boldWhite(L:%L) %n %boldRed( >) %m%n + + + + diff --git a/backend/pick-git/src/main/resources/logback/error-logger.xml b/backend/pick-git/src/main/resources/logback/error-logger.xml new file mode 100644 index 000000000..0005f2492 --- /dev/null +++ b/backend/pick-git/src/main/resources/logback/error-logger.xml @@ -0,0 +1,20 @@ + + + + + ${home}error.log + + ${home}error-%d{yyyyMMdd}-%i.log + + 15MB + + 30 + + + utf8 + + %d{yyyy-MM-dd HH:mm:ss.SSS} %boldGreen(%-5level) %boldCyan(%t) %boldYellow(%class{36}).%boldMagenta(%M) %boldWhite(L:%L) %n %boldRed( >) %m%n + + + + diff --git a/backend/pick-git/src/main/resources/logback/test/test-access-logger.xml b/backend/pick-git/src/main/resources/logback/test/test-access-logger.xml new file mode 100644 index 000000000..7aeef0a16 --- /dev/null +++ b/backend/pick-git/src/main/resources/logback/test/test-access-logger.xml @@ -0,0 +1,11 @@ + + + + + utf8 + + %n%boldGreen(###### HTTP Request ######) %n%fullRequest%boldCyan(###### HTTP Response ######) %n%fullResponse + + + + diff --git a/backend/pick-git/src/main/resources/logback/test/test-basic-logger.xml b/backend/pick-git/src/main/resources/logback/test/test-basic-logger.xml new file mode 100644 index 000000000..a52ee3dd9 --- /dev/null +++ b/backend/pick-git/src/main/resources/logback/test/test-basic-logger.xml @@ -0,0 +1,11 @@ + + + + + utf8 + + %d{yyyy-MM-dd HH:mm:ss.SSS} %boldGreen(%-5level) %boldCyan(%t) %boldYellow(%class{36}).%boldMagenta(%M) %boldWhite(L:%L) %n %boldRed( >) %m%n + + + + diff --git a/backend/pick-git/src/main/resources/static/docs/index.html b/backend/pick-git/src/main/resources/static/docs/index.html index 1b493baaf..8c7e9498d 100644 --- a/backend/pick-git/src/main/resources/static/docs/index.html +++ b/backend/pick-git/src/main/resources/static/docs/index.html @@ -607,7 +607,7 @@

Response

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 49 +Content-Length: 47 { "url" : "http://github.authorization.url" @@ -636,7 +636,7 @@

Response

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 55 +Content-Length: 52 { "token" : "jwt token", @@ -660,11 +660,11 @@

Request

POST /api/posts/1/comments HTTP/1.1
 Content-Type: application/json;charset=UTF-8
 Authorization: Bearer test
-Content-Length: 26
+Content-Length: 32
 Host: localhost:8080
 
 {
-  "content" : "test"
+  "content" : "test Comment"
 }
@@ -678,13 +678,14 @@

Response

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 96 +Content-Length: 138 { "id" : 1, + "profileImageUrl" : "kevin profile image url", "authorName" : "kevin", - "content" : "test comment", - "isLiked" : false + "content" : "test Comment", + "liked" : false } @@ -699,10 +700,12 @@

Request

POST /api/posts/1/comments HTTP/1.1
 Content-Type: application/json;charset=UTF-8
 Authorization: Bearer test
-Content-Length: 2
+Content-Length: 20
 Host: localhost:8080
 
-""
+{ + "content" : "" +} @@ -715,7 +718,7 @@

Response

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 29 +Content-Length: 27 { "errorCode" : "F0001" @@ -733,10 +736,12 @@

Request

POST /api/posts/1/comments HTTP/1.1
 Content-Type: application/json;charset=UTF-8
 Authorization: Bearer test
-Content-Length: 2
+Content-Length: 20
 Host: localhost:8080
 
-""
+{ + "content" : "" +} @@ -749,7 +754,7 @@

Response

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 29 +Content-Length: 27 { "errorCode" : "F0001" @@ -786,7 +791,7 @@

Response

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 50 +Content-Length: 47 { "followerCount" : 1, @@ -802,7 +807,7 @@

언팔로우 요청 - 로그인

Request

-
POST /api/profiles/testUser/followings HTTP/1.1
+
DELETE /api/profiles/testUser/followings HTTP/1.1
 Content-Type: application/json;charset=UTF-8
 Authorization: Bearer testToken
 Accept: */*
@@ -819,7 +824,7 @@ 

Response

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 51 +Content-Length: 48 { "followerCount" : 1, @@ -851,7 +856,7 @@

Response

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 29 +Content-Length: 27 { "errorCode" : "A0001" @@ -866,7 +871,7 @@

언팔로우 요청 - 비 로그인<

Request

-
POST /api/profiles/testUser/followings HTTP/1.1
+
DELETE /api/profiles/testUser/followings HTTP/1.1
 Content-Type: application/json;charset=UTF-8
 Accept: */*
 Host: localhost:8080
@@ -882,7 +887,7 @@

Response

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 29 +Content-Length: 27 { "errorCode" : "A0001" @@ -904,35 +909,263 @@

Request

POST /api/posts HTTP/1.1
 Content-Type: multipart/form-data;charset=UTF-8; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
-Authorization: pickgit
+Authorization: at
 Host: localhost:8080
 
 --6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
 Content-Disposition: form-data; name=githubRepoUrl
 
-https://github.com/woowacourse-teams/2021-pick-git/
+https://github.com/bperhaps
 --6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
 Content-Disposition: form-data; name=content
 
-pickgit
+content
 --6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
 Content-Disposition: form-data; name=tags
 
-java
+tag1
 --6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
 Content-Disposition: form-data; name=tags
 
-spring
+tag2
 --6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
-Content-Disposition: form-data; name=images; filename=testImage1.jpg
+Content-Disposition: form-data; name=images; filename=testImage1.png
 Content-Type: image/jpeg
 
-testimage1Binary
+�PNG
+
+
IHDRx�9R7miCCPICC ProfileH��WXS��[����H	�	�	 %�zG��@B�1!���E�.�`CWE�
+���(��XPQ�E]l��		躯|�|����̙��;�{��I>�@��P����� u�xs.O&a��G(���˻��U'�?�����2�X���2^����yi!D��rr�D�gC�+�B�R�s�x�g)����D6ėP�r��4�A=���y4>C�"��h��8�'��!V�>��`�WBl�%�x3�;Μ��g
�s�9CX�׀���d�|����4�[
+��>l�
+����a
o�M�R`*����8E�!� �+�J�#R���1OƆ��O�.|nH�����c�U��lQb�[�)�BN2�/�B�T6�U�Іl)��ҟ�J�*|=�祰T�o����(&�AL�تH���β��(�ͨb!;v�F*OT�oq�@��NJ��a�*���`��F�����
+����`�x܁�a.�e���2�#���̅/	U�=�S�T<$���ʵ8E����-��
+����$�Z<�nN%?�-)�OVƉ�r#���KA4`��r8��D�Dmݍ��r&p��pRiW�
̈�5	�? �к��Y(��/CZ��	d�
��O!.Q ���yKO�F��\8x0�|8��^?���aAM�J#����$�C��0�=n��~x4���3q��<����	��	��D%����?LU���k��@NO<����Ǎ�����@��j٪�Ua�����{*;�%#��~\���9Ģ����Qƚ5To��̏���U��Q?Zb���Y�v;�5vk�Z�#
+<����Ao���A�?�qU>���Թt�|V�
+�*{�d�T�#,d���A���y�#n.n�(�5ʿ��	�D���n������?�My��������c�����<��H����Є'��K`�q^��P	�@2H�a��p�K�d0���,��Z�l��.�4���8.���:�wO'x	z�;Ї 	�!t�1C�G�
a"H(�$"�H&���92���#ˑ��&�ًDN �v�6��B� �P����	j��D�(�B��qh:	-F硋�J�݉6�'Ћ�u�}��bS��1s�	cbl,���1)6+�*��k���*ցucq"N����x
+��'�3�E�Z|;ހ�¯���+�F0&8|	�hBa2��PA�J8@8
�R'��H�'���YL'���w�ۉ���$ɐ�H�'ő��BR)i
i'��
+���AM]�L�M-L-CM�V�V��C���gj}d-�5ٗG擧������ɗȝ�>�6Ŗ�OI��R�P*)��Ӕ{������>�	�"����{�ϩ?T�Hա:P�ԱT9u1u�8�6�-�F���2h��ŴZ�I��
���G��1K�J�A��+M���&Ks�f�f��~�K��Zd--�Wk�V��A��Z��tmW�8��E�;��k?�!�����u��l�9���-�l:�>���~�ީKԵ��������m�������K՛�W�wD�Cӷ�����/�ߧC��0�a�a�a���2��p� �A��n������y��
��FF	F����6��;�o8ox��}����Ɖ�ӌ7�������HL֘�4�6�7
2�5]izԴˌn`&2[iv��C��b�3*�=����r�M�m�}�)%�-�[R,��ٖ+-[,{�̬b��[�Yݱ&[3��֫��Z����I��o�h�����c[l[g{ώfh7ɮ��=ўi�g������� t�r��:z9��9�� ��!Q3�Չ�T�T���Y�9ڹĹ���H��#��<;򫋧K�����:���%�ͮo��xnUn��i�a�ܛ�_{8z<�{��{�x��l�����%�������������e�31��|�}f������[���O?'�<�~�Gَ��2걿�?��G# 3`c@G�y 7�&�Q�e?hk�3�=+����*�%X| �=ۗ=�}<	)i�	M	]� �",'�.�'�3|Z��BDTIJ���S���y*���6�Q�C�4�9���Ys/�:V��8q+�����O�?�@L�O�Jx��8=�l=iBҎ�w���K��إ�SZR5SǦ֦�OI[��1z���/���қ2H�[3zDŽ�Y5�s���ұ7�َ�2��x����LМ���?�����#�37�[����dUg��ؼռ�� �J~��_�\�,�?{y����9]�@a��[���΍�ݐ�>/.o[^~Z����̂�bq���DӉS&�K%���I��VM�FI���8YS�.��o����?,
+(�*�09u��)�S�SZ�:L]8�YqX�/��i�i-�ͧϙ�pkƦ��̬�-�,g͛�9;|��9�9ys~+q)Y^��ܴ���L�͞�����J5J��7���߰_ Zж�}ᚅ_��e�]�+�?/�-���ϕ?�/�^ܶ�k���ĥ�7�.۾\{y���+bV4�d�,[�ת	��WxTlXMY-_�Q]ٴ�j��5��
+�^�
+��]m\����:��+���o0�P���F��[��75���Tl&n.��tKꖳ�0��j��|�m�m��������a�cIZ'���9v��]!����7���]���y�7s�}Q�Z�3���j�k�����ajCO����)���`���f����m;l~��ޑ%G)G��?V|����x����[&��=9��S	��NG�>w&��ɳ������;|�������.6�z����m^m
��/5]����>����+'��\=s�s������7Rnܺ9�f�-����o��St����{�{e���W<0~P���;�:�<y��(���Ǽ�/�Ȟ|������ٳ��n�w�u]~1�E�K�˾��?���~e���?��l����Z���͢��o����WKo|�w��ޗ}0���#���Oi���M�L�\���K�ר�����%\)w�S�����6h��a�F��Q���'����z����
�nn�gl� �&�U�i$���}h�D�����>���-��H+����������f,����=�B��g�8�KVA�7��O����;PD�~���ܐ�J� �eXIfMM*>F(�iN����x�x��ASCIIScreenshot4���	pHYs%%IR$��iTXtXML:com.adobe.xmp<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 6.0.0">
+   <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+      <rdf:Description rdf:about=""
+            xmlns:exif="http://ns.adobe.com/exif/1.0/">
+         <exif:PixelYDimension>138</exif:PixelYDimension>
+         <exif:PixelXDimension>632</exif:PixelXDimension>
+         <exif:UserComment>Screenshot</exif:UserComment>
+      </rdf:Description>
+   </rdf:RDF>
+</x:xmpmeta>
+�[jRiDOTE(EE*�V�xx*uIDATx�]`U׽$!	�w��D��"UT,���;��(U��"M�JSE�|�" iҥ��B�s�d&��&,a��ཚ�ye޼wv�9{�KV��R19{�*����^�zJ�ǫ��w�˂���3�T��/u�E���1��ul���J���M�r��1��7q��Du0E@P7�)�s��*���ɜ�g�њڶ� �O��:F�V-�N����R�3������������DF	�]����"�((���^*���J߾���G.-AAAҼEk��^Ǩ^�I�P��׶�*sd�&y��~�I&L�tSא�`ɜ9��F����'$$D2e��US��fȐmǝ���(��������VPE�(�����_��BR�O��%v�u�֖���z%x$icnj�|�"d�k䭷9�I�2��7Fr��)K��*#G�v�ҤI#'�+Y�d����L�8�i���n����hE@P"�ϟh�Cc�S�P��2��1��7�.�r�R%e���朚�Z�����Ѧ\�\Yys@?s~-2R�ԩo�}}Q�������;j�"�(��?P����1�)�G
ބ�c�	w��
ҿ����R���n�d˖U�/�M�
ᴥO���q&�`��E2v�8�͗��IdT���;�}E@P���<�y��O<BH?����;t�x����{���m*��nW��yG�n����Z�(��"�����{p�3�J��py�kw9s�{mb�]�ƳҼYSY�x�L�6#������%KHN��m޼Y<�ױs��-ŋa=$�6m����j��%��/��*W�\���w�NTSE@pC@	�ZPE@PE �#�/鿇�E@PE@P�P���E@PE@H�(�K�@PE@P7����E@PE@P�>J��{�+PE@PE�
%xnphAPE@P��������
+E@PE@pC@	�ZPE@PE �#�/鿇�E@PE@P�P���E@PE@H�(�K�@PE@P7����E@PE@P�>J��{�+PE@PE�
%xnphAPE@P��������
+E@PE@pC@	�ZPE@PE �#�/鿇�E@PE@P�P���E@PE@H�(�K�@PE@P7����E@PE@P�>J��{�+PE@PE�
%xnphAPE@P��������
+E@PE@pC@	�ZPE@PE �#�/鿇�E@PE@P�P���E@PE@H�(�K�@PE@P7������/R�H!�ӧ�#G��햃�%�*d�۵{�����ױ�y���N��k�/��/�ӧ����o}2e�$������s~SRE@�wP�wᄋ���-�K��u�����O>�T�_��u��ҥ�"E�S�N���{�ƍ^��r��w�������)�����ԩ�I��9���R�q9�7y��Β;W.oM>�m��O��9N�dɒI���q����_e„IN��(��"�(�!�$^ʔ)�m���t���/,����'I�_�zU�m�!?��@f��P._�|W�_�PA)P ��Ϗ�|�$mڴr��Y9x����Y�v�DEE9sH�*����%�̓%�,���ݭεЬY�{�}Nձ��e���N�'3f���ޗ��0S?j�XY�x�[���/u��y�ƺr�,]�\&M�����!Ή��K~�Q��8��.]Z�~˔In����8p�i�E@PE�O�~�)>t�d͚�����OH�~�姟�}M4�ܽ�<���	�}d�Ï>�_�)�Є���ݮ��Y��s��\#�&ŋs����+�^z�)�[��g�1u111Ҷ]9v�ӧL�G�o�7�4ĞlܸI
*$|����o�~��o]����￯�s缛J����7��C�ԩ��?���PS��zȐ�N��(��"�(�h�Gr7}����}�ֵ��ɯ$�'�I�N$u�T>c6�#��A�}�t�,G�ǚ52p� gn2d�gN��ɓ;uG���ӻ��^(����Ib����LD!c�ns�e��ɝ�t����e�������"O�}�P�עe9s�����\_E@P�K�h�]�t�=�����P�W�rU��k˗//���)���ҥK�LK?/W�Ϡ!�j��B�V}\z���s;�^v}��oF!�:�m��WJ(4X�{�.��>j�hJnݦ��	�g&w�/S?�"�b���}=_�M��6��+�B[>�:]��wQEPE@���%x4��~����}O׿��(�8q������'K&����J��>�\���g���Z�Byi٢�dɒE\	����
�?���+�\���F��J$����g͔4i�����L���i���?�H8g�g�ρ�r�9�Kb�����^g_N�}�%�Xf͚�gkO]�&����s�.y��8����N�y/~��t�2�����×q~��0�ƛ:tHv���[�S$�?^�)��F�
�Wʠ�#G� �8ڭݾ��V�Z%��E��,_���.��K�(��"���}��<)U��?֘��ذq��|��͛ќ��x�m���|)�f�����@gƶmۅ~q<>��;�9��f�Zys�xj�@�2a�X�(��|]�o��yң���ԓO��M�6K�>����<���8�H���6�H��l��m�6ڻ�A =��L���!�15��jXk�|�ۻo�	��<|�?o�)���M,\$���oMN?O�G�pʞ'�O������m��,[��|5�k�)�c��k�ժ�>w�K�7�FE@P��@���;�8~Z~\o��Z�B��&z�)R���/��(�0�\��]��q�h��ގ�&x��EȤ��[͘��̝;�){��kծ��ˎ5k֐.�;�k�Ѧm{���H�BB�e޼/%$8Ĭc�����S��}�֖> _6�DDDHwh�I I�Kd|�Z��C˟[�ʪիݖA��={oz��:��J�ƁtGF^�g>��0y���˖5}f}2[֭[o.w��.Ԗ3�(!�_h��o���z��KB��6E@P��@���m�Z-M����z�]c�M^cy��L�s����:��dvw��;<C�ڔ�Fp���z��.snάЖ���<>�ƌ�z7I����逸���tw�5�An��ڵkI��L�Eh�6l���O*U�h"lY���$�j����k��<�U�R�g�jjɼ		x|y���O�.O�<��{q��^��r��l�d�F��2
�g���}��I�Ȕ8�^�iH�����D<_�ɟ?�dW���+�Z�m�d��	�E��r���4���H�]�l�*Ԛ&$�1�#+*�!y����ڦ(��?�@��YSFMBL�
P10���-����#�Y51�^�2���W|��EB�/��df����#X|+*����:BS{Nm�<4�h�'��ǚnm�`����5w���]�)�`�y�ENn`���qmM:�E[�;50����G\�x����n�R6��ܦU�vr��	�ܼySiڤ�)�<yRZ�j��'|hFԩ-�4vR|%x���ǎ�K�Z/8��Ո;w*�|R�b�׷����z�y5�҄>�/�璝��g?~�_f��q������d,R�<�XE)[��m���b�
+Ҡ~]���v} �E�d���H������s��#��;�h��>p@�]�&�
+�I�Z޹s�=��1w������I�w�|����B-(��"�#�oƄ�E$Fx5ĉH����"G�����!��)44.�F*�]��	 j�8&�x��j4�6�܁衯!q�(8$�h~��I��8�&�PI�iH&(�3/<k�6ɳõYm��8N#8o�>��s��B��5�H����…vѧ��	^׮]�4d/Ԫ�f2~���&_'�(Yj�<�Z���Z�����I�lI���%��]�3fȈ��-̘#�V��+=%G��m��zmg"�J yj-g̘��c��'N:�/�|�-�R����V�0D�C�x��Ԅ�����F~�u�4i��+�c@ӵlG�Ö-[n���������u�G�n�K�/���${�l2ɱwc�:��C�˧�y
)h#�ɶ�o�S�—�$p��)泛/B	�'FZV��D `	�t<K@�nD;��K_���<�����f[��b��QM�F�ewZ.���L�^��k
�#��3�l�h�p�3��^P0�Ē?��b�e�FV�|ޜ	�ވ��u���>X��3$�:�p��W�|��:u��]���o���U*?fn��:u�y�,U���_�-�HiÆ��u���#��v�3�&D��'�˕���۱n��B�i�q����ӄ=s�4ɖ͊�ݰa���7��oESi���U�M�a>��<���s�z�>n��v�᤻��,mCM�����i2%��El�ƾވ"��Æ2��߀7�rٍ��=�h�Eb���|aR�����HGh6m3.��+�sEH�E �X�7m�0�@���Y���F�͝9Z�7�&C�H��d�N�D�*I5l�N���Z(�K�F��\�F/���`��sA��Y��<#��!����K.��`�s^�3�᨟�Z<cf&5tZ,
�UG��OM^�Ήxׯ_Oڶi{�е�˷m
+�7�8p��}���w�hҴ���g�.3��ԍ�,Z��)�dԨ�`�"�����'D�������s.��I�	��)�':[�퀟^��݉P؋�P�yg�h'*7>���C��O��;���l��qxS e�f�[�1UM�4�M��6j�N�n|,�{x;&D�B�moҸ��%��&Yi��k��#��g�_��fW�!Ј�){����^=%+R�6�@>��y{�NP��%xS�
���>r�{��Q$Y�%w<�9�AآUN;�s�R����y����piI��O%�%�@��%C��r��E�Cv�(��fcR�
+N��� `�T)S���Q���x ��!$|���l�g�1�([D��1s�љ5�D�"}|HF�Ul��M�'N�"��7��]����&�[�-
+����ռ��[vF�WN�<A"��u�׮['�e�4�U���1P��/Oq%p��S^������iG��K�;[v���O|ej}��_���Waz��ŋ�u����Ξ��`İ��ʙKjԈ�������U�%��-3fL5�F�7"EL�1v_���өc�w7i�{��n���;� ��K�8��A􃳅[�j�\�C�	s .� �v?�h�5��CJ��Y��DDrh�yL3�(�P`��}�M�X�,��{@�&���7�y�����\%s���ݔ��EP�
+K�j0$�ÀD��D�J81d�T	�2�ºfX�'������(�H	�
[�˙�G$ex�>|	uS�d����A��=?��Ⴠ,g�-br���ٳI�������������Cc�`<�s�c�]�Ȝ�‹��3�-�e�����]Z
�x͑#�L��	�[�����3���Iظ�w��&ā)?�}SهyƦO�i���Aˊ�/�q#��L��j�����d��-�Cx�:v����ko�������{��䄹��0�b�BRv��Ya�\ngFߴÇ��6_^zB3�D���+IM��i����ص�����g;I�[�j�1���휹1�#H�gN��������	��bǒ��aʘ��&k����S�0ŕ��׸<�z��Ӥ�`�˗u�/���?��B5xސ�:E@T���zo��d1���:�|�hF%�HH$��0nD�e�f9}�4r��1{�\OUX
+f���˧wH6��E"�����
SLfI�"45j�hz�u���܀v�����~�:����S�
!���[��CM0y�EH-�g�X����"��h���$���ƒܲQ��O���&����|n�J�Gbk���_�)��*��!>;�(G�u���4(������KL�6mZ��I���͊]ǹ�|�m��S|B������@W����M'Dͺ�y�:��s��z<1��������\��������m��)���
���&�3���~D�fѢ��Dw������s�a��kρ�J���>7�p�k�5�Q�����Ϝ��-��}�+�c�r��lܰv�X-����s�<}�U��
Q�S�@E �	�HC��F2c������CLE���a�
+�Ҕ�2s�H�Ґ�ߖ-��? 9r�v�24u O!�)m��I�_޿�?�$�~2���$ _x8P,��{������.88�1̘��-�E���3c}�_���H��%7t��-�t��Y��pƴL�-��-|�2���h����j���X�胆(����uΜ9��cjk�·^�:��e�f�ӏd"m�Z�}�X��t:�&��)�`Ӻ�L@�U�N]�H�g����RW����N@�T���}ߐ2�<�Zm�������_	^ݺu�}�6n�ju/\�(ĉ~eٲfs4zv���wM����ns=���1�a�q�->Ž$s�X�C���n�F�2�	 �3�I�}C������u��7b�P�ѷ"����d�:�}n_�I��s׫�f�k�n|��s5!+��Qԣ"�$��͘�4)���4\�ҝ�EGG���KFke��@яi��M�y��R#Z�.�PA�Hp���D��-�AQr$�4���YѶ����"+�]����pN|�K�^���#+�/�
+�*K�ܹi���':s���0c�����
-}���$zfd����ͭ�I������F\I�]�#s����_Ǐ�0{�/^T2g��t���cd+	��0
+v��]r&�<Ђ拈0���aT�<���R�&ƏcM�Yo�r���g�x�bF�GS�fh\W )�>hQ��/���,�wk��ˇ�4���.]z�ևs����,P������3��){;q��&ZO��~����x7�vL�	�?1���{��x���ow��>���c�5��)���o�3�k�s��g�wsn�(���У"�$��M�0,��E�	i(�	J�������c�}�V���H��c.<c�I�?���*���ˍH�܁�S/�1h���PxIl4Tg�]��0i�a�f2N��݀�!���0Unܸ^ʖ+�@l�v΍��m�"g��U�hQ!�9Q[x#&�t"�•�	W٨c�M��H�dz�"xpݎ̟���b���[� x��5_�Z�W{�r3����d�<R<�`};�~�<Fn�D��-t�|"A���1����9r�%$�&x/A��,4��4n���
���u���q���ˮݻ��l=��8iҤ���)c5�+I�ma}g~��}*H��\{v_�M�8��՟2�ۯ�����6%xDAEP�
+K�EK]�+�!��M<H��N�GR�a�zDp�7��@ؒA��.�h�h��
+����v$�����z��a�=��A���M�rE���N�$q$p|�p>[��d�-n6� ���fٍ��%K��'�ڣ�1��?�޵kW�!Df]`nfM\D�4���P$��50��8߄����;
+<%w�\�v�(^��g�[�������G�r{x۝F����{��M�}����/����d����g�9a�d���^����X$Iv�`�����_�!�)�����h��e�Vb��ę��H"�#fu���R#I����0�{�7�pW���p/�S�O�\xt+H�=j��ב�9���A�������6E@4��YQ������#z���yh����y���CÒ"E����e��?�]m�7�H�*�Y[��p-n.�<|��FJ���-%K�0��$N!h�t�AK���0�1��S�J%_"qj%<�y����3�9s����R�*�'�yƸKrG�$� ��;�����P������jc(�G�{�^�#o��E0;��MI��C��4�y#�:fNjJa�o�>���e;���'�ۨq�Po~x�]�P�/��7F~��-��
u��Uٻo�����j�r���*��`�J�WoIJ�I�
+�<<a�X{
+r'�w� 	��G���!���7>��������y�bE�	�)��G
�����V�;k�������vT��
�S�@E `	޴qq/N�e�H��?j�G��#G�Cu�W%S�x0g���(+��1J�Z7�6�B�m�H�F��I�H[��C#ͼ��)dӦMrZ�2eʘ��I����k$=��EDD��|������"R�$���o)�n?�������f��F���\˵p.1x�5�t�&Z�U�]v�{���b;?�׎��d B�L
��D�r�T ��Ξe�3���喯���
m-H6|�5�aa8Rw���֟�k׸-��iӶ�U�p�ow��A4���ڭ�L|�w
+h��c��ٳ~��{k?E@P�e��;C~@L����+�7'���rF�R;W
+Y�/""��N���z�~�NrDzE�8��ƌׂ��>�I$�̱��b��?�����|8�N�,p��q)��._�$��i��j�dF"�Jr9�����.BXZ�/[b���*�b�mo B��Cjw�gfu/��z��T�Z�,�D�U�vwD4m�n�����R�1�+M�$���@�S'O_�EH�½L}<����`�Oan����0�(��"�(	!���daf�sgk�H��5���4�x6 ��b�J��/K�l��x�g�VdWIc_&#�D�Js(�}��2�ɣ�D��&xhF�0Y�f�dFμ��6	�I�x��IY�b�T�VER#�������矗L���>tP؏-�Jɜ�>�Z؇5,<���Č(��j�|��GM��da
�m|��dC�9Mo[�%f�v �����m�H��dǡ��<�\h��R�enY�ŕM��zF���e�n�'95rԌy�������2��Q��+=o����-+��"�(K�>0A�;�b�P��^-��Y$~��?�i��dC�r�*2��O�ړեp�Br��4�Z��J*L
����8�DF��J-�,l����$���
��5�(~	9���"q���2�ܣxpg5�+W�@�lp�`������Ȏۤȃťʯ�ɵ��Y�p��/2���S�Ԃ�������y�^]���?^ڶi-5k�0�� ��"�(��"p+��1M��S�V�9�$�Ê�ۃaa`b���̷v�Zɗ/���|��,QT
+�"ji�u%x�&�\*��(:j~H�c�0�w���(�V��/,,\����T~����pZ^CS쒥ˤ(v`Ȗ9��@�A�w�2~w��'�H�Tp����74w\�c�C���V]�pJ��0��)K��P��0��?N "[EPE@�L������A�^AΌ��z7Sf�e�L����9ǨNS0�!����S�0oM_l#�3�h�Ժ[2�X�J�5�/�����G��!����f���$(H��/"���#[���
+��4˦�^�a���E�*�Q����uKj�
���w&���p�"��3��E@PE@Pn��%xS�
��Ůʐ;lQ��G���`�h��KѢE���%k3+z6TN?�� l@�@�4�gS��D�[TQK2t�8�W��=)l��Z�{$�4��~A��7�ß���u�Z����$]�v�C��ܹs- 	������y3:׮g��E�P��|p��C�����&E@PnB ,,���m�[)��>Z�(�n��}0�?�3o��H����n���u�o��ڶ �P��CMg�x�H�;&��.D�+Q\���Fj�z�$�����e�~W�|84,D>��kc��_��p�/P �@ғk�T*LP|��a$�]�\v�L��5��]�|�D�>��C�fZZ�I.)��X�Q��y���
�g4x���^U_+b��(q��U�[0�7SEP��X�����)2[C���G*5x�Z̢L\	t^ M˗��
 \L�Z	T�!�S�3G��F`�̑S���/�ϙ�)2ɣeJ��?�#uI1c��X�S��Z�
+�g�/"�I��ٰ�lZ��3�e$*N�I��#=�.�9��_j|��\�F�V�l�p��ї�J<���5��YiZ������w�H�׾�<}U�t�ҙ�D���n��6���~�(Z��"���%xSF��!Zb��H��A�F���2*xK�֯߄L�G
Q�[��I����°e�N���x���(͹G���?�Bڴi���k��:~YRJ�*a�N�m�oR�~-PD���46��$�Ξ�G}_�i���,�ݗ����S
+,�]""��ɓ� ��}{�/�1�2�0I%H�RV���:C�c<��ګ��/������P셝�|���]�p�|���q��+�@�,��4r�!u�>�_H��cB�G�'�r΃�N���H�[Y� imXx�!nLk�/�ڣѳ4���۶e��Ħ��Ο����y�tIh���O?�sn	�-۷픓�N�K5ZR!�l�l4�Ƙ��A����Lq��!�F��2g�����ܗ;��~����+��B�H-$&��)��Ә ����:�i��EP���-s�L�{����r���/�rE@�-����9�@IDAT�]`SW>�w�[��
c����p�ww)�nc�o0�1��p�
��R���w�^�$MҴI�=м��z�}�؍�'w�w����cl<����}�!��ð����1���%//C��w�t�m�yݺs����A1c�P�8q���[t���@�����[z�&�^��dI�҉c�(�u0eȘ��A1}bJ����[�~��?~���?����)C���)-e���AA���Kz��>N�9�|y����� ڲm;eϖ�r��NA��F[˻7���<�w���j�c��]�((D_$O��bNJEO�>�gϞE_F��+(8́����`�d@1���9
�a��޽��G�����P�����_�Q�R%�r��L�/]��������+���"��?~�A�����?#�e�L>woߑwL -�'mp;�bƔr1�;O�<��k6P��KP��i��`�ӛ���B(����+Pd0���c:}�+�Ŋeh�ˋ+�z���M�i���S�����Gq@q zr ~�x�8qb�<���FO&�Q+(8��x��@��u,Y�ʊ�gX��M<��{�S���(S�����Z�d95i҈�߽Kgϝ��i�P����
�������X���r����{	HcD̥#>��҃��yS�D	(Q�,|KOY��c�{{{���wE*��L�|� χ�1�<s�<eȐ�|�'� ƀqy3�|��Kcp��L��X��������UP�nH�2��[��{�UE����p����<�1��A��`,&K�N�>Kxv[�xpo(^��t��y�r�%���ڷ����G_|�%�Pi��]T���(M�Tt��U�u�6K�|X��Gq��)ڄ8.�s��):y��J���>�)RP�����x���+��'N�!̸�d���bҖ��铏��*���<OD�Ҧf��RB��yC��[�t���>ƾ�my�}?��A�*D?Ď��'KN/^�`�ݣ��5b�Łps�c����D��P$^�����*�j﾿(u��6]Ve<��	R�����Kt��qUi(ir_J�*���!Oڴi������|L�bǡG�Q�E(i���捨KEm� �̙sʂ�P�t��mڷ� ����>��I�H?з'NS2�Х�YwZ�v3U��9��β/A����Mk�7U��7��r�|B�Xb�C��ծ��*D7$L���	����ŋ�6|5^��p�����d����͋��Ey{X=[����b'������9_��I�*�<l$0H�P�k�}���jР��~��1ʓ7�Hق�_s�\�AE{��c\>./^�Ο?/�����/�d���"�Yrx��Y.��U�h�`��+T�fU��s��j7�H�X8'������?XE\'���~���B"S�PA�V�
+�:u�V�X!�[�6�̙�V�Z���D��H�(�h8�J]:�#_��4}�,�f�j Β%35jԀ�^�Ns��7'�$I���bS�2��ݻ��4z��p�ooe�|Fe�~F{���u����|ɒ%��<��c�u����)���Xޣx^1E])�[vDx��hK��)D/X�z��
V�b�i0%a���*uJJ�0K�����b�������ҕԸam��ߋt�U��$d�:x�B�7nq��t�
+]�~�������c�%kV�BZ��ⳤj֧��|�ӧ[�<H��.O/�����ճ)�#�G�88|��j[�Đ����_��.�;���{h����'{~ʖ-C]�t��Yd����V/FcǍ���w����֭[Q�ʕh�5k�I��?̤t��Q���ʕ+aUar��X�b���2:S�…hX�:s�,u��3ܬ�֭}^���W��d�,^��KO�N�����
+�իK
�ץ5k�ќ9����ko>LN||b�m��ɾ�%U>���<��1y�yňɶk�
+	<�z1ڷo?�˛�g�>��;((�e��O��*ɀ�;GxQƌ�ف+dl���P�^��ʕ+-/Y�d�+wN�˳��.�S���c��<ysSЫ :{�]�z��2�?{�±B������<�9e͞U$��r��
v�H�^����˓�U���K��r�����:ag�s�\��-U�c‡:w�\��$�}�����g�]�g^�ͨF�j���4lb^�ɱ-��"�/�N�F�h���ҧOOիW�lٲ��?����&���'nܸI[�le����=V�9�Ca���Y�77�kN%Xʌ��C6lU�'<H�v�lϐ�yf̜%>-!���gB�8��s��emp[�P!ʙ#]�~��qئ+W�Jv___�|�֭[֊�t�����X�7uT�б= :Ŀ��@����C)M�T,�IO[�FP`��$I�����I�_���&8a@�5��f˞������c���cJ�&5��0�����1KY�7n<q��'6ʼn��ŏ��x��%?~�Ձ��!SF����V������X:)e�R0�C��,(FH,?�E���%u�n�?��M%iRG���9e�t��yfN��iS'Sfנ��:���Ƕ^�թE��fM�6�X�ڎ��J��mX�^B�1�֡XJ�2���!	���MG�^q55hP����Y�q�rZ�di��}���ҟ�
+�n)�n����4�-�W��Gԗ�E�n7n�ܘ�<�^={�gg�l
+pҘ5�o���z��kR���0v��;j��A�Nˊgr�h���}����UPp���F`i� �K�����d����w�Νa��˗�����E��\�:;�;Bi8L
+�/�������@:w��U�x��d���J�޽��k�>jҸ����}��r�Q�ܽ��() #2~	ԥg0L����%�4�з|������:����-K�0x�BJ�P/��!�;B�ˑ�f�:��|��;n���)]��о��`��"u�}�ii�ݫ�zƇ	�z�*�ҍ�n���Q#(�ܡ~��!o4���s&�U�V�
+_~!��w�~⩭��}HYK�,�p590AaU􂅋,�K�l6��hԨ��x�s��͛Z�:�!i�p��Թ�1�+��2;���e���������y�8����n�*�٘��Es��k���ߟƏ�^�qh��M�k�/�׫#R;�	����ʣ8�8`�����թ�@�Azg�r1FbU��{�8��I*�N���d�������չ���5�!������k�	9|	�Nc���s6�߿�/*�����+6��9��;�q9���^�J�޽�XJu�A�[*^�K~��U��Ӧc��5J�0��2hv�>,u�bi$B�`|A��Xelx�P��Ȩ���!=un�ς�sE�ݵkV_2��\��ԝ�jݺ����Q��={v=z�7�P�>��y����!1�0q2ݹsǤm���b0��R8�
&�2�:i�P���Ԯmk�{B�[�2:t�e����Կ���0{.���N��~�j�<[�AB5���NK4J�<�J��)S&�۷=e���=L%���͖g�{����ޣ�u�PC�/��<��&���6��mƌY���<�ֵ3}�y9��+M�2͘���*����A��*�g��QPp��&��O�c��	@��p�c��f���A���P)���f�XĻ��ߔ��B��|>lH�#�Z6>�I��xa�3�EA
+g�I!� 
�UV�>~������_:����UL�뛂<R	f��J�yi5��a$����&�c!
�u���!ű�F�R�ڵ����	,{�Ž|L`_������ۚ�Z��Asr@x��=��䲰H��o�Ď��:\њp+���#+�/�k_�N=�RFcgu;��9�8;�:�����{v;�3̗_���3f�j��:^��kق��dt!K5r%�K�:5͛���ԛ4m�5!�ukW��[���r�q&~��<�X"�>:�z��Fe���O��h�9��mcCf;���t�B��MM������xG,Z8O�L ����
��;jGq@q�Qx,��4���"���عB<MY�%� ������3gDE��K^�P���̛բ�8t���wX�Y�1��Y�Ɵ�7T�����s��%���CL2P��M��1vK�YH�?.eg����-^�Ǜ�=�5�i;��?��ky�
/��:e���z��#��Q�9ժ�-5�?6l�L�f}o<�K���Vf�U���SB K3g̜i�
��V��7�4!<r�9	r��Y&����OQ/�#�1�a����;w�<G�FH��ݻR9�^�����~w���x]YJU��T�YB�C�J��I����k���8v�>׬^a�3�Y�
+��NۋcŁ�ٔ�������ڒ���S!;Ŋ����ŋ��}�N槩m��T�bZ�z
���^(���{9�o��^�^�x� ��b��������)��\b6���l�W��G�<
��,�J�>��p͠�
QM���V(F%�+�?/��1#�/R6H��������)l9G�mH�J���@������Gx4H&�B�M�2����l��������T�G,a:���$j(K�4
+��:e�DqB�s�N��,��fZy��O��?c�&�(�xI8��!����~
+���&AD��0)9rd��Ɖ�S���C�g+�j����-i���[`+k��0���c��Pg������߳'t*�R�	��/W<��Ӳ%b��Iꐖ���ϟ7�D�3�PU��m����C��y�Ј2i��f/^���&�2�h�~̘q!����ۊ�N���v���R:s�@�V-�x-^�T<s�c���9��O�`8
Ƚ��!jY��p�K�`��e������Y@I<V\uZ�[�������-˛�;I�S�n��y��o��塿S�bEؙ�U�����J)�(�����e�fc�\�h��1�1�Cyï&�@�e<k�痉fL�j�!D^
��[t�����<�d$`�6���P�!g�
+!�4'�H���xP-CE	o�:
�_�J�2��)B���S��l��<j�pvb�̀�b;j�0>#ޠ-�D<8��˗טJ�:��KR�~}ľ�-���'�����x�hnC��7�4X����S��Fܓv E�4q���%<6xx�@r��q㊴�������e=j�0���=x}���>���[���&��g8+ݸy�%��h-���t��O}�B�*E�����x`�ػ�ؿ�����a_�o��6-�}���Or���ׅ:�2ؿ�1K��0����_S|v�ȝ;�PF��S+#E��z��͘!�H��Ɖ�yQ絼h�d`O�di�a����mM��#/��s�lu��.�k�6I_!�x!���5nf<�<cB�Ί��h��E�6���=�ž����&V+�����<����'M��V-��I��0��6��_�
fT�ZU�+k.�ʖ�k�0�j�Ok��~�ґ#GCգ�IAȠ����-�:���	=)p�\dt-Z�*��9�
+�
<��q:v�m���-CYһq�f�9�`z��*p�ШO��ò�K�a�:vh���%X��	�Cy�B28|����\
�ҿ�v��
+��\����QK6n�����o*W�n<�;��s�H}�
�O�~��:l�)|H�X�7aXw�3���d^|�/��I�_���U@��!��5:|��YQ/��ո��<�@ޠ~�x�$��}��IN����b��!T��%�xU�s��������+b����R�3���/ܘ�xҢ��<c|��<
+��s�\$8E���[���ldn��d�,���/P�.݌�4�[,��b>LK�.�u{�h�vm�������S��rF�g�!-��<�������X�j5�u����de{N�C��P<B/^2��c�`�k�)��4:��I��Z�yh��TlS	��Fj͛5�[�W�`1<@$���I��F
�`��>�����K��p����b	>ֈ�'��8t��Yy���tΣ�-<��K�*E�r8!�e�.9�6��x���-�S�W�@�oL|�w��M;v�%�2u<7jX?�ݫv!�z�����B��z:�6n�@���`����1���4O���5}������ߌg�E���Ͻ�RTE���RJ�.-�/��o��g�x���Cg�@��vl�
+0b��t�)�>Z8g �I�x	����w�&�8Ǵ{%y9��6l1���쾽{(-�Kϒ� 6�6���i�m{�*\���Gh�2�!$/o�B��BF,?�����K_q��J��\���7Z6{�K/I[�:�e)5Ke�
+���{�2�e̢<kK�ծ��^K+R/�|��?H�bUV����l�={�T�ݱF,��F�x��A��X���ea���^�0�	�t��3g��l"	n����a���7����(�3��@��y�&O� |Cm��aN��4<o�Q�6���1�IH]5�� �H#���m��ܿZ=�[�\���G�������Ab�ї_~A�;u>F;'H�`���?E�
�)<����¤��G�*�%�
+�6����C�k����/ۓ=J�Ax�<i�����UQb�ɤI���G&�@�{i���?&g�o��2�x&h���U;v�ͨi�����
}5T�9���W�ܽ�V�
+����2��#��"谨h���b��q��]��/�4i�JpcAj\�Qp��ܵ��������e^l[GW�g��̼�3�_�����o8ȱ�
�ݩ�Vx���c(WΜ6�y4a��j}JM��t=�V�pa0�.i�'<m�2�+`^�X�:�R!��u~9�� O"|�di*��*Uk��K!�,�U���$x0F�Ӈ%r���K.��J�p~��^�6�(�D�h�*�*_��h��j���.]鮥qL�8xɒ&�>}���y��2e��}[U���*eZ�l�%��>F�����?��i���9�6ԭ�����pJ�� }�՗�qE�dl�	�J$_�.��g�yD(!��wߊ}�t��Z�Y^����Gr��n�6/k�5ky���9V���2���x�v��Qr���L���X�8*����U�/s���_��2�掔���k��1l�(1���O�xcC��L�\��6'
�a�8��=ʂ�*X��p�c��ΕCT�����GK����j/���s��vyo�
�nq��/^���sȋ3[6Ӊ���&�
+�ҡ��H��E���[n��;tV������͜``�{�.]����	�\=Ո����&V������@�w���b	/��ɓɚ��!���~y8�X�gd<8s��3厝�Z��v�s��xa����[q��k�,�m-Ձ�.��CzܦmQ+k���,YD�ن^��y
�^���PgCmiN�s��,���sp���ͳ�:Fy���}k��.H�s��3��х��5k�2_����>���}nzW�ϝ;��>Ѫ��[-@7:9�<[��A""÷5�ۥbr�ݧ��
+��Fi��Ma���{��o͛5	եq�'r��k�{.��E�<MA��3�9�zl2dMU�LF�d������d //a���	�@����P�B�%�] �e ����#��A,�($`1j�����{�vH���,�p�cI ��Xph�zu���b�h�|0�F��Ikk�Z2�w���R@��8@�}WK>����m�6;P�-]���������Q��:q�$bO���ӧ�������i�i��yjժ��l�mժ%U�R�.ZB˗�09��-&�5��9��!�ռQQ>,m�	�[�2k��t�US.��E�H��x�⾂�hV�$b ���z�XR�U��*�b�8H�0�`����,�C;���߇ӠAC��W'��	
:�}�/��v��	�߇�p�����WH!��">�5jT���r>A��F�M|@�OL�O��[�6������4X�w㦟%�&4�;X�ǥM�F�B�9q�T��VYO=�x��}���+y1b�P�����<k���v`j�_�%��9�O��*��þ}z2fy�&�+���<��d*)aF����M/��0�j�K�)<���!N���Ķ�LJ���Ec�`ol�'�X�U��-i��BK.kپ�d�+�j�rn�W�`�Z���%=�4l
��=�����tH]��D��	�*v��F-����s���pħ�˕�����zr��~�G�n��]���Kk�jO{[��hg5��1���R��@�]��,���ݻ��?��+�^ϐ\b�(�s"D�,́P�j5�/[��N�l
���-uØ��%ۭA����{^�gB��Ё�A��5�&Mf����)����/�EӀ���C�\�˲eˈ��ACEZ�10�S��lg��+W��fBHF�@=��Bߧ,	���8J�g���)�=�����M�������cY�,�E���$�g�}h��+y�#�i3��$$�C8�8Ժ[�n�h�����/w90��-%�������4�h��!����eE��Ch�Z!�������� ����衠dؖ ��s�{�:N�C?Yz��Þ��}'B�-����C�3ԁ_T���n��)�M�J%:�V�V>|cq��	-ɸu�<���^Œ
+��Xx��Y���~ΒH���,M��*78$����k
��ʋ{���Gt���t�mr���Ƭ<L�L �[8�`���("ހ�}E��>cV��A�r6�ӲM-�M�ok��1>r�����Q
+�C[�� ���ƊÙ��X���x0�n߮��b��Gm:Kz0����h����2��$v��	��pv|(��kr��.U�>�GE��9�wX�$��3gY�'��qfd�Ȯ]:Jw&�����P��=��
+�;Lf�y0����2@���rm�	02�#��z��
+[m��{�������f�\
+��!�>1��jcQs1�fX��d,{��q�Т�0�Cɏ�!=C^c>I��2݆�`Hp�/�( ����>}�բG��h�x�v��s�.Ox��c�^i�ړ�:ѕ��K�.I�p���p_��#��al���5����ʛ?��^�Z=Z��VH�5s���u������5eu�����ġ���
/�C�0�A?$�����OPD<H8���-R[ĭ�8y
+�^�6���l����w�q���rp�
+h���N�����޽���vxÆ������zҾ��T���}o�3�1�ބ�S�+�1�m$_
+��ꙵ9uT_����`)'	`,����G! ��P��VX+�[���������務
N��9��!Kè��Ƕu�w�d�	;;����
���#�0rb����H�:�N�����%���D&���_��UM��3�s�pV�Dp�(�������[�e��������[ �Z�O�6,`�ع��»��޺l<g�v5����s���6l����R��m�핫�@�
+Z�G�s�-�a0m �����ak?���J_%Mo�`̿����
�+�X�-��#!��l�:	�pv=d}}�ه�����g:�GD�]AXG�^��Rժ�k��j��nW�;IL�J
����!L<<����,�c�%4Y�gx&�SO.�C?+�����L�M#N�	�Ȭjg�
�f���d?D�f^P�i^j
����;4-�;���?kh�6]��u����U6x��^	B�X�� y�ӷ�TY�F5^Ϊ��_f���:��TѢE$n\��p���G��,f�t���������d�텏ݔɓxi��0l$K��������0v"��?�W��'���M�8Yl-1�EȤ��0%��S�ڵd�c��޽WT���<$R �f�%�	��EP��?!���{�.'�<��g��'��(Q���t ����Q�
�@�^���D
+�9~5<�]8w*ĻHO�ҧ�r߼=W�m_]P�dˑ׾�����7NJ�K�^jۦ�,)�<{��l>�6�7F��=����1y:�ø4<��\�J����%7X/���
+q����6x�z�u�J�ҵ���?~jִ�4�P��3&��&k��M&�g�A���i3�t��珬}Ղ�E}��}Q������cio{}�����p<BS��/8tJ��+ʲl�bi��uD�I8L 4V�.`N0�@<H�y��q!�-[J�y����“@�x�+�	��QҮ��5׎=�mܰ��Z?����cT�J
�����lg]�:�EH�����Ɯ��`��p_����������C8�dɒr\����H����b^x͚5���
+a�J4x��5�&���],q��c�͛S
G`��xXJ�(?'�02��q�e�o�6ʹ�8(�?��WMj�-^�v�G�CY��\�2�}��m����`����g��=�'��u��NԖ�tK�PU�[����krZ2� �$S$�*�1�d��<�������蚫�mu!�7l �<ȃ$�r�5[�D�9=
�B)!d
+��E��9��X�׾}[�ݳ{D�'_�1�hڴn�租�fUcSY�3�~���\�2N���?�4~H���B�ѣG�z}}y�skA -SIf���Xb���Մ�L�(^|���m���/POAR�#��Cx�<���R[��s�t���G���� ���2ha����.��nj5(�
<�Q��A������…�-���J>w��5����E�x���~��b�I�œ!��ɵ[�:Hr!��ѣ�lU�������M��!g<õΚ�<�1Z�O��D�6�<LP���e|N�s<���a-���<�Lv��.�FE���)m@-�ɧe�:{J?U?"��������عb�I�!�.���U�+ڱcW(�`��|򱔁q>T�z���
+X��)D�w�Cx������V�����ܰz��v�k�*i�#�WS��w̟}V��W38�bI�b�غ��;��
+�Iㆡ.���1�0�
+������
+5�(�Мק��FΊ�/"��@��v�q;]�\_E0`H�z��5~�hQ/�]�^�����VS-Y��`{����k��0���S�2u�ݡ���/���p(�ĢƇ��<~XuD����������90�^����֣:�7r��^��]�~���EW�8�9�E��m��6���K9�U1`�೐(���/q%gӏ�7�t6�_���-/Z�n���V�:,�g��R/����$�h{��Ebo�~��jM�
u �F+�x�Nt��Ӟ3�zlظ���c�vj��=Ƭ	F�ؘK�hk'�MK����	B<�y7�Z��(6,�]�Łpr "�f���O!\L���gai$,���c��6�Y�d�qNJH{ꉈ<Z?o���%o`K}8��Y��>l<�p���!❪?���ٿ_��~�cq�>�~�Rk}y�^�X��=y2�C?�x�E�DJ�cFGD��/���%�����>TD��[�6/^TTz�5zі+W��p�HG����„{X�`������m�bCϑzÛ�����ڱ�>a�4ئi�
+	� �]��A�'iUG�VՂF�8�(a"QK;�	b8�KBr��i�;��a��lZ�C��!lO@�	�����־xaqH�WPP�dD��G4��A�0(��o߮�C#D�hH�@Euq�Ǝ����i��ۧ���wV]md������~PqC���*��UPP�y�ɱ�	�_�B�jY��0&�����C�k�/qm@�yٿ��t���V�MN5�8�8�8�8��V�P��c�Mu>�m۶�l@����ӇYA�G��
+��ݻ�h��Q�3�iAԳg�&�(ĪC�kW�b+H/�tE���O�#B�J7x���@���S����PjW�z�עM�,�8T�i��}��r�ƍD�	��Ż<�!l������)(((���0 �:V=��s�ݾ}�=6�Wph�a
+q���@�yӧOGݺvi�� U4Wי����hH���l)Ԍ=u�7�%�=��$B�`�
+{I�h޲�swؗ��`	����e�f�`�V*~UAb2����~�����������@�s ��Pab�ZM�2�%\�e<��ŕe����ܵG<S5�T�PAjܨ�d����S�NKe#;�V��T���잽�x�5.�[�����ߏ�^�F&L��a�j��:ݙX���ϝ������;���h��%����w�o�޴ic��&	�;w�ڵ�Y��C<�q?�\�Z�E�ooo��a�&_�zU�e�sl��
�5Ψt���7q ��	W���>�x�~�&�PP��#�`�t�
+�X��LJ�|��9�AyQB�4��T��B��c�s����%x�.�iy�͎��*V� `q��!V�9Z������p��!
���Dp���T��T�(��F��5׮]7��-�SVՃ�l�&��T�RRTxbJ��ٲe5�Ӗ�72)�gd��QPP�D$����[�F���\�Νg�B�2mqvh����g�K6�Y��v�
+
+��v�B��n�R�*��
+�
�6��-��l��X���	�˔)#u��Yh�%܈D�O�T�xY�.t��mՎ{�<��E$
��f�O�N%3g�`����u떔���2Q���6�44`!�%R�WT����9�C�K]�Q���+:���`�.�H�%J$ 	��P{A:�Ge���6nɱ'����^��T����v�ǎ��s�Ή����g"	Bpߌ3P���������^b
���\�BC�$x�"�@^dT�V��#��^Tw#&��������x^K
+���d;���i�v���*�5�a���ϟ����ٜ���K�y���P�������x&�U��*T��1�DX���-�ݿ_ֳ
|Vv���j�p�BT����0Y��`������ٳ@0p�Kl���)
+ÆR�u�7�Ν�(���P�<@����-X(���z�	�j+��������8���%��OXz�1&�`�s�*����WPP�D�ӆ
+������-;-]��:�'N�d�>����?%���Z��eg;&M��<$vW�^��'N<p
+r��I~~�D��ϋ��C����6/��cHd�>}��n8�v�t���Ç����"�k�+*6��V�w������5��}	Ǧ+Wqȣ��vK<��Q'�Á�x�(!킝W�$�%d
+Ԁ?����(	�q�ocǎ��L)`N%0����8-X?���X&
+��y7nܤ	'[������"�
+�E6�U{���ap�]/�n��!(Dhg��"�
+�E�U;���vr@<;��)(X�xVY�N(((��
+็�UŁ�F�����i\j,���Q��(yr_���1�2E���8�8�F(��F櫦Bs@��<Q)���r@<G9��+((D(��P����	��&Z
Sq@q �p@��r�T?<�
+�y��Q}SP��P/^t5d��s@<��TU�8�8�8(���(��������^r���%��c�,E����r@<G9��+((D(��RaI)N�8t��u
+���T��&��0�����@�@�	w�ޕ%���PT������xլ���5 �1Դ����ݳ�M�+((X�xVY�N(((����K�"Ō�nܸA�_�vOGT���Q�
+�E�K�:�8�8��r�ō�'N,*Z�j)((8���ʫ8�8�8I�/y�d+Vlz��!=~�8�ZV�((|P�C��j�� ޑ�wL����� �y�UVCR�((�Q�����̙�j֬A/^�e˖;5�1b��).�z��޼y�T��1z�ԩ����t��)֦u+J�ҏ~�#�������ŋK]�t�7.c�豑��ʕ������֭����Զ]�X۶�)iҤ4�B�y�+�4�Q��'�駥鯿�o��nLw��x�bŒ1�%I��m�nE�{ڕ<Ru)(X�x���SK�(N={t��ǎ�С���E�L�OJQ����c��?DIX��M�@=}��%����+�{�:z��Kc{}�M%jִ1mظ�,X(��6u2�M��:w�FW�^5�J����жm�сC�����	��s�g�ծ�p׊-"`��ѣ���S��+*q�:l��c�vT��g4{�\�嗭�zr��S���ݏ.\�`�Uط�^��Μ9�>��ZT�v-��6�}�Ȟj�ʃg��'�ҥKS�:�����zl1N��G���„
+����C��<��]#��~��1�#/~��Խ[*P ����1���R�@�'���J�*e̘A�<y�Ǝ�@�N�6�ii�^���C����q��):s欀F}~g$;�?/G�X��i�f}u���1cF��~M�Ν7�
/��@N�>���yS��52s�4J�2%u��CVO�ҝx�~~~�	�+ޠ�Bݻw����.ݹs�Ξ=�:jm��Ǝ���M�4%�:��[xٲe�ѣF�j�x�O�|M����,��U�&eϞ�֯�@��-Q͚�)G��9��4�P�KjѼ)�IҼyB��
+Ϥ-
+u/�-�Ua)�]�i���R�p�԰A}:v��]�5{�lT�㒔��9�xb������K���`�鎝;E�('ԏ��@�p@�Has�7ҤIc~qW���],U�8q�1����ƍMҧ���1X�x	]�t��G��/�(O���!y�
)�
�v�%KJC(M�ԺT���!�:u���7*<H����i2k0�oޢ��tTx�U�T�B,�E�n�`����V^��	�Ӣ��%�淵��kZ[u�Ν[�j�	�˗/�Q�f���[�<��޽z$��&O��;w�ԩ�`	z�����fӯ��&ɐҕ-[�ڴnI���f�d��˛bp`���}K�}��V���;i��Ur�|�rT�z5�s�Z�b��-�5e�8@���s-ցD?iO������I<x�|��()?�)R�i>�>}�����M)�P���@s@�f�����dP�ڢk׮��)�x��������o�a~l�>T����_Kڻw�?QK6n!�7v�K�N��a�&�A�:���m��f��ޘ?*��,����mƾ[�y��%���/�SQ�5jԐ�U�,��p��c�v�{�Œ���-H$ʐ�c�[�d	Q�_f�}����������q%���Ε/�9�mӊ���ݘ1�Be�h�W��dz��{���h���h„I�����Y��JGB.��a�����T�0Uhؠ��e+-\�X�}��8ۿ�tA?��W�gs���,��/f�6�B�YroN��͛7/U��}��m8h��m��UNJ���x���Ԧ�U��ҏ?.s�_���U�P�F
Ll��i�z'���:v	U�\��Ծ]V^�^��RPP�����$*5///jպ�H
+p"*�������>G�U��/ZRp��D�Ym�jU�Q�����q9q��!B�۫Wwʞ-���	�+�od�	�Dz�9�L&0�0'w<�����O�-L����?�5�W��<M��g�
�(�R�>�N����lk�N�D������f�Z}3��P'�#a���-V���x!l��'�hϞ=�Ç���<K�ҤIM���������E�%�|<���rWy�F��������0�z@����*,��(�c�?o�Aݦ/7|�Pʕ+'MaU��П��];�'�Z�X�IDATr5����x&,
+u��)����Q�9܃��~�7Ϝ=�eg���F�-���E��I�8���x���X��B
?|�(��GX�(QBZ0ߠ6�]�~��P�%<[m���~�:���ۖ�Kw<L~B�=[�sժU�1�i��7�|#����ac��RC�R��s�"�?~B�Z�g�AÇ
{�~�ퟬ@�<w��b#ؤi�l��[%((��
+�1/�ak԰�b)��[�X8�b�D�R��w��={��l�|T;�oK��������2���x�k�ͩb�
+��~�R�-��Jx�G� U����9;6d��������^ˇ-x�t�B"�p�	8u�>(�˃��֮[O�Ir�=zd�#�R]�
�;%x�q�0���
�ѣ��-~vڵ�d���q��u�f��n�FZ�Ƞ"�U��1�GJ�����}t�Vvr�a�%��]���K�x��Ի�i��֮�`n�\��
+ap�4nH��$@��A�׵K'�p�cg(s�$j�+W�r��9B=�w��#G���[�UP0���@e���s��IEND�B`�
 --6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
-Content-Disposition: form-data; name=images; filename=testImage2.jpg
+Content-Disposition: form-data; name=images; filename=testImage2.png
 Content-Type: image/jpeg
 
-testimage2Binary
+�PNG
+
+
IHDR��
tڰmiCCPICC ProfileH��WXS��[����H	�	�	 %�zG��@B�1!���E�.�`CWE�
+���(��XPQ�E]l��		躯|�|����̙��;�{��I>�@��P����� u�xs.O&a��G(���˻��U'�?�����2�X���2^����yi!D��rr�D�gC�+�B�R�s�x�g)����D6ėP�r��4�A=���y4>C�"��h��8�'��!V�>��`�WBl�%�x3�;Μ��g
�s�9CX�׀���d�|����4�[
+��>l�
+����a
o�M�R`*����8E�!� �+�J�#R���1OƆ��O�.|nH�����c�U��lQb�[�)�BN2�/�B�T6�U�Іl)��ҟ�J�*|=�祰T�o����(&�AL�تH���β��(�ͨb!;v�F*OT�oq�@��NJ��a�*���`��F�����
+����`�x܁�a.�e���2�#���̅/	U�=�S�T<$���ʵ8E����-��
+����$�Z<�nN%?�-)�OVƉ�r#���KA4`��r8��D�Dmݍ��r&p��pRiW�
̈�5	�? �к��Y(��/CZ��	d�
��O!.Q ���yKO�F��\8x0�|8��^?���aAM�J#����$�C��0�=n��~x4���3q��<����	��	��D%����?LU���k��@NO<����Ǎ�����@��j٪�Ua�����{*;�%#��~\���9Ģ����Qƚ5To��̏���U��Q?Zb���Y�v;�5vk�Z�#
+<����Ao���A�?�qU>���Թt�|V�
+�*{�d�T�#,d���A���y�#n.n�(�5ʿ��	�D���n������?�My��������c�����<��H����Є'��K`�q^��P	�@2H�a��p�K�d0���,��Z�l��.�4���8.���:�wO'x	z�;Ї 	�!t�1C�G�
a"H(�$"�H&���92���#ˑ��&�ًDN �v�6��B� �P����	j��D�(�B��qh:	-F硋�J�݉6�'Ћ�u�}��bS��1s�	cbl,���1)6+�*��k���*ցucq"N����x
+��'�3�E�Z|;ހ�¯���+�F0&8|	�hBa2��PA�J8@8
�R'��H�'���YL'���w�ۉ���$ɐ�H�'ő��BR)i
i'��
+���AM]�L�M-L-CM�V�V��C���gj}d-�5ٗG擧������ɗȝ�>�6Ŗ�OI��R�P*)��Ӕ{������>�	�"����{�ϩ?T�Hա:P�ԱT9u1u�8�6�-�F���2h��ŴZ�I��
���G��1K�J�A��+M���&Ks�f�f��~�K��Zd--�Wk�V��A��Z��tmW�8��E�;��k?�!�����u��l�9���-�l:�>���~�ީKԵ��������m�������K՛�W�wD�Cӷ�����/�ߧC��0�a�a�a���2��p� �A��n������y��
��FF	F����6��;�o8ox��}����Ɖ�ӌ7�������HL֘�4�6�7
2�5]izԴˌn`&2[iv��C��b�3*�=����r�M�m�}�)%�-�[R,��ٖ+-[,{�̬b��[�Yݱ&[3��֫��Z����I��o�h�����c[l[g{ώfh7ɮ��=ўi�g������� t�r��:z9��9�� ��!Q3�Չ�T�T���Y�9ڹĹ���H��#��<;򫋧K�����:���%�ͮo��xnUn��i�a�ܛ�_{8z<�{��{�x��l�����%�������������e�31��|�}f������[���O?'�<�~�Gَ��2걿�?��G# 3`c@G�y 7�&�Q�e?hk�3�=+����*�%X| �=ۗ=�}<	)i�	M	]� �",'�.�'�3|Z��BDTIJ���S���y*���6�Q�C�4�9���Ys/�:V��8q+�����O�?�@L�O�Jx��8=�l=iBҎ�w���K��إ�SZR5SǦ֦�OI[��1z���/���қ2H�[3zDŽ�Y5�s���ұ7�َ�2��x����LМ���?�����#�37�[����dUg��ؼռ�� �J~��_�\�,�?{y����9]�@a��[���΍�ݐ�>/.o[^~Z����̂�bq���DӉS&�K%���I��VM�FI���8YS�.��o����?,
+(�*�09u��)�S�SZ�:L]8�YqX�/��i�i-�ͧϙ�pkƦ��̬�-�,g͛�9;|��9�9ys~+q)Y^��ܴ���L�͞�����J5J��7���߰_ Zж�}ᚅ_��e�]�+�?/�-���ϕ?�/�^ܶ�k���ĥ�7�.۾\{y���+bV4�d�,[�ת	��WxTlXMY-_�Q]ٴ�j��5��
+�^�
+��]m\����:��+���o0�P���F��[��75���Tl&n.��tKꖳ�0��j��|�m�m��������a�cIZ'���9v��]!����7���]���y�7s�}Q�Z�3���j�k�����ajCO����)���`���f����m;l~��ޑ%G)G��?V|����x����[&��=9��S	��NG�>w&��ɳ������;|�������.6�z����m^m
��/5]����>����+'��\=s�s������7Rnܺ9�f�-����o��St����{�{e���W<0~P���;�:�<y��(���Ǽ�/�Ȟ|������ٳ��n�w�u]~1�E�K�˾��?���~e���?��l����Z���͢��o����WKo|�w��ޗ}0���#���Oi���M�L�\���K�ר�����%\)w�S�����6h��a�F��Q���'����z����
�nn�gl� �&�U�i$���}h�D�����>���-��H+����������f,����=�B��g�8�KVA�7��O����;PD�~���ܐ�J� �eXIfMM*>F(�iN����x����ASCIIScreenshotȊ�	pHYs%%IR$��iTXtXML:com.adobe.xmp<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 6.0.0">
+   <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+      <rdf:Description rdf:about=""
+            xmlns:exif="http://ns.adobe.com/exif/1.0/">
+         <exif:PixelYDimension>188</exif:PixelYDimension>
+         <exif:PixelXDimension>760</exif:PixelXDimension>
+         <exif:UserComment>Screenshot</exif:UserComment>
+      </rdf:Description>
+   </rdf:RDF>
+</x:xmpmeta>
+Lr�iDOT^(^^"�\�8?"�IDATx��U�ƿ�M�!��F
+i�j��E@�R���
+
+�"���+5
+��RTJ���^	!�ߔ���޳;{ggg�;g����=Orgg�ߜ�}��w�;��	��H�H�H�H�H�"A F����F�	�	�	�	��!@�ώ@$@$@$@$@"@���ɦ�	�	�	�	�>�	�	�	�	�	D�~�.&�B$@$@$@$@��$@$@$@$@$!���l
+	�	�	�	�	P��	�	�	�	�@�P�G�b�)$@$@$@$@$@��>@$@$@$@$@"@���ɦ�	�	�	�	�>�	�	�	�	�	D�~�.&�B$@$@$@$@��$@$@$@$@$!���l
+	�	�	�	�	P��	�	�	�	�@�P�G�b�)$@$@$@$@$@��>@$@$@$@$@"@���ɦ�	�	�	�	�>�	�	�	�	�	D�~�.&�B$@$@$@$@��$@$@$@$@$!���l
+	�	�	�	�	P��	�	�	�	�@�P�G�b�)$@$@$@$@$@��>@$@$@$@$@"@���ɦ�	�	�	�	�>�	�	�	�	�	D�~�.&�B$@$@$@$@��$@$@$@$@$!���l
+	�	�	�	�	P��	�	�	�	�@�P�G�b�)$@$@$@$@$@��>@$@$@$@$@"@���ɦ�	�	�	�	�>�	�	�	�	�	D�~�.&�B$@$@$@$@��$@$@$@$@$!���l
+	�	�	�	�	P��	�	�	�	�@�P�G�b�)$@$@$@$@$@��>@$@$@$@$@"@���ɦ�	�	�	�	�@lԨQ1�	�	�	�	�	D�~4�#[A$@$@$@$@�@lȐ!�gg     �����ȅd3H�H�H�H�H(��H�H�H�H�H B(�#t1�    ��g     ������dSH�H�H�H�H��}�H�H�H�H�"D�?B�M!    
+|�    �
+�]L6�H�H�H�H�(��H�H�H�H�H B�~�6�t?X����Evn�P3�  �&ЪQ#9���ҹiSyy�zY�m{�	�D$@
�@F�'�U����f�>�)�~�@�Tw�v�i�[�V�$�[���اsj�pԢ�8{
�]_���*�q��6�ٳ����H�H�sJ����W�v6��t�n9���eg2�9��fwl�Dƴo����o�L��ڕ�8�@C$���_zR�q�T��%��Sb{�έ�Kr���t�[����Ė��{��Ӥ�$�Z����=U�J��zXb��Q��[$@E'��޽d@���K���-z��ΐ��'��#dh�֙��6y��ޢ�-�k����Kt�>���~��Mr�Y�����h�2?9�Lq�U=t�Eg�?T�>��S�:7m+��i־��][D5�7��O�GE���ϙ
���S����w9��&m
{�����%>�~�#��	��C����tT�B��7�-���o�N��E�껯�H�������3m 94��S��E*�'Q����6�o�h��r��P��Tь�w?�[�AJ�o_gĽ��	�r�
+�����$^�(�]'9�q�;=�~݇�~����E�w4s �x�j�z��x��Ա�4���@�	4t����{6Sq�,����E��d
��r�:��NW�gᵓ�t�3�w5�(�d��?*�*U;T�W���D��_)��\.��L^3�;*�a��n�ؚ���%y�o�Ͻ�CA��D*�����Ir쵩�����'+g~ (�@C8�!W���	4���oO��/S��z�4�(�����ؠsT�l��y'G�f���4�NBV'�:�vK�ٶVbf�(��
+_��3�ߗ��I���G����`���A��,>���+���*?7����M�nZ��%��9����{Zz&�B��bZ� 7&��I��ٜ��8/c��T���&�Kl�d��sc�Pf�Y�<��1����zg�P�Fa��M~O�f��Ƚ;Ȱ6�e`�V�y�n�����6m�h�H���V�0���C�Y�J0z��gt���H�Öl�!/~�^��y�mz7������t�~-[
+ڲ��R���!��d�����<V��}\�{y�^rx����?�Z#��$���S�л���{�c|������뵲�B�l�$��\-�k�h��}zK���d��]r��e���ں�lص[�m�&,_.������n]�[���W�&�K�$��5���_�m[Nr��i��ti;@�&�Ujۃ��\��C�W��g��Դ���`��]�{���R~S7o�Ǵ_盨i��b�?�1!>�����7�m9�{��/�:�-[x��is��<��н�˗u�O��DVT�'��߼g�����3k�ʻ�}T,+f�	{��m�M��c��F���C�?6���<b�]�����$�yO孑e>�)�󠳶��Ĥ�սD]W<�8]���1{��������L�s�賿�N8M��7���W���r��.7è�+�ҏ9HR��$d��8�ӈ7���7O��qꜹ���?e�Ry�D�H*+$�m����xJ�0�~�����y����ɧ������{�*9A�~�G�E�1���!�L�����zt�&�xV6����5r���Y���6��ǵ��
+^�~TQ�
Q�Q�[&~�o8��(kw�c�� �[�"l�
����o�D��f}(�� �>��)CT��l���S��� �/�9G��X�Ku���^=�R�qv����@��c#�^m
��0��o_��In�O���C�3�.S_��۳�m�i��X�v�����w�^�_�/��m�*�}85�����y@X^ܫ�$��k���w�;u4�ч��߃Ųb���m�M_,a�M�r�)w�Swb/#�f�c�".�:���!�	���Ǵ8�񉆆�H'��eB>bt�0G�o^�[���Ζݤ����fl���q��?Lb=N'��;Ll�t�����c�eU~VN�W�S�g����NR(��y��:������}�91>��-=+Q����S>�{*�9N����C���۸�$^��Z�R�?�����ur�+����3ub#�]�~���
+�L���F��U������T��m�B��Ȗ��w��&6����1Z����F�_� ���Hms�?F��6JUo[�?0؏�G���RY���Ud������V1��!ݔ�`�.{�^�5eZ�H�-?���[��:R?Y��!����u��u�����O��_G�ʈ#����y��G�t4oܼO�`�y+cv��<�<\��1z���������e������8�O��q?�-���fj�Κ:=��6�/F���C������m����j9�4�v!���?5¼�D�e���7j�w�p��������M.�2��om]tl�os�����?���|�@��i�_�On7%W'��/�ϗ�y9.$*�+!�ӓD��۴$�5�#�P�~��UL�|�HE�K���XI��N�:ո�xr�-ߓ��g��Qw��fwl�%>���S
+��#�&?��������B�D������aH*�mk�]�	e�_��'�Q��v�Ė�R}�ʹ����4s�;�Rr�����Y�ܸY��_[��f�> ̎���q�p�����i3	���nפÙa��)��C�m����/��G�!⯛7_**�&o���o�P㶁g����B�k��{�ꪮ�������g��r	���_h���q��>��e��/�ܕl�y�&iԒ�g������D?�0\{�(�y�VtUZ��������zY���EK���}�Hd?�y�Ǐ?�.��=�,��t�[����n����7}��r�/�����qc�BW�
+�0��g���;a_�N��� ��������/��w���
�l���n݋�7�������_��O1��(-����m�.��K��>���Y�	�%p3��`g���|P��gV��n������
+3�l���G�H�z�m^�s���QW���(�$��e#��kH���o��o�A)�} �O�K�@���;�}�R���-�s���Ce|���g���w�kp_�e�8�D�׼?Я���A��}q�f$������6���M}���oR�W�yéa��O�V{g=���Vf�s߫���YaF}�Nj�o/0ҽuO��-x_����-u��(����m�?[~nz�wʇS���7�^��7�Q�
*~�n6���e�B�l>���<��i�^����8'j���6��f���+s��u�������b���¶�޶���7}��r����V����#�M�r�~��#�xP��w�o��O��A�/�0߶�nzԹ����ܙ��,�k�Z�$ә���ZvM��h�}���?�q�����&�v���Kh���{�$��f*u[J��o!v�N�3'�(O�H]y��-eM�]��>ڛdF(?�l��7mFN����sc�M-H z���7�׉�~�w�}]�oA�Ƽ9I��Ps�6��C��o�nD�6�Ĩ�8�{���m�n;���]m+}���uQ��-�������'���͖���k�k�7/�;�,���޾`
6K]p}�oY��W�]
+����wl�
��m���)E���m�������jS����rЁ�nŎ
+9����x<�!V���o�>����F�������O�',w��;����c����|�����M�5����k<H��Ip�K���K�F���1A�����w�(�,Y��dW�΃��͌O��<��Ѽ�N�F�+4�F3��"Xw���})�tc�̢�;�?t��\��1�
+_j�@ܨ��^��@�z�byT#��
.��	���F$�M���}}@� ������0v�)��7���_S�jJ_?��l���7�����C�� ����pkr�`p"�&/�I�o��]DB�����?YG�1���9�N�m��9���m���
+[o�n�������0?l�}U݁0O	Ѣ��o���}Z���o�����s�o[���>��L[7
+�N[
y��Y�5Y>��8-%f5�������Uȑ5d帼�98��Š���i�$҉��t�y�Ꮽ�_�ܓ�oJ=iQIHW�͘��'G}/墤;��Rb�����\m�V W�wm~�K�ֿ�����}����T��җZ�~��C\�	��:O�P�h��7kX�2�@�{����/��9��+'���{}�m
+�گ�l{�ۦw�Y1�R��"��
+���S�w����1� [=Ib;7e��윙��O�K�.��Q77R�>~���lfb��߾.�gf�X�g2��p��fo2uFN�9���Ʉߚ]x����Զ�1Q��$�q���we�9e��~A# &�Ugp��?���@ۦw�O���{���m�َ`�uJ�����k��ĶǃF�]�P篫b��B~K���쁣���D�����q�{�f��
+|���Q����<�V����L��ۦߟ쯊�.h�й��}��bGC����X�g�:"P����WL���[���8L��̜�W������Ӿf���0T�cVS��{$��E������{�
����ߍ����U@�N�c$9��H��_����g)��o��p�ijSϝ��dOd
LTG;�S��dƬ�K����M�����hC�a%D���k��w1��Y�>�}���܀��TW[~���z"<���"���k�����Y�����	��V��Q1�_h��u-�����z�A>�B�U�߽�����;9A
+��oo�f��$��(�m���^���#�m��^��.^n����c�c��@a��Z3Yc�kw�9�U����	|g��&�N�O�˄�tz��/�/�?������cY��`��0Q��_������!�eM��:J��4���s��,���`%^8C��)�:;�Fa���?����"�?ж�3Zz�j���F����*�H/�u�N�O'{Ͷ|o^�����v�k?�;IaD��P�^����@�����%h����P�0���0���P��]
{q:DlP������~>���2Kq�ե��M�[�
+�}���Fbr
o�0����������M�*�_�������m\M�������>���X�x�	&�{���tw��؟�j�cM˪�Yn:��wi��}<U'�^H�)f�(A'뚰�Zߘ���ʺp9�֚uPqk�M	i�K_+��U�����I�>@���t�~WVRLP6oIt�.&��߻%�xև0��ʠ�n7Ĺ?_G0�H��F�}��]w/Y&�i�E���@ۦ�N�XXP���U��$|����T$zͶ|o^���u!�>ٵ[�PV����{�/ƶ������_�������G|������������mZ��ϫ4�"��I]:y�zYð^���x�F���G�Suӹ��iu�]��w��A_����+��/��յ�����u,��綹.>��+.��C4�V�FrZ�.��\+���l���.�b��F,���n�-jT�m�m���S�	��}��@a��Ul�
בk�PٶZW��)��3Lt���
+|=)��&п�i�W���<{�6�"���<R'�~�����/4o�vfo%�ݠ�`G�v��"qD��g���y-��[WI|��D��֕�1a�,"�s
`ձ��0�G:�p#�D<D~W���!��eD����0"Z��l�m����i�্��*VRE����
�ʦ}x�5��yy`���Xɶ�����a����Q~�+����'���=�:�KÛN��1�~�ͩ�c��Y������ZA�Z0�1?�n���ܬ>�	ۮ����z@1��a�,,�C;tGz�3�{W�x`�!T���#+W�k�7Ȃm�L�',�>0 }�~��G�l���Qw��k(4�F}�<��	���
+�[�=�h�^�W�A�5���|��ߣA���IVj�������郘�݇�@�����߹p;�
+�^�P����m���\&�[	���hL��\�^Gg�H�F�a���"��vM��n:C�aҙ�4�L�%����T��������fm�}��s��-U�|[��~�	q�;��q�
+I�vY���ļ�h�'{��Sl�3���������P����>��c�Z�B~�xi�a�h����w5��
+�_�޿�_�~+F�n��E��EpƒS)�V]�3��/.�7
+�O��g���9���5�T��*�Ϙ<M� O>�ʹ�xV��$��G_ԇ$��M}��	Ӊ(3�����7��So�0���o{��sPj�v���M�kź~��n'u����`����ְ�+5��-��-��?l�����.��ȣX�]�:(O�Á��k6��R��2�v�$P%��M�����fב� ���
+s��W2�c�i�f�#��	w�]��s�A�����,��<��釃j�s2���ƭ��;�ݦ�����_���LQA��j������{X���Qڣ7/�z�8�!�>!ꟓG�g��\���F�Ց���80g�h��n�N�� ���+qW�!�>&��JWoŨl��|�ݡIyr�U.O�jT"��Z��xP묂�k��w�[���\���`;,��~�ƫ��i��-��WT�(�u�
+�v���=�5�܁{t��ꊝn���zp+��y�4�:/�|L0�j�_W~�u�5<b�#����/F��2���{ӆ�.'���]���mV��u��gN/��Û���HL_��ԛ�H�Qa�O���i�����/����=��ռ�4�7�^�B�XP�oa���������@F�'�_R�r�u�?�����7���~�{���MGT�֙�|���P��S�֨�.�S��Pq�/��#���V���P�34�(�Ruk�xgiG��1�����b�)��"��Юk���'�w�ѻ�.�,���0�����G9�aC/�QƤDLC_���[����}�A����E�u�w���S[l�8���#0���&sðH ���f{�ۦ/7��V����}��^~lോ�^�D�j=l_��'��Z�J$@$@ ��i�h��Z�¼"�-�(�H�6�jC��u���zS�N.4�I$P�	<8|i�o]��Nߖ-$�vw�"j'�BX��p�Ϳ
��Oûf�q��W����'�����0�!�$  ��z��� ���?����D#� �?AT��O 6�̻���i���p�ۃ'F��3	�	�	�@8p�iw�����T��@�֧4to�sa�Ղ�*���z��ޙI�u[,K#    (
+�RPe�$@$@$@$@$P&�e�bI�H�H�H�H�(�KA�y�	�	�	�	�@�P��	<�%    �R��/U�I$@$@$@$@e"@�_&�,�H�H�H�H�JA��T�'	�	�	�	�	��~���X    (
+�RPe�$@$@$@$@$P&�e�bI�H�H�H�H����Z�fF1�IDAT��eUu���{�u�6����`cTl�"����؂�h,�Ĉ�&1"Xb�bDl����C�3��=���z{ι����yw�����w�=w�������k����iӦ�*$B@! ��B@�L��/�Q!��B@! JD����B@! ��|���|T�z! ��B@! ��6 ��B@! �#D���ԣ! ��B@|�! ��B@!0! �?U�E!0����"�_���}R��,���a|�y�Xj���׭�R�ꢋ�<�`q��όs	����L:|�=f����ŋ��*n{��ǟ(�|���o��2��-V<����i=\��[[Z�,�T���ϼ�rq�����<׬s�u��e^�6+�Wfջ��Ϳ6�?l��r��6��l�:��7/�
~K,�`�ۊ+�&M*��UO<ٳ�[y�b�&O[���pu;i��:�F7[f�2�?<����W��iK/UL^b�����%���#�ϰv����A�D��.ϳ�a��a�D4�u�ae��-�x����>�r��W���3~���s�O<Qt��m���;ޘ��g�%�(�Z��D���M�h�[oQl��2e�˭���1Q�u������K/g>����d�2��x������p����3�a��'���(c�����+.�Hy���:o�b}Ҧ#}ܣ���w�ԍ����jy�_,^{ޅ��#cC7��3����_/J�����l�I���{W��r�Ż�f���
�&=���D¿gm|�Ը�c�a/ɉ����k۸�F����.��]<�A�O�X��^�����b�[-�lY����M��у�0����8���s�DŇ��6[��<���n�^j|
Y�܆�����q�����w�e3{�s"^�����X`�4�ƶg�c��D���9��c�M���fŎ֩�	m�s�F�+��������m[�Q���s�.��x�f��g+�X|k��e�O��k�S�.�lʺŇ�]�L��]2:p���h���K7�X�t��e�����v\e��&R�5(�A�2u��m��6����<�h��+���o�ۦ`@n"e�����M���o}#������OWZ���K/�^wѥ�ե����n?�{q��{ѯ�u�g�x]~?����Z����}��o����?����_X�ʫM	�ͦ����ۋ��qט ����I���l��*Yl���o������g�]|������, �a�\_j�f���lYL_zv\��ˊk�|j��j?��?�P��	ITBB:O0<������oYm�d�~��5�E���P’�{/���̈���7�?��۞y�8�VB�{�������n�E�R��|����_�{�h�?��f���l�^����LMy�E?���p_߰�*��7�Z��k7�2���P�fva�s9�H�?�r���xI#�'n����+Ӥ�����]x��| ��z��߫.��A�_U�~���6��s}i�
�w��z����xs�{�|���Ef�Mݾ�׵��M�'�a�Z������>k�V|y�
��6�y��O$vGY�t��w�����Ż�\�@�������o"�_����/��M�߻���g�_o��]M�@{����.����u�9hݵ�OL�ܨ��I�[J�ߚk���8��[�(���ڎ?�=7���9���QG�7Zj�∍�|F���FX7⊜��c�AW\���zoSV|���%l�rΣ����8�h?��??q��c,!z|�鯍�ӇQW���R����]|r��孪I�����?�#���ďcu�n��A���o��'��ۆ�֕s�ܟ����.��2�����x�>m-ZZ���Z�Ζx�xw�A}n��ˎ�4Ѹ���7��A|�-�}ִ
+4`�}m��e�:ayeR2��z�;-?{��j�d�u��u�;�@��ⷖM�И�%��f�{�v�2���:�C[�@ΰ��C���F�Y:��o*5���m�G�H�i��b˖��|z�n|�O�aF��i+f������ �a+	����^Vk�ʀ-�M��kY�2x�L�4�:y���z��o��e��{~�~����l��Vq�eW��y8�l��<�����k���|���5�:�����k{^��ָ���h��ϘF0.�_�do3����/<��H�m��O��'���������+Ʈ���2�����cn�1��|ӭ�&��"�fƶ�m^����iʠ�[�1���B:�-�`�U�A�������3�OM|/�O&�Lґ�����6ApY�ޟ��~�b3���m_Z�-��|:{����L���B�/�lq�Zk�[�q���3�� �gN|̛��Q>h��8��wߛ�gc-�h0���������ڴ��LK����l�ˈ��ڴicZ\���i�b��(�{�����KGm�=��^�@��|˖8�,qz�����I�Z[zA���ο�\~�;�@�L�~q��69��}�Mb<��	dž���9�����&����;�X����y!G���%Df�캓uf���?؛22�8�lz�4���M+���Z�X��z��_Z��{�^s���z6(��5��\�L��4�I>��_L�̝v('�aK�*�	w�[|���c�1�]�ߘD2�L���wf�����:��x�A�?]�o?��D[iLۊ��x��������洍Ks��&����1Ƥ`nKn���?#�<�S�`���#�>�ߚ�,&G���
>�C3q��[o+��~�
�D���䣉�.7~,׾���[La�N�WI�����{�cBv��}{Q���v"^�%�,۝d&4HԈ��8���؟�Q���b��Fn4�v�U^�̞o�S�t���/k�гwٱ�������]w����Ad�l�n�i>��@q�s��i䂁��f�D�(C0�4��g���#��N1md�F�k��,U׽�#<��fl],duH]��&i.�B�`6�zU-�F��\��
+�E:A$�?�]b{. q�a"�-������n0��ަ�@~�����7�=P|��9�Z��,�ml��x���j3%����<]�(���������US$V&V�|S�7n�mt����M�hd�(��P�N��ֈ����
ʹ�/m�_����@��6���Z��C�����<
��9��l`+�%VH����ߪ����&�S׶���
+;Y�᳦��=a��W�=�ӈSƮ�A߿����L��9i��/�MqǚRF�`R����%�
u{7��t��
3�g���M;{҈m�ћOkD��״��c�Kl}��%�rĦ�fjK�����f~5c��	\�UVV%�	6��_Ӱ������^��~H7����C�MY�/٪��E��R�2���מ�o��q��������yӅW:�p��K�^������^,^}��Idn����_W�߅���_������k���s�܅����.,*��I����/)m�S
>?@�72��Upj"���S|���a{a��6ˤ6d��]����T�Y��Y;�P��M~1���^aP�2�
�k��h,�B��v�6{p���N���)
+�����`g����cy��{���a
��K�vE���ހ��f�l\A0�����=����W�R>Z��Y��کF>iK��2{�a�*׳�\��#�/��$ˁ�E�K�Sm{�f�K-�S�?k�*�?���?��oF&>LX�� �6�����#3L�h� �du�z��t"�;@��W�c�$���
+K���6ǭ&�Hq5k�„-����Jn���h���}���'�z��`*9�S���f�O�5����\���Բ�'_,{��.-їm?N������}��B�$����l��^��o����t���J�j��ǘ�E4�iJ���wSO���n��J����֔�U�Al��i���]4GX�̉����������~H$����C)�_��\�ƿ�v+�l��C�X���I���������J���\���7��_������+���?���8@�DŽ�Jz�_�������oX��F�����%�1Rz���>��~�X�@;�&q�;�*MZ`��N-B�ĥ�ԋGU�x�AD���b�?e��rՓO���¦Y~�}2ڳ6�Y!8�
䯍$�����˄{�녟GC��3����ీ�}:�_�f��	�N%�_�Y'�eF�Y������=��k2�gZ��W\����8�ܒx0ID���x^���prv�c���YA;JH>6ɩ�"hM�/'��l҂}$Z�#o��,Z04�K�,�"U��ٗ����4Ra�A�w��b�Ŵ���"M��~���^��_|�����J3�*R��p?����������
}��j� 2����Vg�n��/��f���7۴���p�md��\��G��%��??�Mj�A�wl�O�:�' �:����t��	�;X(��[���w��D��u�q�6�?b��M	>�C0���:�`��!4��A�L<����1[m^�M=_A����wf���;M	~��C~�����0�/2E�ƣ�4,y��|�q����8��j	}B��ZG����;lW*X�F��~���1-���.������É�U������L���-7%�<{���g���/�Y(ZC`�0��2�Ы��=�����B�8ӈ���$�m�G�fd4�,�� ��:�-���i�9���p��_&��_S�5���+�8��+j�cV���|��Wf���`c-�j&���A����G��z�]Q6�bg�D�;|�<���2u��T܋�7�����@���m#z�b�����;�6�>6l�����[F
+�8���Oy�mF|�~�4Ƨ�渫4iL$ζ	�2}H�s�
L�y�X���GZ?H��5���ee���+��ge%���V��m����������E����E���?�O�t��㶐�.��9IhچК���=��'��\w���?Nм"�((	��MfW�XV�nJ�	Wi��?�����!��hQ��7��4k��[��b��2�!̧���y��nS�O�.�x����z��
ެ��g}��+|�������)��{�=h���EЙ�q�
FWb��'��?��uW�Oܶ��8�?���?���v��<�D��M���&<�&�Ґ�Q�{`���M���nr�=�����oM%~�&�A��. �P�n���`�芫
��79�A3�ӊǥ1���=���^��8ha�3�-���
�k�cX���w!�_4��!٘���玚����
��Rx¦ ��D�/�x�'���-�P�ԗܤR5@�������o�7�a�pXI�3B�ǒ�J����W=��Q�|�#��tS��]7i�S+nw�&l6c�q�M��r�e��}h�O솱K�������B[~��"0�`Y�:����"l��;YiK��d���ѳ�jQ�����w�z֫<m	~n��u�q�6�	>
+���l���)7R'UuІ�Gof���i��=c��ˉ7��?��#]�C������K?i�L��1�=�ޅ�wi?��z�A��.3���V_�h���,���+�����G35V$�Xv�b��&_������������<s	�?��?����=ܐ�+U�E��E�Ix;3�H��9�Igq�����CL-�m�x���{xH��/��,w�e�C�|H	�/�\A8?d/Vz��瑛��S�ل`y\'����*R����B�Y�����er;���ћM+���y����v�h���p��[���"��'�eb�_���m4�\�ɶM����+h�����S\���M��}'Ktһ&�|�P4�2�1�J�LJs��o����L��� n��s�&R�k��<�5�u�B��JH�/�*�ߦ��	�?�]0�e���;����=v�vS�T)<n�lK�cܪ�h:P�I�*��Y~k��'\N�������d�q�6�	>��'x�K�dV�iC���i޳7�D��Z��������T��>����=����N�z�-9��>+�(��I�O�m�qr�Oo?N��59X�јM������a��d�O�b�?����?��	j�0p;'q������t��+���?�3�������t����O�S�E��E��l��ܝaj�X�`ٸq#=^h�x����k Q܋��Bc���i�n��op�Ϳ̰ǿ&ˣ�<�2�^0���X�]>�CH��[�����F��X���0��~�8�D8��7ߢ��%I���
+I�/���u�����a����Z[&��3j��h��}����)����T
+�'+f�Q���Hb�^%Ͻ�򘕏�0xD�)�'w�,��+Mڟ�a#��@7��Л�Yc��%���������<�^�as�O�/���>8�D��Űu�]>��o�w��$xd���1y��#�J,S����t���k�����Ӑ���[o�����>�|�N���;H|�qx��d3Ϻ�<*Eaʼn�
��-b���&`1�xݕ�m?��z�q�&�k`�c�KP�!�ޟ��i�őv�8Ag���˜��1�S�\k��������iz]	~�I���SZ��{n��/�~��_�1�� ��IEt�6��m���|�p����Y'��ץ���,˧�-���/���K�"	��l�ﳎ��68m��ا#L�路
"Q����{��8M�/'��|��s��M��5Mޛ�{ٕ�n+�P�MH=�P�8@���gc/�=�.)4y,[�J�����;�X�����G#���M�R���m����K�I+�&�'�a3>�\0�z��W��F�m	>'T����Z�Q6ͰA����O��u����V���	ڼ&���ۘbcҖ����d3�c�NJў�)�w��	,�=l�ņ�����{K�w���.��+�'n��C����ۏ|�t3����!+�l�Gz�?(ٓ�s&K��
+��^�8A�7���j }��/�xӪ�@�/7���"�����HЫ��Ɵ�������?͛�&��u��kYI�����m��Ï���/6�򪊘��F���Ӫ��G��8m^�\��[����a9�\�Bjݴ��7�o��VN��Z�_��C�b8�1��/Jn�����y�y�A��R���zS�`�6�C��f�6dzc�Q�L�;@9l&��콱���kx�9�<��\˾ΈG���GZ^������M������:uO�W}�!��@*ܤ��7����b�^��EPҲt%�]��H�Ӳ�������L��+n�c�>?��"Lq��m���&�8�`����Y�o�~x�����O$�q���
h��_���8{����<ƾ*�"��I�C6�WIn�4Ͷ������[A� �t�>s��\��/���N�cS&��w����e2p;��}o*h����P�����l�����t�7;#�{�ɤ��H���`�',Aa7���O~��̚q!�nҌ�f�~��0a8q����X)7/(��$'�\��������Gt{��/� �џ�?a�k�?���2.���-Z]��BU6�d�8f�����k����x1����S/�T��4.t�t��M�0	\��"%w�"�xڡO�bu׃hl`e��x_�0��a�m�d8�����n0�2��TM�rۏ��1�'e��_L����VtR��/�lC��T���V�y�I����A�c�w(+^O��^e�����u$��]ް��ڏ���3�-�<�Q2QO���J�џ���t��d����2�K;'��˓���{g����d�	>���Ƌ��3�6RG�9�OD��ךI
���c�n��s�5���+�\3y}���s#>�Gi������?���n��������]��|w��3E��`*5�4�c�H#U���
+�Mg����j���]v*7��-%�e�����Q�g� �\|sO�mu���ɟ<�+DNЌ&؟2�3� M	>a�����e�bK����e���u��c�iq7iW����e�����#�| ~��zq"K��ߺ�0��n�mZ�����.��	|hT1�`�K���\���6�)����	T�^�*������d��x��-�B����1�"������Ǻ>(N~�4���g��s^ƊF���c���j�������/�/:3�ha�:9q����	$�ƃ�������x�����'LU+[����O,C�k'Դ���<N�
�����~;�*׊^�%&�H������;��9|\g5�������n1h���0�aʝ�����L	>������̋�I�ι*�������W��fL]�_,���on���L^]��OP�V�I3$�qٷ��:�$V5���O
+���&������v7��~��&��Mt񔻺<'�)j.xl�Yn[j��Jr�e	[�_��#.'�U�R��?�([��/���Z�R�~Ѱ��|��D_̱���*�>�F
+�āF�����i���(���$�u5�e`����r������Q�geB�h���㶐	&��b�ԍa.�gs��!n����p�y����Y� E����%����m��&uQ|ƽ��ro#]��FO$^�`�|̐�{4��d�O"!e/G��fx�B���܏�_GP��x|��.�e�xU���0aq��nx̍߫��g�J�?>er� #�Y5���h�ϱ�/ L��m�=��{�+Z��e~ �Q1�xU�?��D:�ƒױv&	8s�Z�TI�����GS!�/�)���izm���O:]�g�Un���U�-����o<��!�uʏO�1�F�$�ǿe�Y��E&-P�μo�DXA����c7|F�9��ػ�)ݷ�_^b��G4]Q��	���cK}����ǽM��vex���]ܸ�2�Qf�˲4yC0�5S��myp_�IF�����^r�dK�v�~�?�B�H��1��U�`�m��R�� �K�����?g�����m�é����l6����)�P�x�8���Dk�D����vbG�ffH<$u#F���-;/
+
'�Eɭ���!��|�x��o�۶<0���V�"�g�#����s��$ew���h�}yl�p��x���gj�������V=__�~iCQr�n�x��h��A��J����4������䶟�RĦ�}.�t���X6���ӓli���.��q@
+���z�7G�����'3g�AE�����90�*�⿥��E�c��(3�W�C��c�`�	i%m~GX!���ψ�s��j?��Op��P�gY���s�w�]c�`5ma���r��x�q�~���
+~s7��=�I�����=F��
+w��ia�W��S�`�K��j�o0���ྲྀ�Wn�Ye�ϳ��i���U9���a�/p���7���"�Z%q�VE�)}'&È�K�ύ��-��?q��O��ጣ��ߦHm�cۥ���]�q���$x߾o}��s��B��@?p��%���у��c��󅚃&`f���Ic�/��9~�p���� ��$u��o���{��[���������h1�Z��r���ϭ��4X?J�-�'�����O�?[K�ڧ�$�k�����]ھ���?��W_WY�e�L���v��٠	���-+�LfK~w�g��7�6�����s�і���=��c��&��M�q�"\/����6����o���?\`�
+��
�u>������I6�{v��?҉��t����}U�r��l��%�az�W[=?�ٖ�q�ݩ���V�(�pNSn"�A�ĉ�{ �.a2�S���>Nj��w������U�g�ԣx�������~���9���z]�
+���N|��7=`���㾑(?�>�ݦ�!^<�;���mǯ����l�,����?����!#��u{������Pٓ���/���[�'L�]�.7���?������OL81嬓^��89���ٕ�1	;qۭ��V	��1�G� �tԜL����9�4�î���8�Ʉ�ibV�OOpI����u���cf�t�יƔ�M��M1H5خ��;�ŧ鰄��L�>lY�@�jڐ�G��"��e�Z��t����M�u�6���/�%nzd�'��~R��ǫ�m����u����%� >��dkh�6�M�ԟ�cL3Yw���k��ZH��v��k�>g���LÏԙϴ��0�v��(~�G�Ww]��޵��yzg�ۺՇ�宲E�i��L4�&�Q�[�A�H�Yw�PN�����Q�
��:�(���;;�S�M����YE�لv���w���U7��K4�~�!���G�D���wl�ѷ��$Zyj�o�����?%��q���3���M�W$>��=J��M�O�'�v��ޯ�ޏ���������&�����ԥ�,g�sV�h۩i��٦�!�/�=r*n���r�Oǿj�~z�o|�W��R�{��+n��*��$S:��%�ݒ�M�0�px+R�dGOr���Dÿ������O���3���ѥ	���]���]�}<�2VK�c�hF]�#�IM�}�fM^|	�f�z�-/H�O|S�9�{cb��+���f�13�ar�F�\�Z��lKq�][�tr��#΀��	T��6��ܲ�?u���tsun���qNq/v�MNR��^q�ۼ���5����1�1����3��'��3��!\o�ŋUl�Ǔf�ó���m��ԍF�kr�E��6i6	���c����r ��6�?7~�<�V��;3�@��b�	V[�;L7W��|������'9��c9�w�����`c� ?m�N�J�5=iڴiL\$@�hX�J ��DjC8�lj�������`�wl�!KlP�ӂ6HNA�@+��Z�5����h�rl��N9��WD��8qŴj���3T�B`>F`n�7��
��I\�`����qɄ]�?��9���-U�h__��*��	�A ��7����*���T.��y'��q˭�y{��
^�77�f��PM�3'��4B`� 0^�M�c�^���ˍ,U�1�E#$unɼ�n<���Ʋ9�	}l����@@�o��(HsbC ��{��G ڋ�9�n�?�J(�>���D�;�%�8�*�#/�Xn|�Ĺ��<���=�k�|��>�}��*ㅀ���C�﷽6.��-����=���j7
zx}Np������	�! �!0^�M�Y}(�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+�
,�A�5�IEND�B`�
 --6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm--
@@ -945,7 +1178,7 @@

Response

Vary: Origin Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers -Location: /api/posts/jipark3/1
+Location: /api/posts/testUser/1
@@ -958,35 +1191,262 @@

Request

POST /api/posts HTTP/1.1
 Content-Type: multipart/form-data;charset=UTF-8; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
-Authorization: Bad AccessToken
 Host: localhost:8080
 
 --6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
 Content-Disposition: form-data; name=githubRepoUrl
 
-https://github.com/woowacourse-teams/2021-pick-git/
+https://github.com/bperhaps
 --6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
 Content-Disposition: form-data; name=content
 
-pickgit
+content
 --6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
 Content-Disposition: form-data; name=tags
 
-java
+tag1
 --6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
 Content-Disposition: form-data; name=tags
 
-spring
+tag2
 --6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
-Content-Disposition: form-data; name=images; filename=testImage1.jpg
+Content-Disposition: form-data; name=images; filename=testImage1.png
 Content-Type: image/jpeg
 
-testimage1Binary
+�PNG
+
+
IHDRx�9R7miCCPICC ProfileH��WXS��[����H	�	�	 %�zG��@B�1!���E�.�`CWE�
+���(��XPQ�E]l��		躯|�|����̙��;�{��I>�@��P����� u�xs.O&a��G(���˻��U'�?�����2�X���2^����yi!D��rr�D�gC�+�B�R�s�x�g)����D6ėP�r��4�A=���y4>C�"��h��8�'��!V�>��`�WBl�%�x3�;Μ��g
�s�9CX�׀���d�|����4�[
+��>l�
+����a
o�M�R`*����8E�!� �+�J�#R���1OƆ��O�.|nH�����c�U��lQb�[�)�BN2�/�B�T6�U�Іl)��ҟ�J�*|=�祰T�o����(&�AL�تH���β��(�ͨb!;v�F*OT�oq�@��NJ��a�*���`��F�����
+����`�x܁�a.�e���2�#���̅/	U�=�S�T<$���ʵ8E����-��
+����$�Z<�nN%?�-)�OVƉ�r#���KA4`��r8��D�Dmݍ��r&p��pRiW�
̈�5	�? �к��Y(��/CZ��	d�
��O!.Q ���yKO�F��\8x0�|8��^?���aAM�J#����$�C��0�=n��~x4���3q��<����	��	��D%����?LU���k��@NO<����Ǎ�����@��j٪�Ua�����{*;�%#��~\���9Ģ����Qƚ5To��̏���U��Q?Zb���Y�v;�5vk�Z�#
+<����Ao���A�?�qU>���Թt�|V�
+�*{�d�T�#,d���A���y�#n.n�(�5ʿ��	�D���n������?�My��������c�����<��H����Є'��K`�q^��P	�@2H�a��p�K�d0���,��Z�l��.�4���8.���:�wO'x	z�;Ї 	�!t�1C�G�
a"H(�$"�H&���92���#ˑ��&�ًDN �v�6��B� �P����	j��D�(�B��qh:	-F硋�J�݉6�'Ћ�u�}��bS��1s�	cbl,���1)6+�*��k���*ցucq"N����x
+��'�3�E�Z|;ހ�¯���+�F0&8|	�hBa2��PA�J8@8
�R'��H�'���YL'���w�ۉ���$ɐ�H�'ő��BR)i
i'��
+���AM]�L�M-L-CM�V�V��C���gj}d-�5ٗG擧������ɗȝ�>�6Ŗ�OI��R�P*)��Ӕ{������>�	�"����{�ϩ?T�Hա:P�ԱT9u1u�8�6�-�F���2h��ŴZ�I��
���G��1K�J�A��+M���&Ks�f�f��~�K��Zd--�Wk�V��A��Z��tmW�8��E�;��k?�!�����u��l�9���-�l:�>���~�ީKԵ��������m�������K՛�W�wD�Cӷ�����/�ߧC��0�a�a�a���2��p� �A��n������y��
��FF	F����6��;�o8ox��}����Ɖ�ӌ7�������HL֘�4�6�7
2�5]izԴˌn`&2[iv��C��b�3*�=����r�M�m�}�)%�-�[R,��ٖ+-[,{�̬b��[�Yݱ&[3��֫��Z����I��o�h�����c[l[g{ώfh7ɮ��=ўi�g������� t�r��:z9��9�� ��!Q3�Չ�T�T���Y�9ڹĹ���H��#��<;򫋧K�����:���%�ͮo��xnUn��i�a�ܛ�_{8z<�{��{�x��l�����%�������������e�31��|�}f������[���O?'�<�~�Gَ��2걿�?��G# 3`c@G�y 7�&�Q�e?hk�3�=+����*�%X| �=ۗ=�}<	)i�	M	]� �",'�.�'�3|Z��BDTIJ���S���y*���6�Q�C�4�9���Ys/�:V��8q+�����O�?�@L�O�Jx��8=�l=iBҎ�w���K��إ�SZR5SǦ֦�OI[��1z���/���қ2H�[3zDŽ�Y5�s���ұ7�َ�2��x����LМ���?�����#�37�[����dUg��ؼռ�� �J~��_�\�,�?{y����9]�@a��[���΍�ݐ�>/.o[^~Z����̂�bq���DӉS&�K%���I��VM�FI���8YS�.��o����?,
+(�*�09u��)�S�SZ�:L]8�YqX�/��i�i-�ͧϙ�pkƦ��̬�-�,g͛�9;|��9�9ys~+q)Y^��ܴ���L�͞�����J5J��7���߰_ Zж�}ᚅ_��e�]�+�?/�-���ϕ?�/�^ܶ�k���ĥ�7�.۾\{y���+bV4�d�,[�ת	��WxTlXMY-_�Q]ٴ�j��5��
+�^�
+��]m\����:��+���o0�P���F��[��75���Tl&n.��tKꖳ�0��j��|�m�m��������a�cIZ'���9v��]!����7���]���y�7s�}Q�Z�3���j�k�����ajCO����)���`���f����m;l~��ޑ%G)G��?V|����x����[&��=9��S	��NG�>w&��ɳ������;|�������.6�z����m^m
��/5]����>����+'��\=s�s������7Rnܺ9�f�-����o��St����{�{e���W<0~P���;�:�<y��(���Ǽ�/�Ȟ|������ٳ��n�w�u]~1�E�K�˾��?���~e���?��l����Z���͢��o����WKo|�w��ޗ}0���#���Oi���M�L�\���K�ר�����%\)w�S�����6h��a�F��Q���'����z����
�nn�gl� �&�U�i$���}h�D�����>���-��H+����������f,����=�B��g�8�KVA�7��O����;PD�~���ܐ�J� �eXIfMM*>F(�iN����x�x��ASCIIScreenshot4���	pHYs%%IR$��iTXtXML:com.adobe.xmp<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 6.0.0">
+   <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+      <rdf:Description rdf:about=""
+            xmlns:exif="http://ns.adobe.com/exif/1.0/">
+         <exif:PixelYDimension>138</exif:PixelYDimension>
+         <exif:PixelXDimension>632</exif:PixelXDimension>
+         <exif:UserComment>Screenshot</exif:UserComment>
+      </rdf:Description>
+   </rdf:RDF>
+</x:xmpmeta>
+�[jRiDOTE(EE*�V�xx*uIDATx�]`U׽$!	�w��D��"UT,���;��(U��"M�JSE�|�" iҥ��B�s�d&��&,a��ཚ�ye޼wv�9{�KV��R19{�*����^�zJ�ǫ��w�˂���3�T��/u�E���1��ul���J���M�r��1��7q��Du0E@P7�)�s��*���ɜ�g�њڶ� �O��:F�V-�N����R�3������������DF	�]����"�((���^*���J߾���G.-AAAҼEk��^Ǩ^�I�P��׶�*sd�&y��~�I&L�tSא�`ɜ9��F����'$$D2e��US��fȐmǝ���(��������VPE�(�����_��BR�O��%v�u�֖���z%x$icnj�|�"d�k䭷9�I�2��7Fr��)K��*#G�v�ҤI#'�+Y�d����L�8�i���n����hE@P"�ϟh�Cc�S�P��2��1��7�.�r�R%e���朚�Z�����Ѧ\�\Yys@?s~-2R�ԩo�}}Q�������;j�"�(��?P����1�)�G
ބ�c�	w��
ҿ����R���n�d˖U�/�M�
ᴥO���q&�`��E2v�8�͗��IdT���;�}E@P���<�y��O<BH?����;t�x����{���m*��nW��yG�n����Z�(��"�����{p�3�J��py�kw9s�{mb�]�ƳҼYSY�x�L�6#������%KHN��m޼Y<�ױs��-ŋa=$�6m����j��%��/��*W�\���w�NTSE@pC@	�ZPE@PE �#�/鿇�E@PE@P�P���E@PE@H�(�K�@PE@P7����E@PE@P�>J��{�+PE@PE�
%xnphAPE@P��������
+E@PE@pC@	�ZPE@PE �#�/鿇�E@PE@P�P���E@PE@H�(�K�@PE@P7����E@PE@P�>J��{�+PE@PE�
%xnphAPE@P��������
+E@PE@pC@	�ZPE@PE �#�/鿇�E@PE@P�P���E@PE@H�(�K�@PE@P7����E@PE@P�>J��{�+PE@PE�
%xnphAPE@P��������
+E@PE@pC@	�ZPE@PE �#�/鿇�E@PE@P�P���E@PE@H�(�K�@PE@P7������/R�H!�ӧ�#G��햃�%�*d�۵{�����ױ�y���N��k�/��/�ӧ����o}2e�$������s~SRE@�wP�wᄋ���-�K��u�����O>�T�_��u��ҥ�"E�S�N���{�ƍ^��r��w�������)�����ԩ�I��9���R�q9�7y��Β;W.oM>�m��O��9N�dɒI���q����_e„IN��(��"�(�!�$^ʔ)�m���t���/,����'I�_�zU�m�!?��@f��P._�|W�_�PA)P ��Ϗ�|�$mڴr��Y9x����Y�v�DEE9sH�*����%�̓%�,���ݭεЬY�{�}Nձ��e���N�'3f���ޗ��0S?j�XY�x�[���/u��y�ƺr�,]�\&M�����!Ή��K~�Q��8��.]Z�~˔In����8p�i�E@PE�O�~�)>t�d͚�����OH�~�姟�}M4�ܽ�<���	�}d�Ï>�_�)�Є���ݮ��Y��s��\#�&ŋs����+�^z�)�[��g�1u111Ҷ]9v�ӧL�G�o�7�4ĞlܸI
*$|����o�~��o]����￯�s缛J����7��C�ԩ��?���PS��zȐ�N��(��"�(�h�Gr7}����}�ֵ��ɯ$�'�I�N$u�T>c6�#��A�}�t�,G�ǚ52p� gn2d�gN��ɓ;uG���ӻ��^(����Ib����LD!c�ns�e��ɝ�t����e�������"O�}�P�עe9s�����\_E@P�K�h�]�t�=�����P�W�rU��k˗//���)���ҥK�LK?/W�Ϡ!�j��B�V}\z���s;�^v}��oF!�:�m��WJ(4X�{�.��>j�hJnݦ��	�g&w�/S?�"�b���}=_�M��6��+�B[>�:]��wQEPE@���%x4��~����}O׿��(�8q������'K&����J��>�\���g���Z�Byi٢�dɒE\	����
�?���+�\���F��J$����g͔4i�����L���i���?�H8g�g�ρ�r�9�Kb�����^g_N�}�%�Xf͚�gkO]�&����s�.y��8����N�y/~��t�2�����×q~��0�ƛ:tHv���[�S$�?^�)��F�
�Wʠ�#G� �8ڭݾ��V�Z%��E��,_���.��K�(��"���}��<)U��?֘��ذq��|��͛ќ��x�m���|)�f�����@gƶmۅ~q<>��;�9��f�Zys�xj�@�2a�X�(��|]�o��yң���ԓO��M�6K�>����<���8�H���6�H��l��m�6ڻ�A =��L���!�15��jXk�|�ۻo�	��<|�?o�)���M,\$���oMN?O�G�pʞ'�O������m��,[��|5�k�)�c��k�ժ�>w�K�7�FE@P��@���;�8~Z~\o��Z�B��&z�)R���/��(�0�\��]��q�h��ގ�&x��EȤ��[͘��̝;�){��kծ��ˎ5k֐.�;�k�Ѧm{���H�BB�e޼/%$8Ĭc�����S��}�֖> _6�DDDHwh�I I�Kd|�Z��C˟[�ʪիݖA��={oz��:��J�ƁtGF^�g>��0y���˖5}f}2[֭[o.w��.Ԗ3�(!�_h��o���z��KB��6E@P��@���m�Z-M����z�]c�M^cy��L�s����:��dvw��;<C�ڔ�Fp���z��.snάЖ���<>�ƌ�z7I����逸���tw�5�An��ڵkI��L�Eh�6l���O*U�h"lY���$�j����k��<�U�R�g�jjɼ		x|y���O�.O�<��{q��^��r��l�d�F��2
�g���}��I�Ȕ8�^�iH�����D<_�ɟ?�dW���+�Z�m�d��	�E��r���4���H�]�l�*Ԛ&$�1�#+*�!y����ڦ(��?�@��YSFMBL�
P10���-����#�Y51�^�2���W|��EB�/��df����#X|+*����:BS{Nm�<4�h�'��ǚnm�`����5w���]�)�`�y�ENn`���qmM:�E[�;50����G\�x����n�R6��ܦU�vr��	�ܼySiڤ�)�<yRZ�j��'|hFԩ-�4vR|%x���ǎ�K�Z/8��Ո;w*�|R�b�׷����z�y5�҄>�/�璝��g?~�_f��q������d,R�<�XE)[��m���b�
+Ҡ~]���v} �E�d���H������s��#��;�h��>p@�]�&�
+�I�Z޹s�=��1w������I�w�|����B-(��"�#�oƄ�E$Fx5ĉH����"G�����!��)44.�F*�]��	 j�8&�x��j4�6�܁衯!q�(8$�h~��I��8�&�PI�iH&(�3/<k�6ɳõYm��8N#8o�>��s��B��5�H����…vѧ��	^׮]�4d/Ԫ�f2~���&_'�(Yj�<�Z���Z�����I�lI���%��]�3fȈ��-̘#�V��+=%G��m��zmg"�J yj-g̘��c��'N:�/�|�-�R����V�0D�C�x��Ԅ�����F~�u�4i��+�c@ӵlG�Ö-[n���������u�G�n�K�/���${�l2ɱwc�:��C�˧�y
)h#�ɶ�o�S�—�$p��)泛/B	�'FZV��D `	�t<K@�nD;��K_���<�����f[��b��QM�F�ewZ.���L�^��k
�#��3�l�h�p�3��^P0�Ē?��b�e�FV�|ޜ	�ވ��u���>X��3$�:�p��W�|��:u��]���o���U*?fn��:u�y�,U���_�-�HiÆ��u���#��v�3�&D��'�˕���۱n��B�i�q����ӄ=s�4ɖ͊�ݰa���7��oESi���U�M�a>��<���s�z�>n��v�᤻��,mCM�����i2%��El�ƾވ"��Æ2��߀7�rٍ��=�h�Eb���|aR�����HGh6m3.��+�sEH�E �X�7m�0�@���Y���F�͝9Z�7�&C�H��d�N�D�*I5l�N���Z(�K�F��\�F/���`��sA��Y��<#��!����K.��`�s^�3�᨟�Z<cf&5tZ,
�UG��OM^�Ήxׯ_Oڶi{�е�˷m
+�7�8p��}���w�hҴ���g�.3��ԍ�,Z��)�dԨ�`�"�����'D�������s.��I�	��)�':[�퀟^��݉P؋�P�yg�h'*7>���C��O��;���l��qxS e�f�[�1UM�4�M��6j�N�n|,�{x;&D�B�moҸ��%��&Yi��k��#��g�_��fW�!Ј�){����^=%+R�6�@>��y{�NP��%xS�
���>r�{��Q$Y�%w<�9�AآUN;�s�R����y����piI��O%�%�@��%C��r��E�Cv�(��fcR�
+N��� `�T)S���Q���x ��!$|���l�g�1�([D��1s�љ5�D�"}|HF�Ul��M�'N�"��7��]����&�[�-
+����ռ��[vF�WN�<A"��u�׮['�e�4�U���1P��/Oq%p��S^������iG��K�;[v���O|ej}��_���Waz��ŋ�u����Ξ��`İ��ʙKjԈ�������U�%��-3fL5�F�7"EL�1v_���өc�w7i�{��n���;� ��K�8��A􃳅[�j�\�C�	s .� �v?�h�5��CJ��Y��DDrh�yL3�(�P`��}�M�X�,��{@�&���7�y�����\%s���ݔ��EP�
+K�j0$�ÀD��D�J81d�T	�2�ºfX�'������(�H	�
[�˙�G$ex�>|	uS�d����A��=?��Ⴠ,g�-br���ٳI�������������Cc�`<�s�c�]�Ȝ�‹��3�-�e�����]Z
�x͑#�L��	�[�����3���Iظ�w��&ā)?�}SهyƦO�i���Aˊ�/�q#��L��j�����d��-�Cx�:v����ko�������{��䄹��0�b�BRv��Ya�\ngFߴÇ��6_^zB3�D���+IM��i����ص�����g;I�[�j�1���휹1�#H�gN��������	��bǒ��aʘ��&k����S�0ŕ��׸<�z��Ӥ�`�˗u�/���?��B5xސ�:E@T���zo��d1���:�|�hF%�HH$��0nD�e�f9}�4r��1{�\OUX
+f���˧wH6��E"�����
SLfI�"45j�hz�u���܀v�����~�:����S�
!���[��CM0y�EH-�g�X����"��h���$���ƒܲQ��O���&����|n�J�Gbk���_�)��*��!>;�(G�u���4(������KL�6mZ��I���͊]ǹ�|�m��S|B������@W����M'Dͺ�y�:��s��z<1��������\��������m��)���
���&�3���~D�fѢ��Dw������s�a��kρ�J���>7�p�k�5�Q�����Ϝ��-��}�+�c�r��lܰv�X-����s�<}�U��
Q�S�@E �	�HC��F2c������CLE���a�
+�Ҕ�2s�H�Ґ�ߖ-��? 9r�v�24u O!�)m��I�_޿�?�$�~2���$ _x8P,��{������.88�1̘��-�E���3c}�_���H��%7t��-�t��Y��pƴL�-��-|�2���h����j���X�胆(����uΜ9��cjk�·^�:��e�f�ӏd"m�Z�}�X��t:�&��)�`Ӻ�L@�U�N]�H�g����RW����N@�T���}ߐ2�<�Zm�������_	^ݺu�}�6n�ju/\�(ĉ~eٲfs4zv���wM����ns=���1�a�q�->Ž$s�X�C���n�F�2�	 �3�I�}C������u��7b�P�ѷ"����d�:�}n_�I��s׫�f�k�n|��s5!+��Qԣ"�$��͘�4)���4\�ҝ�EGG���KFke��@яi��M�y��R#Z�.�PA�Hp���D��-�AQr$�4���YѶ����"+�]����pN|�K�^���#+�/�
+�*K�ܹi���':s���0c�����
-}���$zfd����ͭ�I������F\I�]�#s����_Ǐ�0{�/^T2g��t���cd+	��0
+v��]r&�<Ђ拈0���aT�<���R�&ƏcM�Yo�r���g�x�bF�GS�fh\W )�>hQ��/���,�wk��ˇ�4���.]z�ևs����,P������3��){;q��&ZO��~����x7�vL�	�?1���{��x���ow��>���c�5��)���o�3�k�s��g�wsn�(���У"�$��M�0,��E�	i(�	J�������c�}�V���H��c.<c�I�?���*���ˍH�܁�S/�1h���PxIl4Tg�]��0i�a�f2N��݀�!���0Unܸ^ʖ+�@l�v΍��m�"g��U�hQ!�9Q[x#&�t"�•�	W٨c�M��H�dz�"xpݎ̟���b���[� x��5_�Z�W{�r3����d�<R<�`};�~�<Fn�D��-t�|"A���1����9r�%$�&x/A��,4��4n���
���u���q���ˮݻ��l=��8iҤ���)c5�+I�ma}g~��}*H��\{v_�M�8��՟2�ۯ�����6%xDAEP�
+K�EK]�+�!��M<H��N�GR�a�zDp�7��@ؒA��.�h�h��
+����v$�����z��a�=��A���M�rE���N�$q$p|�p>[��d�-n6� ���fٍ��%K��'�ڣ�1��?�޵kW�!Df]`nfM\D�4���P$��50��8߄����;
+<%w�\�v�(^��g�[�������G�r{x۝F����{��M�}����/����d����g�9a�d���^����X$Iv�`�����_�!�)�����h��e�Vb��ę��H"�#fu���R#I����0�{�7�pW���p/�S�O�\xt+H�=j��ב�9���A�������6E@4��YQ������#z���yh����y���CÒ"E����e��?�]m�7�H�*�Y[��p-n.�<|��FJ���-%K�0��$N!h�t�AK���0�1��S�J%_"qj%<�y����3�9s����R�*�'�yƸKrG�$� ��;�����P������jc(�G�{�^�#o��E0;��MI��C��4�y#�:fNjJa�o�>���e;���'�ۨq�Po~x�]�P�/��7F~��-��
u��Uٻo�����j�r���*��`�J�WoIJ�I�
+�<<a�X{
+r'�w� 	��G���!���7>��������y�bE�	�)��G
�����V�;k�������vT��
�S�@E `	޴qq/N�e�H��?j�G��#G�Cu�W%S�x0g���(+��1J�Z7�6�B�m�H�F��I�H[��C#ͼ��)dӦMrZ�2eʘ��I����k$=��EDD��|������"R�$���o)�n?�������f��F���\˵p.1x�5�t�&Z�U�]v�{���b;?�׎��d B�L
��D�r�T ��Ξe�3���喯���
m-H6|�5�aa8Rw���֟�k׸-��iӶ�U�p�ow��A4���ڭ�L|�w
+h��c��ٳ~��{k?E@P�e��;C~@L����+�7'���rF�R;W
+Y�/""��N���z�~�NrDzE�8��ƌׂ��>�I$�̱��b��?�����|8�N�,p��q)��._�$��i��j�dF"�Jr9�����.BXZ�/[b���*�b�mo B��Cjw�gfu/��z��T�Z�,�D�U�vwD4m�n�����R�1�+M�$���@�S'O_�EH�½L}<����`�Oan����0�(��"�(	!���daf�sgk�H��5���4�x6 ��b�J��/K�l��x�g�VdWIc_&#�D�Js(�}��2�ɣ�D��&xhF�0Y�f�dFμ��6	�I�x��IY�b�T�VER#�������矗L���>tP؏-�Jɜ�>�Z؇5,<���Č(��j�|��GM��da
�m|��dC�9Mo[�%f�v �����m�H��dǡ��<�\h��R�enY�ŕM��zF���e�n�'95rԌy�������2��Q��+=o����-+��"�(K�>0A�;�b�P��^-��Y$~��?�i��dC�r�*2��O�ړեp�Br��4�Z��J*L
����8�DF��J-�,l����$���
��5�(~	9���"q���2�ܣxpg5�+W�@�lp�`������Ȏۤȃťʯ�ɵ��Y�p��/2���S�Ԃ�������y�^]���?^ڶi-5k�0�� ��"�(��"p+��1M��S�V�9�$�Ê�ۃaa`b���̷v�Zɗ/���|��,QT
+�"ji�u%x�&�\*��(:j~H�c�0�w���(�V��/,,\����T~����pZ^CS쒥ˤ(v`Ȗ9��@�A�w�2~w��'�H�Tp����74w\�c�C���V]�pJ��0��)K��P��0��?N "[EPE@�L������A�^AΌ��z7Sf�e�L����9ǨNS0�!����S�0oM_l#�3�h�Ժ[2�X�J�5�/�����G��!����f���$(H��/"���#[���
+��4˦�^�a���E�*�Q����uKj�
���w&���p�"��3��E@PE@Pn��%xS�
��Ůʐ;lQ��G���`�h��KѢE���%k3+z6TN?�� l@�@�4�gS��D�[TQK2t�8�W��=)l��Z�{$�4��~A��7�ß���u�Z����$]�v�C��ܹs- 	������y3:׮g��E�P��|p��C�����&E@PnB ,,���m�[)��>Z�(�n��}0�?�3o��H����n���u�o��ڶ �P��CMg�x�H�;&��.D�+Q\���Fj�z�$�����e�~W�|84,D>��kc��_��p�/P �@ғk�T*LP|��a$�]�\v�L��5��]�|�D�>��C�fZZ�I.)��X�Q��y���
�g4x���^U_+b��(q��U�[0�7SEP��X�����)2[C���G*5x�Z̢L\	t^ M˗��
 \L�Z	T�!�S�3G��F`�̑S���/�ϙ�)2ɣeJ��?�#uI1c��X�S��Z�
+�g�/"�I��ٰ�lZ��3�e$*N�I��#=�.�9��_j|��\�F�V�l�p��ї�J<���5��YiZ������w�H�׾�<}U�t�ҙ�D���n��6���~�(Z��"���%xSF��!Zb��H��A�F���2*xK�֯߄L�G
Q�[��I����°e�N���x���(͹G���?�Bڴi���k��:~YRJ�*a�N�m�oR�~-PD���46��$�Ξ�G}_�i���,�ݗ����S
+,�]""��ɓ� ��}{�/�1�2�0I%H�RV���:C�c<��ګ��/������P셝�|���]�p�|���q��+�@�,��4r�!u�>�_H��cB�G�'�r΃�N���H�[Y� imXx�!nLk�/�ڣѳ4���۶e��Ħ��Ο����y�tIh���O?�sn	�-۷픓�N�K5ZR!�l�l4�Ƙ��A����Lq��!�F��2g�����ܗ;��~����+��B�H-$&��)��Ә ����:�i��EP���-s�L�{����r���/�rE@�-����9�@IDAT�]`SW>�w�[��
c����p�ww)�nc�o0�1��p�
��R���w�^�$MҴI�=м��z�}�؍�'w�w����cl<����}�!��ð����1���%//C��w�t�m�yݺs����A1c�P�8q���[t���@�����[z�&�^��dI�҉c�(�u0eȘ��A1}bJ����[�~��?~���?����)C���)-e���AA���Kz��>N�9�|y����� ڲm;eϖ�r��NA��F[˻7���<�w���j�c��]�((D_$O��bNJEO�>�gϞE_F��+(8́����`�d@1���9
�a��޽��G�����P�����_�Q�R%�r��L�/]��������+���"��?~�A�����?#�e�L>woߑwL -�'mp;�bƔr1�;O�<��k6P��KP��i��`�ӛ���B(����+Pd0���c:}�+�Ŋeh�ˋ+�z���M�i���S�����Gq@q zr ~�x�8qb�<���FO&�Q+(8��x��@��u,Y�ʊ�gX��M<��{�S���(S�����Z�d95i҈�߽Kgϝ��i�P����
�������X���r����{	HcD̥#>��҃��yS�D	(Q�,|KOY��c�{{{���wE*��L�|� χ�1�<s�<eȐ�|�'� ƀqy3�|��Kcp��L��X��������UP�nH�2��[��{�UE����p����<�1��A��`,&K�N�>Kxv[�xpo(^��t��y�r�%���ڷ����G_|�%�Pi��]T���(M�Tt��U�u�6K�|X��Gq��)ڄ8.�s��):y��J���>�)RP�����x���+��'N�!̸�d���bҖ��铏��*���<OD�Ҧf��RB��yC��[�t���>ƾ�my�}?��A�*D?Ď��'KN/^�`�ݣ��5b�Łps�c����D��P$^�����*�j﾿(u��6]Ve<��	R�����Kt��qUi(ir_J�*���!Oڴi������|L�bǡG�Q�E(i���捨KEm� �̙sʂ�P�t��mڷ� ����>��I�H?з'NS2�Х�YwZ�v3U��9��β/A����Mk�7U��7��r�|B�Xb�C��ծ��*D7$L���	����ŋ�6|5^��p�����d����͋��Ey{X=[����b'������9_��I�*�<l$0H�P�k�}���jР��~��1ʓ7�Hق�_s�\�AE{��c\>./^�Ο?/�����/�d���"�Yrx��Y.��U�h�`��+T�fU��s��j7�H�X8'������?XE\'���~���B"S�PA�V�
+�:u�V�X!�[�6�̙�V�Z���D��H�(�h8�J]:�#_��4}�,�f�j Β%35jԀ�^�Ns��7'�$I���bS�2��ݻ��4z��p�ooe�|Fe�~F{���u����|ɒ%��<��c�u����)���Xޣx^1E])�[vDx��hK��)D/X�z��
V�b�i0%a���*uJJ�0K�����b�������ҕԸam��ߋt�U��$d�:x�B�7nq��t�
+]�~�������c�%kV�BZ��ⳤj֧��|�ӧ[�<H��.O/�����ճ)�#�G�88|��j[�Đ����_��.�;���{h����'{~ʖ-C]�t��Yd����V/FcǍ���w����֭[Q�ʕh�5k�I��?̤t��Q���ʕ+aUar��X�b���2:S�…hX�:s�,u��3ܬ�֭}^���W��d�,^��KO�N�����
+�իK
�ץ5k�ќ9����ko>LN||b�m��ɾ�%U>���<��1y�yňɶk�
+	<�z1ڷo?�˛�g�>��;((�e��O��*ɀ�;GxQƌ�ف+dl���P�^��ʕ+-/Y�d�+wN�˳��.�S���c��<ysSЫ :{�]�z��2�?{�±B������<�9e͞U$��r��
v�H�^����˓�U���K��r�����:ag�s�\��-U�c‡:w�\��$�}�����g�]�g^�ͨF�j���4lb^�ɱ-��"�/�N�F�h���ҧOOիW�lٲ��?����&���'nܸI[�le����=V�9�Ca���Y�77�kN%Xʌ��C6lU�'<H�v�lϐ�yf̜%>-!���gB�8��s��emp[�P!ʙ#]�~��qئ+W�Jv___�|�֭[֊�t�����X�7uT�б= :Ŀ��@����C)M�T,�IO[�FP`��$I�����I�_���&8a@�5��f˞������c���cJ�&5��0�����1KY�7n<q��'6ʼn��ŏ��x��%?~�Ձ��!SF����V������X:)e�R0�C��,(FH,?�E���%u�n�?��M%iRG���9e�t��yfN��iS'Sfנ��:���Ƕ^�թE��fM�6�X�ڎ��J��mX�^B�1�֡XJ�2���!	���MG�^q55hP����Y�q�rZ�di��}���ҟ�
+�n)�n����4�-�W��Gԗ�E�n7n�ܘ�<�^={�gg�l
+pҘ5�o���z��kR���0v��;j��A�Nˊgr�h���}����UPp���F`i� �K�����d����w�Νa��˗�����E��\�:;�;Bi8L
+�/�������@:w��U�x��d���J�޽��k�>jҸ����}��r�Q�ܽ��() #2~	ԥg0L����%�4�з|������:����-K�0x�BJ�P/��!�;B�ˑ�f�:��|��;n���)]��о��`��"u�}�ii�ݫ�zƇ	�z�*�ҍ�n���Q#(�ܡ~��!o4���s&�U�V�
+_~!��w�~⩭��}HYK�,�p590AaU􂅋,�K�l6��hԨ��x�s��͛Z�:�!i�p��Թ�1�+��2;���e���������y�8����n�*�٘��Es��k���ߟƏ�^�qh��M�k�/�׫#R;�	����ʣ8�8`�����թ�@�Azg�r1FbU��{�8��I*�N���d�������չ���5�!������k�	9|	�Nc���s6�߿�/*�����+6��9��;�q9���^�J�޽�XJu�A�[*^�K~��U��Ӧc��5J�0��2hv�>,u�bi$B�`|A��Xelx�P��Ȩ���!=un�ς�sE�ݵkV_2��\��ԝ�jݺ����Q��={v=z�7�P�>��y����!1�0q2ݹsǤm���b0��R8�
&�2�:i�P���Ԯmk�{B�[�2:t�e����Կ���0{.���N��~�j�<[�AB5���NK4J�<�J��)S&�۷=e���=L%���͖g�{����ޣ�u�PC�/��<��&���6��mƌY���<�ֵ3}�y9��+M�2͘���*����A��*�g��QPp��&��O�c��	@��p�c��f���A���P)���f�XĻ��ߔ��B��|>lH�#�Z6>�I��xa�3�EA
+g�I!� 
�UV�>~������_:����UL�뛂<R	f��J�yi5��a$����&�c!
�u���!ű�F�R�ڵ����	,{�Ž|L`_������ۚ�Z��Asr@x��=��䲰H��o�Ď��:\њp+���#+�/�k_�N=�RFcgu;��9�8;�:�����{v;�3̗_���3f�j��:^��kق��dt!K5r%�K�:5͛���ԛ4m�5!�ukW��[���r�q&~��<�X"�>:�z��Fe���O��h�9��mcCf;���t�B��MM������xG,Z8O�L ����
��;jGq@q�Qx,��4���"���عB<MY�%� ������3gDE��K^�P���̛բ�8t���wX�Y�1��Y�Ɵ�7T�����s��%���CL2P��M��1vK�YH�?.eg����-^�Ǜ�=�5�i;��?��ky�
/��:e���z��#��Q�9ժ�-5�?6l�L�f}o<�K���Vf�U���SB K3g̜i�
��V��7�4!<r�9	r��Y&����OQ/�#�1�a����;w�<G�FH��ݻR9�^�����~w���x]YJU��T�YB�C�J��I����k���8v�>׬^a�3�Y�
+��NۋcŁ�ٔ�������ڒ���S!;Ŋ����ŋ��}�N槩m��T�bZ�z
���^(���{9�o��^�^�x� ��b��������)��\b6���l�W��G�<
��,�J�>��p͠�
QM���V(F%�+�?/��1#�/R6H��������)l9G�mH�J���@������Gx4H&�B�M�2����l��������T�G,a:���$j(K�4
+��:e�DqB�s�N��,��fZy��O��?c�&�(�xI8��!����~
+���&AD��0)9rd��Ɖ�S���C�g+�j����-i���[`+k��0���c��Pg������߳'t*�R�	��/W<��Ӳ%b��Iꐖ���ϟ7�D�3�PU��m����C��y�Ј2i��f/^���&�2�h�~̘q!����ۊ�N���v���R:s�@�V-�x-^�T<s�c���9��O�`8
Ƚ��!jY��p�K�`��e������Y@I<V\uZ�[�������-˛�;I�S�n��y��o��塿S�bEؙ�U�����J)�(�����e�fc�\�h��1�1�Cyï&�@�e<k�痉fL�j�!D^
��[t�����<�d$`�6���P�!g�
+!�4'�H���xP-CE	o�:
�_�J�2��)B���S��l��<j�pvb�̀�b;j�0>#ޠ-�D<8��˗טJ�:��KR�~}ľ�-���'�����x�hnC��7�4X����S��Fܓv E�4q���%<6xx�@r��q㊴�������e=j�0���=x}���>���[���&��g8+ݸy�%��h-���t��O}�B�*E�����x`�ػ�ؿ�����a_�o��6-�}���Or���ׅ:�2ؿ�1K��0����_S|v�ȝ;�PF��S+#E��z��͘!�H��Ɖ�yQ絼h�d`O�di�a����mM��#/��s�lu��.�k�6I_!�x!���5nf<�<cB�Ί��h��E�6���=�ž����&V+�����<����'M��V-��I��0��6��_�
fT�ZU�+k.�ʖ�k�0�j�Ok��~�ґ#GCգ�IAȠ����-�:���	=)p�\dt-Z�*��9�
+�
<��q:v�m���-CYһq�f�9�`z��*p�ШO��ò�K�a�:vh���%X��	�Cy�B28|����\
�ҿ�v��
+��\����QK6n�����o*W�n<�;��s�H}�
�O�~��:l�)|H�X�7aXw�3���d^|�/��I�_���U@��!��5:|��YQ/��ո��<�@ޠ~�x�$��}��IN����b��!T��%�xU�s��������+b����R�3���/ܘ�xҢ��<c|��<
+��s�\$8E���[���ldn��d�,���/P�.݌�4�[,��b>LK�.�u{�h�vm�������S��rF�g�!-��<�������X�j5�u����de{N�C��P<B/^2��c�`�k�)��4:��I��Z�yh��TlS	��Fj͛5�[�W�`1<@$���I��F
�`��>�����K��p����b	>ֈ�'��8t��Yy���tΣ�-<��K�*E�r8!�e�.9�6��x���-�S�W�@�oL|�w��M;v�%�2u<7jX?�ݫv!�z�����B��z:�6n�@���`����1���4O���5}������ߌg�E���Ͻ�RTE���RJ�.-�/��o��g�x���Cg�@��vl�
+0b��t�)�>Z8g �I�x	����w�&�8Ǵ{%y9��6l1���쾽{(-�Kϒ� 6�6���i�m{�*\���Gh�2�!$/o�B��BF,?�����K_q��J��\���7Z6{�K/I[�:�e)5Ke�
+���{�2�e̢<kK�ծ��^K+R/�|��?H�bUV����l�={�T�ݱF,��F�x��A��X���ea���^�0�	�t��3g��l"	n����a���7����(�3��@��y�&O� |Cm��aN��4<o�Q�6���1�IH]5�� �H#���m��ܿZ=�[�\���G�������Ab�ї_~A�;u>F;'H�`���?E�
�)<����¤��G�*�%�
+�6����C�k����/ۓ=J�Ax�<i�����UQb�ɤI���G&�@�{i���?&g�o��2�x&h���U;v�ͨi�����
}5T�9���W�ܽ�V�
+����2��#��"谨h���b��q��]��/�4i�JpcAj\�Qp��ܵ��������e^l[GW�g��̼�3�_�����o8ȱ�
�ݩ�Vx���c(WΜ6�y4a��j}JM��t=�V�pa0�.i�'<m�2�+`^�X�:�R!��u~9�� O"|�di*��*Uk��K!�,�U���$x0F�Ӈ%r���K.��J�p~��^�6�(�D�h�*�*_��h��j���.]鮥qL�8xɒ&�>}���y��2e��}[U���*eZ�l�%��>F�����?��i���9�6ԭ�����pJ�� }�՗�qE�dl�	�J$_�.��g�yD(!��wߊ}�t��Z�Y^����Gr��n�6/k�5ky���9V���2���x�v��Qr���L���X�8*����U�/s���_��2�掔���k��1l�(1���O�xcC��L�\��6'
�a�8��=ʂ�*X��p�c��ΕCT�����GK����j/���s��vyo�
�nq��/^���sȋ3[6Ӊ���&�
+�ҡ��H��E���[n��;tV������͜``�{�.]����	�\=Ո����&V������@�w���b	/��ɓɚ��!���~y8�X�gd<8s��3厝�Z��v�s��xa����[q��k�,�m-Ձ�.��CzܦmQ+k���,YD�ن^��y
�^���PgCmiN�s��,���sp���ͳ�:Fy���}k��.H�s��3��х��5k�2_����>���}nzW�ϝ;��>Ѫ��[-@7:9�<[��A""÷5�ۥbr�ݧ��
+��Fi��Ma���{��o͛5	եq�'r��k�{.��E�<MA��3�9�zl2dMU�LF�d������d //a���	�@����P�B�%�] �e ����#��A,�($`1j�����{�vH���,�p�cI ��Xph�zu���b�h�|0�F��Ikk�Z2�w���R@��8@�}WK>����m�6;P�-]���������Q��:q�$bO���ӧ�������i�i��yjժ��l�mժ%U�R�.ZB˗�09��-&�5��9��!�ռQQ>,m�	�[�2k��t�US.��E�H��x�⾂�hV�$b ���z�XR�U��*�b�8H�0�`����,�C;���߇ӠAC��W'��	
:�}�/��v��	�߇�p�����WH!��">�5jT���r>A��F�M|@�OL�O��[�6������4X�w㦟%�&4�;X�ǥM�F�B�9q�T��VYO=�x��}���+y1b�P�����<k���v`j�_�%��9�O��*��þ}z2fy�&�+���<��d*)aF����M/��0�j�K�)<���!N���Ķ�LJ���Ec�`ol�'�X�U��-i��BK.kپ�d�+�j�rn�W�`�Z���%=�4l
��=�����tH]��D��	�*v��F-����s���pħ�˕�����zr��~�G�n��]���Kk�jO{[��hg5��1���R��@�]��,���ݻ��?��+�^ϐ\b�(�s"D�,́P�j5�/[��N�l
���-uØ��%ۭA����{^�gB��Ё�A��5�&Mf����)����/�EӀ���C�\�˲eˈ��ACEZ�10�S��lg��+W��fBHF�@=��Bߧ,	���8J�g���)�=�����M�������cY�,�E���$�g�}h��+y�#�i3��$$�C8�8Ժ[�n�h�����/w90��-%�������4�h��!����eE��Ch�Z!�������� ����衠dؖ ��s�{�:N�C?Yz��Þ��}'B�-����C�3ԁ_T���n��)�M�J%:�V�V>|cq��	-ɸu�<���^Œ
+��Xx��Y���~ΒH���,M��*78$����k
��ʋ{���Gt���t�mr���Ƭ<L�L �[8�`���("ހ�}E��>cV��A�r6�ӲM-�M�ok��1>r�����Q
+�C[�� ���ƊÙ��X���x0�n߮��b��Gm:Kz0����h����2��$v��	��pv|(��kr��.U�>�GE��9�wX�$��3gY�'��qfd�Ȯ]:Jw&�����P��=��
+�;Lf�y0����2@���rm�	02�#��z��
+[m��{�������f�\
+��!�>1��jcQs1�fX��d,{��q�Т�0�Cɏ�!=C^c>I��2݆�`Hp�/�( ����>}�բG��h�x�v��s�.Ox��c�^i�ړ�:ѕ��K�.I�p���p_��#��al���5����ʛ?��^�Z=Z��VH�5s���u������5eu�����ġ���
/�C�0�A?$�����OPD<H8���-R[ĭ�8y
+�^�6���l����w�q���rp�
+h���N�����޽���vxÆ������zҾ��T���}o�3�1�ބ�S�+�1�m$_
+��ꙵ9uT_����`)'	`,����G! ��P��VX+�[���������務
N��9��!Kè��Ƕu�w�d�	;;����
���#�0rb����H�:�N�����%���D&���_��UM��3�s�pV�Dp�(�������[�e��������[ �Z�O�6,`�ع��»��޺l<g�v5����s���6l����R��m�핫�@�
+Z�G�s�-�a0m �����ak?���J_%Mo�`̿����
�+�X�-��#!��l�:	�pv=d}}�ه�����g:�GD�]AXG�^��Rժ�k��j��nW�;IL�J
����!L<<����,�c�%4Y�gx&�SO.�C?+�����L�M#N�	�Ȭjg�
�f���d?D�f^P�i^j
����;4-�;���?kh�6]��u����U6x��^	B�X�� y�ӷ�TY�F5^Ϊ��_f���:��TѢE$n\��p���G��,f�t���������d�텏ݔɓxi��0l$K��������0v"��?�W��'���M�8Yl-1�EȤ��0%��S�ڵd�c��޽WT���<$R �f�%�	��EP��?!���{�.'�<��g��'��(Q���t ����Q�
�@�^���D
+�9~5<�]8w*ĻHO�ҧ�r߼=W�m_]P�dˑ׾�����7NJ�K�^jۦ�,)�<{��l>�6�7F��=����1y:�ø4<��\�J����%7X/���
+q����6x�z�u�J�ҵ���?~jִ�4�P��3&��&k��M&�g�A���i3�t��珬}Ղ�E}��}Q������cio{}�����p<BS��/8tJ��+ʲl�bi��uD�I8L 4V�.`N0�@<H�y��q!�-[J�y����“@�x�+�	��QҮ��5׎=�mܰ��Z?����cT�J
�����lg]�:�EH�����Ɯ��`��p_����������C8�dɒr\����H����b^x͚5���
+a�J4x��5�&���],q��c�͛S
G`��xXJ�(?'�02��q�e�o�6ʹ�8(�?��WMj�-^�v�G�CY��\�2�}��m����`����g��=�'��u��NԖ�tK�PU�[����krZ2� �$S$�*�1�d��<�������蚫�mu!�7l �<ȃ$�r�5[�D�9=
�B)!d
+��E��9��X�׾}[�ݳ{D�'_�1�hڴn�租�fUcSY�3�~���\�2N���?�4~H���B�ѣG�z}}y�skA -SIf���Xb���Մ�L�(^|���m���/POAR�#��Cx�<���R[��s�t���G���� ���2ha����.��nj5(�
<�Q��A������…�-���J>w��5����E�x���~��b�I�œ!��ɵ[�:Hr!��ѣ�lU�������M��!g<õΚ�<�1Z�O��D�6�<LP���e|N�s<���a-���<�Lv��.�FE���)m@-�ɧe�:{J?U?"��������عb�I�!�.���U�+ڱcW(�`��|򱔁q>T�z���
+X��)D�w�Cx������V�����ܰz��v�k�*i�#�WS��w̟}V��W38�bI�b�غ��;��
+�Iㆡ.���1�0�
+������
+5�(�Мק��FΊ�/"��@��v�q;]�\_E0`H�z��5~�hQ/�]�^�����VS-Y��`{����k��0���S�2u�ݡ���/���p(�ĢƇ��<~XuD����������90�^����֣:�7r��^��]�~���EW�8�9�E��m��6���K9�U1`�೐(���/q%gӏ�7�t6�_���-/Z�n���V�:,�g��R/����$�h{��Ebo�~��jM�
u �F+�x�Nt��Ӟ3�zlظ���c�vj��=Ƭ	F�ؘK�hk'�MK����	B<�y7�Z��(6,�]�Łpr "�f���O!\L���gai$,���c��6�Y�d�qNJH{ꉈ<Z?o���%o`K}8��Y��>l<�p���!❪?���ٿ_��~�cq�>�~�Rk}y�^�X��=y2�C?�x�E�DJ�cFGD��/���%�����>TD��[�6/^TTz�5zі+W��p�HG����„{X�`������m�bCϑzÛ�����ڱ�>a�4ئi�
+	� �]��A�'iUG�VՂF�8�(a"QK;�	b8�KBr��i�;��a��lZ�C��!lO@�	�����־xaqH�WPP�dD��G4��A�0(��o߮�C#D�hH�@Euq�Ǝ����i��ۧ���wV]md������~PqC���*��UPP�y�ɱ�	�_�B�jY��0&�����C�k�/qm@�yٿ��t���V�MN5�8�8�8�8��V�P��c�Mu>�m۶�l@����ӇYA�G��
+��ݻ�h��Q�3�iAԳg�&�(ĪC�kW�b+H/�tE���O�#B�J7x���@���S����PjW�z�עM�,�8T�i��}��r�ƍD�	��Ż<�!l������)(((���0 �:V=��s�ݾ}�=6�Wph�a
+q���@�yӧOGݺvi�� U4Wי����hH���l)Ԍ=u�7�%�=��$B�`�
+{I�h޲�swؗ��`	����e�f�`�V*~UAb2����~�����������@�s ��Pab�ZM�2�%\�e<��ŕe����ܵG<S5�T�PAjܨ�d����S�NKe#;�V��T���잽�x�5.�[�����ߏ�^�F&L��a�j��:ݙX���ϝ������;���h��%����w�o�޴ic��&	�;w�ڵ�Y��C<�q?�\�Z�E�ooo��a�&_�zU�e�sl��
�5Ψt���7q ��	W���>�x�~�&�PP��#�`�t�
+�X��LJ�|��9�AyQB�4��T��B��c�s����%x�.�iy�͎��*V� `q��!V�9Z������p��!
���Dp���T��T�(��F��5׮]7��-�SVՃ�l�&��T�RRTxbJ��ٲe5�Ӗ�72)�gd��QPP�D$����[�F���\�Νg�B�2mqvh����g�K6�Y��v�
+
+��v�B��n�R�*��
+�
�6��-��l��X���	�˔)#u��Yh�%܈D�O�T�xY�.t��mՎ{�<��E$
��f�O�N%3g�`����u떔���2Q���6�44`!�%R�WT����9�C�K]�Q���+:���`�.�H�%J$ 	��P{A:�Ge���6nɱ'����^��T����v�ǎ��s�Ή����g"	Bpߌ3P���������^b
���\�BC�$x�"�@^dT�V��#��^Tw#&��������x^K
+���d;���i�v���*�5�a���ϟ����ٜ���K�y���P�������x&�U��*T��1�DX���-�ݿ_ֳ
|Vv���j�p�BT����0Y��`������ٳ@0p�Kl���)
+ÆR�u�7�Ν�(���P�<@����-X(���z�	�j+��������8���%��OXz�1&�`�s�*����WPP�D�ӆ
+������-;-]��:�'N�d�>����?%���Z��eg;&M��<$vW�^��'N<p
+r��I~~�D��ϋ��C����6/��cHd�>}��n8�v�t���Ç����"�k�+*6��V�w������5��}	Ǧ+Wqȣ��vK<��Q'�Á�x�(!킝W�$�%d
+Ԁ?����(	�q�ocǎ��L)`N%0����8-X?���X&
+��y7nܤ	'[������"�
+�E6�U{���ap�]/�n��!(Dhg��"�
+�E�U;���vr@<;��)(X�xVY�N(((��
+็�UŁ�F�����i\j,���Q��(yr_���1�2E���8�8�F(��F櫦Bs@��<Q)���r@<G9��+((D(��P����	��&Z
Sq@q �p@��r�T?<�
+�y��Q}SP��P/^t5d��s@<��TU�8�8�8(���(��������^r���%��c�,E����r@<G9��+((D(��RaI)N�8t��u
+���T��&��0�����@�@�	w�ޕ%���PT������xլ���5 �1Դ����ݳ�M�+((X�xVY�N(((����K�"Ō�nܸA�_�vOGT���Q�
+�E�K�:�8�8��r�ō�'N,*Z�j)((8���ʫ8�8�8I�/y�d+Vlz��!=~�8�ZV�((|P�C��j�� ޑ�wL����� �y�UVCR�((�Q�����̙�j֬A/^�e˖;5�1b��).�z��޼y�T��1z�ԩ����t��)֦u+J�ҏ~�#�������ŋK]�t�7.c�豑��ʕ������֭����Զ]�X۶�)iҤ4�B�y�+�4�Q��'�駥鯿�o��nLw��x�bŒ1�%I��m�nE�{ڕ<Ru)(X�x���SK�(N={t��ǎ�С���E�L�OJQ����c��?DIX��M�@=}��%����+�{�:z��Kc{}�M%jִ1mظ�,X(��6u2�M��:w�FW�^5�J����жm�сC�����	��s�g�ծ�p׊-"`��ѣ���S��+*q�:l��c�vT��g4{�\�嗭�zr��S���ݏ.\�`�Uط�^��Μ9�>��ZT�v-��6�}�Ȟj�ʃg��'�ҥKS�:�����zl1N��G���„
+����C��<��]#��~��1�#/~��Խ[*P ����1���R�@�'���J�*e̘A�<y�Ǝ�@�N�6�ii�^���C����q��):s欀F}~g$;�?/G�X��i�f}u���1cF��~M�Ν7�
/��@N�>���yS��52s�4J�2%u��CVO�ҝx�~~~�	�+ޠ�Bݻw����.ݹs�Ξ=�:jm��Ǝ���M�4%�:��[xٲe�ѣF�j�x�O�|M����,��U�&eϞ�֯�@��-Q͚�)G��9��4�P�KjѼ)�IҼyB��
+Ϥ-
+u/�-�Ua)�]�i���R�p�԰A}:v��]�5{�lT�㒔��9�xb������K���`�鎝;E�('ԏ��@�p@�Has�7ҤIc~qW���],U�8q�1����ƍMҧ���1X�x	]�t��G��/�(O���!y�
)�
�v�%KJC(M�ԺT���!�:u���7*<H����i2k0�oޢ��tTx�U�T�B,�E�n�`����V^��	�Ӣ��%�淵��kZ[u�Ν[�j�	�˗/�Q�f���[�<��޽z$��&O��;w�ԩ�`	z�����fӯ��&ɐҕ-[�ڴnI���f�d��˛bp`���}K�}��V���;i��Ur�|�rT�z5�s�Z�b��-�5e�8@���s-ցD?iO������I<x�|��()?�)R�i>�>}�����M)�P���@s@�f�����dP�ڢk׮��)�x��������o�a~l�>T����_Kڻw�?QK6n!�7v�K�N��a�&�A�:���m��f��ޘ?*��,����mƾ[�y��%���/�SQ�5jԐ�U�,��p��c�v�{�Œ���-H$ʐ�c�[�d	Q�_f�}����������q%���Ε/�9�mӊ���ݘ1�Be�h�W��dz��{���h���h„I�����Y��JGB.��a�����T�0Uhؠ��e+-\�X�}��8ۿ�tA?��W�gs���,��/f�6�B�YroN��͛7/U��}��m8h��m��UNJ���x���Ԧ�U��ҏ?.s�_���U�P�F
Ll��i�z'���:v	U�\��Ծ]V^�^��RPP�����$*5///jպ�H
+p"*�������>G�U��/ZRp��D�Ym�jU�Q�����q9q��!B�۫Wwʞ-���	�+�od�	�Dz�9�L&0�0'w<�����O�-L����?�5�W��<M��g�
�(�R�>�N����lk�N�D������f�Z}3��P'�#a���-V���x!l��'�hϞ=�Ç���<K�ҤIM���������E�%�|<���rWy�F��������0�z@����*,��(�c�?o�Aݦ/7|�Pʕ+'MaU��П��];�'�Z�X�IDATr5����x&,
+u��)����Q�9܃��~�7Ϝ=�eg���F�-���E��I�8���x���X��B
?|�(��GX�(QBZ0ߠ6�]�~��P�%<[m���~�:���ۖ�Kw<L~B�=[�sժU�1�i��7�|#����ac��RC�R��s�"�?~B�Z�g�AÇ
{�~�ퟬ@�<w��b#ؤi�l��[%((��
+�1/�ak԰�b)��[�X8�b�D�R��w��={��l�|T;�oK��������2���x�k�ͩb�
+��~�R�-��Jx�G� U����9;6d��������^ˇ-x�t�B"�p�	8u�>(�˃��֮[O�Ir�=zd�#�R]�
�;%x�q�0���
�ѣ��-~vڵ�d���q��u�f��n�FZ�Ƞ"�U��1�GJ�����}t�Vvr�a�%��]���K�x��Ի�i��֮�`n�\��
+ap�4nH��$@��A�׵K'�p�cg(s�$j�+W�r��9B=�w��#G���[�UP0���@e���s��IEND�B`�
 --6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
-Content-Disposition: form-data; name=images; filename=testImage2.jpg
+Content-Disposition: form-data; name=images; filename=testImage2.png
 Content-Type: image/jpeg
 
-testimage2Binary
+�PNG
+
+
IHDR��
tڰmiCCPICC ProfileH��WXS��[����H	�	�	 %�zG��@B�1!���E�.�`CWE�
+���(��XPQ�E]l��		躯|�|����̙��;�{��I>�@��P����� u�xs.O&a��G(���˻��U'�?�����2�X���2^����yi!D��rr�D�gC�+�B�R�s�x�g)����D6ėP�r��4�A=���y4>C�"��h��8�'��!V�>��`�WBl�%�x3�;Μ��g
�s�9CX�׀���d�|����4�[
+��>l�
+����a
o�M�R`*����8E�!� �+�J�#R���1OƆ��O�.|nH�����c�U��lQb�[�)�BN2�/�B�T6�U�Іl)��ҟ�J�*|=�祰T�o����(&�AL�تH���β��(�ͨb!;v�F*OT�oq�@��NJ��a�*���`��F�����
+����`�x܁�a.�e���2�#���̅/	U�=�S�T<$���ʵ8E����-��
+����$�Z<�nN%?�-)�OVƉ�r#���KA4`��r8��D�Dmݍ��r&p��pRiW�
̈�5	�? �к��Y(��/CZ��	d�
��O!.Q ���yKO�F��\8x0�|8��^?���aAM�J#����$�C��0�=n��~x4���3q��<����	��	��D%����?LU���k��@NO<����Ǎ�����@��j٪�Ua�����{*;�%#��~\���9Ģ����Qƚ5To��̏���U��Q?Zb���Y�v;�5vk�Z�#
+<����Ao���A�?�qU>���Թt�|V�
+�*{�d�T�#,d���A���y�#n.n�(�5ʿ��	�D���n������?�My��������c�����<��H����Є'��K`�q^��P	�@2H�a��p�K�d0���,��Z�l��.�4���8.���:�wO'x	z�;Ї 	�!t�1C�G�
a"H(�$"�H&���92���#ˑ��&�ًDN �v�6��B� �P����	j��D�(�B��qh:	-F硋�J�݉6�'Ћ�u�}��bS��1s�	cbl,���1)6+�*��k���*ցucq"N����x
+��'�3�E�Z|;ހ�¯���+�F0&8|	�hBa2��PA�J8@8
�R'��H�'���YL'���w�ۉ���$ɐ�H�'ő��BR)i
i'��
+���AM]�L�M-L-CM�V�V��C���gj}d-�5ٗG擧������ɗȝ�>�6Ŗ�OI��R�P*)��Ӕ{������>�	�"����{�ϩ?T�Hա:P�ԱT9u1u�8�6�-�F���2h��ŴZ�I��
���G��1K�J�A��+M���&Ks�f�f��~�K��Zd--�Wk�V��A��Z��tmW�8��E�;��k?�!�����u��l�9���-�l:�>���~�ީKԵ��������m�������K՛�W�wD�Cӷ�����/�ߧC��0�a�a�a���2��p� �A��n������y��
��FF	F����6��;�o8ox��}����Ɖ�ӌ7�������HL֘�4�6�7
2�5]izԴˌn`&2[iv��C��b�3*�=����r�M�m�}�)%�-�[R,��ٖ+-[,{�̬b��[�Yݱ&[3��֫��Z����I��o�h�����c[l[g{ώfh7ɮ��=ўi�g������� t�r��:z9��9�� ��!Q3�Չ�T�T���Y�9ڹĹ���H��#��<;򫋧K�����:���%�ͮo��xnUn��i�a�ܛ�_{8z<�{��{�x��l�����%�������������e�31��|�}f������[���O?'�<�~�Gَ��2걿�?��G# 3`c@G�y 7�&�Q�e?hk�3�=+����*�%X| �=ۗ=�}<	)i�	M	]� �",'�.�'�3|Z��BDTIJ���S���y*���6�Q�C�4�9���Ys/�:V��8q+�����O�?�@L�O�Jx��8=�l=iBҎ�w���K��إ�SZR5SǦ֦�OI[��1z���/���қ2H�[3zDŽ�Y5�s���ұ7�َ�2��x����LМ���?�����#�37�[����dUg��ؼռ�� �J~��_�\�,�?{y����9]�@a��[���΍�ݐ�>/.o[^~Z����̂�bq���DӉS&�K%���I��VM�FI���8YS�.��o����?,
+(�*�09u��)�S�SZ�:L]8�YqX�/��i�i-�ͧϙ�pkƦ��̬�-�,g͛�9;|��9�9ys~+q)Y^��ܴ���L�͞�����J5J��7���߰_ Zж�}ᚅ_��e�]�+�?/�-���ϕ?�/�^ܶ�k���ĥ�7�.۾\{y���+bV4�d�,[�ת	��WxTlXMY-_�Q]ٴ�j��5��
+�^�
+��]m\����:��+���o0�P���F��[��75���Tl&n.��tKꖳ�0��j��|�m�m��������a�cIZ'���9v��]!����7���]���y�7s�}Q�Z�3���j�k�����ajCO����)���`���f����m;l~��ޑ%G)G��?V|����x����[&��=9��S	��NG�>w&��ɳ������;|�������.6�z����m^m
��/5]����>����+'��\=s�s������7Rnܺ9�f�-����o��St����{�{e���W<0~P���;�:�<y��(���Ǽ�/�Ȟ|������ٳ��n�w�u]~1�E�K�˾��?���~e���?��l����Z���͢��o����WKo|�w��ޗ}0���#���Oi���M�L�\���K�ר�����%\)w�S�����6h��a�F��Q���'����z����
�nn�gl� �&�U�i$���}h�D�����>���-��H+����������f,����=�B��g�8�KVA�7��O����;PD�~���ܐ�J� �eXIfMM*>F(�iN����x����ASCIIScreenshotȊ�	pHYs%%IR$��iTXtXML:com.adobe.xmp<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 6.0.0">
+   <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+      <rdf:Description rdf:about=""
+            xmlns:exif="http://ns.adobe.com/exif/1.0/">
+         <exif:PixelYDimension>188</exif:PixelYDimension>
+         <exif:PixelXDimension>760</exif:PixelXDimension>
+         <exif:UserComment>Screenshot</exif:UserComment>
+      </rdf:Description>
+   </rdf:RDF>
+</x:xmpmeta>
+Lr�iDOT^(^^"�\�8?"�IDATx��U�ƿ�M�!��F
+i�j��E@�R���
+
+�"���+5
+��RTJ���^	!�ߔ���޳;{ggg�;g����=Orgg�ߜ�}��w�;��	��H�H�H�H�H�"A F����F�	�	�	�	��!@�ώ@$@$@$@$@"@���ɦ�	�	�	�	�>�	�	�	�	�	D�~�.&�B$@$@$@$@��$@$@$@$@$!���l
+	�	�	�	�	P��	�	�	�	�@�P�G�b�)$@$@$@$@$@��>@$@$@$@$@"@���ɦ�	�	�	�	�>�	�	�	�	�	D�~�.&�B$@$@$@$@��$@$@$@$@$!���l
+	�	�	�	�	P��	�	�	�	�@�P�G�b�)$@$@$@$@$@��>@$@$@$@$@"@���ɦ�	�	�	�	�>�	�	�	�	�	D�~�.&�B$@$@$@$@��$@$@$@$@$!���l
+	�	�	�	�	P��	�	�	�	�@�P�G�b�)$@$@$@$@$@��>@$@$@$@$@"@���ɦ�	�	�	�	�>�	�	�	�	�	D�~�.&�B$@$@$@$@��$@$@$@$@$!���l
+	�	�	�	�	P��	�	�	�	�@�P�G�b�)$@$@$@$@$@��>@$@$@$@$@"@���ɦ�	�	�	�	�>�	�	�	�	�	D�~�.&�B$@$@$@$@��$@$@$@$@$!���l
+	�	�	�	�	P��	�	�	�	�@�P�G�b�)$@$@$@$@$@��>@$@$@$@$@"@���ɦ�	�	�	�	�@lԨQ1�	�	�	�	�	D�~4�#[A$@$@$@$@�@lȐ!�gg     �����ȅd3H�H�H�H�H(��H�H�H�H�H B(�#t1�    ��g     ������dSH�H�H�H�H��}�H�H�H�H�"D�?B�M!    
+|�    �
+�]L6�H�H�H�H�(��H�H�H�H�H B�~�6�t?X����Evn�P3�  �&ЪQ#9���ҹiSyy�zY�m{�	�D$@
�@F�'�U����f�>�)�~�@�Tw�v�i�[�V�$�[���اsj�pԢ�8{
�]_���*�q��6�ٳ����H�H�sJ����W�v6��t�n9���eg2�9��fwl�Dƴo����o�L��ڕ�8�@C$���_zR�q�T��%��Sb{�έ�Kr���t�[����Ė��{��Ӥ�$�Z����=U�J��zXb��Q��[$@E'��޽d@���K���-z��ΐ��'��#dh�֙��6y��ޢ�-�k����Kt�>���~��Mr�Y�����h�2?9�Lq�U=t�Eg�?T�>��S�:7m+��i־��][D5�7��O�GE���ϙ
���S����w9��&m
{�����%>�~�#��	��C����tT�B��7�-���o�N��E�껯�H�������3m 94��S��E*�'Q����6�o�h��r��P��Tь�w?�[�AJ�o_gĽ��	�r�
+�����$^�(�]'9�q�;=�~݇�~����E�w4s �x�j�z��x��Ա�4���@�	4t����{6Sq�,����E��d
��r�:��NW�gᵓ�t�3�w5�(�d��?*�*U;T�W���D��_)��\.��L^3�;*�a��n�ؚ���%y�o�Ͻ�CA��D*�����Ir쵩�����'+g~ (�@C8�!W���	4���oO��/S��z�4�(�����ؠsT�l��y'G�f���4�NBV'�:�vK�ٶVbf�(��
+_��3�ߗ��I���G����`���A��,>���+���*?7����M�nZ��%��9����{Zz&�B��bZ� 7&��I��ٜ��8/c��T���&�Kl�d��sc�Pf�Y�<��1����zg�P�Fa��M~O�f��Ƚ;Ȱ6�e`�V�y�n�����6m�h�H���V�0���C�Y�J0z��gt���H�Öl�!/~�^��y�mz7������t�~-[
+ڲ��R���!��d�����<V��}\�{y�^rx����?�Z#��$���S�л���{�c|������뵲�B�l�$��\-�k�h��}zK���d��]r��e���ں�lص[�m�&,_.������n]�[���W�&�K�$��5���_�m[Nr��i��ti;@�&�Ujۃ��\��C�W��g��Դ���`��]�{���R~S7o�Ǵ_盨i��b�?�1!>�����7�m9�{��/�:�-[x��is��<��н�˗u�O��DVT�'��߼g�����3k�ʻ�}T,+f�	{��m�M��c��F���C�?6���<b�]�����$�yO孑e>�)�󠳶��Ĥ�սD]W<�8]���1{��������L�s�賿�N8M��7���W���r��.7è�+�ҏ9HR��$d��8�ӈ7���7O��qꜹ���?e�Ry�D�H*+$�m����xJ�0�~�����y����ɧ������{�*9A�~�G�E�1���!�L�����zt�&�xV6����5r���Y���6��ǵ��
+^�~TQ�
Q�Q�[&~�o8��(kw�c�� �[�"l�
����o�D��f}(�� �>��)CT��l���S��� �/�9G��X�Ku���^=�R�qv����@��c#�^m
��0��o_��In�O���C�3�.S_��۳�m�i��X�v�����w�^�_�/��m�*�}85�����y@X^ܫ�$��k���w�;u4�ч��߃Ųb���m�M_,a�M�r�)w�Swb/#�f�c�".�:���!�	���Ǵ8�񉆆�H'��eB>bt�0G�o^�[���Ζݤ����fl���q��?Lb=N'��;Ll�t�����c�eU~VN�W�S�g����NR(��y��:������}�91>��-=+Q����S>�{*�9N����C���۸�$^��Z�R�?�����ur�+����3ub#�]�~���
+�L���F��U������T��m�B��Ȗ��w��&6����1Z����F�_� ���Hms�?F��6JUo[�?0؏�G���RY���Ud������V1��!ݔ�`�.{�^�5eZ�H�-?���[��:R?Y��!����u��u�����O��_G�ʈ#����y��G�t4oܼO�`�y+cv��<�<\��1z���������e������8�O��q?�-���fj�Κ:=��6�/F���C������m����j9�4�v!���?5¼�D�e���7j�w�p��������M.�2��om]tl�os�����?���|�@��i�_�On7%W'��/�ϗ�y9.$*�+!�ӓD��۴$�5�#�P�~��UL�|�HE�K���XI��N�:ո�xr�-ߓ��g��Qw��fwl�%>���S
+��#�&?��������B�D������aH*�mk�]�	e�_��'�Q��v�Ė�R}�ʹ����4s�;�Rr�����Y�ܸY��_[��f�> ̎���q�p�����i3	���nפÙa��)��C�m����/��G�!⯛7_**�&o���o�P㶁g����B�k��{�ꪮ�������g��r	���_h���q��>��e��/�ܕl�y�&iԒ�g������D?�0\{�(�y�VtUZ��������zY���EK���}�Hd?�y�Ǐ?�.��=�,��t�[����n����7}��r�/�����qc�BW�
+�0��g���;a_�N��� ��������/��w���
�l���n݋�7�������_��O1��(-����m�.��K��>���Y�	�%p3��`g���|P��gV��n������
+3�l���G�H�z�m^�s���QW���(�$��e#��kH���o��o�A)�} �O�K�@���;�}�R���-�s���Ce|���g���w�kp_�e�8�D�׼?Я���A��}q�f$������6���M}���oR�W�yéa��O�V{g=���Vf�s߫���YaF}�Nj�o/0ҽuO��-x_����-u��(����m�?[~nz�wʇS���7�^��7�Q�
*~�n6���e�B�l>���<��i�^����8'j���6��f���+s��u�������b���¶�޶���7}��r����V����#�M�r�~��#�xP��w�o��O��A�/�0߶�nzԹ����ܙ��,�k�Z�$ә���ZvM��h�}���?�q�����&�v���Kh���{�$��f*u[J��o!v�N�3'�(O�H]y��-eM�]��>ڛdF(?�l��7mFN����sc�M-H z���7�׉�~�w�}]�oA�Ƽ9I��Ps�6��C��o�nD�6�Ĩ�8�{���m�n;���]m+}���uQ��-�������'���͖���k�k�7/�;�,���޾`
6K]p}�oY��W�]
+����wl�
��m���)E���m�������jS����rЁ�nŎ
+9����x<�!V���o�>����F�������O�',w��;����c����|�����M�5����k<H��Ip�K���K�F���1A�����w�(�,Y��dW�΃��͌O��<��Ѽ�N�F�+4�F3��"Xw���})�tc�̢�;�?t��\��1�
+_j�@ܨ��^��@�z�byT#��
.��	���F$�M���}}@� ������0v�)��7���_S�jJ_?��l���7�����C�� ����pkr�`p"�&/�I�o��]DB�����?YG�1���9�N�m��9���m���
+[o�n�������0?l�}U݁0O	Ѣ��o���}Z���o�����s�o[���>��L[7
+�N[
y��Y�5Y>��8-%f5�������Uȑ5d帼�98��Š���i�$҉��t�y�Ꮽ�_�ܓ�oJ=iQIHW�͘��'G}/墤;��Rb�����\m�V W�wm~�K�ֿ�����}����T��җZ�~��C\�	��:O�P�h��7kX�2�@�{����/��9��+'���{}�m
+�گ�l{�ۦw�Y1�R��"��
+���S�w����1� [=Ib;7e��윙��O�K�.��Q77R�>~���lfb��߾.�gf�X�g2��p��fo2uFN�9���Ʉߚ]x����Զ�1Q��$�q���we�9e��~A# &�Ugp��?���@ۦw�O���{���m�َ`�uJ�����k��ĶǃF�]�P篫b��B~K���쁣���D�����q�{�f��
+|���Q����<�V����L��ۦߟ쯊�.h�й��}��bGC����X�g�:"P����WL���[���8L��̜�W������Ӿf���0T�cVS��{$��E������{�
����ߍ����U@�N�c$9��H��_����g)��o��p�ijSϝ��dOd
LTG;�S��dƬ�K����M�����hC�a%D���k��w1��Y�>�}���܀��TW[~���z"<���"���k�����Y�����	��V��Q1�_h��u-�����z�A>�B�U�߽�����;9A
+��oo�f��$��(�m���^���#�m��^��.^n����c�c��@a��Z3Yc�kw�9�U����	|g��&�N�O�˄�tz��/�/�?������cY��`��0Q��_������!�eM��:J��4���s��,���`%^8C��)�:;�Fa���?����"�?ж�3Zz�j���F����*�H/�u�N�O'{Ͷ|o^�����v�k?�;IaD��P�^����@�����%h����P�0���0���P��]
{q:DlP������~>���2Kq�ե��M�[�
+�}���Fbr
o�0����������M�*�_�������m\M�������>���X�x�	&�{���tw��؟�j�cM˪�Yn:��wi��}<U'�^H�)f�(A'뚰�Zߘ���ʺp9�֚uPqk�M	i�K_+��U�����I�>@���t�~WVRLP6oIt�.&��߻%�xև0��ʠ�n7Ĺ?_G0�H��F�}��]w/Y&�i�E���@ۦ�N�XXP���U��$|����T$zͶ|o^���u!�>ٵ[�PV����{�/ƶ������_�������G|������������mZ��ϫ4�"��I]:y�zYð^���x�F���G�Suӹ��iu�]��w��A_����+��/��յ�����u,��綹.>��+.��C4�V�FrZ�.��\+���l���.�b��F,���n�-jT�m�m���S�	��}��@a��Ul�
בk�PٶZW��)��3Lt���
+|=)��&п�i�W���<{�6�"���<R'�~�����/4o�vfo%�ݠ�`G�v��"qD��g���y-��[WI|��D��֕�1a�,"�s
`ձ��0�G:�p#�D<D~W���!��eD����0"Z��l�m����i�্��*VRE����
�ʦ}x�5��yy`���Xɶ�����a����Q~�+����'���=�:�KÛN��1�~�ͩ�c��Y������ZA�Z0�1?�n���ܬ>�	ۮ����z@1��a�,,�C;tGz�3�{W�x`�!T���#+W�k�7Ȃm�L�',�>0 }�~��G�l���Qw��k(4�F}�<��	���
+�[�=�h�^�W�A�5���|��ߣA���IVj�������郘�݇�@�����߹p;�
+�^�P����m���\&�[	���hL��\�^Gg�H�F�a���"��vM��n:C�aҙ�4�L�%����T��������fm�}��s��-U�|[��~�	q�;��q�
+I�vY���ļ�h�'{��Sl�3���������P����>��c�Z�B~�xi�a�h����w5��
+�_�޿�_�~+F�n��E��EpƒS)�V]�3��/.�7
+�O��g���9���5�T��*�Ϙ<M� O>�ʹ�xV��$��G_ԇ$��M}��	Ӊ(3�����7��So�0���o{��sPj�v���M�kź~��n'u����`����ְ�+5��-��-��?l�����.��ȣX�]�:(O�Á��k6��R��2�v�$P%��M�����fב� ���
+s��W2�c�i�f�#��	w�]��s�A�����,��<��釃j�s2���ƭ��;�ݦ�����_���LQA��j������{X���Qڣ7/�z�8�!�>!ꟓG�g��\���F�Ց���80g�h��n�N�� ���+qW�!�>&��JWoŨl��|�ݡIyr�U.O�jT"��Z��xP묂�k��w�[���\���`;,��~�ƫ��i��-��WT�(�u�
+�v���=�5�܁{t��ꊝn���zp+��y�4�:/�|L0�j�_W~�u�5<b�#����/F��2���{ӆ�.'���]���mV��u��gN/��Û���HL_��ԛ�H�Qa�O���i�����/����=��ռ�4�7�^�B�XP�oa���������@F�'�_R�r�u�?�����7���~�{���MGT�֙�|���P��S�֨�.�S��Pq�/��#���V���P�34�(�Ruk�xgiG��1�����b�)��"��Юk���'�w�ѻ�.�,���0�����G9�aC/�QƤDLC_���[����}�A����E�u�w���S[l�8���#0���&sðH ���f{�ۦ/7��V����}��^~lോ�^�D�j=l_��'��Z�J$@$@ ��i�h��Z�¼"�-�(�H�6�jC��u���zS�N.4�I$P�	<8|i�o]��Nߖ-$�vw�"j'�BX��p�Ϳ
��Oûf�q��W����'�����0�!�$  ��z��� ���?����D#� �?AT��O 6�̻���i���p�ۃ'F��3	�	�	�@8p�iw�����T��@�֧4to�sa�Ղ�*���z��ޙI�u[,K#    (
+�RPe�$@$@$@$@$P&�e�bI�H�H�H�H�(�KA�y�	�	�	�	�@�P��	<�%    �R��/U�I$@$@$@$@e"@�_&�,�H�H�H�H�JA��T�'	�	�	�	�	��~���X    (
+�RPe�$@$@$@$@$P&�e�bI�H�H�H�H����Z�fF1�IDAT��eUu���{�u�6����`cTl�"����؂�h,�Ĉ�&1"Xb�bDl����C�3��=���z{ι����yw�����w�=w�������k����iӦ�*$B@! ��B@�L��/�Q!��B@! JD����B@! ��|���|T�z! ��B@! ��6 ��B@! �#D���ԣ! ��B@|�! ��B@!0! �?U�E!0����"�_���}R��,���a|�y�Xj���׭�R�ꢋ�<�`q��όs	����L:|�=f����ŋ��*n{��ǟ(�|���o��2��-V<����i=\��[[Z�,�T���ϼ�rq�����<׬s�u��e^�6+�Wfջ��Ϳ6�?l��r��6��l�:��7/�
~K,�`�ۊ+�&M*��UO<ٳ�[y�b�&O[���pu;i��:�F7[f�2�?<����W��iK/UL^b�����%���#�ϰv����A�D��.ϳ�a��a�D4�u�ae��-�x����>�r��W���3~���s�O<Qt��m���;ޘ��g�%�(�Z��D���M�h�[oQl��2e�˭���1Q�u������K/g>����d�2��x������p����3�a��'���(c�����+.�Hy���:o�b}Ҧ#}ܣ���w�ԍ����jy�_,^{ޅ��#cC7��3����_/J�����l�I���{W��r�Ż�f���
�&=���D¿gm|�Ը�c�a/ɉ����k۸�F����.��]<�A�O�X��^�����b�[-�lY����M��у�0����8���s�DŇ��6[��<���n�^j|
Y�܆�����q�����w�e3{�s"^�����X`�4�ƶg�c��D���9��c�M���fŎ֩�	m�s�F�+��������m[�Q���s�.��x�f��g+�X|k��e�O��k�S�.�lʺŇ�]�L��]2:p���h���K7�X�t��e�����v\e��&R�5(�A�2u��m��6����<�h��+���o�ۦ`@n"e�����M���o}#������OWZ���K/�^wѥ�ե����n?�{q��{ѯ�u�g�x]~?����Z����}��o����?����_X�ʫM	�ͦ����ۋ��qט ����I���l��*Yl���o������g�]|������, �a�\_j�f���lYL_zv\��ˊk�|j��j?��?�P��	ITBB:O0<������oYm�d�~��5�E���P’�{/���̈���7�?��۞y�8�VB�{�������n�E�R��|����_�{�h�?��f���l�^����LMy�E?���p_߰�*��7�Z��k7�2���P�fva�s9�H�?�r���xI#�'n����+Ӥ�����]x��| ��z��߫.��A�_U�~���6��s}i�
�w��z����xs�{�|���Ef�Mݾ�׵��M�'�a�Z������>k�V|y�
��6�y��O$vGY�t��w�����Ż�\�@�������o"�_����/��M�߻���g�_o��]M�@{����.����u�9hݵ�OL�ܨ��I�[J�ߚk���8��[�(���ڎ?�=7���9���QG�7Zj�∍�|F���FX7⊜��c�AW\���zoSV|���%l�rΣ����8�h?��??q��c,!z|�鯍�ӇQW���R����]|r��孪I�����?�#���ďcu�n��A���o��'��ۆ�֕s�ܟ����.��2�����x�>m-ZZ���Z�Ζx�xw�A}n��ˎ�4Ѹ���7��A|�-�}ִ
+4`�}m��e�:ayeR2��z�;-?{��j�d�u��u�;�@��ⷖM�И�%��f�{�v�2���:�C[�@ΰ��C���F�Y:��o*5���m�G�H�i��b˖��|z�n|�O�aF��i+f������ �a+	����^Vk�ʀ-�M��kY�2x�L�4�:y���z��o��e��{~�~����l��Vq�eW��y8�l��<�����k���|���5�:�����k{^��ָ���h��ϘF0.�_�do3����/<��H�m��O��'���������+Ʈ���2�����cn�1��|ӭ�&��"�fƶ�m^����iʠ�[�1���B:�-�`�U�A�������3�OM|/�O&�Lґ�����6ApY�ޟ��~�b3���m_Z�-��|:{����L���B�/�lq�Zk�[�q���3�� �gN|̛��Q>h��8��wߛ�gc-�h0���������ڴ��LK����l�ˈ��ڴicZ\���i�b��(�{�����KGm�=��^�@��|˖8�,qz�����I�Z[zA���ο�\~�;�@�L�~q��69��}�Mb<��	dž���9�����&����;�X����y!G���%Df�캓uf���?؛22�8�lz�4���M+���Z�X��z��_Z��{�^s���z6(��5��\�L��4�I>��_L�̝v('�aK�*�	w�[|���c�1�]�ߘD2�L���wf�����:��x�A�?]�o?��D[iLۊ��x��������洍Ks��&����1Ƥ`nKn���?#�<�S�`���#�>�ߚ�,&G���
>�C3q��[o+��~�
�D���䣉�.7~,׾���[La�N�WI�����{�cBv��}{Q���v"^�%�,۝d&4HԈ��8���؟�Q���b��Fn4�v�U^�̞o�S�t���/k�гwٱ�������]w����Ad�l�n�i>��@q�s��i䂁��f�D�(C0�4��g���#��N1md�F�k��,U׽�#<��fl],duH]��&i.�B�`6�zU-�F��\��
+�E:A$�?�]b{. q�a"�-������n0��ަ�@~�����7�=P|��9�Z��,�ml��x���j3%����<]�(���������US$V&V�|S�7n�mt����M�hd�(��P�N��ֈ����
ʹ�/m�_����@��6���Z��C�����<
��9��l`+�%VH����ߪ����&�S׶���
+;Y�᳦��=a��W�=�ӈSƮ�A߿����L��9i��/�MqǚRF�`R����%�
u{7��t��
3�g���M;{҈m�ћOkD��״��c�Kl}��%�rĦ�fjK�����f~5c��	\�UVV%�	6��_Ӱ������^��~H7����C�MY�/٪��E��R�2���מ�o��q��������yӅW:�p��K�^������^,^}��Idn����_W�߅���_������k���s�܅����.,*��I����/)m�S
>?@�72��Upj"���S|���a{a��6ˤ6d��]����T�Y��Y;�P��M~1���^aP�2�
�k��h,�B��v�6{p���N���)
+�����`g����cy��{���a
��K�vE���ހ��f�l\A0�����=����W�R>Z��Y��کF>iK��2{�a�*׳�\��#�/��$ˁ�E�K�Sm{�f�K-�S�?k�*�?���?��oF&>LX�� �6�����#3L�h� �du�z��t"�;@��W�c�$���
+K���6ǭ&�Hq5k�„-����Jn���h���}���'�z��`*9�S���f�O�5����\���Բ�'_,{��.-їm?N������}��B�$����l��^��o����t���J�j��ǘ�E4�iJ���wSO���n��J����֔�U�Al��i���]4GX�̉����������~H$����C)�_��\�ƿ�v+�l��C�X���I���������J���\���7��_������+���?���8@�DŽ�Jz�_�������oX��F�����%�1Rz���>��~�X�@;�&q�;�*MZ`��N-B�ĥ�ԋGU�x�AD���b�?e��rՓO���¦Y~�}2ڳ6�Y!8�
䯍$�����˄{�녟GC��3����ీ�}:�_�f��	�N%�_�Y'�eF�Y������=��k2�gZ��W\����8�ܒx0ID���x^���prv�c���YA;JH>6ɩ�"hM�/'��l҂}$Z�#o��,Z04�K�,�"U��ٗ����4Ra�A�w��b�Ŵ���"M��~���^��_|�����J3�*R��p?����������
}��j� 2����Vg�n��/��f���7۴���p�md��\��G��%��??�Mj�A�wl�O�:�' �:����t��	�;X(��[���w��D��u�q�6�?b��M	>�C0���:�`��!4��A�L<����1[m^�M=_A����wf���;M	~��C~�����0�/2E�ƣ�4,y��|�q����8��j	}B��ZG����;lW*X�F��~���1-���.������É�U������L���-7%�<{���g���/�Y(ZC`�0��2�Ы��=�����B�8ӈ���$�m�G�fd4�,�� ��:�-���i�9���p��_&��_S�5���+�8��+j�cV���|��Wf���`c-�j&���A����G��z�]Q6�bg�D�;|�<���2u��T܋�7�����@���m#z�b�����;�6�>6l�����[F
+�8���Oy�mF|�~�4Ƨ�渫4iL$ζ	�2}H�s�
L�y�X���GZ?H��5���ee���+��ge%���V��m����������E����E���?�O�t��㶐�.��9IhچК���=��'��\w���?Nм"�((	��MfW�XV�nJ�	Wi��?�����!��hQ��7��4k��[��b��2�!̧���y��nS�O�.�x����z��
ެ��g}��+|�������)��{�=h���EЙ�q�
FWb��'��?��uW�Oܶ��8�?���?���v��<�D��M���&<�&�Ґ�Q�{`���M���nr�=�����oM%~�&�A��. �P�n���`�芫
��79�A3�ӊǥ1���=���^��8ha�3�-���
�k�cX���w!�_4��!٘���玚����
��Rx¦ ��D�/�x�'���-�P�ԗܤR5@�������o�7�a�pXI�3B�ǒ�J����W=��Q�|�#��tS��]7i�S+nw�&l6c�q�M��r�e��}h�O솱K�������B[~��"0�`Y�:����"l��;YiK��d���ѳ�jQ�����w�z֫<m	~n��u�q�6�	>
+���l���)7R'UuІ�Gof���i��=c��ˉ7��?��#]�C������K?i�L��1�=�ޅ�wi?��z�A��.3���V_�h���,���+�����G35V$�Xv�b��&_������������<s	�?��?����=ܐ�+U�E��E�Ix;3�H��9�Igq�����CL-�m�x���{xH��/��,w�e�C�|H	�/�\A8?d/Vz��瑛��S�ل`y\'����*R����B�Y�����er;���ћM+���y����v�h���p��[���"��'�eb�_���m4�\�ɶM����+h�����S\���M��}'Ktһ&�|�P4�2�1�J�LJs��o����L��� n��s�&R�k��<�5�u�B��JH�/�*�ߦ��	�?�]0�e���;����=v�vS�T)<n�lK�cܪ�h:P�I�*��Y~k��'\N�������d�q�6�	>��'x�K�dV�iC���i޳7�D��Z��������T��>����=����N�z�-9��>+�(��I�O�m�qr�Oo?N��59X�јM������a��d�O�b�?����?��	j�0p;'q������t��+���?�3�������t����O�S�E��E��l��ܝaj�X�`ٸq#=^h�x����k Q܋��Bc���i�n��op�Ϳ̰ǿ&ˣ�<�2�^0���X�]>�CH��[�����F��X���0��~�8�D8��7ߢ��%I���
+I�/���u�����a����Z[&��3j��h��}����)����T
+�'+f�Q���Hb�^%Ͻ�򘕏�0xD�)�'w�,��+Mڟ�a#��@7��Л�Yc��%���������<�^�as�O�/���>8�D��Űu�]>��o�w��$xd���1y��#�J,S����t���k�����Ӑ���[o�����>�|�N���;H|�qx��d3Ϻ�<*Eaʼn�
��-b���&`1�xݕ�m?��z�q�&�k`�c�KP�!�ޟ��i�őv�8Ag���˜��1�S�\k��������iz]	~�I���SZ��{n��/�~��_�1�� ��IEt�6��m���|�p����Y'��ץ���,˧�-���/���K�"	��l�ﳎ��68m��ا#L�路
"Q����{��8M�/'��|��s��M��5Mޛ�{ٕ�n+�P�MH=�P�8@���gc/�=�.)4y,[�J�����;�X�����G#���M�R���m����K�I+�&�'�a3>�\0�z��W��F�m	>'T����Z�Q6ͰA����O��u����V���	ڼ&���ۘbcҖ����d3�c�NJў�)�w��	,�=l�ņ�����{K�w���.��+�'n��C����ۏ|�t3����!+�l�Gz�?(ٓ�s&K��
+��^�8A�7���j }��/�xӪ�@�/7���"�����HЫ��Ɵ�������?͛�&��u��kYI�����m��Ï���/6�򪊘��F���Ӫ��G��8m^�\��[����a9�\�Bjݴ��7�o��VN��Z�_��C�b8�1��/Jn�����y�y�A��R���zS�`�6�C��f�6dzc�Q�L�;@9l&��콱���kx�9�<��\˾ΈG���GZ^������M������:uO�W}�!��@*ܤ��7����b�^��EPҲt%�]��H�Ӳ�������L��+n�c�>?��"Lq��m���&�8�`����Y�o�~x�����O$�q���
h��_���8{����<ƾ*�"��I�C6�WIn�4Ͷ������[A� �t�>s��\��/���N�cS&��w����e2p;��}o*h����P�����l�����t�7;#�{�ɤ��H���`�',Aa7���O~��̚q!�nҌ�f�~��0a8q����X)7/(��$'�\��������Gt{��/� �џ�?a�k�?���2.���-Z]��BU6�d�8f�����k����x1����S/�T��4.t�t��M�0	\��"%w�"�xڡO�bu׃hl`e��x_�0��a�m�d8�����n0�2��TM�rۏ��1�'e��_L����VtR��/�lC��T���V�y�I����A�c�w(+^O��^e�����u$��]ް��ڏ���3�-�<�Q2QO���J�џ���t��d����2�K;'��˓���{g����d�	>���Ƌ��3�6RG�9�OD��ךI
���c�n��s�5���+�\3y}���s#>�Gi������?���n��������]��|w��3E��`*5�4�c�H#U���
+�Mg����j���]v*7��-%�e�����Q�g� �\|sO�mu���ɟ<�+DNЌ&؟2�3� M	>a�����e�bK����e���u��c�iq7iW����e�����#�| ~��zq"K��ߺ�0��n�mZ�����.��	|hT1�`�K���\���6�)����	T�^�*������d��x��-�B����1�"������Ǻ>(N~�4���g��s^ƊF���c���j�������/�/:3�ha�:9q����	$�ƃ�������x�����'LU+[����O,C�k'Դ���<N�
�����~;�*׊^�%&�H������;��9|\g5�������n1h���0�aʝ�����L	>������̋�I�ι*�������W��fL]�_,���on���L^]��OP�V�I3$�qٷ��:�$V5���O
+���&������v7��~��&��Mt񔻺<'�)j.xl�Yn[j��Jr�e	[�_��#.'�U�R��?�([��/���Z�R�~Ѱ��|��D_̱���*�>�F
+�āF�����i���(���$�u5�e`����r������Q�geB�h���㶐	&��b�ԍa.�gs��!n����p�y����Y� E����%����m��&uQ|ƽ��ro#]��FO$^�`�|̐�{4��d�O"!e/G��fx�B���܏�_GP��x|��.�e�xU���0aq��nx̍߫��g�J�?>er� #�Y5���h�ϱ�/ L��m�=��{�+Z��e~ �Q1�xU�?��D:�ƒױv&	8s�Z�TI�����GS!�/�)���izm���O:]�g�Un���U�-����o<��!�uʏO�1�F�$�ǿe�Y��E&-P�μo�DXA����c7|F�9��ػ�)ݷ�_^b��G4]Q��	���cK}����ǽM��vex���]ܸ�2�Qf�˲4yC0�5S��myp_�IF�����^r�dK�v�~�?�B�H��1��U�`�m��R�� �K�����?g�����m�é����l6����)�P�x�8���Dk�D����vbG�ffH<$u#F���-;/
+
'�Eɭ���!��|�x��o�۶<0���V�"�g�#����s��$ew���h�}yl�p��x���gj�������V=__�~iCQr�n�x��h��A��J����4������䶟�RĦ�}.�t���X6���ӓli���.��q@
+���z�7G�����'3g�AE�����90�*�⿥��E�c��(3�W�C��c�`�	i%m~GX!���ψ�s��j?��Op��P�gY���s�w�]c�`5ma���r��x�q�~���
+~s7��=�I�����=F��
+w��ia�W��S�`�K��j�o0���ྲྀ�Wn�Ye�ϳ��i���U9���a�/p���7���"�Z%q�VE�)}'&È�K�ύ��-��?q��O��ጣ��ߦHm�cۥ���]�q���$x߾o}��s��B��@?p��%���у��c��󅚃&`f���Ic�/��9~�p���� ��$u��o���{��[���������h1�Z��r���ϭ��4X?J�-�'�����O�?[K�ڧ�$�k�����]ھ���?��W_WY�e�L���v��٠	���-+�LfK~w�g��7�6�����s�і���=��c��&��M�q�"\/����6����o���?\`�
+��
�u>������I6�{v��?҉��t����}U�r��l��%�az�W[=?�ٖ�q�ݩ���V�(�pNSn"�A�ĉ�{ �.a2�S���>Nj��w������U�g�ԣx�������~���9���z]�
+���N|��7=`���㾑(?�>�ݦ�!^<�;���mǯ����l�,����?����!#��u{������Pٓ���/���[�'L�]�.7���?������OL81嬓^��89���ٕ�1	;qۭ��V	��1�G� �tԜL����9�4�î���8�Ʉ�ibV�OOpI����u���cf�t�יƔ�M��M1H5خ��;�ŧ鰄��L�>lY�@�jڐ�G��"��e�Z��t����M�u�6���/�%nzd�'��~R��ǫ�m����u����%� >��dkh�6�M�ԟ�cL3Yw���k��ZH��v��k�>g���LÏԙϴ��0�v��(~�G�Ww]��޵��yzg�ۺՇ�宲E�i��L4�&�Q�[�A�H�Yw�PN�����Q�
��:�(���;;�S�M����YE�لv���w���U7��K4�~�!���G�D���wl�ѷ��$Zyj�o�����?%��q���3���M�W$>��=J��M�O�'�v��ޯ�ޏ���������&�����ԥ�,g�sV�h۩i��٦�!�/�=r*n���r�Oǿj�~z�o|�W��R�{��+n��*��$S:��%�ݒ�M�0�px+R�dGOr���Dÿ������O���3���ѥ	���]���]�}<�2VK�c�hF]�#�IM�}�fM^|	�f�z�-/H�O|S�9�{cb��+���f�13�ar�F�\�Z��lKq�][�tr��#΀��	T��6��ܲ�?u���tsun���qNq/v�MNR��^q�ۼ���5����1�1����3��'��3��!\o�ŋUl�Ǔf�ó���m��ԍF�kr�E��6i6	���c����r ��6�?7~�<�V��;3�@��b�	V[�;L7W��|������'9��c9�w�����`c� ?m�N�J�5=iڴiL\$@�hX�J ��DjC8�lj�������`�wl�!KlP�ӂ6HNA�@+��Z�5����h�rl��N9��WD��8qŴj���3T�B`>F`n�7��
��I\�`����qɄ]�?��9���-U�h__��*��	�A ��7����*���T.��y'��q˭�y{��
^�77�f��PM�3'��4B`� 0^�M�c�^���ˍ,U�1�E#$unɼ�n<���Ʋ9�	}l����@@�o��(HsbC ��{��G ڋ�9�n�?�J(�>���D�;�%�8�*�#/�Xn|�Ĺ��<���=�k�|��>�}��*ㅀ���C�﷽6.��-����=���j7
zx}Np������	�! �!0^�M�Y}(�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+D����TH! ��B@!��f8)�B@! ��
+�
,�A�5�IEND�B`�
 --6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm--
@@ -1000,10 +1460,10 @@

Response

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 29 +Content-Length: 27 { - "errorCode" : "A0002" + "errorCode" : "A0001" } @@ -1030,26 +1490,33 @@

Response

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 518 +Content-Length: 724 [ { "id" : 1, - "imageUrls" : [ "iamge1Url", "image2Url" ], + "imageUrls" : [ "image1Url", "image2Url" ], "githubRepoUrl" : "githubRepoUrl", "content" : "content", "authorName" : "authorName", "profileImageUrl" : "profileImageUrl", "likesCount" : 1, "tags" : [ "tag1", "tag2" ], - "createdAt" : "2021-07-18T21:53:06.4548197", - "updatedAt" : "2021-07-18T21:53:06.4548197", + "createdAt" : "2021-07-31T17:07:50.902729", + "updatedAt" : "2021-07-31T17:07:50.90274", "comments" : [ { "id" : 1, - "authorName" : "commentAuthorName", - "content" : "commentContent", - "isLiked" : false + "profileImageUrl" : "commentAuthorProfileImageUrl", + "authorName" : "commentAuthorName1", + "content" : "commentContent1", + "liked" : false + }, { + "id" : 2, + "profileImageUrl" : "commentAuthorProfileImageUrl", + "authorName" : "commentAuthorName2", + "content" : "commentContent2", + "liked" : false } ], - "isLiked" : false + "liked" : false } ] @@ -1075,26 +1542,33 @@

Response

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 518 +Content-Length: 725 [ { "id" : 1, - "imageUrls" : [ "iamge1Url", "image2Url" ], + "imageUrls" : [ "image1Url", "image2Url" ], "githubRepoUrl" : "githubRepoUrl", "content" : "content", "authorName" : "authorName", "profileImageUrl" : "profileImageUrl", "likesCount" : 1, "tags" : [ "tag1", "tag2" ], - "createdAt" : "2021-07-18T21:53:06.3530522", - "updatedAt" : "2021-07-18T21:53:06.3530522", + "createdAt" : "2021-07-31T17:07:50.767789", + "updatedAt" : "2021-07-31T17:07:50.767801", "comments" : [ { "id" : 1, - "authorName" : "commentAuthorName", - "content" : "commentContent", - "isLiked" : false + "profileImageUrl" : "commentAuthorProfileImageUrl", + "authorName" : "commentAuthorName1", + "content" : "commentContent1", + "liked" : false + }, { + "id" : 2, + "profileImageUrl" : "commentAuthorProfileImageUrl", + "authorName" : "commentAuthorName2", + "content" : "commentContent2", + "liked" : false } ], - "isLiked" : false + "liked" : false } ] @@ -1121,12 +1595,14 @@

Response

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 51 +Content-Length: 146 [ { - "name" : "pick" + "name" : "pick", + "html_url" : "https://github.com/jipark3/pick" }, { - "name" : "git" + "name" : "git", + "html_url" : "https://github.com/jipark3/git" } ] @@ -1160,21 +1636,21 @@

Response

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 350 +Content-Length: 341 { - "name" : "yjksw", - "image" : "http://img.com", + "name" : "testUser", + "imageUrl" : "http://img.com", "description" : "The Best", "followerCount" : 0, - "followingCount" : 11, - "postCount" : 1, + "followingCount" : 0, + "postCount" : 0, "githubUrl" : "https://github.com/yjksw", "company" : "woowacourse", "location" : "Seoul", "website" : "www.pick-git.com", "twitter" : "pick-git twitter", - "following" : false + "following" : null } @@ -1186,7 +1662,7 @@

다른 사용자 프로필

Request

-
GET /api/profiles/testUser%7D HTTP/1.1
+
GET /api/profiles/testUser2 HTTP/1.1
 Content-Type: application/json;charset=UTF-8
 Authorization: Bearer testToken
 Accept: */*
@@ -1203,21 +1679,21 @@ 

Response

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 350 +Content-Length: 342 { - "name" : "yjksw", - "image" : "http://img.com", + "name" : "testUser2", + "imageUrl" : "http://img.com", "description" : "The Best", - "followerCount" : 0, - "followingCount" : 11, - "postCount" : 1, + "followerCount" : 1, + "followingCount" : 0, + "postCount" : 0, "githubUrl" : "https://github.com/yjksw", "company" : "woowacourse", "location" : "Seoul", "website" : "www.pick-git.com", "twitter" : "pick-git twitter", - "following" : false + "following" : true }
@@ -1229,7 +1705,7 @@

다른 사용자 프

Request

-
GET /api/profiles/testUser%7D HTTP/1.1
+
GET /api/profiles/testUser HTTP/1.1
 Content-Type: application/json;charset=UTF-8
 Accept: */*
 Host: localhost:8080
@@ -1245,15 +1721,15 @@

Response

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 349 +Content-Length: 341 { - "name" : "yjksw", - "image" : "http://img.com", + "name" : "testUser", + "imageUrl" : "http://img.com", "description" : "The Best", "followerCount" : 0, - "followingCount" : 11, - "postCount" : 1, + "followingCount" : 0, + "postCount" : 0, "githubUrl" : "https://github.com/yjksw", "company" : "woowacourse", "location" : "Seoul", @@ -1319,7 +1795,7 @@

Response

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 29 +Content-Length: 27 { "errorCode" : "A0001" @@ -1349,7 +1825,7 @@

Response

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 29 +Content-Length: 27 { "errorCode" : "V0001" @@ -1366,7 +1842,7 @@

Response

diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/PickGitApplicationTests.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/PickGitApplicationTests.java index a142774e9..2cdca7fff 100644 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/PickGitApplicationTests.java +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/PickGitApplicationTests.java @@ -1,17 +1,17 @@ package com.woowacourse.pickgit; -import com.woowacourse.pickgit.post.PostTestConfiguration; +import com.woowacourse.pickgit.config.InfrastructureTestConfiguration; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; -@Import(PostTestConfiguration.class) +@Import(InfrastructureTestConfiguration.class) @SpringBootTest @ActiveProfiles("test") class PickGitApplicationTests { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + } } diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/OAuthAcceptanceTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/acceptance/authentication/OAuthAcceptanceTest.java similarity index 61% rename from backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/OAuthAcceptanceTest.java rename to backend/pick-git/src/test/java/com/woowacourse/pickgit/acceptance/authentication/OAuthAcceptanceTest.java index 6f6ae5989..c9bc6f65a 100644 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/OAuthAcceptanceTest.java +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/acceptance/authentication/OAuthAcceptanceTest.java @@ -1,4 +1,4 @@ -package com.woowacourse.pickgit.authentication; +package com.woowacourse.pickgit.acceptance.authentication; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; @@ -7,7 +7,7 @@ import com.woowacourse.pickgit.authentication.domain.OAuthClient; import com.woowacourse.pickgit.authentication.presentation.dto.OAuthLoginUrlResponse; import com.woowacourse.pickgit.authentication.presentation.dto.OAuthTokenResponse; -import com.woowacourse.pickgit.post.PostTestConfiguration; +import com.woowacourse.pickgit.config.InfrastructureTestConfiguration; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; @@ -25,11 +25,11 @@ import org.springframework.test.annotation.DirtiesContext.ClassMode; import org.springframework.test.context.ActiveProfiles; -@Import(PostTestConfiguration.class) +@Import(InfrastructureTestConfiguration.class) @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD) @ActiveProfiles("test") -public class OAuthAcceptanceTest { +class OAuthAcceptanceTest { @LocalServerPort int port; @@ -44,7 +44,7 @@ void setUp() { @DisplayName("로그인 - Github OAuth 로그인 URL을 요청한다.") @Test - void Authorization_Github_ReturnLoginUrl() { + void githubAuthorizationUrl_Github_ReturnLoginUrl() { // mock when(oAuthClient.getLoginUrl()).thenReturn("https://github.com/login/oauth/authorize?"); @@ -64,31 +64,58 @@ void Authorization_Github_ReturnLoginUrl() { ).isTrue(); } - @DisplayName("로그인 - Github 인증후 리다이렉션을 통해 요청이 오면 토큰을 생성하여 반환한다.") + @DisplayName("첫 로그인 - Github 인증후 리다이렉션을 통해 요청이 오면 토큰을 생성하여 반환한다.") @Test - void Authorization_Redirection_ReturnJwtToken() { + void afterAuthorizeGithubLogin_InitialLogin_ReturnJwtToken() { + //given + OAuthProfileResponse oAuthProfileResponse = OAuthProfileResponse.builder() + .name("pick-git-login") + .description("hi~") + .githubUrl("github.com/") + .build(); + // then - OAuthTokenResponse tokenResponse = 로그인_되어있음(); + OAuthTokenResponse tokenResponse = 로그인_되어있음(oAuthProfileResponse); assertThat(tokenResponse.getToken()).isNotBlank(); assertThat(tokenResponse.getUsername()).isEqualTo("pick-git-login"); } - public OAuthTokenResponse 로그인_되어있음() { - OAuthTokenResponse response = 로그인_요청().as(OAuthTokenResponse.class); + @DisplayName("재 로그인 - 첫 로그인 이후로 동일한 유저로 로그인하면 정보 업데이트 후 토큰을 생성하여 반환한다.") + @Test + void afterAuthorizeGithubLogin_ReLogin_ReturnJwtToken() { + //given + OAuthProfileResponse previousOAuthProfileResponse = OAuthProfileResponse.builder() + .name("pick-git-login") + .description("hi~") + .githubUrl("github.com/") + .build(); + + OAuthProfileResponse afterOAuthProfileResponse = OAuthProfileResponse.builder() + .name("pick-git-login") + .description("bye~") + .githubUrl("github.com/") + .build(); + + //when + 로그인_되어있음(previousOAuthProfileResponse); + + // then + OAuthTokenResponse tokenResponse = 로그인_되어있음(afterOAuthProfileResponse); + assertThat(tokenResponse.getToken()).isNotBlank(); + assertThat(tokenResponse.getUsername()).isEqualTo("pick-git-login"); + } + + private OAuthTokenResponse 로그인_되어있음(OAuthProfileResponse oAuthProfileResponse) { + OAuthTokenResponse response = 로그인_요청(oAuthProfileResponse).as(OAuthTokenResponse.class); assertThat(response.getToken()).isNotBlank(); return response; } - public ExtractableResponse 로그인_요청() { + private ExtractableResponse 로그인_요청(OAuthProfileResponse oAuthProfileResponse) { // given String oauthCode = "1234"; String accessToken = "oauth.access.token"; - OAuthProfileResponse oAuthProfileResponse = new OAuthProfileResponse( - "pick-git-login", "image", "hi~", "github.com/", - null, null, null, null - ); - // mock when(oAuthClient.getAccessToken(oauthCode)).thenReturn(accessToken); when(oAuthClient.getGithubProfile(accessToken)).thenReturn(oAuthProfileResponse); diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/acceptance/post/PostAcceptanceTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/acceptance/post/PostAcceptanceTest.java new file mode 100644 index 000000000..4e6fb8e7b --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/acceptance/post/PostAcceptanceTest.java @@ -0,0 +1,887 @@ +package com.woowacourse.pickgit.acceptance.post; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import com.woowacourse.pickgit.authentication.application.dto.OAuthProfileResponse; +import com.woowacourse.pickgit.authentication.domain.OAuthClient; +import com.woowacourse.pickgit.authentication.presentation.dto.OAuthTokenResponse; +import com.woowacourse.pickgit.common.factory.FileFactory; +import com.woowacourse.pickgit.config.InfrastructureTestConfiguration; +import com.woowacourse.pickgit.exception.authentication.UnauthorizedException; +import com.woowacourse.pickgit.exception.dto.ApiErrorResponse; +import com.woowacourse.pickgit.exception.post.CannotUnlikeException; +import com.woowacourse.pickgit.exception.post.DuplicatedLikeException; +import com.woowacourse.pickgit.post.application.dto.response.CommentResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.PostResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.RepositoryResponseDto; +import com.woowacourse.pickgit.post.presentation.dto.request.ContentRequest; +import com.woowacourse.pickgit.post.presentation.dto.request.PostUpdateRequest; +import com.woowacourse.pickgit.post.presentation.dto.response.LikeResponse; +import com.woowacourse.pickgit.post.presentation.dto.response.PostUpdateResponse; +import io.restassured.RestAssured; +import io.restassured.common.mapper.TypeRef; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EmptySource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@Import(InfrastructureTestConfiguration.class) +@DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD) +@ActiveProfiles("test") +class PostAcceptanceTest { + + private static final String ANOTHER_USERNAME = "pick-git-login"; + private static final String USERNAME = "jipark3"; + + @LocalServerPort + int port; + + @MockBean + private OAuthClient oAuthClient; + + private String githubRepoUrl; + private List tags; + private String content; + + private Map request; + + @BeforeEach + void setUp() { + RestAssured.port = port; + + githubRepoUrl = "https://github.com/woowacourse-teams/2021-pick-git"; + tags = List.of("java", "spring"); + content = "this is content"; + + Map body = new HashMap<>(); + body.put("githubRepoUrl", githubRepoUrl); + body.put("tags", tags); + body.put("content", content); + request = body; + } + + @DisplayName("사용자는 게시글을 등록한다.") + @Test + void write_LoginUser_Success() { + // given + String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); + + // when + requestWrite(token); + } + + @DisplayName("사용자는 태그 없이 게시글을 작성할 수 있다.") + @Test + void write_LoginUserWithNoneTags_Success() { + // given + String token = 로그인_되어있음(USERNAME).getToken(); + + // when, then + given().log().all() + .auth().oauth2(token) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .formParams("githubRepoUrl", githubRepoUrl) + .formParams("content", content) + .multiPart("images", FileFactory.getTestImage1File()) + .multiPart("images", FileFactory.getTestImage2File()) + .when() + .post("/api/posts") + .then().log().all() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + } + + @DisplayName("잘못된 태그 이름을 가진 게시글을 작성할 수 없다.") + @ParameterizedTest + @EmptySource + @ValueSource(strings = {" ", " ", "abcdeabcdeabcdeabcdea"}) + void write_LoginUserWithInvalidTags_Fail(String tagName) { + // given + String token = 로그인_되어있음(USERNAME).getToken(); + List invalidTags = List.of("Java", "JavaScript", tagName); + + // when + ApiErrorResponse response = given().log().all() + .auth().oauth2(token) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .formParams("githubRepoUrl", githubRepoUrl) + .formParams("content", content) + .formParams("tags", invalidTags) + .multiPart("images", FileFactory.getTestImage1File()) + .multiPart("images", FileFactory.getTestImage2File()) + .when() + .post("/api/posts") + .then().log().all() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .extract() + .as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("F0003"); + } + + @DisplayName("사용자는 중복된 태그를 가진 게시글을 작성할 수 없다.") + @Test + void write_LoginUserWithDuplicatedTags_Fail() { + // given + String token = 로그인_되어있음(USERNAME).getToken(); + List duplicatedTags = List.of("Java", "JavaScript", "Java"); + + // when + ApiErrorResponse response = given().log().all() + .auth().oauth2(token) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .formParams("githubRepoUrl", githubRepoUrl) + .formParams("content", content) + .formParams("tags", duplicatedTags) + .multiPart("images", FileFactory.getTestImage1File()) + .multiPart("images", FileFactory.getTestImage2File()) + .when() + .post("/api/posts") + .then().log().all() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .extract() + .as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("P0001"); + } + + @DisplayName("로그인일때 게시물을 조회한다. - 댓글 및 게시글의 좋아요 여부를 확인할 수 있다.") + @Test + void read_LoginUser_Success() { + String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); + + requestToWritePostApi(token, HttpStatus.CREATED); + requestToWritePostApi(token, HttpStatus.CREATED); + requestToWritePostApi(token, HttpStatus.CREATED); + + List response = given().log().all() + .auth().oauth2(token) + .when() + .get("/api/posts?page=0&limit=3") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract() + .as(new TypeRef>() { + }); + + assertThat(response) + .hasSize(3) + .extracting("liked") + .containsExactly(false, false, false); + } + + @DisplayName("비 로그인이어도 게시글 조회가 가능하다. - Comment 및 게시물 좋아요 여부는 항상 false") + @Test + void read_GuestUser_Success() { + String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); + + requestToWritePostApi(token, HttpStatus.CREATED); + requestToWritePostApi(token, HttpStatus.CREATED); + requestToWritePostApi(token, HttpStatus.CREATED); + + List response = given().log().all() + .when() + .get("/api/posts?page=0&limit=3") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract() + .as(new TypeRef>() { + }); + + assertThat(response) + .hasSize(3) + .extracting("liked") + .containsExactly(null, null, null); + } + + @DisplayName("로그인 상태에서 내 피드 조회가 가능하다.") + @Test + void readMyFeed_LoginUser_Success() { + String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); + + requestToWritePostApi(token, HttpStatus.CREATED); + requestToWritePostApi(token, HttpStatus.CREATED); + requestToWritePostApi(token, HttpStatus.CREATED); + + List response = given().log().all() + .auth().oauth2(token) + .when() + .get("/api/posts/me?page=0&limit=3") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract() + .as(new TypeRef>() { + }); + + assertThat(response) + .hasSize(3) + .extracting("liked") + .containsExactly(false, false, false); + } + + @DisplayName("비로그인 상태에서는 내 피드 조회가 불가능하다.") + @Test + void readMyFeed_GuestUser_Success() { + String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); + + requestToWritePostApi(token, HttpStatus.CREATED); + requestToWritePostApi(token, HttpStatus.CREATED); + requestToWritePostApi(token, HttpStatus.CREATED); + + given().log().all() + .when() + .get("/api/posts/me?page=0&limit=3") + .then() + .statusCode(HttpStatus.UNAUTHORIZED.value()); + } + + @DisplayName("로그인 상태에서 다른 유저 피드 조회가 가능하다.") + @Test + void readUserFeed_LoginUser_Success() { + String loginUserToken = 로그인_되어있음(USERNAME).getToken(); + String targetUserToken = 로그인_되어있음(ANOTHER_USERNAME).getToken(); + + requestToWritePostApi(targetUserToken, HttpStatus.CREATED); + requestToWritePostApi(targetUserToken, HttpStatus.CREATED); + requestToWritePostApi(targetUserToken, HttpStatus.CREATED); + + List response = given().log().all() + .auth().oauth2(loginUserToken) + .when() + .get("/api/posts/" + ANOTHER_USERNAME + "?page=0&limit=3") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract() + .as(new TypeRef>() { + }); + + assertThat(response) + .hasSize(3) + .extracting("liked") + .containsExactly(false, false, false); + } + + @DisplayName("비 로그인 상태에서 다른 유저 피드 조회가 가능하다.") + @Test + void readUserFeed_GuestUser_Success() { + String targetUserToken = 로그인_되어있음(ANOTHER_USERNAME).getToken(); + + requestToWritePostApi(targetUserToken, HttpStatus.CREATED); + requestToWritePostApi(targetUserToken, HttpStatus.CREATED); + requestToWritePostApi(targetUserToken, HttpStatus.CREATED); + + List response = given().log().all() + .when() + .get("/api/posts/" + ANOTHER_USERNAME + "?page=0&limit=3") + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract() + .as(new TypeRef>() { + }); + + assertThat(response) + .hasSize(3) + .extracting("liked") + .containsExactly(null, null, null); + } + + @DisplayName("게스트는 게시글을 등록할 수 없다. - 유효하지 않은 토큰이 있는 경우 (Authorization header O)") + @Test + void write_GuestUserWithToken_Fail() { + // given + String token = "Bearer guest"; + + // when + requestToWritePostApi(token, HttpStatus.UNAUTHORIZED); + } + + @DisplayName("게스트는 게시글을 등록할 수 없다. - 토큰이 없는 경우 (Authorization header X)") + @Test + void write_GuestUserWithoutToken_Fail() { + // when + given().log().all() + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .formParams(request) + .multiPart("images", FileFactory.getTestImage1File()) + .multiPart("images", FileFactory.getTestImage2File()) + .when() + .post("/api/posts") + .then().log().all() + .statusCode(HttpStatus.UNAUTHORIZED.value()) + .extract(); + } + + private ExtractableResponse requestToWritePostApi(String token, + HttpStatus httpStatus) { + return given().log().all() + .auth().oauth2(token) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .formParams(request) + .multiPart("images", FileFactory.getTestImage1File()) + .multiPart("images", FileFactory.getTestImage2File()) + .when() + .post("/api/posts") + .then().log().all() + .statusCode(httpStatus.value()) + .extract(); + } + + @DisplayName("User는 Comment을 등록할 수 있다.") + @Test + void addComment_LoginUser_Success() { + // given + String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); + Long postId = 1L; + + requestWrite(token); + + ContentRequest request = new ContentRequest("this is content"); + + // when + CommentResponseDto response = requestAddComment(token, postId, request, HttpStatus.OK) + .as(CommentResponseDto.class); + + // then + assertThat(response.getAuthorName()).isEqualTo(ANOTHER_USERNAME); + assertThat(response.getContent()).isEqualTo("this is content"); + } + + @DisplayName("비로그인 User는 Comment를 등록할 수 없다.") + @Test + void addComment_GuestUser_Fail() { + // given + String writePostToken = 로그인_되어있음(ANOTHER_USERNAME).getToken(); + String invalidToken = "invalid token"; + Long postId = 1L; + + requestWrite(writePostToken); + + ContentRequest request = new ContentRequest("this is content"); + + // when + ApiErrorResponse response + = requestAddComment(invalidToken, postId, request, HttpStatus.UNAUTHORIZED) + .as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("A0001"); + } + + private void requestWrite(String token) { + given().log().all() + .auth().oauth2(token) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .formParams(request) + .multiPart("images", FileFactory.getTestImage1File()) + .multiPart("images", FileFactory.getTestImage2File()) + .when() + .post("/api/posts") + .then().log().all() + .statusCode(HttpStatus.CREATED.value()) + .extract(); + } + + @DisplayName("Comment 내용이 빈 경우 예외가 발생한다. - 400 예외") + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", " "}) + void addComment_NullOrEmpty_400Exception(String content) { + // given + String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); + Long postId = 1L; + ContentRequest request = new ContentRequest(content); + + // when + ApiErrorResponse response = requestAddComment(token, postId, request, + HttpStatus.BAD_REQUEST) + .as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("F0001"); + } + + @DisplayName("존재하지 않는 Post에 Comment를 등록할 수 없다. - 500 예외") + @Test + void addComment_PostNotFound_500Exception() { + // given + String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); + Long postId = 0L; + ContentRequest request = new ContentRequest("a"); + + // when + ApiErrorResponse response = requestAddComment(token, postId, request, + HttpStatus.INTERNAL_SERVER_ERROR) + .as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("P0002"); + } + + @DisplayName("Comment 내용이 100자 초과인 경우 예외가 발생한다. - 400 예외") + @Test + void addComment_Over100_400Exception() { + // given + String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); + Long postId = 1L; + ContentRequest request = new ContentRequest("a".repeat(101)); + + // when + ApiErrorResponse response = requestAddComment(token, postId, request, + HttpStatus.BAD_REQUEST) + .as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("F0002"); + } + + private ExtractableResponse requestAddComment( + String token, + Long postId, + ContentRequest request, + HttpStatus httpStatus) { + return given().log().all() + .auth().oauth2(token) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(request) + .when() + .post("/api/posts/{postId}/comments", postId) + .then().log().all() + .statusCode(httpStatus.value()) + .extract(); + } + + @DisplayName("사용자는 Repository 목록을 가져올 수 있다.") + @Test + void showRepositories_LoginUser_Success() { + // given + String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); + + // when + List response = + request(token, USERNAME, HttpStatus.OK.value()) + .as(new TypeRef<>() { + }); + + // then + assertThat(response).hasSize(2); + } + + @DisplayName("토큰이 유효하지 않은 경우 예외가 발생한다. - 500 예외") + @Test + void showRepositories_InvalidAccessToken_500Exception() { + // given + String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); + + // when + request(token + "hi", USERNAME, HttpStatus.UNAUTHORIZED.value()); + } + + @DisplayName("사용자가 유효하지 않은 경우 예외가 발생한다. - 500 예외") + @Test + void showRepositories_InvalidUsername_400Exception() { + // given + String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); + + // when + ApiErrorResponse response = + request(token, USERNAME + "pika", HttpStatus.INTERNAL_SERVER_ERROR.value()) + .as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("V0001"); + } + + @DisplayName("로그인 사용자는 게시물을 좋아요 할 수 있다. - 성공") + @Test + void likePost_LoginUser_Success() { + // given + String loginUserToken = 로그인_되어있음(USERNAME).getToken(); + String targetUserToken = 로그인_되어있음(ANOTHER_USERNAME).getToken(); + Long postId = 1L; + + requestToWritePostApi(targetUserToken, HttpStatus.CREATED); + + // when + LikeResponse response = given().log().all() + .auth().oauth2(loginUserToken) + .when() + .put("/api/posts/{postId}/likes", postId) + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertThat(response.getLikesCount()).isEqualTo(1); + assertThat(response.getLiked()).isTrue(); + } + + @DisplayName("로그인 사용자는 게시물을 좋아요 취소 할 수 있다. - 성공") + @Test + void unlikePost_LoginUser_Success() { + // given + String loginUserToken = 로그인_되어있음(USERNAME).getToken(); + String targetUserToken = 로그인_되어있음(ANOTHER_USERNAME).getToken(); + Long postId = 1L; + + requestToWritePostApi(targetUserToken, HttpStatus.CREATED); + + LikeResponse likePostResponse = given().log().all() + .auth().oauth2(loginUserToken) + .when() + .put("/api/posts/{postId}/likes", postId) + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract() + .as(new TypeRef<>() { + }); + + assertThat(likePostResponse.getLikesCount()).isEqualTo(1); + assertThat(likePostResponse.getLiked()).isTrue(); + + // when + LikeResponse unlikePostResponse = given().log().all() + .auth().oauth2(loginUserToken) + .when() + .delete("/api/posts/{postId}/likes", postId) + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertThat(unlikePostResponse.getLikesCount()).isEqualTo(0); + assertThat(unlikePostResponse.getLiked()).isFalse(); + } + + @DisplayName("게스트는 게시물을 좋아요 할 수 없다. - 실패") + @Test + void likePost_GuestUser_401ExceptionThrown() { + // given + String targetUserToken = 로그인_되어있음(ANOTHER_USERNAME).getToken(); + Long postId = 1L; + + requestToWritePostApi(targetUserToken, HttpStatus.CREATED); + + // when + UnauthorizedException response = given().log().all() + .when() + .put("/api/posts/{postId}/likes", postId) + .then().log().all() + .statusCode(HttpStatus.UNAUTHORIZED.value()) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertThat(response.getErrorCode()).isEqualTo("A0001"); + } + + @DisplayName("게스트는 게시물을 좋아요 취소 할 수 없다. - 실패") + @Test + void unlikePost_GuestUser_401ExceptionThrown() { + // given + String targetUserToken = 로그인_되어있음(ANOTHER_USERNAME).getToken(); + Long postId = 1L; + + requestToWritePostApi(targetUserToken, HttpStatus.CREATED); + + // when + UnauthorizedException response = given().log().all() + .when() + .delete("/api/posts/{postId}/likes", postId) + .then().log().all() + .statusCode(HttpStatus.UNAUTHORIZED.value()) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertThat(response.getErrorCode()).isEqualTo("A0001"); + } + + @DisplayName("로그인 사용자는 이미 좋아요한 게시물을 좋아요 할 수 없다. - 실패") + @Test + void likePost_DuplicatedLike_400ExceptionThrown() { + // given + String loginUserToken = 로그인_되어있음(USERNAME).getToken(); + String targetUserToken = 로그인_되어있음(ANOTHER_USERNAME).getToken(); + Long postId = 1L; + + requestToWritePostApi(targetUserToken, HttpStatus.CREATED); + + LikeResponse likePostResponse = given().log().all() + .auth().oauth2(loginUserToken) + .when() + .put("/api/posts/{postId}/likes", postId) + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract() + .as(new TypeRef<>() { + }); + + assertThat(likePostResponse.getLikesCount()).isEqualTo(1); + assertThat(likePostResponse.getLiked()).isTrue(); + + // when + DuplicatedLikeException secondLikeResponse = given().log().all() + .auth().oauth2(loginUserToken) + .when() + .put("/api/posts/{postId}/likes", postId) + .then().log().all() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertThat(secondLikeResponse.getErrorCode()).isEqualTo("P0003"); + } + + @DisplayName("로그인 사용자는 좋아요 하지 않은 게시물을 좋아요 취소 할 수 없다. - 실패") + @Test + void unlikePost_cannotUnlike_400ExceptionThrown() { + // given + String loginUserToken = 로그인_되어있음(USERNAME).getToken(); + String targetUserToken = 로그인_되어있음(ANOTHER_USERNAME).getToken(); + Long postId = 1L; + + requestToWritePostApi(targetUserToken, HttpStatus.CREATED); + + // when + CannotUnlikeException unlikeResponse = given().log().all() + .auth().oauth2(loginUserToken) + .when() + .delete("/api/posts/{postId}/likes", postId) + .then().log().all() + .statusCode(HttpStatus.BAD_REQUEST.value()) + .extract() + .as(new TypeRef<>() { + }); + + // then + assertThat(unlikeResponse.getErrorCode()).isEqualTo("P0004"); + } + + private ExtractableResponse request(String token, String username, int statusCode) { + return given().log().all() + .auth().oauth2(token) + .when() + .get("/api/github/{username}/repositories", username) + .then().log().all() + .statusCode(statusCode) + .extract(); + } + + @DisplayName("사용자는 게시물을 수정한다.") + @Test + void update_LoginUser_Success() { + // given + String token = 로그인_되어있음(USERNAME).getToken(); + + requestWrite(token); + + PostUpdateRequest updateRequest = PostUpdateRequest.builder() + .tags(List.of("java", "spring")) + .content("hello") + .build(); + + PostUpdateResponse response = PostUpdateResponse.builder() + .tags(List.of("java", "spring")) + .content("hello") + .build(); + + // when + PostUpdateResponse updateResponse = + putApiForUpdate(token, updateRequest, HttpStatus.CREATED) + .as(PostUpdateResponse.class); + + // then + assertThat(updateResponse) + .usingRecursiveComparison() + .isEqualTo(response); + } + + @DisplayName("유효하지 않은 내용(null)의 게시물은 수정할 수 없다. - 400 예외") + @Test + void update_NullContent_400Exception() { + // given + String token = 로그인_되어있음(USERNAME).getToken(); + + requestWrite(token); + + PostUpdateRequest updateRequest = PostUpdateRequest.builder() + .tags(List.of("java", "spring")) + .content(null) + .build(); + + // when + ApiErrorResponse response = + putApiForUpdate(token, updateRequest, HttpStatus.BAD_REQUEST) + .as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("F0001"); + } + + @DisplayName("유효하지 않은 내용(500자 초과)의 게시물은 수정할 수 없다. - 400 예외") + @Test + void update_Over500Content_400Exception() { + // given + String token = 로그인_되어있음(USERNAME).getToken(); + + requestWrite(token); + + PostUpdateRequest updateRequest = PostUpdateRequest.builder() + .tags(List.of("java", "spring")) + .content("a".repeat(501)) + .build(); + + // when + ApiErrorResponse response = + putApiForUpdate(token, updateRequest, HttpStatus.BAD_REQUEST) + .as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("F0004"); + } + + @DisplayName("유효하지 않은 토큰으로 게시물을 수정할 수 없다. - 401 예외") + @Test + void update_InvalidToken_401Exception() { + // given + String token = 로그인_되어있음(USERNAME).getToken(); + + requestWrite(token); + + PostUpdateRequest updateRequest = PostUpdateRequest.builder() + .tags(List.of("java", "spring")) + .content("hello") + .build(); + + // when + ApiErrorResponse response = + putApiForUpdate("invalidToken", updateRequest, HttpStatus.UNAUTHORIZED) + .as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("A0001"); + } + + private ExtractableResponse putApiForUpdate( + String token, + PostUpdateRequest updateRequest, + HttpStatus httpStatus + ) { + return given().log().all() + .auth().oauth2(token) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(updateRequest) + .when() + .put("/api/posts/{postId}", 1L) + .then().log().all() + .statusCode(httpStatus.value()) + .extract(); + } + + @DisplayName("사용자는 게시물을 삭제한다.") + @Test + void delete_LoginUser_Success() { + // given + String token = 로그인_되어있음(USERNAME).getToken(); + + requestWrite(token); + + // when + deleteApiForUpdate(token, HttpStatus.NO_CONTENT); + } + + @DisplayName("유효하지 않은 토큰으로 게시물을 삭제할 수 없다. - 401 예외") + @Test + void delete_invalidToken_401Exception() { + // given + String token = 로그인_되어있음(USERNAME).getToken(); + + requestWrite(token); + + // when + ApiErrorResponse response = + deleteApiForUpdate("invalidToken", HttpStatus.UNAUTHORIZED) + .as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("A0001"); + } + + private ExtractableResponse deleteApiForUpdate( + String token, + HttpStatus httpStatus) { + return given().log().all() + .auth().oauth2(token) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .when() + .delete("/api/posts/{postId}", 1L) + .then().log().all() + .statusCode(httpStatus.value()) + .extract(); + } + + private OAuthTokenResponse 로그인_되어있음(String name) { + OAuthTokenResponse response = 로그인_요청(name) + .as(OAuthTokenResponse.class); + + assertThat(response.getToken()).isNotBlank(); + + return response; + } + + private ExtractableResponse 로그인_요청(String name) { + // given + String oauthCode = "1234"; + String accessToken = "oauth.access.token"; + + OAuthProfileResponse oAuthProfileResponse = new OAuthProfileResponse( + name, "image", "hi~", "github.com/", + null, null, null, null + ); + + given(oAuthClient.getAccessToken(oauthCode)) + .willReturn(accessToken); + given(oAuthClient.getGithubProfile(accessToken)) + .willReturn(oAuthProfileResponse); + + // when + return given().log().all() + .accept(MediaType.APPLICATION_JSON_VALUE) + .when() + .get("/api/afterlogin?code=" + oauthCode) + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract(); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/TagAcceptanceTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/acceptance/tag/TagAcceptanceTest.java similarity index 71% rename from backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/TagAcceptanceTest.java rename to backend/pick-git/src/test/java/com/woowacourse/pickgit/acceptance/tag/TagAcceptanceTest.java index 89b1d627e..add2b51f4 100644 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/TagAcceptanceTest.java +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/acceptance/tag/TagAcceptanceTest.java @@ -1,4 +1,4 @@ -package com.woowacourse.pickgit.tag; +package com.woowacourse.pickgit.acceptance.tag; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; @@ -6,9 +6,8 @@ import com.woowacourse.pickgit.authentication.application.dto.OAuthProfileResponse; import com.woowacourse.pickgit.authentication.domain.OAuthClient; import com.woowacourse.pickgit.authentication.presentation.dto.OAuthTokenResponse; +import com.woowacourse.pickgit.config.InfrastructureTestConfiguration; import com.woowacourse.pickgit.exception.dto.ApiErrorResponse; -import com.woowacourse.pickgit.post.PostTestConfiguration; -import com.woowacourse.pickgit.tag.TestTagConfiguration; import io.restassured.RestAssured; import io.restassured.common.mapper.TypeRef; import io.restassured.response.ExtractableResponse; @@ -28,8 +27,7 @@ import org.springframework.test.annotation.DirtiesContext.ClassMode; import org.springframework.test.context.ActiveProfiles; - -@Import({TestTagConfiguration.class, PostTestConfiguration.class}) +@Import(InfrastructureTestConfiguration.class) @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD) @ActiveProfiles("test") @@ -42,8 +40,7 @@ class TagAcceptanceTest { private OAuthClient oAuthClient; private String accessToken; - private String userName = "jipark3"; - private String repositoryName = "doms-react"; + private final String repositoryName = "doms-react"; @BeforeEach void setUp() { @@ -65,34 +62,67 @@ private ExtractableResponse requestTags(String accessToken, String url @DisplayName("특정 User의 Repository에 기술된 언어 태그들을 추출한다.") @Test void extractLanguageTags_ValidRepository_ExtractionSuccess() { + // given String url = "/api/github/repositories/" + repositoryName + "/tags/languages"; + // when List response = requestTags(accessToken, url, HttpStatus.OK) - .as(new TypeRef>() {}); + .as(new TypeRef>() { + }); - assertThat(response).containsExactly("JavaScript", "HTML", "CSS"); + // then + assertThat(response).containsExactly("javascript", "html", "css"); } @DisplayName("유효하지 않은 레포지토리 태그 추출 요청시 500 예외 메시지가 반환된다.") @Test void extractLanguageTags_InvalidRepository_ExceptionThrown() { + // given String url = "/api/github/repositories/none-available-repo/tags/languages"; + // when ApiErrorResponse response = requestTags(accessToken, url, HttpStatus.INTERNAL_SERVER_ERROR) .as(ApiErrorResponse.class); + // then assertThat(response.getErrorCode()).isEqualTo("V0001"); } - @DisplayName("유효하지 않은 AccessToken으로 태그 추출 요청시 서버 에러가 발생한다.") + @DisplayName("유효하지 않은 AccessToken으로 태그 추출 요청시 401 예외가 발생한다.") @Test void extractLanguageTags_InvalidAccessToken_ExceptionThrown() { + // given + String url = + "/api/github/repositories/" + repositoryName + "/tags/languages"; + + // when + ApiErrorResponse response = + requestTags("invalidtoken", url, HttpStatus.UNAUTHORIZED) + .as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("A0001"); + } + + @DisplayName("토큰을 포함하지 않고 태그 추출 요청시 401 예외가 발생한다.") + @Test + void extractLanguageTags_EmptyToken_ExceptionThrown() { + // given String url = "/api/github/repositories/" + repositoryName + "/tags/languages"; - requestTags("invalidtoken", url, HttpStatus.UNAUTHORIZED); + // when + ApiErrorResponse response = RestAssured.given().log().all() + .when().get(url) + .then().log().all() + .statusCode(HttpStatus.UNAUTHORIZED.value()) + .extract() + .as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("A0001"); } private OAuthTokenResponse 로그인_되어있음() { @@ -106,10 +136,13 @@ void extractLanguageTags_InvalidAccessToken_ExceptionThrown() { String oauthCode = "1234"; String accessToken = "oauth.access.token"; - OAuthProfileResponse oAuthProfileResponse = new OAuthProfileResponse( - "jipark3", "image", "hi~", "github.com/", - null, null, null, null - ); + OAuthProfileResponse oAuthProfileResponse = OAuthProfileResponse + .builder() + .name("jipark3") + .image("image") + .description("hi~") + .githubUrl("htts://www.github.com/") + .build(); // mock when(oAuthClient.getAccessToken(oauthCode)).thenReturn(accessToken); diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/acceptance/user/UserAcceptanceTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/acceptance/user/UserAcceptanceTest.java new file mode 100644 index 000000000..79d101f2b --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/acceptance/user/UserAcceptanceTest.java @@ -0,0 +1,610 @@ +package com.woowacourse.pickgit.acceptance.user; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; +import static org.mockito.Mockito.when; + +import com.woowacourse.pickgit.authentication.application.dto.OAuthProfileResponse; +import com.woowacourse.pickgit.authentication.domain.OAuthClient; +import com.woowacourse.pickgit.authentication.presentation.dto.OAuthTokenResponse; +import com.woowacourse.pickgit.common.factory.FileFactory; +import com.woowacourse.pickgit.common.factory.UserFactory; +import com.woowacourse.pickgit.config.InfrastructureTestConfiguration; +import com.woowacourse.pickgit.exception.dto.ApiErrorResponse; +import com.woowacourse.pickgit.user.application.dto.response.ContributionResponseDto; +import com.woowacourse.pickgit.user.application.dto.response.UserProfileResponseDto; +import com.woowacourse.pickgit.user.application.dto.response.UserSearchResponseDto; +import com.woowacourse.pickgit.user.domain.User; +import com.woowacourse.pickgit.user.presentation.dto.response.ContributionResponse; +import com.woowacourse.pickgit.user.presentation.dto.response.FollowResponse; +import com.woowacourse.pickgit.user.presentation.dto.response.ProfileEditResponse; +import com.woowacourse.pickgit.user.presentation.dto.response.UserProfileResponse; +import io.restassured.RestAssured; +import io.restassured.common.mapper.TypeRef; +import io.restassured.http.Method; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import java.io.File; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.ActiveProfiles; + +@Import(InfrastructureTestConfiguration.class) +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD) +@ActiveProfiles("test") +class UserAcceptanceTest { + + @LocalServerPort + private int port; + + @MockBean + private OAuthClient oAuthClient; + + private String loginUserAccessToken; + + private User loginUser; + + private User targetUser; + + @BeforeEach + void setUp() { + RestAssured.port = port; + + //given + loginUser = UserFactory.user("testUser"); + targetUser = UserFactory.user("testUser2"); + + loginUserAccessToken = 로그인_되어있음(loginUser).getToken(); + 로그인_되어있음(targetUser); + } + + @DisplayName("로그인된 사용자는 자신의 프로필을 조회할 수 있다.") + @Test + void getAuthenticatedUserProfile_LoginUser_Success() { + // given + UserProfileResponseDto responseDto = UserFactory.mockLoginUserProfileResponseDto(); + + // when + UserProfileResponse response = + authenticatedRequest( + loginUserAccessToken, + "/api/profiles/me", + Method.GET, + HttpStatus.OK + ).as(UserProfileResponse.class); + + // then + assertThat(response) + .usingRecursiveComparison() + .isEqualTo(responseDto); + } + + @DisplayName("유효하지 않은 토큰을 지닌 사용자는 자신의 프로필을 조회할 수 없다. - 401 예외") + @Test + void getAuthenticatedUserProfile_LoginUserWithInvalidToken_401Exception() { + // when + ApiErrorResponse response = + authenticatedRequest( + "testToken", + "/api/profiles/me", + Method.GET, + HttpStatus.UNAUTHORIZED + ).as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("A0001"); + } + + @DisplayName("토큰이 없는 사용자는 자신의 프로필을 조회할 수 없다. - 401 예외") + @Test + void getAuthenticatedUserProfile_LoginUserWithoutToken_401Exception() { + // when + ApiErrorResponse response = + unauthenticatedRequest( + "/api/profiles/me", + Method.GET, + HttpStatus.UNAUTHORIZED + ).as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("A0001"); + } + + @DisplayName("로그인된 사용자는 팔로우한 유저의 프로필을 조회할 수 있다.") + @Test + void getUserProfile_LoginUserIsFollowing_Success() { + // given + UserProfileResponseDto responseDto = + UserFactory.mockLoginUserProfileIsFollowingResponseDto(); + + authenticatedRequest( + loginUserAccessToken, + String.format("/api/profiles/%s/followings", targetUser.getName()), + Method.POST, + HttpStatus.OK + ); + + // when + UserProfileResponse response = + authenticatedRequest( + loginUserAccessToken, + String.format("/api/profiles/%s", targetUser.getName()), + Method.GET, + HttpStatus.OK + ).as(UserProfileResponse.class); + + // then + assertThat(response) + .usingRecursiveComparison() + .isEqualTo(responseDto); + } + + @DisplayName("로그인된 사용자는 팔로우하지 않은 유저의 프로필을 조회할 수 있다.") + @Test + void getUserProfile_LoginUserIsNotFollowing_Success() { + // given + UserProfileResponseDto responseDto = + UserFactory.mockLoginUserProfileIsNotFollowingResponseDto(); + + // when + UserProfileResponse response = + authenticatedRequest( + loginUserAccessToken, + String.format("/api/profiles/%s", targetUser.getName()), + Method.GET, + HttpStatus.OK + ).as(UserProfileResponse.class); + + // then + assertThat(response) + .usingRecursiveComparison() + .isEqualTo(responseDto); + } + + @DisplayName("로그인된 사용자는 존재하지 않는 유저 이름으로 프로필을 조회할 수 없다. - 400 예외") + @Test + void getUserProfile_LoginUser_400Exception() { + // when + ApiErrorResponse response = + authenticatedRequest( + loginUserAccessToken, + String.format("/api/profiles/%s", "invalidName"), + Method.GET, + HttpStatus.BAD_REQUEST + ).as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("U0001"); + } + + @DisplayName("게스트 유저는 다른 유저의 프로필을 조회할 수 있다.") + @Test + void getUserProfile_GuestUser_Success() { + //given + UserProfileResponseDto responseDto = UserFactory.mockGuestUserProfileResponseDto(); + + //when + UserProfileResponse response = unauthenticatedRequest( + String.format("/api/profiles/%s", loginUser.getName()), + Method.GET, + HttpStatus.OK + ).as(UserProfileResponse.class); + + //then + assertThat(response) + .usingRecursiveComparison() + .isEqualTo(responseDto); + } + + @DisplayName("게스트 유저는 존재하지 않는 유저 이름으로 프로필을 조회할 수 없다. - 400 예외") + @Test + void getUserProfile_GuestUser_400Exception() { + // when + ApiErrorResponse response = unauthenticatedRequest( + String.format("/api/profiles/%s", "invalidName"), + Method.GET, + HttpStatus.BAD_REQUEST + ).as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("U0001"); + } + + @DisplayName("로그인되지 않은 사용자는 target 유저를 팔로우 할 수 없다.") + @Test + void followUser_NotLogin_Failure() { + ApiErrorResponse response = unauthenticatedRequest( + String.format("/api/profiles/%s/followings", targetUser.getName()), + Method.POST, + HttpStatus.UNAUTHORIZED + ).as(ApiErrorResponse.class); + + assertThat(response.getErrorCode()).isEqualTo("A0001"); + } + + @DisplayName("팔로우중이지 않다면 source 유저는 target 유저를 팔로우할 수 있다.") + @Test + void followUser_SourceToTarget_Success() { + // given + FollowResponse responseDto = new FollowResponse(1, true); + + // when + FollowResponse response = + authenticatedRequest( + loginUserAccessToken, + String.format("/api/profiles/%s/followings", targetUser.getName()), + Method.POST, + HttpStatus.OK + ).as(FollowResponse.class); + + // then + assertThat(response) + .usingRecursiveComparison() + .isEqualTo(responseDto); + } + + @DisplayName("source 유저와 target 유저가 동일하면 팔로우할 수 없다. - 400 예외") + @Test + void followUser_SameSourceToSameTarget_400Exception() { + // when + ApiErrorResponse response = + authenticatedRequest( + loginUserAccessToken, + String.format("/api/profiles/%s/followings", loginUser.getName()), + Method.POST, + HttpStatus.BAD_REQUEST + ).as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("U0004"); + } + + @DisplayName("source 유저는 존재하지 않는 유저를 팔로우할 수 없다. - 400 예외") + @Test + void followUser_SourceToInvalidTarget_400Exception() { + // when + ApiErrorResponse response = + authenticatedRequest( + loginUserAccessToken, + String.format("/api/profiles/%s/followings", "invalidName"), + Method.POST, + HttpStatus.BAD_REQUEST + ).as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("U0001"); + } + + @DisplayName("source 유저는 이미 팔로우한 유저를 팔로우할 수 없다. - 400 예외") + @Test + void followUser_SourceToExistingTarget_400Exception() { + // given + FollowResponse followResponse = + authenticatedRequest( + loginUserAccessToken, + String.format("/api/profiles/%s/followings", targetUser.getName()), + Method.POST, + HttpStatus.OK + ).as(FollowResponse.class); + + FollowResponse expectedResponse = new FollowResponse(1, true); + + assertThat(followResponse) + .usingRecursiveComparison() + .isEqualTo(expectedResponse); + + // when + ApiErrorResponse errorResponse = + authenticatedRequest( + loginUserAccessToken, + String.format("/api/profiles/%s/followings", targetUser.getName()), + Method.POST, + HttpStatus.BAD_REQUEST + ).as(ApiErrorResponse.class); + + // then + assertThat(errorResponse.getErrorCode()).isEqualTo("U0002"); + } + + @DisplayName("로그인되지 않은 사용자는 target 유저를 언팔로우 할 수 없다.") + @Test + void unfollowUser_NotLogin_Failure() { + ApiErrorResponse response = unauthenticatedRequest( + String.format("/api/profiles/%s/followings", targetUser.getName()), + Method.DELETE, + HttpStatus.UNAUTHORIZED + ).as(ApiErrorResponse.class); + + assertThat(response.getErrorCode()).isEqualTo("A0001"); + } + + @DisplayName("source 유저는 target 유저를 언팔로우할 수 있다.") + @Test + void unfollowUser_SourceToTarget_Success() { + // given + FollowResponse followResponse = + authenticatedRequest( + loginUserAccessToken, + String.format("/api/profiles/%s/followings", targetUser.getName()), + Method.POST, + HttpStatus.OK + ).as(FollowResponse.class); + + FollowResponse followExpectedResponse = new FollowResponse(1, true); + + assertThat(followResponse) + .usingRecursiveComparison() + .isEqualTo(followExpectedResponse); + + FollowResponse unfollowExpectedResponse = new FollowResponse(0, false); + + // when + FollowResponse unfollowResponse = + authenticatedRequest( + loginUserAccessToken, + String.format("/api/profiles/%s/followings", targetUser.getName()), + Method.DELETE, + HttpStatus.OK + ).as(FollowResponse.class); + + // then + assertThat(unfollowResponse) + .usingRecursiveComparison() + .isEqualTo(unfollowExpectedResponse); + } + + @DisplayName("source 유저와 target 유저가 동일하면 언팔로우할 수 없다. - 400 예외") + @Test + void unfollowUser_SameSourceToSameTarget_400Exception() { + // when + ApiErrorResponse response = + authenticatedRequest( + loginUserAccessToken, + String.format("/api/profiles/%s/followings", loginUser.getName()), + Method.DELETE, + HttpStatus.BAD_REQUEST + ).as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("U0004"); + } + + @DisplayName("source 유저는 존재하지 않는 유저를 언팔로우할 수 없다. - 400 예외") + @Test + void unfollowUser_SourceToInvalidTarget_400Exception() { + // when + ApiErrorResponse response = + authenticatedRequest( + loginUserAccessToken, + String.format("/api/profiles/%s/followings", "invalidName"), + Method.DELETE, + HttpStatus.BAD_REQUEST + ).as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("U0001"); + } + + @DisplayName("source 유저는 팔로우하지 않는 유저를 언팔로우할 수 없다. - 400 예외") + @Test + void unfollowUser_NotExistingFollow_ExceptionThrown() { + // when + ApiErrorResponse response = + authenticatedRequest( + loginUserAccessToken, + String.format("/api/profiles/%s/followings", targetUser.getName()), + Method.DELETE, + HttpStatus.BAD_REQUEST + ).as(ApiErrorResponse.class); + + //then + assertThat(response.getErrorCode()).isEqualTo("U0003"); + } + + @DisplayName("로그인 사용자는 자신의 프로필(이미지, 한 줄 소개 포함)을 수정할 수 있다.") + @Test + void editUserProfile_LoginUserWithImageAndDescription_Success() { + // given + String description = "updated profile description"; + File imageFile = FileFactory.getTestImage1File(); + + // when + ProfileEditResponse response = given().log().all() + .auth().oauth2(loginUserAccessToken) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .formParams("description", description) + .multiPart("image", imageFile) + .when() + .post("/api/profiles/me") + .then().log().all() + .extract() + .as(ProfileEditResponse.class); + + // then + assertThat(response.getImageUrl()).isNotBlank(); + assertThat(response.getDescription()).isEqualTo(description); + } + + @DisplayName("게스트는 프로필을 수정할 수 없다.") + @Test + void editUserProfile_GuestUser_Fail() { + // given + String description = "updated profile description"; + + // when + ApiErrorResponse response = given().log().all() + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .formParams("description", description) + .multiPart("image", "") + .when() + .post("/api/profiles/me") + .then().log().all() + .extract() + .as(ApiErrorResponse.class); + + // then + assertThat(response) + .extracting("errorCode") + .isEqualTo("A0001"); + } + + @DisplayName("로그인 - 저장된 유저중 유사한 이름을 가진 유저를 검색할 수 있다. 단, 자기 자신은 검색되지 않는다.(팔로잉 여부 true/false)") + @Test + void searchUser_LoginUser_Success() { + // given + authenticatedRequest( + loginUserAccessToken, + String.format("/api/profiles/%s/followings", targetUser.getName()), + Method.POST, + HttpStatus.OK + ); + User unfollowedUser = UserFactory.user("testUser3"); + 로그인_되어있음(unfollowedUser); + + // when + String url = String.format("/api/search/users?keyword=%s&page=0&limit=5", "testUser"); + List response = + authenticatedRequest( + loginUserAccessToken, + url, + Method.GET, + HttpStatus.OK + ).as(new TypeRef>() { + }); + + // then + assertThat(response) + .hasSize(2) + .extracting("username", "following") + .containsExactly( + tuple(targetUser.getName(), true), + tuple(unfollowedUser.getName(), false) + ); + } + + @DisplayName("비 로그인 - 저장된 유저중 유사한 이름을 가진 유저를 검색할 수 있다. (팔로잉 필드 null)") + @Test + void searchUser_GuestUser_Success() { + // when + String url = String.format("/api/search/users?keyword=%s&page=0&limit=5", "testUser"); + List response = + unauthenticatedRequest( + url, + Method.GET, + HttpStatus.OK + ).as(new TypeRef>() { + }); + + // then + assertThat(response) + .hasSize(2) + .extracting("username", "following") + .containsExactly( + tuple(loginUser.getName(), null), + tuple(targetUser.getName(), null) + ); + } + + @DisplayName("누구든지 활동 통계를 조회할 수 있다.") + @Test + void getContributions_Anyone_Success() { + // given + ContributionResponseDto contributions = UserFactory.mockContributionResponseDto(); + + // when + ContributionResponse response = unauthenticatedRequest( + String.format("/api/profiles/%s/contributions", "testUser"), + Method.GET, + HttpStatus.OK + ).as(ContributionResponse.class); + + // then + assertThat(response) + .usingRecursiveComparison() + .isEqualTo(contributions); + } + + @DisplayName("유효하지 않은 유저 이름으로 활동 통계를 조회할 수 없다. - 400 예외") + @Test + void getContributions_invalidUsername_400Exception() { + // when + ApiErrorResponse response = unauthenticatedRequest( + String.format("/api/profiles/%s/contributions", "invalidName"), + Method.GET, + HttpStatus.BAD_REQUEST + ).as(ApiErrorResponse.class); + + // then + assertThat(response.getErrorCode()).isEqualTo("U0001"); + } + + private ExtractableResponse authenticatedRequest( + String accessToken, + String url, + Method method, + HttpStatus httpStatus + ) { + return RestAssured.given().log().all() + .auth().oauth2(accessToken) + .when().request(method, url) + .then().log().all() + .statusCode(httpStatus.value()) + .extract(); + } + + private ExtractableResponse unauthenticatedRequest( + String url, + Method method, + HttpStatus httpStatus + ) { + return RestAssured.given().log().all() + .when().request(method, url) + .then().log().all() + .statusCode(httpStatus.value()) + .extract(); + } + + private OAuthTokenResponse 로그인_되어있음(User user) { + OAuthTokenResponse response = 로그인_요청(user).as(OAuthTokenResponse.class); + assertThat(response.getToken()).isNotBlank(); + return response; + } + + private ExtractableResponse 로그인_요청(User user) { + // given + String oauthCode = "1234"; + String accessToken = "oauth.access.token"; + + OAuthProfileResponse oAuthProfileResponse = new OAuthProfileResponse( + user.getName(), user.getImage(), user.getDescription(), user.getGithubUrl(), + user.getCompany(), user.getLocation(), user.getWebsite(), user.getTwitter() + ); + + // mock + when(oAuthClient.getAccessToken(oauthCode)).thenReturn(accessToken); + when(oAuthClient.getGithubProfile(accessToken)).thenReturn(oAuthProfileResponse); + + // when + return RestAssured + .given().log().all() + .accept(MediaType.APPLICATION_JSON_VALUE) + .when() + .get("/api/afterlogin?code=" + oauthCode) + .then().log().all() + .statusCode(HttpStatus.OK.value()) + .extract(); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/application/OAuthServiceMockTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/application/OAuthServiceMockTest.java deleted file mode 100644 index 267b065e4..000000000 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/application/OAuthServiceMockTest.java +++ /dev/null @@ -1,178 +0,0 @@ -package com.woowacourse.pickgit.authentication.application; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.woowacourse.pickgit.authentication.application.dto.TokenDto; -import com.woowacourse.pickgit.authentication.application.dto.OAuthProfileResponse; -import com.woowacourse.pickgit.authentication.dao.CollectionOAuthAccessTokenDao; -import com.woowacourse.pickgit.authentication.domain.OAuthClient; -import com.woowacourse.pickgit.authentication.domain.user.AppUser; -import com.woowacourse.pickgit.authentication.domain.user.GuestUser; -import com.woowacourse.pickgit.authentication.domain.user.LoginUser; -import com.woowacourse.pickgit.exception.authentication.InvalidTokenException; -import com.woowacourse.pickgit.user.domain.User; -import com.woowacourse.pickgit.user.domain.UserRepository; -import java.util.Optional; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.context.ActiveProfiles; - -@DisplayName("OAuthService Mock 단위 테스트") -@ExtendWith(MockitoExtension.class) -@ActiveProfiles("test") -class OAuthServiceMockTest { - - @Mock - private OAuthClient oAuthClient; - - @Mock - private UserRepository userRepository; - - @Mock - private CollectionOAuthAccessTokenDao oAuthAccessTokenDao; - - @Mock - private JwtTokenProvider jwtTokenProvider; - - @InjectMocks - private OAuthService oAuthService; - - @DisplayName("Github 로그인 URL을 반환한다.") - @Test - void getGithubAuthorizationUrl_Anonymous_ReturnGithubAuthorizationUrl() { - // given - String url = "https://github.com/login.."; - - // mock - when(oAuthClient.getLoginUrl()).thenReturn(url); - - // then - assertThat(oAuthService.getGithubAuthorizationUrl()).isEqualTo(url); - } - - @DisplayName("회원가입(첫 로그인)시 Github Profile을 가져와서 DB에 insert한다.") - @Test - void createToken_Signup_SaveUserProfile() { - // given - String code = "oauth authorization code"; - String oauthAccessToken = "oauth access token"; - String jwtToken = "jwt token"; - - OAuthProfileResponse githubProfileResponse = new OAuthProfileResponse(); - githubProfileResponse.setName("test"); - githubProfileResponse.setDescription("hi~"); - - User user = new User( - githubProfileResponse.toBasicProfile(), - githubProfileResponse.toGithubProfile() - ); - - // mock - when(oAuthClient.getAccessToken(code)).thenReturn(oauthAccessToken); - when(oAuthClient.getGithubProfile(oauthAccessToken)).thenReturn(githubProfileResponse); - when(userRepository.findByBasicProfile_Name(githubProfileResponse.getName())).thenReturn( - Optional.empty()); - when(jwtTokenProvider.createToken(githubProfileResponse.getName())).thenReturn(jwtToken); - - // when - TokenDto token = oAuthService.createToken(code); - - // then - assertThat(token.getToken()).isEqualTo(jwtToken); - verify(userRepository, times(1)).findByBasicProfile_Name(githubProfileResponse.getName()); - verify(userRepository, times(1)).save(user); - verify(jwtTokenProvider, times(1)).createToken(githubProfileResponse.getName()); - verify(oAuthAccessTokenDao, times(1)).insert(jwtToken, oauthAccessToken); - } - - @DisplayName("로그인(첫 로그인이 아닌경우)시 Github Profile을 가져와서 DB에 저장된 기존 정보를 update한다.") - @Test - void createToken_Signup_UpdateUserProfile() { - // given - String code = "oauth authorization code"; - String oauthAccessToken = "oauth access token"; - String jwtToken = "jwt token"; - - OAuthProfileResponse githubProfileResponse = new OAuthProfileResponse(); - githubProfileResponse.setName("test"); - githubProfileResponse.setDescription("hi~"); - - User user = new User( - githubProfileResponse.toBasicProfile(), - githubProfileResponse.toGithubProfile() - ); - - // mock - when(oAuthClient.getAccessToken(code)).thenReturn(oauthAccessToken); - when(oAuthClient.getGithubProfile(oauthAccessToken)).thenReturn(githubProfileResponse); - when(userRepository.findByBasicProfile_Name(githubProfileResponse.getName())).thenReturn( - Optional.of(user)); - when(jwtTokenProvider.createToken(githubProfileResponse.getName())).thenReturn(jwtToken); - - // when - TokenDto token = oAuthService.createToken(code); - - // then - assertThat(token.getToken()).isEqualTo(jwtToken); - verify(userRepository, times(1)).findByBasicProfile_Name(githubProfileResponse.getName()); - verify(userRepository, never()).save(user); - verify(jwtTokenProvider, times(1)).createToken(githubProfileResponse.getName()); - verify(oAuthAccessTokenDao, times(1)).insert(jwtToken, oauthAccessToken); - } - - @DisplayName("JWT 토큰을 통해 AccessTokenDB에서 LoginUser에 대한 정보를 가져온다.") - @Test - void findRequestUserByToken_ValidToken_ReturnAppUser() { - // given - String token = "jwt token"; - String accessToken = "oauth access token"; - String username = "pick-git"; - - // mock - when(jwtTokenProvider.getPayloadByKey(token, "username")).thenReturn(username); - when(oAuthAccessTokenDao.findByKeyToken(token)).thenReturn(Optional.ofNullable(accessToken)); - - // when - AppUser appUser = oAuthService.findRequestUserByToken(token); - - // then - assertThat(appUser).isInstanceOf(LoginUser.class); - assertThat(appUser.getUsername()).isEqualTo(username); - assertThat(appUser.getAccessToken()).isEqualTo(accessToken); - } - - @DisplayName("AccessTokenDB에 저장되어 있지 않은 JWT 토큰이라면 예외가 발생한다.") - @Test - void findRequestUserByToken_NotFoundToken_ThrowException() { - // given - String token = "never saved jwt token"; - String username = "pick-git"; - - // mock - when(jwtTokenProvider.getPayloadByKey(token, "username")).thenReturn(username); - when(oAuthAccessTokenDao.findByKeyToken(token)).thenReturn(Optional.empty()); - - // then - assertThatThrownBy(() -> oAuthService.findRequestUserByToken(token)) - .isInstanceOf(InvalidTokenException.class); - } - - @DisplayName("빈 JWT 토큰이면 GuestUser를 반환한다.") - @Test - void findRequestUserByToken_EmptyToken_ReturnGuest() { - // when - AppUser appUser = oAuthService.findRequestUserByToken(null); - - // then - assertThat(appUser).isInstanceOf(GuestUser.class); - } -} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/application/OAuthServiceTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/application/OAuthServiceTest.java deleted file mode 100644 index d98d95ff9..000000000 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/application/OAuthServiceTest.java +++ /dev/null @@ -1,142 +0,0 @@ -package com.woowacourse.pickgit.authentication.application; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.when; - -import com.woowacourse.pickgit.authentication.application.dto.OAuthProfileResponse; -import com.woowacourse.pickgit.authentication.dao.OAuthAccessTokenDao; -import com.woowacourse.pickgit.authentication.domain.OAuthClient; -import com.woowacourse.pickgit.authentication.domain.user.AppUser; -import com.woowacourse.pickgit.authentication.domain.user.LoginUser; -import com.woowacourse.pickgit.exception.authentication.InvalidTokenException; -import com.woowacourse.pickgit.post.PostTestConfiguration; -import com.woowacourse.pickgit.user.domain.User; -import com.woowacourse.pickgit.user.domain.UserRepository; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.ActiveProfiles; - -@Import(PostTestConfiguration.class)@DisplayName("OAuthService 통합 테스트 (UserRepository 사용)") -@SpringBootTest(webEnvironment = WebEnvironment.NONE) -@ActiveProfiles("test") -public class OAuthServiceTest { - - @MockBean - private OAuthClient oAuthClient; - - @Autowired - private JwtTokenProvider jwtTokenProvider; - - @Autowired - private OAuthAccessTokenDao oAuthAccessTokenDao; - - @Autowired - private UserRepository userRepository; - - @Autowired - private OAuthService oAuthService; - - @DisplayName("Github 로그인 URL을 반환한다.") - @Test - void getGithubAuthorizationUrl_Anonymous_ReturnGithubAuthorizationUrl() { - // mock - when(oAuthClient.getLoginUrl()).thenReturn("https://github.com/login/oauth/authorize?"); - - // when - String githubAuthorizationUrl = oAuthService.getGithubAuthorizationUrl(); - - // then - assertThat(githubAuthorizationUrl).startsWith("https://github.com/login/oauth/authorize?"); - } - - @DisplayName("회원가입(첫 로그인)시 Github Profile을 가져와서 DB에 insert한다.") - @Test - void createToken_Signup_SaveUserProfile() { - // given - String code = "oauth authorization code"; - String oauthAccessToken = "oauth access token"; - OAuthProfileResponse oAuthProfileResponse = new OAuthProfileResponse( - "binghe", "image", null, "github.com/", - null, null, null, null - ); - - // mock - when(oAuthClient.getAccessToken(code)).thenReturn(oauthAccessToken); - when(oAuthClient.getGithubProfile(oauthAccessToken)) - .thenReturn(oAuthProfileResponse); - - // when - oAuthService.createToken(code); - - // then - User user = userRepository.findByBasicProfile_Name(oAuthProfileResponse.getName()).orElse(null); - assertThat(user).isNotNull(); - assertThat(user.getBasicProfile().getName()).isEqualTo("binghe"); - assertThat(user.getGithubProfile().getGithubUrl()).isEqualTo("github.com/"); - } - - @DisplayName("로그인(첫 로그인이 아닌경우)시 Github Profile을 가져와서 DB에 저장된 기존 정보를 update한다.") - @Test - void createToken_Signup_UpdateUserProfile() { - // given - String code = "oauth authorization code"; - String oauthAccessToken = "oauth access token"; - OAuthProfileResponse oAuthProfileResponse = new OAuthProfileResponse( - "binghe", "image", null, "github.com/", - null, null, null, null - ); - - // mock - when(oAuthClient.getAccessToken(code)).thenReturn(oauthAccessToken); - when(oAuthClient.getGithubProfile(oauthAccessToken)) - .thenReturn(oAuthProfileResponse); - - // when - oAuthService.createToken(code); - - oAuthProfileResponse.setCompany("@woowabros"); - oAuthService.createToken(code); - - // then - User user = userRepository.findByBasicProfile_Name(oAuthProfileResponse.getName()).orElse(null); - assertThat(user).isNotNull(); - assertThat(user.getGithubProfile().getCompany()).isEqualTo("@woowabros"); - } - - @DisplayName("JWT 토큰을 통해 AccessTokenDB에서 LoginUser에 대한 정보를 가져온다.") - @Test - void findRequestUserByToken_ValidToken_ReturnAppUser() { - // given - String username = "pick-git"; - String token = jwtTokenProvider.createToken(username); - String accessToken = "oauth access token"; - - oAuthAccessTokenDao.insert(token, accessToken); - - // when - AppUser appUser = oAuthService.findRequestUserByToken(token); - - // then - assertThat(appUser).isInstanceOf(LoginUser.class); - assertThat(appUser.getUsername()).isEqualTo(username); - assertThat(appUser.getAccessToken()).isEqualTo(accessToken); - } - - @DisplayName("AccessTokenDB에 저장되어 있지 않은 JWT 토큰이라면 예외가 발생한다.") - @Test - void findRequestUserByToken_NotFoundToken_ThrowException() { - // given - String username = "pick-git-test"; - String token = jwtTokenProvider.createToken(username); - - // when, then - assertThatThrownBy(() -> oAuthService.findRequestUserByToken(token)) - .isInstanceOf(InvalidTokenException.class); - } -} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/dao/OAuthAccessTokenDaoTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/dao/OAuthAccessTokenDaoTest.java deleted file mode 100644 index dcf1b4e8b..000000000 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/dao/OAuthAccessTokenDaoTest.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.woowacourse.pickgit.authentication.dao; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.woowacourse.pickgit.post.PostTestConfiguration; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.ActiveProfiles; - -@Import(PostTestConfiguration.class) -@SpringBootTest(webEnvironment = WebEnvironment.NONE) -@ActiveProfiles("test") -class OAuthAccessTokenDaoTest { - - @Autowired - private OAuthAccessTokenDao oAuthAccessTokenDao; - - private String token; - private String oauthAccessToken; - - @BeforeEach - void setUp() { - // given - token = "jwt token"; - oauthAccessToken = "oauth access token"; - } - - @Test - void insertAndFind_NonDuplicated_Save() { - // when - oAuthAccessTokenDao.insert(token, oauthAccessToken); - - // then - assertThat(oAuthAccessTokenDao.findByKeyToken(token).get()).isEqualTo(oauthAccessToken); - } - - @Test - void insertAndFind_Duplicated_Save() { - // when - oAuthAccessTokenDao.insert(token, oauthAccessToken); - - oAuthAccessTokenDao.insert(token, "duplicated"); - - // then - assertThat(oAuthAccessTokenDao.findByKeyToken(token).get()).isEqualTo("duplicated"); - } -} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/FileFactory.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/factory/FileFactory.java similarity index 81% rename from backend/pick-git/src/test/java/com/woowacourse/pickgit/common/FileFactory.java rename to backend/pick-git/src/test/java/com/woowacourse/pickgit/common/factory/FileFactory.java index 2b2189678..5ec88cd2c 100644 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/FileFactory.java +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/factory/FileFactory.java @@ -1,4 +1,4 @@ -package com.woowacourse.pickgit.common; +package com.woowacourse.pickgit.common.factory; import java.io.File; import java.io.IOException; @@ -7,9 +7,9 @@ import java.util.Objects; import org.apache.http.entity.ContentType; import org.springframework.mock.web.MockMultipartFile; -import org.springframework.web.multipart.MultipartFile; public class FileFactory { + private static final ClassLoader CLASS_LOADER = FileFactory.class.getClassLoader(); private static final String FILE_KEY = "images"; @@ -21,6 +21,8 @@ public static MockMultipartFile getTestImage2() { return createImageFile("testImage2.png"); } + public static MockMultipartFile getEmptyTestFile() { return createEmptyImageFile();} + public static File getTestImage1File() { return createFile("testImage1.png"); } @@ -44,6 +46,15 @@ private static MockMultipartFile createImageFile(String fileName) { } } + private static MockMultipartFile createEmptyImageFile() { + return new MockMultipartFile( + "images", + "", + null, + new byte[] {} + ); + } + private static File createFile(String fileName) { URL resource = CLASS_LOADER.getResource(fileName); Objects.requireNonNull(resource); diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/factory/MockUser.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/factory/MockUser.java new file mode 100644 index 000000000..f05600ead --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/factory/MockUser.java @@ -0,0 +1,91 @@ +package com.woowacourse.pickgit.common.factory; + +import com.woowacourse.pickgit.post.domain.Posts; +import com.woowacourse.pickgit.user.domain.User; +import com.woowacourse.pickgit.user.domain.follow.Followers; +import com.woowacourse.pickgit.user.domain.follow.Followings; +import com.woowacourse.pickgit.user.domain.profile.BasicProfile; +import com.woowacourse.pickgit.user.domain.profile.GithubProfile; +import java.util.ArrayList; + +public class MockUser { + + private MockUser() { + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private Long id; + private String name; + private String image = "http://img.com"; + private String description = "The Best"; + private String githubUrl = "https://github.com/yjksw"; + private String company = "woowacourse"; + private String location = "Seoul"; + private String website = "www.pick-git.com"; + private String twitter = "pick-git twitter"; + private Followers followers = new Followers(new ArrayList<>()); + private Followings followings = new Followings(new ArrayList<>()); + private Posts posts = new Posts(new ArrayList<>()); + + public Builder id(Long id) { + this.id = id; + return this; + } + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder image(String image) { + this.image = image; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder githubUrl(String githubUrl) { + this.githubUrl = githubUrl; + return this; + } + + public Builder company(String company) { + this.company = company; + return this; + } + + public Builder location(String location) { + this.location = location; + return this; + } + + public Builder website(String website) { + this.website = website; + return this; + } + + public Builder twitter(String twitter) { + this.twitter = twitter; + return this; + } + + public User build() { + return new User( + id, + new BasicProfile(name, image, description), + new GithubProfile(githubUrl, company, location, website, twitter), + followers, + followings, + posts + ); + } + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/factory/PostFactory.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/factory/PostFactory.java new file mode 100644 index 000000000..8081f0ebc --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/factory/PostFactory.java @@ -0,0 +1,157 @@ +package com.woowacourse.pickgit.common.factory; + +import com.woowacourse.pickgit.post.application.dto.request.PostRequestDto; +import com.woowacourse.pickgit.post.application.dto.response.CommentResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.PostResponseDto; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.web.multipart.MultipartFile; + +public class PostFactory { + + private PostFactory() { + } + + public static class Builder { + + } + + public static List mockPostRequestDtos() { + List images = + List.of(FileFactory.getTestImage1(), FileFactory.getTestImage2()); + + PostRequestDto fixture1 = PostRequestDto.builder() + .token("a") + .username("sean") + .images(images) + .githubRepoUrl("atdd-subway-fare") + .tags(List.of("Java", "Python", "C++")) + .content("woowacourse mission") + .build(); + + PostRequestDto fixture2 = PostRequestDto.builder() + .token("a") + .username("ginger") + .images(images) + .githubRepoUrl("jwp-chess") + .tags(List.of("Javascirpt", "C", "HTML")) + .content("it's so easy!") + .build(); + + PostRequestDto fixture3 = PostRequestDto.builder() + .token("a") + .username("dani") + .images(images) + .githubRepoUrl("java-racingcar") + .tags(List.of("Go", "Objective-C")) + .content("I love TDD") + .build(); + + PostRequestDto fixture4 = PostRequestDto.builder() + .token("a") + .username("coda") + .images(images) + .githubRepoUrl("junit-test") + .tags(List.of("Java", "CSS", "HTML")) + .content("hi there!") + .build(); + + PostRequestDto fixture5 = PostRequestDto.builder() + .token("a") + .username("dave") + .images(images) + .githubRepoUrl("jpa-learning-test") + .tags(List.of("Java", "CSS", "HTML")) + .content("jpa is so fun!") + .build(); + + return List.of(fixture1, fixture2, fixture3, fixture4, fixture5); + } + + public static List mockPostRequestForAssertingMyFeed() { + List images = + List.of(FileFactory.getTestImage1(), FileFactory.getTestImage2()); + + PostRequestDto fixture1 = PostRequestDto.builder() + .token("a") + .username("kevin") + .images(images) + .githubRepoUrl("atdd-subway-fare") + .tags(List.of("Java", "Python", "C++")) + .content("woowacourse mission") + .build(); + + PostRequestDto fixture2 = PostRequestDto.builder() + .token("a") + .username("kevin") + .images(images) + .githubRepoUrl("jwp-chess") + .tags(List.of("Javascirpt", "C", "HTML")) + .content("it's so easy!") + .build(); + + PostRequestDto fixture3 = PostRequestDto.builder() + .token("a") + .username("kevin") + .images(images) + .githubRepoUrl("java-racingcar") + .tags(List.of("Go", "Objective-C")) + .content("I love TDD") + .build(); + + PostRequestDto fixture4 = PostRequestDto.builder() + .token("a") + .username("kevin") + .images(images) + .githubRepoUrl("junit-test") + .tags(List.of("Java", "CSS", "HTML")) + .content("hi there!") + .build(); + + PostRequestDto fixture5 = PostRequestDto.builder() + .token("a") + .username("kevin") + .images(images) + .githubRepoUrl("jpa-learning-test") + .tags(List.of("Java", "CSS", "HTML")) + .content("jpa is so fun!") + .build(); + + return List.of(fixture1, fixture2, fixture3, fixture4, fixture5); + } + + public static List mockPostResponseDtos() { + CommentResponseDto commentFixture1 = CommentResponseDto.builder() + .id(1L) + .profileImageUrl("commentAuthorProfileImageUrl") + .authorName("commentAuthorName1") + .content("commentContent1") + .liked(false) + .build(); + + CommentResponseDto commentFixture2 = CommentResponseDto.builder() + .id(2L) + .profileImageUrl("commentAuthorProfileImageUrl") + .authorName("commentAuthorName2") + .content("commentContent2") + .liked(false) + .build(); + + PostResponseDto fixture1 = PostResponseDto.builder() + .id(1L) + .imageUrls(List.of("image1Url", "image2Url")) + .githubRepoUrl("githubRepoUrl") + .content("content") + .authorName("authorName") + .profileImageUrl("profileImageUrl") + .likesCount(1) + .tags(List.of("tag1", "tag2")) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .comments(List.of(commentFixture1, commentFixture2)) + .liked(false) + .build(); + + return List.of(fixture1); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/factory/UserFactory.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/factory/UserFactory.java new file mode 100644 index 000000000..3f31f4d49 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/factory/UserFactory.java @@ -0,0 +1,133 @@ +package com.woowacourse.pickgit.common.factory; + +import com.woowacourse.pickgit.user.application.dto.response.ContributionResponseDto; +import com.woowacourse.pickgit.user.application.dto.response.UserProfileResponseDto; +import com.woowacourse.pickgit.user.domain.User; +import java.util.List; + +public class UserFactory { + + private UserFactory() { + } + + public static User user(Long id, String name) { + return createUser(id, name); + } + + public static User user(String name) { + return createUser(null, name); + } + + public static User user() { + return createUser(null, "testUser"); + } + + public static User createUser(Long id, String name) { + return MockUser.builder() + .id(id) + .name(name) + .build(); + } + + public static UserProfileResponseDto mockLoginUserProfileResponseDto() { + return UserProfileResponseDto.builder() + .name("testUser") + .imageUrl("http://img.com") + .description("The Best") + .followerCount(0) + .followingCount(0) + .postCount(0) + .githubUrl("https://github.com/yjksw") + .company("woowacourse") + .location("Seoul") + .website("www.pick-git.com") + .twitter("pick-git twitter") + .following(null) + .build(); + } + + public static UserProfileResponseDto mockLoginUserProfileIsFollowingResponseDto() { + return UserProfileResponseDto.builder() + .name("testUser2") + .imageUrl("http://img.com") + .description("The Best") + .followerCount(1) + .followingCount(0) + .postCount(0) + .githubUrl("https://github.com/yjksw") + .company("woowacourse") + .location("Seoul") + .website("www.pick-git.com") + .twitter("pick-git twitter") + .following(true) + .build(); + } + + public static UserProfileResponseDto mockLoginUserProfileIsNotFollowingResponseDto() { + return UserProfileResponseDto.builder() + .name("testUser2") + .imageUrl("http://img.com") + .description("The Best") + .followerCount(0) + .followingCount(0) + .postCount(0) + .githubUrl("https://github.com/yjksw") + .company("woowacourse") + .location("Seoul") + .website("www.pick-git.com") + .twitter("pick-git twitter") + .following(false) + .build(); + } + + public static UserProfileResponseDto mockGuestUserProfileResponseDto() { + return UserProfileResponseDto.builder() + .name("testUser") + .imageUrl("http://img.com") + .description("The Best") + .followerCount(0) + .followingCount(0) + .postCount(0) + .githubUrl("https://github.com/yjksw") + .company("woowacourse") + .location("Seoul") + .website("www.pick-git.com") + .twitter("pick-git twitter") + .following(null) + .build(); + } + + public static ContributionResponseDto mockContributionResponseDto() { + return ContributionResponseDto.builder() + .starsCount(11) + .commitsCount(48) + .prsCount(48) + .issuesCount(48) + .reposCount(48) + .build(); + } + + public static List mockSearchUsers() { + User user1 = user( "binghe"); + User user2 = user( "bing"); + User user3 = user( "jinbinghe"); + User user4 = user( "bbbbinghe"); + User user5 = user( "bingbing"); + + return List.of( + user1, user2, user3, user4, user5 + ); + } + + public static List mockSearchUsersWithId() { + User user1 = user(1L, "binghe"); + User user2 = user(2L,"bing"); + User user3 = user(3L,"jinbinghe"); + User user4 = user(4L,"bbbbinghe"); + User user5 = user(5L,"bingbing"); + + return List.of( + user1, user2, user3, user4, user5 + ); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/mockapi/MockContributionApiRequester.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/mockapi/MockContributionApiRequester.java new file mode 100644 index 000000000..a9c6d9a34 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/mockapi/MockContributionApiRequester.java @@ -0,0 +1,21 @@ +package com.woowacourse.pickgit.common.mockapi; + +import com.woowacourse.pickgit.exception.platform.PlatformHttpErrorException; +import com.woowacourse.pickgit.user.infrastructure.requester.PlatformContributionApiRequester; + +public class MockContributionApiRequester implements PlatformContributionApiRequester { + + @Override + public String request(String url) { + String validPrefix = "https://api.github.com/search/"; + + if (!url.startsWith(validPrefix)) { + throw new PlatformHttpErrorException(); + } + + if (url.contains("stars")) { + return "{\"items\": [{\"stargazers_count\": \"5\"}, {\"stargazers_count\": \"6\"}]}"; + } + return "{\"total_count\": \"48\"}"; + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/mockapi/MockPickGitStorage.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/mockapi/MockPickGitStorage.java new file mode 100644 index 000000000..3e34e018d --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/mockapi/MockPickGitStorage.java @@ -0,0 +1,61 @@ +package com.woowacourse.pickgit.common.mockapi; + +import static java.util.stream.Collectors.toList; + +import com.woowacourse.pickgit.exception.platform.PlatformHttpErrorException; +import com.woowacourse.pickgit.post.domain.repository.PickGitStorage; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; +import org.springframework.web.multipart.MultipartFile; + +public class MockPickGitStorage implements PickGitStorage { + + @Override + public List store(List files, String userName) { + return files.stream() + .map(File::getName) + .collect(toList()); + } + + @Override + public Optional store(File file, String userName) { + return Optional.ofNullable(file.getName()); + } + + @Override + public List storeMultipartFile(List multipartFiles, String userName) { + return store(toFiles(multipartFiles), userName); + } + + private List toFiles(List files) { + return files.stream() + .map(toFile()) + .collect(toList()); + } + + private Function toFile() { + return multipartFile -> { + try { + return multipartFile.getResource().getFile(); + } catch (IOException e) { + return tryCreateTempFile(multipartFile); + } + }; + } + + private File tryCreateTempFile(MultipartFile multipartFile) { + try { + Path tempFile = Files.createTempFile(null, null); + Files.write(tempFile, multipartFile.getBytes()); + + return tempFile.toFile(); + } catch (IOException ioException) { + throw new PlatformHttpErrorException(); + } + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/infrastructure/MockRepositoryApiRequester.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/mockapi/MockRepositoryApiRequester.java similarity index 89% rename from backend/pick-git/src/test/java/com/woowacourse/pickgit/post/infrastructure/MockRepositoryApiRequester.java rename to backend/pick-git/src/test/java/com/woowacourse/pickgit/common/mockapi/MockRepositoryApiRequester.java index 52ebc5d81..2e270b34d 100644 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/infrastructure/MockRepositoryApiRequester.java +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/mockapi/MockRepositoryApiRequester.java @@ -1,6 +1,7 @@ -package com.woowacourse.pickgit.post.infrastructure; +package com.woowacourse.pickgit.common.mockapi; import com.woowacourse.pickgit.exception.platform.PlatformHttpErrorException; +import com.woowacourse.pickgit.post.domain.util.PlatformRepositoryApiRequester; public class MockRepositoryApiRequester implements PlatformRepositoryApiRequester { diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/infrastructure/MockTagApiRequester.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/mockapi/MockTagApiRequester.java similarity index 80% rename from backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/infrastructure/MockTagApiRequester.java rename to backend/pick-git/src/test/java/com/woowacourse/pickgit/common/mockapi/MockTagApiRequester.java index 5e2f53be1..00e6e4770 100644 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/infrastructure/MockTagApiRequester.java +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/common/mockapi/MockTagApiRequester.java @@ -1,8 +1,9 @@ -package com.woowacourse.pickgit.tag.infrastructure; +package com.woowacourse.pickgit.common.mockapi; import com.woowacourse.pickgit.exception.platform.PlatformHttpErrorException; +import com.woowacourse.pickgit.tag.infrastructure.PlatformTagApiRequester; -public class MockTagApiRequester implements PlatformApiRequester{ +public class MockTagApiRequester implements PlatformTagApiRequester { private static final String TESTER_ACCESS_TOKEN = "oauth.access.token"; private static final String USER_NAME = "jipark3"; diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/config/InfrastructureTestConfiguration.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/config/InfrastructureTestConfiguration.java new file mode 100644 index 000000000..e2937504c --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/config/InfrastructureTestConfiguration.java @@ -0,0 +1,36 @@ +package com.woowacourse.pickgit.config; + +import com.woowacourse.pickgit.common.mockapi.MockContributionApiRequester; +import com.woowacourse.pickgit.common.mockapi.MockPickGitStorage; +import com.woowacourse.pickgit.common.mockapi.MockRepositoryApiRequester; +import com.woowacourse.pickgit.common.mockapi.MockTagApiRequester; +import com.woowacourse.pickgit.post.domain.repository.PickGitStorage; +import com.woowacourse.pickgit.post.domain.util.PlatformRepositoryApiRequester; +import com.woowacourse.pickgit.tag.infrastructure.PlatformTagApiRequester; +import com.woowacourse.pickgit.user.infrastructure.requester.PlatformContributionApiRequester; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class InfrastructureTestConfiguration { + + @Bean + public PlatformRepositoryApiRequester platformRepositoryApiRequester() { + return new MockRepositoryApiRequester(); + } + + @Bean + public PlatformTagApiRequester platformTagApiRequester() { + return new MockTagApiRequester(); + } + + @Bean + public PlatformContributionApiRequester platformContributionApiRequester() { + return new MockContributionApiRequester(); + } + + @Bean + public PickGitStorage pickGitStorage() { + return new MockPickGitStorage(); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/TestJpaConfiguration.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/config/JpaTestConfiguration.java similarity index 70% rename from backend/pick-git/src/test/java/com/woowacourse/pickgit/TestJpaConfiguration.java rename to backend/pick-git/src/test/java/com/woowacourse/pickgit/config/JpaTestConfiguration.java index 33cd36177..a09ca7729 100644 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/TestJpaConfiguration.java +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/config/JpaTestConfiguration.java @@ -1,10 +1,10 @@ -package com.woowacourse.pickgit; +package com.woowacourse.pickgit.config; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @TestConfiguration @EnableJpaAuditing -public class TestJpaConfiguration { +public class JpaTestConfiguration { } diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/presentation/interceptor/AuthenticationInterceptorTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/authentication/AuthenticationInterceptorIntegrationTest.java similarity index 63% rename from backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/presentation/interceptor/AuthenticationInterceptorTest.java rename to backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/authentication/AuthenticationInterceptorIntegrationTest.java index 8e3b7c80d..0f24ce4f6 100644 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/presentation/interceptor/AuthenticationInterceptorTest.java +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/authentication/AuthenticationInterceptorIntegrationTest.java @@ -1,20 +1,21 @@ -package com.woowacourse.pickgit.authentication.presentation.interceptor; +package com.woowacourse.pickgit.integration.authentication; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import com.woowacourse.pickgit.authentication.application.JwtTokenProvider; import com.woowacourse.pickgit.authentication.application.OAuthService; -import com.woowacourse.pickgit.authentication.infrastructure.AuthorizationExtractor; import com.woowacourse.pickgit.authentication.infrastructure.JwtTokenProviderImpl; +import com.woowacourse.pickgit.authentication.presentation.interceptor.AuthenticationInterceptor; import com.woowacourse.pickgit.exception.authentication.InvalidTokenException; import java.util.Collections; import java.util.List; import javax.servlet.http.HttpServletRequest; +import org.apache.http.HttpHeaders; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -25,7 +26,7 @@ import org.springframework.http.HttpMethod; @ExtendWith(MockitoExtension.class) -class AuthenticationInterceptorTest { +class AuthenticationInterceptorIntegrationTest { private JwtTokenProvider jwtTokenProvider; @@ -48,10 +49,14 @@ void setUp() { @Test void preHandle_CORS_True() throws Exception { // mock - when(httpServletRequest.getMethod()).thenReturn(HttpMethod.OPTIONS.toString()); - when(httpServletRequest.getHeader("Access-Control-Request-Headers")).thenReturn("X-PINGOTHER, Content-Type"); - when(httpServletRequest.getHeader("Access-Control-Request-Method")).thenReturn("POST"); - when(httpServletRequest.getHeader("Origin")).thenReturn("http://pick-git.example"); + given(httpServletRequest.getMethod()) + .willReturn(HttpMethod.OPTIONS.toString()); + given(httpServletRequest.getHeader("Access-Control-Request-Headers")) + .willReturn("X-PINGOTHER, Content-Type"); + given(httpServletRequest.getHeader("Access-Control-Request-Method")) + .willReturn("POST"); + given(httpServletRequest.getHeader("Origin")) + .willReturn("http://pick-git.example"); // then assertThat(authenticationInterceptor.preHandle(httpServletRequest, null, null)).isTrue(); @@ -64,9 +69,11 @@ void preHandle_ValidToken_True() throws Exception { String validToken = "Bearer " + jwtTokenProvider.createToken("pick-git"); // mock, when - when(httpServletRequest.getMethod()).thenReturn(HttpMethod.OPTIONS.toString()); - when(httpServletRequest.getHeaders(AuthorizationExtractor.AUTHORIZATION)).thenReturn(Collections.enumeration( - List.of(validToken))); + given(httpServletRequest.getMethod()) + .willReturn(HttpMethod.OPTIONS.toString()); + given(httpServletRequest.getHeaders(HttpHeaders.AUTHORIZATION)) + .willReturn(Collections.enumeration( + List.of(validToken))); // then assertThat(authenticationInterceptor.preHandle(httpServletRequest, null, null)).isTrue(); @@ -80,12 +87,15 @@ void preHandle_InvalidToken_ThrowException() { String bearerToken = "Bearer " + "invalid token"; // mock - when(httpServletRequest.getMethod()).thenReturn(HttpMethod.OPTIONS.toString()); - when(httpServletRequest.getHeaders(AuthorizationExtractor.AUTHORIZATION)).thenReturn(Collections.enumeration( - List.of(bearerToken))); + given(httpServletRequest.getMethod()) + .willReturn(HttpMethod.OPTIONS.toString()); + given(httpServletRequest.getHeaders(HttpHeaders.AUTHORIZATION)) + .willReturn(Collections.enumeration( + List.of(bearerToken))); // when, then - assertThatThrownBy(() -> authenticationInterceptor.preHandle(httpServletRequest, null, null)) + assertThatThrownBy( + () -> authenticationInterceptor.preHandle(httpServletRequest, null, null)) .isInstanceOf(InvalidTokenException.class); } @@ -97,12 +107,15 @@ void preHandle_ExpiredToken_ThrowException() { String bearerToken = "Bearer " + jwtTokenProvider.createToken("pick-git"); // mock - when(httpServletRequest.getMethod()).thenReturn(HttpMethod.GET.toString()); - when(httpServletRequest.getHeaders(AuthorizationExtractor.AUTHORIZATION)).thenReturn(Collections.enumeration( - List.of(bearerToken))); + given(httpServletRequest.getMethod()) + .willReturn(HttpMethod.GET.toString()); + given(httpServletRequest.getHeaders(HttpHeaders.AUTHORIZATION)) + .willReturn(Collections.enumeration( + List.of(bearerToken))); // when, then - assertThatThrownBy(() -> authenticationInterceptor.preHandle(httpServletRequest, null, null)) + assertThatThrownBy( + () -> authenticationInterceptor.preHandle(httpServletRequest, null, null)) .isInstanceOf(InvalidTokenException.class); } } diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/presentation/AuthenticationPrincipalArgumentResolverTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/authentication/AuthenticationPrincipalArgumentResolverIntegrationTest.java similarity index 69% rename from backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/presentation/AuthenticationPrincipalArgumentResolverTest.java rename to backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/authentication/AuthenticationPrincipalArgumentResolverIntegrationTest.java index bbe9913fd..7e4f45cb7 100644 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/presentation/AuthenticationPrincipalArgumentResolverTest.java +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/authentication/AuthenticationPrincipalArgumentResolverIntegrationTest.java @@ -1,8 +1,8 @@ -package com.woowacourse.pickgit.authentication.presentation; +package com.woowacourse.pickgit.integration.authentication; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.when; +import static org.mockito.BDDMockito.given; import com.woowacourse.pickgit.authentication.application.JwtTokenProvider; import com.woowacourse.pickgit.authentication.application.OAuthService; @@ -10,6 +10,7 @@ import com.woowacourse.pickgit.authentication.dao.OAuthAccessTokenDao; import com.woowacourse.pickgit.authentication.domain.user.AppUser; import com.woowacourse.pickgit.authentication.infrastructure.JwtTokenProviderImpl; +import com.woowacourse.pickgit.authentication.presentation.AuthenticationPrincipalArgumentResolver; import com.woowacourse.pickgit.authentication.presentation.interceptor.AuthHeader; import com.woowacourse.pickgit.exception.authentication.InvalidTokenException; import com.woowacourse.pickgit.exception.authentication.UnauthorizedException; @@ -18,14 +19,12 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.HttpHeaders; import org.springframework.web.context.request.ServletWebRequest; @ExtendWith(MockitoExtension.class) -class AuthenticationPrincipalArgumentResolverTest { +class AuthenticationPrincipalArgumentResolverIntegrationTest { private JwtTokenProvider jwtTokenProvider; @@ -34,17 +33,20 @@ class AuthenticationPrincipalArgumentResolverTest { @Mock private HttpServletRequest httpServletRequest; - @InjectMocks private OAuthService oAuthService; private AuthenticationPrincipalArgumentResolver authenticationPrincipalArgumentResolver; @BeforeEach void setUp() { - jwtTokenProvider = new JwtTokenProviderImpl("pick-git", 3600000); - oAuthAccessTokenDao = new CollectionOAuthAccessTokenDao(); - oAuthService = new OAuthService(null, jwtTokenProvider, oAuthAccessTokenDao, null); - authenticationPrincipalArgumentResolver = new AuthenticationPrincipalArgumentResolver(oAuthService); + jwtTokenProvider = + new JwtTokenProviderImpl("pick-git", 3600000); + oAuthAccessTokenDao = + new CollectionOAuthAccessTokenDao(); + oAuthService = + new OAuthService(null, jwtTokenProvider, oAuthAccessTokenDao, null); + authenticationPrincipalArgumentResolver = + new AuthenticationPrincipalArgumentResolver(oAuthService); } @DisplayName("유효한 토큰이면 LoginUser를 반환한다.") @@ -58,10 +60,11 @@ void resolveArgument_ValidUserToken_ReturnLoginUser() throws Exception { oAuthAccessTokenDao.insert(jwtToken, accessToken); // mock - when(httpServletRequest.getAttribute(AuthHeader.AUTHENTICATION)).thenReturn(jwtToken); + given(httpServletRequest.getAttribute(AuthHeader.AUTHENTICATION)).willReturn(jwtToken); // when - AppUser loginUser = (AppUser) authenticationPrincipalArgumentResolver.resolveArgument(null, null, new ServletWebRequest(httpServletRequest), null); + AppUser loginUser = (AppUser) authenticationPrincipalArgumentResolver + .resolveArgument(null, null, new ServletWebRequest(httpServletRequest), null); // then assertThat(loginUser.isGuest()).isFalse(); @@ -76,11 +79,12 @@ void resolveArgument_InvalidToken_ThrowException() throws Exception { String jwtToken = "invalid jwt token"; // mock - when(httpServletRequest.getAttribute(AuthHeader.AUTHENTICATION)).thenReturn(jwtToken); + given(httpServletRequest.getAttribute(AuthHeader.AUTHENTICATION)).willReturn(jwtToken); // then assertThatThrownBy(() -> { - authenticationPrincipalArgumentResolver.resolveArgument(null, null, new ServletWebRequest(httpServletRequest), null); + authenticationPrincipalArgumentResolver + .resolveArgument(null, null, new ServletWebRequest(httpServletRequest), null); }).isInstanceOf(InvalidTokenException.class); } @@ -91,10 +95,11 @@ void resolveArgument_NotFoundToken_ThrowException() { String jwtToken = jwtTokenProvider.createToken("pick-git"); // when - when(httpServletRequest.getAttribute(AuthHeader.AUTHENTICATION)).thenReturn(jwtToken); + given(httpServletRequest.getAttribute(AuthHeader.AUTHENTICATION)).willReturn(jwtToken); assertThatThrownBy(() -> { - authenticationPrincipalArgumentResolver.resolveArgument(null, null, new ServletWebRequest(httpServletRequest), null); + authenticationPrincipalArgumentResolver + .resolveArgument(null, null, new ServletWebRequest(httpServletRequest), null); }).isInstanceOf(InvalidTokenException.class); } @@ -102,10 +107,11 @@ void resolveArgument_NotFoundToken_ThrowException() { @Test void resolveArgument_InValidUserToken_ReturnGuest() throws Exception { // mock - when(httpServletRequest.getAttribute(AuthHeader.AUTHENTICATION)).thenReturn(null); + given(httpServletRequest.getAttribute(AuthHeader.AUTHENTICATION)).willReturn(null); // when - AppUser loginUser = (AppUser) authenticationPrincipalArgumentResolver.resolveArgument(null, null, new ServletWebRequest(httpServletRequest), null); + AppUser loginUser = (AppUser) authenticationPrincipalArgumentResolver + .resolveArgument(null, null, new ServletWebRequest(httpServletRequest), null); // then assertThat(loginUser.isGuest()).isTrue(); diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/presentation/interceptor/IgnoreAuthenticationInterceptorTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/authentication/IgnoreAuthenticationInterceptorIntegrationTest.java similarity index 87% rename from backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/presentation/interceptor/IgnoreAuthenticationInterceptorTest.java rename to backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/authentication/IgnoreAuthenticationInterceptorIntegrationTest.java index 2e312c8a6..91097d9a0 100644 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/presentation/interceptor/IgnoreAuthenticationInterceptorTest.java +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/authentication/IgnoreAuthenticationInterceptorIntegrationTest.java @@ -1,4 +1,4 @@ -package com.woowacourse.pickgit.authentication.presentation.interceptor; +package com.woowacourse.pickgit.integration.authentication; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -9,12 +9,13 @@ import com.woowacourse.pickgit.authentication.application.JwtTokenProvider; import com.woowacourse.pickgit.authentication.application.OAuthService; -import com.woowacourse.pickgit.authentication.infrastructure.AuthorizationExtractor; import com.woowacourse.pickgit.authentication.infrastructure.JwtTokenProviderImpl; +import com.woowacourse.pickgit.authentication.presentation.interceptor.IgnoreAuthenticationInterceptor; import com.woowacourse.pickgit.exception.authentication.InvalidTokenException; import java.util.Collections; import java.util.List; import javax.servlet.http.HttpServletRequest; +import org.apache.http.HttpHeaders; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -25,7 +26,7 @@ import org.springframework.http.HttpMethod; @ExtendWith(MockitoExtension.class) -class IgnoreAuthenticationInterceptorTest { +class IgnoreAuthenticationInterceptorIntegrationTest { private JwtTokenProvider jwtTokenProvider; @@ -52,7 +53,7 @@ void preHandle_WithValidToken_ReturnTrue() throws Exception { // mock given(request.getMethod()).willReturn(HttpMethod.GET.toString()); - given(request.getHeaders(AuthorizationExtractor.AUTHORIZATION)) + given(request.getHeaders(HttpHeaders.AUTHORIZATION)) .willReturn(Collections.enumeration(List.of(validToken))); // when, then @@ -60,12 +61,12 @@ void preHandle_WithValidToken_ReturnTrue() throws Exception { verify(request, times(2)).setAttribute(anyString(), anyString()); } - @DisplayName("토큰이 아예 없으면 true를 반환한다. (GuestUser)") + @DisplayName("토큰이 아예 없으면 true를 반환한다. - GuestUser인 경우") @Test void preHandle_WithOutToken_ReturnTrue() throws Exception { // mock given(request.getMethod()).willReturn(HttpMethod.GET.toString()); - given(request.getHeaders(AuthorizationExtractor.AUTHORIZATION)) + given(request.getHeaders(HttpHeaders.AUTHORIZATION)) .willReturn(Collections.emptyEnumeration()); // when, then @@ -80,7 +81,7 @@ void preHandle_WithInvalidToken_ThrowException() { // mock given(request.getMethod()).willReturn(HttpMethod.GET.toString()); - given(request.getHeaders(AuthorizationExtractor.AUTHORIZATION)) + given(request.getHeaders(HttpHeaders.AUTHORIZATION)) .willReturn(Collections.enumeration(List.of(invalidToken))); // when, then diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/authentication/OAuthServiceIntegrationTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/authentication/OAuthServiceIntegrationTest.java new file mode 100644 index 000000000..31ca516e2 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/authentication/OAuthServiceIntegrationTest.java @@ -0,0 +1,203 @@ +package com.woowacourse.pickgit.integration.authentication; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +import com.woowacourse.pickgit.authentication.application.JwtTokenProvider; +import com.woowacourse.pickgit.authentication.application.OAuthService; +import com.woowacourse.pickgit.authentication.application.dto.OAuthProfileResponse; +import com.woowacourse.pickgit.authentication.application.dto.TokenDto; +import com.woowacourse.pickgit.authentication.dao.CollectionOAuthAccessTokenDao; +import com.woowacourse.pickgit.authentication.dao.OAuthAccessTokenDao; +import com.woowacourse.pickgit.authentication.domain.OAuthClient; +import com.woowacourse.pickgit.authentication.domain.user.AppUser; +import com.woowacourse.pickgit.authentication.domain.user.GuestUser; +import com.woowacourse.pickgit.authentication.domain.user.LoginUser; +import com.woowacourse.pickgit.authentication.infrastructure.JwtTokenProviderImpl; +import com.woowacourse.pickgit.config.InfrastructureTestConfiguration; +import com.woowacourse.pickgit.exception.authentication.InvalidTokenException; +import com.woowacourse.pickgit.exception.authentication.UnauthorizedException; +import com.woowacourse.pickgit.user.domain.User; +import com.woowacourse.pickgit.user.domain.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +@Import(InfrastructureTestConfiguration.class) +@DataJpaTest +@ActiveProfiles("test") +class OAuthServiceIntegrationTest { + + private static final String SECRET_KEY = "secret-key"; + private static final Long EXPIRATION_TIME_IN_MILLISECONDS = 3600000L; + + @Mock + private OAuthClient oAuthClient; + + @Autowired + private UserRepository userRepository; + + private JwtTokenProvider jwtTokenProvider; + + private OAuthAccessTokenDao oAuthAccessTokenDao; + + private OAuthService oAuthService; + + @BeforeEach + void setUp() { + this.jwtTokenProvider = new JwtTokenProviderImpl( + SECRET_KEY, + EXPIRATION_TIME_IN_MILLISECONDS + ); + this.oAuthAccessTokenDao = new CollectionOAuthAccessTokenDao(); + this.oAuthService = new OAuthService( + oAuthClient, + jwtTokenProvider, + oAuthAccessTokenDao, + userRepository + ); + } + + @DisplayName("Github 로그인 URL을 반환한다.") + @Test + void getGithubAuthorizationUrl_Anonymous_ReturnGithubAuthorizationUrl() { + // mock + given(oAuthClient.getLoginUrl()) + .willReturn("https://github.com/login/oauth/authorize?"); + + // when + String githubAuthorizationUrl = + oAuthService.getGithubAuthorizationUrl(); + + // then + assertThat(githubAuthorizationUrl) + .startsWith("https://github.com/login/oauth/authorize?"); + } + + @DisplayName("회원가입(첫 로그인)시 Github Profile을 가져와서 DB에 insert한다.") + @Test + void createToken_Signup_SaveUserProfile() { + // given + String code = "oauth authorization code"; + String oauthAccessToken = "oauth access token"; + OAuthProfileResponse oAuthProfileResponse = OAuthProfileResponse.builder() + .name("binghe") + .image("image") + .githubUrl("github.com/") + .build(); + + // mock + given(oAuthClient.getAccessToken(code)) + .willReturn(oauthAccessToken); + given(oAuthClient.getGithubProfile(oauthAccessToken)) + .willReturn(oAuthProfileResponse); + + // when + TokenDto token = oAuthService.createToken(code); + + // then + assertThat(token.getToken()).isNotNull(); + assertThat(token.getUsername()).isEqualTo(oAuthProfileResponse.getName()); + + User user = userRepository.findByBasicProfile_Name(token.getUsername()).orElse(null); + assertThat(user).isNotNull(); + assertThat(user.getName()).isEqualTo("binghe"); + assertThat(user.getImage()).isEqualTo("image"); + assertThat(user.getGithubUrl()).isEqualTo("github.com/"); + } + + @DisplayName("로그인(첫 로그인이 아닌경우)시 Github Profile을 가져와서 DB에 저장된 기존 정보를 update한다.") + @Test + void createToken_Signup_UpdateUserProfile() { + // given + String code = "oauth authorization code"; + String oauthAccessToken = "oauth access token"; + OAuthProfileResponse previousOAuthProfileResponse = OAuthProfileResponse.builder() + .name("binghe") + .image("image") + .githubUrl("github.com/") + .build(); + + User existingUser = new User(previousOAuthProfileResponse.toBasicProfile(), + previousOAuthProfileResponse.toGithubProfile()); + userRepository.save(existingUser); + + OAuthProfileResponse changedOAuthProfileResponse = OAuthProfileResponse.builder() + .name("binghe") + .image("image") + .githubUrl("github.com/") + .company("@woowabros") + .build(); + + // mock + given(oAuthClient.getAccessToken(code)).willReturn(oauthAccessToken); + given(oAuthClient.getGithubProfile(oauthAccessToken)) + .willReturn(changedOAuthProfileResponse); + + // when + TokenDto token = oAuthService.createToken(code); + + // then + assertThat(token.getToken()).isNotNull(); + assertThat(token.getUsername()).isEqualTo(changedOAuthProfileResponse.getName()); + + User user = userRepository.findByBasicProfile_Name(token.getUsername()).orElse(null); + assertThat(user).isNotNull(); + assertThat(user.getName()).isEqualTo("binghe"); + assertThat(user.getImage()).isEqualTo("image"); + assertThat(user.getGithubUrl()).isEqualTo("github.com/"); + assertThat(user.getCompany()).isEqualTo("@woowabros"); + } + + @DisplayName("JWT 토큰을 통해 AccessTokenDB에서 LoginUser에 대한 정보를 가져온다.") + @Test + void findRequestUserByToken_ValidToken_ReturnAppUser() { + // given + String username = "pick-git"; + String token = jwtTokenProvider.createToken(username); + String accessToken = "oauth access token"; + + oAuthAccessTokenDao.insert(token, accessToken); + + // when + AppUser appUser = oAuthService.findRequestUserByToken(token); + + // then + assertThat(appUser).isInstanceOf(LoginUser.class); + assertThat(appUser.getUsername()).isEqualTo(username); + assertThat(appUser.getAccessToken()).isEqualTo(accessToken); + } + + @DisplayName("AccessTokenDB에 저장되어 있지 않은 JWT 토큰이라면 예외가 발생한다.") + @Test + void findRequestUserByToken_NotFoundToken_ThrowException() { + // given + String username = "pick-git-test"; + String token = jwtTokenProvider.createToken(username); + + // when, then + assertThatThrownBy(() -> oAuthService.findRequestUserByToken(token)) + .isInstanceOf(InvalidTokenException.class); + } + + @DisplayName("authentication이 Null 이라면 GuestUser를 반환한다.") + @Test + void findRequestUserByToken_NullAuthenticationParam_ReturnGuestUser() { + // given + // when + AppUser appUser = oAuthService.findRequestUserByToken(null); + + // then + assertThat(appUser).isInstanceOf(GuestUser.class); + assertThatThrownBy(() -> appUser.getUsername()) + .isInstanceOf(UnauthorizedException.class); + assertThatThrownBy(() -> appUser.getAccessToken()) + .isInstanceOf(UnauthorizedException.class); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/post/PostFeedServiceIntegrationTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/post/PostFeedServiceIntegrationTest.java new file mode 100644 index 000000000..3ec298c45 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/post/PostFeedServiceIntegrationTest.java @@ -0,0 +1,202 @@ +package com.woowacourse.pickgit.integration.post; + +import static java.util.stream.Collectors.toList; +import static org.assertj.core.api.Assertions.assertThat; + +import com.woowacourse.pickgit.common.factory.PostFactory; +import com.woowacourse.pickgit.common.factory.UserFactory; +import com.woowacourse.pickgit.config.InfrastructureTestConfiguration; +import com.woowacourse.pickgit.post.application.PostFeedService; +import com.woowacourse.pickgit.post.application.PostService; +import com.woowacourse.pickgit.post.application.dto.request.CommentRequestDto; +import com.woowacourse.pickgit.post.application.dto.request.HomeFeedRequestDto; +import com.woowacourse.pickgit.post.application.dto.request.PostRequestDto; +import com.woowacourse.pickgit.post.application.dto.response.PostResponseDto; +import com.woowacourse.pickgit.user.domain.User; +import com.woowacourse.pickgit.user.domain.UserRepository; +import java.util.List; +import java.util.Objects; +import java.util.stream.IntStream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.ActiveProfiles; + +@Import(InfrastructureTestConfiguration.class) +@DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD) +@SpringBootTest(webEnvironment = WebEnvironment.NONE) +@ActiveProfiles("test") +public class PostFeedServiceIntegrationTest { + + @Autowired + private PostService postService; + + @Autowired + private PostFeedService postFeedService; + + @Autowired + private UserRepository userRepository; + + @DisplayName("저장된 게시물 중 3, 4번째 글을 최신날짜순으로 가져온다.") + @Test + void readHomeFeed_Success() { + //given + createMockPosts(); + + userRepository.save(UserFactory.user("kevin")); + HomeFeedRequestDto homeFeedRequestDto = HomeFeedRequestDto.builder() + .requestUserName("kevin") + .isGuest(false) + .page(1L) + .limit(2L) + .build(); + + // when + List postResponseDtos = postFeedService.homeFeed(homeFeedRequestDto); + + //then + List postNames = postResponseDtos.stream() + .map(PostResponseDto::getAuthorName) + .collect(toList()); + + List repoNames = extractGithubRepoUrls(postResponseDtos); + + assertThat(postResponseDtos).hasSize(2); + assertThat(postNames).containsExactly("dani", "ginger"); + assertThat(repoNames).containsExactly("java-racingcar", "jwp-chess"); + } + + private void createMockPosts() { + List postRequestDtos = PostFactory.mockPostRequestDtos(); + List users = postRequestDtos.stream() + .map(PostRequestDto::getUsername) + .map(UserFactory::user) + .collect(toList()); + + IntStream.range(0, users.size()) + .forEach(index -> { + User user = users.get(index); + PostRequestDto newPost = postRequestDtos.get(index); + + userRepository.save(user); + Long postId = postService.write(newPost).getId(); + + CommentRequestDto commentRequestDto = + new CommentRequestDto(user.getName(), "test comment" + index, postId); + postService.addComment(commentRequestDto); + }); + } + + @DisplayName("내 피드 게시물들만 조회한다.") + @Test + void readMyFeed_Success() { + //given + User savedUser = userRepository.save(UserFactory.user("kevin")); + List postRequestDtos = PostFactory.mockPostRequestForAssertingMyFeed(); + postRequestDtos.forEach(postService::write); + + //when + HomeFeedRequestDto homeFeedRequestDto = HomeFeedRequestDto.builder() + .requestUserName(savedUser.getName()) + .isGuest(false) + .page(0L) + .limit((long) postRequestDtos.size()) + .build(); + + List postResponseDtos = postFeedService.myFeed(homeFeedRequestDto); + List repoNames = postResponseDtos.stream() + .map(PostResponseDto::getGithubRepoUrl) + .collect(toList()); + + //then + assertThat(postResponseDtos).hasSize(postRequestDtos.size()); + assertThat(repoNames).containsAll(extractGithubRepoUrls(postRequestDtos)); + } + + @DisplayName("로그인 사용자가 다른 사용자의 피드 게시물을 조회한다.") + @Test + void readUserFeed_LoginUser_Success() { + //given + User neozal = userRepository.save(UserFactory.user("neozal")); + User kevin = userRepository.save(UserFactory.user("kevin")); + + List postRequestDtos = + PostFactory.mockPostRequestForAssertingMyFeed(); + postRequestDtos.forEach(postService::write); + + HomeFeedRequestDto homeFeedRequestDto = HomeFeedRequestDto.builder() + .requestUserName(neozal.getName()) + .isGuest(false) + .page(0L) + .limit((long) postRequestDtos.size()) + .build(); + + //when + List postResponseDtos = + postFeedService.userFeed(homeFeedRequestDto, kevin.getName()); + + List repoNames = extractGithubRepoUrls(postResponseDtos); + List likes = extractLikes(postResponseDtos); + + //then + assertThat(postResponseDtos).hasSize(postRequestDtos.size()); + assertThat(repoNames).containsAll(extractGithubRepoUrls(postRequestDtos)); + assertThat(likes).containsAll(extractLikes(postResponseDtos)); + } + + @DisplayName("비로그인 사용자가 다른 사용자의 피드 게시물을 조회한다.") + @Test + void readUserFeed_GuestUser_Success() { + User savedUser = userRepository.save(UserFactory.user("kevin")); + + //given + List postRequestDtos = PostFactory.mockPostRequestForAssertingMyFeed(); + postRequestDtos.forEach(postService::write); + + HomeFeedRequestDto homeFeedRequestDto = HomeFeedRequestDto.builder() + .isGuest(true) + .page(0L) + .limit((long) postRequestDtos.size()) + .build(); + + //when + List postResponseDtos = + postFeedService.userFeed(homeFeedRequestDto, savedUser.getName()); + List repoNames = extractGithubRepoUrls(postResponseDtos); + List likes = extractLikes(postResponseDtos); + + //then + assertThat(postResponseDtos).hasSize(postRequestDtos.size()); + assertThat(repoNames).containsAll(extractGithubRepoUrls(postRequestDtos)); + assertThat(likes).allMatch(Objects::isNull); + } + + private List extractLikes(List postResponseDtos) { + return postResponseDtos.stream() + .map(PostResponseDto::getLiked) + .collect(toList()); + } + + private List extractGithubRepoUrls(List dtos) { + Objects.requireNonNull(dtos); + + return dtos.stream() + .map(dto -> { + if (dto instanceof PostResponseDto) { + return ((PostResponseDto) dto).getGithubRepoUrl(); + } + + if (dto instanceof PostRequestDto) { + return ((PostRequestDto) dto).getGithubRepoUrl(); + } + + throw new IllegalArgumentException(); + }).collect(toList()); + } + +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/post/PostServiceIntegrationTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/post/PostServiceIntegrationTest.java new file mode 100644 index 000000000..abb60cf8d --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/post/PostServiceIntegrationTest.java @@ -0,0 +1,658 @@ +package com.woowacourse.pickgit.integration.post; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.woowacourse.pickgit.authentication.domain.user.AppUser; +import com.woowacourse.pickgit.authentication.domain.user.GuestUser; +import com.woowacourse.pickgit.authentication.domain.user.LoginUser; +import com.woowacourse.pickgit.common.factory.FileFactory; +import com.woowacourse.pickgit.common.factory.PostFactory; +import com.woowacourse.pickgit.common.factory.UserFactory; +import com.woowacourse.pickgit.config.InfrastructureTestConfiguration; +import com.woowacourse.pickgit.exception.authentication.UnauthorizedException; +import com.woowacourse.pickgit.exception.platform.PlatformHttpErrorException; +import com.woowacourse.pickgit.exception.post.CannotAddTagException; +import com.woowacourse.pickgit.exception.post.CannotUnlikeException; +import com.woowacourse.pickgit.exception.post.CommentFormatException; +import com.woowacourse.pickgit.exception.post.DuplicatedLikeException; +import com.woowacourse.pickgit.exception.post.PostNotBelongToUserException; +import com.woowacourse.pickgit.exception.post.PostNotFoundException; +import com.woowacourse.pickgit.exception.user.UserNotFoundException; +import com.woowacourse.pickgit.post.application.PostService; +import com.woowacourse.pickgit.post.application.dto.request.CommentRequestDto; +import com.woowacourse.pickgit.post.application.dto.request.PostDeleteRequestDto; +import com.woowacourse.pickgit.post.application.dto.request.PostRequestDto; +import com.woowacourse.pickgit.post.application.dto.request.PostUpdateRequestDto; +import com.woowacourse.pickgit.post.application.dto.request.RepositoryRequestDto; +import com.woowacourse.pickgit.post.application.dto.response.CommentResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.LikeResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.PostImageUrlResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.PostUpdateResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.RepositoryResponseDtos; +import com.woowacourse.pickgit.post.domain.Post; +import com.woowacourse.pickgit.post.domain.repository.PostRepository; +import com.woowacourse.pickgit.user.domain.User; +import com.woowacourse.pickgit.user.domain.UserRepository; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +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.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.ActiveProfiles; + +@Import(InfrastructureTestConfiguration.class) +@DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD) +@SpringBootTest(webEnvironment = WebEnvironment.NONE) +@ActiveProfiles("test") +class PostServiceIntegrationTest { + private static final String USERNAME = "jipark3"; + private static final String ACCESS_TOKEN = "oauth.access.token"; + + @Autowired + private PostService postService; + + @Autowired + private PostRepository postRepository; + + @Autowired + private UserRepository userRepository; + + @DisplayName("게시물에 댓글을 정상 등록한다.") + @Test + void addComment_ValidContent_Success() { + User testUser = UserFactory.user("testUser"); + User savedTestUser = userRepository.save(testUser); + + User kevin = UserFactory.user("kevin"); + User savedKevin = userRepository.save(kevin); + + Post post = Post.builder() + .content("testContent") + .githubRepoUrl("https://github.com/bperhaps") + .author(savedTestUser) + .build(); + Post savedPost = postRepository.save(post); + + // when + CommentRequestDto commentRequestDto = CommentRequestDto.builder() + .userName("kevin") + .content("test comment") + .postId(savedPost.getId()) + .build(); + + CommentResponseDto commentResponseDto = postService.addComment(commentRequestDto); + + // then + assertThat(commentResponseDto.getAuthorName()).isEqualTo(savedKevin.getName()); + assertThat(commentResponseDto.getContent()).isEqualTo("test comment"); + } + + @DisplayName("Post에 빈 Comment은 등록할 수 없다.") + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", " "}) + void addComment_InvalidContent_ExceptionThrown(String content) { + User testUser = UserFactory.user("testUser"); + User savedTestUser = userRepository.save(testUser); + + User kevin = UserFactory.user("kevin"); + User savedKevin = userRepository.save(kevin); + + Post post = Post.builder() + .content("testContent") + .githubRepoUrl("https://github.com/bperhaps") + .author(savedTestUser) + .build(); + Post savedPost = postRepository.save(post); + + CommentRequestDto commentRequestDto = CommentRequestDto.builder() + .userName("kevin") + .content(content) + .postId(savedPost.getId()) + .build(); + + // then + assertThatCode(() -> postService.addComment(commentRequestDto)) + .isInstanceOf(CommentFormatException.class) + .extracting("errorCode") + .isEqualTo("F0002"); + } + + @DisplayName("존재하지 않는 Post에 Comment를 등록할 수 없다.") + @Test + void addComment_PostNotFound_ExceptionThrown() { + userRepository.save(UserFactory.user("kevin")); + + // when + CommentRequestDto commentRequestDto = CommentRequestDto.builder() + .userName("kevin") + .content("content") + .postId(-1L) + .build(); + + // then + assertThatCode(() -> postService.addComment(commentRequestDto)) + .isInstanceOf(PostNotFoundException.class) + .extracting("errorCode") + .isEqualTo("P0002"); + } + + @DisplayName("존재하지 않는 User는 Comment를 등록할 수 없다.") + @Test + void addComment_UserNotFound_ExceptionThrown() { + // given + Post post = Post.builder() + .build(); + Post savedPost = postRepository.save(post); + + // when + CommentRequestDto commentRequestDto = CommentRequestDto.builder() + .userName("anonymous") + .content("content") + .postId(savedPost.getId()) + .build(); + + // then + assertThatCode(() -> postService.addComment(commentRequestDto)) + .isInstanceOf(UserNotFoundException.class) + .extracting("errorCode") + .isEqualTo("U0001"); + } + + @DisplayName("사용자는 게시물을 등록할 수 있다.") + @Test + void write_LoginUser_Success() { + // given + User user = UserFactory.user(USERNAME); + userRepository.save(user); + + PostRequestDto postRequestDto = PostRequestDto.builder() + .token(ACCESS_TOKEN) + .username(USERNAME) + .images(List.of( + FileFactory.getTestImage1(), + FileFactory.getTestImage2() + )) + .githubRepoUrl("https://github.com/bperhaps") + .tags(List.of("java", "c++")) + .content("testContent") + .build(); + + // when + PostImageUrlResponseDto responseDto = postService.write(postRequestDto); + + // then + assertThat(responseDto.getId()).isNotNull(); + } + + @DisplayName("사용자는 게시글 등록시 중복된 태그를 작성할 수 없다.") + @Test + void write_LoginUserWithDuplicateTag_Fail() { + // given + userRepository.save(UserFactory.user(USERNAME)); + + PostRequestDto requestDto = PostRequestDto.builder() + .token(ACCESS_TOKEN) + .username(USERNAME) + .images( + List.of( + FileFactory.getTestImage1(), + FileFactory.getTestImage2() + ) + ) + .githubRepoUrl("https://github.com/bperhaps") + .tags(List.of("Java", "Javascript", "Java")) + .content("content").build(); + + // when, then + assertThatCode(() -> postService.write(requestDto)) + .isInstanceOf(CannotAddTagException.class) + .hasFieldOrPropertyWithValue("errorCode", "P0001") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST); + } + + @DisplayName("사용자는 Repository 목록을 가져올 수 있다.") + @Test + void showRepositories_LoginUser_Success() { + // given + RepositoryRequestDto requestDto = new RepositoryRequestDto(ACCESS_TOKEN, USERNAME); + + // when + RepositoryResponseDtos responseDto = postService.userRepositories(requestDto); + + // then + assertThat(responseDto.getRepositoryResponseDtos()).hasSize(2); + } + + @DisplayName("토큰이 유효하지 않은 경우 예외가 발생한다. - 500 예외") + @Test + void showRepositories_InvalidAccessToken_401Exception() { + // given + String invalidToken = "invalidToken"; + + RepositoryRequestDto requestDto = + new RepositoryRequestDto(invalidToken, USERNAME); + + // then + assertThatThrownBy(() -> { + postService.userRepositories(requestDto); + }).isInstanceOf(PlatformHttpErrorException.class) + .extracting("errorCode") + .isEqualTo("V0001"); + } + + @DisplayName("사용자가 유효하지 않은 경우 예외가 발생한다. - 500 예외") + @Test + void showRepositories_InvalidUsername_404Exception() { + // given + String invalidUserName = "invalidUser"; + + RepositoryRequestDto requestDto = + new RepositoryRequestDto(ACCESS_TOKEN, invalidUserName); + + // then + assertThatThrownBy(() -> + postService.userRepositories(requestDto) + ).isInstanceOf(PlatformHttpErrorException.class) + .extracting("errorCode") + .isEqualTo("V0001"); + } + + @DisplayName("사용자는 특정 게시물을 좋아요 할 수 있다. - 성공") + @Test + void like_ValidUser_Success() { + // given + PostRequestDto postRequestDtos = PostFactory.mockPostRequestDtos().get(0); + User loginUser = userRepository.save(UserFactory.user(postRequestDtos.getUsername())); + + AppUser appUser = new LoginUser(loginUser.getName(), "token"); + + PostImageUrlResponseDto writtenPost = postService.write(postRequestDtos); + + // when + LikeResponseDto likeResponseDto = postService.like(appUser, writtenPost.getId()); + + // then + assertThat(likeResponseDto.getLikesCount()).isEqualTo(1); + assertThat(likeResponseDto.getLiked()).isTrue(); + } + + @DisplayName("사용자는 특정 게시물을 좋아요 취소 할 수 있다. - 성공") + @Test + void unlike_ValidUser_Success() { + // given + PostRequestDto postRequestDtos = PostFactory.mockPostRequestDtos().get(0); + User loginUser = userRepository.save(UserFactory.user(postRequestDtos.getUsername())); + + AppUser appUser = new LoginUser(loginUser.getName(), "token"); + + PostImageUrlResponseDto writtenPost = postService.write(postRequestDtos); + postService.like(appUser, writtenPost.getId()); + + // when + LikeResponseDto likeResponseDto = postService.unlike(appUser, writtenPost.getId()); + + // then + assertThat(likeResponseDto.getLikesCount()).isEqualTo(0); + assertThat(likeResponseDto.getLiked()).isFalse(); + } + + @DisplayName("사용자는 이미 좋아요 한 게시물을 좋아요 추가 할 수 없다. - 실패") + @Test + void like_DuplicatedLike_400ExceptionThrown() { + // given + PostRequestDto postRequestDtos = PostFactory.mockPostRequestDtos().get(0); + User loginUser = userRepository.save(UserFactory.user(postRequestDtos.getUsername())); + + AppUser appUser = new LoginUser(loginUser.getName(), "token"); + + PostImageUrlResponseDto writtenPost = postService.write(postRequestDtos); + postService.like(appUser, writtenPost.getId()); + + // when then + assertThatThrownBy(() -> postService.like(appUser, writtenPost.getId())) + .isInstanceOf(DuplicatedLikeException.class) + .hasFieldOrPropertyWithValue("errorCode", "P0003") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("이미 좋아요한 게시물 중복 좋아요 에러"); + } + + @DisplayName("게스트는 게시물을 좋아요 할 수 없다. - 실패") + @Test + void like_GuestUser_401ExceptionThrown() { + // given + PostRequestDto postRequestDtos = PostFactory.mockPostRequestDtos().get(0); + userRepository.save(UserFactory.user(postRequestDtos.getUsername())); + + AppUser appUser = new GuestUser(); + + PostImageUrlResponseDto writtenPost = postService.write(postRequestDtos); + + // when then + assertThatThrownBy(() -> postService.like(appUser, writtenPost.getId())) + .isInstanceOf(UnauthorizedException.class) + .hasFieldOrPropertyWithValue("errorCode", "A0002") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.UNAUTHORIZED) + .hasMessage("권한 에러"); + } + + @DisplayName("사용자는 좋아요 누르지 않은 게시물을 좋아요 취소 할 수 없다. - 실패") + @Test + void unlike_UnlikePost_400ExceptionThrown() { + // given + PostRequestDto postRequestDtos = PostFactory.mockPostRequestDtos().get(0); + User loginUser = userRepository.save(UserFactory.user(postRequestDtos.getUsername())); + + AppUser appUser = new LoginUser(loginUser.getName(), "token"); + + PostImageUrlResponseDto writtenPost = postService.write(postRequestDtos); + + // when then + assertThatThrownBy(() -> postService.unlike(appUser, writtenPost.getId())) + .isInstanceOf(CannotUnlikeException.class) + .hasFieldOrPropertyWithValue("errorCode", "P0004") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("좋아요 하지 않은 게시물 좋아요 취소 에러"); + } + + @DisplayName("게스트는 게시물을 좋아요 취소 할 수 없다. - 실패") + @Test + void unlike_GuestUser_401ExceptionThrown() { + // given + PostRequestDto postRequestDtos = PostFactory.mockPostRequestDtos().get(0); + userRepository.save(UserFactory.user(postRequestDtos.getUsername())); + User likeUser = userRepository.save(UserFactory.user()); + + AppUser loginUser = new LoginUser(likeUser.getName(), "token"); + AppUser guestUser = new GuestUser(); + + PostImageUrlResponseDto writtenPost = postService.write(postRequestDtos); + postService.like(loginUser, writtenPost.getId()); + + // when then + assertThatThrownBy(() -> postService.unlike(guestUser, writtenPost.getId())) + .isInstanceOf(UnauthorizedException.class) + .hasFieldOrPropertyWithValue("errorCode", "A0002") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.UNAUTHORIZED) + .hasMessage("권한 에러"); + } + + @DisplayName("사용자는 게시물의 태그, 내용을 수정한다.") + @Test + void update_TagsAndContentInCaseOfLoginUser_Success() { + // given + User user = UserFactory.user(USERNAME); + userRepository.save(user); + LoginUser loginUser = new LoginUser(USERNAME, ACCESS_TOKEN); + + PostRequestDto requestDto = PostRequestDto.builder() + .token(ACCESS_TOKEN) + .username(USERNAME) + .images(List.of( + FileFactory.getTestImage1(), + FileFactory.getTestImage2())) + .githubRepoUrl("https://github.com/da-nyee/woowacourse-projects") + .tags(List.of("java", "spring")) + .content("testContent") + .build(); + + postService.write(requestDto); + + PostUpdateRequestDto updateRequestDto = new PostUpdateRequestDto(loginUser, 1L, + List.of("java", "spring", "spring-boot"), "hello"); + + PostUpdateResponseDto responseDto = new PostUpdateResponseDto( + List.of("java", "spring", "spring-boot"), "hello"); + + // when + PostUpdateResponseDto updateResponseDto = postService.update(updateRequestDto); + + // then + assertThat(updateResponseDto) + .usingRecursiveComparison() + .isEqualTo(responseDto); + } + + @DisplayName("사용자는 게시물의 태그를 수정한다.") + @Test + void update_TagsInCaseOfLoginUser_Success() { + // given + User user = UserFactory.user(USERNAME); + userRepository.save(user); + LoginUser loginUser = new LoginUser(USERNAME, ACCESS_TOKEN); + + PostRequestDto requestDto = PostRequestDto.builder() + .token(ACCESS_TOKEN) + .username(USERNAME) + .images(List.of( + FileFactory.getTestImage1(), + FileFactory.getTestImage2())) + .githubRepoUrl("https://github.com/da-nyee/woowacourse-projects") + .tags(List.of("java", "spring")) + .content("testContent") + .build(); + + postService.write(requestDto); + + PostUpdateRequestDto updateRequestDto = new PostUpdateRequestDto(loginUser, 1L, + List.of("java", "spring", "spring-boot"), "testContent"); + + PostUpdateResponseDto responseDto = new PostUpdateResponseDto( + List.of("java", "spring", "spring-boot"), "testContent"); + + // when + PostUpdateResponseDto updateResponseDto = postService.update(updateRequestDto); + + // then + assertThat(updateResponseDto) + .usingRecursiveComparison() + .isEqualTo(responseDto); + } + + @DisplayName("사용자는 게시물의 내용을 수정한다.") + @Test + void update_ContentInCaseOfLoginUser_Success() { + // given + User user = UserFactory.user(USERNAME); + userRepository.save(user); + LoginUser loginUser = new LoginUser(USERNAME, ACCESS_TOKEN); + + PostRequestDto requestDto = PostRequestDto.builder() + .token(ACCESS_TOKEN) + .username(USERNAME) + .images(List.of( + FileFactory.getTestImage1(), + FileFactory.getTestImage2())) + .githubRepoUrl("https://github.com/da-nyee/woowacourse-projects") + .tags(List.of("java", "spring")) + .content("testContent") + .build(); + + postService.write(requestDto); + + PostUpdateRequestDto updateRequestDto = new PostUpdateRequestDto(loginUser, 1L, + List.of("java", "spring"), "hello"); + + PostUpdateResponseDto responseDto = new PostUpdateResponseDto( + List.of("java", "spring"), "hello"); + + // when + PostUpdateResponseDto updateResponseDto = postService.update(updateRequestDto); + + // then + assertThat(updateResponseDto) + .usingRecursiveComparison() + .isEqualTo(responseDto); + } + + @DisplayName("해당하는 사용자의 게시물이 아닌 경우 수정할 수 없다. - 401 예외") + @Test + void update_PostNotBelongToUser_401Exception() { + // given + User user = UserFactory.user(USERNAME); + User anotherUser = UserFactory.user("anotherUser"); + userRepository.save(user); + userRepository.save(anotherUser); + LoginUser loginUser = new LoginUser(USERNAME, ACCESS_TOKEN); + + PostRequestDto requestDto = PostRequestDto.builder() + .token(ACCESS_TOKEN) + .username("anotherUser") + .images(List.of( + FileFactory.getTestImage1(), + FileFactory.getTestImage2())) + .githubRepoUrl("https://github.com/da-nyee/woowacourse-projects") + .tags(List.of("java", "spring")) + .content("testContent") + .build(); + + postService.write(requestDto); + + PostUpdateRequestDto updateRequestDto = new PostUpdateRequestDto(loginUser, 1L, + List.of("java", "spring"), "hello"); + + // when + assertThatThrownBy(() -> + postService.update(updateRequestDto) + ).isInstanceOf(PostNotBelongToUserException.class) + .hasFieldOrPropertyWithValue("errorCode", "P0005") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.UNAUTHORIZED) + .hasMessage("해당하는 사용자의 게시물이 아닙니다."); + } + + @DisplayName("사용자는 중복되는 태그로 게시물을 수정할 수 없다. - 400 예외") + @Test + void update_DuplicateTags_400Exception() { + // given + User user = UserFactory.user(USERNAME); + userRepository.save(user); + LoginUser loginUser = new LoginUser(USERNAME, ACCESS_TOKEN); + + PostRequestDto requestDto = PostRequestDto.builder() + .token(ACCESS_TOKEN) + .username(USERNAME) + .images(List.of( + FileFactory.getTestImage1(), + FileFactory.getTestImage2())) + .githubRepoUrl("https://github.com/da-nyee/woowacourse-projects") + .tags(List.of("java", "spring")) + .content("testContent") + .build(); + + postService.write(requestDto); + + PostUpdateRequestDto updateRequestDto = new PostUpdateRequestDto(loginUser, 1L, + List.of("java", "java"), "testContent"); + + // when + assertThatThrownBy(() -> { + postService.update(updateRequestDto); + }).isInstanceOf(CannotAddTagException.class) + .hasFieldOrPropertyWithValue("errorCode", "P0001") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("태그 추가 에러"); + } + + @DisplayName("사용자는 댓글이 없는 게시물을 삭제한다.") + @Test + void delete_PostWithNoCommentInCaseOfLoginUser_Success() { + // given + User user = UserFactory.user(USERNAME); + User savedUser = userRepository.save(user); + LoginUser loginUser = new LoginUser(savedUser.getName(), ACCESS_TOKEN); + + Post post = Post.builder() + .content("testContent") + .githubRepoUrl("https://github.com/da-nyee") + .author(savedUser) + .build(); + + Post savedPost = postRepository.save(post); + + PostDeleteRequestDto deleteRequestDto = new PostDeleteRequestDto(loginUser, + savedPost.getId()); + + // when + postService.delete(deleteRequestDto); + + // then + assertThat(postRepository.findById(savedPost.getId())).isEmpty(); + } + + @DisplayName("사용자는 댓글이 있는 게시물을 삭제한다.") + @Test + void delete_PostWithCommentInCaseOfLoginUser_Success() { + // given + User user = UserFactory.user(USERNAME); + User savedUser = userRepository.save(user); + LoginUser loginUser = new LoginUser(savedUser.getName(), ACCESS_TOKEN); + + User kevin = UserFactory.user("kevin"); + User savedKevin = userRepository.save(kevin); + + Post post = Post.builder() + .content("testContent") + .githubRepoUrl("https://github.com/da-nyee") + .author(savedUser) + .build(); + + Post savedPost = postRepository.save(post); + + CommentRequestDto request = CommentRequestDto.builder() + .userName(savedKevin.getName()) + .content("testComment") + .postId(savedPost.getId()) + .build(); + + postService.addComment(request); + + PostDeleteRequestDto deleteRequestDto = new PostDeleteRequestDto(loginUser, + savedPost.getId()); + + // when + postService.delete(deleteRequestDto); + + // then + assertThat(postRepository.findById(savedPost.getId())).isEmpty(); + } + + @DisplayName("해당하는 사용자의 게시물이 아닌 경우 삭제할 수 없다. - 401 예외") + @Test + void delete_PostNotBelongToUser_401Exception() { + // given + User user = UserFactory.user(USERNAME); + User kevin = UserFactory.user("kevin"); + userRepository.save(user); + User savedKevin = userRepository.save(kevin); + LoginUser loginUser = new LoginUser(USERNAME, ACCESS_TOKEN); + + Post post = Post.builder() + .content("testContent") + .githubRepoUrl("https://github.com/da-nyee") + .author(savedKevin) + .build(); + + Post savedPost = postRepository.save(post); + + PostDeleteRequestDto deleteRequestDto = new PostDeleteRequestDto(loginUser, + savedPost.getId()); + + // when + assertThatThrownBy(() -> + postService.delete(deleteRequestDto) + ).isInstanceOf(PostNotBelongToUserException.class) + .hasFieldOrPropertyWithValue("errorCode", "P0005") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.UNAUTHORIZED) + .hasMessage("해당하는 사용자의 게시물이 아닙니다."); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/application/TagServiceIntegrationTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/tag/TagServiceIntegrationTest.java similarity index 56% rename from backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/application/TagServiceIntegrationTest.java rename to backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/tag/TagServiceIntegrationTest.java index 05fc86206..4a4300f8c 100644 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/application/TagServiceIntegrationTest.java +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/tag/TagServiceIntegrationTest.java @@ -1,24 +1,30 @@ -package com.woowacourse.pickgit.tag.application; +package com.woowacourse.pickgit.integration.tag; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.woowacourse.pickgit.common.mockapi.MockTagApiRequester; import com.woowacourse.pickgit.exception.platform.PlatformHttpErrorException; import com.woowacourse.pickgit.exception.post.TagFormatException; +import com.woowacourse.pickgit.tag.application.ExtractionRequestDto; +import com.woowacourse.pickgit.tag.application.TagService; +import com.woowacourse.pickgit.tag.application.TagsDto; import com.woowacourse.pickgit.tag.domain.PlatformTagExtractor; import com.woowacourse.pickgit.tag.domain.Tag; import com.woowacourse.pickgit.tag.domain.TagRepository; import com.woowacourse.pickgit.tag.infrastructure.GithubTagExtractor; -import com.woowacourse.pickgit.tag.infrastructure.MockTagApiRequester; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; @DataJpaTest class TagServiceIntegrationTest { @@ -28,10 +34,13 @@ class TagServiceIntegrationTest { @Autowired private TagRepository tagRepository; - private String accessToken = "oauth.access.token"; - private String userName = "jipark3"; - private String repositoryName = "doms-react"; - private ObjectMapper objectMapper = new ObjectMapper(); + @Autowired + private TestEntityManager entityManager; + + private final String accessToken = "oauth.access.token"; + private final String userName = "jipark3"; + private final String repositoryName = "doms-react"; + private final ObjectMapper objectMapper = new ObjectMapper(); @BeforeEach void setUp() { @@ -43,36 +52,54 @@ void setUp() { @DisplayName("Repository에 포함된 언어 태그를 추출한다.") @Test void extractTags_ValidRepository_ExtractionSuccess() { - ExtractionRequestDto extractionRequestDto = - new ExtractionRequestDto(accessToken, userName, repositoryName); - List tags = Arrays.asList("JavaScript", "HTML", "CSS"); - + // given + ExtractionRequestDto extractionRequestDto = ExtractionRequestDto + .builder() + .accessToken(accessToken) + .userName(userName) + .repositoryName(repositoryName) + .build(); + + // when TagsDto tagsDto = tagService.extractTags(extractionRequestDto); - assertThat(tagsDto.getTags()).containsAll(tags); + // then + assertThat(tagsDto.getTagNames()).containsAll(List.of("javascript", "html", "css")); } - @DisplayName("잘못된 경로로 태그 추출 요청시 404 예외가 발생한다.") + @DisplayName("잘못된 경로로 태그 추출 요청시 예외가 발생한다.") @Test void extractTags_InvalidUrl_ExceptionThrown() { + // given String userName = "nonuser"; String repositoryName = "nonrepo"; - ExtractionRequestDto extractionRequestDto = - new ExtractionRequestDto(accessToken, userName, repositoryName); - + ExtractionRequestDto extractionRequestDto = ExtractionRequestDto + .builder() + .accessToken(accessToken) + .userName(userName) + .repositoryName(repositoryName) + .build(); + + // when, then assertThatCode(() -> tagService.extractTags(extractionRequestDto)) .isInstanceOf(PlatformHttpErrorException.class) .extracting("errorCode") .isEqualTo("V0001"); } - @DisplayName("유효하지 않은 토큰으로 태그 추출 요청시 401 예외가 발생한다.") + @DisplayName("유효하지 않은 토큰으로 태그 추출 요청시 예외가 발생한다.") @Test void extractTags_InvalidToken_ExceptionThrown() { + // given String accessToken = "invalidtoken"; - ExtractionRequestDto extractionRequestDto = - new ExtractionRequestDto(accessToken, userName, repositoryName); - + ExtractionRequestDto extractionRequestDto = ExtractionRequestDto + .builder() + .accessToken(accessToken) + .userName(userName) + .repositoryName(repositoryName) + .build(); + + // when, then assertThatCode(() -> tagService.extractTags(extractionRequestDto)) .isInstanceOf(PlatformHttpErrorException.class) .extracting("errorCode") @@ -82,24 +109,33 @@ void extractTags_InvalidToken_ExceptionThrown() { @DisplayName("태그 이름을 태그로 변환한다.") @Test void findOrCreateTags_ValidTag_TransformationSuccess() { + // given tagRepository.save(new Tag("tag3")); - List tagNames = Arrays.asList("tag1", "tag2", "tag3"); + List tagNames = Arrays.asList("Tag1", "tag2", "tag3"); TagsDto tagsDto = new TagsDto(tagNames); + entityManager.flush(); + entityManager.clear(); + + // when List tags = tagService.findOrCreateTags(tagsDto) .stream() .map(Tag::getName) .collect(Collectors.toList()); - assertThat(tags).containsAll(tagNames); + // then + assertThat(tags).containsAll(Arrays.asList("tag1", "tag2", "tag3")); } @DisplayName("잘못된 태그 이름을 태그로 변환 시도시 실패한다.") - @Test - void findOrCreateTags_InvalidTagName_ExceptionThrown() { - List tagNames = Arrays.asList("tag1", "tag2", ""); + @ParameterizedTest + @ValueSource(strings = {"", " "}) + void findOrCreateTags_InvalidTagName_ExceptionThrown(String tagName) { + // given + List tagNames = Arrays.asList("tag1", "tag2", tagName); TagsDto tagsDto = new TagsDto(tagNames); + // when, then assertThatCode(() -> tagService.findOrCreateTags(tagsDto)) .isInstanceOf(TagFormatException.class) .extracting("errorCode") diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/user/UserServiceIntegrationTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/user/UserServiceIntegrationTest.java new file mode 100644 index 000000000..e1bcfc4b1 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/integration/user/UserServiceIntegrationTest.java @@ -0,0 +1,492 @@ +package com.woowacourse.pickgit.integration.user; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.groups.Tuple.tuple; + +import com.woowacourse.pickgit.authentication.domain.user.AppUser; +import com.woowacourse.pickgit.authentication.domain.user.GuestUser; +import com.woowacourse.pickgit.authentication.domain.user.LoginUser; +import com.woowacourse.pickgit.common.factory.FileFactory; +import com.woowacourse.pickgit.common.factory.UserFactory; +import com.woowacourse.pickgit.config.InfrastructureTestConfiguration; +import com.woowacourse.pickgit.exception.authentication.UnauthorizedException; +import com.woowacourse.pickgit.exception.user.DuplicateFollowException; +import com.woowacourse.pickgit.exception.user.InvalidFollowException; +import com.woowacourse.pickgit.exception.user.InvalidUserException; +import com.woowacourse.pickgit.exception.user.SameSourceTargetUserException; +import com.woowacourse.pickgit.user.application.UserService; +import com.woowacourse.pickgit.user.application.dto.request.AuthUserRequestDto; +import com.woowacourse.pickgit.user.application.dto.request.ProfileEditRequestDto; +import com.woowacourse.pickgit.user.application.dto.request.UserSearchRequestDto; +import com.woowacourse.pickgit.user.application.dto.response.ContributionResponseDto; +import com.woowacourse.pickgit.user.application.dto.response.FollowResponseDto; +import com.woowacourse.pickgit.user.application.dto.response.ProfileEditResponseDto; +import com.woowacourse.pickgit.user.application.dto.response.UserProfileResponseDto; +import com.woowacourse.pickgit.user.application.dto.response.UserSearchResponseDto; +import com.woowacourse.pickgit.user.domain.User; +import com.woowacourse.pickgit.user.domain.UserRepository; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.ActiveProfiles; + +@Import(InfrastructureTestConfiguration.class) +@SpringBootTest(webEnvironment = WebEnvironment.NONE) +@DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD) +@ActiveProfiles("test") +class UserServiceIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @DisplayName("비로그인 유저는 내 프로필을 조회할 수 없다.") + @Test + void getMyUserProfile_Guest_Failure() { + // given + AuthUserRequestDto requestDto = createGuestAuthUserRequestDto(); + + // when, then + assertThatCode(() -> userService.getMyUserProfile(requestDto)) + .isInstanceOf(UnauthorizedException.class); + } + + @DisplayName("로그인된 사용자는 자신의 프로필을 조회할 수 있다.") + @Test + void getMyUserProfile_WithMyName_Success() { + //given + User loginUser = userRepository.save(UserFactory.user()); + AuthUserRequestDto requestDto = createLoginAuthUserRequestDto(loginUser.getName()); + UserProfileResponseDto responseDto = UserFactory.mockLoginUserProfileResponseDto(); + + //when + UserProfileResponseDto myUserProfile = userService.getMyUserProfile(requestDto); + + //then + assertThat(myUserProfile) + .usingRecursiveComparison() + .isEqualTo(responseDto); + } + + @DisplayName("게스트 유저는 유저 이름으로 검색하여 다른 유저의 프로필을 조회할 수 있다.") + @Test + void getUserProfile_FindByNameInCaseOfGuestUser_Success() { + //given + AuthUserRequestDto authUserRequestDto = createGuestAuthUserRequestDto(); + UserProfileResponseDto responseDto = UserFactory.mockGuestUserProfileResponseDto(); + User targetUser = userRepository.save(UserFactory.user()); + + //when + UserProfileResponseDto userProfile = + userService.getUserProfile(authUserRequestDto, targetUser.getName()); + + //then + assertThat(userProfile) + .usingRecursiveComparison() + .isEqualTo(responseDto); + } + + @DisplayName("게스트 유저는 존재하지 않는 유저 이름으로 프로필을 조회할 수 없다. - 400 예외") + @Test + void getUserProfile_FindByInvalidNameInCaseOfGuestUser_400Exception() { + // given + AuthUserRequestDto authUserRequestDto = createGuestAuthUserRequestDto(); + + // when + assertThatThrownBy(() -> + userService.getUserProfile(authUserRequestDto, "invalidName") + ).isInstanceOf(InvalidUserException.class) + .hasFieldOrPropertyWithValue("errorCode", "U0001") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("유효하지 않은 유저입니다."); + } + + @DisplayName("로그인 유저는 팔로잉한 유저 이름을 검색하여 프로필을 조회할 수 있다.") + @Test + void getUserProfile_FindByNameInCaseOfLoginUserIsFollowing_Success() { + // given + User loginUser = userRepository.save(UserFactory.user("testUser")); + User target = userRepository.save(UserFactory.user("testUser2")); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUser.getName()); + + userService.followUser(authUserRequestDto, target.getName()); + + UserProfileResponseDto responseDto = + UserFactory.mockLoginUserProfileIsFollowingResponseDto(); + + // when + UserProfileResponseDto userProfile = + userService.getUserProfile(authUserRequestDto, target.getName()); + + // then + assertThat(userProfile) + .usingRecursiveComparison() + .isEqualTo(responseDto); + } + + @DisplayName("로그인 유저는 팔로잉하지 않은 유저 이름을 검색하여 프로필을 조회할 수 있다.") + @Test + void getUserProfile_FindByNameInCaseOfLoginUserIsNotFollowing_Success() { + // given + User loginUser = userRepository.save(UserFactory.user("testUser")); + User target = userRepository.save(UserFactory.user("testUser2")); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUser.getName()); + + UserProfileResponseDto responseDto = + UserFactory.mockLoginUserProfileIsNotFollowingResponseDto(); + + // when + UserProfileResponseDto userProfile = + userService.getUserProfile(authUserRequestDto, target.getName()); + + // then + assertThat(userProfile) + .usingRecursiveComparison() + .isEqualTo(responseDto); + } + + @DisplayName("로그인 유저는 존재하지 않는 유저 이름으로 프로필을 조회할 수 없다. - 400 예외") + @Test + void getUserProfile_FindByInvalidNameInCaseOfLoginUser_400Exception() { + // given + User loginUser = userRepository.save(UserFactory.user("testUser")); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUser.getName()); + + // when + assertThatThrownBy(() -> + userService.getUserProfile(authUserRequestDto, "invalidName")) + .isInstanceOf(InvalidUserException.class) + .hasFieldOrPropertyWithValue("errorCode", "U0001") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("유효하지 않은 유저입니다."); + } + + @DisplayName("비로그인 유저는 팔로우할 수 없다.") + void follow_Guest_Failure() { + // given + AuthUserRequestDto requestDto = createGuestAuthUserRequestDto(); + + // when, then + assertThatCode(() -> userService.followUser(requestDto, "testUser")) + .isInstanceOf(UnauthorizedException.class); + } + + @DisplayName("로그인 유저는 존재하지 않는 유저에 대해 팔로우할 수 없다. - 400 예") + @Test + void follow_FindByInvalidName_400Exception() { + // given + User loginUser = userRepository.save(UserFactory.user("testUser")); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUser.getName()); + + // when, then + assertThatCode(() -> userService.followUser(authUserRequestDto, "kevin")) + .isInstanceOf(InvalidUserException.class) + .hasFieldOrPropertyWithValue("errorCode", "U0001") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("유효하지 않은 유저입니다."); + } + + @DisplayName("로그인 유저는 자기 자신을 팔로우할 수 없다. - 400 예외") + @Test + void follow_SameUser_400Exception() { + //given + User loginUser = userRepository.save(UserFactory.user("testUser")); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUser.getName()); + + // when, then + assertThatCode( + () -> userService.followUser(authUserRequestDto, loginUser.getName())) + .isInstanceOf(SameSourceTargetUserException.class) + .hasFieldOrPropertyWithValue("errorCode", "U0004") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("같은 Source 와 Target 유저입니다."); + } + + @DisplayName("로그인 유저는 팔로잉하지 않는 Target 유저를 팔로우할 수 있다.") + @Test + void followUser_SourceToTarget_Success() { + // given + User loginUser = userRepository.save(UserFactory.user("testUser")); + User target = userRepository.save(UserFactory.user("testUser2")); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUser.getName()); + + // when + FollowResponseDto responseDto = userService + .followUser(authUserRequestDto, target.getName()); + + // then + assertThat(responseDto.getFollowerCount()).isOne(); + assertThat(responseDto.isFollowing()).isTrue(); + } + + @DisplayName("로그인 유저는 이미 팔로우 중인 Target 유저를 팔로우할 수 없다. - 400 예외") + @Test + void followUser_ExistingFollow_400Exception() { + // given + User loginUser = userRepository.save(UserFactory.user("testUser")); + User target = userRepository.save(UserFactory.user("testUser2")); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUser.getName()); + + userService.followUser(authUserRequestDto, target.getName()); + + // when + assertThatThrownBy(() -> + userService.followUser(authUserRequestDto, target.getName())) + .isInstanceOf(DuplicateFollowException.class) + .hasFieldOrPropertyWithValue("errorCode", "U0002") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("이미 팔로우 중 입니다."); + } + + @DisplayName("비로그인 유저는 언팔로우할 수 없다.") + @Test + void unfollow_Guest_Failure() { + // given + AuthUserRequestDto requestDto = createGuestAuthUserRequestDto(); + + // when, then + assertThatCode(() -> userService.unfollowUser(requestDto, "testUser")) + .isInstanceOf(UnauthorizedException.class); + } + + @DisplayName("로그인 유저는 존재하지 않는 유저에 대해 언팔로우할 수 없다. - 400 예외") + @Test + void unfollow_FindByInvalidName_400Exception() { + //given + User loginUser = userRepository.save(UserFactory.user("testUser")); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUser.getName()); + + // when, then + assertThatCode(() -> userService.followUser(authUserRequestDto, "kevin")) + .isInstanceOf(InvalidUserException.class) + .hasFieldOrPropertyWithValue("errorCode", "U0001") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("유효하지 않은 유저입니다."); + } + + @DisplayName("로그인 유저는 자기 자신을 언팔로우할 수 없다. - 400 예외") + @Test + void unfollow_SameUser_400Exception() { + //given + User loginUser = userRepository.save(UserFactory.user("testUser")); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUser.getName()); + + // when, then + assertThatCode( + () -> userService.unfollowUser(authUserRequestDto, loginUser.getName())) + .isInstanceOf(SameSourceTargetUserException.class) + .hasFieldOrPropertyWithValue("errorCode", "U0004") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("같은 Source 와 Target 유저입니다."); + } + + @DisplayName("로그인 유저는 언팔로우중인 Target 유저를 언팔로우할 수 없다. - 400 예외") + @Test + void unfollowUser_NotExistingFollow_400Exception() { + // given + User loginUser = userRepository.save(UserFactory.user("testUser")); + User target = userRepository.save(UserFactory.user("testUser2")); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUser.getName()); + + // when, then + assertThatThrownBy( + () -> userService.unfollowUser(authUserRequestDto, target.getName())) + .isInstanceOf(InvalidFollowException.class) + .hasFieldOrPropertyWithValue("errorCode", "U0003") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("존재하지 않는 팔로우 입니다."); + } + + @DisplayName("로그인 유저는 팔로우 중인 Target 유저를 언팔로우 할 수 있다.") + @Test + void unfollowUser_SourceToTarget_Success() { + // given + User loginUser = userRepository.save(UserFactory.user("testUser")); + User target = userRepository.save(UserFactory.user("testUser2")); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUser.getName()); + + userService.followUser(authUserRequestDto, target.getName()); + + // when + FollowResponseDto responseDto = userService + .unfollowUser(authUserRequestDto, target.getName()); + + // then + assertThat(responseDto.getFollowerCount()).isZero(); + assertThat(responseDto.isFollowing()).isFalse(); + } + + @DisplayName("누구든지 활동 통계를 조회할 수 있다.") + @Test + void getContributions_Anyone_Success() { + // given + userRepository.save(UserFactory.user()); + + ContributionResponseDto contributions = UserFactory.mockContributionResponseDto(); + + // when + ContributionResponseDto responseDto = userService.calculateContributions("testUser"); + + // then + assertThat(responseDto) + .usingRecursiveComparison() + .isEqualTo(contributions); + } + + @DisplayName("존재하지 않은 유저 이름으로 활동 통계를 조회할 수 없다. - 400 예외") + @Test + void getContributions_InvalidUsername_400Exception() { + // when + assertThatThrownBy(() -> + userService.calculateContributions("invalidName")) + .isInstanceOf(InvalidUserException.class) + .hasFieldOrPropertyWithValue("errorCode", "U0001") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("유효하지 않은 유저입니다."); + } + + @DisplayName("자신의 프로필(이미지, 한 줄 소개 포함)을 수정할 수 있다.") + @Test + void editUserProfile_WithImageAndDescription_Success() { + // given + String updatedDescription = "updated description"; + User user = UserFactory.user("testUser"); + AuthUserRequestDto authUserRequestDto = createLoginAuthUserRequestDto("testUser"); + + userRepository.save(user); + + // when + ProfileEditRequestDto profileEditRequestDto = ProfileEditRequestDto + .builder() + .image(FileFactory.getTestImage1()) + .decription(updatedDescription) + .build(); + ProfileEditResponseDto responseDto = + userService.editProfile(authUserRequestDto, profileEditRequestDto); + + // then + assertThat(responseDto.getImageUrl()).isNotBlank(); + assertThat(responseDto.getDescription()).isEqualTo(updatedDescription); + } + + @DisplayName("자신의 프로필(한 줄 소개만 포함)을 수정할 수 있다.") + @Test + void editUserProfile_WithDescription_Success() { + // given + String updatedDescription = "updated description"; + User user = UserFactory.user("testUser"); + AuthUserRequestDto authUserRequestDto = createLoginAuthUserRequestDto("testUser"); + + userRepository.save(user); + + // when + ProfileEditRequestDto profileEditRequestDto = ProfileEditRequestDto + .builder() + .image(FileFactory.getEmptyTestFile()) + .decription(updatedDescription) + .build(); + ProfileEditResponseDto responseDto = + userService.editProfile(authUserRequestDto, profileEditRequestDto); + + // then + assertThat(responseDto.getImageUrl()).isEqualTo(user.getImage()); + assertThat(responseDto.getDescription()).isEqualTo(updatedDescription); + } + + @DisplayName("로그인 - 저장된 유저중 유사한 이름을 가진 유저를 검색한다. 단, 자기 자신은 검색되지 않는다. (팔로잉한 여부 boolean)") + @Test + void searchUser_LoginUser_Success() { + // given + String searchKeyword = "bing"; + UserSearchRequestDto userSearchRequestDto = UserSearchRequestDto + .builder() + .keyword(searchKeyword) + .page(0L) + .limit(5L) + .build(); + List usersInDb = UserFactory.mockSearchUsers(); + User loginUser = usersInDb.get(0); + List searchedUsers = usersInDb.subList(1, usersInDb.size()); + AuthUserRequestDto authUserRequestDto = createLoginAuthUserRequestDto(loginUser.getName()); + + userRepository.save(loginUser); + searchedUsers.forEach(user -> userRepository.save(user)); + + // when + userService.followUser(authUserRequestDto, searchedUsers.get(0).getName()); + + List searchResult = + userService.searchUser(authUserRequestDto, userSearchRequestDto); + + // then + assertThat(searchResult).hasSize(4); + assertThat(searchResult) + .extracting("username", "imageUrl", "following") + .containsExactly( + tuple(searchedUsers.get(0).getName(), searchedUsers.get(0).getImage(), true), + tuple(searchedUsers.get(1).getName(), searchedUsers.get(1).getImage(), false), + tuple(searchedUsers.get(2).getName(), searchedUsers.get(2).getImage(), false), + tuple(searchedUsers.get(3).getName(), searchedUsers.get(3).getImage(), false) + ); + } + + @DisplayName("비 로그인 - 저장된 유저중 유사한 이름을 가진 유저를 검색한다. (팔로잉 필드 null)") + @Test + void searchUser_GuestUser_Success() { + // given + String searchKeyword = "bing"; + UserSearchRequestDto userSearchRequestDto = UserSearchRequestDto + .builder() + .keyword(searchKeyword) + .page(0L) + .limit(3L) + .build(); + AuthUserRequestDto authUserRequestDto = createGuestAuthUserRequestDto(); + List userInDb = UserFactory.mockSearchUsers(); + userRepository.saveAll(userInDb); + + // when + List searchResult = + userService.searchUser(authUserRequestDto, userSearchRequestDto); + + // then + assertThat(searchResult) + .extracting("username", "imageUrl", "following") + .containsExactly( + tuple(userInDb.get(0).getName(), userInDb.get(0).getImage(), null), + tuple(userInDb.get(1).getName(), userInDb.get(1).getImage(), null), + tuple(userInDb.get(2).getName(), userInDb.get(2).getImage(), null) + ); + } + + private AuthUserRequestDto createLoginAuthUserRequestDto(String username) { + AppUser appUser = new LoginUser(username, "Bearer testToken"); + return AuthUserRequestDto.from(appUser); + } + + private AuthUserRequestDto createGuestAuthUserRequestDto() { + return AuthUserRequestDto.from(new GuestUser()); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/PostAcceptanceTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/PostAcceptanceTest.java deleted file mode 100644 index 62f095af7..000000000 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/PostAcceptanceTest.java +++ /dev/null @@ -1,443 +0,0 @@ -package com.woowacourse.pickgit.post; - -import static io.restassured.RestAssured.given; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; - -import com.woowacourse.pickgit.authentication.application.dto.OAuthProfileResponse; -import com.woowacourse.pickgit.authentication.domain.OAuthClient; -import com.woowacourse.pickgit.authentication.presentation.dto.OAuthTokenResponse; -import com.woowacourse.pickgit.common.FileFactory; -import com.woowacourse.pickgit.exception.dto.ApiErrorResponse; -import com.woowacourse.pickgit.post.application.dto.CommentResponse; -import com.woowacourse.pickgit.post.application.dto.response.PostResponseDto; -import com.woowacourse.pickgit.post.domain.dto.RepositoryResponseDto; -import com.woowacourse.pickgit.post.presentation.dto.request.ContentRequest; -import io.restassured.RestAssured; -import io.restassured.common.mapper.TypeRef; -import io.restassured.response.ExtractableResponse; -import io.restassured.response.Response; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.boot.web.server.LocalServerPort; -import org.springframework.context.annotation.Import; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.test.annotation.DirtiesContext; -import org.springframework.test.annotation.DirtiesContext.ClassMode; -import org.springframework.test.context.ActiveProfiles; - -@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) -@Import(PostTestConfiguration.class) -@DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD) -@ActiveProfiles("test") -public class PostAcceptanceTest { - - private static final String ANOTHER_USERNAME = "pick-git-login"; - private static final String USERNAME = "jipark3"; - - @LocalServerPort - int port; - - @MockBean - private OAuthClient oAuthClient; - - private String githubRepoUrl; - private List tags; - private String content; - - private Map request; - - @BeforeEach - void setUp() { - RestAssured.port = port; - - githubRepoUrl = "https://github.com/woowacourse-teams/2021-pick-git"; - tags = List.of("java", "spring"); - content = "this is content"; - - Map body = new HashMap<>(); - body.put("githubRepoUrl", githubRepoUrl); - body.put("tags", tags); - body.put("content", content); - request = body; - } - - @DisplayName("사용자는 게시글을 등록한다.") - @Test - void write_LoginUser_Success() { - // given - String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); - - // when - requestWrite(token); - } - - @DisplayName("로그인일때 게시물을 조회한다. - 댓글 및 게시글의 좋아요 여부를 확인할 수 있다.") - @Test - void read_LoginUser_Success() { - String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); - - requestToWritePostApi(token, HttpStatus.CREATED); - requestToWritePostApi(token, HttpStatus.CREATED); - requestToWritePostApi(token, HttpStatus.CREATED); - - List response = given().log().all() - .auth().oauth2(token) - .when() - .get("/api/posts?page=0&limit=3") - .then() - .statusCode(HttpStatus.OK.value()) - .extract() - .as(new TypeRef>() { - }); - - assertThat(response).hasSize(3); - } - - @DisplayName("비 로그인이어도 게시글 조회가 가능하다. - 댓글 및 게시물 좋아요 여부는 항상 false") - @Test - void read_GuestUser_Success() { - String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); - - requestToWritePostApi(token, HttpStatus.CREATED); - requestToWritePostApi(token, HttpStatus.CREATED); - requestToWritePostApi(token, HttpStatus.CREATED); - - List response = given().log().all() - .when() - .get("/api/posts?page=0&limit=3") - .then() - .statusCode(HttpStatus.OK.value()) - .extract() - .as(new TypeRef>() { - }); - - assertThat(response).hasSize(3); - } - - @DisplayName("로그인 상태에서 내 피드 조회가 가능하다.") - @Test - void readMyFeed_LoginUser_Success() { - String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); - - requestToWritePostApi(token, HttpStatus.CREATED); - requestToWritePostApi(token, HttpStatus.CREATED); - requestToWritePostApi(token, HttpStatus.CREATED); - - List response = given().log().all() - .auth().oauth2(token) - .when() - .get("/api/posts/me?page=0&limit=3") - .then() - .statusCode(HttpStatus.OK.value()) - .extract() - .as(new TypeRef>() { - }); - - assertThat(response).hasSize(3); - } - - @DisplayName("비로그인 상태에서는 내 피드 조회가 불가능하다.") - @Test - void readMyFeed_GuestUser_Success() { - String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); - - requestToWritePostApi(token, HttpStatus.CREATED); - requestToWritePostApi(token, HttpStatus.CREATED); - requestToWritePostApi(token, HttpStatus.CREATED); - - given().log().all() - .when() - .get("/api/posts/me?page=0&limit=3") - .then() - .statusCode(HttpStatus.UNAUTHORIZED.value()); - } - - @DisplayName("로그인 상태에서 다른 유저 피드 조회가 가능하다.") - @Test - void readUserFeed_LoginUser_Success() { - String loginUserToken = 로그인_되어있음(USERNAME).getToken(); - String targetUserToken = 로그인_되어있음(ANOTHER_USERNAME).getToken(); - - requestToWritePostApi(targetUserToken, HttpStatus.CREATED); - requestToWritePostApi(targetUserToken, HttpStatus.CREATED); - requestToWritePostApi(targetUserToken, HttpStatus.CREATED); - requestToWritePostApi(loginUserToken, HttpStatus.CREATED); - requestToWritePostApi(loginUserToken, HttpStatus.CREATED); - - List response = given().log().all() - .auth().oauth2(loginUserToken) - .when() - .get("/api/posts/" + ANOTHER_USERNAME + "?page=0&limit=3") - .then() - .statusCode(HttpStatus.OK.value()) - .extract() - .as(new TypeRef>() { - }); - - assertThat(response).hasSize(3); - } - - @DisplayName("로그인 상태에서 다른 유저 피드 조회가 가능하다.") - @Test - void readUserFeed_GuestUser_Success() { - String targetUserToken = 로그인_되어있음(ANOTHER_USERNAME).getToken(); - - requestToWritePostApi(targetUserToken, HttpStatus.CREATED); - requestToWritePostApi(targetUserToken, HttpStatus.CREATED); - requestToWritePostApi(targetUserToken, HttpStatus.CREATED); - - List response = given().log().all() - .when() - .get("/api/posts/" + ANOTHER_USERNAME + "?page=0&limit=3") - .then() - .statusCode(HttpStatus.OK.value()) - .extract() - .as(new TypeRef>() { - }); - - assertThat(response).hasSize(3); - } - - @DisplayName("게스트는 게시글을 등록할 수 없다. - 유효하지 않은 토큰이 있는 경우 (Authorization header O)") - @Test - void write_GuestUserWithToken_Fail() { - // given - String token = "Bearer guest"; - - // when - requestToWritePostApi(token, HttpStatus.UNAUTHORIZED); - } - - @DisplayName("게스트는 게시글을 등록할 수 없다. - 토큰이 없는 경우 (Authorization header X)") - @Test - void write_GuestUserWithoutToken_Fail() { - // when - given().log().all() - .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) - .formParams(request) - .multiPart("images", FileFactory.getTestImage1File()) - .multiPart("images", FileFactory.getTestImage2File()) - .when() - .post("/api/posts") - .then().log().all() - .statusCode(HttpStatus.UNAUTHORIZED.value()) - .extract(); - } - - private ExtractableResponse requestToWritePostApi(String token, - HttpStatus httpStatus) { - return given().log().all() - .auth().oauth2(token) - .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) - .formParams(request) - .multiPart("images", FileFactory.getTestImage1File()) - .multiPart("images", FileFactory.getTestImage2File()) - .when() - .post("/api/posts") - .then().log().all() - .statusCode(httpStatus.value()) - .extract(); - } - - @DisplayName("사용자는 댓글을 등록할 수 있다.") - @Test - void addComment_LoginUser_Success() { - // given - String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); - - requestWrite(token); - - ContentRequest request = new ContentRequest("this is content"); - - // when - CommentResponse response = requestAddComment(token, request, HttpStatus.OK) - .as(CommentResponse.class); - - // then - assertThat(response.getAuthorName()).isEqualTo(ANOTHER_USERNAME); - assertThat(response.getContent()).isEqualTo("this is content"); - } - - private void requestWrite(String token) { - given().log().all() - .auth().oauth2(token) - .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) - .formParams(request) - .multiPart("images", FileFactory.getTestImage1File()) - .multiPart("images", FileFactory.getTestImage2File()) - .when() - .post("/api/posts") - .then().log().all() - .statusCode(HttpStatus.CREATED.value()) - .extract(); - } - - @DisplayName("댓글 내용이 null인 경우 예외가 발생한다. - 400 예외") - @Test - void addComment_Null_400Exception() { - // given - String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); - ContentRequest request = new ContentRequest(null); - - // when - ApiErrorResponse response = requestAddComment(token, request, HttpStatus.BAD_REQUEST) - .as(ApiErrorResponse.class); - - // then - assertThat(response.getErrorCode()).isEqualTo("F0001"); - } - - @DisplayName("댓글 내용이 빈 칸인 경우 예외가 발생한다. - 400 예외") - @Test - void addComment_Empty_400Exception() { - // given - String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); - ContentRequest request = new ContentRequest(""); - - // when - ApiErrorResponse response = requestAddComment(token, request, HttpStatus.BAD_REQUEST) - .as(ApiErrorResponse.class); - - // then - assertThat(response.getErrorCode()).isEqualTo("F0001"); - } - - @DisplayName("댓글 내용이 공백인 경우 예외가 발생한다. - 400 예외") - @Test - void addComment_Blank_400Exception() { - // given - String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); - ContentRequest request = new ContentRequest(" "); - - // when - ApiErrorResponse response = requestAddComment(token, request, HttpStatus.BAD_REQUEST) - .as(ApiErrorResponse.class); - - // then - assertThat(response.getErrorCode()).isEqualTo("F0001"); - } - - @DisplayName("댓글 내용이 100자 초과인 경우 예외가 발생한다. - 400 예외") - @Test - void addComment_Over100_400Exception() { - // given - String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); - ContentRequest request = new ContentRequest("a".repeat(101)); - - // when - ApiErrorResponse response = requestAddComment(token, request, HttpStatus.BAD_REQUEST) - .as(ApiErrorResponse.class); - - // then - assertThat(response.getErrorCode()).isEqualTo("F0002"); - } - - private ExtractableResponse requestAddComment( - String token, - ContentRequest request, - HttpStatus httpStatus) { - return given().log().all() - .auth().oauth2(token) - .contentType(MediaType.APPLICATION_JSON_VALUE) - .body(request) - .when() - .post("/api/posts/{postId}/comments", 1L) - .then().log().all() - .statusCode(httpStatus.value()) - .extract(); - } - - @DisplayName("사용자는 Repository 목록을 가져올 수 있다.") - @Test - void showRepositories_LoginUser_Success() { - // given - String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); - - // when - List response = - request(token, USERNAME, HttpStatus.OK.value()) - .as(new TypeRef>() { - }); - - // then - assertThat(response).hasSize(2); - } - - @DisplayName("토큰이 유효하지 않은 경우 예외가 발생한다. - 500 예외") - @Test - void showRepositories_InvalidAccessToken_500Exception() { - // given - String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); - - // when - request(token + "hi", USERNAME, HttpStatus.UNAUTHORIZED.value()); - } - - @DisplayName("사용자가 유효하지 않은 경우 예외가 발생한다. - 500 예외") - @Test - void showRepositories_InvalidUsername_400Exception() { - // given - String token = 로그인_되어있음(ANOTHER_USERNAME).getToken(); - - // when - ApiErrorResponse response = - request(token, USERNAME + "pika", HttpStatus.INTERNAL_SERVER_ERROR.value()) - .as(ApiErrorResponse.class); - - // then - assertThat(response.getErrorCode()).isEqualTo("V0001"); - } - - private ExtractableResponse request(String token, String username, int statusCode) { - return given().log().all() - .auth().oauth2(token) - .when() - .get("/api/github/{username}/repositories", username) - .then().log().all() - .statusCode(statusCode) - .extract(); - } - - private OAuthTokenResponse 로그인_되어있음(String name) { - OAuthTokenResponse response = 로그인_요청(name) - .as(OAuthTokenResponse.class); - - assertThat(response.getToken()).isNotBlank(); - - return response; - } - - private ExtractableResponse 로그인_요청(String name) { - // given - String oauthCode = "1234"; - String accessToken = "oauth.access.token"; - - OAuthProfileResponse oAuthProfileResponse = new OAuthProfileResponse( - name, "image", "hi~", "github.com/", - null, null, null, null - ); - - given(oAuthClient.getAccessToken(oauthCode)) - .willReturn(accessToken); - given(oAuthClient.getGithubProfile(accessToken)) - .willReturn(oAuthProfileResponse); - - // when - return given().log().all() - .accept(MediaType.APPLICATION_JSON_VALUE) - .when() - .get("/api/afterlogin?code=" + oauthCode) - .then().log().all() - .statusCode(HttpStatus.OK.value()) - .extract(); - } -} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/PostTestConfiguration.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/PostTestConfiguration.java deleted file mode 100644 index 99e3e3bb2..000000000 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/PostTestConfiguration.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.woowacourse.pickgit.post; - -import com.woowacourse.pickgit.post.infrastructure.MockRepositoryApiRequester; -import com.woowacourse.pickgit.post.infrastructure.PlatformRepositoryApiRequester; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; - -@TestConfiguration -public class PostTestConfiguration { - - @Bean - public PlatformRepositoryApiRequester platformRepositoryApiRequester() { - return new MockRepositoryApiRequester(); - } -} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/application/PostFactory.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/application/PostFactory.java deleted file mode 100644 index 8916a23d4..000000000 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/application/PostFactory.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.woowacourse.pickgit.post.application; - -import com.woowacourse.pickgit.common.FileFactory; -import com.woowacourse.pickgit.post.application.dto.CommentResponse; -import com.woowacourse.pickgit.post.application.dto.request.PostRequestDto; -import com.woowacourse.pickgit.post.application.dto.response.PostResponseDto; -import com.woowacourse.pickgit.user.domain.User; -import com.woowacourse.pickgit.user.domain.profile.BasicProfile; -import com.woowacourse.pickgit.user.domain.profile.GithubProfile; -import java.time.LocalDateTime; -import java.util.List; -import org.springframework.web.multipart.MultipartFile; - -public class PostFactory { - - private PostFactory() { - } - - public static List mockPostRequestDtos() { - List images = - List.of(FileFactory.getTestImage1(), FileFactory.getTestImage2()); - - return List.of( - new PostRequestDto("a", "sean", images, - "atdd-subway-fare", List.of("Java", "Python", "C++"), - "woowacourse mission"), - new PostRequestDto("a", "ginger", images, - "jwp-chess", List.of("Javascirpt", "C", "HTML"), - "it's so easy!"), - new PostRequestDto("a", "dani", images, - "java-racingcar", List.of("Go", "Objective-C"), - "I love TDD"), - new PostRequestDto("a", "coda", images, - "junit-test", List.of("Java", "CSS", "HTML"), - "hi there!"), - new PostRequestDto("a", "dave", images, - "jpa-learning-teest", List.of("Java", "CSS", "HTML"), - "jpa is so fun!") - ); - } - - public static List mockPostRequestForAssertingMyFeed() { - List images = - List.of(FileFactory.getTestImage1(), FileFactory.getTestImage2()); - - return List.of( - new PostRequestDto("a", "kevin", images, - "atdd-subway-fare", List.of("Java", "Python", "C++"), - "woowacourse mission"), - new PostRequestDto("a", "kevin", images, - "jwp-chess", List.of("Javascirpt", "C", "HTML"), - "it's so easy!"), - new PostRequestDto("a", "kevin", images, - "java-racingcar", List.of("Go", "Objective-C"), - "I love TDD"), - new PostRequestDto("a", "ala", images, - "junit-test", List.of("Java", "CSS", "HTML"), - "hi there!"), - new PostRequestDto("a", "dave", images, - "jpa-learning-teest", List.of("Java", "CSS", "HTML"), - "jpa is so fun!") - ); - } - - public static List mockUsers() { - return List.of( - new User(new BasicProfile("sean", "a.jpg", "a"), - new GithubProfile("github1.com", "a", "a", "a", "a")), - new User(new BasicProfile("ginger", "a.jpg", "a"), - new GithubProfile("github2.com", "a", "a", "a", "a")), - new User(new BasicProfile("dani", "a.jpg", "a"), - new GithubProfile("dani.com", "a", "a", "a", "a")), - new User(new BasicProfile("coda", "a.jpg", "a"), - new GithubProfile("coda.com", "a", "a", "a", "a")), - new User(new BasicProfile("dave", "a.jpg", "a"), - new GithubProfile("dave.com", "a", "a", "a", "a")) - ); - } - - public static List mockUsers2() { - return List.of( - new User(new BasicProfile("ala", "a.jpg", "a"), - new GithubProfile("github1.com", "a", "a", "a", "a")), - new User(new BasicProfile("dave", "a.jpg", "a"), - new GithubProfile("github2.com", "a", "a", "a", "a")) - ); - } - - public static List mockPostResponseDtos() { - return List.of( - new PostResponseDto(1L, List.of("iamge1Url", "image2Url"), "githubRepoUrl", "content", - "authorName", "profileImageUrl", 1, List.of("tag1", "tag2"), LocalDateTime.now(), - LocalDateTime.now(), - List.of(new CommentResponse(1L, "commentAuthorName", "commentContent", false)), - false) - ); - } -} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/application/PostServiceIntegrationTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/application/PostServiceIntegrationTest.java deleted file mode 100644 index 437488c73..000000000 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/application/PostServiceIntegrationTest.java +++ /dev/null @@ -1,318 +0,0 @@ -package com.woowacourse.pickgit.post.application; - -import static java.util.stream.Collectors.toList; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.woowacourse.pickgit.authentication.domain.user.GuestUser; -import com.woowacourse.pickgit.authentication.domain.user.LoginUser; -import com.woowacourse.pickgit.common.FileFactory; -import com.woowacourse.pickgit.exception.platform.PlatformHttpErrorException; -import com.woowacourse.pickgit.exception.post.CommentFormatException; -import com.woowacourse.pickgit.post.PostTestConfiguration; -import com.woowacourse.pickgit.post.application.dto.CommentResponse; -import com.woowacourse.pickgit.post.application.dto.response.PostResponseDto; -import com.woowacourse.pickgit.post.application.dto.request.PostRequestDto; -import com.woowacourse.pickgit.post.application.dto.request.RepositoryRequestDto; -import com.woowacourse.pickgit.post.application.dto.response.PostImageUrlResponseDto; -import com.woowacourse.pickgit.post.application.dto.response.RepositoriesResponseDto; -import com.woowacourse.pickgit.post.domain.Post; -import com.woowacourse.pickgit.post.domain.PostRepository; -import com.woowacourse.pickgit.post.domain.comment.Comments; -import com.woowacourse.pickgit.post.presentation.dto.request.CommentRequest; -import com.woowacourse.pickgit.post.presentation.dto.request.HomeFeedRequest; -import com.woowacourse.pickgit.user.domain.User; -import com.woowacourse.pickgit.user.domain.UserRepository; -import com.woowacourse.pickgit.user.domain.profile.BasicProfile; -import com.woowacourse.pickgit.user.domain.profile.GithubProfile; -import java.util.ArrayList; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; -import org.springframework.context.annotation.Import; -import org.springframework.test.annotation.DirtiesContext; -import org.springframework.test.annotation.DirtiesContext.ClassMode; -import org.springframework.test.context.ActiveProfiles; - -@Import(PostTestConfiguration.class) -@SpringBootTest(webEnvironment = WebEnvironment.NONE) -@ActiveProfiles("test") -@DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD) -class PostServiceIntegrationTest { - - private static final String USERNAME = "jipark3"; - private static final String ACCESS_TOKEN = "oauth.access.token"; - - @Autowired - private PostService postService; - - @Autowired - private PostRepository postRepository; - - @Autowired - private UserRepository userRepository; - - private String image; - private String description; - private String githubUrl; - private String company; - private String location; - private String website; - private String twitter; - private String githubRepoUrl; - private List tags; - private String content; - - private BasicProfile basicProfile; - private GithubProfile githubProfile; - private User user1; - private User user2; - private Post post; - - @BeforeEach - void setUp() { - image = "image1"; - description = "hello"; - githubUrl = "https://github.com/da-nyee"; - company = "woowacourse"; - location = "seoul"; - website = "https://da-nyee.github.io/"; - twitter = "dani"; - githubRepoUrl = "https://github.com/woowacourse-teams/2021-pick-git/"; - tags = List.of("java", "spring"); - content = "this is content"; - - basicProfile = new BasicProfile(USERNAME, image, description); - githubProfile = new GithubProfile(githubUrl, company, location, website, twitter); - user1 = new User(basicProfile, githubProfile); - user2 = new User(new BasicProfile("kevin", "a.jpg", "a"), - new GithubProfile("github.com", "a", "a", "a", "a")); - post = new Post(null, null, null, null, - null, new Comments(), new ArrayList<>(), null); - - userRepository.save(user1); - userRepository.save(user2); - postRepository.save(post); - } - - @DisplayName("게시물에 댓글을 정상 등록한다.") - @Test - void addComment_ValidContent_Success() { - post = new Post(null, null, null, null, null, new Comments(), new ArrayList<>(), null); - postRepository.save(post); - - CommentRequest commentRequest = - new CommentRequest("kevin", "test comment", post.getId()); - - CommentResponse commentResponseDto = postService.addComment(commentRequest); - - assertThat(commentResponseDto.getAuthorName()).isEqualTo("kevin"); - assertThat(commentResponseDto.getContent()).isEqualTo("test comment"); - } - - @DisplayName("게시물에 빈 댓글은 등록할 수 없다.") - @Test - void addComment_InvalidContent_ExceptionThrown() { - post = new Post(null, null, null, null, null, new Comments(), new ArrayList<>(), null); - postRepository.save(post); - - CommentRequest commentRequest = - new CommentRequest("kevin", "", post.getId()); - - assertThatCode(() -> postService.addComment(commentRequest)) - .isInstanceOf(CommentFormatException.class) - .extracting("errorCode") - .isEqualTo("F0002"); - } - - @DisplayName("사용자는 게시물을 등록할 수 있다.") - @Test - void write_LoginUser_Success() { - // given - PostRequestDto requestDto = - new PostRequestDto(ACCESS_TOKEN, USERNAME, - List.of( - FileFactory.getTestImage1(), - FileFactory.getTestImage2() - ), githubRepoUrl, tags, content); - - // when - PostImageUrlResponseDto responseDto = postService.write(requestDto); - - // then - assertThat(responseDto.getId()).isNotNull(); - } - - @DisplayName("사용자는 Repository 목록을 가져올 수 있다.") - @Test - void showRepositories_LoginUser_Success() { - // given - RepositoryRequestDto requestDto = new RepositoryRequestDto(ACCESS_TOKEN, USERNAME); - - // when - RepositoriesResponseDto responseDto = postService.showRepositories(requestDto); - - // then - assertThat(responseDto.getRepositories()).hasSize(2); - } - - @DisplayName("토큰이 유효하지 않은 경우 예외가 발생한다. - 500 예외") - @Test - void showRepositories_InvalidAccessToken_401Exception() { - // given - RepositoryRequestDto requestDto = - new RepositoryRequestDto(ACCESS_TOKEN + "hi", USERNAME); - - // then - assertThatThrownBy(() -> { - postService.showRepositories(requestDto); - }).isInstanceOf(PlatformHttpErrorException.class) - .extracting("errorCode") - .isEqualTo("V0001"); - } - - @DisplayName("사용자가 유효하지 않은 경우 예외가 발생한다. - 500 예외") - @Test - void showRepositories_InvalidUsername_404Exception() { - // given - RepositoryRequestDto requestDto = - new RepositoryRequestDto(ACCESS_TOKEN, USERNAME + "hi"); - - // then - assertThatThrownBy(() -> { - postService.showRepositories(requestDto); - }).isInstanceOf(PlatformHttpErrorException.class) - .extracting("errorCode") - .isEqualTo("V0001"); - } - - @DisplayName("저장된 게시물 중 3, 4번째 글을 최신날짜순으로 가져온다.") - @Test - void readHomeFeed_Success() { - createMockPosts(); - - HomeFeedRequest homeFeedRequest = - new HomeFeedRequest(new LoginUser("kevin", "a"), 1L, 2L); - List postResponseDtos = postService.readHomeFeed(homeFeedRequest); - - List postNames = postResponseDtos.stream() - .map(PostResponseDto::getAuthorName) - .collect(toList()); - - List repoNames = postResponseDtos.stream() - .map(PostResponseDto::getGithubRepoUrl) - .collect(toList()); - - assertThat(postResponseDtos).hasSize(2); - assertThat(postNames).containsExactly("dani", "ginger"); - assertThat(repoNames).containsExactly("java-racingcar", "jwp-chess"); - } - - private void createMockPosts() { - List postRequestDtos = PostFactory.mockPostRequestDtos(); - List users = PostFactory.mockUsers(); - for (int i = 0; i < postRequestDtos.size(); i++) { - userRepository.save(users.get(i)); - PostImageUrlResponseDto response = postService.write(postRequestDtos.get(i)); - CommentRequest commentRequest = - new CommentRequest(users.get(i).getName(), "test comment" + i, response.getId()); - postService.addComment(commentRequest); - } - } - - @DisplayName("내 피드 게시물들만 조회한다.") - @Test - void readMyFeed_Success() { - //given - List postRequestDtos = PostFactory.mockPostRequestForAssertingMyFeed(); - - List users = PostFactory.mockUsers2(); - for (User user : users) { - userRepository.save(user); - } - for (PostRequestDto postRequestDto : postRequestDtos) { - postService.write(postRequestDto); - } - - //when - HomeFeedRequest homeFeedRequest = - new HomeFeedRequest(new LoginUser("kevin", "a"), 0L, 3L); - List postResponseDtos = postService.readMyFeed(homeFeedRequest); - List repoNames = postResponseDtos.stream() - .map(PostResponseDto::getGithubRepoUrl) - .collect(toList()); - - //then - assertThat(postResponseDtos).hasSize(3); - assertThat(repoNames).containsExactly("java-racingcar", "jwp-chess", "atdd-subway-fare"); - } - - @DisplayName("로그인 사용자가 다른 사용자의 피드 게시물을 조회한다.") - @Test - void readUserFeed_LoginUser_Success() { - //given - List postRequestDtos = PostFactory.mockPostRequestForAssertingMyFeed(); - List users = PostFactory.mockUsers2(); - - for (User user : users) { - userRepository.save(user); - } - - for (PostRequestDto postRequestDto : postRequestDtos) { - postService.write(postRequestDto); - } - - //when - HomeFeedRequest homeFeedRequest = - new HomeFeedRequest(new LoginUser("ala", "a"), 0L, 3L); - List postResponseDtos = postService.readUserFeed(homeFeedRequest, "kevin"); - List repoNames = postResponseDtos.stream() - .map(PostResponseDto::getGithubRepoUrl) - .collect(toList()); - List likes = postResponseDtos.stream() - .map(PostResponseDto::getIsLiked) - .collect(toList()); - - //then - assertThat(postResponseDtos).hasSize(3); - assertThat(repoNames).containsExactly("java-racingcar", "jwp-chess", "atdd-subway-fare"); - assertThat(likes).containsExactly(Boolean.FALSE, Boolean.FALSE, Boolean.FALSE); - } - - @DisplayName("비로그인 사용자가 다른 사용자의 피드 게시물을 조회한다.") - @Test - void readUserFeed_GuestUser_Success() { - //given - List postRequestDtos = PostFactory.mockPostRequestForAssertingMyFeed(); - List users = PostFactory.mockUsers2(); - - for (User user : users) { - userRepository.save(user); - } - - for (PostRequestDto postRequestDto : postRequestDtos) { - postService.write(postRequestDto); - } - - //when - HomeFeedRequest homeFeedRequest = - new HomeFeedRequest(new GuestUser(), 0L, 3L); - List postResponseDtos = postService.readUserFeed(homeFeedRequest, "kevin"); - List repoNames = postResponseDtos.stream() - .map(PostResponseDto::getGithubRepoUrl) - .collect(toList()); - List likes = postResponseDtos.stream() - .map(PostResponseDto::getIsLiked) - .collect(toList()); - - //then - assertThat(postResponseDtos).hasSize(3); - assertThat(repoNames).containsExactly("java-racingcar", "jwp-chess", "atdd-subway-fare"); - assertThat(likes).containsExactly(null, null, null); - } -} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/application/PostServiceTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/application/PostServiceTest.java deleted file mode 100644 index a18e4c899..000000000 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/application/PostServiceTest.java +++ /dev/null @@ -1,240 +0,0 @@ -package com.woowacourse.pickgit.post.application; - -import static java.util.stream.Collectors.toList; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -import com.woowacourse.pickgit.common.FileFactory; -import com.woowacourse.pickgit.post.application.dto.request.PostRequestDto; -import com.woowacourse.pickgit.post.application.dto.request.RepositoryRequestDto; -import com.woowacourse.pickgit.post.application.dto.response.PostImageUrlResponseDto; -import com.woowacourse.pickgit.post.domain.PlatformRepositoryExtractor; -import com.woowacourse.pickgit.post.application.dto.CommentResponse; -import com.woowacourse.pickgit.post.domain.Post; -import com.woowacourse.pickgit.post.domain.PostContent; -import com.woowacourse.pickgit.post.domain.PostRepository; -import com.woowacourse.pickgit.exception.post.CommentFormatException; -import com.woowacourse.pickgit.post.domain.comment.Comments; -import com.woowacourse.pickgit.post.domain.content.Image; -import com.woowacourse.pickgit.post.domain.content.Images; -import com.woowacourse.pickgit.post.domain.dto.RepositoryResponseDto; -import com.woowacourse.pickgit.post.presentation.PickGitStorage; -import com.woowacourse.pickgit.post.presentation.dto.request.CommentRequest; -import com.woowacourse.pickgit.tag.application.TagService; -import com.woowacourse.pickgit.tag.application.TagsDto; -import com.woowacourse.pickgit.tag.domain.Tag; -import com.woowacourse.pickgit.user.domain.User; -import com.woowacourse.pickgit.user.domain.UserRepository; -import com.woowacourse.pickgit.user.domain.profile.BasicProfile; -import com.woowacourse.pickgit.user.domain.profile.GithubProfile; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import javax.persistence.EntityManager; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class PostServiceTest { - - private static final String USERNAME = "dani"; - private static final String ACCESS_TOKEN = "pickgit"; - - @InjectMocks - private PostService postService; - - @Mock - private UserRepository userRepository; - - @Mock - private PostRepository postRepository; - - @Mock - private PickGitStorage pickGitStorage; - - @Mock - private PlatformRepositoryExtractor platformRepositoryExtractor; - - @Mock - private TagService tagService; - - @Mock - private EntityManager entityManager; - - private String image; - private String description; - private String githubUrl; - private String company; - private String location; - private String website; - private String twitter; - private Images images; - private String githubRepoUrl; - private List tags; - private String content; - - private BasicProfile basicProfile; - private GithubProfile githubProfile; - private User user; - - private PostContent postContent; - private Post post; - - private BasicProfile basicProfile2; - private User user2; - - private Post post2; - - @BeforeEach - void setUp() { - image = "image1"; - description = "hello"; - githubUrl = "https://github.com/da-nyee"; - company = "woowacourse"; - location = "seoul"; - website = "https://da-nyee.github.io/"; - twitter = "dani"; - images = new Images(getImages()); - githubRepoUrl = "https://github.com/woowacourse-teams/2021-pick-git/"; - tags = List.of("java", "spring"); - content = "this is content"; - - basicProfile = new BasicProfile(USERNAME, image, description); - githubProfile = new GithubProfile(githubUrl, company, location, website, twitter); - user = new User(basicProfile, githubProfile); - - postContent = new PostContent(content); - post = new Post(1L, images, postContent, githubRepoUrl, - null, null, null, user); - - basicProfile2 = new BasicProfile("kevin", "a.jpg", "a"); - user2 = new User(basicProfile2, null); - - post2 = new Post(null, null, null, null, - null, new Comments(), new ArrayList<>(), null); - } - - private List getImages() { - return List.of("image1", "imgae2").stream() - .map(Image::new) - .collect(toList()); - } - - @DisplayName("사용자는 게시물을 등록할 수 있다.") - @Test - void write_LoginUser_Success() { - // given - given(userRepository.findByBasicProfile_Name(anyString())) - .willReturn(Optional.of(user)); - given(postRepository.save(any(Post.class))) - .willReturn(post); - given(pickGitStorage.store(anyList(), anyString())) - .willReturn(List.of("imageUrl1", "imageUrl2")); - given(tagService.findOrCreateTags(any())) - .willReturn(List.of(new Tag("java"), new Tag("spring"))); - - PostRequestDto requestDto = getRequestDto(); - - // when - PostImageUrlResponseDto responseDto = postService.write(requestDto); - - // then - assertThat(responseDto.getId()).isNotNull(); - verify(userRepository, times(1)) - .findByBasicProfile_Name(requestDto.getUsername()); - verify(postRepository, times(1)) - .save(new Post(postContent, any(), githubRepoUrl, user)); - verify(pickGitStorage, times(1)) - .store(anyList(), anyString()); - verify(tagService, times(1)) - .findOrCreateTags(any(TagsDto.class)); - } - - private PostRequestDto getRequestDto() { - return new PostRequestDto( - ACCESS_TOKEN, - USERNAME, - List.of( - FileFactory.getTestImage1(), - FileFactory.getTestImage2() - ), - githubRepoUrl, - tags, - content - ); - } - - @DisplayName("게시물에 댓글을 정상 등록한다.") - @Test - void addComment_ValidContent_Success() { - given(postRepository.findById(1L)) - .willReturn(Optional.of(post2)); - given(userRepository.findByBasicProfile_Name("kevin")) - .willReturn(Optional.of(user2)); - Mockito.doNothing().when(entityManager).flush(); - - CommentRequest commentRequest = - new CommentRequest("kevin", "test comment", 1L); - - CommentResponse commentResponseDto = postService.addComment(commentRequest); - - assertThat(commentResponseDto.getAuthorName()).isEqualTo("kevin"); - assertThat(commentResponseDto.getContent()).isEqualTo("test comment"); - verify(postRepository, times(1)).findById(1L); - verify(userRepository, times(1)).findByBasicProfile_Name("kevin"); - } - - @DisplayName("게시물에 빈 댓글을 등록할 수 없다.") - @Test - void addComment_InvalidContent_ExceptionThrown() { - given(postRepository.findById(1L)) - .willReturn(Optional.of(post)); - given(userRepository.findByBasicProfile_Name("kevin")) - .willReturn(Optional.of(user)); - - CommentRequest commentRequest = - new CommentRequest("kevin", "", 1L); - - assertThatCode(() -> postService.addComment(commentRequest)) - .isInstanceOf(CommentFormatException.class) - .extracting("errorCode") - .isEqualTo("F0002"); - verify(postRepository, times(1)).findById(1L); - verify(userRepository, times(1)).findByBasicProfile_Name("kevin"); - } - - @DisplayName("사용자는 Repository 목록을 가져올 수 있다.") - @Test - void showRepositories_LoginUser_Success() { - // given - RepositoryRequestDto requestDto = new RepositoryRequestDto(ACCESS_TOKEN, USERNAME); - List repositories = List.of( - new RepositoryResponseDto("pick", "https://github.com/jipark3/pick"), - new RepositoryResponseDto("git", "https://github.com/jipark3/git") - ); - - given(platformRepositoryExtractor.extract(requestDto.getToken(), requestDto.getUsername())) - .willReturn(repositories); - - // when - List responsesDto = - platformRepositoryExtractor.extract(requestDto.getToken(), requestDto.getUsername()); - - // then - assertThat(responsesDto).containsAll(repositories); - verify(platformRepositoryExtractor, times(1)) - .extract(requestDto.getToken(), requestDto.getUsername()); - } -} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/domain/PostRepositoryTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/domain/PostRepositoryTest.java deleted file mode 100644 index dc673e52a..000000000 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/domain/PostRepositoryTest.java +++ /dev/null @@ -1,144 +0,0 @@ -package com.woowacourse.pickgit.post.domain; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.woowacourse.pickgit.TestJpaConfiguration; -import com.woowacourse.pickgit.post.domain.comment.Comment; -import com.woowacourse.pickgit.post.domain.comment.Comments; -import com.woowacourse.pickgit.post.domain.content.Image; -import com.woowacourse.pickgit.post.domain.content.Images; -import com.woowacourse.pickgit.tag.domain.Tag; -import com.woowacourse.pickgit.tag.domain.TagRepository; -import com.woowacourse.pickgit.user.domain.User; -import com.woowacourse.pickgit.user.domain.profile.BasicProfile; -import com.woowacourse.pickgit.user.domain.profile.GithubProfile; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; -import org.springframework.context.annotation.Import; - -@Import({TestJpaConfiguration.class}) -@DataJpaTest -class PostRepositoryTest { - - private static final String USERNAME = "dani"; - - @Autowired - private PostRepository postRepository; - - @Autowired - private TagRepository tagRepository; - - @Autowired - private TestEntityManager testEntityManager; - - private String image; - private String description; - private String githubUrl; - private String company; - private String location; - private String website; - private String twitter; - private String content; - private String githubRepoUrl; - - private BasicProfile basicProfile; - private GithubProfile githubProfile; - private User user; - private PostContent postContent; - - @BeforeEach - void setUp() { - image = "image1"; - description = "hello"; - githubUrl = "https://github.com/da-nyee"; - company = "woowacourse"; - location = "seoul"; - website = "https://da-nyee.github.io/"; - twitter = "dani"; - content = "this is content"; - githubRepoUrl = "https://github.come/da-nyee/myRepo"; - - basicProfile = new BasicProfile(USERNAME, image, description); - githubProfile = new GithubProfile(githubUrl, company, location, website, twitter); - user = new User(basicProfile, githubProfile); - postContent = new PostContent(content); - } - - @DisplayName("게시글을 저장한다.") - @Test - void save_SavedPost_Success() { - // given - Images images = new Images(List.of(new Image(image))); - Post post = new Post(postContent, images, githubRepoUrl, user); - - // when - Post savedPost = postRepository.save(post); - - // then - assertThat(savedPost.getId()).isNotNull(); - } - - @DisplayName("게시글을 저장하면 자동으로 생성 날짜가 주입된다.") - @Test - void save_SavedPostWithCreatedDate_Success() { - // given - Images images = new Images(List.of(new Image(image))); - Post post = new Post(postContent, images, githubRepoUrl, user); - - // when - Post savedPost = postRepository.save(post); - - assertThat(savedPost.getCreatedAt()).isNotNull(); - assertThat(savedPost.getCreatedAt()).isBefore(LocalDateTime.now()); - } - - @DisplayName("게시글을 저장할 때 태그도 함께 영속화된다.") - @Test - void save_WhenSavingPost_TagSavedTogether() { - Post post = - new Post(null, null, new PostContent(), githubRepoUrl, null, null, new ArrayList<>(), - null); - List tags = Arrays.asList(new Tag("tag1"), new Tag("tag2")); - post.addTags(tags); - postRepository.save(post); - Tag entityTag = tagRepository.save(new Tag("33")); - post.addTags(Arrays.asList(entityTag)); - - testEntityManager.flush(); - testEntityManager.clear(); - - Post findPost = postRepository.findAll().get(0); - - assertThat(findPost.getTags()).hasSize(3); - assertThat(tagRepository.findAll()).hasSize(3); - } - - @DisplayName("Post에 Comment를 추가하면 Comment가 자동 영속화된다.") - @Test - void addComment_WhenSavingPost_CommentSavedTogether() { - Post post = - new Post(null, null, new PostContent(), githubRepoUrl, null, new Comments(), new ArrayList<>(), - null); - Comment comment = new Comment("test comment") - .toPost(post); - post.addComment(comment); - - postRepository.save(post); - testEntityManager.flush(); - testEntityManager.clear(); - - Post findPost = postRepository.findById(post.getId()) - .orElseThrow(IllegalArgumentException::new); - List comments = findPost.getComments(); - - assertThat(comments).hasSize(1); - } -} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/domain/PostTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/domain/PostTest.java deleted file mode 100644 index 3222e5eb3..000000000 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/domain/PostTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.woowacourse.pickgit.post.domain; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; - -import com.woowacourse.pickgit.exception.post.CannotAddTagException; -import com.woowacourse.pickgit.tag.domain.Tag; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -class PostTest { - - @DisplayName("Tag를 정상적으로 Post에 등록한다.") - @Test - void addTags_ValidTags_RegistrationSuccess() { - Post post = - new Post(null, null, new PostContent(), null, null, null, new ArrayList<>(), null); - List tags = - Arrays.asList(new Tag("tag1"), new Tag("tag2"), new Tag("tag3")); - - post.addTags(tags); - - assertThat(post.getTags()).hasSize(3); - } - - @DisplayName("중복되는 이름의 Tag가 존재하면 Post에 추가할 수 없다.") - @Test - void addTags_DuplicatedTagName_ExceptionThrown() { - Post post = - new Post(null, null, new PostContent(), null, null, null, new ArrayList<>(), null); - List tags = - Arrays.asList(new Tag("tag1"), new Tag("tag2"), new Tag("tag3")); - post.addTags(tags); - - List duplicatedTags = Arrays.asList(new Tag("tag4"), new Tag("tag3")); - - assertThatCode(() -> post.addTags(duplicatedTags)) - .isInstanceOf(CannotAddTagException.class) - .extracting("errorCode") - .isEqualTo("P0001"); - } -} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/infrastructure/S3StorageTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/infrastructure/S3StorageTest.java deleted file mode 100644 index d1b24b928..000000000 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/infrastructure/S3StorageTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.woowacourse.pickgit.post.infrastructure; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyMap; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; - -import com.woowacourse.pickgit.common.FileFactory; -import java.util.List; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.ResponseEntity; - -@ExtendWith(MockitoExtension.class) -class S3StorageTest { - - @InjectMocks - private S3Storage s3Storage; - - @Mock - private RestClient restClient; - - @DisplayName("이미지를 보내면 이미지 주소를 반환한다.") - @Test - void store_IfImagesGivenReturnUrls_True() { - //given - List expected = List.of("testUrl1", "testUrl2"); - given(restClient.postForEntity(any(), any(), any(), (Object) any())) - .willReturn(ResponseEntity.ok( - new S3Storage.StorageDto(expected)) - ); - - List actual = s3Storage.store(List.of( - FileFactory.getTestImage1File(), - FileFactory.getTestImage2File() - ), "testUser"); - - assertThat(expected) - .usingRecursiveComparison() - .isEqualTo(actual); - } -} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/presentation/PostControllerTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/presentation/PostControllerTest.java deleted file mode 100644 index 587c657fd..000000000 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/presentation/PostControllerTest.java +++ /dev/null @@ -1,404 +0,0 @@ -package com.woowacourse.pickgit.post.presentation; - -import static com.woowacourse.pickgit.docs.ApiDocumentUtils.getDocumentRequest; -import static com.woowacourse.pickgit.docs.ApiDocumentUtils.getDocumentResponse; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; -import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; -import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; -import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; -import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; -import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; -import static org.springframework.restdocs.payload.JsonFieldType.STRING; -import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; -import static org.springframework.restdocs.payload.PayloadDocumentation.requestPartBody; -import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; -import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; -import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; -import static org.springframework.restdocs.request.RequestDocumentation.requestParameters; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.woowacourse.pickgit.authentication.application.OAuthService; -import com.woowacourse.pickgit.authentication.domain.user.LoginUser; -import com.woowacourse.pickgit.common.FileFactory; -import com.woowacourse.pickgit.exception.post.CommentFormatException; -import com.woowacourse.pickgit.post.PostTestConfiguration; -import com.woowacourse.pickgit.post.application.PostFactory; -import com.woowacourse.pickgit.post.application.PostService; -import com.woowacourse.pickgit.post.application.dto.CommentResponse; -import com.woowacourse.pickgit.post.application.dto.request.PostRequestDto; -import com.woowacourse.pickgit.post.application.dto.request.RepositoryRequestDto; -import com.woowacourse.pickgit.post.application.dto.response.PostImageUrlResponseDto; -import com.woowacourse.pickgit.post.application.dto.response.RepositoriesResponseDto; -import com.woowacourse.pickgit.post.domain.dto.RepositoryResponseDto; -import com.woowacourse.pickgit.post.presentation.dto.request.CommentRequest; -import com.woowacourse.pickgit.post.presentation.dto.request.ContentRequest; -import com.woowacourse.pickgit.post.presentation.dto.request.HomeFeedRequest; -import java.util.List; -import org.apache.http.entity.ContentType; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.Import; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.multipart.MultipartFile; - -@AutoConfigureRestDocs -@Import({PostTestConfiguration.class}) -@ExtendWith(SpringExtension.class) -@WebMvcTest(PostController.class) -@ActiveProfiles("test") -class PostControllerTest { - - private static final String USERNAME = "jipark3"; - private static final String ACCESS_TOKEN = "pickgit"; - private static final String API_ACCESS_TOKEN = "oauth.access.token"; - - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - @MockBean - private PostService postService; - - @MockBean - private OAuthService oAuthService; - - private LoginUser user; - private List images; - private String githubRepoUrl; - private String[] tags; - private String postContent; - - @BeforeEach - void setUp() { - user = new LoginUser(USERNAME, ACCESS_TOKEN); - images = List.of( - FileFactory.getTestImage1(), - FileFactory.getTestImage2() - ); - githubRepoUrl = "https://github.com/woowacourse-teams/2021-pick-git/"; - tags = new String[]{"java", "spring"}; - postContent = "pickgit"; - } - - - @DisplayName("게시물을 작성할 수 있다. - 사용자") - @Test - void write_LoginUser_Success() throws Exception { - // given - given(oAuthService.validateToken(any())) - .willReturn(true); - given(oAuthService.findRequestUserByToken(any())) - .willReturn(user); - given(postService.write(any(PostRequestDto.class))) - .willReturn(new PostImageUrlResponseDto(1L)); - - MultiValueMap multiValueMap = new LinkedMultiValueMap<>(); - multiValueMap.add("githubRepoUrl", githubRepoUrl); - multiValueMap.add("content", postContent); - - // then - ResultActions perform = mockMvc.perform(multipart("/api/posts") - .file(new MockMultipartFile("images", "testImage1.jpg", - ContentType.IMAGE_JPEG.getMimeType(), "testimage1Binary".getBytes())) - .file(new MockMultipartFile("images", "testImage2.jpg", - ContentType.IMAGE_JPEG.getMimeType(), "testimage2Binary".getBytes())) - .params(multiValueMap) - .param("tags", this.tags) - .header(HttpHeaders.AUTHORIZATION, user.getAccessToken())) - .andExpect(status().isCreated()); - - perform.andDo(document("posts-post-user", - getDocumentRequest(), - getDocumentResponse(), - requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description("bearer token") - ), - requestPartBody("images"), - responseHeaders( - headerWithName(HttpHeaders.LOCATION).description("게시물 주소") - )) - ); - } - - @DisplayName("게시물을 작성할 수 없다. - 게스트") - @Test - void write_GuestUser_Fail() throws Exception { - // given - given(oAuthService.validateToken(any())) - .willReturn(true); - given(oAuthService.findRequestUserByToken(any())) - .willCallRealMethod(); - - MultiValueMap multiValueMap = new LinkedMultiValueMap<>(); - multiValueMap.add("githubRepoUrl", githubRepoUrl); - multiValueMap.add("content", postContent); - - // then - ResultActions perform = mockMvc.perform(multipart("/api/posts") - .file(new MockMultipartFile("images", "testImage1.jpg", - ContentType.IMAGE_JPEG.getMimeType(), "testimage1Binary".getBytes())) - .file(new MockMultipartFile("images", "testImage2.jpg", - ContentType.IMAGE_JPEG.getMimeType(), "testimage2Binary".getBytes())) - - .params(multiValueMap) - .param("tags", tags) - .header(HttpHeaders.AUTHORIZATION, "Bad AccessToken")) - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("errorCode").value("A0002")); - - perform.andDo(document("posts-post-guest", - getDocumentRequest(), - getDocumentResponse(), - requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description("Bad Bearer token") - ), - requestPartBody("images"), - responseFields( - fieldWithPath("errorCode").type(STRING).description("에러 코드") - ) - )); - } - - @DisplayName("특정 Post에 댓글을 추가한다.") - @Test - void addComment_ValidContent_Success() throws Exception { - LoginUser loginUser = new LoginUser("kevin", "token"); - given(oAuthService.validateToken(anyString())) - .willReturn(true); - given(oAuthService.findRequestUserByToken(anyString())) - .willReturn(loginUser); - - String url = "/api/posts/{postId}/comments"; - CommentResponse commentResponseDto = - new CommentResponse(1L, "kevin", "test comment", false); - - String requestBody = objectMapper.writeValueAsString(new ContentRequest("test")); - String responseBody = objectMapper.writeValueAsString(commentResponseDto); - given(postService.addComment(any(CommentRequest.class))) - .willReturn(commentResponseDto); - - ResultActions perform = addCommentApi(url, requestBody) - .andExpect(status().isOk()) - .andExpect(content().string(responseBody)); - - verify(postService, times(1)).addComment(any(CommentRequest.class)); - - perform.andDo(document("comment-post", - getDocumentRequest(), - getDocumentResponse(), - requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description("bearer token") - ), - pathParameters( - parameterWithName("postId").description("포스트 id") - ), - requestFields( - fieldWithPath("content").type(STRING).description("댓글 내용") - ), - responseFields( - fieldWithPath("id").type(NUMBER).description("댓글 id"), - fieldWithPath("authorName").type(STRING).description("작성자 이름"), - fieldWithPath("content").type(STRING).description("댓글 내용"), - fieldWithPath("isLiked").type(BOOLEAN).description("좋아요 여부") - ) - )); - } - - @DisplayName("특정 Post에 댓글 등록 실패한다. - 빈 댓글인 경우.") - @Test - void addComment_InValidContent_ExceptionThrown() throws Exception { - LoginUser loginUser = new LoginUser("kevin", "token"); - given(oAuthService.validateToken(anyString())) - .willReturn(true); - given(oAuthService.findRequestUserByToken(anyString())) - .willReturn(loginUser); - - String url = "/api/posts/{postId}/comments"; - String requestBody = objectMapper.writeValueAsString(""); - given(postService.addComment(any(CommentRequest.class))) - .willThrow(new CommentFormatException()); - - ResultActions perform = addCommentApi(url, requestBody) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("errorCode").value("F0001")); - verify(postService, never()).addComment(any(CommentRequest.class)); - - perform.andDo(document("comment-post-emptyContent", - getDocumentRequest(), - getDocumentResponse(), - requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description("bearer token") - ), - pathParameters( - parameterWithName("postId").description("포스트 id") - ), - responseFields( - fieldWithPath("errorCode").type(STRING).description("에러 코드") - ) - )); - } - - private ResultActions addCommentApi(String url, String requestBody) throws Exception { - return mockMvc.perform(post(url, 1) - .header("Authorization", "Bearer test") - .contentType(MediaType.APPLICATION_JSON) - .content(requestBody)); - } - - @DisplayName("사용자는 Repository 목록을 가져올 수 있다.") - @Test - void showRepositories_LoginUser_Success() throws Exception { - // given - given(oAuthService.validateToken(any())) - .willReturn(true); - given(oAuthService.findRequestUserByToken(any())) - .willReturn(user); - - RepositoriesResponseDto responseDto = new RepositoriesResponseDto(List.of( - new RepositoryResponseDto("pick", "https://github.com/jipark3/pick"), - new RepositoryResponseDto("git", "https://github.com/jipark3/git") - )); - String repositories = objectMapper.writeValueAsString(responseDto.getRepositories()); - - given(postService.showRepositories(any(RepositoryRequestDto.class))) - .willReturn(responseDto); - - // then - ResultActions perform = mockMvc - .perform(get("/api/github/${userName}/repositories", USERNAME) - .header(HttpHeaders.AUTHORIZATION, API_ACCESS_TOKEN)) - .andExpect(status().isOk()) - .andExpect(content().string(repositories)); - - perform.andDo(document("repositories-loggedIn", - getDocumentRequest(), - getDocumentResponse(), - requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description("bearer token") - ), - pathParameters( - parameterWithName("userName").description("유저 이름") - ), - responseFields( - fieldWithPath("[].name").type(STRING).description("레포지토리 이름"), - fieldWithPath("[].html_url").type(STRING).description("레포지토리 주소") - ) - )); - } - - @DisplayName("비로그인 유저는 홈피드를 조회할 수 있다.") - @Test - void readHomeFeed_GuestUser_Success() throws Exception { - given(postService.readHomeFeed(any(HomeFeedRequest.class))) - .willReturn(PostFactory.mockPostResponseDtos()); - - ResultActions perform = mockMvc.perform(get("/api/posts") - .param("page", "0") - .param("limit", "3")) - .andExpect(status().isOk()); - - perform.andDo(document("post-homefeed-unLoggedIn", - getDocumentRequest(), - getDocumentResponse(), - requestParameters( - parameterWithName("page").description("page"), - parameterWithName("limit").description("limit") - ), - responseFields( - fieldWithPath("[].id").type(NUMBER).description("게시물 id"), - fieldWithPath("[].imageUrls").type(ARRAY).description("이미지 주소 목록"), - fieldWithPath("[].githubRepoUrl").type(STRING).description("깃허브 주소"), - fieldWithPath("[].content").type(STRING).description("게시물 내용"), - fieldWithPath("[].authorName").type(STRING).description("작성자 이름"), - fieldWithPath("[].profileImageUrl").type(STRING).description("프로필 이미지 주소"), - fieldWithPath("[].likesCount").type(NUMBER).description("좋아요 수"), - fieldWithPath("[].tags").type(ARRAY).description("태그 목록"), - fieldWithPath("[].createdAt").type(STRING).description("글 작성 시간"), - fieldWithPath("[].updatedAt").type(STRING).description("마지막 글 수정 시간"), - fieldWithPath("[].comments").type(ARRAY).description("댓글 목록"), - fieldWithPath("[].comments[].id").type(NUMBER).description("댓글 아이디"), - fieldWithPath("[].comments[].authorName").type(STRING).description("댓글 작성자 이름"), - fieldWithPath("[].comments[].content").type(STRING).description("댓글 내용"), - fieldWithPath("[].comments[].isLiked").type(BOOLEAN).description("댓글 좋아요 여부"), - fieldWithPath("[].isLiked").type(BOOLEAN).description("좋아요 여부") - ) - ) - ); - } - - - @DisplayName("로그인 유저는 홈피드를 조회할 수 있다.") - @Test - void readHomeFeed_LoginUser_Success() throws Exception { - given(oAuthService.validateToken(any())) - .willReturn(true); - given(oAuthService.findRequestUserByToken(any())) - .willReturn(user); - given(postService.readHomeFeed(any(HomeFeedRequest.class))) - .willReturn(PostFactory.mockPostResponseDtos()); - - ResultActions perform = mockMvc.perform(get("/api/posts") - .param("page", "0") - .param("limit", "3") - .header(HttpHeaders.AUTHORIZATION, API_ACCESS_TOKEN)) - .andExpect(status().isOk()); - - perform.andDo(document("post-homefeed-LoggedIn", - getDocumentRequest(), - getDocumentResponse(), - requestParameters( - parameterWithName("page").description("page"), - parameterWithName("limit").description("limit") - ), - responseFields( - fieldWithPath("[].id").type(NUMBER).description("게시물 id"), - fieldWithPath("[].imageUrls").type(ARRAY).description("이미지 주소 목록"), - fieldWithPath("[].githubRepoUrl").type(STRING).description("깃허브 주소"), - fieldWithPath("[].content").type(STRING).description("게시물 내용"), - fieldWithPath("[].authorName").type(STRING).description("작성자 이름"), - fieldWithPath("[].profileImageUrl").type(STRING).description("프로필 이미지 주소"), - fieldWithPath("[].likesCount").type(NUMBER).description("좋아요 수"), - fieldWithPath("[].tags").type(ARRAY).description("태그 목록"), - fieldWithPath("[].createdAt").type(STRING).description("글 작성 시간"), - fieldWithPath("[].updatedAt").type(STRING).description("마지막 글 수정 시간"), - fieldWithPath("[].comments").type(ARRAY).description("댓글 목록"), - fieldWithPath("[].comments[].id").type(NUMBER).description("댓글 아이디"), - fieldWithPath("[].comments[].authorName").type(STRING).description("댓글 작성자 이름"), - fieldWithPath("[].comments[].content").type(STRING).description("댓글 내용"), - fieldWithPath("[].comments[].isLiked").type(BOOLEAN).description("댓글 좋아요 여부"), - fieldWithPath("[].isLiked").type(BOOLEAN).description("좋아요 여부") - ) - ) - ); - } -} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/TestTagConfiguration.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/TestTagConfiguration.java deleted file mode 100644 index a34bb04ab..000000000 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/TestTagConfiguration.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.woowacourse.pickgit.tag; - -import com.woowacourse.pickgit.tag.infrastructure.MockTagApiRequester; -import com.woowacourse.pickgit.tag.infrastructure.PlatformApiRequester; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; - -@TestConfiguration -public class TestTagConfiguration { - - @Bean - public PlatformApiRequester platformApiRequester() { - return new MockTagApiRequester(); - } -} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/authentication/application/OAuthServiceTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/authentication/application/OAuthServiceTest.java new file mode 100644 index 000000000..c4f6b7de0 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/authentication/application/OAuthServiceTest.java @@ -0,0 +1,229 @@ +package com.woowacourse.pickgit.unit.authentication.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.woowacourse.pickgit.authentication.application.JwtTokenProvider; +import com.woowacourse.pickgit.authentication.application.OAuthService; +import com.woowacourse.pickgit.authentication.application.dto.OAuthProfileResponse; +import com.woowacourse.pickgit.authentication.application.dto.TokenDto; +import com.woowacourse.pickgit.authentication.dao.CollectionOAuthAccessTokenDao; +import com.woowacourse.pickgit.authentication.domain.OAuthClient; +import com.woowacourse.pickgit.authentication.domain.user.AppUser; +import com.woowacourse.pickgit.authentication.domain.user.GuestUser; +import com.woowacourse.pickgit.authentication.domain.user.LoginUser; +import com.woowacourse.pickgit.exception.authentication.InvalidTokenException; +import com.woowacourse.pickgit.user.domain.User; +import com.woowacourse.pickgit.user.domain.UserRepository; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.ActiveProfiles; + +@ExtendWith(MockitoExtension.class) +@ActiveProfiles("test") +class OAuthServiceTest { + + private static final String GITHUB_CODE = "oauth authorization code"; + private static final String OAUTH_ACCESS_TOKEN = "oauth access token"; + private static final String JWT_TOKEN = "jwt token"; + + @Mock + private OAuthClient oAuthClient; + + @Mock + private UserRepository userRepository; + + @Mock + private CollectionOAuthAccessTokenDao oAuthAccessTokenDao; + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @InjectMocks + private OAuthService oAuthService; + + @DisplayName("Github 로그인 URL을 반환하는데 성공한다.") + @Test + void getGithubAuthorizationUrl_Anonymous_ReturnGithubAuthorizationUrl() { + // given + String url = "https://github.com/login.."; + + // mock + given(oAuthClient.getLoginUrl()).willReturn(url); + + // then + assertThat(oAuthService.getGithubAuthorizationUrl()).isEqualTo(url); + } + + @DisplayName("회원가입(첫 로그인)시 Github Profile을 가져와서 DB에 insert한다.") + @Test + void createToken_Signup_SaveUserProfile() { + // given + OAuthProfileResponse githubProfileResponse = OAuthProfileResponse.builder() + .name("name") + .image("img.png") + .description("bio") + .githubUrl("www.github.com") + .company("company") + .location("location") + .website("website") + .twitter("twitter") + .build(); + + User user = new User( + githubProfileResponse.toBasicProfile(), + githubProfileResponse.toGithubProfile() + ); + + // mock + given(oAuthClient.getAccessToken(GITHUB_CODE)) + .willReturn(OAUTH_ACCESS_TOKEN); + given(oAuthClient.getGithubProfile(OAUTH_ACCESS_TOKEN)) + .willReturn(githubProfileResponse); + given(userRepository.findByBasicProfile_Name(githubProfileResponse.getName())) + .willReturn(Optional.empty()); + given(jwtTokenProvider.createToken(githubProfileResponse.getName())) + .willReturn(JWT_TOKEN); + + // when + TokenDto token = oAuthService.createToken(GITHUB_CODE); + + // then + assertThat(token.getToken()).isEqualTo(JWT_TOKEN); + verify(userRepository, times(1)) + .findByBasicProfile_Name(anyString()); + verify(userRepository, times(1)) + .save(any(User.class)); + verify(jwtTokenProvider, times(1)) + .createToken(anyString()); + verify(oAuthAccessTokenDao, times(1)) + .insert(anyString(), anyString()); + + verify(userRepository, times(1)) + .findByBasicProfile_Name(githubProfileResponse.getName()); + verify(userRepository, times(1)) + .save(user); + verify(jwtTokenProvider, times(1)) + .createToken(githubProfileResponse.getName()); + verify(oAuthAccessTokenDao, times(1)) + .insert(JWT_TOKEN, OAUTH_ACCESS_TOKEN); + } + + @DisplayName("로그인(첫 로그인이 아닌경우)시 Github Profile을 가져와서 DB에 저장된 기존 정보를 update한다.") + @Test + void createToken_Signup_UpdateUserProfile() { + // given + OAuthProfileResponse githubProfileResponse = OAuthProfileResponse.builder() + .name("name") + .image("img.png") + .description("bio") + .githubUrl("www.github.com") + .company("company") + .location("location") + .website("website") + .twitter("twitter") + .build(); + + User user = new User( + githubProfileResponse.toBasicProfile(), + githubProfileResponse.toGithubProfile() + ); + + // mock + given(oAuthClient.getAccessToken(GITHUB_CODE)) + .willReturn(OAUTH_ACCESS_TOKEN); + given(oAuthClient.getGithubProfile(OAUTH_ACCESS_TOKEN)) + .willReturn(githubProfileResponse); + given(userRepository.findByBasicProfile_Name(githubProfileResponse.getName())) + .willReturn(Optional.of(user)); + given(jwtTokenProvider.createToken(githubProfileResponse.getName())) + .willReturn(JWT_TOKEN); + + // when + TokenDto token = oAuthService.createToken(GITHUB_CODE); + + // then + assertThat(token.getToken()).isEqualTo(JWT_TOKEN); + verify(userRepository, times(1)) + .findByBasicProfile_Name(anyString()); + verify(userRepository, never()) + .save(any(User.class)); + verify(jwtTokenProvider, times(1)) + .createToken(anyString()); + verify(oAuthAccessTokenDao, times(1)) + .insert(anyString(), anyString()); + + verify(userRepository, times(1)) + .findByBasicProfile_Name(githubProfileResponse.getName()); + verify(userRepository, never()) + .save(user); + verify(jwtTokenProvider, times(1)) + .createToken(githubProfileResponse.getName()); + verify(oAuthAccessTokenDao, times(1)) + .insert(JWT_TOKEN, OAUTH_ACCESS_TOKEN); + } + + @DisplayName("JWT 토큰을 통해 AccessTokenDB에서 LoginUser에 대한 정보를 가져온다.") + @Test + void findRequestUserByToken_ValidToken_ReturnAppUser() { + // given + String username = "pick-git"; + + // mock + given(jwtTokenProvider.getPayloadByKey(JWT_TOKEN, "username")) + .willReturn(username); + given(oAuthAccessTokenDao.findByKeyToken(JWT_TOKEN)) + .willReturn(Optional.ofNullable(OAUTH_ACCESS_TOKEN)); + + // when + AppUser appUser = oAuthService.findRequestUserByToken(JWT_TOKEN); + + // then + assertThat(appUser).isInstanceOf(LoginUser.class); + assertThat(appUser.getUsername()).isEqualTo(username); + assertThat(appUser.getAccessToken()).isEqualTo(OAUTH_ACCESS_TOKEN); + } + + @DisplayName("AccessTokenDB에 저장되어 있지 않은 JWT 토큰이라면 예외가 발생한다.") + @Test + void findRequestUserByToken_NotFoundToken_ThrowException() { + // given + String notSavedToken = "not_saved_jwt_token"; + String username = "pick-git"; + + // mock + given(jwtTokenProvider.getPayloadByKey(notSavedToken, "username")) + .willReturn(username); + given(oAuthAccessTokenDao.findByKeyToken(notSavedToken)) + .willReturn(Optional.empty()); + + // then + assertThatThrownBy(() -> oAuthService.findRequestUserByToken(notSavedToken)) + .isInstanceOf(InvalidTokenException.class) + .hasFieldOrPropertyWithValue("errorCode", "A0001") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.UNAUTHORIZED) + .hasMessage("토큰 인증 에러"); + } + + @DisplayName("빈 JWT 토큰이면 GuestUser를 반환한다.") + @Test + void findRequestUserByToken_EmptyToken_ReturnGuest() { + // when + AppUser appUser = oAuthService.findRequestUserByToken(null); + + // then + assertThat(appUser).isInstanceOf(GuestUser.class); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/authentication/dao/OAuthAccessTokenDaoTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/authentication/dao/OAuthAccessTokenDaoTest.java new file mode 100644 index 000000000..01b8871d4 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/authentication/dao/OAuthAccessTokenDaoTest.java @@ -0,0 +1,44 @@ +package com.woowacourse.pickgit.unit.authentication.dao; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.woowacourse.pickgit.authentication.dao.CollectionOAuthAccessTokenDao; +import com.woowacourse.pickgit.authentication.dao.OAuthAccessTokenDao; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class OAuthAccessTokenDaoTest { + + private static final String TOKEN = "jwt token"; + private static final String OAUTH_ACCESS_TOKEN = "oauth access token"; + + private OAuthAccessTokenDao oAuthAccessTokenDao; + + @BeforeEach + void setUp() { + // given + oAuthAccessTokenDao = new CollectionOAuthAccessTokenDao(); + } + + @DisplayName("처음 생성된 JWT 토큰과 OAuth Access Token을 맵에 성공적으로 저장하고 불러온다.") + @Test + void insertAndFind_NonDuplicated_Save() { + // when + oAuthAccessTokenDao.insert(TOKEN, OAUTH_ACCESS_TOKEN); + + // then + assertThat(oAuthAccessTokenDao.findByKeyToken(TOKEN).get()).isEqualTo(OAUTH_ACCESS_TOKEN); + } + + @DisplayName("이미 있는 JWT 토큰에 대해서 새로운 OAuth Access Token을 추가해도 성공적으로 덮어씌워진다.") + @Test + void insertAndFind_Duplicated_Save() { + // when + oAuthAccessTokenDao.insert(TOKEN, OAUTH_ACCESS_TOKEN); + oAuthAccessTokenDao.insert(TOKEN, "duplicated"); + + // then + assertThat(oAuthAccessTokenDao.findByKeyToken(TOKEN).get()).isEqualTo("duplicated"); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/presentation/OAuthControllerTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/authentication/presentation/OAuthControllerTest.java similarity index 87% rename from backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/presentation/OAuthControllerTest.java rename to backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/authentication/presentation/OAuthControllerTest.java index 6890046e2..2e26eeb24 100644 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/authentication/presentation/OAuthControllerTest.java +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/authentication/presentation/OAuthControllerTest.java @@ -1,8 +1,8 @@ -package com.woowacourse.pickgit.authentication.presentation; +package com.woowacourse.pickgit.unit.authentication.presentation; import static com.woowacourse.pickgit.docs.ApiDocumentUtils.getDocumentRequest; import static com.woowacourse.pickgit.docs.ApiDocumentUtils.getDocumentResponse; -import static org.mockito.Mockito.when; +import static org.mockito.BDDMockito.given; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.payload.JsonFieldType.STRING; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; @@ -14,20 +14,18 @@ import com.woowacourse.pickgit.authentication.application.OAuthService; import com.woowacourse.pickgit.authentication.application.dto.TokenDto; +import com.woowacourse.pickgit.authentication.presentation.OAuthController; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; @AutoConfigureRestDocs -@ExtendWith(SpringExtension.class) @WebMvcTest(OAuthController.class) @ActiveProfiles("test") class OAuthControllerTest { @@ -43,7 +41,7 @@ class OAuthControllerTest { void authorizationGithubUrl_InvalidAccount_GithubUrl() throws Exception { // given String githubAuthorizationGithubUrl = "http://github.authorization.url"; - when(oAuthService.getGithubAuthorizationUrl()).thenReturn(githubAuthorizationGithubUrl); + given(oAuthService.getGithubAuthorizationUrl()).willReturn(githubAuthorizationGithubUrl); // when, then ResultActions perform = mockMvc.perform(get("/api/authorization/github")) @@ -65,8 +63,8 @@ void authorizationGithubUrl_InvalidAccount_GithubUrl() throws Exception { void afterAuthorizeGithubLogin_ValidAccount_JWTToken() throws Exception { // given String githubAuthorizationCode = "random"; - when(oAuthService.createToken(githubAuthorizationCode)) - .thenReturn(new TokenDto("jwt token", "binghe")); + given(oAuthService.createToken(githubAuthorizationCode)) + .willReturn(new TokenDto("jwt token", "binghe")); // when, then ResultActions perform = mockMvc diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/application/PostFeedServiceTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/application/PostFeedServiceTest.java new file mode 100644 index 000000000..decb3f2cb --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/application/PostFeedServiceTest.java @@ -0,0 +1,280 @@ +package com.woowacourse.pickgit.unit.post.application; + +import static java.util.stream.Collectors.toList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.woowacourse.pickgit.common.factory.UserFactory; +import com.woowacourse.pickgit.exception.authentication.UnauthorizedException; +import com.woowacourse.pickgit.exception.user.UserNotFoundException; +import com.woowacourse.pickgit.post.application.PostFeedService; +import com.woowacourse.pickgit.post.application.dto.request.HomeFeedRequestDto; +import com.woowacourse.pickgit.post.application.dto.response.PostResponseDto; +import com.woowacourse.pickgit.post.domain.Post; +import com.woowacourse.pickgit.post.domain.repository.PostRepository; +import com.woowacourse.pickgit.user.domain.User; +import com.woowacourse.pickgit.user.domain.UserRepository; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; + +@ExtendWith(MockitoExtension.class) +public class PostFeedServiceTest { + @InjectMocks + private PostFeedService postFeedService; + + @Mock + private UserRepository userRepository; + + @Mock + private PostRepository postRepository; + + @DisplayName("메인 홈 피드를 가져온다.") + @Test + void readHomeFeed_getMainHomeFeed_success() { + //given + List posts = List.of( + createPostOfId(1L), + createPostOfId(2L), + createPostOfId(3L) + ); + + HomeFeedRequestDto homeFeedRequestDto = HomeFeedRequestDto.builder() + .isGuest(true) + .page(1L) + .limit(3L) + .build(); + + given(postRepository.findAllPosts(any(Pageable.class))) + .willReturn(posts); + + //when + List postResponseDtos = postFeedService.homeFeed(homeFeedRequestDto); + + //then + List expected = posts.stream() + .map(Post::getId) + .collect(toList()); + + List actual = postResponseDtos.stream() + .map(PostResponseDto::getId) + .collect(toList()); + + assertThat(actual).containsAll(expected); + } + + private Post createPostOfId(long id) { + return Post.builder() + .id(id) + .author(UserFactory.user()) + .content("test") + .build(); + } + + @DisplayName("나의 홈 피드를 가져온다.") + @Test + void readMyFeed_getMyHomeFeed_success() { + //given + List posts = List.of( + createPostOfId(1L), + createPostOfId(2L), + createPostOfId(3L) + ); + + HomeFeedRequestDto homeFeedRequestDto = HomeFeedRequestDto.builder() + .requestUserName("testUser") + .isGuest(false) + .page(1L) + .limit(3L) + .build(); + + given(userRepository.findByBasicProfile_Name(anyString())) + .willReturn(Optional.of(UserFactory.user("testUser"))); + given(postRepository.findAllPostsByUser(any(User.class), any(Pageable.class))) + .willReturn(posts); + + //when + List postResponseDtos = postFeedService.myFeed(homeFeedRequestDto); + + //then + List expected = posts.stream() + .map(Post::getId) + .collect(toList()); + + List actual = postResponseDtos.stream() + .map(PostResponseDto::getId) + .collect(toList()); + + assertThat(actual).containsAll(expected); + } + + @DisplayName("잘못된 유저는 나의 홈 피드를 가져오지 못한다.") + @Test + void readMyFeed_invalidUser_ExceptionOccur() { + //given + HomeFeedRequestDto homeFeedRequestDto = HomeFeedRequestDto.builder() + .requestUserName("testUser") + .isGuest(false) + .page(1L) + .limit(3L) + .build(); + + //when + assertThatCode(() -> postFeedService.myFeed(homeFeedRequestDto)) + .isInstanceOf(UserNotFoundException.class) + .extracting("errorCode") + .isEqualTo("U0001"); + + verify(userRepository, times(1)) + .findByBasicProfile_Name(anyString()); + } + + @DisplayName("게스트 유저는 나의 홈 피드를 가져오지 못한다.") + @Test + void readMyFeed_guestUser_ExceptionOccur() { + //given + HomeFeedRequestDto homeFeedRequestDto = HomeFeedRequestDto.builder() + .isGuest(true) + .page(1L) + .limit(3L) + .build(); + + //when + assertThatCode(() -> postFeedService.myFeed(homeFeedRequestDto)) + .isInstanceOf(UnauthorizedException.class) + .extracting("errorCode") + .isEqualTo("A0002"); + } + + @DisplayName("다른 유저의 홈 피드를 가져온다") + @Test + void readUserFeed_validUser_ExceptionOccur() { + //given + List posts = List.of( + createPostOfId(1L), + createPostOfId(2L), + createPostOfId(3L) + ); + + HomeFeedRequestDto homeFeedRequestDto = HomeFeedRequestDto.builder() + .requestUserName("loginUser") + .isGuest(false) + .page(1L) + .limit(3L) + .build(); + + given(userRepository.findByBasicProfile_Name(anyString())) + .willReturn(Optional.of(UserFactory.user("testUser"))); + given(postRepository.findAllPostsByUser(any(User.class), any(Pageable.class))) + .willReturn(posts); + + //when + List postResponseDtos = + postFeedService.userFeed(homeFeedRequestDto, "testUser"); + + //then + List expected = posts.stream() + .map(Post::getId) + .collect(toList()); + + List actual = postResponseDtos.stream() + .map(PostResponseDto::getId) + .collect(toList()); + + assertThat(actual).containsAll(expected); + + verify(userRepository, times(2)).findByBasicProfile_Name(anyString()); + } + + @DisplayName("게스트 유저는 다른 유저의 홈 피드를 가져온다") + @Test + void readUserFeed_guestUser_ExceptionOccur() { + //given + List posts = List.of( + createPostOfId(1L), + createPostOfId(2L), + createPostOfId(3L) + ); + + HomeFeedRequestDto homeFeedRequestDto = HomeFeedRequestDto.builder() + .isGuest(true) + .page(1L) + .limit(3L) + .build(); + + given(userRepository.findByBasicProfile_Name(anyString())) + .willReturn(Optional.of(UserFactory.user("testUser"))); + given(postRepository.findAllPostsByUser(any(User.class), any(Pageable.class))) + .willReturn(posts); + + //when + List postResponseDtos = + postFeedService.userFeed(homeFeedRequestDto, "testUser"); + + //then + List expected = posts.stream() + .map(Post::getId) + .collect(toList()); + + List actual = postResponseDtos.stream() + .map(PostResponseDto::getId) + .collect(toList()); + + assertThat(actual).containsAll(expected); + + verify(userRepository, times(1)).findByBasicProfile_Name(anyString()); + } + + @DisplayName("잘못된 유저는 다른 유저의 홈 피드를 가져온다") + @Test + void readUserFeed_invalidUser_ExceptionOccur() { + //given + List posts = List.of( + createPostOfId(1L), + createPostOfId(2L), + createPostOfId(3L) + ); + + HomeFeedRequestDto homeFeedRequestDto = HomeFeedRequestDto.builder() + .requestUserName("invalidUser") + .isGuest(false) + .page(1L) + .limit(3L) + .build(); + + given(userRepository.findByBasicProfile_Name("testUser")) + .willReturn(Optional.of(UserFactory.user("testUser"))); + given(userRepository.findByBasicProfile_Name("invalidUser")) + .willReturn(Optional.of(UserFactory.user("invalidUser"))); + given(postRepository.findAllPostsByUser(any(User.class), any(Pageable.class))) + .willReturn(posts); + + //when + List postResponseDtos = + postFeedService.userFeed(homeFeedRequestDto, "testUser"); + + //then + List expected = posts.stream() + .map(Post::getId) + .collect(toList()); + + List actual = postResponseDtos.stream() + .map(PostResponseDto::getId) + .collect(toList()); + + assertThat(actual).containsAll(expected); + + verify(userRepository, times(2)).findByBasicProfile_Name(anyString()); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/application/PostServiceTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/application/PostServiceTest.java new file mode 100644 index 000000000..1ab046085 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/application/PostServiceTest.java @@ -0,0 +1,749 @@ +package com.woowacourse.pickgit.unit.post.application; + +import static java.util.stream.Collectors.toList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.woowacourse.pickgit.authentication.domain.user.AppUser; +import com.woowacourse.pickgit.authentication.domain.user.GuestUser; +import com.woowacourse.pickgit.authentication.domain.user.LoginUser; +import com.woowacourse.pickgit.common.factory.FileFactory; +import com.woowacourse.pickgit.common.factory.PostFactory; +import com.woowacourse.pickgit.common.factory.UserFactory; +import com.woowacourse.pickgit.exception.authentication.UnauthorizedException; +import com.woowacourse.pickgit.exception.post.CannotUnlikeException; +import com.woowacourse.pickgit.exception.post.CommentFormatException; +import com.woowacourse.pickgit.exception.post.DuplicatedLikeException; +import com.woowacourse.pickgit.exception.post.PostFormatException; +import com.woowacourse.pickgit.exception.post.PostNotBelongToUserException; +import com.woowacourse.pickgit.exception.post.PostNotFoundException; +import com.woowacourse.pickgit.exception.post.RepositoryParseException; +import com.woowacourse.pickgit.exception.user.UserNotFoundException; +import com.woowacourse.pickgit.post.application.PostService; +import com.woowacourse.pickgit.post.application.dto.request.CommentRequestDto; +import com.woowacourse.pickgit.post.application.dto.request.PostDeleteRequestDto; +import com.woowacourse.pickgit.post.application.dto.request.PostRequestDto; +import com.woowacourse.pickgit.post.application.dto.request.PostUpdateRequestDto; +import com.woowacourse.pickgit.post.application.dto.request.RepositoryRequestDto; +import com.woowacourse.pickgit.post.application.dto.response.CommentResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.LikeResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.PostImageUrlResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.PostUpdateResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.RepositoryResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.RepositoryResponseDtos; +import com.woowacourse.pickgit.post.domain.Post; +import com.woowacourse.pickgit.post.domain.content.Image; +import com.woowacourse.pickgit.post.domain.content.Images; +import com.woowacourse.pickgit.post.domain.repository.PickGitStorage; +import com.woowacourse.pickgit.post.domain.repository.PostRepository; +import com.woowacourse.pickgit.post.domain.util.PlatformRepositoryExtractor; +import com.woowacourse.pickgit.post.domain.util.dto.RepositoryUrlAndName; +import com.woowacourse.pickgit.post.presentation.dto.request.PostUpdateRequest; +import com.woowacourse.pickgit.tag.application.TagService; +import com.woowacourse.pickgit.tag.application.TagsDto; +import com.woowacourse.pickgit.tag.domain.Tag; +import com.woowacourse.pickgit.user.domain.User; +import com.woowacourse.pickgit.user.domain.UserRepository; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.multipart.MultipartFile; + +@ExtendWith(MockitoExtension.class) +class PostServiceTest { + + @InjectMocks + private PostService postService; + + @Mock + private UserRepository userRepository; + + @Mock + private PostRepository postRepository; + + @Mock + private PickGitStorage pickGitStorage; + + @Mock + private PlatformRepositoryExtractor platformRepositoryExtractor; + + @Mock + private TagService tagService; + + @DisplayName("사용자는 게시물을 등록할 수 있다.") + @Test + void write_LoginUser_Success() { + // given + PostRequestDto requestDto = PostFactory.mockPostRequestDtos().get(0); + User user = UserFactory.user(1L, "testUser"); + Post post = Post.builder() + .id(1L) + .content("testContent") + .images(extractImagesFrom(requestDto)) + .build(); + + given(userRepository.findByBasicProfile_Name(anyString())) + .willReturn(Optional.of(user)); + given(postRepository.save(any(Post.class))) + .willReturn(post); + given(pickGitStorage.storeMultipartFile(anyList(), anyString())) + .willReturn(extractImageUrlsFrom(requestDto)); + given(tagService.findOrCreateTags(any())) + .willReturn(extractTagsFrom(requestDto)); + + // when + PostImageUrlResponseDto responseDto = postService.write(requestDto); + + // then + assertThat(responseDto.getId()).isNotNull(); + assertThat(responseDto.getImageUrls()).containsAll(extractImageUrlsFrom(requestDto)); + + verify(userRepository, times(1)) + .findByBasicProfile_Name(requestDto.getUsername()); + verify(postRepository, times(1)) + .save(any(Post.class)); + verify(pickGitStorage, times(1)) + .storeMultipartFile(anyList(), anyString()); + verify(tagService, times(1)) + .findOrCreateTags(any(TagsDto.class)); + } + + private Images extractImagesFrom(PostRequestDto requestDto) { + List images = extractImageUrlsFrom(requestDto).stream() + .map(Image::new) + .collect(toList()); + + return new Images(images); + } + + private List extractImageUrlsFrom(PostRequestDto requestDto) { + return requestDto.getImages().stream() + .map(MultipartFile::getName) + .map(name -> String.format("http://testImages.test/%s", name)) + .collect(toList()); + } + + private List extractTagsFrom(PostRequestDto requestDto) { + return requestDto.getTags().stream() + .map(Tag::new) + .collect(toList()); + } + + @DisplayName("사용자는 게시물을 등록할 수 있다.") + @Test + void write_InvalidUser_ExceptionOccur() { + // given + PostRequestDto requestDto = PostFactory.mockPostRequestDtos().get(0); + + given(userRepository.findByBasicProfile_Name(anyString())) + .willReturn(Optional.empty()); + + // when then + assertThatCode(() -> postService.write(requestDto)) + .isInstanceOf(UserNotFoundException.class) + .extracting("errorCode") + .isEqualTo("U0001"); + + verify(userRepository, times(1)) + .findByBasicProfile_Name(requestDto.getUsername()); + } + + @DisplayName("컨텐츠의 길이가 500보다 크면 게시물을 등록할 수 없다.") + @Test + void write_ContentLengthOver500_Fail() { + given(userRepository.findByBasicProfile_Name(anyString())) + .willReturn(Optional.of(UserFactory.user("kevin"))); + + // given + PostRequestDto requestDto = PostRequestDto.builder() + .username("kevin") + .content("a".repeat(501)) + .images(List.of(FileFactory.getTestImage1())) + .build(); + + // when then + assertThatCode(() -> postService.write(requestDto)) + .isInstanceOf(PostFormatException.class); + } + + @DisplayName("게시물에 댓글을 정상 등록한다.") + @Test + void addComment_ValidContent_Success() { + //given + String comment_content = "test comment"; + User user = UserFactory.user(1L, "testUser1"); + Post post = Post.builder() + .id(1L) + .author(user) + .build(); + + given(postRepository.findById(anyLong())) + .willReturn(Optional.of(post)); + given(userRepository.findByBasicProfile_Name(anyString())) + .willReturn(Optional.of(user)); + + CommentRequestDto commentRequestDto = + new CommentRequestDto(user.getName(), comment_content, post.getId()); + + //when + CommentResponseDto commentResponseDto = postService.addComment(commentRequestDto); + + //then + assertThat(commentResponseDto.getAuthorName()).isEqualTo(user.getName()); + assertThat(commentResponseDto.getContent()).isEqualTo(comment_content); + + verify(postRepository, times(1)).findById(anyLong()); + verify(userRepository, times(1)).findByBasicProfile_Name(anyString()); + } + + @DisplayName("게시물에 빈 댓글을 등록할 수 없다.") + @Test + void addComment_InvalidContent_ExceptionThrown() { + Post post = Post.builder() + .id(1L) + .build(); + + User user = UserFactory.user("testuser"); + + given(userRepository.findByBasicProfile_Name(user.getName())) + .willReturn(Optional.of(user)); + given(postRepository.findById(post.getId())) + .willReturn(Optional.of(post)); + + CommentRequestDto commentRequestDto = + new CommentRequestDto(user.getName(), "", post.getId()); + + // then + assertThatCode(() -> postService.addComment(commentRequestDto)) + .isInstanceOf(CommentFormatException.class) + .extracting("errorCode") + .isEqualTo("F0002"); + } + + @DisplayName("존재하지 않는 사용자는 댓글을 등록할 수 없다.") + @Test + void addComment_invalidUser_ExceptionOccur() { + //given + CommentRequestDto commentRequestDto = + new CommentRequestDto("invalidUser", "comment_content", 1L); + + given(userRepository.findByBasicProfile_Name(anyString())) + .willThrow(new UserNotFoundException()); + + //when then + assertThatCode(() -> postService.addComment(commentRequestDto)) + .isInstanceOf(UserNotFoundException.class) + .extracting("errorCode") + .isEqualTo("U0001"); + + verify(userRepository, times(1)).findByBasicProfile_Name(anyString()); + } + + @DisplayName("존재하지 않는 게시물에는 댓글을 등록할 수 없다.") + @Test + void addComment_invalidPost_ExceptionOccur() { + //given + CommentRequestDto commentRequestDto = + new CommentRequestDto("testUser", "comment_content", 1L); + + given(userRepository.findByBasicProfile_Name(anyString())) + .willReturn(Optional.of(UserFactory.user())); + given(postRepository.findById(anyLong())) + .willThrow(new PostNotFoundException()); + + //when then + assertThatCode(() -> postService.addComment(commentRequestDto)) + .isInstanceOf(PostNotFoundException.class) + .extracting("errorCode") + .isEqualTo("P0002"); + + verify(userRepository, times(1)).findByBasicProfile_Name(anyString()); + verify(postRepository, times(1)).findById(anyLong()); + } + + @DisplayName("사용자는 Repository 목록을 가져올 수 있다.") + @Test + void showRepositories_LoginUser_Success() { + // given + String accessToken = "bearer token"; + String userName = "testUserName"; + + RepositoryRequestDto requestDto = new RepositoryRequestDto(accessToken, userName); + List repositories = List.of( + new RepositoryUrlAndName("pick", "https://github.com/jipark3/pick"), + new RepositoryUrlAndName("git", "https://github.com/jipark3/git") + ); + + given(platformRepositoryExtractor + .extract(requestDto.getToken(), requestDto.getUsername())) + .willReturn(repositories); + + // when + RepositoryResponseDtos repositoryResponseDtos = postService.userRepositories(requestDto); + List responseDtos = repositoryResponseDtos.getRepositoryResponseDtos(); + + // then + assertThat(responseDtos) + .usingRecursiveComparison() + .isEqualTo(repositories); + verify(platformRepositoryExtractor, times(1)) + .extract(requestDto.getToken(), requestDto.getUsername()); + } + + @DisplayName("AccessToken이 잘못되었다면, Repository 목록을 가져올 수 없다.") + @Test + void showRepositories_InvalidAccessToken_Fail() { + // given + String accessToken = "bearer invalid token"; + String userName = "testUserName"; + + RepositoryRequestDto requestDto = new RepositoryRequestDto(accessToken, userName); + + given(platformRepositoryExtractor.extract(requestDto.getToken(), requestDto.getUsername())) + .willThrow(new RepositoryParseException()); + + // when then + assertThatCode(() -> postService.userRepositories(requestDto)) + .isInstanceOf(RepositoryParseException.class); + + verify(platformRepositoryExtractor, times(1)) + .extract(requestDto.getToken(), requestDto.getUsername()); + } + + @DisplayName("UserName이 잘못되었다면, Repository 목록을 가져올 수 없다.") + @Test + void showRepositories_InvalidUserName_Fail() { + // given + String accessToken = "bearer test token"; + String userName = "invalidName"; + + RepositoryRequestDto requestDto = new RepositoryRequestDto(accessToken, userName); + + given(platformRepositoryExtractor.extract(requestDto.getToken(), requestDto.getUsername())) + .willThrow(new RepositoryParseException( + "V0001", + HttpStatus.BAD_REQUEST, + "레포지토리 목록을 불러올 수 없습니다." + )); + + // when then + assertThatCode(() -> postService.userRepositories(requestDto)) + .isInstanceOf(RepositoryParseException.class); + + verify(platformRepositoryExtractor, times(1)) + .extract(requestDto.getToken(), requestDto.getUsername()); + } + + @DisplayName("사용자는 특정 게시물을 좋아요 할 수 있다.") + @Test + void like_ValidUser_Success() { + // given + AppUser appUser = new LoginUser("test user", "token"); + Long postId = 1L; + + given(userRepository.findByBasicProfile_Name(anyString())) + .willReturn(Optional.of(UserFactory.user(1L, appUser.getUsername()))); + given(postRepository.findById(anyLong())) + .willReturn(Optional.of( + Post.builder() + .id(postId) + .content("abc") + .build()) + ); + + // when + LikeResponseDto likeResponseDto = postService.like(appUser, postId); + + // then + assertThat(likeResponseDto.getLikesCount()).isEqualTo(1); + assertThat(likeResponseDto.getLiked()).isTrue(); + + verify(userRepository, times(1)) + .findByBasicProfile_Name(appUser.getUsername()); + verify(postRepository, times(1)) + .findById(postId); + } + + @DisplayName("사용자는 특정 게시물을 좋아요 취소 할 수 있다.") + @Test + void unlike_ValidUser_Success() { + // given + AppUser appUser = new LoginUser("test user", "token"); + Long postId = 1L; + User user = UserFactory.user(1L, appUser.getUsername()); + Post post = Post.builder() + .id(postId) + .content("abc") + .build(); + + post.like(user); + + given(userRepository.findByBasicProfile_Name(appUser.getUsername())) + .willReturn(Optional.of(user)); + given(postRepository.findById(postId)) + .willReturn(Optional.of(post)); + + // when + LikeResponseDto likeResponseDto = postService.unlike(appUser, postId); + + // then + assertThat(likeResponseDto.getLikesCount()).isEqualTo(0); + assertThat(likeResponseDto.getLiked()).isFalse(); + + verify(userRepository, times(1)) + .findByBasicProfile_Name(appUser.getUsername()); + verify(postRepository, times(1)) + .findById(anyLong()); + } + + @DisplayName("사용자는 이미 좋아요 한 게시물을 좋아요 추가 할 수 없다.") + @Test + void like_DuplicatedLike_400ExceptionThrown() { + // given + AppUser appUser = new LoginUser("test user", "token"); + Long postId = 1L; + User user = UserFactory.user(1L, appUser.getUsername()); + Post post = Post.builder() + .id(postId) + .content("abc") + .build(); + + post.like(user); + + given(userRepository.findByBasicProfile_Name(anyString())) + .willReturn(Optional.of(user)); + given(postRepository.findById(anyLong())) + .willReturn(Optional.of(post)); + + // when then + assertThatThrownBy(() -> postService.like(appUser, postId)) + .isInstanceOf(DuplicatedLikeException.class) + .hasFieldOrPropertyWithValue("errorCode", "P0003") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("이미 좋아요한 게시물 중복 좋아요 에러"); + + verify(userRepository, times(1)) + .findByBasicProfile_Name(appUser.getUsername()); + verify(postRepository, times(1)) + .findById(postId); + } + + @DisplayName("사용자는 좋아요 누르지 않은 게시물을 좋아요 취소 할 수 없다.") + @Test + void unlike_UnlikePost_400ExceptionThrown() { + // given + AppUser appUser = new LoginUser("test user", "token"); + Long postId = 1L; + User user = UserFactory.user(1L, appUser.getUsername()); + Post post = Post.builder() + .id(postId) + .content("abc") + .build(); + + given(userRepository.findByBasicProfile_Name(anyString())) + .willReturn(Optional.of(user)); + given(postRepository.findById(anyLong())) + .willReturn(Optional.of(post)); + + // when then + assertThatThrownBy(() -> postService.unlike(appUser, postId)) + .isInstanceOf(CannotUnlikeException.class) + .hasFieldOrPropertyWithValue("errorCode", "P0004") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("좋아요 하지 않은 게시물 좋아요 취소 에러"); + + verify(userRepository, times(1)) + .findByBasicProfile_Name(appUser.getUsername()); + verify(postRepository, times(1)) + .findById(postId); + } + + @DisplayName("사용자는 게시물의 태그, 내용을 수정한다.") + @Test + void update_TagsAndContentInCaseOfLoginUser_Success() { + // given + LoginUser loginUser = new LoginUser("testUser", "Bearer testToken"); + User user = UserFactory.user(1L, loginUser.getUsername()); + Post post = Post.builder() + .id(1L) + .content("testContent") + .author(user) + .build(); + + PostUpdateRequest updateRequest = PostUpdateRequest.builder() + .tags(List.of("java", "spring")) + .content("hello") + .build(); + PostUpdateRequestDto updateRequestDto = new PostUpdateRequestDto(loginUser, 1L, + updateRequest.getTags(), updateRequest.getContent()); + + given(userRepository.findByBasicProfile_Name(anyString())) + .willReturn(Optional.of(user)); + given(postRepository.findById(anyLong())) + .willReturn(Optional.of(post)); + given(tagService.findOrCreateTags(any(TagsDto.class))) + .willReturn(List.of(new Tag("java"), new Tag("spring"))); + + PostUpdateResponseDto responseDto = PostUpdateResponseDto.builder() + .tags(List.of("java", "spring")) + .content("hello") + .build(); + + // when + PostUpdateResponseDto updateResponseDto = postService.update(updateRequestDto); + + // then + assertThat(updateResponseDto) + .usingRecursiveComparison() + .isEqualTo(responseDto); + + verify(userRepository, times(1)) + .findByBasicProfile_Name("testUser"); + verify(postRepository, times(1)) + .findById(1L); + verify(tagService, times(1)) + .findOrCreateTags(any(TagsDto.class)); + } + + @DisplayName("사용자는 게시물의 태그를 수정한다.") + @Test + void update_TagsInCaseOfLoginUser_Success() { + // given + LoginUser loginUser = new LoginUser("testUser", "Bearer testToken"); + User user = UserFactory.user(1L, loginUser.getUsername()); + Post post = Post.builder() + .id(1L) + .content("testContent") + .author(user) + .build(); + + PostUpdateRequest updateRequest = PostUpdateRequest.builder() + .tags(List.of()) + .content("hello") + .build(); + PostUpdateRequestDto updateRequestDto = new PostUpdateRequestDto(loginUser, 1L, + updateRequest.getTags(), updateRequest.getContent()); + + given(userRepository.findByBasicProfile_Name(anyString())) + .willReturn(Optional.of(user)); + given(postRepository.findById(anyLong())) + .willReturn(Optional.of(post)); + given(tagService.findOrCreateTags(any(TagsDto.class))) + .willReturn(List.of()); + + PostUpdateResponseDto responseDto = PostUpdateResponseDto.builder() + .tags(List.of()) + .content("hello") + .build(); + + // when + PostUpdateResponseDto updateResponseDto = postService.update(updateRequestDto); + + // then + assertThat(updateResponseDto) + .usingRecursiveComparison() + .isEqualTo(responseDto); + + verify(userRepository, times(1)) + .findByBasicProfile_Name("testUser"); + verify(postRepository, times(1)) + .findById(1L); + verify(tagService, times(1)) + .findOrCreateTags(any(TagsDto.class)); + } + + @DisplayName("사용자는 게시물의 내용을 수정한다.") + @Test + void update_ContentInCaseOfLoginUser_Success() { + // given + LoginUser loginUser = new LoginUser("testUser", "Bearer testToken"); + User user = UserFactory.user(1L, loginUser.getUsername()); + Post post = Post.builder() + .id(1L) + .content("testContent") + .author(user) + .build(); + + PostUpdateRequest updateRequest = PostUpdateRequest.builder() + .tags(List.of("java", "spring")) + .content("testContent") + .build(); + PostUpdateRequestDto updateRequestDto = new PostUpdateRequestDto(loginUser, 1L, + updateRequest.getTags(), updateRequest.getContent()); + + given(userRepository.findByBasicProfile_Name(anyString())) + .willReturn(Optional.of(user)); + given(postRepository.findById(anyLong())) + .willReturn(Optional.of(post)); + given(tagService.findOrCreateTags(any(TagsDto.class))) + .willReturn(List.of(new Tag("java"), new Tag("spring"))); + + PostUpdateResponseDto responseDto = PostUpdateResponseDto.builder() + .tags(List.of("java", "spring")) + .content("testContent") + .build(); + + // when + PostUpdateResponseDto updateResponseDto = postService.update(updateRequestDto); + + // then + assertThat(updateResponseDto) + .usingRecursiveComparison() + .isEqualTo(responseDto); + + verify(userRepository, times(1)) + .findByBasicProfile_Name("testUser"); + verify(postRepository, times(1)) + .findById(1L); + verify(tagService, times(1)) + .findOrCreateTags(any(TagsDto.class)); + } + + @DisplayName("게스트는 게시물의 내용을 수정할 수 없다.") + @Test + void update_GuestUser_401Exception() { + // given + GuestUser guestUser = new GuestUser(); + + PostUpdateRequest updateRequest = PostUpdateRequest.builder() + .tags(List.of("java", "spring")) + .content("testContent") + .build(); + + // when + assertThatThrownBy(() -> { + new PostUpdateRequestDto(guestUser, 1L, + updateRequest.getTags(), updateRequest.getContent()); + }).isInstanceOf(UnauthorizedException.class) + .hasFieldOrPropertyWithValue("errorCode", "A0002") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.UNAUTHORIZED) + .hasMessage("권한 에러"); + } + + @DisplayName("해당하는 사용자의 게시물이 아닌 경우 수정할 수 없다. - 401 예외") + @Test + void update_PostNotBelongToUser_401Exception() { + // given + LoginUser loginUser = new LoginUser("testUser", "Bearer testToken"); + User user = UserFactory.user(1L, loginUser.getUsername()); + + given(userRepository.findByBasicProfile_Name(anyString())) + .willReturn(Optional.of(user)); + given(postRepository.findById(anyLong())) + .willThrow(new PostNotBelongToUserException()); + + PostUpdateRequest updateRequest = PostUpdateRequest.builder() + .tags(List.of("java", "spring")) + .content("testContent") + .build(); + PostUpdateRequestDto updateRequestDto = new PostUpdateRequestDto(loginUser, 1L, + updateRequest.getTags(), updateRequest.getContent()); + + // when + assertThatThrownBy(() -> { + postService.update(updateRequestDto); + }).isInstanceOf(PostNotBelongToUserException.class) + .hasFieldOrPropertyWithValue("errorCode", "P0005") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.UNAUTHORIZED) + .hasMessage("해당하는 사용자의 게시물이 아닙니다."); + + // then + verify(userRepository, times(1)) + .findByBasicProfile_Name("testUser"); + verify(postRepository, times(1)) + .findById(1L); + } + + @DisplayName("사용자는 게시물을 삭제한다.") + @Test + void delete_LoginUser_Success() { + // given + LoginUser loginUser = new LoginUser("testUser", "Bearer testToken"); + User user = UserFactory.user(1L, loginUser.getUsername()); + Post post = Post.builder() + .id(1L) + .content("testContent") + .author(user) + .build(); + + given(userRepository.findByBasicProfile_Name(anyString())) + .willReturn(Optional.of(user)); + given(postRepository.findById(anyLong())) + .willReturn(Optional.of(post)); + willDoNothing() + .given(postRepository) + .delete(any(Post.class)); + + PostDeleteRequestDto deleteRequestDto = new PostDeleteRequestDto(loginUser, 1L); + + // when + postService.delete(deleteRequestDto); + + // then + verify(userRepository, times(1)) + .findByBasicProfile_Name("testUser"); + verify(postRepository, times(1)) + .findById(1L); + verify(postRepository, times(1)) + .delete(any(Post.class)); + } + + @DisplayName("게스트는 게시물을 삭제할 수 없다. - 401 예외") + @Test + void delete_GuestUser_401Exception() { + // given + GuestUser guestUser = new GuestUser(); + + // when + assertThatThrownBy(() -> { + new PostDeleteRequestDto(guestUser, 1L); + }).isInstanceOf(UnauthorizedException.class) + .hasFieldOrPropertyWithValue("errorCode", "A0002") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.UNAUTHORIZED) + .hasMessage("권한 에러"); + } + + @DisplayName("해당하는 사용자의 게시물이 아닌 경우 삭제할 수 없다. - 401 예외") + @Test + void delete_PostNotBelongToUser_401Exception() { + // given + LoginUser loginUser = new LoginUser("testUser", "Bearer testToken"); + User user = UserFactory.user(1L, loginUser.getUsername()); + + given(userRepository.findByBasicProfile_Name(anyString())) + .willReturn(Optional.of(user)); + given(postRepository.findById(anyLong())) + .willThrow(new PostNotBelongToUserException()); + + PostDeleteRequestDto deleteRequestDto = new PostDeleteRequestDto(loginUser, 1L); + + // when + assertThatThrownBy(() -> { + postService.delete(deleteRequestDto); + }).isInstanceOf(PostNotBelongToUserException.class) + .hasFieldOrPropertyWithValue("errorCode", "P0005") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.UNAUTHORIZED) + .hasMessage("해당하는 사용자의 게시물이 아닙니다."); + + // then + verify(userRepository, times(1)) + .findByBasicProfile_Name("testUser"); + verify(postRepository, times(1)) + .findById(1L); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/PostRepositoryTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/PostRepositoryTest.java new file mode 100644 index 000000000..7c48d8575 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/PostRepositoryTest.java @@ -0,0 +1,166 @@ +package com.woowacourse.pickgit.unit.post.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.woowacourse.pickgit.common.factory.UserFactory; +import com.woowacourse.pickgit.config.JpaTestConfiguration; +import com.woowacourse.pickgit.post.domain.Post; +import com.woowacourse.pickgit.post.domain.comment.Comment; +import com.woowacourse.pickgit.post.domain.repository.PostRepository; +import com.woowacourse.pickgit.tag.domain.Tag; +import com.woowacourse.pickgit.tag.domain.TagRepository; +import com.woowacourse.pickgit.user.domain.User; +import com.woowacourse.pickgit.user.domain.UserRepository; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.dao.InvalidDataAccessApiUsageException; + +@Import(JpaTestConfiguration.class) +@DataJpaTest +class PostRepositoryTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private PostRepository postRepository; + + @Autowired + private TagRepository tagRepository; + + @Autowired + private TestEntityManager testEntityManager; + + @DisplayName("게시글을 저장한다.") + @Test + void save_SavedPost_Success() { + // given + User savedTestUser = userRepository.save( + UserFactory.user("testUser") + ); + + Post post = Post.builder() + .content("test") + .author(savedTestUser) + .build(); + + // when + Post savedPost = postRepository.save(post); + flushAndClear(); + + Post actual = postRepository.findById(savedPost.getId()) + .orElseThrow(IllegalArgumentException::new); + + // then + assertThat(actual.getId()).isEqualTo(savedPost.getId()); + } + + @DisplayName("게시글을 저장하면 자동으로 생성 날짜가 주입된다.") + @Test + void save_SavedPostWithCreatedDate_Success() { + // given + User savedTestUser = userRepository.save( + UserFactory.user("testUser") + ); + + Post post = Post.builder() + .content("test") + .author(savedTestUser) + .build(); + + // when + Post savedPost = postRepository.save(post); + + // then + assertThat(savedPost.getCreatedAt()).isNotNull(); + assertThat(savedPost.getCreatedAt()).isBefore(LocalDateTime.now()); + } + + @DisplayName("Post을 저장할 때 PostTag도 함께 영속화된다.") + @Test + void save_WhenSavingPost_TagSavedTogether() { + //given + User testUser = UserFactory.user("testUser"); + User savedTestUser = userRepository.save(testUser); + + Post post = Post.builder() + .content("testContent") + .githubRepoUrl("https://github.com/bperhaps") + .author(savedTestUser) + .build(); + Post savedPost = postRepository.save(post); + + Tag tag1 = new Tag("tag1"); + Tag tag2 = new Tag("tag2"); + + tagRepository.save(tag1); + tagRepository.save(tag2); + + // when + post.addTags(List.of(tag1, tag2)); + flushAndClear(); + + // then + Post findPost = postRepository.findById(savedPost.getId()) + .orElse(null); + + assertThat(findPost).isNotNull(); + assertThat(findPost.getTagNames()).hasSize(2); + } + + @DisplayName("Post를 저장할 때 Tag는 함께 영속화되지 않는다. (태그가 존재하지 않을 경우 예외가 발생한다)") + @Test + void save_WhenSavingPost_TagNotSavedTogether() { + // given + Post post = Post.builder() + .content("abc") + .build(); + List tags = Arrays.asList(new Tag("tag1"), new Tag("tag2")); + + // when, then + post.addTags(tags); + assertThatThrownBy(() -> postRepository.save(post)) + .isInstanceOf(InvalidDataAccessApiUsageException.class); + } + + @DisplayName("Post에 Comment를 추가하면 Comment가 자동 영속화된다.") + @Test + void addComment_WhenSavingPost_CommentSavedTogether() { + // given + User testUser = UserFactory.user("testUser"); + User savedTestUser = userRepository.save(testUser); + + Post post = Post.builder() + .content("testContent") + .githubRepoUrl("https://github.com/bperhaps") + .author(savedTestUser) + .build(); + + // when + Comment comment = new Comment("test comment", testUser); + post.addComment(comment); + + postRepository.save(post); + flushAndClear(); + + // then + Post findPost = postRepository.findById(post.getId()) + .orElseThrow(IllegalArgumentException::new); + + List comments = findPost.getComments(); + assertThat(comments).hasSize(1); + } + + private void flushAndClear() { + testEntityManager.flush(); + testEntityManager.clear(); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/PostTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/PostTest.java new file mode 100644 index 000000000..83de7d321 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/PostTest.java @@ -0,0 +1,136 @@ +package com.woowacourse.pickgit.unit.post.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.woowacourse.pickgit.common.factory.UserFactory; +import com.woowacourse.pickgit.exception.post.CannotAddTagException; +import com.woowacourse.pickgit.exception.post.CannotUnlikeException; +import com.woowacourse.pickgit.exception.post.DuplicatedLikeException; +import com.woowacourse.pickgit.post.domain.Post; +import com.woowacourse.pickgit.tag.domain.Tag; +import com.woowacourse.pickgit.user.domain.User; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +class PostTest { + + @DisplayName("Tag를 정상적으로 Post에 등록한다.") + @Test + void addTags_ValidTags_RegistrationSuccess() { + Post post = Post.builder() + .content("abc") + .build(); + + List tags = List.of( + new Tag("tag1"), + new Tag("tag2"), + new Tag("tag3") + ); + + post.addTags(tags); + + assertThat(post.getTagNames()).hasSize(3); + } + + @DisplayName("중복되는 이름의 Tag가 존재하면 Post에 추가할 수 없다.") + @Test + void addTags_DuplicatedTagName_ExceptionThrown() { + Post post = Post.builder() + .content("abc") + .build(); + + List tags = List.of( + new Tag("tag1"), + new Tag("tag2"), + new Tag("tag3") + ); + + post.addTags(tags); + + List duplicatedTags = List.of( + new Tag("tag4"), + new Tag("tag3") + ); + + assertThatCode(() -> post.addTags(duplicatedTags)) + .isInstanceOf(CannotAddTagException.class) + .extracting("errorCode") + .isEqualTo("P0001"); + } + + @DisplayName("사용자는 특정 게시물을 좋아요 할 수 있다.") + @Test + void like_validUser_Success() { + Post post = Post.builder() + .content("abc") + .build(); + + User user = UserFactory.user(); + + post.like(user); + + assertThat(post.getLikeCounts()).isEqualTo(1); + assertThat(post.isLikedBy(user)).isTrue(); + } + + @DisplayName("사용자는 특정 게시물을 좋아요 취소 할 수 있다.") + @Test + void unlike_validUser_Success() { + Post post = Post.builder() + .content("abc") + .build(); + + User user1 = UserFactory.user(1L, "user"); + User user2 = UserFactory.user(2L, "another User"); + + post.like(user1); + post.like(user2); + + assertThat(post.getLikeCounts()).isEqualTo(2); + assertThat(post.isLikedBy(user1)).isTrue(); + assertThat(post.isLikedBy(user2)).isTrue(); + + post.unlike(user2); + + assertThat(post.getLikeCounts()).isEqualTo(1); + assertThat(post.isLikedBy(user1)).isTrue(); + assertThat(post.isLikedBy(user2)).isFalse(); + } + + @DisplayName("사용자는 이미 좋아요한 게시물을 좋아요 추가 할 수 없다.") + @Test + void like_AlreadyLikePost_ExceptionThrown() { + Post post = Post.builder() + .content("abc") + .build(); + + User user = UserFactory.user(1L, "user"); + post.like(user); + + assertThatThrownBy(() -> post.like(user)) + .isInstanceOf(DuplicatedLikeException.class) + .hasFieldOrPropertyWithValue("errorCode", "P0003") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("이미 좋아요한 게시물 중복 좋아요 에러"); + } + + @DisplayName("사용자는 좋아요 하지 않은 게시물을 좋아요 취소 할 수 없다.") + @Test + void unlike_nonLikePost_ExceptionThrown() { + Post post = Post.builder() + .content("abc") + .build(); + + User user = UserFactory.user(1L, "user"); + + assertThatThrownBy(() -> post.unlike(user)) + .isInstanceOf(CannotUnlikeException.class) + .hasFieldOrPropertyWithValue("errorCode", "P0004") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("좋아요 하지 않은 게시물 좋아요 취소 에러"); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/PostsTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/PostsTest.java new file mode 100644 index 000000000..791a3d186 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/PostsTest.java @@ -0,0 +1,24 @@ +package com.woowacourse.pickgit.unit.post.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.woowacourse.pickgit.post.domain.Post; +import com.woowacourse.pickgit.post.domain.Posts; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class PostsTest { + + @DisplayName("post의 수를 반환한다.") + @Test + void getCounts_getCountsOfPosts_returnCountsOfPosts() { + Posts posts = new Posts(List.of( + Post.builder().build(), + Post.builder().build(), + Post.builder().build() + )); + + assertThat(posts.count()).isEqualTo(3); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/domain/comment/CommentTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/comment/CommentTest.java similarity index 60% rename from backend/pick-git/src/test/java/com/woowacourse/pickgit/post/domain/comment/CommentTest.java rename to backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/comment/CommentTest.java index 5e4af80aa..43a43db8b 100644 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/domain/comment/CommentTest.java +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/comment/CommentTest.java @@ -1,46 +1,48 @@ -package com.woowacourse.pickgit.post.domain.comment; +package com.woowacourse.pickgit.unit.post.domain.comment; import static org.assertj.core.api.Assertions.assertThatCode; import com.woowacourse.pickgit.exception.post.CommentFormatException; +import com.woowacourse.pickgit.post.domain.comment.Comment; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; class CommentTest { @DisplayName("100자 이하의 댓글을 생성할 수 있다.") @Test void newComment_Under100Length_Success() { - StringBuilder content = new StringBuilder(); - for (int i = 0; i < 100; i++) { - content.append("a"); - } + // given + String content = "a".repeat(99); - assertThatCode(() -> new Comment(content.toString())) + // when, then + assertThatCode(() -> new Comment(content, null)) .doesNotThrowAnyException(); } @DisplayName("100자 초과의 댓글을 생성할 수 없다.") @Test void newComment_Over100Length_ExceptionThrown() { - StringBuilder content = new StringBuilder(); - for (int i = 0; i < 101; i++) { - content.append("a"); - } + // given + String content = "a".repeat(100); - assertThatCode(() -> new Comment(content.toString())) + // when, then + assertThatCode(() -> new Comment(content, null)) .isInstanceOf(CommentFormatException.class) .extracting("errorCode") .isEqualTo("F0002"); } - @DisplayName("댓글은 null이거나 빈 문자열이어서는 안 된다.") + @DisplayName("댓글은 null이거나 빈 문자열(공백만 있는 문자열 포함)이면 생성할 수 없다.") @ParameterizedTest @NullAndEmptySource + @ValueSource(strings = {" ", " "}) void newComment_NullOrEmpty_ExceptionThrown(String content) { - assertThatCode(() -> new Comment(content)) + // when, then + assertThatCode(() -> new Comment(content, null)) .isInstanceOf(CommentFormatException.class) .extracting("errorCode") .isEqualTo("F0002"); diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/content/ImageTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/content/ImageTest.java new file mode 100644 index 000000000..ee97f6922 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/content/ImageTest.java @@ -0,0 +1,30 @@ +package com.woowacourse.pickgit.unit.post.domain.content; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.woowacourse.pickgit.post.domain.Post; +import com.woowacourse.pickgit.post.domain.content.Image; +import java.lang.reflect.Field; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class ImageTest { + + @DisplayName("Image와 연관관계를 같는 post를 바인딩 한다.") + @Test + void toPost() throws NoSuchFieldException, IllegalAccessException { + //given + Post post = Post.builder().id(1L).build(); + Image testImageUrl = new Image("testImageUrl"); + + //when + testImageUrl.belongTo(post); + + //then + Field postField = Image.class.getDeclaredField("post"); + postField.setAccessible(true); + Post fieldPost = (Post) postField.get(testImageUrl); + + assertThat(post).isEqualTo(fieldPost); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/content/ImagesTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/content/ImagesTest.java new file mode 100644 index 000000000..d41db02c6 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/content/ImagesTest.java @@ -0,0 +1,79 @@ +package com.woowacourse.pickgit.unit.post.domain.content; + +import static java.util.stream.Collectors.toList; +import static org.assertj.core.api.Assertions.assertThat; + +import com.woowacourse.pickgit.post.domain.Post; +import com.woowacourse.pickgit.post.domain.content.Image; +import com.woowacourse.pickgit.post.domain.content.Images; +import java.lang.reflect.Field; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class ImagesTest { + + private List imageObjects; + private Images images; + + @BeforeEach + void setUp() { + imageObjects = List.of(new Image("imageUrl1"), + new Image("imageUrl2"), + new Image("imageUrl3"), + new Image("imageUrl4")); + + images = new Images(imageObjects); + } + + @DisplayName("이미지들의 url을 리스트로 받아온다.") + @Test + void getUrls() { + //when + List actual = images.getUrls(); + + //then + List expected = imageObjects.stream() + .map(Image::getUrl) + .collect(toList()); + + assertThat(expected).containsAll(actual); + } + + @DisplayName("각 이미지들을 연관관계가 만들어질 post와 바인딩 한다.") + @Test + void setMapping() throws NoSuchFieldException, IllegalAccessException { + //when + Post post = Post.builder().id(1L).build(); + images.belongTo(post); + + List actual = getMappedPostsOf(images); + + assertThat(actual).allMatch(p -> p.equals(post)); + } + + @SuppressWarnings("unchecked") + private List getMappedPostsOf(Images images) + throws NoSuchFieldException, IllegalAccessException { + Field imagesField = Images.class.getDeclaredField("images"); + imagesField.setAccessible(true); + + List imageObjects = (List) imagesField.get(images); + + return imageObjects.stream() + .map(this::getPostFrom) + .collect(toList()); + } + + private Post getPostFrom(Image image) { + try { + Field postField = Image.class.getDeclaredField("post"); + postField.setAccessible(true); + + return (Post) postField.get(image); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/domain/PostContentTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/content/PostContentTest.java similarity index 72% rename from backend/pick-git/src/test/java/com/woowacourse/pickgit/post/domain/PostContentTest.java rename to backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/content/PostContentTest.java index 6eae043cf..d2bee8d8d 100644 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/domain/PostContentTest.java +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/content/PostContentTest.java @@ -1,8 +1,9 @@ -package com.woowacourse.pickgit.post.domain; +package com.woowacourse.pickgit.unit.post.domain.content; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.woowacourse.pickgit.exception.post.PostFormatException; +import com.woowacourse.pickgit.post.domain.content.PostContent; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -15,9 +16,9 @@ void validate_IsOver500_ThrowsException() { String content = "hi".repeat(500); // then - assertThatThrownBy(() -> { new PostContent(content); }) + assertThatThrownBy(() -> new PostContent(content)) .isInstanceOf(PostFormatException.class) .extracting("errorCode") - .isEqualTo("F0001"); + .isEqualTo("F0004"); } } diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/like/LikeTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/like/LikeTest.java new file mode 100644 index 000000000..466745b2c --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/like/LikeTest.java @@ -0,0 +1,30 @@ +package com.woowacourse.pickgit.unit.post.domain.like; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.woowacourse.pickgit.common.factory.UserFactory; +import com.woowacourse.pickgit.post.domain.Post; +import com.woowacourse.pickgit.post.domain.like.Like; +import com.woowacourse.pickgit.user.domain.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class LikeTest { + + @DisplayName("like의 동등성를 판단한다.") + @Test + void isOwnedBy() { + //given + final String userName = "testUser1"; + + Post post = Post.builder().id(1L).build(); + User user = UserFactory.user("testUser1"); + Like like = new Like(post, user); + + //when + boolean ownedBy = like.equals(new Like(post, user)); + + //then + assertThat(ownedBy).isTrue(); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/like/LikesTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/like/LikesTest.java new file mode 100644 index 000000000..1a930ea1a --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/domain/like/LikesTest.java @@ -0,0 +1,50 @@ +package com.woowacourse.pickgit.unit.post.domain.like; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.woowacourse.pickgit.common.factory.UserFactory; +import com.woowacourse.pickgit.post.domain.Post; +import com.woowacourse.pickgit.post.domain.like.Like; +import com.woowacourse.pickgit.post.domain.like.Likes; +import com.woowacourse.pickgit.user.domain.User; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class LikesTest { + + private Likes likes; + private Post post; + + @BeforeEach + void setUp() { + post = Post.builder().id(1L).build(); + + User testUser1 = UserFactory.user(1L, "testUser1"); + User testUser2 = UserFactory.user(2L, "testUser2"); + User testUser3 = UserFactory.user(3L, "testUser3"); + + likes = new Likes(List.of( + new Like(post, testUser1), + new Like(post, testUser2), + new Like(post, testUser3) + )); + } + + @DisplayName("like의 개수를 확인한다.") + @Test + void getCounts() { + assertThat(likes.getCounts()).isEqualTo(3); + } + + @DisplayName("특정 사용자의 like가 포함되어 있는지 확인한다.") + @ParameterizedTest + @CsvSource(value = {"1, testUser1, true", "2,testUser2, true", "4, noTestUser1, false"}) + void contains(Long id, String userName, boolean expected) { + User user = UserFactory.user(id, userName); + assertThat(likes.contains(new Like(post, user))).isEqualTo(expected); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/infrastructure/GithubRepositoryApiRequesterTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/infrastructure/GithubRepositoryApiRequesterTest.java new file mode 100644 index 000000000..849320aba --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/infrastructure/GithubRepositoryApiRequesterTest.java @@ -0,0 +1,113 @@ +package com.woowacourse.pickgit.unit.post.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.woowacourse.pickgit.post.infrastructure.requester.GithubRepositoryApiRequester; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestClientException; + +class GithubRepositoryApiRequesterTest { + + private static final String BEARER_TOKEN = "rightToken"; + private static final String RIGHT_RESPONSE = "[{\"name\": \"binghe-hi\" }, {\"name\": \"doms-react\" }]"; + private static final String BAD_CREDENTIAL = "{\n" + + " \"message\": \"Bad credentials\",\n" + + " \"documentation_url\": \"https://docs.github.com/rest\"\n" + + "}"; + + private GithubRepositoryApiRequester githubRepositoryApiRequester; + + @BeforeEach + void setUp() { + setUpGithubRepositoryApiRequester((requestEntity, responseType) -> { + HttpHeaders headers = requestEntity.getHeaders(); + + List authorizations = Optional.ofNullable( + headers.get(HttpHeaders.AUTHORIZATION) + ).orElse(List.of()); + + boolean hasRightToken = authorizations.stream() + .anyMatch(authorization -> authorization.contains(BEARER_TOKEN)); + + if (!hasRightToken) { + return ResponseEntity.ok( + responseType.cast(BAD_CREDENTIAL) + ); + } + + return ResponseEntity.ok( + responseType.cast(RIGHT_RESPONSE) + ); + } + ); + } + + @DisplayName("깃허브 레포지토리를 요청하면, 정상적인 반환값을 얻는다.") + @Test + void request_requestGithubRepositoryOfUser_thenGetRightResponse() { + //when + String request = githubRepositoryApiRequester + .request(BEARER_TOKEN, "githubUrl"); + + //then + assertThat(request).isEqualTo(RIGHT_RESPONSE); + } + + @DisplayName("잘못된 토큰으로 깃허브 레포지토리 요청을 하면, badCredential 예외가 발생한다.") + @Test + void request_requestGithubRepositoryOfUserWithBadBearerToken_thenGetBadCredential() { + //when + String request = githubRepositoryApiRequester + .request("Bearer invalidToken", "githubUrl"); + + //then + assertThat(request).isEqualTo(BAD_CREDENTIAL); + } + + @DisplayName("토큰 없이 깃허브 레포지토리 요청을 하면, badCredential 예외가 발생한다.") + @Test + void request_requestGithubRepositoryWithoutBearerToken_thenGetBadCredential() { + //when + String request = githubRepositoryApiRequester + .request(null, "githubUrl"); + + //then + assertThat(request).isEqualTo(BAD_CREDENTIAL); + } + + private void setUpGithubRepositoryApiRequester( + RestClientExchangeFunctionalInterface functionalInterface + ) { + githubRepositoryApiRequester = new GithubRepositoryApiRequester( + new StubRestClient() { + @Override + public ResponseEntity exchange( + RequestEntity requestEntity, Class responseType + ) throws RestClientException { + ResponseEntity exchange = + functionalInterface.exchange(requestEntity, responseType); + + return new ResponseEntity<>( + responseType.cast(exchange.getBody()), + exchange.getHeaders(), + exchange.getStatusCode() + ); + } + } + ); + } + + private interface RestClientExchangeFunctionalInterface { + + ResponseEntity exchange( + RequestEntity requestEntity, Class responseType + ); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/infrastructure/GithubRepositoryExtractorTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/infrastructure/GithubRepositoryExtractorTest.java similarity index 62% rename from backend/pick-git/src/test/java/com/woowacourse/pickgit/post/infrastructure/GithubRepositoryExtractorTest.java rename to backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/infrastructure/GithubRepositoryExtractorTest.java index 3364b1c0b..fe5dc245c 100644 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/post/infrastructure/GithubRepositoryExtractorTest.java +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/infrastructure/GithubRepositoryExtractorTest.java @@ -1,12 +1,14 @@ -package com.woowacourse.pickgit.post.infrastructure; +package com.woowacourse.pickgit.unit.post.infrastructure; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.fasterxml.jackson.databind.ObjectMapper; +import com.woowacourse.pickgit.common.mockapi.MockRepositoryApiRequester; import com.woowacourse.pickgit.exception.platform.PlatformHttpErrorException; -import com.woowacourse.pickgit.post.domain.PlatformRepositoryExtractor; -import com.woowacourse.pickgit.post.domain.dto.RepositoryResponseDto; +import com.woowacourse.pickgit.post.domain.util.PlatformRepositoryExtractor; +import com.woowacourse.pickgit.post.domain.util.dto.RepositoryUrlAndName; +import com.woowacourse.pickgit.post.infrastructure.extractor.GithubRepositoryExtractor; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -27,15 +29,18 @@ void setUp() { new GithubRepositoryExtractor(objectMapper, new MockRepositoryApiRequester()); } - @DisplayName("Public Repository 목록을 가져온다.") + @DisplayName("깃허브 레포지토리를 요청하면, 레포지토리를 반환한다.") @Test - void extract_LoginUser_Success() { - // when - List responsesDto = - platformRepositoryExtractor.extract(ACCESS_TOKEN, USERNAME); + void extract_requestGithubRepository_returnRepositories() { + List repositories = platformRepositoryExtractor + .extract(ACCESS_TOKEN, "jipark3"); - // then - assertThat(responsesDto).hasSize(2); + assertThat(repositories) + .usingRecursiveComparison() + .isEqualTo(List.of( + createRRepositoryResponseDto("binghe-hi", null), + createRRepositoryResponseDto("doms-react", null) + )); } @DisplayName("토큰이 유효하지 않은 경우 예외가 발생한다. - 500 예외") @@ -59,4 +64,8 @@ void extract_InvalidUserName_404Exception() { .extracting("errorCode") .isEqualTo("V0001"); } + + private RepositoryUrlAndName createRRepositoryResponseDto(String name, String url) { + return new RepositoryUrlAndName(name, url); + } } diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/infrastructure/S3StorageTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/infrastructure/S3StorageTest.java new file mode 100644 index 000000000..6c6643f9d --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/infrastructure/S3StorageTest.java @@ -0,0 +1,89 @@ +package com.woowacourse.pickgit.unit.post.infrastructure; + +import static java.util.stream.Collectors.toList; +import static org.assertj.core.api.Assertions.assertThat; + +import com.woowacourse.pickgit.common.factory.FileFactory; +import com.woowacourse.pickgit.post.infrastructure.S3Storage; +import com.woowacourse.pickgit.post.infrastructure.S3Storage.StorageDto; +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.FileSystemResource; +import org.springframework.http.ResponseEntity; +import org.springframework.lang.Nullable; +import org.springframework.util.MultiValueMap; + +class S3StorageTest { + + private S3Storage s3Storage; + + @BeforeEach + void setUp() { + s3Storage = new S3Storage( + new StubRestClient() { + @Override + @SuppressWarnings({"rawtypes", "unchecked"}) + public ResponseEntity postForEntity( + String url, + @Nullable Object request, + Class responseType, + Object... uriVariables + ) { + MultiValueMap datas = (MultiValueMap) request; + + ArrayList fileSystemResources = + (ArrayList) datas.get("files"); + + if(Objects.isNull(fileSystemResources)) { + fileSystemResources = new ArrayList<>(); + } + + List fileNames = fileSystemResources.stream() + .map(FileSystemResource::getFile) + .map(File::getName) + .collect(toList()); + + return ResponseEntity.ok( + responseType.cast( + new StorageDto(fileNames) + ) + ); + } + }, + "testS3ProxyUrl" + ); + } + + @DisplayName("이미지 저장을 요청하면 url을 반환한다.") + @Test + void store_RequestToSaveImages_ReturnImageUrls() { + File testImage1File = FileFactory.getTestImage1File(); + File testImage2File = FileFactory.getTestImage2File(); + + List imageFiles = List.of( + testImage1File, testImage2File + ); + + List fileUrls = s3Storage.store(imageFiles, "testUser"); + + assertThat(fileUrls).containsExactly( + testImage1File.getName(), + testImage2File.getName() + ); + } + + @DisplayName("이미지 없이 저장을 요청하면 빈 배열을 요청한다.") + @Test + void store() { + List imageFiles = List.of(); + + List fileUrls = s3Storage.store(imageFiles, "testUser"); + + assertThat(fileUrls).isEmpty(); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/infrastructure/StubRestClient.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/infrastructure/StubRestClient.java new file mode 100644 index 000000000..8191f7a4f --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/infrastructure/StubRestClient.java @@ -0,0 +1,260 @@ +package com.woowacourse.pickgit.unit.post.infrastructure; + +import com.woowacourse.pickgit.post.domain.util.RestClient; +import java.net.URI; +import java.util.Map; +import java.util.Set; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RequestCallback; +import org.springframework.web.client.ResponseExtractor; +import org.springframework.web.client.RestClientException; + +public class StubRestClient implements RestClient { + + @Override + public T getForObject(String url, Class responseType, Object... uriVariables) + throws RestClientException { + return null; + } + + @Override + public T getForObject(String url, Class responseType, Map uriVariables) + throws RestClientException { + return null; + } + + @Override + public T getForObject(URI url, Class responseType) throws RestClientException { + return null; + } + + @Override + public ResponseEntity getForEntity(String url, Class responseType, + Object... uriVariables) throws RestClientException { + return null; + } + + @Override + public ResponseEntity getForEntity(String url, Class responseType, + Map uriVariables) throws RestClientException { + return null; + } + + @Override + public ResponseEntity getForEntity(URI url, Class responseType) + throws RestClientException { + return null; + } + + @Override + public HttpHeaders headForHeaders(String url, Object... uriVariables) + throws RestClientException { + return null; + } + + @Override + public HttpHeaders headForHeaders(String url, Map uriVariables) + throws RestClientException { + return null; + } + + @Override + public HttpHeaders headForHeaders(URI url) throws RestClientException { + return null; + } + + @Override + public URI postForLocation(String url, Object request, Object... uriVariables) + throws RestClientException { + return null; + } + + @Override + public URI postForLocation(String url, Object request, Map uriVariables) + throws RestClientException { + return null; + } + + @Override + public URI postForLocation(URI url, Object request) throws RestClientException { + return null; + } + + @Override + public T postForObject(String url, Object request, Class responseType, + Object... uriVariables) throws RestClientException { + return null; + } + + @Override + public T postForObject(String url, Object request, Class responseType, + Map uriVariables) throws RestClientException { + return null; + } + + @Override + public T postForObject(URI url, Object request, Class responseType) + throws RestClientException { + return null; + } + + @Override + public ResponseEntity postForEntity(String url, Object request, Class responseType, + Object... uriVariables) throws RestClientException { + return null; + } + + @Override + public ResponseEntity postForEntity(String url, Object request, Class responseType, + Map uriVariables) throws RestClientException { + return null; + } + + @Override + public ResponseEntity postForEntity(URI url, Object request, Class responseType) + throws RestClientException { + return null; + } + + @Override + public void put(String url, Object request, Object... uriVariables) throws RestClientException { + + } + + @Override + public void put(String url, Object request, Map uriVariables) + throws RestClientException { + + } + + @Override + public void put(URI url, Object request) throws RestClientException { + + } + + @Override + public T patchForObject(String url, Object request, Class responseType, + Object... uriVariables) throws RestClientException { + return null; + } + + @Override + public T patchForObject(String url, Object request, Class responseType, + Map uriVariables) throws RestClientException { + return null; + } + + @Override + public T patchForObject(URI url, Object request, Class responseType) + throws RestClientException { + return null; + } + + @Override + public void delete(String url, Object... uriVariables) throws RestClientException { + + } + + @Override + public void delete(String url, Map uriVariables) throws RestClientException { + + } + + @Override + public void delete(URI url) throws RestClientException { + + } + + @Override + public Set optionsForAllow(String url, Object... uriVariables) + throws RestClientException { + return null; + } + + @Override + public Set optionsForAllow(String url, Map uriVariables) + throws RestClientException { + return null; + } + + @Override + public Set optionsForAllow(URI url) throws RestClientException { + return null; + } + + @Override + public ResponseEntity exchange(String url, HttpMethod method, + HttpEntity requestEntity, Class responseType, Object... uriVariables) + throws RestClientException { + return null; + } + + @Override + public ResponseEntity exchange(String url, HttpMethod method, + HttpEntity requestEntity, Class responseType, Map uriVariables) + throws RestClientException { + return null; + } + + @Override + public ResponseEntity exchange(URI url, HttpMethod method, HttpEntity requestEntity, + Class responseType) throws RestClientException { + return null; + } + + @Override + public ResponseEntity exchange(String url, HttpMethod method, + HttpEntity requestEntity, ParameterizedTypeReference responseType, + Object... uriVariables) throws RestClientException { + return null; + } + + @Override + public ResponseEntity exchange(String url, HttpMethod method, + HttpEntity requestEntity, ParameterizedTypeReference responseType, + Map uriVariables) throws RestClientException { + return null; + } + + @Override + public ResponseEntity exchange(URI url, HttpMethod method, HttpEntity requestEntity, + ParameterizedTypeReference responseType) throws RestClientException { + return null; + } + + @Override + public ResponseEntity exchange(RequestEntity requestEntity, Class responseType) + throws RestClientException { + return null; + } + + @Override + public ResponseEntity exchange(RequestEntity requestEntity, + ParameterizedTypeReference responseType) throws RestClientException { + return null; + } + + @Override + public T execute(String url, HttpMethod method, RequestCallback requestCallback, + ResponseExtractor responseExtractor, Object... uriVariables) throws RestClientException { + return null; + } + + @Override + public T execute(String url, HttpMethod method, RequestCallback requestCallback, + ResponseExtractor responseExtractor, Map uriVariables) + throws RestClientException { + return null; + } + + @Override + public T execute(URI url, HttpMethod method, RequestCallback requestCallback, + ResponseExtractor responseExtractor) throws RestClientException { + return null; + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/presentation/PostControllerTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/presentation/PostControllerTest.java new file mode 100644 index 000000000..402ef94f5 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/presentation/PostControllerTest.java @@ -0,0 +1,799 @@ +package com.woowacourse.pickgit.unit.post.presentation; + +import static com.woowacourse.pickgit.docs.ApiDocumentUtils.getDocumentRequest; +import static com.woowacourse.pickgit.docs.ApiDocumentUtils.getDocumentResponse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; +import static org.springframework.restdocs.payload.JsonFieldType.NULL; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestPartBody; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.woowacourse.pickgit.authentication.application.OAuthService; +import com.woowacourse.pickgit.authentication.domain.user.AppUser; +import com.woowacourse.pickgit.authentication.domain.user.LoginUser; +import com.woowacourse.pickgit.common.factory.FileFactory; +import com.woowacourse.pickgit.config.InfrastructureTestConfiguration; +import com.woowacourse.pickgit.exception.post.CannotUnlikeException; +import com.woowacourse.pickgit.exception.post.CommentFormatException; +import com.woowacourse.pickgit.exception.post.DuplicatedLikeException; +import com.woowacourse.pickgit.post.application.PostService; +import com.woowacourse.pickgit.post.application.dto.request.CommentRequestDto; +import com.woowacourse.pickgit.post.application.dto.request.PostDeleteRequestDto; +import com.woowacourse.pickgit.post.application.dto.request.PostRequestDto; +import com.woowacourse.pickgit.post.application.dto.request.PostUpdateRequestDto; +import com.woowacourse.pickgit.post.application.dto.request.RepositoryRequestDto; +import com.woowacourse.pickgit.post.application.dto.response.CommentResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.LikeResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.PostImageUrlResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.PostUpdateResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.RepositoryResponseDto; +import com.woowacourse.pickgit.post.application.dto.response.RepositoryResponseDtos; +import com.woowacourse.pickgit.post.presentation.PostController; +import com.woowacourse.pickgit.post.presentation.dto.request.ContentRequest; +import com.woowacourse.pickgit.post.presentation.dto.request.PostUpdateRequest; +import com.woowacourse.pickgit.post.presentation.dto.response.LikeResponse; +import com.woowacourse.pickgit.post.presentation.dto.response.PostUpdateResponse; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +@AutoConfigureRestDocs +@Import(InfrastructureTestConfiguration.class) +@WebMvcTest(PostController.class) +@ActiveProfiles("test") +class PostControllerTest { + + private static final String USERNAME = "jipark3"; + private static final String ACCESS_TOKEN = "testToken"; + private static final String API_ACCESS_TOKEN = "oauth.access.token"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private PostService postService; + + @MockBean + private OAuthService oAuthService; + + + @DisplayName("게시물을 작성할 수 있다. - 사용자") + @Test + void write_LoginUser_Success() throws Exception { + // given + LoginUser loginUser = new LoginUser("testUser", "at"); + + given(oAuthService.validateToken(any())) + .willReturn(true); + given(oAuthService.findRequestUserByToken(any())) + .willReturn(loginUser); + given(postService.write(any(PostRequestDto.class))) + .willReturn(new PostImageUrlResponseDto(1L)); + + // when + ResultActions perform = mockMvc.perform(multipart("/api/posts") + .file(FileFactory.getTestImage1()) + .file(FileFactory.getTestImage2()) + .param(PickGit.GITHUB_REPO_URL, "https://github.com/bperhaps") + .param(PickGit.CONTENT, "content") + .param(PickGit.TAGS, new String[]{"tag1", "tag2"}) + .header(HttpHeaders.AUTHORIZATION, loginUser.getAccessToken())); + + // then + perform.andExpect(status().isCreated()); + + //documentation + perform.andDo(document("posts-post-user", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("bearer token") + ), + requestPartBody("images"), + responseHeaders( + headerWithName(HttpHeaders.LOCATION).description("게시물 주소") + )) + ); + } + + @DisplayName("게시물을 작성할 수 없다. - 게스트") + @Test + void write_GuestUser_Fail() throws Exception { + // given + given(oAuthService.validateToken(any())) + .willReturn(false); + + // when + ResultActions perform = mockMvc.perform(multipart("/api/posts") + .file(FileFactory.getTestImage1()) + .file(FileFactory.getTestImage2()) + .param(PickGit.GITHUB_REPO_URL, "https://github.com/bperhaps") + .param(PickGit.CONTENT, "content") + .param(PickGit.TAGS, new String[]{"tag1", "tag2"})); + + // then + perform + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("errorCode").value("A0001")); + + //documentation + perform.andDo(document("posts-post-guest", + getDocumentRequest(), + getDocumentResponse(), + requestPartBody("images"), + responseFields( + fieldWithPath("errorCode").type(STRING).description("에러 코드") + ) + )); + + verify(oAuthService, times(1)) + .validateToken(null); + } + + @DisplayName("특정 Post에 Comment을 추가한다.") + @Test + void addComment_ValidContent_Success() throws Exception { + // given + LoginUser loginUser = new LoginUser("kevin", "token"); + CommentResponseDto commentResponseDto = CommentResponseDto.builder() + .id(1L) + .profileImageUrl("kevin profile image url") + .authorName(loginUser.getUsername()) + .content("test Comment") + .liked(false) + .build(); + ContentRequest commentRequest = new ContentRequest("test Comment"); + + given(oAuthService.validateToken(anyString())) + .willReturn(true); + given(oAuthService.findRequestUserByToken(anyString())) + .willReturn(loginUser); + given(postService.addComment(any(CommentRequestDto.class))) + .willReturn(commentResponseDto); + + String requestBody = objectMapper.writeValueAsString(commentRequest); + String responseBody = objectMapper.writeValueAsString(commentResponseDto); + + // when + ResultActions perform = addCommentApi("/api/posts/{postId}/comments", 1L, requestBody); + + // then + perform + .andExpect(status().isOk()) + .andExpect(content().string(responseBody)); + + verify(postService, times(1)).addComment(any(CommentRequestDto.class)); + + // documentation + perform.andDo(document("comment-post", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("bearer token") + ), + pathParameters( + parameterWithName("postId").description("포스트 id") + ), + requestFields( + fieldWithPath("content").type(STRING).description("댓글 내용") + ), + responseFields( + fieldWithPath("id").type(NUMBER).description("댓글 id"), + fieldWithPath("profileImageUrl").type(STRING).description("댓글 작성자 프로필 사진"), + fieldWithPath("authorName").type(STRING).description("작성자 이름"), + fieldWithPath("content").type(STRING).description("댓글 내용"), + fieldWithPath("liked").type(BOOLEAN).description("좋아요 여부") + ) + )); + } + + @DisplayName("특정 Post에 댓글 등록 실패한다. - 빈 Comment인 경우.") + @Test + void addComment_InValidContent_ExceptionThrown() throws Exception { + // given + ContentRequest commentRequest = new ContentRequest(""); + + LoginUser loginUser = new LoginUser("kevin", "token"); + given(oAuthService.validateToken(anyString())) + .willReturn(true); + given(oAuthService.findRequestUserByToken(anyString())) + .willReturn(loginUser); + given(postService.addComment(any(CommentRequestDto.class))) + .willThrow(new CommentFormatException()); + + String requestBody = objectMapper.writeValueAsString(commentRequest); + + // when + ResultActions perform = addCommentApi("/api/posts/{postId}/comments", 1L, requestBody); + + // then + perform + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("errorCode").value("F0001")); + + verify(postService, never()).addComment(any(CommentRequestDto.class)); + + // documentation + perform.andDo(document("comment-post-emptyContent", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("bearer token") + ), + pathParameters( + parameterWithName("postId").description("포스트 id") + ), + responseFields( + fieldWithPath("errorCode").type(STRING).description("에러 코드") + ) + )); + } + + private ResultActions addCommentApi(String url, Long postId, String requestBody) + throws Exception { + return mockMvc.perform(post(url, postId) + .header(HttpHeaders.AUTHORIZATION, "Bearer test") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)); + } + + @DisplayName("사용자는 Repository 목록을 가져올 수 있다.") + @Test + void showRepositories_LoginUser_Success() throws Exception { + // given + LoginUser loginUser = new LoginUser("testUser", "at"); + + given(oAuthService.validateToken(any())) + .willReturn(true); + given(oAuthService.findRequestUserByToken(any())) + .willReturn(loginUser); + + RepositoryResponseDtos responseDto = new RepositoryResponseDtos(List.of( + new RepositoryResponseDto("pick", "https://github.com/jipark3/pick"), + new RepositoryResponseDto("git", "https://github.com/jipark3/git") + )); + String repositories = objectMapper.writeValueAsString(responseDto.getRepositoryResponseDtos()); + + given(postService.userRepositories(any(RepositoryRequestDto.class))) + .willReturn(responseDto); + + // then + ResultActions perform = mockMvc + .perform(get("/api/github/${userName}/repositories", USERNAME) + .header(HttpHeaders.AUTHORIZATION, API_ACCESS_TOKEN)) + .andExpect(status().isOk()) + .andExpect(content().string(repositories)); + + //documentation + perform.andDo(document("repositories-loggedIn", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("bearer token") + ), + pathParameters( + parameterWithName("userName").description("유저 이름") + ), + responseFields( + fieldWithPath("[].name").type(STRING).description("레포지토리 이름"), + fieldWithPath("[].html_url").type(STRING).description("레포지토리 주소") + ) + )); + } + + @DisplayName("로그인 한 사용자는 게시물을 좋아요 할 수 있다. - 성공") + @Test + void likePost_LoginUser_Success() throws Exception { + // given + LoginUser loginUser = new LoginUser("testUser", "at"); + LikeResponseDto likeResponseDto = new LikeResponseDto(1, true); + Long postId = 1L; + + given(oAuthService.validateToken(anyString())) + .willReturn(true); + given(oAuthService.findRequestUserByToken(anyString())) + .willReturn(loginUser); + given(postService.like(any(AppUser.class), anyLong())) + .willReturn(likeResponseDto); + + String likeResponse = + objectMapper.writeValueAsString( + new LikeResponse(likeResponseDto.getLikesCount(), likeResponseDto.getLiked()) + ); + + // when + ResultActions perform = + mockMvc.perform(put("/api/posts/{postId}/likes", postId) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + ACCESS_TOKEN)); + + // then + perform + .andExpect(status().isOk()) + .andExpect(content().string(likeResponse)); + + // documentation + perform.andDo(document("post-likePost-LoggedIn", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer " + ACCESS_TOKEN) + ), + pathParameters( + parameterWithName("postId").description("포스트 id") + ), + responseFields( + fieldWithPath("likesCount").type(NUMBER).description("게시물 좋아요 개수"), + fieldWithPath("liked").type(BOOLEAN).description("게시물 좋아요 여부") + ) + )); + + verify(oAuthService, times(1)) + .validateToken(ACCESS_TOKEN); + verify(oAuthService, times(1)) + .findRequestUserByToken(ACCESS_TOKEN); + verify(postService, times(1)) + .like(loginUser, postId); + } + + @DisplayName("로그인 한 사용자는 게시물을 좋아요 취소 할 수 있다. - 성공") + @Test + void unlikePost_LoginUser_Success() throws Exception { + // given + LoginUser loginUser = new LoginUser("testUser", "at"); + LikeResponseDto likeResponseDto = new LikeResponseDto(0, false); + Long postId = 1L; + + given(oAuthService.validateToken(any())) + .willReturn(true); + given(oAuthService.findRequestUserByToken(any())) + .willReturn(loginUser); + given(postService.unlike(any(AppUser.class), anyLong())) + .willReturn(likeResponseDto); + + String likeResponse = + objectMapper.writeValueAsString( + new LikeResponse(likeResponseDto.getLikesCount(), likeResponseDto.getLiked()) + ); + + // when + ResultActions perform = + mockMvc.perform(delete("/api/posts/{postId}/likes", postId) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + ACCESS_TOKEN)); + + // then + perform + .andExpect(status().isOk()) + .andExpect(content().string(likeResponse)); + + // documentation + perform.andDo(document("post-unlikePost-LoggedIn", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer " + ACCESS_TOKEN) + ), + pathParameters( + parameterWithName("postId").description("포스트 id") + ), + responseFields( + fieldWithPath("likesCount").type(NUMBER).description("게시물 좋아요 개수"), + fieldWithPath("liked").type(BOOLEAN).description("게시물 좋아요 여부") + ) + )); + + verify(oAuthService, times(1)) + .validateToken(ACCESS_TOKEN); + verify(oAuthService, times(1)) + .findRequestUserByToken(ACCESS_TOKEN); + verify(postService, times(1)) + .unlike(loginUser, postId); + } + + @DisplayName("사용자는 좋아요한 게시물을 중복 좋아요 추가 할 수 없다. - 실패") + @Test + void likePost_DuplicatedLike_ExceptionThrown() throws Exception { + // given + LoginUser loginUser = new LoginUser("testUser", "at"); + Long postId = 1L; + + given(oAuthService.validateToken(any())) + .willReturn(true); + given(oAuthService.findRequestUserByToken(any())) + .willReturn(loginUser); + given(postService.like(any(AppUser.class), anyLong())) + .willThrow(new DuplicatedLikeException()); + + // when + ResultActions perform = + mockMvc.perform(put("/api/posts/{postId}/likes", postId) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + ACCESS_TOKEN)); + + // then + perform + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("errorCode").value("P0003")); + + // documentation + perform.andDo(document("post-likePost-duplicatedLike", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer " + ACCESS_TOKEN) + ), + pathParameters( + parameterWithName("postId").description("포스트 id") + ), + responseFields( + fieldWithPath("errorCode").type(STRING).description("에러 코드") + ) + )); + + verify(oAuthService, times(1)) + .validateToken(ACCESS_TOKEN); + verify(oAuthService, times(1)) + .findRequestUserByToken(ACCESS_TOKEN); + verify(postService, times(1)) + .like(loginUser, postId); + } + + @DisplayName("사용자는 좋아요 하지 않은 게시물을 좋아요 취소 할 수 없다. - 실패") + @Test + void unlikePost_unlikePost_ExceptionThrown() throws Exception { + // given + LoginUser loginUser = new LoginUser("testUser", "at"); + Long postId = 1L; + + given(oAuthService.validateToken(any())) + .willReturn(true); + given(oAuthService.findRequestUserByToken(any())) + .willReturn(loginUser); + given(postService.unlike(any(AppUser.class), anyLong())) + .willThrow(new CannotUnlikeException()); + + // when + ResultActions perform = + mockMvc.perform(delete("/api/posts/{postId}/likes", postId) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + ACCESS_TOKEN)); + + // then + perform + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("errorCode").value("P0004")); + + // documentation + perform.andDo(document("post-unlikePost-unlikedPost", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer " + ACCESS_TOKEN) + ), + pathParameters( + parameterWithName("postId").description("포스트 id") + ), + responseFields( + fieldWithPath("errorCode").type(STRING).description("에러 코드") + ) + )); + + verify(oAuthService, times(1)) + .validateToken(ACCESS_TOKEN); + verify(oAuthService, times(1)) + .findRequestUserByToken(ACCESS_TOKEN); + verify(postService, times(1)) + .unlike(loginUser, postId); + } + + @DisplayName("게스트는 게시물을 좋아요 할 수 없다. - 실패") + @Test + void like_GuestUser_ExceptionThrown() throws Exception { + // given + given(oAuthService.validateToken(any())) + .willReturn(false); + + // when + ResultActions perform = + mockMvc.perform(put("/api/posts/{postId}/likes", 1L)); + + // then + perform + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("errorCode").value("A0001")); + + // documentation + perform.andDo(document("post-likePost-unLoggedIn", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("postId").description("포스트 id") + ), + responseFields( + fieldWithPath("errorCode").type(STRING).description("에러 코드") + ) + )); + + verify(oAuthService, times(1)) + .validateToken(any()); + } + + @DisplayName("게스트는 게시물을 좋아요 취소 할 수 없다. - 실패") + @Test + void unlike_GuestUser_ExceptionThrown() throws Exception { + // given + given(oAuthService.validateToken(any())) + .willReturn(false); + + // when + ResultActions perform = + mockMvc.perform(delete("/api/posts/{postId}/likes", 1L)); + + // then + perform + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("errorCode").value("A0001")); + + // documentation + perform.andDo(document("post-unlikePost-unLoggedIn", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("postId").description("포스트 id") + ), + responseFields( + fieldWithPath("errorCode").type(STRING).description("에러 코드") + ) + )); + + verify(oAuthService, times(1)) + .validateToken(any()); + } + + private static class PickGit { + + public static final String GITHUB_REPO_URL = "githubRepoUrl"; + public static final String CONTENT = "content"; + public static final String TAGS = "tags"; + } + + @DisplayName("사용자는 게시물을 수정한다.") + @Test + void update_LoginUser_Success() throws Exception { + // given + LoginUser user = new LoginUser("testUser", "Bearer testToken"); + + PostUpdateResponseDto updateResponseDto = PostUpdateResponseDto.builder() + .tags(List.of("java", "spring")) + .content("hello") + .build(); + + given(oAuthService.validateToken(any())) + .willReturn(true); + given(oAuthService.findRequestUserByToken(any())) + .willReturn(user); + given(postService.update(any(PostUpdateRequestDto.class))) + .willReturn(updateResponseDto); + + PostUpdateRequest updateRequest = PostUpdateRequest.builder() + .tags(List.of("java", "spring")) + .content("hello") + .build(); + PostUpdateResponse updateResponse = PostUpdateResponse.builder() + .tags(List.of("java", "spring")) + .content("hello") + .build(); + + String requestBody = objectMapper.writeValueAsString(updateRequest); + String responseBody = objectMapper.writeValueAsString(updateResponse); + + // when + ResultActions perform = mockMvc.perform(put("/api/posts/{postId}", 1L) + .header(HttpHeaders.AUTHORIZATION, "Bearer testToken") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(requestBody)); + + // then + perform + .andExpect(status().isCreated()) + .andExpect(content().string(responseBody)); + + verify(postService, times(1)) + .update(any(PostUpdateRequestDto.class)); + + perform.andDo(document("post-update", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer token") + ), + pathParameters( + parameterWithName("postId").description("게시물 id") + ), + requestFields( + fieldWithPath("tags").type(ARRAY).description("수정할 태그들"), + fieldWithPath("content").type(STRING).description("수정할 내용") + ), + responseFields( + fieldWithPath("tags").type(ARRAY).description("수정된 태그들"), + fieldWithPath("content").type(STRING).description("수정된 내용") + ) + )); + } + + @DisplayName("유효하지 않은 내용(null)으로 게시물을 수정할 수 없다. - 400 예외") + @Test + void update_NullContent_400Exception() throws Exception { + // given + LoginUser user = new LoginUser("testUser", "Bearer testToken"); + + given(oAuthService.validateToken(any())) + .willReturn(true); + given(oAuthService.findRequestUserByToken(any())) + .willReturn(user); + + PostUpdateRequest updateRequest = PostUpdateRequest.builder() + .tags(List.of("java", "spring")) + .content(null) + .build(); + + String requestBody = objectMapper.writeValueAsString(updateRequest); + + // when + ResultActions perform = mockMvc.perform(put("/api/posts/{postId}", 1L) + .header(HttpHeaders.AUTHORIZATION, "Bearer testToken") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(requestBody)); + + // then + perform + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("errorCode").value("F0001")); + + verify(postService, never()) + .update(any(PostUpdateRequestDto.class)); + + perform.andDo(document("post-update-nullContent", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer token") + ), + pathParameters( + parameterWithName("postId").description("게시물 id") + ), + requestFields( + fieldWithPath("tags").type(ARRAY).description("수정할 태그들"), + fieldWithPath("content").type(NULL).description("수정할 내용(null)") + ), + responseFields( + fieldWithPath("errorCode").type(STRING).description("에러 코드") + ) + )); + } + + @DisplayName("유효하지 않은 내용(500자 초)으로 게시물을 수정할 수 없다. - 400 예외") + @Test + void update_Over500Content_400Exception() throws Exception { + // given + LoginUser user = new LoginUser("testUser", "Bearer testToken"); + + given(oAuthService.validateToken(any())) + .willReturn(true); + given(oAuthService.findRequestUserByToken(any())) + .willReturn(user); + + PostUpdateRequest updateRequest = PostUpdateRequest.builder() + .tags(List.of("java", "spring")) + .content("a".repeat(501)) + .build(); + + String requestBody = objectMapper.writeValueAsString(updateRequest); + + // when + ResultActions perform = mockMvc.perform(put("/api/posts/{postId}", 1L) + .header(HttpHeaders.AUTHORIZATION, "Bearer testToken") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(requestBody)); + + // then + perform + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("errorCode").value("F0004")); + + verify(postService, never()) + .update(any(PostUpdateRequestDto.class)); + + perform.andDo(document("post-update-Over500Content", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer token") + ), + pathParameters( + parameterWithName("postId").description("게시물 id") + ), + requestFields( + fieldWithPath("tags").type(ARRAY).description("수정할 태그들"), + fieldWithPath("content").type(STRING).description("수정할 내용(500자 초과)") + ), + responseFields( + fieldWithPath("errorCode").type(STRING).description("에러 코드") + ) + )); + } + + @DisplayName("사용자는 게시물을 삭제한다.") + @Test + void delete_LoginUser_Success() throws Exception { + // given + LoginUser user = new LoginUser("testUser", "Bearer testToken"); + + given(oAuthService.validateToken(any())) + .willReturn(true); + given(oAuthService.findRequestUserByToken(any())) + .willReturn(user); + willDoNothing() + .given(postService) + .delete(any(PostDeleteRequestDto.class)); + + // when + ResultActions perform = mockMvc.perform(delete("/api/posts/{postId}", 1L) + .header(HttpHeaders.AUTHORIZATION, "Bearer testToken")); + + // then + perform + .andExpect(status().isNoContent()); + + verify(postService, times(1)) + .delete(any(PostDeleteRequestDto.class)); + + perform.andDo(document("post-delete", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer token") + ), + pathParameters( + parameterWithName("postId").description("게시물 id") + ) + )); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/presentation/PostFeedControllerTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/presentation/PostFeedControllerTest.java new file mode 100644 index 000000000..c9f03acfa --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/post/presentation/PostFeedControllerTest.java @@ -0,0 +1,159 @@ +package com.woowacourse.pickgit.unit.post.presentation; + +import static com.woowacourse.pickgit.docs.ApiDocumentUtils.getDocumentRequest; +import static com.woowacourse.pickgit.docs.ApiDocumentUtils.getDocumentResponse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.requestParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.woowacourse.pickgit.authentication.application.OAuthService; +import com.woowacourse.pickgit.authentication.domain.user.GuestUser; +import com.woowacourse.pickgit.authentication.domain.user.LoginUser; +import com.woowacourse.pickgit.common.factory.PostFactory; +import com.woowacourse.pickgit.config.InfrastructureTestConfiguration; +import com.woowacourse.pickgit.post.application.PostFeedService; +import com.woowacourse.pickgit.post.application.dto.request.HomeFeedRequestDto; +import com.woowacourse.pickgit.post.presentation.PostFeedController; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +@AutoConfigureRestDocs +@Import(InfrastructureTestConfiguration.class) +@WebMvcTest(PostFeedController.class) +@ActiveProfiles("test") +public class PostFeedControllerTest { + + private static final String API_ACCESS_TOKEN = "oauth.access.token"; + + @Autowired + private MockMvc mockMvc; + + @MockBean + private PostFeedService postfeedService; + + @MockBean + private OAuthService oAuthService; + + @DisplayName("비로그인 유저는 홈피드를 조회할 수 있다.") + @Test + void readHomeFeed_GuestUser_Success() throws Exception { + // given + given(oAuthService.findRequestUserByToken(any())) + .willReturn(new GuestUser()); + given(postfeedService.homeFeed(any(HomeFeedRequestDto.class))) + .willReturn(PostFactory.mockPostResponseDtos()); + + // when + ResultActions perform = mockMvc.perform(get("/api/posts") + .param("page", "0") + .param("limit", "3")); + + // then + perform.andExpect(status().isOk()); + + // documentation + perform.andDo(document("post-homefeed-unLoggedIn", + getDocumentRequest(), + getDocumentResponse(), + requestParameters( + parameterWithName("page").description("page"), + parameterWithName("limit").description("limit") + ), + responseFields( + fieldWithPath("[].id").type(NUMBER).description("게시물 id"), + fieldWithPath("[].imageUrls").type(ARRAY).description("이미지 주소 목록"), + fieldWithPath("[].githubRepoUrl").type(STRING).description("깃허브 주소"), + fieldWithPath("[].content").type(STRING).description("게시물 내용"), + fieldWithPath("[].authorName").type(STRING).description("작성자 이름"), + fieldWithPath("[].profileImageUrl").type(STRING).description("프로필 이미지 주소"), + fieldWithPath("[].likesCount").type(NUMBER).description("좋아요 수"), + fieldWithPath("[].tags").type(ARRAY).description("태그 목록"), + fieldWithPath("[].createdAt").type(STRING).description("글 작성 시간"), + fieldWithPath("[].updatedAt").type(STRING).description("마지막 글 수정 시간"), + fieldWithPath("[].comments").type(ARRAY).description("댓글 목록"), + fieldWithPath("[].comments[].id").type(NUMBER).description("댓글 아이디"), + fieldWithPath("[].comments[].profileImageUrl").type(STRING) + .description("댓글 작성자 프로필 사진"), + fieldWithPath("[].comments[].authorName").type(STRING).description("댓글 작성자 이름"), + fieldWithPath("[].comments[].content").type(STRING).description("댓글 내용"), + fieldWithPath("[].comments[].liked").type(BOOLEAN).description("댓글 좋아요 여부"), + fieldWithPath("[].liked").type(BOOLEAN).description("좋아요 여부") + ) + ) + ); + } + + + @DisplayName("로그인 유저는 홈피드를 조회할 수 있다.") + @Test + void readHomeFeed_LoginUser_Success() throws Exception { + // given + LoginUser loginUser = new LoginUser("testUser", "at"); + + given(oAuthService.validateToken(any())) + .willReturn(true); + given(oAuthService.findRequestUserByToken(any())) + .willReturn(loginUser); + given(postfeedService.homeFeed(any(HomeFeedRequestDto.class))) + .willReturn(PostFactory.mockPostResponseDtos()); + + // when + ResultActions perform = mockMvc.perform(get("/api/posts") + .param("page", "0") + .param("limit", "3") + .header(HttpHeaders.AUTHORIZATION, API_ACCESS_TOKEN)); + + // then + perform + .andExpect(status().isOk()); + + perform.andDo(document("post-homefeed-LoggedIn", + getDocumentRequest(), + getDocumentResponse(), + requestParameters( + parameterWithName("page").description("page"), + parameterWithName("limit").description("limit") + ), + responseFields( + fieldWithPath("[].id").type(NUMBER).description("게시물 id"), + fieldWithPath("[].imageUrls").type(ARRAY).description("이미지 주소 목록"), + fieldWithPath("[].githubRepoUrl").type(STRING).description("깃허브 주소"), + fieldWithPath("[].content").type(STRING).description("게시물 내용"), + fieldWithPath("[].authorName").type(STRING).description("작성자 이름"), + fieldWithPath("[].profileImageUrl").type(STRING).description("프로필 이미지 주소"), + fieldWithPath("[].likesCount").type(NUMBER).description("좋아요 수"), + fieldWithPath("[].tags").type(ARRAY).description("태그 목록"), + fieldWithPath("[].createdAt").type(STRING).description("글 작성 시간"), + fieldWithPath("[].updatedAt").type(STRING).description("마지막 글 수정 시간"), + fieldWithPath("[].comments").type(ARRAY).description("댓글 목록"), + fieldWithPath("[].comments[].id").type(NUMBER).description("댓글 아이디"), + fieldWithPath("[].comments[].profileImageUrl").type(STRING) + .description("댓글 작성자 프로필 사진"), + fieldWithPath("[].comments[].authorName").type(STRING).description("댓글 작성자 이름"), + fieldWithPath("[].comments[].content").type(STRING).description("댓글 내용"), + fieldWithPath("[].comments[].liked").type(BOOLEAN).description("댓글 좋아요 여부"), + fieldWithPath("[].liked").type(BOOLEAN).description("좋아요 여부") + ) + ) + ); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/application/TagServiceTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/tag/application/TagServiceTest.java similarity index 60% rename from backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/application/TagServiceTest.java rename to backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/tag/application/TagServiceTest.java index 4a37f5b65..daaf194e0 100644 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/application/TagServiceTest.java +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/tag/application/TagServiceTest.java @@ -1,7 +1,8 @@ -package com.woowacourse.pickgit.tag.application; +package com.woowacourse.pickgit.unit.tag.application; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.times; @@ -9,6 +10,9 @@ import com.woowacourse.pickgit.exception.platform.PlatformHttpErrorException; import com.woowacourse.pickgit.exception.post.TagFormatException; +import com.woowacourse.pickgit.tag.application.ExtractionRequestDto; +import com.woowacourse.pickgit.tag.application.TagService; +import com.woowacourse.pickgit.tag.application.TagsDto; import com.woowacourse.pickgit.tag.domain.PlatformTagExtractor; import com.woowacourse.pickgit.tag.domain.Tag; import com.woowacourse.pickgit.tag.domain.TagRepository; @@ -19,10 +23,12 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.HttpStatus; @ExtendWith(MockitoExtension.class) class TagServiceTest { @@ -36,37 +42,52 @@ class TagServiceTest { @Mock private TagRepository tagRepository; - private String accessToken = "abc"; - private String userName = "asap"; - private String repositoryName = "next-level"; + private final String accessToken = "abc"; + private final String userName = "asap"; + private final String repositoryName = "next-level"; @DisplayName("Repository에 포함된 언어 태그를 추출한다.") @Test void extractTags_ValidRepository_ExtractionSuccess() { - ExtractionRequestDto extractionRequestDto = - new ExtractionRequestDto(accessToken, userName, repositoryName); + // given + ExtractionRequestDto extractionRequestDto = ExtractionRequestDto + .builder() + .accessToken(accessToken) + .userName(userName) + .repositoryName(repositoryName) + .build(); List tags = Arrays.asList("Java", "HTML", "CSS"); + // mock given(platformTagExtractor.extractTags(accessToken, userName, repositoryName)) .willReturn(tags); + // when TagsDto tagsDto = tagService.extractTags(extractionRequestDto); - assertThat(tagsDto.getTags()).containsAll(tags); + // then + assertThat(tagsDto.getTagNames()).containsAll(Arrays.asList("java", "html", "css")); verify(platformTagExtractor, times(1)).extractTags(accessToken, userName, repositoryName); } - @DisplayName("잘못된 경로로 태그 추출 요청시 404 예외가 발생한다.") + @DisplayName("잘못된 경로로 태그 추출 요청시 예외가 발생한다.") @Test void extractTags_InvalidUrl_ExceptionThrown() { + // given String userName = "nonuser"; String repositoryName = "nonrepo"; - ExtractionRequestDto extractionRequestDto = - new ExtractionRequestDto(accessToken, userName, repositoryName); - + ExtractionRequestDto extractionRequestDto = ExtractionRequestDto + .builder() + .accessToken(accessToken) + .userName(userName) + .repositoryName(repositoryName) + .build(); + + // mock given(platformTagExtractor.extractTags(accessToken, userName, repositoryName)) .willThrow(new PlatformHttpErrorException()); + // when, then assertThatCode(() -> tagService.extractTags(extractionRequestDto)) .isInstanceOf(PlatformHttpErrorException.class) .extracting("errorCode") @@ -75,16 +96,23 @@ void extractTags_InvalidUrl_ExceptionThrown() { .extractTags(accessToken, userName, repositoryName); } - @DisplayName("유효하지 않은 토큰으로 태그 추출 요청시 401 예외가 발생한다.") + @DisplayName("유효하지 않은 토큰으로 태그 추출 요청시 예외가 발생한다.") @Test void extractTags_InvalidToken_ExceptionThrown() { + // given String accessToken = "invalidtoken"; - ExtractionRequestDto extractionRequestDto = - new ExtractionRequestDto(accessToken, userName, repositoryName); - + ExtractionRequestDto extractionRequestDto = ExtractionRequestDto + .builder() + .accessToken(accessToken) + .userName(userName) + .repositoryName(repositoryName) + .build(); + + // mock given(platformTagExtractor.extractTags(accessToken, userName, repositoryName)) .willThrow(new PlatformHttpErrorException()); + // when, then assertThatCode(() -> tagService.extractTags(extractionRequestDto)) .isInstanceOf(PlatformHttpErrorException.class) .extracting("errorCode") @@ -96,9 +124,15 @@ void extractTags_InvalidToken_ExceptionThrown() { @DisplayName("태그 이름을 태그로 변환한다.") @Test void findOrCreateTags_ValidTag_TransformationSuccess() { - List tagNames = Arrays.asList("tag1", "tag2", "tag3"); + // given + List tagNames = Arrays.asList("Tag1", "tag2", "tag3"); TagsDto tagsDto = new TagsDto(tagNames); + // mock + given(tagRepository.save(new Tag("tag1"))) + .willReturn(new Tag("tag1")); + given(tagRepository.save(new Tag("tag2"))) + .willReturn(new Tag("tag2")); given(tagRepository.findByName("tag1")) .willReturn(Optional.empty()); given(tagRepository.findByName("tag2")) @@ -106,32 +140,50 @@ void findOrCreateTags_ValidTag_TransformationSuccess() { given(tagRepository.findByName("tag3")) .willReturn(Optional.of(new Tag("tag3"))); + // when List tags = tagService.findOrCreateTags(tagsDto) .stream() .map(Tag::getName) .collect(Collectors.toList()); - assertThat(tags).containsAll(tagNames); + // then + assertThat(tags).containsAll(Arrays.asList("tag1", "tag2", "tag3")); verify(tagRepository, times(3)).findByName(anyString()); } @DisplayName("잘못된 태그 이름을 태그로 변환 시도시 실패한다.") - @Test - void findOrCreateTags_InvalidTagName_ExceptionThrown() { - List tagNames = Arrays.asList("tag1", "tag2", ""); + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", " ", "abcdeabcdeabcdeabcdea"}) + void findOrCreateTags_InvalidTagName_ExceptionThrown(String tagName) { + // given + List tagNames = Arrays.asList("tag1", "tag2", tagName); TagsDto tagsDto = new TagsDto(tagNames); + // mock given(tagRepository.findByName("tag1")) .willReturn(Optional.empty()); given(tagRepository.findByName("tag2")) .willReturn(Optional.empty()); - given(tagRepository.findByName("")) - .willReturn(Optional.empty()); + // when, then assertThatCode(() -> tagService.findOrCreateTags(tagsDto)) .isInstanceOf(TagFormatException.class) .extracting("errorCode") .isEqualTo("F0003"); - verify(tagRepository, times(3)).findByName(anyString()); + verify(tagRepository, times(2)).findByName(any()); + } + + @DisplayName("태그 이름이 없다면 빈 태그를 반환한다.") + @Test + void findOrCreateTags_NoneTagName_ReturnEmptyList() { + // given + TagsDto tagsDto = new TagsDto(null); + + // when + List tags = tagService.findOrCreateTags(tagsDto); + + // then + assertThat(tags.size()).isZero(); } } diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/tag/domain/TagRepositoryTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/tag/domain/TagRepositoryTest.java new file mode 100644 index 000000000..ee787ffc4 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/tag/domain/TagRepositoryTest.java @@ -0,0 +1,70 @@ +package com.woowacourse.pickgit.unit.tag.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.woowacourse.pickgit.tag.domain.Tag; +import com.woowacourse.pickgit.tag.domain.TagRepository; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.dao.DataIntegrityViolationException; + +@DataJpaTest +public class TagRepositoryTest { + + @Autowired + private TagRepository tagRepository; + + @Autowired + private TestEntityManager entityManager; + + @DisplayName("태그를 저장하고 조회할 수 있다.") + @Test + void saveAndFindByName_FindSavedTag_Success() { + // given + Tag savedTag = tagRepository.save(new Tag("java")); + + entityManager.flush(); + entityManager.clear(); + + // when + Tag findTag = tagRepository.findByName(savedTag.getName()) + .orElse(null); + + // then + assertThat(findTag).isNotNull(); + assertThat(savedTag.getId()).isEqualTo(findTag.getId()); + assertThat(savedTag.getName()).isEqualTo(findTag.getName()); + } + + @DisplayName("존재하지 않는 태그 조회시 Optional.empty를 반환한다.") + @Test + void findByName_FindNonExistsTag_ReturnOptionalEmpty() { + // given + String name = "nonexists tag name"; + + // when + Optional findTag = tagRepository.findByName(name); + + // then + assertThat(findTag).isEqualTo(Optional.empty()); + } + + @DisplayName("이미 존재하는 태그(이름)을 저장하면 예외가 발생한다.") + @Test + void save_DuplicateTagName_Fail() { + // given + Tag savedTag = tagRepository.save(new Tag("java")); + + entityManager.flush(); + entityManager.clear(); + + // when, then + assertThatThrownBy(() -> tagRepository.save(new Tag(savedTag.getName()))) + .isInstanceOf(DataIntegrityViolationException.class); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/domain/TagTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/tag/domain/TagTest.java similarity index 67% rename from backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/domain/TagTest.java rename to backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/tag/domain/TagTest.java index 51e05d13f..4e2e4a879 100644 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/domain/TagTest.java +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/tag/domain/TagTest.java @@ -1,8 +1,10 @@ -package com.woowacourse.pickgit.tag.domain; +package com.woowacourse.pickgit.unit.tag.domain; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import com.woowacourse.pickgit.exception.post.TagFormatException; +import com.woowacourse.pickgit.tag.domain.Tag; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -21,11 +23,21 @@ void newTag_ValidName_Success() { @DisplayName("태그 이름이 null이거나 빈 문자열이거나 20자를 넘어가면 예외가 발생한다.") @ParameterizedTest @NullAndEmptySource - @ValueSource(strings = {"abcdeabcdeabcdeabcdea"}) + @ValueSource(strings = {"abcdeabcdeabcdeabcdea", " ", " "}) void newTag_InvalidName_Failure(String name) { assertThatCode(() -> new Tag(name)) .isInstanceOf(TagFormatException.class) .extracting("errorCode") .isEqualTo("F0003"); } + + @DisplayName("태그 이름은 소문자로 자동 변환된다.") + @Test + void newTag_ToLowerCase_Success() { + // given + Tag tag = new Tag("Java"); + + // when, then + assertThat(tag.getName()).isEqualTo("java"); + } } diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/infrastructure/GithubTagExtractorTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/tag/infrastructure/GithubTagExtractorTest.java similarity index 74% rename from backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/infrastructure/GithubTagExtractorTest.java rename to backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/tag/infrastructure/GithubTagExtractorTest.java index ae9f1aef8..9bdec6f3d 100644 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/infrastructure/GithubTagExtractorTest.java +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/tag/infrastructure/GithubTagExtractorTest.java @@ -1,11 +1,14 @@ -package com.woowacourse.pickgit.tag.infrastructure; +package com.woowacourse.pickgit.unit.tag.infrastructure; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.woowacourse.pickgit.common.mockapi.MockTagApiRequester; import com.woowacourse.pickgit.exception.platform.PlatformHttpErrorException; import com.woowacourse.pickgit.tag.domain.PlatformTagExtractor; +import com.woowacourse.pickgit.tag.infrastructure.GithubTagExtractor; +import com.woowacourse.pickgit.tag.infrastructure.PlatformTagApiRequester; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -18,12 +21,12 @@ class GithubTagExtractorTest { private static final String REPOSITORY_NAME = "doms-react"; private PlatformTagExtractor platformTagExtractor; - private ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper = new ObjectMapper(); @BeforeEach void setUp() { - PlatformApiRequester platformApiRequester = new MockTagApiRequester(); - platformTagExtractor = new GithubTagExtractor(platformApiRequester, objectMapper); + PlatformTagApiRequester platformTagApiRequester = new MockTagApiRequester(); + platformTagExtractor = new GithubTagExtractor(platformTagApiRequester, objectMapper); } @DisplayName("명시된 User의 Repository에 기술된 Language Tags를 추출한다.") @@ -45,7 +48,7 @@ void extractTags_InvalidAccessToken_ExceptionThrown() { .isEqualTo("V0001"); } - @DisplayName("username/repositoryname 링크에 해당하는 경로가 존재하지 않으면 조회 예외가 발생한다.") + @DisplayName("요청 URL 링크(username/repositoryname)에 해당하는 경로가 존재하지 않으면 조회 예외가 발생한다.") @Test void extractTags_InvalidUrl_ExceptionThrown() { assertThatCode(() -> { diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/presentation/TagControllerTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/tag/presentation/TagControllerTest.java similarity index 93% rename from backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/presentation/TagControllerTest.java rename to backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/tag/presentation/TagControllerTest.java index 3b1dee6b4..66c6e28ea 100644 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/tag/presentation/TagControllerTest.java +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/tag/presentation/TagControllerTest.java @@ -1,4 +1,4 @@ -package com.woowacourse.pickgit.tag.presentation; +package com.woowacourse.pickgit.unit.tag.presentation; import static com.woowacourse.pickgit.docs.ApiDocumentUtils.getDocumentRequest; import static com.woowacourse.pickgit.docs.ApiDocumentUtils.getDocumentResponse; @@ -9,13 +9,13 @@ import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; import static org.springframework.restdocs.payload.JsonFieldType.STRING; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -27,23 +27,21 @@ import com.woowacourse.pickgit.tag.application.ExtractionRequestDto; import com.woowacourse.pickgit.tag.application.TagService; import com.woowacourse.pickgit.tag.application.TagsDto; +import com.woowacourse.pickgit.tag.presentation.TagController; import java.util.Arrays; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.HttpHeaders; -import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; @AutoConfigureRestDocs -@ExtendWith(SpringExtension.class) @WebMvcTest(TagController.class) class TagControllerTest { @@ -59,9 +57,9 @@ class TagControllerTest { @MockBean private OAuthService oAuthService; - private String accessToken = "Bearer validtoken"; - private String userName = "abc"; - private String repositoryName = "repo"; + private final String accessToken = "Bearer validtoken"; + private final String userName = "abc"; + private final String repositoryName = "repo"; @BeforeEach void setUp() { @@ -75,16 +73,19 @@ void setUp() { @DisplayName("특정 User의 Repository에 기술된 언어 태그들을 추출한다.") @Test void extractLanguageTags_ValidRepository_ExtractionSuccess() throws Exception { + // given String url = "/api/github/repositories/{repositoryName}}/tags/languages"; List tags = Arrays.asList("Java", "Python", "HTML"); TagsDto tagsDto = new TagsDto(tags); - String expectedResponse = objectMapper.writeValueAsString(tagsDto.getTags()); + String expectedResponse = objectMapper.writeValueAsString(tagsDto.getTagNames()); + // mock given(tagService.extractTags(any(ExtractionRequestDto.class))) .willReturn(tagsDto); + // when, then ResultActions perform = mockMvc.perform(get(url, repositoryName) .header("Authorization", accessToken)) .andExpect(status().isOk()) @@ -93,6 +94,7 @@ void extractLanguageTags_ValidRepository_ExtractionSuccess() throws Exception { verify(tagService, times(1)) .extractTags(any(ExtractionRequestDto.class)); + // restdocs perform.andDo(document("tag-extractTagFromRepositoryOfSpecificUser", getDocumentRequest(), getDocumentResponse(), @@ -111,17 +113,21 @@ void extractLanguageTags_ValidRepository_ExtractionSuccess() throws Exception { @DisplayName("유효하지 않은 AccessToken으로 태그 추출 요청시 401 예외 메시지가 반환된다.") @Test void extractLanguageTags_InvalidAccessToken_ExceptionThrown() throws Exception { + // given String url = "/api/github/repositories/{repositoryName}}/tags/languages"; + // mock given(oAuthService.validateToken(any(String.class))) .willReturn(false); + // when, then ResultActions perform = mockMvc.perform(get(url, userName) .header("Authorization", "Bearer invalid")) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("errorCode").value("A0001")); + // restdocs perform.andDo(document("tags-invalidToken", getDocumentRequest(), getDocumentResponse(), @@ -140,12 +146,15 @@ void extractLanguageTags_InvalidAccessToken_ExceptionThrown() throws Exception { @DisplayName("유효하지 않은 레포지토리 태그 추출 요청시 404 예외 메시지가 반환된다.") @Test void extractLanguageTags_InvalidRepository_ExceptionThrown() throws Exception { + // given String url = "/api/github/repositories/{repositoryName}}/tags/languages"; + // mock given(tagService.extractTags(any(ExtractionRequestDto.class))) .willThrow(new PlatformHttpErrorException()); + // when, then ResultActions perform = mockMvc.perform(get(url, userName, "invalidrepo") .header("Authorization", accessToken)) .andExpect(status().isInternalServerError()) @@ -154,6 +163,7 @@ void extractLanguageTags_InvalidRepository_ExceptionThrown() throws Exception { verify(tagService, times(1)) .extractTags(any(ExtractionRequestDto.class)); + // restdocs perform.andDo(document("tags-invalidRepository", getDocumentRequest(), getDocumentResponse(), diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/application/UserServiceTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/application/UserServiceTest.java new file mode 100644 index 000000000..0e194731c --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/application/UserServiceTest.java @@ -0,0 +1,779 @@ +package com.woowacourse.pickgit.unit.user.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.woowacourse.pickgit.authentication.domain.user.AppUser; +import com.woowacourse.pickgit.authentication.domain.user.GuestUser; +import com.woowacourse.pickgit.authentication.domain.user.LoginUser; +import com.woowacourse.pickgit.common.factory.FileFactory; +import com.woowacourse.pickgit.common.factory.UserFactory; +import com.woowacourse.pickgit.exception.authentication.UnauthorizedException; +import com.woowacourse.pickgit.exception.user.DuplicateFollowException; +import com.woowacourse.pickgit.exception.user.InvalidFollowException; +import com.woowacourse.pickgit.exception.user.InvalidUserException; +import com.woowacourse.pickgit.exception.user.SameSourceTargetUserException; +import com.woowacourse.pickgit.post.domain.repository.PickGitStorage; +import com.woowacourse.pickgit.user.application.UserService; +import com.woowacourse.pickgit.user.application.dto.request.AuthUserRequestDto; +import com.woowacourse.pickgit.user.application.dto.request.ProfileEditRequestDto; +import com.woowacourse.pickgit.user.application.dto.request.UserSearchRequestDto; +import com.woowacourse.pickgit.user.application.dto.response.ContributionResponseDto; +import com.woowacourse.pickgit.user.application.dto.response.FollowResponseDto; +import com.woowacourse.pickgit.user.application.dto.response.ProfileEditResponseDto; +import com.woowacourse.pickgit.user.application.dto.response.UserProfileResponseDto; +import com.woowacourse.pickgit.user.application.dto.response.UserSearchResponseDto; +import com.woowacourse.pickgit.user.domain.Contribution; +import com.woowacourse.pickgit.user.domain.PlatformContributionCalculator; +import com.woowacourse.pickgit.user.domain.User; +import com.woowacourse.pickgit.user.domain.UserRepository; +import java.io.File; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.web.multipart.MultipartFile; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @InjectMocks + private UserService userService; + + @Mock + private UserRepository userRepository; + + @Mock + private PickGitStorage pickGitStorage; + + @Mock + private PlatformContributionCalculator platformContributionCalculator; + + @DisplayName("getMyUserProfile 메서드는") + @Nested + class Describe_getMyUserProfile { + + @DisplayName("로그인 되어있을 때") + @Nested + class Context_Login { + + @DisplayName("사용자는 내 프로필을 조회할 수 있다.") + @Test + void getMyUserProfile_WithMyName_Success() { + // given + User loginUser = UserFactory.user(); + String username = loginUser.getName(); + AuthUserRequestDto requestDto = createLoginAuthUserRequestDto(username); + + given(userRepository.findByBasicProfile_Name(username)) + .willReturn(Optional.of(loginUser)); + + UserProfileResponseDto responseDto = UserFactory.mockLoginUserProfileResponseDto(); + + // when + UserProfileResponseDto myUserProfile = userService.getMyUserProfile(requestDto); + + // then + assertThat(myUserProfile) + .usingRecursiveComparison() + .isEqualTo(responseDto); + + verify(userRepository, times(1)) + .findByBasicProfile_Name(username); + } + } + + @DisplayName("비로그인되어 있으면") + @Nested + class Context_Guest { + + @DisplayName("사용자는 내 프로필을 조회할 수 없다.") + @Test + void getMyUserProfile_Guest_Failure() { + // given + AuthUserRequestDto requestDto = createGuestAuthUserRequestDto(); + + // when, then + assertThatCode(() -> userService.getMyUserProfile(requestDto)) + .isInstanceOf(UnauthorizedException.class); + } + } + } + + @DisplayName("getUserProfile 메서드는") + @Nested + class Describe_getUserProfile { + + @DisplayName("게스트 유저일 때") + @Nested + class Context_GuestUser { + + @DisplayName("유저 이름으로 검색하여 유저의 프로필을 조회할 수 있다.") + @Test + void getUserProfile_FindByNameInCaseOfGuestUser_Success() { + //given + AuthUserRequestDto authUserRequestDto = createGuestAuthUserRequestDto(); + User targetUser = UserFactory.user("testUser"); + String targetUsername = targetUser.getName(); + + given(userRepository.findByBasicProfile_Name(targetUsername)) + .willReturn(Optional.of(targetUser)); + + UserProfileResponseDto responseDto = UserFactory.mockGuestUserProfileResponseDto(); + + //when + UserProfileResponseDto userProfile = userService + .getUserProfile(authUserRequestDto, targetUsername); + + //then + assertThat(userProfile) + .usingRecursiveComparison() + .isEqualTo(responseDto); + + verify(userRepository, times(1)) + .findByBasicProfile_Name(targetUsername); + } + + @DisplayName("존재하지 않는 유저 이름으로 프로필을 조회할 수 없다. - 400 예외") + @Test + void getUserProfile_FindByInvalidNameInCaseOfGuestUser_400Exception() { + //given + AuthUserRequestDto authUserRequestDto = createGuestAuthUserRequestDto(); + String invalidName = "InvalidName"; + + given(userRepository.findByBasicProfile_Name(invalidName)) + .willReturn(Optional.empty()); + + //when + assertThatThrownBy( + () -> userService.getUserProfile(authUserRequestDto, invalidName) + ).isInstanceOf(InvalidUserException.class) + .hasFieldOrPropertyWithValue("errorCode", "U0001") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("유효하지 않은 유저입니다."); + + // then + verify(userRepository, times(1)) + .findByBasicProfile_Name(invalidName); + } + } + + @DisplayName("로그인 유저일 때") + @Nested + class Context_LoginUser { + + @DisplayName("팔로잉 중인 유저의 프로필을 조회할 수 있다.") + @Test + void getUserProfile_FindByNameInCaseOfLoginUserIsFollowing_Success() { + //given + User loginUser = UserFactory.user(1L, "testUser"); + String loginUsername = loginUser.getName(); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUsername); + User targetUser = UserFactory.user(2L, "testUser2"); + String targetUsername = targetUser.getName(); + + given(userRepository.findByBasicProfile_Name(targetUsername)) + .willReturn(Optional.of(targetUser)); + given(userRepository.findByBasicProfile_Name(loginUsername)) + .willReturn(Optional.of(loginUser)); + + UserProfileResponseDto responseDto = + UserFactory.mockLoginUserProfileIsFollowingResponseDto(); + + userService.followUser(authUserRequestDto, targetUsername); + + //when + UserProfileResponseDto userProfile = userService + .getUserProfile(authUserRequestDto, targetUsername); + + //then + assertThat(userProfile) + .usingRecursiveComparison() + .isEqualTo(responseDto); + + verify(userRepository, times(2)) + .findByBasicProfile_Name(targetUsername); + verify(userRepository, times(2)) + .findByBasicProfile_Name(loginUsername); + } + + @DisplayName("팔로잉하지 않은 유저의 프로필을 조회할 수 있다.") + @Test + void getUserProfile_FindByNameInCaseOfLoginUseIsNotFollowing_Success() { + //given + User loginUser = UserFactory.user("testUser"); + String loginUsername = loginUser.getName(); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUsername); + User targetUser = UserFactory.user("testUser2"); + String targetUsername = targetUser.getName(); + + given(userRepository.findByBasicProfile_Name(targetUsername)) + .willReturn(Optional.of(targetUser)); + given(userRepository.findByBasicProfile_Name(loginUsername)) + .willReturn(Optional.of(loginUser)); + + UserProfileResponseDto responseDto = + UserFactory.mockLoginUserProfileIsNotFollowingResponseDto(); + + //when + UserProfileResponseDto userProfile = userService + .getUserProfile(authUserRequestDto, targetUsername); + + //then + assertThat(userProfile) + .usingRecursiveComparison() + .isEqualTo(responseDto); + + verify(userRepository, times(1)) + .findByBasicProfile_Name(targetUsername); + verify(userRepository, times(1)) + .findByBasicProfile_Name(loginUsername); + } + + @DisplayName("존재하지 않는 유저 이름으로 프로필을 조회할 수 없다. - 400 예외") + @Test + void getUserProfile_FindByInvalidNameInCaseOfLoginUser_400Exception() { + //given + User loginUser = UserFactory.user("testUser"); + String loginUsername = loginUser.getName(); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUsername); + String targetUsername = "invalidname"; + + given(userRepository.findByBasicProfile_Name(targetUsername)) + .willReturn(Optional.empty()); + + //when, then + assertThatThrownBy( + () -> userService.getUserProfile(authUserRequestDto, targetUsername) + ).isInstanceOf(InvalidUserException.class) + .hasFieldOrPropertyWithValue("errorCode", "U0001") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("유효하지 않은 유저입니다."); + + verify(userRepository, times(1)) + .findByBasicProfile_Name(targetUsername); + verify(userRepository, times(0)) + .findByBasicProfile_Name(loginUsername); + } + } + } + + @DisplayName("followUser 메서드는") + @Nested + class Describe_followUser { + + @DisplayName("비로그인되어 있으면") + @Nested + class Context_Guest { + + @DisplayName("팔로우할 수 없다.") + @Test + void follow_Guest_Failure() { + // given + AuthUserRequestDto requestDto = createGuestAuthUserRequestDto(); + + // when, then + assertThatCode(() -> userService.followUser(requestDto, "testUser")) + .isInstanceOf(UnauthorizedException.class); + } + } + + @DisplayName("Target 유저가 존재하지 않는다면") + @Nested + class Context_NotExistingOtherUser { + + @DisplayName("팔로우할 수 없다. - 400 예외") + @Test + void follow_FindByInvalidName_400Exception() { + //given + User loginUser = UserFactory.user(1L, "testUser"); + String loginUsername = loginUser.getName(); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUsername); + String invalidTargetName = "django"; + + given(userRepository.findByBasicProfile_Name(loginUsername)) + .willReturn(Optional.of(loginUser)); + given(userRepository.findByBasicProfile_Name(invalidTargetName)) + .willReturn(Optional.empty()); + + // when, then + assertThatCode(() -> userService.followUser(authUserRequestDto, invalidTargetName)) + .isInstanceOf(InvalidUserException.class) + .hasFieldOrPropertyWithValue("errorCode", "U0001") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("유효하지 않은 유저입니다."); + + verify(userRepository, times(1)) + .findByBasicProfile_Name(loginUsername); + verify(userRepository, times(1)) + .findByBasicProfile_Name(invalidTargetName); + } + } + + @DisplayName("Source 유저와 Target 유저가 동일하다면") + @Nested + class Context_SourceAndTargetUserSame { + + @DisplayName("팔로우할 수 없다. - 400 예외") + @Test + void follow_SameUser_400Exception() { + //given + User loginUser = UserFactory.user(1L, "testUser"); + String loginUsername = loginUser.getName(); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUsername); + + given(userRepository.findByBasicProfile_Name(loginUsername)) + .willReturn(Optional.of(loginUser)); + + // when, then + assertThatCode(() -> userService.followUser(authUserRequestDto, loginUsername)) + .isInstanceOf(SameSourceTargetUserException.class) + .hasFieldOrPropertyWithValue("errorCode", "U0004") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("같은 Source 와 Target 유저입니다."); + + verify(userRepository, times(2)) + .findByBasicProfile_Name(loginUsername); + } + } + + @DisplayName("Source 유저가 특정 Target 유저를 팔로우 중이지 않을 때") + @Nested + class Context_ValidOtherUser { + + @DisplayName("팔로우할 수 있다.") + @Test + void followUser_SourceToTarget_Success() { + //given + User loginUser = UserFactory.user(1L, "testUser"); + String loginUsername = loginUser.getName(); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUsername); + User targetUser = UserFactory.user(2L, "testUser2"); + String targetUsername = targetUser.getName(); + + given(userRepository.findByBasicProfile_Name(loginUsername)) + .willReturn(Optional.of(loginUser)); + given(userRepository.findByBasicProfile_Name(targetUsername)) + .willReturn(Optional.of(targetUser)); + + //when + FollowResponseDto responseDto = + userService.followUser(authUserRequestDto, targetUsername); + + //then + assertThat(responseDto.getFollowerCount()).isEqualTo(1); + assertThat(responseDto.isFollowing()).isTrue(); + + verify(userRepository, times(1)) + .findByBasicProfile_Name(loginUsername); + verify(userRepository, times(1)) + .findByBasicProfile_Name(targetUsername); + } + } + + @DisplayName("Source 유저가 특정 Target 유저를 이미 팔로우 중이라면") + @Nested + class Context_AlreadyFollowingOtherUser { + + @DisplayName("팔로우 할 수 없다.") + @Test + void followUser_ExistingFollow_400Exception() { + //given + User loginUser = UserFactory.user(1L, "testUser"); + String loginUsername = loginUser.getName(); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUsername); + User targetUser = UserFactory.user(2L, "testUser2"); + String targetUsername = targetUser.getName(); + + given(userRepository.findByBasicProfile_Name(loginUsername)) + .willReturn(Optional.of(loginUser)); + given(userRepository.findByBasicProfile_Name(targetUsername)) + .willReturn(Optional.of(targetUser)); + + userService.followUser(authUserRequestDto, targetUsername); + + //when, then + assertThatThrownBy( + () -> userService.followUser(authUserRequestDto, targetUsername) + ).isInstanceOf(DuplicateFollowException.class) + .hasFieldOrPropertyWithValue("errorCode", "U0002") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("이미 팔로우 중 입니다."); + + verify(userRepository, times(2)) + .findByBasicProfile_Name(loginUsername); + verify(userRepository, times(2)) + .findByBasicProfile_Name(targetUsername); + } + } + } + + @DisplayName("unfollowUser 메서드는") + @Nested + class Describe_unfollowUser { + + @DisplayName("비로그인되어 있으면") + @Nested + class Context_Guest { + + @DisplayName("언팔로우할 수 없다.") + @Test + void unfollow_Guest_Failure() { + // given + AuthUserRequestDto requestDto = createGuestAuthUserRequestDto(); + + // when, then + assertThatCode(() -> userService.unfollowUser(requestDto, "testUser")) + .isInstanceOf(UnauthorizedException.class); + } + } + + @DisplayName("Target 유저가 존재하지 않는다면") + @Nested + class Context_NotExistingOtherUser { + + @DisplayName("언팔로우할 수 없다. - 400 예외") + @Test + void unfollow_FindByInvalidName_400Exception() { + //given + User loginUser = UserFactory.user(1L, "testUser"); + String loginUsername = loginUser.getName(); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUsername); + String invalidTargetName = "django"; + + given(userRepository.findByBasicProfile_Name(loginUsername)) + .willReturn(Optional.of(loginUser)); + given(userRepository.findByBasicProfile_Name(invalidTargetName)) + .willReturn(Optional.empty()); + + // when, then + assertThatCode( + () -> userService.unfollowUser(authUserRequestDto, invalidTargetName)) + .isInstanceOf(InvalidUserException.class) + .hasFieldOrPropertyWithValue("errorCode", "U0001") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("유효하지 않은 유저입니다."); + + verify(userRepository, times(1)) + .findByBasicProfile_Name(loginUsername); + verify(userRepository, times(1)) + .findByBasicProfile_Name(invalidTargetName); + } + } + + @DisplayName("Source 유저와 Target 유저가 동일하다면") + @Nested + class Context_SourceAndTargetUserSame { + + @DisplayName("언팔로우할 수 없다. - 400 예외") + @Test + void unfollow_SameUser_400Exception() { + //given + User loginUser = UserFactory.user(1L, "testUser"); + String loginUsername = loginUser.getName(); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUsername); + + given(userRepository.findByBasicProfile_Name(loginUsername)) + .willReturn(Optional.of(loginUser)); + + // when, then + assertThatCode(() -> userService.unfollowUser(authUserRequestDto, loginUsername)) + .isInstanceOf(SameSourceTargetUserException.class) + .hasFieldOrPropertyWithValue("errorCode", "U0004") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("같은 Source 와 Target 유저입니다."); + + verify(userRepository, times(2)) + .findByBasicProfile_Name(loginUsername); + } + } + + @DisplayName("Source 유저가 특정 Target 유저를 이미 언팔로우 중이라면") + @Nested + class Context_InvalidOtherUser { + + @DisplayName("언팔로우할 수 없다. - 400 예외") + @Test + void unfollowUser_NotExistingFollow_400Exception() { + //given + User loginUser = UserFactory.user(1L, "testUser"); + String loginUsername = loginUser.getName(); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUsername); + User targetUser = UserFactory.user(2L, "testUser2"); + String targetUsername = targetUser.getName(); + + given(userRepository.findByBasicProfile_Name(loginUsername)) + .willReturn(Optional.of(loginUser)); + given(userRepository.findByBasicProfile_Name(targetUsername)) + .willReturn(Optional.of(targetUser)); + + //when + assertThatThrownBy( + () -> userService.unfollowUser(authUserRequestDto, targetUsername) + ).isInstanceOf(InvalidFollowException.class) + .hasFieldOrPropertyWithValue("errorCode", "U0003") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("존재하지 않는 팔로우 입니다."); + + // then + verify(userRepository, times(1)) + .findByBasicProfile_Name(loginUsername); + verify(userRepository, times(1)) + .findByBasicProfile_Name(targetUsername); + } + } + + @DisplayName("Source 유저가 특정 Target 유저를 이미 팔로우 중이라면") + @Nested + class Context_AlreadyFollowingOtherUser { + + @DisplayName("언팔로우 할 수 있다.") + @Test + void unfollowUser_SourceToTarget_Success() { + //given + User loginUser = UserFactory.user(1L, "testUser"); + String loginUsername = loginUser.getName(); + AuthUserRequestDto authUserRequestDto = + createLoginAuthUserRequestDto(loginUsername); + User targetUser = UserFactory.user(2L, "testUser2"); + String targetUsername = targetUser.getName(); + + given(userRepository.findByBasicProfile_Name(loginUsername)) + .willReturn(Optional.of(loginUser)); + given(userRepository.findByBasicProfile_Name(targetUsername)) + .willReturn(Optional.of(targetUser)); + + userService.followUser(authUserRequestDto, targetUsername); + + //when + FollowResponseDto responseDto = + userService.unfollowUser(authUserRequestDto, targetUsername); + + //then + assertThat(responseDto.getFollowerCount()).isZero(); + assertThat(responseDto.isFollowing()).isFalse(); + + verify(userRepository, times(2)) + .findByBasicProfile_Name(loginUsername); + verify(userRepository, times(2)) + .findByBasicProfile_Name(targetUsername); + } + } + } + + @DisplayName("자신의 프로필(이미지, 한 줄 소개 포함)을 수정할 수 있다.") + @Test + void editUserProfile_WithImageAndDescription_Success() { + // given + MultipartFile image = FileFactory.getTestImage1(); + String updatedDescription = "updated description"; + AuthUserRequestDto authUserRequestDto = createLoginAuthUserRequestDto("testUser"); + + // mock + given(userRepository.findByBasicProfile_Name("testUser")) + .willReturn(Optional.of(UserFactory.user(1L, "testUser"))); + given(pickGitStorage.store(any(File.class), anyString())) + .willReturn(Optional.ofNullable(image.getName())); + + // when + ProfileEditRequestDto profileEditRequestDto = ProfileEditRequestDto + .builder() + .image(image) + .decription(updatedDescription) + .build(); + ProfileEditResponseDto responseDto = + userService.editProfile(authUserRequestDto, profileEditRequestDto); + + // then + assertThat(responseDto.getImageUrl()).isEqualTo(image.getName()); + assertThat(responseDto.getDescription()).isEqualTo(updatedDescription); + verify(userRepository, times(1)) + .findByBasicProfile_Name("testUser"); + verify(pickGitStorage, times(1)) + .store(any(File.class), anyString()); + } + + @DisplayName("자신의 프로필(한 줄 소개만 포함)을 수정할 수 있다.") + @Test + void editUserProfile_WithDescription_Success() { + // given + User user = UserFactory.user(1L, "testUser"); + String updatedDescription = "updated descrption"; + AuthUserRequestDto authUserRequestDto = createLoginAuthUserRequestDto("testUser"); + + // mock + given(userRepository.findByBasicProfile_Name("testUser")) + .willReturn(Optional.of(user)); + + // when + ProfileEditRequestDto profileEditRequestDto = ProfileEditRequestDto + .builder() + .image(FileFactory.getEmptyTestFile()) + .decription(updatedDescription) + .build(); + ProfileEditResponseDto responseDto = + userService.editProfile(authUserRequestDto, profileEditRequestDto); + + // then + assertThat(responseDto.getImageUrl()).isEqualTo(user.getImage()); + assertThat(responseDto.getDescription()).isEqualTo(updatedDescription); + verify(userRepository, times(1)) + .findByBasicProfile_Name(user.getName()); + } + + @DisplayName("로그인 - 저장된 유저중 유사한 이름을 가진 유저를 검색한다. (팔로잉한 여부 boolean)") + @Test + void searchUser_LoginUser_Success() { + // given + String searchKeyword = "bing"; + int page = 0; + int limit = 5; + List usersInDb = UserFactory.mockSearchUsersWithId(); + User loginUser = usersInDb.get(0); + List searchedUser = usersInDb.subList(1, usersInDb.size()); + UserSearchRequestDto userSearchRequestDto = UserSearchRequestDto + .builder() + .keyword(searchKeyword) + .page(0L) + .limit(5L) + .build(); + AuthUserRequestDto authUserRequestDto = createLoginAuthUserRequestDto(loginUser.getName()); + + // mock + given(userRepository.searchByUsernameLike(searchKeyword, PageRequest.of(page, limit))) + .willReturn(searchedUser); + given(userRepository.findByBasicProfile_Name(loginUser.getName())) + .willReturn(Optional.ofNullable(loginUser)); + + // when + loginUser.follow(searchedUser.get(0)); + List searchResponses = userService + .searchUser(authUserRequestDto, userSearchRequestDto); + + // then + assertThat(searchResponses).hasSize(4); + assertThat(searchResponses) + .extracting("username") + .containsExactly(searchedUser.stream().map(User::getName).toArray()); + assertThat(searchResponses) + .extracting("following") + .containsExactly(true, false, false, false); + verify(userRepository, times(1)) + .searchByUsernameLike(searchKeyword, PageRequest.of(page, limit)); + verify(userRepository, times(1)).findByBasicProfile_Name(loginUser.getName()); + } + + @DisplayName("비 로그인 - 저장된 유저중 유사한 이름을 가진 유저를 검색한다. (팔로잉 필드 null)") + @Test + void searchUser_GuestUser_Success() { + // given + String searchKeyword = "bing"; + int page = 0; + int limit = 5; + List usersInDb = UserFactory.mockSearchUsersWithId(); + UserSearchRequestDto userSearchRequestDto = UserSearchRequestDto + .builder() + .keyword(searchKeyword) + .page(0L) + .limit(5L) + .build(); + AuthUserRequestDto authUserRequestDto = createGuestAuthUserRequestDto(); + + // mock + given(userRepository.searchByUsernameLike(searchKeyword, PageRequest.of(page, limit))) + .willReturn(usersInDb); + + // when + List searchResult = + userService.searchUser(authUserRequestDto, userSearchRequestDto); + + // then + assertThat(searchResult).hasSize(5); + assertThat(searchResult) + .extracting("username") + .containsExactly(usersInDb.stream().map(User::getName).toArray()); + assertThat(searchResult) + .extracting("following") + .containsExactly(null, null, null, null, null); + verify(userRepository, times(1)) + .searchByUsernameLike(searchKeyword, PageRequest.of(page, limit)); + verify(userRepository, times(0)).findByBasicProfile_Name(anyString()); + } + + @DisplayName("누구든지 활동 통계를 조회할 수 있다.") + @Test + void calculateContributions_Anyone_Success() { + // given + User user = UserFactory.user(); + + Contribution contribution = new Contribution(11, 48, 48, 48, 48); + + given(userRepository.findByBasicProfile_Name("testUser")) + .willReturn(Optional.of(user)); + given(platformContributionCalculator.calculate("testUser")) + .willReturn(contribution); + + ContributionResponseDto responseDto = UserFactory.mockContributionResponseDto(); + + // when + ContributionResponseDto contributions = userService.calculateContributions("testUser"); + + // then + assertThat(contributions) + .usingRecursiveComparison() + .isEqualTo(responseDto); + + verify(userRepository, times(1)) + .findByBasicProfile_Name("testUser"); + verify(platformContributionCalculator, times(1)) + .calculate("testUser"); + } + + @DisplayName("존재하지 않는 유저 이름으로 활동 통계를 조회할 수 없다. - 400 예외") + @Test + void calculateContributions_InvalidUsername_400Exception() { + // when + assertThatThrownBy(() -> { + userService.calculateContributions("invalidName"); + }).isInstanceOf(InvalidUserException.class) + .hasFieldOrPropertyWithValue("errorCode", "U0001") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.BAD_REQUEST) + .hasMessage("유효하지 않은 유저입니다."); + + // then + verify(userRepository, times(1)) + .findByBasicProfile_Name("invalidName"); + } + + private AuthUserRequestDto createLoginAuthUserRequestDto(String username) { + AppUser appUser = new LoginUser(username, "Bearer testToken"); + return AuthUserRequestDto.from(appUser); + } + + private AuthUserRequestDto createGuestAuthUserRequestDto() { + return AuthUserRequestDto.from(new GuestUser()); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/domain/FollowTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/domain/FollowTest.java new file mode 100644 index 000000000..fd160ab59 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/domain/FollowTest.java @@ -0,0 +1,52 @@ +package com.woowacourse.pickgit.unit.user.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import com.woowacourse.pickgit.common.factory.UserFactory; +import com.woowacourse.pickgit.exception.user.SameSourceTargetUserException; +import com.woowacourse.pickgit.user.domain.User; +import com.woowacourse.pickgit.user.domain.follow.Follow; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class FollowTest { + + @DisplayName("Follow 인스턴스는 Source와 Target이 동일하면 생성 예외가 발생한다.") + @Test + void new_SameSourceTarget_Exception() { + User source = UserFactory.user(1L, "testUser"); + + assertThatCode(() -> new Follow(source, source)) + .isInstanceOf(SameSourceTargetUserException.class) + .hasMessage("같은 Source 와 Target 유저입니다."); + } + + @DisplayName("팔로잉을 하고 있다. - target == targetUser") + @Test + void isFollowing_Following_True() { + // given + User source = UserFactory.user(1L, "testUser"); + User target = UserFactory.user(2L, "testUser2"); + User targetUser = UserFactory.user(2L, "testUser2"); + + Follow follow = new Follow(source, target); + + // then + assertThat(follow.isFollowing(targetUser)).isTrue(); + } + + @DisplayName("팔로잉을 하고 있지 않다. - target != targetUser") + @Test + void isFollowing_Following_False() { + // given + User source = UserFactory.user(1L, "testUser"); + User target = UserFactory.user(2L, "testUser2"); + User targetUser = UserFactory.user(3L, "testUser3"); + + Follow follow = new Follow(source, target); + + // then + assertThat(follow.isFollowing(targetUser)).isFalse(); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/domain/FollowersTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/domain/FollowersTest.java new file mode 100644 index 000000000..89ebe8166 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/domain/FollowersTest.java @@ -0,0 +1,114 @@ +package com.woowacourse.pickgit.unit.user.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import com.woowacourse.pickgit.common.factory.UserFactory; +import com.woowacourse.pickgit.exception.user.DuplicateFollowException; +import com.woowacourse.pickgit.exception.user.InvalidFollowException; +import com.woowacourse.pickgit.user.domain.User; +import com.woowacourse.pickgit.user.domain.follow.Follow; +import com.woowacourse.pickgit.user.domain.follow.Followers; +import java.util.ArrayList; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class FollowersTest { + + private Followers followers; + + @BeforeEach + void setUp() { + // given + followers = new Followers(new ArrayList<>()); + User user1 = UserFactory.user(1L, "kevin"); + User user2 = UserFactory.user(2L, "danyee"); + followers.add(new Follow(user1, user2)); + } + + @DisplayName("add 메서드는") + @Nested + class Describe_add { + + @DisplayName("현재 팔로워 목록에 동일한 Follow 정보가 없을 때") + @Nested + class Context_NotDuplicateFollowExits { + + @DisplayName("팔로워 목록에 Follow 정보를 추가한다.") + @Test + void add_Success() { + // given + User user1 = UserFactory.user(3L, "kafd2"); + User user2 = UserFactory.user(4L, "daadfadfe"); + + // when + followers.add(new Follow(user1, user2)); + + // then + assertThat(followers.count()).isEqualTo(2); + } + } + + @DisplayName("현재 팔로워 목록에 동일한 Follow 정보가 존재하면") + @Nested + class Context_DuplicateFollowExits { + + @DisplayName("팔로워 목록에 Follow 정보를 추가할 수 없다.") + @Test + void add_ExceptionThrown() { + // given + User user1 = UserFactory.user(1L, "kevin"); + User user2 = UserFactory.user(2L, "danyee"); + + // when, then + assertThatCode(() -> followers.add(new Follow(user1, user2))) + .isInstanceOf(DuplicateFollowException.class) + .hasMessage("이미 팔로우 중 입니다."); + } + } + } + + @DisplayName("remove 메서드는") + @Nested + class Describe_remove { + + @DisplayName("팔로워 목록에 동일한 Follow 정보가 있을 때") + @Nested + class Context_DuplicateFollowExits { + + @DisplayName("팔로워 목록에 Follow 정보를 삭제한다.") + @Test + void remove_Success() { + // given + User user1 = UserFactory.user(1L, "kevin"); + User user2 = UserFactory.user(2L, "danyee"); + + // when + followers.remove(new Follow(user1, user2)); + + // then + assertThat(followers.count()).isZero(); + } + } + + @DisplayName("팔로워 목록에 동일한 Follow 정보가 없을 때") + @Nested + class Context_NotDuplicateFollowExits { + + @DisplayName("팔로워 목록에 Follow 정보를 삭제할 수 없다.") + @Test + void remove_ExceptionThrown() { + // given + User user1 = UserFactory.user(3L, "kadfevin"); + User user2 = UserFactory.user(4L, "danyafdadfee"); + + // when, then + assertThatCode(() -> followers.remove(new Follow(user1, user2))) + .isInstanceOf(InvalidFollowException.class) + .hasMessage("존재하지 않는 팔로우 입니다."); + } + } + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/domain/FollowingsTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/domain/FollowingsTest.java new file mode 100644 index 000000000..cf2accf01 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/domain/FollowingsTest.java @@ -0,0 +1,164 @@ +package com.woowacourse.pickgit.unit.user.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import com.woowacourse.pickgit.common.factory.UserFactory; +import com.woowacourse.pickgit.exception.user.DuplicateFollowException; +import com.woowacourse.pickgit.exception.user.InvalidFollowException; +import com.woowacourse.pickgit.user.domain.User; +import com.woowacourse.pickgit.user.domain.follow.Follow; +import com.woowacourse.pickgit.user.domain.follow.Followings; +import java.util.ArrayList; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class FollowingsTest { + + private Followings followings; + + @BeforeEach + void setUp() { + // given + followings = new Followings(new ArrayList<>()); + User user1 = UserFactory.user(1L, "kevin"); + User user2 = UserFactory.user(2L, "danyee"); + followings.add(new Follow(user1, user2)); + } + + @DisplayName("add 메서드는") + @Nested + class Describe_add { + + @DisplayName("현재 팔로잉 목록에 동일한 Follow 정보가 없을 때") + @Nested + class Context_NotDuplicateFollowExits { + + @DisplayName("팔로잉 목록에 Follow 정보를 추가한다.") + @Test + void add_Success() { + // given + User user1 = UserFactory.user(3L, "kafd2"); + User user2 = UserFactory.user(4L, "daadfadfe"); + + // when + followings.add(new Follow(user1, user2)); + + // then + assertThat(followings.count()).isEqualTo(2); + } + } + + @DisplayName("현재 팔로잉 목록에 동일한 Follow 정보가 존재하면") + @Nested + class Context_DuplicateFollowExits { + + @DisplayName("팔로잉 목록에 Follow 정보를 추가할 수 없다.") + @Test + void add_ExceptionThrown() { + // given + User user1 = UserFactory.user(1L, "kevin"); + User user2 = UserFactory.user(2L, "danyee"); + + // when, then + assertThatCode(() -> followings.add(new Follow(user1, user2))) + .isInstanceOf(DuplicateFollowException.class) + .hasMessage("이미 팔로우 중 입니다."); + } + } + } + + @DisplayName("remove 메서드는") + @Nested + class Describe_remove { + + @DisplayName("팔로잉 목록에 동일한 Follow 정보가 있을 때") + @Nested + class Context_DuplicateFollowExits { + + @DisplayName("팔로잉 목록에 Follow 정보를 삭제한다.") + @Test + void remove_Success() { + // given + User user1 = UserFactory.user(1L, "kevin"); + User user2 = UserFactory.user(2L, "danyee"); + + // when + followings.remove(new Follow(user1, user2)); + + // then + assertThat(followings.count()).isZero(); + } + } + + @DisplayName("팔로잉 목록에 동일한 Follow 정보가 없을 때") + @Nested + class Context_NotDuplicateFollowExits { + + @DisplayName("팔로잉 목록에 Follow 정보를 삭제할 수 없다.") + @Test + void remove_ExceptionThrown() { + // given + User user1 = UserFactory.user(3L, "kadfevin"); + User user2 = UserFactory.user(4L, "danyafdadfee"); + + // when, then + assertThatCode(() -> followings.remove(new Follow(user1, user2))) + .isInstanceOf(InvalidFollowException.class) + .hasMessage("존재하지 않는 팔로우 입니다."); + } + } + } + + @DisplayName("contains 메서드는") + @Nested + class Describe_contains { + + @DisplayName("팔로잉 목록에서 특정 Follow 정보가 포함되어 있으면 True를 반환한다.") + @Test + void contains_Exist_True() { + // given + Follow follow = new Follow(UserFactory.user(1L, "kevin"), UserFactory.user(2L, "danyee")); + + // when, then + assertThat(followings.contains(follow)).isTrue(); + } + + @DisplayName("팔로잉 목록에 특정 Follow 정보가 포함되어 있지 않으면 False를 반환한다.") + @Test + void contains_NotExists_False() { + // given + Follow follow = new Follow(UserFactory.user(3L, "kevin"), UserFactory.user(4L, "danyee")); + + // when, then + assertThat(followings.contains(follow)).isFalse(); + } + } + + @DisplayName("isFollowing 메서드는") + @Nested + class Describe_isFollowing { + + @DisplayName("특정 User가 팔로잉 목록에 존재한다면 True를 반환한다.") + @Test + void isFollowing_Exists_True() { + // given + User user = UserFactory.createUser(2L, "danyee"); + + // when, then + assertThat(followings.isFollowing(user)).isTrue(); + } + + @DisplayName("특정 User가 팔로잉 목록에 존재하지 않다면 False를 반환한다.") + @Test + void isFollowing_NotExists_False() { + // given + User user = UserFactory.createUser(3L, "coda"); + + // when, then + assertThat(followings.isFollowing(user)).isFalse(); + } + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/domain/UserRepositoryTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/domain/UserRepositoryTest.java new file mode 100644 index 000000000..52acc1511 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/domain/UserRepositoryTest.java @@ -0,0 +1,321 @@ +package com.woowacourse.pickgit.unit.user.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.woowacourse.pickgit.common.factory.UserFactory; +import com.woowacourse.pickgit.exception.user.InvalidUserException; +import com.woowacourse.pickgit.post.domain.Post; +import com.woowacourse.pickgit.post.domain.repository.PostRepository; +import com.woowacourse.pickgit.user.domain.User; +import com.woowacourse.pickgit.user.domain.UserRepository; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@DataJpaTest +class UserRepositoryTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private PostRepository postRepository; + + @Autowired + private TestEntityManager testEntityManager; + + private List users; + + @BeforeEach + void setUp() { + // given + users = UserFactory.mockSearchUsersWithId(); + users.forEach(user -> userRepository.save(user)); + userRepository.save(UserFactory.user()); + + testEntityManager.flush(); + testEntityManager.clear(); + } + + @DisplayName("유저 이름으로 유저를 조회한다.") + @Test + void findUserByBasicProfile_Name_ValidUserName_Success() { + // when + User user = userRepository + .findByBasicProfile_Name(users.get(0).getName()) + .orElseThrow(InvalidUserException::new); + + // then + assertThat(user) + .usingRecursiveComparison() + .ignoringFields("id") + .isEqualTo(users.get(0)); + } + + @DisplayName("등록되지 않은 유저 이름으로 유저를 조회할 수 없다.- 400 예외") + @Test + void findUserByBasicProfile_Name_InvalidUserName_400Exception() { + // when, then + assertThat(userRepository.findByBasicProfile_Name("invalidUser")).isEmpty(); + } + + @DisplayName("저장된 유저중 유사한 이름을 가진 유저를 검색할 수 있다.") + @Test + void findAllByUsername_SearchUserByUsername_Success() { + // given + String searchKeyword = "bing"; + + // when + Pageable pageable = PageRequest.of(0, 3); + List searchResult = userRepository.searchByUsernameLike(searchKeyword, pageable); + + // then + assertThat(searchResult).hasSize(3); + assertThat(searchResult) + .extracting("name") + .containsExactly( + users.get(0).getName(), + users.get(1).getName(), + users.get(2).getName() + ); + } + + @DisplayName("저장된 유저중 유사한 이름을 가진 4, 5번째 유저를 검색할 수 있다.") + @Test + void findAllByUsername_SearchThirdAndFourthUserByUsername_Success() { + // given + String seachKeyword = "bing"; + + // when + Pageable pageable = PageRequest.of(1, 3); + List searchResult = userRepository.searchByUsernameLike(seachKeyword, pageable); + + // then + assertThat(searchResult).hasSize(2); + assertThat(searchResult) + .extracting("name") + .containsExactly( + users.get(3).getName(), + users.get(4).getName() + ); + } + + @DisplayName("저장된 유저중 검색 키워드와 유사한 유저가 없으면 빈 값을 반환한다.") + @Test + void findAllByUsername_SearchNoMatchedUser_Success() { + // given + String searchKeyword = "woowatech"; + + // when + Pageable pageable = PageRequest.of(0, 3); + List searchResult = userRepository.searchByUsernameLike(searchKeyword, pageable); + + // then + assertThat(searchResult).hasSize(0); + } + + @DisplayName("save 메서드는") + @Nested + class Describe_save { + + @DisplayName("저장하려는 유저가 다른 유저를 팔로우한다면") + @Nested + class Context_UserFollowingOther { + + @DisplayName("추가된 Followings & Followers 정보 또한 함께 영속화시킨다.") + @Test + void follow_Valid_PersistenceSuccess() { + // given + User source = UserFactory.user("kevin"); + User target = UserFactory.user("danyee"); + userRepository.save(source); + userRepository.save(target); + source.follow(target); + + testEntityManager.flush(); + testEntityManager.clear(); + + // when + User findSource = userRepository.findById(source.getId()) + .orElseThrow(IllegalArgumentException::new); + User findTarget = userRepository.findById(target.getId()) + .orElseThrow(IllegalArgumentException::new); + + // then + assertThat(findSource.getFollowingCount()).isEqualTo(1); + assertThat(findTarget.getFollowerCount()).isEqualTo(1); + } + } + + @DisplayName("영속화된 유저가 다른 유저를 언팔로우하면") + @Nested + class Context_UserUnfollowingOther { + + @DisplayName("변경된 Followings & Followers 정보도 변경 감지 대상이 된다.") + @Test + void unfollow_Valid_PersistenceSuccess() { + // given + User source = UserFactory.user("kevin"); + User target = UserFactory.user("danyee"); + userRepository.save(source); + userRepository.save(target); + source.follow(target); + + testEntityManager.flush(); + testEntityManager.clear(); + + // when + User findSource = userRepository.findById(source.getId()) + .orElseThrow(IllegalArgumentException::new); + User findTarget = userRepository.findById(target.getId()) + .orElseThrow(IllegalArgumentException::new); + int beforeFollowingCounts = findSource.getFollowingCount(); + int beforeFollowerCounts = findTarget.getFollowerCount(); + findSource.unfollow(findTarget); + + testEntityManager.flush(); + testEntityManager.clear(); + + User findSource2 = userRepository.findById(source.getId()) + .orElseThrow(IllegalArgumentException::new); + User findTarget2 = userRepository.findById(target.getId()) + .orElseThrow(IllegalArgumentException::new); + + // then + assertThat(beforeFollowingCounts).isOne(); + assertThat(beforeFollowerCounts).isOne(); + assertThat(findSource2.getFollowingCount()).isZero(); + assertThat(findTarget2.getFollowerCount()).isZero(); + } + } + } + + @DisplayName("delete 메서드는") + @Nested + class Describe_delete { + + @DisplayName("팔로우 중인 Target User를 삭제하면") + @Nested + class Context_FollowingUserDeleted { + + @DisplayName("Source User의 팔로잉 정보 또한 변경된다.") + @Test + void follow_WhenFollowingDeleted_InformationUpdated() { + // given + User source = UserFactory.user("kevin"); + User target = UserFactory.user("danyee"); + userRepository.save(source); + userRepository.save(target); + source.follow(target); + + testEntityManager.flush(); + testEntityManager.clear(); + + // when + int comparableFollowingCounts = userRepository.findById(source.getId()) + .orElseThrow(IllegalArgumentException::new) + .getFollowingCount(); + + testEntityManager.clear(); + + userRepository.deleteById(target.getId()); + + testEntityManager.flush(); + testEntityManager.clear(); + + User findSource = userRepository.findById(source.getId()) + .orElseThrow(IllegalArgumentException::new); + + // then + assertThat(comparableFollowingCounts).isOne(); + assertThat(findSource.getFollowingCount()).isZero(); + } + } + + @DisplayName("팔로워인 Source User를 삭제하면") + @Nested + class Context_FollowerUserDeleted { + + @DisplayName("Target User의 팔로워 정보 또한 변경된다.") + @Test + void follow_WhenFollowerDeleted_InformationUpdated() { + // given + User source = UserFactory.user("kevin"); + User target = UserFactory.user("danyee"); + userRepository.save(source); + userRepository.save(target); + source.follow(target); + + testEntityManager.flush(); + testEntityManager.clear(); + + // when + int comparableFollowerCounts = userRepository.findById(target.getId()) + .orElseThrow(IllegalArgumentException::new) + .getFollowerCount(); + + testEntityManager.clear(); + + userRepository.deleteById(source.getId()); + + testEntityManager.flush(); + testEntityManager.clear(); + + User findTarget = userRepository.findById(target.getId()) + .get(); + + // then + assertThat(comparableFollowerCounts).isOne(); + assertThat(findTarget.getFollowerCount()).isZero(); + } + } + + @DisplayName("특정 유저를 삭제하면") + @Nested + class Context_AnyUserDeleted { + + @DisplayName("해당 유저가 작성한 게시물 또한 삭제된다.") + @Test + void deleteUser_RelatedPostsRemoved_True() { + // given + User user = userRepository.findByBasicProfile_Name("testUser") + .orElseThrow(IllegalArgumentException::new); + Post post = Post.builder().author(user).build(); + Post post1 = Post.builder().author(user).build(); + Post post2 = Post.builder().author(user).build(); + postRepository.save(post); + postRepository.save(post1); + postRepository.save(post2); + + testEntityManager.flush(); + testEntityManager.clear(); + + // when + List beforePosts = testEntityManager.getEntityManager() + .createQuery("select p from Post p where p.user = :user", Post.class) + .setParameter("user", user) + .getResultList(); + userRepository.deleteById(user.getId()); + + testEntityManager.flush(); + testEntityManager.clear(); + + List afterPosts = testEntityManager.getEntityManager() + .createQuery("select p from Post p where p.user = :user", Post.class) + .setParameter("user", user) + .getResultList(); + + // then + assertThat(beforePosts).hasSize(3); + assertThat(afterPosts).isEmpty(); + } + } + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/domain/UserTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/domain/UserTest.java new file mode 100644 index 000000000..b16e89ce2 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/domain/UserTest.java @@ -0,0 +1,193 @@ +package com.woowacourse.pickgit.unit.user.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import com.woowacourse.pickgit.common.factory.UserFactory; +import com.woowacourse.pickgit.exception.user.DuplicateFollowException; +import com.woowacourse.pickgit.exception.user.InvalidFollowException; +import com.woowacourse.pickgit.exception.user.SameSourceTargetUserException; +import com.woowacourse.pickgit.user.domain.User; +import com.woowacourse.pickgit.user.domain.profile.GithubProfile; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class UserTest { + + private User source; + private User target; + + @BeforeEach + void setUp() { + // given + source = UserFactory.createUser(1L, "source"); + target = UserFactory.createUser(2L, "target"); + } + + @DisplayName("User는 자신의 GithubProfile을 변경한다.") + @Test + void changeGithubProfile_Valid_Success() { + // given + GithubProfile beforeProfile = + new GithubProfile("before-url", "company", "twitter", "abc", "kkk"); + GithubProfile afterProfile = + new GithubProfile("after-url", "pick-git", "insta", "kkk", "js"); + User user = new User(null, null, beforeProfile, null, null, null); + + // when + user.changeGithubProfile(afterProfile); + + //then + assertThat(user) + .extracting("githubProfile") + .isSameAs(afterProfile); + } + + @DisplayName("Follow 메서드는") + @Nested + class Describe_follow { + + @DisplayName("아직 팔로우하지 않은 타인 User에 대해서") + @Nested + class Context_ValidOtherUser { + + @DisplayName("팔로우에 성공한다.") + @Test + void follow_Success() { + // when + source.follow(target); + + // then + assertThat(source.getFollowingCount()).isEqualTo(1); + assertThat(target.getFollowerCount()).isEqualTo(1); + } + } + + @DisplayName("이미 팔로우하고 있는 타인 User에 대해서") + @Nested + class Context_AlreadyFollowingOtherUser { + + @DisplayName("팔로우 할 수 없다.") + @Test + void follow_DuplicateUser_Failure() { + // given + source.follow(target); + + // when, then + assertThatCode(() -> source.follow(target)) + .isInstanceOf(DuplicateFollowException.class) + .hasMessage("이미 팔로우 중 입니다."); + } + } + + @DisplayName("자기 자신에 대해서") + @Nested + class Context_Myself { + + @DisplayName("팔로우할 수 없다.") + @Test + void follow_Myself_Failure() { + // when, then + assertThatCode(() -> source.follow(source)) + .isInstanceOf(SameSourceTargetUserException.class) + .hasMessage("같은 Source 와 Target 유저입니다."); + } + } + } + + @DisplayName("Unfollow 메서드는") + @Nested + class Describe_unfollow { + + @DisplayName("팔로우하고 있는 타인 User에 대해서") + @Nested + class Context_ValidOtherUser { + + @DisplayName("언팔로우에 성공한다.") + @Test + void unfollow_Success() { + // given + source.follow(target); + + // when + source.unfollow(target); + + // then + assertThat(source.getFollowingCount()).isZero(); + assertThat(target.getFollowerCount()).isZero(); + } + } + + @DisplayName("팔로우하고 있지 않는 타인 User에 대해서") + @Nested + class Context_NotFollowingOtherUser { + + @DisplayName("언팔로우에 실패한다.") + @Test + void unfollow_NotFollowingUser_Failure() { + // when, then + assertThatCode(() -> source.unfollow(target)) + .isInstanceOf(InvalidFollowException.class) + .hasMessage("존재하지 않는 팔로우 입니다."); + } + } + + @DisplayName("자기 자신에 대해서") + @Nested + class Context_Myself { + + @DisplayName("언팔로우할 수 없다.") + @Test + void unfollow_Myself_Failure() { + // when, then + assertThatCode(() -> source.unfollow(source)) + .isInstanceOf(SameSourceTargetUserException.class) + .hasMessage("같은 Source 와 Target 유저입니다."); + } + } + } + + @DisplayName("isFollowing 메서드는") + @Nested + class Describe_isFollowing { + + @DisplayName("현재 팔로우 중이면 True를 반환한다.") + @Test + void isFollowing_Valid_True() { + // given + source.follow(target); + + // when, then + assertThat(source.isFollowing(target)).isTrue(); + } + + @DisplayName("현재 팔로우 중이 아니라면 False를 반환한다.") + @Test + void isFollowing_Invalid_False() { + // when, then + assertThat(source.isFollowing(target)).isFalse(); + } + } + + @DisplayName("equals 메서드는") + @Nested + class Describe_equals { + + @DisplayName("ID 식별자가 동일하면 동일 엔티티로 인식한다.") + @Test + void equals_SameId_True() { + User source = UserFactory.createUser(1L, "kevin"); + User target = UserFactory.createUser(1L, "mark"); + + assertThat(source).isEqualTo(target); + } + + @DisplayName("ID 식별자가 다르면 다른 엔티티로 인식한다.") + @Test + void equals_DifferentId_False() { + assertThat(source).isNotEqualTo(target); + } + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/infrastructure/GithubContributionCalculatorTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/infrastructure/GithubContributionCalculatorTest.java new file mode 100644 index 000000000..d3d47abe3 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/infrastructure/GithubContributionCalculatorTest.java @@ -0,0 +1,126 @@ +package com.woowacourse.pickgit.unit.user.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.woowacourse.pickgit.common.mockapi.MockContributionApiRequester; +import com.woowacourse.pickgit.exception.user.ContributionParseException; +import com.woowacourse.pickgit.user.domain.Contribution; +import com.woowacourse.pickgit.user.domain.PlatformContributionCalculator; +import com.woowacourse.pickgit.user.domain.PlatformContributionExtractor; +import com.woowacourse.pickgit.user.infrastructure.calculator.GithubContributionCalculator; +import com.woowacourse.pickgit.user.infrastructure.extractor.GithubContributionExtractor; +import com.woowacourse.pickgit.user.infrastructure.requester.PlatformContributionApiRequester; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +class GithubContributionCalculatorTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final String apiUrlFormatForStar = "https://api.github.com/search/repositories?q=user:%s stars:>=1"; + private final String apiUrlFormatForCount = "https://api.github.com/search/"; + + private PlatformContributionExtractor platformContributionExtractor; + private PlatformContributionCalculator platformContributionCalculator; + + @BeforeEach + void setUp() { + platformContributionExtractor = new GithubContributionExtractor( + objectMapper, + new MockContributionApiRequester(), + apiUrlFormatForStar, + apiUrlFormatForCount + ); + platformContributionCalculator = new GithubContributionCalculator( + platformContributionExtractor + ); + } + + @DisplayName("활동 통계를 조회할 수 있다.") + @Test + void calculate_ValidCalculation_Success() { + // given + Contribution contribution = new Contribution(11, 48, 48, 48, 48); + + // when + Contribution result = platformContributionCalculator.calculate("testUser"); + + // then + assertThat(result) + .usingRecursiveComparison() + .isEqualTo(contribution); + } + + @DisplayName("JSON key 값이 다른 경우 스타 개수를 조회할 수 없다. - 500 예외") + @Test + void calculate_InvalidCalculationInCaseOfStars_500Exception() { + // given + platformContributionExtractor = new GithubContributionExtractor( + objectMapper, + new MockStarsContributionApiErrorRequester(), + apiUrlFormatForStar, + apiUrlFormatForCount + ); + platformContributionCalculator = new GithubContributionCalculator( + platformContributionExtractor + ); + + // when + assertThatThrownBy(() -> { + platformContributionCalculator.calculate("testUser"); + }).isInstanceOf(ContributionParseException.class) + .hasFieldOrPropertyWithValue("errorCode", "V0001") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.INTERNAL_SERVER_ERROR) + .hasMessage("활동 통계를 조회할 수 없습니다."); + } + + private static class MockStarsContributionApiErrorRequester + implements PlatformContributionApiRequester { + + @Override + public String request(String url) { + if (url.contains("stars")) { + return "[{\"stargazers\": \"5\"}, {\"stargazers\": \"6\"}]"; + } + return "{\"total_count\": \"48\"}"; + } + } + + @DisplayName("JSON key 값이 다른 경우 커밋, PR, 이슈, 퍼블릭 레포지토리 개수를 조회할 수 없다. - 500 예외") + @Test + void calculate_InvalidCalculationInCaseOfCount_500Exception() { + // given + platformContributionExtractor = new GithubContributionExtractor( + objectMapper, + new MockCountContributionApiErrorRequester(), + apiUrlFormatForStar, + apiUrlFormatForCount + ); + platformContributionCalculator = new GithubContributionCalculator( + platformContributionExtractor + ); + + // when + assertThatThrownBy(() -> { + platformContributionCalculator.calculate("testUser"); + }).isInstanceOf(ContributionParseException.class) + .hasFieldOrPropertyWithValue("errorCode", "V0001") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.INTERNAL_SERVER_ERROR) + .hasMessage("활동 통계를 조회할 수 없습니다."); + } + + private static class MockCountContributionApiErrorRequester + implements PlatformContributionApiRequester { + + @Override + public String request(String url) { + if (url.contains("stars")) { + return "{\"items\": [{\"stargazers\": \"5\"}, {\"stargazers\": \"6\"}]}"; + } + return "{\"total\": \"48\"}"; + } + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/infrastructure/GithubContributionExtractorTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/infrastructure/GithubContributionExtractorTest.java new file mode 100644 index 000000000..85bed523d --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/infrastructure/GithubContributionExtractorTest.java @@ -0,0 +1,209 @@ +package com.woowacourse.pickgit.unit.user.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.woowacourse.pickgit.common.mockapi.MockContributionApiRequester; +import com.woowacourse.pickgit.exception.user.ContributionParseException; +import com.woowacourse.pickgit.user.domain.PlatformContributionExtractor; +import com.woowacourse.pickgit.user.infrastructure.dto.CountDto; +import com.woowacourse.pickgit.user.infrastructure.dto.ItemDto; +import com.woowacourse.pickgit.user.infrastructure.dto.StarsDto; +import com.woowacourse.pickgit.user.infrastructure.extractor.GithubContributionExtractor; +import com.woowacourse.pickgit.user.infrastructure.requester.PlatformContributionApiRequester; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +class GithubContributionExtractorTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final String apiUrlFormatForStar = "https://api.github.com/search/repositories?q=user:%s stars:>=1"; + private final String apiUrlFormatForCount = "https://api.github.com/search/"; + + private PlatformContributionExtractor platformContributionExtractor; + + @BeforeEach + void setUp() { + platformContributionExtractor = new GithubContributionExtractor( + objectMapper, + new MockContributionApiRequester(), + apiUrlFormatForStar, + apiUrlFormatForCount + ); + } + + @DisplayName("스타 개수를 추출한다.") + @Test + void extractStars_Stars_Success() { + // when + ItemDto stars = platformContributionExtractor.extractStars("testUser"); + + // then + assertThat(stars.getItems() + .stream() + .mapToInt(StarsDto::getStars) + .sum()) + .isEqualTo(11); + } + + @DisplayName("JSON key 값이 다른 경우 스타 개수를 추출할 수 없다. - 500 예외") + @Test + void extractStars_InvalidJsonKeyInCaseOfStars_500Exception() { + // given + platformContributionExtractor = new GithubContributionExtractor( + objectMapper, + new MockContributionApiErrorRequester(), + apiUrlFormatForStar, + apiUrlFormatForCount + ); + + // when + assertThatThrownBy(() -> { + platformContributionExtractor.extractStars("testUser"); + }).isInstanceOf(ContributionParseException.class) + .hasFieldOrPropertyWithValue("errorCode", "V0001") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.INTERNAL_SERVER_ERROR) + .hasMessage("활동 통계를 조회할 수 없습니다."); + } + + @DisplayName("커밋 개수를 추출한다.") + @Test + void extractCount_Commits_Success() { + // when + CountDto commits = platformContributionExtractor + .extractCount("/commits?q=committer:%s", "testUser"); + + // then + assertThat(commits.getCount()).isEqualTo(48); + } + + @DisplayName("JSON key 값이 다른 경우 커밋 개수를 추출할 수 없다. - 500 예외") + @Test + void extractCount_InvalidJsonKeyInCaseOfCommits_500Exception() { + // given + platformContributionExtractor = new GithubContributionExtractor( + objectMapper, + new MockContributionApiErrorRequester(), + apiUrlFormatForStar, + apiUrlFormatForCount + ); + + // when + assertThatThrownBy(() -> { + platformContributionExtractor.extractCount("/commits?q=committer:%s", "testUser"); + }).isInstanceOf(ContributionParseException.class) + .hasFieldOrPropertyWithValue("errorCode", "V0001") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.INTERNAL_SERVER_ERROR) + .hasMessage("활동 통계를 조회할 수 없습니다."); + } + + @DisplayName("PR 개수를 추출한다.") + @Test + void extractCount_PRs_Success() { + // when + CountDto prs = platformContributionExtractor + .extractCount("/issues?q=author:%s type:pr", "testUser"); + + // then + assertThat(prs.getCount()).isEqualTo(48); + } + + @DisplayName("JSON key 값이 다른 경우 PR 개수를 추출할 수 없다. - 500 예외") + @Test + void extractCount_InvalidJsonKeyInCaseOfPRs_500Exception() { + // given + platformContributionExtractor = new GithubContributionExtractor( + objectMapper, + new MockContributionApiErrorRequester(), + apiUrlFormatForStar, + apiUrlFormatForCount + ); + + // when + assertThatThrownBy(() -> { + platformContributionExtractor.extractCount("/issues?q=author:%s type:pr", "testUser"); + }).isInstanceOf(ContributionParseException.class) + .hasFieldOrPropertyWithValue("errorCode", "V0001") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.INTERNAL_SERVER_ERROR) + .hasMessage("활동 통계를 조회할 수 없습니다."); + } + + @DisplayName("이슈 개수를 추출한다.") + @Test + void extractCount_Issues_Success() { + // when + CountDto issues = platformContributionExtractor + .extractCount("/issues?q=author:%s type:issue", "testUser"); + + // then + assertThat(issues.getCount()).isEqualTo(48); + } + + @DisplayName("JSON key 값이 다른 경우 이슈 개수를 추출할 수 없다. - 500 예외") + @Test + void extractCount_InvalidJsonKeyInCaseOfIssues_500Exception() { + // given + platformContributionExtractor = new GithubContributionExtractor( + objectMapper, + new MockContributionApiErrorRequester(), + apiUrlFormatForStar, + apiUrlFormatForCount + ); + + // when + assertThatThrownBy(() -> { + platformContributionExtractor + .extractCount("/issues?q=author:%s type:issue", "testUser"); + }).isInstanceOf(ContributionParseException.class) + .hasFieldOrPropertyWithValue("errorCode", "V0001") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.INTERNAL_SERVER_ERROR) + .hasMessage("활동 통계를 조회할 수 없습니다."); + } + + @DisplayName("퍼블릭 레포지토리 개수를 추출한다.") + @Test + void extractCount_Repos_Success() { + // when + CountDto repos = platformContributionExtractor + .extractCount("/repositories?q=user:%s is:public", "testUser"); + + // then + assertThat(repos.getCount()).isEqualTo(48); + } + + @DisplayName("JSON key 값이 다른 경우 퍼블릭 레포지토리 개수를 추출할 수 없다. - 500 예외") + @Test + void extractCount_InvalidJsonKeyInCaseOfRepos_500Exception() { + // given + platformContributionExtractor = new GithubContributionExtractor( + objectMapper, + new MockContributionApiErrorRequester(), + apiUrlFormatForStar, + apiUrlFormatForCount + ); + + // when + assertThatThrownBy(() -> { + platformContributionExtractor + .extractCount("/repositories?q=user:%s is:public", "testUser"); + }).isInstanceOf(ContributionParseException.class) + .hasFieldOrPropertyWithValue("errorCode", "V0001") + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.INTERNAL_SERVER_ERROR) + .hasMessage("활동 통계를 조회할 수 없습니다."); + } + + private static class MockContributionApiErrorRequester + implements PlatformContributionApiRequester { + + @Override + public String request(String url) { + if (url.contains("stars")) { + return "{\"items\": [{\"stargazers\": \"5\"}, {\"stargazers\": \"6\"}]}"; + } + return "{\"total\": \"48\"}"; + } + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/presentation/UserControllerTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/presentation/UserControllerTest.java new file mode 100644 index 000000000..7a288604e --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/presentation/UserControllerTest.java @@ -0,0 +1,581 @@ +package com.woowacourse.pickgit.unit.user.presentation; + +import static com.woowacourse.pickgit.docs.ApiDocumentUtils.getDocumentRequest; +import static com.woowacourse.pickgit.docs.ApiDocumentUtils.getDocumentResponse; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; +import static org.springframework.restdocs.payload.JsonFieldType.NULL; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestPartBody; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.woowacourse.pickgit.authentication.application.OAuthService; +import com.woowacourse.pickgit.authentication.domain.user.AppUser; +import com.woowacourse.pickgit.authentication.domain.user.LoginUser; +import com.woowacourse.pickgit.common.factory.FileFactory; +import com.woowacourse.pickgit.common.factory.UserFactory; +import com.woowacourse.pickgit.user.application.UserService; +import com.woowacourse.pickgit.user.application.dto.request.AuthUserRequestDto; +import com.woowacourse.pickgit.user.application.dto.request.ProfileEditRequestDto; +import com.woowacourse.pickgit.user.application.dto.response.ContributionResponseDto; +import com.woowacourse.pickgit.user.application.dto.response.FollowResponseDto; +import com.woowacourse.pickgit.user.application.dto.response.ProfileEditResponseDto; +import com.woowacourse.pickgit.user.application.dto.response.UserProfileResponseDto; +import com.woowacourse.pickgit.user.presentation.UserController; +import org.apache.http.HttpHeaders; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +@AutoConfigureRestDocs +@WebMvcTest(UserController.class) +@ActiveProfiles("test") +class UserControllerTest { + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private MockMvc mockMvc; + + @MockBean + private OAuthService oAuthService; + + @MockBean + private UserService userService; + + @DisplayName("로그인 되어있을 때") + @Nested + class Describe_UnderLoginCondition { + + @DisplayName("사용자는 내 프로필을 조회할 수 있다.") + @Test + void getAuthenticatedUserProfile_LoginUser_Success() throws Exception { + // given + UserProfileResponseDto responseDto = UserFactory.mockLoginUserProfileResponseDto(); + + given(oAuthService.validateToken("testToken")) + .willReturn(true); + given(oAuthService.findRequestUserByToken("testToken")) + .willReturn(new LoginUser("loginUser", "testToken")); + given(userService.getMyUserProfile(any(AuthUserRequestDto.class))) + .willReturn(responseDto); + + // when + ResultActions perform = mockMvc.perform(get("/api/profiles/me") + .header(HttpHeaders.AUTHORIZATION, "Bearer testToken") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.ALL)); + + // then + String body = perform + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + assertThat(body).isEqualTo(objectMapper.writeValueAsString(responseDto)); + + verify(oAuthService, times(1)) + .validateToken("testToken"); + verify(oAuthService, times(1)) + .findRequestUserByToken("testToken"); + verify(userService, times(1)) + .getMyUserProfile(any(AuthUserRequestDto.class)); + + perform.andDo(document("profilesMe", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer token") + ), + responseFields( + fieldWithPath("name").type(STRING).description("사용자 이름"), + fieldWithPath("imageUrl").type(STRING).description("프로필 이미지 url"), + fieldWithPath("description").type(STRING).description("한줄 소개"), + fieldWithPath("followerCount").type(NUMBER).description("팔로워 수"), + fieldWithPath("followingCount").type(NUMBER).description("팔로잉 수"), + fieldWithPath("postCount").type(NUMBER).description("게시물 수"), + fieldWithPath("githubUrl").type(STRING).description("깃허브 url"), + fieldWithPath("company").type(STRING).description("회사"), + fieldWithPath("location").type(STRING).description("위치"), + fieldWithPath("website").type(STRING).description("웹 사이트"), + fieldWithPath("twitter").type(STRING).description("트위터"), + fieldWithPath("following").type(NULL).description("팔로잉 여부") + ) + )); + } + + @DisplayName("사용자는 타인의 프로필을 조회할 수 있다.") + @Test + void getUserProfile_LoginUser_Success() throws Exception { + // given + UserProfileResponseDto responseDto = + UserFactory.mockLoginUserProfileIsFollowingResponseDto(); + + given(oAuthService.validateToken("testToken")) + .willReturn(true); + given(oAuthService.findRequestUserByToken("testToken")) + .willReturn(new LoginUser("loginUser", "Bearer testToken")); + given(userService.getUserProfile(any(AuthUserRequestDto.class), eq("testUser2"))) + .willReturn(responseDto); + + // when + ResultActions perform = mockMvc.perform(get("/api/profiles/{userName}", "testUser2") + .header(HttpHeaders.AUTHORIZATION, "Bearer testToken") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.ALL)); + + // then + String body = perform + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + assertThat(body).isEqualTo(objectMapper.writeValueAsString(responseDto)); + + verify(oAuthService, times(1)) + .validateToken("testToken"); + verify(oAuthService, times(1)) + .findRequestUserByToken("testToken"); + verify(userService, times(1)) + .getUserProfile(any(AuthUserRequestDto.class), eq("testUser2")); + + perform.andDo(document("profiles-LoggedIn", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("userName").description("다른 사용자 이름") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer token") + ), + responseFields( + fieldWithPath("name").type(STRING).description("사용자 이름"), + fieldWithPath("imageUrl").type(STRING).description("프로필 이미지 url"), + fieldWithPath("description").type(STRING).description("한줄 소개"), + fieldWithPath("followerCount").type(NUMBER).description("팔로워 수"), + fieldWithPath("followingCount").type(NUMBER).description("팔로잉 수"), + fieldWithPath("postCount").type(NUMBER).description("게시물 수"), + fieldWithPath("githubUrl").type(STRING).description("깃허브 url"), + fieldWithPath("company").type(STRING).description("회사"), + fieldWithPath("location").type(STRING).description("위치"), + fieldWithPath("website").type(STRING).description("웹 사이트"), + fieldWithPath("twitter").type(STRING).description("트위터"), + fieldWithPath("following").type(BOOLEAN).description("팔로잉 여부") + ) + )); + } + + @DisplayName("사용자는 팔로우를 할 수 있다.") + @Test + void followUser_LoginUser_Success() throws Exception { + // given + FollowResponseDto responseDto = new FollowResponseDto(1, true); + + given(oAuthService.validateToken("testToken")) + .willReturn(true); + given(oAuthService.findRequestUserByToken("testToken")) + .willReturn(new LoginUser("loginUser", "Bearer testToken")); + given(userService.followUser(any(AuthUserRequestDto.class), eq("testUser"))) + .willReturn(responseDto); + + // when + ResultActions perform = mockMvc + .perform(post("/api/profiles/{userName}/followings", "testUser") + .header(HttpHeaders.AUTHORIZATION, "Bearer testToken") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.ALL)); + + // then + String body = perform + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + assertThat(body).isEqualTo(objectMapper.writeValueAsString(responseDto)); + + verify(oAuthService, times(1)) + .validateToken("testToken"); + verify(oAuthService, times(1)) + .findRequestUserByToken("testToken"); + verify(userService, times(1)) + .followUser(any(AuthUserRequestDto.class), eq("testUser")); + + perform.andDo(document("following-LoggedIn", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer token") + ), + pathParameters( + parameterWithName("userName").description("다른 사용자 이름") + ), + responseFields( + fieldWithPath("followerCount").description("팔로워 수"), + fieldWithPath("following").description("팔로잉 여부") + ) + )); + } + + @DisplayName("사용자는 언팔로우를 할 수 있다.") + @Test + void unfollowUser_LoginUser_Success() throws Exception { + // given + FollowResponseDto followResponseDto = new FollowResponseDto(1, false); + + given(oAuthService.validateToken("testToken")) + .willReturn(true); + given(oAuthService.findRequestUserByToken("testToken")) + .willReturn(new LoginUser("loginUser", "Bearer testToken")); + given(userService.unfollowUser(any(AuthUserRequestDto.class), eq("testUser"))) + .willReturn(followResponseDto); + + // when + ResultActions perform = mockMvc + .perform(RestDocumentationRequestBuilders + .delete("/api/profiles/{userName}/followings", "testUser") + .header(HttpHeaders.AUTHORIZATION, "Bearer testToken") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.ALL)); + + // then + String body = perform + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + assertThat(body).isEqualTo(objectMapper.writeValueAsString(followResponseDto)); + + verify(oAuthService, times(1)) + .validateToken("testToken"); + verify(oAuthService, times(1)) + .findRequestUserByToken("testToken"); + verify(userService, times(1)) + .unfollowUser(any(AuthUserRequestDto.class), eq("testUser")); + + perform.andDo(document("unfollowing-LoggedIn", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer token") + ), + pathParameters( + parameterWithName("userName").description("다른 사용자 이름") + ), + responseFields( + fieldWithPath("followerCount").description("팔로워 수"), + fieldWithPath("following").description("팔로잉 여부") + ) + )); + } + + @DisplayName("사용자는 자신의 프로필을 수정할 수 있다.") + @Test + void editUserProfile_LoginUserWithImageAndDescription_Success() throws Exception { + // given + AppUser loginUser = new LoginUser("testUser", "token"); + String description = "updated description"; + MockMultipartFile image = FileFactory.getTestImage1(); + ProfileEditResponseDto responseDto = ProfileEditResponseDto.builder() + .imageUrl(image.getOriginalFilename()) + .description(description) + .build(); + + // mock + given(oAuthService.validateToken("token")) + .willReturn(true); + given(oAuthService.findRequestUserByToken("token")) + .willReturn(loginUser); + given(userService + .editProfile(any(AuthUserRequestDto.class), any(ProfileEditRequestDto.class))) + .willReturn(responseDto); + + // when + ResultActions perform = mockMvc.perform(multipart("/api/profiles/me") + .file(image) + .param("description", description) + .header(HttpHeaders.AUTHORIZATION, "Bearer token") + ); + + // then + perform + .andExpect(status().isOk()) + .andExpect(jsonPath("imageUrl").value(image.getOriginalFilename())) + .andExpect(jsonPath("description").value(description)); + + verify(oAuthService, times(1)).validateToken("token"); + verify(oAuthService, times(1)).findRequestUserByToken("token"); + verify(userService, times(1)) + .editProfile(any(AuthUserRequestDto.class), any(ProfileEditRequestDto.class)); + + // restdocs + perform.andDo(document("edit-profile", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("bearer token") + ), + requestPartBody("images"), + responseFields( + fieldWithPath("imageUrl").type(STRING).description("변경된 프로필 이미지 url"), + fieldWithPath("description").type(STRING).description("변경된 한 줄 소개") + )) + ); + } + } + + @DisplayName("비로그인 상태일 때") + @Nested + class Describe_UnderGuestCondition { + + @DisplayName("게스트는 내 프로필을 조회할 수 없다. - 401 예외") + @Test + void getAuthenticatedUserProfile_LoginUser_401Exception() throws Exception { + // given + given(oAuthService.validateToken(any())) + .willReturn(false); + + // when + ResultActions perform = mockMvc.perform(get("/api/profiles/me") + .header(HttpHeaders.AUTHORIZATION, "Bad testToken") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.ALL)); + + // then + verify(oAuthService, times(1)) + .validateToken(any()); + + perform + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("errorCode").value("A0001")); + + perform.andDo(document("profilesMe", + getDocumentRequest(), + getDocumentResponse(), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bad token") + ), + responseFields( + fieldWithPath("errorCode").type(STRING).description("에러 코드") + ) + )); + } + + @DisplayName("게스트는 타인의 프로필을 조회할 수 있다.") + @Test + void getUserProfile_GuestUser_Success() throws Exception { + // given + UserProfileResponseDto responseDto = UserFactory.mockGuestUserProfileResponseDto(); + + given(oAuthService.findRequestUserByToken(any())) + .willCallRealMethod(); + given(userService.getUserProfile(any(AuthUserRequestDto.class), eq("testUser"))) + .willReturn(responseDto); + + // when + ResultActions perform = mockMvc.perform(get("/api/profiles/{userName}", "testUser") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.ALL)); + + // then + String body = perform + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + assertThat(body).isEqualTo(objectMapper.writeValueAsString(responseDto)); + + verify(oAuthService, times(1)) + .findRequestUserByToken(any()); + verify(userService, times(1)) + .getUserProfile(any(AuthUserRequestDto.class), eq("testUser")); + + perform.andDo(document("profiles-unLoggedIn", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("userName").description("다른 사용자 이름") + ), + responseFields( + fieldWithPath("name").type(STRING).description("사용자 이름"), + fieldWithPath("imageUrl").type(STRING).description("프로필 이미지 url"), + fieldWithPath("description").type(STRING).description("한줄 소개"), + fieldWithPath("followerCount").type(NUMBER).description("팔로워 수"), + fieldWithPath("followingCount").type(NUMBER).description("팔로잉 수"), + fieldWithPath("postCount").type(NUMBER).description("게시물 수"), + fieldWithPath("githubUrl").type(STRING).description("깃허브 url"), + fieldWithPath("company").type(STRING).description("회사"), + fieldWithPath("location").type(STRING).description("위치"), + fieldWithPath("website").type(STRING).description("웹 사이트"), + fieldWithPath("twitter").type(STRING).description("트위터"), + fieldWithPath("following").type(NULL).description("팔로잉 여부") + ) + )); + } + + @DisplayName("게스트는 팔로우를 할 수 없다. - 401 예외") + @Test + void followUser_GuestUser_401Exception() throws Exception { + // given + given(oAuthService.validateToken(any())) + .willReturn(false); + + // when + ResultActions perform = mockMvc + .perform(post("/api/profiles/{userName}/followings", "testUser") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.ALL)); + + // then + verify(oAuthService, times(1)) + .validateToken(any()); + + perform + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("errorCode").value("A0001")); + + perform.andDo(document("following-unLoggedIn", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("userName").description("다른 사용자 이름") + ), + responseFields( + fieldWithPath("errorCode").description("에러 코드") + ) + )); + } + + @DisplayName("게스트는 언팔로우를 할 수 없다. - 401 예외") + @Test + void unfollowUser_GuestUser_401Exception() throws Exception { + // given + given(oAuthService.validateToken(any())) + .willReturn(false); + + // when + ResultActions perform = mockMvc + .perform(RestDocumentationRequestBuilders + .delete("/api/profiles/{userName}/followings", "testUser") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.ALL)); + + // then + verify(oAuthService, times(1)) + .validateToken(any()); + + perform + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("errorCode").value("A0001")); + + perform.andDo(document("unfollowing-unLoggedIn", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("userName").description("다른 사용자 이름") + ), + responseFields( + fieldWithPath("errorCode").description("에러 코드") + ) + )); + } + + @DisplayName("게스트는 프로필을 수정할 수 없다.") + @Test + void editUserProfile_GuestUser_Fail() throws Exception { + // given + MockMultipartFile image = FileFactory.getTestImage1(); + + // mock + given(oAuthService.validateToken(any())) + .willReturn(false); + + // when + ResultActions perform = mockMvc.perform(multipart("/api/profiles/me") + .file(image) + .param("description", "updated description") + ); + + // then + perform + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("errorCode").value("A0001")); + + verify(oAuthService, times(1)).validateToken(any()); + } + } + + @DisplayName("누구든지 활동 통계를 조회할 수 있다.") + @Test + void getContributions_Anyone_Success() throws Exception { + // given + ContributionResponseDto responseDto = UserFactory.mockContributionResponseDto(); + + given(userService.calculateContributions("testUser")) + .willReturn(responseDto); + + // when + ResultActions perform = mockMvc + .perform(get("/api/profiles/{username}/contributions", "testUser") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.ALL)); + + // then + String body = perform + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + assertThat(body).isEqualTo(objectMapper.writeValueAsString(responseDto)); + verify(userService, times(1)).calculateContributions("testUser"); + + perform.andDo(document("contributions-LoggedIn", + getDocumentRequest(), + getDocumentResponse(), + pathParameters( + parameterWithName("username").description("사용자 이름") + ), + responseFields( + fieldWithPath("starsCount").description("스타 개수"), + fieldWithPath("commitsCount").description("커밋 개수"), + fieldWithPath("prsCount").description("PR 개수"), + fieldWithPath("issuesCount").description("이슈 개수"), + fieldWithPath("reposCount").description("퍼블릭 레포지토리 개수") + ) + )); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/presentation/UserSearchControllerTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/presentation/UserSearchControllerTest.java new file mode 100644 index 000000000..66cc2e496 --- /dev/null +++ b/backend/pick-git/src/test/java/com/woowacourse/pickgit/unit/user/presentation/UserSearchControllerTest.java @@ -0,0 +1,182 @@ +package com.woowacourse.pickgit.unit.user.presentation; + +import static com.woowacourse.pickgit.docs.ApiDocumentUtils.getDocumentRequest; +import static com.woowacourse.pickgit.docs.ApiDocumentUtils.getDocumentResponse; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInRelativeOrder; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; +import static org.springframework.restdocs.payload.JsonFieldType.NULL; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.requestParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.woowacourse.pickgit.authentication.application.OAuthService; +import com.woowacourse.pickgit.authentication.domain.user.GuestUser; +import com.woowacourse.pickgit.authentication.domain.user.LoginUser; +import com.woowacourse.pickgit.user.application.UserService; +import com.woowacourse.pickgit.user.application.dto.request.AuthUserRequestDto; +import com.woowacourse.pickgit.user.application.dto.request.UserSearchRequestDto; +import com.woowacourse.pickgit.user.application.dto.response.UserSearchResponseDto; +import com.woowacourse.pickgit.user.presentation.UserSearchController; +import java.util.List; +import org.apache.http.HttpHeaders; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +@AutoConfigureRestDocs +@WebMvcTest(UserSearchController.class) +@ActiveProfiles("test") +class UserSearchControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private OAuthService oAuthService; + + @MockBean + private UserService userService; + + @DisplayName("로그인 - 검색 키워드와 유사한 이름을 가진 유저를 검색할 수 있다. (팔로잉한 여부 boolean)") + @Test + void searchUser_LoginUser_Success() throws Exception { + // given + String searchKeyword = "bing"; + List userSearchRespons = List.of( + new UserSearchResponseDto("image1", "bingbingdola", true), + new UserSearchResponseDto("image2", "bing", false), + new UserSearchResponseDto("image3", "bbbbbing", false) + ); + + // mock + given(oAuthService.validateToken("token")).willReturn(true); + given(oAuthService.findRequestUserByToken("token")) + .willReturn(new LoginUser("pick-git", "token")); + given(userService.searchUser(any(AuthUserRequestDto.class), any(UserSearchRequestDto.class))) + .willReturn(userSearchRespons); + + // when + ResultActions perform = mockMvc + .perform( + get("/api/search/users") + .param("keyword", searchKeyword) + .param("page", "0") + .param("limit", "5") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.ALL) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")); + + // then + perform + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[*].imageUrl", + contains("image1", "image2", "image3"))) + .andExpect(jsonPath("$[*].username", + containsInRelativeOrder("bingbingdola", "bing", "bbbbbing"))) + .andExpect(jsonPath("$[*].following", + containsInRelativeOrder(true, false, false))); + + verify(oAuthService, times(1)).validateToken("token"); + verify(oAuthService, times(1)) + .findRequestUserByToken("token"); + verify(userService, times(1)) + .searchUser(any(AuthUserRequestDto.class), any(UserSearchRequestDto.class)); + + // restdocs + perform.andDo(document("search-user-LoggedIn", + getDocumentRequest(), + getDocumentResponse(), + requestParameters( + parameterWithName("keyword").description("검색 키워드"), + parameterWithName("page").description("page"), + parameterWithName("limit").description("limit") + ), + responseFields( + fieldWithPath("[].imageUrl").type(STRING).description("유저 이미지 url"), + fieldWithPath("[].username").type(STRING).description("유저 이름"), + fieldWithPath("[].following").type(BOOLEAN).description("로그인시 검색된 유저 팔로잉 여부") + ) + )); + } + + @DisplayName("비 로그인 - 검색 키워드와 유사한 이름을 가진 유저를 검색할 수 있다. (팔로잉 필드 null)") + @Test + void searchUser_GuestUser_Success() throws Exception { + // given + String searchKeyword = "bing"; + List userSearchRespons = List.of( + new UserSearchResponseDto("image1", "bingbingdola", null), + new UserSearchResponseDto("image2", "bing", null), + new UserSearchResponseDto("image3", "bbbbbing", null) + ); + + // mock + given(oAuthService.findRequestUserByToken(any())) + .willReturn(new GuestUser()); + given(userService.searchUser(any(AuthUserRequestDto.class), any(UserSearchRequestDto.class))) + .willReturn(userSearchRespons); + + // when + ResultActions perform = mockMvc + .perform( + get("/api/search/users") + .param("keyword", searchKeyword) + .param("page", "0") + .param("limit", "5") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.ALL)); + + // then + perform + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[*].imageUrl", + contains("image1", "image2", "image3"))) + .andExpect(jsonPath("$[*].username", + containsInRelativeOrder("bingbingdola", "bing", "bbbbbing"))) + .andExpect(jsonPath("$[*].following", + containsInRelativeOrder(nullValue(), nullValue(), nullValue() + ))); + verify(oAuthService, times(1)) + .findRequestUserByToken(any()); + verify(userService, times(1)) + .searchUser(any(AuthUserRequestDto.class), any(UserSearchRequestDto.class)); + + // restdocs + perform.andDo(document("search-user-unLoggedIn", + getDocumentRequest(), + getDocumentResponse(), + requestParameters( + parameterWithName("keyword").description("검색 키워드"), + parameterWithName("page").description("page"), + parameterWithName("limit").description("limit") + ), + responseFields( + fieldWithPath("[].imageUrl").type(STRING).description("유저 이미지 url"), + fieldWithPath("[].username").type(STRING).description("유저 이름"), + fieldWithPath("[].following").type(NULL).description("비 로그인시 검색된 유저 팔로잉 여부") + ) + )); + } +} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/UserAcceptanceTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/UserAcceptanceTest.java deleted file mode 100644 index 5e12ed255..000000000 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/UserAcceptanceTest.java +++ /dev/null @@ -1,361 +0,0 @@ -package com.woowacourse.pickgit.user; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; - -import com.woowacourse.pickgit.authentication.application.dto.OAuthProfileResponse; -import com.woowacourse.pickgit.authentication.domain.OAuthClient; -import com.woowacourse.pickgit.authentication.presentation.dto.OAuthTokenResponse; -import com.woowacourse.pickgit.post.PostTestConfiguration; -import com.woowacourse.pickgit.user.UserFactory; -import com.woowacourse.pickgit.user.domain.User; -import com.woowacourse.pickgit.user.presentation.dto.FollowResponse; -import com.woowacourse.pickgit.user.presentation.dto.UserProfileResponse; -import io.restassured.RestAssured; -import io.restassured.response.ExtractableResponse; -import io.restassured.response.Response; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.boot.web.server.LocalServerPort; -import org.springframework.context.annotation.Import; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.test.annotation.DirtiesContext; -import org.springframework.test.annotation.DirtiesContext.ClassMode; -import org.springframework.test.context.ActiveProfiles; - -@Import(PostTestConfiguration.class) -@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) -@DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD) -@ActiveProfiles("test") -public class UserAcceptanceTest { - - private static final String SOURCE_USER_NAME = "yjksw"; - private static final String TARGET_USER_NAME = "pickgit"; - - @LocalServerPort - private int port; - - @MockBean - private OAuthClient oAuthClient; - - private UserFactory userFactory = new UserFactory(); - - private String userAccessToken; - - private String anotherAccessToken; - - @BeforeEach - void setUp() { - RestAssured.port = port; - - userAccessToken = 로그인_되어있음(userFactory.user()).getToken(); - anotherAccessToken = 로그인_되어있음(userFactory.anotherUser()).getToken(); - } - - - @DisplayName("본인의 프로필 조회에 성공한다.") - @Test - void getAuthenticatedUserProfile_ValidUser_Success() { - //given - User user = userFactory.user(); - String requestUrl = "/api/profiles/me"; - UserProfileResponse expectedResponseDto = - new UserProfileResponse(user.getName(), user.getImage(), user.getDescription(), - user.getFollowerCount(), user.getFollowingCount(), user.getPostCount(), - user.getGithubUrl(), user.getCompany(), user.getLocation(), user.getWebsite(), - user.getTwitter(), null); - - //when - UserProfileResponse actualResponseDto = - authenticatedGetRequest(userAccessToken, requestUrl, HttpStatus.OK) - .as(UserProfileResponse.class); - - //then - assertThat(actualResponseDto) - .usingRecursiveComparison() - .isEqualTo(expectedResponseDto); - } - - @DisplayName("본인의 프로필 조회시 토큰이 없으면 예외를 발생시킨다.") - @Test - void getAuthenticatedUserProfile_noToken_ExceptionThrown() { - //given - String requestUrl = "/api/profiles/me"; - - //when - //then - unauthenticatedGetRequest(requestUrl, HttpStatus.UNAUTHORIZED); - } - - @DisplayName("로그인 상태에서 팔로우하는 타인의 프로필 조회에 성공한다.") - @Test - void getUserProfile_ValidLoginUserFollowing_Success() { - //given - User targetUser = userFactory.anotherUser(); - - String followRequestUrl = "/api/profiles/" + targetUser.getName() + "/followings"; - String requestUrl = "/api/profiles/" + targetUser.getName(); - UserProfileResponse expectedResponseDto = - new UserProfileResponse(targetUser.getName(), targetUser.getImage(), targetUser.getDescription(), - 1, targetUser.getFollowingCount(), targetUser.getPostCount(), - targetUser.getGithubUrl(), targetUser.getCompany(), targetUser.getLocation(), targetUser.getWebsite(), - targetUser.getTwitter(), true); - - authenticatedPostRequest(userAccessToken, followRequestUrl, HttpStatus.OK); - - //when - UserProfileResponse actualResponseDto = - authenticatedGetRequest(userAccessToken, requestUrl, HttpStatus.OK) - .as(UserProfileResponse.class); - - //then - assertThat(actualResponseDto) - .usingRecursiveComparison() - .isEqualTo(expectedResponseDto); - } - - @DisplayName("로그인 상태에서 팔로우하지 않는 타인의 프로필 조회에 성공한다.") - @Test - void getUserProfile_ValidLoginUserUnfollowing_Success() { - //given - User user = userFactory.anotherUser(); - String requestUrl = "/api/profiles/" + user.getName(); - UserProfileResponse expectedResponseDto = - new UserProfileResponse(user.getName(), user.getImage(), user.getDescription(), - user.getFollowerCount(), user.getFollowingCount(), user.getPostCount(), - user.getGithubUrl(), user.getCompany(), user.getLocation(), user.getWebsite(), - user.getTwitter(), false); - - //when - UserProfileResponse actualResponseDto = - authenticatedGetRequest(userAccessToken, requestUrl, HttpStatus.OK) - .as(UserProfileResponse.class); - - //then - assertThat(actualResponseDto) - .usingRecursiveComparison() - .isEqualTo(expectedResponseDto); - } - - @DisplayName("비로그인 상태에서 타인의 프로필 조회에 성공한다.") - @Test - void getUserProfile_ValidGuestUser_Success() { - //given - User user = userFactory.anotherUser(); - String requestUrl = "/api/profiles/" + user.getName(); - UserProfileResponse expectedResponseDto = - new UserProfileResponse(user.getName(), user.getImage(), user.getDescription(), - user.getFollowerCount(), user.getFollowingCount(), user.getPostCount(), - user.getGithubUrl(), user.getCompany(), user.getLocation(), user.getWebsite(), - user.getTwitter(), null); - - //when - UserProfileResponse actualResponseDto = - unauthenticatedGetRequest(requestUrl, HttpStatus.OK) - .as(UserProfileResponse.class); - - //then - assertThat(actualResponseDto) - .usingRecursiveComparison() - .isEqualTo(expectedResponseDto); - } - - @DisplayName("한 로그인 유저가 다른 유저를 팔로우하는데 성공한다.") - @Test - void followUser_ValidUser_Success() { - //given - User user = userFactory.anotherUser(); - String requestUrl = "/api/profiles/" + user.getName() + "/followings"; - FollowResponse expectedResponseDto = new FollowResponse(1, true); - - //when - FollowResponse actualResponseDto = - authenticatedPostRequest(userAccessToken, requestUrl, HttpStatus.OK) - .as(FollowResponse.class); - - //then - assertThat(actualResponseDto) - .usingRecursiveComparison() - .isEqualTo(expectedResponseDto); - } - - @DisplayName("같은 source와 target이 팔로우 요청을 하면 예외가 발생한다.") - @Test - void followUser_SameSourceTargetUser_ExceptionThrown() { - //given - String requestUrl = "/api/profiles/" + SOURCE_USER_NAME + "/followings"; - - //when - //then - authenticatedPostRequest(userAccessToken, requestUrl, HttpStatus.BAD_REQUEST); - } - - @DisplayName("한 로그인 유저가 없는 유저를 팔로우하면 예외가 발생한다.") - @Test - void followUser_InvalidTargetUser_ExceptionThrown() { - //given - String requestUrl = "/api/profiles/" + "invalidUser" + "/followings"; - - //when - //then - authenticatedPostRequest(userAccessToken, requestUrl, HttpStatus.BAD_REQUEST); - } - - @DisplayName("이미 존재하는 팔로우 요청 시 예외가 발생한다.") - @Test - void followUser_ExistingFollow_ExceptionThrown() { - //given - User user = userFactory.anotherUser(); - String requestUrl = "/api/profiles/" + user.getName() + "/followings"; - FollowResponse followResponse = authenticatedPostRequest( - userAccessToken, requestUrl, HttpStatus.OK) - .as(FollowResponse.class); - - FollowResponse followExpectedResponseDto = new FollowResponse(1, true); - - assertThat(followResponse) - .usingRecursiveComparison() - .isEqualTo(followExpectedResponseDto); - - //when - //then - authenticatedPostRequest(userAccessToken, requestUrl, HttpStatus.BAD_REQUEST); - } - - @DisplayName("한 로그인 유저가 다른 유저를 언팔로우하는데 성공한다.") - @Test - void unfollowUser_ValidUser_Success() { - //given - User user = userFactory.anotherUser(); - String requestUrl = "/api/profiles/" + user.getName() + "/followings"; - FollowResponse followResponse = authenticatedPostRequest( - userAccessToken, requestUrl, HttpStatus.OK) - .as(FollowResponse.class); - - FollowResponse followExpectedResponseDto = new FollowResponse(1, true); - FollowResponse unfollowExpectedResponseDto = new FollowResponse(0, false); - - assertThat(followResponse) - .usingRecursiveComparison() - .isEqualTo(followExpectedResponseDto); - - //when - FollowResponse actualResponseDto = - authenticatedDeleteRequest(userAccessToken, requestUrl, HttpStatus.OK) - .as(FollowResponse.class); - - //then - assertThat(actualResponseDto) - .usingRecursiveComparison() - .isEqualTo(unfollowExpectedResponseDto); - } - - @DisplayName("같은 source와 target이 팔로우 요청을 하면 예외가 발생한다.") - @Test - void unfollowUser_SameSourceTargetUser_ExceptionThrown() { - //given - String requestUrl = "/api/profiles/" + SOURCE_USER_NAME + "/followings"; - - //when - //then - authenticatedDeleteRequest(userAccessToken, requestUrl, HttpStatus.BAD_REQUEST); - } - - @DisplayName("한 로그인 유저가 없는 유저를 언팔로우하면 예외가 발생한다.") - @Test - void unfollowUser_InvalidTargetUser_ExceptionThrown() { - //given - String requestUrl = "/api/profiles/" + "invalidUser" + "/followings"; - - //when - //then - authenticatedDeleteRequest(userAccessToken, requestUrl, HttpStatus.BAD_REQUEST); - } - - @DisplayName("존재하지 않는 팔로우 관계에 대한 언팔로우 요청 시 예외가 발생한다.") - @Test - void unfollowUser_NotExistingFollow_ExceptionThrown() { - //given - User user = userFactory.anotherUser(); - String requestUrl = "/api/profiles/" + user.getName() + "/followings"; - - //when - //then - authenticatedDeleteRequest(userAccessToken, requestUrl, HttpStatus.BAD_REQUEST); - } - - private ExtractableResponse authenticatedGetRequest(String accessToken, String url, - HttpStatus httpStatus) { - return RestAssured.given().log().all() - .auth().oauth2(accessToken) - .when().get(url) - .then().log().all() - .statusCode(httpStatus.value()) - .extract(); - } - - private ExtractableResponse unauthenticatedGetRequest(String url, - HttpStatus httpStatus) { - return RestAssured.given().log().all() - .when().get(url) - .then().log().all() - .statusCode(httpStatus.value()) - .extract(); - } - - private ExtractableResponse authenticatedPostRequest(String accessToken, String url, - HttpStatus httpStatus) { - return RestAssured.given().log().all() - .auth().oauth2(accessToken) - .when().post(url) - .then().log().all() - .statusCode(httpStatus.value()) - .extract(); - } - - private ExtractableResponse authenticatedDeleteRequest(String accessToken, String url, - HttpStatus httpStatus) { - return RestAssured.given().log().all() - .auth().oauth2(accessToken) - .when().delete(url) - .then().log().all() - .statusCode(httpStatus.value()) - .extract(); - } - - public OAuthTokenResponse 로그인_되어있음(User user) { - OAuthTokenResponse response = 로그인_요청(user).as(OAuthTokenResponse.class); - assertThat(response.getToken()).isNotBlank(); - return response; - } - - public ExtractableResponse 로그인_요청(User user) { - // given - String oauthCode = "1234"; - String accessToken = "oauth.access.token"; - - OAuthProfileResponse oAuthProfileResponse = new OAuthProfileResponse( - user.getName(), user.getImage(), user.getDescription(), user.getGithubUrl(), - user.getCompany(), user.getLocation(), user.getWebsite(), user.getTwitter() - ); - - // mock - when(oAuthClient.getAccessToken(oauthCode)).thenReturn(accessToken); - when(oAuthClient.getGithubProfile(accessToken)).thenReturn(oAuthProfileResponse); - - // when - return RestAssured - .given().log().all() - .accept(MediaType.APPLICATION_JSON_VALUE) - .when() - .get("/api/afterlogin?code=" + oauthCode) - .then().log().all() - .statusCode(HttpStatus.OK.value()) - .extract(); - } -} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/UserFactory.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/UserFactory.java deleted file mode 100644 index 0c77111cd..000000000 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/UserFactory.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.woowacourse.pickgit.user; - -import com.woowacourse.pickgit.user.application.dto.UserProfileServiceDto; -import com.woowacourse.pickgit.user.domain.User; -import com.woowacourse.pickgit.user.domain.profile.BasicProfile; -import com.woowacourse.pickgit.user.domain.profile.GithubProfile; -import com.woowacourse.pickgit.user.presentation.dto.UserProfileResponse; - -public class UserFactory { - - private static final Long ID_SOURCE = 1L; - private static final Long ID_TARGET = 2L; - private static final String NAME_SOURCE = "yjksw"; - private static final String NAME_TARGET = "pickgit"; - private static final String IMAGE = "http://img.com"; - private static final String DESCRIPTION = "The Best"; - private static final String GITHUB_URL = "https://github.com/yjksw"; - private static final String COMPANY = "woowacourse"; - private static final String LOCATION = "Seoul"; - private static final String WEBSITE = "www.pick-git.com"; - private static final String TWITTER = "pick-git twitter"; - - private BasicProfile basicProfileSource = - new BasicProfile(NAME_SOURCE, IMAGE, DESCRIPTION); - private BasicProfile basicProfileTarget = - new BasicProfile(NAME_TARGET, IMAGE, DESCRIPTION); - private GithubProfile githubProfile_source = - new GithubProfile(GITHUB_URL, COMPANY, LOCATION, WEBSITE, TWITTER); - private GithubProfile githubProfile_target = - new GithubProfile(GITHUB_URL, COMPANY, LOCATION, WEBSITE, TWITTER); - - public User user() { - return new User(ID_SOURCE, basicProfileSource, githubProfile_source); - } - - public User anotherUser() { - return new User(ID_TARGET, basicProfileTarget, githubProfile_target); - } - - public UserProfileServiceDto mockLoginUserProfileServiceDto() { - return new UserProfileServiceDto( - NAME_SOURCE, IMAGE, DESCRIPTION, 0, 11, - 1, GITHUB_URL, COMPANY, LOCATION, WEBSITE, TWITTER, false); - } - public UserProfileServiceDto mockUnLoginUserProfileServiceDto() { - return new UserProfileServiceDto( - NAME_SOURCE, IMAGE, DESCRIPTION, 0, 11, - 1, GITHUB_URL, COMPANY, LOCATION, WEBSITE, TWITTER, null); - } - -} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/application/UserServiceIntegrationTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/application/UserServiceIntegrationTest.java deleted file mode 100644 index ef788e809..000000000 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/application/UserServiceIntegrationTest.java +++ /dev/null @@ -1,226 +0,0 @@ -package com.woowacourse.pickgit.user.application; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.woowacourse.pickgit.authentication.domain.user.AppUser; -import com.woowacourse.pickgit.authentication.domain.user.GuestUser; -import com.woowacourse.pickgit.authentication.domain.user.LoginUser; -import com.woowacourse.pickgit.exception.user.DuplicateFollowException; -import com.woowacourse.pickgit.exception.user.InvalidFollowException; -import com.woowacourse.pickgit.exception.user.InvalidUserException; -import com.woowacourse.pickgit.post.PostTestConfiguration; -import com.woowacourse.pickgit.user.UserFactory; -import com.woowacourse.pickgit.user.application.dto.AuthUserServiceDto; -import com.woowacourse.pickgit.user.application.dto.FollowServiceDto; -import com.woowacourse.pickgit.user.application.dto.UserProfileServiceDto; -import com.woowacourse.pickgit.user.domain.User; -import com.woowacourse.pickgit.user.domain.UserRepository; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; -import org.springframework.context.annotation.Import; -import org.springframework.test.annotation.DirtiesContext; -import org.springframework.test.annotation.DirtiesContext.ClassMode; -import org.springframework.test.context.ActiveProfiles; - -@Import(PostTestConfiguration.class) -@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) -@DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD) -@ActiveProfiles("test") -public class UserServiceIntegrationTest { - - private static final String NAME = "yjksw"; - private static final String IMAGE = "http://img.com"; - private static final String DESCRIPTION = "The Best"; - private static final String GITHUB_URL = "https://github.com/yjksw"; - private static final String COMPANY = "woowacourse"; - private static final String LOCATION = "Seoul"; - private static final String WEBSITE = "www.pick-git.com"; - private static final String TWITTER = "pick-git twitter"; - - @Autowired - private UserService userService; - - @Autowired - private UserRepository userRepository; - - private UserFactory userFactory = new UserFactory(); - - @DisplayName("개인 프로필 정보를 성공적으로 가져온다.") - @Test - public void getMyUserProfile_FindUserInfoByName_Success() { - //given - AuthUserServiceDto authUserServiceDto = new AuthUserServiceDto(NAME); - userRepository.save(userFactory.user()); - UserProfileServiceDto expectedUserProfileDto = new UserProfileServiceDto( - NAME, IMAGE, DESCRIPTION, - 0, 0, 0, - GITHUB_URL, COMPANY, LOCATION, WEBSITE, TWITTER, null - ); - - //when - UserProfileServiceDto actualUserProfileDto = userService.getMyUserProfile(authUserServiceDto); - - //then - assertThat(actualUserProfileDto) - .usingRecursiveComparison() - .isEqualTo(expectedUserProfileDto); - } - - @DisplayName("게스트 유저가 프로필 조회시 프로필 정보를 성공적으로 가져온다.") - @Test - public void getUserProfile_GuestFindUserInfoByName_Success() { - //given - AppUser guestUser = new GuestUser(); - userRepository.save(userFactory.user()); - UserProfileServiceDto expectedUserProfileDto = new UserProfileServiceDto( - NAME, IMAGE, DESCRIPTION, - 0, 0, 0, - GITHUB_URL, COMPANY, LOCATION, WEBSITE, TWITTER, null - ); - - //when - UserProfileServiceDto actualUserProfileDto = userService.getUserProfile(guestUser, NAME); - - //then - assertThat(actualUserProfileDto) - .usingRecursiveComparison() - .isEqualTo(expectedUserProfileDto); - } - - @DisplayName("로그인 유저가 팔로우 하는 프로필 조회시 프로필 정보를 성공적으로 가져온다.") - @Test - public void getUserProfile_FindFollowingUserInfoByName_Success() { - //given - AppUser loginUser = new LoginUser(NAME, "token"); - User source = userRepository.save(userFactory.user()); - User target = userRepository.save(userFactory.anotherUser()); - - AuthUserServiceDto authUserServiceDto = new AuthUserServiceDto(source.getName()); - userService.followUser(authUserServiceDto, target.getName()); - - UserProfileServiceDto expectedUserProfileDto = new UserProfileServiceDto( - target.getName(), target.getImage(), target.getDescription(), - 1, 0, 0, - target.getGithubUrl(), target.getCompany(), target.getLocation(), - target.getWebsite(), target.getTwitter(), true - ); - - //when - UserProfileServiceDto actualUserProfileDto = userService.getUserProfile(loginUser, target.getName()); - - //then - assertThat(actualUserProfileDto) - .usingRecursiveComparison() - .isEqualTo(expectedUserProfileDto); - } - - @DisplayName("로그인 유저가 팔로우하고 있지 않은 프로필 조회시 프로필 정보를 성공적으로 가져온다.") - @Test - public void getUserProfile_FindUnfollowingUserInfoByName_Success() { - //given - AppUser loginUser = new LoginUser(NAME, "token"); - userRepository.save(userFactory.user()); - userRepository.save(userFactory.anotherUser()); - - UserProfileServiceDto expectedUserProfileDto = new UserProfileServiceDto( - NAME, IMAGE, DESCRIPTION, - 0, 0, 0, - GITHUB_URL, COMPANY, LOCATION, WEBSITE, TWITTER, false - ); - - //when - UserProfileServiceDto actualUserProfileDto = userService.getUserProfile(loginUser, NAME); - - //then - assertThat(actualUserProfileDto) - .usingRecursiveComparison() - .isEqualTo(expectedUserProfileDto); - } - - @DisplayName("존재하지 않는 유저 이름으로 프로필 조회시 예외가 발생한다.") - @Test - void getUserProfile_FindUserInfoByInvalidName_Success() { - //given - //when - //then - AppUser appUser = new GuestUser(); - assertThatThrownBy( - () -> userService.getUserProfile(appUser, "InvalidName") - ).hasMessage(new InvalidUserException().getMessage()); - } - - @DisplayName("Source 유저가 Target 유저를 follow 하면 성공한다.") - @Test - void followUser_ValidUser_Success() { - //given - userRepository.save(userFactory.user()); - userRepository.save(userFactory.anotherUser()); - AuthUserServiceDto authUserServiceDto = new AuthUserServiceDto(NAME); - String targetName = "pickgit"; - - //when - FollowServiceDto followServiceDto = userService.followUser(authUserServiceDto, targetName); - - //then - assertThat(followServiceDto.getFollowerCount()).isEqualTo(1); - assertThat(followServiceDto.isFollowing()).isTrue(); - } - - @DisplayName("이미 존재하는 Follow 추가 시 예외가 발생한다.") - @Test - void followUser_ExistingFollow_ExceptionThrown() { - //given - userRepository.save(userFactory.user()); - userRepository.save(userFactory.anotherUser()); - AuthUserServiceDto authUserServiceDto = new AuthUserServiceDto(NAME); - String targetName = "pickgit"; - - userService.followUser(authUserServiceDto, targetName); - - //when - //then - assertThatThrownBy( - () -> userService.followUser(authUserServiceDto, targetName) - ).hasMessage(new DuplicateFollowException().getMessage()); - } - - @DisplayName("Source 유저가 Target 유저를 unfollow 하면 성공한다.") - @Test - void unfollowUser_ValidUser_Success() { - //given - userRepository.save(userFactory.user()); - userRepository.save(userFactory.anotherUser()); - AuthUserServiceDto authUserServiceDto = new AuthUserServiceDto(NAME); - String targetName = "pickgit"; - - userService.followUser(authUserServiceDto, targetName); - - //when - FollowServiceDto followServiceDto = userService - .unfollowUser(authUserServiceDto, targetName); - - //then - assertThat(followServiceDto.getFollowerCount()).isEqualTo(0); - assertThat(followServiceDto.isFollowing()).isFalse(); - } - - @DisplayName("존재하지 않는 Follow 관계를 unfollow 하면 예외가 발생한다.") - @Test - void unfollowUser_NotExistingFollow_ExceptionThrown() { - //given - userRepository.save(userFactory.user()); - userRepository.save(userFactory.anotherUser()); - AuthUserServiceDto authUserServiceDto = new AuthUserServiceDto(NAME); - String targetName = "pickgit"; - - //when - //then - assertThatThrownBy( - () -> userService.unfollowUser(authUserServiceDto, targetName) - ).hasMessage(new InvalidFollowException().getMessage()); - } -} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/application/UserServiceMockTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/application/UserServiceMockTest.java deleted file mode 100644 index 06ba22e70..000000000 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/application/UserServiceMockTest.java +++ /dev/null @@ -1,202 +0,0 @@ -package com.woowacourse.pickgit.user.application; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -import com.woowacourse.pickgit.authentication.domain.user.AppUser; -import com.woowacourse.pickgit.authentication.domain.user.GuestUser; -import com.woowacourse.pickgit.user.UserFactory; -import com.woowacourse.pickgit.user.application.dto.AuthUserServiceDto; -import com.woowacourse.pickgit.user.application.dto.FollowServiceDto; -import com.woowacourse.pickgit.user.application.dto.UserProfileServiceDto; -import com.woowacourse.pickgit.user.domain.UserRepository; -import com.woowacourse.pickgit.exception.user.DuplicateFollowException; -import com.woowacourse.pickgit.exception.user.InvalidFollowException; -import com.woowacourse.pickgit.exception.user.InvalidUserException; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class UserServiceMockTest { - - private static final String NAME = "yjksw"; - private static final String IMAGE = "http://img.com"; - private static final String DESCRIPTION = "The Best"; - private static final String GITHUB_URL = "https://github.com/yjksw"; - private static final String COMPANY = "woowacourse"; - private static final String LOCATION = "Seoul"; - private static final String WEBSITE = "www.pick-git.com"; - private static final String TWITTER = "pick-git twitter"; - - @InjectMocks - private UserService userService; - - @Mock - private UserRepository userRepository; - - private UserFactory userFactory; - - @BeforeEach - void setUp() { - this.userFactory = new UserFactory(); - } - - @DisplayName("본인의 프로필 정보를 성공적으로 가져온다.") - @Test - void name() { - - } - - @DisplayName("유저이름으로 검색한 User 기반으로 프로필 정보를 성공적으로 가져온다.") - @Test - void getUserProfile_FindUserInfoByName_Success() { - //given - AppUser appUser = new GuestUser(); - given( - userRepository.findByBasicProfile_Name(anyString()) - ).willReturn(Optional.of(userFactory.user())); - - UserProfileServiceDto expectedUserProfileDto = new UserProfileServiceDto( - NAME, IMAGE, DESCRIPTION, - 0, 0, 0, - GITHUB_URL, COMPANY, LOCATION, WEBSITE, TWITTER, null - ); - - //when - UserProfileServiceDto actualUserProfileDto = userService.getUserProfile(appUser, NAME); - - //then - assertThat(actualUserProfileDto) - .usingRecursiveComparison() - .isEqualTo(expectedUserProfileDto); - - verify(userRepository, times(1)).findByBasicProfile_Name(anyString()); - } - - @DisplayName("존재하지 않는 유저 이름으로 프로필 조회시 예외가 발생한다.") - @Test - void getUserProfile_FindUserInfoByInvalidName_Success() { - //given - AppUser appUser = new GuestUser(); - //when - //then - assertThatThrownBy( - () -> userService.getUserProfile(appUser, "InvalidName") - ).hasMessage(new InvalidUserException().getMessage()); - - verify(userRepository, times(1)).findByBasicProfile_Name(anyString()); - } - - @DisplayName("Source 유저가 Target 유저를 follow 하면 성공한다.") - @Test - void followUser_ValidUser_Success() { - //given - AuthUserServiceDto authUserServiceDto = new AuthUserServiceDto(NAME); - String targetName = "target"; - - given( - userRepository.findByBasicProfile_Name(NAME) - ).willReturn(Optional.of(userFactory.user())); - - given( - userRepository.findByBasicProfile_Name("target") - ).willReturn(Optional.of(userFactory.anotherUser())); - - //when - FollowServiceDto followServiceDto = userService.followUser(authUserServiceDto, targetName); - - //then - assertThat(followServiceDto.getFollowerCount()).isEqualTo(1); - assertThat(followServiceDto.isFollowing()).isTrue(); - - verify(userRepository, times(2)).findByBasicProfile_Name(anyString()); - } - - @DisplayName("이미 존재하는 Follow 추가 시 예외가 발생한다.") - @Test - void followUser_ExistingFollow_ExceptionThrown() { - //given - AuthUserServiceDto authUserServiceDto = new AuthUserServiceDto(NAME); - String targetName = "target"; - - given( - userRepository.findByBasicProfile_Name(NAME) - ).willReturn(Optional.of(userFactory.user())); - - given( - userRepository.findByBasicProfile_Name("target") - ).willReturn(Optional.of(userFactory.anotherUser())); - - userService.followUser(authUserServiceDto, targetName); - - //when - //then - assertThatThrownBy( - () -> userService.followUser(authUserServiceDto, targetName) - ).hasMessage(new DuplicateFollowException().getMessage()); - - verify(userRepository, times(4)).findByBasicProfile_Name(anyString()); - } - - @DisplayName("Source 유저가 Target 유저를 unfollow 하면 성공한다.") - @Test - void unfollowUser_ValidUser_Success() { - //given - AuthUserServiceDto authUserServiceDto = new AuthUserServiceDto(NAME); - String targetName = "target"; - - given( - userRepository.findByBasicProfile_Name(NAME) - ).willReturn(Optional.of(userFactory.user())); - - given( - userRepository.findByBasicProfile_Name("target") - ).willReturn(Optional.of(userFactory.anotherUser())); - - userService.followUser(authUserServiceDto, targetName); - - //when - FollowServiceDto followServiceDto = userService - .unfollowUser(authUserServiceDto, targetName); - - //then - assertThat(followServiceDto.getFollowerCount()).isEqualTo(0); - assertThat(followServiceDto.isFollowing()).isFalse(); - - verify(userRepository, times(4)).findByBasicProfile_Name(anyString()); - } - - @DisplayName("존재하지 않는 Follow 관계를 unfollow 하면 예외가 발생한다.") - @Test - void unfollowUser_NotExistingFollow_ExceptionThrown() { - //given - AuthUserServiceDto authUserServiceDto = new AuthUserServiceDto(NAME); - String targetName = "target"; - - given( - userRepository.findByBasicProfile_Name(NAME) - ).willReturn(Optional.of(userFactory.user())); - - given( - userRepository.findByBasicProfile_Name("target") - ).willReturn(Optional.of(userFactory.anotherUser())); - - //when - //then - assertThatThrownBy( - () -> userService.unfollowUser(authUserServiceDto, targetName) - ).hasMessage(new InvalidFollowException().getMessage()); - - verify(userRepository, times(2)).findByBasicProfile_Name(anyString()); - } -} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/domain/UserRepositoryTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/domain/UserRepositoryTest.java deleted file mode 100644 index 8f9f896fd..000000000 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/domain/UserRepositoryTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.woowacourse.pickgit.user.domain; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.woowacourse.pickgit.user.UserFactory; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; - -@DataJpaTest -class UserRepositoryTest { - - @Autowired - private UserRepository userRepository; - - @Autowired - private TestEntityManager testEntityManager; - - private UserFactory userFactory; - - @BeforeEach - void setUp() { - this.userFactory = new UserFactory(); - userRepository.save(userFactory.user()); - testEntityManager.flush(); - testEntityManager.clear(); - } - - @DisplayName("유저 이름으로 User 엔티티를 조회한다.") - @Test - void findUserByBasicProfile_Name_saveUser_Success() { - User actualUser = userRepository - .findByBasicProfile_Name("yjksw") - .get(); - - assertThat(actualUser) - .usingRecursiveComparison() - .ignoringFields("id") - .isEqualTo(userFactory.user()); - } -} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/domain/UserTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/domain/UserTest.java deleted file mode 100644 index 3792cbdb6..000000000 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/domain/UserTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.woowacourse.pickgit.user.domain; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.woowacourse.pickgit.post.domain.Post; -import com.woowacourse.pickgit.post.domain.comment.Comment; -import com.woowacourse.pickgit.post.domain.comment.Comments; -import java.util.ArrayList; -import java.util.List; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -class UserTest { - - @DisplayName("User가 특정 Post에 Comment를 추가한다.") - @Test - void addComment_Valid_RegistrationSuccess() { - Post post = new Post(null, null, null, null, null, new Comments(), new ArrayList<>(), null); - Comment comment = new Comment("test comment."); - User user = new User(); - - user.addComment(post, comment); - List comments = post.getComments(); - - assertThat(comments).hasSize(1); - assertThat(comments.get(0).getUser()).isEqualTo(user); - } -} diff --git a/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/presentation/UserControllerTest.java b/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/presentation/UserControllerTest.java deleted file mode 100644 index dffe389c3..000000000 --- a/backend/pick-git/src/test/java/com/woowacourse/pickgit/user/presentation/UserControllerTest.java +++ /dev/null @@ -1,341 +0,0 @@ -package com.woowacourse.pickgit.user.presentation; - -import static com.woowacourse.pickgit.docs.ApiDocumentUtils.getDocumentRequest; -import static com.woowacourse.pickgit.docs.ApiDocumentUtils.getDocumentResponse; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; -import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; -import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; -import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; -import static org.springframework.restdocs.payload.JsonFieldType.BOOLEAN; -import static org.springframework.restdocs.payload.JsonFieldType.NULL; -import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; -import static org.springframework.restdocs.payload.JsonFieldType.STRING; -import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; -import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; -import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.woowacourse.pickgit.authentication.application.OAuthService; -import com.woowacourse.pickgit.authentication.domain.user.AppUser; -import com.woowacourse.pickgit.authentication.domain.user.LoginUser; -import com.woowacourse.pickgit.user.UserFactory; -import com.woowacourse.pickgit.user.application.UserService; -import com.woowacourse.pickgit.user.application.dto.AuthUserServiceDto; -import com.woowacourse.pickgit.user.application.dto.FollowServiceDto; -import com.woowacourse.pickgit.user.application.dto.UserProfileServiceDto; -import org.apache.http.HttpHeaders; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.MvcResult; -import org.springframework.test.web.servlet.ResultActions; - -@AutoConfigureRestDocs -@WebMvcTest(UserController.class) -@ActiveProfiles("test") -class UserControllerTest { - - @Autowired - private ObjectMapper objectMapper; - - @Autowired - private MockMvc mockMvc; - - @MockBean - private UserService userService; - - @MockBean - private OAuthService oAuthService; - - @DisplayName("인증된 사용자의 프로필을 가져온다.") - @Test - void getAuthenticatedUserProfile() throws Exception { - UserProfileServiceDto userProfileServiceDto = new UserFactory() - .mockLoginUserProfileServiceDto(); - - given(userService.getMyUserProfile(any(AuthUserServiceDto.class))) - .willReturn(userProfileServiceDto); - given(oAuthService.validateToken(anyString())) - .willReturn(true); - given(oAuthService.findRequestUserByToken(anyString())) - .willReturn(new LoginUser("test", "test")); - - ResultActions perform = mockMvc.perform(get("/api/profiles/me") - .header(HttpHeaders.AUTHORIZATION, "Bearer testToken") - .contentType(MediaType.APPLICATION_JSON_VALUE) - .accept(MediaType.ALL)); - - MvcResult mvcResult = perform.andReturn(); - String body = mvcResult.getResponse().getContentAsString(); - - assertThat(body).isEqualTo(objectMapper.writeValueAsString(userProfileServiceDto)); - - perform.andDo(document("profilesMe", - getDocumentRequest(), - getDocumentResponse(), - requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description("baerer token") - ), - responseFields( - fieldWithPath("name").type(STRING).description("사용자 이름"), - fieldWithPath("image").type(STRING).description("프로필 이미지 url"), - fieldWithPath("description").type(STRING).description("한줄 소개"), - fieldWithPath("followerCount").type(NUMBER).description("팔로워 수"), - fieldWithPath("followingCount").type(NUMBER).description("팔로잉 수"), - fieldWithPath("postCount").type(NUMBER).description("게시물 수"), - fieldWithPath("githubUrl").type(STRING).description("깃허브 url"), - fieldWithPath("company").type(STRING).description("회사"), - fieldWithPath("location").type(STRING).description("위치"), - fieldWithPath("website").type(STRING).description("웹 사이트"), - fieldWithPath("twitter").type(STRING).description("트위터"), - fieldWithPath("following").type(BOOLEAN).description("팔로잉 여부") - ) - )); - } - - @DisplayName("다른 사용자의 프로필을 가져온다. - 로그인") - @Test - void getUserProfile_loggedIn() throws Exception { - UserProfileServiceDto userProfileServiceDto = new UserFactory() - .mockLoginUserProfileServiceDto(); - - given(userService.getUserProfile(any(AppUser.class), anyString())) - .willReturn(userProfileServiceDto); - given(oAuthService.validateToken(anyString())) - .willReturn(true); - given(oAuthService.findRequestUserByToken(anyString())) - .willReturn(new LoginUser("test", "test")); - - ResultActions perform = mockMvc.perform(get("/api/profiles/{userName}}", "testUser") - .header(HttpHeaders.AUTHORIZATION, "Bearer testToken") - .contentType(MediaType.APPLICATION_JSON_VALUE) - .accept(MediaType.ALL)); - - MvcResult mvcResult = perform.andReturn(); - String body = mvcResult.getResponse().getContentAsString(); - - assertThat(body).isEqualTo(objectMapper.writeValueAsString(userProfileServiceDto)); - - perform.andDo(document("profiles-LoggedIn", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("userName").description("다른 사용자 이름") - ), - requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description("baerer token") - ), - responseFields( - fieldWithPath("name").type(STRING).description("사용자 이름"), - fieldWithPath("image").type(STRING).description("프로필 이미지 url"), - fieldWithPath("description").type(STRING).description("한줄 소개"), - fieldWithPath("followerCount").type(NUMBER).description("팔로워 수"), - fieldWithPath("followingCount").type(NUMBER).description("팔로잉 수"), - fieldWithPath("postCount").type(NUMBER).description("게시물 수"), - fieldWithPath("githubUrl").type(STRING).description("깃허브 url"), - fieldWithPath("company").type(STRING).description("회사"), - fieldWithPath("location").type(STRING).description("위치"), - fieldWithPath("website").type(STRING).description("웹 사이트"), - fieldWithPath("twitter").type(STRING).description("트위터"), - fieldWithPath("following").type(BOOLEAN).description("팔로잉 여부") - ) - )); - } - - @DisplayName("다른 사용자의 프로필을 가져온다. - 비 로그인") - @Test - void getUserProfile_unLoggedIn() throws Exception { - UserProfileServiceDto userProfileServiceDto = new UserFactory() - .mockUnLoginUserProfileServiceDto(); - - given(userService.getUserProfile(any(AppUser.class), anyString())) - .willReturn(userProfileServiceDto); - given(oAuthService.validateToken(anyString())) - .willReturn(true); - given(oAuthService.findRequestUserByToken(any())) - .willReturn(new LoginUser("test", "test")); - - ResultActions perform = mockMvc.perform(get("/api/profiles/{userName}}", "testUser") - .contentType(MediaType.APPLICATION_JSON_VALUE) - .accept(MediaType.ALL)); - - MvcResult mvcResult = perform.andReturn(); - String body = mvcResult.getResponse().getContentAsString(); - - assertThat(body).isEqualTo(objectMapper.writeValueAsString(userProfileServiceDto)); - - perform.andDo(document("profiles-unLoggedIn", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("userName").description("다른 사용자 이름") - ), - responseFields( - fieldWithPath("name").type(STRING).description("사용자 이름"), - fieldWithPath("image").type(STRING).description("프로필 이미지 url"), - fieldWithPath("description").type(STRING).description("한줄 소개"), - fieldWithPath("followerCount").type(NUMBER).description("팔로워 수"), - fieldWithPath("followingCount").type(NUMBER).description("팔로잉 수"), - fieldWithPath("postCount").type(NUMBER).description("게시물 수"), - fieldWithPath("githubUrl").type(STRING).description("깃허브 url"), - fieldWithPath("company").type(STRING).description("회사"), - fieldWithPath("location").type(STRING).description("위치"), - fieldWithPath("website").type(STRING).description("웹 사이트"), - fieldWithPath("twitter").type(STRING).description("트위터"), - fieldWithPath("following").type(NULL).description("팔로잉 여부") - ) - )); - } - - @DisplayName("팔로잉을 한다. - 로그인") - @Test - void followUser() throws Exception { - FollowServiceDto followServiceDto = new FollowServiceDto(1, true); - - given(userService.followUser(any(AuthUserServiceDto.class), anyString())) - .willReturn(followServiceDto); - given(oAuthService.validateToken(anyString())) - .willReturn(true); - given(oAuthService.findRequestUserByToken(anyString())) - .willReturn(new LoginUser("test", "test")); - - ResultActions perform = mockMvc.perform(post("/api/profiles/{userName}/followings", "testUser") - .header(HttpHeaders.AUTHORIZATION, "Bearer testToken") - .contentType(MediaType.APPLICATION_JSON_VALUE) - .accept(MediaType.ALL)); - - MvcResult mvcResult = perform.andReturn(); - String body = mvcResult.getResponse().getContentAsString(); - - assertThat(body).isEqualTo(objectMapper.writeValueAsString(followServiceDto)); - - perform.andDo(document("following-LoggedIn", - getDocumentRequest(), - getDocumentResponse(), - requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description("bearer token") - ), - pathParameters( - parameterWithName("userName").description("다른 사용자 이름") - ), - responseFields( - fieldWithPath("followerCount").description("팔로워 수"), - fieldWithPath("following").description("팔로잉 여부") - ) - )); - } - - @DisplayName("언팔로잉일 한다. - 로그인") - @Test - void unfollowUser() throws Exception { - FollowServiceDto followServiceDto = new FollowServiceDto(1, false); - - given(userService.followUser(any(AuthUserServiceDto.class), anyString())) - .willReturn(followServiceDto); - given(oAuthService.validateToken(anyString())) - .willReturn(true); - given(oAuthService.findRequestUserByToken(anyString())) - .willReturn(new LoginUser("test", "test")); - - ResultActions perform = mockMvc.perform(post("/api/profiles/{userName}/followings", "testUser") - .header(HttpHeaders.AUTHORIZATION, "Bearer testToken") - .contentType(MediaType.APPLICATION_JSON_VALUE) - .accept(MediaType.ALL)); - - MvcResult mvcResult = perform.andReturn(); - String body = mvcResult.getResponse().getContentAsString(); - - assertThat(body).isEqualTo(objectMapper.writeValueAsString(followServiceDto)); - - perform.andDo(document("unfollowing-LoggedIn", - getDocumentRequest(), - getDocumentResponse(), - requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description("bearer token") - ), - pathParameters( - parameterWithName("userName").description("다른 사용자 이름") - ), - responseFields( - fieldWithPath("followerCount").description("팔로워 수"), - fieldWithPath("following").description("팔로잉 여부") - ) - )); - } - - @DisplayName("팔로잉을 한다. - 비 로그인") - @Test - void followUser_unLogin() throws Exception { - FollowServiceDto followServiceDto = new FollowServiceDto(1, true); - - given(userService.followUser(any(AuthUserServiceDto.class), anyString())) - .willReturn(followServiceDto); - given(oAuthService.validateToken(anyString())) - .willReturn(true); - given(oAuthService.findRequestUserByToken(anyString())) - .willReturn(new LoginUser("test", "test")); - - ResultActions perform = mockMvc.perform(post("/api/profiles/{userName}/followings", "testUser") - .contentType(MediaType.APPLICATION_JSON_VALUE) - .accept(MediaType.ALL)); - - MvcResult mvcResult = perform.andReturn(); - String body = mvcResult.getResponse().getContentAsString(); - - perform.andExpect(jsonPath("errorCode").value("A0001")); - - perform.andDo(document("following-unLoggedIn", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("userName").description("다른 사용자 이름") - ), - responseFields( - fieldWithPath("errorCode").description("A0001") - ) - )); - } - - @DisplayName("언팔로잉일 한다. - 비 로그인") - @Test - void unfollowUser_unLogin() throws Exception { - FollowServiceDto followServiceDto = new FollowServiceDto(1, false); - - given(userService.followUser(any(AuthUserServiceDto.class), anyString())) - .willReturn(followServiceDto); - given(oAuthService.validateToken(anyString())) - .willReturn(true); - given(oAuthService.findRequestUserByToken(anyString())) - .willReturn(new LoginUser("test", "test")); - - ResultActions perform = mockMvc.perform(post("/api/profiles/{userName}/followings", "testUser") - .contentType(MediaType.APPLICATION_JSON_VALUE) - .accept(MediaType.ALL)); - - perform.andExpect(jsonPath("errorCode").value("A0001")); - - perform.andDo(document("unfollowing-unLoggedIn", - getDocumentRequest(), - getDocumentResponse(), - pathParameters( - parameterWithName("userName").description("다른 사용자 이름") - ), - responseFields( - fieldWithPath("errorCode").description("A0001") - ) - )); - } -} \ No newline at end of file diff --git a/frontend/.eslintrc b/frontend/.eslintrc index 96c53617f..a021035d0 100644 --- a/frontend/.eslintrc +++ b/frontend/.eslintrc @@ -43,6 +43,7 @@ "react/react-in-jsx-scope": "off", "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/no-empty-interface": "off", + "@typescript-eslint/no-empty-function": "off", "prettier/prettier": "error" } } \ No newline at end of file diff --git a/frontend/.storybook/utils/LoggedInWrapper.tsx b/frontend/.storybook/utils/LoggedInWrapper.tsx index 49ce10bbd..a3924ac29 100644 --- a/frontend/.storybook/utils/LoggedInWrapper.tsx +++ b/frontend/.storybook/utils/LoggedInWrapper.tsx @@ -1,7 +1,7 @@ import { useContext, useEffect } from "react"; import UserContext, { UserContextProvider } from "../../src/contexts/UserContext"; -const LoggedInWrapper = ({ children }: { children: React.ReactElement }) => { +const LoggedInWrapper = ({ children }: { children: React.ReactNode }) => { const Inner = () => { const { login } = useContext(UserContext); diff --git a/frontend/.storybook/utils/components.tsx b/frontend/.storybook/utils/components.tsx index caed1b703..1598de2a0 100644 --- a/frontend/.storybook/utils/components.tsx +++ b/frontend/.storybook/utils/components.tsx @@ -4,4 +4,5 @@ export const TextEditorWrapper = styled.div` border-radius: 4px; padding: 1.1rem 1rem; background-color: #efefef; + height: 200px; `; diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts new file mode 100644 index 000000000..6dd87f111 --- /dev/null +++ b/frontend/jest.config.ts @@ -0,0 +1,12 @@ +import type { Config } from "@jest/types"; + +const config: Config.InitialOptions = { + verbose: true, + roots: ["/src"], + testMatch: ["**/?(*.)+(test).+(ts|tsx)"], + transform: { + "^.+\\.(ts|tsx)$": "ts-jest", + }, +}; + +export default config; diff --git a/frontend/package.json b/frontend/package.json index 8d44333e1..453b66e84 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,8 @@ "@storybook/cli": "^6.3.2", "@storybook/react": "^6.3.2", "@svgr/webpack": "^5.5.0", + "@testing-library/react-hooks": "^7.0.1", + "@types/jest": "^26.0.24", "@types/node": "^16.0.1", "@types/react": "^17.0.13", "@types/react-dom": "^17.0.8", @@ -48,8 +50,12 @@ "eslint-plugin-react": "^7.24.0", "eslint-plugin-react-hooks": "^4.2.0", "html-webpack-plugin": "^5.3.2", + "jest": "^27.0.6", "json-server": "^0.16.3", + "nock": "^13.1.1", "prettier": "^2.3.2", + "react-test-renderer": "^17.0.2", + "ts-jest": "^27.0.4", "ts-loader": "^9.2.3", "ts-node": "^10.0.0", "typescript": "^4.3.5", diff --git a/frontend/public/index.html b/frontend/public/index.html index 7f4f11483..f58e75784 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -12,5 +12,7 @@
+ +
diff --git a/frontend/src/@types/index.ts b/frontend/src/@types/index.ts index a42993257..39323efda 100644 --- a/frontend/src/@types/index.ts +++ b/frontend/src/@types/index.ts @@ -1,6 +1,9 @@ +import { clientErrorCodeName, httpErrorStatus, httpErrorStatusName } from "../constants/error"; +import { API_ERROR_MESSAGE, CLIENT_ERROR_MESSAGE } from "../constants/messages"; + export interface ProfileData { name: string; - image: string; + imageUrl: string; description: string; followerCount: number; followingCount: number; @@ -10,23 +13,37 @@ export interface ProfileData { location: string; website: string; twitter: string; - following?: boolean; + following: boolean | null; +} + +export interface MutateResponseFollow { + followerCount: number; + following: boolean; } +export interface SearchResultUser { + imageUrl: string; + username: string; + following: boolean | null; +} + +export interface SearchResultTag {} + export interface CommentData { - commentId: string; + id: number; + profileImageUrl: string; authorName: string; content: string; - isLiked: boolean; + liked: boolean; } export interface CommentAddData { - postId: Post["postId"]; + postId: Post["id"]; commentContent: CommentData["content"]; } export interface Post { - postId: string; + id: number; imageUrls: string[]; githubRepoUrl: string; content: string; @@ -37,22 +54,28 @@ export interface Post { createdAt: string; updatedAt: string; comments: CommentData[]; - isLiked: boolean; + liked: boolean; } -export interface PostAddFormData { +export interface PostUploadData { files: File[]; githubRepositoryName: string; tags: string[]; content: string; } +export interface PostEditData { + postId: Post["id"]; + tags: string[]; + content: string; +} + export interface GithubStats { - stars: string; - commits: string; - prs: string; - issues: string; - contributes: string; + starsCount: number; + commitsCount: number; + prsCount: number; + issuesCount: number; + contributesCount: number; } export interface GithubRepository { @@ -60,4 +83,38 @@ export interface GithubRepository { name: string; } +export interface TabItem { + name: string; + onTabChange: () => void; +} + export type Tags = string[]; + +export type Step = { + title: string; + path: string; +}; + +export type TabIndicatorKind = "line" | "pill"; + +export type APIErrorCode = keyof typeof API_ERROR_MESSAGE; + +export type ErrorResponse = { + errorCode: APIErrorCode; +}; + +export type HTTPErrorStatus = keyof typeof httpErrorStatus; + +export type HTTPErrorStatusName = typeof httpErrorStatusName[number]; + +export type HTTPErrorHandler = { + [V in HTTPErrorStatusName]?: () => void; +}; + +export type ClientErrorCode = keyof typeof CLIENT_ERROR_MESSAGE; + +export type ClientErrorCodeName = typeof clientErrorCodeName[number]; + +export type ClientErrorHandler = { + [V in ClientErrorCodeName]?: () => void; +}; diff --git a/frontend/src/App.style.ts b/frontend/src/App.style.ts index 373e028d1..687d05819 100644 --- a/frontend/src/App.style.ts +++ b/frontend/src/App.style.ts @@ -28,7 +28,7 @@ export const GlobalStyle = createGlobalStyle` padding: 0; height: 100%; font-size: 16px; - font-family: 'Noto Sans KR', sans-serif; + font-family: 'Noto Sans KR', sans-serif; } body { @@ -69,10 +69,9 @@ export const GlobalStyle = createGlobalStyle` font-size: 2rem; display: block; margin: 20px; - } - a { all: unset; } + a:link { text-decoration: none; color: #3f464d; @@ -90,6 +89,10 @@ export const GlobalStyle = createGlobalStyle` cursor: pointer; } + a, button { + -webkit-tap-highlight-color: transparent; + } + * { box-sizing: border-box; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9e64c3433..d47af8898 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ -import { BrowserRouter, Redirect, Route, Switch } from "react-router-dom"; +import { useContext, useEffect } from "react"; +import { BrowserRouter, Route, Redirect, Switch } from "react-router-dom"; import { PAGE_URL } from "./constants/urls"; import LoginPage from "./pages/LoginPage/LoginPage"; @@ -11,28 +12,64 @@ import AddPostPage from "./pages/AddPostPage/AddPostPage"; import { PostAddDataContextProvider } from "./contexts/PostAddDataContext"; import UserFeedPage from "./pages/UserFeedPage/UserFeedPage"; import TagFeedPage from "./pages/TagFeedPage/TagFeedPage"; +import SearchPage from "./pages/SearchPage/SearchPage"; +import SearchHeader from "./components/@layout/SearchHeader/SearchHeader"; +import UserContext from "./contexts/UserContext"; +import { getAccessToken } from "./storage/storage"; +import { requestGetSelfProfile } from "./services/requests"; +import SnackBarContext from "./contexts/SnackbarContext"; +import { SUCCESS_MESSAGE } from "./constants/messages"; +import EditPostPage from "./pages/EditPostPage/EditPostPage"; +import { PostEditStepContextProvider } from "./contexts/PostEditStepContext"; const App = () => { + const { currentUsername, login, logout } = useContext(UserContext); + const { pushSnackbarMessage } = useContext(SnackBarContext); + + useEffect(() => { + const accessToken = getAccessToken(); + + if (!accessToken || !currentUsername) return; + + (async () => { + try { + const { name } = await requestGetSelfProfile(accessToken); + + login(accessToken, name); + pushSnackbarMessage(SUCCESS_MESSAGE.LOGIN); + } catch (error) { + logout(); + pushSnackbarMessage(SUCCESS_MESSAGE.LOGOUT); + } + })(); + }, []); + return ( - + + + + - + - + + + + @@ -50,6 +87,11 @@ const App = () => { + + + + + diff --git a/frontend/src/assets/icons/arrow-right.svg b/frontend/src/assets/icons/arrow-right.svg new file mode 100644 index 000000000..3f3f060da --- /dev/null +++ b/frontend/src/assets/icons/arrow-right.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/icons/camera.svg b/frontend/src/assets/icons/camera.svg new file mode 100644 index 000000000..0592086a4 --- /dev/null +++ b/frontend/src/assets/icons/camera.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/cancel-no-circle.svg b/frontend/src/assets/icons/cancel-no-circle.svg new file mode 100644 index 000000000..6c1cae147 --- /dev/null +++ b/frontend/src/assets/icons/cancel-no-circle.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/assets/icons/edit.svg b/frontend/src/assets/icons/edit.svg index df35c78d1..90fc5cc29 100644 --- a/frontend/src/assets/icons/edit.svg +++ b/frontend/src/assets/icons/edit.svg @@ -1,6 +1,5 @@ - - - - + + + diff --git a/frontend/src/assets/icons/go-down.svg b/frontend/src/assets/icons/go-down.svg new file mode 100644 index 000000000..706ee024a --- /dev/null +++ b/frontend/src/assets/icons/go-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/index.tsx b/frontend/src/assets/icons/index.tsx index 825f20079..248e041bc 100644 --- a/frontend/src/assets/icons/index.tsx +++ b/frontend/src/assets/icons/index.tsx @@ -4,6 +4,7 @@ export { ReactComponent as AddBoxIcon } from "./add-box.svg"; export { ReactComponent as SearchIcon } from "./search.svg"; export { ReactComponent as LoginIcon } from "./login.svg"; export { ReactComponent as CancelIcon } from "./cancel.svg"; +export { ReactComponent as CancelNoCircleIcon } from "./cancel-no-circle.svg"; export { ReactComponent as GoBackIcon } from "./go-back.svg"; export { ReactComponent as GoForwardIcon } from "./go-forward.svg"; export { ReactComponent as HeartLineIcon } from "./heart-line.svg"; @@ -25,3 +26,8 @@ export { ReactComponent as PrIcon } from "./pr.svg"; export { ReactComponent as IssueIcon } from "./issue.svg"; export { ReactComponent as BookIcon } from "./book.svg"; export { ReactComponent as RepositoryIcon } from "./github-repository.svg"; +export { ReactComponent as CameraIcon } from "./camera.svg"; +export { ReactComponent as GoDownIcon } from "./go-down.svg"; +export { ReactComponent as VerticalDotsIcon } from "./vertical-dots.svg"; +export { ReactComponent as TrashIcon } from "./trash.svg"; +export { ReactComponent as ArrowRightIcon } from "./arrow-right.svg"; diff --git a/frontend/src/assets/icons/trash.svg b/frontend/src/assets/icons/trash.svg new file mode 100644 index 000000000..5ecd3bf58 --- /dev/null +++ b/frontend/src/assets/icons/trash.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/src/assets/icons/vertical-dots.svg b/frontend/src/assets/icons/vertical-dots.svg new file mode 100644 index 000000000..0352cb4a0 --- /dev/null +++ b/frontend/src/assets/icons/vertical-dots.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/src/assets/images/empty-post-image.png b/frontend/src/assets/images/empty-post-image.png new file mode 100644 index 000000000..1335d01c6 Binary files /dev/null and b/frontend/src/assets/images/empty-post-image.png differ diff --git a/frontend/src/components/@layout/BottomSliderPortal/BottomSliderPortal.stories.tsx b/frontend/src/components/@layout/BottomSliderPortal/BottomSliderPortal.stories.tsx new file mode 100644 index 000000000..4df8cf72d --- /dev/null +++ b/frontend/src/components/@layout/BottomSliderPortal/BottomSliderPortal.stories.tsx @@ -0,0 +1,12 @@ +import { Story } from "@storybook/react"; +import BottomSliderPortal, { Props } from "./BottomSliderPortal"; + +export default { + title: "Components/BottomSliderPortal", + component: BottomSliderPortal, +}; + +const Template: Story = (args) => ; + +export const Default = Template.bind({}); +Default.args = {}; diff --git a/frontend/src/components/@layout/BottomSliderPortal/BottomSliderPortal.styles.tsx b/frontend/src/components/@layout/BottomSliderPortal/BottomSliderPortal.styles.tsx new file mode 100644 index 000000000..bac193e32 --- /dev/null +++ b/frontend/src/components/@layout/BottomSliderPortal/BottomSliderPortal.styles.tsx @@ -0,0 +1,20 @@ +import styled from "styled-components"; + +export const Container = styled.div<{ isSliderShown: boolean }>` + position: fixed; + width: 100%; + height: 100%; + z-index: 100000000; + bottom: 0; + border-top-left-radius: 16px; + border-top-right-radius: 16px; + background-color: ${({ theme }) => theme.color.white}; + + transition: transform 0.75s; + + ${({ isSliderShown }) => ` + transform: translateY(${isSliderShown ? "0%" : "100%"}); + box-shadow: ${isSliderShown ? "" : "0px -4px 8px rgba(0, 0, 0, 0.2)"}; + border-radius: ${isSliderShown ? "0" : "initial"}; + `}; +`; diff --git a/frontend/src/components/@layout/BottomSliderPortal/BottomSliderPortal.tsx b/frontend/src/components/@layout/BottomSliderPortal/BottomSliderPortal.tsx new file mode 100644 index 000000000..81de32b27 --- /dev/null +++ b/frontend/src/components/@layout/BottomSliderPortal/BottomSliderPortal.tsx @@ -0,0 +1,20 @@ +import { createPortal } from "react-dom"; +import { Container } from "./BottomSliderPortal.styles"; + +export interface Props { + children: React.ReactNode; + isSliderShown: boolean; +} + +export const BottomSlider = ({ isSliderShown, children }: Props) => { + return {children}; +}; + +const BottomSliderPortal = ({ ...props }: Props) => { + const $BottomSliderWrapper = document.getElementById("bottom-slider"); + if (!$BottomSliderWrapper) throw Error("cannot find bottom-slider wrapper"); + + return createPortal(, $BottomSliderWrapper); +}; + +export default BottomSliderPortal; diff --git a/frontend/src/components/@layout/MessageModalPortal/MessageModalPortal.stories.tsx b/frontend/src/components/@layout/MessageModalPortal/MessageModalPortal.stories.tsx new file mode 100644 index 000000000..c9dedc892 --- /dev/null +++ b/frontend/src/components/@layout/MessageModalPortal/MessageModalPortal.stories.tsx @@ -0,0 +1,16 @@ +import { Story } from "@storybook/react"; +import { MessageModal, Props } from "./MessageModalPortal"; + +export default { + title: "Components/Layout/MessageModal", + component: MessageModal, +}; + +const Template: Story = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + heading: "제목", + onClose: () => {}, + onConfirm: () => {}, +}; diff --git a/frontend/src/components/@layout/MessageModalPortal/MessageModalPortal.styles.tsx b/frontend/src/components/@layout/MessageModalPortal/MessageModalPortal.styles.tsx new file mode 100644 index 000000000..fd256a984 --- /dev/null +++ b/frontend/src/components/@layout/MessageModalPortal/MessageModalPortal.styles.tsx @@ -0,0 +1,71 @@ +import styled from "styled-components"; +import { Z_INDEX } from "../../../constants/layout"; +import { fadeIn } from "../../@styled/keyframes"; + +export const Container = styled.div` + display: flex; + justify-content: center; + align-items: center; + position: fixed; + top: 0; + width: 100vw; + height: 100vh; + z-index: ${Z_INDEX.HIGHEST}; + animation: ${fadeIn} 0.5s forwards; +`; + +export const ModalContent = styled.div` + width: 90%; + max-width: 25rem; + height: 30%; + border-radius: 8px; + box-shadow: 5px 5px 25px rgba(0, 0, 0, 0.2); + background-color: ${({ theme }) => theme.color.white}; + overflow: hidden; +`; + +export const ModalBody = styled.div` + display: flex; + flex-direction: column; + align-items: center; + height: 100%; +`; + +export const Text = styled.span` + flex-grow: 1; + display: flex; + font-size: 1.1rem; + line-height: 1.75rem; + text-align: center; + justify-content: center; + align-items: center; + letter-spacing: 0.7px; + margin-top: 1rem; + padding: 0 1.5rem; + word-break: keep-all; +`; + +const Button = styled.button` + width: 100%; + text-align: center; + padding: 1rem; +`; + +export const ConfirmButton = styled(Button)` + ${({ theme }) => ` + background-color: ${theme.color.primaryColor}}; + color: ${theme.color.white}}; + `} +`; + +export const CancelButton = styled(Button)` + ${({ theme }) => ` + background-color: ${theme.color.lighterTextColor}}; + color: ${theme.color.white}}; + `} +`; + +export const ButtonsWrapper = styled.div` + width: 100%; + display: flex; +`; diff --git a/frontend/src/components/@layout/MessageModalPortal/MessageModalPortal.tsx b/frontend/src/components/@layout/MessageModalPortal/MessageModalPortal.tsx new file mode 100644 index 000000000..5e980befb --- /dev/null +++ b/frontend/src/components/@layout/MessageModalPortal/MessageModalPortal.tsx @@ -0,0 +1,44 @@ +import { createPortal } from "react-dom"; +import { + ButtonsWrapper, + ModalBody, + Container, + ModalContent, + Text, + CancelButton, + ConfirmButton, +} from "./MessageModalPortal.styles"; +import BackDrop from "../../@styled/BackDrop"; + +export interface Props { + heading: string; + onClose: () => void; + onCancel?: () => void; + onConfirm?: () => void; +} + +export const MessageModal = ({ heading, onClose, onCancel, onConfirm }: Props) => { + return ( + + + + + {heading} + + {onCancel && 취소} + {onConfirm && 확인} + + + + + ); +}; + +const MessageModalPortal = ({ ...props }: Props) => { + const $MessageModal = document.getElementById("modal"); + if (!$MessageModal) throw Error("cannot find modal wrapper"); + + return createPortal(, $MessageModal); +}; + +export default MessageModalPortal; diff --git a/frontend/src/components/@layout/Modal/ModalPortal.stories.tsx b/frontend/src/components/@layout/Modal/ModalPortal.stories.tsx new file mode 100644 index 000000000..de295e21a --- /dev/null +++ b/frontend/src/components/@layout/Modal/ModalPortal.stories.tsx @@ -0,0 +1,16 @@ +import { Story } from "@storybook/react"; + +import { Modal, Props } from "./ModalPortal"; + +export default { + title: "Components/Layout/Modal", + component: Modal, +}; + +const Template: Story = (args) => 모달입니다; + +export const Default = Template.bind({}); +Default.args = { + onClose: () => {}, + isCloseButtonShown: true, +}; diff --git a/frontend/src/components/@layout/Modal/ModalPortal.style.ts b/frontend/src/components/@layout/Modal/ModalPortal.style.ts new file mode 100644 index 000000000..bf52da443 --- /dev/null +++ b/frontend/src/components/@layout/Modal/ModalPortal.style.ts @@ -0,0 +1,34 @@ +import styled from "styled-components"; +import { Z_INDEX } from "../../../constants/layout"; +import { fadeIn } from "../../@styled/keyframes"; + +export const Container = styled.div` + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: ${Z_INDEX.MIDDLE}; + + display: flex; + justify-content: center; + align-items: center; + animation: ${fadeIn} 0.5s forwards; +`; + +export const ModalContent = styled.div` + width: fit-content; + height: fit-content; + padding: 0.75rem; + border-radius: 4px; + background-color: ${({ theme }) => theme.color.white}; +`; + +export const CloseButtonWrapper = styled.div` + display: flex; + justify-content: flex-end; + + > button:active { + transform: scale(0.98); + } +`; diff --git a/frontend/src/components/@layout/Modal/ModalPortal.tsx b/frontend/src/components/@layout/Modal/ModalPortal.tsx new file mode 100644 index 000000000..0a63f4427 --- /dev/null +++ b/frontend/src/components/@layout/Modal/ModalPortal.tsx @@ -0,0 +1,40 @@ +import { createPortal } from "react-dom"; +import { CancelNoCircleIcon } from "../../../assets/icons"; +import Button from "../../@shared/Button/Button"; +import BackDrop from "../../@styled/BackDrop"; + +import { CloseButtonWrapper, Container, ModalContent } from "./ModalPortal.style"; + +export interface Props { + onClose: () => void; + isCloseButtonShown?: boolean; + children: React.ReactNode; +} + +export const Modal = ({ onClose, isCloseButtonShown = false, children }: Props) => { + return ( + + + + {isCloseButtonShown && ( + + + + )} + {children} + + + ); +}; + +const ModalPortal = ({ ...props }: Props) => { + const $MessageModal = document.getElementById("modal"); + + if (!$MessageModal) throw Error("cannot find modal wrapper"); + + return createPortal(, $MessageModal); +}; + +export default ModalPortal; diff --git a/frontend/src/components/@layout/NavigationHeader/NavigationHeader.style.ts b/frontend/src/components/@layout/NavigationHeader/NavigationHeader.style.ts index 42371b79f..fa7a665c1 100644 --- a/frontend/src/components/@layout/NavigationHeader/NavigationHeader.style.ts +++ b/frontend/src/components/@layout/NavigationHeader/NavigationHeader.style.ts @@ -5,13 +5,17 @@ import { Header } from "../../@styled/layout"; export const Container = styled(Header)` display: flex; justify-content: space-between; + align-items: center; padding: 1.0625rem 1.375rem; `; -export const HomeLink = styled(Link)``; +export const HomeLink = styled(Link)` + height: fit-content; +`; export const Navigation = styled.nav` display: flex; + align-items: center; `; export const NavigationItem = styled(Link)` diff --git a/frontend/src/components/@layout/NavigationHeader/NavigationHeader.tsx b/frontend/src/components/@layout/NavigationHeader/NavigationHeader.tsx index 589adae06..c101fc096 100644 --- a/frontend/src/components/@layout/NavigationHeader/NavigationHeader.tsx +++ b/frontend/src/components/@layout/NavigationHeader/NavigationHeader.tsx @@ -12,6 +12,9 @@ const NavigationHeader = () => { const UnAuthenticatedNavigation = () => ( + + + @@ -44,8 +47,8 @@ const NavigationHeader = () => { {isLoggedIn ? ( - ) : ( diff --git a/frontend/src/components/@layout/SearchHeader/SearchHeader.stories.tsx b/frontend/src/components/@layout/SearchHeader/SearchHeader.stories.tsx index 675863d8b..d078e96d6 100644 --- a/frontend/src/components/@layout/SearchHeader/SearchHeader.stories.tsx +++ b/frontend/src/components/@layout/SearchHeader/SearchHeader.stories.tsx @@ -1,13 +1,13 @@ import { Story } from "@storybook/react"; -import SearchHeader, { Props } from "./SearchHeader"; +import SearchHeader from "./SearchHeader"; export default { title: "Components/Layout/SearchHeader", component: SearchHeader, }; -const Template: Story = (args) => ; +const Template: Story = (args) => ; export const Default = Template.bind({}); Default.args = {}; diff --git a/frontend/src/components/@layout/SearchHeader/SearchHeader.tsx b/frontend/src/components/@layout/SearchHeader/SearchHeader.tsx index b73a8fbda..3b2654f88 100644 --- a/frontend/src/components/@layout/SearchHeader/SearchHeader.tsx +++ b/frontend/src/components/@layout/SearchHeader/SearchHeader.tsx @@ -1,24 +1,45 @@ -import Input from "../../@shared/Input/Input"; -import { Container, GoBackLink, SearchInputWrapper } from "./SearchHeader.style"; -import { GoBackIcon } from "../../../assets/icons"; +import { useContext, useEffect, useState } from "react"; import { useHistory } from "react-router-dom"; -export interface Props extends React.HTMLAttributes {} +import Input from "../../@shared/Input/Input"; +import { Container, GoBackLink, SearchInputWrapper } from "./SearchHeader.style"; +import { GoBackIcon, SearchIcon } from "../../../assets/icons"; +import SearchContext from "../../../contexts/SearchContext"; +import useDebounce from "../../../services/hooks/@common/useDebounce"; -const SearchHeader = ({}: Props) => { +const SearchHeader = () => { + const [localKeyword, setLocalKeyword] = useState(""); const history = useHistory(); + const { onKeywordChange } = useContext(SearchContext); const handleGoBack = () => { history.goBack(); }; + const applyKeywordToContext = useDebounce(() => onKeywordChange(localKeyword), 300); + + const handleKeywordChange: React.ChangeEventHandler = ({ target: { value } }) => { + setLocalKeyword(value); + applyKeywordToContext(); + }; + + useEffect(() => { + onKeywordChange(""); + }, []); + return ( - + } + placeholder="검색하기" + /> ); diff --git a/frontend/src/components/@shared/Snackbar/Snackbar.stories.tsx b/frontend/src/components/@layout/Snackbar/Snackbar.stories.tsx similarity index 100% rename from frontend/src/components/@shared/Snackbar/Snackbar.stories.tsx rename to frontend/src/components/@layout/Snackbar/Snackbar.stories.tsx diff --git a/frontend/src/components/@shared/Snackbar/Snackbar.style.ts b/frontend/src/components/@layout/Snackbar/Snackbar.style.ts similarity index 54% rename from frontend/src/components/@shared/Snackbar/Snackbar.style.ts rename to frontend/src/components/@layout/Snackbar/Snackbar.style.ts index ba1f225eb..47e7c3ec0 100644 --- a/frontend/src/components/@shared/Snackbar/Snackbar.style.ts +++ b/frontend/src/components/@layout/Snackbar/Snackbar.style.ts @@ -1,26 +1,5 @@ -import styled, { css, keyframes } from "styled-components"; - -const BottomToBottom = keyframes` - from { - opacity: 0; - transform: translateX(-50%) translateY(50%); - } - - 25% { - opacity: 1; - transform: translateX(-50%) translateY(0); - } - - 75% { - opacity: 1; - transform: translateX(-50%) translateY(0); - } - - to { - opacity: 0; - transform: translateX(-50%) translateY(50%); - } -`; +import styled, { css } from "styled-components"; +import { bottomToBottom } from "../../@styled/keyframes"; export const Container = styled.div<{ snackbarDuration: number } & React.CSSProperties>` display: flex; @@ -42,6 +21,6 @@ export const Container = styled.div<{ snackbarDuration: number } & React.CSSProp padding: 1rem 2rem; animation: ${({ animationDuration }) => css` - ${BottomToBottom} ${animationDuration} ease + ${bottomToBottom} ${animationDuration} ease `}; `; diff --git a/frontend/src/components/@shared/Snackbar/Snackbar.tsx b/frontend/src/components/@layout/Snackbar/Snackbar.tsx similarity index 100% rename from frontend/src/components/@shared/Snackbar/Snackbar.tsx rename to frontend/src/components/@layout/Snackbar/Snackbar.tsx diff --git a/frontend/src/components/@layout/StepHeader/StepHeader.tsx b/frontend/src/components/@layout/StepHeader/StepHeader.tsx index 6e5912740..05e2ffb01 100644 --- a/frontend/src/components/@layout/StepHeader/StepHeader.tsx +++ b/frontend/src/components/@layout/StepHeader/StepHeader.tsx @@ -1,6 +1,5 @@ import { Container, Content, EmptySpace, StepLink } from "./StepHeader.style"; import { GoBackIcon, GoForwardIcon } from "../../../assets/icons"; -import { useHistory } from "react-router-dom"; export interface Props extends React.HTMLAttributes { isNextStepExist: boolean; diff --git a/frontend/src/components/@shared/Button/Button.style.ts b/frontend/src/components/@shared/Button/Button.style.ts index cf6a8e9b2..f747f6393 100644 --- a/frontend/src/components/@shared/Button/Button.style.ts +++ b/frontend/src/components/@shared/Button/Button.style.ts @@ -14,20 +14,20 @@ const Button = styled.button` } `; -const InlineButton = styled(Button)` +const InlineButton = styled(Button)` display: inline-block; - padding: 0.4375rem 0.875rem; + padding: ${({ padding }) => `${padding ?? "0.4375rem 0.875rem"}`}; font-size: 0.75rem; `; const BlockButton = styled(Button)` display: block; width: 100%; - padding: 0.5rem; + padding: ${({ padding }) => `${padding ?? "0.5rem"}`}; font-size: 1rem; `; -export const SquaredInlineButton = styled(InlineButton)` +export const SquaredInlineButton = styled(InlineButton)` border-radius: 4px; `; diff --git a/frontend/src/components/@shared/Button/Button.tsx b/frontend/src/components/@shared/Button/Button.tsx index 06aa46d04..bd678953a 100644 --- a/frontend/src/components/@shared/Button/Button.tsx +++ b/frontend/src/components/@shared/Button/Button.tsx @@ -4,6 +4,7 @@ export interface Props extends React.ButtonHTMLAttributes { kind?: "squaredInline" | "squaredBlock" | "roundedInline" | "roundedBlock"; backgroundColor?: string; color?: string; + padding?: string; } const Button = ({ kind = "squaredInline", ...props }: Props) => { diff --git a/frontend/src/components/@shared/ButtonDrawer/ButtonDrawer.stories.tsx b/frontend/src/components/@shared/ButtonDrawer/ButtonDrawer.stories.tsx new file mode 100644 index 000000000..5bbbef793 --- /dev/null +++ b/frontend/src/components/@shared/ButtonDrawer/ButtonDrawer.stories.tsx @@ -0,0 +1,23 @@ +import { Story } from "@storybook/react"; + +import { TrashIcon, EditIcon } from "../../../assets/icons"; +import ButtonDrawer, { Props } from "./ButtonDrawer"; + +export default { + title: "Components/Shared/ButtonDrawer", + component: ButtonDrawer, +}; + +const Template: Story = (args) => ( +
+ +
+); + +export const Default = Template.bind({}); +Default.args = { + circleButtons: [ + { node: , onClick: () => alert("click!") }, + { node: , onClick: () => alert("click!") }, + ], +}; diff --git a/frontend/src/components/@shared/ButtonDrawer/ButtonDrawer.style.ts b/frontend/src/components/@shared/ButtonDrawer/ButtonDrawer.style.ts new file mode 100644 index 000000000..c8407c874 --- /dev/null +++ b/frontend/src/components/@shared/ButtonDrawer/ButtonDrawer.style.ts @@ -0,0 +1,47 @@ +import styled from "styled-components"; +import { Z_INDEX } from "../../../constants/layout"; + +export const Container = styled.span` + position: relative; + display: inline-flex; + justify-content: center; + align-items: center; +`; + +export const VerticalDotsWrapper = styled.div` + cursor: pointer; +`; + +export const CircleButton = styled.div<{ + isShown: boolean; + index: number; + buttonsCount: number; +}>` + position: absolute; + top: 0; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + border-radius: 50%; + width: 2.2rem; + height: 2.2rem; + background-color: ${({ theme }) => theme.color.white}; + box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.2); + transition: box-shadow 0.5s, opacity 0.5s, transform 0.5s; + visibility: hidden; + opacity: 0; + z-index: ${Z_INDEX.LOW}; + + :hover { + box-shadow: 2px 6px 12px rgba(0, 0, 0, 0.2); + } + + ${({ isShown, index, buttonsCount }) => ` + visibility: ${isShown ? "visible" : "hidden"}; + opacity: ${isShown ? "1" : "0"}; + transform: ${ + isShown ? `translate(${(-3.5 * index) / buttonsCount - 0.3}rem, ${(-3.5 * index) / buttonsCount + 1.2}rem)` : "" + }; +`} +`; diff --git a/frontend/src/components/@shared/ButtonDrawer/ButtonDrawer.tsx b/frontend/src/components/@shared/ButtonDrawer/ButtonDrawer.tsx new file mode 100644 index 000000000..21ca5b465 --- /dev/null +++ b/frontend/src/components/@shared/ButtonDrawer/ButtonDrawer.tsx @@ -0,0 +1,43 @@ +import { useState } from "react"; +import { VerticalDotsIcon } from "../../../assets/icons"; +import { Container, CircleButton, VerticalDotsWrapper } from "./ButtonDrawer.style"; + +type CircleButton = { + node: React.ReactNode; + onClick: () => void; +}; + +export interface Props extends React.HTMLAttributes { + circleButtons: CircleButton[]; +} + +const ButtonDrawer = ({ circleButtons }: Props) => { + const [isButtonsShown, setIsButtonsShown] = useState(false); + + const circleButtonItems = circleButtons.map((circleButton, index) => ( + + {circleButton.node} + + )); + + const handleVerticalDotsClick = () => { + setIsButtonsShown(!isButtonsShown); + }; + + return ( + + + + + {circleButtonItems} + + ); +}; + +export default ButtonDrawer; diff --git a/frontend/src/components/@shared/Comment/Comment.stories.tsx b/frontend/src/components/@shared/Comment/Comment.stories.tsx index 425755a12..1d62d031f 100644 --- a/frontend/src/components/@shared/Comment/Comment.stories.tsx +++ b/frontend/src/components/@shared/Comment/Comment.stories.tsx @@ -13,5 +13,5 @@ export const Default = Template.bind({}); Default.args = { authorName: "Tanney", content: "개발 너무 재미있어 미치겠어", - isLiked: false, + liked: false, }; diff --git a/frontend/src/components/@shared/Comment/Comment.tsx b/frontend/src/components/@shared/Comment/Comment.tsx index 5ae0d7fe0..5d6f3e309 100644 --- a/frontend/src/components/@shared/Comment/Comment.tsx +++ b/frontend/src/components/@shared/Comment/Comment.tsx @@ -3,20 +3,20 @@ import { HeartIcon, HeartLineIcon } from "../../../assets/icons"; export interface Props { authorName: string; - content: string; - isLiked: boolean; + content: React.ReactNode; + liked: boolean; link?: string; onCommentLike: () => void; } -const Comment = ({ authorName, link, content, isLiked, onCommentLike }: Props) => { +const Comment = ({ authorName, link, content, liked, onCommentLike }: Props) => { return (
{authorName} {content}
- {isLiked ? : } + {liked ? : }
); }; diff --git a/frontend/src/components/@shared/ContributionGraph/ContributionGraph.style.ts b/frontend/src/components/@shared/ContributionGraph/ContributionGraph.style.ts index 556b1dc70..ccced4772 100644 --- a/frontend/src/components/@shared/ContributionGraph/ContributionGraph.style.ts +++ b/frontend/src/components/@shared/ContributionGraph/ContributionGraph.style.ts @@ -1,4 +1,3 @@ -import defaultImage from "../../../assets/images/default-image.png"; import styled from "styled-components"; export const Container = styled.div<{ columnCount: number; rowCount: number }>` diff --git a/frontend/src/components/@shared/ImageSlider/ImageSlider.style.ts b/frontend/src/components/@shared/ImageSlider/ImageSlider.style.ts index dc86df447..f84e28297 100644 --- a/frontend/src/components/@shared/ImageSlider/ImageSlider.style.ts +++ b/frontend/src/components/@shared/ImageSlider/ImageSlider.style.ts @@ -5,7 +5,7 @@ export const Container = styled.div` width: ${width}; `} - overflow-x: hidden; + overflow: hidden; position: relative; `; @@ -62,3 +62,20 @@ export const SlideButton = styled.button<{ filter: brightness(1.2); } `; + +export const Indicator = styled.div` + position: absolute; + left: 50%; + bottom: 0.8125rem; + transform: translateX(-50%); + + display: flex; + justify-content: center; + width: 2.5rem; + font-size: 0.6rem; + line-height: 0.9; + color: ${({ theme }) => theme.color.white}; + background-color: rgba(0, 0, 0, 0.4); + padding: 0.3rem; + border-radius: 10px; +`; diff --git a/frontend/src/components/@shared/ImageSlider/ImageSlider.tsx b/frontend/src/components/@shared/ImageSlider/ImageSlider.tsx index 7970dd661..510524ced 100644 --- a/frontend/src/components/@shared/ImageSlider/ImageSlider.tsx +++ b/frontend/src/components/@shared/ImageSlider/ImageSlider.tsx @@ -3,7 +3,7 @@ import { ThemeContext } from "styled-components"; import { GoBackIcon, GoForwardIcon } from "../../../assets/icons/index"; import useThrottle from "../../../services/hooks/@common/useThrottle"; -import { Container, ImageList, ImageListSlider, SlideButton } from "./ImageSlider.style"; +import { Container, ImageList, ImageListSlider, Indicator, SlideButton } from "./ImageSlider.style"; export interface Props extends React.CSSProperties { imageUrls: string[]; @@ -12,7 +12,7 @@ export interface Props extends React.CSSProperties { const SLIDE_THROTTLE_DELAY = 800; -const ImageSlider = ({ imageUrls, slideButtonKind, width }: Props) => { +const ImageSlider = ({ imageUrls, width, slideButtonKind }: Props) => { const [imageIndex, setImageIndex] = useState(0); const [isFirstImage, setIsFirstImage] = useState(true); const [isLastImage, setIsLastImage] = useState(false); @@ -73,6 +73,11 @@ const ImageSlider = ({ imageUrls, slideButtonKind, width }: Props) => { )} + {imageCount.current > 1 && ( + + {imageIndex + 1} / {imageCount.current} + + )} ); }; diff --git a/frontend/src/components/@shared/InfiniteScrollContainer/InfiniteScrollContainer.tsx b/frontend/src/components/@shared/InfiniteScrollContainer/InfiniteScrollContainer.tsx index 761658adb..eac2e503a 100644 --- a/frontend/src/components/@shared/InfiniteScrollContainer/InfiniteScrollContainer.tsx +++ b/frontend/src/components/@shared/InfiniteScrollContainer/InfiniteScrollContainer.tsx @@ -22,7 +22,7 @@ const InfiniteScrollContainer = ({ isLoaderShown, onIntersect, children }: Props useEffect(() => { loaderRef.current && observer.observe(loaderRef.current); - }, [loaderRef]); + }, [loaderRef.current]); return ( diff --git a/frontend/src/components/@shared/Input/Input.tsx b/frontend/src/components/@shared/Input/Input.tsx index d7f4f63f8..9190b5007 100644 --- a/frontend/src/components/@shared/Input/Input.tsx +++ b/frontend/src/components/@shared/Input/Input.tsx @@ -5,17 +5,30 @@ export interface Props extends React.HTMLAttributes, StyleProp kind?: "borderBottom" | "rounded"; icon?: React.ReactNode; name?: string; + value?: string; } -const Input = ({ kind, icon, textAlign = "left", backgroundColor, bottomBorderColor, name, ...props }: Props) => { +const Input = ({ + kind, + icon, + textAlign = "left", + backgroundColor, + bottomBorderColor, + name, + value, + ...props +}: Props) => { const inputRef = useRef(null); const input = ( <> {icon} diff --git a/frontend/src/components/@shared/Loader/Loader.style.ts b/frontend/src/components/@shared/Loader/Loader.style.ts index 960587103..5bb4aa629 100644 --- a/frontend/src/components/@shared/Loader/Loader.style.ts +++ b/frontend/src/components/@shared/Loader/Loader.style.ts @@ -1,23 +1,5 @@ -import styled, { keyframes } from "styled-components"; - -const bounceAnimation = keyframes` - 0%, 80%, 100% { - transform: scale(0); - } - 40% { - transform: scale(1); - } -`; - -const spinAnimation = keyframes` - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } -`; +import styled from "styled-components"; +import { bounceAnimation, spinAnimation } from "../../@styled/keyframes"; export const LoadingDots = styled.div``; diff --git a/frontend/src/components/@shared/PostItem/PostItem.stories.tsx b/frontend/src/components/@shared/PostItem/PostItem.stories.tsx index fd0438389..32b6ce9ca 100644 --- a/frontend/src/components/@shared/PostItem/PostItem.stories.tsx +++ b/frontend/src/components/@shared/PostItem/PostItem.stories.tsx @@ -25,19 +25,23 @@ Default.args = { "가는 그들에게 너의 설산에서 것은 곳으로 힘차게 그러므로 사막이다. 피어나는 않는 영원히 우리의 용기가 풍부하게 교향악이다. 예가 황금시대를 열락의 같이, 있다. 소금이라 피가 황금시대의 때에, 것이다. 생생하며, 원질이 찬미를 주는 고동을 튼튼하며, 그들은 이상의 피다. 살았으며, 행복스럽고 방황하였으며, 쓸쓸하랴? 가는 생생하며, 되는 싸인 불러 인간에 소금이라 방황하여도, 사랑의 힘있다. 인도하겠다는 대한 청춘을 봄바람이다. 불어 끓는 쓸쓸한 거친 무엇을 내려온 장식하는 것이다. 열매를 끓는 끓는 착목한는 쓸쓸한 봄바람이다. 원대하고, 사랑의 살 부패를 없으면, 그들은 이것을 그들의 고동을 것이다. 타오르고 공자는 천고에 미묘한 듣기만 것이다. 것은 이상을 피어나기 피다. 방황하였으며, 돋고, 이상은 이상 귀는 길지 내는 발휘하기 풍부하게 아니다. 끝에 그러므로 밝은 하였으며, 것이다. 옷을 공자는 있으며, 놀이 이상을 끓는다. 새 그들은 청춘이 없는 어디 사는가 힘차게 위하여, 있다. 원질이 오아이스도 그들의 철환하였는가? 끓는 만천하의 듣기만 없으면 이상의 노래하며 이것을 것이다. 현저하게 살았으며, 대한 같지 아니다.", comments: [ { - commentId: "1", + id: 1, + profileImageUrl: + "https://images.unsplash.com/photo-1543610892-0b1f7e6d8ac1?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=634&q=80", authorName: "swon3210", content: "이게 댓글이지", - isLiked: true, + liked: true, }, { - commentId: "2", + id: 2, + profileImageUrl: + "https://images.unsplash.com/photo-1543610892-0b1f7e6d8ac1?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=634&q=80", authorName: "swon3210", content: "이게 댓글이지", - isLiked: true, + liked: true, }, ], createdAt: "3일 전", - isLiked: true, + liked: true, likeCount: 32, }; diff --git a/frontend/src/components/@shared/PostItem/PostItem.style.ts b/frontend/src/components/@shared/PostItem/PostItem.style.ts index eb74c7466..52be5c344 100644 --- a/frontend/src/components/@shared/PostItem/PostItem.style.ts +++ b/frontend/src/components/@shared/PostItem/PostItem.style.ts @@ -92,14 +92,34 @@ export const TagItemLinkButton = styled(Link)` margin-bottom: 0.5625rem; `; +export const CommentsWrapper = styled.div``; + export const CommentWrapper = styled.div` margin-bottom: 0.5rem; `; -export const MyComment = styled.div` +export const CommentSliderToggleLink = styled.a` display: flex; + justify-content: flex-end; align-items: center; - padding: 0 0.75rem; + padding: 0 1rem; + font-size: 10px; + transition: opacity 0.5s; + + :hover { + opacity: 0.5; + } +`; + +export const CommentSliderToggleLinkText = styled.span` + font-size: 12px; + font-weight: bold; + margin-right: 0.5rem; +`; + +export const MoreCommentExistIndicator = styled.div` + text-align: center; + cursor: pointer; `; export const CommentInputWrapper = styled.div` @@ -109,6 +129,6 @@ export const CommentInputWrapper = styled.div` export const PostCreatedDateText = styled.span` padding: 0.75rem; - font-size: 0.6875rem; + font-size: 0.75rem; font-weight: bold; `; diff --git a/frontend/src/components/@shared/PostItem/PostItem.tsx b/frontend/src/components/@shared/PostItem/PostItem.tsx index cc0991357..3c7375ffe 100644 --- a/frontend/src/components/@shared/PostItem/PostItem.tsx +++ b/frontend/src/components/@shared/PostItem/PostItem.tsx @@ -1,9 +1,8 @@ import { Container, - MyComment, - CommentInputWrapper, + CommentSliderToggleLink, + CommentsWrapper, CommentWrapper, - IconLinkButton, IconLink, IconLinkButtonsWrapper, LikeCountText, @@ -17,6 +16,8 @@ import { TagItemLinkButton, PostCreatedDateText, MoreContentLinkButton, + CommentSliderToggleLinkText, + MoreCommentExistIndicator, } from "./PostItem.style"; import Avatar from "../Avatar/Avatar"; import CircleIcon from "../CircleIcon/CircleIcon"; @@ -24,32 +25,44 @@ import Comment from "../Comment/Comment"; import ImageSlider from "../ImageSlider/ImageSlider"; import Chip from "../Chip/Chip"; import { CommentData } from "../../../@types"; -import { EditIcon, PostHeartIcon, PostHeartLineIcon, GithubIcon, SendIcon } from "../../../assets/icons"; +import { + EditIcon, + PostHeartIcon, + PostHeartLineIcon, + GithubIcon, + TrashIcon, + ArrowRightIcon, +} from "../../../assets/icons"; import { useContext, useState } from "react"; import { ThemeContext } from "styled-components"; import { PAGE_URL } from "../../../constants/urls"; import { LIMIT } from "../../../constants/limits"; -import TextEditor from "../TextEditor/TextEditor"; import { getTimeDiffFromCurrent } from "../../../utils/date"; +import EmptyPostImage from "../../../assets/images/empty-post-image.png"; +import ButtonDrawer from "../ButtonDrawer/ButtonDrawer"; +import { getTextElementsWithWithBr } from "../../../utils/text"; export interface Props { + currentUserName: string; authorName: string; authorImageUrl: string; authorGithubUrl: string; isEditable: boolean; imageUrls: string[]; likeCount: number; - isLiked: boolean; + liked: boolean; content: string; comments: CommentData[]; commenterImageUrl: string; tags: string[]; createdAt: string; - commentValue: string; - onCommentValueChange: React.ChangeEventHandler; - onCommentValueSave: () => void; + isLoggedIn: boolean; + onMoreCommentClick: () => void; + onCommentInputClick: () => void; + onPostEdit: () => void; + onPostDelete: () => void; onPostLike: () => void; - onCommentLike: (commentId: string) => void; + onCommentLike: (commentId: CommentData["id"]) => void; } const timeDiffTextTable = { @@ -60,25 +73,27 @@ const timeDiffTextTable = { }; const PostItem = ({ + currentUserName, authorName, authorImageUrl, authorGithubUrl, isEditable, imageUrls, likeCount, - isLiked, + liked, content, comments, - commenterImageUrl, tags, createdAt, - commentValue, - onCommentValueChange, - onCommentValueSave, + isLoggedIn, + onMoreCommentClick, + onCommentInputClick, onCommentLike, + onPostEdit, + onPostDelete, onPostLike, }: Props) => { - const [shouldHideContent, setShouldHideContent] = useState(content.length > LIMIT.POST_CONTENT_HIDE_LENGTH); + const [shouldHideContent, setShouldHideContent] = useState(true); const { color } = useContext(ThemeContext); const { min, hour, day } = getTimeDiffFromCurrent(createdAt); @@ -90,28 +105,38 @@ const PostItem = ({ ? timeDiffTextTable.min(min) : timeDiffTextTable.sec(); + const circleButtons = [ + { node: , onClick: onPostEdit }, + { node: , onClick: onPostDelete }, + ]; + const commentList = comments.map((comment) => ( - + onCommentLike(comment.commentId)} + link={currentUserName === comment.authorName ? PAGE_URL.MY_PROFILE : PAGE_URL.USER_PROFILE(comment.authorName)} + onCommentLike={() => onCommentLike(comment.id)} /> )); - const tagList = JSON.parse(tags.join(",")).map((tag: string) => ( - + const tagList = tags.map((tag: string, index: number) => ( + // TODO: key prop 수정 => tag가 unique임이 보장된 후에! + {tag} )); - const onMoreContentShow = () => { + const handleMoreContentShow = () => { setShouldHideContent(false); }; + const handleMoreContentHide = () => { + setShouldHideContent(true); + }; + return ( @@ -119,16 +144,16 @@ const PostItem = ({ {authorName} - {isEditable && ( - - - - )} + {isEditable && } - + - {isLiked ? : } + {isLoggedIn ? ( + {liked ? : } + ) : ( +
+ )} @@ -138,29 +163,31 @@ const PostItem = ({ 좋아요 {likeCount}개 {authorName} - {shouldHideContent ? content.slice(0, LIMIT.POST_CONTENT_HIDE_LENGTH).concat("...") : content} - {shouldHideContent && 더보기} + {shouldHideContent + ? getTextElementsWithWithBr(content) + .slice(0, LIMIT.POST_CONTENT_HIDE_LENGTH) + .concat(...) + : getTextElementsWithWithBr(content)} + {shouldHideContent ? ( + 더보기 + ) : ( + 간략히 + )} {shouldHideContent || tagList} - {commentList} + + {commentList.length > 10 + ? commentList + .slice(0, 10) + .concat(...) + : commentList} +
- - - - - - - - - {currentTimeDiffText} + + {isLoggedIn ? "댓글 작성" : "댓글 보기"} + +
); }; diff --git a/frontend/src/components/@shared/ProfileHeader/ProfileHeader.stories.tsx b/frontend/src/components/@shared/ProfileHeader/ProfileHeader.stories.tsx index 3027e1658..dfb32630a 100644 --- a/frontend/src/components/@shared/ProfileHeader/ProfileHeader.stories.tsx +++ b/frontend/src/components/@shared/ProfileHeader/ProfileHeader.stories.tsx @@ -10,7 +10,7 @@ export default { const mockProfile = { name: "Beuccol", - image: + imageUrl: "https://images.unsplash.com/photo-1518574095400-c75c9b094daa?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=934&q=80", description: "브로콜리입니다.", followingCount: 154, diff --git a/frontend/src/components/@shared/ProfileHeader/ProfileHeader.style.ts b/frontend/src/components/@shared/ProfileHeader/ProfileHeader.style.ts index 9bfaefe21..456bf1316 100644 --- a/frontend/src/components/@shared/ProfileHeader/ProfileHeader.style.ts +++ b/frontend/src/components/@shared/ProfileHeader/ProfileHeader.style.ts @@ -1,4 +1,6 @@ import styled from "styled-components"; +import Button from "../Button/Button"; +import { Spinner } from "../Loader/Loader.style"; export const Container = styled.div` display: flex; @@ -12,3 +14,15 @@ export const Indicators = styled.div` width: 13.25rem; margin-bottom: 0.8125rem; `; + +export const ButtonLoader = styled(Button)` + opacity: 0.6; + position: relative; +`; + +export const ButtonSpinner = styled(Spinner)` + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +`; diff --git a/frontend/src/components/@shared/ProfileHeader/ProfileHeader.tsx b/frontend/src/components/@shared/ProfileHeader/ProfileHeader.tsx index b2a3ab4da..3794bc160 100644 --- a/frontend/src/components/@shared/ProfileHeader/ProfileHeader.tsx +++ b/frontend/src/components/@shared/ProfileHeader/ProfileHeader.tsx @@ -1,35 +1,32 @@ import { useContext } from "react"; import { ThemeContext } from "styled-components"; - import { ProfileData } from "../../../@types"; -import SnackBarContext from "../../../contexts/SnackbarContext"; + import UserContext from "../../../contexts/UserContext"; -import { useFollowingMutation, useUnfollowingMutation } from "../../../services/queries"; +import useModal from "../../../services/hooks/@common/useModal"; +import useFollow from "../../../services/hooks/useFollow"; +import ModalPortal from "../../@layout/Modal/ModalPortal"; +import ProfileModificationForm from "../../ProfileModificationForm/ProfileModificationForm"; import Avatar from "../Avatar/Avatar"; import Button from "../Button/Button"; import CountIndicator from "../CountIndicator/CountIndicator"; -import { Container, Indicators } from "./ProfileHeader.style"; +import { ButtonLoader, ButtonSpinner, Container, Indicators } from "./ProfileHeader.style"; export interface Props { - profile?: ProfileData; isMyProfile: boolean; + profile: ProfileData | null; + username: string; } -const ProfileHeader = ({ profile, isMyProfile }: Props) => { +const ProfileHeader = ({ isMyProfile, profile, username }: Props) => { const theme = useContext(ThemeContext); const { isLoggedIn } = useContext(UserContext); - const { pushMessage } = useContext(SnackBarContext); - - const { mutate: follow, isLoading: isFollowLoading } = useFollowingMutation(profile?.name); - const { mutate: unFollow, isLoading: isUnfollowLoading } = useUnfollowingMutation(profile?.name); - - const onFollowButtonClick = () => { - if (profile?.following === null) return; + const { isModalShown, showModal, hideModal } = useModal(false); + const { toggleFollow, isFollowLoading, isUnfollowLoading } = useFollow(); - if (profile?.following) { - unFollow(); - } else { - follow(); + const handleFollowButtonClick = () => { + if (profile && profile.following !== null) { + toggleFollow(username, profile.following); } }; @@ -39,12 +36,17 @@ const ProfileHeader = ({ profile, isMyProfile }: Props) => { } if (isFollowLoading || isUnfollowLoading) { - return
loading
; + return ( + + {isFollowLoading ? "팔로우" : "팔로우 취소"} + + + ); } if (isMyProfile) { return ( - ); @@ -56,14 +58,14 @@ const ProfileHeader = ({ profile, isMyProfile }: Props) => { type="button" kind="squaredBlock" backgroundColor={theme.color.tertiaryColor} - onClick={onFollowButtonClick} + onClick={handleFollowButtonClick} > 팔로우 취소 ); } else { return ( - ); @@ -72,7 +74,7 @@ const ProfileHeader = ({ profile, isMyProfile }: Props) => { return ( - +
@@ -81,6 +83,16 @@ const ProfileHeader = ({ profile, isMyProfile }: Props) => {
+ {isModalShown && isLoggedIn && ( + + + + )}
); }; diff --git a/frontend/src/components/@shared/Tabs/Tabs.stories.tsx b/frontend/src/components/@shared/Tabs/Tabs.stories.tsx index 27c02c445..da7c78944 100644 --- a/frontend/src/components/@shared/Tabs/Tabs.stories.tsx +++ b/frontend/src/components/@shared/Tabs/Tabs.stories.tsx @@ -14,11 +14,11 @@ Default.args = { tabItems: [ { name: "포스트", - content: "포스트 내용", + onTabChange: () => {}, }, { name: "Github 통계", - content: "활동 통계 내용", + onTabChange: () => {}, }, ], }; diff --git a/frontend/src/components/@shared/Tabs/Tabs.style.ts b/frontend/src/components/@shared/Tabs/Tabs.style.ts index 301c920be..4bed9461b 100644 --- a/frontend/src/components/@shared/Tabs/Tabs.style.ts +++ b/frontend/src/components/@shared/Tabs/Tabs.style.ts @@ -1,8 +1,10 @@ import styled from "styled-components"; +import { TabIndicatorKind } from "../../../@types"; export const Container = styled.section` width: 100%; - overflow: hidden; + height: fit-content; + overflow-x: hidden; `; export const TabButtonWrapper = styled.div` @@ -11,45 +13,52 @@ export const TabButtonWrapper = styled.div` justify-content: space-between; `; -export const TabButton = styled.a<{ textColor?: string }>` +export const TabButton = styled.button<{ + textColor?: string; + tabIndicatorKind: TabIndicatorKind; +}>` width: 100%; flex-grow: 1; - padding: 0.625rem; + padding: 0.5rem 0.625rem 0.625rem 0.625rem; text-align: center; font-weight: 600; overflow: hidden; - color: ${({ theme, textColor }) => (textColor ? textColor : theme.color.textColor)}; + transition: background-color 0.5s, opacity 0.5s; + + ${({ theme, textColor, tabIndicatorKind }) => ` + color: ${textColor ? textColor : theme.color.textColor}; - transition: background-color 0.5s; - :hover { - background-color: #eee; - } + :hover { + background-color: ${tabIndicatorKind === "line" && "#eee"}; + } + `}; `; -export const TabIndicator = styled.div<{ tabIndex: number; tabCount: number; tabIndicatorColor?: string }>` +export const TabIndicator = styled.div<{ + tabIndicatorKind: TabIndicatorKind; + tabIndex: number; + tabCount: number; + tabIndicatorColor?: string; +}>` position: absolute; - border-bottom: 2px solid ${({ theme, tabIndicatorColor }) => tabIndicatorColor ?? theme.color.primaryColor}; bottom: 0; - z-index: 100; transition: transform 0.5s; ${({ tabIndex, tabCount }) => ` transform: translateX(${100 * tabIndex}%); width: ${100 / tabCount}%; `}; -`; - -export const TabContentWrapper = styled.div<{ tabIndex: number; tabCount: number }>` - ${({ tabCount, tabIndex }) => ` - width: ${tabCount * 100}%; - transform: translateX(-${(100 / tabCount) * tabIndex}%); - `} - - display: flex; - padding-top: 0.3rem; - transition: transform 0.5s; -`; -export const TabContent = styled.div<{ tabCount: number }>` - width: ${({ tabCount }) => 100 / tabCount}%; + ${({ theme, tabIndicatorKind, tabIndicatorColor }) => + tabIndicatorKind === "line" + ? ` + border-bottom: 2px solid ${tabIndicatorColor ?? theme.color.primaryColor}; + z-index: 100; + ` + : ` + background-color: ${tabIndicatorColor ?? theme.color.primaryColor}; + border-radius: 24px; + height: 100%; + z-index: -1; + `} `; diff --git a/frontend/src/components/@shared/Tabs/Tabs.tsx b/frontend/src/components/@shared/Tabs/Tabs.tsx index a7fcc442d..52c3f605a 100644 --- a/frontend/src/components/@shared/Tabs/Tabs.tsx +++ b/frontend/src/components/@shared/Tabs/Tabs.tsx @@ -1,29 +1,29 @@ -import { useContext, useState } from "react"; +import { useState } from "react"; -import { ThemeContext } from "styled-components"; -import { Container, TabIndicator, TabButton, TabButtonWrapper, TabContentWrapper, TabContent } from "./Tabs.style"; +import { TabIndicatorKind, TabItem } from "../../../@types"; +import { getTabTextColor } from "../../../utils/tabs"; +import { Container, TabIndicator, TabButton, TabButtonWrapper } from "./Tabs.style"; export interface Props extends React.HTMLAttributes { - tabItems: { - name: string; - content: React.ReactNode; - }[]; + tabItems: TabItem[]; tabIndicatorColor?: string; + tabIndicatorKind: TabIndicatorKind; } -const Tabs = ({ tabItems, tabIndicatorColor, ...props }: Props) => { - const { color } = useContext(ThemeContext); +const Tabs = ({ tabIndicatorKind, tabItems, tabIndicatorColor, ...props }: Props) => { const [tabIndex, setTabIndex] = useState(0); - const handleTabIndexChange = (index: number) => { + const handleTabIndexChange = (index: number, onTabChange: () => void) => { setTabIndex(index); + onTabChange(); }; const tabButtonList = tabItems.map((tabItem, index) => ( handleTabIndexChange(index)} + textColor={getTabTextColor(tabIndicatorKind, index === tabIndex)} + onClick={() => handleTabIndexChange(index, tabItem.onTabChange)} > {tabItem.name} @@ -33,15 +33,13 @@ const Tabs = ({ tabItems, tabIndicatorColor, ...props }: Props) => { {tabButtonList} - + - - {tabItems.map(({ name, content }) => ( - - {content} - - ))} - ); }; diff --git a/frontend/src/components/@shared/TextEditor/TextEditor.stories.tsx b/frontend/src/components/@shared/TextEditor/TextEditor.stories.tsx index 9e13eb6ca..e6fbb96f6 100644 --- a/frontend/src/components/@shared/TextEditor/TextEditor.stories.tsx +++ b/frontend/src/components/@shared/TextEditor/TextEditor.stories.tsx @@ -55,3 +55,11 @@ Transparent.args = { placeholder: "내용을 입력해주세요", autoGrow: true, }; + +export const WithIndicator = Template.bind({}); +WithIndicator.args = { + width: "100%", + height: "200px", + placeholder: "내용을 입력해주세요", + maxLength: 50, +}; diff --git a/frontend/src/components/@shared/TextEditor/TextEditor.style.ts b/frontend/src/components/@shared/TextEditor/TextEditor.style.ts index a3942d826..0ae5ac7b9 100644 --- a/frontend/src/components/@shared/TextEditor/TextEditor.style.ts +++ b/frontend/src/components/@shared/TextEditor/TextEditor.style.ts @@ -1,15 +1,37 @@ import styled from "styled-components"; -const TextArea = styled.textarea` - ${({ width, height, minHeight, fontSize }) => ` +export const Container = styled.div` + display: flex; + flex-direction: column; + align-items: flex-end; + + ${({ height, minHeight }) => ` + height: ${height === "" ? minHeight : `calc(${height} + 1.5rem)`}; + min-height: ${minHeight}; + `} +`; + +export const TextArea = styled.textarea` + ${({ width, height, fontSize }) => ` width: ${width ?? "100%"}; - min-height: ${minHeight ?? "fit-content"}; - height: ${height ?? "fit-content"}; + height: ${height === "" ? "100%" : height}; font-size: ${fontSize ?? "1rem"}; - `} +`} + height: 100%; border: none; outline: none; + background-color: transparent; `; -export default TextArea; +export const TextLengthIndicator = styled.div` + margin-top: 0.5rem; + color: ${({ theme }) => theme.color.lighterTextColor}; + font-size: 0.8rem; + float: right; + + display: flex; + justify-content: flex-end; + width: 3.5rem; + height: 1rem; +`; diff --git a/frontend/src/components/@shared/TextEditor/TextEditor.tsx b/frontend/src/components/@shared/TextEditor/TextEditor.tsx index f4f3ac417..d8c0fa37e 100644 --- a/frontend/src/components/@shared/TextEditor/TextEditor.tsx +++ b/frontend/src/components/@shared/TextEditor/TextEditor.tsx @@ -1,6 +1,6 @@ -import { ChangeEventHandler, KeyboardEventHandler, useState } from "react"; +import { useState } from "react"; -import TextArea from "./TextEditor.style"; +import { Container, TextArea, TextLengthIndicator } from "./TextEditor.style"; export interface Props { width?: string; @@ -8,16 +8,26 @@ export interface Props { fontSize?: string; autoGrow?: boolean; placeholder?: string; + maxLength?: number; value: string; - onChange: ChangeEventHandler; + onChange: React.ChangeEventHandler; } const TEXT_EDITOR_LINE_HEIGHT = 1.2; -const TextEditor = ({ width, height, fontSize = "1rem", autoGrow = false, placeholder, value, onChange }: Props) => { +const TextEditor = ({ + width, + height, + fontSize = "1rem", + autoGrow = false, + placeholder, + maxLength, + value, + onChange, +}: Props) => { const [currentHeight, setCurrentHeight] = useState(""); - const onKeyUp: KeyboardEventHandler = ({ key }) => { + const handleKeyUp: React.KeyboardEventHandler = ({ key }) => { if (!autoGrow) return; if (key === "Enter" || key === "Backspace" || key === "Delete") { @@ -29,17 +39,28 @@ const TextEditor = ({ width, height, fontSize = "1rem", autoGrow = false, placeh } }; + const handleChange: React.ChangeEventHandler = (event) => { + if (maxLength && event.target.value.length > maxLength) { + return; + } + + onChange(event); + }; + return ( -